diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55796928..961ec8ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,11 +36,23 @@ jobs: run: npm ci - name: Run Node tests run: npm test + - name: Setup Node.js for native Zstd coverage + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + - name: Verify native compressed-log compatibility + run: | + node -e "if (!require('./out/providers/codex/codexZstd').supportsCodexZstd()) throw new Error('Native Zstd is unavailable')" + node --test out/test/codexZstd.test.js out/test/codexManifest.test.js out/test/codexCompressed*.test.js - ui: - name: UI Test + ui_shards: + name: UI Shard ${{ matrix.shard }}/4 runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] container: image: mcr.microsoft.com/playwright:v1.61.1-noble options: --ipc=host @@ -57,7 +69,19 @@ jobs: - name: Install dependencies run: npm ci - name: Run full Webview gate - run: npm run test:ui + run: npm run test:ui -- --fully-parallel --workers=1 --shard=${{ matrix.shard }}/4 + + # Preserve the stable required check and fail closed on any failed, + # cancelled or skipped shard. Each shard has its own VM/server/storage. + ui: + name: UI Test + needs: [ui_shards] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require every UI shard to pass + run: test '${{ needs.ui_shards.result }}' = 'success' package: name: Release Package diff --git a/AGENTS.md b/AGENTS.md index fead8850..6c4ce193 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,34 @@ Simplified-Chinese review copy lives in [AGENTS.zh-CN.md](AGENTS.zh-CN.md). the same provider-aware render functions and the same CSS contract. - Prefer token-attribution accuracy over billing precision. Keep exact totals, labelled estimates, and point-in-time quota observations as separate concepts. -- Keep the extension local-first, lightweight, and read-mostly. Never modify - Claude or Codex conversation JSONL files. Treat credential handling as security-sensitive - and preserve the existing reviewed behavior. -- Add no new runtime dependencies in v2.3.0. +- Keep the extension local-first, lightweight, and read-mostly. Usage ingestion + and derived-data clearing never rewrite or delete provider conversation JSONL. + The existing, separately opt-in Session Actions feature may move exactly one + confirmed Claude log to the OS trash; this is not authority for an agent to + delete logs. Codex source logs remain read-only. Treat credential handling as + security-sensitive and preserve the existing reviewed behavior. +- Add no new runtime dependencies for the current candidate without explicit + maintainer approval. + +## Maintainer decisions and agent execution + +- The maintainer owns user value, feature scope, defaults, disclosure and release + promises. Agents own implementation, counterexamples, regression tests, + performance measurements and evidence integration within the authorized scope. + Routine implementation choices do not require a new product decision. +- For a feature, identify the user question, observable result, default/off/reset + behavior and privacy/resource constraints. Prefer the smallest coherent change; + code size, badge counts and green-test totals are not measures of user benefit. +- Read documentation by task: `ARCHITECTURE.md` for module/data-flow changes, + `LOCAL-DATA.md` and its versioned baseline for retained/exported fields and + clearing, and `CONTRIBUTING.md` for test/package/release workflows. Small copy + edits do not require replaying every historical plan. Task-local worktree paths, + permissions and acceptance status belong in the current handoff, not this policy. +- Distinguish source integration, synthetic tests, native behavior and user + acceptance. Missing data is not zero; passing fixtures do not establish that + upstream logs always satisfy the same assumptions. Reuse relevant independent + findings; seek a new bounded assessment for a substantive unresolved question, + not for every routine fix or delivery. ## Architecture boundaries @@ -28,8 +52,9 @@ Simplified-Chinese review copy lives in [AGENTS.zh-CN.md](AGENTS.zh-CN.md). and content-analysis reducers retained for exact compatibility. - `src/claudeIncrementalIndex.ts`: the production in-memory per-file Claude usage index, append-tail parser, exact global deduplication, and materialized - dashboard aggregates. Runtime refreshes must not fall back to a full-corpus - body read or full-record aggregation. + dashboard aggregates. Unchanged warm refreshes must not fall back to a + full-corpus body read or full-record aggregation; documented correctness-first + content-analysis replay is a separate path, not the default for each watcher event. - `src/providers/providerTypes.ts` and provider adapters: provider-neutral token, coverage, confidence, and limit contracts. Do not erase provider semantics. - `src/providers/codex/`: allowed-root discovery, schema guards, exact-request @@ -41,7 +66,9 @@ Simplified-Chinese review copy lives in [AGENTS.zh-CN.md](AGENTS.zh-CN.md). direct configuration reads. - `src/statusBar.ts`: status-bar token/cost/quota/context presentation. - `src/webview.ts`: the single provider-aware Claude/Codex dashboard HTML and - client behavior. Compare never sums cost or quota across providers. + client behavior, plus existing host-side receipt/notice ownership. New pure + calculations and eligibility rules belong in domain modules, not another + renderer or duplicated scan. Compare never sums cost or quota across providers. - `src/i18n.ts`: all user-facing copy for all eight UI locales. - `src/types.ts`: shared contracts. - Read `ARCHITECTURE.md` before changing module ownership or the data flow. If @@ -54,8 +81,9 @@ Simplified-Chinese review copy lives in [AGENTS.zh-CN.md](AGENTS.zh-CN.md). session IDs, credentials, or local usernames. New v2.3.0 performance diagnostics must not log them either; do not broaden older diagnostic output without an explicit privacy review. -- Codex discovery is allowlisted to `$CODEX_HOME/sessions/**/*.jsonl` and - `$CODEX_HOME/archived_sessions/**/*.jsonl` (default `~/.codex`). Never read +- Codex usage discovery is allowlisted to `.jsonl` and `.jsonl.zst` regular files + recursively under `$CODEX_HOME/sessions` and `$CODEX_HOME/archived_sessions` + (default `~/.codex`); reject symlinks and non-regular files. Never read `auth.json`, SQLite databases, config secrets, keychains, browser state, or unknown files for Codex usage. - Additionally, `$CODEX_HOME/session_index.jsonl` may be streamed solely to @@ -102,11 +130,12 @@ Simplified-Chinese review copy lives in [AGENTS.zh-CN.md](AGENTS.zh-CN.md). returns to the low-power incremental path. - Claude runtime refreshes use the in-memory per-file index in `claudeIncrementalIndex.ts`. An unchanged refresh reads zero JSONL bodies; - append/truncate/replace/move/delete work is limited to affected files and - affected aggregate groups while preserving the established response-identity - and content-analysis semantics. A new Extension Host may still perform one - cold in-memory build; this is distinct from rereading the corpus after every - watcher event. + an ordinary verified single-file append reads its tail and updates affected + groups. With content analysis enabled, unsafe truncation/replacement/deletion, + changed source order or UUID first-owner changes can require an ordered wider + replay to preserve exact semantics. Report that cost separately; never turn it + into unconditional full-corpus work on each watcher event. A new Extension Host + may still perform one cold in-memory build. - Codex optimization advice uses structural numeric signals only. Never inspect or persist prompt/response/command bodies or tool arguments. @@ -128,6 +157,15 @@ npx @vscode/vsce package - User-facing changes require a `CHANGELOG.md` entry and matching documentation. - UI changes also require an F5 Extension Development Host smoke test; release candidates require an installed-VSIX smoke test on macOS and Linux when available. +- Before F5, installation or a second Code instance uses fixtures, identify and + isolate every writable profile, extension/shared/agent store, global/workspace + storage, provider home, derived cache and lock path. A window or worktree alone + is not isolation. Follow `tests/native/README.md`, verify actual storage paths + and compare selected normal-state metadata/hashes before and after. Unexplained + differences remain unknown, not proof of non-disruption. Never replace the + daily installation or run a plain default-profile install as a test shortcut. +- Documentation-only edits verify affected facts, links and document/policy + tests. They do not establish new runtime, native, performance or release passes. ### UI rendering-environment fidelity @@ -138,13 +176,16 @@ that invariant. Visual snapshots produced before this is true are not acceptance evidence. Variable values must come from the registered values of VS Code's built-in themes; do not mix in another color system. +This is a necessary rendering-fidelity guard, not native acceptance for resource +URIs, fonts, screen readers, actual zoom or installed-package behavior. ### Interaction-state acceptance -Webview refresh is a full-page replacement. Every new user-interactive state -(expand/collapse, sorting, filtering, or selection) must use the existing -persistence mechanism and include an operation → reload → state-remains test. -A static assertion of the first render is not acceptance evidence. +Webview updates may use a full-page replacement or a provider-panel live patch. +Every new user-interactive state (expand/collapse, sorting, filtering, or +selection) must use the existing persistence mechanism and include an operation +→ update → reload → state-remains test, preserving relevant focus and scroll +anchors. A static assertion of the first render is not acceptance evidence. ## Localization and review documents @@ -167,7 +208,27 @@ A static assertion of the first render is not acceptance evidence. - Tracked files default to mode `100644` and LF endings. Use `100755` only for a real directly executed program, register it in the repository policy test, and verify modes with `git ls-files --stage` before committing. -- Keep commits focused. The repository squash-merges reviewed PRs to `main`. +- This project defaults to **Squash and merge**: each reviewed PR contributes + one commit to `main`, including multi-stage features, performance work and + version development. A normal merge or rebase merge is an exception only when + explicitly specified by the maintainer for that PR. +- Local progress commits are separate from the PR merge strategy. Preserve + meaningful implementation, test and fix commits until the PR is merged so + stages remain available for verification and rollback; do not pre-squash them + merely to prepare a PR. Preserving their original SHAs in `main` is not the default. +- Commit at meaningful, verifiable milestones, not per file, agent turn or + contribution-graph target. Keep a logical change with its regression tests and + necessary documentation together. Fold trivial formatting, wording and immediate + fixups into related unpublished work; retain separate commits for independently + useful fixes or evidence-backed milestones until merge. Do not rewrite published + history or shared branches to apply this default. Leave previously squashed + history unchanged; do not reconstruct or re-merge old commits merely to restore + contribution-graph counts. +- Preserve the original PR and truthful authorship when squashing: verify the + squash commit author and all applicable `Co-authored-by` trailers. Keep real + contributor credit on each Release change entry; an aggregate contributor list + is not a substitute. Squashing must not relabel another author's work as the + maintainer's or an assistant's. - Never copy changes from a contributor pull request into a maintainer PR and close the contributor PR as superseded. To preserve attribution, merge the contributor's original pull request, or—with explicit authorization—update that @@ -215,4 +276,4 @@ Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.c privileged maintainer-only mention workflow. ## Codex 工具適配 -瀏覽器操作使用當前 Codex browser/computer-use 工具;原文的 Claude MCP 工具名稱只描述操作意圖,不能直接呼叫。提問依目前模式使用可用工具;Git 操作仍需當次明確授權。 +瀏覽器操作使用當前 Codex browser/computer-use 工具;原文的 Claude MCP 工具名稱只描述操作意圖,不能直接呼叫。提問依目前模式使用可用工具;已授權範圍內的本地小提交可保存進度,推送、PR、合併、tag 與發布仍須維護者明確授權。 diff --git a/AGENTS.zh-CN.md b/AGENTS.zh-CN.md index 38ae0bb0..cb4f20ab 100644 --- a/AGENTS.zh-CN.md +++ b/AGENTS.zh-CN.md @@ -14,9 +14,25 @@ Claude 与 Codex 仪表盘必须复用同一组 provider-aware render function 和同一份 CSS contract。 - 优先保证 token 归因准确,不追求账单级精度。精确总量、明确标注的估算、 某一时点的配额观测必须保持为不同概念。 -- 扩展保持 local-first、轻量和 read-mostly。绝不修改 Claude 或 Codex 对话 JSONL; +- 扩展保持 local-first、轻量和 read-mostly。用量读取和派生数据清除绝不改写或删除 + 供应商对话 JSONL。既有“会话操作”只有在另行启用并精确确认后,才能将单个 Claude + 日志移入系统回收站;这不授权 agent 删除日志。Codex 源日志始终只读。 credential 处理属于安全敏感范围,只保留已经审阅的既有行为。 -- v2.3.0 不新增 runtime dependency。 +- 当前候选未经维护者明确批准,不新增 runtime dependency。 + +## 维护者决策与智能体执行 + +- 维护者裁决用户价值、功能范围、默认行为、披露和发布承诺;智能体在已授权范围内 + 负责实现、反例、回归测试、性能测量和证据整合。普通实现细节不需要反复升级为产品决策。 +- 新功能先明确用户问题、可观察结果、默认/关闭/重置行为和隐私/资源约束。 + 优先完成最小而完整的改动;代码量、徽章数量和绿测总数不等于用户收益。 +- 按任务读取文档:模块/数据流变化读 `ARCHITECTURE.md`;保留/导出字段和清除 + 读 `LOCAL-DATA.md` 及其版本化基线;测试/打包/发布读 `CONTRIBUTING.md`。 + 小文案修改无需重读全部历史方案。当前工作树路径、授权和验收状态放在当前交接, + 不写入这份长期政策。 +- 区分源码接入、合成测试、原生行为和用户接受。缺失不是零;夹具通过不证明上游 + 日志永远满足相同假设。复用相关独立意见,只为实质未解问题做有范围的新判断, + 不在每次普通修补或交付时重审。 ## 架构边界 @@ -24,8 +40,9 @@ - `src/dataLoader.ts`:Claude JSONL 解析 primitive、校验、归因与内容分析 reducer, 用于保持既有精确语义兼容。 - `src/claudeIncrementalIndex.ts`:生产环境使用的 Claude 内存 per-file 用量索引、 - append-tail 解析、精确全局去重与已物化的 dashboard aggregate。运行中刷新不得 - 回退为全语料 body 重读或全 records 重新聚合。 + append-tail 解析、精确全局去重与已物化的 dashboard aggregate。未变化暖刷新不得 + 回退为全语料 body 重读或全 records 重新聚合;已有正确性优先的内容分析重放是 + 独立路径,不得成为每个 watcher event 的默认处理。 - `src/providers/providerTypes.ts` 与 provider adapter:provider-neutral token、 coverage、confidence 与 limit contract;不得抹平 provider 语义。 - `src/providers/codex/`:允许目录发现、schema guard、精确单次请求解析及 @@ -35,8 +52,9 @@ provider contract,不负责 HTML、client code 或样式。 - `src/settings.ts`:`SETTINGS` catalog 与 `SettingsStore`;不要散落直接配置读取。 - `src/statusBar.ts`:状态栏 token、成本、配额和 context 展示。 -- `src/webview.ts`:Claude/Codex 唯一一套 provider-aware dashboard HTML 与客户端行为; - Compare 绝不跨 provider 求和成本或配额。 +- `src/webview.ts`:Claude/Codex 唯一一套 provider-aware dashboard HTML 与客户端行为, + 以及既有宿主收据/提示所有权。新增纯计算和资格规则进入领域模块,不另建 renderer + 或重复扫描器;Compare 绝不跨 provider 求和成本或配额。 - `src/i18n.ts`:八个 UI locale 的全部用户可见文案。 - `src/types.ts`:共享 contract。 - 改变模块职责或数据流前先读 `ARCHITECTURE.md`;若该变化需要维护者审阅, @@ -47,8 +65,9 @@ - 绝不上传 prompt、response、原始 JSONL 行、绝对路径、原始 session ID、 credential 或本地用户名。v2.3.0 新增的诊断也不得记录这些内容;未经过 明确隐私审阅,不扩大历史诊断输出范围。 -- Codex 只发现 `$CODEX_HOME/sessions/**/*.jsonl` 与 - `$CODEX_HOME/archived_sessions/**/*.jsonl`(默认 `~/.codex`)。不得为用量 +- Codex 用量发现仅限 `$CODEX_HOME/sessions` 与 `$CODEX_HOME/archived_sessions` + 下递归的 `.jsonl` 和 `.jsonl.zst` 普通文件(默认 `~/.codex`);拒绝符号链接与 + 非普通文件。不得为用量 读取 `auth.json`、SQLite、config secret、keychain、浏览器状态或未知文件。 - 另允许流式读取 `$CODEX_HOME/session_index.jsonl`,但仅用于恢复真实线程标题所需的 `id` → `thread_name` 映射。标题中的绝对路径必须遮蔽,标题仅驻留内存且绝不持久化; @@ -83,10 +102,10 @@ 与 single-flight。一次性的未完成回填可以使用自适应、受限的本地 worker pool 与 更粗粒度的 durable checkpoint;收敛后必须回到低功耗增量路径。 - Claude 运行中刷新使用 `claudeIncrementalIndex.ts` 的内存 per-file 索引。 - Unchanged refresh 的 JSONL body read 必须为 0;append/truncate/replace/move/delete - 只处理受影响文件和 aggregate group,同时保持既有 response-identity 去重与 - content-analysis 语义。新的 Extension Host 仍可执行一次冷内存建索引;这与每次 - watcher event 都重读全语料是两回事。 + Unchanged refresh 的 JSONL body read 必须为 0;普通已验证单文件 append 只读 tail + 并更新受影响组。内容分析开启时,不安全截断/替换/删除、来源顺序或 UUID first-owner + 变化可能为保持精确语义触发更广的有序重放;该成本单独报告,不得变成每个 watcher + event 的无条件全语料处理。新的 Extension Host 仍可执行一次冷内存建索引。 - Codex 优化建议只使用结构化数字信号,不读取或持久化 prompt/response/command body 或 tool arguments。 @@ -108,6 +127,12 @@ npx @vscode/vsce package - 用户可见变更需要更新 `CHANGELOG.md` 和对应文档。 - UI 变更还要用 F5 Extension Development Host smoke test;候选版本在条件允许时 需要在 macOS 和 Linux 安装 VSIX 做 smoke test。 +- F5、安装或第二个 Code 实例使用夹具之前,识别并隔离全部可写 profile、extensions/ + shared/agent store、global/workspaceStorage、provider home、派生缓存和锁路径。 + 单独窗口或工作树不等于隔离。按 `tests/native/README.md` 核对实际存储路径,前后 + 比较选定日常状态元数据/hash;无法归因的变化保留未知,不声称无干扰。 + 不替换日常安装,不把默认 profile 的直接安装当作测试捷径。 +- 纯文档修订核对相关事实、链接和文档/政策测试,不产生新的运行期、原生、性能或发布通过结论。 ### UI 渲染环境保真度 @@ -115,11 +140,13 @@ npx @vscode/vsce package 被引用的 `--vscode-*` 变量必须在测试 harness 中有真实取值,且有守卫测试强制 这一点。在此之前产生的视觉快照一律不作为验收依据。 变量的取值必须来自 VS Code 内置主题的注册值,不得混用其他配色体系。 +这是必要的渲染保真守卫,不证明原生资源 URI、字体、读屏、真实缩放或已安装包行为。 ### 交互状态验收 -webview 刷新为整页替换。任何新增的用户可交互状态(展开/折叠、排序、筛选、 -选中),必须接入现有持久化机制,并附带一条“操作 → reload → 状态仍在”的测试。 +webview 更新可能整页替换,也可能使用供应商面板 live patch。任何新增的用户可交互 +状态(展开/折叠、排序、筛选、选中)必须接入现有持久化机制,并附带 +“操作 → 更新 → reload → 状态仍在”的测试,保持相关焦点与滚动锚点。 只验证首次渲染的静态断言不构成验收。 ## 本地化与审阅文档 @@ -139,7 +166,20 @@ webview 刷新为整页替换。任何新增的用户可交互状态(展开/ - 脏工作区里的无关改动必须保留;实现应使用干净的 `codex/` 分支或隔离 worktree。 - tracked 文件默认模式为 `100644` 且使用 LF。只有确实由操作系统直接执行的程序 才能用 `100755`,并要登记到仓库 policy 测试;提交前用 `git ls-files --stage` 核对。 -- commit 保持聚焦;仓库通过已审阅 PR squash-merge 到 `main`。 +- 本项目默认使用 **Squash and merge**:每个已审阅 PR 向 `main` 合入一个提交, + 多阶段功能、性能优化和版本开发也适用。只有维护者针对该 PR 明确指定时, + 才例外使用普通 merge 或 rebase merge。 +- 本地进度提交与 PR 合并策略分别管理。有意义的实现、测试和修复提交保留至 PR 合并, + 便于分阶段验证和回滚;不要仅为准备 PR 提前 squash。 + 将这些提交的原始 SHA 保留在 `main` 不再是默认要求。 +- 按有意义、可验证的阶段提交,不按文件、对话轮次或热力图数量拆分。一个逻辑修改应与 + 对应回归测试、必要文档一起提交。琐碎格式、措辞及紧随其后的修补并入相关未发布工作; + 独立有用的修复或有证据的阶段成果单独保留至合并。不得为应用此默认规则而重写 + 已发布历史或共享分支。已经 squash 的旧历史保持不变, + 不为恢复贡献热力图数量重建或重新合入旧提交。 +- Squash 时保留原始 PR 和真实作者归属:核对 squash commit 的作者及所有适用的 + `Co-authored-by` trailer。Release 每条变更保留实际贡献者 credit,汇总贡献者名单 + 不能替代逐条归因。不得因 squash 将他人工作改记为维护者或助手的贡献。 - 严禁把贡献者 PR 的改动吸收到维护者 PR 后,再以 superseded/已吸收为由直接关闭 贡献者 PR。必须优先合并贡献者的原始 PR;如确需修改,应先获得明确授权,在该原始 PR 分支上修改后再合并。额外测试、重构或加固应作为建立在已合并贡献之上的后续 PR。 @@ -182,5 +222,5 @@ Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.c ## Codex 工具适配 浏览器操作使用当前可用的 Codex browser/computer-use 工具;原文中的 Claude MCP -工具名称只描述操作意图,不能直接调用。提问按当前模式使用可用工具;Git 操作仍须 -获得当次明确授权。 +工具名称只描述操作意图,不能直接调用。提问按当前模式使用可用工具;已授权范围内的 +本地小提交可保存进度,推送、PR、合并、tag 和发布仍须维护者明确授权。 diff --git a/ARCHITECTURE-zh-CN.md b/ARCHITECTURE-zh-CN.md index f34e6751..8f89f122 100644 --- a/ARCHITECTURE-zh-CN.md +++ b/ARCHITECTURE-zh-CN.md @@ -3,8 +3,10 @@ > 本文简要说明扩展的 provider 边界、数据流与用量语义。模块职责或 > provider 行为变化时必须同步更新。英文版见 > [`ARCHITECTURE.md`](ARCHITECTURE.md)。 -> 持久数据清单、保留、迁移、清除和网络边界的规范来源是双语 -> [`v2.3.1 本地数据契约`](docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md)。 +> 双语 [`v2.3.1 本地数据契约`](docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md) +> 是字段级历史基线;[`LOCAL-DATA.zh-CN.md`](LOCAL-DATA.zh-CN.md) 是当前 2.5.0 +> 数据清单与后续字段、保留、迁移、清除、导出的版本化补充。其明确的后续章节只覆盖 +> 对应事项;未变化的基线隐私规则继续适用。 ## 产品边界 @@ -15,12 +17,21 @@ local-first 的建议有效性闭环、唯一的显式 BYOK 请求边界、可 Codex 有界的本地每周重置观测历史,以及滚动 30 天的日期→小时投影。 v2.4 第一阶段把既有分享界面收敛为一个预览优先的工作台。呈现方式选择器可切换 综合活动热力图、旧版 Claude 分享卡与仅 Claude 的 token 热力图;全宽导出预览始终位于控制项之前。 +2.5.0 版本整合 Codex 压缩日志兼容与默认开启的本地分级成就(`enableAchievements`)、 +一次性升级关闭选项及证据/性能保护。GitHub 联动成就、个人回顾与可视化重设计 +不在 2.5.0 范围。 +本版还提供供应商/指标热力图分享、最多六项手动展示柜及默认关闭的周额度节奏。 +本文描述实现契约,不是发布或验收记录。原生/F5、已安装包及性能检查需要各自的 +证据,浏览器检查不能证明这些结果。 +源码守卫只核对源代码契约,不证明界面状态转换已实际执行、原生 Webview 行为或已安装包验收。 +Codex effort 统计及仅累计范围的金额新活动收据已完成接入。 - Claude:精确的本地 token bucket、模型成本估算和 Anthropic OAuth 5 小时/每周配额。 - Codex:本地 processed/fresh/cache/output/reasoning 指标、模型与 effort 拆分、 thread 结构、索引 coverage、quality flag 与结构化优化建议。已知模型还会显示明确限定的 API 等效成本估算;它绝不是账单或订阅扣费,未知模型保持未定价。 -- Compare:只并列可比指标,绝不把不同 provider 的成本、配额或 token 求和为误导性总量。 +- 分享与对比(内部 provider 键仍为 `compare`):分享与可比指标并列展示的导航/页标题, + 绝不把不同 provider 的成本、配额或 token 求和为误导性总量。 完整账单/发票对账、驱动任一 coding agent、后台 telemetry 与跨设备聚合同步不在范围内。 既有 Claude 热力图发布仍是用户明确触发、仅公开仓库且需精确确认目标的 GitHub 动作; @@ -32,8 +43,25 @@ v2.4 第一阶段把既有分享界面收敛为一个预览优先的工作台。 | 模块 | 职责 | |---|---| | `extension.ts` | 激活、命令、设置、provider 生命周期、刷新编排、watcher、状态栏/webview 接线和匿名诊断。 | +| `claudeApiClient.ts` | 既有 Claude OAuth/额度请求及最近成功请求所用凭证的运行期单向身份信号;周节奏只读取该信号,不新增请求或凭证查询。 | | `dataLoader.ts` | 为精确兼容保留的 Claude 解析、校验、归因和内容分析 primitive。 | | `claudeIncrementalIndex.ts` | 生产环境 Claude 内存 per-file 索引:append-tail 解析、精确跨文件 response 去重、受影响 group 聚合、内容分析 contribution 和已物化 dashboard row。 | +| `achievements.ts`、`achievementSnapshot.ts`、`achievementCalendar.ts`、`achievementHours.ts` | 带版本的规则与数值 Token/日/周/小时证据;`codexAchievementCalendarProjection` 先解析全清单祖先关系,再选择有见证的日期,区分正向子集与全清单完整性。 | +| `achievementActivity.ts`、`achievementIcons.ts`、`achievementShare.ts` | 白名单内的主动本地行为枚举、随插件打包的徽章查找与明确触发的自包含 SVG 导出。 | +| `achievementGallery.ts`、`achievementOverview.ts`、`achievementRecordStore.ts` | 纯主题归属/记录投影、稳定目标、自动/手动展示选择及 ≤64 KiB 权威 v2 封套;最多六项有序手动固定,共用 v1 租约/迁移屏障。 | +| `achievementUiIcons.ts` | 纯函数渲染已知的内联 SVG 控件图标;无依赖、网络访问或额外资源文件。既有本地化标签仍由共享 Webview 渲染器提供。 | +| `achievementMoney.ts`、`achievementMoneyScopes.ts`、`achievementMoneyCatalog.ts` | 六个供应商独立主题的固定美元参考价证据;Codex 周金额子集要求绑定全清单的日期证明及完整归日的计费/模型向量。定价覆盖独立于日期覆盖及仪表盘浮动估算。 | +| `achievementMoneyOrigin.ts` | 仅运行期固定参考价增量校验、固定字段复制/经济上界及累计跨级必要/充分判断;独立于 Token 来源,不持久化为索引证明。 | +| `achievementModels.ts` | 协作/彩虹及 Claude 家族的非金额模型标签识别,独立于固定计价别名;识别不证明远端型号身份或价格。 | +| `achievementReferenceBooks.ts` | 三个明确已发布的 v1 参考价标签,用于读取已存金额记录,独立于当前价表选择与新增写入授权。 | +| `achievementTitles.ts` | 公开共用标题助手:等级单独显示时,仅移除由目录中点分隔的匹配本地化等级或其旧名后缀;ID 和固定项不变。 | +| `achievementDetailPresentation.ts`、`achievementHistoryDates.ts`、`achievementCalibration.ts` | 精确共同条件展示、仅运行期逐级日志日期归约及受 guard 保护的本地增量校准;不新增持久存储或来源解析器。 | +| `achievementEffortStatistics.ts`、`achievementCollaboration.ts` | 当前完整索引的 Codex 根任务 × effort 统计及既有联合等级条件;可选 `CodexAchievementSignals.effortStatistics` 按证明修订缓存,仅在已有 effort 广度详情显示。 | +| `apiHeatmapDaily.ts`、`pricing.ts#getCurrentEstimatePricing` | 最多 366 日只读投影,按次缓存选中型号/组件的内存单价准入;保留当前成本标量,排除通用未知型号回退覆盖,不联网、不读全量记录。 | +| `apiHeatmap.ts`、`apiHeatmapCopy.ts` | 白名单供应商/指标配置及规范本地 SVG/Markdown 产物;默认 Token、主动选择金额、供应商金额分块及八语产物文案。 | +| `quotaPace.ts`、`quotaPaceAdapters.ts`、`quotaPaceSource.ts` | 纯周期均速计算、最多 16 个窗口的数值适配及使用原始成功额度观测的运行期账号/窗口连续性所有者;无 I/O、定时器或持久历史。 | +| `providers/codex/codexHourProof.ts` | 与规范文件、已验证偏移、当前身份与谱系绑定的有界 UTC 输出小时证明;可选旧索引证明迁移不清零主用量。 | +| `providers/codex/codexAchievementOrigin.ts` | 仅运行期主尾部 Token/固定参考金额下界;已知原生逐事件计价、受影响聚合差额准入、稳定规范谱系及固定复制 worker DTO;无持久索引字段或额外正文读取。 | | `adviceEffectiveness/contract.ts`、`adapters.ts`、`payload.ts` | 严格的观察/证据/建议/行动/结果契约,隐私重建型 provider adapter,以及 canonical 聚合/个性化 payload。 | | `adviceEffectiveness/preparedRequest.ts` / `remoteAdvice.ts` | host 持有的唯一完整 HTTP Prepared 对象、精确 preview/send bytes、BYOK-only 授权、取消与严格结构化解析。 | | `adviceEffectiveness/comparisonPairing.ts`、`comparisonResult.ts`、`versionedPersistence.ts` | 净化的可比任务配对、冻结的 measurement-version envelope 与有界的本地反馈/比较存储。 | @@ -44,23 +72,31 @@ v2.4 第一阶段把既有分享界面收敛为一个预览优先的工作台。 | `providers/codex/codexSchema.ts` | 最小安全 JSON guard,不展开或返回 message/command/tool body。 | | `providers/codex/codexParser.ts` | Codex 精确单次请求解析及 cumulative high-water 回退、伪名 lineage metadata、结构计数、quality flag 和 last-observed limit。 | | `providers/codex/codexManifest.ts` | Codex 允许目录发现、HMAC file key、fingerprint 和 manifest diff。 | +| `providers/codex/codexZstd.ts` / `codexJsonlScanner.ts` | 有界 header 检查与原生单 frame Zstd 解码;共享 scanner 保留 decoded 字节坐标,将可取消的前缀重放计入工作量。 | | `providers/codex/codexIndex.ts` | schema-3 的 per-file 数字聚合与重放证据持久化、有界 cold/tail parse、独立 aggregate/period/滚动 30 天小时 coverage 和原子存取。 | +| `providers/codex/codexLineage.ts` | 打包谱系 fingerprint contract 与仅运行期、迭代/记忆化的主统计深度排序;不增长递归调用栈,不新增持久字段。 | | `providers/codex/codexQuotaHistory.ts` | 小型、账户中性的每周重置观测缓存;压缩重复本地样本,不保留凭据、账户名或原始标签。 | | `providers/codex/codexIndexWorker.ts` / `codexIndexClient.ts` | 后台协调器、recent-first progress、cancel、resume、checkpoint 持久化和 single-flight client。 | | `providers/codex/codexFilePassPool.ts` / `codexFilePassWorker.ts` | 未完成回填期间,用于独立 main、lineage、period、current-day 和 identity per-file pass 的自适应受限本地 pool。 | -| `providers/codex/codexProvider.ts` | 面向 extension 的 Codex snapshot facade 与 partial/unavailable/error outcome。 | +| `providers/codex/codexProvider.ts` | 面向 extension 的 Codex snapshot facade 与 partial/unavailable/error outcome;从当前 contribution 独立的大小/偏移/前缀/时区构造仅运行期日期见证,不采信传入聚合自带的见证。 | | `providers/codex/codexUsage.ts` | Codex 自然日 Today/小时、7 天、30 天、月度、task 与项目 view model 聚合,以及精确模型 API 等效成本。 | | `providers/codex/codexInsights.ts` | 确定性的结构用量建议,不读 prompt/body。 | | `codexView.ts` / `codexViewComponents.ts` | Codex 本地化文案与默认 provider contract;不负责 HTML renderer、client script 或 CSS。 | | `settings.ts` | 权威 `SETTINGS` catalog 和 `SettingsStore`;普通值进入 configuration/globalState,BYOK 凭证只进入 SecretStorage,且绝不进入 Webview snapshot。不得散落直接读取。 | -| `statusBar.ts` / `codexStatus.ts` | Provider-specific 状态展示和通用 Claude 配额格式化。 | -| `webview.ts` | Claude/Codex 唯一一套 provider-aware dashboard shell、共享 render function、共享 client 行为、provider tab、Compare 展示与统一分享工作台。 | +| `statusBar.ts` / `codexStatus.ts` | Provider-specific 状态展示和通用 Claude 配额格式化;周节奏提示复用共享 I18n 文案,不改变简短文字及自定义格式。 | +| `webview.ts` | Claude/Codex 唯一一套 provider-aware dashboard shell、共享 render function/client 行为、provider tab、Compare 与分享;当前也拥有宿主成就观测/收据/提示状态,不是纯展示模块。 | +| `dashboardRefreshClient.ts` | 共享实时补丁的滚动锚点/焦点恢复,拒绝禁用控件并回退到稳定摘要;不执行供应商 I/O。 | | `i18n.ts` | 八个 UI locale 的全部用户可见文案。 | | `types.ts` | 共享 extension 和 Claude contract。 | `quotaFormat.ts`、`dateKeys.ts`、`shareCard.ts`、`heatmap.ts`、`conversationLog.ts`、 `miniMarkdown.ts` 等既有纯模块保持当前职责与测试。 +新增计算、分组与资格规则进入既有纯领域模块;`extension.ts` 协调宿主生命周期/I/O, +`webview.ts` 整合已接收结果、既有收据与展示。不为小功能另建数据生产者、renderer 或 +全语料扫描器。这些边界指导新增代码,不代表混合的旧方法已经拆出,也不要求顺带 +进行无关的大规模重构。 + ## Provider 数据流 ```text @@ -84,8 +120,23 @@ Claude JSONL Claude aggregate + Codex scope ──> 同一套 `webview.ts` dashboard render stack Claude aggregate + Codex scope ──> 并列 Compare(不跨 provider 求和) +已验证供应商数值证据 + 白名单内主动本地行为 + ──> 带版本成就规则 ──> 有界本地授予/峰值账本 + ──> 图标优先图鉴 ──> 主动选择徽章预览 ──> 本地 SVG 导出 +核验 Claude/Codex 新追加窗口 + 同固定参考价下的已定价增量 + ──> 累计跨级必要/充分判断 ──> 独立 Token/金额收据 + ──> 守卫下原子 recordUsage ──> 首次记录日/未读 + 一个既有提示批次 +完整当前规范 Codex 索引 + 证明修订 + ──> 缓存可选六行 effortStatistics ──> 仅已有 effort 广度展开区 + 已物化 Claude + Codex 每日聚合 - ──> 统一分享选择器 ──> 综合活动预览 ──> 显式本地 SVG/Markdown 导出 + ──> 最多 366 日只读投影 ──> Claude/Codex/两者 × Token/API 等价金额 + ──> 不可变已接受 SVG + Markdown + 绑定文档的不透明句柄 + ──> 核对后明确本地导出(不跨供应商相加金额) +既有成功原始额度 + 原始 observedAt + 已核验本机账号 HMAC + ──> 运行期连续性所有者 ──> 有界每周数值 DTO ──> 周期平均节奏 + ──> 既有刷新回调 ──> 绑定文档/供应商的页面补丁 + 状态栏提示 +既有 Codex 本地额度 ──> 账号可信度未知 ──> 预测不可用 已物化 Claude 聚合 ──> 统一分享选择器 ──> 旧版分享卡或 Claude token 热力图预览 ──> 显式本地导出,或仅公开仓库且精确确认目标的 Claude 热力图 GitHub 发布 @@ -151,14 +202,15 @@ Codex 只在同一解析后的数据目录内保留失败前统计;切换目 已完成的稳定轮询不制造回填进度,也不因切换 loading 状态重复渲染隐藏面板。 分享工作台只渲染一次:两个 provider 都有数据时位于「对比」,否则作为 Claude「所有」的 -回退入口。`enableShareCard` 是唯一可见的分享开关。公共命令 ID `exportShareCard`、 +回退入口。仅有 Codex 时,在成就开启的前提下「所有」页也提供工作台;热力图本身支持 +单供应商或两者。`enableShareCard` 是唯一可见的分享开关。公共命令 ID `exportShareCard`、 `exportHeatmap` 与 `publishHeatmapToGitHub` 至少保留一个版本并打开对应呈现方式,不绕过预览。 退役的 `showHeatmap` 仍保留在 catalog 中并可被清除,但隐藏且不再创建重复面板。所选呈现方式 只是本地 UI 偏好,不改变 provider 统计口径。显式兼容命令会生成 provider 生命周期内单调递增的 revision,并把它作为独立的 live intent;Webview 应用后 ACK 精确 revision,host 随即删除 live intent, 但不回退 revision 历史。因此,已确认的命令不会在普通重绘、分享重置或同一 provider 的 dispose/reopen 后重放。「重置分享偏好」复用既有的确认式本地数据动作:host 发送带 request-id 的 -client action,Webview 验证八个 allowlist localStorage key 均已删除;只有匹配 ACK 为 true 才算成功, +client action,Webview 验证十二个 allowlist localStorage key 均已删除;只有匹配 ACK 为 true 才算成功, 否则 host 报错并保留恢复 tombstone。 在窄 Webview 中,1200×680 Claude 分享卡在可键盘聚焦的局部横向滚动区内保留原始宽度, @@ -218,6 +270,449 @@ v2.3.0 不读 Codex credential,也不发网络请求刷新它。 ## 隐私与持久化 +### 本地成就(2.5.0) + +历史日期采用冻结的 138 项规则注册清单,逐项匹配 ID、计数器、门槛、分组、规则版本、 +系列、基础 ID 和金额范围。新增规则或修改口径不能默默继承日期;模块级签名覆盖实际及 +已注册元数据。会话重建仅消费规范索引完整的会话准入,不把任意正输出记录视为合格会话。 +会话缓存以记录数组为弱键,来源或证据退役时显式释放;日志变化后需要重新校准。 +校准期间暂存独立核验的真实增量收据,UI 取消或另一来源失败不丢失其首次记录日期。 +只有历史补认保持静默;完成、部分已保存、取消和失败分别报告。 + +图鉴全部展开/收起仅修改既有 `openDetails` 本地 UI 持久状态中的卡片项,不选择来源、 +授奖或确认未读。等级显示为本地化铜/银/金(繁体銅/銀/金),旧 ID、固定项、参考价表 +和门槛不变。各级条件与单位仅在完全相同时集中显示。`webview.ts` 统一渲染金额参考说明, +列出各已启用供应商的当前价表/日期及一次非账单提示。不同历史 `referencePricebook` +按价表归组并保留准确的奖项名称/等级关联;当前价表选择不改写这些关联。 +卡片移除重复价表/依据说明,保留范围和计价覆盖率。 +满级仍显示未截断的当前统计,证据缺失或过期时保持不可用,不以目标值或已存徽章替代。 + +展开等级行按各等级主题 token 渲染名称(高对比模式回退到前景色),门槛在同一标题行 +加粗、右对齐。仅证据就绪时,最高已获等级的下一等级显示「进行中」;后续等级、满级及 +证据待定均不显示。保留日志已核验日期仅显示 ISO 日期,来源含义在「数据依据」解释一次, +title/ARIA 保留来源说明。两种日期同时存在时仅显示已核验日期,首次记录详情保留在 +title/ARIA;仅有首次记录日期时保留明确标签,未知仍为未知。此项仅调整渲染,授奖规则、 +日期计算、目录及持久记录均不变。 + +`root-portfolio` 本地化名称为「独立主任务」(Independent root tasks),ID、门槛及 +已存授奖不变。`achievements.ts` 仅在根归属可核验且已识别模型桶的输入**或**输出为正时 +建立 `modelsByRoot`;`verifiedRootCount` 将主任务树及其合格子任务合计一次, +不声明任务已完成,也不读取任务内容。 + +「分享与对比」改名不改变 `compare` provider 键或导航状态归属。徽章分享仅增加 +选择项的基本等宽对齐及等级单列。本地图标修复沿用主仪表盘 CSP 的 +`webview.cspSource`,允许打包资源的 `asWebviewUri`,不新增远程图片权限。 +`achievementShare.ts` 嵌入固定公开锚点 `https://github.com/ClaudeCodeUsage/ClaudeCodeUsage`, +不接受输入派生目标,不获取资源、不新增遥测或个人元数据。标题、页脚及等级/门槛 +在既有网格内通过换行和间距避让;输出仍确定、自包含,使用内嵌图片,预览/导出不可变约定不变。 +分享页整体视觉统一/美化及持久用量归档推迟至下一版本,2.5.0 未实现这两项。 + +逐级历史日期是 `schemaVersion: 1` 的派生注释,绑定 `ruleVersion`、数据时区及实际目录 +形状;缓存复用还依赖当前来源证据、启用供应商及计价上下文。来源、OFF、时区或价格变化 +重建或撤回注释。该缓存仅存在于运行期,不是新存储;规范 v2 的原有 `recordedDay`、 +未知日期和未读状态保持不变。显示的日志日期仅表示保留本地证据(含已核验子集)可确认的 +最早日期,不能证明账户真实完整历史;已存首次记录日仍是独立事实。 + +自动日期计算复用已物化每日数据,支持通用用量、输出、缓存、模型/家族、活跃及周规则; +Codex 仅接纳严格核验且用量完整带日期的文件证据。日期语义沿用既有 evaluator:活跃日计数 +及连续活跃包含当天正活动,日期为当天;合格家族/日条件仍须闭日,日期为该日加一天。 +四周窗口按首个正活动日排除首个不完整周,不受更早的全零行影响。这些是既有条件的日期 +投影,不是规则调整。仅显式手动校准会归约已持有、未受界面条数上限截断的 Claude 记录 +内存,计算输出会话、Fable 会话及同会话 Fable/其他家族日期。日期归约仅读已物化数值或 +已持有记录的 usage 元数据,不额外扫描日志正文、不检查对话内容或导出元数据。 +正常本地增量刷新仍可按既有分析设置读取允许范围的新增/变更 JSONL,不强制全量重读、 +不新增扫描器或网络访问;普通热刷新不为日期重聚合这些记录。 + +联合智能体/effort 条件、小时/夜间条件、单任务金额日期,以及缺失时间戳的迁移本地操作 +没有受支持的历史日期处理器。它们保持未知或显示原有首次记录,不能补成今天。 +未来规则必须显式进入日期处理器白名单并隔离规则版本,新范围默认未知;未来目录封套 +继续沿用只读、fail-closed 行为,不自动改写。 + +手动校准仅执行本地 single-flight 增量操作,不新增 OAuth/额度、在线计价、BYOK 或其他 +网络请求,不强制全量扫描或清除旧徽章。pending guard 暂停历史补认及基线推进, +真实增量收据独立暂存;通过 guard 的提交静默补认历史,不强制授奖。忙碌、当前索引全清单 +未获接纳、失败及提交前取消不能报告成功。基础索引须当前 manifest 已接纳且索引完整; +独立日历、计价或子集覆盖可不完整。已核验部分历史保留下界或未知,完成不代表所有授奖 +范围完整覆盖。历史补认在提交前取消不新增历史奖;guard 有效时原子提交一旦完成, +committed 标志保留真实结果,后续取消不撤销已保存记录,也不能误报未保存。 +这些展示功能不改变存储 +schema、来源解析器、依赖或软件包版本。 + +图鉴包含 50 张展示主题卡、178 个解锁条件。`enableAchievements` 默认开启,已有关闭偏好 +优先;一次性非模态升级提醒可查看或关闭成就,不自动打开图鉴。历史授予先建立 +静默基线,之后符合条件的新成就只短暂在面板内提示,不发声、不抢焦点;夜间与 +24/48 小时输出徽章始终静默。 + +24 个 API 等价条件分属 Claude/Codex 各自累计、已结束周及单次会话/任务六个主题。 +`achievementMoney.ts` 固定 `api-reference-2026-10-{backend}-v1`(2026-10-09), +精确十进制组件求和后才向下取整为 microUSD。仅精确型号及完整价格(含 Claude 已知 +缓存写入 TTL)合格;未知型号未定价,可信已定价子集只证明下界。周范围为当前数据 +时区内已结束的周一自然周;单次须为一个 Claude 会话或独立核验的 Codex 任务树。 +历史补认静默;累计金额跨级已使用核验的新追加收据,已结束周及单次会话/任务金额 +范围仍静默且日期未知。仪表盘浮动计价及显示币种不改变这些规则。 + +`claude-api-week` 与 `codex-api-week` 各有五级:铜 $100、银 $500、金 $2,500、 +铂金 $10,000、传奇 $25,000。仅新增四个铂金/传奇 ID,原三级 ID/门槛、系列归属、 +图案、已结束周边界及固定价表来源不变。四项显式历史注册将清单增至 138 项;受支持的 +日志日期注释仍不同于已存首次记录日。旧封套拒绝新 ID,扩展前的读取器遇未知等级拒绝 +操作,不改写或清除任一封套。schema/catalog 版本和 65,536 字节上限不变;已报告的 +最大封套夹具为 37,374 字节。这些合成检查不构成原生/F5 验收。 + +`achievementModels.ts` 将协作/彩虹等非金额模型识别与 `achievementMoney.ts` 的固定计价别名分开。 +Claude 家族标签仅允许明确的 `global`、`us`、`eu`、`jp`、`apac`、`in` Anthropic 前缀, +拒绝 `xx` 等任意前缀。已识别的 Codex 命名家族标签仍可能未定价;识别不增加价格或 +计价别名,也不证明上游路由或可用性。 + +图鉴按已启用供应商分别统计系列与等级。计数之前,记录加载中、暂不可用、状态已变更及 +已关闭四种状态使用八语明确说明,不显示貌似空图鉴的 `0 / 0`。已启用的 Codex 来源尚未 +核验时,可信 Claude 累计小计显示为 `≥` 下界,未知 Codex 用量不视为精确零。 +稳定系列键负责等级变化后的展开/焦点恢复, +兼容旧的等级键。全部/待解锁/已获得筛选仅保存本地界面枚举,不调度供应商任务。 +安全的供应商枚举与一次性焦点意图用于从分享返回原图鉴。卡面展示简短下一目标, +完整各级条件放在展开区;设置入口定位已有关闭选项。 +目录提供 52 套独立本地图案;同系列各等级复用徽章,以等级色和文字 +体现进阶。图鉴使用向下取整的整数缩写,不把未达标进度四舍五入为达标;提示与 +无障碍文本保留完整整数。数据边界放在默认收起、可恢复展开状态的说明区。导出 +图片按文件名缓存,不为同系列的每个等级重复保留内嵌字节。 + +`achievementGallery.ts` 仅映射展示归属,不迁移规则或账本。十四日(`streak-machine`) +与首个双供应商达标日(`dual-provider-day`)里程碑放入对应的持续成长主题。每个正式规则 +在详情中出现一次;里程碑保留自己的图标、状态和条件,不增加等级圆点。仅获得旧里程碑 +时,卡片仍为已获得,但正式等级数为零。代表徽章、最近获得与展开别名共用此映射。 +取消旧固定项后按钮若消失,已有运行期焦点回退优先返回归属主题摘要,其次成就页签。 +幸存按钮仍优先保留焦点,不新增持久焦点状态。 +插件探索详情只读投影已提交的 `achievementActivity` 中三个既有安全功能枚举; +按目录排列,不是行为时间线。各步标识已记录/暂无记录,不用历史奖章或待保存操作 +补造证明。建议反馈与设置修改保持独立规则;不增加控件、状态字段、读取、事件或评估逻辑。 +图鉴、提示及本地 SVG 共用新 `safe-feature-journey-v242.png` 映射,不再使用错误的“10X”图案;旧文件保留。 +仅提示中的图标通过 CSS 执行一次 440ms 透明度/缩放变化,提示框与文字不移动。 +减少动态效果及两种原生高对比度 body 类完全关闭动效。投递、冷却、关闭与 ACK 协议不变; +不增加计时器、持久状态、网络请求或重放。 + +`achievementGallery.ts` 白名单映射同供应商当前模型/Token 统计。共享渲染器要求已有物化数据, +模型入口还需对应家族;共享、日历、功能操作条件不以当前总量替代。客户端只由已渲染按钮 +解析固定 DOM 目的地。既有 Webview UI 状态仅新增一个公开主题键及供应商枚举用于返回, +专用往返保留展开/滚动,普通标签或供应商切换取消意图;不新增宿主命令、日志扫描、日期或分享授权。 +保存的固定键保持原样;旧重复固定项可在同一卡内分别取消。分享、授予 ID、日期和未读组 +均不改变;渲染不写入,也不读取日志。 + +`achievementStages.ts` 在正式 Token 等级之间加入仅用于展示的 1/2/5 整数节点, +Token 首级前的一数量级也有展示节点,但需正向且有效的进度;金额首级前不追加,末级后均不追加。收起的卡片也显示当前/下一正式等级,适用时显示下一阶段; +待补齐/过期证据不显示这些进度。次数/设置探索不添加节点;正式 ID、门槛、获奖 +数量、提示和分享选项均不变。节点只从固定目录及当前聚合计算,不新增写入、历史 +扫描、定时器或权限。 + +`achievementOverview.ts` 从已归并、供应商已启用的条目中选择最多三枚代表徽章及 +三个有当前有效正向进度的目标。目标按下一展示阶段完成比例排序,以固定目录打破 +并列,同主题最多一项;代表徽章优先已获等级,再考虑当前供应商。夜间/小时目标 +不主动推荐。选择键仅在本次浏览的宿主内存中保存,进入页面、切换供应商、手动刷新、 +关闭或清除时重新选择,不成为分享选择。实时变化只更新数值、移除失效目标,不换位 +或替补。点击打开已有、可保留的系列详情,不读日志、不发刷新请求、不保存第二份 +聚合索引。固定项、记录投影及前台错过提示摘要使用下述有界权威封套,不创建第二份获奖账本。 +下一目标概览使用目标名称、等级标签及等级色;图鉴已获卡片保留已获等级。 +`achievementTitles.ts` 与 SVG 导出共用,仅在等级单独显示时移除由目录中点分隔的 +匹配本地化等级或其旧名后缀,避免重复;其他标题文字及无等级里程碑保持原样。 + +`achievementLongTerm.ts` 给15个既有 Claude/持续活动系列追加30个固定高等级, +原三级 ID、门槛与证据不变;新 ID 复用明确基础规则的计数、图案和本语种名称/等级。 +不追加连续活跃日等级或扫描器。家族活跃日归活动主题,自动代表不再必然挤掉模型用量。 +旧 ID 和v1系列固定词汇独立冻结,不能从新版系列成员反推旧资格。新等级不能作为v1 +证明导入;v2 可读取旧 144 项封套,当前最多 178 项,仍不超过 64 KiB。旧二进制可以拒绝 +未知ID,但不能覆盖权威封套。高阶输出/缓存沿用原生尾部因果核验,会话/日期补认 +保持静默且日期未知;分享选择不自动升级。 + +`codexAchievementRevision.ts` 的运行期 v3 摘要绑定全清单、独立的索引/全局日期/ +时区边界、contribution 见证及 period 游标/日期向量。仅游标、见证或边界变化也会 +退役日期/金额缓存,即使显示计数相同。复用既有摘要/缓存路径,不增加 I/O、 +定时器、存储或持久索引字段。 + +供应商每次先重建当前全清单与独立见证,再与上次发布的快照精确比较。只有相等、 +与原数据分离且深度不可变、由 `immutableCodexAchievementFiles` 登记的副本,才能在 +独立边界仍一致时复用摘要及日期投影缓存。`indexChanged`、零正文读取、总量相等或 +调用方提供的摘要均不能替代此比较。可变/异常输入继续走普通验证;不修改 worker +拥有的聚合或先前快照。金额范围绑定复用内部已核验投影,避免重复日期计算,但不信任 +任意传入的投影。 + +20,000 文件 × 每文件三个日期的合成测量包含快照构建:暖评估中位数约为 +348 → 109 ms,变化评估约为 2,110 → 1,460 ms。该规模的变化/追加仍约需 1.5 秒, +复杂度仍为 O(files);即使缓存复用也须比较全清单。这些本机测量不包括 worker 解析、 +文件发现或仪表板渲染,不代表端到端刷新、原生响应或通用延迟保证。 + +`codexAchievementCalendarProjection` 要求当前清单已验证、主索引完整。供应商从 +contribution 自身大小、偏移、预期谱系前缀及时区构造仅运行期见证:EOF 必须与 +period 游标一致,前缀/时区须符合当前 period 语义,且无 period 迁移、超长行丢弃或谱系 +对账。传入聚合自带见证被剥离;刷新失败后不能沿用见证作为授权。投影先解析全清单 +祖先关系,再选日期,排除冲突分支及后代。可信正向日期分量即使存在未归日余量或 +全局日期边界不完整,仍可作为活跃、模型家族及单周 Token 规则的 `verified-subset` +下界。子集不能证明完整/零用量历史、四个已结束周的周均值或保留日历峰值; +双供应商同日规则也保留既有完整证明门。 + +`codexAchievementMoneyScopes` 获取原始全清单的已核验投影,仅对上述不可变快照复用, +核对 `sourceRevision`、完整性/ +范围及数值日期/模型签名后,才选择完整归日文件。每个入选文件的所有计费计数和 +模型/日期向量必须精确相等,仅 Token 包含关系不足以证明金额。此子集中已结束的 +周一始自然周正金额仅为下界,覆盖标为部分并附运行期 `scopeLowerBound`;部分范围 +的纯零值/空值仍待补齐。正金额按精确单位判断,即使整数 microUSD 向下取整为零。 +定价覆盖独立于日期/清单覆盖:未知型号仍未定价,溢出金额 +仍不可用。累计及任务范围不变。不新增价格或规则;复用既有八语证据说明,更新文案 +以涵盖无法核验的 Codex 谱系或日期用量。即使身份覆盖完整,存在日期/周金额范围 +下界时页脚也显示该说明。见证/摘要不把原始身份带入渲染、导出或持久索引状态。 + +顶部采用宽度受限的横向卡片,少量徽章不再平分桌面全宽;卡内固定按钮仍是独立按钮, +下一目标的细条仅辅助已有带标签数值。保留三档大小、图案、主题变量和状态持久化。 +`modelRainbowNextEligibility` 排除超过当前供应商家族容量的下一目标(Claude 四家族、 +Codex 三家族),不改变正式条件、固定项或图鉴可见性。 + +正向综合日期规则从各供应商独立验证的日期派生有界、运行期的逐规则下界集合。 +另一方不完整时,活跃日、连续活跃、活跃周、单周 Token 峰值及已证明的三家族同日 +仍可显示 `≥`;部分证据的零计数仍未知,双供应商同日与四周平均保留完整证明门。 +此集合不打开综合域提示基线,也不伪造历史授予日期。`normalizeClaudeNativeUsage` +仅由两个明确、安全的已知 TTL 分项恢复缺失的缓存写入总数,不改源记录;全量和 +紧凑/增量 reducer 共用此逻辑,矛盾或未知 TTL 仍不能成为金额证明。 + +启用成就证明时,缺少当前谱系标记但可兼容读取的旧 Codex period 进入既有有界日期 +迁移,不再永远暖刷新跳过。canonical 文件重验完成前全部时间证明覆盖仍不完整, +可读兼容片仍服务普通展示;保存游标支持取消/恢复,完成后暖刷新正文零读取。 +关闭成就时跳过这项额外重验。 + +`achievementEvidence.ts` 将规则分为九个独立的运行期证据域。有效、待补齐与过期证据 +独立于已保存获奖身份:待补齐/过期卡片保留已获等级,不显示数值进度,不进入待解锁 +筛选。各域首次或恢复证明时建立静默基线,日期证明不足不再压住后续可信 Token 奖项。 +供应商退役只重置受影响的域。用量域静默校准历史后,迁移复核集合不再永久压住其 +后续等级提示。这些状态只在内存中,不等同于持久化错过提示记录或跨宿主协调。 + +Claude 索引使用仅运行期尾追加来源合同。异步工作前复制明确的已接受观测 +窗口,在内容分析计划扩大前沿用既有物理文件/前缀/EOF 验证的偏移。全局响应去重 +后,只有偏移后的新增规范赢家、窗口内事件时间和安全原生 Token 数值才进入固定的 +总量/输出/缓存读取/缓存创建/四模型家族下界。冷构建、新发现导入、替换、未完成尾部及 +时区退役不提供新活动来源;混合历史只保留独立可证明的新增部分。结果不含来源身份, +不持久化索引字段,不增加正文读取或定时器。等级助手复用正式门槛,逐等级要求新增 +部分足以使旧进度达标,且从新进度扣除它后不再达标;不据总量推断日历/会话/协作。 +宿主现于解析前读取权威获奖代次/序号,先提交已接受索引,再交接已有物化引用, +不依赖重绘。四模型家族、输出、缓存读取、缓存创建及累计跨级进入原子用量授奖; +累计金额使用下述独立证明路径;其他用量规则仍待各自来源证明,保持静默。 +创建、读取、输出必须为安全整数,分量和 +不得超过已处理总量;原生向量中的缓存创建仅计一次。手动重建不提供新活动窗口。仅呈现代次 +退役不丢弃可信来源提交;来源、失败、清除、OFF、时区及销毁守卫会使证明退役。 +显示时区的用量收据不把其他时区的日历证据重新标记。 + +Codex 已补上对应的 producer/worker 基础。仅完整旧主索引中、物理身份已建立的 +文件追加收到明确且已复制的观测窗口。正常主解析器累计窗口内输入+输出、输出、 +缓存输入和三模型家族数值;去重及谱系处理后,新旧快照都须验证相同物理/会话身份 +和稳定继承前缀,每个接收字段不得超过该文件实际聚合增长。无关身份冲突只排除 +自身分支,不压住可信新增。冷构建/导入/替换、未完成尾部、时区变化、计数回退及 +未稳定前缀不提供新活动证明。`providers/codex/codexAchievementOrigin.ts` 管理此运行期 +下界、谱系接收与严格窗口/DTO 重建;不增加持久字段或正文读取。 +主文件 worker、coordinator 和 client 重建固定数值 DTO,外来窗口/请求、重复任务回复、 +恢复/旧缓存沿用及取消不能提供证明,最终原子保存失败不交付成功结果。证明只存在 +于运行期,零正文读取的暖刷新不重放。逐等级助手使用既有正式门槛及必要/充分判断, +不推断日历、effort 广度、会话或协作授奖。 +Codex facade 在异步工作前复制窗口,仅返回匹配的成功证明。每次观测前,webview +所有者在共享 lease 内重读权威获奖代次/序号;扩展交接已接受快照不依赖重绘, +暂停页面更新时仍可授奖。单独的已接受引用进入评估器,不改显示快照。来源、设置、 +时区、清除及销毁守卫退役证明;无关 Claude 失败不取消可信 Codex 新增量。 +固定的 Token 跨级集合经 `recordUsage` 保存首次记录日/时区及未读状态。只有选定 +奖项且序号晚于观测基线的历史补认可被替代;已有历史、本地操作及用量来源不改写。 +替代会递增序号,旧显示确认不能读掉新记录。写入失败保留最多图鉴 ID 数量的 +运行期收据,供普通刷新重试;退役收据释放来源守卫。不增加索引字段、正文读取、 +监听或定时器。前台提示复用共享冷却与确认守卫。Claude 复用相同的有界收据队列与 +lease;任一供应商失败只退役自身证明。其他用量范围仍静默补认,直到各自来源接入; +累计金额采用下述独立运行期路径。 + +`achievementMoneyOrigin.ts` 只接收每家供应商四个累计金额等级跨级,要求全范围前后 +证据及新增金额使用同一 +固定参考价。增量既须足以使旧总量跨过既有门槛,又须是新总量达标的必要部分。 +Claude 正常解析/索引只纳入额外已接受响应身份,参考价缓存 TTL 证据须完整,支持 +两个固定 Claude 后端价表;Codex 真实 worker/facade 使用 OpenAI 参考价给已知原生 +事件计价,并由受影响文件的已定价聚合差额限制增量。无已接受窗口或关闭成就时不做 +额外金额来源计算;主解析计量语义不变。 + +可选运行期金额证明只含供应商/后端/公开价表版本、最多 64 位十进制精确单位字符串、 +microUSD 及合格已处理 Token。IPC 边界固定字段重建,固定价表最大逐 Token 单价作为 +经济上界;无效金额只撤回金额证明,不污染 Token 证明。新增金额来源证明的金额、 +精确单位、合格 Token 计数、原始事件/来源 ID 及证明均不写入索引或获奖文件; +金额奖章仅在普通有界记录字段之外保留公开 `referencePricebook` 版本,既有用量聚合 +仍遵守原存储合同。 +金额来源只使用固定参考价,不使用当前浮动单价。 + +宿主保存有界、独立金额收据,由来源、身份、范围、日期/时区、清除代次及关闭成就 +守卫保护。租约提交时再次核对当前参考价及合格全累计范围;仅金额价表/范围失效时 +只撤回金额,不取消独立有效的 Token 收据。共同的来源/索引、清除、OFF 及销毁守卫 +仍适用于两者;Claude 后端切换还会使索引代次失效,可退役待保存的 Token 证明。 +保存失败保留可重试运行期证明,不乐观显示日期。 +`recordUsage` 原子保存 `usage` 来源、首次记录日/时区及未读;仅同价表且序号晚于 +已接受基线的其他窗口历史可修复并生成新序号,不同价表或已有历史保留原依据/日期。 +同次已接受更新的 Token/金额写入合并为一个既有五分钟提示批次,不新增定时器; +隐藏面板/未读摘要行为不变。已结束周、单次会话/任务及其他未支持新活动范围仍静默且日期未知。 + +`CodexAchievementSignals.effortStatistics` 是完整当前规范索引的可选证据。 +`extension.ts` 只在证明修订变化时计算;未变化暖更新复用同一信号对象/结果,不再次 +调用助手。关闭成就跳过计算,来源/配置替换或索引不完整撤回为未知,不补六行零值。 +纯助手先汇总非 approval-reviewer 的根任务 × effort,只接收该组合自身有正输出的 +用量。low/medium/high/xhigh/max/ultra 六行仅位于已有 effort 广度卡片展开区, +沿用下一既有等级的三个联合条件;渲染与授奖共用 `EFFORT_PROCESSED`、 +`EFFORT_OUTPUT` 及 `EFFORT_ROOTS`: + +| 既有等级 | 每 effort 已处理 Token | 每 effort 输出 Token | 每 effort 合格根任务 | +|---|---|---|---| +| 铜 | 1 亿 | 10 万 | 5 | +| 银 | 5 亿 | 50 万 | 10 | +| 金 | 20 亿 | 200 万 | 30 | + +每行进度取三个条件完成比例的最小值,最多 100%。仅供参考的小计/覆盖率使用固定 +美元 `api-reference-2026-10-openai-v1`(2026-10-09),不随显示币种变动,也不参与 +effort 资格判断。独立模型/effort 边际不能证明交叉归属,不按比例分配;只有单模型 +或单 effort 完整价格组件对账 +才能接收已知已定价子集,其余 Token 未定价。空、未定价、部分、无效、溢出分别处理, +不安全金额撤回。DTO 不输出身份/型号标签/精确单位字符串,不保存历史,不增加 +规则、门槛、导航或偏好;目前 Claude 日志不提供此处的 effort 归属。 + +主动分享可包含已关闭供应商的保留徽章;图鉴及新增固定按已启用供应商筛选。 +保留记录不提供当前用量进度,也不授予新的解锁权限;分享不会重新开启供应商或读取其日志。 + +徽章分享仅保留一份已接受 SVG 及不透明运行期句柄。客户端标识与请求序号拒绝过期 +回复;回复可移除不再可用的勾选项,但不得新增徽章。界面恢复复用匹配的已接受内容。 +导出要求句柄、有序 ID 与界面语言一致,在单次保存对话框前固定字节。成功、取消、 +匿名写入失败均在面板内反馈;已开始保存的字节不受后续语言变化影响。失效的选择有 +明确提示,不会自动升级勾选。替换、清除、关闭功能或面板 +都会退役保留产物;生成失败也会废弃旧句柄,仅返回匿名失败标记。 + +旧 globalState 精确账本只含白名单规则 ID、29 个安全设置键与四个本地功能枚举、 +上限为 48 的小时连续输出最大值,以及一个带语境的四周周均数值峰值。不保存设置值、 +账户/来源标识、提示词、凭据、操作时间戳或导出历史。审计及可恢复迁移状态进入 +Clear All 的精确白名单。完整字段与上限见[本地数据说明](LOCAL-DATA.md#local-achievements-250)。 + +Claude 在 Sessions 页 1,000 行显示上限之前计算会话成就证据。平铺的 +`projects//agent-*.jsonl` 缺少路径可证明的父会话,仅从会话成就证据中排除, +不把记录的 `sessionId`/`isSidechain` 元数据提升为归属契约。Token 总量、计价、每日 +聚合及可见会话行不变,嵌套代理保留既有父目录契约。这一仅运行期筛选不改源日志, +也不删除缓存或已保存的徽章。 + +`achievementRecordStore.ts` 单独管理扩展全局存储中的权威获奖封套 +`achievements-v2.json`,不与运行期基线混淆。总序列化大小不超过 64 KiB,最多 +178 个当前规则 ID;含单调序号/代次、原因枚举及可选首次记录日/时区。 +v1 文件在明确清除之外只作只读迁移输入;已保存的 21 个改变过的模型/团队/effort/ +彩虹条件归入最多 48 条旧规则记录,不用于证明新条件,旧金级不能证明新规则。 +固定价奖项可保存公开参考价版本,不保存个人金额。 +读取已保存的金额记录时,接收该供应商当前价表及 `achievementReferenceBooks.ts` 中三个 +明确已发布的 v1 标签:Claude 的 `api-reference-2026-10-anthropic-v1`、 +`api-reference-2026-10-aws-bedrock-in-region-v1`,及 Codex 的 +`api-reference-2026-10-openai-v1`。当前价表选择改变不重写已存标签;新增 `append` 或 +`recordUsage` 草稿必须使用当前供应商价表,可读的历史标签本身不能授权新授奖。 +这是明确的已发布历史登记,不按字符串模式放行未来价表,不新增价格、ID、存储字段或 +schema。未知且非当前的价表安全拒绝,整个封套保留不动,清除全部也不改写。 + +schema/catalog 版本、自动/手动模式、有序固定项、固定修订及有界旧文件边界共用此封套。 +两份文件共用既有 v1 租约; +清除先保存 v2 墓碑,再保存空 v1 降级屏障,中断清除在同一租约内续办。 +通过旧审计的奖章只在文件不存在时静默迁入,日期未知;已有文件优先于旧 globalState。 +每次事务在既有文件 lease 内重读、合并,刷新私有临时文件后原子替换,不覆盖其他 +宿主的奖章。清除在同一 lease 内删除精确相关旧键,再保存空代次/序号屏障;旧宿主 +在重新加载前安全停止回写。本地操作、小时及日历写入均核对已接收代次。 +未知、未来、过大或符号链接输入保留不动,不回退到旧获奖镜像;关闭成就不初始化 +新文件。真实已保存的本地操作保留未读记录,不因隐藏或投递失败丢失。可证明的 +Claude/Codex Token 跨级和同一冻结参考价下的累计 API 等价跨级,也通过独立收据 +保存未读与首次记录日。单次/任务、日历/结构等奖章若没有各自可验证的新活动来源, +仍静默补认且日期未知;只有证据域增长或价格变化不能证明来自新增活动。封套也管理原有 +有界行为/小时/日历证据:29 个安全设置键与四个枚举取并集,小时最大值上限 48, +日历峰值只在相同测量语境内合并。仅 Claude 的保留峰值忽略无关的 Codex parser/period +版本;规则、时区、供应商存在位及完整当前证据门仍适用,任何包含 Codex 的语境仍核对 +这两个版本。旧 Memento 辅助字段只作迁移输入,不作运行期 +镜像;恢复始终接收权威证据,包括清除后的空证明。引导期间用最多 33 个不同键 +合并主动操作,恢复完成后才重放,避免旧窗口整对象缓存复活。插件动作来源比较 +lease 内真实保存前后的行为差异,不会把其他宿主导入的进度当作本窗口新行为。 +主动操作与它新跨过的插件奖章在一次事务内保存,避免其他窗口历史补认抢先占用 +这些奖章的来源/日期/未读状态。 +实时提示在同一 lease 内预留跨窗口五分钟冷却,但不标记已读。复用现有原生窗口状态 +所有者判定前台;面板可见/就绪、OFF/清除代次及文档替换共同约束投递与确认。 +运行期匿名句柄将显示确认绑定到本批实际代表的 ID 与发出时的最大序号,其他或后续 +未读保留。投递 false/失败/缓慢不会阻塞奖章保存或清除。客户端只在可见插入后 +确认,不抢焦点;悬浮/键盘操作期间暂停六秒自动关闭。错过的批次不重放实时提示。 +前台未读摘要现提供明确的有界确认;未支持的用量新活动范围仍待接入,分享仍须明确同意。 + +`showAchievementNotices` 是默认开启的本地呈现偏好,与成就总开关分开。关闭它不退役 +来源证据、不停止授奖、不删除未读、不消耗提示冷却。单独的运行期偏好代次拒绝排队 +预留及旧确认,包括快速 OFF→ON;可见客户端立即关闭提示,在保存后的宿主页面 +重新启用前拒绝过期提示。重新开启不补弹错过的提示。设置路由只更新页面呈现, +不重启供应商或扫描;这个关闭偏好不计入插件体验成就的设置白名单。 + +恢复页面的就绪握手现报告实际供应商/页签,宿主只接受当前供应商的合法页签, +对比页仅有 `all`;普通切页使用同一白名单。恢复不计作功能体验、不启用其他 +供应商、不授奖或标记已读。未读展示的准备投影从完整有界封套选择最多六个 +精确非历史等级,不受三十条历史显示上限影响;其他窗口新增不扩大原组的 +ID/序号,成员隐藏、已读或替换时重新选组。投影复制记录,不写确认。 +默认折叠「新增 N 项」使用运行期匿名组句柄及文档身份;只有同一就绪/可见/聚焦 +成就页的明确「标记本组已读」才排队保存。共享 lease 提交边界再次复核来源开启、 +供应商、清除代次及组身份,仅确认宿主保存的精确 ID/序号,不接受客户端任意 ID。 +重读合并保留其他窗口后来新增;失败仅给固定本地化反馈,保存后的渲染失败不误报 +保存失败。移除组后焦点回到摘要或成就页签;浏览/固定/分享不确认。 +没有新增定时器、日志扫描或持久字段。完整文档替换及补丁失败回退更新文档身份, +拒绝旧页面就绪/切页/已读消息;普通数据补丁保留身份。失焦/关闭/清除/销毁/ +切换供应商或页签退役组所有权,不重放错过提示。 + +`achievementOverview.ts` 已准备只读记录投影:最多三个非历史最近系列、三十条按 +首次记录序号排列的记录及当前可见等级的精确未读 ID。记录顺序不冒充原始达成时间, +投影淘汰不删除奖章。自动模式推荐最多三个已获主题;手动模式只显示选中的最多六项, +不自动补位,不改变下一目标。收起卡片的固定控件及可搜索编辑器在共享租约内保存选择/ +顺序/模式;批量选择及排序核对当前固定修订,拒绝旧窗口覆盖其他窗口选择。 +`setPinned` 在文件 lease 内施加单项幂等意图,拒绝未获系列,最多六个位置,不以旧 +整份列表覆盖其他窗口的固定项。记录投影已呈现最多三个最近系列及折叠的三十条 +记录;每条保留精确公开等级、首次记录日/时区或明确的历史未知日期。点击仅打开 +现有规则,不标记已读、不选择分享;折叠状态跨页面替换保持。已获徽章详情发送单项 +带代次的固定/取消意图,只在成就开启、前台面板可见/就绪时受理。成功保存后经 +既有仪表盘补丁更新按钮状态及展示柜,不改变下一目标或分享。稳定控件 ID 保持 +焦点/滚动锚点;保存失败保留旧选择并使用本地化行内反馈。渲染失败不冒充保存失败。 +关闭供应商只阻止新增固定,不阻止明确取消已保存的公开键。折叠入口最多列出六个 +隐藏固定项,共享 lease 下仅移除选中项,不启用供应商、不读日志、不改变奖章、 +未读及分享。按钮消失后焦点回到组摘要,列表消失则回到成就页签,不新增客户端状态。 + +Claude 在会话列表 1000 行显示截断前物化全会话计数。Codex Token/模型/协作里程碑 +要求当前清单索引完整,并逐谱系核验规范化证据。身份冲突只排除相关分支及后代, +不阻断无关谱系的正向证明。筛选与根会话定位采用迭代/记忆化;无效数值、孤立/ +循环/未知角色及不安全求和均不授予。日期选择沿用上述全清单祖先解析及运行期见证 +合同;只有全清单完整性才能证明零值、四周周均及保留峰值。 +UTC 正输出小时在正常解析时同步获取;旧索引使用有界、可保存 +检查点的证明迁移,保留主用量。关闭成就只停止这项额外迁移,不停止普通用量解析。 +宿主另保留一个仅运行期的 SHA-256 成就证明版本:规范化身份、六种 Token 计数、 +模型/推理档位桶、period 谱系标记/游标、运行期 contribution 见证与日期桶,以及 +独立的索引/全局日期/时区边界。证明单独变化 +时,即使仪表板完整渲染合同相同,也更新成就信号;证明不变及纯标题/结构显示变化 +复用信号。摘要从已有聚合逐项计算,不另留完整数据副本、不读取 JSONL、不增加持久 +键或定时器,亦不记录或导出。关闭成就完全跳过摘要和信号计算并退役内存证明; +重新开启后在下次已接受快照采用当前证据。来源替换也退役版本和信号。不撤回历史 +奖项,也不自动把旧 period 当作完整日期证明。 +刷新失败立即撤销新授予权限,不重绘或丢弃上次显示的数据;暂停自动刷新也不能绕过。 +用量证据版本与清除/活动版本分别保护排队写入,退役供应商不能授予新成就。首次 +历史对账保持静默。 + +插件操作来源通过正式规则评估器比较已提交账本的前后状态;只有新跨过门槛的插件 +规则进入最多六个 ID 的运行期集合,读取/导入旧账本不进入。授予保存成功后消费 ID, +失败保留可重试证明;关闭/清除退役。操作账本成功保存后,无可见/就绪面板也评估 +并保存奖项,不渲染隐藏页面。排队的用量 ID 仍检查供应商证据代次,插件 ID 检查独立 +清除/活动代次,避免供应商证明退役误删本地操作证明。不新增持久字段或时间戳, +这尚不是跨宿主协调。 + +Claude 项目/会话/分支/工作流的无上限时间范围改为单次遍历计算,不用参数展开; +项目合并亦逐项追加。这消除了独立复现的 15 万条记录 V8 调用栈溢出,不改变去重 +和数值计量口径。 +密集工具结果追加也逐项合并结构事件:同一工具 ID 的结果 envelope 没有数量上限, +不能用数组参数展开。15 万条结果追加回归核对完整内容分析一致性,并保留暖刷新零正文读取。 +Claude 刷新失败仅报告固定阶段/原因枚举与实际观测到的匿名计数;无法确认的计数和耗时 +明确记为 `unknown`,区分读取/解析、聚合与内容分析。任意异常名称、正文、调用栈和路径 +不进入日志。同一净化阶段/原因对在 Extension Host 生命周期内只报告一次,上限为 60 对; +既有已验证统计继续保持权威。 +Claude 同一失败版本的自动重试等待 30/60/120/240/480 秒,再以 15 分钟封顶; +手动重试保留 5 秒最小冷却。该运行时门使用单调时钟,只保留当前一个版本,不增加 +定时器、扫描器或持久化历史。来源/价格/解析权限变化可立即尝试;展示设置、watcher +突发与已排队刷新不能绕过。退役任务不能重置其他版本的退避,只有已验证的索引成功 +才恢复普通暖刷新节奏。额度请求继续沿用独立策略。被冷却的手动重试仍提供简短失败 +反馈,不替换已验证用量,也不读取日志正文。 +Codex 主统计的谱系深度排序使用显式 DFS frame 与一个活跃路径集合,保留既有 +memo 优先于循环检测、父分支顺序、孤立节点与根节点语义。不再递归、复制祖先集合或 +展开父节点数组为函数参数。保存/重载后的 1 万层主统计重算回归保持零正文读取; +受限小图对照核对原排序语义。这与成就证据遍历是两条路径,不改变索引 schema、 +fingerprint、前缀扣除或供应商 Token/成本/额度语义。 + AI 密钥初始化在激活前完成一次协议兼容迁移。已有密钥而没有明确的 state/旧配置协议时, 保留旧 Anthropic 默认;明确的协议不变,新安装无密钥时采用 OpenAI 兼容默认。 旧 VS Code 配置只在通用设置迁移前读取,恢复默认后不会让过时协议重新生效。 @@ -228,8 +723,8 @@ AI 密钥初始化在激活前完成一次协议兼容迁移。已有密钥而 Codex 发现仅限: -- `$CODEX_HOME/sessions/**/*.jsonl` -- `$CODEX_HOME/archived_sessions/**/*.jsonl` +- `$CODEX_HOME/sessions/**/*.jsonl` 与 `sessions/**/*.jsonl.zst` +- `$CODEX_HOME/archived_sessions/**/*.jsonl` 与 `archived_sessions/**/*.jsonl.zst` - 默认 `$CODEX_HOME`:`~/.codex` 绝不读取 `auth.json`、SQLite、config secret、keychain、浏览器状态或未知文件。 @@ -237,6 +732,48 @@ Raw path/session/parent ID 只留在本地 worker 的短期内存。磁盘只持 按 day/model/effort/session 聚合的数字,以及上面所述的有界账户中性额度历史;绝不存 prompt、 response、command、tool arguments、raw line、raw path、账户名、凭据或原始 provider label。 +2.5.0 中的 `.jsonl.zst` 是原始 JSONL 的无损 Zstd 存储,不是 LLM 摘要; +磁盘文件变小不会改变日志中的 token 计数。发现阶段拒绝符号链接与非普通文件。 +同目录同 basename 的 `.jsonl` 优先于 `.jsonl.zst`,HMAC 文件键只剥离末尾 `.zst`。 +这是规范来源选择,不声称两份表示字节相同;跨活动/归档区的副本仍通过完整清单的 +lineage 去重。源文件只读,解压明文不落地。 + +Manifest `size`、parser 游标与 migration offset 继续使用解压后的字节坐标。 +仅压缩记录新增 `compression: 'zstd'`、`storageSize`(物理字节数)与 `storageCtimeMs`; +`mtimeMs`/`dev`/`ino` 同样描述物理文件。schema 3 以附加字段保留这些指纹及 +`compressionVerified: true` 持久标记,不提升 schema 版号;缺少这些字段的旧记录仍按 plain 处理。 +该标记要求核验帧 EOF,不能仅凭 `offset === size` 建立。编码或物理版本变化须定向重读 +历史,包括同 inode、同大小覆写后恢复 mtime。压缩文件移动且指纹变化时,也将目标键 +标为 replacement;这些历史重整静默进行,不产生新的 live usage receipt。 + +确定性的正文解码失败以带版本的 `compressionFailure` 允许列表枚举及物理指纹 +替换整份临时贡献,不保留 token、lineage、额度或日历证明。同一失败版本不反复解码 +或写 checkpoint,也不视为 pending 工作,但 `failedFiles` 及未完成覆盖仍明确保留。 +物理指纹/编码变化会作废失败状态;临时 I/O、取消和来源竞争仍可重试。 +未知持久化失败版本须重新验证,不取得权威性。Header 发现错误仍使本轮刷新失败, +保留上次有效 snapshot。 +逐文件额度观测须在压缩帧验证成功后才合入保留的全局历史,中途 draft checkpoint 不提升该证据。 + +压缩 frame 的 EOF 核验成功后,没有换行符的有效末条 JSON 记录只解析一次。 +非法尾片段标记 `invalid-json`,超长末行标记 `oversized-jsonl-line`;这几种情况均将 +游标推进到真实 decoded EOF,避免不可追加的尾部反复触发整帧重放。这些 JSONL +质量标记不豁免 frame 损坏,也不证明用量覆盖完整;active plain 日志的未终止行语义不变。 + +发现阶段通过只读句柄读取最多 18 字节 header,将 fstat 身份/大小与 lstat 核对, +并检查读取期间是否发生变化。Codec 只接受显式 Frame Content Size(FCS)、无 dictionary-ID +字段的单 frame;无效/未知大小、损坏、额外 frame、尾随字节或大小不符明确失败。 +Header 本身不证明正文有效或读取完成。硬上限为解压数据 4 GiB、前缀重放 4 GiB、 +物理数据 2 GiB、window 64 MiB。解码使用 [Node 原生 Zstd API](https://nodejs.org/api/zlib.html#zlibcreatezstddecompressoptions), +22.x 从 22.15.0、23.x 从 23.8.0 提供,不新增依赖;以实际扩展宿主能力为准。 +不支持的宿主明确报错并保留上次已验证 snapshot,不冒充完成。 + +同一物理版本已完整验证时,warm refresh 只核对 metadata/header,正文解压量为零。 +Resume 从 frame 起点重放到 decoded 游标,丢弃的前缀计入工作量且可取消。 +每轮最多允许首个历史压缩文件在 codec 硬上限内超出 soft byte budget,避免永久延后。 +含压缩来源的回填最多使用两个 file-pass worker。数字索引仍是可重建缓存而非备份: +真正消失的来源会移除贡献,仍存在的压缩/归档表示则重新核对。 +以上是实现契约,不是本机恢复或验收证据。 + Advice 只消费已物化聚合,不重读 JSONL,也不重遍历保留的 records。远程默认为 aggregate-only;prompt sample 与可选 user context 需单独明确同意,并必须原样出现在精确请求预览中。 host 保管 Prepared 对象和 API key;webview 只拿到预览与不透明 handle。第二次点击才把同一 bytes 对象 @@ -264,6 +801,82 @@ foreground/background worker profile;通用失败路径无法确认模式时 草稿。导出在异步保存对话框之前捕获已确认 SVG,不重新计算不同内容,不保留产物 历史,也不额外联网或重读源日志。 +热力图白名单配置为 `provider=claude|codex|both` 与 `metric=tokens|apiEquivalent`; +金额必须同时 `showAmounts=true`,否则规范化为 Token。`apiHeatmapDaily.ts` 在倒序 +每日数组中以对数级日期键查找定位范围,只投影选中日期的 payload;调用方提供自然日 +边界及分桶时区,不重新分桶、不读全量记录、不重算旧每日/模型成本、不调用 API。 +仅对选中 Claude 型号行调用 `getCurrentEstimatePricing` 查询既有内存单价,每次投影 +按型号缓存。通用未知型号 Sonnet 回退不证明已知覆盖;缺失必要组件单价为未定价, +显式零价有效,部分覆盖只计单价已知组件。运行期/家族价保持估算;旧 1 小时转 +5 分钟缓存估算不等同于固定参考价定义。一般成本计量不变,最多物化 366 日。 +相同日期行去重;冲突行撤回金额,仅保留最大观测 Token 下界,不相加重复总量。 + +金额使用当前每日浮动估算,不用成就固定参考价。Claude 保留当前后端/回退/覆盖价 +估算;Codex 只保留精确已知型号的已定价 Token 子集。无数据、未定价活动、部分定价 +及真实已定价零值彼此区分;来源覆盖不完整明确标示,已定价小计仅证明下界。 +两者模式分别渲染 Claude/Codex 金额,不计算联合金额。隐藏金额恢复 Token 的颜色、 +标签、标题/提示、元数据及 Markdown;Token 模式不查单价或每日金额/质量 getter。 +产物身份只含白名单配置、数值质量、来源/价格/显示修订及内置币种元数据;内部签名不导出。 + +宿主保留一份不可变已接受产物、不透明预览 ID、文档身份及已物化来源引用。修改控件 +即退役;请求序号及文档检查拒绝旧回复。来源、价格、时区、语言/币种/显示变更作废 +接受状态;价格刷新待完成时不接受金额预览。导出在单次保存对话框前后复核,以已确认 +SVG 原字节写入,不重新构建;复制 Markdown 也须有有效产物。不新增全量记录副本、导出历史、认证或联网。 + +`showWeeklyQuotaPace` 为默认 false 的 STATE 设置,Claude/Codex 共用。关闭时宿主 +在来源 getter、时钟/缓存/client 访问及投影前退出;开启不会启用额度跟踪。开启后 +仅消费既有成功原始缓存、原始 `observedAt` 及既有请求中已核验的账号 HMAC 信号, +只存内存,不把 profile/home/窗口标签当作账号证明。运行期所有者按响应保留原始 +元数据,跨开关检查计数/重置/窗口连续性。账号/来源变化、下降或异常重置锁住不可信 +状态,直到核验正常的七天滚动重置;重绘或恢复较高计数不能解除。当前 Codex 本地 +日志快照始终为账号可信度未知,预测不可用。 + +纯公式为起点 = 重置减七天,参考 = 原始观测时刻的已过周期比例,预测 = 已用比例/ +已过比例。因此已用 29%、已过 25% 得出 116%,偏离为 +4 个百分点。它代表账号级 +周期平均消耗,包含其他设备/成员。前六小时显示样本不足;超过三十分钟或重置后 +撤回预测。凭证失效把成功缓存时间清零即立即撤回。既有刷新回调发送绑定文档/供应商 +的最小宿主补丁,暂停自动刷新仍可更新;客户端不乐观重绘预测。不新增请求、定时器、 +历史扫描或持久字段。仪表盘与状态栏提示共用八语 `weeklyQuotaPaceCopy`,简短状态栏及自定义格式不变。 + +图鉴工具列先于展示柜/概览。小/中/大设置图鉴图标为 56/80/112 px,展示柜为 +56/72/96 px,保留字号及点击区域。固定错误位于对应控件附近;控件禁用或消失 +时回退到稳定摘要。既有图鉴/API 浏览器检查只证明浏览器行为,不证明原生/F5、 +新 VSIX 或性能已经验证。 +样式契约逐一核对实际使用的全部 47 个 VS Code 变量是否在对应注册 fixture 主题中声明, +注册的 null 颜色明确设为 CSS `initial`。未注册粉色变量改为其原有精确端点 +`#d16ba5`,不改变配色/外观,也不表示更广的视觉重设计;自动检查不替代用户主观视觉审阅。 + +共享渲染器在大于 960 px 时让徽章栏按内容收缩,上限为 +`2 * --achievement-overview-width + 8px`;下一目标栏填满剩余空间,两栏间距 16 px, +避免单个徽章仍占用半幅空白栏。960 px 及以下直接上下堆叠,间距 12 px。 +辅助历史区统一放在两者之后。`achievementUiIcons` 为既有 +分享/设置/筛选/尺寸/编辑/信息本地化标签提供装饰性内联 SVG 图标,汇总计数使用 +紧凑标签。此次展示调整保留控件 ID、状态保存、资格判断及宿主/客户端消息协议。 +早期数据证据仍在调查;此布局调整不证明数据已修复,也不新增运行期/原生验收结论。 + +待补齐/已过期卡片详情通过 I18n 八语文案说明日期、模型归属、金额及当前来源的要求, +说明旁带信息图标;这是范围要求,不是精确根因分类,不暴露原始来源/错误详情, +也不赋予新的证据效力。 + +`extension.ts` 的提前返回 +仅当 `codexWorkerCancellationRequested` 为 true、结果为 `error`、 +`failureCode === 'cancelled'` 且严格满足 `result.diagnostic?.failedFiles === 0` 时生效。 +缺失计数仍是未知,继续走失败/证据撤销路径。零计数检查是防御性守卫:当前生产端 +在终止取消/抛错时因没有最终逐文件诊断而报告 `failedFiles: 0`,并不证明取消前 +没有文件失败。 +宿主忽略该取消结果,不应用快照、不记录成功时间、不生成新的接纳收据;保留先前失败状态 +及最后成功时间,待新的扫描成功后恢复处理。中断结果不授权新授奖,后续仅历史补认 +仍按 import-only 处理,不生成实时日期或提示。`codexProvider.ts` 将所有错误结果的 +清单标记为不完整。报告的非零/缺失失败计数、其他错误及非宿主拥有的取消仍走 +失败/证据撤销路径。假设取消结果报告非零计数的测试只覆盖宿主防御分支, +不是当前生产端提供的失败文件统计。 + +有效的周期迁移检查点在 `tokenEventsSeen` 尚未达到继承前缀时也可续跑,前提是 +`days` 为空。有界游标可跨批次、持久化/重启及取消继续推进。续跑本身不提升日期证据: +仍须通过完整前缀、EOF 及当前时区见证检查。已审阅回归覆盖分批、持久化、取消、 +EOF 反例及未变化热刷新读取零正文。无日期、超大行或冲突证据仍保守保持待补齐, +此次修正不表示所有缺失日期均已解决。 + 综合活动仅在明确的活动可视化中把 Claude processed 与 Codex processed 相加;Codex cached input 与 reasoning 子集不会重复计入,也不声称成本、额度、 能力或生产率等价。Claude 分享卡与 Claude 热力图仍只属于 Claude。本地预览/导出不联网; @@ -333,8 +946,10 @@ dashboard 不再因此一直标为「仍在索引」。 Claude polling 始终遵守 `refreshInterval`,file watcher 使用配置的 quiet debounce。 生产 Claude 路径维护内存 per-file 索引:unchanged refresh 的 JSONL body read 为 0, -append 只读已验证 tail,truncate/replace/move/delete 只重建受影响文件和 aggregate group。 -内容分析 contribution 与既有跨文件 response-identity 规则通过同一原子路径更新。内容分析维护 +普通已验证单文件 append 从 tail 和受影响 aggregate group 开始。内容分析开启时, +不安全 truncate/replace/move/delete、来源顺序或 UUID 所有权变化可能按下文触发更广的 +有序重放,不能保证只读变化文件。内容分析 contribution 与既有跨文件 response-identity +规则通过同一原子路径更新。内容分析维护 process-local 的已物化 accumulator,其 cutoff 与 `ClaudeDataLoader` 一样按毫秒连续滚动,而不是 用本地午夜近似。两个 loader 共用精确截止点 helper;完整 loader 只采样一次截止点,同时用于内容 分析与校准,避免解析期间时钟前移导致两者的窗口不一致。每个文件记录“已纳入 contribution 的最早 @@ -375,6 +990,9 @@ rank。时间戳相同的文件若 `discoveryIndex` 相对顺序改变,也属 debounce(默认 30 秒,可选 Off/10/30/60/120/300)。 Claude log、Codex log 与 Claude credentials directory watcher 都只作为 `fs.watch` 加速路径: 异步 watcher error 会关闭受影响 handle,并在 polling 继续可用时按有上限的指数退避重新挂载。 +Claude 目录发现使用独立于已安装 watcher 的 setup generation。同目录的重复 setup 不会 +使现有事件或错误回调失效;更新的目录查询会淘汰旧查询。关闭 watcher 会同时使待完成 +查询和已退役回调失效,避免失焦、关闭功能、切换来源或销毁后的迟到查询重新挂载。 如果失败的 credentials watcher 重试时 profile directory 暂时不存在,同一有界链只会在窗口聚焦且 额度跟踪仍启用时继续;目录重建后只恢复一个 watcher。只有 credentials watcher 接受操作系统省略 filename 的事件,因为这可能表示 credential file 被原子替换;该类事件仅以匿名计数进入诊断。 @@ -383,7 +1001,7 @@ Codex 按多 GiB 本地历史设计: - 发现与解析在 Extension Host 之外的 worker 中执行; - recent-first 索引,支持 progress 与 cancel;未完成回填最多使用可用逻辑 CPU 的一半, - 并把本地 file-pass worker 上限设为 6;索引完成后回到单一低功耗协调路径; + 并把本地 file-pass worker 上限设为 6(含压缩来源时最多 2 个);索引完成后回到单一低功耗协调路径; - 按顺序应用的 file-pass frontier 将执行中、缓冲中和应用中的结果总数限制在 worker 数的两倍, 6 个 worker 时最多 12 个。慢文件或 checkpoint 会让空闲 worker 等待,避免保留整批结果; 顺序、取消与续传行为保持不变; @@ -424,21 +1042,75 @@ watcher、worker、network request 与 backfill 的创建者,只在真实 stop 用户暂停与失败退避仍保留。刷新失败只记录固定白名单错误代码,不记录任意报错、 路径或调用栈。 +可选诊断 `tests/perf/measure-codex-worker-lifecycle.cjs` 使用新建的合成 home、 +索引和 lease 目录,运行已编译的生产索引/file-pass worker。三轮分别请求成就小时 +证明回填 ON/OFF,构造明确不完整的旧 period 投影,每组执行 20 次零正文暖刷新, +再在真实请求期间 dispose。这个参数是回填请求,不是完整成就/界面开关;旧版本 +可能没有实现它。诊断观察线程退出、线程共享的进程 RSS、按 isolate 采样的 heap/ +external 和 V8 序列化消息字节代理,不保留消息或 heap dump。冻结资源/时间保护 +只终止测试所属 worker;`--out` 可选择不可变编译版本进行对照。这些是 Node +诊断,不是原生 Extension Host、面板生命周期、多窗口或 Windows OOM 验收证据。 + +原生诊断启动另有可选安全守卫 `tests/native/isolation.cjs`:从测试所属根目录 +派生十个不同目录,显式包括 user/extensions/shared、agent 资料和合成 +Claude/Codex home。在任何设置种子写入前核验 Test 模式、Extension Host 的 +实际存储/配置路径与无符号链接逃逸;随后调用者才可在该测试 context 的 +globalState 中先关闭额度/建议相关网络入口,再激活生产代码。仅设置 +settings.json 不能控制目录中以 globalState 为存储的设置。写入失败即停止。 +纯 `nativeTestIsolation.test.ts` 不启动 Code;该守卫不是沙箱、凭据扫描器, +也不是原生激活成功或安装 VSIX/F5 的实测证明。测试文件继续排除出 VSIX。 + +tests/native/prepare.cjs 可在全新临时根目录中复制不改动的编译模块/assets, +并生成六份有界合成日志。测试专用 main 先执行真实 context 守卫,再激活生产 +扩展;原生 runner 使用真实 API 验证刷新/面板/dispose。显式 CLI 参数还选择 +内存 SecretStorage,并关闭最近目录记录、崩溃上报和实验功能。准备器单测不启动 +Code。这个小语料开发宿主诊断不代表最终 VSIX、像素、手动 F5、长测、多窗口或 +Windows 验收;正常实例前后状态核对和后台启动要求见辅助模块 README。 + 每周 API 等效价值历史只从已经聚合的 Token 用量生成。最新有效的重置观测用于锚定互不重叠的七天 展示窗口;没有观测时,仅已用历史按 UTC 周一至周一的自然周分组。真实的额度用量比例观测可以支持 历史窗口的总额度近似,而不只支持当前窗口。Codex 的 account-wide `codex` series 会纳入同一 home 中所有符合条件的本地文件,因为文件键不是账号身份。重置时间如果偏离七天网格,则按观测时间映射到 -对应展示窗口;来源不确定、重置漂移或日切片跨边界时降低可信度并标注为近似。真正不同的 quota -series 仍仅显示已用值;当前周期不显示未用值,历史周期只有在同时存在总额度近似时才显示未用值。 +对应展示窗口;来源不确定、重置漂移或日切片跨边界时降低可信度并标注为近似。 每条用量仍只进入一个窗口。 +每个有效周期内观测按“截至该观测时点的本地已定价用量”,而不是截至当前时刻的全部用量, +生成候选: + +```text +candidateFullEquivalent = pricedUsedEquivalentThroughObservedAt / usedFraction +``` + +只接受正的本地等价值和 `0 < usedFraction <= 1`。多个候选按定价覆盖、观测可信度/账号 +归属、边界标记和观测时点做加权中位数。当前周期即使账号归属不明,也可保留低可信本地估算: +把同一本地 home/窗口的合格 Codex 用量与最近适用的额度观测簇对照,不拆分为各账号事实。 +当前重叠/竞争系列强制低可信。只有多个不同 `seriesKey` 映射到同一已结束 Codex +展示窗口时才撤下总额/未用估算;单一未归属或带账号歧义标记的系列,结束后仍可保留低可信估算。 + +有有效汇总候选时,展示总等价值不低于当前已定价的已用等价值,未用等价值为两者非负差。 +若累计已知已用等价值已经超过汇总候选,则保留已用值作为低可信下界,撤下未用值, +不伪造零。这里比较的是累计值而非仅观测后的新增量,候选是加权中位数而非单个样本。 +无有效候选时只显示已用。这些数值是 API 等价估算,不是实付金额、官方账号余额 +或可继续使用多少订阅额度的承诺。它不是 `showWeeklyQuotaPace` 按已过时间比例计算的预测, +后者的可信账号/窗口规则不变。 + +维护者于 2026-10-11 明确保留这个现有当前周期估算方式。准确按账号归属计算属于后续 +多账户兼容范围,2.5.0 未实现或承诺该能力。 + v2.3.0 不推断 20、100 或 200 美元的订阅档位。本地 Codex 日志没有可靠的账号与套餐身份, 后续比较必须采用用户明确选择的 opt-in 账号映射,不能猜测后绑定价格。 ## 发布不变量 +- 默认 **Squash and merge**:开发期间保留本地提交及验证证据,最终每个 PR 在 `main` + 落为一个 squash commit。仅维护者显式例外可使用普通 merge;不为追溯应用此原则而 + 改写已发布 Git 历史。 - 根据变更风险执行 strict TypeScript、red-green TDD、完整 `node:test`、F5 smoke test 和安装 VSIX smoke test。 +- CI 的标准浏览器门禁使用四个隔离分片,各一个 worker、35 分钟时限。稳定的 + `UI Test` 汇总以 `if: always()` 运行,仅在全部分片成功时通过;失败、取消或跳过 + 任一分片均按失败处理。`Node Tests` 与 `Release Package` 检查名称不变,打包仍依赖 + Node 及汇总 UI 门禁成功。此配置本身不证明某次 CI 运行已通过。 - 用户可见字符串覆盖 `en`、`de-DE`、`zh-TW`、`zh-CN`、`ja`、`ko`、`pt-BR`、`id`; 九份 README(首页加八份语言版)同步。 - dormant 的 preparation/experiment 模块与仅供评审的 v2.3.1 文档必须保持不可从生产 command graph 到达,并排除出 VSIX。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3aef4786..60c24276 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,9 +4,12 @@ > semantics. Update it whenever module ownership or provider behavior changes. > A faithful Simplified-Chinese companion lives in > [`ARCHITECTURE-zh-CN.md`](ARCHITECTURE-zh-CN.md). -> The normative persistent-data inventory, retention, migration, clearing, and -> network boundaries live in the bilingual -> [`v2.3.1 local data contract`](docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md). +> The bilingual +> [`v2.3.1 local data contract`](docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md) +> is the historical field-level baseline. [`LOCAL-DATA.md`](LOCAL-DATA.md) is +> the current 2.5.0 inventory and versioned supplement for later fields, +> retention, migration, clearing and exports. Its explicitly later sections +> supersede only those subjects; unchanged baseline privacy rules still apply. ## Product boundary @@ -21,6 +24,19 @@ v2.4 phase 1 consolidates the existing sharing surfaces into one preview-first workspace. A presentation selector switches among the combined activity heatmap, the legacy Claude Share Card, and the Claude-only token heatmap; the full-width export preview always precedes its controls. +Version 2.5.0 combines Codex compressed-log compatibility +with default-on, local-only tiered achievements +(`enableAchievements`), a once-only upgrade opt-out, and evidence/performance guards. +It also integrates provider/metric heatmap sharing, a six-pin manual showcase and +default-off weekly quota pace. +This document describes implementation contracts, +not a release or acceptance record. Native/F5, installed-package and performance +checks require their own evidence; browser checks do not establish those results. +Source-level guards check source contracts; they do not establish executed UI +transitions, native Webview behavior or installed-package acceptance. +Codex effort statistics and cumulative-only monetary-origin receipts are integrated. +GitHub-linked achievements, personal reviews and visualization redesign are not +part of version 2.5.0. - Claude: exact local token buckets, model pricing estimates, and Anthropic OAuth 5-hour/weekly quota. @@ -29,7 +45,8 @@ full-width export preview always precedes its controls. structural optimization guidance. Known models also receive a clearly qualified API-equivalent cost estimate; it is never a bill or subscription charge, and unknown models remain unpriced. -- Compare: side-by-side compatible metrics only. It never sums provider cost, +- Share & compare (internal provider key `compare`): the navigation/page label for + sharing and side-by-side compatible metrics. It never sums provider cost, quota, or tokens into a misleading combined total. Full invoice reconciliation, driving either coding agent, background telemetry, @@ -44,8 +61,25 @@ only the separate publish action can perform that network operation. | Module | Role | |---|---| | `extension.ts` | Activation, commands, settings, provider lifecycle, refresh orchestration, watchers, status/webview wiring, and anonymous diagnostics. | +| `claudeApiClient.ts` | Existing Claude OAuth/quota transport and runtime one-way identity signal for the credentials used by the last successful request; pace reads this signal without a new request or credential lookup. | | `dataLoader.ts` | Claude parsing, validation, attribution, and content-analysis primitives retained for exact compatibility. | | `claudeIncrementalIndex.ts` | Production in-memory per-file Claude index: append-tail parsing, exact cross-file response deduplication, affected-group aggregation, content-analysis contributions, and materialized dashboard rows. | +| `achievements.ts`, `achievementSnapshot.ts`, `achievementCalendar.ts`, `achievementHours.ts` | Versioned rules and numeric token/day/week/hour evidence; `codexAchievementCalendarProjection` resolves full-census ancestry before selecting witnessed dates, keeping positive subsets separate from whole-census completeness. | +| `achievementActivity.ts`, `achievementIcons.ts`, `achievementShare.ts` | Allowlisted deliberate local-action enums, bundled emblem lookup and explicit self-contained badge SVG export. | +| `achievementGallery.ts`, `achievementOverview.ts`, `achievementRecordStore.ts` | Pure theme ownership/record projections, stable goals, automatic/manual showcase selection and the ≤64 KiB canonical v2 envelope; six ordered manual pins and shared v1 lease/migration fences. | +| `achievementUiIcons.ts` | Pure rendering of known inline SVG control icons; no dependencies, network access or additional asset files. Existing localized labels remain in the shared Webview renderer. | +| `achievementMoney.ts`, `achievementMoneyScopes.ts`, `achievementMoneyCatalog.ts` | Fixed USD reference-price evidence for six provider-specific themes; weekly Codex subsets require census-bound date proof and fully dated billable/model vectors. Pricing coverage is separate from date coverage and floating dashboard estimates. | +| `achievementMoneyOrigin.ts` | Runtime-only frozen-book increment validation, fixed-field copying/economic bounds and necessary-and-sufficient cumulative crossings; independent of Token origin and never persisted as index proof. | +| `achievementModels.ts` | Non-monetary model-label recognition for collaboration/rainbow and Claude families, independent of fixed-price aliases; recognition does not establish a remote model's identity or pricing. | +| `achievementReferenceBooks.ts` | Three explicitly published v1 reference-book labels for reading saved money records, separate from current-book selection and new-write authorization. | +| `achievementTitles.ts` | Shared public title helper removing a matching localized rank or its legacy alias only after a catalog middle-dot separator; IDs and pins are unchanged. | +| `achievementDetailPresentation.ts`, `achievementHistoryDates.ts`, `achievementCalibration.ts` | Exact shared-condition display, runtime-only per-level log-date reduction and guarded local incremental recalibration; no new persistent store or source parser. | +| `achievementEffortStatistics.ts`, `achievementCollaboration.ts` | Complete-current-census Codex root × effort statistics and existing joint tier criteria; optional `CodexAchievementSignals.effortStatistics` is cached by proof revision and rendered only inside the existing effort-breadth disclosure. | +| `apiHeatmapDaily.ts`, `pricing.ts#getCurrentEstimatePricing` | Read-only ≤366-date projection with per-projection memoized, in-memory selected-model/component rate admission; preserves current cost scalars, excludes generic unknown-model fallback coverage and adds no network/corpus reads. | +| `apiHeatmap.ts`, `apiHeatmapCopy.ts` | Whitelisted provider/metric configuration and canonical local SVG/Markdown artifact; Token default, explicit amounts, separate provider monetary panels and eight-locale artifact copy. | +| `quotaPace.ts`, `quotaPaceAdapters.ts`, `quotaPaceSource.ts` | Pure cycle-average calculation, ≤16-window numeric adapters and runtime account/window continuity owner using original successful quota observations; no I/O, timers or persisted history. | +| `providers/codex/codexHourProof.ts` | Bounded UTC positive-output hour proof tied to canonical files, verified offsets, current identity and lineage. Optional old-checkpoint proof migration never resets primary totals. | +| `providers/codex/codexAchievementOrigin.ts` | Runtime-only main-tail Token and frozen-reference-money lower bounds; known native per-event pricing, affected aggregate-difference admission, settled canonical lineages and fixed copied worker DTOs. No persistent index fields or extra source reads. | | `adviceEffectiveness/contract.ts`, `adapters.ts`, and `payload.ts` | Strict observation/evidence/recommendation/action/result contracts, privacy-rebuilding provider adapters, and canonical aggregate/personalization payloads. | | `adviceEffectiveness/preparedRequest.ts` and `remoteAdvice.ts` | One host-owned full HTTP request object, exact preview/send bytes, BYOK-only authorization, cancellation, and strict structured-advice parsing. | | `adviceEffectiveness/comparisonPairing.ts`, `comparisonResult.ts`, and `versionedPersistence.ts` | Sanitized comparable-task pairing, frozen measurement-version envelopes, and bounded local feedback/comparison storage. | @@ -56,17 +90,20 @@ only the separate publish action can perform that network operation. | `providers/codex/codexSchema.ts` | Minimal safe JSON guards; never flattens or returns message/command/tool bodies. | | `providers/codex/codexParser.ts` | Codex exact-request parsing with cumulative high-water fallback, pseudonymous lineage metadata, structural counters, quality flags, and last-observed limits. | | `providers/codex/codexManifest.ts` | Allowlisted Codex directory discovery, HMAC file keys, fingerprints, and manifest diffing. | +| `providers/codex/codexZstd.ts` / `codexJsonlScanner.ts` | Bounded header inspection and native single-frame Zstd decoding; the shared scanner keeps decoded byte coordinates and counts cancellable prefix replay as work. | | `providers/codex/codexIndex.ts` | Schema-3 persistent per-file numeric aggregates and replay evidence, bounded cold/tail parsing, independent aggregate/period/rolling-30-day-hour coverage, and atomic save/load. | +| `providers/codex/codexLineage.ts` | Packed lineage fingerprint contract and runtime-only iterative, memoized primary depth ordering; no recursive stack growth or new persisted fields. | | `providers/codex/codexQuotaHistory.ts` | Small account-neutral weekly reset-observation cache; compacts repeated local samples without retaining credentials, account names, or raw labels. | | `providers/codex/codexIndexWorker.ts` / `codexIndexClient.ts` | Background coordinator, recent-first progress, cancellation, resume, checkpoint persistence, and single-flight client. | | `providers/codex/codexFilePassPool.ts` / `codexFilePassWorker.ts` | Adaptive bounded local pool for independent per-file main, lineage, period, current-day, and identity passes during incomplete backfills. | -| `providers/codex/codexProvider.ts` | Extension-facing Codex snapshot facade and partial/unavailable/error outcomes. | +| `providers/codex/codexProvider.ts` | Extension-facing Codex snapshot facade and partial/unavailable/error outcomes; constructs runtime-only period witnesses from independent current contribution size/offset/prefix/zone, never from an incoming aggregate's witness. | | `providers/codex/codexUsage.ts` | Codex calendar-Today/hourly, 7-day, 30-day, monthly, task, and project view-model aggregation with exact-model API-equivalent cost. | | `providers/codex/codexInsights.ts` | Deterministic structural usage guidance; no prompt/body inspection. | | `codexView.ts` / `codexViewComponents.ts` | Codex localized-copy and default-provider contracts; no HTML renderer, client script, or CSS ownership. | | `settings.ts` | Canonical `SETTINGS` catalog and `SettingsStore`; ordinary values use configuration/globalState, while BYOK credentials use SecretStorage and never enter Webview snapshots. Do not scatter direct reads. | -| `statusBar.ts` / `codexStatus.ts` | Provider-specific status presentation and generic Claude quota formatting. | -| `webview.ts` | Single provider-aware Claude/Codex dashboard shell, shared render functions, shared client behavior, provider tabs, Compare presentation, and the unified sharing workspace. | +| `statusBar.ts` / `codexStatus.ts` | Provider-specific status presentation and generic Claude quota formatting; weekly-pace tooltip additions reuse shared I18n copy without changing bar text/custom formats. | +| `webview.ts` | Single provider-aware Claude/Codex dashboard shell, shared render functions/client behavior, provider tabs, Compare and sharing. It also currently owns in-host achievement observation/receipt/notice state; it is not a presentation-only module. | +| `dashboardRefreshClient.ts` | Shared live-patch anchor/focus restoration, including disabled-control rejection and stable summary fallbacks; no provider I/O. | | `i18n.ts` | All user-facing copy for all eight UI locales. | | `types.ts` | Shared extension and Claude contracts. | @@ -74,6 +111,13 @@ Existing pure modules such as `quotaFormat.ts`, `dateKeys.ts`, `shareCard.ts`, `heatmap.ts`, `conversationLog.ts`, and `miniMarkdown.ts` keep their current ownership and tests. +New calculations, grouping and eligibility rules belong in the existing pure +domain modules. `extension.ts` coordinates host lifecycle/I/O; `webview.ts` +integrates accepted results, existing receipts and presentation. Do not add a +second producer, renderer or corpus scan to implement a small feature. These +boundaries guide additions; they do not imply that mixed legacy methods have +already been extracted or require an unrelated large refactor. + ## Provider data flow ```text @@ -97,8 +141,23 @@ allowlisted Codex JSONL Claude aggregates + Codex scopes ──> one `webview.ts` dashboard render stack Claude aggregates + Codex scopes ──> side-by-side Compare (no cross-provider totals) +verified numeric provider evidence + deliberate allowlisted local actions + ──> versioned achievement rules ──> bounded local grants/peak ledger + ──> icon-first gallery ──> explicitly selected badge preview ──> local SVG export +verified new Claude/Codex append window + same frozen-book priced increment + ──> necessary-and-sufficient cumulative crossings ──> independent Token/money receipts + ──> guarded atomic recordUsage ──> first-record day/unread + one existing notice batch +complete current canonical Codex census + proof revision + ──> cached optional six-row effortStatistics ──> existing effort-breadth disclosure only + materialized Claude + Codex daily aggregates - ──> unified sharing selector ──> combined activity preview ──> explicit local SVG/Markdown export + ──> ≤366-date read-only projection ──> Claude / Codex / Both × Tokens / API-equivalent amount + ──> immutable accepted SVG + Markdown + opaque document-bound handle + ──> checked explicit local export (amounts never sum across providers) +existing successful raw quota + original observedAt + verified machine-local account HMAC + ──> runtime continuity owner ──> bounded numeric weekly DTO ──> cycle-average pace + ──> existing refresh callback ──> document/provider-bound dashboard patch + status tooltip +existing Codex local-log limits ──> account trust unknown ──> forecast unavailable materialized Claude aggregates ──> unified sharing selector ──> legacy Share Card or Claude token heatmap preview ──> explicit local export, or public-only exact-target GitHub publication for the Claude heatmap @@ -208,7 +267,9 @@ Codex 仅在同一数据目录内保留失败前的已验证统计;切换目 不能改写新任务。已完成且未变化的轮询复用视图,不制造回填进度或重复渲染隐藏面板。 The sharing workspace is rendered once: in Compare when both providers have -data, otherwise as a Claude All-time fallback. `enableShareCard` is the only +data, otherwise as a Claude All-time fallback. Codex-only All-time also exposes +it while achievements are enabled. The heatmap presentation itself supports one +provider or both. `enableShareCard` is the only visible sharing on/off control. The public command IDs `exportShareCard`, `exportHeatmap`, and `publishHeatmapToGitHub` remain registered for compatibility and open the matching presentation; they do not bypass preview. The retired @@ -221,7 +282,7 @@ exact revision; the host then removes the live intent without rewinding the revision history, so an acknowledged command cannot replay on a normal render, sharing reset, or dispose/reopen of the same provider. Reset Sharing Preferences uses the existing confirmed local-data action: the host sends a -request-id client action, the Webview verifies deletion of the eight allowlisted +request-id client action, the Webview verifies deletion of the twelve allowlisted localStorage keys, and the host reports failure (with its recovery tombstone) unless the matching ACK is true. @@ -303,10 +364,688 @@ credentials or make a network call to refresh it. ## Privacy and persistence +### Local achievements (2.5.0) + +Historical dates use a frozen 138-rule registration manifest. Each handler verifies +the exact ID, counter, target, group, rule version, series, base ID and monetary +scope; adding a rule or changing its interpretation cannot silently inherit a date. +The module-level signature covers actual and registered metadata. +Session reconstruction consumes only the canonical uncapped session admission, +not arbitrary positive-output records. Its cache uses weak record-array keys and +is explicitly retired on source/evidence changes. Changed logs require recalibration. +Calibration stages independently verified native activity receipts and preserves +their first-recorded dates even if the UI operation is cancelled or another source +fails. Only historical reconciliation stays quiet. Completed, partially saved, +cancelled and failed outcomes remain distinct. + +Gallery-wide expand/collapse changes only card entries in existing `openDetails` +local UI persistence; it does not select sources, grant awards or acknowledge unread +records. Bronze/Silver/Gold are localized display labels, including 铜/银/金 and +銅/銀/金. Legacy IDs, pins, reference pricebooks and thresholds are unchanged. +Conditions and units are consolidated only when exactly equal across levels. +`webview.ts` renders one shared achievement money-reference disclosure with the +current book/date per enabled provider and one non-billing note. Saved different +historical `referencePricebook` values are grouped with the exact associated award +titles/ranks; current book selection never rewrites those associations. Per-card +price/basis notes are removed while scope and pricing coverage remain. The current +statistic remains uncapped even at the highest rank. Missing/stale evidence stays +unavailable rather than being replaced by a target or a saved award. + +Expanded rank rows render names with per-rank theme tokens (foreground fallback in +high contrast) and bold, right-aligned thresholds on the same header row. Only the +next rank above the highest earned renders “In progress” when proof is ready; +later ranks, fully earned cards and pending proof omit it. Verified retained-log +dates render as bare ISO dates, with source interpretation once in Evidence details +and provenance in title/ARIA. If both dates exist, only the verified date is visible +and first-recorded details remain in title/ARIA; a first-recorded date alone retains +its explicit label, and unknown stays unknown. These are renderer-only changes: +award rules, date calculations, catalog and persisted records are unchanged. + +`root-portfolio` retains its IDs, thresholds and saved awards under the localized +name Independent root tasks / 独立主任务. `achievements.ts` builds `modelsByRoot` only +from verified root ownership and recognized model buckets with positive input +**or** output. `verifiedRootCount` counts each root tree once, including eligible +children, without asserting task completion or reading task content. + +The Share & compare label changes neither the `compare` provider key nor navigation +state ownership. Achievement sharing only adds basic equal-width checkbox options +with a separate rank line. The local-icon repair uses the same +`webview.cspSource` as the main dashboard CSP for bundled `asWebviewUri` resources; +it adds no remote image permission. `achievementShare.ts` embeds the fixed public +`https://github.com/ClaudeCodeUsage/ClaudeCodeUsage` anchor with no input-derived +destination, resource fetch, telemetry or personal metadata. Headings, footers and +rank/threshold labels wrap with reserved spacing in the existing grid; output +remains deterministic with bundled image data and the immutable preview/export seal. +Broad sharing-page visual unification/beautification and a durable usage archive +are deferred to the next version and are not implemented in 2.5.0. + +Per-level historical dates are derived annotations with `schemaVersion: 1`, bound +to `ruleVersion`, data timezone and actual catalog shape. Cache reuse also depends +on current source evidence, enabled providers and pricing context. Source, OFF, +timezone or price changes rebuild or withdraw annotations. This runtime cache is +not a new store: canonical v2 `recordedDay`, original unknown dates and unread state +remain intact. A displayed log date is the earliest verifiable date in retained +local evidence, including a verified subset; it cannot establish the account's +actual complete history. A saved first-record date remains a distinct fact. + +Automatic date evaluation reuses materialized daily rows for supported volume, +output, cache, model/family, activity and weekly rules. Codex admits strictly +verified, fully dated file evidence. Consistent with the existing evaluator, +active-day counts and streaks include today's positive activity and use that day +as their date; qualified family/day rules require a closed day and use day + 1. +Four-week windows exclude the first incomplete week based on the first positive +activity day, not an earlier zero-only row. These are date projections of existing +conditions, not rule changes. Only explicit manual recalibration reduces the +already-held, uncapped Claude record memory for output-session, Fable-session and +same-session Fable/other-family dates. Date reduction reads only materialized numbers +or usage metadata in held records, with no extra log-body scan, conversation-content +inspection or metadata export. Normal local incremental refresh may still read +allowlisted new/changed JSONL under existing analysis settings; it forces no full +reread and adds no scanner or network access. Ordinary warm refreshes never regroup +those records for dates. + +Joint-agent/effort conditions, hourly/night conditions, single-task monetary dates +and migrated local actions without timestamps have no supported historical date +handler. Keep them unknown or show their original first record; never fill today +as a substitute. Future rules require an explicit date-handler allowlist and rule +version isolation; new scopes default to unknown. Future catalog envelopes retain +the existing read-only, fail-closed behavior rather than being rewritten. + +Manual calibration is a local-only single-flight incremental operation. It adds +no OAuth/quota, online pricing, BYOK or other network request, forced full scan or +old-award clearing. While pending, a shared guard pauses historical grants and baseline +advancement while real native receipts are staged. The guarded commit reconciles history quietly, with +no new-award notices or forced grants. Busy work, an unaccepted current index census, +failure or pre-commit cancellation cannot be reported as success. The base index +requires an accepted current manifest and complete indexing; independent calendar, +pricing or subset coverage may remain partial. Verified partial history retains +lower bounds or unknowns; completion does not mean all award scopes are fully covered. +Cancellation before the historical commit adds no historical awards; +once the atomic commit completes under a valid guard, a committed flag records the +actual result, and a later cancellation neither undoes saved records nor reports +them as unsaved. No storage schema, source parser, +dependency or package-version change is introduced by these presentation features. + +The gallery has 50 display themes and 178 award conditions. `enableAchievements` defaults +on, but an existing opt-out wins; a once-only non-modal upgrade announcement offers +view/disable actions without opening the gallery automatically. Historical grants +establish a quiet baseline. Subsequent eligible in-panel notices have no sound or +focus change; night and 24/48-hour output badges remain silent. + +Twenty-four API-equivalent conditions belong to six separate Claude/Codex cumulative, +completed-week and single-session/task themes. `achievementMoney.ts` freezes +`api-reference-2026-10-{backend}-v1`, dated 2026-10-09; exact decimal components +sum before flooring to microUSD. Exact models and complete rates, including known +Claude cache-write TTL, qualify; unknown models remain unpriced. Verified priced +subsets prove only lower bounds. Week scopes use completed Monday-start weeks in +the current data zone; single scopes require one Claude session or an independently +verified Codex task tree. Historical recognition is quiet. Cumulative monetary +crossings now use verified live append receipts; completed-week and single-session/ +task monetary scopes still reconcile quietly with unknown dates. Dashboard pricing/ +display currency does not alter these rules. + +Both `claude-api-week` and `codex-api-week` now contain five ranks: Bronze $100, +Silver $500, Gold $2,500, Platinum $10,000 and Legendary $25,000. Only the four +Platinum/Legendary IDs are added; the existing three IDs/targets, series ownership, +artwork, completed-week boundary and frozen-price provenance remain unchanged. +Their four explicit history registrations bring the manifest to 138 entries; a +supported log-derived annotation is still separate from a saved first-record date. +Legacy envelopes reject the new IDs, and the pre-expansion reader fails closed +without rewriting or clearing either envelope. Schema/catalog versions and the +65,536-byte cap stay unchanged; the reported maximum-envelope fixture is 37,374 +bytes. These synthetic checks do not establish native/F5 acceptance. + +`achievementModels.ts` keeps non-monetary collaboration/rainbow recognition +independent of `achievementMoney.ts`'s fixed pricing aliases. Claude family labels +admit only the explicit `global`, `us`, `eu`, `jp`, `apac` and `in` Anthropic prefixes; +arbitrary prefixes such as `xx` are rejected. Recognized Codex named-family labels +may still be unpriced; recognition adds no rate or pricing alias and is not proof +of upstream routing or availability. + +Gallery counts use the enabled-provider series and level denominators separately. +Before those counts render, record loading, unavailable, changed and disabled +states use explicit eight-locale status copy instead of an apparent empty `0 / 0` +collection. A verified Claude cumulative subtotal is shown as a `≥` lower bound +while an enabled Codex source remains unverified; unknown Codex usage is not exact zero. +Canonical series keys own disclosure/focus restoration across tier changes, with +aliases for older level-specific keys. All/Next/Earned filtering uses only a local +UI enum; it never schedules provider work. A safe provider enum and one-shot focus +intent return from sharing to the originating gallery. A compact next target precedes the +expandable all-tier rules. The settings shortcut focuses the existing opt-out. +The catalog supplies 52 artwork groups with distinct bundled raster artwork. Tiers share +their series emblem; level color/labels provide the progression. Gallery numbers +are floored integer abbreviations, never rounded up to an unmet target; full +integer progress remains in tooltips and accessibility text. Detailed data caveats +are in a persisted, initially collapsed disclosure. Embedded export images are +cached by filename rather than repeated for every tier ID. + +`achievementGallery.ts` is a pure display-ownership map, not a rule or ledger +migration. Fourteen-day (`streak-machine`) and first shared-day (`dual-provider-day`) +milestones live in their canonical growing themes. Every formal rule appears once +in details; milestones keep their own icon, status and condition without an extra +tier pip. A milestone-only award is earned but has zero earned formal tiers. +Overview/recent/disclosure aliases use this map. Saved pin keys remain exact; old +duplicate pins are individually removable in the same card. Sharing, grant IDs, +dates and unread batches are unchanged; rendering neither writes nor reads logs. +If a legacy unpin control disappears, the existing transient focus fallback +returns to the canonical theme summary, then the achievement tab if unavailable. +Surviving control IDs still take precedence; no persistent focus state is added. + +The exploration card details project the existing three safe feature enums from +committed `achievementActivity`, in their catalog order, not an action timeline. +Each step says recorded/not recorded; neither historical awards nor pending writes +supply missing facts. Advice feedback and setting changes remain separate rules. +This display adds no controls, state fields, reads, events or evaluator changes. +Its bundled `safe-feature-journey-v242.png` replaces the misleading “10X” lookup; +the old asset remains untouched. Gallery, notice and local SVG use the same map. +Notice motion is CSS-only: one 440 ms opacity/scale reveal on the existing emblem, +not the notice root or text. Reduced motion and both native high-contrast body +classes disable it entirely. Existing delivery, cooldown, dismissal and ACK +protocols are unchanged; no new timer, persistence, network or replay is added. + +`achievementGallery.ts` allowlists same-provider current model/token destinations. +The shared renderer requires existing materialized data and a matching family for +model links; shared/calendar/action badges have no substitute totals link. The client +resolves only fixed DOM destinations from current rendered buttons. One public theme +key and provider enum in existing Webview UI state preserve the statistics return; +special navigation retains disclosures/scroll, while ordinary tab/provider changes +retire the intent. No host command, log scan, award evidence/date or share consent is added. + +`achievementStages.ts` adds display-only 1/2/5 integer nodes between formal +Token tiers and within one decade before a Token entry tier, never after the last. +First-tier stages require positive, ready progress; monetary entry tiers stay unchanged. Closed cards show current +progress toward the next formal level and, where applicable, the next intermediate +stage. Pending/stale proof has neither. Counter/setting exploration is excluded; +formal IDs, thresholds, award counts, notifications and share choices are unchanged. +Nodes are derived only from the fixed catalog and current aggregates: no writes, +history scanner, timer or additional permission. + +`achievementOverview.ts` selects at most three earned representatives and three +positive, current goals from already-collapsed enabled-provider rows. Goals use +the next display-stage fraction, with canonical ties and distinct topics; rank +and current-provider ties guide representatives. Night/hour goals are not +promoted. Selection keys stay in host memory for a visit, reset on entry/provider +change/manual refresh/OFF/clear, and never become share choices. Live changes +update values and remove invalid goals without replacing or reordering them. +Overview buttons open the existing persisted series disclosure; they do not +read logs, issue refresh requests or persist a second aggregate index. Pins and +record projections and the foreground missed-notice summary use the bounded +canonical envelope below without creating a second award store. +Next-target overview cards use the target's name, rank label and rank color; +earned gallery cards keep their earned rank. `achievementTitles.ts` is shared +with SVG export so a matching localized rank suffix or its legacy alias after a +catalog middle dot is not repeated beside a separate rank label; other title text +and unranked milestones remain intact. + +`achievementLongTerm.ts` appends 30 fixed ranks to 15 existing sustained Claude/ +activity series. Their first three IDs, thresholds and evidence remain unchanged; +new IDs reuse their explicit base rule's counter/art and locale-native name/rank. +No consecutive-day tiers or scanners are added. Family days now share the activity +topic, so one automatic slot need not suppress an independently earned model-volume +badge. Historical IDs and v1 series-pin vocabulary stay frozen, independent of live +series membership. Additive IDs cannot be imported as v1 proof; current v2 accepts +old 144-record envelopes and up to 178 current records under the unchanged 64 KiB cap. An +old binary may reject unfamiliar IDs but must not overwrite the canonical envelope. +Higher output/cache tiers use the same causal native-tail checks; session/calendar +reconciliation remains quiet with unknown dates. Explicit share choices never +auto-upgrade to a higher rank. + +`codexAchievementRevision.ts` uses a runtime v3 digest binding the full census, +independent indexed/global-calendar/timezone fences, contribution witnesses and +period cursors/day vectors. Cursor-, witness- or fence-only changes retire +calendar/Money caches even when displayed counters match. The digest reuses the +existing hash/cache path; it adds no I/O, timer, store or persistent index field. + +The provider rebuilds the current full census and independent witnesses before +comparing them exactly with the preceding publication. Only equal, detached, +deeply immutable copies registered by `immutableCodexAchievementFiles` can reuse +revision and calendar-projection memoization under the same independent fences. +`indexChanged`, zero body reads, equal totals or a caller-supplied digest are not +substitutes for this comparison. Mutable or malformed inputs take the ordinary +validation path; worker-owned aggregates and older publications are not mutated. +Money-scope binding reuses the internally validated projection instead of repeating +the same calendar computation, without trusting an arbitrary supplied projection. + +A synthetic 20,000-file × three-date benchmark, including snapshot construction, +measured median warm evaluation of about 348 → 109 ms and changed evaluation of +about 2,110 → 1,460 ms. Changed/append work remains roughly 1.5 seconds and O(files) +in this case; even reuse still requires full-census comparison. These local +measurements exclude worker parsing, file discovery and dashboard rendering. +They are not end-to-end refresh, native responsiveness or general latency guarantees. + +`codexAchievementCalendarProjection` requires a verified current manifest and +complete base index. The provider constructs each runtime-only witness from the +contribution's own size, offset, expected lineage prefix and zone: EOF must match +the period cursor, prefix/zone must match current period semantics, and period migration, +oversized-line discard and lineage reconciliation must be absent. Incoming +aggregate witnesses are stripped; failed refreshes cannot reuse them as authority. +The projection resolves ancestry over the full census before selecting dates, +excluding conflicting branches and descendants. Sound positive dated components +remain `verified-subset` lower bounds for activity, family and weekly Token rules, +even with undated residuals or an incomplete global calendar fence. A subset never +proves complete/zero history, a four-completed-week mean or a retained calendar peak; +dual-provider date rules also retain their existing full-proof fence. + +`codexAchievementMoneyScopes` obtains the validated projection of the original +full census, reusing it only for the immutable snapshots described above, and checks +`sourceRevision`, completeness/scope and numeric day/model signatures before +selecting fully dated files. Each selected file must cover every billable counter +and model/date vector exactly; Token containment alone is insufficient. A positive +completed-Monday-week amount from this subset is a lower bound with partial +coverage and runtime `scopeLowerBound`; partial zero-only/empty remains pending. +Positivity uses exact units, even when integer microUSD floors to zero. +Pricing coverage is independent of date/census coverage: unknown models remain +unpriced and overflow remains unavailable. Cumulative and task scopes are unchanged. +No prices or rules are added; the existing eight-locale evidence caveat is reused +with updated wording for unverified Codex lineage or dated usage. The footer also +shows it for date/weekly-money scope bounds when identity coverage is complete. +Witnesses/revisions carry no raw identity into rendering, export or persistent index state. + +The overview uses bounded horizontal cards rather than stretching a few icons +across the desktop. In-card pins remain separate buttons; next-target bars are +decorative companions to labelled numeric progress. Existing size choices, artwork, +theme tokens and persistence routes remain. `modelRainbowNextEligibility` +excludes a NEXT tier above enabled-provider family capacity (Claude four, Codex +three), without changing formal conditions, pins or gallery visibility. + +Positive combined calendar rules have a bounded runtime set of per-rule lower +bounds derived from independently validated provider days. Active days, streaks, +active weeks, weekly Token peak and a proved three-family day can show `≥` while +another provider is incomplete. Zero partial counts remain unknown; shared dates +and four-week averages retain full-proof fences. This does not reopen the combined +notice latch or invent dates for imported grants. `normalizeClaudeNativeUsage` +restores a missing flat cache-creation count only from both explicit safe known +TTL components, without mutating the source. Full and compact/incremental reducers +share it; contradictions and unknown TTL fields remain excluded from money proof. + +With achievement proof enabled, a compatible legacy Codex period without current +lineage markers enters the existing bounded period migration, rather than staying +a warm no-op forever. All-time proof coverage remains incomplete until canonical +files are revalidated; readable compatible slices remain available for ordinary +display. Persisted cursors support cancellation/resume and completed proof returns +to zero-body warm refresh. OFF skips this additional revalidation. + +`achievementEvidence.ts` classifies rules into nine independent runtime evidence +domains. Ready, pending and stale proof is separate from saved award identity: +pending/stale cards retain earned tiers but omit numeric progress and are excluded +from Next filtering. Each domain establishes a quiet baseline on first/recovered +proof; incomplete calendar proof does not suppress subsequent verified Token +awards. Provider retirement resets only affected domains. Once a usage domain has +quietly reconciled calibrated history, later levels are no longer permanently +silenced by the migration recheck set. These latches are memory-only; they do not +yet constitute a durable missed-notice journal or cross-host coordination. + +The Claude index has a runtime-only tail-origin contract. An explicit +accepted-observation window is copied before asynchronous work; existing physical +file/prefix/EOF verification supplies offsets before content-analysis plans may +expand. After global response deduplication, only new canonical winners beyond +those offsets, with safe native token vectors and timestamps in that window, +contribute to a fixed total/output/cache-read/cache-creation/four-family numeric lower bound. +Cold builds, discovered imports, replacements, incomplete tails and timezone +retirement do not supply live origin. Mixed history retains only independently +verified additions. No source identities enter the result, no index field is +persisted, and no additional body read or timer is introduced. The rule helper +uses the existing formal targets and requires these additions to be sufficient +against the previous progress and necessary against the new progress, per exact +level; calendar/session/agent rules are not inferred from volume. The host now +captures the canonical award fence before parsing and commits the accepted index +before handing off existing materialized references, independently of rendering. +Four-family, output, cache-read, cache-creation and cumulative crossings use atomic +usage awards. Cumulative monetary proof follows the separate path below; other +usage rules remain quiet pending their own origin proof. Claude's +creation, read and output components must be safe integers with a sum no greater +than processed volume; cached creation is counted once in the native vector. +Explicit rebuilds never provide a live window. Retiring only presentation does not +discard a sound source commit; source/failure/clear/OFF/timezone/disposal guards do. +Display-zone volume receipts do not relabel calendar evidence from another zone. + +Codex now has the corresponding producer/worker foundation. Only established +physical-file appends from a complete prior base index receive a copied explicit +observation window. The normal main parser accumulates input-plus-output, +output, cached-input and three-family numbers within that window. After duplicate +and lineage reconciliation, both snapshots must verify the same physical/session +ownership and settled inherited prefix; each admitted field is bounded by that +file's actual aggregate increase. An unrelated identity conflict excludes its +branch, not sound additions. Cold/imported/replaced files, incomplete tails, +changed timezone, counter regression and unsettled prefixes provide no live proof. +Main file-pass workers, coordinator and client rebuild the fixed numeric DTO; +foreign windows/requests, duplicate task replies, recovery/adoption and cancellation +cannot supply it. Final atomic-save failure never delivers a successful result. +Proof is ephemeral and warm zero-body refreshes never replay it. The per-level +helper uses existing approved targets with the same necessary-and-sufficient +volume test; it does not infer calendar, effort breadth, sessions or agent awards. +The Codex facade copies the window before asynchronous work and returns only +matching successful proof. Before each observation the webview owner rereads the +canonical award generation/sequence under the shared lease. The extension hands +off accepted snapshots without rendering, including when dashboard updates are +paused. Separate accepted references feed the evaluator without changing the +displayed snapshot. Source, setting, timezone, clear and disposal guards retire +proof; an unrelated Claude failure cannot erase sound Codex additions. The fixed +Token crossing set commits first-record day/zone and unread state through +`recordUsage`. Only selected history rows written after the accepted baseline +sequence may be superseded; older history, action provenance and existing usage +remain unchanged. Superseding advances sequence, fencing old display ACKs. +Failed writes retain at most the catalog's ID count of runtime receipts for an +ordinary-refresh retry; retired receipts release their source guards. No new +index fields, body reads, watcher or timer are added. Foreground notice delivery +uses the existing shared cooldown and ACK guards. Claude uses the same bounded +receipt queue and lease; either provider's failure retires only its own proofs. +Other usage scopes still reconcile quietly pending their own origin proof; +cumulative money uses the independent runtime path below. + +`achievementMoneyOrigin.ts` admits only the four cumulative monetary tiers per +provider, and only when before/after full-scope evidence and the new increment use +the same frozen book. The admitted +increment must both suffice from the previous total and be necessary to the new +total reaching the existing target. Claude's normal parser/index includes only +additional accepted response identities with complete reference-price TTL evidence; +two fixed Claude backend books are supported. Codex's real worker/facade path prices +known native events under the OpenAI book and bounds each admitted candidate by the +affected file's priced aggregate difference. No accepted window or achievements OFF +means no extra monetary-origin work. Primary parser accounting is unchanged. + +The optional runtime money proof contains only provider/backend/public book version, +an exact-unit decimal string of at most 64 digits, microUSD and eligible processed +Tokens. Fixed-field reconstruction at IPC boundaries and a frozen-book maximum +per-Token-rate economic bound reject invalid proof independently of Token proof. +The new monetary-origin amounts, exact units, eligible-Token counters, raw event/source +IDs and proof are never written to the index or award file; only the public +`referencePricebook` version accompanies a money award's ordinary record metadata. +Existing usage aggregates retain their documented storage contract. Price evidence +is always the frozen reference, never current floating prices. + +The host keeps bounded independent money receipts behind source, identity, scope, +date/timezone, clear-generation and feature-OFF guards. It rechecks the current book +and qualified full cumulative scope at the lease commit. A money-only book/scope +invalidation withdraws money without cancelling an independently valid Token receipt; +the common source/index, clear, OFF and disposal guards still apply to both. In +particular, a Claude backend change also invalidates the index generation and can +retire pending Token proof. Failed saves retain retryable +runtime proof and never display an optimistic date. `recordUsage` saves origin +`usage`, first-record day/zone and unread state atomically. Only same-book peer history +written after the accepted baseline sequence can be repaired with a new sequence; +different-book or established history keeps its original basis/date. Token and money +writes from one accepted update coalesce into one existing five-minute notice batch. +No timer is added; hidden-panel/unread-summary behavior is unchanged. Completed-week, +single-session/task and other unsupported live usage scopes stay quiet with unknown dates. + +`CodexAchievementSignals.effortStatistics` is optional evidence from a complete, +current canonical index census. `extension.ts` computes it only when the proof +revision changes and reuses the same signal object/helper result on unchanged warm +updates. OFF skips the helper; source/config replacement or incomplete indexing +withdraws evidence as unknown, not six fabricated zero rows. The pure helper pools +non-approval-reviewer root × effort populations and admits only each population's +own positive output. Six rows (low/medium/high/xhigh/max/ultra) appear only inside +the expanded existing effort-breadth card, using its next existing tier's three +joint criteria: + +| Existing tier | Processed Tokens per effort | Output Tokens per effort | Qualifying roots per effort | +|---|---|---|---| +| Bronze | 100M | 100K | 5 | +| Silver | 500M | 500K | 10 | +| Gold | 2B | 2M | 30 | + +Row progress is the minimum of the three criterion fractions, capped at 100%; +renderer and evaluator share `EFFORT_PROCESSED`, `EFFORT_OUTPUT` and `EFFORT_ROOTS`. +The informational subtotal/coverage is fixed USD under +`api-reference-2026-10-openai-v1` (2026-10-09), independent of display currency; +it is not an effort-qualification condition. +Separate model and effort marginals cannot establish a model × effort cross-tab; +there is no ratio allocation. A single-model or single-effort full pricing-component +reconciliation admits a known priced subset; remaining Tokens stay unpriced. +Empty, unpriced, partial, invalid and overflow states remain distinct, with unsafe +money withheld. The DTO exports no identities/model labels/exact-unit strings, +stores no history and adds no rules, thresholds, navigation or preferences. Current +Claude logs supply no effort attribution for this surface. + +Plugin-action provenance compares the committed before/after activity ledger +through the canonical rule evaluator. Only newly crossed plugin targets enter +a bounded six-ID runtime set; hydration and imported ledgers do not. Successful +grant persistence consumes the IDs; failed saves retain retryable proof, while +OFF/clear retires it. Local action awards are evaluated after a successful ledger +save even without a visible/ready panel, without rendering hidden views. Queued +usage IDs still check the provider-evidence epoch; plugin IDs check the separate +clear/activity epoch so provider revocation cannot erase independent action proof. +This adds neither storage fields nor timestamps and is not cross-host coordination. + +`achievementRecordStore.ts` now owns the canonical `achievements-v2.json` award +envelope in extension global storage, separately from those runtime latches. +It validates at most 178 current unique rule IDs, monotonic sequence/generation, reason +enums and optional first-record day/zone within 64 KiB. Audited old grants seed +only a missing canonical file. The v1 file is read-only migration input outside +explicit clearing. Versioned migration archives saved awards from the 21 changed +model/team/effort/rainbow conditions; legacy records are capped at 48 and never +prove current rules. Fixed-price award rows may retain a public reference-pricebook +version, never a personal amount. + +Reading saved monetary rows accepts the provider's current books and the three +explicitly published v1 labels in `achievementReferenceBooks.ts`: Claude +`api-reference-2026-10-anthropic-v1` and +`api-reference-2026-10-aws-bedrock-in-region-v1`, and Codex +`api-reference-2026-10-openai-v1`. Retained labels are not rewritten when current +book selection changes. New `append` and `recordUsage` drafts require a current +provider book; readable historical labels alone cannot authorize new grants. +This is an explicit published-history registry, not a pattern-based future +whitelist, and adds no prices, IDs, storage fields or schema. An unknown non-current +book fails closed; its envelope remains untouched, including by Clear All. + +Schema/catalog versions, automatic/manual mode, ordered pins, pin revision and a +bounded legacy-file boundary share the same envelope. +The existing v1 lease coordinates both files; Clear All commits a v2 tombstone +then an empty v1 downgrade fence, with interrupted clears resumed under that lease. +Old Gold is not evidence for recalibrated rules. Migration preserves unknown +dates; an existing canonical file wins over +stale globalState. Each transaction rereads under the existing filesystem lease, +flushes a private temporary file and atomically renames it. Concurrent hosts do +not overwrite peers' awards. Clear holds that lease while deleting exact related +globalState keys, then saves an empty generation/sequence fence; old hosts fail +closed until reload. Action/hour/calendar writes check their adopted generation. +Unknown, future, oversized or symlink inputs remain untouched and do not fall +back to a legacy grant mirror. Disabled achievements do not initialize the file. +Actual saved local actions retain unread records even when hidden or delivery +fails. Proven Claude/Codex Token crossings and same-frozen-book cumulative +API-equivalent crossings also save unread first-record days through independent +receipts. Session/task and calendar/structure awards without their own verified +new-activity origin still reconcile quietly with unknown dates. Ready-domain +growth or price-only changes do not establish producer-origin evidence. The envelope also owns +the existing bounded activity/hour/calendar proof: activity unions the 29 safe +keys/four enums, hours retain a maximum ≤48, and calendar maxima merge only within +the same measurement context. A Claude-only retained peak ignores unrelated Codex +parser/period versions; rules, timezone, provider-presence and complete-current +evidence fences still apply, and any context including Codex still checks both versions. +Legacy auxiliary Memento entries are migration inputs, not runtime mirrors. +Hydration always adopts canonical proof, including +empty proof after clear; a bounded 33-key pending-action map replays deliberate +bootstrap actions only after hydration. This avoids restoring stale whole-object +Memento caches. Plugin provenance compares the actual under-lease before/after +activity, not another host's imported progress. A deliberate action and its newly +crossed plugin awards commit in one transaction, so peer history reconciliation +cannot preempt their origin/date/unread state. Live notices reserve a shared five-minute +cooldown under the same lease without marking records read. The existing native +window-state owner supplies foreground eligibility; panel visibility/readiness, +OFF/clear generation and document replacement fence delivery and ACK. A runtime +opaque token binds display confirmation to only the represented IDs and maximum +issued sequence. Other or later unread records survive. False/rejected/slow delivery +does not block award writes or clear. The client confirms only after visible insertion, +without focus theft, and pauses its six-second dismissal during hover/keyboard use. +Lost batches never replay as live notices. A foreground unread summary now provides +explicit bounded confirmation; unsupported live usage scopes remain pending. Sharing +consent is still explicit. + +`showAchievementNotices` is a default-on local presentation preference alongside +the full achievement switch. Disabling it does not retire producer evidence, +stop grants, delete unread records or spend a notice cooldown. A separate runtime +preference epoch retires queued reservations and ACKs, including rapid OFF→ON; +the visible client dismisses immediately and rejects stale notices until a saved +host render enables them. Re-enabling never replays missed popups. The settings +route updates only dashboard presentation, without restarting providers or scans. +This opt-out is excluded from the plugin-action achievement allowlist. + +`achievementOverview.ts` now prepares read-only record projections: at most three +recent non-historical series, thirty recording-order rows and exact visible unread +level IDs. Recording order is not inferred attainment time, and projection eviction +does not remove awards. Automatic mode recommends at most three earned themes; +manual mode shows only the chosen pins, up to six, without automatic fillers or +changes to next goals. Closed-card pin controls and a searchable editor save +selection/order/mode under the shared lease. Bulk selection/reordering checks the +current pin revision so stale windows cannot replace peers' choices. +`setPinned` applies one idempotent intent under the +filesystem lease, rejects unearned keys, and never exceeds six slots or overwrites +a peer's unrelated pins. The record projection is now rendered with up to three +recent series and a folded thirty-row history. Each row keeps the exact public +level and first-record day/zone or an explicit historical unknown-date label; +clicks only open existing rules and never acknowledge records or select sharing. +The disclosure survives replacement. Earned badge details send one generation-bound +pin/unpin intent, accepted only by the ready foreground panel while achievements +are enabled. After a successful save, existing dashboard patches hydrate pressed +state and the showcase without modifying next goals or sharing. Stable control IDs +preserve focus/anchor; save failure preserves the old preference and uses fixed +localized inline feedback. A render failure is not reported as a failed durable save. +Disabled-source guards apply to pin acquisition, not to deliberate cancellation +of a known public key. A folded list exposes at most six hidden saved pins; +canceling one preserves peers' unrelated preferences under the same lease, without +provider activation, log reads, awards or read acknowledgement. Error dispatch +matches existing data keys, including hidden controls, without constructing selectors. +Removing a focused hidden-pin control uses up to two transient, existing DOM +focus descriptors: the management summary, then the achievement tab if the list +disappears. The surviving target supplies the scroll anchor; no preference or +client state is added. Acquisition rechecks source availability when the queued +transaction executes, not only when the message arrives. + +The ready handshake now reports the actual restored provider/tab. The host accepts +only its current provider and allowed panels (compare has only `all`); ordinary +tab messages use the same allowlist. Restoration does not count a feature use, +change provider or grant/read an award. The unread display preparation selects at +most six exact non-historical unread levels from the complete bounded record +envelope, independently of the thirty-row history view. An unchanged represented +group retains its IDs and sequence when a peer adds an award; hiding, reading or +replacing a member retires the group. This pure projection copies rows and never +writes receipts. The folded New awards UI binds that group to an opaque runtime +summary token and document identity. Only an explicit confirmation from the same +ready, visible, focused achievement panel can enqueue a receipt. The host uses +its own exact IDs/maximum sequence, never client-supplied IDs, and rechecks enabled +sources, provider, clear epoch and packet identity at the lease commit boundary. +The fresh shared-file merge preserves peers' other and later unread awards. +Failure sends only fixed localized feedback; success adopts saved state before +patching, and a later render failure cannot pretend that durable save failed. +Group removal restores focus to its summary or the achievement tab. Viewing, +pinning and sharing do not read it. No additional timer, scan or persisted field +is introduced. Full document replacement, including patch-failure fallback, +renews the runtime document identity and retires old ready/tab/receipt messages; +ordinary data patches keep it. Blur/OFF/clear/disposal/provider/tab changes retire +group ownership rather than replaying missed notices. + +就绪握手报告实际恢复的供应商/页签,宿主只接受当前供应商的合法页面;对比页 +只接受 `all`,普通切页也使用同一白名单。恢复不计作功能体验、不切换供应商、 +不授奖或确认已读。未读展示的准备投影从完整有界封套选择最多六个精确非历史 +等级,不受三十条历史显示上限影响;其他窗口新增不扩大既有组的 ID/序号。 +成员隐藏、已读或被替换后重新选组。只读投影复制记录,不写确认。 +默认折叠「新增 N 项」使用运行期匿名组句柄及文档身份;只有同一就绪/可见/聚焦 +成就页的明确「标记本组已读」才排队保存。共享 lease 提交边界再次复核来源开启、 +供应商、清除代次及组身份,仅确认宿主保存的精确 ID/序号,不接受客户端任意 ID。 +重读合并保留其他窗口后来新增;失败仅给固定本地化反馈,保存后的渲染失败不误报 +保存失败。移除组后焦点回到摘要或成就页签;浏览/固定/分享不确认。 +没有新增定时器、日志扫描或持久字段。完整文档替换及补丁失败回退更新文档身份, +拒绝旧页面就绪/切页/已读消息;普通数据补丁保留身份。失焦/关闭/清除/销毁/ +切换供应商或页签退役组所有权,不重放错过提示。 + +关闭供应商只阻止新增固定,不阻止明确取消已保存的公开键。折叠入口最多列出六个 +隐藏固定项,共享 lease 下仅移除选中项,不启用供应商、不读日志、不改变奖章、 +未读及分享;错误反馈匹配既有数据键,兼容隐藏项按钮,不拼接任意选择器。 +取消按钮消失时,焦点优先回到管理组摘要,列表消失则回到成就页签;最多两个 +既有 DOM 描述符仅存于运行期,保留幸存锚点,不新增偏好或客户端状态。 +排队保存前也复核供应商是否仍启用,避免请求发出后关闭供应商仍新增固定。 + +Explicit badge sharing can include retained earned records from a disabled provider. +The gallery and pin acquisition filter by enabled providers; retained records supply +neither current usage progress nor new grant authority. Sharing does not re-enable +the provider or read its logs. + +Badge sharing keeps one accepted SVG plus an opaque runtime-only handle. Client +token/request counters reject superseded replies; a response may remove unavailable +selected IDs but never add badges. Restore reuses matching accepted bytes. Export +requires the same handle, ordered IDs and display language, and captures those bytes +before its single-flight save dialog. Success, cancellation and anonymous write +failure are acknowledged in the panel; a later language change cannot mutate an +already-started save. Removed choices are explained, never silently upgraded. +Replacing, clearing, disabling or closing the panel retires the retained +artifact. Build failures retire the old handle and return only an anonymous flag. + +The legacy globalState ledger holds allowlisted rule IDs, 29 safe setting-key names +and four local-feature enums, a streak maximum capped at 48, and one qualified +four-week-average numeric maximum. It stores no setting values, account/source +identifiers, prompts, credentials, timestamps of actions, or export history. +Audit and recoverable migration state is exact-allowlisted in Clear All. +See [the complete inventory](LOCAL-DATA.md#local-achievements-250). + +Claude materializes session-achievement evidence before the Sessions tab's +1,000-row display cap. Flat `projects//agent-*.jsonl` files have no +path-proven parent and are excluded only from that session-achievement evidence. +Record `sessionId`/`isSidechain` metadata is not promoted into an ownership contract. +Token totals, pricing, daily aggregates and visible session rows are unchanged; +nested agents keep the existing parent-directory contract. This runtime-only +filter neither changes source logs nor removes cached or saved awards. +Codex positive token/model/agent milestones require complete current manifest +indexing and independently verified canonical lineages. Identity conflicts block +only affected branches and descendants, not unrelated positive proof. Selection +and root resolution are iterative/memoized; invalid vectors, orphan/cyclic/unknown +roles and unsafe sums fail closed. Calendar selection uses the full-census ancestry +and runtime-witness contract above; only whole-census completeness authorizes zero, +four-week means and retained peaks. Positive-output UTC hours piggyback on normal parsing; existing +indexes use bounded checkpointable proof migration while preserving primary +totals. OFF suppresses that extra migration, not ordinary usage parsing. +The host keeps a separate runtime-only SHA-256 achievement proof revision: +canonical identities, six Token counters, model/effort buckets, period lineage +markers/cursors, runtime contribution witnesses and date buckets, plus independent +indexed/global-calendar/timezone fences. A proof-only change +updates achievement signals even when the complete dashboard render contract +is equal. Unchanged proof and display-only title/structural changes reuse the +signals; no second corpus, JSONL read, persistent key or timer is added. The +revision is streamed from existing aggregates and is never logged or exported. +OFF skips this projection and evaluation entirely and drops its cached proof; +re-enabling adopts current evidence on the next accepted snapshot. Source +replacement also retires the revision and signals. This does not retroactively +remove historical grants or make legacy period proof complete automatically. +Failed refreshes revoke new-grant authority without rerendering or discarding +last displayed statistics, including when auto-refresh presentation is paused. +Separate usage-evidence and clear/activity epochs guard queued writes; retired +providers cannot authorize a new grant. First-baseline reconciliation stays quiet. + +Timestamp bounds for unbounded Claude project/session/branch/workflow histories +are calculated in one pass without argument spreading; project merges likewise +append entries individually. This avoids the independently reproduced 150k-row +V8 stack overflow without changing deduplication or numeric accounting. +Dense tool-result appends likewise add structural events individually: one tool +ID may have unbounded result envelopes, so array argument expansion is unsafe. +The 150k-result append regression matches full content analysis and retains +zero-body-read warm refreshes. +Claude refresh failures report only fixed phase/reason enums and actually +observed anonymous counters; unavailable counts/timings say `unknown`. +Read/parse, aggregate and content-analysis phases remain distinguishable. +No arbitrary exception name, message, stack or path is logged. Identical +sanitized phase/reason pairs are reported once per Extension Host lifetime, +bounded to 60 pairs; verified prior statistics remain authoritative. +A failed Claude revision admits automatic retries after 30/60/120/240/480 seconds, +then caps at 15 minutes; explicit retries retain a 5-second minimum cooldown. +This runtime-only gate uses a monotonic clock, holds one current revision and +adds no timer, scanner or persistent history. Source/price/parse authority changes +admit new work, while presentation settings, watcher bursts and queued follow-ups +cannot bypass it. Retired work cannot reset another revision's backoff, and only +verified index success restores ordinary warm-refresh cadence. Quota fetching +keeps its independent existing policy. Skipped manual retries still deliver +compact failure feedback without replacing verified usage or reading log bodies. +Primary Codex lineage depth ordering uses explicit DFS frames and one active-path +set. It preserves the prior memo-before-cycle, parent order, orphan and root +semantics without recursive calls, copied ancestor sets or parent-array argument +spreading. The persisted 10k-depth derived-reconciliation regression reads zero +source bodies; bounded small-graph oracle tests preserve accounting order. This +is separate from achievement evidence traversal and does not change index schema, +fingerprints, prefix subtraction or provider Token/cost/limit semantics. + Codex discovery is restricted to: -- `$CODEX_HOME/sessions/**/*.jsonl` -- `$CODEX_HOME/archived_sessions/**/*.jsonl` +- `$CODEX_HOME/sessions/**/*.jsonl` and `sessions/**/*.jsonl.zst` +- `$CODEX_HOME/archived_sessions/**/*.jsonl` and `archived_sessions/**/*.jsonl.zst` - default `$CODEX_HOME`: `~/.codex` It never reads `auth.json`, SQLite databases, config secrets, keychains, browser @@ -316,6 +1055,65 @@ numeric per-day/model/effort/session aggregates, and the bounded neutral quota history described above—never prompt, response, command, tool-argument, raw-line, raw-path, account name, credential, or raw provider-label content. +In version 2.5.0, `.jsonl.zst` is lossless Zstd storage of the original +JSONL, not an LLM summary: a smaller disk file does not change its logged token +counts. Discovery rejects symlinks and non-regular files. Same-directory, +same-basename `.jsonl` wins over `.jsonl.zst`; the HMAC file key strips only the +final `.zst`. This canonical source selection makes no byte-equivalence claim. +Copies across active/archive areas still use the full-census lineage deduplication. +Sources are read-only and decoded plaintext is never written to disk. + +Manifest `size`, parser cursors and migration offsets remain decoded byte +coordinates. Only compressed entries add `compression: 'zstd'`, `storageSize` +(physical bytes) and `storageCtimeMs`; `mtimeMs`/`dev`/`ino` also describe the physical +file. Schema 3 adds these fields and the persisted `compressionVerified: true` +marker without changing its version; old entries without them remain plain. +The marker requires verified frame EOF, not merely `offset === size`. Encoding or +physical-version changes force a targeted historical reread, including a same-inode, +same-size overwrite with restored mtime. A compressed move with changed fingerprints +also replaces the destination. Such reconciliation is quiet and creates no new +live-usage receipt. + +For a deterministic body-decoding failure, a versioned `compressionFailure` +allowlisted code and physical fingerprint replace the entire provisional contribution. +No tokens, lineage, quota or calendar witnesses survive in that row. An unchanged +failed version is not decoded or checkpointed again and is not pending work, but +`failedFiles` and incomplete coverage remain visible. A physical/encoding change +invalidates the failure state; transient I/O, cancellation and source races remain +retryable. Unknown persisted failure versions force validation rather than authority. +Header discovery errors still fail the refresh and preserve the last-good snapshot. +Per-file quota observations are promoted into retained global history only after +compressed-frame verification, never from an intermediate draft checkpoint. + +After verified compressed-frame EOF, a valid final JSON record without a newline +is parsed once. An invalid trailing fragment records `invalid-json`; an oversized +final line records `oversized-jsonl-line`. In each case the cursor advances to the +actual decoded EOF, so an immutable tail cannot cause repeated whole-frame replay. +These JSONL quality flags do not excuse frame corruption or establish complete +usage coverage. Active plain logs keep their existing unterminated-line behavior. + +Discovery reads at most 18 header bytes through a read-only handle, checking fstat +identity/size against lstat and checking for mutation during the read. The codec +accepts one frame with explicit Frame Content Size (FCS) and no dictionary-ID field. +Malformed/unknown-size input, damage, extra frames, trailing bytes or size mismatch +fail explicitly; a header alone never establishes body validity or completion. +Hard ceilings are 4 GiB decoded data, 4 GiB replay prefix, 2 GiB physical data and a +64 MiB window. Decoding uses [Node's native Zstd API](https://nodejs.org/api/zlib.html#zlibcreatezstddecompressoptions), +available from Node 22.15.0 on the 22.x line and 23.8.0 on the 23.x line, with no new +dependency. Capability is checked in the actual extension host; an unsupported +host reports the error and retains the last verified snapshot rather than claiming +completion. + +An unchanged, fully verified compressed version needs only metadata/header checks +on a warm refresh, with zero body decompression. Resume replays from the frame +start to the decoded cursor; discarded prefix bytes count toward work and remain +cancellable. At most the first historical compressed file in a refresh may exceed +the soft byte budget within codec hard caps, avoiding perpetual deferral. These +backfills use at most two file-pass workers when compressed sources are present. +The numeric index remains rebuildable, not a backup: truly removed sources lose +their contributions; remaining compressed/archived representations are reconciled. +These are implementation contracts, not evidence of native recovery or acceptance. + Advice starts from materialized aggregates rather than rereading JSONL or walking retained records. Aggregate-only is the remote default. Prompt samples and optional user context have a separate explicit consent and must appear in @@ -379,6 +1177,117 @@ does not retain a history of artifacts or perform network/log reads. 修改控制项后须重新预览;过期回复不会恢复导出权限。导出在保存对话框之前捕获已确认 的 SVG,不重新生成不同内容,也不保留产物历史。 +The heatmap config allowlists `provider=claude|codex|both` and +`metric=tokens|apiEquivalent`. Amounts require `showAmounts=true`; otherwise it +normalizes to Tokens. `apiHeatmapDaily.ts` locates the interval with logarithmic +date-key lookup in existing descending daily arrays and projects only selected +payloads. The caller supplies civil-day bounds/timezone; it never re-buckets days, +reads retained records, recomputes old daily/model cost scalars or calls an API. +For selected Claude model rows only, `getCurrentEstimatePricing` checks existing +in-memory rates, memoized once per model per projection. The generic unknown-model +Sonnet fallback cannot supply known coverage; a missing required component rate +is unpriced, an explicit zero is valid, and partial coverage counts known components. +Runtime/family rates remain estimates. The legacy 1-hour-to-5-minute cache estimate +is not a frozen-book definition. General cost accounting is unchanged. At most +366 dates are materialized. Equal duplicate rows deduplicate; conflicting rows withhold money +and retain the largest observed Token bound, never add duplicate totals. + +Amounts are current floating daily estimates, not achievement reference money. +Claude keeps current backend/fallback/override estimates; Codex keeps exact known- +model-priced Token subsets. Empty/missing days, unpriced activity, partial pricing +and genuinely priced zero remain distinct. Incomplete source coverage is explicit; +a priced subtotal is a lower bound, not a complete total. Both renders separate +Claude/Codex amount blocks and never computes a joint monetary total. Hiding amounts +restores Token colors, labels, titles/tooltips, metadata and Markdown; Token mode +does not access rates or daily money/quality getters. The renderer's identity contains only +whitelisted config, numeric quality, source/pricing/display revisions and bundled +currency metadata; internal signatures are not exported. + +The host retains one accepted immutable artifact with an opaque preview ID, +document identity and materialized source references. Control changes retire it; +request sequence and document checks reject old responses. Source, pricing, +timezone, locale/currency/display changes invalidate acceptance. Monetary previews +are unavailable during pending repricing. Export requires the current visible, +ready document and rechecks acceptance before and after the single-flight save +dialog, then writes the accepted SVG bytes without +rebuilding. Markdown copying also requires the accepted artifact. No second corpus, +export history, authentication or network path is introduced. + +`showWeeklyQuotaPace` is a default-false STATE setting shared by Claude and Codex. +With it off, the owner exits before source getters, clock/cache/client access or +projection; enabling it does not enable quota tracking. On consumes the existing +successful raw cache, original `observedAt` and existing verified request-account +HMAC signal in memory, never profile/home/window labels as account proof. The +runtime source owner preserves original metadata per response and checks numeric +counter/reset/window continuity across toggles. Changed account/source, drops or +unexpected resets latch distrust until a verified ordinary seven-day rollover; +repainting or a restored high counter does not clear it. Current Codex local-log +snapshots always carry unknown account trust, so their forecast is unavailable. + +The pure formula uses start = reset − seven days, reference = elapsed fraction +at the original observation, forecast = used fraction / elapsed fraction. Thus +29% used at 25% elapsed forecasts 116%, and deviation is +4 percentage points. +It describes account-wide cycle-average consumption, including other devices or +members. The first six hours show insufficient sample; after thirty minutes of +staleness or reset, the forecast is withdrawn. A zeroed successful-cache time on +credential invalidation withdraws it immediately. Existing refresh callbacks +deliver a minimal document/provider-bound host patch even with dashboard auto- +refresh off; the client performs no optimistic forecast repaint. No new request, +timer, historical scan or persistent state is added. Dashboard and status-bar +tooltip share eight-locale `weeklyQuotaPaceCopy`; bar text/custom formats are unchanged. + +The gallery toolbar precedes showcase/overview. Small/Medium/Large set collection +emblems to 56/80/112 px and showcase emblems to 56/72/96 px, retaining text size and +click areas. Saved pin errors appear near the requesting controls; disabled or +removed controls fall back to a stable summary. Existing browser gallery/API checks +exercise these surfaces, but do not validate native/F5, new VSIX or performance. +The stylesheet contract checks all 47 used VS Code variables against each matching +registered fixture theme, treating registered null colors as CSS `initial`. +The unregistered pink-variable reference now uses its exact existing endpoint +`#d16ba5`; this is no palette/appearance change or broader visual redesign. +Automated checks do not replace the user's subjective appearance review. + +Above 960 px, the shared renderer uses a content-sized badge column capped at +`2 * --achievement-overview-width + 8px` and a next-target column that fills the +remaining space, with a 16 px gap. This avoids reserving an empty half-width column +for one badge. At 960 px or below they stack directly with a 12 px gap. Auxiliary +history sections follow both. `achievementUiIcons` supplies decorative inline SVG +icons beside existing localized share/settings/filter/size/editor/info labels; +summary counts use compact chips. This presentation change preserves control IDs, +persistence, eligibility and host/client message protocols. Early-data evidence +remains under investigation; this layout change establishes no data fix or new +runtime/native acceptance result. + +Pending/stale card details use all eight I18n locales to explain calendar, +model-ownership, money and current-source requirements, with an information icon +on notes; these are scope requirements, not exact root-cause classifications, and +expose no raw source/error details or new evidence authority. + +`extension.ts` returns early only when `codexWorkerCancellationRequested` is +true, the outcome is `error`, `failureCode === 'cancelled'`, and +`result.diagnostic?.failedFiles === 0` exactly; a missing count remains unknown +and follows failure/revocation behavior. This zero-count check is defensive: +the current producer reports `failedFiles: 0` for terminal cancellation/throws +because final per-file diagnostics are unavailable. It does not prove that no +files failed before cancellation. The host ignores this cancellation result: +no snapshot application, success timestamp or new acceptance receipt; the prior +failed state and last successful time remain intact. Processing resumes after a +fresh successful scan. The interrupted result authorizes no new awards; subsequent +history-only reconciliation remains import-only, without live dates or notices. +`codexProvider.ts` marks every error result's manifest incomplete. Reported nonzero +or missing failed-file counts, other errors and unowned cancellations retain +failure/revocation behavior. A hypothetical nonzero cancelled result tests the +host's defensive branch, not a failure census supplied by the current producer. + +Valid period-migration checkpoints now resume even when `tokenEventsSeen` is below +the inherited prefix, provided `days` is empty. The bounded cursor advances across +passes, persistence/restart and cancellation. Resuming does not promote dates: +promotion still requires the complete prefix, EOF and current-timezone witness +checks. Reviewed regressions cover batching, persistence, cancellation, negative +EOF cases and unchanged warm reads of zero bodies. Undated, oversized and +conflicting evidence remains conservatively pending; this correction does not +resolve every missing date. + Combined activity adds Claude processed volume to Codex processed volume only for the explicit activity visualization; Codex cached input and reasoning subsets are not added twice, and no cost, quota, capability, or productivity equivalence is @@ -495,10 +1404,12 @@ body, or tool-argument content. Claude polling always honors `refreshInterval`; its file watcher uses the configured quiet debounce. The production Claude path maintains an in-memory -per-file index: unchanged refreshes read zero JSONL bodies, appends read only a -verified tail, and truncate/replace/move/delete changes rebuild only affected -files and aggregate groups. Content-analysis contributions and the established -cross-file response-identity rules are updated through the same atomic path. +per-file index: unchanged refreshes read zero JSONL bodies, and an ordinary +verified single-file append starts with its tail and affected aggregate groups. +With content analysis enabled, unsafe truncate/replace/move/delete and source-order +or UUID ownership changes may require a wider ordered replay as described below; +they are not guaranteed to read only the changed file. Content-analysis contributions +and the established cross-file response-identity rules use the same atomic path. Content analysis keeps a process-local materialized accumulator. Its cutoff is the same continuously rolling millisecond cutoff used by `ClaudeDataLoader`, not a local-midnight approximation. Both loaders use the shared exact-cutoff helper; @@ -565,14 +1476,19 @@ is focused and quota tracking remains enabled; recreating the directory restores one watcher. A filename omitted by the operating system is accepted only by the credentials watcher, where the event can represent an atomic credential-file replacement and is counted anonymously for diagnosis. +Claude directory discovery has a separate setup generation from the installed +watcher generation. A redundant same-directory setup leaves installed event and +error callbacks live; a newer lookup supersedes an older lookup. Closing the +watcher invalidates both pending discovery and retired callbacks, so stopping on +blur, disabling, source change or disposal cannot be undone by a late lookup. Codex history is designed for multi-gigabyte local corpora: - discovery and parsing run outside the Extension Host in a worker; - files are indexed recent-first with progress and cancellation; an incomplete backfill uses up to half of the available logical CPUs, capped at six local - file-pass workers, while completed indexes return to the single low-power - coordinator path; + file-pass workers (at most two if compressed sources are present), while + completed indexes return to the single low-power coordinator path; - the ordered file-pass apply frontier admits at most twice the worker count of in-flight, buffered and applying results together (at most 12 for six workers). A slow file/checkpoint backpressures idle workers instead of @@ -636,6 +1552,41 @@ not arbitrary error messages, paths, or stacks. 中文:已完成状态遇到已验证覆盖率回退时重新补齐,不要求代次变化或损坏标记; 用户暂停和失败退避仍保留。失败诊断只记录固定白名单错误代码,不记录原始报错、路径或调用栈。 +The opt-in `tests/perf/measure-codex-worker-lifecycle.cjs` diagnostic runs the +compiled production coordinator/file-pass workers against a fresh synthetic +home, index and lease directory. Three rounds request achievement-hour backfill +ON/OFF, exercise an explicitly incomplete legacy period projection, perform +20 zero-body warm refreshes per group, then dispose during an active request. +The flag is a backfill request, not a full achievement/UI switch; older cohorts +may not implement it. It observes worker exits, process-wide RSS (shared by +threads), sampled per-isolate heap/external usage, and V8-serialized message +byte proxies without retaining messages or heap dumps. Frozen resource/time +guards stop only test-owned workers. `--out` can select an immutable compiled +cohort for comparisons. These are Node diagnostics, not native Extension Host, +panel lifecycle, multi-window or Windows OOM acceptance evidence. + +Native diagnostic setup has a separate opt-in safety guard in +`tests/native/isolation.cjs`. It derives ten distinct test-owned directories, +including explicit user/extensions/shared and agent stores plus synthetic +Claude/Codex homes. Before any seed write, it checks Test mode, actual +Extension Host storage/configuration paths and symlink-free owned paths. +Only then may its caller seed quota/advice/network-related settings in that +test context's globalState before production activation; settings.json alone +does not control settings whose catalog storage is globalState. A failed seed +write stops setup. Pure `nativeTestIsolation.test.ts` checks do not launch Code. +The guard is not a sandbox, a credential scanner, proof of native activation, +or an installed-VSIX/F5 smoke test. Test files remain excluded from the VSIX. + +tests/native/prepare.cjs can stage unchanged compiled modules and assets/ in a +fresh temporary root with six bounded synthetic logs. Its test-only main first +runs the actual-context guard, then activates the production extension; the +native runner exercises real API refresh/panel/dispose behavior. Explicit CLI +options additionally select in-memory SecretStorage and disable recent-folder +entries, crash reporting and experiments. Preparation tests never launch Code. +This small development-host diagnostic is not the final packaged VSIX, pixel +acceptance, manual F5, long soak, multi-window or Windows evidence. See the +helper README for normal-state before/after and background-launch requirements. + Weekly API-equivalent history is derived from already-aggregated token usage. The newest valid reset observation anchors non-overlapping seven-day display buckets; without one, usage-only history uses Monday-to-Monday UTC calendar @@ -645,9 +1596,40 @@ use all eligible local files in the same home because file keys are not account identities. A reset that drifts from the seven-day grid is mapped by observation time to the corresponding display bucket; source uncertainty, reset drift, and daily slices crossing a boundary lower confidence and are rendered as an -approximation. A genuinely different quota series remains usage-only. Current -unused value is withheld, while historical unused value is shown only alongside -a full estimate. Each usage row still contributes to exactly one bucket. +approximation. Each usage row still contributes to exactly one bucket. + +For each eligible in-window observation, the candidate uses the already-priced +local usage through that observation, not all usage through the current time: + +```text +candidateFullEquivalent = pricedUsedEquivalentThroughObservedAt / usedFraction +``` + +Only positive local amounts and `0 < usedFraction <= 1` produce a candidate. +Multiple candidates use an evidence-weighted median, considering pricing coverage, +observation confidence/account attribution, boundary flags and observation timing. +The current window may show a low-confidence local estimate even when account +attribution is uncertain: eligible Codex usage from the same local home/window is +compared with the latest applicable quota-observation cluster, not allocated to +individual accounts. Current overlapping/competing series force low confidence. +Only completed Codex display windows with multiple distinct mapped `seriesKey` +values withhold total/unused inference; a single unattributed or account-ambiguous +series can still retain a low-confidence estimate after completion. + +With a valid aggregate candidate, displayed full equivalent is at least the +current priced used equivalent; unused equivalent is their non-negative difference. +If cumulative known used value exceeds the aggregate candidate, retain used value +as a low-confidence lower bound and withhold unused value rather than fabricate +zero. This compares the cumulative amount, not just post-observation additions, +against the weighted-median candidate, not one sample. With no valid candidate, +show used value only. Full/unused values are +API-equivalent estimates, not money paid, an official account balance or a promise +of remaining subscription usage. This is not the elapsed-time prediction in +`showWeeklyQuotaPace`, whose trusted-account/window rules are unchanged. + +The maintainer's 2026-10-11 decision preserves this existing current-cycle +estimation behavior. Accurate per-account attribution belongs to future +multi-account compatibility; it is not implemented or promised by version 2.5.0. v2.3.0 does not infer a $20, $100, or $200 subscription tier. Local Codex logs do not expose a reliable account-and-plan identity, so a future comparison must @@ -655,8 +1637,18 @@ use an explicit opt-in account mapping rather than attaching prices by guess. ## Release invariants +- Default to **Squash and merge**: keep local development commits and their + validation evidence during implementation, then land one squash commit per PR + on `main`. A normal merge requires an explicit maintainer exception. Do not + rewrite published Git history to retrofit this policy. - Strict TypeScript, red-green TDD, full `node:test`, F5 smoke test, and installed VSIX smoke test are required in proportion to the change. +- The canonical CI browser gate uses four isolated shards, one worker per shard + and a 35-minute timeout per shard. The stable `UI Test` aggregate runs with + `if: always()` and passes only when all shards succeed; failed, cancelled or + skipped shards fail closed. `Node Tests` and `Release Package` keep their check + names, and packaging depends on both Node and aggregated UI success. This + configuration is not evidence that a particular CI run has passed. - User-visible strings cover `en`, `de-DE`, `zh-TW`, `zh-CN`, `ja`, `ko`, `pt-BR`, and `id`; all nine README files (the main page plus eight locale editions) move together. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc2efe0..8caa397d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,505 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel ## [Unreleased] +## [2.5.0] — 2026-10-15 + +Version 2.5.0 combines local achievements and Codex compressed-log compatibility. +The maintainer reported successful testing, accepted the candidate and authorized +Squash and merge plus publication. This records maintainer acceptance, not an +agent-performed native/F5 or Linux installed-package pass. +中文:2.5.0 整合本地成就与 Codex 压缩日志兼容。维护者反馈测试通过、接受候选, +并明确授权 Squash and merge 及发布;此处记录维护者验收,不声称代理完成原生/F5 +或 Linux 已安装包测试。 + +Broad sharing-page visual unification/beautification and a durable usage archive +are deferred to the next version; neither is implemented in 2.5.0. +中文:分享页整体视觉统一/美化与持久用量归档推迟至下一版本,2.5.0 未实现这两项。 + +### Added — 2.5.0 + +- **Compact expanded achievement rows** — grade names use per-rank theme tokens + (foreground fallback in high contrast), with bold, right-aligned thresholds on + the same header row. Only the next rank above the highest earned shows “In progress” + when proof is ready; later ranks, fully earned cards and pending proof omit it. + Verified retained-log dates display as bare ISO dates, with source interpretation + once in Evidence details and provenance in title/ARIA. When both dates exist, + only the verified date is visible; first-recorded details remain in title/ARIA. + First-recorded-only dates keep an explicit label; unknown stays unknown. Awards, + rules, date calculations, catalog and stored records are unchanged. + 中文:展开等级行按各等级主题 token 显示名称(高对比回退到前景色),门槛在同一标题行 + 加粗、右对齐。仅证据就绪时,最高已获等级的下一等级显示「进行中」;后续等级、满级及 + 证据待定均不显示。已核验保留日志日期仅显示 ISO 日期,来源含义在「数据依据」解释一次, + title/ARIA 保留来源说明。两种日期同时存在时仅显示已核验日期,首次记录详情留在 + title/ARIA;仅有首次记录日期时保留明确标签,未知仍为未知。授奖、规则、日期计算、 + 目录及已存记录不变。 + +- **Five weekly API-equivalent ranks per provider** — Claude and Codex each retain + Bronze $100, Silver $500 and Gold $2,500, and add Platinum $10,000 and Legendary + $25,000. Version 2.5.0 includes these new thresholds. Four new IDs bring the catalog to 178 rules, including 24 + monetary conditions, with 50 themes unchanged and 138 explicit date registrations. + Completed Monday-start weeks, configured timezone, frozen reference prices and + provider-specific provenance retain their existing requirements. Old IDs, + thresholds, awards and artwork remain intact; legacy envelopes cannot grant the + new ranks, and an older reader rejects unknown ranks without rewriting or clearing + either envelope. This does not add live weekly-award receipts or a usage archive. + 中文:Claude/Codex 的周 API 等价金额各保留铜 $100、银 $500、金 $2,500,新增铂金 + $10,000、传奇 $25,000,合为五级;2.5.0 采用这组新门槛。四个新 ID + 将目录增至 178 项规则,其中金额条件 24 项;主题仍为 50 个,显式日期注册增至 138 项。 + 仍须设置时区内已结束的周一自然周、固定参考价及各供应商来源证明。旧 ID、门槛、授奖 + 和图案不变;旧封套不能授予新等级,旧读取器遇未知等级拒绝操作,不改写或清除任一封套。 + 不新增周奖章实时收据或用量归档。 + +- **Shared achievement money reference** — show the current book/date once per + enabled provider with a shared non-billing explanation. Different historical + books retain exact award associations. Cards retain scope and pricing coverage + while repeated price/basis notes are removed; saved award provenance is unchanged. + 中文:金额参考说明集中显示各已启用供应商的当前价表/日期及非账单提示;不同历史价表 + 保留与具体奖项的准确关联。卡片保留范围和计价覆盖率,移除重复价表/依据说明,不改已存授奖来源。 +- **Independent root tasks** — rename `root-portfolio` in all eight locales + (独立主任务 in Simplified Chinese). Count verifiable root ownership with recognized + positive model usage, input **or** output; a root and its children count once. + This does not establish task completion. Existing IDs, thresholds and awards remain unchanged. + 中文:`root-portfolio` 八语名称明确为独立主任务;要求根归属可核验且已识别模型的输入或输出 + 有正用量,主任务及子任务合计一次,不表示任务已完成;既有 ID、门槛与授奖不变。 +- **Share & compare: bounded sharing fixes** — rename the navigation/page label + (分享与对比), align badge choices at equal width with a separate rank line, and + add the fixed public `https://github.com/ClaudeCodeUsage/ClaudeCodeUsage` link to + exported SVGs. Long headings, footers and rank/threshold labels use wrapping and + spacing safeguards. The checkbox-icon repair uses the main webview's + `webview.cspSource`, rather than an additional remote image source. Preview/export + adds no automatic network request or personal metadata; explicit selection and + the immutable accepted-preview/export contract remain intact. + 中文:导航/页标题改为「分享与对比」,徽章选项仅做等宽对齐与等级单列,导出 SVG 加入 + 固定公开仓库链接;长标题、页脚及等级/门槛通过换行和间距避免重叠。勾选图标 + 修复沿用主 Webview 的 `webview.cspSource`,不新增远程图片来源。预览/导出不新增自动 + 网络请求或个人元数据,主动选择及不可变预览/导出约定不变。 + +- **Compressed Codex history** — discover `.jsonl` and `.jsonl.zst` only under + `sessions` / `archived_sessions`, retaining the regular-file and no-symlink + boundary. Zstd is lossless compression, not an LLM summary: disk savings do not + change logged tokens. Sources stay read-only, with no plaintext copy on disk. + Same-directory plain files win with a canonical key that strips `.zst`; cross-area + copies still use full-census lineage deduplication. Schema 3 adds compressed-source + fingerprints and verified-EOF evidence while preserving decoded size/cursors and + old plain entries. Encoding or physical-version changes cause quiet targeted + rereads, not new live-usage receipts. Verified unchanged files need only header + checks on warm refresh; cancellable resume accounts for prefix replay. Native + Node Zstd (22.15+ / 23.8+) adds no dependency; unsupported hosts retain last-good + results with an explicit error. Only explicit-size, dictionary-free single frames + are accepted; corruption, extra frames and trailing data fail. Hard caps are + 4 GiB decoded/replay, 2 GiB physical and a 64 MiB window; the first historical + compressed file may exceed a soft refresh budget within those caps. + 中文:仅在 sessions/archived_sessions 读取普通 `.jsonl`/`.jsonl.zst` 文件,拒绝符号链接。 + Zstd 无损压缩不是 LLM 摘要,磁盘变小不改变 token;不写源、不落地明文。同目录 plain + 优先,文件键剥离 `.zst`,跨区仍按完整清单 lineage 去重。schema 3 附加物理指纹与 EOF + 验证标记,保留 decoded 大小/游标和旧 plain 记录;编码/物理版本变化定向静默重读, + 不生成新活动收据。warm 仅读 header,resume 前缀重放计工作量且可取消。使用原生 Node + Zstd,不新增依赖;旧宿主明确报错并保留 last-good。仅接受显式大小、无字典的单 frame, + 损坏/额外帧/尾随数据失败;解压及重放各限 4 GiB、物理 2 GiB、window 64 MiB,首个 + 历史压缩文件可在硬上限内超出 soft budget。 + +- **Date compatibility and calibration transaction boundaries** — explicitly register + 138 rule shapes, including counter/series/base/scope, instead of dating future IDs + by series membership. Canonical uncapped session admission excludes output-only + non-sessions; weak cache keys are retired on source/evidence changes. Changed logs + require recalibration. Independently verified native usage keeps its first-recorded + receipt when calibration is cancelled or fails; historical reconciliation stays + quiet and partial saves are not reported as total failure or full completion. + 中文:138 项日期规则显式注册,完整匹配计数器/系列/基础/范围,未知新 ID 不自动继承。 + 会话沿用规范完整准入,弱键缓存随来源/证据退役,日志变化后重新校准。真实已核验增量 + 的首次记录不因校准取消或失败而丢失;历史补认静默,部分保存单独报告。 + +- **Gallery management and log-date annotations** — expand/collapse reuses card + `openDetails` local UI state only. Localized Bronze/Silver/Gold labels preserve + legacy IDs, pins and pricebooks. Exactly matching conditions/units appear once; + the shared money-reference section retains current and historical award bases. + Fully earned cards retain uncapped current + statistics, never a target substituted for stale evidence. Per-level dates use + a runtime-only v1 annotation bound to rule version, timezone, catalog shape and + source/pricing context, without changing v2 `recordedDay`, unknown dates or unread + state. Retained logs establish only the earliest verifiable date, not complete + account history. Supported daily reductions reuse materialized days and strictly + verified fully dated Codex files. Active-day counts/streaks include today's positive + activity; qualified family/day dates require closure and use day + 1. Four-week + windows exclude the first incomplete week from the first positive activity day. + Only manual calibration additionally reduces already-held uncapped Claude record + memory for output/Fable/mixed-family session dates; ordinary warm refreshes do not + regroup records. Unsupported joint-agent/effort/hour/night/single-task money and + timestamp-less migrated actions remain unknown or first-recorded, never dated today + by default. Date reduction uses materialized numbers or held-record usage metadata, + without extra log-body scans, conversation-content inspection or metadata export. + Normal local incremental refresh may read allowlisted new/changed JSONL under + existing analysis settings; no forced full reread, new scanner, network request + or old-award clearing is added. Pending + guards pause historical grants/baselines while native receipts are staged; a valid history commit stays quiet. + Cancellation before the historical commit adds no historical awards; cancellation after atomic commit does + not undo saved records or misreport them as unsaved. Busy work, an unaccepted + current index census, failure or pre-commit cancellation is not success. Verified + partial history retains lower bounds or unknowns; completion does not establish + full coverage of all award scopes. Explicit date-handler allowlists and rule-version isolation keep new + scopes unknown; future catalog envelopes remain read-only and fail closed. No new + persistent store, storage schema, source parser, dependency or package version. + 中文:图鉴展开状态沿用本地 UI 存储,铜/银/金短名不改 ID、固定项或价表;完全相同 + 条件/单位集中一次,金额参考区保留当前及历史授奖依据;满级保留未截断当前统计,过期证据不冒充目标值。 + 逐级日期仅为绑定规则版本、时区、目录及来源/计价上下文的运行期 v1 注释,不改 v2 + 原日期与未读,不承诺完整账户历史。自动日归约复用物化数据与严格完整日期文件; + 活跃计数/连续活跃含当天正活动,合格家族日须闭日后加一天,四周窗口按首个正活动日 + 排除首个不完整周。仅手动校准归约已持有的未截断 Claude 会话记录,普通热更新不重聚合; + 不支持的联合/小时/单任务金额及缺时迁移操作保持未知或首次记录,不补今天。 + 日期归约仅读物化数值或已持有记录的 usage 元数据,不额外扫描日志正文、不检查对话内容 + 或导出元数据;正常本地增量刷新仍按既有分析设置读取允许的新增/变更 JSONL, + 不强制全量重读、不新增扫描器或网络、不清旧徽章。 + pending guard 暂停历史补认及基线推进,真实增量独立暂存;历史提交静默。提交前取消不新增历史奖,提交后不撤销 + 已保存记录或误报未提交;忙碌、当前索引全清单未获接纳、失败及提交前取消不是成功。 + 已核验部分历史保留下界或未知,完成不代表所有授奖范围完整覆盖。未来处理器显式白名单及版本隔离, + 新范围未知、未来封套只读拒写;不新增持久存储、schema、解析器、依赖或版号。 + +- **Merge policy** — default to Squash and merge: retain development commits and + validation evidence locally, then land one squash commit per PR on `main`. + Normal merge is an explicit maintainer exception; published history is not rewritten. + 中文:默认 Squash and merge;开发阶段保留本地提交与验证证据,每个 PR 最终在 `main` + 形成一个 squash commit;普通 merge 须维护者显式例外,不追溯改写已发布历史。 + +- **Claude-heavy long-term ranks** — fifteen existing Claude/sustained-activity + series gain thirty Platinum/Legendary conditions, preserving the original three + targets and artwork. Output, sessions and nonconsecutive activity support solo + Claude and concentrated weekly patterns without buying models or using Codex. + No consecutive-day tiers are added. Locale-native titles, current progress, + explicit sharing and quiet historical reconciliation retain their contracts. + Frozen v1 IDs/pin keys and v2 canonical records stay compatible; up to 178 current + records remain bounded by 64 KiB. Old labels cannot authorize new high ranks. + +- **Provider/metric heatmap sharing** — Claude / Codex / Both × Tokens / + API-equivalent amount, defaulting to Tokens with explicit amount disclosure. + Current daily Claude estimates and Codex exact-model-priced subsets remain + separate from achievement reference prices. Both amount panels never sum; + missing/partial pricing and incomplete sources retain explicit coverage/lower bounds. + Hiding amounts restores Token colors/text/tooltips/metadata/Markdown and retires + the old handle. A read-only ≤366-date projector reuses materialized daily rows, + without scanning the corpus, repricing or adding network access. Export writes + the accepted immutable SVG after document/source/pricing/display/config checks + before and after the single-flight save dialog; stale replies cannot enable it. +- **Weekly quota pace** — default-off shared STATE preference uses existing quota + snapshots and their original observation time. At 25% elapsed, 29% used forecasts + 116% over the account-wide cycle. No forecast in the first six hours, beyond + thirty minutes without a fresh observation or after reset. Credential invalidation + withdraws it immediately. Verified account-HMAC/window continuity stays runtime- + only; counter/source/account/reset discontinuities latch until a verified ordinary + seven-day rollover. Codex local-log account trust is unknown, so forecasting is + unavailable. Existing callbacks update a document/provider-bound dashboard fragment + even with auto-refresh off; no optimistic client calculation, new request, timer + or history. Dashboard/status tooltip share eight-locale copy; bar text and custom + formats are unchanged. Off avoids source getter, clock/cache/client access. +- **Verified cumulative-money awards** — four existing cumulative tiers per provider + now use accepted native Claude/Codex tail-window increments under the same frozen + public pricebook as the before/after scope. The increment must be sufficient from + prior progress and necessary to the new crossing. Token/money receipts remain + independent; money-only book/scope invalidation cannot erase a separately valid + Token receipt. Common source/index, clear, OFF and disposal guards still apply + to both; a Claude backend switch can retire pending Token proof via its index + invalidation. Atomic saves retain unread first-record day/zone and public + `referencePricebook`, never numeric money or proof. Same-book provisional history + after the accepted baseline can be repaired; older or different-book history + keeps its basis/date. Failed saves have no optimistic dates, and one update uses + one existing notice batch. History, completed weeks and single sessions/tasks stay + quiet with unknown dates; price changes and rebuilds are not new activity. +- **Inline Codex effort statistics** — the existing effort-breadth disclosure shows + low/medium/high/xhigh/max/ultra rows from complete current root-task × effort + populations with their own positive output, excluding approval reviewers. + Processed/output/root-count progress uses the existing three shared tier criteria. + Frozen USD subtotal/coverage is informational, never an effort qualification. + Ambiguous model × effort attribution is not allocated by ratios; empty/unpriced/ + partial/invalid/overflow states remain explicit and absent evidence is unknown, + not six zero rows. The optional DTO is cached by proof revision; no new rule, + preference, stored history or log read. +- **Recognizable API resource emblems** — six Claude/Codex cumulative, week and + single-session/task emblems use restrained gold coins or small money pouches + alongside their ledger, calendar or task scope. These cues identify API-equivalent + resources, not income, wealth or task quality. Original artwork is retained; + gallery, notices and explicitly selected SVG exports share the versioned lookup. + 中文:六枚 API 资源徽章以少量金币或小钱袋增强金额辨识度,保留账册、周历及单次 + 会话/任务标记,不使用财富炫耀意象,也不表示收入或任务质量;旧图案保留。 +- **Current statistics from badge details** — model/token badges link to existing + same-provider statistics, with a reload-safe keyboard return and preserved + disclosures. Shared/calendar conditions and absent views have no misleading + totals link. No log scan, award date, new host command or automatic sharing. +- **Quiet badge arrival** — a new foreground notice gives its emblem one 440 ms + reveal, with no moving text, sound or focus change. Reduced-motion and both + high-contrast modes stay static; dashboard patches and reloads do not replay it. +- **Accurate exploration artwork** — the three-step local journey uses a new + text-free emblem instead of the obsolete “10X” picture, including in SVG export. + Existing award conditions are unchanged. +- **Short local feature journey** — the exploration badge details list collection, + share-preview and successful local-export facts from saved action proof. Queued + or cancelled exports and optional advice feedback cannot complete the journey; + old awards do not invent missing step records. No extra awards or actions. +- **Keyboard-safe legacy pins** — removing a merged milestone pin restores focus + to that theme's summary when its button disappears. Surviving controls retain + focus normally; no refresh, read acknowledgement or new preference is added. +- **Growing themes without duplicate cards** — the current default gallery has 50 theme + cards, 178 rules and 52 artwork groups, including six API resource themes. The fourteen-day and + first shared-day milestones live inside their growing themes, not as extra + tiers. Old disclosure links and pins still work; duplicate old pins can be + removed separately. Recent views collapse themes but preserve exact records, + unread levels, dates and independent share choices. +- **Accurate model experience names** — all eight locales and share cards now + distinguish model breadth within one task, repeated multi-model tasks and + same-session Fable output. Names no longer imply an unproven sequential relay; + rule identities, thresholds, artwork and saved awards are unchanged. +- **Current-tab reselection** — clicking the already selected dashboard tab no + longer clears saved expanded rules or repeats host tab/inventory requests. + Real tab switches keep their existing reset behavior. +- **Separate notice opt-out** — turn off Show achievement notices without hiding + the gallery, stopping grants or deleting unread records. It dismisses an active + notice, retires queued delivery and its old ACK, and never replays missed notices + when re-enabled. The local preference does not restart providers or indexing. +- **Verified new-Token awards** — Claude and Codex tail parsers can return bounded, + runtime-only new-Token proof without extra log reads or persisted index fields. + Codex verifies settled canonical lineages and transports only fixed numeric + fields through local workers; imports, recovery, foreign requests and failed + saves cannot fabricate live origin. Exact-tier helpers retain approved targets. + Accepted Claude/Codex snapshots now hand off independently of dashboard refresh; proven + Token tier crossings save first-record days and unread state under the shared + lease. Late peer history cannot steal new provenance; pre-existing history is + not rewritten. Failed saves retain bounded retryable proof, retired sources + release it. Claude commits its index before handoff and explicit rebuilds stay + quiet. Four Claude families, output, cache reads, cache creation and cumulative + volume are supported; cumulative-money crossings use their independent proof + above, while other live usage scopes still require their own origin proof; + unsupported or historical crossings stay quiet with unknown dates. +- **Personal badge pins and size controls** — closed-card icons and a searchable + editor select/order up to six manual showcase themes. Manual mode shows only + chosen pins; automatic mode recommends at most three. Revision-checked atomic + intents preserve other windows' choices without changing goals or sharing consent. Only a + saved host preference updates the UI; failed saves retain the old choice. + Disabled-source pins remain removable from a folded management list without + re-enabling that source, reading logs or deleting other saved pins. + Size/filter controls precede the overview; collection emblems use 56/80/112 px, + showcase emblems 56/72/96 px. Errors stay near controls and disabled/removed + controls fall back to a stable summary without losing keyboard focus. +- **Recent award views** — up to three actually recorded new series, plus a + collapsible thirty-row list linked to the existing rules. First-record dates + stay distinct from historical unknown dates; browsing neither marks records + read nor selects sharing. +- **Explicit unread groups** — a folded New awards summary presents up to six + exact earned levels, including multiple tiers of the same series. Mark this + group read saves only those represented IDs after foreground/source/document + checks under the shared lease; peer additions stay unread. Failed saves retain + the group with fixed localized feedback. Browsing/pinning/sharing do not confirm + it, and missed notices never replay as popups. No new scan, timer or store is added. +- **Quiet confirmed notices** — only the visible foreground panel can show new + action awards, with a shared five-minute cooldown across windows/restarts. + Display ACK reads only its bounded batch; missed, failed or retired deliveries + remain unread. Hover/keyboard interaction pauses dismissal without stealing + focus. Verified Claude/Codex Token crossings use the same delivery guards; + cumulative-money crossings now share this guarded delivery path. Other live + usage scopes still require their own origin proof. +- **Bounded durable award records** — a ≤64 KiB canonical file keeps each public + rule once, with quiet unknown-date migration and unread saved-action records. + Cross-process leases merge peers' awards; an empty clear-generation fence + prevents older hosts from restoring deleted awards or cached auxiliary + proof. Existing bounded local-action/hour/calendar proof is merged in the same + envelope, not mirrored into stale window caches. Invalid/future + input remains untouched; OFF does not initialize it. Inventory and Clear All + cover the file. Pins and explicit unread confirmations use the same envelope. +- **Badge overview** — automatic mode shows up to three earned representatives, + manual mode only the selected themes (up to six); three + reachable goals precede the complete collection. Selection favors distinct + themes and current-provider ties, and stays stable during browsing. Unused, + zero-progress, pending or stale goals are not promoted. Clicking opens the + existing rule with keyboard focus; it does not refresh logs or select exports. +- **Progress between Token tiers** — closed cards show integer progress toward + the next formal tier; long Token ladders include display-only 1/2/5 stage targets. + These are not extra awards, notifications or sharing choices, and do not change + any formal threshold. Pending/outdated proof never fabricates stage progress. +- **Tiered local achievements** — 50 icon-first themes and 178 award conditions, + with distinct bundled artwork per series, shared by its colored tiers. Token-led Claude Sonnet/Opus/Haiku/Fable and Codex + Astra/Sol/Luna progression, output/cache, model/effort and agent experiences, + calendar activity, weekly peaks, completed four-week averages, and a few safe + deliberate plugin actions. Cumulative tokens share one five-tier series. +- **Default-on with a clear opt-out** — a once-only non-modal upgrade reminder + offers View achievements or Turn achievements off, respecting release-reminder + settings and existing opt-outs. Historical awards establish a quiet baseline; + subsequent eligible awards use a brief in-panel notice without sound or focus + theft. Sensitive time-of-day and 24/48-hour output badges remain silent. +- **Local badge sharing** — explicitly choose up to nine earned badges, accept + the preview and export a self-contained SVG. No authentication or background + upload; no credential/network-consent achievements. +- **Lower-friction gallery** — series and level counts are separate; All / Next + level / Earned filters stay local and survive reload. Cards show a compact next + target, with every tier's rule inside a stable disclosure. The settings shortcut + focuses the existing opt-out without changing it; sharing offers a return path. + +### Fixed — 2.5.0 + +- **Codex achievement evidence reuse** — each successful refresh reconstructs the + full file census and independent witnesses, then compares them exactly with a + detached, deeply immutable publication. Equal evidence can reuse its revision + and calendar projection, including the projection checked by monetary scopes. + Unchanged flags, zero body reads or matching totals alone cannot authorize reuse. + Changed evidence retains full validation; this does not make refresh work constant-time. +- **Claude session-achievement evidence** — flat `agent-*.jsonl` files without a + path-proven parent no longer count as independent session evidence for achievements. + Token totals, prices, daily aggregates and visible session rows are unchanged. + Nested agents retain the existing parent-path contract, and saved awards remain. +- **Complete UI CI gate** — the canonical browser suite runs in four isolated + shards, each with one worker and a 35-minute limit. The stable `UI Test` aggregate + fails if any shard fails, is cancelled or is skipped. `Node Tests` and + `Release Package` retain their check names; packaging still requires both gates. + +- **Achievement recognition and retained history** — non-monetary collaboration/ + rainbow model recognition is separate from fixed pricing aliases; Claude accepts + only explicit global/us/eu/jp/apac/in Anthropic prefixes, and recognized but + unpriced labels remain unpriced. Saved monetary records retain current or + explicitly published reference-book labels, while new append/usage writes + require a current provider book. Unknown non-current books still fail closed + and remain untouched by clearing. Claude-only retained peaks ignore unrelated + Codex parser/period changes, preserving rule/timezone/provider and complete-current + evidence checks. No prices, award IDs, storage fields or schema are added. +- **Achievement record and target display** — eight-locale loading, unavailable, + changed and disabled states precede gallery counts instead of showing `0 / 0`. + Verified Claude cumulative usage stays a `≥` lower bound while enabled Codex + evidence is unverified. Next-target cards show the target name/rank/color; + earned gallery ranks remain unchanged. Shared public title handling removes + only exact localized rank suffixes from SVG titles when rank is shown separately. + +- **Codex calendar subset proof** — runtime contribution witnesses and full-census + ancestry/revision checks preserve positive activity/family/weekly Token bounds. + Weekly money requires fully dated billable/model equality; date/pricing coverage + stays separate. Partial zero-only/empty stays pending, without four-week means or + retained peaks. Cumulative/task scopes, prices and rules are unchanged; no new + I/O, timers, stores, persistent witnesses or identity export. + +- **Calendar cursor cache binding** — cursor-only period changes now retire + runtime achievement date/Money revisions even when displayed counters match. + This adds no source reads, persistent fields or new award authority. + +- **Historical rank versus current progress** — retained awards preserve their + earned rank but never replace a current verified subtotal with the old target. + After log retention, an earned Legendary badge can therefore show a smaller + current amount truthfully without deleting its historical qualification. + +- **Calendar proof recovery and partial progress** — legacy Codex date indexes + without current lineage proof enter bounded, resumable revalidation when + achievements are enabled, then return to zero-body warm refresh. Independently + validated positive Claude calendar metrics remain visible as lower bounds while + Codex dates are incomplete; shared-provider dates and four-week means retain + complete-proof gates and recovered history does not generate live notices. +- **Claude split-only cache writes** — both explicit safe TTL components restore + a missing flat count consistently through full/incremental aggregates and fixed + reference money. Contradictory totals, unknown fields and incomplete splits do + not gain pricing proof. Source records are unchanged. +- **Reachable next targets** — model-rainbow tiers above enabled-provider family + capacity are not recommended; pins and formal rules remain. Positive proven + Token progress gains display-only stages within one decade before its first + formal tier, without new grants, dates, notifications or monetary entry stages. + +- **Compact achievement overview** — bounded horizontal badge/next-target cards + replace sparse full-width columns. Icons, titles, ranks and progress stay together, + pins remain inside their cards, and next targets show a thin progress bar. Existing + artwork, three sizes, theme tokens, evidence labels and keyboard routes remain. + Light-theme gold rank labels use legible foreground text with a gold accent. + +- **Achievement controls and paired showcase** — lightweight inline SVG icons + accompany existing localized share/settings/filter/size/editor/info labels; + counts use compact chips. Above 960 px, the badge column fits its content up to + two overview-card widths plus their internal gap; next targets fill the remaining + space, with a 16 px column gap. At 960 px or below they stack directly with a + 12 px gap. Auxiliary history + sections follow both. Gallery icons remain 56/80/112 px; overview icons use + 56/72/96 px. The pure `achievementUiIcons` module uses known SVG paths without + dependencies, network access or additional asset files. Existing controls, + persistence, eligibility and message protocols are unchanged. + +- **Owned Codex cancellation** — an explicitly host-requested cancellation can + retain the prior accepted view without accepting the interrupted snapshot, + recording a successful refresh or creating a new award receipt. Earlier failures + stay failed; new activity requires a fresh verified scan, and history-only + reconciliation stays quiet. The strict zero failed-file check is defensive: + the current producer reports zero on terminal cancellation because final file + diagnostics are unavailable, not because no file failed. Missing/nonzero reported + counts, other errors and unowned cancellations retain failure/revocation behavior. + +- **Codex period-migration continuation** — valid checkpoints now resume while + `tokenEventsSeen` is below the inherited prefix when `days` is empty. The bounded + cursor advances across passes, persistence/restart and cancellation; dates are + promoted only after the complete prefix, EOF and current-timezone witness checks. + Reviewed regressions cover batching, persistence, cancellation, negative EOF + cases and unchanged warm reads of zero bodies. Undated, oversized or conflicting + evidence remains conservatively pending; this does not fix every missing date. + +- **Sharing and subtotal edge cases** — the Codex-only gallery share action + selects the requested achievement card, not the heatmap fallback. Restoring a + default title no longer suppresses provider/amount preview regeneration. + Positive partial subtotals below one dollar use `<$1`, without the invalid `≥<` prefix. +- **Usage-origin integration** — verified Claude/Codex Token append proof now reaches + atomic awards independently of dashboard delivery, as described above. Per-level + checks cannot turn unrelated live tokens into dated historical awards; analysis + reparsing retains verified boundaries and observation windows precede asynchronous + work. Cumulative money has independent origin proof; other live usage scopes + still need their own proof. No new scan, timer + or index field. +- **Retired dashboard messages** — readiness, tab changes and unread receipts + carry a runtime document identity. Full replacement and failed-patch fallback + retire the old identity; ordinary in-place patches retain it. A late old page + cannot revive readiness or confirm another group's awards. +- **Source-only compilation** — TypeScript discovers only `src/**/*.ts`, so + private source snapshots used for evidence cannot be emitted or included as + extension source. This changes no runtime dependency or packaged feature. +- **Deliberate plugin-action provenance** — restored experience records grant + quietly; only a newly crossed target in a successfully saved action can notify. + Awards save even with the dashboard closed, without refreshing hidden panels. + Provider-proof retirement no longer cancels independently queued action awards; + failed grant saves remain retryable and re-enabling reconciles quietly. +- **Unknown progress is not zero** — pending/outdated evidence is visible on the + badge and excluded from Next filtering, without deleting earned tiers. Independent + quiet baselines prevent incomplete calendar proof from suppressing later verified + Token upgrades; migration rechecks no longer silence all future usage levels. +- **Proof changes without chart refreshes** — refreshed Codex period lineage + evidence and effort validation now reach achievements even when chart data is + identical. Display-only changes reuse proof; disabling achievements skips this + extra computation. No new log read, timer or persisted data is introduced. +- **Repeated Claude watcher setup** — directory discovery no longer invalidates + an already installed watcher's event or error callbacks when the watched + directory is unchanged. Overlapping setup still accepts only the newest + request; blur, disabling and disposal cancel pending setup and retire callbacks. +- **Codex awards blocked by unrelated identity conflicts** — after complete current + indexing, independently verified token/model/effort/agent lineages can prove + positive milestones. Conflicting copies and affected descendants are excluded; + calendar awards still require complete identity and date proof. Deep lineage + selection/root resolution is iterative and memoized, without rereading logs. +- **Integer-first, quieter achievement cards** — progress/targets use integer + abbreviations floored below the next threshold; exact integer progress remains + accessible. Duplicate status and tier-title prose is removed; necessary retained- + log/calendar caveats stay available in an initially collapsed evidence disclosure. +- **Large Claude group stack overflow** — reported by [@mkgaskin-ops](https://github.com/mkgaskin-ops) + in [#126](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/126), fixed by + [@Carl723000](https://github.com/Carl723000). Project, session, branch and workflow + timestamp bounds use a one-pass reducer rather than passing unbounded arrays + as function arguments. Project merging likewise appends records individually. + Regression coverage includes 150,000 synthetic records and zero-body-read + unchanged refreshes. This is distinct from the v2.4.1 warning-flood fix and is + not evidence that every Windows memory/CPU issue is solved. +- **Award-proof lifecycle** — refresh failures immediately revoke permission to + award from stale numeric evidence, even while dashboard presentation is paused. + Last displayed statistics and legitimate saved awards remain intact; source + changes, clearing and retired callbacks cannot grant on behalf of a new source. +- **Optional hour-proof work** — old Codex checkpoints retain primary totals while + a bounded, resumable positive-output-hour pass completes. Disabling achievements + stops this extra historical work without recreating the usage provider or index. +- **Stable achievement interaction** — tier upgrades preserve disclosure identity + and canonical card order. Live notices count advanced series rather than calling + them imported history. Sharing exports the exact accepted SVG, rejects superseded + responses/handles, reconciles unavailable selections and recovers visibly from + preview failures without exposing private diagnostics. Automatic display restore + reuses the accepted artifact instead of silently regenerating it. +- **Review-driven polish** — high-tier cards name the actual milestone; single-provider + views hide unreachable dual-provider series. Qualified units, near-target number + precision and consecutive-hour wording are explicit. Five-level disclosures fit + 320px; silver and platinum have distinct accents. Sharing returns to the original + provider with keyboard focus, reports save/cancel/failure, prevents duplicate save + dialogs and checks language before accepting an export. + ### Fixed - **Source-scoped Codex caches** — different Codex homes, profile salts and data timezones sharing VS Code global storage no longer replace one another's diff --git a/CLAUDE.md b/CLAUDE.md index 15e79f6c..cf146731 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,17 @@ Compatibility entry point for Claude Code. `AGENTS.md` is the canonical repository policy; read and follow it before changing this repository. The faithful maintainer review copy is `AGENTS.zh-CN.md`. +For PR integration, follow its [Git and release discipline](AGENTS.md#git-and-release-discipline): +default to Squash and merge, one commit per PR on `main`, while retaining local +stage commits until merge for verification and rollback. Normal or rebase merges +require an explicit maintainer exception; preserve original PRs and truthful credit. +Use its task-scoped documentation routes rather than loading every historical +plan. Verify the actual checkout/branch and current local handoff before a +version-specific task; a source manifest version is not proof of publication. + +Version 2.5.0 combines local achievements and Codex compressed-log +compatibility. Follow the +expanded `.jsonl` / `.jsonl.zst` usage allowlist in `AGENTS.md`; source logs remain read-only. Claude-specific compatibility notes: @@ -13,6 +24,8 @@ Claude-specific compatibility notes: two roles. - Claude conversation JSONL is read-only. OAuth credential refresh is a separate, existing security-sensitive behavior; do not broaden it casually. + The existing opt-in, exactly confirmed single-log move to OS trash is separate + from read-only usage ingestion and derived-data clearing, not an agent permission. - Claude runtime usage refreshes go through `claudeIncrementalIndex.ts`; do not restore the old full-corpus read and full-record aggregation path after each watcher event. @@ -20,3 +33,5 @@ Claude-specific compatibility notes: `refreshInterval`; do not restore a hidden active polling override. - Do not create a second policy source here. Update `AGENTS.md` and its Chinese review copy when repository rules change. +- F5 and installed-VSIX smoke tests use fully isolated writable state. A separate + window is insufficient; follow `tests/native/README.md` without replacing daily Code. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64b8b8fa..8dc5cbbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,8 +14,10 @@ reconciliation. Features that sharpen attribution, privacy, or the advice experience are the best fit. Usage ingestion reads only each provider's local metadata and usage logs. -Usage ingestion is read-only, and Codex data is never mutated. Codex does not estimate -dollar cost. Its usage JSONL is streamed and temporarily parsed for allowlisted +Usage ingestion is read-only, and Codex data is never mutated. +Codex provides clearly labelled API-equivalent cost estimates for exactly known +models, never a bill or subscription charge; unknown models remain unpriced. +Its usage JSONL is streamed and temporarily parsed for allowlisted metadata; conversation fields are not inspected or used for analysis and are never retained. Codex limits are last-observed values from local logs, not real-time billing data. @@ -35,6 +37,16 @@ npm run watch # recompile on change - Press **F5** in VS Code to launch the Extension Development Host and test your changes manually. - Package a `.vsix` with `npx -y @vscode/vsce@3.9.1 package`. + This is the pinned contributor recipe, not a claim about the latest stable + VSCE. For a candidate, record the actual verified packaging tool/version and + freeze its source and package hashes; do not silently upgrade dependencies. + +Before host smoke or installation, isolate all writable user-data, extensions, +shared/agent state, global/workspace storage, provider homes, caches and lock +paths. See [the guarded native-test instructions](tests/native/README.md). +A separate window or worktree is insufficient. Compare selected normal-state +metadata/hashes before and after; unexplained changes stay unknown. Do not use +a default-profile `code --install-extension` or modify the daily installation. ## Pull requests @@ -45,6 +57,31 @@ npm run watch # recompile on change - If your change affects user-facing behaviour, update `CHANGELOG.md` and the relevant parts of `README.md`. +### Merge strategy and progress commits + +The project defaults to **Squash and merge**: each reviewed PR adds one commit +to `main`, including multi-stage features and version work. A normal merge or +rebase merge requires an explicit maintainer exception for that PR. +Keep meaningful local stage commits until the PR is merged for verification and +rollback; this default does not require pre-squashing progress commits before +review. Do not rewrite published history or shared branches to apply it. + +Merge the contributor's original PR and verify the squash commit author and all +applicable `Co-authored-by` trailers. Never copy another contributor's PR into a +maintainer PR and close the original as superseded. If changes are needed, +prefer an author update, or update the original PR branch with explicit +authorization. Keep truthful credit on each Release change entry, as described +in [Release-note attribution](#release-note-attribution). +See [the repository Git policy](AGENTS.md#git-and-release-discipline). + +中文审阅:本项目默认 Squash and merge,每个已审阅 PR 向 `main` 合入一个提交, +多阶段功能和版本开发也适用。只有维护者针对该 PR 明确指定时,才例外采用普通 merge +或 rebase merge。本地有意义的阶段提交保留至 PR 合并,供验证和回滚,不要求在审阅前 +提前 squash;不得为应用此规则重写已发布历史或共享分支。合并贡献者的原始 PR, +核对 squash commit 作者及所有适用的 `Co-authored-by` trailer;不得把他人 PR +复制进维护者 PR 后以 superseded 为由关闭原 PR。需要修改时,优先请作者更新, +或经明确授权在原 PR 分支修改。Release 每条变更均保留真实贡献者 credit。 + ## Tests Tests run in three layers: @@ -59,6 +96,18 @@ Tests run in three layers: VS Code host activation, real local metadata, and theme smoke behavior. This layer does not replace the first two layers. +Browser checks use the production renderer and synthetic fixtures, not an actual +native installed window. Theme-variable guards are required but cannot prove +resource URIs, native fonts, screen readers or real zoom. Record those gates +separately; preparing a launch configuration or succeeding at CLI installation +does not execute F5 or accept the installed UI. + +中文审阅:Codex 对已知精确型号提供明确标注的 API 等价估值,不是账单或订阅扣费, +未知型号仍未定价。原生/安装测试须隔离全部可写状态,单独窗口或工作树不足以证明隔离; +不替换日常 Code,无法归因的日常状态变化保留未知。浏览器生产渲染夹具与主题变量守卫 +不代替原生字体、资源 URI、真实缩放、读屏、实际 F5 或已安装界面验收。贡献者的 VSCE +固定版本示例不是“最新稳定版”声明;候选记录实际工具版本、冻结源码和包 hash。 + CI uses separate gates, with packaging after both test jobs pass on every PR (`.github/workflows/test.yml`). Canonical Linux screenshots may only be updated with the exact official container @@ -99,7 +148,7 @@ tags: clear). A maintainer applies the matching label when merging — that label sets the version bump (`minor` → minor, `breaking` or `major` → major; ordinary `feature` / `fix` / docs and unlabeled PRs → patch). -2. **A maintainer merges it.** [Release Drafter](https://github.com/release-drafter/release-drafter) +2. **A maintainer squash-merges it by default.** [Release Drafter](https://github.com/release-drafter/release-drafter) then adds your change to a continuously-updated **draft GitHub Release** and recomputes the next version. 3. **To ship, a maintainer reviews that draft and clicks Publish.** That creates @@ -110,8 +159,10 @@ tags: can recover either registry without changing the release tag. Older releases that have no asset yet are rebuilt once from their exact tag and repaired. -Because changes ship by **merging** your PR — not by re-applying it — your commit -authorship and the PR's *merged* status are preserved. +Changes ship through your original PR, preserving its *merged* status. The default +squash produces a new commit rather than preserving each branch commit SHA; the +maintainer verifies its author and applicable `Co-authored-by` trailers and retains +truthful per-entry Release credit. ### Release-note attribution diff --git a/LOCAL-DATA.md b/LOCAL-DATA.md index 3a1bc46d..de3fe022 100644 --- a/LOCAL-DATA.md +++ b/LOCAL-DATA.md @@ -4,25 +4,106 @@ Claude Code Usage is local-first. It reads provider-owned usage logs, stores only bounded derived state needed for fast refresh and reset-aware estimates, and never treats API-equivalent cost as a bill. -This page is the user-facing inventory for the v2.3.x development line. The -v2.3.1 normative field-level contract is in +This page is the current inventory and versioned data-contract supplement for +version 2.5.0, combining achievements and compressed-log +compatibility. The +historical v2.3.1 field-level baseline is in [`docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md`](docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md). +The later compression, sharing, quota-pace and achievement sections here govern their added +runtime/persistent fields and exact clearing rules, including the canonical v2 +envelope and twelve sharing keys. They do not relax unchanged baseline source-log, +credential, account-separation or explicit-network-consent restrictions. ## What the extension reads and retains | Data class | Source | What is retained by the extension | Retention and clearing | Remote interaction | |---|---|---|---|---| | Claude usage records | Claude Code's local `projects/**/*.jsonl` | Runtime usage records and an in-memory per-file index; bounded prompt samples only when content analysis is enabled | Released on window reload/exit. Data controls never touch source logs; the separately opt-in Session Actions feature may move one exactly confirmed log to the OS trash (recoverable) | None unless the user separately previews and sends an advice request | -| Codex usage records | `$CODEX_HOME/sessions/**/*.jsonl` and `archived_sessions/**/*.jsonl` | A versioned incremental index of pseudonymous file keys, offsets, numeric usage, dates, model/effort, and sanitized structural aggregates | Until rebuild, explicit clear, schema replacement, or host removal | None | +| Codex usage records | `.jsonl` and `.jsonl.zst` regular files recursively under `$CODEX_HOME/sessions` and `$CODEX_HOME/archived_sessions` | A schema-3 incremental index of pseudonymous file keys, decoded offsets, numeric usage, dates, model/effort, sanitized structural aggregates, and optional compressed-source fingerprints/EOF marker | Until rebuild, explicit clear, schema replacement, or host removal | None | | Codex titles | Exactly `$CODEX_HOME/session_index.jsonl` | `id → thread_name` is streamed for runtime display; titles are not written to the index | Runtime only | None | | Quota observations | Claude's official quota response or structured Codex rate-limit events | Provider, machine-local anonymous account epoch, observed/reset time, period, used/remaining fraction, anonymous window identity, source, confidence, and quality flags | At most 180 days and 512 observations per provider/account/period after boundary-preserving compaction; clear separately or with all derived data | Claude quota lookup contacts Anthropic when enabled; an expired Claude token is refreshed with Anthropic and written back to its credential source. Codex quota evidence is local | -| Settings and UI preferences | User choices | Typed extension settings, selected tab/filters, bounded background-work state, sharing presentation, Share Card draft controls, heatmap title/range/privacy preview, and the optional display-currency preset | Until reset, clear, or uninstall; ordinary VS Code settings may participate in Settings Sync | No extension-initiated transfer; no exchange-rate lookup | +| Settings and UI preferences | User choices | Typed extension settings, selected tab/filters, bounded background-work state, sharing presentation, Share Card draft controls, heatmap title/range/privacy/palette/provider/metric/show-amounts controls, gallery filter/size, and the optional display-currency preset | Until reset, clear, or uninstall; ordinary VS Code settings may participate in Settings Sync | No extension-initiated transfer; no exchange-rate lookup | +| Heatmap artifact (2.5.0) | At most 366 selected materialized civil-day aggregates | One accepted immutable SVG/Markdown artifact, opaque document-bound preview ID, source references and whitelisted config/quality identity | Runtime only; replaced/retired on control, source, pricing or display change, reset or disposal | None; preview/local export adds no source-log reads or network requests | +| Weekly quota pace (2.5.0) | Existing successful raw quota cache and original observation time | Runtime-only ≤16-window numeric projection, original-response metadata and bounded verified-account/window continuity anchors; default-off shared `showWeeklyQuotaPace` STATE preference | No extra observation store; source/credential invalidation withdraws forecasts and disposal releases runtime state | None added; does not enable quota tracking or request new credentials | | Share destination | User-entered GitHub target | One versioned `ccu.heatmapDestination.v1` object containing optional `owner/repository/path`; no GitHub credential | Until sharing preferences are reset | Only after an explicit publish action and exact destination confirmation. A successful write is reported even if saving this convenience preference fails | | Advice evidence | Derived local aggregates and explicit feedback | Coarse observations, recommendations, ratings, snoozes, comparable-task metrics, coverage, and versions | Bounded local ledger; clear independently | Nothing by default; only the exact previewed request is sent after a separate action | +| Local achievements (2.5.0) | Verified numeric usage and deliberate allowlisted local actions | A ≤64 KiB award envelope with public IDs, sequence/reason/unread state and optional first-record day/zone; safe setting-key/feature enums (never values), hour-streak maximum and qualified four-week peak | Default on; off stops new grants and extra proof work. Clear all removes exact achievement state and retains only an empty clear fence | None; sharing requires explicit local preview/export, not GitHub authentication | | Advice API key | User-provided secret | Secret value in VS Code SecretStorage only | Until the key or all derived data is cleared | Used only as the authorization credential for the explicitly configured endpoint | +## Compressed Codex sources (2.5.0) + +Zstd losslessly encodes the original JSONL; it is not an LLM summary and does not +change recorded token counts. Only the two usage roots above admit `.jsonl.zst`; +symlinks, non-regular files, other roots, credentials and SQLite remain excluded. +The separate title-only `session_index.jsonl` permission is unchanged. Source files +are never rewritten and no decompressed plaintext copy is saved. + +Schema 3 adds optional `compression: 'zstd'`, `storageSize` (physical bytes), +`storageCtimeMs` and `compressionVerified: true`. The first three identify the +compressed representation alongside physical `mtimeMs`/`dev`/`ino`; the last marks +successful frame-EOF validation for that version. `size`, parser and migration +offsets remain decoded byte coordinates. Old entries without the new fields remain +plain. These anonymous numeric/enum/boolean fields live in the existing per-file +index and follow its rebuild/clear lifecycle; no new store or remote disclosure is added. +Deterministic decoding failures retain only `compressionFailure: { version: 1, code }` +and the physical fingerprint. The code is an allowlisted enum, never a native error, +path or message. Such rows contain no usage or proof, remain incomplete, and are not +retried until the physical version changes. Transient I/O and source changes remain +retryable. Unknown failure versions are discarded and revalidated. +Only verified compressed versions can add observations to the retained quota +history; a provisional checkpoint or rejected frame cannot seed that store. + +This is a rebuildable index, not a historical backup: a source truly removed from +both allowed roots loses its usage contribution. Compression or an archive move +is not deletion; its remaining canonical source is reconciled without adding it twice. + +Same-directory, same-basename plain JSONL is canonical and the compressed HMAC +key strips `.zst`; selection does not assert identical contents. Cross-area copies +still require full-census lineage deduplication. Encoding/physical-version changes +invalidate old completion evidence and trigger quiet targeted historical rereads, +including moves with changed ctime; they do not create new live-usage receipts. + +Unchanged verified files need at most 18 header bytes each and no body decompression +on warm refresh. Resume replays the decoded prefix, counts that work and permits +cancellation. The codec accepts one explicit-size frame without a dictionary-ID +field; corrupt/unknown-size input, extra frames or trailing data fail rather than +produce false completion. Limits are 4 GiB decoded/replay, 2 GiB physical and a +64 MiB window; the first historical compressed file may exceed a soft refresh budget +within those hard caps. Cold backfills containing compressed sources use at most +two file-pass workers. Native Node Zstd (22.15+ / 23.8+) adds no dependency. +Unsupported hosts report the limitation and retain last-good verified totals. +See [architecture](ARCHITECTURE.md) for the implementation boundary. + ## What is never cached +Long-term and weekly monetary ranks add only public catalog IDs to the existing envelope: at most 178 +current records within the unchanged 64 KiB total cap, including auxiliary evidence +and retained old-rule records. No usage/cost value or source identity is stored per rank. +Old 144-record v2 envelopes and pins retain provenance; +new IDs cannot be accepted in v1 or inferred from an old rank. Downgrade readers +may fail closed without overwriting unknown current records. + +The two weekly API-equivalent series each have five levels: Bronze $100, Silver +$500, Gold $2,500, Platinum $10,000 and Legendary $25,000. Only the last two ranks +per provider are new; old IDs, thresholds and saved provenance are unchanged. +The catalog now has 24 monetary conditions within 178 rules and 50 themes. Weekly +evidence still requires a completed Monday-start week in the configured timezone +and the existing frozen reference-price basis. No live weekly receipt, extra +usage history or storage schema is introduced. The four new IDs are rejected by +legacy-envelope parsing; a pre-expansion reader fails closed without rewriting +or clearing either envelope. The reported maximum-envelope fixture is 37,374 bytes +against the unchanged 65,536-byte cap; this is synthetic validation, not native acceptance. + +The compact achievement overview adds no stored fields or source reads. A bounded +runtime set of positive combined-calendar lower-bound rule IDs reuses validated +provider days; it is recomputed on evaluation, cleared when achievements stop and +never serialized or shared as an identity. NEXT capacity checks and first-tier +display stages are pure projections, not additional awards or dates. Claude +split-only cache-creation normalization changes only accepted numeric aggregates +when both known TTL counts are explicit and safe; source JSONL remains unchanged. +Legacy Codex calendar proof recovery reuses existing numeric migration cursors and +lineage markers after bounded body revalidation, not another store or scanner. + - OAuth access or refresh tokens, cookies, authorization headers, or GitHub credentials in extension-owned derived storage. Claude's provider-owned credential file or macOS Keychain item may be updated when its OAuth token @@ -41,10 +122,35 @@ If it is expired, the existing refresh token is sent to back to that profile's credential file or macOS Keychain item. This is separate from optional BYOK AI advice, which never uses Claude subscription credentials. -Different anonymous account fingerprints are not merged. If local Codex logs -cannot establish one coherent account/window, the dashboard shows only used -API-equivalent value and explains why it cannot claim an account-level unused -allowance. +Different anonymous account fingerprints are not merged into an account-level +fact. Current-cycle total/unused API-equivalent estimates nevertheless retain +the existing low-confidence local-history calculation when account attribution +is uncertain; they are not verified account balances or remaining subscription +allowances. For Codex this can compare eligible usage from one local home/window, +potentially spanning several sign-ins, with the latest applicable quota-observation +cluster. It does not identify or divide the result among accounts. + +Each valid sample estimates total equivalent as the local priced used value +through that sample divided by its observed used fraction. Several samples use +an evidence-weighted median; coverage, attribution and boundary uncertainty lower +confidence. The displayed full estimate is no smaller than the currently known +used equivalent, and unused is the difference when the candidate remains valid. +For example, local API-equivalent use of USD 74 at an observed 74% gives an +estimated total of USD 100 and an unused equivalent of USD 26 when current use +still equals 74. This does not mean USD 26 of account credit is available. +If cumulative known local use exceeds the aggregate candidate, unused is withheld +rather than shown as a fabricated zero; the comparison does not use only the +newly added amount. Missing positive usage or valid observations leaves used value +only. Completed Codex display windows receiving multiple distinct quota series +also remain usage-only, but a single unattributed series may retain low-confidence +total/unused estimates after completion. + +The 2026-10-11 maintainer decision keeps this current-period estimate unchanged. +Accurate per-account calculation is deferred until multi-account compatibility +can establish account/window ownership. This is separate from the elapsed-time +forecast controlled by `showWeeklyQuotaPace`; its trusted-account/window admission +rules are not relaxed. No extra request, credential lookup or identity store is +introduced by this documentation clarification. Display-currency conversion is presentation-only. Pricing, aggregation, persistence, sorting, and comparisons remain USD-denominated. The extension @@ -59,7 +165,8 @@ values from the [ECB euro reference rates](https://www.ecb.europa.eu/stats/polic The preview-first sharing workspace reuses materialized provider aggregates; it does not scan logs again or create another statistics cache. Its remembered presentation is one local enum (`ccu.sharing.template`): combined activity -heatmap, Claude Share Card, or Claude token heatmap. `enableShareCard` is the +heatmap, Claude Share Card, Claude token heatmap, or (in 2.5.0) +explicitly selected earned badges. `enableShareCard` is the only visible on/off setting. The retired `showHeatmap` value remains catalogued for bounded clearing and one-release compatibility, but it no longer creates a second panel. Explicit legacy-command coordination is runtime-only: the host @@ -91,13 +198,41 @@ token volume—not productivity, billing, model capability, or provider equivalence. Local SVG and Markdown exports contain only a bounded title, selected date -range, daily Claude/Codex/combined aggregates, labels, and caveats. They do not +range, daily Claude/Codex/combined Token aggregates or explicitly selected separate +provider amount estimates, pricing/source coverage, labels and caveats. They do not contain accounts, projects, thread titles, paths, prompts, or log content. Claude Share Card and Claude token heatmap presentations consume Claude aggregates only; they do not imply that Codex exposes equivalent fields. Preview and local export are strictly network-free and do not request GitHub authentication, profile, avatar, or name data. +The heatmap defaults to Tokens and allowlists Claude/Codex/Both plus +Tokens/API-equivalent amount. Amounts require an explicit choice and reuse current +floating daily estimates, not the fixed USD pricebooks used for achievements. +Claude estimates retain current backend/fallback/override semantics; Codex prices +exact known models only. Missing/unpriced/partial/true-zero days remain distinct; +incomplete data or pricing is labelled, and a priced subset proves a lower bound. +Both renders independent monetary panels and never sums provider amounts. +The read-only projector selects at most 366 dates from existing daily arrays; +it neither scans the retained corpus nor reprices usage. Token/hidden mode does +not access money/quality getters. Hiding amounts restores Token text, colors, +titles/tooltips, metadata and Markdown and retires the old opaque preview handle. + +One accepted heatmap artifact is retained in host memory, not as an export history. +Document/request checks reject superseded replies; source references, controls, +pricing/timezone/locale/currency/display revisions bind acceptance. A changed +source or pending repricing cannot export a previous amount preview. Export checks +before and after the save dialog, including the current visible/ready document, +then writes the immutable accepted SVG bytes; +Markdown copying also requires current acceptance. + +Reset Sharing Preferences clears exactly twelve browser keys: `ccu.sharing.template`, +`ccu.sharing.achievementBadges`, `ccu.sharing.shareCardDraft`, and the nine +`ccu.combinedHeatmap.` suffixes `title`, `range`, `intensityMode`, `privacyPreview`, +`palette`, `customAccent`, `provider`, `metric`, `showAmounts`. These are bounded +presentation choices, not provider logs, quota observations or credentials. It +uses the existing request-ID/ACK path and applies defaults only after deletion succeeds. + Direct GitHub publication is a separate optional action and never runs in the background. It requests `public_repo`, verifies the repository is public and resolves its default branch, then asks the user to confirm the exact @@ -106,6 +241,419 @@ Private repositories use local SVG plus Markdown instead of silently requesting broader access. After GitHub accepts the write, success is authoritative and is reported before any warning that the atomic destination preference could not be saved. +## Weekly quota pace (2.5.0) + +`showWeeklyQuotaPace` defaults false, is shared by both providers and does not +enable quota tracking. Off exits before source getters, clock/cache/client access +or projection. On uses only the existing successful raw response and its original +`observedAt`; repainting never refreshes the observation time. Account continuity +uses the existing verified request-account HMAC in memory, not a profile, home, +label, raw account ID or credential. No new request, timer, log scan, observation +store or account history is added. The numeric DTO exposes no HMAC or identity. + +The inferred start is reset minus seven days. A 29% observation at 25% elapsed +projects 116% account-wide cycle use, including other devices/members. No forecast +is shown in the first six hours; after thirty minutes without a fresh observation +or after reset it is withdrawn. Credential invalidation zeroes the successful-cache +time and withdraws it immediately. Counter drops, changed account/source/window +or unexpected resets latch distrust until a verified ordinary seven-day rollover; +higher counters or a repaint alone cannot restore it. Current Codex local-log +evidence has unknown account trust, so forecasting remains unavailable. + +Existing refresh callbacks send a minimal document/provider-bound host patch, +including when dashboard auto-refresh is off. The client does not optimistically +recompute forecasts. The dashboard and status-bar tooltip reuse shared eight-locale +copy without changing compact bar text or custom quota formats. + + + +## Local achievements (2.5.0) + +The historical-date manifest explicitly registers 138 current rules; registration +alone never proves that retained logs can date a level. Unknown IDs or changed +counter/threshold/version/ownership/base/scope remain undated. Manual session +dates follow canonical uncapped admission; weak cache keys retain no second +records corpus and source retirement releases the annotations. Changed logs require +another calibration. Real producer-verified new usage remains an independent +receipt with its original date/unread contract, even when calibration is cancelled; +historical reconciliation does not consume it. Partial saves have a separate result. + +Gallery management reuses existing card `openDetails` local UI persistence. +Expand/collapse does not touch sources, awards or unread state. Localized +Bronze/Silver/Gold labels and exact shared conditions/units add no stored field. +One shared money-reference section shows each enabled provider's current book/date +and the non-billing explanation. Different historical books keep their exact award +associations from saved `referencePricebook` values; presentation does not relabel +or rewrite awards. Cards retain scope and pricing coverage without repeated +price/basis notes. Legacy IDs, +pins and pricebooks remain unchanged. Even a fully earned card shows the uncapped +current statistic; stale or missing evidence is not replaced by its target. + +Compact expanded rows use per-rank theme colors (foreground fallback in high +contrast) and bold, right-aligned thresholds on the same header row. “In progress” +appears only on the next rank above the highest earned when proof is ready, never +on later ranks, fully earned cards or pending proof. Verified retained-log dates +appear as bare ISO dates; Evidence details explains their source once, while +title/ARIA retains provenance. If both dates exist, only the verified date is visible +and first-recorded details remain in title/ARIA; first-recorded-only dates retain +their explicit label and unknown dates remain unknown. This display precedence +does not mutate awards, rules, date calculations, catalog, records or storage. + +The `root-portfolio` display name is Independent root tasks. Its existing aggregate +counts verifiable Codex root ownership with recognized positive model usage (input +or output), counting a root and its children once. It does not prove task completion, +read task content or add persisted identities. IDs, thresholds and saved awards are unchanged. + +The navigation/page label Share & compare, basic equal-width badge choices and +separate rank line add no data category. The checkbox-icon repair permits +the same bundled local resource source (`webview.cspSource`) as the main webview +CSP, without enabling remote images. Broad sharing-page visual unification/beautification +and a durable usage archive are deferred to the next version; version 2.5.0 adds +neither. Current derived caches are not a promised durable usage archive. + +Log-derived per-level dates are runtime-only annotations (`schemaVersion: 1`), +bound to `ruleVersion`, timezone, catalog shape and current source/pricing context. +Source changes, OFF, timezone and price changes rebuild or withdraw this derived +cache. It adds no persistent store and does not alter canonical v2 original +`recordedDay`, unknown dates, unread state or storage schema. The earliest date +verifiable from retained logs is not the account's actual complete history and +remains distinct from when the extension first recorded an award. + +Supported volume/output/cache/model/family/activity/weekly dates automatically reuse +materialized days and strictly verified fully dated Codex files. Active-day counts +and streaks include today's positive activity and use that day; qualified family/day +conditions require a closed day and use day + 1. Four-week windows exclude the first +incomplete week using the first positive activity day, not leading zero-only rows. +This follows existing rules. Only manual calibration additionally reduces already-held, +uncapped Claude record memory for output-session, Fable-session and same-session +Fable/other-family dates. Date reduction uses only materialized numbers or held +record usage metadata, without extra log-body scans, conversation-content inspection +or metadata export. Normal incremental refresh may read allowlisted new/changed +JSONL under existing analysis settings, without forced full rereads, new scanners or +network access. Ordinary warm updates do not regroup those records for dates. Joint-agent/effort, +hour/night, single-task money and timestamp-less migrated local-action dates remain +unknown or retain first-record dates; today is never invented as a replacement. + +Calibration is local-only, single-flight and incremental, with no forced full scan, +old-award clearing, OAuth/quota, online pricing, BYOK or other network call. A pending +guard pauses historical grants and baseline advancement while real native receipts are staged; the guarded +commit reconciles history quietly without new notices or forced grants. Busy work, +an unaccepted current index census, failure or pre-commit cancellation is not success. +Verified partial history retains lower bounds or unknowns; completion does not mean +all award scopes are fully covered. Cancellation before the historical commit adds no historical awards; +after a valid atomic commit, saved records remain and late cancellation must not +misreport the completed commit. +Future date handlers require an explicit allowlist and rule-version isolation; +new scopes default to unknown, and future catalog envelopes remain read-only and +fail closed. Source parsers, dependencies and package version are unchanged. + +Codex achievement reuse compares the rebuilt full file census and independent +witnesses exactly, then reuses detached, deeply immutable copies for revision and +calendar/money-projection memoization. Flags or matching totals alone do not prove +equality. These copies and caches are runtime-only, not another persistent index; +source reads, exported identity and clearing boundaries are unchanged. + +Flat Claude `agent-*.jsonl` files without a path-proven parent are excluded only +from session-achievement evidence. Token totals, prices, daily aggregates and +visible session rows remain unchanged. Nested agents retain their parent-path +contract, source logs stay untouched, and cached/saved awards are retained. + +`enableAchievements` defaults to true. The once-only upgrade reminder reuses +`ccu.lastSeenVersion`; it requests no new permission, opens no panel automatically, +respects disabled release reminders and preserves an existing achievement opt-out. +Historical unlocks are saved quietly before new-activity notifications become +eligible. Later notices are brief, dashboard-only, and never play sound or move focus. +Night/24–48-hour output badges do not produce these notices. + +The host's 64-character achievement proof revision is memory-only, streamed +from existing numeric/identity aggregates, never logged, persisted or exported. +It separates proof changes from chart changes without retaining a second index +or reading logs. Turning achievements off skips this projection and evaluation; +source replacement retires the cached proof. Existing grants remain saved. + +Codex calendar witnesses are runtime-only projections of the provider's independent +current contribution size/offset/prefix/zone, admitted only with a verified current +manifest and complete base index, without period migration, oversized-line discard +or lineage reconciliation. +`sourceRevision` binds the full census, global calendar fence and numeric day +vectors in the existing hash/cache; ancestry is resolved before date selection. +Neither witnesses nor revisions are logged, persisted or exported; they add no +raw identity exposure, index field, I/O, timer or store. Weekly money coverage's +`scopeLowerBound` is only a runtime marker alongside numeric coverage, distinct +from pricing coverage; no subset identity list reaches the UI or exports. + +Nine fixed, memory-only evidence-domain latches distinguish first/recovered +history from later eligible awards. Pending or stale proof is shown explicitly, +not as zero usage; saved awards remain independent. No action history, new +persistent key, source identifier or network request is added by these latches. +Record loading, unavailable, changed and disabled states have explicit eight-locale +copy before gallery counts render, rather than an apparent empty `0 / 0` catalog. +A verified Claude cumulative subtotal remains a `≥` lower bound while an enabled +Codex source is unverified. Next-target names/ranks and duplicate-free SVG rank +labels are display projections, with no new stored fields or award authority. + +Plugin notices require an actual target crossing in a successfully saved local +action delta. Hydrated/imported actions grant quietly; re-enabling does not turn +them into new actions. At most six public plugin-rule IDs are held temporarily +in memory until their grant saves, or the feature is disabled/cleared. There are +no action timestamps in that provenance latch. A failed grant save can retry without +repeating the action. Provider-log proof retirement does not cancel these independent +local-action awards. Saving while the dashboard is hidden/closed does not open or +refresh it. This provenance guard is not a durable missed-notice journal. + +Intermediate Token stages are ephemeral display targets derived from formal +thresholds, not new awards. They store no milestone history, produce no notices, +and never become selectable export badges. + +The automatic overview retains at most six public series/rule selection keys +(three badges plus three goals) in host memory during a visit. It adds no timestamp, usage history or +share consent. Entering the gallery can recompute this small display selection +from accepted aggregates without reading logs. Manual pins and first-record dates +use the canonical envelope below, not a second Webview preference copy. +Manual mode shows only up to six chosen themes, with saved order and no automatic +fillers. Closed-card pin controls and the searchable editor use the same lease; +bulk selection/reorder checks the current pin revision. Nearby save errors retain +the old choice, with stable summary focus when a control is removed or disabled. +The toolbar precedes the overview; Small/Medium/Large changes collection emblems +to 56/80/112 px and showcase emblems to 56/72/96 px while preserving text/click areas. +Above 960 px, the badge column fits its content up to two overview-card widths +plus their internal gap; next targets fill the remaining space, with 16 px between +columns. At 960 px or below they stack directly with a 12 px gap; auxiliary history +sections follow both. +Count chips and decorative control icons retain the existing localized labels. +The pure `achievementUiIcons` module renders known inline SVG paths without +dependencies, network access or additional asset files. These presentation changes +add no stored fields or source reads and preserve controls, persistence, eligibility +and message protocols. Early-data evidence remains under investigation; no data +repair or new validation pass is claimed here. + +Pending/stale card details show calendar, model-ownership, money and current-source +requirements in all eight I18n locales, with information icons on notes. These +scope requirements do not classify exact root causes, expose raw source/error +details, or confer new evidence authority. + +The host returns early only for explicit +`codexWorkerCancellationRequested`, an `error` outcome with +`failureCode === 'cancelled'`, and exactly `result.diagnostic?.failedFiles === 0`. +A missing count remains unknown and follows failure/revocation behavior. +The zero-count check is defensive: the current producer reports `failedFiles: 0` +on terminal cancellation/throws because final file diagnostics are unavailable. +It does not prove that no files failed before cancellation. +The cancellation result supplies no applied snapshot, success timestamp or new +acceptance receipt; prior failed state and last successful time remain intact. +Processing resumes after a fresh successful scan. The interrupted result does not +authorize new awards; subsequent history-only reconciliation remains import-only, +without live dates or notices. All error results mark the manifest incomplete. +Reported nonzero/missing failure counts, other errors and unowned cancellations +retain failure/revocation behavior. Tests supplying a nonzero cancelled count cover +a defensive host branch, not a failure census supplied by the current producer. + +Valid period-migration checkpoints now resume with `tokenEventsSeen` below the +inherited prefix when `days` is empty. Their bounded cursor advances across passes, +persistence/restart and cancellation; dates are promoted only after the complete +prefix, EOF and current-timezone witness checks. Reviewed regressions cover +batching, persistence, cancellation, negative EOF cases and unchanged warm reads +of zero bodies. Undated, oversized and conflicting evidence remains conservatively +pending; this does not resolve every missing date. + +The gallery persists only `all` / `next` / `earned` filter and small/medium/large +size enums in existing Webview UI state; disclosure keys are public series IDs. The sharing +return route remembers only a Claude/Codex enum and a one-shot focus boolean. They contain no +usage, paths or identities and are removed by Reset Dashboard UI State. Opening, +filtering or expanding the gallery does not read logs or add network calls. +Current-statistics navigation also keeps one public theme key and a provider enum +in that same UI state until return, ordinary navigation or UI reset. It stores no +dates, models, paths or new action history and does not scan provider logs. +Explicit sharing can include retained earned badges from a disabled provider. +The gallery and new pins filter by enabled providers. Retained badges do not supply +current progress or new grant authority, and sharing does not re-enable a provider +or read its logs. +Badge-sharing preferences remember only manually selected public rule IDs (at +most nine). One accepted SVG and its opaque preview handle stay in provider +memory, not on disk as an export history. The current language is checked before +export; switching language never silently saves an outdated-language preview. +Export writes those exact preview bytes; +replacement, reset, disable or panel close releases the retained artifact. + +The extension-owned ledger contains only the following validated fields: + +- `globalStorage/achievements-v2.json`: the canonical award envelope, at most + 64 KiB and one current record for each of 178 public rule IDs. Fields are schema/catalog/rule + version, monotonic clear generation/record sequence, four reason enums + (`existing`, `history`, `usage`, `activity`), unread boolean, optional first-record + calendar day and timezone, at most six ordered public series pins, automatic/manual + showcase mode, pin revision and one notification + cooldown number, plus the existing auxiliary proof described below. The entire + envelope, including auxiliary proof, shares the 64 KiB limit. A bounded legacy + boundary contains only the old derived-store generation/sequence/signature. + At most 48 old-rule records retain saved awards from the 21 changed model/team/ + effort/rainbow conditions without authorizing new rules; unknown dates stay unknown. + Money records may retain a public frozen-reference pricebook version, never + personal amounts. Reads accept the provider's current books plus the three + explicitly published v1 labels in `achievementReferenceBooks.ts`: Claude's + `api-reference-2026-10-anthropic-v1` and + `api-reference-2026-10-aws-bedrock-in-region-v1`, and Codex's + `api-reference-2026-10-openai-v1`. Changing the current book does not relabel + saved records; new `append` and `recordUsage` drafts require a current provider + book. Historical readability does not authorize a new grant. This registry + adds no prices, IDs, storage fields or schema and does not admit future books + by string pattern. Unknown non-current books fail closed, leaving the entire + envelope untouched even by Clear All; the exact deletion scope is not broadened. + Notice reservation + updates the shared five-minute cooldown, not unread state. Only foreground display + confirmation with a current opaque runtime token reads its represented IDs through + the issued sequence; other/later awards remain unread. Failed delivery, OFF, blur, + clear or a replaced document cannot confirm a retired notice. The missed-notice + summary is folded by default and shows at most six exact visible non-historical + levels per group. Explicit Mark this group read saves only those IDs after + current document/provider/source/focus/clear checks under the same lease; + other windows' later awards remain unread. The opaque group and document + identities exist only in memory, not in the file or network. Failed saves + retain unread state; browsing/pinning/sharing are not confirmation. No extra + timer or log scan is added. Earned badge details save one pin/unpin intent under the + shared v1 lease, with at most six public keys; no award, read status or share choice + is changed. Failed saves retain the old preference; old/OFF/clear requests are rejected. + Disabled-source pins can be removed explicitly from a folded list of at most + six saved public keys; this does not enable a provider or read its logs. + The gallery shows at most three recent non-historical series and a folded + thirty-row record projection without deleting awards. Dates are first-record + days in their saved zones, not inferred attainment dates. Old records explicitly + retain unknown dates; ordinary browsing is not a read ACK or sharing consent. + Audited old awards migrate quietly with unknown dates. Verified Claude/Codex Token + crossings and newly crossed, saved local actions retain unread first-record + days—not inferred original attainment times. Other usage reconciliation stays + quiet until its own new source activity can be proved. Claude cache-creation + origin adds one safe numeric runtime component, never a persisted field. + Claude/Codex observation windows, + fixed numeric deltas and retry receipts exist only in bounded runtime memory; + no new source identity or timeline is stored. Claude retains references to the + already committed materialized inputs, not another copied log corpus. Only late history recorded after + the observation's canonical sequence can be replaced by proven live origin; + pre-existing history is not rewritten. Retired/disabled/cleared sources discard + pending proof. No action timeline is retained. + The default-on `showAchievementNotices` local setting only controls floating + in-panel notices. Turning it off keeps grants and unread records, retires queued + delivery/ACKs and never replays missed notices when re-enabled. It is not counted + in the setting-exploration achievement ledger and does not start scans. + Transactions merge the latest bytes under a cross-process lease, with flushed + private temporary files and atomic rename. Missing-state initialization waits + for legacy audits and does not run while OFF. Unknown/future/malformed/oversized + or symlink files fail closed without quarantine or overwrite. Clear All deletes + exact regular `.achievements-v1.json..tmp` / `.achievements-v2.json..tmp` + files and related old keys under that lease, replacing the canonical v2 file with an empty clear fence + (schema/generation/sequence, empty records/pins/activity, zero cooldown/hour + maximum and no calendar peak), then writes an empty v1 downgrade fence. It is not + unlinked, preventing a still-running old host from resurrecting legacy awards. + Hosts with the old generation suspend writes until reload. Leases, + lookalikes, unknown files and provider sources are not deleted. Inventory P12 + reports only aggregate byte/record counts, not record contents or dates. +- `globalStorage/achievements-v1.json`: read-only migration input outside explicit + clearing; not a current award mirror. Existing v2 is authoritative after downgrade. + Both files share the existing v1 lease; interrupted clear fences resume under it. + +- `ccu.achievements.grants.v5`: audited legacy migration input of at most 124 IDs; + real hosts do not write a grant mirror after adopting the canonical envelope. +- `auxiliary.activity` (old `ccu.achievements.activity.v2` migration input): at most 29 safe setting-key names and four feature + enums. Only deliberate, successful local actions count; no setting values, + secrets, paths, consent decisions or default/migration/reset events are retained. +- `auxiliary.hours` (old `ccu.achievements.hours.v3` migration input): the numeric maximum positive-output hour streak, + capped at 48. It is not a list of human working hours. +- `auxiliary.calendarPeak` (old `ccu.achievements.calendarPeak.v1` migration input): one verified four-completed-week average + maximum plus measurement versions, timezone and provider-presence bitmask. + Reuse requires matching context and complete current normalized numeric evidence. + Claude-only peaks ignore unrelated Codex parser/period versions; rule version, + timezone, provider-presence and complete-current evidence checks remain. Any + context including Codex still requires matching parser and period versions. + Canonical transactions merge activity sets/hour maxima and matching-context + calendar peaks; different calendar contexts are never combined. Real hosts do + not mirror these fields back to cached Memento entries. The empty clear fence + wins over every old auxiliary migration input, as well as old award IDs. +- Exact audit/migration booleans and a bounded rule-ID migration draft; unknown + fields are rejected. Clear All includes these and known older achievement schemas + by exact name, without deleting unrelated or future state. + +Non-monetary collaboration/rainbow model recognition in `achievementModels.ts` +is independent of fixed pricing aliases. Claude admits only explicit `global`, +`us`, `eu`, `jp`, `apac` and `in` Anthropic prefixes, rejecting `xx`; recognized +named-family labels without fixed rates remain unpriced. This adds no pricing +coverage or proof of upstream model identity. + +Numeric evaluation reuses materialized indexes. Six API-equivalent achievement +themes use fixed versioned USD reference prices +(`api-reference-2026-10-{backend}-v1`, dated 2026-10-09), exact known models and +complete component rates (including known Claude +cache-write TTL). They cover cumulative priced subtotals, completed Monday-start +weeks in the current data zone and one Claude session/verified Codex task tree. +Priced subsets prove a lower bound, not a bill. Monetary history stays quiet; +only the four cumulative money tiers per provider can save unread first-record +day/zone from accepted native Claude/Codex tail-window increments. Before/after +cumulative evidence and the increment must use the same frozen public pricebook; +the increment must suffice from the prior total and be necessary to the new total +crossing the tier. Price changes, imports and rebuilds do not establish live origin. +Completed-week and single-session/task money awards stay quiet with unknown dates. + +Monetary-origin proof is runtime-only: public provider/backend/book version, +bounded exact units, microUSD and eligible processed Tokens. The new numeric money, +exact units and proof counters are never added to the index or award file; the award +retains only public `referencePricebook` plus its ordinary bounded record metadata. +Money-only pricebook/scope invalidation does not cancel independently valid Token +receipts; common source/index, clear, OFF and disposal guards still apply to both. +A Claude backend change also invalidates the index generation and can retire pending +Token proof. Saves recheck source/scope/book, date/timezone, clear generation and OFF +under the shared lease. Failed saves retain bounded retryable proof without an +optimistic date. Same-book provisional peer history written after the accepted +baseline sequence may be repaired; pre-baseline history or a different pricebook +keeps its saved basis/date. One accepted update uses the existing notice batch. + +The existing Codex effort-breadth details consume an optional six-row runtime +`effortStatistics` DTO, cached by complete-current-index proof revision. It pools +each non-approval-reviewer root task × effort and requires that population's own +positive output; no raw root/session IDs, model labels or exact-unit strings reach +the UI. Processed Tokens, output and distinct qualifying roots jointly determine +progress using the existing shared tier constants. The frozen USD subtotal and +coverage are informational and do not qualify effort awards. Model/effort marginals +are never allocated proportionally; only a reconciled single-model or single-effort +population admits a known priced subset. Empty, unpriced, partial, invalid and +overflow states remain explicit: partial money shows a lower bound, unsafe money +is withheld; absent/incomplete evidence shows +unknown, not six zero rows. No new effort preference, rule, storage or log read is added. + +Once the verified current manifest is fully indexed, positive Codex milestones +may use independently verified lineages, excluding conflicting branches and +descendants. Sound witnessed dates support activity/family/weekly Token lower +bounds; partial history cannot prove zero, a four-week mean or a retained peak. +Weekly money revalidates the full-census revision and day vectors, then requires +exact billable/model date equality in each selected fully dated file. Only positive +amounts support a partial-scope lower bound; partial zero-only/empty remains pending. +Date coverage does not imply pricing coverage: unknown prices and overflow retain +their existing states. Cumulative/task scopes and reference prices are unchanged. +This selection stays in memory; no new scan, network call or persistence is added. +Codex's per-file index additionally +stores positive-output UTC hour IDs with a verified byte offset and excluded-lineage +event count: at most 32,768 hours per file and 524,288 unique hours per snapshot. +Capacity exhaustion fails closed instead of certifying a partial hour history. +Existing indexes can complete this proof through a bounded, resumable pass, leaving +primary token totals intact. With achievements off, no extra historical proof pass +is scheduled; ordinary new-log indexing still runs. + +Refreshed grants require current verified evidence; displayed stale statistics +after a refresh failure cannot grant new awards. Saved legitimate awards remain +available when the underlying rolling window expires. Source changes and clearing +invalidate queued evidence. Continuous output means recorded output in each UTC +hour, not continuous human work. Week-based rules use the configured timezone; +partial dates cannot be counted as proven zero-use dates. + +Badges cover local history, which may include shared accounts and multiple sign-ins. +They do not certify identity, skill, productivity, subscription allowance or bills. +Badge sharing exports only selected public rule labels, tier markers, bundled icons, +caveats and the fixed public repository link +`https://github.com/ClaudeCodeUsage/ClaudeCodeUsage`, not source paths, account data +or logs. The SVG hyperlink contains no user-supplied URL or tracking parameters; +preview/export does not fetch it. It adds no automatic network request, external +image dependency or personal metadata. Long labels wrap with reserved spacing; +the accepted immutable preview/export seal remains unchanged. Choose up to nine earned badges +and accept a local preview before export; no automatic export, sign-in or upload. + ## Clear and migration behavior Separate Command Palette actions rebuild the Codex derived index, clear quota @@ -164,10 +712,20 @@ and the clear control is run again (or the setting is removed manually). ## Known limits +This inventory describes data contracts, not a release or acceptance record. +Native/F5, installed-package and performance checks require their own evidence; +browser checks do not establish those results. Live monetary award dates are +limited to verified cumulative crossings; other monetary scopes remain historical. +Source-level guards check source contracts, not executed UI transitions, native +Webview behavior or installed-package acceptance. + - A reset that never appears in an official response or local structured event cannot be reconstructed. Day-only Codex evidence lowers boundary confidence. -- Local Codex histories may include several sign-ins. Ambiguous overlap remains - used-only; the extension never invents an account split. +- Local Codex histories may include several sign-ins. Current-cycle total/unused + estimates retain the low-confidence local-history approximation described above. + Multiple distinct quota series mapped to one completed window remain used-only; + the extension never invents an account split. Accurate per-account separation + remains deferred to multi-account support. - API-equivalent values use currently known public API prices and pricing coverage. They are estimates, not provider bills or subscription prices. - Retention of source logs is controlled by Claude Code and Codex, not this diff --git a/LOCAL-DATA.zh-CN.md b/LOCAL-DATA.zh-CN.md index 6edb618c..9d57c9f8 100644 --- a/LOCAL-DATA.zh-CN.md +++ b/LOCAL-DATA.zh-CN.md @@ -4,22 +4,82 @@ Claude Code Usage 采用本地优先设计。插件只读取供应商拥有的 并保留快速刷新、重置识别和额度估算所必需的有界派生状态;API 等效成本始终 只是估算,不是账单。 -本文是 v2.3.x 开发线面向用户的数据清单。v2.3.1 字段级规范见 +本文是 2.5.0 的当前数据清单与版本化契约补充,整合成就系统与 +压缩日志兼容。 +v2.3.1 字段级历史基线见 [`docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md`](docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md)。 +本文后续压缩、分享、额度节奏和成就章节规范其新增运行期/持久字段及精确清除边界, +包括 canonical v2 封套与十二个分享键;不放宽未变化的源日志、凭据、账号隔离或 +明确网络同意限制。 ## 插件读取和保留什么 +长期等级及周金额等级仅给既有封套增加公开规则 ID:最多 178 项当前记录,连同辅助证明和保留的 +旧规则记录,整体 64 KiB 上限不变。不保存逐等级用量、金额或来源身份。 +旧 144 项 v2 封套和固定项保留原始来源;新 ID 不能被 v1 接受或从旧等级 +反推。降级读取可拒绝未知新记录,但不能覆盖权威封套。 + +Claude/Codex 周 API 等价金额各有五级:铜 $100、银 $500、金 $2,500、铂金 $10,000、 +传奇 $25,000。仅各供应商后两级为新增,旧 ID、门槛及已存来源不变;当前目录共 178 项 +规则,其中金额条件 24 项,主题仍为 50 个。周证据仍须设置时区内已结束的周一自然周及 +既有固定参考价,不新增周奖章实时收据、额外用量历史或存储 schema。四个新 ID 不能通过 +旧封套解析;扩展前的读取器拒绝未知等级,不改写或清除任一封套。已报告的最大封套夹具为 +37,374 字节,低于不变的 65,536 字节上限;这是合成验证,不是原生验收。 + +紧凑成就顶部不增加持久字段或源读取。正向综合日期下界只在运行期保存有界规则 ID +集合,复用已验证日期,每次计算重建、关闭成就时清空,不序列化或作为身份分享。 +下一目标家族容量检查与首级前展示阶段只是纯投影,不新增奖项或日期。Claude +split-only 缓存写入仅在两个已知 TTL 分项明确且安全时恢复数值聚合,不改源 JSONL。 +旧 Codex 日期补证复用既有数值迁移游标,经过有界正文重验后写入谱系标记,不建第二份 +存储或扫描器。 + | 数据类别 | 来源 | 插件保留的内容 | 保留与清除 | 远程交互 | |---|---|---|---|---| | Claude 用量记录 | Claude Code 本地 `projects/**/*.jsonl` | 运行时用量记录和内存中的逐文件索引;仅在启用内容分析时保留有界提示样本 | 窗口重载/退出即释放。本地数据控制绝不触碰源日志;另行启用的“会话操作”可把一个精确确认的日志移入系统回收站(可恢复) | 默认无;只有用户另行预览并发送建议请求时才可能包含获准内容 | -| Codex 用量记录 | `$CODEX_HOME/sessions/**/*.jsonl` 与 `archived_sessions/**/*.jsonl` | 版本化增量索引:匿名文件键、偏移量、数值用量、日期、模型/effort 和已净化结构聚合 | 保留到重建、明确清除、schema 替换或宿主移除 | 无 | +| Codex 用量记录 | `$CODEX_HOME/sessions` 与 `$CODEX_HOME/archived_sessions` 下递归的 `.jsonl` 和 `.jsonl.zst` 普通文件 | schema-3 增量索引:匿名文件键、decoded 偏移量、数值用量、日期、模型/effort、已净化结构聚合及可选压缩来源指纹/EOF 标记 | 保留到重建、明确清除、schema 替换或宿主移除 | 无 | | Codex 标题 | 仅 `$CODEX_HOME/session_index.jsonl` | 流式读取 `id → thread_name` 供运行时显示;标题不写入索引 | 仅运行时 | 无 | | 额度观测 | Claude 官方额度响应或 Codex 结构化 rate-limit 事件 | 供应商、本机匿名账号 epoch、观测/重置时间、周期、已用/剩余比例、匿名窗口标识、来源、可信度和质量标志 | 保留不超过 180 天;边界保护压缩后每个供应商/账号/周期最多 512 条;可单独或随全部派生数据清除 | 启用时 Claude 额度查询会访问 Anthropic;过期 token 会经 Anthropic 刷新并写回原凭证来源。Codex 额度证据只来自本地 | -| 设置与 UI 偏好 | 用户选择 | 类型化设置、标签页/筛选、有限后台任务状态、分享呈现方式、分享卡未应用的控件草稿、热力图标题/范围/隐私预览,以及可选显示币种预设 | 保留到重置、清除或卸载;普通 VS Code 设置可能参与 Settings Sync | 插件不主动传输,也不查询汇率 | +| 设置与 UI 偏好 | 用户选择 | 类型化设置、标签页/筛选、有限后台任务状态、分享呈现方式、分享卡草稿、热力图标题/范围/隐私/配色/供应商/指标/显示金额、图鉴筛选/尺寸及可选显示币种预设 | 保留到重置、清除或卸载;普通 VS Code 设置可能参与 Settings Sync | 插件不主动传输,也不查询汇率 | +| 热力图产物(2.5.0) | 最多 366 个选中自然日的已物化聚合 | 一份不可变已接受 SVG/Markdown、绑定文档的不透明预览 ID、来源引用及白名单配置/质量身份 | 仅运行期;控件、来源、价格、显示变化、重置或释放时替换/退役 | 无;预览/本地导出不新增源日志读取或网络请求 | +| 周额度节奏(2.5.0) | 既有成功原始额度缓存与原始观测时间 | 仅运行期最多 16 窗口数值投影、原响应元数据及有界已核验账号/窗口连续性锚点;默认关闭的共享 STATE 偏好 `showWeeklyQuotaPace` | 不增加观测存储;来源/凭证失效撤回预测,释放时清除运行期状态 | 不增加远程交互,不启用额度跟踪、不索取新凭证 | | 分享目标 | 用户输入的 GitHub 目标 | 单个版本化 `ccu.heatmapDestination.v1` 对象,包含可选 `owner/repository/path`;不保存 GitHub 凭据 | 保留到重置分享偏好 | 仅在用户明确发布并确认精确目标后发生;即使保存此便利偏好失败,成功写入仍会如实报告 | | 建议证据 | 本地派生聚合与明确反馈 | 粗粒度观察、建议、评分、暂停、可比任务指标、覆盖率和版本 | 有界本地台账;可独立清除 | 默认不发送;只有单独确认后才发送精确预览过的请求 | +| 本地成就(2.5.0) | 已验证的数值用量及白名单内的主动本地行为 | ≤64 KiB 获奖封套:公开 ID、序号/原因/未读及可选首次记录日/时区;安全设置键/功能枚举(不含值)、小时连续最大值及合格四周峰值 | 默认开启;关闭停止新授予及额外证明。清除全部处理精确成就字段,只保留空清除屏障 | 无;分享须明确预览并本地导出,不要求 GitHub 认证 | | 建议 API Key | 用户自备密钥 | 仅存于 VS Code SecretStorage | 保留到清除密钥或全部派生数据 | 只作为用户明确配置端点的授权凭据 | +## 压缩 Codex 来源(2.5.0) + +Zstd 对原始 JSONL 进行无损编码,不是 LLM 摘要,不改变日志中的 token 计数。 +只有上述两个用量根目录允许 `.jsonl.zst`;仍排除符号链接、非普通文件、其他根、 +凭据与 SQLite。独立的 `session_index.jsonl` 标题读取许可不变。源文件绝不改写, +不保存解压后的明文副本。 + +schema 3 附加可选 `compression: 'zstd'`、`storageSize`(物理字节数)、 +`storageCtimeMs` 与 `compressionVerified: true`。前三者和物理 `mtimeMs`/`dev`/`ino` +共同识别压缩表示,最后一项标记该版本已成功核验 frame EOF。`size`、parser 及 migration +offset 继续使用 decoded 字节坐标,缺少新字段的旧记录仍按 plain 处理。这些匿名数值/ +枚举/布尔字段保存在既有逐文件索引内,沿用其重建/清除生命周期,不新增存储或远程披露。 +确定性的解码失败只保留 `compressionFailure: { version: 1, code }` 及物理指纹; +code 为允许列表枚举,不保存原生错误、路径或 message。这类记录不含用量或证明, +保持未完成状态,同一物理版本不反复重试;临时 I/O 与来源变化仍可重试。 +未知失败版本须丢弃并重新验证。 +只有已验证压缩版本可向保留的额度历史加入观测;临时 checkpoint 或拒绝的帧不能写入该历史。 + +这是可重建索引,不是历史备份:来源真正从两个允许根目录消失后,其用量贡献也会移除。 +压缩或移入归档不等于删除;仍存在的规范来源会重新核对,不重复累加。 + +同目录同 basename 的 plain JSONL 为规范来源,压缩文件的 HMAC 键剥离 `.zst`; +这一选择不声称内容相同。跨区副本仍须通过完整清单的 lineage 去重。编码/物理版本 +变化会作废旧完成证据并定向静默重读历史,包括 ctime 变化的移动,不生成新的 live usage receipt。 + +未变化且已验证的文件在 warm refresh 中每份最多读取 18 字节 header,正文解压量为零。 +Resume 重放 decoded 前缀,计入工作量并允许取消。Codec 只接受显式大小、无 dictionary-ID +字段的单 frame;损坏/未知大小、额外 frame 或尾随数据明确失败,不产生虚假完成状态。 +硬上限为解压/重放各 4 GiB、物理 2 GiB、window 64 MiB;首个历史压缩文件可在 +硬上限内超过每轮 soft budget。含压缩来源的冷回填最多使用两个 file-pass worker。 +使用 Node 原生 Zstd(22.15+/23.8+),不新增依赖。 +不支持的宿主明确提示限制并保留上次已验证统计。实现边界见[架构说明](ARCHITECTURE-zh-CN.md)。 + ## 绝不缓存什么 - 插件自有派生存储中不会缓存 OAuth access/refresh token、cookie、authorization @@ -37,8 +97,23 @@ Claude Code Usage 采用本地优先设计。插件只读取供应商拥有的 凭证文件或 macOS 钥匙串项目。这与可选的自备密钥 AI 建议分开;后者不会使用 Claude 订阅凭据。 -不同匿名账号 fingerprint 不会合并。如果本地 Codex 日志无法证明属于同一账号 -和同一窗口,仪表盘只显示已用 API 等效价值,并说明为何不能声称账号级未用额度。 +不同匿名账号 fingerprint 不会合并为账号级事实。账号归属不明时,当前周期的总额/ +未用 API 等价值仍保留既有的低可信本地历史估算,不是已核验账号余额或剩余订阅额度。 +Codex 可把同一本地 home/窗口内、可能包含多次登录的合格用量,与最近适用的额度 +观测簇对照;它没有识别各个账号,也没有把结果分账。 + +每个有效样本用“截至该样本的本地已定价用量等价值 ÷ 观测已用比例”估算总等价值。 +多个样本取按证据加权的中位数,覆盖、归属与边界不确定性降低可信度。展示总额不小于 +当前已知已用等价值;候选仍有效时,未用等价值为两者差额。例如,截至某次观测的本地 +API 等价用量为74美元、观测已用74%,则估算总额100美元;当前用量仍为74美元时, +未用等价值为26美元。这不表示账号还剩26美元可用余额。累计已知用量超过汇总候选时撤下 +未用值,不伪造零,不能只拿新增量比较。缺少正用量或有效观测时仅显示已用;多个不同 +额度系列映射到同一已结束 Codex 展示窗口时也仅显示已用,但单一未归属系列结束后仍可 +保留低可信总额/未用估算。 + +维护者于2026-10-11明确保留本版当前周期的估算方式。准确按账号计算留待后续多账户 +兼容建立可验证的账号/窗口归属后实现。它与 `showWeeklyQuotaPace` 按时间进度计算的 +预测不同,不放宽后者的可信账号/窗口准入;本次说明不新增请求、凭据查询或身份存储。 显示币种换算只作用于呈现层。定价、聚合、持久化、排序和比较仍全部以 USD 为基准。 插件只保存一个通过校验的币种预设代码,并使用日期为 2026-09-09 的内置参考汇率; @@ -51,7 +126,8 @@ Claude 订阅凭据。 预览优先的分享工作台直接复用已物化的供应商聚合,不会再次扫描日志,也不会新增第二套 统计缓存。记住的呈现方式只是本地枚举 `ccu.sharing.template`:综合活动热力图、Claude -分享卡或 Claude token 热力图。`enableShareCard` 是唯一可见的开关。退役的 `showHeatmap` +分享卡、Claude token 热力图,或 2.5.0 中明确选择的已获得徽章。 +`enableShareCard` 是唯一可见的开关。退役的 `showHeatmap` 仍保留在 catalog 中,以便执行有界清除并维持一个版本的兼容性,但不再生成第二个面板。 显式旧命令的协调仅存在于运行时:host 把 provider 生命周期内单调递增的 revision 与唯一 live intent 分开保存,并在 Webview ACK 精确 revision 后删除 live intent。浏览器存储不保存 @@ -75,17 +151,313 @@ Codex 的 cached input 已包含在 input total 中,reasoning 已包含在 out 因此均不得重复相加。综合活动量只代表本地 Token 活动,不代表生产率、账单、模型能力 或两个供应商的能力等价。 -本地 SVG 与 Markdown 导出只含有界标题、所选日期范围、每日 Claude/Codex/综合聚合、 -标签和免责声明;不含账号、项目、线程标题、路径、提示或日志内容。 +本地 SVG 与 Markdown 导出只含有界标题、所选日期范围、每日 Claude/Codex/综合 Token +聚合或主动选择的分供应商金额估算、定价/来源覆盖、标签和免责声明;不含账号、项目、 +线程标题、路径、提示或日志内容。 Claude 分享卡和 Claude token 热力图只消费 Claude 聚合,不暗示 Codex 具有等价字段。 预览与本地导出严格不联网,也不请求 GitHub authentication、profile、头像或名称数据。 +热力图默认 Token,白名单选择 Claude/Codex/两者及 Token/API 等价金额。 +金额须主动选择,复用当前每日浮动估算,不使用成就固定美元参考价。Claude 保留当前 +后端/回退/覆盖价语义,Codex 只计精确已知型号。无数据、未定价、部分定价与真实 +已定价零值分别显示;数据或定价不足明确标示,已定价子集只能证明下界。两者模式 +独立呈现金额,绝不跨供应商相加。只读投影从既有每日数组选最多 366 日,不扫描 +全量记录、不重新计价;Token/隐藏模式不访问金额/质量 getter。隐藏金额同步恢复 +Token 的文字、颜色、标题/提示、元数据与 Markdown,并退役旧不透明预览句柄。 + +宿主内存只保留一份已接受热力图,不保存导出历史。文档/请求检查拒绝过期回复, +来源引用、控件、价格/时区/语言/币种/显示修订绑定接受状态。来源改变或价格 +刷新待完成不能导出旧金额预览;保存对话框前后复核当前可见、就绪的文档及接受状态, +再写入不可变已确认 SVG 原字节, +复制 Markdown 同样要求当前有效产物。 + +「重置分享偏好」精确删除十二个浏览器键:`ccu.sharing.template`、 +`ccu.sharing.achievementBadges`、`ccu.sharing.shareCardDraft`,以及 +`ccu.combinedHeatmap.` 的九个后缀 `title`、`range`、`intensityMode`、`privacyPreview`、 +`palette`、`customAccent`、`provider`、`metric`、`showAmounts`。它们只含有界呈现选择, +不是日志、额度观测或凭证;复用既有请求 ID/ACK 通路,删除成功后才应用默认值。 + 直接发布到 GitHub 是独立的可选动作,且绝不在后台运行。它仅请求 `public_repo`, 先验证仓库公开性和默认分支,再要求用户确认精确的 `owner/repository/branch/path` 以及创建或覆盖动作。私有仓库应改用本地 SVG + Markdown,不会静默索取更宽权限。GitHub 接受写入后,插件会先如实报告成功;若原子化 目的地偏好保存失败,再另行显示警告。 +## 周额度节奏(2.5.0) + +`showWeeklyQuotaPace` 默认 false,两家供应商共用,开启不会启用额度跟踪。关闭时 +在来源 getter、时钟/缓存/client 访问及投影之前退出。开启后只使用已有成功原始 +响应及其原始 `observedAt`,重绘不刷新观测时间。账号连续性只在内存使用既有请求中 +已核验的账号 HMAC,不使用 profile、home、标签、原始账号 ID 或凭证。不增加请求、 +定时器、日志扫描、观测存储或账号历史;数值 DTO 不含 HMAC 或身份。 + +起点推算为重置减七天。已过 25% 时观测已用 29%,预测整周期账号级用量为 116%, +包含其他设备/成员。前六小时不预测;超过三十分钟没有新观测或重置后撤回。 +凭证失效把成功缓存时间清零即立即撤回。计数下降、账号/来源/窗口变化或异常 +重置会锁住不可信状态,直到验证正常七天滚动重置;恢复更高计数或重绘不能解除。 +当前 Codex 本地日志的账号可信度未知,因此预测不可用。 + +既有刷新回调发送绑定文档/供应商的最小宿主补丁,暂停仪表盘自动刷新仍可更新; +客户端不乐观重算预测。仪表盘与状态栏提示复用八语文案,不改变简短文字或自定义格式。 + +## 本地成就(2.5.0) + +日期清单显式注册当前 138 项规则,但注册不表示日志足够确定日期。未知 ID 或改变的计数器、 +门槛、版本、归属、基础规则、范围均不继承旧日期。手动会话日期沿用规范索引完整准入; +弱键缓存不保留第二份记录语料,来源退役释放注释,日志变化后需再次校准。真实生产者已核验 +的新增用量仍保留独立收据及原有日期/未读语义,校准取消不消耗该收据;历史补认不冒充、 +替代或抹除真实增量。部分已保存另行报告。 + +图鉴管理复用既有卡片 `openDetails` 本地 UI 持久状态;展开/收起不改变来源、授奖或 +未读状态。铜/银/金等本地化短名及完全相同条件/单位的集中展示不新增存储字段。 +共用金额参考区显示各已启用供应商的当前价表/日期及非账单提示;不同历史价表按已存 +`referencePricebook` 保留与具体奖项的准确关联,展示调整不重标或改写授奖。 +卡片保留范围与计价覆盖率,不再重复价表/依据说明。旧 ID、固定项、价表不变。满级仍显示未截断的当前 +统计;证据过期或缺失不以目标值替代。 + +紧凑展开行按各等级主题颜色显示名称(高对比模式回退到前景色),门槛在同一标题行 +加粗、右对齐。「进行中」仅在证据就绪时显示于最高已获等级的下一等级,后续等级、满级或 +证据待定不显示。保留日志已核验日期仅显示 ISO 日期,来源含义在「数据依据」解释一次, +title/ARIA 保留来源说明。两种日期同时存在时仅显示已核验日期,首次记录详情留在 +title/ARIA;仅有首次记录日期时保留明确标签,未知仍为未知。此显示优先级不改写授奖、 +规则、日期计算、目录、记录或存储。 + +`root-portfolio` 显示名为「独立主任务」。既有聚合统计根归属可核验、且已识别模型有 +正用量(输入或输出)的 Codex 主任务,主任务与其子任务合计一次;不证明任务已完成, +不读取任务内容、不新增持久身份。ID、门槛与已存授奖不变。 + +导航/页标题「分享与对比」、徽章选择项等宽对齐及等级单列不新增数据类别。 +勾选图标修复允许与主 Webview CSP 相同的打包本地资源来源(`webview.cspSource`), +不开放远程图片。分享页整体视觉统一/美化和持久用量归档均推迟至下一版本, +2.5.0 未新增这两项;当前派生缓存不构成持久用量归档承诺。 + +逐级日志日期是仅运行期存在的派生注释(`schemaVersion: 1`),绑定 `ruleVersion`、 +时区、目录形状及当前来源/计价上下文。来源、OFF、时区及价格变化重建或撤回派生缓存。 +不新增持久存储,不修改规范 v2 原有 `recordedDay`、未知日期、未读状态或存储 schema。 +保留日志可确认的最早日期不等于账户真实完整历史,也不同于插件首次记录授奖的日期。 + +通用用量/输出/缓存/模型/家族/活跃/周日期自动复用已物化每日数据及严格核验、 +完整带日期的 Codex 文件。活跃日计数及连续活跃包含当天正活动,日期为当天;合格家族/日 +条件须闭日,日期为该日加一天。四周窗口按首个正活动日排除首个不完整周,不以开头全零行 +确定起点,语义与既有规则一致。仅手动校准额外归约已持有、未受界面上限截断的 Claude +记录内存,计算输出会话、Fable 会话及同会话 Fable/其他家族日期。日期归约仅读已物化 +数值或已持有记录的 usage 元数据,不额外扫描日志正文、不检查对话内容或导出元数据。 +正常增量刷新仍可按既有分析设置读取允许范围的新增/变更 JSONL,不强制全量重读、不新增 +扫描器或网络访问。普通热更新不为日期重聚合记录。联合智能体/effort、小时/夜间、单任务金额 +及缺失时间戳的迁移本地操作保持未知或原有首次记录,不能补造今天日期。 + +校准是本地 single-flight 增量操作,不强制全量扫描、不清除旧徽章,不请求 OAuth/额度、 +在线价格、BYOK 或其他网络服务。pending guard 暂停历史补认及基线推进,真实增量收据独立暂存; +通过 guard 的提交静默补认历史,不产生新提示或强制授奖。忙碌、当前索引全清单未获接纳、 +失败或提交前取消不是成功;已核验部分历史保留下界或未知,完成不代表所有授奖范围完整覆盖。 +历史补认在提交前取消不新增历史奖;原子提交完成后保留已存记录,后续取消不能误报提交未完成。 +未来日期处理器须显式白名单及规则版本隔离,新范围默认未知; +未来目录封套继续只读并 fail-closed。来源解析器、依赖和软件包版本保持不变。 + +Codex 成就复用先精确比较重建的全文件清单及独立见证,再利用与原数据分离、深度 +不可变的副本复用摘要及日期/金额投影缓存。标志或总量相同本身不证明相等。 +副本及缓存仅在运行期存在,不是另一份持久索引;源读取、身份导出及清除边界不变。 + +平铺且缺少路径可证明父会话的 Claude `agent-*.jsonl` 仅从会话成就证据中排除。 +Token 总量、价格、每日聚合和可见会话行不变;嵌套代理保留既有父路径契约, +不改源日志,并保留缓存及已保存的徽章。 + +`enableAchievements` 默认开启。一次性升级提醒复用 `ccu.lastSeenVersion`,不请求新权限、 +不自动打开面板,并尊重已关闭的发版提醒及原有成就关闭偏好。历史补认先静默保存, +之后符合条件的新成就仅在仪表盘内短暂提示,不发声、不抢焦点。夜间与 24/48 小时 +输出徽章始终不触发这类提示。 + +宿主的 64 字符成就证明摘要仅在内存中,从已有数值/身份聚合逐项计算,不记录、 +保存或导出。它使证明更新独立于图表变化,不保留第二份索引或读取日志。关闭成就 +跳过摘要和评估;来源替换退役缓存证明,保留已有奖章。 + +Codex 日期见证仅在运行期从供应商当前 contribution 独立的大小/偏移/前缀/时区 +投影;仅当前清单已验证、主索引完整且无 period 迁移、超长行丢弃或谱系对账时接收。 +`sourceRevision` +在既有摘要/缓存中绑定全清单、全局日期边界及数值日期向量,先解析祖先关系再选日期。 +见证及摘要不记录、持久化或导出,不增加原始身份暴露、索引字段、I/O、定时器或存储。 +周金额覆盖的 `scopeLowerBound` 只是数值覆盖旁的运行期标记,独立于定价覆盖; +子集身份清单不进入界面或导出。 + +九个固定、仅内存的证据域基线区分首次/恢复历史与后续可提示的奖项。证据待补齐或 +过期明确标识,不显示为零用量;获奖记录独立保留。这些基线不新增行为历史、持久键、 +来源标识或联网请求。 +图鉴计数之前,记录加载中、暂不可用、状态已变更及已关闭四种状态使用八语明确说明, +不显示貌似空目录的 `0 / 0`。已启用 Codex 来源尚未核验时,可信 Claude 累计小计仍为 +`≥` 下界。下一目标名称/等级及 SVG 中不重复的等级标签只属展示投影,不新增存储字段或授奖依据。 + +插件体验提示必须来自已成功保存的本地操作增量,且该操作确实跨过对应门槛。读取/ +导入旧操作静默补认;重新开启不会把旧记录变成新操作。内存暂存最多六个公开插件 +规则 ID,授予成功或关闭/清除后释放,该来源锁存不增加操作时间戳。授予保存失败可 +重试,不要求重复操作。供应商日志证明退役不取消独立的本地操作奖项;隐藏/关闭 +仪表盘时保存奖项不自动打开或刷新页面。这个来源保护尚不等于持久的漏看提示记录。 + +Token 中间阶段是从正式门槛计算的临时展示目标,不是新增奖章。不保存阶段流水、 +不触发提示,也不会成为可选择导出的徽章。 + +自动概览只在本次浏览的宿主内存中保留最多六个公开系列/规则选择键(三徽章及三目标),不新增 +日期、用量历史或分享同意。进入图鉴可从已接受聚合重新计算这个小型展示选择, +不读日志;手动固定及首次记录日使用下述权威封套,不另存 Webview 选择副本。 +手动模式仅显示最多六个选中主题,保存顺序,不自动补位。收起卡片的固定控件及 +可搜索编辑器使用同一租约;批量选择/排序核对当前固定修订。保存失败在相关控件 +附近提示并保留旧选择,控件消失或禁用时焦点回到稳定摘要。工具列先于概览; +小/中/大使用 56/80/112 px 图鉴图标及 56/72/96 px 展示柜图标,保留字号及点击区域。 +大于 960 px 时徽章栏按内容收缩,最大为两张概览卡宽度加内部间距;下一目标栏 +填满剩余空间,两栏间距 16 px。960 px 及以下直接上下堆叠,间距 12 px;辅助历史区 +统一放在两者之后。计数标签和装饰性控件图标保留既有本地化文案。 +纯函数模块 `achievementUiIcons` 渲染已知内联 SVG 路径,无依赖、网络访问或额外资源文件。 +这些展示调整不增加存储字段或源数据读取,保留控件、状态保存、资格判断及消息协议。 +早期数据证据仍在调查;本文不据此宣称数据已修复或新增验证通过。 + +待补齐/已过期卡片详情以 I18n 八语文案显示日期、模型归属、金额及当前来源的要求, +说明旁带信息图标;这些范围要求不作精确根因分类,不暴露原始来源/错误详情, +也不赋予新的证据效力。 + +宿主的提前返回 +仅当明确的 `codexWorkerCancellationRequested` 为 true、结果为 `error`、 +`failureCode === 'cancelled'` 且严格满足 `result.diagnostic?.failedFiles === 0` 时生效。 +缺失计数仍是未知,继续走失败/证据撤销路径。零计数检查是防御性守卫:当前生产端 +在终止取消/抛错时因没有最终逐文件诊断而报告 `failedFiles: 0`,并不证明取消前 +没有文件失败。 +该取消结果不应用快照、不记录成功时间、不生成新的接纳收据;保留先前失败状态及最后 +成功时间,待新的扫描成功后恢复处理。中断结果不授权新授奖,后续仅历史补认仍按 +import-only 处理,不生成实时日期或提示。所有错误结果的清单均标记为不完整; +报告的非零/缺失失败计数、其他错误及非宿主拥有的取消仍走失败/证据撤销路径。 +假设取消结果报告非零计数的测试只覆盖宿主防御分支,不是当前生产端提供的失败文件统计。 + +有效周期迁移检查点在 `tokenEventsSeen` 尚未达到继承前缀且 `days` 为空时也可续跑; +有界游标可跨批次、持久化/重启及取消继续推进。日期证据仍须通过完整前缀、EOF 及 +当前时区见证检查后才能提升。已审阅回归覆盖分批、持久化、取消、EOF 反例及未变化 +热刷新读取零正文。无日期、超大行或冲突证据仍保守保持待补齐,不表示所有缺失日期 +均已解决。 + +图鉴只在现有 Webview 界面状态中保存 `all`/`next`/`earned` 筛选及 small/medium/large +尺寸枚举,展开键为公开系列 ID;分享返回路径只记住 Claude/Codex 枚举与一次性焦点布尔值,不含用量、路径 +或身份;「重置仪表盘界面状态」可删除。打开、筛选或展开 +图鉴不会读取日志或新增联网。当前统计往返还在同一 UI 状态保存一个公开主题键及 +供应商枚举,返回、普通切页或 UI 重置时清除;不含日期、型号、路径或行为历史,也不扫描日志。 +主动分享可包含已关闭供应商的保留徽章;图鉴及新增固定按已启用供应商筛选。 +保留徽章不提供当前进度或新的授奖权限,分享不会重新开启供应商或读取其日志。 +徽章分享偏好仅记住主动选择的公开规则 ID(最多九个)。 +一份已接受 SVG 及匿名预览句柄仅保留在 provider 内存,不落盘成为导出历史;导出写入 +已确认预览的原字节,导出前核对当前语言,切换语言不会静默保存旧语言预览。 +替换、重置、关闭功能或面板均释放保留产物。 + +扩展自有账本只保存下列通过校验的字段: + +- `globalStorage/achievements-v2.json`:权威获奖封套,总序列化大小不超过 64 KiB, + 每个当前公开规则 ID 最多一条(共 178 项)。字段限 schema/catalog/规则版本、单调清除 + 代次/记录序号、`existing`/`history`/`usage`/`activity` 原因枚举、未读布尔值、 + 可选首次记录自然日/时区、最多六个有序公开系列固定项、自动/手动模式、固定修订及 + 一个提示冷却数值,以及下述原有辅助证明。辅助证明也计入整个封套的 64 KiB 总上限。 + 有界旧文件边界只含派生存储的 + 代次/序号/签名;最多 48 条旧规则记录保存已获的 21 个改变过的模型/团队/effort/ + 彩虹条件,未知日期仍未知,不证明新规则。金额记录可含公开固定参考价版本,不含个人金额。 + 读取接收供应商当前价表及 `achievementReferenceBooks.ts` 中三个明确已发布的 v1 标签: + Claude 的 `api-reference-2026-10-anthropic-v1`、 + `api-reference-2026-10-aws-bedrock-in-region-v1`,及 Codex 的 + `api-reference-2026-10-openai-v1`。当前价表改变不重标已存记录;新增 `append` 和 + `recordUsage` 草稿要求当前供应商价表,可读历史不授权新授奖。登记不新增价格、ID、 + 存储字段或 schema,不按字符串模式放行未来价表。未知且非当前的价表安全拒绝, + 整个封套保留不动,清除全部也不改写;精确删除范围不扩大。 + 提示预留只更新跨窗口五分钟冷却,不改变未读。前台页面以有效运行期匿名句柄 + 确认显示后,只标记本批 ID 及发出序号范围内记录已读;其他/后续奖章仍未读。 + 失败投递、OFF、失焦、清除或文档替换不能确认过期提示。错过提示归入默认折叠的 + 新增摘要,每组最多六个可见非历史精确等级。「标记本组已读」在同一 lease 内复核 + 当前文档/供应商/来源/聚焦/清除代次,只确认本组 ID;其他窗口后来新增仍未读。 + 匿名组句柄及文档身份仅存运行期,不落文件、不联网;保存失败不改变未读。 + 浏览/固定/分享不算确认;没有新增定时器或日志扫描。 + 已获徽章详情通过共享 v1 lease 保存单项固定/取消意图,最多六个公开键,不改变 + 奖章、未读或分享。保存失败保留旧选择,旧代次/关闭/清除请求被拒绝。 + 关闭供应商后的固定项可在最多六个公开键的折叠列表中明确取消,不启用供应商、不读取日志。 + 页面呈现最多三个非历史最近系列及折叠的三十条记录,不删除奖章。 + 日期是保存时区中的首次记录日,不推测原始达成时间;浏览不标记已读、不代表分享同意。 + 通过旧审计的奖章静默迁入,日期未知;已证明的 Claude/Codex Token 跨级与已保存 + 本地操作保留未读及首次记录日,不推测原始达成时间。其他用量补认保持静默,直到证明 + 自身新增来源。观测窗口、固定数值增量及可重试收据只在有界内存中保留,不增加持久来源 + 身份或时间线。只有序号晚于观测基线的指定历史记录能由可信新活动来源替代,已有历史不改写; + 来源退役/关闭/清除丢弃待办证明。默认开启的 `showAchievementNotices` 仅控制浮动提示, + 关闭保留授奖与未读、退役投递/ACK,重新开启不补弹;不计入安全设置体验,不开始扫描。 + 事务在跨进程 lease 内重读合并,刷新私有 + 临时文件后原子替换。缺失时等待旧审计再初始化;OFF 不初始化。未知/未来/损坏/ + 过大/符号链接输入安全拒绝,原文件不隔离、不覆盖。清除全部在同一 lease 内 + 删除精确相关旧键及普通 `.achievements-v1.json..tmp`/`.achievements-v2.json..tmp` + 文件,权威 v2 文件改为空屏障(schema/代次/序号、空记录/固定项/行为、零冷却/小时最大值、 + 无日历峰值),再写空 v1 降级屏障;不删除权威文件,防止仍运行的旧 + 宿主恢复旧奖章。旧代次宿主在重新加载前停止回写。锁、相似文件、未知文件和 + 供应商源数据不删除;P12 清单只展示占用/记录数量,不展示内容或日期。 +- `globalStorage/achievements-v1.json`:明确清除之外仅作只读迁移输入,不作当前获奖镜像; + 降级后也以既有 v2 为权威。两文件共用既有 v1 租约,中断清除在同一租约内续办。 +- `ccu.achievements.grants.v5`:最多 124 个已审计旧规则 ID,仅作迁移输入;真实宿主 + 接收权威封套后不再写入获奖镜像。 +- `auxiliary.activity`(旧 `ccu.achievements.activity.v2` 仅作迁移输入):最多 29 个安全设置键及四个本地功能枚举。只有主动、 + 成功的本地动作计入;不保留设置值、密钥、路径、授权决定,以及默认/迁移/重置事件。 +- `auxiliary.hours`(旧 `ccu.achievements.hours.v3` 仅作迁移输入):最高连续正输出小时数,上限 48;不是人的工作时长清单。 +- `auxiliary.calendarPeak`(旧 `ccu.achievements.calendarPeak.v1` 仅作迁移输入):一个已验证的四个完整周周均最大值,以及测量版本、 + 时区和供应商存在位掩码。复用要求语境匹配及完整、当前、规范化的数值证据。 + 仅 Claude 的峰值忽略无关 Codex parser/period 版本;规则版本、时区、供应商存在位 + 及完整当前证据检查保留。任何包含 Codex 的语境仍须匹配 parser 和 period 版本。 + 权威事务合并行为集合/小时最大值与同语境日历峰值;不同日历语境不合并。真实 + 宿主不把这些字段镜像回旧 Memento 缓存;空清除屏障优先于所有旧辅助输入与奖章 ID。 +- 精确列明的审计/迁移布尔值及有界规则 ID 迁移草稿;未知字段拒绝接收。清除全部 + 包含这些字段及已知旧成就 schema,按精确名称处理,不泛删无关或未来状态。 + +`achievementModels.ts` 中协作/彩虹的非金额模型识别独立于固定计价别名。Claude +仅允许明确的 `global`、`us`、`eu`、`jp`、`apac`、`in` Anthropic 前缀,拒绝 `xx`; +已识别但无固定价格的命名家族标签仍未定价,不增加定价覆盖,也不证明上游型号身份。 + +数值评估复用已物化索引。六个 API 等价成就主题使用带版本固定美元参考价 +(`api-reference-2026-10-{backend}-v1`,2026-10-09)、精确已知型号及完整组件价格 +(含已知 Claude 缓存写入 TTL);覆盖累计已定价小计、当前数据时区 +的已结束周一自然周及一个 Claude 会话/核验 Codex 任务树。已定价子集只证明下界, +不是账单;金额历史静默。只有每家供应商四个累计金额等级可由已接受的原生 Claude/ +Codex 尾追加窗口保存未读与首次记录日/时区。前后累计证据及增量须使用同一固定 +公开参考价表;增量须足以令旧进度达标,且从新进度扣除后不再达标。涨价、导入与 +重建不能证明新活动。已结束周及单次会话/任务金额奖章仍静默且日期未知。 + +金额来源证明只存于运行期:公开供应商/后端/价表版本、有界精确单位、microUSD +及合格已处理 Token。新增金额、精确单位与证明计数不写入索引或获奖文件;奖章只 +在普通有界记录字段之外保存公开 `referencePricebook`。仅金额价表/范围失效不取消 +独立有效的 Token 收据;共同的来源/索引、清除、OFF 及销毁守卫仍适用于两者。 +Claude 后端切换还会使索引代次失效,可退役待保存的 Token 证明。 +保存时在共享租约下复核来源/范围/价表、日期/时区、清除 +代次及 OFF;失败只保留有界可重试证明,不乐观显示日期。同价表且在已接受基线序号 +之后写入的并发暂存历史可修复;基线前历史或不同价表保留原依据/日期。同次更新 +沿用一个既有提示批次。 + +已有 Codex effort 广度详情消费可选六行运行期 `effortStatistics` DTO,按完整当前 +索引的证明修订缓存。先汇总非 approval-reviewer 的根任务 × effort,只计该组合自身 +有正输出的用量;原始根任务/会话 ID、型号标签与精确单位字符串不进入界面。 +已处理 Token、输出及不同合格根任务共用既有等级常量,联合决定进度。固定美元小计 +与覆盖率仅供参考,不参与 effort 授奖。模型/effort 边际不按比例分摊,只接受已完整 +对账的单模型或单 effort 定价子集;空、未定价、部分、无效、溢出分别标示或撤回金额。 +证据缺失/不完整显示未知,不补六行零值,不增加 effort 偏好、规则、存储或日志读取。 + +当前已验证清单完成索引后,可用独立核验谱系证明正向 Codex 里程碑,排除冲突分支 +及后代。可信见证日期可证明活跃/模型家族/单周 Token 下界;部分历史不能证明 +零值、四周周均或保留峰值。周金额重验全清单摘要及日期向量,再要求每个入选的 +完整归日文件中计费/模型日期向量精确相等。仅正金额可作为部分范围下界;部分 +范围的纯零值/空值仍待补齐。日期覆盖不代表定价覆盖:未知价格及溢出保留既有状态。 +累计/任务范围及参考价不变。选择只在内存中,不新增扫描、联网或持久化。 +Codex 逐文件索引另保存正输出 UTC 小时 ID、已验证的字节 +偏移及排除谱系事件数:每文件最多 32,768 个小时,每次快照最多 524,288 个不同小时。 +超出容量时安全拒绝证明,不把部分小时历史认证为完整。旧索引可通过有界、可续跑的 +补齐获得这些证明,主 Token 总量保持不变。关闭成就后不再调度额外历史证明补齐, +普通新日志索引仍继续。 + +新授予必须有当前已验证证据;刷新失败后保留在界面的旧统计不能用于发放新徽章。 +已正确保存的徽章不因滚动窗口过期而撤销。来源切换及清除会使排队证据失效。 +连续输出表示每个 UTC 小时都有输出记录,不代表人持续工作。周规则使用设置中的 +时区;覆盖不完整的日期不能当作已证明的零用量日期。 + +徽章涵盖本地历史,可能包含共享账户或多个登录,不认证身份、能力、生产率、订阅额度 +或账单。徽章分享只导出主动选择的公开规则标签、等级标记、内置图标、免责声明与固定公开 +仓库链接 `https://github.com/ClaudeCodeUsage/ClaudeCodeUsage`。SVG 超链接不含用户提供的 +URL 或跟踪参数,预览/导出不获取该地址,不新增自动网络请求、外部图片依赖或个人元数据。 +长标签通过换行与间距避让,已接受预览/导出的不可变约定不变; +不含来源路径、账号数据或日志。选择最多九个已获得徽章,接受本地预览后再导出; +不自动导出、不登录、不上传。 + ## 清除与迁移 命令面板提供独立维护动作:重建 Codex 派生索引、清除额度历史、清除建议数据、重置 @@ -129,8 +501,15 @@ Folder 配置作用域。只存在于关闭工作区中的旧值会继续保留 ## 已知限制 +本文描述数据契约,不是发布或验收记录。原生/F5、已安装包及性能检查需要各自的 +证据,浏览器检查不能证明这些结果。金额的实时获奖日期仅限已验证的累计跨级, +其他金额范围保持历史补认。 +源码守卫只核对源代码契约,不证明界面状态转换已实际执行、原生 Webview 行为或已安装包验收。 + - 从未出现在官方响应或本地结构化事件中的重置无法重建;只有日粒度的 Codex 证据 会降低边界可信度。 -- 同一 Codex home 可能包含多个登录。归属重叠不清时只显示已用值,绝不虚构账号拆分。 +- 同一 Codex home 可能包含多个登录。当前周期总额/未用值保留上文所述的低可信 + 本地历史近似;多个不同额度系列映射到同一已结束周期时仅显示已用值,不虚构 + 账号拆分。准确分账仍留待后续多账户兼容。 - API 等效价值按当前已知公开 API 单价和定价覆盖率估算,不是供应商账单或订阅价格。 - 源日志保留期由 Claude Code 和 Codex 控制,不由本插件控制。 diff --git a/README-de-DE.md b/README-de-DE.md index ada8c4b7..a84f1210 100644 --- a/README-de-DE.md +++ b/README-de-DE.md @@ -56,6 +56,184 @@ jeweils passender Bedeutung an. Sie ist **kein Abrechnungswerkzeug**. Das Bild nutzt synthetische Daten, nicht das Konto einer Person. Weitere Abbildungen und Details stehen in der [ausführlichen englischen Hauptseite](README.md). +Version 2.5.0 verbindet lokale Erfolge mit Codex-Unterstützung für `.jsonl.zst` in `sessions` und `archived_sessions`. Zstd komprimiert verlustfrei, ohne LLM-Zusammenfassung: kleinere Dateien ändern die protokollierten Tokenzahlen nicht. Das Lesen bleibt lokal, ohne Quelldateien zu ändern oder entpackte Klartextkopien zu speichern; gleichnamige `.jsonl` im selben Ordner haben Vorrang. Natives Zstd (Node 22.15+ / 23.8+) benötigt keine neue Abhängigkeit; nicht unterstützte Hosts melden die Einschränkung und behalten zuletzt verifizierte Summen. + +## Lokale Erfolge (2.5.0) + +Sitzungsdaten verwenden dieselbe vollständige Zulassung wie der kanonische Index; geänderte Logs erfordern eine erneute Prüfung. Bestätigte neue Nutzung behält ihren ersten Nachweis auch bei Abbruch. Teilweise gespeicherte Ergebnisse werden gesondert gemeldet. + +Alle ausklappen / einklappen speichert nur den Aufklappzustand der Karten im +bestehenden lokalen UI-Zustand; Quellen und Auszeichnungen bleiben unberührt. +Die Ränge heißen Bronze / Silber / Gold; IDs, Pins und Referenzpreislisten bleiben +gleich. Exakt gleiche Bedingungen und Einheiten erscheinen einmal. Ein gemeinsamer +Geldreferenzbereich nennt je aktiviertem Anbieter die aktuelle Preisliste samt Datum +und den Hinweis, dass dies keine Rechnung ist. Abweichende historische Preislisten +bleiben den genauen Auszeichnungen zugeordnet; Karten behalten Umfang und Preisabdeckung +ohne wiederholte Preis-/Berechnungsgrundlagen. Vollständig erreichte +Karten zeigen den ungekürzten aktuellen Wert; veraltete oder fehlende Belege werden +nicht durch einen Zielwert ersetzt. + +Ausgeklappte Rangzeilen färben den Rangnamen mit rangspezifischen Theme-Farben +(Vordergrundfarbe bei hohem Kontrast); der Schwellenwert steht fett und rechtsbündig +in derselben Kopfzeile. Nur der nächste Rang über dem höchsten erreichten zeigt bei +bereitem Nachweis „In Arbeit“; spätere Ränge, vollständig erreichte Karten und ausstehende +Nachweise zeigen dieses Label nicht. Verifizierte Daten aus erhaltenen Logs erscheinen +als reine ISO-Daten; ihre Bedeutung wird einmal unter „Datengrundlage“ erklärt, die Herkunft +bleibt in title/ARIA. Liegen beide Daten vor, ist nur das verifizierte sichtbar; die erste +Erfassung bleibt in title/ARIA. Alleinstehende Ersterfassungsdaten behalten ihr ausdrückliches +Label, unbekannte Daten bleiben unbekannt. Auszeichnungen, Regeln, Datumsberechnungen und +gespeicherte Datensätze ändern sich dadurch nicht. + +**Unabhängige Hauptaufgaben** zählt verifizierbare Codex-Wurzelzuordnungen mit +erkannter positiver Modellnutzung (Eingabe oder Ausgabe). Haupt- und Unteraufgaben +zählen zusammen einmal, nicht als abgeschlossene Aufgaben. Regel-IDs und Schwellen bleiben gleich. + +Navigation und Seitentitel heißen **Teilen & vergleichen**. Abzeichenauswahlfelder +haben gleiche Breite und eine eigene Rangzeile. Die Symbolkorrektur +nutzt dieselbe lokale Ressourcenquelle wie die Haupt-Webview-CSP. Exportierte SVGs +enthalten den festen öffentlichen [Repository-Link](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage) +und umbrechende lange Beschriftungen. Vorschau/Export ergänzen weder automatische +Netzwerkanfragen noch persönliche Metadaten; ausdrückliche Auswahl und unveränderliche +bestätigte SVGs bleiben erhalten. Die umfassende visuelle Vereinheitlichung/Verschönerung +der Freigabeseite und ein dauerhaftes Nutzungsarchiv sind auf die nächste Version +verschoben und in Version 2.5.0 nicht implementiert. + +Datumsangaben je Stufe entstehen nur im Arbeitsspeicher und bezeichnen das früheste +aus erhaltenen Logs belegbare Datum, nicht den vollständigen Kontoverlauf. Gespeicherte +Ersterfassungsdaten und Ungelesen-Markierungen bleiben erhalten. Tagesaggregate liefern +unterstützte Nutzungs-, Ausgabe-, Cache-, Modell-/Familien-, Aktivitäts- und Wochendaten. +Nur die manuelle Neuprüfung wertet bereits gehaltene Claude-Datensätze zusätzlich für +Ausgabesitzungen, Fable-Sitzungen und Fable/andere Familien in derselben Sitzung aus; +normale warme Aktualisierungen gruppieren diese Datensätze nicht erneut. +Nicht unterstützte Bedingungen behalten unbekannte Daten oder die erste Erfassung. + +Die Neuprüfung läuft lokal und inkrementell, jeweils nur einmal gleichzeitig, ohne +erzwungenen Vollscan, Abzeichenlöschung oder OAuth-, Preis-, BYOK- und andere +Netzwerkanfragen. Sie erzwingt keine Auszeichnung. Beschäftigte Vorgänge, ein nicht +akzeptierter aktueller Indexgesamtbestand oder Fehler gelten nicht als Erfolg. +Verifizierte Teilhistorien behalten Untergrenzen oder unbekannte Werte; der Abschluss +bedeutet keine vollständige Abdeckung aller Auszeichnungsbereiche. Ein Abbruch vor dem Commit +fügt keine historischen Auszeichnungen hinzu; nach dem Commit bleiben gespeicherte Nachweise und +das erfolgreiche Ergebnis bestehen. Historische Abgleiche bleiben still. +[Datums- und Speichergrenzen](LOCAL-DATA.md#local-achievements-250). + +Die Modellerkennung für Zusammenarbeit/Regenbogen ist von der Preisberechnung getrennt, sodass erkannte Modelle ohne Preis unbepreist bleiben; gespeicherte Geldwert-Auszeichnungen behalten veröffentlichte Preisbuchkennungen, und reine Claude-Spitzen bleiben bei unabhängigen Codex-Versionsänderungen erhalten. +Ladende, nicht verfügbare, geänderte und deaktivierte Aufzeichnungen erhalten klare Statusanzeigen statt leerer Zähler; verifizierte kumulative Claude-Nutzung zeigt `≥`, solange die Nachweise des aktivierten Codex noch nicht verifiziert sind. +Nächste Ziele zeigen Zielname und Zielrang, erhaltene Karten behalten ihren erreichten Rang, und SVG-Exporte zeigen jeden Rang nur einmal. + +Abzeichendetails öffnen vorhandene aktuelle Modell-/Tokenstatistiken desselben Anbieters +mit Rückweg und erhaltenem Fokus. Keine Logsuche oder Ersetzung historischer Nachweise durch aktuelle Summen. + +Beim Lösen alter angehefteter Abzeichen bleibt der Tastaturfokus im Thema, ohne Datenaktualisierung. +Die Erkundungsdetails zeigen gespeicherte Schritte: Sammlung, Vorschau und erfolgreicher lokaler Export. +Alte Abzeichen erfinden keine fehlenden Schritte; Feedback und Einstellungsänderungen bleiben unabhängig. +Das Symbol enthält keine festen Zahlen. Neue Hinweise bewegen nur das Abzeichen einmal für 440 ms; +Text, reduzierte Bewegung und hoher Kontrast bleiben statisch, ohne Ton oder Fokuswechsel. + +Modelle je Aufgabe und die Anzahl von Mehrmodell-Aufgaben sind getrennte Erlebnisse. +Fable: gemeinsame Sitzung verlangt Ausgabe von Fable und einer weiteren erkannten +Claude-Familie in derselben Sitzung, nicht eine bestimmte Reihenfolge. + +**Erfolgshinweise anzeigen** (`showAchievementNotices`) lässt sich separat ausschalten. +Abzeichen und ungelesene Einträge bleiben; erneutes Einschalten spielt keine alten Hinweise ab. + +Nachweislich neue Claude-/Codex-Token-Stufen behalten ungelesene Erstaufnahmetage, +auch bei pausierten Dashboard-Updates. Unterstützt sind Modellfamilien, Ausgabe, +Cache-Lesezugriffe, Claude-Cache-Erstellung und Gesamtvolumen. Importe und manuelle +Neuindizierung bleiben still; kumulative API-Referenzwerte haben eigene Live-Nachweise, andere Nutzungsbereiche benötigen sie noch. + +Der lokale Nachweisspeicher ist auf 64 KiB begrenzt. Alte Abzeichen haben kein +bekanntes Datum; neue gespeicherte Aktionen behalten ungelesene Erstaufnahmetage, +auch bei verpasstem Hinweis. Fenster führen Auszeichnungen zusammen. +Gespeicherte Auszeichnungen bleiben nach Neustarts erhalten und werden zwischen Fenstern geteilt. +Alte Regeln behalten ihre Historie, belegen aber keine neuen Bedingungen. Löschen verändert keine Anbieterlogs. +Hinweise erscheinen nur im sichtbaren Vordergrundpanel, höchstens alle fünf Minuten +über Fenster und Neustarts hinweg. Nur bestätigte Anzeigen markieren ihre Gruppe +als gelesen; verpasste bleiben ungelesen. Maus- und Tastaturfokus pausieren das Ausblenden. +Verpasste Auszeichnungen stehen in einer zunächst eingeklappten Übersicht mit +höchstens sechs konkreten Stufen je Gruppe. Nur die ausdrückliche Bestätigung nach +erfolgreichem lokalem Speichern liest diese Gruppe; Ansehen, Anheften und Teilen +nicht. Spätere Auszeichnungen anderer Fenster bleiben ungelesen. +Zuletzt erhalten zeigt höchstens drei neue Serien; die einklappbaren Einträge umfassen +bis zu dreißig Stufen mit Regellinks. Daten bezeichnen die erste Erfassung, nicht die +ursprüngliche Erlangung; alte Daten bleiben unbekannt. Beim Ansehen werden Einträge +nicht als gelesen markiert und nicht zum Teilen ausgewählt. +Bis zu sechs Themen lassen sich über Pins auf geschlossenen Karten und eine durchsuchbare Auswahl anheften und ordnen. +Manuell erscheinen nur gewählte Pins, automatisch höchstens drei Empfehlungen. +Auswahl und Reihenfolge bleiben nach Neustart erhalten und sind keine Freigabe zum Teilen. +Ausgeblendete angeheftete Abzeichen können in einer einklappbaren Liste gelöst werden, +ohne den deaktivierten Anbieter wieder einzuschalten. + +Beim ausdrücklichen Teilen können erhaltene Abzeichen deaktivierter Anbieter gewählt werden. +Galerie und neue Pins richten sich nach aktivierten Anbietern; gespeicherte Abzeichen belegen +weder aktuellen Fortschritt noch die Berechtigung für neue Auszeichnungen. + +Importierte Plugin-Nutzungsnachweise bleiben ohne Hinweis. Erfolgreich gespeicherte +neue Aktionen können Abzeichen auch bei geschlossenem Dashboard freischalten, ohne versteckte Ansichten zu öffnen oder zu aktualisieren. + +Flach abgelegte Agentenlogs ohne durch den Pfad belegte Elternsitzung zählen nicht +als Nachweis für sitzungsbezogene Erfolge. Token, Preise, Tagessummen und sichtbare +Sitzungen bleiben unverändert; verschachtelte Zuordnung und gespeicherte Abzeichen bleiben erhalten. + +Die Galerie in 2.5.0 bietet 50 Themenkarten, 178 Freischaltbedingungen und 52 Motivgruppen: Modell-Token, Ausgabe/Cache, +Aktivität, Wochenrekorde und einige bewusst genutzte Plugin-Funktionen. + +15 Claude-/Aktivitätsserien erhalten Platin und Legendär; die ersten drei Schwellen und Nachweise bleiben unverändert, die Motive werden weiterverwendet. Aktivitätstage, Ausgabe und Sitzungen ermöglichen Fortschritt bei drei konzentrierten Tagen pro Woche, ohne Codex oder höhere Ausgaben. Serien aufeinanderfolgender Tage werden nicht erweitert. + +Davon betreffen 24 Bedingungen sechs getrennte Claude-/Codex-API-Äquivalent-Themen: bepreiste Gesamtsummen, +abgeschlossene Wochen ab Montag in der aktuellen Datenzeitzone und eine Claude-Sitzung bzw. ein verifizierter Codex-Aufgabenbaum. +Beide Wochenserien haben fünf Ränge: Bronze 100 USD, Silber 500 USD, Gold 2.500 USD, +Platin 10.000 USD und Legendär 25.000 USD. Nur die letzten beiden je Anbieter sind neu; +alte IDs/Schwellen, abgeschlossene Wochen und die festen Referenzpreisgrundlagen bleiben unverändert. +Feste USD-Referenzpreise mit Versionsangabe verlangen bekannte exakte Modelle und vollständige Komponentenpreise einschließlich Claude-Cache-TTL. +Unbekannte Modelle bleiben unbepreist; belegte Teilmengen zeigen eine Untergrenze. Die Beträge sind keine Rechnung, Qualitätswertung oder Einkommensangabe. +Geldhistorie bleibt still. Nur kumulative Geldmeilensteine speichern bei verifizierten neuen Anhängen unter derselben Preisreferenz ungelesene Erstaufnahmetage; Wochen- und Einzelaufgabenwerte bleiben still mit unbekanntem Datum. Token-/Geldnachweise sind unabhängig und teilen einen Hinweisbatch; fehlgeschlagenes Speichern erzeugt kein Datum. Modell-/Team-/Effort-/Regenbogenregeln verbinden Ausgabe, wiederholte Nutzung und Umfang. +Die bestehende Codex-Effort-Karte zeigt aufgeklappt sechs Stufen mit Token-, Ausgabe-, Wurzelaufgabenfortschritt und informativem festen USD-Subtotal samt Abdeckung. Nur eigene positive Ausgabe je Wurzel × Effort zählt, ohne Approval-Reviewer; uneindeutige Modellzuordnung wird nicht proportional verteilt. Fehlende Nachweise bleiben unbekannt; keine neuen Regeln oder Einstellungen, keine Claude-Effort-Zuordnung. +Die Heatmap bietet Claude / Codex / Beide und standardmäßig Tokens; API-Äquivalent-Beträge müssen ausdrücklich gewählt werden. +Sie nutzt aktuelle Tagesschätzungen, keine Erfolgspreise: Claude geschätzt, Codex nur mit exakten bekannten Modellpreisen; fehlende Preise bleiben sichtbar, Teilbeträge sind Untergrenzen. Beträge beider Anbieter werden nie addiert. +Claude-Abdeckung prüft ausgewählte Modelle/Komponenten nur im Speicher; generische unbekannte Sonnet-Ersatzpreise zählen nicht. Fehlende Raten bleiben unbekannt, explizite Null ist gültig; gespeicherte Kosten bleiben gleich. Token-/versteckter Modus fragt weder Raten noch Geld ab. +Beträge ausblenden stellt Token-Farben, Texte und Markdown wieder her und verwirft die alte Vorschau. Export speichert die bestätigten SVG-Bytes mit Prüfungen vor und nach dem Speicherdialog, ohne neue Logsuche oder Netzaufrufe. +**Wöchentliches Kontingenttempo** (`showWeeklyQuotaPace`) ist standardmäßig aus und nutzt vorhandene Beobachtungen: 29% Nutzung bei 25% Zeit ergibt 116% für den Zyklus. +Keine Prognose in den ersten sechs Stunden, nach dreißig Minuten ohne neue Beobachtung oder nach Reset; ungültige Zugangsdaten ziehen sie auch bei pausiertem Dashboard zurück. Konto-/Quellenwechsel und Zählerrückgänge sperren sie bis zu einem verifizierten normalen Sieben-Tage-Wechsel. +Codex-Logs verifizieren derzeit kein Konto, daher ist die Prognose nicht verfügbar. Dashboard und Statusleisten-Tooltip teilen die Übersetzungen; Balkentext und eigene Formate bleiben gleich. Keine neuen Anfragen, Timer oder Historien. +Bei unklarer Kontozuordnung bleiben der API-äquivalente Gesamtwert und der ungenutzte +Wert des aktuellen Zyklus lokale Verlaufsschätzungen mit geringer Sicherheit, keine +Kontoguthaben. Eine genaue Trennung nach Konto ist zukünftiger Mehrkontenunterstützung +vorbehalten. Diese Näherung ist von der Prognose des wöchentlichen Kontingenttempos getrennt. + +Die Übersicht verwendet horizontale Karten mit begrenzter Breite: Bild, Name, Rang und Fortschritt bleiben zusammen. Pins sitzen in der Karte, nächste Ziele haben einen schmalen Fortschrittsbalken. Wenige Abzeichen verteilen sich nicht mehr über die ganze Bildschirmbreite; schmale Ansichten umbrechen ohne kleinere Schrift. Gold-Rangbezeichnungen bleiben in hellen Designs gut lesbar und behalten einen goldenen Akzent. + +Leichte Inline-SVG-Symbole ergänzen die vorhandenen übersetzten Beschriftungen für Teilen, Einstellungen, Filter, Größe, Schaukasten-Editor und Informationen; Zähler erscheinen als kompakte Chips. +Über 960 px passt sich die Abzeichenspalte dem Inhalt an, begrenzt auf zwei Übersichtskartenbreiten plus deren inneren Abstand. Die Zielspalte füllt den verbleibenden Platz; der Spaltenabstand beträgt 16 px. Bei höchstens 960 px stehen beide direkt untereinander mit 12 px Abstand. Ergänzende Verlaufsbereiche folgen beiden. +Größenwahl vor der Übersicht: Sammlung 56 / 80 / 112 px, Schaukasten 56 / 72 / 96 px; Pinfehler stehen beim Steuerelement, der Tastaturfokus bleibt stabil. +Der USD-Effort-Teilbetrag zählt nicht zur Qualifikation. Leere und unbepreiste Werte werden gekennzeichnet, Teilbeträge als Untergrenze; ungültige oder überlaufende Beträge bleiben unverfügbar. +Details: [Englisch](README-en.md#heatmap-sharing-and-weekly-quota-pace-250). +Jede Serie hat ein eigenes Motiv, ihre Stufen teilen es mit Stufenfarben. Zahlen sind ganzzahlig; Datengrenzen sind aufklappbar. +Fehlende oder veraltete Nachweise werden statt Nullfortschritt angezeigt und vom nächsten Ziel ausgeschlossen; erworbene Abzeichen bleiben gespeichert. +Geschlossene Karten zeigen den aktuellen Fortschritt zur nächsten Stufe; Zwischenziele auf langen Token-Leitern sind keine zusätzlichen Abzeichen, Hinweise oder Exportoptionen. +Klein / Mittel / Groß verändern Abzeichen und Kartenrahmen, nicht Schriftgröße oder Klickflächen. +Nächste Ziele zeigt höchstens drei Themen mit aktuellen positiven Nachweisen; unbelegte Ziele und ungenutzte Modelle werden übersprungen. Die Auswahl bleibt beim Browsen stabil; ein Klick öffnet Regeln ohne Log-Lesen oder automatische Freigabe. +Nach vollständiger aktueller Indexierung zählen konfliktfreie Codex-Abstammungen für Token-/Agentenmeilensteine; Konfliktzweige und ihre Nachkommen sind ausgeschlossen. + +Bei verifizierter, vollständig indexierter aktueller lokaler Historie können belegte Teilzeiträume +Aktivitäts-, Modellfamilien- und Wochen-Token-Fortschritt mit `≥` zeigen. Untergrenzen für Wochenbeträge +nutzen nur verifizierte ausgewählte Dateien mit vollständig datierter Nutzung; Preisabdeckung und +Datumsabdeckung sind unabhängig. Teilzeiträume belegen weder vollständige Historie noch Nullverbrauch; +Vierwochenmittel und gespeicherte Spitzenwerte bleiben bis zur vollständigen Abdeckung ausstehend. +Gesamt-/Aufgabenumfänge bleiben unverändert, ohne zusätzliche Quelldatenlesevorgänge, Speicherung oder Netzaufrufe. + +Serien und Stufen werden getrennt gezählt. Lokale Filter bleiben nach Neuladen +erhalten; das nächste Ziel steht auf der Karte, alle Stufenregeln im aufklappbaren Bereich. +Standardmäßig an; **Local achievements** in ⚙ Settings (`enableAchievements`) +schaltet neue Auszeichnungen und den zusätzlichen Stunden-Nachlauf ab. +Beim Upgrade erscheint einmal ein nicht-modaler Hinweis mit Abschaltoption; +vorherige Abschaltungen bleiben erhalten. Historische Freischaltungen sind still. +Spätere Hinweise bleiben kurz im Dashboard, ohne Ton oder Fokuswechsel. +Bis zu neun gewählte Abzeichen lassen sich nach Vorschau als genau dieses SVG exportieren; +veraltete Antworten erlauben keinen Export. Direkte Wege führen zurück zur Galerie und zur Abschaltoption, +ohne GitHub-Anmeldung. Die Daten können mehrere Anmeldungen enthalten und +belegen keine persönliche Produktivität. [Speicherung und Löschen](LOCAL-DATA.md#local-achievements-250). + ## Daten richtig lesen Bei Codex gilt **verarbeitet = Eingabe + Ausgabe** und **nicht gecachte Nutzung diff --git a/README-en.md b/README-en.md index 0c5c5761..72a7ec04 100644 --- a/README-en.md +++ b/README-en.md @@ -60,6 +60,264 @@ These five v2.3 captures use the production renderer, synthetic fixtures, and VS - **Multi-vendor pricing** — Opus 4.x / Sonnet 4.x / Haiku 4.5 verified against Anthropic's public pricing; reference rates for OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen with family-aware fallback. `Refresh Token Pricing` pulls live LiteLLM data. - **Personalisation** — language, timezone, decimal places, compact numbers, project grouping, dashboard auto-refresh toggle. +Version 2.5.0 combines local achievements with Codex `.jsonl.zst` compatibility in `sessions` and `archived_sessions`. Zstd is lossless compression, not an LLM summary: smaller disk files preserve logged token counts. Reads stay local without changing source logs or saving decompressed copies; same-directory, same-basename `.jsonl` takes priority. Native Zstd (Node 22.15+ / 23.8+) adds no dependency; unsupported hosts report the limitation and retain last verified totals. + +## Local achievements (2.5.0) + +Session dates use the same uncapped admission as the canonical index; changed logs require recalibration. Verified real usage keeps its native first-recorded receipt even if calibration is cancelled. Partial saves are reported separately. + +Expand all / Collapse all saves only card disclosure state in the existing local UI +state; it does not change sources or awards. Ranks use localized Bronze / Silver / +Gold labels without changing IDs, pins or reference pricebooks. Identical conditions +and units are shown once across levels. One shared money-reference section shows +each enabled provider's current book/date and the non-billing note. Different +historical books retain their exact award associations there; cards keep scope +and pricing coverage without repeated price/basis notes. Fully earned cards show the uncapped current statistic; +stale or missing evidence is never replaced with a target value. + +Expanded rank rows use per-rank theme colors for the grade name (foreground fallback +in high contrast), with a bold, right-aligned threshold on the same header row. +Only the next rank above the highest earned shows “In progress” when proof is ready; +later ranks, fully earned cards and pending proof show no such label. Verified +retained-log dates appear as bare ISO dates, with their meaning explained once in +Evidence details and provenance retained in title/ARIA text. When both dates exist, +only the verified date is visible; first-recorded details remain in title/ARIA text. +A first-recorded date alone keeps its explicit label; unknown dates stay unknown. +This presentation change leaves awards, rules, date calculations and saved records intact. + +**Independent root tasks** counts verifiable Codex root ownership with recognized +positive model usage (input or output). A root and its children count once; this is +not a count of completed tasks. Existing rule IDs and thresholds are unchanged. + +The navigation and page label is **Share & compare**. Badge choices use equal-width +alignment and a separate rank line. The checkbox-icon repair uses the same +local-resource source as the main webview CSP. Exported badge SVGs include the fixed +public [repository link](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage), with +wrapping/spacing for long labels. Preview/export adds no automatic network request +or personal metadata and retains the accepted immutable SVG and explicit selection. +Broad sharing-page visual unification/beautification and a durable usage archive +are deferred to the next version; neither is implemented in 2.5.0. + +Per-level log dates are derived in memory and mean the earliest date verifiable +from retained logs, not complete account history. They do not replace saved +first-record dates or unread state. Daily totals supply supported volume, output, +cache, model/family, activity and weekly dates. Manual recalibration alone also +reduces already-held Claude records for output-session, Fable-session and +same-session Fable/other-family dates; ordinary warm refreshes do not regroup those +records. Unsupported conditions retain unknown dates or their saved first record. + +Recalibration is local, incremental and single-flight. It neither forces a full +scan nor clears badges, and makes no OAuth, pricing, BYOK or other network request. +It cannot force awards; busy work, an unaccepted current index census or failure is +not success. Verified partial history retains lower bounds or unknowns; completion +does not mean every award scope is fully covered. Cancellation +before the historical commit adds no historical awards; after commit it does not undo saved records or change +the successful result. Historical reconciliation stays quiet. See +[date and storage boundaries](LOCAL-DATA.md#local-achievements-250). + +Collaboration/rainbow model recognition is separate from pricing, so recognized but unpriced labels stay unpriced; retained monetary records keep their published pricebook labels, and Claude-only peaks survive unrelated Codex version changes. +Loading, unavailable, changed and disabled record states replace empty counts; verified Claude cumulative usage shows `≥` while enabled Codex evidence remains unverified. +Next targets show the target name and rank, earned cards keep their earned rank, and SVG exports show each rank once. + +Badge details link to existing current model/token statistics for the same provider, +with a keyboard-safe return. Shared and historical-period badges do not substitute +one provider's current totals for award evidence; navigation does not scan logs. + +Removing a legacy pin keeps keyboard focus within its theme, without refreshing data. +Exploration details show three saved local steps: collection, share preview and +successful export. Missing step records are not inferred from old awards; +advice feedback and setting changes remain separate, optional activities. +The journey emblem has no embedded counts. New foreground notices reveal only +the badge once for 440 ms; text stays still, with static reduced-motion and +high-contrast alternatives. No sound or focus change. + +Model experiences distinguish models recorded within one task from repeated +multi-model tasks. Fable same-session requires output from Fable and another +recognized Claude family. These names do not imply a sequence or hand-off. + +Turn off **Show achievement notices** (`showAchievementNotices`) to keep badges +and unread records without floating notices; re-enabling does not replay them. + +Proven new Claude/Codex Token tier crossings save unread first-record days even +when dashboard updates are paused. Supported rules cover model-family volume, +output, cache reads, Claude cache creation and cumulative volume. Imports and +explicit rebuilds stay quiet. Proven cumulative API-reference crossings now have +their own live receipts; other usage scopes still need their own origin proof. + +Awards use a local envelope capped at 64 KiB. Old badges keep unknown dates; +new saved-action badges retain unread first-record days even if a notice is missed. +Saved awards survive restarts and are shared across windows. Awards under older +rules retain their history without proving newer conditions. Clearing removes +the award records without changing provider source logs. +Badge notices appear only in the visible foreground panel, at most once per five +minutes across windows/restarts. Display confirmation reads only that batch; missed +notices stay unread. Hovering or keyboard focus pauses dismissal. +Missed awards appear in a folded New awards summary with at most six exact levels +per group. Only Mark this group read confirms that group after a successful local +save; browsing, pinning and sharing do not. Other windows' later awards remain unread. +Recently earned shows at most three new series; Recent records folds up to thirty +saved levels and links to their rules. My badges supports up to six manually pinned +themes through closed-card pin icons and a searchable editor with explicit ordering. +Manual mode shows only chosen pins; automatic mode recommends at most three. +Pins and order survive restart and never select sharing. +Pins hidden by a disabled provider can be removed from the folded hidden-pin list +without re-enabling that provider. + +Explicit sharing can include retained earned badges from a disabled provider. +The gallery and pin choices follow enabled providers; retained badges do not +supply current progress or authorize new awards. +Dates mean first recorded, not inferred +attainment; historical dates remain unknown. Browsing does not mark records read or select sharing. + +Imported plugin-experience records stay quiet. A newly saved action can unlock +its badge even with the dashboard closed; it never opens or refreshes a hidden panel. + +Flat agent logs without a path-proven parent do not count toward session-based +achievements. Their Token totals, pricing, daily aggregates and visible session +rows stay unchanged; nested-agent ownership and saved awards are retained. + +The 2.5.0 icon-first gallery groups **50 themes and 178 award conditions** into bronze, +silver and gold tiers, with platinum/legendary sustained-use and weekly-money ranks. Expand a card +for every level's rule; the next target is visible on the card. Series and unlocked +levels are counted separately. Local All / Next level / Earned filters survive reload, +and tier upgrades keep the disclosure open. Claude Sonnet/Opus/Haiku/Fable and Codex +Astra/Sol/Luna progression is primarily token-based; effort, agents, active days, +cache/output, and a few deliberate plugin actions add variety. +Each series has distinct artwork, with one emblem shared by its colored tiers. +Achievement numbers use integers; detailed conditions and data caveats stay expandable. +The gallery uses 52 artwork groups. Fourteen-day and first shared-day +milestones sit inside their growing themes, with distinct award and sharing choices. +Fifteen sustained Claude/activity series add Platinum and Legendary ranks without changing their first three targets. Existing verified counters and artwork are reused. Activity days, output and sessions support concentrated three-day weeks without requiring Codex or higher spending; consecutive-day tiers are not extended. +Pending or outdated evidence is labelled, not shown as zero progress, and excluded +from Next level filtering; earned badges remain saved. +Closed cards show current / next-level progress. Long Token ladders include +intermediate stages, without extra awards, notices or sharing choices. +Small / Medium / Large sizes change badge and card frames, preserving text size +and click areas. Next goals highlights at most three distinct themes before +the complete collection. Goals require positive, current evidence; unproven goals +and unused models are not promoted. Selection stays fixed during a visit; opening a badge focuses +its existing rule without reading logs or selecting it for sharing. + +Cumulative processed tokens use one series: **100M → 1B → 10B → 100B → 1T**. +Calendar-week peaks use **500M / 2B / 10B**; the highest average across four +completed weeks uses **500M / 2B / 5B**. Weeks start Monday in the configured +timezone. Unfinished weeks and incomplete coverage cannot certify a four-week average. + +API-equivalent milestones add 24 conditions across six separate Claude/Codex +themes: cumulative priced subtotals, completed Monday-start weeks in the current +data timezone, and one Claude session or one verified Codex task tree. +Both weekly series have five ranks: Bronze $100, Silver $500, Gold $2,500, +Platinum $10,000 and Legendary $25,000. Only the last two per provider are new; +old IDs/thresholds, completed-week requirements and frozen-price provenance remain unchanged. Fixed USD +reference prices (`api-reference-2026-10-{backend}-v1`, dated 2026-10-09) require +exact known models and complete component rates, including Claude's known +5-minute/1-hour cache-write TTL. Codex counts uncached input, cached input and +output once. Unknown models remain unpriced; verified priced subsets can prove +a lower bound (`≥`), not full coverage. These amounts are neither bills, quality +scores nor income. History stays quiet; only cumulative money milestones now save +unread first-record days from verified new appends under the same reference book. +Completed-week and single-session/task money awards stay quiet with unknown dates. +Independent Token/money receipts use one existing notice batch; failed saves do +not show optimistic dates. Model/team/effort/rainbow rules now combine output, repeated use +and usage scale, excluding duplicate aliases, input-only activity and unverified structures. + +Expand the existing Codex effort-breadth card for six rows: low, medium, high, +xhigh, max and ultra. Each shows processed/output Tokens, qualifying root tasks +and joint progress toward the existing next tier. Per-row processed targets are +**100M / 500M / 2B**, output **100K / 500K / 2M**, and roots **5 / 10 / 30**; all three +must hold. Each root × effort population requires its own positive output and +excludes approval-reviewer usage. The informational USD subtotal/coverage uses +`api-reference-2026-10-openai-v1` (2026-10-09) and does not qualify effort awards. +Model × effort ambiguity is never +allocated by ratios; known pricing needs reconciled full component coverage under +a single model or single effort. Empty, unpriced, partial, invalid and overflow +states stay distinct: partial amounts show a lower bound, invalid/overflow amounts +are withheld; absent/incomplete proof shows unknown, not six zero rows. +No rules, targets, navigation or preferences are added. Claude effort attribution +is currently unavailable. + +The gallery is **on by default**. Disable **Local achievements** in ⚙ Settings +(`enableAchievements`). With release announcements enabled, an upgrade shows one +non-modal reminder offering **View achievements** or **Turn achievements off**; +previous opt-outs are preserved. Historical unlocks stay quiet; later unlocks use +a brief in-dashboard notice without sound or focus theft. Night/24–48-hour badges +remain silent. Off stops new awards and extra hour-proof backfill, not usage tracking. + +Sharing requires selecting up to nine earned badges, accepting a preview and +exporting that exact SVG. Stale replies cannot enable export for a newer selection; +preview failures offer retry guidance. Return to the gallery from sharing, or use +its settings shortcut to focus the opt-out without changing it. No GitHub identity, +auto-export or upload is involved. +Rules reuse verified numeric aggregates; expanding cards does not read logs. +With complete current indexing, unrelated Codex identity conflicts no longer block +independently verified Token/agent milestones; conflicting branches and descendants +are excluded. + +With current local history verified and fully indexed, trusted partial dates can +show `≥` for activity, model-family and weekly Token progress. Weekly money bounds +use only verified selected files whose usage is fully dated; pricing coverage is +independent of date coverage. Partial dates do not establish complete history or +verified zero, and four-week means/retained peaks remain pending until full coverage. +Cumulative/task scopes are unchanged, with no extra source reads, storage or network calls. + +Older Codex indexes may get one bounded, resumable hour-proof pass, without +resetting primary totals. Refresh failures retain visible statistics but cannot +award from stale evidence. + +Badges describe local history, possibly from shared accounts or several sign-ins, +not personal productivity or subscription allowance. Hour streaks mean recorded +output in each UTC hour, not continuous human work. Safe-setting exploration and +local feedback never require credentials or network consent. The exact bounded +award storage and clear controls are in [LOCAL-DATA.md](LOCAL-DATA.md#local-achievements-250). + +## Heatmap sharing and weekly quota pace (2.5.0) + +The 2.5.0 sharing heatmap offers **Claude / Codex / Both** +and **Tokens / API-equivalent amount**. Tokens are the default; amounts require +an explicit choice. Amounts use current daily estimates, not achievement reference +prices: Claude is estimated, while Codex prices exact known models only. Missing +pricing stays unavailable or shows a priced lower bound with coverage. Both uses +two monetary panels and never sums amounts. Hiding amounts restores Token colors, +text, tooltips, metadata and Markdown and retires the old preview. Export saves the +exact accepted SVG bytes after checking document, controls, source, pricing and +display revisions before and after the save dialog. The bounded daily projection +reuses materialized data without a new log scan or network request. For selected +Claude days, a memoized in-memory rate lookup admits known model/component coverage; +the generic unknown-model Sonnet fallback is unpriced. Missing component rates +reduce coverage; explicit zero rates are valid. Stored daily/model costs are not +recomputed, family/runtime prices remain estimated, and hidden/Token mode never +looks up rates or money. + +**Weekly quota pace** (`showWeeklyQuotaPace`) defaults off, with one setting for both +providers, and uses only existing quota observations when quota tracking is enabled. +Uniform progress starts at reset minus seven days; 29% used at 25% elapsed forecasts +116% over the cycle. The first six hours have no forecast; an observation older than +thirty minutes or a passed reset withdraws it. Credential invalidation withdraws +it immediately, even with dashboard auto-refresh off. Account/source changes, +counter drops or unexpected resets suspend trust until a verified ordinary seven-day +rollover. Current Codex local logs cannot verify an account, so forecasting is +unavailable. Dashboard and status-bar tooltip copy is shared across all eight +locales; bar text/custom formats stay unchanged. No requests, timers or history are added. + +Current-cycle total and unused API-equivalent values remain low-confidence +local-history estimates when account attribution is unclear. Accurate per-account +separation is deferred to future multi-account support; these values are not account +balances. This approximation is separate from the weekly quota-pace forecast. + +The overview uses bounded horizontal cards: artwork, title, rank and progress stay together, with pins inside the badge card and a thin progress bar for next targets. A few badges no longer spread across the entire desktop width; narrow views wrap without shrinking text. Gold rank labels stay legible in light themes while retaining a gold accent. + +Lightweight inline SVG icons accompany the existing localized share, settings, +filter, size, showcase-editor and information labels; counts appear as compact chips. +Above 960 px, the badge column fits its content up to two overview-card widths +plus their internal gap; the next-target column fills the remaining space, with +16 px between columns. At 960 px or below they stack directly with a 12 px gap. +Auxiliary history sections follow both. + +Gallery size controls now precede the overview. Small / Medium / Large use 56 / 80 / +112 px collection icons and 56 / 72 / 96 px showcase icons, with nearby pin errors +and stable keyboard focus when controls disappear or become disabled. See [the data inventory](LOCAL-DATA.md) +for exact storage boundaries. + ## What's new in v2.4
diff --git a/README-id.md b/README-id.md index 1277b3c7..282b60c9 100644 --- a/README-id.md +++ b/README-id.md @@ -58,6 +58,181 @@ Lima gambar v2.3 ini menggunakan renderer produksi, data sintetis, dan variabel - **Harga multi-vendor** — Opus 4.x / Sonnet 4.x / Haiku 4.5 diverifikasi terhadap harga publik Anthropic; tarif referensi untuk OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen dengan fallback berbasis family model. `Refresh Token Pricing` menarik data LiteLLM langsung. - **Personalisasi** — bahasa, zona waktu, angka desimal, angka ringkas, pengelompokan proyek, toggle penyegaran otomatis dashboard. +Versi 2.5.0 menggabungkan pencapaian lokal dan dukungan `.jsonl.zst` Codex di `sessions` serta `archived_sessions`. Zstd adalah kompresi tanpa kehilangan data, bukan ringkasan LLM: berkas yang lebih kecil tidak mengubah jumlah token tercatat. Pembacaan tetap lokal tanpa mengubah log sumber atau menyimpan salinan teks hasil dekompresi; `.jsonl` bernama sama dalam folder yang sama diprioritaskan. Zstd bawaan (Node 22.15+ / 23.8+) tidak menambah dependensi; host yang tidak mendukungnya melaporkan keterbatasan dan mempertahankan total terakhir yang terverifikasi. + +## Pencapaian lokal (2.5.0) + +Tanggal sesi memakai kelayakan lengkap yang sama dengan indeks kanonis; log yang berubah perlu dikalibrasi ulang. Penggunaan baru yang terverifikasi tetap menyimpan catatan pertama meski kalibrasi dibatalkan. Penyimpanan sebagian dilaporkan terpisah. + +Buka semua / Tutup semua hanya menyimpan keadaan buka kartu dalam status UI lokal +yang sudah ada, tanpa mengubah sumber atau pemberian lencana. Tingkat memakai +Perunggu / Perak / Emas; ID lama, sematan, dan daftar harga referensi tetap sama. +Syarat dan satuan yang persis sama ditampilkan sekali. Bagian referensi nilai uang bersama +menampilkan daftar harga saat ini/tanggal untuk setiap penyedia aktif serta keterangan bahwa +nilainya bukan tagihan. Daftar harga historis yang berbeda tetap dikaitkan dengan penghargaan +yang tepat; kartu mempertahankan cakupan penggunaan/harga tanpa mengulang catatan harga/dasar pemberian. +Kartu dengan semua tingkat +tercapai menampilkan statistik saat ini tanpa dipotong; bukti usang atau hilang +tidak diganti dengan nilai target. + +Baris tingkat yang dibuka memakai warna tema per tingkat untuk nama (warna latar depan +pada kontras tinggi), dengan ambang tebal dan rata kanan pada baris judul yang sama. +Hanya tingkat tepat setelah tingkat tertinggi yang diraih menampilkan “Berlangsung” saat +bukti siap; tingkat selanjutnya, kartu yang seluruh tingkatnya diraih, dan bukti tertunda +tidak menampilkan label itu. Tanggal terverifikasi dari log tersimpan tampil sebagai tanggal +ISO saja; maknanya dijelaskan sekali di “Dasar data” dan asalnya tetap ada dalam title/ARIA. +Jika kedua tanggal tersedia, hanya tanggal terverifikasi yang terlihat; rincian pencatatan +pertama tetap di title/ARIA. Jika hanya tanggal pencatatan pertama tersedia, label eksplisitnya +tetap tampil; tanggal tidak diketahui tetap demikian. Perubahan tampilan ini tidak mengubah +pemberian lencana, aturan, perhitungan tanggal, atau catatan tersimpan. + +**Tugas utama independen** menghitung kepemilikan akar Codex yang dapat diverifikasi +dengan penggunaan positif model yang dikenali (input atau output). Tugas utama dan +turunannya dihitung sekali, bukan sebagai tugas selesai. ID dan ambang aturan lama tetap sama. + +Navigasi dan judul halaman menjadi **Bagikan & bandingkan**. Pilihan lencana memiliki +lebar seragam dan baris tingkat terpisah. Perbaikan ikon kotak centang memakai sumber +daya lokal yang sama dengan CSP Webview utama. SVG ekspor memuat +[tautan repositori publik tetap](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage), dengan +pembungkusan teks dan jarak untuk label panjang. Pratinjau/ekspor tidak menambah permintaan +jaringan otomatis atau metadata pribadi; pilihan eksplisit dan SVG yang telah diterima tetap +tidak berubah. Penyatuan/pemolesan visual halaman berbagi secara menyeluruh dan arsip +penggunaan tahan lama ditunda ke versi berikutnya; keduanya belum diterapkan dalam versi 2.5.0. + +Tanggal tiap tingkat hanya diturunkan dalam memori dan berarti tanggal paling awal +yang dapat diverifikasi dari log tersimpan, bukan riwayat lengkap akun. Tanggal +pencatatan pertama dan status belum dibaca tetap utuh. Agregat harian menyediakan +tanggal penggunaan, output, cache, model/keluarga, aktivitas, dan pekan yang didukung. +Hanya kalibrasi manual yang juga mengagregasi catatan Claude yang sudah ada dalam +memori untuk sesi dengan output, sesi Fable, serta Fable/keluarga lain dalam satu +sesi. Penyegaran rutin saat data sudah dimuat tidak mengagregasi ulang catatan itu. +Syarat yang belum didukung tetap bertanggal tidak diketahui atau memakai catatan pertama. + +Kalibrasi berjalan lokal dan inkremental, satu operasi pada satu waktu, tanpa +memaksa pemindaian penuh, menghapus lencana, atau meminta OAuth, harga, BYOK, maupun +layanan jaringan lain. Kalibrasi tidak memaksa pemberian lencana; sibuk, keseluruhan +indeks terkini belum diterima, atau gagal bukan keberhasilan. Riwayat parsial yang +terverifikasi tetap berupa batas bawah atau tidak diketahui; selesai tidak berarti +semua cakupan lencana lengkap. Pembatalan sebelum commit riwayat tidak menambah lencana historis; +setelah commit, catatan tersimpan dan hasil berhasil tidak dibatalkan. +Rekonsiliasi historis tetap tanpa notifikasi. +[Batas tanggal dan penyimpanan](LOCAL-DATA.md#local-achievements-250). + +Pengenalan model untuk kolaborasi/pelangi terpisah dari harga, sehingga model yang dikenali tanpa tarif tetap tanpa harga acuan; catatan nilai uang mempertahankan label tabel harga yang telah diterbitkan, dan puncak khusus Claude tetap tersimpan saat versi Codex yang tidak terkait berubah. +Status catatan sedang dimuat, tidak tersedia, berubah, dan dinonaktifkan ditampilkan secara jelas alih-alih jumlah kosong; penggunaan kumulatif Claude yang terverifikasi menunjukkan `≥` selama bukti Codex yang diaktifkan belum terverifikasi. +Target berikutnya menampilkan nama dan tingkat target, kartu yang diperoleh mempertahankan tingkatnya, dan ekspor SVG menampilkan setiap tingkat sekali saja. + +Detail lencana membuka statistik model/token saat ini dari penyedia yang sama, dengan kembali dan fokus terjaga. +Tidak memindai ulang log atau mengganti bukti lintas penyedia/periode lama dengan jumlah saat ini. + +Saat melepas sematan lama, fokus keyboard tetap dalam tema tanpa menyegarkan data. +Detail eksplorasi menampilkan langkah tersimpan: koleksi, pratinjau, dan ekspor lokal yang berhasil. +Lencana lama tidak mengisi langkah yang hilang; umpan balik dan pengaturan tetap terpisah. +Ikon tidak memuat angka tetap. Notifikasi baru hanya menggerakkan lencana sekali selama 440 ms; +teks, gerakan dikurangi, dan kontras tinggi tetap statis, tanpa suara atau perubahan fokus. + +Model dalam satu tugas dibedakan dari jumlah tugas multimodel yang terkumpul. +Fable dalam sesi yang sama memerlukan output Fable dan keluarga Claude lain +yang dikenali dalam satu sesi, tanpa menyiratkan urutan eksekusi. + +Matikan **Tampilkan notifikasi pencapaian** (`showAchievementNotices`) untuk tetap +menyimpan lencana dan catatan belum dibaca tanpa notifikasi melayang; mengaktifkan kembali tidak memutar ulangnya. + +Tingkat Token Claude/Codex baru yang terbukti menyimpan status belum dibaca dan +hari pencatatan pertama meski pembaruan dasbor dijeda. Aturan mencakup volume +keluarga model, output, pembacaan cache, pembuatan cache Claude, dan volume kumulatif. +Impor dan pembangunan ulang manual tetap senyap; nilai referensi API kumulatif kini memiliki bukti sendiri, cakupan penggunaan lain masih memerlukan bukti asal. + +Catatan penghargaan hanya lokal, maksimal 64 KiB. Tanggal lama tetap tidak diketahui; +tindakan baru yang tersimpan mempertahankan status belum dibaca dan hari pencatatan pertama +meski notifikasi terlewat. Penghargaan tersimpan bertahan setelah mulai ulang dan dibagikan antarjendela. +Penghargaan dari aturan lama mempertahankan riwayatnya tanpa membuktikan syarat baru. Penghapusan tidak mengubah log sumber penyedia. +Notifikasi hanya tampil di panel yang terlihat di depan, maksimal sekali per lima +menit antarjendela dan setelah mulai ulang. Hanya kelompok yang dikonfirmasi tampil +ditandai dibaca; yang terlewat tetap belum dibaca. Hover atau fokus papan ketik menunda penutupan. +Penghargaan yang terlewat masuk ringkasan yang awalnya terlipat, maksimal enam +tingkat tepat per kelompok. Hanya konfirmasi eksplisit setelah penyimpanan lokal +berhasil yang menandai kelompok itu dibaca; melihat, menyematkan dan berbagi tidak. +Penghargaan baru dari jendela lain tetap belum dibaca. +Baru diperoleh menampilkan maksimal tiga seri baru; catatan yang dapat dilipat +memuat maksimal tiga puluh tingkat dengan tautan aturan. Tanggal adalah pencatatan +pertama, bukan perkiraan pencapaian asli; tanggal lama tetap tidak diketahui. +Melihat catatan tidak menandainya sebagai sudah dibaca atau memilihnya untuk dibagikan. +Ikon semat pada kartu tertutup dan pemilih dengan pencarian mendukung hingga enam tema dengan urutan eksplisit. +Mode manual hanya menampilkan pilihan pengguna; rekomendasi otomatis maksimal tiga. +Pilihan dan urutan bertahan setelah mulai ulang, tanpa memilih lencana untuk dibagikan. +Lencana tersemat yang tersembunyi dapat dilepas melalui daftar lipat tanpa mengaktifkan kembali penyedia. + +Berbagi secara eksplisit dapat menyertakan lencana yang sudah diperoleh dan tersimpan dari penyedia nonaktif. +Galeri dan pilihan sematan baru mengikuti penyedia aktif; lencana tersimpan tidak membuktikan +progres saat ini atau memberi hak untuk penghargaan baru. + +Catatan penggunaan plugin yang diimpor tidak memicu pemberitahuan. Tindakan baru +yang berhasil disimpan dapat membuka lencana meski dasbor ditutup, tanpa membuka atau menyegarkan panel tersembunyi. + +Log agen yang diletakkan sejajar tanpa sesi induk yang terbukti dari jalur tidak +dihitung sebagai bukti pencapaian berbasis sesi. Token, harga, agregat harian dan +baris sesi tetap sama; atribusi agen bersarang serta lencana tersimpan dipertahankan. + +Galeri 2.5.0 memuat 50 tema, 178 syarat pembukaan, dan 52 kelompok gambar, meliputi token model, +output/cache, hari aktif, rekor mingguan, dan beberapa tindakan sengaja di plugin. + +Lima belas seri Claude/aktivitas menambah Platina dan Legendaris, tanpa mengubah tiga target awal, bukti, atau gambar. Hari aktif, output, dan sesi mendukung kemajuan dalam tiga hari kerja terpusat per pekan, tanpa Codex atau belanja tambahan. Tingkat hari berturut-turut tidak ditambah. + +Ada 24 syarat nilai uang dalam enam tema terpisah Claude/Codex: subtotal kumulatif yang memiliki harga, minggu kalender yang telah berakhir dan dimulai pada Senin, serta penggunaan tunggal. +Kedua seri mingguan memiliki lima tingkat: Perunggu USD 100, Perak USD 500, Emas USD 2.500, +Platina USD 10.000, dan Legendaris USD 25.000. Hanya dua tingkat terakhir tiap penyedia yang baru; +ID/ambang lama, syarat minggu yang telah berakhir, dan dasar harga referensi tetap tidak berubah. +Minggu mengikuti zona waktu data saat ini; penggunaan tunggal berarti satu sesi Claude atau satu pohon tugas Codex yang terverifikasi. +Harga referensi USD tetap dengan versi memerlukan model yang dikenal secara tepat dan tarif komponen lengkap, termasuk TTL cache Claude. +Model tak dikenal tetap tanpa harga; bagian terverifikasi membuktikan batas bawah, bukan tagihan, kualitas atau pendapatan. Hanya tonggak uang kumulatif dari tambahan baru terverifikasi dengan referensi sama menyimpan status belum dibaca dan hari pertama tercatat. Riwayat, minggu selesai dan sesi/tugas tunggal tetap senyap dengan tanggal tak diketahui. Bukti Token/uang terpisah dan memakai satu kelompok notifikasi; gagal simpan tidak menampilkan tanggal optimistis. +Kartu effort Codex yang ada menampilkan enam tingkat ketika dibuka: Token, output, tugas akar, progres gabungan dan subtotal USD referensi tetap/cakupan sebagai informasi. Hanya output positif milik akar × effort itu sendiri yang dihitung, tanpa approval-reviewer. Atribusi model ambigu tidak dibagi proporsional; bukti hilang tetap tak diketahui. Tanpa aturan/preferensi baru atau atribusi effort Claude. +Model/tim/effort/pelangi memakai syarat gabungan output, pengalaman berulang, dan volume. +Heatmap berbagi menyediakan Claude / Codex / Keduanya dan Token / Nilai setara API. Token adalah default; nilai uang dipilih secara eksplisit dan memakai estimasi harian terkini, bukan harga referensi pencapaian. +Claude berupa estimasi, Codex hanya memakai harga model tepat yang dikenal. Harga hilang dan subtotal batas bawah ditandai dengan cakupan; nilai kedua penyedia tidak pernah dijumlahkan. +Cakupan Claude hanya memeriksa tarif model/komponen terpilih dalam memori. Fallback Sonnet generik untuk model tak dikenal tidak dihitung; tarif hilang tetap tanpa harga, nol eksplisit sah. Biaya tersimpan tidak dihitung ulang; mode Token/nilai tersembunyi tidak mencari tarif atau uang. +Menyembunyikan nilai mengembalikan warna, teks dan Markdown ke Token serta membatalkan pratinjau lama. Ekspor memakai byte SVG yang diterima, dengan pemeriksaan sebelum/sesudah dialog simpan, tanpa pemindaian log atau jaringan baru. +**Laju kuota mingguan** (`showWeeklyQuotaPace`) default nonaktif dan memakai observasi yang sudah ada: terpakai 29% saat 25% siklus berlalu memproyeksikan 116%. +Tidak ada prediksi dalam enam jam pertama, setelah tiga puluh menit tanpa observasi baru, atau setelah reset. Kredensial tidak valid segera menarik prediksi meski dasbor dijeda; pergantian akun/sumber dan penurunan penghitung menunggu pergantian tujuh hari normal yang terverifikasi. +Log Codex saat ini tidak memverifikasi akun, sehingga prediksi tidak tersedia. Dasbor dan tooltip bilah status berbagi terjemahan; teks bilah dan format khusus tetap sama. Tanpa permintaan, timer atau riwayat baru. +Jika atribusi akun tidak jelas, nilai total dan belum terpakai setara API untuk siklus +berjalan tetap berupa estimasi riwayat lokal berkeyakinan rendah, bukan saldo akun. +Pemisahan akurat per akun ditunda hingga dukungan multiakun mendatang; perkiraan ini +terpisah dari prediksi laju kuota mingguan. + +Ringkasan memakai kartu horizontal dengan lebar terbatas: gambar, nama, tingkat, dan progres tetap berdekatan. Tombol semat berada di dalam kartu, dan target berikutnya memiliki bilah progres tipis. Beberapa lencana tidak lagi tersebar sepanjang layar; tampilan sempit membungkus kartu tanpa mengecilkan teks. Label tingkat emas tetap terbaca pada tema terang dengan aksen emas yang dipertahankan. + +Ikon SVG inline ringan menyertai label terjemahan yang ada untuk berbagi, pengaturan, filter, ukuran, editor etalase, dan informasi; jumlah ditampilkan sebagai chip ringkas. +Di atas 960 px, kolom lencana mengikuti isi dengan batas dua lebar kartu ringkasan beserta jarak internalnya. Kolom target berikutnya mengisi ruang tersisa, dengan jarak antarkolom 16 px. Pada lebar 960 px atau kurang, keduanya langsung bertumpuk dengan jarak 12 px. Bagian riwayat tambahan berada setelah keduanya. +Pilihan ukuran mendahului ringkasan: koleksi 56 / 80 / 112 px, etalase 56 / 72 / 96 px; kesalahan semat dekat kontrol dan fokus keyboard tetap stabil. +Subtotal USD effort bukan syarat pencapaian. Kosong dan tanpa harga dibedakan; nilai parsial menunjukkan batas bawah, sedangkan nilai tidak valid/overflow tidak tersedia. +[Detail bahasa Inggris](README-en.md#heatmap-sharing-and-weekly-quota-pace-250). +Setiap seri memiliki gambar berbeda, dipakai bersama oleh tingkatnya dengan warna tingkat. Angka hanya bilangan bulat; batas data dapat dibuka. +Bukti belum lengkap atau kedaluwarsa ditandai, bukan dianggap progres nol, dan dikecualikan dari target berikutnya; lencana yang diperoleh tetap tersimpan. +Kartu tertutup menampilkan progres menuju tingkat berikutnya; tahap perantara pada tangga token panjang bukan lencana, pemberitahuan, atau pilihan berbagi tambahan. +Kecil / Sedang / Besar mengubah lencana dan bingkai kartu, menjaga ukuran huruf dan area klik. Target berikutnya maksimal tiga tema, melewati target yang belum terbukti dan model yang belum dipakai. +Target memerlukan progres positif dengan bukti terkini; urutan tetap selama kunjungan. Klik membuka aturan tanpa membaca log atau otomatis memilih untuk berbagi. +Setelah indeks terkini lengkap, silsilah Codex tanpa konflik dapat membuktikan tonggak token/agen; cabang yang berkonflik dan keturunannya dikecualikan. + +Setelah riwayat lokal terkini terverifikasi dan seluruhnya terindeks, sebagian tanggal yang tepercaya +dapat menampilkan `≥` untuk progres aktivitas, keluarga model dan Token mingguan. Batas bawah nilai uang +mingguan hanya memakai berkas terpilih yang terverifikasi dengan seluruh penggunaan bertanggal; +cakupan harga dan tanggal terpisah. Sebagian tanggal tidak membuktikan riwayat lengkap atau penggunaan +nol, dan rata-rata empat minggu/puncak tersimpan tetap tertunda hingga cakupan lengkap. +Cakupan kumulatif/tugas tetap sama, tanpa pembacaan sumber, penyimpanan atau panggilan jaringan tambahan. + +Seri dan tingkat dihitung terpisah. Filter lokal bertahan setelah muat ulang; +target berikutnya tampil di kartu, semua aturan tingkat ada di bagian yang dapat dibuka. +Aktif secara default; matikan **Local achievements** di ⚙ Settings +(`enableAchievements`) untuk menghentikan penghargaan baru dan pengisian bukti jam +tambahan, bukan pelacakan penggunaan biasa. Saat upgrade ada satu pemberitahuan +non-modal dengan pilihan menonaktifkan; pilihan nonaktif sebelumnya tetap berlaku. +Pembukaan historis tetap senyap; pemberitahuan baru singkat di panel tanpa suara +atau mengambil fokus. Pilih hingga sembilan lencana dan ekspor SVG persis seperti pratinjau; +balasan lama tidak mengaktifkan ekspor. Ada pintasan kembali ke galeri dan ke pengaturan nonaktif, +tanpa login GitHub. Riwayat akun bersama/beberapa login bukan bukti produktivitas +pribadi. [Penyimpanan dan penghapusan](LOCAL-DATA.md#local-achievements-250). + ## Yang baru di v2.4
diff --git a/README-ja.md b/README-ja.md index bffb6feb..56f6bc06 100644 --- a/README-ja.md +++ b/README-ja.md @@ -58,6 +58,159 @@ - **マルチベンダー価格** — Opus 4.x / Sonnet 4.x / Haiku 4.5 は Anthropic 公式価格で検証済み。OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen の参考価格(ファミリー認識フォールバック付き)。`Refresh Token Pricing` で LiteLLM の最新価格を取得。 - **パーソナライズ** — 言語、タイムゾーン、小数点桁数、コンパクト数値、プロジェクトのグループ化、ダッシュボード自動更新の切り替え。 +バージョン 2.5.0 は、ローカル実績と Codex の `sessions`・`archived_sessions` 内の `.jsonl.zst` 対応を統合します。Zstd は可逆圧縮であり、LLM による要約ではありません。ファイルが小さくなっても記録済みトークン数は変わりません。読み取りはローカルで行い、元ログの変更や解凍した平文コピーの保存はせず、同じフォルダーの同名 `.jsonl` を優先します。ネイティブ Zstd(Node 22.15+ / 23.8+)を使い、依存関係は追加しません。非対応のホストでは制限を明示し、最後に検証した集計を保持します。 + +## ローカル実績(2.5.0) + +セッションの日付は正規インデックスと同じ全件の採用条件で計算し、ログ変更後は再検証できます。再検証を取り消しても検証済みの新しい使用量の初回記録は失われません。一部のみ保存された結果は別途表示します。 + +すべて展開/折りたたむ操作は、カードの開閉状態だけを既存のローカル UI 状態に保存し、 +データソースや授与を変更しません。ランクはブロンズ/シルバー/ゴールドとし、旧 ID、 +ピン、参照価格表は維持します。完全に同じ条件と単位は一度だけ表示します。共通の金額参照欄に +有効な各プロバイダーの現行価格表・日付と、請求額ではない旨を表示します。異なる過去の価格表は +対応する各実績との正確な関連を維持し、カードには対象範囲と価格適用率を残して価格・授与根拠の +重複注記を除きます。最高ランクでも現在の統計値を上限で切らず、 +古い証拠や欠落した値を目標値で補いません。 + +展開したランク行では名称にランク別のテーマ色を使い(ハイコントラストでは前景色に戻す)、 +同じ見出し行の右側にしきい値を太字・右揃えで表示します。証拠が揃った場合に限り、獲得済みの +最高ランクの次だけに「進行中」を表示し、それ以降のランク、全ランク獲得済み、証拠待ちでは +表示しません。保管ログで検証済みの日付は ISO 日付だけを表示し、意味は「データの根拠」で +一度説明、出典は title/ARIA に残します。両日付がある場合は検証済み日付だけを表示し、 +初回記録の詳細は title/ARIA に残します。初回記録日だけの場合は明示ラベルを維持し、不明は +不明のままです。この表示変更は授与、ルール、日付計算、保存済み記録を変更しません。 + +**独立メインタスク**は、ルートへの帰属を検証でき、識別済みモデルの正の使用量(入力または出力)が +ある Codex タスクを数えます。メインタスクと子タスクは合わせて一件で、完了件数ではありません。 +既存のルール ID としきい値は変わりません。 + +ナビゲーションとページ名は**共有と比較**です。実績の選択欄は等幅に揃え、ランクを別行に表示します。 +チェック欄アイコン修正は、メイン Webview CSP と同じローカルリソースの許可元を使います。 +エクスポート SVG には固定の公開[リポジトリリンク](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage)を加え、 +長いラベルは改行と余白で重なりを防ぎます。プレビュー/出力は自動通信や個人メタデータを追加せず、 +明示的な選択と確認済み SVG の不変性を維持します。共有ページ全体の視覚的な統一・美化と永続的な +使用量アーカイブは次のバージョンに延期され、2.5.0 には実装されていません。 + +各ランクのログ日付はメモリ内だけで算出し、残存ログで確認できる最も早い日付を示します。 +アカウントの完全な履歴ではなく、保存済みの初回記録日や未読状態も置き換えません。 +使用量、出力、キャッシュ、モデル/系統、活動、週単位の日付は既存の日別集計を使用します。 +手動再検証時だけ、保持済みの Claude メモリ記録から出力セッション、Fable セッション、 +同一セッション内の Fable/他系統の日付も集計します。通常のウォーム更新ではこの再集計を +行いません。未対応の条件は日付不明、または保存済みの初回記録のままです。 + +再検証はローカルの差分処理を一度に一件だけ実行し、全件スキャンの強制、バッジ削除、 +OAuth・価格取得・BYOK・その他のネットワーク要求を行いません。授与を強制せず、 +処理中・現在の索引全体が未承認・失敗を成功とみなしません。検証済みの部分履歴は下限または +不明を維持し、完了はすべての授与範囲の完全な網羅を意味しません。履歴コミット前のキャンセルは過去分の授与を追加せず、 +コミット後のキャンセルは保存済み記録を取り消したり成功結果を変更したりしません。 +過去分の照合は通知しません。 +[日付と保存の範囲](LOCAL-DATA.md#local-achievements-250)。 + +協働/レインボーのモデル認識は価格計算から独立し、認識されても参照価格がないモデルはそのまま価格計算の対象外となり、保存済み金額実績は公開済み価格表のラベルを保持し、Claude のみのピークは無関係な Codex のバージョン変更で失われません。 +記録の読み込み中、利用不可、変更済み、無効を空の件数の代わりに明示し、有効な Codex の証拠が未検証の間は検証済み Claude 累計用量を `≥` の下限として表示します。 +次の目標には目標名とランクを表示し、獲得済みカードは獲得ランクを保持し、SVG 出力ではランクを重複表示しません。 + +バッジから同じプロバイダーの既存の現在モデル/トークン統計を開き、展開とフォーカスを保って戻れます。 +ログは再走査せず、現在の合計を共有・過去期間の獲得証拠として扱いません。 + +旧ピンを解除してもキーボードフォーカスは同じテーマ内に残り、データは更新しません。 +探索の詳細には図鑑、共有プレビュー、成功したローカル出力の保存済み記録を表示します。 +旧実績から手順を推測せず、フィードバックと設定変更は独立した体験です。 +探索アイコンに固定の数値はありません。新しい通知はバッジだけを一度440ms動かし、文字は動かしません。 +動きを減らす設定と高コントラストでは静止表示。音やフォーカス移動はありません。 + +同一タスクのモデル数と、複数モデルを使ったタスクの累積数を区別します。 +Fable 同一セッションは、Fable と別の認識済み Claude 系統が同じセッションで出力した証拠を必要とし、順番は意味しません。 + +「実績通知を表示」(`showAchievementNotices`) をオフにすると、バッジと未読記録は保持し、 +浮動通知のみ止めます。再びオンにしても過去の通知は再表示しません。 + +証明できる Claude/Codex の新規 Token 段階到達は、画面更新を一時停止しても未読と初回記録日を保存します。 +モデル系統別用量、出力、キャッシュ読み取り、Claude のキャッシュ作成、累計用量に対応。履歴の取り込みと手動再構築は通知しません。 +累計API参照額は独立した新規発生の証明に対応し、その他の用量範囲は引き続き個別の証明が必要です。 + +実績の記録はローカルのみで、全体を 64 KiB 以下に制限します。過去の獲得日は不明のままです。 +新しい保存済み操作の実績は、通知を見逃しても未読状態と初回記録日を保持します。 +保存済み実績は再起動後も保持され、ウィンドウ間で共有されます。 +旧ルールの実績は履歴を残しますが、新しい条件の証明にはなりません。削除してもプロバイダーの元ログは変更しません。 +通知は前面の表示中パネルのみで、複数ウィンドウや再起動を通して5分に1回まで。 +表示確認した分だけ既読にし、見逃した分は未読のまま保持します。ホバーやキーボード操作中は自動非表示を一時停止します。 +見逃したバッジは初期状態で折りたたむ新規獲得一覧にまとめ、1組に最大6つの正確な段階を表示します。 +明示的にこの組を既読にする操作がローカル保存に成功した場合のみ既読にします。閲覧・固定・共有では既読にせず、 +別のウィンドウで後から獲得したバッジも未読のままです。 +最近の獲得は新しく記録した最大3シリーズ、折りたたむ最近の記録は最大30段階と条件へのリンクを表示します。 +日付は初回記録日で、元の獲得日を推測しません。過去の日付は不明のままです。閲覧だけでは既読にせず、共有にも選びません。 +閉じたカードの固定アイコンと検索可能な選択画面で、最大6テーマを手動固定し、順序を指定できます。 +手動では選んだ項目だけ、自動では最大3件を表示します。固定と順序は再起動後も保持し、共有の選択には使いません。 +無効なプロバイダーの固定済み実績は、折りたたみ一覧から解除できます。プロバイダーの再有効化は不要です。 + +明示的な共有では、無効化したプロバイダーの獲得済みバッジも選べます。 +ギャラリーと新しい固定候補は有効なプロバイダーに従います。保存済みバッジは +現在の進捗を示したり、新たな実績の獲得を認めたりする根拠にはなりません。 + +取り込んだ過去のプラグイン利用記録は通知しません。新しい操作の保存に成功すれば、 +ダッシュボードを閉じていてもバッジを保存します。非表示のパネルは開いたり更新したりしません。 + +同じ階層に置かれ、パスから親セッションを確認できないエージェントログは、 +セッション実績の証拠に数えません。Token・価格・日別集計・表示セッションは変わらず、 +入れ子のエージェントの帰属ルールと保存済みバッジも維持します。 + +2.5.0 のギャラリーは50テーマ、178解除条件、52図柄グループを収録。モデル別トークン、 +出力・キャッシュ、活動日、週間記録、少数のプラグイン体験を記録します。 + +Claude・継続活動の15系列にプラチナとレジェンドを追加し、最初の3段階の条件と図柄を維持。週3日の集中利用でも累積活動日・出力・セッションで成長でき、Codexや支出増加は不要です。連続活動日の段階は増やしません。 + +うち24条件は Claude/Codex 別の6つのAPI相当額テーマで、価格判明分の累計、終了済みの月曜開始週、1セッション/1タスクを扱います。 +両方の週次系列は5段階です:ブロンズ $100、シルバー $500、ゴールド $2,500、プラチナ $10,000、 +レジェンド $25,000。各プロバイダーの後ろ2段階のみを追加し、旧 ID・しきい値、終了済み週の条件、 +固定参照価格による授与根拠は維持します。 +週は現在のデータタイムゾーンを使い、単位は Claude の1セッションまたは検証済み Codex タスクツリーです。 +固定USD参照価格の版を使用し、既知の正確なモデルと完全な料金成分(Claude のキャッシュTTLを含む)が必要です。 +未知モデルは未価格設定、検証済み部分は下限で、請求額・品質・収入ではありません。同一参照価格で検証した新規追記による累計金額だけが未読と初回記録日を保存します。履歴・終了週・単一セッション/タスクは無通知で日付不明。Tokenと金額の証明は独立し、同じ通知バッチにまとめ、保存失敗で日付を先に表示しません。 +既存Codex effortカードを開くと6段階のToken・出力・ルートタスク・共同進捗と固定USD参考小計/カバー率を表示します。ルート × effort 自身の正の出力のみを数え、approval-reviewerは除外。モデル帰属の曖昧さを比例配分せず、証拠欠落は不明。新規ルール・設定はなく、Claude effortの帰属は未対応です。 +モデル/チーム/effort/レインボーは出力・反復経験・規模の複合条件に変更。 +共有ヒートマップは Claude/Codex/両方と Token/API相当額を選べます。既定はTokenで、金額は明示選択し、実績の固定参照価格ではなく現在の日別推定を使います。 +Claude は推定、Codex は既知の正確なモデル価格のみ。未価格設定と部分価格の下限・カバー率を表示し、両社の金額は合算しません。 +Claudeのカバー率は選択範囲のモデル/成分価格をメモリ内で確認します。未知モデルの一般的Sonnet代替価格は既知扱いせず、欠落単価は未価格、明示ゼロは有効。保存済みコストは再計算せず、Token/金額非表示時は単価・金額を参照しません。 +金額を隠すと色・文字・MarkdownもTokenに戻し、旧プレビューを無効化。保存ダイアログ前後に確認し、承認済みSVGの同じバイトを出力します。ログ再走査・通信は追加しません。 +「週枠ペース」(`showWeeklyQuotaPace`) は既定オフで既存観測を再利用。経過25%、使用29%なら周期全体の予測は116%です。 +最初の6時間、観測から30分超、リセット後は予測しません。資格情報の無効化は画面更新停止中も即時撤回し、アカウント/ソース変更や値の減少は通常の7日間更新が検証されるまで予測を止めます。 +現行Codexログはアカウントを検証できず、予測は利用不可です。画面とステータスバーのツールチップは共通翻訳を使い、バー文字・独自書式は維持。新しい要求・タイマー・履歴はありません。 +アカウントの帰属が不明な場合、現在の周期の合計・未使用API相当額は、ローカル履歴に +基づく信頼度の低い推定のままで、口座残高ではありません。正確なアカウント別の分離は +今後の複数アカウント対応に持ち越します。この近似は週枠ペースの予測とは別です。 + +概要は幅を制限した横向きカードで、画像・名前・ランク・進捗をまとめます。固定ボタンはカード内、次の目標には細い進捗バーを配置。少数のバッジを画面全幅に引き伸ばさず、狭い画面では文字を縮めず折り返します。ゴールドのランク表示はライトテーマでも読みやすく、金色のアクセントを残します。 + +共有・設定・フィルター・サイズ・展示編集・情報には既存の翻訳ラベルと軽量なインラインSVGアイコンを使い、件数はコンパクトなチップで表示します。 +幅960 px超ではバッジ列を内容に合わせ、概要カード2枚分の幅と内部間隔を上限とします。次の目標列は残りの幅を使い、列間は16 pxです。960 px以下では間隔12 pxの縦並びになり、補助的な履歴は両方の後に配置します。 +サイズ操作は概要より前に配置。図鑑56/80/112 px、展示56/72/96 pxで、固定エラーは操作の近くに表示し、キーボードフォーカスを保持します。 +effort のUSD小計は獲得条件に含めません。空と未価格設定を区別し、部分金額は下限、不正/オーバーフロー金額は利用不可とします。 +[英語の詳細](README-en.md#heatmap-sharing-and-weekly-quota-pace-250)。 +各シリーズは別の図柄を使い、同シリーズの段階は色で区別。数値は整数のみ、データの制約は展開して確認できます。 +根拠が未確認または古い場合はゼロ進捗を表示せず、次の目標から除外します。獲得済みバッジは保持します。 +閉じたカードにも現在値と次の段階の進捗を表示します。長いトークン階段の中間目標は追加バッジ・通知・共有項目ではありません。 +小/中/大はバッジとカード枠を変え、文字サイズとクリック領域は維持します。次の目標は最大3テーマで、未検証の目標や未使用モデルは除外します。 +目標には現在有効な進捗が必要です。閲覧中は並び替えず、クリックで既存の条件を開きます。ログの読込や共有への自動追加はありません。 +現在の索引が完成した後は、競合しない Codex 系譜のトークン・エージェント実績を解放できます。競合する分岐と子孫は除外します。 + +現在のローカル履歴が検証済みで索引が完成していれば、信頼できる一部の日別記録から +活動・モデルファミリー・週別 Token の進捗を `≥` で表示できます。週の金額の下限には、 +全使用量の日付が確認できる検証済みの選択ファイルだけを使い、価格と日付の網羅性は別々に扱います。 +一部の日別記録は完全な履歴やゼロ使用量を証明せず、4週平均と保存済みピークは全体の確認待ちです。 +累計/タスクの範囲は従来どおりで、追加のソース読込、保存、ネットワーク呼び出しはありません。 + +シリーズと段階は別々に集計。ローカルフィルターは再読み込み後も維持され、 +カードに次の目標、展開部分に全段階の条件を表示します。 +既定で有効ですが、⚙ Settings の **Local achievements**(`enableAchievements`)で +新規実績と追加の時間証拠バックフィルを停止できます。通常の使用量計測は継続します。 +アップグレード時は無効化を選べる非モーダル通知を一度だけ表示し、以前の無効設定は維持。 +過去分の解放は無通知、新しい通知は音やフォーカス移動なしでパネル内に短く表示します。 +最大九個の選択済みバッジを確認したプレビューそのままの SVG として出力し、古い応答では出力を有効化しません。 +図鑑への戻りと無効化設定への入口を用意。GitHub 認証は不要。 +共有アカウントや複数ログインの記録は個人の生産性の証明ではありません。 +[保存と削除](LOCAL-DATA.md#local-achievements-250)。 + ## v2.4 の新機能
diff --git a/README-ko.md b/README-ko.md index 30825307..6feac4ff 100644 --- a/README-ko.md +++ b/README-ko.md @@ -58,6 +58,158 @@ - **멀티 벤더 가격** — Opus 4.x / Sonnet 4.x / Haiku 4.5는 Anthropic 공식 가격으로 검증. OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen 참고 가격(패밀리 인식 폴백 포함). `Refresh Token Pricing`으로 LiteLLM 최신 가격 가져오기. - **개인화** — 언어, 시간대, 소수 자릿수, 간략 숫자, 프로젝트 그룹화, 대시보드 자동 새로고침 토글. +버전 2.5.0은 로컬 업적과 Codex `sessions`, `archived_sessions`의 `.jsonl.zst` 지원을 통합합니다. Zstd는 LLM 요약이 아닌 무손실 압축으로, 파일이 작아져도 기록된 토큰 수는 바뀌지 않습니다. 로컬에서 읽으며 원본 로그를 수정하거나 압축을 푼 평문 사본을 저장하지 않고, 같은 폴더의 같은 이름 `.jsonl`을 우선합니다. 네이티브 Zstd(Node 22.15+ / 23.8+)를 사용하므로 새 의존성이 없습니다. 지원하지 않는 호스트는 제한을 알리고 마지막으로 검증된 합계를 유지합니다. + +## 로컬 업적 (2.5.0) + +세션 날짜는 정규 인덱스와 동일한 전체 승인 범위로 계산하며 로그가 바뀌면 재검증할 수 있습니다. 재검증을 취소해도 검증된 실제 신규 사용량의 최초 기록은 유지됩니다. 일부만 저장된 결과는 별도로 알립니다. + +모두 펼치기/접기는 카드의 펼침 상태만 기존 로컬 UI 상태에 저장하며 데이터 원본이나 +수여를 바꾸지 않습니다. 등급은 브론즈/실버/골드이며 기존 ID, 고정 항목, 참조 가격표는 +유지됩니다. 완전히 같은 조건과 단위는 한 번만 표시합니다. 공통 금액 참조 영역에 활성 공급자별 +현재 가격표·날짜와 청구액이 아니라는 설명을 모읍니다. 다른 과거 가격표는 해당 수여 항목과의 정확한 +연결을 유지하며, 카드에는 범위와 가격 적용률을 남기고 가격·수여 근거의 반복 설명을 제거합니다. +최고 등급 카드도 현재 통계값을 잘라 표시하지 않으며 오래되거나 +없는 증거를 목표값으로 대신하지 않습니다. + +펼친 등급 행은 등급별 테마 색으로 이름을 표시하고(고대비에서는 전경색 사용), 같은 머리글 행에 +기준값을 굵게 오른쪽 정렬합니다. 증거가 준비된 경우에만 최고 획득 등급 바로 다음에 “진행 중”을 +표시하며, 이후 등급이나 모든 등급을 획득한 카드, 증거 대기 상태에는 표시하지 않습니다. +보존 로그로 검증된 날짜는 ISO 날짜만 표시하고, 의미는 “데이터 근거”에서 한 번 설명하며 +출처는 title/ARIA에 유지합니다. 두 날짜가 모두 있으면 검증된 날짜만 보이고 최초 기록 정보는 +title/ARIA에 남습니다. 최초 기록일만 있으면 명시적 라벨을 유지하며, 알 수 없는 날짜는 그대로 +둡니다. 이 표시 변경은 수여, 규칙, 날짜 계산 또는 저장된 기록을 바꾸지 않습니다. + +**독립 주 작업**은 루트 귀속을 검증할 수 있고 식별된 모델의 양수 사용량(입력 또는 출력)이 있는 +Codex 작업을 셉니다. 주 작업과 하위 작업을 합쳐 한 번만 세며, 완료한 작업 수가 아닙니다. +기존 규칙 ID와 기준값은 유지됩니다. + +탐색 메뉴와 페이지 이름은 **공유 및 비교**입니다. 배지 선택 항목은 같은 너비로 정렬하고 등급을 +별도 줄에 표시합니다. 체크박스 아이콘 수정은 기본 Webview CSP와 같은 로컬 리소스 +출처를 사용합니다. 내보낸 SVG에는 고정 공개 [저장소 링크](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage)를 +넣고 긴 라벨은 줄바꿈과 간격으로 겹침을 방지합니다. 미리보기·내보내기는 자동 네트워크 요청이나 +개인 메타데이터를 추가하지 않고 명시적 선택과 승인된 SVG의 불변성을 유지합니다. 공유 페이지 전체의 +시각적 통일·미화와 영구 사용량 보관은 다음 버전으로 미뤄졌으며 2.5.0에는 구현되지 않았습니다. + +등급별 로그 날짜는 메모리에서만 계산하며 보존된 로그로 확인할 수 있는 가장 이른 날짜입니다. +계정의 전체 기록을 뜻하지 않고 저장된 최초 기록일과 읽지 않음 상태를 바꾸지 않습니다. +사용량, 출력, 캐시, 모델/계열, 활동, 주간 날짜는 기존 일별 집계를 사용합니다. +수동 재검증에서만 이미 보유한 Claude 메모리 기록을 추가 집계하여 출력 세션, Fable 세션, +같은 세션의 Fable/다른 계열 날짜를 확인합니다. 일반 웜 갱신은 이 기록을 다시 집계하지 +않습니다. 지원하지 않는 조건은 날짜 미상 또는 기존 최초 기록으로 유지됩니다. + +재검증은 로컬 증분 작업을 한 번에 하나씩 실행하며 전체 스캔 강제, 배지 삭제, +OAuth·가격 조회·BYOK 및 기타 네트워크 요청을 하지 않습니다. 수여를 강제하지 않으며 +사용 중, 현재 전체 색인의 미승인, 실패는 성공이 아닙니다. 검증된 부분 이력은 하한 또는 미상으로 +유지하며 완료가 모든 수여 범위의 완전한 커버리지를 뜻하지 않습니다. 이력 커밋 전 취소는 과거 수여를 추가하지 않으며, +커밋 후 취소는 저장된 기록이나 성공 결과를 되돌리지 않습니다. 과거 기록 조정은 알림 없이 진행됩니다. +[날짜 및 저장 범위](LOCAL-DATA.md#local-achievements-250). + +협업/무지개 모델 인식은 가격 계산과 별개여서 인식돼도 참조 가격이 없는 모델은 가격 산정에서 제외되며, 저장된 금액 기록은 공개된 가격표 라벨을 보존하고 Claude만 포함된 최고치는 무관한 Codex 버전 변경으로 사라지지 않습니다. +기록 불러오는 중, 사용 불가, 변경됨, 꺼짐 상태를 빈 개수 대신 명시하며, 활성화된 Codex 근거가 검증되지 않은 동안 검증된 Claude 누적 사용량을 `≥` 하한으로 표시합니다. +다음 목표에는 목표 이름과 등급을 표시하고 획득한 카드는 획득 등급을 유지하며 SVG 내보내기는 등급을 중복 표시하지 않습니다. + +배지에서 같은 공급자의 기존 현재 모델/토큰 통계를 보고 펼침 상태와 초점을 유지한 채 돌아올 수 있습니다. +로그를 다시 검색하거나 현재 합계를 공유/과거 기간의 획득 증거로 대체하지 않습니다. + +기존 고정을 해제해도 키보드 초점은 해당 테마에 유지되며 데이터를 새로고침하지 않습니다. +탐색 상세에는 도감, 공유 미리 보기, 성공한 로컬 내보내기의 저장된 기록을 표시합니다. +기존 업적에서 누락된 단계를 추정하지 않으며 피드백과 설정 변경은 별개입니다. +탐색 아이콘에는 고정 숫자가 없습니다. 새 알림은 배지만 440ms 동안 한 번 움직이며 텍스트는 고정됩니다. +동작 줄이기와 고대비 모드는 정적 표시이며 소리나 초점 이동이 없습니다. + +한 작업의 모델 수와 여러 모델을 사용한 작업의 누적 수를 구분합니다. +Fable 같은 세션은 Fable과 다른 인식된 Claude 계열이 같은 세션에서 모두 출력한 근거를 요구하며, 실행 순서를 뜻하지 않습니다. + +「업적 알림 표시」(`showAchievementNotices`)를 끄면 배지와 읽지 않은 기록을 유지하고 +떠 있는 알림만 멈춥니다. 다시 켜도 지난 알림은 재생하지 않습니다. + +검증된 Claude/Codex의 새 Token 등급은 화면 갱신을 멈춰도 읽지 않음과 최초 기록일을 저장합니다. +모델 계열 사용량, 출력, 캐시 읽기, Claude 캐시 생성, 누적 사용량을 지원하며 과거 기록 가져오기와 수동 재구축은 조용히 처리합니다. +누적 API 참조 금액은 독립적인 발생 근거에 연결됐으며 다른 사용량 범위는 개별 근거가 필요합니다. + +업적 기록은 로컬에만 저장하며 전체 크기는 64 KiB 이하입니다. 과거 획득일은 미상으로 유지합니다. +새로 저장된 작업의 업적은 알림을 놓쳐도 읽지 않음 상태와 최초 기록일을 보존합니다. +저장된 업적은 재시작 후에도 유지되며 창 사이에서 공유됩니다. +이전 규칙의 업적은 기록을 유지하지만 새 조건을 증명하지는 않습니다. 삭제해도 공급자 원본 로그는 바꾸지 않습니다. +알림은 전경의 보이는 패널에서만 표시하며, 창과 재시작 간에도 5분에 한 번으로 제한합니다. +표시 확인된 묶음만 읽음 처리하고 놓친 기록은 보존합니다. 마우스를 올리거나 키보드로 조작하는 동안 자동 닫힘을 멈춥니다. +놓친 배지는 기본적으로 접힌 새 획득 요약에 모으며 한 묶음에 정확한 등급을 최대 6개 표시합니다. +이 묶음을 읽음으로 표시하는 명시적 작업이 로컬 저장에 성공해야 읽음 처리합니다. 보기·고정·공유로는 처리하지 않으며, +다른 창에서 나중에 획득한 배지는 읽지 않음 상태를 유지합니다. +최근 획득은 새로 기록한 최대 3개 시리즈를, 접을 수 있는 최근 기록은 최대 30개 등급과 조건 링크를 표시합니다. +날짜는 최초 기록일이며 원래 달성일을 추정하지 않습니다. 과거 날짜는 미상으로 유지합니다. 보기만으로 읽음 처리하거나 공유를 선택하지 않습니다. +접힌 카드의 고정 아이콘과 검색 가능한 선택 화면에서 최대 6개 테마를 수동 고정하고 순서를 지정할 수 있습니다. +수동 모드는 선택한 항목만, 자동 추천은 최대 3개를 표시합니다. 고정과 순서는 재시작 후에도 유지하며 공유 선택과는 별개입니다. +비활성화된 공급자의 고정 배지는 접이식 목록에서 해제할 수 있으며 공급자를 다시 켤 필요가 없습니다. + +직접 공유할 때는 비활성화한 공급자의 보존된 획득 배지도 선택할 수 있습니다. +도감과 새 고정 선택지는 활성 공급자를 따릅니다. 보존된 배지는 현재 진행률의 +근거가 아니며 새로운 업적을 부여할 근거도 아닙니다. + +가져온 과거 플러그인 사용 기록은 알리지 않습니다. 새 작업을 성공적으로 저장하면 +대시보드가 닫혀 있어도 배지를 저장하며, 숨겨진 패널을 열거나 새로고침하지 않습니다. + +같은 디렉터리에 놓여 경로로 부모 세션을 확인할 수 없는 에이전트 로그는 세션 업적의 +근거에 포함하지 않습니다. Token·가격·일별 합계·표시 세션 행은 그대로이며, +중첩 에이전트의 기존 귀속 규칙과 저장된 배지도 유지합니다. + +2.5.0 도감에는 50개 테마, 178개 해제 조건, 52개 그림 그룹이 있습니다. 모델별 토큰, +출력/캐시, 활동일, 주간 기록 및 일부 플러그인 사용 경험을 기록합니다. + +Claude 및 지속 활동 15개 시리즈에 플래티넘과 전설을 추가하며 기존 세 단계 조건과 그림은 유지합니다. 주 3일 집중 사용도 누적 활동일, 출력, 세션으로 성장할 수 있고 Codex나 지출 증가는 필요하지 않습니다. 연속 활동일 등급은 늘리지 않습니다. + +이 중 금액 조건 24개는 Claude/Codex 각각의 가격 확인분 누적, 종료된 월요일 시작 주, 단일 사용의 6개 테마입니다. +두 주간 금액 시리즈는 브론즈 $100, 실버 $500, 골드 $2,500, 플래티넘 $10,000, 레전드 $25,000의 +5단계입니다. 공급자별 마지막 두 단계만 추가하며 기존 ID·기준값, 종료된 주 조건과 고정 참조 가격의 +수여 근거는 유지합니다. +주는 현재 데이터 시간대를 따르며 단일 사용은 Claude 세션 하나 또는 검증된 Codex 작업 트리 하나입니다. +버전이 고정된 USD 참조 가격은 정확히 알려진 모델과 완전한 구성요소 단가(Claude 캐시 TTL 포함)를 요구합니다. +알 수 없는 모델은 가격 미확정이며 검증된 부분은 하한만 증명하고 청구액·품질·소득이 아닙니다. 같은 참조 가격으로 검증한 새 추가 기록의 누적 금액만 읽지 않음과 최초 기록일을 저장합니다. 이력·완료 주·단일 세션/작업은 조용히 처리하며 날짜는 미상입니다. Token과 금액 근거는 독립적이며 같은 알림 묶음을 쓰고 저장 실패 시 날짜를 먼저 표시하지 않습니다. +기존 Codex effort 카드를 펼치면 여섯 단계의 Token·출력·루트 작업·공동 진행률과 정보용 고정 USD 소계/커버리지를 표시합니다. 루트 × effort 자체의 양수 출력만 인정하고 approval-reviewer는 제외합니다. 모델 귀속이 모호하면 비례 배분하지 않고 근거가 없으면 미상입니다. 새 규칙·설정은 없고 Claude effort 귀속은 지원하지 않습니다. +모델/팀/effort/무지개는 출력·반복 경험·규모의 복합 조건을 적용합니다. +공유 히트맵에서 Claude/Codex/둘 다와 Token/API 등가 금액을 선택합니다. 기본은 Token이며 금액은 직접 선택하고 업적 참조 가격이 아닌 현재 일별 추정치를 씁니다. +Claude는 추정, Codex는 정확히 알려진 모델 가격만 사용합니다. 가격 미확정과 부분 합계의 하한·커버리지를 표시하며 두 공급자 금액은 합산하지 않습니다. +Claude 커버리지는 선택 범위의 모델/구성요소 단가만 메모리에서 확인합니다. 미지 모델의 일반 Sonnet 대체 단가는 알려진 가격으로 인정하지 않습니다. 누락 단가는 미확정, 명시적 0은 유효하며 기존 비용은 재계산하지 않습니다. Token/금액 숨김은 단가·금액을 조회하지 않습니다. +금액을 숨기면 색·텍스트·Markdown도 Token으로 복원하고 기존 미리 보기를 폐기합니다. 저장 대화상자 전후를 확인해 승인된 SVG 바이트 그대로 내보냅니다. 로그 재검색이나 네트워크 요청은 추가하지 않습니다. +「주간 한도 사용 속도」(`showWeeklyQuotaPace`)는 기본 꺼짐이며 기존 관측을 재사용합니다. 주기 25% 경과에 29% 사용이면 전체 주기 예상은 116%입니다. +첫 6시간, 30분 넘게 새 관측이 없을 때, 재설정 후에는 예상치를 표시하지 않습니다. 자격 증명 무효화는 화면 갱신을 멈춰도 즉시 철회하며 계정/소스 변경과 카운터 감소는 검증된 정상 7일 주기 전환까지 예상을 중단합니다. +현재 Codex 로그는 계정을 검증하지 못하므로 예상은 이용 불가입니다. 화면과 상태 표시줄 툴팁은 번역을 공유하고 표시줄 문자·사용자 형식은 유지합니다. 새 요청·타이머·이력은 없습니다. +계정 귀속이 불명확하면 현재 주기의 총 API 등가치와 미사용 등가치는 신뢰도가 낮은 +로컬 기록 추정치이며 계정 잔액이 아닙니다. 정확한 계정별 분리는 향후 다중 계정 지원으로 +미뤄집니다. 이 근사치는 주간 한도 사용 속도 예측과 별개입니다. + +개요는 너비가 제한된 가로 카드에 그림, 이름, 등급, 진행률을 모아 표시합니다. 고정 버튼은 카드 안에, 다음 목표에는 가는 진행 막대를 배치합니다. 소수의 배지를 화면 전체에 펼치지 않으며 좁은 화면에서는 글자 크기를 줄이지 않고 줄바꿈합니다. 밝은 테마에서도 금색 등급 표시는 읽기 쉬우며 금색 강조는 유지됩니다. + +공유·설정·필터·크기·전시 편집·정보는 기존 번역 레이블에 가벼운 인라인 SVG 아이콘을 더하고, 개수는 작은 칩으로 표시합니다. +너비 960 px 초과에서는 배지 열을 내용에 맞추되 개요 카드 두 장의 너비와 내부 간격을 상한으로 합니다. 다음 목표 열은 나머지 공간을 채우며 열 사이 간격은 16 px입니다. 960 px 이하에서는 12 px 간격으로 세로 배치하고 보조 이력은 두 영역 뒤에 둡니다. +크기 선택은 개요 앞에 배치하며 도감 56/80/112 px, 전시 56/72/96 px를 사용합니다. 고정 오류는 관련 컨트롤 옆에 표시하고 키보드 초점을 유지합니다. +effort USD 소계는 획득 조건에 포함되지 않습니다. 빈 값과 미가격 값을 구분하고 부분 금액은 하한으로 표시하며 잘못된 값/오버플로 금액은 표시하지 않습니다. +[영문 상세](README-en.md#heatmap-sharing-and-weekly-quota-pace-250). +시리즈마다 별도 그림을 쓰고 같은 시리즈의 등급은 색으로 구분합니다. 숫자는 정수만 표시하며 데이터 제약은 펼쳐 확인합니다. +근거가 미확인되거나 오래되면 0 진행률 대신 상태를 표시하고 다음 목표에서 제외합니다. 획득한 배지는 유지합니다. +접힌 카드에도 현재 값과 다음 등급 진행률을 표시합니다. 긴 토큰 단계의 중간 목표는 추가 배지·알림·공유 항목이 아닙니다. +작게/중간/크게는 배지와 카드 틀을 바꾸고 글자 크기와 클릭 영역을 유지합니다. 다음 목표는 최대 3개 테마이며 미검증 목표와 미사용 모델은 제외합니다. +목표에는 현재 유효한 진행 근거가 필요합니다. 탐색 중 순서는 유지되고 클릭하면 기존 조건을 엽니다. 로그를 읽거나 공유에 자동 추가하지 않습니다. +현재 인덱스가 완성되면 충돌 없는 Codex 계보의 토큰·에이전트 업적을 해제할 수 있습니다. 충돌하는 분기와 후손은 제외합니다. + +현재 로컬 기록이 검증되고 인덱스가 완성되면 신뢰할 수 있는 일부 날짜 기록으로 활동·모델 계열· +주간 Token 진행률을 `≥`로 표시할 수 있습니다. 주간 금액 하한은 모든 사용량의 날짜가 확인된 +검증된 선택 파일만 사용하며, 가격 적용 범위와 날짜 범위는 별개입니다. 일부 날짜는 전체 기록이나 +사용량 0을 증명하지 않으며, 4주 평균과 저장된 최고값은 전체 범위가 확인될 때까지 대기합니다. +누적·작업 범위는 그대로이며 추가 원본 읽기, 저장 또는 네트워크 호출은 없습니다. + +시리즈와 등급은 별도로 집계합니다. 로컬 필터는 새로고침 후에도 유지되며, +카드에 다음 목표, 펼친 영역에 모든 등급 조건을 표시합니다. +기본 활성화되며 ⚙ Settings의 **Local achievements** (`enableAchievements`)에서 +새 업적과 추가 시간 증거 보충을 끌 수 있습니다. 일반 사용량 추적은 계속됩니다. +업그레이드 때 끄기 옵션을 포함한 비모달 안내가 한 번 표시되고, 기존 끄기 선택은 유지됩니다. +과거 기록의 해제는 조용히 처리하며 새 알림은 소리나 포커스 변경 없이 패널에 잠깐 표시합니다. +최대 9개 배지를 직접 선택하고 확인한 미리보기 그대로 SVG로 내보냅니다. 오래된 응답은 내보내기를 활성화하지 않습니다. +도감으로 돌아가기와 끄기 설정 바로가기를 제공합니다. GitHub 로그인은 없습니다. +공유 계정·여러 로그인 기록은 개인 생산성을 증명하지 않습니다. +[저장 및 삭제](LOCAL-DATA.md#local-achievements-250). + ## v2.4 새로운 기능
diff --git a/README-pt-BR.md b/README-pt-BR.md index 56f0db46..56a808ad 100644 --- a/README-pt-BR.md +++ b/README-pt-BR.md @@ -53,6 +53,179 @@ dos seus dados. **Não é uma ferramenta de cobrança.** A imagem usa dados sintéticos, não informações de uma conta real. Veja mais imagens e detalhes na [página principal completa em inglês](README.md). +A versão 2.5.0 reúne conquistas locais e suporte a `.jsonl.zst` do Codex em `sessions` e `archived_sessions`. Zstd é compressão sem perdas, não um resumo por LLM: arquivos menores preservam a contagem de tokens registrada. A leitura é local, sem alterar os logs nem salvar cópias descompactadas em texto; o `.jsonl` de mesmo nome na mesma pasta tem prioridade. Zstd nativo (Node 22.15+ / 23.8+) não adiciona dependências; hosts incompatíveis informam a limitação e mantêm os últimos totais verificados. + +## Conquistas locais (2.5.0) + +As datas das sessões usam a mesma admissão completa do índice canônico; logs alterados exigem nova calibração. O uso novo verificado mantém seu primeiro registro mesmo após cancelamento. Salvamentos parciais são informados separadamente. + +Expandir tudo / Recolher tudo salva apenas a abertura dos cartões no estado local +de interface existente, sem alterar fontes ou concessões. Os níveis são Bronze / +Prata / Ouro; IDs, itens fixados e tabelas de referência permanecem iguais. +Condições e unidades exatamente iguais aparecem uma vez. Uma seção comum de referência +monetária mostra a tabela atual e sua data por provedor ativo, além do aviso de que não +se trata de uma fatura. Tabelas históricas diferentes mantêm a associação exata com cada +conquista; os cartões preservam escopo e cobertura de preços sem repetir notas de preço/base. +Cartões completos mostram o +valor atual sem corte; evidências antigas ou ausentes não são substituídas pela meta. + +Nas linhas expandidas, o nome usa cores do tema por nível (cor de primeiro plano em +alto contraste), com o limite em negrito e alinhado à direita no mesmo cabeçalho. +Só o nível seguinte ao maior conquistado mostra “Em andamento” quando a comprovação +está pronta; níveis posteriores, cartões completos e comprovação pendente não mostram +esse rótulo. Datas verificadas nos logs preservados aparecem apenas em ISO; o significado +é explicado uma vez em “Base dos dados” e a origem permanece em title/ARIA. Quando ambas +as datas existem, só a verificada fica visível; o primeiro registro permanece em title/ARIA. +Se houver apenas o primeiro registro, seu rótulo explícito permanece; datas desconhecidas +continuam desconhecidas. A mudança visual não altera concessões, regras, cálculos de datas +nem registros salvos. + +**Tarefas raiz independentes** conta raízes Codex com vínculo verificável e uso positivo +de um modelo reconhecido (entrada ou saída). A raiz e suas subtarefas contam uma única +vez; não é uma contagem de tarefas concluídas. IDs e limites das regras existentes permanecem iguais. + +A navegação e o título da página passam a ser **Compartilhar e comparar**. As opções de +emblemas têm largura igual e o nível em linha separada. A correção dos ícones +de seleção usa a mesma origem local permitida pela CSP do Webview principal. O SVG +exportado inclui o [link público fixo do repositório](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage) +e quebra rótulos longos para evitar sobreposição. Prévia/exportação não adiciona pedidos +automáticos de rede nem metadados pessoais e preserva a seleção explícita e o SVG aceito +imutável. A unificação visual/embelezamento amplo da página e um arquivo durável de uso +ficam para a próxima versão; nenhum dos dois está implementado na versão 2.5.0. + +As datas por nível são derivadas apenas na memória e indicam a data mais antiga +verificável nos logs preservados, não o histórico completo da conta. Não substituem +o primeiro registro salvo nem o estado não lido. Agregados diários fornecem datas +suportadas de uso, saída, cache, modelo/família, atividade e semanas. Só a recalibração +manual também agrega registros Claude já mantidos na memória para sessões com saída, +sessões Fable e Fable/outra família na mesma sessão; atualizações normais com dados +já carregados não reagrupam esses registros. Condições sem suporte mantêm a data +desconhecida ou o primeiro registro salvo. + +A recalibração é local, incremental e executa uma operação por vez, sem forçar uma +varredura completa, apagar emblemas ou fazer chamadas OAuth, de preços, BYOK ou de +outra rede. Não força concessões; ocupado, inventário atual do índice não aceito ou +falha não significa sucesso. Histórico parcial verificado mantém limites inferiores +ou valores desconhecidos; concluir não implica cobertura completa de todos os escopos. +Cancelar antes do commit histórico não adiciona concessões históricas; após o commit, não desfaz os +registros salvos nem altera o resultado bem-sucedido. A reconciliação histórica é +silenciosa. +[Limites de datas e armazenamento](LOCAL-DATA.md#local-achievements-250). + +O reconhecimento de modelos para colaboração/arco-íris é separado dos preços, mantendo sem preço os modelos reconhecidos sem tarifa; registros monetários preservam os rótulos das tabelas publicadas, e picos apenas do Claude resistem a mudanças independentes de versão do Codex. +Registros carregando, indisponíveis, alterados e desativados têm estados explícitos em vez de contagens vazias; o uso cumulativo verificado do Claude mostra `≥` enquanto as evidências do Codex ativado não forem verificadas. +Os próximos objetivos mostram nome e nível de destino, os cartões conquistados mantêm o nível obtido e as exportações SVG mostram cada nível uma única vez. + +Os detalhes abrem estatísticas atuais existentes de modelos/tokens do mesmo provedor, com retorno e foco preservado. +Não releem logs nem substituem evidências compartilhadas ou de períodos anteriores pelos totais atuais. + +Ao desafixar um emblema antigo, o foco do teclado permanece no tema, sem atualizar os dados. +Os detalhes da exploração mostram coleção, prévia e exportação local bem-sucedida registradas. +Emblemas antigos não inventam etapas ausentes; feedback e configurações continuam separados. +O ícone não inclui números fixos. Novos avisos animam só o emblema uma vez por 440 ms; +texto, movimento reduzido e alto contraste ficam estáticos, sem som ou mudança de foco. + +Modelos por tarefa e o total de tarefas multimodelo são experiências distintas. +Fable na mesma sessão exige saída de Fable e outra família Claude reconhecida +na mesma sessão, sem pressupor ordem de execução. + +Desative **Mostrar avisos de conquistas** (`showAchievementNotices`) para manter +os prêmios e registros não lidos sem avisos flutuantes; reativar não repete avisos antigos. + +Novos níveis de Token comprovados de Claude/Codex guardam estado não lido e dia +do primeiro registro mesmo com o painel pausado. São cobertos volume por família, +saída, leituras de cache, criação de cache do Claude e volume acumulado. Importações +e reconstruções manuais ficam silenciosas; valores cumulativos de referência API têm prova própria, e outros escopos ainda aguardam sua origem. + +O registro local é limitado a 64 KiB. Datas antigas continuam desconhecidas; +novas ações salvas mantêm o estado não lido e o dia do primeiro registro mesmo +sem aviso. Os prêmios salvos persistem após reiniciar e são compartilhados entre janelas. +Prêmios de regras antigas mantêm seu histórico, mas não comprovam condições novas. A limpeza não altera os logs dos provedores. +Avisos aparecem apenas no painel visível em primeiro plano, no máximo a cada cinco +minutos entre janelas e reinícios. Só o lote com exibição confirmada é marcado como lido; +avisos perdidos continuam não lidos. Mouse ou foco do teclado pausam o fechamento. +Avisos perdidos ficam num resumo inicialmente recolhido, com até seis níveis exatos +por grupo. Apenas a confirmação explícita, após salvar localmente com sucesso, +marca esse grupo como lido; consultar, fixar e compartilhar não. Prêmios posteriores +de outras janelas continuam não lidos. +Conquistas recentes mostra até três séries novas; os registros recolhíveis mostram +até trinta níveis com links para as regras. Datas indicam o primeiro registro, não a +conquista original; datas históricas continuam desconhecidas. Consultar não marca como lido nem seleciona para compartilhar. +Ícones em cartões fechados e um seletor com busca permitem fixar até seis temas e definir sua ordem. +O modo manual mostra só os escolhidos; o automático recomenda até três. +Escolha e ordem persistem após reiniciar, sem selecionar medalhas para compartilhar. +Medalhas fixadas ocultas podem ser desafixadas na lista recolhível sem reativar o provedor. + +Ao compartilhar explicitamente, é possível escolher emblemas conquistados e preservados de um provedor desativado. +A galeria e as novas fixações seguem os provedores ativados; emblemas preservados não comprovam +progresso atual nem autorizam novas conquistas. + +Registros importados de uso do plugin não geram avisos. Novas ações salvas com sucesso +podem desbloquear emblemas com o painel fechado, sem abrir nem atualizar painéis ocultos. + +Logs de agentes no mesmo diretório, sem sessão-pai comprovada pelo caminho, não +contam como evidência para conquistas de sessão. Tokens, preços, totais diários e +linhas de sessão visíveis não mudam; a atribuição aninhada e os emblemas salvos são preservados. + +A galeria da versão 2.5.0 tem 50 temas, 178 condições de desbloqueio e 52 grupos de arte: tokens por modelo, saída/cache, +atividade, recordes semanais e algumas ações deliberadas no plugin. + +Quinze séries de Claude/atividade recebem Platina e Lendário, preservando as três metas iniciais, evidências e arte. Dias ativos, saída e sessões permitem progresso com três dias concentrados por semana, sem Codex ou maior gasto. As séries de dias consecutivos não são ampliadas. + +São 24 condições monetárias em seis temas separados de Claude/Codex: subtotal acumulado com preço conhecido, semanas encerradas que começam na segunda-feira e uso individual. +As duas séries semanais têm cinco níveis: Bronze US$ 100, Prata US$ 500, Ouro US$ 2.500, +Platina US$ 10.000 e Lendário US$ 25.000. Só os dois últimos por provedor são novos; +IDs/limites antigos, exigência de semana encerrada e base de preços de referência fixos permanecem iguais. +As semanas usam o fuso atual dos dados; uso individual é uma sessão Claude ou uma árvore de tarefa Codex verificada. +Preços de referência fixos em USD, com versão, exigem modelos exatos conhecidos e todas as tarifas dos componentes, incluindo TTL do cache Claude. +Modelos desconhecidos ficam sem preço; parcelas verificadas comprovam um limite inferior, não uma fatura, qualidade ou renda. Só marcos monetários cumulativos de novos acréscimos verificados, com a mesma referência, salvam estado não lido e primeiro dia registrado. Histórico, semanas e sessões/tarefas individuais continuam silenciosos com datas desconhecidas; provas de Token/dinheiro são independentes e compartilham um lote de aviso, sem datas otimistas se a gravação falhar. +O cartão Codex effort existente mostra, quando expandido, seis níveis com Tokens, saída, tarefas raiz, progresso conjunto e subtotal informativo USD fixo/cobertura. Cada raiz × effort precisa de saída própria positiva, sem approval-reviewer; atribuição ambígua de modelo não é dividida por proporção. Prova ausente fica desconhecida; sem novas regras/preferências ou atribuição Claude effort. +Modelo/equipe/effort/arco-íris combinam saída, repetição e volume. +O mapa oferece Claude / Codex / Ambos e Tokens por padrão; valores equivalentes à API exigem escolha explícita e usam estimativas diárias atuais, não preços das conquistas. +Claude é estimado; Codex usa apenas preços de modelos exatos conhecidos. Preços ausentes e limites inferiores têm indicação de cobertura; valores dos provedores nunca são somados. +A cobertura Claude consulta apenas taxas de modelos/componentes selecionados em memória; fallback Sonnet genérico de modelo desconhecido não conta. Taxa ausente fica sem preço, zero explícito vale; custos salvos não são recalculados. Token/valores ocultos não consultam taxas ou dinheiro. +Ocultar valores restaura cores, textos e Markdown de Tokens e invalida a prévia antiga. A exportação salva os mesmos bytes SVG, com verificações antes e depois do diálogo, sem nova leitura de logs ou rede. +**Ritmo da cota semanal** (`showWeeklyQuotaPace`) vem desligado e reutiliza observações: 29% usado com 25% do ciclo decorrido projeta 116%. +Não há previsão nas primeiras seis horas, após trinta minutos sem nova observação ou após o reset. Credenciais invalidadas retiram a previsão mesmo com o painel pausado; mudanças de conta/origem e quedas do contador exigem uma virada normal de sete dias verificada. +Logs Codex não verificam a conta, portanto a previsão está indisponível. Painel e tooltip compartilham traduções; texto da barra e formatos próprios ficam iguais. Sem novas requisições, timers ou histórico. +Quando a atribuição de conta é incerta, os valores total e não utilizado equivalentes +à API do ciclo atual continuam sendo estimativas locais de baixa confiança, não saldos +de conta. A separação precisa por conta fica para um futuro suporte a múltiplas contas; +essa aproximação é distinta da previsão de ritmo da cota semanal. + +O resumo usa cartões horizontais com largura limitada: imagem, nome, nível e progresso ficam juntos. Os botões de fixação ficam no cartão e os próximos objetivos têm uma barra fina. Poucos emblemas não se espalham por toda a tela; em telas estreitas, os cartões quebram linha sem reduzir o texto. Rótulos do nível ouro permanecem legíveis em temas claros, mantendo um detalhe dourado. + +Ícones SVG leves e embutidos acompanham os rótulos traduzidos existentes de compartilhamento, configurações, filtro, tamanho, editor da vitrine e informações; as contagens aparecem em chips compactos. +Acima de 960 px, a coluna de emblemas se ajusta ao conteúdo, limitada à largura de dois cartões do resumo mais o espaço interno. A coluna de próximos objetivos ocupa o espaço restante, com 16 px entre colunas. Até 960 px, ambas ficam diretamente empilhadas com 12 px de espaço. As seções auxiliares de histórico vêm depois de ambas. +O seletor de tamanho precede o resumo: ícones da coleção 56 / 80 / 112 px, vitrine 56 / 72 / 96 px, erros de fixação junto aos controles e foco estável. +O subtotal USD de effort não qualifica a conquista. Valores vazios e sem preço são distintos; valores parciais mostram limite inferior, e valores inválidos ou com overflow ficam indisponíveis. +[Detalhes em inglês](README-en.md#heatmap-sharing-and-weekly-quota-pace-250). +Cada série tem arte própria, compartilhada entre seus níveis coloridos. Os números são inteiros; limites dos dados ficam na seção expansível. +Evidências pendentes ou desatualizadas substituem o progresso zero e ficam fora do próximo alvo; emblemas conquistados são preservados. +Cartões fechados mostram o progresso até o próximo nível; etapas intermediárias nas longas escalas de tokens não geram prêmios, avisos ou opções de compartilhamento extras. +Pequeno / Médio / Grande alteram medalhas e molduras, preservando fonte e áreas de clique. Próximas metas destaca até três temas e exclui metas sem comprovação e modelos não utilizados. +Metas exigem progresso positivo atual; a ordem fica estável durante a visita. Clicar abre as regras sem ler logs nem selecionar para compartilhar. +Após indexação atual completa, linhagens Codex sem conflito podem comprovar marcos de tokens/agentes; ramos conflitantes e seus descendentes são excluídos. + +Com o histórico local atual verificado e totalmente indexado, datas parciais confiáveis podem mostrar +`≥` no progresso de atividade, famílias de modelos e Tokens semanais. Limites inferiores de valores +semanais usam apenas arquivos selecionados verificados com todo o uso datado; cobertura de preços e +de datas são independentes. Datas parciais não comprovam histórico completo nem uso zero, e médias de +quatro semanas/picos retidos ficam pendentes até a cobertura completa. Escopos acumulados/de tarefa +não mudam, sem leituras adicionais das fontes, armazenamento ou chamadas de rede. + +Séries e níveis têm contagens separadas. Filtros locais persistem após recarregar; +o próximo alvo aparece no cartão e as regras de todos os níveis ficam na seção expansível. +Ativas por padrão; desative **Local achievements** em ⚙ Settings +(`enableAchievements`) para interromper novos prêmios e o preenchimento adicional +de horas. A atualização mostra um aviso não modal uma única vez, com opção de +desativar; escolhas anteriores de desativação são preservadas. Conquistas históricas +ficam silenciosas; novos avisos são breves no painel, sem som ou mudança de foco. +Escolha até nove emblemas e exporte exatamente a prévia SVG; respostas antigas não +liberam a exportação. Há atalhos para voltar ao catálogo e localizar a opção de desativar, sem login +GitHub. Históricos com múltiplos logins não comprovam produtividade individual. +[Armazenamento e limpeza](LOCAL-DATA.md#local-achievements-250). + ## Como interpretar os números No Codex, **processado = entrada + saída** e **uso sem cache = entrada sem diff --git a/README-zh-CN.md b/README-zh-CN.md index 8ac1ea81..f92c4777 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -81,6 +81,199 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 --- +2.5.0 版本整合本地成就与 Codex `sessions`、`archived_sessions` 下的 `.jsonl.zst` 兼容。Zstd 是无损压缩,不是 LLM 摘要;磁盘文件变小不改变日志中的 token 计数。读取保持本地,不改写源日志、不保存解压明文副本,同目录同名 `.jsonl` 优先。使用原生 Zstd(Node 22.15+/23.8+),不新增依赖;不支持的宿主明确提示限制并保留上次已验证统计。 + +## 本地成就(2.5.0) + +会话日期按规范索引的完整准入范围计算,日志变化后可重新校准。取消校准不丢弃已核验真实增量的首次记录;部分保存单独提示。 + +全部展开/收起仅将卡片展开状态保存到既有本地 UI 状态,不改变来源或授奖。 +等级简称为铜/银/金,旧 ID、固定项和参考价表保持不变。各级完全相同的条件与单位 +集中显示一次。金额参考说明统一列出各已启用供应商的当前价表/日期及非账单提示; +不同历史价表保留与具体奖项的准确关联。卡片保留范围与计价覆盖率,不再重复价表及授奖依据说明。 +满级卡片显示未截断的当前统计, +证据过期或缺失时不以目标值替代。 + +展开的等级行使用各等级主题颜色显示名称(高对比模式回退到前景色),门槛在同一标题行加粗、 +右对齐。仅在证据就绪时,最高已获等级的下一等级显示「进行中」;后续等级不重复,满级或 +证据待定时不显示。保留日志已核验日期仅显示 ISO 日期,来源含义在「数据依据」统一解释一次, +title/ARIA 保留来源说明;两种日期同时存在时仅显示已核验日期,首次记录详情留在 title/ARIA。 +仅有首次记录日期时保留明确标签,未知仍为未知;此展示调整不改变授奖、规则、日期计算或已存记录。 + +**独立主任务**统计根归属可核验、且有已识别模型正用量(输入或输出)的 Codex 任务; +主任务与其子任务合计一次,不表示任务已完成。既有规则 ID 与门槛不变。 + +导航及页面标题改为**分享与对比**。徽章选择项仅做等宽对齐、等级单列一行; +勾选图标修复沿用主 Webview CSP 的本地资源来源。导出 SVG 加入固定的公开 +[仓库链接](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage),长标签通过换行与间距避让。 +预览/导出不新增自动网络请求或个人元数据,保留主动选择及已接受 SVG 的不可变导出约定。 +分享页整体视觉统一/美化、持久用量归档均推迟至下一版本,2.5.0 未实现这两项。 + +逐级日志日期仅在内存中派生,表示保留日志可确认的最早日期,不代表账户真实完整历史; +不替换已存首次记录日期或未读状态。通用用量、输出、缓存、模型/家族、活跃及周范围 +日期复用每日聚合。仅手动校准会额外归约已持有的 Claude 内存记录,确认输出会话、 +Fable 会话及同会话 Fable/其他家族日期;普通热刷新不重聚合这些记录。 +不支持的条件仍显示日期未知或原有首次记录。 + +手动校准只执行本地增量工作,同一时间仅一轮;不强制全量扫描、不清除徽章, +不请求 OAuth、在线价格、BYOK 或其他网络服务,也不能强制授奖。忙碌、当前索引全清单 +未获接纳或失败不作为成功;已核验的部分历史保留下界或未知,完成不代表所有授奖范围 +完整覆盖。历史补认在提交前取消不新增历史奖,提交后不撤销已保存记录,也不误报未提交。 +历史补认保持静默。 +详见[日期与存储边界](LOCAL-DATA.zh-CN.md)。 + +协作/彩虹的模型识别独立于计价,已识别但未定价的型号仍未定价;已存金额记录保留已发布价表标签,仅 Claude 的峰值不受无关 Codex 版本变化影响。 +记录加载中、暂不可用、状态已变更及已关闭均明确提示,不显示空计数;已启用 Codex 证据尚未核验时,可信 Claude 累计用量显示 `≥` 下界。 +下一目标显示目标名称与等级,已获卡片保留已获等级,SVG 导出不重复显示等级。 + +徽章详情可进入同供应商已有的当前模型/Token 统计,并返回原徽章;展开状态与键盘焦点保留。 +共享及历史周期条件不以单供应商当前总量冒充获奖证据;跳转不扫描日志。 + +取消旧固定项后,键盘焦点留在对应主题,不刷新数据。 +插件探索详情列出已保存的查看图鉴、预览分享和成功本地导出三步; +旧徽章不补造缺失的步骤记录。建议反馈、设置修改保持独立,可自行选择体验。 +探索图标不写入固定数字。新解锁的前台提示仅图标轻动一次(440ms),文字不移动; +减少动态效果及高对比度模式保持静态,不发声、不抢焦点。 + +「同任务模型」记录单个任务中的模型种类;「多模型任务」记录此类任务的积累。 +「Fable 同会话」要求 Fable 与另一已识别 Claude 家族在同会话内均有输出,不表示先后接力。 + +关闭「显示成就提示」(`showAchievementNotices`) 可保留徽章与未读记录,仅取消浮动提示; +重新开启不补弹旧提示。 + +可证明的 Claude/Codex 新增 Token 跨级会保存未读与首次记录日,暂停页面更新也不丢失。 +支持模型家族用量、输出、缓存读取、Claude 缓存创建和累计用量;导入历史与手动重建保持静默。 +累计 API 参考金额跨级已接入独立新活动收据;其他用量范围仍待各自来源证明。 + +获奖封套仅存本地,总大小不超过 64 KiB。旧徽章日期未知;真实新操作获奖会保留 +未读状态与首次记录日,不因错过提示丢失。已存徽章重启后保留,并在多个窗口间共享。 +旧规则下的徽章保留历史,但不能证明新条件。清除成就记录不改动供应商源日志。 +提示只在前台可见面板显示,多窗口及重启共用五分钟冷却。页面确认显示后仅标记 +本批已读,错过仍保留未读;悬浮或键盘操作期间暂停自动关闭。 +错过的奖章归入默认折叠的「新增 N 项」,每组最多六个精确等级。 +主动点击「标记本组已读」并成功保存后才确认本组;浏览、固定和分享不确认已读, +其他窗口后来的新奖章仍保留未读,不重放弹窗。 +“最近获得”最多展示三个新记录系列;“近期记录”折叠最多三十个等级,可打开对应规则。 +日期为首次记录日,不推测原始达成日期,历史日期保持未知。浏览不标记已读,也不选入分享。 +“我的徽章”可手动固定最多六个主题;收起的卡片也有固定图标,可搜索已获徽章并明确调整顺序。 +手动模式仅显示所选项,自动推荐最多三个;固定与顺序重启后保持,固定不代表分享同意。 +关闭供应商后,可在折叠的“已隐藏的固定徽章”中取消固定,无需重新开启供应商。 + +主动分享仍可选择已关闭供应商的保留徽章;图鉴及新增固定选项按已启用供应商筛选。 +保留徽章不代表当前进度,也不授权新的授奖。 + +导入旧的插件体验记录保持静默。新操作成功保存后,即使未打开仪表盘也可保存对应徽章, +不会自动打开或刷新隐藏面板。 + +平铺且缺少路径可证明父会话的代理日志不计入会话类成就证据;Token 总量、计价、 +每日聚合和可见会话行不变。嵌套代理保留原有归属规则,已存徽章仍保留。 + +2.5.0 的本地图鉴以徽章图标为主,包含 **50 张主题卡、178 个解锁条件、52 套图案**。铜、银、金 +体现持续积累;持续使用与周金额系列另有铂金、传奇等级。同系列只占一张卡,展开后查看 +各级数值与证据要求,卡面直接显示下一目标。系列与已解锁等级分别计数;「全部系列/ +待解锁等级/已获得系列」筛选只在本地进行,重载后保留,升级等级不收起已展开的卡片。 +成就记录使用经历,不评价能力、生产率或消费水平。 +15 个 Claude/持续活动系列追加铂金、传奇,保留原三级门槛与证据口径,复用既有图案。每周三天集中使用也可经累计活跃日、输出和会话成长,不要求 Codex 或更多消费;连续活跃日不追加等级。 +十四日与首个双供应商达标日放入对应成长主题的详情,保留独立授奖及分享选项, +不把单次里程碑算成新增等级。 +各系列使用独立图案,同系列各等级复用徽章并以等级色区分。成就数值仅显示整数; +完整条件与数据说明按需展开。 +证据待补齐或过期时明确标识,不显示为零进度、不进入待解锁筛选;已获徽章仍保留。 +收起的卡片也显示当前/下一等级进度;较长 Token 梯度增加中间阶段,不新增奖章、提示或分享项。 +小/中/大三档改变徽章及卡框大小,保留正文字号和点击区域。「下一目标」最多三个不同主题, +须有当前有效的正向进度,跳过未证实的目标和未使用的模型;完整图鉴在后,本次浏览期间不重排。 +点击徽章打开既有条件,不读日志,也不自动选入分享。 + +- **模型与积累**:Claude 的 Sonnet、Opus、Haiku、Fable,以及 Codex 的 + Astra、Sol、Luna,均以真实 Token 为主要指标;另有输出、缓存、effort、 + 多智能体、多模型经历、活跃日等系列。 +- **里程碑与稳定强度**:累计 Token 合并为 1 亿 → 10 亿 → 100 亿 → 1000 亿 + → 1 万亿;单个自然周峰值为 5 亿/20 亿/100 亿,连续四个已结束自然周的 + 最高周均值为 5 亿/20 亿/50 亿。使用设置中的时区,周一开始;覆盖不完整 + 不能当作零用量,也不能据此解锁周均成就。 +- **插件体验**:主动修改安全设置、预览并成功本地导出分享图、保存本地建议反馈。 + 不要求填写密钥、打开隐私授权、登录 GitHub 或公开发布。 +- **API 等价金额**:共 24 个条件,分为 Claude/Codex 各自的累计已定价小计、 + 已结束自然周及单次三个主题,共六个主题。自然周在当前数据时区从周一开始; + 单次分别指一个 Claude 会话和一棵独立核验的 Codex 任务树,不以全局金额代替范围证据。 + 两个周金额系列均有五级:铜 $100、银 $500、金 $2,500、铂金 $10,000、传奇 $25,000。 + 仅各供应商后两级为新增;旧 ID/门槛、已结束周要求及固定参考价来源保持不变。 + 使用固定美元参考价(`api-reference-2026-10-{backend}-v1`,参考日期 2026-10-09), + 仅纳入精确已知型号及完整组件单价,保留 Claude 已知的 5 分钟/1 小时缓存写入 TTL; + Codex 的未缓存输入、缓存输入与输出各计一次。未知型号保持未定价,可信已定价子集 + 可证明金额下界(`≥`),不冒充全覆盖。金额不是账单、质量评分或收入。 + 历史补认保持静默;只有累计金额里程碑可由同一参考价下核验的真实新追加,保存未读 + 与首次记录日。已结束周、单次会话/任务金额奖章仍静默且日期未知。Token 与金额 + 收据独立,同次更新合为一个既有提示批次;保存失败不乐观显示日期。 +- **联合条件重新校准**:模型、团队、effort 与彩虹系列同时考察输出、重复经历及使用规模; + 模型别名重复计数、仅输入活动和未核验任务结构不能替代合格证据。 +- **Codex effort 详情**:展开已有 effort 广度卡片,查看 low、medium、high、xhigh、max、 + ultra 六行的已处理/输出 Token、合格根任务数与下一既有等级进度。每行三个联合条件 + 分别为已处理 **1 亿/5 亿/20 亿**、输出 **10 万/50 万/200 万**、根任务 **5/10/30**。 + 只计同一根任务 × effort 自身有正输出的用量,排除 approval-reviewer。仅供参考的美元 + 小计与覆盖率使用 `api-reference-2026-10-openai-v1`(2026-10-09),不参与 effort 授奖;模型 × effort 归属 + 不清时不按比例分摊,须由单模型或单 effort 完整价格组件对账证明已知子集。 + 空、未定价、部分、无效与溢出分开处理;部分金额显示下界,无效/溢出金额撤回。 + 缺少/不完整证据显示未知,不补六行零值。 + 不新增规则、门槛、导航或偏好;目前没有 Claude effort 归属统计。 +- **默认开启、可关闭**:在 ⚙ 设置中关闭「本地成就」(`enableAchievements`)。 + 若发版提醒未被关闭,升级时只出现一次非模态提示,可查看图鉴或直接关闭成就; + 原有关闭偏好不被覆盖。历史补认保持静默,之后新达成的成就仅短暂在面板内提示, + 不发声音、不抢焦点;夜间与 24/48 小时输出徽章始终静默。 +- **成就分享卡**:主动选择最多九个已获得徽章,确认预览后本地导出 SVG。 + 导出与已确认预览一致,过期回复不能为新选择启用导出;失败时提供重试提示。 + 分享页可返回图鉴,图鉴中的设置入口直接定位关闭选项,但不会自动更改设置。 + 不自动导出、不联网,也不获取 GitHub 身份。 + +图鉴复用已验证的数值聚合,展开卡片不重读日志。旧 Codex 索引可能需要一次有界、 +可续跑的逐小时输出证明补齐,不会为此清零主索引;关闭成就即可停止这项额外历史工作, +普通用量统计仍运行。刷新失败保留上次显示的数据,但不能用过期证据发放新徽章。 +当前索引完整后,少量 Codex 身份冲突不再阻断无冲突谱系已证明的 Token 与协作里程碑; +冲突分支及后代不计入。 + +当前本地历史已验证且完整索引后,可信的部分日期可用 `≥` 显示活跃、模型家族及单周 +Token 进度。周金额下界仅使用已验证且全部用量已归日的入选文件,定价覆盖独立于 +日期覆盖。部分日期不能证明完整历史或零用量,四周周均及保留峰值须待完整覆盖。 +累计/任务范围不变,不增加源数据读取、存储或联网请求。 + +本地历史可能包含共享账户或多个登录;徽章不是个人使用证明。逐小时输出表示 +每个 UTC 小时都有输出记录,不代表人连续工作。「清除全部派生数据」会删除本地 +成就记录,但不动供应商源日志。详见[本地数据说明](LOCAL-DATA.md#local-achievements-250)。 + +## 热力图分享与周额度节奏(2.5.0) + +2.5.0 的分享热力图提供 **Claude/Codex/两者**及 +**Token/API 等价金额**两个选择维度。默认显示 Token,金额需主动选择。 +金额复用当前每日浮动计价估算,不使用成就的固定美元参考价:Claude 标为估算, +Codex 仅纳入精确已知型号;缺失定价显示不可用或带覆盖率的已定价下界。 +双供应商金额分为两块,绝不相加。隐藏金额同步恢复 Token 的指标、颜色、文字、 +提示、元数据与 Markdown,并废弃旧预览。导出使用已确认 SVG 原字节,在保存对话框 +前后核对文档、控制项、来源、价格及显示修订;只读有界的已物化每日聚合,不重扫日志、不联网。 +选中 Claude 日期范围内仅查询已在内存的模型/组件价格,每次投影按型号缓存;通用未知 +型号 Sonnet 回退价不算已知覆盖,缺失组件单价保持未定价,显式零价有效。不重算既有 +每日/模型成本;家族/运行期价格仍标为估算。隐藏/Token 模式不查单价或金额。 + +「周额度节奏」(`showWeeklyQuotaPace`)默认关闭,两家供应商共用一个偏好; +仅在额度跟踪已开启时复用已有观测。从重置时刻减七天推算均匀进度, +已用 29%、周期经过 25% 时,整周期预测为 116%。前六小时不预测;观测超过三十分钟 +或已过重置时刻即撤回预测。凭证失效立即撤回,暂停仪表盘自动刷新也不阻止撤回。 +计数下降、账号/来源变化或异常重置会锁住可信度,直到验证正常的七天滚动重置。 +当前 Codex 本地日志不能核验账号,因此预测显示不可用。仪表盘与状态栏提示共用八语文案, +简短状态栏文字及自定义格式不变;不增加请求、定时器或存储历史。 + +账号归属不明时,当前周期的总额/未用 API 等价值仍采用低可信本地历史估算, +不是账号余额。准确按账号拆分留待后续多账户兼容;这种近似与周额度节奏预测分开。 + +顶部使用宽度受限的横向卡片,集中展示图标、名称、等级和进度;固定按钮位于徽章卡内,下一目标带细进度条。少量徽章不再平分整个桌面宽度,窄屏换行而不缩小正文字号。浅色主题中的金色等级标签保持清晰可读,并保留金色点缀。 + +分享、设置、筛选、尺寸、展示柜编辑和信息入口保留原有本地化标签,并配上轻量内联 SVG 图标;计数以紧凑标签呈现。 +宽度大于 960 px 时,徽章栏按内容收缩,最大为两张概览卡宽度加内部间距;下一目标栏填满剩余空间,两栏间距 16 px。960 px 及以下直接上下堆叠,间距 12 px;辅助历史区统一放在两者之后。 + +图鉴尺寸控件位于概览前;小/中/大分别使用 56/80/112 px 图鉴图标及 +56/72/96 px 展示柜图标。固定错误显示在相关控件附近;控件消失或禁用时, +键盘焦点回到稳定摘要。 +精确存储边界见[本地数据说明](LOCAL-DATA.zh-CN.md)。 + ## 2.4 新功能
@@ -298,7 +491,7 @@ ext install GrowthJack.claude-code-usage 切换时区可只读沿用能证明同一来源的完整检查点,只迁移日期/小时,不从零重建主历史。 -- 所有 token / 成本 / session 分析都在**本地**进行,只读取你的 `~/.claude/projects/**/*.jsonl` 文件。 +- Token / 成本 / session 分析都在**本地**进行:Claude 读取 `~/.claude/projects/**/*.jsonl`,Codex 只读取其 home 下 `sessions`/`archived_sessions` 内的 `.jsonl`/`.jsonl.zst` 普通用量日志。 - 配额指示器用 Claude Code 现有的 OAuth token 调用 **`api.anthropic.com/api/oauth/usage`**。如 token 已过期,插件会将现有 refresh token 发送至 **`console.anthropic.com/v1/oauth/token`**,并把刷新后的凭证写回选中的 Claude 凭证文件或 macOS 钥匙串。详见[本地数据与隐私](LOCAL-DATA.zh-CN.md)。 - **AI 建议**和**用量优化器**是仅有的会调用模型的功能,而且只在你查看完整请求后再次主动点击「发送」时调用。AI 建议默认只发送聚合证据;提示样本与用户上下文需要独立同意,并完整出现在预览中。优化器只包含你粘贴的文字。两者仅使用 `advice.apiUrl` 与自备 `advice.apiKey`;密钥只进入请求头,插件不使用订阅凭据,也没有后台请求。 diff --git a/README-zh-TW.md b/README-zh-TW.md index 8a5dac9f..42992ecb 100644 --- a/README-zh-TW.md +++ b/README-zh-TW.md @@ -58,6 +58,142 @@ - **多廠商定價** — Opus 4.x / Sonnet 4.x / Haiku 4.5 對照 Anthropic 官方定價;OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen 參考價,含家族感知回退。`Refresh Token Pricing` 可拉取 LiteLLM 即時價格。 - **個人化** — 語言、時區、小數位數、精簡數字、專案分組、儀表板自動刷新開關。 +2.5.0 版本整合本機成就與 Codex `sessions`、`archived_sessions` 下的 `.jsonl.zst` 相容性。Zstd 是無損壓縮,不是 LLM 摘要;磁碟檔案變小不改變記錄中的 token 計數。讀取維持本機處理,不改寫來源記錄、不儲存解壓明文副本,同目錄同名 `.jsonl` 優先。使用原生 Zstd(Node 22.15+/23.8+),不新增相依套件;不支援的宿主明確提示限制並保留上次已驗證統計。 + +## 本機成就(2.5.0) + +會話日期依規範索引的完整准入範圍計算,日誌變動後可重新校準。取消校準不丟棄已核驗真實增量的首次記錄;部分儲存另行提示。 + +全部展開/收合僅將卡片展開狀態儲存至既有本機 UI 狀態,不改變來源或授獎。 +等級簡稱為銅/銀/金,舊 ID、固定項和參考價表維持不變。各級完全相同的條件與單位 +集中顯示一次。共用金額參考說明列出各已啟用供應商的目前價表/日期及非帳單提示; +不同歷史價表保留與各獎項的精確關聯。卡片保留範圍與計價涵蓋率,不再重複價表及授獎依據。 +滿級卡片顯示未截斷的目前統計, +證據過期或缺失時不以目標值替代。 + +展開的等級列使用各等級主題顏色顯示名稱(高對比模式回退至前景色),門檻在同一標題列加粗、 +靠右對齊。僅在證據就緒時,最高已獲等級的下一等級顯示「進行中」;後續等級不重複,滿級或 +證據待定時不顯示。保留日誌已核驗日期僅顯示 ISO 日期,來源含義在「資料依據」統一解釋一次, +title/ARIA 保留來源說明;兩種日期同時存在時僅顯示已核驗日期,首次記錄詳情留在 title/ARIA。 +僅有首次記錄日期時保留明確標籤,未知仍為未知;此顯示調整不改變授獎、規則、日期計算或已存記錄。 + +**獨立主任務**統計根歸屬可核驗、且有已識別模型正用量(輸入或輸出)的 Codex 任務; +主任務與其子任務合計一次,不表示任務已完成。既有規則 ID 與門檻不變。 + +導覽及頁面標題改為**分享與比較**。徽章選項僅做等寬對齊、等級另列一行; +勾選圖示修復沿用主 Webview CSP 的本機資源來源。匯出 SVG 加入固定公開的 +[儲存庫連結](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage),長標籤以換行與間距避讓。 +預覽/匯出不新增自動網路請求或個人中繼資料,保留主動選擇及已接受 SVG 的不可變匯出約定。 +分享頁整體視覺統一/美化與持久用量封存均延至下一版本,2.5.0 未實作這兩項。 + +逐級日誌日期僅在記憶體中衍生,表示保留日誌可確認的最早日期,不代表帳戶真實完整歷史; +不取代已存首次記錄日期或未讀狀態。一般用量、輸出、快取、模型/家族、活躍及週範圍 +日期沿用每日彙總。僅手動校準會額外歸約已持有的 Claude 記憶體記錄,確認輸出工作階段、 +Fable 工作階段及同工作階段 Fable/其他家族日期;一般暖更新不重新彙總這些記錄。 +不支援的條件仍顯示日期未知或原有首次記錄。 + +手動校準只執行本機增量工作,同一時間僅一輪;不強制完整掃描、不清除徽章, +不請求 OAuth、線上價格、BYOK 或其他網路服務,也不能強制授獎。忙碌、目前索引全清單 +未獲接納或失敗不視為成功;已核驗的部分歷史保留下界或未知,完成不代表所有授獎範圍 +完整涵蓋。歷史補認在提交前取消不新增歷史獎,提交後不撤銷已儲存記錄,也不誤報未提交。 +歷史補認保持靜默。 +詳見[日期與儲存界線](LOCAL-DATA.md#local-achievements-250)。 + +協作/彩虹的模型識別獨立於計價,已識別但未定價的型號仍未定價;已存金額記錄保留已發布價表標籤,僅 Claude 的峰值不受無關 Codex 版本變化影響。 +記錄載入中、暫時無法使用、狀態已變更及已關閉均明確提示,不顯示空計數;已啟用 Codex 證據尚未核驗時,可信 Claude 累計用量顯示 `≥` 下界。 +下一目標顯示目標名稱與等級,已獲卡片保留已獲等級,SVG 匯出不重複顯示等級。 + +徽章詳情可前往同供應商現有的目前模型/Token 統計,再返回原徽章,保留展開與焦點; +不掃描日誌,也不用目前總量取代共享或歷史週期證據。 + +取消舊固定項後,鍵盤焦點留在對應主題,不重新整理資料。 +插件探索詳情列出已保存的圖鑑、分享預覽及成功本機匯出三步; +舊徽章不補造步驟記錄,建議回饋與設定修改仍是獨立體驗。 +探索圖示不寫入固定數字。新解鎖的前景提示僅圖示輕動一次(440ms),文字不移動; +減少動態效果及高對比模式保持靜態,不發聲、不搶焦點。 + +「同任務模型」記錄單個任務中的模型種類;「多模型任務」記錄此類任務的累積。 +「Fable 同會話」要求 Fable 與另一已識別 Claude 家族在同會話內均有輸出,不表示先後接力。 + +關閉「顯示成就提示」(`showAchievementNotices`) 可保留徽章與未讀記錄,僅取消浮動提示; +重新開啟不補播舊提示。 + +可證明的 Claude/Codex 新增 Token 跨級會保存未讀與首次記錄日,暫停頁面更新也不遺失。 +支援模型家族用量、輸出、快取讀取、Claude 快取建立和累計用量;匯入歷史與手動重建保持靜默。 +累計 API 參考金額已接入獨立新活動收據,其他用量範圍仍待各自來源證明。 + +獲獎封套僅存本機,總大小不超過 64 KiB。舊徽章日期未知;真實新操作獲獎會保留 +未讀狀態與首次記錄日,不因錯過提示遺失。已存徽章重新啟動後保留,並在多個視窗間共用。 +舊規則下的徽章保留歷史,但不能證明新條件;清除不更動供應商來源日誌。 +提示只在前景可見面板顯示,多視窗及重新啟動共用五分鐘冷卻。面板確認顯示後僅標記 +本批已讀,錯過仍保留未讀;游標停留或鍵盤操作期間暫停自動關閉。 +錯過的獎章歸入預設摺疊的「新增 N 項」,每組最多六個精確等級。 +主動按下「標記本組已讀」並成功儲存後才確認本組;瀏覽、固定和分享不確認已讀, +其他視窗後來的新獎章仍保留未讀,不重播提示。 +「最近獲得」最多顯示三個新記錄系列;「近期記錄」摺疊最多三十個等級,可開啟對應規則。 +日期為首次記錄日,不推測原始達成日期,歷史日期保持未知。瀏覽不標記已讀,也不選入分享。 +可用收起卡片的固定圖示與可搜尋選單,手動固定最多六個主題並調整順序。 +手動模式僅顯示所選項,自動推薦最多三個;固定與順序重新啟動後保留,不代表分享同意。 +關閉供應商後,可在折疊的「已隱藏的固定徽章」中取消固定,無需重新開啟供應商。 + +主動分享仍可選擇已關閉供應商的保留徽章;圖鑑及新增固定選項依已啟用供應商篩選。 +保留徽章不代表目前進度,也不授權新的獲獎。 + +匯入舊的外掛體驗記錄保持靜默。新操作成功儲存後,即使未開啟儀表板也可儲存對應徽章, +不會自動開啟或重新整理隱藏面板。 + +平鋪且缺少路徑可證明父會話的代理日誌不計入會話類成就證據;Token 總量、計價、 +每日聚合及可見會話列不變。巢狀代理保留原有歸屬規則,已存徽章仍保留。 + +2.5.0 的本機圖鑑包含 50 張主題卡、178 個解鎖條件、52 套圖案,涵蓋模型 Token、輸出/快取、 +活躍日、週用量里程碑及少量外掛體驗。系列與等級分別計數,本機篩選重載後保留; + +15 個 Claude/持續活動系列追加鉑金與傳奇,原三級門檻和證據口徑不變,沿用圖案。每週三天集中使用可透過累計活躍日、輸出和工作階段成長,不要求 Codex 或更多消費;連續活躍日不追加等級。 + +其中 24 個金額條件分為 Claude/Codex 各自的累計已定價小計、已結束週一自然週及單次,共六個主題。 +兩個週金額系列均有五級:銅 $100、銀 $500、金 $2,500、鉑金 $10,000、傳奇 $25,000。 +僅各供應商後兩級為新增;舊 ID/門檻、已結束週要求及固定參考價來源維持不變。 +自然週依目前資料時區;單次分別為一個 Claude 會話與一棵已核驗 Codex 任務樹。 +固定美元參考價附版本,僅納入精確已知型號與完整組件單價(含 Claude 快取 TTL);未知型號不定價,可信子集僅證明下界。 +金額不代表帳單、品質或收入;只有同一參考價下核驗的真實新追加可為累計金額保存未讀及首次記錄日。歷史、已結束週與單次會話/任務保持靜默且日期未知。Token/金額收據獨立,同次更新共用提示批次,保存失敗不樂觀顯示日期。模型/團隊/effort/彩虹改用輸出、重複經歷及規模的聯合條件。 +展開已有 Codex effort 卡片可看六檔 Token、輸出、合格根任務及聯合進度,並附固定美元參考小計/覆蓋率。只計同根任務 × effort 自身有正輸出的用量,排除 approval-reviewer;模型歸屬不清不按比例分攤,缺失證據顯示未知。不新增規則/偏好,目前沒有 Claude effort 歸屬統計。 +分享熱力圖已接入 Claude/Codex/兩者及 Token/API 等價金額,預設 Token,金額須主動選擇。 +金額使用目前每日浮動估算而非成就固定參考價:Claude 為估算,Codex 僅計精確已知型號;缺失定價或部分下界均有覆蓋說明,兩家金額絕不相加。 +Claude 覆蓋僅在記憶體查詢選中範圍的模型/組件價格;通用未知型號 Sonnet 回退價不算已知,缺失單價未定價、明確零價有效。既有成本不重算,Token/隱藏模式不查價格或金額。 +隱藏金額同步恢復 Token 顏色、文字與 Markdown,廢棄舊預覽。保存對話框前後核對後匯出已確認 SVG 原字節,不重掃日誌、不新增網路請求。 +「週額度節奏」(`showWeeklyQuotaPace`)預設關閉,重用既有觀測;已用 29%、週期經過 25% 時預測整週期 116%。 +前六小時、觀測超過三十分鐘或重置後不預測;憑證失效即撤回,暫停頁面更新也有效。帳號/來源變動、計數下降或異常重置須待核驗正常七天滾動重置。 +目前 Codex 日誌無法核驗帳號,預測不可用。儀表板與狀態列提示共用翻譯,簡短文字及自訂格式不變,不新增請求、計時器或歷史。 +帳號歸屬不明時,目前週期的總額/未用 API 等價值仍採低可信本機歷史估算, +不是帳號餘額。準確按帳號拆分留待後續多帳戶支援;此近似與週額度節奏預測分開。 + +概覽採寬度受限的橫向卡片,集中呈現圖示、名稱、等級與進度;固定按鈕位於徽章卡內,下一目標附細進度條。少量徽章不再平分桌面全寬,窄螢幕換行而不縮小文字。淺色主題中的金色等級標籤保持清晰可讀,並保留金色點綴。 + +分享、設定、篩選、尺寸、展示櫃編輯和資訊入口保留原有在地化標籤,並配上輕量內嵌 SVG 圖示;計數以精簡標籤呈現。 +寬度大於 960 px 時,徽章欄依內容收縮,最大為兩張概覽卡寬度加內部間距;下一目標欄填滿剩餘空間,兩欄間距 16 px。960 px 及以下直接上下堆疊,間距 12 px;輔助歷史區統一放在兩者之後。 +尺寸工具列位於概覽前,圖鑑 56/80/112 px、展示櫃 56/72/96 px;固定錯誤顯示於相關控件附近,鍵盤焦點保持穩定。 +effort 美元小計不參與授獎;空與未定價分別標示,部分金額顯示下界,無效/溢出金額撤回。 +[詳細英文說明](README-en.md#heatmap-sharing-and-weekly-quota-pace-250)。 +各系列圖案不同,同系列以等級色區分;數值僅顯示整數,資料說明可展開。 +證據待補齊或過期時明確標示,不顯示為零進度、不進入待解鎖篩選;已獲徽章仍保留。 +收起的卡片也顯示目前/下一等級進度;較長 Token 梯度增加中間階段,不新增獎章、提示或分享項目。 +小/中/大三檔改變徽章及卡框,保留字級與點擊區域。「下一目標」最多三個主題,跳過未證實目標及未使用模型。 +目標須有目前有效正向進度;瀏覽期間不重排,點擊開啟既有條件,不讀日誌、不自動加入分享。 +當前索引完整後,無衝突 Codex 譜系可解鎖已核實的 Token/協作里程碑;衝突分支及後代不計入。 + +當前本機歷史已驗證且完整索引後,可信的部分日期可用 `≥` 顯示活躍、模型家族及單週 +Token 進度。週金額下界僅使用已驗證且全部用量已歸日的入選檔案,定價覆蓋獨立於 +日期覆蓋。部分日期不能證明完整歷史或零用量,四週週均及保留峰值須待完整覆蓋。 +累計/任務範圍不變,不增加來源資料讀取、儲存或網路請求。 + +卡面顯示下一目標,展開查看各級條件。預設開啟,可在 ⚙ 設定的「本地成就」 +(`enableAchievements`)關閉;停止新解鎖及額外小時證據補齊,仍保留一般用量統計。 +升級時有一次非模態提醒與關閉選項,保留先前關閉偏好;歷史補認靜默,新提示只短暫 +顯示於面板,不發聲、不搶焦點。可主動選擇最多九枚徽章,預覽後匯出本機 SVG, +匯出與確認的預覽一致,過期回覆不能啟用匯出;可返回圖鑑或定位關閉設定。 +無需 GitHub 登入。共用帳戶/多個登入的本機歷史不是個人生產率證明。 +詳見[本機資料與清除](LOCAL-DATA.md#local-achievements-250)。 + ## v2.4 新功能
diff --git a/README.md b/README.md index a67e4833..c555ff94 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,265 @@ real account or an installed-VSIX capture.* --- +Version 2.5.0 combines local achievements with Codex `.jsonl.zst` compatibility in `sessions` and `archived_sessions`. Zstd is lossless compression, not an LLM summary: smaller disk files preserve logged token counts. Reads stay local without changing source logs or saving decompressed copies; same-directory, same-basename `.jsonl` takes priority. Native Zstd (Node 22.15+ / 23.8+) adds no dependency; unsupported hosts report the limitation and retain last verified totals. + +## Local achievements (2.5.0) + +Session dates use the same uncapped admission as the canonical index; changed logs require recalibration. Verified real usage keeps its native first-recorded receipt even if calibration is cancelled. Partial saves are reported separately. + +Expand all / Collapse all saves only card disclosure state in the existing local UI +state; it does not change sources or awards. Ranks use localized Bronze / Silver / +Gold labels without changing IDs, pins or reference pricebooks. Identical conditions +and units are shown once across levels. One shared money-reference section shows +each enabled provider's current book/date and the non-billing note. Different +historical books retain their exact award associations there; cards keep scope +and pricing coverage without repeated price/basis notes. Fully earned cards show the uncapped current statistic; +stale or missing evidence is never replaced with a target value. + +Expanded rank rows use per-rank theme colors for the grade name (foreground fallback +in high contrast), with a bold, right-aligned threshold on the same header row. +Only the next rank above the highest earned shows “In progress” when proof is ready; +later ranks, fully earned cards and pending proof show no such label. Verified +retained-log dates appear as bare ISO dates, with their meaning explained once in +Evidence details and provenance retained in title/ARIA text. When both dates exist, +only the verified date is visible; first-recorded details remain in title/ARIA text. +A first-recorded date alone keeps its explicit label; unknown dates stay unknown. +This presentation change leaves awards, rules, date calculations and saved records intact. + +**Independent root tasks** counts verifiable Codex root ownership with recognized +positive model usage (input or output). A root and its children count once; this is +not a count of completed tasks. Existing rule IDs and thresholds are unchanged. + +The navigation and page label is **Share & compare**. Badge choices use equal-width +alignment and a separate rank line. The checkbox-icon repair uses the same +local-resource source as the main webview CSP. Exported badge SVGs include the fixed +public [repository link](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage), with +wrapping/spacing for long labels. Preview/export adds no automatic network request +or personal metadata and retains the accepted immutable SVG and explicit selection. +Broad sharing-page visual unification/beautification and a durable usage archive +are deferred to the next version; neither is implemented in 2.5.0. + +Per-level log dates are derived in memory and mean the earliest date verifiable +from retained logs, not complete account history. They do not replace saved +first-record dates or unread state. Daily totals supply supported volume, output, +cache, model/family, activity and weekly dates. Manual recalibration alone also +reduces already-held Claude records for output-session, Fable-session and +same-session Fable/other-family dates; ordinary warm refreshes do not regroup those +records. Unsupported conditions retain unknown dates or their saved first record. + +Recalibration is local, incremental and single-flight. It neither forces a full +scan nor clears badges, and makes no OAuth, pricing, BYOK or other network request. +It cannot force awards; busy work, an unaccepted current index census or failure is +not success. Verified partial history retains lower bounds or unknowns; completion +does not mean every award scope is fully covered. Cancellation +before the historical commit adds no historical awards; after commit it does not undo saved records or change +the successful result. Historical reconciliation stays quiet. See +[date and storage boundaries](LOCAL-DATA.md#local-achievements-250). + +Collaboration/rainbow model recognition is separate from pricing, so recognized but unpriced labels stay unpriced; retained monetary records keep their published pricebook labels, and Claude-only peaks survive unrelated Codex version changes. +Loading, unavailable, changed and disabled record states replace empty counts; verified Claude cumulative usage shows `≥` while enabled Codex evidence remains unverified. +Next targets show the target name and rank, earned cards keep their earned rank, and SVG exports show each rank once. + +Badge details link to existing current model/token statistics for the same provider, +with a keyboard-safe return. Shared and historical-period badges do not substitute +one provider's current totals for award evidence; navigation does not scan logs. + +Removing a legacy pin keeps keyboard focus within its theme, without refreshing data. +Exploration details show three saved local steps: collection, share preview and +successful export. Missing step records are not inferred from old awards; +advice feedback and setting changes remain separate, optional activities. +The journey emblem has no embedded counts. New foreground notices reveal only +the badge once for 440 ms; text stays still, with static reduced-motion and +high-contrast alternatives. No sound or focus change. + +Model experiences distinguish models recorded within one task from repeated +multi-model tasks. Fable same-session requires output from Fable and another +recognized Claude family. These names do not imply a sequence or hand-off. + +Turn off **Show achievement notices** (`showAchievementNotices`) to keep badges +and unread records without floating notices; re-enabling does not replay them. + +Proven new Claude/Codex Token tier crossings save unread first-record days even +when dashboard updates are paused. Supported rules cover model-family volume, +output, cache reads, Claude cache creation and cumulative volume. Imports and +explicit rebuilds stay quiet. Proven cumulative API-reference crossings now have +their own live receipts; other usage scopes still need their own origin proof. + +Awards use a local envelope capped at 64 KiB. Old badges keep unknown dates; +new saved-action badges retain unread first-record days even if a notice is missed. +Saved awards survive restarts and are shared across windows. Awards under older +rules retain their history without proving newer conditions. Clearing removes +the award records without changing provider source logs. +Badge notices appear only in the visible foreground panel, at most once per five +minutes across windows/restarts. Display confirmation reads only that batch; missed +notices stay unread. Hovering or keyboard focus pauses dismissal. +Missed awards appear in a folded New awards summary with at most six exact levels +per group. Only Mark this group read confirms that group after a successful local +save; browsing, pinning and sharing do not. Other windows' later awards remain unread. +Recently earned shows at most three new series; Recent records folds up to thirty +saved levels and links to their rules. My badges supports up to six manually pinned +themes, with pin icons on closed cards and a searchable selection editor with explicit +ordering. Manual mode shows only chosen pins; automatic mode recommends at most +three. Pins and order survive restart and never select sharing. +Pins hidden by a disabled provider can be removed from the folded hidden-pin list +without re-enabling that provider. + +Explicit sharing can include retained earned badges from a disabled provider. +The gallery and pin choices follow enabled providers; retained badges do not +supply current progress or authorize new awards. +Dates mean first recorded, not inferred +attainment; historical dates remain unknown. Browsing does not mark records read or select sharing. + +Imported plugin-experience records stay quiet. A newly saved action can unlock +its badge even with the dashboard closed; it never opens or refreshes a hidden panel. + +Flat agent logs without a path-proven parent do not count toward session-based +achievements. Their Token totals, pricing, daily aggregates and visible session +rows stay unchanged; nested-agent ownership and saved awards are retained. + +The 2.5.0 icon-first gallery has **50 display themes and 178 award conditions**, +using 52 artwork groups. Fourteen-day and first shared-day +milestones live inside their growing themes, with distinct award and sharing choices. +The gallery has bronze, silver and gold tiers, plus platinum/legendary sustained-use and weekly-money ranks. +Fifteen sustained Claude/activity series add Platinum and Legendary ranks without changing their first three targets. These use existing verified counters and artwork; concentrated three-day weeks can grow through activity days, output and sessions without requiring Codex or higher spending. Consecutive-day tiers are not extended. +One card shows each series and its compact next target; expand it for every level's +rule and evidence. Series and unlocked levels are counted separately. Local All / +Next level / Earned filters survive reload; upgrading a tier keeps its disclosure open. +It records usage experience, not skill, productivity, billing or account entitlement. +Each series has distinct artwork; tiers share that emblem and use level colors. +Achievement numbers use integers. Rules and data caveats are available on demand. +Pending or outdated evidence is labelled, not shown as zero progress, and excluded +from Next level filtering; earned badges remain saved. +Closed cards show current / next-level progress. Long Token ladders include +intermediate stages, without extra awards, notices or sharing choices. +Small / Medium / Large sizes change badges and card frames while preserving text +size and click areas. Next goals highlights at most three distinct themes before +the complete collection. Goals require positive, current evidence; unproven goals +and unused models are not promoted. Selection stays fixed during a visit; opening a badge focuses +its existing rule without reading logs or selecting it for sharing. + +- **Token-led progression:** Claude Sonnet/Opus/Haiku/Fable and Codex Astra/Sol/Luna, + output, cache, model/effort exploration, agents, active days and repeat experiences. + Cumulative processed tokens share one series: 100M → 1B → 10B → 100B → 1T. +- **Peaks and sustained use:** a calendar-week peak of 500M / 2B / 10B tokens; + the highest average across four completed calendar weeks has 500M / 2B / 5B tiers. + Weeks start Monday in the configured timezone; incomplete coverage cannot prove zero use. +- **Small local discoveries:** deliberately changing safe settings, previewing/exporting + a share card, and saving local advice feedback. Credentials, network consent and + public GitHub publication are never achievement requirements. +- **API-equivalent milestones:** 24 conditions in six separate Claude/Codex themes + cover cumulative priced subtotals, completed Monday-start weeks in the current + data timezone, and one Claude session or one independently verified Codex task tree. + Both weekly series have five ranks: Bronze $100, Silver $500, Gold $2,500, + Platinum $10,000 and Legendary $25,000. Only the last two per provider are new; + old IDs/thresholds, completed-week requirements and frozen-price provenance remain unchanged. + These use fixed USD reference prices (`api-reference-2026-10-{backend}-v1`, + dated 2026-10-09), exact known models and complete component rates, including + Claude's known 5-minute/1-hour cache-write TTL. Codex counts uncached input, + cached input and output once. Unknown models stay unpriced; a verified priced + subset can prove a lower bound (`≥`), not full coverage. This is neither a bill, + quality score nor income. History is recognized quietly. Only cumulative money + milestones now save unread first-record days from verified new appends under + the same reference book; completed-week and single-session/task awards stay + quiet with unknown dates. Token and money receipts remain independent and use + one existing notice batch; failed saves never show optimistic dates. +- **Recalibrated experiences:** model, team, effort and rainbow rules combine + output with repeated use and usage scale. Duplicate aliases, input-only activity and + unverified task structures cannot substitute for the joint evidence. +- **Codex effort details:** expand the existing effort-breadth card to see low, + medium, high, xhigh, max and ultra rows with processed/output Tokens, qualifying + root tasks and progress toward its existing next tier. Per-row tier criteria are + processed **100M / 500M / 2B**, output **100K / 500K / 2M** and roots **5 / 10 / 30**, + jointly required. Only each root × effort population's own positive output counts; + approval-reviewer usage is excluded. An informational USD subtotal and coverage + use `api-reference-2026-10-openai-v1` (2026-10-09) and do not qualify effort awards. + Ambiguous model × effort attribution is not divided proportionally; partial money + shows a lower bound, and invalid/overflow money is withheld. Empty and unpriced + states are explicit. Incomplete evidence shows unknown, + not six zero rows. This adds no rules, targets, navigation or preferences; + Claude effort attribution is not currently available. +- **On by default, easy to stop:** turn off **Local achievements** in ⚙ Settings + (`enableAchievements`). On upgrade, with release announcements enabled, one + non-modal reminder offers **View achievements** and **Turn achievements off**. + Existing opt-outs remain off; historical unlocks stay quiet. Later unlocks use a + brief in-dashboard notice, without sound or focus theft. Night/24–48-hour badges stay silent. +- **Preview-first sharing:** explicitly select up to nine earned badges, preview, + then export that exact SVG. Old responses cannot enable export for a new selection; + failures offer retry guidance. A return action leads back to the gallery, and its + settings shortcut focuses the opt-out without changing it. No auto-export, sign-in, + profile lookup or background upload. + +Evaluation reuses verified numeric aggregates; opening cards never reads logs. +After complete current indexing, independently verified Codex lineages support +Token/agent milestones, excluding conflicting branches and descendants. + +With current local history verified and fully indexed, trusted partial dates can +show `≥` for activity, model-family and weekly Token progress. Weekly money bounds +use only verified selected files whose usage is fully dated; pricing coverage is +independent of date coverage. Partial dates do not establish complete history or +verified zero, and four-week means/retained peaks remain pending until full coverage. +Cumulative/task scopes are unchanged, with no extra source reads, storage or network calls. + +An older Codex index may need a bounded, resumable pass for positive-output hour +proof, without resetting its primary totals. Turning achievements off stops this +extra historical work, not the ordinary usage tracker. A refresh failure revokes +new-award authority while retaining the last displayed statistics. + +Shared accounts and multiple sign-ins in one local history can contribute to these +badges; they are not proof of one person's activity. Continuous output means a +positive-output record in each UTC hour, not continuous human work. Clearing all +extension-derived data removes the local award ledger, never provider logs. +See [local storage and clearing](LOCAL-DATA.md#local-achievements-250). + +## Heatmap sharing and weekly quota pace (2.5.0) + +The 2.5.0 sharing heatmap offers **Claude / Codex / +Both** and **Tokens / API-equivalent amount** controls. Tokens are the default; +amounts require an explicit choice. Amount mode uses current daily pricing estimates, +separate from achievements' fixed USD references: Claude is estimated, and Codex +prices only exact known models. Missing pricing shows an unavailable amount or a +priced lower bound with coverage. Both keeps two monetary panels; amounts are never summed. +Hiding amounts switches the metric, colors, labels, tooltips, metadata and Markdown +back to Tokens and retires the previous preview. Export writes the exact accepted +SVG bytes, checking the document, controls, source, pricing and display revisions +before and after the save dialog. Preview/export reads only bounded materialized +daily aggregates, with no new log scan or network request. Selected-range Claude +coverage uses memoized in-memory model/component rates: the generic unknown-model +Sonnet fallback does not count as known coverage, missing rates stay unpriced, +and an explicit zero rate is valid. Stored daily/model costs are not recomputed; +family/runtime prices remain estimates. Hidden/Token mode performs no rate or money lookup. + +**Weekly quota pace** (`showWeeklyQuotaPace`) is off by default and shared by both +provider settings. When quota tracking is already enabled, it compares the original +account-wide weekly observation with uniform progress from reset minus seven days: +29% used at 25% elapsed projects 116% for the cycle. There is no forecast in the +first six hours, after thirty minutes without a fresh observation, or after reset. +Credential invalidation withdraws it immediately, including while dashboard +auto-refresh is paused. Counter drops, changed accounts/sources and unexpected +resets suspend trust until a verified ordinary seven-day rollover. Current Codex +local-log evidence cannot verify an account, so its forecast remains unavailable. +The dashboard and status-bar tooltip share localized copy; compact bar text and +custom quota formats are unchanged. This adds no requests, timers or stored history. + +Current-cycle total and unused API-equivalent values remain low-confidence +local-history estimates when account attribution is unclear. Accurate per-account +separation is deferred to future multi-account support; these values are not account +balances. This approximation is separate from the weekly quota-pace forecast. + +The overview uses bounded horizontal cards: artwork, title, rank and progress stay together, with pins inside the badge card and a thin progress bar for next targets. A few badges no longer spread across the entire desktop width; narrow views wrap without shrinking text. Gold rank labels stay legible in light themes while retaining a gold accent. + +Lightweight inline SVG icons accompany the existing localized share, settings, +filter, size, showcase-editor and information labels; counts appear as compact chips. +Above 960 px, the badge column fits its content up to two overview-card widths +plus their internal gap; the next-target column fills the remaining space, with +16 px between columns. At 960 px or below they stack directly with a 12 px gap. +Auxiliary history sections follow both. + +Gallery size controls precede the overview: Small / Medium / Large use 56 / 80 / +112 px collection emblems and 56 / 72 / 96 px showcase emblems. Pin errors stay +near their controls, and keyboard focus returns to a stable summary when a control +disappears or becomes disabled. + ## What's new in 2.4
@@ -638,8 +897,8 @@ only date/hour buckets migrate, without restarting primary history. - All **Claude** token / cost / session analysis runs locally by reading your `~/.claude/projects/**/*.jsonl` files. -- Codex usage records are discovered only from `sessions/**/*.jsonl` and - `archived_sessions/**/*.jsonl` below your Codex home. Separately, the extension +- Codex usage records are discovered only from `.jsonl` and `.jsonl.zst` regular + files under `sessions` and `archived_sessions` below your Codex home. Separately, the extension streams exactly `$CODEX_HOME/session_index.jsonl` for the `id` → `thread_name` mapping used by truthful thread titles. Absolute paths in those titles are redacted and the titles remain memory-only. Credentials, databases, and unknown @@ -758,7 +1017,7 @@ only date/hour buckets migrate, without restarting primary history. Created by [@jack21](https://github.com/jack21) and maintained by [@Carl723000](https://github.com/Carl723000), with community PRs, issue reports, reviews, and translations. Thank you to everyone who helped shape the project. -The lists below cover public PR and issue authors through v2.4.0; a linked PR +The contributor credits below are reconciled for v2.5.0; a linked PR is not a claim that every proposed line shipped. See [CONTRIBUTING.md](CONTRIBUTING.md) for the release-credit convention. @@ -772,7 +1031,7 @@ is not a claim that every proposed line shipped. See - [@e7d](https://github.com/e7d) — [Opus 5 context window](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/81). - [@henrique-carvalho-dev](https://github.com/henrique-carvalho-dev) — [Brazilian Portuguese](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/48). - [@jack21](https://github.com/jack21) — original project; [Codex adaptation guidance](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/96). -- [@jackieyangjq](https://github.com/jackieyangjq) — [dashboard date-label performance](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/115). +- [@jackyyangjq](https://github.com/jackyyangjq) — [dashboard date-label performance](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/115). - [@mxzinke](https://github.com/mxzinke) — [model pricing](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/5); [German translation](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/6). - [@nickearnshaw](https://github.com/nickearnshaw) — localization; loading/quota fixes; test, CI, and docs foundations ([PRs](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pulls?q=is%3Apr+is%3Amerged+author%3Anickearnshaw)). - [@oxsean](https://github.com/oxsean) — [session actions and quota resilience](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/43). @@ -798,7 +1057,7 @@ is not a claim that every proposed line shipped. See - [@danielboxer](https://github.com/danielboxer) [#26](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/26) · [@lyrzxz](https://github.com/lyrzxz) [#27](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/27) · [@BarisKuzu](https://github.com/BarisKuzu) [#45](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/45), [#87](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/87) - [@ekimminau](https://github.com/ekimminau) [#51](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/51) · [@dbookstaber](https://github.com/dbookstaber) [#54](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/54) · [@eduardogomezgvp](https://github.com/eduardogomezgvp) [#55](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/55) · [@chuccv](https://github.com/chuccv) [#70](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/70) - [@nsevene](https://github.com/nsevene) [#80](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/80) · [@arhneu](https://github.com/arhneu) [#82](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/82) · [@jcastillooo](https://github.com/jcastillooo) [#84](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/84) -- [@HoangJN](https://github.com/HoangJN) [#89](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/89) · [@Bozodragon](https://github.com/Bozodragon) [#91](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/91) · [@aaroncvan](https://github.com/aaroncvan) [#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94) · [@mkgaskin-ops](https://github.com/mkgaskin-ops) [#99](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/99) +- [@HoangJN](https://github.com/HoangJN) [#89](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/89) · [@Bozodragon](https://github.com/Bozodragon) [#91](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/91) · [@aaroncvan](https://github.com/aaroncvan) [#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94) · [@mkgaskin-ops](https://github.com/mkgaskin-ops) [#99](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/99), [#126](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/126) - PR authors also reported issues: [@nickearnshaw](https://github.com/nickearnshaw) [#25](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/25), [#33](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/33) · [@oxsean](https://github.com/oxsean) [#40](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/40) · [@projectronic](https://github.com/projectronic) [#74](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/74). The maintainer's own issues are in the [issue history](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues?q=is%3Aissue+author%3ACarl723000).
diff --git a/assets/achievements/active-days-v242.png b/assets/achievements/active-days-v242.png new file mode 100644 index 00000000..bfb560fd Binary files /dev/null and b/assets/achievements/active-days-v242.png differ diff --git a/assets/achievements/active-weeks-v242.png b/assets/achievements/active-weeks-v242.png new file mode 100644 index 00000000..6cbe5892 Binary files /dev/null and b/assets/achievements/active-weeks-v242.png differ diff --git a/assets/achievements/api-v242-prompts-v2.json b/assets/achievements/api-v242-prompts-v2.json new file mode 100644 index 00000000..002dfd30 --- /dev/null +++ b/assets/achievements/api-v242-prompts-v2.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "mode": "built-in image_gen edit", + "generatedOn": "2026-10-10", + "transparentBackground": false, + "packagedSize": { + "width": 256, + "height": 256 + }, + "designIntent": "Restrained gold coins and small money pouches identify API-equivalent resource use; no wealth, profit or productivity claim.", + "packaging": "Selected built-in outputs copied into versioned project siblings and downscaled with macOS sips; no content edits.", + "assets": [ + { + "file": "claude-api-total-v242-v2.png", + "prompt": "Use case: precise-object-edit. Asset type: a bundled raster API-equivalent usage achievement emblem for a VS Code tracker. Input image 1 is the edit target. Preserve the exact friendly robot character, its provider color, off-white square canvas, thick near-black rounded-square border, bold flat outlines, generous margins and clean legibility at 56px. Add intuitive, restrained monetary imagery so this denotes metered API-equivalent resources, not wealth, profit or personal success. Use muted warm-gold coins or a small neutral drawstring pouch as a supporting clue, not a dominant trophy. No huge piles, overflowing treasure, crowns, money rain, luxury objects, sparkly rewards, celebratory wealth poses, logos, text, letters, numerals or currency signs. Keep the existing image's overall composition. Produce exactly one square emblem, not a contact sheet. Change only the three small chip symbols on the stacked resource-ledger tiles into three simple modest gold coin emblems with raised coin rims. Retain all three ledger tiles and their teal record lines, and the orange crab robot. The restrained ledger-with-coins arrangement should read as cumulative resource accounting, not a pile of cash.", + "reference": "claude-api-total-v242.png" + }, + { + "file": "claude-api-week-v242-v2.png", + "prompt": "Use case: precise-object-edit. Asset type: a bundled raster API-equivalent usage achievement emblem for a VS Code tracker. Input image 1 is the edit target. Preserve the exact friendly robot character, its provider color, off-white square canvas, thick near-black rounded-square border, bold flat outlines, generous margins and clean legibility at 56px. Add intuitive, restrained monetary imagery so this denotes metered API-equivalent resources, not wealth, profit or personal success. Use muted warm-gold coins or a small neutral drawstring pouch as a supporting clue, not a dominant trophy. No huge piles, overflowing treasure, crowns, money rain, luxury objects, sparkly rewards, celebratory wealth poses, logos, text, letters, numerals or currency signs. Keep the existing image's overall composition. Produce exactly one square emblem, not a contact sheet. Retain the orange crab robot and calendar with exactly seven day squares in a single row. Replace only the small chip at the calendar's lower corner with one simple warm-gold coin with a raised rim. Keep the recording line and quiet weekly-accounting composition; no additional coins.", + "reference": "claude-api-week-v242.png" + }, + { + "file": "claude-api-session-v242-v2.png", + "prompt": "Use case: precise-object-edit. Asset type: a bundled raster API-equivalent usage achievement emblem for a VS Code tracker. Input image 1 is the edit target. Preserve the exact friendly robot character, its provider color, off-white square canvas, thick near-black rounded-square border, bold flat outlines, generous margins and clean legibility at 56px. Add intuitive, restrained monetary imagery so this denotes metered API-equivalent resources, not wealth, profit or personal success. Use muted warm-gold coins or a small neutral drawstring pouch as a supporting clue, not a dominant trophy. No huge piles, overflowing treasure, crowns, money rain, luxury objects, sparkly rewards, celebratory wealth poses, logos, text, letters, numerals or currency signs. Keep the existing image's overall composition. Produce exactly one square emblem, not a contact sheet. Retain the orange crab robot holding exactly one large session tile and the bracket-shaped session outline. In the tile, introduce one small plain drawstring money pouch between the session brackets, with at most one modest gold coin peeking out. Keep a small resource-record line, remove the former chip symbol, and avoid making the pouch overflow or dominate the robot. This is one session's resource budget.", + "reference": "claude-api-session-v242.png" + }, + { + "file": "codex-api-total-v242-v2.png", + "prompt": "Use case: precise-object-edit. Asset type: a bundled raster API-equivalent usage achievement emblem for a VS Code tracker. Input image 1 is the edit target. Preserve the exact friendly robot character, its provider color, off-white square canvas, thick near-black rounded-square border, bold flat outlines, generous margins and clean legibility at 56px. Add intuitive, restrained monetary imagery so this denotes metered API-equivalent resources, not wealth, profit or personal success. Use muted warm-gold coins or a small neutral drawstring pouch as a supporting clue, not a dominant trophy. No huge piles, overflowing treasure, crowns, money rain, luxury objects, sparkly rewards, celebratory wealth poses, logos, text, letters, numerals or currency signs. Keep the existing image's overall composition. Produce exactly one square emblem, not a contact sheet. Change only the three small blue chip symbols on the stacked resource-ledger tiles into three simple modest gold coin emblems with raised rims. Preserve all three ledger tiles and teal record lines, and the friendly blue cloud-shaped terminal robot. The restrained ledger-with-coins arrangement denotes cumulative metered API resources, not wealth.", + "reference": "codex-api-total-v242.png" + }, + { + "file": "codex-api-week-v242-v2.png", + "prompt": "Use case: precise-object-edit. Asset type: a bundled raster API-equivalent usage achievement emblem for a VS Code tracker. Input image 1 is the edit target. Preserve the exact friendly robot character, its provider color, off-white square canvas, thick near-black rounded-square border, bold flat outlines, generous margins and clean legibility at 56px. Add intuitive, restrained monetary imagery so this denotes metered API-equivalent resources, not wealth, profit or personal success. Use muted warm-gold coins or a small neutral drawstring pouch as a supporting clue, not a dominant trophy. No huge piles, overflowing treasure, crowns, money rain, luxury objects, sparkly rewards, celebratory wealth poses, logos, text, letters, numerals or currency signs. Keep the existing image's overall composition. Produce exactly one square emblem, not a contact sheet. Retain the friendly blue robot and calendar with exactly seven checked day squares in a single row. Replace only the small blue chip below the calendar with one simple muted warm-gold coin with a raised rim; retain the teal connecting resource line. Keep the completed-week accounting symbolism and no extra coins.", + "reference": "codex-api-week-v242.png" + }, + { + "file": "codex-api-task-v242-v2.png", + "prompt": "Use case: precise-object-edit. Asset type: a bundled raster API-equivalent usage achievement emblem for a VS Code tracker. Input image 1 is the edit target. Preserve the exact friendly robot character, its provider color, off-white square canvas, thick near-black rounded-square border, bold flat outlines, generous margins and clean legibility at 56px. Add intuitive, restrained monetary imagery so this denotes metered API-equivalent resources, not wealth, profit or personal success. Use muted warm-gold coins or a small neutral drawstring pouch as a supporting clue, not a dominant trophy. No huge piles, overflowing treasure, crowns, money rain, luxury objects, sparkly rewards, celebratory wealth poses, logos, text, letters, numerals or currency signs. Keep the existing image's overall composition. Produce exactly one square emblem, not a contact sheet. Retain the blue-violet friendly robot holding exactly one ledger tile with exactly one connected task-tree glyph: one top square linked to three lower squares. Replace only the chip at the tile's lower corner with one small plain neutral drawstring pouch featuring a single subtle gold coin emblem. Preserve the tree glyph as the dominant scope clue. No additional pouches or coin piles; this represents the API-equivalent resources for one task.", + "reference": "codex-api-task-v242.png" + } + ] +} diff --git a/assets/achievements/api-v242-prompts.json b/assets/achievements/api-v242-prompts.json new file mode 100644 index 00000000..0e365098 --- /dev/null +++ b/assets/achievements/api-v242-prompts.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "mode": "built-in image_gen", + "generatedOn": "2026-10-10", + "transparentBackground": false, + "packagedSize": { + "width": 256, + "height": 256 + }, + "packaging": "Selected built-in outputs copied into the project and downscaled with macOS sips; no content edits.", + "assets": [ + { + "file": "claude-api-total-v242.png", + "prompt": "Use case: stylized-concept. Asset type: one small raster achievement emblem for a VS Code local usage tracker, same visual language as the inspected bundled icons: square off-white canvas, thick near-black rounded-square frame, bold friendly flat pictogram, subtle shadow, generous padding, legible at 56px. Primary request: Claude cumulative API-equivalent usage badge. Depict an orange pixel-like friendly crab robot beside three stacked accounting ledger tiles, each tile contains a simple resource line and a small orange API chip symbol. The ledger accumulation, not coins, signifies accumulated metered API resources. Palette off-white, charcoal outlines, warm coral orange, a small muted teal detail. Centered clean composition with no scene/background clutter. No text, numerals, dollar signs, letters, logos, currency, money bags, trophies or wealth imagery. The image communicates recorded resource accumulation, not financial gain or productivity. Produce exactly one square emblem, not a contact sheet." + }, + { + "file": "claude-api-week-v242.png", + "prompt": "Use case: stylized-concept. Asset type: one small raster achievement emblem for a VS Code local usage tracker. Primary request: An orange friendly crab robot beside a large simple calendar card with exactly seven small day squares arranged on a single row, a thin recording line underneath, and one small orange API chip. The closed seven-day calendar distinguishes a completed week. Style: off-white square canvas with a thick near-black rounded-square frame, friendly flat pictogram with bold dark outlines, subtle grounding shadow, generous padding and simple shapes legible at 56px. Match the previously generated cumulative Claude API resource emblem, warm coral orange robot, muted teal resource line, charcoal outlines. Centered clean composition. No text, numerals, letters, dollar signs, currency, logos, trophies or wealth imagery. This denotes recorded API resources, not money gained or productivity. Produce exactly one square emblem, never a contact sheet." + }, + { + "file": "claude-api-session-v242.png", + "prompt": "Use case: stylized-concept. Asset type: one small raster achievement emblem for a VS Code local usage tracker. Primary request: An orange friendly crab robot holding one large single resource ledger tile with a simple bracket-shaped session outline and a small orange API chip. One compact transcript/session is unmistakable, not a stack or calendar. Style: off-white square canvas with a thick near-black rounded-square frame, friendly flat pictogram with bold dark outlines, subtle grounding shadow, generous padding and simple shapes legible at 56px. Match the previously generated cumulative Claude API resource emblem, warm coral orange robot, muted teal resource line, charcoal outlines. Centered clean composition. No text, numerals, letters, dollar signs, currency, logos, trophies or wealth imagery. This denotes recorded API resources, not money gained or productivity. Produce exactly one square emblem, never a contact sheet." + }, + { + "file": "codex-api-total-v242.png", + "prompt": "Use case: stylized-concept. Asset type: one bundled raster achievement emblem for a VS Code usage tracker. Primary request: a small friendly blue cloud-shaped terminal robot with two short legs, beside three stacked resource ledger tiles with teal lines and blue API chip symbols; accumulated API resource records. Same visual language as generated orange Claude API icons and inspected Codex output icon: off-white square canvas, thick near-black rounded-square border, friendly flat illustrated pictogram with bold dark outlines, subtle shadow, generous padding, simple shape readable at 56px. Blue-violet robot, teal resource lines, near-black strokes. The robot face may show a simple white terminal chevron, not a word or logo. Centered clean composition. No text, letters, numerals, dollar signs, currency, logos, trophies, coin stacks or financial gain imagery. Depicts recorded API resources only. Exactly one square emblem, never a contact sheet." + }, + { + "file": "codex-api-week-v242.png", + "prompt": "Use case: stylized-concept. Asset type: one bundled raster achievement emblem for a VS Code usage tracker. Primary request: a small friendly blue cloud-shaped terminal robot beside a large simple calendar card with exactly seven day squares in one row, a teal recording line and a small blue API chip; a completed seven-day API resource window. Same visual language as generated orange Claude API icons and inspected Codex output icon: off-white square canvas, thick near-black rounded-square border, friendly flat illustrated pictogram with bold dark outlines, subtle shadow, generous padding, simple shape readable at 56px. Blue-violet robot, teal resource lines, near-black strokes. The robot face may show a simple white terminal chevron, not a word or logo. Centered clean composition. No text, letters, numerals, dollar signs, currency, logos, trophies, coin stacks or financial gain imagery. Depicts recorded API resources only. Exactly one square emblem, never a contact sheet." + }, + { + "file": "codex-api-task-v242.png", + "prompt": "Use case: stylized-concept. Asset type: one raster Codex single-task API-equivalent usage achievement emblem for a VS Code local tracker. Off-white square canvas, thick near-black rounded-square frame, friendly blue-violet cloud-shaped terminal robot, bold dark outlines, muted teal accents, subtle grounding shadow, generous padding. Match the cumulative and weekly API resource icons generated in this conversation. Primary subject: the small robot holds one large task resource ledger tile, with a simple compact connected task-tree glyph inside the tile: one top square linked to three small lower squares, and one small API chip at the lower corner. Exactly one task tree and one ledger tile, not a stack or calendar. Simple readable silhouette at 56px. No text, letters, numerals, dollar signs, coins, logos, trophies or wealth imagery. This represents metered resources in one verified task, not quality or money earned. Exactly one square emblem, never a contact sheet." + } + ] +} diff --git a/assets/achievements/astra-days-v242.png b/assets/achievements/astra-days-v242.png new file mode 100644 index 00000000..cf60262f Binary files /dev/null and b/assets/achievements/astra-days-v242.png differ diff --git a/assets/achievements/claude-api-session-v242-v2.png b/assets/achievements/claude-api-session-v242-v2.png new file mode 100644 index 00000000..dbd99b17 Binary files /dev/null and b/assets/achievements/claude-api-session-v242-v2.png differ diff --git a/assets/achievements/claude-api-session-v242.png b/assets/achievements/claude-api-session-v242.png new file mode 100644 index 00000000..fde804e1 Binary files /dev/null and b/assets/achievements/claude-api-session-v242.png differ diff --git a/assets/achievements/claude-api-total-v242-v2.png b/assets/achievements/claude-api-total-v242-v2.png new file mode 100644 index 00000000..ed4f75e5 Binary files /dev/null and b/assets/achievements/claude-api-total-v242-v2.png differ diff --git a/assets/achievements/claude-api-total-v242.png b/assets/achievements/claude-api-total-v242.png new file mode 100644 index 00000000..7ec2fcf2 Binary files /dev/null and b/assets/achievements/claude-api-total-v242.png differ diff --git a/assets/achievements/claude-api-week-v242-v2.png b/assets/achievements/claude-api-week-v242-v2.png new file mode 100644 index 00000000..839c66d0 Binary files /dev/null and b/assets/achievements/claude-api-week-v242-v2.png differ diff --git a/assets/achievements/claude-api-week-v242.png b/assets/achievements/claude-api-week-v242.png new file mode 100644 index 00000000..90d87a32 Binary files /dev/null and b/assets/achievements/claude-api-week-v242.png differ diff --git a/assets/achievements/claude-cache-creation-v242.png b/assets/achievements/claude-cache-creation-v242.png new file mode 100644 index 00000000..7ee7c46a Binary files /dev/null and b/assets/achievements/claude-cache-creation-v242.png differ diff --git a/assets/achievements/claude-cache-wizard.png b/assets/achievements/claude-cache-wizard.png new file mode 100644 index 00000000..aa756127 Binary files /dev/null and b/assets/achievements/claude-cache-wizard.png differ diff --git a/assets/achievements/claude-context-marathon.png b/assets/achievements/claude-context-marathon.png new file mode 100644 index 00000000..945007ea Binary files /dev/null and b/assets/achievements/claude-context-marathon.png differ diff --git a/assets/achievements/claude-fable-day-pass.png b/assets/achievements/claude-fable-day-pass.png new file mode 100644 index 00000000..6cab6067 Binary files /dev/null and b/assets/achievements/claude-fable-day-pass.png differ diff --git a/assets/achievements/claude-haiku-worker.png b/assets/achievements/claude-haiku-worker.png new file mode 100644 index 00000000..3f060c54 Binary files /dev/null and b/assets/achievements/claude-haiku-worker.png differ diff --git a/assets/achievements/claude-opus-cannon.png b/assets/achievements/claude-opus-cannon.png new file mode 100644 index 00000000..7d9fe35c Binary files /dev/null and b/assets/achievements/claude-opus-cannon.png differ diff --git a/assets/achievements/claude-output-sessions-v242.png b/assets/achievements/claude-output-sessions-v242.png new file mode 100644 index 00000000..3d18dc8a Binary files /dev/null and b/assets/achievements/claude-output-sessions-v242.png differ diff --git a/assets/achievements/claude-sonnet-main.png b/assets/achievements/claude-sonnet-main.png new file mode 100644 index 00000000..054e902c Binary files /dev/null and b/assets/achievements/claude-sonnet-main.png differ diff --git a/assets/achievements/codex-agent-standup.png b/assets/achievements/codex-agent-standup.png new file mode 100644 index 00000000..8341f7ae Binary files /dev/null and b/assets/achievements/codex-agent-standup.png differ diff --git a/assets/achievements/codex-api-task-v242-v2.png b/assets/achievements/codex-api-task-v242-v2.png new file mode 100644 index 00000000..e3ff26b7 Binary files /dev/null and b/assets/achievements/codex-api-task-v242-v2.png differ diff --git a/assets/achievements/codex-api-task-v242.png b/assets/achievements/codex-api-task-v242.png new file mode 100644 index 00000000..74f5e68e Binary files /dev/null and b/assets/achievements/codex-api-task-v242.png differ diff --git a/assets/achievements/codex-api-total-v242-v2.png b/assets/achievements/codex-api-total-v242-v2.png new file mode 100644 index 00000000..80a3bc28 Binary files /dev/null and b/assets/achievements/codex-api-total-v242-v2.png differ diff --git a/assets/achievements/codex-api-total-v242.png b/assets/achievements/codex-api-total-v242.png new file mode 100644 index 00000000..c0d0df5e Binary files /dev/null and b/assets/achievements/codex-api-total-v242.png differ diff --git a/assets/achievements/codex-api-week-v242-v2.png b/assets/achievements/codex-api-week-v242-v2.png new file mode 100644 index 00000000..961f7cac Binary files /dev/null and b/assets/achievements/codex-api-week-v242-v2.png differ diff --git a/assets/achievements/codex-api-week-v242.png b/assets/achievements/codex-api-week-v242.png new file mode 100644 index 00000000..82130865 Binary files /dev/null and b/assets/achievements/codex-api-week-v242.png differ diff --git a/assets/achievements/codex-astra-regular.png b/assets/achievements/codex-astra-regular.png new file mode 100644 index 00000000..b1078f76 Binary files /dev/null and b/assets/achievements/codex-astra-regular.png differ diff --git a/assets/achievements/codex-cache-read-v242.png b/assets/achievements/codex-cache-read-v242.png new file mode 100644 index 00000000..dfafa8a2 Binary files /dev/null and b/assets/achievements/codex-cache-read-v242.png differ diff --git a/assets/achievements/codex-cyber-foreman.png b/assets/achievements/codex-cyber-foreman.png new file mode 100644 index 00000000..6bce71a8 Binary files /dev/null and b/assets/achievements/codex-cyber-foreman.png differ diff --git a/assets/achievements/codex-effort-explorer.png b/assets/achievements/codex-effort-explorer.png new file mode 100644 index 00000000..c7a80a60 Binary files /dev/null and b/assets/achievements/codex-effort-explorer.png differ diff --git a/assets/achievements/codex-effort-spectrum.png b/assets/achievements/codex-effort-spectrum.png new file mode 100644 index 00000000..69addb4f Binary files /dev/null and b/assets/achievements/codex-effort-spectrum.png differ diff --git a/assets/achievements/codex-luna-regular.png b/assets/achievements/codex-luna-regular.png new file mode 100644 index 00000000..64703137 Binary files /dev/null and b/assets/achievements/codex-luna-regular.png differ diff --git a/assets/achievements/codex-multi-core-human.png b/assets/achievements/codex-multi-core-human.png new file mode 100644 index 00000000..a66b049a Binary files /dev/null and b/assets/achievements/codex-multi-core-human.png differ diff --git a/assets/achievements/codex-output-v242.png b/assets/achievements/codex-output-v242.png new file mode 100644 index 00000000..20a6e55a Binary files /dev/null and b/assets/achievements/codex-output-v242.png differ diff --git a/assets/achievements/codex-sol-regular.png b/assets/achievements/codex-sol-regular.png new file mode 100644 index 00000000..71ee0d4d Binary files /dev/null and b/assets/achievements/codex-sol-regular.png differ diff --git a/assets/achievements/codex-tiny-ai-company.png b/assets/achievements/codex-tiny-ai-company.png new file mode 100644 index 00000000..975233dc Binary files /dev/null and b/assets/achievements/codex-tiny-ai-company.png differ diff --git a/assets/achievements/combined-active-day-streak.png b/assets/achievements/combined-active-day-streak.png new file mode 100644 index 00000000..237686fc Binary files /dev/null and b/assets/achievements/combined-active-day-streak.png differ diff --git a/assets/achievements/combined-billion-token-club.png b/assets/achievements/combined-billion-token-club.png new file mode 100644 index 00000000..f6ef1148 Binary files /dev/null and b/assets/achievements/combined-billion-token-club.png differ diff --git a/assets/achievements/combined-dashboard-archaeologist.png b/assets/achievements/combined-dashboard-archaeologist.png new file mode 100644 index 00000000..0f4bb255 Binary files /dev/null and b/assets/achievements/combined-dashboard-archaeologist.png differ diff --git a/assets/achievements/combined-fourteen-day-streak.png b/assets/achievements/combined-fourteen-day-streak.png new file mode 100644 index 00000000..eed55efc Binary files /dev/null and b/assets/achievements/combined-fourteen-day-streak.png differ diff --git a/assets/achievements/combined-hundred-billion-token-club.png b/assets/achievements/combined-hundred-billion-token-club.png new file mode 100644 index 00000000..92318702 Binary files /dev/null and b/assets/achievements/combined-hundred-billion-token-club.png differ diff --git a/assets/achievements/combined-hundred-million-club.png b/assets/achievements/combined-hundred-million-club.png new file mode 100644 index 00000000..744a2a3e Binary files /dev/null and b/assets/achievements/combined-hundred-million-club.png differ diff --git a/assets/achievements/combined-model-rainbow.png b/assets/achievements/combined-model-rainbow.png new file mode 100644 index 00000000..4d870048 Binary files /dev/null and b/assets/achievements/combined-model-rainbow.png differ diff --git a/assets/achievements/combined-quiet-legend.png b/assets/achievements/combined-quiet-legend.png new file mode 100644 index 00000000..504ce39f Binary files /dev/null and b/assets/achievements/combined-quiet-legend.png differ diff --git a/assets/achievements/combined-settings-tourist.png b/assets/achievements/combined-settings-tourist.png new file mode 100644 index 00000000..4851063e Binary files /dev/null and b/assets/achievements/combined-settings-tourist.png differ diff --git a/assets/achievements/combined-share-it.png b/assets/achievements/combined-share-it.png new file mode 100644 index 00000000..085f2ae5 Binary files /dev/null and b/assets/achievements/combined-share-it.png differ diff --git a/assets/achievements/combined-ten-billion-token-club.png b/assets/achievements/combined-ten-billion-token-club.png new file mode 100644 index 00000000..c4c9e596 Binary files /dev/null and b/assets/achievements/combined-ten-billion-token-club.png differ diff --git a/assets/achievements/combined-ten-million-club.png b/assets/achievements/combined-ten-million-club.png new file mode 100644 index 00000000..6d1234c9 Binary files /dev/null and b/assets/achievements/combined-ten-million-club.png differ diff --git a/assets/achievements/combined-three-am-partner.png b/assets/achievements/combined-three-am-partner.png new file mode 100644 index 00000000..7deadfc9 Binary files /dev/null and b/assets/achievements/combined-three-am-partner.png differ diff --git a/assets/achievements/dual-provider-day-v242.png b/assets/achievements/dual-provider-day-v242.png new file mode 100644 index 00000000..fdcee291 Binary files /dev/null and b/assets/achievements/dual-provider-day-v242.png differ diff --git a/assets/achievements/dual-provider-days-v242.png b/assets/achievements/dual-provider-days-v242.png new file mode 100644 index 00000000..e7c3b498 Binary files /dev/null and b/assets/achievements/dual-provider-days-v242.png differ diff --git a/assets/achievements/fable-days-v242.png b/assets/achievements/fable-days-v242.png new file mode 100644 index 00000000..951045d4 Binary files /dev/null and b/assets/achievements/fable-days-v242.png differ diff --git a/assets/achievements/fable-relay-v242.png b/assets/achievements/fable-relay-v242.png new file mode 100644 index 00000000..6dd7e3df Binary files /dev/null and b/assets/achievements/fable-relay-v242.png differ diff --git a/assets/achievements/fable-sessions-v242.png b/assets/achievements/fable-sessions-v242.png new file mode 100644 index 00000000..74b3e020 Binary files /dev/null and b/assets/achievements/fable-sessions-v242.png differ diff --git a/assets/achievements/haiku-days-v242.png b/assets/achievements/haiku-days-v242.png new file mode 100644 index 00000000..e922ae8a Binary files /dev/null and b/assets/achievements/haiku-days-v242.png differ diff --git a/assets/achievements/local-advice-feedback-v242.png b/assets/achievements/local-advice-feedback-v242.png new file mode 100644 index 00000000..19410fcc Binary files /dev/null and b/assets/achievements/local-advice-feedback-v242.png differ diff --git a/assets/achievements/luna-days-v242.png b/assets/achievements/luna-days-v242.png new file mode 100644 index 00000000..04ce57cc Binary files /dev/null and b/assets/achievements/luna-days-v242.png differ diff --git a/assets/achievements/model-relay-roots-v242.png b/assets/achievements/model-relay-roots-v242.png new file mode 100644 index 00000000..ecd09bc3 Binary files /dev/null and b/assets/achievements/model-relay-roots-v242.png differ diff --git a/assets/achievements/opus-days-v242.png b/assets/achievements/opus-days-v242.png new file mode 100644 index 00000000..1712e3d9 Binary files /dev/null and b/assets/achievements/opus-days-v242.png differ diff --git a/assets/achievements/output-hours-v242.png b/assets/achievements/output-hours-v242.png new file mode 100644 index 00000000..a09a8c33 Binary files /dev/null and b/assets/achievements/output-hours-v242.png differ diff --git a/assets/achievements/root-portfolio-v242.png b/assets/achievements/root-portfolio-v242.png new file mode 100644 index 00000000..80561c8a Binary files /dev/null and b/assets/achievements/root-portfolio-v242.png differ diff --git a/assets/achievements/safe-feature-journey-v242.png b/assets/achievements/safe-feature-journey-v242.png new file mode 100644 index 00000000..4186a04e Binary files /dev/null and b/assets/achievements/safe-feature-journey-v242.png differ diff --git a/assets/achievements/sol-days-v242.png b/assets/achievements/sol-days-v242.png new file mode 100644 index 00000000..02aa970a Binary files /dev/null and b/assets/achievements/sol-days-v242.png differ diff --git a/assets/achievements/sonnet-days-v242.png b/assets/achievements/sonnet-days-v242.png new file mode 100644 index 00000000..b19cd0a1 Binary files /dev/null and b/assets/achievements/sonnet-days-v242.png differ diff --git a/assets/achievements/three-family-day-v242.png b/assets/achievements/three-family-day-v242.png new file mode 100644 index 00000000..e7c8d539 Binary files /dev/null and b/assets/achievements/three-family-day-v242.png differ diff --git a/assets/achievements/weekly-average-v242.png b/assets/achievements/weekly-average-v242.png new file mode 100644 index 00000000..7bbd67b4 Binary files /dev/null and b/assets/achievements/weekly-average-v242.png differ diff --git a/assets/achievements/weekly-volume-v242.png b/assets/achievements/weekly-volume-v242.png new file mode 100644 index 00000000..175ee22e Binary files /dev/null and b/assets/achievements/weekly-volume-v242.png differ diff --git a/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md b/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md index 56163776..14182550 100644 --- a/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md +++ b/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md @@ -2,7 +2,15 @@ Status: normative v2.3.1 specification. Items marked **migration input** describe legacy storage that v2.3.1 must import or clear; they are not the desired final shape. -This document is the source of truth for data read, retained, derived, displayed, exported, or sent by the extension. Any new persistent field must update this contract, its Chinese counterpart, migration and clearing logic, privacy tests, and the README privacy table in the same change. +This document is the field-level baseline for v2.3.1 data read, retained, derived, +displayed, exported or sent by the extension, not a complete inventory of later +candidate fields. The current v2.4/2.4.2 supplement is [LOCAL-DATA.md](../../../LOCAL-DATA.md), +with its [Chinese counterpart](../../../LOCAL-DATA.zh-CN.md): explicitly later +sections govern added sharing keys, runtime quota pace and the P12-equivalent +achievement envelope. Unchanged baseline restrictions remain applicable. Any new +persistent field must update the applicable versioned contract/supplement, its +Chinese counterpart, migration and clearing logic, privacy tests and the README +privacy table in the same change. ## 1. Non-negotiable rules @@ -130,7 +138,29 @@ For each valid observation in one stable fingerprint/window: candidateFullEquivalent = pricedUsedEquivalentThroughObservedAt / usedFraction ``` -Candidates require `0 < usedFraction <= 1`. They are robustly aggregated with weights for price coverage, log/index coverage, time-boundary quality, account attribution, and observation recency. The displayed total is never below final confirmed used equivalent. Whenever one coherent window produces a valid total, the dashboard also shows `max(full - confirmedUsed, 0)` for completed and in-progress windows, except when usage recorded after the latest observation has already overrun that observation-derived candidate; in that stale-observation case the total remains a low-confidence lower bound and unused value is withheld rather than presenting a false `$0`. Unattributed, boundary-approximate, partially priced, or locally multi-sign-in evidence remains visible but is forced to low confidence and described as a local-history durability estimate—not an official account balance. Actually overlapping quota series or competing mapped epochs still fall back to used equivalent only; no account split is invented. +Candidates require `0 < usedFraction <= 1`. They use an evidence-weighted median, +with pricing/log coverage, boundary quality, account attribution and observation +timing contributing to weights or confidence. The displayed total is never below +the known used equivalent. With a valid candidate, completed and in-progress +windows may show `max(full - used, 0)`; if cumulative known used value exceeds the aggregate candidate, +retain used as a low-confidence lower bound and withhold unused rather than show +a fabricated `$0`. Unattributed, boundary-approximate, low-price-coverage or locally +multi-sign-in evidence remains low-confidence local-history estimation, not an +official account balance. Completed Codex display windows with multiple distinct +mapped `seriesKey` values fall back to used equivalent only. One unattributed or +account-ambiguous series can retain a low-confidence estimate after completion; +no account split is invented. Overrun compares the cumulative used amount with +the weighted-median candidate, not new additions with a single sample. + +Current v2.4 clarification, approved by the maintainer on 2026-10-11: preserve +the existing current-period total/unused calculation, including low-confidence +local estimates when Codex account attribution or current series overlap is +uncertain. Eligible home/window usage and the latest applicable observation +cluster are not proven to describe one account. These are API-equivalent +reference values, not remaining credit or promised subscription capacity. +Accurate per-account calculation awaits future multi-account compatibility and +verified account/window ownership. This does not relax fingerprint separation, +change `weeklyValue.ts` or enable untrusted `showWeeklyQuotaPace` forecasts. Full/unused estimates are not persisted facts. They are recomputed when pricing, coverage, logs, or observation evidence changes. diff --git a/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md b/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md index 8e31137a..1dbbcb3f 100644 --- a/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md +++ b/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md @@ -2,7 +2,11 @@ 状态:v2.3.1 规范性说明。标记为 **迁移输入** 的项目描述旧版存储,只能用于迁移或清除,不是最终目标结构。 -本文件是插件读取、保留、派生、展示、导出或发送数据的事实来源。任何新增持久字段,都必须在同一变更中更新本文件、英文版、迁移与清除逻辑、隐私测试和 README 隐私表。 +本文件是 v2.3.1 读取、保留、派生、展示、导出或发送数据的字段级基线,不是后续 +候选字段的完整清单。当前 v2.4/2.4.2 补充见 [LOCAL-DATA.zh-CN.md](../../../LOCAL-DATA.zh-CN.md) +及其[英文版](../../../LOCAL-DATA.md):明确的后续章节规范新增分享键、运行期额度节奏和 +对应 P12 的成就封套;未变化的基线限制继续适用。任何新增持久字段,都必须在同一 +变更中更新适用的版本化契约/补充及中英文、迁移与清除逻辑、隐私测试和 README 隐私表。 ## 1. 不可放宽的原则 @@ -118,7 +122,21 @@ interface QuotaObservationV2 { candidateFullEquivalent = pricedUsedEquivalentThroughObservedAt / usedFraction ``` -只接受 `0 < usedFraction <= 1`。多个候选按定价覆盖、日志/索引覆盖、边界质量、账号归属和时效做稳健聚合。展示的总额度不得低于最终确认的已用等价值。只要一个内部一致的窗口能产生有效总额,已结束和进行中的窗口都显示 `max(full - confirmedUsed, 0)`;但如果最后一次观测之后新增的日志已超过该观测推导出的候选总额,则把总额保留为低可信度下界,并暂不显示未用值,避免伪造 `$0`。未归属、边界近似、低定价覆盖或本地多登录证据仍可展示,但必须强制为低可信度,并明确称为“本地历史耐用度估算”,而非官方账号余额。真正重叠的额度系列或竞争性的映射 epoch 仍只显示已用等价值,不虚构账号拆分。 +只接受 `0 < usedFraction <= 1`。多个候选取证据加权中位数,定价/日志覆盖、边界质量、 +账号归属和观测时点参与权重或可信度。展示总等价值不得低于已知已用等价值。有有效 +候选时,已结束和进行中的窗口可显示 `max(full - used, 0)`;若累计已知已用值超过汇总候选,则 +保留已用值作为低可信下界,撤下未用值,不伪造 `$0`。未归属、边界近似、低定价覆盖 +或本地多登录证据仅是低可信本地历史估算,不是官方账号余额。多个不同 `seriesKey` +映射到同一已结束 Codex 展示窗口时,仅显示已用等价值;单一未归属或带账号歧义标记的 +系列,结束后仍可保留低可信估算,不虚构账号拆分。超额比较累计已用值与加权中位数候选, +不是比较新增量与单个样本。 + +当前v2.4口径澄清,经维护者于2026-10-11确认:保留已有当前周期总额/未用计算, +包括 Codex 账号归属或当前系列重叠不明时的低可信本地估算。合格 home/窗口用量与 +最近适用的观测簇并未被证明属于同一账号;结果是 API 等价参考值,不是剩余余额或 +订阅可用量承诺。准确按账号计算留待后续多账户兼容建立可验证的账号/窗口归属。 +这不放宽 fingerprint 隔离、不改变 `weeklyValue.ts`,也不启用不可信来源的 +`showWeeklyQuotaPace` 预测。 总额/未用估算不作为永久事实落盘;单价、覆盖、日志或观测变化后重新计算。 diff --git a/package.json b/package.json index a62108c2..eb01a607 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,11 @@ } }, "commands": [ + { + "command": "claudeCodeUsage.recalibrateAchievements", + "title": "Recalibrate Local Achievements", + "category": "Claude Code Usage" + }, { "command": "claudeCodeUsage.refresh", "title": "Refresh Usage Data", diff --git a/src/achievementActivity.ts b/src/achievementActivity.ts new file mode 100644 index 00000000..cfa616c2 --- /dev/null +++ b/src/achievementActivity.ts @@ -0,0 +1,81 @@ +/** Minimal, local evidence of deliberate dashboard actions. Never store values. */ +export interface AchievementActivity { + schemaVersion: 2; + settingKeys: string[]; + features: AchievementFeature[]; +} + +export type AchievementFeature = + 'achievementGalleryViewed' | 'achievementSharePreview' | + 'localShareExport' | 'localAdviceFeedback'; + +// Public, low-risk dashboard controls only. Paths, secrets, network/consent and +// provider collection switches must never become an achievement requirement. +export const ACHIEVEMENT_SETTING_KEYS = [ + 'compactNumbers', 'dashboardAutoRefresh', 'decimalPlaces', 'displayCurrency', + 'enableShareCard', 'fileWatchSeconds', 'language', 'projectGroupingMode', + 'quotaFiveHourOnly', 'refreshInterval', 'releaseAnnouncements', + 'resetCountdownFormat', 'showContext', 'showCost', 'showCostliestMessages', + 'showEfficiency', 'showHeatmap', 'showInsights', 'showProjectUsageMatrix', + 'showResetInStatusBar', 'showScopedWeekly', 'showWeeklyEquivalentValue', + 'statusBarMetric', 'statusBarProvider', 'statusBarQuotaFormat', 'timezone', + 'tokenDecimalPlaces', 'workflowQuotaWarnPercent', 'codex.statusMetric', +] as const; + +const SETTING_KEYS = new Set(ACHIEVEMENT_SETTING_KEYS); +export const SAFE_EXPLORATION_FEATURES: readonly AchievementFeature[] = [ + 'achievementGalleryViewed', 'achievementSharePreview', 'localShareExport', +]; +const FEATURES = new Set([ + ...SAFE_EXPLORATION_FEATURES, 'localAdviceFeedback', +]); + +export function emptyAchievementActivity(): AchievementActivity { + return { schemaVersion: 2, settingKeys: [], features: [] }; +} + +export function isAchievementSettingKey(key: string): boolean { + return SETTING_KEYS.has(key); +} + +function normalizeAchievementActivity(raw: unknown, version: 1 | 2): AchievementActivity { + if (!raw || typeof raw !== 'object' || (raw as { schemaVersion?: unknown }).schemaVersion !== version) { + return emptyAchievementActivity(); + } + const state = raw as { settingKeys?: unknown; features?: unknown }; + return { + schemaVersion: 2, + settingKeys: Array.isArray(state.settingKeys) + ? [...new Set(state.settingKeys.filter((key): key is string => + typeof key === 'string' && SETTING_KEYS.has(key)))].sort() + : [], + features: Array.isArray(state.features) + ? [...new Set(state.features.filter((feature): feature is AchievementFeature => + typeof feature === 'string' && FEATURES.has(feature as AchievementFeature) && + (version === 2 || feature !== 'achievementSharePreview')))].sort() + : [], + }; +} + +export function parseAchievementActivity(raw: unknown): AchievementActivity { + return normalizeAchievementActivity(raw, 2); +} + +/** v1 could record a restored preview as a deliberate action. Drop only that fact. */ +export function migrateAchievementActivityV1(raw: unknown): AchievementActivity { + return normalizeAchievementActivity(raw, 1); +} + +export function recordAchievementSettingChange( + state: AchievementActivity, key: string, changed: boolean, +): AchievementActivity { + if (!changed || !SETTING_KEYS.has(key) || state.settingKeys.includes(key)) return state; + return { ...state, settingKeys: [...state.settingKeys, key].sort() }; +} + +export function recordAchievementFeature( + state: AchievementActivity, feature: AchievementFeature, +): AchievementActivity { + if (!FEATURES.has(feature) || state.features.includes(feature)) return state; + return { ...state, features: [...state.features, feature].sort() }; +} diff --git a/src/achievementCalendar.ts b/src/achievementCalendar.ts new file mode 100644 index 00000000..53e64e57 --- /dev/null +++ b/src/achievementCalendar.ts @@ -0,0 +1,435 @@ +import { claudeAchievementFamily, codexModelFamily, normalizeCodexAchievementModel } from './achievementModels'; +import type { CodexFileAggregate } from './providers/codex/codexIndex'; +import { CODEX_PERIOD_LINEAGE_VERSION } from './providers/codex/codexPeriodIndex'; +import { createCodexParserState } from './providers/codex/codexParser'; +import type { ProviderTokenCounts } from './providers/providerTypes'; +import type { UsageData } from './types'; +import { verifiedCodexAchievementFiles } from './codexAchievementProof'; +import { codexAchievementProofRevision, isImmutableCodexAchievementFiles } from './codexAchievementRevision'; + +/** Numeric, already-materialized calendar evidence; no source-log I/O. */ +export interface AchievementDay { + day: string; + processed: number; + output: number; + models: Record; +} +export interface AchievementDayEvidence { + complete: boolean; days: AchievementDay[]; timeZone?: string; + /** Positive dated components only. Missing dates never mean verified zero. */ + scope?: 'verified-subset'; + /** Runtime census/provenance binding, not persisted, rendered or exported. */ + sourceRevision?: string; +} +export interface AchievementTokenEvidence { complete: boolean; output: number; cachedInput: number; lowerBound?: true } +export interface AchievementCalendarSignals { + familyDays: Partial>; + dualProviderDays: number; + activeWeeks: number; + threeFamilyDay: boolean; + dualProviderDay: boolean; + peakFourWeekAverage: number; + /** True only after both normalized day proofs pass validation, not a UI flag. */ + peakEvidenceComplete?: boolean; + claudeEvidenceComplete?: boolean; + codexEvidenceComplete?: boolean; + codexEvidenceUsable?: boolean; +} +const DAY_MS = 86_400_000; +type Family = keyof AchievementCalendarSignals['familyDays']; +const FAMILIES: Family[] = ['sonnet', 'opus', 'haiku', 'fable', 'astra', 'sol', 'luna']; +const count = (n: unknown): n is number => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0; +const object = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export interface CalendarPeakContext { + ruleVersion: number; + parserVersion: number; + periodVersion: number; + timeZone: string; + providers: number; +} +export interface CalendarPeakEvidence extends CalendarPeakContext { + schemaVersion: 1; + peakFourWeekAverage: number; +} +const CALENDAR_PEAK_RULE_VERSION = 1; +const CURRENT_PARSER_VERSION = createCodexParserState('achievement-version').schemaVersion; +export function calendarPeakContext(timeZone: string, claude: boolean, codex: boolean): CalendarPeakContext { + return { ruleVersion: CALENDAR_PEAK_RULE_VERSION, parserVersion: CURRENT_PARSER_VERSION, + periodVersion: CODEX_PERIOD_LINEAGE_VERSION, timeZone, providers: Number(claude) + 2 * Number(codex) }; +} +/** Rebuild an allowlisted DTO; never retain unknown source or personal fields. */ +export function parseCalendarPeakEvidence(value: unknown): CalendarPeakEvidence | undefined { + if (!object(value) || value.schemaVersion !== 1 || !count(value.peakFourWeekAverage) || + !count(value.ruleVersion) || value.ruleVersion < 1 || !count(value.parserVersion) || value.parserVersion < 1 || + !count(value.periodVersion) || value.periodVersion < 1 || !count(value.providers) || + value.providers < 1 || value.providers > 3 || typeof value.timeZone !== 'string' || !validTimeZone(value.timeZone)) return undefined; + return { schemaVersion: 1, ruleVersion: value.ruleVersion, parserVersion: value.parserVersion, + periodVersion: value.periodVersion, timeZone: value.timeZone, providers: value.providers, + peakFourWeekAverage: value.peakFourWeekAverage }; +} +function samePeakContext(saved: CalendarPeakEvidence, current: CalendarPeakContext): boolean { + return saved.ruleVersion === current.ruleVersion && saved.timeZone === current.timeZone && + saved.providers === current.providers && + ((current.providers & 2) === 0 || (saved.parserVersion === current.parserVersion && + saved.periodVersion === current.periodVersion)); +} +export function readCalendarPeakEvidence(value: unknown, context: CalendarPeakContext, complete: boolean): number { + const saved = parseCalendarPeakEvidence(value); + return complete && saved && samePeakContext(saved, context) ? saved.peakFourWeekAverage : 0; +} +export function updateCalendarPeakEvidence(previous: CalendarPeakEvidence | undefined, context: CalendarPeakContext, + peak: number, complete: boolean): CalendarPeakEvidence | undefined { + if (!complete || !count(peak) || peak === 0) return previous; + if (previous && readCalendarPeakEvidence(previous, context, true) >= peak) return previous; + return parseCalendarPeakEvidence({ schemaVersion: 1, ...context, peakFourWeekAverage: peak }) ?? previous; +} +function validTimeZone(timeZone: string): boolean { + if (typeof timeZone !== 'string' || !timeZone) return false; + try { new Intl.DateTimeFormat('en-GB', { timeZone }); return true; } catch { return false; } +} +function dayTimestamp(day: string): number { + const time = /^\d{4}-\d{2}-\d{2}$/.test(day) ? Date.parse(day + 'T00:00:00.000Z') : NaN; + return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === day ? time : NaN; +} +function monday(time: number): number { + return time - ((new Date(time).getUTCDay() + 6) % 7) * DAY_MS; +} +/** Undefined is invalid/incomplete; an empty Map is verified empty evidence. */ +function validatedDays(evidence: AchievementDayEvidence, asOf: number, additiveContributions = false): Map | undefined { + const rows = new Map(); + if (!object(evidence) || (evidence.complete !== true && evidence.scope !== 'verified-subset') || + !Array.isArray(evidence.days)) return undefined; + for (const row of evidence.days) { + if (!object(row) || typeof row.day !== 'string' || !object(row.models)) return undefined; + if (!additiveContributions && rows.has(row.day)) return undefined; + const time = dayTimestamp(row.day); + if (!Number.isFinite(time) || !count(row.processed) || !count(row.output) || row.output > row.processed) return undefined; + const target = rows.get(row.day) ?? { day: row.day, processed: 0, output: 0, models: {} }; + target.processed += row.processed; + target.output += row.output; + if (!count(target.processed) || !count(target.output)) return undefined; + let modelProcessed = 0; + let modelOutput = 0; + for (const [model, tokens] of Object.entries(row.models)) { + if (!object(tokens) || !count(tokens.processed) || !count(tokens.output) || tokens.output > tokens.processed) return undefined; + modelProcessed += tokens.processed; + modelOutput += tokens.output; + const bucket = Object.prototype.hasOwnProperty.call(target.models, model) + ? target.models[model] : { processed: 0, output: 0 }; + bucket.processed += tokens.processed; + bucket.output += tokens.output; + if (!count(bucket.processed) || !count(bucket.output)) return undefined; + Object.defineProperty(target.models, model, { value: bucket, enumerable: true, configurable: true, writable: true }); + } + if (!count(modelProcessed) || !count(modelOutput) || modelProcessed > row.processed || modelOutput > row.output) return undefined; + rows.set(row.day, target); + } + // Civil dates are already bucketed in the provider's zone. UTC arithmetic is + // only an ordinal for those labels, so DST never changes a week or its divisor. + for (const date of rows.keys()) if (dayTimestamp(date) >= asOf) rows.delete(date); + return rows; +} + +export function achievementCalendarSignals(input: { + claude: AchievementDayEvidence; codex: AchievementDayEvidence; asOfDay: string; +}): AchievementCalendarSignals { + const result: AchievementCalendarSignals = { familyDays: Object.fromEntries(FAMILIES.map(f => [f, 0])), dualProviderDays: 0, activeWeeks: 0, + threeFamilyDay: false, dualProviderDay: false, peakFourWeekAverage: 0, peakEvidenceComplete: false, + claudeEvidenceComplete: false, codexEvidenceComplete: false }; + const asOf = dayTimestamp(input.asOfDay); + if (!Number.isFinite(asOf)) return result; + if (!input.claude.timeZone || !validTimeZone(input.claude.timeZone) || + input.claude.timeZone !== input.codex.timeZone) return result; + const claudeProof = validatedDays(input.claude, asOf); + const codexProof = validatedDays(input.codex, asOf); + result.claudeEvidenceComplete = !!claudeProof && input.claude.complete === true; + result.codexEvidenceComplete = !!codexProof && input.codex.complete === true; + result.codexEvidenceUsable = !!codexProof; + const claude = claudeProof ?? new Map(); + const codex = codexProof ?? new Map(); + const familyDates = new Map>(); + for (const [rows, provider] of [[claude, 'claude'], [codex, 'codex']] as const) { + for (const [date, row] of rows) { + const families = familyDates.get(date) ?? new Map(); + for (const [model, tokens] of Object.entries(row.models)) { + const codexModel = normalizeCodexAchievementModel(model); + const family = provider === 'claude' ? claudeAchievementFamily(model) : codexModel ? codexModelFamily(codexModel) : null; + if (!family) continue; + const bucket = families.get(family) ?? { processed: 0, output: 0 }; + bucket.processed += tokens.processed; + bucket.output += tokens.output; + if (!count(bucket.processed) || !count(bucket.output)) return result; + families.set(family, bucket); + } + familyDates.set(date, families); + } + } + for (const families of familyDates.values()) { + let qualified = 0; + for (const [family, tokens] of families) { + if (tokens.processed >= 1_000_000 && tokens.output > 0) result.familyDays[family]!++; + if (tokens.processed >= 1_000_000 && tokens.output > 0) qualified++; + } + result.threeFamilyDay ||= qualified >= 3; + } + if (result.claudeEvidenceComplete && result.codexEvidenceComplete) { + for (const [date, row] of claude) { + const other = codex.get(date); + if (row.processed >= 1_000_000 && row.output > 0 && other && other.processed >= 1_000_000 && other.output > 0) result.dualProviderDays++; + } + } + result.dualProviderDay = result.dualProviderDays > 0; + const weekTotals = new Map(); + const weekOutputDays = new Map>(); + let first = Infinity; + for (const rows of [claude, codex]) for (const [date, row] of rows) { + if (row.processed === 0) continue; + const time = dayTimestamp(date); + first = Math.min(first, time); + const week = monday(time); + const total = (weekTotals.get(week) ?? 0) + row.processed; + if (!count(total)) return { ...result, activeWeeks: 0, peakFourWeekAverage: 0 }; + weekTotals.set(week, total); + if (row.output > 0 && row.processed >= 1_000_000) { + const dates = weekOutputDays.get(week) ?? new Set(); + dates.add(date); + weekOutputDays.set(week, dates); + } + } + const currentWeek = monday(asOf); + result.activeWeeks = [...weekOutputDays].filter(([week, days]) => week < currentWeek && days.size >= 4).length; + // Both present providers must be complete. A missing provider is an explicit + // complete-empty input; incomplete evidence never supplies a combined mean. + if (!result.claudeEvidenceComplete || !result.codexEvidenceComplete) return result; + result.peakEvidenceComplete = true; + if (!Number.isFinite(first)) return result; + const firstWeek = monday(first) + (first === monday(first) ? 0 : 7 * DAY_MS); + // Examine only windows containing an observed week: O(number of data weeks), + // not an unbounded walk across empty years. Zero weeks remain in the divisor. + const starts = new Set(); + for (const week of weekTotals.keys()) for (let back = 0; back < 4; back++) starts.add(week - back * 7 * DAY_MS); + for (const start of starts) { + if (start < firstWeek || start + 4 * 7 * DAY_MS > currentWeek) continue; + let sum = 0; + for (let offset = 0; offset < 4; offset++) sum += weekTotals.get(start + offset * 7 * DAY_MS) ?? 0; + if (count(sum)) result.peakFourWeekAverage = Math.max(result.peakFourWeekAverage, Math.floor(sum / 4)); + } + return result; +} + +const TOKEN_FIELDS = ['inputTotal', 'outputTotal', 'cachedInput', 'reasoningOutput', 'cacheWriteInput', 'sourceTotal'] as const; +function validTokens(tokens: unknown): tokens is ProviderTokenCounts { + return object(tokens) && count(tokens.inputTotal) && count(tokens.outputTotal) && + count(tokens.inputTotal + tokens.outputTotal) && + TOKEN_FIELDS.every(field => tokens[field] === undefined || count(tokens[field])) && + (tokens.cachedInput === undefined || (tokens.cachedInput as number) <= tokens.inputTotal) && + (tokens.reasoningOutput === undefined || (tokens.reasoningOutput as number) <= tokens.outputTotal); +} +function sumTokens(buckets: readonly ProviderTokenCounts[]): ProviderTokenCounts | undefined { + const total: ProviderTokenCounts = { inputTotal: 0, outputTotal: 0 }; + for (const bucket of buckets) { + if (!validTokens(bucket)) return undefined; + for (const field of TOKEN_FIELDS) { + const next = (total[field] ?? 0) + (bucket[field] ?? 0); + if (!count(next)) return undefined; + total[field] = next; + } + } + return validTokens(total) ? total : undefined; +} +function tokensFit(actual: ProviderTokenCounts, allowed: ProviderTokenCounts): boolean { + // An omitted optional allowance is unknown, consistent with the period DTO. + return TOKEN_FIELDS.every(field => allowed[field] === undefined || (actual[field] ?? 0) <= allowed[field]!); +} +function validBuckets(buckets: unknown, total: ProviderTokenCounts): buckets is Record { + if (!object(buckets)) return false; + const values = Object.values(buckets); + if (!values.every(validTokens)) return false; + const sum = sumTokens(values); + return !!sum && tokensFit(sum, total); +} + +/** Callers supply only canonical, reconciled files and their host completeness fence. */ +function validCanonicalFiles(files: readonly CodexFileAggregate[]): boolean { + if (!Array.isArray(files)) return false; + const parents = new Map(); + for (const file of files) { + if (!object(file) || !object(file.session) || !validTokens(file.total) || !validBuckets(file.byModel, file.total)) return false; + const { sessionKey, parentSessionKey, role } = file.session; + if (typeof sessionKey !== 'string' || !sessionKey.trim() || parents.has(sessionKey) || + (parentSessionKey !== undefined && (typeof parentSessionKey !== 'string' || !parentSessionKey.trim())) || + ((role === 'subagent' || role === 'approval-reviewer') && parentSessionKey === undefined)) return false; + parents.set(sessionKey, parentSessionKey); + } + // Iterative graph walk handles deep ancestry without recursion or O(n²) walks. + const verified = new Set(); + for (const session of parents.keys()) { + const path = new Set(); + let current: string | undefined = session; + while (current !== undefined && !verified.has(current)) { + if (!parents.has(current) || path.has(current)) return false; + path.add(current); + current = parents.get(current); + } + for (const key of path) verified.add(key); + } + return true; +} + +/** No date projection is required for all-time output/cache proof. */ +export function codexAchievementTokenEvidence(files: readonly CodexFileAggregate[], complete: boolean): AchievementTokenEvidence { + const fail = (): AchievementTokenEvidence => ({ complete: false, output: 0, cachedInput: 0 }); + if (complete !== true || !Array.isArray(files)) return fail(); + const verified = verifiedCodexAchievementFiles(files); + if (files.length && !verified.length) return fail(); + const total = sumTokens(verified.map(file => file.total)); + return total ? { complete: true, output: total.outputTotal, cachedInput: total.cachedInput ?? 0, + ...(verified.length < files.length ? { lowerBound: true as const } : {}) } : fail(); +} + +/** Validate original dated components without confusing containment with coverage. */ +function codexFileDays(file: CodexFileAggregate, timeZone: string): AchievementDayEvidence | undefined { + const days: AchievementDay[] = []; + const period = file.period; + if (!object(period) || period.timeZone !== timeZone || period.lineageVersion !== CODEX_PERIOD_LINEAGE_VERSION || + !count(period.lineagePrefixEvents) || !count(period.indexedThrough) || + !object(period.days) || !validTokens(file.total) || !validBuckets(file.byModel, file.total)) return undefined; + const periodTotals: ProviderTokenCounts[] = []; + const periodModels = new Map(); + for (const [date, slice] of Object.entries(period.days)) { + if (!Number.isFinite(dayTimestamp(date)) || !object(slice) || !validTokens(slice.total) || + !validBuckets(slice.byModel, slice.total) || !validBuckets(slice.byEffort, slice.total)) return undefined; + periodTotals.push(slice.total); + for (const [model, tokens] of Object.entries(slice.byModel)) { + const sum = sumTokens([periodModels.get(model) ?? { inputTotal: 0, outputTotal: 0 }, tokens]); + const allowed = Object.prototype.hasOwnProperty.call(file.byModel, model) ? file.byModel[model] : undefined; + if (!sum || !allowed || !tokensFit(sum, allowed)) return undefined; + periodModels.set(model, sum); + } + const models = Object.fromEntries(Object.entries(slice.byModel).map(([model, tokens]) => + [model, { processed: tokens.inputTotal + tokens.outputTotal, output: tokens.outputTotal }])); + days.push({ day: date, processed: slice.total.inputTotal + slice.total.outputTotal, output: slice.total.outputTotal, models }); + } + const total = sumTokens(periodTotals); + if (!total || !tokensFit(total, file.total)) return undefined; + const equal = (a: ProviderTokenCounts, b: ProviderTokenCounts): boolean => + TOKEN_FIELDS.every(field => b[field] === undefined || (a[field] ?? 0) === b[field]); + const complete = equal(total, file.total) && Object.entries(file.byModel).every(([model, tokens]) => + equal(periodModels.get(model) ?? { inputTotal: 0, outputTotal: 0 }, tokens)); + return { complete, days, timeZone, ...(!complete ? { scope: 'verified-subset' as const } : {}) }; +} +/** Period proof is independent of byDay's legacy UTC projection. The caller + * supplies its complete-census/provenance fence; host snapshots use the stricter + * runtime-witness projection below. */ +export function codexAchievementDayEvidence(files: readonly CodexFileAggregate[], timeZone: string, complete: boolean): AchievementDayEvidence { + const fail = (): AchievementDayEvidence => ({ complete: false, days: [], timeZone }); + if (complete !== true || !validTimeZone(timeZone) || !validCanonicalFiles(files)) return fail(); + const proofs = files.map(file => codexFileDays(file,timeZone)); + if (proofs.some(proof => !proof)) return fail(); + const days = proofs.flatMap(proof => proof!.days); + const combined = validatedDays({ complete: true, days }, Infinity, true); + if (!combined) return fail(); + const whole = proofs.every(proof => proof!.complete); + return { complete:whole, days:[...combined.values()], timeZone, + ...(!whole && [...combined.values()].some(day=>day.processed>0) ? {scope:'verified-subset' as const} : {}) }; +} + +export interface CodexAchievementCalendarProjection { + evidence: AchievementDayEvidence; + /** Fully dated files selected AFTER full-census ancestry validation. Money + * still validates all billable model/date vectors before admitting a week. */ + completeDateFiles: CodexFileAggregate[]; + completeDateEvidence: AchievementDayEvidence; +} +const calendarProjections = new WeakMap(); +function freezeDayEvidence(evidence: AchievementDayEvidence): void { + for (const day of evidence.days) { + for (const model of Object.values(day.models)) Object.freeze(model); + Object.freeze(day.models); Object.freeze(day); + } + Object.freeze(evidence.days); Object.freeze(evidence); +} +/** No source reads. Expected offsets/prefixes come from the provider's actual + * contributions, never copied from the period that they are meant to verify. + * Only a detached, deeply immutable census can reuse this evaluation. All + * independent fences are keys; caller-supplied projections cannot seed it. */ +export function codexAchievementCalendarProjection(files: readonly CodexFileAggregate[], timeZone: string, + indexed: boolean, wholeCensusComplete = true): CodexAchievementCalendarProjection { + const cached = calendarProjections.get(files); + if (cached && cached.timeZone === timeZone && cached.indexed === indexed && + cached.wholeCensusComplete === wholeCensusComplete) return cached.projection; + const empty = (): CodexAchievementCalendarProjection => ({evidence:{complete:false,days:[],timeZone}, + completeDateFiles:[],completeDateEvidence:{complete:false,days:[],timeZone}}); + if (!indexed || !Array.isArray(files) || !validTimeZone(timeZone)) return empty(); + const selected = verifiedCodexAchievementFiles(files); + const proofs: AchievementDayEvidence[] = [], completeDateFiles: CodexFileAggregate[] = [], moneyDays: AchievementDay[] = []; + for (const file of selected) { + const witness=file.achievementPeriodWitness, period=file.period; + if (!witness || !period || !count(witness.size) || !count(witness.offset) || + witness.size!==witness.offset || witness.offset!==period.indexedThrough || + witness.timeZone!==timeZone || !count(witness.expectedPrefixEvents) || + witness.expectedPrefixEvents!==period.lineagePrefixEvents) continue; + const proof=codexFileDays(file,timeZone); + if (!proof) continue; + proofs.push(proof); + if (proof.complete) {completeDateFiles.push(file); moneyDays.push(...proof.days);} + } + const rows=validatedDays({complete:true,days:proofs.flatMap(proof=>proof.days)},Infinity,true); + const moneyRows=validatedDays({complete:true,days:moneyDays},Infinity,true); + if (!rows || !moneyRows) return empty(); + const whole=wholeCensusComplete && selected.length===files.length && proofs.length===files.length && proofs.every(proof=>proof.complete); + const days=[...rows.values()]; + const sourceRevision=codexAchievementProofRevision(files,{indexed,calendarComplete:wholeCensusComplete,timeZone}); + const projection: CodexAchievementCalendarProjection = {evidence:{complete:whole,days,timeZone,sourceRevision, + ...(!whole && days.some(day=>day.processed>0) ? {scope:'verified-subset' as const} : {})}, + completeDateFiles, completeDateEvidence:{complete:true,days:[...moneyRows.values()],timeZone}}; + if (isImmutableCodexAchievementFiles(files)) { + freezeDayEvidence(projection.evidence); freezeDayEvidence(projection.completeDateEvidence); + Object.freeze(projection.completeDateFiles); Object.freeze(projection); + calendarProjections.set(files, { timeZone, indexed, wholeCensusComplete, projection }); + } + return projection; +} + +/** All four Claude buckets are disjoint; models must fit each original component. */ +export function claudeAchievementDayEvidence( + rows: readonly { date: string; data: UsageData }[], timeZone: string, complete: boolean, +): AchievementDayEvidence { + const fail = (): AchievementDayEvidence => ({ complete: false, days: [], timeZone }); + if (complete !== true || !validTimeZone(timeZone) || !Array.isArray(rows)) return fail(); + const days = new Map(); + const signatures = new Map(); + for (const row of rows) { + if (!object(row) || typeof row.date !== 'string' || !Number.isFinite(dayTimestamp(row.date)) || + !object(row.data) || !object(row.data.modelBreakdown)) return fail(); + const total = [row.data.totalInputTokens, row.data.totalOutputTokens, row.data.totalCacheCreationTokens, row.data.totalCacheReadTokens]; + if (!total.every(count) || !count(total.reduce((a, b) => a + b, 0))) return fail(); + const components = [0, 0, 0, 0]; + const modelRows: Array<[string, number[]]> = []; + for (const [model, tokens] of Object.entries(row.data.modelBreakdown)) { + if (!object(tokens)) return fail(); + const parts = [tokens.inputTokens, tokens.outputTokens, tokens.cacheCreationTokens, tokens.cacheReadTokens]; + if (!parts.every(count) || !count(parts.reduce((a, b) => a + b, 0))) return fail(); + for (let i = 0; i < 4; i++) { + components[i] += parts[i]; + if (!count(components[i]) || components[i] > total[i]) return fail(); + } + modelRows.push([model, parts]); + } + // Duplicate dashboard projections are not additive contributions. Ignore + // cost/count metadata, but retain every numeric component in the comparison. + modelRows.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0); + const signature = JSON.stringify([total, modelRows]); + const previous = signatures.get(row.date); + if (previous !== undefined && previous !== signature) return fail(); + signatures.set(row.date, signature); + days.set(row.date, { day: row.date, processed: total.reduce((a, b) => a + b, 0), output: total[1], + models: Object.fromEntries(modelRows.map(([model, parts]) => + [model, { processed: parts.reduce((a, b) => a + b, 0), output: parts[1] }])) }); + } + return { complete: true, days: [...days.values()], timeZone }; +} diff --git a/src/achievementCalibration.ts b/src/achievementCalibration.ts new file mode 100644 index 00000000..3fac000e --- /dev/null +++ b/src/achievementCalibration.ts @@ -0,0 +1,59 @@ +/** Runtime-only command orchestration. No storage, credentials, network, + * scheduler, retries, or award decisions belong at this boundary. */ +export type AchievementCalibrationResult = 'recalibrated' | 'partial' | 'disabled' | 'cancelled' | 'failed'; +export type AchievementCalibrationCommitResult = 'partial' | boolean | void; + +export interface AchievementCalibrationOperation { + enabled(): boolean; + cancelled(): boolean; + /** Includes source/configuration and OFF -> ON transitions. */ + generation(): unknown; + /** One local incremental pass (or the already-running production pass). + * Reject on actual failure; a stale last-good snapshot is not success. */ + refreshLocal(current: () => boolean): Promise; + /** The Webview checks this live guard after awaits and at its durable write + * fence. True confirms completed calibration (possibly with no new writes); + * partial confirms some work was saved but calibration did not fully finish. + * Neither outcome can be undone by late cancellation. False/legacy void + * still require a current post-await guard. */ + recalibrate(current: () => boolean): Promise; +} + +export class AchievementCalibrationSingleFlight { + private pending: Promise | undefined; + + run(operation: AchievementCalibrationOperation): Promise { + if (this.pending) return this.pending; + const generation = operation.generation(); + const retired = (): AchievementCalibrationResult | undefined => + !operation.enabled() ? 'disabled' : + operation.cancelled() || operation.generation() !== generation ? 'cancelled' : undefined; + const current = () => retired() === undefined; + const pending = Promise.resolve().then(async (): Promise => { + const before = retired(); + if (before) return before; + try { + await operation.refreshLocal(current); + const after = retired(); + if (after) return after; + const committed = await operation.recalibrate(current); + // Report actual completion/partial saves, even when cancellation or + // source retirement arrived afterward. Never claim saved work vanished. + if (committed === 'partial') return 'partial'; + return committed === true ? 'recalibrated' : retired() ?? 'recalibrated'; + } catch { + return retired() ?? 'failed'; + } + }).finally(() => { + if (this.pending === pending) this.pending = undefined; + }); + this.pending = pending; + return pending; + } +} + +/** Called by the real Claude refresh before even looking up quota credentials. + * Local-only requests must never be merged with ordinary quota-bearing work. */ +export function shouldFetchQuotaForRefresh(request: { localOnly?: boolean }): boolean { + return request.localOnly !== true; +} diff --git a/src/achievementCollaboration.ts b/src/achievementCollaboration.ts new file mode 100644 index 00000000..91cba975 --- /dev/null +++ b/src/achievementCollaboration.ts @@ -0,0 +1,159 @@ +import type { CodexFileAggregate } from './providers/codex/codexIndex'; +import { codexModelFamily, normalizeCodexAchievementModel } from './achievementModels'; +import { validAchievementBuckets, verifiedCodexAchievementFiles } from './codexAchievementProof'; + +export type CollaborationTierCounts = [number, number, number]; +export interface AchievementCollaborationEvidence { + /** Eligible root tasks, ordinary/silver/gold; award repetition targets live in the caller. */ + teamTaskCounts: CollaborationTierCounts; + /** Exactly teamTaskCounts[0]. */ + orchestratedTasks: number; + /** Non-APR subagents with at least 5K explicitly recognized model output. */ + qualifiedSubagentCount: number; + /** Eligible root tasks for 2×10K / 3×20K / 4×50K normalized models. */ + modelTaskCounts: CollaborationTierCounts; + /** Exactly modelTaskCounts[0]. */ + multiModelTasks: number; + /** Number of low..ultra tiers satisfying each volume/output/root conjunction. + * The caller compares these counts with its 4/5/6 award targets. */ + effortQualifiedTierCounts: CollaborationTierCounts; + rainbowFamilies: Record<'astra' | 'sol' | 'luna', { processed: number; output: number }>; +} + +const TEAM_MEMBERS = [3, 5, 8] as const; +const TEAM_OUTPUT = [5_000, 10_000, 20_000] as const; +const MODEL_COUNTS = [2, 3, 4] as const; +const MODEL_OUTPUT = [10_000, 20_000, 50_000] as const; +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const; +type Effort = typeof EFFORTS[number]; +export const EFFORT_PROCESSED = [100_000_000, 500_000_000, 2_000_000_000] as const; +export const EFFORT_OUTPUT = [100_000, 500_000, 2_000_000] as const; +export const EFFORT_ROOTS = [5, 10, 30] as const; +const triple = (): CollaborationTierCounts => [0, 0, 0]; +// All selected operands are safe nonnegative integers. Saturation preserves a +// safe lower bound beyond representable totals, never a rounded-up token claim. +const add = (left: number, right: number): number => + right > Number.MAX_SAFE_INTEGER - left ? Number.MAX_SAFE_INTEGER : left + right; + +interface Task { + members: CollaborationTierCounts; + modelOutput: Map; +} + +/** + * Pure reduction of the host's complete canonical, reconciled index snapshot. + * The host must already have removed duplicate events and inherited prefixes; + * aggregates cannot recover that provenance or discover missing files. Repeat + * the canonical selector here to withhold conflicting identities/descendants, + * malformed token/model vectors, orphans and cycles. No log I/O or durable IDs. + * + * Member output is the sum of explicitly normalized OpenAI model buckets in + * that member. Task model output pools only that model within that root tree. + * Effort uses its own marginal bucket: there is no model×effort cross-tab to + * infer. Pool each root×effort first; only its own positive output admits that + * task's processed/output volume and one distinct root to that tier's proof. + * APR contributes to no evidence; its sound ancestry may connect descendants. + */ +export function codexCollaborationEvidence(files: readonly CodexFileAggregate[]): AchievementCollaborationEvidence { + const result: AchievementCollaborationEvidence = { + teamTaskCounts: [0, 0, 0], orchestratedTasks: 0, qualifiedSubagentCount: 0, + modelTaskCounts: [0, 0, 0], multiModelTasks: 0, effortQualifiedTierCounts: [0, 0, 0], + rainbowFamilies: { + astra: { processed: 0, output: 0 }, sol: { processed: 0, output: 0 }, luna: { processed: 0, output: 0 }, + }, + }; + const verified = verifiedCodexAchievementFiles(files); + const bySession = new Map(verified.map(file => [file.session.sessionKey, file])); + const rootBySession = new Map(); + // Iterative path compression keeps even reversed deep trees linear. Resolve + // over the selected map so no excluded ancestor can reconnect a branch. + const taskRoot = (key: string): string | null => { + const path = new Set(); + let current: string | undefined = key; + let root: string | null = null; + while (current !== undefined) { + if (rootBySession.has(current)) { root = rootBySession.get(current)!; break; } + const file = bySession.get(current); + if (!file || path.has(current)) break; + path.add(current); + const parent = file.session.parentSessionKey; + if (parent === undefined) { root = file.session.role === 'root' ? current : null; break; } + current = parent; + } + for (const session of path) rootBySession.set(session, root); + return root; + }; + const tasks = new Map(); + const effortEvidence = new Map }>( + EFFORTS.map(effort => [effort, { processed: 0, output: 0, roots: new Set() }]), + ); + const taskEfforts = new Map>(); + for (const file of verified) { + if (file.session.role === 'approval-reviewer') continue; + const root = taskRoot(file.session.sessionKey); + if (root === null) continue; + let task = tasks.get(root); + if (!task) tasks.set(root, task = { members: triple(), modelOutput: new Map() }); + let memberOutput = 0; + for (const [label, tokens] of Object.entries(file.byModel)) { + const model = normalizeCodexAchievementModel(label); + if (!model) continue; + const family = codexModelFamily(model); + if (family) { + const bucket = result.rainbowFamilies[family]; + bucket.processed = add(bucket.processed, tokens.inputTotal + tokens.outputTotal); + bucket.output = add(bucket.output, tokens.outputTotal); + } + // Family accumulation combines that family's actual input/output. Its + // separate output condition still prevents input-only qualification. + if (tokens.outputTotal === 0) continue; + memberOutput = add(memberOutput, tokens.outputTotal); + task.modelOutput.set(model, add(task.modelOutput.get(model) ?? 0, tokens.outputTotal)); + } + if (file.session.role === 'subagent') { + for (let tier = 0; tier < 3; tier++) if (memberOutput >= TEAM_OUTPUT[tier]) task.members[tier]++; + if (memberOutput >= TEAM_OUTPUT[0]) result.qualifiedSubagentCount++; + } + // Validate the entire effort vector independently; malformed effort cannot + // discard independently valid team/model/family evidence. + if (!validAchievementBuckets(file.byEffort, file.total)) continue; + let ownEfforts = taskEfforts.get(root); + if (!ownEfforts) taskEfforts.set(root, ownEfforts = new Map()); + for (const effort of EFFORTS) { + const tokens = file.byEffort[effort]; + if (!tokens) continue; + let bucket = ownEfforts.get(effort); + if (!bucket) ownEfforts.set(effort, bucket = { processed: 0, output: 0 }); + bucket.processed = add(bucket.processed, tokens.inputTotal + tokens.outputTotal); + bucket.output = add(bucket.output, tokens.outputTotal); + } + } + for (const [root, ownEfforts] of taskEfforts) { + for (const [effort, own] of ownEfforts) { + if (own.output === 0) continue; + const bucket = effortEvidence.get(effort)!; + bucket.processed = add(bucket.processed, own.processed); + bucket.output = add(bucket.output, own.output); + bucket.roots.add(root); + } + } + for (const [root, task] of tasks) { + for (let tier = 0; tier < 3; tier++) { + if (bySession.get(root)!.total.outputTotal > 0 && task.members[tier] >= TEAM_MEMBERS[tier]) { + result.teamTaskCounts[tier]++; + } + let qualifiedModels = 0; + for (const output of task.modelOutput.values()) if (output >= MODEL_OUTPUT[tier]) qualifiedModels++; + if (qualifiedModels >= MODEL_COUNTS[tier]) result.modelTaskCounts[tier]++; + } + } + result.orchestratedTasks = result.teamTaskCounts[0]; + result.multiModelTasks = result.modelTaskCounts[0]; + for (const bucket of effortEvidence.values()) { + for (let tier = 0; tier < 3; tier++) { + if (bucket.processed >= EFFORT_PROCESSED[tier] && bucket.output >= EFFORT_OUTPUT[tier] && + bucket.roots.size >= EFFORT_ROOTS[tier]) result.effortQualifiedTierCounts[tier]++; + } + } + return result; +} diff --git a/src/achievementConditions.ts b/src/achievementConditions.ts new file mode 100644 index 00000000..3f120567 --- /dev/null +++ b/src/achievementConditions.ts @@ -0,0 +1,141 @@ +import type { AchievementId } from './achievements'; +import type { SupportedLanguage } from './types'; + +interface ConditionTemplates { + team: (members: number, output: string) => string; + participation: string; + models: (models: number, output: string) => string; + effort: (processed: string, output: string, tasks: number) => string; + rainbow: string; + evidence: string; + roots: string; +} + +// Qualification only: repetition, tier-count and family-count targets are +// already shown by the caller. K/M/B denote token counts in every locale. +const templates: Record = { + en: { + team: (n, output) => `Per task: ≥${n} distinct subagents, each with ≥${output} output tokens; main session has output.`, + participation: 'Each distinct subagent: ≥5K output tokens.', + models: (n, output) => `Per task: ≥${n} distinct recognized models, each with ≥${output} output tokens. Model aliases count once.`, + effort: (processed, output, tasks) => `Per effort tier: ≥${processed} processed tokens and ≥${output} output tokens across ≥${tasks} distinct root tasks. Pool each task and tier first; count only tasks with output in that tier.`, + rainbow: 'Per recognized family (including supported Fable 5/5.1): ≥10M processed tokens and ≥20K output tokens. Family aliases count once.', + evidence: 'Verified, deduplicated usage only; approval reviewers excluded.', + roots: 'Main tasks with verifiable log ownership and recognized model usage. A task and its subagents count once together; this does not mean task completion.', + }, + 'zh-CN': { + team: (n, output) => `每任务≥${n}个不同子智能体,各输出≥${output} Token;主会话有输出。`, + participation: '每个不同子智能体输出≥5K Token。', + models: (n, output) => `每任务≥${n}个不同明确模型,各输出≥${output} Token;模型别名不重复计数。`, + effort: (processed, output, tasks) => `每档已处理≥${processed} Token且输出≥${output} Token,分布于≥${tasks}个不同根任务;先按根任务与同档位汇总,仅计入该档有输出的任务。`, + rainbow: '每个可识别家族(含支持的 Fable 5/5.1)已处理≥10M Token且输出≥20K Token;家族别名不重复计数。', + evidence: '仅计可确认、去重用量;排除审批审查器。', + roots: '可从日志核验归属、且含已识别模型用量的主任务;同一主任务及其子会话只计一次,不表示任务完成。', + }, + 'zh-TW': { + team: (n, output) => `每任務≥${n}個不同子代理,各輸出≥${output} Token;主工作階段有輸出。`, + participation: '每個不同子代理輸出≥5K Token。', + models: (n, output) => `每任務≥${n}個不同明確模型,各輸出≥${output} Token;模型別名不重複計數。`, + effort: (processed, output, tasks) => `每檔已處理≥${processed} Token且輸出≥${output} Token,分布於≥${tasks}個不同根任務;先按根任務與同檔位彙總,僅計入該檔有輸出的任務。`, + rainbow: '每個可識別家族(含支援的 Fable 5/5.1)已處理≥10M Token且輸出≥20K Token;家族別名不重複計數。', + evidence: '僅計已確認、去重用量;排除審批審查器。', + roots: '可由日誌核驗歸屬,且含已識別模型用量的主任務;同一主任務與其子工作階段只計一次,不表示任務完成。', + }, + 'de-DE': { + team: (n, output) => `Je Aufgabe: ≥${n} verschiedene Unteragenten, je ≥${output} Ausgabe-Token; die Hauptsitzung hat Ausgabe.`, + participation: 'Je verschiedener Unteragent: ≥5K Ausgabe-Token.', + models: (n, output) => `Je Aufgabe: ≥${n} verschiedene erkannte Modelle, je ≥${output} Ausgabe-Token. Modell-Aliasse zählen einmal.`, + effort: (processed, output, tasks) => `Je Denkstufe: ≥${processed} verarbeitete Token und ≥${output} Ausgabe-Token aus ≥${tasks} verschiedenen Hauptaufgaben. Erst je Aufgabe und Stufe summieren; nur Aufgaben mit Ausgabe in dieser Stufe zählen.`, + rainbow: 'Je erkannter Familie (einschließlich unterstützter Fable 5/5.1): ≥10M verarbeitete Token und ≥20K Ausgabe-Token. Familien-Aliasse zählen einmal.', + evidence: 'Nur bestätigte, deduplizierte Nutzung; Freigabeprüfer ausgeschlossen.', + roots: 'Hauptaufgaben mit prüfbarer Protokollzuordnung und Nutzung erkannter Modelle. Aufgabe und Unteragenten zählen gemeinsam einmal; kein Nachweis eines Aufgabenabschlusses.', + }, + ja: { + team: (n, output) => `各タスクに異なる子エージェント≥${n}件、各出力≥${output}トークン;メインセッションも出力あり。`, + participation: '各異なる子エージェントの出力≥5Kトークン。', + models: (n, output) => `各タスクに異なる認識済みモデル≥${n}種類、各出力≥${output}トークン。モデルの別名は重複計上しません。`, + effort: (processed, output, tasks) => `各推論レベルで処理≥${processed}トークンかつ出力≥${output}トークン、異なるルートタスク≥${tasks}件。タスクとレベルごとに先に合計し、そのレベルに出力のあるタスクのみ計上。`, + rainbow: '各認識済み系統(対応する Fable 5/5.1 を含む)で処理≥10Mトークンかつ出力≥20Kトークン。系統の別名は重複計上しません。', + evidence: '確認済み・重複除去済みの使用量のみ;承認レビュアーは除外。', + roots: 'ログで所属を検証でき、認識済みモデルの使用量があるメインタスク。同じタスクと子セッションはまとめて一件と数え、完了を意味しません。', + }, + ko: { + team: (n, output) => `각 작업에 서로 다른 하위 에이전트 ≥${n}개, 각 출력 ≥${output} 토큰; 주 세션에도 출력이 있어야 합니다.`, + participation: '서로 다른 각 하위 에이전트의 출력 ≥5K 토큰.', + models: (n, output) => `각 작업에 서로 다른 인식된 모델 ≥${n}개, 각 출력 ≥${output} 토큰. 모델 별칭은 중복 집계하지 않습니다.`, + effort: (processed, output, tasks) => `각 추론 단계에서 처리 ≥${processed} 토큰 및 출력 ≥${output} 토큰, 서로 다른 루트 작업 ≥${tasks}개. 작업과 단계별로 먼저 합산하고 해당 단계에 출력이 있는 작업만 집계.`, + rainbow: '인식된 각 계열(지원되는 Fable 5/5.1 포함)에서 처리 ≥10M 토큰 및 출력 ≥20K 토큰. 계열 별칭은 중복 집계하지 않습니다.', + evidence: '확인된 중복 제거 사용량만 집계; 승인 검토자는 제외.', + roots: '로그에서 소속을 검증할 수 있고 인식된 모델 사용량이 있는 주 작업입니다. 같은 작업과 하위 세션은 함께 한 번만 집계하며 완료를 뜻하지 않습니다.', + }, + 'pt-BR': { + team: (n, output) => `Por tarefa: ≥${n} subagentes distintos, cada um com ≥${output} tokens de saída; a sessão principal tem saída.`, + participation: 'Cada subagente distinto: ≥5K tokens de saída.', + models: (n, output) => `Por tarefa: ≥${n} modelos reconhecidos distintos, cada um com ≥${output} tokens de saída. Aliases de modelo contam uma vez.`, + effort: (processed, output, tasks) => `Por nível de esforço: ≥${processed} tokens processados e ≥${output} tokens de saída, em ≥${tasks} tarefas raiz distintas. Some por tarefa e nível primeiro; conte apenas tarefas com saída nesse nível.`, + rainbow: 'Por família reconhecida (incluindo Fable 5/5.1 compatíveis): ≥10M tokens processados e ≥20K tokens de saída. Aliases de família contam uma vez.', + evidence: 'Apenas uso verificado e deduplicado; revisores de aprovação excluídos.', + roots: 'Tarefas principais com vínculo verificável nos logs e uso de modelos reconhecidos. A tarefa e seus subagentes contam uma vez em conjunto; não significa conclusão.', + }, + id: { + team: (n, output) => `Setiap tugas: ≥${n} subagen berbeda, tiap subagen dengan ≥${output} token output; sesi utama memiliki output.`, + participation: 'Setiap subagen berbeda: ≥5K token output.', + models: (n, output) => `Setiap tugas: ≥${n} model dikenali yang berbeda, tiap model dengan ≥${output} token output. Alias model dihitung sekali.`, + effort: (processed, output, tasks) => `Setiap tingkat upaya: ≥${processed} token diproses dan ≥${output} token output, dalam ≥${tasks} tugas akar berbeda. Jumlahkan per tugas dan tingkat dahulu; hanya tugas dengan output pada tingkat itu dihitung.`, + rainbow: 'Setiap keluarga dikenali (termasuk Fable 5/5.1 yang didukung): ≥10M token diproses dan ≥20K token output. Alias keluarga dihitung sekali.', + evidence: 'Hanya penggunaan terverifikasi dan dideduplikasi; peninjau persetujuan dikecualikan.', + roots: 'Tugas utama dengan asal log yang dapat diverifikasi dan penggunaan model yang dikenali. Tugas beserta subagennya dihitung sekali bersama; bukan berarti tugas selesai.', + }, +}; + +function conditionsFor(copy: ConditionTemplates): Partial> { + const detail = (text: string): string => `${text} ${copy.evidence}`; + const ordinaryTeam = detail(copy.team(3, '5K')); + const participation = detail(copy.participation); + const ordinaryModels = detail(copy.models(2, '10K')); + const rainbow = detail(copy.rainbow); + return { + 'root-portfolio-bronze': copy.roots, + 'root-portfolio-silver': copy.roots, + 'root-portfolio-gold': copy.roots, + 'agent-standup': ordinaryTeam, + 'tiny-ai-company': detail(copy.team(5, '10K')), + 'agent-team-gold': detail(copy.team(8, '20K')), + 'agent-conductor': ordinaryTeam, + 'agent-conductor-silver': ordinaryTeam, + 'agent-conductor-gold': ordinaryTeam, + 'agent-participation-bronze': participation, + 'agent-participation-silver': participation, + 'agent-participation-gold': participation, + 'multi-core-human': ordinaryModels, + 'same-root-models-silver': detail(copy.models(3, '20K')), + 'same-root-models-gold': detail(copy.models(4, '50K')), + 'model-relay-roots-bronze': ordinaryModels, + 'model-relay-roots-silver': ordinaryModels, + 'model-relay-roots-gold': ordinaryModels, + 'effort-explorer': detail(copy.effort('100M', '100K', 5)), + 'effort-breadth-silver': detail(copy.effort('500M', '500K', 10)), + 'effort-spectrum': detail(copy.effort('2B', '2M', 30)), + 'model-rainbow-bronze': rainbow, + 'model-rainbow': rainbow, + 'model-rainbow-gold': rainbow, + }; +} + +const conditions: Record>> = { + en: conditionsFor(templates.en), + 'zh-CN': conditionsFor(templates['zh-CN']), + 'zh-TW': conditionsFor(templates['zh-TW']), + 'de-DE': conditionsFor(templates['de-DE']), + ja: conditionsFor(templates.ja), + ko: conditionsFor(templates.ko), + 'pt-BR': conditionsFor(templates['pt-BR']), + id: conditionsFor(templates.id), +}; + +/** Pure detail/SVG copy lookup; unknown locales use English, other IDs have no condition. */ +export function achievementConditionFor(locale: string, id: AchievementId): string | undefined { + const copy = Object.prototype.hasOwnProperty.call(conditions, locale) + ? conditions[locale as SupportedLanguage] : conditions.en; + return Object.prototype.hasOwnProperty.call(copy, id) ? copy[id] : undefined; +} diff --git a/src/achievementDetailPresentation.ts b/src/achievementDetailPresentation.ts new file mode 100644 index 00000000..638a0b9f --- /dev/null +++ b/src/achievementDetailPresentation.ts @@ -0,0 +1,27 @@ +/** Share only an exact, nonblank condition present at every level. */ +export function compactAchievementLevelNotes( + notes: readonly (string | undefined)[], +): { shared?: string; perLevel: (string | undefined)[] } { + const perLevel = [...notes]; + const first = perLevel[0]; + if (perLevel.length > 1 && typeof first === 'string' && first.trim().length > 0 + && perLevel.every(note => note === first)) { + // Preserve the whole original text, including reference-price versions. + return { shared: first, perLevel: perLevel.map(() => undefined) }; + } + return { perLevel }; +} + +/** + * Current measured progress, in its original integer unit (including microUSD). + * The caller owns freshness via ready; targets and historical awards cannot + * supply a missing value or cap a value that exceeds the highest level. + */ +export function achievementCurrentValue( + result: { progress: number; target: number }, + ready: boolean, +): number | undefined { + if (ready !== true) return undefined; + const progress = result?.progress; + return Number.isSafeInteger(progress) && progress >= 0 ? progress : undefined; +} diff --git a/src/achievementEffortStatistics.ts b/src/achievementEffortStatistics.ts new file mode 100644 index 00000000..3441e996 --- /dev/null +++ b/src/achievementEffortStatistics.ts @@ -0,0 +1,188 @@ +import { getAchievementMoneyPricebook, normalizeAchievementMoneyModel } from './achievementMoney'; +import { codexMoneyEvidenceFromModels } from './achievementMoneyScopes'; +import { validAchievementBuckets, verifiedCodexAchievementFiles } from './codexAchievementProof'; +import type { CodexFileAggregate } from './providers/codex/codexIndex'; +import type { ProviderTokenCounts } from './providers/providerTypes'; + +export const CODEX_ACHIEVEMENT_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const; +export type AchievementEffort = typeof CODEX_ACHIEVEMENT_EFFORTS[number]; +export const EFFORT_STATISTICS_PRICEBOOK_VERSION = getAchievementMoneyPricebook('openai').version; +export const EFFORT_STATISTICS_REFERENCE_DATE = getAchievementMoneyPricebook('openai').referenceDate; +export type EffortStatisticsStatus = 'empty' | 'unpriced' | 'partial' | 'complete' | 'invalid' | 'overflow'; +export interface AchievementEffortStatisticsRow { + readonly effort: AchievementEffort; + readonly processed: number; + readonly output: number; + readonly qualifyingRoots: number; + /** Frozen-reference subtotal, floored once after the eligible population sum. + * Divide by 1_000_000 for USD. null is unavailable, never a synthetic $0. */ + readonly microUsd: number | null; + readonly amountPositive: boolean; + readonly pricedTokens: number; + readonly unpricedTokens: number; + readonly pricingCoverage: number; + readonly status: EffortStatisticsStatus; + /** False only for invalid money components or unsafe summed tokens/amount. + * Unpriced and partial rows remain valid observations of the population. */ + readonly valid: boolean; + /** Money bound only. Token overflow saturates processed/output like the + * collaboration evaluator, with status=overflow and money withheld. */ + readonly boundState: 'exact' | 'lower-bound' | 'unavailable'; +} +export interface AchievementEffortStatistics { + readonly pricingVersion: string; + readonly referenceDate: typeof EFFORT_STATISTICS_REFERENCE_DATE; + readonly rows: readonly AchievementEffortStatisticsRow[]; +} + +const SCALE = 10n ** 18n; +const MAX = BigInt(Number.MAX_SAFE_INTEGER); +const PRICING_FIELDS = ['inputTotal', 'outputTotal', 'cachedInput', 'cacheWriteInput'] as const; +interface MoneySum { priced: bigint; units: bigint; invalid: boolean } +interface Population extends MoneySum { processed: bigint; output: bigint; roots: number } +const emptyMoney = (): MoneySum => ({ priced: 0n, units: 0n, invalid: false }); +const emptyPopulation = (): Population => ({ ...emptyMoney(), processed: 0n, output: 0n, roots: 0 }); +const safeLowerBound = (value: bigint): number => Number(value > MAX ? MAX : value); +const field = (tokens: ProviderTokenCounts, key: typeof PRICING_FIELDS[number]): bigint => BigInt(tokens[key] ?? 0); +const hasPricingTokens = (tokens: ProviderTokenCounts): boolean => PRICING_FIELDS.some(key => field(tokens, key) > 0n); +const coversPricing = (tokens: ProviderTokenCounts, total: ProviderTokenCounts): boolean => + PRICING_FIELDS.every(key => field(tokens, key) === field(total, key)); +function addMoney(target: MoneySum, source: MoneySum): void { + target.priced += source.priced; + target.units += source.units; + target.invalid ||= source.invalid; +} + +/** Every pricing component must fit the interpreted total, including optional + * fields whose absence means zero. The selector's optional-field allowances + * alone cannot establish this. Fresh input must independently fit as well. */ +function pricingBucketsFit(buckets: Readonly>, total: ProviderTokenCounts): boolean { + const sums = PRICING_FIELDS.map(() => 0n); + let fresh = 0n, writes = 0n; + for (const tokens of Object.values(buckets)) { + PRICING_FIELDS.forEach((key, i) => { sums[i] += field(tokens, key); }); + fresh += field(tokens, 'inputTotal') - field(tokens, 'cachedInput'); + writes += field(tokens, 'cacheWriteInput'); + } + return PRICING_FIELDS.every((key, i) => sums[i] <= field(total, key)) && + fresh <= field(total, 'inputTotal') - field(total, 'cachedInput') && writes === 0n; +} + +function singleCoveringModel(file: CodexFileAggregate): string | undefined { + let model: string | undefined; + const sums = PRICING_FIELDS.map(() => 0n); + for (const [label, tokens] of Object.entries(file.byModel)) { + if (!hasPricingTokens(tokens)) continue; + const canonical = normalizeAchievementMoneyModel(label); + if (!canonical || (model !== undefined && model !== canonical)) return undefined; + model = canonical; + PRICING_FIELDS.forEach((key, i) => { sums[i] += field(tokens, key); }); + } + // Explicit reference aliases may describe the same model. Their complete + // numeric partition, not normalization alone, proves its entire-file usage. + return PRICING_FIELDS.every((key, i) => sums[i] === field(file.total, key)) ? model : undefined; +} + +/** Never construct a cross-tab from two marginals. Entire pricing-component + * coverage proves either a single effort for the file's model buckets, or a + * single model for each effort bucket. Residual model tokens stay unpriced. */ +function effortMoney(file: CodexFileAggregate, effort: AchievementEffort): MoneySum { + if (field(file.total, 'cacheWriteInput') !== 0n || !pricingBucketsFit(file.byEffort, file.total)) { + return { ...emptyMoney(), invalid: true }; + } + const own = file.byEffort[effort]; + const efforts = Object.values(file.byEffort).filter(hasPricingTokens); + let models: Readonly> | undefined; + if (efforts.length === 1 && coversPricing(own, file.total)) { + if (!pricingBucketsFit(file.byModel, file.total)) return { ...emptyMoney(), invalid: true }; + models = file.byModel; + } else { + const model = singleCoveringModel(file); + if (model) models = { [model]: own }; + } + if (!models) return emptyMoney(); + const money = codexMoneyEvidenceFromModels(models, 'openai'); + if (money.status === 'invalid') return { ...emptyMoney(), invalid: true }; + // The helper retains exact units even for amount overflow. Preserve them + // internally and withhold the final row if its sum cannot be represented. + return { priced: BigInt(money.pricedTokens), units: BigInt(money.exactMicroUsdUnits), invalid: false }; +} + +function statisticsRow(effort: AchievementEffort, total: Population): AchievementEffortStatisticsRow { + const overflow = total.processed > MAX || total.output > MAX || total.priced > MAX || total.units / SCALE > MAX; + const invalid = total.invalid || total.priced > total.processed; + const valid = !overflow && !invalid; + const status: EffortStatisticsStatus = overflow ? 'overflow' : invalid ? 'invalid' + : total.processed === 0n ? 'empty' : total.priced === 0n ? 'unpriced' + : total.priced < total.processed ? 'partial' : 'complete'; + const processed = safeLowerBound(total.processed); + // Unsafe money is withheld as a whole, not displayed as a smaller exact sum. + const pricedTokens = valid ? Number(total.priced) : 0; + const available = valid && pricedTokens > 0; + return { + effort, processed, output: safeLowerBound(total.output), qualifyingRoots: total.roots, + microUsd: available ? Number(total.units / SCALE) : null, + amountPositive: available && total.units > 0n, + pricedTokens, unpricedTokens: processed - pricedTokens, + pricingCoverage: processed > 0 ? pricedTokens / processed : 0, + status, valid, boundState: !available ? 'unavailable' : status === 'partial' ? 'lower-bound' : 'exact', + }; +} + +/** Caller supplies a complete current canonical, prefix-reconciled census. + * This getter cannot prove manifest completeness. It repeats existing lineage + * and vector selection, pools non-APR root × effort first, and admits only + * pools with their OWN positive output, exactly as collaboration evidence does. + * Transient identities resolve ancestry only; the fixed DTO emits no identities, + * model labels, exact-unit strings, thresholds, I/O or persistence instructions. */ +export function codexAchievementEffortStatistics(files: readonly CodexFileAggregate[]): AchievementEffortStatistics { + const verified = verifiedCodexAchievementFiles(files); + const bySession = new Map(verified.map(file => [file.session.sessionKey, file])); + const roots = new Map(); + function rootFor(key: string): string | null { + const path = new Set(); + let current: string | undefined = key; + let root: string | null = null; + while (current !== undefined) { + if (roots.has(current)) { root = roots.get(current)!; break; } + const file = bySession.get(current); + if (!file || path.has(current)) break; + path.add(current); + const parent = file.session.parentSessionKey; + if (parent === undefined) { root = file.session.role === 'root' ? current : null; break; } + current = parent; + } + for (const session of path) roots.set(session, root); + return root; + } + const tasks = new Map>(); + for (const file of verified) { + if (file.session.role === 'approval-reviewer' || !validAchievementBuckets(file.byEffort, file.total)) continue; + const root = rootFor(file.session.sessionKey); + if (root === null) continue; + let efforts = tasks.get(root); + if (!efforts) tasks.set(root, efforts = new Map()); + for (const effort of CODEX_ACHIEVEMENT_EFFORTS) { + const tokens = file.byEffort[effort]; + if (!tokens) continue; + let population = efforts.get(effort); + if (!population) efforts.set(effort, population = emptyPopulation()); + population.processed += BigInt(tokens.inputTotal) + BigInt(tokens.outputTotal); + population.output += BigInt(tokens.outputTotal); + if (hasPricingTokens(tokens)) addMoney(population, effortMoney(file, effort)); + } + } + const totals = new Map(CODEX_ACHIEVEMENT_EFFORTS.map(effort => [effort, emptyPopulation()])); + for (const efforts of tasks.values()) { + for (const [effort, population] of efforts) { + if (population.output === 0n) continue; + const total = totals.get(effort)!; + total.processed += population.processed; + total.output += population.output; + total.roots++; + addMoney(total, population); + } + } + return { pricingVersion: EFFORT_STATISTICS_PRICEBOOK_VERSION, referenceDate: EFFORT_STATISTICS_REFERENCE_DATE, + rows: CODEX_ACHIEVEMENT_EFFORTS.map(effort => statisticsRow(effort, totals.get(effort)!)) }; +} diff --git a/src/achievementEvidence.ts b/src/achievementEvidence.ts new file mode 100644 index 00000000..e5cc62e9 --- /dev/null +++ b/src/achievementEvidence.ts @@ -0,0 +1,141 @@ +import { ACHIEVEMENT_DEFINITIONS, AchievementId, AchievementResult } from './achievements'; +import type { ClaudeAchievementUsageDelta } from './claudeIncrementalIndex'; +import { CodexAchievementUsageDelta, copyCodexUsageWindow, copyCodexUsageDelta } from './providers/codex/codexAchievementOrigin'; +import {copyMoneyProofs} from './achievementMoneyOrigin'; + +/** Same per-level causal test as Claude, with Codex's native input + output + * semantics. Volume proof never authorizes calendar, effort breadth or agents. */ +export function codexTailAchievementCrossings( + before: readonly AchievementResult[], after: readonly AchievementResult[], + rawDelta: CodexAchievementUsageDelta | undefined, +): AchievementId[] { + const delta = copyCodexUsageDelta(rawDelta, copyCodexUsageWindow(rawDelta)); + const count = (n: unknown): n is number => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0; + if (!delta || delta.processed === 0 || before.length > ACHIEVEMENT_DEFINITIONS.length || + after.length > ACHIEVEMENT_DEFINITIONS.length) return []; + const previous = new Map(before.map(row => [row.id, row])); + const current = new Map(after.map(row => [row.id, row])); + if (previous.size !== before.length || current.size !== after.length) return []; + const ids: AchievementId[] = []; + for (const rule of ACHIEVEMENT_DEFINITIONS) { + const old = previous.get(rule.id), next = current.get(rule.id); + if (!old || !next || old.target !== rule.target || next.target !== rule.target || + old.group !== rule.group || next.group !== rule.group || + !count(old.progress) || !count(next.progress) || old.progress >= rule.target || next.progress < rule.target) continue; + const addition = rule.id.startsWith('astra-regular') ? delta.families.astra + : rule.id.startsWith('sol-regular') ? delta.families.sol + : rule.id.startsWith('luna-regular') ? delta.families.luna + : rule.id.startsWith('codex-output-') ? delta.output + : rule.id.startsWith('codex-cache-read-') ? delta.cacheRead + : ['hundred-million-club', 'billion-token-club', 'ten-billion-token-club', + 'hundred-billion-token-club', 'trillion-token-club'].includes(rule.id) ? delta.processed : 0; + if (addition <= 0 || addition > next.progress || !count(old.progress + addition)) continue; + if (old.progress + addition >= rule.target && next.progress - addition < rule.target) ids.push(rule.id); + } + return ids; +} + +/** Proven tail additions are a lower bound, not permission to date all awards + * in a mixed refresh. Certify only volume rules where this addition is both + * sufficient against the prior baseline and necessary against the new total. + * The caller still owns readiness, accepted producer window and lease fencing. */ +export function claudeTailAchievementCrossings( + before: readonly AchievementResult[], after: readonly AchievementResult[], + rawDelta: ClaudeAchievementUsageDelta | undefined, +): AchievementId[] { + const delta = copyClaudeUsageDelta(rawDelta, rawDelta); + if (!delta || delta.processed === 0 || before.length > ACHIEVEMENT_DEFINITIONS.length || + after.length > ACHIEVEMENT_DEFINITIONS.length) return []; + const previous = new Map(before.map((row) => [row.id, row])); + const current = new Map(after.map((row) => [row.id, row])); + if (previous.size !== before.length || current.size !== after.length) return []; + const ids: AchievementId[] = []; + for (const rule of ACHIEVEMENT_DEFINITIONS) { + const old = previous.get(rule.id), next = current.get(rule.id); + if (!old || !next || old.target !== rule.target || next.target !== rule.target || + old.group !== rule.group || next.group !== rule.group || + !count(old.progress) || !count(next.progress) || + old.progress >= rule.target || next.progress < rule.target) continue; + const addition = rule.id.startsWith('sonnet-main') ? delta.families.sonnet + : rule.id.startsWith('opus-cannon') ? delta.families.opus + : rule.id.startsWith('haiku-worker') ? delta.families.haiku + : rule.id.startsWith('fable-volume-') ? delta.families.fable + : rule.id.startsWith('cache-wizard') ? delta.cacheRead + : /^claude-cache-creation-(bronze|silver|gold|platinum|legendary)$/.test(rule.id) ? delta.cacheCreation + : /^claude-output-(bronze|silver|gold|platinum|legendary)$/.test(rule.id) ? delta.output + : ['hundred-million-club', 'billion-token-club', 'ten-billion-token-club', + 'hundred-billion-token-club', 'trillion-token-club'].includes(rule.id) ? delta.processed : 0; + if (addition <= 0 || addition > next.progress || !Number.isSafeInteger(old.progress + addition)) continue; + if (old.progress + addition >= rule.target && next.progress - addition < rule.target) ids.push(rule.id); + } + return ids; +} + +const count = (n: unknown): n is number => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0; +/** Fixed-field native copy. Invalid optional money cannot invalidate Tokens. */ +export function copyClaudeUsageDelta(raw: unknown, + window: {after:number;through:number} | undefined): ClaudeAchievementUsageDelta | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !window) return undefined; + const delta = raw as ClaudeAchievementUsageDelta; + if (!delta || delta.provider !== 'claude' || !count(delta.after) || !count(delta.through) || + delta.after !== window.after || delta.through !== window.through || + delta.through > 8.64e15 || delta.through <= delta.after || + !count(delta.processed) || !count(delta.output) || + !count(delta.cacheRead) || !count(delta.cacheCreation) || + !Number.isSafeInteger(delta.output + delta.cacheRead + delta.cacheCreation) || + delta.output + delta.cacheRead + delta.cacheCreation > delta.processed || + !delta.families || typeof delta.families !== 'object' || Array.isArray(delta.families)) return undefined; + let families = 0; + for (const family of ['sonnet', 'opus', 'haiku', 'fable'] as const) { + const value = delta.families[family]; + if (!count(value)) return undefined; + families += value; + } + if (!Number.isSafeInteger(families) || families > delta.processed) return undefined; + const money = copyMoneyProofs(delta.money,'claude',delta.processed); + return {provider:'claude',after:delta.after,through:delta.through,processed:delta.processed, + output:delta.output,cacheRead:delta.cacheRead,cacheCreation:delta.cacheCreation, + families:{sonnet:delta.families.sonnet,opus:delta.families.opus,haiku:delta.families.haiku,fable:delta.families.fable}, + ...(money ? {money} : {})}; +} + +export const ACHIEVEMENT_EVIDENCE_DOMAINS = [ + 'claude-usage', 'codex-lineage', 'codex-tokens', 'claude-calendar', + 'codex-calendar', 'combined-usage', 'combined-calendar', 'hours', 'plugin', +] as const; +export type AchievementEvidenceDomain = typeof ACHIEVEMENT_EVIDENCE_DOMAINS[number]; +export type AchievementEvidenceState = 'ready' | 'pending' | 'stale'; +export type AchievementEvidenceStates = Record; + +/** Rule scope, not whichever provider tab happens to be selected. */ +export function achievementEvidenceDomain(rule: Pick): AchievementEvidenceDomain { + if (rule.group === 'plugin') return 'plugin'; + if (rule.id.startsWith('output-hours-') || rule.id === 'night-output-peak') return 'hours'; + if (/^(?:(?:astra|sol|luna|sonnet|opus|haiku|fable|dual-provider)-days-|active-|weekly-)/.test(rule.id) || + ['streak-machine', 'three-family-day', 'dual-provider-day'].includes(rule.id)) { + return rule.group === 'claude' ? 'claude-calendar' : rule.group === 'codex' ? 'codex-calendar' : 'combined-calendar'; + } + if (rule.group === 'claude') return 'claude-usage'; + if (rule.group === 'codex') return /^(?:codex-output-|codex-cache-read-)/.test(rule.id) ? 'codex-tokens' : 'codex-lineage'; + return 'combined-usage'; +} + +/** Nine runtime latches only. First/recovered history in each domain is quiet. */ +export class AchievementNoticeBaseline { + private readonly ready = new Set(); + + accept(states: AchievementEvidenceStates): ReadonlySet { + const previouslyReady = new Set(); + for (const domain of ACHIEVEMENT_EVIDENCE_DOMAINS) { + if (states[domain] !== 'ready') { this.ready.delete(domain); continue; } + if (this.ready.has(domain)) previouslyReady.add(domain); + this.ready.add(domain); + } + return previouslyReady; + } + + isReady(domain: AchievementEvidenceDomain): boolean { return this.ready.has(domain); } + reset(domains: readonly AchievementEvidenceDomain[] = ACHIEVEMENT_EVIDENCE_DOMAINS): void { + for (const domain of domains) this.ready.delete(domain); + } +} diff --git a/src/achievementFormat.ts b/src/achievementFormat.ts new file mode 100644 index 00000000..eed088da --- /dev/null +++ b/src/achievementFormat.ts @@ -0,0 +1,13 @@ +/** Achievement-only presentation: integer labels never round up to a goal. */ +export function formatAchievementInteger(value: number, locale = 'en'): string { + const integer = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; + return integer.toLocaleString(locale, { maximumFractionDigits: 0 }); +} + +export function formatAchievementCompact(value: number, locale = 'en'): string { + const integer = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; + for (const [scale, suffix] of [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'k']] as const) { + if (integer >= scale) return formatAchievementInteger(Math.floor(integer / scale), locale) + suffix; + } + return formatAchievementInteger(integer, locale); +} diff --git a/src/achievementGallery.ts b/src/achievementGallery.ts new file mode 100644 index 00000000..c07a914d --- /dev/null +++ b/src/achievementGallery.ts @@ -0,0 +1,52 @@ +import { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_SERIES, AchievementId } from './achievements'; + +export interface AchievementGalleryEntry { + readonly key: string; + readonly levelIds: readonly AchievementId[]; + readonly milestoneIds: readonly AchievementId[]; +} + +// Display ownership only. Award identities, conditions, artwork and saved pin keys stay intact. +const milestoneOwners = new Map([ + ['streak-machine', 'active-day-streak'], + ['dual-provider-day', 'dual-provider-days'], +]); +const seriesFor = new Map(Object.entries(ACHIEVEMENT_SERIES) + .flatMap(([key, ids]) => ids.map(id => [id, key] as const))); +export const ACHIEVEMENT_GALLERY: readonly AchievementGalleryEntry[] = Object.freeze( + [...new Set(ACHIEVEMENT_DEFINITIONS.filter(rule => !milestoneOwners.has(rule.id)) + .map(rule => seriesFor.get(rule.id) ?? rule.id))].map(key => Object.freeze({ + key, + levelIds: Object.freeze([...(ACHIEVEMENT_SERIES[key] ?? [key as AchievementId])]), + milestoneIds: Object.freeze([...milestoneOwners].filter(([, owner]) => owner === key).map(([id]) => id)), + })), +); +const displayKey = new Map(ACHIEVEMENT_GALLERY.flatMap(entry => + [entry.key, ...entry.levelIds, ...entry.milestoneIds].map(alias => [alias, entry.key] as const))); + +/** Resolve a known old disclosure/pin/award key without changing persisted identities. */ +export function achievementGalleryKey(key: string): string | undefined { + return displayKey.get(key); +} + +/** Current materialized statistics, never award-date or shared-calendar proof. */ +const statisticsModels = new Map([ + ['sonnet-main', { provider: 'claude', family: 'sonnet' }], + ['opus-cannon', { provider: 'claude', family: 'opus' }], + ['haiku-worker', { provider: 'claude', family: 'haiku' }], + ['fable-volume', { provider: 'claude', family: 'fable' }], + ['astra-regular', { provider: 'codex', family: 'astra' }], + ['sol-regular', { provider: 'codex', family: 'sol' }], + ['luna-regular', { provider: 'codex', family: 'luna' }], +]); +const statisticsTokens = new Map([ + ['cache-wizard', 'claude'], ['claude-output', 'claude'], ['claude-cache-creation', 'claude'], + ['codex-output', 'codex'], ['codex-cache-read', 'codex'], +]); +export function achievementStatisticsDestination(key: string, provider: string): + { section: 'models'; family: string } | { section: 'tokens' } | undefined { + const model = statisticsModels.get(key); + if (model?.provider === provider) return { section: 'models', family: model.family }; + return statisticsTokens.get(key) === provider && (provider === 'claude' || provider === 'codex') + ? { section: 'tokens' } : undefined; +} diff --git a/src/achievementHistoryDates.ts b/src/achievementHistoryDates.ts new file mode 100644 index 00000000..0dffe246 --- /dev/null +++ b/src/achievementHistoryDates.ts @@ -0,0 +1,315 @@ +import {ACHIEVEMENT_DEFINITIONS,ACHIEVEMENT_SERIES,type AchievementId} from './achievements'; +import {achievementRuleVersion} from './achievementRuleVersions'; +import {claudeAchievementFamily,codexModelFamily,normalizeCodexAchievementModel} from './achievementModels'; +import type {AchievementDay,AchievementDayEvidence} from './achievementCalendar'; +import {codexAchievementCalendarProjection} from './achievementCalendar'; +import {claudeMoneyEvidenceFromModels,codexMoneyEvidenceFromModels} from './achievementMoneyScopes'; +import {getAchievementMoneyPricebook,type AchievementMoneyEvidence,type AchievementMoneyBackend} from './achievementMoney'; +import {API_ACHIEVEMENT_DEFINITIONS} from './achievementMoneyCatalog'; +import type {CodexFileAggregate} from './providers/codex/codexIndex'; +import type {UsageData,ClaudeUsageRecord} from './types'; +import {dayKeyInZone} from './dateKeys'; +import {historyRegistrationAllows,ACHIEVEMENT_HISTORY_CATALOG_SIGNATURE} from './achievementHistoryRegistry'; +import {ClaudeDataLoader} from './dataLoader'; + +/** Derived, runtime-only annotations. Never an award receipt or account-history claim. */ +export interface AchievementHistoryDay extends AchievementDay { + cacheRead?:number;cacheCreation?:number;money?:AchievementMoneyEvidence; +} +export interface AchievementSessionHistoryDay {day:string;outputSessions:number;fableSessions:number;mixedSessions:number} +export interface AchievementHistoryInput { + timeZone:string;asOfDay:string; + claude:Omit & {days:readonly AchievementHistoryDay[]}; + codex:Omit & {days:readonly AchievementHistoryDay[]}; + enabled:{claude:boolean;codex:boolean}; + claudeSessions?:readonly AchievementSessionHistoryDay[]; +} +export interface AchievementHistoryDate { + schemaVersion:1;ruleVersion:number;day:string;timeZone:string; + scope:'retained-local-history'|'verified-subset'; + pricebookVersion?:string; +} +const DAY=86400000; +const count=(n:unknown):n is number=>typeof n==='number'&&Number.isSafeInteger(n)&&n>=0; +const ordinal=(day:string):number=>{ + const n=typeof day==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(day)?Date.parse(day+'T00:00:00Z'):NaN; + return Number.isFinite(n)&&new Date(n).toISOString().slice(0,10)===day?n:NaN; +}; +const label=(time:number)=>new Date(time).toISOString().slice(0,10); +const monday=(n:number)=>n-((new Date(n).getUTCDay()+6)%7)*DAY; +const rules=new Map(ACHIEVEMENT_DEFINITIONS.map(r=>[r.id,r])); +const SCALE=10n**18n; +/** All-session provenance stays within this reducer; the output carries counts only. + * A Sessions UI cap or a basename without parent ownership cannot provide dates. */ +export function claudeAchievementSessionHistory(records:readonly ClaudeUsageRecord[],zone:string,now=Date.now(), + eligible?:ReadonlySet):readonly AchievementSessionHistoryDay[]{ + try{new Intl.DateTimeFormat('en',{timeZone:zone});}catch{return [];} + if(!eligible)return []; + const sessions=new Map(); + for(const r of records){ + if((r as ClaudeUsageRecord&{_unownedAgentSession?:true})._unownedAgentSession||!r._sessionId||r._sessionId==='unknown'||!eligible.has(r._sessionId))continue; + const time=Date.parse(r.timestamp),output=r.message?.usage?.output_tokens; + if(!Number.isFinite(time)||time>now||time<=0)continue; + const state=sessions.get(r._sessionId)??{output:Infinity,fable:Infinity,other:Infinity,admitted:Infinity}; + // Use the existing metadata-only reducer's exact prompt/cost admission. A + // session that becomes eligible later must not inherit an earlier free output. + if(time0||metadata.totalCost>0)state.admitted=time; + } + sessions.set(r._sessionId,state); + if(r._isUserPrompt||r.isApiErrorMessage||!r.message.model||r.message.model===''||!count(output)||output===0)continue; + state.output=Math.min(state.output,time); + const family=claudeAchievementFamily(r.message.model??''); + if(family==='fable')state.fable=Math.min(state.fable,time); + else if(family)state.other=Math.min(state.other,time); + sessions.set(r._sessionId,state); + } + const days=new Map(); + const add=(time:number,key:'outputSessions'|'fableSessions'|'mixedSessions')=>{ + if(!Number.isFinite(time))return; + const day=dayKeyInZone(new Date(time),zone),row=days.get(day)??{day,outputSessions:0,fableSessions:0,mixedSessions:0}; + row[key]++;days.set(day,row); + }; + for(const s of sessions.values()){ + add(Math.max(s.output,s.admitted),'outputSessions'); + add(Math.max(s.fable,s.admitted),'fableSessions');add(Math.max(s.fable,s.other,s.admitted),'mixedSessions'); + } + return [...days.values()].sort((a,b)=>a.day.localeCompare(b.day)); +} +export function claudeAchievementHistoryEvidence(proof:AchievementDayEvidence, + days:readonly {date:string;data:UsageData}[],backend:AchievementMoneyBackend):AchievementHistoryInput['claude']{ + const rows=new Map(days.map(r=>[r.date,r.data])); + return {...proof,days:proof.days.map(row=>{ + const data=rows.get(row.day); + if(!data)return row; + const raw=claudeMoneyEvidenceFromModels(data.modelBreakdown,backend); + const money=['complete','partial'].includes(raw.status)?raw as AchievementMoneyEvidence:undefined; + return {...row,cacheRead:data.totalCacheReadTokens,cacheCreation:data.totalCacheCreationTokens,...(money?{money}:{})}; + })}; +} +/** Dates/prices/cache are admitted only from the fully dated file selection AFTER + * full-census ancestry/witness validation. A row copied from legacy byDay is not proof. */ +export function codexAchievementHistoryEvidence(files:readonly CodexFileAggregate[],zone:string,indexed:boolean, + calendarComplete:boolean):AchievementHistoryInput['codex']{ + const projection=codexAchievementCalendarProjection(files,zone,indexed,calendarComplete); + const rows=new Map(projection.completeDateEvidence.days.map(r=>[r.day,{...r,cacheRead:0, + units:0n,totalTokens:0,pricedTokens:0,moneyKnown:true}])); + for(const file of projection.completeDateFiles)for(const [day,slice]of Object.entries(file.period!.days)){ + const row=rows.get(day);if(!row)continue; + row.cacheRead+=slice.total.cachedInput??0; + const priced=codexMoneyEvidenceFromModels(slice.byModel,'openai'); + if(!['complete','partial','empty','unpriced'].includes(priced.status)){row.moneyKnown=false;continue;} + const money=priced as AchievementMoneyEvidence; + row.units+=BigInt(money.exactMicroUsdUnits);row.totalTokens+=money.totalTokens;row.pricedTokens+=money.pricedTokens; + } + const days:AchievementHistoryDay[]=[...rows.values()].map(r=>{ + const {units,totalTokens,pricedTokens,moneyKnown,...day}=r; + if(!moneyKnown||!count(totalTokens)||!count(pricedTokens)||units/SCALE>BigInt(Number.MAX_SAFE_INTEGER))return day; + const money:AchievementMoneyEvidence={provider:'codex',backend:'openai',pricebookVersion:getAchievementMoneyPricebook('openai').version, + exactMicroUsdUnits:units.toString(),microUsd:Number(units/SCALE),totalTokens,pricedTokens, + unpricedTokens:totalTokens-pricedTokens,pricingCoverage:totalTokens?pricedTokens/totalTokens:0, + status:totalTokens===0?'empty':pricedTokens===0?'unpriced':pricedTokens(); + let total=0,output=0; + for(const row of proof.days){ + if(!row||!Number.isFinite(ordinal(row.day))||seen.has(row.day)||!count(row.processed)||!count(row.output)|| + row.output>row.processed||!row.models||typeof row.models!=='object'||Array.isArray(row.models))return; + seen.add(row.day); + let p=0,o=0; + for(const vector of Object.values(row.models) as Array<{processed:number;output:number}>){ + if(!vector||!count(vector.processed)||!count(vector.output)||vector.output>vector.processed)return; + p+=vector.processed;o+=vector.output; + } + if(!count(p)||!count(o)||p>row.processed||o>row.output)return; + if(ordinal(row.day)>asOf)continue; + total+=row.processed;output+=row.output; + if(!count(total)||!count(output))return; + days.push(row); + } + return days.sort((a,b)=>a.day.localeCompare(b.day)); +} +type Family='sonnet'|'opus'|'haiku'|'fable'|'astra'|'sol'|'luna'; +const families:Family[]=['sonnet','opus','haiku','fable','astra','sol','luna']; +/** O(days * supported catalog size); no source I/O, raw identity, timers or ledger writes. + * A retained-log crossing can be later than the true account unlock after rotation. + * All returned labels expressly mean the earliest date this retained proof establishes. */ +export function achievementHistoryDates(input:AchievementHistoryInput):Readonly>>{ + const result:Partial>={}; + const asOf=ordinal(input.asOfDay); + try {if(!Number.isFinite(asOf)||!input.timeZone)return Object.freeze(result); + new Intl.DateTimeFormat('en',{timeZone:input.timeZone}); + }catch{return Object.freeze(result);} + const claude=input.enabled.claude?validated(input.claude,input.timeZone,asOf):[]; + const codex=input.enabled.codex?validated(input.codex,input.timeZone,asOf):[]; + const whole=!!claude&&!!codex&&(!input.enabled.claude||input.claude.complete)&&(!input.enabled.codex||input.codex.complete); + const scope=(provider:'claude'|'codex'|'combined')=>provider==='combined'?!whole: + input[provider].complete!==true; + const mark=(ids:readonly AchievementId[],value:number,time:number,provider:'claude'|'codex'|'combined',counter:string)=>{ + if(!count(value)||time>asOf)return; + for(const id of ids){ + const rule=rules.get(id); + // All handlers below model v1 numeric rules, not the recalibrated joint v2 rules. + if(!rule||!historyRegistrationAllows(id,counter)||rule.group!==provider||value + mark(ACHIEVEMENT_SERIES[name]??[],value,time,provider,name); + const byDay=new Map(); + for(const provider of ['claude','codex'] as const)for(const row of (provider==='claude'?claude:codex)??[]){ + const bucket=byDay.get(row.day)??{};bucket[provider]=row;byDay.set(row.day,bucket); + } + const processed={claude:0,codex:0},outputs={claude:0,codex:0}; + const cache={read:0,creation:0,codexRead:0,readKnown:true,creationKnown:true,codexReadKnown:true}; + const moneyTotals={claude:0n,codex:0n}; + const moneyWeeks={claude:new Map(),codex:new Map()}; + const moneyBooks:Partial>={}; + const moneyBounds={claude:false,codex:false}; + const markMoney=(provider:'claude'|'codex',scopeKey:'cumulative'|'completedMondayWeekPeak',units:bigint,time:number)=>{ + if(units<0n||units/SCALE>BigInt(Number.MAX_SAFE_INTEGER))return; + const ids=API_ACHIEVEMENT_DEFINITIONS.filter(r=>r.group===provider&&r.scope===scopeKey).map(r=>r.id); + mark(ids,Number(units/SCALE),time,provider,provider+'-api-'+(scopeKey==='cumulative'?'total':'week')); + for(const id of ids)if(result[id])result[id]=Object.freeze({...result[id]!, + pricebookVersion:moneyBooks[provider],...(moneyBounds[provider]?{scope:'verified-subset' as const}:{})}); + }; + const familyVolume=Object.fromEntries(families.map(f=>[f,0])) as Record; + const familyDays=Object.fromEntries(families.map(f=>[f,0])) as Record; + const weeks=new Map(); + let active=0,run=0,previous=-Infinity,dual=0; + for(const [day,bucket]of [...byDay].sort(([a],[b])=>a.localeCompare(b))){ + const time=ordinal(day),closed=time(); + const week=weeks.get(monday(time))??{total:0,qualifiedDays:0}; + let dayVolume=0,qualified=false; + for(const provider of ['claude','codex'] as const){ + const row=bucket[provider];if(!row)continue; + processed[provider]+=row.processed;outputs[provider]+=row.output;dayVolume+=row.processed; + qualified ||= row.processed>=1e6&&row.output>0; + for(const [model,vector]of Object.entries(row.models)){ + const normalized=provider==='codex'?normalizeCodexAchievementModel(model):undefined; + const family=provider==='claude'?claudeAchievementFamily(model):normalized?codexModelFamily(normalized):null; + if(!family)continue; + familyVolume[family]+=vector.processed; + const merged=dailyFamilies.get(family)??{processed:0,output:0}; + merged.processed+=vector.processed;merged.output+=vector.output;dailyFamilies.set(family,merged); + } + if(provider==='claude'){ + cache.readKnown &&= count(row.cacheRead)&&row.cacheRead!<=row.processed; + cache.creationKnown &&= count(row.cacheCreation)&&row.cacheCreation!<=row.processed; + if(cache.readKnown)cache.read+=row.cacheRead!; + if(cache.creationKnown)cache.creation+=row.cacheCreation!; + }else{ + cache.codexReadKnown &&= count(row.cacheRead)&&row.cacheRead!<=row.processed; + if(cache.codexReadKnown)cache.codexRead+=row.cacheRead!; + } + const m=row.money; + if(m&&m.provider===provider&&m.backend&&(provider==='claude'?['anthropic','aws-bedrock-in-region'].includes(m.backend):m.backend==='openai')&& + m.pricebookVersion===getAchievementMoneyPricebook(m.backend).version&& + ['complete','partial'].includes(m.status)&&typeof m.exactMicroUsdUnits==='string'&&/^\d{1,40}$/.test(m.exactMicroUsdUnits)&& + count(m.totalTokens)&&m.totalTokens<=row.processed&&count(m.pricedTokens)&&m.pricedTokens<=m.totalTokens&& + count(m.unpricedTokens)&&m.pricedTokens+m.unpricedTokens===m.totalTokens&& + count(m.microUsd)&&BigInt(m.microUsd)===BigInt(m.exactMicroUsdUnits)/SCALE){ + if(moneyBooks[provider]&&moneyBooks[provider]!==m.pricebookVersion)return Object.freeze({}); + moneyBooks[provider]=m.pricebookVersion;moneyBounds[provider] ||= m.status==='partial'||m.totalTokens0){ + moneyBounds[provider]=true; + } + } + if(!count(dayVolume)||!count(processed.claude+processed.codex))return Object.freeze({}); + if(claude){ + series('sonnet-main',familyVolume.sonnet,time,'claude');series('opus-cannon',familyVolume.opus,time,'claude'); + series('haiku-worker',familyVolume.haiku,time,'claude');series('fable-volume',familyVolume.fable,time,'claude'); + series('claude-output',outputs.claude,time,'claude'); + if(cache.readKnown)series('cache-wizard',cache.read,time,'claude'); + if(cache.creationKnown)series('claude-cache-creation',cache.creation,time,'claude'); + } + if(codex){ + for(const f of ['astra','sol','luna']as const)series(f+'-regular',familyVolume[f],time,'codex'); + series('codex-output',outputs.codex,time,'codex'); + if(cache.codexReadKnown)series('codex-cache-read',cache.codexRead,time,'codex'); + } + mark(['hundred-million-club','billion-token-club','ten-billion-token-club','hundred-billion-token-club','trillion-token-club'], + processed.claude+processed.codex,time,'combined','cumulative-volume'); + week.total+=dayVolume; + if(!count(week.total))return Object.freeze({}); + // Existing weekly-volume rule includes the current observed week. + series('weekly-volume',week.total,time,'combined'); + if(closed&&qualified)week.qualifiedDays++; + weeks.set(monday(time),week); + if(dayVolume>0){ + active++;run=time===previous+DAY?run+1:1;previous=time; + series('active-days',active,time,'combined'); + series('active-day-streak',run,time,'combined');mark(['streak-machine'],run,time,'combined','active-day-streak'); + } + if(!closed)continue; + const eligibleAt=time+DAY; + let qualifiedFamilies=0; + for(const [family,vector]of dailyFamilies)if(vector.processed>=1e6&&vector.output>0){ + qualifiedFamilies++;familyDays[family]++; + series(family+'-days',familyDays[family],eligibleAt,['astra','sol','luna'].includes(family)?'codex':'claude'); + } + if(qualifiedFamilies>=3)mark(['three-family-day'],1,eligibleAt,'combined','three-family-day'); + if(whole&&bucket.claude&&bucket.codex&&bucket.claude.processed>=1e6&&bucket.claude.output>0&& + bucket.codex.processed>=1e6&&bucket.codex.output>0){ + dual++;series('dual-provider-days',dual,eligibleAt,'combined');mark(['dual-provider-day'],1,eligibleAt,'combined','dual-provider-days'); + } + } + for(const provider of ['claude','codex']as const){ + for(const [w,units]of [...moneyWeeks[provider]].sort(([a],[b])=>a-b))if(w+7*DAY<=asOf){ + markMoney(provider,'completedMondayWeekPeak',units,w+7*DAY); + } + } + if(input.enabled.claude&&claude&&Array.isArray(input.claudeSessions)){ + let outputs=0,fable=0,mixed=0;const seen=new Set(); + const rows=[...input.claudeSessions].sort((a,b)=>a.day.localeCompare(b.day)); + if(rows.every(r=>Number.isFinite(ordinal(r.day))&&!seen.has(r.day)&&seen.add(r.day)&& + count(r.outputSessions)&&count(r.fableSessions)&&count(r.mixedSessions))){ + for(const r of rows){ + const time=ordinal(r.day);if(time>asOf)continue; + outputs+=r.outputSessions;fable+=r.fableSessions;mixed+=r.mixedSessions; + series('claude-output-sessions',outputs,time,'claude');series('fable-sessions',fable,time,'claude'); + mark(['fable-relay'],mixed,time,'claude','fable-relay'); + } + } + } + // Completion events, including quiet empty weeks, do not depend on a log on Monday. + let activeWeeks=0; + let firstTime=Infinity;for(const [day,rows]of byDay)if((rows.claude?.processed??0)+(rows.codex?.processed??0)>0){ + firstTime=Math.min(firstTime,ordinal(day)); + } + const firstWeek=monday(firstTime)+(firstTime===monday(firstTime)?0:7*DAY); + const completed=[...weeks].filter(([w])=>w+7*DAY<=asOf).sort(([a],[b])=>a-b); + for(const [w,week]of completed){ + if(week.qualifiedDays>=4)activeWeeks++; + series('active-weeks',activeWeeks,w+7*DAY,'combined'); + } + if(whole&&Number.isFinite(firstTime)){ + const starts=new Set(); + for(const w of weeks.keys())for(let back=0;back<4;back++)starts.add(w-back*7*DAY); + for(const start of [...starts].sort((a,b)=>a-b)){ + const end=start+28*DAY; + if(startmonday(asOf))continue; + let sum=0;for(let n=0;n<4;n++)sum+=weeks.get(start+n*7*DAY)?.total??0; + if(count(sum))series('weekly-average',Math.floor(sum/4),end,'combined'); + } + } + return Object.freeze(result); +} diff --git a/src/achievementHistoryRegistry.ts b/src/achievementHistoryRegistry.ts new file mode 100644 index 00000000..89ded68e --- /dev/null +++ b/src/achievementHistoryRegistry.ts @@ -0,0 +1,166 @@ +import {ACHIEVEMENT_DEFINITIONS,ACHIEVEMENT_SERIES} from './achievements'; +import {achievementRuleVersion} from './achievementRuleVersions'; +import {LONG_TERM_ACHIEVEMENT_DEFINITIONS} from './achievementLongTerm'; +import {API_ACHIEVEMENT_DEFINITIONS} from './achievementMoneyCatalog'; + +export const HISTORY_REGISTRY_VERSION=1; +export interface HistoryRuleShape {target:number;group:string;ruleVersion:number;series:string|null;baseId:string|null;scope:string|null} +export interface HistoryRegistration extends HistoryRuleShape {id:string;counter:string} +// Explicit registration, not a live filter over future definitions. Update this +// reviewed manifest deliberately when a rule's historical interpretation changes. +export const HISTORY_DATE_REGISTRATIONS:readonly Readonly[] = Object.freeze([ + Object.freeze({"id":"sonnet-main","counter":"sonnet-main","target":100000000,"group":"claude","ruleVersion":1,"series":"sonnet-main","baseId":null,"scope":null}), + Object.freeze({"id":"sonnet-main-silver","counter":"sonnet-main","target":1000000000,"group":"claude","ruleVersion":1,"series":"sonnet-main","baseId":null,"scope":null}), + Object.freeze({"id":"sonnet-main-gold","counter":"sonnet-main","target":10000000000,"group":"claude","ruleVersion":1,"series":"sonnet-main","baseId":null,"scope":null}), + Object.freeze({"id":"opus-cannon","counter":"opus-cannon","target":100000000,"group":"claude","ruleVersion":1,"series":"opus-cannon","baseId":null,"scope":null}), + Object.freeze({"id":"opus-cannon-silver","counter":"opus-cannon","target":1000000000,"group":"claude","ruleVersion":1,"series":"opus-cannon","baseId":null,"scope":null}), + Object.freeze({"id":"opus-cannon-gold","counter":"opus-cannon","target":10000000000,"group":"claude","ruleVersion":1,"series":"opus-cannon","baseId":null,"scope":null}), + Object.freeze({"id":"haiku-worker","counter":"haiku-worker","target":10000000,"group":"claude","ruleVersion":1,"series":"haiku-worker","baseId":null,"scope":null}), + Object.freeze({"id":"haiku-worker-silver","counter":"haiku-worker","target":100000000,"group":"claude","ruleVersion":1,"series":"haiku-worker","baseId":null,"scope":null}), + Object.freeze({"id":"haiku-worker-gold","counter":"haiku-worker","target":1000000000,"group":"claude","ruleVersion":1,"series":"haiku-worker","baseId":null,"scope":null}), + Object.freeze({"id":"cache-wizard","counter":"cache-wizard","target":100000000,"group":"claude","ruleVersion":1,"series":"cache-wizard","baseId":null,"scope":null}), + Object.freeze({"id":"cache-wizard-silver","counter":"cache-wizard","target":1000000000,"group":"claude","ruleVersion":1,"series":"cache-wizard","baseId":null,"scope":null}), + Object.freeze({"id":"cache-wizard-gold","counter":"cache-wizard","target":10000000000,"group":"claude","ruleVersion":1,"series":"cache-wizard","baseId":null,"scope":null}), + Object.freeze({"id":"claude-output-bronze","counter":"claude-output","target":2000000,"group":"claude","ruleVersion":1,"series":"claude-output","baseId":null,"scope":null}), + Object.freeze({"id":"claude-output-silver","counter":"claude-output","target":20000000,"group":"claude","ruleVersion":1,"series":"claude-output","baseId":null,"scope":null}), + Object.freeze({"id":"claude-output-gold","counter":"claude-output","target":200000000,"group":"claude","ruleVersion":1,"series":"claude-output","baseId":null,"scope":null}), + Object.freeze({"id":"claude-output-sessions-bronze","counter":"claude-output-sessions","target":10,"group":"claude","ruleVersion":1,"series":"claude-output-sessions","baseId":null,"scope":null}), + Object.freeze({"id":"claude-output-sessions-silver","counter":"claude-output-sessions","target":100,"group":"claude","ruleVersion":1,"series":"claude-output-sessions","baseId":null,"scope":null}), + Object.freeze({"id":"claude-output-sessions-gold","counter":"claude-output-sessions","target":1000,"group":"claude","ruleVersion":1,"series":"claude-output-sessions","baseId":null,"scope":null}), + Object.freeze({"id":"claude-cache-creation-bronze","counter":"claude-cache-creation","target":10000000,"group":"claude","ruleVersion":1,"series":"claude-cache-creation","baseId":null,"scope":null}), + Object.freeze({"id":"claude-cache-creation-silver","counter":"claude-cache-creation","target":100000000,"group":"claude","ruleVersion":1,"series":"claude-cache-creation","baseId":null,"scope":null}), + Object.freeze({"id":"claude-cache-creation-gold","counter":"claude-cache-creation","target":1000000000,"group":"claude","ruleVersion":1,"series":"claude-cache-creation","baseId":null,"scope":null}), + Object.freeze({"id":"fable-volume-bronze","counter":"fable-volume","target":50000000,"group":"claude","ruleVersion":1,"series":"fable-volume","baseId":null,"scope":null}), + Object.freeze({"id":"fable-volume-silver","counter":"fable-volume","target":500000000,"group":"claude","ruleVersion":1,"series":"fable-volume","baseId":null,"scope":null}), + Object.freeze({"id":"fable-volume-gold","counter":"fable-volume","target":5000000000,"group":"claude","ruleVersion":1,"series":"fable-volume","baseId":null,"scope":null}), + Object.freeze({"id":"fable-days-bronze","counter":"fable-days","target":7,"group":"claude","ruleVersion":1,"series":"fable-days","baseId":null,"scope":null}), + Object.freeze({"id":"fable-days-silver","counter":"fable-days","target":30,"group":"claude","ruleVersion":1,"series":"fable-days","baseId":null,"scope":null}), + Object.freeze({"id":"fable-days-gold","counter":"fable-days","target":120,"group":"claude","ruleVersion":1,"series":"fable-days","baseId":null,"scope":null}), + Object.freeze({"id":"fable-relay","counter":"fable-relay","target":1,"group":"claude","ruleVersion":1,"series":null,"baseId":null,"scope":null}), + Object.freeze({"id":"fable-sessions-bronze","counter":"fable-sessions","target":10,"group":"claude","ruleVersion":1,"series":"fable-sessions","baseId":null,"scope":null}), + Object.freeze({"id":"fable-sessions-silver","counter":"fable-sessions","target":100,"group":"claude","ruleVersion":1,"series":"fable-sessions","baseId":null,"scope":null}), + Object.freeze({"id":"fable-sessions-gold","counter":"fable-sessions","target":1000,"group":"claude","ruleVersion":1,"series":"fable-sessions","baseId":null,"scope":null}), + Object.freeze({"id":"weekly-volume-bronze","counter":"weekly-volume","target":500000000,"group":"combined","ruleVersion":1,"series":"weekly-volume","baseId":null,"scope":null}), + Object.freeze({"id":"weekly-volume-silver","counter":"weekly-volume","target":2000000000,"group":"combined","ruleVersion":1,"series":"weekly-volume","baseId":null,"scope":null}), + Object.freeze({"id":"weekly-volume-gold","counter":"weekly-volume","target":10000000000,"group":"combined","ruleVersion":1,"series":"weekly-volume","baseId":null,"scope":null}), + Object.freeze({"id":"astra-regular","counter":"astra-regular","target":500000000,"group":"codex","ruleVersion":1,"series":"astra-regular","baseId":null,"scope":null}), + Object.freeze({"id":"astra-regular-silver","counter":"astra-regular","target":5000000000,"group":"codex","ruleVersion":1,"series":"astra-regular","baseId":null,"scope":null}), + Object.freeze({"id":"astra-regular-gold","counter":"astra-regular","target":50000000000,"group":"codex","ruleVersion":1,"series":"astra-regular","baseId":null,"scope":null}), + Object.freeze({"id":"sol-regular","counter":"sol-regular","target":1000000000,"group":"codex","ruleVersion":1,"series":"sol-regular","baseId":null,"scope":null}), + Object.freeze({"id":"sol-regular-silver","counter":"sol-regular","target":10000000000,"group":"codex","ruleVersion":1,"series":"sol-regular","baseId":null,"scope":null}), + Object.freeze({"id":"sol-regular-gold","counter":"sol-regular","target":100000000000,"group":"codex","ruleVersion":1,"series":"sol-regular","baseId":null,"scope":null}), + Object.freeze({"id":"luna-regular","counter":"luna-regular","target":100000000,"group":"codex","ruleVersion":1,"series":"luna-regular","baseId":null,"scope":null}), + Object.freeze({"id":"luna-regular-silver","counter":"luna-regular","target":1000000000,"group":"codex","ruleVersion":1,"series":"luna-regular","baseId":null,"scope":null}), + Object.freeze({"id":"luna-regular-gold","counter":"luna-regular","target":10000000000,"group":"codex","ruleVersion":1,"series":"luna-regular","baseId":null,"scope":null}), + Object.freeze({"id":"active-day-streak","counter":"active-day-streak","target":7,"group":"combined","ruleVersion":1,"series":"active-day-streak","baseId":null,"scope":null}), + Object.freeze({"id":"active-day-streak-silver","counter":"active-day-streak","target":30,"group":"combined","ruleVersion":1,"series":"active-day-streak","baseId":null,"scope":null}), + Object.freeze({"id":"active-day-streak-gold","counter":"active-day-streak","target":100,"group":"combined","ruleVersion":1,"series":"active-day-streak","baseId":null,"scope":null}), + Object.freeze({"id":"active-days-bronze","counter":"active-days","target":30,"group":"combined","ruleVersion":1,"series":"active-days","baseId":null,"scope":null}), + Object.freeze({"id":"active-days-silver","counter":"active-days","target":180,"group":"combined","ruleVersion":1,"series":"active-days","baseId":null,"scope":null}), + Object.freeze({"id":"active-days-gold","counter":"active-days","target":365,"group":"combined","ruleVersion":1,"series":"active-days","baseId":null,"scope":null}), + Object.freeze({"id":"streak-machine","counter":"active-day-streak","target":14,"group":"combined","ruleVersion":1,"series":null,"baseId":null,"scope":null}), + Object.freeze({"id":"hundred-million-club","counter":"cumulative-volume","target":100000000,"group":"combined","ruleVersion":1,"series":"cumulative-volume","baseId":null,"scope":null}), + Object.freeze({"id":"billion-token-club","counter":"cumulative-volume","target":1000000000,"group":"combined","ruleVersion":1,"series":"cumulative-volume","baseId":null,"scope":null}), + Object.freeze({"id":"ten-billion-token-club","counter":"cumulative-volume","target":10000000000,"group":"combined","ruleVersion":1,"series":"cumulative-volume","baseId":null,"scope":null}), + Object.freeze({"id":"hundred-billion-token-club","counter":"cumulative-volume","target":100000000000,"group":"combined","ruleVersion":1,"series":"cumulative-volume","baseId":null,"scope":null}), + Object.freeze({"id":"trillion-token-club","counter":"cumulative-volume","target":1000000000000,"group":"combined","ruleVersion":1,"series":"cumulative-volume","baseId":null,"scope":null}), + Object.freeze({"id":"codex-output-bronze","counter":"codex-output","target":5000000,"group":"codex","ruleVersion":1,"series":"codex-output","baseId":null,"scope":null}), + Object.freeze({"id":"codex-output-silver","counter":"codex-output","target":50000000,"group":"codex","ruleVersion":1,"series":"codex-output","baseId":null,"scope":null}), + Object.freeze({"id":"codex-output-gold","counter":"codex-output","target":500000000,"group":"codex","ruleVersion":1,"series":"codex-output","baseId":null,"scope":null}), + Object.freeze({"id":"codex-cache-read-bronze","counter":"codex-cache-read","target":1000000000,"group":"codex","ruleVersion":1,"series":"codex-cache-read","baseId":null,"scope":null}), + Object.freeze({"id":"codex-cache-read-silver","counter":"codex-cache-read","target":10000000000,"group":"codex","ruleVersion":1,"series":"codex-cache-read","baseId":null,"scope":null}), + Object.freeze({"id":"codex-cache-read-gold","counter":"codex-cache-read","target":100000000000,"group":"codex","ruleVersion":1,"series":"codex-cache-read","baseId":null,"scope":null}), + Object.freeze({"id":"astra-days-bronze","counter":"astra-days","target":7,"group":"codex","ruleVersion":1,"series":"astra-days","baseId":null,"scope":null}), + Object.freeze({"id":"astra-days-silver","counter":"astra-days","target":30,"group":"codex","ruleVersion":1,"series":"astra-days","baseId":null,"scope":null}), + Object.freeze({"id":"astra-days-gold","counter":"astra-days","target":120,"group":"codex","ruleVersion":1,"series":"astra-days","baseId":null,"scope":null}), + Object.freeze({"id":"sol-days-bronze","counter":"sol-days","target":7,"group":"codex","ruleVersion":1,"series":"sol-days","baseId":null,"scope":null}), + Object.freeze({"id":"sol-days-silver","counter":"sol-days","target":30,"group":"codex","ruleVersion":1,"series":"sol-days","baseId":null,"scope":null}), + Object.freeze({"id":"sol-days-gold","counter":"sol-days","target":120,"group":"codex","ruleVersion":1,"series":"sol-days","baseId":null,"scope":null}), + Object.freeze({"id":"luna-days-bronze","counter":"luna-days","target":7,"group":"codex","ruleVersion":1,"series":"luna-days","baseId":null,"scope":null}), + Object.freeze({"id":"luna-days-silver","counter":"luna-days","target":30,"group":"codex","ruleVersion":1,"series":"luna-days","baseId":null,"scope":null}), + Object.freeze({"id":"luna-days-gold","counter":"luna-days","target":120,"group":"codex","ruleVersion":1,"series":"luna-days","baseId":null,"scope":null}), + Object.freeze({"id":"sonnet-days-bronze","counter":"sonnet-days","target":7,"group":"claude","ruleVersion":1,"series":"sonnet-days","baseId":null,"scope":null}), + Object.freeze({"id":"sonnet-days-silver","counter":"sonnet-days","target":30,"group":"claude","ruleVersion":1,"series":"sonnet-days","baseId":null,"scope":null}), + Object.freeze({"id":"sonnet-days-gold","counter":"sonnet-days","target":120,"group":"claude","ruleVersion":1,"series":"sonnet-days","baseId":null,"scope":null}), + Object.freeze({"id":"opus-days-bronze","counter":"opus-days","target":7,"group":"claude","ruleVersion":1,"series":"opus-days","baseId":null,"scope":null}), + Object.freeze({"id":"opus-days-silver","counter":"opus-days","target":30,"group":"claude","ruleVersion":1,"series":"opus-days","baseId":null,"scope":null}), + Object.freeze({"id":"opus-days-gold","counter":"opus-days","target":120,"group":"claude","ruleVersion":1,"series":"opus-days","baseId":null,"scope":null}), + Object.freeze({"id":"haiku-days-bronze","counter":"haiku-days","target":7,"group":"claude","ruleVersion":1,"series":"haiku-days","baseId":null,"scope":null}), + Object.freeze({"id":"haiku-days-silver","counter":"haiku-days","target":30,"group":"claude","ruleVersion":1,"series":"haiku-days","baseId":null,"scope":null}), + Object.freeze({"id":"haiku-days-gold","counter":"haiku-days","target":120,"group":"claude","ruleVersion":1,"series":"haiku-days","baseId":null,"scope":null}), + Object.freeze({"id":"dual-provider-days-bronze","counter":"dual-provider-days","target":7,"group":"combined","ruleVersion":1,"series":"dual-provider-days","baseId":null,"scope":null}), + Object.freeze({"id":"dual-provider-days-silver","counter":"dual-provider-days","target":30,"group":"combined","ruleVersion":1,"series":"dual-provider-days","baseId":null,"scope":null}), + Object.freeze({"id":"dual-provider-days-gold","counter":"dual-provider-days","target":100,"group":"combined","ruleVersion":1,"series":"dual-provider-days","baseId":null,"scope":null}), + Object.freeze({"id":"active-weeks-bronze","counter":"active-weeks","target":4,"group":"combined","ruleVersion":1,"series":"active-weeks","baseId":null,"scope":null}), + Object.freeze({"id":"active-weeks-silver","counter":"active-weeks","target":12,"group":"combined","ruleVersion":1,"series":"active-weeks","baseId":null,"scope":null}), + Object.freeze({"id":"active-weeks-gold","counter":"active-weeks","target":26,"group":"combined","ruleVersion":1,"series":"active-weeks","baseId":null,"scope":null}), + Object.freeze({"id":"weekly-average-bronze","counter":"weekly-average","target":500000000,"group":"combined","ruleVersion":1,"series":"weekly-average","baseId":null,"scope":null}), + Object.freeze({"id":"weekly-average-silver","counter":"weekly-average","target":2000000000,"group":"combined","ruleVersion":1,"series":"weekly-average","baseId":null,"scope":null}), + Object.freeze({"id":"weekly-average-gold","counter":"weekly-average","target":5000000000,"group":"combined","ruleVersion":1,"series":"weekly-average","baseId":null,"scope":null}), + Object.freeze({"id":"three-family-day","counter":"three-family-day","target":1,"group":"combined","ruleVersion":1,"series":null,"baseId":null,"scope":null}), + Object.freeze({"id":"dual-provider-day","counter":"dual-provider-days","target":1,"group":"combined","ruleVersion":1,"series":null,"baseId":null,"scope":null}), + Object.freeze({"id":"sonnet-main-platinum","counter":"sonnet-main","target":20000000000,"group":"claude","ruleVersion":1,"series":"sonnet-main","baseId":"sonnet-main-gold","scope":null}), + Object.freeze({"id":"sonnet-main-legendary","counter":"sonnet-main","target":50000000000,"group":"claude","ruleVersion":1,"series":"sonnet-main","baseId":"sonnet-main-gold","scope":null}), + Object.freeze({"id":"opus-cannon-platinum","counter":"opus-cannon","target":20000000000,"group":"claude","ruleVersion":1,"series":"opus-cannon","baseId":"opus-cannon-gold","scope":null}), + Object.freeze({"id":"opus-cannon-legendary","counter":"opus-cannon","target":50000000000,"group":"claude","ruleVersion":1,"series":"opus-cannon","baseId":"opus-cannon-gold","scope":null}), + Object.freeze({"id":"haiku-worker-platinum","counter":"haiku-worker","target":2000000000,"group":"claude","ruleVersion":1,"series":"haiku-worker","baseId":"haiku-worker-gold","scope":null}), + Object.freeze({"id":"haiku-worker-legendary","counter":"haiku-worker","target":5000000000,"group":"claude","ruleVersion":1,"series":"haiku-worker","baseId":"haiku-worker-gold","scope":null}), + Object.freeze({"id":"fable-volume-platinum","counter":"fable-volume","target":10000000000,"group":"claude","ruleVersion":1,"series":"fable-volume","baseId":"fable-volume-gold","scope":null}), + Object.freeze({"id":"fable-volume-legendary","counter":"fable-volume","target":25000000000,"group":"claude","ruleVersion":1,"series":"fable-volume","baseId":"fable-volume-gold","scope":null}), + Object.freeze({"id":"cache-wizard-platinum","counter":"cache-wizard","target":20000000000,"group":"claude","ruleVersion":1,"series":"cache-wizard","baseId":"cache-wizard-gold","scope":null}), + Object.freeze({"id":"cache-wizard-legendary","counter":"cache-wizard","target":50000000000,"group":"claude","ruleVersion":1,"series":"cache-wizard","baseId":"cache-wizard-gold","scope":null}), + Object.freeze({"id":"claude-output-platinum","counter":"claude-output","target":500000000,"group":"claude","ruleVersion":1,"series":"claude-output","baseId":"claude-output-gold","scope":null}), + Object.freeze({"id":"claude-output-legendary","counter":"claude-output","target":1000000000,"group":"claude","ruleVersion":1,"series":"claude-output","baseId":"claude-output-gold","scope":null}), + Object.freeze({"id":"claude-cache-creation-platinum","counter":"claude-cache-creation","target":2000000000,"group":"claude","ruleVersion":1,"series":"claude-cache-creation","baseId":"claude-cache-creation-gold","scope":null}), + Object.freeze({"id":"claude-cache-creation-legendary","counter":"claude-cache-creation","target":5000000000,"group":"claude","ruleVersion":1,"series":"claude-cache-creation","baseId":"claude-cache-creation-gold","scope":null}), + Object.freeze({"id":"claude-output-sessions-platinum","counter":"claude-output-sessions","target":2500,"group":"claude","ruleVersion":1,"series":"claude-output-sessions","baseId":"claude-output-sessions-gold","scope":null}), + Object.freeze({"id":"claude-output-sessions-legendary","counter":"claude-output-sessions","target":5000,"group":"claude","ruleVersion":1,"series":"claude-output-sessions","baseId":"claude-output-sessions-gold","scope":null}), + Object.freeze({"id":"fable-sessions-platinum","counter":"fable-sessions","target":2500,"group":"claude","ruleVersion":1,"series":"fable-sessions","baseId":"fable-sessions-gold","scope":null}), + Object.freeze({"id":"fable-sessions-legendary","counter":"fable-sessions","target":5000,"group":"claude","ruleVersion":1,"series":"fable-sessions","baseId":"fable-sessions-gold","scope":null}), + Object.freeze({"id":"sonnet-days-platinum","counter":"sonnet-days","target":520,"group":"claude","ruleVersion":1,"series":"sonnet-days","baseId":"sonnet-days-gold","scope":null}), + Object.freeze({"id":"sonnet-days-legendary","counter":"sonnet-days","target":1000,"group":"claude","ruleVersion":1,"series":"sonnet-days","baseId":"sonnet-days-gold","scope":null}), + Object.freeze({"id":"opus-days-platinum","counter":"opus-days","target":520,"group":"claude","ruleVersion":1,"series":"opus-days","baseId":"opus-days-gold","scope":null}), + Object.freeze({"id":"opus-days-legendary","counter":"opus-days","target":1000,"group":"claude","ruleVersion":1,"series":"opus-days","baseId":"opus-days-gold","scope":null}), + Object.freeze({"id":"haiku-days-platinum","counter":"haiku-days","target":520,"group":"claude","ruleVersion":1,"series":"haiku-days","baseId":"haiku-days-gold","scope":null}), + Object.freeze({"id":"haiku-days-legendary","counter":"haiku-days","target":1000,"group":"claude","ruleVersion":1,"series":"haiku-days","baseId":"haiku-days-gold","scope":null}), + Object.freeze({"id":"fable-days-platinum","counter":"fable-days","target":520,"group":"claude","ruleVersion":1,"series":"fable-days","baseId":"fable-days-gold","scope":null}), + Object.freeze({"id":"fable-days-legendary","counter":"fable-days","target":1000,"group":"claude","ruleVersion":1,"series":"fable-days","baseId":"fable-days-gold","scope":null}), + Object.freeze({"id":"active-days-platinum","counter":"active-days","target":750,"group":"combined","ruleVersion":1,"series":"active-days","baseId":"active-days-gold","scope":null}), + Object.freeze({"id":"active-days-legendary","counter":"active-days","target":1500,"group":"combined","ruleVersion":1,"series":"active-days","baseId":"active-days-gold","scope":null}), + Object.freeze({"id":"active-weeks-platinum","counter":"active-weeks","target":52,"group":"combined","ruleVersion":1,"series":"active-weeks","baseId":"active-weeks-gold","scope":null}), + Object.freeze({"id":"active-weeks-legendary","counter":"active-weeks","target":104,"group":"combined","ruleVersion":1,"series":"active-weeks","baseId":"active-weeks-gold","scope":null}), + Object.freeze({"id":"claude-api-total-bronze","counter":"claude-api-total","target":100000000,"group":"claude","ruleVersion":1,"series":"claude-api-total","baseId":null,"scope":"cumulative"}), + Object.freeze({"id":"claude-api-total-silver","counter":"claude-api-total","target":1000000000,"group":"claude","ruleVersion":1,"series":"claude-api-total","baseId":null,"scope":"cumulative"}), + Object.freeze({"id":"claude-api-total-gold","counter":"claude-api-total","target":10000000000,"group":"claude","ruleVersion":1,"series":"claude-api-total","baseId":null,"scope":"cumulative"}), + Object.freeze({"id":"claude-api-total-platinum","counter":"claude-api-total","target":100000000000,"group":"claude","ruleVersion":1,"series":"claude-api-total","baseId":null,"scope":"cumulative"}), + Object.freeze({"id":"claude-api-week-bronze","counter":"claude-api-week","target":100000000,"group":"claude","ruleVersion":1,"series":"claude-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"claude-api-week-silver","counter":"claude-api-week","target":500000000,"group":"claude","ruleVersion":1,"series":"claude-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"claude-api-week-gold","counter":"claude-api-week","target":2500000000,"group":"claude","ruleVersion":1,"series":"claude-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"claude-api-week-platinum","counter":"claude-api-week","target":10000000000,"group":"claude","ruleVersion":1,"series":"claude-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"claude-api-week-legendary","counter":"claude-api-week","target":25000000000,"group":"claude","ruleVersion":1,"series":"claude-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"codex-api-total-bronze","counter":"codex-api-total","target":100000000,"group":"codex","ruleVersion":1,"series":"codex-api-total","baseId":null,"scope":"cumulative"}), + Object.freeze({"id":"codex-api-total-silver","counter":"codex-api-total","target":1000000000,"group":"codex","ruleVersion":1,"series":"codex-api-total","baseId":null,"scope":"cumulative"}), + Object.freeze({"id":"codex-api-total-gold","counter":"codex-api-total","target":10000000000,"group":"codex","ruleVersion":1,"series":"codex-api-total","baseId":null,"scope":"cumulative"}), + Object.freeze({"id":"codex-api-total-platinum","counter":"codex-api-total","target":100000000000,"group":"codex","ruleVersion":1,"series":"codex-api-total","baseId":null,"scope":"cumulative"}), + Object.freeze({"id":"codex-api-week-bronze","counter":"codex-api-week","target":100000000,"group":"codex","ruleVersion":1,"series":"codex-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"codex-api-week-silver","counter":"codex-api-week","target":500000000,"group":"codex","ruleVersion":1,"series":"codex-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"codex-api-week-gold","counter":"codex-api-week","target":2500000000,"group":"codex","ruleVersion":1,"series":"codex-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"codex-api-week-platinum","counter":"codex-api-week","target":10000000000,"group":"codex","ruleVersion":1,"series":"codex-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), + Object.freeze({"id":"codex-api-week-legendary","counter":"codex-api-week","target":25000000000,"group":"codex","ruleVersion":1,"series":"codex-api-week","baseId":null,"scope":"completedMondayWeekPeak"}), +]); +const registered=new Map(HISTORY_DATE_REGISTRATIONS.map(r=>[r.id,r])); +export function matchesAchievementHistoryRegistration(id:string,counter:string,actual:HistoryRuleShape):boolean { + const expected=registered.get(id); + return !!expected&&expected.counter===counter&&expected.target===actual.target&&expected.group===actual.group&& + expected.ruleVersion===actual.ruleVersion&&expected.series===actual.series&&expected.baseId===actual.baseId&&expected.scope===actual.scope; +} +const seriesFor=new Map(Object.entries(ACHIEVEMENT_SERIES).flatMap(([name,ids])=>ids.map(id=>[id,name] as const))); +const shapes=new Map(ACHIEVEMENT_DEFINITIONS.map(r=>[r.id,{target:r.target,group:r.group,ruleVersion:achievementRuleVersion(r.id), + series:seriesFor.get(r.id)??null,baseId:LONG_TERM_ACHIEVEMENT_DEFINITIONS.find(x=>x.id===r.id)?.baseId??null, + scope:API_ACHIEVEMENT_DEFINITIONS.find(x=>x.id===r.id)?.scope??null}])); +export function historyRegistrationAllows(id:string,counter:string):boolean { + const actual=shapes.get(id as typeof ACHIEVEMENT_DEFINITIONS[number]['id']); + return !!actual&&matchesAchievementHistoryRegistration(id,counter,actual); +} +export const ACHIEVEMENT_HISTORY_CATALOG_SIGNATURE=JSON.stringify({algorithm:1,registry:HISTORY_REGISTRY_VERSION, + actual:[...shapes],registered:HISTORY_DATE_REGISTRATIONS}); diff --git a/src/achievementHours.ts b/src/achievementHours.ts new file mode 100644 index 00000000..444b9c1f --- /dev/null +++ b/src/achievementHours.ts @@ -0,0 +1,145 @@ +import type { ClaudeUsageRecord } from './types'; + +const HOUR_MS = 3_600_000; + +export interface HourEvidence { + schemaVersion: 3; + /** Bounded proof retained after a rolling hourly window expires. */ + maxStreak: number; +} + +export function parseHourEvidence(raw: unknown): HourEvidence { + if (!raw || typeof raw !== 'object' || (raw as { schemaVersion?: unknown }).schemaVersion !== 3) { + return { schemaVersion: 3, maxStreak: 0 }; + } + const value = (raw as { maxStreak?: unknown }).maxStreak; + return { + schemaVersion: 3, + maxStreak: typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= 48 + ? value : 0, + }; +} + +export function updateHourEvidence(current: HourEvidence, observed: number): HourEvidence { + if (!Number.isSafeInteger(observed) || observed <= current.maxStreak) return current; + return { schemaVersion: 3, maxStreak: Math.min(48, observed) }; +} + +export function collectClaudeOutputHours( + records: readonly Pick[], + nowMs = Date.now(), +): number[] { + const hours = new Set(); + for (const record of records) { + const output = record.message?.usage?.output_tokens; + const timestamp = Date.parse(record.timestamp); + if (Number.isSafeInteger(output) && output > 0 && Number.isFinite(timestamp) && + timestamp > 0 && timestamp <= nowMs) { + hours.add(Math.floor(timestamp / HOUR_MS)); + } + } + return [...hours].sort((a, b) => a - b); +} + +interface CodexOutputHour { + hour: string; + output: number; +} + +function partsForHour(formatter: Intl.DateTimeFormat, timestamp: number): Record { + return Object.fromEntries(formatter.formatToParts(timestamp) + .filter(({ type }) => type === 'year' || type === 'month' || type === 'day' || + type === 'hour' || type === 'minute') + .map(({ type, value }) => [type, value])); +} + +/** + * The existing Codex sidecar uses local civil hours, not UTC instants. Only + * uniquely mappable whole-hour offsets are accepted; DST repeats and 30/45m + * offsets must not manufacture a continuous absolute-hour streak. + */ +export function collectCodexOutputHours( + rowsByDay: Record, + timeZone: string, + coverageComplete: boolean, + nowMs = Date.now(), +): number[] { + if (!coverageComplete) return []; + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat('en-GB', { + timeZone, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hourCycle: 'h23', + }); + } catch { + return []; + } + const hours = new Set(); + for (const [day, rows] of Object.entries(rowsByDay)) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue; + const utcDay = Date.parse(`${day}T00:00:00.000Z`); + if (!Number.isFinite(utcDay) || new Date(utcDay).toISOString().slice(0, 10) !== day) continue; + const byLocalHour = new Map(); + for (let offset = -18; offset <= 42; offset++) { + const timestamp = utcDay + offset * HOUR_MS; + const parts = partsForHour(formatter, timestamp); + if (`${parts.year}-${parts.month}-${parts.day}` === day && parts.minute === '00') { + const candidates = byLocalHour.get(parts.hour) ?? []; + candidates.push(timestamp / HOUR_MS); + byLocalHour.set(parts.hour, candidates); + } + } + for (const { hour, output } of rows) { + if (!/^(?:[01]\d|2[0-3])$/.test(hour) || !Number.isSafeInteger(output) || output <= 0) continue; + const candidates = byLocalHour.get(hour) ?? []; + if (candidates.length === 1 && (candidates[0] + 1) * HOUR_MS <= nowMs) { + hours.add(candidates[0]); + } + } + } + return [...hours].sort((a, b) => a - b); +} + +/** Adjacent absolute hour IDs, regardless of local midnight or DST. */ +export function longestOutputHourStreak(input: Iterable): number { + const hours = [...new Set([...input].filter((hour) => Number.isSafeInteger(hour) && hour > 0))] + .sort((a, b) => a - b); + let max = 0; + let run = 0; + let previous = -Infinity; + for (const hour of hours) { + run = hour === previous + 1 ? run + 1 : 1; + max = Math.max(max, run); + previous = hour; + } + return max; +} + +/** Output-positive hours, not token peaks or inferred human work time. */ +export function nightOutputPeak(input: Iterable, timeZone: string): boolean { + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat('en-GB', { + timeZone, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', hourCycle: 'h23', + }); + } catch { + return false; + } + const counts = [0, 0, 0]; + const days = new Set(); + const nightDays = new Set(); + for (const utcHour of new Set(input)) { + if (!Number.isSafeInteger(utcHour) || utcHour <= 0) continue; + const parts = partsForHour(formatter, utcHour * HOUR_MS); + const localHour = Number(parts.hour); + if (!Number.isInteger(localHour) || localHour < 0 || localHour > 23) continue; + const day = `${parts.year}-${parts.month}-${parts.day}`; + days.add(day); + const bin = localHour >= 22 || localHour < 6 ? 0 : localHour < 14 ? 1 : 2; + counts[bin] += 1; + if (bin === 0) nightDays.add(day); + } + return days.size >= 14 && nightDays.size >= 5 && + counts[0] >= Math.ceil(Math.max(counts[1], counts[2]) * 1.2); +} diff --git a/src/achievementIcons.ts b/src/achievementIcons.ts new file mode 100644 index 00000000..8cb38935 --- /dev/null +++ b/src/achievementIcons.ts @@ -0,0 +1,168 @@ +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import type { AchievementId } from './achievements'; +import { API_ACHIEVEMENT_DEFINITIONS, type AchievementMoneyId } from './achievementMoneyCatalog'; +import { LONG_TERM_ACHIEVEMENT_DEFINITIONS, type LongTermAchievementId } from './achievementLongTerm'; +const apiImageFiles = Object.fromEntries(API_ACHIEVEMENT_DEFINITIONS.map(row=> + [row.id,row.id.replace(/-(bronze|silver|gold|platinum|legendary)$/,'-v242-v2.png')])) as Record; + +/** Only bundled artwork can appear in the gallery or exported SVG. */ +const BASE_ACHIEVEMENT_IMAGE_FILES: Record, string> = { + ...apiImageFiles, + 'codex-output-bronze': 'codex-output-v242.png', + 'codex-output-silver': 'codex-output-v242.png', + 'codex-output-gold': 'codex-output-v242.png', + 'codex-cache-read-bronze': 'codex-cache-read-v242.png', + 'codex-cache-read-silver': 'codex-cache-read-v242.png', + 'codex-cache-read-gold': 'codex-cache-read-v242.png', + 'astra-days-bronze': 'astra-days-v242.png', + 'astra-days-silver': 'astra-days-v242.png', + 'astra-days-gold': 'astra-days-v242.png', + 'sol-days-bronze': 'sol-days-v242.png', + 'sol-days-silver': 'sol-days-v242.png', + 'sol-days-gold': 'sol-days-v242.png', + 'luna-days-bronze': 'luna-days-v242.png', + 'luna-days-silver': 'luna-days-v242.png', + 'luna-days-gold': 'luna-days-v242.png', + 'sonnet-days-bronze': 'sonnet-days-v242.png', + 'sonnet-days-silver': 'sonnet-days-v242.png', + 'sonnet-days-gold': 'sonnet-days-v242.png', + 'opus-days-bronze': 'opus-days-v242.png', + 'opus-days-silver': 'opus-days-v242.png', + 'opus-days-gold': 'opus-days-v242.png', + 'haiku-days-bronze': 'haiku-days-v242.png', + 'haiku-days-silver': 'haiku-days-v242.png', + 'haiku-days-gold': 'haiku-days-v242.png', + 'dual-provider-days-bronze': 'dual-provider-days-v242.png', + 'dual-provider-days-silver': 'dual-provider-days-v242.png', + 'dual-provider-days-gold': 'dual-provider-days-v242.png', + 'active-weeks-bronze': 'active-weeks-v242.png', + 'active-weeks-silver': 'active-weeks-v242.png', + 'active-weeks-gold': 'active-weeks-v242.png', + 'weekly-average-bronze': 'weekly-average-v242.png', + 'weekly-average-silver': 'weekly-average-v242.png', + 'weekly-average-gold': 'weekly-average-v242.png', + 'three-family-day': 'three-family-day-v242.png', + 'dual-provider-day': 'dual-provider-day-v242.png', + 'sonnet-main': 'claude-sonnet-main.png', + 'sonnet-main-silver': 'claude-sonnet-main.png', + 'sonnet-main-gold': 'claude-sonnet-main.png', + 'opus-cannon': 'claude-opus-cannon.png', + 'opus-cannon-silver': 'claude-opus-cannon.png', + 'opus-cannon-gold': 'claude-opus-cannon.png', + 'haiku-worker': 'claude-haiku-worker.png', + 'haiku-worker-silver': 'claude-haiku-worker.png', + 'haiku-worker-gold': 'claude-haiku-worker.png', + 'cache-wizard': 'claude-cache-wizard.png', + 'cache-wizard-silver': 'claude-cache-wizard.png', + 'cache-wizard-gold': 'claude-cache-wizard.png', + 'claude-output-bronze': 'claude-context-marathon.png', + 'claude-output-silver': 'claude-context-marathon.png', + 'claude-output-gold': 'claude-context-marathon.png', + 'claude-output-sessions-bronze': 'claude-output-sessions-v242.png', + 'claude-output-sessions-silver': 'claude-output-sessions-v242.png', + 'claude-output-sessions-gold': 'claude-output-sessions-v242.png', + 'claude-cache-creation-bronze': 'claude-cache-creation-v242.png', + 'claude-cache-creation-silver': 'claude-cache-creation-v242.png', + 'claude-cache-creation-gold': 'claude-cache-creation-v242.png', + 'fable-volume-bronze': 'claude-fable-day-pass.png', + 'fable-volume-silver': 'claude-fable-day-pass.png', + 'fable-volume-gold': 'claude-fable-day-pass.png', + 'fable-days-bronze': 'fable-days-v242.png', + 'fable-days-silver': 'fable-days-v242.png', + 'fable-days-gold': 'fable-days-v242.png', + 'fable-relay': 'fable-relay-v242.png', + 'fable-sessions-bronze': 'fable-sessions-v242.png', + 'fable-sessions-silver': 'fable-sessions-v242.png', + 'fable-sessions-gold': 'fable-sessions-v242.png', + 'weekly-volume-bronze': 'weekly-volume-v242.png', + 'weekly-volume-silver': 'weekly-volume-v242.png', + 'weekly-volume-gold': 'weekly-volume-v242.png', + 'output-hours-bronze': 'output-hours-v242.png', + 'output-hours-silver': 'output-hours-v242.png', + 'output-hours-gold': 'output-hours-v242.png', + 'night-output-peak': 'combined-three-am-partner.png', + 'settings-explorer-bronze': 'combined-settings-tourist.png', + 'settings-explorer-silver': 'combined-settings-tourist.png', + 'settings-explorer-gold': 'combined-settings-tourist.png', + 'safe-feature-explorer': 'safe-feature-journey-v242.png', + 'local-share-export': 'combined-share-it.png', + 'local-advice-feedback': 'local-advice-feedback-v242.png', + 'astra-regular': 'codex-astra-regular.png', + 'astra-regular-silver': 'codex-astra-regular.png', + 'astra-regular-gold': 'codex-astra-regular.png', + 'sol-regular': 'codex-sol-regular.png', + 'sol-regular-silver': 'codex-sol-regular.png', + 'sol-regular-gold': 'codex-sol-regular.png', + 'luna-regular': 'codex-luna-regular.png', + 'luna-regular-silver': 'codex-luna-regular.png', + 'luna-regular-gold': 'codex-luna-regular.png', + 'agent-standup': 'codex-agent-standup.png', + 'tiny-ai-company': 'codex-agent-standup.png', + 'agent-team-gold': 'codex-agent-standup.png', + 'agent-conductor': 'codex-cyber-foreman.png', + 'agent-conductor-silver': 'codex-cyber-foreman.png', + 'agent-conductor-gold': 'codex-cyber-foreman.png', + 'root-portfolio-bronze': 'root-portfolio-v242.png', + 'root-portfolio-silver': 'root-portfolio-v242.png', + 'root-portfolio-gold': 'root-portfolio-v242.png', + 'agent-participation-bronze': 'codex-tiny-ai-company.png', + 'agent-participation-silver': 'codex-tiny-ai-company.png', + 'agent-participation-gold': 'codex-tiny-ai-company.png', + 'model-relay-roots-bronze': 'model-relay-roots-v242.png', + 'model-relay-roots-silver': 'model-relay-roots-v242.png', + 'model-relay-roots-gold': 'model-relay-roots-v242.png', + 'multi-core-human': 'codex-multi-core-human.png', + 'same-root-models-silver': 'codex-multi-core-human.png', + 'same-root-models-gold': 'codex-multi-core-human.png', + 'effort-explorer': 'codex-effort-explorer.png', + 'effort-breadth-silver': 'codex-effort-explorer.png', + 'effort-spectrum': 'codex-effort-explorer.png', + 'active-day-streak': 'combined-active-day-streak.png', + 'active-day-streak-silver': 'combined-active-day-streak.png', + 'active-day-streak-gold': 'combined-active-day-streak.png', + 'active-days-bronze': 'active-days-v242.png', + 'active-days-silver': 'active-days-v242.png', + 'active-days-gold': 'active-days-v242.png', + 'streak-machine': 'combined-fourteen-day-streak.png', + 'model-rainbow': 'combined-model-rainbow.png', + 'model-rainbow-bronze': 'combined-model-rainbow.png', + 'model-rainbow-gold': 'combined-model-rainbow.png', + 'hundred-million-club': 'combined-hundred-million-club.png', + 'billion-token-club': 'combined-hundred-million-club.png', + 'ten-billion-token-club': 'combined-hundred-million-club.png', + 'hundred-billion-token-club': 'combined-hundred-million-club.png', + 'trillion-token-club': 'combined-hundred-million-club.png', +}; + +// Tiers share an emblem: retain one byte string per file, not per award ID. +export const ACHIEVEMENT_IMAGE_FILES: Record = { + ...BASE_ACHIEVEMENT_IMAGE_FILES, + ...Object.fromEntries(LONG_TERM_ACHIEVEMENT_DEFINITIONS.map(rule=>[rule.id,BASE_ACHIEVEMENT_IMAGE_FILES[rule.baseId]])), +} as Record; +const encoded = new Map(); + +export function achievementImagePath(id: AchievementId): string { + return path.join(__dirname, '..', 'assets', 'achievements', ACHIEVEMENT_IMAGE_FILES[id]); +} + +export function achievementImageDataUrl(id: AchievementId): string { + const filename = ACHIEVEMENT_IMAGE_FILES[id]; + let url = encoded.get(filename); + if (!url) { + url = 'data:image/png;base64,' + readFileSync(achievementImagePath(id)).toString('base64'); + encoded.set(filename, url); + } + return url; +} + +/** Self-contained exported SVG uses only local, allowlisted PNG bytes. */ +export function renderAchievementIcon( + id: AchievementId, + _color: 'currentColor' | '#db8b5b' | '#82a9ed' | '#ad99e2', + position?: { x: number; y: number; size?: number }, +): string { + const coordinates = position ? ` x="${position.x}" y="${position.y}"` : ''; + const size = position?.size ?? 48; + return ``; +} diff --git a/src/achievementLongTerm.ts b/src/achievementLongTerm.ts new file mode 100644 index 00000000..c25dc807 --- /dev/null +++ b/src/achievementLongTerm.ts @@ -0,0 +1,36 @@ +/** Additive long-term ranks. Existing IDs/qualifications/first three targets stay + * unchanged. The base rule supplies the same verified counter, label and art; + * this catalog is not a new scanner, score or productivity classification. */ +export const LONG_TERM_ACHIEVEMENT_DEFINITIONS = [ + {id:'sonnet-main-platinum',series:'sonnet-main',baseId:'sonnet-main-gold',group:'claude',target:20_000_000_000}, + {id:'sonnet-main-legendary',series:'sonnet-main',baseId:'sonnet-main-gold',group:'claude',target:50_000_000_000}, + {id:'opus-cannon-platinum',series:'opus-cannon',baseId:'opus-cannon-gold',group:'claude',target:20_000_000_000}, + {id:'opus-cannon-legendary',series:'opus-cannon',baseId:'opus-cannon-gold',group:'claude',target:50_000_000_000}, + {id:'haiku-worker-platinum',series:'haiku-worker',baseId:'haiku-worker-gold',group:'claude',target:2_000_000_000}, + {id:'haiku-worker-legendary',series:'haiku-worker',baseId:'haiku-worker-gold',group:'claude',target:5_000_000_000}, + {id:'fable-volume-platinum',series:'fable-volume',baseId:'fable-volume-gold',group:'claude',target:10_000_000_000}, + {id:'fable-volume-legendary',series:'fable-volume',baseId:'fable-volume-gold',group:'claude',target:25_000_000_000}, + {id:'cache-wizard-platinum',series:'cache-wizard',baseId:'cache-wizard-gold',group:'claude',target:20_000_000_000}, + {id:'cache-wizard-legendary',series:'cache-wizard',baseId:'cache-wizard-gold',group:'claude',target:50_000_000_000}, + {id:'claude-output-platinum',series:'claude-output',baseId:'claude-output-gold',group:'claude',target:500_000_000}, + {id:'claude-output-legendary',series:'claude-output',baseId:'claude-output-gold',group:'claude',target:1_000_000_000}, + {id:'claude-cache-creation-platinum',series:'claude-cache-creation',baseId:'claude-cache-creation-gold',group:'claude',target:2_000_000_000}, + {id:'claude-cache-creation-legendary',series:'claude-cache-creation',baseId:'claude-cache-creation-gold',group:'claude',target:5_000_000_000}, + {id:'claude-output-sessions-platinum',series:'claude-output-sessions',baseId:'claude-output-sessions-gold',group:'claude',target:2_500}, + {id:'claude-output-sessions-legendary',series:'claude-output-sessions',baseId:'claude-output-sessions-gold',group:'claude',target:5_000}, + {id:'fable-sessions-platinum',series:'fable-sessions',baseId:'fable-sessions-gold',group:'claude',target:2_500}, + {id:'fable-sessions-legendary',series:'fable-sessions',baseId:'fable-sessions-gold',group:'claude',target:5_000}, + {id:'sonnet-days-platinum',series:'sonnet-days',baseId:'sonnet-days-gold',group:'claude',target:520}, + {id:'sonnet-days-legendary',series:'sonnet-days',baseId:'sonnet-days-gold',group:'claude',target:1_000}, + {id:'opus-days-platinum',series:'opus-days',baseId:'opus-days-gold',group:'claude',target:520}, + {id:'opus-days-legendary',series:'opus-days',baseId:'opus-days-gold',group:'claude',target:1_000}, + {id:'haiku-days-platinum',series:'haiku-days',baseId:'haiku-days-gold',group:'claude',target:520}, + {id:'haiku-days-legendary',series:'haiku-days',baseId:'haiku-days-gold',group:'claude',target:1_000}, + {id:'fable-days-platinum',series:'fable-days',baseId:'fable-days-gold',group:'claude',target:520}, + {id:'fable-days-legendary',series:'fable-days',baseId:'fable-days-gold',group:'claude',target:1_000}, + {id:'active-days-platinum',series:'active-days',baseId:'active-days-gold',group:'combined',target:750}, + {id:'active-days-legendary',series:'active-days',baseId:'active-days-gold',group:'combined',target:1_500}, + {id:'active-weeks-platinum',series:'active-weeks',baseId:'active-weeks-gold',group:'combined',target:52}, + {id:'active-weeks-legendary',series:'active-weeks',baseId:'active-weeks-gold',group:'combined',target:104}, +] as const; +export type LongTermAchievementId = typeof LONG_TERM_ACHIEVEMENT_DEFINITIONS[number]['id']; diff --git a/src/achievementModels.ts b/src/achievementModels.ts new file mode 100644 index 00000000..632d5ddc --- /dev/null +++ b/src/achievementModels.ts @@ -0,0 +1,40 @@ +export type CodexModelFamily = 'astra' | 'sol' | 'luna'; +export type ClaudeModelFamily = 'sonnet' | 'opus' | 'haiku'; + +// Union of the existing Fable and Bedrock-pricing prefix contracts. This is +// label recognition, not proof of routing or actual upstream availability. +const CLAUDE_PREFIX = '(?:(?:global|us|eu|jp|apac|in)\\.anthropic\\.)?'; +const FABLE_MODEL_ID = new RegExp(`^${CLAUDE_PREFIX}claude-(?:fable|mythos)-5(?:-1)?(?:\\[1m\\])?$`, 'i'); +const CLAUDE_OTHER_MODEL_ID = new RegExp(`^${CLAUDE_PREFIX}claude-(?:3-\\d-)?(sonnet|opus|haiku)(?:-\\d(?:-\\d)?(?:-\\d{8})?|-\\d{8})(?:\\[1m\\])?(?:-v\\d+:\\d+)?$`, 'i'); + +/** Dashboard pricing fallback is deliberately not an achievement proof. */ +export function claudeAchievementFamily(model: string): ClaudeModelFamily | 'fable' | null { + const name = model.trim(); + if (FABLE_MODEL_ID.test(name)) return 'fable'; + const match = CLAUDE_OTHER_MODEL_ID.exec(name); + return match ? match[1].toLowerCase() as ClaudeModelFamily : null; +} + +/** Preserve the public family classifier; callers separately admit full IDs. */ +export function codexModelFamily(model: string): CodexModelFamily | null { + const match = /^gpt-\d+(?:\.\d+)?-(astra|sol|luna)(?:-|$)/i.exec(model.trim()); + return match ? match[1].toLowerCase() as CodexModelFamily : null; +} + +// Retain the old explicitly admitted non-family models and their native +// openai/ aliases, independently of monetary rates or pricebook revisions. +const LEGACY_CODEX_MODELS = new Set([ + 'gpt-5.5', 'gpt-5.4', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano', + 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini', +]); +// New generations of named families may be unpriced. Arbitrary trailing +// words (custom/proxy/etc.) and unnamed families are never admitted. Keep a +// snapshot suffix in its identity; do not guess that it is the base model. +const CODEX_NAMED_MODEL = /^gpt-\d+(?:\.\d+)?-(?:astra|sol|luna|terra)(?:-(?:\d{8}|\d{4}-\d{2}-\d{2}))?$/; + +/** Strict recorded labels, not a claim that a remote model really exists. */ +export function normalizeCodexAchievementModel(label: string): string | null { + const name = label.trim().toLowerCase(); + const model = name.startsWith('openai/') ? name.slice('openai/'.length) : name; + return LEGACY_CODEX_MODELS.has(model) || CODEX_NAMED_MODEL.test(model) ? model : null; +} diff --git a/src/achievementMoney.ts b/src/achievementMoney.ts new file mode 100644 index 00000000..3b898b7f --- /dev/null +++ b/src/achievementMoney.ts @@ -0,0 +1,317 @@ +import type { TokenUsage } from './pricing'; +import type { ProviderTokenCounts, UsageProvider } from './providers/providerTypes'; + +export type AchievementMoneyBackend = 'anthropic' | 'aws-bedrock-in-region' | 'openai'; +export type AchievementDecimalRate = string; +export interface AchievementMoneyRates { + /** Decimal USD per million tokens, numerically equal to microUSD per token. + * Unsigned ordinary decimal notation, at most 18 fractional digits; no + * exponents, coercion or binary floating point. Missing is distinct from 0. */ + readonly input?: AchievementDecimalRate; + readonly output?: AchievementDecimalRate; + readonly cacheRead?: AchievementDecimalRate; + readonly cacheWrite5m?: AchievementDecimalRate; + readonly cacheWrite1h?: AchievementDecimalRate; +} +export interface AchievementMoneyPricebook { + readonly version: string; + readonly referenceDate: '2026-10-09'; + readonly backend: AchievementMoneyBackend; + readonly rateUnit: 'usd-per-million-tokens'; + readonly rounding: 'floor-after-sum'; + readonly aliases: Readonly>; + readonly prices: Readonly>; +} + +// Candidate reference snapshot of src/pricing.ts at +// b2e4f72a84b73e1465bc09f68a8ecb5584d84c53. This is deliberately a versioned +// reference, not a claim about today's bills or a live vendor catalog. Values +// below are written as decimals, never copied from mutable per-token floats. +// Baseline standard rates only: no Fast/Batch/Flex, request-level context +// uplift, subscriptions, discounts or inferred currency conversions. +// Initial coverage is the native Claude and OpenAI IDs explicitly listed +// below. Other proxy vendors remain unpriced even when the dashboard has an +// estimate; adding coverage or aliases requires a new reference version. +const native = (input: string, output: string, cacheRead: string, + cacheWrite5m: string, cacheWrite1h: string): AchievementMoneyRates => + Object.freeze({ input, output, cacheRead, cacheWrite5m, cacheWrite1h }); +const openai = (input: string, output: string, cacheRead: string): AchievementMoneyRates => + Object.freeze({ input, output, cacheRead }); +const opus = native('5', '25', '0.5', '6.25', '10'); +const legacyOpus = native('15', '75', '1.5', '18.75', '30'); +const sonnet = native('3', '15', '0.3', '3.75', '6'); +const anthropic: Readonly> = Object.freeze({ + 'claude-fable-5-1': native('10', '50', '0.25', '12.5', '20'), + 'claude-fable-5': native('10', '50', '1', '12.5', '20'), + 'claude-opus-5-5': native('4', '20', '0.2', '5', '8'), + 'claude-opus-5': opus, + 'claude-opus-4-8': opus, + 'claude-opus-4-7': opus, + 'claude-opus-4-6': opus, + 'claude-opus-4-5': opus, + 'claude-opus-4-1': legacyOpus, + 'claude-opus-4-20250514': legacyOpus, + 'claude-sonnet-5-5': native('2', '10', '0.2', '2.5', '4'), + 'claude-sonnet-5': sonnet, + 'claude-sonnet-4-6': sonnet, + 'claude-sonnet-4-5': sonnet, + 'claude-sonnet-4-20250514': sonnet, + 'claude-3-5-sonnet-20241022': sonnet, + 'claude-haiku-4-5': native('1', '5', '0.1', '1.25', '2'), + 'claude-3-5-haiku-20241022': native('0.8', '4', '0.08', '1', '1.6'), +}); +const openaiPrices: Readonly> = Object.freeze({ + 'gpt-6-astra': openai('10', '50', '1'), + 'gpt-6.1-sol': openai('2', '10', '0.1'), + 'gpt-6-sol': openai('2', '10', '0.2'), + 'gpt-6-luna': openai('0.1', '0.5', '0.01'), + 'gpt-5.6-sol': openai('5', '30', '0.5'), + 'gpt-5.6-terra': openai('2', '12', '0.2'), + 'gpt-5.6-luna': openai('0.2', '1.2', '0.02'), + 'gpt-5.5': openai('5', '30', '0.5'), + 'gpt-5.4': openai('2.5', '15', '0.25'), + 'gpt-5': openai('1.25', '10', '0.125'), + 'gpt-5-mini': openai('0.25', '2', '0.025'), + 'gpt-5-nano': openai('0.05', '0.4', '0.005'), + 'gpt-4.1': openai('2', '8', '0.5'), + 'gpt-4.1-mini': openai('0.4', '1.6', '0.1'), + 'gpt-4.1-nano': openai('0.1', '0.4', '0.025'), + 'gpt-4o': openai('2.5', '10', '1.25'), + 'gpt-4o-mini': openai('0.15', '0.6', '0.075'), + 'o3': openai('2', '8', '0.5'), + 'o4-mini': openai('1.1', '4.4', '0.275'), +}); +const regionalOpus = native('5.5', '27.5', '0.55', '6.875', '11'); +const regionalSonnet = native('3.3', '16.5', '0.33', '4.125', '6.6'); +const bedrock = Object.freeze({ + 'claude-opus-5-5': native('4.4', '22', '0.22', '5.5', '8.8'), + 'claude-opus-5': regionalOpus, + 'claude-opus-4-8': regionalOpus, + 'claude-opus-4-7': regionalOpus, + 'claude-opus-4-6': regionalOpus, + 'claude-opus-4-5': regionalOpus, + 'claude-sonnet-5-5': native('2.2', '11', '0.22', '2.75', '4.4'), + 'claude-sonnet-5': regionalSonnet, + 'claude-sonnet-4-6': regionalSonnet, + 'claude-sonnet-4-5': regionalSonnet, + 'claude-haiku-4-5': native('1.1', '5.5', '0.11', '1.375', '2.2'), +}); + +// This finite list is the entire normalization policy. No substring matching, +// date stripping, bracket stripping, case folding or guessed family aliases. +const aliasEntries: Record = Object.create(null); +for (const model of Object.keys(anthropic)) { + aliasEntries[model] = model; + aliasEntries[`anthropic/${model}`] = model; +} +for (const model of Object.keys(openaiPrices)) { + aliasEntries[model] = model; + aliasEntries[`openai/${model}`] = model; +} +Object.assign(aliasEntries, { + 'claude-mythos-5': 'claude-fable-5', + 'claude-mythos-5-1': 'claude-fable-5-1', + 'anthropic/claude-mythos-5': 'claude-fable-5', + 'anthropic/claude-mythos-5-1': 'claude-fable-5-1', + 'claude-opus-4-5-20251101': 'claude-opus-4-5', + 'claude-opus-4-1-20250805': 'claude-opus-4-1', + 'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5', + 'claude-haiku-4-5-20251001': 'claude-haiku-4-5', +}); +const aliases = Object.freeze(aliasEntries); +function book(backend: AchievementMoneyBackend, prices: Readonly>): AchievementMoneyPricebook { + return Object.freeze({ + version: `api-reference-2026-10-${backend}-v1`, referenceDate: '2026-10-09', + backend, rateUnit: 'usd-per-million-tokens', rounding: 'floor-after-sum', + aliases, prices: Object.freeze({ ...prices, ...openaiPrices }), + }); +} +const books = Object.freeze({ + openai: book('openai', Object.freeze({})), + anthropic: book('anthropic', anthropic), + 'aws-bedrock-in-region': book('aws-bedrock-in-region', bedrock), +}); + +export function getAchievementMoneyPricebook(backend: AchievementMoneyBackend): AchievementMoneyPricebook { + if (!Object.prototype.hasOwnProperty.call(books, backend)) throw new RangeError('Unsupported achievement backend'); + return books[backend]; +} +/** Recognizes a reference model label, not proof of its remote backend identity. */ +export function normalizeAchievementMoneyModel(model: string | undefined): string | null { + return typeof model === 'string' && Object.prototype.hasOwnProperty.call(aliases, model) + ? aliases[model] : null; +} + +const SCALE = 10n ** 18n; +const MAX = BigInt(Number.MAX_SAFE_INTEGER); +export interface AchievementMoneyComponent { readonly tokens: number; readonly rate?: AchievementDecimalRate } +function count(value: number): bigint { + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError('Token counters must be nonnegative safe integers'); + return BigInt(value); +} +function safe(value: bigint): number { + if (value < 0n || value > MAX) throw new RangeError('Token aggregate overflow'); + return Number(value); +} +function decimalUnits(rate: AchievementDecimalRate | undefined): bigint | null { + if (typeof rate !== 'string' || rate.length > 40 || !/^\d+(?:\.\d{1,18})?$/.test(rate)) return null; + const [whole, fractional = ''] = rate.split('.'); + return BigInt(whole) * SCALE + BigInt(fractional.padEnd(18, '0')); +} +function componentUnits(components: readonly AchievementMoneyComponent[]): bigint | null { + let units = 0n; + for (const component of components) { + const tokens = count(component.tokens); + if (tokens === 0n) continue; + const rate = decimalUnits(component.rate); + if (rate === null) return null; + units += tokens * rate; + } + return units; +} +/** Arithmetic helper only; arbitrary caller rates are never an award pricebook. */ +export function calculateAchievementMicroUsd(components: readonly AchievementMoneyComponent[]): number | null { + const units = componentUnits(components); + if (units === null || units / SCALE > MAX) return null; + return Number(units / SCALE); +} + +export type AchievementMoneyModelBucket = + | { readonly provider: 'claude'; readonly model: string | undefined; readonly usage: TokenUsage } + | { readonly provider: 'codex'; readonly model: string | undefined; readonly tokens: ProviderTokenCounts }; +export type AchievementMoneyUnpricedReason = 'unknown-model' | 'missing-rate' | 'incomplete-cache-ttl' | 'overflow'; +export interface AchievementMoneyModelResult { + readonly canonicalModel: string | null; + readonly microUsd: number | null; + /** Exact decimal integer in 10^-18 microUSD units, for lossless incremental + * reduction. Internal numeric evidence only; never a share DTO. */ + readonly exactMicroUsdUnits: string; + readonly totalTokens: number; + readonly pricedTokens: number; + readonly reason: AchievementMoneyUnpricedReason | null; +} +export interface AchievementMoneyEvidence { + readonly provider: UsageProvider; + readonly pricebookVersion: string; + readonly backend: AchievementMoneyBackend; + readonly microUsd: number | null; + readonly exactMicroUsdUnits: string; + readonly totalTokens: number; + readonly pricedTokens: number; + readonly unpricedTokens: number; + readonly pricingCoverage: number; + readonly status: 'empty' | 'unpriced' | 'partial' | 'complete' | 'overflow'; +} + +/** Prices an already materialized numeric model bucket. Never accepts records, + * paths, timestamps or content. Caller owns identity/date/lineage eligibility. */ +export function priceAchievementModelBucket(bucket: AchievementMoneyModelBucket, + backend: AchievementMoneyBackend): AchievementMoneyModelResult { + const pricebook = getAchievementMoneyPricebook(backend); + const canonicalModel = normalizeAchievementMoneyModel(bucket.model); + const rates = canonicalModel ? pricebook.prices[canonicalModel] : undefined; + let totalTokens: number; + let components: AchievementMoneyComponent[]; + let incompleteTtl = false; + if (bucket.provider === 'claude') { + const usage = bucket.usage; + const input = count(usage.input_tokens); + const output = count(usage.output_tokens); + const read = count(usage.cache_read_input_tokens ?? 0); + const five = count(usage.cache_creation?.ephemeral_5m_input_tokens ?? 0); + const hour = count(usage.cache_creation?.ephemeral_1h_input_tokens ?? 0); + const splitTotal = five + hour; + const flat = usage.cache_creation_input_tokens === undefined ? splitTotal : count(usage.cache_creation_input_tokens); + const writes = flat > splitTotal ? flat : splitTotal; + totalTokens = safe(input + output + read + writes); + // Aggregates lacking TTL cannot prove which price applies. A complete + // split-only shape is valid; residual or contradictory flat totals are not. + incompleteTtl = writes > 0n && (!usage.cache_creation || flat !== splitTotal); + components = [ + { tokens: safe(input), rate: rates?.input }, { tokens: safe(output), rate: rates?.output }, + { tokens: safe(read), rate: rates?.cacheRead }, { tokens: safe(five), rate: rates?.cacheWrite5m }, + { tokens: safe(hour), rate: rates?.cacheWrite1h }, + ]; + } else if (bucket.provider === 'codex') { + const tokens = bucket.tokens; + const input = count(tokens.inputTotal); + const cached = count(tokens.cachedInput ?? 0); + const output = count(tokens.outputTotal); + const reasoning = count(tokens.reasoningOutput ?? 0); + if (cached > input || reasoning > output) throw new RangeError('Invalid Codex token subset'); + if (count(tokens.cacheWriteInput ?? 0) !== 0n) throw new RangeError('Codex has no separate cache write bucket'); + totalTokens = safe(input + output); + components = [ + { tokens: safe(input - cached), rate: rates?.input }, + { tokens: safe(cached), rate: rates?.cacheRead }, { tokens: safe(output), rate: rates?.output }, + ]; + } else { + throw new RangeError('Unsupported achievement provider'); + } + const excluded = (reason: AchievementMoneyUnpricedReason): AchievementMoneyModelResult => ({ + canonicalModel, totalTokens, pricedTokens: 0, microUsd: null, exactMicroUsdUnits: '0', reason, + }); + if (!canonicalModel) return excluded('unknown-model'); + if (incompleteTtl) return excluded('incomplete-cache-ttl'); + if (!rates) return excluded('missing-rate'); + const units = componentUnits(components); + if (units === null) return excluded('missing-rate'); + if (units / SCALE > MAX) return excluded('overflow'); + return { canonicalModel, totalTokens, pricedTokens: totalTokens, + microUsd: Number(units / SCALE), exactMicroUsdUnits: String(units), reason: null }; +} + +function evidence(provider: UsageProvider, backend: AchievementMoneyBackend, + total: bigint, priced: bigint, units: bigint): AchievementMoneyEvidence { + const totalTokens = safe(total); + const pricedTokens = safe(priced); + const rounded = units / SCALE; + const status: AchievementMoneyEvidence['status'] = rounded > MAX ? 'overflow' + : total === 0n ? 'empty' : priced === 0n ? 'unpriced' : priced < total ? 'partial' : 'complete'; + return Object.freeze({ provider, backend, pricebookVersion: getAchievementMoneyPricebook(backend).version, + microUsd: status === 'overflow' || priced === 0n ? null : Number(rounded), + exactMicroUsdUnits: String(units), totalTokens, pricedTokens, + unpricedTokens: safe(total - priced), pricingCoverage: totalTokens > 0 ? pricedTokens / totalTokens : 0, + status }); +} +/** Reduces only caller-selected aggregate buckets. Use independently proven + * global/day/week/task scopes; this function cannot grant scope eligibility. */ +export function achievementMoneyEvidenceFromBuckets(provider: UsageProvider, + buckets: Iterable, backend: AchievementMoneyBackend): AchievementMoneyEvidence { + if (provider !== 'claude' && provider !== 'codex') throw new RangeError('Unsupported achievement provider'); + let total = 0n, priced = 0n, units = 0n; + for (const bucket of buckets) { + if (bucket.provider !== provider) throw new RangeError('Cannot combine achievement providers'); + const result = priceAchievementModelBucket(bucket, backend); + total += BigInt(result.totalTokens); + priced += BigInt(result.pricedTokens); + units += BigInt(result.exactMicroUsdUnits); + } + return evidence(provider, backend, total, priced, units); +} +/** Lossless merge of numeric evidence, e.g. affected model/day buckets. Carry + * exact units through indexes; adding individually floored microUSD loses value. + * Versions/backends/providers may never be mixed. Empty input has no identity. */ +export function mergeAchievementMoneyEvidence(parts: readonly AchievementMoneyEvidence[]): AchievementMoneyEvidence { + if (parts.length === 0) throw new RangeError('At least one evidence item is required'); + const first = parts[0]; + const version = getAchievementMoneyPricebook(first.backend).version; + let total = 0n, priced = 0n, units = 0n; + for (const part of parts) { + if (part.provider !== first.provider) throw new RangeError('Cannot combine achievement providers'); + if (part.backend !== first.backend || part.pricebookVersion !== version) throw new RangeError('Incompatible pricebook version'); + const partTotal = count(part.totalTokens), partPriced = count(part.pricedTokens); + if (partPriced > partTotal || !/^\d{1,100}$/.test(part.exactMicroUsdUnits)) throw new RangeError('Invalid money evidence'); + total += partTotal; + priced += partPriced; + units += BigInt(part.exactMicroUsdUnits); + } + return evidence(first.provider, first.backend, total, priced, units); +} +/** Threshold is a positive integer microUSD, before any whole-dollar display + * rounding. Partial positive coverage can prove a lower bound. This proves + * money only, never dates, identity, notification eligibility or task quality. */ +export function achievementMoneyMeetsThreshold(value: AchievementMoneyEvidence, thresholdMicroUsd: number): boolean { + if (!Number.isSafeInteger(thresholdMicroUsd) || thresholdMicroUsd <= 0) throw new RangeError('Invalid money threshold'); + return (value.status === 'complete' || value.status === 'partial') && value.microUsd !== null && value.microUsd >= thresholdMicroUsd; +} diff --git a/src/achievementMoneyCatalog.ts b/src/achievementMoneyCatalog.ts new file mode 100644 index 00000000..857be099 --- /dev/null +++ b/src/achievementMoneyCatalog.ts @@ -0,0 +1,51 @@ +import type { AchievementMoneyScopes } from './achievementMoneyScopes'; +import {getAchievementMoneyPricebook} from './achievementMoney'; + +/** All targets are exact integer microUSD, never rounded display dollars. */ +export const API_ACHIEVEMENT_DEFINITIONS = [ + {id:'claude-api-total-bronze',group:'claude',target:100_000_000,scope:'cumulative'}, + {id:'claude-api-total-silver',group:'claude',target:1_000_000_000,scope:'cumulative'}, + {id:'claude-api-total-gold',group:'claude',target:10_000_000_000,scope:'cumulative'}, + {id:'claude-api-total-platinum',group:'claude',target:100_000_000_000,scope:'cumulative'}, + {id:'claude-api-week-bronze',group:'claude',target:100_000_000,scope:'completedMondayWeekPeak'}, + {id:'claude-api-week-silver',group:'claude',target:500_000_000,scope:'completedMondayWeekPeak'}, + {id:'claude-api-week-gold',group:'claude',target:2_500_000_000,scope:'completedMondayWeekPeak'}, + {id:'claude-api-week-platinum',group:'claude',target:10_000_000_000,scope:'completedMondayWeekPeak'}, + {id:'claude-api-week-legendary',group:'claude',target:25_000_000_000,scope:'completedMondayWeekPeak'}, + {id:'claude-api-session-bronze',group:'claude',target:10_000_000,scope:'maxSessionTask'}, + {id:'claude-api-session-silver',group:'claude',target:100_000_000,scope:'maxSessionTask'}, + {id:'claude-api-session-gold',group:'claude',target:1_000_000_000,scope:'maxSessionTask'}, + {id:'codex-api-total-bronze',group:'codex',target:100_000_000,scope:'cumulative'}, + {id:'codex-api-total-silver',group:'codex',target:1_000_000_000,scope:'cumulative'}, + {id:'codex-api-total-gold',group:'codex',target:10_000_000_000,scope:'cumulative'}, + {id:'codex-api-total-platinum',group:'codex',target:100_000_000_000,scope:'cumulative'}, + {id:'codex-api-week-bronze',group:'codex',target:100_000_000,scope:'completedMondayWeekPeak'}, + {id:'codex-api-week-silver',group:'codex',target:500_000_000,scope:'completedMondayWeekPeak'}, + {id:'codex-api-week-gold',group:'codex',target:2_500_000_000,scope:'completedMondayWeekPeak'}, + {id:'codex-api-week-platinum',group:'codex',target:10_000_000_000,scope:'completedMondayWeekPeak'}, + {id:'codex-api-week-legendary',group:'codex',target:25_000_000_000,scope:'completedMondayWeekPeak'}, + {id:'codex-api-task-bronze',group:'codex',target:10_000_000,scope:'maxSessionTask'}, + {id:'codex-api-task-silver',group:'codex',target:100_000_000,scope:'maxSessionTask'}, + {id:'codex-api-task-gold',group:'codex',target:1_000_000_000,scope:'maxSessionTask'}, +] as const; +export type AchievementMoneyId = typeof API_ACHIEVEMENT_DEFINITIONS[number]['id']; +export type AchievementMoneyFacts = Partial>; +const known = new Set(API_ACHIEVEMENT_DEFINITIONS.map(row=>row.id)); +export function isAchievementMoneyId(id: string): id is AchievementMoneyId { return known.has(id); } +export function achievementMoneyProgress(facts: AchievementMoneyFacts | undefined): Record { + const value = (row: typeof API_ACHIEVEMENT_DEFINITIONS[number]): number => { + const input=facts?.[row.group]; + if (input?.provider!==row.group) return 0; + const backend=input.backend; + if (!backend || (row.group==='codex' ? backend!=='openai' : backend!=='anthropic' && backend!=='aws-bedrock-in-region') || + input.pricebookVersion!==getAchievementMoneyPricebook(backend).version || + !['complete','partial'].includes(input.coverage?.[row.scope]?.status)) return 0; + const raw=row.scope==='cumulative' ? input.cumulative?.microUsd : input[row.scope]; + return typeof raw==='number' && Number.isSafeInteger(raw) && raw>=0 ? raw : 0; + }; + return Object.fromEntries(API_ACHIEVEMENT_DEFINITIONS.map(row=>[row.id,value(row)])) as Record; +} +export const API_ACHIEVEMENT_SERIES: Record = Object.fromEntries( + [...new Set(API_ACHIEVEMENT_DEFINITIONS.map(row=>row.id.replace(/-(bronze|silver|gold|platinum|legendary)$/,'')))].map(key=> + [key,API_ACHIEVEMENT_DEFINITIONS.filter(row=>row.id.startsWith(key+'-')).map(row=>row.id)]), +); diff --git a/src/achievementMoneyCopy.ts b/src/achievementMoneyCopy.ts new file mode 100644 index 00000000..6dc969f8 --- /dev/null +++ b/src/achievementMoneyCopy.ts @@ -0,0 +1,29 @@ +import type {SupportedLanguage} from './types'; +interface Copy {unit:string;reference:string;coverage:string;unpriced:string;total:string;week:string;session:string;task:string;note:string} +const copies:Record={ + en:{unit:'USD API equivalent',reference:'Achievement reference price',coverage:'Priced {percent}%; unpriced usage remains in Token totals.',unpriced:'Usage is unpriced.', + total:'Cumulative verified API-equivalent resources.',week:'Peak of a completed Monday-start calendar week in the selected timezone.',session:'One confirmed complete session.',task:'One verified task tree, including recorded approval-reviewer resources.',note:'Baseline reference estimate, not a bill or a task-quality score.'}, + 'zh-CN':{unit:'USD API 等价金额',reference:'成就参考价',coverage:'已定价 {percent}%;未定价用量仍计入 Token。',unpriced:'用量尚未定价。', + total:'累计可核验的 API 等价资源投入。',week:'当前时区内,周一起算的已结束自然周峰值。',session:'一次可确认的完整会话。',task:'一次可确认的任务树,含已记录的审批审查器资源。',note:'按基准参考价估算,不是账单或任务质量评分。'}, + 'zh-TW':{unit:'USD API 等效金額',reference:'成就參考價',coverage:'已定價 {percent}%;未定價用量仍計入 Token。',unpriced:'用量尚未定價。', + total:'累計可核驗的 API 等效資源投入。',week:'目前時區內,週一起算的已結束自然週峰值。',session:'一次可確認的完整工作階段。',task:'一次可確認的任務樹,含已記錄的審批審查器資源。',note:'依基準參考價估算,不是帳單或任務品質評分。'}, + 'de-DE':{unit:'USD API-Gegenwert',reference:'Referenzpreis des Nachweises',coverage:'{percent}% bepreist; unbepreiste Nutzung bleibt in Token-Summen.',unpriced:'Nutzung ohne Preis.', + total:'Kumulierte bestätigte API-Ressourcen.',week:'Maximum einer abgeschlossenen Kalenderwoche ab Montag in der gewählten Zeitzone.',session:'Eine bestätigte vollständige Sitzung.',task:'Ein bestätigter Aufgabenbaum einschließlich erfasster Freigabeprüfer-Ressourcen.',note:'Basispreis-Schätzung, keine Rechnung oder Qualitätsbewertung.'}, + ja:{unit:'USD API 等価額',reference:'実績の参照価格',coverage:'価格確認済み {percent}%;未定価の使用量もトークン合計に含みます。',unpriced:'使用量の価格は未確認です。', + total:'確認済み API 資源の累積等価額。',week:'選択したタイムゾーンの月曜開始・終了済み週の最大値。',session:'確認済みの完全な一セッション。',task:'記録済み承認レビュアーの資源を含む、確認済みの一タスクツリー。',note:'基準参照価格の推定値であり、請求額や品質評価ではありません。'}, + ko:{unit:'USD API 등가액',reference:'성과 참조 가격',coverage:'가격 확인 {percent}%; 미확인 사용량도 토큰 합계에 포함됩니다.',unpriced:'사용량 가격 미확인.', + total:'확인된 API 자원의 누적 등가액.',week:'선택한 시간대의 월요일 시작 종료된 주간 최댓값.',session:'확인된 완전한 한 세션.',task:'기록된 승인 검토자 자원을 포함한 확인된 한 작업 트리.',note:'기준 가격 추정치이며 청구액이나 작업 품질 점수가 아닙니다.'}, + 'pt-BR':{unit:'USD equivalente API',reference:'Preço de referência da conquista',coverage:'{percent}% com preço; uso sem preço continua nos totais de tokens.',unpriced:'Uso sem preço.', + total:'Recursos API verificados acumulados.',week:'Pico de uma semana encerrada, iniciada na segunda-feira, no fuso selecionado.',session:'Uma sessão completa confirmada.',task:'Uma árvore de tarefa verificada, incluindo recursos registrados dos revisores de aprovação.',note:'Estimativa de preço-base, não uma fatura nem uma nota de qualidade.'}, + id:{unit:'USD ekuivalen API',reference:'Harga referensi pencapaian',coverage:'{percent}% memiliki harga; penggunaan tanpa harga tetap dalam total token.',unpriced:'Penggunaan belum memiliki harga.', + total:'Akumulasi sumber daya API terverifikasi.',week:'Puncak minggu kalender yang telah selesai, mulai Senin, dalam zona waktu terpilih.',session:'Satu sesi lengkap yang terkonfirmasi.',task:'Satu pohon tugas terverifikasi, termasuk sumber daya peninjau persetujuan yang tercatat.',note:'Estimasi harga dasar, bukan tagihan atau penilaian kualitas tugas.'}, +}; +const earnedReferences:Record={ + en:'Awarded using', 'zh-CN':'授奖依据', 'zh-TW':'授獎依據', + 'de-DE':'Verliehen mit', ja:'授与時の参照価格', ko:'수여 당시 참조 가격', + 'pt-BR':'Concedido com', id:'Diberikan menggunakan', +}; +export function achievementMoneyCopyFor(locale:string):Copy & {earnedReference:string} { + const language=Object.prototype.hasOwnProperty.call(copies,locale)?locale as SupportedLanguage:'en'; + return {...copies[language],earnedReference:earnedReferences[language]}; +} diff --git a/src/achievementMoneyOrigin.ts b/src/achievementMoneyOrigin.ts new file mode 100644 index 00000000..272bf4f7 --- /dev/null +++ b/src/achievementMoneyOrigin.ts @@ -0,0 +1,139 @@ +import { getAchievementMoneyPricebook, type AchievementMoneyBackend, type AchievementMoneyPricebook } from './achievementMoney'; +import { API_ACHIEVEMENT_DEFINITIONS, type AchievementMoneyId } from './achievementMoneyCatalog'; +import type { AchievementMoneyScopes } from './achievementMoneyScopes'; +import type { UsageProvider } from './providers/providerTypes'; + +/** Ephemeral numeric lower bound in 10^-18 microUSD units. No model/source + * labels, dates or ledger authority; callers own the accepted producer window. */ +export interface AchievementMoneyProof { + readonly provider: UsageProvider; + readonly backend: AchievementMoneyBackend; + readonly pricebookVersion: string; + readonly exactMicroUsdUnits: string; + readonly microUsd: number; + readonly eligibleProcessed: number; +} +/** At most two Claude books or one Codex book, never arbitrary backend keys. */ +export type AchievementMoneyProofs = Partial>; +const SCALE = 10n ** 18n; +const count = (n: unknown): n is number => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0; +const object = (v: unknown): v is Record => v !== null && typeof v === 'object' && !Array.isArray(v); +const units = (v: unknown): v is string => typeof v === 'string' && /^(?:0|[1-9][0-9]{0,63})$/.test(v); +const allowed = (provider: UsageProvider, backend: AchievementMoneyBackend): boolean => + provider === 'codex' ? backend === 'openai' + : provider === 'claude' && (backend === 'anthropic' || backend === 'aws-bedrock-in-region'); + +// Lazy, finite frozen-book cache only. Importing this module or copying an +// absent proof performs no price-table traversal. No model distribution is +// inferred: each eligible processed token can cost at most the largest rate. +const maximumRates = new WeakMap>>(); +function maximumRateUnits(provider: UsageProvider, book: AchievementMoneyPricebook): bigint | null { + let cached = maximumRates.get(book); + if (!cached) { cached = {}; maximumRates.set(book, cached); } + if (cached[provider] !== undefined) return cached[provider]!; + const fields = provider === 'codex' ? ['input', 'output', 'cacheRead'] as const + : ['input', 'output', 'cacheRead', 'cacheWrite5m', 'cacheWrite1h'] as const; + let maximum = 0n; + for (const rates of Object.values(book.prices)) { + for (const field of fields) { + const rate = rates[field]; + if (rate === undefined) continue; + if (typeof rate !== 'string' || rate.length > 40 || !/^\d+(?:\.\d{1,18})?$/.test(rate)) { + cached[provider] = null; return null; + } + const [whole, fraction = ''] = rate.split('.'); + const exact = BigInt(whole) * SCALE + BigInt(fraction.padEnd(18, '0')); + if (exact > maximum) maximum = exact; + } + } + cached[provider] = maximum; + return maximum; +} + +export function validateMoneyProof(value: unknown, provider: UsageProvider, backend: AchievementMoneyBackend, + maxProcessed = Number.MAX_SAFE_INTEGER): value is AchievementMoneyProof { + if (!object(value) || !allowed(provider, backend) || !count(maxProcessed) || + !['provider', 'backend', 'pricebookVersion', 'exactMicroUsdUnits', 'microUsd', 'eligibleProcessed'] + .every(field => Object.prototype.hasOwnProperty.call(value, field)) || + value.provider !== provider || value.backend !== backend || + !units(value.exactMicroUsdUnits) || !count(value.microUsd) || + !count(value.eligibleProcessed) || value.eligibleProcessed === 0 || value.eligibleProcessed > maxProcessed) return false; + const book = getAchievementMoneyPricebook(backend); + if (value.pricebookVersion !== book.version) return false; + const exact = BigInt(value.exactMicroUsdUnits); + if (exact / SCALE !== BigInt(value.microUsd)) return false; + const maximum = maximumRateUnits(provider, book); + return maximum !== null && exact <= maximum * BigInt(value.eligibleProcessed); +} +/** Always rebuild fixed fields, including at every worker boundary. */ +export function copyMoneyProof(value: unknown, provider: UsageProvider, backend: AchievementMoneyBackend, + maxProcessed = Number.MAX_SAFE_INTEGER): AchievementMoneyProof | undefined { + if (!validateMoneyProof(value, provider, backend, maxProcessed)) return undefined; + return { provider, backend, pricebookVersion: value.pricebookVersion, + exactMicroUsdUnits: value.exactMicroUsdUnits, microUsd: value.microUsd, eligibleProcessed: value.eligibleProcessed }; +} +export function copyMoneyProofs(value: unknown, provider: UsageProvider, maxProcessed: number): AchievementMoneyProofs | undefined { + if (!object(value)) return undefined; + const result: AchievementMoneyProofs = {}; + for (const backend of ['anthropic', 'aws-bedrock-in-region', 'openai'] as const) { + if (!Object.prototype.hasOwnProperty.call(value, backend)) continue; + const proof = copyMoneyProof(value[backend], provider, backend, maxProcessed); + if (proof) result[backend] = proof; + } + return Object.keys(result).length ? result : undefined; +} +/** Floor once AFTER adding exact decimal units. Failed/mixed/overflowed sums + * return no proof, independently of the native Token delta. */ +export function accumulateMoneyProof(previous: AchievementMoneyProof | undefined, + addition: AchievementMoneyProof): AchievementMoneyProof | undefined { + const next = copyMoneyProof(addition, addition.provider, addition.backend); + if (!next) return undefined; + if (!previous) return next; + const old = copyMoneyProof(previous, next.provider, next.backend); + if (!old) return undefined; + const exact = BigInt(old.exactMicroUsdUnits) + BigInt(next.exactMicroUsdUnits); + const eligibleProcessed = old.eligibleProcessed + next.eligibleProcessed; + return copyMoneyProof({ ...next, eligibleProcessed, exactMicroUsdUnits: String(exact), microUsd: Number(exact / SCALE) }, next.provider, next.backend); +} + +function cumulativeUnits(scope: AchievementMoneyScopes | undefined): bigint | undefined { + const value = scope?.cumulative, backend = scope?.backend; + if (!scope || !value || !backend || !allowed(scope.provider, backend) || + scope.pricebookVersion !== getAchievementMoneyPricebook(backend).version || + value.provider !== scope.provider || value.backend !== backend || value.pricebookVersion !== scope.pricebookVersion || + !['empty', 'unpriced', 'partial', 'complete'].includes(value.status) || scope.coverage?.cumulative?.status !== value.status || + !units(value.exactMicroUsdUnits) || !count(value.totalTokens) || !count(value.pricedTokens) || !count(value.unpricedTokens) || + value.pricedTokens > value.totalTokens || value.unpricedTokens !== value.totalTokens - value.pricedTokens || + value.pricingCoverage !== (value.totalTokens ? value.pricedTokens / value.totalTokens : 0)) return undefined; + const exact = BigInt(value.exactMicroUsdUnits); + const coverage = scope.coverage.cumulative; + if (coverage.totalTokens !== undefined && coverage.totalTokens !== value.totalTokens || + coverage.pricedTokens !== undefined && coverage.pricedTokens !== value.pricedTokens || + coverage.unpricedTokens !== undefined && coverage.unpricedTokens !== value.unpricedTokens || + coverage.pricingCoverage !== undefined && coverage.pricingCoverage !== value.pricingCoverage || + coverage.amountPositive !== undefined && coverage.amountPositive !== (exact > 0n)) return undefined; + if (value.pricedTokens === 0) { + if (exact !== 0n || value.microUsd !== null || value.status !== (value.totalTokens ? 'unpriced' : 'empty')) return undefined; + } else if (!count(value.microUsd) || BigInt(value.microUsd) !== exact / SCALE || + value.status !== (value.pricedTokens < value.totalTokens ? 'partial' : 'complete')) return undefined; + return exact; +} +/** CUMULATIVE ONLY. Same frozen reference and valid history on both sides; + * the admitted amount must be sufficient from before and necessary at after. + * No owner/source/date/ledger authority is inferred by this numeric helper. */ +export function cumulativeCrossings(before: AchievementMoneyScopes | undefined, after: AchievementMoneyScopes | undefined, + money: AchievementMoneyProofs | undefined): AchievementMoneyId[] { + if (!before || !after || before.provider !== after.provider || before.backend !== after.backend || + before.pricebookVersion !== after.pricebookVersion || !after.backend) return []; + const old = cumulativeUnits(before), next = cumulativeUnits(after); + if (old === undefined || next === undefined || next < old) return []; + const proof = copyMoneyProofs(money, after.provider, after.cumulative!.pricedTokens)?.[after.backend]; + if (!proof) return []; + const addition = BigInt(proof.exactMicroUsdUnits); + if (addition === 0n || addition > next - old) return []; + return API_ACHIEVEMENT_DEFINITIONS.filter(rule => { + if (rule.group !== after.provider || rule.scope !== 'cumulative') return false; + const target = BigInt(rule.target) * SCALE; + return old < target && next >= target && old + addition >= target && next - addition < target; + }).map(rule => rule.id); +} diff --git a/src/achievementMoneyScopes.ts b/src/achievementMoneyScopes.ts new file mode 100644 index 00000000..d08df2af --- /dev/null +++ b/src/achievementMoneyScopes.ts @@ -0,0 +1,430 @@ +import { + getAchievementMoneyPricebook, normalizeAchievementMoneyModel, + type AchievementMoneyBackend, type AchievementMoneyEvidence, + type AchievementMoneyPricebook, +} from './achievementMoney'; +import { verifiedCodexAchievementFiles, validAchievementTokenVector, validAchievementBuckets } from './codexAchievementProof'; +import type { CodexFileAggregate } from './providers/codex/codexIndex'; +import { CODEX_PERIOD_LINEAGE_VERSION } from './providers/codex/codexPeriodIndex'; +import type { ProviderTokenCounts, UsageProvider } from './providers/providerTypes'; +import type { UsageData } from './types'; +import { codexAchievementCalendarProjection, type AchievementDayEvidence } from './achievementCalendar'; + +/** Accept existing materialized model rows without their dashboard cost/count. + * referenceUsage is the admitted TTL-complete subset, never the full bucket. */ +export type ClaudeMoneyModel = Pick; +export type ClaudeMoneyModels = Record; +export type CodexMoneyModels = Readonly>; +export type MoneyEvidence = AchievementMoneyEvidence; +/** No fake zero counts or pricebook identity for malformed input. */ +export type InvalidMoneyEvidence = { readonly status: 'invalid' } & { + readonly [K in Exclude]?: undefined; +}; +export type MoneyEvidenceResult = MoneyEvidence | InvalidMoneyEvidence; +export interface MoneyScopeCoverage { + readonly status: MoneyEvidence['status'] | 'invalid' | 'scopepending'; + readonly pricingCoverage?: number; + readonly totalTokens?: number; + readonly pricedTokens?: number; + readonly unpricedTokens?: number; + /** Preserve real positive amounts even when integer microUSD floors to 0. */ + readonly amountPositive?: boolean; + /** Pricing coverage and date/census coverage are independent. */ + readonly scopeLowerBound?: true; +} +export interface AchievementMoneyScopes { + readonly provider: UsageProvider; + readonly backend?: AchievementMoneyBackend; + readonly pricebookVersion?: string; + readonly cumulative?: MoneyEvidence; + readonly completedMondayWeekPeak?: number; + readonly maxSessionTask?: number; + readonly coverage: { + readonly cumulative: MoneyScopeCoverage; + /** Coverage describes the selected peak, not global coverage. */ + readonly completedMondayWeekPeak: MoneyScopeCoverage; + readonly maxSessionTask: MoneyScopeCoverage; + readonly verifiedFiles?: number; + readonly excludedFiles?: number; + }; +} +export interface ClaudeMoneyDay { readonly date: string; readonly models: ClaudeMoneyModels } +export interface ClaudeMoneyScopesInput { + readonly backend: AchievementMoneyBackend; + readonly asOfDay: string; + readonly allTimeModels: ClaudeMoneyModels; + /** Independently complete numeric date/model projection in caller's zone. + * Missing dates mean verified zero. A UI range or incomplete index is false. */ + readonly days?: { readonly complete: boolean; readonly rows: Iterable }; + /** One complete numeric model bucket per canonical session. Supply the + * uncapped index Iterable, never the Sessions UI's 1000-row projection. */ + readonly allSessionModels?: Iterable; + readonly sessionsComplete?: boolean; +} +export interface CodexMoneyScopesInput { + readonly backend: AchievementMoneyBackend; + readonly asOfDay: string; + /** Canonical, prefix/duplicate-reconciled files from a complete current + * census. Keys are ephemeral parent-resolution input, never output/storage. */ + readonly files: readonly CodexFileAggregate[]; + readonly complete: boolean; + /** Independent host coverage fence: the snapshot may omit unsettled usage + * contributions even while its base byte index is complete. */ + readonly calendarComplete?: boolean; + /** Independent date proof plus each file's complete period/model buckets. + * Caller proves indexedThrough caught up and zone/prefix provenance. Legacy + * byDay and a successful cumulative value cannot replace this proof. */ + readonly dayEvidence?: AchievementDayEvidence; +} + +const SCALE = 10n ** 18n; +const MAX = BigInt(Number.MAX_SAFE_INTEGER); +const DAY_MS = 86_400_000; +const INVALID = Object.freeze({ status: 'invalid' as const }); +const PENDING = Object.freeze({ status: 'scopepending' as const }); +const object = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +function count(value: unknown): bigint { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new RangeError('Invalid aggregate'); + return BigInt(value); +} +function safe(value: bigint): number { + if (value < 0n || value > MAX) throw new RangeError('Invalid aggregate'); + return Number(value); +} +interface Sum { total: bigint; priced: bigint; units: bigint } +const zero = (): Sum => ({ total: 0n, priced: 0n, units: 0n }); +function add(to: Sum, from: Sum): void { + to.total += from.total; to.priced += from.priced; to.units += from.units; + safe(to.total); safe(to.priced); +} +function fromEvidence(e: MoneyEvidence): Sum { + return { total: BigInt(e.totalTokens), priced: BigInt(e.pricedTokens), units: BigInt(e.exactMicroUsdUnits) }; +} +function evidence(provider: UsageProvider, book: AchievementMoneyPricebook, sum: Sum): MoneyEvidence { + const totalTokens = safe(sum.total), pricedTokens = safe(sum.priced); + if (sum.priced > sum.total) throw new RangeError('Invalid subset'); + const amount = sum.units / SCALE; + const status: MoneyEvidence['status'] = amount > MAX ? 'overflow' + : sum.total === 0n ? 'empty' : sum.priced === 0n ? 'unpriced' : sum.priced < sum.total ? 'partial' : 'complete'; + return Object.freeze({ provider, backend: book.backend, pricebookVersion: book.version, + status, microUsd: status === 'overflow' || sum.priced === 0n ? null : Number(amount), + exactMicroUsdUnits: String(sum.units), totalTokens, pricedTokens, + unpricedTokens: safe(sum.total - sum.priced), pricingCoverage: totalTokens ? pricedTokens / totalTokens : 0 }); +} +/** Frozen pricebook decimals only; retain exact units even when an individual + * amount overflows. The base pricing helper deliberately drops that amount. */ +function price(model: string, components: readonly bigint[], + keys: readonly ('input' | 'output' | 'cacheRead' | 'cacheWrite5m' | 'cacheWrite1h')[], + book: AchievementMoneyPricebook): Sum { + const total = components.reduce((a, b) => a + b, 0n); + safe(total); + const canonical = normalizeAchievementMoneyModel(model); + const rates = canonical && Object.prototype.hasOwnProperty.call(book.prices, canonical) ? book.prices[canonical] : undefined; + if (!rates) return { total, priced: 0n, units: 0n }; + let units = 0n; + for (let i = 0; i < components.length; i++) { + if (components[i] === 0n) continue; + const rate = rates[keys[i]]; + // A missing required price excludes this model subset, as in §3.2. + if (typeof rate !== 'string' || rate.length > 40 || !/^\d+(?:\.\d{1,18})?$/.test(rate)) return { total, priced: 0n, units: 0n }; + const [whole, fractional = ''] = rate.split('.'); + units += components[i] * (BigInt(whole) * SCALE + BigInt(fractional.padEnd(18, '0'))); + } + return { total, priced: total, units }; +} +function claudeVector(row: ClaudeMoneyModel): bigint[] { + if (!object(row)) throw new RangeError('Invalid aggregate'); + return [count(row.inputTokens), count(row.outputTokens), count(row.cacheReadTokens), count(row.cacheCreationTokens)]; +} +function claudeEvidence(models: ClaudeMoneyModels, book: AchievementMoneyPricebook): MoneyEvidence { + if (!object(models)) throw new RangeError('Invalid aggregate'); + const sum = zero(); + for (const [model, row] of Object.entries(models)) { + const primary = claudeVector(row); + const total = primary.reduce((a, b) => a + b, 0n); + safe(total); + const known = row.referenceUsage; + if (known === undefined) { add(sum, { total, priced: 0n, units: 0n }); continue; } + if (!object(known)) throw new RangeError('Invalid shadow'); + const components = [count(known.inputTokens), count(known.outputTokens), count(known.cacheReadTokens), + count(known.cacheCreation5mTokens), count(known.cacheCreation1hTokens)]; + if (components[0] > primary[0] || components[1] > primary[1] || components[2] > primary[2] || + components[3] + components[4] > primary[3]) throw new RangeError('Invalid shadow subset'); + const priced = price(model, components, ['input', 'output', 'cacheRead', 'cacheWrite5m', 'cacheWrite1h'], book); + // Unknown TTL records leave ALL of their original four components here. + add(sum, { ...priced, total }); + } + return evidence('claude', book, sum); +} +function codexVector(tokens: ProviderTokenCounts): bigint[] { + if (!validAchievementTokenVector(tokens) || count(tokens.cacheWriteInput ?? 0) !== 0n) throw new RangeError('Invalid aggregate'); + return [count(tokens.inputTotal), count(tokens.outputTotal), count(tokens.cachedInput ?? 0)]; +} +function codexEvidence(models: CodexMoneyModels, book: AchievementMoneyPricebook): MoneyEvidence { + if (!object(models)) throw new RangeError('Invalid aggregate'); + const sum = zero(); + for (const [model, tokens] of Object.entries(models)) { + const [input, output, cached] = codexVector(tokens); + add(sum, price(model, [input - cached, output, cached], ['input', 'output', 'cacheRead'], book)); + } + return evidence('codex', book, sum); +} +/** Pure model aggregation; malformed/unsafe counters return one fixed sentinel. + * Valid evidence retains empty/unpriced/partial/complete/overflow semantics. */ +export function claudeMoneyEvidenceFromModels(models: ClaudeMoneyModels, backend: AchievementMoneyBackend): MoneyEvidenceResult { + try { return claudeEvidence(models, getAchievementMoneyPricebook(backend)); } catch { return INVALID; } +} +export function codexMoneyEvidenceFromModels(models: CodexMoneyModels, backend: AchievementMoneyBackend): MoneyEvidenceResult { + try { return codexEvidence(models, getAchievementMoneyPricebook(backend)); } catch { return INVALID; } +} +function coverage(e: MoneyEvidence): MoneyScopeCoverage { + return Object.freeze({ status: e.status, pricingCoverage: e.pricingCoverage, + totalTokens: e.totalTokens, pricedTokens: e.pricedTokens, unpricedTokens: e.unpricedTokens, + amountPositive: e.exactMicroUsdUnits!=='0' }); +} +function base(provider: UsageProvider, book?: AchievementMoneyPricebook): AchievementMoneyScopes { + return { provider, backend: book?.backend, pricebookVersion: book?.version, coverage: { + cumulative: book ? PENDING : INVALID, completedMondayWeekPeak: book ? PENDING : INVALID, + maxSessionTask: book ? PENDING : INVALID } }; +} +function dayOrdinal(day: string): number { + if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) throw new RangeError('Invalid date'); + const time = Date.parse(day + 'T00:00:00.000Z'); + if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== day) throw new RangeError('Invalid date'); + return time; +} +function monday(day: number): number { return day - ((new Date(day).getUTCDay() + 6) % 7) * DAY_MS; } +interface Peak { value?: number; coverage: MoneyScopeCoverage } +function peak(parts: Iterable, provider: UsageProvider, book: AchievementMoneyPricebook): Peak { + let selected: MoneyEvidence | undefined; + let fallback = evidence(provider, book, zero()); + for (const part of parts) { + // An unsafe candidate prevents a claim about the maximum. No substitute + // cumulative amount and no winner chosen from only nonoverflowing tasks. + if (part.status === 'overflow') return { coverage: coverage(part) }; + if (part.microUsd !== null && (!selected || BigInt(part.exactMicroUsdUnits) > BigInt(selected.exactMicroUsdUnits))) selected = part; + if (part.status === 'unpriced') fallback = part; + } + return selected ? { value: selected.microUsd ?? undefined, coverage: coverage(selected) } : { coverage: coverage(fallback) }; +} +function weekPeak(days: Map, provider: UsageProvider, book: AchievementMoneyPricebook, asOfDay: string): Peak { + const asOf = dayOrdinal(asOfDay); + const weeks = new Map(); + let hasOpenWeek = false; + for (const [day, amount] of days) { + const start = monday(dayOrdinal(day)); + // End is Sunday's civil date; strictly earlier than caller's asOfDay. + if (start + 6 * DAY_MS >= asOf) { hasOpenWeek = true; continue; } + const sum = weeks.get(start) ?? zero(); add(sum, fromEvidence(amount)); weeks.set(start, sum); + } + if (!weeks.size && hasOpenWeek) return { coverage: PENDING }; + return peak([...weeks.values()].map(sum => evidence(provider, book, sum)), provider, book); +} +function claudeSignature(models: ClaudeMoneyModels): string { + // Compare only source numeric vectors, including shadow: equal rounded + // money/total counters cannot prove equal TTL evidence. Cost/count ignored. + return JSON.stringify(Object.entries(models).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([model, row]) => + [model, ...claudeVector(row).map(String), row.referenceUsage === undefined ? null : [ + row.referenceUsage.inputTokens, row.referenceUsage.outputTokens, row.referenceUsage.cacheReadTokens, + row.referenceUsage.cacheCreation5mTokens, row.referenceUsage.cacheCreation1hTokens]])); +} +export function claudeAchievementMoneyScopes(input: ClaudeMoneyScopesInput): AchievementMoneyScopes { + let book: AchievementMoneyPricebook; + try { book = getAchievementMoneyPricebook(input.backend); } catch { return base('claude'); } + const result = base('claude', book); + let cumulative: MoneyEvidence | undefined; + let cumulativeCoverage: MoneyScopeCoverage = INVALID; + try { cumulative = claudeEvidence(input.allTimeModels, book); cumulativeCoverage = coverage(cumulative); } catch { /* independent scopes remain available */ } + let week: Peak = { coverage: PENDING }, task: Peak = { coverage: PENDING }; + if (input.days?.complete === true) { + try { + const days = new Map(), signatures = new Map(); + for (const row of input.days.rows) { + dayOrdinal(row.date); + const value = claudeEvidence(row.models, book), signature = claudeSignature(row.models); + if (signatures.has(row.date) && signatures.get(row.date) !== signature) throw new RangeError('Conflicting day'); + signatures.set(row.date, signature); days.set(row.date, value); + } + week = weekPeak(days, 'claude', book, input.asOfDay); + } catch { week = { coverage: INVALID }; } + } + if (input.sessionsComplete === true && input.allSessionModels !== undefined) { + try { + function* amounts(): Iterable { + for (const models of input.allSessionModels!) yield claudeEvidence(models, book); + } + task = peak(amounts(), 'claude', book); + } catch { task = { coverage: INVALID }; } + } + return { ...result, cumulative, completedMondayWeekPeak: week.value, maxSessionTask: task.value, + coverage: { cumulative: cumulativeCoverage, completedMondayWeekPeak: week.coverage, maxSessionTask: task.coverage } }; +} + +function fileEvidence(file: CodexFileAggregate, book: AchievementMoneyPricebook): MoneyEvidence { + const [input, output] = codexVector(file.total); + const modelEvidence = codexEvidence(file.byModel, book); + const sum = fromEvidence(modelEvidence); + if (sum.total > input + output) throw new RangeError('Invalid model subset'); + sum.total = input + output; // Unattributed total stays an unpriced counter. + return evidence('codex', book, sum); +} +/** Path-compressed iterative resolution: every net file enters exactly one + * root sum. APRs may contribute money, but this module emits no team counts. */ +function codexTaskPeak(files: readonly CodexFileAggregate[], book: AchievementMoneyPricebook): Peak { + const byKey = new Map(files.map(f => [f.session.sessionKey, f])); + const roots = new Map(), sums = new Map(); + for (const file of files) { + let current = file.session.sessionKey; + const chain: string[] = []; + while (!roots.has(current)) { + const entry = byKey.get(current); + if (!entry) throw new RangeError('Unverified parent'); + chain.push(current); + if (entry.session.parentSessionKey === undefined) { roots.set(current, current); break; } + current = entry.session.parentSessionKey; + } + const root = roots.get(current)!; + for (const key of chain) roots.set(key, root); + const sum = sums.get(root) ?? zero(); add(sum, fromEvidence(fileEvidence(file, book))); sums.set(root, sum); + } + return peak([...sums.values()].map(sum => evidence('codex', book, sum)), 'codex', book); +} +const TOKEN_FIELDS = ['inputTotal', 'outputTotal', 'cachedInput', 'cacheWriteInput', 'reasoningOutput', 'sourceTotal'] as const; +function addTokens(to: ProviderTokenCounts, from: ProviderTokenCounts): void { + codexVector(from); + for (const field of TOKEN_FIELDS) to[field] = safe(count(to[field] ?? 0) + count(from[field] ?? 0)); +} +function fits(actual: ProviderTokenCounts, allowed: ProviderTokenCounts): boolean { + return TOKEN_FIELDS.every(field => allowed[field] === undefined || (actual[field] ?? 0) <= allowed[field]!); +} +const emptyTokens = (): ProviderTokenCounts => ({ inputTotal: 0, outputTotal: 0 }); +interface CodexDay { total: ProviderTokenCounts; models: Map } +function dayProofSignature(processed: number, output: number, models: Record): string { + count(processed); count(output); + if (output > processed || !object(models)) throw new RangeError('Invalid day proof'); + let modelTotal = 0n, modelOutput = 0n; + const rows = Object.entries(models).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([model, t]) => { + if (!object(t) || count(t.output) > count(t.processed)) throw new RangeError('Invalid model proof'); + modelTotal += count(t.processed); modelOutput += count(t.output); + return [model, t.processed, t.output]; + }); + if (safe(modelTotal) > processed || safe(modelOutput) > output) throw new RangeError('Invalid day subset'); + return JSON.stringify([processed, output, rows]); +} +function codexMoneyDays(files: readonly CodexFileAggregate[], proof: AchievementDayEvidence, book: AchievementMoneyPricebook): Map { + if (proof.complete !== true || typeof proof.timeZone !== 'string' || !proof.timeZone || !Array.isArray(proof.days)) throw new RangeError('Missing date proof'); + // Validate the supplied zone label without re-bucketing dates; timezone and + // indexed-through provenance remain the caller's independent proof. + new Intl.DateTimeFormat('en-GB', { timeZone: proof.timeZone }); + const proofDays = new Map(); + for (const row of proof.days) { + dayOrdinal(row.day); + const signature = dayProofSignature(row.processed, row.output, row.models); + if (proofDays.has(row.day) && proofDays.get(row.day) !== signature) throw new RangeError('Conflicting proof day'); + proofDays.set(row.day, signature); + } + const days = new Map(); + for (const file of files) { + const period = file.period; + if (!period || period.timeZone !== proof.timeZone || period.lineageVersion !== CODEX_PERIOD_LINEAGE_VERSION || !object(period.days)) throw new RangeError('Missing period proof'); + count(period.lineagePrefixEvents); count(period.indexedThrough); + const total = emptyTokens(), models = new Map(); + for (const [date, slice] of Object.entries(period.days)) { + dayOrdinal(date); + if (!object(slice) || !validAchievementBuckets(slice.byModel, slice.total)) throw new RangeError('Invalid date bucket'); + const day = days.get(date) ?? { total: emptyTokens(), models: new Map() }; + addTokens(total, slice.total); addTokens(day.total, slice.total); + for (const [model, tokens] of Object.entries(slice.byModel)) { + const value = models.get(model) ?? emptyTokens(); addTokens(value, tokens); models.set(model, value); + const daily = day.models.get(model) ?? emptyTokens(); addTokens(daily, tokens); day.models.set(model, daily); + } + days.set(date, day); + } + // Complete date attribution must cover all billable counters. Independent + // proof may be marked complete while a period still omits undated usage. + const allowed = codexVector(file.total), actual = codexVector(total); + if (!fits(total, file.total) || actual.some((value, i) => value !== allowed[i])) throw new RangeError('Incomplete dates'); + for (const [model, tokens] of models) { + const limit = Object.prototype.hasOwnProperty.call(file.byModel, model) ? file.byModel[model] : undefined; + if (!limit || !fits(tokens, limit)) throw new RangeError('Invalid model/date joint'); + } + for (const [model, tokens] of Object.entries(file.byModel)) { + const actualModel = codexVector(models.get(model) ?? emptyTokens()); + if (codexVector(tokens).some((value, i) => value !== actualModel[i])) throw new RangeError('Incomplete model dates'); + } + } + const amounts = new Map(); + for (const [date, day] of days) { + const models = Object.fromEntries([...day.models].map(([model, tokens]) => + [model, { processed: tokens.inputTotal + tokens.outputTotal, output: tokens.outputTotal }])); + if (proofDays.get(date) !== dayProofSignature(safe(count(day.total.inputTotal) + count(day.total.outputTotal)), day.total.outputTotal, models)) throw new RangeError('Date proof mismatch'); + const money = codexEvidence(Object.fromEntries(day.models), book), sum = fromEvidence(money); + sum.total = count(day.total.inputTotal) + count(day.total.outputTotal); + amounts.set(date, evidence('codex', book, sum)); proofDays.delete(date); + } + // Zero-only extra dates are harmless; any unmatched positive proof fails. + for (const signature of proofDays.values()) if (signature !== dayProofSignature(0, 0, {})) throw new RangeError('Unmatched date proof'); + return amounts; +} +export function codexAchievementMoneyScopes(input: CodexMoneyScopesInput): AchievementMoneyScopes { + let book: AchievementMoneyPricebook; + try { book = getAchievementMoneyPricebook(input.backend); } catch { return base('codex'); } + const result = base('codex', book); + if (input.complete !== true) return result; + let files: CodexFileAggregate[]; + try { + if (!Array.isArray(input.files)) throw new RangeError('Missing file census'); + files = verifiedCodexAchievementFiles(input.files); + if (input.files.length && !files.length) throw new RangeError('No verified files'); + } catch { return { ...result, coverage: { cumulative: INVALID, maxSessionTask: INVALID, completedMondayWeekPeak: PENDING } }; } + let cumulative: MoneyEvidence | undefined, cumulativeCoverage: MoneyScopeCoverage = INVALID; + try { + const sum = zero(); for (const file of files) add(sum, fromEvidence(fileEvidence(file, book))); + cumulative = evidence('codex', book, sum); cumulativeCoverage = coverage(cumulative); + } catch { /* no unsafe host values */ } + let task: Peak; + try { task = codexTaskPeak(files, book); } catch { task = { coverage: INVALID }; } + let week: Peak = { coverage: PENDING }; + // Date proof over the full census cannot silently become proof for a newly + // selected subset; caller may supply a separately validated subset instead. + if (input.dayEvidence?.sourceRevision !== undefined) { + try { + const supplied=input.dayEvidence; + // Immutable producer snapshots reuse the internally evaluated projection + // (including its full-census ancestry/witness checks). Mutable inputs are + // re-evaluated. A supplied digest or matching totals never seeds the cache. + const projection=codexAchievementCalendarProjection(input.files,supplied.timeZone ?? '',input.complete,input.calendarComplete!==false); + const current=projection.evidence; + const signatures=(proof:AchievementDayEvidence):string => { + const rows=new Map(); + for (const row of proof.days) { + dayOrdinal(row.day); + if (rows.has(row.day)) throw new RangeError('Duplicate date binding'); + rows.set(row.day,dayProofSignature(row.processed,row.output,row.models)); + } + return JSON.stringify([...rows].sort(([a],[b])=>ab?1:0)); + }; + if (current.sourceRevision!==supplied.sourceRevision || current.complete!==supplied.complete || + current.scope!==supplied.scope || signatures(current)!==signatures(supplied)) throw new RangeError('Retired date binding'); + if (current.complete || current.scope==='verified-subset') { + week=weekPeak(codexMoneyDays(projection.completeDateFiles,projection.completeDateEvidence,book),'codex',book,input.asOfDay); + if (!current.complete) { + // Only a positive lower bound is meaningful for an incomplete scope. + // Do not certify zero/empty weeks, or replace overflow/unpriced states. + if (week.value!==undefined && week.coverage.amountPositive && + (week.coverage.status==='complete' || week.coverage.status==='partial')) { + week={...week,coverage:{...week.coverage,status:'partial',scopeLowerBound:true}}; + } else if (week.coverage.status==='empty') week={coverage:PENDING}; + } + } + } catch { week={coverage:PENDING}; } + } else if (input.dayEvidence?.complete === true && files.length === input.files.length) { + try { week = weekPeak(codexMoneyDays(files, input.dayEvidence, book), 'codex', book, input.asOfDay); } + catch { week = { coverage: INVALID }; } + } + return { ...result, cumulative, completedMondayWeekPeak: week.value, maxSessionTask: task.value, + coverage: { cumulative: cumulativeCoverage, maxSessionTask: task.coverage, completedMondayWeekPeak: week.coverage, + verifiedFiles: files.length, excludedFiles: input.files.length - files.length } }; +} diff --git a/src/achievementOverview.ts b/src/achievementOverview.ts new file mode 100644 index 00000000..20523b73 --- /dev/null +++ b/src/achievementOverview.ts @@ -0,0 +1,143 @@ +import { ACHIEVEMENT_DEFINITIONS, AchievementGroup } from './achievements'; +import { ACHIEVEMENT_GALLERY, achievementGalleryKey } from './achievementGallery'; +import type { AchievementEvidenceState } from './achievementEvidence'; +import { nextAchievementStage } from './achievementStages'; +import type { AchievementId } from './achievements'; +import type { AchievementRecord } from './achievementRecordStore'; + +export interface AchievementOverviewCandidate { + key: string; + group: AchievementGroup; + earnedLevel: number; + earned: boolean; + next: boolean; + progress: number; + target: number; + proof: AchievementEvidenceState; + /** Explicit structural eligibility for NEXT only; absent means unknown. */ + nextEligible?: boolean; +} +export interface AchievementOverviewSelection { badges: string[]; next: string[] } +const order = ACHIEVEMENT_GALLERY.map(entry => entry.key); +const known = new Set(order); +const knownAwardIds = new Set(ACHIEVEMENT_DEFINITIONS.map(rule => rule.id)); + +/** Current recognized family capacity, not usage proof or selected-tab preference. */ +export function modelRainbowNextEligibility( + target: number, providers: { claude?: boolean; codex?: boolean }, +): boolean | undefined { + const formal = ACHIEVEMENT_DEFINITIONS.some(rule => + (rule.id === 'model-rainbow' || rule.id.startsWith('model-rainbow-')) && rule.target === target); + if (!formal || typeof providers.claude !== 'boolean' || typeof providers.codex !== 'boolean') return; + const capacity = (providers.claude ? 4 : 0) + (providers.codex ? 3 : 0); + return target <= capacity; +} + +export interface AchievementUnreadBatch { + total: number; + sequence: number; + records: AchievementRecord[]; +} + +/** Bounded display projection, not a read receipt. Exact levels must not collapse into series. */ +export function selectUnreadAchievementBatch( + records: readonly AchievementRecord[], visibleIds: readonly string[], previous?: AchievementUnreadBatch, +): AchievementUnreadBatch { + const visible = new Set(visibleIds.filter(id => knownAwardIds.has(id))); + const rows = records.filter(row => visible.has(row.id) && row.unread && + (row.origin === 'usage' || row.origin === 'activity')).sort((a, b) => b.seq - a.seq); + // Never widen an issued group when a different window appends a later award. + // A removed/read/hidden/replaced member invalidates the complete old group. + const retained = previous && previous.records.length > 0 && previous.records.length <= 6 && + previous.records.every(old => rows.some(row => row.id === old.id && row.seq === old.seq)); + const selected = retained ? previous.records.map(old => rows.find(row => row.id === old.id && row.seq === old.seq)!) + : rows.slice(0, 6); + return { total: rows.length, sequence: retained ? previous.sequence : Math.max(0, ...selected.map(row => row.seq)), + records: selected.map(row => ({ ...row })) }; +} + +/** Read-only views of validated local records; chronology is recording order, not inferred attainment time. */ +export function projectAchievementRecords(records: readonly AchievementRecord[], visibleIds: readonly string[]): { + recent: AchievementRecord[]; history: AchievementRecord[]; unreadIds: AchievementId[]; +} { + const visible = new Set(visibleIds.filter(id => knownAwardIds.has(id))); + const rows = records.filter(row => visible.has(row.id)); + const recent: AchievementRecord[] = [], seen = new Set(); + const latest = rows.slice().sort((a, b) => b.seq - a.seq); + for (const row of latest) { + if (row.origin !== 'usage' && row.origin !== 'activity') continue; + const key = achievementGalleryKey(row.id)!; + if (seen.has(key)) continue; + seen.add(key); recent.push({ ...row }); + if (recent.length === 3) break; + } + return { recent, history: latest.slice(0, 30).map(row => ({ ...row })), + unreadIds: rows.filter(row => row.unread).map(row => row.id) }; +} + +/** Similar counters share one showcase slot, not one award identity. */ +export function achievementOverviewTopic(key: string): string { + if (key==='cumulative-volume' || /^(claude|codex)-api-total$/.test(key)) return 'resource-volume'; + if (/^(claude|codex)-api-week$/.test(key)) return 'weeks'; + if (/^(claude|codex)-api-(session|task)$/.test(key)) return 'resource-volume'; + if (/^(astra|sol|luna|sonnet|opus|haiku|fable)-days$/.test(key)) return 'activity'; + if (/^(astra|sol|luna|sonnet|opus|haiku|fable)-(regular|main|cannon|worker|volume|sessions)$/.test(key) || key === 'fable-relay') return 'models'; + if (key.startsWith('weekly-')) return 'weeks'; + if (/^(active-|streak-machine|dual-provider-)/.test(key)) return 'activity'; + if (/^(settings-|safe-feature-|local-)/.test(key)) return 'plugin'; + if (/^(agent-|root-portfolio)/.test(key)) return 'agents'; + if (/^(same-root-|model-relay-|model-rainbow|three-family-)/.test(key)) return 'model-breadth'; + if (/^(claude-output|codex-output|codex-cache|claude-cache|cache-wizard)/.test(key)) return 'token-shape'; + return key; +} + +function valid(row: AchievementOverviewCandidate): boolean { + return known.has(row.key) && Number.isSafeInteger(row.earnedLevel) && row.earnedLevel >= 0 && row.earnedLevel <= 5 && + Number.isSafeInteger(row.progress) && row.progress >= 0 && Number.isSafeInteger(row.target) && row.target > 0; +} +function reachable(row: AchievementOverviewCandidate): boolean { + return valid(row) && row.next && row.nextEligible !== false && row.proof === 'ready' && row.progress > 0 && row.progress < row.target && + row.key !== 'output-hours' && row.key !== 'night-output-peak'; +} +function diversify(rows: AchievementOverviewCandidate[], pinned: string[] = []): string[] { + const topics = new Set(pinned.map(achievementOverviewTopic)); + const selected: string[] = pinned.slice(); + for (const row of rows) { + if (selected.length === 3) break; + const topic = achievementOverviewTopic(row.key); + if (topics.has(topic)) continue; + topics.add(topic); selected.push(row.key); + if (selected.length === 3) break; + } + return selected; +} + +/** Only already-collapsed, enabled-provider rows enter this display-only selector. */ +export function selectAchievementOverview( + rows: readonly AchievementOverviewCandidate[], preferred: 'claude' | 'codex' | 'compare', + pins: readonly string[] = [], mode?: 'automatic' | 'manual', +): AchievementOverviewSelection { + const earned = new Set(rows.filter(row => valid(row) && row.earned).map(row => row.key)); + const pinned = [...new Set(pins.map(achievementGalleryKey) + .filter((key): key is string => key !== undefined && earned.has(key)))].slice(0, mode === 'manual' ? 6 : 3); + const tie = (a: AchievementOverviewCandidate, b: AchievementOverviewCandidate): number => + Number(b.group === preferred) - Number(a.group === preferred) || order.indexOf(a.key) - order.indexOf(b.key); + const fraction = (row: AchievementOverviewCandidate): number => row.progress / + (nextAchievementStage(row.key, row.progress, row.target, true) ?? row.target); + return { + badges: mode === 'manual' ? pinned : diversify(rows.filter(row => valid(row) && row.earned) + .sort((a, b) => b.earnedLevel - a.earnedLevel || tie(a, b)), mode === 'automatic' ? [] : pinned), + next: diversify(rows.filter(reachable).sort((a, b) => fraction(b) - fraction(a) || tie(a, b))), + }; +} + +/** During a visit, invalid goals disappear but never invite a reshuffle. */ +export function retainAchievementOverview( + selection: AchievementOverviewSelection, rows: readonly AchievementOverviewCandidate[], +): AchievementOverviewSelection { + const byKey = new Map(rows.filter(valid).map(row => [row.key, row])); + return { + badges: selection.badges.filter(key => byKey.get(key)?.earned).slice(0, 6), + next: selection.next.filter(key => { const row = byKey.get(key); return !!row && reachable(row); }).slice(0, 3), + }; +} diff --git a/src/achievementRecordStore.ts b/src/achievementRecordStore.ts new file mode 100644 index 00000000..fd6ba532 --- /dev/null +++ b/src/achievementRecordStore.ts @@ -0,0 +1,576 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, open, rename, unlink, readdir } from 'node:fs/promises'; +import * as path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_SERIES, AchievementId, newlyUnlockedPluginAchievements, notifiableAchievementIds } from './achievements'; +import { AchievementActivity, emptyAchievementActivity, parseAchievementActivity } from './achievementActivity'; +import { CalendarPeakEvidence, parseCalendarPeakEvidence, updateCalendarPeakEvidence } from './achievementCalendar'; +import { HourEvidence, parseHourEvidence } from './achievementHours'; +import { acquireCodexIndexLease } from './providers/codex/codexIndexLease'; +import { achievementRuleVersion, isLegacyAchievementId } from './achievementRuleVersions'; +import { isAchievementMoneyId, type AchievementMoneyId } from './achievementMoneyCatalog'; +import { getAchievementMoneyPricebook } from './achievementMoney'; +import {isPublishedAchievementReferencePricebook} from './achievementReferenceBooks'; + +// The joint-rule evaluator and canonical migration switch together. +export const ACHIEVEMENT_RECORD_FILE = 'achievements-v2.json'; +export const ACHIEVEMENT_V2_RECORD_FILE = 'achievements-v2.json'; +export const ACHIEVEMENT_LEGACY_RECORD_FILE = 'achievements-v1.json'; +export const ACHIEVEMENT_RECORD_BYTE_LIMIT = 65_536; +export function achievementRecordAuxiliaryName(name: string): boolean { + return /^\.achievements-v[12]\.json\.[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.tmp$/.test(name); +} +export type AchievementRecordOrigin = 'existing' | 'history' | 'usage' | 'activity'; +export interface AchievementRecordDraft { + id: AchievementId; + origin: AchievementRecordOrigin; + /** First recording day, never inferred as the original achievement date. */ + recordedDay: string | null; + timeZone: string | null; + /** Public frozen reference identity only; no personal amount or config. */ + referencePricebook?: string; +} +export interface AchievementUsageRecording { + recordedDay: string; + timeZone: string; + /** Required exactly for selected money IDs; public frozen versions only. */ + referencePricebooks?: Partial>; +} +export interface AchievementRecord extends AchievementRecordDraft { + ruleVersion: 1 | 2; + seq: number; + unread: boolean; +} +export interface AchievementRecordStore { + schemaVersion: 1 | 2; + generation: number; + sequence: number; + lastNoticeAt: number; + pins: string[]; + records: AchievementRecord[]; + auxiliary: AchievementAuxiliaryProof; + /** Present only in v2. Old qualifications do not confer current grant rights. */ + catalogVersion?: 2; + legacyRecords?: AchievementRecord[]; + showcaseMode?: 'automatic' | 'manual'; + pinRevision?: number; + /** Bounded derived-store revision, not an account or usage-source identity. */ + legacyBoundary?: { generation: number; sequence: number; signature: string } | null; +} +/** Existing bounded proof only; no per-action history or setting values. */ +export interface AchievementAuxiliaryProof { + activity: AchievementActivity; + hours: HourEvidence; + calendarPeak: CalendarPeakEvidence | null; +} +function emptyAuxiliary(): AchievementAuxiliaryProof { + return { activity: emptyAchievementActivity(), hours: { schemaVersion: 3, maxStreak: 0 }, calendarPeak: null }; +} +const ids = new Set(ACHIEVEMENT_DEFINITIONS.map(row => row.id)); +const seriesIds = new Set(Object.values(ACHIEVEMENT_SERIES).flat()); +const keys = new Set([...Object.keys(ACHIEVEMENT_SERIES), ...ACHIEVEMENT_DEFINITIONS.filter(row => !seriesIds.has(row.id)).map(row => row.id)]); +// Frozen v1 pin vocabulary: adding tiers must neither invalidate old series pins +// nor let a newly invented series key enter a downgrade-readable file. +const legacySeriesKeys = new Set([ + 'cumulative-volume', 'sonnet-main', 'opus-cannon', 'haiku-worker', 'cache-wizard', + 'claude-output', 'claude-output-sessions', 'claude-cache-creation', 'fable-volume', + 'fable-days', 'fable-sessions', 'weekly-volume', 'output-hours', 'settings-explorer', + 'astra-regular', 'sol-regular', 'luna-regular', 'agent-team', 'same-root-models', + 'effort-breadth', 'agent-conductor', 'root-portfolio', 'agent-participation', + 'model-relay-roots', 'active-day-streak', 'active-days', 'model-rainbow', + 'codex-output', 'codex-cache-read', 'astra-days', 'sol-days', 'luna-days', + 'sonnet-days', 'opus-days', 'haiku-days', 'dual-provider-days', 'active-weeks', 'weekly-average', +]); +const origins = new Set(['existing', 'history', 'usage', 'activity']); +type WriteGuard = () => boolean; +const invalid = (): never => { throw new Error('achievement-record:invalid'); }; +const integer = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; +function object(value: unknown, fields: readonly string[]): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid(); + const actual = Object.keys(value); + if (actual.length !== fields.length || actual.some(key => !fields.includes(key))) return invalid(); + return value as Record; +} +function validDay(value: unknown): boolean { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && + Number.isFinite(Date.parse(value + 'T00:00:00Z')) && new Date(value + 'T00:00:00Z').toISOString().slice(0, 10) === value; +} +function validZone(value: unknown): boolean { + if (typeof value !== 'string' || value.length > 64 || !/^[A-Za-z0-9_+./-]+$/.test(value)) return false; + try { new Intl.DateTimeFormat('en', { timeZone: value }); return true; } catch { return false; } +} +function draft(value: unknown, bookPolicy: 'current' | 'history' = 'current'): AchievementRecordDraft { + const monetary = !!value && typeof value === 'object' && typeof (value as {id?:unknown}).id === 'string' && + isAchievementMoneyId((value as {id:string}).id); + const row = object(value, ['id', 'origin', 'recordedDay', 'timeZone', ...(monetary ? ['referencePricebook'] : [])]); + if (monetary) { + const provider=String(row.id).startsWith('codex-') ? 'codex' : 'claude'; + const current=provider==='codex' ? [getAchievementMoneyPricebook('openai').version] : + [getAchievementMoneyPricebook('anthropic').version,getAchievementMoneyPricebook('aws-bedrock-in-region').version]; + if (!current.includes(row.referencePricebook as string) && + !(bookPolicy==='history' && isPublishedAchievementReferencePricebook(provider,row.referencePricebook))) return invalid(); + } + if (typeof row.id !== 'string' || !ids.has(row.id) || typeof row.origin !== 'string' || !origins.has(row.origin)) return invalid(); + const historical = row.origin === 'history' || row.origin === 'existing'; + if (historical ? row.recordedDay !== null || row.timeZone !== null + : !validDay(row.recordedDay) || !validZone(row.timeZone)) return invalid(); + return row as unknown as AchievementRecordDraft; +} +function legacyPinKey(key:string):boolean { + return isLegacyAchievementId(key) || legacySeriesKeys.has(key); +} +function pins(value: unknown, limit = 3, legacy = false): string[] { + if (!Array.isArray(value) || value.length > limit || new Set(value).size !== value.length || + value.some(key => !keys.has(key) || legacy && !legacyPinKey(key))) return invalid(); + return value.slice(); +} +function recordIds(value: unknown): AchievementId[] { + if (!Array.isArray(value) || value.length > ids.size || new Set(value).size !== value.length || + value.some(id => typeof id !== 'string' || !ids.has(id))) return invalid(); + return value.slice(); +} +function activity(value: unknown): AchievementActivity { + const parsed = parseAchievementActivity(value); + if (!isDeepStrictEqual(value, parsed)) return invalid(); + return parsed; +} +function hours(value: unknown): HourEvidence { + const parsed = parseHourEvidence(value); + if (!isDeepStrictEqual(value, parsed)) return invalid(); + return parsed; +} +function calendarPeak(value: unknown): CalendarPeakEvidence | null { + if (value === null) return null; + const parsed = parseCalendarPeakEvidence(value); + if (!parsed || !validZone(parsed.timeZone) || !isDeepStrictEqual(value, parsed)) return invalid(); + return parsed; +} +function auxiliary(value: unknown): AchievementAuxiliaryProof { + const row = object(value, ['activity', 'hours', 'calendarPeak']); + return { activity: activity(row.activity), hours: hours(row.hours), calendarPeak: calendarPeak(row.calendarPeak) }; +} +function mergeActivity(current: AchievementActivity, next: AchievementActivity): AchievementActivity { + return { schemaVersion: 2, + settingKeys: [...new Set([...current.settingKeys, ...next.settingKeys])].sort(), + features: [...new Set([...current.features, ...next.features])].sort() }; +} +function appendRecords(store: AchievementRecordStore, drafts: readonly AchievementRecordDraft[]): AchievementRecord[] { + const added: AchievementRecord[] = []; + const existing = new Set(store.records.map(row => row.id)); + for (const row of drafts) { + if (store.schemaVersion === 1 && !isLegacyAchievementId(row.id)) return invalid(); + if (existing.has(row.id)) continue; + if (store.sequence === Number.MAX_SAFE_INTEGER) return invalid(); + const record: AchievementRecord = { ...row, ruleVersion: store.schemaVersion === 2 ? achievementRuleVersion(row.id) : 1, seq: ++store.sequence, + unread: row.origin === 'usage' || row.origin === 'activity' }; + existing.add(row.id); store.records.push(record); added.push(record); + } + return added; +} +export function parseAchievementRecordStore(value: unknown): AchievementRecordStore { + if (!value || typeof value !== 'object') return invalid(); + const version = (value as {schemaVersion?: unknown}).schemaVersion; + if (version !== 1 && version !== 2) return invalid(); + const store = object(value, ['schemaVersion', 'generation', 'sequence', 'lastNoticeAt', 'pins', 'records', 'auxiliary', + ...(version === 2 ? ['catalogVersion', 'legacyRecords', 'showcaseMode', 'pinRevision', 'legacyBoundary'] : [])]); + if (!integer(store.generation) || store.generation < 1 || + !integer(store.sequence) || !integer(store.lastNoticeAt) || !Array.isArray(store.records) || store.records.length > ids.size) return invalid(); + function rows(values: unknown, archived = false): AchievementRecord[] { + if (!Array.isArray(values) || values.length > (archived ? 48 : version === 1 ? 124 : ids.size)) return invalid(); + const seen = new Set(); + let previous = 0; + return values.map(value => { + const monetary = version === 2 && !archived && !!value && typeof value === 'object' && + isAchievementMoneyId((value as {id:string}).id); + const row = object(value, ['id', 'origin', 'recordedDay', 'timeZone', 'ruleVersion', 'seq', 'unread', + ...(monetary ? ['referencePricebook'] : [])]); + const base = draft({ id: row.id, origin: row.origin, recordedDay: row.recordedDay, timeZone: row.timeZone, + ...(monetary ? {referencePricebook:row.referencePricebook} : {}) },'history'); + const expectedVersion = archived || version === 1 ? 1 : achievementRuleVersion(base.id); + if ((version === 1 || archived) && !isLegacyAchievementId(base.id)) return invalid(); + if (archived && (achievementRuleVersion(base.id) !== 2 || row.unread !== false)) return invalid(); + if (row.ruleVersion !== expectedVersion || !integer(row.seq) || row.seq <= previous || row.seq > (store.sequence as number) || + seen.has(base.id) || typeof row.unread !== 'boolean' || + ((base.origin === 'history' || base.origin === 'existing') && row.unread)) return invalid(); + seen.add(base.id); previous = row.seq; + return { ...base, ruleVersion: expectedVersion, seq: row.seq, unread: row.unread }; + }); + } + const result: AchievementRecordStore = { schemaVersion: version, generation: store.generation, sequence: store.sequence, + lastNoticeAt: store.lastNoticeAt, pins: pins(store.pins, version === 2 ? 6 : 3, version===1), records: rows(store.records), auxiliary: auxiliary(store.auxiliary) }; + if (version === 2) { + if (store.catalogVersion !== 2 || !['automatic','manual'].includes(store.showcaseMode as string) || !integer(store.pinRevision)) return invalid(); + let boundary: AchievementRecordStore['legacyBoundary'] = null; + if (store.legacyBoundary !== null) { + const parsed = object(store.legacyBoundary, ['generation','sequence','signature']); + if (!integer(parsed.generation) || parsed.generation < 1 || !integer(parsed.sequence) || + typeof parsed.signature !== 'string' || !/^[a-f0-9]{64}$/.test(parsed.signature)) return invalid(); + boundary = {generation:parsed.generation,sequence:parsed.sequence,signature:parsed.signature}; + } + Object.assign(result, {catalogVersion:2, legacyRecords:rows(store.legacyRecords,true), + showcaseMode:store.showcaseMode,pinRevision:store.pinRevision,legacyBoundary:boundary}); + const receipts = new Set(result.records.map(row => row.seq)); + const currentById = new Map(result.records.map(row => [row.id,row])); + for (const row of result.legacyRecords!) { + if (receipts.has(row.seq) || (currentById.get(row.id)?.seq ?? Infinity) <= row.seq) return invalid(); + receipts.add(row.seq); + } + } + if (Buffer.byteLength(JSON.stringify(result)) > ACHIEVEMENT_RECORD_BYTE_LIMIT) return invalid(); + return result; +} +export function recentAchievementRecords(store: AchievementRecordStore): AchievementRecord[] { + return store.records.slice(-30).reverse(); +} +function empty(generation = 1, sequence = 0): AchievementRecordStore { + return { schemaVersion: 1, generation, sequence, lastNoticeAt: 0, pins: [], records: [], auxiliary: emptyAuxiliary() }; +} +function v2Empty(generation = 1, sequence = 0, legacyBoundary: AchievementRecordStore['legacyBoundary'] = null): AchievementRecordStore { + return {...empty(generation,sequence),schemaVersion:2,catalogVersion:2,legacyRecords:[], + showcaseMode:'automatic',pinRevision:0,legacyBoundary}; +} +function boundaryFor(store: AchievementRecordStore): NonNullable { + return {generation:store.generation,sequence:store.sequence, + signature:createHash('sha256').update(JSON.stringify(store)).digest('hex')}; +} +function migrateV1(store: AchievementRecordStore): AchievementRecordStore { + if (store.schemaVersion !== 1 || store.generation === Number.MAX_SAFE_INTEGER) return invalid(); + const result = v2Empty(store.generation + 1,store.sequence,boundaryFor(store)); + result.lastNoticeAt = store.lastNoticeAt; + result.auxiliary = auxiliary(store.auxiliary); + result.records = store.records.filter(row => achievementRuleVersion(row.id) === 1).map(row => ({...row})); + result.legacyRecords = store.records.filter(row => achievementRuleVersion(row.id) === 2).map(row => ({...row,unread:false})); + result.pins = store.pins.slice(); + result.showcaseMode = result.pins.length ? 'manual' : 'automatic'; + return result; +} +function errno(error: unknown, code: string): boolean { return (error as NodeJS.ErrnoException)?.code === code; } +/** Pin an open regular file and read at most 64 KiB + one overflow byte. */ +export async function loadAchievementRecords(file: string): Promise { + let before; + try { before = await lstat(file); } catch (error) { if (errno(error, 'ENOENT')) return undefined; throw error; } + if (!before.isFile() || before.isSymbolicLink() || before.size > ACHIEVEMENT_RECORD_BYTE_LIMIT) return invalid(); + const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const info = await handle.stat(); + if (!info.isFile() || info.ino !== before.ino || info.dev !== before.dev || info.size > ACHIEVEMENT_RECORD_BYTE_LIMIT) return invalid(); + const buffer = Buffer.alloc(ACHIEVEMENT_RECORD_BYTE_LIMIT + 1); + let length = 0; + while (length < buffer.length) { + const read = await handle.read(buffer, length, buffer.length - length, length); + if (!read.bytesRead) break; + length += read.bytesRead; + } + if (length > ACHIEVEMENT_RECORD_BYTE_LIMIT) return invalid(); + let value; + try { value = JSON.parse(buffer.toString('utf8', 0, length)); } catch { return invalid(); } + return parseAchievementRecordStore(value); + } finally { await handle.close(); } +} +function check(guard: WriteGuard): void { if (!guard()) throw new Error('achievement-record:cancelled'); } +async function save(file: string, store: AchievementRecordStore, guard: WriteGuard): Promise { + const bytes = JSON.stringify(parseAchievementRecordStore(store)); + const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`); + let handle; + try { + check(guard); + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(bytes, 'utf8'); + await handle.sync(); await handle.close(); handle = undefined; + check(guard); + await rename(temporary, file); + // Directory fsync is unsupported on some platforms; the file was flushed. + const directory = await open(path.dirname(file), 'r').catch(() => undefined); + if (directory) { try { await directory.sync().catch(() => undefined); } finally { await directory.close(); } } + } finally { + await handle?.close().catch(() => undefined); + await unlink(temporary).catch(error => { if (!errno(error, 'ENOENT')) throw error; }); + } +} +/** Per-operation filesystem lease, not an in-memory queue: hosts merge the latest bytes. */ +export class AchievementRecordRepository { + private readonly v2: boolean; + private readonly legacyFile: string; + private adopted = false; + constructor(readonly file: string) { + this.v2 = path.basename(file) === ACHIEVEMENT_V2_RECORD_FILE; + this.legacyFile = path.join(path.dirname(file), ACHIEVEMENT_LEGACY_RECORD_FILE); + } + private async transaction(guard: WriteGuard, run: () => Promise): Promise { + check(guard); + // v1 writers already use this anchor. One shared lease avoids cross-version + // races without ever copying v2 fields into a v1-readable file. + const lease = await acquireCodexIndexLease(this.v2 ? this.legacyFile : this.file, { timeoutMs: 5_000, shouldCancel: () => !guard() }); + try { check(guard); return await run(); } finally { await lease.release(); } + } + private async owned(): Promise { + const saved = await loadAchievementRecords(this.file); + if (saved && saved.schemaVersion !== (this.v2 ? 2 : 1)) return invalid(); + return saved; + } + private async legacy(): Promise { + const saved = await loadAchievementRecords(this.legacyFile); + if (saved && saved.schemaVersion !== 1) return invalid(); + return saved; + } + private async synchronizeLegacy(store: AchievementRecordStore, guard: WriteGuard, adopt: boolean): Promise { + if (!this.v2) return store; + let legacy = await this.legacy(); + const previous = store.legacyBoundary; + // Resume an explicit clear after the v2 tombstone committed but flushing + // the old-version fence failed. Never re-import pre-clear qualification. + if (previous && (!legacy || legacy.generation < previous.generation) && + boundaryFor(empty(previous.generation,previous.sequence)).signature === previous.signature) { + legacy = empty(previous.generation,previous.sequence); + await save(this.legacyFile,legacy,guard); + } + if (legacy && legacy.generation > (previous?.generation ?? 1)) { + if (store.generation === Number.MAX_SAFE_INTEGER) return invalid(); + const cleared = v2Empty(store.generation + 1,store.sequence,boundaryFor(legacy)); + await save(this.file,cleared,guard); + return cleared; + } + const next = legacy ? boundaryFor(legacy) : null; + if (!isDeepStrictEqual(previous,next)) { + if (!adopt) throw new Error('achievement-record:old-writer'); + // A freshly reloaded host may adopt the revision, not its grants/proof. + // Existing v2 stays authoritative even after a downgrade has written v1. + store.legacyBoundary = next; + await save(this.file,store,guard); + } + return store; + } + async initialize(legacy: readonly AchievementId[], guard: WriteGuard = () => true, + legacyAuxiliary: AchievementAuxiliaryProof = emptyAuxiliary()): Promise { + return this.transaction(guard, async () => { + const saved = await this.owned(); + if (saved) { + const synchronized = await this.synchronizeLegacy(saved,guard,!this.adopted); + this.adopted = true; + return synchronized; + } + const prior = this.v2 ? await this.legacy() : undefined; + if (prior) { + const migrated = migrateV1(prior); + await save(this.file,migrated,guard); + this.adopted = true; + return migrated; + } + const store = this.v2 ? v2Empty() : empty(); + store.auxiliary = auxiliary(legacyAuxiliary); + for (const id of [...new Set(legacy)].sort()) { + if (!ids.has(id)) return invalid(); + if (!isLegacyAchievementId(id)) { + if (this.v2 && isAchievementMoneyId(id)) continue; + return invalid(); + } + const row: AchievementRecord = { id, origin: 'existing', recordedDay: null, timeZone: null, ruleVersion: 1, seq: ++store.sequence, unread: false }; + if (this.v2 && achievementRuleVersion(id) === 2) store.legacyRecords!.push(row); + else store.records.push(row); + } + await save(this.file, store, guard); this.adopted = true; return store; + }); + } + private async mutate(generation: number, guard: WriteGuard, run: (store: AchievementRecordStore) => T): Promise<{ store: AchievementRecordStore; value: T }> { + return this.transaction(guard, async () => { + const loaded = await this.owned(); + const store = loaded ? await this.synchronizeLegacy(loaded,guard,false) : undefined; + if (!store || store.generation !== generation) throw new Error('achievement-record:generation'); + const before = JSON.stringify(store); + const pinBefore = JSON.stringify([store.pins,store.showcaseMode]); + const value = run(store); + if (this.v2 && pinBefore !== JSON.stringify([store.pins,store.showcaseMode])) { + if (store.pinRevision === Number.MAX_SAFE_INTEGER) return invalid(); + store.pinRevision! += 1; + } + if (JSON.stringify(store) !== before) await save(this.file, store, guard); + return { store, value }; + }); + } + /** Merge the latest proof under the same lease, never a cached Memento object. */ + async updateAuxiliary(generation: number, key: K, + value: AchievementAuxiliaryProof[K], guard: WriteGuard = () => true): Promise<{ + store: AchievementRecordStore; previous: AchievementAuxiliaryProof; added: AchievementRecord[]; + }> { + // Runtime callers/messages must not supply arbitrary keys or nested fields. + if (!['activity', 'hours', 'calendarPeak'].includes(key)) return invalid(); + const incoming = key === 'activity' ? activity(value) : key === 'hours' ? hours(value) : calendarPeak(value); + const result = await this.mutate(generation, guard, store => { + const previous = auxiliary(store.auxiliary); + if (key === 'activity') { + const current = store.auxiliary.activity; + const next = incoming as AchievementActivity; + store.auxiliary.activity = mergeActivity(current, next); + } else if (key === 'hours') { + store.auxiliary.hours = { schemaVersion: 3, + maxStreak: Math.max(store.auxiliary.hours.maxStreak, (incoming as HourEvidence).maxStreak) }; + } else if (incoming) { + const next = incoming as CalendarPeakEvidence; + store.auxiliary.calendarPeak = updateCalendarPeakEvidence(store.auxiliary.calendarPeak ?? undefined, + next, next.peakFourWeekAverage, true) ?? null; + } + return previous; + }); + return { store: result.store, previous: result.value, added: [] }; + } + /** Successful deliberate action and newly crossed catalog targets are one transaction. */ + async recordActivity(generation: number, value: AchievementActivity, + recording: { recordedDay: string; timeZone: string }, guard: WriteGuard = () => true): Promise<{ + store: AchievementRecordStore; previous: AchievementAuxiliaryProof; added: AchievementRecord[]; + }> { + const incoming = activity(value); + const stamp = object(recording, ['recordedDay', 'timeZone']); + if (!validDay(stamp.recordedDay) || !validZone(stamp.timeZone)) return invalid(); + const result = await this.mutate(generation, guard, store => { + const previous = auxiliary(store.auxiliary); + store.auxiliary.activity = mergeActivity(previous.activity, incoming); + const drafts = newlyUnlockedPluginAchievements(previous.activity, store.auxiliary.activity).map(id => + draft({ id, origin: 'activity', recordedDay: stamp.recordedDay, timeZone: stamp.timeZone })); + return { previous, added: appendRecords(store, drafts) }; + }); + return { store: result.store, ...result.value }; + } + /** An old activity migration must not recreate evidence after any clear. */ + async migrateLegacyActivity(run: () => Promise): Promise { + await this.transaction(() => true, async () => { + const store = await loadAchievementRecords(this.file); + if (store) return; + await run(); + }); + } + async append(generation: number, values: readonly AchievementRecordDraft[], guard: WriteGuard = () => true): Promise<{ store: AchievementRecordStore; added: AchievementRecord[] }> { + if (!Array.isArray(values) || values.length > ids.size) return invalid(); + const drafts = values.map(value=>draft(value)); + const result = await this.mutate(generation, guard, store => appendRecords(store, drafts)); + return { store: result.store, added: result.value }; + } + /** The host supplies exact producer-proven crossings and the canonical sequence + * accepted BEFORE observing that growth. Only later peer history can be repaired; + * established history/existing/activity/usage records are never redated. */ + async recordUsage(generation: number, baselineSequence: number, values: readonly AchievementId[], + recording: AchievementUsageRecording, guard: WriteGuard = () => true): Promise<{ + store: AchievementRecordStore; added: AchievementRecord[]; + }> { + if (!integer(baselineSequence)) return invalid(); + const selection = recordIds(values); + if (selection.some(id => ACHIEVEMENT_DEFINITIONS.find(row => row.id === id)?.group === 'plugin')) return invalid(); + const monetaryIds = selection.filter(isAchievementMoneyId); + const stamp = object(recording, ['recordedDay', 'timeZone', ...(monetaryIds.length ? ['referencePricebooks'] : [])]); + if (!validDay(stamp.recordedDay) || !validZone(stamp.timeZone)) return invalid(); + const books = monetaryIds.length ? object(stamp.referencePricebooks, monetaryIds) : undefined; + if (books && Reflect.ownKeys(books).length !== monetaryIds.length) return invalid(); + const drafts = selection.map(id => draft({ id, origin: 'usage', + recordedDay: stamp.recordedDay, timeZone: stamp.timeZone, + ...(isAchievementMoneyId(id) ? { referencePricebook: books![id] } : {}) })); + const result = await this.mutate(generation, guard, store => { + if (baselineSequence > store.sequence) return invalid(); + // Drafts snapshot the validated books before waiting on the shared lease. + const repair = new Map(drafts.map(row => [row.id, row])); + store.records = store.records.filter(row => !(repair.has(row.id) && + row.origin === 'history' && row.seq > baselineSequence && + (!isAchievementMoneyId(row.id) || row.referencePricebook === repair.get(row.id)!.referencePricebook))); + // Reissued sequence also fences ACKs issued for the preempting history. + return appendRecords(store, drafts); + }); + return { store: result.store, added: result.value }; + } + async acknowledge(generation: number, sequence: number, guard: WriteGuard = () => true): Promise { + return (await this.mutate(generation, guard, store => { + if (!integer(sequence) || sequence > store.sequence) return invalid(); + for (const record of store.records) if (record.seq <= sequence) record.unread = false; + })).store; + } + /** Reserve the shared disturbance budget, not read status. No replay queue. */ + async reserveNotice(generation: number, values: readonly AchievementId[], now: number, + guard: WriteGuard = () => true): Promise<{ store: AchievementRecordStore; records: AchievementRecord[] }> { + const selection = new Set(notifiableAchievementIds(recordIds(values))); + if (!integer(now) || now === 0) return invalid(); + const result = await this.mutate(generation, guard, store => { + if (store.lastNoticeAt !== 0 && now - store.lastNoticeAt <= 300_000) return []; + const records = store.records.filter(row => row.unread && selection.has(row.id)); + if (records.length > 0) store.lastNoticeAt = now; + return records; + }); + return { store: result.store, records: result.value }; + } + /** A live notice confirms only the IDs actually represented by its batch. */ + async acknowledgeRecords(generation: number, sequence: number, values: readonly AchievementId[], + guard: WriteGuard = () => true): Promise { + const selection = new Set(recordIds(values)); + return (await this.mutate(generation, guard, store => { + if (!integer(sequence) || sequence > store.sequence) return invalid(); + for (const record of store.records) if (record.seq <= sequence && selection.has(record.id)) record.unread = false; + })).store; + } + async pin(generation: number, values: readonly string[], guard: WriteGuard = () => true, + expectedRevision?: number): Promise { + if (this.v2 && !integer(expectedRevision)) return invalid(); + const selection = pins(values,this.v2 ? 6 : 3,!this.v2); + return (await this.mutate(generation, guard, store => { + if (this.v2 && expectedRevision !== store.pinRevision) throw new Error('achievement-record:pin-stale'); + if (this.v2 && selection.some(key => !store.pins.includes(key) && + !store.records.some(row => row.id === key || ACHIEVEMENT_SERIES[key]?.includes(row.id)))) return invalid(); + store.pins = selection; + if (this.v2) store.showcaseMode = 'manual'; + })).store; + } + /** Apply one deliberate pin intent against fresh peer state, not a stale whole list. */ + async setPinned(generation: number, key: string, selected: boolean, + guard: WriteGuard = () => true): Promise { + if (typeof key !== 'string' || !keys.has(key) || !this.v2 && !legacyPinKey(key) || typeof selected !== 'boolean') return invalid(); + return (await this.mutate(generation, guard, store => { + if (!selected) { store.pins = store.pins.filter(value => value !== key); return; } + if (!store.records.some(row => row.id === key || ACHIEVEMENT_SERIES[key]?.includes(row.id))) return invalid(); + if (store.pins.includes(key)) { + if (this.v2) store.showcaseMode = 'manual'; + return; + } + if (store.pins.length === (this.v2 ? 6 : 3)) throw new Error('achievement-record:pin-limit'); + store.pins.push(key); + if (this.v2) store.showcaseMode = 'manual'; + })).store; + } + async reorderPinned(generation: number, values: readonly string[], revision: number, + guard: WriteGuard = () => true): Promise { + if (!this.v2 || !integer(revision)) return invalid(); + const selection = pins(values,6); + return (await this.mutate(generation,guard,store => { + if (revision !== store.pinRevision) throw new Error('achievement-record:pin-stale'); + if (selection.length !== store.pins.length || selection.some(key => !store.pins.includes(key))) return invalid(); + store.pins = selection; + })).store; + } + async setShowcaseMode(generation: number, mode: 'automatic' | 'manual', revision: number, + guard: WriteGuard = () => true): Promise { + if (!this.v2 || !['automatic','manual'].includes(mode) || !integer(revision)) return invalid(); + return (await this.mutate(generation,guard,store => { + if (revision !== store.pinRevision) throw new Error('achievement-record:pin-stale'); + store.showcaseMode = mode; + })).store; + } + async clear(resetRelatedState?: () => Promise): Promise { + return this.transaction(() => true, async () => { + const current = await this.owned(); + if (current?.generation === Number.MAX_SAFE_INTEGER) return invalid(); + const legacy = this.v2 ? await this.legacy() : undefined; + const legacyGeneration = Math.max(legacy?.generation ?? 1,current?.legacyBoundary?.generation ?? 1); + if (this.v2 && legacyGeneration === Number.MAX_SAFE_INTEGER) return invalid(); + await resetRelatedState?.(); + const oldFence = this.v2 ? empty(legacyGeneration + 1,legacy?.sequence ?? current?.legacyBoundary?.sequence ?? 0) : undefined; + const store = this.v2 ? v2Empty((current?.generation ?? 1) + 1,current?.sequence ?? 0,boundaryFor(oldFence!)) + : empty((current?.generation ?? 1) + 1, current?.sequence ?? 0); + await save(this.file, store, () => true); + if (oldFence) await save(this.legacyFile,oldFence,() => true); + // Only this exact writer's interrupted-file names. Never follow links, + // remove leases, quarantine unknown files or traverse source directories. + for (const name of await readdir(path.dirname(this.file))) { + if (!achievementRecordAuxiliaryName(name) || (!this.v2 && !name.startsWith('.achievements-v1.json.'))) continue; + const auxiliary = path.join(path.dirname(this.file), name); + const info = await lstat(auxiliary); + if (info.isFile() && !info.isSymbolicLink()) await unlink(auxiliary); + } + return store; + }); + } +} diff --git a/src/achievementReferenceBooks.ts b/src/achievementReferenceBooks.ts new file mode 100644 index 00000000..eb0636b1 --- /dev/null +++ b/src/achievementReferenceBooks.ts @@ -0,0 +1,12 @@ +import type {UsageProvider} from './providers/providerTypes'; + +/** Published historical identities, independent of the currently selected + * reference rates. Append released versions here; never infer arbitrary future + * books from their string shape or use these labels to authorize new grants. */ +const published: Readonly> = Object.freeze({ + claude:Object.freeze(['api-reference-2026-10-anthropic-v1','api-reference-2026-10-aws-bedrock-in-region-v1']), + codex:Object.freeze(['api-reference-2026-10-openai-v1']), +}); +export function isPublishedAchievementReferencePricebook(provider:UsageProvider,value:unknown):value is string { + return typeof value==='string' && published[provider].includes(value); +} diff --git a/src/achievementRuleVersions.ts b/src/achievementRuleVersions.ts new file mode 100644 index 00000000..bd94a829 --- /dev/null +++ b/src/achievementRuleVersions.ts @@ -0,0 +1,37 @@ +import type { AchievementId } from './achievements'; + +/** Joint output/repeated-task conditions supersede the R1 count-only rules. */ +export const RECALIBRATED_ACHIEVEMENT_IDS_V2: readonly AchievementId[] = Object.freeze([ + 'agent-standup', 'tiny-ai-company', 'agent-team-gold', + 'agent-conductor', 'agent-conductor-silver', 'agent-conductor-gold', + 'agent-participation-bronze', 'agent-participation-silver', 'agent-participation-gold', + 'model-relay-roots-bronze', 'model-relay-roots-silver', 'model-relay-roots-gold', + 'multi-core-human', 'same-root-models-silver', 'same-root-models-gold', + 'effort-explorer', 'effort-breadth-silver', 'effort-spectrum', + 'model-rainbow-bronze', 'model-rainbow', 'model-rainbow-gold', +]); +const changed = new Set(RECALIBRATED_ACHIEVEMENT_IDS_V2); +export function achievementRuleVersion(id: string): 1 | 2 { return changed.has(id) ? 2 : 1; } + +/** Frozen v1 allowlist. New monetary IDs must never be accepted as old proof. */ +const legacyIds = new Set([ + 'sonnet-main','sonnet-main-silver','sonnet-main-gold','opus-cannon','opus-cannon-silver','opus-cannon-gold', + 'haiku-worker','haiku-worker-silver','haiku-worker-gold','cache-wizard','cache-wizard-silver','cache-wizard-gold', + 'claude-output-bronze','claude-output-silver','claude-output-gold','claude-output-sessions-bronze','claude-output-sessions-silver','claude-output-sessions-gold', + 'claude-cache-creation-bronze','claude-cache-creation-silver','claude-cache-creation-gold', + 'fable-volume-bronze','fable-volume-silver','fable-volume-gold','fable-days-bronze','fable-days-silver','fable-days-gold','fable-relay', + 'fable-sessions-bronze','fable-sessions-silver','fable-sessions-gold','weekly-volume-bronze','weekly-volume-silver','weekly-volume-gold', + 'output-hours-bronze','output-hours-silver','output-hours-gold','night-output-peak', + 'settings-explorer-bronze','settings-explorer-silver','settings-explorer-gold','safe-feature-explorer','local-share-export','local-advice-feedback', + 'astra-regular','astra-regular-silver','astra-regular-gold','sol-regular','sol-regular-silver','sol-regular-gold','luna-regular','luna-regular-silver','luna-regular-gold', + ...RECALIBRATED_ACHIEVEMENT_IDS_V2, + 'root-portfolio-bronze','root-portfolio-silver','root-portfolio-gold', + 'active-day-streak','active-day-streak-silver','active-day-streak-gold','active-days-bronze','active-days-silver','active-days-gold','streak-machine', + 'hundred-million-club','billion-token-club','ten-billion-token-club','hundred-billion-token-club','trillion-token-club', + 'codex-output-bronze','codex-output-silver','codex-output-gold','codex-cache-read-bronze','codex-cache-read-silver','codex-cache-read-gold', + 'astra-days-bronze','astra-days-silver','astra-days-gold','sol-days-bronze','sol-days-silver','sol-days-gold','luna-days-bronze','luna-days-silver','luna-days-gold', + 'sonnet-days-bronze','sonnet-days-silver','sonnet-days-gold','opus-days-bronze','opus-days-silver','opus-days-gold','haiku-days-bronze','haiku-days-silver','haiku-days-gold', + 'dual-provider-days-bronze','dual-provider-days-silver','dual-provider-days-gold','active-weeks-bronze','active-weeks-silver','active-weeks-gold', + 'weekly-average-bronze','weekly-average-silver','weekly-average-gold','three-family-day','dual-provider-day', +]); +export function isLegacyAchievementId(id: string): boolean { return legacyIds.has(id); } diff --git a/src/achievementShare.ts b/src/achievementShare.ts new file mode 100644 index 00000000..dd96f10d --- /dev/null +++ b/src/achievementShare.ts @@ -0,0 +1,164 @@ +import { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_SERIES, AchievementId, AchievementResult } from './achievements'; +import { renderAchievementIcon } from './achievementIcons'; +import type { AchievementCopy } from './i18n'; +import { randomUUID } from 'node:crypto'; +import {isAchievementMoneyId} from './achievementMoneyCatalog'; +import {achievementConditionFor} from './achievementConditions'; +import { achievementTitleWithoutRank } from './achievementTitles'; + +/** One accepted local artifact, not an export history. Capture before any save dialog. */ +export class AchievementSharePreview { + private current?: { previewId: string; ids: string; svg: string; locale: string }; + + accept(ids: readonly AchievementId[], svg: string, locale = ''): { previewId?: string; svg: string } { + this.clear(); + if (!ids.length || !svg) return { svg: '' }; + this.current = { previewId: randomUUID(), ids: JSON.stringify(ids), svg, locale }; + return { previewId: this.current.previewId, svg }; + } + + capture(previewId: unknown, ids: readonly string[], locale = ''): string | undefined { + return typeof previewId === 'string' && this.current?.previewId === previewId && + this.current.ids === JSON.stringify(ids) && this.current.locale === locale ? this.current.svg : undefined; + } + + read(ids: readonly string[], locale = ''): { previewId: string; svg: string } | undefined { + return this.current?.ids === JSON.stringify(ids) && this.current.locale === locale + ? { previewId: this.current.previewId, svg: this.current.svg } : undefined; + } + + clear(): void { this.current = undefined; } +} + +/** One earned representative per series, in canonical rule order. */ +export function highestEarnedAchievements(results: readonly AchievementResult[]): AchievementResult[] { + const earned = new Set(results.filter((result) => result.unlocked).map((result) => result.id)); + for (const ids of Object.values(ACHIEVEMENT_SERIES)) { + const highest = [...ids].reverse().find((id) => earned.has(id)); + for (const id of ids) if (id !== highest) earned.delete(id); + } + return results.filter((result) => earned.has(result.id)); +} + +/** Export is an explicit subset of earned badges; client-provided IDs are never trusted. */ +export function selectShareAchievements( + results: readonly AchievementResult[], + requested: readonly string[] | undefined, +): AchievementId[] { + // Privacy by default: entering the studio never preselects personal patterns. + const chosen = new Set(requested ?? []); + const unlocked = new Set(highestEarnedAchievements(results.filter((result) => chosen.has(result.id))) + .map((result) => result.id)); + return ACHIEVEMENT_DEFINITIONS.map((entry) => entry.id) + .filter((id) => unlocked.has(id) && chosen.has(id)) + .slice(0, 9); +} + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (char) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[char]!); +} + +/** Conservative em widths keep export deterministic without browser/font I/O. */ +function wrapLabel(value: string, maxEm: number, maxLines = Infinity): string[] { + const width = (text: string): number => Array.from(text).reduce((sum, char) => sum + + (/\s/.test(char) ? 0.35 : /[ilI1.,:;!'|]/.test(char) ? 0.35 : + /[MWmw@%]/.test(char) ? 1 : /[\u2e80-\uffef]/.test(char) ? 1.1 : 0.7), 0); + const lines: string[] = []; + let current = ''; + for (const char of Array.from(value)) { + if (current && width(current + char) > maxEm) { + const space = current.lastIndexOf(' '); + lines.push(space > 0 ? current.slice(0, space) : current); + current = space > 0 ? current.slice(space + 1) : ''; + } + current += char; + } + if (current) lines.push(current.trim()); + if (lines.length > maxLines) { + lines.length = maxLines; + let last = lines[maxLines - 1]; + while (last && width(last + '…') > maxEm) last = Array.from(last).slice(0, -1).join(''); + lines[maxLines - 1] = last.trimEnd() + '…'; + } + return lines; +} + +function textLines(lines: readonly string[], x: number, spacing: number): string { + return lines.map((line, index) => + `${escapeXml(line)}`).join(''); +} + +// Navigation only: never used as an image/resource URL or derived from input. +const REPOSITORY_URL = 'https://github.com/ClaudeCodeUsage/ClaudeCodeUsage'; + +/** Deterministic, self-contained SVG; carries titles only, never raw usage or identities. */ +export function renderAchievementShareSvg(input: { + selected: readonly AchievementId[]; + titles: Partial>; + heading: string; + subtitle: string; + groups: AchievementCopy['groups']; + levels: AchievementCopy['levels']; + milestone: string; + footer: string; + locale?: string; +}): string { + const rows = input.selected.slice(0, 9); + const headingLines = wrapLabel(input.heading, 28); + const subtitleLines = wrapLabel(input.subtitle, 56, 2); + const subtitleY = 66 + Math.max(0, headingLines.length - 1) * 38 + 35; + const gridY = subtitleY + Math.max(0, subtitleLines.length - 1) * 19 + 35; + const badges = rows.map((id) => { + const series = Object.values(ACHIEVEMENT_SERIES).find((ids) => ids.includes(id)); + const level = series?.indexOf(id); + const title = achievementTitleWithoutRank(input.titles[id] || id, + level === undefined ? undefined : input.levels[level]); + const titleLines = wrapLabel(title, 11, 3); + const group = ACHIEVEMENT_DEFINITIONS.find((entry) => entry.id === id)?.group ?? 'combined'; + const accent: Parameters[1] = + group === 'claude' ? '#db8b5b' : group === 'codex' ? '#82a9ed' : '#ad99e2'; + const color = level === undefined ? accent : + ['#e7b899', '#d6dce5', '#efce76', '#8dcaf0', '#cfadff'][level]; + const levelLabel = level === undefined ? input.milestone : input.levels[level]; + const target=ACHIEVEMENT_DEFINITIONS.find(row=>row.id===id)!.target; + const threshold=isAchievementMoneyId(id) ? '≥$'+new Intl.NumberFormat(input.locale ?? 'en').format(target/1_000_000)+' USD' : ''; + const condition=achievementConditionFor(input.locale ?? 'en',id); + const publicTitle=[title,levelLabel,threshold,condition].filter(Boolean).join(' · '); + const groupLines = wrapLabel(input.groups[group], 14); + const rankLines = wrapLabel(levelLabel + (threshold ? ' · ' + threshold : ''), 14); + // Reserve three title lines, then grow downward for full group/rank labels. + const groupY = 88; + const rankY = groupY + Math.max(0, groupLines.length - 1) * 16 + 24; + const height = rankY + Math.max(0, rankLines.length - 1) * 16 + 20; + return { id, titleLines, groupLines, rankLines, publicTitle, accent, color, groupY, rankY, height }; + }); + const cellHeight = Math.max(132, ...badges.map(badge => badge.height)); + const cells = badges.map((badge, index) => { + const { id, titleLines, groupLines, rankLines, publicTitle, accent, color, groupY, rankY } = badge; + const x = 48 + (index % 3) * 304; + const y = gridY + Math.floor(index / 3) * (cellHeight + 20); + return `${escapeXml(publicTitle)}` + + `` + + renderAchievementIcon(id, accent, { x: x + 11, y: y + 24, size: 64 }) + + `${textLines(titleLines, x + 86, 19)}` + + `${textLines(groupLines, x + 86, 16)}` + + `${textLines(rankLines, x + 86, 16)}`; + }).join(''); + const rowCount = Math.ceil(badges.length / 3); + const gridBottom = gridY + rowCount * (cellHeight + 20) - (rowCount ? 20 : 0); + const footerLines = wrapLabel(`Claude Code Usage · ${input.footer}`, 74); + const footerY = gridBottom + 44; + const linkY = footerY + Math.max(0, footerLines.length - 1) * 16 + 40; + const height = Math.max(330, linkY + 32); + return `` + + `` + + `` + + `${textLines(headingLines, 48, 38)}` + + `${textLines(subtitleLines, 48, 19)}` + + `${cells}` + + `${textLines(footerLines, 48, 16)}` + + `` + + `${REPOSITORY_URL}`; +} diff --git a/src/achievementStages.ts b/src/achievementStages.ts new file mode 100644 index 00000000..84c50703 --- /dev/null +++ b/src/achievementStages.ts @@ -0,0 +1,41 @@ +import { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_SERIES } from './achievements'; + +/** Display targets only: no IDs, grant writes, dates, notices or share choices. */ +const TOKEN_STAGE_SERIES = new Set([ + 'astra-regular', 'sol-regular', 'luna-regular', 'cumulative-volume', + 'weekly-volume', 'weekly-average', 'sonnet-main', 'opus-cannon', + 'haiku-worker', 'fable-volume', 'cache-wizard', 'claude-cache-creation', + 'claude-output', 'codex-output', 'codex-cache-read', + 'claude-api-total','claude-api-week','claude-api-session', + 'codex-api-total','codex-api-week','codex-api-task', +]); +const targets = new Map(ACHIEVEMENT_DEFINITIONS.map(rule => [rule.id, rule.target])); + +export function achievementStages(series: string): readonly number[] { + if (!TOKEN_STAGE_SERIES.has(series)) return []; + const formal = ACHIEVEMENT_SERIES[series].map(id => targets.get(id)!); + const stages: number[] = []; + // One bounded decade before Token entry tiers; money keeps its existing gaps. + const firstIndex = series.includes('-api-') ? 1 : 0; + for (let index = firstIndex; index < formal.length; index++) { + const lower = index === 0 ? formal[0] / 10 : formal[index - 1], upper = formal[index]; + for (let power = Math.floor(Math.log10(lower)); power <= Math.floor(Math.log10(upper)); power++) { + for (const multiplier of [1, 2, 5]) { + const value = multiplier * 10 ** power; + if (Number.isSafeInteger(value) && (index === 0 ? value >= lower : value > lower) && value < upper) stages.push(value); + } + } + } + return stages; +} + +export function nextAchievementStage( + series: string, progress: number, formalTarget: number, evidenceReady: boolean, +): number | undefined { + if (!evidenceReady || !Number.isSafeInteger(progress) || progress < 0 || !TOKEN_STAGE_SERIES.has(series)) return; + const formal = ACHIEVEMENT_SERIES[series].map(id => targets.get(id)!); + const index = formal.indexOf(formalTarget); + if (index < 0 || progress >= formalTarget || index === 0 && progress === 0) return; + const lower = index === 0 ? 0 : formal[index - 1]; + return achievementStages(series).find(value => value > lower && value > progress && value < formalTarget); +} diff --git a/src/achievementTitles.ts b/src/achievementTitles.ts new file mode 100644 index 00000000..efccad0d --- /dev/null +++ b/src/achievementTitles.ts @@ -0,0 +1,21 @@ +/** Keep the localized name intact when its rank is shown separately. + * Only catalog middle-dot separators qualify; words containing a rank do not. + * Pass undefined for an unranked milestone. No rule/ID or copy lookup occurs here. + */ +export function achievementTitleWithoutRank(title: string, rankLabel: string | undefined): string { + if (!rankLabel) return title; + // Display-only compatibility for titles supplied with the former rank names. + // Match only aliases of the requested rank; IDs and saved pins stay untouched. + const aliases: Record = { + '铜': ['普通'], '銅': ['一般', '普通'], '银': ['银色'], '銀': ['銀色'], '金': ['金色'], + Bronze: ['Ordinary', 'Basis', 'Básico'], 'ブロンズ': ['通常'], '브론즈': ['일반'], Perunggu: ['Dasar'], + }; + const oldLabels = Object.prototype.hasOwnProperty.call(aliases, rankLabel) ? aliases[rankLabel] : []; + for (const label of [rankLabel, ...oldLabels]) { + if (!title.endsWith(label)) continue; + const prefix = title.slice(0, -label.length); + const separator = /\s*[·・]\s*$/u.exec(prefix); + if (separator && separator.index > 0) return prefix.slice(0, separator.index); + } + return title; +} diff --git a/src/achievementUiIcons.ts b/src/achievementUiIcons.ts new file mode 100644 index 00000000..820d5fd9 --- /dev/null +++ b/src/achievementUiIcons.ts @@ -0,0 +1,17 @@ +/** Small code-native chrome, separate from the established badge artwork. */ +const paths = { + medal:'', + target:'', + share:'', + settings:'', + filter:'', + size:'', + display:'', + info:'', + expand:'', + collapse:'', + recalibrate:'', +} as const; +export function achievementUiIcon(name:keyof typeof paths):string { + return ''; +} diff --git a/src/achievements.ts b/src/achievements.ts new file mode 100644 index 00000000..c96dab31 --- /dev/null +++ b/src/achievements.ts @@ -0,0 +1,775 @@ +import type { CodexFileAggregate } from './providers/codex/codexIndex'; +import { API_ACHIEVEMENT_DEFINITIONS, API_ACHIEVEMENT_SERIES, achievementMoneyProgress, + type AchievementMoneyFacts } from './achievementMoneyCatalog'; +import type {AchievementMoneyScopes} from './achievementMoneyScopes'; +import type {AchievementEffortStatistics} from './achievementEffortStatistics'; +import type {AchievementHistoryInput} from './achievementHistoryDates'; +import type { ProviderTokenCounts } from './providers/providerTypes'; +import type { AchievementActivity } from './achievementActivity'; +import { SAFE_EXPLORATION_FEATURES } from './achievementActivity'; +import { validAchievementBuckets, verifiedCodexAchievementFiles } from './codexAchievementProof'; +import type { AchievementCalendarSignals, AchievementDayEvidence, AchievementTokenEvidence } from './achievementCalendar'; +import { codexCollaborationEvidence, AchievementCollaborationEvidence } from './achievementCollaboration'; +import { RECALIBRATED_ACHIEVEMENT_IDS_V2, isLegacyAchievementId } from './achievementRuleVersions'; +import { LONG_TERM_ACHIEVEMENT_DEFINITIONS, type LongTermAchievementId } from './achievementLongTerm'; + +import { claudeAchievementFamily, codexModelFamily, normalizeCodexAchievementModel, + type CodexModelFamily, type ClaudeModelFamily } from './achievementModels'; +export { claudeAchievementFamily, codexModelFamily } from './achievementModels'; +export type { CodexModelFamily, ClaudeModelFamily } from './achievementModels'; +type EffortTier = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; +const SPECTRUM_EFFORTS: readonly EffortTier[] = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']; + +export interface CodexAchievementSignals { + /** Produced only from independently validated canonical lineages. */ + verifiedSubset?: true; + modelCount: number; + efforts: string[]; + familyRootCounts: Record; + /** Reconciled input + output; cached input/reasoning are never added again. */ + familyProcessedTokens?: Record; + effortProcessedTokens?: Partial>; + /** One reconciled, nonnegative file total per session; cumulative only. */ + processedLowerBound?: number; + dayEvidence?: AchievementDayEvidence; + /** Runtime numeric dating proof; never an award or persisted index field. */ + historyDayEvidence?: AchievementHistoryInput['codex']; + tokenEvidence?: AchievementTokenEvidence; + /** Distinct, lineage-verified roots with actual usage at each effort tier. */ + effortRootCounts?: Partial>; + /** Unique, evidenced child sessions under one verified root. */ + maxVerifiedAgentsPerRoot?: number; + maxRealModelsPerRoot?: number; + multiAgentRootCount?: number; + /** Unique lineage-verified root tasks with a real model and positive usage. */ + verifiedRootCount?: number; + verifiedChildCount?: number; + multiModelRootCount?: number; + /** Versioned joint counters, never inferred from old marginal maxima. */ + collaboration?: AchievementCollaborationEvidence; + money?: AchievementMoneyScopes; + /** Current complete-census statistics; informational, never another rule. */ + effortStatistics?: AchievementEffortStatistics; +} + +export interface AchievementFacts { + money?: AchievementMoneyFacts; + /** Local recorded volume only; never a billing or productivity measure. */ + claudeTokens: number; + claudeOutputTokens?: number; + claudeCacheReadTokens?: number; + claudeCacheCreationTokens?: number; + claudeOutputSessions?: number; + codexTokens: number; + activeDays: number; + claudeFamilies: string[]; + claudeFamilyTokens?: Partial>; + claudeFamilyOutputTokens?: Partial>; + fable?: FableAchievementSignals; + weeklyPeakProcessed?: number; + codexOutputTokens?: number; + codexCachedInputTokens?: number; + calendar?: AchievementCalendarSignals; + outputHourStreak?: number; + nightOutputPeak?: boolean; + activity?: AchievementActivity; + longestStreak?: number; + codex: CodexAchievementSignals; +} + +interface ClaudeModelTokens { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +type ClaudeModelRows = Record; + +export interface FableAchievementSignals { + processedTokens: number; + outputTokens?: number; + activeDays: number; + mixedFamilySession: boolean; + outputSessions: number; + allOutputSessions: number; +} + +/** Only aggregate achievement proof crosses from the host into the Webview. */ +export interface ClaudeSessionAchievementEvidence { + outputSessions: number; + fableOutputSessions: number; + fableMixedFamilySession: boolean; +} + +export function claudeSessionAchievementEvidence(sessions: Iterable<{ + sessionId?: string; + data: { totalOutputTokens: number; modelBreakdown: ClaudeModelRows }; +}>): ClaudeSessionAchievementEvidence { + const bySession = new Map(); + let index = 0; + for (const { sessionId, data } of sessions) { + const key = sessionId ? `id:${sessionId}` : `row:${index}`; + index++; + const evidence = bySession.get(key) ?? { output: false, fable: false, other: false }; + evidence.output ||= Number.isSafeInteger(data.totalOutputTokens) && data.totalOutputTokens > 0; + for (const [model, tokens] of Object.entries(data.modelBreakdown)) { + if (!positiveOutput(tokens)) continue; + const family = claudeAchievementFamily(model); + evidence.fable ||= family === 'fable'; + evidence.other ||= family !== null && family !== 'fable'; + } + bySession.set(key, evidence); + } + let outputSessions = 0; + let fableOutputSessions = 0; + let fableMixedFamilySession = false; + for (const evidence of bySession.values()) { + if (evidence.output) outputSessions++; + if (evidence.fable) fableOutputSessions++; + if (evidence.fable && evidence.other) fableMixedFamilySession = true; + } + return { outputSessions, fableOutputSessions, fableMixedFamilySession }; +} + +function positiveOutput(tokens: ClaudeModelTokens): boolean { + return Number.isSafeInteger(tokens.outputTokens) && tokens.outputTokens > 0; +} + +function processedClaudeTokens(tokens: ClaudeModelTokens): number { + const parts = [tokens.inputTokens, tokens.outputTokens, tokens.cacheCreationTokens, tokens.cacheReadTokens]; + return parts.every((part) => Number.isSafeInteger(part) && part >= 0) + ? parts.reduce((sum, part) => sum + part, 0) : 0; +} + +function hasFableOutput(models: ClaudeModelRows): boolean { + return Object.entries(models).some(([model, tokens]) => + claudeAchievementFamily(model) === 'fable' && positiveOutput(tokens)); +} + +/** Consumes already-materialized Claude aggregates; never opens a transcript. */ +export function fableAchievementSignals(input: { + allTimeModels: ClaudeModelRows; + days: readonly { date: string; data: { modelBreakdown: ClaudeModelRows } }[]; + sessions: readonly { sessionId?: string; data: { modelBreakdown: ClaudeModelRows; totalOutputTokens?: number } }[]; + sessionEvidence?: ClaudeSessionAchievementEvidence; +}): FableAchievementSignals { + const processedTokens = Object.entries(input.allTimeModels) + .filter(([model]) => claudeAchievementFamily(model) === 'fable') + .reduce((sum, [, tokens]) => sum + processedClaudeTokens(tokens), 0); + const activeDays = new Set(input.days + .filter(({ date, data }) => /^\d{4}-\d{2}-\d{2}$/.test(date) && + Number.isFinite(Date.parse(`${date}T00:00:00.000Z`)) && + new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date && + hasFableOutput(data.modelBreakdown)) + .map(({ date }) => date)).size; + let mixedFamilySession = false; + const outputSessionKeys = new Set(); + const allOutputSessionKeys = new Set(); + if (!input.sessionEvidence) input.sessions.forEach(({ sessionId, data }, index) => { + if ((Number.isSafeInteger(data.totalOutputTokens) && (data.totalOutputTokens ?? 0) > 0) || + (data.totalOutputTokens === undefined && Object.values(data.modelBreakdown).some(positiveOutput))) { + allOutputSessionKeys.add(sessionId ? `id:${sessionId}` : `row:${index}`); + } + if (!hasFableOutput(data.modelBreakdown)) return; + outputSessionKeys.add(sessionId ? `id:${sessionId}` : `row:${index}`); + if (!mixedFamilySession) mixedFamilySession = Object.entries(data.modelBreakdown) + .some(([model, tokens]) => { + const family = claudeAchievementFamily(model); + return family !== null && family !== 'fable' && positiveOutput(tokens); + }); + }); + return { + processedTokens, activeDays, + outputTokens: Object.entries(input.allTimeModels).filter(([model]) => claudeAchievementFamily(model) === 'fable') + .reduce((sum,[,tokens]) => sum + (positiveOutput(tokens) ? tokens.outputTokens : 0),0), + mixedFamilySession: input.sessionEvidence?.fableMixedFamilySession ?? mixedFamilySession, + outputSessions: input.sessionEvidence?.fableOutputSessions ?? outputSessionKeys.size, + allOutputSessions: input.sessionEvidence?.outputSessions ?? allOutputSessionKeys.size, + }; +} + +/** Day keys are already civil dates in the configured timezone. Weeks start Monday. */ +export function peakCalendarWeekProcessed( + rows: Iterable<{ day: string; processed: number }>, + coverageComplete: boolean, +): number { + if (!coverageComplete) return 0; + const weeks = new Map(); + for (const { day, processed } of rows) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || + !Number.isSafeInteger(processed) || processed < 0) continue; + const timestamp = Date.parse(`${day}T00:00:00.000Z`); + if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== day) continue; + const weekday = new Date(timestamp).getUTCDay(); + const monday = new Date(timestamp - ((weekday + 6) % 7) * 86_400_000).toISOString().slice(0, 10); + weeks.set(monday, (weeks.get(monday) ?? 0) + processed); + } + let peak = 0; + for (const value of weeks.values()) if (Number.isSafeInteger(value)) peak = Math.max(peak, value); + return peak; +} + +/** Do not turn ambiguous Codex day projections into activity milestones. */ +export function achievementActiveDayKeys( + claudeDays: Iterable<{ day: string; processed: number }>, + codexDays: Iterable<{ day: string; processed: number }>, + codexDaysTrusted: boolean, +): Set { + const days = new Set(); + const add = (rows: Iterable<{ day: string; processed: number }>): void => { + for (const { day, processed } of rows) { + const timestamp = /^\d{4}-\d{2}-\d{2}$/.test(day) + ? Date.parse(`${day}T00:00:00.000Z`) : NaN; + if (Number.isSafeInteger(processed) && processed > 0 && + Number.isFinite(timestamp) && + new Date(timestamp).toISOString().slice(0, 10) === day) { + days.add(day); + } + } + }; + add(claudeDays); + if (codexDaysTrusted) add(codexDays); + return days; +} + +export type AchievementGroup = 'claude' | 'codex' | 'combined' | 'plugin'; + +/** Auditable milestones: no badge is awarded just for opening a model once. */ +export const ACHIEVEMENT_DEFINITIONS = [ + { id: 'sonnet-main', group: 'claude', target: 100_000_000 }, + { id: 'sonnet-main-silver', group: 'claude', target: 1_000_000_000 }, + { id: 'sonnet-main-gold', group: 'claude', target: 10_000_000_000 }, + { id: 'opus-cannon', group: 'claude', target: 100_000_000 }, + { id: 'opus-cannon-silver', group: 'claude', target: 1_000_000_000 }, + { id: 'opus-cannon-gold', group: 'claude', target: 10_000_000_000 }, + { id: 'haiku-worker', group: 'claude', target: 10_000_000 }, + { id: 'haiku-worker-silver', group: 'claude', target: 100_000_000 }, + { id: 'haiku-worker-gold', group: 'claude', target: 1_000_000_000 }, + { id: 'cache-wizard', group: 'claude', target: 100_000_000 }, + { id: 'cache-wizard-silver', group: 'claude', target: 1_000_000_000 }, + { id: 'cache-wizard-gold', group: 'claude', target: 10_000_000_000 }, + { id: 'claude-output-bronze', group: 'claude', target: 2_000_000 }, + { id: 'claude-output-silver', group: 'claude', target: 20_000_000 }, + { id: 'claude-output-gold', group: 'claude', target: 200_000_000 }, + { id: 'claude-output-sessions-bronze', group: 'claude', target: 10 }, + { id: 'claude-output-sessions-silver', group: 'claude', target: 100 }, + { id: 'claude-output-sessions-gold', group: 'claude', target: 1_000 }, + { id: 'claude-cache-creation-bronze', group: 'claude', target: 10_000_000 }, + { id: 'claude-cache-creation-silver', group: 'claude', target: 100_000_000 }, + { id: 'claude-cache-creation-gold', group: 'claude', target: 1_000_000_000 }, + { id: 'fable-volume-bronze', group: 'claude', target: 50_000_000 }, + { id: 'fable-volume-silver', group: 'claude', target: 500_000_000 }, + { id: 'fable-volume-gold', group: 'claude', target: 5_000_000_000 }, + { id: 'fable-days-bronze', group: 'claude', target: 7 }, + { id: 'fable-days-silver', group: 'claude', target: 30 }, + { id: 'fable-days-gold', group: 'claude', target: 120 }, + { id: 'fable-relay', group: 'claude', target: 1 }, + { id: 'fable-sessions-bronze', group: 'claude', target: 10 }, + { id: 'fable-sessions-silver', group: 'claude', target: 100 }, + { id: 'fable-sessions-gold', group: 'claude', target: 1_000 }, + { id: 'weekly-volume-bronze', group: 'combined', target: 500_000_000 }, + { id: 'weekly-volume-silver', group: 'combined', target: 2_000_000_000 }, + { id: 'weekly-volume-gold', group: 'combined', target: 10_000_000_000 }, + { id: 'output-hours-bronze', group: 'combined', target: 12 }, + { id: 'output-hours-silver', group: 'combined', target: 24 }, + { id: 'output-hours-gold', group: 'combined', target: 48 }, + { id: 'night-output-peak', group: 'combined', target: 1 }, + { id: 'settings-explorer-bronze', group: 'plugin', target: 3 }, + { id: 'settings-explorer-silver', group: 'plugin', target: 10 }, + { id: 'settings-explorer-gold', group: 'plugin', target: 20 }, + { id: 'safe-feature-explorer', group: 'plugin', target: SAFE_EXPLORATION_FEATURES.length }, + { id: 'local-share-export', group: 'plugin', target: 1 }, + { id: 'local-advice-feedback', group: 'plugin', target: 1 }, + { id: 'astra-regular', group: 'codex', target: 500_000_000 }, + { id: 'astra-regular-silver', group: 'codex', target: 5_000_000_000 }, + { id: 'astra-regular-gold', group: 'codex', target: 50_000_000_000 }, + { id: 'sol-regular', group: 'codex', target: 1_000_000_000 }, + { id: 'sol-regular-silver', group: 'codex', target: 10_000_000_000 }, + { id: 'sol-regular-gold', group: 'codex', target: 100_000_000_000 }, + { id: 'luna-regular', group: 'codex', target: 100_000_000 }, + { id: 'luna-regular-silver', group: 'codex', target: 1_000_000_000 }, + { id: 'luna-regular-gold', group: 'codex', target: 10_000_000_000 }, + { id: 'agent-standup', group: 'codex', target: 5 }, + { id: 'tiny-ai-company', group: 'codex', target: 20 }, + { id: 'agent-team-gold', group: 'codex', target: 50 }, + { id: 'agent-conductor', group: 'codex', target: 10 }, + { id: 'agent-conductor-silver', group: 'codex', target: 50 }, + { id: 'agent-conductor-gold', group: 'codex', target: 200 }, + { id: 'root-portfolio-bronze', group: 'codex', target: 25 }, + { id: 'root-portfolio-silver', group: 'codex', target: 250 }, + { id: 'root-portfolio-gold', group: 'codex', target: 2_000 }, + { id: 'agent-participation-bronze', group: 'codex', target: 25 }, + { id: 'agent-participation-silver', group: 'codex', target: 250 }, + { id: 'agent-participation-gold', group: 'codex', target: 2_500 }, + { id: 'model-relay-roots-bronze', group: 'codex', target: 10 }, + { id: 'model-relay-roots-silver', group: 'codex', target: 100 }, + { id: 'model-relay-roots-gold', group: 'codex', target: 1_000 }, + { id: 'multi-core-human', group: 'codex', target: 3 }, + { id: 'same-root-models-silver', group: 'codex', target: 10 }, + { id: 'same-root-models-gold', group: 'codex', target: 30 }, + { id: 'effort-explorer', group: 'codex', target: 4 }, + { id: 'effort-breadth-silver', group: 'codex', target: 5 }, + { id: 'effort-spectrum', group: 'codex', target: 6 }, + { id: 'active-day-streak', group: 'combined', target: 7 }, + { id: 'active-day-streak-silver', group: 'combined', target: 30 }, + { id: 'active-day-streak-gold', group: 'combined', target: 100 }, + { id: 'active-days-bronze', group: 'combined', target: 30 }, + { id: 'active-days-silver', group: 'combined', target: 180 }, + { id: 'active-days-gold', group: 'combined', target: 365 }, + { id: 'streak-machine', group: 'combined', target: 14 }, + { id: 'model-rainbow-bronze', group: 'combined', target: 2 }, + { id: 'model-rainbow', group: 'combined', target: 5 }, + { id: 'model-rainbow-gold', group: 'combined', target: 7 }, + { id: 'hundred-million-club', group: 'combined', target: 100_000_000 }, + { id: 'billion-token-club', group: 'combined', target: 1_000_000_000 }, + { id: 'ten-billion-token-club', group: 'combined', target: 10_000_000_000 }, + { id: 'hundred-billion-token-club', group: 'combined', target: 100_000_000_000 }, + { id: 'trillion-token-club', group: 'combined', target: 1_000_000_000_000 }, + { id: 'codex-output-bronze', group: 'codex', target: 5_000_000 }, + { id: 'codex-output-silver', group: 'codex', target: 50_000_000 }, + { id: 'codex-output-gold', group: 'codex', target: 500_000_000 }, + { id: 'codex-cache-read-bronze', group: 'codex', target: 1_000_000_000 }, + { id: 'codex-cache-read-silver', group: 'codex', target: 10_000_000_000 }, + { id: 'codex-cache-read-gold', group: 'codex', target: 100_000_000_000 }, + { id: 'astra-days-bronze', group: 'codex', target: 7 }, + { id: 'astra-days-silver', group: 'codex', target: 30 }, + { id: 'astra-days-gold', group: 'codex', target: 120 }, + { id: 'sol-days-bronze', group: 'codex', target: 7 }, + { id: 'sol-days-silver', group: 'codex', target: 30 }, + { id: 'sol-days-gold', group: 'codex', target: 120 }, + { id: 'luna-days-bronze', group: 'codex', target: 7 }, + { id: 'luna-days-silver', group: 'codex', target: 30 }, + { id: 'luna-days-gold', group: 'codex', target: 120 }, + { id: 'sonnet-days-bronze', group: 'claude', target: 7 }, + { id: 'sonnet-days-silver', group: 'claude', target: 30 }, + { id: 'sonnet-days-gold', group: 'claude', target: 120 }, + { id: 'opus-days-bronze', group: 'claude', target: 7 }, + { id: 'opus-days-silver', group: 'claude', target: 30 }, + { id: 'opus-days-gold', group: 'claude', target: 120 }, + { id: 'haiku-days-bronze', group: 'claude', target: 7 }, + { id: 'haiku-days-silver', group: 'claude', target: 30 }, + { id: 'haiku-days-gold', group: 'claude', target: 120 }, + { id: 'dual-provider-days-bronze', group: 'combined', target: 7 }, + { id: 'dual-provider-days-silver', group: 'combined', target: 30 }, + { id: 'dual-provider-days-gold', group: 'combined', target: 100 }, + { id: 'active-weeks-bronze', group: 'combined', target: 4 }, + { id: 'active-weeks-silver', group: 'combined', target: 12 }, + { id: 'active-weeks-gold', group: 'combined', target: 26 }, + { id: 'weekly-average-bronze', group: 'combined', target: 500_000_000 }, + { id: 'weekly-average-silver', group: 'combined', target: 2_000_000_000 }, + { id: 'weekly-average-gold', group: 'combined', target: 5_000_000_000 }, + { id: 'three-family-day', group: 'combined', target: 1 }, + { id: 'dual-provider-day', group: 'combined', target: 1 }, + ...LONG_TERM_ACHIEVEMENT_DEFINITIONS, + ...API_ACHIEVEMENT_DEFINITIONS, +] as const; + +export type AchievementId = typeof ACHIEVEMENT_DEFINITIONS[number]['id']; + +/** Preserve existing tier IDs; migrations recheck grants whose evidence rules changed. */ +export const ACHIEVEMENT_SERIES: Record = { + 'cumulative-volume': ['hundred-million-club', 'billion-token-club', 'ten-billion-token-club', + 'hundred-billion-token-club', 'trillion-token-club'], + 'sonnet-main': ['sonnet-main', 'sonnet-main-silver', 'sonnet-main-gold'], + 'opus-cannon': ['opus-cannon', 'opus-cannon-silver', 'opus-cannon-gold'], + 'haiku-worker': ['haiku-worker', 'haiku-worker-silver', 'haiku-worker-gold'], + 'cache-wizard': ['cache-wizard', 'cache-wizard-silver', 'cache-wizard-gold'], + 'claude-output': ['claude-output-bronze', 'claude-output-silver', 'claude-output-gold'], + 'claude-output-sessions': ['claude-output-sessions-bronze', 'claude-output-sessions-silver', 'claude-output-sessions-gold'], + 'claude-cache-creation': ['claude-cache-creation-bronze', 'claude-cache-creation-silver', 'claude-cache-creation-gold'], + 'fable-volume': ['fable-volume-bronze', 'fable-volume-silver', 'fable-volume-gold'], + 'fable-days': ['fable-days-bronze', 'fable-days-silver', 'fable-days-gold'], + 'fable-sessions': ['fable-sessions-bronze', 'fable-sessions-silver', 'fable-sessions-gold'], + 'weekly-volume': ['weekly-volume-bronze', 'weekly-volume-silver', 'weekly-volume-gold'], + 'output-hours': ['output-hours-bronze', 'output-hours-silver', 'output-hours-gold'], + 'settings-explorer': ['settings-explorer-bronze', 'settings-explorer-silver', 'settings-explorer-gold'], + 'astra-regular': ['astra-regular', 'astra-regular-silver', 'astra-regular-gold'], + 'sol-regular': ['sol-regular', 'sol-regular-silver', 'sol-regular-gold'], + 'luna-regular': ['luna-regular', 'luna-regular-silver', 'luna-regular-gold'], + 'agent-team': ['agent-standup', 'tiny-ai-company', 'agent-team-gold'], + 'same-root-models': ['multi-core-human', 'same-root-models-silver', 'same-root-models-gold'], + 'effort-breadth': ['effort-explorer', 'effort-breadth-silver', 'effort-spectrum'], + 'agent-conductor': ['agent-conductor', 'agent-conductor-silver', 'agent-conductor-gold'], + 'root-portfolio': ['root-portfolio-bronze', 'root-portfolio-silver', 'root-portfolio-gold'], + 'agent-participation': ['agent-participation-bronze', 'agent-participation-silver', 'agent-participation-gold'], + 'model-relay-roots': ['model-relay-roots-bronze', 'model-relay-roots-silver', 'model-relay-roots-gold'], + 'active-day-streak': ['active-day-streak', 'active-day-streak-silver', 'active-day-streak-gold'], + 'active-days': ['active-days-bronze', 'active-days-silver', 'active-days-gold'], + 'model-rainbow': ['model-rainbow-bronze', 'model-rainbow', 'model-rainbow-gold'], + 'codex-output': ['codex-output-bronze', 'codex-output-silver', 'codex-output-gold'], + 'codex-cache-read': ['codex-cache-read-bronze', 'codex-cache-read-silver', 'codex-cache-read-gold'], + 'astra-days': ['astra-days-bronze', 'astra-days-silver', 'astra-days-gold'], + 'sol-days': ['sol-days-bronze', 'sol-days-silver', 'sol-days-gold'], + 'luna-days': ['luna-days-bronze', 'luna-days-silver', 'luna-days-gold'], + 'sonnet-days': ['sonnet-days-bronze', 'sonnet-days-silver', 'sonnet-days-gold'], + 'opus-days': ['opus-days-bronze', 'opus-days-silver', 'opus-days-gold'], + 'haiku-days': ['haiku-days-bronze', 'haiku-days-silver', 'haiku-days-gold'], + 'dual-provider-days': ['dual-provider-days-bronze', 'dual-provider-days-silver', 'dual-provider-days-gold'], + 'active-weeks': ['active-weeks-bronze', 'active-weeks-silver', 'active-weeks-gold'], + 'weekly-average': ['weekly-average-bronze', 'weekly-average-silver', 'weekly-average-gold'], + ...API_ACHIEVEMENT_SERIES, +}; +for (const rule of LONG_TERM_ACHIEVEMENT_DEFINITIONS) { + ACHIEVEMENT_SERIES[rule.series] = [...ACHIEVEMENT_SERIES[rule.series], rule.id]; +} + +const QUIET_ACHIEVEMENTS = new Set([ + 'night-output-peak', 'output-hours-silver', 'output-hours-gold', +]); + +/** Frozen pre-release targets: retained IDs prove only their former floor. */ +const PRE_CALIBRATION_LADDERS: Record = { + 'sonnet-main': [2e6, 1e8, 1e9], 'opus-cannon': [2e6, 5e8, 1e10], + 'haiku-worker': [5e5, 1e7, 1e8], 'cache-wizard': [1e7, 1e9, 1e10], + 'claude-output': [1e6, 5e7, 5e8], 'claude-cache-creation': [1e7, 1e9, 1e10], + 'fable-volume': [1e7, 1e8, 1e9], 'astra-regular': [1e8, 1e9, 1e10], + 'sol-regular': [1e8, 1e9, 1e10], 'codex-output': [1e6, 5e7, 5e8], + 'codex-cache-read': [1e8, 1e9, 1e10], 'weekly-average': [5e8, 2e9, 1e10], + 'model-rainbow': [2, 5, 6], +}; +const CALIBRATION_RECHECK_ONLY: readonly AchievementId[] = [ + ...ACHIEVEMENT_SERIES['fable-days'].slice(0,3), ...ACHIEVEMENT_SERIES['active-weeks'].slice(0,3), 'three-family-day', + ...RECALIBRATED_ACHIEVEMENT_IDS_V2, +]; +export const CALIBRATED_ACHIEVEMENT_IDS: readonly AchievementId[] = [ + ...Object.entries(PRE_CALIBRATION_LADDERS).flatMap(([series, former]) => ACHIEVEMENT_SERIES[series].slice(0,former.length)), + ...CALIBRATION_RECHECK_ONLY, +]; + +/** Keep unrelated awards; a changed condition cannot inherit numeric proof. */ +export function migrateCalibratedAchievementGrants(value: unknown): AchievementId[] { + if (!Array.isArray(value)) return []; + const allowed = new Set(ACHIEVEMENT_DEFINITIONS.map(rule => rule.id)); + const existing = new Set(value.filter((id): id is AchievementId => + typeof id === 'string' && allowed.has(id) && isLegacyAchievementId(id))); + const changed = new Set(CALIBRATED_ACHIEVEMENT_IDS); + const retained = new Set([...existing].filter(id => !changed.has(id))); + for (const [series, formerTargets] of Object.entries(PRE_CALIBRATION_LADDERS)) { + const ids = ACHIEVEMENT_SERIES[series].slice(0,formerTargets.length); + const provenFloor = ids.reduce((floor, id, index) => existing.has(id) + ? Math.max(floor, formerTargets[index]) : floor, 0); + for (const id of ids) { + if (RECALIBRATED_ACHIEVEMENT_IDS_V2.includes(id)) continue; + const target = ACHIEVEMENT_DEFINITIONS.find(rule => rule.id === id)!.target; + if (provenFloor >= target) retained.add(id); + } + } + return [...retained].sort(); +} + +export function notifiableAchievementIds(ids: readonly AchievementId[]): AchievementId[] { + return ids.filter((id) => !QUIET_ACHIEVEMENTS.has(id)); +} +export interface AchievementResult { + id: AchievementId; + group: AchievementGroup; + progress: number; + target: number; + unlocked: boolean; +} + +function positiveUsage(tokens: ProviderTokenCounts | undefined): boolean { + return !!tokens && + Number.isSafeInteger(tokens.inputTotal) && tokens.inputTotal >= 0 && + Number.isSafeInteger(tokens.outputTotal) && tokens.outputTotal >= 0 && + Number.isSafeInteger(tokens.inputTotal + tokens.outputTotal) && + tokens.inputTotal + tokens.outputTotal > 0; +} + +/** + * Uses already-reconciled per-file aggregates. Orphan/cyclic parent chains, + * inherited fork prefixes, unknown models, and zero usage cannot award. + */ +export function codexModelSignals(files: readonly CodexFileAggregate[]): CodexAchievementSignals { + const bySession = new Map(); + const ambiguousSessions = new Set(); + const processedBySession = new Map(); + for (const file of files) { + const key = file.session.sessionKey; + if (!key) continue; + if (bySession.has(key)) ambiguousSessions.add(key); + bySession.set(key, file); + const input = file.total.inputTotal; + const output = file.total.outputTotal; + if (!Number.isSafeInteger(input) || input < 0 || + !Number.isSafeInteger(output) || output < 0 || + !Number.isSafeInteger(input + output)) continue; + processedBySession.set(key, Math.max(processedBySession.get(key) ?? 0, input + output)); + } + const processedLowerBound = [...processedBySession.values()].reduce((sum, value) => sum + value, 0); + const models = new Set(); + const efforts = new Set(); + const familyProcessedTokens: Record = { astra: 0, sol: 0, luna: 0 }; + const effortProcessedTokens: Partial> = {}; + const roots: Record> = { + astra: new Set(), sol: new Set(), luna: new Set(), + }; + const effortRoots: Record> = { + low: new Set(), medium: new Set(), high: new Set(), + xhigh: new Set(), max: new Set(), ultra: new Set(), + }; + const childrenByRoot = new Map>(); + const verifiedChildren = new Set(); + const modelsByRoot = new Map>(); + const rootBySession = new Map(); + const verifiedRoot = (file: CodexFileAggregate): string | null => { + let current = file; + const seen = new Set(); + let root: string | null = null; + while (true) { + const key = current.session.sessionKey; + if (!key || seen.has(key) || ambiguousSessions.has(key)) break; + if (rootBySession.has(key)) { root = rootBySession.get(key)!; break; } + seen.add(key); + const parent = current.session.parentSessionKey; + if (!parent) { root = current.session.role === 'root' ? key : null; break; } + const next = bySession.get(parent); + if (!next) break; + current = next; + } + for (const key of seen) rootBySession.set(key, root); + return root; + }; + for (const file of verifiedCodexAchievementFiles(files)) { + const root = verifiedRoot(file); + if (!root) continue; + // The subset selector has already verified the complete model vector, + // including cached/reasoning counters, before admitting this lineage. + let hasRealModelUsage = false; + for (const [rawModel, tokens] of Object.entries(file.byModel)) { + if (!positiveUsage(tokens)) continue; + const model = normalizeCodexAchievementModel(rawModel); + if (!model) continue; + hasRealModelUsage = true; + models.add(model); + let rootModels = modelsByRoot.get(root); + if (!rootModels) modelsByRoot.set(root, rootModels = new Set()); + rootModels.add(model); + const family = codexModelFamily(model); + if (family) roots[family].add(root); + if (family) familyProcessedTokens[family] += tokens.inputTotal + tokens.outputTotal; + } + if (!hasRealModelUsage) continue; + const key = file.session.sessionKey; + if (key !== root && key && positiveUsage(file.total) && file.session.role === 'subagent') { + let children = childrenByRoot.get(root); + if (!children) childrenByRoot.set(root, children = new Set()); + children.add(key); + verifiedChildren.add(key); + } + if (!validAchievementBuckets(file.byEffort, file.total)) continue; + for (const [effort, tokens] of Object.entries(file.byEffort)) { + if (!positiveUsage(tokens) || !['none', 'minimal', ...SPECTRUM_EFFORTS].includes(effort)) continue; + efforts.add(effort); + if (SPECTRUM_EFFORTS.includes(effort as EffortTier)) effortRoots[effort as EffortTier].add(root); + if (SPECTRUM_EFFORTS.includes(effort as EffortTier)) { + const tier = effort as EffortTier; + effortProcessedTokens[tier] = (effortProcessedTokens[tier] ?? 0) + tokens.inputTotal + tokens.outputTotal; + } + } + } + return { + verifiedSubset: true, + modelCount: models.size, + efforts: [...efforts].sort(), + familyProcessedTokens, + effortProcessedTokens, + processedLowerBound: Number.isSafeInteger(processedLowerBound) ? processedLowerBound : 0, + familyRootCounts: { + astra: roots.astra.size, sol: roots.sol.size, luna: roots.luna.size, + }, + effortRootCounts: Object.fromEntries(SPECTRUM_EFFORTS.map((tier) => [tier, effortRoots[tier].size])), + maxVerifiedAgentsPerRoot: [...childrenByRoot.values()].reduce((max, children) => Math.max(max, children.size), 0), + maxRealModelsPerRoot: [...modelsByRoot.values()].reduce((max, rootModels) => Math.max(max, rootModels.size), 0), + multiAgentRootCount: [...childrenByRoot.values()].filter((children) => children.size >= 3).length, + verifiedRootCount: modelsByRoot.size, + verifiedChildCount: verifiedChildren.size, + multiModelRootCount: [...modelsByRoot.values()].filter((rootModels) => rootModels.size >= 2).length, + collaboration: codexCollaborationEvidence(files), + }; +} + +/** Calendar dates are already in the dashboard's configured timezone. */ +export function longestActiveDayStreak(days: Iterable): number { + const dates = new Set(); + for (const day of days) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue; + const timestamp = Date.parse(`${day}T00:00:00.000Z`); + if (Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === day) { + dates.add(timestamp); + } + } + let longest = 0; + let current = 0; + let previous = -Infinity; + for (const date of [...dates].sort((a, b) => a - b)) { + current = date - previous === 86_400_000 ? current + 1 : 1; + longest = Math.max(longest, current); + previous = date; + } + return longest; +} + +function safeCount(value: number | undefined): number { + return Number.isSafeInteger(value) && (value ?? 0) > 0 ? value! : 0; +} + +export function evaluateAchievements(facts: AchievementFacts): AchievementResult[] { + const combined = safeCount(facts.claudeTokens) + safeCount(facts.codexTokens); + const diverseFamilies = (['sonnet', 'opus', 'haiku'] as const) + .filter((family) => safeCount(facts.claudeFamilyTokens?.[family]) >= 10_000_000 && + safeCount(facts.claudeFamilyOutputTokens?.[family]) >= 20_000).length + + (['astra', 'sol', 'luna'] as const) + .filter((family) => safeCount(facts.codex.collaboration?.rainbowFamilies[family].processed) >= 10_000_000 && + safeCount(facts.codex.collaboration?.rainbowFamilies[family].output) >= 20_000).length + + (safeCount(facts.fable?.processedTokens) >= 10_000_000 && safeCount(facts.fable?.outputTokens) >= 20_000 ? 1 : 0); + const values: Record, number> = { + ...achievementMoneyProgress(facts.money), + 'sonnet-main': safeCount(facts.claudeFamilyTokens?.sonnet), + 'sonnet-main-silver': safeCount(facts.claudeFamilyTokens?.sonnet), + 'sonnet-main-gold': safeCount(facts.claudeFamilyTokens?.sonnet), + 'opus-cannon': safeCount(facts.claudeFamilyTokens?.opus), + 'opus-cannon-silver': safeCount(facts.claudeFamilyTokens?.opus), + 'opus-cannon-gold': safeCount(facts.claudeFamilyTokens?.opus), + 'haiku-worker': safeCount(facts.claudeFamilyTokens?.haiku), + 'haiku-worker-silver': safeCount(facts.claudeFamilyTokens?.haiku), + 'haiku-worker-gold': safeCount(facts.claudeFamilyTokens?.haiku), + 'cache-wizard': safeCount(facts.claudeCacheReadTokens), + 'cache-wizard-silver': safeCount(facts.claudeCacheReadTokens), + 'cache-wizard-gold': safeCount(facts.claudeCacheReadTokens), + 'claude-output-bronze': safeCount(facts.claudeOutputTokens), + 'claude-output-silver': safeCount(facts.claudeOutputTokens), + 'claude-output-gold': safeCount(facts.claudeOutputTokens), + 'claude-output-sessions-bronze': safeCount(facts.claudeOutputSessions), + 'claude-output-sessions-silver': safeCount(facts.claudeOutputSessions), + 'claude-output-sessions-gold': safeCount(facts.claudeOutputSessions), + 'claude-cache-creation-bronze': safeCount(facts.claudeCacheCreationTokens), + 'claude-cache-creation-silver': safeCount(facts.claudeCacheCreationTokens), + 'claude-cache-creation-gold': safeCount(facts.claudeCacheCreationTokens), + 'fable-volume-bronze': safeCount(facts.fable?.processedTokens), + 'fable-volume-silver': safeCount(facts.fable?.processedTokens), + 'fable-volume-gold': safeCount(facts.fable?.processedTokens), + 'fable-days-bronze': safeCount(facts.calendar?.familyDays.fable), + 'fable-days-silver': safeCount(facts.calendar?.familyDays.fable), + 'fable-days-gold': safeCount(facts.calendar?.familyDays.fable), + 'fable-relay': facts.fable?.mixedFamilySession ? 1 : 0, + 'fable-sessions-bronze': safeCount(facts.fable?.outputSessions), + 'fable-sessions-silver': safeCount(facts.fable?.outputSessions), + 'fable-sessions-gold': safeCount(facts.fable?.outputSessions), + 'weekly-volume-bronze': safeCount(facts.weeklyPeakProcessed), + 'weekly-volume-silver': safeCount(facts.weeklyPeakProcessed), + 'weekly-volume-gold': safeCount(facts.weeklyPeakProcessed), + 'output-hours-bronze': safeCount(facts.outputHourStreak), + 'output-hours-silver': safeCount(facts.outputHourStreak), + 'output-hours-gold': safeCount(facts.outputHourStreak), + 'night-output-peak': facts.nightOutputPeak ? 1 : 0, + 'settings-explorer-bronze': facts.activity?.settingKeys.length ?? 0, + 'settings-explorer-silver': facts.activity?.settingKeys.length ?? 0, + 'settings-explorer-gold': facts.activity?.settingKeys.length ?? 0, + 'safe-feature-explorer': SAFE_EXPLORATION_FEATURES + .filter((feature) => facts.activity?.features.includes(feature)).length, + 'local-share-export': facts.activity?.features.includes('localShareExport') ? 1 : 0, + 'local-advice-feedback': facts.activity?.features.includes('localAdviceFeedback') ? 1 : 0, + 'astra-regular': safeCount(facts.codex.familyProcessedTokens?.astra), + 'astra-regular-silver': safeCount(facts.codex.familyProcessedTokens?.astra), + 'astra-regular-gold': safeCount(facts.codex.familyProcessedTokens?.astra), + 'sol-regular': safeCount(facts.codex.familyProcessedTokens?.sol), + 'sol-regular-silver': safeCount(facts.codex.familyProcessedTokens?.sol), + 'sol-regular-gold': safeCount(facts.codex.familyProcessedTokens?.sol), + 'luna-regular': safeCount(facts.codex.familyProcessedTokens?.luna), + 'luna-regular-silver': safeCount(facts.codex.familyProcessedTokens?.luna), + 'luna-regular-gold': safeCount(facts.codex.familyProcessedTokens?.luna), + 'agent-standup': safeCount(facts.codex.collaboration?.teamTaskCounts[0]), + 'tiny-ai-company': safeCount(facts.codex.collaboration?.teamTaskCounts[1]), + 'agent-team-gold': safeCount(facts.codex.collaboration?.teamTaskCounts[2]), + 'agent-conductor': safeCount(facts.codex.collaboration?.orchestratedTasks), + 'agent-conductor-silver': safeCount(facts.codex.collaboration?.orchestratedTasks), + 'agent-conductor-gold': safeCount(facts.codex.collaboration?.orchestratedTasks), + 'root-portfolio-bronze': safeCount(facts.codex.verifiedRootCount), + 'root-portfolio-silver': safeCount(facts.codex.verifiedRootCount), + 'root-portfolio-gold': safeCount(facts.codex.verifiedRootCount), + 'agent-participation-bronze': safeCount(facts.codex.collaboration?.qualifiedSubagentCount), + 'agent-participation-silver': safeCount(facts.codex.collaboration?.qualifiedSubagentCount), + 'agent-participation-gold': safeCount(facts.codex.collaboration?.qualifiedSubagentCount), + 'model-relay-roots-bronze': safeCount(facts.codex.collaboration?.multiModelTasks), + 'model-relay-roots-silver': safeCount(facts.codex.collaboration?.multiModelTasks), + 'model-relay-roots-gold': safeCount(facts.codex.collaboration?.multiModelTasks), + 'multi-core-human': safeCount(facts.codex.collaboration?.modelTaskCounts[0]), + 'same-root-models-silver': safeCount(facts.codex.collaboration?.modelTaskCounts[1]), + 'same-root-models-gold': safeCount(facts.codex.collaboration?.modelTaskCounts[2]), + 'effort-explorer': safeCount(facts.codex.collaboration?.effortQualifiedTierCounts[0]), + 'effort-breadth-silver': safeCount(facts.codex.collaboration?.effortQualifiedTierCounts[1]), + 'effort-spectrum': safeCount(facts.codex.collaboration?.effortQualifiedTierCounts[2]), + 'active-day-streak': safeCount(facts.longestStreak), + 'active-day-streak-silver': safeCount(facts.longestStreak), + 'active-day-streak-gold': safeCount(facts.longestStreak), + 'active-days-bronze': safeCount(facts.activeDays), + 'active-days-silver': safeCount(facts.activeDays), + 'active-days-gold': safeCount(facts.activeDays), + 'streak-machine': safeCount(facts.longestStreak), + 'model-rainbow-bronze': diverseFamilies, + 'model-rainbow': diverseFamilies, + 'model-rainbow-gold': diverseFamilies, + 'hundred-million-club': combined, + 'billion-token-club': combined, + 'ten-billion-token-club': combined, + 'hundred-billion-token-club': combined, + 'trillion-token-club': combined, + 'codex-output-bronze': safeCount(facts.codexOutputTokens), + 'codex-output-silver': safeCount(facts.codexOutputTokens), + 'codex-output-gold': safeCount(facts.codexOutputTokens), + 'codex-cache-read-bronze': safeCount(facts.codexCachedInputTokens), + 'codex-cache-read-silver': safeCount(facts.codexCachedInputTokens), + 'codex-cache-read-gold': safeCount(facts.codexCachedInputTokens), + 'astra-days-bronze': safeCount(facts.calendar?.familyDays.astra), + 'astra-days-silver': safeCount(facts.calendar?.familyDays.astra), + 'astra-days-gold': safeCount(facts.calendar?.familyDays.astra), + 'sol-days-bronze': safeCount(facts.calendar?.familyDays.sol), + 'sol-days-silver': safeCount(facts.calendar?.familyDays.sol), + 'sol-days-gold': safeCount(facts.calendar?.familyDays.sol), + 'luna-days-bronze': safeCount(facts.calendar?.familyDays.luna), + 'luna-days-silver': safeCount(facts.calendar?.familyDays.luna), + 'luna-days-gold': safeCount(facts.calendar?.familyDays.luna), + 'sonnet-days-bronze': safeCount(facts.calendar?.familyDays.sonnet), + 'sonnet-days-silver': safeCount(facts.calendar?.familyDays.sonnet), + 'sonnet-days-gold': safeCount(facts.calendar?.familyDays.sonnet), + 'opus-days-bronze': safeCount(facts.calendar?.familyDays.opus), + 'opus-days-silver': safeCount(facts.calendar?.familyDays.opus), + 'opus-days-gold': safeCount(facts.calendar?.familyDays.opus), + 'haiku-days-bronze': safeCount(facts.calendar?.familyDays.haiku), + 'haiku-days-silver': safeCount(facts.calendar?.familyDays.haiku), + 'haiku-days-gold': safeCount(facts.calendar?.familyDays.haiku), + 'dual-provider-days-bronze': safeCount(facts.calendar?.dualProviderDays), + 'dual-provider-days-silver': safeCount(facts.calendar?.dualProviderDays), + 'dual-provider-days-gold': safeCount(facts.calendar?.dualProviderDays), + 'active-weeks-bronze': safeCount(facts.calendar?.activeWeeks), + 'active-weeks-silver': safeCount(facts.calendar?.activeWeeks), + 'active-weeks-gold': safeCount(facts.calendar?.activeWeeks), + 'weekly-average-bronze': safeCount(facts.calendar?.peakFourWeekAverage), + 'weekly-average-silver': safeCount(facts.calendar?.peakFourWeekAverage), + 'weekly-average-gold': safeCount(facts.calendar?.peakFourWeekAverage), + 'three-family-day': facts.calendar?.threeFamilyDay ? 1 : 0, + 'dual-provider-day': facts.calendar?.dualProviderDay ? 1 : 0, + }; + return ACHIEVEMENT_DEFINITIONS.map(rule => { + const progress = safeCount(values['baseId' in rule ? rule.baseId : rule.id]); + return { id:rule.id, group:rule.group, target:rule.target, progress, unlocked: progress >= rule.target }; + }); +} + +/** Only a committed local-action delta, never hydration, proves a new plugin unlock. */ +export function newlyUnlockedPluginAchievements( + before: AchievementActivity, after: AchievementActivity, +): AchievementId[] { + const facts: AchievementFacts = { + claudeTokens: 0, codexTokens: 0, activeDays: 0, claudeFamilies: [], + codex: { modelCount: 0, efforts: [], familyRootCounts: { astra: 0, sol: 0, luna: 0 } }, + }; + // Reuse the actual catalog/evaluator so provenance cannot drift from targets. + const previous = new Set(evaluateAchievements({ ...facts, activity: before }) + .filter(result => result.group === 'plugin' && result.unlocked).map(result => result.id)); + return evaluateAchievements({ ...facts, activity: after }) + .filter(result => result.group === 'plugin' && result.unlocked && !previous.has(result.id)) + .map(result => result.id); +} diff --git a/src/apiHeatmap.ts b/src/apiHeatmap.ts new file mode 100644 index 00000000..e15655c7 --- /dev/null +++ b/src/apiHeatmap.ts @@ -0,0 +1,413 @@ +import { createHash } from 'node:crypto'; +import { + CombinedDayUsage, CombinedHeatmapRange, addCalendarDays, + combinedHeatmapRangeDays, normalizeCombinedHeatmapRange, sanitizeCombinedHeatmapTitle, +} from './combinedHeatmap'; +import { + COMBINED_ACTIVITY_PALETTES, CombinedHeatmapIntensityMode, CombinedHeatmapPalette, + customCombinedHeatmapScale, normalizeCombinedHeatmapAccent, + normalizeCombinedHeatmapIntensityMode, normalizeCombinedHeatmapPalette, renderCombinedHeatmapSvg, +} from './combinedHeatmapSvg'; +import { + CURRENCY_REFERENCE_DATE, DisplayCurrencyCode, formatUsdForDisplay, + normalizeDisplayCurrencyCode, resolveCurrencyDisplay, +} from './currencyDisplay'; +import { DayUsage } from './heatmap'; +import { resolveTimeZone } from './dateKeys'; +import { HeatmapSvgOptions, renderHeatmapSvg } from './heatmapSvg'; +import { artifactLocale } from './i18n'; +import { API_HEATMAP_COPY } from './apiHeatmapCopy'; +import { escapeSvgAttribute, escapeSvgText } from './svgEscape'; +import { SupportedLanguage } from './types'; + +export type ApiHeatmapProvider = 'claude' | 'codex' | 'both'; +export type ApiHeatmapMetric = 'tokens' | 'apiEquivalent'; +export type ApiHeatmapPricingStatus = 'empty' | 'unpriced' | 'partial' | 'complete'; + +/** Current floating daily estimates, never achievement reference-price money. + * Counts use the same producer-defined pricing evidence unit for both fields. + * Claude includes current backend/fallback/override semantics (estimated). + * Codex supplies exact-model-priced subsets only, with estimated=false. + * Missing rows / empty status mean no data; complete amountUSD=0 is real zero. + */ +export interface ApiHeatmapDailyUsage { + readonly tokens: number; + readonly amountUSD: number | null; + readonly pricingStatus: ApiHeatmapPricingStatus; + readonly estimated: boolean; + readonly pricedCount: number; + readonly unpricedCount: number; + /** False when a missing date cannot prove absence in an incomplete index. */ + readonly sourceComplete?: boolean; +} + +export type ApiHeatmapDaily = Readonly>; + +export interface ApiHeatmapOptions { + endDateISO: string; + provider?: ApiHeatmapProvider; + metric?: ApiHeatmapMetric; + showAmounts?: boolean; + hideAmounts?: boolean; + range?: CombinedHeatmapRange; + timezone?: string; + locale?: string; + currency?: string; + title?: string; + palette?: CombinedHeatmapPalette | string; + customAccent?: string; + intensityMode?: CombinedHeatmapIntensityMode | string; + claudeBackend?: 'anthropic' | 'aws-bedrock-in-region'; + /** Nonpersonal monotonic counters, bumped by the producer on replacement. */ + dataRevision?: number; + pricingRevision?: number; +} + +export interface ApiHeatmapConfig { + endDateISO: string; + startDateISO: string; + provider: ApiHeatmapProvider; + metric: ApiHeatmapMetric; + showAmounts: boolean; + range: CombinedHeatmapRange; + timezone: string; + locale: SupportedLanguage; + currency: DisplayCurrencyCode; + /** Empty uses the renderer's locale/provider-specific default heading. */ + title: string; + palette: CombinedHeatmapPalette; + customAccent: string; + intensityMode: CombinedHeatmapIntensityMode; + claudeBackend: 'anthropic' | 'aws-bedrock-in-region'; + dataRevision: number; + pricingRevision: number; +} + +export interface ApiHeatmapArtifact { + config: ApiHeatmapConfig; + svg: string; + summary: string; + filename: string; + markdown: string; + /** Internal identity only: never serialize into public SVG or Markdown. */ + cacheKey: string; + dataQualitySignature: string; +} + +export const API_HEATMAP_RENDER_VERSION = 3; + +function counter(value: unknown): number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0; +} + +function timezoneLabel(raw: unknown): string { + if (typeof raw !== 'string' || raw.length > 64) return 'UTC'; + const zone = raw.trim(); + // An explicit empty app selection means the system zone. Unsupported input + // still fails closed to UTC; never use dateKeys' invalid-input fallback here. + if (zone === '') return resolveTimeZone(''); + if (!/^[A-Za-z0-9_+\-/:]+$/.test(zone)) return 'UTC'; + try { + new Intl.DateTimeFormat('en', { timeZone: zone }); + return resolveTimeZone(zone); + } catch { + return 'UTC'; + } +} + +/** Whitelisted fields only: bounded workbench titles, no FX multipliers/raw IDs. */ +export function normalizeApiHeatmapConfig(options: ApiHeatmapOptions): ApiHeatmapConfig { + const endDateISO = options.endDateISO; + if (typeof endDateISO !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(endDateISO)) { + throw new Error('Invalid heatmap end date'); + } + // Also checks real calendar dates; no implicit clock or machine timezone. + addCalendarDays(endDateISO, 0); + const range = normalizeCombinedHeatmapRange(options.range); + const showAmounts = options.hideAmounts !== true && options.showAmounts === true && options.metric === 'apiEquivalent'; + return { + endDateISO, + startDateISO: addCalendarDays(endDateISO, -(combinedHeatmapRangeDays(range) - 1)), + provider: options.provider === 'claude' || options.provider === 'codex' ? options.provider : 'both', + metric: showAmounts ? 'apiEquivalent' : 'tokens', + showAmounts, + range, + timezone: timezoneLabel(options.timezone), + locale: artifactLocale(options.locale), + currency: normalizeDisplayCurrencyCode(options.currency), + title: sanitizeCombinedHeatmapTitle(options.title, ''), + palette: normalizeCombinedHeatmapPalette(options.palette), + customAccent: normalizeCombinedHeatmapAccent(options.customAccent), + intensityMode: normalizeCombinedHeatmapIntensityMode(options.intensityMode), + claudeBackend: options.claudeBackend === 'aws-bedrock-in-region' ? 'aws-bedrock-in-region' : 'anthropic', + dataRevision: counter(options.dataRevision), + pricingRevision: counter(options.pricingRevision), + }; +} + +type Provider = Exclude; +interface SelectedDay { + dateISO: string; + tokens: number; + pricing?: { + status: ApiHeatmapPricingStatus; + amountUSD: number | null; + estimated: boolean; + pricedCount: number; + unpricedCount: number; + sourceComplete: boolean; + }; +} + +function checkedNumber(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) { + throw new Error('Invalid heatmap numeric aggregate'); + } + return value; +} + +function addTotal(a: number, b: number): number { + return checkedNumber(a + b); +} + +function selectPricing(source: ApiHeatmapDailyUsage | undefined, tokens: number, provider: Provider): NonNullable { + const status = source?.pricingStatus; + const sourceComplete = source?.sourceComplete === undefined || source.sourceComplete === true; + const empty = {status:'empty' as const,amountUSD:null,estimated:false,pricedCount:0,unpricedCount:0,sourceComplete}; + if (!source || (status === 'empty' && tokens === 0 && sourceComplete)) return empty; + const rawPriced = source.pricedCount; + const rawUnpriced = source.unpricedCount; + const rawEstimated = source.estimated; + const priced = counter(rawPriced); + const unpriced = counter(rawUnpriced); + const estimated = rawEstimated === true; + const unknown = { + status: 'unpriced' as const, amountUSD: null, estimated, + pricedCount:0,unpricedCount:addTotal(priced,unpriced),sourceComplete, + }; + if (status !== 'complete' && status !== 'partial') return unknown; + if (typeof rawPriced !== 'number' || !Number.isSafeInteger(rawPriced) || rawPriced < 0 || + typeof rawUnpriced !== 'number' || !Number.isSafeInteger(rawUnpriced) || rawUnpriced < 0 || + typeof rawEstimated !== 'boolean') return unknown; + const amount = source.amountUSD; + if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0 || amount > Number.MAX_SAFE_INTEGER || + (provider === 'codex' && rawEstimated !== false) || + (tokens > 0 && priced === 0) || + (status === 'complete' && unpriced > 0) || + (status === 'partial' && (priced === 0 || unpriced === 0))) return unknown; + return {status,amountUSD:amount,estimated,pricedCount:priced,unpricedCount:unpriced,sourceComplete}; +} + +/** Bounded range access, without Object.entries/spread on provider payloads. + * Token mode intentionally never touches any pricing property, even quality. + */ +function selectDays(daily: ApiHeatmapDaily, config: ApiHeatmapConfig, provider: Provider): SelectedDay[] { + const result: SelectedDay[] = []; + const days = combinedHeatmapRangeDays(config.range); + for (let index = 0; index < days; index++) { + const dateISO = addCalendarDays(config.startDateISO, index); + const source = daily[dateISO]?.[provider]; + const tokens = source === undefined ? 0 : checkedNumber(source.tokens); + const selected: SelectedDay = { dateISO, tokens }; + if (config.metric === 'apiEquivalent') selected.pricing = selectPricing(source, tokens, provider); + result.push(selected); + } + return result; +} + +function totalTokens(days: readonly SelectedDay[]): number { + return days.reduce((total, day) => addTotal(total, day.tokens), 0); +} + +function compact(value: number, config: ApiHeatmapConfig): string { + return new Intl.NumberFormat(config.locale, { notation: 'compact', maximumFractionDigits: 1 }).format(value); +} + +function money(value: number, config: ApiHeatmapConfig): string { + const rate = resolveCurrencyDisplay(config.currency).unitsPerUsd; + const converted = checkedNumber(value * rate); + // Avoid falsely displaying a small positive amount as true zero. + return converted > 0 && converted < 0.01 + ? `<${formatUsdForDisplay(0.01 / rate, config.currency)}` + : formatUsdForDisplay(value, config.currency); +} + +function coverage(priced: number, unpriced: number, config: ApiHeatmapConfig): string { + const total = addTotal(priced, unpriced); + const percentage = total === 0 ? '—' : new Intl.NumberFormat(config.locale, { + style: 'percent', maximumFractionDigits: 0, + }).format(priced / total); + return `${API_HEATMAP_COPY[config.locale].coverage} ${priced}/${total} (${percentage})`; +} + +function pricingSummary(days: readonly SelectedDay[], config: ApiHeatmapConfig): string { + const copy = API_HEATMAP_COPY[config.locale]; + let subtotal = 0; + let priced = 0; + let unpriced = 0; + let hasPriced = false; + let hasData = false; + let sourceComplete = true; + for (const day of days) { + const quality = day.pricing!; + sourceComplete &&= quality.sourceComplete; + hasData ||= quality.status !== 'empty'; + priced = addTotal(priced, quality.pricedCount); + unpriced = addTotal(unpriced, quality.unpricedCount); + if (quality.amountUSD !== null) { + hasPriced = true; + subtotal = addTotal(subtotal, quality.amountUSD); + } + } + if (!hasData) return copy.noData; + const note=sourceComplete ? '' : ` · ${copy.sourceIncomplete}`; + if (!hasPriced) return `${copy.unpriced} · ${coverage(priced,unpriced,config)}${note}`; + return `${unpriced>0 || !sourceComplete ? `${copy.subtotal} ≥` : ''}${money(subtotal,config)} · ${coverage(priced,unpriced,config)}${note}`; +} + +function activityScale(config: ApiHeatmapConfig): string[] { + return config.palette === 'custom' + ? customCombinedHeatmapScale(config.customAccent) + : COMBINED_ACTIVITY_PALETTES[config.palette]; +} + +function renderAmountBlock(days: readonly SelectedDay[], provider: Provider, config: ApiHeatmapConfig): { svg: string; summary: string } { + const copy = API_HEATMAP_COPY[config.locale]; + const name = provider === 'claude' ? 'Claude' : 'Codex'; + const label = provider === 'claude' ? copy.estimated : copy.exact; + const summary = `${name} · ${copy.apiEquivalent} · ${label} · ${pricingSummary(days, config)}`; + const byDate = new Map(days.map(day => [day.dateISO, day])); + const usage: Record = {}; + for (const day of days) usage[day.dateISO] = { tokens: day.tokens, cost: day.pricing!.amountUSD ?? 0, sessions: 0 }; + const currency = resolveCurrencyDisplay(config.currency); + const subtitle = `${config.startDateISO} – ${config.endDateISO} · ${config.timezone} · ${config.currency}`; + const title = config.title || summary; + const backend = provider === 'claude' ? config.claudeBackend : 'openai'; + const footer = `${copy.currentPricing}: ${backend} · ${copy.notBill}`; + const options: HeatmapSvgOptions = { + metric: 'cost', startDateISO: config.startDateISO, endDateISO: config.endDateISO, + locale: config.locale, scale: activityScale(config), + intensityMode: config.intensityMode, minWidth: 900, title, + subtitle: config.title ? `${summary} · ${subtitle}` : subtitle, + footerNote: footer, watermark: copy.missingLegend, + ariaLabel: `${title}. ${summary}. ${subtitle}. ${footer}. ${copy.missingLegend}`, + cellStyle: date => { + const status = byDate.get(date)!.pricing!.status; + if (status === 'empty') return { fill: '#f6f8fa', stroke: '#d0d7de' }; + if (status === 'unpriced') return { fill: '#d1d5db', stroke: '#6b7280' }; + if (status === 'partial') return { stroke: '#6b7280', dashed: true }; + return undefined; + }, + tooltip: date => { + const quality = byDate.get(date)!.pricing!; + const prefix = `${date} · ${name} · ${copy.apiEquivalent}`; + const note=quality.sourceComplete ? '' : ` · ${copy.sourceIncomplete}`; + if (quality.status === 'empty') return `${prefix} · ${copy.noData}`; + if (quality.status === 'unpriced') return `${prefix} · ${copy.unpriced} · ${coverage(quality.pricedCount, quality.unpricedCount, config)}${note}`; + return `${prefix} · ${label} · ${quality.status==='partial' || !quality.sourceComplete ? `${copy.subtotal} ≥` : ''}` + + `${money(quality.amountUSD!,config)} · ${coverage(quality.pricedCount,quality.unpricedCount,config)}${note}`; + }, + }; + const fx = currency.converted ? ` · FX ${currency.referenceDate}` : ''; + const svg = renderHeatmapSvg(usage, options).replace(/^(]*>)/, opening => + `${opening}\n${escapeSvgText(`${title}. ${summary}. ${subtitle}. ${footer}${fx}. ${copy.missingLegend}`)}`); + return { svg, summary }; +} + +function composeAmountBlocks(blocks: readonly { svg: string; summary: string }[], title: string): string { + const widths = blocks.map(block => Number(/width="([\d.]+)"/.exec(block.svg)![1])); + const heights = blocks.map(block => Number(/height="([\d.]+)"/.exec(block.svg)![1])); + const width = Math.max(...widths); + const gap = 12; + const height = heights.reduce((sum, item) => sum + item, 0) + gap * (blocks.length - 1); + const summary = blocks.map(block => block.summary).join('; '); + const heading = title ? `${title}. ${summary}` : summary; + const parts = [ + ``, + `${escapeSvgText(heading)}`, + ]; + let offset = 0; + for (let index = 0; index < blocks.length; index++) { + parts.push(blocks[index].svg.replace(/^'); + return parts.join('\n'); +} + +/** Stateless canonical artifact; callers use this exact svg for preview/export. + * The cache identity is internal. Caller retires old preview handles on config + * or producer revision changes, and validates identity again before saving. + */ +export function buildApiHeatmapArtifact(daily: ApiHeatmapDaily, options: ApiHeatmapOptions): ApiHeatmapArtifact { + const config = normalizeApiHeatmapConfig(options); + const providers: Provider[] = config.provider === 'both' ? ['claude', 'codex'] : [config.provider]; + const selections = providers.map(provider => ({ provider, days: selectDays(daily, config, provider) })); + const copy = API_HEATMAP_COPY[config.locale]; + let svg: string; + let summary: string; + if (config.metric === 'apiEquivalent') { + const blocks = selections.map(({ provider, days }) => renderAmountBlock(days, provider, config)); + summary = blocks.map(block => block.summary).join('; '); + svg = blocks.length === 2 ? composeAmountBlocks(blocks, config.title) + : blocks[0].svg.replace(/^ + `${provider === 'claude' ? 'Claude' : 'Codex'} ${compact(totalTokens(days), config)} ${copy.tokens}`, + ).join(' · '); + if (config.provider === 'both') { + const combined: Record = {}; + for (let index = 0; index < selections[0].days.length; index++) { + const claude = selections[0].days[index]; + const codex = selections[1].days[index]; + // Omit missing zero days exactly as the existing producer does. + if (claude.tokens === 0 && codex.tokens === 0) continue; + combined[claude.dateISO] = { + dateISO: claude.dateISO, claudeProcessed: claude.tokens, codexProcessed: codex.tokens, + combinedProcessed: addTotal(claude.tokens, codex.tokens), + }; + } + // Exact established combined Token renderer output, including palette. + addTotal(totalTokens(selections[0].days), totalTokens(selections[1].days)); + svg = renderCombinedHeatmapSvg(combined, { + range: config.range, endDateISO: config.endDateISO, locale: config.locale, + title: config.title || undefined, palette: config.palette, + customAccent: config.customAccent, intensityMode: config.intensityMode, + }); + } else { + const usage: Record = {}; + for (const day of selections[0].days) usage[day.dateISO] = { tokens: day.tokens, cost: 0, sessions: 0 }; + svg = renderHeatmapSvg(usage, { + startDateISO: config.startDateISO, endDateISO: config.endDateISO, locale: config.locale, + minWidth: 720, title: config.title || summary, + subtitle: config.title ? summary : undefined, + ariaLabel: config.title ? `${config.title}. ${summary}` : summary, + scale: activityScale(config), intensityMode: config.intensityMode, + tooltip: (date: string) => `${date} · ${config.provider === 'claude' ? 'Claude' : 'Codex'} ${compact(usage[date].tokens, config)} ${copy.tokens}`, + }); + } + } + const currency = resolveCurrencyDisplay(config.currency); + // Only our explicit projection is hashed, never a raw object or private field. + const signature = JSON.stringify([ + API_HEATMAP_RENDER_VERSION, config, currency.referenceDate, currency.unitsPerUsd, + selections.map(({ provider, days }) => [provider, days.map(day => + config.metric === 'tokens' ? [day.dateISO, day.tokens] + : [day.dateISO, day.tokens, day.pricing!.status, day.pricing!.amountUSD, + day.pricing!.estimated,day.pricing!.pricedCount,day.pricing!.unpricedCount,day.pricing!.sourceComplete], + )]), + ]); + const dataQualitySignature = createHash('sha256').update(signature).digest('hex'); + const cacheKey = JSON.stringify([API_HEATMAP_RENDER_VERSION, config, CURRENCY_REFERENCE_DATE, currency.unitsPerUsd, dataQualitySignature]); + const filename = `api-heatmap-${config.provider}-${config.metric}-${config.range}-${config.endDateISO}.svg`; + const alt = config.title ? `${config.title} · ${summary}` : summary; + const markdown = `![${alt.replace(/[\r\n\[\]]/g, ' ')}](${filename})`; + return { config, svg, summary, filename, markdown, cacheKey, dataQualitySignature }; +} + +export function renderApiHeatmapSvg(daily: ApiHeatmapDaily, options: ApiHeatmapOptions): string { + return buildApiHeatmapArtifact(daily, options).svg; +} diff --git a/src/apiHeatmapCopy.ts b/src/apiHeatmapCopy.ts new file mode 100644 index 00000000..27e66ab2 --- /dev/null +++ b/src/apiHeatmapCopy.ts @@ -0,0 +1,39 @@ +import { SupportedLanguage } from './types'; + +export interface ApiHeatmapCopy { + sourceIncomplete: string; + apiEquivalent: string; + tokens: string; + estimated: string; + exact: string; + currentPricing: string; + noData: string; + unpriced: string; + subtotal: string; + coverage: string; + notBill: string; + missingLegend: string; +} + +/** Compact artifact copy only; integration UI remains owned by I18n. */ +const baseCopies: Record> = { + en: { apiEquivalent: 'API equivalent', tokens: 'Processed tokens', estimated: 'Estimated', exact: 'Exact model pricing', currentPricing: 'Current pricing', noData: 'No data', unpriced: 'Unpriced', subtotal: 'Priced subtotal', coverage: 'Coverage', notBill: 'Reference estimate; not a bill or subscription usage.', missingLegend: 'Gray: unpriced; outline: no data; dashed: partial pricing.' }, + 'de-DE': { apiEquivalent: 'API-Äquivalent', tokens: 'Verarbeitete Token', estimated: 'Geschätzt', exact: 'Exakte Modellpreise', currentPricing: 'Aktuelle Preise', noData: 'Keine Daten', unpriced: 'Ohne Preis', subtotal: 'Bepreiste Teilsumme', coverage: 'Abdeckung', notBill: 'Referenzschätzung; keine Rechnung oder Abonutzung.', missingLegend: 'Grau: ohne Preis; Umriss: keine Daten; gestrichelt: Teilpreise.' }, + 'zh-TW': { apiEquivalent: 'API 等價金額', tokens: '已處理 Token', estimated: '估算', exact: '精確型號定價', currentPricing: '目前定價', noData: '無資料', unpriced: '未定價', subtotal: '已定價小計', coverage: '覆蓋', notBill: '參考估值;不是帳單或訂閱用量。', missingLegend: '灰色:未定價;空框:無資料;虛線:部分定價。' }, + 'zh-CN': { apiEquivalent: 'API 等价金额', tokens: '已处理 Token', estimated: '估算', exact: '精确型号定价', currentPricing: '当前定价', noData: '无数据', unpriced: '未定价', subtotal: '已定价小计', coverage: '覆盖', notBill: '参考估值;不是账单或订阅用量。', missingLegend: '灰色:未定价;空框:无数据;虚线:部分定价。' }, + ja: { apiEquivalent: 'API 相当額', tokens: '処理済みトークン', estimated: '推定', exact: '正確なモデル料金', currentPricing: '現在の料金', noData: 'データなし', unpriced: '料金不明', subtotal: '料金算出済み小計', coverage: '算出範囲', notBill: '参考推定額。請求額やサブスク使用量ではありません。', missingLegend: '灰色:料金不明、枠のみ:データなし、破線:一部算出。' }, + ko: { apiEquivalent: 'API 환산 금액', tokens: '처리된 토큰', estimated: '추정', exact: '정확한 모델 요금', currentPricing: '현재 요금', noData: '데이터 없음', unpriced: '요금 미확인', subtotal: '요금 확인 소계', coverage: '확인 범위', notBill: '참고 추정치이며 청구액이나 구독 사용량이 아닙니다.', missingLegend: '회색: 요금 미확인, 테두리: 데이터 없음, 점선: 일부 확인.' }, + 'pt-BR': { apiEquivalent: 'Equivalente de API', tokens: 'Tokens processados', estimated: 'Estimado', exact: 'Preços exatos por modelo', currentPricing: 'Preços atuais', noData: 'Sem dados', unpriced: 'Sem preço', subtotal: 'Subtotal precificado', coverage: 'Cobertura', notBill: 'Estimativa de referência; não é fatura nem uso da assinatura.', missingLegend: 'Cinza: sem preço; contorno: sem dados; tracejado: preço parcial.' }, + id: { apiEquivalent: 'Setara API', tokens: 'Token diproses', estimated: 'Perkiraan', exact: 'Harga model tepat', currentPricing: 'Harga saat ini', noData: 'Tidak ada data', unpriced: 'Tanpa harga', subtotal: 'Subtotal berharga', coverage: 'Cakupan', notBill: 'Perkiraan referensi; bukan tagihan atau pemakaian langganan.', missingLegend: 'Abu-abu: tanpa harga; garis tepi: tanpa data; putus-putus: sebagian.' }, +}; +const incompleteCopies:Record={ + en:'Indexed subtotal; source history incomplete', + 'de-DE':'Indizierte Teilsumme; Quellhistorie unvollständig', + 'zh-TW':'已索引小計;來源歷史尚未完整', 'zh-CN':'已索引小计;来源历史尚未完整', + ja:'索引済み小計。元の履歴は未完了', ko:'색인된 소계이며 원본 이력이 불완전합니다', + 'pt-BR':'Subtotal indexado; histórico da origem incompleto', + id:'Subtotal terindeks; riwayat sumber belum lengkap', +}; +export const API_HEATMAP_COPY:Record = Object.fromEntries( + Object.entries(baseCopies).map(([locale,copy])=>[locale,{...copy,sourceIncomplete:incompleteCopies[locale as SupportedLanguage]}]), +) as Record; diff --git a/src/apiHeatmapDaily.ts b/src/apiHeatmapDaily.ts new file mode 100644 index 00000000..02dc0d42 --- /dev/null +++ b/src/apiHeatmapDaily.ts @@ -0,0 +1,233 @@ +import type { ApiHeatmapConfig, ApiHeatmapDaily, ApiHeatmapDailyUsage, ApiHeatmapMetric, ApiHeatmapProvider } from './apiHeatmap'; +import { addCalendarDays } from './combinedHeatmap'; +import type { CodexDailyUsageView } from './providers/codex/codexUsage'; +import type { UsageData } from './types'; +import {getCurrentEstimatePricing} from './pricing'; +import type {ModelPricing} from './types'; + +export interface ApiHeatmapDailyRange { + startDateISO: string; + endDateISO: string; + /** Observed/configured zone of the already-bucketed input, supplied by caller. */ + timezone: string; + provider?: ApiHeatmapProvider; + metric?: ApiHeatmapMetric; + showAmounts?: boolean; + hideAmounts?: boolean; +} + +export type ClaudeApiHeatmapDailyRow = { readonly date: string; readonly data: UsageData }; + +type Provider = 'claude' | 'codex'; +type Components = readonly [number, number, number, number]; + +function validTokens(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function validMoney(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER; +} + +function tokenSum(values: readonly number[]): number { + let total = 0; + for (const value of values) { + if (!validTokens(value) || !validTokens(total + value)) throw new Error('Invalid heatmap daily token aggregate'); + total += value; + } + return total; +} + +function empty(provider: Provider): ApiHeatmapDailyUsage { + return { tokens: 0, amountUSD: null, pricingStatus: 'empty', estimated: provider === 'claude', pricedCount: 0, unpricedCount: 0 }; +} + +function unpriced(tokens: number, provider: Provider, denominator = tokens): ApiHeatmapDailyUsage { + return { tokens, amountUSD: null, pricingStatus: 'unpriced', estimated: provider === 'claude', pricedCount: 0, unpricedCount: denominator }; +} + +function identifiableModel(name: string): boolean { + return name.trim().length > 0 && name.length <= 256 && !/[\x00-\x1f\x7f]/.test(name) && + name !== '' && name !== 'unknown' && name !== '' && + !Object.prototype.hasOwnProperty.call(Object.prototype, name); +} + +function claudeDay(data: UsageData, amounts: boolean, + pricingFor: (model: string) => ModelPricing | null): ApiHeatmapDailyUsage { + const components: Components = [data.totalInputTokens, data.totalOutputTokens, + data.totalCacheCreationTokens, data.totalCacheReadTokens]; + const tokens = tokenSum(components); + if (!amounts) return tokens === 0 ? empty('claude') : unpriced(tokens, 'claude'); + const currentAmount = data.totalCost; + if (!validMoney(currentAmount)) return unpriced(tokens, 'claude'); + // A present, explicitly priced zero row differs from an absent calendar day. + if (tokens === 0) return currentAmount === 0 + ? { ...empty('claude'), amountUSD: 0, pricingStatus: 'complete' } + : unpriced(tokens, 'claude'); + const models = data.modelBreakdown; + if (!models || typeof models !== 'object') return unpriced(tokens, 'claude'); + let pricedTokens = 0; + let subtotal = 0; + const attributed = [0, 0, 0, 0]; + // Sort model keys so subtotal rounding and duplicate comparison do not depend + // on object insertion order. Only existing model scalar estimates are read; + // Only bounded in-memory rate admission, no repricing, TTL evidence, + // achievement book, network or record inspection. Missing rates are not zero. + for (const name of Object.keys(models).sort()) { + const part = models[name]; + if (!part || typeof part !== 'object') return unpriced(tokens, 'claude'); + const values: Components = [part.inputTokens, part.outputTokens, part.cacheCreationTokens, part.cacheReadTokens]; + if (!values.every(validTokens)) return unpriced(tokens, 'claude'); + for (let index = 0; index < values.length; index++) { + const next = attributed[index] + values[index]; + if (!validTokens(next) || next > components[index]) return unpriced(tokens, 'claude'); + attributed[index] = next; + } + if (!identifiableModel(name)) continue; + const count = part.count; + const cost = part.cost; + if (!validTokens(count) || count === 0 || !validMoney(cost)) continue; + const partTokens = tokenSum(values); + if (partTokens === 0) continue; + const pricing = pricingFor(name); + if (!pricing) continue; + const rates = [pricing.input_cost_per_token, pricing.output_cost_per_token, + pricing.cache_creation_input_token_cost, pricing.cache_read_input_token_cost]; + const knownTokens = tokenSum(values.map((value,index) => + typeof rates[index] === 'number' && Number.isFinite(rates[index]) && rates[index]! >= 0 ? value : 0)); + if (knownTokens === 0) continue; + pricedTokens = tokenSum([pricedTokens, knownTokens]); + if (!validMoney(subtotal + cost)) return unpriced(tokens, 'claude'); + subtotal += cost; + } + if (pricedTokens === 0) return unpriced(tokens, 'claude'); + const unpricedTokens = tokens - pricedTokens; + return { + tokens, amountUSD: unpricedTokens > 0 ? subtotal : currentAmount, + pricingStatus: unpricedTokens > 0 ? 'partial' : 'complete', estimated: true, + pricedCount: pricedTokens, unpricedCount: unpricedTokens, + }; +} + +function codexDay(row: CodexDailyUsageView, amounts: boolean): ApiHeatmapDailyUsage { + const tokens = tokenSum([row.total.processed]); + if (!amounts) return tokens === 0 ? empty('codex') : unpriced(tokens, 'codex'); + const pricing = row.apiEquivalent; + if (!pricing || typeof pricing !== 'object') return unpriced(tokens, 'codex'); + const amount = pricing.equivalentUsd; + const priced = pricing.pricedTokens; + const denominator = pricing.totalTokens; + if (!validMoney(amount) || !validTokens(priced) || !validTokens(denominator) || priced > denominator || + (tokens > 0 && (denominator === 0 || priced === 0))) { + return unpriced(tokens, 'codex', validTokens(denominator) && denominator > 0 ? denominator : tokens); + } + if (priced === 0 && (denominator > 0 || amount > 0)) return unpriced(tokens, 'codex', denominator); + return { + tokens, amountUSD: amount, pricingStatus: priced < denominator ? 'partial' : 'complete', estimated: false, + pricedCount: priced, unpricedCount: denominator - priced, + }; +} + +function calendarKey(value: string): string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error('Invalid heatmap daily date'); + return addCalendarDays(value, 0); +} + +/** Read date keys to locate the selected interval in an existing descending + * daily array. Only matching payloads are projected: O(log history + rows in + * range), with no copy/sort/iteration of the full array or source records. */ +function selectedDays( + rows: readonly Row[], key: (row: Row) => string, + project: (row: Row) => ApiHeatmapDailyUsage, + start: string, end: string, provider: Provider, +): Map { + let low = 0; + let high = rows.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (calendarKey(key(rows[middle])) > end) low = middle + 1; + else high = middle; + } + const result = new Map(); + const conflicts = new Set(); + let previousDate = end; + for (let index = low; index < rows.length; index++) { + const row = rows[index]; + const date = calendarKey(key(row)); + if (date < start) break; + if (date > previousDate) throw new Error('Heatmap daily rows must be newest first'); + previousDate = date; + const candidate = project(row); + const previous = result.get(date); + // Both objects are explicit scalar DTOs; private source fields never enter + // duplicate identity. Hidden mode compares only token-derived scalars. + if (previous && (conflicts.has(date) || JSON.stringify(previous) !== JSON.stringify(candidate))) { + conflicts.add(date); + // A known conflicting day is unavailable pricing, not absent activity. + // Max retains a valid observed bound without adding duplicate totals. + // Keep examining later rows so the bound is independent of row order; + // once conflicted, no later duplicate can restore a monetary amount. + const tokens = Math.max(previous.tokens, candidate.tokens); + const denominator = Math.max(tokens, + tokenSum([previous.pricedCount, previous.unpricedCount]), + tokenSum([candidate.pricedCount, candidate.unpricedCount])); + result.set(date, unpriced(tokens, provider, denominator)); + } else { + result.set(date, candidate); + } + } + return result; +} + +/** Pure projection of current daily aggregates into at most 366 civil dates. + * Both producer arrays must be newest first, as their materialized views are. + * Caller supplies canonical bounds and the observed zone used to bucket them; + * this function never re-buckets a day or resolves an implicit current date. + * Conflicting duplicates withhold money as unpriced and retain the maximum + * observed token/coverage bound; duplicate totals are never summed. + */ +export function buildApiHeatmapDaily( + claudeDaily: readonly ClaudeApiHeatmapDailyRow[], + codexDaily: readonly CodexDailyUsageView[], + options: ApiHeatmapDailyRange | ApiHeatmapConfig, + pricingResolver: (model: string) => ModelPricing | null = getCurrentEstimatePricing, +): ApiHeatmapDaily { + const config: ApiHeatmapDailyRange = options; + const start = calendarKey(config.startDateISO); + const end = calendarKey(config.endDateISO); + const daySpan = (Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000; + if (end < start || daySpan > 365) throw new Error('Heatmap daily range must contain 1–366 days'); + if (typeof config.timezone !== 'string' || !config.timezone.trim()) throw new Error('Heatmap daily zone must be supplied'); + // Validate only the caller's observed zone. There is no system-zone fallback. + new Intl.DateTimeFormat('en', { timeZone: config.timezone }); + const provider = config.provider === 'claude' || config.provider === 'codex' ? config.provider : 'both'; + const amounts = config.hideAmounts !== true && config.metric === 'apiEquivalent' && config.showAmounts === true; + const prices = new Map(); + const pricingFor = (model: string): ModelPricing | null => { + if (!prices.has(model)) prices.set(model, pricingResolver(model)); + return prices.get(model)!; + }; + const claude = provider === 'codex' ? undefined : selectedDays(claudeDaily, row => row.date, + row => claudeDay(row.data, amounts, pricingFor), start, end, 'claude'); + const codex = provider === 'claude' ? undefined : selectedDays(codexDaily, row => row.day, + row => codexDay(row, amounts), start, end, 'codex'); + const output: Record = {}; + const totals = { claude: { tokens: 0, amount: 0 }, codex: { tokens: 0, amount: 0 } }; + for (let date = start; ; date = addCalendarDays(date, 1)) { + const entry: { claude?: ApiHeatmapDailyUsage; codex?: ApiHeatmapDailyUsage } = {}; + for (const [name, source] of [['claude', claude], ['codex', codex]] as const) { + if (!source) continue; + const value = source.get(date) ?? empty(name); + totals[name].tokens = tokenSum([totals[name].tokens, value.tokens]); + if (value.amountUSD !== null) { + const amount = totals[name].amount + value.amountUSD; + if (!validMoney(amount)) throw new Error('Invalid heatmap daily amount aggregate'); + totals[name].amount = amount; + } + entry[name] = value; + } + output[date] = entry; + if (date === end) break; + } + return output; +} diff --git a/src/claudeAchievementMoneyProjection.ts b/src/claudeAchievementMoneyProjection.ts new file mode 100644 index 00000000..e119738c --- /dev/null +++ b/src/claudeAchievementMoneyProjection.ts @@ -0,0 +1,43 @@ +import type {ClaudeUsageIndex} from './claudeIncrementalIndex'; +import type {AchievementMoneyBackend} from './achievementMoney'; +import {claudeAchievementMoneyScopes,type AchievementMoneyScopes} from './achievementMoneyScopes'; +import {dayKeyInZone} from './dateKeys'; + +interface Cached { backend:AchievementMoneyBackend; day:string; timeZone:string; + allTime:ClaudeUsageIndex['aggregates']['allTime']; days:ClaudeUsageIndex['aggregates']['byLocalDay']; + sessions:ClaudeUsageIndex['aggregates']['bySession']; manifest:ClaudeUsageIndex['manifest']; value:AchievementMoneyScopes } +// One entry per committed immutable index, weakly held. Never copy a corpus, +// retain session identities in the DTO, or re-iterate warm aggregates. +const cache=new WeakMap(); +/** Called by the canonical reducer only after a successful commit with zero + * aggregate mutations. New Map wrappers alone are not new usage evidence. */ +export function inheritClaudeAchievementMoneyProjection(previous:ClaudeUsageIndex,next:ClaudeUsageIndex):void { + const old=cache.get(previous); + if (!old || next.timeZone!==previous.timeZone || next.manifest===null) return; + cache.set(next,{...old,allTime:next.aggregates.allTime,days:next.aggregates.byLocalDay, + sessions:next.aggregates.bySession,manifest:next.manifest}); +} +export function claudeAchievementMoneyProjection(index:ClaudeUsageIndex,backend:AchievementMoneyBackend,now:Date,enabled:boolean):AchievementMoneyScopes|undefined { + if (enabled!==true || index.manifest===null) return; + const day=dayKeyInZone(now,index.timeZone); + const old=cache.get(index); + if (old && old.backend===backend && old.day===day && old.timeZone===index.timeZone && + old.allTime===index.aggregates.allTime && old.days===index.aggregates.byLocalDay && + old.sessions===index.aggregates.bySession && old.manifest===index.manifest) return old.value; + function* days() { + for (const [date,data] of index.aggregates.byLocalDay) yield {date,models:data.modelBreakdown}; + } + function* sessions() { + for (const [key,data] of index.aggregates.bySession) { + // Missing session identity must not combine unrelated records into one + // apparently large single session. Cumulative evidence remains separate. + if (key && key!=='unknown') yield data.modelBreakdown; + } + } + const scopes=claudeAchievementMoneyScopes({backend,asOfDay:day,allTimeModels:index.aggregates.allTime.modelBreakdown, + days:{complete:true,rows:days()},allSessionModels:sessions(),sessionsComplete:true}); + const value=Object.freeze({...scopes,coverage:Object.freeze(scopes.coverage)}); + cache.set(index,{backend,day,timeZone:index.timeZone,allTime:index.aggregates.allTime,days:index.aggregates.byLocalDay, + sessions:index.aggregates.bySession,manifest:index.manifest,value}); + return value; +} diff --git a/src/claudeIncrementalIndex.ts b/src/claudeIncrementalIndex.ts index 1d34d48c..c0edcc93 100644 --- a/src/claudeIncrementalIndex.ts +++ b/src/claudeIncrementalIndex.ts @@ -15,9 +15,12 @@ import { analyzeLine, ClaudeDataLoader, compactUsageRecord, + tagClaudeSessionEvidence, + ClaudeSessionUsage, finalizeAnalysis, mergeAnalysisAcc, newAnalysisAcc, + recordTimestampBounds, validateUsageRecord, } from './dataLoader'; import { @@ -28,8 +31,14 @@ import { rollingDayKeys, } from './dateKeys'; import { I18n } from './i18n'; +import { claudeAchievementFamily, claudeSessionAchievementEvidence, ClaudeSessionAchievementEvidence } from './achievements'; import { detachedPromptPrefix, isRetryDuplicatePrompt } from './promptDedup'; import { ownRecordValue, setRecordValue } from './ownRecord'; +import { addClaudeReferenceUsage, claudeReferenceUsage } from './claudeReferenceUsage'; +import {claudeAchievementMoneyProjection,inheritClaudeAchievementMoneyProjection} from './claudeAchievementMoneyProjection'; +import { getAchievementMoneyPricebook, priceAchievementModelBucket, type AchievementMoneyBackend } from './achievementMoney'; +import { accumulateMoneyProof, type AchievementMoneyProofs } from './achievementMoneyOrigin'; +import type {AchievementMoneyScopes} from './achievementMoneyScopes'; import { buildProjectUsageMatrixSnapshot, ProjectUsageMatrixSnapshot, @@ -39,7 +48,9 @@ import { scanCodexJsonlLines, } from './providers/codex/codexJsonlScanner'; import { CodexRuntimeManifestEntry } from './providers/codex/codexManifest'; -import { LoadUsageDiagnostics } from './refreshDiagnostics'; +import { + ClaudeRefreshFailureReason, ClaudeRefreshStage, claudeRefreshFailureReason, LoadUsageDiagnostics, +} from './refreshDiagnostics'; import { BranchUsage, ClaudeUsageRecord, @@ -221,7 +232,7 @@ export interface ClaudeUsageIndex { visibleKeysByBranch: Map>; visibleKeysByWorkflow: Map>; visibleKeysByLocalDay: Map>; - sessionRows: Map; + sessionRows: Map; projectRows: Map; projectSessionIds: Map>; branchRows: Map; @@ -243,6 +254,7 @@ export interface ClaudeUsageIndex { export interface ClaudeUsageIndexDiagnostics extends LoadUsageDiagnostics { bodyReads: number; aggregateMutations: number; + failureReason?: ClaudeRefreshFailureReason; changed: { append: number; rebuild: number; @@ -256,7 +268,28 @@ export interface ClaudeUsageIndexUpdateOptions { windowDays?: number; manifest?: UsageManifest; beforeBodyReads?: () => Promise; + /** Runtime-only phase counters; no raw file/error metadata or per-row callback. */ + onStage?: (stage: ClaudeRefreshStage, counters?: Partial) => void; log?: (line: string) => void; + /** Accepted host baseline → current observation. Runtime-only, never persisted. + * A refresh trigger or file mtime alone cannot establish this window. */ + achievementUsageWindow?: { after: number; through: number }; +} + +/** Lower bound from newly accepted, deduplicated tail records only. Not an award + * or attainment date; the host must still prove the rule's counterfactual and + * source/clear generation before committing a grant. No source identities. */ +export interface ClaudeAchievementUsageDelta { + provider: 'claude'; + after: number; + through: number; + processed: number; + output: number; + cacheRead: number; + cacheCreation: number; + families: Record<'sonnet' | 'opus' | 'haiku' | 'fable', number>; + /** Frozen reference subsets only, runtime-only and cumulative-only. */ + money?: AchievementMoneyProofs; } export interface ClaudeUsageIndexUpdateResult { @@ -264,6 +297,7 @@ export interface ClaudeUsageIndexUpdateResult { records: ClaudeUsageRecord[]; contentAnalysis: ContentAnalysis | null; diagnostics: ClaudeUsageIndexDiagnostics; + achievementUsageDelta?: ClaudeAchievementUsageDelta; } export interface ClaudeUsageAggregateSnapshot { @@ -291,6 +325,9 @@ export interface ClaudeUsageDashboardSnapshot extends ClaudeUsageAggregateSnapsh session: SessionData | null; workspaceToday: UsageData | null; sessions: SessionUsage[]; + /** Counts from all materialized rows; no uncapped IDs or paths leave the host. */ + achievementSessionEvidence: ClaudeSessionAchievementEvidence; + achievementMoney?: AchievementMoneyScopes; projects: ProjectGroup[]; branches: BranchUsage[]; workflows: WorkflowUsage[]; @@ -339,6 +376,82 @@ interface ParsedPlan extends FilePlan { linesParsed: number; } +function acceptedTailUsageDelta( + previous: ClaudeUsageIndex, + next: ClaudeUsageIndex, + verifiedTails: ReadonlyMap, + affectedMessages: ReadonlySet, + affectedDirect: ReadonlySet, + window: ClaudeUsageIndexUpdateOptions['achievementUsageWindow'], + completeBaseline: boolean, +): ClaudeAchievementUsageDelta | undefined { + if (!window || !completeBaseline || previous.timeZone !== next.timeZone || + !Number.isSafeInteger(window.after) || !Number.isSafeInteger(window.through) || + window.after < 0 || window.through > 8.64e15 || window.through <= window.after) return undefined; + // Capture verified source appends before content-analysis plans may expand + // into a full parse. No second fingerprint/body read or raw candidate copy. + if (!verifiedTails.size) return undefined; + const delta: ClaudeAchievementUsageDelta = { + provider: 'claude', after: window.after, through: window.through, + processed: 0, output: 0, cacheRead: 0, cacheCreation: 0, + families: { sonnet: 0, opus: 0, haiku: 0, fable: 0 }, + }; + let overflow = false; + const add = (identity: string): void => { + if (previous.canonicalByIdentity.has(identity)) return; + const winner = next.canonicalByIdentity.get(identity); + if (!winner) return; + const offset = verifiedTails.get(winner.fileId); + const file = next.files.get(winner.fileId); + if (offset === undefined || winner.endOffset <= offset || + !file || file.offset !== file.fingerprint.size) return; + const time = Date.parse(winner.record.timestamp); + if (!Number.isFinite(time) || time <= window.after || time > window.through) return; + const usage = winner.record.message.usage; + const parts = [usage.input_tokens, usage.output_tokens, + usage.cache_creation_input_tokens ?? 0, usage.cache_read_input_tokens ?? 0]; + if (parts.some((n) => !Number.isSafeInteger(n) || n < 0)) return; + const processed = parts.reduce((sum, n) => sum + n, 0); + if (!Number.isSafeInteger(processed) || processed <= 0) return; + const family = claudeAchievementFamily(winner.record.message.model ?? ''); + const sum = delta.processed + processed; + if (!Number.isSafeInteger(sum)) { overflow = true; return; } + delta.processed = sum; + delta.output += parts[1]; + delta.cacheCreation += parts[2]; + delta.cacheRead += parts[3]; + if (family) delta.families[family] += processed; + // This loop already admits only additional response identities. Use the + // same TTL-complete shadow semantics as materialized historical evidence; + // counter rewrites, missing TTL and mutable dashboard prices are excluded. + if (claudeReferenceUsage(usage)) { + for (const backend of ['anthropic', 'aws-bedrock-in-region'] as const) { + const priced = priceAchievementModelBucket({ provider: 'claude', model: winner.record.message.model, usage }, backend); + if (priced.reason !== null || priced.microUsd === null || priced.pricedTokens === 0) continue; + const proof = accumulateMoneyProof(delta.money?.[backend], { + provider: 'claude', backend, pricebookVersion: getAchievementMoneyPricebook(backend).version, + exactMicroUsdUnits: priced.exactMicroUsdUnits, microUsd: priced.microUsd, eligibleProcessed: priced.pricedTokens, + }); + if (proof) (delta.money ??= {})[backend] = proof; + else if (delta.money) delete delta.money[backend]; + } + } + }; + for (const message of affectedMessages) { + // Request-less identities may be re-keyed when a later request ID appears. + // Preserve every prior winner before classifying an additional one as new. + let stable = true; + for (const identity of previous.canonicalIdentitiesByMessage.get(message) ?? []) { + if (previous.canonicalByIdentity.get(identity)?.record !== + next.canonicalByIdentity.get(identity)?.record) { stable = false; break; } + } + if (stable) for (const identity of next.canonicalIdentitiesByMessage.get(message) ?? []) add(identity); + } + for (const identity of affectedDirect) add(identity); + if (delta.money && !Object.keys(delta.money).length) delete delta.money; + return !overflow && delta.processed > 0 ? delta : undefined; +} + function emptyUsageData(): UsageData { return { totalInputTokens: 0, @@ -1173,7 +1286,9 @@ function refreshedStructuralRuntime( const events = (state.toolEventsById.get(toolId) ?? []) .filter((event) => !changedFileIds.has(event.fileId)) .map((event) => ({ ...event, value: { ...event.value } } as TaggedAnalysisToolEvent)); - events.push(...(appendedEventsById.get(toolId) ?? [])); + // One tool ID can have arbitrarily many result envelopes. Passing them + // as function arguments hits V8's stack ceiling before analysis completes. + for (const event of appendedEventsById.get(toolId) ?? []) events.push(event); events.sort(compare); const nextBuckets = resolveToolBuckets(events); addBucketDelta( @@ -1485,6 +1600,7 @@ async function parsePlan( _sessionId: sessionInfo.sessionId, _projectDirEncoded: sessionInfo.projectPath, }; + tagClaudeSessionEvidence(record, plan.entry.path); const cwd = parsed.cwd; if (typeof cwd === 'string' && cwd.trim()) { record._projectPath = cwd; @@ -1511,6 +1627,7 @@ async function parsePlan( if (!validateUsageRecord(parsed)) return; const record = compactUsageRecord(parsed as unknown as ClaudeUsageRecord); record._sessionId = sessionInfo.sessionId; + tagClaudeSessionEvidence(record, plan.entry.path); record._projectDirEncoded = sessionInfo.projectPath; const cwd = parsed.cwd; if (typeof cwd === 'string' && cwd.trim()) { @@ -1602,7 +1719,10 @@ function cloneUsageData(value: UsageData): UsageData { ...value, costBreakdown: { ...value.costBreakdown }, modelBreakdown: Object.fromEntries( - Object.entries(value.modelBreakdown).map(([key, model]) => [key, { ...model }]), + Object.entries(value.modelBreakdown).map(([key, model]) => [key, { + ...model, + ...(model.referenceUsage ? { referenceUsage: { ...model.referenceUsage } } : {}), + }]), ), }; } @@ -1646,6 +1766,11 @@ function addUsageData(target: UsageData, source: UsageData, sign: 1 | -1): void current.cacheReadTokens += sign * part.cacheReadTokens; current.cost += sign * part.cost; current.count += sign * part.count; + if (part.referenceUsage) { + const reference = addClaudeReferenceUsage(current.referenceUsage, part.referenceUsage, sign); + if (reference) current.referenceUsage = reference; + else delete current.referenceUsage; + } if (current.count === 0 && current.inputTokens === 0 && current.outputTokens === 0 && current.cacheCreationTokens === 0 && current.cacheReadTokens === 0) { delete target.modelBreakdown[model]; @@ -1964,6 +2089,7 @@ function retagMovedFile( const retag = (value: IndexedRecord): IndexedRecord => { const record = { ...value.record, message: value.record.message }; record._sessionId = newInfo.sessionId; + tagClaudeSessionEvidence(record, entry.path); record._projectDirEncoded = newInfo.projectPath; if (record._projectPath === oldInfo.projectPath) { record._projectPath = newInfo.projectPath; @@ -2144,7 +2270,7 @@ function recomputeDerivedRows(index: ClaudeUsageIndex): void { index.projectSessionIds.delete(projectKey); continue; } - const timestamps = records.map((record) => Date.parse(record.timestamp)).filter(Number.isFinite); + const timestamps = recordTimestampBounds(records); const first = records[0]; const sessions = new Set(records.map((record) => record._sessionId || 'unknown')); index.projectSessionIds.set(projectKey, sessions); @@ -2152,8 +2278,8 @@ function recomputeDerivedRows(index: ClaudeUsageIndex): void { projectName: first._projectName || 'unknown', projectPath: first._projectPath || first._projectName || 'unknown', sessionCount: sessions.size, - firstSeen: timestamps.length > 0 ? new Date(Math.min(...timestamps)) : new Date(0), - lastSeen: timestamps.length > 0 ? new Date(Math.max(...timestamps)) : new Date(0), + firstSeen: new Date(timestamps.first), + lastSeen: new Date(timestamps.last), data: ClaudeDataLoader.calculateUsageData(records), }); } @@ -2415,12 +2541,17 @@ export function claudeUsageDashboardSnapshot( projectGroupingMode?: 'git' | 'folder' | 'flat'; contextWindowOverride?: number; adviceWindowDays?: number; + achievementMoneyBackend?: AchievementMoneyBackend; now?: Date; } = {}, ): ClaudeUsageDashboardSnapshot { const now = options.now ?? new Date(); const aggregates = claudeUsageAggregateSnapshot(index, now); - const sessions = [...index.sessionRows.values()] + const achievementSessions = [...index.sessionRows.values()]; + const achievementSessionEvidence = claudeSessionAchievementEvidence( + achievementSessions.filter((row) => !row._unownedAgentSession), + ); + const sessions = [...achievementSessions] .sort((left, right) => right.endTime.getTime() - left.endTime.getTime()) .slice(0, 1000); const scopedSessions = options.workspacePath @@ -2497,6 +2628,9 @@ export function claudeUsageDashboardSnapshot( session, workspaceToday, sessions, + achievementSessionEvidence, + ...(options.achievementMoneyBackend ? {achievementMoney:claudeAchievementMoneyProjection( + index,options.achievementMoneyBackend,now,true)} : {}), projects: buildProjectGroups(index, options.projectGroupingMode ?? 'git'), branches: [...index.branchRows.values()] .sort((left, right) => right.data.totalCost - left.data.totalCost) @@ -2517,6 +2651,9 @@ export async function updateClaudeUsageIndex( options: ClaudeUsageIndexUpdateOptions = {}, ): Promise { const started = performance.now(); + const achievementUsageWindow = options.achievementUsageWindow ? { + after: options.achievementUsageWindow.after, through: options.achievementUsageWindow.through, + } : undefined; const configuredTimeZone = resolveTimeZone(I18n.getTimezone()); const timeKeyers = createConfiguredTimeKeyers(configuredTimeZone); const timeZoneChanged = previous.timeZone !== configuredTimeZone; @@ -2530,10 +2667,24 @@ export async function updateClaudeUsageIndex( // established content-analysis window. const analysisCutoffMs = analysisWindowCutoffMs(nowMs, windowDays); const manifest = options.manifest ?? await scanUsageManifest([root]); + options.onStage?.('planning', { filesDiscovered: manifest.entries.size }); const currentEntries = stableDiscoveryRanks(previous, [...manifest.entries.values()]); - const previousByPath = new Map([...previous.files.values()].map((file) => [file.path, file])); + const previousByPath = new Map(); + let completeUsageBaseline = previous.manifest !== null && + previous.files.size === previous.manifest.entries.size; + for (const file of previous.files.values()) { + previousByPath.set(file.path, file); + if (file.offset !== file.fingerprint.size) completeUsageBaseline = false; + } const seenPrevious = new Set(); const plans: FilePlan[] = []; + const verifiedUsageTails = new Map(); + const retainVerifiedTail = (file: ClaudeUsageFileContribution, entry: UsageFileFingerprint): void => { + if (achievementUsageWindow && completeUsageBaseline && + file.path === entry.path && file.offset === file.fingerprint.size) { + verifiedUsageTails.set(file.fileId, file.offset); + } + }; const analysisRebases = new Map(); const analysisPayloadRebases = new Set(); const moves: Array<{ prior: ClaudeUsageFileContribution; entry: UsageFileFingerprint }> = []; @@ -2590,6 +2741,7 @@ export async function updateClaudeUsageIndex( entry.size > sameIdentity.fingerprint.size && await appendPrefixStillMatches(sameIdentity, entry) && await appendBoundaryStillMatches(sameIdentity, entry); + if (grewOnly) retainVerifiedTail(sameIdentity, entry); analysisRebases.delete(fileId); analysisPayloadRebases.delete(fileId); plans.push({ @@ -2607,6 +2759,7 @@ export async function updateClaudeUsageIndex( const append = entry.size > sameIdentity.fingerprint.size && await appendPrefixStillMatches(sameIdentity, entry) && await appendBoundaryStillMatches(sameIdentity, entry); + if (append) retainVerifiedTail(sameIdentity, entry); const rebaseChangedPayload = Boolean( rebasedAnalysis && !sameAnalysisPayload(sameIdentity, rebasedAnalysis), ); @@ -2794,6 +2947,7 @@ export async function updateClaudeUsageIndex( (plan.kind === 'rebuild' && !plan.prior && !plan.replacedFileId)), ); + options.onStage?.('read-parse'); await options.beforeBodyReads?.(); const parsedPlans: ParsedPlan[] = []; let bytesRead = 0; @@ -2922,7 +3076,11 @@ export async function updateClaudeUsageIndex( } } } catch (error) { - options.log?.(`incremental loader retained the previous snapshot: ${error instanceof Error ? error.name : 'read-error'}`); + const failureReason = claudeRefreshFailureReason(error); + options.onStage?.('read-parse', { + filesFailed: 1, bytesRead, linesParsed, bodyReads: bodyReads + 1, + readParseMs: performance.now() - started, + }); return { index: previous, records: recordsOf(previous), @@ -2930,6 +3088,7 @@ export async function updateClaudeUsageIndex( diagnostics: { filesDiscovered: manifest.entries.size, filesFailed: 1, + failureReason, bytesRead, linesParsed, readParseMs: performance.now() - started, @@ -2940,6 +3099,10 @@ export async function updateClaudeUsageIndex( }; } + options.onStage?.('aggregate', { + filesFailed: 0, bytesRead, linesParsed, bodyReads, + readParseMs: performance.now() - started, + }); const next = cloneIndexForCommit(previous); next.timeZone = configuredTimeZone; next.timeKeyers = timeKeyers; @@ -3027,11 +3190,14 @@ export async function updateClaudeUsageIndex( } for (const messageId of affectedMessages) aggregateMutations += recomputeMessage(next, messageId); for (const identity of affectedDirect) aggregateMutations += recomputeDirectIdentity(next, identity); + const achievementUsageDelta = acceptedTailUsageDelta(previous, next, verifiedUsageTails, + affectedMessages, affectedDirect, achievementUsageWindow, completeUsageBaseline); for (const sessionId of affectedSessions) { refreshSessionTitle(next, sessionId); next.dirtyGroups.sessions.add(sessionId); } recomputeDerivedRows(next); + options.onStage?.('content-analysis', { aggregateMutations }); if (!analyzeContent) { next.contentAnalysis = null; @@ -3162,10 +3328,12 @@ export async function updateClaudeUsageIndex( `incremental loader: bodies=${bodyReads}, bytes=${bytesRead}, ` + `lines=${linesParsed}, aggregate-mutations=${aggregateMutations}`, ); + if (aggregateMutations===0 && !timeZoneChanged) inheritClaudeAchievementMoneyProjection(previous,next); return { index: next, records: recordsOf(next), contentAnalysis: next.contentAnalysis, + ...(achievementUsageDelta ? { achievementUsageDelta } : {}), diagnostics: { filesDiscovered: manifest.entries.size, filesFailed: 0, diff --git a/src/claudeReferenceUsage.ts b/src/claudeReferenceUsage.ts new file mode 100644 index 00000000..a63be6fe --- /dev/null +++ b/src/claudeReferenceUsage.ts @@ -0,0 +1,81 @@ +import { ClaudeReferenceUsage, ClaudeUsageRecord } from './types'; + +function safeCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +/** Shared with compaction so unsupported TTL shapes cannot become apparently + * complete after unknown fields are dropped from the retained record. */ +export function hasKnownClaudeCacheCreationShape( + value: unknown, +): value is NonNullable { + return value !== null && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).every(key => + key === 'ephemeral_5m_input_tokens' || key === 'ephemeral_1h_input_tokens'); +} + +/** Fill only a missing flat count from an explicitly complete native TTL split. + * Never change supplied flat evidence or infer an absent TTL count. Keep the + * source immutable; all other shapes retain their existing primary semantics. */ +export function normalizeClaudeNativeUsage( + usage: ClaudeUsageRecord['message']['usage'], +): ClaudeUsageRecord['message']['usage'] { + if (usage.cache_creation_input_tokens !== undefined) return usage; + const split = usage.cache_creation; + if (!hasKnownClaudeCacheCreationShape(split) || + !safeCount(split.ephemeral_5m_input_tokens) || !safeCount(split.ephemeral_1h_input_tokens)) return usage; + const creation = split.ephemeral_5m_input_tokens + split.ephemeral_1h_input_tokens; + return Number.isSafeInteger(creation) ? { ...usage, cache_creation_input_tokens: creation } : usage; +} + +/** Called only inside the existing accepted/canonical record reducer. Never + * infer TTL from prices, model names, or the primary cost fallback. */ +export function claudeReferenceUsage( + usage: ClaudeUsageRecord['message']['usage'], +): ClaudeReferenceUsage | undefined { + usage = normalizeClaudeNativeUsage(usage); + const input = usage.input_tokens; + const output = usage.output_tokens; + const read = usage.cache_read_input_tokens === undefined ? 0 : usage.cache_read_input_tokens; + const creation = usage.cache_creation_input_tokens === undefined ? 0 : usage.cache_creation_input_tokens; + if (!safeCount(input) || !safeCount(output) || !safeCount(read) || !safeCount(creation)) return undefined; + + let fiveM = 0; + let oneH = 0; + const split = usage.cache_creation; + if (split === undefined) { + if (creation !== 0) return undefined; + } else { + if (!hasKnownClaudeCacheCreationShape(split)) return undefined; + // Missing counts can only be zero: equality to the safe flat total below + // proves that no positive residual has an unobserved TTL. + const fiveMValue = split.ephemeral_5m_input_tokens === undefined ? 0 : split.ephemeral_5m_input_tokens; + const oneHValue = split.ephemeral_1h_input_tokens === undefined ? 0 : split.ephemeral_1h_input_tokens; + if (!safeCount(fiveMValue) || !safeCount(oneHValue) || + !Number.isSafeInteger(fiveMValue + oneHValue) || fiveMValue + oneHValue !== creation) return undefined; + fiveM = fiveMValue; + oneH = oneHValue; + } + return { + inputTokens: input, outputTokens: output, cacheReadTokens: read, + cacheCreation5mTokens: fiveM, cacheCreation1hTokens: oneH, + }; +} + +/** Return an owned vector for ordinary aggregate add/subtract. An empty subset + * is absent, matching the full reducer after the last eligible record leaves. */ +export function addClaudeReferenceUsage( + current: ClaudeReferenceUsage | undefined, + contribution: ClaudeReferenceUsage, + sign: 1 | -1 = 1, +): ClaudeReferenceUsage | undefined { + const next = { + inputTokens: (current?.inputTokens ?? 0) + sign * contribution.inputTokens, + outputTokens: (current?.outputTokens ?? 0) + sign * contribution.outputTokens, + cacheReadTokens: (current?.cacheReadTokens ?? 0) + sign * contribution.cacheReadTokens, + cacheCreation5mTokens: (current?.cacheCreation5mTokens ?? 0) + sign * contribution.cacheCreation5mTokens, + cacheCreation1hTokens: (current?.cacheCreation1hTokens ?? 0) + sign * contribution.cacheCreation1hTokens, + }; + return next.inputTokens === 0 && next.outputTokens === 0 && next.cacheReadTokens === 0 && + next.cacheCreation5mTokens === 0 && next.cacheCreation1hTokens === 0 ? undefined : next; +} diff --git a/src/codexAchievementProof.ts b/src/codexAchievementProof.ts new file mode 100644 index 00000000..80a7c224 --- /dev/null +++ b/src/codexAchievementProof.ts @@ -0,0 +1,73 @@ +import type { CodexFileAggregate } from './providers/codex/codexIndex'; +import type { ProviderTokenCounts } from './providers/providerTypes'; + +const count = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const object = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +const fields = ['inputTotal', 'cachedInput', 'cacheWriteInput', 'outputTotal', 'reasoningOutput', 'sourceTotal'] as const; + +/** Counter subsets never become additional processed Tokens. */ +export function validAchievementTokenVector(value: unknown): value is ProviderTokenCounts { + return object(value) && count(value.inputTotal) && count(value.outputTotal) && + count(value.inputTotal + value.outputTotal) && + fields.every(field => value[field] === undefined || count(value[field])) && + (value.cachedInput === undefined || (value.cachedInput as number) <= value.inputTotal) && + (value.reasoningOutput === undefined || (value.reasoningOutput as number) <= value.outputTotal); +} + +export function validAchievementBuckets(value: unknown, total: ProviderTokenCounts): boolean { + if (!object(value) || !validAchievementTokenVector(total)) return false; + const sums: Partial> = {}; + for (const row of Object.values(value)) { + if (!validAchievementTokenVector(row)) return false; + for (const field of fields) { + const next = (sums[field] ?? 0) + (row[field] ?? 0); + if (!count(next) || (total[field] !== undefined && next > total[field]!)) return false; + sums[field] = next; + } + } + return true; +} + +/** + * Input is the host's canonical, already-reconciled snapshot. Census every + * identity before selecting: conflicting copies and all their descendants are + * withheld, but an unrelated sound lineage keeps its positive evidence. + * The caller still requires a complete current manifest/index: undiscovered + * files could otherwise invalidate identity uniqueness. No I/O or extra scan. + */ +export function verifiedCodexAchievementFiles(files: readonly CodexFileAggregate[]): CodexFileAggregate[] { + const bySession = new Map(); + const conflicting = new Set(); + for (const file of files) { + const key = file?.session?.sessionKey; + if (typeof key !== 'string' || !key.trim()) continue; + if (bySession.has(key)) conflicting.add(key); + bySession.set(key, file); + } + const sound = (file: CodexFileAggregate): boolean => { + const { parentSessionKey: parent, role } = file.session; + return !conflicting.has(file.session.sessionKey) && + ['root', 'subagent', 'approval-reviewer'].includes(role) && + (parent === undefined ? role === 'root' : role !== 'root' && typeof parent === 'string' && !!parent.trim()) && + validAchievementTokenVector(file.total) && validAchievementBuckets(file.byModel, file.total); + }; + const verified = new Map(); + for (const key of bySession.keys()) { + if (verified.has(key)) continue; + const chain = new Set(); + let current: string | undefined = key; + let valid = true; + while (current !== undefined) { + if (verified.has(current)) { valid = verified.get(current)!; break; } + const file = bySession.get(current); + if (!file || chain.has(current) || !sound(file)) { valid = false; break; } + chain.add(current); + current = file.session.parentSessionKey; + } + if (!chain.size) verified.set(key, false); + for (const session of chain) verified.set(session, valid); + } + return files.filter(file => verified.get(file?.session?.sessionKey) === true && sound(file)); +} diff --git a/src/codexAchievementRevision.ts b/src/codexAchievementRevision.ts new file mode 100644 index 00000000..e41de478 --- /dev/null +++ b/src/codexAchievementRevision.ts @@ -0,0 +1,124 @@ +import { createHash } from 'crypto'; +import { isDeepStrictEqual } from 'node:util'; +import type { CodexFileAggregate } from './providers/codex/codexIndex'; + +const TOKEN_FIELDS = ['inputTotal', 'cachedInput', 'cacheWriteInput', 'outputTotal', 'reasoningOutput', 'sourceTotal'] as const; +const object = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +// This registry certifies immutability only, never current-source authority. +// No caller-supplied revision, unchanged flag or shallow Object.freeze can +// enroll an arbitrary mutable census. Weak keys retain no second file corpus. +const immutableFiles = new WeakSet(); +export function isImmutableCodexAchievementFiles(files: readonly CodexFileAggregate[]): boolean { + return immutableFiles.has(files); +} + +// Plain worker data only. Preserve undefined/non-finite numeric counter values +// so malformed proof is rejected by consumers, never normalized into validity. +// Use ES2020 primitives, including on older Extension Hosts without structuredClone. +function copyFrozenData(value: unknown, copies: Map): unknown { + if (value === null || typeof value !== 'object') { + if (typeof value === 'function' || typeof value === 'symbol') throw new TypeError('Non-data input'); + return value; + } + if (copies.has(value)) return copies.get(value); + const proto = Object.getPrototypeOf(value); + if ((!Array.isArray(value) && proto !== Object.prototype && proto !== null) || + Object.getOwnPropertySymbols(value).length) throw new TypeError('Non-plain input'); + const copy = Array.isArray(value) ? new Array(value.length) : proto === null ? Object.create(null) : {}; + copies.set(value, copy); + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + if (!('value' in descriptor)) throw new TypeError('Accessor input'); + const child = copyFrozenData(descriptor.value, copies); + if (key === '__proto__') Object.defineProperty(copy, key, { value: child, enumerable: true }); + else copy[key] = child; + } + return Object.freeze(copy); +} + +/** Publish detached plain-data files. The provider must first reconstruct the + * current census and independent contribution witnesses. Exact comparison is + * intentionally O(files); bodyReads/indexChanged/totals are not equality proof. + * Worker-owned aggregates and previously published snapshots stay untouched. */ +export function immutableCodexAchievementFiles(files: CodexFileAggregate[], + previous?: CodexFileAggregate[]): CodexFileAggregate[] { + if (previous && immutableFiles.has(previous) && isDeepStrictEqual(files, previous)) return previous; + try { + const copy = copyFrozenData(files, new Map()) as CodexFileAggregate[]; + immutableFiles.add(copy); + return copy; + } catch { + // Non-worker / malformed input receives the ordinary noncached validation. + return files; + } +} + +interface ProofFence { indexed: boolean; calendarComplete: boolean; timeZone: string } +const revisions = new WeakMap(); + +/** + * Runtime-only proof revision, independent of the dashboard render revision. + * Reads only already-materialized numeric/identity projections; no JSONL, I/O, + * timers, persistence or retained second corpus. The 64-character digest and + * its fences are memoized only for immutable snapshots (never logged/exported). + * Stream each vector instead of serializing the entire history or walking + * parent chains recursively. + */ +export function codexAchievementProofRevision( + files: readonly CodexFileAggregate[], + fence: ProofFence, +): string { + const cached = revisions.get(files); + if (cached && cached.indexed === fence.indexed && cached.calendarComplete === fence.calendarComplete && + cached.timeZone === fence.timeZone) return cached.revision; + const hash = createHash('sha256'); + const scalar = (value: unknown): void => { + // Type framing distinguishes invalid null/non-finite counters from absent + // optional counters. Undefined is not silently converted to null. + hash.update(JSON.stringify([typeof value, value === null ? null : String(value)])); + }; + const vector = (value: unknown): void => { + if (!object(value)) { scalar('invalid-vector'); scalar(value); return; } + scalar('vector'); + for (const field of TOKEN_FIELDS) scalar(value[field]); + }; + const buckets = (value: unknown): void => { + if (!object(value)) { scalar('invalid-buckets'); scalar(value); return; } + const keys = Object.keys(value).sort(); + scalar('buckets'); scalar(keys.length); + for (const key of keys) { scalar(key); vector(value[key]); } + }; + scalar('codex-achievement-proof-v3'); + scalar(fence.indexed); scalar(fence.calendarComplete); scalar(fence.timeZone); + scalar(files.length); + for (const file of files) { + scalar(file.session.sessionKey); scalar(file.session.parentSessionKey); scalar(file.session.role); + vector(file.total); buckets(file.byModel); buckets(file.byEffort); + const witness = file.achievementPeriodWitness; + if (!object(witness)) { scalar('invalid-witness'); scalar(witness); } + else { + scalar('witness'); scalar(witness.size); scalar(witness.offset); + scalar(witness.expectedPrefixEvents); scalar(witness.timeZone); + } + const period = file.period; + if (!object(period)) { scalar('invalid-period'); scalar(period); continue; } + scalar('period'); scalar(period.timeZone); scalar(period.lineageVersion); scalar(period.lineagePrefixEvents); + // Numeric provenance must retire a stale calendar binding even when a + // cursor-only source change leaves all displayed counters unchanged. + scalar(period.indexedThrough); + if (!object(period.days)) { scalar('invalid-days'); scalar(period.days); continue; } + const dates = Object.keys(period.days).sort(); + scalar(dates.length); + for (const date of dates) { + scalar(date); + const day = period.days[date]; + if (!object(day)) { scalar('invalid-day'); scalar(day); continue; } + vector(day.total); buckets(day.byModel); buckets(day.byEffort); + } + } + const revision = hash.digest('hex'); + if (immutableFiles.has(files)) revisions.set(files, { ...fence, revision }); + return revision; +} diff --git a/src/dashboardRefreshClient.ts b/src/dashboardRefreshClient.ts index c560b54f..46f76fb4 100644 --- a/src/dashboardRefreshClient.ts +++ b/src/dashboardRefreshClient.ts @@ -30,6 +30,7 @@ var __ccuRefreshScrollerKinds = [ ['local-data-table', '.local-data-table-wrap'], ['heatmap', '.heatmap-svg'], ['share-preview', '.share-preview'], + ['achievement-share-preview', '.achievement-share-preview'], ['advice-preview', '.advice-payload-preview pre'] ]; var __ccuRefreshScrollerSelector = __ccuRefreshScrollerKinds @@ -390,6 +391,15 @@ function ccuCaptureRefreshContext(panel) { ? document.activeElement : null; var focus = ccuRefreshDescriptor(focused, panel, true); + // A deliberately removed control may nominate at most two existing focus targets. + // These are transient DOM descriptors, never client state or host commands. + var focusFallbacks = focused && focused.getAttribute ? + (focused.getAttribute('data-refresh-focus-fallback') || '').split(' ').slice(0, 2).map(function(id) { + if (!id || id.length > 256) { return null; } + var target = document.getElementById(id); + if (!target || !panel.contains(target) || !target.matches(__ccuFocusableSelector)) { return null; } + return { descriptor: { kind: 'id', value: id }, top: target.getBoundingClientRect().top }; + }).filter(Boolean) : []; var controls = []; var seen = []; [focused, document.getElementById('optDraft'), document.getElementById('optResolve'), @@ -402,6 +412,7 @@ function ccuCaptureRefreshContext(panel) { }); return { focus: focus, + focusFallbacks: focusFallbacks, anchor: ccuCaptureRefreshAnchor(panel, focus), controls: controls, advicePreviews: ccuCaptureAdvicePreviews(panel), @@ -426,25 +437,42 @@ function ccuRestoreTransientControls(context, panel) { }); } +function ccuResolveRefreshFocus(descriptor, panel) { + var target = ccuResolveRefreshDescriptor(descriptor, panel); + // A surviving ID is not a usable focus target when the confirmed patch + // disables it (for example Up after moving a pin to the first position). + if (!target || target.disabled === true || + (target.matches && target.matches(':disabled'))) { return null; } + return target; +} function ccuRestoreRefreshPosition(context, panel) { requestAnimationFrame(function() { requestAnimationFrame(function() { var anchor = context.anchor ? ccuResolveRefreshDescriptor(context.anchor.descriptor, panel) : null; + var fallback = (context.focusFallbacks || []).find(function(saved) { + return !!ccuResolveRefreshFocus(saved.descriptor, panel); + }); + var anchorTop = context.anchor ? context.anchor.top : 0; + if (!anchor && fallback) { + anchor = ccuResolveRefreshDescriptor(fallback.descriptor, panel); + anchorTop = fallback.top; + } if (anchor) { - window.scrollBy(0, anchor.getBoundingClientRect().top - context.anchor.top); + window.scrollBy(0, anchor.getBoundingClientRect().top - anchorTop); } else { window.scrollTo(0, context.scrollY); } ccuRestoreLocalScrollPositions(context.localScrollPositions, panel); - var focused = ccuResolveRefreshDescriptor(context.focus, panel); + var focused = ccuResolveRefreshFocus(context.focus, panel) || + (fallback ? ccuResolveRefreshFocus(fallback.descriptor, panel) : null); if (focused && typeof focused.focus === 'function') { try { focused.focus({ preventScroll: true }); } catch (e) { focused.focus(); } } ccuRestoreLocalScrollPositions(context.localScrollPositions, panel); if (anchor) { - window.scrollBy(0, anchor.getBoundingClientRect().top - context.anchor.top); + window.scrollBy(0, anchor.getBoundingClientRect().top - anchorTop); } __ccuUiReady = true; }); @@ -452,10 +480,13 @@ function ccuRestoreRefreshPosition(context, panel) { } function ccuRestoreDashboardUiAfterPatch(context, panel, tab, adviceSnapshotIds) { - try { localStorage.setItem('ccu.activeTab', tab); } catch (e) {} - showTab(tab, true); + if (ccuProviderName() !== 'compare') { + try { localStorage.setItem('ccu.activeTab', tab); } catch (e) {} + showTab(tab, true); + } restoreSessionFilter(); restorePersistedDetails(); + restoreAchievementFilter(); restoreClaudeDrilldownDetails(); restoreCodexHourlyDetails(); restoreSessionDetails(); @@ -470,6 +501,11 @@ function ccuRestoreDashboardUiAfterPatch(context, panel, tab, adviceSnapshotIds) restoreProjectMatrixState(panel); ccuRestoreTransientControls(context, panel); scSyncPreviewStatus(); + // Display hydration is not a deliberate preview or export action. Reapply + // saved choices against the newly earned badges and recalculate capacity. + restoreSharingTemplate(); + restoreAchievementShareSelection(); + restoreAchievementReturnFocus(); restoreAdviceEffectivenessState(); ccuRestoreAdvicePreviews(context.advicePreviews, adviceSnapshotIds); formatOptSettings(); @@ -486,10 +522,11 @@ function ccuApplyDashboardDataPatch(message) { } var panel = document.getElementById('provider-panel'); var valid = panel && - (message.provider === 'claude' || message.provider === 'codex') && + (message.provider === 'claude' || message.provider === 'codex' || message.provider === 'compare') && message.provider === ccuProviderName() && - ['today', 'month', 'all', 'sessions', 'projects', 'content', 'branches', 'workflows', 'settings'] - .indexOf(message.tab) !== -1 && + (message.provider === 'compare' ? message.tab === 'all' : + ['today', 'month', 'all', 'sessions', 'projects', 'content', 'branches', 'workflows', 'achievements', 'settings'] + .indexOf(message.tab) !== -1) && typeof message.html === 'string' && message.html.length <= 32 * 1024 * 1024 && !/)/i.test(message.html) && diff --git a/src/dataLoader.ts b/src/dataLoader.ts index aecca049..a42e3da9 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -5,6 +5,7 @@ import * as path from 'path'; // Removed tinyglobby dependency - using native fs instead // Removed zod dependency - using native validation instead import { calculateCostBreakdown, getModelRatesPerMillion } from './pricing'; +import { addClaudeReferenceUsage, claudeReferenceUsage, hasKnownClaudeCacheCreationShape, normalizeClaudeNativeUsage } from './claudeReferenceUsage'; import { detachedPromptPrefix, isRetryDuplicatePrompt } from './promptDedup'; import { dayKeyInZone, @@ -45,6 +46,42 @@ import { WorkflowUsage, } from './types'; +/** In-memory provenance only; never accept this flag from JSONL metadata. */ +type ClaudeSessionEvidenceRecord = ClaudeUsageRecord & { _unownedAgentSession?: true }; +export type ClaudeSessionUsage = SessionUsage & { _unownedAgentSession?: true }; + +/** Flat agent files have no path-proven parent. Keep their usage/session rows, + * but do not treat their basename as evidence of an independent conversation. + * Record sessionId/isSidechain metadata is not an established ownership contract + * here; nested agents retain the existing parent-directory contract. No I/O. */ +export function tagClaudeSessionEvidence(record: ClaudeUsageRecord, filePath: string): void { + const tagged = record as ClaudeSessionEvidenceRecord; + if (/(?:^|[\\/])projects[\\/][^\\/]+[\\/]agent-[^\\/]+\.jsonl$/.test(filePath)) { + tagged._unownedAgentSession = true; + } else { + delete tagged._unownedAgentSession; + } +} + +/** One-pass bounds; spreading an unbounded history exceeds V8's argument limit. */ +export function recordTimestampBounds( + records: Iterable>, + ignoreInvalid = true, +): { first: number; last: number } { + let first = Infinity; + let last = -Infinity; + for (const record of records) { + const timestamp = Date.parse(record.timestamp); + if (!Number.isFinite(timestamp)) { + if (!ignoreInvalid) return { first: NaN, last: NaN }; + continue; + } + if (timestamp < first) first = timestamp; + if (timestamp > last) last = timestamp; + } + return first === Infinity ? { first: 0, last: 0 } : { first, last }; +} + // Constants const CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR'; const CLAUDE_PROJECTS_DIR_NAME = 'projects'; @@ -97,7 +134,7 @@ function usageModelName(value: unknown): string | undefined { * A usage-bearing assistant row may contain a very large response/tool body; * retaining the parsed row would pin that body in every dashboard snapshot. */ export function compactUsageRecord(data: ClaudeUsageRecord): ClaudeUsageRecord { - const sourceUsage = data.message.usage; + const sourceUsage = normalizeClaudeNativeUsage(data.message.usage); const model = usageModelName(data.message.model); const usage: ClaudeUsageRecord['message']['usage'] = { input_tokens: sourceUsage.input_tokens, @@ -108,6 +145,10 @@ export function compactUsageRecord(data: ClaudeUsageRecord): ClaudeUsageRecord { ? { cache_read_input_tokens: sourceUsage.cache_read_input_tokens } : {}), ...(sourceUsage.cache_creation !== undefined ? { cache_creation: { + // Keep only a bounded numeric rejection marker, not unknown fields or + // their bodies. Pricing still sees exactly the original known counts. + ...(!hasKnownClaudeCacheCreationShape(sourceUsage.cache_creation) + ? { _unsupportedTTLShape: 1 } : {}), ...(sourceUsage.cache_creation?.ephemeral_1h_input_tokens !== undefined ? { ephemeral_1h_input_tokens: sourceUsage.cache_creation.ephemeral_1h_input_tokens } : {}), ...(sourceUsage.cache_creation?.ephemeral_5m_input_tokens !== undefined @@ -996,6 +1037,7 @@ export class ClaudeDataLoader { _sessionId: sessionInfo.sessionId, _projectDirEncoded: sessionInfo.projectPath, }; + tagClaudeSessionEvidence(prompt, file); const pcwd = lineAny.cwd; if (typeof pcwd === 'string' && pcwd.trim() !== '') { prompt._projectPath = pcwd; @@ -1025,6 +1067,7 @@ export class ClaudeDataLoader { // over the lossy, dash-encoded folder name when it is available. const record = compactUsageRecord(data as ClaudeUsageRecord); record._sessionId = sessionInfo.sessionId; + tagClaudeSessionEvidence(record, file); record._projectDirEncoded = sessionInfo.projectPath; const cwd = (parsed as { cwd?: unknown }).cwd; if (typeof cwd === 'string' && cwd.trim() !== '') { @@ -1316,7 +1359,7 @@ export class ClaudeDataLoader { continue; } - const usage = record.message.usage; + const usage = normalizeClaudeNativeUsage(record.message.usage); // Skip error records and invalid records if (model === '' || record.isApiErrorMessage) { continue; @@ -1362,6 +1405,10 @@ export class ClaudeDataLoader { modelData.cacheReadTokens += usage.cache_read_input_tokens || 0; modelData.cost += calculatedCost; modelData.count++; + const reference = claudeReferenceUsage(usage); + if (reference) { + modelData.referenceUsage = addClaudeReferenceUsage(modelData.referenceUsage, reference); + } } return data; @@ -1446,11 +1493,11 @@ export class ClaudeDataLoader { } const usageData = this.calculateUsageData(sessionRecords); - const times = sessionRecords.map((r) => new Date(r.timestamp).getTime()); + const times = recordTimestampBounds(sessionRecords, false); return { ...usageData, - sessionStart: new Date(Math.min(...times)), - sessionEnd: new Date(Math.max(...times)), + sessionStart: new Date(times.first), + sessionEnd: new Date(times.last), }; } @@ -2399,7 +2446,7 @@ export class ClaudeDataLoader { * cap, not a "recent" window: the Sessions tab shows all of a normal user's * sessions and narrows them with client-side time / project / model filters. */ - static getSessionBreakdown(records: ClaudeUsageRecord[], limit: number = 1000): SessionUsage[] { + static getSessionBreakdown(records: ClaudeUsageRecord[], limit: number = 1000): ClaudeSessionUsage[] { // Internal grouping tables have no inherited keys. Public rows/snapshots // still use their existing ordinary-object shape. const recordsBySession: Record = Object.create(null); @@ -2412,12 +2459,10 @@ export class ClaudeDataLoader { recordsBySession[sessionId].push(record); } - const sessions: SessionUsage[] = Object.entries(recordsBySession).map(([sessionId, sessionRecords]) => { - const timestamps = sessionRecords - .map((r) => new Date(r.timestamp).getTime()) - .filter((t) => !isNaN(t)); - const startTime = timestamps.length > 0 ? new Date(Math.min(...timestamps)) : new Date(0); - const endTime = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : new Date(0); + const sessions: ClaudeSessionUsage[] = Object.entries(recordsBySession).map(([sessionId, sessionRecords]) => { + const timestamps = recordTimestampBounds(sessionRecords); + const startTime = new Date(timestamps.first); + const endTime = new Date(timestamps.last); const first = sessionRecords[0]; const peakContextTokens = sessionRecords.reduce((peak, r) => Math.max(peak, this.recordContextTokens(r)), 0); @@ -2432,6 +2477,8 @@ export class ClaudeDataLoader { endTime, data: this.calculateUsageData(sessionRecords), peakContextTokens, + ...(sessionRecords.some((r) => (r as ClaudeSessionEvidenceRecord)._unownedAgentSession) + ? { _unownedAgentSession: true as const } : {}), }; }); @@ -2538,10 +2585,10 @@ export class ClaudeDataLoader { } const timeRange = (rs: ClaudeUsageRecord[]): { start: Date; end: Date } => { - const timestamps = rs.map((r) => new Date(r.timestamp).getTime()).filter((t) => !isNaN(t)); + const timestamps = recordTimestampBounds(rs); return { - start: timestamps.length > 0 ? new Date(Math.min(...timestamps)) : new Date(0), - end: timestamps.length > 0 ? new Date(Math.max(...timestamps)) : new Date(0), + start: new Date(timestamps.first), + end: new Date(timestamps.last), }; }; @@ -3045,27 +3092,27 @@ export class ClaudeDataLoader { } } - const timestamps = projectRecords.map((r) => new Date(r.timestamp).getTime()).filter((t) => !isNaN(t)); + const timestamps = recordTimestampBounds(projectRecords); const first = projectRecords[0]; const project: ProjectUsage = { projectName: first._projectName || 'unknown', projectPath: displayPathByKey[key], sessionCount: new Set(projectRecords.map((r) => r._sessionId || 'unknown')).size, - firstSeen: timestamps.length > 0 ? new Date(Math.min(...timestamps)) : new Date(0), - lastSeen: timestamps.length > 0 ? new Date(Math.max(...timestamps)) : new Date(0), + firstSeen: new Date(timestamps.first), + lastSeen: new Date(timestamps.last), data: this.calculateUsageData(projectRecords), }; if (!groups[groupKey]) { groups[groupKey] = { records: [], children: [], displayPath: groupDisplayPath, isGitRepo }; } - groups[groupKey].records.push(...projectRecords); + for (const record of projectRecords) groups[groupKey].records.push(record); groups[groupKey].children.push(project); }); // 4. Aggregate each group. const result: ProjectGroup[] = Object.values(groups).map((g) => { - const timestamps = g.records.map((r) => new Date(r.timestamp).getTime()).filter((t) => !isNaN(t)); + const timestamps = recordTimestampBounds(g.records); const sessionCount = new Set(g.records.map((r) => r._sessionId || 'unknown')).size; const children = g.children.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()); const pathSegments = g.displayPath.split(/[\\/]/).filter((s) => s.length > 0); @@ -3077,8 +3124,8 @@ export class ClaudeDataLoader { isGitRepo: g.isGitRepo, projectCount: children.length, sessionCount, - firstSeen: timestamps.length > 0 ? new Date(Math.min(...timestamps)) : new Date(0), - lastSeen: timestamps.length > 0 ? new Date(Math.max(...timestamps)) : new Date(0), + firstSeen: new Date(timestamps.first), + lastSeen: new Date(timestamps.last), data: this.calculateUsageData(g.records), children, }; @@ -3110,13 +3157,13 @@ export class ClaudeDataLoader { const result: BranchUsage[] = Object.values(byKey).map((recs) => { const first = recs[0]; const branch = first._gitBranch && first._gitBranch.trim() !== '' ? first._gitBranch : '-'; - const timestamps = recs.map((r) => new Date(r.timestamp).getTime()).filter((t) => !isNaN(t)); + const timestamps = recordTimestampBounds(recs); return { branch, projectName: first._projectName || 'unknown', projectPath: first._projectPath || '', sessionCount: new Set(recs.map((r) => r._sessionId || 'unknown')).size, - lastSeen: timestamps.length > 0 ? new Date(Math.max(...timestamps)) : new Date(0), + lastSeen: new Date(timestamps.last), data: this.calculateUsageData(recs), }; }); diff --git a/src/extension.ts b/src/extension.ts index a8134b0d..913cc672 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,7 +15,10 @@ import { } from './claudeIncrementalIndex'; import { StatusBarManager } from './statusBar'; import { UsageWebviewProvider } from './webview'; -import { I18n } from './i18n'; +import { AchievementCalibrationSingleFlight, AchievementCalibrationResult, AchievementCalibrationCommitResult, shouldFetchQuotaForRefresh } from './achievementCalibration'; +import { codexAchievementHistoryEvidence } from './achievementHistoryDates'; +import { achievementCopyFor, achievementRecordStatusCopyFor, artifactLocale, ACHIEVEMENT_RECORD_INVENTORY_COPY, I18n } from './i18n'; +import { ACHIEVEMENT_RECORD_FILE, ACHIEVEMENT_LEGACY_RECORD_FILE, AchievementRecordStore, achievementRecordAuxiliaryName, loadAchievementRecords } from './achievementRecordStore'; import { dayKeyInZone, resolveTimeZone } from './dateKeys'; import { GITHUB_HEATMAP_DESTINATION_KEY, @@ -32,7 +35,8 @@ import { ClaudeApiClient } from './claudeApiClient'; import { buildOptimizerSystemPrompt, } from './advisor'; -import { ClaudeApiUsageResponse, ContentAnalysis, ExtensionConfig } from './types'; +import { ClaudeApiUsageResponse, ClaudeUsageRecord, ContentAnalysis, ExtensionConfig } from './types'; +import type { ClaudeUsageIndexUpdateResult } from './claudeIncrementalIndex'; import { KNOWN_CONFIGURATION_SETTING_KEYS, OWNED_SETTING_GLOBAL_STATE_KEYS, @@ -41,6 +45,9 @@ import { SettingsStore, } from './settings'; import { normalizeQuotaWindows } from './quotaWindows'; +import { ClaudeQuotaPaceSource, projectCodexQuotaPaceSource } from './quotaPaceSource'; +import type { QuotaPaceAdapterSnapshot } from './quotaPaceAdapters'; +import {codexAchievementEffortStatistics} from './achievementEffortStatistics'; import { WeeklyQuotaObservation, } from './weeklyValue'; @@ -64,6 +71,7 @@ import { UsageManifest, } from './claudeUsageFiles'; import { + ClaudeRefreshRecovery, commitRefreshSnapshot, codexRefreshProfileForTrigger, pollIntervalMs, @@ -79,8 +87,12 @@ import { WindowActivityGate, } from './refreshPolicy'; import { + ClaudeFailureDiagnosticBudget, + ClaudeRefreshStage, + claudeRefreshFailureReason, formatCodexIndexDiagnostic, formatRefreshDiagnostic, + RefreshDiagnostic, } from './refreshDiagnostics'; import { CodexProvider, @@ -96,6 +108,10 @@ import { weeklyQuotaObservationsFromCodexHistory } from './providers/codex/codex import { resolveCodexHome, scanCodexManifest } from './providers/codex/codexManifest'; import { codexIndexStoragePath, codexIndexCanonicalName, readOwnedLegacyCodexIndex } from './providers/codex/codexIndexStorage'; import { buildCodexUsageView, CodexUsageView } from './providers/codex/codexUsage'; +import { codexModelSignals, CodexAchievementSignals } from './achievements'; +import {codexAchievementMoneyScopes} from './achievementMoneyScopes'; +import { codexAchievementCalendarProjection, codexAchievementTokenEvidence } from './achievementCalendar'; +import { codexAchievementProofRevision } from './codexAchievementRevision'; import { buildScopedCodexInsights, CodexScopedInsights, @@ -420,12 +436,31 @@ const WHATS_NEW: ReleaseAnnouncementCatalog = { version: '2.3.2', body: () => I18n.t.releaseAnnouncement.v232, }, + '2.5.0': { + version: '2.5.0', + body: () => achievementCopyFor(I18n.getLocale()).upgradeOn, + }, }; +const EMPTY_CODEX_ACHIEVEMENT_SIGNALS: CodexAchievementSignals = Object.freeze({ + modelCount: 0, + efforts: [], + familyRootCounts: Object.freeze({ astra: 0, sol: 0, luna: 0 }), +}); + +interface AchievementCalibrationRefresh { + current(): boolean; + /** Actual source/lifecycle authority, independent of the progress Cancel token. */ + sourceCurrent?(): boolean; + accepted: boolean; +} + export class ClaudeCodeUsageExtension { private static readonly CODEX_BACKGROUND_WORK_STATE_KEY = 'ccu.codex.backgroundWork.v1'; - private static readonly CODEX_BACKGROUND_MEASUREMENT_VERSION = 1; + // Reopen completed v1 background work once so existing indexes can acquire + // the bounded historical output-hour proof required by achievements. + private static readonly CODEX_BACKGROUND_MEASUREMENT_VERSION = 2; private static readonly CODEX_FIRST_BACKFILL_BLUR_DEADLINE_MS = 10_000; private statusBar: StatusBarManager; private webviewProvider: UsageWebviewProvider; @@ -445,7 +480,13 @@ export class ClaudeCodeUsageExtension { private codexWatchedHome: string | null = null; private readonly watchDebounce = this.createOwnedRefreshDebounce('claude'); private readonly refreshGate = new RefreshSingleFlight(); + private claudeRefreshDrain: Promise | null = null; + private readonly achievementCalibration = new AchievementCalibrationSingleFlight(); + private achievementCalibrationPending: Promise | undefined; + private achievementCalibrationGeneration = 0; private providerUiSyncFailureReported = false; + private claudeFailureDiagnosticBudget: ClaudeFailureDiagnosticBudget | undefined; + private claudeRefreshRecovery: ClaudeRefreshRecovery | undefined; private readonly codexRefreshGate = new RefreshSingleFlight(); private codexRefreshDrain: Promise | null = null; private codexRefreshSuspensionDepth = 0; @@ -508,14 +549,19 @@ export class ClaudeCodeUsageExtension { private quotaObservationStore: QuotaObservationStoreV2; private readonly quotaFingerprintSalt: string; private activeClaudeQuotaFingerprint: string | undefined; + private readonly claudeQuotaPaceSource = new ClaudeQuotaPaceSource(); private activePricingBackend: ExtensionConfig['pricingBackend'] = 'anthropic'; private codexProvider: CodexProvider; private readonly codexSalt: string; private codexView: CodexUsageView | null = null; + private codexAchievementSignals: CodexAchievementSignals = EMPTY_CODEX_ACHIEVEMENT_SIGNALS; + private codexAchievementRevision?: string; + private codexAchievementMoneyRevision?: string; private codexInsights: CodexScopedInsights = emptyCodexScopedInsights(); private codexAvailable = false; private codexHasData = false; private codexRefreshing = false; + private codexCompressionRuntimeWarned = false; private codexProgress: CodexIndexProgress | null = null; private codexProgressLastRenderedAt = 0; /** In-memory source ownership only; never written as a raw path. */ @@ -553,6 +599,7 @@ export class ClaudeCodeUsageExtension { private codexBackgroundStateWrite: Promise = Promise.resolve(); private initializationWrites: Promise = Promise.resolve(); private initializationWriteFailure: unknown = null; + private pendingReleaseAnnouncementVersion?: string; private localDataActionWrite: Promise = Promise.resolve(); private pendingClientResetReplay: Promise = Promise.resolve(); private pendingClientResetRevision = 0; @@ -570,6 +617,8 @@ export class ClaudeCodeUsageExtension { private configurationGeneration = 0; /** Source/pricing invalidation is separate from presentation settings. */ private claudeIndexGeneration = 0; + /** Pending discovery must not retire callbacks on the installed watcher. */ + private fileWatcherSetupGeneration = 0; private fileWatcherGeneration = 0; private codexWatcherGeneration = 0; private credentialsWatcherGeneration = 0; @@ -611,6 +660,7 @@ export class ClaudeCodeUsageExtension { this.queueInitializationWrite(() => this.saveCodexBackgroundState()); } this.webviewProvider = new UsageWebviewProvider(context); + this.webviewProvider.setAchievementWindowFocused(this.windowActivity.focused); const existingQuotaSalt = context.globalState.get( 'ccu.quota.fingerprintSalt.v1', ); @@ -677,7 +727,7 @@ export class ClaudeCodeUsageExtension { expectedSourceRevision, expectedConsentGeneration, ) => { - const config = this.getConfiguration(); + const config = this.getConfiguration(true); return this.runAdviceNetwork((signal) => requestPreparedOptimizer(prepared, { apiKey: config.adviceApiKey, @@ -717,7 +767,7 @@ export class ClaudeCodeUsageExtension { expectedSourceRevision, expectedConsentGeneration, ) => { - const config = this.getConfiguration(); + const config = this.getConfiguration(true); return this.runAdviceNetwork((signal) => requestStructuredAdvice(prepared, references, { apiKey: config.adviceApiKey, @@ -796,21 +846,23 @@ export class ClaudeCodeUsageExtension { private maybeAnnounceWhatsNew(): void { const current = (this.context.extension?.packageJSON?.version as string) || ''; const last = this.context.globalState.get('ccu.lastSeenVersion'); - if (!current) { + if (!current || this.pendingReleaseAnnouncementVersion === current) { return; } + this.pendingReleaseAnnouncementVersion = current; const announcement = announcementForUpgrade( current, last, this.getConfiguration().releaseAnnouncements, WHATS_NEW, ); - this.queueInitializationWrite(() => - this.context.globalState.update('ccu.lastSeenVersion', current), - ); - if (announcement) { - this.showWhatsNew(announcement.version); - } + this.queueInitializationWrite(async () => { + await this.context.globalState.update('ccu.lastSeenVersion', current); + // Persist first: a failed store must not turn reload into repeated toasts. + if (!this.disposed && !this.clearingAllLocalData && !this.localDataClearedRequiresReload && announcement) { + this.showWhatsNew(announcement.version); + } + }); } /** Show the what's-new toast for one exact release, if it is catalogued. */ @@ -819,8 +871,37 @@ export class ClaudeCodeUsageExtension { if (!announcement) { return; } + if (version === '2.5.0' && this.getConfiguration().enableAchievements !== false) { + const copy = achievementCopyFor(I18n.getLocale()); + void Promise.resolve(vscode.window.showInformationMessage( + `Claude Code Usage ${version}: ${copy.upgradeOn}`, copy.openAchievements, copy.disableAchievements, + )).then((pick) => { + if (this.disposed || this.clearingAllLocalData || this.localDataClearedRequiresReload) return; + if (pick === copy.openAchievements && this.getConfiguration().enableAchievements !== false) { + this.webviewProvider.show('achievements'); + } else if (pick === copy.disableAchievements) { + // The clear-all barrier owns this write too; a delayed click cannot + // resurrect settings after local data was cleared. + this.queueInitializationWrite(async () => { + if (this.clearingAllLocalData) return; + try { + await this.settings.set('enableAchievements', false); + if (!this.disposed && !this.localDataClearedRequiresReload) { + this.onSettingsChangedFromPanel('enableAchievements'); + } + } catch { + if (!this.disposed && !this.localDataClearedRequiresReload) { + void vscode.window.showErrorMessage(copy.disableFailed); + } + } + }); + } + }).catch(() => undefined); + return; + } const open = I18n.t.popup.title; // "Show details" entry point label - void vscode.window.showInformationMessage(`Claude Code Usage ${version}: ${announcement.body()}`, open).then((pick) => { + const body = version === '2.5.0' ? achievementCopyFor(I18n.getLocale()).upgradeOff : announcement.body(); + void vscode.window.showInformationMessage(`Claude Code Usage ${version}: ${body}`, open).then((pick) => { if (pick === open) { vscode.commands.executeCommand('claudeCodeUsage.showDetails'); } @@ -841,6 +922,8 @@ export class ClaudeCodeUsageExtension { private setupCommands(): void { const commands = [ + vscode.commands.registerCommand('claudeCodeUsage.recalibrateAchievements', () => + this.recalibrateAchievements()), vscode.commands.registerCommand('claudeCodeUsage.refresh', () => { // Manual refresh always updates the dashboard even when // dashboardAutoRefresh is off. @@ -906,9 +989,120 @@ export class ClaudeCodeUsageExtension { commands.forEach(command => this.context.subscriptions.push(command)); } + /** One owner holds progress and quiet mode across duplicate command invocations. */ + private recalibrateAchievements(): Promise { + if (this.achievementCalibrationPending) return this.achievementCalibrationPending; + const webview = this.webviewProvider; + const pending = Promise.resolve().then(async () => { + let result: AchievementCalibrationResult = 'failed'; + try { + if (this.getConfiguration().enableAchievements === false) { + result = 'disabled'; + void vscode.window.showInformationMessage(achievementRecordStatusCopyFor(I18n.getLocale()).disabled); + return; + } + result = await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: I18n.achievementManagement.recalibrating, + cancellable: true, + }, async (_progress, token) => { + if (this.getConfiguration().enableAchievements === false) return 'disabled' as const; + if (token.isCancellationRequested || this.disposed || this.clearingAllLocalData || + this.localDataClearedRequiresReload) return 'cancelled' as const; + webview.setAchievementCalibrationState(true); + return this.achievementCalibration.run({ + enabled: () => this.getConfiguration().enableAchievements !== false, + cancelled: () => token.isCancellationRequested || this.disposed || + this.clearingAllLocalData || this.localDataClearedRequiresReload, + generation: () => `${this.configurationGeneration}:${this.achievementCalibrationGeneration}`, + refreshLocal: current => this.refreshLocalForAchievementCalibration(current), + // Preserve completed/partial results; a late cancellation cannot + // erase work already saved, and completion need not add new awards. + recalibrate: (current): Promise => webview.recalibrateAchievementsFromCurrentData(current), + }); + }); + if (result === 'recalibrated') { + void vscode.window.showInformationMessage(I18n.achievementManagement.recalibrated); + } else if (result === 'partial') { + void vscode.window.showWarningMessage(I18n.achievementManagement.recalibratedPartial); + } else if (result === 'failed') { + void vscode.window.showErrorMessage(I18n.achievementManagement.recalibrationFailed); + } + } catch { + void vscode.window.showErrorMessage(I18n.achievementManagement.recalibrationFailed); + } finally { + // The panel may optimistically mark its button busy before dispatch. + // Even a disabled/cancelled preflight must acknowledge completion. + webview.setAchievementCalibrationState(false, result === 'failed'); + } + }).finally(() => { + if (this.achievementCalibrationPending === pending) this.achievementCalibrationPending = undefined; + }); + this.achievementCalibrationPending = pending; + return pending; + } + + private async refreshLocalForAchievementCalibration(current: () => boolean): Promise { + // Wait for already-owned production work, never coalesce a local-only + // request into an ordinary request (which may legitimately fetch quota). + // A permanently busy source fails explicitly instead of adding a retry loop. + for (let attempt = 0; attempt < 4; attempt++) { + if (!current()) return; + const drains = [this.claudeRefreshDrain, this.codexRefreshDrain].filter( + (value): value is Promise => !!value); + if (!drains.length) break; + await Promise.all(drains); + } + if (!current()) return; + if (this.claudeRefreshDrain || this.codexRefreshDrain || this.codexRefreshSuspensionDepth > 0) { + throw new Error('achievement-calibration:refresh-busy'); + } + const config = this.getConfiguration(), provider = this.codexProvider; + const generation = this.configurationGeneration; + const indexGeneration = this.claudeIndexGeneration; + const achievementGeneration = this.achievementCalibrationGeneration; + const sourceCurrent = () => !this.disposed && !this.clearingAllLocalData && !this.localDataClearedRequiresReload && + this.getConfiguration().enableAchievements !== false && achievementGeneration === this.achievementCalibrationGeneration && + generation === this.configurationGeneration && indexGeneration === this.claudeIndexGeneration && provider === this.codexProvider; + const ownsSources = () => current() && sourceCurrent(); + const claudeDirectory = await ClaudeDataLoader.findClaudeDataDirectory(config.dataDirectory || undefined); + if (!ownsSources()) throw new Error('achievement-calibration:source-retired'); + const codexAvailable = config.codexEnabled ? await provider.isAvailable() : false; + if (!ownsSources()) throw new Error('achievement-calibration:source-retired'); + // An absent, never-loaded provider is optional. A previously verified or + // explicitly configured source disappearing is a real failure. + if ((!claudeDirectory && (this.cache.manifest !== null || !!config.dataDirectory)) || + (config.codexEnabled && !codexAvailable && (!!this.codexView || !!config.codexDataDirectory))) { + throw new Error('achievement-calibration:source-unavailable'); + } + if (!claudeDirectory && !codexAvailable) throw new Error('achievement-calibration:no-source'); + + if (claudeDirectory) { + // No await between admission and starting the owned local pass. + if (this.claudeRefreshDrain) throw new Error('achievement-calibration:refresh-busy'); + const request = this.refreshGate.request(false, 'manual'); + if (!request) throw new Error('achievement-calibration:refresh-busy'); + const calibration = { current: ownsSources, sourceCurrent, accepted: false }; + await this.startClaudeRefresh({ ...request, localOnly: true, calibration }); + if (!ownsSources()) throw new Error('achievement-calibration:source-retired'); + if (!calibration.accepted || this.providerRefreshStates.claude.failed) { + throw new Error('achievement-calibration:claude-refresh-failed'); + } + } + if (codexAvailable) { + const calibration = { current: ownsSources, sourceCurrent, accepted: false }; + await this.refreshCodexData('manual', calibration); + if (!ownsSources()) throw new Error('achievement-calibration:source-retired'); + if (!calibration.accepted || this.providerRefreshStates.codex.failed || + this.codexView?.currentManifestVerified !== true || this.codexView.coverage.complete !== true) { + throw new Error('achievement-calibration:codex-refresh-failed'); + } + } + } + private async derivedFileFamilyNames( filePath: string, - family: 'codex-index' | 'quota-observations', + family: 'codex-index' | 'quota-observations' | 'achievement-records', ): Promise { const directory = path.dirname(filePath); const canonical = path.basename(filePath); @@ -920,6 +1114,10 @@ export class ClaudeCodeUsageExtension { throw error; } return names.filter((name) => { + if (family === 'achievement-records') { + return name === ACHIEVEMENT_RECORD_FILE || name === ACHIEVEMENT_LEGACY_RECORD_FILE || + achievementRecordAuxiliaryName(name); + } if (name === canonical) return true; if (family === 'codex-index') { return codexIndexCanonicalName(name) === canonical; @@ -931,7 +1129,7 @@ export class ClaudeCodeUsageExtension { private async localFileFamilyInventory( filePath: string, - family: 'codex-index' | 'quota-observations', + family: 'codex-index' | 'quota-observations' | 'achievement-records', ): Promise<{ approximateBytes: number | null; itemCount: number; @@ -1114,10 +1312,21 @@ export class ClaudeCodeUsageExtension { zh ? chinese : english; const indexPath = path.join(this.context.globalStorageUri.fsPath, 'codex-index-v1.json'); const quotaPath = path.join(this.context.globalStorageUri.fsPath, QUOTA_OBSERVATION_FILE); - const [indexFile, quotaFile] = await Promise.all([ + const achievementPath = path.join(this.context.globalStorageUri.fsPath, ACHIEVEMENT_RECORD_FILE); + const [indexFile, quotaFile, achievementFile, achievementRecords] = await Promise.all([ this.localFileFamilyInventory(indexPath, 'codex-index'), this.localFileFamilyInventory(quotaPath, 'quota-observations'), + this.localFileFamilyInventory(achievementPath, 'achievement-records'), + loadAchievementRecords(achievementPath).then(async current => { + // v2 is authoritative. Count its archive once, not again through v1. + // Before adoption, only the bounded canonical v1 is a migration input. + if (current) return current.schemaVersion === 2 ? current : null; + const legacy = await loadAchievementRecords(path.join( + this.context.globalStorageUri.fsPath, ACHIEVEMENT_LEGACY_RECORD_FILE)); + return legacy && legacy.schemaVersion !== 1 ? null : legacy; + }).catch(() => null), ]); + const achievementCopy = ACHIEVEMENT_RECORD_INVENTORY_COPY[artifactLocale(I18n.getLocale())]; const stateKeys = this.extensionGlobalStateKeys(); const legacyQuotaKeys = stateKeys.filter((key) => key.startsWith(LEGACY_QUOTA_PREFIX) || key.startsWith(LEGACY_WEEKLY_PREFIX), @@ -1296,6 +1505,15 @@ export class ClaudeCodeUsageExtension { networkInteraction: local('Local export: none; GitHub publish: explicit confirmed action only', '本地导出:无;GitHub 发布:仅明确确认的动作'), clearability: local('Reset Sharing Preferences', '重置分享偏好'), }, + { + id: 'P12', category: 'P12 · ' + achievementCopy.category, + locationClass: 'VS Code globalStorage', schema: `${achievementRecords?.schemaVersion ?? 2} · ≤64 KiB`, + approximateBytes: achievementFile.approximateBytes, + itemCount: achievementRecords === null ? null : + (achievementRecords?.records.length ?? 0) + (achievementRecords?.legacyRecords?.length ?? 0), + oldestAt: null, newestAt: null, + networkInteraction: achievementCopy.network, clearability: achievementCopy.clear, + }, ]; return { schemaVersion: 1, @@ -1749,6 +1967,7 @@ export class ClaudeCodeUsageExtension { this.cancelQuotaNetworks('cancelled'), ]); await this.settings.resetAllOwnedData(); + await this.webviewProvider.clearAchievementGrants({ holdUntilReload: true }); await this.clearCodexDerivedIndex(false); for (const indexPath of await this.codexDerivedIndexPaths()) { await this.removeDerivedFileFamilyWithLease(indexPath, 'codex-index'); @@ -1816,13 +2035,37 @@ export class ClaudeCodeUsageExtension { if (quota.disposition !== 'valid' || quota.store.observations.length > 0) { throw new Error('local-data-clear:quota-postcondition-failed'); } + const achievementPath = path.join(this.context.globalStorageUri.fsPath, ACHIEVEMENT_RECORD_FILE); + const legacyPath = path.join(this.context.globalStorageUri.fsPath, ACHIEVEMENT_LEGACY_RECORD_FILE); + // Read both fences under the same lease used by old and current writers. + const lease = await acquireCodexIndexLease(legacyPath, { timeoutMs: 5_000 }); + try { + const [record, legacy, recordNames] = await Promise.all([ + loadAchievementRecords(achievementPath), + loadAchievementRecords(legacyPath), + this.derivedFileFamilyNames(achievementPath, 'achievement-records'), + ]); + const emptyRecord = (store: AchievementRecordStore): boolean => + store.records.length === 0 && store.pins.length === 0 && store.lastNoticeAt === 0 && + store.auxiliary.activity.features.length === 0 && store.auxiliary.activity.settingKeys.length === 0 && + store.auxiliary.hours.maxStreak === 0 && store.auxiliary.calendarPeak === null; + if (!record || record.schemaVersion !== 2 || record.generation <= 1 || !emptyRecord(record) || + record.legacyRecords!.length > 0 || !legacy || legacy.schemaVersion !== 1 || + legacy.generation <= 1 || !emptyRecord(legacy) || + !isDeepStrictEqual(record.legacyBoundary, { + generation: legacy.generation, sequence: legacy.sequence, + signature: createHash('sha256').update(JSON.stringify(legacy)).digest('hex'), + }) || recordNames.some(name => name !== ACHIEVEMENT_RECORD_FILE && name !== ACHIEVEMENT_LEGACY_RECORD_FILE)) { + throw new Error('local-data-clear:achievement-postcondition-failed'); + } + } finally { await lease.release(); } } private async refreshPricing(): Promise { try { const result = await fetchLatestPricing(); this.invalidateClaudeUsagePricingCache(); - this.webviewProvider.invalidateShareCardPreview(); + this.webviewProvider.invalidateShareCardPreview({pricingChanged:true}); vscode.window.showInformationMessage(`${I18n.t.popup.pricingUpdated} (${result.updated})`); // Force a full recompute so the new prices take effect. void this.refreshData(true, 'pricing'); @@ -2126,7 +2369,7 @@ export class ClaudeCodeUsageExtension { return { error: I18n.t.popup.noDataMessage }; } - const config = this.getConfiguration(); + const config = this.getConfiguration(true); const needsKey = config.adviceBackend === 'api' && (!config.adviceApiKey || config.adviceApiKey.trim() === ''); if (needsKey) { @@ -2160,7 +2403,7 @@ export class ClaudeCodeUsageExtension { this.activePricingBackend = config.pricingBackend; setPricingBackend(config.pricingBackend); this.applyFormattingConfiguration(config); - this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat, config.showWeeklyQuotaPace); // Listen for configuration changes vscode.workspace.onDidChangeConfiguration(e => { @@ -2191,7 +2434,7 @@ export class ClaudeCodeUsageExtension { I18n.setTimezone(config.timezone); } - private getConfiguration(): ExtensionConfig { + private getConfiguration(includeAdviceSecret = false): ExtensionConfig { // All settings flow through SettingsStore: language and dataDirectory live // in VS Code config, BYOK secrets live in SecretStorage, and the rest use // the dashboard-managed store. Defaults come from the settings catalog. @@ -2215,18 +2458,22 @@ export class ClaudeCodeUsageExtension { tokenDecimalPlaces: s.get('tokenDecimalPlaces'), compactNumbers: s.get('compactNumbers'), releaseAnnouncements: s.get('releaseAnnouncements'), + enableAchievements: s.get('enableAchievements') ?? true, timezone: s.get('timezone'), showCost: s.get('showCost'), showContext: s.get('showContext'), contextWindowOverride: s.get('contextWindowOverride'), statusBarMetric: s.get<'cost' | 'monthly-cost' | 'tokens'>('statusBarMetric'), showScopedWeekly: s.get('showScopedWeekly'), + showWeeklyQuotaPace: s.get('showWeeklyQuotaPace') === true, showResetInStatusBar: s.get('showResetInStatusBar'), quotaFiveHourOnly: s.get('quotaFiveHourOnly'), resetCountdownFormat: s.get<'decimal' | 'units' | 'clock'>('resetCountdownFormat'), statusBarQuotaFormat: s.get('statusBarQuotaFormat'), usageLimitTracking: s.get('usageLimitTracking'), - adviceApiKey: s.get('advice.apiKey'), + // Local refresh, presentation and calibration never look up credentials. + // Only explicit advice/optimizer preparation or sending opts in. + adviceApiKey: includeAdviceSecret ? s.get('advice.apiKey') : '', adviceApiUrl: s.get('advice.apiUrl'), adviceModel: s.get('advice.model'), adviceReasoningEffort: s.get('advice.reasoningEffort'), @@ -2262,6 +2509,7 @@ export class ClaudeCodeUsageExtension { legacyIndexPath: path.join(this.context.globalStorageUri.fsPath, 'codex-index-v1.json'), salt: this.codexSalt, timeZone: resolveTimeZone(config.timezone), + allowAchievementHourProof: config.enableAchievements !== false, }); } @@ -2276,12 +2524,15 @@ export class ClaudeCodeUsageExtension { this.statusBar.clearCodex?.(); this.codexView = null; this.codexInsights = emptyCodexScopedInsights(); + this.codexAchievementRevision = undefined; + this.codexAchievementSignals = EMPTY_CODEX_ACHIEVEMENT_SIGNALS; this.codexHasData = false; this.codexAvailable = false; this.codexRefreshing = false; this.codexProgress = null; this.codexProgressLastRenderedAt = 0; this.codexDashboardHydrated = false; + this.tryProviderUiUpdate(() => this.webviewProvider.invalidateAchievementEvidence?.('codex')); this.providerRefreshStates.codex = { failed: false }; delete this.deliveredRefreshStates.codex; this.tryProviderUiUpdate(() => this.webviewProvider.updateRefreshState?.('codex', { failed: false })); @@ -2330,6 +2581,11 @@ export class ClaudeCodeUsageExtension { this.providerRefreshStates[provider] = failed ? { ...previous, failed: true } : { failed: false, lastSuccessfulAt: Date.now() }; + if (failed) { + // Revoke award authority even when automatic panel delivery is paused. + // Numeric display data remains the last verified snapshot. + this.tryProviderUiUpdate(() => this.webviewProvider.invalidateAchievementEvidence?.(provider)); + } // A direct failed retry must reach the compact in-page feedback even when // the same failure was already observed by an automatic attempt. this.publishRefreshState(provider, trigger, failed && trigger === 'manual'); @@ -2384,6 +2640,11 @@ export class ClaudeCodeUsageExtension { private syncProviderUi(trigger: RefreshTrigger = 'settings'): void { const config = this.getConfiguration(); + if (config.showWeeklyQuotaPace === true && config.usageLimitTracking) { + const pace = this.getClaudeQuotaPaceSnapshot(); + this.statusBar.updateQuota(this.cache.usageLimits ?? null, pace); + this.webviewProvider.updateQuotaPace?.(pace, this.cache.usageLimits ?? null); + } const claudeHasData = this.cache.records.length > 0; // This non-rendering handoff revokes prepared handles when the verified // source revision or consent changes, including while the page is paused. @@ -2404,6 +2665,8 @@ export class ClaudeCodeUsageExtension { this.codexView.today, config.codexStatusMetric, this.codexView.limit, + projectCodexQuotaPaceSource({enabled: config.showWeeklyQuotaPace === true && config.usageLimitTracking, + snapshot: this.codexView.limit}), ); } else { this.statusBar.clearCodex?.(); @@ -2422,7 +2685,8 @@ export class ClaudeCodeUsageExtension { codexData: this.codexHasData, codexLoading: this.codexRefreshing, codexProgress: this.codexDashboardProgress(), - }); + }, this.codexView ? this.codexAchievementSignals : undefined, + !this.providerRefreshStates.codex.failed); if (this.codexView) this.codexDashboardHydrated = true; if (trigger === 'settings' || trigger === 'pricing') { this.publishRefreshState('claude', trigger); @@ -2568,7 +2832,11 @@ export class ClaudeCodeUsageExtension { // migration work is represented by main, period and hourly coverage. return !coverage.complete || !coverage.period.allTime.complete || - !snapshot.hourlyCoverage?.complete; + !snapshot.hourlyCoverage?.complete || + (this.getConfiguration().enableAchievements !== false && + snapshot.outputHourProof?.complete === false && + (snapshot.outputHourProof.backfillableFiles === undefined || + snapshot.outputHourProof.backfillableFiles > 0)); } private codexIndexGeneration(snapshot: CodexProviderSnapshot | null): number | null { @@ -2587,9 +2855,13 @@ export class ClaudeCodeUsageExtension { const main = snapshot.coverage; const period = main.period.allTime; const hourly = snapshot.hourlyCoverage; + const hourProof = this.getConfiguration().enableAchievements !== false + ? snapshot.outputHourProof : undefined; return { - completedUnits: main.indexedFiles + period.migratedFiles + (hourly?.indexedFiles ?? 0), - totalUnits: main.totalFiles + period.totalFiles + (hourly?.totalFiles ?? 0), + completedUnits: main.indexedFiles + period.migratedFiles + (hourly?.indexedFiles ?? 0) + + (hourProof?.provenFiles ?? 0), + totalUnits: main.totalFiles + period.totalFiles + (hourly?.totalFiles ?? 0) + + (hourProof?.totalFiles ?? 0), completedBytes: main.indexedBytes + period.migratedBytes + (hourly?.indexedBytes ?? 0), totalBytes: main.totalBytes + period.totalBytes + (hourly?.totalBytes ?? 0), }; @@ -2793,8 +3065,9 @@ export class ClaudeCodeUsageExtension { } void this.maybeFetchUsageLimits(this.getConfiguration()).then((retry) => { if (retry) { - this.statusBar.updateQuota(retry); + this.statusBar.updateQuota(retry, this.getClaudeQuotaPaceSnapshot()); this.webviewProvider.updateQuota(retry); + this.webviewProvider.updateQuotaPace?.(this.getClaudeQuotaPaceSnapshot(), this.cache.usageLimits ?? null); } }); }, 8_000); @@ -2847,12 +3120,13 @@ export class ClaudeCodeUsageExtension { return context; } - private async refreshCodexData(trigger: RefreshTrigger): Promise { + private async refreshCodexData(trigger: RefreshTrigger, calibration?: AchievementCalibrationRefresh): Promise { if ( this.disposed || this.localDataClearedRequiresReload || this.codexRefreshSuspensionDepth > 0 ) return; + if (calibration && (!calibration.current() || this.codexRefreshDrain)) return; const request = this.codexRefreshGate.request(false, trigger); if (request === null) { this.codexCoalescedTriggersSinceRefresh += 1; @@ -2860,7 +3134,7 @@ export class ClaudeCodeUsageExtension { return; } - const drain = Promise.resolve().then(() => this.drainCodexRefreshes(request)); + const drain = Promise.resolve().then(() => this.drainCodexRefreshes(request, calibration)); this.codexRefreshDrain = drain; try { await drain; @@ -2871,7 +3145,7 @@ export class ClaudeCodeUsageExtension { } } - private async drainCodexRefreshes(initial: RefreshRequest): Promise { + private async drainCodexRefreshes(initial: RefreshRequest, calibration?: AchievementCalibrationRefresh): Promise { let request: RefreshRequest | null = initial; try { while (request !== null) { @@ -2880,7 +3154,8 @@ export class ClaudeCodeUsageExtension { this.localDataClearedRequiresReload || this.codexRefreshSuspensionDepth > 0 ) break; - await this.runScheduledCodexRefresh(request.trigger); + await this.runScheduledCodexRefresh(request.trigger, calibration); + calibration = undefined; request = this.codexRefreshGate.complete(); } } finally { @@ -2893,7 +3168,7 @@ export class ClaudeCodeUsageExtension { } } - private async runScheduledCodexRefresh(trigger: RefreshTrigger): Promise { + private async runScheduledCodexRefresh(trigger: RefreshTrigger, calibration?: AchievementCalibrationRefresh): Promise { const generation = this.configurationGeneration; try { await this.waitForCodexProviderRetirements(); @@ -2909,15 +3184,16 @@ export class ClaudeCodeUsageExtension { this.disposed || this.localDataClearedRequiresReload || this.codexRefreshSuspensionDepth > 0 || - generation !== this.configurationGeneration + generation !== this.configurationGeneration || (calibration && !calibration.current()) ) return; const diagnosticContext = this.takeCodexRefreshDiagnosticContext(); const provider = this.codexProvider; - const operation = this.runCodexRefresh(trigger, diagnosticContext); + const operation = this.runCodexRefresh(trigger, diagnosticContext, calibration); this.activeCodexRefreshes.add(operation); try { await operation; } catch { + if (calibration) calibration.accepted = false; if (this.disposed || generation !== this.configurationGeneration || provider !== this.codexProvider) return; this.codexRefreshing = false; @@ -2928,21 +3204,24 @@ export class ClaudeCodeUsageExtension { formatCodexIndexDiagnostic({ trigger, outcome: 'error', + failureCode: 'refresh-failed', watcherEvents: diagnosticContext.watcherEvents, coalescedTriggers: diagnosticContext.coalescedTriggers, backfillMode: 'unknown', workerMode: 'unknown', - indexedFiles: 0, - totalFiles: 0, - indexedBytes: 0, - totalBytes: 0, - periodMigratedBytes: 0, - periodTotalBytes: 0, - migrationPending: false, - bodyReads: 0, - failedFiles: 1, - metadataMs: 0, - parseMs: 0, + coverageSource: 'unknown', + indexedFiles: null, + totalFiles: null, + indexedBytes: null, + totalBytes: null, + periodMigratedBytes: null, + periodTotalBytes: null, + migrationPending: null, + indexRecovery: null, + bodyReads: null, + failedFiles: null, + metadataMs: null, + parseMs: null, qualityFlags: { 'refresh-failed': 1 }, }), ); @@ -2968,18 +3247,29 @@ export class ClaudeCodeUsageExtension { private async runCodexRefresh( trigger: RefreshTrigger, diagnosticContext = this.takeCodexRefreshDiagnosticContext(), + calibration?: AchievementCalibrationRefresh, ): Promise { if (this.disposed) return; let ownedBackfillLease: ResourceLease | undefined; let ownedWorkerLease: ResourceLease | undefined; let workerStoppedSafely = false; + let producerResultCommitted = false; let continueHistoricalWork = false; const workerMode = codexRefreshProfileForTrigger(trigger); const config = this.getConfiguration(); const provider = this.codexProvider; const generation = this.configurationGeneration; - const ownsCurrentSource = () => !this.disposed && provider === this.codexProvider && - generation === this.configurationGeneration; + const achievementGeneration = this.achievementCalibrationGeneration ?? 0; + const home = this.codexHome(config); + const ownsCurrentSourceBase = () => !this.disposed && !this.clearingAllLocalData && + !this.localDataClearedRequiresReload && provider === this.codexProvider && + generation === this.configurationGeneration && home === this.codexHome(this.getConfiguration()) && + (!calibration || ((this.achievementCalibrationGeneration ?? 0) === achievementGeneration && + this.getConfiguration().enableAchievements !== false && (calibration.sourceCurrent?.() ?? true))); + const ownsCurrentSource = () => ownsCurrentSourceBase() && (!calibration || calibration.current()); + // Used only after the producer has returned, never to admit another scan. + const ownsProducedResult = () => ownsCurrentSourceBase() && + (producerResultCommitted || !calibration || calibration.current()); this.selectCodexSnapshotHome(this.codexHome(config)); try { if (!config.codexEnabled) { @@ -3002,6 +3292,7 @@ export class ClaudeCodeUsageExtension { this.codexRefreshing = false; this.codexProgress = null; this.codexProgressLastRenderedAt = 0; + this.tryProviderUiUpdate(() => this.webviewProvider.invalidateAchievementEvidence?.('codex')); return; } const available = await provider.isAvailable(); @@ -3146,24 +3437,60 @@ export class ClaudeCodeUsageExtension { this.codexWorkerLease = ownedWorkerLease; this.codexWorkerCancellationRequested = false; } + let achievementObservation: Awaited>; + // No worker has started while this optional bounded receipt read waits. + // Retirement here may release the registered-but-unused ownership. + workerStoppedSafely = true; + try { + achievementObservation = await this.webviewProvider.beginCodexAchievementObservation?.(ownsCurrentSource, ownsCurrentSourceBase); + } catch (error) { + if (calibration) throw error; + /* Optional achievement receipt failure never stops ordinary indexing. */ + } + if (!ownsCurrentSource()) return; + workerStoppedSafely = false; const result = await provider.refresh( workerMode, (progress) => { if (ownsCurrentSource()) this.onCodexIndexProgress(progress, trigger); }, historicalAttempt, + achievementObservation?.window, ); workerStoppedSafely = true; - if (!ownsCurrentSource()) { + producerResultCommitted = (result.outcome === 'success' || result.outcome === 'partial') && + (result.diagnostic?.failedFiles ?? 0) === 0; + if (!ownsProducedResult()) { return; } + if (result.diagnostic?.errorCode === 'zstd-unsupported' && !this.codexCompressionRuntimeWarned) { + this.codexCompressionRuntimeWarned = true; + void vscode.window.showWarningMessage(I18n.codexCompressionRuntimeUnsupported); + } if (result.outcome === 'unavailable') { this.codexAvailable = Boolean(this.codexView); if (this.codexView || trigger === 'manual') this.recordRefreshState('codex', true, trigger); return; } + // Only this host's explicit lifecycle cancellation is neutral. A + // cancelled worker alone is not trusted. A reported failed file still + // withdraws current evidence; the current facade's thrown/cancelled + // response reports zero because final file diagnostics are unavailable. + // Keep the defensive guard for richer producers. This branch preserves + // only prior accepted proof. Do not accept its display-only snapshot, + // timestamp a success, or issue a new achievement receipt. + if (this.codexWorkerCancellationRequested && result.outcome === 'error' && + result.failureCode === 'cancelled' && result.diagnostic?.failedFiles === 0) { + if (historicalAttempt && this.codexBackgroundState.status === 'running') { + this.codexBackgroundState = interruptBackgroundWork(this.codexBackgroundState, {now:Date.now()}); + await this.saveCodexBackgroundState(); + if (!ownsCurrentSource()) return; + } + this.outputChannel.appendLine('refresh: codex-cancelled=1; prior accepted snapshot retained'); + return; + } const refreshFailed = result.outcome === 'error' || (result.diagnostic?.failedFiles ?? 0) > 0; if (!refreshFailed || (!this.codexSkipPersistedHydration && !this.codexView && result.snapshot.coverage.totalFiles > 0)) { - this.applyCodexSnapshot(result.snapshot); + this.applyCodexSnapshot(result.snapshot, !refreshFailed); } if (!refreshFailed && this.codexBackgroundState.indexGeneration !== this.codexIndexGeneration(result.snapshot)) { @@ -3176,7 +3503,7 @@ export class ClaudeCodeUsageExtension { indexGeneration: this.codexIndexGeneration(result.snapshot), }); await this.saveCodexBackgroundState(); - if (!ownsCurrentSource()) return; + if (!ownsProducedResult()) return; } if (historicalAttempt && this.codexBackgroundState.status === 'running') { const complete = !this.codexHistoricalWorkPending(result.snapshot); @@ -3202,7 +3529,7 @@ export class ClaudeCodeUsageExtension { continueHistoricalWork = this.codexBackgroundState.status === 'eligible'; } await this.saveCodexBackgroundState(); - if (!ownsCurrentSource()) return; + if (!ownsProducedResult()) return; } if (refreshFailed) { this.recordRefreshState('codex', true, trigger); @@ -3212,6 +3539,10 @@ export class ClaudeCodeUsageExtension { // its checkpoint too. It is not evidence that the failed work resumed // or succeeded, and cannot clear the stale-data marker/time. this.recordRefreshState('codex', false, trigger); + const accept = () => this.webviewProvider.acceptCodexAchievementSnapshot?.( + this.codexView, this.codexAchievementSignals, achievementObservation, result.achievementUsageDelta); + if (calibration) accept(); else this.tryProviderUiUpdate(accept); + if (calibration) calibration.accepted = true; } else { this.recordRefreshState('codex', true, trigger); } @@ -3230,7 +3561,7 @@ export class ClaudeCodeUsageExtension { indexGeneration: this.codexIndexGeneration(result.snapshot), }); await this.saveCodexBackgroundState(); - if (!ownsCurrentSource()) return; + if (!ownsProducedResult()) return; continueHistoricalWork = true; } this.outputChannel.appendLine( @@ -3293,12 +3624,12 @@ export class ClaudeCodeUsageExtension { if (workerStoppedSafely && this.codexWorkerLease === ownedWorkerLease) { this.codexWorkerLease = undefined; } - if (ownsCurrentSource() && workerStoppedSafely && ownedBackfillLease) { + if (ownsProducedResult() && workerStoppedSafely && ownedBackfillLease) { this.codexFirstBackfillActive = false; } } } finally { - if (ownsCurrentSource()) { + if (ownsProducedResult()) { this.codexRefreshing = false; this.codexProgress = null; this.codexProgressLastRenderedAt = 0; @@ -3306,6 +3637,7 @@ export class ClaudeCodeUsageExtension { } if ( ownsCurrentSource() && + !calibration && continueHistoricalWork && provider === this.codexProvider && this.getConfiguration().codexEnabled && @@ -3335,8 +3667,10 @@ export class ClaudeCodeUsageExtension { } } - private applyCodexSnapshot(snapshot: CodexProviderSnapshot): void { - this.selectCodexSnapshotHome(this.codexHome(this.getConfiguration())); + private applyCodexSnapshot(snapshot: CodexProviderSnapshot, acceptedForAchievements = true): void { + const config = this.getConfiguration(); + this.selectCodexSnapshotHome(this.codexHome(config)); + if (!this.codexView) this.codexAchievementRevision = undefined; const nextView = buildCodexUsageView(snapshot); const capturedObservations = snapshot.weeklyValueInputs?.observations ?? []; const capturedQuotaFacts = codexQuotaCapturesFromWeeklyObservations( @@ -3367,6 +3701,45 @@ export class ClaudeCodeUsageExtension { this.codexView = nextView; this.codexInsights = buildScopedCodexInsights(nextView); } + // Display equality does not imply proof equality: period lineage markers + // and optional effort counters can change without changing any chart. + if (config.enableAchievements === false) { + this.codexAchievementRevision = undefined; + this.codexAchievementMoneyRevision = undefined; + this.codexAchievementSignals = EMPTY_CODEX_ACHIEVEMENT_SIGNALS; + } else { + const indexed = acceptedForAchievements && nextView.currentManifestVerified === true && nextView.coverage.complete; + const calendarComplete = indexed && nextView.coverage.identity.complete && + nextView.periodCoverage.allTime.complete; + // The provider may reuse a deeply immutable census after reconstructing + // current witnesses and comparing every field. Revision/calendar memoize + // only that publication; mutable snapshots still take full validation. + const revision = codexAchievementProofRevision(snapshot.files, { + indexed, calendarComplete, timeZone: nextView.periodCoverage.timeZone, + }); + if (this.codexAchievementRevision !== revision) { + this.codexAchievementSignals = codexModelSignals(snapshot.files); + this.codexAchievementSignals.effortStatistics = indexed + ? codexAchievementEffortStatistics(snapshot.files) : undefined; + this.codexAchievementSignals.tokenEvidence = codexAchievementTokenEvidence(snapshot.files, indexed); + this.codexAchievementSignals.dayEvidence = codexAchievementCalendarProjection( + snapshot.files, nextView.periodCoverage.timeZone, + indexed, calendarComplete, + ).evidence; + this.codexAchievementSignals.historyDayEvidence = indexed + ? codexAchievementHistoryEvidence(snapshot.files, nextView.periodCoverage.timeZone, indexed, calendarComplete) + : undefined; + this.codexAchievementRevision = revision; + this.codexAchievementMoneyRevision = undefined; + } + const moneyDay=dayKeyInZone(new Date(),nextView.periodCoverage.timeZone); + const moneyRevision=revision+'|'+moneyDay; + if (this.codexAchievementMoneyRevision!==moneyRevision) { + this.codexAchievementSignals.money=codexAchievementMoneyScopes({backend:'openai',asOfDay:moneyDay, + files:snapshot.files,complete:indexed,calendarComplete,dayEvidence:this.codexAchievementSignals.dayEvidence}); + this.codexAchievementMoneyRevision=moneyRevision; + } + } this.codexSkipPersistedHydration = false; this.codexAvailable = true; this.codexHasData = snapshot.coverage.totalFiles > 0; @@ -3430,6 +3803,7 @@ export class ClaudeCodeUsageExtension { 'showCost', 'showContext', 'statusBarMetric', 'showScopedWeekly', 'quotaFiveHourOnly', 'showResetInStatusBar', 'resetCountdownFormat', 'statusBarQuotaFormat', 'statusBarProvider', 'codex.statusMetric', + 'showWeeklyQuotaPace', ]); // Presentation-only dashboard toggles must not restart watchers, recreate @@ -3438,6 +3812,8 @@ export class ClaudeCodeUsageExtension { 'dashboardAutoRefresh', 'showWeeklyEquivalentValue', 'showProjectUsageMatrix', + 'enableAchievements', + 'showAchievementNotices', ]); // These values only reformat already-materialized USD estimates. They must @@ -3457,13 +3833,28 @@ export class ClaudeCodeUsageExtension { return; } if (key && ClaudeCodeUsageExtension.DASHBOARD_ONLY_SETTINGS.has(key)) { + if (key === 'showAchievementNotices' && this.settings.get(key) === false) { + this.webviewProvider.retireAchievementNotices?.(); + } + if (key === 'enableAchievements') { + this.achievementCalibrationGeneration = (this.achievementCalibrationGeneration ?? 0) + 1; + this.webviewProvider.retireAchievementUsageObservations?.(); + if (this.getConfiguration().enableAchievements === false) { + this.codexAchievementRevision = undefined; + this.codexAchievementSignals = EMPTY_CODEX_ACHIEVEMENT_SIGNALS; + } + this.codexProvider?.setAchievementHourProofEnabled?.( + this.getConfiguration().enableAchievements !== false); + } this.syncProviderUi(); return; } if (key && ClaudeCodeUsageExtension.STATUS_BAR_ONLY_SETTINGS.has(key)) { const config = this.getConfiguration(); - this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat); - this.statusBar.updateQuota(this.cache.usageLimits ?? null); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat, config.showWeeklyQuotaPace); + this.statusBar.updateQuota(this.cache.usageLimits ?? null, this.getClaudeQuotaPaceSnapshot()); + if (key === 'showWeeklyQuotaPace') this.webviewProvider.updateQuotaPace?.( + this.getClaudeQuotaPaceSnapshot(),this.cache.usageLimits ?? null); this.syncProviderUi(); return; } @@ -3530,10 +3921,11 @@ export class ClaudeCodeUsageExtension { setPricingBackend(config.pricingBackend); if (pricingBackendChanged) { this.invalidateClaudeUsagePricingCache(); - this.webviewProvider.invalidateShareCardPreview(); + this.webviewProvider.invalidateShareCardPreview({pricingChanged:true}); } this.applyFormattingConfiguration(config); - this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat, config.showWeeklyQuotaPace); + this.webviewProvider.updateQuotaPace?.(this.getClaudeQuotaPaceSnapshot(),this.cache.usageLimits ?? null); // Restart auto-refresh with new interval this.startAutoRefresh(); @@ -3544,6 +3936,21 @@ export class ClaudeCodeUsageExtension { this.stopCredentialsWatching(); this.selectClaudeProfile(config.dataDirectory); const retiringCodexProvider = this.codexProvider; + if (this.codexView) { + // A replacement provider has not verified the published display data. + // Withdraw award proof before any asynchronous retirement or blur return. + this.codexAchievementRevision = undefined; + this.codexAchievementMoneyRevision = undefined; + this.codexAchievementSignals = {...this.codexAchievementSignals,effortStatistics:undefined}; + this.codexView = { + ...this.codexView, + currentManifestVerified: false, + ...(this.codexView.outputHourProof ? { + outputHourProof: { ...this.codexView.outputHourProof, complete: false, hours: [] }, + } : {}), + }; + this.syncProviderUiSafely('settings'); + } this.codexWorkerCancellationRequested = true; retiringCodexProvider.cancel(); this.trackCodexProviderRetirement(retiringCodexProvider); @@ -3780,7 +4187,8 @@ export class ClaudeCodeUsageExtension { if (!recoveryAttempt) { this.resetWatcherRecovery('claude', 'cancelled'); } - const requestGeneration = ++this.fileWatcherGeneration; + const requestGeneration = ++this.fileWatcherSetupGeneration; + const installedGeneration = this.fileWatcherGeneration; if (!this.windowActivity.focused) { this.stopFileWatching('window-blur'); return; @@ -3794,14 +4202,18 @@ export class ClaudeCodeUsageExtension { try { dataDirectory = await ClaudeDataLoader.findClaudeDataDirectory(config.dataDirectory || undefined); } catch (error) { - if (requestGeneration === this.fileWatcherGeneration) { + if ( + requestGeneration === this.fileWatcherSetupGeneration && + installedGeneration === this.fileWatcherGeneration + ) { this.scheduleWatcherRecovery('claude', error); } return; } if ( this.disposed || - requestGeneration !== this.fileWatcherGeneration + requestGeneration !== this.fileWatcherSetupGeneration || + installedGeneration !== this.fileWatcherGeneration ) { return; } @@ -3879,6 +4291,7 @@ export class ClaudeCodeUsageExtension { 'cancelled' | 'window-blur' | 'feature-disabled' | 'extension-dispose' | 'settings-change' | 'profile-change' > = 'settings-change', ): void { + this.fileWatcherSetupGeneration += 1; this.fileWatcherGeneration += 1; this.watchDebounce.clear(); const watcher = this.fileWatcher; @@ -3946,7 +4359,7 @@ export class ClaudeCodeUsageExtension { (_event, filename) => { if (this.disposed || activeGeneration !== this.codexWatcherGeneration) return; this.markWatcherHealthy('codex'); - if (!filename || !String(filename).endsWith('.jsonl')) { + if (!filename || !/\.jsonl(?:\.zst)?$/.test(String(filename))) { return; } const delaySeconds = this.getConfiguration().codexFileWatchSeconds; @@ -4161,6 +4574,8 @@ export class ClaudeCodeUsageExtension { this.cache.usageLimitsBackoffUntil = new Date(0); this.claudeProfileGeneration += 1; this.activeClaudeQuotaFingerprint = undefined; + this.statusBar.updateQuota(this.cache.usageLimits ?? null, null); + this.webviewProvider.updateQuotaPace?.(null); this.claudeWeeklyQuotaHistory = []; this.webviewProvider.updateWeeklyQuotaHistory([]); void this.refreshData(false, 'credentials'); @@ -4251,6 +4666,7 @@ export class ClaudeCodeUsageExtension { private handleWindowFocusChange(focused: boolean): void { if (this.disposed) return; + this.webviewProvider.setAchievementWindowFocused?.(focused); const transition = this.windowActivity.update(focused); if (transition === 'suspend') { this.suspendRecurringWork(); @@ -4447,6 +4863,20 @@ export class ClaudeCodeUsageExtension { if (!this.disposed) this.syncProviderUiSafely('poll'); } + /** Existing successful quota only. OFF performs no source/clock/storage work. + * A zero cache time explicitly retires even a previously verified response. + */ + private getClaudeQuotaPaceSnapshot(): QuotaPaceAdapterSnapshot | null { + if (this.settings.get('showWeeklyQuotaPace') !== true || + this.settings.get('usageLimitTracking') !== true) return null; + const observedAt = this.cache.usageLimitsLastUpdate.getTime(); + if (!(observedAt > 0) || !this.cache.usageLimits) return null; + const signal = this.apiClient.getLastQuotaIdentitySignal(); + return this.claudeQuotaPaceSource.project({enabled: true, usage: this.cache.usageLimits, + observedAt, verifiedAccountFingerprint: signal + ? fingerprintForStableIdentity(this.quotaFingerprintSalt, 'claude', signal) ?? null : null}); + } + private async maybeFetchUsageLimits(config: ExtensionConfig): Promise { if ( this.disposed || @@ -4530,7 +4960,7 @@ export class ClaudeCodeUsageExtension { if (fetched) { const observedAt = Date.now(); this.cache.usageLimits = fetched; - this.cache.usageLimitsLastUpdate = new Date(); + this.cache.usageLimitsLastUpdate = new Date(observedAt); this.cache.usageLimitsFailStreak = 0; // Even on success, hold off /usage for 30s — this floor also covers the // expired-window bypass so a just-rolled window can't trigger an immediate @@ -4575,7 +5005,18 @@ export class ClaudeCodeUsageExtension { this.coalescedTriggersSinceRefresh += 1; return; } - await this.runRefresh(request); + await this.startClaudeRefresh(request); + } + + private startClaudeRefresh(request: RefreshRequest & { + localOnly?: boolean; calibration?: AchievementCalibrationRefresh; + }): Promise { + const operation = this.runRefresh(request); + this.claudeRefreshDrain = operation; + void operation.finally(() => { + if (this.claudeRefreshDrain === operation) this.claudeRefreshDrain = null; + }).catch(() => undefined); + return operation; } private handleColdRefreshFailure(updateWebview: boolean, trigger: RefreshTrigger = 'poll'): void { @@ -4612,6 +5053,7 @@ export class ClaudeCodeUsageExtension { private materializeClaudeDashboard(index: ClaudeUsageIndex, config: ExtensionConfig, now: Date): ReturnType { const next = claudeUsageDashboardSnapshot(index, { + achievementMoneyBackend: config.enableAchievements === false ? undefined : config.pricingBackend, workspacePath: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, projectGroupingMode: config.projectGroupingMode, contextWindowOverride: config.contextWindowOverride, @@ -4621,7 +5063,9 @@ export class ClaudeCodeUsageExtension { return this.claudeRenderSnapshot!; } - private async runRefresh(request: RefreshRequest): Promise { + private async runRefresh(request: RefreshRequest & { + localOnly?: boolean; calibration?: AchievementCalibrationRefresh; + }): Promise { const totalStarted = performance.now(); const watcherEvents = this.watcherEventsSinceRefresh ?? 0; const coalescedTriggers = this.coalescedTriggersSinceRefresh ?? 0; @@ -4632,16 +5076,39 @@ export class ClaudeCodeUsageExtension { this.credentialsWatcherMissingFilenameEventsSinceRefresh = 0; let updateWebview = request.trigger === 'manual'; let current = () => !this.disposed; + let indexCurrent = () => false; + let failureStage: ClaudeRefreshStage = 'discovery'; + const observed: Partial = {}; + const recovery = this.claudeRefreshRecovery ??= new ClaudeRefreshRecovery(); + let recoveryRevision: string | undefined; + const reportFailure = (reason: RefreshDiagnostic['failureReason']): void => { + if (request.calibration) request.calibration.accepted = false; + if (recoveryRevision !== undefined) recovery.fail(recoveryRevision); + const budget = this.claudeFailureDiagnosticBudget ??= new ClaudeFailureDiagnosticBudget(); + if (!budget.accept(failureStage, reason)) return; + this.outputChannel.appendLine(formatRefreshDiagnostic({ + trigger: request.trigger, filesDiscovered: null, filesChanged: null, filesReused: null, + filesRemoved: null, filesFailed: null, bytesRead: null, linesParsed: null, + bodyReads: null, aggregateMutations: null, manifestMs: null, readParseMs: null, + aggregateRenderMs: null, ...observed, + watcherEvents, coalescedTriggers, quotaWatcherMissingFilenameEvents, + failureStage, failureReason: reason, totalMs: performance.now() - totalStarted, + })); + }; try { - if (this.disposed) return; + if (this.disposed || (request.calibration && !request.calibration.current())) return; const config = this.getConfiguration(); this.selectClaudeUsageSource(config.dataDirectory); const generation = this.configurationGeneration; + const achievementGeneration = this.achievementCalibrationGeneration ?? 0; const source = this.claudeUsageSource; let indexGeneration = this.claudeIndexGeneration ?? 0; - const indexCurrent = () => !this.disposed && !this.clearingAllLocalData && + const indexSourceCurrent = () => !this.disposed && !this.clearingAllLocalData && !this.localDataClearedRequiresReload && indexGeneration === (this.claudeIndexGeneration ?? 0) && - source === this.claudeUsageSourceKey(this.getConfiguration().dataDirectory); + source === this.claudeUsageSourceKey(this.getConfiguration().dataDirectory) && + (!request.calibration || (achievementGeneration === (this.achievementCalibrationGeneration ?? 0) && + this.getConfiguration().enableAchievements !== false)); + indexCurrent = () => indexSourceCurrent() && (!request.calibration || request.calibration.current()); current = () => indexCurrent() && generation === this.configurationGeneration; const snapshotNow = new Date(Date.now()); updateWebview = this.shouldDeliverDashboard(request.trigger) || @@ -4649,16 +5116,28 @@ export class ClaudeCodeUsageExtension { // Account quota is independent from local JSONL. Do not let a slow OAuth // request delay the local usage refresh. - void this.maybeFetchUsageLimits(config).then((limits) => { + if (shouldFetchQuotaForRefresh(request)) void this.maybeFetchUsageLimits(config).then((limits) => { if (!current()) return; - this.statusBar.updateQuota(limits); + this.statusBar.updateQuota(limits, this.getClaudeQuotaPaceSnapshot()); this.webviewProvider.updateQuota(limits); + this.webviewProvider.updateQuotaPace?.(this.getClaudeQuotaPaceSnapshot(), this.cache.usageLimits ?? null); if (!limits && !this.cache.usageLimits && !this.quotaColdRetryDone) { this.quotaColdRetryDone = true; this.scheduleQuotaColdRetry(); } }).catch(() => undefined); + const timeZone = resolveTimeZone(''); + const revisionForIndex = () => `${indexGeneration}:${config.enableContentAnalysis ? 1 : 0}:` + + `${config.enableContentAnalysis ? config.advicePromptWindowDays : 0}:${timeZone}`; + recoveryRevision = revisionForIndex(); + if (!recovery.admit(recoveryRevision, request.trigger)) { + // Keep the last verified numbers and failed authority unchanged. An + // explicit rapid retry gets feedback, but not another scan/body read. + if (request.trigger === 'manual') this.publishRefreshState('claude', request.trigger, true); + return; + } + const dataDirectory = await ClaudeDataLoader.findClaudeDataDirectory( config.dataDirectory || undefined ); @@ -4668,23 +5147,7 @@ export class ClaudeCodeUsageExtension { } if (!dataDirectory) { this.handleColdRefreshFailure(updateWebview, request.trigger); - this.outputChannel.appendLine(formatRefreshDiagnostic({ - trigger: request.trigger, - filesDiscovered: 0, - filesChanged: 0, - filesReused: 0, - filesRemoved: 0, - filesFailed: 0, - bytesRead: 0, - linesParsed: 0, - watcherEvents, - coalescedTriggers, - quotaWatcherMissingFilenameEvents, - manifestMs: 0, - readParseMs: 0, - aggregateRenderMs: 0, - totalMs: performance.now() - totalStarted, - })); + reportFailure('source-unavailable'); return; } @@ -4693,7 +5156,10 @@ export class ClaudeCodeUsageExtension { path.resolve(this.cache.dataDirectory) !== path.resolve(dataDirectory)) { this.clearClaudeUsageSource(); indexGeneration = this.claudeIndexGeneration; + recoveryRevision = revisionForIndex(); + recovery.admit(recoveryRevision, request.trigger); } + failureStage = 'manifest'; const manifest = await scanUsageManifest([dataDirectory]); if (!current()) { if (!this.disposed) this.selectClaudeUsageSource(this.getConfiguration().dataDirectory); @@ -4701,6 +5167,9 @@ export class ClaudeCodeUsageExtension { } const delta = diffUsageManifests(this.cache.manifest, manifest); const manifestMs = performance.now() - manifestStarted; + Object.assign(observed, { filesDiscovered: manifest.entries.size, + filesChanged: delta.changed.length, filesReused: delta.reused.length, + filesRemoved: delta.removed.length, manifestMs }); const directoryChanged = this.cache.dataDirectory !== dataDirectory; const needFullRefresh = shouldReloadUsage({ forceReload: request.forceReload, @@ -4710,7 +5179,31 @@ export class ClaudeCodeUsageExtension { removedFiles: delta.removed.length, }); + let achievementObservation: Awaited>; + try { + achievementObservation = await this.webviewProvider.beginClaudeAchievementObservation?.(indexCurrent, indexSourceCurrent); + } catch (error) { + if (request.calibration) throw error; + /* Receipt provenance is optional; ordinary usage refresh must continue. */ + } + if (!indexCurrent()) return; + const handoffAchievements = (materialized: ReturnType, + records: ClaudeUsageRecord[], timeZone: string, + proof?: ClaudeUsageIndexUpdateResult['achievementUsageDelta']) => { + // Capture this accepted index, without building a set or consuming the + // uncapped rows on warm refresh. Explicit calibration replays them later. + const sessionRows = this.cache.claudeIndex.sessionRows; + const accept = () => this.webviewProvider.acceptClaudeAchievementSnapshot?.({ + allTime: records.length ? materialized.allTime : null, + days: materialized.dailyForAllTime, sessions: materialized.sessions, + sessionEvidence: materialized.achievementSessionEvidence, records, timeZone, money: materialized.achievementMoney, + sessionHistoryEligibility: () => sessionRows.values(), + }, achievementObservation, proof); + if (request.calibration) accept(); else this.tryProviderUiUpdate(accept); + }; + if (!needFullRefresh) { + failureStage = 'materialize'; const materialized = this.materializeClaudeDashboard(this.cache.claudeIndex, config, snapshotNow); this.statusBar.updateContext(materialized.context); const timeZone = this.cache.claudeIndex.timeZone; @@ -4749,6 +5242,9 @@ export class ClaudeCodeUsageExtension { materialized.hourlyForLast30DaysByDay, materialized.projectUsageMatrix, materialized.dailyForAllTime, + materialized.achievementSessionEvidence, + timeZone, + materialized.achievementMoney, ); this.claudeDashboardHydrated = true; }); @@ -4757,7 +5253,10 @@ export class ClaudeCodeUsageExtension { } this.cache.manifest = manifest; this.cache.dataDirectory = dataDirectory; + handoffAchievements(materialized, this.cache.records, this.cache.claudeIndex.timeZone); this.recordRefreshState('claude', false, request.trigger); + if (request.calibration) request.calibration.accepted = true; + recovery.succeed(recoveryRevision); this.outputChannel.appendLine(formatRefreshDiagnostic({ trigger: request.trigger, filesDiscovered: manifest.entries.size, @@ -4767,6 +5266,10 @@ export class ClaudeCodeUsageExtension { filesFailed: 0, bytesRead: 0, linesParsed: 0, + // No index pass occurs on this unchanged-manifest path: no source + // bodies are opened and the existing aggregates are reused. + bodyReads: 0, + aggregateMutations: 0, watcherEvents, coalescedTriggers, quotaWatcherMissingFilenameEvents, @@ -4790,15 +5293,29 @@ export class ClaudeCodeUsageExtension { const baseIndex = directoryChanged ? createClaudeUsageIndex() : this.cache.claudeIndex; + failureStage = 'planning'; const loaded = await updateClaudeUsageIndex(baseIndex, dataDirectory, { analyzeContent: config.enableContentAnalysis, windowDays: config.advicePromptWindowDays, manifest, + beforeBodyReads: async () => { + if (!indexCurrent()) throw new Error('achievement-calibration:source-retired'); + }, + // Explicit rebuilds remain quiet even if their underlying index happens + // to reuse an append plan. Only an ordinary accepted observation proves live origin. + achievementUsageWindow: request.forceReload ? undefined : achievementObservation?.window, + onStage: (stage, counters) => { + failureStage = stage; + if (counters) Object.assign(observed, counters); + }, log: (line) => this.outputChannel.appendLine( `[${new Date().toLocaleTimeString(undefined, { hour12: false })}] ${line}` ), }); - const commitLoadedIndex = () => commitRefreshSnapshot( + Object.assign(observed, loaded.diagnostics); + // The completed in-memory producer result is not another scan. Preserve + // its verified native delta under source authority even after UI Cancel. + const commitLoadedIndex = () => indexSourceCurrent() && commitRefreshSnapshot( manifest, { records: loaded.records, contentAnalysis: loaded.contentAnalysis }, loaded.diagnostics.filesFailed, @@ -4816,37 +5333,45 @@ export class ClaudeCodeUsageExtension { // same-source work. The queued settings refresh can reconcile from this // index without repeating a cold read. Source, prices, clear-all and // disposal still revoke the index itself, including A -> B -> A changes. - if (indexCurrent()) commitLoadedIndex(); + if (indexSourceCurrent()) { + if (shouldCommitUsageLoad(loaded.diagnostics.filesFailed)) { + failureStage = 'materialize'; + const materialized = this.materializeClaudeDashboard(loaded.index, config, snapshotNow); + if (commitLoadedIndex()) { + handoffAchievements(materialized, loaded.records, loaded.index.timeZone, loaded.achievementUsageDelta); + if (request.calibration) { + this.recordRefreshState('claude', false, request.trigger); + request.calibration.accepted = true; + } + recovery.succeed(recoveryRevision); + } + } else { + // A presentation generation cannot erase a verified same-source + // failure or let the queued settings pass re-read it immediately. + this.handleColdRefreshFailure(this.shouldDeliverDashboard(request.trigger), request.trigger); + reportFailure(loaded.diagnostics.failureReason ?? 'read-failed'); + } + } if (!this.disposed) this.selectClaudeUsageSource(this.getConfiguration().dataDirectory); return; } if (!shouldCommitUsageLoad(loaded.diagnostics.filesFailed)) { this.handleColdRefreshFailure(updateWebview, request.trigger); - this.outputChannel.appendLine(formatRefreshDiagnostic({ - trigger: request.trigger, - filesDiscovered: manifest.entries.size, - filesChanged: delta.changed.length, - filesReused: delta.reused.length, - filesRemoved: delta.removed.length, - filesFailed: loaded.diagnostics.filesFailed, - bytesRead: loaded.diagnostics.bytesRead, - linesParsed: loaded.diagnostics.linesParsed, - bodyReads: loaded.diagnostics.bodyReads, - aggregateMutations: loaded.diagnostics.aggregateMutations, - watcherEvents, - coalescedTriggers, - quotaWatcherMissingFilenameEvents, - manifestMs, - readParseMs: loaded.diagnostics.readParseMs, - aggregateRenderMs: 0, - totalMs: performance.now() - totalStarted, - })); + reportFailure(loaded.diagnostics.failureReason ?? 'read-failed'); return; } const aggregateStarted = performance.now(); + failureStage = 'materialize'; const records = loaded.records; const contentAnalysis = loaded.contentAnalysis; + const materialized = this.materializeClaudeDashboard(loaded.index, config, snapshotNow); + failureStage = 'commit'; + if (!commitLoadedIndex()) return; + // Accepted-source observation is committed before either paused or live + // presentation. Rendering failure cannot manufacture or erase its provenance. + handoffAchievements(materialized, records, loaded.index.timeZone, loaded.achievementUsageDelta); + failureStage = 'materialize'; if (records.length === 0) { const error = 'No usage records found. Make sure Claude Code is running.'; @@ -4859,7 +5384,6 @@ export class ClaudeCodeUsageExtension { }); } } else { - const materialized = this.materializeClaudeDashboard(loaded.index, config, snapshotNow); const sessionData = materialized.session; const todayData = materialized.today; const workspaceTodayData = materialized.workspaceToday; @@ -4883,15 +5407,18 @@ export class ClaudeCodeUsageExtension { this.statusBar.updateContext(materialized.context); if (updateWebview) { this.tryProviderUiUpdate(() => { - this.webviewProvider.updateData(sessionData, todayData, rolling30Data, allTimeData, dailyDataForRolling30, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages, hourlyDataForRolling30DaysByDay, materialized.projectUsageMatrix, materialized.dailyForAllTime); + this.webviewProvider.updateData(sessionData, todayData, rolling30Data, allTimeData, dailyDataForRolling30, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages, hourlyDataForRolling30DaysByDay, materialized.projectUsageMatrix, materialized.dailyForAllTime, materialized.achievementSessionEvidence, loaded.index.timeZone, materialized.achievementMoney); this.claudeDashboardHydrated = true; }); } } const aggregateRenderMs = performance.now() - aggregateStarted; - commitLoadedIndex(); + observed.aggregateRenderMs = aggregateRenderMs; + failureStage = 'commit'; this.recordRefreshState('claude', false, request.trigger); + if (request.calibration) request.calibration.accepted = true; + recovery.succeed(recoveryRevision); this.outputChannel.appendLine(formatRefreshDiagnostic({ trigger: request.trigger, filesDiscovered: manifest.entries.size, @@ -4911,36 +5438,24 @@ export class ClaudeCodeUsageExtension { aggregateRenderMs, totalMs: performance.now() - totalStarted, })); - } catch { + } catch (error) { if (!current()) { + if (indexCurrent()) { + this.handleColdRefreshFailure(this.shouldDeliverDashboard(request.trigger), request.trigger); + reportFailure(claudeRefreshFailureReason(error)); + } if (!this.disposed) this.selectClaudeUsageSource(this.getConfiguration().dataDirectory); return; } - // Keep the previous records and manifest authoritative. The next trigger - // retries scanner/reconciliation failures instead of presenting no data. + // Keep verified data authoritative. Report the actual failing phase, + // not a fabricated scan/file failure, without exposing arbitrary errors. this.handleColdRefreshFailure(updateWebview, request.trigger); - this.outputChannel.appendLine(formatRefreshDiagnostic({ - trigger: request.trigger, - filesDiscovered: 0, - filesChanged: 0, - filesReused: 0, - filesRemoved: 0, - filesFailed: 1, - bytesRead: 0, - linesParsed: 0, - watcherEvents, - coalescedTriggers, - quotaWatcherMissingFilenameEvents, - manifestMs: 0, - readParseMs: 0, - aggregateRenderMs: 0, - totalMs: performance.now() - totalStarted, - })); + reportFailure(claudeRefreshFailureReason(error)); } finally { if (!this.disposed) this.syncProviderUiSafely(request.trigger); const next = this.refreshGate.complete(); if (!this.disposed && next !== null) { - queueMicrotask(() => void this.runRefresh(next)); + queueMicrotask(() => void this.startClaudeRefresh(next)); } } } diff --git a/src/heatmapSvg.ts b/src/heatmapSvg.ts index c05d2579..35a220f2 100644 --- a/src/heatmapSvg.ts +++ b/src/heatmapSvg.ts @@ -40,6 +40,8 @@ export interface HeatmapSvgOptions { ariaLabel?: string; locale?: string; tooltip?: (dateISO: string, usage: DayUsage, value: number, metric: HeatMetric) => string; + /** Optional missing/partial-data styling; absent preserves legacy SVG bytes. */ + cellStyle?: (dateISO: string) => { fill?: string; stroke?: string; dashed?: boolean } | undefined; } function lowerBound(sorted: readonly number[], value: number): number { @@ -303,8 +305,14 @@ export function renderHeatmapSvg(daily: Record, opts: HeatmapS : metric === 'cost' ? `$${compactNum(c.value, locale)} ${copy.on} ${when}` : `${compactNum(c.value, locale)} ${noun} ${copy.on} ${when}`; + const style = opts.cellStyle?.(c.dateISO); + const fill = safeSvgColor(style?.fill, scale[c.bucket]); + const stroke = style?.stroke === undefined + ? '' + : ` stroke="${safeSvgColor(style.stroke, 'transparent')}"`; + const dash = style?.dashed === true ? ' stroke-dasharray="2 2"' : ''; parts.push( - `${esc(tip)}` + `${esc(tip)}` ); } diff --git a/src/i18n.ts b/src/i18n.ts index 61a28fc5..26a21247 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -5,6 +5,1674 @@ import { resolveCurrencyDisplay, } from './currencyDisplay'; import { CODEX_COPY_EN, CodexViewCopy } from './codexView'; +import type { AchievementId } from './achievements'; +import { API_ACHIEVEMENT_DEFINITIONS, type AchievementMoneyId } from './achievementMoneyCatalog'; +import { API_HEATMAP_COPY } from './apiHeatmapCopy'; +import { LONG_TERM_ACHIEVEMENT_DEFINITIONS, type LongTermAchievementId } from './achievementLongTerm'; + +export function achievementEvidenceHintCopyFor(locale:string):Record<'calendar'|'lineage'|'money'|'index'|'stale',string> { + const words:Record={ + en:['Dates, time zone and session ownership must be verified. Cumulative Tokens cannot establish daily or weekly progress.', + 'Session ownership needs verification; unknown or conflicting branches are excluded.', + 'Only priced usage verified for this scope counts. A priced lifetime total cannot establish a weekly or single-task amount.', + 'Waiting for current-source verification. Cached totals alone cannot establish new achievements.', + 'The current source is not verified. Refresh to retry; saved awards remain, while new awards are paused.'], + 'zh-CN':['日期、时区与会话归属须通过核验;累计 Token 不能代替按日或按周的进度。', + '会话归属尚待核验;来源不明或存在冲突的分支不计入。', + '仅统计本范围内已定价且可核验的用量;累计金额不能代替单周或单次任务金额。', + '等待当前来源核验;缓存中的旧总量不作为新成就依据。', + '当前来源尚未通过重新核验。可刷新重试;已获徽章保留,新增授奖暂停。'], + 'zh-TW':['日期、時區與會話歸屬須通過核驗;累計 Token 不能代替按日或按週的進度。', + '會話歸屬尚待核驗;來源不明或存在衝突的分支不計入。', + '僅統計本範圍內已定價且可核驗的用量;累計金額不能代替單週或單次任務金額。', + '等待目前來源核驗;快取中的舊總量不作為新成就依據。', + '目前來源尚未通過重新核驗。可重新整理再試;已獲徽章保留,新增授獎暫停。'], + 'de-DE':['Datum, Zeitzone und Sitzungszuordnung müssen geprüft sein. Kumulative Tokens belegen keine Tages- oder Wochenfortschritte.', + 'Die Sitzungszuordnung muss geprüft werden; unbekannte oder widersprüchliche Zweige sind ausgeschlossen.', + 'Nur bepreiste, für diesen Bereich geprüfte Nutzung zählt. Eine Gesamtsumme belegt keinen Wochen- oder Aufgabenbetrag.', + 'Die aktuelle Quelle wird geprüft. Zwischengespeicherte Summen allein belegen keine neuen Erfolge.', + 'Die aktuelle Quelle ist nicht geprüft. Aktualisieren Sie erneut; gespeicherte Auszeichnungen bleiben, neue sind ausgesetzt.'], + ja:['日付・タイムゾーン・セッションの所属を検証する必要があります。累計 Token は日別・週別の進捗の根拠にはなりません。', + 'セッションの所属を検証する必要があります。不明または競合する分岐は除外されます。', + 'この範囲で料金と根拠を確認できる使用量のみを集計します。累計金額は週別・タスク別金額の根拠にはなりません。', + '現在のデータソースの検証を待っています。キャッシュ済み合計だけでは新しい実績を付与しません。', + '現在のデータソースは未検証です。更新して再試行してください。取得済みのバッジは保持し、新規付与は一時停止します。'], + ko:['날짜, 시간대, 세션 소속을 검증해야 합니다. 누적 Token은 일별 또는 주별 진행의 근거가 아닙니다.', + '세션 소속을 검증해야 합니다. 알 수 없거나 충돌하는 분기는 제외합니다.', + '이 범위에서 가격과 근거가 검증된 사용량만 집계합니다. 누적 금액은 주별 또는 작업별 금액의 근거가 아닙니다.', + '현재 데이터 소스 검증을 기다립니다. 캐시된 합계만으로 새 업적을 부여하지 않습니다.', + '현재 데이터 소스가 검증되지 않았습니다. 새로 고쳐 다시 시도하세요. 기존 배지는 유지하고 새 수여는 일시 중지합니다.'], + 'pt-BR':['Datas, fuso horário e vínculo das sessões precisam ser verificados. Tokens acumulados não comprovam progresso diário ou semanal.', + 'O vínculo das sessões precisa ser verificado; ramos desconhecidos ou conflitantes são excluídos.', + 'Conta apenas o uso com preço e evidência verificados neste escopo. O total acumulado não comprova um valor semanal ou por tarefa.', + 'Aguardando a verificação da fonte atual. Totais em cache não comprovam novas conquistas.', + 'A fonte atual não está verificada. Atualize para tentar novamente; os prêmios salvos permanecem e novas concessões são suspensas.'], + id:['Tanggal, zona waktu, dan kepemilikan sesi harus diverifikasi. Token kumulatif tidak membuktikan progres harian atau mingguan.', + 'Kepemilikan sesi perlu diverifikasi; cabang yang tidak diketahui atau bertentangan dikecualikan.', + 'Hanya penggunaan berharga yang terverifikasi dalam cakupan ini dihitung. Total kumulatif tidak membuktikan nilai mingguan atau per tugas.', + 'Menunggu verifikasi sumber saat ini. Total dalam cache saja tidak membuktikan pencapaian baru.', + 'Sumber saat ini belum terverifikasi. Muat ulang data untuk mencoba lagi; penghargaan tersimpan tetap ada, pemberian baru ditunda.'], + }; + const [calendar,lineage,money,index,stale]=words[locale as SupportedLanguage] ?? words.en; + return {calendar,lineage,money,index,stale}; +} + +export function achievementRecordStatusCopyFor(locale:string):Record<'loading'|'unavailable'|'changed'|'disabled',string> { + const words:Record={ + en:['Loading achievement records.','Achievement records are unavailable. Reload this VS Code window to retry.','Achievement records have changed. Reload this VS Code window to continue.','Local achievements are disabled. Enable them in Achievement settings.'], + 'zh-CN':['正在加载成就记录。','成就记录暂不可用。请重新加载 VS Code 窗口后重试。','成就记录状态已变更。请重新加载 VS Code 窗口后继续。','本地成就已关闭。可在成就设置中启用。'], + 'zh-TW':['正在載入成就記錄。','成就記錄暫時無法使用。請重新載入 VS Code 視窗後重試。','成就記錄狀態已變更。請重新載入 VS Code 視窗後繼續。','本機成就已關閉。可在成就設定中啟用。'], + 'de-DE':['Erfolgsaufzeichnungen werden geladen.','Erfolgsaufzeichnungen sind nicht verfügbar. Laden Sie dieses VS Code-Fenster erneut.','Erfolgsaufzeichnungen haben sich geändert. Laden Sie dieses VS Code-Fenster erneut, um fortzufahren.','Lokale Erfolge sind deaktiviert. Aktivieren Sie sie in den Erfolgseinstellungen.'], + ja:['実績の記録を読み込んでいます。','実績の記録を利用できません。VS Code のウィンドウを再読み込みしてください。','実績の記録が変更されました。続行するには VS Code のウィンドウを再読み込みしてください。','ローカル実績は無効です。実績設定で有効にできます。'], + ko:['업적 기록을 불러오는 중입니다.','업적 기록을 사용할 수 없습니다. VS Code 창을 다시 불러오세요.','업적 기록이 변경되었습니다. 계속하려면 VS Code 창을 다시 불러오세요.','로컬 업적이 꺼져 있습니다. 업적 설정에서 켤 수 있습니다.'], + 'pt-BR':['Carregando registros de conquistas.','Os registros de conquistas estão indisponíveis. Recarregue esta janela do VS Code para tentar novamente.','Os registros de conquistas mudaram. Recarregue esta janela do VS Code para continuar.','As conquistas locais estão desativadas. Ative-as nas configurações de conquistas.'], + id:['Memuat catatan pencapaian.','Catatan pencapaian tidak tersedia. Muat ulang jendela VS Code ini untuk mencoba lagi.','Catatan pencapaian telah berubah. Muat ulang jendela VS Code ini untuk melanjutkan.','Pencapaian lokal dinonaktifkan. Aktifkan di pengaturan pencapaian.'], + }; + const [loading,unavailable,changed,disabled]=words[locale as SupportedLanguage] ?? words.en; + return {loading,unavailable,changed,disabled}; +} + +function apiAchievementTitles(locale: SupportedLanguage): Record { + const themes:Record = { + en:['API-equivalent total','Weekly API equivalent','Session API equivalent','Task API equivalent'], + 'zh-CN':['API 等价积累','单周 API 等价','单次会话 API 等价','单次任务 API 等价'], + 'zh-TW':['API 等效累積','單週 API 等效','單次工作階段 API 等效','單次任務 API 等效'], + 'de-DE':['API-Gegenwert gesamt','API-Gegenwert je Woche','API-Gegenwert je Sitzung','API-Gegenwert je Aufgabe'], + ja:['API 等価の累積','週間 API 等価','セッション API 等価','タスク API 等価'], + ko:['누적 API 등가','주간 API 등가','세션 API 등가','작업 API 등가'], + 'pt-BR':['Equivalente API acumulado','Equivalente API semanal','Equivalente API por sessão','Equivalente API por tarefa'], + id:['Total ekuivalen API','Ekuivalen API mingguan','Ekuivalen API per sesi','Ekuivalen API per tugas'], + }; + const levels:Record = { + en:['Bronze','Silver','Gold','Platinum','Legendary'], 'zh-CN':['铜','银','金','铂金','传奇'], + 'zh-TW':['銅','銀','金','鉑金','傳奇'], 'de-DE':['Bronze','Silber','Gold','Platin','Legendär'], + ja:['ブロンズ','シルバー','ゴールド','プラチナ','レジェンド'],ko:['브론즈','실버','골드','플래티넘','전설'], + 'pt-BR':['Bronze','Prata','Ouro','Platina','Lendário'],id:['Perunggu','Perak','Emas','Platina','Legendaris'], + }; + return Object.fromEntries(API_ACHIEVEMENT_DEFINITIONS.map(row=>{ + const theme=row.scope==='cumulative' ? 0 : row.scope==='completedMondayWeekPeak' ? 1 : row.group==='claude' ? 2 : 3; + const parts=row.id.split('-'); + const rank=['bronze','silver','gold','platinum','legendary'].indexOf(parts[parts.length-1]); + return [row.id,(row.group==='claude' ? 'Claude' : 'Codex')+' '+themes[locale][theme]+' · '+levels[locale][rank]]; + })) as Record; +} + +export interface AchievementCopy { + tab: string; + heading: string; + intro: string; + evidenceTitle: string; + evidencePending: string; + evidenceStale: string; + lowerBoundNote: string; + lowerBoundDetails: string; + earned: string; + locked: string; + nextTarget: string; + nextStage: string; + myBadges: string; + nextGoals: string; + galleryTitle: string; + viewBadge: string; + recentAwards: string; + recordHistory: string; + recordedDay: string; + historicalAward: string; + pinBadge: string; + unpinBadge: string; + pinLimit: string; + pinFailed: string; + hiddenPins: string; + unreadAwards: string; + unreadGroup: string; + readGroup: string; + readFailed: string; + seriesCount: string; + levelCount: string; + shownCount: string; + filterLabel: string; + filterAll: string; + filterNext: string; + filterEarned: string; + filterEmpty: string; + settingsTitle: string; + allLevels: string; + complete: string; + shareTitle: string; + shareIntro: string; + shareChoose: string; + shareEmpty: string; + shareSelect: string; + shareLimit: string; + sharePending: string; + shareStale: string; + shareFailed: string; + shareExportFailed: string; + shareSaved: string; + shareCancelled: string; + shareSaving: string; + shareSelectionChanged: string; + shareBack: string; + unlockedNotice: string; + unlockedBatch: string; + upgradeOn: string; + upgradeOff: string; + openAchievements: string; + disableAchievements: string; + disableFailed: string; + groups: { claude: string; codex: string; combined: string; plugin: string }; + levels: readonly [string, string, string, string, string]; + milestone: string; + streakTheme: string; + sharedDaysTheme: string; + featureJourney: string; + journeySteps: readonly [string, string, string]; + stepRecorded: string; + stepUnrecorded: string; + viewCurrentModels: string; + viewCurrentTokens: string; + backToBadge: string; + units: { + outputTokens: string; + cacheReadTokens: string; + cacheCreationTokens: string; + encounter: string; + models: string; + roots: string; + childSessions: string; + efforts: string; + days: string; + sessions: string; + outputSessions: string; + hours: string; + tokens: string; + agents: string; + agentRoots: string; + streak: string; + families: string; + threeFamilyDay: string; + /** All seven family-day series, including Fable, share this qualification. */ + qualifiedDays: string; + sharedDays: string; + weeks: string; + weeklyAverage: string; + codexCachedTokens: string; + settings: string; + features: string; + exports: string; + feedback: string; + nightPeak: string; + relay: string; + }; + titles: Record; +} + +/** Value-free labels for the canonical award storage inventory row. */ +export const ACHIEVEMENT_RECORD_INVENTORY_COPY: Record = { + en: { category: 'Achievement records', network: 'None', clear: 'Clear all extension-derived data' }, + 'zh-CN': { category: '成就记录', network: '无', clear: '清除全部插件派生数据' }, + 'zh-TW': { category: '成就記錄', network: '無', clear: '清除全部擴充功能衍生資料' }, + 'de-DE': { category: 'Erfolgsnachweise', network: 'Keine', clear: 'Alle abgeleiteten Erweiterungsdaten löschen' }, + ja: { category: '実績の記録', network: 'なし', clear: '拡張機能の派生データをすべて削除' }, + ko: { category: '업적 기록', network: '없음', clear: '확장 프로그램의 파생 데이터 모두 삭제' }, + 'pt-BR': { category: 'Registros de conquistas', network: 'Nenhuma', clear: 'Limpar todos os dados derivados da extensão' }, + id: { category: 'Catatan pencapaian', network: 'Tidak ada', clear: 'Hapus semua data turunan ekstensi' }, +}; + +/** Local-only achievement copy. Every card has a title in each UI locale. */ +type BaseAchievementCopy = Omit & { + titles: Record,string>; +}; +const BASE_ACHIEVEMENT_COPY: Record = { + en: { + tab: 'Achievements', heading: 'Local achievements', + intro: 'Milestones from indexed local usage. They describe activity, not skill or productivity.', + evidenceTitle: 'Evidence details', + evidencePending: 'Evidence pending', evidenceStale: 'Evidence outdated', + lowerBoundNote: '≥ Verified lower bound. Unverified Codex lineage or dated usage is excluded.', + lowerBoundDetails: 'Calendar awards need verified retained logs; four-week means skip the first event week. Deleted/rotated logs can leave gaps.', + earned: 'Earned', locked: 'In progress', nextTarget: 'Next level', nextStage: 'Next stage', + myBadges: 'My badges', nextGoals: 'Next goals', galleryTitle: 'Complete collection', viewBadge: 'View badge', + recentAwards: 'Recently earned', recordHistory: 'Recent records', recordedDay: 'First recorded', historicalAward: 'Historical award · date unknown', + pinBadge: 'Pin badge', unpinBadge: 'Unpin badge', pinLimit: 'Pin up to 6 badges.', pinFailed: 'Could not save the pin. Try again.', + hiddenPins: 'Hidden pinned badges', + unreadAwards: '{count} new awards', unreadGroup: 'This group: {count} awards', + readGroup: 'Mark this group read', readFailed: 'Could not save. This group is still unread. Try again.', + seriesCount: '{earned} / {total} series', levelCount: '{earned} / {total} levels', shownCount: '{count} series shown', + filterLabel: 'Show achievements', filterAll: 'All series', filterNext: 'Next levels', filterEarned: 'Earned series', + filterEmpty: 'No series match this view. Choose All series to browse the collection.', + settingsTitle: 'Achievement settings', allLevels: 'Level requirements', complete: 'All levels earned', + shareSelect: 'Select up to 9 earned badges below to preview your card.', shareLimit: '9 badges selected. Remove one to choose another.', + sharePending: 'Preparing preview…', shareStale: 'Preview your current selection before exporting.', + shareFailed: 'Could not prepare the preview. Try updating it again.', shareExportFailed: 'Could not save the achievement card. Please try again.', + shareBack: 'Back to achievements', + shareSaved: 'Achievement card saved.', shareCancelled: 'Save cancelled. Your preview is unchanged.', shareSaving: 'Saving card…', + shareSelectionChanged: 'Available badges changed. Check your selection before sharing; new levels are not selected automatically.', + shareTitle: 'Achievement card', shareIntro: 'Choose earned badges to include in a local SVG. Export only after preview.', shareChoose: 'Badges to share', shareEmpty: 'Earn a badge before creating a card.', + unlockedNotice: 'Badge unlocked: {name}', unlockedBatch: '{count} achievement series advanced', + upgradeOn: 'Local achievements are now on by default. Historical badges are added quietly; you can turn achievements off here or in dashboard Settings.', + upgradeOff: 'Local achievements are available. Your existing OFF preference has been kept; you can enable them in dashboard Settings.', + openAchievements: 'View achievements', disableAchievements: 'Turn achievements off', + disableFailed: 'Could not save the OFF preference. Achievements remain enabled; please try again in dashboard Settings.', + groups: { claude: 'Claude', codex: 'Codex', combined: 'Combined activity', plugin: 'Extension exploration' }, + levels: ['Bronze', 'Silver', 'Gold', 'Platinum', 'Legendary'], milestone: 'Milestone', + streakTheme: 'Active streak', sharedDaysTheme: 'Shared active days', + featureJourney: 'Local feature steps', + journeySteps: ['View the collection', 'Preview a share card', 'Export a card locally'], + stepRecorded: 'Recorded', stepUnrecorded: 'Not recorded', + viewCurrentModels: 'View current model statistics', viewCurrentTokens: 'View current token statistics', backToBadge: 'Back to badge', + units: { + outputTokens: 'output tokens', + cacheReadTokens: 'cache-read tokens', + cacheCreationTokens: 'cache-creation tokens', + encounter: 'model used', + models: 'qualified multi-model tasks', + roots: 'independent root tasks', + childSessions: 'qualified subagents', + efforts: 'qualified effort tiers', + days: 'active days', + sessions: 'sessions with Fable output', + outputSessions: 'sessions with output', + hours: 'consecutive UTC hours with output (not human work)', + tokens: 'processed tokens', + agents: 'qualified team tasks', + agentRoots: 'qualified team tasks', + streak: 'consecutive active days', + families: 'qualified model families', + threeFamilyDay: 'one closed day: any 3 of 7 recognized families (including supported Fable 5/5.1), each with 1M+ processed tokens and positive output', + qualifiedDays: 'closed days, each with 1M+ family processed tokens and positive output', + sharedDays: 'closed days with 1M+ processed tokens and positive output per provider', + weeks: 'closed Monday-start weeks with 4+ days, each with 1M+ processed tokens and positive output', + weeklyAverage: 'peak processed tokens/week over 4 consecutive closed Monday-start weeks; zero weeks included, total ÷ 4', + codexCachedTokens: 'cached input tokens', + settings: 'different safe settings deliberately changed', features: 'local features: gallery, badge preview and SVG export', + exports: 'successful local SVG exports', feedback: 'saved local advice feedback', + nightPeak: '30-day portrait: 14+ active days, 5+ night days; output-positive hours at 22:00–06:00 exceed each other 8-hour band by 20% (not work time)', + relay: 'sessions with output from Fable and another recognized Claude family', + }, + titles: { + ...apiAchievementTitles('en'), + 'sonnet-main': 'Sonnet mainstay', 'opus-cannon': 'Opus deep dive', 'haiku-worker': 'Haiku workhorse', + 'agent-standup': 'Agent team · Bronze', 'tiny-ai-company': 'Agent team · Silver', + 'agent-team-gold': 'Agent team · Gold', 'same-root-models-silver': 'Models in one task · Silver', + 'same-root-models-gold': 'Models in one task · Gold', 'effort-breadth-silver': 'Effort breadth · Silver', + 'model-rainbow-bronze': 'Model rainbow · Bronze', 'model-rainbow-gold': 'Model rainbow · Gold', + 'active-day-streak': 'Seven-day streak', 'streak-machine': 'Fourteen-day streak', + 'model-rainbow': 'Model rainbow', + 'cache-wizard': 'Cache reading', 'agent-conductor': 'Agent conductor', + 'multi-core-human': 'Models in one task', 'effort-explorer': 'Effort explorer', + 'trillion-token-club': '1T club', 'hundred-million-club': '100M club', + 'astra-regular': 'Astra regular', 'sol-regular': 'Sol regular', 'luna-regular': 'Luna regular', + 'effort-spectrum': 'Full effort spectrum', + 'billion-token-club': '1B club', 'ten-billion-token-club': '10B club', + 'hundred-billion-token-club': '100B club', + 'fable-volume-bronze': 'Fable volume · Bronze', 'fable-volume-silver': 'Fable volume · Silver', + 'fable-volume-gold': 'Fable volume · Gold', 'fable-days-bronze': 'Fable days · Bronze', + 'fable-days-silver': 'Fable days · Silver', 'fable-days-gold': 'Fable days · Gold', + 'fable-relay': 'Fable same-session', + 'fable-sessions-bronze': 'Fable sessions · Bronze', 'fable-sessions-silver': 'Fable sessions · Silver', + 'fable-sessions-gold': 'Fable sessions · Gold', + 'agent-conductor-silver': 'Agent conductor · Silver', 'agent-conductor-gold': 'Agent conductor · Gold', + 'root-portfolio-bronze': 'Independent root tasks · Bronze', 'root-portfolio-silver': 'Independent root tasks · Silver', + 'root-portfolio-gold': 'Independent root tasks · Gold', + 'claude-output-bronze': 'Claude output · Bronze', 'claude-output-silver': 'Claude output · Silver', + 'claude-output-gold': 'Claude output · Gold', + 'claude-output-sessions-bronze': 'Output sessions · Bronze', 'claude-output-sessions-silver': 'Output sessions · Silver', + 'claude-output-sessions-gold': 'Output sessions · Gold', + 'claude-cache-creation-bronze': 'Cache creation · Bronze', 'claude-cache-creation-silver': 'Cache creation · Silver', + 'claude-cache-creation-gold': 'Cache creation · Gold', + 'agent-participation-bronze': 'Agent participation · Bronze', 'agent-participation-silver': 'Agent participation · Silver', + 'agent-participation-gold': 'Agent participation · Gold', + 'model-relay-roots-bronze': 'Multi-model tasks · Bronze', 'model-relay-roots-silver': 'Multi-model tasks · Silver', + 'model-relay-roots-gold': 'Multi-model tasks · Gold', + 'active-days-bronze': 'Active days · Bronze', 'active-days-silver': 'Active days · Silver', + 'active-days-gold': 'Active days · Gold', + 'weekly-volume-bronze': 'Weekly volume · Bronze', 'weekly-volume-silver': 'Weekly volume · Silver', + 'weekly-volume-gold': 'Weekly volume · Gold', + 'output-hours-bronze': 'Hourly output · Bronze', 'output-hours-silver': 'Hourly output · Silver', + 'output-hours-gold': 'Hourly output · Gold', 'night-output-peak': 'Night-time output pattern', + 'settings-explorer-bronze': 'Settings explorer · Bronze', 'settings-explorer-silver': 'Settings explorer · Silver', + 'settings-explorer-gold': 'Settings explorer · Gold', 'safe-feature-explorer': 'Safe feature tour', + 'local-share-export': 'Local share export', 'local-advice-feedback': 'Local advice feedback', + 'sonnet-main-silver': 'Sonnet mainstay · Silver', 'sonnet-main-gold': 'Sonnet mainstay · Gold', + 'opus-cannon-silver': 'Opus deep dive · Silver', 'opus-cannon-gold': 'Opus deep dive · Gold', + 'haiku-worker-silver': 'Haiku workhorse · Silver', 'haiku-worker-gold': 'Haiku workhorse · Gold', + 'cache-wizard-silver': 'Cache reading · Silver', 'cache-wizard-gold': 'Cache reading · Gold', + 'astra-regular-silver': 'Astra regular · Silver', 'astra-regular-gold': 'Astra regular · Gold', + 'sol-regular-silver': 'Sol regular · Silver', 'sol-regular-gold': 'Sol regular · Gold', + 'luna-regular-silver': 'Luna regular · Silver', 'luna-regular-gold': 'Luna regular · Gold', + 'active-day-streak-silver': '30-day streak', 'active-day-streak-gold': '100-day streak', + 'codex-output-bronze': 'Codex output · Bronze', + 'codex-output-silver': 'Codex output · Silver', + 'codex-output-gold': 'Codex output · Gold', + 'codex-cache-read-bronze': 'Codex cache · Bronze', + 'codex-cache-read-silver': 'Codex cache · Silver', + 'codex-cache-read-gold': 'Codex cache · Gold', + 'astra-days-bronze': 'Astra days · Bronze', + 'astra-days-silver': 'Astra days · Silver', + 'astra-days-gold': 'Astra days · Gold', + 'sol-days-bronze': 'Sol days · Bronze', + 'sol-days-silver': 'Sol days · Silver', + 'sol-days-gold': 'Sol days · Gold', + 'luna-days-bronze': 'Luna days · Bronze', + 'luna-days-silver': 'Luna days · Silver', + 'luna-days-gold': 'Luna days · Gold', + 'sonnet-days-bronze': 'Sonnet days · Bronze', + 'sonnet-days-silver': 'Sonnet days · Silver', + 'sonnet-days-gold': 'Sonnet days · Gold', + 'opus-days-bronze': 'Opus days · Bronze', + 'opus-days-silver': 'Opus days · Silver', + 'opus-days-gold': 'Opus days · Gold', + 'haiku-days-bronze': 'Haiku days · Bronze', + 'haiku-days-silver': 'Haiku days · Silver', + 'haiku-days-gold': 'Haiku days · Gold', + 'dual-provider-days-bronze': 'Dual-provider days · Bronze', + 'dual-provider-days-silver': 'Dual-provider days · Silver', + 'dual-provider-days-gold': 'Dual-provider days · Gold', + 'active-weeks-bronze': 'Active weeks · Bronze', + 'active-weeks-silver': 'Active weeks · Silver', + 'active-weeks-gold': 'Active weeks · Gold', + 'weekly-average-bronze': 'Weekly average · Bronze', + 'weekly-average-silver': 'Weekly average · Silver', + 'weekly-average-gold': 'Weekly average · Gold', + 'three-family-day': 'Three-family day', + 'dual-provider-day': 'Dual-provider day', + }, + }, + 'zh-CN': { + tab: '成就', heading: '本地成就', + intro: '依据已索引的本地用量解锁;记录使用经历,不评价能力或生产力。', + evidenceTitle: '数据依据', + evidencePending: '证据待补齐', evidenceStale: '证据已过期', + lowerBoundNote: '≥ 已核验下界;无法核验的 Codex 谱系或日期用量未计入。', + lowerBoundDetails: '日历成就须核实留存日志;四周均值排除首条事件所在周。删除或轮转的日志可能留下缺口。', + earned: '已获得', locked: '进行中', nextTarget: '下一等级', nextStage: '下一阶段', + myBadges: '我的徽章', nextGoals: '下一目标', galleryTitle: '完整图鉴', viewBadge: '查看徽章', + recentAwards: '最近获得', recordHistory: '近期记录', recordedDay: '首次记录', historicalAward: '历史成就 · 日期未知', + pinBadge: '固定徽章', unpinBadge: '取消固定', pinLimit: '最多固定 6 个徽章。', pinFailed: '固定选择未保存,请重试。', + hiddenPins: '已隐藏的固定徽章', + unreadAwards: '新增 {count} 项', unreadGroup: '本组 {count} 项', + readGroup: '标记本组已读', readFailed: '未能保存,本组仍未读。请重试。', + seriesCount: '{earned} / {total} 个系列', levelCount: '{earned} / {total} 个等级', shownCount: '显示 {count} 个系列', + filterLabel: '显示范围', filterAll: '全部系列', filterNext: '待解锁等级', filterEarned: '已获得系列', + filterEmpty: '当前范围没有成就,选择“全部系列”可查看完整图鉴。', + settingsTitle: '成就设置', allLevels: '各等级条件', complete: '全部等级已获得', + shareSelect: '请在下方选择最多 9 枚已获得的徽章,生成分享预览。', shareLimit: '已选择 9 枚徽章,取消一枚后可选择其他徽章。', + sharePending: '正在生成预览…', shareStale: '请先预览当前选择,再导出。', + shareFailed: '预览生成失败,请重新更新预览。', shareExportFailed: '成就分享卡保存失败,请重试。', + shareBack: '返回成就', + shareSaved: '成就分享卡已保存。', shareCancelled: '已取消保存,预览保持不变。', shareSaving: '正在保存分享卡…', + shareSelectionChanged: '可分享徽章已变化,请确认选择;新等级不会自动勾选。', + shareTitle: '成就分享卡', shareIntro: '选择已获得的徽章生成本地 SVG,预览后再导出。', shareChoose: '选择展示的徽章', shareEmpty: '获得徽章后即可制作分享卡。', + unlockedNotice: '解锁徽章:{name}', unlockedBatch: '{count} 个成就系列取得新进展', + upgradeOn: '本地成就默认开启,历史徽章将静默补齐。可在此关闭,也可在面板设置中随时关闭。', + upgradeOff: '本地成就已上线,已保留你原有的关闭选择。可在面板设置中开启。', + openAchievements: '查看成就', disableAchievements: '关闭成就', + disableFailed: '未能保存关闭设置。成就仍开启,请在面板设置中重试。', + groups: { claude: 'Claude', codex: 'Codex', combined: '综合活动', plugin: '插件探索' }, + levels: ['铜', '银', '金', '铂金', '传奇'], milestone: '里程碑', + streakTheme: '连续活跃', sharedDaysTheme: '双供应商活跃', + featureJourney: '本地功能体验', + journeySteps: ['查看图鉴', '预览分享卡', '本地导出分享卡'], + stepRecorded: '已记录', stepUnrecorded: '暂无记录', + viewCurrentModels: '查看当前模型统计', viewCurrentTokens: '查看当前 Token 统计', backToBadge: '返回徽章', + units: { + outputTokens: '输出 Token', + cacheReadTokens: '缓存读取 Token', + cacheCreationTokens: '缓存写入 Token', + encounter: '种模型已使用', + models: '个合格多模型任务', + roots: '个独立主任务', + childSessions: '个合格子智能体', + efforts: '个合格推理档位', + days: '个活跃日', + sessions: '个有 Fable 输出的会话', + outputSessions: '个有输出的会话', + hours: '个连续 UTC 小时有输出(不代表用户连续工作)', + tokens: '已处理 Token', + agents: '个合格团队任务', + agentRoots: '个合格团队任务', + streak: '个连续活跃日', + families: '个合格模型家族', + threeFamilyDay: '同一已结束自然日,7 个可识别家族中任意 3 个(含支持的 Fable 5/5.1)各至少 100万已处理 Token 且有正输出', + qualifiedDays: '个已结束自然日,每日该家族至少 100万已处理 Token 且有正输出', + sharedDays: '个已结束自然日,两供应商各至少 100万已处理 Token 且有正输出', + weeks: '个周一起算的已结束自然周,每周 4+ 天各至少 100万已处理 Token 且有正输出', + weeklyAverage: '连续 4 个周一起算的已结束自然周,每周已处理 Token 均值峰值;含零用量周,总量 ÷ 4', + codexCachedTokens: '缓存输入 Token', + settings: '项主动修改过的不同安全设置', features: '项本地功能:图鉴、徽章预览、SVG 导出', + exports: '次成功的本地 SVG 导出', feedback: '项已保存的本地建议反馈', + nightPeak: '项近 30 日画像:≥14 个活跃日、≥5 个夜间活跃日;22:00–06:00 有输出小时数比另两个八小时时段分别高至少 20%(非工作时长)', + relay: '个 Fable 与另一已识别 Claude 家族均有输出的会话', + }, + titles: { + 'sonnet-main': 'Sonnet 主力', 'opus-cannon': 'Opus 深度使用', 'haiku-worker': 'Haiku 多面手', + ...apiAchievementTitles('zh-CN'), + 'agent-standup': '智能体团队·铜', 'tiny-ai-company': '智能体团队·银', + 'agent-team-gold': '智能体团队·金', 'same-root-models-silver': '同任务模型·银', + 'same-root-models-gold': '同任务模型·金', 'effort-breadth-silver': '推理档位·银', + 'model-rainbow-bronze': '模型彩虹·铜', 'model-rainbow-gold': '模型彩虹·金', + 'active-day-streak': '连续七日', 'streak-machine': '连续十四日', + 'model-rainbow': '模型彩虹', + 'cache-wizard': '缓存读取里程碑', 'agent-conductor': '智能体编排者', + 'multi-core-human': '同任务模型', 'effort-explorer': '推理档位探索者', + 'trillion-token-club': '万亿俱乐部', 'hundred-million-club': '一亿俱乐部', + 'astra-regular': 'Astra 常客', 'sol-regular': 'Sol 常客', 'luna-regular': 'Luna 常客', + 'effort-spectrum': '六档全览', + 'billion-token-club': '十亿俱乐部', 'ten-billion-token-club': '百亿俱乐部', + 'hundred-billion-token-club': '千亿俱乐部', + 'fable-volume-bronze': 'Fable 使用量·铜', 'fable-volume-silver': 'Fable 使用量·银', + 'fable-volume-gold': 'Fable 使用量·金', 'fable-days-bronze': 'Fable 活跃日·铜', + 'fable-days-silver': 'Fable 活跃日·银', 'fable-days-gold': 'Fable 活跃日·金', + 'fable-relay': 'Fable 同会话', + 'fable-sessions-bronze': 'Fable 输出会话·铜', 'fable-sessions-silver': 'Fable 输出会话·银', + 'fable-sessions-gold': 'Fable 输出会话·金', + 'agent-conductor-silver': '智能体编排者·银', 'agent-conductor-gold': '智能体编排者·金', + 'root-portfolio-bronze': '独立主任务·铜', 'root-portfolio-silver': '独立主任务·银', + 'root-portfolio-gold': '独立主任务·金', + 'claude-output-bronze': 'Claude 输出·铜', 'claude-output-silver': 'Claude 输出·银', + 'claude-output-gold': 'Claude 输出·金', + 'claude-output-sessions-bronze': '输出会话·铜', 'claude-output-sessions-silver': '输出会话·银', + 'claude-output-sessions-gold': '输出会话·金', + 'claude-cache-creation-bronze': '缓存写入·铜', 'claude-cache-creation-silver': '缓存写入·银', + 'claude-cache-creation-gold': '缓存写入·金', + 'agent-participation-bronze': '子智能体参与·铜', 'agent-participation-silver': '子智能体参与·银', + 'agent-participation-gold': '子智能体参与·金', + 'model-relay-roots-bronze': '多模型任务·铜', 'model-relay-roots-silver': '多模型任务·银', + 'model-relay-roots-gold': '多模型任务·金', + 'active-days-bronze': '活跃日积累·铜', 'active-days-silver': '活跃日积累·银', + 'active-days-gold': '活跃日积累·金', + 'weekly-volume-bronze': '单周用量·铜', 'weekly-volume-silver': '单周用量·银', + 'weekly-volume-gold': '单周用量·金', + 'output-hours-bronze': '逐小时输出·铜', 'output-hours-silver': '逐小时输出·银', + 'output-hours-gold': '逐小时输出·金', 'night-output-peak': '夜间输出偏好', + 'settings-explorer-bronze': '设置探索·铜', 'settings-explorer-silver': '设置探索·银', + 'settings-explorer-gold': '设置探索·金', 'safe-feature-explorer': '安全功能巡游', + 'local-share-export': '本地分享导出', 'local-advice-feedback': '本地建议反馈', + 'sonnet-main-silver': 'Sonnet 主力·银', 'sonnet-main-gold': 'Sonnet 主力·金', + 'opus-cannon-silver': 'Opus 深度使用·银', 'opus-cannon-gold': 'Opus 深度使用·金', + 'haiku-worker-silver': 'Haiku 多面手·银', 'haiku-worker-gold': 'Haiku 多面手·金', + 'cache-wizard-silver': '缓存读取·银', 'cache-wizard-gold': '缓存读取·金', + 'astra-regular-silver': 'Astra 常客·银', 'astra-regular-gold': 'Astra 常客·金', + 'sol-regular-silver': 'Sol 常客·银', 'sol-regular-gold': 'Sol 常客·金', + 'luna-regular-silver': 'Luna 常客·银', 'luna-regular-gold': 'Luna 常客·金', + 'active-day-streak-silver': '连续三十日', 'active-day-streak-gold': '连续一百日', + 'codex-output-bronze': 'Codex 输出·铜', + 'codex-output-silver': 'Codex 输出·银', + 'codex-output-gold': 'Codex 输出·金', + 'codex-cache-read-bronze': 'Codex 缓存·铜', + 'codex-cache-read-silver': 'Codex 缓存·银', + 'codex-cache-read-gold': 'Codex 缓存·金', + 'astra-days-bronze': 'Astra 活跃日·铜', + 'astra-days-silver': 'Astra 活跃日·银', + 'astra-days-gold': 'Astra 活跃日·金', + 'sol-days-bronze': 'Sol 活跃日·铜', + 'sol-days-silver': 'Sol 活跃日·银', + 'sol-days-gold': 'Sol 活跃日·金', + 'luna-days-bronze': 'Luna 活跃日·铜', + 'luna-days-silver': 'Luna 活跃日·银', + 'luna-days-gold': 'Luna 活跃日·金', + 'sonnet-days-bronze': 'Sonnet 活跃日·铜', + 'sonnet-days-silver': 'Sonnet 活跃日·银', + 'sonnet-days-gold': 'Sonnet 活跃日·金', + 'opus-days-bronze': 'Opus 活跃日·铜', + 'opus-days-silver': 'Opus 活跃日·银', + 'opus-days-gold': 'Opus 活跃日·金', + 'haiku-days-bronze': 'Haiku 活跃日·铜', + 'haiku-days-silver': 'Haiku 活跃日·银', + 'haiku-days-gold': 'Haiku 活跃日·金', + 'dual-provider-days-bronze': '双供应商日·铜', + 'dual-provider-days-silver': '双供应商日·银', + 'dual-provider-days-gold': '双供应商日·金', + 'active-weeks-bronze': '活跃周·铜', + 'active-weeks-silver': '活跃周·银', + 'active-weeks-gold': '活跃周·金', + 'weekly-average-bronze': '四周周均·铜', + 'weekly-average-silver': '四周周均·银', + 'weekly-average-gold': '四周周均·金', + 'three-family-day': '三家族同日', + 'dual-provider-day': '双供应商同日', + }, + }, + 'zh-TW': { + tab: '成就', heading: '本機成就', intro: '根據已索引的本機用量解鎖;記錄使用經歷,不評價能力或生產力。', + evidenceTitle: '資料依據', + evidencePending: '證據待補齊', evidenceStale: '證據已過期', + lowerBoundNote: '≥ 已核驗下界;無法核驗的 Codex 譜系或日期用量未計入。', + lowerBoundDetails: '日曆成就須核實留存日誌;四週均值排除首筆事件所在週。刪除或輪替的日誌可能留下缺口。', + earned: '已獲得', locked: '進行中', nextTarget: '下一等級', nextStage: '下一階段', + myBadges: '我的徽章', nextGoals: '下一目標', galleryTitle: '完整圖鑑', viewBadge: '查看徽章', + recentAwards: '最近獲得', recordHistory: '近期記錄', recordedDay: '首次記錄', historicalAward: '歷史成就 · 日期未知', + pinBadge: '固定徽章', unpinBadge: '取消固定', pinLimit: '最多固定 6 個徽章。', pinFailed: '固定選擇未儲存,請重試。', + hiddenPins: '已隱藏的固定徽章', + unreadAwards: '新增 {count} 項', unreadGroup: '本組 {count} 項', + readGroup: '標記本組已讀', readFailed: '未能儲存,本組仍未讀。請重試。', + seriesCount: '{earned} / {total} 個系列', levelCount: '{earned} / {total} 個等級', shownCount: '顯示 {count} 個系列', + filterLabel: '顯示範圍', filterAll: '全部系列', filterNext: '待解鎖等級', filterEarned: '已獲得系列', + filterEmpty: '目前範圍沒有成就,選擇「全部系列」可查看完整圖鑑。', + settingsTitle: '成就設定', allLevels: '各等級條件', complete: '全部等級已獲得', + shareSelect: '請在下方選擇最多 9 枚已獲得的徽章,產生分享預覽。', shareLimit: '已選擇 9 枚徽章,取消一枚後可選擇其他徽章。', + sharePending: '正在產生預覽…', shareStale: '請先預覽目前選擇,再匯出。', + shareFailed: '預覽產生失敗,請重新更新預覽。', shareExportFailed: '成就分享卡儲存失敗,請重試。', + shareBack: '返回成就', + shareSaved: '成就分享卡已儲存。', shareCancelled: '已取消儲存,預覽保持不變。', shareSaving: '正在儲存分享卡…', + shareSelectionChanged: '可分享徽章已變更,請確認選擇;新等級不會自動勾選。', + shareTitle: '成就分享卡', shareIntro: '選擇已獲得的徽章產生本機 SVG,預覽後再匯出。', shareChoose: '選擇展示的徽章', shareEmpty: '獲得徽章後即可製作分享卡。', + unlockedNotice: '解鎖徽章:{name}', unlockedBatch: '{count} 個成就系列取得新進展', + upgradeOn: '本機成就預設開啟,歷史徽章將靜默補齊。可在此關閉,也可隨時在面板設定中關閉。', + upgradeOff: '本機成就已推出,已保留你原有的關閉選擇。可在面板設定中開啟。', + openAchievements: '檢視成就', disableAchievements: '關閉成就', + disableFailed: '無法儲存關閉設定。成就仍開啟,請在面板設定中重試。', + groups: { claude: 'Claude', codex: 'Codex', combined: '綜合活動', plugin: '擴充功能探索' }, + levels: ['銅', '銀', '金', '鉑金', '傳奇'], milestone: '里程碑', + streakTheme: '連續活躍', sharedDaysTheme: '雙供應商活躍', + featureJourney: '本機功能體驗', + journeySteps: ['查看圖鑑', '預覽分享卡', '本機匯出分享卡'], + stepRecorded: '已記錄', stepUnrecorded: '尚無記錄', + viewCurrentModels: '查看目前模型統計', viewCurrentTokens: '查看目前 Token 統計', backToBadge: '返回徽章', + units: { + outputTokens: '輸出 Token', + cacheReadTokens: '快取讀取 Token', + cacheCreationTokens: '快取寫入 Token', + encounter: '種模型已使用', + models: '個合格多模型任務', + roots: '個獨立主任務', + childSessions: '個合格子代理', + efforts: '個合格推理檔位', + days: '個活躍日', + sessions: '個有 Fable 輸出的工作階段', + outputSessions: '個有輸出的工作階段', + hours: '個連續 UTC 小時有輸出(非使用者持續工作)', + tokens: '已處理 Token', + agents: '個合格團隊任務', + agentRoots: '個合格團隊任務', + streak: '個連續活躍日', + families: '個合格模型家族', + threeFamilyDay: '同一已結束自然日,7 個可識別家族中任意 3 個(含支援的 Fable 5/5.1)各至少 100萬已處理 Token 且有正輸出', + qualifiedDays: '個已結束自然日,每日該家族至少 100萬已處理 Token 且有正輸出', + sharedDays: '個已結束自然日,兩供應商各至少 100萬已處理 Token 且有正輸出', + weeks: '個週一起算的已結束自然週,每週 4+ 天各至少 100萬已處理 Token 且有正輸出', + weeklyAverage: '連續 4 個週一起算的已結束自然週,每週已處理 Token 均值峰值;含零用量週,總量 ÷ 4', + codexCachedTokens: '快取輸入 Token', + settings: '項主動修改過的不同安全設定', features: '項本機功能:圖鑑、徽章預覽、SVG 匯出', + exports: '次成功的本機 SVG 匯出', feedback: '項已儲存的本機建議回饋', + nightPeak: '項近 30 日使用型態:≥14 個活躍日、≥5 個夜間活躍日;22:00–06:00 有輸出小時數比另兩個八小時時段分別高至少 20%(非工作時長)', + relay: '個 Fable 與另一已識別 Claude 家族均有輸出的會話', + }, + titles: { + 'sonnet-main': 'Sonnet 主力', 'opus-cannon': 'Opus 深度使用', 'haiku-worker': 'Haiku 多面手', + ...apiAchievementTitles('zh-TW'), + 'agent-standup': '代理團隊・銅', 'tiny-ai-company': '代理團隊・銀', + 'agent-team-gold': '代理團隊・金', 'same-root-models-silver': '同任務模型・銀', + 'same-root-models-gold': '同任務模型・金', 'effort-breadth-silver': '推理檔位・銀', + 'model-rainbow-bronze': '模型彩虹・銅', 'model-rainbow-gold': '模型彩虹・金', + 'active-day-streak': '連續七日', 'streak-machine': '連續十四日', + 'model-rainbow': '模型彩虹', + 'cache-wizard': '快取讀取里程碑', 'agent-conductor': '代理編排者', + 'multi-core-human': '同任務模型', 'effort-explorer': '推理檔位探索者', + 'trillion-token-club': '萬億俱樂部', 'hundred-million-club': '一億俱樂部', + 'astra-regular': 'Astra 常客', 'sol-regular': 'Sol 常客', 'luna-regular': 'Luna 常客', + 'effort-spectrum': '六檔全覽', + 'billion-token-club': '十億俱樂部', 'ten-billion-token-club': '百億俱樂部', + 'hundred-billion-token-club': '千億俱樂部', + 'fable-volume-bronze': 'Fable 用量・銅', 'fable-volume-silver': 'Fable 用量・銀', + 'fable-volume-gold': 'Fable 用量・金', 'fable-days-bronze': 'Fable 活躍日・銅', + 'fable-days-silver': 'Fable 活躍日・銀', 'fable-days-gold': 'Fable 活躍日・金', + 'fable-relay': 'Fable 同會話', + 'fable-sessions-bronze': 'Fable 輸出工作階段・銅', 'fable-sessions-silver': 'Fable 輸出工作階段・銀', + 'fable-sessions-gold': 'Fable 輸出工作階段・金', + 'agent-conductor-silver': '代理編排者・銀', 'agent-conductor-gold': '代理編排者・金', + 'root-portfolio-bronze': '獨立主任務・銅', 'root-portfolio-silver': '獨立主任務・銀', + 'root-portfolio-gold': '獨立主任務・金', + 'claude-output-bronze': 'Claude 輸出・銅', 'claude-output-silver': 'Claude 輸出・銀', + 'claude-output-gold': 'Claude 輸出・金', + 'claude-output-sessions-bronze': '輸出工作階段・銅', 'claude-output-sessions-silver': '輸出工作階段・銀', + 'claude-output-sessions-gold': '輸出工作階段・金', + 'claude-cache-creation-bronze': '快取寫入・銅', 'claude-cache-creation-silver': '快取寫入・銀', + 'claude-cache-creation-gold': '快取寫入・金', + 'agent-participation-bronze': '子代理參與・銅', 'agent-participation-silver': '子代理參與・銀', + 'agent-participation-gold': '子代理參與・金', + 'model-relay-roots-bronze': '多模型任務・銅', 'model-relay-roots-silver': '多模型任務・銀', + 'model-relay-roots-gold': '多模型任務・金', + 'active-days-bronze': '活躍日累積・銅', 'active-days-silver': '活躍日累積・銀', + 'active-days-gold': '活躍日累積・金', + 'weekly-volume-bronze': '單週用量・銅', 'weekly-volume-silver': '單週用量・銀', + 'weekly-volume-gold': '單週用量・金', + 'output-hours-bronze': '逐時輸出・銅', 'output-hours-silver': '逐時輸出・銀', + 'output-hours-gold': '逐時輸出・金', 'night-output-peak': '夜間輸出傾向', + 'settings-explorer-bronze': '設定探索・銅', 'settings-explorer-silver': '設定探索・銀', + 'settings-explorer-gold': '設定探索・金', 'safe-feature-explorer': '安全功能探索', + 'local-share-export': '本機分享匯出', 'local-advice-feedback': '本機建議回饋', + 'sonnet-main-silver': 'Sonnet 主力・銀', 'sonnet-main-gold': 'Sonnet 主力・金', + 'opus-cannon-silver': 'Opus 深度使用・銀', 'opus-cannon-gold': 'Opus 深度使用・金', + 'haiku-worker-silver': 'Haiku 多面手・銀', 'haiku-worker-gold': 'Haiku 多面手・金', + 'cache-wizard-silver': '快取讀取・銀', 'cache-wizard-gold': '快取讀取・金', + 'astra-regular-silver': 'Astra 常客・銀', 'astra-regular-gold': 'Astra 常客・金', + 'sol-regular-silver': 'Sol 常客・銀', 'sol-regular-gold': 'Sol 常客・金', + 'luna-regular-silver': 'Luna 常客・銀', 'luna-regular-gold': 'Luna 常客・金', + 'active-day-streak-silver': '連續三十日', 'active-day-streak-gold': '連續一百日', + 'codex-output-bronze': 'Codex 輸出・銅', + 'codex-output-silver': 'Codex 輸出・銀', + 'codex-output-gold': 'Codex 輸出・金', + 'codex-cache-read-bronze': 'Codex 快取・銅', + 'codex-cache-read-silver': 'Codex 快取・銀', + 'codex-cache-read-gold': 'Codex 快取・金', + 'astra-days-bronze': 'Astra 活躍日・銅', + 'astra-days-silver': 'Astra 活躍日・銀', + 'astra-days-gold': 'Astra 活躍日・金', + 'sol-days-bronze': 'Sol 活躍日・銅', + 'sol-days-silver': 'Sol 活躍日・銀', + 'sol-days-gold': 'Sol 活躍日・金', + 'luna-days-bronze': 'Luna 活躍日・銅', + 'luna-days-silver': 'Luna 活躍日・銀', + 'luna-days-gold': 'Luna 活躍日・金', + 'sonnet-days-bronze': 'Sonnet 活躍日・銅', + 'sonnet-days-silver': 'Sonnet 活躍日・銀', + 'sonnet-days-gold': 'Sonnet 活躍日・金', + 'opus-days-bronze': 'Opus 活躍日・銅', + 'opus-days-silver': 'Opus 活躍日・銀', + 'opus-days-gold': 'Opus 活躍日・金', + 'haiku-days-bronze': 'Haiku 活躍日・銅', + 'haiku-days-silver': 'Haiku 活躍日・銀', + 'haiku-days-gold': 'Haiku 活躍日・金', + 'dual-provider-days-bronze': '雙供應商日・銅', + 'dual-provider-days-silver': '雙供應商日・銀', + 'dual-provider-days-gold': '雙供應商日・金', + 'active-weeks-bronze': '活躍週・銅', + 'active-weeks-silver': '活躍週・銀', + 'active-weeks-gold': '活躍週・金', + 'weekly-average-bronze': '四週週均・銅', + 'weekly-average-silver': '四週週均・銀', + 'weekly-average-gold': '四週週均・金', + 'three-family-day': '三家族同日', + 'dual-provider-day': '雙供應商同日', + }, + }, + 'de-DE': { + tab: 'Erfolge', heading: 'Lokale Erfolge', intro: 'Meilensteine aus lokal indexierter Nutzung; keine Bewertung von Können oder Produktivität.', + evidenceTitle: 'Datengrundlage', + evidencePending: 'Nachweis ausstehend', evidenceStale: 'Nachweis veraltet', + lowerBoundNote: '≥ Geprüfte Untergrenze. Ungeprüfte Codex-Abstammungen oder datierte Nutzung zählen nicht.', + lowerBoundDetails: 'Kalendererfolge brauchen geprüfte erhaltene Logs; Vierwochenmittel überspringen die erste Ereigniswoche. Gelöschte/rotierte Logs können Lücken hinterlassen.', + earned: 'Erreicht', locked: 'In Arbeit', nextTarget: 'Nächste Stufe', nextStage: 'Nächstes Etappenziel', + myBadges: 'Meine Abzeichen', nextGoals: 'Nächste Ziele', galleryTitle: 'Vollständige Sammlung', viewBadge: 'Abzeichen ansehen', + recentAwards: 'Zuletzt erhalten', recordHistory: 'Neueste Einträge', recordedDay: 'Erstmals erfasst', historicalAward: 'Historisches Abzeichen · Datum unbekannt', + pinBadge: 'Abzeichen anheften', unpinBadge: 'Abzeichen lösen', pinLimit: 'Bis zu 6 Abzeichen anheften.', pinFailed: 'Anheften nicht gespeichert. Erneut versuchen.', + hiddenPins: 'Ausgeblendete angeheftete Abzeichen', + unreadAwards: '{count} neue Abzeichen', unreadGroup: 'Diese Gruppe: {count} Abzeichen', + readGroup: 'Diese Gruppe als gelesen markieren', readFailed: 'Speichern fehlgeschlagen. Diese Gruppe ist noch ungelesen. Erneut versuchen.', + seriesCount: '{earned} / {total} Serien', levelCount: '{earned} / {total} Stufen', shownCount: '{count} Serien angezeigt', + filterLabel: 'Erfolge anzeigen', filterAll: 'Alle Serien', filterNext: 'Nächste Stufen', filterEarned: 'Erreichte Serien', + filterEmpty: 'Keine Serie in dieser Ansicht. Unter Alle Serien ist die gesamte Sammlung sichtbar.', + settingsTitle: 'Erfolgseinstellungen', allLevels: 'Stufenbedingungen', complete: 'Alle Stufen erreicht', + shareSelect: 'Unten bis zu 9 erreichte Abzeichen für die Vorschau auswählen.', shareLimit: '9 Abzeichen ausgewählt. Für ein anderes zuerst eines abwählen.', + sharePending: 'Vorschau wird erstellt…', shareStale: 'Aktuelle Auswahl vor dem Export in der Vorschau prüfen.', + shareFailed: 'Vorschau fehlgeschlagen. Bitte erneut aktualisieren.', shareExportFailed: 'Erfolgskarte konnte nicht gespeichert werden. Bitte erneut versuchen.', + shareBack: 'Zurück zu Erfolgen', + shareSaved: 'Erfolgskarte gespeichert.', shareCancelled: 'Speichern abgebrochen. Die Vorschau bleibt unverändert.', shareSaving: 'Karte wird gespeichert…', + shareSelectionChanged: 'Verfügbare Abzeichen haben sich geändert. Auswahl prüfen; neue Stufen werden nicht automatisch ausgewählt.', + shareTitle: 'Erfolgskarte', shareIntro: 'Erreichte Abzeichen für eine lokale SVG auswählen; erst nach Vorschau exportieren.', shareChoose: 'Abzeichen auswählen', shareEmpty: 'Erst ein Abzeichen verdienen, dann eine Karte erstellen.', + unlockedNotice: 'Abzeichen freigeschaltet: {name}', unlockedBatch: '{count} Erfolgsserien weiterentwickelt', + upgradeOn: 'Lokale Erfolge sind jetzt standardmäßig aktiviert. Historische Abzeichen werden ohne Hinweis ergänzt. Hier oder in den Dashboard-Einstellungen können Sie Erfolge deaktivieren.', + upgradeOff: 'Lokale Erfolge sind verfügbar. Ihre bisherige Deaktivierung bleibt erhalten. Aktivieren ist in den Dashboard-Einstellungen möglich.', + openAchievements: 'Erfolge ansehen', disableAchievements: 'Erfolge deaktivieren', + disableFailed: 'Deaktivierung konnte nicht gespeichert werden. Erfolge bleiben aktiviert. Bitte in den Dashboard-Einstellungen erneut versuchen.', + groups: { claude: 'Claude', codex: 'Codex', combined: 'Gemeinsame Aktivität', plugin: 'Erweiterung erkunden' }, + levels: ['Bronze', 'Silber', 'Gold', 'Platin', 'Legendär'], milestone: 'Meilenstein', + streakTheme: 'Aktivitätsserie', sharedDaysTheme: 'Gemeinsame aktive Tage', + featureJourney: 'Lokale Funktionsschritte', + journeySteps: ['Sammlung ansehen', 'Karte voranzeigen', 'Karte lokal exportieren'], + stepRecorded: 'Erfasst', stepUnrecorded: 'Nicht erfasst', + viewCurrentModels: 'Aktuelle Modellstatistik ansehen', viewCurrentTokens: 'Aktuelle Tokenstatistik ansehen', backToBadge: 'Zurück zum Abzeichen', + units: { + outputTokens: 'Ausgabe-Token', + cacheReadTokens: 'Cache-Lese-Token', + cacheCreationTokens: 'Cache-Schreib-Token', + encounter: 'Modell genutzt', + models: 'qualifizierte Mehrmodell-Aufgaben', + roots: 'unabhängige Hauptaufgaben', + childSessions: 'qualifizierte Unteragenten', + efforts: 'qualifizierte Denkstufen', + days: 'aktive Tage', + sessions: 'Sitzungen mit Fable-Ausgabe', + outputSessions: 'Sitzungen mit Ausgabe', + hours: 'aufeinanderfolgende UTC-Stunden mit Ausgabe (keine menschliche Arbeitszeit)', + tokens: 'verarbeitete Token', + agents: 'qualifizierte Team-Aufgaben', + agentRoots: 'qualifizierte Team-Aufgaben', + streak: 'aufeinanderfolgende aktive Tage', + families: 'qualifizierte Modellfamilien', + threeFamilyDay: 'ein abgeschlossener Tag: beliebige 3 von 7 erkannten Familien (einschließlich unterstützter Fable 5/5.1), je 1M+ verarbeitete Token mit positiver Ausgabe', + qualifiedDays: 'abgeschlossene Tage mit je 1M+ verarbeiteten Familien-Token und positiver Ausgabe', + sharedDays: 'abgeschlossene Tage mit je 1M+ verarbeiteten Token und positiver Ausgabe pro Anbieter', + weeks: 'abgeschlossene Montagwochen mit 4+ Tagen, jeder mit 1M+ verarbeiteten Token und positiver Ausgabe', + weeklyAverage: 'höchster Schnitt verarbeiteter Token/Woche über 4 aufeinanderfolgende abgeschlossene Montagwochen; Nullwochen enthalten, Summe ÷ 4', + codexCachedTokens: 'gecachte Eingabe-Token', + settings: 'bewusst geänderte unterschiedliche sichere Einstellungen', features: 'lokale Funktionen: Galerie, Abzeichenvorschau und SVG-Export', + exports: 'erfolgreiche lokale SVG-Exporte', feedback: 'gespeicherte lokale Feedbacks', + nightPeak: '30-Tage-Profil: 14+ aktive Tage, 5+ Nachttage; Ausgabestunden von 22–06 Uhr liegen mindestens 20% über jedem anderen 8-Stunden-Band (keine Arbeitszeit)', + relay: 'Sitzungen mit Ausgabe von Fable und einer anderen erkannten Claude-Familie', + }, + titles: { + 'sonnet-main': 'Sonnet als Hauptmodell', 'opus-cannon': 'Opus intensiv', 'haiku-worker': 'Haiku im Einsatz', + ...apiAchievementTitles('de-DE'), + 'agent-standup': 'Agenten-Team · Bronze', 'tiny-ai-company': 'Agenten-Team · Silber', + 'agent-team-gold': 'Agenten-Team · Gold', 'same-root-models-silver': 'Modelle je Aufgabe · Silber', + 'same-root-models-gold': 'Modelle je Aufgabe · Gold', 'effort-breadth-silver': 'Denkstufen · Silber', + 'model-rainbow-bronze': 'Modellvielfalt · Bronze', 'model-rainbow-gold': 'Modellvielfalt · Gold', + 'active-day-streak': 'Sieben Tage in Folge', 'streak-machine': 'Vierzehn Tage in Folge', + 'model-rainbow': 'Modellvielfalt', + 'cache-wizard': 'Cache-Lesemeilenstein', 'agent-conductor': 'Agenten-Koordinator', + 'multi-core-human': 'Modelle je Aufgabe', 'effort-explorer': 'Denkstufen-Entdecker', + 'trillion-token-club': 'Billionen-Club', 'hundred-million-club': '100-Millionen-Club', + 'astra-regular': 'Astra-Stammgast', 'sol-regular': 'Sol-Stammgast', 'luna-regular': 'Luna-Stammgast', + 'effort-spectrum': 'Alle sechs Denkstufen', + 'billion-token-club': '1-Milliarde-Club', 'ten-billion-token-club': '10-Milliarden-Club', + 'hundred-billion-token-club': '100-Milliarden-Club', + 'fable-volume-bronze': 'Fable-Volumen · Bronze', 'fable-volume-silver': 'Fable-Volumen · Silber', + 'fable-volume-gold': 'Fable-Volumen · Gold', 'fable-days-bronze': 'Fable-Tage · Bronze', + 'fable-days-silver': 'Fable-Tage · Silber', 'fable-days-gold': 'Fable-Tage · Gold', + 'fable-relay': 'Fable: gemeinsame Sitzung', + 'fable-sessions-bronze': 'Fable-Sitzungen · Bronze', 'fable-sessions-silver': 'Fable-Sitzungen · Silber', + 'fable-sessions-gold': 'Fable-Sitzungen · Gold', + 'agent-conductor-silver': 'Agenten-Koordinator · Silber', 'agent-conductor-gold': 'Agenten-Koordinator · Gold', + 'root-portfolio-bronze': 'Unabhängige Hauptaufgaben · Bronze', 'root-portfolio-silver': 'Unabhängige Hauptaufgaben · Silber', + 'root-portfolio-gold': 'Unabhängige Hauptaufgaben · Gold', + 'claude-output-bronze': 'Claude-Ausgabe · Bronze', 'claude-output-silver': 'Claude-Ausgabe · Silber', + 'claude-output-gold': 'Claude-Ausgabe · Gold', + 'claude-output-sessions-bronze': 'Ausgabesitzungen · Bronze', 'claude-output-sessions-silver': 'Ausgabesitzungen · Silber', + 'claude-output-sessions-gold': 'Ausgabesitzungen · Gold', + 'claude-cache-creation-bronze': 'Cache-Erstellung · Bronze', 'claude-cache-creation-silver': 'Cache-Erstellung · Silber', + 'claude-cache-creation-gold': 'Cache-Erstellung · Gold', + 'agent-participation-bronze': 'Agententeilnahme · Bronze', 'agent-participation-silver': 'Agententeilnahme · Silber', + 'agent-participation-gold': 'Agententeilnahme · Gold', + 'model-relay-roots-bronze': 'Mehrmodell-Aufgaben · Bronze', 'model-relay-roots-silver': 'Mehrmodell-Aufgaben · Silber', + 'model-relay-roots-gold': 'Mehrmodell-Aufgaben · Gold', + 'active-days-bronze': 'Aktive Tage · Bronze', 'active-days-silver': 'Aktive Tage · Silber', + 'active-days-gold': 'Aktive Tage · Gold', + 'weekly-volume-bronze': 'Wochenvolumen · Bronze', 'weekly-volume-silver': 'Wochenvolumen · Silber', + 'weekly-volume-gold': 'Wochenvolumen · Gold', + 'output-hours-bronze': 'Stündliche Ausgabe · Bronze', 'output-hours-silver': 'Stündliche Ausgabe · Silber', + 'output-hours-gold': 'Stündliche Ausgabe · Gold', 'night-output-peak': 'Nächtliches Ausgabemuster', + 'settings-explorer-bronze': 'Einstellungen · Bronze', 'settings-explorer-silver': 'Einstellungen · Silber', + 'settings-explorer-gold': 'Einstellungen · Gold', 'safe-feature-explorer': 'Sichere Funktionen', + 'local-share-export': 'Lokaler Export', 'local-advice-feedback': 'Lokales Feedback', + 'sonnet-main-silver': 'Sonnet als Hauptmodell · Silber', 'sonnet-main-gold': 'Sonnet als Hauptmodell · Gold', + 'opus-cannon-silver': 'Opus intensiv · Silber', 'opus-cannon-gold': 'Opus intensiv · Gold', + 'haiku-worker-silver': 'Haiku im Einsatz · Silber', 'haiku-worker-gold': 'Haiku im Einsatz · Gold', + 'cache-wizard-silver': 'Cache-Lesen · Silber', 'cache-wizard-gold': 'Cache-Lesen · Gold', + 'astra-regular-silver': 'Astra-Stammgast · Silber', 'astra-regular-gold': 'Astra-Stammgast · Gold', + 'sol-regular-silver': 'Sol-Stammgast · Silber', 'sol-regular-gold': 'Sol-Stammgast · Gold', + 'luna-regular-silver': 'Luna-Stammgast · Silber', 'luna-regular-gold': 'Luna-Stammgast · Gold', + 'active-day-streak-silver': '30 Tage in Folge', 'active-day-streak-gold': '100 Tage in Folge', + 'codex-output-bronze': 'Codex-Ausgabe · Bronze', + 'codex-output-silver': 'Codex-Ausgabe · Silber', + 'codex-output-gold': 'Codex-Ausgabe · Gold', + 'codex-cache-read-bronze': 'Codex-Cache · Bronze', + 'codex-cache-read-silver': 'Codex-Cache · Silber', + 'codex-cache-read-gold': 'Codex-Cache · Gold', + 'astra-days-bronze': 'Astra-Tage · Bronze', + 'astra-days-silver': 'Astra-Tage · Silber', + 'astra-days-gold': 'Astra-Tage · Gold', + 'sol-days-bronze': 'Sol-Tage · Bronze', + 'sol-days-silver': 'Sol-Tage · Silber', + 'sol-days-gold': 'Sol-Tage · Gold', + 'luna-days-bronze': 'Luna-Tage · Bronze', + 'luna-days-silver': 'Luna-Tage · Silber', + 'luna-days-gold': 'Luna-Tage · Gold', + 'sonnet-days-bronze': 'Sonnet-Tage · Bronze', + 'sonnet-days-silver': 'Sonnet-Tage · Silber', + 'sonnet-days-gold': 'Sonnet-Tage · Gold', + 'opus-days-bronze': 'Opus-Tage · Bronze', + 'opus-days-silver': 'Opus-Tage · Silber', + 'opus-days-gold': 'Opus-Tage · Gold', + 'haiku-days-bronze': 'Haiku-Tage · Bronze', + 'haiku-days-silver': 'Haiku-Tage · Silber', + 'haiku-days-gold': 'Haiku-Tage · Gold', + 'dual-provider-days-bronze': 'Gemeinsame Tage · Bronze', + 'dual-provider-days-silver': 'Gemeinsame Tage · Silber', + 'dual-provider-days-gold': 'Gemeinsame Tage · Gold', + 'active-weeks-bronze': 'Aktive Wochen · Bronze', + 'active-weeks-silver': 'Aktive Wochen · Silber', + 'active-weeks-gold': 'Aktive Wochen · Gold', + 'weekly-average-bronze': 'Wochendurchschnitt · Bronze', + 'weekly-average-silver': 'Wochendurchschnitt · Silber', + 'weekly-average-gold': 'Wochendurchschnitt · Gold', + 'three-family-day': 'Drei Familien an einem Tag', + 'dual-provider-day': 'Gemeinsamer Tag', + }, + }, + ja: { + tab: '実績', heading: 'ローカル実績', intro: '索引済みのローカル利用記録に基づく実績です。能力や生産性の評価ではありません。', + evidenceTitle: 'データの根拠', + evidencePending: '根拠を確認中', evidenceStale: '根拠が古くなっています', + lowerBoundNote: '≥ 検証済みの下限。未検証の Codex 系譜や日付別使用量は含みません。', + lowerBoundDetails: '暦の実績には検証済みの残存ログが必要です。4週平均は最初のイベント週を除外します。削除・ローテーションされたログには欠落があり得ます。', + earned: '獲得済み', locked: '進行中', nextTarget: '次のレベル', nextStage: '次の中間目標', + myBadges: 'マイバッジ', nextGoals: '次の目標', galleryTitle: 'すべてのバッジ', viewBadge: 'バッジを見る', + recentAwards: '最近の獲得', recordHistory: '最近の記録', recordedDay: '初回記録日', historicalAward: '過去の実績 · 日付不明', + pinBadge: '実績を固定', unpinBadge: '固定を解除', pinLimit: '最大6件の実績を固定できます。', pinFailed: '固定を保存できませんでした。再試行してください。', + hiddenPins: '非表示の固定済み実績', + unreadAwards: '新規 {count} 件', unreadGroup: 'このグループ:{count} 件', + readGroup: 'このグループを既読にする', readFailed: '保存できませんでした。このグループは未読です。再試行してください。', + seriesCount: '{earned} / {total} シリーズ', levelCount: '{earned} / {total} レベル', shownCount: '{count} シリーズを表示', + filterLabel: '表示範囲', filterAll: '全シリーズ', filterNext: '未獲得レベル', filterEarned: '獲得済みシリーズ', + filterEmpty: 'この範囲に実績はありません。「全シリーズ」で図鑑を表示できます。', + settingsTitle: '実績の設定', allLevels: '各レベルの条件', complete: '全レベル獲得済み', + shareSelect: '下から獲得済みバッジを最大 9 個選び、プレビューを作成してください。', shareLimit: '9 個選択済みです。別のバッジを選ぶには 1 個解除してください。', + sharePending: 'プレビューを作成中…', shareStale: '現在の選択をプレビューしてから書き出してください。', + shareFailed: 'プレビューの作成に失敗しました。再度更新してください。', shareExportFailed: '実績カードを保存できませんでした。再試行してください。', + shareBack: '実績に戻る', + shareSaved: '実績カードを保存しました。', shareCancelled: '保存をキャンセルしました。プレビューは変わりません。', shareSaving: 'カードを保存中…', + shareSelectionChanged: '共有可能なバッジが変わりました。選択を確認してください。新しいランクは自動選択されません。', + shareTitle: '実績共有カード', shareIntro: '獲得済みのバッジを選んでローカル SVG に。プレビュー後に書き出します。', shareChoose: '表示するバッジ', shareEmpty: 'バッジを獲得するとカードを作れます。', + unlockedNotice: 'バッジを獲得:{name}', unlockedBatch: '{count} 個の実績シリーズが進展', + upgradeOn: 'ローカル実績は既定で有効です。過去のバッジは通知せず追加します。ここで、またはダッシュボードの設定で無効にできます。', + upgradeOff: 'ローカル実績が利用可能になりました。以前の無効設定は保持されています。ダッシュボードの設定で有効にできます。', + openAchievements: '実績を表示', disableAchievements: '実績を無効にする', + disableFailed: '無効設定を保存できませんでした。実績は有効のままです。ダッシュボードの設定で再試行してください。', + groups: { claude: 'Claude', codex: 'Codex', combined: '統合アクティビティ', plugin: '拡張機能の探索' }, + levels: ['ブロンズ', 'シルバー', 'ゴールド', 'プラチナ', 'レジェンド'], milestone: 'マイルストーン', + streakTheme: '連続アクティブ日', sharedDaysTheme: '両プロバイダーの活動日', + featureJourney: 'ローカル機能の体験', + journeySteps: ['図鑑を見る', '共有カードをプレビュー', 'カードをローカルに出力'], + stepRecorded: '記録済み', stepUnrecorded: '記録なし', + viewCurrentModels: '現在のモデル統計を見る', viewCurrentTokens: '現在のトークン統計を見る', backToBadge: 'バッジに戻る', + units: { + outputTokens: '出力トークン', + cacheReadTokens: 'キャッシュ読み取りトークン', + cacheCreationTokens: 'キャッシュ書き込みトークン', + encounter: 'モデルを使用', + models: '件の条件を満たす複数モデルのタスク', + roots: '独立したメインタスク', + childSessions: '件の条件を満たす子エージェント', + efforts: '段階の条件を満たす推論レベル', + days: '活動日', + sessions: 'Fable の出力があるセッション', + outputSessions: '出力があるセッション', + hours: '出力がある連続 UTC 時間(人の作業時間ではありません)', + tokens: '処理トークン', + agents: '件の条件を満たすチームタスク', + agentRoots: '件の条件を満たすチームタスク', + streak: '連続活動日', + families: '系統(条件を満たすモデル系列)', + threeFamilyDay: '同じ終了済みの日に、認識済み7系統中の任意の3系統(対応する Fable 5/5.1 を含む)が各100万以上の処理トークンと正の出力', + qualifiedDays: '終了済みの日数:各日で当該系統の処理トークン100万以上と正の出力', + sharedDays: '終了済みの日数:両プロバイダーそれぞれに処理トークン100万以上と正の出力', + weeks: '月曜始まりの終了済み週数:4+日で各日100万以上の処理トークンと正の出力', + weeklyAverage: '連続する月曜始まりの終了済み4週の週当たり処理トークン平均の最大値;ゼロ使用週を含み、合計 ÷ 4', + codexCachedTokens: 'キャッシュ済み入力トークン', + settings: '種類の意図的に変更した安全な設定', features: 'ローカル機能:図鑑、バッジのプレビュー、SVG 出力', + exports: '回の成功したローカル SVG 出力', feedback: '件の保存済みローカル提案フィードバック', + nightPeak: '直近30日:活動14日以上、夜間5日以上;22–06時の出力時間数が他の各8時間帯より20%以上多い(作業時間ではない)', + relay: '件の Fable と別の認識済み Claude 系列が共に出力したセッション', + }, + titles: { + 'sonnet-main': 'Sonnet の主力運用', 'opus-cannon': 'Opus の深い活用', 'haiku-worker': 'Haiku の活用', + ...apiAchievementTitles('ja'), + 'agent-standup': 'エージェント連携 · ブロンズ', 'tiny-ai-company': 'エージェント連携 · シルバー', + 'agent-team-gold': 'エージェント連携 · ゴールド', 'same-root-models-silver': '同一タスクのモデル · シルバー', + 'same-root-models-gold': '同一タスクのモデル · ゴールド', 'effort-breadth-silver': '推論レベル · シルバー', + 'model-rainbow-bronze': 'モデルの多彩な活用 · ブロンズ', 'model-rainbow-gold': 'モデルの多彩な活用 · ゴールド', + 'active-day-streak': '7 日連続', 'streak-machine': '14 日連続', + 'model-rainbow': 'モデルの多彩な活用', + 'cache-wizard': 'キャッシュ読み取りの節目', 'agent-conductor': 'エージェント統括', + 'multi-core-human': '同一タスクのモデル', 'effort-explorer': '推論レベル探検家', + 'trillion-token-club': '1 兆クラブ', 'hundred-million-club': '1 億クラブ', + 'astra-regular': 'Astra の常連', 'sol-regular': 'Sol の常連', 'luna-regular': 'Luna の常連', + 'effort-spectrum': '6 段階すべて', + 'billion-token-club': '10 億クラブ', 'ten-billion-token-club': '100 億クラブ', + 'hundred-billion-token-club': '1000 億クラブ', + 'fable-volume-bronze': 'Fable 使用量 · ブロンズ', 'fable-volume-silver': 'Fable 使用量 · シルバー', + 'fable-volume-gold': 'Fable 使用量 · ゴールド', 'fable-days-bronze': 'Fable 活動日 · ブロンズ', + 'fable-days-silver': 'Fable 活動日 · シルバー', 'fable-days-gold': 'Fable 活動日 · ゴールド', + 'fable-relay': 'Fable 同一セッション', + 'fable-sessions-bronze': 'Fable 出力セッション · ブロンズ', 'fable-sessions-silver': 'Fable 出力セッション · シルバー', + 'fable-sessions-gold': 'Fable 出力セッション · ゴールド', + 'agent-conductor-silver': 'エージェント統括 · シルバー', 'agent-conductor-gold': 'エージェント統括 · ゴールド', + 'root-portfolio-bronze': '独立メインタスク · ブロンズ', 'root-portfolio-silver': '独立メインタスク · シルバー', + 'root-portfolio-gold': '独立メインタスク · ゴールド', + 'claude-output-bronze': 'Claude 出力 · ブロンズ', 'claude-output-silver': 'Claude 出力 · シルバー', + 'claude-output-gold': 'Claude 出力 · ゴールド', + 'claude-output-sessions-bronze': '出力セッション · ブロンズ', 'claude-output-sessions-silver': '出力セッション · シルバー', + 'claude-output-sessions-gold': '出力セッション · ゴールド', + 'claude-cache-creation-bronze': 'キャッシュ書き込み · ブロンズ', 'claude-cache-creation-silver': 'キャッシュ書き込み · シルバー', + 'claude-cache-creation-gold': 'キャッシュ書き込み · ゴールド', + 'agent-participation-bronze': '子エージェント参加 · ブロンズ', 'agent-participation-silver': '子エージェント参加 · シルバー', + 'agent-participation-gold': '子エージェント参加 · ゴールド', + 'model-relay-roots-bronze': '複数モデルのタスク · ブロンズ', 'model-relay-roots-silver': '複数モデルのタスク · シルバー', + 'model-relay-roots-gold': '複数モデルのタスク · ゴールド', + 'active-days-bronze': '活動日の蓄積 · ブロンズ', 'active-days-silver': '活動日の蓄積 · シルバー', + 'active-days-gold': '活動日の蓄積 · ゴールド', + 'weekly-volume-bronze': '週間使用量 · ブロンズ', 'weekly-volume-silver': '週間使用量 · シルバー', + 'weekly-volume-gold': '週間使用量 · ゴールド', + 'output-hours-bronze': '毎時出力 · ブロンズ', 'output-hours-silver': '毎時出力 · シルバー', + 'output-hours-gold': '毎時出力 · ゴールド', 'night-output-peak': '夜間の出力傾向', + 'settings-explorer-bronze': '設定探索 · ブロンズ', 'settings-explorer-silver': '設定探索 · シルバー', + 'settings-explorer-gold': '設定探索 · ゴールド', 'safe-feature-explorer': '安全な機能の探索', + 'local-share-export': 'ローカル共有の書き出し', 'local-advice-feedback': 'ローカル助言の評価', + 'sonnet-main-silver': 'Sonnet の主力運用 · シルバー', 'sonnet-main-gold': 'Sonnet の主力運用 · ゴールド', + 'opus-cannon-silver': 'Opus の深い活用 · シルバー', 'opus-cannon-gold': 'Opus の深い活用 · ゴールド', + 'haiku-worker-silver': 'Haiku の活用 · シルバー', 'haiku-worker-gold': 'Haiku の活用 · ゴールド', + 'cache-wizard-silver': 'キャッシュ読み取り · シルバー', 'cache-wizard-gold': 'キャッシュ読み取り · ゴールド', + 'astra-regular-silver': 'Astra の常連 · シルバー', 'astra-regular-gold': 'Astra の常連 · ゴールド', + 'sol-regular-silver': 'Sol の常連 · シルバー', 'sol-regular-gold': 'Sol の常連 · ゴールド', + 'luna-regular-silver': 'Luna の常連 · シルバー', 'luna-regular-gold': 'Luna の常連 · ゴールド', + 'active-day-streak-silver': '30 日連続', 'active-day-streak-gold': '100 日連続', + 'codex-output-bronze': 'Codex 出力 · ブロンズ', + 'codex-output-silver': 'Codex 出力 · シルバー', + 'codex-output-gold': 'Codex 出力 · ゴールド', + 'codex-cache-read-bronze': 'Codex キャッシュ · ブロンズ', + 'codex-cache-read-silver': 'Codex キャッシュ · シルバー', + 'codex-cache-read-gold': 'Codex キャッシュ · ゴールド', + 'astra-days-bronze': 'Astra 活動日 · ブロンズ', + 'astra-days-silver': 'Astra 活動日 · シルバー', + 'astra-days-gold': 'Astra 活動日 · ゴールド', + 'sol-days-bronze': 'Sol 活動日 · ブロンズ', + 'sol-days-silver': 'Sol 活動日 · シルバー', + 'sol-days-gold': 'Sol 活動日 · ゴールド', + 'luna-days-bronze': 'Luna 活動日 · ブロンズ', + 'luna-days-silver': 'Luna 活動日 · シルバー', + 'luna-days-gold': 'Luna 活動日 · ゴールド', + 'sonnet-days-bronze': 'Sonnet 活動日 · ブロンズ', + 'sonnet-days-silver': 'Sonnet 活動日 · シルバー', + 'sonnet-days-gold': 'Sonnet 活動日 · ゴールド', + 'opus-days-bronze': 'Opus 活動日 · ブロンズ', + 'opus-days-silver': 'Opus 活動日 · シルバー', + 'opus-days-gold': 'Opus 活動日 · ゴールド', + 'haiku-days-bronze': 'Haiku 活動日 · ブロンズ', + 'haiku-days-silver': 'Haiku 活動日 · シルバー', + 'haiku-days-gold': 'Haiku 活動日 · ゴールド', + 'dual-provider-days-bronze': '両プロバイダーの日数 · ブロンズ', + 'dual-provider-days-silver': '両プロバイダーの日数 · シルバー', + 'dual-provider-days-gold': '両プロバイダーの日数 · ゴールド', + 'active-weeks-bronze': '活動週 · ブロンズ', + 'active-weeks-silver': '活動週 · シルバー', + 'active-weeks-gold': '活動週 · ゴールド', + 'weekly-average-bronze': '4週の週平均 · ブロンズ', + 'weekly-average-silver': '4週の週平均 · シルバー', + 'weekly-average-gold': '4週の週平均 · ゴールド', + 'three-family-day': '同日に3系統', + 'dual-provider-day': '同日に両プロバイダー', + }, + }, + ko: { + tab: '업적', heading: '로컬 업적', intro: '색인된 로컬 사용 기록에 기반합니다. 능력이나 생산성을 평가하지 않습니다.', + evidenceTitle: '데이터 근거', + evidencePending: '근거 확인 중', evidenceStale: '근거가 오래됨', + lowerBoundNote: '≥ 검증된 하한. 검증되지 않은 Codex 계보나 날짜별 사용량은 제외합니다.', + lowerBoundDetails: '달력 업적에는 검증된 잔존 로그가 필요합니다. 4주 평균은 첫 이벤트 주를 제외합니다. 삭제·순환된 로그에는 누락이 있을 수 있습니다.', + earned: '획득', locked: '진행 중', nextTarget: '다음 단계', nextStage: '다음 중간 목표', + myBadges: '내 배지', nextGoals: '다음 목표', galleryTitle: '전체 배지', viewBadge: '배지 보기', + recentAwards: '최근 획득', recordHistory: '최근 기록', recordedDay: '최초 기록일', historicalAward: '과거 업적 · 날짜 미상', + pinBadge: '배지 고정', unpinBadge: '고정 해제', pinLimit: '배지는 최대 6개까지 고정할 수 있습니다.', pinFailed: '고정을 저장하지 못했습니다. 다시 시도하세요.', + hiddenPins: '숨겨진 고정 배지', + unreadAwards: '새 업적 {count}개', unreadGroup: '이 그룹: {count}개', + readGroup: '이 그룹 읽음 처리', readFailed: '저장하지 못했습니다. 이 그룹은 아직 읽지 않은 상태입니다. 다시 시도하세요.', + seriesCount: '{earned} / {total} 시리즈', levelCount: '{earned} / {total} 단계', shownCount: '{count}개 시리즈 표시', + filterLabel: '표시 범위', filterAll: '전체 시리즈', filterNext: '다음 단계', filterEarned: '획득한 시리즈', + filterEmpty: '이 범위에 업적이 없습니다. 전체 시리즈에서 도감을 확인하세요.', + settingsTitle: '업적 설정', allLevels: '단계별 조건', complete: '모든 단계 획득', + shareSelect: '아래에서 획득한 배지를 최대 9개 선택하여 미리 보세요.', shareLimit: '배지 9개를 선택했습니다. 다른 배지를 고르려면 하나를 해제하세요.', + sharePending: '미리 보기 생성 중…', shareStale: '현재 선택을 미리 본 뒤 내보내세요.', + shareFailed: '미리 보기 생성에 실패했습니다. 다시 업데이트하세요.', shareExportFailed: '업적 카드를 저장하지 못했습니다. 다시 시도하세요.', + shareBack: '업적으로 돌아가기', + shareSaved: '업적 카드를 저장했습니다.', shareCancelled: '저장을 취소했습니다. 미리 보기는 유지됩니다.', shareSaving: '카드 저장 중…', + shareSelectionChanged: '공유 가능한 배지가 변경되었습니다. 선택을 확인하세요. 새 등급은 자동으로 선택되지 않습니다.', + shareTitle: '업적 공유 카드', shareIntro: '획득한 배지를 골라 로컬 SVG를 만듭니다. 미리 본 뒤 내보내세요.', shareChoose: '공유할 배지', shareEmpty: '배지를 획득하면 카드를 만들 수 있습니다.', + unlockedNotice: '배지 잠금 해제: {name}', unlockedBatch: '{count}개 업적 시리즈에 새 진전', + upgradeOn: '로컬 업적이 기본으로 활성화됩니다. 과거 배지는 알림 없이 추가됩니다. 여기 또는 대시보드 설정에서 끌 수 있습니다.', + upgradeOff: '로컬 업적을 사용할 수 있습니다. 기존 비활성화 설정은 유지됩니다. 대시보드 설정에서 켤 수 있습니다.', + openAchievements: '업적 보기', disableAchievements: '업적 끄기', + disableFailed: '비활성화 설정을 저장하지 못했습니다. 업적은 계속 활성화됩니다. 대시보드 설정에서 다시 시도하세요.', + groups: { claude: 'Claude', codex: 'Codex', combined: '통합 활동', plugin: '확장 기능 탐색' }, + levels: ['브론즈', '실버', '골드', '플래티넘', '전설'], milestone: '이정표', + streakTheme: '연속 활동', sharedDaysTheme: '두 공급자 활동일', + featureJourney: '로컬 기능 체험', + journeySteps: ['도감 보기', '공유 카드 미리 보기', '카드를 로컬로 내보내기'], + stepRecorded: '기록됨', stepUnrecorded: '기록 없음', + viewCurrentModels: '현재 모델 통계 보기', viewCurrentTokens: '현재 토큰 통계 보기', backToBadge: '배지로 돌아가기', + units: { + outputTokens: '출력 토큰', + cacheReadTokens: '캐시 읽기 토큰', + cacheCreationTokens: '캐시 쓰기 토큰', + encounter: '개 모델 사용', + models: '개 조건을 충족한 다중 모델 작업', + roots: '독립 주 작업', + childSessions: '개 조건을 충족한 하위 에이전트', + efforts: '개 조건을 충족한 추론 단계', + days: '개 활동일', + sessions: '개 Fable 출력 세션', + outputSessions: '개 출력 세션', + hours: '출력이 있는 연속 UTC 시간(사람의 근무 시간 아님)', + tokens: '처리 토큰', + agents: '개 조건을 충족한 팀 작업', + agentRoots: '개 조건을 충족한 팀 작업', + streak: '연속 활동일', + families: '개 조건을 충족한 모델 계열', + threeFamilyDay: '같은 종료된 하루에 인식된 7개 계열 중 임의의 3개 (지원되는 Fable 5/5.1 포함), 각각 100만+ 처리 토큰과 양의 출력', + qualifiedDays: '종료된 일수: 매일 해당 계열의 100만+ 처리 토큰과 양의 출력', + sharedDays: '종료된 일수: 두 제공자 각각 100만+ 처리 토큰과 양의 출력', + weeks: '월요일 시작의 종료된 주 수: 4+일, 매일 100만+ 처리 토큰과 양의 출력', + weeklyAverage: '월요일 시작의 종료된 연속 4주에서 주당 처리 토큰 평균 최댓값; 사용량 0인 주 포함, 합계 ÷ 4', + codexCachedTokens: '캐시된 입력 토큰', + settings: '개 직접 변경한 서로 다른 안전 설정', features: '개 로컬 기능: 도감, 배지 미리보기, SVG 내보내기', + exports: '회 성공한 로컬 SVG 내보내기', feedback: '개 저장된 로컬 조언 피드백', + nightPeak: '최근 30일: 활동 14일 이상, 야간 5일 이상; 22–06시 출력 시간 수가 다른 각 8시간대보다 20% 이상 많음(근무 시간 아님)', + relay: '개 Fable과 다른 인식된 Claude 계열이 함께 출력한 세션', + }, + titles: { + 'sonnet-main': 'Sonnet 주력', 'opus-cannon': 'Opus 집중 활용', 'haiku-worker': 'Haiku 활용', + ...apiAchievementTitles('ko'), + 'agent-standup': '에이전트 협업 · 브론즈', 'tiny-ai-company': '에이전트 협업 · 실버', + 'agent-team-gold': '에이전트 협업 · 골드', 'same-root-models-silver': '한 작업의 모델 · 실버', + 'same-root-models-gold': '한 작업의 모델 · 골드', 'effort-breadth-silver': '추론 수준 · 실버', + 'model-rainbow-bronze': '다채로운 모델 · 브론즈', 'model-rainbow-gold': '다채로운 모델 · 골드', + 'active-day-streak': '7일 연속', 'streak-machine': '14일 연속', + 'model-rainbow': '다채로운 모델', + 'cache-wizard': '캐시 읽기 이정표', 'agent-conductor': '에이전트 조율자', + 'multi-core-human': '한 작업의 모델', 'effort-explorer': '추론 수준 탐험가', + 'trillion-token-club': '1조 클럽', 'hundred-million-club': '1억 클럽', + 'astra-regular': 'Astra 단골', 'sol-regular': 'Sol 단골', 'luna-regular': 'Luna 단골', + 'effort-spectrum': '여섯 수준 모두', + 'billion-token-club': '10억 클럽', 'ten-billion-token-club': '100억 클럽', + 'hundred-billion-token-club': '1천억 클럽', + 'fable-volume-bronze': 'Fable 사용량 · 브론즈', 'fable-volume-silver': 'Fable 사용량 · 실버', + 'fable-volume-gold': 'Fable 사용량 · 골드', 'fable-days-bronze': 'Fable 활동일 · 브론즈', + 'fable-days-silver': 'Fable 활동일 · 실버', 'fable-days-gold': 'Fable 활동일 · 골드', + 'fable-relay': 'Fable 같은 세션', + 'fable-sessions-bronze': 'Fable 출력 세션 · 브론즈', 'fable-sessions-silver': 'Fable 출력 세션 · 실버', + 'fable-sessions-gold': 'Fable 출력 세션 · 골드', + 'agent-conductor-silver': '에이전트 조율자 · 실버', 'agent-conductor-gold': '에이전트 조율자 · 골드', + 'root-portfolio-bronze': '독립 주 작업 · 브론즈', 'root-portfolio-silver': '독립 주 작업 · 실버', + 'root-portfolio-gold': '독립 주 작업 · 골드', + 'claude-output-bronze': 'Claude 출력 · 브론즈', 'claude-output-silver': 'Claude 출력 · 실버', + 'claude-output-gold': 'Claude 출력 · 골드', + 'claude-output-sessions-bronze': '출력 세션 · 브론즈', 'claude-output-sessions-silver': '출력 세션 · 실버', + 'claude-output-sessions-gold': '출력 세션 · 골드', + 'claude-cache-creation-bronze': '캐시 쓰기 · 브론즈', 'claude-cache-creation-silver': '캐시 쓰기 · 실버', + 'claude-cache-creation-gold': '캐시 쓰기 · 골드', + 'agent-participation-bronze': '하위 에이전트 참여 · 브론즈', 'agent-participation-silver': '하위 에이전트 참여 · 실버', + 'agent-participation-gold': '하위 에이전트 참여 · 골드', + 'model-relay-roots-bronze': '다중 모델 작업 · 브론즈', 'model-relay-roots-silver': '다중 모델 작업 · 실버', + 'model-relay-roots-gold': '다중 모델 작업 · 골드', + 'active-days-bronze': '활동일 누적 · 브론즈', 'active-days-silver': '활동일 누적 · 실버', + 'active-days-gold': '활동일 누적 · 골드', + 'weekly-volume-bronze': '주간 사용량 · 브론즈', 'weekly-volume-silver': '주간 사용량 · 실버', + 'weekly-volume-gold': '주간 사용량 · 골드', + 'output-hours-bronze': '시간별 출력 · 브론즈', 'output-hours-silver': '시간별 출력 · 실버', + 'output-hours-gold': '시간별 출력 · 골드', 'night-output-peak': '야간 출력 패턴', + 'settings-explorer-bronze': '설정 탐색 · 브론즈', 'settings-explorer-silver': '설정 탐색 · 실버', + 'settings-explorer-gold': '설정 탐색 · 골드', 'safe-feature-explorer': '안전한 기능 탐색', + 'local-share-export': '로컬 공유 내보내기', 'local-advice-feedback': '로컬 조언 평가', + 'sonnet-main-silver': 'Sonnet 주력 · 실버', 'sonnet-main-gold': 'Sonnet 주력 · 골드', + 'opus-cannon-silver': 'Opus 집중 활용 · 실버', 'opus-cannon-gold': 'Opus 집중 활용 · 골드', + 'haiku-worker-silver': 'Haiku 활용 · 실버', 'haiku-worker-gold': 'Haiku 활용 · 골드', + 'cache-wizard-silver': '캐시 읽기 · 실버', 'cache-wizard-gold': '캐시 읽기 · 골드', + 'astra-regular-silver': 'Astra 단골 · 실버', 'astra-regular-gold': 'Astra 단골 · 골드', + 'sol-regular-silver': 'Sol 단골 · 실버', 'sol-regular-gold': 'Sol 단골 · 골드', + 'luna-regular-silver': 'Luna 단골 · 실버', 'luna-regular-gold': 'Luna 단골 · 골드', + 'active-day-streak-silver': '30일 연속', 'active-day-streak-gold': '100일 연속', + 'codex-output-bronze': 'Codex 출력 · 브론즈', + 'codex-output-silver': 'Codex 출력 · 실버', + 'codex-output-gold': 'Codex 출력 · 골드', + 'codex-cache-read-bronze': 'Codex 캐시 · 브론즈', + 'codex-cache-read-silver': 'Codex 캐시 · 실버', + 'codex-cache-read-gold': 'Codex 캐시 · 골드', + 'astra-days-bronze': 'Astra 활동일 · 브론즈', + 'astra-days-silver': 'Astra 활동일 · 실버', + 'astra-days-gold': 'Astra 활동일 · 골드', + 'sol-days-bronze': 'Sol 활동일 · 브론즈', + 'sol-days-silver': 'Sol 활동일 · 실버', + 'sol-days-gold': 'Sol 활동일 · 골드', + 'luna-days-bronze': 'Luna 활동일 · 브론즈', + 'luna-days-silver': 'Luna 활동일 · 실버', + 'luna-days-gold': 'Luna 활동일 · 골드', + 'sonnet-days-bronze': 'Sonnet 활동일 · 브론즈', + 'sonnet-days-silver': 'Sonnet 활동일 · 실버', + 'sonnet-days-gold': 'Sonnet 활동일 · 골드', + 'opus-days-bronze': 'Opus 활동일 · 브론즈', + 'opus-days-silver': 'Opus 활동일 · 실버', + 'opus-days-gold': 'Opus 활동일 · 골드', + 'haiku-days-bronze': 'Haiku 활동일 · 브론즈', + 'haiku-days-silver': 'Haiku 활동일 · 실버', + 'haiku-days-gold': 'Haiku 활동일 · 골드', + 'dual-provider-days-bronze': '두 제공자 활동일 · 브론즈', + 'dual-provider-days-silver': '두 제공자 활동일 · 실버', + 'dual-provider-days-gold': '두 제공자 활동일 · 골드', + 'active-weeks-bronze': '활동 주 · 브론즈', + 'active-weeks-silver': '활동 주 · 실버', + 'active-weeks-gold': '활동 주 · 골드', + 'weekly-average-bronze': '4주 주간 평균 · 브론즈', + 'weekly-average-silver': '4주 주간 평균 · 실버', + 'weekly-average-gold': '4주 주간 평균 · 골드', + 'three-family-day': '하루에 세 계열', + 'dual-provider-day': '하루에 두 제공자', + }, + }, + 'pt-BR': { + tab: 'Conquistas', heading: 'Conquistas locais', intro: 'Marcos do uso local indexado; não medem habilidade nem produtividade.', + evidenceTitle: 'Base dos dados', + evidencePending: 'Evidência pendente', evidenceStale: 'Evidência desatualizada', + lowerBoundNote: '≥ Limite inferior verificado. Linhagens ou uso por data do Codex não verificados são excluídos.', + lowerBoundDetails: 'Conquistas de calendário exigem logs retidos verificados; médias de quatro semanas excluem a primeira semana com evento. Logs apagados/rotacionados podem deixar lacunas.', + earned: 'Conquistada', locked: 'Em andamento', nextTarget: 'Próximo nível', nextStage: 'Próxima etapa', + myBadges: 'Minhas medalhas', nextGoals: 'Próximas metas', galleryTitle: 'Coleção completa', viewBadge: 'Ver medalha', + recentAwards: 'Conquistas recentes', recordHistory: 'Registros recentes', recordedDay: 'Primeiro registro', historicalAward: 'Conquista histórica · data desconhecida', + pinBadge: 'Fixar medalha', unpinBadge: 'Desafixar medalha', pinLimit: 'Fixe até 6 medalhas.', pinFailed: 'Não foi possível salvar. Tente novamente.', + hiddenPins: 'Medalhas fixadas ocultas', + unreadAwards: '{count} novas conquistas', unreadGroup: 'Este grupo: {count} conquistas', + readGroup: 'Marcar este grupo como lido', readFailed: 'Não foi possível salvar. Este grupo continua não lido. Tente novamente.', + seriesCount: '{earned} / {total} séries', levelCount: '{earned} / {total} níveis', shownCount: '{count} séries exibidas', + filterLabel: 'Exibir conquistas', filterAll: 'Todas as séries', filterNext: 'Próximos níveis', filterEarned: 'Séries conquistadas', + filterEmpty: 'Nenhuma série nesta visualização. Selecione Todas as séries para ver a coleção.', + settingsTitle: 'Configurações de conquistas', allLevels: 'Requisitos dos níveis', complete: 'Todos os níveis conquistados', + shareSelect: 'Selecione abaixo até 9 emblemas conquistados para a prévia.', shareLimit: '9 emblemas selecionados. Remova um para escolher outro.', + sharePending: 'Preparando prévia…', shareStale: 'Confira a seleção atual na prévia antes de exportar.', + shareFailed: 'Não foi possível preparar a prévia. Atualize novamente.', shareExportFailed: 'Não foi possível salvar o cartão. Tente novamente.', + shareBack: 'Voltar às conquistas', + shareSaved: 'Cartão de conquistas salvo.', shareCancelled: 'Salvamento cancelado. A prévia não mudou.', shareSaving: 'Salvando cartão…', + shareSelectionChanged: 'Os emblemas disponíveis mudaram. Confira a seleção; novos níveis não são selecionados automaticamente.', + shareTitle: 'Cartão de conquistas', shareIntro: 'Escolha emblemas conquistados para um SVG local; exporte após conferir a prévia.', shareChoose: 'Emblemas para compartilhar', shareEmpty: 'Conquiste um emblema para criar o cartão.', + unlockedNotice: 'Emblema desbloqueado: {name}', unlockedBatch: '{count} séries de conquistas avançaram', + upgradeOn: 'As conquistas locais agora vêm ativadas. Os emblemas históricos são adicionados sem aviso. Você pode desativar aqui ou nas configurações do painel.', + upgradeOff: 'As conquistas locais estão disponíveis. Sua opção anterior de desativar foi mantida. Você pode ativar nas configurações do painel.', + openAchievements: 'Ver conquistas', disableAchievements: 'Desativar conquistas', + disableFailed: 'Não foi possível salvar a desativação. As conquistas continuam ativas; tente novamente nas configurações do painel.', + groups: { claude: 'Claude', codex: 'Codex', combined: 'Atividade combinada', plugin: 'Exploração da extensão' }, + levels: ['Bronze', 'Prata', 'Ouro', 'Platina', 'Lendário'], milestone: 'Marco', + streakTheme: 'Sequência de atividade', sharedDaysTheme: 'Dias de atividade conjunta', + featureJourney: 'Etapas de funções locais', + journeySteps: ['Ver coleção', 'Pré-visualizar cartão', 'Exportar cartão localmente'], + stepRecorded: 'Registrado', stepUnrecorded: 'Sem registro', + viewCurrentModels: 'Ver estatísticas atuais de modelos', viewCurrentTokens: 'Ver estatísticas atuais de tokens', backToBadge: 'Voltar ao distintivo', + units: { + outputTokens: 'tokens de saída', + cacheReadTokens: 'tokens de leitura de cache', + cacheCreationTokens: 'tokens de criação de cache', + encounter: 'modelo usado', + models: 'tarefas multimodelo qualificadas', + roots: 'tarefas raiz independentes', + childSessions: 'subagentes qualificados', + efforts: 'níveis de esforço qualificados', + days: 'dias ativos', + sessions: 'sessões com saída Fable', + outputSessions: 'sessões com saída', + hours: 'horas UTC consecutivas com saída (não indica trabalho humano)', + tokens: 'tokens processados', + agents: 'tarefas de equipe qualificadas', + agentRoots: 'tarefas de equipe qualificadas', + streak: 'dias ativos consecutivos', + families: 'famílias de modelos qualificadas', + threeFamilyDay: 'um dia encerrado: quaisquer 3 de 7 famílias reconhecidas (incluindo Fable 5/5.1 compatíveis), cada uma com 1 milhão+ de tokens processados e saída positiva', + qualifiedDays: 'dias encerrados, cada um com 1 milhão+ de tokens processados da família e saída positiva', + sharedDays: 'dias encerrados com 1 milhão+ de tokens processados e saída positiva por provedor', + weeks: 'semanas encerradas desde segunda-feira com 4+ dias, cada um com 1 milhão+ de tokens processados e saída positiva', + weeklyAverage: 'maior média de tokens processados/semana em 4 semanas consecutivas encerradas desde segunda-feira; semanas zeradas incluídas, total ÷ 4', + codexCachedTokens: 'tokens de entrada em cache', + settings: 'configurações seguras distintas alteradas deliberadamente', features: 'recursos locais: catálogo, prévia de emblemas e exportação SVG', + exports: 'exportações SVG locais bem-sucedidas', feedback: 'feedbacks locais de sugestões salvos', + nightPeak: 'perfil de 30 dias: 14+ dias ativos, 5+ noturnos; horas com saída entre 22–06h superam cada outra faixa de 8h em 20% (não é tempo de trabalho)', + relay: 'sessões com saída de Fable e outra família Claude reconhecida', + }, + titles: { + 'sonnet-main': 'Sonnet principal', 'opus-cannon': 'Opus em profundidade', 'haiku-worker': 'Haiku em ação', + ...apiAchievementTitles('pt-BR'), + 'agent-standup': 'Equipe de agentes · Bronze', 'tiny-ai-company': 'Equipe de agentes · Prata', + 'agent-team-gold': 'Equipe de agentes · Ouro', 'same-root-models-silver': 'Modelos por tarefa · Prata', + 'same-root-models-gold': 'Modelos por tarefa · Ouro', 'effort-breadth-silver': 'Níveis de raciocínio · Prata', + 'model-rainbow-bronze': 'Variedade de modelos · Bronze', 'model-rainbow-gold': 'Variedade de modelos · Ouro', + 'active-day-streak': 'Sete dias seguidos', 'streak-machine': 'Quatorze dias seguidos', + 'model-rainbow': 'Variedade de modelos', + 'cache-wizard': 'Leitura de cache', 'agent-conductor': 'Coordenador de agentes', + 'multi-core-human': 'Modelos por tarefa', 'effort-explorer': 'Explorador de níveis', + 'trillion-token-club': 'Clube do trilhão', 'hundred-million-club': 'Clube dos 100 milhões', + 'astra-regular': 'Frequentador Astra', 'sol-regular': 'Frequentador Sol', 'luna-regular': 'Frequentador Luna', + 'effort-spectrum': 'Seis níveis de esforço', + 'billion-token-club': 'Clube do bilhão', 'ten-billion-token-club': 'Clube dos 10 bilhões', + 'hundred-billion-token-club': 'Clube dos 100 bilhões', + 'fable-volume-bronze': 'Volume Fable · Bronze', 'fable-volume-silver': 'Volume Fable · Prata', + 'fable-volume-gold': 'Volume Fable · Ouro', 'fable-days-bronze': 'Dias Fable · Bronze', + 'fable-days-silver': 'Dias Fable · Prata', 'fable-days-gold': 'Dias Fable · Ouro', + 'fable-relay': 'Fable na mesma sessão', + 'fable-sessions-bronze': 'Sessões Fable · Bronze', 'fable-sessions-silver': 'Sessões Fable · Prata', + 'fable-sessions-gold': 'Sessões Fable · Ouro', + 'agent-conductor-silver': 'Coordenador de agentes · Prata', 'agent-conductor-gold': 'Coordenador de agentes · Ouro', + 'root-portfolio-bronze': 'Tarefas raiz independentes · Bronze', 'root-portfolio-silver': 'Tarefas raiz independentes · Prata', + 'root-portfolio-gold': 'Tarefas raiz independentes · Ouro', + 'claude-output-bronze': 'Saída Claude · Bronze', 'claude-output-silver': 'Saída Claude · Prata', + 'claude-output-gold': 'Saída Claude · Ouro', + 'claude-output-sessions-bronze': 'Sessões com saída · Bronze', 'claude-output-sessions-silver': 'Sessões com saída · Prata', + 'claude-output-sessions-gold': 'Sessões com saída · Ouro', + 'claude-cache-creation-bronze': 'Gravação de cache · Bronze', 'claude-cache-creation-silver': 'Gravação de cache · Prata', + 'claude-cache-creation-gold': 'Gravação de cache · Ouro', + 'agent-participation-bronze': 'Participação de agentes · Bronze', 'agent-participation-silver': 'Participação de agentes · Prata', + 'agent-participation-gold': 'Participação de agentes · Ouro', + 'model-relay-roots-bronze': 'Tarefas multimodelo · Bronze', 'model-relay-roots-silver': 'Tarefas multimodelo · Prata', + 'model-relay-roots-gold': 'Tarefas multimodelo · Ouro', + 'active-days-bronze': 'Dias ativos · Bronze', 'active-days-silver': 'Dias ativos · Prata', + 'active-days-gold': 'Dias ativos · Ouro', + 'weekly-volume-bronze': 'Volume semanal · Bronze', 'weekly-volume-silver': 'Volume semanal · Prata', + 'weekly-volume-gold': 'Volume semanal · Ouro', + 'output-hours-bronze': 'Saída por hora · Bronze', 'output-hours-silver': 'Saída por hora · Prata', + 'output-hours-gold': 'Saída por hora · Ouro', 'night-output-peak': 'Padrão de saída noturna', + 'settings-explorer-bronze': 'Configurações · Bronze', 'settings-explorer-silver': 'Configurações · Prata', + 'settings-explorer-gold': 'Configurações · Ouro', 'safe-feature-explorer': 'Recursos seguros', + 'local-share-export': 'Exportação local', 'local-advice-feedback': 'Feedback local', + 'sonnet-main-silver': 'Sonnet principal · Prata', 'sonnet-main-gold': 'Sonnet principal · Ouro', + 'opus-cannon-silver': 'Opus em profundidade · Prata', 'opus-cannon-gold': 'Opus em profundidade · Ouro', + 'haiku-worker-silver': 'Haiku em ação · Prata', 'haiku-worker-gold': 'Haiku em ação · Ouro', + 'cache-wizard-silver': 'Leitura de cache · Prata', 'cache-wizard-gold': 'Leitura de cache · Ouro', + 'astra-regular-silver': 'Frequentador Astra · Prata', 'astra-regular-gold': 'Frequentador Astra · Ouro', + 'sol-regular-silver': 'Frequentador Sol · Prata', 'sol-regular-gold': 'Frequentador Sol · Ouro', + 'luna-regular-silver': 'Frequentador Luna · Prata', 'luna-regular-gold': 'Frequentador Luna · Ouro', + 'active-day-streak-silver': '30 dias seguidos', 'active-day-streak-gold': '100 dias seguidos', + 'codex-output-bronze': 'Saída Codex · Bronze', + 'codex-output-silver': 'Saída Codex · Prata', + 'codex-output-gold': 'Saída Codex · Ouro', + 'codex-cache-read-bronze': 'Cache Codex · Bronze', + 'codex-cache-read-silver': 'Cache Codex · Prata', + 'codex-cache-read-gold': 'Cache Codex · Ouro', + 'astra-days-bronze': 'Dias Astra · Bronze', + 'astra-days-silver': 'Dias Astra · Prata', + 'astra-days-gold': 'Dias Astra · Ouro', + 'sol-days-bronze': 'Dias Sol · Bronze', + 'sol-days-silver': 'Dias Sol · Prata', + 'sol-days-gold': 'Dias Sol · Ouro', + 'luna-days-bronze': 'Dias Luna · Bronze', + 'luna-days-silver': 'Dias Luna · Prata', + 'luna-days-gold': 'Dias Luna · Ouro', + 'sonnet-days-bronze': 'Dias Sonnet · Bronze', + 'sonnet-days-silver': 'Dias Sonnet · Prata', + 'sonnet-days-gold': 'Dias Sonnet · Ouro', + 'opus-days-bronze': 'Dias Opus · Bronze', + 'opus-days-silver': 'Dias Opus · Prata', + 'opus-days-gold': 'Dias Opus · Ouro', + 'haiku-days-bronze': 'Dias Haiku · Bronze', + 'haiku-days-silver': 'Dias Haiku · Prata', + 'haiku-days-gold': 'Dias Haiku · Ouro', + 'dual-provider-days-bronze': 'Dias com ambos · Bronze', + 'dual-provider-days-silver': 'Dias com ambos · Prata', + 'dual-provider-days-gold': 'Dias com ambos · Ouro', + 'active-weeks-bronze': 'Semanas ativas · Bronze', + 'active-weeks-silver': 'Semanas ativas · Prata', + 'active-weeks-gold': 'Semanas ativas · Ouro', + 'weekly-average-bronze': 'Média semanal · Bronze', + 'weekly-average-silver': 'Média semanal · Prata', + 'weekly-average-gold': 'Média semanal · Ouro', + 'three-family-day': 'Três famílias no dia', + 'dual-provider-day': 'Dia com ambos', + }, + }, + id: { + tab: 'Pencapaian', heading: 'Pencapaian lokal', intro: 'Tonggak dari penggunaan lokal yang terindeks; bukan ukuran keterampilan atau produktivitas.', + evidenceTitle: 'Dasar data', + evidencePending: 'Bukti belum lengkap', evidenceStale: 'Bukti kedaluwarsa', + lowerBoundNote: '≥ Batas bawah terverifikasi. Silsilah atau penggunaan per tanggal Codex yang belum terverifikasi dikecualikan.', + lowerBoundDetails: 'Pencapaian kalender perlu log tersimpan terverifikasi; rata-rata empat pekan melewati pekan event pertama. Log dihapus/dirotasi dapat meninggalkan celah.', + earned: 'Diraih', locked: 'Berlangsung', nextTarget: 'Tingkat berikutnya', nextStage: 'Tahap berikutnya', + myBadges: 'Lencana saya', nextGoals: 'Target berikutnya', galleryTitle: 'Koleksi lengkap', viewBadge: 'Lihat lencana', + recentAwards: 'Baru diperoleh', recordHistory: 'Catatan terbaru', recordedDay: 'Pertama dicatat', historicalAward: 'Pencapaian lama · tanggal tidak diketahui', + pinBadge: 'Sematkan lencana', unpinBadge: 'Lepas sematan', pinLimit: 'Sematkan hingga 6 lencana.', pinFailed: 'Sematan gagal disimpan. Coba lagi.', + hiddenPins: 'Lencana tersemat yang tersembunyi', + unreadAwards: '{count} pencapaian baru', unreadGroup: 'Grup ini: {count} pencapaian', + readGroup: 'Tandai grup ini dibaca', readFailed: 'Tidak dapat menyimpan. Grup ini masih belum dibaca. Coba lagi.', + seriesCount: '{earned} / {total} seri', levelCount: '{earned} / {total} tingkat', shownCount: '{count} seri ditampilkan', + filterLabel: 'Tampilkan pencapaian', filterAll: 'Semua seri', filterNext: 'Tingkat berikutnya', filterEarned: 'Seri yang diraih', + filterEmpty: 'Tidak ada seri dalam tampilan ini. Pilih Semua seri untuk melihat koleksi.', + settingsTitle: 'Pengaturan pencapaian', allLevels: 'Syarat tiap tingkat', complete: 'Semua tingkat diraih', + shareSelect: 'Pilih hingga 9 lencana yang diraih di bawah untuk pratinjau.', shareLimit: '9 lencana dipilih. Hapus satu pilihan untuk memilih yang lain.', + sharePending: 'Menyiapkan pratinjau…', shareStale: 'Pratinjau pilihan saat ini sebelum mengekspor.', + shareFailed: 'Pratinjau gagal disiapkan. Coba perbarui lagi.', shareExportFailed: 'Kartu pencapaian gagal disimpan. Silakan coba lagi.', + shareBack: 'Kembali ke pencapaian', + shareSaved: 'Kartu pencapaian tersimpan.', shareCancelled: 'Penyimpanan dibatalkan. Pratinjau tidak berubah.', shareSaving: 'Menyimpan kartu…', + shareSelectionChanged: 'Lencana yang tersedia berubah. Periksa pilihan; tingkat baru tidak dipilih otomatis.', + shareTitle: 'Kartu pencapaian', shareIntro: 'Pilih lencana yang diraih untuk SVG lokal; ekspor setelah melihat pratinjau.', shareChoose: 'Lencana yang dibagikan', shareEmpty: 'Raih lencana sebelum membuat kartu.', + unlockedNotice: 'Lencana terbuka: {name}', unlockedBatch: '{count} seri pencapaian meningkat', + upgradeOn: 'Pencapaian lokal kini aktif secara default. Lencana historis ditambahkan tanpa notifikasi. Anda dapat menonaktifkannya di sini atau di Pengaturan dasbor.', + upgradeOff: 'Pencapaian lokal tersedia. Pilihan nonaktif sebelumnya tetap dipertahankan. Aktifkan melalui Pengaturan dasbor.', + openAchievements: 'Lihat pencapaian', disableAchievements: 'Nonaktifkan pencapaian', + disableFailed: 'Pilihan nonaktif tidak dapat disimpan. Pencapaian tetap aktif; coba lagi di Pengaturan dasbor.', + groups: { claude: 'Claude', codex: 'Codex', combined: 'Aktivitas gabungan', plugin: 'Jelajah ekstensi' }, + levels: ['Perunggu', 'Perak', 'Emas', 'Platina', 'Legendaris'], milestone: 'Tonggak', + streakTheme: 'Aktivitas beruntun', sharedDaysTheme: 'Hari aktif kedua penyedia', + featureJourney: 'Langkah fitur lokal', + journeySteps: ['Lihat koleksi', 'Pratinjau kartu berbagi', 'Ekspor kartu secara lokal'], + stepRecorded: 'Tercatat', stepUnrecorded: 'Belum tercatat', + viewCurrentModels: 'Lihat statistik model saat ini', viewCurrentTokens: 'Lihat statistik token saat ini', backToBadge: 'Kembali ke lencana', + units: { + outputTokens: 'token keluaran', + cacheReadTokens: 'token baca cache', + cacheCreationTokens: 'token pembuatan cache', + encounter: 'model digunakan', + models: 'tugas multimodel yang memenuhi syarat', + roots: 'tugas utama independen', + childSessions: 'subagen yang memenuhi syarat', + efforts: 'tingkat upaya yang memenuhi syarat', + days: 'hari aktif', + sessions: 'sesi dengan keluaran Fable', + outputSessions: 'sesi dengan keluaran', + hours: 'jam UTC berurutan dengan keluaran (bukan kerja manusia)', + tokens: 'token diproses', + agents: 'tugas tim yang memenuhi syarat', + agentRoots: 'tugas tim yang memenuhi syarat', + streak: 'hari aktif berturut-turut', + families: 'keluarga model yang memenuhi syarat', + threeFamilyDay: 'satu hari berakhir: sembarang 3 dari 7 keluarga dikenali (termasuk Fable 5/5.1 yang didukung), masing-masing dengan 1 juta+ token diproses dan keluaran positif', + qualifiedDays: 'hari berakhir, setiap hari dengan 1 juta+ token keluarga diproses dan keluaran positif', + sharedDays: 'hari berakhir dengan 1 juta+ token diproses dan keluaran positif per penyedia', + weeks: 'pekan berakhir mulai Senin dengan 4+ hari, setiap hari dengan 1 juta+ token diproses dan keluaran positif', + weeklyAverage: 'rata-rata puncak token diproses/pekan dari 4 pekan berakhir berturut-turut mulai Senin; pekan nol termasuk, total ÷ 4', + codexCachedTokens: 'token masukan tersimpan dalam cache', + settings: 'pengaturan aman berbeda yang sengaja diubah', features: 'fitur lokal: galeri, pratinjau lencana dan ekspor SVG', + exports: 'ekspor SVG lokal yang berhasil', feedback: 'umpan balik saran lokal tersimpan', + nightPeak: 'potret 30 hari: 14+ hari aktif, 5+ malam; jam beroutput pukul 22–06 melebihi tiap kelompok 8 jam lainnya sebesar 20% (bukan waktu kerja)', + relay: 'sesi dengan output Fable dan keluarga Claude lain yang dikenali', + }, + titles: { + 'sonnet-main': 'Sonnet andalan', 'opus-cannon': 'Opus mendalam', 'haiku-worker': 'Haiku andalan', + ...apiAchievementTitles('id'), + 'agent-standup': 'Tim agen · Perunggu', 'tiny-ai-company': 'Tim agen · Perak', + 'agent-team-gold': 'Tim agen · Emas', 'same-root-models-silver': 'Model dalam satu tugas · Perak', + 'same-root-models-gold': 'Model dalam satu tugas · Emas', 'effort-breadth-silver': 'Tingkat penalaran · Perak', + 'model-rainbow-bronze': 'Ragam model · Perunggu', 'model-rainbow-gold': 'Ragam model · Emas', + 'active-day-streak': 'Tujuh hari berturut-turut', 'streak-machine': 'Empat belas hari berturut-turut', + 'model-rainbow': 'Ragam model', + 'cache-wizard': 'Tonggak baca cache', 'agent-conductor': 'Koordinator agen', + 'multi-core-human': 'Model dalam satu tugas', 'effort-explorer': 'Penjelajah tingkat penalaran', + 'trillion-token-club': 'Klub 1 triliun', 'hundred-million-club': 'Klub 100 juta', + 'astra-regular': 'Pengguna tetap Astra', 'sol-regular': 'Pengguna tetap Sol', 'luna-regular': 'Pengguna tetap Luna', + 'effort-spectrum': 'Enam tingkat lengkap', + 'billion-token-club': 'Klub 1 miliar', 'ten-billion-token-club': 'Klub 10 miliar', + 'hundred-billion-token-club': 'Klub 100 miliar', + 'fable-volume-bronze': 'Volume Fable · Perunggu', 'fable-volume-silver': 'Volume Fable · Perak', + 'fable-volume-gold': 'Volume Fable · Emas', 'fable-days-bronze': 'Hari Fable · Perunggu', + 'fable-days-silver': 'Hari Fable · Perak', 'fable-days-gold': 'Hari Fable · Emas', + 'fable-relay': 'Fable dalam sesi yang sama', + 'fable-sessions-bronze': 'Sesi Fable · Perunggu', 'fable-sessions-silver': 'Sesi Fable · Perak', + 'fable-sessions-gold': 'Sesi Fable · Emas', + 'agent-conductor-silver': 'Koordinator agen · Perak', 'agent-conductor-gold': 'Koordinator agen · Emas', + 'root-portfolio-bronze': 'Tugas utama independen · Perunggu', 'root-portfolio-silver': 'Tugas utama independen · Perak', + 'root-portfolio-gold': 'Tugas utama independen · Emas', + 'claude-output-bronze': 'Keluaran Claude · Perunggu', 'claude-output-silver': 'Keluaran Claude · Perak', + 'claude-output-gold': 'Keluaran Claude · Emas', + 'claude-output-sessions-bronze': 'Sesi keluaran · Perunggu', 'claude-output-sessions-silver': 'Sesi keluaran · Perak', + 'claude-output-sessions-gold': 'Sesi keluaran · Emas', + 'claude-cache-creation-bronze': 'Penulisan cache · Perunggu', 'claude-cache-creation-silver': 'Penulisan cache · Perak', + 'claude-cache-creation-gold': 'Penulisan cache · Emas', + 'agent-participation-bronze': 'Partisipasi agen · Perunggu', 'agent-participation-silver': 'Partisipasi agen · Perak', + 'agent-participation-gold': 'Partisipasi agen · Emas', + 'model-relay-roots-bronze': 'Tugas multimodel · Perunggu', 'model-relay-roots-silver': 'Tugas multimodel · Perak', + 'model-relay-roots-gold': 'Tugas multimodel · Emas', + 'active-days-bronze': 'Akumulasi hari aktif · Perunggu', 'active-days-silver': 'Akumulasi hari aktif · Perak', + 'active-days-gold': 'Akumulasi hari aktif · Emas', + 'weekly-volume-bronze': 'Volume mingguan · Perunggu', 'weekly-volume-silver': 'Volume mingguan · Perak', + 'weekly-volume-gold': 'Volume mingguan · Emas', + 'output-hours-bronze': 'Keluaran per jam · Perunggu', 'output-hours-silver': 'Keluaran per jam · Perak', + 'output-hours-gold': 'Keluaran per jam · Emas', 'night-output-peak': 'Pola keluaran malam', + 'settings-explorer-bronze': 'Jelajah pengaturan · Perunggu', 'settings-explorer-silver': 'Jelajah pengaturan · Perak', + 'settings-explorer-gold': 'Jelajah pengaturan · Emas', 'safe-feature-explorer': 'Jelajah fitur aman', + 'local-share-export': 'Ekspor berbagi lokal', 'local-advice-feedback': 'Umpan balik lokal', + 'sonnet-main-silver': 'Sonnet andalan · Perak', 'sonnet-main-gold': 'Sonnet andalan · Emas', + 'opus-cannon-silver': 'Opus mendalam · Perak', 'opus-cannon-gold': 'Opus mendalam · Emas', + 'haiku-worker-silver': 'Haiku andalan · Perak', 'haiku-worker-gold': 'Haiku andalan · Emas', + 'cache-wizard-silver': 'Baca cache · Perak', 'cache-wizard-gold': 'Baca cache · Emas', + 'astra-regular-silver': 'Pengguna tetap Astra · Perak', 'astra-regular-gold': 'Pengguna tetap Astra · Emas', + 'sol-regular-silver': 'Pengguna tetap Sol · Perak', 'sol-regular-gold': 'Pengguna tetap Sol · Emas', + 'luna-regular-silver': 'Pengguna tetap Luna · Perak', 'luna-regular-gold': 'Pengguna tetap Luna · Emas', + 'active-day-streak-silver': '30 hari berturut-turut', 'active-day-streak-gold': '100 hari berturut-turut', + 'codex-output-bronze': 'Keluaran Codex · Perunggu', + 'codex-output-silver': 'Keluaran Codex · Perak', + 'codex-output-gold': 'Keluaran Codex · Emas', + 'codex-cache-read-bronze': 'Cache Codex · Perunggu', + 'codex-cache-read-silver': 'Cache Codex · Perak', + 'codex-cache-read-gold': 'Cache Codex · Emas', + 'astra-days-bronze': 'Hari Astra · Perunggu', + 'astra-days-silver': 'Hari Astra · Perak', + 'astra-days-gold': 'Hari Astra · Emas', + 'sol-days-bronze': 'Hari Sol · Perunggu', + 'sol-days-silver': 'Hari Sol · Perak', + 'sol-days-gold': 'Hari Sol · Emas', + 'luna-days-bronze': 'Hari Luna · Perunggu', + 'luna-days-silver': 'Hari Luna · Perak', + 'luna-days-gold': 'Hari Luna · Emas', + 'sonnet-days-bronze': 'Hari Sonnet · Perunggu', + 'sonnet-days-silver': 'Hari Sonnet · Perak', + 'sonnet-days-gold': 'Hari Sonnet · Emas', + 'opus-days-bronze': 'Hari Opus · Perunggu', + 'opus-days-silver': 'Hari Opus · Perak', + 'opus-days-gold': 'Hari Opus · Emas', + 'haiku-days-bronze': 'Hari Haiku · Perunggu', + 'haiku-days-silver': 'Hari Haiku · Perak', + 'haiku-days-gold': 'Hari Haiku · Emas', + 'dual-provider-days-bronze': 'Hari dua penyedia · Perunggu', + 'dual-provider-days-silver': 'Hari dua penyedia · Perak', + 'dual-provider-days-gold': 'Hari dua penyedia · Emas', + 'active-weeks-bronze': 'Pekan aktif · Perunggu', + 'active-weeks-silver': 'Pekan aktif · Perak', + 'active-weeks-gold': 'Pekan aktif · Emas', + 'weekly-average-bronze': 'Rata-rata mingguan · Perunggu', + 'weekly-average-silver': 'Rata-rata mingguan · Perak', + 'weekly-average-gold': 'Rata-rata mingguan · Emas', + 'three-family-day': 'Tiga keluarga sehari', + 'dual-provider-day': 'Dua penyedia sehari', + }, + }, +}; + +// New ranks reuse each locale's own established series name and rank labels. +// Materialize once, not once per card/render; no English fallback for new IDs. +export const ACHIEVEMENT_COPY: Record = Object.fromEntries( + Object.entries(BASE_ACHIEVEMENT_COPY).map(([locale,base]) => [locale,{...base,titles:{...base.titles, + ...Object.fromEntries(LONG_TERM_ACHIEVEMENT_DEFINITIONS.map(rule => { + let title=base.titles[rule.baseId]; + for(const level of base.levels) if(title.endsWith(level)) { + const prefix=title.slice(0,-level.length); + if(/[\s·・]$/u.test(prefix)) {title=prefix.replace(/[\s·・]+$/u,'');break;} + } + return [rule.id,title+' · '+base.levels[rule.id.endsWith('-platinum')?3:4]]; + })), + }}]), +) as Record; + +export function achievementCopyFor(locale: string): AchievementCopy { + return Object.prototype.hasOwnProperty.call(ACHIEVEMENT_COPY, locale) + ? ACHIEVEMENT_COPY[locale as SupportedLanguage] + : ACHIEVEMENT_COPY.en; +} + +export interface AchievementManagementCopy { + expandAll: string; + collapseAll: string; + recalibrate: string; + recalibrating: string; + recalibrated: string; + recalibratedPartial: string; + recalibrationFailed: string; + currentValue: string; + verifiedDay: string; + firstRecorded: string; + unknownDay: string; + dateNote: string; + sharedConditions: string; + dateUnavailable: string; +} + +/** Management labels are independent of catalog IDs, rules and award storage. */ +const ACHIEVEMENT_MANAGEMENT_COPY: Record = { + en: { + expandAll: 'Expand all', collapseAll: 'Collapse all', recalibrate: 'Recalibrate records', + recalibrating: 'Recalibrating…', + recalibrated: 'Recalibration complete. Earned badges are kept; new awards still require verified conditions.', + recalibrationFailed: 'Recalibration failed. Earned badges are kept. Please try again.', + recalibratedPartial: "Some records were saved; the remaining conditions still need verification.", + currentValue: 'Current value', verifiedDay: 'Earliest verified date', firstRecorded: 'First recorded', + unknownDay: 'Date unknown', + dateNote: 'The date is the earliest that retained logs can verify, not the actual complete account history. First recorded is when this extension first saved the award.', + sharedConditions: 'Shared conditions', dateUnavailable: 'Date unavailable', + }, + 'zh-CN': { + expandAll: '全部展开', collapseAll: '全部收起', recalibrate: '校准记录', + recalibrating: '正在校准…', recalibrated: '校准完成。已获徽章保留;新增授奖仍须满足已核验的条件。', + recalibrationFailed: '校准失败,已获徽章保留。请重试。', + recalibratedPartial: "部分记录已保存,其余条件仍待核验。", + currentValue: '当前值', verifiedDay: '最早可确认日期', firstRecorded: '首次记录', unknownDay: '日期未知', + dateNote: '日期为现存日志可确认的最早日期,不代表账户真实完整历史;首次记录是本插件首次保存该授奖记录的时间。', + sharedConditions: '共同条件', dateUnavailable: '日期不可用', + }, + 'zh-TW': { + expandAll: '全部展開', collapseAll: '全部收合', recalibrate: '校準記錄', + recalibrating: '正在校準…', recalibrated: '校準完成。已獲徽章保留;新增授獎仍須滿足已核驗的條件。', + recalibrationFailed: '校準失敗,已獲徽章保留。請重試。', + recalibratedPartial: "部分記錄已儲存,其餘條件仍待核驗。", + currentValue: '目前值', verifiedDay: '最早可確認日期', firstRecorded: '首次記錄', unknownDay: '日期未知', + dateNote: '日期為現存日誌可確認的最早日期,不代表帳戶真實完整歷史;首次記錄是本擴充功能首次儲存該授獎記錄的時間。', + sharedConditions: '共同條件', dateUnavailable: '日期無法取得', + }, + 'de-DE': { + expandAll: 'Alle ausklappen', collapseAll: 'Alle einklappen', recalibrate: 'Nachweise neu prüfen', + recalibrating: 'Nachweise werden neu geprüft…', + recalibrated: 'Prüfung abgeschlossen. Erworbene Abzeichen bleiben erhalten; neue Auszeichnungen erfordern weiterhin geprüfte Bedingungen.', + recalibrationFailed: 'Prüfung fehlgeschlagen. Erworbene Abzeichen bleiben erhalten. Bitte erneut versuchen.', + recalibratedPartial: "Einige Einträge wurden gespeichert; die übrigen Bedingungen müssen noch geprüft werden.", + currentValue: 'Aktueller Wert', verifiedDay: 'Frühestes belegbares Datum', firstRecorded: 'Erstmals erfasst', + unknownDay: 'Datum unbekannt', + dateNote: 'Das Datum ist das früheste durch vorhandene Logs belegbare Datum, nicht der tatsächliche vollständige Kontoverlauf. Erstmals erfasst bezeichnet die erste Speicherung der Auszeichnung durch diese Erweiterung.', + sharedConditions: 'Gemeinsame Bedingungen', dateUnavailable: 'Datum nicht verfügbar', + }, + ja: { + expandAll: 'すべて展開', collapseAll: 'すべて折りたたむ', recalibrate: '記録を再検証', + recalibrating: '再検証中…', recalibrated: '再検証が完了しました。獲得済みバッジは保持され、新しい授与には引き続き条件の検証が必要です。', + recalibrationFailed: '再検証に失敗しました。獲得済みバッジは保持されます。もう一度お試しください。', + recalibratedPartial: "一部の記録を保存しました。残りの条件は引き続き検証が必要です。", + currentValue: '現在の値', verifiedDay: '確認できる最も早い日付', firstRecorded: '初回記録', unknownDay: '日付不明', + dateNote: '日付は現存するログで確認できる最も早い日付であり、アカウントの実際の完全な履歴ではありません。初回記録は、この拡張機能が授与を初めて保存した日時です。', + sharedConditions: '共通条件', dateUnavailable: '日付を取得できません', + }, + ko: { + expandAll: '모두 펼치기', collapseAll: '모두 접기', recalibrate: '기록 재검증', + recalibrating: '재검증 중…', recalibrated: '재검증이 완료되었습니다. 획득한 배지는 유지되며 새 수여에는 계속 조건 검증이 필요합니다.', + recalibrationFailed: '재검증에 실패했습니다. 획득한 배지는 유지됩니다. 다시 시도하세요.', + recalibratedPartial: "일부 기록이 저장되었습니다. 나머지 조건은 추가 검증이 필요합니다.", + currentValue: '현재 값', verifiedDay: '확인 가능한 가장 이른 날짜', firstRecorded: '최초 기록', unknownDay: '날짜 미상', + dateNote: '날짜는 보존된 로그로 확인할 수 있는 가장 이른 날짜이며 계정의 실제 전체 기록이 아닙니다. 최초 기록은 이 확장 프로그램이 수여 기록을 처음 저장한 시점입니다.', + sharedConditions: '공통 조건', dateUnavailable: '날짜를 확인할 수 없음', + }, + 'pt-BR': { + expandAll: 'Expandir tudo', collapseAll: 'Recolher tudo', recalibrate: 'Recalibrar registros', + recalibrating: 'Recalibrando…', + recalibrated: 'Recalibração concluída. Os emblemas obtidos são mantidos; novas concessões ainda exigem condições verificadas.', + recalibrationFailed: 'Falha na recalibração. Os emblemas obtidos são mantidos. Tente novamente.', + recalibratedPartial: "Alguns registros foram salvos; as demais condições ainda precisam de verificação.", + currentValue: 'Valor atual', verifiedDay: 'Data mais antiga verificável', firstRecorded: 'Primeiro registro', + unknownDay: 'Data desconhecida', + dateNote: 'A data é a mais antiga verificável nos logs preservados, não o histórico completo real da conta. Primeiro registro indica quando esta extensão salvou a concessão pela primeira vez.', + sharedConditions: 'Condições comuns', dateUnavailable: 'Data indisponível', + }, + id: { + expandAll: 'Buka semua', collapseAll: 'Tutup semua', recalibrate: 'Kalibrasi ulang catatan', + recalibrating: 'Mengkalibrasi ulang…', + recalibrated: 'Kalibrasi ulang selesai. Lencana yang diperoleh tetap disimpan; pemberian baru tetap memerlukan syarat yang terverifikasi.', + recalibrationFailed: 'Kalibrasi ulang gagal. Lencana yang diperoleh tetap disimpan. Silakan coba lagi.', + recalibratedPartial: "Sebagian catatan telah disimpan; syarat lainnya masih perlu diverifikasi.", + currentValue: 'Nilai saat ini', verifiedDay: 'Tanggal paling awal yang dapat diverifikasi', firstRecorded: 'Pertama dicatat', + unknownDay: 'Tanggal tidak diketahui', + dateNote: 'Tanggal ini adalah tanggal paling awal yang dapat diverifikasi dari log tersimpan, bukan riwayat lengkap akun yang sebenarnya. Pertama dicatat menunjukkan saat ekstensi ini pertama kali menyimpan pemberian lencana.', + sharedConditions: 'Syarat bersama', dateUnavailable: 'Tanggal tidak tersedia', + }, +}; + +export interface AchievementAppearanceCopy { + sizeLabel: string; + small: string; + medium: string; + large: string; + editShowcase: string; + automatic: string; + manual: string; + selectedCount: string; + moveUp: string; + moveDown: string; + legacyRules: string; + legacyPins: string; + recalibratedHint: string; + oldWriterReload: string; + cancelEdit: string; +} + +/** Appearance and migration controls, independent of the main achievement catalog. */ +const ACHIEVEMENT_APPEARANCE_COPY: Record = { + en: { + sizeLabel: 'Gallery size', small: 'Small', medium: 'Medium', large: 'Large', + editShowcase: 'Edit showcase', automatic: 'Automatic recommendations', manual: 'Manual selection', + selectedCount: 'Selected {count}/6', moveUp: 'Move up', moveDown: 'Move down', + legacyRules: 'Previous rule records', legacyPins: 'Previous rule pins', + recalibratedHint: 'Rules updated; verification uses current conditions.', + oldWriterReload: 'Reload older extension instances to use new records.', cancelEdit: 'Cancel editing', + }, + 'zh-CN': { + sizeLabel: '图鉴大小', small: '小', medium: '中', large: '大', + editShowcase: '编辑展示', automatic: '自动推荐', manual: '手动选择', + selectedCount: '已选 {count}/6', moveUp: '上移', moveDown: '下移', + legacyRules: '旧规则记录', legacyPins: '旧规则固定项', + recalibratedHint: '规则已调整,按新条件核验。', + oldWriterReload: '请重载旧版插件后使用新记录。', cancelEdit: '取消编辑', + }, + 'zh-TW': { + sizeLabel: '圖鑑大小', small: '小', medium: '中', large: '大', + editShowcase: '編輯展示', automatic: '自動推薦', manual: '手動選擇', + selectedCount: '已選 {count}/6', moveUp: '上移', moveDown: '下移', + legacyRules: '舊規則記錄', legacyPins: '舊規則固定項', + recalibratedHint: '規則已調整,按新條件核驗。', + oldWriterReload: '請重新載入舊版擴充功能後使用新記錄。', cancelEdit: '取消編輯', + }, + 'de-DE': { + sizeLabel: 'Galeriegröße', small: 'Klein', medium: 'Mittel', large: 'Groß', + editShowcase: 'Vitrine bearbeiten', automatic: 'Automatische Empfehlungen', manual: 'Manuelle Auswahl', + selectedCount: 'Ausgewählt {count}/6', moveUp: 'Nach oben', moveDown: 'Nach unten', + legacyRules: 'Nachweise früherer Regeln', legacyPins: 'Anheftungen früherer Regeln', + recalibratedHint: 'Regeln geändert; Prüfung nach aktuellen Bedingungen.', + oldWriterReload: 'Ältere Erweiterungsinstanzen für neue Nachweise neu laden.', cancelEdit: 'Bearbeitung abbrechen', + }, + ja: { + sizeLabel: '図鑑のサイズ', small: '小', medium: '中', large: '大', + editShowcase: '展示を編集', automatic: '自動おすすめ', manual: '手動選択', + selectedCount: '選択済み {count}/6', moveUp: '上へ移動', moveDown: '下へ移動', + legacyRules: '旧ルールの記録', legacyPins: '旧ルールの固定項目', + recalibratedHint: 'ルール更新後の条件で検証します。', + oldWriterReload: '新しい記録の使用には旧版の拡張機能を再読み込みしてください。', cancelEdit: '編集をキャンセル', + }, + ko: { + sizeLabel: '도감 크기', small: '작게', medium: '보통', large: '크게', + editShowcase: '전시 편집', automatic: '자동 추천', manual: '수동 선택', + selectedCount: '선택됨 {count}/6', moveUp: '위로 이동', moveDown: '아래로 이동', + legacyRules: '이전 규칙 기록', legacyPins: '이전 규칙 고정 항목', + recalibratedHint: '규칙이 변경되어 현재 조건으로 검증합니다.', + oldWriterReload: '새 기록을 사용하려면 이전 버전 확장을 다시 로드하세요.', cancelEdit: '편집 취소', + }, + 'pt-BR': { + sizeLabel: 'Tamanho do catálogo', small: 'Pequeno', medium: 'Médio', large: 'Grande', + editShowcase: 'Editar vitrine', automatic: 'Recomendações automáticas', manual: 'Seleção manual', + selectedCount: 'Selecionados {count}/6', moveUp: 'Mover para cima', moveDown: 'Mover para baixo', + legacyRules: 'Registros de regras anteriores', legacyPins: 'Fixações de regras anteriores', + recalibratedHint: 'Regras atualizadas; verificação pelas condições atuais.', + oldWriterReload: 'Recarregue extensões antigas para usar os novos registros.', cancelEdit: 'Cancelar edição', + }, + id: { + sizeLabel: 'Ukuran katalog', small: 'Kecil', medium: 'Sedang', large: 'Besar', + editShowcase: 'Edit etalase', automatic: 'Rekomendasi otomatis', manual: 'Pilihan manual', + selectedCount: 'Dipilih {count}/6', moveUp: 'Pindah ke atas', moveDown: 'Pindah ke bawah', + legacyRules: 'Catatan aturan lama', legacyPins: 'Sematan aturan lama', + recalibratedHint: 'Aturan diperbarui; verifikasi memakai syarat terkini.', + oldWriterReload: 'Muat ulang ekstensi versi lama untuk memakai catatan baru.', cancelEdit: 'Batalkan edit', + }, +}; + +export function achievementAppearanceCopyFor(locale: string): AchievementAppearanceCopy { + return Object.prototype.hasOwnProperty.call(ACHIEVEMENT_APPEARANCE_COPY, locale) + ? ACHIEVEMENT_APPEARANCE_COPY[locale as SupportedLanguage] + : ACHIEVEMENT_APPEARANCE_COPY.en; +} /** Compact recovery and preview copy, shared by both provider pages. */ export const DASHBOARD_FEEDBACK_TRANSLATIONS: Record = { en: providerTranslations( - { claude: 'Claude', codexBeta: 'Codex', compare: 'Compare' }, + { claude: 'Claude', codexBeta: 'Codex', compare: 'Share & compare' }, {}, ), 'de-DE': providerTranslations( - { claude: 'Claude', codexBeta: 'Codex', compare: 'Vergleichen' }, + { claude: 'Claude', codexBeta: 'Codex', compare: 'Teilen & vergleichen' }, { ...TASK8_CODEX_COPY['de-DE'], accountSnapshotLastObserved: @@ -1031,7 +2699,7 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY['de-DE'], ), 'zh-TW': providerTranslations( - { claude: 'Claude', codexBeta: 'Codex', compare: '比較' }, + { claude: 'Claude', codexBeta: 'Codex', compare: '分享與比較' }, { ...TASK8_CODEX_COPY['zh-TW'], accountSnapshotLastObserved: @@ -1047,7 +2715,7 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY['zh-TW'], ), 'zh-CN': providerTranslations( - { claude: 'Claude', codexBeta: 'Codex', compare: '对比' }, + { claude: 'Claude', codexBeta: 'Codex', compare: '分享与对比' }, { ...TASK8_CODEX_COPY['zh-CN'], accountSnapshotLastObserved: @@ -1063,7 +2731,7 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY['zh-CN'], ), ja: providerTranslations( - { claude: 'Claude', codexBeta: 'Codex', compare: '比較' }, + { claude: 'Claude', codexBeta: 'Codex', compare: '共有と比較' }, { ...TASK8_CODEX_COPY.ja, accountSnapshotLastObserved: @@ -1079,7 +2747,7 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY.ja, ), ko: providerTranslations( - { claude: 'Claude', codexBeta: 'Codex', compare: '비교' }, + { claude: 'Claude', codexBeta: 'Codex', compare: '공유 및 비교' }, { ...TASK8_CODEX_COPY.ko, accountSnapshotLastObserved: @@ -1095,7 +2763,7 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY.ko, ), 'pt-BR': providerTranslations( - { claude: 'Claude', codexBeta: 'Codex', compare: 'Comparar' }, + { claude: 'Claude', codexBeta: 'Codex', compare: 'Compartilhar e comparar' }, { ...TASK8_CODEX_COPY['pt-BR'], accountSnapshotLastObserved: @@ -1111,7 +2779,7 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY['pt-BR'], ), id: providerTranslations( - { claude: 'Claude', codexBeta: 'Codex', compare: 'Bandingkan' }, + { claude: 'Claude', codexBeta: 'Codex', compare: 'Bagikan & bandingkan' }, { ...TASK8_CODEX_COPY.id, accountSnapshotLastObserved: @@ -4247,6 +5915,7 @@ export function artifactLocale(value: string | undefined): SupportedLanguage { const SETTINGS_I18N: Partial>> = { 'de-DE': { 'language': { label: 'Anzeigesprache', help: 'UI-Sprache. "auto" folgt VS Code.' }, + 'showWeeklyQuotaPace': { label: 'Wöchentliches Kontingenttempo', help: 'Standardmäßig aus. Vergleich mit gleichmäßiger Nutzung im geschätzten Wochenfenster; basiert auf vorhandenen Kontobeobachtungen, nicht nur diesem Gerät. Keine zusätzlichen Anfragen.' }, 'decimalPlaces': { label: 'Kosten-Dezimalstellen', help: '' }, 'displayCurrency': { label: 'Anzeigewährung', help: 'Nur Anzeige. Verwendet integrierte Referenzkurse vom 09.09.2026; Kurse sind weder bearbeitbar noch werden sie abgerufen, die Basis bleibt USD.' }, 'tokenDecimalPlaces': { label: 'Token-Dezimalstellen', help: 'Dezimalstellen für kompakte Token-Anzeige (1.2M / 345.6K). Volle Ganzzahlen bleiben unberührt.' }, @@ -4266,6 +5935,8 @@ const SETTINGS_I18N: Partial = { + en: ['Weekly quota pace', 'Used', 'Reference progress', 'Estimated at reset ≈', 'Insufficient sample', 'Uniform cycle-average reference; start inferred as reset minus 7 days. Account-wide use includes other devices and members.'], + 'de-DE': ['Wöchentliches Kontingenttempo', 'Verbraucht', 'Referenzfortschritt', 'Geschätzt beim Zurücksetzen ≈', 'Zu wenig Daten', 'Gleichmäßige Zyklusreferenz; Beginn als Zurücksetzen minus 7 Tage abgeleitet. Kontoweite Nutzung umfasst andere Geräte und Mitglieder.'], + 'zh-TW': ['週額度使用節奏', '已用', '參考進度', '週期末預計約', '樣本不足', '均勻消耗的週期平均參考;起點推定為重置時間減去7天。帳號總用量包含其他裝置及成員。'], + 'zh-CN': ['周额度使用节奏', '已用', '参考进度', '周期末预计约', '样本不足', '均匀消耗的周期平均参考;起点推定为重置时间减去7天。账号总用量包含其他设备及成员。'], + ja: ['週間クォータの使用ペース', '使用済み', '基準の進捗', 'リセット時の予測 ≈', 'サンプル不足', '均等消費の周期平均基準。開始はリセットの7日前と推定。アカウント全体の使用量には他の端末やメンバーを含みます。'], + ko: ['주간 할당량 사용 속도', '사용량', '기준 진행률', '초기화 시 예상 ≈', '표본 부족', '균등 사용의 주기 평균 기준이며 시작은 초기화 7일 전으로 추정합니다. 계정 전체 사용량에는 다른 기기와 구성원이 포함됩니다.'], + 'pt-BR': ['Ritmo da cota semanal', 'Usado', 'Progresso de referência', 'Estimativa na redefinição ≈', 'Amostra insuficiente', 'Referência uniforme da média do ciclo; início inferido como redefinição menos 7 dias. O uso da conta inclui outros dispositivos e membros.'], + id: ['Laju kuota mingguan', 'Terpakai', 'Progres acuan', 'Perkiraan saat reset ≈', 'Sampel belum cukup', 'Acuan rata-rata siklus dengan pemakaian merata; awal diasumsikan 7 hari sebelum reset. Pemakaian akun mencakup perangkat dan anggota lain.'], + }; + const [title, used, reference, forecast, sample, hint] = copy[language]; + const extra:Record = { + en:['Forecast unavailable','Observed'], + 'de-DE':['Prognose nicht verfügbar','Beobachtet'], + 'zh-TW':['預測暫不可用','觀測時間'], 'zh-CN':['预测暂不可用','观测时间'], + ja:['予測は利用できません','観測時刻'], ko:['예측을 사용할 수 없음','관측 시각'], + 'pt-BR':['Previsão indisponível','Observado'], id:['Perkiraan tidak tersedia','Diamati'], + }; + return {title,used,reference,forecast,sample,hint,unavailable:extra[language][0],observed:extra[language][1]}; +} + +export function achievementEffortCopyFor(language: SupportedLanguage): { + title:string;processed:string;output:string;tasks:string;money:string;coverage:string; + joint:string;criteria:string;unpriced:string;empty:string;note:string; +} { + const words:Record={ + en:['Effort usage and progress','Processed Token','Output Token','Tasks with output','API-equivalent subtotal (USD)', + 'Priced coverage','Joint progress','Criteria','Unpriced','No qualifying usage', + 'Only verified tasks with output in the same effort count; approval reviewers are excluded. Amounts use the frozen reference and do not qualify this badge. Mixed model–effort attribution stays unpriced; no proportional split.'], + 'de-DE':['Denkstufen: Nutzung und Fortschritt','Verarbeitete Token','Ausgabe-Token','Aufgaben mit Ausgabe','API-Teilsumme (USD)', + 'Preisbedeckung','Gemeinsamer Fortschritt','Kriterien','Ohne Preis','Keine qualifizierte Nutzung', + 'Nur bestätigte Aufgaben mit Ausgabe in derselben Denkstufe zählen; Freigabeprüfer sind ausgeschlossen. Beträge nutzen die feste Referenz und qualifizieren diesen Nachweis nicht. Gemischte Modell–Stufen-Zuordnung bleibt unbepreist; keine anteilige Aufteilung.'], + 'zh-TW':['檔位用量與進度','已處理 Token','輸出 Token','有輸出任務','API 等價小計(USD)', + '已定價覆蓋','聯合進度','比較條件','未定價','尚無合格用量', + '僅統計可確認、同檔有輸出的任務;排除審批審查器。金額採用固定參考價,不參與此徽章門檻。混合模型與檔位無法確認對應時保留未定價,不按比例分攤。'], + 'zh-CN':['档位用量与进度','已处理 Token','输出 Token','有输出任务','API 等价小计(USD)', + '已定价覆盖','联合进度','比较条件','未定价','尚无合格用量', + '仅统计可确认、同档有输出的任务;排除审批审查器。金额采用固定参考价,不参与此徽章门槛。混合模型与档位无法确认对应时保留未定价,不按比例分摊。'], + ja:['推論レベル別の使用量と進捗','処理トークン','出力トークン','出力のあるタスク','API 等価小計(USD)', + '価格確認率','共同進捗','比較条件','価格未確認','対象となる使用なし', + '同じレベルで出力のある確認済みタスクのみを数え、承認レビュアーを除外します。金額は固定参照価格であり、この実績の条件ではありません。モデルとレベルの対応が不明な混合分は未定価のまま、比率で配分しません。'], + ko:['추론 단계별 사용량과 진행률','처리 토큰','출력 토큰','출력이 있는 작업','API 환산 소계(USD)', + '가격 확인 비율','공동 진행률','비교 조건','가격 미확인','조건에 맞는 사용 없음', + '같은 단계에서 출력이 있는 확인된 작업만 집계하며 승인 검토자는 제외합니다. 금액은 고정 참조 가격이며 이 배지의 조건이 아닙니다. 모델과 단계의 대응이 불명확한 혼합 사용은 미확인으로 유지하고 비례 배분하지 않습니다.'], + 'pt-BR':['Uso e progresso por esforço','Tokens processados','Tokens de saída','Tarefas com saída','Subtotal equivalente de API (USD)', + 'Cobertura de preço','Progresso conjunto','Critérios','Sem preço','Nenhum uso qualificado', + 'Contam apenas tarefas verificadas com saída nesse esforço; revisores de aprovação são excluídos. Valores usam a referência fixa e não qualificam esta conquista. Atribuições mistas de modelo–esforço ficam sem preço; sem divisão proporcional.'], + id:['Penggunaan dan progres per upaya','Token diproses','Token keluaran','Tugas dengan keluaran','Subtotal setara API (USD)', + 'Cakupan harga','Progres gabungan','Kriteria','Tanpa harga','Belum ada penggunaan yang memenuhi', + 'Hanya tugas terverifikasi dengan keluaran pada upaya yang sama dihitung; peninjau persetujuan dikecualikan. Nilai memakai harga referensi tetap dan bukan syarat lencana ini. Atribusi model–upaya campuran tetap tanpa harga; tidak dibagi proporsional.'], + }; + const [title,processed,output,tasks,money,coverage,joint,criteria,unpriced,empty,note]=words[language]; + return {title,processed,output,tasks,money,coverage,joint,criteria,unpriced,empty,note}; +} + +export interface ApiHeatmapUiTranslations { + providerLabel: string; + bothProviders: string; + metricLabel: string; + amountMetric: string; + showAmounts: string; + amountDisclosure: string; + stale: string; + failed: string; + saved: string; + cancelled: string; + titleDisclosure: string; +} + +export const API_HEATMAP_UI_TRANSLATIONS: Record = { + "en": { + "providerLabel": "Provider", + "bothProviders": "Claude + Codex", + "metricLabel": "Dimension", + "amountMetric": "API equivalent (includes amounts)", + "showAmounts": "Include monetary amounts", + "amountDisclosure": "The export includes current estimated amounts and amount-based colors for each selected provider. It is not a bill or subscription usage.", + "stale": "Preview changed. Generate a new preview before exporting.", + "failed": "Could not generate or save this preview. Try again.", + "saved": "SVG saved.", + "cancelled": "Export cancelled.", + "titleDisclosure": "Your custom title is included in the export." + }, + "de-DE": { + "providerLabel": "Anbieter", + "bothProviders": "Claude + Codex", + "metricLabel": "Dimension", + "amountMetric": "API-Gegenwert (mit Beträgen)", + "showAmounts": "Geldbeträge einschließen", + "amountDisclosure": "Der Export enthält aktuelle geschätzte Beträge und betragsabhängige Farben je gewähltem Anbieter. Er ist keine Rechnung oder Abonutzung.", + "stale": "Vorschau geändert. Vor dem Export neu erzeugen.", + "failed": "Vorschau konnte nicht erzeugt oder gespeichert werden. Erneut versuchen.", + "saved": "SVG gespeichert.", + "cancelled": "Export abgebrochen.", + "titleDisclosure": "Der eigene Titel wird mit exportiert." + }, + "zh-TW": { + "providerLabel": "供應商", + "bothProviders": "Claude + Codex", + "metricLabel": "維度", + "amountMetric": "API 等價金額(含金額)", + "showAmounts": "包含金額", + "amountDisclosure": "匯出將包含各所選供應商的目前估值及金額色階,不代表帳單或訂閱用量。", + "stale": "預覽已變更,請重新產生預覽後匯出。", + "failed": "無法產生或儲存預覽,請重試。", + "saved": "SVG 已儲存。", + "cancelled": "已取消匯出。", + "titleDisclosure": "自訂標題也會包含於匯出。" + }, + "zh-CN": { + "providerLabel": "供应商", + "bothProviders": "Claude + Codex", + "metricLabel": "维度", + "amountMetric": "API 等价金额(含金额)", + "showAmounts": "包含金额", + "amountDisclosure": "导出将包含各所选供应商的当前估值及金额色阶,不代表账单或订阅用量。", + "stale": "预览已变更,请重新生成预览后导出。", + "failed": "无法生成或保存预览,请重试。", + "saved": "SVG 已保存。", + "cancelled": "已取消导出。", + "titleDisclosure": "自定义标题也会包含于导出。" + }, + "ja": { + "providerLabel": "プロバイダー", + "bothProviders": "Claude + Codex", + "metricLabel": "指標", + "amountMetric": "API 相当額(金額を含む)", + "showAmounts": "金額を含める", + "amountDisclosure": "選択した各プロバイダーの現在の推定額と金額に基づく色を出力します。請求額やサブスク使用量ではありません。", + "stale": "プレビューが変わりました。再生成してから出力してください。", + "failed": "プレビューを生成または保存できませんでした。再試行してください。", + "saved": "SVG を保存しました。", + "cancelled": "出力をキャンセルしました。", + "titleDisclosure": "カスタムタイトルも出力されます。" + }, + "ko": { + "providerLabel": "제공자", + "bothProviders": "Claude + Codex", + "metricLabel": "지표", + "amountMetric": "API 환산 금액(금액 포함)", + "showAmounts": "금액 포함", + "amountDisclosure": "선택한 각 제공자의 현재 추정 금액과 금액 기반 색상이 내보내기에 포함됩니다. 청구액이나 구독 사용량이 아닙니다.", + "stale": "미리보기가 변경되었습니다. 다시 생성한 후 내보내세요.", + "failed": "미리보기를 생성하거나 저장하지 못했습니다. 다시 시도하세요.", + "saved": "SVG가 저장되었습니다.", + "cancelled": "내보내기가 취소되었습니다.", + "titleDisclosure": "사용자 지정 제목도 내보내기에 포함됩니다." + }, + "pt-BR": { + "providerLabel": "Provedor", + "bothProviders": "Claude + Codex", + "metricLabel": "Dimensão", + "amountMetric": "Equivalente de API (inclui valores)", + "showAmounts": "Incluir valores monetários", + "amountDisclosure": "A exportação inclui estimativas atuais e cores baseadas nos valores de cada provedor selecionado. Não representa fatura ou uso da assinatura.", + "stale": "A prévia mudou. Gere uma nova antes de exportar.", + "failed": "Não foi possível gerar ou salvar a prévia. Tente novamente.", + "saved": "SVG salvo.", + "cancelled": "Exportação cancelada.", + "titleDisclosure": "O título personalizado também será exportado." + }, + "id": { + "providerLabel": "Penyedia", + "bothProviders": "Claude + Codex", + "metricLabel": "Dimensi", + "amountMetric": "Setara API (termasuk nilai uang)", + "showAmounts": "Sertakan nilai uang", + "amountDisclosure": "Ekspor memuat estimasi saat ini dan warna berbasis nilai untuk tiap penyedia terpilih. Bukan tagihan atau penggunaan langganan.", + "stale": "Pratinjau berubah. Buat ulang sebelum mengekspor.", + "failed": "Pratinjau tidak dapat dibuat atau disimpan. Coba lagi.", + "saved": "SVG disimpan.", + "cancelled": "Ekspor dibatalkan.", + "titleDisclosure": "Judul kustom juga disertakan dalam ekspor." + } +}; + +const CODEX_COMPRESSION_RUNTIME_UNSUPPORTED: Record = { + en: 'The current VS Code runtime does not support compressed Codex logs. Please update VS Code. The last verified data is retained.', + 'de-DE': 'Die aktuelle VS Code-Laufzeit unterstützt keine komprimierten Codex-Protokolle. Aktualisieren Sie VS Code. Die zuletzt geprüften Daten bleiben erhalten.', + 'zh-CN': '当前 VS Code 运行环境不支持 Codex 压缩日志。请更新 VS Code;保留上次已验证的数据。', + 'zh-TW': '目前 VS Code 執行環境不支援 Codex 壓縮日誌。請更新 VS Code;保留上次已驗證的資料。', + ja: '現在の VS Code 実行環境は Codex の圧縮ログに対応していません。VS Code を更新してください。前回の検証済みデータは保持されます。', + ko: '현재 VS Code 실행 환경은 Codex 압축 로그를 지원하지 않습니다. VS Code를 업데이트하세요. 마지막으로 검증된 데이터는 유지됩니다.', + 'pt-BR': 'O ambiente de execução atual do VS Code não suporta logs compactados do Codex. Atualize o VS Code. Os últimos dados verificados são mantidos.', + id: 'Runtime VS Code saat ini tidak mendukung log Codex terkompresi. Perbarui VS Code. Data terakhir yang terverifikasi tetap dipertahankan.', +}; + export class I18n { private static currentLanguage: SupportedLanguage = 'en'; private static currentDecimalPlaces: number = 2; @@ -4733,10 +6613,22 @@ export class I18n { return translations[this.currentLanguage]; } + static get codexCompressionRuntimeUnsupported(): string { + return CODEX_COMPRESSION_RUNTIME_UNSUPPORTED[this.currentLanguage]; + } + + static get achievementManagement(): AchievementManagementCopy { + return ACHIEVEMENT_MANAGEMENT_COPY[this.currentLanguage]; + } + static get sharingWorkspace(): SharingWorkspaceTranslations { return SHARING_WORKSPACE_TRANSLATIONS[this.currentLanguage]; } + static get apiHeatmap() { + return { ...API_HEATMAP_COPY[this.currentLanguage], ...API_HEATMAP_UI_TRANSLATIONS[this.currentLanguage] }; + } + static get dashboardFeedback(): typeof DASHBOARD_FEEDBACK_TRANSLATIONS.en { return DASHBOARD_FEEDBACK_TRANSLATIONS[this.currentLanguage]; } @@ -4785,11 +6677,14 @@ export class I18n { return formatUsdBaselineValue(amount, decimalPlaces); } - /** Always-compact token count (k / M / B) honouring the user's decimal + /** Always-compact token count (k / M / B / T) honouring the user's decimal * places — used by the status-bar "tokens" metric so it stays short. */ static formatTokensCompact(num: number): string { const p = this.tokenDecimalPlaces; const abs = Math.abs(num); + if (abs >= 1_000_000_000_000) { + return (num / 1_000_000_000_000).toFixed(p) + 'T'; + } if (abs >= 1_000_000_000) { return (num / 1_000_000_000).toFixed(p) + 'B'; } diff --git a/src/localDataControls.ts b/src/localDataControls.ts index 544ce032..ac6ba575 100644 --- a/src/localDataControls.ts +++ b/src/localDataControls.ts @@ -108,6 +108,24 @@ export const LOCAL_DATA_PENDING_CLIENT_RESET_KEY = /** Exact non-setting globalState keys eligible for clear-all. */ export const LOCAL_DATA_GLOBAL_STATE_KEYS = [ + 'ccu.achievements.grants.v1', + 'ccu.achievements.grants.v2', + 'ccu.achievements.grants.v3', + 'ccu.achievements.grants.v4', + 'ccu.achievements.grants.v5', + 'ccu.achievements.structuralAudit.v1', + 'ccu.achievements.tokenAudit.v1', + 'ccu.achievements.bucketAudit.v1', + 'ccu.achievements.manifestAudit.v1', + 'ccu.achievements.calendarAudit.v1', + 'ccu.achievements.calibrationAudit.v1', + 'ccu.achievements.calibrationPending.v1', + 'ccu.achievements.calendarPeak.v1', + 'ccu.achievements.activity.v1', + 'ccu.achievements.activity.v2', + 'ccu.achievements.hours.v1', + 'ccu.achievements.hours.v2', + 'ccu.achievements.hours.v3', 'ccu.adviceEffectiveness.localState', 'ccu.codex.backgroundWork.v1', 'ccu.codex.machineSalt', @@ -152,6 +170,7 @@ export const LOCAL_DATA_ACTION_TARGETS: Record') { return null; } @@ -632,12 +645,13 @@ export function getModelPricing(modelName: string | undefined): ModelPricing | n // Family-aware fallback for unrecognised (typically newer) snapshots. const inferred = inferPricingByFamily(modelName); if (inferred) { - warnUnknownModelOnce(modelName, inferred.family); + if (diagnostics) warnUnknownModelOnce(modelName, inferred.family); return inferred.pricing; } // Truly unknown model (no family keyword) — fall back to Sonnet, the most common default. - warnUnknownModelOnce(modelName, 'Sonnet'); + if (!genericDefault) return null; + if (diagnostics) warnUnknownModelOnce(modelName, 'Sonnet'); return SONNET; } diff --git a/src/providers/codex/codexAchievementOrigin.ts b/src/providers/codex/codexAchievementOrigin.ts new file mode 100644 index 00000000..ce35ee22 --- /dev/null +++ b/src/providers/codex/codexAchievementOrigin.ts @@ -0,0 +1,157 @@ +import type { NormalizedUsageEvent } from '../providerTypes'; +import type { CodexFileAggregate, CodexFileContribution, CodexIndexV3 } from './codexIndex'; +import { codexModelFamily, normalizeCodexAchievementModel } from '../../achievementModels'; +import { validAchievementTokenVector, verifiedCodexAchievementFiles } from '../../codexAchievementProof'; +import { classifyCodexSessionDuplicates } from './codexDedup'; +import { getAchievementMoneyPricebook, priceAchievementModelBucket } from '../../achievementMoney'; +import { codexMoneyEvidenceFromModels } from '../../achievementMoneyScopes'; +import { accumulateMoneyProof, copyMoneyProofs, type AchievementMoneyProofs } from '../../achievementMoneyOrigin'; + +export interface CodexAchievementUsageWindow { after: number; through: number } +/** Runtime-only numeric lower bound; never part of an index/checkpoint. */ +export interface CodexAchievementUsageDelta extends CodexAchievementUsageWindow { + provider: 'codex'; processed: number; output: number; cacheRead: number; + families: Record<'astra' | 'sol' | 'luna', number>; + money?: AchievementMoneyProofs; +} +const count = (n: unknown): n is number => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0; +export function copyCodexUsageWindow(value: unknown): CodexAchievementUsageWindow | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const { after, through } = value as CodexAchievementUsageWindow; + return count(after) && count(through) && after < through && through <= 8.64e15 + ? { after, through } : undefined; +} +export function emptyCodexUsageDelta(window: CodexAchievementUsageWindow): CodexAchievementUsageDelta { + return { provider: 'codex', after: window.after, through: window.through, processed: 0, output: 0, cacheRead: 0, + families: { astra: 0, sol: 0, luna: 0 } }; +} +/** Rebuild allowlisted fields at every worker boundary; no arbitrary extras. */ +export function copyCodexUsageDelta(value: unknown, window: CodexAchievementUsageWindow | undefined): CodexAchievementUsageDelta | undefined { + if (!copyCodexUsageWindow(window) || !window || !value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const row = value as CodexAchievementUsageDelta; + if (row.provider !== 'codex' || row.after !== window.after || row.through !== window.through || + !count(row.processed) || !count(row.output) || !count(row.cacheRead) || + !count(row.output + row.cacheRead) || row.output + row.cacheRead > row.processed || + !row.families || typeof row.families !== 'object' || Array.isArray(row.families)) return undefined; + const families = { astra: row.families.astra, sol: row.families.sol, luna: row.families.luna }; + if (!Object.values(families).every(count) || !count(families.astra + families.sol + families.luna) || + families.astra + families.sol + families.luna > row.processed) return undefined; + const money = copyMoneyProofs(row.money, 'codex', row.processed); + return { provider: 'codex', after: window.after, through: window.through, + processed: row.processed, output: row.output, cacheRead: row.cacheRead, families, + ...(money ? { money } : {}) }; +} +export function addCodexUsageDelta(target: CodexAchievementUsageDelta, addition: CodexAchievementUsageDelta): boolean { + const old = copyCodexUsageDelta(target, target), incoming = copyCodexUsageDelta(addition, target); + if (!old || !incoming) return false; + const next = { ...old, processed: old.processed + incoming.processed, + output: old.output + incoming.output, cacheRead: old.cacheRead + incoming.cacheRead, + families: { astra: old.families.astra + incoming.families.astra, + sol: old.families.sol + incoming.families.sol, luna: old.families.luna + incoming.families.luna } }; + const proof = incoming.money?.openai + ? accumulateMoneyProof(old.money?.openai, incoming.money.openai) : old.money?.openai; + delete next.money; + if (proof) next.money = { openai: proof }; + if (!copyCodexUsageDelta(next, target)) return false; + delete target.money; + Object.assign(target, next); return true; +} +/** Called only for events emitted by the established main tail parser. */ +export function addCodexOriginEvent(target: CodexAchievementUsageDelta, event: NormalizedUsageEvent): boolean { + if (event.timestamp <= target.after || event.timestamp > target.through) return true; + if (!count(event.timestamp) || !validAchievementTokenVector(event.tokens)) return false; + const addition = emptyCodexUsageDelta(target); + addition.processed = event.tokens.inputTotal + event.tokens.outputTotal; + addition.output = event.tokens.outputTotal; + addition.cacheRead = event.tokens.cachedInput ?? 0; + const model = event.model && normalizeCodexAchievementModel(event.model); + const family = model ? codexModelFamily(model) : null; + if (family) addition.families[family] = addition.processed; + if (event.provider === 'codex') { + // Unknown aliases and non-native cache buckets remain an excluded subset. + // Invalid optional pricing must never suppress a sound native Token event. + try { + const priced = priceAchievementModelBucket({ provider: 'codex', model: event.model, tokens: event.tokens }, 'openai'); + if (priced.reason === null && priced.microUsd !== null && priced.pricedTokens > 0) addition.money = { openai: { + provider: 'codex', backend: 'openai', pricebookVersion: getAchievementMoneyPricebook('openai').version, + exactMicroUsdUnits: priced.exactMicroUsdUnits, microUsd: priced.microUsd, eligibleProcessed: priced.pricedTokens, + } }; + } catch { /* native Token proof remains usable */ } + } + return addCodexUsageDelta(target, addition); +} +function stableContribution(row: CodexFileContribution): boolean { + return (row.compression !== 'zstd' || row.compressionVerified === true) && + row.offset === row.size && !row.discardingOversizedLine && row.identityChecked === true && + row.parserState.schemaVersion === 4 && row.parserState.identityLocked === true && + !row.lineageReconciliation && !!row.lineage && row.lineage.appliedPrefixEvents === row.lineage.desiredPrefixEvents && + !row.qualityFlags.some(flag => ['stale-file', 'stale-reset-required', 'oversized-jsonl-line', + 'invalid-event-timestamp', 'counter-regression', 'component-delta-clamped', 'invalid-token-count', + 'invalid-session-meta', 'identity-conflict'].includes(flag)); +} +/** Full base coverage, independent of globally incomplete calendar/identity proof. */ +export function codexOriginBaselineReady(index: CodexIndexV3): boolean { + return index.coverage.complete && index.coverage.indexedFiles === index.coverage.totalFiles && + Object.keys(index.files).length === index.coverage.totalFiles && + Object.values(index.files).every(row => (row.compression !== 'zstd' || row.compressionVerified === true) && + row.offset === row.size && !row.discardingOversizedLine); +} +function trustedContributions(index: CodexIndexV3): Set { + const keys = classifyCodexSessionDuplicates(index.files).canonicalFileKeys; + const rows = [...keys].map(key => index.files[key]); + const verified = new Set(verifiedCodexAchievementFiles(rows.map(row => row.aggregate))); + const stable = rows.filter(row => verified.has(row.aggregate) && stableContribution(row)); + // Removing an unsettled ancestor also excludes its descendants. + const sound = new Set(verifiedCodexAchievementFiles(stable.map(row => row.aggregate))); + return new Set(stable.filter(row => sound.has(row.aggregate))); +} +function aggregateVolume(aggregate: CodexFileAggregate, window: CodexAchievementUsageWindow): CodexAchievementUsageDelta { + const volume = emptyCodexUsageDelta(window); + volume.processed = aggregate.total.inputTotal + aggregate.total.outputTotal; + volume.output = aggregate.total.outputTotal; + volume.cacheRead = aggregate.total.cachedInput ?? 0; + for (const [label, tokens] of Object.entries(aggregate.byModel)) { + const model = normalizeCodexAchievementModel(label); + const family = model ? codexModelFamily(model) : null; + if (family) volume.families[family] += tokens.inputTotal + tokens.outputTotal; + } + return volume; +} +/** The coordinator admits only unchanged physical ownership and settled prefixes. */ +export function finalizeCodexUsageDelta(previous: CodexIndexV3, current: CodexIndexV3, + candidates: ReadonlyMap, window: CodexAchievementUsageWindow | undefined, +): CodexAchievementUsageDelta | undefined { + if (!window || !candidates.size || !codexOriginBaselineReady(previous) || !codexOriginBaselineReady(current) || + previous.coverage.period.timeZone !== current.coverage.period.timeZone) return undefined; + const before = trustedContributions(previous), after = trustedContributions(current); + const total = emptyCodexUsageDelta(window); + for (const [key, raw] of candidates) { + const old = previous.files[key], next = current.files[key]; + const delta = copyCodexUsageDelta(raw, window); + if (!delta || !old || !next || !before.has(old) || !after.has(next) || + !(old.dev! > 0 && old.ino! > 0) || old.dev !== next.dev || old.ino !== next.ino || + old.aggregate.session.sessionKey !== next.aggregate.session.sessionKey || + old.aggregate.session.parentSessionKey !== next.aggregate.session.parentSessionKey || + old.lineage!.appliedPrefixEvents !== next.lineage!.appliedPrefixEvents) continue; + const oldVolume = aggregateVolume(old.aggregate, window), newVolume = aggregateVolume(next.aggregate, window); + if (!copyCodexUsageDelta(oldVolume, window) || !copyCodexUsageDelta(newVolume, window)) continue; + if ((['processed', 'output', 'cacheRead'] as const).some(field => + newVolume[field] - oldVolume[field] < delta[field]) || + (['astra', 'sol', 'luna'] as const).some(family => + newVolume.families[family] - oldVolume.families[family] < delta.families[family])) continue; + if (delta.money?.openai) { + // Price ONLY the affected admitted candidate's already materialized model + // aggregates. Same frozen book on both sides; exact units, never a sum of + // per-event floors. Reconciliation failure removes only this money subset. + const oldMoney = codexMoneyEvidenceFromModels(old.aggregate.byModel, 'openai'); + const newMoney = codexMoneyEvidenceFromModels(next.aggregate.byModel, 'openai'); + const proof = delta.money.openai; + if (oldMoney.status === 'invalid' || newMoney.status === 'invalid' || + oldMoney.pricebookVersion !== proof.pricebookVersion || newMoney.pricebookVersion !== proof.pricebookVersion || + newMoney.pricedTokens! - oldMoney.pricedTokens! < proof.eligibleProcessed || + BigInt(newMoney.exactMicroUsdUnits!) - BigInt(oldMoney.exactMicroUsdUnits!) < BigInt(proof.exactMicroUsdUnits)) delete delta.money; + } + if (!addCodexUsageDelta(total, delta)) return undefined; + } + return total.processed > 0 ? total : undefined; +} diff --git a/src/providers/codex/codexDedup.ts b/src/providers/codex/codexDedup.ts index 0fa60b0b..be0aedca 100644 --- a/src/providers/codex/codexDedup.ts +++ b/src/providers/codex/codexDedup.ts @@ -72,6 +72,7 @@ function contributionSignature(contribution: CodexFileContribution): string { function isVerified(contribution: CodexFileContribution): boolean { return ( + (contribution.compression !== 'zstd' || contribution.compressionVerified === true) && contribution.offset === contribution.size && !contribution.discardingOversizedLine ); diff --git a/src/providers/codex/codexFilePassWorker.ts b/src/providers/codex/codexFilePassWorker.ts index 3941f266..2862e0ca 100644 --- a/src/providers/codex/codexFilePassWorker.ts +++ b/src/providers/codex/codexFilePassWorker.ts @@ -4,6 +4,7 @@ import { CodexFilePassOutcome, CodexFilePassTask, runCodexFilePass, + codexFilePassFailureCode, } from './codexIndex'; export interface CodexFilePassWorkerRequest { @@ -25,10 +26,11 @@ parentPort?.on('message', async (request: CodexFilePassWorkerRequest) => { let outcome: CodexFilePassOutcome; try { outcome = await runCodexFilePass(request.task); - } catch { + } catch (error) { // File paths and parser errors stay inside the local worker. The - // coordinator needs only the pseudonymous task ID and failure state. - outcome = { taskId: request.task.taskId, ok: false }; + // coordinator receives only a fixed codec enum, never native error data. + const failureCode = codexFilePassFailureCode(error); + outcome = { taskId: request.task.taskId, ok: false, ...(failureCode ? { failureCode } : {}) }; } const response: CodexFilePassWorkerResponse = { type: 'outcome', diff --git a/src/providers/codex/codexHourProof.ts b/src/providers/codex/codexHourProof.ts new file mode 100644 index 00000000..571fee7f --- /dev/null +++ b/src/providers/codex/codexHourProof.ts @@ -0,0 +1,153 @@ +/** A timezone-neutral, positive-output hour set. No source text or identifiers. */ +export const CODEX_UTC_HOUR_PROOF_VERSION = 1; +export const MAX_CODEX_HOURS_PER_FILE = 32_768; +export const MAX_CODEX_HOURS_IN_SNAPSHOT = 524_288; +const HOUR_MS = 3_600_000; + +export interface CodexHourProof { + version: typeof CODEX_UTC_HOUR_PROOF_VERSION; + /** Verified JSONL byte position, never a guessed file size. */ + indexedThrough: number; + /** Lineage token events intentionally excluded from this proof. */ + prefixEvents: number; + /** Strictly increasing absolute UTC hour IDs with positive output. */ + hours: number[]; +} + +function safeNonnegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +export function sanitizeCodexHourProof(value: unknown): CodexHourProof | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const candidate = value as Record; + if (candidate.version !== CODEX_UTC_HOUR_PROOF_VERSION || + !safeNonnegativeInteger(candidate.indexedThrough) || + !safeNonnegativeInteger(candidate.prefixEvents) || + !Array.isArray(candidate.hours) || + candidate.hours.length > MAX_CODEX_HOURS_PER_FILE) return undefined; + const hours: number[] = []; + for (const hour of candidate.hours) { + if (!Number.isSafeInteger(hour) || hour <= 0 || + (hours.length > 0 && hour <= hours[hours.length - 1])) return undefined; + hours.push(hour); + } + return { + version: CODEX_UTC_HOUR_PROOF_VERSION, + indexedThrough: candidate.indexedThrough, + prefixEvents: candidate.prefixEvents, + hours, + }; +} + +/** Return undefined on capacity exhaustion; callers must fail closed. */ +export function addCodexOutputHour( + hours: readonly number[], + timestamp: number, + outputTotal: number, +): number[] | undefined { + if (!Number.isFinite(timestamp) || timestamp <= 0 || + !Number.isSafeInteger(outputTotal) || outputTotal <= 0) return hours as number[]; + const hour = Math.floor(timestamp / HOUR_MS); + if (!Number.isSafeInteger(hour) || hour <= 0) return hours as number[]; + let low = 0; + let high = hours.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (hours[mid] < hour) low = mid + 1; + else high = mid; + } + if (hours[low] === hour) return hours as number[]; + if (hours.length >= MAX_CODEX_HOURS_PER_FILE) return undefined; + const next = [...hours]; + next.splice(low, 0, hour); + return next; +} + +/** Mutates only an owned working array; avoids copying it for every event. */ +export function addCodexOutputHourInPlace( + hours: number[], + timestamp: number, + outputTotal: number, +): boolean { + if (!Number.isFinite(timestamp) || timestamp <= 0 || + !Number.isSafeInteger(outputTotal) || outputTotal <= 0) return true; + const hour = Math.floor(timestamp / HOUR_MS); + if (!Number.isSafeInteger(hour) || hour <= 0) return true; + let low = 0; + let high = hours.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (hours[mid] < hour) low = mid + 1; + else high = mid; + } + if (hours[low] === hour) return true; + if (hours.length >= MAX_CODEX_HOURS_PER_FILE) return false; + hours.splice(low, 0, hour); + return true; +} + +export interface CodexHourProofFile { + compression?: 'zstd'; + compressionVerified?: true; + offset: number; + size: number; + discardingOversizedLine: boolean; + qualityFlags: readonly string[]; + identityChecked?: boolean; + lineage?: { + appliedPrefixEvents?: number; + desiredPrefixEvents?: number; + }; + lineageReconciliation?: unknown; + hourProof?: unknown; +} + +export interface CanonicalCodexOutputHours { + complete: boolean; + hours: number[]; + provenFiles: number; + totalFiles: number; + /** Files eligible for a bounded historical pass; zero means retrying cannot help. */ + backfillableFiles?: number; +} + +/** Never expose a partial union as a historical certificate. */ +export function canonicalCodexOutputHours( + files: Readonly>, + canonicalKeys: ReadonlySet, + identityComplete: boolean, +): CanonicalCodexOutputHours { + let provenFiles = 0; + const union = new Set(); + for (const key of canonicalKeys) { + const file = files[key]; + const proof = sanitizeCodexHourProof(file?.hourProof); + const eligible = file && proof && + (file.compression !== 'zstd' || file.compressionVerified === true) && + file.identityChecked === true && + file.offset === file.size && proof.indexedThrough === file.offset && + !file.discardingOversizedLine && !file.lineageReconciliation && + file.lineage?.appliedPrefixEvents === file.lineage?.desiredPrefixEvents && + proof.prefixEvents === file.lineage?.desiredPrefixEvents && + !file.qualityFlags.some((flag) => [ + 'stale-file', 'stale-reset-required', 'oversized-jsonl-line', + 'invalid-event-timestamp', + ].includes(flag)); + if (!eligible) continue; + provenFiles += 1; + for (const hour of proof.hours) { + union.add(hour); + if (union.size > MAX_CODEX_HOURS_IN_SNAPSHOT) { + return { complete: false, hours: [], provenFiles, totalFiles: canonicalKeys.size }; + } + } + } + const complete = identityComplete && provenFiles === canonicalKeys.size; + return { + complete, + hours: complete ? [...union].sort((left, right) => left - right) : [], + provenFiles, + totalFiles: canonicalKeys.size, + }; +} diff --git a/src/providers/codex/codexIndex.ts b/src/providers/codex/codexIndex.ts index d1499546..af914371 100644 --- a/src/providers/codex/codexIndex.ts +++ b/src/providers/codex/codexIndex.ts @@ -35,6 +35,7 @@ import { CodexStructuralSummary, CodexTodayMigrationState, isCompatibleCodexPeriodLineage, + isCurrentCodexPeriodLineage, pruneCodexHourlyDays, reduceCodexRollingHourlySlice, reduceCodexStructuralSlice, @@ -42,6 +43,7 @@ import { codexPeriodFitsAggregate, } from './codexPeriodIndex'; import { parseJsonObject, stringField } from './codexSchema'; +import { CodexZstdError, type CodexZstdErrorCode } from './codexZstd'; import { CodexManifest, CodexPersistedManifest, @@ -66,10 +68,14 @@ import { pseudonymousIdentityKey, } from './codexIdentity'; import { sanitizeCodexMetadataLabel } from './codexMetadataLabel'; +import { CodexAchievementUsageWindow, CodexAchievementUsageDelta, copyCodexUsageWindow, + emptyCodexUsageDelta, addCodexOriginEvent, copyCodexUsageDelta, + codexOriginBaselineReady, finalizeCodexUsageDelta } from './codexAchievementOrigin'; import { CODEX_LINEAGE_FINGERPRINT_BYTES, CODEX_LINEAGE_FINGERPRINTS_PER_BLOCK, CodexLineageTrace, + createLineageDepthResolver, } from './codexLineage'; import { appendCodexQuotaHistory, @@ -79,6 +85,14 @@ import { CodexQuotaObservation, sanitizeCodexQuotaHistory, } from './codexQuotaHistory'; +import { + CODEX_UTC_HOUR_PROOF_VERSION, + MAX_CODEX_HOURS_PER_FILE, + CodexHourProof, + CanonicalCodexOutputHours, + addCodexOutputHourInPlace, + canonicalCodexOutputHours, +} from './codexHourProof'; export { CodexPeriodMigrationState, @@ -105,12 +119,27 @@ export interface CodexFileAggregate { }; structural: CodexStructuralSummary; period?: CodexFilePeriodIndex; + /** Runtime-only current-source calendar provenance; never persisted as index proof. */ + achievementPeriodWitness?: { + size: number; + offset: number; + expectedPrefixEvents: number; + timeZone: string; + }; today?: CodexFileTodayIndex; } export interface CodexFileContribution { fileKey: string; sourceArea?: CodexSourceArea; + /** size/offset remain decoded-byte coordinates; these describe on-disk bytes. */ + compression?: 'zstd'; + storageSize?: number; + storageCtimeMs?: number; + /** Present only after the exact physical representation reached verified EOF. */ + compressionVerified?: true; + /** A deterministic failure of this exact physical representation, never usage. */ + compressionFailure?: { version: 1; code: CodexTerminalCompressionFailureCode }; size: number; mtimeMs: number; dev?: number; @@ -129,6 +158,17 @@ export interface CodexFileContribution { identityChecked?: boolean; periodMigration?: CodexPeriodMigrationState; todayMigration?: CodexTodayMigrationState; + hourProof?: CodexHourProof; + hourProofMigration?: CodexHourProofMigrationState; +} + +export interface CodexHourProofMigrationState extends CodexJsonlCursor { + version: typeof CODEX_UTC_HOUR_PROOF_VERSION; + prefixEvents: number; + tokenEventsSeen: number; + parserState: CodexParserState; + hours: number[]; + overflow?: boolean; } /** @@ -140,7 +180,8 @@ export interface CodexFileContribution { export function isCodexUsageContributionCurrent( contribution: CodexFileContribution, ): boolean { - return !contribution.qualityFlags.includes('stale-reset-required'); + return !contribution.compressionFailure && !contribution.qualityFlags.includes('stale-reset-required') && + (contribution.compression !== 'zstd' || contribution.compressionVerified === true); } export interface CodexLineageReconciliationState extends CodexJsonlCursor { @@ -149,6 +190,8 @@ export interface CodexLineageReconciliationState extends CodexJsonlCursor { parserState: CodexParserState; aggregate: CodexFileAggregate; qualityFlags: string[]; + hourProofHours?: number[]; + hourProofOverflow?: boolean; } // A first-time or legacy backfill receives one large streaming ceiling so a @@ -298,6 +341,61 @@ export type CodexFilePassKind = | 'today' | 'identity'; +/** Identity-incomplete or partially migrated histories expose no hour IDs. */ +export function codexHourProofSnapshot( + index: CodexIndexV3, + nowMs = Date.now(), +): CanonicalCodexOutputHours { + const canonicalKeys = classifyCodexSessionDuplicates(index.files).canonicalFileKeys; + const proof = canonicalCodexOutputHours( + index.files, + canonicalKeys, + index.coverage.complete && index.coverage.identity.complete && + index.coverage.indexedFiles === index.coverage.totalFiles, + ); + // A future-dated event in the current UTC hour cannot be distinguished + // from an already-observed one by the minimal hour ID alone. Count only + // closed UTC hours, accepting a delay of at most one hour for awards. + return { + ...proof, + hours: proof.hours.filter((hour) => (hour + 1) * 3_600_000 <= nowMs), + backfillableFiles: index.coverage.identity.complete + ? [...canonicalKeys].filter((key) => { + const contribution = index.files[key]; + return contribution && contribution.offset === contribution.size && + needsHourProofBackfill(contribution); + }).length + : 0, + }; +} + +function needsHourProofBackfill(contribution: CodexFileContribution): boolean { + if (contribution.identityChecked !== true || contribution.discardingOversizedLine || + contribution.lineageReconciliation || contribution.hourProofMigration?.overflow || + contribution.lineage?.appliedPrefixEvents !== contribution.lineage?.desiredPrefixEvents || + contribution.qualityFlags.some((flag) => [ + 'stale-file', 'stale-reset-required', 'oversized-jsonl-line', + 'invalid-event-timestamp', + ].includes(flag))) return false; + const proof = sanitizeCodexHourProof(contribution.hourProof); + return !proof || proof.indexedThrough !== contribution.offset || + proof.prefixEvents !== contribution.lineage?.desiredPrefixEvents; +} + +function hourProofWorkEntries( + index: CodexIndexV3, + manifest: CodexManifest, +): CodexRuntimeManifestEntry[] { + if (!index.coverage.identity.complete) return []; + const canonical = classifyCodexSessionDuplicates(index.files).canonicalFileKeys; + return manifest.files.filter((entry) => { + if (!canonical.has(entry.fileKey)) return false; + const contribution = index.files[entry.fileKey]; + return !!contribution && contribution.offset === entry.size && + needsHourProofBackfill(contribution); + }); +} + /** * A self-contained, per-file pass. Runtime paths are sent only to the local * worker that reads the file and are never persisted in the index. @@ -311,6 +409,7 @@ export interface CodexFilePassTask { timeZone: string; asOfDay: string; endExclusive: number; + achievementUsageWindow?: CodexAchievementUsageWindow; } export type CodexFilePassOutcome = @@ -319,8 +418,34 @@ export type CodexFilePassOutcome = ok: true; contribution: CodexFileContribution; bytesRead: number; + achievementUsageDelta?: CodexAchievementUsageDelta; } - | { taskId: string; ok: false }; + | { taskId: string; ok: false; failureCode?: CodexZstdErrorCode }; + +const terminalCompressionCodes = [ + 'malformed', 'truncated', 'trailing-data', 'size-mismatch', 'decode-failed', 'size-limit', + 'unknown-size', 'dictionary', 'reserved', +] as const satisfies readonly CodexZstdErrorCode[]; +type CodexTerminalCompressionFailureCode = typeof terminalCompressionCodes[number]; + +/** Fixed enum copying at the worker boundary; never copy Error/message/stack. */ +function copyFilePassFailureCode(value: unknown): CodexZstdErrorCode | undefined { + switch (value) { + case 'malformed': case 'truncated': case 'trailing-data': case 'size-mismatch': + case 'decode-failed': case 'size-limit': case 'unknown-size': case 'dictionary': case 'reserved': + case 'codec-unavailable': case 'replay-limit': case 'invalid-range': case 'invalid-options': + case 'io-error': case 'aborted': case 'source-changed': return value; + default: return undefined; + } +} + +export function codexFilePassFailureCode(error: unknown): CodexZstdErrorCode | undefined { + return error instanceof CodexZstdError ? copyFilePassFailureCode(error.code) : undefined; +} + +function terminalCompressionCode(value: unknown): CodexTerminalCompressionFailureCode | undefined { + return terminalCompressionCodes.find(code => code === value); +} export type CodexFilePassBatchRunner = ( tasks: readonly CodexFilePassTask[], @@ -329,6 +454,8 @@ export type CodexFilePassBatchRunner = ( ) => Promise; export interface CodexIndexUpdateOptions { + /** Caller-owned accepted observation window, never inferred from refresh triggers. */ + achievementUsageWindow?: CodexAchievementUsageWindow; salt: string; timeZone: string; now?: () => number; @@ -343,11 +470,14 @@ export interface CodexIndexUpdateOptions { /** False skips unchanged historical lineage/period/hour/identity passes while * still allowing the main changed-file and append-tail stage to run. */ allowHistoricalBackfill?: boolean; + /** False avoids separate history reads for achievements; existing usage cursors stay intact. */ + allowAchievementHourProof?: boolean; /** Internal resolved civil day shared with local file-pass workers. */ asOfDay?: string; } export interface CodexIndexUpdateResult { + achievementUsageDelta?: CodexAchievementUsageDelta; index: CodexIndexV3; indexChanged: boolean; bodyReads: number; @@ -654,6 +784,23 @@ function uniqueFlags(...groups: string[][]): string[] { return [...new Set(groups.flat())].sort(); } +/** A valid checkpoint may still be skipping inherited events. Resuming that + * scan is not permission to publish its dates; promotion still requires the + * entire prefix and source cursor below. */ +function resumablePeriodMigration( + contribution: CodexFileContribution, + timeZone: string, +): CodexPeriodMigrationState | undefined { + const migration = contribution.periodMigration; + const prefixEvents = contribution.lineage?.desiredPrefixEvents ?? 0; + const seen = migration?.tokenEventsSeen; + if (!migration || migration.timeZone !== timeZone || migration.prefixEvents !== prefixEvents || + typeof seen !== 'number' || !Number.isSafeInteger(seen) || seen < 0 || + !Number.isSafeInteger(migration.offset) || migration.offset < 0 || migration.offset > contribution.offset || + (seen < prefixEvents && Object.keys(migration.days).length > 0)) return undefined; + return migration; +} + function promoteCaughtUpPeriod( contribution: CodexFileContribution, timeZone: string, @@ -802,6 +949,8 @@ function previousManifest(index: CodexIndexV3): CodexPersistedManifest { mtimeMs: file.mtimeMs, dev: file.dev, ino: file.ino, + ...(file.compression === 'zstd' ? { compression: file.compression, + storageSize: file.storageSize, storageCtimeMs: file.storageCtimeMs } : {}), }, ]] : [], @@ -822,23 +971,96 @@ function contributionFor( mtimeMs: 0, dev: entry.dev, ino: entry.ino, + ...compressedSourceMetadata(entry), offset: 0, discardingOversizedLine: false, parserState, lineage: createLineageTrace(), + hourProof: { + version: CODEX_UTC_HOUR_PROOF_VERSION, + indexedThrough: 0, + prefixEvents: 0, + hours: [], + }, aggregate: { ...emptyFileAggregate(entry.fileKey, parserState.role), - period: { timeZone, indexedThrough: 0, days: {} }, + // A new empty cursor is built by the current reducer from byte zero. + // Unlike a persisted legacy slice, it needs no second validation pass. + period: { timeZone, indexedThrough: 0, days: {}, + lineageVersion: CODEX_PERIOD_LINEAGE_VERSION, lineagePrefixEvents: 0 }, }, qualityFlags: [...flags], }; } interface CodexFilePassResult { + achievementUsageDelta?: CodexAchievementUsageDelta; contribution: CodexFileContribution; bytesRead: number; } +function compressedSourceMetadata(entry: CodexRuntimeManifestEntry): + Pick { + return entry.compression === 'zstd' ? { compression: 'zstd', storageSize: entry.storageSize, + storageCtimeMs: entry.storageCtimeMs } : {}; +} + +function validCompressionFingerprint(row: Record): boolean { + return row.compression === 'zstd' && + typeof row.size === 'number' && Number.isSafeInteger(row.size) && row.size >= 0 && + typeof row.storageSize === 'number' && Number.isSafeInteger(row.storageSize) && row.storageSize > 0 && + typeof row.mtimeMs === 'number' && Number.isFinite(row.mtimeMs) && + typeof row.storageCtimeMs === 'number' && Number.isFinite(row.storageCtimeMs) && row.storageCtimeMs > 0 && + [row.dev, row.ino].every(value => value === undefined || + typeof value === 'number' && Number.isFinite(value) && value >= 0); +} + +function hasCurrentCompressionFailure(contribution: CodexFileContribution | undefined, + entry: CodexRuntimeManifestEntry): boolean { + return !!contribution && contribution.compressionFailure?.version === 1 && + terminalCompressionCode(contribution.compressionFailure.code) !== undefined && + contribution.compression === 'zstd' && entry.compression === 'zstd' && + contribution.fileKey === entry.fileKey && contribution.size === entry.size && + contribution.storageSize === entry.storageSize && contribution.mtimeMs === entry.mtimeMs && + contribution.storageCtimeMs === entry.storageCtimeMs && + contribution.dev === entry.dev && contribution.ino === entry.ino; +} + +/** Rebuild a metadata-only empty contribution. Never retain a failed draft's + * parser state, tokens, quota observations, lineage or period/hour witnesses. */ +function compressionFailureContribution( + entry: Pick, + code: CodexTerminalCompressionFailureCode, +): CodexFileContribution { + // This path is also a persistence sanitizer: construct the neutral state + // through the explicit local DTO whitelist, not an opaque imported object. + const parserState = sanitizeParserState(undefined, entry.fileKey); + return { + fileKey: entry.fileKey, sourceArea: entry.sourceArea, compression: 'zstd', + size: entry.size, mtimeMs: entry.mtimeMs, storageSize: entry.storageSize, + storageCtimeMs: entry.storageCtimeMs, dev: entry.dev, ino: entry.ino, + compressionFailure: { version: 1, code }, offset: 0, discardingOversizedLine: false, + parserState, aggregate: emptyFileAggregate(entry.fileKey, parserState.role), + qualityFlags: ['stale-file', 'stale-reset-required'], + }; +} + +/** A cancellation after the final decoded chunk can leave an unverified draft. + * Revalidate rather than promoting an offset-equals-size claim. */ +function compressedDraftNeedsRestart(contribution: CodexFileContribution | undefined): boolean { + return contribution?.compression === 'zstd' && contribution.compressionVerified !== true && + contribution.offset >= contribution.size; +} + +/** Compressed history is an immutable whole-frame task. Permit at most the + * first file in a refresh to exceed the soft byte budget (codec hard caps still + * apply), otherwise defer it. This avoids replay-prefix budget starvation. */ +function compressedMainPassEnd(entry: CodexRuntimeManifestEntry, start: number, remaining: number, first: boolean): number { + if (entry.compression === 'zstd') return entry.size <= remaining || first ? entry.size : start; + return Math.min(entry.size, start + remaining); +} + type ContributionChunkHandler = ( contribution: CodexFileContribution, bytesRead: number, @@ -852,6 +1074,8 @@ async function updateContribution( endExclusive: number, onChunk: ContributionChunkHandler, ): Promise { + const usageWindow = entry.compression === 'zstd' ? undefined : copyCodexUsageWindow(options.achievementUsageWindow); + let usageDelta = usageWindow ? emptyCodexUsageDelta(usageWindow) : undefined; let parserState = contribution.parserState; const aggregate = contribution.aggregate; const lineage = contribution.lineage ?? createLineageTrace(); @@ -876,6 +1100,13 @@ async function updateContribution( lineage.appliedPrefixEvents === desiredPrefixEvents && isCompatibleCodexPeriodLineage(aggregate.period, desiredPrefixEvents) && codexPeriodFitsAggregate(aggregate.period, aggregate.total); + const activeHourProof = contribution.hourProof?.version === CODEX_UTC_HOUR_PROOF_VERSION && + contribution.hourProof.indexedThrough === contribution.offset && + contribution.hourProof.prefixEvents === desiredPrefixEvents && + lineage.appliedPrefixEvents === desiredPrefixEvents && + !contribution.lineageReconciliation + ? contribution.hourProof + : undefined; if (aggregate.today && !reanchorRollingToday(aggregate.today, asOfDay, timeZone)) { delete aggregate.today; } @@ -889,6 +1120,7 @@ async function updateContribution( ? aggregate.today : undefined; let invalidEventTimestamp = false; + let activeHourProofOverflow = false; const snapshot = (cursor: CodexJsonlCursor): CodexFileContribution => { syncSession(aggregate, parserState); @@ -896,6 +1128,7 @@ async function updateContribution( activeToday.indexedThrough = cursor.offset; syncTodayProjection(activeToday); } + if (activeHourProof) activeHourProof.indexedThrough = cursor.offset; return { ...contribution, fileKey: entry.fileKey, @@ -904,11 +1137,29 @@ async function updateContribution( mtimeMs: entry.mtimeMs, dev: entry.dev, ino: entry.ino, + ...compressedSourceMetadata(entry), + ...(entry.compression === 'zstd' ? { compressionVerified: undefined } : {}), offset: cursor.offset, discardingOversizedLine: cursor.discardingOversizedLine, parserState, lineage, aggregate, + ...(activeHourProof && !activeHourProofOverflow ? { + hourProof: { ...activeHourProof, hours: [...activeHourProof.hours] }, + } : {}), + ...(activeHourProofOverflow ? { + hourProof: undefined, + hourProofMigration: { + version: CODEX_UTC_HOUR_PROOF_VERSION, + prefixEvents: desiredPrefixEvents, + tokenEventsSeen: 0, + offset: cursor.offset, + discardingOversizedLine: cursor.discardingOversizedLine, + parserState, + hours: [], + overflow: true, + }, + } : {}), limit, limits, ...(quotaHistory.length > 0 ? { quotaHistory } : {}), @@ -937,7 +1188,14 @@ async function updateContribution( addLineageToken(lineage, parsed.lineageTokenKey); } for (const event of parsed.events) { + if (usageDelta && !addCodexOriginEvent(usageDelta, event)) usageDelta = undefined; reduceUsage(aggregate, event); + if (activeHourProof) { + const added = addCodexOutputHourInPlace( + activeHourProof.hours, event.timestamp, event.tokens.outputTotal, + ); + if (!added) activeHourProofOverflow = true; + } if (!Number.isFinite(event.timestamp) || event.timestamp <= 0) { invalidEventTimestamp = true; } else if (advancePeriod && aggregate.period) { @@ -1011,11 +1269,16 @@ async function updateContribution( packPendingLineageFingerprints(lineage); } const updated = snapshot(scan.cursor); + if (entry.compression === 'zstd' && scan.cursor.offset === entry.size && !scan.cursor.discardingOversizedLine) { + updated.compressionVerified = true; + } updated.qualityFlags = uniqueFlags( updated.qualityFlags, scan.oversizedLines > 0 ? ['oversized-jsonl-line'] : [], + scan.finalLineDiscarded ? ['invalid-json'] : [], ); - return { contribution: updated, bytesRead: scan.bytesRead }; + return { contribution: updated, bytesRead: scan.bytesRead, + ...(usageDelta && usageDelta.processed > 0 ? { achievementUsageDelta: usageDelta } : {}) }; } function promoteCaughtUpLineage( @@ -1034,6 +1297,27 @@ function promoteCaughtUpLineage( } contribution.aggregate = reconciliation.aggregate; lineage.appliedPrefixEvents = reconciliation.prefixEvents; + if (reconciliation.hourProofHours && !reconciliation.hourProofOverflow) { + contribution.hourProof = { + version: CODEX_UTC_HOUR_PROOF_VERSION, + indexedThrough: contribution.offset, + prefixEvents: reconciliation.prefixEvents, + hours: reconciliation.hourProofHours, + }; + delete contribution.hourProofMigration; + } else if (reconciliation.hourProofOverflow) { + delete contribution.hourProof; + contribution.hourProofMigration = { + version: CODEX_UTC_HOUR_PROOF_VERSION, + prefixEvents: reconciliation.prefixEvents, + tokenEventsSeen: reconciliation.tokenEventsSeen, + offset: contribution.offset, + discardingOversizedLine: false, + parserState: reconciliation.parserState, + hours: [], + overflow: true, + }; + } if (contribution.aggregate.period) { contribution.aggregate.period.lineageVersion = CODEX_PERIOD_LINEAGE_VERSION; contribution.aggregate.period.lineagePrefixEvents = reconciliation.prefixEvents; @@ -1045,6 +1329,23 @@ function promoteCaughtUpLineage( delete contribution.lineageReconciliation; } +function promoteCaughtUpHourProof(contribution: CodexFileContribution): void { + const draft = contribution.hourProofMigration; + if (!draft || draft.version !== CODEX_UTC_HOUR_PROOF_VERSION || + draft.overflow || draft.discardingOversizedLine || + draft.offset < contribution.offset || contribution.lineageReconciliation || + draft.tokenEventsSeen < draft.prefixEvents || + contribution.lineage?.appliedPrefixEvents !== draft.prefixEvents || + contribution.lineage.desiredPrefixEvents !== draft.prefixEvents) return; + contribution.hourProof = { + version: CODEX_UTC_HOUR_PROOF_VERSION, + indexedThrough: contribution.offset, + prefixEvents: draft.prefixEvents, + hours: draft.hours, + }; + delete contribution.hourProofMigration; +} + async function reconcileContributionLineage( contribution: CodexFileContribution, entry: CodexRuntimeManifestEntry, @@ -1080,11 +1381,14 @@ async function reconcileContributionLineage( today: emptyTodayIndex(asOfDay, timeZone), }, qualityFlags: [], + hourProofHours: [], }; let parserState = initial.parserState; let tokenEventsSeen = initial.tokenEventsSeen; const aggregate = initial.aggregate; let invalidEventTimestamp = false; + let hourProofHours = initial.hourProofHours ? [...initial.hourProofHours] : undefined; + let hourProofOverflow = initial.hourProofOverflow === true; const pseudonymize = pseudonymizer(options.salt); let quotaHistory = appendCodexQuotaHistory( contribution.quotaHistory ?? [], @@ -1118,6 +1422,8 @@ async function reconcileContributionLineage( parserState.qualityFlags, invalidEventTimestamp ? ['invalid-event-timestamp'] : [], ), + ...(hourProofHours ? { hourProofHours: hourProofOverflow ? [] : [...hourProofHours] } : {}), + ...(hourProofOverflow ? { hourProofOverflow: true } : {}), }, }; }; @@ -1150,6 +1456,12 @@ async function reconcileContributionLineage( if (includeUsage) { for (const event of parsed.events) { reduceUsage(aggregate, event); + if (hourProofHours && !hourProofOverflow) { + const added = addCodexOutputHourInPlace( + hourProofHours, event.timestamp, event.tokens.outputTotal, + ); + if (!added) hourProofOverflow = true; + } if (!Number.isFinite(event.timestamp) || event.timestamp <= 0) { invalidEventTimestamp = true; } else if (aggregate.period) { @@ -1209,12 +1521,8 @@ async function migrateContributionPeriod( ): Promise { const timeZone = resolveTimeZone(options.timeZone); const prefixEvents = contribution.lineage?.desiredPrefixEvents ?? 0; - const savedMigration = contribution.periodMigration; - const canResume = savedMigration?.timeZone === timeZone && - savedMigration.prefixEvents === prefixEvents && - Number.isFinite(savedMigration.tokenEventsSeen) && - (savedMigration.tokenEventsSeen ?? 0) >= prefixEvents; - const initial: CodexPeriodMigrationState = canResume + const savedMigration = resumablePeriodMigration(contribution, timeZone); + const initial: CodexPeriodMigrationState = savedMigration ? savedMigration : { timeZone, @@ -1242,7 +1550,11 @@ async function migrateContributionPeriod( ...(quotaHistory.length > 0 ? { quotaHistory } : {}), aggregate: { ...contribution.aggregate, - period: undefined, + // A compatible legacy slice can still serve ordinary usage while its + // stronger calendar proof is rebuilt. Only the completed body pass may + // replace it with current lineage markers. + period: isReadableCodexPeriod(contribution, timeZone) + ? contribution.aggregate.period : undefined, }, periodMigration: { timeZone, @@ -1318,23 +1630,86 @@ async function migrateContributionPeriod( ['oversized-jsonl-line'], ); } - if ( - updated.periodMigration && - updated.periodMigration.offset >= contribution.offset && - !updated.periodMigration.discardingOversizedLine - ) { - updated.aggregate.period = { - timeZone, + promoteCaughtUpPeriod(updated, timeZone); + return { contribution: updated, bytesRead: scan.bytesRead }; +} + +/** One-time historical UTC hour scan; its draft is safe to checkpoint mid-file. */ +async function migrateContributionHourProof( + contribution: CodexFileContribution, + entry: CodexRuntimeManifestEntry, + options: CodexIndexUpdateOptions, + io: CodexIndexIo, + endExclusive: number, + onChunk: ContributionChunkHandler, +): Promise { + const prefixEvents = contribution.lineage?.desiredPrefixEvents ?? 0; + const saved = contribution.hourProofMigration; + const initial: CodexHourProofMigrationState = saved?.version === CODEX_UTC_HOUR_PROOF_VERSION && + saved.prefixEvents === prefixEvents && !saved.overflow + ? saved + : { + version: CODEX_UTC_HOUR_PROOF_VERSION, + prefixEvents, + tokenEventsSeen: 0, + offset: 0, + discardingOversizedLine: false, + parserState: createCodexParserState(entry.fileKey), + hours: [], + }; + let parserState = initial.parserState; + let tokenEventsSeen = initial.tokenEventsSeen; + const hours = [...initial.hours]; + let overflow = false; + const pseudonymize = pseudonymizer(options.salt); + const snapshot = (cursor: CodexJsonlCursor): CodexFileContribution => ({ + ...contribution, + hourProof: undefined, + hourProofMigration: { + version: CODEX_UTC_HOUR_PROOF_VERSION, + prefixEvents, + tokenEventsSeen, + offset: cursor.offset, + discardingOversizedLine: cursor.discardingOversizedLine, + parserState, + hours: overflow ? [] : [...hours], + ...(overflow ? { overflow: true } : {}), + }, + }); + const scan = await scanCodexJsonlLines( + entry, + io, + { offset: initial.offset, discardingOversizedLine: initial.discardingOversizedLine }, + endExclusive, + (line) => { + if (line.trim() === '') return; + const parsed = parseCodexLine(line, parserState, pseudonymize); + parserState = parsed.state; + if (parsed.lineageTokenKey) tokenEventsSeen += 1; + if (tokenEventsSeen <= prefixEvents || overflow) return; + for (const event of parsed.events) { + if (!addCodexOutputHourInPlace(hours, event.timestamp, event.tokens.outputTotal)) { + overflow = true; + break; + } + } + }, + async (progress) => { await onChunk(snapshot(progress.cursor), progress.bytesRead); }, + ); + if (!scan.reachedEnd) throw new Error('Codex log changed during hour proof migration'); + if (scan.oversizedLines > 0) overflow = true; + const updated = snapshot(scan.cursor); + if (updated.hourProofMigration && + updated.hourProofMigration.offset >= contribution.offset && + !updated.hourProofMigration.discardingOversizedLine && + tokenEventsSeen >= prefixEvents && !overflow) { + updated.hourProof = { + version: CODEX_UTC_HOUR_PROOF_VERSION, indexedThrough: contribution.offset, - days: updated.periodMigration.days, - lineageVersion: CODEX_PERIOD_LINEAGE_VERSION, - lineagePrefixEvents: prefixEvents, + prefixEvents, + hours, }; - updated.qualityFlags = uniqueFlags( - updated.qualityFlags, - updated.periodMigration.qualityFlags, - ); - delete updated.periodMigration; + delete updated.hourProofMigration; } return { contribution: updated, bytesRead: scan.bytesRead }; } @@ -1524,6 +1899,7 @@ export async function runCodexFilePass( salt: task.salt, timeZone: task.timeZone, asOfDay: task.asOfDay, + ...(task.kind === 'main' ? { achievementUsageWindow: copyCodexUsageWindow(task.achievementUsageWindow) } : {}), }; const noChunk: ContributionChunkHandler = async () => undefined; let result: CodexFilePassResult; @@ -1587,6 +1963,7 @@ export async function runCodexFilePass( ok: true, contribution: result.contribution, bytesRead: result.bytesRead, + ...(result.achievementUsageDelta ? { achievementUsageDelta: result.achievementUsageDelta } : {}), }; } @@ -1682,7 +2059,7 @@ function reconcileLineageContributions( }; for (const fileKey of canonicalFileKeys) { const contribution = files[fileKey]; - if (!contribution?.lineage) { + if (!contribution?.lineage || (contribution.compression === 'zstd' && !contribution.compressionVerified)) { continue; } const key = contribution.aggregate.session.sessionKey; @@ -1698,7 +2075,7 @@ function reconcileLineageContributions( } for (const contribution of Object.values(files)) { - if (!contribution.lineage) { + if (!contribution.lineage || (contribution.compression === 'zstd' && !contribution.compressionVerified)) { continue; } const childTrace = traceFor(contribution); @@ -1757,35 +2134,17 @@ function reconcileLineageContributions( } } - const depthMemo = new Map(); - const depthFor = ( - contribution: CodexFileContribution, - visiting = new Set(), - ): number => { - const cached = depthMemo.get(contribution); - if (cached !== undefined) { - return cached; - } - if (visiting.has(contribution)) { - return 0; - } + const depthFor = createLineageDepthResolver((contribution) => { const parentKey = contribution.aggregate.session.parentSessionKey; if (!parentKey) { - depthMemo.set(contribution, 0); - return 0; + return null; } - const nextVisiting = new Set(visiting).add(contribution); - const parents = (parentCandidates.get(parentKey) ?? []).filter((parent) => + return (parentCandidates.get(parentKey) ?? []).filter((parent) => !contribution.parserState.treeKey || !parent.parserState.treeKey || contribution.parserState.treeKey === parent.parserState.treeKey, ); - const depth = parents.length > 0 - ? 1 + Math.min(...parents.map((parent) => depthFor(parent, nextVisiting))) - : 1; - depthMemo.set(contribution, depth); - return depth; - }; + }); for (const tree of treeCandidates.values()) { const ordered = [...tree].sort((left, right) => @@ -1869,6 +2228,10 @@ function recomputeAggregate( function mergeCodexQuotaHistory(index: CodexIndexV3): void { const additions: CodexQuotaObservation[] = []; for (const contribution of Object.values(index.files)) { + // Draft decoded bytes are provisional until the entire physical frame is + // verified. The global history outlives files, so premature promotion here + // could not be withdrawn by later terminal-frame cleanup. + if (contribution.compression === 'zstd' && !isCodexUsageContributionCurrent(contribution)) continue; additions.push(...(contribution.quotaHistory ?? [])); for (const snapshot of [ ...Object.values(contribution.limits ?? {}), @@ -1914,6 +2277,7 @@ function coverageFor( timeZone: string, asOfDay: string, deduplication = classifyCodexSessionDuplicates(files), + requireCalendarProof = false, ): CodexIndexCoverage { let indexedFiles = 0; let indexedBytes = 0; @@ -1931,6 +2295,7 @@ function coverageFor( indexedBytes += parsedBytes; if ( !resetIsStale && + isCodexUsageContributionCurrent(contribution) && !flags.has('stale-file') && contribution.offset >= entry.size && !contribution.discardingOversizedLine && @@ -1955,6 +2320,7 @@ function coverageFor( const flags = new Set(contribution?.qualityFlags ?? []); const mainVerified = Boolean( contribution && + isCodexUsageContributionCurrent(contribution) && contribution.offset === entry.size && !contribution.discardingOversizedLine && contribution.lineage?.appliedPrefixEvents === @@ -1977,7 +2343,12 @@ function coverageFor( } rangeTotalFiles += 1; rangeTotalBytes += entry.size; - const periodIsCurrent = isReadableCodexPeriod(contribution, timeZone); + // Recent display ranges retain legacy compatibility. Calendar evidence + // requires proof across every canonical file, including older history. + const periodIsCurrent = isReadableCodexPeriod(contribution, timeZone) && + (!requireCalendarProof || oldestDay !== undefined || + isCurrentCodexPeriodLineage(contribution?.aggregate.period, + contribution?.lineage?.desiredPrefixEvents ?? 0)); const periodMigration = contribution?.periodMigration; // A migration draft is intentionally not a readable period yet, but // its cursor still represents verified work for the progress display. @@ -2170,6 +2541,7 @@ function recomputeDerivedIndex( timeZone: string, asOfDay: string, reconcileLineage = true, + requireCalendarProof = false, ): void { const initialDeduplication = classifyCodexSessionDuplicates(index.files); if (reconcileLineage) { @@ -2187,6 +2559,7 @@ function recomputeDerivedIndex( timeZone, asOfDay, deduplication, + requireCalendarProof, ); } @@ -2207,6 +2580,8 @@ function isWarmNoOp( diff: ReturnType, timeZone: string, asOfDay: string, + allowHistoricalBackfill: boolean, + allowAchievementHourProof: boolean, ): boolean { // Older schema-3 indexes have a current `limit` projection but no neutral // reset history. Give the next refresh one chance to seed the bounded cache @@ -2215,27 +2590,34 @@ function isWarmNoOp( return false; } const totalBytes = manifest.files.reduce((sum, entry) => sum + entry.size, 0); + const terminalEntries = manifest.files.filter(entry => hasCurrentCompressionFailure(previous.files[entry.fileKey], entry)); + const terminalCount = terminalEntries.length; + const availableBytes = totalBytes - terminalEntries.reduce((sum, entry) => sum + entry.size, 0); const hourlyCoverage = previous.coverage.hourly; if ( + (allowHistoricalBackfill && allowAchievementHourProof && + hourProofWorkEntries(previous, manifest).length > 0) || diff.appended.length > 0 || diff.truncated.length > 0 || diff.replaced.length > 0 || diff.moved.length > 0 || diff.added.length > 0 || diff.removed.length > 0 || - !previous.coverage.complete || + (!terminalCount && !previous.coverage.complete) || previous.coverage.totalFiles !== manifest.files.length || - previous.coverage.indexedFiles !== manifest.files.length || + previous.coverage.indexedFiles + terminalCount !== manifest.files.length || previous.coverage.totalBytes !== totalBytes || - previous.coverage.indexedBytes !== totalBytes || - !previous.coverage.period.allTime.complete || + previous.coverage.indexedBytes !== availableBytes || + (terminalCount ? previous.coverage.period.allTime.migratedFiles + terminalCount !== previous.coverage.period.allTime.totalFiles + : !previous.coverage.period.allTime.complete) || previous.coverage.period.timeZone !== timeZone || previous.coverage.period.asOfDay !== asOfDay || - !hourlyCoverage?.complete || + !hourlyCoverage || (terminalCount ? hourlyCoverage.indexedFiles !== hourlyCoverage.totalFiles : !hourlyCoverage.complete) || hourlyCoverage.timeZone !== timeZone || hourlyCoverage.asOfDay !== asOfDay || hourlyCoverage.windowDays !== CODEX_ROLLING_HOURLY_DAYS || - !previous.coverage.today?.complete || + !previous.coverage.today || (terminalCount ? previous.coverage.today.indexedFiles !== previous.coverage.today.totalFiles + : !previous.coverage.today.complete) || previous.coverage.today.timeZone !== timeZone || previous.coverage.today.day !== asOfDay ) { @@ -2246,6 +2628,7 @@ function isWarmNoOp( const rollingDays = rollingHourlyDaySet(asOfDay); return manifest.files.every((entry) => { const contribution = previous.files[entry.fileKey]; + if (hasCurrentCompressionFailure(contribution, entry)) return true; const baseIsCurrent = Boolean( contribution && contribution.offset === entry.size && @@ -2257,8 +2640,11 @@ function isWarmNoOp( contribution.aggregate.period?.timeZone === timeZone && contribution.aggregate.period.indexedThrough >= contribution.offset && isReadableCodexPeriod(contribution, timeZone) && + (!allowAchievementHourProof || !canonical.has(entry.fileKey) || isCurrentCodexPeriodLineage( + contribution.aggregate.period, contribution.lineage?.desiredPrefixEvents ?? 0, + )) && !contribution.qualityFlags.includes('stale-file') && - !contribution.qualityFlags.includes('stale-reset-required'), + isCodexUsageContributionCurrent(contribution), ); if (!baseIsCurrent || !contribution) { return false; @@ -2294,11 +2680,17 @@ export async function updateCodexIndex( const io = options.io ?? defaultIo(); const timeZone = resolveTimeZone(options.timeZone); const refreshInstant = (options.now ?? Date.now)(); + const requireCalendarProof = options.allowAchievementHourProof !== false; + const requestedUsageWindow = copyCodexUsageWindow(options.achievementUsageWindow); + const usageWindow = requestedUsageWindow && requestedUsageWindow.through <= refreshInstant && + previous.coverage.period.timeZone === timeZone && codexOriginBaselineReady(previous) + ? requestedUsageWindow : undefined; const asOfDay = dayKeyInZone(new Date(refreshInstant), timeZone); const normalizedOptions: CodexIndexUpdateOptions = { ...options, timeZone, asOfDay, + achievementUsageWindow: undefined, }; const scheduling: CodexIndexSchedulingPolicy = { ...DEFAULT_CODEX_INDEX_SCHEDULING, @@ -2326,12 +2718,20 @@ export async function updateCodexIndex( ), }; const diff = diffCodexManifest(previousManifest(previous), manifest.persistable); - if (isWarmNoOp(previous, manifest, diff, timeZone, asOfDay)) { + const terminalCount = manifest.files.filter(entry => hasCurrentCompressionFailure(previous.files[entry.fileKey], entry)).length; + const originAppends = new Set(usageWindow ? diff.appended.filter(key => { + const prior = previous.files[key], entry = manifest.persistable[key]; + return prior && entry && !prior.compression && !entry.compression && prior.dev! > 0 && prior.ino! > 0 && + prior.dev === entry.dev && prior.ino === entry.ino; + }) : []); + const originCandidates = new Map(); + if (isWarmNoOp(previous, manifest, diff, timeZone, asOfDay, + options.allowHistoricalBackfill !== false, options.allowAchievementHourProof !== false)) { return { index: previous, indexChanged: false, bodyReads: 0, - failedFiles: 0, + failedFiles: terminalCount, migration: { filePasses: 0, bytesRead: 0, @@ -2348,7 +2748,7 @@ export async function updateCodexIndex( } const entries = new Map(manifest.files.map((entry) => [entry.fileKey, entry])); let bodyReads = 0; - let failedFiles = 0; + let failedFiles = terminalCount; let filePasses = 0; let bytesRead = 0; let cancellationCheckpointed = false; @@ -2363,6 +2763,12 @@ export async function updateCodexIndex( let lastCheckpointAt = scheduling.now(); let lastCheckpointBytes = 0; let reconcileLineageForCheckpoint = true; + const recordTerminalFailure = (entry: CodexRuntimeManifestEntry, value: unknown): boolean => { + const code = terminalCompressionCode(copyFilePassFailureCode(value)); + if (entry.compression !== 'zstd' || !code || !validCompressionFingerprint({ ...entry })) return false; + index.files[entry.fileKey] = compressionFailureContribution(entry, code); + return true; + }; for (const move of diff.moved) { const contribution = index.files[move.fromKey]; const entry = entries.get(move.toKey); @@ -2386,6 +2792,9 @@ export async function updateCodexIndex( if (contribution.todayMigration) { contribution.todayMigration.parserState.fileKey = move.toKey; } + if (contribution.hourProofMigration) { + contribution.hourProofMigration.parserState.fileKey = move.toKey; + } index.files[move.toKey] = contribution; } for (const key of diff.removed) { @@ -2393,6 +2802,7 @@ export async function updateCodexIndex( } for (const contribution of Object.values(index.files)) { promoteCaughtUpLineage(contribution); + promoteCaughtUpHourProof(contribution); const desiredPrefixEvents = contribution.lineage?.desiredPrefixEvents ?? 0; if (contribution.aggregate.period && ( !isCompatibleCodexPeriodLineage( @@ -2407,15 +2817,7 @@ export async function updateCodexIndex( )) { delete contribution.aggregate.period; } - if ( - contribution.periodMigration && - ( - contribution.periodMigration.timeZone !== timeZone || - contribution.periodMigration.prefixEvents !== desiredPrefixEvents || - !Number.isFinite(contribution.periodMigration.tokenEventsSeen) || - (contribution.periodMigration.tokenEventsSeen ?? 0) < desiredPrefixEvents - ) - ) { + if (contribution.periodMigration && !resumablePeriodMigration(contribution, timeZone)) { delete contribution.periodMigration; } promoteCaughtUpPeriod(contribution, timeZone); @@ -2463,6 +2865,14 @@ export async function updateCodexIndex( for (const key of diff.replaced) { resetFlags.set(key, 'replaced-jsonl'); } + // A known encoding/version replacement withdraws old authority immediately, + // including cancellation before its first decoded chunk or checkpoint. + for (const key of diff.replaced) { + const prior = index.files[key], entry = entries.get(key); + if (prior) delete prior.compressionFailure; + if (prior && (prior.compression || entry?.compression)) prior.qualityFlags = uniqueFlags( + prior.qualityFlags, ['stale-reset-required']); + } const changedKeys = new Set([ ...diff.added, ...diff.appended, @@ -2471,12 +2881,14 @@ export async function updateCodexIndex( ]); for (const entry of manifest.files) { const contribution = index.files[entry.fileKey]; + if (hasCurrentCompressionFailure(contribution, entry)) continue; if ( contribution && (contribution.offset < entry.size || contribution.discardingOversizedLine || contribution.qualityFlags.includes('stale-file') || - contribution.qualityFlags.includes('stale-reset-required')) + contribution.qualityFlags.includes('stale-reset-required') || + compressedDraftNeedsRestart(contribution)) ) { changedKeys.add(entry.fileKey); } @@ -2502,7 +2914,7 @@ export async function updateCodexIndex( if (!due) { return false; } - recomputeDerivedIndex(index, manifest, timeZone, asOfDay, false); + recomputeDerivedIndex(index, manifest, timeZone, asOfDay, false, requireCalendarProof); options.onProgress(progressFor(index, index.coverage.indexedFiles)); lastProgressAt = current; lastProgressBytes = bytesRead; @@ -2530,6 +2942,7 @@ export async function updateCodexIndex( timeZone, asOfDay, reconcileLineageForCheckpoint, + requireCalendarProof, ); options.onProgress?.(progressFor(index, index.coverage.indexedFiles)); lastProgressAt = current; @@ -2560,6 +2973,7 @@ export async function updateCodexIndex( timeZone, asOfDay, reconcileLineageForCheckpoint, + requireCalendarProof, ); publishProgress(true); return; @@ -2571,6 +2985,7 @@ export async function updateCodexIndex( timeZone, asOfDay, reconcileLineageForCheckpoint, + requireCalendarProof, ); } }; @@ -2633,12 +3048,16 @@ export async function updateCodexIndex( if (!task) { throw new Error('Codex file worker returned an unknown task'); } + byId.delete(outcome.taskId); if (outcome.ok) { bytesRead += outcome.bytesRead; index.files[task.entry.fileKey] = outcome.contribution; + const proof = task.kind === 'main' && originAppends.has(task.entry.fileKey) + ? copyCodexUsageDelta(outcome.achievementUsageDelta, usageWindow) : undefined; + if (proof) originCandidates.set(task.entry.fileKey, proof); } else { failedFiles += 1; - onFailure(task); + if (!recordTerminalFailure(task.entry, outcome.failureCode)) onFailure(task); } dirtySinceCheckpoint = true; filePassesSinceCheckpoint += 1; @@ -2682,7 +3101,7 @@ export async function updateCodexIndex( } const resetFlag = resetFlags.get(entry.fileKey); const prior = index.files[entry.fileKey]; - const base = resetFlag || !prior || + const base = resetFlag || !prior || compressedDraftNeedsRestart(prior) || prior.qualityFlags.includes('stale-reset-required') ? contributionFor(entry, timeZone, resetFlag ? [resetFlag] : []) : { @@ -2692,14 +3111,15 @@ export async function updateCodexIndex( ), }; const remainingBytes = budget.maxBytes - bytesRead - reservedBytes; - const endExclusive = Math.min(entry.size, base.offset + remainingBytes); - if (endExclusive <= base.offset) { + const endExclusive = compressedMainPassEnd(entry, base.offset, remainingBytes, filePasses + tasks.length === 0 && bytesRead === 0); + if (endExclusive <= base.offset && !(entry.compression === 'zstd' && entry.size === 0)) { break; } const taskId = `main:${entry.fileKey}`; tasks.push({ taskId, kind: 'main', + ...(originAppends.has(entry.fileKey) ? { achievementUsageWindow: usageWindow } : {}), contribution: base, entry, salt: normalizedOptions.salt, @@ -2709,7 +3129,7 @@ export async function updateCodexIndex( }); priorByTask.set(taskId, prior); resetFlagByTask.set(taskId, resetFlag); - reservedBytes += endExclusive - base.offset; + reservedBytes += entry.compression === 'zstd' ? endExclusive : endExclusive - base.offset; } await runParallelTasks(tasks, (task) => { const prior = priorByTask.get(task.taskId); @@ -2734,7 +3154,7 @@ export async function updateCodexIndex( await cancelBeforePass(); const resetFlag = resetFlags.get(entry.fileKey); const prior = index.files[entry.fileKey]; - const base = resetFlag || !prior || + const base = resetFlag || !prior || compressedDraftNeedsRestart(prior) || prior.qualityFlags.includes('stale-reset-required') ? contributionFor(entry, timeZone, resetFlag ? [resetFlag] : []) : { @@ -2744,8 +3164,8 @@ export async function updateCodexIndex( ), }; const remainingBytes = budget.maxBytes - bytesRead; - const endExclusive = Math.min(entry.size, base.offset + remainingBytes); - if (endExclusive <= base.offset) { + const endExclusive = compressedMainPassEnd(entry, base.offset, remainingBytes, filePasses === 0 && bytesRead === 0); + if (endExclusive <= base.offset && !(entry.compression === 'zstd' && entry.size === 0)) { break; } bodyReads += 1; @@ -2755,19 +3175,25 @@ export async function updateCodexIndex( const pass = await updateContribution( base, entry, - normalizedOptions, + { ...normalizedOptions, achievementUsageWindow: originAppends.has(entry.fileKey) ? usageWindow : undefined }, io, endExclusive, onChunk(entry, passStartingBytes), ); bytesRead = passStartingBytes + pass.bytesRead; index.files[entry.fileKey] = pass.contribution; + const proof = copyCodexUsageDelta(pass.achievementUsageDelta, usageWindow); + if (proof) originCandidates.set(entry.fileKey, proof); } catch (error) { if (error instanceof CodexIndexCancelledError) { throw error; } failedFiles += 1; - if (prior) { + if (recordTerminalFailure(entry, codexFilePassFailureCode(error))) { + // Provisional chunks may already have been checkpointed; replace them + // with the safe failure record before the next durable stage boundary. + } else if (!prior && entry.compression === 'zstd') delete index.files[entry.fileKey]; + else if (prior) { index.files[entry.fileKey] = { ...prior, qualityFlags: uniqueFlags( @@ -2787,6 +3213,7 @@ export async function updateCodexIndex( } await finishStage(filePasses > mainPassStart, true); + const mainPassEnd = filePasses; const allowHistoricalBackfill = options.allowHistoricalBackfill !== false; const lineageWork = (allowHistoricalBackfill ? manifest.files : []) .filter((entry) => { @@ -2898,7 +3325,7 @@ export async function updateCodexIndex( throw error; } failedFiles += 1; - index.files[entry.fileKey] = prior; + if (!recordTerminalFailure(entry, codexFilePassFailureCode(error))) index.files[entry.fileKey] = prior; } dirtySinceCheckpoint = true; filePassesSinceCheckpoint += 1; @@ -2925,6 +3352,10 @@ export async function updateCodexIndex( contribution.offset >= entry.size && !contribution.discardingOversizedLine && ( + (requireCalendarProof && !isCurrentCodexPeriodLineage( + contribution.aggregate.period, + contribution.lineage?.desiredPrefixEvents ?? 0, + )) || !isCompatibleCodexPeriodLineage( contribution.aggregate.period, contribution.lineage?.desiredPrefixEvents ?? 0, @@ -2958,14 +3389,7 @@ export async function updateCodexIndex( continue; } const base = cloneContribution(prior); - const start = base.periodMigration?.timeZone === timeZone && - base.periodMigration.prefixEvents === - (base.lineage?.desiredPrefixEvents ?? 0) && - Number.isFinite(base.periodMigration.tokenEventsSeen) && - (base.periodMigration.tokenEventsSeen ?? 0) >= - (base.lineage?.desiredPrefixEvents ?? 0) - ? base.periodMigration.offset - : 0; + const start = resumablePeriodMigration(base, timeZone)?.offset ?? 0; const endExclusive = Math.min( base.offset, start + (budget.maxBytes - bytesRead - reservedBytes), @@ -3003,14 +3427,7 @@ export async function updateCodexIndex( continue; } const base = cloneContribution(prior); - const start = base.periodMigration?.timeZone === timeZone && - base.periodMigration.prefixEvents === - (base.lineage?.desiredPrefixEvents ?? 0) && - Number.isFinite(base.periodMigration.tokenEventsSeen) && - (base.periodMigration.tokenEventsSeen ?? 0) >= - (base.lineage?.desiredPrefixEvents ?? 0) - ? base.periodMigration.offset - : 0; + const start = resumablePeriodMigration(base, timeZone)?.offset ?? 0; const endExclusive = Math.min( base.offset, start + (budget.maxBytes - bytesRead), @@ -3037,7 +3454,7 @@ export async function updateCodexIndex( throw error; } failedFiles += 1; - index.files[entry.fileKey] = prior; + if (!recordTerminalFailure(entry, codexFilePassFailureCode(error))) index.files[entry.fileKey] = prior; } dirtySinceCheckpoint = true; filePassesSinceCheckpoint += 1; @@ -3183,7 +3600,7 @@ export async function updateCodexIndex( throw error; } failedFiles += 1; - index.files[entry.fileKey] = prior; + if (!recordTerminalFailure(entry, codexFilePassFailureCode(error))) index.files[entry.fileKey] = prior; } dirtySinceCheckpoint = true; filePassesSinceCheckpoint += 1; @@ -3201,7 +3618,8 @@ export async function updateCodexIndex( ? manifest.files.sort(recentFirst) : []; const pendingIdentity = identityWork.filter( - (entry) => index.files[entry.fileKey]?.identityChecked !== true, + (entry) => !hasCurrentCompressionFailure(index.files[entry.fileKey], entry) && + index.files[entry.fileKey]?.identityChecked !== true, ); if (options.filePassBatch && pendingIdentity.length > 1) { await cancelBeforePass(); @@ -3232,7 +3650,7 @@ export async function updateCodexIndex( reservedBytes += identityBytes; } await runParallelTasks(tasks, () => undefined); - } else for (const entry of identityWork) { + } else for (const entry of pendingIdentity) { if (!hasBudget()) { break; } @@ -3270,6 +3688,7 @@ export async function updateCodexIndex( throw error; } failedFiles += 1; + recordTerminalFailure(entry, codexFilePassFailureCode(error)); } dirtySinceCheckpoint = true; filePassesSinceCheckpoint += 1; @@ -3279,12 +3698,63 @@ export async function updateCodexIndex( } await finishStage(filePasses > identityPassStart); + // A migrated index has no older hour positions in its period projection. + // Run this only under the historical-work allowance and only after lineage + // and duplicate classification have settled. Each pass is streaming and + // checkpointed at the same safe JSONL cursor as the other migrations. + recomputeDerivedIndex(index, manifest, timeZone, asOfDay, true, requireCalendarProof); + const hourProofStart = filePasses; + // Routine appended tails should not starve older hour proofs. Keep only one + // historical migration stage per refresh to protect the bounded I/O budget. + if (allowHistoricalBackfill && options.allowAchievementHourProof !== false && + filePasses === mainPassEnd && hasBudget()) { + const hourWork = hourProofWorkEntries(index, manifest).sort(recentFirst); + for (const entry of hourWork) { + if (!hasBudget()) break; + await cancelBeforePass(); + const prior = index.files[entry.fileKey]; + const base = cloneContribution(prior); + const draft = base.hourProofMigration; + const start = draft?.version === CODEX_UTC_HOUR_PROOF_VERSION && + draft.prefixEvents === (base.lineage?.desiredPrefixEvents ?? 0) && + !draft.overflow + ? draft.offset : 0; + const endExclusive = Math.min(base.offset, start + budget.maxBytes - bytesRead); + if (endExclusive <= start) break; + bodyReads += 1; + filePasses += 1; + const passStartingBytes = bytesRead; + try { + const pass = await migrateContributionHourProof( + base, entry, normalizedOptions, io, endExclusive, + onChunk(entry, passStartingBytes), + ); + bytesRead = passStartingBytes + pass.bytesRead; + index.files[entry.fileKey] = pass.contribution; + } catch (error) { + if (error instanceof CodexIndexCancelledError) throw error; + failedFiles += 1; + if (!recordTerminalFailure(entry, codexFilePassFailureCode(error))) index.files[entry.fileKey] = prior; + } + dirtySinceCheckpoint = true; + filePassesSinceCheckpoint += 1; + if (!(await checkpoint(false))) publishProgress(false); + await new Promise((resolve) => setImmediate(resolve)); + } + } + await finishStage(filePasses > hourProofStart); if (filePasses === 0) { options.onProgress?.(progressFor(index, manifest.files.length)); } else { publishProgress(true); } + const historicalHourProof = codexHourProofSnapshot(index); + const achievementUsageDelta = finalizeCodexUsageDelta(previous, index, originCandidates, usageWindow); + const terminalSettled = manifest.files.some(entry => hasCurrentCompressionFailure(index.files[entry.fileKey], entry)) && + isWarmNoOp(index, manifest, diffCodexManifest(previousManifest(index), manifest.persistable), + timeZone, asOfDay, allowHistoricalBackfill, options.allowAchievementHourProof !== false); return { + ...(achievementUsageDelta ? { achievementUsageDelta } : {}), index, indexChanged: true, bodyReads, @@ -3293,9 +3763,11 @@ export async function updateCodexIndex( filePasses, bytesRead, pending: - !index.coverage.complete || + !terminalSettled && (!index.coverage.complete || !index.coverage.period.allTime.complete || - !index.coverage.hourly?.complete, + !index.coverage.hourly?.complete || + (options.allowAchievementHourProof !== false && !historicalHourProof.complete && + (historicalHourProof.backfillableFiles ?? 0) > 0)), }, }; } @@ -3785,6 +4257,59 @@ function sanitizePeriodMigration( }; } +// Keep the persisted DTO sanitizer local: repository policy audits every field +// reachable from sanitizeIndexV2 rather than trusting an external return value. +function sanitizeCodexHourProof(value: unknown): CodexHourProof | undefined { + if (!isRecord(value) || value.version !== CODEX_UTC_HOUR_PROOF_VERSION || + !Number.isSafeInteger(value.indexedThrough) || (value.indexedThrough as number) < 0 || + !Number.isSafeInteger(value.prefixEvents) || (value.prefixEvents as number) < 0 || + !Array.isArray(value.hours) || value.hours.length > MAX_CODEX_HOURS_PER_FILE) { + return undefined; + } + const hours: number[] = []; + for (const hour of value.hours) { + if (!Number.isSafeInteger(hour) || hour <= 0 || + (hours.length > 0 && hour <= hours[hours.length - 1])) return undefined; + hours.push(hour); + } + return { + version: CODEX_UTC_HOUR_PROOF_VERSION, + indexedThrough: value.indexedThrough as number, + prefixEvents: value.prefixEvents as number, + hours, + }; +} + +function sanitizeHourProofMigration( + value: unknown, + fileKey: string, +): CodexHourProofMigrationState | undefined { + if (!isRecord(value) || value.version !== CODEX_UTC_HOUR_PROOF_VERSION || + !Number.isSafeInteger(value.offset) || (value.offset as number) < 0 || + !Number.isSafeInteger(value.prefixEvents) || (value.prefixEvents as number) < 0 || + !Number.isSafeInteger(value.tokenEventsSeen) || + (value.tokenEventsSeen as number) < 0 || + !isRecord(value.parserState) || value.parserState.schemaVersion !== 4 || + value.parserState.fileKey !== fileKey) return undefined; + const proof = sanitizeCodexHourProof({ + version: value.version, + indexedThrough: value.offset, + prefixEvents: value.prefixEvents, + hours: value.hours, + }); + if (!proof) return undefined; + return { + version: CODEX_UTC_HOUR_PROOF_VERSION, + offset: proof.indexedThrough, + prefixEvents: proof.prefixEvents, + tokenEventsSeen: value.tokenEventsSeen as number, + discardingOversizedLine: value.discardingOversizedLine === true, + parserState: sanitizeParserState(value.parserState, fileKey), + hours: proof.hours, + ...(value.overflow === true ? { overflow: true } : {}), + }; +} + function sanitizeTodayMigration( value: unknown, fileKey: string, @@ -3950,6 +4475,12 @@ function sanitizeLineageReconciliation( return undefined; } const parserState = sanitizeParserState(value.parserState, fileKey); + const hourProof = sanitizeCodexHourProof({ + version: CODEX_UTC_HOUR_PROOF_VERSION, + indexedThrough: value.offset, + prefixEvents: value.prefixEvents, + hours: value.hourProofHours, + }); return { prefixEvents: Math.max(0, Math.floor(finiteNumber(value.prefixEvents))), tokenEventsSeen: Math.max( @@ -3961,6 +4492,8 @@ function sanitizeLineageReconciliation( parserState, aggregate: sanitizeFileAggregate(value.aggregate, parserState), qualityFlags: sanitizeQualityFlags(value.qualityFlags), + ...(hourProof ? { hourProofHours: hourProof.hours } : {}), + ...(value.hourProofOverflow === true ? { hourProofOverflow: true } : {}), }; } @@ -4177,6 +4710,18 @@ function sanitizeFileContribution( ): CodexFileContribution { const contribution = isRecord(value) ? value : {}; const fileKey = key; + const failure = isRecord(contribution.compressionFailure) ? contribution.compressionFailure : undefined; + const failureCode = failure?.version === 1 ? terminalCompressionCode(failure.code) : undefined; + if (failureCode && validCompressionFingerprint(contribution)) { + return compressionFailureContribution({ + fileKey, + sourceArea: contribution.sourceArea === 'sessions' || contribution.sourceArea === 'archive' + ? contribution.sourceArea : undefined, + size: contribution.size as number, mtimeMs: contribution.mtimeMs as number, + storageSize: contribution.storageSize as number, storageCtimeMs: contribution.storageCtimeMs as number, + dev: optionalNumber(contribution.dev), ino: optionalNumber(contribution.ino), + }, failureCode); + } const parserState = sanitizeParserState(contribution.parserState, fileKey); const safeOffset = offsetOverride ?? Math.max(0, finiteNumber(contribution.offset)); const limit = sanitizeLimit(contribution.limit); @@ -4229,6 +4774,12 @@ function sanitizeFileContribution( contribution.todayMigration, fileKey, ); + const rawHourProof = sanitizeCodexHourProof(contribution.hourProof); + const hourProof = rawHourProof && rawHourProof.indexedThrough <= safeOffset + ? rawHourProof : undefined; + const rawHourDraft = sanitizeHourProofMigration(contribution.hourProofMigration, fileKey); + const hourProofMigration = rawHourDraft && rawHourDraft.offset <= safeOffset + ? rawHourDraft : undefined; if (todayMigration) { todayMigration.offset = Math.min(todayMigration.offset, safeOffset); todayMigration.prefixEvents = Math.min( @@ -4260,6 +4811,16 @@ function sanitizeFileContribution( ...(optionalNumber(contribution.ino) !== undefined ? { ino: optionalNumber(contribution.ino) } : {}), + ...(contribution.compression === 'zstd' ? { + compression: 'zstd' as const, + storageSize: finiteNumber(contribution.storageSize), + storageCtimeMs: finiteNumber(contribution.storageCtimeMs), + ...(contribution.compressionVerified === true && contribution.compressionFailure === undefined && + typeof contribution.storageSize === 'number' && + Number.isSafeInteger(contribution.storageSize) && contribution.storageSize > 0 && + typeof contribution.storageCtimeMs === 'number' && Number.isFinite(contribution.storageCtimeMs) && + contribution.storageCtimeMs > 0 ? { compressionVerified: true as const } : {}), + } : {}), offset: safeOffset, discardingOversizedLine: discardingOverride ?? contribution.discardingOversizedLine === true, @@ -4270,12 +4831,15 @@ function sanitizeFileContribution( ...(limit ? { limit } : {}), ...(limits ? { limits } : {}), ...(quotaHistory.length > 0 ? { quotaHistory } : {}), - qualityFlags: sanitizeQualityFlags(contribution.qualityFlags), + qualityFlags: uniqueFlags(sanitizeQualityFlags(contribution.qualityFlags), + contribution.compression !== undefined && contribution.compression !== 'zstd' ? ['stale-reset-required'] : []), ...(typeof contribution.identityChecked === 'boolean' ? { identityChecked: contribution.identityChecked } : {}), ...(periodMigration ? { periodMigration } : {}), ...(todayMigration ? { todayMigration } : {}), + ...(hourProof ? { hourProof } : {}), + ...(hourProofMigration ? { hourProofMigration } : {}), }; } diff --git a/src/providers/codex/codexIndexClient.ts b/src/providers/codex/codexIndexClient.ts index bded606b..5d803c37 100644 --- a/src/providers/codex/codexIndexClient.ts +++ b/src/providers/codex/codexIndexClient.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { Worker } from 'node:worker_threads'; import { CodexIndexProgress } from './codexIndex'; +import { CodexAchievementUsageWindow, copyCodexUsageWindow, copyCodexUsageDelta } from './codexAchievementOrigin'; import { CodexWorkerMessage, CodexWorkerRefreshInput, @@ -27,6 +28,7 @@ export class CodexWorkerError extends Error { } interface ActiveRefresh { + achievementUsageWindow?: CodexAchievementUsageWindow; requestId: string; promise: Promise; resolve: (result: CodexWorkerResult) => void; @@ -59,6 +61,7 @@ export class CodexIndexClient { input: CodexWorkerRefreshInput, onProgress?: (progress: CodexIndexProgress) => void, ): Promise { + input = { ...input, achievementUsageWindow: copyCodexUsageWindow(input.achievementUsageWindow) }; if (this.disposed) { return Promise.reject( new CodexWorkerError('disposed', 'Codex index client is disposed'), @@ -89,6 +92,7 @@ export class CodexIndexClient { reject = onReject; }); this.active = { + achievementUsageWindow: copyCodexUsageWindow(input.achievementUsageWindow), requestId, promise, resolve, @@ -189,7 +193,10 @@ export class CodexIndexClient { .then(() => { if (this.active === active) this.active = null; if (terminal.type === 'result') { - active.resolve(terminal.result); + const { achievementUsageDelta: rawDelta, ...committed } = terminal.result; + const achievementUsageDelta = !active.cancelSent + ? copyCodexUsageDelta(rawDelta, active.achievementUsageWindow) : undefined; + active.resolve({ ...committed, ...(achievementUsageDelta ? { achievementUsageDelta } : {}) }); } else { active.reject(new CodexWorkerError(terminal.error.code, terminal.error.message)); } diff --git a/src/providers/codex/codexIndexWorker.ts b/src/providers/codex/codexIndexWorker.ts index 1c3641f0..7718642c 100644 --- a/src/providers/codex/codexIndexWorker.ts +++ b/src/providers/codex/codexIndexWorker.ts @@ -26,6 +26,8 @@ import { } from './codexIndexLease'; import { CodexFilePassPool } from './codexFilePassPool'; import { adoptLegacyCodexIndex } from './codexIndexStorage'; +import { copyCodexUsageWindow, copyCodexUsageDelta } from './codexAchievementOrigin'; +import { supportsCodexZstd, CodexZstdError } from './codexZstd'; const cancelled = new Set(); let activeRequestId: string | null = null; @@ -49,6 +51,10 @@ function safeError( requestId: string, error: unknown, ): Extract { + if (error instanceof CodexZstdError && error.code === 'codec-unavailable') return { + type: 'error', requestId, error: { code: 'zstd-unsupported', + message: 'Compressed Codex logs require a newer VS Code runtime' }, + }; if ( error instanceof CodexIndexCancelledError || error instanceof CodexIndexLeaseCancelledError @@ -87,7 +93,7 @@ export interface CodexWorkerRefreshRuntime { scanCodexManifest: typeof scanCodexManifest; updateCodexIndex: typeof updateCodexIndex; saveCodexIndexAtomic: typeof saveCodexIndexAtomic; - createCodexFilePassPool?: () => Pick; + createCodexFilePassPool?: (workerLimit?: number) => Pick; now?: () => number; adoptLegacyCodexIndex?: typeof adoptLegacyCodexIndex; } @@ -96,6 +102,7 @@ export async function runCodexWorkerRefresh( request: Extract, runtime: CodexWorkerRefreshRuntime, ): Promise { + const requestedUsageWindow = copyCodexUsageWindow(request.achievementUsageWindow); const now = runtime.now ?? Date.now; try { const lease = await runtime.acquireCodexIndexLease(request.indexPath, { @@ -105,18 +112,20 @@ export async function runCodexWorkerRefresh( try { let indexRecovery: CodexIndexRecovery | undefined; const metadataStarted = now(); - const [loaded, manifest] = await Promise.all([ - runtime.loadCodexIndex(request.indexPath, request.timeZone, (event) => { - indexRecovery = event; - }), - runtime.scanCodexManifest(request.codexHome, request.salt), - ]); + const manifest = await runtime.scanCodexManifest(request.codexHome, request.salt); + // Discover, but never silently omit, compressed history on an older host. + // This fence precedes even legacy adoption or corrupt-cache quarantine. + if (manifest.files.some(file => file.compression === 'zstd') && !supportsCodexZstd()) { + throw new CodexZstdError('codec-unavailable'); + } + const loaded = await runtime.loadCodexIndex(request.indexPath, request.timeZone, event => { indexRecovery = event; }); const previous = request.legacyIndexPath && runtime.adoptLegacyCodexIndex ? await runtime.adoptLegacyCodexIndex(request.indexPath, request.legacyIndexPath, manifest, request.timeZone, runtime.isCancelled, { codexHome: request.codexHome, salt: request.salt }) ?? loaded : loaded; const metadataMs = now() - metadataStarted; + const usageWindow = !indexRecovery && previous === loaded ? requestedUsageWindow : undefined; const parseStarted = now(); const foreground = request.profile === 'foreground'; const allowHistoricalBackfill = request.allowHistoricalBackfill !== false; @@ -125,12 +134,13 @@ export async function runCodexWorkerRefresh( manifest.files.length, ); const filePassPool = expeditedBackfill - ? runtime.createCodexFilePassPool?.() + ? runtime.createCodexFilePassPool?.(manifest.files.some(file => file.compression === 'zstd') ? 2 : undefined) : undefined; let checkpointWrites = 0; let updated; try { updated = await runtime.updateCodexIndex(previous, manifest, { + achievementUsageWindow: usageWindow, salt: request.salt, timeZone: request.timeZone, now, @@ -158,6 +168,7 @@ export async function runCodexWorkerRefresh( progress, }), allowHistoricalBackfill, + allowAchievementHourProof: request.allowAchievementHourProof !== false, ...(filePassPool ? { filePassBatch: filePassPool.run } : {}), }); } finally { @@ -172,8 +183,11 @@ export async function runCodexWorkerRefresh( if (runtime.isCancelled()) { throw new CodexIndexCancelledError(); } + const { achievementUsageDelta: rawDelta, ...committed } = updated; + const achievementUsageDelta = copyCodexUsageDelta(rawDelta, usageWindow); result = { - ...updated, + ...committed, + ...(achievementUsageDelta ? { achievementUsageDelta } : {}), ...(indexRecovery ? { indexRecovery } : {}), metadataMs, parseMs: now() - parseStarted, @@ -216,7 +230,7 @@ async function runRefresh( updateCodexIndex, saveCodexIndexAtomic, adoptLegacyCodexIndex, - createCodexFilePassPool: () => new CodexFilePassPool(), + createCodexFilePassPool: workerLimit => new CodexFilePassPool(workerLimit), }); } finally { cancelled.delete(request.requestId); diff --git a/src/providers/codex/codexJsonlScanner.ts b/src/providers/codex/codexJsonlScanner.ts index b5452edd..7d89bc32 100644 --- a/src/providers/codex/codexJsonlScanner.ts +++ b/src/providers/codex/codexJsonlScanner.ts @@ -1,6 +1,8 @@ import { createReadStream } from 'node:fs'; import { CodexRuntimeManifestEntry } from './codexManifest'; +import { readCodexZstdRange } from './codexZstd'; +import { parseJsonObject } from './codexSchema'; // Cold rebuilds are dominated by JSON parsing and index bookkeeping rather // than raw disk latency. A 1 MiB stream chunk keeps cancellation responsive @@ -18,6 +20,7 @@ export interface CodexJsonlReader { entry: CodexRuntimeManifestEntry, start: number, endExclusive: number, + control?: { onReplayProgress(bytes: number): Promise }, ): AsyncIterable; } @@ -27,6 +30,7 @@ export interface CodexJsonlScanResult { reachedEnd: boolean; oversizedLines: number; finalLineAccepted: boolean; + finalLineDiscarded?: true; } export interface CodexJsonlChunkProgress { @@ -35,7 +39,15 @@ export interface CodexJsonlChunkProgress { } export const defaultCodexJsonlReader: CodexJsonlReader = { - async *read(entry, start, endExclusive) { + async *read(entry, start, endExclusive, control) { + if (entry.compression === 'zstd') { + yield* readCodexZstdRange(entry.absolutePath, start, endExclusive, entry.size, { + expectedSource: { size: entry.storageSize!, mtimeMs: entry.mtimeMs, + ctimeMs: entry.storageCtimeMs, dev: entry.dev, ino: entry.ino }, + onReplayProgress: control?.onReplayProgress, + }); + return; + } if (endExclusive <= start) { return; } @@ -75,10 +87,16 @@ export async function scanCodexJsonlLines( let pendingParts: Buffer[] = []; let pendingBytes = 0; let bytesRead = 0; + let replayedBytes = 0; let oversizedLines = 0; let finalLineAccepted = false; - for await (const rawChunk of reader.read(entry, start, end)) { + for await (const rawChunk of reader.read(entry, start, end, { + onReplayProgress: async bytes => { + replayedBytes += bytes; + await onChunk?.({ cursor: snapshotCursor(safeOffset, discarding), bytesRead: bytesRead + replayedBytes }); + }, + })) { if (readPosition >= end) { break; } @@ -137,11 +155,13 @@ export async function scanCodexJsonlLines( await onChunk?.({ cursor: snapshotCursor(safeOffset, discarding), - bytesRead, + bytesRead: bytesRead + replayedBytes, }); } - if (!discarding && pendingBytes > 0 && readPosition >= end && acceptFinalLine) { + const compressedEof = entry.compression === 'zstd' && end === entry.size && readPosition >= end; + const finalAcceptor = acceptFinalLine ?? (compressedEof ? (line: string) => parseJsonObject(line) !== null : undefined); + if (!discarding && pendingBytes > 0 && readPosition >= end && finalAcceptor) { let lineBuffer = pendingParts.length === 1 ? pendingParts[0] : Buffer.concat(pendingParts, pendingBytes); @@ -149,18 +169,29 @@ export async function scanCodexJsonlLines( lineBuffer = lineBuffer.subarray(0, lineBuffer.length - 1); } const line = lineBuffer.toString('utf8'); - if (acceptFinalLine(line)) { + if (finalAcceptor(line)) { onLine(line, end); safeOffset = end; finalLineAccepted = true; } } + // A validated compressed frame is immutable, unlike an active plain log. + // Adjudicate its last record once; an invalid/oversized fragment cannot wait + // for an append that will never arrive and force whole-frame replay forever. + const finalLineDiscarded = compressedEof && !discarding && pendingBytes > 0 && !finalLineAccepted; + if (compressedEof) { + if (discarding) oversizedLines += 1; + safeOffset = end; + discarding = false; + } + return { cursor: snapshotCursor(safeOffset, discarding), - bytesRead, + bytesRead: bytesRead + replayedBytes, reachedEnd: readPosition >= end, oversizedLines, finalLineAccepted, + ...(finalLineDiscarded ? { finalLineDiscarded: true as const } : {}), }; } diff --git a/src/providers/codex/codexLineage.ts b/src/providers/codex/codexLineage.ts index 4e386eb9..b18a9ee6 100644 --- a/src/providers/codex/codexLineage.ts +++ b/src/providers/codex/codexLineage.ts @@ -10,3 +10,52 @@ export interface CodexLineageTrace { desiredPrefixEvents: number; appliedPrefixEvents: number; } + +/** Runtime-only DFS memo; null parents denote a root, empty parents an orphan. */ +export function createLineageDepthResolver( + parentsFor: (node: T) => readonly T[] | null, +): (node: T) => number { + const memo = new Map(); + return (node: T): number => { + const cached = memo.get(node); + if (cached !== undefined) { + return cached; + } + const active = new Set([node]); + const frames: Array<{ + node: T; + parents: readonly T[] | null; + nextParent: number; + minimum: number; + }> = [{ node, parents: parentsFor(node), nextParent: 0, minimum: Infinity }]; + while (frames.length > 0) { + const frame = frames[frames.length - 1]; + if (frame.parents && frame.nextParent < frame.parents.length) { + const parent = frame.parents[frame.nextParent++]; + const parentDepth = memo.get(parent); + // Preserve legacy memo-before-cycle and ordered branch traversal semantics. + if (parentDepth !== undefined || active.has(parent)) { + frame.minimum = Math.min(frame.minimum, parentDepth ?? 0); + } else { + active.add(parent); + frames.push({ + node: parent, parents: parentsFor(parent), nextParent: 0, minimum: Infinity, + }); + } + continue; + } + const depth = frame.parents === null ? 0 + : frame.parents.length === 0 ? 1 : 1 + frame.minimum; + memo.set(frame.node, depth); + active.delete(frame.node); + frames.pop(); + const caller = frames[frames.length - 1]; + if (!caller) { + return depth; + } + caller.minimum = Math.min(caller.minimum, depth); + } + // The root frame always returns above. + throw new Error('lineage depth resolver exhausted without a result'); + }; +} diff --git a/src/providers/codex/codexManifest.ts b/src/providers/codex/codexManifest.ts index 5290175d..d3f41263 100644 --- a/src/providers/codex/codexManifest.ts +++ b/src/providers/codex/codexManifest.ts @@ -1,6 +1,8 @@ import { createHmac } from 'node:crypto'; -import { lstat, readdir } from 'node:fs/promises'; +import { constants, Stats } from 'node:fs'; +import { lstat, open, readdir } from 'node:fs/promises'; import * as path from 'node:path'; +import { CODEX_ZSTD_LIMITS, CodexZstdError, inspectCodexZstdHeader } from './codexZstd'; export type CodexSourceArea = 'sessions' | 'archive'; @@ -11,6 +13,9 @@ export interface CodexPersistedManifestEntry { mtimeMs: number; dev?: number; ino?: number; + compression?: 'zstd'; + storageSize?: number; + storageCtimeMs?: number; } export type CodexPersistedManifest = Record< @@ -59,7 +64,48 @@ export function pseudonymousFileKey( salt: string, absolutePath: string, ): string { - return createHmac('sha256', salt).update(absolutePath).digest('hex'); + const canonicalPath = absolutePath.endsWith('.jsonl.zst') + ? absolutePath.slice(0, -4) + : absolutePath; + return createHmac('sha256', salt).update(canonicalPath).digest('hex'); +} + +function samePhysicalFile(expected: Stats, actual: Stats): boolean { + return actual.isFile() && expected.dev === actual.dev && expected.ino === actual.ino && + expected.size === actual.size && expected.mtimeMs === actual.mtimeMs && + expected.ctimeMs === actual.ctimeMs; +} + +async function compressedLogicalSize(absolutePath: string, expected: Stats): Promise { + try { + // NOFOLLOW prevents a final-component symlink race; NONBLOCK lets fstat reject + // a replacement FIFO without hanging. No body decompression occurs here. + const handle = await open(absolutePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); + try { + if (!samePhysicalFile(expected, await handle.stat())) { + throw new Error('source-changed'); + } + const header = Buffer.alloc(18); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + if (bytesRead !== Math.min(expected.size, header.length) || + !samePhysicalFile(expected, await handle.stat())) { + throw new Error('source-changed'); + } + const frame = inspectCodexZstdHeader(header.subarray(0, bytesRead)); + if (frame.decodedSize > CODEX_ZSTD_LIMITS.maxDecodedBytes || + frame.windowSize > CODEX_ZSTD_LIMITS.maxWindowBytes || + expected.size > CODEX_ZSTD_LIMITS.maxCompressedBytes) throw new CodexZstdError('size-limit'); + return frame.decodedSize; + } finally { + await handle.close(); + } + } catch (error) { + // Native filesystem errors carry paths. Only the codec's safe typed errors + // cross this boundary; never return a partial census after a header failure. + if (error instanceof CodexZstdError) throw error; + throw new Error('Codex compressed source inspection failed'); + } } function isMissing(error: unknown): boolean { @@ -90,13 +136,15 @@ async function walkAllowlistedDirectory( } const entries = await readdir(directory, { withFileTypes: true }); + const candidates: Array<{ absolutePath: string; fileStat: Stats; compressed: boolean }> = []; for (const entry of entries) { const absolutePath = path.join(directory, entry.name); if (entry.isDirectory()) { await walkAllowlistedDirectory(absolutePath, sourceArea, salt, output); continue; } - if (!entry.isFile() || !entry.name.endsWith('.jsonl')) { + const compressed = entry.name.endsWith('.jsonl.zst'); + if (!entry.isFile() || (!entry.name.endsWith('.jsonl') && !compressed)) { continue; } try { @@ -104,23 +152,34 @@ async function walkAllowlistedDirectory( if (!fileStat.isFile()) { continue; } - const fileKey = pseudonymousFileKey(salt, absolutePath); - output.push({ - fileKey, - absolutePath, - nonPersisted: true, - sourceArea, - size: fileStat.size, - mtimeMs: fileStat.mtimeMs, - dev: fileStat.dev > 0 ? fileStat.dev : undefined, - ino: fileStat.ino > 0 ? fileStat.ino : undefined, - }); + candidates.push({ absolutePath, fileStat, compressed }); } catch (error) { if (!isMissing(error)) { throw error; } } } + // Select from validated regular files before inspecting compressed headers. + // Plain wins regardless of readdir order; this is not an equivalence claim. + const plainPaths = new Set(candidates.filter(file => !file.compressed).map(file => file.absolutePath)); + for (const { absolutePath, fileStat, compressed } of candidates) { + if (compressed && plainPaths.has(absolutePath.slice(0, -4))) continue; + output.push({ + fileKey: pseudonymousFileKey(salt, absolutePath), + absolutePath, + nonPersisted: true, + sourceArea, + size: compressed ? await compressedLogicalSize(absolutePath, fileStat) : fileStat.size, + ...(compressed ? { + compression: 'zstd' as const, + storageSize: fileStat.size, + storageCtimeMs: fileStat.ctimeMs, + } : {}), + mtimeMs: fileStat.mtimeMs, + dev: fileStat.dev > 0 ? fileStat.dev : undefined, + ino: fileStat.ino > 0 ? fileStat.ino : undefined, + }); + } } export async function scanCodexManifest( @@ -151,6 +210,11 @@ export async function scanCodexManifest( mtimeMs: file.mtimeMs, dev: file.dev, ino: file.ino, + ...(file.compression ? { + compression: file.compression, + storageSize: file.storageSize, + storageCtimeMs: file.storageCtimeMs, + } : {}), }; } return { files, persistable }; @@ -180,6 +244,18 @@ function stableIdentityChanged( return comparable && !stableIdentityMatches(left, right); } +function representationChanged( + before: CodexPersistedManifestEntry, + after: CodexPersistedManifestEntry, +): boolean { + return before.compression !== after.compression || + before.storageSize !== after.storageSize || + before.storageCtimeMs !== after.storageCtimeMs || + // Decoded offsets are never append/truncate evidence for a changed frame. + ((before.compression === 'zstd' || after.compression === 'zstd') && + (before.size !== after.size || before.mtimeMs !== after.mtimeMs)); +} + export function diffCodexManifest( previous: CodexPersistedManifest, current: CodexPersistedManifest, @@ -204,7 +280,7 @@ export function diffCodexManifest( continue; } consumedPrevious.add(key); - if (stableIdentityChanged(before, after)) { + if (representationChanged(before, after) || stableIdentityChanged(before, after)) { result.replaced.push(key); } else if (before.size === after.size && before.mtimeMs === after.mtimeMs) { result.unchanged.push(key); @@ -229,6 +305,11 @@ export function diffCodexManifest( if (movedFrom) { consumedPrevious.add(movedFrom); result.moved.push({ fromKey: movedFrom, toKey: currentKey }); + // Preserve move identity, but force a rebuild when compressed evidence + // changed (including rename's ctime); moving does not prove byte equality. + if (representationChanged(previous[movedFrom], after)) { + result.replaced.push(currentKey); + } } else { result.added.push(currentKey); } diff --git a/src/providers/codex/codexProvider.ts b/src/providers/codex/codexProvider.ts index c8324984..1ddbb451 100644 --- a/src/providers/codex/codexProvider.ts +++ b/src/providers/codex/codexProvider.ts @@ -1,5 +1,6 @@ import { lstat } from 'node:fs/promises'; import * as path from 'node:path'; +import { immutableCodexAchievementFiles } from '../../codexAchievementRevision'; import { ProviderLimitSnapshot, @@ -16,14 +17,17 @@ import { CodexTodayCoverage, CodexIndexV1, createEmptyCodexIndex, + codexHourProofSnapshot, isReadableCodexPeriod, isCodexUsageContributionCurrent, loadCodexIndex, } from './codexIndex'; +import type { CanonicalCodexOutputHours } from './codexHourProof'; import { CODEX_ROLLING_HOURLY_DAYS, codexPeriodFitsAggregate, isCompatibleCodexPeriodLineage, + isCurrentCodexPeriodLineage, } from './codexPeriodIndex'; import { appendCodexQuotaHistory, @@ -41,6 +45,8 @@ import { safeCodexIndexErrorCode, } from './codexWorkerProtocol'; import { CodexSessionTitleCache } from './codexIdentity'; +import { CodexAchievementUsageDelta, CodexAchievementUsageWindow, + copyCodexUsageWindow, copyCodexUsageDelta } from './codexAchievementOrigin'; import { classifyCodexSessionDuplicates } from './codexDedup'; import { equivalentUsageFromProviderTokens, @@ -66,8 +72,12 @@ export interface CodexProviderSnapshot { provider: 'codex'; /** Persisted index generation used to isolate migration completion state. */ indexGeneration?: number; + /** Runtime-only: this worker run has checked the current source manifest. */ + currentManifestVerified?: boolean; total: ProviderTokenCounts; files: CodexFileAggregate[]; + /** Complete, canonical UTC positive-output hour proof; empty while pending. */ + outputHourProof?: CanonicalCodexOutputHours; coverage: CodexIndexCoverage; qualityFlags: Record; limits: ProviderLimitSnapshot[]; @@ -83,8 +93,12 @@ export interface CodexProviderSnapshot { export interface CodexProviderResult { outcome: ProviderSourceOutcome; + /** Fixed diagnostic category only; never a raw worker error or its message. */ + failureCode?: CodexProviderFailureCode; snapshot: CodexProviderSnapshot; progress?: CodexIndexProgress; + /** Runtime-only proof for this caller's accepted observation window. */ + achievementUsageDelta?: CodexAchievementUsageDelta; diagnostic?: { bodyReads: number; failedFiles: number; @@ -96,6 +110,41 @@ export interface CodexProviderResult { }; } +export type CodexProviderFailureCode = + | 'cancelled' + | 'busy' + | 'worker-failed' + | 'disposed' + | 'refresh-failed'; + +function safeWorkerFailureCode(error: unknown): CodexProviderFailureCode { + if (error instanceof CodexWorkerError) { + switch (error.code) { + case 'cancelled': + case 'busy': + case 'worker-failed': + case 'disposed': + case 'refresh-failed': + return error.code; + } + } + return 'refresh-failed'; +} + +function displayOnlySnapshot(snapshot: CodexProviderSnapshot): CodexProviderSnapshot { + return { + ...snapshot, + currentManifestVerified: false, + files: snapshot.files.map((file) => { + const { achievementPeriodWitness: _witness, ...display } = file; + return display; + }), + ...(snapshot.outputHourProof ? { + outputHourProof: { ...snapshot.outputHourProof, complete: false, hours: [] }, + } : {}), + }; +} + export type CodexIndexClientFactory = () => CodexIndexClientLike; function emptySnapshot(timeZone: string): CodexProviderSnapshot { @@ -265,6 +314,7 @@ function codexWeeklyValueInputs( function snapshotFromIndex( index: CodexIndexV1, sessionTitles: Map = new Map(), + currentManifestVerified = false, ): CodexProviderSnapshot { const canonicalFileKeys = classifyCodexSessionDuplicates( index.files, @@ -287,8 +337,30 @@ function snapshotFromIndex( file, index.coverage.period.timeZone, ); + // Source size/cursor/prefix are independent of the period's own claims. + // This proves one file only; completeness of the canonical identity + // census and any withheld usage contributions belongs to the consumer. + const timeZone = index.coverage.period.timeZone; + const expectedPrefixEvents = file.lineage ? file.lineage.desiredPrefixEvents : 0; + const period = file.aggregate.period; + const witnessed = currentManifestVerified === true && index.coverage.complete && + isCodexUsageContributionCurrent(file) && !file.lineageReconciliation && + (!file.lineage || file.lineage.appliedPrefixEvents === expectedPrefixEvents) && + Number.isSafeInteger(expectedPrefixEvents) && expectedPrefixEvents >= 0 && + !file.discardingOversizedLine && + Number.isSafeInteger(file.size) && file.size >= 0 && + Number.isSafeInteger(file.offset) && file.offset === file.size && + file.periodMigration === undefined && periodReadable && + isCurrentCodexPeriodLineage(period, expectedPrefixEvents) && + period?.timeZone === timeZone && period.indexedThrough === file.offset; + // Never accept a witness carried by an incoming aggregate or write the + // projection back to the worker's persistent contribution. + const { achievementPeriodWitness: _witness, ...aggregate } = file.aggregate; return { - ...file.aggregate, + ...aggregate, + ...(witnessed ? { + achievementPeriodWitness: { size: file.size, offset: file.offset, expectedPrefixEvents, timeZone }, + } : {}), // Keep the trusted all-time aggregate visible, but do not let a // rejected or rebuilding period projection reach any period consumer. ...(periodReadable ? {} : { period: undefined }), @@ -317,8 +389,12 @@ function snapshotFromIndex( return { provider: 'codex', indexGeneration: index.indexGeneration, + currentManifestVerified, total: aggregateTotal(usageContributions), files, + outputHourProof: currentManifestVerified + ? codexHourProofSnapshot(index) + : { ...codexHourProofSnapshot(index), complete: false, hours: [] }, coverage: index.coverage, qualityFlags: qualityCounts(contributions), limits, @@ -382,10 +458,11 @@ export class CodexProvider { async loadPersistedSnapshot(): Promise { if (this.disposed) return null; if (this.currentSnapshot) { - return this.currentSnapshot; + return displayOnlySnapshot(this.currentSnapshot); } if (this.persistedSnapshotLoad) { - return this.persistedSnapshotLoad; + const snapshot = await this.persistedSnapshotLoad; + return snapshot ? displayOnlySnapshot(snapshot) : null; } const generation = this.snapshotGeneration; const pending = (async (): Promise => { @@ -397,6 +474,10 @@ export class CodexProvider { loadCodexIndex(this.options.indexPath, this.options.timeZone), this.sessionTitleCache.load(this.options.codexHome, this.options.salt), ]); + if (this.disposed) { + this.sessionTitleCache.clear(); + return null; + } if ( index.coverage.totalFiles === 0 && Object.keys(index.files).length === 0 @@ -405,12 +486,12 @@ export class CodexProvider { } const snapshot = snapshotFromIndex(index, sessionTitles); if (generation !== this.snapshotGeneration) { - return this.currentSnapshot; + return this.currentSnapshot ? displayOnlySnapshot(this.currentSnapshot) : null; } this.currentSnapshot = snapshot; return snapshot; } catch { - return this.currentSnapshot; + return this.currentSnapshot ? displayOnlySnapshot(this.currentSnapshot) : null; } })(); this.persistedSnapshotLoad = pending; @@ -427,17 +508,19 @@ export class CodexProvider { profile: 'background' | 'foreground' = 'background', onProgress?: (progress: CodexIndexProgress) => void, allowHistoricalBackfill: boolean = true, + achievementUsageWindow?: CodexAchievementUsageWindow, ): Promise { + const usageWindow = copyCodexUsageWindow(achievementUsageWindow); if (this.disposed) { return { outcome: 'unavailable', - snapshot: this.currentSnapshot ?? emptySnapshot(this.options.timeZone), + snapshot: this.invalidateCurrentManifestProof(), }; } if (!(await this.isAvailable())) { return { outcome: 'unavailable', - snapshot: this.currentSnapshot ?? emptySnapshot(this.options.timeZone), + snapshot: this.invalidateCurrentManifestProof(), }; } this.client ??= this.clientFactory(); @@ -453,18 +536,35 @@ export class CodexProvider { timeZone: this.options.timeZone, profile, allowHistoricalBackfill, + allowAchievementHourProof: this.options.allowAchievementHourProof !== false, + achievementUsageWindow: usageWindow, }, (progress) => { this.lastProgress = progress; onProgress?.(progress); }, ); + if (this.disposed || this.client !== client) { + return { outcome: 'unavailable', snapshot: this.invalidateCurrentManifestProof() }; + } const sessionTitles = await this.sessionTitleCache.load( this.options.codexHome, this.options.salt, ); + if (this.disposed || this.client !== client) { + this.sessionTitleCache.clear(); + return { outcome: 'unavailable', snapshot: this.invalidateCurrentManifestProof() }; + } this.snapshotGeneration += 1; - this.currentSnapshot = snapshotFromIndex(result.index, sessionTitles); + const snapshot = snapshotFromIndex(result.index, sessionTitles, true); + if (result.failedFiles === 0 && this.options.allowAchievementHourProof !== false) { + // Rebuild current census and witnesses above even on zero-body reads. + // Only full equality against a detached frozen publication allows reuse; + // indexChanged is persistence metadata, not an achievement revision. + snapshot.files = immutableCodexAchievementFiles(snapshot.files, + this.currentSnapshot?.currentManifestVerified ? this.currentSnapshot.files : undefined); + } + this.currentSnapshot = snapshot; const outcome: ProviderSourceOutcome = result.failedFiles > 0 || !result.index.coverage.complete || @@ -480,6 +580,8 @@ export class CodexProvider { outcome, snapshot: this.currentSnapshot, progress: this.lastProgress, + achievementUsageDelta: result.failedFiles === 0 && result.index.coverage.complete + ? copyCodexUsageDelta(result.achievementUsageDelta, usageWindow) : undefined, diagnostic: { bodyReads: result.bodyReads, failedFiles: result.failedFiles, @@ -492,6 +594,7 @@ export class CodexProvider { }, }; } catch (error) { + const snapshot = this.invalidateCurrentManifestProof(); try { await client.dispose(); } finally { @@ -499,9 +602,12 @@ export class CodexProvider { } return { outcome: 'error', - snapshot: this.currentSnapshot ?? emptySnapshot(this.options.timeZone), + failureCode: safeWorkerFailureCode(error), + snapshot, progress: this.lastProgress, diagnostic: { + // A thrown/cancelled worker returns no final per-file diagnostics. + // Zero here means unavailable, not a fresh successful failure census. bodyReads: 0, failedFiles: 0, metadataMs: 0, parseMs: 0, migrationPending: false, errorCode: safeCodexIndexErrorCode(error instanceof CodexWorkerError ? error.code : undefined), @@ -510,6 +616,21 @@ export class CodexProvider { } } + /** Apply an achievement toggle without replacing the usage provider or its checkpoint. */ + setAchievementHourProofEnabled(enabled: boolean): void { + this.options.allowAchievementHourProof = enabled; + } + + private invalidateCurrentManifestProof(): CodexProviderSnapshot { + this.snapshotGeneration += 1; + if (this.currentSnapshot) { + // Keep the last numeric subtotal useful without pretending a failed or + // unavailable current scan verified it. Published snapshots stay immutable. + this.currentSnapshot = displayOnlySnapshot(this.currentSnapshot); + } + return this.currentSnapshot ?? emptySnapshot(this.options.timeZone); + } + snapshot(): CodexProviderSnapshot | null { return this.currentSnapshot; } @@ -527,7 +648,7 @@ export class CodexProvider { async dispose(): Promise { if (this.disposal) return this.disposal; this.disposed = true; - this.snapshotGeneration += 1; + this.invalidateCurrentManifestProof(); this.sessionTitleCache.clear(); const client = this.client; this.client = null; diff --git a/src/providers/codex/codexUsage.ts b/src/providers/codex/codexUsage.ts index 763d8af1..633747a3 100644 --- a/src/providers/codex/codexUsage.ts +++ b/src/providers/codex/codexUsage.ts @@ -180,6 +180,8 @@ export interface CodexBehaviorScopesView { } export interface CodexUsageView { + /** Cached display data cannot prove new awards before source verification. */ + currentManifestVerified?: boolean; /** Calendar day in the configured provider timezone. */ today: CodexUsageScopeView; /** Exact, sparse current-day hours from the independently resumable sidecar. */ @@ -188,6 +190,8 @@ export interface CodexUsageView { /** Sparse data-day -> exact hour rows for the configured rolling window. */ last30DaysHourlyByDay: Record; hourlyCoverage: CodexHourlyCoverage; + /** Canonical, complete-only positive-output UTC hours for achievements. */ + outputHourProof?: CodexProviderSnapshot['outputHourProof']; lastTask: CodexUsageScopeView | null; lastTaskIdentity: CodexTaskIdentityView | null; last7Days: CodexUsageScopeView; @@ -1191,6 +1195,7 @@ export function buildCodexUsageView( return { today: todayScope, + currentManifestVerified: snapshot.currentManifestVerified === true, todayHourly: hourlyRows( snapshot.files, periodCoverage.asOfDay, @@ -1199,6 +1204,7 @@ export function buildCodexUsageView( todayCoverage: snapshot.coverage.today, last30DaysHourlyByDay, hourlyCoverage, + outputHourProof: snapshot.outputHourProof, lastTask: recentScope, lastTaskIdentity: recent.length > 0 ? { diff --git a/src/providers/codex/codexWorkerProtocol.ts b/src/providers/codex/codexWorkerProtocol.ts index 005ad4e0..fe66a98d 100644 --- a/src/providers/codex/codexWorkerProtocol.ts +++ b/src/providers/codex/codexWorkerProtocol.ts @@ -4,14 +4,16 @@ import { CodexIndexUpdateResult, CodexIndexV1, } from './codexIndex'; +import type { CodexAchievementUsageWindow, CodexAchievementUsageDelta } from './codexAchievementOrigin'; -export type CodexIndexErrorCode = 'busy' | 'cancelled' | 'disposed' | 'worker-failed' | 'refresh-failed'; +export type CodexIndexErrorCode = 'busy' | 'cancelled' | 'disposed' | 'worker-failed' | 'refresh-failed' | 'zstd-unsupported'; export function safeCodexIndexErrorCode(value: unknown): CodexIndexErrorCode { - return value === 'busy' || value === 'cancelled' || value === 'disposed' || value === 'worker-failed' + return value === 'busy' || value === 'cancelled' || value === 'disposed' || value === 'worker-failed' || value === 'zstd-unsupported' ? value : 'refresh-failed'; } export interface CodexWorkerRefreshInput { + achievementUsageWindow?: CodexAchievementUsageWindow; codexHome: string; indexPath: string; /** Read-only compatibility input, adopted only after manifest ownership proof. */ @@ -23,6 +25,8 @@ export interface CodexWorkerRefreshInput { /** False keeps changed-file/tail maintenance active but suppresses unchanged * historical reconciliation until the persisted eligibility time. */ allowHistoricalBackfill?: boolean; + /** False skips achievement-only UTC output proof migration, not usage indexing. */ + allowAchievementHourProof?: boolean; } export type CodexWorkerRequest = @@ -30,6 +34,7 @@ export type CodexWorkerRequest = | { type: 'cancel'; requestId: string }; export interface CodexWorkerResult { + achievementUsageDelta?: CodexAchievementUsageDelta; index: CodexIndexV1; indexRecovery?: CodexIndexRecovery; indexChanged: boolean; diff --git a/src/providers/codex/codexZstd.ts b/src/providers/codex/codexZstd.ts new file mode 100644 index 00000000..d413d29a --- /dev/null +++ b/src/providers/codex/codexZstd.ts @@ -0,0 +1,311 @@ +import { open, lstat } from 'node:fs/promises'; +import { constants, Stats } from 'node:fs'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import * as zlib from 'node:zlib'; + +/** Max bytes needed to inspect any RFC 8878 frame header, including magic. */ +export const CODEX_ZSTD_HEADER_BYTES = 18; + +/** Hard per-call ceilings. Options may reduce, but never increase these bounds. */ +export const CODEX_ZSTD_LIMITS = Object.freeze({ + chunkBytes: 64 * 1024, + maxChunkBytes: 1024 * 1024, + maxDecodedBytes: 4 * 1024 ** 3, + maxWindowBytes: 64 * 1024 * 1024, + maxCompressedBytes: 2 * 1024 ** 3, + maxReplayBytes: 4 * 1024 ** 3, +}); + +export interface CodexZstdHeader { + decodedSize: number; + windowSize: number; + hasChecksum: boolean; + singleSegment: boolean; + headerSize: number; +} + +export interface CodexZstdReadOptions { + /** 64 bytes to 1 MiB; bounds compressed reads and emitted decoded chunks. */ + chunkBytes?: number; + maxDecodedBytes?: number; + maxWindowBytes?: number; + maxCompressedBytes?: number; + /** Maximum discarded decoded prefix (start). Resuming always replays from byte zero. */ + maxReplayBytes?: number; + signal?: AbortSignal; + /** Awaited prefix byte DELTAS, each <= chunkBytes; callback errors propagate unchanged. */ + onReplayProgress?: (decodedPrefixBytes: number) => Promise; + expectedSource?: { + size: number; + mtimeMs: number; + ctimeMs?: number; + dev?: number; + ino?: number; + }; +} + +export type CodexZstdErrorCode = + | 'codec-unavailable' | 'unknown-size' | 'dictionary' | 'reserved' | 'malformed' + | 'size-limit' | 'replay-limit' | 'invalid-range' | 'invalid-options' + | 'size-mismatch' | 'trailing-data' | 'truncated' | 'decode-failed' | 'io-error' | 'aborted' + | 'source-changed'; + +/** Safe for diagnostics: no source path, decoded contents or native error payload. */ +export class CodexZstdError extends Error { + constructor(readonly code: CodexZstdErrorCode) { + super(`Codex Zstd: ${code}`); + this.name = 'CodexZstdError'; + } +} + +/** + * RFC 8878 §3.1.1.1. Header metadata only: this does NOT establish frame/body + * validity, checksum integrity, or absence of concatenated/trailing frames. + * Callers may supply just the header or a longer prefix. Unknown sizes and all + * dictionary-ID fields (including zero) are deliberately unsupported. + */ +export function inspectCodexZstdHeader(buffer: Buffer): CodexZstdHeader { + if (buffer.length < 5 || buffer.readUInt32LE(0) !== 0xfd2fb528) { + throw new CodexZstdError('malformed'); + } + const descriptor = buffer[4]; + if (descriptor & 0x08) throw new CodexZstdError('reserved'); + // Bit 4 is unused: RFC requires decoders to ignore it. + const singleSegment = Boolean(descriptor & 0x20); + const sizeFlag = descriptor >>> 6; + const sizeBytes = sizeFlag === 0 ? (singleSegment ? 1 : 0) : 2 ** sizeFlag; + const dictionaryBytes = [0, 1, 2, 4][descriptor & 3]; + const sizeOffset = 5 + (singleSegment ? 0 : 1) + dictionaryBytes; + const headerSize = sizeOffset + sizeBytes; + if (buffer.length < headerSize) throw new CodexZstdError('malformed'); + if (dictionaryBytes) throw new CodexZstdError('dictionary'); + if (!sizeBytes) throw new CodexZstdError('unknown-size'); + const size = sizeBytes === 8 + ? buffer.readBigUInt64LE(sizeOffset) + : BigInt(buffer.readUIntLE(sizeOffset, sizeBytes) + (sizeBytes === 2 ? 256 : 0)); + if (size > BigInt(Number.MAX_SAFE_INTEGER)) throw new CodexZstdError('size-limit'); + const decodedSize = Number(size); + const windowBase = 2 ** (10 + (buffer[5] >>> 3)); + const windowSize = singleSegment ? decodedSize : windowBase * (1 + (buffer[5] & 7) / 8); + return { decodedSize, windowSize, hasChecksum: Boolean(descriptor & 4), singleSegment, headerSize }; +} + +/** Namespace access is safe on Node 20, which has no Zstd named exports. */ +export function supportsCodexZstd(): boolean { + return typeof zlib.createZstdDecompress === 'function' && + typeof zlib.constants.ZSTD_d_windowLogMax === 'number'; +} + +function boundedOption(value: number | undefined, fallback: number, ceiling = fallback, minimum = 0): number { + const result = value ?? fallback; + if (!Number.isSafeInteger(result) || result < minimum || result > ceiling) { + throw new CodexZstdError('invalid-options'); + } + return result; +} + +/** + * Read [start, end) in DECODED byte coordinates, without modifying the source + * or materializing a decoded file. Each call starts at the compressed beginning; + * maxReplayBytes bounds the discarded prefix, maxDecodedBytes the entire frame. + * + * Partial ranges intentionally cancel both streams: their unread suffix and + * checksum are NOT verified. Full ranges (including empty frames/end ranges) + * consume decoder EOF and validate exact length and a single frame. Yielded + * bytes remain provisional until successful iterator completion. A consumer + * break/return is cancellation, never evidence of a verified full frame. + * AbortSignal interrupts active reads; always close an abandoned iterator. + * + * Native API: Node 22.16 zlib ZstdOptions/ZSTD_d_windowLogMax; do not use relaxed + * finishFlush or maxOutputLength (the latter only bounds convenience methods). + */ +export async function* readCodexZstdRange( + path: string, start: number, end: number, expectedDecodedSize: number, + options: CodexZstdReadOptions = {}, +): AsyncGenerator { + if (!supportsCodexZstd()) throw new CodexZstdError('codec-unavailable'); + if (![start, end, expectedDecodedSize].every(n => Number.isSafeInteger(n) && n >= 0) + || start > end || end > expectedDecodedSize) throw new CodexZstdError('invalid-range'); + const limits = CODEX_ZSTD_LIMITS; + const chunkBytes = boundedOption(options.chunkBytes, limits.chunkBytes, limits.maxChunkBytes, 64); + const maxDecoded = boundedOption(options.maxDecodedBytes, limits.maxDecodedBytes); + const maxWindow = boundedOption(options.maxWindowBytes, limits.maxWindowBytes); + const maxCompressed = boundedOption(options.maxCompressedBytes, limits.maxCompressedBytes); + const maxReplay = boundedOption(options.maxReplayBytes, limits.maxReplayBytes); + if (expectedDecodedSize > maxDecoded) throw new CodexZstdError('size-limit'); + if (start > maxReplay) throw new CodexZstdError('replay-limit'); + const checkAbort = () => { + if (options.signal?.aborted) throw new CodexZstdError('aborted'); + }; + checkAbort(); + const before = await lstat(path).catch(() => { throw new CodexZstdError('io-error'); }); + if (!before.isFile() || before.isSymbolicLink()) throw new CodexZstdError('source-changed'); + // NONBLOCK also avoids hanging on a FIFO substituted between lstat/open. + const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)) + .catch(() => { throw new CodexZstdError('io-error'); }); + // Classify at the filesystem boundary, before pipeline can forward the same + // rejection as a decoder error. Temporary EIO must never poison a version. + const readSource = (buffer: Buffer, offset: number, length: number, position: number) => + file.read(buffer, offset, length, position).catch(() => { throw new CodexZstdError('io-error'); }); + const statSource = () => file.stat().catch(() => { throw new CodexZstdError('io-error'); }); + let source: Readable | undefined; + let decoder: zlib.ZstdDecompress | undefined; + let completion: Promise | undefined; + let streamError: unknown; + let snapshot: Stats | undefined; + let failed = false; + let fullyConsumed = false; + let replayCallbackFailed = false; + const cancellation = new AbortController(); + const sameSource = (actual: Stats, expected: NonNullable) => + actual.isFile() && (['size', 'mtimeMs', 'ctimeMs', 'dev', 'ino'] as const) + .every(key => expected[key] === undefined || actual[key] === expected[key]); + const abort = () => { + cancellation.abort(); + const error = new CodexZstdError('aborted'); + source?.destroy(error); + decoder?.destroy(error); + }; + try { + checkAbort(); + const stat = await statSource(); + if (!sameSource(stat, before) || (options.expectedSource && !sameSource(stat, options.expectedSource))) { + throw new CodexZstdError('source-changed'); + } + snapshot = stat; + if (stat.size > maxCompressed) throw new CodexZstdError('size-limit'); + const prefix = Buffer.alloc(CODEX_ZSTD_HEADER_BYTES); + let prefixBytes = 0; + while (prefixBytes < prefix.length) { + checkAbort(); + const { bytesRead } = await readSource(prefix, prefixBytes, prefix.length - prefixBytes, prefixBytes); + if (!bytesRead) break; + prefixBytes += bytesRead; + } + const header = inspectCodexZstdHeader(prefix.subarray(0, prefixBytes)); + if (header.decodedSize !== expectedDecodedSize) throw new CodexZstdError('size-mismatch'); + if (header.windowSize > maxWindow) throw new CodexZstdError('size-limit'); + + // Parse only the frame envelope, not compressed contents. This bounded + // input generator prevents a second frame (even zero-output/skippable) from + // reaching the decoder: native EOF alone may silently ignore trailing data. + let position = header.headerSize; + let frameComplete = false; + const exact = async (size: number): Promise => { + if (position + size > maxCompressed) throw new CodexZstdError('size-limit'); + const buffer = Buffer.allocUnsafe(size); + let offset = 0; + while (offset < size) { + checkAbort(); + const { bytesRead } = await readSource(buffer, offset, size - offset, position); + if (!bytesRead) throw new CodexZstdError('truncated'); + offset += bytesRead; + position += bytesRead; + } + return buffer; + }; + async function* compressedChunks(): AsyncGenerator { + yield prefix.subarray(0, header.headerSize); + let last = false; + while (!last) { + const block = await exact(3); + const bits = block.readUIntLE(0, 3); + last = Boolean(bits & 1); + const kind = (bits >>> 1) & 3; + const size = bits >>> 3; + if (kind === 3 || size > Math.min(header.windowSize, 128 * 1024)) { + throw new CodexZstdError('malformed'); + } + yield block; + let remaining = kind === 1 ? 1 : size; + while (remaining > 0) { + const length = Math.min(remaining, chunkBytes); + yield await exact(length); + remaining -= length; + } + } + if (header.hasChecksum) yield await exact(4); + checkAbort(); + const tail = Buffer.alloc(1); + if ((await readSource(tail, 0, 1, position)).bytesRead) throw new CodexZstdError('trailing-data'); + frameComplete = true; + } + + const decoderOptions = { + chunkSize: chunkBytes, + readableHighWaterMark: chunkBytes, + writableHighWaterMark: chunkBytes, + params: { + // Native minimum is 1 KiB. The exact requested ceiling was checked above; + // rounding up never exceeds our power-of-two hard window ceiling. + [zlib.constants.ZSTD_d_windowLogMax]: Math.max(10, Math.ceil(Math.log2(Math.max(1, maxWindow)))), + }, + }; + decoder = zlib.createZstdDecompress(decoderOptions); + source = Readable.from(compressedChunks(), { objectMode: false, highWaterMark: chunkBytes }); + // Install rejection handling immediately: input/decode errors may precede next(). + completion = pipeline(source, decoder, { signal: cancellation.signal }).catch(error => { streamError = error; }); + options.signal?.addEventListener('abort', abort, { once: true }); + checkAbort(); + let decoded = 0; + for await (const value of decoder) { + checkAbort(); + const chunk = value as Buffer; + const next = decoded + chunk.length; + if (next > maxDecoded) throw new CodexZstdError('size-limit'); + if (next > expectedDecodedSize) throw new CodexZstdError('size-mismatch'); + const prefixLength = Math.max(0, Math.min(next, start) - decoded); + if (options.onReplayProgress) { + for (let offset = 0; offset < prefixLength; offset += chunkBytes) { + checkAbort(); + try { + await options.onReplayProgress(Math.min(chunkBytes, prefixLength - offset)); + } catch (error) { + replayCallbackFailed = true; + throw error; + } + checkAbort(); + } + } + const from = Math.max(start - decoded, 0); + const to = Math.min(end - decoded, chunk.length); + for (let offset = from; offset < to; offset += chunkBytes) { + checkAbort(); + yield chunk.subarray(offset, Math.min(offset + chunkBytes, to)); + } + decoded = next; + // Only an explicitly partial range may stop before native end/checksum. + if (end < expectedDecodedSize && decoded >= end) return; + } + await completion; + checkAbort(); + if (streamError) throw streamError; + if (!frameComplete) throw new CodexZstdError('truncated'); + if (decoded !== expectedDecodedSize) throw new CodexZstdError('size-mismatch'); + fullyConsumed = true; + } catch (error) { + failed = true; + if (replayCallbackFailed) throw error; + if (options.signal?.aborted) throw new CodexZstdError('aborted'); + if (error instanceof CodexZstdError) throw error; + throw new CodexZstdError('decode-failed'); + } finally { + options.signal?.removeEventListener('abort', abort); + if (!fullyConsumed) cancellation.abort(); + source?.destroy(); + decoder?.destroy(); + await completion; + try { + if (!failed && snapshot) { + const after = await statSource(); + const named = await lstat(path).catch(() => { throw new CodexZstdError('source-changed'); }); + if (!sameSource(after, snapshot) || !sameSource(named, snapshot) || named.isSymbolicLink()) { + throw new CodexZstdError('source-changed'); + } + } + } finally { + await file.close().catch(() => { throw new CodexZstdError('io-error'); }); + } + } +} diff --git a/src/quotaPace.ts b/src/quotaPace.ts new file mode 100644 index 00000000..4f992f35 --- /dev/null +++ b/src/quotaPace.ts @@ -0,0 +1,147 @@ +/** A bounded snapshot contract, deliberately independent of quota I/O and UI. + * All times are Unix milliseconds; usedFraction is the original 0..1 usage. + * Supply raw observations, before liveQuotaWindows synthesizes expired zeros. + */ +export interface QuotaPaceObservation { + readonly observedAt: number; + readonly resetAt: number | null; + /** Must be known from provider semantics, never guessed from a display label. */ + readonly periodMs: number | null; + readonly usedFraction: number; + readonly source: 'observed' | 'synthetic-reset'; + /** The adapter owns account/window selection. Mark conflicts, unknown + * identity, unexpected resets, counter drops or source changes as untrusted + * unless a trustworthy new seven-day window has been established. This + * single-snapshot function cannot discover those historical facts itself. + */ + readonly accountWindowTrust: 'trusted' | 'ambiguous' | 'unknown' | 'discontinuous'; +} + +export interface QuotaPaceInput { + readonly enabled: boolean; + /** Explicit clock used only to reject future, stale or expired snapshots. */ + readonly now: number; + readonly observation: QuotaPaceObservation | null; +} + +export type QuotaPaceUnavailableReason = + | 'missing-observation' + | 'synthetic-observation' + | 'untrusted-account-window' + | 'missing-reset' + | 'unknown-period' + | 'invalid-number' + | 'invalid-time' + | 'unsupported-period' + | 'invalid-usage' + | 'future-observation' + | 'reset-passed' + | 'invalid-window' + | 'stale-observation'; + +interface QuotaPaceValues { + readonly observedAt: number; + readonly resetAt: number; + /** Inferred from resetAt minus the known seven-day duration. */ + readonly windowStart: number; + readonly usedPercent: number; + readonly referencePercent: number; + /** Signed percentage points, not relative percent. */ + readonly deviationPercentagePoints: number; +} + +/** Presentation values retain precision; the consuming UI chooses rounding. + * Unavailable/off results carry no numeric values. A ready forecast may exceed + * 100%; it describes account-wide cycle-average use, not local-device speed. + */ +export type QuotaPaceResult = + | { readonly status: 'off' } + | { readonly status: 'unavailable'; readonly reason: QuotaPaceUnavailableReason } + | (QuotaPaceValues & { readonly status: 'insufficient-sample'; readonly forecastPercent: null }) + | (QuotaPaceValues & { readonly status: 'ready'; readonly forecastPercent: number }); + +export type QuotaPaceStatus = QuotaPaceResult['status']; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const MIN_SAMPLE_MS = 6 * 60 * 60 * 1000; +const MAX_AGE_MS = 30 * 60 * 1000; +const MAX_TIMESTAMP = 8_640_000_000_000_000; + +function unavailable(reason: QuotaPaceUnavailableReason): QuotaPaceResult { + return { status: 'unavailable', reason }; +} + +/** O(1), no I/O, timers, clock reads, history scans or input mutation. */ +export function calculateQuotaPace(input: QuotaPaceInput): QuotaPaceResult { + if (input.enabled !== true) { + return { status: 'off' }; + } + const observation = input.observation; + if (!observation) { + return unavailable('missing-observation'); + } + if (observation.source !== 'observed') { + return unavailable('synthetic-observation'); + } + if (observation.accountWindowTrust !== 'trusted') { + return unavailable('untrusted-account-window'); + } + const { observedAt, resetAt, periodMs, usedFraction } = observation; + const now = input.now; + if (resetAt === null) { + return unavailable('missing-reset'); + } + if (periodMs === null) { + return unavailable('unknown-period'); + } + if ( + !Number.isFinite(now) || !Number.isFinite(observedAt) || + !Number.isFinite(resetAt) || !Number.isFinite(periodMs) || + !Number.isFinite(usedFraction) + ) { + return unavailable('invalid-number'); + } + if ( + now < 0 || now > MAX_TIMESTAMP || + observedAt < 0 || observedAt > MAX_TIMESTAMP || + resetAt < 0 || resetAt > MAX_TIMESTAMP + ) { + return unavailable('invalid-time'); + } + if (periodMs !== SEVEN_DAYS_MS) { + return unavailable('unsupported-period'); + } + if (usedFraction < 0 || usedFraction > 1) { + return unavailable('invalid-usage'); + } + if (observedAt > now) { + return unavailable('future-observation'); + } + if (resetAt <= now) { + return unavailable('reset-passed'); + } + const windowStart = resetAt - periodMs; + if (windowStart < 0 || observedAt < windowStart) { + return unavailable('invalid-window'); + } + if (now - observedAt > MAX_AGE_MS) { + return unavailable('stale-observation'); + } + + // The original observation anchors every value. A repaint may only withdraw + // an expired forecast; it must never make the same usage look slower. + const elapsedMs = observedAt - windowStart; + const elapsedFraction = elapsedMs / periodMs; + const values: QuotaPaceValues = { + observedAt, + resetAt, + windowStart, + usedPercent: 100 * usedFraction, + referencePercent: 100 * elapsedFraction, + deviationPercentagePoints: 100 * (usedFraction - elapsedFraction), + }; + if (elapsedMs < MIN_SAMPLE_MS) { + return { status: 'insufficient-sample', ...values, forecastPercent: null }; + } + return { status: 'ready', ...values, forecastPercent: 100 * usedFraction / elapsedFraction }; +} diff --git a/src/quotaPaceAdapters.ts b/src/quotaPaceAdapters.ts new file mode 100644 index 00000000..61dfa53d --- /dev/null +++ b/src/quotaPaceAdapters.ts @@ -0,0 +1,128 @@ +import { calculateQuotaPace, QuotaPaceObservation, QuotaPaceResult } from './quotaPace'; + +export const QUOTA_PACE_MAX_WINDOWS = 16; +export const QUOTA_PACE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +/** Slot is the index in the owner's RAW normalized windows, before filtering + * or liveQuotaWindows. It is only a display join, never an account identity. + * Supply actual numeric percentages; normalization's fallback zero is not an + * observation. Unknown values must be NaN/null, never synthesized zero. + */ +export interface QuotaPaceAdapterWindow { + readonly slot: number; + readonly kind: 'weekly_all' | 'weekly_scoped' | 'other'; + readonly usedPercent: number; + readonly resetAt: number | null; + readonly periodMs: number | null; +} + +/** Fixed numeric/enum DTO: no account IDs, labels, paths, history or credentials. + * The owner must establish current account/window trust from existing source + * evidence. Neither a Codex home, file key, limitId nor label proves an account. + * Unknown/mixed accounts and source changes without a verified new window must + * be marked unknown/ambiguous/discontinuous. Missing evidence stays unknown. + * At most sixteen already-materialized windows, never a log/history scan. + */ +export interface QuotaPaceAdapterSnapshot { + readonly observedAt: number | null; + readonly source: QuotaPaceObservation['source']; + readonly accountWindowTrust: QuotaPaceObservation['accountWindowTrust']; + readonly windows: readonly QuotaPaceAdapterWindow[]; +} + +export interface QuotaPaceAdapterInput { + readonly enabled: boolean; + readonly now: number; + readonly snapshot: QuotaPaceAdapterSnapshot | null; + /** Optional immediately preceding observation from the SAME verified source. + * Do not replace this with a historical reset-cluster representative. The + * owner retains any discontinuity until a trustworthy new window is observed. + * Slots must preserve window identity; otherwise mark trust ambiguous. + */ + readonly previous?: QuotaPaceAdapterSnapshot | null; +} + +export interface QuotaPaceAdapterEntry { + readonly slot: number; + readonly kind: 'weekly_all' | 'weekly_scoped'; + readonly result: QuotaPaceResult; +} + +function rejected(slot = 0, kind: QuotaPaceAdapterEntry['kind'] = 'weekly_all'): QuotaPaceAdapterEntry { + return { slot, kind, result: { status: 'unavailable', reason: 'untrusted-account-window' } }; +} + +function bounded(snapshot: QuotaPaceAdapterSnapshot): boolean { + return Array.isArray(snapshot.windows) && snapshot.windows.length <= QUOTA_PACE_MAX_WINDOWS && + snapshot.windows.every((window) => Number.isInteger(window.slot) && window.slot >= 0 && window.slot < QUOTA_PACE_MAX_WINDOWS); +} + +function continuous(current: QuotaPaceAdapterSnapshot, window: QuotaPaceAdapterWindow, previous?: QuotaPaceAdapterSnapshot | null): boolean { + if (!previous) return true; + if (!bounded(previous) || previous.accountWindowTrust !== 'trusted' || previous.source !== 'observed') return false; + if (current.observedAt === null || previous.observedAt === null || + !Number.isFinite(previous.observedAt) || current.observedAt < previous.observedAt) return false; + const matches = previous.windows.filter((prior) => prior.slot === window.slot && prior.kind === window.kind); + if (matches.length !== 1) return false; + const prior = matches[0]; + if (!Number.isFinite(prior.usedPercent) || prior.usedPercent < 0 || prior.usedPercent > 100 || + prior.periodMs !== QUOTA_PACE_WEEK_MS || prior.resetAt === null || !Number.isFinite(prior.resetAt) || + previous.observedAt < prior.resetAt - QUOTA_PACE_WEEK_MS || previous.observedAt >= prior.resetAt) return false; + if (window.resetAt === prior.resetAt) { + return window.usedPercent >= prior.usedPercent && + (current.observedAt !== previous.observedAt || window.usedPercent === prior.usedPercent); + } + // A decrease is allowed only after a verified ordinary rollover. Early reset + // drift and arbitrary account/window substitution are never extrapolated. + return window.resetAt !== null && window.resetAt === prior.resetAt + QUOTA_PACE_WEEK_MS && + current.observedAt >= prior.resetAt; +} + +function adapt(input: QuotaPaceAdapterInput, provider: 'claude' | 'codex'): QuotaPaceAdapterEntry[] { + if (input.enabled !== true) return []; + const snapshot = input.snapshot; + if (!snapshot) return []; + if (!bounded(snapshot)) return [rejected()]; + const windows = snapshot.windows.filter((window) => window.kind === 'weekly_all' || + (provider === 'claude' && window.kind === 'weekly_scoped')); + if (provider === 'codex' && windows.length > 1) return [rejected()]; + const slots = new Set(); + const duplicateSlot = snapshot.windows.some((window) => { + if (slots.has(window.slot)) return true; + slots.add(window.slot); + return false; + }); + const duplicateAccount = windows.filter((window) => window.kind === 'weekly_all').length > 1; + return windows.map((window): QuotaPaceAdapterEntry => { + const kind = window.kind as QuotaPaceAdapterEntry['kind']; + if (duplicateSlot || duplicateAccount) return rejected(window.slot, kind); + const trust = continuous(snapshot, window, input.previous) ? snapshot.accountWindowTrust : 'discontinuous'; + return { + slot: window.slot, + kind, + result: calculateQuotaPace({ + enabled: true, + now: input.now, + observation: { + observedAt: snapshot.observedAt ?? NaN, + resetAt: window.resetAt, + periodMs: window.periodMs, + usedFraction: typeof window.usedPercent === 'number' ? window.usedPercent / 100 : NaN, + source: snapshot.source, + accountWindowTrust: trust, + }, + }), + }; + }); +} + +/** Pure bounded adapters. The supplied snapshot is the latest one, even if + * invalid/stale; never select an older 'better' sample or combine accounts. + */ +export function adaptClaudeQuotaPace(input: QuotaPaceAdapterInput): QuotaPaceAdapterEntry[] { + return adapt(input, 'claude'); +} + +export function adaptCodexQuotaPace(input: QuotaPaceAdapterInput): QuotaPaceAdapterEntry[] { + return adapt(input, 'codex'); +} diff --git a/src/quotaPaceSource.ts b/src/quotaPaceSource.ts new file mode 100644 index 00000000..e4153fc6 --- /dev/null +++ b/src/quotaPaceSource.ts @@ -0,0 +1,269 @@ +import { ClaudeApiUsageResponse, ClaudeUsageLimitEntry } from './types'; +import { ProviderLimitSnapshot } from './providers/providerTypes'; +import { + QUOTA_PACE_MAX_WINDOWS, QUOTA_PACE_WEEK_MS, + QuotaPaceAdapterSnapshot, QuotaPaceAdapterWindow, +} from './quotaPaceAdapters'; + +export interface ClaudeQuotaPaceSourceInput { + readonly enabled: boolean; + /** Existing raw successful response, BEFORE normalization/live reset zeros. */ + readonly usage: ClaudeApiUsageResponse | null; + /** ORIGINAL successful fetch time, never the dashboard repaint time. */ + readonly observedAt: number | null; + /** Owner supplies fingerprintForStableIdentity ONLY when the request had a + * verified account signal. Profile continuity, raw IDs and tokens do not qualify. + * Shape validation below is not verification of the caller's attribution. + */ + readonly verifiedAccountFingerprint: string | null; +} + +interface SourceRow { + readonly rank: number; + readonly key: string | null; + readonly window: QuotaPaceAdapterWindow; +} +interface Projection { + readonly source: 'generic' | 'legacy'; + readonly rows: readonly SourceRow[]; + readonly ambiguous: boolean; +} +interface Anchor { + readonly account: string; + readonly source: Projection['source']; + readonly observedAt: number; + readonly rows: readonly SourceRow[]; +} + +const MAX_TIME = 8_640_000_000_000_000; +function timestamp(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= MAX_TIME ? value : null; +} +function percent(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 ? value : NaN; +} +function reset(value: unknown): number | null { + // Do not coerce numbers/null or interpret timezone-less dates in local time. + if (typeof value !== 'string' || value.length > 64 || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return null; + return timestamp(Date.parse(value)); +} +function fingerprint(value: unknown): string | null { + return typeof value === 'string' && /^acct_[a-f0-9]{32}$/.test(value) ? value : null; +} +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function scopePart(value: unknown): string | null { + return typeof value === 'string' && value.length <= 256 && value.trim().length > 0 ? value : null; +} +function scopeKey(entry: ClaudeUsageLimitEntry): string | null { + const scope = entry.scope; + if (!record(scope)) return null; + const model = scope.model; + const id = record(model) ? scopePart(model.id) : null; + const name = record(model) ? scopePart(model.display_name) : null; + const surface = scopePart(scope.surface); + // A rename of a verified model id is harmless; matching display labels never + // conceal a changed id. Surface remains part of the window identity. + return id || name || surface ? JSON.stringify([id, id ? null : name, surface]) : null; +} + +function projectClaude(usage: ClaudeApiUsageResponse): Projection { + const rows: SourceRow[] = []; + const entries = usage.limits; + let ambiguous = false; + // Never fall through from a nonempty generic payload to legacy authority. + // Reject oversize BEFORE item getters, normalization or sorting can scan it. + if (Array.isArray(entries) && entries.length > 0) { + if (entries.length > QUOTA_PACE_MAX_WINDOWS) return { source: 'generic', rows, ambiguous: true }; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (!record(entry)) { ambiguous = true; continue; } + const rawKind = entry.kind; + // Mirror the display normalizer's recognized-row ordering, including its + // group fallback, WITHOUT granting that fallback weekly semantics. + const displayKind = rawKind === 'session' || rawKind === 'weekly_all' || rawKind === 'weekly_scoped' + ? rawKind : entry.group === 'session' ? 'session' : entry.group === 'weekly' ? 'weekly_scoped' : null; + if (displayKind === null) { ambiguous = true; continue; } + const weekly = rawKind === 'weekly_all' || rawKind === 'weekly_scoped'; + const kind = weekly ? rawKind : 'other'; + const key = rawKind === 'weekly_all' ? 'all' : rawKind === 'weekly_scoped' ? scopeKey(entry) : null; + if (rawKind !== 'session' && !weekly) ambiguous = true; + rows.push({ + rank: displayKind === 'session' ? 0 : displayKind === 'weekly_all' ? 1 : 2, + key, + window: { slot: index, kind, usedPercent: percent(entry.percent), resetAt: reset(entry.resets_at), + periodMs: weekly ? QUOTA_PACE_WEEK_MS : rawKind === 'session' ? 5 * 60 * 60_000 : null }, + }); + } + // Stable bounded ordering matches normalizeQuotaWindows; slots are assigned + // before adapters filter session/unknown rows or liveQuotaWindows drops them. + rows.sort((a, b) => a.rank - b.rank || a.window.slot - b.window.slot); + return { source: 'generic', rows: rows.map((row, slot) => ({ ...row, window: { ...row.window, slot } })), ambiguous }; + } + if (entries !== null && entries !== undefined && !Array.isArray(entries)) { + return { source: 'generic', rows, ambiguous: true }; + } + const legacy = [ + ['five_hour', 'other', null], ['seven_day', 'weekly_all', 'all'], + ['seven_day_opus', 'weekly_scoped', 'legacy-opus'], ['seven_day_sonnet', 'weekly_scoped', 'legacy-sonnet'], + ] as const; + for (const [field, kind, key] of legacy) { + const limit = usage[field]; + if (limit === null || limit === undefined) continue; + if (!record(limit)) { ambiguous = true; continue; } + rows.push({ rank: 0, key, window: { + slot: rows.length, kind, usedPercent: percent(limit.utilization), resetAt: reset(limit.resets_at), + periodMs: kind === 'other' ? 5 * 60 * 60_000 : QUOTA_PACE_WEEK_MS, + } }); + } + return { source: 'legacy', rows, ambiguous }; +} + +function weeklyRows(projection: Projection): readonly SourceRow[] { + return projection.rows.filter(row => row.window.kind !== 'other'); +} +function validRows(rows: readonly SourceRow[], observedAt: number | null): boolean { + if (observedAt === null || rows.length === 0) return false; + const identities = new Set(); + return rows.every(({ key, window }) => { + if (key === null || identities.has(key)) return false; + identities.add(key); + return Number.isFinite(window.usedPercent) && window.resetAt !== null && + window.resetAt >= QUOTA_PACE_WEEK_MS && observedAt >= window.resetAt - QUOTA_PACE_WEEK_MS && observedAt < window.resetAt; + }); +} +function sameIdentity(anchor: Anchor, next: Anchor): boolean { + return next.account === anchor.account && next.source === anchor.source && next.rows.length === anchor.rows.length && + next.rows.every((row, index) => row.key === anchor.rows[index].key && + row.window.slot === anchor.rows[index].window.slot && row.window.kind === anchor.rows[index].window.kind); +} +function continuous(anchor: Anchor, next: Anchor, requireRollover: boolean): boolean { + if (!sameIdentity(anchor, next) || next.observedAt < anchor.observedAt) return false; + return next.rows.every((row, index) => { + const prior = anchor.rows[index]; + const current = row.window; + const previous = prior.window; + if (row.key !== prior.key || current.slot !== previous.slot || current.kind !== previous.kind) return false; + if (current.resetAt === previous.resetAt) { + return !requireRollover && current.usedPercent >= previous.usedPercent && + (next.observedAt !== anchor.observedAt || current.usedPercent === previous.usedPercent); + } + return current.resetAt === previous.resetAt! + QUOTA_PACE_WEEK_MS && next.observedAt >= previous.resetAt!; + }); +} + +/** One owner per runtime, retained across settings toggles and account changes. + * project() consumes only existing successful snapshots; no I/O, subscriptions, + * persistence, scan, timers or clock. Work/state per window is bounded by 16. + * Weak keys retain original fetch metadata without retaining raw responses. + * Do not recreate this owner on repaint or use persisted quota history as input. + */ +export class ClaudeQuotaPaceSource { + private readonly original = new WeakMap(); + private anchor: Anchor | null = null; + // At most one separately verified changed identity, each with at most 16 + // rows. It cannot confer trust until its own ordinary seven-day rollover. + private pending: Anchor | null = null; + private latched = false; + + project(input: ClaudeQuotaPaceSourceInput): QuotaPaceAdapterSnapshot | null { + if (input.enabled !== true) return null; + const usage = input.usage; + if (!record(usage)) return null; + const suppliedAccount = fingerprint(input.verifiedAccountFingerprint); + let original = this.original.get(usage); + if (!original) { + original = { observedAt: timestamp(input.observedAt), account: suppliedAccount }; + this.original.set(usage, original); + } + const { observedAt, account } = original; + const projection = projectClaude(usage); + const rows = weeklyRows(projection); + const valid = !projection.ambiguous && validRows(rows, observedAt); + const sameAttribution = account === suppliedAccount; + let trust: QuotaPaceAdapterSnapshot['accountWindowTrust'] = projection.ambiguous ? 'ambiguous' : 'unknown'; + const next: Anchor | null = valid && account !== null && sameAttribution + ? { account, source: projection.source, observedAt: observedAt!, rows } : null; + if (this.anchor) { + let recovered = next && continuous(this.anchor, next, this.latched) ? next : null; + if (!recovered && next && !sameIdentity(this.anchor, next)) { + if (this.pending && sameIdentity(this.pending, next)) { + if (continuous(this.pending, next, true)) { + recovered = next; + } else if (continuous(this.pending, next, false) && + next.rows.every((row, index) => row.window.resetAt === this.pending!.rows[index].window.resetAt)) { + // Advance same-window high-water values, but retain the reset basis + // through drops, early drift and partially rolled scoped rowsets. + this.pending = next; + } + } else if (next.observedAt >= this.anchor.observedAt && + (!this.pending || next.observedAt >= this.pending.observedAt)) { + this.pending = next; + } + } + if (recovered) { + this.anchor = recovered; + this.pending = null; + this.latched = false; + trust = 'trusted'; + } else { + // Keep the last good high-water/reset/identity anchor, never the bad row. + // A later repaint or restored higher counter cannot silently bless it. + this.latched = true; + trust = 'discontinuous'; + } + } else if (next) { + this.anchor = next; + trust = 'trusted'; + } + // Private keys/account evidence never enter the numeric adapter DTO. + // Copy values so callers cannot mutate the continuity anchor through it. + return { observedAt, source: 'observed', accountWindowTrust: trust, + windows: projection.rows.map(row => ({ ...row.window })) }; + } +} + +export interface CodexQuotaPaceSourceInput { + readonly enabled: boolean; + readonly snapshot: ProviderLimitSnapshot | null; +} + +/** Strict display join, before the normalizer's intentionally tolerant zero. + * Reuses the same bounded raw projection without account/state/clock work. + */ +export function matchesClaudeQuotaPaceWindow(usage: ClaudeApiUsageResponse | null, + evidence: QuotaPaceAdapterWindow): boolean { + if (!record(usage) || !Number.isInteger(evidence.slot) || evidence.slot < 0 || + evidence.slot >= QUOTA_PACE_MAX_WINDOWS) return false; + const projection = projectClaude(usage); + if (projection.ambiguous) return false; + const actual = projection.rows[evidence.slot]?.window; + return !!actual && actual.kind !== 'other' && actual.kind === evidence.kind && + Number.isFinite(actual.usedPercent) && actual.usedPercent === evidence.usedPercent && + actual.resetAt !== null && actual.resetAt === evidence.resetAt && actual.periodMs === evidence.periodMs; +} + +/** Existing Codex local-log snapshots carry no verified account signal. Numeric + * weekly rows are useful display joins but ALWAYS remain unknown account trust. + * Never infer identity from limitId, path, confidence or single-window count. + */ +export function projectCodexQuotaPaceSource(input: CodexQuotaPaceSourceInput): QuotaPaceAdapterSnapshot | null { + if (input.enabled !== true) return null; + const snapshot = input.snapshot; + if (!snapshot || snapshot.provider !== 'codex' || snapshot.source !== 'local-log') return null; + const rawWindows = snapshot.windows; + const windows: QuotaPaceAdapterWindow[] = []; + if (Array.isArray(rawWindows) && rawWindows.length <= QUOTA_PACE_MAX_WINDOWS) { + for (let slot = 0; slot < rawWindows.length; slot++) { + const row = rawWindows[slot]; + if (!record(row)) continue; + const periodMs = typeof row.windowMinutes === 'number' && Number.isFinite(row.windowMinutes) && row.windowMinutes > 0 + && Number.isFinite(row.windowMinutes * 60_000) ? row.windowMinutes * 60_000 : null; + windows.push({ slot, kind: periodMs === QUOTA_PACE_WEEK_MS ? 'weekly_all' : 'other', + usedPercent: percent(row.usedPercent), resetAt: timestamp(row.resetsAt), periodMs }); + } + } + return { observedAt: timestamp(snapshot.observedAt), source: 'observed', accountWindowTrust: 'unknown', windows }; +} diff --git a/src/refreshDiagnostics.ts b/src/refreshDiagnostics.ts index 4e057f5e..a37e0974 100644 --- a/src/refreshDiagnostics.ts +++ b/src/refreshDiagnostics.ts @@ -2,6 +2,7 @@ import { RefreshTrigger } from './refreshPolicy'; import { ProviderSourceOutcome } from './providers/providerTypes'; import { CodexIndexRecoveryReason } from './providers/codex/codexIndex'; import { CodexIndexErrorCode, safeCodexIndexErrorCode } from './providers/codex/codexWorkerProtocol'; +import type { CodexProviderFailureCode } from './providers/codex/codexProvider'; export interface LoadUsageDiagnostics { filesDiscovered: number; @@ -11,22 +12,69 @@ export interface LoadUsageDiagnostics { readParseMs: number; } -export interface RefreshDiagnostic extends LoadUsageDiagnostics { +export type ClaudeRefreshStage = 'discovery' | 'manifest' | 'planning' | 'read-parse' | + 'aggregate' | 'content-analysis' | 'materialize' | 'publish' | 'commit'; +export type ClaudeRefreshFailureReason = 'range-error' | 'io-error' | 'read-failed' | + 'source-unavailable' | 'refresh-failed'; + +export interface RefreshDiagnostic { trigger: RefreshTrigger; - filesChanged: number; - filesReused: number; - filesRemoved: number; + filesDiscovered: number | null; + filesFailed: number | null; + bytesRead: number | null; + linesParsed: number | null; + readParseMs: number | null; + filesChanged: number | null; + filesReused: number | null; + filesRemoved: number | null; watcherEvents: number; coalescedTriggers: number; quotaWatcherMissingFilenameEvents: number; - manifestMs: number; - aggregateRenderMs: number; + manifestMs: number | null; + aggregateRenderMs: number | null; totalMs: number; - bodyReads?: number; - aggregateMutations?: number; + bodyReads?: number | null; + aggregateMutations?: number | null; + failureStage?: ClaudeRefreshStage; + failureReason?: ClaudeRefreshFailureReason; +} + +const ms = (value: number | null): string => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value.toFixed(1) : 'unknown'; +const observedCount = (value: number | null | undefined): string => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? String(value) : 'unknown'; + +const KNOWN_CLAUDE_STAGES = new Set([ + 'discovery', 'manifest', 'planning', 'read-parse', 'aggregate', + 'content-analysis', 'materialize', 'publish', 'commit', +]); +const KNOWN_CLAUDE_FAILURES = new Set([ + 'range-error', 'io-error', 'read-failed', 'source-unavailable', 'refresh-failed', +]); + +/** Never include arbitrary exception names, messages, codes or stacks. */ +export function claudeRefreshFailureReason(error: unknown): ClaudeRefreshFailureReason { + if (error instanceof RangeError) return 'range-error'; + const code = error instanceof Error ? Object.getOwnPropertyDescriptor(error, 'code')?.value : undefined; + if (typeof code === 'string' && + ['ENOENT', 'EACCES', 'EPERM', 'EIO', 'EMFILE', 'ENFILE'].includes(code)) { + return 'io-error'; + } + return 'refresh-failed'; } -const ms = (value: number): string => value.toFixed(1); +/** One diagnostic per fixed phase/reason pair for the entire host lifetime. + * There are at most 60 sanitized pairs, independent of paths/exception text. */ +export class ClaudeFailureDiagnosticBudget { + private readonly reported = new Set(); + + accept(stage: unknown, reason: unknown): boolean { + const key = safeEnum(stage, KNOWN_CLAUDE_STAGES) + ':' + safeEnum(reason, KNOWN_CLAUDE_FAILURES); + if (this.reported.has(key)) return false; + this.reported.add(key); + return true; + } +} const KNOWN_REFRESH_TRIGGERS = new Set([ 'startup', @@ -47,6 +95,10 @@ const KNOWN_PROVIDER_OUTCOMES = new Set([ ]); const KNOWN_BACKFILL_MODES = new Set(['steady', 'historical', 'unknown']); const KNOWN_WORKER_MODES = new Set(['background', 'foreground', 'unknown']); +const KNOWN_CODEX_FAILURE_CODES = new Set([ + 'cancelled', 'busy', 'worker-failed', 'disposed', 'refresh-failed', +]); +const KNOWN_CODEX_COVERAGE_SOURCES = new Set(['current-scan', 'last-snapshot', 'unknown']); function safeEnum(value: unknown, allowed: ReadonlySet): string { return typeof value === 'string' && allowed.has(value) ? value : 'unknown'; @@ -73,14 +125,18 @@ const KNOWN_CODEX_QUALITY_FLAGS = new Set([ export function formatRefreshDiagnostic(value: RefreshDiagnostic): string { const incremental = value.bodyReads === undefined && value.aggregateMutations === undefined ? '' - : `incremental(bodies=${value.bodyReads ?? 0} ` + - `aggregate-mutations=${value.aggregateMutations ?? 0}) `; + : `incremental(bodies=${observedCount(value.bodyReads)} ` + + `aggregate-mutations=${observedCount(value.aggregateMutations)}) `; + const failure = value.failureStage === undefined && value.failureReason === undefined ? '' : + `failure(stage=${safeEnum(value.failureStage, KNOWN_CLAUDE_STAGES)} ` + + `reason=${safeEnum(value.failureReason, KNOWN_CLAUDE_FAILURES)}) `; return `refresh: trigger=${safeEnum(value.trigger, KNOWN_REFRESH_TRIGGERS)} ` + - `files(discovered=${value.filesDiscovered} changed=${value.filesChanged} ` + - `reused=${value.filesReused} removed=${value.filesRemoved} failed=${value.filesFailed}) ` + - `io(bytes=${value.bytesRead} lines=${value.linesParsed}) ${incremental}` + - `events(watcher=${value.watcherEvents} coalesced=${value.coalescedTriggers} ` + - `quota-unnamed=${value.quotaWatcherMissingFilenameEvents}) ` + + failure + + `files(discovered=${observedCount(value.filesDiscovered)} changed=${observedCount(value.filesChanged)} ` + + `reused=${observedCount(value.filesReused)} removed=${observedCount(value.filesRemoved)} failed=${observedCount(value.filesFailed)}) ` + + `io(bytes=${observedCount(value.bytesRead)} lines=${observedCount(value.linesParsed)}) ${incremental}` + + `events(watcher=${observedCount(value.watcherEvents)} coalesced=${observedCount(value.coalescedTriggers)} ` + + `quota-unnamed=${observedCount(value.quotaWatcherMissingFilenameEvents)}) ` + `ms(manifest=${ms(value.manifestMs)} read-parse=${ms(value.readParseMs)} ` + `aggregate-render=${ms(value.aggregateRenderMs)} total=${ms(value.totalMs)})`; } @@ -92,19 +148,21 @@ export interface CodexIndexDiagnostic { coalescedTriggers: number; backfillMode: 'steady' | 'historical' | 'unknown'; workerMode: 'background' | 'foreground' | 'unknown'; - indexedFiles: number; - totalFiles: number; - indexedBytes: number; - totalBytes: number; - periodMigratedBytes: number; - periodTotalBytes: number; - migrationPending: boolean; - indexRecovery?: CodexIndexRecoveryReason; + coverageSource?: 'current-scan' | 'last-snapshot' | 'unknown'; + indexedFiles: number | null; + totalFiles: number | null; + indexedBytes: number | null; + totalBytes: number | null; + periodMigratedBytes: number | null; + periodTotalBytes: number | null; + migrationPending: boolean | null; + indexRecovery?: CodexIndexRecoveryReason | null; errorCode?: CodexIndexErrorCode; - bodyReads: number; - failedFiles: number; - metadataMs: number; - parseMs: number; + failureCode?: CodexProviderFailureCode; + bodyReads: number | null; + failedFiles: number | null; + metadataMs: number | null; + parseMs: number | null; qualityFlags: Record; } @@ -128,22 +186,30 @@ export function formatCodexIndexDiagnostic(value: CodexIndexDiagnostic): string value.indexRecovery === 'invalid-json' || value.indexRecovery === 'unsupported-schema' ? value.indexRecovery - : 'none'; + : value.indexRecovery === null ? 'unknown' : 'none'; const trigger = safeEnum(value.trigger, KNOWN_REFRESH_TRIGGERS); const outcome = safeEnum(value.outcome, KNOWN_PROVIDER_OUTCOMES); const backfillMode = safeEnum(value.backfillMode, KNOWN_BACKFILL_MODES); const workerMode = safeEnum(value.workerMode, KNOWN_WORKER_MODES); + const failure = value.failureCode === undefined + ? '' + : `failure=${safeEnum(value.failureCode, KNOWN_CODEX_FAILURE_CODES)} `; + const coverage = value.coverageSource === undefined + ? '' + : `coverage=${safeEnum(value.coverageSource, KNOWN_CODEX_COVERAGE_SOURCES)} `; + const migrationPending = value.migrationPending === true ? 'true' + : value.migrationPending === false ? 'false' : 'unknown'; return ( `codex-index trigger=${trigger} outcome=${outcome} ` + `mode(backfill=${backfillMode} worker=${workerMode}) ` + `events(watcher=${value.watcherEvents} coalesced=${value.coalescedTriggers}) ` + - `files=${value.indexedFiles}/${value.totalFiles} ` + - `bytes=${value.indexedBytes}/${value.totalBytes} ` + - `periodBytes=${value.periodMigratedBytes}/${value.periodTotalBytes} ` + - `migrationPending=${value.migrationPending} ` + - `recovery=${recovery} ` + + coverage + `files=${observedCount(value.indexedFiles)}/${observedCount(value.totalFiles)} ` + + `bytes=${observedCount(value.indexedBytes)}/${observedCount(value.totalBytes)} ` + + `periodBytes=${observedCount(value.periodMigratedBytes)}/${observedCount(value.periodTotalBytes)} ` + + `migrationPending=${migrationPending} ` + + `recovery=${recovery} ` + failure + (value.errorCode === undefined ? '' : `errorCode=${safeCodexIndexErrorCode(value.errorCode)} `) + - `bodyReads=${value.bodyReads} failed=${value.failedFiles} ` + + `bodyReads=${observedCount(value.bodyReads)} failed=${observedCount(value.failedFiles)} ` + `metadataMs=${ms(value.metadataMs)} parseMs=${ms(value.parseMs)} ` + `flags=${safeFlagCounts(value.qualityFlags)}` ); diff --git a/src/refreshPolicy.ts b/src/refreshPolicy.ts index 5537f748..4a3ef4ec 100644 --- a/src/refreshPolicy.ts +++ b/src/refreshPolicy.ts @@ -56,6 +56,45 @@ export function watcherFailureBackoffMs(failStreak: number): number { return Math.min(60_000, 1_000 * Math.pow(2, exponent)); } +/** One runtime revision, not a retry queue or persisted source history. The + * normal scheduler supplies attempts; this gate never creates another timer. + * A revision changes only with data/price/parse authority, not presentation. */ +export class ClaudeRefreshRecovery { + private revision: string | undefined; + private failStreak = 0; + private automaticRetryAt = 0; + private manualRetryAt = 0; + + constructor(private readonly now: () => number = () => performance.now()) {} + + admit(revision: string, trigger: RefreshTrigger): boolean { + if (revision !== this.revision) { + this.revision = revision; + this.reset(); + } + return this.failStreak === 0 || this.now() >= + (trigger === 'manual' ? this.manualRetryAt : this.automaticRetryAt); + } + + fail(revision: string): void { + if (revision !== this.revision) return; + this.failStreak = Math.min(6, this.failStreak + 1); + const now = this.now(); + this.automaticRetryAt = now + Math.min(900_000, 30_000 * 2 ** (this.failStreak - 1)); + this.manualRetryAt = now + 5_000; + } + + succeed(revision: string): void { + if (revision === this.revision) this.reset(); + } + + private reset(): void { + this.failStreak = 0; + this.automaticRetryAt = 0; + this.manualRetryAt = 0; + } +} + const TRIGGER_PRIORITY: Record = { startup: 0, poll: 1, diff --git a/src/releaseAnnouncements.ts b/src/releaseAnnouncements.ts index 66431e62..77b6fd81 100644 --- a/src/releaseAnnouncements.ts +++ b/src/releaseAnnouncements.ts @@ -16,6 +16,12 @@ export function announcementForUpgrade( if (!enabled || !current || !lastSeen || current === lastSeen) { return null; } + const exactVersion = /^\d+\.\d+\.\d+$/; + if (!exactVersion.test(current) || !exactVersion.test(lastSeen)) return null; + const currentParts = current.split('.').map(Number); + const lastParts = lastSeen.split('.').map(Number); + const changedPart = currentParts.findIndex((value, index) => value !== lastParts[index]); + if (changedPart < 0 || currentParts[changedPart] <= lastParts[changedPart]) return null; return catalog[current] ?? null; } diff --git a/src/settings.ts b/src/settings.ts index 8502830c..7a891f9d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -401,6 +401,26 @@ export const SETTINGS: SettingDef[] = [ help: 'On by default. Show the single preview-first sharing workspace. Turn it off to hide sharing UI; the legacy export commands still open the matching preview explicitly.', providers: ['claude', 'codex'], }, + { + key: 'enableAchievements', + type: 'boolean', + default: true, + storage: 'state', + group: 'features', + label: 'Show local achievements', + help: 'On by default. Recognize evidence-backed local usage milestones and show the Achievements tab. Turning it off hides the tab and stops new grants without deleting existing badges.', + providers: ['claude', 'codex'], + }, + { + key: 'showAchievementNotices', + type: 'boolean', + default: true, + storage: 'state', + group: 'features', + label: 'Show achievement notices', + help: 'Show brief in-panel unlock notices. Turning them off keeps achievements and unread records.', + providers: ['claude', 'codex'], + }, { key: 'showCostliestMessages', type: 'boolean', @@ -534,6 +554,16 @@ export const SETTINGS: SettingDef[] = [ group: 'statusBar', label: 'Show 5h / weekly quota', }, + { + key: 'showWeeklyQuotaPace', + type: 'boolean', + default: false, + storage: 'state', + group: 'statusBar', + providers: ['claude', 'codex'], + label: 'Weekly quota pace', + help: 'Off by default. Compare account-wide weekly use with uniform progress to the quota reset. The start is inferred as reset minus seven days; this cycle-average estimate includes other devices and members. No forecast in the first six hours or after thirty minutes without a fresh observation. Uses existing snapshots and does not enable quota tracking.', + }, { // Opt-in model-scoped weekly caps in the status bar. Was showOpusWeekly // (PR #38, @wheelbarrel00) until the API began naming the scope itself. diff --git a/src/sharingCommandIntent.ts b/src/sharingCommandIntent.ts index 16e9c8b3..134d1e9e 100644 --- a/src/sharingCommandIntent.ts +++ b/src/sharingCommandIntent.ts @@ -1,4 +1,4 @@ -export type SharingTemplate = 'combinedHeatmap' | 'claudeShareCard' | 'claudeHeatmap'; +export type SharingTemplate = 'combinedHeatmap' | 'claudeShareCard' | 'claudeHeatmap' | 'achievements'; export interface SharingCommandIntent { revision: number; diff --git a/src/statusBar.ts b/src/statusBar.ts index bfa38f78..23dd89a6 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { ClaudeApiUsageResponse, ContextWindowInfo, UsageData } from './types'; -import { I18n } from './i18n'; +import { I18n, weeklyQuotaPaceCopy } from './i18n'; import { CONTEXT_FILL_THRESHOLDS, QUOTA_FILL_THRESHOLDS, @@ -32,6 +32,13 @@ import { } from './codexStatus'; import { CodexUsageScopeView } from './providers/codex/codexUsage'; import { ProviderLimitSnapshot } from './providers/providerTypes'; +import { + adaptClaudeQuotaPace, + adaptCodexQuotaPace, + QuotaPaceAdapterEntry, + QuotaPaceAdapterSnapshot, + QUOTA_PACE_MAX_WINDOWS, +} from './quotaPaceAdapters'; export class StatusBarManager { private statusBarItem: vscode.StatusBarItem; @@ -49,6 +56,9 @@ export class StatusBarManager { // (@wheelbarrel00), which named a single model; the API now scopes these caps // itself, so the label follows whatever it reports. private showScopedWeekly: boolean = false; + private showWeeklyQuotaPace: boolean = false; + private lastClaudePace: QuotaPaceAdapterSnapshot | null = null; + private previousClaudePace: QuotaPaceAdapterSnapshot | null = null; // Quota display preferences. private quotaFiveHourOnly: boolean = false; // show only the 5h window private showResetInBar: boolean = false; // append reset countdown to the bar @@ -67,6 +77,8 @@ export class StatusBarManager { scope: CodexUsageScopeView; metric: CodexStatusMetric; limit: ProviderLimitSnapshot | null; + pace: QuotaPaceAdapterSnapshot | null; + previousPace: QuotaPaceAdapterSnapshot | null; } | null = null; constructor() { @@ -106,6 +118,8 @@ export class StatusBarManager { this.lastCodex.scope, this.lastCodex.metric, this.lastCodex.limit, + this.lastCodex.pace, + this.lastCodex.previousPace, ); } else { this.statusBarItem.text = 'CX —'; @@ -125,7 +139,7 @@ export class StatusBarManager { this.lastClaudeUsage.monthData, ); } - this.updateQuota(this.lastClaudeQuota); + this.updateQuota(this.lastClaudeQuota, this.lastClaudePace, this.previousClaudePace); this.updateContext(this.lastClaudeContext); } @@ -141,7 +155,8 @@ export class StatusBarManager { quotaFiveHourOnly: boolean = false, showResetInBar: boolean = false, resetCountdownFormat: ResetCountdownFormat = 'decimal', - quotaFormat: string = '' + quotaFormat: string = '', + showWeeklyQuotaPace: boolean = false, ): void { this.showCost = showCost; this.showContext = showContext; @@ -152,6 +167,15 @@ export class StatusBarManager { this.showResetInBar = showResetInBar; this.resetCountdownFormat = resetCountdownFormat; this.quotaFormat = quotaFormat; + this.showWeeklyQuotaPace = showWeeklyQuotaPace && usageLimitTracking; + if (!this.showWeeklyQuotaPace) { + this.lastClaudePace = null; + this.previousClaudePace = null; + if (this.lastCodex) { + this.lastCodex.pace = null; + this.lastCodex.previousPace = null; + } + } if (!showContext) { this.contextItem.hide(); } @@ -165,7 +189,11 @@ export class StatusBarManager { this.lastCodex.scope, this.lastCodex.metric, this.lastCodex.limit, + this.lastCodex.pace, + this.lastCodex.previousPace, ); + } else if (this.provider === 'claude' && this.lastClaudeQuota) { + this.updateQuota(this.lastClaudeQuota, this.lastClaudePace, this.previousClaudePace); } } @@ -327,8 +355,14 @@ export class StatusBarManager { * OAuth usage API. Hidden when the data is unavailable (e.g. not signed in). * Public so it can be refreshed on its own while the rest of the UI is idle. */ - updateQuota(usageLimits: ClaudeApiUsageResponse | null): void { + updateQuota( + usageLimits: ClaudeApiUsageResponse | null, + pace: QuotaPaceAdapterSnapshot | null = null, + previousPace: QuotaPaceAdapterSnapshot | null = null, + ): void { this.lastClaudeQuota = usageLimits; + this.lastClaudePace = this.showWeeklyQuotaPace && this.usageLimitTracking ? pace : null; + this.previousClaudePace = this.lastClaudePace ? previousPace : null; if (this.provider === 'codex') { return; } @@ -369,18 +403,60 @@ export class StatusBarManager { // the official Claude app does — QUOTA_FILL_THRESHOLDS). this.quotaItem.backgroundColor = this.fillBackground(fillLevel(worstPct, QUOTA_FILL_THRESHOLDS)); - this.quotaItem.tooltip = this.createQuotaTooltip(live, creditsFromUsage(usageLimits)); + const tooltip = this.createQuotaTooltip(live, creditsFromUsage(usageLimits)); + if (this.showWeeklyQuotaPace && this.lastClaudePace) { + const raw = normalizeQuotaWindows(usageLimits); + const entries = adaptClaudeQuotaPace({ enabled: true, now: Date.now(), snapshot: this.lastClaudePace, previous: this.previousClaudePace }); + this.appendQuotaPace(tooltip, entries, (entry) => { + const window = raw[entry.slot]; + const evidence = this.lastClaudePace?.windows.find((w) => w.slot === entry.slot); + if (!window || !evidence || raw.length > QUOTA_PACE_MAX_WINDOWS || window.kind !== entry.kind || + !this.hasRawClaudePaceNumber(usageLimits, window) || + window.utilization !== evidence.usedPercent || Date.parse(window.resetsAt) !== evidence.resetAt) return null; + return this.quotaRowLabel(window); + }); + } + this.quotaItem.tooltip = tooltip; this.quotaItem.show(); } + /** normalizeQuotaWindows intentionally tolerates invalid numeric fields for + * the existing quota display. That fallback has no prediction authority. + * Unknown weekly-group kinds also cannot establish a seven-day duration. + */ + private hasRawClaudePaceNumber(usage: ClaudeApiUsageResponse | null, window: QuotaWindow): boolean { + if (!usage) return false; + const entries = usage.limits; + if (Array.isArray(entries) && entries.length > 0) { + if (entries.length > QUOTA_PACE_MAX_WINDOWS) return false; + const usesEntries = entries.some((entry) => ['session', 'weekly_all', 'weekly_scoped'].includes(entry.kind ?? '') || + entry.group === 'session' || entry.group === 'weekly'); + if (usesEntries) { + const matches = entries.filter((entry) => entry.kind === window.kind && + (window.kind === 'weekly_all' || (entry.scope?.model?.display_name || entry.scope?.surface || undefined) === window.scopeLabel)); + return matches.length === 1 && typeof matches[0].percent === 'number' && + Number.isFinite(matches[0].percent) && matches[0].percent === window.utilization && + matches[0].resets_at === window.resetsAt; + } + } + const legacy = window.kind === 'weekly_all' ? usage.seven_day : + window.scopeLabel === 'Opus' ? usage.seven_day_opus : + window.scopeLabel === 'Sonnet' ? usage.seven_day_sonnet : null; + return !!legacy && typeof legacy.utilization === 'number' && Number.isFinite(legacy.utilization) && + legacy.utilization === window.utilization && legacy.resets_at === window.resetsAt; + } + updateCodex( scope: CodexUsageScopeView, metric: CodexStatusMetric, limit: ProviderLimitSnapshot | null, + pace: QuotaPaceAdapterSnapshot | null = null, + previousPace: QuotaPaceAdapterSnapshot | null = null, ): void { - this.lastCodex = { scope, metric, limit }; + const retainedPace = this.showWeeklyQuotaPace && this.usageLimitTracking ? pace : null; + this.lastCodex = { scope, metric, limit, pace: retainedPace, previousPace: retainedPace ? previousPace : null }; if (this.provider === 'codex') { - this.renderCodex(scope, metric, limit); + this.renderCodex(scope, metric, limit, this.lastCodex.pace, this.lastCodex.previousPace); } } @@ -399,6 +475,8 @@ export class StatusBarManager { scope: CodexUsageScopeView, metric: CodexStatusMetric, limit: ProviderLimitSnapshot | null, + pace: QuotaPaceAdapterSnapshot | null = null, + previousPace: QuotaPaceAdapterSnapshot | null = null, ): void { const now = Date.now(); const quotaOptions = { quotaFiveHourOnly: this.quotaFiveHourOnly }; @@ -434,7 +512,7 @@ export class StatusBarManager { limit ) { this.quotaItem.text = `$(dashboard) ${formatted.limitText}`; - this.quotaItem.tooltip = this.createCodexQuotaTooltip(limit, now); + this.quotaItem.tooltip = this.createCodexQuotaTooltip(limit, now, pace, previousPace); this.quotaItem.backgroundColor = this.fillBackground( fillLevel(codexQuotaWarningPercent(limit, now, quotaOptions), QUOTA_FILL_THRESHOLDS), ); @@ -577,7 +655,12 @@ export class StatusBarManager { * Render them with the exact table, progress bar, reset-cell, and threshold * vocabulary used by Claude so switching providers does not change the * interaction model. */ - private createCodexQuotaTooltip(limit: ProviderLimitSnapshot, now: number): vscode.MarkdownString { + private createCodexQuotaTooltip( + limit: ProviderLimitSnapshot, + now: number, + pace: QuotaPaceAdapterSnapshot | null = null, + previousPace: QuotaPaceAdapterSnapshot | null = null, + ): vscode.MarkdownString { const t = I18n.t.popup; const copy = I18n.t.providers.codex; const live = visibleCodexQuotaWindows(limit, now, { @@ -618,9 +701,49 @@ export class StatusBarManager { for (const note of copy.accountSnapshotLastObserved.split(' · ')) { md.appendMarkdown(` \n*${note}*`); } + // A known model-specific allowance cannot be promoted by a generic label. + // This excludes incompatible series; account trust still comes from DTO + // evidence and is never inferred merely from limitId/name or Codex home. + const seriesId = (limit.limitId ?? '').trim().toLowerCase(); + const seriesName = (limit.limitName ?? '').trim().toLowerCase(); + if (this.showWeeklyQuotaPace && pace && limit.provider === 'codex' && + limit.source === 'local-log' && (limit.confidence === 'last-observed' || limit.confidence === 'exact') && + (seriesId ? seriesId === 'codex' : !seriesName || seriesName === 'codex') && + pace.observedAt === limit.observedAt && + limit.windows.length <= QUOTA_PACE_MAX_WINDOWS && + limit.windows.filter((window) => window.windowMinutes === 10080).length === 1) { + const entries = adaptCodexQuotaPace({ enabled: true, now, snapshot: pace, previous: previousPace }); + this.appendQuotaPace(md, entries, (entry) => { + const window = limit.windows[entry.slot]; + const evidence = pace.windows.find((w) => w.slot === entry.slot); + return window && evidence && window.windowMinutes === 10080 && + window.usedPercent === evidence.usedPercent && window.resetsAt === evidence.resetAt + ? t.quotaWeekly : null; + }); + } return md; } + private appendQuotaPace( + md: vscode.MarkdownString, + entries: readonly QuotaPaceAdapterEntry[], + labelFor: (entry: QuotaPaceAdapterEntry) => string | null, + ): void { + const copy = weeklyQuotaPaceCopy(I18n.getCurrentLanguage()); + let body = ''; + for (const entry of entries) { + const result = entry.result; + if (result.status !== 'ready' && result.status !== 'insufficient-sample') continue; + const label = labelFor(entry); + if (!label) continue; + const forecast = result.status === 'ready' + ? `${copy.forecast} ${Math.round(result.forecastPercent)}%` : copy.sample; + body += `${this.escapeTooltipHtml(label)}: ${copy.used} ${Math.round(result.usedPercent)}% · ` + + `${copy.reference} ${Math.round(result.referencePercent)}% · ${forecast} \n`; + } + if (body) md.appendMarkdown(`\n\n**${copy.title}**\n\n${body}\n*${copy.hint}*`); + } + /** Provider labels can originate in local structured observations. Escape * them before inserting them into the HTML-enabled Markdown tooltip. */ private escapeTooltipHtml(value: string): string { diff --git a/src/test/achievementActivity.test.ts b/src/test/achievementActivity.test.ts new file mode 100644 index 00000000..ea6476f5 --- /dev/null +++ b/src/test/achievementActivity.test.ts @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { + emptyAchievementActivity, + migrateAchievementActivityV1, + parseAchievementActivity, + recordAchievementSettingChange, + recordAchievementFeature, +} from '../achievementActivity'; + +test('setting exploration counts only distinct saved user changes from a safe allowlist', () => { + const start = emptyAchievementActivity(); + const first = recordAchievementSettingChange(start, 'language', true); + assert.deepEqual(first.settingKeys, ['language']); + assert.equal(recordAchievementSettingChange(first, 'language', true), first); + assert.equal(recordAchievementSettingChange(first, 'timezone', false), first); + assert.equal(recordAchievementSettingChange(first, 'advice.apiKey', true), first); + assert.equal(recordAchievementSettingChange(first, 'advice.promptPersonalizationConsent', true), first); + assert.equal(recordAchievementSettingChange(first, 'showAchievementNotices', true), first, + 'quiet-notice opt-out must not count as exploration'); + assert.deepEqual(recordAchievementSettingChange(first, 'displayCurrency', true).settingKeys, + ['displayCurrency', 'language']); + assert.deepEqual(start.settingKeys, []); +}); + +test('feature evidence records a successful local export once without a target or payload', () => { + const exported = recordAchievementFeature(emptyAchievementActivity(), 'localShareExport'); + assert.deepEqual(exported.features, ['localShareExport']); + assert.equal(recordAchievementFeature(exported, 'localShareExport'), exported); + assert.deepEqual(Object.keys(exported).sort(), ['features', 'schemaVersion', 'settingKeys']); +}); + +test('safe feature inventory counts deliberate gallery and share preview separately', () => { + const gallery = recordAchievementFeature(emptyAchievementActivity(), 'achievementGalleryViewed'); + const preview = recordAchievementFeature(gallery, 'achievementSharePreview'); + assert.deepEqual(preview.features, ['achievementGalleryViewed', 'achievementSharePreview']); +}); + +test('activity hydration is versioned, bounded and strips unknown or secret-bearing fields', () => { + assert.deepEqual(parseAchievementActivity({ schemaVersion: 99, settingKeys: ['language'] }), + emptyAchievementActivity()); + assert.deepEqual(parseAchievementActivity({ + schemaVersion: 2, + settingKeys: ['language', 'language', 'advice.apiKey', '../auth.json', 'timezone'], + features: ['localShareExport', 'localShareExport', 'publishToGitHub'], + key: 'must-not-survive', path: '/private/input', + }), { + schemaVersion: 2, + settingKeys: ['language', 'timezone'], + features: ['localShareExport'], + }); +}); + +test('legacy preview evidence is not carried into deliberate feature awards', () => { + const migrated = migrateAchievementActivityV1({ + schemaVersion: 1, + settingKeys: ['language', 'timezone'], + features: ['achievementGalleryViewed', 'achievementSharePreview', 'localShareExport'], + }); + assert.deepEqual(migrated, { + schemaVersion: 2, + settingKeys: ['language', 'timezone'], + features: ['achievementGalleryViewed', 'localShareExport'], + }); +}); diff --git a/src/test/achievementCalendar.test.ts b/src/test/achievementCalendar.test.ts new file mode 100644 index 00000000..d7014aaf --- /dev/null +++ b/src/test/achievementCalendar.test.ts @@ -0,0 +1,470 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { CODEX_PERIOD_LINEAGE_VERSION } from '../providers/codex/codexPeriodIndex'; + +// Dynamic access makes the first red run a behavioral failure, not a compile failure. +const calendar = require('../achievementCalendar') as any; +const day = (date: string, processed = 1_000_000, output = 1, model = 'gpt-6-astra') => ({ + day: date, processed, output, models: { [model]: { processed, output } }, +}); +const evidence = (days: unknown[], complete = true) => ({ days, complete, timeZone: 'UTC' }); +const signals = (claude: unknown[], codex: unknown[], extra = {}) => calendar.achievementCalendarSignals({ + claude: evidence(claude), codex: evidence(codex), asOfDay: '2026-09-28', ...extra, +}); + +test('model output days need reconciled daily volume, output and exact family IDs', () => { + const value = signals([], [day('2026-09-01'), + day('2026-09-02', 999_999), day('2026-09-03', 1_000_000, 0), + day('2026-09-04', 1_000_000, 1, 'custom-astra-proxy')]); + assert.equal(value.familyDays.astra, 1); + assert.equal(signals([day('2026-09-01', 1_000_000, 1, 'claude-opus-5')], []).familyDays.opus, 1); + assert.equal(signals([], [day('2026-09-01')], { codex: evidence([day('2026-09-01')], false) }).familyDays.astra, 0); +}); + +test('calendar families share native namespace/date admission and reject custom suffixes', () => { + const value = signals([], [ + day('2026-09-01', 1_000_000, 1, 'openai/gpt-6-sol'), + day('2026-09-02', 1_000_000, 1, 'gpt-6-sol-20261001'), + day('2026-09-03', 1_000_000, 1, 'gpt-6-sol-custom'), + ]); + assert.equal(value.familyDays.sol, 2); + assert.equal(signals([], [day('2026-09-03', 1_000_000, 1, 'gpt-6-sol-custom')]).familyDays.sol, 0); +}); + +test('three-family and dual-provider milestones use same-day joined output evidence', () => { + const rows = ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'].map(model => day('2026-09-01', 1_000_000, 1, model)); + assert.equal(signals([], [{ day: '2026-09-01', processed: 3_000_000, output: 3, + models: Object.assign({}, ...rows.map(row => row.models)) }]).threeFamilyDay, true); + assert.equal(signals([], rows.map((row, index) => ({ ...row, day: `2026-09-0${index + 1}` }))).threeFamilyDay, false); + assert.equal(signals([day('2026-09-01', 1_000_000, 1, 'claude-sonnet-5')], [day('2026-09-01')]).dualProviderDays, 1); + assert.equal(signals([day('2026-09-01', 1_000_000, 0, 'claude-sonnet-5')], [day('2026-09-01')]).dualProviderDay, false); + assert.equal(signals([day('2026-09-01', 1_000_000, 1, 'claude-sonnet-5')], [day('2026-09-02')]).dualProviderDays, 0); +}); + +test('normalized public calendar proof rejects duplicate dates and mismatched explicit zones', () => { + assert.equal(signals([], [day('2026-09-01', 500_000), day('2026-09-01', 500_000)]).familyDays.astra, 0); + assert.equal(signals([], [day('2026-09-01')], { + claude: { ...evidence([]), timeZone: 'Europe/Istanbul' }, + }).familyDays.astra, 0); +}); + +test('Fable qualifies in the same seven-family daily system, while tiny output cannot manufacture active weeks', () => { + const models = Object.fromEntries(['claude-fable-5-1', 'claude-sonnet-5', 'claude-opus-5'] + .map(model => [model, { processed: 1_000_000, output: 1 }])); + const value = signals([{ day: '2026-09-01', processed: 3_000_000, output: 3, models }], []); + assert.equal(value.threeFamilyDay, true); + assert.equal(value.familyDays.fable, 1); + assert.equal(signals(['01', '02', '03', '04'].map(date => + day(`2026-09-${date}`, 999_999, 1, 'claude-sonnet-5')), []).activeWeeks, 0); +}); + +test('highest weekly average needs four closed consecutive calendar weeks and includes zero weeks', () => { + const rows = ['2026-08-31', '2026-09-07', '2026-09-14', '2026-09-21'].map(date => day(date, 2_000_000_000)); + assert.equal(signals([], rows).peakFourWeekAverage, 2_000_000_000); + assert.equal(signals([], rows.slice(1)).peakFourWeekAverage, 0, 'short history is not a four-week record'); + assert.equal(signals([], [rows[0], rows[3]]).peakFourWeekAverage, 1_000_000_000, 'inactive weeks stay in the denominator'); + assert.equal(signals([], [...rows, day('2026-09-28', 999_000_000_000)]).peakFourWeekAverage, 2_000_000_000, 'current week excluded'); + assert.equal(signals([], rows, { codex: evidence(rows, false) }).peakFourWeekAverage, 0); +}); + +test('calendar evidence rejects invalid dates, future days and unsafe totals', () => { + const rows = [day('2026-02-30'), day('2026-10-01'), day('2026-09-01', Number.MAX_SAFE_INTEGER), + day('2026-09-01', Number.MAX_SAFE_INTEGER), day('2026-09-02', -1)]; + assert.equal(signals([], rows).familyDays.astra, 0); + assert.equal(signals([], rows).peakFourWeekAverage, 0); +}); + +test('active weeks require four distinct output days, not rows or input-only activity', () => { + const rows = ['01', '02', '03', '04'].map(d => day(`2026-09-${d}`)); + assert.equal(signals([], rows).activeWeeks, 1); + assert.equal(signals([], [rows[0], rows[0], rows[1], rows[2]]).activeWeeks, 0); + assert.equal(signals([], rows.map(row => ({ ...row, output: 0 }))).activeWeeks, 0); +}); + +test('new calendar awards use closed days and malformed expected-provider evidence blocks the mean', () => { + assert.equal(signals([], [day('2026-09-28')]).familyDays.astra, 0); + const weeks = ['2026-08-31', '2026-09-07', '2026-09-14', '2026-09-21'].map(date => day(date, 2_000_000_000)); + assert.equal(signals([day('2026-09-01', -1, 1, 'claude-sonnet-5')], weeks).peakFourWeekAverage, 0); + assert.equal(signals([], [day('2026-09-01', 1_000_000, 1, 'gpt-6-astra-')]).familyDays.astra, 0); +}); + +test('Codex token proof validates lineage and subset components before new output/cache awards', () => { + const total = { inputTotal: 120_000_000, cachedInput: 100_000_000, outputTotal: 1_000_000, reasoningOutput: 1_000_000 }; + const file = { total, byModel: { 'gpt-6-astra': total }, byDay: {}, byEffort: {}, + session: { sessionKey: 'one', role: 'root' }, structural: {} }; + const read = (files: any[], complete = true) => calendar.codexAchievementTokenEvidence(files, complete); + assert.deepEqual(read([file]), { complete: true, output: 1_000_000, cachedInput: 100_000_000 }); + assert.equal(read([file], false).complete, false); + assert.equal(read([file, file]).complete, false); + assert.equal(read([{ ...file, session: { ...file.session, parentSessionKey: 'absent', role: 'subagent' } }]).complete, false); + assert.equal(read([{ ...file, total: { ...total, cachedInput: 121_000_000 } }]).complete, false); + assert.equal(read([{ ...file, total: { ...total, reasoningOutput: 1_000_001 } }]).complete, false); +}); + +test('Codex day proof rejects fallback UTC dates, wrong zones and malformed model buckets', () => { + const total = { inputTotal: 999_999, outputTotal: 1, cachedInput: 900_000 }; + const slice = { total, byModel: { 'gpt-6-astra': total }, byEffort: {}, structural: {} }; + const file = { total, byDay: { '2026-09-01': total }, byModel: { 'gpt-6-astra': total }, + byEffort: {}, session: { sessionKey: 'one', role: 'root' }, structural: {}, + period: { timeZone: 'Europe/Istanbul', indexedThrough: 1, lineageVersion: 1, lineagePrefixEvents: 0, + days: { '2026-09-01': slice } } }; + const read = (files: any[], complete = true) => calendar.codexAchievementDayEvidence(files, 'Europe/Istanbul', complete); + assert.equal(read([file]).complete, true); + assert.equal(read([file]).days[0].models['gpt-6-astra'].processed, 1_000_000); + assert.equal(read([file], false).complete, false); + assert.equal(read([{ ...file, period: undefined }]).complete, false); + assert.equal(read([{ ...file, period: { ...file.period, timeZone: 'UTC' } }]).complete, false); + assert.equal(read([{ ...file, period: { ...file.period, days: { '2026-09-01': { ...slice, + byModel: { 'gpt-6-astra': { inputTotal: 2_000_000, outputTotal: 1 } } } } } }]).complete, false); + assert.equal(read([file, file]).complete, false, 'duplicate session copies cannot manufacture output days'); +}); + +const zone = 'America/New_York'; +const tokens = (inputTotal = 999_999, outputTotal = 1) => ({ inputTotal, outputTotal }); +const file = (sessionKey = 'root', total: any = tokens()): any => ({ + total, byModel: { 'gpt-6-astra': total }, byDay: {}, byEffort: {}, + session: { sessionKey, role: 'root' }, structural: {}, + period: { timeZone: zone, indexedThrough: 1, lineageVersion: CODEX_PERIOD_LINEAGE_VERSION, + lineagePrefixEvents: 0, days: { '2026-09-01': { total, byModel: { 'gpt-6-astra': total }, byEffort: {}, structural: {} } } }, +}); +const dayProof = (files: any[], complete = true, timeZone = zone) => + calendar.codexAchievementDayEvidence(files, timeZone, complete); +const tokenProof = (files: any[], complete = true) => calendar.codexAchievementTokenEvidence(files, complete); +const fourWeeks = () => ['2026-08-31', '2026-09-07', '2026-09-14', '2026-09-21'].map(date => day(date, 2_000_000_000)); +const claudeRow = (date = '2026-09-01'): any => ({ + date, data: { totalInputTokens: 100_000, totalOutputTokens: 1, totalCacheCreationTokens: 200_000, totalCacheReadTokens: 699_999, + totalCost: 0.1, costBreakdown: { input: 0.01, output: 0.01, cacheWrite: 0.03, cacheRead: 0.05 }, messageCount: 1, + modelBreakdown: { 'claude-sonnet-5': { inputTokens: 100_000, outputTokens: 1, + cacheCreationTokens: 200_000, cacheReadTokens: 699_999, cost: 0.1, count: 1 } } }, +}); + +test('current and future civil days cannot prove family, dual-provider or three-family days', () => { + for (const date of ['2026-09-28', '2026-09-29']) { + const value = signals([day(date, 1_000_000, 1, 'claude-sonnet-5')], + ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'].map(model => day(date, 1_000_000, 1, model))); + assert.equal(value.familyDays.astra, 0); + assert.equal(value.familyDays.sonnet, 0); + assert.equal(value.dualProviderDays, 0); + assert.equal(value.threeFamilyDay, false); + } +}); + +test('malformed complete provider blocks the combined mean; verified complete-empty permits it', () => { + const weeks = fourWeeks(); + assert.equal(signals([], weeks).peakFourWeekAverage, 2_000_000_000); + for (const bad of [day('2026-09-01', -1), day('2026-02-30'), day('2026-09-01', 0.5)]) { + assert.equal(signals([bad], weeks).peakFourWeekAverage, 0); + assert.equal(signals(weeks, [bad]).peakFourWeekAverage, 0); + } + assert.equal(signals([], weeks, { claude: evidence([], false) }).peakFourWeekAverage, 0); + assert.equal(signals([], weeks, { claude: { complete: true, days: null } }).peakFourWeekAverage, 0); +}); + +test('malformed rows and models fail closed without discarding the other provider family proof', () => { + for (const bad of [null, {}, { ...day('2026-09-01'), models: null }, + { ...day('2026-09-01'), models: { 'gpt-6-astra': null } }]) { + const value = signals([day('2026-09-01', 1_000_000, 1, 'claude-opus-5')], [bad]); + assert.equal(value.familyDays.opus, 1); + assert.equal(value.familyDays.astra, 0); + assert.equal(value.dualProviderDays, 0); + } +}); + +test('unsafe daily originals and duplicate-date overflow invalidate expected-provider proof', () => { + const badRows = [ + [day('2026-09-01', NaN)], [day('2026-09-01', Infinity)], + [day('2026-09-01', Number.MAX_SAFE_INTEGER + 1)], + [day('2026-09-01', Number.MAX_SAFE_INTEGER), day('2026-09-01', 1)], + [{ ...day('2026-09-01'), models: { 'gpt-6-astra': { processed: 0.5, output: 0 } } }], + [{ ...day('2026-09-01'), models: { 'gpt-6-astra': { processed: 1_000_001, output: 1 } } }], + ]; + for (const rows of badRows) { + assert.equal(signals(fourWeeks(), rows).peakFourWeekAverage, 0); + assert.equal(signals([], rows).familyDays.astra, 0); + } +}); + +test('Codex family matching requires a complete named-model grammar before matching the prefix', () => { + for (const model of ['gpt-6-astra-', 'gpt-6-sol--max', 'gpt-6-luna/preview', + 'gpt-6-astra-☀', 'gpt-6-astra-hello world', 'gpt-6-astra.proxy', 'gpt-6-astra-max_1', + 'gpt-6.1-astra-max-2', ' GPT-6.1-ASTRA-MAX ']) { + const value = signals([], [day('2026-09-01', 1_000_000, 1, model)]); + assert.equal(Object.values(value.familyDays).reduce((a: number, b: any) => a + b, 0), 0, model); + } + // Effort is a separate field, not evidence for an arbitrary model suffix. + for (const model of ['gpt-6-astra', 'gpt-6.1-astra-20261001', ' GPT-6.1-ASTRA ']) { + assert.equal(signals([], [day('2026-09-01', 1_000_000, 1, model)]).familyDays.astra, 1, model); + } + assert.equal(signals([day('2026-09-01', 1_000_000, 1, 'claude-fable-5')], []).threeFamilyDay, false); + assert.deepEqual(Object.keys(signals([], []).familyDays).sort(), ['astra', 'fable', 'haiku', 'luna', 'opus', 'sol', 'sonnet']); +}); + +test('civil Mondays and four-week means survive spring and autumn DST transitions', () => { + for (const [dates, asOfDay] of [ + [['2026-02-23', '2026-03-02', '2026-03-09', '2026-03-16'], '2026-03-23'], + [['2026-10-12', '2026-10-19', '2026-10-26', '2026-11-02'], '2026-11-09'], + ] as const) { + const rows = dates.map(date => day(date, 8_000_000)); + assert.equal(signals([], [], { asOfDay, claude: { ...evidence([]), timeZone: zone }, + codex: { ...evidence(rows), timeZone: zone } }).peakFourWeekAverage, 8_000_000); + } +}); + +test('first partial week and current week are excluded from four-week windows', () => { + const rows = [day('2026-08-25', 1_000_000_000_000), ...fourWeeks(), day('2026-09-28', 1_000_000_000_000)]; + assert.equal(signals([], rows).peakFourWeekAverage, 2_000_000_000); + const currentWeek = ['2026-09-28', '2026-09-29', '2026-09-30', '2026-10-01'].map(date => day(date)); + assert.equal(signals([], currentWeek, { asOfDay: '2026-10-02' }).activeWeeks, 0); +}); + +test('four-week divisor includes leading, internal and trailing zero weeks', () => { + assert.equal(signals([], [day('2026-08-31', 0, 0), day('2026-09-07', 8_000_000)]).peakFourWeekAverage, 0, + 'zero rows cannot invent an earlier history start'); + assert.equal(signals([], [day('2026-08-31', 8_000_000)]).peakFourWeekAverage, 2_000_000); + assert.equal(signals([], [day('2026-08-31', 4_000_000), day('2026-09-21', 4_000_000)]).peakFourWeekAverage, 2_000_000); +}); + +test('peak four-week mean retains an older maximum and floors fractional averages', () => { + const older = ['2025-12-01', '2025-12-08', '2025-12-15', '2025-12-22'].map(date => day(date, 80_000_000)); + assert.equal(signals([], [...older, ...fourWeeks().map(row => ({ ...row, processed: 1, output: 1, models: {} }))]) + .peakFourWeekAverage, 80_000_000); + assert.equal(signals([], [day('2026-08-31', 7)]).peakFourWeekAverage, 1); +}); + +test('active weeks join providers by distinct date and need positive output', () => { + const value = signals(['2026-09-01', '2026-09-02'].map(date => day(date, 1_000_000, 1, 'claude-sonnet-5')), + ['2026-09-02', '2026-09-03', '2026-09-04'].map(date => day(date, 1_000_000))); + assert.equal(value.activeWeeks, 1); + assert.equal(signals([], ['2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04'] + .map(date => day(date, 0, 0))).activeWeeks, 0); +}); + +test('Codex token proof distinguishes complete-empty, incomplete and malformed evidence', () => { + assert.deepEqual(tokenProof([]), { complete: true, output: 0, cachedInput: 0 }); + assert.deepEqual(tokenProof([], false), { complete: false, output: 0, cachedInput: 0 }); + for (const bad of [null, {}, { ...file(), total: null }, { ...file(), session: null }]) { + assert.deepEqual(tokenProof([bad]), { complete: false, output: 0, cachedInput: 0 }); + assert.deepEqual(tokenProof([file('valid'), bad]), { ...tokenProof([file('valid')]), lowerBound: true }); + } +}); + +test('canonical Codex roots and nested children can arrive in any order', () => { + const root = file('root', { ...tokens(10, 4), cachedInput: 7, reasoningOutput: 3 }); + const child = file('child', { ...tokens(5, 2), cachedInput: 2 }); + child.session = { sessionKey: 'child', parentSessionKey: 'root', role: 'subagent' }; + const grandchild = file('grandchild', tokens(1, 1)); + grandchild.session = { sessionKey: 'grandchild', parentSessionKey: 'child', role: 'approval-reviewer' }; + assert.deepEqual(tokenProof([grandchild, child, root]), { complete: true, output: 7, cachedInput: 9 }); + assert.equal(dayProof([grandchild, child, root]).complete, true); + assert.equal(dayProof([grandchild, child, root]).days[0].processed, 23); +}); + +test('Codex calendar rejects bad ancestry; token proof withholds only affected branches', () => { + const root = file(); + const child = file('child'); + child.session = { sessionKey: 'child', parentSessionKey: 'root', role: 'subagent' }; + const cycleRoot = { ...root, session: { ...root.session, parentSessionKey: 'child' } }; + const cases = [ + [root, root], [child], [root, { ...child, session: { ...child.session, parentSessionKey: 'absent' } }], + [{ ...root, session: { ...root.session, parentSessionKey: 'root' } }], [cycleRoot, child], + [{ ...child, session: { sessionKey: 'child', role: 'subagent' } }], + [{ ...child, session: { sessionKey: 'child', role: 'approval-reviewer' } }], + [{ ...root, session: { ...root.session, sessionKey: '' } }], + [{ ...root, session: { ...root.session, sessionKey: ' ' } }], + [{ ...root, session: { ...root.session, parentSessionKey: '' } }], + ]; + for (const files of cases) { + const safeRootSurvives = files.length === 2 && files[0] === root && files[1].session.parentSessionKey === 'absent'; + assert.equal(tokenProof(files).complete, safeRootSurvives); + if (safeRootSurvives) assert.deepEqual(tokenProof(files), { ...tokenProof([root]), lowerBound: true }); + assert.equal(dayProof(files).complete, false); + } +}); + +test('token proof rejects unsafe original components, impossible subsets and aggregate overflow', () => { + for (const field of ['inputTotal', 'outputTotal', 'cachedInput', 'reasoningOutput', 'cacheWriteInput', 'sourceTotal']) { + for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '1', null]) { + assert.equal(tokenProof([file('bad', { ...tokens(10, 4), [field]: value })]).complete, false, `${field}: ${value}`); + } + } + for (const bad of [{ ...tokens(10, 4), cachedInput: 11 }, { ...tokens(10, 4), reasoningOutput: 5 }, + tokens(Number.MAX_SAFE_INTEGER, 1)]) { + assert.equal(tokenProof([file('bad', bad)]).complete, false); + } + assert.equal(tokenProof([file('one', tokens(Number.MAX_SAFE_INTEGER, 0)), file('two', tokens(1, 0))]).complete, false); +}); + +test('Codex file model originals and component sums must fit their aggregate', () => { + for (const models of [null, { 'gpt-6-astra': tokens(0.5, 0) }, { 'gpt-6-astra': tokens(1_000_000, 1) }, + { one: tokens(500_000, 1), two: tokens(500_000, 1) }, + { 'gpt-6-astra': { ...tokens(), cachedInput: 1_000_000 } }]) { + const bad = { ...file(), byModel: models }; + assert.equal(dayProof([bad]).complete, false); + assert.equal(tokenProof([bad]).complete, false); + } +}); + +test('Codex day evidence returns its exact zone for valid-empty and incomplete inputs', () => { + assert.deepEqual(dayProof([]), { complete: true, days: [], timeZone: zone }); + assert.deepEqual(dayProof([], false), { complete: false, days: [], timeZone: zone }); + assert.equal(dayProof([file()]).timeZone, zone); + for (const badZone of ['', 'Invalid/Zone']) { + assert.deepEqual(dayProof([], true, badZone), { complete: false, days: [], timeZone: badZone }); + } +}); + +test('Codex daily proof requires current lineage marker and exact zone with no UTC fallback', () => { + for (const period of [undefined, { ...file().period, lineageVersion: undefined }, + { ...file().period, lineageVersion: CODEX_PERIOD_LINEAGE_VERSION - 1 }, + { ...file().period, timeZone: 'UTC' }, { ...file().period, days: null }]) { + assert.equal(dayProof([{ ...file(), byDay: { '2026-09-01': tokens() }, period }]).complete, false); + } +}); + +test('Codex daily proof validates original day and model integers even if fractions add to integers', () => { + for (const field of ['inputTotal', 'outputTotal', 'cachedInput', 'reasoningOutput']) { + const bad = file('bad', { ...tokens(10, 4), cachedInput: 10, reasoningOutput: 4 }); + bad.period.days = Object.fromEntries(['2026-09-01', '2026-09-02'].map(date => [date, { + total: { ...tokens(2, 2), [field]: 0.5 }, byModel: {}, byEffort: {}, structural: {}, + }])); + assert.equal(dayProof([bad]).complete, false, field); + } + const bad = file(); + bad.period.days['2026-09-01'].byModel = { one: tokens(0.5, 0), two: tokens(0.5, 0) }; + assert.equal(dayProof([bad]).complete, false); +}); + +test('Codex period model totals across days must fit the same all-time model component buckets', () => { + const bad = file('root', tokens(20, 10)); + bad.byModel = { 'gpt-6-astra': tokens(5, 2), 'gpt-6-sol': tokens(15, 8) }; + bad.period.days = Object.fromEntries(['2026-09-01', '2026-09-02'].map(date => [date, { + total: tokens(4, 2), byModel: { 'gpt-6-astra': tokens(4, 2) }, byEffort: {}, structural: {}, + }])); + assert.equal(dayProof([bad]).complete, false, 'each day fits, but the model period exceeds its file allowance'); + bad.byModel = { 'gpt-6-astra': tokens(8, 4), 'gpt-6-sol': tokens(12, 6) }; + assert.equal(dayProof([bad]).complete, false, 'valid dated subset is not exhaustive all-time coverage'); + assert.equal(dayProof([bad]).scope, 'verified-subset'); + assert.equal(dayProof([bad]).days.length, 2); + bad.period.days['2026-09-02'].byModel = { 'gpt-6-luna': tokens(4, 2) }; + assert.equal(dayProof([bad]).complete, false, 'a period model missing from the file cannot prove a named family'); +}); + +test('Codex daily model subsets and component sums must fit both day and file', () => { + const bad = file('root', { ...tokens(10, 4), cachedInput: 2, reasoningOutput: 1 }); + bad.period.days['2026-09-01'].byModel = { 'gpt-6-astra': { ...tokens(10, 4), cachedInput: 3, reasoningOutput: 2 } }; + assert.equal(dayProof([bad]).complete, false); + bad.period.days['2026-09-01'] = { total: { ...tokens(10, 4), cachedInput: 3, reasoningOutput: 2 }, + byModel: {}, byEffort: {}, structural: {} }; + assert.equal(dayProof([bad]).complete, false); +}); + +test('Codex dated containment permits a positive bound, but completeness requires all-time equality', () => { + const valid = file('root', tokens(10, 4)); + const slice = { total: tokens(5, 2), byModel: { 'gpt-6-astra': tokens(5, 2) }, byEffort: {}, structural: {} }; + valid.period.days = { '2026-09-01': slice }; + assert.equal(dayProof([valid]).complete, false); + assert.equal(dayProof([valid]).scope, 'verified-subset'); + assert.equal(dayProof([valid]).days.length, 1); + valid.period.days['2026-09-02'] = slice; + assert.equal(dayProof([valid]).complete, true); + valid.period.days['2026-09-03'] = slice; + assert.equal(dayProof([valid]).complete, false); +}); + +test('Codex daily proof rejects impossible dates and merged-day safe-integer overflow', () => { + const bad = file(); + bad.period.days = { '2026-02-30': bad.period.days['2026-09-01'] }; + assert.equal(dayProof([bad]).complete, false); + assert.equal(dayProof([file('one', tokens(Number.MAX_SAFE_INTEGER, 0)), file('two', tokens(1, 0))]).complete, false); + const lastDay = file(); + lastDay.period.days = { '9999-12-31': lastDay.period.days['2026-09-01'] }; + assert.equal(dayProof([lastDay]).days[0].day, '9999-12-31'); +}); + +test('Codex daily proof requires both current lineage version and a valid prefix marker', () => { + for (const prefix of [undefined, -1, 0.5, NaN, Number.MAX_SAFE_INTEGER + 1]) { + const malformed = file(); + malformed.period.lineagePrefixEvents = prefix; + assert.equal(dayProof([malformed]).complete, false); + } + const old = file(); + old.period.lineageVersion = undefined; + assert.equal(dayProof([old]).complete, false); +}); + +test('Codex proof preserves unattributed and unknown-model volume without inventing named days', () => { + const valid = file(); + valid.byModel = {}; + valid.period.days['2026-09-01'].byModel = {}; + assert.equal(dayProof([valid]).complete, true); + assert.deepEqual(tokenProof([valid]), { complete: true, output: 1, cachedInput: 0 }); + assert.equal(signals([], dayProof([valid]).days).familyDays.astra, 0); +}); + +test('Claude numeric day adapter sums its four disjoint buckets and returns the supplied zone', () => { + const value = calendar.claudeAchievementDayEvidence([claudeRow()], zone, true); + assert.deepEqual(value, { complete: true, timeZone: zone, days: [ + day('2026-09-01', 1_000_000, 1, 'claude-sonnet-5'), + ] }); + assert.deepEqual(calendar.claudeAchievementDayEvidence([], zone, true), { complete: true, timeZone: zone, days: [] }); + assert.deepEqual(calendar.claudeAchievementDayEvidence([claudeRow()], zone, false), { complete: false, timeZone: zone, days: [] }); + const noModels = claudeRow(); + noModels.data.modelBreakdown = {}; + assert.deepEqual(calendar.claudeAchievementDayEvidence([noModels], zone, true).days[0].models, {}); +}); + +test('Claude adapter validates original numeric components and per-component model containment', () => { + for (const [field, totalField] of [['inputTokens', 'totalInputTokens'], ['outputTokens', 'totalOutputTokens'], + ['cacheCreationTokens', 'totalCacheCreationTokens'], ['cacheReadTokens', 'totalCacheReadTokens']]) { + for (const invalid of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const row = claudeRow(); + row.data[totalField] = invalid; + assert.equal(calendar.claudeAchievementDayEvidence([row], zone, true).complete, false); + } + const row = claudeRow(); + row.data.modelBreakdown['claude-sonnet-5'][field] = row.data[totalField] + 1; + assert.equal(calendar.claudeAchievementDayEvidence([row], zone, true).complete, false, field); + } + assert.equal(calendar.claudeAchievementDayEvidence([claudeRow('2026-02-30')], zone, true).complete, false); + assert.equal(calendar.claudeAchievementDayEvidence([claudeRow()], 'Invalid/Zone', true).complete, false); +}); + +test('Claude identical numeric duplicate projections deduplicate, while conflicting duplicates fail closed', () => { + const first = claudeRow(); + const same = claudeRow(); + same.data.totalCost = 999; + same.data.modelBreakdown['claude-sonnet-5'].count = 9; + assert.equal(calendar.claudeAchievementDayEvidence([first, same], zone, true).days[0].processed, 1_000_000); + const different = claudeRow(); + different.data.totalInputTokens++; + assert.deepEqual(calendar.claudeAchievementDayEvidence([first, different], zone, true), + { complete: false, days: [], timeZone: zone }); + const differentModels = claudeRow(); + differentModels.data.modelBreakdown = { 'claude-opus-5': differentModels.data.modelBreakdown['claude-sonnet-5'] }; + assert.equal(calendar.claudeAchievementDayEvidence([first, differentModels], zone, true).complete, false); +}); + +test('Claude duplicate equality includes all four original buckets, not only processed and output', () => { + const first = claudeRow(); + const swapped = claudeRow(); + swapped.data.totalInputTokens--; + swapped.data.totalCacheReadTokens++; + swapped.data.modelBreakdown['claude-sonnet-5'].inputTokens--; + swapped.data.modelBreakdown['claude-sonnet-5'].cacheReadTokens++; + assert.equal(calendar.claudeAchievementDayEvidence([first, swapped], zone, true).complete, false); +}); + +test('numeric adapters and calendar aggregation leave caller-owned inputs untouched', () => { + const deepFreeze = (value: any): any => { + if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); Object.freeze(value); } + return value; + }; + const files = deepFreeze([file()]); + assert.equal(dayProof(files).complete, true); + assert.equal(tokenProof(files).complete, true); + const rows = deepFreeze([claudeRow(), claudeRow()]); + const projected = calendar.claudeAchievementDayEvidence(rows, zone, true); + assert.equal(projected.days.length, 1); + assert.equal(projected.days[0].processed, 1_000_000); + assert.equal(signals([], deepFreeze(dayProof(files).days)).familyDays.astra, 1); +}); diff --git a/src/test/achievementCalendarPeak.test.ts b/src/test/achievementCalendarPeak.test.ts new file mode 100644 index 00000000..7e458c55 --- /dev/null +++ b/src/test/achievementCalendarPeak.test.ts @@ -0,0 +1,58 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +const calendar = require('../achievementCalendar') as any; + +test('retained calendar peak is bounded numeric evidence tied to current rules, parser, zone and providers', () => { + const context = calendar.calendarPeakContext('UTC', true, true); + const saved = calendar.updateCalendarPeakEvidence(undefined, context, 2_000_000_000, true); + assert.equal(saved.peakFourWeekAverage, 2_000_000_000); + assert.equal(calendar.readCalendarPeakEvidence(saved, context, true), 2_000_000_000); + assert.equal(calendar.readCalendarPeakEvidence(saved, context, false), 0); + for (const change of [ + { timeZone: 'Europe/Istanbul' }, { providers: 1 }, { parserVersion: context.parserVersion + 1 }, + { periodVersion: context.periodVersion + 1 }, { ruleVersion: context.ruleVersion + 1 }, + ]) assert.equal(calendar.readCalendarPeakEvidence(saved, { ...context, ...change }, true), 0); + assert.equal(calendar.updateCalendarPeakEvidence(saved, context, 1_000_000_000, true), saved); + assert.equal(calendar.updateCalendarPeakEvidence(saved, context, 9_000_000_000, false), saved); + assert.equal(calendar.updateCalendarPeakEvidence(saved, context, 3_000_000_000, true).peakFourWeekAverage, 3_000_000_000); + assert.equal(calendar.updateCalendarPeakEvidence(saved, { ...context, timeZone: 'Europe/Istanbul' }, 0, true), saved); + assert.equal(calendar.updateCalendarPeakEvidence(saved, { ...context, timeZone: 'Europe/Istanbul' }, 500_000_000, true).peakFourWeekAverage, 500_000_000); + assert.ok(JSON.stringify(saved).length < 512, 'one context and one maximum, not an unbounded history'); +}); + +test('malformed calendar maxima and unknown storage fields cannot become award proof', () => { + const context = calendar.calendarPeakContext('UTC', false, true); + const valid = calendar.updateCalendarPeakEvidence(undefined, context, 10, true); + for (const value of [null, [], {}, { ...valid, schemaVersion: 2 }, { ...valid, peakFourWeekAverage: -1 }, + { ...valid, peakFourWeekAverage: Infinity }, { ...valid, peakFourWeekAverage: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, timeZone: 'invalid/zone' }, { ...valid, providers: 0 }, { ...valid, parserVersion: '4' }]) { + assert.equal(calendar.parseCalendarPeakEvidence(value), undefined); + assert.equal(calendar.readCalendarPeakEvidence(value, context, true), 0); + } + assert.deepEqual(calendar.parseCalendarPeakEvidence({ ...valid, prompt: 'discard', path: 'discard' }), valid); + for (const volume of [-1, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, 1.5]) { + assert.equal(calendar.updateCalendarPeakEvidence(undefined, context, volume, true), undefined); + } +}); + +test('Claude-only retained peaks ignore unrelated Codex versions but retain zone, rules and completeness fences',()=>{ + const context=calendar.calendarPeakContext('UTC',true,false); + const saved=calendar.updateCalendarPeakEvidence(undefined,context,4_900_000_000,true); + const revised={...context,parserVersion:context.parserVersion+1,periodVersion:context.periodVersion+1}; + assert.equal(calendar.readCalendarPeakEvidence(saved,revised,true),4_900_000_000); + assert.equal(calendar.readCalendarPeakEvidence(saved,revised,false),0); + for(const change of [{timeZone:'Asia/Shanghai'},{ruleVersion:context.ruleVersion+1},{providers:3}]) + assert.equal(calendar.readCalendarPeakEvidence(saved,{...revised,...change},true),0); +}); + +test('a complete flag alone does not certify duplicated or malformed normalized day evidence', () => { + const day = { day: '2026-08-31', processed: 8_000_000_000, output: 1, models: {} }; + const input = { claude: { complete: true, days: [], timeZone: 'UTC' }, + codex: { complete: true, days: [day], timeZone: 'UTC' }, asOfDay: '2026-09-28' }; + assert.equal(calendar.achievementCalendarSignals(input).peakEvidenceComplete, true); + assert.equal(calendar.achievementCalendarSignals({ ...input, + codex: { ...input.codex, days: [day, day] } }).peakEvidenceComplete, false); + assert.equal(calendar.achievementCalendarSignals({ ...input, + codex: { ...input.codex, complete: false } }).peakEvidenceComplete, false); +}); diff --git a/src/test/achievementCalendarPeakUi.test.ts b/src/test/achievementCalendarPeakUi.test.ts new file mode 100644 index 00000000..e90a06af --- /dev/null +++ b/src/test/achievementCalendarPeakUi.test.ts @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import Module = require('node:module'); +import { I18n } from '../i18n'; + +const peakKey = 'ccu.achievements.calendarPeak.v1'; +function state(): Map { + return new Map(['structural', 'token', 'bucket', 'manifest', 'calendar', 'calibration'] + .map(name => [`ccu.achievements.${name}Audit.v1`, true])); +} +async function withProvider(values: Map, run: (provider: any) => Promise, + save?: (key: string, value: unknown, persist: () => void) => Promise): Promise { + const load = (Module as any)._load; + const zone = I18n.getTimezone(); + (Module as any)._load = function(request: string, parent: unknown, main: boolean) { + return request === 'vscode' ? { workspace: { workspaceFolders: [] } } : load.call(this, request, parent, main); + }; + try { + I18n.setTimezone('UTC'); + const { UsageWebviewProvider } = require('../webview'); + const provider = new UsageWebviewProvider({ globalState: { + get: (key: string) => values.get(key), update: async (key: string, value: unknown) => { + const persist = () => value === undefined ? values.delete(key) : values.set(key, value); + if (save) await save(key, value, persist); else persist(); + }, + } }) as any; + await run(provider); + } finally { I18n.setTimezone(zone); (Module as any)._load = load; } +} +function proof(provider: any, volume = 8_000_000_000): void { + provider.providerAvailability = { claude: false, codex: true, codexData: true }; + provider.codexAchievementSignals = { modelCount: 0, efforts: [], familyRootCounts: { astra: 0, sol: 0, luna: 0 }, + dayEvidence: { complete: true, timeZone: 'UTC', days: volume ? [{ day: '2026-08-31', processed: volume, + output: 1, models: {} }] : [] } }; + provider.codexView = { currentManifestVerified: true, coverage: { complete: true, identity: { complete: true } }, + periodCoverage: { timeZone: 'UTC', allTime: { complete: true }, last30Days: { complete: false } }, + last30DaysHourlyByDay: {}, hourlyCoverage: { complete: false }, allTimeDaily: [], + allTime: { total: { processed: volume } } }; +} +const result = (provider: any, id = 'weekly-average-silver') => provider.achievementResults().find((r: any) => r.id === id); +async function settle(provider: any): Promise { + await Promise.all([provider.achievementWrite, provider.achievementHourWrite, provider.achievementCalendarWrite] + .map(p => Promise.resolve(p).catch(() => undefined))); + await new Promise(resolve => setImmediate(resolve)); +} + +test('host saves verified weekly mean once and retains it after log retention without using stale-zone proof', async () => { + const values = state(); let peakWrites = 0; + await withProvider(values, async provider => { + proof(provider); assert.equal(result(provider).unlocked, true); await settle(provider); + assert.equal((values.get(peakKey) as any)?.peakFourWeekAverage, 2_000_000_000); + for (let index = 0; index < 8; index++) result(provider); + await settle(provider); assert.equal(peakWrites, 1, 'unchanged render does not persist repeatedly'); + }, async (key, value, persist) => { if (key === peakKey) peakWrites++; persist(); }); + await withProvider(values, async provider => { + proof(provider, 0); assert.equal(result(provider).progress, 2_000_000_000); + provider.codexView.currentManifestVerified = false; + assert.equal(result(provider).progress, 0, 'cached manifest cannot unlock from a retained maximum'); + provider.codexView.currentManifestVerified = true; + I18n.setTimezone('Europe/Istanbul'); assert.equal(result(provider).progress, 0); + I18n.setTimezone('UTC'); await provider.clearAchievementGrants(); + assert.equal(values.has(peakKey), false); + assert.equal(result(provider).progress, 0); + }); +}); + +test('failed or disabled calendar saves are retryable and clear fences in-flight writes', async () => { + const values = state(); let failures = 1; let pause: Promise | undefined; let release!: () => void; + await withProvider(values, async provider => { + proof(provider); result(provider); await settle(provider); + assert.equal(values.has(peakKey), false, 'failed save is not retained optimistically'); + result(provider); await settle(provider); assert.equal(values.has(peakKey), true); + await provider.clearAchievementGrants(); + pause = new Promise(resolve => { release = resolve; }); + proof(provider, 12_000_000_000); result(provider); + await new Promise(resolve => setImmediate(resolve)); + const clearing = provider.clearAchievementGrants(); release(); await clearing; + assert.equal(values.has(peakKey), false, 'clear waits and deletes a write already in flight'); + provider.settings = { get: () => false }; proof(provider); result(provider); await settle(provider); + assert.equal(values.has(peakKey), false); + }, async (key, value, persist) => { + if (key === peakKey && value !== undefined) { + if (failures-- > 0) throw new Error('fixture storage unavailable'); + if (pause) await pause; + } + persist(); + }); +}); diff --git a/src/test/achievementCalendarProjection.test.ts b/src/test/achievementCalendarProjection.test.ts new file mode 100644 index 00000000..ba18b8b9 --- /dev/null +++ b/src/test/achievementCalendarProjection.test.ts @@ -0,0 +1,123 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { codexAchievementMoneyScopes } from '../achievementMoneyScopes'; +import { CODEX_PERIOD_LINEAGE_VERSION } from '../providers/codex/codexPeriodIndex'; +const calendar = require('../achievementCalendar') as any; +const project = (files: any[], indexed = true) => calendar.codexAchievementCalendarProjection(files, 'UTC', indexed); +function file(key: string, parent?: string): any { + const t = { inputTotal: 500_000_000, outputTotal: 4 }; + const s = { inputTotal: 125_000_000, outputTotal: 1 }; + return { total: t, byModel: { 'gpt-6.1-sol': t }, byEffort: {}, byDay: {}, structural: {}, + session: { sessionKey: key, parentSessionKey: parent, role: parent ? 'subagent' : 'root' }, + achievementPeriodWitness: { size: 100, offset: 100, expectedPrefixEvents: 0, timeZone: 'UTC' }, + period: { timeZone: 'UTC', indexedThrough: 100, lineageVersion: CODEX_PERIOD_LINEAGE_VERSION, + lineagePrefixEvents: 0, days: Object.fromEntries(['05','06','07','08'].map(d => [`2026-10-${d}`, + { total: { ...s }, byModel: { 'gpt-6.1-sol': { ...s } }, byEffort: {}, structural: {} }])) } }; +} +const money = (files: any[], dayEvidence = project(files).evidence) => + codexAchievementMoneyScopes({ files, complete: true, backend: 'openai', asOfDay: '2026-10-12', dayEvidence }); +test('full census conflicts retain separately witnessed dates and positive weekly money as a bound', () => { + const good = file('good'), files = [file('duplicate'), good, file('duplicate'), file('child','duplicate')]; + const p = project(files); + assert.equal(p.evidence.complete, false); + assert.equal(p.evidence.scope, 'verified-subset'); + assert.equal(p.evidence.days.length, 4); + const facts = calendar.achievementCalendarSignals({claude:{complete:true,days:[],timeZone:'UTC'},codex:p.evidence,asOfDay:'2026-10-12'}); + assert.equal(facts.familyDays.sol, 4); + assert.equal(facts.activeWeeks, 1); + assert.equal(facts.codexEvidenceComplete, false); + assert.equal(facts.codexEvidenceUsable, true); + assert.equal(facts.peakEvidenceComplete, false); + const joined=calendar.achievementCalendarSignals({claude:{complete:true,timeZone:'UTC',days:[ + {day:'2026-10-05',processed:2_000_000,output:1,models:{'claude-sonnet-5':{processed:2_000_000,output:1}}}]}, + codex:p.evidence,asOfDay:'2026-10-12'}); + assert.equal(joined.dualProviderDays,0,'existing dual-provider full-coverage fence is not expanded'); + const value = money(files, p.evidence), exact = money([good]); + assert.equal(value.completedMondayWeekPeak, exact.completedMondayWeekPeak); + assert.equal(value.completedMondayWeekPeak, 1_000_000_040); + assert.equal(value.coverage.completedMondayWeekPeak.status, 'partial'); + assert.equal((value.coverage.completedMondayWeekPeak as any).scopeLowerBound, true); + assert.equal(value.cumulative?.microUsd, exact.cumulative?.microUsd); + assert.equal(money(files, project([good]).evidence).completedMondayWeekPeak, undefined, 'subset proof is bound to its original census'); +}); +test('undated residual is usable positive evidence, not complete-zero or weekly monetary evidence', () => { + const f = file('r'); delete f.period.days['2026-10-08']; + const p = project([f]); + assert.equal(p.evidence.complete, false); + assert.equal(p.evidence.scope, 'verified-subset'); + assert.equal(p.evidence.days.length, 3); + assert.equal(money([f],p.evidence).completedMondayWeekPeak, undefined); + f.period.days = {}; + assert.equal(project([f]).evidence.scope, undefined); + assert.deepEqual(project([f]).evidence.days, []); + assert.equal(project([f]).evidence.complete, false); + assert.equal(money([f]).coverage.completedMondayWeekPeak.status, 'scopepending'); +}); +test('witness mismatch, absent provenance or incomplete manifest cannot admit a date', () => { + for (const mutate of [(f:any)=>delete f.achievementPeriodWitness, + (f:any)=>f.achievementPeriodWitness.offset--, (f:any)=>f.achievementPeriodWitness.size++, + (f:any)=>f.achievementPeriodWitness.expectedPrefixEvents++, (f:any)=>f.period.indexedThrough--, + (f:any)=>f.period.lineageVersion--, (f:any)=>f.achievementPeriodWitness.timeZone='Asia/Shanghai', + (f:any)=>f.period.days['2026-10-05'].total.inputTotal=-1]) { + const f = file('bad'); mutate(f); + assert.deepEqual(project([f]).evidence.days, []); + assert.equal(project([f]).evidence.complete, false); + assert.equal(money([f]).completedMondayWeekPeak, undefined); + } + assert.deepEqual(project([file('r')],false).evidence.days, []); +}); +test('resolved ancestry precedes date selection; net child usage is not subtracted again', () => { + const root = file('root'), child = file('child','root'); delete root.achievementPeriodWitness; + child.achievementPeriodWitness.expectedPrefixEvents = child.period.lineagePrefixEvents = 123; + assert.equal(project([child,root]).evidence.days[0].processed, 125_000_001); + assert.equal(money([child,root]).completedMondayWeekPeak, 1_000_000_040); + assert.deepEqual(project([child]).evidence.days, [], 'orphan cannot be made sound by shortening the census'); + root.session.parentSessionKey='child'; root.session.role='subagent'; + assert.deepEqual(project([root,child]).evidence.days, [], 'cycle withheld'); +}); +test('whole empty remains verified empty; positive-less subsets never certify a zero peak', () => { + assert.equal(project([]).evidence.complete, true); + assert.equal(money([]).coverage.completedMondayWeekPeak.status, 'empty'); + const onlyOpen = file('r'); onlyOpen.period.days=Object.fromEntries(Object.values(onlyOpen.period.days).map((s:any,i)=>[`2026-10-${12+i}`,s])); + const files = [onlyOpen,file('duplicate'),file('duplicate')]; + assert.equal(money(files).completedMondayWeekPeak, undefined); + assert.equal(money(files).coverage.completedMondayWeekPeak.status, 'scopepending'); +}); +test('runtime census revision and numeric proof mismatch retire weekly money without changing cumulative', () => { + const f = file('r'), p=project([f]).evidence, before=money([f],p).cumulative?.microUsd; + f.period.indexedThrough=99; + assert.equal(money([f],p).completedMondayWeekPeak, undefined); + assert.equal(money([f],p).cumulative?.microUsd,before); + f.period.indexedThrough=100; + const forged=structuredClone(project([f]).evidence); forged.days[0].models['gpt-6.1-sol'].output++; + assert.equal(money([f],forged).completedMondayWeekPeak, undefined); +}); +test('independent global fence stays partial even when every supplied file is fully dated', () => { + const files=[file('r')], p=calendar.codexAchievementCalendarProjection(files,'UTC',true,false).evidence; + assert.equal(p.complete,false); + assert.equal(p.scope,'verified-subset'); + const value=codexAchievementMoneyScopes({files,complete:true,calendarComplete:false,dayEvidence:p,backend:'openai',asOfDay:'2026-10-12'}); + assert.equal(value.completedMondayWeekPeak,1_000_000_040); + assert.equal(value.coverage.completedMondayWeekPeak.status,'partial'); + assert.equal(codexAchievementMoneyScopes({files,complete:true,calendarComplete:true,dayEvidence:p,backend:'openai',asOfDay:'2026-10-12'}).completedMondayWeekPeak,undefined); +}); +test('unknown model and overflowing merged vectors do not fabricate safe weekly dollars', () => { + const f=file('r'); f.byModel={unknown:f.total}; + for (const slice of Object.values(f.period.days) as any[]) slice.byModel={unknown:slice.total}; + assert.equal(money([f]).coverage.completedMondayWeekPeak.status,'unpriced'); + assert.equal(money([f]).completedMondayWeekPeak,undefined); + const big=file('one'), second=file('two'); + for (const row of [big,second]) { + row.total={inputTotal:Number.MAX_SAFE_INTEGER,outputTotal:0}; row.byModel={'gpt-6.1-sol':row.total}; + row.period.days={'2026-10-05':{total:row.total,byModel:row.byModel,byEffort:{},structural:{}}}; + } + assert.deepEqual(project([big,second]).evidence.days,[]); + assert.equal(money([big,second]).completedMondayWeekPeak,undefined); +}); +test('a fully dated child behind invalid ancestry stays withheld, regardless of array order', () => { + const root=file('root'), child=file('child','root'), good=file('good'); + root.session.role='unknown'; + assert.equal(project([child,root,good]).evidence.days[0].processed,125_000_001); + assert.equal(project([good,root,child]).evidence.days[0].processed,125_000_001); + assert.equal(money([child,root,good]).completedMondayWeekPeak,1_000_000_040); +}); diff --git a/src/test/achievementCalendarSubsetUi.test.ts b/src/test/achievementCalendarSubsetUi.test.ts new file mode 100644 index 00000000..6bfce6a7 --- /dev/null +++ b/src/test/achievementCalendarSubsetUi.test.ts @@ -0,0 +1,125 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import Module = require('node:module'); +import { I18n } from '../i18n'; +import { codexAchievementCalendarProjection } from '../achievementCalendar'; +import { codexModelSignals } from '../achievements'; +import { codexAchievementMoneyScopes } from '../achievementMoneyScopes'; + +test('reliable Claude calendar remains a visible lower bound when Codex dates are pending', async () => { + const load = (Module as any)._load; + const zone = I18n.getTimezone(); + const language = I18n.getLocale(); + (Module as any)._load = function(request: string, parent: unknown, main: boolean) { + return request === 'vscode' ? {workspace:{workspaceFolders:[]}} : load.call(this,request,parent,main); + }; + try { + I18n.setTimezone('UTC'); I18n.setLanguage('en'); + const {UsageWebviewProvider} = require('../webview'); + const values = new Map(); + const host = new UsageWebviewProvider({globalState:{get:(key:string)=>values.get(key), + update:async(key:string,value:unknown)=>{values.set(key,value);}}}) as any; + const model = {inputTokens:99_999_999,outputTokens:1,cacheCreationTokens:0,cacheReadTokens:0}; + const data = {totalInputTokens:model.inputTokens,totalOutputTokens:1,totalCacheCreationTokens:0, + totalCacheReadTokens:0,modelBreakdown:{'claude-sonnet-5':model}}; + host.providerAvailability = {claude:true,codex:true,codexData:true}; + host.allTimeData = data; + host.dailyDataForEveryDay = Array.from({length:7},(_,i)=>({date:`2026-09-0${i+1}`,data})); + host.achievementCalendarTimeZone = 'UTC'; + host.codexView = {currentManifestVerified:true,coverage:{complete:true,identity:{complete:false}}, + periodCoverage:{timeZone:'UTC',allTime:{complete:false}},allTime:{total:{processed:0}}, + last30DaysHourlyByDay:{},hourlyCoverage:{complete:false},allTimeDaily:[]}; + const results = new Map(host.achievementResults().map((row:any)=>[row.id,row])); + assert.equal(results.get('weekly-volume-bronze').progress,600_000_000,'weeks begin Monday, not the first fixture day'); + assert.equal(results.get('active-day-streak').unlocked,true); + const html = host.renderAchievementData(); + const card = (key:string) => html.match(new RegExp(`]*data-achievement-key="${key}"[\\s\\S]*?<\\/article>`))![0]; + assert.match(card('weekly-volume'),/data-achievement-proof="ready"/); + assert.match(card('weekly-volume'),/≥600M/); + assert.match(card('active-day-streak'),/data-achievement-proof="ready"/); + assert.match(card('active-days'),/≥7/); + assert.match(card('weekly-average'),/data-achievement-proof="pending"/); + assert.match(card('dual-provider-days'),/data-achievement-proof="pending"/); + assert.match(card('sol-days'),/data-achievement-proof="pending"/); + assert.equal(host.achievementEvidenceStates['combined-calendar'],'pending','do not reopen the whole notice domain'); + host.achievementGrants.add('weekly-volume-gold'); + const historicalHtml = host.renderAchievementData(); + const historicalCard = historicalHtml.match(/]*data-achievement-key="weekly-volume"[\s\S]*?<\/article>/)![0]; + assert.match(historicalCard,/data-achievement-earned="true"/,'retain the historical earned rank'); + assert.match(historicalCard,/achievement-current-value[^>]*>Current value: ≥600M/,'a saved target is not current lower-bound evidence'); + assert.doesNotMatch(historicalCard,/≥10B/); + host.achievementGrants.delete('weekly-volume-gold'); + const days=Array.from({length:750},(_,i)=>({date:new Date(Date.UTC(2024,0,1+i)).toISOString().slice(0,10),data})); + host.dailyDataForEveryDay=days; + const multiplied={...data,totalInputTokens:data.totalInputTokens*750,totalOutputTokens:750, + modelBreakdown:{'claude-sonnet-5':{...model,inputTokens:model.inputTokens*750,outputTokens:750}}}; + host.allTimeData=multiplied; + const longResults=new Map(host.achievementResults().map((row:any)=>[row.id,row])); + assert.equal(longResults.get('active-days-platinum').unlocked,true,'Codex date gaps do not block a verified Claude long-term day rank'); + assert.equal(longResults.get('active-days-legendary').unlocked,false); + const partialLong=host.renderAchievementData().match(/]*data-achievement-key="active-days"[\s\S]*?<\/article>/)![0]; + assert.match(partialLong,/≥750 \/ 1,?500/); + host.codexView.coverage.identity.complete=true; + host.codexView.periodCoverage.allTime.complete=true; + host.codexAchievementSignals={...host.codexAchievementSignals,dayEvidence:{complete:true,timeZone:'UTC',days:[]}}; + const exactLong=host.renderAchievementData().match(/]*data-achievement-key="active-days"[\s\S]*?<\/article>/)![0]; + assert.doesNotMatch(exactLong,/≥750/,'complete-empty Codex proof upgrades the bound to an exact count'); + host.dailyDataForEveryDay=Array.from({length:7},(_,i)=>({date:`2026-09-0${i+1}`,data})); + host.allTimeData=data; + host.codexView.coverage.identity.complete=false; + host.codexView.periodCoverage.allTime.complete=false; + host.revokedAchievementEvidence.add('codex'); + assert.match(host.renderAchievementData(),/≥600M/,'a stale independent provider cannot retire verified Claude dates'); + host.revokedAchievementEvidence.delete('codex'); + I18n.setTimezone('Asia/Shanghai'); + assert.doesNotMatch(host.renderAchievementData(),/≥600M/,'old-zone rows cannot prove the lower bound'); + await Promise.all([host.achievementWrite,host.achievementHourWrite,host.achievementCalendarWrite]); + } finally { I18n.setTimezone(zone); I18n.setLanguage(language as any); (Module as any)._load = load; } +}); + +test('Codex witnessed date subsets show bounded progress but do not enable a complete calendar notice domain', async () => { + const load=(Module as any)._load, zone=I18n.getTimezone(), language=I18n.getLocale(); + (Module as any)._load=function(request:string,parent:unknown,main:boolean) { + return request==='vscode' ? {workspace:{workspaceFolders:[]}} : load.call(this,request,parent,main); + }; + try { + I18n.setTimezone('UTC'); I18n.setLanguage('en'); + const {UsageWebviewProvider}=require('../webview'); + const values=new Map(); + const host=new UsageWebviewProvider({globalState:{get:(key:string)=>values.get(key), + update:async(key:string,value:unknown)=>{values.set(key,value);}}}) as any; + const tokens={inputTotal:1_000_000_000,outputTotal:8}; + const slice={total:{inputTotal:125_000_000,outputTotal:1}, + byModel:{'gpt-6.1-sol':{inputTotal:125_000_000,outputTotal:1}},byEffort:{},structural:{}}; + const good:any={total:tokens,byModel:{'gpt-6.1-sol':tokens},byEffort:{},byDay:{},structural:{}, + session:{sessionKey:'good',role:'root'},achievementPeriodWitness:{size:1,offset:1,expectedPrefixEvents:0,timeZone:'UTC'}, + period:{indexedThrough:1,lineageVersion:1,lineagePrefixEvents:0,timeZone:'UTC',days:Object.fromEntries( + ['2026-09-28','2026-09-29','2026-09-30','2026-10-01','2026-10-05','2026-10-06','2026-10-07','2026-10-08'].map(date=>[date,slice]))}}; + const files=[good,{...good,session:{sessionKey:'duplicate',role:'root'}},{...good,session:{sessionKey:'duplicate',role:'root'}}]; + const proof=codexAchievementCalendarProjection(files,'UTC',true,false).evidence; + host.providerAvailability={claude:false,codex:true,codexData:true}; + host.codexView={currentManifestVerified:true,coverage:{complete:true,identity:{complete:false}}, + periodCoverage:{timeZone:'UTC',allTime:{complete:false}},allTime:{total:{processed:tokens.inputTotal+8}}, + last30DaysHourlyByDay:{},hourlyCoverage:{complete:false},allTimeDaily:[]}; + host.codexAchievementSignals={...codexModelSignals(files),dayEvidence:proof, + money:codexAchievementMoneyScopes({files,complete:true,calendarComplete:false,dayEvidence:proof,backend:'openai',asOfDay:'2026-10-12'})}; + const html=host.renderAchievementData(); + assert.match(html,/Unverified Codex lineage or dated usage is excluded/); + const card=(key:string,source=html)=>source.match(new RegExp(`]*data-achievement-key="${key}"[\\s\\S]*?<\\/article>`))![0]; + assert.match(card('sol-days'),/data-achievement-proof="ready"/); + assert.match(card('sol-days'),/≥8/); + assert.match(card('weekly-volume'),/≥500M/); + assert.match(card('codex-api-week'),/data-achievement-proof="ready"/); + assert.match(card('codex-api-week'),/≥\$1,000/); + assert.match(card('weekly-average'),/data-achievement-proof="pending"/); + assert.equal(host.achievementEvidenceStates['codex-calendar'],'pending'); + assert.equal(host.achievementEvidenceStates['combined-calendar'],'pending'); + host.revokedAchievementEvidence.add('codex'); + const revoked=host.renderAchievementData(); + assert.doesNotMatch(card('sol-days',revoked),/≥8/,'same-reference cached calendar is revoked too'); + host.revokedAchievementEvidence.delete('codex'); + assert.match(card('sol-days',host.renderAchievementData()),/≥8/); + await Promise.all([host.achievementWrite,host.achievementHourWrite,host.achievementCalendarWrite]); + assert.equal(host.achievementCalendarPeak,undefined,'partial dates never retain a four-week maximum'); + } finally {I18n.setTimezone(zone);I18n.setLanguage(language as any);(Module as any)._load=load;} +}); diff --git a/src/test/achievementCalibration.test.ts b/src/test/achievementCalibration.test.ts new file mode 100644 index 00000000..24dd5c6c --- /dev/null +++ b/src/test/achievementCalibration.test.ts @@ -0,0 +1,873 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_SERIES, codexModelSignals, evaluateAchievements } from '../achievements'; +import { ACHIEVEMENT_RECORD_FILE, AchievementRecordRepository, loadAchievementRecords } from '../achievementRecordStore'; +import { RECALIBRATED_ACHIEVEMENT_IDS_V2 } from '../achievementRuleVersions'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { CodexFileAggregate } from '../providers/codex/codexIndex'; +const rules = require('../achievements') as any; + +test('calibrated volume ladders leave sustained high-use milestones beyond entry tiers', () => { + const expected: Record = { + 'sol-regular': [1e9, 1e10, 1e11], 'astra-regular': [5e8, 5e9, 5e10], 'luna-regular': [1e8, 1e9, 1e10], + 'sonnet-main': [1e8, 1e9, 1e10], 'opus-cannon': [1e8, 1e9, 1e10], 'haiku-worker': [1e7, 1e8, 1e9], + 'fable-volume': [5e7, 5e8, 5e9], 'cache-wizard': [1e8, 1e9, 1e10], + 'claude-cache-creation': [1e7, 1e8, 1e9], 'claude-output': [2e6, 2e7, 2e8], + 'codex-output': [5e6, 5e7, 5e8], 'codex-cache-read': [1e9, 1e10, 1e11], + 'weekly-volume': [5e8, 2e9, 1e10], 'weekly-average': [5e8, 2e9, 5e9], 'model-rainbow': [2, 5, 7], + }; + for (const [series, targets] of Object.entries(expected)) { + assert.deepEqual(ACHIEVEMENT_SERIES[series].slice(0,targets.length).map(id => ACHIEVEMENT_DEFINITIONS.find(rule => rule.id === id)!.target), targets, series); + } +}); + +test('candidate calibration remaps only volume proof, never carries a changed calendar condition', () => { + assert.equal(typeof rules.migrateCalibratedAchievementGrants, 'function'); + const migrated = rules.migrateCalibratedAchievementGrants([ + 'sol-regular-gold', 'astra-regular-gold', 'sonnet-main-gold', 'haiku-worker-gold', + 'codex-output-bronze', 'claude-cache-creation-silver', 'active-weeks-gold', + 'fable-days-gold', 'three-family-day', 'local-share-export', 'hundred-billion-token-club', 'unknown', + ]); + assert.deepEqual(migrated, [ + 'astra-regular', 'astra-regular-silver', 'claude-cache-creation-bronze', + 'claude-cache-creation-gold', 'claude-cache-creation-silver', + 'haiku-worker', 'haiku-worker-silver', 'hundred-billion-token-club', + 'local-share-export', 'sol-regular', 'sol-regular-silver', 'sonnet-main', 'sonnet-main-silver', + ].sort()); + assert.deepEqual(rules.migrateCalibratedAchievementGrants(null), []); +}); + +test('Fable completes the seven-family rainbow without proxy-name or output-day fallback', () => { + const codexFiles = ['astra', 'sol', 'luna'].map(family => { + const tokens = { inputTotal: 9_980_000, outputTotal: 20_000 }; + return { session: { sessionKey: family, role: 'root' }, total: tokens, + byModel: { [`gpt-6-${family}`]: tokens }, byEffort: {}, byDay: {}, structural: {} } as CodexFileAggregate; + }); + const facts: any = { + claudeTokens: 0, codexTokens: 0, activeDays: 0, claudeFamilies: [], + claudeFamilyTokens: { sonnet: 10_000_000, opus: 10_000_000, haiku: 10_000_000 }, + claudeFamilyOutputTokens: { sonnet: 20_000, opus: 20_000, haiku: 20_000 }, + fable: { processedTokens: 10_000_000, outputTokens: 20_000, activeDays: 200 }, + calendar: { familyDays: { fable: 6 } }, + codex: codexModelSignals(codexFiles), + }; + const read = () => new Map(evaluateAchievements(facts).map(row => [row.id, row])); + assert.equal(read().get('model-rainbow-gold')!.unlocked, true); + assert.equal(read().get('fable-days-bronze')!.unlocked, false, 'unqualified old output days cannot substitute calendar proof'); + facts.calendar.familyDays.fable = 7; + assert.equal(read().get('fable-days-bronze')!.unlocked, true); + facts.fable.processedTokens = 9_999_999; + assert.equal(read().get('model-rainbow-gold')!.unlocked, false); + assert.equal(read().get('model-rainbow-gold')!.progress, 6); + facts.fable.processedTokens = 10_000_000; + facts.fable.outputTokens = 19_999; + assert.equal(read().get('model-rainbow-gold')!.unlocked, false); + facts.fable.outputTokens = 20_000; + assert.equal(read().get('model-rainbow-gold')!.unlocked, true); + for (const family of ['sonnet', 'opus', 'haiku']) { + facts.claudeFamilyTokens[family]--; + assert.equal(read().get('model-rainbow-gold')!.progress, 6, `${family}: processed floor`); + facts.claudeFamilyTokens[family]++; + facts.claudeFamilyOutputTokens[family]--; + assert.equal(read().get('model-rainbow-gold')!.progress, 6, `${family}: output floor`); + facts.claudeFamilyOutputTokens[family]++; + } +}); + +test('calibration rechecks every changed joint condition instead of inheriting its old grant', () => { + assert.equal(RECALIBRATED_ACHIEVEMENT_IDS_V2.length, 21); + assert.deepEqual(rules.migrateCalibratedAchievementGrants([ + ...RECALIBRATED_ACHIEVEMENT_IDS_V2, 'local-share-export', 'hundred-billion-token-club', + ]), ['hundred-billion-token-club', 'local-share-export']); +}); + +test('canonical v2 archives all changed rules and reissues only newly proven qualifications without Memento promotion', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'ccu-calibration-joint-')); + try { + // An old writer remains an explicit v1 fixture, not a production read path. + const legacyFile = path.join(directory, 'achievements-v1.json'); + await new AchievementRecordRepository(legacyFile).initialize([ + ...RECALIBRATED_ACHIEVEMENT_IDS_V2, 'local-share-export', + ]); + const oldBytes = await readFile(legacyFile); + const currentFile = path.join(directory, ACHIEVEMENT_RECORD_FILE); + const repository = new AchievementRecordRepository(currentFile); + let saved = await repository.initialize(RECALIBRATED_ACHIEVEMENT_IDS_V2); + assert.equal(saved.schemaVersion, 2); + assert.deepEqual(saved.records.map(row => row.id), ['local-share-export']); + assert.deepEqual(saved.legacyRecords!.map(row => row.id).sort(), [...RECALIBRATED_ACHIEVEMENT_IDS_V2].sort()); + assert.ok(saved.legacyRecords!.every(row => row.ruleVersion === 1 && !row.unread && + row.recordedDay === null && row.timeZone === null)); + assert.deepEqual(await readFile(legacyFile), oldBytes); + assert.deepEqual(await new AchievementRecordRepository(currentFile).initialize(RECALIBRATED_ACHIEVEMENT_IDS_V2), saved); + + // Current family evidence independently meets bronze and silver, not gold. + const results = evaluateAchievements({ claudeTokens: 0, codexTokens: 0, activeDays: 0, claudeFamilies: [], + claudeFamilyTokens: { sonnet: 10_000_000, opus: 10_000_000, haiku: 10_000_000 }, + claudeFamilyOutputTokens: { sonnet: 20_000, opus: 20_000, haiku: 20_000 }, + fable: { processedTokens: 10_000_000, outputTokens: 20_000, activeDays: 0, + mixedFamilySession: false, outputSessions: 0, allOutputSessions: 0 }, + codex: codexModelSignals([{ + session: { sessionKey: 'current', role: 'root' }, total: { inputTotal: 9_980_000, outputTotal: 20_000 }, + byModel: { 'gpt-6-astra': { inputTotal: 9_980_000, outputTotal: 20_000 } }, + byEffort: {}, byDay: {}, structural: {} as CodexFileAggregate['structural'], + }]), + }); + const proven = results.filter(row => row.unlocked && RECALIBRATED_ACHIEVEMENT_IDS_V2.includes(row.id)); + assert.deepEqual(proven.map(row => row.id).sort(), ['model-rainbow', 'model-rainbow-bronze']); + saved = (await repository.append(saved.generation, proven.map(row => ({ + id: row.id, origin: 'history' as const, recordedDay: null, timeZone: null, + })))).store; + assert.deepEqual(saved.records.filter(row => row.ruleVersion === 2).map(row => row.id).sort(), + ['model-rainbow', 'model-rainbow-bronze']); + assert.ok(saved.records.every(row => !row.unread && row.recordedDay === null)); + assert.equal(saved.legacyRecords!.length, 21); + assert.deepEqual(await loadAchievementRecords(currentFile), saved); + assert.deepEqual(await new AchievementRecordRepository(currentFile).initialize(RECALIBRATED_ACHIEVEMENT_IDS_V2), saved); + } finally { await rm(directory, { recursive: true, force: true }); } +}); +import { AchievementCalibrationSingleFlight, AchievementCalibrationOperation, shouldFetchQuotaForRefresh } from '../achievementCalibration'; +import { createClaudeUsageIndex } from '../claudeIncrementalIndex'; +import { RefreshSingleFlight } from '../refreshPolicy'; +import { ClaudeDataLoader } from '../dataLoader'; +import { appendFile, mkdir, writeFile } from 'node:fs/promises'; +import { snapshotFixture } from './codexFixtures'; +import { createBackgroundWorkState } from '../backgroundWorkState'; +import { ResourceOwnershipRegistry } from '../resourceOwnership'; +import { createEmptyQuotaObservationStore } from '../quotaObservationStore'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +function fixture() { + let enabled = true, cancelled = false, generation = 0; + const calls: string[] = []; + const operation: AchievementCalibrationOperation = { + enabled: () => enabled, + cancelled: () => cancelled, + generation: () => generation, + refreshLocal: async current => { assert.ok(current()); calls.push('local-refresh'); }, + recalibrate: async () => { calls.push('quiet-recalibration'); }, + }; + return { operation, calls, disable: () => { enabled = false; }, + cancel: () => { cancelled = true; }, change: () => { generation++; } }; +} + +test('calibration joins duplicate commands and runs one quiet commit after local refresh', async () => { + const f = fixture(), ready = deferred(), flight = new AchievementCalibrationSingleFlight(); + f.operation.refreshLocal = async current => { f.calls.push('local-refresh'); await ready.promise; assert.ok(current()); }; + const first = flight.run(f.operation), second = flight.run(f.operation); + assert.equal(first, second); + await Promise.resolve(); + assert.deepEqual(f.calls, ['local-refresh']); + ready.resolve(); + assert.equal(await first, 'recalibrated'); + assert.deepEqual(f.calls, ['local-refresh', 'quiet-recalibration']); + assert.equal(await flight.run(f.operation), 'recalibrated'); +}); + +test('OFF and cancellation before entry perform no reads or writes', async () => { + for (const mode of ['disabled', 'cancelled'] as const) { + const f = fixture(); + mode === 'disabled' ? f.disable() : f.cancel(); + assert.equal(await new AchievementCalibrationSingleFlight().run(f.operation), mode); + assert.deepEqual(f.calls, []); + } +}); + +test('failed local refresh cannot recalibrate or change a baseline', async () => { + const f = fixture(); + f.operation.refreshLocal = async () => { throw new Error('fixture failure'); }; + assert.equal(await new AchievementCalibrationSingleFlight().run(f.operation), 'failed'); + assert.deepEqual(f.calls, []); +}); + +test('OFF, cancellation and generation changes during refresh retire its commit guard', async () => { + for (const mode of ['disabled', 'cancelled', 'generation'] as const) { + const f = fixture(); + f.operation.refreshLocal = async current => { + assert.ok(current()); + mode === 'disabled' ? f.disable() : mode === 'cancelled' ? f.cancel() : f.change(); + assert.equal(current(), false); + }; + assert.equal(await new AchievementCalibrationSingleFlight().run(f.operation), + mode === 'disabled' ? 'disabled' : 'cancelled'); + assert.deepEqual(f.calls, []); + } +}); + +test('the same current guard reaches the durable commit and rejects late OFF, cancel and generation changes', async () => { + for (const mode of ['disabled', 'cancelled', 'generation'] as const) { + const f = fixture(), entered = deferred(), ready = deferred(); + let refreshGuard: (() => boolean) | undefined, writes = 0; + f.operation.refreshLocal = async current => { refreshGuard = current; }; + f.operation.recalibrate = async current => { + assert.equal(typeof current, 'function'); + assert.equal(current, refreshGuard); + assert.ok(current()); + entered.resolve(); + await ready.promise; + if (!current()) throw new Error('fixture retired before append'); + writes++; + }; + const pending = new AchievementCalibrationSingleFlight().run(f.operation); + // Do not strand the test if a regression omits the callback guard. + await Promise.race([entered.promise, pending]); + mode === 'disabled' ? f.disable() : mode === 'cancelled' ? f.cancel() : f.change(); + ready.resolve(); + assert.equal(await pending, mode === 'disabled' ? 'disabled' : 'cancelled'); + assert.equal(writes, 0); + } +}); + +test('local-only quota guard leaves ordinary refresh quota policy intact', () => { + assert.equal(shouldFetchQuotaForRefresh({ localOnly: true }), false); + assert.equal(shouldFetchQuotaForRefresh({}), true); + assert.equal(shouldFetchQuotaForRefresh({ localOnly: false }), true); +}); + +const calibrationNotifications: string[] = []; +const calibrationWarnings: string[] = []; +let calibrationCancelled = false; +function loadCalibrationExtension(): typeof import('../extension').ClaudeCodeUsageExtension { + const loader = require('node:module') as { _load: (...args: any[]) => any }; + const original = loader._load; + const vscode: any = new Proxy(function () {}, { + get: (_target, key) => key === 'then' ? undefined : key === 'window' ? { + showInformationMessage: (message: string) => { calibrationNotifications.push(message); }, + showErrorMessage: (message: string) => { calibrationNotifications.push(message); }, + showWarningMessage: (message: string) => { calibrationWarnings.push(message); }, + withProgress: async (_options: unknown, callback: any) => callback({}, { + get isCancellationRequested() { return calibrationCancelled; }, + }), + } : key === 'workspace' ? { workspaceFolders: undefined } : vscode, + apply: () => vscode, + construct: () => vscode, + }); + loader._load = function (request, ...args) { + return request === 'vscode' ? vscode : Reflect.apply(original, this, [request, ...args]); + }; + let Extension: typeof import('../extension').ClaudeCodeUsageExtension; + try { Extension = require('../extension').ClaudeCodeUsageExtension; } + finally { loader._load = original; } + return Extension!; +} + +test('production runRefresh excludes credentials and quota transport only for local-only work', async () => { + const Extension = loadCalibrationExtension(); + for (const localOnly of [true, false, undefined]) { + const extension: any = Object.create(Extension!.prototype); + let quotaCalls = 0, credentialReads = 0; + Object.assign(extension, { + disposed: false, configurationGeneration: 0, claudeIndexGeneration: 0, + getConfiguration: () => ({ dataDirectory: '' }), + selectClaudeUsageSource: () => undefined, + claudeUsageSourceKey: () => undefined, + shouldDeliverDashboard: () => false, + // Stop before discovery so the fixture cannot inspect real user logs. + claudeRefreshRecovery: { admit: () => false }, + publishRefreshState: () => undefined, + syncProviderUiSafely: () => undefined, + refreshGate: { complete: () => null }, + cache: {}, + statusBar: { updateQuota: () => undefined }, + webviewProvider: { updateQuota: () => undefined }, + getClaudeQuotaPaceSnapshot: () => undefined, + scheduleQuotaColdRetry: () => undefined, + maybeFetchUsageLimits: async () => { quotaCalls++; credentialReads++; return {}; }, + }); + await extension.runRefresh({ forceReload: false, trigger: 'manual', localOnly }); + assert.equal(quotaCalls, localOnly === true ? 0 : 1); + assert.equal(credentialReads, localOnly === true ? 0 : 1); + } +}); + +function calibrationExtension(directory: string) { + const Extension = loadCalibrationExtension(); + const extension: any = Object.create(Extension.prototype); + const states: Array<[boolean, boolean]> = [], calls: string[] = []; + const config = { dataDirectory: directory, enableAchievements: true, codexEnabled: false, + dashboardAutoRefresh: false, enableContentAnalysis: false, advicePromptWindowDays: 30, + projectGroupingMode: 'flat', contextWindowOverride: 0, pricingBackend: 'anthropic' }; + Object.assign(extension, { + disposed: false, configurationGeneration: 0, claudeIndexGeneration: 0, + achievementCalibrationGeneration: 0, achievementCalibration: new AchievementCalibrationSingleFlight(), + getConfiguration: () => config, codexProvider: {}, codexRefreshSuspensionDepth: 0, + refreshGate: new RefreshSingleFlight(), + cache: { records: [], contentAnalysis: null, manifest: null, claudeIndex: createClaudeUsageIndex(), + lastUpdate: new Date(0), dataDirectory: null, usageLimits: null }, + providerRefreshStates: { claude: { failed: false }, codex: { failed: false } }, + deliveredRefreshStates: {}, + statusBar: { updateUsageData: () => undefined, updateContext: () => undefined, setLoading: () => undefined }, + outputChannel: { appendLine: () => undefined }, + syncProviderUiSafely: () => undefined, + maybeFetchUsageLimits: () => { throw new Error('credential/transport canary'); }, + webviewProvider: { + setAchievementCalibrationState: (pending: boolean, failed = false) => { states.push([pending, failed]); }, + recalibrateAchievementsFromCurrentData: async () => { calls.push('recalibrate'); }, + beginClaudeAchievementObservation: async (current: () => boolean) => { + assert.ok(current()); calls.push('observe'); return {}; + }, + acceptClaudeAchievementSnapshot: () => { calls.push('snapshot'); }, + updateData: () => { calls.push('dashboard'); }, + updateRefreshState: () => undefined, + invalidateAchievementEvidence: () => undefined, + setLoading: () => undefined, + }, + }); + return { extension, states, calls, config }; +} + +test('real local command refreshes an isolated Claude fixture while paused and keeps warm zero-body reuse', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-manual-calibration-')); + try { + await mkdir(path.join(root, 'projects', 'fixture'), { recursive: true }); + await writeFile(path.join(root, 'projects', 'fixture', 'session.jsonl'), JSON.stringify({ + type: 'assistant', timestamp: new Date().toISOString(), requestId: 'fixture', + message: { id: 'fixture', model: 'claude-opus-5-5', usage: { input_tokens: 100, output_tokens: 1 } }, + }) + '\n'); + calibrationCancelled = false; + calibrationNotifications.length = 0; + const f = calibrationExtension(root); + const snapshots: any[] = []; + f.extension.webviewProvider.acceptClaudeAchievementSnapshot = (snapshot: unknown) => { + snapshots.push(snapshot); f.calls.push('snapshot'); + }; + const diagnostics: string[] = []; + f.extension.outputChannel.appendLine = (line: string) => diagnostics.push(line); + const first = f.extension.recalibrateAchievements(), duplicate = f.extension.recalibrateAchievements(); + assert.equal(first, duplicate); + await first; + assert.deepEqual(f.states, [[true, false], [false, false]], JSON.stringify({calls:f.calls,diagnostics})); + assert.equal(f.calls.filter(call => call === 'recalibrate').length, 1); + assert.ok(f.calls.includes('dashboard'), 'explicit pass delivers while dashboard is paused'); + assert.equal(f.extension.cache.records.length, 1); + const index = f.extension.cache.claudeIndex; + f.calls.length = 0; + diagnostics.length = 0; + await f.extension.recalibrateAchievements(); + assert.equal(f.extension.cache.claudeIndex, index); + assert.ok(diagnostics.some(line => /bodies=0/.test(line)), diagnostics.join('\n')); + assert.equal(f.calls.filter(call => call === 'recalibrate').length, 1); + const originalRows = [...index.sessionRows.values()]; + assert.ok(originalRows.length > 0); + f.extension.cache.claudeIndex = createClaudeUsageIndex(); + for (const snapshot of snapshots) { + assert.equal(typeof snapshot.sessionHistoryEligibility, 'function'); + assert.deepEqual([...snapshot.sessionHistoryEligibility()], originalRows); + assert.deepEqual([...snapshot.sessionHistoryEligibility()], originalRows, 'eligibility is replayable'); + } + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('Claude receipt source guard survives UI cancellation but retains source/index/clear/disposal fences', async (t) => { + const f = calibrationExtension('/fixture'); + let cancelled = false, ioCurrent!: () => boolean, receiptCurrent!: () => boolean, bodyPasses = 0; + t.mock.method(ClaudeDataLoader, 'findClaudeDataDirectory', async () => '/fixture'); + t.mock.method(require('../claudeUsageFiles'), 'scanUsageManifest', async () => + ({ entries: new Map(), scannedAtMs: 0, scanDurationMs: 0 })); + t.mock.method(require('../claudeIncrementalIndex'), 'updateClaudeUsageIndex', async () => { + bodyPasses++; throw new Error('body scan must not start after cancellation'); + }); + f.extension.webviewProvider.beginClaudeAchievementObservation = async (current: () => boolean, source: () => boolean) => { + ioCurrent = current; receiptCurrent = source; + cancelled = true; + return {}; + }; + const calibration = { current: () => !cancelled, accepted: false }; + await f.extension.startClaudeRefresh({ ...f.extension.refreshGate.request(false, 'manual'), localOnly: true, calibration }); + assert.equal(ioCurrent(), false); + assert.equal(typeof receiptCurrent, 'function'); + assert.equal(receiptCurrent(), true); + assert.equal(bodyPasses, 0); + assert.equal(calibration.accepted, false); + f.extension.claudeIndexGeneration++; + assert.equal(receiptCurrent(), false); + f.extension.claudeIndexGeneration--; + f.config.dataDirectory = '/replacement'; + assert.equal(receiptCurrent(), false); + f.config.dataDirectory = '/fixture'; + for (const field of ['disposed', 'clearingAllLocalData', 'localDataClearedRequiresReload']) { + f.extension[field] = true; + assert.equal(receiptCurrent(), false, field); + f.extension[field] = false; + } +}); + +test('Claude UI cancellation during discovery prevents manifest scanning', async (t) => { + const f = calibrationExtension('/fixture'); + let cancelled = false, scans = 0; + t.mock.method(ClaudeDataLoader, 'findClaudeDataDirectory', async () => { cancelled = true; return '/fixture'; }); + t.mock.method(require('../claudeUsageFiles'), 'scanUsageManifest', async () => { scans++; throw new Error('unexpected scan'); }); + const calibration = { current: () => !cancelled, accepted: false }; + await f.extension.startClaudeRefresh({ ...f.extension.refreshGate.request(false, 'manual'), localOnly: true, calibration }); + assert.equal(scans, 0); + assert.equal(calibration.accepted, false); +}); + +test('command OFF/cancel/lifecycle preflight/failure settles busy state without entering final recalibration', async () => { + for (const mode of ['off', 'cancel', 'disposed', 'clearing', 'reload', 'failure'] as const) { + calibrationCancelled = mode === 'cancel'; + const f = calibrationExtension('/fixture/never-read'); + if (mode === 'off') f.config.enableAchievements = false; + if (mode === 'disposed') f.extension.disposed = true; + if (mode === 'clearing') f.extension.clearingAllLocalData = true; + if (mode === 'reload') f.extension.localDataClearedRequiresReload = true; + f.extension.refreshLocalForAchievementCalibration = async () => { throw new Error('fixture failure'); }; + await f.extension.recalibrateAchievements(); + assert.deepEqual(f.calls, []); + assert.deepEqual(f.states, mode === 'failure' ? [[true, false], [false, true]] : [[false, false]]); + } + calibrationCancelled = false; +}); + +test('local configuration avoids BYOK lookups; explicit advice configuration still opts in', () => { + const Extension = loadCalibrationExtension(), extension: any = Object.create(Extension.prototype); + let reads = 0; + extension.settings = { get: (key: string) => { if (key === 'advice.apiKey') { reads++; return 'fixture-key'; } } }; + assert.equal(extension.getConfiguration().adviceApiKey, ''); + assert.equal(reads, 0); + assert.equal(extension.getConfiguration(true).adviceApiKey, 'fixture-key'); + assert.equal(reads, 1); +}); + +test('calibration admission cannot enqueue ordinary quota work when discovery races a production refresh', async (t) => { + const f = calibrationExtension('/fixture'); + const active = deferred(); + t.mock.method(ClaudeDataLoader, 'findClaudeDataDirectory', async () => { + f.extension.claudeRefreshDrain = active.promise; + return '/fixture'; + }); + let requests = 0; + f.extension.refreshGate.request = () => { requests++; return null; }; + await assert.rejects(f.extension.refreshLocalForAchievementCalibration(() => true), /refresh-busy/); + assert.equal(requests, 0); + active.resolve(); +}); + +test('ordinary work queued behind a local-only pass retains its own quota request', async (t) => { + const f = calibrationExtension('/fixture'), discovery = deferred(); + t.mock.method(ClaudeDataLoader, 'findClaudeDataDirectory', async () => { await discovery.promise; return null; }); + let quotaCalls = 0; + f.extension.maybeFetchUsageLimits = async () => { quotaCalls++; return {}; }; + f.extension.refreshCodexData = async () => undefined; + f.extension.getClaudeQuotaPaceSnapshot = () => undefined; + const request = f.extension.refreshGate.request(false, 'manual'); + const local = f.extension.startClaudeRefresh({ ...request, localOnly: true }); + await Promise.resolve(); + await f.extension.refreshData(false, 'poll'); + assert.equal(quotaCalls, 0); + discovery.resolve(); + await local; + if (f.extension.claudeRefreshDrain) await f.extension.claudeRefreshDrain; + assert.equal(quotaCalls, 1); +}); + +test('source generation changes during discovery cannot commit a calibration', async (t) => { + const f = calibrationExtension('/fixture'); + calibrationCancelled = false; + t.mock.method(ClaudeDataLoader, 'findClaudeDataDirectory', async () => { + f.extension.configurationGeneration++; + return '/fixture'; + }); + await f.extension.recalibrateAchievements(); + assert.deepEqual(f.calls, []); + assert.deepEqual(f.states, [[true, false], [false, false]]); +}); + +test('command forwards commit guard to Webview and late cancellation/source changes never announce completion', async () => { + for (const mode of ['cancel', 'source'] as const) { + const f = calibrationExtension('/fixture'), entered = deferred(), ready = deferred(); + calibrationCancelled = false; + calibrationNotifications.length = 0; + let writes = 0; + f.extension.refreshLocalForAchievementCalibration = async () => undefined; + f.extension.webviewProvider.recalibrateAchievementsFromCurrentData = async (current: () => boolean) => { + assert.equal(typeof current, 'function'); + assert.ok(current()); + entered.resolve(); + await ready.promise; + if (!current()) throw new Error('fixture commit guard retired'); + writes++; + }; + const pending = f.extension.recalibrateAchievements(); + await Promise.race([entered.promise, pending]); + if (mode === 'cancel') calibrationCancelled = true; + else f.extension.configurationGeneration++; + ready.resolve(); + await pending; + assert.equal(writes, 0); + assert.deepEqual(calibrationNotifications, []); + assert.deepEqual(f.states, [[true, false], [false, false]]); + } + calibrationCancelled = false; +}); + +test('successful durable commit is not relabelled cancelled by a subsequent cancel click', async () => { + for (const mode of ['cancel', 'generation'] as const) { + const f = fixture(); + let writes = 0; + f.operation.recalibrate = async current => { + assert.ok(current()); + writes++; + mode === 'cancel' ? f.cancel() : f.change(); + return true; + }; + assert.equal(await new AchievementCalibrationSingleFlight().run(f.operation), 'recalibrated'); + assert.equal(writes, 1); + } +}); + +test('precommit cancellation never invokes a callback capable of confirming a commit', async () => { + const f = fixture(); + let commitCalls = 0; + f.operation.refreshLocal = async () => { f.cancel(); }; + f.operation.recalibrate = async () => { commitCalls++; return true; }; + assert.equal(await new AchievementCalibrationSingleFlight().run(f.operation), 'cancelled'); + assert.equal(commitCalls, 0); +}); + +test('explicit completed result can confirm a no-new-write calibration despite a later cancel', async () => { + const f = fixture(); + f.operation.recalibrate = async current => { + assert.ok(current()); + // Existing awards already meet all conditions; the Webview confirms that + // the calibration itself completed before the subsequent cancellation. + f.cancel(); + return true; + }; + assert.equal(await new AchievementCalibrationSingleFlight().run(f.operation), 'recalibrated'); + assert.deepEqual(f.calls, ['local-refresh']); +}); + +test('false or legacy void results without confirmed writes still check retirement after await', async () => { + for (const committed of [false, undefined]) { + for (const mode of ['cancel', 'generation', 'off', 'current'] as const) { + const f = fixture(); + f.operation.recalibrate = async current => { + assert.ok(current()); + await Promise.resolve(); + if (mode === 'cancel') f.cancel(); + else if (mode === 'generation') f.change(); + else if (mode === 'off') f.disable(); + return committed; + }; + assert.equal(await new AchievementCalibrationSingleFlight().run(f.operation), + mode === 'current' ? 'recalibrated' : mode === 'off' ? 'disabled' : 'cancelled'); + } + } +}); + +test('command success toast follows confirmed commit despite late cancel or source retirement', async () => { + for (const committed of [true, false, undefined]) { + for (const mode of ['cancel', 'source'] as const) { + const f = calibrationExtension('/fixture'); + calibrationCancelled = false; + calibrationNotifications.length = 0; + f.extension.refreshLocalForAchievementCalibration = async () => undefined; + f.extension.webviewProvider.recalibrateAchievementsFromCurrentData = async (current: () => boolean) => { + assert.ok(current()); + // true represents a confirmed atomic write made while current() was + // valid; false/void do not establish that any new award was committed. + if (mode === 'cancel') calibrationCancelled = true; + else f.extension.configurationGeneration++; + return committed; + }; + await f.extension.recalibrateAchievements(); + assert.deepEqual(calibrationNotifications, committed === true + ? [require('../i18n').I18n.achievementManagement.recalibrated] : []); + assert.deepEqual(f.states, [[true, false], [false, false]]); + } + } + calibrationCancelled = false; +}); + +test('partial completion survives late cancellation/source retirement without becoming failed', async () => { + for (const mode of ['current', 'cancel', 'generation'] as const) { + const f = fixture(); + f.operation.recalibrate = async current => { + assert.ok(current()); + if (mode === 'cancel') f.cancel(); + if (mode === 'generation') f.change(); + return 'partial'; + }; + assert.equal(await new AchievementCalibrationSingleFlight().run(f.operation), 'partial'); + } +}); + +test('command partial completion uses warning toast even after a late cancellation', async () => { + for (const cancelled of [false, true]) { + const f = calibrationExtension('/fixture'); + calibrationCancelled = false; + calibrationNotifications.length = 0; + calibrationWarnings.length = 0; + f.extension.refreshLocalForAchievementCalibration = async () => undefined; + f.extension.webviewProvider.recalibrateAchievementsFromCurrentData = async (current: () => boolean) => { + assert.ok(current()); + calibrationCancelled = cancelled; + return 'partial'; + }; + await f.extension.recalibrateAchievements(); + const copy = require('../i18n').I18n.achievementManagement.recalibratedPartial; + assert.equal(typeof copy, 'string'); + assert.deepEqual(calibrationWarnings, [copy]); + assert.deepEqual(calibrationNotifications, []); + assert.deepEqual(f.states, [[true, false], [false, false]]); + } + calibrationCancelled = false; +}); + +test('a cooled failed production refresh cannot be reported as successful recalibration', async (t) => { + const f = calibrationExtension('/fixture'); + t.mock.method(ClaudeDataLoader, 'findClaudeDataDirectory', async () => '/fixture'); + f.extension.claudeRefreshRecovery = { admit: () => false }; + await f.extension.recalibrateAchievements(); + assert.deepEqual(f.calls, []); + assert.deepEqual(f.states, [[true, false], [false, true]]); +}); + +test('production Codex calibration performs one bounded pass and admits only a successful result', async () => { + for (const outcome of ['success', 'error', 'cancel'] as const) { + const f = calibrationExtension(''); + f.config.codexEnabled = true; + const snapshot = snapshotFixture(); + let passes = 0; + Object.assign(f.extension, { + codexRefreshGate: new RefreshSingleFlight(), codexRefreshDrain: null, + activeCodexRefreshes: new Set(), resourceOwnership: new ResourceOwnershipRegistry(), + waitForCodexProviderRetirements: async () => undefined, + codexHome: () => '/fixture/codex', selectCodexSnapshotHome: () => undefined, + saveCodexBackgroundState: async () => undefined, + stopFirstBackfillBlurDeadline: () => undefined, + codexBackgroundState: createBackgroundWorkState({ measurementVersion: 2, reason: 'history-backfill', now: 0 }), + windowActivity: { focused: true }, + applyCodexSnapshot: () => { f.extension.codexView = {}; }, + codexHistoricalWorkPending: () => true, + codexProvider: { + isAvailable: async () => true, + loadPersistedSnapshot: async () => snapshot, + refresh: async () => { + passes++; + return { outcome, snapshot, diagnostic: { failedFiles: outcome === 'error' ? 1 : 0 } }; + }, + }, + }); + let cancelled = false, ioCurrent!: () => boolean, receiptCurrent!: () => boolean; + f.extension.webviewProvider.beginCodexAchievementObservation = async (current: () => boolean, source: () => boolean) => { + ioCurrent = current; receiptCurrent = source; + cancelled = outcome === 'cancel'; + return {}; + }; + f.extension.webviewProvider.acceptCodexAchievementSnapshot = () => f.calls.push('codex-snapshot'); + const calibration = { current: () => !cancelled, accepted: false }; + await f.extension.refreshCodexData('manual', calibration); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(passes, outcome === 'cancel' ? 0 : 1); + assert.equal(calibration.accepted, outcome === 'success'); + assert.equal(f.calls.includes('codex-snapshot'), outcome === 'success'); + assert.equal(f.extension.codexRefreshDrain, null); + if (outcome === 'cancel') { + assert.equal(ioCurrent(), false); + assert.equal(receiptCurrent(), true); + f.extension.configurationGeneration++; + assert.equal(receiptCurrent(), false); + f.extension.configurationGeneration--; + const provider = f.extension.codexProvider; + f.extension.codexProvider = {}; + assert.equal(receiptCurrent(), false); + f.extension.codexProvider = provider; + f.extension.disposed = true; + assert.equal(receiptCurrent(), false); + } + } +}); + +test('Codex numeric history projection runs only for enabled accepted current-index evidence', async (t) => { + const historyModule = require('../achievementHistoryDates') as typeof import('../achievementHistoryDates'); + let projections = 0; + t.mock.method(historyModule, 'codexAchievementHistoryEvidence', (_files: CodexFileAggregate[], zone: string) => { + projections++; + return { timeZone: zone, complete: false, days: [] }; + }); + for (const mode of ['accepted', 'off', 'failed', 'unverified'] as const) { + const f = calibrationExtension(''); + f.config.enableAchievements = mode !== 'off'; + const snapshot = snapshotFixture(); + snapshot.currentManifestVerified = mode !== 'unverified'; + snapshot.coverage.complete = true; + Object.assign(f.extension, { + codexHome: () => '/fixture/codex', selectCodexSnapshotHome: () => undefined, + quotaObservationStore: createEmptyQuotaObservationStore(), + recordCodexQuotaObservations: async () => undefined, + codexView: null, + }); + const before = projections; + f.extension.applyCodexSnapshot(snapshot, mode !== 'failed'); + assert.equal(projections - before, mode === 'accepted' ? 1 : 0); + assert.equal(!!f.extension.codexAchievementSignals.historyDayEvidence, mode === 'accepted'); + } +}); + +test('completed real Claude index hands off its native delta after UI cancellation, but not actual source retirement', async (t) => { + const indexModule = require('../claudeIncrementalIndex') as typeof import('../claudeIncrementalIndex'); + const update = indexModule.updateClaudeUsageIndex; + let afterProduced = (_result: Awaited>) => {}; + t.mock.method(indexModule, 'updateClaudeUsageIndex', async (...args: Parameters) => { + const result = await update(...args); + afterProduced(result); // Real producer has finalized its index and native delta; host await has not resumed. + return result; + }); + for (const mode of ['cancel', 'presentation', 'source', 'clear', 'reload', 'off', 'off-on', 'disposed'] as const) { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-postcommit-')); + afterProduced = () => {}; + try { + const project = path.join(root, 'projects', 'fixture'), file = path.join(project, 'session.jsonl'); + await mkdir(project, { recursive: true }); + const row = (id: string) => JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString(), + requestId: id, message: { id, model: 'claude-sonnet-5', usage: { input_tokens: 100, output_tokens: 1 } } }) + '\n'; + await writeFile(file, row('baseline')); + const f = calibrationExtension(root); + let receiptCurrent!: () => boolean, acceptedDelta: unknown, acceptedSnapshot: any, historyCalls = 0, cancelled = false; + f.extension.webviewProvider.beginClaudeAchievementObservation = async (_current: () => boolean, source: () => boolean) => { + receiptCurrent = source; + return { window: { after: Date.now() - 60_000, through: Date.now() } }; + }; + f.extension.webviewProvider.acceptClaudeAchievementSnapshot = (snapshot: any, _handle: unknown, delta: unknown) => { + assert.equal(receiptCurrent(), true); + assert.equal(snapshot.records, f.extension.cache.records); + acceptedDelta = delta; acceptedSnapshot = snapshot; + }; + const start = (calibration?: { current(): boolean; accepted: boolean }) => f.extension.startClaudeRefresh({ + ...f.extension.refreshGate.request(false, 'manual'), localOnly: true, calibration, + }); + await start(); + acceptedSnapshot = undefined; acceptedDelta = undefined; + await appendFile(file, row('tail')); + let produced: Awaited> | undefined, producerPasses = 0; + afterProduced = result => { + produced = result; producerPasses++; + assert.equal(result.diagnostics.filesFailed, 0); + assert.ok((result.achievementUsageDelta?.processed ?? 0) > 0); + cancelled = true; + if (mode === 'presentation') f.extension.configurationGeneration++; + if (mode === 'source') f.extension.claudeIndexGeneration++; + if (mode === 'clear') f.extension.clearingAllLocalData = true; + if (mode === 'reload') f.extension.localDataClearedRequiresReload = true; + if (mode === 'off') f.config.enableAchievements = false; + if (mode === 'off-on') f.extension.achievementCalibrationGeneration++; + if (mode === 'disposed') f.extension.disposed = true; + }; + let calibration!: { current(): boolean; accepted: boolean }; + const result = await new AchievementCalibrationSingleFlight().run({ + enabled: () => f.config.enableAchievements, + cancelled: () => cancelled, + generation: () => f.extension.achievementCalibrationGeneration, + refreshLocal: async current => { calibration = { current, accepted: false }; await start(calibration); }, + recalibrate: async () => { historyCalls++; return true; }, + }); + assert.equal(result, mode === 'off' ? 'disabled' : 'cancelled'); + assert.equal(historyCalls, 0); + assert.equal(producerPasses, 1); + assert.equal(calibration.accepted, mode === 'cancel'||mode==='presentation', mode); + if (mode === 'cancel'||mode==='presentation') { + assert.equal(f.extension.cache.claudeIndex, produced!.index); + assert.equal(acceptedDelta, produced!.achievementUsageDelta, 'forward exactly the native delta, never rebuild one'); + assert.ok(acceptedSnapshot); + assert.equal(receiptCurrent(), true); + assert.equal(f.extension.providerRefreshStates.claude.failed, false); + } else assert.equal(acceptedSnapshot, undefined, mode); + } finally { afterProduced = () => {}; await rm(root, { recursive: true, force: true }); } + } +}); + +test('persisted real Codex worker result reaches handoff after cancellation before provider await resumes', async () => { + const { CodexProvider } = require('../providers/codex/codexProvider') as typeof import('../providers/codex/codexProvider'); + for (const mode of ['cancel', 'provider', 'clear', 'reload', 'off', 'off-on', 'disposed'] as const) { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-codex-postcommit-')); + let provider: InstanceType | undefined; + try { + const home = path.join(root, 'home'), sessions = path.join(home, 'sessions'), file = path.join(sessions, 'session.jsonl'); + const indexPath = path.join(root, 'derived', 'index.json'); + await mkdir(sessions, { recursive: true }); + const token = (input: number) => JSON.stringify({ timestamp: new Date().toISOString(), type: 'event_msg', + payload: { type: 'token_count', info: { total_token_usage: { input_tokens: input, output_tokens: input / 10, + cached_input_tokens: 0, reasoning_output_tokens: 0, total_tokens: input + input / 10 } } } }) + '\n'; + await writeFile(file, JSON.stringify({ type: 'session_meta', payload: { id: 'fixture-live' } }) + '\n' + + JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-6.1-astra', effort: 'max' } }) + '\n' + token(100)); + const f = calibrationExtension(''); + f.config.codexEnabled = true; + const logs: string[] = []; + f.extension.outputChannel.appendLine = (line: string) => logs.push(line); + provider = new CodexProvider({ enabled: true, codexHome: home, indexPath, + salt: 'isolated-calibration-fixture', timeZone: 'UTC', allowAchievementHourProof: true }); + Object.assign(f.extension, { + codexRefreshGate: new RefreshSingleFlight(), codexRefreshDrain: null, + activeCodexRefreshes: new Set(), resourceOwnership: new ResourceOwnershipRegistry(), + waitForCodexProviderRetirements: async () => undefined, + codexHome: () => home, saveCodexBackgroundState: async () => undefined, + stopFirstBackfillBlurDeadline: () => undefined, + codexBackgroundState: createBackgroundWorkState({ measurementVersion: 2, reason: 'history-backfill', now: 0 }), + windowActivity: { focused: true }, codexProvider: provider, codexView: null, + quotaObservationStore: createEmptyQuotaObservationStore(), recordCodexQuotaObservations: async () => undefined, + }); + let receiptCurrent!: () => boolean, acceptedDelta: unknown, acceptedView: unknown, cancelled = false, historyCalls = 0; + f.extension.webviewProvider.beginCodexAchievementObservation = async (_current: () => boolean, source: () => boolean) => { + receiptCurrent = source; + return { window: { after: Date.now() - 60_000, through: Date.now() } }; + }; + f.extension.webviewProvider.acceptCodexAchievementSnapshot = (view: unknown, _signals: unknown, _handle: unknown, delta: unknown) => { + assert.equal(receiptCurrent(), true); + acceptedView = view; acceptedDelta = delta; + }; + await f.extension.refreshCodexData('manual', { current: () => true, accepted: false }); + assert.ok(f.extension.codexView?.currentManifestVerified, logs.join('\n')); + acceptedDelta = undefined; acceptedView = undefined; + await appendFile(file, token(300)); + const refresh = provider.refresh.bind(provider); + let produced: Awaited> | undefined, producerPasses = 0, persisted = false; + provider.refresh = async (...args: Parameters) => { + const result = await refresh(...args); + produced = result; producerPasses++; + persisted = (await readFile(indexPath)).length > 0; + cancelled = true; // Persisted real worker result exists, extension await has not resumed. + if (mode === 'provider') f.extension.codexProvider = {}; + if (mode === 'clear') f.extension.clearingAllLocalData = true; + if (mode === 'reload') f.extension.localDataClearedRequiresReload = true; + if (mode === 'off') f.config.enableAchievements = false; + if (mode === 'off-on') f.extension.achievementCalibrationGeneration++; + if (mode === 'disposed') f.extension.disposed = true; + return result; + }; + let calibration!: { current(): boolean; accepted: boolean }; + const result = await new AchievementCalibrationSingleFlight().run({ + enabled: () => f.config.enableAchievements, cancelled: () => cancelled, + generation: () => f.extension.achievementCalibrationGeneration, + refreshLocal: async current => { calibration = { current, accepted: false }; await f.extension.refreshCodexData('manual', calibration); }, + recalibrate: async () => { historyCalls++; return true; }, + }); + assert.equal(producerPasses, 1, logs.join('\n')); + assert.equal(produced?.diagnostic?.failedFiles, 0, JSON.stringify(produced?.diagnostic)); + assert.ok((produced?.achievementUsageDelta?.processed ?? 0) > 0, JSON.stringify({ + diagnostic: produced?.diagnostic, delta: produced?.achievementUsageDelta, + complete: produced?.snapshot.coverage.complete, quality: produced?.snapshot.qualityFlags, + })); + assert.ok(persisted, 'worker has already persisted the isolated index'); + assert.equal(result, mode === 'off' ? 'disabled' : 'cancelled'); + assert.equal(historyCalls, 0); + assert.equal(producerPasses, 1); + assert.equal(calibration.accepted, mode === 'cancel', mode); + if (mode === 'cancel') { + assert.equal(acceptedDelta, produced!.achievementUsageDelta); + assert.equal(acceptedView, f.extension.codexView); + assert.ok(acceptedView); + assert.equal(receiptCurrent(), true); + assert.equal(f.extension.providerRefreshStates.codex.failed, false); + } else assert.equal(acceptedView, undefined, mode); + } finally { await provider?.dispose(); await rm(root, { recursive: true, force: true }); } + } +}); diff --git a/src/test/achievementCalibrationUi.test.ts b/src/test/achievementCalibrationUi.test.ts new file mode 100644 index 00000000..ea411ef5 --- /dev/null +++ b/src/test/achievementCalibrationUi.test.ts @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import Module = require('node:module'); + +async function makeProvider(values: Map, fail?: (key: string, value: unknown) => boolean): Promise { + const load = (Module as any)._load; + (Module as any)._load = function(request: string, parent: unknown, main: boolean) { + return request === 'vscode' ? { workspace: { workspaceFolders: [] } } : load.call(this, request, parent, main); + }; + try { + const { UsageWebviewProvider } = require('../webview'); + const provider = new UsageWebviewProvider({ globalState: { + get: (key: string) => values.get(key), update: async (key: string, value: unknown) => { + if (fail?.(key, value)) throw new Error('fixture persistence failure'); + if (value === undefined) values.delete(key); else values.set(key, value); + }, + } }) as any; + await provider.achievementWrite.catch(() => undefined); + return provider; + } finally { (Module as any)._load = load; } +} +const marker = 'ccu.achievements.calibrationAudit.v1'; +function state(): Map { + return new Map([ + ...['structural', 'token', 'bucket', 'manifest', 'calendar'].map(name => + [`ccu.achievements.${name}Audit.v1`, true] as [string, unknown]), + ['ccu.achievements.grants.v5', ['sol-regular-gold', 'local-share-export', 'hundred-million-club']], + ]); +} + +test('calibration remaps proven floors once and resumes safely after each persistence boundary fails', async () => { + for (const failedKey of ['ccu.achievements.calibrationPending.v1', 'ccu.achievements.grants.v5', marker]) { + const values = state(); let failed = false; + await makeProvider(values, (key, value) => { + if (!failed && key === failedKey && value !== undefined) { failed = true; return true; } + return false; + }); + assert.equal(failed, true, `exercises ${failedKey}`); + assert.notEqual(values.get(marker), true, 'failed migration cannot claim completion'); + const resumed = await makeProvider(values); + assert.equal(resumed.achievementGrants.has('sol-regular-silver'), true, + 'old gold proves 10B, even if grants saved before a failed completion marker'); + assert.equal(resumed.achievementGrants.has('sol-regular-gold'), false, 'old 10B cannot prove new 100B'); + assert.equal(resumed.achievementGrants.has('local-share-export'), true); + assert.equal(values.get(marker), true); + assert.equal(values.has('ccu.achievements.calibrationPending.v1'), false); + const again = await makeProvider(values); + assert.deepEqual([...again.achievementGrants].sort(), [...resumed.achievementGrants].sort()); + await again.clearAchievementGrants(); + assert.equal(values.has(marker), false); + assert.equal(values.has('ccu.achievements.calibrationPending.v1'), false); + } +}); + +test('recalibration reproof is quiet and cannot discard unrelated local-action history', async () => { + const values = state(); const provider = await makeProvider(values); const notices: unknown[] = []; + provider.panel = { visible: true, webview: { postMessage: async (message: unknown) => { notices.push(message); return true; } } }; + provider.webviewClientReady = true; + provider.allTimeData = { totalInputTokens: 100_000_000, totalOutputTokens: 0, + totalCacheCreationTokens: 0, totalCacheReadTokens: 0, + modelBreakdown: { 'claude-sonnet-5': { inputTokens: 100_000_000, outputTokens: 0, + cacheCreationTokens: 0, cacheReadTokens: 0 } } }; + provider.achievementResults(); await provider.achievementWrite; + assert.equal(provider.achievementGrants.has('sonnet-main'), true); + assert.equal(provider.achievementGrants.has('local-share-export'), true); + assert.deepEqual(notices, [], 'a changed threshold must not announce a backlog on upgrade'); +}); diff --git a/src/test/achievementCatalog.test.ts b/src/test/achievementCatalog.test.ts new file mode 100644 index 00000000..ac58780a --- /dev/null +++ b/src/test/achievementCatalog.test.ts @@ -0,0 +1,166 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_SERIES } from '../achievements'; +import { ACHIEVEMENT_IMAGE_FILES, achievementImageDataUrl } from '../achievementIcons'; +import { createHash } from 'node:crypto'; +import { ACHIEVEMENT_GALLERY, achievementGalleryKey } from '../achievementGallery'; +import { API_ACHIEVEMENT_DEFINITIONS, API_ACHIEVEMENT_SERIES, isAchievementMoneyId } from '../achievementMoneyCatalog'; +import { achievementConditionFor } from '../achievementConditions'; +import { formatAchievementCompact, formatAchievementInteger } from '../achievementFormat'; +import { achievementCopyFor } from '../i18n'; + +const artworkGroupCount = Object.keys(ACHIEVEMENT_SERIES).length + + ACHIEVEMENT_DEFINITIONS.filter(rule => !Object.values(ACHIEVEMENT_SERIES).some(ids => ids.includes(rule.id))).length; + +const { achievementCatalog, renderCatalogGroup, renderCatalogMarkdown } = require(resolve( + __dirname, '../../tests/ui/support/achievement-catalog.cjs', +)); + +test('mobile review catalog covers every live rule exactly once without placeholder awards', () => { + const catalog = achievementCatalog('zh-CN'); + const ids = catalog.flatMap((card: any) => card.levels.map((level: any) => level.id)); + assert.equal(new Set(ids).size, ids.length); + assert.deepEqual(ids.sort(), ACHIEVEMENT_DEFINITIONS.map((entry) => entry.id).sort()); + assert.equal(catalog.filter((card: any) => card.levels.length > 1).length, + Object.keys(ACHIEVEMENT_SERIES).length); + assert.ok(catalog.every((card: any) => card.title && card.metric && card.note)); + for (const card of catalog) assert.equal(card.displayKey, achievementGalleryKey(card.key)); + assert.equal(new Set(catalog.map((card: any) => card.displayKey)).size, ACHIEVEMENT_GALLERY.length); + assert.equal(catalog.length, artworkGroupCount); +}); + +test('different achievement series have distinct artwork; every tier within a series shares its emblem', () => { + const catalog = achievementCatalog('en'); + const hashes = new Set(); + for (const card of catalog) { + assert.equal(new Set(card.levels.map((level: any) => level.artwork)).size, 1, card.key); + const hash = createHash('sha256').update(card.levels[0].image).digest('hex'); + assert.equal(hashes.has(hash), false, `duplicated artwork: ${card.key}`); + hashes.add(hash); + } + assert.equal(hashes.size, artworkGroupCount); +}); + +test('review targets and all tier icons come directly from the production catalog', () => { + const definitionsBefore = JSON.stringify(ACHIEVEMENT_DEFINITIONS); + for (const locale of ['zh-CN', 'en']) { + for (const card of achievementCatalog(locale)) { + for (const level of card.levels) { + const definition = ACHIEVEMENT_DEFINITIONS.find((entry) => entry.id === level.id)!; + assert.equal(level.target, definition.target); + assert.equal(level.artwork, ACHIEVEMENT_IMAGE_FILES[level.id as keyof typeof ACHIEVEMENT_IMAGE_FILES]); + assert.equal(level.image, achievementImageDataUrl(definition.id)); + } + } + } + assert.equal(JSON.stringify(ACHIEVEMENT_DEFINITIONS), definitionsBefore); +}); + +test('review catalog includes twenty-four monetary conditions in six provider-specific themes', () => { + assert.equal(API_ACHIEVEMENT_DEFINITIONS.length, 24); + assert.equal(Object.keys(API_ACHIEVEMENT_SERIES).length, 6); + for (const locale of ['zh-CN', 'en']) { + const catalog = achievementCatalog(locale); + const monetary = catalog.filter((card: any) => card.levels.some((level: any) => isAchievementMoneyId(level.id))); + assert.equal(monetary.length, 6); + assert.deepEqual(monetary.flatMap((card: any) => card.levels.map((level: any) => level.id)), + API_ACHIEVEMENT_DEFINITIONS.map(rule => rule.id)); + for (const provider of ['claude', 'codex']) { + const cards = monetary.filter((card: any) => card.group === provider); + assert.deepEqual(cards.map((card: any) => card.levels.length), [4, 5, 3]); + assert.equal(cards.reduce((sum: number, card: any) => sum + card.levels.length, 0), 12); + } + for (const card of monetary) { + assert.match(card.metric, /USD/); + assert.match(card.note, locale === 'en' ? /Frozen reference pricebook/ : /冻结参考价目表/); + assert.match(card.note, locale === 'en' ? /not a bill or a task-quality score/ : /不是账单或任务质量评分/); + assert.match(card.note, locale === 'en' ? /unknown-price usage is excluded/ : /未知价格用量不计入金额/); + assert.match(card.note, /≥/); + assert.match(card.note, /microUSD/); + } + const fourTier = catalog.filter((card: any) => card.levels.length === 4); + assert.deepEqual(fourTier.map((card: any) => card.key), ['claude-api-total', 'codex-api-total']); + for (const card of fourTier) { + assert.deepEqual(card.levels.map((level: any) => level.tier), ['ordinary', 'silver', 'gold', 'platinum']); + assert.deepEqual(card.levels.map((level: any) => level.label), achievementCopyFor(locale).levels.slice(0, 4)); + } + const markdown = renderCatalogMarkdown(catalog, locale); + assert.match(markdown, locale === 'en' ? /2 four-tier series/ : /2 个四级系列/); + assert.match(markdown, new RegExp(`${ACHIEVEMENT_DEFINITIONS.length} ${locale === 'en' ? 'award conditions' : '个解锁条件'}`)); + assert.match(markdown, new RegExp(`${ACHIEVEMENT_GALLERY.length} ${locale === 'en' ? 'default theme cards' : '张主题卡'}`)); + } +}); + +test('API badges select six versioned coin or pouch emblems without changing award identities', () => { + const images = new Set(); + for (const rule of API_ACHIEVEMENT_DEFINITIONS) { + const expected = rule.id.replace(/-(bronze|silver|gold|platinum|legendary)$/, '-v242-v2.png'); + assert.equal(ACHIEVEMENT_IMAGE_FILES[rule.id], expected); + const bytes = Buffer.from(achievementImageDataUrl(rule.id).split(',')[1], 'base64'); + assert.equal(bytes.readUInt32BE(16), 256); + assert.equal(bytes.readUInt32BE(20), 256); + images.add(expected); + } + assert.equal(images.size, 6); + assert.equal(API_ACHIEVEMENT_DEFINITIONS.length, 24); +}); + +test('monetary review displays floored USD while retaining exact production microUSD targets', () => { + for (const locale of ['zh-CN', 'en']) { + const catalog = achievementCatalog(locale).filter((card: any) => isAchievementMoneyId(card.levels[0].id)); + for (const card of catalog) { + const html = renderCatalogGroup([card], card.group, locale); + const markdown = renderCatalogMarkdown([card], locale); + for (const level of card.levels) { + const displayed = '$' + formatAchievementInteger(Math.floor(level.target / 1_000_000), locale); + assert.ok(html.includes(`${displayed}`)); + assert.ok(markdown.includes(displayed)); + assert.ok(!html.includes(`${formatAchievementCompact(level.target)}`)); + } + } + const sample = { ...catalog[0], levels: [{ ...catalog[0].levels[0], target: 100_999_999 }] }; + assert.match(renderCatalogGroup([sample], sample.group, locale), /\$100<\/strong>/); + assert.equal(sample.levels[0].target, 100_999_999); + } +}); + +test('joint qualifications use production per-level copy alongside formal repetition or breadth targets', () => { + for (const locale of ['zh-CN', 'en']) { + const catalog = achievementCatalog(locale); + const qualifiedIds = ACHIEVEMENT_DEFINITIONS.filter(rule => achievementConditionFor(locale, rule.id)).map(rule => rule.id); + const reviewedIds: string[] = []; + for (const card of catalog) { + for (const level of card.levels) { + const condition = achievementConditionFor(locale, level.id); + assert.equal(level.condition, condition); + if (!condition) continue; + reviewedIds.push(level.id); + assert.ok(card.note.includes(`${level.label}: ≥${formatAchievementCompact(level.target)} ${card.metric}; ${condition}`), level.id); + assert.ok(renderCatalogMarkdown([card], locale).includes(condition), level.id); + } + } + assert.deepEqual(reviewedIds, qualifiedIds); + } +}); + +test('mobile review sheets explicitly show catalog conditions, not personal unlock progress', () => { + const catalog = achievementCatalog('zh-CN'); + for (const group of ['claude', 'codex', 'combined', 'plugin']) { + const html = renderCatalogGroup(catalog, group, 'zh-CN'); + assert.match(html, /图鉴审阅,非个人解锁记录/); + assert.doesNotMatch(html, / { + for (const locale of ['zh-CN', 'en']) { + const markdown = renderCatalogMarkdown(achievementCatalog(locale), locale); + assert.match(markdown, /^# v2\.4\.2 /); + assert.doesNotMatch(markdown, /v2\.4\.1/); + assert.doesNotMatch(markdown, /已获得|personally earned/); + assert.match(markdown, locale === 'en' ? /not personal award history/ : /不是个人获奖记录/); + } +}); diff --git a/src/test/achievementCodexOriginModels.test.ts b/src/test/achievementCodexOriginModels.test.ts new file mode 100644 index 00000000..dc0b5a28 --- /dev/null +++ b/src/test/achievementCodexOriginModels.test.ts @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { addCodexOriginEvent, emptyCodexUsageDelta, finalizeCodexUsageDelta } from '../providers/codex/codexAchievementOrigin'; +import { createEmptyCodexIndex, updateCodexIndex } from '../providers/codex/codexIndex'; +import { scanCodexManifest } from '../providers/codex/codexManifest'; +import type { CodexIndexV3 } from '../providers/codex/codexIndex'; +import type { NormalizedUsageEvent } from '../providers/providerTypes'; + +const window = { after: Date.parse('2026-10-09T01:00:00Z'), through: Date.parse('2026-10-09T02:00:00Z') }; +const tokens = { inputTotal: 10, cachedInput: 4, outputTotal: 2 }; +const familyLabels = [ + ['gpt-6-sol', 'sol'], ['openai/gpt-6-sol', 'sol'], ['openai/gpt-6.1-astra', 'astra'], + ['gpt-6.2-sol', 'sol'], ['openai/gpt-6-luna-2026-09-01', 'luna'], + [' GPT-6-SOL ', 'sol'], [' OPENAI/GPT-6.1-ASTRA ', 'astra'], +] as const; +const rejectedLabels = ['gpt-6-sol-custom', 'gpt-6-sol-', 'openai/gpt-6-sol-custom', + 'gpt-6-sol-2026-09-01-custom', 'proxy/gpt-6-sol', 'unknown', ' GPT-6-SOL-CUSTOM ']; +function event(model: string): NormalizedUsageEvent { + return { provider: 'codex', sourceKind: 'local-jsonl', schemaVariant: 'fixture', timestamp: window.after + 1, + sessionKey: 'fixture', model, tokens: { ...tokens }, confidence: 'exact', qualityFlags: [] }; +} + +test('origin events admit strict family aliases and future snapshots without changing totals or money', () => { + for (const [model, family] of familyLabels) { + const delta = emptyCodexUsageDelta(window); + assert.equal(addCodexOriginEvent(delta, event(model)), true); + assert.equal(delta.families[family], 12, model); + assert.equal(delta.processed, 12); assert.equal(delta.output, 2); assert.equal(delta.cacheRead, 4); + if (model === 'gpt-6-sol' || model === 'openai/gpt-6-sol') { + assert.equal(delta.money?.openai?.microUsd, 32); + assert.equal(delta.money?.openai?.eligibleProcessed, 12); + } + if (model.includes('6.2-sol') || model.includes('2026-09-01') || model.startsWith(' ')) { + assert.equal(delta.money, undefined, 'nonmoney normalization never expands monetary alias admission'); + } + } + const native = emptyCodexUsageDelta(window), alias = emptyCodexUsageDelta(window); + addCodexOriginEvent(native, event('gpt-6-sol')); + addCodexOriginEvent(alias, event('openai/gpt-6-sol')); + assert.deepEqual(alias, native, 'known aliases preserve exact money proof and global counters'); +}); + +test('origin events retain global usage but withhold family and money for custom/dangling labels', () => { + for (const model of rejectedLabels) { + const delta = emptyCodexUsageDelta(window); + assert.equal(addCodexOriginEvent(delta, event(model)), true); + assert.deepEqual(delta.families, { astra: 0, sol: 0, luna: 0 }, model); + assert.equal(delta.processed, 12); assert.equal(delta.output, 2); assert.equal(delta.cacheRead, 4); + assert.equal(delta.money, undefined); + } +}); + +async function withIndexes(run: (before: CodexIndexV3, after: CodexIndexV3, key: string) => void): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-origin-models-')); + const salt = 'origin-model-test'; + try { + const sessions = path.join(root, 'sessions'); await mkdir(sessions); + const log = path.join(sessions, 'usage.jsonl'); + const tokenLine = (multiple: number, timestamp: string) => JSON.stringify({ type: 'event_msg', timestamp, + payload: { type: 'token_count', info: { + total_token_usage: { input_tokens: 10 * multiple, cached_input_tokens: 4 * multiple, output_tokens: 2 * multiple }, + last_token_usage: { input_tokens: 10, cached_input_tokens: 4, output_tokens: 2 }, + } } }) + '\n'; + await writeFile(log, JSON.stringify({ type: 'session_meta', payload: { id: 'root', cwd: '/fixture' } }) + '\n' + + JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-6-sol' } }) + '\n' + + tokenLine(1, '2026-10-09T00:00:00Z')); + const options = { salt, timeZone: 'UTC', now: () => window.through, achievementUsageWindow: window }; + let baseline = await updateCodexIndex(createEmptyCodexIndex('UTC'), await scanCodexManifest(root, salt), options); + for (let i = 0; baseline.migration.pending && i < 6; i++) { + baseline = await updateCodexIndex(baseline.index, await scanCodexManifest(root, salt), options); + } + await appendFile(log, tokenLine(2, '2026-10-09T01:30:00Z')); + const live = await updateCodexIndex(baseline.index, await scanCodexManifest(root, salt), options); + assert.equal(live.achievementUsageDelta?.processed, 12, 'fixture has settled live append proof'); + run(baseline.index, live.index, Object.keys(live.index.files)[0]); + } finally { await rm(root, { recursive: true, force: true }); } +} + +function relabel(index: CodexIndexV3, key: string, model: string): CodexIndexV3 { + const copy = structuredClone(index); + copy.files[key].aggregate.byModel = { [model]: { ...copy.files[key].aggregate.total } }; + return copy; +} + +test('aggregate origin reconciliation admits the same canonical family aliases and unpriced snapshots', async () => { + await withIndexes((before, after, key) => { + for (const [model, family] of familyLabels) { + const delta = { ...emptyCodexUsageDelta(window), processed: 12, output: 2, cacheRead: 4 }; + delta.families[family] = 12; + const result = finalizeCodexUsageDelta(relabel(before, key, model), relabel(after, key, model), new Map([[key, delta]]), window); + assert.deepEqual(result, delta, model); + } + }); +}); + +test('aggregate custom labels cannot validate a claimed family delta but retain global-only proof', async () => { + await withIndexes((before, after, key) => { + for (const model of rejectedLabels) { + const old = relabel(before, key, model), next = relabel(after, key, model); + const global = { ...emptyCodexUsageDelta(window), processed: 12, output: 2, cacheRead: 4 }; + assert.deepEqual(finalizeCodexUsageDelta(old, next, new Map([[key, global]]), window), global, model); + const claimed = { ...global, families: { astra: 0, sol: 12, luna: 0 } }; + assert.equal(finalizeCodexUsageDelta(old, next, new Map([[key, claimed]]), window), undefined, model); + } + }); +}); diff --git a/src/test/achievementCodexSubset.test.ts b/src/test/achievementCodexSubset.test.ts new file mode 100644 index 00000000..0d2c603b --- /dev/null +++ b/src/test/achievementCodexSubset.test.ts @@ -0,0 +1,192 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import Module = require('node:module'); +import { codexModelSignals } from '../achievements'; +import { codexAchievementTokenEvidence } from '../achievementCalendar'; +import { verifiedCodexAchievementFiles } from '../codexAchievementProof'; +import type { CodexFileAggregate } from '../providers/codex/codexIndex'; +import { ACHIEVEMENT_RECORD_FILE, loadAchievementRecords } from '../achievementRecordStore'; +import { mkdtemp, rm } from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { I18n } from '../i18n'; + +function file(key: string, input = 600_000_000, output = 6_000_000): CodexFileAggregate { + const tokens = { inputTotal: input, cachedInput: input, outputTotal: output, reasoningOutput: output }; + return { session: { sessionKey: key, role: 'root' }, total: tokens, + byModel: { 'gpt-6.1-astra': tokens }, byEffort: { max: tokens }, byDay: {}, structural: {} as any }; +} + +function corpus(): CodexFileAggregate[] { + const child = file('ambiguous-child'); + child.session = { sessionKey: 'ambiguous-child', parentSessionKey: 'conflict', role: 'subagent' }; + return [file('safe'), file('conflict', 1), file('conflict', 2), child]; +} + +test('a conflicting branch cannot erase independently verified output/cache milestone proof', () => { + const proof = codexAchievementTokenEvidence(corpus(), true); + assert.equal(proof.complete, true); + assert.equal(proof.output, 6_000_000); + assert.equal(proof.cachedInput, 600_000_000); + assert.equal((proof as any).lowerBound, true); + assert.equal(codexAchievementTokenEvidence(corpus(), false).complete, false, + 'incomplete index may still discover conflicts; do not grant early'); +}); + +test('a malformed model vector or unsafe subset counter is excluded from both family and total proof', () => { + for (const mutate of [ + (row: CodexFileAggregate) => { row.total.cachedInput = row.total.inputTotal + 1; }, + (row: CodexFileAggregate) => { row.byModel['gpt-6.1-astra'].cachedInput = row.total.inputTotal + 1; }, + (row: CodexFileAggregate) => { row.total.reasoningOutput = row.total.outputTotal + 1; }, + ]) { + const bad = file('bad'); + mutate(bad); + assert.equal(codexModelSignals([bad]).familyProcessedTokens?.astra, 0); + assert.equal(codexAchievementTokenEvidence([bad], true).complete, false); + } +}); + +test('unknown roles, root-with-parent, missing parents and cycles block only their lineages', () => { + const safe = file('safe'); + for (const rows of [ + [file('unknown')].map(row => ({ ...row, session: { sessionKey: 'unknown', role: 'unknown' } })), + [file('bad-root')].map(row => ({ ...row, session: { sessionKey: 'bad-root', role: 'root', parentSessionKey: 'safe' } })), + [file('orphan')].map(row => ({ ...row, session: { sessionKey: 'orphan', role: 'subagent', parentSessionKey: 'absent' } })), + [file('cycle-a'), file('cycle-b')].map((row, index) => ({ ...row, + session: { sessionKey: 'cycle-' + (index ? 'b' : 'a'), role: 'subagent', parentSessionKey: 'cycle-' + (index ? 'a' : 'b') } })), + ]) { + const invalid = rows as CodexFileAggregate[]; + const child = file('bad-child'); + child.session = { sessionKey: 'bad-child', role: 'subagent', parentSessionKey: invalid[0].session.sessionKey }; + for (const input of [[safe, ...invalid, child], [child, ...invalid, safe]]) { + assert.deepEqual(verifiedCodexAchievementFiles(input).map(row => row.session.sessionKey), ['safe']); + assert.equal(codexModelSignals(input).familyProcessedTokens?.astra, 606_000_000); + assert.equal(codexAchievementTokenEvidence(input, true).output, 6_000_000); + } + } +}); + +test('verified subset selection is non-recursive for large inherited trees and never mutates inputs', () => { + const rows = Array.from({ length: 10_000 }, (_, index) => { + const row = file('session-' + index, 1, 1); + if (index) row.session = { sessionKey: 'session-' + index, role: 'subagent', parentSessionKey: 'session-' + (index - 1) }; + return row; + }).reverse(); + const before = JSON.stringify(rows); + assert.equal(verifiedCodexAchievementFiles(rows).length, 10_000); + assert.equal(JSON.stringify(rows), before); + rows.push(file('session-0', 2, 2)); + assert.equal(verifiedCodexAchievementFiles(rows).length, 0, 'a conflicting root withholds the entire descendant tree'); +}); + +test('model signals reuse ancestry proof instead of walking every deep parent chain again', () => { + const size = 10_000; + let parentReads = 0; + const rows = Array.from({ length: size }, (_, index) => { + const row = file('session-' + index, 1, 1); + row.session.role = index ? 'subagent' : 'root'; + Object.defineProperty(row.session, 'parentSessionKey', { enumerable: true, get: () => { + assert.ok(++parentReads < size * 40, 'ancestry traversal must stay linear in the file count'); + return index ? 'session-' + (index - 1) : undefined; + } }); + return row; + }).reverse(); + const signals = codexModelSignals(rows); + assert.equal(signals.verifiedRootCount, 1); + assert.equal(signals.verifiedChildCount, size - 1); + assert.equal(signals.familyProcessedTokens?.astra, size * 2); +}); + +test('production gallery grants safe Codex milestones despite unrelated identity ambiguity, not calendar claims', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'ccu-subset-canonical-')); + const zone = I18n.getTimezone(); + const load = (Module as any)._load; + (Module as any)._load = function(request: string, parent: unknown, main: boolean) { + return request === 'vscode' ? { workspace: { workspaceFolders: [] } } : load.call(this, request, parent, main); + }; + try { + I18n.setTimezone('UTC'); + const { UsageWebviewProvider } = require('../webview'); + const state = new Map(); + const provider = new UsageWebviewProvider({ globalStorageUri: { fsPath: directory }, globalState: { + get: (key: string) => state.get(key), update: async (key: string, value: unknown) => state.set(key, value), + } }) as any; + await provider.achievementWrite; + provider.settings = { get: () => true }; + provider.providerAvailability = { claude: false, codex: true, codexData: true }; + assert.equal(provider.ensureAchievementRecords(), false); + await provider.achievementWrite; + assert.equal(provider.achievementRecordState.schemaVersion, 2); + const notices: any[] = []; + provider.panel = { visible: true, active: true, webview: { postMessage: async (message: any) => { + if (message.command === 'achievementUnlockNotice') notices.push(message); + return true; + } } }; + provider.webviewClientReady = true; + provider.setAchievementWindowFocused(true); + // Each extra root contributes output in the same max bucket; the large + // safe root alone cannot prove the new five-task effort requirement. + const initial = [...corpus(), ...Array.from({ length: 4 }, (_, index) => file(`effort-${index}`, 20_000_000, 20_000))]; + provider.codexAchievementSignals = codexModelSignals(initial); + provider.codexAchievementSignals.tokenEvidence = codexAchievementTokenEvidence(initial, true); + provider.codexView = { + currentManifestVerified: true, coverage: { complete: true, identity: { complete: false } }, + allTime: { total: { processed: 2_000_000_000 } }, allTimeDaily: [], + periodCoverage: { timeZone: 'UTC', allTime: { complete: true } }, + hourlyCoverage: { complete: false, timeZone: 'UTC' }, last30DaysHourlyByDay: {}, + outputHourProof: { complete: false, hours: [] }, + }; + const baseline = await provider.beginCodexAchievementObservation(() => true); + provider.acceptCodexAchievementSnapshot(provider.codexView, provider.codexAchievementSignals, baseline); + await provider.achievementWrite; + const read = () => new Map(provider.achievementResults().map((row: any) => [row.id, row])); + assert.equal(read().get('astra-regular').unlocked, true); + assert.equal(read().get('astra-regular-silver').unlocked, false); + assert.equal(read().get('codex-output-bronze').unlocked, true); + assert.equal(read().get('effort-explorer').progress, 1); + for (const id of ['astra-days-bronze', 'weekly-volume-bronze', 'weekly-average-bronze']) { + assert.equal(read().get(id).unlocked, false, id); + } + await provider.achievementWrite; + const canonicalFile = path.join(directory, ACHIEVEMENT_RECORD_FILE); + const saved = (await loadAchievementRecords(canonicalFile))!; + assert.equal(saved.schemaVersion, 2); + assert.ok(saved.records.some(row => row.id === 'astra-regular')); + assert.ok(!(state.get('ccu.achievements.grants.v5') as string[]).includes('astra-regular'), + 'canonical grants are not promoted back into legacy Memento storage'); + assert.equal(notices.length, 0, 'first observed subset history is quiet'); + const observation = await provider.beginCodexAchievementObservation(() => true); + assert.ok(observation.window, 'live producer observation follows the accepted history baseline'); + const increased = initial.slice(); + increased[0] = file('safe', 6_000_000_000); + provider.codexAchievementSignals = codexModelSignals(increased); + provider.codexAchievementSignals.tokenEvidence = codexAchievementTokenEvidence(increased, true); + provider.codexView = { ...provider.codexView, allTime: { total: { processed: 8_000_000_000 } } }; + provider.acceptCodexAchievementSnapshot(provider.codexView, provider.codexAchievementSignals, observation, + { ...observation.window, provider: 'codex', processed: 5_400_000_000, output: 0, cacheRead: 5_400_000_000, + families: { astra: 5_400_000_000, sol: 0, luna: 0 } }); + assert.equal(read().get('astra-regular-silver').unlocked, true); + await provider.achievementWrite; + await provider.achievementNoticeWrite; + assert.equal(notices.length, 1, 'independent model growth is not silenced by unrelated date/identity ambiguity'); + const silver = (await loadAchievementRecords(canonicalFile))!.records.find(row => row.id === 'astra-regular-silver')!; + assert.equal(silver.origin, 'usage'); + assert.equal(silver.unread, true); + assert.match(silver.recordedDay!, /^\d{4}-\d{2}-\d{2}$/); + assert.equal(silver.timeZone, 'UTC'); + assert.equal(read().get('weekly-volume-bronze').unlocked, false); + provider.invalidateAchievementEvidence('codex'); + assert.equal(read().get('astra-regular').progress, 0, 'failed refresh revokes new authority'); + assert.equal(provider.achievementGrants.has('astra-regular'), true, 'durable awards survive'); + provider.revokedAchievementEvidence.clear(); + provider.codexView.coverage.complete = false; + assert.equal(read().get('astra-regular').progress, 0); + provider.codexView.coverage.complete = true; + provider.codexView.currentManifestVerified = false; + assert.equal(read().get('astra-regular').progress, 0); + } finally { + I18n.setTimezone(zone); + (Module as any)._load = load; + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/test/achievementCollaboration.test.ts b/src/test/achievementCollaboration.test.ts new file mode 100644 index 00000000..d5077b22 --- /dev/null +++ b/src/test/achievementCollaboration.test.ts @@ -0,0 +1,357 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { codexCollaborationEvidence } from '../achievementCollaboration'; +import { priceAchievementModelBucket } from '../achievementMoney'; +import type { CodexFileAggregate } from '../providers/codex/codexIndex'; +import type { ProviderTokenCounts } from '../providers/providerTypes'; + +const tokens = (inputTotal = 0, outputTotal = 0): ProviderTokenCounts => ({ inputTotal, outputTotal }); +const models = ['gpt-6-astra', 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-luna']; +const efforts = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']; +function file(key: string, byModel: Record = { 'gpt-6.1-sol': tokens(0, 1) }, + parent?: string, role: CodexFileAggregate['session']['role'] = parent === undefined ? 'root' : 'subagent', +): CodexFileAggregate { + const total = Object.values(byModel).reduce((sum, row) => tokens( + sum.inputTotal + row.inputTotal, sum.outputTotal + row.outputTotal), tokens()); + return { session: { sessionKey: key, role, ...(parent === undefined ? {} : { parentSessionKey: parent }) }, + total, byModel, byEffort: {}, byDay: {}, + structural: { patchCalls: 0, toolCalls: 0, postPatchToolCalls: 0, compactCount: 0, taskCompleteCount: 0 } }; +} +function team(key: string, members: number, output: number): CodexFileAggregate[] { + return [file(key), ...Array.from({ length: members }, (_, i) => + file(`${key}-child-${i}`, { 'gpt-6.1-sol': tokens(0, output) }, key))]; +} +function modelTask(key: string, count: number, output: number): CodexFileAggregate[] { + return [file(key, Object.fromEntries(models.slice(0, count).map(model => [model, tokens(0, output)])))]; +} +function effortTasks(prefix: string, roots: number, processed: number, output: number): CodexFileAggregate[] { + return Array.from({ length: roots }, (_, i) => { + const bucket = i ? tokens(0, 1) : tokens(processed - output, output - roots + 1); + const row = file(`${prefix}-${i}`, { 'gpt-6-astra': tokens(bucket.inputTotal * 6, bucket.outputTotal * 6) }); + row.byEffort = Object.fromEntries(efforts.map(effort => [effort, { ...bucket }])); + return row; + }); +} + +test('empty evidence has a fixed numeric DTO and no identity or labels', () => { + assert.deepEqual(codexCollaborationEvidence([]), { + teamTaskCounts: [0, 0, 0], orchestratedTasks: 0, qualifiedSubagentCount: 0, + modelTaskCounts: [0, 0, 0], multiModelTasks: 0, effortQualifiedTierCounts: [0, 0, 0], + rainbowFamilies: { astra: { processed: 0, output: 0 }, sol: { processed: 0, output: 0 }, + luna: { processed: 0, output: 0 } }, + }); +}); + +test('one enormous tree is one task and cannot prove repeat-task gold', () => { + const result = codexCollaborationEvidence(team('private-root', 100, 1_000_000)); + assert.deepEqual(result.teamTaskCounts, [1, 1, 1]); + assert.equal(result.orchestratedTasks, 1); + assert.equal(result.qualifiedSubagentCount, 100); + assert.ok(result.teamTaskCounts[2] < 50); + assert.ok(!JSON.stringify(result).includes('private-root')); +}); + +test('distinct eligible tasks preserve every repeated team threshold exactly', () => { + for (const count of [4, 5, 19, 20, 49, 50, 199, 200]) { + const result = codexCollaborationEvidence(Array.from({ length: count }, (_, i) => team(`root-${i}`, 8, 20_000)).flat()); + assert.deepEqual(result.teamTaskCounts, [count, count, count]); + assert.equal(result.orchestratedTasks, count); + assert.equal(result.qualifiedSubagentCount, count * 8); + } +}); + +test('team member and output boundaries are inclusive at all three levels', () => { + for (const [members, output, expected] of [ + [2, 5_000, [0, 0, 0]], [3, 4_999, [0, 0, 0]], [3, 5_000, [1, 0, 0]], + [4, 10_000, [1, 0, 0]], [5, 9_999, [1, 0, 0]], [5, 10_000, [1, 1, 0]], + [7, 20_000, [1, 1, 0]], [8, 19_999, [1, 1, 0]], [8, 20_000, [1, 1, 1]], + ] as const) assert.deepEqual(codexCollaborationEvidence(team('root', members, output)).teamTaskCounts, expected); +}); + +test('member output cannot be borrowed from another member or an unknown model', () => { + const rows = team('root', 3, 4_999); + rows.push(file('huge', { 'gpt-6-astra': tokens(0, 1_000_000) }, 'root')); + rows.push(file('unknown-output', { 'gpt-6-sol': tokens(100_000, 0), unknown: tokens(0, 50_000) }, 'root')); + rows.push(file('input-only', { 'gpt-6-astra': tokens(1_000_000, 0) }, 'root')); + const result = codexCollaborationEvidence(rows); + assert.deepEqual(result.teamTaskCounts, [0, 0, 0]); + assert.equal(result.qualifiedSubagentCount, 1); +}); + +test('teams require root output and deeply nested members still belong to one root', () => { + const rows = team('root', 3, 5_000); + rows[2].session.parentSessionKey = rows[1].session.sessionKey; + rows[3].session.parentSessionKey = rows[2].session.sessionKey; + assert.deepEqual(codexCollaborationEvidence(rows.reverse()).teamTaskCounts, [1, 0, 0]); + const silent = file('root', { 'gpt-6-astra': tokens(1_000_000, 0) }); + const result = codexCollaborationEvidence([silent, ...rows.filter(row => row.session.role !== 'root')]); + assert.deepEqual(result.teamTaskCounts, [0, 0, 0]); + assert.equal(result.qualifiedSubagentCount, 3); +}); + +test('model task count and per-model output boundaries remain aligned', () => { + for (const [count, output, expected] of [ + [1, 1_000_000, [0, 0, 0]], [2, 9_999, [0, 0, 0]], [2, 10_000, [1, 0, 0]], + [2, 20_000, [1, 0, 0]], [3, 19_999, [1, 0, 0]], [3, 20_000, [1, 1, 0]], + [3, 50_000, [1, 1, 0]], [4, 49_999, [1, 1, 0]], [4, 50_000, [1, 1, 1]], + ] as const) { + const result = codexCollaborationEvidence(modelTask('root', count, output)); + assert.deepEqual(result.modelTaskCounts, expected); + assert.equal(result.multiModelTasks, expected[0]); + } + for (const count of [2, 3, 9, 10, 29, 30, 99, 100, 999, 1_000]) { + assert.deepEqual(codexCollaborationEvidence(Array.from({ length: count }, (_, i) => + modelTask(`root-${i}`, 4, 50_000)).flat()).modelTaskCounts, [count, count, count]); + } +}); + +test('model output pools across members of the same task but never across tasks or models', () => { + const rows = [file('root', { 'gpt-6-astra': tokens(0, 9_999) }), + file('child', { 'gpt-6-astra': tokens(0, 1), 'gpt-6-sol': tokens(0, 10_000) }, 'root')]; + assert.deepEqual(codexCollaborationEvidence(rows).modelTaskCounts, [1, 0, 0]); + assert.equal(codexCollaborationEvidence([ + file('a', { 'gpt-6-astra': tokens(0, 1_000_000), 'gpt-6-sol': tokens(0, 9_999) }), + file('b', { 'gpt-6-sol': tokens(0, 1_000_000), 'gpt-6-astra': tokens(0, 9_999) }), + ]).multiModelTasks, 0); +}); + +test('explicit aliases merge outputs while genuine generations and o-series remain distinct', () => { + const aliases = file('root', { 'gpt-6-sol': tokens(0, 5_000), 'openai/gpt-6-sol': tokens(0, 5_000) }); + assert.equal(codexCollaborationEvidence([aliases]).multiModelTasks, 0); + const child = file('child', { 'gpt-6.1-sol': tokens(0, 10_000) }, 'root'); + assert.equal(codexCollaborationEvidence([aliases, child]).multiModelTasks, 1); + assert.equal(codexCollaborationEvidence([file('o-series', { o3: tokens(0, 10_000), + 'openai/o4-mini': tokens(0, 10_000) })]).multiModelTasks, 1); + const member = file('member', aliases.byModel, 'root'); + assert.equal(codexCollaborationEvidence([file('root'), member]).qualifiedSubagentCount, 1); +}); + +test('strict unpriced family labels still qualify team, model and rainbow output', () => { + for (const model of ['gpt-6.2-sol', 'gpt-6.1-astra', 'gpt-6-luna-2026-09-01', 'gpt-6-sol-20260901']) { + const rows = [file('root', { [model]: tokens(10_000, 10_000) }), + ...Array.from({ length: 3 }, (_, i) => file(`child-${i}`, { [model]: tokens(0, 5_000) }, 'root'))]; + const result = codexCollaborationEvidence(rows); + assert.deepEqual(result.teamTaskCounts, [1, 0, 0], model); + assert.equal(result.qualifiedSubagentCount, 3, model); + assert.deepEqual(result.modelTaskCounts, [0, 0, 0], 'one shared model is not two models'); + const family = model.includes('-astra') ? 'astra' : model.includes('-luna') ? 'luna' : 'sol'; + assert.deepEqual(result.rainbowFamilies[family], { processed: 35_000, output: 25_000 }, model); + const money = priceAchievementModelBucket({ provider: 'codex', model, tokens: rows[0].total }, 'anthropic'); + assert.equal(money.pricedTokens, 0, model); + assert.equal(money.microUsd, null, model); + } + const root = file('mixed', { 'gpt-6.2-sol': tokens(0, 10_000), 'gpt-6.1-astra': tokens(0, 10_000) }); + assert.deepEqual(codexCollaborationEvidence([root]).modelTaskCounts, [1, 0, 0]); +}); + +test('unpriced recognized labels retain inclusive per-member and per-model output thresholds', () => { + for (const output of [4_999, 5_000]) { + const rows = [file('root'), ...Array.from({ length: 3 }, (_, i) => + file(`child-${i}`, { 'gpt-6.2-sol': tokens(1_000_000, output) }, 'root'))]; + assert.equal(codexCollaborationEvidence(rows).qualifiedSubagentCount, output === 5_000 ? 3 : 0); + assert.deepEqual(codexCollaborationEvidence(rows).teamTaskCounts, output === 5_000 ? [1, 0, 0] : [0, 0, 0]); + } + for (const output of [9_999, 10_000]) { + const row = file('root', { 'gpt-6.2-sol': tokens(1_000_000, output), 'gpt-6.1-astra': tokens(0, 10_000) }); + assert.equal(codexCollaborationEvidence([row]).multiModelTasks, output === 10_000 ? 1 : 0); + } + const silent = file('root', { 'gpt-6.2-sol': tokens(10_000_000, 0) }); + assert.deepEqual(codexCollaborationEvidence([silent]).rainbowFamilies.sol, { processed: 10_000_000, output: 0 }); + assert.equal(codexCollaborationEvidence([silent]).multiModelTasks, 0); +}); + +test('unknown, custom and non-OpenAI labels cannot prove explicit models or rainbow', () => { + const row = file('root', Object.fromEntries(['unknown', 'gpt-99-custom', 'my-gpt-6-astra', + 'gpt-6-sol-custom', ' GPT-6-SOL-CUSTOM ', 'claude-opus-5-5', 'anthropic/claude-fable-5', + '__proto__', 'constructor'].map(model => [model, tokens(0, 100_000)]))); + assert.equal(codexCollaborationEvidence([row]).multiModelTasks, 0); + assert.deepEqual(codexCollaborationEvidence([row]).rainbowFamilies, + codexCollaborationEvidence([]).rainbowFamilies); +}); + +test('APR contributes to none of team, model, effort or rainbow proof', () => { + const rows = team('root', 2, 5_000); + const apr = file('apr', { 'gpt-6-astra': tokens(10_000_000_000, 20_000_000) }, 'root', 'approval-reviewer'); + apr.byEffort = { max: { ...apr.total } }; + const before = codexCollaborationEvidence(rows); + assert.deepEqual(codexCollaborationEvidence([...rows, apr]), before); + const effortRows = effortTasks('effort', 5, 100_000_000, 100_000); + for (const row of effortRows) row.session = { ...row.session, role: 'approval-reviewer', parentSessionKey: 'root' }; + assert.deepEqual(codexCollaborationEvidence([...rows, ...effortRows]), before); +}); + +test('one high-volume effort task cannot establish task breadth', () => { + const rows = effortTasks('root', 1, 20_000_000_000, 20_000_000); + assert.deepEqual(codexCollaborationEvidence(rows).effortQualifiedTierCounts, [0, 0, 0]); + for (let i = 0; i < 40; i++) { + const child = file(`child-${i}`, { 'gpt-6-astra': tokens(0, 6) }, 'root-0'); + child.byEffort = Object.fromEntries(efforts.map(effort => [effort, tokens(0, 1)])); + rows.push(child); + } + assert.deepEqual(codexCollaborationEvidence(rows).effortQualifiedTierCounts, [0, 0, 0]); +}); + +test('effort volume, own output and distinct output roots all meet inclusive thresholds', () => { + const levels = [[5, 100_000_000, 100_000], [10, 500_000_000, 500_000], [30, 2_000_000_000, 2_000_000]]; + for (let level = 0; level < levels.length; level++) { + const [roots, processed, output] = levels[level]; + const exact = codexCollaborationEvidence(effortTasks('exact', roots, processed, output)); + assert.deepEqual(exact.effortQualifiedTierCounts, levels.map((_, i) => i <= level ? 6 : 0)); + for (const [r, p, o] of [[roots - 1, processed, output], [roots, processed - 1, output], [roots, processed, output - 1]]) { + assert.equal(codexCollaborationEvidence(effortTasks('below', r, p, o)).effortQualifiedTierCounts[level], 0); + } + } +}); + +test('effort buckets cannot borrow global output or another effort output/task count', () => { + const rows = Array.from({ length: 5 }, (_, i) => { + const row = file(`root-${i}`, { 'gpt-6-astra': tokens(100_000_000, 100_000) }); + row.byEffort = { low: tokens(50_000_000, 0), medium: tokens(0, 100_000), + none: tokens(0, 0), minimal: tokens(0, 0), invented: tokens(0, 0) }; + return row; + }); + assert.deepEqual(codexCollaborationEvidence(rows).effortQualifiedTierCounts, [0, 0, 0]); + for (let i = 1; i < rows.length; i++) rows[i].byEffort = { low: tokens(50_000_000, 0) }; + rows[0].byEffort.low.outputTotal = 100_000; + delete rows[0].byEffort.medium; + assert.deepEqual(codexCollaborationEvidence(rows).effortQualifiedTierCounts, [0, 0, 0]); +}); + +test('effort proof pools the same root and tier before excluding input-only tasks', () => { + const effortRow = (key: string, input: number, output: number, parent?: string, tier = 'low') => { + const row = file(key, { 'gpt-6-astra': tokens(input, output) }, parent); + row.byEffort = { [tier]: tokens(input, output) }; + return row; + }; + const otherRoots = Array.from({ length: 4 }, (_, i) => effortRow(`other-${i}`, 0, 1)); + const combined = [effortRow('root', 99_900_000, 99_996), ...otherRoots]; + const split = [effortRow('root', 99_900_000, 0), + effortRow('child', 0, 99_996, 'root'), ...otherRoots]; + assert.deepEqual(codexCollaborationEvidence(combined).effortQualifiedTierCounts, [1, 0, 0]); + assert.deepEqual(codexCollaborationEvidence(split).effortQualifiedTierCounts, [1, 0, 0]); + assert.deepEqual(codexCollaborationEvidence(split.reverse()).effortQualifiedTierCounts, [1, 0, 0]); + // Neither an unrelated root nor a different tier may lend its input volume. + assert.deepEqual(codexCollaborationEvidence([ + effortRow('silent', 99_900_000, 0), effortRow('root', 0, 99_996), ...otherRoots, + ]).effortQualifiedTierCounts, [0, 0, 0]); + assert.deepEqual(codexCollaborationEvidence([ + effortRow('root', 99_900_000, 0, undefined, 'high'), + effortRow('child', 0, 99_996, 'root'), ...otherRoots, + ]).effortQualifiedTierCounts, [0, 0, 0]); + const apr = effortRow('apr', 99_900_000, 0, 'root'); + apr.session.role = 'approval-reviewer'; + assert.deepEqual(codexCollaborationEvidence([ + effortRow('root', 0, 99_996), apr, ...otherRoots, + ]).effortQualifiedTierCounts, [0, 0, 0]); +}); + +test('effort DTO counts qualified tiers independently of the main 4/5/6 award targets', () => { + for (const tierCount of [1, 3, 4, 5, 6]) { + const rows = effortTasks('root', 30, 2_000_000_000, 2_000_000); + for (const row of rows) for (const effort of efforts.slice(tierCount)) delete row.byEffort[effort]; + assert.deepEqual(codexCollaborationEvidence(rows).effortQualifiedTierCounts, [tierCount, tierCount, tierCount]); + } +}); + +test('none, minimal and invented effort labels never enter the six-tier spectrum', () => { + const rows = effortTasks('root', 30, 2_000_000_000, 2_000_000); + for (const row of rows) { + const bucket = row.byEffort.low; + row.byEffort = { none: bucket, minimal: bucket, invented: bucket }; + } + assert.deepEqual(codexCollaborationEvidence(rows).effortQualifiedTierCounts, [0, 0, 0]); +}); + +test('rainbow keeps family processed and output aligned without cache/reasoning duplication', () => { + const astra = { inputTotal: 9_980_000, outputTotal: 20_000, cachedInput: 9_980_000, reasoningOutput: 20_000 }; + const root = file('root', { 'gpt-6-astra': astra, 'gpt-6.1-sol': tokens(30_000, 5_000), + 'gpt-6-luna': tokens(40_000, 0) }); + root.total.cachedInput = astra.cachedInput; + root.total.reasoningOutput = astra.reasoningOutput; + const child = file('child', { 'openai/gpt-6-sol': tokens(10_000, 15_000) }, 'root'); + assert.deepEqual(codexCollaborationEvidence([root, child]).rainbowFamilies, { + astra: { processed: 10_000_000, output: 20_000 }, sol: { processed: 60_000, output: 20_000 }, + luna: { processed: 40_000, output: 0 }, + }); +}); + +test('a family may accumulate input across observations but still needs its own output threshold', () => { + const input = file('input', { 'gpt-6-luna': tokens(9_980_000,0) }); + const output = file('output', { 'gpt-6-luna': tokens(0,20_000) }); + const result = codexCollaborationEvidence([input,output]); + assert.deepEqual(result.rainbowFamilies.luna,{processed:10_000_000,output:20_000}); + assert.deepEqual(result.teamTaskCounts,[0,0,0]); + assert.equal(result.qualifiedSubagentCount,0); +}); + +test('canonical selection rejects copies and their ambiguous descendants, orphans, cycles and bad roles', () => { + const sound = team('safe', 3, 5_000); + const bad = team('duplicate', 8, 50_000); + bad.push(file('duplicate')); + const orphan = file('orphan', { 'gpt-6-astra': tokens(0, 50_000) }, 'missing'); + const cycleA = file('cycle-a', undefined, 'cycle-b'); + const cycleB = file('cycle-b', undefined, 'cycle-a'); + const unknown = file('role'); + unknown.session.role = 'unknown' as CodexFileAggregate['session']['role']; + const rootWithParent = file('bad-root', undefined, 'safe', 'root'); + const badChild = file('bad-child', undefined, 'role'); + const combined = [...sound, ...bad, orphan, cycleA, cycleB, unknown, rootWithParent, badChild]; + assert.deepEqual(codexCollaborationEvidence(combined), codexCollaborationEvidence(sound)); + assert.deepEqual(codexCollaborationEvidence(combined.reverse()), codexCollaborationEvidence(sound)); +}); + +test('invalid model vectors reject the branch; invalid effort rejects only effort proof', () => { + const sound = team('safe', 3, 5_000); + for (const mutate of [ + (row: CodexFileAggregate) => { row.total.outputTotal = NaN; }, + (row: CodexFileAggregate) => { row.total.cachedInput = 1; }, + (row: CodexFileAggregate) => { row.byModel['gpt-6.1-sol'].outputTotal = -1; }, + (row: CodexFileAggregate) => { row.byModel['gpt-6.1-sol'].reasoningOutput = 2; }, + (row: CodexFileAggregate) => { row.total.inputTotal = Number.MAX_SAFE_INTEGER; }, + ]) { + const bad = team('invalid', 8, 20_000); + mutate(bad[0]); + assert.deepEqual(codexCollaborationEvidence([...sound, ...bad]), codexCollaborationEvidence(sound)); + } + const rows = effortTasks('effort', 5, 100_000_000, 100_000); + rows[0].byEffort.low.outputTotal = rows[0].total.outputTotal + 1; + assert.deepEqual(codexCollaborationEvidence(rows).effortQualifiedTierCounts, [0, 0, 0]); + assert.ok(codexCollaborationEvidence(rows).rainbowFamilies.astra.output > 0); +}); + +test('zeroed inherited prefixes and input-only subagents cannot create qualifying members', () => { + const rows = [file('root')]; + for (let i = 0; i < 20; i++) rows.push(file(`prefix-${i}`, { + 'gpt-6-astra': tokens(i % 2 ? 1_000_000 : 0, 0), + }, 'root')); + assert.deepEqual(codexCollaborationEvidence(rows).teamTaskCounts, [0, 0, 0]); + assert.equal(codexCollaborationEvidence(rows).qualifiedSubagentCount, 0); +}); + +test('10k reversed deep lineage is iterative, memoized and never mutates indexed data', () => { + const size = 10_000; + let reads = 0; + const rows = Array.from({ length: size }, (_, i) => { + const row = file(`session-${i}`, { 'gpt-6-astra': tokens(0, 5_000) }, i ? `session-${i - 1}` : undefined); + Object.defineProperty(row.session, 'parentSessionKey', { enumerable: true, get: () => { + assert.ok(++reads < size * 40, 'ancestry reads must be linear'); + return i ? `session-${i - 1}` : undefined; + } }); + return row; + }).reverse(); + const before = JSON.stringify(rows); + const result = codexCollaborationEvidence(rows); + assert.deepEqual(result.teamTaskCounts, [1, 0, 0]); + assert.equal(result.qualifiedSubagentCount, size - 1); + assert.deepEqual(result.rainbowFamilies.astra, { processed: size * 5_000, output: size * 5_000 }); + assert.equal(JSON.stringify(rows), before); + rows.push(file('session-0')); + assert.deepEqual(codexCollaborationEvidence(rows), codexCollaborationEvidence([])); +}); + +test('numeric aggregation saturates to a safe lower bound rather than rounding into unsafe DTO values', () => { + const rows = [file('a', { 'gpt-6-astra': tokens(0, Number.MAX_SAFE_INTEGER) }), + file('b', { 'gpt-6-astra': tokens(0, Number.MAX_SAFE_INTEGER) })]; + assert.deepEqual(codexCollaborationEvidence(rows).rainbowFamilies.astra, + { processed: Number.MAX_SAFE_INTEGER, output: Number.MAX_SAFE_INTEGER }); +}); diff --git a/src/test/achievementCompactLevels.test.ts b/src/test/achievementCompactLevels.test.ts new file mode 100644 index 00000000..2ac3168d --- /dev/null +++ b/src/test/achievementCompactLevels.test.ts @@ -0,0 +1,46 @@ +import {test} from 'node:test'; +import * as assert from 'node:assert/strict'; +import {resolve} from 'node:path'; +const {renderHarness}=require(resolve(__dirname,'../../tests/ui/support/render-harness.cjs')); +const text=(value:string)=>value.replace(/<[^>]+>/g,' ').replace(/\s+/g,' ').trim(); +function card(html:string,key:string):string{ + const found=html.match(new RegExp(']*data-achievement-key="'+key+'"[\\s\\S]*?')); + assert.ok(found,'missing card '+key);return found[0]; +} +function levels(html:string):string[]{return [...html.matchAll(/
  • [\s\S]*?<\/li>/g)].map(row=>row[0]);} +test('only the next unearned rank is in progress; future ranks retain thresholds without repeated state',async()=>{ + const html=await renderHarness({provider:'claude',locale:'zh-CN',fixture:'achievement-management-history',claudeOnly:true}); + const rows=levels(card(html,'sonnet-main')); + assert.equal(rows.length,5); + assert.deepEqual(rows.filter(row=>text(row).includes('进行中')).map(row=>row.match(/data-achievement-level="([^"]+)/)![1]),['sonnet-main-gold']); + assert.ok(rows.every(row=>row.includes('achievement-level-value')&&row.includes('achievement-level-name'))); + assert.ok(!text(rows[0]).includes('最早可确认日期')); + assert.ok(text(rows[0]).includes('2026-06-01')); + assert.ok(text(html).includes('最早可确认日期')); +}); +test('fully earned ranks and pending proof never pretend another rank is progressing',async()=>{ + const full=await renderHarness({provider:'claude',locale:'en',fixture:'achievement-management-full',claudeOnly:true}); + assert.ok(!levels(card(full,'sonnet-main')).some(row=>text(row).includes('In progress'))); + const stale=await renderHarness({provider:'codex',locale:'en',fixture:'achievement-proof-stale'}); + const pending=levels(card(stale,'astra-regular')); + assert.ok(!pending.some(row=>text(row).includes('In progress'))); +}); +test('first-recorded-only and unknown dates keep their meanings in compact metadata',async()=>{ + const recorded=await renderHarness({provider:'claude',locale:'en',fixture:'achievement-records',claudeOnly:true}); + const exportCard=card(recorded,'local-share-export'); + assert.ok(exportCard.includes('data-date-source="first-recorded"')); + assert.ok(text(exportCard).includes('First recorded')); + assert.ok(text(exportCard).includes('2026-07-20')); + const old=await renderHarness({provider:'claude',locale:'en',fixture:'achievement-history-only',claudeOnly:true}); + assert.ok(card(old,'local-share-export').includes('data-date-source="unknown"')); + assert.ok(!card(old,'local-share-export').includes('{ + const html=await renderHarness({provider:'claude',locale:'zh-CN',fixture:'achievement-management-history-recorded',claudeOnly:true}); + const first=levels(card(html,'sonnet-main'))[0]; + assert.ok(text(first).includes('2026-06-01')); + assert.ok(!text(first).includes('2026-07-20')); + assert.equal((first.match(/
  • ' + + this.escapeHtml(series ? copy.levels[index] : copy.milestone) + '' + + this.escapeHtml(compactValue(entry.id,entry.target) + (commonUnit?'':' '+unitFor(entry.id))) + '' + + (notes.perLevel[index] ? '' + this.escapeHtml(notes.perLevel[index]!) + '' : '') + + (metadata?'
    '+metadata+'
    ':'')+'
  • '; + }).join(''); + const milestoneRules = milestones.length ? '
    ' + this.escapeHtml(copy.milestone) + + '
      ' + milestones.map(entry => { + const metadata=dated(entry); + const src = webview?.asWebviewUri && this.context.extensionPath + ? webview.asWebviewUri(vscode.Uri.file(achievementImagePath(entry.id))).toString() : achievementImageDataUrl(entry.id); + return '
    • ' + + '' + this.escapeHtml(copy.titles[entry.id]) + + '' + this.escapeHtml(compact(entry.target) + ' ' + unitFor(entry.id)) + ''+ + (metadata?'
      '+metadata+'
      ':'')+'
    • '; + }).join('') + '
    ' : ''; + // Saved allowlisted facts only. A historical grant or queued action cannot + // reconstruct individual steps or pretend a cancelled export succeeded. + const journey = key === 'safe-feature-explorer' ? '
    ' + this.escapeHtml(copy.featureJourney) + + '
      ' + SAFE_EXPLORATION_FEATURES.map((feature, index) => { + const recorded = this.achievementActivity.features.includes(feature); + return '
    1. ' + this.escapeHtml(copy.journeySteps[index]) + '' + + this.escapeHtml(recorded ? copy.stepRecorded : copy.stepUnrecorded) + '
    2. '; + }).join('') + '
    ' : ''; + const statistics = this.achievementStatisticsSection(key); + const monetaryDetails = monetary ? '

    ' + + this.escapeHtml(moneyCopy[monetary.scope==='cumulative' ? 'total' : monetary.scope==='completedMondayWeekPeak' ? 'week' : monetary.group==='claude' ? 'session' : 'task']) + + '

    ' + + (quality?.pricingCoverage === undefined ? '' : '

    ' + + this.escapeHtml(moneyCopy.coverage.replace('{percent}',integer(Math.floor(quality.pricingCoverage*100)))) + '

    ') + + '' : ''; + const statisticsButton = statistics ? '' : ''; + return '
    ' + + (pinButtons ? '
    ' + pinButtons + '
    ' : '') + + '
    ' + + '' + + '' + + '

    ' + this.escapeHtml(baseName) + '

    ' + + (earned ? '' + this.escapeHtml(levelName) + '' : '') + + tierPips + '' + faceProgress + '' + + (proofReady && next ? '' : '') + '
    ' + + '
    ' + tierMarkers + + (proofReady ? + '' + + '' : + '

    ' + this.escapeHtml(proofLabel) + '

    ' + + achievementUiIcon('info') + this.escapeHtml(proofHint) + '

    ') + + '
    ' + this.escapeHtml(copy.allLevels) + '
    ' + + sharedDetails+'
      ' + levelRules + '
    ' + milestoneRules + journey + monetaryDetails + + (key === 'effort-breadth' ? this.renderAchievementEffortStatistics(nextTierIndex) : '') + + statisticsButton + '
    '; + }; + const groups: Array<'claude' | 'codex' | 'combined' | 'plugin'> = + ['claude', 'codex', 'combined', 'plugin']; + const visibleGroups = groups.filter(group => group === 'combined' || group === 'plugin' || this.providerAvailability[group]); + const visibleResult = (result: AchievementResult): boolean => visibleGroups.includes(result.group) && + (!result.id.startsWith('dual-provider-') || (this.providerAvailability.claude && this.providerAvailability.codex)); + const visibleCards = presentation.filter(entry => visibleResult(entry.result)); + const overviewRows = visibleCards.map(entry => ({ + key: entry.key, group: entry.result.group, + earnedLevel: entry.series ? entry.earnedLevels.lastIndexOf(true) + 1 : 1, + earned: entry.earnedTiers > 0 || isEarned(entry.result) || entry.milestones.some(isEarned), + next: entry.series ? entry.hasNextTier : !(entry.result.unlocked || this.achievementGrants.has(entry.result.id)), + progress: entry.nextResult.progress, target: entry.nextResult.target, + proof: evidenceFor(entry.nextResult), + nextEligible: entry.key === 'model-rainbow' + ? modelRainbowNextEligibility(entry.nextResult.target, this.providerAvailability) : undefined, + })); + const pins = this.achievementRecordState?.pins ?? []; + const currentPin = (key: string): boolean => this.achievementRecordState?.records.some(row => + row.id === key || ACHIEVEMENT_SERIES[key]?.includes(row.id)) === true; + const legacyPins = pins.filter(key => !currentPin(key) && this.achievementRecordState?.legacyRecords?.some(row => + row.id === key || ACHIEVEMENT_SERIES[key]?.includes(row.id))); + const hiddenPins = pins.filter(key => !legacyPins.includes(key) && + !visibleCards.some(entry => entry.key === achievementGalleryKey(key))); + const hiddenPinSection = hiddenPins.length ? '
    ' + + '' + this.escapeHtml(copy.hiddenPins) + ' · ' + integer(hiddenPins.length) + '
      ' + + hiddenPins.map(key => { + const ids = ACHIEVEMENT_SERIES[key] ?? [key]; + const id = [...ids].reverse().find(id => this.achievementRecordState!.records.some(record => record.id === id)) ?? ids[0]; + return '
    • ' + this.escapeHtml(copy.titles[id as AchievementId]) + '
    • '; + }).join('') + '
    ' : ''; + const mode = this.achievementRecordState?.showcaseMode ?? (pins.length ? 'manual' : 'automatic'); + const pinsSignature = JSON.stringify([pins, mode, this.achievementRecordState?.pinRevision]); + if (!this.achievementOverviewSelection || this.achievementOverviewProvider !== this.currentProvider) { + this.achievementOverviewSelection = selectAchievementOverview(overviewRows, this.currentProvider, pins, mode); + this.achievementOverviewProvider = this.currentProvider; + } else if (this.achievementOverviewPinsSignature !== pinsSignature) { + this.achievementOverviewSelection.badges = selectAchievementOverview(overviewRows, this.currentProvider, pins, mode).badges; + } + this.achievementOverviewPinsSignature = pinsSignature; + const overview = retainAchievementOverview(this.achievementOverviewSelection, overviewRows); + const overviewSection = (kind: 'badges' | 'next', heading: string): string => { + if (!overview[kind].length) return ''; + return '

    ' + achievementUiIcon(kind==='badges'?'medal':'target')+ + this.escapeHtml(heading) + '

    ' + overview[kind].map(key => { + const entry = visibleCards.find(entry => entry.key === key)!; + const state = overviewRows.find(row => row.key === key)!; + const name = themeName(key, kind==='next' ? entry.nextResult : entry.result); + const rank = kind==='next' && entry.series ? copy.nextTarget+' · '+copy.levels[entry.nextTierIndex] : + entry.series && state.earnedLevel > 0 ? copy.levels[state.earnedLevel - 1] : + state.earned ? copy.milestone : copy.locked; + const proofLabel = state.proof === 'stale' ? copy.evidenceStale : copy.evidencePending; + const monetary=API_ACHIEVEMENT_DEFINITIONS.find(row=>row.id===entry.nextResult.id); + const moneyProof=monetary ? this.achievementMoneyFacts[monetary.group] : undefined; + const moneyLowerBound=monetary && !moneyValue(entry.nextResult.id,state.progress).startsWith('<') && + (moneyProof?.coverage[monetary.scope].status==='partial' || (moneyProof?.coverage.excludedFiles ?? 0)>0); + const progress = (state.proof === 'ready' ? + (monetary ? moneyLowerBound ? '≥' : '' : this.achievementCalendarLowerBounds.has(entry.nextResult.id) || + cumulativeLowerBound && (entry.series === 'cumulative-volume' || entry.result.group === 'codex') ? '≥' : '') + + compactValue(entry.nextResult.id,state.progress) + ' / ' + compactValue(entry.nextResult.id,state.target) : proofLabel); + const imagePath = achievementImagePath(entry.result.id); + const iconSrc = this.panel?.webview?.asWebviewUri && this.context.extensionPath ? + this.panel.webview.asWebviewUri(vscode.Uri.file(imagePath)).toString() : achievementImageDataUrl(entry.result.id); + const selectedPin = pins.find(pin => achievementGalleryKey(pin) === key); + const pinKey = selectedPin ?? (currentPin(key) ? key : entry.milestones.find(row => currentPin(row.id))?.id); + return '
    ' + (kind === 'badges' && pinKey ? + pinControl(pinKey, !!selectedPin, 'achievement-overview-pin-' + key, 'achievement-summary-' + key, name) : '') + + '
    '; + }).join('') + '
    '; + }; + const visibleResults = results.filter(visibleResult); + const records = projectAchievementRecords(this.achievementRecordState?.records ?? [], + visibleResults.filter(isEarned).map(row => row.id)); + const recordButton = (record: AchievementRecord): string => { + const key = achievementGalleryKey(record.id)!; + const iconSrc = this.panel?.webview?.asWebviewUri && this.context.extensionPath + ? this.panel.webview.asWebviewUri(vscode.Uri.file(achievementImagePath(record.id))).toString() + : achievementImageDataUrl(record.id); + const stamp = record.recordedDay === null ? this.escapeHtml(copy.historicalAward) + : ''; + const spokenStamp = record.recordedDay === null ? copy.historicalAward + : copy.recordedDay + ': ' + record.recordedDay + ' (' + record.timeZone + ')'; + return ''; + }; + const recentSection = records.recent.length ? '

    ' + + this.escapeHtml(copy.recentAwards) + '

    ' + + records.recent.map(recordButton).join('') + '
    ' : ''; + const unread = this.unreadAchievementPacket(visibleResults.filter(isEarned).map(row => row.id)); + const unreadSection = unread ? '
    ' + + this.escapeHtml(copy.unreadAwards.replace('{count}', I18n.formatNumber(unread.batch.total))) + '' + + '

    ' + this.escapeHtml(copy.unreadGroup.replace('{count}', I18n.formatNumber(unread.batch.records.length))) + + '

    ' + unread.batch.records.map(recordButton).join('') + '
    ' + + '
    ' : ''; + const historySection = records.history.length ? '
    ' + + this.escapeHtml(copy.recordHistory) + '
      ' + + records.history.map(record => '
    • ' + recordButton(record) + '
    • ').join('') + '
    ' : ''; + const pinName = (key: string): string => copy.titles[(ACHIEVEMENT_SERIES[key]?.[0] ?? key) as AchievementId]; + const legacySection = this.achievementRecordState?.legacyRecords?.length ? + '
    ' + + this.escapeHtml(appearance.legacyRules) + ' · ' + integer(this.achievementRecordState.legacyRecords.length) + + '

    ' + this.escapeHtml(appearance.recalibratedHint) + '

      ' + + this.achievementRecordState.legacyRecords.map(row => '
    • ' + this.escapeHtml(copy.titles[row.id]) + + '' + this.escapeHtml(row.recordedDay === null ? copy.historicalAward : copy.recordedDay + ': ' + row.recordedDay) + + '
    • ').join('') + '
    ' + (legacyPins.length ? '

    ' + this.escapeHtml(appearance.legacyPins) + + '

      ' + legacyPins.map(key => '
    • ' + this.escapeHtml(pinName(key)) + + '' + pinControl(key, true, 'achievement-legacy-unpin-' + key, 'achievementShowcaseEditor', pinName(key)) + '
    • ').join('') + + '
    ' : '') + '
    ' : ''; + const showcaseEditor = this.achievementRecordState ? + '
    ' + achievementUiIcon('display') + this.escapeHtml(appearance.editShowcase) + '
    ' + + (['automatic','manual'] as const).map(value => '').join('') + '

    ' + + this.escapeHtml(appearance.selectedCount.replace('{count}', integer(pins.length))) + '

    ' + + '
      ' + pins.map((key, index) => + '
    1. ' + this.escapeHtml(pinName(key)) + '' + + '' + + pinControl(key,true,'achievement-edit-unpin-' + key,'achievementShowcaseEditorSummary',pinName(key)) + '
    2. ').join('') + + '
    ' + + '' + + '
    ' + visibleCards.filter(entry => currentPin(entry.key) || entry.milestones.some(row => currentPin(row.id))) + .map(entry => { + const key = currentPin(entry.key) ? entry.key : entry.milestones.find(row => currentPin(row.id))!.id; + const selected = pins.includes(key); + const name = themeName(entry.key, entry.result); + return ''; + }).join('') + '
    ' : ''; + const filterToolbar = '
    '+ + '
    '+ + '
    '; + const earnedCount = visibleResults.filter(isEarned).length; + const seriesCount = overviewRows.filter(entry => entry.earned).length; + const countLabel = (template: string, earned: number, total: number): string => + template.replace('{earned}', integer(earned)).replace('{total}', integer(total)); + return '
    ' + + '

    ' + achievementUiIcon('medal') + '' + this.escapeHtml(copy.heading) + '

    ' + + '
    ' + + achievementUiIcon('info') + this.escapeHtml(copy.evidenceTitle) + '

    ' + + this.escapeHtml((cumulativeLowerBound || this.achievementCalendarLowerBounds.size > 0 || + this.achievementMoneyFacts.codex?.coverage.completedMondayWeekPeak.scopeLowerBound + ? copy.lowerBoundNote + ' ' : '') + copy.lowerBoundDetails) + + '

    '+this.escapeHtml(management.verifiedDay)+': '+this.escapeHtml(management.dateNote)+'

    '+this.renderAchievementMoneyReferences()+'
    ' + + '

    ' + + '' + this.escapeHtml(countLabel(copy.seriesCount, seriesCount, visibleCards.length)) + ' · ' + + '' + this.escapeHtml(countLabel(copy.levelCount, earnedCount, visibleResults.length)) + '

    ' + + '
    ' + + (this.setting('enableShareCard', true) + ? '' : '') + + ''+ + '
    '+ + '

    '+this.escapeHtml(this.achievementCalibrationPending? + management.recalibrating:this.achievementCalibrationFailed?management.recalibrationFailed:'')+'

    '+ + '

    ' + + '

    ' + + filterToolbar + + showcaseEditor + '
    ' + overviewSection('badges', copy.myBadges) + + overviewSection('next', copy.nextGoals) + '
    ' + hiddenPinSection + legacySection + unreadSection + recentSection + historySection + + '' + + '' + + visibleGroups + .map((group) => '

    ' + + this.escapeHtml(copy.groups[group]) + '

    ' + + visibleCards.filter((entry) => entry.result.group === group) + .map(card).join('') + + '
    ').join('') + '
    '; + } + + /** One common disclosure; historical books keep their exact award association. */ + private renderAchievementMoneyReferences(): string { + const copy=achievementMoneyCopyFor(I18n.getLocale()),names=achievementCopyFor(I18n.getLocale()); + const history:string[]=[],current:string[]=[]; + for(const provider of ['claude','codex'] as const){ + if(!this.providerAvailability[provider])continue; + const facts=this.achievementMoneyFacts[provider]; + const backend=provider==='codex'?'openai':facts?.backend==='aws-bedrock-in-region'|| + (!facts?.backend&&this.setting('pricingBackend','anthropic')==='aws-bedrock-in-region') + ?'aws-bedrock-in-region':'anthropic'; + const book=getAchievementMoneyPricebook(backend); + const version=facts?.pricebookVersion??book.version; + const providerName=names.groups[provider]; + current.push('
    '+this.escapeHtml(providerName)+ + '
    '+this.escapeHtml(version)+''+(version===book.version? + ' ':'')+'
    '); + const prior=new Map(); + for(const row of this.achievementRecordState?.records??[]){ + if(!isAchievementMoneyId(row.id)||!row.id.startsWith(provider+'-')||!row.referencePricebook||row.referencePricebook===version)continue; + const titles=prior.get(row.referencePricebook)??[];titles.push(names.titles[row.id]);prior.set(row.referencePricebook,titles); + } + for(const [basis,titles]of prior)history.push('
  • '+this.escapeHtml(providerName)+ + ''+this.escapeHtml(basis)+''+this.escapeHtml(titles.join(' / '))+'
  • '); + } + if(!current.length)return ''; + return '
    '+ + '

    '+this.escapeHtml(copy.reference)+'

    '+this.escapeHtml(copy.note)+ + '

    '+current.join('')+'
    '+(history.length? + '

    '+this.escapeHtml(copy.earnedReference)+'

      '+history.join('')+'
    ':'')+'
    '; + } + + private earnedAchievements(): AchievementResult[] { + return this.achievementResults().map((result) => ({ + ...result, + unlocked: result.unlocked || this.achievementGrants.has(result.id), + })); + } + + private achievementShareSvg(ids: readonly string[] | undefined): string { + const copy = achievementCopyFor(I18n.getLocale()); + const selected = selectShareAchievements(this.earnedAchievements(), ids); + if (selected.length === 0) return ''; + return renderAchievementShareSvg({ + selected, + titles: copy.titles, + heading: copy.shareTitle, + subtitle: copy.intro, + groups: copy.groups, + levels: copy.levels, + milestone: copy.milestone, + footer: copy.heading, + locale: I18n.getLocale(), + }); + } + + private renderAchievementSharePresentation(selected: SharingTemplate): string { + const copy = achievementCopyFor(I18n.getLocale()); + const workspaceCopy = combinedHeatmapUiCopy(I18n.getLocale()); + const results = highestEarnedAchievements(this.earnedAchievements()); + const chosen = selectShareAchievements(results, this.achievementShareSelection); + const accepted = selected === 'achievements' ? this.acceptedAchievementPreview.read(chosen, I18n.getLocale()) : undefined; + const preview = accepted?.svg ?? ''; + const iconSrc = (id: AchievementId): string => this.panel?.webview?.asWebviewUri && this.context.extensionPath + ? this.panel.webview.asWebviewUri(vscode.Uri.file(achievementImagePath(id))).toString() : achievementImageDataUrl(id); + const choices = results.map((result) => { + const series=Object.values(ACHIEVEMENT_SERIES).find(ids=>ids.includes(result.id)); + const rank=series?copy.levels[series.indexOf(result.id)]:copy.milestone; + const title=achievementTitleWithoutRank(copy.titles[result.id],rank); + return ''; + }).join(''); + return ''; + } + /** * The ⚙ Settings tab: every setting, grouped, editable in place. Core * settings (language / dataDirectory) still write to VS Code config; secrets @@ -4830,8 +7471,12 @@ export class UsageWebviewProvider { '
    ' + this.escapeHtml(I18n.t.statusBar.loading) + '
    ' + ''; }).join('') + ''; + const sharingWorkspace = !this.providerAvailability.claude && + (this.setting('enableShareCard', true) || this.sharingWorkspaceRequested) && + this.setting('enableAchievements', true) + ? this.renderSharingWorkspace() : ''; return this.renderUsageData(null, provider, view.allTime) + - this.renderWeeklyValuePanel(provider) + breakdown; + this.renderWeeklyValuePanel(provider) + breakdown + sharingWorkspace; } if (!this.allTimeData) { return `

    ${I18n.t.popup.noDataMessage}

    `; @@ -8750,6 +11395,10 @@ export class UsageWebviewProvider { .quota-format-custom input { width: 100%; box-sizing: border-box; } .set-switch { position: relative; display: inline-block; width: 40px; height: 22px; } .set-switch input { opacity: 0; width: 0; height: 0; } + .set-switch input:focus-visible + .set-slider { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; + } .set-slider { position: absolute; cursor: pointer; @@ -9486,9 +12135,9 @@ export class UsageWebviewProvider { } .cache-creation-bar { - /* NB: VS Code has no --vscode-charts-pink; without a fallback the whole - gradient is invalid and the bar renders transparent (invisible). */ - background: linear-gradient(to top, var(--vscode-charts-purple), var(--vscode-charts-pink, #d16ba5)); + /* Keep the established endpoint directly: there is no registered + VS Code pink token. No phantom variable or new palette is needed. */ + background: linear-gradient(to top, var(--vscode-charts-purple), #d16ba5); } .cache-read-bar { @@ -10181,6 +12830,7 @@ export class UsageWebviewProvider { .sharing-controls-panel .sc-field > span { color: var(--vscode-foreground); } + .combined-config-card .table-hint { color: var(--vscode-foreground); } .combined-heatmap-controls { display: grid; grid-template-columns: minmax(0, 2fr) repeat(2, minmax(160px, 0.8fr)); @@ -11261,7 +13911,269 @@ export class UsageWebviewProvider { grid-template-columns: minmax(0, 1fr); } } + .achievement-collection { --achievement-emblem-size: 80px; --achievement-card-width: 168px; + --achievement-showcase-size: 72px; --achievement-overview-width: 320px; --achievement-card-height: 180px; display: grid; gap: 12px; } + .achievement-collection[data-achievement-size="small"] { --achievement-emblem-size: 56px; + --achievement-card-width: 136px; --achievement-showcase-size: 56px; --achievement-overview-width: 280px; --achievement-card-height: 156px; } + .achievement-collection[data-achievement-size="large"] { --achievement-emblem-size: 112px; + --achievement-card-width: 208px; --achievement-showcase-size: 96px; --achievement-overview-width: 360px; --achievement-card-height: 212px; } + .achievement-ui-icon { width: 17px; height: 17px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.7; + stroke-linecap: round; stroke-linejoin: round; vertical-align: middle; } + .achievement-showcase { display: grid; grid-template-columns: fit-content(calc(2 * var(--achievement-overview-width) + 8px)) minmax(0,1fr); + gap: 16px; align-items: start; } + .achievement-showcase:empty { display: none; } + .achievement-showcase > :only-child { grid-column: 1 / -1; } + .achievement-overview { min-width: 0; } + .achievement-overview h3, .achievement-gallery-heading { margin: 0 0 8px; } + .achievement-overview h3 { display: flex; align-items: center; gap: 7px; font-size: 1rem; } + .achievement-overview h3 > .achievement-ui-icon { color: var(--vscode-descriptionForeground); } + .achievement-gallery-heading { margin-bottom: 0; } + .achievement-overview-grid { display: flex; flex-wrap: wrap; gap: 8px; } + .achievement-overview-item { position: relative; min-width: 0; width: var(--achievement-overview-width); max-width: 100%; + border: 1px solid var(--vscode-panel-border); border-radius: 8px; box-sizing: border-box; } + .achievement-overview-badge { background: transparent; color: var(--vscode-foreground); border: 0; + display: grid; grid-template-columns: var(--achievement-showcase-size) minmax(0, 1fr); + align-content: center; align-items: center; column-gap: 12px; row-gap: 4px; + text-align: left; padding: 10px; min-width: 0; width: 100%; box-sizing: border-box; border-radius: 8px; } + [data-achievement-overview="badges"] .achievement-overview-badge { padding-right: 48px; } + .achievement-overview-badge:hover { background: var(--vscode-list-hoverBackground); } + .achievement-overview-badge img { width: var(--achievement-showcase-size); max-width: 100%; height: auto; aspect-ratio: 1; object-fit: contain; + grid-column: 1; grid-row: 1 / span 4; } + .achievement-overview-name, .achievement-overview-rank, .achievement-overview-progress, .achievement-overview-track { grid-column: 2; min-width: 0; } + .achievement-overview-name { font-weight: 650; text-wrap: balance; overflow-wrap: anywhere; } + .achievement-overview-rank { font-size: .9rem; } + .achievement-overview-progress { color: var(--vscode-descriptionForeground); font-size: .8rem; overflow-wrap: anywhere; } + .achievement-overview-track { height: 3px; background: var(--vscode-panel-border); overflow: hidden; } + .achievement-overview-track > span { display: block; height: 100%; background: var(--vscode-progressBar-background); } + .achievement-overview-badge[data-overview-rank="silver"] .achievement-overview-rank { color: var(--vscode-charts-blue); } + .achievement-overview-badge[data-overview-rank="gold"] .achievement-overview-rank { color: var(--vscode-charts-yellow); } + .vscode-light .achievement-overview-badge[data-overview-rank="gold"] .achievement-overview-rank { + color: var(--vscode-foreground); border-inline-start: 2px solid var(--vscode-charts-yellow); padding-inline-start: 6px; } + .achievement-overview-badge:focus-visible { outline: 2px solid var(--vscode-focusBorder); outline-offset: 2px; } + .achievement-recent-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), var(--achievement-overview-width))); gap: 12px; } + #achievementPinStatus:empty, #achievementUnreadStatus:empty { display: none; } + .achievement-hidden-pins { list-style: none; padding: 0; } + .achievement-hidden-pins li { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 8px 0; } + .achievement-hidden-pins li span { min-width: 0; overflow-wrap: anywhere; } + .achievement-hidden-pins .achievement-pin { flex-shrink: 0; margin: 0; max-width: 50%; } + .achievement-pin { margin: 8px 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; + background: transparent; color: var(--vscode-foreground); border: 1px solid var(--vscode-panel-border); } + .achievement-pin-icon { display: inline-grid; place-items: center; width: 32px; height: 32px; + padding: 6px; margin: 0; flex-shrink: 0; border-radius: 4px; } + .achievement-pin-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; } + .achievement-pin-icon[aria-pressed="true"] svg { fill: currentColor; } + .achievement-pin-icon:focus-visible { outline: 2px solid var(--vscode-focusBorder); outline-offset: 1px; } + .achievement-card-pins { position: absolute; top: 4px; right: 4px; z-index: 1; display: flex; flex-wrap: wrap; gap: 3px; } + .achievement-overview-item > .achievement-pin { position: absolute; top: 6px; right: 6px; z-index: 1; } + #achievementShowcaseEditor > summary { cursor: pointer; } + #achievementShowcaseEditor[open] > summary { margin-bottom: 10px; } + .achievement-selected-pins { list-style: none; padding: 0; display: grid; gap: 6px; } + .achievement-selected-pins li { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } + .achievement-selected-pins li > span { flex: 1 1 100px; min-width: 0; overflow-wrap: anywhere; } + .achievement-pin-choices { margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,180px),1fr)); gap: 8px; } + .achievement-pin-choices label { display: flex; align-items: center; gap: 8px; min-height: 32px; overflow-wrap: anywhere; } + .achievement-pin-choices label[hidden] { display: none; } + #achievementPinSearch { max-width: 100%; box-sizing: border-box; } + @media (pointer: coarse) { + .achievement-pin-icon { width: 44px; height: 44px; } + [data-achievement-overview="badges"] .achievement-overview-badge { padding-right: 56px; } + } + .achievement-record-badge { display: grid; grid-template-columns: 56px minmax(0, 1fr); column-gap: 12px; + row-gap: 4px; align-items: center; width: 100%; padding: 8px 0; border: 0; background: transparent; + color: var(--vscode-foreground); text-align: left; } + .achievement-record-badge img { width: 56px; height: 56px; object-fit: contain; grid-row: 1 / span 2; } + .achievement-record-name { font-weight: 600; overflow-wrap: anywhere; } + .achievement-record-badge small { color: var(--vscode-descriptionForeground); overflow-wrap: anywhere; } + .achievement-record-badge:focus-visible { outline: 2px solid var(--vscode-focusBorder); outline-offset: 2px; } + .achievement-record-history > summary { cursor: pointer; color: var(--vscode-descriptionForeground); } + .achievement-records-list { display: grid; gap: 4px; margin: 8px 0 0; padding: 0; list-style: none; } + .achievement-records-list .achievement-record-badge { grid-template-columns: 40px minmax(0, 1fr); } + .achievement-records-list img { width: 40px; height: 40px; } + @media (max-width: 720px) { .achievement-recent-grid { grid-template-columns: minmax(0, 1fr); gap: 4px; } } + @media (max-width: 480px) { + .achievement-overview-grid { gap: 8px; } + .achievement-overview-item { width: 100%; } + .achievement-overview-badge { column-gap: 10px; padding: 10px; } + [data-achievement-overview="badges"] .achievement-overview-badge { padding-right: 56px; } + } + .achievement-intro { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 16px; align-items: center; } + .achievement-intro h2 { display: flex; align-items: center; gap: 9px; } + .achievement-heading-icon { display: inline-grid; place-items: center; width: 32px; height: 32px; + border-radius: 8px; background: var(--vscode-editorWidget-background); color: var(--vscode-charts-blue); } + .achievement-heading-icon > svg { width: 24px; height: 24px; } + .achievement-intro > h2 { grid-column: 1; grid-row: 1; } + .achievement-intro > .achievement-count { grid-column: 1; grid-row: 2; } + .achievement-intro > .achievement-record-status { grid-column: 1; grid-row: 2; overflow-wrap: anywhere; } + .achievement-intro > .achievement-actions { grid-column: 2; grid-row: 1 / span 2; } + .achievement-intro > .achievement-evidence-note { grid-column: 1 / -1; grid-row: 3; } + @media (max-width: 720px) { + .achievement-intro { grid-template-columns: minmax(0, 1fr); } + .achievement-intro > .achievement-actions { grid-column: 1; grid-row: 3; } + .achievement-intro > .achievement-evidence-note { grid-row: 4; } + } + .achievement-intro h2 { margin: 0 0 5px; font-size: 1.35rem; } + .achievement-intro p { margin: 0; color: var(--vscode-descriptionForeground); line-height: 1.5; } + .achievement-evidence-note { color: var(--vscode-descriptionForeground); font-size: .85rem; } + .achievement-evidence-note > summary { cursor: pointer; } + .achievement-evidence-note > summary > svg, #achievementShowcaseEditorSummary > svg { margin-inline: 5px 6px; } + .achievement-evidence-note > p { margin-top: 6px; } + .achievement-money-references { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--vscode-panel-border); color: var(--vscode-foreground); } + .achievement-money-references h3, .achievement-money-references h4 { font-size: .9rem; margin: 0 0 8px; } + .achievement-money-references p { margin: 0 0 10px; } + .achievement-money-references:focus-visible { outline: 2px solid var(--vscode-focusBorder); outline-offset: 3px; } + .achievement-reference-list { display: grid; gap: 10px; margin: 0 0 12px; } + .achievement-reference-list > div { display: grid; grid-template-columns: minmax(0,76px) minmax(0,1fr); gap: 8px; } + .achievement-reference-list dt { font-weight: 600; } + .achievement-reference-list dd { margin: 0; min-width: 0; } + .achievement-reference-list code, .achievement-reference-history code { overflow-wrap: anywhere; } + .achievement-reference-list time { margin-inline-start: 8px; white-space: nowrap; color: var(--vscode-descriptionForeground); } + .achievement-reference-history { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; } + .achievement-reference-history li { display: grid; gap: 4px; min-width: 0; overflow-wrap: anywhere; } + .achievement-reference-link { display: inline-flex; gap: 5px; align-items: center; white-space: normal; margin-top: 4px; } + .achievement-proof-status { color: var(--vscode-descriptionForeground); font-size: .8rem; } + .achievement-proof-note { background: var(--vscode-editorWidget-background); padding: 8px; border-radius: 5px; + font-size: .85rem; line-height: 1.5; overflow-wrap: anywhere; } + .achievement-proof-note > svg { margin-inline-end: 6px; } + .achievement-intro .achievement-count { margin-top: 0; color: var(--vscode-foreground); font-weight: 500; display: flex; flex-wrap: wrap; gap: 6px; } + .achievement-count > span { background: var(--vscode-editorWidget-background); padding: 4px 9px; border-radius: 5px; font-size: .85rem; } + .achievement-action { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 32px; border-radius: 6px; } + .achievement-intro .achievement-action-share { background: var(--vscode-button-background); color: var(--vscode-button-foreground); } + .achievement-intro .achievement-action-share:hover { background: var(--vscode-button-hoverBackground); } + .achievement-actions, .achievement-filter-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + .achievement-filter-toolbar { color: var(--vscode-descriptionForeground); } + .achievement-filter-control { display: flex; align-items: center; gap: 7px; padding: 4px 8px; + background: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-panel-border); border-radius: 6px; min-width: 0; } + .achievement-filter-control label { display: flex; align-items: center; gap: 6px; font-size: .85rem; color: var(--vscode-foreground); } + .achievement-filter-control select { min-height: 28px; } + .achievement-batch-actions { display: flex; flex-wrap: wrap; gap: 6px; } + .achievement-common-conditions { margin: 6px 0 10px; color: var(--vscode-foreground); line-height: 1.5; overflow-wrap: anywhere; } + .achievement-level-rules .achievement-level-date { color: var(--vscode-descriptionForeground); line-height: 1.45; font-size: .78rem; overflow-wrap: anywhere; margin-inline-start: auto; text-align: end; } + .achievement-level-date time { white-space: nowrap; } + .achievement-level-rules .achievement-level-status { font-size: .78rem; line-height: 1.45; font-weight: 500; color: var(--vscode-descriptionForeground); } + .achievement-current-value { display: block; margin-top: 3px; color: var(--vscode-foreground); font-size: .9rem; } + #achievementCalibrationStatus:empty { display: none; } + #achievementCalibrationStatus { margin: 6px 0; color: var(--vscode-foreground); overflow-wrap: anywhere; } + #achievementShownCount { margin-inline-start: auto; font-size: .85rem; } + #achievementShowcaseEditor { border-block-end: 1px solid var(--vscode-panel-border); padding-bottom: 8px; } + #achievementShowcaseEditorSummary { width: fit-content; font-size: .9rem; color: var(--vscode-foreground); } + @media (max-width: 960px) { .achievement-showcase { grid-template-columns: minmax(0,1fr); gap: 12px; } } + @media (max-width: 480px) { .achievement-filter-control { width: 100%; box-sizing: border-box; } + .achievement-filter-control select { margin-inline-start: auto; min-width: 0; } } + .achievement-filter-toolbar select { max-width: 100%; background: var(--vscode-dropdown-background); color: var(--vscode-dropdown-foreground); border: 1px solid var(--vscode-dropdown-border); border-radius: 4px; padding: 5px 7px; } + .achievement-filter-toolbar select:focus-visible { outline: 2px solid var(--vscode-focusBorder); } + .achievement-group { --achievement-accent: var(--vscode-charts-purple); } + .achievement-group-claude { --achievement-accent: var(--vscode-charts-orange); } + .achievement-group-codex { --achievement-accent: var(--vscode-charts-blue); } + .achievement-group h3 { margin: 0 0 12px; font-size: 1.05rem; font-weight: 650; } + .achievement-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--achievement-card-width)), 1fr)); gap: 10px; align-items: start; } + .achievement-card { --achievement-tier-color: var(--achievement-accent); position: relative; min-width: 0; border: 1px solid var(--vscode-panel-border); border-radius: 11px; background: var(--vscode-editor-background); overflow: hidden; } + .achievement-card.earned { border-color: var(--achievement-accent); } + .achievement-card[data-achievement-tier="bronze"] { --achievement-tier-color: var(--vscode-charts-orange); } + .achievement-card[data-achievement-tier="silver"] { --achievement-tier-color: var(--vscode-descriptionForeground); } + .achievement-card[data-achievement-tier="gold"] { --achievement-tier-color: var(--vscode-charts-yellow); } + .achievement-card[data-achievement-tier="platinum"] { --achievement-tier-color: var(--vscode-charts-blue); } + .achievement-card[data-achievement-tier="legendary"] { --achievement-tier-color: var(--vscode-charts-purple); } + .achievement-card[data-achievement-series] { --achievement-tier-label-color: var(--vscode-foreground); } + .achievement-card[data-achievement-series].earned { border-color: var(--achievement-tier-color); } + .achievement-card details { min-width: 0; } + .achievement-card.achievement-statistics-expanded { grid-column: 1 / -1; } + .achievement-effort-statistics { color: var(--vscode-foreground); font-size: .85rem; line-height: 1.5; overflow-wrap: anywhere; } + .achievement-effort-rows { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,240px),1fr)); gap: 12px; } + .achievement-effort-rows article { min-width: 0; border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 10px; } + .achievement-effort-rows h6 { font-size: inherit; margin: 0 0 4px; } + .achievement-effort-metric { display: grid; grid-template-columns: minmax(0,1fr); margin-top: 7px; } + .achievement-effort-metric strong { font-variant-numeric: tabular-nums; } + .achievement-effort-metric progress { width: 100%; max-width: 100%; } + .achievement-effort-rows p { margin: 7px 0 0; } + .achievement-card summary { display: grid; justify-items: center; align-content: start; gap: 4px; min-height: var(--achievement-card-height); padding: 38px 10px 12px; cursor: pointer; list-style: none; text-align: center; } + .achievement-card summary::-webkit-details-marker { display: none; } + .achievement-card summary:hover { background: var(--vscode-list-hoverBackground); } + .achievement-card summary > * { min-width: 0; max-width: 100%; } + .achievement-card summary::after { content: '⌄'; color: var(--vscode-descriptionForeground); line-height: 1; } + .achievement-card details[open] summary::after { content: '⌃'; } + .achievement-card summary:focus-visible { outline: 2px solid var(--vscode-focusBorder); outline-offset: -3px; border-radius: 10px; } + .achievement-emblem { display: grid; place-items: center; width: var(--achievement-emblem-size); height: var(--achievement-emblem-size); border-radius: 13px; overflow: hidden; } + .achievement-card summary .achievement-emblem { margin-bottom: 4px; } + .achievement-emblem img { display: block; width: 100%; height: 100%; object-fit: contain; } + .achievement-card.locked .achievement-emblem img { filter: grayscale(.45); opacity: .75; } + .achievement-card h4 { margin: 0; font-size: .9rem; font-weight: 600; line-height: 1.25; overflow-wrap: anywhere; } + .achievement-card[data-achievement-series].earned h4 { color: var(--achievement-tier-label-color); } + .achievement-rank { display: inline-block; max-width: 100%; padding: 2px 7px; border: 1px solid var(--vscode-panel-border); border-radius: 999px; color: var(--vscode-descriptionForeground); font-size: .72rem; line-height: 1.25; overflow-wrap: anywhere; } + .achievement-next-target { color: var(--vscode-descriptionForeground); font-size: .72rem; line-height: 1.4; overflow-wrap: anywhere; } + .achievement-current-progress, .achievement-stage-target { display: block; } + .achievement-current-progress { color: var(--vscode-foreground); font-variant-numeric: tabular-nums; } + .achievement-progress-track { display: block; width: 100%; max-width: 108px; height: 3px; background: var(--vscode-panel-border); overflow: hidden; } + .achievement-progress-track > span { display: block; height: 100%; background: var(--achievement-accent); } + .achievement-card-detail h5 { margin: 12px 0 6px; font-size: .8rem; } + .achievement-level-rules { min-width: 0; list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; } + .achievement-level-rules li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: baseline; gap: 4px 8px; padding-block: 6px; font-size: .8rem; line-height: 1.45; overflow-wrap: anywhere; } + .achievement-level-rules li + li { border-top: 1px solid var(--vscode-panel-border); padding-top: 8px; } + .achievement-level-rules li > strong { color: var(--vscode-foreground); font-size: .85rem; } + .achievement-level-rules li span { color: var(--vscode-descriptionForeground); } + .achievement-level-rules .achievement-level-value { justify-self: end; text-align: end; white-space: nowrap; color: var(--vscode-foreground); font-weight: 700; font-size: .85rem; font-variant-numeric: tabular-nums; } + .achievement-level-rules li > small, .achievement-level-meta { grid-column: 1 / -1; min-width: 0; } + .achievement-level-meta { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 3px 6px; } + .achievement-level-rules .achievement-level-name { color: var(--vscode-foreground); color: color-mix(in srgb,var(--achievement-level-accent,var(--vscode-editor-foreground)) 40%,var(--vscode-editor-foreground)); } + .achievement-level-name[data-rank="bronze"] { --achievement-level-accent: var(--vscode-charts-orange); } + .achievement-level-name[data-rank="silver"] { color: var(--vscode-descriptionForeground); } + .achievement-level-name[data-rank="gold"] { --achievement-level-accent: var(--vscode-charts-yellow); } + .achievement-level-name[data-rank="platinum"] { --achievement-level-accent: var(--vscode-charts-blue); } + .achievement-level-name[data-rank="legendary"] { --achievement-level-accent: var(--vscode-charts-purple); } + .vscode-high-contrast .achievement-level-rules .achievement-level-name, .vscode-high-contrast-light .achievement-level-rules .achievement-level-name { color: var(--vscode-foreground); } + .achievement-milestones li { grid-template-columns: 40px minmax(0, 1fr); column-gap: 8px; } + .achievement-milestones img { grid-row: 1 / span 3; width: 40px; height: 40px; object-fit: contain; } + .achievement-milestones strong, .achievement-milestones small, .achievement-milestones span { grid-column: 2; } + .achievement-milestones .achievement-level-meta { grid-column: 2; } + .achievement-feature-journey { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; } + .achievement-feature-journey li { min-width: 0; display: grid; grid-template-columns: 16px minmax(0, 1fr); column-gap: 6px; font-size: .76rem; line-height: 1.4; overflow-wrap: anywhere; } + .achievement-step-marker { grid-row: 1 / span 2; } + .achievement-feature-journey small { grid-column: 2; color: var(--vscode-descriptionForeground); } + .achievement-share-choice-icon { flex: none; width: 32px; height: 32px; object-fit: contain; } + .achievement-share-presentation .sc-check { min-width: 0; } + .achievement-share-presentation .sc-check span { overflow-wrap: anywhere; } + .achievement-share-presentation .achievement-share-options { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,230px),1fr)); gap: 8px; } + .achievement-share-presentation .achievement-share-option { display: grid; grid-template-columns: 16px 36px minmax(0,1fr); align-items: center; gap: 10px; min-height: 64px; box-sizing: border-box; padding: 8px 10px; border-radius: 6px; } + .achievement-share-option:has(input:checked) { border-color: var(--vscode-focusBorder); } + .achievement-share-option:focus-within { outline: 2px solid var(--vscode-focusBorder); outline-offset: 1px; } + .achievement-share-option input { margin: 0; } + .achievement-share-option-text { display: grid; gap: 3px; min-width: 0; } + .achievement-share-option-name { font-weight: 500; line-height: 1.4; } + .achievement-share-option-text small { font-size: 11px; color: var(--vscode-descriptionForeground); } + .achievement-card[data-achievement-series].earned .achievement-rank { border-color: var(--achievement-tier-color); color: var(--achievement-tier-label-color); } + .achievement-tier-pips { display: flex; align-items: center; justify-content: center; gap: 6px; height: 10px; } + .achievement-tier-pip { width: 5px; height: 5px; border: 1px solid var(--vscode-descriptionForeground); border-radius: 50%; opacity: .65; } + .achievement-tier-pip.complete { width: 7px; height: 7px; border-color: var(--achievement-tier-color); background: var(--achievement-tier-color); opacity: 1; } + .achievement-tier-pip.next { width: 7px; height: 7px; border-color: var(--vscode-foreground); opacity: .8; } + .achievement-card-detail { padding: 0 12px 13px; border-top: 1px solid var(--vscode-panel-border); } + .achievement-card [data-achievement-statistics] { max-width: 100%; white-space: normal; margin-top: 12px; } + .achievement-statistics-return { position: sticky; top: 8px; z-index: 2; max-width: 100%; white-space: normal; margin: 8px 0; } + .achievement-tier-markers { display: flex; flex-wrap: wrap; gap: 5px; margin: 9px 0 4px; } + .achievement-tier-marker { display: grid; place-items: center; min-width: 19px; height: 17px; padding: 0 3px; border: 1px solid var(--vscode-panel-border); border-radius: 4px; color: var(--vscode-descriptionForeground); font-size: .68rem; line-height: 1; } + .achievement-tier-marker.complete { border-color: var(--achievement-tier-color); color: var(--achievement-tier-label-color); font-weight: 700; } + .achievement-tier-marker.next { border-style: dashed; } + .achievement-progress-label { display: block; margin-bottom: 5px; font-size: .82rem; color: var(--vscode-descriptionForeground); overflow-wrap: anywhere; } + .achievement-card progress { display: block; width: 100%; height: 5px; accent-color: var(--achievement-accent); } + .achievement-unlock-notice { position: fixed; inset-inline-end: 16px; bottom: 17px; z-index: 100; display: flex; align-items: center; gap: 10px; max-width: min(360px, calc(100vw - 32px)); padding: 10px 12px; border: 1px solid var(--vscode-focusBorder); border-radius: 9px; background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); box-shadow: 0 4px 16px var(--vscode-widget-shadow, rgba(0,0,0,.22)); } + .achievement-unlock-notice img { flex: none; width: 44px; height: 44px; object-fit: contain; animation: ccuBadgeArrive 440ms ease-out 1; } + @keyframes ccuBadgeArrive { + from { opacity: .65; transform: scale(.88); } + to { opacity: 1; transform: scale(1); } + } + body.vscode-high-contrast .achievement-unlock-notice img, + body.vscode-high-contrast-light .achievement-unlock-notice img { animation: none; } + .achievement-unlock-notice span { min-width: 0; overflow-wrap: anywhere; } + .achievement-unlock-notice button { align-self: start; margin-inline-start: auto; border: 0; background: transparent; color: inherit; cursor: pointer; } + .achievement-unlock-notice button:focus-visible { outline: 2px solid var(--vscode-focusBorder); outline-offset: 2px; } + .achievement-intro .btn-secondary { margin-top: 0; } + .achievement-share-preview { overflow-x: auto; border: 1px solid var(--vscode-panel-border); border-radius: 10px; background: var(--vscode-editor-background); } + .achievement-share-preview svg { display: block; width: 100%; min-width: 320px; height: auto; } + @media (max-width: 480px) { + .achievement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + .achievement-card summary { min-height: 160px; padding: 11px 6px 10px; } + .achievement-card h4 { font-size: .82rem; } + } @media (prefers-reduced-motion: reduce) { + .achievement-unlock-notice img { animation: none !important; } *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; @@ -11287,6 +14199,7 @@ export class UsageWebviewProvider { return ` // Get VSCode API const vscode = acquireVsCodeApi(); +var __ccuDocumentId = '${this.dashboardDocumentId}'; const __adviceCopy = ${JSON.stringify(I18n.t.popup.adviceEffectiveness)}; const __dashboardFeedbackCopy = ${JSON.stringify(I18n.dashboardFeedback)}; const __shareCardDefaults = ${JSON.stringify(DEFAULT_SECTIONS)}; @@ -11307,6 +14220,10 @@ function ccuRequestDestinationText(message) { __dashboardFeedbackCopy.protocol + ': ' + message.apiFormat + ' · ' + __dashboardFeedbackCopy.model + ': ' + message.model; } +const __achievementDismissLabel = ${JSON.stringify(I18n.t.popup.dismiss)}; +const __achievementCopy = ${JSON.stringify((({ titles, ...chrome }) => chrome)(achievementCopyFor(I18n.getLocale())))}; +const __achievementAppearanceCopy = ${JSON.stringify(achievementAppearanceCopyFor(I18n.getLocale()))}; +const __achievementNoticesEnabled = ${this.setting('enableAchievements', true) && this.setting('showAchievementNotices', true)}; let __claudeLast30HoursByDay = ${LIVE_PATCH_HOURS_START}${inlineScriptJson( claudeHourlyDisplayDto(this.hourlyDataForRolling30DaysByDay), )}${LIVE_PATCH_HOURS_END}; @@ -11540,7 +14457,9 @@ function restorePersistedDetails() { var open = st.openDetails || []; if (!open.length) { return; } document.querySelectorAll('details[data-persist]').forEach(function(d){ - if (open.indexOf(d.getAttribute('data-persist')) !== -1) { d.open = true; } + var aliases = (d.getAttribute('data-persist-aliases') || '').split(' '); + if (open.indexOf(d.getAttribute('data-persist')) !== -1 || + aliases.some(function(key) { return key && open.indexOf(key) !== -1; })) { d.open = true; } }); } catch (e) {} } @@ -11559,7 +14478,12 @@ function clearPersistedDetails() { } // 'toggle' doesn't bubble — listen in the capture phase. document.addEventListener('toggle', function(e){ - if (e.target && e.target.matches && e.target.matches('details[data-persist]')) { savePersistedDetails(); } + if (e.target && e.target.matches && e.target.matches('details[data-persist]')) { + if (e.target.dataset.persist === 'achievement:effort-breadth') { + e.target.closest('.achievement-card').classList.toggle('achievement-statistics-expanded',e.target.open); + } + savePersistedDetails(); + } }, true); // Restore the active tab from localStorage before first paint, so a reload can't change it. @@ -11588,6 +14512,7 @@ function restoreUi() { restoreActiveTab(); restoreSessionFilter(); restorePersistedDetails(); + restoreAchievementFilter(); restoreClaudeDrilldownDetails(); restoreCodexHourlyDetails(); restoreAdviceEffectivenessState(); @@ -11599,12 +14524,17 @@ function restoreUi() { restoreHourlyOverviewSelections(); initializeStatusRegions(); restoreSharingTemplate(); + restoreAchievementShareSelection(); restoreCombinedHeatmapConfig(); restoreShareCardDraft(); restoreProjectMatrixState(); requestLocalDataInventoryForVisibleSettings(); restoreScrollPosition(); - vscode.postMessage({ command: 'localDataClientReady' }); + restoreAchievementReturnFocus(); + var readyProvider = ccuProviderName(); + var readyTab = document.querySelector('.tabs .tab.active[data-dashboard-tab]'); + vscode.postMessage({ command: 'localDataClientReady', provider: readyProvider, + activeTab: readyProvider === 'compare' ? 'all' : readyTab ? readyTab.dataset.dashboardTab : 'today', documentId: __ccuDocumentId }); } ${getDashboardRefreshClientScript()} @@ -11675,6 +14605,7 @@ const __currencyDisplay = ${JSON.stringify({ decimalPlaces: I18n.getDecimalPlaces(), })}; const __combinedHeatmapCopy = ${JSON.stringify(combinedHeatmapUiCopy(I18n.getLocale()))}; +const __apiHeatmapCopy = ${JSON.stringify(I18n.apiHeatmap)}; const __sharingWorkspaceCopy = ${JSON.stringify(I18n.sharingWorkspace)}; const __sharingTemplateCommand = ${JSON.stringify(this.sharingCommandIntents.pending() ?? null)}; const __localDataCopy = ${JSON.stringify(localDataUiCopy(I18n.getLocale()))}; @@ -11739,13 +14670,14 @@ function ccuRollingDaySet(count) { // Define basic functions function showProvider(provider, tab) { + if (provider !== ccuProviderName()) { ccuWriteUiState('achievementStatisticsReturn', undefined); } vscode.postMessage({ command: 'providerChanged', provider: provider, tab: tab || '' }); } ${getProviderNavClientScript()} function selectSharingTemplate(template, save) { - if (['combinedHeatmap', 'claudeShareCard', 'claudeHeatmap'].indexOf(template) < 0) { return; } + if (['combinedHeatmap', 'claudeShareCard', 'claudeHeatmap', 'achievements'].indexOf(template) < 0) { return; } var selector = document.getElementById('sharingTemplate'); var option = selector ? selector.querySelector('option[value="' + template + '"]') : null; if (!selector || !option || option.disabled) { return; } @@ -11758,6 +14690,320 @@ function selectSharingTemplate(template, save) { vscode.postMessage({ command: 'sharingTemplateChanged', template: template }); } } +function selectedAchievementShareIds(changed) { + var choices = Array.from(document.querySelectorAll('.achievement-share-choice')); + var checked = choices.filter(function(input) { return input.checked; }); + if (checked.length > 9 && changed && changed.checked) { + changed.checked = false; + checked = checked.filter(function(input) { return input !== changed; }); + } + checked.slice(9).forEach(function(input) { input.checked = false; }); + checked = checked.slice(0, 9); + choices.forEach(function(input) { input.disabled = checked.length >= 9 && !input.checked; }); + var count = document.getElementById('achievementShareCount'); + if (count) { count.textContent = checked.length + '/9'; } + var limit = document.getElementById('achievementShareLimit'); + if (limit) { limit.textContent = checked.length === 9 ? __achievementCopy.shareLimit : ''; } + return checked.map(function(input) { return input.value; }); +} +var __achievementShareRequest = 0; +var __achievementShareClientId = globalThis.crypto.randomUUID(); +var __achievementShareRequestedIds = []; +var __achievementExportPreviewId = ''; +var __achievementSelectionChanged = false; +function requestAchievementShare(command, ids) { + __achievementShareRequestedIds = ids.slice(); + var preview = document.getElementById('achievementSharePreview'); + var exportButton = document.getElementById('achievementShareExportBtn'); + var status = document.getElementById('achievementShareStatus'); + if (preview) { + preview.dataset.previewId = ''; + preview.setAttribute('aria-busy', ids.length ? 'true' : 'false'); + if (!ids.length) { + preview.textContent = document.querySelector('.achievement-share-choice') + ? __achievementCopy.shareSelect : __achievementCopy.shareEmpty; + } + } + if (exportButton) { exportButton.disabled = true; } + if (status) { status.textContent = ids.length ? __achievementCopy.sharePending : ''; } + vscode.postMessage({ command: command, ids: ids, requestId: ++__achievementShareRequest, clientId: __achievementShareClientId }); +} +function previewAchievementShare(changed) { + __achievementSelectionChanged = false; + var ids = selectedAchievementShareIds(changed); + try { localStorage.setItem('ccu.sharing.achievementBadges', JSON.stringify(ids)); } catch (e) {} + requestAchievementShare('previewAchievementShare', ids); +} +function exportAchievementShare() { + var preview = document.getElementById('achievementSharePreview'); + var button = document.getElementById('achievementShareExportBtn'); + if (!preview || !preview.dataset.previewId || !button || button.disabled || __achievementExportPreviewId) { return; } + __achievementExportPreviewId = preview.dataset.previewId; + button.disabled = true; + var status = document.getElementById('achievementShareStatus'); + if (status) { status.textContent = __achievementCopy.shareSaving; } + vscode.postMessage({ command: 'exportAchievementShare', ids: selectedAchievementShareIds(), previewId: preview.dataset.previewId }); +} +function filterAchievements(value, save) { + var selector = document.getElementById('achievementFilter'); + if (!selector) { return; } + var filter = ['all', 'next', 'earned'].indexOf(value) >= 0 ? value : 'all'; + selector.value = filter; + var shown = 0; + document.querySelectorAll('.achievement-card').forEach(function(card) { + card.hidden = filter === 'earned' ? card.dataset.achievementEarned !== 'true' : + filter === 'next' ? card.dataset.achievementNext !== 'true' : false; + if (!card.hidden) { shown++; } + }); + document.querySelectorAll('.achievement-group').forEach(function(group) { + group.hidden = !Array.from(group.querySelectorAll('.achievement-card')).some(function(card) { return !card.hidden; }); + }); + var empty = document.getElementById('achievementFilterEmpty'); + if (empty) { empty.hidden = shown > 0; } + var count = document.getElementById('achievementShownCount'); + if (count) { count.textContent = __achievementCopy.shownCount.replace('{count}', shown.toLocaleString()); } + if (save) { ccuWriteUiState('achievementFilter', filter); } +} +function restoreAchievementFilter() { + filterAchievements(ccuReadUiState().achievementFilter, false); + setAchievementSize(ccuReadUiState().achievementSize, false); + var pinSearch = document.getElementById('achievementPinSearch'); + if (pinSearch) { pinSearch.value = ccuReadUiState().achievementPinSearch || ''; filterAchievementPinChoices(pinSearch.value, false); } + restoreAchievementStatisticsReturn(); +} +function setAchievementSize(value, save) { + var size = ['small', 'medium', 'large'].includes(value) ? value : 'medium'; + var collection = document.querySelector('.achievement-collection'); + if (!collection) { return; } + var anchor = save && Array.from(collection.querySelectorAll('.achievement-card')).find(function(card) { + var rect = card.getBoundingClientRect(); return !card.hidden && rect.bottom > 0 && rect.top < innerHeight; + }); + var before = anchor ? anchor.getBoundingClientRect().top : 0; + collection.dataset.achievementSize = size; + var selector = document.getElementById('achievementSize'); + if (selector) { selector.value = size; } + if (save) { ccuWriteUiState('achievementSize', size); } + if (anchor) { window.scrollBy(0, anchor.getBoundingClientRect().top - before); } +} +function filterAchievementPinChoices(query, save) { + var value = typeof query === 'string' ? query.slice(0, 120) : ''; + document.querySelectorAll('[data-pin-search]').forEach(function(label) { + label.hidden = !label.dataset.pinSearch.includes(value.toLocaleLowerCase()); + }); + if (save !== false) { ccuWriteUiState('achievementPinSearch', value); } +} +function achievementStatisticsEntry(intent) { + if (!intent || (intent.provider !== 'claude' && intent.provider !== 'codex') || + intent.provider !== ccuProviderName() || typeof intent.key !== 'string' || + !/^[a-z][a-z0-9-]{0,63}$/.test(intent.key)) { return null; } + var button = document.getElementById('achievement-statistics-' + intent.key); + if (!button || button.dataset.achievementStatistics !== intent.key || !button.closest('#achievements')) { return null; } + var section = button.dataset.statisticsSection; + var target = section === 'models' ? document.querySelector('#all .model-breakdown h3') + : section === 'tokens' ? document.querySelector('#all .usage-summary .summary-grid') : null; + return target ? { button: button, target: target } : null; +} +function restoreAchievementStatisticsReturn() { + var back = document.getElementById('achievement-statistics-back'); + if (!back) { return; } + var entry = achievementStatisticsEntry(ccuReadUiState().achievementStatisticsReturn); + document.querySelectorAll('#all [id="achievement-statistics-target"]').forEach(function(target) { + if (entry && target === entry.target) { return; } + target.removeAttribute('id'); target.removeAttribute('tabindex'); + target.removeAttribute('data-refresh-focus-fallback'); + }); + back.hidden = !entry; + if (entry) { + entry.target.id = 'achievement-statistics-target'; + entry.target.tabIndex = -1; + entry.target.dataset.refreshFocusFallback = 'tab-all'; + } +} +function openAchievementStatistics(button) { + var intent = { key: button && button.dataset.achievementStatistics, provider: ccuProviderName() }; + var entry = achievementStatisticsEntry(intent); + if (!entry || entry.button !== button) { return; } + flushScrollPosition(); saveScrollPosition(); savePersistedDetails(); + ccuWriteUiState('achievementStatisticsReturn', intent); + showTab('all', false, true); + restoreAchievementStatisticsReturn(); + entry.target.focus({ preventScroll: true }); entry.target.scrollIntoView({ block: 'center' }); +} +function backFromAchievementStatistics() { + var entry = achievementStatisticsEntry(ccuReadUiState().achievementStatisticsReturn); + if (!entry) { restoreAchievementStatisticsReturn(); return; } + flushScrollPosition(); saveScrollPosition(); + showTab('achievements', false, true); + ccuWriteUiState('achievementStatisticsReturn', undefined); + restoreAchievementStatisticsReturn(); restorePersistedDetails(); + entry.button.focus({ preventScroll: true }); restoreScrollPosition(); +} +function openAchievementReference(button) { + var root = button && button.closest('.achievement-collection'); + var reference = root && root.querySelector('#achievementMoneyReference'); + var disclosure = reference && reference.closest('details'); + if (!reference || !disclosure) { return; } + disclosure.open = true; + savePersistedDetails(); + reference.focus({ preventScroll: true }); + reference.scrollIntoView({ block: 'center' }); +} +function openAchievementDetail(key) { + var card = Array.from(document.querySelectorAll('.achievement-card')).find(function(card) { + var detail = card.querySelector('details'); + return card.dataset.achievementKey === key || (detail && + (detail.dataset.persistAliases || '').split(' ').includes('achievement:' + key)); + }); + if (!card) { return; } + filterAchievements('all', true); + var detail = card.querySelector('details'); + var summary = detail && detail.querySelector('summary'); + if (!detail || !summary) { return; } + detail.open = true; + summary.focus({ preventScroll: true }); + card.scrollIntoView({ block: 'center' }); +} +var __achievementShowcaseBusy = false; +function setAchievementShowcaseBusy(busy) { + __achievementShowcaseBusy = busy; + document.querySelectorAll('[data-achievement-pin], [data-showcase-mode], [data-pin-move]').forEach(function(control) { + if (busy) { + if (control.dataset.pinWasDisabled === undefined) { control.dataset.pinWasDisabled = String(control.disabled); } + control.setAttribute('aria-disabled', 'true'); + // Native disabling blurs the active control, destroying its refresh + // anchor/fallback. Keep just that control focusable; the busy gate blocks + // every request and checkbox handler restores its confirmed value. + control.disabled = control.dataset.pinWasDisabled === 'true' || control !== document.activeElement; + } + else if (control.dataset.pinWasDisabled !== undefined) { + control.disabled = control.dataset.pinWasDisabled === 'true'; delete control.dataset.pinWasDisabled; + control.removeAttribute('aria-disabled'); + } + }); +} +function placeAchievementPinStatus(control) { + var status = document.getElementById('achievementPinStatus'); + var owner = control && control.closest('[data-pin-key], .achievement-card, .achievement-overview-item, .achievement-hidden-pins li, .achievement-pin-choices'); + if (!owner && control) { owner = control.closest('#achievementShowcaseEditor .achievement-actions'); } + if (status && owner) { owner.appendChild(status); } + if (status) { status.textContent = ''; } +} +function requestAchievementPin(button) { + if (!button || !button.dataset) { return; } + if (button.type === 'checkbox') { button.checked = button.getAttribute('aria-pressed') === 'true'; } + if (__achievementShowcaseBusy) { return; } + var generation = Number(button.dataset.achievementGeneration); + if (!Number.isSafeInteger(generation) || generation < 1) { return; } + placeAchievementPinStatus(button); + setAchievementShowcaseBusy(true); + vscode.postMessage({ command: 'setAchievementPinned', key: button.dataset.achievementPin, + selected: button.getAttribute('aria-pressed') !== 'true', generation: generation, documentId: __ccuDocumentId }); +} +function requestAchievementShowcase(action, fields) { + var editor = document.getElementById('achievementShowcaseEditor'); + if (!editor || __achievementShowcaseBusy) { return; } + var generation = Number(editor.dataset.generation), revision = Number(editor.dataset.revision); + if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(revision) || revision < 0) { return; } + setAchievementShowcaseBusy(true); + vscode.postMessage(Object.assign({ command: 'editAchievementShowcase', action: action, + generation: generation, revision: revision, documentId: __ccuDocumentId }, fields)); +} +function requestAchievementShowcaseMode(button) { + if (button && ['automatic', 'manual'].includes(button.dataset.showcaseMode)) { + if (__achievementShowcaseBusy) { return; } + placeAchievementPinStatus(button); + requestAchievementShowcase('mode', { mode: button.dataset.showcaseMode }); + } +} +function requestAchievementPinMove(button) { + var row = button && button.closest('[data-pin-key]'); + var list = document.getElementById('achievementSelectedPins'); + if (!row || !list) { return; } + var keys = Array.from(list.children).map(function(item) { return item.dataset.pinKey; }); + var index = keys.indexOf(row.dataset.pinKey), next = index + (button.dataset.pinMove === 'up' ? -1 : 1); + if (index < 0 || next < 0 || next >= keys.length) { return; } + if (__achievementShowcaseBusy) { return; } + placeAchievementPinStatus(button); + var swap = keys[index]; keys[index] = keys[next]; keys[next] = swap; + requestAchievementShowcase('reorder', { keys: keys }); +} + +function requestAchievementSummaryRead(button) { + if (!button || !/^[0-9a-f]{48}$/.test(button.dataset.achievementSummary || '')) { return; } + vscode.postMessage({ command: 'achievementSummaryAck', summaryId: button.dataset.achievementSummary, + documentId: __ccuDocumentId }); +} +function openAchievementSettings() { + showTab('settings'); + var setting = document.getElementById('set_enableAchievements'); + if (setting) { setting.focus(); setting.scrollIntoView({ block: 'center' }); } +} +function setAchievementDisclosures(button, open) { + var root = button && button.closest('.achievement-collection'); + if (!root || typeof open !== 'boolean') { return; } + // Only gallery cards, including filtered cards. Evidence/history/settings + // retain their own disclosure and focus; no host request or grant action. + root.querySelectorAll('details.achievement-disclosure[data-persist]').forEach(function(details) { + details.open = open; + }); + savePersistedDetails(); + button.focus({ preventScroll: true }); +} +function requestAchievementCalibration(button) { + if (!button || button.disabled || !button.closest('.achievement-collection')) { return; } + button.disabled = true; button.setAttribute('aria-busy', 'true'); + vscode.postMessage({command:'recalibrateAchievements',documentId:__ccuDocumentId}); +} +function backToAchievements() { + try { localStorage.setItem('ccu.activeTab', 'achievements'); } catch (e) {} + ccuWriteUiState('achievementReturnFocus', true); + if (document.getElementById('tab-achievements')) { showTab('achievements'); restoreAchievementReturnFocus(); } + else { + var origin = ccuReadUiState().achievementShareOrigin; + if ((origin !== 'claude' && origin !== 'codex') || !document.getElementById('provider-tab-' + origin)) { + origin = document.getElementById('provider-tab-codex') ? 'codex' : 'claude'; + } + showProvider(origin, 'achievements'); + } +} +function restoreAchievementReturnFocus() { + var filter = document.getElementById('achievementFilter'); + var tab = document.getElementById('tab-achievements'); + if (ccuReadUiState().achievementReturnFocus !== true || !filter || !tab || tab.getAttribute('aria-selected') !== 'true') { return; } + ccuWriteUiState('achievementReturnFocus', false); + filter.focus({ preventScroll: true }); +} +function openAchievementShare() { + var origin = document.querySelector('.provider-tab[aria-selected="true"]'); + if (origin && (origin.id === 'provider-tab-claude' || origin.id === 'provider-tab-codex')) { + ccuWriteUiState('achievementShareOrigin', origin.id.replace('provider-tab-', '')); + } + try { localStorage.setItem('ccu.sharing.template', 'achievements'); } catch (e) {} + if (document.getElementById('provider-tab-compare')) { + showProvider('compare', 'all'); + } else { + showTab('all'); + selectSharingTemplate('achievements', true); + } +} +function restoreAchievementShareSelection() { + var choices = document.querySelectorAll('.achievement-share-choice'); + if (!choices.length) { return; } + var stored = null; + try { + var raw = localStorage.getItem('ccu.sharing.achievementBadges'); + if (raw !== null) { stored = JSON.parse(raw); } + } catch (e) {} + if (!Array.isArray(stored)) { selectedAchievementShareIds(); return; } + choices.forEach(function(input) { input.checked = stored.indexOf(input.value) >= 0; }); + var ids = selectedAchievementShareIds(); + __achievementSelectionChanged = stored.some(function(id) { return ids.indexOf(id) < 0; }); + requestAchievementShare('restoreAchievementShare', ids); + if (__achievementSelectionChanged) { + var status = document.getElementById('achievementShareStatus'); + if (status) { status.textContent = __achievementCopy.shareSelectionChanged; } + } +} function restoreSharingTemplate() { var selector = document.getElementById('sharingTemplate'); if (!selector) { return; } @@ -11934,13 +15180,19 @@ function combinedHeatmapReadConfig() { var privacy = document.getElementById('combinedHeatmapPrivacy'); var palette = document.querySelector('input[name="combinedHeatmapPalette"]:checked'); var customAccent = document.getElementById('combinedHeatmapCustomAccent'); + var provider = document.getElementById('combinedHeatmapProvider'); + var metric = document.getElementById('combinedHeatmapMetric'); + var amounts = document.getElementById('combinedHeatmapShowAmounts'); + var showAmounts = !!(amounts && amounts.checked && metric && metric.value === 'apiEquivalent'); return { title: title ? title.value : __combinedHeatmapCopy.defaultTitle, range: range ? range.value : 'year', intensityMode: intensityMode ? intensityMode.value : 'quantile', privacyPreview: privacy ? privacy.checked : true, palette: palette ? palette.value : 'academicViolet', - customAccent: customAccent ? customAccent.value : '#4f2f87' + customAccent: customAccent ? customAccent.value : '#4f2f87', + provider: provider ? provider.value : 'both', + metric: showAmounts ? 'apiEquivalent' : 'tokens', showAmounts: showAmounts }; } function combinedHeatmapSaveConfig(config) { @@ -11951,12 +15203,72 @@ function combinedHeatmapSaveConfig(config) { localStorage.setItem('ccu.combinedHeatmap.privacyPreview', config.privacyPreview ? 'true' : 'false'); localStorage.setItem('ccu.combinedHeatmap.palette', config.palette); localStorage.setItem('ccu.combinedHeatmap.customAccent', config.customAccent); + localStorage.setItem('ccu.combinedHeatmap.provider', config.provider); + localStorage.setItem('ccu.combinedHeatmap.metric', config.metric); + localStorage.setItem('ccu.combinedHeatmap.showAmounts', config.showAmounts ? 'true' : 'false'); } catch (e) {} } -function toggleCombinedCustomAccent() { +function toggleCombinedCustomAccent(changed) { var config = combinedHeatmapReadConfig(); var customAccent = document.getElementById('combinedHeatmapCustomAccent'); if (customAccent) { customAccent.disabled = config.palette !== 'custom'; } + if (changed === true) { combinedHeatmapConfigChanged(); } +} +var __apiHeatmapRequestId = 0; +var __apiHeatmapPendingConfigKey = ''; +var __apiHeatmapExportPreviewId = ''; +function combinedHeatmapConfigKey(config) { + return JSON.stringify([config.title,config.range,config.intensityMode,config.palette,config.customAccent, + config.provider,config.metric,config.showAmounts]); +} +function combinedHeatmapSetExportState() { + var preview = document.getElementById('combinedHeatmapPreview'); + var valid = preview && preview.dataset.previewId && preview.dataset.configKey === combinedHeatmapConfigKey(combinedHeatmapReadConfig()); + ['combinedHeatmapExport','combinedHeatmapCopy'].forEach(function(id) { + var button = document.getElementById(id); + if (button) { button.disabled = !valid || !!__apiHeatmapExportPreviewId; } + }); +} +function combinedHeatmapRetire(notify, clear) { + var preview = document.getElementById('combinedHeatmapPreview'); + var markdown = document.getElementById('combinedHeatmapMarkdown'); + if (preview) { + preview.dataset.previewId = ''; preview.dataset.configKey = ''; + preview.setAttribute('aria-busy','false'); + if (clear !== false) { preview.innerHTML = ''; } + } + if (markdown) { markdown.value = ''; } + __apiHeatmapPendingConfigKey = ''; + __apiHeatmapRequestId += 1; + combinedHeatmapSetExportState(); + if (notify) { vscode.postMessage({command:'retireCombinedHeatmapPreview',requestId:__apiHeatmapRequestId,documentId:__ccuDocumentId}); } +} +function combinedHeatmapUpdateDisclosure() { + var config = combinedHeatmapReadConfig(); + var disclosure = document.getElementById('combinedHeatmapAmountDisclosure'); + var includes = document.getElementById('combinedHeatmapPrivacyIncludes'); + if (disclosure) { disclosure.hidden = !config.showAmounts; } + if (includes) { includes.textContent = '✓ ' + (config.showAmounts ? __apiHeatmapCopy.amountDisclosure : includes.dataset.tokenCopy); } +} +function combinedHeatmapConfigChanged() { + var config = combinedHeatmapReadConfig(); + combinedHeatmapSaveConfig(config); + combinedHeatmapRetire(true, true); + combinedHeatmapUpdateDisclosure(); + var status = document.getElementById('combinedHeatmapStatus'); + if (status) { status.textContent = __apiHeatmapCopy.stale; } +} +function combinedHeatmapMetricChanged() { + var metric = document.getElementById('combinedHeatmapMetric'); + var amounts = document.getElementById('combinedHeatmapShowAmounts'); + if (amounts && metric) { amounts.checked = metric.value === 'apiEquivalent'; } + combinedHeatmapConfigChanged(); +} +function combinedHeatmapAmountsChanged() { + var metric = document.getElementById('combinedHeatmapMetric'); + var amounts = document.getElementById('combinedHeatmapShowAmounts'); + if (metric && amounts) { metric.value = amounts.checked ? 'apiEquivalent' : 'tokens'; } + combinedHeatmapConfigChanged(); } function toggleCombinedHeatmapPrivacy(save) { var config = combinedHeatmapReadConfig(); @@ -11970,6 +15282,9 @@ function restoreCombinedHeatmapConfig() { var intensityMode = document.getElementById('combinedHeatmapIntensityMode'); var privacy = document.getElementById('combinedHeatmapPrivacy'); var customAccent = document.getElementById('combinedHeatmapCustomAccent'); + var provider = document.getElementById('combinedHeatmapProvider'); + var metric = document.getElementById('combinedHeatmapMetric'); + var amounts = document.getElementById('combinedHeatmapShowAmounts'); if (!title || !range || !intensityMode || !privacy || !customAccent) { return; } var changed = false; try { @@ -11979,7 +15294,21 @@ function restoreCombinedHeatmapConfig() { var storedPrivacy = localStorage.getItem('ccu.combinedHeatmap.privacyPreview'); var storedPalette = localStorage.getItem('ccu.combinedHeatmap.palette'); var storedAccent = localStorage.getItem('ccu.combinedHeatmap.customAccent'); - if (storedTitle) { title.value = storedTitle; changed = storedTitle !== title.getAttribute('data-default-value'); } + var storedProvider = localStorage.getItem('ccu.combinedHeatmap.provider'); + var storedMetric = localStorage.getItem('ccu.combinedHeatmap.metric'); + var storedAmounts = localStorage.getItem('ccu.combinedHeatmap.showAmounts'); + var sourceAvailable = true; + if (provider && ['claude','codex','both'].includes(storedProvider)) { + var option = Array.from(provider.options).find(function(option) { return option.value === storedProvider; }); + sourceAvailable = !!option && !option.disabled; + if (sourceAvailable) { provider.value = storedProvider; changed = changed || storedProvider !== provider.dataset.defaultValue; } + } + if (metric && amounts) { + amounts.checked = sourceAvailable && storedMetric === 'apiEquivalent' && storedAmounts === 'true'; + metric.value = amounts.checked ? 'apiEquivalent' : 'tokens'; + changed = changed || amounts.checked; + } + if (storedTitle) { title.value = storedTitle; changed = changed || storedTitle !== title.getAttribute('data-default-value'); } if (storedRange === '30d' || storedRange === '90d' || storedRange === 'year') { range.value = storedRange; changed = changed || storedRange !== range.getAttribute('data-default-value'); @@ -12001,24 +15330,42 @@ function restoreCombinedHeatmapConfig() { } catch (e) {} toggleCombinedCustomAccent(); toggleCombinedHeatmapPrivacy(false); + combinedHeatmapUpdateDisclosure(); if (changed) { generateCombinedHeatmapPreview(); } + else { + var preview = document.getElementById('combinedHeatmapPreview'); + if (preview && preview.dataset.previewId) { preview.dataset.configKey = combinedHeatmapConfigKey(combinedHeatmapReadConfig()); } + combinedHeatmapSetExportState(); + } } -function generateCombinedHeatmapPreview() { +function generateCombinedHeatmapPreview(save) { var config = combinedHeatmapReadConfig(); - combinedHeatmapSaveConfig(config); + if (save !== false) { combinedHeatmapSaveConfig(config); } + combinedHeatmapRetire(true, true); + __apiHeatmapPendingConfigKey = combinedHeatmapConfigKey(config); + __apiHeatmapRequestId += 1; + var preview = document.getElementById('combinedHeatmapPreview'); + if (preview) { preview.setAttribute('aria-busy','true'); } var status = document.getElementById('combinedHeatmapStatus'); if (status) { status.textContent = __combinedHeatmapCopy.updatePreview + '…'; } - vscode.postMessage({ command: 'previewCombinedHeatmap', title: config.title, range: config.range, intensityMode: config.intensityMode, palette: config.palette, customAccent: config.customAccent }); + vscode.postMessage(Object.assign({command:'previewCombinedHeatmap',requestId:__apiHeatmapRequestId,documentId:__ccuDocumentId},config)); } function exportCombinedHeatmap() { var config = combinedHeatmapReadConfig(); + var preview = document.getElementById('combinedHeatmapPreview'); + if (!preview || !preview.dataset.previewId || __apiHeatmapExportPreviewId || + preview.dataset.configKey !== combinedHeatmapConfigKey(config)) { return; } combinedHeatmapSaveConfig(config); - vscode.postMessage({ command: 'exportCombinedHeatmap', title: config.title, range: config.range, intensityMode: config.intensityMode, palette: config.palette, customAccent: config.customAccent }); + __apiHeatmapExportPreviewId = preview.dataset.previewId; + combinedHeatmapSetExportState(); + vscode.postMessage(Object.assign({command:'exportCombinedHeatmap',previewId:preview.dataset.previewId,documentId:__ccuDocumentId},config)); } function copyCombinedHeatmapMarkdown() { var config = combinedHeatmapReadConfig(); + var preview = document.getElementById('combinedHeatmapPreview'); + if (!preview || !preview.dataset.previewId || preview.dataset.configKey !== combinedHeatmapConfigKey(config)) { return; } combinedHeatmapSaveConfig(config); - vscode.postMessage({ command: 'copyCombinedHeatmapMarkdown', title: config.title, range: config.range, intensityMode: config.intensityMode, palette: config.palette, customAccent: config.customAccent }); + vscode.postMessage(Object.assign({command:'copyCombinedHeatmapMarkdown',previewId:preview.dataset.previewId,documentId:__ccuDocumentId},config)); } function resetCombinedHeatmapPreferences() { __ccuDirectSharingResetPending = true; @@ -12043,6 +15390,7 @@ function getAdvice() { } function setSetting(key, value, type) { + if (key === 'showAchievementNotices' && value === false) { ccuDismissAchievementNotice(); } if (type === 'number') { var n = Number(value); if (!isFinite(n)) { return; } @@ -12087,13 +15435,17 @@ var __ccuUiPreferenceKeys = [ ]; var __ccuSharingPreferenceKeys = [ 'ccu.sharing.template', + 'ccu.sharing.achievementBadges', 'ccu.sharing.shareCardDraft', 'ccu.combinedHeatmap.title', 'ccu.combinedHeatmap.range', 'ccu.combinedHeatmap.intensityMode', 'ccu.combinedHeatmap.privacyPreview', 'ccu.combinedHeatmap.palette', - 'ccu.combinedHeatmap.customAccent' + 'ccu.combinedHeatmap.customAccent', + 'ccu.combinedHeatmap.provider', + 'ccu.combinedHeatmap.metric', + 'ccu.combinedHeatmap.showAmounts' ]; function ccuCountPresentStorageKeys(storage, keys) { var count = 0; @@ -12172,18 +15524,18 @@ function ccuApplyDefaultSharingControls() { var defaultPalette = document.querySelector('input[name="combinedHeatmapPalette"][value="academicViolet"]'); if (defaultPalette) { defaultPalette.checked = true; } if (customAccent) { customAccent.value = '#4f2f87'; } + var provider = document.getElementById('combinedHeatmapProvider'); + var metric = document.getElementById('combinedHeatmapMetric'); + var amounts = document.getElementById('combinedHeatmapShowAmounts'); + if (provider) { provider.value = provider.dataset.defaultValue; } + if (metric) { metric.value = 'tokens'; } + if (amounts) { amounts.checked = false; } selectDefaultSharingTemplate(); scApplyDefaultControls(); toggleCombinedCustomAccent(); toggleCombinedHeatmapPrivacy(false); - vscode.postMessage({ - command: 'previewCombinedHeatmap', - title: __combinedHeatmapCopy.defaultTitle, - range: 'year', - intensityMode: 'quantile', - palette: 'academicViolet', - customAccent: '#4f2f87' - }); + combinedHeatmapUpdateDisclosure(); + generateCombinedHeatmapPreview(false); } function ccuApplyLocalDataClientAction(action) { @@ -12765,8 +16117,15 @@ function restoreTableSorts(root) { }); } -function showTab(tabName, restoring) { +function showTab(tabName, restoring, preserveAchievementDetails) { try { + // Reselecting the current view is not a tab switch. Preserve disclosures + // and avoid repeating host tab/inventory work; restoration still reconciles. + var currentTab = document.getElementById('tab-' + tabName); + var currentContent = document.getElementById(tabName); + if (!restoring && currentTab && currentContent && + currentTab.getAttribute('aria-selected') === 'true' && + currentContent.classList.contains('active') && !currentContent.hidden) { return; } // Keep the visual state and the WAI-ARIA tab contract in lockstep. document.querySelectorAll('.tabs [role="tab"]').forEach(function(tab) { tab.classList.remove('active'); @@ -12790,9 +16149,13 @@ function showTab(tabName, restoring) { selectedContent.hidden = false; if (!restoring) { - vscode.postMessage({ command: 'tabChanged', tab: tabName }); + vscode.postMessage({ command: 'tabChanged', tab: tabName, documentId: __ccuDocumentId }); // Switching tabs resets expanded rows to their default (Carl's rule). - clearPersistedDetails(); + if (!preserveAchievementDetails) { + clearPersistedDetails(); + ccuWriteUiState('achievementStatisticsReturn', undefined); + restoreAchievementStatisticsReturn(); + } // Persist in localStorage too — survives the reload, restored before paint. try { localStorage.setItem('ccu.activeTab', tabName); } catch (e) {} if (tabName === 'settings') { requestLocalDataInventory(); } @@ -13400,10 +16763,105 @@ document.addEventListener('click', function(event) { } }); +var __achievementNoticeTimer = null; +function ccuDismissAchievementNotice() { + if (__achievementNoticeTimer) clearTimeout(__achievementNoticeTimer); + __achievementNoticeTimer = null; + var notice = document.getElementById('ccuAchievementUnlockNotice'); + if (notice) notice.remove(); +} +function ccuShowAchievementNotice(message) { + var preference = document.getElementById('set_showAchievementNotices'); + if (!__achievementNoticesEnabled || (preference && !preference.checked)) return; + if (document.hidden || !document.hasFocus()) return; + var previous = document.getElementById('ccuAchievementUnlockNotice'); + if (previous) previous.remove(); + if (__achievementNoticeTimer) clearTimeout(__achievementNoticeTimer); + if (typeof message.text !== 'string' || message.text.length > 240) return; + var root = document.createElement('div'); + root.id = 'ccuAchievementUnlockNotice'; + root.className = 'achievement-unlock-notice'; + root.setAttribute('role', 'status'); + root.setAttribute('aria-live', 'polite'); + if (typeof message.iconFile === 'string' && + /^[a-z0-9-]+\.png$/.test(message.iconFile)) { + var galleryIcon = document.querySelector('img[data-achievement-artwork="' + message.iconFile + '"]'); + if (galleryIcon) { + var icon = document.createElement('img'); + icon.src = galleryIcon.src; + icon.alt = ''; + root.appendChild(icon); + } + } + var label = document.createElement('span'); + label.textContent = message.text; + root.appendChild(label); + var close = document.createElement('button'); + close.type = 'button'; + close.setAttribute('aria-label', __achievementDismissLabel); + close.textContent = '×'; + close.addEventListener('click', ccuDismissAchievementNotice); + root.appendChild(close); + document.body.appendChild(root); + var hovering = false; + function scheduleDismiss() { + if (__achievementNoticeTimer) clearTimeout(__achievementNoticeTimer); + if (hovering || root.contains(document.activeElement)) return; + __achievementNoticeTimer = setTimeout(function() { root.remove(); }, 6000); + } + root.addEventListener('pointerenter', function() { hovering = true; scheduleDismiss(); }); + root.addEventListener('pointerleave', function() { hovering = false; scheduleDismiss(); }); + root.addEventListener('focusin', scheduleDismiss); + root.addEventListener('focusout', function(event) { + if (!root.contains(event.relatedTarget) && !hovering) { + if (__achievementNoticeTimer) clearTimeout(__achievementNoticeTimer); + __achievementNoticeTimer = setTimeout(function() { root.remove(); }, 6000); + } + }); + scheduleDismiss(); + if (typeof message.noticeId === 'string' && /^[a-f0-9]{48}$/.test(message.noticeId)) { + vscode.postMessage({ command: 'achievementNoticeAck', noticeId: message.noticeId }); + } +} +window.addEventListener('blur', ccuDismissAchievementNotice); +document.addEventListener('visibilitychange', function() { if (document.hidden) ccuDismissAchievementNotice(); }); + // Handle messages from extension window.addEventListener('message', async function(event) { const message = event.data; + if (message.command === 'achievementCalibrationState') { + if (message.documentId !== __ccuDocumentId || typeof message.pending !== 'boolean' || typeof message.failed !== 'boolean') { return; } + var calibrationButton = document.getElementById('achievementRecalibrate'); + var calibrationStatus = document.getElementById('achievementCalibrationStatus'); + if (calibrationButton) { + calibrationButton.disabled = message.pending; + if (message.pending) { calibrationButton.setAttribute('aria-busy', 'true'); } + else { calibrationButton.removeAttribute('aria-busy'); } + } + if (calibrationStatus) { calibrationStatus.textContent = typeof message.text === 'string' && message.text.length <= 1024 ? message.text : ''; } + return; + } + + if (message.command === 'weeklyQuotaPace') { + var pace = document.getElementById('weeklyQuotaPace'); + if (message.documentId === __ccuDocumentId && pace && + message.provider === pace.dataset.quotaPaceProvider && typeof message.html === 'string') { + pace.innerHTML = message.html; + pace.hidden = message.html.length === 0; + } + return; + } + + if (message.command === 'achievementUnlockNotice') { + ccuShowAchievementNotice(message); + return; + } + if (message.command === 'achievementNoticeDismiss') { + ccuDismissAchievementNotice(); + return; + } + if (message.command === 'advicePreviewsInvalidated') { adviceInvalidateAllPreviews(); return; @@ -13413,6 +16871,33 @@ window.addEventListener('message', async function(event) { ccuQueueDashboardDataPatch(message); return; } + if (message.command === 'achievementPinResult' && + (message.documentId === undefined || message.documentId === __ccuDocumentId) && + typeof message.key === 'string' && Array.from(document.querySelectorAll('[data-achievement-pin]')).some(function(button) { + return button.dataset.achievementPin === message.key; + })) { + setAchievementShowcaseBusy(false); + var pinStatus = document.getElementById('achievementPinStatus'); + if (pinStatus) { pinStatus.textContent = message.ok === true ? '' : message.reason === 'limit' ? __achievementCopy.pinLimit : + message.reason === 'old-writer' ? __achievementAppearanceCopy.oldWriterReload : __achievementCopy.pinFailed; } + return; + } + if (message.command === 'achievementShowcaseResult' && message.documentId === __ccuDocumentId) { + setAchievementShowcaseBusy(false); + var status = document.getElementById('achievementPinStatus'); + if (status) { status.textContent = message.ok === true ? '' : message.reason === 'old-writer' ? + __achievementAppearanceCopy.oldWriterReload : __achievementCopy.pinFailed; } + return; + } + + if (message.command === 'achievementSummaryResult' && message.ok === false && message.documentId === __ccuDocumentId) { + var readButton = document.getElementById('achievement-unread-read'); + var readStatus = document.getElementById('achievementUnreadStatus'); + if (readButton && readStatus && message.summaryId === readButton.dataset.achievementSummary) { + readStatus.textContent = __achievementCopy.readFailed; + } + return; + } if ( message.command === 'codexIndexProgress' && @@ -13725,31 +17210,113 @@ window.addEventListener('message', async function(event) { }); } + if (message.command === 'achievementShareResult') { + if (message.clientId !== __achievementShareClientId || message.requestId !== __achievementShareRequest || + JSON.stringify(__achievementShareRequestedIds) !== JSON.stringify(selectedAchievementShareIds())) { return; } + // A grant may become unavailable during an audit. Accept only a subset of + // the explicit selection, in its original order; never add a new badge. + var validIds = Array.isArray(message.ids) && message.ids.length <= 9 && + message.ids.every(function(id) { return typeof id === 'string'; }) && + JSON.stringify(__achievementShareRequestedIds.filter(function(id) { return message.ids.indexOf(id) >= 0; })) === JSON.stringify(message.ids); + if (validIds && message.error !== true) { + __achievementSelectionChanged = __achievementSelectionChanged || message.ids.length !== __achievementShareRequestedIds.length; + document.querySelectorAll('.achievement-share-choice').forEach(function(input) { + if (message.ids.indexOf(input.value) < 0) { input.checked = false; } + }); + __achievementShareRequestedIds = selectedAchievementShareIds(); + try { localStorage.setItem('ccu.sharing.achievementBadges', JSON.stringify(__achievementShareRequestedIds)); } catch (e) {} + } + var achievementPreview = document.getElementById('achievementSharePreview'); + var achievementStatus = document.getElementById('achievementShareStatus'); + var accepted = validIds && message.error !== true && message.ids.length > 0 && typeof message.svg === 'string' && message.svg.length > 0 && + typeof message.previewId === 'string' && message.previewId.length > 0 && message.previewId.length <= 64; + var failed = message.error === true || !validIds || (message.ids.length > 0 && !accepted); + if (achievementPreview) { + achievementPreview.setAttribute('aria-busy', 'false'); + achievementPreview.dataset.previewId = accepted ? message.previewId : ''; + if (accepted) { achievementPreview.innerHTML = message.svg; } + else { achievementPreview.textContent = failed ? __achievementCopy.shareFailed : + document.querySelector('.achievement-share-choice') ? __achievementCopy.shareSelect : __achievementCopy.shareEmpty; } + } + if (achievementStatus) { achievementStatus.textContent = failed ? __achievementCopy.shareFailed : + __achievementSelectionChanged ? __achievementCopy.shareSelectionChanged : ''; } + var achievementExport = document.getElementById('achievementShareExportBtn'); + if (achievementExport) { achievementExport.disabled = !accepted || !!__achievementExportPreviewId; } + } + + if (message.command === 'achievementShareExportResult' && message.previewId === __achievementExportPreviewId && __achievementExportPreviewId) { + __achievementExportPreviewId = ''; + var exportPreview = document.getElementById('achievementSharePreview'); + var exportButton = document.getElementById('achievementShareExportBtn'); + var exportStatus = document.getElementById('achievementShareStatus'); + if (exportPreview && exportPreview.dataset.previewId === message.previewId) { + if (message.stale === true) { exportPreview.dataset.previewId = ''; } + if (exportStatus) { exportStatus.textContent = message.ok === true ? __achievementCopy.shareSaved : + message.cancelled === true ? __achievementCopy.shareCancelled : message.stale === true ? + __achievementCopy.shareStale : __achievementCopy.shareExportFailed; } + } + if (exportButton) { exportButton.disabled = !exportPreview || !exportPreview.dataset.previewId || + exportPreview.getAttribute('aria-busy') === 'true'; } + } + + if (message.command === 'combinedHeatmapPreviewRetired' && message.documentId === __ccuDocumentId) { + combinedHeatmapRetire(false, true); + var retiredStatus = document.getElementById('combinedHeatmapStatus'); + if (retiredStatus) { retiredStatus.textContent = __apiHeatmapCopy.stale; } + } + if (message.command === 'combinedHeatmapResult') { + if (message.documentId !== __ccuDocumentId || message.requestId !== __apiHeatmapRequestId || + __apiHeatmapPendingConfigKey !== combinedHeatmapConfigKey(combinedHeatmapReadConfig())) { return; } var combinedPreview = document.getElementById('combinedHeatmapPreview'); var combinedMarkdown = document.getElementById('combinedHeatmapMarkdown'); var combinedStatus = document.getElementById('combinedHeatmapStatus'); - if (message.error) { - if (combinedStatus) { combinedStatus.textContent = String(message.error); } + var accepted = message.failed !== true && message.hasData === true && + typeof message.previewId === 'string' && /^[0-9a-f]{48}$/.test(message.previewId) && + typeof message.svg === 'string' && message.svg.length > 0 && message.svg.length <= 1048576; + if (combinedPreview) { + combinedPreview.dataset.previewId = accepted ? message.previewId : ''; + combinedPreview.dataset.configKey = accepted ? __apiHeatmapPendingConfigKey : ''; + combinedPreview.setAttribute('aria-busy','false'); + } + if (message.failed === true) { + if (combinedPreview) { combinedPreview.textContent = __apiHeatmapCopy.failed; } + if (combinedMarkdown) { combinedMarkdown.value = ''; } + if (combinedStatus) { combinedStatus.textContent = __apiHeatmapCopy.failed; } } else { if (combinedPreview) { - if (message.hasData) { + if (accepted) { combinedPreview.innerHTML = message.svg || ''; } else { combinedPreview.textContent = __combinedHeatmapCopy.noData; } } - if (combinedMarkdown) { combinedMarkdown.value = message.markdown || ''; } + if (combinedMarkdown) { combinedMarkdown.value = accepted && typeof message.markdown === 'string' ? message.markdown : ''; } if (combinedStatus) { combinedStatus.textContent = ''; } } + __apiHeatmapPendingConfigKey = ''; + combinedHeatmapSetExportState(); + } + + if (message.command === 'combinedHeatmapExportResult' && message.documentId === __ccuDocumentId && + message.previewId === __apiHeatmapExportPreviewId && __apiHeatmapExportPreviewId) { + __apiHeatmapExportPreviewId = ''; + var exportedPreview = document.getElementById('combinedHeatmapPreview'); + var exportedStatus = document.getElementById('combinedHeatmapStatus'); + if (exportedPreview && exportedPreview.dataset.previewId === message.previewId) { + if (message.stale === true) { combinedHeatmapRetire(false, true); } + if (exportedStatus) { exportedStatus.textContent = message.ok === true ? __apiHeatmapCopy.saved : + message.cancelled === true ? __apiHeatmapCopy.cancelled : message.stale === true ? __apiHeatmapCopy.stale : __apiHeatmapCopy.failed; } + } + combinedHeatmapSetExportState(); } - if (message.command === 'combinedHeatmapMarkdownCopied') { + if (message.command === 'combinedHeatmapMarkdownCopied' && message.documentId === __ccuDocumentId) { var copyStatus = document.getElementById('combinedHeatmapStatus'); if (copyStatus) { copyStatus.textContent = message.ok ? __combinedHeatmapCopy.copyMarkdown + ' ✓' - : String(message.error || 'Could not copy Markdown.'); + : message.stale === true ? __apiHeatmapCopy.stale : __apiHeatmapCopy.failed; } } @@ -14624,6 +18191,7 @@ function renderHourlyChart(hourlyData, metric) { } dispose(): void { + this.retireAchievementUsageObservations(); this.todayAttributionCache = undefined; if (this.panel) { this.panel.dispose(); diff --git a/tests/native/README.md b/tests/native/README.md new file mode 100644 index 00000000..dcb2f80e --- /dev/null +++ b/tests/native/README.md @@ -0,0 +1,149 @@ +# Optional guarded native diagnostic + +For actual background-window resource observation use mode: 'background' with +an explicit durationMs (for example120000). This stages a separate test-only +runner; it never calls a simulated focus handler, opens/reveals panels or +suppresses native window events. Watch settings are enabled, then forty explicit +manual warm refreshes cover whole-achievement OFF/ON. During observation, exact +native/logical focus must agree; settled background samples require every owned +recurring resource count to be known zero. Twenty idle observations, actual +duration, known-zero warm I/O and completed production teardown are all required. +Foreground samples are recorded, not misclassified as background. Memory uses +the existing fixed soak budgets. Separate profiles do not prove same-profile +writes, actual foreground interaction, notice ACK, thirty-minute combined soak, +final installed VSIX/F5 or Windows crash resolution. Prior simulated-focus soak +failures remain preserved, not relabelled passes by this different test. + +中文审阅:mode: 'background' 是独立的原生后台资源诊断,明确指定时长;不模拟聚焦、 +不打开/显示面板、不屏蔽真实窗口事件。开启监听设置,成就关闭/开启各执行20次明确 +手动暖刷新;观测期以真实/逻辑焦点一致为前提,稳定后台样本所属循环资源须全为已知0。 +至少20次空闲样本、实际时长、已知零正文读取和生产停止清理均通过才算本诊断通过。 +真实前台样本照实记录,不冒充后台。预算沿用既有上限;各自独立profile不代表同profile +写竞争,亦非前台交互/通知ACK、30分钟组合长测、最终VSIX/F5或Windows崩溃验收。 +既有模拟焦点soak失败继续保留,不能因新测试通过而改写其结果。 + +These helpers are excluded from the VSIX. They do not replace a production +VSIX installation, manual F5, pixel inspection, long-history soak or Windows +acceptance. Production src/ and compiled runtime modules are not modified. + +Compile normally. Call prepareNativeDiagnostic({ repository, sourceHead }) +from prepare.cjs with the absolute repository and its verified local commit. +Preparation creates a fresh ccu-native-v242-* temporary root, copies regular +compiled .js files and the actual assets/ directory, excludes out/test and source maps, and seeds +six bounded anonymous synthetic JSONL files. It never launches an app, reads +normal provider homes, fetches dependencies or installs into daily Code. + +The returned launchArguments explicitly isolate user, extensions, shared and +agent stores. The installed Code must support every option; check its local +CLI metadata before use. Start only a new instance with those exact arguments. +Use background launch to avoid unnecessary focus changes. In-memory +SecretStorage, no recent-folder entry, disabled crash reporting/experiments, +disabled telemetry/update/sync-related online entry points, and synthetic +provider paths complement (but do not replace) actual storage verification. +Do not set or repurpose HOME or CODEX_HOME. +On macOS preparation uses a fresh short /private/tmp root, keeping the observed +Code IPC socket below its 103-character path limit; no system temp setting changes. + +bootstrap.cjs is the staged test-only main. It validates real Test-mode +ExtensionContext/config paths and symlink-free owned directories before any +globalState seed. Only then does it load and activate the production module. +It captures that host's own instance at the existing command-setup boundary, +restores the method immediately, and gives the test runner access to that +instance. This wrapper is not shipped as a release candidate. + +runner.cjs uses the real native VS Code API, not a mocked webview. It exercises +the small synthetic cold scan, twenty explicit warm refreshes, twenty actual +WebviewPanel open/dispose cycles, achievement OFF/ON, twenty content-analysis +toggles with awaited old-provider retirement, and awaited production +disposal. Reports contain fixed stages, numeric process-memory samples and +anonymous resource counts, not credentials, logs, paths or instance objects. +Disposal waits for the real onDidDispose event, not a fixed paint delay. A +directly seeded synthetic single preview tests retirement, not earned-badge +selection, share consent, the preview UI or an export. Separate pure helper +tests use synthetic async events and the actual AchievementSharePreview class; +they are not native evidence. +A missing client-ready observation is not pixel acceptance. Process RSS includes +worker threads; samples are not instantaneous peaks or a post-GC leak verdict. +Warm I/O uses a fixed-size numeric-only probe of existing production diagnostic +formats: unknown is not treated as zero, at least twenty observations per provider +are required, and no raw channel text is retained. The content-analysis setting +path legitimately replaces providers; its I/O is separate from unchanged warm +refreshes and is not asserted to be a presentation-only zero-I/O operation. + +Before claiming non-disruption, inspect the normal instance's relevant storage +metadata/settings and installed-runtime hashes before/after. A separate window, +directory naming or this guard is not an OS sandbox. Changes during active +normal use may be natural; investigate rather than asserting all bytes stayed +unchanged. Do not close or restart daily Code, change its extension/profile, +stop unrelated processes, or weaken a failed isolation check. + +## Optional resource-duration diagnostic / 资源持续运行诊断 + +prepareNativeDiagnostic({ repository, sourceHead, mode: 'soak', durationMs: 1800000 }) +stages a separate opt-in runner. Use a short duration first to verify setup. +A short preflight always reports longSoakDurationPassed=false; setup/exit alone +is never a 30-minute pass. The monotonic clock is not scaled. +After the two single watcher-setting writes, setup samples only public expected +readbacks every 25 ms until they match for 500 ms. This consumes the existing +five-second readiness deadline; it never rewrites settings or retries a case. +The original settings/configuration/watcher assertions still run afterward, +and any later mismatch fails. Only equality/type fields and bounded convergence +counts enter the report. This test-only SDK initialization barrier does not prove +normal-profile persistence or replace installed/F5 acceptance; earlier failures remain failures. +An anonymous stage marker is written before each asynchronous phase. A normal +host exit with no final native-soak-result.json remains incomplete; its last +marker does not identify a product crash or satisfy any resource-duration gate. +Failures additionally record only exact logical/native focus booleans and a +known watcher count, or null when unavailable. Real window transitions are +counted, never suppressed to make simultaneous background windows appear active. +Failure reasons distinguish only helper-owned guards and fixed readiness assertions: +resource/sample limits, operation/wait timeout and the Claude/Codex watcher setup. +Classification uses private error-object identity, not exception messages/codes/getters; +unrecognised errors stay assertion-or-stage-failure. Setting values are not reported. +This additional diagnostic does not relax budgets, suppress native events or establish +the cause of a previously incomplete run. + +Each minute the runner switches the whole achievement feature ON/OFF, appends +one bounded synthetic record to each of two exact regular fixture files, waits +for actual Claude/Codex fs.watch refresh diagnostics and exact usage changes, +then performs twenty known-zero manual warm refreshes. Actual panels open and +await their disposal events. Watched append checks finish before panel creation; +the panel remains open during the warm refreshes. Native window events remain +observed and unsuppressed. Simulated focus invokes the existing production +focus handler to exercise suspension/rearming without foregrounding Code. +It is not proof of native OS-focus behavior or notice delivery/ACK. + +Five-second fixed numeric samples bound host RSS/heap/external memory, owned +timers/watchers/worker-controller/backfill/network counts and panel/notice caches. +No raw messages, logs, paths, settings values or heap dumps enter the report. +The small fixture grows by at most two records per cycle; sources are not real +logs. Unknown measurements do not become zero. Deadlines and resource guards +fail the run, and the finally block awaits only this host's production teardown. +Reports preserve limitations: no final VSIX/F5/pixel acceptance, worker-isolate +heap/message measurements, same-profile multi-window races, or Windows OOM +conclusion. Sampled host heap without forced GC is not a retained-heap leak test. + +中文审阅:使用 mode: 'soak' 明确启用持续诊断,先短程自检;短测必然标为未通过 +30分钟时长门,不能以准备或进程退出冒充验收,不加速单调时钟。 +监听设置各只写一次;随后每25毫秒只读公开预期值,匹配满500毫秒才继续。 +稳定检查占用原有五秒就绪期限,不反复覆盖设置或重试用例;之后仍执行原来的 +设置/配置/监听严格断言,晚到不匹配仍失败。报告只含相等/类型及有界计数。 +这只是测试初始化屏障,不证明日常配置持久化或已安装包/F5;此前失败不改记为通过。 +各异步阶段开始前记录有界匿名标记;即使宿主正常退出,没有最终结果文件也仍是 +未完成,不能凭最后阶段标记认定产品崩溃或算持续运行门通过。 +失败时仅补充逻辑/原生焦点布尔值及已知监听数量,未知记null;计数真实窗口 +状态变化,不屏蔽它们来让多个后台窗口看似同时活跃。 +失败原因仅细分辅助程序自身的资源/采样上限、操作/等待超时和固定监听初始化 +断言;按错误对象的私有身份分类,不读取任意异常消息、code、getter等属性。 +未知仍记assertion-or-stage-failure,不保存设置值,不放宽预算或屏蔽真实事件, +新增诊断也不能倒推此前未完成运行的根因。 +每分钟开关整个成就功能,向两个精确的自有常规合成文件各追加一条有界记录,等待真实文件监听 +诊断与精确用量更新,再执行20次有明确零正文计数的手动暖刷新,并等待真实面板 +销毁事件。追加监听检查先于面板创建;暖刷新期间仍有真实面板,不屏蔽原生窗口事件。 +模拟焦点只调用既有生产处理器、检验暂停/重新安装,不切到前台,也不 +当作OS焦点或通知投递/ACK验收。每5秒保存固定匿名数字,限制宿主内存、所属 +定时器/监听/worker协调器/回填/网络和面板/提示缓存;报告不含原始消息、日志、 +路径、设置值或heap dump。每轮最多增加两条合成记录,不使用真实日志;未知不 +算零。超时或预算超限即失败,最终只等待本测试宿主的实际资源释放。这个小样本 +不能替代最终VSIX/F5/像素、worker独立内存/消息、同profile跨窗口竞争或Windows +OOM验收;未强制GC的宿主heap采样也不能证明存活对象泄漏。 diff --git a/tests/native/background.cjs b/tests/native/background.cjs new file mode 100644 index 00000000..d15e1785 --- /dev/null +++ b/tests/native/background.cjs @@ -0,0 +1,144 @@ +'use strict'; + +// Test-only actual background-window resource observation. Never calls the +// production focus handler, reveals panels, or suppresses native focus events. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { performance } = require('node:perf_hooks'); +const kinds = ['timer', 'watcher', 'worker', 'network', 'backfill']; +const count = n => Number.isSafeInteger(n) && n >= 0; +const failures = new WeakMap(); +function fail(reason) { + const error = new Error('native-background:' + reason); + failures.set(error, reason); + throw error; +} +function assertBackgroundIdle(nativeFocused, logicalFocused, sample) { + if (typeof nativeFocused !== 'boolean' || typeof logicalFocused !== 'boolean' || + nativeFocused !== logicalFocused) fail('focus'); + if (nativeFocused) return false; + if (sample.ownedActive !== 0 || kinds.some(k => sample.ownedByKind[k] !== 0)) fail('not-idle'); + return true; +} +function assertBackgroundComplete(report) { + if (!count(report.elapsedMs) || !count(report.requestedMs) || report.requestedMs < 100 || + report.elapsedMs < report.requestedMs || !count(report.idleSamples) || report.idleSamples < 20 || + report.stopCompleted !== true) fail('incomplete'); + const { assertZeroBodyWarmRefreshes } = require(path.basename(__filename) === 'native-background.cjs' + ? './native-runner.cjs' : './runner.cjs'); + for (const mode of ['on', 'off']) assertZeroBodyWarmRefreshes(report.warmIo[mode], 20); +} +async function run(plan, rawOptions) { + const vscode = require('vscode'); + const staged = path.basename(__filename) === 'native-background.cjs'; + const { validateSoakOptions, bounded, sampleOwnedResources, assertResourceBudget, appendBoundedSample } = + require(staged ? './native-soak.cjs' : './soak.cjs'); + const { settle, createRefreshIoProbe } = require(staged ? './native-runner.cjs' : './runner.cjs'); + const { nativeLaunchArguments } = require(staged ? './native-isolation.cjs' : './isolation.cjs'); + const options = validateSoakOptions(rawOptions); + nativeLaunchArguments(plan); + const manifest = JSON.parse(await fs.readFile(path.join(plan.root, 'native-stage-manifest.json'), 'utf8')); + const started = performance.now(), samples = [], warmIo = {}; + let api, outputAppend, observer, stage = 'activation', failureStage = null, failureReason = null; + let stopCompleted = false, idleSamples = 0, nativeWindowTransitions = 0, completedMs = 0; + const elapsed = () => Math.floor(performance.now() - started); + const deadline = () => { if (elapsed() > options.durationMs + 180000) fail('deadline'); }; + const mark = async next => { + stage = next; + await fs.writeFile(path.join(plan.root, 'native-background-progress.json'), + JSON.stringify({ stage, elapsedMs: elapsed(), idleSamples, nativeWindowTransitions }) + '\n'); + }; + const sample = () => { + deadline(); + const instance = api.instance, view = instance.webviewProvider; + const value = sampleOwnedResources(process.memoryUsage(), instance.resourceOwnership.snapshotForTests(), + elapsed(), instance.getConfiguration().enableAchievements, !!view.panel, + view.dataPanelCache.size, view.pendingAchievementNotice.size); + assertResourceBudget(value); + const nativeFocused = vscode.window.state.focused, logicalFocused = instance.windowActivity.focused; + if (assertBackgroundIdle(nativeFocused, logicalFocused, value)) idleSamples++; + appendBoundedSample(samples, { ...value, nativeFocused, logicalFocused }); + }; + try { + await mark('activation'); + const extension = vscode.extensions.getExtension('GrowthJack.claude-code-usage'); + assert.ok(extension); + api = await bounded(extension.activate()); + assert.equal(api.plan.root, plan.root); + assert.ok(Object.values(api.isolation).every(v => v === true)); + const instance = api.instance; + assert.equal(instance.getConfiguration().usageLimitTracking, false); + assert.equal(instance.settings.get('advice.apiKey'), ''); + observer = vscode.window.onDidChangeWindowState(state => { + if (typeof state.focused === 'boolean') nativeWindowTransitions++; + }); + await mark('cold-refresh'); + await bounded(instance.refreshData(false, 'manual')); await bounded(settle(instance), 65000); + await mark('enable-watch-settings'); + await instance.settings.set('fileWatchSeconds', '1'); + await instance.settings.set('codex.fileWatchSeconds', '10'); + instance.onSettingsChangedFromPanel('fileWatchSeconds'); + await bounded(settle(instance), 65000); + for (const enabled of [false, true]) { + const mode = enabled ? 'on' : 'off'; + await mark('warm-' + mode); + const provider = instance.codexProvider; + await instance.settings.set('enableAchievements', enabled); + instance.onSettingsChangedFromPanel('enableAchievements'); + assert.equal(instance.codexProvider, provider); + const probe = createRefreshIoProbe(); + outputAppend = instance.outputChannel.appendLine; + instance.outputChannel.appendLine = line => { + probe.observe(line); outputAppend.call(instance.outputChannel, line); + }; + for (let i = 0; i < 20; i++) { + deadline(); + await bounded(instance.refreshData(false, 'manual')); await bounded(settle(instance), 65000); + } + instance.outputChannel.appendLine = outputAppend; outputAppend = undefined; + warmIo[mode] = probe.snapshot(); + } + await mark('actual-background-resources'); + const monitorStarted = performance.now(); + while (performance.now() - monitorStarted < options.durationMs) { + await bounded(settle(instance), 65000); sample(); + await new Promise(resolve => setTimeout(resolve, + Math.max(1, Math.min(2000, options.durationMs - (performance.now() - monitorStarted))))); + } + completedMs = Math.floor(performance.now() - monitorStarted); + sample(); + } catch (error) { + failureStage = stage; + failureReason = error && typeof error === 'object' ? failures.get(error) ?? 'assertion-or-stage-failure' + : 'assertion-or-stage-failure'; + } finally { + observer?.dispose(); + if (api) { + if (outputAppend) api.instance.outputChannel.appendLine = outputAppend; + try { + await bounded(api.stop()); + assert.equal(api.instance.resourceOwnership.snapshotForTests().activeCount, 0); + assert.equal(api.instance.webviewProvider.panel, undefined); + stopCompleted = true; + } catch { failureStage ??= 'dispose'; failureReason ??= 'stop-failure'; } + } + if (!failureStage) { + try { assertBackgroundComplete({ elapsedMs: completedMs, requestedMs: options.durationMs, + idleSamples, stopCompleted, warmIo }); } + catch { failureStage = 'acceptance'; failureReason = 'incomplete'; } + } + const report = { kind: 'actual-native-background-resource-observation-not-final-vsix', + sourceHead: manifest.sourceHead, vscodeVersion: vscode.version, platform: process.platform, + outcome: failureStage ? 'fail' : 'pass', failureStage, failureReason, + elapsedMs: elapsed(), completedMs, requestedMs: options.durationMs, idleSamples, + nativeWindowTransitions, samples, warmIo, stopCompleted, + simulatedFocusUsed: false, nativeEventsSuppressed: false, panelsOpened: 0, + longSoakDurationPassed: false, nativeOsFocusAcceptancePassed: false, + noticeDeliveryOrAckTested: false, sameProfileConcurrentWriteTested: false, + finalInstallF5Passed: false, heapLeakConclusionEstablished: false }; + await fs.writeFile(path.join(plan.root, 'native-background-result.json'), JSON.stringify(report, null, 2) + '\n', { flag: 'wx' }); + } + if (failureStage) fail('failed-' + failureStage); +} +module.exports = { run, assertBackgroundIdle, assertBackgroundComplete }; diff --git a/tests/native/bootstrap.cjs b/tests/native/bootstrap.cjs new file mode 100644 index 00000000..8c2c46bd --- /dev/null +++ b/tests/native/bootstrap.cjs @@ -0,0 +1,34 @@ +'use strict'; + +// Test-only development main. The production module loads only after actual +// ExtensionContext and configured synthetic sources pass the isolation guard. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const vscode = require('vscode'); +const { guardAndSeedNativeActivation } = require('./native-isolation.cjs'); +let production; +async function activate(context) { + const plan = JSON.parse(await fs.readFile(path.join(__dirname, 'native-plan.json'), 'utf8')); + const config = vscode.workspace.getConfiguration('claudeCodeUsage'); + const actual = { + extensionMode: context.extensionMode, extensionPath: context.extensionPath, + // Derive from the real context, not a test environment-variable assertion. + userDataPath: path.resolve(context.globalStorageUri.fsPath, '../../..'), + globalStoragePath: context.globalStorageUri.fsPath, workspaceStoragePath: context.storageUri?.fsPath, + claudeDataDirectory: config.get('dataDirectory'), codexDataDirectory: config.get('codex.dataDirectory'), + }; + await guardAndSeedNativeActivation(plan, actual, context.globalState); + production = require('./out/extension.js'); + const prototype = production.ClaudeCodeUsageExtension?.prototype, original = prototype?.setupCommands; + if (typeof original !== 'function') throw new Error('native-test-bootstrap:unsupported-capture'); + let instance; + prototype.setupCommands = function (...args) { instance = this; return original.apply(this, args); }; + try { await production.activate(context); } + finally { prototype.setupCommands = original; } + if (!instance) throw new Error('native-test-bootstrap:missing-instance'); + return Object.freeze({ plan, context, instance, stop: deactivate, + isolation: Object.freeze({ testModeVerified: true, actualStatePathsVerified: true, + syntheticHomesVerified: true, ownSafetySeedCompleted: true }) }); +} +async function deactivate() { await production?.deactivate(); } +module.exports = { activate, deactivate }; diff --git a/tests/native/isolation.cjs b/tests/native/isolation.cjs new file mode 100644 index 00000000..2d109b70 --- /dev/null +++ b/tests/native/isolation.cjs @@ -0,0 +1,105 @@ +'use strict'; + +// Opt-in native-test safety guard, not production extension code. +// No launch, global profile discovery, credential read or network in this module. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const directories = Object.freeze({ + userData: 'user-data', extensions: 'extensions', sharedData: 'shared-data', + agentPlugins: 'agent-plugins', agentsUserData: 'agents-user-data', + agentsExtensions: 'agents-extensions', workspace: 'workspace', + developmentExtension: 'development-extension', claudeHome: 'claude-home', + codexHome: 'codex-home', +}); +function fail(reason) { throw new Error('native-test-isolation:' + reason); } +function buildNativeIsolationPlan(root) { + if (typeof root !== 'string' || !path.isAbsolute(root) || + path.resolve(root) !== root || !/^ccu-native-v242-[a-zA-Z0-9-]{6,64}$/.test(path.basename(root))) { + fail('invalid-root'); + } + return Object.freeze({ version: 1, root, directories: Object.freeze( + Object.fromEntries(Object.entries(directories).map(([key, name]) => [key, path.join(root, name)])), + ) }); +} +function validate(plan) { + let expected; + try { expected = buildNativeIsolationPlan(plan?.root); } catch { fail('invalid-plan'); } + if (plan.version !== 1 || Object.keys(plan).sort().join('|') !== 'directories|root|version' || + !plan.directories || Object.keys(plan.directories).sort().join('|') !== Object.keys(directories).sort().join('|') || + Object.keys(directories).some(key => plan.directories[key] !== expected.directories[key])) { + fail('invalid-plan'); + } + return expected; +} +function nativeLaunchArguments(plan) { + const p = validate(plan).directories; + return ['--new-window', '--disable-extensions', '--disable-updates', '--disable-telemetry', + '--use-inmemory-secretstorage', '--skip-add-to-recently-opened', + '--disable-crash-reporter', '--disable-experiments', + '--sync', 'off', + '--disable-workspace-trust', '--skip-release-notes', '--skip-welcome', + '--user-data-dir', p.userData, '--extensions-dir', p.extensions, + '--shared-data-dir', p.sharedData, '--agent-plugins-dir', p.agentPlugins, + '--agents-user-data-dir', p.agentsUserData, '--agents-extensions-dir', p.agentsExtensions, + '--extensionDevelopmentPath', p.developmentExtension, + '--extensionTestsPath', path.join(p.developmentExtension, 'native-runner.cjs'), p.workspace]; +} +function within(directory, candidate) { + return typeof candidate === 'string' && path.isAbsolute(candidate) && + path.resolve(candidate) === candidate && (candidate === directory || candidate.startsWith(directory + path.sep)); +} +async function noSymlinks(root, candidate) { + if (!within(root, candidate)) fail('invalid-context'); + const suffix = path.relative(root, candidate).split(path.sep).filter(Boolean); + let current = root; + for (const component of ['', ...suffix]) { + if (component) current = path.join(current, component); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) fail('symlink-path'); + if (!stat.isDirectory()) fail('invalid-context'); + } catch (error) { + if (error.code === 'ENOENT') break; // Host-managed child storage may not exist yet. + throw error; + } + } +} +async function assertNativeActivationIsolation(plan, actual) { + const p = validate(plan).directories; + if (actual?.extensionMode !== 3) fail('non-test-mode'); + if (actual.userDataPath !== p.userData || actual.extensionPath !== p.developmentExtension || + actual.claudeDataDirectory !== p.claudeHome || actual.codexDataDirectory !== p.codexHome || + !within(path.join(p.userData, 'User/globalStorage'), actual.globalStoragePath) || + !within(path.join(p.userData, 'User/workspaceStorage'), actual.workspaceStoragePath)) { + fail('invalid-context'); + } + await noSymlinks(plan.root, plan.root); + for (const directory of Object.values(p)) { + await noSymlinks(plan.root, directory); + if (!(await fs.lstat(directory)).isDirectory()) fail('invalid-context'); + } + for (const key of ['globalStoragePath', 'workspaceStoragePath']) { + await noSymlinks(plan.root, actual[key]); + } +} +const safeSeed = Object.freeze({ + 'ccu.setting.usageLimitTracking': false, + 'ccu.setting.releaseAnnouncements': false, + 'ccu.setting.advice.effectiveness.enabled': false, + 'ccu.setting.advice.optimizer.enabled': false, + 'ccu.setting.fileWatchSeconds': '0', + 'ccu.setting.codex.fileWatchSeconds': '0', + 'ccu.setting.refreshInterval': 60, + 'ccu.setting.dashboardAutoRefresh': true, + 'ccu.setting.enableContentAnalysis': true, + 'ccu.setting.enableAchievements': true, + 'ccu.setting.codex.enabled': true, + 'ccu.setting.timezone': 'UTC', +}); +async function guardAndSeedNativeActivation(plan, actual, globalState) { + await assertNativeActivationIsolation(plan, actual); + if (typeof globalState?.update !== 'function') fail('invalid-context'); + for (const [key, value] of Object.entries(safeSeed)) await globalState.update(key, value); +} +module.exports = { buildNativeIsolationPlan, nativeLaunchArguments, + assertNativeActivationIsolation, guardAndSeedNativeActivation }; diff --git a/tests/native/prepare.cjs b/tests/native/prepare.cjs new file mode 100644 index 00000000..81f737ad --- /dev/null +++ b/tests/native/prepare.cjs @@ -0,0 +1,140 @@ +'use strict'; + +// Opt-in staging only. Does not launch Code, install a VSIX, inspect normal +// profiles, set HOME/CODEX_HOME, fetch dependencies or copy real provider logs. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { createHash } = require('node:crypto'); +const { buildNativeIsolationPlan, nativeLaunchArguments } = require('./isolation.cjs'); +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +function fail(reason) { throw new Error('native-test-preparation:' + reason); } +async function regularBytes(file) { + if (!(await fs.lstat(file)).isFile()) fail('nonregular-source'); + return fs.readFile(file); +} +async function copyTree(source, destination, prefix, filter, inventory, budget) { + if (!(await fs.lstat(source)).isDirectory()) fail('nonregular-source'); + await fs.mkdir(destination, { recursive: true }); + for (const entry of (await fs.readdir(source, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + const relative = prefix ? prefix + '/' + entry.name : entry.name; + if (!filter(relative, entry.isDirectory())) continue; + if (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) fail('nonregular-source'); + const from = path.join(source, entry.name), to = path.join(destination, entry.name); + if (entry.isDirectory()) await copyTree(from, to, relative, filter, inventory, budget); + else { + const bytes = await regularBytes(from); + budget.files++; budget.bytes += bytes.length; + if (budget.files > 2000 || budget.bytes > 256 * 1024 ** 2) fail('stage-budget'); + await fs.writeFile(to, bytes, { flag: 'wx' }); + inventory.push({ filename: relative, bytes: bytes.length, sha256: sha(bytes) }); + } + } +} +async function seedFixtures(plan) { + const p = plan.directories, hash = createHash('sha256'); + const day = new Date().toISOString().slice(0, 10), start = Date.parse(day + 'T00:00:00.000Z'); + let bytes = 0, files = 0; + await fs.mkdir(path.join(p.claudeHome, 'projects', 'synthetic-native'), { recursive: true }); + await fs.mkdir(path.join(p.codexHome, 'sessions'), { recursive: true }); + for (let file = 0; file < 3; file++) { + const id = 'synthetic-native-' + file; + const claude = [], codex = [ + { timestamp: new Date(start).toISOString(), type: 'session_meta', payload: { id, cwd: p.workspace } }, + { timestamp: new Date(start).toISOString(), type: 'turn_context', + payload: { model: ['gpt-6-astra', 'gpt-6.1-sol', 'gpt-6-luna'][file], effort: 'max' } }, + ]; + for (let i = 1; i <= 200; i++) { + const timestamp = new Date(start + i * 1000).toISOString(); + claude.push({ type: 'assistant', timestamp, cwd: p.workspace, sessionId: id, + requestId: id + '-request-' + i, message: { id: id + '-message-' + i, role: 'assistant', + model: 'claude-opus-5-5', content: [{ type: 'text', text: 'Synthetic native output.' }], + usage: { input_tokens: 1000, output_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 500 } } }); + codex.push({ type: 'event_msg', timestamp, payload: { type: 'token_count', info: { + total_token_usage: { input_tokens: i * 1000, cached_input_tokens: i * 500, + output_tokens: i * 100, reasoning_output_tokens: i * 25, total_tokens: i * 1100 } } } }); + } + for (const [target, rows] of [ + [path.join(p.claudeHome, 'projects/synthetic-native', id + '.jsonl'), claude], + [path.join(p.codexHome, 'sessions', 'rollout-' + file + '.jsonl'), codex], + ]) { + const body = rows.map(row => JSON.stringify(row)).join('\n') + '\n'; + const data = Buffer.from(body); + bytes += data.length; files++; hash.update(data); + await fs.writeFile(target, data, { flag: 'wx' }); + } + } + return { files, bytes, sha256: hash.digest('hex'), clockDayUtc: day, + meaning: 'six small anonymous synthetic logs, not a large-history or user-crash reproduction' }; +} +async function prepareNativeDiagnostic({ repository, sourceHead, mode = 'diagnostic', durationMs = 1800000 } = {}) { + if (!['diagnostic', 'soak', 'background'].includes(mode)) fail('invalid-input'); + const soak = mode !== 'diagnostic' ? require('./soak.cjs').validateSoakOptions({ durationMs }) : undefined; + if (typeof repository !== 'string' || !path.isAbsolute(repository) || path.resolve(repository) !== repository || + typeof sourceHead !== 'string' || !/^[a-f0-9]{7,40}$/.test(sourceHead)) fail('invalid-input'); + const pkg = JSON.parse((await regularBytes(path.join(repository, 'package.json'))).toString('utf8')); + if (pkg.name !== 'claude-code-usage' || pkg.publisher !== 'GrowthJack' || typeof pkg.version !== 'string' || + pkg.main !== './out/extension.js') fail('invalid-input'); + await regularBytes(path.join(repository, 'out/extension.js')); + for (const file of ['isolation.cjs', 'bootstrap.cjs', 'runner.cjs']) { + await regularBytes(path.join(repository, 'tests/native', file)); + } + // macOS's per-user temp path can exceed the observed 103-character IPC + // socket limit once Code adds user-data/1.14-main.sock. Use its canonical + // short temp directory, without altering HOME or the ordinary app profile. + const temporaryParent = process.platform === 'darwin' ? '/private/tmp' : os.tmpdir(); + const root = await fs.realpath(await fs.mkdtemp(path.join(temporaryParent, 'ccu-native-v242-'))); + const plan = buildNativeIsolationPlan(root), p = plan.directories; + try { + for (const directory of Object.values(p)) await fs.mkdir(directory); + const stage = p.developmentExtension, compiledFiles = [], assetFiles = [], budget = { files: 0, bytes: 0 }; + await copyTree(path.join(repository, 'out'), path.join(stage, 'out'), '', + (relative, directory) => relative !== 'test' && !relative.startsWith('test/') && (directory || relative.endsWith('.js')), + compiledFiles, budget); + await copyTree(path.join(repository, 'assets'), path.join(stage, 'assets'), '', () => true, assetFiles, budget); + for (const [source, target] of [['isolation.cjs', 'native-isolation.cjs'], + ['bootstrap.cjs', 'native-bootstrap.cjs'], ['runner.cjs', 'native-runner.cjs']]) { + await fs.writeFile(path.join(stage, target), await regularBytes(path.join(repository, 'tests/native', source)), { flag: 'wx' }); + } + if (soak) { + await fs.writeFile(path.join(stage, 'native-soak.cjs'), await regularBytes(path.join(repository, 'tests/native/soak.cjs')), { flag: 'wx' }); + const optionsName = mode === 'background' ? 'native-background-options.json' : 'native-soak-options.json'; + await fs.writeFile(path.join(stage, optionsName), JSON.stringify(soak) + '\n', { flag: 'wx' }); + if (mode === 'background') { + await fs.writeFile(path.join(stage, 'native-background.cjs'), + await regularBytes(path.join(repository, 'tests/native/background.cjs')), { flag: 'wx' }); + } + } + const stagedPackage = { ...pkg, displayName: 'CCU 2.4.2 Isolated Native Diagnostic', + main: './native-bootstrap.cjs', activationEvents: ['onStartupFinished'] }; + if (pkg.icon) { + if (pkg.icon !== 'icon.png') fail('invalid-input'); + await fs.writeFile(path.join(stage, 'icon.png'), await regularBytes(path.join(repository, 'icon.png')), { flag: 'wx' }); + } + const settings = { + 'claudeCodeUsage.dataDirectory': p.claudeHome, 'claudeCodeUsage.codex.dataDirectory': p.codexHome, + 'claudeCodeUsage.language': 'en', 'extensions.autoUpdate': false, 'extensions.autoCheckUpdates': false, + 'update.mode': 'none', 'telemetry.telemetryLevel': 'off', 'workbench.enableExperiments': false, + 'security.workspace.trust.enabled': false, 'workbench.startupEditor': 'none', + 'window.title': 'CCU 2.4.2 Native Diagnostic — isolated', 'git.enabled': false, + 'chat.disableAIFeatures': true, 'workbench.settings.enableNaturalLanguageSearch': false, + }; + await fs.mkdir(path.join(p.userData, 'User')); + const fixture = await seedFixtures(plan), launchArguments = nativeLaunchArguments(plan); + const manifest = { kind: 'isolated-native-diagnostic-not-final-vsix', sourceHead, nativeTestMode: mode, + packageVersion: pkg.version, nativeExecuted: false, finalInstallF5Passed: false, + compiledFiles, assetFiles, fixture, stagedAt: new Date().toISOString() }; + for (const [target, value] of [ + [path.join(stage, 'package.json'), stagedPackage], [path.join(stage, 'native-plan.json'), plan], + [path.join(p.userData, 'User/settings.json'), settings], [path.join(root, 'launch-plan.json'), { plan, launchArguments }], + [path.join(root, 'native-stage-manifest.json'), manifest], + ]) await fs.writeFile(target, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' }); + return Object.freeze({ plan, launchArguments, manifest }); + } catch (error) { + // Only this function's fresh synthetic mkdtemp is removed on preparation + // failure; never a caller-specified workspace/profile. + await fs.rm(root, { recursive: true, force: true }); + throw error; + } +} +module.exports = { prepareNativeDiagnostic }; diff --git a/tests/native/runner.cjs b/tests/native/runner.cjs new file mode 100644 index 00000000..93748663 --- /dev/null +++ b/tests/native/runner.cjs @@ -0,0 +1,219 @@ +'use strict'; + +// Real installed Code Extension Development/Test Host diagnostic. No mocks, +// credentials, ordinary profiles or final-VSIX/visual/F5 claims. The small +// staging wrapper captures only this host's real production instance. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function bounded(work, milliseconds, reason) { + let timer; + try { return await Promise.race([work, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('native-test-runner:' + reason)), milliseconds); + })]); } finally { clearTimeout(timer); } +} +async function disposePanelAndWait(view, milliseconds = 10_000) { + const panel = view.panel; + assert.equal(typeof panel?.onDidDispose, 'function', 'real panel disposal event available'); + let subscription; + const disposed = new Promise(resolve => { subscription = panel.onDidDispose(resolve); }); + try { + panel.dispose(); + await bounded(disposed, milliseconds, 'panel-dispose-timeout'); + assert.equal(view.panel, undefined); + } finally { subscription?.dispose(); } +} +function assertNativePanelCachesCleared(view) { + assert.equal(view.dataPanelCache.size, 0); + assert.equal(view.pendingAchievementNotice.size, 0); + // AchievementSharePreview owns one optional current artifact, not a Map. + // Inspect only its known test-observable empty state, never serialize bytes. + assert.equal(typeof view.acceptedAchievementPreview?.read, 'function'); + assert.equal(view.acceptedAchievementPreview.current, undefined); +} +function createRefreshIoProbe() { + const counts = { + claude: { records: 0, bodyReads: 0, unknownBodyReads: 0, bytesRead: 0, unknownBytesRead: 0 }, + codex: { records: 0, bodyReads: 0, unknownBodyReads: 0 }, + }; + const add = (summary, key, unknownKey, raw) => { + const value = raw === undefined || raw === 'unknown' ? NaN : Number(raw); + if (!Number.isSafeInteger(value) || value < 0 || !Number.isSafeInteger(summary[key] + value)) { + summary[unknownKey]++; + } else summary[key] += value; + }; + return Object.freeze({ + observe(line) { + // Never retain raw output, paths, labels, errors or arbitrary objects. + if (typeof line !== 'string' || line.length > 4096) return; + if (/^refresh: trigger=(startup|poll|credentials|watch|focus|workspace|settings|pricing|manual) /.test(line)) { + counts.claude.records++; + add(counts.claude, 'bodyReads', 'unknownBodyReads', /incremental\(bodies=(unknown|[0-9]+) /.exec(line)?.[1]); + add(counts.claude, 'bytesRead', 'unknownBytesRead', /io\(bytes=(unknown|[0-9]+) /.exec(line)?.[1]); + } else if (/^codex-index trigger=(startup|poll|credentials|watch|focus|workspace|settings|pricing|manual) /.test(line)) { + counts.codex.records++; + add(counts.codex, 'bodyReads', 'unknownBodyReads', / bodyReads=(unknown|[0-9]+) /.exec(line)?.[1]); + } + }, + snapshot: () => ({ claude: { ...counts.claude }, codex: { ...counts.codex } }), + }); +} +function assertZeroBodyWarmRefreshes(io, minimum) { + for (const provider of ['claude', 'codex']) { + assert.ok(io[provider].records >= minimum, 'observed warm refreshes'); + assert.equal(io[provider].unknownBodyReads, 0, 'known warm I/O'); + assert.equal(io[provider].bodyReads, 0, 'zero warm body reads'); + } + assert.equal(io.claude.unknownBytesRead, 0, 'known warm I/O'); + assert.equal(io.claude.bytesRead, 0, 'zero warm body bytes'); +} +async function settle(instance) { + await bounded(instance.initializationWrites, 60_000, 'initialization-timeout'); + const deadline = Date.now() + 60_000; + while (instance.refreshGate.active || instance.activeCodexRefreshes.size || + instance.codexCheckpointHydration || instance.pendingResourceStops.size || + instance.codexProviderRetirements.size) { + if (Date.now() >= deadline) throw new Error('native-test-runner:settle-timeout'); + await delay(25); + } + if (instance.initializationWriteFailure) throw new Error('native-test-runner:initialization-write'); + if (instance.codexProviderRetirementFailure) throw new Error('native-test-runner:provider-retirement'); + await bounded(instance.codexBackgroundStateWrite, 60_000, 'background-write-timeout'); +} +async function run() { + const vscode = require('vscode'); + const plan = JSON.parse(await fs.readFile(path.join(__dirname, 'native-plan.json'), 'utf8')); + let background; + try { background = await fs.readFile(path.join(__dirname, 'native-background-options.json'), 'utf8'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (background !== undefined) { + assert.ok(background.length <= 128, 'bounded own background options'); + return require('./native-background.cjs').run(plan, JSON.parse(background)); + } + let soak; + try { soak = await fs.readFile(path.join(__dirname, 'native-soak-options.json'), 'utf8'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (soak !== undefined) { + assert.ok(soak.length <= 128, 'bounded own soak options'); + return require('./native-soak.cjs').run(plan, JSON.parse(soak)); + } + const manifest = JSON.parse(await fs.readFile(path.join(plan.root, 'native-stage-manifest.json'), 'utf8')); + const samples = [], assertions = [], started = Date.now(); + let api, stage = 'activation', failureStage, stopCompleted = false, warmIo = null, outputAppend; + const sample = phase => { + const owned = api.instance.resourceOwnership.snapshotForTests(), memory = process.memoryUsage(); + samples.push({ phase, rssBytes: memory.rss, heapUsedBytes: memory.heapUsed, externalBytes: memory.external, + arrayBufferBytes: memory.arrayBuffers, ownedActive: owned.activeCount, ownedByKind: owned.byKind, + panelOpen: !!api.instance.webviewProvider.panel, + clientReadyObserved: api.instance.webviewProvider.webviewClientReady === true }); + assert.ok(memory.rss < 2 * 1024 ** 3, 'native sampled host RSS budget'); + assert.equal(owned.byKind.network, 0, 'no extension-owned quota or advice network'); + assert.equal(owned.byKind.watcher, 0, 'watchers disabled in this small native diagnostic'); + }; + try { + const extension = vscode.extensions.getExtension('GrowthJack.claude-code-usage'); + assert.ok(extension, 'isolated development extension found'); + api = await bounded(extension.activate(), 60_000, 'activation-timeout'); + assert.equal(api.plan.root, plan.root); + assert.ok(Object.values(api.isolation).every(value => value === true)); + assert.equal(api.instance.getConfiguration().usageLimitTracking, false); + assert.equal(api.instance.settings.get('advice.apiKey'), ''); + assertions.push('real Test-mode context and synthetic sources guarded before activation'); + stage = 'cold-refresh'; + await api.instance.refreshData(false, 'manual'); + await settle(api.instance); + assert.equal(api.instance.cache.dataDirectory, plan.directories.claudeHome); + assert.ok(api.instance.cache.manifest); + assert.equal(api.instance.codexSnapshotHome, plan.directories.codexHome); + assert.equal(api.instance.codexHasData, true); + sample('cold-ready'); + assertions.push('real production Claude and Codex indexes loaded the six synthetic files'); + stage = 'warm-refresh'; + const ioProbe = createRefreshIoProbe(); + outputAppend = api.instance.outputChannel.appendLine; + api.instance.outputChannel.appendLine = line => { + ioProbe.observe(line); outputAppend.call(api.instance.outputChannel, line); + }; + // Explicit own-host refresh avoids mistaking a blurred-window poll no-op + // for an actual warm refresh in this background diagnostic. + for (let i = 0; i < 20; i++) { await api.instance.refreshData(false, 'manual'); await settle(api.instance); } + api.instance.outputChannel.appendLine = outputAppend; + outputAppend = undefined; + warmIo = ioProbe.snapshot(); + assertZeroBodyWarmRefreshes(warmIo, 20); + sample('twenty-warm-refreshes'); + assertions.push('twenty actual host refreshes observe known zero Claude/Codex body reads and Claude body bytes, without owned network or watcher'); + stage = 'panel-lifecycle'; + const view = api.instance.webviewProvider; + for (let cycle = 1; cycle <= 20; cycle++) { + view.show('achievements'); + assert.ok(view.panel, 'actual panel created'); + assert.ok(view.panel.webview.html.length > 0, 'actual webview HTML set'); + await delay(100); + sample('panel-open-' + cycle); + // Direct synthetic artifact seed exercises lifecycle retirement only. + // It is not an earned selection, user share consent, preview UI or export. + view.acceptedAchievementPreview.accept(['astra-regular'], 'synthetic-test-only', 'en'); + stage = 'panel-lifecycle-dispose'; + await disposePanelAndWait(view); + assertNativePanelCachesCleared(view); + stage = 'panel-lifecycle'; + } + assertions.push('twenty actual WebviewPanel disposal events clear map/set caches and directly seeded synthetic preview artifacts, not share-consent/export acceptance'); + stage = 'feature-toggle'; + const originalProvider = api.instance.codexProvider; + for (const enabled of [false, true]) { + await api.instance.settings.set('enableAchievements', enabled); + api.instance.onSettingsChangedFromPanel('enableAchievements'); + await settle(api.instance); + assert.equal(api.instance.codexProvider, originalProvider); + assert.equal(api.instance.getConfiguration().enableAchievements, enabled); + sample(enabled ? 'achievements-on' : 'achievements-off'); + } + assertions.push('real own-state achievement OFF/ON preserves the provider instance'); + stage = 'analysis-provider-retirement'; + for (let cycle = 0; cycle < 20; cycle++) { + const enabled = cycle % 2 !== 0, priorProvider = api.instance.codexProvider; + await api.instance.settings.set('enableContentAnalysis', enabled); + api.instance.onSettingsChangedFromPanel('enableContentAnalysis'); + await settle(api.instance); + assert.notEqual(api.instance.codexProvider, priorProvider); + assert.equal(api.instance.codexProviderRetirements.size, 0); + assert.equal(api.instance.getConfiguration().enableContentAnalysis, enabled); + await api.instance.refreshData(false, 'manual'); + await settle(api.instance); + sample('content-analysis-toggle-' + (cycle + 1)); + } + assertions.push('twenty actual own-state content-analysis toggles await old provider retirement before refreshing the replacement'); + } catch (error) { + failureStage = stage; + // Fixed phase only in report. Do not serialize arbitrary errors, stacks, + // user paths or private instance/context objects. + console.error('CCU isolated native diagnostic failed at ' + stage); + } finally { + if (api) { + if (outputAppend) api.instance.outputChannel.appendLine = outputAppend; + stage = 'dispose'; + try { + await bounded(api.stop(), 60_000, 'dispose-timeout'); + assert.equal(api.instance.resourceOwnership.snapshotForTests().activeCount, 0); + assert.equal(api.instance.webviewProvider.panel, undefined); + stopCompleted = true; + } catch { failureStage ??= stage; } + } + const report = { + kind: 'actual-native-development-host-api-diagnostic-not-final-install-f5', + sourceHead: manifest.sourceHead, stagedRuntimeVersion: manifest.packageVersion, + vscodeVersion: vscode.version, platform: process.platform, + outcome: failureStage ? 'fail' : 'pass', failureStage: failureStage ?? null, + assertions, samples, warmIo, stopCompleted, elapsedMs: Date.now() - started, + heapLeakConclusionEstablished: false, pixelVisualAcceptancePassed: false, finalInstallF5Passed: false, + limits: { sampledHostRssBytes: 2 * 1024 ** 3, panelCycles: 20, warmRefreshes: 20, contentAnalysisToggles: 20 }, + }; + await fs.writeFile(path.join(plan.root, 'native-result.json'), JSON.stringify(report, null, 2) + '\n', { flag: 'wx' }); + } + if (failureStage) throw new Error('native-test-runner:failed-' + failureStage); +} +module.exports = { run, disposePanelAndWait, assertNativePanelCachesCleared, settle, + createRefreshIoProbe, assertZeroBodyWarmRefreshes }; diff --git a/tests/native/soak.cjs b/tests/native/soak.cjs new file mode 100644 index 00000000..4f4b0617 --- /dev/null +++ b/tests/native/soak.cjs @@ -0,0 +1,425 @@ +'use strict'; + +// Opt-in native resource diagnostic. All activity is synthetic and writes only +// two exact fresh-root fixture files. Simulated focus invokes the production +// handler without bringing the app forward; it is not an OS-focus/notice test. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const { constants } = require('node:fs'); +const path = require('node:path'); +const { performance } = require('node:perf_hooks'); +const { nativeLaunchArguments } = require(path.basename(__filename) === 'native-soak.cjs' + ? './native-isolation.cjs' : './isolation.cjs'); +const failureReasons = new WeakMap(); +const allowedFailureReasons = new Set(['invalid-options', 'invalid-activity', 'nonregular-target', 'activity-budget', + 'invalid-sample', 'resource-budget', 'sample-budget', 'operation-timeout', 'wait-timeout', + 'setup-claude-watch-setting', 'setup-codex-watch-setting', 'setup-watchers']); +function failureError(reason) { + const error = new Error('native-soak:' + reason); + if (allowedFailureReasons.has(reason)) failureReasons.set(error, reason); + return error; +} +const fail = reason => { throw failureError(reason); }; +function diagnosticFailureReason(error) { + // Identity-only provenance: never inspect an arbitrary exception's code, + // name, message, stack, getters or proxy properties to classify the report. + return error !== null && (typeof error === 'object' || typeof error === 'function') + ? failureReasons.get(error) ?? 'assertion-or-stage-failure' : 'assertion-or-stage-failure'; +} +const kinds = ['timer', 'watcher', 'worker', 'network', 'backfill']; +const finiteCount = value => Number.isSafeInteger(value) && value >= 0; +const limits = Object.freeze({ hostRssBytes: 2 * 1024 ** 3, hostHeapBytes: 512 * 1024 ** 2, + hostExternalBytes: 256 * 1024 ** 2, sampleCount: 1024, timer: 8, watcher: 2, + worker: 1, network: 0, backfill: 1, panelCacheEntries: 24, pendingNoticeEntries: 2, + sampleEveryMs: 5000, cycleEveryMs: 60000, minimumLongMs: 1800000 }); +async function bounded(work, milliseconds = 60000) { + let timer; + try { + return await Promise.race([work, new Promise((_, reject) => { + timer = setTimeout(() => reject(failureError('operation-timeout')), milliseconds); + })]); + } finally { clearTimeout(timer); } +} +function validateSoakOptions(value) { + if (!value || Object.keys(value).join('|') !== 'durationMs' || + !Number.isSafeInteger(value.durationMs) || value.durationMs < 100 || value.durationMs > 3600000) fail('invalid-options'); + return Object.freeze({ durationMs: value.durationMs }); +} +function longSoakDurationPassed(elapsedMs) { + return Number.isFinite(elapsedMs) && elapsedMs >= limits.minimumLongMs; +} +function failureFocusObservation(logicalFocused, nativeFocused, watcherCount) { + if (typeof logicalFocused !== 'boolean' || typeof nativeFocused !== 'boolean' || + !finiteCount(watcherCount)) return null; + return { logicalFocused, nativeFocused, watcherCount }; +} +function assertRecurringReady(configuration, resources) { + if (configuration.fileWatchSeconds !== 1) fail('setup-claude-watch-setting'); + if (configuration.codexFileWatchSeconds !== 10) fail('setup-codex-watch-setting'); + if (resources.byKind.watcher !== 2) fail('setup-watchers'); +} +function createReadbackStability() { + const requiredStableMs = 500; + let previous = -1, since = null, samples = 0, mismatches = 0, convergenceMs = null; + return Object.freeze({ + observe(elapsedMs, expected) { + if (!finiteCount(elapsedMs) || elapsedMs < previous || typeof expected !== 'boolean') fail('invalid-sample'); + if (samples >= 256) fail('sample-budget'); + previous = elapsedMs; samples++; + if (!expected) { since = null; convergenceMs = null; mismatches++; return false; } + if (since === null) since = elapsedMs; + if (elapsedMs - since < requiredStableMs) return false; + convergenceMs = elapsedMs; + return true; + }, + snapshot() { return { requiredStableMs, samples, mismatches, convergenceMs }; }, + }); +} +const setupReadbackPhases = new Set(['after-cold-settle', 'after-claude-set', 'after-codex-set', + 'after-readback-convergence', 'after-handler', 'after-settle', 'after-focus', 'on-failure']); +const setupReadbackBound = 32; +function projectSetupReadback(phase, mementoClaude, mementoCodex, settingsClaude, settingsCodex, + configurationClaude, configurationCodex, counts) { + if (!setupReadbackPhases.has(phase)) return null; + const updates = {}; + for (const kind of ['claude', 'codex', 'other']) { + const started = counts?.[kind]?.started, completed = counts?.[kind]?.completed; + if (!finiteCount(started) || !finiteCount(completed) || completed > started || + started > setupReadbackBound) return null; + updates[kind] = { started, completed }; + } + // Never stringify, coerce or inspect a value. Unknown objects/proxies and + // secrets can contribute only a typeof enum and strict expected equality. + const readback = (value, expected) => ({ expectedEqual: value === expected, type: typeof value }); + return { phase, + memento: { claude: readback(mementoClaude, '1'), codex: readback(mementoCodex, '10') }, + settings: { claude: readback(settingsClaude, '1'), codex: readback(settingsCodex, '10') }, + configuration: { claude: readback(configurationClaude, 1), codex: readback(configurationCodex, 10) }, + updates }; +} +function createSetupReadbackProbe(globalState) { + const original = globalState.update; + const descriptor = Object.getOwnPropertyDescriptor(globalState, 'update'); + const counts = { claude: { started: 0, completed: 0 }, codex: { started: 0, completed: 0 }, + other: { started: 0, completed: 0 } }; + const increment = (kind, field) => { + counts[kind][field] = Math.min(setupReadbackBound, counts[kind][field] + 1); + }; + globalState.update = function (...args) { + const kind = args[0] === 'ccu.setting.fileWatchSeconds' ? 'claude' + : args[0] === 'ccu.setting.codex.fileWatchSeconds' ? 'codex' : 'other'; + increment(kind, 'started'); + const result = original.apply(globalState, args); + // Observe completion without replacing the original result/rejection or + // retaining keys, values, IDs or exceptions in diagnostic state. + void Promise.resolve(result).then(() => increment(kind, 'completed'), () => undefined); + return result; + }; + let restored = false; + return Object.freeze({ + capture(rows, phase, settings, configuration) { + if (rows.length >= setupReadbackBound || !setupReadbackPhases.has(phase)) return false; + const row = projectSetupReadback(phase, + globalState.get('ccu.setting.fileWatchSeconds'), globalState.get('ccu.setting.codex.fileWatchSeconds'), + settings.get('fileWatchSeconds'), settings.get('codex.fileWatchSeconds'), + configuration.fileWatchSeconds, configuration.codexFileWatchSeconds, counts); + if (!row) return false; + rows.push(row); + return true; + }, + restore() { + if (restored) return; + if (descriptor) Object.defineProperty(globalState, 'update', descriptor); + else delete globalState.update; + restored = true; + }, + }); +} +function sampleOwnedResources(memory, resources, elapsedMs, achievementsEnabled, panelOpen, panelCacheEntries, pendingNoticeEntries) { + if (![memory.rss, memory.heapUsed, memory.external, memory.arrayBuffers, resources.activeCount, + elapsedMs, panelCacheEntries, pendingNoticeEntries, ...kinds.map(k => resources.byKind[k])].every(finiteCount) || + typeof achievementsEnabled !== 'boolean' || typeof panelOpen !== 'boolean') fail('invalid-sample'); + return { elapsedMs, rssBytes: memory.rss, heapUsedBytes: memory.heapUsed, + externalBytes: memory.external, arrayBufferBytes: memory.arrayBuffers, ownedActive: resources.activeCount, + ownedByKind: Object.fromEntries(kinds.map(k => [k, resources.byKind[k]])), + achievementsEnabled, panelOpen, panelCacheEntries, pendingNoticeEntries }; +} +function assertResourceBudget(sample) { + if (sample.rssBytes >= limits.hostRssBytes || sample.heapUsedBytes >= limits.hostHeapBytes || + sample.externalBytes >= limits.hostExternalBytes || kinds.some(k => sample.ownedByKind[k] > limits[k]) || + sample.ownedActive !== kinds.reduce((sum, k) => sum + sample.ownedByKind[k], 0) || + sample.panelCacheEntries > limits.panelCacheEntries || sample.pendingNoticeEntries > limits.pendingNoticeEntries) fail('resource-budget'); +} +function appendBoundedSample(samples, sample) { + if (samples.length >= limits.sampleCount) fail('sample-budget'); + samples.push(sample); +} +async function appendSyntheticActivity(plan, sequence, timestamp) { + nativeLaunchArguments(plan); // Exact validated source plan, no arbitrary target. + if (!Number.isSafeInteger(sequence) || sequence <= 200 || sequence >= 300 || + !Number.isSafeInteger(timestamp) || timestamp < 0 || !Number.isFinite(new Date(timestamp).getTime())) fail('invalid-activity'); + const p = plan.directories, targets = [ + path.join(p.claudeHome, 'projects/synthetic-native/synthetic-native-0.jsonl'), + path.join(p.codexHome, 'sessions/rollout-0.jsonl'), + ]; + const date = new Date(timestamp).toISOString(), id = 'synthetic-native-0'; + const rows = [ + { type: 'assistant', timestamp: date, cwd: p.workspace, sessionId: id, + requestId: id + '-request-' + sequence, message: { id: id + '-message-' + sequence, role: 'assistant', + model: 'claude-opus-5-5', content: [{ type: 'text', text: 'Synthetic native output.' }], + usage: { input_tokens: 1000, output_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 500 } } }, + { type: 'event_msg', timestamp: date, payload: { type: 'token_count', info: { + total_token_usage: { input_tokens: sequence * 1000, cached_input_tokens: sequence * 500, + output_tokens: sequence * 100, reasoning_output_tokens: sequence * 25, total_tokens: sequence * 1100 } } } }, + ]; + const handles = []; + let bytes = 0; + try { + // Verify/open both targets before writing either, reject every path symlink. + for (const target of targets) { + if (await fs.realpath(target) !== target || !(await fs.lstat(target)).isFile()) fail('nonregular-target'); + const handle = await fs.open(target, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW); + handles.push(handle); + if (!(await handle.stat()).isFile()) fail('nonregular-target'); + } + for (let i = 0; i < handles.length; i++) { + const body = Buffer.from(JSON.stringify(rows[i]) + '\n'); + if (body.length > 2048) fail('activity-budget'); + bytes += body.length; + await handles[i].writeFile(body); + } + return { files: 2, bytes }; + } finally { await Promise.all(handles.map(handle => handle.close())); } +} +async function run(plan, rawOptions) { + const options = validateSoakOptions(rawOptions), vscode = require('vscode'); + nativeLaunchArguments(plan); + const { settle, disposePanelAndWait, assertNativePanelCachesCleared, + createRefreshIoProbe, assertZeroBodyWarmRefreshes } = require(path.basename(__filename) === 'native-soak.cjs' + ? './native-runner.cjs' : './runner.cjs'); + const manifest = JSON.parse(await fs.readFile(path.join(plan.root, 'native-stage-manifest.json'), 'utf8')); + const started = performance.now(), samples = [], assertions = [], setupReadbacks = []; + const warm = { on: createRefreshIoProbe(), off: createRefreshIoProbe() }; + const counters = { cycles: 0, onCycles: 0, offCycles: 0, panelDisposals: 0, settingsToggles: 0, + syntheticFocusTransitions: 0, nativeWindowTransitions: 0, + appendBytes: 0, claudeWatchRefreshes: 0, codexWatchRefreshes: 0 }; + let api, append, warmMode, timer, focusObserver, setupReadbackProbe, setupStability, failureStage, failureReason, + failureFocus = null, stage = 'activation', stopCompleted = false; + let soakStarted, completedSoakMs = 0, watchdogFailure = null; + const elapsed = () => Math.floor(performance.now() - started); + const sample = () => { + const instance = api.instance, view = instance.webviewProvider; + const value = sampleOwnedResources(process.memoryUsage(), instance.resourceOwnership.snapshotForTests(), + elapsed(), instance.getConfiguration().enableAchievements, !!view.panel, + view.dataPanelCache.size, view.pendingAchievementNotice.size); + assertResourceBudget(value); appendBoundedSample(samples, value); + return value; + }; + const checkpoint = async () => { + const value = sample(); + await fs.writeFile(path.join(plan.root, 'native-soak-progress.json'), + JSON.stringify({ elapsedMs: elapsed(), stage, counters, sample: value }) + '\n'); + }; + // Write a bounded phase marker before asynchronous work. An interrupted + // host without a final report is incomplete, never a successful soak. + const markStage = async next => { + stage = next; + await fs.writeFile(path.join(plan.root, 'native-soak-progress.json'), + JSON.stringify({ elapsedMs: elapsed(), stage, counters }) + '\n'); + }; + const waitUntil = async (predicate, milliseconds, pollMs = 100) => { + const deadline = performance.now() + milliseconds; + while (!predicate()) { + if (watchdogFailure) fail(watchdogFailure); + if (performance.now() >= deadline) fail('wait-timeout'); + await new Promise(resolve => setTimeout(resolve, pollMs)); + } + }; + const focus = async (focused, setupDeadline) => { + api.instance.handleWindowFocusChange(focused); + counters.syntheticFocusTransitions++; + await bounded(settle(api.instance), 65000); + if (focused) { + // Wait for the real handler's async setup, never call setup a second + // time while the same watcher is already attached. + const remaining = setupDeadline === undefined ? 5000 : setupDeadline - performance.now(); + if (remaining <= 0) fail('wait-timeout'); + await waitUntil(() => api.instance.resourceOwnership.snapshotForTests().byKind.watcher === 2, remaining); + await bounded(settle(api.instance), 65000); + } + }; + try { + await markStage('activation'); + const extension = vscode.extensions.getExtension('GrowthJack.claude-code-usage'); + assert.ok(extension); + api = await bounded(extension.activate()); + // Observe only this Test host's real window events. Do not suppress them + // to make simultaneous background windows pass simulated-focus assertions. + focusObserver = vscode.window.onDidChangeWindowState(state => { + if (typeof state.focused === 'boolean') counters.nativeWindowTransitions++; + }); + assert.equal(api.plan.root, plan.root); + assert.ok(Object.values(api.isolation).every(v => v === true)); + assert.equal(api.instance.getConfiguration().usageLimitTracking, false); + assert.equal(api.instance.settings.get('advice.apiKey'), ''); + assertions.push('actual guarded Test-mode host, own synthetic data, no quota/BYOK'); + // Only the already guarded Test host's captured production context may be + // wrapped. No subscription to private storage notifications is installed. + assert.equal(api.context.extensionMode, vscode.ExtensionMode.Test); + assert.equal(api.context.extensionPath, plan.directories.developmentExtension); + assert.equal(api.instance.context, api.context); + setupReadbackProbe = createSetupReadbackProbe(api.context.globalState); + const setupReadback = phase => setupReadbackProbe.capture(setupReadbacks, phase, + api.instance.settings, api.instance.getConfiguration()); + await markStage('cold-refresh'); + await bounded(api.instance.refreshData(false, 'manual')); await bounded(settle(api.instance), 65000); + setupReadback('after-cold-settle'); + assert.equal(api.instance.cache.dataDirectory, plan.directories.claudeHome); + assert.equal(api.instance.codexSnapshotHome, plan.directories.codexHome); + assert.equal(api.instance.codexHasData, true); + const instance = api.instance, view = instance.webviewProvider; + append = instance.outputChannel.appendLine; + instance.outputChannel.appendLine = line => { + if (typeof line === 'string' && line.length <= 4096) { + if (/^refresh: trigger=watch /.test(line)) counters.claudeWatchRefreshes++; + if (/^codex-index trigger=watch /.test(line)) counters.codexWatchRefreshes++; + if (warmMode) warm[warmMode].observe(line); + } + append.call(instance.outputChannel, line); + }; + await markStage('recurring-setup'); + // SDK notifications can temporarily regress public readbacks after an + // awaited write. Observe convergence, never overwrite/retry settings. This + // consumes the existing five-second readiness budget, not a new deadline. + const setupDeadline = performance.now() + 5000; + await instance.settings.set('fileWatchSeconds', '1'); + setupReadback('after-claude-set'); + await instance.settings.set('codex.fileWatchSeconds', '10'); + setupReadback('after-codex-set'); + const stabilityStarted = performance.now(); + setupStability = createReadbackStability(); + await waitUntil(() => { + const state = api.context.globalState, config = instance.getConfiguration(); + const expected = state.get('ccu.setting.fileWatchSeconds') === '1' && + state.get('ccu.setting.codex.fileWatchSeconds') === '10' && + instance.settings.get('fileWatchSeconds') === '1' && instance.settings.get('codex.fileWatchSeconds') === '10' && + config.fileWatchSeconds === 1 && config.codexFileWatchSeconds === 10; + return setupStability.observe(Math.floor(performance.now() - stabilityStarted), expected); + }, Math.max(0, setupDeadline - performance.now()), 25); + setupReadback('after-readback-convergence'); + instance.onSettingsChangedFromPanel('fileWatchSeconds'); + setupReadback('after-handler'); + await bounded(settle(instance), 65000); + setupReadback('after-settle'); + await focus(true, setupDeadline); + setupReadback('after-focus'); + assertRecurringReady(instance.getConfiguration(), instance.resourceOwnership.snapshotForTests()); + sample(); + timer = setInterval(() => { + try { + if (elapsed() > options.durationMs + 240000) fail('operation-timeout'); + sample(); + } catch (error) { watchdogFailure = diagnosticFailureReason(error); } + }, limits.sampleEveryMs); + soakStarted = performance.now(); + while (performance.now() - soakStarted < options.durationMs || counters.cycles < 2) { + if (watchdogFailure) fail(watchdogFailure); + await markStage('feature-toggle'); + const enabled = counters.cycles % 2 === 1, provider = instance.codexProvider; + await instance.settings.set('enableAchievements', enabled); + instance.onSettingsChangedFromPanel('enableAchievements'); + await bounded(settle(instance), 65000); + assert.equal(instance.getConfiguration().enableAchievements, enabled); + assert.equal(instance.codexProvider, provider); + counters.settingsToggles++; + if (enabled) counters.onCycles++; else counters.offCycles++; + await focus(true); + const recordsBefore = instance.cache.records.length; + const processedBefore = instance.codexView.allTime.total.processed; + const claudeWatchBefore = counters.claudeWatchRefreshes, codexWatchBefore = counters.codexWatchRefreshes; + await markStage('watch-append'); + const result = await appendSyntheticActivity(plan, 201 + counters.cycles, Date.now()); + counters.appendBytes += result.bytes; + await waitUntil(() => counters.claudeWatchRefreshes > claudeWatchBefore && + counters.codexWatchRefreshes > codexWatchBefore, 30000); + await bounded(settle(instance), 65000); + assert.equal(instance.cache.records.length, recordsBefore + 1); + assert.equal(instance.codexView.allTime.total.processed, processedBefore + 1100); + // Panel creation can cause real window-state transitions. Complete the + // critical watched append first; retain an actual open panel for warm + // refreshes and disposal, without suppressing any native event. + view.show('achievements'); + assert.ok(view.panel); + await markStage('warm-refresh'); + warmMode = enabled ? 'on' : 'off'; + for (let i = 0; i < 20; i++) { + await bounded(instance.refreshData(false, 'manual')); await bounded(settle(instance), 65000); + } + warmMode = undefined; + assertZeroBodyWarmRefreshes(warm[enabled ? 'on' : 'off'].snapshot(), 20); + await markStage('panel-disposal'); + await disposePanelAndWait(view); assertNativePanelCachesCleared(view); + counters.panelDisposals++; + await markStage('focus-suspend'); + await focus(false); + const suspended = sample(); + assert.equal(suspended.ownedByKind.watcher, 0); + assert.equal(suspended.ownedByKind.timer, 0); + assert.equal(suspended.ownedByKind.worker, 0); + counters.cycles++; + await checkpoint(); + await markStage('duration-wait'); + // Use short nonblocking waits and the monotonic clock. No clock scaling. + const nextCycle = soakStarted + counters.cycles * limits.cycleEveryMs; + await waitUntil(() => performance.now() >= Math.min(nextCycle, soakStarted + options.durationMs), 65000); + } + completedSoakMs = Math.floor(performance.now() - soakStarted); + assertZeroBodyWarmRefreshes(warm.on.snapshot(), 20); + assertZeroBodyWarmRefreshes(warm.off.snapshot(), 20); + assertions.push('actual fs.watch refreshes on both providers account for each exact synthetic append', + 'twenty known-zero manual warm refreshes per cycle with whole achievements ON and OFF', + 'actual panel disposal and production focus-handler suspension release panel caches/watchers/timers', + 'achievement presentation toggles do not replace the provider and do not stop basic usage'); + } catch (error) { + failureStage = stage; + failureReason = watchdogFailure ?? diagnosticFailureReason(error); + try { + if (api && setupReadbackProbe) setupReadbackProbe.capture(setupReadbacks, 'on-failure', + api.instance.settings, api.instance.getConfiguration()); + } catch { /* Preserve the original fixed failure if readback is unavailable. */ } + try { + failureFocus = failureFocusObservation(api?.instance?.windowActivity?.focused, + vscode.window?.state?.focused, api?.instance?.resourceOwnership.snapshotForTests().byKind.watcher); + } catch { failureFocus = null; } + console.error('CCU native resource diagnostic failed at ' + stage); + } finally { + if (timer) clearInterval(timer); + focusObserver?.dispose(); + setupReadbackProbe?.restore(); + if (api) { + if (append) api.instance.outputChannel.appendLine = append; + try { + await bounded(api.stop()); + assert.equal(api.instance.resourceOwnership.snapshotForTests().activeCount, 0); + assert.equal(api.instance.webviewProvider.panel, undefined); + stopCompleted = true; + } catch { failureStage ??= 'dispose'; failureReason ??= 'stop-failure'; } + } + const report = { kind: 'actual-native-resource-diagnostic-synthetic-focus-not-final-vsix', + sourceHead: manifest.sourceHead, vscodeVersion: vscode.version, platform: process.platform, + outcome: failureStage ? 'fail' : 'pass', failureStage: failureStage ?? null, + failureReason: failureReason ?? null, failureFocus, assertions, counters, samples, setupReadbacks, + setupReadbackStability: setupStability?.snapshot() ?? null, + warmIo: { on: warm.on.snapshot(), off: warm.off.snapshot() }, stopCompleted, + elapsedMs: elapsed(), completedSoakMs, requestedSoakMs: options.durationMs, + longSoakDurationPassed: !failureStage && longSoakDurationPassed(completedSoakMs), + limits, noticeDeliveryTested: false, nativeOsFocusTested: false, + workerHeapOrMessageBudgetTested: false, heapLeakConclusionEstablished: false, + pixelVisualAcceptancePassed: false, finalInstallF5Passed: false }; + await fs.writeFile(path.join(plan.root, 'native-soak-result.json'), JSON.stringify(report, null, 2) + '\n', { flag: 'wx' }); + } + if (failureStage) fail('failed-' + failureStage); +} +module.exports = { run, validateSoakOptions, longSoakDurationPassed, sampleOwnedResources, + assertResourceBudget, appendBoundedSample, appendSyntheticActivity, bounded, failureFocusObservation, + assertRecurringReady, diagnosticFailureReason, projectSetupReadback, createSetupReadbackProbe, createReadbackStability }; diff --git a/tests/perf/measure-codex-worker-lifecycle.cjs b/tests/perf/measure-codex-worker-lifecycle.cjs new file mode 100644 index 00000000..e8e10530 --- /dev/null +++ b/tests/perf/measure-codex-worker-lifecycle.cjs @@ -0,0 +1,298 @@ +'use strict'; + +// Opt-in synthetic Node worker/pool evidence, not native Extension Host/F5, +// Webview, Windows OOM or proof of an exact instantaneous memory peak. +// node --expose-gc tests/perf/measure-codex-worker-lifecycle.cjs +// Optional --out points to an already-compiled immutable comparison cohort. +// Every source/index/lease write is confined to this run's fresh temp directory. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { serialize } = require('node:v8'); +const threads = require('node:worker_threads'); +const { performance } = require('node:perf_hooks'); +const RealWorker = threads.Worker; +const marker = '__ccuSyntheticWorkerPerf'; + +if (!threads.isMainThread) { + // The production entry runs unchanged; instrumentation retains counters only. + const send = threads.parentPort.postMessage.bind(threads.parentPort); + const telemetry = event => send({ [marker]: event }); + let outgoingMessages = 0, outgoingBytes = 0, maxOutgoingBytes = 0; + threads.parentPort.postMessage = message => { + const bytes = serialize(message).byteLength; + outgoingMessages++; outgoingBytes += bytes; + maxOutgoingBytes = Math.max(maxOutgoingBytes, bytes); + send(message); + }; + threads.Worker = class extends RealWorker { + constructor(filename, options) { + super(__filename, { ...options, workerData: { entry: filename } }); + const id = this.threadId; + telemetry({ kind: 'child-start', id }); + this.on('message', message => { + if (message?.[marker]) telemetry({ kind: 'child-sample', id, ...message[marker] }); + else telemetry({ kind: 'child-message', id, bytes: serialize(message).byteLength }); + }); + this.once('exit', () => telemetry({ kind: 'child-exit', id })); + } + postMessage(message) { + telemetry({ kind: 'child-request', id: this.threadId, bytes: serialize(message).byteLength }); + super.postMessage(message); + } + }; + const sample = () => { + const memory = process.memoryUsage(); + telemetry({ kind: 'sample', heapUsed: memory.heapUsed, external: memory.external, + outgoingMessages, outgoingBytes, maxOutgoingBytes }); + }; + const sampler = setInterval(sample, 50); + sampler.unref(); + sample(); + require(threads.workerData.entry); +} else { + function option(name, fallback) { + const at = process.argv.indexOf(name); + return at < 0 ? fallback : process.argv[at + 1]; + } + const compiled = path.resolve(option('--out', path.resolve(__dirname, '../../out'))); + const label = option('--label', 'candidate'); + const rowsPerFile = Number(option('--rows', '5000')); + const fileCount = Number(option('--files', '12')); + const rounds = Number(option('--rounds', '3')); + assert.match(label, /^[a-zA-Z0-9.-]{1,64}$/); + assert.ok(Number.isInteger(rowsPerFile) && rowsPerFile >= 1000 && rowsPerFile <= 20000); + assert.ok(Number.isInteger(fileCount) && fileCount >= 8 && fileCount <= 48); + assert.ok(Number.isInteger(rounds) && rounds >= 1 && rounds <= 3); + assert.equal(typeof global.gc, 'function', 'run with --expose-gc for post-GC measurements'); + const { CodexIndexClient } = require(path.join(compiled, 'providers/codex/codexIndexClient.js')); + const entry = path.join(compiled, 'providers/codex/codexIndexWorker.js'); + const ownedWorkers = new Set(); + const summaries = []; + const started = performance.now(); + const limits = { rssBytes: 2 * 1024 ** 3, workerHeapBytes: 2 * 1024 ** 3, + fileWorkersPerController: 6, settledHeapGrowthBytes: 64 * 1024 ** 2, + settledExternalGrowthBytes: 32 * 1024 ** 2, elapsedMs: 8 * 60_000 }; + let budgetFailure, peakRssBytes = process.memoryUsage().rss; + let maxWorkerHeapBytes = 0, maxWorkerExternalBytes = 0, maxFileWorkers = 0; + let controllersCreated = 0, controllersExited = 0, parsedWorkersCreated = 0, parsedWorkersExited = 0; + const parserTraffic = { requests: 0, requestBytes: 0, messages: 0, bytes: 0, maxBytes: 0 }; + let outgoingMessages = 0, outgoingBytes = 0, maxOutgoingBytes = 0; + let requestMessages = 0, requestBytes = 0; + let activeGroup = null, maxSettledHeapBytes = 0, maxSettledExternalBytes = 0; + const observations = new Map(); + const sample = () => { + const memory = process.memoryUsage(); + peakRssBytes = Math.max(peakRssBytes, memory.rss); + let heap = 0, external = 0; + for (const observation of observations.values()) { + for (const value of [observation.controller, ...observation.children.values()]) { + heap += value?.heapUsed ?? 0; external += value?.external ?? 0; + } + } + maxWorkerHeapBytes = Math.max(maxWorkerHeapBytes, heap); + maxWorkerExternalBytes = Math.max(maxWorkerExternalBytes, external); + if (activeGroup) { + activeGroup.peakRssBytes = Math.max(activeGroup.peakRssBytes, memory.rss); + activeGroup.maxWorkerHeapBytes = Math.max(activeGroup.maxWorkerHeapBytes, heap); + } + if (memory.rss > limits.rssBytes || heap > limits.workerHeapBytes || + performance.now() - started > limits.elapsedMs) { + budgetFailure ??= new Error('synthetic worker lifecycle exceeded frozen resources/time'); + for (const worker of ownedWorkers) void worker.terminate(); + } + }; + const sampler = setInterval(sample, 50); + const controller = () => { + const worker = new RealWorker(__filename, { workerData: { entry } }); + controllersCreated++; + ownedWorkers.add(worker); + const observation = { controller: null, children: new Map() }; + observations.set(worker, observation); + worker.on('message', message => { + const telemetry = message?.[marker]; + if (telemetry) { + if (telemetry.kind === 'child-start') { + parsedWorkersCreated++; + observation.children.set(telemetry.id, null); + maxFileWorkers = Math.max(maxFileWorkers, observation.children.size); + if (observation.children.size > limits.fileWorkersPerController) { + budgetFailure ??= new Error('too many synthetic file workers'); + void worker.terminate(); + } + } else if (telemetry.kind === 'child-exit') { + parsedWorkersExited++; + observation.children.delete(telemetry.id); + } else if (telemetry.kind === 'child-message') { + parserTraffic.messages++; parserTraffic.bytes += telemetry.bytes; + parserTraffic.maxBytes = Math.max(parserTraffic.maxBytes, telemetry.bytes); + } else if (telemetry.kind === 'child-request') { + parserTraffic.requests++; parserTraffic.requestBytes += telemetry.bytes; + } + else if (telemetry.id !== undefined) observation.children.set(telemetry.id, telemetry); + else observation.controller = telemetry; + sample(); + } else { + const bytes = serialize(message).byteLength; + outgoingMessages++; outgoingBytes += bytes; + maxOutgoingBytes = Math.max(maxOutgoingBytes, bytes); + } + }); + const post = worker.postMessage.bind(worker); + worker.postMessage = request => { + requestMessages++; requestBytes += serialize(request).byteLength; + post(request); + }; + worker.once('exit', () => { + controllersExited++; + ownedWorkers.delete(worker); observations.delete(worker); + }); + return worker; + }; + const afterTurn = () => new Promise(resolve => setImmediate(resolve)); + async function settledMemory() { + await afterTurn(); + global.gc?.(); + await afterTurn(); + sample(); + const memory = process.memoryUsage(); + maxSettledHeapBytes = Math.max(maxSettledHeapBytes, memory.heapUsed); + maxSettledExternalBytes = Math.max(maxSettledExternalBytes, memory.external); + return memory; + } + async function bounded(work) { + let timer; + try { + return await Promise.race([work, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('synthetic worker refresh timeout')), 30_000); + })]); + } finally { clearTimeout(timer); } + } + function seed(index) { + const lines = [ + { timestamp: '2026-07-20T00:00:00.000Z', type: 'session_meta', + payload: { id: 'synthetic-session-' + index, cwd: '/fixture/worker-lifecycle' } }, + { timestamp: '2026-07-20T00:00:00.000Z', type: 'turn_context', + payload: { model: 'gpt-6.1-sol', effort: 'max' } }, + ]; + for (let i = 1; i <= rowsPerFile; i++) lines.push({ + timestamp: new Date(Date.parse('2026-07-20T00:00:00.000Z') + i * 1000).toISOString(), + type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { + input_tokens: i, cached_input_tokens: Math.floor(i / 2), + output_tokens: Math.floor(i / 5) + 1, reasoning_output_tokens: 0, + total_tokens: i + Math.floor(i / 5) + 1, + } } }, + }); + return lines.map(line => JSON.stringify(line)).join('\n') + '\n'; + } + (async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ccu-worker-lifecycle-')); + const salt = 'synthetic-worker-lifecycle'; + const fixtureHash = createHash('sha256'); + let fixtureBytes = 0; + try { + const sessions = path.join(root, 'home', 'sessions'); + await fs.mkdir(sessions, { recursive: true }); + for (let i = 0; i < fileCount; i++) { + const body = seed(i); + fixtureHash.update(body); fixtureBytes += Buffer.byteLength(body); + await fs.writeFile(path.join(sessions, 'rollout-' + i + '.jsonl'), body); + } + const initialMemory = await settledMemory(); + for (let round = 1; round <= rounds; round++) for (const proof of [false, true]) { + const client = new CodexIndexClient(controller); + const indexPath = path.join(root, 'cache', `${round}-${proof}.json`); + const input = { codexHome: path.join(root, 'home'), indexPath, salt, timeZone: 'UTC', + allowAchievementHourProof: proof }; + const groupStarted = performance.now(); + activeGroup = { peakRssBytes: 0, maxWorkerHeapBytes: 0 }; + const groupWorkersBefore = parsedWorkersCreated; + try { + let current = await bounded(client.refresh(input)); + assert.equal(current.failedFiles, 0); + assert.equal(current.index.coverage.complete, true); + assert.equal(current.index.aggregate.total.inputTotal, rowsPerFile * fileCount); + assert.equal(ownedWorkers.size, 0, 'terminal replies wait for controller termination'); + // Construct an incomplete legacy period projection in our synthetic + // cache, to exercise expedited production migration and its parser pool. + let legacy = JSON.parse(await fs.readFile(indexPath, 'utf8')); + for (const value of Object.values(legacy.files)) { + delete value.aggregate.period; delete value.periodMigration; + } + legacy.coverage.complete = false; + legacy.coverage.period.allTime.complete = false; + await fs.writeFile(indexPath, JSON.stringify(legacy)); + legacy = null; // Do not retain the fixture rewrite as an old snapshot. + const workersBefore = parsedWorkersCreated; + current = await bounded(client.refresh(input)); + assert.equal(current.index.coverage.period.allTime.complete, true); + assert.equal(current.index.aggregate.total.inputTotal, rowsPerFile * fileCount); + assert.ok(parsedWorkersCreated > workersBefore, 'period migration must use real parser workers'); + assert.equal(parsedWorkersExited, parsedWorkersCreated, 'all started parser workers must report exit'); + const warmStarted = await settledMemory(); + let peakSettledHeapGrowth = 0, peakSettledExternalGrowth = 0; + for (let pass = 0; pass < 20; pass++) { + current = await bounded(client.refresh(input)); + assert.equal(current.bodyReads, 0); + assert.equal(current.migration.bytesRead, 0); + assert.equal(current.indexChanged, false); + assert.equal(ownedWorkers.size, 0); + assert.equal(observations.size, 0); + const memory = await settledMemory(); + peakSettledHeapGrowth = Math.max(peakSettledHeapGrowth, memory.heapUsed - warmStarted.heapUsed); + peakSettledExternalGrowth = Math.max(peakSettledExternalGrowth, memory.external - warmStarted.external); + } + assert.ok(peakSettledHeapGrowth <= Math.max(limits.settledHeapGrowthBytes, warmStarted.heapUsed / 10)); + assert.ok(peakSettledExternalGrowth <= limits.settledExternalGrowthBytes); + // Dispose during a real worker request, then verify later requests are rejected. + const pending = client.refresh(input); + const rejected = assert.rejects(pending, { code: 'disposed' }); + await bounded(client.dispose()); await rejected; + await assert.rejects(client.refresh(input), { code: 'disposed' }); + await afterTurn(); + assert.equal(ownedWorkers.size, 0); + assert.equal(observations.size, 0); + summaries.push({ round, achievementHourProofRequested: proof, warmPasses: 20, + warmBodyReads: 0, warmBytesRead: 0, controllerAndParserReleaseObserved: true, + peakSettledHeapGrowth, peakSettledExternalGrowth, + parserWorkersCreated: parsedWorkersCreated - groupWorkersBefore, ...activeGroup, + elapsedMs: Math.round(performance.now() - groupStarted) }); + process.stdout.write(JSON.stringify({ phase: 'group', ...summaries.at(-1) }) + '\n'); + } finally { await client.dispose(); activeGroup = null; } + } + const finalMemory = await settledMemory(); + assert.equal(controllersCreated, controllersExited); + assert.equal(parsedWorkersCreated, parsedWorkersExited); + assert.equal(ownedWorkers.size, 0); + assert.ok(maxSettledHeapBytes - initialMemory.heapUsed <= + Math.max(limits.settledHeapGrowthBytes, initialMemory.heapUsed / 10)); + assert.ok(maxSettledExternalBytes - initialMemory.external <= limits.settledExternalGrowthBytes); + assert.ok(!process.getActiveResourcesInfo().includes('MessagePort'), 'no worker MessagePorts remain'); + if (budgetFailure) throw budgetFailure; + process.stdout.write(JSON.stringify({ phase: 'summary', outcome: 'pass', label, + kind: 'synthetic-production-worker-pool-not-native-or-OOM-proof', runtime: process.version, + fixture: { fileCount, rowsPerFile, fixtureBytes, sha256: fixtureHash.digest('hex') }, + samplingMs: 50, serializedMessageBytesAreProxy: true, + peakRssBytes, maxWorkerHeapBytes, maxWorkerExternalBytes, maxFileWorkers, + controllersCreated, controllersExited, parsedWorkersCreated, parsedWorkersExited, + parserTraffic, + controllerOutgoing: { messages: outgoingMessages, bytes: outgoingBytes, maxBytes: maxOutgoingBytes }, + hostRequests: { messages: requestMessages, bytes: requestBytes }, + initialMemory, finalMemory, maxSettledHeapBytes, maxSettledExternalBytes, limits, groups: summaries, + }) + '\n'); + } finally { + clearInterval(sampler); + await Promise.all([...ownedWorkers].map(worker => worker.terminate())); + assert.ok(path.basename(root).startsWith('ccu-worker-lifecycle-')); + await fs.rm(root, { recursive: true, force: true }); + } + })().catch(error => { + clearInterval(sampler); + process.stderr.write(JSON.stringify({ phase: 'summary', outcome: 'failure', label, + reason: error instanceof assert.AssertionError ? 'assertion' : 'runtime-or-budget', + message: error.message }) + '\n'); + process.exitCode = 1; + }); +} diff --git a/tests/perf/measure-dashboard-reuse.cjs b/tests/perf/measure-dashboard-reuse.cjs index f02a49df..6fb0ed61 100644 --- a/tests/perf/measure-dashboard-reuse.cjs +++ b/tests/perf/measure-dashboard-reuse.cjs @@ -38,6 +38,12 @@ function measured(fn) { (async () => { try { await renderHarness({ provider: 'claude', autoRefresh: false }); + // Browser screenshot fixtures inline PNGs so they need no resource server. + // A real extension uses asWebviewUri instead; measure that installed path. + provider.context.extensionPath = process.cwd(); + provider.panel = { webview: { + asWebviewUri: uri => ({ toString: () => 'vscode-resource://synthetic/' + require('node:path').basename(uri.fsPath) }), + } }; Date.now = () => CODEX_WEBVIEW_NOW; provider.dataPanelCache.clear(); provider.weeklyUsageCache = undefined; @@ -58,6 +64,7 @@ function measured(fn) { const medianMs = warm.map((row) => row.ms).sort((a, b) => a - b)[5]; process.stdout.write(JSON.stringify({ kind: 'synthetic-host-renderer-not-installed-VSIX-or-native-scroll-FPS', + assetMode: 'extension-resource-uri', records: records.length, cold, warmMedianMs: medianMs, warm, minute, nextHour, dataPanelCalls: calls, cacheEntries: provider.dataPanelCache.size, peakSampledRssBytes: process.memoryUsage().rss, diff --git a/tests/perf/measure-large-claude-index.mjs b/tests/perf/measure-large-claude-index.mjs index aad2da26..ab887496 100644 --- a/tests/perf/measure-large-claude-index.mjs +++ b/tests/perf/measure-large-claude-index.mjs @@ -27,6 +27,8 @@ const targetGiB = option('--gib', 2.4); const fileCount = option('--files', 645); const analyzeContent = process.argv.includes('--analyze-content'); const assistantPayload = process.argv.includes('--assistant-payload'); +const lineKiB = option('--line-kib', Buffer.byteLength('synthetic-only-') * 17_000 / 1024); +if (lineKiB < 1 || lineKiB > 256) throw new Error('Expected 1 <= --line-kib <= 256'); const modelOption = process.argv.indexOf('--model'); const model = modelOption < 0 ? 'claude-sonnet-4-5' : process.argv[modelOption + 1]; if (!model || model.length > 160 || !/^[a-z0-9][a-z0-9._-]*$/i.test(model)) { @@ -40,7 +42,7 @@ if (targetGiB <= 0 || targetGiB > 3 || !Number.isInteger(fileCount) || fileCount } const timestamp = new Date().toISOString(); -const payload = 'synthetic-only-'.repeat(17_000); // ~255 KiB; safely below the 1 MiB line cap. +const payload = 'synthetic-only-'.repeat(Math.floor(lineKiB * 1024 / Buffer.byteLength('synthetic-only-'))); const fillerLine = `${JSON.stringify(assistantPayload ? { type: 'assistant', timestamp, cwd: '/fixture/large-history', message: { @@ -80,6 +82,7 @@ async function measured(label, work) { const start = performance.now(); const result = await work(); const memory = process.memoryUsage(); + peakRss = Math.max(peakRss, memory.rss); return { label, elapsedMs: Math.round(performance.now() - start), @@ -180,11 +183,14 @@ try { process.stdout.write(`${JSON.stringify({ fixtureBytes: writtenBytes, files: fileCount, + fillerLineBytes: fillerBytes, + fillerLines: fillerCount, coldRecords, contentAnalysis: analyzeContent, assistantPayload, model, warningCalls, + memoryScope: 'single-process synthetic mutations; phase results retain prior snapshots, not a production soak', peakRssBytes: peakRss, phases: [ { label: generated.label, elapsedMs: generated.elapsedMs, diff --git a/tests/perf/measure-production-dashboard.cjs b/tests/perf/measure-production-dashboard.cjs index 841d435d..e7ab6b04 100644 --- a/tests/perf/measure-production-dashboard.cjs +++ b/tests/perf/measure-production-dashboard.cjs @@ -17,6 +17,7 @@ const webview = { set html(_value) { documentWrites++; }, onDidReceiveMessage: () => ({ dispose() {} }), postMessage: async message => { posted.push(message); return true; }, + asWebviewUri: uri => ({ toString: () => 'vscode-resource://synthetic/' + path.basename(uri.fsPath) }), }; const vscode = { ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }, @@ -67,10 +68,15 @@ Module._load = load; const values = new Map(SETTINGS.map(def => [def.key, def.default])); values.set('dashboardAutoRefresh', true); values.set('advice.effectiveness.enabled', false); + values.set('enableAchievements', true); + values.set('pricingBackend', 'anthropic'); + values.set('usageLimitTracking', false); + values.set('showWeeklyQuotaPace', false); const settings = { get: key => values.get(key), snapshot: () => SETTINGS.map(def => ({ ...def, value: values.get(def.key), configured: false, })) }; - const p = new UsageWebviewProvider({ globalState: { get: () => undefined, update: async () => undefined } }); + const p = new UsageWebviewProvider({ extensionPath: process.cwd(), + globalState: { get: () => undefined, update: async () => undefined } }); p.settings = settings; p.show(); const diagnostics = []; @@ -84,7 +90,9 @@ Module._load = load; webviewProvider: p, outputChannel: { appendLine: line => diagnostics.push(line) }, statusBar: { updateContext() {}, updateUsageData() {}, updateQuota() {}, setProvider() {}, setLoading() {} }, getConfiguration: () => ({ dataDirectory: root, dashboardAutoRefresh: true, statusBarProvider: 'claude', - enableContentAnalysis: true, advicePromptWindowDays: 30, projectGroupingMode: 'flat', contextWindowOverride: 0 }), + enableContentAnalysis: true, enableAchievements: true, pricingBackend: 'anthropic', + usageLimitTracking: false, showWeeklyQuotaPace: false, + advicePromptWindowDays: 30, projectGroupingMode: 'flat', contextWindowOverride: 0 }), maybeFetchUsageLimits: async () => null, refreshCodexData: async () => undefined, }); const coldStarted = performance.now(); @@ -176,7 +184,10 @@ Module._load = load; assert.equal(e.cache.records.length, recordCount + 1); assert.ok(renders > 0, 'a real append still invalidates panels'); process.stdout.write(JSON.stringify({ kind: 'synthetic-production-coordinator-plus-renderer-not-native-UI-or-OOM-proof', - records: recordCount, contentAnalysis: true, coldMs, claude, hourBoundary, compare, appendInvalidates: true, peakSampledRssBytes }, null, 2) + '\n'); + assetMode: 'extension-resource-uri', + records: recordCount, contentAnalysis: true, achievements: true, pricingBackend: 'anthropic', + usageLimitTracking: false, weeklyQuotaPace: false, + coldMs, claude, hourBoundary, compare, appendInvalidates: true, peakSampledRssBytes }, null, 2) + '\n'); } finally { ClaudeDataLoader.getUsageAttribution = originalAttribution; restoreClock(); diff --git a/tests/perf/measure-v242-money-scope.cjs b/tests/perf/measure-v242-money-scope.cjs new file mode 100644 index 00000000..0adf12dd --- /dev/null +++ b/tests/perf/measure-v242-money-scope.cjs @@ -0,0 +1,394 @@ +'use strict'; +// Bounded opt-in current-only production indexes/worker/facade/host adoption. +// Adapts private measure-equal-byte-density and measure-5000-codex-index inputs. +// No native app, normal provider state, dependency install, network or Git writes. +// node --expose-gc this.cjs --out /private/tmp/ccu-v242-perf-cohort-XXXXXX/out +// --provider claude|codex --case low-one|low-5000|dense-one|dense-20 +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); +const Module = require('node:module'); +const {createHash} = require('node:crypto'); +const {performance} = require('node:perf_hooks'); +const threads = require('node:worker_threads'); +const {serialize} = require('node:v8'); +const RealWorker = threads.Worker; +const MARK = '__ccuMoneyScopePerf'; +const arg = (name, fallback) => { + const i = process.argv.indexOf(name); + return i < 0 ? fallback : process.argv[i + 1]; +}; +const round2 = n => +n.toFixed(2); +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const blankIo = () => ({sourceReadCalls:0, sourceBytesRead:0, sourceFiles:{}, + stateReadCalls:0, stateBytesRead:0, otherReadCalls:0, otherBytesRead:0, + metadataCalls:0, writeCalls:0}); +const counterNames = ['priceAchievementModelBucket','normalizeAchievementMoneyModel', + 'calculateAchievementMicroUsd','claudeAchievementMoneyScopes','codexAchievementMoneyScopes', + 'claudeMoneyEvidenceFromModels','codexMoneyEvidenceFromModels']; +function instrumentation(root, compiled) { + let io = blankIo(), pricing = Object.fromEntries(counterNames.map(n => [n,0])); + const coverage = {}; + const within = value => typeof value === 'string' && + (value === root || value.startsWith(root + path.sep)); + const read = (file, bytes) => { + if (!within(file)) { io.otherReadCalls++; io.otherBytesRead += bytes; return; } + if (file.endsWith('.jsonl')) { + io.sourceReadCalls++; io.sourceBytesRead += bytes; + const key = path.relative(root,file); + io.sourceFiles[key] = (io.sourceFiles[key] ?? 0) + bytes; + } else { io.stateReadCalls++; io.stateBytesRead += bytes; } + }; + const originalOpen = fsp.open; + fsp.open = async (...args) => { + const handle = await originalOpen(...args), file = args[0]; + const originalRead = handle.read.bind(handle); + handle.read = async (...values) => { + const result = await originalRead(...values); read(file,result.bytesRead); return result; + }; + const originalReadFile = handle.readFile.bind(handle); + handle.readFile = async (...values) => { + const value = await originalReadFile(...values); + read(file,Buffer.byteLength(value)); return value; + }; + for (const method of ['write','writeFile']) { + const original = handle[method].bind(handle); + handle[method] = async (...values) => { if(within(file))io.writeCalls++;return original(...values); }; + } + return handle; + }; + const originalReadFile = fsp.readFile; + fsp.readFile = async (...args) => { + const value = await originalReadFile(...args);read(args[0],Buffer.byteLength(value));return value; + }; + const originalStream = fs.createReadStream; + fs.createReadStream = (...args) => { + const stream = originalStream(...args); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function*() { + for await(const value of iterate()) { read(args[0],value.length);yield value; } + }; + return stream; + }; + for(const method of ['stat','lstat','readdir']) { + const original = fsp[method]; + fsp[method] = async (...args) => {if(within(args[0]))io.metadataCalls++;return original(...args);}; + } + for(const method of ['writeFile','appendFile','rename','unlink','mkdir']) { + const original = fsp[method]; + fsp[method] = async (...args) => {if(within(args[0]))io.writeCalls++;return original(...args);}; + } + // Hooks cover exported reference-price computation and scopes. Calls internal + // to the same module are not separately counted; scope hooks cover that path. + for(const moduleName of ['achievementMoney','achievementMoneyScopes']) { + const modulePath = path.join(compiled,moduleName + '.js'); + if(!fs.existsSync(modulePath)) continue; + const exports = require(modulePath); + for(const name of counterNames) if(typeof exports[name] === 'function') { + coverage[name] = true; + const original = exports[name]; + exports[name] = (...args) => {pricing[name]++;return original(...args);}; + } + } + return {coverage, reset() {io=blankIo();pricing=Object.fromEntries(counterNames.map(n=>[n,0]));}, + snapshot() {return {io:structuredClone(io),pricing:{...pricing},pricingCoverage:{...coverage}, + uninstrumentedPricingWork:null};}}; +} +global.fetch = async () => {throw new Error('network prohibited in money-scope benchmark');}; + +if(!threads.isMainThread) { + const {root,compiled,entry,now} = threads.workerData; + assert.match(root,/^\/private\/tmp\/ccu-v242-money-scope-[A-Za-z0-9]+$/); + const trace = instrumentation(root,compiled); + require(path.join(path.dirname(compiled),'tests/ui/support/frozen-clock.cjs')).freezeClock(now); + const send = threads.parentPort.postMessage.bind(threads.parentPort); + const telemetry = value => send({[MARK]:value}); + threads.Worker = class extends RealWorker { + constructor(filename,options) { + super(__filename,{...options,workerData:{root,compiled,entry:filename,now}}); + telemetry({kind:'worker-start',id:this.threadId}); + this.once('exit',()=>telemetry({kind:'worker-exit',id:this.threadId})); + } + emit(event,...args) { + if(event==='message' && args[0]?.[MARK]) { + telemetry(args[0][MARK]); return true; + } + return super.emit(event,...args); + } + }; + threads.parentPort.postMessage = message => { + if(message?.type==='result' || (Number.isInteger(message?.sequence) && message?.outcome)) { + telemetry({kind:'trace',id:threads.threadId,...trace.snapshot(), + memory:process.memoryUsage(),messageBytes:serialize(message).byteLength}); + trace.reset(); + } + send(message); + }; + const sample = setInterval(()=>telemetry({kind:'memory',id:threads.threadId,heapUsed:process.memoryUsage().heapUsed, + external:process.memoryUsage().external}),50);sample.unref(); + require(entry); +} else { + (async()=>{ + const compiled = path.resolve(arg('--out','')); + const stage = path.dirname(compiled); + assert.match(stage,/^\/private\/tmp\/ccu-v242-perf-cohort-[A-Za-z0-9]+$/); + assert.equal(path.basename(compiled),'out'); + const manifest = JSON.parse(fs.readFileSync(path.join(stage,'cohort-manifest.json'),'utf8')); + assert.match(manifest.commit,/^[a-f0-9]{40}$/); + assert.notEqual(manifest.commit,'b2e4f72a84b73e1465bc09f68a8ecb5584d84c53', + 'money-scope benchmark is current-only'); + assert.equal(typeof global.gc,'function','use --expose-gc'); + const providerName = arg('--provider'), caseName = arg('--case'); + assert.ok(['claude','codex'].includes(providerName)); + const cases = {'low-one':[30000,1],'low-5000':[30000,5000], + 'dense-one':[160000,1],'dense-20':[160000,20]}; + assert.ok(Object.hasOwn(cases,caseName)); + const [rows,fileCount] = cases[caseName], totalBytes=153600000; + const now = Date.parse('2026-10-10T12:00:00Z'), timestamp = '2026-10-10T11:00:00.000Z'; + const limits={rssBytes:2*1024**3,totalWorkerHeapBytes:2*1024**3, + maxFileWorkers:6,warmHeapGrowthBytes:64*1024**2,warmExternalGrowthBytes:32*1024**2, + elapsedMs:480000,refreshMs:90000}; + const root = await fsp.mkdtemp('/private/tmp/ccu-v242-money-scope-'); + const source = path.join(root,'source'), state = path.join(root,'state'); + const files=[],phases=[],ownedWorkers=new Set(),workerMem=new Map(); + let peakRssBytes=process.memoryUsage().rss,peakWorkerHeapBytes=0,workerCount=0,maxFileWorkers=0; + let trace,workerTraces=[],messages=0,messageBytes=0,result,baselineMemory,afterWarmMemory; + let budgetFailure,workerMemorySamples=0,pendingPhase; + let retainedMoney,oldMoney,client,facade,index,restoreClock; + const started=performance.now(); + const report = {kind:'bounded-synthetic-production-money-scopes-not-native-Windows-or-OOM-proof', + runtime:process.version,execPath:process.execPath,platform:process.platform, + commit:manifest.commit,driverSha256:sha(fs.readFileSync(__filename)),provider:providerName, + case:caseName,rows,files:fileCount,fixtureBytes:totalBytes,moneyScope:true, + contentAnalysis:providerName==='claude',usageLimitTracking:false,weeklyQuotaPace:false, + limits,phases,scope:'cold,20 unchanged warm polls,append,one-file replacement,delete,final warm', + ioCoverage:'fs/promises readFile/open/FileHandle.read + async createReadStream; metadata counted separately', + uninstrumentedPricingWork:null}; + function sample() { + const memory=process.memoryUsage();peakRssBytes=Math.max(peakRssBytes,memory.rss); + const heap=[...workerMem.values()].reduce((sum,m)=>sum+m.heapUsed,0); + peakWorkerHeapBytes=Math.max(peakWorkerHeapBytes,heap); + assert.ok(memory.rsssetImmediate(r));global.gc();global.gc();sample();return process.memoryUsage();} + async function bounded(work) { + let timer;try{return await Promise.race([work,new Promise((_,reject)=>{ + timer=setTimeout(()=>reject(Error('bounded refresh timeout')),limits.refreshMs); + })]);}finally{clearTimeout(timer);} + } + function telemetry(value) { + if(value.kind==='trace') {workerTraces.push(value);workerMem.set(value.id,value.memory);workerMemorySamples++;} + else if(value.kind==='memory') {workerMem.set(value.id,value);workerMemorySamples++;} + else if(value.kind==='worker-start'){workerCount++;maxFileWorkers=Math.max(maxFileWorkers,workerCount); + assert.ok(workerCount<=limits.maxFileWorkers,'parser worker budget');} + else if(value.kind==='worker-exit'){workerCount--;workerMem.delete(value.id);} + } + function factory() { + const worker=new RealWorker(__filename,{workerData:{root,compiled,now, + entry:path.join(compiled,'providers/codex/codexIndexWorker.js')}}); + ownedWorkers.add(worker); + worker.on('message',message=>{ + if(message?.[MARK])telemetry(message[MARK]); + else{messages++;messageBytes+=serialize(message).byteLength;} + }); + const id=worker.threadId; + worker.once('exit',()=>{ownedWorkers.delete(worker);workerMem.delete(id);}); + // Controller samples and nested parser samples are reported separately in + // trace records. No absent worker sample is interpreted as observed zero. + return worker; + } + function claudeLine(id,padding=0) { + return JSON.stringify({type:'assistant',timestamp,requestId:'synthetic-request-'+id, + cwd:'/fixture/project',message:{id:'synthetic-message-'+id,role:'assistant', + model:'claude-opus-5-5',content:[{type:'text',text:'x'.repeat(padding)}], + usage:{input_tokens:10,output_tokens:1,cache_read_input_tokens:0, + cache_creation_input_tokens:0,cache_creation:{ephemeral_5m_input_tokens:0, + ephemeral_1h_input_tokens:0}}}})+'\n'; + } + function codexLine(sequence,padding=0) { + return JSON.stringify({timestamp,type:'event_msg',payload:{type:'token_count', + padding:'x'.repeat(padding),info:{total_token_usage:{input_tokens:sequence*10, + output_tokens:sequence,cached_input_tokens:0,reasoning_output_tokens:0}, + last_token_usage:{input_tokens:10,output_tokens:1,cached_input_tokens:0, + reasoning_output_tokens:0}}}})+'\n'; + } + function header(n) {return providerName==='claude'?'':[ + {timestamp,type:'session_meta',payload:{id:'synthetic-session-'+n,cwd:'/fixture/project'}}, + {timestamp,type:'turn_context',payload:{model:'gpt-6-sol',effort:'high'}}, + ].map(x=>JSON.stringify(x)+'\n').join('');} + const digest=createHash('sha256'); + let written=0,expectedRows=rows; + const restoredLoad=Module._load; + const sampler=setInterval(()=>{try{sample();}catch(error){budgetFailure??=error; + for(const worker of ownedWorkers)void worker.terminate();}},50); + try { + const directory=path.join(source,providerName==='claude'?'projects/-fixture':'sessions'); + await fsp.mkdir(directory,{recursive:true});await fsp.mkdir(state,{recursive:true}); + const perFileRows=rows/fileCount; + assert.ok(Number.isInteger(perFileRows));assert.ok(Number.isInteger(totalBytes/fileCount)); + for(let n=0;n=0,'line-density budget'); + const line=providerName==='claude'?claudeLine(id,pad):codexLine(k+1,pad); + assert.equal(Buffer.byteLength(line),target);body+=line; + } + await fsp.appendFile(file,body);digest.update(body);written+=Buffer.byteLength(body); + } + } + assert.equal(written,totalBytes);report.fixtureSha256=digest.digest('hex'); + trace=instrumentation(root,compiled); + restoreClock=require(path.join(stage,'tests/ui/support/frozen-clock.cjs')).freezeClock(now); + Module._load=function(name,parent,isMain){return name==='vscode'?{ + workspace:{workspaceFolders:[]},Uri:{file:fsPath=>({fsPath})},env:{language:'en'}, + }:restoredLoad.call(this,name,parent,isMain);}; + const {I18n}=require(path.join(compiled,'i18n.js'));I18n.setTimezone('UTC');I18n.setLanguage('en'); + const ci=require(path.join(compiled,'claudeIncrementalIndex.js')); + const {CodexIndexClient}=require(path.join(compiled,'providers/codex/codexIndexClient.js')); + const {CodexProvider}=require(path.join(compiled,'providers/codex/codexProvider.js')); + const {ClaudeCodeUsageExtension}=require(path.join(compiled,'extension.js')); + const {createEmptyQuotaObservationStore}=require(path.join(compiled,'quotaObservationStore.js')); + const host=Object.create(ClaudeCodeUsageExtension.prototype); + Object.assign(host,{getConfiguration:()=>({enableAchievements:true,codexDataDirectory:source, + timezone:'UTC',pricingBackend:'anthropic',dataDirectory:source,dashboardAutoRefresh:false, + usageLimitTracking:false,showWeeklyQuotaPace:false}), + selectCodexSnapshotHome:()=>undefined,recordCodexQuotaObservations:async()=>undefined, + quotaObservationStore:createEmptyQuotaObservationStore(),quotaFingerprintSalt:'synthetic-money-scope', + codexView:null,codexInsights:{},settings:{get:()=>false}}); + index=ci.createClaudeUsageIndex(); + client=new CodexIndexClient(factory); + facade=new CodexProvider({enabled:true,codexHome:source,indexPath:path.join(state,'index.json'), + salt:'synthetic-money-scope',timeZone:'UTC',allowAchievementHourProof:true},()=>client); + async function pass(label,reads,changedFiles) { + trace.reset();workerTraces=[];messages=0;messageBytes=0; + pendingPhase={label}; + const phaseStart=performance.now(); + let diagnostics,money,projectionIo; + const ioDifference=(before,after)=>Object.fromEntries(Object.keys(before) + .filter(key=>key!=='sourceFiles').map(key=>[key,after[key]-before[key]])); + if(providerName==='claude'){ + result=await bounded(ci.updateClaudeUsageIndex(index,source,{analyzeContent:true})); + index=result.index;diagnostics=result.diagnostics; + assert.equal(diagnostics.filesFailed,0);assert.equal(result.records.length,expectedRows); + if(expectedRows>0)assert.equal(result.contentAnalysis?.calibration?.realOutputTokens,expectedRows); + const beforeProjection=trace.snapshot().io; + const view=ci.claudeUsageDashboardSnapshot(index,{now:new Date(now), + projectGroupingMode:'flat',achievementMoneyBackend:'anthropic'}); + projectionIo=ioDifference(beforeProjection,trace.snapshot().io); + money=view.achievementMoney; + assert.equal(view.allTime.totalOutputTokens,expectedRows); + }else{ + result=await bounded(facade.refresh('foreground')); + assert.equal(result.outcome,'success');assert.ok(result.snapshot); + assert.equal(result.snapshot.coverage.complete,true);assert.equal(result.diagnostic?.migrationPending,false); + assert.equal(result.snapshot.total.inputTotal,expectedRows*10); + diagnostics=result.diagnostic; + const beforeProjection=trace.snapshot().io; + host.applyCodexSnapshot(result.snapshot);money=host.codexAchievementSignals.money; + projectionIo=ioDifference(beforeProjection,trace.snapshot().io); + } + assert.ok(money,'actual moneyScope ON must produce proof'); + assert.equal(money.cumulative?.status,expectedRows>0?'complete':'empty'); + assert.equal(money.cumulative.totalTokens,expectedRows*11); + const observed=trace.snapshot(); + for(const wt of workerTraces) { + for(const key of Object.keys(observed.io))if(key!=='sourceFiles')observed.io[key]+=wt.io[key]; + for(const [file,bytes]of Object.entries(wt.io.sourceFiles)) + observed.io.sourceFiles[file]=(observed.io.sourceFiles[file]??0)+bytes; + for(const key of counterNames)observed.pricing[key]+=wt.pricing[key]; + } + pendingPhase={label,diagnostics,observed,projectionIo}; + assert.equal(typeof diagnostics?.bodyReads,'number','missing bodyReads is unknown'); + assert.equal(observed.pricingCoverage.claudeAchievementMoneyScopes,true,'missing pricing hook is unknown'); + assert.equal(observed.pricingCoverage.codexAchievementMoneyScopes,true,'missing pricing hook is unknown'); + if(reads!==undefined)assert.equal(diagnostics.bodyReads,reads); + if(reads===0){ + assert.equal(observed.io.sourceReadCalls,0);assert.equal(observed.io.sourceBytesRead,0); + assert.equal(observed.io.otherReadCalls,0,'untracked extra reads must be investigated'); + } + if(/^(warm-|after-append-warm|final-warm)/.test(label)){ + assert.ok(counterNames.every(key=>observed.pricing[key]===0),'unchanged pricing/scopes work'); + assert.equal(money,retainedMoney,'unchanged money proof identity'); + assert.ok(Object.values(projectionIo).every(value=>value===0),'money projection adds no I/O'); + } + if(changedFiles){ + const allowed=new Set(changedFiles.map(file=>path.relative(root,file))); + assert.ok(Object.keys(observed.io.sourceFiles).every(file=>allowed.has(file)), + 'changed source read must stay inside affected files'); + } + const refreshAndProjectionMs=round2(performance.now()-phaseStart); + const memory=await settled(); + phases.push({label,elapsedMs:round2(performance.now()-phaseStart),refreshAndProjectionMs, + emptyCorpusVerified:expectedRows===0,diagnosticBodyReads:diagnostics.bodyReads, + diagnosticBytesRead:typeof diagnostics.bytesRead==='number'?diagnostics.bytesRead:null, + io:observed.io,projectionIo,pricing:observed.pricing,pricingCoverage:observed.pricingCoverage, + uninstrumentedPricingWork:null,memory,moneyDtoBytes:Buffer.byteLength(JSON.stringify(money)), + moneyMicroUsd:money.cumulative.microUsd,messageCount:messages,messageBytes, + liveControllers:ownedWorkers.size,workerSamples:workerTraces.map(w=>w.memory)}); + sample();if(budgetFailure)throw budgetFailure; + assert.equal(ownedWorkers.size,0,'production client must retire controllers before terminal refresh'); + retainedMoney=money;result=null; + pendingPhase=undefined; + process.stderr.write(JSON.stringify({case:caseName,provider:providerName,label, + ms:phases.at(-1).elapsedMs,bodyReads:diagnostics.bodyReads, + sourceBytesRead:observed.io.sourceBytesRead})+'\n'); + } + await pass('cold',fileCount,files);baselineMemory=await settled(); + for(let n=0;n<20;n++)await pass('warm-'+n,0,[]); + afterWarmMemory=await settled();oldMoney=retainedMoney; + const oldJson=JSON.stringify(oldMoney); + const tail=providerName==='claude'?claudeLine(rows):codexLine(perFileRows+1); + await fsp.appendFile(files.at(-1),tail);expectedRows++; + await pass('append',1,[files.at(-1)]); + assert.notEqual(retainedMoney,oldMoney);assert.equal(JSON.stringify(oldMoney),oldJson); + await pass('after-append-warm',0,[]); + // Content-analysis ordered ownership may rebuild on unsafe Claude edits. + // Record that established contract explicitly; do not assert fake locality. + await fsp.writeFile(files[0],header(0)+(providerName==='claude'?claudeLine(rows+1):codexLine(1))); + expectedRows-=perFileRows-1+(fileCount===1?1:0); + await pass('replace-one',providerName==='claude'?undefined:1, + providerName==='claude'?undefined:[files[0]]); + await fsp.unlink(files[0]);expectedRows--; + await pass('delete-one',providerName==='claude'?undefined:0, + providerName==='claude'?undefined:[]); + // Empty final corpus legitimately has empty money, unlike unknown evidence. + if(expectedRows>0)await pass('final-warm',0,[]); + report.claudeUnsafeEditCanRebuildOrderedContent=providerName==='claude'; + report.baselineMemory=baselineMemory;report.afterWarmMemory=afterWarmMemory; + report.warmHeapGrowthBytes=afterWarmMemory.heapUsed-baselineMemory.heapUsed; + report.warmExternalGrowthBytes=afterWarmMemory.external-baselineMemory.external; + assert.ok(report.warmHeapGrowthBytes<=Math.max(limits.warmHeapGrowthBytes,baselineMemory.heapUsed/10)); + assert.ok(report.warmExternalGrowthBytes<=limits.warmExternalGrowthBytes); + report.peakSampledRssBytes=peakRssBytes; + report.peakSampledWorkerHeapBytes=workerMemorySamples?peakWorkerHeapBytes:null; + report.workerMemorySamples=workerMemorySamples; + report.workerPeakMeasurement='50ms plus terminal samples; no claim of exact instantaneous worker peak'; + report.maxObservedFileWorkers=maxFileWorkers;report.elapsedMs=round2(performance.now()-started); + report.passed=true; + process.stdout.write(JSON.stringify(report,null,2)+'\n'); + } catch(error) { + report.passed=false;report.failure=error.stack;report.elapsedMs=round2(performance.now()-started); + report.failedPhase=pendingPhase; + process.stdout.write(JSON.stringify(report,null,2)+'\n');throw error; + } finally { + clearInterval(sampler); + Module._load=restoredLoad; + await facade?.dispose();await client?.dispose(); + await Promise.allSettled([...ownedWorkers].map(worker=>worker.terminate())); + restoreClock?.();index=undefined;result=null; + assert.match(root,/^\/private\/tmp\/ccu-v242-money-scope-[A-Za-z0-9]+$/); + await fsp.rm(root,{recursive:true,force:true}); + } + })().catch(error=>{process.stderr.write(error.stack+'\n');process.exitCode=1;}); +} diff --git a/tests/ui/achievement-calendar-subset.spec.mjs b/tests/ui/achievement-calendar-subset.spec.mjs new file mode 100644 index 00000000..ad861b88 --- /dev/null +++ b/tests/ui/achievement-calendar-subset.spec.mjs @@ -0,0 +1,37 @@ +import AxeBuilder from '@axe-core/playwright'; +import {test,expect,openCodex} from './support/app.mjs'; +for (const locale of ['en','de-DE','zh-TW','zh-CN','ja','ko','pt-BR','id']) { + for (const theme of ['light','dark','highContrast']) { + test(`${locale} ${theme} calendar bounds preserve unknown means, disclosure and local restoration`,async({page},testInfo)=>{ + await openCodex(page,{locale,theme,fixture:'achievement-calendar-subset',width:locale==='en'?1280:360}); + await page.locator('#tab-achievements').click(); + const card=key=>page.locator(`.achievement-card[data-achievement-key="${key}"]`); + for(const [key,text] of [['sol-days',/≥4/],['weekly-volume',/≥500M/],['codex-api-week',/≥\$1[,.]000/]]) { + await expect(card(key)).toHaveAttribute('data-achievement-proof','ready'); + await expect(card(key).locator('.achievement-next-target')).toContainText(text); + } + await expect(card('weekly-average')).toHaveAttribute('data-achievement-proof','pending'); + // Existing cumulative volume admits one maximal counter per identity; + // dates/money require the separately sound root. Preserve these scopes. + await expect(card('cumulative-volume').locator('.achievement-next-target')).toContainText('≥1B'); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await card('sol-days').locator('summary').focus(); + await page.keyboard.press('Enter'); + await expect(card('sol-days').locator('details')).toHaveAttribute('open'); + await page.reload(); + await expect(card('sol-days').locator('details')).toHaveAttribute('open'); + await expect(card('sol-days')).toHaveAttribute('data-achievement-proof','ready'); + await page.setViewportSize({width:320,height:900}); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + const violations=(await new AxeBuilder({page}).include('.achievement-collection').analyze()).violations; + expect(violations).toEqual([]); + expect((await page.evaluate(()=>window.__ccuPostedMessages)).filter(m=> + /refresh|updateSetting|previewAchievementShare|exportAchievementShare|setAchievementPinned/.test(m.command))).toEqual([]); + if(locale==='zh-CN'&&theme==='dark') { + await page.setViewportSize({width:1280,height:900}); + await card('codex-api-week').scrollIntoViewIfNeeded(); + await page.screenshot({path:testInfo.outputPath('calendar-lower-bounds.png'),animations:'disabled'}); + } + }); + } +} diff --git a/tests/ui/achievement-compact-levels.spec.mjs b/tests/ui/achievement-compact-levels.spec.mjs new file mode 100644 index 00000000..0d73fcc0 --- /dev/null +++ b/tests/ui/achievement-compact-levels.spec.mjs @@ -0,0 +1,276 @@ +import { createRequire } from 'node:module'; +import { test, expect, openClaude, openCodex } from './support/app.mjs'; + +const require = createRequire(import.meta.url); +const { I18n, achievementCopyFor } = require('../../out/i18n.js'); +const locales = ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']; +const themes = ['light', 'dark', 'highContrast']; +const ranks = ['bronze', 'silver', 'gold', 'platinum', 'legendary']; +const rowSelector = 'ol.achievement-level-rules > li'; + +async function openHistory(page, options = {}) { + await openClaude(page, { fixture: 'achievement-management-history', claudeOnly: true, ...options }); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-key="sonnet-main"]'); + await card.locator('summary').focus(); + await card.locator('summary').press('Enter'); + return card; +} + +async function expectNoImplicitActions(page) { + const commands = await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command)); + expect(commands.filter(command => + /^(refresh|recalibrateAchievements|updateSetting|setAchievementPinned|editAchievementShowcase|achievementNoticeAck|markAchievementGroupRead|previewAchievementShare|exportAchievementShare|restoreAchievementShare)$/.test(command) + || /grant/i.test(command))).toEqual([]); +} + +async function expectCompactGeometry(page, card) { + const rows = card.locator(rowSelector); + await expect(rows).toHaveCount(5); + expect(await rows.locator(':scope > strong.achievement-level-name').evaluateAll(nodes => + nodes.map(node => node.dataset.rank))).toEqual(ranks); + await expect(rows.locator(':scope > span.achievement-level-value')).toHaveCount(5); + const geometry = await rows.evaluateAll(nodes => nodes.map((row, index) => { + const name = row.querySelector(':scope > strong.achievement-level-name'); + const value = row.querySelector(':scope > span.achievement-level-value'); + const box = row.getBoundingClientRect(); + const textBox = node => { + const range = document.createRange(); + range.selectNodeContents(node); + const rects = [...range.getClientRects()]; + return { top: rects[0].top, bottom: rects.at(-1).bottom, + left: Math.min(...rects.map(rect => rect.left)), right: Math.max(...rects.map(rect => rect.right)), + lines: new Set(rects.map(rect => Math.round(rect.top))).size }; + }; + // Zero-size inline probes measure the actual font baseline, not just the + // outer grid-cell boxes (which can align while their text is stacked). + const baseline = node => { + const probe = document.createElement('span'); + probe.style.cssText = 'display:inline-block;width:0;height:0;padding:0;margin:0;border:0;vertical-align:baseline'; + node.append(probe); + const y = probe.getBoundingClientRect().top; + probe.remove(); + return y; + }; + const style = getComputedStyle(row); + return { name: textBox(name), value: textBox(value), baselineDelta: Math.abs(baseline(name) - baseline(value)), + right: box.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth), + weight: Number(getComputedStyle(value).fontWeight), + separator: index === 0 || (parseFloat(style.borderTopWidth) >= 1 && style.borderTopStyle !== 'none' + && style.borderTopColor !== 'transparent' && style.borderTopColor !== 'rgba(0, 0, 0, 0)'), + gap: index === 0 ? null : box.top - nodes[index - 1].getBoundingClientRect().bottom, + overflow: row.scrollWidth > row.clientWidth + 1 }; + })); + for (const [index, row] of geometry.entries()) { + expect(row.name.lines, ranks[index] + ' name wraps').toBe(1); + expect(row.value.lines, ranks[index] + ' threshold wraps').toBe(1); + expect(row.baselineDelta, ranks[index] + ' baseline').toBeLessThanOrEqual(1.5); + expect(row.name.right + 4, ranks[index] + ' name/value separation').toBeLessThanOrEqual(row.value.left); + expect(Math.abs(row.value.right - row.right), ranks[index] + ' right alignment').toBeLessThanOrEqual(2); + expect(row.weight, ranks[index] + ' bold threshold').toBeGreaterThanOrEqual(600); + expect(row.separator, ranks[index] + ' visible row separator').toBe(true); + if (index) expect(row.gap, ranks[index] + ' row separation').toBeGreaterThanOrEqual(6); + expect(row.overflow).toBe(false); + } + expect(Math.max(...geometry.map(row => row.value.right)) - Math.min(...geometry.map(row => row.value.right))) + .toBeLessThanOrEqual(2); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); +} + +async function expectSingleNext(card, index) { + const rows = card.locator(rowSelector); + await expect(rows.locator('small.achievement-level-status[data-level-status="next"]')).toHaveCount(1); + await expect(rows.nth(index).locator('small.achievement-level-status[data-level-status="next"]')).toBeVisible(); + for (let i = index + 1; i < await rows.count(); i++) { + await expect(rows.nth(i).locator('.achievement-level-status')).toHaveCount(0); + } +} + +// Cover every locale/theme, rotating width rather than multiplying by all +// three widths. Extra color and state cases below remain locale-independent. +for (const [localeIndex, locale] of locales.entries()) for (const [themeIndex, theme] of themes.entries()) { + const width = [320, 360, 1280][(localeIndex + themeIndex) % 3]; + test(`${locale} ${theme} ${width}: compact ranks align and verified dates avoid repeated prefixes`, async ({ page }) => { + I18n.setLanguage(locale); + const management = I18n.achievementManagement; + const card = await openHistory(page, { locale, theme, width }); + await expectCompactGeometry(page, card); + await expect(card.locator(rowSelector).locator(':scope > strong.achievement-level-name')) + .toHaveText(achievementCopyFor(locale).levels); + await expectSingleNext(card, 2); + for (const [id, day] of [['sonnet-main', '2026-06-01'], ['sonnet-main-silver', '2026-06-03']]) { + const row = card.locator(`[data-achievement-level="${id}"]`); + const date = row.locator('[data-date-source="retained-logs"]'); + await expect(date).toHaveText(day); + await expect(date.locator('time')).toHaveAttribute('datetime', day); + const accessibleSource = await date.evaluate(node => [node, ...node.querySelectorAll('time')] + .flatMap(el => [el.getAttribute('title') || '', el.getAttribute('aria-label') || '']).join(' ')); + expect(accessibleSource).toContain(management.verifiedDay); + await expect(row).not.toContainText(management.verifiedDay); + await expect(row.locator('[data-date-source="first-recorded"]')).toHaveCount(0); + } + const evidence = page.locator('.achievement-evidence-note'); + await expect(evidence).not.toHaveAttribute('open'); + await evidence.locator('summary').click(); + await expect(evidence.locator('.achievement-date-note')).toHaveText(management.verifiedDay + ': ' + management.dateNote); + await expectNoImplicitActions(page); + }); +} + +for (const theme of themes) { + test(`${theme}: resolved browser rank colors are readable and normal themes distinguish five ranks`, async ({ page }, info) => { + const card = await openHistory(page, { theme, width: 360 }); + // Keep the same registered nullable tokens as the existing fidelity guard. + const missing = await page.evaluate(() => { + const css = [...document.querySelectorAll('head style:not(#test-vscode-theme)')].map(el => el.textContent).join('\n'); + const definitions = new Map([...document.querySelector('#test-vscode-theme').textContent + .matchAll(/(--vscode-[\w-]+)\s*:\s*([^;{}]*);/g)].map(match => [match[1], match[2].trim()])); + const nullable = new Set(['--vscode-input-border', '--vscode-textBlockQuote-background', '--vscode-toolbar-hoverBackground', + '--vscode-button-secondaryBackground', '--vscode-button-secondaryHoverBackground', '--vscode-charts-orange', + '--vscode-list-inactiveSelectionBackground', '--vscode-widget-shadow']); + const computed = getComputedStyle(document.documentElement); + return [...new Set([...css.matchAll(/var\(\s*(--vscode-[\w-]+)/g)].map(match => match[1]))] + .filter(name => !definitions.has(name) || (!computed.getPropertyValue(name).trim() + && !(nullable.has(name) && definitions.get(name) === 'initial'))); + }); + expect(missing).toEqual([]); + const names = card.locator(rowSelector).locator(':scope > strong.achievement-level-name'); + await expect(names).toHaveCount(5); + const colors = await names.evaluateAll(nodes => { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d', { willReadFrequently: true }); + const rgba = css => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = css; context.fillRect(0, 0, 1, 1); + return [...context.getImageData(0, 0, 1, 1).data].map((v, i) => i === 3 ? v / 255 : v); + }; + const over = (front, back) => front.slice(0, 3).map((v, i) => v * front[3] + back[i] * (1 - front[3])); + const luminance = rgb => rgb.map(v => v / 255).map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4) + .reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0); + return nodes.map(node => { + const ancestors = []; for (let el = node; el; el = el.parentElement) ancestors.unshift(el); + // Start at Chromium's white canvas, then composite every actual ancestor + // background. Canvas resolves color(srgb ...), color-mix and alpha. + let background = [255, 255, 255]; + const unsupported = []; + for (const el of ancestors) { + const style = getComputedStyle(el); + if (style.backgroundImage !== 'none' || Number(style.opacity) !== 1) unsupported.push(el.className || el.tagName); + background = over(rgba(style.backgroundColor), background); + } + const foreground = over(rgba(getComputedStyle(node).color), background); + const a = luminance(foreground), b = luminance(background); + return { rank: node.dataset.rank, foreground, background, unsupported, + contrast: (Math.max(a, b) + .05) / (Math.min(a, b) + .05) }; + }); + }); + await info.attach('resolved-rank-colors', { body: JSON.stringify(colors, null, 2), contentType: 'application/json' }); + for (const color of colors) { + expect(color.unsupported, 'Do not silently approximate gradients or group opacity').toEqual([]); + expect(color.contrast, `${theme} ${color.rank} normal text contrast`).toBeGreaterThanOrEqual(4.5); + } + if (theme !== 'highContrast') { + expect(new Set(colors.map(color => color.foreground.map(Math.round).join(','))).size).toBe(5); + } + }); +} + +test('first-recorded-only dates retain their visible provenance', async ({ page }) => { + await openClaude(page, { fixture: 'achievement-records', locale: 'en', claudeOnly: true, width: 320 }); + await page.locator('#tab-achievements').click(); + const row = page.locator('[data-achievement-level="settings-explorer-bronze"]'); + await row.locator('xpath=ancestor::details').locator('summary').click(); + await expect(row.locator('[data-date-source="retained-logs"]')).toHaveCount(0); + await expect(row.locator('[data-date-source="first-recorded"]')).toHaveText('First recorded: 2026-07-20'); + await expect(row.locator('time')).toHaveAttribute('datetime', '2026-07-20'); + await expect(row).not.toContainText('Earliest verified date'); + await expectNoImplicitActions(page); +}); + +test('verified dates take visual priority while first-recorded provenance remains accessible', async ({ page }) => { + const card = await openHistory(page, { fixture: 'achievement-management-history-recorded', locale: 'en', width: 320 }); + for (const [id, verified] of [['sonnet-main', '2026-06-01'], ['sonnet-main-silver', '2026-06-03']]) { + const row = card.locator(`[data-achievement-level="${id}"]`); + const date = row.locator('[data-date-source="retained-logs"]'); + await expect(row.locator('time')).toHaveCount(1); + await expect(date).toHaveText(verified); + await expect(date.locator('time')).toHaveAttribute('datetime', verified); + await expect(date).toHaveAttribute('title', /Earliest verified date: 2026-06-0[13]/); + await expect(date).toHaveAttribute('title', /First recorded: 2026-07-20/); + await expect(date.locator('time')).toHaveAttribute('aria-label', /First recorded: 2026-07-20/); + await expect(row).not.toContainText('First recorded'); + await expect(row).not.toContainText('2026-07-20'); + await expect(row.locator('[data-date-source="first-recorded"]')).toHaveCount(0); + } + await expectNoImplicitActions(page); +}); + +test('default and retained-award fixtures target only the rank above the highest earned', async ({ page }) => { + for (const [fixture, key, next] of [['default', 'sonnet-main', 0], ['achievement-review-next-rank', 'cumulative-volume', 1]]) { + await openClaude(page, { fixture, locale: 'en', claudeOnly: true, width: 360 }); + await page.locator('#tab-achievements').click(); + const card = page.locator(`[data-achievement-key="${key}"]`); + await expect(card).toHaveAttribute('data-achievement-proof', 'ready'); + await card.locator('summary').click(); + await expectSingleNext(card, next); + await expectNoImplicitActions(page); + } +}); + +test('full earned ranks retain thresholds and never advertise a next rank', async ({ page }) => { + const full = await openHistory(page, { fixture: 'achievement-management-full' }); + await expect(full).toHaveAttribute('data-achievement-tier', 'legendary'); + await expect(full.locator('.achievement-level-status[data-level-status="next"]')).toHaveCount(0); + await expect(full.locator('.achievement-current-value')).toContainText('60B'); + await expect(full.locator(rowSelector).locator(':scope > span.achievement-level-value')) + .toHaveText(['100M', '1B', '10B', '20B', '50B']); + await expectNoImplicitActions(page); +}); + +test('pending and stale proof never advertise a next rank', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-proof-stale', width: 320 }); + await page.locator('#tab-achievements').click(); + const cards = page.locator('.achievement-card[data-achievement-proof]:not([data-achievement-proof="ready"])'); + expect(await cards.count()).toBeGreaterThan(0); + expect(await page.locator('.achievement-card[data-achievement-proof="stale"]').count()).toBeGreaterThan(0); + expect(await page.locator('.achievement-card[data-achievement-proof="pending"]').count()).toBeGreaterThan(0); + await page.locator('#achievementExpandAll').click(); + await expect(cards.locator('.achievement-level-status[data-level-status="next"]')).toHaveCount(0); + await expect(cards.locator('.achievement-level-status').filter({ hasText: /^In progress$/ })).toHaveCount(0); + await expectNoImplicitActions(page); +}); + +for (const theme of ['light', 'dark']) { + test(`${theme}: expanded compact detail survives host update and reload without host work`, async ({ page }) => { + const card = await openHistory(page, { theme, width: 360 }); + const summary = card.locator('summary'); + const before = await card.locator(rowSelector).allTextContents(); + const proof = await card.getAttribute('data-achievement-proof'); + await summary.focus(); + const scrollBefore = await page.evaluate(() => scrollY); + const response = await page.context().request.get(page.url()); + expect(response.ok()).toBe(true); + await page.evaluate(html => { + const panel = new DOMParser().parseFromString(html, 'text/html').getElementById('provider-panel'); + dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', provider: 'claude', + tab: 'achievements', revision: 83, html: panel.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + }, await response.text()); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.some(row => + row.command === 'dashboardDataPatchAck' && row.revision === 83 && row.ok))).toBe(true); + await page.clock.runFor(200); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(summary).toBeFocused(); + expect(Math.abs(await page.evaluate(() => scrollY) - scrollBefore)).toBeLessThanOrEqual(2); + expect(await card.locator(rowSelector).allTextContents()).toEqual(before); + await expect(card).toHaveAttribute('data-achievement-proof', proof); + await expectNoImplicitActions(page); // Check before reload clears the message collector. + await page.reload(); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await expect(card.locator('details')).toHaveAttribute('open'); + expect(await card.locator(rowSelector).allTextContents()).toEqual(before); + await expectCompactGeometry(page, card); + await expectSingleNext(card, 2); + await expectNoImplicitActions(page); + }); +} diff --git a/tests/ui/achievement-compare-refresh.spec.mjs b/tests/ui/achievement-compare-refresh.spec.mjs new file mode 100644 index 00000000..5c76ad41 --- /dev/null +++ b/tests/ui/achievement-compare-refresh.spec.mjs @@ -0,0 +1,136 @@ +import { test, expect, openCompare } from './support/app.mjs'; + +const deliberateShareCommands = ['previewAchievementShare', 'exportAchievementShare']; + +test('Compare aggregate patches preserve badge choices, template, focus, and scroll without share actions', async ({ page }) => { + await openCompare(page, { + fixture: 'combined-heatmap', commandTemplate: 'achievements', width: 900, height: 420, + }); + const choice = page.locator('.achievement-share-choice').first(); + await choice.check(); + const selectedId = await choice.inputValue(); + await expect.poll(() => page.evaluate(() => + window.__ccuPostedMessages.some((message) => message.command === 'previewAchievementShare'), + )).toBe(true); + + // Fresh server render supplies unchecked badges. The browser fixture has no + // extension-host bridge, so hydration requests are observed separately. + const response = await page.context().request.get(page.url()); + expect(response.ok()).toBe(true); + const documentText = await response.text(); + const before = await page.evaluate(({ documentText, selectedId }) => { + const panel = document.getElementById('provider-panel'); + const next = new DOMParser().parseFromString(documentText, 'text/html') + .getElementById('provider-panel'); + if (!panel || !next) throw new Error('Compare fixture must provide both panel renders'); + if (!next.querySelector(`.achievement-share-choice[value="${selectedId}"]`)) { + throw new Error('The fresh render must contain the same earned badge'); + } + next.querySelectorAll('.achievement-share-choice').forEach((input) => { + input.checked = false; + input.removeAttribute('checked'); + }); + // Model stale server defaults while retaining the earned-badge controls. + // Hydration must reapply the saved presentation without firing change events. + next.querySelectorAll('#sharingTemplate option').forEach((option) => { + option.toggleAttribute('selected', option.value === 'combinedHeatmap'); + }); + next.querySelectorAll('[data-sharing-presentation]').forEach((section) => { + section.hidden = section.getAttribute('data-sharing-presentation') !== 'combinedHeatmap'; + }); + const addGeometry = (root) => { + const filler = document.createElement('div'); + filler.style.height = '1200px'; + root.append(filler); + const preview = root.querySelector('#achievementSharePreview'); + preview.style.cssText = 'display:block;height:120px;overflow:auto;'; + const content = document.createElement('div'); + content.style.cssText = 'width:1800px;height:400px;'; + preview.append(content); + }; + addGeometry(panel); + addGeometry(next); + next.querySelector('.usage-summary').setAttribute('data-compare-refresh-fixture', 'fresh'); + const preview = document.getElementById('achievementSharePreview'); + preview.focus({ preventScroll: true }); + preview.scrollLeft = 81; + preview.scrollTop = 29; + window.scrollTo(0, 180); + window.dispatchEvent(new Event('scroll')); + window.__compareRefreshDocument = document.documentElement; + window.__compareRefreshPanel = panel; + const snapshot = { + scrollY, scrollLeft: preview.scrollLeft, scrollTop: preview.scrollTop, + template: localStorage.getItem('ccu.sharing.template'), + badges: localStorage.getItem('ccu.sharing.achievementBadges'), + }; + window.__ccuPostedMessages = []; + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'compare', tab: 'all', revision: 1, + html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {}, + } })); + return snapshot; + }, { documentText, selectedId }); + expect(before.template).toBe('achievements'); + expect(JSON.parse(before.badges)).toEqual([selectedId]); + expect(before.scrollY).toBeGreaterThan(0); + expect(before.scrollLeft).toBeGreaterThan(0); + expect(before.scrollTop).toBeGreaterThan(0); + + await expect.poll(() => page.evaluate(() => + window.__ccuPostedMessages.filter((message) => message.command === 'dashboardDataPatchAck'), + )).toEqual([{ command: 'dashboardDataPatchAck', revision: 1, ok: true }]); + await expect(page.locator('.usage-summary[data-compare-refresh-fixture="fresh"]')).toHaveCount(1); + await expect(page.locator('#provider-tab-compare')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#sharingTemplate')).toHaveValue('achievements'); + await expect(page.locator('.achievement-share-presentation')).toBeVisible(); + await expect(page.locator('.achievement-share-choice:checked')).toHaveCount(1); + await expect(page.locator(`.achievement-share-choice[value="${selectedId}"]`)).toBeChecked(); + await expect(page.locator('#achievementShareCount')).toHaveText('1/9'); + await expect.poll(() => page.evaluate(() => { + const preview = document.getElementById('achievementSharePreview'); + return { scrollY, scrollLeft: preview.scrollLeft, scrollTop: preview.scrollTop, + focused: document.activeElement?.id }; + })).toEqual({ scrollY: before.scrollY, scrollLeft: before.scrollLeft, + scrollTop: before.scrollTop, focused: 'achievementSharePreview' }); + expect(await page.evaluate(() => + document.documentElement === window.__compareRefreshDocument && + document.getElementById('provider-panel') === window.__compareRefreshPanel, + )).toBe(true); + const posted = await page.evaluate(() => window.__ccuPostedMessages); + expect(posted.filter((message) => deliberateShareCommands.includes(message.command))).toEqual([]); + expect(await page.evaluate(() => ({ + template: localStorage.getItem('ccu.sharing.template'), + badges: localStorage.getItem('ccu.sharing.achievementBadges'), + }))).toEqual({ template: before.template, badges: before.badges }); +}); + +test('Compare rejects wrong-provider and script-bearing patches without touching sharing controls', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + const choice = page.locator('.achievement-share-choice').first(); + await choice.check(); + const selectedId = await choice.inputValue(); + const result = await page.evaluate(() => { + const panel = document.getElementById('provider-panel'); + const html = panel.innerHTML; + const first = panel.firstElementChild; + window.__ccuPostedMessages = []; + const outcomes = [ + { provider: 'claude', html }, + { provider: 'compare', html: '' }, + ].map((payload, index) => ccuApplyDashboardDataPatch({ + ...payload, command: 'dashboardDataPatch', tab: 'all', revision: index + 1, + claudeLast30HoursByDay: {}, adviceSnapshotIds: {}, + })); + return { outcomes, unchanged: panel.firstElementChild === first, + scriptRan: window.__compareUnsafeScript === true, messages: window.__ccuPostedMessages }; + }); + expect(result.outcomes).toEqual([false, false]); + expect(result.unchanged).toBe(true); + expect(result.scriptRan).toBe(false); + expect(result.messages.filter((message) => message.command === 'dashboardDataPatchAck')) + .toEqual([1, 2].map((revision) => ({ command: 'dashboardDataPatchAck', revision, ok: false }))); + expect(result.messages.filter((message) => deliberateShareCommands.includes(message.command))).toEqual([]); + await expect(page.locator(`.achievement-share-choice[value="${selectedId}"]`)).toBeChecked(); + await expect(page.locator('#sharingTemplate')).toHaveValue('achievements'); +}); diff --git a/tests/ui/achievement-detail-readability.spec.mjs b/tests/ui/achievement-detail-readability.spec.mjs new file mode 100644 index 00000000..eb9177a5 --- /dev/null +++ b/tests/ui/achievement-detail-readability.spec.mjs @@ -0,0 +1,52 @@ +import {test,expect,openClaude,openCodex} from './support/app.mjs'; +import AxeBuilder from '@axe-core/playwright'; + +for(const locale of ['en','zh-CN'])for(const theme of ['light','dark','highContrast']){ + test(`${locale} ${theme}: shared money reference and separated rank rules survive reload`,async({page},info)=>{ + await openClaude(page,{locale,theme,fixture:'achievement-management-full',claudeOnly:true,width:1280}); + await page.locator('#tab-achievements').click(); + const card=page.locator('[data-achievement-key="claude-api-total"]'); + await card.locator('summary').click(); + await expect(card).not.toContainText('api-reference-'); + const rows=card.locator('.achievement-level-rules > li'); + expect(await rows.count()).toBeGreaterThanOrEqual(3); + const separated=await rows.evaluateAll(rows=>rows.every((row,index)=>!index|| + row.getBoundingClientRect().top-rows[index-1].getBoundingClientRect().bottom>=8)); + expect(separated).toBe(true); + await card.locator('[data-achievement-reference]').click(); + const reference=page.locator('#achievementMoneyReference'); + await expect(reference).toBeVisible();await expect(reference).toBeFocused(); + await expect(reference.locator('[data-reference-provider="claude"] code')).toHaveCount(1); + await expect(reference.locator('[data-reference-provider="codex"] code')).toHaveCount(0); + await page.reload();await expect(reference).toBeVisible(); + const narrow=page.locator('[data-achievement-key="claude-api-total"]'); + await expect(narrow.locator('details')).toHaveAttribute('open',''); + await page.setViewportSize({width:360,height:900}); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); + const audit=await new AxeBuilder({page}).include('.achievement-collection').analyze(); + expect(audit.violations.filter(v=>['serious','critical'].includes(v.impact))).toEqual([]); + if(locale==='zh-CN')await narrow.screenshot({path:info.outputPath('rank-requirements-'+theme+'.png'),animations:'disabled'}); + expect(await page.evaluate(()=>window.__ccuPostedMessages.filter(row=> + ['refresh','recalibrateAchievements','updateSetting'].includes(row.command)))).toEqual([]); + }); +} +test('independent root task copy explains counting without implying task completion',async({page})=>{ + await openCodex(page,{locale:'zh-CN'});await page.locator('#tab-achievements').click(); + const card=page.locator('[data-achievement-key="root-portfolio"]'); + await expect(card.locator('h4')).toHaveText('独立主任务');await card.locator('summary').click(); + await expect(card.locator('.achievement-common-conditions')).toContainText('只计一次'); + await expect(card.locator('.achievement-common-conditions')).toContainText('不表示任务完成'); + await expect(page.locator('#provider-tab-compare')).toHaveText('分享与对比'); + await expect(page.locator('#achievementMoneyReference [data-reference-provider="claude"] code')).toHaveCount(1); + await expect(page.locator('#achievementMoneyReference [data-reference-provider="codex"] code')).toHaveCount(1); +}); +test('both weekly API themes expose all five ranks with unchanged initial thresholds',async({page})=>{ + await openCodex(page,{locale:'zh-CN'});await page.locator('#tab-achievements').click(); + for(const provider of ['claude','codex']){ + const card=page.locator(`[data-achievement-key="${provider}-api-week"]`);await card.locator('summary').click(); + const rows=card.locator('.achievement-level-rules > li');await expect(rows).toHaveCount(5); + await expect(rows.locator(':scope > strong')).toHaveText(['铜','银','金','铂金','传奇']); + await expect(rows.locator(':scope > span')).toHaveText(['$100','$500','$2,500','$10,000','$25,000']); + await expect(card).not.toContainText('api-reference-'); + } +}); diff --git a/tests/ui/achievement-effort-statistics.spec.mjs b/tests/ui/achievement-effort-statistics.spec.mjs new file mode 100644 index 00000000..bbffde5b --- /dev/null +++ b/tests/ui/achievement-effort-statistics.spec.mjs @@ -0,0 +1,233 @@ +import AxeBuilder from '@axe-core/playwright'; +import { createRequire } from 'node:module'; +import { test, expect } from './support/app.mjs'; + +const require = createRequire(import.meta.url); +const { EFFORT_PROCESSED, EFFORT_OUTPUT, EFFORT_ROOTS } = require('../../out/achievementCollaboration.js'); +const base = `http://127.0.0.1:${process.env.CCU_UI_TEST_PORT ?? 4173}`; +const efforts = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']; +const locales = ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']; +const titles = { + en: 'Effort usage and progress', 'de-DE': 'Denkstufen: Nutzung und Fortschritt', + 'zh-TW': '檔位用量與進度', 'zh-CN': '档位用量与进度', ja: '推論レベル別の使用量と進捗', + ko: '추론 단계별 사용량과 진행률', 'pt-BR': 'Uso e progresso por esforço', id: 'Penggunaan dan progres per upaya', +}; +const card = page => page.locator('.achievement-card[data-achievement-key="effort-breadth"]'); +const stats = page => card(page).locator('.achievement-effort-statistics'); +const row = (page, effort) => stats(page).locator(`[data-effort-statistics-row="${effort}"]`); +const posted = page => page.evaluate(() => window.__ccuPostedMessages); +const evidence = new WeakMap(); + +// Synthetic in-memory fixture host only. No real Extension Host, local logs, +// account, OAuth, grant receipts, filesystem exports or pricing fetches run. +test.beforeEach(async ({ page }) => { + const observed = { external: [], requests: [], errors: [] }; + evidence.set(page, observed); + page.on('request', request => observed.requests.push(request.url())); + page.on('pageerror', error => observed.errors.push(error.message)); + await page.route('**/*', route => { + if (new URL(route.request().url()).origin === base) return route.continue(); + observed.external.push(route.request().url()); + return route.abort(); + }); +}); +test.afterEach(async ({ page }) => { + expect(evidence.get(page).external).toEqual([]); + expect(evidence.get(page).errors).toEqual([]); + expect((await posted(page) ?? []).filter(message => + /pricing|fetch|refresh|preview.*share|export.*share|updateSetting|setAchievementPinned|editAchievementShowcase/i.test(message.command))).toEqual([]); +}); + +async function open(page, { locale = 'en', theme = 'light', width = 1280, + fixture = 'achievement-effort-statistics', enabled = true, provider = 'codex' } = {}) { + await page.setViewportSize({ width, height: 900 }); + const params = new URLSearchParams({ provider, locale, theme, fixture, + achievementsEnabled: String(enabled), autoRefresh: 'false' }); + const response = await page.goto(`${base}/?${params}`, { waitUntil: 'load' }); + expect(response.status()).toBe(200); + await expect(page.locator('#provider-panel')).toBeVisible(); + if (enabled) await page.locator('#tab-achievements').click(); +} + +async function expand(page) { + const summary = card(page).locator(':scope > details > summary'); + await summary.focus(); + await summary.press('Enter'); + await expect(card(page).locator(':scope > details')).toHaveAttribute('open'); + await expect(card(page)).toHaveClass(/achievement-statistics-expanded/); + await expect(stats(page)).toBeVisible(); +} + +async function themeGuard(page, theme) { + const tokens = await page.evaluate(() => { + const css = [...document.querySelectorAll('head style:not(#test-vscode-theme)')].map(el => el.textContent).join('\n'); + const defined = new Map([...document.querySelector('#test-vscode-theme').textContent + .matchAll(/(--vscode-[\w-]+)\s*:\s*([^;{}]*);/g)].map(m => [m[1], m[2].trim()])); + const referenced = [...new Set([...css.matchAll(/var\(\s*(--vscode-[\w-]+)/g)].map(m => m[1]))]; + // Registered nullable colors may compute empty only when explicitly initial. + const nullable = new Set(['--vscode-input-border', '--vscode-textBlockQuote-background', '--vscode-toolbar-hoverBackground', + '--vscode-button-secondaryBackground', '--vscode-button-secondaryHoverBackground', '--vscode-charts-orange', + '--vscode-list-inactiveSelectionBackground', '--vscode-widget-shadow']); + const computed = getComputedStyle(document.documentElement); + return { count: referenced.length, missing: referenced.filter(name => !defined.has(name) || + (!computed.getPropertyValue(name).trim() && !(nullable.has(name) && defined.get(name) === 'initial'))), + background: getComputedStyle(document.documentElement).getPropertyValue('--vscode-editor-background').trim() }; + }); + expect(tokens.count).toBeGreaterThan(0); + expect(tokens.missing).toEqual([]); + expect(tokens.background).toBe({ light: '#ffffff', dark: '#1e1e1e', highContrast: '#000000' }[theme]); +} + +async function structure(page, locale = 'en') { + await expect(stats(page).locator(':scope > h5')).toHaveText(titles[locale]); + await expect(stats(page).locator('[data-effort-statistics-row]')).toHaveCount(6); + await expect(stats(page).locator('[data-effort-progress]')).toHaveCount(18); + await expect(stats(page).locator('[data-effort-joint-progress]')).toHaveCount(6); + await expect(stats(page)).toContainText('USD'); + await expect(stats(page)).not.toContainText('api-reference-2026-10-openai-v1'); + await expect(stats(page).locator('[data-achievement-reference]')).toHaveCount(1); + await expect(page.locator('#achievementMoneyReference [data-reference-provider="codex"]')).toContainText('api-reference-2026-10-openai-v1'); + await expect(page.locator('#achievementMoneyReference [data-reference-provider="codex"]')).toContainText('2026-10-09'); + expect(await stats(page).innerHTML()).not.toMatch(/private-effort|private-custom-model|sessionKey|parentSessionKey|gpt-6/); + // This fixture publishes current statistics, not durable earned grants. + await expect(card(page)).toHaveAttribute('data-achievement-earned', 'false'); + for (const effort of efforts) { + await expect(row(page, effort).locator('h6')).toHaveText(effort); + for (const [metric, target] of [['processed', EFFORT_PROCESSED[0]], ['output', EFFORT_OUTPUT[0]], ['tasks', EFFORT_ROOTS[0]]]) { + const progress = row(page, effort).locator(`[data-effort-progress="${metric}"]`); + await expect(progress).toHaveAttribute('max', String(target)); + await expect(progress).toHaveAttribute('aria-label', /.+: .+ \/ .+/); + } + } +} + +test('six inline rows separate empty, unknown, mixed attribution, partial subtotal and positive submicro money', async ({ page }) => { + await open(page); + await expect(stats(page)).toBeHidden(); + await expect(card(page)).not.toHaveClass(/achievement-statistics-expanded/); + await expand(page); + await structure(page); + await expect(row(page, 'low').locator('[data-effort-money]')).toHaveAttribute('data-effort-money', 'empty'); + await expect(row(page, 'low')).toContainText('No qualifying usage'); + for (const effort of ['medium', 'max', 'ultra']) { + await expect(row(page, effort).locator('[data-effort-money]')).toHaveAttribute('data-effort-money', 'unpriced'); + await expect(row(page, effort)).toContainText('Unpriced'); + await expect(row(page, effort)).not.toContainText('$0'); + } + await expect(row(page, 'high').locator('[data-effort-money]')).toHaveAttribute('data-effort-money', 'partial'); + await expect(row(page, 'high').locator('[data-effort-money]')).toContainText('≥$'); + await expect(row(page, 'xhigh').locator('[data-effort-money]')).toHaveAttribute('data-effort-money', 'complete'); + await expect(row(page, 'xhigh').locator('[data-effort-money]')).toContainText('<$1'); + await expect(row(page, 'high').locator('[data-effort-joint-progress]')).toHaveAttribute('data-effort-joint-progress', '40'); + for (const [effort, values] of [['low', [0, 0, 0]], ['medium', [11, 1, 1]], + ['high', [100_100_011, 100_001, 2]], ['xhigh', [1, 1, 1]], ['max', [1, 1, 1]], ['ultra', [1, 1, 1]]]) { + for (const [index, metric] of ['processed', 'output', 'tasks'].entries()) { + const expected = Math.min(values[index], [EFFORT_PROCESSED[0], EFFORT_OUTPUT[0], EFFORT_ROOTS[0]][index]); + await expect(row(page, effort).locator(`[data-effort-progress="${metric}"]`)).toHaveAttribute('value', String(expected)); + } + } + expect((await posted(page)).some(message => /grant|unlock|award/i.test(message.command))).toBe(false); +}); + +test('keyboard expansion uses generic openDetails; reload, filter return and provider return restore derived full width', async ({ page }) => { + await open(page); + const share = await page.evaluate(() => localStorage.getItem('ccu.sharing.achievementBadges')); + await expand(page); + await page.clock.runFor(250); + const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('__ccu-vscode-state'))); + expect(saved.openDetails).toContain('achievement:effort-breadth'); + expect(Object.keys(saved).filter(key => /effort|statisticsExpanded/i.test(key))).toEqual([]); + await page.reload(); + await expect(card(page).locator(':scope > details')).toHaveAttribute('open'); + await expect(card(page)).toHaveClass(/achievement-statistics-expanded/); + await page.locator('#achievementFilter').selectOption('earned'); + await expect(card(page)).toBeHidden(); + await page.locator('#achievementFilter').selectOption('all'); + await expect(stats(page)).toBeVisible(); + await open(page, { provider: 'claude' }); + await open(page); + await expect(card(page).locator(':scope > details')).toHaveAttribute('open'); + await expect(card(page)).toHaveClass(/achievement-statistics-expanded/); + expect(await page.evaluate(() => localStorage.getItem('ccu.sharing.achievementBadges'))).toBe(share); + evidence.get(page).requests = []; + await card(page).locator(':scope > details > summary').press('Enter'); + await expect(card(page).locator(':scope > details')).not.toHaveAttribute('open'); + await expect(card(page)).not.toHaveClass(/achievement-statistics-expanded/); + expect(evidence.get(page).requests).toEqual([]); +}); + +test('desktop disclosure spans its real multi-column grid only while open, without host work', async ({ page }, testInfo) => { + await open(page); + const widths = () => card(page).evaluate(el => ({ card: el.getBoundingClientRect().width, + grid: el.parentElement.getBoundingClientRect().width })); + const closed = await widths(); + expect(closed.card).toBeLessThan(closed.grid / 2); + evidence.get(page).requests = []; + await expand(page); + const opened = await widths(); + expect(Math.abs(opened.card - opened.grid)).toBeLessThanOrEqual(1); + expect(evidence.get(page).requests).toEqual([]); + const path = testInfo.outputPath('effort-expanded-desktop-1280.png'); + await stats(page).screenshot({ path, animations: 'disabled' }); + await testInfo.attach('expanded-desktop', { path, contentType: 'image/png' }); + await card(page).locator(':scope > details > summary').press('Enter'); + await expect(card(page)).not.toHaveClass(/achievement-statistics-expanded/); + expect((await widths()).card).toBeCloseTo(closed.card, 1); + expect(evidence.get(page).requests).toEqual([]); +}); + +test('retired current source hides old derived rows, OFF never accesses statistics, empty census is genuine zero', async ({ page }) => { + await open(page, { fixture: 'achievement-effort-statistics-retired' }); + await card(page).locator(':scope > details > summary').click(); + await expect(stats(page)).toHaveCount(0); + await expect(card(page).locator('[data-effort-statistics-row]')).toHaveCount(0); + await expect(card(page).locator('.achievement-proof-status').first()).toBeVisible(); + await open(page, { enabled: false }); + await expect(page.locator('.achievement-effort-statistics')).toHaveCount(0); + await open(page, { fixture: 'achievement-effort-statistics-empty' }); + if (!(await card(page).locator('details').evaluate(el => el.open))) await expand(page); + for (const effort of efforts) { + await expect(row(page, effort).locator('[data-effort-money]')).toHaveAttribute('data-effort-money', 'empty'); + await expect(row(page, effort)).toContainText('No qualifying usage'); + for (const metric of ['processed', 'output', 'tasks']) + await expect(row(page, effort).locator(`[data-effort-progress="${metric}"]`)).toHaveAttribute('value', '0'); + await expect(row(page, effort)).not.toContainText('Unpriced'); + } +}); + +for (const locale of locales) for (const theme of ['light', 'dark', 'highContrast']) for (const width of [320, 360]) { + test(`${locale} ${theme} ${width}px: expanded statistics fit three sizes and 200% CSS surrogate`, async ({ page }, testInfo) => { + await open(page, { locale, theme, width }); + await themeGuard(page, theme); + await expand(page); + await structure(page, locale); + evidence.get(page).requests = []; + for (const scale of [1, 2]) { + // CSS layout stress only, not native browser zoom acceptance. + await page.setViewportSize({ width: width * scale, height: 900 }); + await page.evaluate(scale => { document.documentElement.style.zoom = String(scale); }, scale); + for (const [size, emblem] of [['small', 56], ['medium', 80], ['large', 112]]) { + await page.locator('#achievementSize').selectOption(size); + await expect(card(page)).toHaveClass(/achievement-statistics-expanded/); + const geometry = await card(page).evaluate((el, scale) => ({ + fullWidth: getComputedStyle(el).gridColumnStart === '1' && getComputedStyle(el).gridColumnEnd === '-1', + emblem: el.querySelector('.achievement-emblem').getBoundingClientRect().width / scale, + pageFits: document.documentElement.scrollWidth <= innerWidth + 1, + overflowing: [...el.querySelectorAll('.achievement-effort-statistics,.achievement-effort-rows article,.achievement-effort-metric,strong,progress')] + .filter(node => node.scrollWidth > node.clientWidth + 1).map(node => node.tagName), + }), scale); + expect(geometry).toEqual({ fullWidth: true, emblem, pageFits: true, overflowing: [] }); + } + await stats(page).scrollIntoViewIfNeeded(); + const result = await new AxeBuilder({ page }).include('.achievement-effort-statistics') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + expect(result.violations).toEqual([]); + expect(result.passes.some(entry => entry.id === 'color-contrast')).toBe(true); + expect(result.incomplete.filter(entry => entry.id === 'color-contrast')).toEqual([]); + const path = testInfo.outputPath(`effort-${locale}-${theme}-${width}-${scale}x-css-surrogate.png`); + await stats(page).screenshot({ path, animations: 'disabled' }); + await testInfo.attach(`expanded-effort-${scale}x`, { path, contentType: 'image/png' }); + } + expect(evidence.get(page).requests).toEqual([]); + }); +} diff --git a/tests/ui/achievement-evidence-reasons.spec.mjs b/tests/ui/achievement-evidence-reasons.spec.mjs new file mode 100644 index 00000000..da3bf751 --- /dev/null +++ b/tests/ui/achievement-evidence-reasons.spec.mjs @@ -0,0 +1,29 @@ +import {createRequire} from 'node:module'; +import {test,expect,openCodex} from './support/app.mjs'; +const require=createRequire(import.meta.url); +const {achievementEvidenceHintCopyFor}=require('../../out/i18n.js'); +for(const locale of ['en','zh-CN','zh-TW','de-DE','ja','ko','pt-BR','id'])for(const theme of ['dark','light']){ + test(locale+' '+theme+': proof requirements stay readable and persistent without fabricated progress',async({page},testInfo)=>{ + const copy=achievementEvidenceHintCopyFor(locale); + for(const [fixture,state,reason] of [['default','pending','calendar'],['achievement-proof-stale','stale','stale']]){ + await openCodex(page,{locale,theme,width:320,fixture}); + await page.locator('#tab-achievements').click(); + const card=page.locator('[data-achievement-key="astra-days"]'); + await expect(card).toHaveAttribute('data-achievement-proof',state); + await card.locator('summary').focus(); + if(!await card.locator('details').evaluate(element=>element.open))await card.locator('summary').press('Enter'); + const hint=card.locator('[data-proof-explanation]'); + await expect(hint).toHaveAttribute('data-proof-explanation',reason); + await expect(hint).toBeVisible(); + await expect(hint).toContainText(copy[reason]); + await expect(hint.locator('svg')).toHaveAttribute('aria-hidden','true'); + await expect(card.locator('progress,.achievement-current-progress')).toHaveCount(0); + await page.clock.runFor(200);await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(hint).toContainText(copy[reason]); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + if(locale==='zh-CN')await card.screenshot({path:testInfo.outputPath('proof-'+state+'-'+theme+'.png'),animations:'disabled'}); + expect(await page.evaluate(()=>window.__ccuPostedMessages.some(row=>['refresh','updateSetting','exportAchievementShare','previewAchievementShare'].includes(row.command)))).toBe(false); + } + }); +} diff --git a/tests/ui/achievement-experience.spec.mjs b/tests/ui/achievement-experience.spec.mjs new file mode 100644 index 00000000..df805807 --- /dev/null +++ b/tests/ui/achievement-experience.spec.mjs @@ -0,0 +1,408 @@ +import { test, expect, openCodex, openClaude, openCompare } from './support/app.mjs'; + +const modelExperienceNames = { + en: ['Models in one task', 'Multi-model tasks', 'Fable same-session'], + 'zh-CN': ['同任务模型', '多模型任务', 'Fable 同会话'], + 'zh-TW': ['同任務模型', '多模型任務', 'Fable 同會話'], + 'de-DE': ['Modelle je Aufgabe', 'Mehrmodell-Aufgaben', 'Fable: gemeinsame Sitzung'], + ja: ['同一タスクのモデル', '複数モデルのタスク', 'Fable 同一セッション'], + ko: ['한 작업의 모델', '다중 모델 작업', 'Fable 같은 세션'], + 'pt-BR': ['Modelos por tarefa', 'Tarefas multimodelo', 'Fable na mesma sessão'], + id: ['Model dalam satu tugas', 'Tugas multimodel', 'Fable dalam sesi yang sama'], +}; + +// Compare exposes its share workspace, not a provider achievement/settings tab. +for (const [provider, open] of [['claude', openClaude], ['codex', openCodex]]) { + test(`${provider} reselecting the current tab preserves expanded rules and sends no duplicate host work`, async ({ page }) => { + await open(page, { width: 360 }); + await page.locator('#tab-achievements').click(); + const card = page.locator('.achievement-card').first(); + await card.locator('summary').focus(); + await card.locator('summary').press('Enter'); + await page.clock.runFor(200); + const before = await page.evaluate(() => ({ + state: JSON.parse(localStorage.getItem('__ccu-vscode-state')), + messages: window.__ccuPostedMessages.length, + })); + expect(before.state.openDetails.length).toBeGreaterThan(0); + await page.locator('#tab-achievements').click(); + await page.clock.runFor(200); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('__ccu-vscode-state')).openDetails)) + .toEqual(before.state.openDetails); + expect(await page.evaluate(() => window.__ccuPostedMessages.length)).toBe(before.messages); + // Focus/scroll may independently save UI state; only host work and the + // exact disclosure set must stay unchanged on current-tab reselection. + await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); + await page.locator('#tab-settings').click(); + const inventoryRequests = await page.evaluate(() => window.__ccuPostedMessages.filter(message => + message.command === 'requestLocalDataInventory').length); + // The basic renderer fixture omits the host-backed inventory section. + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(message => + message.command === 'tabChanged' && message.tab === 'settings').length)).toBe(1); + const settingsMessages = await page.evaluate(() => window.__ccuPostedMessages.length); + await page.locator('#tab-settings').click(); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(message => + message.command === 'requestLocalDataInventory').length)).toBe(inventoryRequests); + expect(await page.evaluate(() => window.__ccuPostedMessages.length)).toBe(settingsMessages); + await page.locator('#tab-achievements').click(); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('__ccu-vscode-state')).openDetails)).toEqual([]); + }); +} + +for (const [locale, names] of Object.entries(modelExperienceNames)) { + test(`${locale} model experience cards distinguish breadth, repeated tasks and same-session Fable`, async ({ page }, testInfo) => { + for (const theme of ['dark', 'light']) { + await openClaude(page, { locale, width: 320, theme, fixture: 'achievement-token-stages' }); + await page.locator('#tab-achievements').click(); + const keys = ['same-root-models', 'model-relay-roots', 'fable-relay']; + for (const [index, key] of keys.entries()) { + const card = page.locator(`.achievement-card[data-achievement-key="${key}"]`); + await expect(card.locator('h4')).toHaveText(names[index]); + await card.locator('summary').focus(); + // The second theme visit deliberately retains the first visit's state. + if (!(await card.locator('details').evaluate(element => element.open))) { + await card.locator('summary').press('Enter'); + } + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(card.locator('[data-achievement-level]')).toHaveCount(index === 2 ? 1 : 3); + } + // Flush native toggle tasks before reload; the harness freezes its clock. + await page.clock.runFor(200); + await expect.poll(() => page.evaluate(() => { + const state = JSON.parse(localStorage.getItem('__ccu-vscode-state') || '{}'); + return ['same-root-models', 'model-relay-roots', 'fable-relay'].every(key => + state.openDetails?.includes(`achievement:${key}`)); + })).toBe(true); + await page.reload(); + for (const key of keys) await expect(page.locator(`.achievement-card[data-achievement-key="${key}"] details`)).toHaveAttribute('open'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await page.evaluate(() => window.__ccuPostedMessages.some(message => + ['refresh', 'updateSetting', 'previewAchievementShare', 'exportAchievementShare'].includes(message.command)))).toBe(false); + await page.locator('.achievement-card[data-achievement-key="same-root-models"]').screenshot({ + path: testInfo.outputPath(`model-breadth-320-${theme}.png`), + }); + } + }); +} + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} overview badge opens the canonical rule without refreshing or selecting a share`, async ({ page }, testInfo) => { + for (const [width, theme] of [[320, 'dark'], [360, 'light']]) { + await openClaude(page, { locale, width, theme, fixture: 'achievement-token-stages' }); + await page.locator('#tab-achievements').click(); + for (const kind of ['badges', 'next']) { + const buttons = page.locator(`[data-achievement-overview="${kind}"] .achievement-overview-badge`); + expect(await buttons.count()).toBeGreaterThan(0); + expect(await buttons.count()).toBeLessThanOrEqual(3); + } + const button = page.locator('[data-achievement-overview="next"] .achievement-overview-badge').first(); + const key = await button.getAttribute('data-achievement-open'); + await page.locator('#achievementFilter').selectOption('earned'); + await button.focus(); + await button.press('Enter'); + const detail = page.locator(`.achievement-card[data-achievement-key="${key}"] details`); + await expect(page.locator('#achievementFilter')).toHaveValue('all'); + await expect(detail).toHaveAttribute('open'); + await expect(detail.locator('summary')).toBeFocused(); + await page.reload(); + await expect(detail).toHaveAttribute('open'); + await expect(page.locator('#achievementFilter')).toHaveValue('all'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await page.evaluate(() => window.__ccuPostedMessages.some(message => + ['refresh', 'updateSetting', 'previewAchievementShare', 'exportAchievementShare'].includes(message.command)))).toBe(false); + await page.locator('#tab-achievements').click(); + await page.evaluate(() => scrollTo(0, 0)); + await page.screenshot({ path: testInfo.outputPath(`overview-${width}-${theme}.png`) }); + } + }); +} + +test('integer-only progress and folded evidence stay keyboard-accessible without host refreshes', async ({ page }) => { + await openCodex(page, { width: 360 }); + await page.locator('#tab-achievements').click(); + const disclosure = page.locator('.achievement-evidence-note'); + await expect(disclosure).not.toHaveAttribute('open'); + await expect(disclosure.locator('p').first()).toBeHidden(); + await expect(disclosure.locator('.achievement-date-note')).toBeHidden(); + await disclosure.locator('summary').focus(); + await disclosure.locator('summary').press('Enter'); + await expect(disclosure.locator('p').first()).toContainText('first event week'); + await expect(disclosure.locator('.achievement-date-note')).toBeVisible(); + await page.reload(); + await expect(disclosure).toHaveAttribute('open'); + await disclosure.locator('summary').focus(); + await disclosure.locator('summary').press('Enter'); + await page.reload(); + await expect(disclosure).not.toHaveAttribute('open'); + for (const summary of await page.locator('.achievement-disclosure > summary').all()) await summary.click(); + const progress = await page.locator('.achievement-progress-label').allTextContents(); + const verified = page.locator('.achievement-card[data-achievement-proof="ready"]'); + const pending = page.locator('.achievement-card[data-achievement-proof="pending"]'); + expect(progress.length).toBe(await verified.count()); + expect(await pending.count()).toBeGreaterThan(0); + await expect(pending.locator('progress,.achievement-progress-label')).toHaveCount(0); + for (const summary of await pending.locator('summary').all()) { + await expect(summary).toHaveAttribute('aria-label', /Evidence pending/); + } + for (const text of progress) expect(text).not.toMatch(/\d+[.,]\d+[kMBT]/); + await expect(page.locator('.achievement-state')).toHaveCount(0); + const messages = await page.evaluate(() => window.__ccuPostedMessages); + expect(messages.some(message => ['refresh', 'updateSetting'].includes(message.command))).toBe(false); +}); + +test('gallery filters are local, survive reload, and do not read or refresh logs', async ({ page }) => { + await openCodex(page); + await page.locator('#tab-achievements').click(); + await page.locator('#achievementFilter').selectOption('next'); + const expected = await page.locator('.achievement-card[data-achievement-next="true"]').count(); + await expect(page.locator('.achievement-card:visible')).toHaveCount(expected); + await page.reload(); + await expect(page.locator('#achievementFilter')).toHaveValue('next'); + await expect(page.locator('.achievement-card:visible')).toHaveCount(expected); + await page.locator('#achievementFilter').selectOption('earned'); + const earned = await page.locator('.achievement-card[data-achievement-earned="true"]').count(); + await expect(page.locator('.achievement-card:visible')).toHaveCount(earned); + const messages = await page.evaluate(() => window.__ccuPostedMessages); + expect(messages.some(message => ['refresh', 'updateSetting'].includes(message.command))).toBe(false); + await expect(page.locator('.achievement-count')).toContainText('series'); + await expect(page.locator('.achievement-count')).toContainText('levels'); +}); + +test('a gallery settings shortcut focuses the existing opt-out without changing it', async ({ page }) => { + await openCodex(page); + await page.locator('#tab-achievements').click(); + await page.getByRole('button', { name: 'Achievement settings', exact: true }).click(); + await expect(page.locator('#tab-settings')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#set_enableAchievements')).toBeFocused(); + await expect(page.locator('#set_enableAchievements')).toBeChecked(); + await page.reload(); + await expect(page.locator('#tab-settings')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#set_enableAchievements').locator('..')).toBeVisible(); + await page.locator('#set_enableAchievements').focus(); + await page.locator('#set_enableAchievements').press('Space'); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.some(message => + message.command === 'updateSetting' && message.key === 'enableAchievements' && message.value === false))).toBe(true); +}); + +test('a tier update keeps its series disclosure, filter, card order and keyboard focus', async ({ page }) => { + await openCodex(page); + await page.locator('#tab-achievements').click(); + await page.locator('#achievementFilter').selectOption('all'); + const card = page.locator('[data-achievement-series="astra-regular"]'); + await card.locator('summary').click(); + await card.locator('summary').focus(); + const order = await page.locator('.achievement-card').evaluateAll(cards => cards.map(card => card.dataset.achievementKey)); + const source = await (await page.context().request.get(page.url())).text(); + await page.evaluate(source => { + const nextPanel = new DOMParser().parseFromString(source, 'text/html').getElementById('provider-panel'); + // The host rendering contract separately verifies real tier upgrades. This + // patch exercises client restoration while the displayed level ID changes. + const upgraded = nextPanel.querySelector('[data-achievement-series="astra-regular"]'); + upgraded.dataset.achievementId = 'astra-regular-silver'; + upgraded.querySelector('h4').textContent = 'Astra regular · Silver'; + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'codex', tab: 'achievements', revision: 1, + html: nextPanel.innerHTML, claudeLast30HoursByDay: {}, + } })); + }, source); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.some(message => + message.command === 'dashboardDataPatchAck' && message.revision === 1 && message.ok))).toBe(true); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(card.locator('summary')).toBeFocused(); + await expect(page.locator('#achievementFilter')).toHaveValue('all'); + expect(await page.locator('.achievement-card').evaluateAll(cards => cards.map(card => card.dataset.achievementKey))).toEqual(order); + await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(page.locator('#achievementFilter')).toHaveValue('all'); +}); + +test('closing a disclosure restored from an old level key remains closed after reload', async ({ page }) => { + await openCodex(page); + await page.locator('#tab-achievements').click(); + await page.evaluate(() => { + const state = JSON.parse(localStorage.getItem('__ccu-vscode-state')) || {}; + state.openDetails = ['achievement:sol-regular-silver']; + localStorage.setItem('__ccu-vscode-state', JSON.stringify(state)); + }); + await page.reload(); + const card = page.locator('[data-achievement-series="sol-regular"]'); + await expect(card.locator('details')).toHaveAttribute('open'); + await card.locator('summary').click(); + await expect(card.locator('details')).not.toHaveAttribute('open'); + await page.reload(); + await expect(card.locator('details')).not.toHaveAttribute('open'); +}); + +test('five-level rules and distinct silver/platinum treatments fit 320px and 360px', async ({ page }) => { + for (const width of [320, 360]) { + await openCodex(page, { width }); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-series="cumulative-volume"]'); + if (!await card.locator('details').evaluate(details => details.open)) await card.locator('summary').click(); + const fits = await card.evaluate(card => [...card.querySelectorAll('summary,.achievement-tier-markers,.achievement-level-rules')] + .every(element => element.scrollWidth <= element.clientWidth + 1)); + expect(fits).toBe(true); + } + const colors = await page.evaluate(() => { + const card = document.querySelector('.achievement-card'); + card.dataset.achievementTier = 'silver'; + const silver = getComputedStyle(card).getPropertyValue('--achievement-tier-color'); + card.dataset.achievementTier = 'platinum'; + return { silver, platinum: getComputedStyle(card).getPropertyValue('--achievement-tier-color') }; + }); + expect(colors.silver.trim()).not.toBe(colors.platinum.trim()); +}); + +for (const [provider, open] of [['claude', openClaude], ['codex', openCodex]]) { + test(`sharing returns to the ${provider} gallery with keyboard focus`, async ({ page }) => { + await open(page, { fixture: 'combined-heatmap' }); + await page.locator('#tab-achievements').click(); + await page.locator('#achievements button[onclick="openAchievementShare()"] ').click(); + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await page.locator('#achievementShareBack').click(); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'providerChanged').at(-1))) + .toEqual({ command: 'providerChanged', provider, tab: 'achievements' }); + await open(page, { fixture: 'combined-heatmap' }); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#achievementFilter')).toBeFocused(); + }); +} + +test('an upgrade never silently selects a new badge and explains selection changes', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await page.evaluate(() => localStorage.setItem('ccu.sharing.achievementBadges', JSON.stringify(['no-longer-offered-tier']))); + await page.reload(); + await expect(page.locator('.achievement-share-choice:checked')).toHaveCount(0); + await expect(page.locator('#achievementShareStatus')).toContainText('changed'); + await expect(page.locator('#achievementShareExportBtn')).toBeDisabled(); +}); + +test('export suppresses repeated clicks and reports success, cancel and a retryable error', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await page.locator('.achievement-share-choice').first().check(); + const request = await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'previewAchievementShare').at(-1)); + await page.evaluate(request => window.dispatchEvent(new MessageEvent('message', { data: { + ...request, command: 'achievementShareResult', previewId: 'export-preview', svg: '', + } })), request); + const button = page.locator('#achievementShareExportBtn'); + await button.click(); + await page.evaluate(() => exportAchievementShare()); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'exportAchievementShare').length)).toBe(1); + await expect(button).toBeDisabled(); + for (const [reply, text] of [[{ ok: true }, 'saved'], [{ ok: false, cancelled: true }, 'cancelled'], [{ ok: false, stale: false }, 'Could not save']]) { + await page.evaluate(reply => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementShareExportResult', previewId: 'export-preview', ...reply, + } })), reply); + await expect(page.locator('#achievementShareStatus')).toContainText(text); + await expect(button).toBeEnabled(); + if (!reply.stale && !('stale' in reply)) await button.click(); + } +}); + +test('share selection explains its empty state and waits for the matching preview, ignoring stale replies', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await expect(page.locator('#achievementSharePreview')).toContainText('Select'); + await expect(page.locator('.achievement-share-choice-icon').first()).toBeVisible(); + await page.locator('.achievement-share-choice').first().check(); + const request = await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'previewAchievementShare').at(-1)); + expect(request.requestId).toBeGreaterThan(0); + await expect(page.locator('#achievementShareExportBtn')).toBeDisabled(); + await expect(page.locator('#achievementShareStatus')).toContainText('Preparing'); + await page.evaluate(request => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementShareResult', requestId: request.requestId - 1, clientId: request.clientId, ids: request.ids, + previewId: 'stale', svg: '', + } })), request); + await expect(page.locator('#achievementShareExportBtn')).toBeDisabled(); + await page.evaluate(request => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementShareResult', requestId: request.requestId, clientId: request.clientId, ids: request.ids, + previewId: 'accepted-preview', svg: '', + } })), request); + await expect(page.locator('#achievementShareExportBtn')).toBeEnabled(); + await page.locator('#achievementShareExportBtn').click(); + const exported = await page.evaluate(() => window.__ccuPostedMessages.find(message => message.command === 'exportAchievementShare')); + expect(exported.previewId).toBe('accepted-preview'); + expect(exported.ids).toEqual(request.ids); + await page.locator('.achievement-share-choice').first().uncheck(); + await expect(page.locator('#achievementShareExportBtn')).toBeDisabled(); + await page.locator('#achievementShareBack').click(); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'providerChanged').at(-1))) + .toEqual({ command: 'providerChanged', provider: 'codex', tab: 'achievements' }); + // The harness records provider requests; the extension host is tested separately. + await openCodex(page, { fixture: 'combined-heatmap' }); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); +}); + +test('a matching preview reconciles only unavailable selected badges and survives reload', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await page.locator('.achievement-share-choice').nth(0).check(); + const request = await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'previewAchievementShare').at(-1)); + const acceptedIds = []; + await page.evaluate(({ request, acceptedIds }) => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementShareResult', requestId: request.requestId, clientId: request.clientId, ids: acceptedIds, + svg: '', + } })), { request, acceptedIds }); + await expect(page.locator('#achievementShareExportBtn')).toBeDisabled(); + await expect(page.locator('#achievementSharePreview')).toHaveAttribute('aria-busy', 'false'); + await expect(page.locator('.achievement-share-choice').nth(0)).not.toBeChecked(); + await expect(page.locator('#achievementShareCount')).toHaveText('0/9'); + await page.reload(); + const restored = await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'restoreAchievementShare').at(-1)); + expect(restored.ids).toEqual(acceptedIds); +}); + +test('a matching malformed preview fails visibly instead of remaining busy or accepting extra badges', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await page.locator('.achievement-share-choice').nth(0).check(); + const request = await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'previewAchievementShare').at(-1)); + await page.evaluate(request => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementShareResult', requestId: request.requestId, clientId: request.clientId, ids: ['not-selected'], + previewId: 'untrusted-preview', svg: '', + } })), request); + await expect(page.locator('#achievementShareExportBtn')).toBeDisabled(); + await expect(page.locator('#achievementSharePreview')).toHaveAttribute('aria-busy', 'false'); + await expect(page.locator('#achievementShareStatus')).toContainText(/could not/i); + await expect(page.locator('#achievementSharePreview svg')).toHaveCount(0); + await expect(page.locator('.achievement-share-choice').nth(0)).toBeChecked(); +}); + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} current progress and display-only stages fit closed cards`, async ({ page }, testInfo) => { + for (const [width, theme] of [[320, 'dark'], [360, 'light']]) { + await openClaude(page, { locale, theme, width, fixture: 'achievement-token-stages' }); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-series="sonnet-main"]'); + // Moving between viewport/theme cases deliberately retains Webview state. + // Close the previous case's restored disclosure before testing its face. + if (await card.locator('details').evaluate(details => details.open)) await card.locator('summary').click(); + await expect(card.locator('details')).not.toHaveAttribute('open'); + await expect(card.locator('.achievement-current-progress')).toHaveText('295M / 1B'); + await expect(card.locator('.achievement-stage-target')).toContainText('500M'); + const fit = await card.locator('summary').evaluate(element => element.scrollWidth <= element.clientWidth + 1); + expect(fit).toBe(true); + await card.locator('summary').focus(); + await card.locator('summary').press('Enter'); + await expect(card.locator('[data-achievement-level]')).toHaveCount(5); + await expect(card.locator('.achievement-level-rules')).not.toContainText('500M'); + await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(card.locator('.achievement-stage-target')).toContainText('500M'); + expect((await page.evaluate(() => window.__ccuPostedMessages)).some(message => + ['refresh', 'updateSetting', 'previewAchievementShare'].includes(message.command))).toBe(false); + if (locale === 'zh-CN') await card.screenshot({ path: testInfo.outputPath(`stage-${width}-${theme}.png`) }); + } + }); + test(`${locale} compact targets, full tier rules, and controls fit the narrow gallery`, async ({ page }, testInfo) => { + await openCodex(page, { locale, theme: 'dark', width: 360 }); + await page.locator('#tab-achievements').click(); + await expect(page.locator('.achievement-next-target').first()).toBeVisible(); + await page.locator('[data-achievement-series="astra-regular"] summary').click(); + await expect(page.locator('[data-achievement-series="astra-regular"] [data-achievement-level]')).toHaveCount(3); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1); + expect(overflow).toBe(false); + await page.locator('#achievementFilter').selectOption('next'); + await page.reload(); + await expect(page.locator('#achievementFilter')).toHaveValue('next'); + if (locale === 'zh-CN') await page.locator('#achievements').screenshot({ path: testInfo.outputPath('experience-narrow-dark.png') }); + }); +} diff --git a/tests/ui/achievement-gallery-v2.spec.mjs b/tests/ui/achievement-gallery-v2.spec.mjs new file mode 100644 index 00000000..96466b56 --- /dev/null +++ b/tests/ui/achievement-gallery-v2.spec.mjs @@ -0,0 +1,297 @@ +import { test, expect, openClaude } from './support/app.mjs'; + +async function confirmAchievementPatch(page, fixture) { + const url = new URL(page.url()); + url.searchParams.set('fixture', fixture); + const response = await page.context().request.get(url.href); + expect(response.ok()).toBe(true); + await page.evaluate(html => { + const next = new DOMParser().parseFromString(html, 'text/html').getElementById('provider-panel'); + window.__ccuPostedMessages = []; + dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'claude', tab: 'achievements', revision: 1, + html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {}, + } })); + }, await response.text()); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.filter(m => m.command === 'dashboardDataPatchAck'))) + .toEqual([{command:'dashboardDataPatchAck',revision:1,ok:true}]); + await page.clock.runFor(200); +} + +async function rememberShareAndDocument(page) { + return page.evaluate(() => { + // Seed a deliberate, nonempty choice so preservation cannot pass vacuously. + const choices = Array.from(document.querySelectorAll('.achievement-share-choice')); + if (!choices.length) throw new Error('Production fixture must contain earned share choices'); + const ids = [choices[0].value]; + choices.forEach(el => { el.checked = ids.includes(el.value); }); + localStorage.setItem('ccu.sharing.achievementBadges',JSON.stringify(ids)); + window.__galleryDocument = document.documentElement; + return { + share: localStorage.getItem('ccu.sharing.achievementBadges'), + selected: Array.from(document.querySelectorAll('.achievement-share-choice:checked')).map(el => el.value), + }; + }); +} + +async function expectShareAndDocumentUnchanged(page, before) { + const after = await page.evaluate(() => ({ + sameDocument: window.__galleryDocument === document.documentElement, + share: localStorage.getItem('ccu.sharing.achievementBadges'), + selected: Array.from(document.querySelectorAll('.achievement-share-choice:checked')).map(el => el.value), + messages: window.__ccuPostedMessages, + })); + const {messages,...state} = after; + expect(state).toEqual({...before,sameDocument:true}); + // Existing Claude patch hydration reapplies saved choices to the real share + // presentation. It must not request a deliberate preview/export or host work. + expect(messages.map(m => m.command)).toEqual(['restoreAchievementShare','dashboardDataPatchAck']); + expect(messages[0].ids).toEqual(before.selected); +} + +test('gallery size changes emblem and card geometry, keeps text and survives reload without host work', async ({page}) => { + await openClaude(page,{width:360,fixture:'achievement-pins'}); + await page.locator('#tab-achievements').click(); + const size = page.locator('#achievementSize'); + await expect(size).toHaveValue('medium'); + const card = page.locator('.achievement-card').first(); + const textSize = await card.locator('h4').evaluate(el=>getComputedStyle(el).fontSize); + for (const [value,pixels] of [['small',56],['medium',80],['large',112]]) { + await size.selectOption(value); + expect(await card.locator('.achievement-emblem').evaluate(el=>el.getBoundingClientRect().width)).toBe(pixels); + expect(await card.locator('h4').evaluate(el=>getComputedStyle(el).fontSize)).toBe(textSize); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + } + await page.reload(); + await expect(size).toHaveValue('large'); + expect(await page.evaluate(()=>window.__ccuPostedMessages.some(m=>['refresh','updateSetting','setAchievementPinned'].includes(m.command)))).toBe(false); +}); + +const galleryLocales = ['en','de-DE','zh-TW','zh-CN','ja','ko','pt-BR','id']; +const galleryThemes = ['light','dark','highContrast']; +const gallerySizes = [['small',56],['medium',80],['large',112]]; +const moneyArtworkKeys = ['claude-api-total','claude-api-week','claude-api-session', + 'codex-api-total','codex-api-week','codex-api-task']; + +test.describe('money v2 artwork narrow captures', () => { + for (const locale of galleryLocales) for (const theme of ['light','dark']) for (const [size,emblem] of gallerySizes) { + test(`${locale} ${theme} ${size}: six v2 money cards at 320px`, async ({page},testInfo) => { + await openClaude(page,{locale,theme,fixture:'achievement-six-pins',width:320,height:900}); + await page.locator('#tab-achievements').click(); + await expectThemeFidelity(page,theme); + await page.locator('#achievementSize').selectOption(size); + await expect(page.locator('.achievement-collection')).toHaveAttribute('data-achievement-size',size); + for (const key of moneyArtworkKeys) { + const card = page.locator(`.achievement-card[data-achievement-key="${key}"]`); + await expect(card).toHaveCount(1); + const artwork = card.locator('.achievement-emblem img'); + // Version proof comes from the production registry's rendered metadata; + // a pre-v2 image cannot silently produce an accepted v2 screenshot. + await expect(artwork).toHaveAttribute('data-achievement-artwork',`${key}-v242-v2.png`); + await card.scrollIntoViewIfNeeded(); + await expect.poll(() => artwork.evaluate(el => el.complete && el.naturalWidth > 0)).toBe(true); + const geometry = await card.evaluate(el => ({ + emblem:el.querySelector('.achievement-emblem').getBoundingClientRect().width, + titleFont:parseFloat(getComputedStyle(el.querySelector('h4')).fontSize), + titleFits:el.querySelector('h4').scrollWidth <= el.querySelector('h4').clientWidth + 1, + closed:!el.querySelector('details').open, + noPageOverflow:document.documentElement.scrollWidth <= innerWidth + 1, + })); + expect(geometry).toEqual({emblem,titleFont:expect.any(Number),titleFits:true,closed:true,noPageOverflow:true}); + expect(geometry.titleFont).toBeGreaterThanOrEqual(13); + // The standard fixture has no invented money awards. These captures + // faithfully show the production locked/evidence-pending card state. + await card.screenshot({path:testInfo.outputPath(`${locale}-${theme}-${size}-${key}-v242-v2.png`),animations:'disabled'}); + } + expect(await page.evaluate(() => window.__ccuPostedMessages.some(m => + ['refresh','updateSetting','setAchievementPinned','editAchievementShowcase','previewAchievementShare','exportAchievementShare'].includes(m.command)))).toBe(false); + }); + } +}); + +async function expectThemeFidelity(page, theme) { + const tokens = await page.evaluate(() => { + const css = Array.from(document.querySelectorAll('head style:not(#test-vscode-theme)')) + .map(el => el.textContent).join('\n'); + const harness = document.querySelector('#test-vscode-theme').textContent; + const defined = new Map(Array.from(harness.matchAll(/(--vscode-[\w-]+)\s*:\s*([^;{}]*);/g), m => [m[1],m[2].trim()])); + const referenced = Array.from(new Set(Array.from(css.matchAll(/var\(\s*(--vscode-[\w-]+)/g), m => m[1]))); + // Only explicit `initial` for these registered nullable theme colors may + // compute empty. Every reference, including fallbacks, needs a definition. + const nullable = new Set(['--vscode-input-border','--vscode-textBlockQuote-background','--vscode-toolbar-hoverBackground', + '--vscode-button-secondaryBackground','--vscode-button-secondaryHoverBackground','--vscode-charts-orange', + '--vscode-list-inactiveSelectionBackground','--vscode-widget-shadow']); + const computed = getComputedStyle(document.documentElement); + return { + referenced: referenced.length, + missingRequired: referenced.filter(name => !defined.has(name) || (!computed.getPropertyValue(name).trim() && + !(nullable.has(name) && defined.get(name) === 'initial'))), + background: getComputedStyle(document.documentElement).getPropertyValue('--vscode-editor-background').trim(), + bodyClass: document.body.className, + }; + }); + expect(tokens.referenced).toBeGreaterThan(0); + expect(tokens.missingRequired).toEqual([]); + expect(tokens.background).toBe({light:'#ffffff',dark:'#1e1e1e',highContrast:'#000000'}[theme]); + expect(tokens.bodyClass).toContain({light:'vscode-light',dark:'vscode-dark',highContrast:'vscode-high-contrast'}[theme]); +} + +test.describe('gallery locale, size, theme and narrow-layout matrix', () => { + // Chromium's touch-capable context exercises the production coarse-pointer + // media query; no CSS replacement is used to manufacture the 44px hit target. + test.use({hasTouch:true}); + for (const locale of galleryLocales) for (const theme of galleryThemes) for (const width of [320,360]) { + test(`${locale} ${theme} ${width}px: all sizes, coarse targets and 200% CSS zoom`, async ({page}, testInfo) => { + await openClaude(page,{locale,theme,fixture:'achievement-six-pins',width,height:900}); + await page.locator('#tab-achievements').click(); + await expectThemeFidelity(page,theme); + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(true); + await page.locator('#achievementShowcaseEditor > summary').click(); + const collection = page.locator('.achievement-collection'); + const toolbar = page.locator('.achievement-filter-toolbar'); + const card = page.locator('.achievement-card').first(); + for (const zoom of [1,2]) { + // CSS zoom is a layout stress surrogate, not a claim of native browser + // zoom. Doubling the surface retains 320/360 effective CSS pixels. + await page.setViewportSize({width:width*zoom,height:900}); + await page.evaluate(value => { document.documentElement.style.zoom = String(value); },zoom); + let baseFonts; + for (const [size,emblem] of gallerySizes) { + await page.locator('#achievementSize').selectOption(size); + await expect(collection).toHaveAttribute('data-achievement-size',size); + const layout = await page.evaluate(({zoom}) => { + const root = document.querySelector('.achievement-collection'); + const toolbar = root.querySelector('.achievement-filter-toolbar'); + const editor = root.querySelector('#achievementShowcaseEditor'); + const overview = root.querySelector('[data-achievement-overview="badges"]'); + const first = root.querySelector('.achievement-card'); + const textSelectors = ['h4','.achievement-next-target']; + const fonts = textSelectors.map(selector => parseFloat(getComputedStyle(first.querySelector(selector)).fontSize)); + const overflowingText = Array.from(root.querySelectorAll('.achievement-card h4,.achievement-next-target,.achievement-overview-name,.achievement-selected-pins li > span')) + .filter(el => el.getClientRects().length && el.scrollWidth > el.clientWidth + 1) + .map(el => el.textContent.trim()); + const pinTargets = Array.from(root.querySelectorAll('.achievement-pin-icon')) + .filter(el => el.getClientRects().length) + .map(el => ({width:el.getBoundingClientRect().width/zoom,height:el.getBoundingClientRect().height/zoom})); + return { fonts,overflowingText,pinTargets, + overflow:document.documentElement.scrollWidth > innerWidth + 1, + documentWidth:document.documentElement.scrollWidth,viewportWidth:innerWidth, + overflowElements:Array.from(root.querySelectorAll('*')).filter(el => { + if (!el.getClientRects().length) return false; + const rect = el.getBoundingClientRect(); + return rect.right > innerWidth + 1 || rect.left < -1; + }).slice(0,8).map(el => ({tag:el.tagName,id:el.id,class:el.className, + right:el.getBoundingClientRect().right,left:el.getBoundingClientRect().left})), + toolbarBeforeEditor:Boolean(toolbar.compareDocumentPosition(editor) & Node.DOCUMENT_POSITION_FOLLOWING), + toolbarBeforeOverview:Boolean(toolbar.compareDocumentPosition(overview) & Node.DOCUMENT_POSITION_FOLLOWING), + emblem:first.querySelector('.achievement-emblem').getBoundingClientRect().width/zoom, + }; + },{zoom}); + expect(layout.overflow,JSON.stringify({locale,theme,width,zoom,size, + documentWidth:layout.documentWidth,viewportWidth:layout.viewportWidth,overflowElements:layout.overflowElements})).toBe(false); + expect(layout.overflowingText).toEqual([]); + expect(layout.toolbarBeforeEditor).toBe(true); + expect(layout.toolbarBeforeOverview).toBe(true); + expect(layout.emblem).toBeCloseTo(emblem,1); + expect(layout.fonts[0]).toBeGreaterThanOrEqual(13); + expect(layout.fonts[1]).toBeGreaterThanOrEqual(11.5); + if (!baseFonts) baseFonts = layout.fonts; + expect(layout.fonts).toEqual(baseFonts); + expect(layout.pinTargets.length).toBeGreaterThan(0); + for (const target of layout.pinTargets) { + expect(target.width).toBeGreaterThanOrEqual(44); + expect(target.height).toBeGreaterThanOrEqual(44); + } + await toolbar.scrollIntoViewIfNeeded(); + await page.screenshot({path:testInfo.outputPath(`${size}-${zoom*100}pct-controls.png`),animations:'disabled'}); + await card.scrollIntoViewIfNeeded(); + await page.screenshot({path:testInfo.outputPath(`${size}-${zoom*100}pct-gallery.png`),animations:'disabled'}); + } + } + await page.evaluate(() => { document.documentElement.style.zoom = ''; }); + await page.setViewportSize({width,height:900}); + await page.locator('#achievementFilter').selectOption('earned'); + await page.reload(); + await expect(page.locator('#achievementSize')).toHaveValue('large'); + await expect(page.locator('#achievementFilter')).toHaveValue('earned'); + await expect(page.locator('#achievementShowcaseEditor')).toHaveAttribute('open'); + expect(await page.evaluate(() => window.__ccuPostedMessages.some(m => + ['refresh','updateSetting','setAchievementPinned','editAchievementShowcase'].includes(m.command)))).toBe(false); + }); + } +}); + +test('a pin is visible on a closed card, sends one document-bound intent and does not expand', async ({page}) => { + await openClaude(page,{width:320,fixture:'achievement-pins'}); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-key="local-share-export"]'); + const pin = card.locator('[data-achievement-pin="local-share-export"]'); + await expect(card.locator('details')).not.toHaveAttribute('open'); + await expect(pin).toBeVisible(); + expect(await pin.evaluate(el=>el.getBoundingClientRect().width)).toBeGreaterThanOrEqual(32); + await pin.focus(); await pin.press('Enter'); + await expect(card.locator('details')).not.toHaveAttribute('open'); + const requests = await page.evaluate(()=>window.__ccuPostedMessages.filter(m=>m.command==='setAchievementPinned')); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({key:'local-share-export',selected:false,generation:1}); + expect(requests[0].documentId).toMatch(/^[0-9a-f]{48}$/); + await expect(pin).toBeDisabled(); + await page.evaluate(()=>dispatchEvent(new MessageEvent('message',{data:{command:'achievementPinResult',key:'local-share-export',ok:false,reason:'failed'}}))); + await expect(pin).toBeEnabled(); await expect(pin).toHaveAttribute('aria-pressed','true'); +}); + +test('six explicit pins stay manual; keyboard reorder includes the complete saved list', async ({page}) => { + await openClaude(page,{fixture:'achievement-six-pins',width:360}); + await page.locator('#tab-achievements').click(); + await expect(page.locator('[data-achievement-overview="badges"] .achievement-overview-badge')).toHaveCount(6); + await page.locator('#achievementShowcaseEditor > summary').click(); + await expect(page.locator('#achievementSelectedCount')).toContainText('6/6'); + const selected = page.locator('#achievementSelectedPins > li'); + await expect(selected).toHaveCount(6); + const before = await selected.evaluateAll(rows=>rows.map(row=>row.dataset.pinKey)); + await selected.nth(1).locator('[data-pin-move="up"]').focus(); + await selected.nth(1).locator('[data-pin-move="up"]').press('Enter'); + const request = await page.evaluate(()=>window.__ccuPostedMessages.find(m=>m.command==='editAchievementShowcase')); + expect(request).toMatchObject({action:'reorder',generation:1,revision:0}); + expect(request.keys).toEqual([before[1],before[0],...before.slice(2)]); + expect(request.documentId).toMatch(/^[0-9a-f]{48}$/); +}); + +test('confirmed reorder recovers focus on the same pin down button when its up button becomes disabled', async ({page}) => { + await openClaude(page,{fixture:'achievement-six-pins',claudeOnly:true,width:360}); + await page.locator('#tab-achievements').click(); + await page.locator('#achievementShowcaseEditor > summary').click(); + const before = await rememberShareAndDocument(page); + const keys = await page.locator('#achievementSelectedPins > li').evaluateAll(rows => rows.map(row => row.dataset.pinKey)); + const up = page.locator(`#achievement-move-up-${keys[1]}`); + await up.focus(); await up.press('Enter'); + await expect(up).toBeFocused(); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(m => m.command === 'editAchievementShowcase'))) + .toEqual([{command:'editAchievementShowcase',action:'reorder',generation:1,revision:0, + documentId:expect.stringMatching(/^[0-9a-f]{48}$/),keys:[keys[1],keys[0],...keys.slice(2)]}]); + await confirmAchievementPatch(page,'achievement-six-pins-reordered'); + await expect(page.locator('#achievementSelectedPins > li').first()).toHaveAttribute('data-pin-key',keys[1]); + await expect(up).toBeDisabled(); + await expect(page.locator(`#achievement-move-down-${keys[1]}`)).toBeFocused(); + await expect(page.locator('#achievementShowcaseEditor')).toHaveAttribute('open'); + await expectShareAndDocumentUnchanged(page,before); +}); + +test('confirmed final pin removal recovers focus on the stable editor summary', async ({page}) => { + await openClaude(page,{fixture:'achievement-last-pin',claudeOnly:true,width:320}); + await page.locator('#tab-achievements').click(); + await page.locator('#achievementShowcaseEditor > summary').click(); + const before = await rememberShareAndDocument(page); + const unpin = page.locator('#achievement-edit-unpin-local-share-export'); + await unpin.focus(); await unpin.press('Enter'); + await expect(unpin).toBeFocused(); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(m => m.command === 'setAchievementPinned'))) + .toEqual([{command:'setAchievementPinned',key:'local-share-export',selected:false,generation:1, + documentId:expect.stringMatching(/^[0-9a-f]{48}$/)}]); + await confirmAchievementPatch(page,'achievement-last-pin-removed'); + await expect(page.locator('#achievementSelectedPins > li')).toHaveCount(0); + await expect(page.locator('#achievementSelectedCount')).toContainText('0/6'); + await expect(page.locator('#achievementShowcaseEditorSummary')).toBeFocused(); + await expect(page.locator('#achievementShowcaseEditor')).toHaveAttribute('open'); + await expectShareAndDocumentUnchanged(page,before); +}); diff --git a/tests/ui/achievement-journey.spec.mjs b/tests/ui/achievement-journey.spec.mjs new file mode 100644 index 00000000..eb20fba0 --- /dev/null +++ b/tests/ui/achievement-journey.spec.mjs @@ -0,0 +1,54 @@ +import { test, expect, openCodex } from './support/app.mjs'; + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} saved local feature steps stay readable, honest and folded after reload`, async ({ page }, testInfo) => { + for (const theme of ['light', 'dark']) { + await openCodex(page, { fixture: 'achievement-journey-partial', width: 320, theme, locale }); + await page.evaluate(() => localStorage.removeItem('__ccu-vscode-state')); await page.reload(); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-key="safe-feature-explorer"]'); + await expect(card.locator('details')).not.toHaveAttribute('open'); + await card.locator('summary').focus(); await card.locator('summary').press('Enter'); + const journey = card.locator('.achievement-feature-journey'); + await expect(journey.locator('[data-achievement-step]')).toHaveCount(3); + await expect(journey.locator('[data-complete="true"]')).toHaveCount(2); + await expect(journey.locator('[data-achievement-step="localShareExport"]')).toHaveAttribute('data-complete', 'false'); + await expect(journey.locator('button,a,input')).toHaveCount(0); + await page.clock.runFor(200); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await card.screenshot({ path: testInfo.outputPath(`journey-${locale}-${theme}.png`) }); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(journey.locator('[data-complete="true"]')).toHaveCount(2); + const full = new URL(page.url()); full.searchParams.set('fixture', 'achievement-journey-complete'); + const response = await page.context().request.get(full.href); expect(response.ok()).toBe(true); + await card.locator('summary').focus(); + await page.evaluate(html => { + const panel = new DOMParser().parseFromString(html, 'text/html').getElementById('provider-panel'); + window.__ccuPostedMessages = []; + window.dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', provider: 'codex', + tab: 'achievements', revision: 1, html: panel.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + }, await response.text()); + await page.clock.runFor(200); + await expect(card.locator('summary')).toBeFocused(); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(journey.locator('[data-complete="true"]')).toHaveCount(3); + for (const command of ['refresh', 'exportAchievementShare', 'previewAchievementShare', 'achievementNoticeAck', 'updateSetting']) + expect(await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command))).not.toContain(command); + await page.goto(full.href); await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(journey.locator('[data-complete="true"]')).toHaveCount(3); + } + }); +} + +test('historical award artwork does not invent recorded steps or request a new export', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-journey-history', width: 360 }); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-key="safe-feature-explorer"]'); + await expect(card).toHaveAttribute('data-achievement-earned', 'true'); + await card.locator('summary').click(); + await expect(card.locator('.achievement-feature-journey [data-complete="false"]')).toHaveCount(3); + expect(await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command))).not.toContain('exportAchievementShare'); +}); diff --git a/tests/ui/achievement-long-term.spec.mjs b/tests/ui/achievement-long-term.spec.mjs new file mode 100644 index 00000000..23de4011 --- /dev/null +++ b/tests/ui/achievement-long-term.spec.mjs @@ -0,0 +1,44 @@ +import AxeBuilder from '@axe-core/playwright'; +import {test,expect,openClaude} from './support/app.mjs'; + +async function upgrade(page,rank,revision) { + const url=new URL(page.url());url.searchParams.set('fixture','achievement-long-term-'+rank); + const response=await page.context().request.get(url.href); + expect(response.ok()).toBe(true); + await page.evaluate(({html,revision,url})=>{ + const next=new DOMParser().parseFromString(html,'text/html').getElementById('provider-panel'); + dispatchEvent(new MessageEvent('message',{data:{command:'dashboardDataPatch',provider:'claude', + tab:'achievements',revision,html:next.innerHTML,claudeLast30HoursByDay:{},adviceSnapshotIds:{}}})); + history.replaceState(null,'',url); + },{html:await response.text(),revision,url:url.href}); + await page.clock.runFor(200); +} + +for(const locale of ['en','zh-CN','zh-TW','de-DE','ja','ko','pt-BR','id']) for(const theme of ['dark','light','highContrast']) { + test(`${locale} ${theme}: Gold to Platinum to Legendary preserves details and explicit sharing`,async({page},testInfo)=>{ + await openClaude(page,{locale,theme,fixture:'achievement-long-term-gold',claudeOnly:true,width:320}); + await page.locator('#tab-achievements').click(); + const card=page.locator('.achievement-card[data-achievement-key="sonnet-main"]'); + await expect(card).toHaveAttribute('data-achievement-tier','gold'); + await card.locator('summary').focus();await page.keyboard.press('Enter'); + await expect(card.locator('details')).toHaveAttribute('open',''); + await expect(card.locator('[data-achievement-level]')).toHaveCount(5); + await page.evaluate(()=>localStorage.setItem('ccu.sharing.achievementBadges',JSON.stringify(['sonnet-main-gold']))); + for(const [index,rank] of ['platinum','legendary'].entries()) { + await upgrade(page,rank,index+1); + await expect(card).toHaveAttribute('data-achievement-tier',rank); + await expect(card.locator('details')).toHaveAttribute('open',''); + expect(await page.evaluate(()=>JSON.parse(localStorage.getItem('ccu.sharing.achievementBadges')))).toEqual(['sonnet-main-gold']); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + expect(await card.locator('h4').evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true); + await card.scrollIntoViewIfNeeded(); + await page.screenshot({path:testInfo.outputPath(rank+'.png'),animations:'disabled'}); + } + const accessibility=await new AxeBuilder({page}).include('.achievement-collection').analyze(); + expect(accessibility.violations).toEqual([]); + await page.reload(); + await expect(card).toHaveAttribute('data-achievement-tier','legendary'); + await expect(card.locator('details')).toHaveAttribute('open',''); + expect(await page.evaluate(()=>JSON.parse(localStorage.getItem('ccu.sharing.achievementBadges')))).toEqual(['sonnet-main-gold']); + }); +} diff --git a/tests/ui/achievement-management.spec.mjs b/tests/ui/achievement-management.spec.mjs new file mode 100644 index 00000000..24843904 --- /dev/null +++ b/tests/ui/achievement-management.spec.mjs @@ -0,0 +1,92 @@ +import {test,expect,openClaude} from './support/app.mjs'; +import AxeBuilder from '@axe-core/playwright'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {I18n}=require('../../out/i18n.js'); +const locales=['en','de-DE','zh-TW','zh-CN','ja','ko','pt-BR','id']; +async function confirmedUpdate(page){ + const response=await page.context().request.get(page.url()); + expect(response.ok()).toBe(true); + await page.evaluate(html=>{ + const panel=new DOMParser().parseFromString(html,'text/html').getElementById('provider-panel'); + dispatchEvent(new MessageEvent('message',{data:{command:'dashboardDataPatch',provider:'claude',tab:'achievements', + revision:71,html:panel.innerHTML,claudeLast30HoursByDay:{},adviceSnapshotIds:{}}})); + },await response.text()); + await expect.poll(()=>page.evaluate(()=>window.__ccuPostedMessages.some(r=>r.command==='dashboardDataPatchAck'&&r.revision===71&&r.ok))).toBe(true); + await page.clock.runFor(200); +} +for(const locale of locales)for(const theme of ['light','dark','highContrast']){ + test(locale+' '+theme+': batch disclosures persist through update/reload without affecting evidence or grants',async({page},info)=>{ + I18n.setLanguage(locale); + const copy=I18n.achievementManagement; + await openClaude(page,{locale,theme,fixture:'achievement-pins',claudeOnly:true,width:1280}); + await page.locator('#tab-achievements').click(); + const cards=page.locator('details.achievement-disclosure'); + expect(await cards.count()).toBeGreaterThan(20); + const actions=['achievementExpandAll','achievementCollapseAll','achievementRecalibrate']; + for(const id of actions){ + await expect(page.locator('#'+id)).toBeVisible(); + await expect(page.locator('#'+id)).toHaveAccessibleName(/\S/); + await expect(page.locator('#'+id+' svg')).toHaveAttribute('aria-hidden','true'); + } + await page.locator('#achievementExpandAll').focus();await page.keyboard.press('Enter'); + await expect.poll(()=>cards.evaluateAll(rows=>rows.every(r=>r.open))).toBe(true); + await expect(page.locator('#achievementExpandAll')).toBeFocused(); + await expect(page.locator('.achievement-evidence-note')).not.toHaveAttribute('open',''); + await confirmedUpdate(page); + await expect.poll(()=>cards.evaluateAll(rows=>rows.every(r=>r.open))).toBe(true); + await page.reload();await expect.poll(()=>cards.evaluateAll(rows=>rows.every(r=>r.open))).toBe(true); + await page.locator('#achievementFilter').selectOption('earned'); + await page.locator('#achievementCollapseAll').focus();await page.keyboard.press('Enter'); + await expect.poll(()=>cards.evaluateAll(rows=>rows.every(r=>!r.open))).toBe(true); + await page.reload();await expect.poll(()=>cards.evaluateAll(rows=>rows.every(r=>!r.open))).toBe(true); + await expect(page.locator('#achievementFilter')).toHaveValue('earned'); + await page.setViewportSize({width:320,height:900}); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); + await expect(page.locator('#achievementExpandAll')).toHaveText(copy.expandAll); + const audit=await new AxeBuilder({page}).include('.achievement-filter-toolbar').include('.achievement-intro').analyze(); + expect(audit.violations.filter(v=>['serious','critical'].includes(v.impact))).toEqual([]); + if(locale==='zh-CN')await page.screenshot({path:info.outputPath('management-'+theme+'-320.png'),animations:'disabled'}); + const forbidden=await page.evaluate(()=>window.__ccuPostedMessages.filter(r=>/^(refresh|updateSetting|setAchievementPinned|recalibrateAchievements|achievementNoticeAck|markAchievementGroupRead)$/.test(r.command))); + expect(forbidden).toEqual([]); + }); +} +test('different levels display their retained-log crossing date without replacing original records',async({page})=>{ + await openClaude(page,{locale:'zh-CN',fixture:'achievement-management-history',claudeOnly:true,timeZone:'UTC'}); + await page.locator('#tab-achievements').click(); + const card=page.locator('[data-achievement-key="sonnet-main"]'); + await card.locator('summary').click(); + await expect(card.locator('[data-achievement-level="sonnet-main"] time')).toHaveAttribute('datetime','2026-06-01'); + await expect(card.locator('[data-achievement-level="sonnet-main-silver"] time')).toHaveAttribute('datetime','2026-06-03'); + await expect(card.locator('[data-achievement-level="sonnet-main-gold"] time')).toHaveCount(0); + await expect(card.locator('.achievement-common-conditions')).toHaveCount(1); + await expect(card.locator('[data-achievement-level="sonnet-main"] strong')).toHaveText('铜'); + await expect(card.locator('[data-achievement-level="sonnet-main-silver"] strong')).toHaveText('银'); + await expect(card.locator('[data-achievement-level="sonnet-main-gold"] strong')).toHaveText('金'); + await page.reload();await expect(card.locator('[data-achievement-level="sonnet-main-silver"] time')).toHaveAttribute('datetime','2026-06-03'); +}); +test('highest earned rank retains its actual current value above the final threshold',async({page})=>{ + await openClaude(page,{locale:'en',fixture:'achievement-management-full',claudeOnly:true}); + await page.locator('#tab-achievements').click(); + const card=page.locator('[data-achievement-key="sonnet-main"]'); + await expect(card.locator('.achievement-current-value')).toContainText('60B'); + await expect(card.locator('.achievement-current-value')).not.toContainText('50B'); + await card.locator('summary').click(); + await expect(card.locator('.achievement-progress-label')).toContainText('60B'); + await expect(card.locator('.achievement-progress-label')).not.toContainText('60B / 50B'); +}); +test('explicit recalibration is generation-bound and pending cannot double-submit',async({page})=>{ + await openClaude(page,{locale:'en',fixture:'achievement-pins',claudeOnly:true}); + await page.locator('#tab-achievements').click(); + await page.locator('#achievementRecalibrate').focus();await page.keyboard.press('Enter'); + await expect(page.locator('#achievementRecalibrate')).toBeDisabled(); + const requests=await page.evaluate(()=>window.__ccuPostedMessages.filter(r=>r.command==='recalibrateAchievements')); + expect(requests).toHaveLength(1);expect(requests[0].documentId).toMatch(/^[a-f0-9]{48}$/); + await page.evaluate(request=>dispatchEvent(new MessageEvent('message',{data:{command:'achievementCalibrationState', + documentId:'wrong',pending:false,failed:false,text:'not accepted'}})),requests[0]); + await expect(page.locator('#achievementRecalibrate')).toBeDisabled(); + await page.evaluate(request=>dispatchEvent(new MessageEvent('message',{data:{command:'achievementCalibrationState', + documentId:request.documentId,pending:false,failed:true,text:'Could not recalibrate.'}})),requests[0]); + await expect(page.locator('#achievementRecalibrate')).toBeEnabled(); + await expect(page.locator('#achievementCalibrationStatus')).toHaveText('Could not recalibrate.'); +}); diff --git a/tests/ui/achievement-notice-motion.spec.mjs b/tests/ui/achievement-notice-motion.spec.mjs new file mode 100644 index 00000000..779b55da --- /dev/null +++ b/tests/ui/achievement-notice-motion.spec.mjs @@ -0,0 +1,74 @@ +import { test, expect, openCodex } from './support/app.mjs'; + +// Keep every builtin theme value, removing only the harness-wide motion override. +// Otherwise screenshots' deterministic override would hide production animation. +async function allowProductionMotion(page) { + await page.evaluate(() => { + const style = document.getElementById('test-vscode-theme'); + style.textContent = style.textContent.replace(/\*\s*,\s*\*::before\s*,\s*\*::after\s*\{[^}]*animation[^}]*\}/g, ''); + }); +} +async function show(page) { + await page.locator('#tab-achievements').focus(); + await page.evaluate(() => { + window.__ccuPostedMessages = []; + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementUnlockNotice', text: 'Astra unlocked', + iconFile: 'codex-astra-regular.png', noticeId: 'a'.repeat(48), + } })); + }); +} + +test('new award animates only its emblem once, without focus theft or extra messages', async ({ page }, testInfo) => { + await openCodex(page); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await allowProductionMotion(page); + const html = await (await page.context().request.get(page.url())).text(); + await page.clock.pauseAt(await page.evaluate(() => new Date(Date.now() + 60000).toISOString())); + await show(page); + const notice = page.locator('#ccuAchievementUnlockNotice'); + const icon = notice.locator('img'); + const style = await icon.evaluate(el => { + const s = getComputedStyle(el); + return { name: s.animationName, duration: parseFloat(s.animationDuration), count: s.animationIterationCount }; + }); + expect(style.name).toBe('ccuBadgeArrive'); + expect(style.duration).toBeGreaterThan(0); expect(style.duration).toBeLessThanOrEqual(.6); + expect(style.count).toBe('1'); + expect(await notice.evaluate(el => getComputedStyle(el).animationName)).toBe('none'); + await expect(page.locator('#tab-achievements')).toBeFocused(); + await page.clock.runFor(700); + // Browser CSS uses the real document timeline, unlike the mocked JS dismissal clock. + await expect.poll(() => icon.evaluate(el => el.getAnimations().filter(a => a.playState === 'running').length)).toBe(0); + await page.evaluate(html => { + const panel = new DOMParser().parseFromString(html, 'text/html').getElementById('provider-panel'); + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'codex', tab: 'today', revision: 1, + html: panel.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {}, + } })); + }, html); + await page.clock.runFor(200); + expect(await icon.evaluate(el => el.getAnimations().filter(a => a.playState === 'running').length)).toBe(0); + await notice.screenshot({ path: testInfo.outputPath('notice-static-after-single-motion.png') }); + await page.clock.runFor(4000); + await expect(notice).toBeVisible(); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(row => row.command === 'achievementNoticeAck'))).toEqual([ + { command: 'achievementNoticeAck', noticeId: 'a'.repeat(48) }, + ]); + for (const command of ['refresh', 'exportAchievementShare', 'updateSetting']) + expect(await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command))).not.toContain(command); + await page.clock.runFor(1500); await expect(notice).toHaveCount(0); + await page.reload(); await expect(notice).toHaveCount(0); +}); + +for (const mode of ['reduced', 'high-contrast', 'high-contrast-light']) { + test(`${mode} keeps new award feedback entirely static`, async ({ page }) => { + await openCodex(page); + await page.emulateMedia({ reducedMotion: mode === 'reduced' ? 'reduce' : 'no-preference' }); + if (mode !== 'reduced') await page.evaluate(mode => { document.body.className = `vscode-${mode}`; }, mode); + await allowProductionMotion(page); await show(page); + const icon = page.locator('#ccuAchievementUnlockNotice img'); + expect(await icon.evaluate(el => getComputedStyle(el).animationName)).toBe('none'); + await expect(page.locator('#tab-achievements')).toBeFocused(); + }); +} diff --git a/tests/ui/achievement-notice-preference.spec.mjs b/tests/ui/achievement-notice-preference.spec.mjs new file mode 100644 index 00000000..82fe26a7 --- /dev/null +++ b/tests/ui/achievement-notice-preference.spec.mjs @@ -0,0 +1,54 @@ +import { test, expect, openCodex, openClaude } from './support/app.mjs'; + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} notice opt-out is separate, keyboard-operated and fits narrow themes`, async ({ page }, testInfo) => { + for (const theme of ['light', 'dark']) for (const open of [openClaude, openCodex]) { + await open(page, { locale, theme, width: 320 }); + await page.locator('#tab-settings').click(); + const control = page.locator('#set_showAchievementNotices'); + const switchLabel = control.locator('..'); + await switchLabel.scrollIntoViewIfNeeded(); + await expect(switchLabel).toBeVisible(); await expect(control).toBeChecked(); + const label = page.locator('label[for="set_showAchievementNotices"]'); + await expect(label).not.toBeEmpty(); + await expect(control).toHaveAccessibleName(await label.innerText()); + await control.focus(); + // Establish real keyboard modality after the pointer-selected Settings tab. + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(control).toBeFocused(); + expect(await switchLabel.locator('.set-slider').evaluate(element => getComputedStyle(element).outlineStyle)).toBe('solid'); + await control.press('Space'); + expect(await page.evaluate(() => window.__ccuPostedMessages.at(-1))) + .toEqual({ command: 'updateSetting', key: 'showAchievementNotices', value: false }); + await expect(page.locator('#set_enableAchievements')).toBeChecked(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`notice-preference-${locale}-${theme}-${open === openClaude ? 'claude' : 'codex'}.png`) }); + // Simulated host re-render after persistence; real preference persistence is a Node SettingsStore test. + const url = new URL(page.url()); url.searchParams.set('achievementNotices', 'false'); + await page.goto(url.href); await page.reload(); + await expect(control).not.toBeChecked(); + await expect(page.locator('#tab-achievements')).toBeVisible(); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementUnlockNotice', text: 'Retired notice', noticeId: 'b'.repeat(48), + } }))); + await expect(page.locator('#ccuAchievementUnlockNotice')).toHaveCount(0); + expect(await page.evaluate(() => window.__ccuPostedMessages.some(row => row.command === 'achievementNoticeAck'))).toBe(false); + } + }); +} + +test('opting out immediately dismisses an already visible notice without confirming more awards', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-records' }); + await page.locator('#tab-settings').click(); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementUnlockNotice', text: 'New badge', + } }))); + await expect(page.locator('#ccuAchievementUnlockNotice')).toBeVisible(); + await page.locator('#set_showAchievementNotices').focus(); + await page.locator('#set_showAchievementNotices').press('Space'); + await expect(page.locator('#ccuAchievementUnlockNotice')).toHaveCount(0); + await page.locator('#tab-achievements').click(); + await expect(page.locator('[data-persist="achievement:unread"]')).toBeVisible(); + expect(await page.evaluate(() => window.__ccuPostedMessages.some(row => row.command === 'achievementSummaryAck'))).toBe(false); +}); diff --git a/tests/ui/achievement-overview-layout.spec.mjs b/tests/ui/achievement-overview-layout.spec.mjs new file mode 100644 index 00000000..7ad4d0bc --- /dev/null +++ b/tests/ui/achievement-overview-layout.spec.mjs @@ -0,0 +1,101 @@ +import { test, expect, openClaude } from './support/app.mjs'; +import AxeBuilder from '@axe-core/playwright'; + +for (const theme of ['dark','light','highContrast']) { + test(`showcase pairs badges with goals and uses accessible illustrated controls: ${theme}`, async ({page},testInfo) => { + await openClaude(page,{locale:'zh-CN',theme,fixture:'achievement-pins',width:1440}); + await page.locator('#tab-achievements').click(); + const showcase=page.locator('.achievement-showcase'); + await expect(showcase.locator(':scope > [data-achievement-overview]')).toHaveCount(2); + const badges=showcase.locator('[data-achievement-overview="badges"]'); + const next=showcase.locator('[data-achievement-overview="next"]'); + const a=await badges.boundingBox(),b=await next.boundingBox(); + expect(Math.abs(a.y-b.y)).toBeLessThanOrEqual(1); + expect(b.x-a.x-a.width).toBeLessThanOrEqual(20); + for(const command of ['openAchievementShare()','openAchievementSettings()']) { + const button=page.locator('.achievement-intro button[onclick="'+command+'"]'); + await expect(button).toBeVisible(); + await expect(button).toHaveAccessibleName(/\S/); + await expect(button.locator('svg')).toHaveAttribute('aria-hidden','true'); + } + if(theme==='light'){ + const audit=await new AxeBuilder({page}).include('.achievement-filter-toolbar').analyze(); + expect(audit.violations.filter(row=>['serious','critical'].includes(row.impact))).toEqual([]); + } + await page.screenshot({path:testInfo.outputPath('compact-showcase-desktop.png'),animations:'disabled'}); + await page.setViewportSize({width:320,height:900}); + const narrowA=await badges.boundingBox(),narrowB=await next.boundingBox(); + expect(narrowB.y-narrowA.y-narrowA.height).toBeLessThanOrEqual(16); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await page.locator('#achievementSize').selectOption('small'); + await page.locator('#achievementFilter').selectOption('earned'); + await page.reload(); + await expect(page.locator('#achievementSize')).toHaveValue('small'); + await expect(page.locator('#achievementFilter')).toHaveValue('earned'); + await expect(page.locator('.achievement-showcase > [data-achievement-overview]')).toHaveCount(2); + await page.screenshot({path:testInfo.outputPath('compact-showcase-narrow.png'),animations:'disabled'}); + expect(await page.evaluate(()=>window.__ccuPostedMessages.some(row=>['refresh','updateSetting'].includes(row.command)))).toBe(false); + }); +} + +for (const theme of ['dark', 'light', 'highContrast']) { + test(`overview uses bounded horizontal cards rather than sparse columns: ${theme}`, async ({page}, testInfo) => { + await openClaude(page, {locale:'zh-CN', theme, fixture:'achievement-pins', width:1920}); + await page.locator('#tab-achievements').click(); + const items = page.locator('.achievement-overview-item'); + expect(await items.count()).toBeGreaterThan(0); + const geometry = await items.evaluateAll(rows => rows.map(row => { + const card = row.getBoundingClientRect(); + const image = row.querySelector('img').getBoundingClientRect(); + const name = row.querySelector('.achievement-overview-name').getBoundingClientRect(); + const pin = row.querySelector('.achievement-pin')?.getBoundingClientRect(); + return {width:card.width,height:card.height,horizontal:name.left>=image.right, + pinInside:!pin || pin.right<=card.right && pin.left>=card.left}; + })); + for (const card of geometry) { + expect(card.width).toBeLessThanOrEqual(400); + expect(card.height).toBeLessThanOrEqual(180); + expect(card.horizontal).toBe(true); + expect(card.pinInside).toBe(true); + } + await expect(page.locator('[data-achievement-overview="next"] .achievement-overview-track').first()).toBeVisible(); + await page.locator('.achievement-collection').scrollIntoViewIfNeeded(); + await page.screenshot({path:testInfo.outputPath('overview-desktop.png'),animations:'disabled'}); + await items.first().locator('.achievement-overview-badge').focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('.achievement-disclosure[open]').first()).toBeVisible(); + }); +} + +test('compact overview remains readable at 320px and preserves size after reload', async ({page},testInfo) => { + await openClaude(page,{locale:'de-DE',theme:'dark',fixture:'achievement-six-pins',width:320}); + await page.locator('#tab-achievements').click(); + for (const size of ['small','medium','large']) { + await page.locator('#achievementSize').selectOption(size); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await page.locator('.achievement-overview-name').evaluateAll(rows => rows.every(row => row.scrollWidth<=row.clientWidth+1))).toBe(true); + } + await page.reload(); + await expect(page.locator('#achievementSize')).toHaveValue('large'); + await page.locator('.achievement-collection').scrollIntoViewIfNeeded(); + await page.screenshot({path:testInfo.outputPath('overview-narrow.png'),animations:'disabled'}); + expect(await page.evaluate(() => window.__ccuPostedMessages.some(row => ['refresh','updateSetting'].includes(row.command)))).toBe(false); +}); + +test.describe('touch overview', () => { + test.use({hasTouch:true}); + test('pin hit areas never cover the adjacent badge title', async ({page}) => { + await openClaude(page,{locale:'de-DE',theme:'dark',fixture:'achievement-six-pins',width:320}); + await page.locator('#tab-achievements').click(); + for (const size of ['small','medium','large']) { + await page.locator('#achievementSize').selectOption(size); + const gaps = await page.locator('[data-achievement-overview="badges"] .achievement-overview-item').evaluateAll(rows => rows.map(row => { + const pin = row.querySelector('.achievement-pin')?.getBoundingClientRect(); + const name = row.querySelector('.achievement-overview-name').getBoundingClientRect(); + return pin ? pin.left-name.right : 0; + })); + expect(gaps.length).toBeGreaterThan(0); + expect(gaps.every(gap => gap >= 0)).toBe(true); + } + }); +}); diff --git a/tests/ui/achievement-pins.spec.mjs b/tests/ui/achievement-pins.spec.mjs new file mode 100644 index 00000000..ab96c6ad --- /dev/null +++ b/tests/ui/achievement-pins.spec.mjs @@ -0,0 +1,197 @@ +import { test, expect, openCodex, openClaude } from './support/app.mjs'; + +const unpinLabels = { + en: 'Unpin badge', 'de-DE': 'Abzeichen lösen', 'zh-TW': '取消固定', 'zh-CN': '取消固定', + ja: '固定を解除', ko: '고정 해제', 'pt-BR': 'Desafixar medalha', id: 'Lepas sematan', +}; + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} hidden pins can be removed with disabled Codex and no implicit side effects`, async ({ page }, testInfo) => { + for (const theme of ['light', 'dark']) { + await openClaude(page, { fixture: 'achievement-hidden-pins', claudeOnly: true, locale, theme, width: 320, height: 360 }); + // Theme iterations start fresh; reload preservation is asserted separately below. + await page.evaluate(() => localStorage.removeItem('__ccu-vscode-state')); + await page.reload(); + await page.locator('#tab-achievements').click(); + const manager = page.locator('[data-persist="achievement:hidden-pins"]'); + await expect(manager).not.toHaveAttribute('open'); + await manager.locator('summary').click(); + await expect(manager.locator('button')).toHaveCount(3); + await expect(page.locator('[data-achievement-key="astra-regular"]')).toHaveCount(0); + const button = page.locator('#achievement-unpin-astra-regular'); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await button.focus(); await button.press('Enter'); + expect(await page.evaluate(() => window.__ccuPostedMessages)).toEqual([ + { command: 'setAchievementPinned', key: 'astra-regular', selected: false, generation: 1, + documentId: expect.stringMatching(/^[0-9a-f]{48}$/) }, + ]); + await expect(button).toHaveAttribute('aria-pressed', 'true'); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementPinResult', key: 'astra-regular', ok: false, reason: 'failed', message: '/private/raw/path', + } }))); + await expect(page.locator('#achievementPinStatus')).not.toBeEmpty(); + await expect(page.locator('#achievementPinStatus')).not.toContainText('/private/raw/path'); + await expect(button).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`hidden-pins-${locale}-${theme}.png`) }); + // Renderer fixtures simulate durable host state; provider tests reopen a real isolated store. + const confirmedUrl = new URL(page.url()); confirmedUrl.searchParams.set('fixture', 'achievement-hidden-pins-released'); + const response = await page.context().request.get(confirmedUrl.href); + expect(response.ok()).toBe(true); + // Leave scroll headroom: deleting inline feedback at scrollY=0 cannot + // preserve an anchor's old position because browsers clamp negative scrolling. + await manager.locator('summary').evaluate(element => element.scrollIntoView({ block: 'center' })); + await page.clock.runFor(200); + expect(await page.evaluate(() => scrollY)).toBeGreaterThan(40); + const summaryTop = await manager.locator('summary').evaluate(element => element.getBoundingClientRect().top); + await page.evaluate(nextHtml => { + const next = new DOMParser().parseFromString(nextHtml, 'text/html').getElementById('provider-panel'); + window.__ccuPostedMessages = []; + window.__hiddenPinDocument = document.documentElement; + window.dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', provider: 'claude', + tab: 'achievements', revision: 1, html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + }, await response.text()); + await expect(button).toHaveCount(0); + await expect(manager).toHaveAttribute('open'); + await expect(manager.locator('button')).toHaveCount(2); + await page.clock.runFor(200); + await expect(manager.locator('summary')).toBeFocused(); + expect(Math.abs(await manager.locator('summary').evaluate(element => element.getBoundingClientRect().top) - summaryTop)).toBeLessThanOrEqual(1); + expect(await page.evaluate(() => document.documentElement === window.__hiddenPinDocument)).toBe(true); + const commands = await page.evaluate(() => window.__ccuPostedMessages.map(message => message.command)); + for (const command of ['refresh', 'achievementNoticeAck', 'previewAchievementShare', 'exportAchievementShare', 'updateSetting']) + expect(commands).not.toContain(command); + await page.goto(confirmedUrl.href); await page.reload(); + await expect(manager).toHaveAttribute('open'); + await expect(manager.locator('button')).toHaveCount(2); + } + }); +} + +test('removing the final hidden pin returns keyboard focus to achievements without refresh or consent', async ({ page }) => { + await openClaude(page, { fixture: 'achievement-hidden-pins-last', claudeOnly: true, width: 320 }); + await page.locator('#tab-achievements').click(); + await page.locator('[data-persist="achievement:hidden-pins"] summary').click(); + await page.locator('#achievement-unpin-astra-regular').focus(); + await page.locator('#achievement-unpin-astra-regular').press('Enter'); + const confirmedUrl = new URL(page.url()); confirmedUrl.searchParams.set('fixture', 'achievement-records'); + const response = await page.context().request.get(confirmedUrl.href); + expect(response.ok()).toBe(true); + await page.evaluate(nextHtml => { + const next = new DOMParser().parseFromString(nextHtml, 'text/html').getElementById('provider-panel'); + window.__ccuPostedMessages = []; + window.dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', provider: 'claude', + tab: 'achievements', revision: 1, html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + }, await response.text()); + await expect(page.locator('[data-persist="achievement:hidden-pins"]')).toHaveCount(0); + await page.clock.runFor(200); + await expect(page.locator('#tab-achievements')).toBeFocused(); + expect(await page.evaluate(() => window.__ccuPostedMessages.map(message => message.command))).toEqual(['dashboardDataPatchAck']); +}); + +test('pin button sends a generation-bound preference only and failures stay inline', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-records', width: 360 }); + await page.locator('#tab-achievements').click(); + const card = page.locator('.achievement-card[data-achievement-key="local-share-export"]'); + const button = page.locator('#achievement-pin-local-share-export'); + await expect(card.locator('details')).not.toHaveAttribute('open'); + await expect(button).toBeVisible(); + await expect(card.locator(':scope > .achievement-card-pins > button')).toHaveCount(1); + await expect(button).toHaveAttribute('aria-label', `Pin badge: ${await card.locator('h4').textContent()}`); + await expect(button).toHaveAttribute('aria-pressed', 'false'); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await button.focus(); await button.press('Enter'); + expect(await page.evaluate(() => window.__ccuPostedMessages)).toEqual([ + { command: 'setAchievementPinned', key: 'local-share-export', selected: true, generation: 1, + documentId: expect.stringMatching(/^[0-9a-f]{48}$/) }, + ]); + await expect(card.locator('details')).not.toHaveAttribute('open'); + await expect(button).toHaveAttribute('aria-pressed', 'false'); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementPinResult', key: 'local-share-export', ok: false, reason: 'limit', + } }))); + await expect(page.locator('#achievementPinStatus')).toHaveText('Pin up to 6 badges.'); + await expect(button).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +}); + +test('a host-confirmed pin patch keeps focus, disclosure, anchor and share state then hydrates after reload', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-records', width: 360, height: 600 }); + await page.locator('#tab-achievements').click(); + await page.locator('[data-achievement-key="local-share-export"] summary').click(); + const button = page.locator('#achievement-pin-local-share-export'); + await button.focus(); await button.press('Enter'); + const confirmedUrl = new URL(page.url()); confirmedUrl.searchParams.set('fixture', 'achievement-pins'); + const response = await page.context().request.get(confirmedUrl.href); + expect(response.ok()).toBe(true); + const nextHtml = await response.text(); + const before = await page.evaluate(nextHtml => { + // Browser bridge receives a fresh production-renderer fixture; real filesystem + // save and reopen are covered by the paired provider test, not this fake host. + const next = new DOMParser().parseFromString(nextHtml, 'text/html').getElementById('provider-panel'); + const button = document.getElementById('achievement-pin-local-share-export'); + window.__pinDocument = document.documentElement; + const state = { top: button.getBoundingClientRect().top, share: localStorage.getItem('ccu.sharing.achievementBadges') }; + window.__ccuPostedMessages = []; + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'codex', tab: 'achievements', revision: 1, + html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {}, + } })); + return state; + }, nextHtml); + await expect(button).toHaveAttribute('aria-pressed', 'true'); + await expect(button).toBeFocused(); + await expect(page.locator('[data-achievement-key="local-share-export"] details')).toHaveAttribute('open'); + const showcaseBadge = page.locator('[data-achievement-overview="badges"] .achievement-overview-badge').first(); + await expect(showcaseBadge).toHaveAttribute('data-achievement-open', 'local-share-export'); + const showcasePin = showcaseBadge.locator('..').locator('[data-achievement-pin="local-share-export"]'); + await expect(showcasePin).toBeVisible(); + await expect(showcasePin).toHaveAttribute('aria-pressed', 'true'); + await expect(showcasePin).toHaveAttribute('aria-label', `Unpin badge: ${await showcaseBadge.locator('.achievement-overview-name').textContent()}`); + await page.clock.runFor(200); + const after = await page.evaluate(() => ({ top: document.getElementById('achievement-pin-local-share-export').getBoundingClientRect().top, + sameDocument: document.documentElement === window.__pinDocument, share: localStorage.getItem('ccu.sharing.achievementBadges'), + commands: window.__ccuPostedMessages.map(message => message.command) })); + expect(after.sameDocument).toBe(true); + expect(Math.abs(after.top - before.top)).toBeLessThanOrEqual(1); + expect(after.share).toBe(before.share); + for (const command of ['refresh', 'achievementNoticeAck', 'previewAchievementShare', 'exportAchievementShare']) + expect(after.commands).not.toContain(command); + await page.goto(confirmedUrl.href); await page.reload(); + await expect(button).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('[data-achievement-key="local-share-export"] details')).toHaveAttribute('open'); + await button.click(); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'setAchievementPinned'))).toEqual([ + { command: 'setAchievementPinned', key: 'local-share-export', selected: false, generation: 1, + documentId: expect.stringMatching(/^[0-9a-f]{48}$/) }, + ]); +}); + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} pin actions and errors fit narrow themes with no raw host error`, async ({ page }, testInfo) => { + for (const theme of ['light', 'dark']) { + await openCodex(page, { fixture: 'achievement-pins', locale, theme, width: 320 }); + await page.locator('#tab-achievements').click(); + const card = page.locator('.achievement-card[data-achievement-key="local-share-export"]'); + const detail = card.locator('details'); + if (await detail.evaluate(element => element.open)) await detail.locator('summary').click(); + const button = page.locator('#achievement-pin-local-share-export'); + await expect(detail).not.toHaveAttribute('open'); + await expect(button).toBeVisible(); + await expect(card.locator(':scope > .achievement-card-pins > button')).toHaveCount(1); + await expect(button).toHaveAttribute('aria-label', `${unpinLabels[locale]}: ${await card.locator('h4').textContent()}`); + await detail.locator('summary').click(); + await expect(detail).toHaveAttribute('open'); + await expect(button).toHaveAttribute('aria-pressed', 'true'); + await button.focus(); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementPinResult', key: 'local-share-export', ok: false, reason: 'failed', message: '/private/raw/path', + } }))); + await expect(page.locator('#achievementPinStatus')).not.toBeEmpty(); + await expect(page.locator('#achievementPinStatus')).not.toContainText('/private/raw/path'); + await expect(button).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`pins-${locale}-${theme}.png`) }); + } + }); +} diff --git a/tests/ui/achievement-ready.spec.mjs b/tests/ui/achievement-ready.spec.mjs new file mode 100644 index 00000000..5529f870 --- /dev/null +++ b/tests/ui/achievement-ready.spec.mjs @@ -0,0 +1,36 @@ +import { test, expect, openCodex, openClaude, openCompare } from './support/app.mjs'; + +// Browser bridge proves client restoration, not canonical filesystem writes. +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + for (const provider of ['claude', 'codex']) { + test(`${locale} ${provider} readiness reports restored achievements then another tab without implicit action`, async ({ page }) => { + await (provider === 'claude' ? openClaude : openCodex)(page, { locale, fixture: 'achievement-records', width: 360 }); + await page.locator('#tab-achievements').click(); + await page.locator('[data-persist="achievement:records"] summary').click(); + await page.reload(); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('[data-persist="achievement:records"]')).toHaveAttribute('open'); + const restored = await page.evaluate(() => window.__ccuPostedMessages); + expect(restored.filter(message => message.command === 'localDataClientReady')).toEqual([ + { command: 'localDataClientReady', provider, activeTab: 'achievements', documentId: expect.stringMatching(/^[0-9a-f]{48}$/) }, + ]); + for (const command of ['tabChanged', 'achievementNoticeAck', 'setAchievementPinned', 'previewAchievementShare', + 'exportAchievementShare', 'refresh', 'updateSetting']) expect(restored.map(message => message.command)).not.toContain(command); + await page.locator('#tab-month').click(); + await page.reload(); + await expect(page.locator('#tab-month')).toHaveAttribute('aria-selected', 'true'); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'localDataClientReady'))).toEqual([ + { command: 'localDataClientReady', provider, activeTab: 'month', documentId: expect.stringMatching(/^[0-9a-f]{48}$/) }, + ]); + }); + } +} + +test('compare readiness reports its actual all panel despite an old achievement preference', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-records' }); + await page.locator('#tab-achievements').click(); + await openCompare(page); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter(message => message.command === 'localDataClientReady'))).toEqual([ + { command: 'localDataClientReady', provider: 'compare', activeTab: 'all', documentId: expect.stringMatching(/^[0-9a-f]{48}$/) }, + ]); +}); diff --git a/tests/ui/achievement-records.spec.mjs b/tests/ui/achievement-records.spec.mjs new file mode 100644 index 00000000..d80cdae0 --- /dev/null +++ b/tests/ui/achievement-records.spec.mjs @@ -0,0 +1,53 @@ +import { test, expect, openCodex } from './support/app.mjs'; + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} recorded awards fit both narrow themes and quiet history never appears recent`, async ({ page }, testInfo) => { + for (const theme of ['light', 'dark']) { + await openCodex(page, { fixture: 'achievement-records', width: 320, locale, theme }); + await page.locator('#tab-achievements').click(); + const recent = page.locator('[data-achievement-overview="recent"]'); + await expect(recent.locator('button')).toHaveCount(3); + await expect(recent.locator('time')).toHaveCount(3); + await expect(recent.locator('img')).toHaveCount(3); + const disclosure = page.locator('[data-persist="achievement:records"]'); + if (!await disclosure.evaluate(element => element.open)) await disclosure.locator('summary').click(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.reload(); + await expect(page.locator('[data-persist="achievement:records"]')).toHaveAttribute('open'); + await page.evaluate(() => scrollTo(0, 0)); + await page.screenshot({ path: testInfo.outputPath(`records-${locale}-${theme}.png`) }); + await openCodex(page, { fixture: 'achievement-history-only', width: 320, locale, theme }); + await page.locator('#tab-achievements').click(); + await expect(page.locator('[data-achievement-overview="recent"]')).toHaveCount(0); + const history = page.locator('[data-persist="achievement:records"]'); + await expect(history.locator('li')).toHaveCount(5); + await expect(history.locator('time')).toHaveCount(0); + } + }); +} + +test('recent saved awards link to exact rules and history disclosure survives reload', async ({ page }, testInfo) => { + await openCodex(page, { fixture: 'achievement-records', width: 360 }); + await page.locator('#tab-achievements').click(); + const recent = page.locator('[data-achievement-overview="recent"]'); + await expect(recent.locator('button')).toHaveCount(3); + await expect(recent.locator('[data-achievement-record="hundred-million-club"]')).toHaveCount(0); + await expect(recent.locator('[data-achievement-record="settings-explorer-bronze"]')).toHaveCount(0); + const button = recent.locator('[data-achievement-record="settings-explorer-silver"]'); + await expect(button).toHaveAttribute('aria-label', /First recorded: 2026-07-20/); + await button.focus(); + await button.press('Enter'); + await expect(page.locator('[data-achievement-series="settings-explorer"] details')).toHaveAttribute('open'); + const history = page.locator('[data-persist="achievement:records"]'); + await expect(history).not.toHaveAttribute('open'); + await history.locator('summary').click(); + await page.reload(); + await expect(history).toHaveAttribute('open'); + await expect(history).toContainText('Historical award'); + await expect(history).toContainText('First recorded'); + expect(await page.evaluate(() => window.__ccuPostedMessages.some(message => + ['refresh', 'achievementNoticeAck', 'previewAchievementShare', 'exportAchievementShare'].includes(message.command)))).toBe(false); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.evaluate(() => scrollTo(0, 0)); + await page.screenshot({ path: testInfo.outputPath('recent-records-light.png') }); +}); diff --git a/tests/ui/achievement-review-fixes.spec.mjs b/tests/ui/achievement-review-fixes.spec.mjs new file mode 100644 index 00000000..9bf6116f --- /dev/null +++ b/tests/ui/achievement-review-fixes.spec.mjs @@ -0,0 +1,113 @@ +import { createRequire } from 'node:module'; +import { test, expect, openClaude } from './support/app.mjs'; + +const require = createRequire(import.meta.url); +const { achievementCopyFor, achievementRecordStatusCopyFor } = require('../../out/i18n.js'); +const locales = ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']; + +async function openReview(page, { locale, theme, width, fixture }) { + // Normal server route: every load/reload gets fresh production HTML with + // controlled synthetic host flags, without fabricated markup or DOM patches. + await openClaude(page, { locale, theme, width, fixture, claudeOnly: true }); + const tab = page.locator('#tab-achievements'); + await tab.focus(); + await tab.press('Enter'); + await expect(tab).toHaveAttribute('aria-selected', 'true'); +} + +async function expectNoImplicitActions(page) { + const commands = await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command)); + expect(commands.filter(command => /^(?:refresh|updateSetting|setAchievementPinned|previewAchievementShare|exportAchievementShare|restoreAchievementShare|achievementNoticeAck|markAchievementGroupRead)$/.test(command))).toEqual([]); +} + +async function expectFits(page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); +} + +for (const locale of locales) for (const theme of ['light', 'dark']) for (const width of [320, 1280]) { + test(`${locale} ${theme} ${width}: record loading/failure survive reload with keyboard settings and no empty awards`, async ({ page }, testInfo) => { + const copy = achievementCopyFor(locale); + const statusCopy = achievementRecordStatusCopyFor(locale); + for (const [fixture, state] of [['achievement-review-loading', 'loading'], ['achievement-review-failure', 'unavailable']]) { + await openReview(page, { locale, theme, width, fixture }); + const collection = page.locator('.achievement-collection'); + const status = collection.locator('.achievement-intro p[role]'); + const settings = collection.getByRole('button', { name: copy.settingsTitle, exact: true }); + const assertState = async () => { + await expect(collection).toHaveAttribute('data-achievement-record-state', state); + await expect(status).toHaveText(statusCopy[state]); + await expect(status).toHaveAttribute('role', state === 'loading' ? 'status' : 'alert'); + if (state === 'loading') await expect(status).toHaveAttribute('aria-busy', 'true'); + else await expect(status).not.toHaveAttribute('aria-busy'); + await expect(collection).not.toContainText(/\b0\s*\/\s*0\b/); + await expect(collection.locator('.achievement-count, .achievement-card, .achievement-filter-toolbar')).toHaveCount(0); + await expect(collection.locator('[onclick="openAchievementShare()"]')).toHaveCount(0); + await expect(collection.getByRole('button', { name: copy.shareTitle, exact: true })).toHaveCount(0); + await expect(page.locator('.achievement-share-choice')).toHaveCount(0); + await expect(settings).toBeVisible(); + await expectFits(page); + }; + await assertState(); + if (locale === 'zh-CN' && theme === 'dark' && state === 'unavailable') { + await collection.screenshot({ path: testInfo.outputPath(`record-failure-zh-CN-dark-${width}.png`), animations: 'disabled' }); + } + await page.reload(); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await assertState(); + await expectNoImplicitActions(page); + + await settings.focus(); + await settings.press('Enter'); + await expect(page.locator('#tab-settings')).toHaveAttribute('aria-selected', 'true'); + // The accessible switch input is intentionally visually hidden; its + // styled parent is the visible control, while the input owns focus. + await expect(page.locator('#set_enableAchievements').locator('..')).toBeVisible(); + await expect(page.locator('#set_enableAchievements')).toBeFocused(); + await page.reload(); + await expect(page.locator('#tab-settings')).toHaveAttribute('aria-selected', 'true'); + await page.locator('#tab-achievements').focus(); + await page.locator('#tab-achievements').press('Enter'); + await assertState(); + await expectNoImplicitActions(page); + } + }); + + test(`${locale} ${theme} ${width}: next overview names the silver target while the gallery retains earned bronze`, async ({ page }, testInfo) => { + await openReview(page, { locale, theme, width, fixture: 'achievement-review-next-rank' }); + const copy = achievementCopyFor(locale); + const next = page.locator('[data-achievement-overview="next"] [data-achievement-open="cumulative-volume"]'); + const card = page.locator('.achievement-card[data-achievement-key="cumulative-volume"]'); + const badge = page.locator('[data-achievement-overview="badges"] [data-achievement-open="cumulative-volume"]'); + const assertRanks = async () => { + await expect(next).toBeVisible(); + await expect(next).toHaveAttribute('data-overview-rank', 'silver'); + await expect(next.locator('.achievement-overview-name')).toHaveText(copy.titles['billion-token-club']); + await expect(next.locator('.achievement-overview-rank')).toHaveText(`${copy.nextTarget} · ${copy.levels[1]}`); + await expect(next).toHaveAccessibleName(new RegExp(copy.titles['billion-token-club'].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + await expect(card).toHaveAttribute('data-achievement-earned', 'true'); + await expect(card).toHaveAttribute('data-achievement-tier', 'bronze'); + await expect(card.locator('h4')).toHaveText(copy.titles['hundred-million-club']); + await expect(card.locator('.achievement-rank')).toHaveText(copy.levels[0]); + await expect(badge).toHaveAttribute('data-overview-rank', 'bronze'); + await expect(badge.locator('.achievement-overview-rank')).toHaveText(copy.levels[0]); + await expect(card.locator('.achievement-current-progress')).toHaveText('6M / 1B'); + await expectFits(page); + }; + await assertRanks(); + if (locale === 'zh-CN' && theme === 'dark') { + // Include the production section heading and target cards in one image. + await page.locator('[data-achievement-overview="next"]').screenshot({ + path: testInfo.outputPath(`next-overview-zh-CN-dark-${width}.png`), animations: 'disabled', + }); + } + await next.focus(); + await next.press('Enter'); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(card.locator('summary')).toBeFocused(); + await page.reload(); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await expect(card.locator('details')).toHaveAttribute('open'); + await assertRanks(); + await expectNoImplicitActions(page); + }); +} diff --git a/tests/ui/achievement-share-export-layout.spec.mjs b/tests/ui/achievement-share-export-layout.spec.mjs new file mode 100644 index 00000000..b7f6c6f3 --- /dev/null +++ b/tests/ui/achievement-share-export-layout.spec.mjs @@ -0,0 +1,35 @@ +import {test,expect} from './support/app.mjs'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {renderAchievementShareSvg}=require('../../out/achievementShare.js'); +const {achievementCopyFor}=require('../../out/i18n.js'); +const ids=['claude-api-week-legendary','codex-api-week-legendary','claude-api-total-platinum', + 'codex-api-total-platinum','claude-api-session-gold','codex-api-task-gold','sonnet-main','model-rainbow','root-portfolio-gold']; +for(const locale of ['en','de-DE','zh-TW','zh-CN','ja','ko','pt-BR','id'])for(const count of [1,3,9]){ + test(`${locale} exported ${count}-badge SVG keeps labels and repository link inside the artifact`,async({page},info)=>{ + const copy=achievementCopyFor(locale); + const svg=renderAchievementShareSvg({selected:ids.slice(0,count),titles:copy.titles,heading:copy.shareTitle, + subtitle:copy.intro,groups:copy.groups,levels:copy.levels,milestone:copy.milestone,footer:copy.heading,locale}); + await page.setViewportSize({width:1040,height:1000}); + await page.setContent(''+svg); + await expect(page.locator('[data-achievement-share-badge]')).toHaveCount(count); + const link=page.locator('svg > a'); + await expect(link).toHaveAttribute('href','https://github.com/ClaudeCodeUsage/ClaudeCodeUsage'); + await expect(link).toContainText('https://github.com/ClaudeCodeUsage/ClaudeCodeUsage'); + const failures=await page.locator('body>svg').evaluate(svg=>{ + const failures=[],box=svg.viewBox.baseVal; + for(const text of svg.querySelectorAll('text')){ + const b=text.getBBox();if(b.x<0||b.y<0||b.x+b.width>box.width+1||b.y+b.height>box.height+1)failures.push('outside SVG: '+text.textContent); + } + for(const badge of svg.querySelectorAll('[data-achievement-share-badge]')){ + const frame=badge.querySelector('rect').getBBox(),texts=[...badge.querySelectorAll(':scope>text')]; + for(const [index,text]of texts.entries()){ + const b=text.getBBox();if(b.x+b.width>frame.x+frame.width-7||b.y+b.height>frame.y+frame.height-7)failures.push('outside badge: '+text.textContent); + if(index&&b.ysvg').screenshot({path:info.outputPath('share-export-zh-CN-9.png')}); + }); +} diff --git a/tests/ui/achievement-share-resources.spec.mjs b/tests/ui/achievement-share-resources.spec.mjs new file mode 100644 index 00000000..1e21a9dd --- /dev/null +++ b/tests/ui/achievement-share-resources.spec.mjs @@ -0,0 +1,108 @@ +import { test, expect } from './support/app.mjs'; + +const baseUrl = `http://127.0.0.1:${process.env.CCU_UI_TEST_PORT ?? 4173}`; +const iconRoute = '/__achievement_icons__/'; + +async function openSharing(page, provider, nativeResources = true) { + const query = new URLSearchParams({ provider, locale: 'zh-CN', theme: 'dark', fixture: 'achievement-share-resources', + commandTemplate: 'achievements', nativeResources: String(nativeResources), + claudeOnly: String(provider === 'claude') }); + await page.goto(`${baseUrl}/?${query}`); + await expect(page.locator('#sharingTemplate')).toHaveValue('achievements'); + await expect(page.locator(`#provider-tab-${provider}`)).toHaveAttribute('aria-selected', 'true'); +} + +async function expectLoadedThumbnails(page) { + const images = page.locator('.achievement-share-choice-icon'); + expect(await images.count()).toBeGreaterThan(9); + // Lazy images must enter the viewport before judging their loaded state. + for (const image of await images.all()) { + await image.scrollIntoViewIfNeeded(); + await expect.poll(() => image.evaluate(img => img.complete && img.naturalWidth > 0)).toBe(true); + } +} + +for (const provider of ['compare', 'claude']) { + test(`${provider} loads selected and unselected bundled thumbnails with a narrow image CSP`, async ({ page }, info) => { + await openSharing(page, provider); + const choices = page.locator('.achievement-share-choice'); + await choices.first().check(); + await expect(page.locator('.achievement-share-choice:checked')).toHaveCount(1); + expect(await page.locator('.achievement-share-choice:not(:checked)').count()).toBeGreaterThan(0); + for (const image of await page.locator('.achievement-share-choice-icon').all()) { + expect(await image.getAttribute('src')).toMatch(/^\/__achievement_icons__\/[a-z0-9-]+\.png$/); + } + // This is the red regression on the original Compare CSP: URLs exist and + // return PNGs, but img-src data: blocks every resource thumbnail. + await expectLoadedThumbnails(page); + if(provider==='compare')await page.locator('.achievement-share-options').screenshot({path:info.outputPath('share-selection-dark.png')}); + const csp = await page.locator('meta[http-equiv="Content-Security-Policy"]').getAttribute('content'); + const imageSources = csp.split(';').map(part => part.trim()).find(part => part.startsWith('img-src ')); + expect(imageSources.split(/\s+/).slice(1).sort()).toEqual(["'self'", 'data:'].sort()); + }); + + test(`${provider} keyboard choices enforce nine and survive a panel update and reload`, async ({ page }) => { + await openSharing(page, provider); + const choices = page.locator('.achievement-share-choice'); + expect(await choices.count()).toBeGreaterThan(9); + await expect(page.locator('.achievement-share-choice:checked')).toHaveCount(0); + for (let index = 0; index < 9; index += 1) { + await choices.nth(index).focus(); + await choices.nth(index).press('Space'); + } + await expect(page.locator('#achievementShareCount')).toHaveText('9/9'); + await expect(choices.nth(9)).toBeDisabled(); + // A tenth checkbox cannot take keyboard focus while the limit is reached. + await choices.nth(9).focus(); + await expect(choices.nth(9)).not.toBeFocused(); + await expect(page.locator('.achievement-share-choice:checked')).toHaveCount(9); + await choices.first().focus(); + await choices.first().press('Space'); + await expect(choices.nth(9)).toBeEnabled(); + await choices.nth(9).focus(); + await choices.nth(9).press('Space'); + const selected = await page.locator('.achievement-share-choice:checked').evaluateAll(inputs => inputs.map(input => input.value)); + expect(selected).toHaveLength(9); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('ccu.sharing.achievementBadges')))).toEqual(selected); + const response = await page.request.get(page.url()); + expect(response.ok()).toBe(true); + await page.evaluate(({ html, provider }) => { + const next = new DOMParser().parseFromString(html, 'text/html').getElementById('provider-panel'); + window.dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', + provider, tab: 'all', revision: 1, html: next.innerHTML, + claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + }, { html: await response.text(), provider }); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.filter(message => + message.command === 'dashboardDataPatchAck'))).toEqual([{ command: 'dashboardDataPatchAck', revision: 1, ok: true }]); + const expectSelection = async () => { + await expect(page.locator('#sharingTemplate')).toHaveValue('achievements'); + expect(await page.locator('.achievement-share-choice:checked').evaluateAll(inputs => inputs.map(input => input.value))).toEqual(selected); + await expect(page.locator('#achievementShareCount')).toHaveText('9/9'); + for (const unchecked of await page.locator('.achievement-share-choice:not(:checked)').all()) await expect(unchecked).toBeDisabled(); + }; + await expectSelection(); + await page.reload(); + await expectSelection(); + await expectLoadedThumbnails(page); + }); +} + +test('resource mode is opt-in and the image route rejects non-catalog paths', async ({ page, request }) => { + await openSharing(page, 'compare', false); + for (const image of await page.locator('.achievement-share-choice-icon').all()) { + expect(await image.getAttribute('src')).toMatch(/^data:image\/png;base64,/); + } + await expectLoadedThumbnails(page); + await openSharing(page, 'claude'); + const src = await page.locator('.achievement-share-choice-icon').first().getAttribute('src'); + const response = await request.get(baseUrl + src); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('image/png'); + expect((await response.body()).subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + for (const path of ['unknown.png', 'package.json', '%2e%2e%2fpackage.json', + '%2Fetc%2Fpasswd', 'claude-sonnet-main.png/extra', 'claude-sonnet-main.svg']) { + expect((await request.get(baseUrl + iconRoute + path)).status()).toBe(404); + } + expect((await request.get(`${baseUrl}/?provider=compare&fixture=combined-heatmap`)).status()).toBe(200); + expect((await request.get(`${baseUrl}/health`)).status()).toBe(200); +}); diff --git a/tests/ui/achievement-statistics.spec.mjs b/tests/ui/achievement-statistics.spec.mjs new file mode 100644 index 00000000..d8fa9232 --- /dev/null +++ b/tests/ui/achievement-statistics.spec.mjs @@ -0,0 +1,129 @@ +import { test, expect, openCodex, openClaude } from './support/app.mjs'; + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} current-statistics return stays readable and keeps local state in both providers and themes`, async ({ page }, testInfo) => { + for (const theme of ['light', 'dark']) for (const [open, provider, key, section] of [ + [openClaude, 'claude', 'claude-output', 'tokens'], [openCodex, 'codex', 'sol-regular', 'models'], + ]) { + await open(page, { fixture: 'complete-index', width: 320, locale, theme }); + await page.evaluate(() => localStorage.removeItem('__ccu-vscode-state')); await page.reload(); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-key="' + key + '"]'); + await card.locator('summary').focus(); await card.locator('summary').press('Enter'); + const button = page.locator('#achievement-statistics-' + key); + await expect(button).toHaveAttribute('data-statistics-section', section); + await button.focus(); await page.clock.runFor(250); + const share = await page.evaluate(() => localStorage.getItem('ccu.sharing.achievementBadges')); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await button.press('Enter'); + await expect(page.locator('#tab-all')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#achievement-statistics-target')).toBeFocused(); + await page.clock.runFor(250); + await page.reload(); + await expect(page.locator('#achievement-statistics-back')).toBeVisible(); + await page.locator('#achievement-statistics-back').focus(); await page.locator('#achievement-statistics-back').press('Enter'); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(button).toBeFocused(); await page.clock.runFor(250); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await page.evaluate(() => localStorage.getItem('ccu.sharing.achievementBadges'))).toBe(share); + await card.screenshot({ path: testInfo.outputPath(`statistics-${provider}-${locale}-${theme}.png`) }); + const commands = await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command)); + for (const command of ['refresh', 'previewAchievementShare', 'exportAchievementShare', 'achievementNoticeAck', 'updateSetting']) + expect(commands).not.toContain(command); + await page.reload(); await expect(card.locator('details')).toHaveAttribute('open'); + await button.click(); await page.locator('#tab-today').click(); await page.locator('#tab-all').click(); + await expect(page.locator('#achievement-statistics-back')).toBeHidden(); + } + }); +} + +test('tampered, cross-provider and unavailable statistics intents cannot navigate or refresh', async ({ page }) => { + await openCodex(page, { fixture: 'complete-index' }); + await page.locator('#tab-achievements').click(); + for (const key of ['weekly-volume', 'cumulative-volume', 'safe-feature-explorer']) + await expect(page.locator('#achievement-statistics-' + key)).toHaveCount(0); + await page.evaluate(() => { + window.__ccuPostedMessages = []; + for (const intent of [{ provider: 'claude', key: 'sol-regular' }, { provider: 'codex', key: '../../private' }, + { provider: 'codex', key: '__proto__' }, { provider: 'codex', key: 'weekly-volume' }]) { + ccuWriteUiState('achievementStatisticsReturn', intent); + restoreAchievementStatisticsReturn(); backFromAchievementStatistics(); + } + }); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#achievement-statistics-back')).toBeHidden(); + expect(await page.evaluate(() => window.__ccuPostedMessages)).toEqual([]); +}); + +test('statistics patch keeps target focus, saved disclosure and return without reopening logs', async ({ page }) => { + await openCodex(page, { fixture: 'complete-index', width: 360 }); + await page.locator('#tab-achievements').click(); + await page.locator('[data-achievement-key="sol-regular"] summary').click(); + await page.locator('#achievement-statistics-sol-regular').click(); + const response = await page.context().request.get(page.url()); expect(response.ok()).toBe(true); + await page.evaluate(html => { + window.__ccuPostedMessages = []; + const next = new DOMParser().parseFromString(html, 'text/html').getElementById('provider-panel'); + window.dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', provider: 'codex', + tab: 'all', revision: 1, html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + }, await response.text()); + await page.clock.runFor(250); + await expect(page.locator('#achievement-statistics-target')).toBeFocused(); + await expect(page.locator('#achievement-statistics-back')).toBeVisible(); + expect(await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command))).toEqual(['dashboardDataPatchAck']); + await page.locator('#achievement-statistics-back').click(); + await expect(page.locator('#achievement-statistics-sol-regular')).toBeFocused(); + await expect(page.locator('[data-achievement-key="sol-regular"] details')).toHaveAttribute('open'); +}); + +test('switching badge destinations retires the old target identity and revoked links return focus safely', async ({ page }) => { + await openCodex(page, { fixture: 'complete-index', width: 360 }); + await page.locator('#tab-achievements').click(); + await page.locator('[data-achievement-key="sol-regular"] summary').click(); + await page.locator('#achievement-statistics-sol-regular').click(); + await page.locator('#achievement-statistics-back').click(); + await page.locator('[data-achievement-key="codex-output"] summary').click(); + await page.locator('#achievement-statistics-codex-output').click(); + await expect(page.locator('#achievement-statistics-target')).toHaveCount(1); + await expect(page.locator('#all .summary-grid')).toHaveAttribute('id', 'achievement-statistics-target'); + await page.locator('#achievement-statistics-back').click(); + await page.locator('#achievement-statistics-sol-regular').click(); + const changed = new URL(page.url()); changed.searchParams.set('fixture', 'unknown-models'); + const response = await page.context().request.get(changed.href); expect(response.ok()).toBe(true); + expect(await response.text()).not.toContain('id="achievement-statistics-sol-regular"'); + await page.evaluate(html => { + const next = new DOMParser().parseFromString(html, 'text/html').getElementById('provider-panel'); + window.__ccuPostedMessages = []; + window.dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', provider: 'codex', + tab: 'all', revision: 1, html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + }, await response.text()); + await page.clock.runFor(250); + await expect(page.locator('#achievement-statistics-back')).toBeHidden(); + await expect(page.locator('#tab-all')).toBeFocused(); + expect(await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command))).toEqual(['dashboardDataPatchAck']); +}); + +test('Codex current statistics keeps badge disclosure and keyboard return across reload without implicit work', async ({ page }) => { + await openCodex(page, { fixture: 'complete-index', width: 360 }); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-key="sol-regular"]'); + await card.locator('summary').click(); + const link = page.locator('#achievement-statistics-sol-regular'); + await expect(link).toBeVisible(); + await link.focus(); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await link.press('Enter'); + await expect(page.locator('#tab-all')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#all .model-breakdown h3').first()).toBeFocused(); + await expect(page.locator('#achievement-statistics-back')).toBeVisible(); + for (const command of ['refresh', 'previewAchievementShare', 'exportAchievementShare', 'achievementNoticeAck', 'updateSetting']) + expect(await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command))).not.toContain(command); + await page.reload(); + await expect(page.locator('#tab-all')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#achievement-statistics-back')).toBeVisible(); + await page.locator('#achievement-statistics-back').focus(); + await page.locator('#achievement-statistics-back').press('Enter'); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(link).toBeFocused(); +}); diff --git a/tests/ui/achievement-themes.spec.mjs b/tests/ui/achievement-themes.spec.mjs new file mode 100644 index 00000000..6d713e6f --- /dev/null +++ b/tests/ui/achievement-themes.spec.mjs @@ -0,0 +1,116 @@ +import { test, expect, openCodex, openCompare } from './support/app.mjs'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { ACHIEVEMENT_GALLERY } = require('../../out/achievementGallery.js'); +const { ACHIEVEMENT_DEFINITIONS } = require('../../out/achievements.js'); + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} milestones share one theme card and retain icons, exact rules and reload state`, async ({ page }, testInfo) => { + for (const theme of ['light', 'dark']) { + await openCodex(page, { fixture: 'achievement-theme-milestones', width: 320, locale, theme }); + await page.evaluate(() => localStorage.removeItem('__ccu-vscode-state')); + await page.reload(); + await page.locator('#tab-achievements').click(); + await expect(page.locator('.achievement-card')).toHaveCount(ACHIEVEMENT_GALLERY.length); + await expect(page.locator('.achievement-card [data-achievement-level]')).toHaveCount(ACHIEVEMENT_DEFINITIONS.length); + const card = page.locator('[data-achievement-key="active-day-streak"]'); + await expect(card).toHaveAttribute('data-achievement-earned', 'true'); + await expect(card.locator('.achievement-tier-pip.complete')).toHaveCount(0); + await expect(card).toHaveAttribute('data-achievement-tier', ''); + await expect(card.locator('summary img')).toHaveAttribute('data-achievement-icon', 'streak-machine'); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + const recent = page.locator('[data-achievement-overview="recent"]'); + await expect(recent.locator('button')).toHaveCount(2); + const record = recent.locator('[data-achievement-record="streak-machine"]'); + await expect(record).toHaveAttribute('data-achievement-open', 'active-day-streak'); + await record.focus(); await record.press('Enter'); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(card.locator('summary')).toBeFocused(); + const milestone = card.locator('[data-achievement-milestone="streak-machine"]'); + await expect(milestone.locator('img')).toBeVisible(); + await expect(card.locator('[data-achievement-level]')).toHaveCount(4); + await expect(card.locator('.achievement-tier-pip')).toHaveCount(3); + expect(await milestone.locator('img').evaluate(img => img.complete && img.naturalWidth > 0)).toBe(true); + await page.clock.runFor(200); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await card.screenshot({ path: testInfo.outputPath(`theme-${locale}-${theme}.png`) }); + const commands = await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command)); + for (const command of ['refresh', 'achievementNoticeAck', 'setAchievementPinned', 'previewAchievementShare', 'exportAchievementShare']) + expect(commands).not.toContain(command); + await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(card.locator('[data-achievement-pin="streak-machine"]')).toHaveAttribute('aria-pressed', 'true'); + } + }); +} + +test('old milestone disclosure aliases and direct links reopen the canonical theme without host work', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-theme-milestones', width: 360 }); + await page.locator('#tab-achievements').click(); + await page.evaluate(() => { + const state = JSON.parse(localStorage.getItem('__ccu-vscode-state')); + localStorage.setItem('__ccu-vscode-state', JSON.stringify({ ...state, openDetails: ['achievement:streak-machine'] })); + }); + await page.reload(); + const card = page.locator('[data-achievement-key="active-day-streak"]'); + await expect(card.locator('details')).toHaveAttribute('open'); + await card.locator('summary').click(); + await page.evaluate(() => { window.__ccuPostedMessages = []; window.openAchievementDetail('streak-machine'); }); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(card.locator('summary')).toBeFocused(); + await page.clock.runFor(200); + expect(await page.evaluate(() => window.__ccuPostedMessages)).toEqual([]); + await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); +}); + +test('two legacy theme pins stay individually removable and a confirmed removal keeps disclosure after reload', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-theme-pins', width: 360 }); + await page.locator('#tab-achievements').click(); + const card = page.locator('[data-achievement-key="active-day-streak"]'); + await card.locator('summary').click(); + await expect(card.locator('[data-achievement-pin]')).toHaveCount(2); + await expect(page.locator('[data-achievement-overview="badges"] [data-achievement-open="active-day-streak"]')).toHaveCount(1); + await expect(page.locator('[data-persist="achievement:hidden-pins"]')).toHaveCount(0); + const button = card.locator('[data-achievement-pin="streak-machine"]'); + const documentId = await page.evaluate(() => window.__ccuPostedMessages.findLast( + message => message.command === 'localDataClientReady').documentId); + expect(documentId).toMatch(/^[0-9a-f]{48}$/); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await button.focus(); await button.press('Enter'); + expect(await page.evaluate(() => window.__ccuPostedMessages)).toEqual([ + { command: 'setAchievementPinned', key: 'streak-machine', selected: false, generation: 1, documentId }, + ]); + await expect(button).toHaveAttribute('aria-pressed', 'true'); + await page.clock.runFor(200); + const confirmedUrl = new URL(page.url()); confirmedUrl.searchParams.set('fixture', 'achievement-theme-pins-released'); + const response = await page.context().request.get(confirmedUrl.href); + expect(response.ok()).toBe(true); + await page.evaluate(nextHtml => { + const next = new DOMParser().parseFromString(nextHtml, 'text/html').getElementById('provider-panel'); + window.__ccuPostedMessages = []; + window.dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', provider: 'codex', + tab: 'achievements', revision: 1, html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + }, await response.text()); + await expect(card.locator('[data-achievement-pin]')).toHaveCount(1); + await expect(card.locator('[data-achievement-pin="active-day-streak"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(card.locator('details')).toHaveAttribute('open'); + await page.clock.runFor(200); + await expect(card.locator('summary')).toBeFocused(); + expect(await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command))) + .not.toContain('refresh'); + await page.goto(confirmedUrl.href); await page.reload(); + await expect(card.locator('details')).toHaveAttribute('open'); + await expect(card.locator('[data-achievement-pin]')).toHaveCount(1); +}); + +test('merged display themes do not merge the exact legacy share choices or opt into export', async ({ page }) => { + await openCompare(page, { fixture: 'achievement-theme-pins', commandTemplate: 'achievements' }); + for (const id of ['streak-machine', 'dual-provider-day', 'active-day-streak', 'dual-provider-days-bronze']) + await expect(page.locator(`.achievement-share-choice[value="${id}"]`)).toHaveCount(1); + await expect(page.locator('.achievement-share-choice:checked')).toHaveCount(0); + await expect(page.locator('#achievementShareExportBtn')).toBeDisabled(); + const commands = await page.evaluate(() => window.__ccuPostedMessages.map(row => row.command)); + for (const command of ['refresh', 'achievementNoticeAck', 'exportAchievementShare', 'updateSetting']) + expect(commands).not.toContain(command); +}); diff --git a/tests/ui/achievement-unread.spec.mjs b/tests/ui/achievement-unread.spec.mjs new file mode 100644 index 00000000..47a3f4e9 --- /dev/null +++ b/tests/ui/achievement-unread.spec.mjs @@ -0,0 +1,75 @@ +import { test, expect, openCodex } from './support/app.mjs'; + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} unread group stays folded until opened, sends explicit bounded confirmation and survives reload`, async ({ page }, testInfo) => { + for (const theme of ['light', 'dark']) { + await openCodex(page, { locale, theme, fixture: 'achievement-records', width: 320 }); + await page.evaluate(() => localStorage.removeItem('__ccu-vscode-state')); + await page.reload(); + await page.locator('#tab-achievements').click(); + const group = page.locator('[data-persist="achievement:unread"]'); + await expect(group).not.toHaveAttribute('open'); + await group.locator('summary').click(); + await expect(group.locator('[data-achievement-record]')).toHaveCount(4); + for (const image of await group.locator('img').all()) + expect(await image.evaluate(element => element.complete && element.naturalWidth > 0)).toBe(true); + await page.reload(); + await expect(group).toHaveAttribute('open'); + expect(await page.evaluate(() => window.__ccuPostedMessages.some(message => message.command === 'achievementSummaryAck'))).toBe(false); + const button = page.locator('#achievement-unread-read'); + const id = await button.getAttribute('data-achievement-summary'); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await button.focus(); await button.press('Enter'); + const messages = await page.evaluate(() => window.__ccuPostedMessages); + expect(messages).toEqual([{ command: 'achievementSummaryAck', summaryId: id, + documentId: expect.stringMatching(/^[0-9a-f]{48}$/) }]); + await expect(group.locator('[data-achievement-record]')).toHaveCount(4, 'no optimistic read before durable host patch'); + await page.evaluate(message => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementSummaryResult', ...message, ok: false, rawError: '/private/path/canary', + } })), { summaryId: id, documentId: messages[0].documentId }); + await expect(page.locator('#achievementUnreadStatus')).not.toBeEmpty(); + await expect(page.locator('#achievementUnreadStatus')).not.toContainText('/private/path/canary'); + await expect(button).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`unread-${locale}-${theme}.png`) }); + } + }); +} + +test('a host-confirmed final group patch keeps gallery and share state and returns focus to the tab', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-records', width: 360, height: 600 }); + await page.locator('#tab-achievements').click(); + await page.locator('[data-persist="achievement:unread"] summary').click(); + const button = page.locator('#achievement-unread-read'); + await button.focus(); await button.press('Enter'); + const url = new URL(page.url()); url.searchParams.set('fixture', 'achievement-records-read'); + const response = await page.context().request.get(url.href); expect(response.ok()).toBe(true); + // This browser bridge simulates a persisted host response. Provider tests perform real filesystem save/reopen. + const old = await page.evaluate(html => { + const next = new DOMParser().parseFromString(html, 'text/html').getElementById('provider-panel'); + window.__unreadDocument = document.documentElement; + const state = localStorage.getItem('ccu.sharing.achievementBadges'); + window.__ccuPostedMessages = []; + window.dispatchEvent(new MessageEvent('message', { data: { command: 'dashboardDataPatch', provider: 'codex', + tab: 'achievements', revision: 1, html: next.innerHTML, claudeLast30HoursByDay: {}, adviceSnapshotIds: {} } })); + return state; + }, await response.text()); + await expect(page.locator('[data-persist="achievement:unread"]')).toHaveCount(0); + await page.clock.runFor(200); + await expect(page.locator('#tab-achievements')).toBeFocused(); + expect(await page.evaluate(() => document.documentElement === window.__unreadDocument)).toBe(true); + expect(await page.evaluate(() => localStorage.getItem('ccu.sharing.achievementBadges'))).toBe(old); + expect(await page.evaluate(() => window.__ccuPostedMessages.map(message => message.command))).toEqual(['dashboardDataPatchAck']); + await page.goto(url.href); await page.reload(); + await expect(page.locator('[data-persist="achievement:unread"]')).toHaveCount(0); + await expect(page.locator('[data-achievement-overview="recent"]')).toBeVisible(); +}); + +test('retired or unrelated summary error cannot change the current status', async ({ page }) => { + await openCodex(page, { fixture: 'achievement-records' }); + await page.locator('#tab-achievements').click(); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementSummaryResult', summaryId: 'retired', documentId: 'retired', ok: false, + } }))); + await expect(page.locator('#achievementUnreadStatus')).toBeEmpty(); +}); diff --git a/tests/ui/achievements.spec.mjs b/tests/ui/achievements.spec.mjs new file mode 100644 index 00000000..18eca0f6 --- /dev/null +++ b/tests/ui/achievements.spec.mjs @@ -0,0 +1,438 @@ +import { test, expect, openCodex, openCompare } from './support/app.mjs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { ACHIEVEMENT_COPY } = require('../../out/i18n.js'); +const { renderAchievementShareSvg } = require('../../out/achievementShare.js'); +const { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_SERIES } = require('../../out/achievements.js'); +const { ACHIEVEMENT_GALLERY } = require('../../out/achievementGallery.js'); + +function shareSvg(selected, copy) { + return renderAchievementShareSvg({ selected, titles: copy.titles, heading: copy.shareTitle, + subtitle: copy.intro, groups: copy.groups, levels: copy.levels, milestone: copy.milestone, footer: copy.heading }); +} + +// Renderer-only layout coverage supplies canonical IDs in the visible sharing +// host. These synthetic choices say nothing about personal earned eligibility. +async function selectRendererIds(page, selected) { + expect(new Set(selected).size).toBe(selected.length); + expect(selected.length).toBeLessThanOrEqual(9); + expect(selected.every(id => ACHIEVEMENT_DEFINITIONS.some(rule => rule.id === id))).toBe(true); + const host = page.locator('.achievement-share-presentation'); + await expect(host).toBeVisible(); + await host.locator('fieldset').evaluate((fieldset, selected) => { + fieldset.querySelectorAll('label, .achievement-share-choice').forEach(element => element.remove()); + for (const id of selected) { + const label = document.createElement('label'); + label.className = 'sc-check'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.className = 'achievement-share-choice'; + input.value = id; + input.addEventListener('change', () => window.previewAchievementShare(input)); + label.append(input, document.createTextNode(id)); + fieldset.append(label); + } + }, selected); + for (const id of selected) await host.locator(`.achievement-share-choice[value="${id}"]`).check(); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages + .filter(message => message.command === 'previewAchievementShare').at(-1)?.ids)).toEqual(selected); +} + +async function acceptAchievementSvg(page, svg) { + await page.evaluate(svg => { + const request = window.__ccuPostedMessages.filter(message => + ['previewAchievementShare', 'restoreAchievementShare'].includes(message.command)).at(-1); + if (!request) throw new Error('fixture needs a matching preview request'); + const checked = [...document.querySelectorAll('.achievement-share-choice:checked')].map(input => input.value); + const badges = [...new DOMParser().parseFromString(svg, 'image/svg+xml') + .querySelectorAll('g[data-achievement-share-badge]')].map(badge => badge.getAttribute('data-achievement-share-badge')); + if (JSON.stringify(request.ids) !== JSON.stringify(checked) || + JSON.stringify(request.ids) !== JSON.stringify(badges)) { + throw new Error('preview selection, host request and SVG badge IDs must match'); + } + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'achievementShareResult', clientId: request.clientId, requestId: request.requestId, + ids: request.ids, previewId: 'synthetic-preview', svg, + } })); + }, svg); +} + +test('achievements are on by default and remain selected after reload', async ({ page }, testInfo) => { + await openCodex(page, { theme: 'dark' }); + await expect(page.locator('#tab-achievements')).toBeVisible(); + await page.locator('#tab-achievements').click(); + await expect(page.locator('#achievements')).toBeVisible(); + // Tiered series occupy one card, including the five-level cumulative ladder. + await expect(page.locator('.achievement-card')).toHaveCount(ACHIEVEMENT_GALLERY.length); + await expect(page.locator('.achievement-card img[data-achievement-icon]')).toHaveCount(ACHIEVEMENT_GALLERY.length); + await expect(page.locator('.achievement-count')).toContainText(`/ ${ACHIEVEMENT_DEFINITIONS.length}`); + expect(await page.locator('.achievement-card').evaluateAll(cards => + cards.map(card => card.getAttribute('data-achievement-series') ?? card.getAttribute('data-achievement-id')).sort())) + .toEqual(ACHIEVEMENT_GALLERY.map(entry => entry.key).sort()); + await expect(page.locator('[data-achievement-id="astra-regular"]')).toContainText('Astra regular'); + await expect(page.locator('[data-achievement-series="effort-breadth"]')).toContainText('Effort explorer'); + await page.locator('#achievements').screenshot({ path: testInfo.outputPath('achievements-dark.png') }); + await page.reload(); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#achievements')).toBeVisible(); +}); + +test('saved tier badges retain readable title contrast in the dark gallery', async ({ page }) => { + // Default incomplete usage has no earned tier. Use an existing fixture with + // explicit saved records for this visual assertion. + await openCodex(page, { theme: 'dark', fixture: 'achievement-six-pins' }); + await page.locator('#tab-achievements').click(); + await expect(page.locator('.achievement-card[data-achievement-series].earned').first()).toBeVisible(); + const tierTitleContrast = await page.locator('.achievement-card[data-achievement-series].earned').first() + .evaluate((card) => { + const channels = (color) => { + const values = color.match(/[\d.]+/g).slice(0, 3).map(Number); + return color.startsWith('color(srgb') ? values : values.map((value) => value / 255); + }; + const luminance = (color) => channels(color).map((value) => { + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }).reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index], 0); + const foreground = luminance(getComputedStyle(card.querySelector('h4')).color); + const background = luminance(getComputedStyle(card).backgroundColor); + return (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05); + }); + expect(tierTitleContrast).toBeGreaterThanOrEqual(7); +}); + +test('gallery leads with artwork and reveals numeric progress on demand', async ({ page }) => { + await openCodex(page, { theme: 'dark' }); + await page.locator('#tab-achievements').click(); + const tile = page.locator('.achievement-card[data-achievement-proof="ready"]').first(); + const series = await tile.getAttribute('data-achievement-series'); + const detail = tile.locator('details[data-persist]'); + const icon = tile.locator('img[data-achievement-icon]'); + await expect(detail).not.toHaveAttribute('open'); + await expect(tile.locator('.achievement-progress-label')).toBeHidden(); + await expect(page.locator('#achievementSize')).toHaveValue('medium'); + expect(await icon.evaluate((element) => element.getBoundingClientRect().width)).toBe(80); + await detail.locator('summary').click(); + await expect(detail).toHaveAttribute('open'); + await expect(tile.locator('.achievement-progress-label')).toBeVisible(); + await page.reload(); + await expect(page.locator(`[data-achievement-series="${series}"] details`)).toHaveAttribute('open'); +}); + +test('turning achievements off removes its tab and setting remains available', async ({ page }) => { + await page.goto(`http://127.0.0.1:${process.env.CCU_UI_TEST_PORT ?? 4173}/?provider=codex&achievementsEnabled=false`); + await expect(page.locator('#tab-achievements')).toHaveCount(0); + await page.locator('#tab-settings').click(); + await expect(page.locator('#set_enableAchievements')).not.toBeChecked(); +}); + +test('earned badges join the preview-first sharing workspace without automatic export', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await page.locator('#sharingTemplate').selectOption('achievements'); + await expect(page.locator('.achievement-share-presentation')).toBeVisible(); + const count = await page.locator('.achievement-share-choice').count(); + expect(count).toBeGreaterThan(0); + // Nothing is preselected for public-facing export, even if earned locally. + await expect(page.locator('#achievementSharePreview > svg')).toHaveCount(0); + await expect(page.locator('#achievementShareExportBtn')).toBeDisabled(); + await page.locator('.achievement-share-choice').first().check(); + const selectedId = await page.locator('.achievement-share-choice').first().inputValue(); + await expect.poll(async () => (await page.evaluate(() => window.__ccuPostedMessages)) + .some((message) => message.command === 'previewAchievementShare' && + message.ids.length === 1 && message.ids[0] === selectedId)).toBe(true); + // This browser harness records host messages but does not run the extension + // host; acknowledge its preview response explicitly. + await acceptAchievementSvg(page, shareSvg([selectedId], ACHIEVEMENT_COPY.en)); + await expect(page.locator('#achievementSharePreview > svg')).toBeVisible(); + await expect(page.locator('#achievementSharePreview g[data-achievement-share-badge]')) + .toHaveAttribute('data-achievement-share-badge', selectedId); + await page.locator('.achievement-share-choice').first().uncheck(); + const firstId = await page.locator('.achievement-share-choice').first().inputValue(); + const posted = await page.evaluate(() => window.__ccuPostedMessages); + expect(posted.some((message) => message.command === 'previewAchievementShare')).toBe(true); + expect(posted.some((message) => message.command === 'exportAchievementShare')).toBe(false); + await page.reload(); + await page.locator('#sharingTemplate').selectOption('achievements'); + await expect(page.locator(`.achievement-share-choice[value="${firstId}"]`)).not.toBeChecked(); +}); + +test('restoring badge choices is display hydration, not a deliberate share preview', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await page.locator('#sharingTemplate').selectOption('achievements'); + const selectedId = await page.locator('.achievement-share-choice').first().inputValue(); + await page.evaluate((id) => { + localStorage.setItem('ccu.sharing.achievementBadges', JSON.stringify([id])); + }, selectedId); + await page.reload(); + await expect(page.locator(`.achievement-share-choice[value="${selectedId}"]`)).toBeChecked(); + const restored = await page.evaluate(() => window.__ccuPostedMessages); + expect(restored.some((message) => message.command === 'restoreAchievementShare' && + message.ids.includes(selectedId))).toBe(true); + expect(restored.some((message) => message.command === 'previewAchievementShare')).toBe(false); + await page.locator('.achievement-share-choice:checked').first().uncheck(); + await expect.poll(async () => (await page.evaluate(() => window.__ccuPostedMessages)) + .some((message) => message.command === 'previewAchievementShare')).toBe(true); +}); + +test('Codex-only users can reach the achievement presentation without Claude data', async ({ page }) => { + await openCodex(page, { codexOnly: true }); + await page.locator('#tab-achievements').click(); + await page.getByRole('button', { name: 'Achievement card' }).click(); + await expect(page.locator('#all')).toBeVisible(); + await expect(page.locator('#sharingTemplate')).toHaveValue('achievements'); + const before = await page.evaluate(() => ({ + request: window.__ccuPostedMessages.findLast(message => message.command === 'sharingTemplateChanged'), + documentId: window.__ccuPostedMessages.findLast(message => message.command === 'localDataClientReady').documentId, + commands: window.__ccuPostedMessages.map(message => message.command), + })); + expect(before.request).toEqual({ command: 'sharingTemplateChanged', template: 'achievements' }); + expect(before.documentId).toMatch(/^[0-9a-f]{48}$/); + // fakeHost response only, not real Extension Host/server integration. The + // legacy commandTemplate route requires Claude records, so mirror only the + // validated dashboard enum handler's state before calling the real renderer. + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const { renderHarness } = require('./support/render-harness.cjs'); + const { UsageWebviewProvider } = require('../../out/webview.js'); + const render = UsageWebviewProvider.prototype.getWebviewContent; + let html; + try { + UsageWebviewProvider.prototype.getWebviewContent = function () { + if (this.currentProvider !== 'codex' || this.providerAvailability.claude) + throw new Error('fakeHost response must remain Codex-only'); + this.sharingTemplate = before.request.template; + this.currentTab = 'all'; + return render.call(this); + }; + html = await renderHarness({ provider: 'codex', codexOnly: true }); + } finally { + UsageWebviewProvider.prototype.getWebviewContent = render; + } + const url = new URL(page.url()); + expect(url.searchParams.get('codexOnly')).toBe('true'); + url.searchParams.set('fakeHostTemplate', before.request.template); + await page.route(url.href, route => route.fulfill({ status: 200, contentType: 'text/html', body: html })); + const response = await page.goto(url.href, { waitUntil: 'load' }); + expect(response.status()).toBe(200); + const after = await page.evaluate(() => ({ + documentId: window.__ccuPostedMessages.findLast(message => message.command === 'localDataClientReady').documentId, + commands: window.__ccuPostedMessages.map(message => message.command), + })); + expect(after.documentId).toMatch(/^[0-9a-f]{48}$/); + expect(after.documentId).not.toBe(before.documentId); + await expect(page.locator('#provider-tab-codex')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#all')).toBeVisible(); + await expect(page.locator('#sharingTemplate')).toHaveValue(before.request.template); + await expect(page.locator('.achievement-share-presentation')).toBeVisible(); + await expect(page.locator('option[value="claudeShareCard"]')).toHaveAttribute('disabled', ''); + await expect(page.locator('option[value="claudeHeatmap"]')).toHaveAttribute('disabled', ''); + expect([...before.commands, ...after.commands].filter(command => + /^(previewAchievementShare|exportAchievementShare|buildShareCard|exportShareCard|previewCombinedHeatmap|exportCombinedHeatmap|publishHeatmap)$/.test(command))).toEqual([]); +}); + +test('share choices visibly enforce the nine-badge card capacity', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + await page.locator('#sharingTemplate').selectOption('achievements'); + await page.evaluate(() => { + const fieldset = document.querySelector('.achievement-share-presentation fieldset'); + for (let index = 0; index < 10; index += 1) { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.className = 'achievement-share-choice'; + input.value = `test-${index}`; + input.checked = true; + fieldset.append(input); + } + window.previewAchievementShare(); + }); + await expect(page.locator('.achievement-share-choice:checked')).toHaveCount(9); + await expect(page.locator('#achievementShareCount')).toHaveText('9/9'); + await expect(page.locator('.achievement-share-choice:not(:checked)').first()).toBeDisabled(); +}); + +test('turning sharing off keeps achievements but removes the share action', async ({ page }) => { + await openCodex(page, { shareStudio: false }); + await page.locator('#tab-achievements').click(); + await expect(page.locator('.achievement-card')).toHaveCount(ACHIEVEMENT_GALLERY.length); + await expect(page.getByRole('button', { name: 'Achievement card' })).toHaveCount(0); +}); + +test('gallery sharing and notice dismissal remain keyboard-operated without focus theft', async ({ page }) => { + // This fixture has evidenced earned badges. The incomplete Codex-only fixture + // correctly presents an empty selector rather than inventing share choices. + await openCodex(page, { fixture: 'combined-heatmap' }); + const galleryTab = page.locator('#tab-achievements'); + await galleryTab.focus(); + await galleryTab.press('Enter'); + await expect(page.locator('#achievements')).toBeVisible(); + const shareAction = page.getByRole('button', { name: 'Achievement card' }); + await shareAction.focus(); + await shareAction.press('Enter'); + // Provider navigation is a host request, not client-side fabrication of a + // second provider panel. This harness records that boundary; load its real + // Compare render to stand in for the Extension Host response. + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.some((message) => + message.command === 'providerChanged' && message.provider === 'compare' && + message.tab === 'all'))).toBe(true); + await openCompare(page, { fixture: 'combined-heatmap' }); + await expect(page.locator('#sharingTemplate')).toHaveValue('achievements'); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.some((message) => + message.command === 'sharingTemplateChanged' && message.template === 'achievements'))).toBe(true); + // The harness records host requests rather than executing them. Supply the + // requested template's host response before testing its real controls. + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'achievements' }); + const choice = page.locator('.achievement-share-choice').first(); + await choice.focus(); + await choice.press('Space'); + await expect(choice).toBeChecked(); + await expect(choice).toBeFocused(); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { + data: { command: 'achievementUnlockNotice', text: 'New local badge' }, + }))); + const notice = page.locator('#ccuAchievementUnlockNotice'); + await expect(notice).toHaveAttribute('role', 'status'); + await expect(notice).toHaveAttribute('aria-live', 'polite'); + await expect(choice).toBeFocused(); + const dismiss = notice.getByRole('button'); + await dismiss.focus(); + await expect(dismiss).toHaveAccessibleName(/\S/); + await expect(dismiss).toBeFocused(); + await dismiss.press('Enter'); + await expect(notice).toHaveCount(0); +}); + +test('unlock notice is literal text, has no animation, and dismisses after six seconds', async ({ page }) => { + await openCodex(page); + const before = await page.evaluate(() => window.__ccuPostedMessages.length); + const literalText = ' local badge'; + await page.evaluate((text) => window.dispatchEvent(new MessageEvent('message', { + data: { command: 'achievementUnlockNotice', text }, + })), literalText); + const notice = page.locator('#ccuAchievementUnlockNotice'); + await expect(notice.locator('span')).toHaveText(literalText); + await expect(notice.locator('img')).toHaveCount(0); + expect(await notice.evaluate((element) => getComputedStyle(element).animationName)).toBe('none'); + await page.clock.runFor(6001); + await expect(notice).toHaveCount(0); + expect(await page.evaluate(() => window.__ccuPostedMessages.length)).toBe(before); +}); + +test('unlock notice reuses a bundled gallery icon even when its PNG exceeds the old data URL cap', async ({ page }) => { + await openCodex(page); + await expect(page.locator('#tab-achievements')).not.toHaveAttribute('aria-selected', 'true'); + const artwork = 'codex-astra-regular.png'; + const galleryIcon = page.locator(`.achievement-card img[data-achievement-artwork="${artwork}"]`).first(); + await expect(galleryIcon).toHaveCount(1); + await page.evaluate((iconFile) => window.dispatchEvent(new MessageEvent('message', { + data: { command: 'achievementUnlockNotice', text: 'Astra unlocked', iconFile }, + })), artwork); + const noticeIcon = page.locator('#ccuAchievementUnlockNotice img'); + await expect(noticeIcon).toBeVisible(); + await expect(noticeIcon).toHaveAttribute('src', await galleryIcon.getAttribute('src')); +}); + +test('a visible notice acknowledges exactly its opaque host token without moving focus', async ({ page }) => { + await openCodex(page); + const tab = page.locator('#tab-achievements'); + await tab.focus(); + const noticeId = 'a'.repeat(48); + await page.evaluate((noticeId) => window.dispatchEvent(new MessageEvent('message', { + data: { command: 'achievementUnlockNotice', text: 'Local badge', noticeId }, + })), noticeId); + await expect(page.locator('#ccuAchievementUnlockNotice')).toBeVisible(); + await expect(tab).toBeFocused(); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.filter(row => row.command === 'achievementNoticeAck'))) + .toEqual([{ command: 'achievementNoticeAck', noticeId }]); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { data: { command: 'achievementNoticeDismiss' } }))); + await expect(page.locator('#ccuAchievementUnlockNotice')).toHaveCount(0); +}); + +test('notice timer pauses while keyboard focus or pointer is inside it', async ({ page }) => { + await openCodex(page); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { + data: { command: 'achievementUnlockNotice', text: 'Local badge' }, + }))); + const notice = page.locator('#ccuAchievementUnlockNotice'); + await notice.getByRole('button').focus(); + await page.clock.runFor(6001); + await expect(notice).toBeVisible(); + await page.locator('#tab-achievements').focus(); + await notice.hover(); + await page.clock.runFor(6001); + await expect(notice).toBeVisible(); + await page.locator('#tab-achievements').hover(); + await page.clock.runFor(6001); + await expect(notice).toHaveCount(0); +}); + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + for (const theme of ['light', 'dark']) { + test(`${locale} exported achievement levels stay inside the ${theme} narrow preview`, async ({ page }, testInfo) => { + await openCompare(page, { locale, theme, fixture: 'combined-heatmap', commandTemplate: 'achievements', width: 360 }); + const copy = ACHIEVEMENT_COPY[locale]; + const cumulative = ACHIEVEMENT_SERIES['cumulative-volume']; + const longest = Object.keys(copy.titles).filter((id) => !cumulative.includes(id)) + .sort((left, right) => copy.titles[right].length - copy.titles[left].length).slice(0, 4); + const selected = [...cumulative, ...longest]; + // Actual production SVG, not a placeholder host acknowledgement. Rules + // determining earned selection are covered by host/unit tests separately. + const svg = shareSvg(selected, copy); + await selectRendererIds(page, selected); + await acceptAchievementSvg(page, svg); + const preview = page.locator('#achievementSharePreview > svg'); + await expect(preview).toBeVisible(); + await expect(preview.locator('g[data-achievement-share-badge]')).toHaveCount(9); + expect(await preview.locator('g[data-achievement-share-badge]').evaluateAll(badges => + badges.map(badge => badge.getAttribute('data-achievement-share-badge')))).toEqual(selected); + const overflows = await preview.evaluate((svg) => { + const bounds = svg.viewBox.baseVal; + const outside = (box, area) => box.x < area.x - 1 || box.y < area.y - 1 || + box.x + box.width > area.x + area.width + 1 || box.y + box.height > area.y + area.height + 1; + const failures = []; + for (const text of svg.querySelectorAll('text')) { + const badge = text.closest('g[data-achievement-share-badge]'); + const area = badge ? badge.querySelector('rect').getBBox() : bounds; + if (outside(text.getBBox(), area)) failures.push(text.textContent); + } + return failures; + }); + expect(overflows).toEqual([]); + for (const label of copy.levels) await expect(preview).toContainText(label); + await expect(preview).not.toContainText('local achievement milestones'); + if (locale === 'zh-CN') await preview.screenshot({ path: testInfo.outputPath(`achievement-share-levels-${theme}.png`) }); + }); + + test(`${locale} achievement cards fit a narrow ${theme} webview`, async ({ page }, testInfo) => { + await openCodex(page, { locale, theme, width: 360, height: 800 }); + await page.locator('#tab-achievements').click(); + const weeklyTitle = await page.locator('[data-achievement-series="weekly-volume"] h4').textContent(); + expect(weeklyTitle).not.toContain(ACHIEVEMENT_COPY[locale].levels[0]); + const sizes = await page.evaluate(() => ({ + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + cards: [...document.querySelectorAll('.achievement-card')].map((card) => card.scrollWidth - card.clientWidth), + })); + expect(sizes.document, sizes).toBeLessThanOrEqual(sizes.viewport + 1); + expect(sizes.body, sizes).toBeLessThanOrEqual(sizes.viewport + 1); + expect(sizes.cards.every((overflow) => overflow <= 1), sizes).toBe(true); + const icons = page.locator('.achievement-card img[data-achievement-icon]'); + await expect(icons).toHaveCount(ACHIEVEMENT_GALLERY.length); + // Walk the gallery in small batches, preserving production lazy loading. + // Offscreen PNGs must decode before a full-gallery evidence screenshot; + // capturing first would show blank emblems that are not a product defect. + const galleryIcons = await icons.all(); + for (let index = 0; index < galleryIcons.length; index += 4) { + await galleryIcons[index].scrollIntoViewIfNeeded(); + } + await galleryIcons.at(-1).scrollIntoViewIfNeeded(); + await expect.poll(() => icons.evaluateAll((images) => + images.every((image) => image.complete && image.naturalWidth > 0))).toBe(true); + if (locale === 'zh-CN') { + await galleryIcons[0].scrollIntoViewIfNeeded(); + await page.locator('#achievements').screenshot({ + path: testInfo.outputPath(`achievements-zh-CN-${theme}.png`), + }); + } + }); + } +} diff --git a/tests/ui/api-heatmap.spec.mjs b/tests/ui/api-heatmap.spec.mjs new file mode 100644 index 00000000..86af16db --- /dev/null +++ b/tests/ui/api-heatmap.spec.mjs @@ -0,0 +1,471 @@ +import AxeBuilder from '@axe-core/playwright'; +import { createRequire } from 'node:module'; +import { test, expect, openCompare, openClaude, openCodex } from './support/app.mjs'; + +const require = createRequire(import.meta.url); +const { buildApiHeatmapArtifact } = require('../../out/apiHeatmap.js'); +const { API_HEATMAP_UI_TRANSLATIONS } = require('../../out/i18n.js'); + +// Browser protocol tests use fakeHost: no Extension Host save dialog, clipboard, +// filesystem write or pricing loader runs here. The synthetic response bytes do +// come from the current production artifact builder. Real-host immutable export +// and source/price retirement are covered separately by apiHeatmapHost.test.ts. +const usage = (tokens, amountUSD, pricingStatus = 'complete', estimated = false) => ({ + tokens, amountUSD, pricingStatus, estimated, + pricedCount: ['empty', 'unpriced'].includes(pricingStatus) ? 0 : 2, + unpricedCount: ['unpriced', 'partial'].includes(pricingStatus) ? 1 : 0, +}); +const knownDaily = { + '2026-07-20': { claude: usage(170_000, 17, 'complete', true), codex: usage(270_000, 27) }, +}; +const qualityDaily = { + '2026-07-16': { claude: usage(100, null, 'unpriced', true) }, + '2026-07-17': { claude: usage(100, 2, 'partial', true) }, + '2026-07-18': { claude: { ...usage(0, 0), pricedCount: 0 } }, + '2026-07-20': knownDaily['2026-07-20'], +}; +const preview = page => page.locator('#combinedHeatmapPreview'); +const exports = page => page.locator('#combinedHeatmapExport'); +const copies = page => page.locator('#combinedHeatmapCopy'); +const posted = page => page.evaluate(() => window.__ccuPostedMessages); +const clearMessages = page => page.evaluate(() => { window.__ccuPostedMessages = []; }); +const reply = (page, data) => page.evaluate(data => { + window.dispatchEvent(new MessageEvent('message', { data })); +}, data); + +async function requestPreview(page) { + await page.locator('.combined-share-actions .btn-primary').click(); + const request = (await posted(page)).findLast(row => row.command === 'previewCombinedHeatmap'); + expect(request.documentId).toMatch(/^[0-9a-f]{48}$/); + expect(Number.isSafeInteger(request.requestId) && request.requestId > 0).toBe(true); + return request; +} + +function fakeHost(request, { daily = knownDaily, locale = 'en' } = {}) { + const artifact = buildApiHeatmapArtifact(daily, { + ...request, endDateISO: '2026-07-20', timezone: 'Asia/Hong_Kong', locale, currency: 'USD', + }); + return { artifact, response: { + command: 'combinedHeatmapResult', documentId: request.documentId, requestId: request.requestId, + previewId: request.requestId.toString(16).padStart(48, '0'), hasData: true, + svg: artifact.svg, markdown: artifact.markdown, filename: artifact.filename, + } }; +} + +async function acceptPreview(page, options) { + const request = await requestPreview(page); + const result = fakeHost(request, options); + await reply(page, result.response); + await expect(preview(page)).toHaveAttribute('data-preview-id', result.response.previewId); + await expect(exports(page)).toBeEnabled(); + return { request, ...result }; +} + +async function expectRetired(page) { + await expect(preview(page).locator('svg')).toHaveCount(0); + await expect(preview(page)).toHaveAttribute('data-preview-id', ''); + await expect(preview(page)).toHaveAttribute('data-config-key', ''); + await expect(page.locator('#combinedHeatmapMarkdown')).toHaveValue(''); + await expect(exports(page)).toBeDisabled(); + await expect(copies(page)).toBeDisabled(); +} + +const attemptedExternalRequests = new WeakMap(); +test.beforeEach(async ({ page }) => { + const attempts = []; + attemptedExternalRequests.set(page, attempts); + await page.route('**/*', route => { + const url = new URL(route.request().url()); + if (url.hostname === '127.0.0.1') return route.continue(); + attempts.push(url.origin); + return route.abort(); + }); +}); +test.afterEach(async ({ page }) => { + expect(attemptedExternalRequests.get(page)).toEqual([]); + expect((await posted(page)).some(row => /github|publish|pricing|fetch/i.test(row.command))).toBe(false); +}); + +for (const provider of ['claude', 'codex', 'both']) { + for (const metric of ['tokens', 'apiEquivalent']) { + test(`fakeHost accepts canonical ${provider} × ${metric}, then explicit export/copy`, async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + await expect(page.locator('#combinedHeatmapMetric')).toHaveValue('tokens'); + await expect(page.locator('#combinedHeatmapShowAmounts')).not.toBeChecked(); + await page.locator('#combinedHeatmapProvider').selectOption(provider); + await page.locator('#combinedHeatmapMetric').selectOption(metric); + await clearMessages(page); + await expectRetired(page); + // Even invoking the public handlers cannot emit an export before ACK. + await page.evaluate(() => { exportCombinedHeatmap(); copyCombinedHeatmapMarkdown(); }); + expect(await posted(page)).toEqual([]); + const { request, artifact, response } = await acceptPreview(page); + expect((await posted(page)).map(row => row.command)).toEqual([ + 'retireCombinedHeatmapPreview', 'previewCombinedHeatmap', + ]); + await expect(page.locator('#combinedHeatmapMarkdown')).toHaveValue(artifact.markdown); + const svg = await preview(page).locator(':scope > svg').evaluate(node => node.outerHTML); + if (metric === 'apiEquivalent') { + const selected = provider === 'both' ? ['claude', 'codex'] : [provider]; + await expect(preview(page).locator('[data-provider]')).toHaveCount(selected.length); + for (const source of selected) { + await expect(preview(page).locator(`[data-provider="${source}"]`)) + .toContainText(source === 'claude' ? '$17.00' : '$27.00'); + } + expect(svg + artifact.markdown).not.toContain('$44.00'); + await expect(page.locator('#combinedHeatmapAmountDisclosure')).toBeVisible(); + } else { + expect(svg + artifact.markdown).not.toMatch(/\$|amountUSD|pricedCount|Current pricing|Coverage/); + await expect(page.locator('#combinedHeatmapAmountDisclosure')).toBeHidden(); + expect(artifact.summary).toContain(provider === 'codex' ? '270K' : '170K'); + } + expect(request).toMatchObject({ provider, metric, showAmounts: metric === 'apiEquivalent' }); + const acceptedIdentity = await preview(page).evaluate(node => ({ + html: node.innerHTML, previewId: node.dataset.previewId, configKey: node.dataset.configKey, + })); + // Privacy preview is UI information only. It travels with the draft but + // cannot change artifact bytes or invalidate the accepted artifact key. + await page.locator('#combinedHeatmapPrivacy').uncheck(); + expect(await preview(page).evaluate(node => ({ + html: node.innerHTML, previewId: node.dataset.previewId, configKey: node.dataset.configKey, + }))).toEqual(acceptedIdentity); + expect(fakeHost({ ...request, privacyPreview: false }).artifact).toEqual(artifact); + await exports(page).click(); + await expect(exports(page)).toBeDisabled(); + await expect(copies(page)).toBeDisabled(); + await reply(page, { command: 'combinedHeatmapExportResult', documentId: request.documentId, + previewId: response.previewId, ok: true }); + await expect(exports(page)).toBeEnabled(); + await copies(page).click(); + const actions = (await posted(page)).filter(row => /^(exportCombinedHeatmap|copyCombinedHeatmapMarkdown)$/.test(row.command)); + expect(actions).toEqual(['exportCombinedHeatmap', 'copyCombinedHeatmapMarkdown'].map(command => { + const { command: _command, requestId: _requestId, ...draft } = request; + return { command, ...draft, privacyPreview: false, previewId: response.previewId }; + })); + }); + } +} + +test('canonical artifact distinguishes missing, unpriced, partial and real zero in the browser', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + await page.locator('#combinedHeatmapProvider').selectOption('claude'); + await page.locator('#combinedHeatmapMetric').selectOption('apiEquivalent'); + await acceptPreview(page, { daily: qualityDaily }); + const cell = date => preview(page).locator('rect').filter({ has: page.locator('title', { hasText: date }) }); + await expect(cell('2026-07-16')).toHaveAttribute('fill', '#d1d5db'); + await expect(cell('2026-07-16')).toContainText('Unpriced'); + await expect(cell('2026-07-16')).not.toContainText('$0'); + await expect(cell('2026-07-17')).toHaveAttribute('stroke-dasharray', '2 2'); + await expect(cell('2026-07-17')).toContainText('Priced subtotal ≥$2.00'); + await expect(cell('2026-07-17')).toContainText('Coverage 2/3'); + await expect(cell('2026-07-18')).toHaveAttribute('fill', '#ebedf0'); + await expect(cell('2026-07-18')).toContainText('$0.00'); + await expect(cell('2026-07-19')).toHaveAttribute('fill', '#f6f8fa'); + await expect(cell('2026-07-19')).toContainText('No data'); + await expect(cell('2026-07-19')).not.toContainText('$0'); +}); + +test('hide amounts synchronously retires money bytes, metadata, colors and rejects late money reply', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + await page.locator('#combinedHeatmapMetric').selectOption('apiEquivalent'); + const money = await acceptPreview(page); + // Inspect immediately inside the actual change event turn, before any waits. + const immediate = await page.locator('#combinedHeatmapShowAmounts').evaluate(input => { + input.click(); + const node = document.getElementById('combinedHeatmapPreview'); + return { html: node.innerHTML, previewId: node.dataset.previewId, + markdown: document.getElementById('combinedHeatmapMarkdown').value, + disabled: document.getElementById('combinedHeatmapExport').disabled }; + }); + expect(immediate).toEqual({ html: '', previewId: '', markdown: '', disabled: true }); + await expect(page.locator('#combinedHeatmapMetric')).toHaveValue('tokens'); + await expect(page.locator('#combinedHeatmapAmountDisclosure')).toBeHidden(); + await reply(page, money.response); + await expectRetired(page); + // Also reject a genuinely pending money response after the opt-in is revoked. + await page.locator('#combinedHeatmapMetric').selectOption('apiEquivalent'); + const pending = fakeHost(await requestPreview(page)); + await page.locator('#combinedHeatmapShowAmounts').uncheck(); + await reply(page, pending.response); + await expectRetired(page); + const token = await acceptPreview(page); + const protectedDaily = { '2026-07-20': {} }; + for (const provider of ['claude', 'codex']) { + const source = { tokens: knownDaily['2026-07-20'][provider].tokens }; + for (const key of ['amountUSD', 'pricingStatus', 'estimated', 'pricedCount', 'unpricedCount', 'sourceComplete']) { + Object.defineProperty(source, key, { get() { throw new Error(`hidden read ${key}`); } }); + } + protectedDaily['2026-07-20'][provider] = source; + } + expect(fakeHost(token.request, { daily: protectedDaily }).artifact).toEqual(token.artifact); + const content = await preview(page).evaluate(node => node.innerHTML); + expect(content + token.artifact.markdown).not.toMatch(/\$|amountUSD|pricedCount|Coverage|Current pricing| nodes.map(node => node.getAttribute('fill'))); + expect(tokenFills).not.toContain('#d1d5db'); + expect(tokenFills).not.toContain('#f6f8fa'); +}); + +test('wrong document, old request and changed draft cannot accept a reply', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + const first = fakeHost(await requestPreview(page)); + await reply(page, { ...first.response, documentId: 'f'.repeat(48) }); + await expectRetired(page); + const second = fakeHost(await requestPreview(page)); + await reply(page, first.response); + await expectRetired(page); + // A changed draft is rejected even without firing a retirement event. + await page.locator('#combinedHeatmapTitle').evaluate(input => { input.value = 'Different unsubmitted draft'; }); + await reply(page, second.response); + await expectRetired(page); + await acceptPreview(page); + const messages = (await posted(page)).filter(row => /^(retireCombinedHeatmapPreview|previewCombinedHeatmap)$/.test(row.command)); + expect(messages.every((row, index) => row.requestId > 0 && (index === 0 || row.requestId > messages[index - 1].requestId))).toBe(true); + expect(new Set(messages.map(row => row.documentId)).size).toBe(1); +}); + +test('each artifact input immediately retires an accepted preview without requesting export', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + for (const [id, value, event] of [ + ['combinedHeatmapTitle', 'Changed title', 'input'], + ['combinedHeatmapRange', '30d', 'change'], + ['combinedHeatmapIntensityMode', 'linear', 'change'], + ['combinedHeatmapProvider', 'codex', 'change'], + ['combinedHeatmapMetric', 'apiEquivalent', 'change'], + ['combinedHeatmapCustomAccent', '#123456', 'change'], + ]) { + await acceptPreview(page); + await clearMessages(page); + const immediate = await page.locator(`#${id}`).evaluate((input, { value, event }) => { + input.value = value; + input.dispatchEvent(new Event(event, { bubbles: true })); + return { html: document.getElementById('combinedHeatmapPreview').innerHTML, + markdown: document.getElementById('combinedHeatmapMarkdown').value, + disabled: document.getElementById('combinedHeatmapExport').disabled }; + }, { value, event }); + expect(immediate, id).toEqual({ html: '', markdown: '', disabled: true }); + expect((await posted(page)).map(row => row.command)).toEqual(['retireCombinedHeatmapPreview']); + } + await acceptPreview(page); + await page.getByRole('radio', { name: 'Custom', exact: true }).check(); + await expectRetired(page); +}); + +test('no-data, failed and malformed preview acknowledgments cannot enable export', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + for (const invalid of [{ hasData: false }, { failed: true }, { previewId: 'invalid' }, { svg: '' }]) { + const result = fakeHost(await requestPreview(page)); + await reply(page, { ...result.response, ...invalid }); + await expect(exports(page)).toBeDisabled(); + await expect(copies(page)).toBeDisabled(); + await expect(preview(page)).toHaveAttribute('data-preview-id', ''); + await expect(page.locator('#combinedHeatmapMarkdown')).toHaveValue(''); + } +}); + +for (const outcome of ['saved', 'cancelled', 'failed', 'stale']) { + test(`matching API export ${outcome} feedback releases busy state`, async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + await page.locator('#combinedHeatmapMetric').selectOption('apiEquivalent'); + const accepted = await acceptPreview(page); + await exports(page).click(); + const feedback = { command: 'combinedHeatmapExportResult', documentId: accepted.request.documentId, + previewId: accepted.response.previewId, ok: outcome === 'saved', + cancelled: outcome === 'cancelled', stale: outcome === 'stale' }; + await reply(page, { ...feedback, documentId: 'f'.repeat(48) }); + await reply(page, { ...feedback, previewId: 'e'.repeat(48) }); + await expect(exports(page)).toBeDisabled(); + await reply(page, feedback); + await expect(page.locator('#combinedHeatmapStatus')).toHaveText(API_HEATMAP_UI_TRANSLATIONS.en[outcome]); + if (outcome === 'stale') await expectRetired(page); + else await expect(exports(page)).toBeEnabled(); + await acceptPreview(page); + await exports(page).click(); + expect((await posted(page)).filter(row => row.command === 'exportCombinedHeatmap')).toHaveLength(2); + }); +} + +for (const [provider, metric] of [['both', 'apiEquivalent'], ['claude', 'tokens'], ['codex', 'tokens']]) { + test(`default-title reload regenerates restored ${provider} ${metric} independently of title edits`, async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + const title = page.locator('#combinedHeatmapTitle'); + const defaultTitle = await title.getAttribute('data-default-value'); + await expect(title).toHaveValue(defaultTitle); + if (provider !== 'both') await page.locator('#combinedHeatmapProvider').selectOption(provider); + if (metric !== 'tokens') await page.locator('#combinedHeatmapMetric').selectOption(metric); + const before = await acceptPreview(page); + expect(await page.evaluate(() => localStorage.getItem('ccu.combinedHeatmap.title'))).toBe(defaultTitle); + await page.reload({ waitUntil: 'load' }); + await expect(title).toHaveValue(defaultTitle); + await expect(page.locator('#combinedHeatmapProvider')).toHaveValue(provider); + await expect(page.locator('#combinedHeatmapMetric')).toHaveValue(metric); + expect(await page.locator('#combinedHeatmapShowAmounts').isChecked()).toBe(metric === 'apiEquivalent'); + const restoredState = await page.evaluate(() => ({ + previewRequests: window.__ccuPostedMessages.filter(row => row.command === 'previewCombinedHeatmap').length, + hasSvg: !!document.querySelector('#combinedHeatmapPreview svg'), + exportDisabled: document.querySelector('#combinedHeatmapExport').disabled, + copyDisabled: document.querySelector('#combinedHeatmapCopy').disabled, + })); + // Restored controls may not relabel the default both-provider token artifact + // as accepted. Require a fresh, document-bound production request first. + expect(restoredState).toEqual({ previewRequests: 1, hasSvg: false, exportDisabled: true, copyDisabled: true }); + await expectRetired(page); + const request = (await posted(page)).findLast(row => row.command === 'previewCombinedHeatmap'); + expect(request).toMatchObject({ title: defaultTitle, provider, metric, showAmounts: metric === 'apiEquivalent' }); + expect(request.documentId).not.toBe(before.request.documentId); + await reply(page, before.response); + await expectRetired(page); + const restored = fakeHost(request); + await reply(page, restored.response); + await expect(preview(page)).toHaveAttribute('data-preview-id', restored.response.previewId); + await expect(exports(page)).toBeEnabled(); + await expect(copies(page)).toBeEnabled(); + await expect(page.locator('#combinedHeatmapMarkdown')).toHaveValue(restored.artifact.markdown); + if (metric === 'apiEquivalent') { + await expect(preview(page).locator('svg[data-provider]')).toHaveCount(2); + for (const selected of ['claude', 'codex']) + await expect(preview(page).locator(`svg[data-provider="${selected}"]`)).toHaveCount(1); + await expect(preview(page)).toContainText('$17.00'); + await expect(preview(page)).toContainText('$27.00'); + } else { + await expect(preview(page).locator(':scope > svg')).toHaveAttribute('aria-label', `${defaultTitle}. ${restored.artifact.summary}`); + await expect(preview(page)).toContainText(restored.artifact.summary); + expect(restored.artifact.summary).toContain(provider === 'claude' ? 'Claude 170K' : 'Codex 270K'); + expect(await preview(page).textContent()).not.toMatch(/\$17|\$27/); + } + // The resulting explicit export must carry the newly accepted identity. + await exports(page).click(); + expect((await posted(page)).findLast(row => row.command === 'exportCombinedHeatmap')) + .toMatchObject({ previewId: restored.response.previewId, documentId: request.documentId, + provider, metric, showAmounts: metric === 'apiEquivalent' }); + }); +} + +test('operation → reload preserves API preferences separately from Share Card estimatedCost', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'claudeShareCard' }); + await page.locator('.sc-sec[data-sec="estimatedCost"]').uncheck(); + await page.getByLabel('Presentation').selectOption('combinedHeatmap'); + await openCompare(page, { fixture: 'combined-heatmap' }); + await page.locator('#combinedHeatmapProvider').selectOption('codex'); + await page.locator('#combinedHeatmapMetric').selectOption('apiEquivalent'); + await page.locator('#combinedHeatmapTitle').fill('Saved API draft'); + await page.locator('#combinedHeatmapRange').selectOption('90d'); + const before = await acceptPreview(page); + await page.reload({ waitUntil: 'load' }); + await expect(page.locator('#combinedHeatmapProvider')).toHaveValue('codex'); + await expect(page.locator('#combinedHeatmapMetric')).toHaveValue('apiEquivalent'); + await expect(page.locator('#combinedHeatmapShowAmounts')).toBeChecked(); + await expect(page.locator('#combinedHeatmapTitle')).toHaveValue('Saved API draft'); + await expect(page.locator('#combinedHeatmapRange')).toHaveValue('90d'); + await expectRetired(page); + const restored = (await posted(page)).findLast(row => row.command === 'previewCombinedHeatmap'); + expect(restored.documentId).not.toBe(before.request.documentId); + await reply(page, before.response); + await expectRetired(page); + await reply(page, fakeHost(restored).response); + await expect(exports(page)).toBeEnabled(); + await openCompare(page, { fixture: 'combined-heatmap', commandTemplate: 'claudeShareCard' }); + await expect(page.locator('.sc-sec[data-sec="estimatedCost"]')).not.toBeChecked(); + await page.locator('.sc-sec[data-sec="estimatedCost"]').check(); + await page.reload({ waitUntil: 'load' }); + await expect(page.locator('.sc-sec[data-sec="estimatedCost"]')).toBeChecked(); + expect(await page.evaluate(() => localStorage.getItem('ccu.combinedHeatmap.showAmounts'))).toBe('true'); +}); + +for (const surviving of ['claude', 'codex']) { + test(`unavailable restored provider cannot transfer amount opt-in to ${surviving}`, async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap' }); + await page.locator('#combinedHeatmapProvider').selectOption(surviving === 'claude' ? 'codex' : 'claude'); + await page.locator('#combinedHeatmapMetric').selectOption('apiEquivalent'); + await acceptPreview(page); + if (surviving === 'claude') await openClaude(page, { fixture: 'combined-heatmap', claudeOnly: true }); + else await openCodex(page, { fixture: 'combined-heatmap', codexOnly: true }); + await page.locator('#tab-all').click(); + await page.getByLabel('Presentation').selectOption('combinedHeatmap'); + await page.reload({ waitUntil: 'load' }); + await expect(page.locator('#combinedHeatmapProvider')).toHaveValue(surviving); + await expect(page.locator('#combinedHeatmapMetric')).toHaveValue('tokens'); + await expect(page.locator('#combinedHeatmapShowAmounts')).not.toBeChecked(); + await expect(page.locator('#combinedHeatmapAmountDisclosure')).toBeHidden(); + expect((await posted(page)).filter(row => row.command === 'previewCombinedHeatmap') + .every(row => row.metric === 'tokens' && row.showAmounts === false)).toBe(true); + }); +} + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} provider, metric, opt-in and adjacent disclosure are localized and accessible`, async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', locale }); + const copy = API_HEATMAP_UI_TRANSLATIONS[locale]; + const panel = page.locator('[data-sharing-presentation="combinedHeatmap"]'); + await expect(panel.getByRole('combobox', { name: copy.providerLabel, exact: true })).toHaveValue('both'); + await expect(panel.getByRole('combobox', { name: copy.metricLabel, exact: true })).toHaveValue('tokens'); + await expect(panel.getByRole('checkbox', { name: copy.showAmounts, exact: true })).not.toBeChecked(); + await panel.getByRole('combobox', { name: copy.metricLabel, exact: true }).selectOption('apiEquivalent'); + await expect(panel.getByRole('checkbox', { name: copy.showAmounts, exact: true })).toBeChecked(); + await expect(page.locator('#combinedHeatmapAmountDisclosure')).toHaveText(copy.amountDisclosure); + await expect(page.locator('#combinedHeatmapAmountDisclosure')).toBeVisible(); + expect(await page.locator('#combinedHeatmapShowAmounts').evaluate(input => + input.closest('label').nextElementSibling.id)).toBe('combinedHeatmapAmountDisclosure'); + await acceptPreview(page, { locale }); + const results = await new AxeBuilder({ page }).include('.combined-heatmap-panel').analyze(); + expect(results.violations).toEqual([]); + }); +} + +for (const [width, locale, theme] of [[320, 'en', 'light'], [360, 'zh-CN', 'dark']]) { + test(`${width}px ${locale} ${theme} API scroller retains provider headings and actual screenshot`, async ({ page }, testInfo) => { + await openCompare(page, { fixture: 'combined-heatmap', locale, theme, width, height: 900 }); + // Establish rendering-environment fidelity before accepting screenshots. + const missingThemeVariables = await page.evaluate(() => { + const css = [...document.querySelectorAll('head style:not(#test-vscode-theme)')].map(el => el.textContent).join('\n'); + const harnessCss = document.querySelector('#test-vscode-theme').textContent; + const defined = new Map([...harnessCss.matchAll(/(--vscode-[\w-]+)\s*:\s*([^;{}]*);/g)] + .map(match => [match[1], match[2].trim()])); + const nullable = new Set(['--vscode-input-border', '--vscode-textBlockQuote-background', '--vscode-toolbar-hoverBackground', + '--vscode-button-secondaryBackground', '--vscode-button-secondaryHoverBackground', '--vscode-charts-orange', + '--vscode-list-inactiveSelectionBackground', '--vscode-widget-shadow']); + const style = getComputedStyle(document.documentElement); + return [...new Set([...css.matchAll(/var\(\s*(--vscode-[\w-]+)/g)].map(match => match[1]))] + .filter(name => { + if (!defined.has(name)) return true; + if (style.getPropertyValue(name).trim()) return false; + // An explicit registered null color is faithful; missing variables + // and arbitrary empty definitions are never rescued by a fallback. + return !(nullable.has(name) && defined.get(name) === 'initial'); + }); + }); + expect(missingThemeVariables).toEqual([]); + await page.locator('#combinedHeatmapMetric').selectOption('apiEquivalent'); + await acceptPreview(page, { locale }); + await expect(preview(page)).toHaveAttribute('tabindex', '0'); + const geometry = await preview(page).evaluate(node => { + const svg = node.querySelector(':scope > svg'); + const labels = [...svg.querySelectorAll('text')]; + return { pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, + headings: labels.filter(label => /Claude|Codex/.test(label.textContent)).map(label => label.textContent), + outside: [...svg.querySelectorAll('svg[data-provider]')].flatMap(block => { + const box = block.viewBox.baseVal; + return [...block.querySelectorAll('text')].filter(label => { + const bounds = label.getBBox(); + return bounds.x < -0.5 || bounds.y < -0.5 || bounds.x + bounds.width > box.width + 0.5 || bounds.y + bounds.height > box.height + 0.5; + }).map(label => label.textContent); + }) }; + }); + expect(geometry.pageOverflow).toBeLessThanOrEqual(1); + expect(geometry.scrollWidth).toBeGreaterThan(geometry.clientWidth); + expect(geometry.headings.some(text => text.includes('Claude'))).toBe(true); + expect(geometry.headings.some(text => text.includes('Codex'))).toBe(true); + expect(geometry.outside, geometry).toEqual([]); + await preview(page).scrollIntoViewIfNeeded(); + const path = testInfo.outputPath(`api-${width}-${locale}-${theme}.png`); + await page.screenshot({ path, fullPage: true }); + await testInfo.attach('API heatmap actual browser screenshot', { path, contentType: 'image/png' }); + await preview(page).focus(); + await page.keyboard.press('ArrowRight'); + await page.clock.runFor(300); + await expect.poll(() => preview(page).evaluate(node => node.scrollLeft)).toBeGreaterThan(0); + const scrollerPath = testInfo.outputPath(`api-scroller-${width}-${locale}-${theme}.png`); + await preview(page).screenshot({ path: scrollerPath }); + await testInfo.attach('API heatmap focused scroller screenshot', { path: scrollerPath, contentType: 'image/png' }); + }); +} diff --git a/tests/ui/codex-accessibility.spec.mjs b/tests/ui/codex-accessibility.spec.mjs index 7a3ea38c..67a0eaf1 100644 --- a/tests/ui/codex-accessibility.spec.mjs +++ b/tests/ui/codex-accessibility.spec.mjs @@ -73,7 +73,7 @@ async function expectKeyboardFocusRing(page, locator) { expect(treatment.outlineOffset).toBeGreaterThanOrEqual(2); } -for (const tab of ['today', 'month', 'all', 'sessions', 'projects', 'content', 'settings']) { +for (const tab of ['today', 'month', 'all', 'sessions', 'projects', 'content', 'achievements', 'settings']) { test(`${tab} has no serious or critical Axe violations`, async ({ page }) => { await openCodex(page, { locale: 'en' }); if (tab !== 'today') { @@ -84,6 +84,12 @@ for (const tab of ['today', 'month', 'all', 'sessions', 'projects', 'content', ' }); } +test('achievement gallery has no serious or critical Axe violations in the light theme', async ({ page }) => { + await openCodex(page, { locale: 'zh-CN', theme: 'light' }); + await page.locator('#tab-achievements').click(); + expect(await seriousOrCriticalViolations(page)).toEqual([]); +}); + for (const scenario of [ { name: 'Claude light English', open: openClaude, options: { locale: 'en', theme: 'light' } }, { name: 'Claude dark CJK', open: openClaude, options: { locale: 'zh-CN', theme: 'dark' } }, @@ -101,7 +107,7 @@ test('dashboard tablist uses automatic activation with roving keyboard focus', a await openCodex(page, { locale: 'en' }); const tablist = page.locator('.tabs[role="tablist"]'); - const tabNames = ['today', 'month', 'all', 'sessions', 'projects', 'content', 'settings']; + const tabNames = ['today', 'month', 'all', 'sessions', 'projects', 'content', 'achievements', 'settings']; await expect(tablist).toHaveAttribute('aria-label', /\S/); await expect(tablist.getByRole('tab')).toHaveCount(tabNames.length); @@ -199,7 +205,7 @@ test('shared chart controls expose and update aria-pressed from the keyboard', a for (const scenario of [ { name: 'Claude', label: 'Claude', open: openClaude }, { name: 'Codex', label: 'Codex', open: openCodex }, - { name: 'Compare', label: 'Compare', open: openCompare }, + { name: 'Share & compare', label: 'Share & compare', open: openCompare }, ]) { test(`${scenario.name} chart regions have unique provider-qualified accessible names`, async ({ page }) => { await scenario.open(page, { locale: 'en' }); diff --git a/tests/ui/codex-visual.spec.mjs b/tests/ui/codex-visual.spec.mjs index 7636856a..4bde089b 100644 --- a/tests/ui/codex-visual.spec.mjs +++ b/tests/ui/codex-visual.spec.mjs @@ -19,6 +19,8 @@ const REGISTERED_VSCODE_VARIABLES = new Set([ '--vscode-symbolIcon-functionForeground', '--vscode-testing-iconPassed', '--vscode-textBlockQuote-background', '--vscode-textBlockQuote-border', '--vscode-textCodeBlock-background', '--vscode-textLink-foreground', '--vscode-toolbar-hoverBackground', + '--vscode-editor-foreground', '--vscode-editorWidget-foreground', + '--vscode-list-inactiveSelectionBackground', '--vscode-sideBar-background', '--vscode-widget-shadow', ]); const SHARED_VISUAL_TOKENS = [ @@ -206,6 +208,7 @@ test('Codex uses the shared dashboard shell and tab vocabulary', async ({ page } 'Sessions', 'Projects', 'Recommendations', + 'Achievements', 'Settings', ]); await expect(page.locator('.tab-content.active')).toHaveAttribute('id', 'today'); @@ -550,57 +553,32 @@ for (const theme of ['light', 'dark']) { test(`${theme} harness defines every VS Code variable used by the dashboard`, async ({ page }) => { await openClaude(page, { theme }); - const { referenced, defined } = await page.evaluate(() => { - const productionCss = document.querySelector('head style:not(#test-vscode-theme)')?.textContent ?? ''; + const { referenced, defined, missing } = await page.evaluate(() => { + const productionCss = [...document.querySelectorAll('head style:not(#test-vscode-theme)')] + .map(node => node.textContent).join('\n'); const harnessCss = document.querySelector('#test-vscode-theme')?.textContent ?? ''; - const references = []; - const pattern = /var\(\s*(--vscode-[\w-]+)/g; - let match; - while ((match = pattern.exec(productionCss)) !== null) { - let depth = 1; - let hasFallback = false; - for (let index = pattern.lastIndex; index < productionCss.length; index += 1) { - if (productionCss[index] === '(') depth += 1; - if (productionCss[index] === ')') depth -= 1; - if (productionCss[index] === ',' && depth === 1) hasFallback = true; - if (depth === 0) break; - } - references.push({ name: match[1], hasFallback }); - } + const references = [...new Set([...productionCss.matchAll(/var\(\s*(--vscode-[\w-]+)/g)] + .map(match => match[1]))]; + const declarations = new Map([...harnessCss.matchAll(/(--vscode-[\w-]+)\s*:\s*([^;{}]*);/g)] + .map(match => [match[1], match[2].trim()])); + const nullable = new Set(['--vscode-input-border', '--vscode-textBlockQuote-background', '--vscode-toolbar-hoverBackground', + '--vscode-button-secondaryBackground', '--vscode-button-secondaryHoverBackground', '--vscode-charts-orange', + '--vscode-list-inactiveSelectionBackground', '--vscode-widget-shadow']); + const style = getComputedStyle(document.documentElement); return { referenced: references, - defined: [...new Set([...harnessCss.matchAll(/(--vscode-[\w-]+)\s*:/g)].map((match) => match[1]))], + defined: [...declarations.keys()], + // A fallback never excuses a missing definition. Computed-empty values + // are valid only for explicitly initial, registered nullable colors. + missing: references.filter(name => !declarations.has(name) || (!style.getPropertyValue(name).trim() && + !(nullable.has(name) && declarations.get(name) === 'initial'))), }; }); - const referenceKinds = new Map(); - for (const { name, hasFallback } of referenced) { - const kinds = referenceKinds.get(name) ?? new Set(); - kinds.add(hasFallback); - referenceKinds.set(name, kinds); - } - const definedSet = new Set(defined); - const missingWithoutFallback = [...referenceKinds] - .filter(([name, kinds]) => - (REGISTERED_VSCODE_VARIABLES.has(name) && !definedSet.has(name)) || - (!REGISTERED_VSCODE_VARIABLES.has(name) && kinds.has(false))) - .map(([name]) => name) - .sort(); - const definedDespiteFallback = [...referenceKinds] - .filter(([name, kinds]) => - !REGISTERED_VSCODE_VARIABLES.has(name) && kinds.has(true) && !kinds.has(false) && definedSet.has(name)) - .map(([name]) => name) - .sort(); - - const failureMessage = [ - `缺失(无回退): ${missingWithoutFallback.join(', ') || '无'}`, - `不应定义(有回退): ${definedDespiteFallback.join(', ') || '无'}`, - ].join('\n'); expect(referenced.length).toBeGreaterThan(0); - expect( - { missingWithoutFallback, definedDespiteFallback }, - failureMessage, - ).toEqual({ missingWithoutFallback: [], definedDespiteFallback: [] }); + expect(referenced.filter(name => !REGISTERED_VSCODE_VARIABLES.has(name))).toEqual([]); + expect(defined.filter(name => !REGISTERED_VSCODE_VARIABLES.has(name))).toEqual([]); + expect(missing).toEqual([]); }); test(`${theme} composition fills are fully opaque`, async ({ page }) => { diff --git a/tests/ui/combined-heatmap.spec.mjs b/tests/ui/combined-heatmap.spec.mjs index 88f2a246..01184b9b 100644 --- a/tests/ui/combined-heatmap.spec.mjs +++ b/tests/ui/combined-heatmap.spec.mjs @@ -3,7 +3,30 @@ import { createRequire } from 'node:module'; import { test, expect, openClaude, openCompare } from './support/app.mjs'; const require = createRequire(import.meta.url); -const { renderCombinedHeatmapSvg } = require('../../out/combinedHeatmapSvg.js'); +const { buildApiHeatmapArtifact } = require('../../out/apiHeatmap.js'); + +// This is a fakeHost browser bridge, not evidence of a real save dialog or +// filesystem export. Matching ACK bytes are produced by the canonical builder. +async function acknowledgeCombinedPreview(page, options = {}) { + const request = await page.evaluate(() => window.__ccuPostedMessages.findLast( + message => message.command === 'previewCombinedHeatmap', + )); + expect(request.documentId).toMatch(/^[0-9a-f]{48}$/); + expect(request.requestId).toBeGreaterThan(0); + const artifact = buildApiHeatmapArtifact({ + '2026-07-20': { + claude: { tokens: 170000, amountUSD: 17, pricingStatus: 'complete', estimated: true, pricedCount: 1, unpricedCount: 0 }, + codex: { tokens: 270000, amountUSD: 27, pricingStatus: 'complete', estimated: false, pricedCount: 1, unpricedCount: 0 }, + }, + }, { ...request, endDateISO: '2026-07-20', timezone: 'Asia/Hong_Kong', locale: 'en', ...options }); + const previewId = request.requestId.toString(16).padStart(48, '0'); + await page.evaluate(data => { + window.dispatchEvent(new MessageEvent('message', { data })); + }, { command: 'combinedHeatmapResult', documentId: request.documentId, requestId: request.requestId, + previewId, hasData: true, svg: artifact.svg, markdown: artifact.markdown, filename: artifact.filename }); + await expect(page.locator('#combinedHeatmapExport')).toBeEnabled(); + return { request, previewId }; +} test('Compare opens one preview-first sharing workspace on the combined presentation', async ({ page }) => { await openCompare(page, { fixture: 'combined-heatmap', locale: 'en' }); @@ -18,7 +41,7 @@ test('Compare opens one preview-first sharing workspace on the combined presenta const markdown = page.locator('#combinedHeatmapMarkdown'); await expect(markdown).toHaveAttribute('aria-label', 'README / Markdown snippet'); await expect(markdown).toHaveValue( - /^!\[Claude \+ Codex local activity\]\(claude-codex-activity-year-\d{4}-\d{2}-\d{2}\.svg\)$/, + /^!\[Claude \+ Codex local activity · Claude .* · Codex .*\]\(api-heatmap-both-tokens-year-\d{4}-\d{2}-\d{2}\.svg\)$/, ); await page.locator('.combined-output-panel summary').click(); await expect(markdown).toBeVisible(); @@ -84,6 +107,7 @@ test('presentation selection swaps truthful previews and survives reload', async 'Combined activity heatmap', 'Claude Share Card', 'Claude token heatmap', + 'Achievement card', ]); await selector.selectOption('claudeShareCard'); @@ -318,7 +342,14 @@ test('combined share preferences stay local and every export action is explicit' await page.evaluate(() => { window.__ccuPostedMessages = []; }); await page.getByRole('button', { name: 'Update preview' }).click(); + await expect(page.locator('#combinedHeatmapExport')).toBeDisabled(); + await expect(page.locator('#combinedHeatmapCopy')).toBeDisabled(); + const accepted = await acknowledgeCombinedPreview(page); await page.getByRole('button', { name: 'Export SVG…' }).click(); + await page.evaluate(data => { + window.dispatchEvent(new MessageEvent('message', { data })); + }, { command: 'combinedHeatmapExportResult', documentId: accepted.request.documentId, + previewId: accepted.previewId, ok: true }); await page.getByRole('button', { name: 'Copy Markdown' }).click(); const state = await page.evaluate(() => ({ @@ -336,10 +367,18 @@ test('combined share preferences stay local and every export action is explicit' title: 'My local activity', range: '30d', privacy: 'false', intensityMode: 'logarithmic', palette: 'custom', customAccent: '#0f766e', }); + const draft = { title: 'My local activity', range: '30d', intensityMode: 'logarithmic', + palette: 'custom', customAccent: '#0f766e', privacyPreview: false, + provider: 'both', metric: 'tokens', showAmounts: false }; expect(state.messages).toEqual([ - { command: 'previewCombinedHeatmap', title: 'My local activity', range: '30d', intensityMode: 'logarithmic', palette: 'custom', customAccent: '#0f766e' }, - { command: 'exportCombinedHeatmap', title: 'My local activity', range: '30d', intensityMode: 'logarithmic', palette: 'custom', customAccent: '#0f766e' }, - { command: 'copyCombinedHeatmapMarkdown', title: 'My local activity', range: '30d', intensityMode: 'logarithmic', palette: 'custom', customAccent: '#0f766e' }, + { command: 'retireCombinedHeatmapPreview', requestId: accepted.request.requestId - 1, + documentId: accepted.request.documentId }, + { command: 'previewCombinedHeatmap', requestId: accepted.request.requestId, + documentId: accepted.request.documentId, ...draft }, + { command: 'exportCombinedHeatmap', previewId: accepted.previewId, + documentId: accepted.request.documentId, ...draft }, + { command: 'copyCombinedHeatmapMarkdown', previewId: accepted.previewId, + documentId: accepted.request.documentId, ...draft }, ]); expect(state.messages.some((message) => /publish/i.test(message.command))).toBe(false); @@ -384,6 +423,8 @@ test('combined share preferences stay local and every export action is explicit' intensityMode: localStorage.getItem('ccu.combinedHeatmap.intensityMode'), palette: localStorage.getItem('ccu.combinedHeatmap.palette'), customAccent: localStorage.getItem('ccu.combinedHeatmap.customAccent'), + remainingSharingKeys: Object.keys(localStorage).filter(key => + key.startsWith('ccu.combinedHeatmap.') || key.startsWith('ccu.sharing.') || key.startsWith('ccu.github.')), messages: window.__ccuPostedMessages, })); expect(reset).toMatchObject({ @@ -393,6 +434,7 @@ test('combined share preferences stay local and every export action is explicit' intensityMode: null, palette: null, customAccent: null, + remainingSharingKeys: [], }); expect(reset.messages.some((message) => message.command === 'resetCombinedHeatmapPreferences')).toBe(false); await expect(page.locator('#combinedHeatmapStatus')).toContainText('Sharing preferences and destination strings were reset.'); @@ -553,22 +595,10 @@ test('combined heatmap remains usable at a 360px webview width', async ({ page } test('maximum ASCII and CJK titles remain inside the rendered SVG viewBox', async ({ page }) => { await openCompare(page, { fixture: 'combined-heatmap', locale: 'en', width: 360, height: 900 }); for (const title of ['W'.repeat(80), '综合活动热力图'.repeat(12)]) { - const svg = renderCombinedHeatmapSvg({}, { - range: '30d', - endDateISO: '2026-07-22', - title, - }); - await page.evaluate((renderedSvg) => { - window.dispatchEvent(new MessageEvent('message', { - data: { - command: 'combinedHeatmapResult', - svg: renderedSvg, - markdown: '', - filename: 'contained.svg', - hasData: true, - }, - })); - }, svg); + await page.locator('#combinedHeatmapTitle').fill(title.slice(0, 80)); + await page.locator('#combinedHeatmapRange').selectOption('30d'); + await page.getByRole('button', { name: 'Update preview' }).click(); + await acknowledgeCombinedPreview(page); const geometry = await page.locator('#combinedHeatmapPreview svg').evaluate((element) => { const viewBox = element.viewBox.baseVal; const titleNode = element.querySelector('text'); diff --git a/tests/ui/dashboard-live-refresh.spec.mjs b/tests/ui/dashboard-live-refresh.spec.mjs index 194aad63..b82b6eee 100644 --- a/tests/ui/dashboard-live-refresh.spec.mjs +++ b/tests/ui/dashboard-live-refresh.spec.mjs @@ -1,7 +1,36 @@ import { test, expect, openClaude, openCodex } from './support/app.mjs'; +async function pauseRefreshClock(page) { + // Keep the 120ms quiet/500ms maximum contract independent of host load and + // browser RPC time. The fixture clock starts on July 20; July 21 is safely + // ahead of setup, and runFor below controls each simulated scroll burst. + await page.clock.pauseAt(new Date('2026-07-21T12:00:00.000Z')); +} + +test('achievement data updates stay local and preserve the selected gallery', async ({ page }) => { + await openCodex(page, { height: 420 }); + await page.locator('#tab-achievements').click(); + const cardCount = await page.locator('.achievement-card').count(); + expect(cardCount).toBeGreaterThan(0); + await page.evaluate(() => { + const panel = document.getElementById('provider-panel'); + window.__achievementDocument = document.documentElement; + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'codex', tab: 'achievements', revision: 1, + html: panel.innerHTML, claudeLast30HoursByDay: {}, + } })); + }); + await expect.poll(() => page.evaluate(() => + window.__ccuPostedMessages.filter((message) => message.command === 'dashboardDataPatchAck'), + )).toEqual([{ command: 'dashboardDataPatchAck', revision: 1, ok: true }]); + await expect(page.locator('#tab-achievements')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.achievement-card')).toHaveCount(cardCount); + expect(await page.evaluate(() => document.documentElement === window.__achievementDocument)).toBe(true); +}); + test('a live Codex refresh waits for an active scroll burst before replacing the panel', async ({ page }) => { await openCodex(page, { height: 420 }); + await pauseRefreshClock(page); const before = await page.evaluate(() => { const panel = document.getElementById('provider-panel'); const filler = document.createElement('div'); @@ -23,6 +52,7 @@ test('a live Codex refresh waits for an active scroll burst before replacing the expect(before.scrollY).toBeGreaterThan(0); expect(await page.evaluate(() => document.getElementById('provider-panel').firstElementChild === window.__ccuScrollPanel)).toBe(true); expect(await page.evaluate(() => window.__ccuPostedMessages.some((message) => message.command === 'dashboardDataPatchAck'))).toBe(false); + await page.clock.runFor(200); await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.some((message) => message.command === 'dashboardDataPatchAck' && message.revision === 1 && message.ok), )).toBe(true); @@ -31,6 +61,7 @@ test('a live Codex refresh waits for an active scroll burst before replacing the test('continued Codex scrolling coalesces updates but cannot starve the latest patch', async ({ page }) => { await openCodex(page, { height: 420 }); + await pauseRefreshClock(page); await page.evaluate(() => { const panel = document.getElementById('provider-panel'); const filler = document.createElement('div'); @@ -47,6 +78,7 @@ test('continued Codex scrolling coalesces updates but cannot starve the latest p } }); try { + await page.clock.runFor(600); await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.filter((message) => message.command === 'dashboardDataPatchAck'), )).toEqual([{ command: 'dashboardDataPatchAck', revision: 4, ok: true }]); @@ -57,7 +89,8 @@ test('continued Codex scrolling coalesces updates but cannot starve the latest p test('a 20-update scroll storm swaps the provider panel at most twice', async ({ page }) => { await openCodex(page, { height: 420 }); - const result = await page.evaluate(async () => { + await pauseRefreshClock(page); + const storm = page.evaluate(async () => { const panel = document.getElementById('provider-panel'); const filler = document.createElement('div'); filler.style.height = '1600px'; @@ -69,6 +102,7 @@ test('a 20-update scroll storm swaps the provider panel at most twice', async ({ }); observer.observe(panel, { childList: true }); scrollTo(0, 160); + window.__ccuScrollStormStarted = true; for (let revision = 1; revision <= 20; revision += 1) { window.dispatchEvent(new Event('scroll')); window.dispatchEvent(new MessageEvent('message', { data: { @@ -86,6 +120,9 @@ test('a 20-update scroll storm swaps the provider panel at most twice', async ({ .map((message) => message.revision), }; }); + await expect.poll(() => page.evaluate(() => window.__ccuScrollStormStarted)).toBe(true); + await page.clock.runFor(1200); + const result = await storm; expect(result.swaps).toBeGreaterThan(0); expect(result.swaps).toBeLessThanOrEqual(2); expect(result.acks.at(-1)).toBe(20); diff --git a/tests/ui/dashboard-recovery.spec.mjs b/tests/ui/dashboard-recovery.spec.mjs index 817fda2c..6c204a97 100644 --- a/tests/ui/dashboard-recovery.spec.mjs +++ b/tests/ui/dashboard-recovery.spec.mjs @@ -85,16 +85,35 @@ test('live patches preserve unfocused share drafts, pending previews and edit li await openClaude(page, { fixture: 'combined-heatmap', claudeOnly: true }); await page.locator('#tab-all').click(); await page.locator('#sharingTemplate').selectOption('claudeShareCard'); + const requestedTemplate = await page.evaluate(() => window.__ccuPostedMessages.findLast( + message => message.command === 'sharingTemplateChanged')); + expect(requestedTemplate).toEqual({ command: 'sharingTemplateChanged', template: 'claudeShareCard' }); + const previousDocumentId = await page.evaluate(() => window.__ccuPostedMessages.findLast( + message => message.command === 'localDataClientReady').documentId); + // fakeHost supplies the actual production render requested above. Selecting + // a lazy presentation alone cannot fabricate its host-owned controls. + await openClaude(page, { fixture: 'combined-heatmap', claudeOnly: true, commandTemplate: requestedTemplate.template }); + const documentId = await page.evaluate(() => window.__ccuPostedMessages.findLast( + message => message.command === 'localDataClientReady').documentId); + expect(documentId).toMatch(/^[0-9a-f]{48}$/); + expect(documentId).not.toBe(previousDocumentId); + await expect(page.locator('#sharingTemplate')).toHaveValue(requestedTemplate.template); + await expect(page.locator('#scRange')).toBeVisible(); const response = await page.context().request.get(page.url()); + expect(response.ok()).toBe(true); const source = await response.text(); async function patch(revision) { - await page.evaluate(({ source, revision }) => { + await page.evaluate(({ source, revision, documentId }) => { + const current = window.__ccuPostedMessages.findLast(message => message.command === 'localDataClientReady'); + if (current.documentId !== documentId) throw new Error('fakeHost patch belongs to a retired document'); const panel = new DOMParser().parseFromString(source, 'text/html').getElementById('provider-panel'); + if (panel.querySelector('#sharingTemplate').value !== 'claudeShareCard' || !panel.querySelector('#scRange')) + throw new Error('fakeHost response does not match the selected production Share Card config'); window.dispatchEvent(new MessageEvent('message', { data: { - command: 'dashboardDataPatch', provider: 'claude', tab: 'all', revision, + command: 'dashboardDataPatch', provider: 'claude', tab: 'all', revision, documentId, html: panel.innerHTML, claudeLast30HoursByDay: {}, } })); - }, { source, revision }); + }, { source, revision, documentId }); await expect.poll(() => page.evaluate((revision) => window.__ccuPostedMessages.some((m) => m.command === 'dashboardDataPatchAck' && m.revision === revision && m.ok), revision)).toBe(true); } diff --git a/tests/ui/support/achievement-catalog.cjs b/tests/ui/support/achievement-catalog.cjs new file mode 100644 index 00000000..9271c601 --- /dev/null +++ b/tests/ui/support/achievement-catalog.cjs @@ -0,0 +1,163 @@ +// Review-only, network-free catalog; never evaluates grants or reads source logs. +const { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_SERIES } = require('../../../out/achievements'); +const { achievementCopyFor } = require('../../../out/i18n'); +const { ACHIEVEMENT_IMAGE_FILES, achievementImageDataUrl } = require('../../../out/achievementIcons'); +const { ACHIEVEMENT_GALLERY, achievementGalleryKey } = require('../../../out/achievementGallery'); +const { achievementConditionFor } = require('../../../out/achievementConditions'); +const { API_ACHIEVEMENT_DEFINITIONS, isAchievementMoneyId } = require('../../../out/achievementMoneyCatalog'); +const { achievementMoneyCopyFor } = require('../../../out/achievementMoneyCopy'); + +// Units and evidence notes, not independently maintained award thresholds. +const evidence = { + 'sonnet-main': ['已处理 Token', 'processed tokens', '仅认可可确认的 Sonnet 模型家族。', 'Verified Sonnet family only.'], + 'opus-cannon': ['已处理 Token', 'processed tokens', '仅认可可确认的 Opus 模型家族。', 'Verified Opus family only.'], + 'haiku-worker': ['已处理 Token', 'processed tokens', '仅认可可确认的 Haiku 模型家族。', 'Verified Haiku family only.'], + 'cache-wizard': ['缓存读取 Token', 'cache-read tokens', '衡量缓存读取总量,不是命中率。', 'Cache-read volume, not cache hit rate.'], + 'claude-output': ['输出 Token', 'output tokens', '只计 Claude 输出,不计输入与缓存。', 'Claude output only; input and cache excluded.'], + 'claude-output-sessions': ['有输出的会话', 'sessions with output', '完整会话汇总去重,不受列表 1000 条上限影响。', 'Deduplicated full session evidence; not the 1,000-row display cap.'], + 'claude-cache-creation': ['缓存写入 Token', 'cache-creation tokens', '衡量缓存写入总量,不评价缓存效率。', 'Cache-creation volume, not an efficiency score.'], + 'codex-output': ['输出 Token', 'output tokens', '只计已核验的 Codex 输出;推理是输出的子集,不重复相加。', 'Verified Codex output only; reasoning is an output subset and is not added again.'], + 'codex-cache-read': ['缓存输入 Token', 'cached input tokens', '只计已核验的 Codex 缓存输入;它是输入的子集,不是 Claude 缓存写入或命中率。', 'Verified Codex cached input only; an input subset, not Claude cache writes or cache hit rate.'], + 'fable-volume': ['已处理 Token', 'processed tokens', '仅使用明确支持的 Fable/Mythos 5、5.1 模型 ID。', 'Only explicitly supported Fable/Mythos 5 and 5.1 IDs.'], + 'fable-days': ['Fable 达标日', 'qualified Fable days', '当前配置时区内,每个已结束自然日 Fable 家族均须有输出且已处理 Token ≥1M;须有完整、已验证的日期数值证据,不要求连续。', 'In the current configured timezone, Fable needs output and 1M+ processed tokens on each counted closed civil day; complete validated numeric day evidence required, with no consecutive-day requirement.'], + 'fable-sessions': ['有 Fable 输出的会话', 'sessions with Fable output', '同一会话只计一次;仅输入不算。', 'Each session once; input-only activity does not count.'], + 'fable-relay': ['同一会话', 'one session', 'Fable 与另一 Claude 家族在同一会话中各有输出。', 'Fable and another Claude family both output in the same session.'], + 'astra-regular': ['已处理 Token', 'processed tokens', '完整当前索引中已独立核验的 Astra 谱系用量;冲突分支及其子会话不计入。', 'Astra usage from independently verified lineages in the complete current index; conflicting branches and descendants excluded.'], + 'sol-regular': ['已处理 Token', 'processed tokens', '完整当前索引中已独立核验的 Sol 谱系用量;冲突分支及其子会话不计入。', 'Sol usage from independently verified lineages in the complete current index; conflicting branches and descendants excluded.'], + 'luna-regular': ['已处理 Token', 'processed tokens', '完整当前索引中已独立核验的 Luna 谱系用量;冲突分支及其子会话不计入。', 'Luna usage from independently verified lineages in the complete current index; conflicting branches and descendants excluded.'], + 'sonnet-days': ['Sonnet 达标日', 'qualified Sonnet days', '当前配置时区内,每个已结束自然日 Sonnet 家族有输出且已处理 Token ≥1M;不要求连续。', 'In the current configured timezone, Sonnet has output and 1M+ processed tokens on each counted closed civil day; days need not be consecutive.'], + 'opus-days': ['Opus 达标日', 'qualified Opus days', '当前配置时区内,每个已结束自然日 Opus 家族有输出且已处理 Token ≥1M;不要求连续。', 'In the current configured timezone, Opus has output and 1M+ processed tokens on each counted closed civil day; days need not be consecutive.'], + 'haiku-days': ['Haiku 达标日', 'qualified Haiku days', '当前配置时区内,每个已结束自然日 Haiku 家族有输出且已处理 Token ≥1M;不要求连续。', 'In the current configured timezone, Haiku has output and 1M+ processed tokens on each counted closed civil day; days need not be consecutive.'], + 'astra-days': ['Astra 达标日', 'qualified Astra days', '当前配置时区内,每个已结束自然日 Astra 家族有输出且已处理 Token ≥1M;须有完整索引、身份及全历史日期核验。', 'In the current configured timezone, Astra has output and 1M+ processed tokens on each counted closed civil day; complete index, identity and all-time day verification required.'], + 'sol-days': ['Sol 达标日', 'qualified Sol days', '当前配置时区内,每个已结束自然日 Sol 家族有输出且已处理 Token ≥1M;须有完整索引、身份及全历史日期核验。', 'In the current configured timezone, Sol has output and 1M+ processed tokens on each counted closed civil day; complete index, identity and all-time day verification required.'], + 'luna-days': ['Luna 达标日', 'qualified Luna days', '当前配置时区内,每个已结束自然日 Luna 家族有输出且已处理 Token ≥1M;须有完整索引、身份及全历史日期核验。', 'In the current configured timezone, Luna has output and 1M+ processed tokens on each counted closed civil day; complete index, identity and all-time day verification required.'], + 'dual-provider-days': ['双供应商达标日', 'qualified dual-provider days', '当前配置时区内,同一已结束自然日 Claude 与 Codex 各有输出且各已处理 Token ≥1M;不要求连续,Codex 日期证据须完整核验。', 'In the current configured timezone, Claude and Codex each have output and 1M+ processed tokens on the same closed civil day; days need not be consecutive and Codex day evidence must be fully verified.'], + 'dual-provider-day': ['同一自然日', 'one civil day', '当前配置时区内,Claude 与 Codex 在同一已结束自然日各有输出且各已处理 Token ≥1M;Codex 日期证据须完整核验。', 'In the current configured timezone, Claude and Codex each have output and 1M+ processed tokens on the same closed civil day; Codex day evidence must be fully verified.'], + 'three-family-day': ['同日达标的三个家族', 'three qualified families in one day', '当前配置时区内,同一已结束自然日从 Sonnet/Opus/Fable/Haiku/Astra/Sol/Luna 中至少三个家族各有输出且各已处理 Token ≥1M;日期数值证据须完整核验。', 'In the current configured timezone, at least three of Sonnet/Opus/Fable/Haiku/Astra/Sol/Luna each need output and 1M+ processed tokens on the same closed civil day; complete validated numeric day evidence required.'], + 'agent-team': ['达标任务数', 'qualified tasks', '', ''], + 'agent-conductor': ['达标编排任务数', 'qualified orchestration tasks', '', ''], + 'root-portfolio': ['可确认的主会话', 'verified main sessions', '须有真实模型用量;孤儿与冲突副本不算。', 'Actual model usage required; orphans and conflicting copies excluded.'], + 'agent-participation': ['达标的不同子智能体', 'distinct qualified subagents', '', ''], + 'model-relay-roots': ['达标多模型任务数', 'qualified multi-model tasks', '', ''], + 'same-root-models': ['达标多模型任务数', 'qualified multi-model tasks', '', ''], + 'effort-breadth': ['达标的 effort 档位', 'qualified effort tiers', '', ''], + 'weekly-volume': ['单个自然周的已处理 Token', 'processed tokens in one calendar week', '当前配置时区、周一开始;仅用完整、已验证的日期数值证据,不代表账户额度或个人工作量。', 'Current configured timezone, Monday start; complete validated numeric day evidence only, not account quota or personal work.'], + 'active-weeks': ['至少四个达标日的完整自然周', 'completed calendar weeks with 4+ qualified days', '当前配置时区、周一开始;每周至少四个已结束自然日 Claude 或 Codex 有输出且已处理 Token ≥1M,跨供应商按日期去重;完整核验日期数值证据,不要求周连续。', 'Current configured timezone, Monday start; at least four closed days per completed week on which Claude or Codex has output and 1M+ processed tokens, deduplicated across providers. Complete validated numeric day evidence required; weeks need not be consecutive.'], + 'weekly-average': ['四个已结束自然周的周均值峰值', 'best mean processed tokens/week over four completed weeks', '当前配置时区、周一开始;取连续四个完整已结束周的总量除以四后的峰值,排除首个不完整周及本周。仅经完整数值证据核验的零用量周留在分母;日历覆盖不完整不能证明实际用量为零。成功持久保存后,本地仅留存一个已核验的数值最大值;复用须计量版本、时区、供应商存在状态一致,且所有预期供应商的当前规范化证据完整有效。清空成就删除该值。', 'Current configured timezone, Monday start; best total divided by four across four complete consecutive closed weeks, excluding the first partial week and current week. Only verified zero-usage weeks stay in the denominator; incomplete calendar coverage cannot prove zero actual usage. After a successful durable save, one verified numeric maximum is retained locally; reuse requires matching measurement versions, timezone and provider presence, plus complete valid current normalized proofs from every expected provider. Clearing achievements removes it.'], + 'output-hours': ['连续 UTC 小时', 'consecutive UTC hours', '每小时都有输出,可跨日/模型/供应商;不是连续工作。24/48 小时静默。', 'Output each hour across dates/models/providers; not human work. 24/48 hours stay quiet.'], + 'active-day-streak': ['连续活跃日', 'consecutive active days', '所选时区中每日均有可确认本地用量。', 'Verified local usage every civil day in the configured timezone.'], + 'active-days': ['累计活跃日', 'distinct active days', '跨供应商按日期去重,不要求连续。', 'Dates deduplicated across providers; no consecutive-day requirement.'], + 'model-rainbow': ['达标模型家族', 'qualified model families', '', ''], + 'night-output-peak': ['30 日内的夜间画像', 'night-time pattern over 30 days', '≥14 输出日、≥5 夜间输出日;22–06 点有输出小时数比另两时段均高 ≥20%。静默。', '14+ output days, 5+ night days; 22–06 output-positive hours exceed both other bands by 20%+. Quiet.'], + 'streak-machine': ['连续活跃日', 'consecutive active days', '保留原十四日单次徽章,放入连续活跃主题详情,不替代三级连续日系列。', 'Legacy fourteen-day single badge inside the active streak details, not a replacement for its three tiers.'], + 'settings-explorer': ['主动修改的不同安全设置项', 'distinct deliberately changed safe settings', '只计成功保存;不计默认、迁移、重置或重复操作。', 'Successful deliberate changes only; defaults, migrations, resets and repeats excluded.'], + 'safe-feature-explorer': ['安全体验步骤', 'safe feature steps', '查看成就页 → 主动预览分享卡 → 成功本地导出。', 'View gallery, deliberately preview sharing, successfully export locally.'], + 'local-share-export': ['成功本地 SVG 导出', 'successful local SVG export', '预览不算;无需 GitHub 或公开发布。', 'Preview is not export; no GitHub access or public publication required.'], + 'local-advice-feedback': ['已保存的本地反馈', 'saved local feedback', '查看有证据的本地建议并反馈;无需密钥或模型请求。', 'View evidence-backed local advice and save feedback; no API key or model request.'], +}; +for (const id of ['cumulative-volume']) { + evidence[id] = ['累计已处理 Token', 'cumulative processed tokens', 'Claude 与 Codex 本地用量;歧义 Codex 仅计保守下界。', 'Combined local usage; ambiguous Codex copies contribute only a conservative lower bound.']; +} + +function achievementCatalog(locale = 'zh-CN') { + if (!['zh-CN', 'en'].includes(locale)) throw new Error('Review catalog supports zh-CN and en'); + const zh = locale === 'zh-CN'; + const copy = achievementCopyFor(locale); + const moneyCopy = achievementMoneyCopyFor(locale); + const byId = new Map(ACHIEVEMENT_DEFINITIONS.map((row) => [row.id, row])); + const seriesById = new Map(Object.entries(ACHIEVEMENT_SERIES) + .flatMap(([key, ids]) => ids.map((id) => [id, key]))); + const seen = new Set(); + const cards = []; + for (const definition of ACHIEVEMENT_DEFINITIONS) { + const key = seriesById.get(definition.id) ?? definition.id; + if (seen.has(key)) continue; + seen.add(key); + const ids = ACHIEVEMENT_SERIES[key] ?? [definition.id]; + const monetary = API_ACHIEVEMENT_DEFINITIONS.find((row) => row.id === ids[0]); + const scope = monetary && (monetary.scope === 'cumulative' ? 'total' : + monetary.scope === 'completedMondayWeekPeak' ? 'week' : monetary.group === 'claude' ? 'session' : 'task'); + const detail = monetary ? [moneyCopy.unit, moneyCopy.unit, + moneyCopy[scope] + ' ' + moneyCopy.note + ' 冻结参考价目表,不采用实时账单价格;未知价格用量不计入金额,可靠的已定价部分以 ≥ 下界表示。内部目标为整数 microUSD,显示美元向下取整。', + moneyCopy[scope] + ' ' + moneyCopy.note + ' Frozen reference pricebook, not live billing prices; unknown-price usage is excluded from money and a reliable priced partial is shown as a ≥ lower bound. Internal targets are integer microUSD; displayed USD is floored.'] : evidence[key]; + if (!detail) throw new Error(`Missing reviewed evidence note: ${key}`); + let title = copy.titles[ids[0]]; + if (ids.length > 1) { + const suffix = [` · ${copy.levels[0]}`, `·${copy.levels[0]}`, ' I'] + .find((ending) => title.endsWith(ending)); + if (suffix) title = title.slice(0, -suffix.length); + } + if (key === 'active-day-streak') title = zh ? '连续活跃日' : 'Active-day streak'; + if (key === 'cumulative-volume') title = zh ? '累计 Token' : 'Cumulative tokens'; + const levels = ids.map((id, index) => ({ + id, target: byId.get(id).target, title: copy.titles[id], + condition: achievementConditionFor(locale, id), + tier: ids.length === 1 ? 'single' : ['ordinary', 'silver', 'gold', 'platinum', 'legendary'][index], + label: ids.length === 1 ? copy.milestone : copy.levels[index], + artwork: ACHIEVEMENT_IMAGE_FILES[id], image: achievementImageDataUrl(id), + })); + // Qualification text is production-owned; the formal target supplies the + // repetition/breadth requirement rather than a second threshold table. + const qualifications = levels.filter((level) => level.condition).map((level) => + `${level.label}: ≥${compact(level.target)} ${detail[zh ? 0 : 1]}; ${level.condition}`).join(' '); + cards.push({ + key, displayKey: achievementGalleryKey(key), group: definition.group, title, + metric: detail[zh ? 0 : 1], note: qualifications || detail[zh ? 2 : 3], levels, + }); + } + return cards; +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[char])); +} +function compact(value) { + return require('../../../out/achievementFormat').formatAchievementCompact(value); +} +function displayTarget(level, locale) { + return isAchievementMoneyId(level.id) + ? '$' + require('../../../out/achievementFormat').formatAchievementInteger(Math.floor(level.target / 1_000_000), locale) + : compact(level.target); +} + +function renderCatalogGroup(catalog, group, locale = 'zh-CN') { + const zh = locale === 'zh-CN'; + const cards = catalog.filter((card) => card.group === group); + const copy = achievementCopyFor(locale); + const ruleCount = cards.reduce((sum, card) => sum + card.levels.length, 0); + return `${escapeHtml(copy.groups[group])}

    ${escapeHtml(copy.groups[group])}

    ${zh ? '图鉴审阅,非个人解锁记录' : 'Catalog review, not personal award history'}

    ${cards.length} ${zh ? '张卡片' : 'cards'} / ${ruleCount} ${zh ? '个可解锁等级' : 'unlockable awards'}

    + ${cards.map((card) => `
    ${escapeHtml(card.title)}

    ${escapeHtml(card.title)}

    ${escapeHtml(card.metric)}

    ${card.levels.map((level) => `${level.label}${displayTarget(level, locale)}`).join('')}

    ${escapeHtml(card.note)}

    ${new Set(card.levels.map((level) => level.artwork)).size > 1 ? `
    ${card.levels.map((level) => `${escapeHtml(level.title)}${level.label}`).join('')}
    ` : ''}
    `).join('')} +
    ${zh ? '使用候选内图标,重复系列图案已替换。M=百万,B=十亿,T=万亿。记录本地使用经历,不评价能力、生产力或个人工作时间。' : 'Bundled candidate artwork; duplicate series art replaced. M = million; B = billion; T = trillion. Local usage history, not skill, productivity or human working time.'}
    `; +} + +function renderCatalogMarkdown(catalog, locale = 'zh-CN') { + const zh = locale === 'zh-CN'; + const copy = achievementCopyFor(locale); + const threeTierCount = catalog.filter(card => card.levels.length === 3).length; + const fourTierCount = catalog.filter(card => card.levels.length === 4).length; + const fiveTierCount = catalog.filter(card => card.levels.length === 5).length; + const singleCount = catalog.filter(card => card.levels.length === 1).length; + const lines = [`# ${zh ? 'v2.4.2 成就与图标审阅清单' : 'v2.4.2 achievement and artwork review'}`, '', + zh ? `${threeTierCount} 个三级系列 + ${fourTierCount} 个四级系列 + ${fiveTierCount} 个五级系列 + ${singleCount} 个单次成就,共 ${catalog.length} 套图案、${catalog.reduce((sum, card) => sum + card.levels.length, 0)} 个解锁条件;默认界面归为 ${ACHIEVEMENT_GALLERY.length} 张主题卡。十四日与双供应商首日保留原图,进入对应主题详情。图鉴不是个人获奖记录。门槛与图片直接来自编译后的候选代码;每个系列图案独立,各等级共用同一徽章。` : + `${threeTierCount} three-tier series + ${fourTierCount} four-tier series + ${fiveTierCount} five-tier series + ${singleCount} single awards: ${catalog.length} artwork groups and ${catalog.reduce((sum, card) => sum + card.levels.length, 0)} award conditions in ${ACHIEVEMENT_GALLERY.length} default theme cards. Fourteen-day and first shared-day milestones retain their art inside theme details. This catalog is not personal award history. Targets and artwork come directly from the compiled candidate; each series has distinct artwork shared by its tiers.`, '']; + for (const group of ['claude', 'codex', 'combined', 'plugin']) { + lines.push(`## ${copy.groups[group]}`, '', zh ? '| 成就 | 等级门槛(低→高) | 计量与条件 | 原图 |' : + '| Achievement | Level targets (low to high) | Evidence | Artwork |', '|:--|:--|:--|:--|'); + for (const card of catalog.filter((entry) => entry.group === group)) { + lines.push(`| ${card.title} | ${card.levels.map((level) => displayTarget(level, locale)).join(' / ')} | ${card.metric};${card.note} | ${[...new Set(card.levels.map((level) => level.artwork))].map((name) => `\`${name}\``).join(', ')} |`); + } + lines.push(''); + } + return lines.join('\n'); +} + +module.exports = { achievementCatalog, renderCatalogGroup, renderCatalogMarkdown }; diff --git a/tests/ui/support/app.mjs b/tests/ui/support/app.mjs index 5bba5fb8..57bf2202 100644 --- a/tests/ui/support/app.mjs +++ b/tests/ui/support/app.mjs @@ -31,11 +31,11 @@ export { expect }; export async function openCodex( page, - { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, projectMatrix = true, width = 1280, height = 900 } = {}, + { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, projectMatrix = true, codexOnly = false, width = 1280, height = 900 } = {}, ) { await page.setViewportSize({ width, height }); await page.goto( - `${uiBaseUrl}/?provider=codex&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}&projectMatrix=${projectMatrix}`, + `${uiBaseUrl}/?provider=codex&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}&projectMatrix=${projectMatrix}&codexOnly=${codexOnly}`, { waitUntil: 'load' }, ); await page.locator('.tab-content.active').waitFor(); diff --git a/tests/ui/support/capture-achievement-catalog.mjs b/tests/ui/support/capture-achievement-catalog.mjs new file mode 100644 index 00000000..aa6b6508 --- /dev/null +++ b/tests/ui/support/capture-achievement-catalog.mjs @@ -0,0 +1,40 @@ +// Review composition from bundled artwork, not an installed-VSIX screenshot. +// npm run compile && node tests/ui/support/capture-achievement-catalog.mjs +import { createRequire } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { chromium, expect } from '@playwright/test'; + +const require = createRequire(import.meta.url); +const { achievementCatalog, renderCatalogGroup, renderCatalogMarkdown } = require('./achievement-catalog.cjs'); +const output = resolve('test-results/v242-mobile-review'); +await mkdir(output, { recursive: true }); +const browser = await chromium.launch(); +try { + const context = await browser.newContext({ + viewport: { width: 440, height: 800 }, deviceScaleFactor: 2, reducedMotion: 'reduce', + }); + const page = await context.newPage(); + await page.route('**/*', (route) => route.abort()); + for (const locale of ['zh-CN', 'en']) { + const catalog = achievementCatalog(locale); + await writeFile(resolve(output, `catalog.${locale}.md`), renderCatalogMarkdown(catalog, locale)); + for (const group of ['claude', 'codex', 'combined', 'plugin']) { + const html = renderCatalogGroup(catalog, group, locale); + await writeFile(resolve(output, `${group}.${locale}.html`), html); + if (locale !== 'zh-CN') continue; + await page.setContent(html); + await page.evaluate(() => Promise.all([...document.images].map((image) => image.decode()))); + await expect(page.locator('article')).toHaveCount(catalog.filter((card) => card.group === group).length); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(440); + for (const heading of await page.locator('h2').all()) { + expect(await heading.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + } + await page.locator('main').screenshot({ path: resolve(output, `${group}.zh-CN.png`), animations: 'disabled' }); + } + } + await context.close(); +} finally { + await browser.close(); +} +console.log(`Saved four mobile catalog sheets and bilingual reference: ${output}`); diff --git a/tests/ui/support/render-harness.cjs b/tests/ui/support/render-harness.cjs index 2f776f17..9b8d056e 100644 --- a/tests/ui/support/render-harness.cjs +++ b/tests/ui/support/render-harness.cjs @@ -1,14 +1,23 @@ 'use strict'; const Module = require('node:module'); +const path = require('node:path'); const { freezeClock } = require('./frozen-clock.cjs'); const originalLoad = Module._load; let latestHarnessPanel; -function createHarnessPanel() { +function createHarnessPanel(nativeResources = false) { const webview = { html: '', + ...(nativeResources ? { + cspSource: "'self'", + asWebviewUri: (uri) => { + const route = achievementResourceRoutes.get(uri.fsPath); + if (!route) throw new Error('Harness resource is not a packaged achievement icon'); + return { toString: () => route }; + }, + } : {}), onDidReceiveMessage: () => ({ dispose: () => undefined }), postMessage: async () => true, }; @@ -48,6 +57,10 @@ Module._load = function load(request, parent, isMain) { const { UsageWebviewProvider } = require('../../../out/webview.js'); const { I18n } = require('../../../out/i18n.js'); const { SETTINGS } = require('../../../out/settings.js'); +const { ClaudeQuotaPaceSource } = require('../../../out/quotaPaceSource.js'); +const { QUOTA_PACE_WEEK_MS } = require('../../../out/quotaPaceAdapters.js'); +const { codexModelSignals } = require('../../../out/achievements.js'); +const { codexAchievementEffortStatistics } = require('../../../out/achievementEffortStatistics.js'); const { buildCodexUsageView } = require('../../../out/providers/codex/codexUsage.js'); const { buildScopedCodexInsights } = require('../../../out/providers/codex/codexInsights.js'); const { buildProjectUsageMatrixSnapshot } = require('../../../out/projectUsageMatrix.js'); @@ -66,6 +79,16 @@ const { Module._load = originalLoad; +// Synthetic equivalent of VS Code resource authorization, not native URI +// acceptance. No request-controlled path ever reaches filesystem I/O. +const { ACHIEVEMENT_IMAGE_FILES } = require('../../../out/achievementIcons.js'); +const extensionPath = path.resolve(__dirname, '../../..'); +const achievementResourceRoutes = new Map([...new Set(Object.values(ACHIEVEMENT_IMAGE_FILES))] + .map(filename => [path.join(extensionPath, 'assets', 'achievements', filename), + '/__achievement_icons__/' + filename])); +exports.achievementResourceFiles = new Map([...achievementResourceRoutes] + .map(([filename, route]) => [route, filename])); + // Resolve Light+/Dark+ overrides against VS Code's registered color defaults. // Font variables use VS Code's macOS defaults. input.border is registered with // a null theme value, so `initial` preserves the real webview fallback path. @@ -114,6 +137,11 @@ const THEMES = { '--vscode-charts-green:#388A34;' + '--vscode-charts-purple:#652D90;' + '--vscode-charts-yellow:#BF8803;' + + '--vscode-list-inactiveSelectionBackground:#E4E6F1;' + + '--vscode-sideBar-background:#F3F3F3;' + + '--vscode-editor-foreground:#000000;' + + '--vscode-editorWidget-foreground:#616161;' + + '--vscode-widget-shadow:rgba(0,0,0,0.16);' + '}*,*::before,*::after{animation:none!important;transition:none!important}', dark: ':root{' + '--vscode-font-family:-apple-system,BlinkMacSystemFont,sans-serif;' + @@ -159,9 +187,43 @@ const THEMES = { '--vscode-charts-green:#89D185;' + '--vscode-charts-purple:#B180D7;' + '--vscode-charts-yellow:#CCA700;' + + '--vscode-list-inactiveSelectionBackground:#37373D;' + + '--vscode-sideBar-background:#252526;' + + '--vscode-editor-foreground:#D4D4D4;' + + '--vscode-editorWidget-foreground:#CCCCCC;' + + '--vscode-widget-shadow:rgba(0,0,0,0.36);' + '}*,*::before,*::after{animation:none!important;transition:none!important}', }; +// VS Code built-in Dark High Contrast (hc_black.json) and registered hcDark +// defaults, resolved from the installed workbench color registry. Null color +// registrations use `initial`, matching the existing Light+/Dark+ harness. +THEMES.highContrast = ':root{' + Object.entries({ + 'font-family': '-apple-system,BlinkMacSystemFont,sans-serif', + 'font-size': '13px', + 'editor-font-family': 'Menlo,Monaco,"Courier New",monospace', + 'editor-background': '#000000', 'foreground': '#FFFFFF', + 'descriptionForeground': 'rgba(255,255,255,0.7)', 'errorForeground': '#F48771', + 'textLink-foreground': '#21A6FF', 'textCodeBlock-background': '#000000', + 'textBlockQuote-background': 'initial', 'textBlockQuote-border': '#FFFFFF', + 'panel-border': '#6FC3DF', 'toolbar-hoverBackground': 'initial', + 'input-background': '#000000', 'input-foreground': '#FFFFFF', 'input-border': '#6FC3DF', + 'inputValidation-warningBackground': '#000000', 'inputValidation-warningBorder': '#6FC3DF', + 'dropdown-background': '#000000', 'dropdown-foreground': '#FFFFFF', 'dropdown-border': '#6FC3DF', + 'button-background': '#000000', 'button-foreground': '#FFFFFF', 'button-hoverBackground': '#000000', + 'button-secondaryBackground': 'initial', 'button-secondaryForeground': '#FFFFFF', + 'button-secondaryHoverBackground': 'initial', 'badge-background': '#000000', 'badge-foreground': '#FFFFFF', + 'focusBorder': '#F38518', 'list-hoverBackground': 'rgba(255,255,255,0.1)', + 'progressBar-background': '#6FC3DF', 'editorWidget-background': '#0C141F', + 'testing-iconPassed': '#73c991', 'symbolIcon-functionForeground': '#B180D7', + 'charts-foreground': '#FFFFFF', 'charts-lines': 'rgba(255,255,255,0.5)', + 'charts-blue': '#59A4F9', 'charts-orange': 'initial', 'charts-red': '#F48771', + 'charts-green': '#89D185', 'charts-purple': '#B180D7', 'charts-yellow': '#FFD370', + 'list-inactiveSelectionBackground': 'initial', 'sideBar-background': '#000000', + 'editor-foreground': '#FFFFFF', 'editorWidget-foreground': '#FFFFFF', 'widget-shadow': 'initial', +}).map(([name, value]) => `--vscode-${name}:${value};`).join('') + + '}*,*::before,*::after{animation:none!important;transition:none!important}'; + function settingsStore({ autoRefresh = false, weeklyValue = true, @@ -170,6 +232,9 @@ function settingsStore({ adviceOptimizer = false, displayCurrency = 'USD', projectMatrix = true, + achievementsEnabled = true, + achievementNotices = true, + quotaPace = false, } = {}) { const values = new Map(SETTINGS.map((definition) => [definition.key, definition.default])); values.set('codex.optimization.enabled', true); @@ -180,6 +245,9 @@ function settingsStore({ values.set('advice.optimizer.enabled', adviceOptimizer); values.set('displayCurrency', displayCurrency); values.set('showProjectUsageMatrix', projectMatrix); + values.set('enableAchievements', achievementsEnabled); + values.set('showAchievementNotices', achievementNotices); + if (quotaPace) values.set('showWeeklyQuotaPace', true); return { get: (key) => values.get(key), snapshot: () => SETTINGS.map((definition) => ({ @@ -190,6 +258,27 @@ function settingsStore({ }; } +// Explicit synthetic account evidence and raw successful response. This is a +// pure source projection: no credentials, cache, logs or Extension Host I/O. +function quotaPaceFixture(state = 'fresh') { + const resetAt = CODEX_WEBVIEW_NOW + 3 * QUOTA_PACE_WEEK_MS / 4; + const usage = { limits: [ + { kind: 'session', percent: 73, resets_at: new Date(CODEX_WEBVIEW_NOW + 3_600_000).toISOString() }, + { kind: 'weekly_all', percent: 29, resets_at: new Date(resetAt).toISOString() }, + { kind: 'weekly_scoped', percent: 40, resets_at: new Date(resetAt).toISOString(), + scope: { model: { id: 'synthetic-model', display_name: 'Synthetic scoped model' } } }, + ] }; + const snapshot = new ClaudeQuotaPaceSource().project({ + enabled: true, usage, observedAt: CODEX_WEBVIEW_NOW, + verifiedAccountFingerprint: 'acct_0123456789abcdef0123456789abcdef', + }); + // Stale/reset fixtures advance the renderer clock below, retaining this + // original observation and its pure source DTO. A repaint is not a fetch. + if (state === 'null') return { usage, snapshot: null }; + if (state === 'raw-mismatch') usage.limits[1].percent = 30; + return { usage, snapshot }; +} + function claudeProjectUsageMatrix() { const points = []; for (let project = 1; project <= 16; project += 1) { @@ -263,7 +352,7 @@ function addClaudeData(provider, { fixture = 'default', enableContent = false } const now = new Date(CODEX_WEBVIEW_NOW); const completedWeeklyFixture = fixture === 'weekly-claude-completed'; const completedResetAt = CODEX_WEBVIEW_NOW - 24 * 60 * 60_000; - const combinedHeatmapRecords = fixture === 'combined-heatmap' + const combinedHeatmapRecords = fixture === 'combined-heatmap' || fixture === 'achievement-share-resources' || fixture.startsWith('achievement-theme-') ? [ { timestamp: '2026-07-19T10:00:00.000Z', @@ -332,12 +421,12 @@ function addClaudeData(provider, { fixture = 'default', enableContent = false } claudeUsage(6), claudeUsage(18), [ - { date: '2026-07-19', data: claudeUsage(0.4) }, { date: '2026-07-20', data: claudeUsage(0.6) }, + { date: '2026-07-19', data: claudeUsage(0.4) }, ], [ - { date: '2026-06-01', data: claudeUsage(5) }, { date: '2026-07-01', data: claudeUsage(13) }, + { date: '2026-06-01', data: claudeUsage(5) }, ], [ { hour: '18:00', data: claudeUsage(0.35) }, @@ -368,9 +457,9 @@ function addClaudeData(provider, { fixture = 'default', enableContent = false } }, claudeProjectUsageMatrix(), [ - { date: '2026-06-01', data: claudeUsage(5) }, - { date: '2026-07-19', data: claudeUsage(0.4) }, { date: '2026-07-20', data: claudeUsage(0.6) }, + { date: '2026-07-19', data: claudeUsage(0.4) }, + { date: '2026-06-01', data: claudeUsage(5) }, ], ); if (completedWeeklyFixture) { @@ -457,6 +546,39 @@ function completedHistorySnapshot(snapshot) { return snapshot; } +function effortStatisticsFixture(empty = false) { + const t = (inputTotal, outputTotal) => ({ inputTotal, outputTotal, cachedInput: 0, cacheWriteInput: 0 }); + const file = (key, models, efforts, parentSessionKey, role = 'root') => { + const total = Object.values(models).reduce((sum, row) => t(sum.inputTotal + row.inputTotal, + sum.outputTotal + row.outputTotal), t(0, 0)); + return { total, byModel: models, byEffort: efforts, byDay: { '2026-07-20': { ...total } }, + session: { sessionKey: 'private-effort-' + key, role, ...(parentSessionKey ? { parentSessionKey } : {}), + startedAt: CODEX_WEBVIEW_NOW - 60_000, endedAt: CODEX_WEBVIEW_NOW }, + structural: { patchCalls: 0, toolCalls: 0, postPatchToolCalls: 0, compactCount: 0, taskCompleteCount: 0 } }; + }; + const known = t(100_000_000, 100_000), small = t(0, 1), unknown = t(10, 1); + const files = empty ? [] : [ + file('known-high', { 'gpt-6.1-sol': known }, { high: known }), + file('unknown-high', { 'private-custom-model': unknown }, { high: unknown }), + file('unknown-medium', { 'private-custom-model': unknown }, { medium: unknown }), + file('small-xhigh', { 'gpt-6-luna': small }, { xhigh: small }), + file('mixed', { 'gpt-6.1-sol': small, 'gpt-6-astra': small }, { max: small, ultra: small }), + // Own root × effort output is required; idle input and APR must not inflate rows. + file('idle-low', { 'gpt-6.1-sol': t(100_000_000, 0) }, { low: t(100_000_000, 0) }), + file('apr', { 'gpt-6-astra': known }, { high: known }, 'private-effort-known-high', 'approval-reviewer'), + ]; + const snapshot = codexWebviewFixture(); + snapshot.files = files; + snapshot.total = files.reduce((sum, row) => t(sum.inputTotal + row.total.inputTotal, + sum.outputTotal + row.total.outputTotal), t(0, 0)); + // Only current indexed-census authority is supplied. This is not historical + // calendar, request-money-origin, accepted-receipt or durable badge proof. + snapshot.currentManifestVerified = true; + Object.assign(snapshot.coverage, { complete: true, indexedFiles: files.length, totalFiles: files.length, + indexedBytes: files.length, totalBytes: files.length }); + return snapshot; +} + exports.renderHarness = async function renderHarness({ provider: selectedProvider = 'codex', locale = 'en', @@ -466,23 +588,36 @@ exports.renderHarness = async function renderHarness({ weeklyValue = true, shareStudio = true, projectMatrix = true, + achievementsEnabled = true, + achievementNotices = true, adviceFeedback = 'none', timeZone = 'Asia/Hong_Kong', codexMonth = '', claudeOnly = false, + codexOnly = false, commandTemplate = '', commandAfterReset = false, + quotaPace = false, + quotaPaceState = 'fresh', + nativeResources = false, } = {}) { + vscodeHost.window.createWebviewPanel = () => createHarnessPanel(nativeResources); I18n.setLanguage(locale); I18n.setTimezone(timeZone); I18n.setDecimalPlaces(2); const localCurrencyFixture = fixture === 'local-currency'; const displayCurrency = localCurrencyFixture ? 'EUR' : 'USD'; I18n.setCurrencyDisplay(displayCurrency); - vscodeHost.window.activeColorTheme.kind = theme === 'dark' ? 2 : 1; - const restoreClock = freezeClock(CODEX_WEBVIEW_NOW); + vscodeHost.window.activeColorTheme.kind = theme === 'highContrast' ? 3 : theme === 'dark' ? 2 : 1; + const paceRenderTime = fixture !== 'weekly-quota-pace' ? CODEX_WEBVIEW_NOW + : quotaPaceState === 'stale' ? CODEX_WEBVIEW_NOW + 31 * 60_000 + : quotaPaceState === 'reset' ? CODEX_WEBVIEW_NOW + 3 * QUOTA_PACE_WEEK_MS / 4 + : CODEX_WEBVIEW_NOW; + const restoreClock = freezeClock(paceRenderTime); try { - const baseSnapshot = fixture === 'rootless-cycle' + const effortFixture = fixture.startsWith('achievement-effort-statistics'); + const baseSnapshot = effortFixture ? effortStatisticsFixture(fixture.endsWith('-empty')) + : fixture === 'rootless-cycle' ? rootlessCrossProjectCycleFixture() : fixture === 'root-over-limit' ? rootedTaskBeyondRecentRowCapFixture() @@ -508,7 +643,16 @@ exports.renderHarness = async function renderHarness({ ? completedHistorySnapshot(baseSnapshot) : baseSnapshot; const view = buildCodexUsageView(snapshot, CODEX_WEBVIEW_NOW); - const provider = new UsageWebviewProvider({ globalState: memoryGlobalState() }); + if (fixture === 'weekly-quota-pace' && view.limit) { + // Give Codex the same valid 29% weekly numeric sample. Its local-log + // account attribution remains unknown and must never yield 116%. + view.limit = { ...view.limit, observedAt: CODEX_WEBVIEW_NOW, + windows: view.limit.windows.map(row => row.windowMinutes === 10_080 + ? { ...row, usedPercent: 29, resetsAt: CODEX_WEBVIEW_NOW + 3 * QUOTA_PACE_WEEK_MS / 4 } + : row) }; + } + const provider = new UsageWebviewProvider({ globalState: memoryGlobalState(), + ...(nativeResources ? { extensionPath } : {}) }); // The real extension loads globalState asynchronously before enabling any // consent or feedback control. Let that same path settle in the harness. await new Promise((resolve) => setImmediate(resolve)); @@ -528,8 +672,16 @@ exports.renderHarness = async function renderHarness({ adviceOptimizer: adviceOptimizerFixture, displayCurrency, projectMatrix, + achievementsEnabled, + achievementNotices, + quotaPace, }); - addClaudeData(provider, { fixture, enableContent: adviceContentFixture }); + if (!codexOnly && fixture !== 'achievement-calendar-subset') addClaudeData(provider, { fixture, enableContent: adviceContentFixture }); + if (fixture === 'achievement-token-stages') { + // Synthetic materialized Token history for presentation, not a calendar + // proof fixture or an injected grant. Run the actual rule evaluator. + provider.allTimeData = claudeUsage(1000); + } if (adviceEffectivenessFixture) { provider.updateAdviceEffectivenessData( buildAdviceEffectivenessFixture({ locale }).states, @@ -576,14 +728,235 @@ exports.renderHarness = async function renderHarness({ }], }; } + let effortSignals; + if (effortFixture) { + effortSignals = achievementsEnabled ? { ...codexModelSignals(snapshot.files), + effortStatistics: codexAchievementEffortStatistics(snapshot.files) } + : { modelCount: 0, efforts: [], familyRootCounts: { astra: 0, sol: 0, luna: 0 } }; + if (!achievementsEnabled) Object.defineProperty(effortSignals, 'effortStatistics', { + get: () => { throw new Error('Achievements OFF must not access effort statistics'); }, + }); + } provider.updateProviderData( claudeOnly ? null : view, buildScopedCodexInsights(view), claudeOnly ? { claude: true, codex: false, codexData: false } - : { claude: true, codex: true }, + : { claude: !codexOnly, codex: true, codexData: true }, + effortSignals, ); provider.currentProvider = selectedProvider; + if (fixture === 'achievement-calendar-subset') { + // Synthetic full-census conflict plus one sound dated root. Exercise + // production projection/evaluation, not injected ready flags or grants. + const {codexAchievementCalendarProjection}=require('../../../out/achievementCalendar.js'); + const {codexAchievementMoneyScopes}=require('../../../out/achievementMoneyScopes.js'); + const total={inputTotal:500_000_000,outputTotal:4}; + const part={inputTotal:125_000_000,outputTotal:1}; + const good={total,byModel:{'gpt-6.1-sol':total},byEffort:{},byDay:{},structural:{}, + session:{sessionKey:'synthetic-calendar-good',role:'root'}, + achievementPeriodWitness:{size:1,offset:1,expectedPrefixEvents:0,timeZone}, + period:{timeZone,indexedThrough:1,lineageVersion:1,lineagePrefixEvents:0, + days:Object.fromEntries(['13','14','15','16'].map(d=>[`2026-07-${d}`, + {total:part,byModel:{'gpt-6.1-sol':part},byEffort:{},structural:{}}]))}}; + const files=[good,{...good,session:{sessionKey:'synthetic-calendar-conflict',role:'root'}}, + {...good,session:{sessionKey:'synthetic-calendar-conflict',role:'root'}}]; + const dayEvidence=codexAchievementCalendarProjection(files,timeZone,true,false).evidence; + provider.codexView={...view,currentManifestVerified:true,coverage:{...view.coverage,complete:true, + identity:{...view.coverage.identity,complete:false}}, + allTime:{...view.allTime,total:{...view.allTime.total,processed:1_500_000_012}}, + periodCoverage:{...view.periodCoverage,timeZone,allTime:{...view.periodCoverage.allTime,complete:false}}}; + provider.providerAvailability={claude:false,codex:true,codexData:true}; + provider.codexAchievementSignals={...codexModelSignals(files),dayEvidence, + money:codexAchievementMoneyScopes({files,complete:true,calendarComplete:false,dayEvidence, + backend:'openai',asOfDay:'2026-07-20'})}; + } + if (fixture === 'achievement-effort-statistics-retired') provider.invalidateAchievementEvidence('codex'); + if (fixture === 'weekly-quota-pace') { + const { usage, snapshot: paceSnapshot } = quotaPaceFixture(quotaPaceState); + // Set catalog state before supplying the original raw response and the + // numeric DTO, exactly as the production opt-in renderer expects. + provider.updateQuotaPace(paceSnapshot, usage); + } + + if (fixture === 'achievement-share-resources') { + // More than nine independently earned choices, including unselected + // thumbnails. Synthetic retained awards; no filesystem record store. + const { parseAchievementRecordStore } = require('../../../out/achievementRecordStore.js'); + const { achievementRuleVersion } = require('../../../out/achievementRuleVersions.js'); + const ids = ['local-share-export', 'local-advice-feedback', 'settings-explorer-bronze', + 'hundred-million-club', 'sonnet-main', 'opus-cannon', 'haiku-worker', + 'streak-machine', 'dual-provider-day', 'active-day-streak', 'model-rainbow']; + provider.achievementRecordState = parseAchievementRecordStore({ schemaVersion: 2, catalogVersion: 2, + generation: 1, sequence: ids.length, lastNoticeAt: 0, pinRevision: 0, showcaseMode: 'automatic', + legacyBoundary: null, legacyRecords: [], pins: [], + records: ids.map((id, index) => ({ id, seq: index + 1, ruleVersion: achievementRuleVersion(id), + origin: 'history', unread: false, recordedDay: null, timeZone: null })), + auxiliary: { activity: { schemaVersion: 2, settingKeys: [], features: [] }, + hours: { schemaVersion: 3, maxStreak: 0 }, calendarPeak: null } }); + provider.achievementGrants = new Set(ids); + } + if (fixture === 'combined-heatmap') { + // Sharing protocol tests need one explicitly saved award. Their tiny + // heatmap usage is no longer enough for output-qualified model rainbow. + const { parseAchievementRecordStore } = require('../../../out/achievementRecordStore.js'); + provider.achievementRecordState = parseAchievementRecordStore({schemaVersion:2,catalogVersion:2,generation:1, + sequence:1,lastNoticeAt:0,pinRevision:0,showcaseMode:'automatic',legacyBoundary:null,legacyRecords:[],pins:[], + records:[{id:'local-share-export',seq:1,ruleVersion:1,origin:'history',unread:false,recordedDay:null,timeZone:null}], + auxiliary:{activity:{schemaVersion:2,settingKeys:[],features:[]},hours:{schemaVersion:3,maxStreak:0},calendarPeak:null}}); + provider.achievementGrants = new Set(['local-share-export']); + } + if (['achievement-six-pins', 'achievement-six-pins-reordered', 'achievement-last-pin', 'achievement-last-pin-removed'].includes(fixture)) { + const { parseAchievementRecordStore } = require('../../../out/achievementRecordStore.js'); + const ids = ['local-share-export','local-advice-feedback','settings-explorer-bronze','hundred-million-club','sonnet-main','opus-cannon']; + const pins = ['local-share-export','local-advice-feedback','settings-explorer','cumulative-volume','sonnet-main','opus-cannon']; + if (fixture === 'achievement-six-pins-reordered') [pins[0], pins[1]] = [pins[1], pins[0]]; + if (fixture === 'achievement-last-pin') pins.splice(1); + if (fixture === 'achievement-last-pin-removed') pins.splice(0); + provider.achievementRecordState = parseAchievementRecordStore({schemaVersion:2,catalogVersion:2,generation:1, + sequence:6,lastNoticeAt:0,pinRevision:fixture.endsWith('-reordered') || fixture.endsWith('-removed') ? 1 : 0, + showcaseMode:'manual',legacyBoundary:null,legacyRecords:[],pins, + records:ids.map((id,index)=>({id,seq:index+1,ruleVersion:1,origin:'history',unread:false,recordedDay:null,timeZone:null})), + auxiliary:{activity:{schemaVersion:2,settingKeys:[],features:[]},hours:{schemaVersion:3,maxStreak:0},calendarPeak:null}}); + provider.achievementGrants = new Set(ids); + // Claude-only focus fixtures include the real sharing presentation so a + // patch can preserve a deliberate choice independently of showcase pins. + provider.sharingTemplate = 'achievements'; + } + if (fixture.startsWith('achievement-long-term-')) { + const {ACHIEVEMENT_DEFINITIONS,ACHIEVEMENT_SERIES}=require('../../../out/achievements.js'); + const {parseAchievementRecordStore}=require('../../../out/achievementRecordStore.js'); + const rank=['gold','platinum','legendary'].indexOf(fixture.replace('achievement-long-term-',''))+2; + const ids=ACHIEVEMENT_SERIES['sonnet-main'].slice(0,rank+1); + const progress=ACHIEVEMENT_DEFINITIONS.find(row=>row.id===ids.at(-1)).target; + provider.providerAvailability={claude:true,codex:false,codexData:false}; + // Deliberately synthetic renderer facts; domain boundary tests separately + // exercise the real evaluator and native-receipt qualification. + provider.achievementResults=()=>ACHIEVEMENT_DEFINITIONS.map(row=>({...row, + progress:ACHIEVEMENT_SERIES['sonnet-main'].includes(row.id)?progress:0, + unlocked:ACHIEVEMENT_SERIES['sonnet-main'].includes(row.id)&&progress>=row.target})); + provider.achievementRecordState=parseAchievementRecordStore({schemaVersion:2,catalogVersion:2,generation:1, + sequence:ids.length,lastNoticeAt:0,pinRevision:0,showcaseMode:'manual',legacyBoundary:null, + legacyRecords:[],pins:['sonnet-main'],records:ids.map((id,i)=>({id,seq:i+1,ruleVersion:1, + origin:'history',unread:false,recordedDay:null,timeZone:null})), + auxiliary:{activity:{schemaVersion:2,settingKeys:[],features:[]},hours:{schemaVersion:3,maxStreak:0},calendarPeak:null}}); + provider.achievementGrants=new Set(ids); + provider.sharingTemplate='achievements'; + } + if (fixture === 'achievement-records' || fixture === 'achievement-records-read' || fixture === 'achievement-history-only' || fixture === 'achievement-pins' || fixture.startsWith('achievement-hidden-pins')) { + // Validated synthetic local records, not claimed personal achievement dates. + const { parseAchievementRecordStore } = require('../../../out/achievementRecordStore.js'); + const ids = ['local-share-export', 'settings-explorer-bronze', 'settings-explorer-silver', 'local-advice-feedback', 'hundred-million-club']; + const hiddenPins = fixture.startsWith('achievement-hidden-pins'); + if (hiddenPins) ids.push('astra-regular', 'sol-regular', 'luna-regular'); + provider.achievementRecordState = parseAchievementRecordStore({ schemaVersion: 1, generation: 1, + sequence: ids.length, lastNoticeAt: 0, pins: hiddenPins + ? fixture === 'achievement-hidden-pins-last' ? ['astra-regular'] + : fixture === 'achievement-hidden-pins-released' ? ['sol-regular', 'luna-regular'] : ['astra-regular', 'sol-regular', 'luna-regular'] + : fixture === 'achievement-pins' ? ['local-share-export'] : [], + records: ids.map((id, index) => { + const historical = fixture === 'achievement-history-only' || id === 'hundred-million-club'; + return { id, seq: index + 1, ruleVersion: 1, origin: historical ? 'history' : 'activity', + unread: !historical && fixture !== 'achievement-records-read', recordedDay: historical ? null : '2026-07-20', timeZone: historical ? null : 'Asia/Hong_Kong' }; + }), auxiliary: { activity: { schemaVersion: 2, settingKeys: [], features: [] }, + hours: { schemaVersion: 3, maxStreak: 0 }, calendarPeak: null } }); + provider.achievementGrants = new Set(ids); + } + + if (fixture.startsWith('achievement-theme-')) { + const { parseAchievementRecordStore } = require('../../../out/achievementRecordStore.js'); + const milestoneOnly = fixture === 'achievement-theme-milestones'; + const ids = milestoneOnly ? ['streak-machine', 'dual-provider-day'] + : ['active-day-streak', 'streak-machine', 'dual-provider-days-bronze', 'dual-provider-day']; + provider.achievementRecordState = parseAchievementRecordStore({ schemaVersion: 1, generation: 1, + sequence: ids.length, lastNoticeAt: 0, + pins: milestoneOnly ? ['streak-machine', 'dual-provider-day'] : fixture === 'achievement-theme-pins-released' + ? ['active-day-streak', 'dual-provider-day'] : ['streak-machine', 'active-day-streak', 'dual-provider-day'], + records: ids.map((id, index) => ({ id, seq: index + 1, ruleVersion: 1, + origin: 'usage', unread: true, recordedDay: '2026-07-20', timeZone: 'Asia/Hong_Kong' })), + auxiliary: { activity: { schemaVersion: 2, settingKeys: [], features: [] }, + hours: { schemaVersion: 3, maxStreak: 0 }, calendarPeak: null } }); + provider.achievementGrants = new Set(ids); + } + + if (fixture.startsWith('achievement-journey-')) { + const { parseAchievementRecordStore } = require('../../../out/achievementRecordStore.js'); + const complete = fixture === 'achievement-journey-complete'; + const historical = fixture === 'achievement-journey-history'; + provider.achievementRecordState = parseAchievementRecordStore({ schemaVersion: 1, generation: 1, + sequence: historical ? 2 : 0, lastNoticeAt: 0, pins: [], records: historical + ? ['safe-feature-explorer', 'local-share-export'].map((id, index) => ({ id, seq: index + 1, + ruleVersion: 1, origin: 'history', unread: false, recordedDay: null, timeZone: null })) : [], + auxiliary: { activity: { schemaVersion: 2, settingKeys: [], features: historical ? [] : complete + ? ['achievementGalleryViewed', 'achievementSharePreview', 'localShareExport'] + : ['achievementGalleryViewed', 'achievementSharePreview', 'localAdviceFeedback'] }, + hours: { schemaVersion: 3, maxStreak: 0 }, calendarPeak: null } }); + provider.achievementActivity = provider.achievementRecordState.auxiliary.activity; + provider.achievementGrants = new Set(provider.achievementRecordState.records.map(row => row.id)); + if (!complete && !historical) provider.pendingAchievementActivity = { ...provider.achievementActivity, + features: [...provider.achievementActivity.features, 'localShareExport'] }; + } + + if (fixture === 'achievement-proof-stale') { + provider.updateRefreshState('codex',{failed:true,lastSuccessfulAt:Date.parse('2026-07-20T11:00:00Z')}); + } + if (fixture === 'achievement-review-loading' || fixture === 'achievement-review-failure') { + // Controlled host state only: production renders the complete document. + // Async initialization/failure is covered by Node host tests; this fixture + // must not open a filesystem store or start another hydration attempt. + await provider.achievementWrite; + provider.achievementRecordState = undefined; + provider.achievementRecordRepository = { + initialize: () => { throw new Error('synthetic-review:unexpected-hydration'); }, + }; + provider.achievementRecordLoading = fixture === 'achievement-review-loading'; + provider.achievementRecordLoadFailed = fixture === 'achievement-review-failure'; + } + if (fixture === 'achievement-management-history' || fixture === 'achievement-management-full' || fixture === 'achievement-management-history-recorded') { + const {parseAchievementRecordStore}=require('../../../out/achievementRecordStore.js'); + const full=fixture==='achievement-management-full'; + const used=full?[56_000_000_000,4_000_000_000]:[100_000_000,1_000_000_000]; + const usage=n=>({...claudeUsage(),totalInputTokens:n,totalOutputTokens:0, + totalCacheCreationTokens:0,totalCacheReadTokens:0, + modelBreakdown:{'claude-sonnet-4-5':{inputTokens:n,outputTokens:0,cacheCreationTokens:0,cacheReadTokens:0, + cost:0,count:1,referenceUsage:{inputTokens:n,outputTokens:0,cacheReadTokens:0,cacheCreation5mTokens:0,cacheCreation1hTokens:0}}}}); + provider.providerAvailability={claude:true,codex:false,codexData:false}; + provider.allTimeData=usage(used[0]+used[1]); + provider.dailyDataForEveryDay=[{date:'2026-06-03',data:usage(used[1])},{date:'2026-06-01',data:usage(used[0])}]; + provider.sessionBreakdown=[];provider.achievementCalendarTimeZone=timeZone; + const ids=full?['sonnet-main','sonnet-main-silver','sonnet-main-gold','sonnet-main-platinum','sonnet-main-legendary']: + ['sonnet-main','sonnet-main-silver']; + provider.achievementRecordState=parseAchievementRecordStore({schemaVersion:2,catalogVersion:2,generation:1, + sequence:ids.length,lastNoticeAt:0,pinRevision:0,showcaseMode:'manual',legacyBoundary:null,legacyRecords:[],pins:[], + records:ids.map((id,i)=>({id,seq:i+1,ruleVersion:1,origin:fixture==='achievement-management-history-recorded'?'usage':'history', + unread:false,recordedDay:fixture==='achievement-management-history-recorded'?'2026-07-20':null, + timeZone:fixture==='achievement-management-history-recorded'?timeZone:null})), + auxiliary:{activity:{schemaVersion:2,settingKeys:[],features:[]},hours:{schemaVersion:3,maxStreak:0},calendarPeak:null}}); + provider.achievementGrants=new Set(ids); + } + if (fixture === 'achievement-review-next-rank') { + // A retained historical bronze award with a smaller current subtotal. + // Use the real record validator, evaluator and renderer; do not replace + // achievementResults or build an overview/gallery HTML fragment here. + const { parseAchievementRecordStore } = require('../../../out/achievementRecordStore.js'); + provider.providerAvailability = { claude: true, codex: false, codexData: false }; + provider.allTimeData = { ...claudeUsage(), totalInputTokens: 6_000_000, + totalOutputTokens: 1, totalCacheCreationTokens: 0, totalCacheReadTokens: 0, + modelBreakdown: {} }; + provider.dailyDataForEveryDay = []; + provider.sessionBreakdown = []; + provider.achievementCalendarTimeZone = timeZone; + provider.achievementRecordState = parseAchievementRecordStore({ + schemaVersion: 2, catalogVersion: 2, generation: 1, sequence: 1, + lastNoticeAt: 0, pinRevision: 0, showcaseMode: 'automatic', + legacyBoundary: null, legacyRecords: [], pins: [], + records: [{ id: 'hundred-million-club', seq: 1, ruleVersion: 1, + origin: 'history', unread: false, recordedDay: null, timeZone: null }], + auxiliary: { activity: { schemaVersion: 2, settingKeys: [], features: [] }, + hours: { schemaVersion: 3, maxStreak: 0 }, calendarPeak: null }, + }); + provider.achievementGrants = new Set(['hundred-million-club']); + } if (/^\d{4}-\d{2}$/.test(codexMonth)) { const rows = view.allTimeDaily @@ -592,6 +965,7 @@ exports.renderHarness = async function renderHarness({ return provider.renderCodexMonthDailyDetail(codexMonth, rows); } + if (nativeResources && !commandTemplate) provider.panel = createHarnessPanel(true); latestHarnessPanel = undefined; if (commandTemplate) { provider.showSharingWorkspace(commandTemplate); @@ -606,7 +980,7 @@ exports.renderHarness = async function renderHarness({ const fixtureHtml = persistedDetailsFixture ? html.replace('
    ', ``) .replace(' { + if (!lstatSync(filename).isFile()) throw new Error('Expected a regular packaged achievement icon'); + return [route, readFileSync(filename)]; +})); const locales = new Set(['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']); const uiTestPort = Number(process.env.CCU_UI_TEST_PORT ?? 4173); @@ -15,6 +22,13 @@ const server = createServer(async (request, response) => { return; } if (url.pathname !== '/') { + const image = achievementResources.get(url.pathname); + if (image) { + response.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff' }); + response.end(image); + return; + } response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); response.end('not found'); return; @@ -26,7 +40,8 @@ const server = createServer(async (request, response) => { const provider = requestedProvider === 'claude' || requestedProvider === 'compare' ? requestedProvider : 'codex'; - const theme = url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'; + const requestedTheme = url.searchParams.get('theme'); + const theme = ['dark', 'highContrast'].includes(requestedTheme) ? requestedTheme : 'light'; const requestedFixture = url.searchParams.get('fixture') ?? 'default'; const fixture = [ 'default', @@ -36,6 +51,7 @@ const server = createServer(async (request, response) => { 'persisted-details', 'weekly-usage-only', 'weekly-claude-completed', + 'weekly-quota-pace', 'unknown-models', 'zero-input', 'covered-day-without-hourly-rows', @@ -46,6 +62,39 @@ const server = createServer(async (request, response) => { 'combined-heatmap', 'session-timezone-boundaries', 'local-currency', + 'achievement-token-stages', + 'achievement-share-resources', + 'achievement-calendar-subset', + 'achievement-review-loading', + 'achievement-review-failure', + 'achievement-proof-stale', + 'achievement-review-next-rank', + 'achievement-management-history', + 'achievement-management-history-recorded', + 'achievement-management-full', + 'achievement-long-term-gold', + 'achievement-long-term-platinum', + 'achievement-long-term-legendary', + 'achievement-effort-statistics', + 'achievement-effort-statistics-empty', + 'achievement-effort-statistics-retired', + 'achievement-records', + 'achievement-records-read', + 'achievement-history-only', + 'achievement-pins', + 'achievement-six-pins', + 'achievement-six-pins-reordered', + 'achievement-last-pin', + 'achievement-last-pin-removed', + 'achievement-theme-milestones', + 'achievement-theme-pins', + 'achievement-theme-pins-released', + 'achievement-journey-partial', + 'achievement-journey-complete', + 'achievement-journey-history', + 'achievement-hidden-pins', + 'achievement-hidden-pins-released', + 'achievement-hidden-pins-last', ].includes(requestedFixture) ? requestedFixture : 'default'; @@ -57,6 +106,8 @@ const server = createServer(async (request, response) => { const weeklyValue = url.searchParams.get('weeklyValue') !== 'false'; const shareStudio = url.searchParams.get('shareStudio') !== 'false'; const projectMatrix = url.searchParams.get('projectMatrix') !== 'false'; + const achievementsEnabled = url.searchParams.get('achievementsEnabled') !== 'false'; + const achievementNotices = url.searchParams.get('achievementNotices') !== 'false'; const requestedFeedback = url.searchParams.get('adviceFeedback'); const adviceFeedback = requestedFeedback === 'claude-helpful' || requestedFeedback === 'optimizer-helpful' @@ -67,11 +118,16 @@ const server = createServer(async (request, response) => { ? requestedCodexMonth : ''; const claudeOnly = url.searchParams.get('claudeOnly') === 'true'; + const codexOnly = url.searchParams.get('codexOnly') === 'true'; const requestedCommandTemplate = url.searchParams.get('commandTemplate') ?? ''; - const commandTemplate = ['claudeShareCard', 'claudeHeatmap'].includes(requestedCommandTemplate) + const commandTemplate = ['claudeShareCard', 'claudeHeatmap', 'achievements'].includes(requestedCommandTemplate) ? requestedCommandTemplate : ''; const commandAfterReset = url.searchParams.get('commandAfterReset') === 'true'; + const quotaPace = url.searchParams.get('quotaPace') === 'true'; + const requestedPaceState = url.searchParams.get('quotaPaceState') ?? 'fresh'; + const quotaPaceState = ['fresh', 'stale', 'reset', 'null', 'raw-mismatch'].includes(requestedPaceState) + ? requestedPaceState : 'fresh'; const html = await renderHarness({ provider, locale, @@ -81,12 +137,18 @@ const server = createServer(async (request, response) => { weeklyValue, shareStudio, projectMatrix, + achievementsEnabled, + achievementNotices, adviceFeedback, timeZone, codexMonth, claudeOnly, + codexOnly, commandTemplate, commandAfterReset, + quotaPace, + quotaPaceState, + nativeResources: url.searchParams.get('nativeResources') === 'true', }); response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', diff --git a/tests/ui/weekly-quota-pace.spec.mjs b/tests/ui/weekly-quota-pace.spec.mjs new file mode 100644 index 00000000..d2fc70a7 --- /dev/null +++ b/tests/ui/weekly-quota-pace.spec.mjs @@ -0,0 +1,241 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect } from './support/app.mjs'; + +// fakeHost only: synthetic quota responses and production HTML. No extension +// activation, normal credentials/cache/logs, quota fetch or host filesystem test. +const base = `http://127.0.0.1:${process.env.CCU_UI_TEST_PORT ?? 4173}`; +const pace = page => page.locator('#weeklyQuotaPace'); +const setting = page => page.locator('#set_showWeeklyQuotaPace'); +const posted = page => page.evaluate(() => window.__ccuPostedMessages); +const locales = ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']; +// Independent copy expectations, rather than importing the same translations +// that generate the HTML and allowing a missing locale to pass tautologically. +const copy = { + en: ['Weekly quota pace', 'Used', 'Reference progress', 'Estimated at reset ≈', 'Forecast unavailable', 'Account-wide use includes other devices and members.'], + 'de-DE': ['Wöchentliches Kontingenttempo', 'Verbraucht', 'Referenzfortschritt', 'Geschätzt beim Zurücksetzen ≈', 'Prognose nicht verfügbar', 'Kontoweite Nutzung umfasst andere Geräte und Mitglieder.'], + 'zh-TW': ['週額度使用節奏', '已用', '參考進度', '週期末預計約', '預測暫不可用', '帳號總用量包含其他裝置及成員。'], + 'zh-CN': ['周额度使用节奏', '已用', '参考进度', '周期末预计约', '预测暂不可用', '账号总用量包含其他设备及成员。'], + ja: ['週間クォータの使用ペース', '使用済み', '基準の進捗', 'リセット時の予測 ≈', '予測は利用できません', 'アカウント全体の使用量には他の端末やメンバーを含みます。'], + ko: ['주간 할당량 사용 속도', '사용량', '기준 진행률', '초기화 시 예상 ≈', '예측을 사용할 수 없음', '계정 전체 사용량에는 다른 기기와 구성원이 포함됩니다.'], + 'pt-BR': ['Ritmo da cota semanal', 'Usado', 'Progresso de referência', 'Estimativa na redefinição ≈', 'Previsão indisponível', 'O uso da conta inclui outros dispositivos e membros.'], + id: ['Laju kuota mingguan', 'Terpakai', 'Progres acuan', 'Perkiraan saat reset ≈', 'Perkiraan tidak tersedia', 'Pemakaian akun mencakup perangkat dan anggota lain.'], +}; + +async function open(page, options = {}) { + const { width = 1280, height = 900, provider = 'claude', locale = 'en', theme = 'light', + enabled = true, state = 'fresh' } = options; + await page.setViewportSize({ width, height }); + const params = new URLSearchParams({ provider, locale, theme, fixture: 'weekly-quota-pace', + quotaPace: String(enabled), quotaPaceState: state, autoRefresh: 'false' }); + const response = await page.goto(`${base}/?${params}`, { waitUntil: 'load' }); + expect(response.status()).toBe(200); + await expect(page.locator('#provider-panel')).toBeVisible(); +} + +const network = new WeakMap(); +test.beforeEach(async ({ page }) => { + const evidence = { external: [], requests: [], errors: [] }; + network.set(page, evidence); + page.on('request', request => evidence.requests.push(request.url())); + page.on('pageerror', error => evidence.errors.push(error.message)); + await page.route('**/*', route => { + if (new URL(route.request().url()).origin === base) return route.continue(); + evidence.external.push(route.request().url()); + return route.abort(); + }); +}); +test.afterEach(async ({ page }) => { + expect(network.get(page).external).toEqual([]); + expect(network.get(page).errors).toEqual([]); + expect((await posted(page) ?? []).filter(row => /fetch|pricing|publish|refresh/i.test(row.command))).toEqual([]); +}); + +// Obtain replacement content from the actual production render helper through +// the fixture route, never from fabricated markup. APIRequestContext does not +// execute the replacement document or change the browser's current identity. +async function fakeHost(page, changes = {}) { + const url = new URL(page.url()); + for (const [key, value] of Object.entries(changes)) url.searchParams.set(key, String(value)); + const response = await page.context().request.get(url.href); + expect(response.status()).toBe(200); + const html = await response.text(); + return page.evaluate(html => { + const next = new DOMParser().parseFromString(html, 'text/html').getElementById('weeklyQuotaPace'); + if (!next) throw new Error('Production pace container missing from fixture HTML'); + const ready = window.__ccuPostedMessages.findLast(row => row.command === 'localDataClientReady'); + if (!ready?.documentId) throw new Error('Production document identity missing'); + return { command: 'weeklyQuotaPace', documentId: ready.documentId, + provider: next.dataset.quotaPaceProvider, html: next.innerHTML }; + }, html); +} + +for (const theme of ['light', 'dark', 'highContrast']) { + test(`${theme}: narrow 200% CSS surrogate setting is keyboard operable and axe labelled`, async ({ page }) => { + await open(page, { theme, width: 640, enabled: false }); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + await expectThemeFidelity(page, theme); + await page.locator('#tab-settings').focus(); + await page.keyboard.press('Enter'); + await expect(setting(page)).not.toBeChecked(); + await setting(page).focus(); + network.get(page).requests = []; + await page.keyboard.press('Space'); + await expect(setting(page)).toBeChecked(); + await expect(setting(page)).toBeFocused(); + const result = await new AxeBuilder({ page }).include('.set-row:has(#set_showWeeklyQuotaPace)') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + expect(result.violations).toEqual([]); + expect((await posted(page)).filter(row => row.command === 'updateSetting')) + .toEqual([{ command: 'updateSetting', key: 'showWeeklyQuotaPace', value: true }]); + expect(network.get(page).requests).toEqual([]); + }); +} +async function deliver(page, message) { + await page.evaluate(data => dispatchEvent(new MessageEvent('message', { data })), message); +} + +async function expectThemeFidelity(page, theme) { + const evidence = await page.evaluate(() => { + const css = [...document.querySelectorAll('head style:not(#test-vscode-theme)')].map(el => el.textContent).join('\n'); + const themeCss = document.querySelector('#test-vscode-theme').textContent; + const defined = new Map([...themeCss.matchAll(/(--vscode-[\w-]+)\s*:\s*([^;{}]*);/g)].map(m => [m[1], m[2].trim()])); + const references = [...new Set([...css.matchAll(/var\(\s*(--vscode-[\w-]+)/g)].map(m => m[1]))]; + // Registered nullable colors may compute empty only when explicitly initial. + const nullable = new Set(['--vscode-input-border', '--vscode-textBlockQuote-background', '--vscode-toolbar-hoverBackground', + '--vscode-button-secondaryBackground', '--vscode-button-secondaryHoverBackground', '--vscode-charts-orange', + '--vscode-list-inactiveSelectionBackground', '--vscode-widget-shadow']); + const computed = getComputedStyle(document.documentElement); + return { count: references.length, + missing: references.filter(name => !defined.has(name) || (!computed.getPropertyValue(name).trim() && + !(nullable.has(name) && defined.get(name) === 'initial'))), + background: getComputedStyle(document.documentElement).getPropertyValue('--vscode-editor-background').trim(), + bodyClass: document.body.className }; + }); + expect(evidence.count).toBeGreaterThan(0); + expect(evidence.missing).toEqual([]); + expect(evidence.background).toBe({ light: '#ffffff', dark: '#1e1e1e', highContrast: '#000000' }[theme]); + expect(evidence.bodyClass).toContain({ light: 'vscode-light', dark: 'vscode-dark', highContrast: 'vscode-high-contrast' }[theme]); +} + +test('default OFF renders no pace block and starts no new requests', async ({ page }) => { + await open(page, { enabled: false }); + await expect(pace(page).locator('.quota-pace')).toHaveCount(0); + await page.locator('#tab-settings').click(); + await expect(setting(page)).not.toBeChecked(); + await expect(page.locator('#set_dashboardAutoRefresh')).not.toBeChecked(); + network.get(page).requests = []; + await page.clock.runFor(60_000); + expect(network.get(page).requests).toEqual([]); +}); + +test('real keyboard setting OFF/ON emits updateSetting; fakeHost production patches preserve document', async ({ page }) => { + await open(page, { enabled: false }); + await page.locator('#tab-settings').click(); + await setting(page).focus(); + network.get(page).requests = []; + await page.evaluate(() => { window.__paceDocument = document.documentElement; }); + await page.keyboard.press('Space'); + await expect(setting(page)).toBeChecked(); + expect((await posted(page)).filter(row => row.command === 'updateSetting')) + .toEqual([{ command: 'updateSetting', key: 'showWeeklyQuotaPace', value: true }]); + const on = await fakeHost(page, { quotaPace: true }); + await deliver(page, on); + await expect(pace(page)).toContainText('116%'); + await setting(page).focus(); + await page.keyboard.press('Space'); + await expect(setting(page)).not.toBeChecked(); + await deliver(page, await fakeHost(page, { quotaPace: false })); + await expect(pace(page)).toBeHidden(); + expect((await posted(page)).filter(row => row.command === 'updateSetting').map(row => row.value)).toEqual([true, false]); + expect(await page.evaluate(() => window.__paceDocument === document.documentElement)).toBe(true); + expect(network.get(page).requests).toEqual([]); +}); + +for (const state of ['stale', 'reset', 'null']) { + test(`${state} fakeHost patch withdraws 116 while auto-refresh is paused, preserving focus/scroll/document`, async ({ page }) => { + await open(page); + await expect(pace(page)).toContainText('116%'); + const valid = await fakeHost(page, { quotaPaceState: state }); + const previous = await pace(page).innerHTML(); + await deliver(page, { ...valid, documentId: 'retired-document' }); + await deliver(page, { ...valid, provider: 'codex' }); + expect(await pace(page).innerHTML()).toBe(previous); + await page.locator('#tab-settings').click(); + await expect(page.locator('#set_dashboardAutoRefresh')).not.toBeChecked(); + await setting(page).focus(); + const before = await page.evaluate(() => { + window.__paceDocument = document.documentElement; + return { x: scrollX, y: scrollY, focus: document.activeElement.id }; + }); + expect(before.y).toBeGreaterThan(0); + network.get(page).requests = []; + await deliver(page, valid); + await expect(pace(page)).not.toContainText('116%'); + expect(await page.evaluate(() => ({ sameDocument: window.__paceDocument === document.documentElement, + x: scrollX, y: scrollY, focus: document.activeElement.id }))).toEqual({ ...before, sameDocument: true }); + expect(network.get(page).requests).toEqual([]); + await page.locator('#tab-today').click(); + await expect(pace(page)).toContainText('Forecast unavailable'); + }); +} + +test('raw/numeric mismatch cannot display an apparently trusted 116', async ({ page }) => { + await open(page, { state: 'raw-mismatch' }); + await expect(pace(page)).not.toContainText('116%'); + await expect(pace(page).locator('[data-quota-pace-status="unavailable"]')).toHaveCount(1); + await expect(pace(page)).toContainText('160%'); +}); + +for (const locale of locales) { + test(`${locale}: genuine weekly 29/25/116 and scoped 40/25/160; 5h excluded; Codex unknown unavailable`, async ({ page }) => { + await open(page, { locale }); + const [title, used, reference, forecast, unavailable, disclosure] = copy[locale]; + await expect(pace(page).locator('h3')).toHaveText(title); + const rows = pace(page).locator('[data-quota-pace-status]'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0)).toContainText(`${used} 29% · ${reference} 25% · ${forecast} 116%`); + await expect(rows.nth(1)).toContainText(`${used} 40% · ${reference} 25% · ${forecast} 160%`); + await expect(rows.nth(1)).toContainText('Synthetic scoped model'); + await expect(pace(page)).not.toContainText('73%'); + await expect(pace(page)).toContainText('2026-07-20T12:00:00.000Z'); + await expect(pace(page)).toContainText(disclosure); + await open(page, { locale, provider: 'codex' }); + await expect(pace(page)).toContainText(unavailable); + await expect(pace(page)).not.toContainText('116%'); + await expect(pace(page).locator('[data-quota-pace-status="ready"]')).toHaveCount(0); + }); +} + +for (const locale of locales) for (const theme of ['light', 'dark', 'highContrast']) for (const width of [320, 360]) { + test(`${locale} ${theme} ${width}px: readable pace, theme guard, axe and 200% CSS surrogate`, async ({ page }, testInfo) => { + await open(page, { locale, theme, width }); + await expectThemeFidelity(page, theme); + await expect(pace(page)).toContainText('116%'); + for (const scale of [1, 2]) { + // Layout stress surrogate only: doubling the viewport while applying CSS + // zoom preserves the effective width. This is not native browser zoom. + await page.setViewportSize({ width: width * scale, height: 900 }); + await page.evaluate(scale => { document.documentElement.style.zoom = String(scale); }, scale); + await pace(page).scrollIntoViewIfNeeded(); + const geometry = await pace(page).evaluate(node => { + const texts = [...node.querySelectorAll('h3,.label,.model-details')]; + return { fits: node.scrollWidth <= node.clientWidth + 1, + overflow: texts.filter(el => el.scrollWidth > el.clientWidth + 1).map(el => el.textContent), + fonts: texts.map(el => parseFloat(getComputedStyle(el).fontSize)) }; + }); + expect(geometry.fits).toBe(true); + expect(geometry.overflow).toEqual([]); + expect(Math.min(...geometry.fonts)).toBeGreaterThanOrEqual(11); + const result = await new AxeBuilder({ page }).include('#weeklyQuotaPace') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + await testInfo.attach(`axe-${scale}x`, { body: JSON.stringify({ violations: result.violations, + incomplete: result.incomplete, passes: result.passes.map(row => row.id) }), contentType: 'application/json' }); + expect(result.violations).toEqual([]); + expect(result.passes.some(row => row.id === 'color-contrast')).toBe(true); + expect(result.incomplete.filter(row => row.id === 'color-contrast')).toEqual([]); + const screenshot = testInfo.outputPath(`pace-${locale}-${theme}-${width}-${scale}x-css-surrogate.png`); + await pace(page).screenshot({ path: screenshot, animations: 'disabled' }); + await testInfo.attach(`pace-${scale}x`, { path: screenshot, contentType: 'image/png' }); + } + }); +} diff --git a/tsconfig.json b/tsconfig.json index ec15e359..59be739c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,9 @@ "rootDir": "src", "strict": true }, + "include": ["src/**/*.ts"], "exclude": [ "node_modules", ".vscode-test" ] -} \ No newline at end of file +}