diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..97e7983c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.vsix binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5c00372f..5279f478 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -29,7 +29,8 @@ Keep the section headings; fill what applies, delete what doesn't. ## Checklist - [ ] `npm run compile` is clean -- [ ] User-facing strings go through `I18n` with **all six languages** filled +- [ ] User-facing strings go through `I18n` with **all eight UI locales** filled +- [ ] Behaviour/documentation changes update **all seven README editions** together - [ ] `CHANGELOG.md` updated (user-visible changes) - [ ] `README.md` updated if behaviour/settings changed - [ ] New settings default to existing behaviour (opt-in) diff --git a/.github/scripts/automationPolicy.test.mjs b/.github/scripts/automationPolicy.test.mjs new file mode 100644 index 00000000..21a09059 --- /dev/null +++ b/.github/scripts/automationPolicy.test.mjs @@ -0,0 +1,100 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const REPO_ROOT = resolve(import.meta.dirname, '..', '..'); +const CHECKOUT_SHA = 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5'; + +function read(relativePath) { + return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8'); +} + +test('runner attributes the tier whose reply becomes final and posts only commentBody', () => { + const runner = read('.github/scripts/first-pass.mjs'); + assert.match(runner, /const TRANSPORT = 'anthropic-messages'/); + assert.match(runner, /resolveGeneratorAttribution\(env\.CCU_BOT_GENERATOR, model, TRANSPORT\)/); + assert.match(runner, /resolveGeneratorAttribution\(env\.CCU_BOT_GENERATOR_PRO, modelPro, TRANSPORT\)/); + assert.match(runner, /parseFirstPassResponse/); + assert.match(runner, /chooseFinalReply/); + assert.match(runner, /formatAutomatedComment\(reply, \{ kind, generator: finalGenerator \}\)/); + assert.match(runner, /JSON\.stringify\(\{\s*body:\s*commentBody\s*\}\)/); + assert.doesNotMatch(runner, /JSON\.stringify\(\{\s*body:\s*reply\s*\}\)/); + assert.doesNotMatch(runner, /CCU_BOT_TRANSPORT|prompt-injection safe|injection-safe/i); +}); + +test('runner uses one bounded reader for AGENTS grounding and requested source', () => { + const runner = read('.github/scripts/first-pass.mjs'); + assert.match(runner, /# AGENTS\.md/); + assert.equal((runner.match(/createRepoReadSession\(/g) ?? []).length, 1); + assert.ok((runner.match(/repoReader\.read\(/g) ?? []).length >= 2); + assert.doesNotMatch(runner, /const ALLOWED_EXT|const readRepoFiles/); +}); + +test('automatic first pass defaults to English and is bilingual only for Chinese authors', () => { + const runner = read('.github/scripts/first-pass.mjs'); + assert.match(runner, /Reply in English by default/i); + assert.match(runner, /author wrote in Chinese/i); + assert.match(runner, /English first/i); + assert.doesNotMatch(runner, /Reply in the same language as the author/i); + assert.doesNotMatch(runner, /\*\*TL;DR \/ 结论\*\*/); +}); + +test('comment-only workflows configure cheap and pro independently', () => { + for (const workflow of [ + read('.github/workflows/issue-first-pass.yml'), + read('.github/workflows/pr-first-pass.yml'), + ]) { + assert.match(workflow, /CCU_BOT_GENERATOR:/); + assert.match(workflow, /CCU_BOT_GENERATOR_PRO:/); + assert.match(workflow, /contents: read/); + assert.doesNotMatch(workflow, /contents: write/); + assert.doesNotMatch(workflow, /CCU_BOT_TRANSPORT/); + assert.match(workflow, new RegExp(CHECKOUT_SHA)); + } +}); + +test('PR diff is required and public text is not overclaimed as injection safe', () => { + const pr = read('.github/workflows/pr-first-pass.yml'); + const issue = read('.github/workflows/issue-first-pass.yml'); + assert.match(pr, /gh pr diff[^\n]+> \/tmp\/pr\.diff\n\s+test -s \/tmp\/pr\.diff/); + assert.doesNotMatch(pr, /\|\| true/); + assert.doesNotMatch(`${pr}\n${issue}`, /prompt-injection safe|injection-safe/i); +}); + +test('manual publish retries require a release tag and can target one registry', () => { + const workflow = read('.github/workflows/publish.yml'); + assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:\s*\n\s+tag:/); + assert.match(workflow, /tag:\s*\n(?:\s+[^\n]+\n)*?\s+required: true/); + assert.match(workflow, /RELEASE_TAG:.*github\.event\.release\.tag_name.*inputs\.tag/); + assert.match(workflow, /uses: actions\/checkout@[a-f0-9]+[\s\S]*?ref: refs\/tags\/\$\{\{ env\.RELEASE_TAG \}\}/); + assert.match(workflow, /git rev-parse HEAD/); + assert.match(workflow, /git rev-parse "refs\/tags\/\$RELEASE_TAG\^\{commit\}"/); + assert.match(workflow, /if: github\.event_name == 'release' \|\| inputs\.publish_vscode/); + assert.match(workflow, /if: github\.event_name == 'release' \|\| inputs\.publish_open_vsx/); + assert.doesNotMatch(workflow, /name: Set version from release tag\s*\n\s+if:/); +}); + +test('maintainer-only mention workflow retains its privileged Claude boundary', () => { + const privileged = read('.github/workflows/claude.yml'); + assert.match(privileged, /contents: write/); + assert.match(privileged, /anthropics\/claude-code-action@v1/); + assert.match(privileged, /OWNER","MEMBER","COLLABORATOR/); +}); + +test('CONTRIBUTING distinguishes current automatic, reviewed Codex, and privileged agent text', () => { + const contributing = read('CONTRIBUTING.md'); + assert.match(contributing, /### Controlled automatic first pass/); + assert.match(contributing, /DeepSeek or Claude/); + assert.match(contributing, /Codex automatic attribution is not enabled in v2\.2\.1/); + assert.match(contributing, /Generated with \[OpenAI Codex\]/); + assert.match(contributing, /### Maintainer-only mention agent/); + assert.match(contributing, /does not migrate this privileged workflow to Codex/); +}); + +test('automation hardening is appended to the one existing v2.2.1 section', () => { + const changelog = read('CHANGELOG.md'); + assert.equal((changelog.match(/^## \[2\.2\.1\] — Unreleased$/gm) ?? []).length, 1); + assert.match(changelog, /truthful per-tier provider attribution/); + assert.match(changelog, /bounded base-repository file reads/); +}); diff --git a/.github/scripts/first-pass-lib.mjs b/.github/scripts/first-pass-lib.mjs new file mode 100644 index 00000000..bd4a160e --- /dev/null +++ b/.github/scripts/first-pass-lib.mjs @@ -0,0 +1,351 @@ +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, +} from 'node:fs'; +import { isAbsolute, join, resolve, sep } from 'node:path'; + +const GENERATORS = Object.freeze({ + deepseek: Object.freeze({ + id: 'deepseek', + label: 'DeepSeek', + url: 'https://www.deepseek.com/', + modelPattern: /deepseek/i, + transports: new Set(['anthropic-messages']), + }), + claude: Object.freeze({ + id: 'claude', + label: 'Claude', + url: 'https://www.anthropic.com/claude', + modelPattern: /(?:^|[-_/])claude(?:$|[-_/])/i, + transports: new Set(['anthropic-messages']), + }), + codex: Object.freeze({ + id: 'codex', + label: 'OpenAI Codex', + url: 'https://developers.openai.com/codex/', + modelPattern: /(?:^|[-_/])codex(?:$|[-_/])/i, + transports: new Set(['openai-responses-codex']), + }), +}); + +function inferGenerator(modelName) { + const matches = Object.values(GENERATORS).filter((item) => item.modelPattern.test(modelName)); + if (matches.length !== 1) { + throw new Error(`Cannot infer generator from model ${modelName}`); + } + return matches[0].id; +} + +export function resolveGeneratorAttribution(rawId, rawModelName, transport) { + const modelName = String(rawModelName || '').trim(); + if (!modelName) throw new Error('A model id is required for generator attribution'); + const id = String(rawId || inferGenerator(modelName)).trim().toLowerCase(); + const generator = GENERATORS[id]; + if (!generator) throw new Error(`Unsupported generator: ${id}`); + if (!generator.modelPattern.test(modelName)) { + throw new Error(`Model ${modelName} does not match configured generator ${id}`); + } + if (!generator.transports.has(transport)) { + const required = [...generator.transports].join(' or '); + throw new Error(`Generator ${id} requires transport ${required}; received ${transport}`); + } + return Object.freeze({ + id: generator.id, + label: generator.label, + url: generator.url, + transport, + modelName, + automaticFooter: + `🤖 Generated by [${generator.label}](${generator.url}) ` + + 'as an automated first pass — not a maintainer decision.', + }); +} + +function visibleLine(rawLine) { + let line = rawLine.trim(); + let previous; + do { + previous = line; + line = line + .replace(/^>\s?/, '') + .replace(/^(?:[-+*]|\d+[.)])\s+/, '') + .replace(/^`{3,}[a-z0-9_-]*\s*/i, '') + .trim(); + } while (line !== previous); + + return line + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/[*_~`]/g, '') + .replace(/^🤖\s*/u, '') + .trim() + .toLowerCase(); +} + +function isSpoofLine(line) { + const visible = visibleLine(line); + return /^(?:automated first-pass|generated (?:with|by)|this is model-generated\b.*not (?:a )?maintainer decision)/iu.test(visible); +} + +function isOrphanable(line) { + return /^\s*(?:---+|`{3,}[a-z0-9_-]*)?\s*$/i.test(line); +} + +function sanitizeUntrustedBody(rawReply) { + const lines = String(rawReply || '').replace(/\r\n?/g, '\n').split('\n'); + const removed = lines.map(isSpoofLine); + + for (let index = 0; index < removed.length; index += 1) { + if (!removed[index]) continue; + for (const direction of [-1, 1]) { + let adjacent = index + direction; + while (adjacent >= 0 && adjacent < lines.length && isOrphanable(lines[adjacent])) { + removed[adjacent] = true; + adjacent += direction; + } + } + } + + return lines + .filter((_, index) => !removed[index]) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function formatAutomatedComment(reply, { kind, generator }) { + if (!['reply', 'review'].includes(kind)) { + throw new Error(`Unsupported first-pass kind: ${kind}`); + } + if (!generator?.automaticFooter) { + throw new Error('A trusted generator attribution is required'); + } + + const body = sanitizeUntrustedBody(reply); + if (!body) throw new Error('Cannot format an empty first-pass body'); + + const suffix = ['---', generator.automaticFooter].join('\n'); + const comment = [ + `🤖 Automated first-pass ${kind}`, + '', + body, + '', + suffix, + ].join('\n'); + + const markers = comment.match(/generated\s+(?:with|by)/giu) ?? []; + if (markers.length !== 1 || !comment.endsWith(suffix)) { + throw new Error('Trusted first-pass attribution invariant failed'); + } + return comment; +} + +const ALLOWED_ROOT_FILES = new Set([ + 'AGENTS.md', 'ARCHITECTURE.md', 'CLAUDE.md', 'CONTRIBUTING.md', + 'CHANGELOG.md', 'package.json', 'tsconfig.json', +]); +const ALLOWED_SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|ya?ml|txt)$/i; +const DENIED_COMPONENT = /^(?:\..+|(?:auth|oauth|token|credentials?|secrets?)(?:\..*)?|.*\.(?:pem|key|p12|pfx))$/i; + +function normalizedRepoPath(raw) { + const value = String(raw || '').trim(); + if (!value || value.includes('\0')) return null; + const normalized = value.replace(/\\/g, '/'); + if (/^[a-z]:\//i.test(normalized) || isAbsolute(normalized) || normalized.startsWith('/')) { + return null; + } + const components = normalized.split('/'); + if (components.some((component) => !component || component === '.' || component === '..')) { + return null; + } + return normalized; +} + +export function isAllowedRepoPath(raw) { + const normalized = normalizedRepoPath(raw); + if (!normalized) return false; + const components = normalized.split('/'); + if (components.some((component) => DENIED_COMPONENT.test(component))) return false; + if (ALLOWED_ROOT_FILES.has(normalized)) return true; + return components[0] === 'src' && components.length > 1 && ALLOWED_SOURCE_EXTENSION.test(normalized); +} + +function containedWithin(root, candidate) { + return candidate === root || candidate.startsWith(`${root}${sep}`); +} + +function boundedOption(value, hardMaximum) { + if (!Number.isInteger(value) || value <= 0) return hardMaximum; + return Math.min(value, hardMaximum); +} + +function decodeCompleteUtf8(buffer) { + const decoder = new TextDecoder('utf-8', { fatal: true }); + for (let end = buffer.length; end >= Math.max(0, buffer.length - 4); end -= 1) { + try { + return { text: decoder.decode(buffer.subarray(0, end)), bytes: end }; + } catch { + // A byte budget may cut the final UTF-8 code point; backtrack at most 4 bytes. + } + } + return null; +} + +export function createRepoReadSession(options = {}) { + const repoRoot = realpathSync(resolve(String(options.repoRoot || process.cwd()))); + const maxFiles = boundedOption(options.maxFiles, 6); + const fileBudget = boundedOption(options.fileBudget, 16_000); + const totalBudget = boundedOption(options.totalBudget, 60_000); + const seenPaths = new Set(); + let filesRead = 0; + let totalBytes = 0; + + function read(wanted) { + const files = []; + for (const raw of Array.isArray(wanted) ? wanted : []) { + const relativePath = normalizedRepoPath(raw); + if (!relativePath || seenPaths.has(relativePath) || !isAllowedRepoPath(relativePath)) continue; + seenPaths.add(relativePath); + if (filesRead >= maxFiles || totalBytes >= totalBudget) break; + + let descriptor; + try { + const lexicalPath = resolve(repoRoot, relativePath); + if (!containedWithin(repoRoot, lexicalPath)) continue; + + let prefix = repoRoot; + let leafStat; + for (const component of relativePath.split('/')) { + prefix = join(prefix, component); + const currentStat = lstatSync(prefix); + if (currentStat.isSymbolicLink()) { + leafStat = null; + break; + } + leafStat = currentStat; + } + if (!leafStat?.isFile()) continue; + + const realFile = realpathSync(lexicalPath); + if (!containedWithin(repoRoot, realFile)) continue; + + descriptor = openSync(lexicalPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); + const openedStat = fstatSync(descriptor); + if ( + !openedStat.isFile() || + openedStat.dev !== leafStat.dev || + openedStat.ino !== leafStat.ino + ) continue; + + const remaining = totalBudget - totalBytes; + const requestedBytes = Math.min(fileBudget, remaining); + if (requestedBytes <= 0) break; + const buffer = Buffer.alloc(requestedBytes); + const bytesRead = readSync(descriptor, buffer, 0, requestedBytes, 0); + const decoded = decodeCompleteUtf8(buffer.subarray(0, bytesRead)); + if (!decoded) continue; + + filesRead += 1; + totalBytes += decoded.bytes; + files.push({ + path: relativePath, + text: decoded.text, + bytes: decoded.bytes, + }); + } catch { + // Missing, unreadable, replaced, or unsafe files are skipped without path disclosure. + } finally { + if (descriptor !== undefined) { + try { + closeSync(descriptor); + } catch { + // Nothing useful can be done if closing an already-failed descriptor fails. + } + } + } + } + + return { + text: files.map((file) => `# ${file.path}\n${file.text}`).join('\n\n'), + files, + totalBytes, + }; + } + + return Object.freeze({ + read, + snapshot: () => Object.freeze({ filesRead, totalBytes }), + }); +} + +export function validateFirstPassEnvironment(env) { + const required = ['GH_TOKEN', 'REPO', 'EVENT_KIND', 'ITEM_NUMBER', 'ANTHROPIC_API_KEY']; + const missing = required.filter((name) => !String(env?.[name] || '').trim()); + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(', ')}`); + } + + const repo = String(env.REPO).trim(); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + throw new Error('REPO must use owner/name format'); + } + const eventKind = String(env.EVENT_KIND).trim(); + if (!['issue', 'pr'].includes(eventKind)) { + throw new Error('EVENT_KIND must be issue or pr'); + } + const itemNumber = Number(env.ITEM_NUMBER); + if (!Number.isInteger(itemNumber) || itemNumber <= 0) { + throw new Error('ITEM_NUMBER must be a positive integer'); + } + if (eventKind === 'pr' && !String(env.DIFF_FILE || '').trim()) { + throw new Error('DIFF_FILE is required for pull requests'); + } + const [owner, name] = repo.split('/'); + return Object.freeze({ + owner, + repo: name, + itemNumber, + isPr: eventKind === 'pr', + diffFile: eventKind === 'pr' ? String(env.DIFF_FILE).trim() : undefined, + }); +} + +function tag(text, name) { + const match = String(text || '').match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i')); + return match ? match[1].trim() : null; +} + +export function parseFirstPassResponse(raw) { + const text = String(raw || ''); + const taggedReply = tag(text, 'reply'); + const fallback = text.replace(/[\s\S]*?<\/control>/i, '').trim(); + const reply = (taggedReply !== null ? taggedReply : fallback).trim(); + let control = { answerable: true, want_files: [] }; + const taggedControl = tag(text, 'control'); + if (taggedControl) { + try { + const value = JSON.parse(taggedControl); + control = { + answerable: value.answerable !== false, + want_files: Array.isArray(value.want_files) ? value.want_files : [], + }; + } catch { + // Malformed control never grants additional file reads. + } + } + return { reply, ...control }; +} + +export function chooseFinalReply(cheap, pro) { + if (pro && typeof pro.reply === 'string' && pro.reply.trim() !== '') { + return { reply: pro.reply.trim(), generator: pro.generator }; + } + if (cheap && typeof cheap.reply === 'string' && cheap.reply.trim() !== '') { + return { reply: cheap.reply.trim(), generator: cheap.generator }; + } + throw new Error('No non-empty model reply'); +} diff --git a/.github/scripts/first-pass.mjs b/.github/scripts/first-pass.mjs index a1c54a07..cbdb92ff 100644 --- a/.github/scripts/first-pass.mjs +++ b/.github/scripts/first-pass.mjs @@ -1,119 +1,114 @@ -// Controlled first-pass reply for issues / PRs — still NOT an autonomous agent. -// It uses no agent tools and never runs anything from the issue/PR, so a -// malicious public issue cannot steer it into misusing the token or leaking the -// key (prompt-injection safe). Flow: -// -// Tier 1 cheap model (CCU_BOT_MODEL, default deepseek-v4-flash) answers from -// the project docs (+ PR diff). It also returns a signal. -// Tier 2 if it says it can't answer from the docs, escalate to a stronger -// model (CCU_BOT_MODEL_PRO, default deepseek-v4-pro) with thinking on, -// and let it name a few repo source files — WHICH THE SCRIPT reads and -// validates (no tool call, no traversal, size-capped). It re-answers. -// Tier 3 if it still can't, its reply politely asks for the missing info / -// logs and (when fields are missing) suggests the issue template. -// -// Then post ONE comment. The model only ever READS repo-relative text files the -// script hand-picks; it never executes anything. -// -// Env in: GH_TOKEN, REPO (owner/name), EVENT_KIND (issue|pr), ITEM_NUMBER, -// ITEM_TITLE, ITEM_BODY, DIFF_FILE (pr only), ANTHROPIC_API_KEY, -// ANTHROPIC_BASE_URL (third-party OK), CCU_BOT_MODEL, CCU_BOT_MODEL_PRO. +// Controlled, comment-only first pass for new issues and external pull requests. +// Public text remains untrusted: this fixed runner has no agent tools, executes +// no contributor code, reads only validated base-repository text, and posts at +// most one comment. Model output can still be wrong and is never authoritative. -import { readFileSync, existsSync, statSync } from 'node:fs'; -import { resolve, sep } from 'node:path'; +import { + closeSync, + constants, + fstatSync, + openSync, + readSync, +} from 'node:fs'; +import { resolve } from 'node:path'; +import { + chooseFinalReply, + createRepoReadSession, + formatAutomatedComment, + parseFirstPassResponse, + resolveGeneratorAttribution, + validateFirstPassEnvironment, +} from './first-pass-lib.mjs'; +const TRANSPORT = 'anthropic-messages'; +const REPO_ROOT = resolve(process.cwd()); const env = process.env; const base = (env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, ''); const model = env.CCU_BOT_MODEL || 'deepseek-v4-flash'; const modelPro = env.CCU_BOT_MODEL_PRO || 'deepseek-v4-pro'; -const isPr = env.EVENT_KIND === 'pr'; -const num = env.ITEM_NUMBER; -const kind = isPr ? 'review' : 'reply'; -const fail = (msg) => { - console.error(msg); +const fail = (message) => { + console.error(message); process.exit(1); }; -const readDoc = (path, max = 12000) => { - try { - return existsSync(path) ? readFileSync(path, 'utf8').slice(0, max) : ''; - } catch { - return ''; - } -}; -const docs = - `# ARCHITECTURE.md\n${readDoc('ARCHITECTURE.md')}\n\n` + - `# CLAUDE.md\n${readDoc('CLAUDE.md')}\n\n` + - `# CONTRIBUTING.md\n${readDoc('CONTRIBUTING.md')}`; -let diff = ''; -if (isPr && env.DIFF_FILE) { - diff = readDoc(env.DIFF_FILE, 40000); +let preflight; +let cheapGenerator; +let proGenerator; +try { + preflight = validateFirstPassEnvironment(env); + cheapGenerator = resolveGeneratorAttribution(env.CCU_BOT_GENERATOR, model, TRANSPORT); + proGenerator = resolveGeneratorAttribution(env.CCU_BOT_GENERATOR_PRO, modelPro, TRANSPORT); +} catch (error) { + fail(`Invalid first-pass configuration: ${error.message}`); } -// --- Safe repo-file reader: only repo-relative text files, no traversal ------ -const REPO_ROOT = resolve(process.cwd()); -const ALLOWED_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|json|md|ya?ml|txt)$/i; -const readRepoFiles = (wanted) => { - const out = []; - let budget = 60000; // total bytes across all requested files - for (const raw of (wanted || []).slice(0, 6)) { - const rel = String(raw || '').trim().replace(/^\.?\//, ''); - if (!rel || rel.includes('..') || rel.includes('\0') || !ALLOWED_EXT.test(rel)) { - continue; - } - const abs = resolve(REPO_ROOT, rel); - if (abs !== REPO_ROOT && !abs.startsWith(REPO_ROOT + sep)) { - continue; // escaped the repo root - } - try { - if (!existsSync(abs) || !statSync(abs).isFile()) { - continue; +const { isPr, itemNumber: num, owner, repo } = preflight; +const kind = isPr ? 'review' : 'reply'; +const repoReader = createRepoReadSession({ repoRoot: REPO_ROOT }); +const docs = repoReader.read([ + 'AGENTS.md', // rendered in the prompt as "# AGENTS.md" + 'ARCHITECTURE.md', + 'CLAUDE.md', + 'CONTRIBUTING.md', +]).text; + +function readBoundedFixedFile(path, maxBytes) { + let descriptor; + try { + descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); + if (!fstatSync(descriptor).isFile()) return ''; + const buffer = Buffer.alloc(maxBytes); + const bytesRead = readSync(descriptor, buffer, 0, maxBytes, 0); + const bytes = buffer.subarray(0, bytesRead); + const decoder = new TextDecoder('utf-8', { fatal: true }); + for (let end = bytes.length; end >= Math.max(0, bytes.length - 4); end -= 1) { + try { + return decoder.decode(bytes.subarray(0, end)); + } catch { + // Backtrack if the byte cap split the final UTF-8 code point. } - const body = readFileSync(abs, 'utf8').slice(0, Math.min(16000, budget)); - budget -= body.length; - out.push(`# ${rel}\n${body}`); - if (budget <= 0) { - break; + } + return ''; + } catch { + return ''; + } finally { + if (descriptor !== undefined) { + try { + closeSync(descriptor); + } catch { + // The caller fails closed on an empty result. } - } catch { - /* skip unreadable */ } } - return out.join('\n\n'); -}; +} -// --- System prompt ----------------------------------------------------------- -const buildSystem = (final) => - [ - 'You are the ClaudeCodeUsage repository assistant, replying via Claude Code tooling.', - "The underlying model may be a third-party Anthropic-format model, so do not claim to be Anthropic's Claude specifically.", - `Write ONE concise, concrete first-pass ${kind} for the ${isPr ? 'pull request' : 'issue'} below.`, - isPr - ? '- Focus on correctness risks, convention/i18n/CHANGELOG gaps, and merge-readiness, grounded in the diff + docs.' - : '- Identify the request, where in the architecture it relates, and a concrete direction or a specific clarifying question.', - '- Answer ONLY from the provided material (docs, diff, and any source files supplied). NEVER guess or invent code facts.', - '- Reply in the SAME language the author wrote in. Be specific; no filler, no AI-flavoured padding.', - '', - 'Format your reply markdown in this order so a human can skim it:', - `- First line: "🤖 Automated first-pass ${kind} (via Claude Code)".`, - '- Then **TL;DR / 结论**: one or two sentences — the answer or the current status.', - '- Then **分析 / Analysis**: briefly why / what is happening / where it sits.', - '- Then **建议 / Suggested next step(s)**: a concrete direction, a specific question, or exactly what info is needed.', - `- Last line, on its own: "_This is model-generated from the repository docs and the ${isPr ? 'PR diff' : 'issue'} — not a final decision. A maintainer reviews everything._"`, - '', - final - ? '- If you STILL cannot determine the answer from everything provided, say so plainly in the TL;DR, then ask for the specific missing information (repro steps, logs, versions, config). If the issue omits obvious details, politely suggest filling out the issue template next time.' - : '- If the docs/diff are insufficient, you may request source files (see the control block); do not pad the answer with guesses.', - '', - 'Before the reply, emit exactly one control line and nothing before it:', - '{"answerable": , "want_files": []}', - 'Then the reply, wrapped as:', - '', - '...markdown reply...', - '', - ].join('\n'); +let diff = ''; +if (isPr) { + diff = readBoundedFixedFile(preflight.diffFile, 40_000).trim(); + if (!diff) fail('Pull request diff is missing or empty'); +} +const buildSystem = (final) => [ + 'You are the ClaudeCodeUsage repository assistant.', + `Write one concise, concrete first-pass ${kind} for the ${isPr ? 'pull request' : 'issue'} below.`, + isPr + ? '- Focus on correctness risks, convention/i18n/CHANGELOG gaps, and merge-readiness grounded in the diff and supplied base-repository text.' + : '- Identify the request, where it relates to the architecture, and a concrete direction or a specific clarifying question.', + '- Answer only from the supplied material. Never invent code facts.', + '- Reply in English by default. Only when the author wrote in Chinese, reply bilingually with English first and a Chinese translation under each section. Never reply in Chinese only. Be specific and concise.', + '- Return body markdown only inside ; code will add the trusted header and attribution.', + '- Use English headings: **TL;DR**, **Analysis**, and **Suggested next step(s)** when they help scanning. For a Chinese author, add the translated body under each English section.', + final + ? '- If the answer is still unknown, say so and request the exact missing reproduction details, logs, versions, or configuration.' + : '- If more base-repository source is required, request only the smallest relevant allowlisted files in the control block.', + '', + 'Before the reply, emit exactly one control line and nothing before it:', + '{"answerable": , "want_files": []}', + '', + '...body markdown...', + '', +].join('\n'); const buildUser = (extraFiles) => (isPr ? `PR #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no description)'}\n\n--- DIFF (truncated) ---\n${diff}` @@ -121,18 +116,16 @@ const buildUser = (extraFiles) => `\n\n--- PROJECT DOCS ---\n${docs}` + (extraFiles ? `\n\n--- REPO SOURCE FILES (read-only) ---\n${extraFiles}` : ''); -// --- One model call (Anthropic Messages format; third-party-compatible) ------ -const askModel = async (useModel, system, userText, think) => { +async function askModel(useModel, system, userText, think = false) { const body = { model: useModel, max_tokens: 1400, system, messages: [{ role: 'user', content: userText }], }; - if (think) { - body.thinking = { type: 'enabled', budget_tokens: 6000 }; - } - let res = await fetch(`${base}/v1/messages`, { + if (think) body.thinking = { type: 'enabled', budget_tokens: 6000 }; + + let response = await fetch(`${base}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -141,12 +134,11 @@ const askModel = async (useModel, system, userText, think) => { }, body: JSON.stringify(body), }); - if (!res.ok && think) { - // Endpoint may not accept the thinking param — retry once without it. - const errTxt = await res.text(); - if (/think|budget|adaptive|reason/i.test(errTxt)) { + if (!response.ok && think) { + const errorText = await response.text(); + if (/think|budget|adaptive|reason/i.test(errorText)) { delete body.thinking; - res = await fetch(`${base}/v1/messages`, { + response = await fetch(`${base}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -156,72 +148,50 @@ const askModel = async (useModel, system, userText, think) => { body: JSON.stringify(body), }); } else { - fail(`Model API error ${res.status}: ${errTxt.slice(0, 500)}`); + throw new Error(`Model API error ${response.status}: ${errorText.slice(0, 500)}`); } } - if (!res.ok) { - fail(`Model API error ${res.status}: ${(await res.text()).slice(0, 500)}`); + if (!response.ok) { + throw new Error(`Model API error ${response.status}: ${(await response.text()).slice(0, 500)}`); } - const data = await res.json(); + const data = await response.json(); return (data.content || []) - .filter((b) => b && b.type === 'text' && typeof b.text === 'string') - .map((b) => b.text) + .filter((block) => block?.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) .join('') .trim(); -}; - -const tag = (text, name) => { - const m = text.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i')); - return m ? m[1].trim() : null; -}; -const parse = (raw) => { - const replyBody = tag(raw, 'reply') || raw.replace(/[\s\S]*?<\/control>/i, '').trim(); - let control = { answerable: true, want_files: [] }; - const ctl = tag(raw, 'control'); - if (ctl) { - try { - const j = JSON.parse(ctl); - control = { - answerable: j.answerable !== false, - want_files: Array.isArray(j.want_files) ? j.want_files : [], - }; - } catch { - /* keep default (treat as answerable → no escalation) */ - } - } - return { reply: replyBody, ...control }; -}; +} -let reply = ''; +let selected; try { - // Tier 1: cheap model, docs only. - const first = parse(await askModel(model, buildSystem(false), buildUser(''))); - reply = first.reply; + const first = parseFirstPassResponse(await askModel(model, buildSystem(false), buildUser(''))); + let proCandidate; - // Tier 2/3: escalate to the stronger model with thinking + the source files - // it named (read & validated by the script), and take its final reply. if (!first.answerable) { - const extra = readRepoFiles(first.want_files); - const second = parse(await askModel(modelPro, buildSystem(true), buildUser(extra), true)); - if (second.reply) { - reply = second.reply; - } + const extra = repoReader.read(first.want_files).text; + const second = parseFirstPassResponse( + await askModel(modelPro, buildSystem(true), buildUser(extra), true), + ); + proCandidate = { reply: second.reply, generator: proGenerator }; } -} catch (e) { - fail(`Model call failed: ${e.message}`); + selected = chooseFinalReply( + { reply: first.reply, generator: cheapGenerator }, + proCandidate, + ); +} catch (error) { + fail(`Model call failed: ${error.message}`); } -if (!reply) { - // A blank reply is a soft no-op, not a failure: posting nothing is fine, and - // we don't want a red ✗ check on the PR/issue just because the model returned - // no text (e.g. a huge or self-referential diff). - console.warn('Empty model reply — nothing to post.'); - process.exit(0); +const { reply, generator: finalGenerator } = selected; + +let commentBody; +try { + commentBody = formatAutomatedComment(reply, { kind, generator: finalGenerator }); +} catch (error) { + fail(`First-pass formatting failed: ${error.message}`); } -// Post ONE comment via the GitHub API (issues + PRs share this endpoint). -const [owner, repo] = (env.REPO || '/').split('/'); try { - const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${num}/comments`, { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${num}/comments`, { method: 'POST', headers: { authorization: `Bearer ${env.GH_TOKEN}`, @@ -229,12 +199,12 @@ try { 'content-type': 'application/json', 'user-agent': 'ccu-bot', }, - body: JSON.stringify({ body: reply }), + body: JSON.stringify({ body: commentBody }), }); - if (!res.ok) { - fail(`Comment post failed ${res.status}: ${(await res.text()).slice(0, 300)}`); + if (!response.ok) { + fail(`Comment post failed ${response.status}: ${(await response.text()).slice(0, 300)}`); } -} catch (e) { - fail(`Comment post failed: ${e.message}`); +} catch (error) { + fail(`Comment post failed: ${error.message}`); } console.log(`Posted first-pass ${kind} on #${num}.`); diff --git a/.github/scripts/first-pass.test.mjs b/.github/scripts/first-pass.test.mjs new file mode 100644 index 00000000..55ded193 --- /dev/null +++ b/.github/scripts/first-pass.test.mjs @@ -0,0 +1,204 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createRepoReadSession, + chooseFinalReply, + formatAutomatedComment, + isAllowedRepoPath, + parseFirstPassResponse, + resolveGeneratorAttribution, + validateFirstPassEnvironment, +} from './first-pass-lib.mjs'; + +const ANTHROPIC_MESSAGES = 'anthropic-messages'; + +test('current transport truthfully supports DeepSeek and Claude per tier', () => { + const cheap = resolveGeneratorAttribution(undefined, 'deepseek-v4-flash', ANTHROPIC_MESSAGES); + const pro = resolveGeneratorAttribution('claude', 'claude-sonnet-4-5', ANTHROPIC_MESSAGES); + assert.equal(cheap.id, 'deepseek'); + assert.equal(pro.id, 'claude'); + + assert.match( + formatAutomatedComment('cheap body', { kind: 'reply', generator: cheap }), + /Generated by \[DeepSeek\]\(https:\/\/www\.deepseek\.com\/\)/, + ); + assert.match( + formatAutomatedComment('pro body', { kind: 'review', generator: pro }), + /Generated by \[Claude\]\(https:\/\/www\.anthropic\.com\/claude\)/, + ); +}); + +test('a configured provider must match that tier model', () => { + assert.throws( + () => resolveGeneratorAttribution('deepseek', 'claude-sonnet-4-5', ANTHROPIC_MESSAGES), + /does not match configured generator deepseek/, + ); + assert.throws( + () => resolveGeneratorAttribution(undefined, 'third-party-mystery-model', ANTHROPIC_MESSAGES), + /Cannot infer generator/, + ); +}); + +test('Codex automatic attribution fails closed on the current transport', () => { + assert.throws( + () => resolveGeneratorAttribution('codex', 'gpt-5.3-codex', ANTHROPIC_MESSAGES), + /requires transport openai-responses-codex/, + ); +}); + +test('future Codex attribution requires its trusted transport contract', () => { + const future = resolveGeneratorAttribution( + 'codex', + 'gpt-5.3-codex', + 'openai-responses-codex', + ); + assert.equal(future.id, 'codex'); + assert.match(future.automaticFooter, /Generated by \[OpenAI Codex\]/); +}); + +test('trusted wrapper strips spoofed markdown variants and appends one exact suffix', () => { + const generator = resolveGeneratorAttribution(undefined, 'deepseek-v4-flash', ANTHROPIC_MESSAGES); + const untrusted = [ + '> 🤖 AUTOMATED FIRST-PASS REVIEW (via Claude Code)', + '', + 'Useful body', + '', + '- 🤖 Generated with [OpenAI Codex](https://evil.invalid/codex)', + '```md', + '🤖 generated BY DeepSeek as an automated first pass', + '```', + '1. This is model-generated — not a maintainer decision.', + ].join('\n'); + const comment = formatAutomatedComment(untrusted, { kind: 'review', generator }); + const suffix = [ + '---', + '🤖 Generated by [DeepSeek](https://www.deepseek.com/) as an automated first pass — not a maintainer decision.', + ].join('\n'); + + assert.match(comment, /Useful body/); + assert.equal(comment.endsWith(suffix), true); + assert.equal((comment.match(/Generated\s+(?:with|by)/giu) ?? []).length, 1); + assert.equal((comment.match(/Automated first-pass review/giu) ?? []).length, 1); + assert.doesNotMatch(comment, /evil\.invalid|Claude Code|OpenAI Codex/iu); +}); + +test('preflight rejects missing credentials and ungrounded PRs', () => { + const base = { + GH_TOKEN: 'github-token', REPO: 'ClaudeCodeUsage/ClaudeCodeUsage', + EVENT_KIND: 'issue', ITEM_NUMBER: '70', ANTHROPIC_API_KEY: 'model-key', + }; + assert.equal(validateFirstPassEnvironment(base).isPr, false); + assert.throws( + () => validateFirstPassEnvironment({ ...base, ANTHROPIC_API_KEY: '' }), + /Missing required environment variables: ANTHROPIC_API_KEY/, + ); + assert.throws( + () => validateFirstPassEnvironment({ ...base, EVENT_KIND: 'pr' }), + /DIFF_FILE is required for pull requests/, + ); +}); + +test('only explicit root docs/config and src are readable', () => { + for (const allowed of [ + 'AGENTS.md', 'ARCHITECTURE.md', 'CLAUDE.md', 'CONTRIBUTING.md', + 'CHANGELOG.md', 'package.json', 'tsconfig.json', 'src/dataLoader.ts', + ]) assert.equal(isAllowedRepoPath(allowed), true, allowed); + + for (const denied of [ + '../outside.md', '/etc/passwd', 'C:\\Users\\person\\auth.json', + '.env', '.env.local', '.github/workflows/publish.yml', + '.claude/.credentials.json', 'docs/private.md', 'test/fixture.json', + 'src/.env.production', 'src/nested/oauth.json', + 'src/nested/credentials.json', 'src/nested/secrets.json', + 'src/nested/private.pem', 'src/nested/token.txt', 'src/code.bin', + ]) assert.equal(isAllowedRepoPath(denied), false, denied); +}); + +test('repo reader shares stable-dedup, byte budgets and symlink denial for the run', (t) => { + const repoRoot = mkdtempSync(join(tmpdir(), 'ccu-reader-')); + const outsideRoot = mkdtempSync(join(tmpdir(), 'ccu-reader-outside-')); + t.after(() => { + rmSync(repoRoot, { recursive: true, force: true }); + rmSync(outsideRoot, { recursive: true, force: true }); + }); + + mkdirSync(join(repoRoot, 'src', 'real-dir'), { recursive: true }); + for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'CLAUDE.md', 'CONTRIBUTING.md']) { + writeFileSync(join(repoRoot, name), `${name}\n`, 'utf8'); + } + for (const name of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) { + writeFileSync(join(repoRoot, 'src', `${name}.txt`), `${name}-body\n`, 'utf8'); + } + writeFileSync(join(repoRoot, 'src', 'huge.txt'), 'x'.repeat(20_000), 'utf8'); + writeFileSync(join(repoRoot, 'src', 'unicode.txt'), '中文🙂'.repeat(8_000), 'utf8'); + writeFileSync(join(repoRoot, 'src', 'real-dir', 'inside.txt'), 'inside regular', 'utf8'); + writeFileSync(join(outsideRoot, 'secret.txt'), 'outside secret', 'utf8'); + symlinkSync(join(outsideRoot, 'secret.txt'), join(repoRoot, 'src', 'link.txt')); + symlinkSync(outsideRoot, join(repoRoot, 'src', 'external-dir')); + symlinkSync(join(repoRoot, 'src', 'real-dir'), join(repoRoot, 'src', 'internal-link')); + + const ordering = createRepoReadSession({ repoRoot }); + const ordered = ordering.read([ + 'src/a.txt', 'src/a.txt', 'src/b.txt', 'src/c.txt', 'src/d.txt', + 'src/e.txt', 'src/f.txt', 'src/g.txt', 'src/link.txt', + ]); + assert.deepEqual( + ordered.files.map((file) => file.path), + ['src/a.txt', 'src/b.txt', 'src/c.txt', 'src/d.txt', 'src/e.txt', 'src/f.txt'], + ); + + const capped = createRepoReadSession({ repoRoot }); + const result = capped.read([ + 'src/huge.txt', 'src/unicode.txt', 'src/a.txt', 'src/b.txt', + 'src/c.txt', 'src/d.txt', 'src/link.txt', + 'src/external-dir/secret.txt', 'src/internal-link/inside.txt', + 'src/nested/oauth.json', '../outside.md', + ]); + assert.ok(result.files.every((file) => file.bytes <= 16_000)); + assert.ok(result.totalBytes <= 60_000); + assert.equal(result.files.find((file) => file.path === 'src/huge.txt')?.bytes, 16_000); + assert.equal(result.files.some((file) => file.text.endsWith('\uFFFD')), false); + assert.equal(result.text.includes('outside secret'), false); + assert.equal(result.files.some((file) => /link|oauth|outside/.test(file.path)), false); + + const perRun = createRepoReadSession({ repoRoot }); + const grounding = perRun.read(['AGENTS.md', 'ARCHITECTURE.md', 'CLAUDE.md', 'CONTRIBUTING.md']); + const requested = perRun.read([ + 'AGENTS.md', + 'src/a.txt', 'src/b.txt', 'src/c.txt', 'src/d.txt', 'src/e.txt', 'src/f.txt', + ]); + assert.equal(grounding.files.length, 4); + assert.ok(requested.files.length <= 2); + assert.ok(perRun.snapshot().filesRead <= 6); + assert.ok(perRun.snapshot().totalBytes <= 60_000); +}); + +test('empty tagged pro reply preserves the cheap body and attribution', () => { + const cheap = resolveGeneratorAttribution('deepseek', 'deepseek-v4-flash', 'anthropic-messages'); + const pro = resolveGeneratorAttribution('claude', 'claude-sonnet-4-6', 'anthropic-messages'); + const parsedPro = parseFirstPassResponse('{"answerable":true} '); + assert.equal(parsedPro.reply, ''); + const selected = chooseFinalReply({ reply: 'cheap body', generator: cheap }, { + reply: parsedPro.reply, + generator: pro, + }); + assert.equal(selected.reply, 'cheap body'); + assert.equal(selected.generator.id, 'deepseek'); +}); + +test('no non-empty tier can never reach the GitHub formatter', () => { + const cheap = resolveGeneratorAttribution('deepseek', 'deepseek-v4-flash', 'anthropic-messages'); + assert.throws( + () => chooseFinalReply({ reply: ' ', generator: cheap }), + /No non-empty model reply/, + ); +}); diff --git a/.github/workflows/issue-first-pass.yml b/.github/workflows/issue-first-pass.yml index f93bfdf1..2387cfd3 100644 --- a/.github/workflows/issue-first-pass.yml +++ b/.github/workflows/issue-first-pass.yml @@ -1,15 +1,15 @@ -# Auto first-pass reply on every NEW issue — a CONTROLLED single model call -# (not an autonomous agent). See .github/scripts/first-pass.mjs: it reads the -# project docs, asks the model once, and posts one comment. No tools run, so a -# malicious public issue can't steer it into misusing the token or key. +# Auto first-pass reply on every NEW issue — a controlled, comment-only model +# workflow. Public text remains untrusted; the fixed runner has no tools or +# credentials beyond its model/comment calls, reads only validated base-repo +# text, and model output can still be wrong. # # INERT until the owner sets: # AUTOMATION_ENABLED = 'true' (repo variable, kill switch) # ANTHROPIC_API_KEY (repo secret — official or third-party key) # Optional repo variables: ANTHROPIC_BASE_URL (third-party Anthropic-format -# endpoint), CCU_BOT_MODEL (cheap tier, default deepseek-v4-flash), -# CCU_BOT_MODEL_PRO (escalation tier used when the cheap tier can't answer from -# the docs, default deepseek-v4-pro). +# endpoint), CCU_BOT_MODEL / CCU_BOT_MODEL_PRO and matching +# CCU_BOT_GENERATOR / CCU_BOT_GENERATOR_PRO. Generator values are optional for +# recognized model IDs; explicit values are deepseek or claude. name: Issue first-pass (auto) on: @@ -31,7 +31,7 @@ jobs: timeout-minutes: 8 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 1 @@ -47,4 +47,6 @@ jobs: ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} CCU_BOT_MODEL: ${{ vars.CCU_BOT_MODEL }} CCU_BOT_MODEL_PRO: ${{ vars.CCU_BOT_MODEL_PRO }} + CCU_BOT_GENERATOR: ${{ vars.CCU_BOT_GENERATOR }} + CCU_BOT_GENERATOR_PRO: ${{ vars.CCU_BOT_GENERATOR_PRO }} run: node .github/scripts/first-pass.mjs diff --git a/.github/workflows/pr-first-pass.yml b/.github/workflows/pr-first-pass.yml index fc9cdf17..8c2db602 100644 --- a/.github/workflows/pr-first-pass.yml +++ b/.github/workflows/pr-first-pass.yml @@ -1,11 +1,14 @@ # Auto first-pass review on every NEW pull request — a CONTROLLED single model # call (not an autonomous agent). Uses pull_request_target for the token but # NEVER runs the PR's code: it checks out the BASE repo, fetches the diff via the -# API, asks the model once, and posts one comment. No tools → injection-safe. +# API, asks the model, and posts one comment. Public text remains untrusted; the +# fixed runner has no tools, reads only validated base-repo text, and model +# output can still be wrong. # # INERT until AUTOMATION_ENABLED = 'true' + the ANTHROPIC_API_KEY secret. -# Optional repo vars: ANTHROPIC_BASE_URL, CCU_BOT_MODEL (cheap tier, default -# deepseek-v4-flash), CCU_BOT_MODEL_PRO (escalation tier, default deepseek-v4-pro). +# Optional repo vars: ANTHROPIC_BASE_URL, CCU_BOT_MODEL / CCU_BOT_MODEL_PRO and +# matching CCU_BOT_GENERATOR / CCU_BOT_GENERATOR_PRO. Generator values are +# optional for recognized model IDs; explicit values are deepseek or claude. name: PR first-pass (auto) on: @@ -34,14 +37,16 @@ jobs: steps: # Base repo only — never checks out or runs the PR head with the token. - name: Checkout base repository - uses: actions/checkout@v6 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 1 - name: Fetch PR diff (via API, no code run) env: GH_TOKEN: ${{ github.token }} - run: gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} > /tmp/pr.diff || true + run: | + gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} > /tmp/pr.diff + test -s /tmp/pr.diff - name: First-pass review env: @@ -56,4 +61,6 @@ jobs: ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} CCU_BOT_MODEL: ${{ vars.CCU_BOT_MODEL }} CCU_BOT_MODEL_PRO: ${{ vars.CCU_BOT_MODEL_PRO }} + CCU_BOT_GENERATOR: ${{ vars.CCU_BOT_GENERATOR }} + CCU_BOT_GENERATOR_PRO: ${{ vars.CCU_BOT_GENERATOR_PRO }} run: node .github/scripts/first-pass.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 31e43e66..31a69569 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,8 +13,9 @@ # - publishes to the VS Code Marketplace (VSCE_PAT) and Open VSX (OVSX_PAT), # - attaches the .vsix to the Release. # -# Trigger manually from Actions → Publish Extension → Run workflow -# (workflow_dispatch — useful to retry a failed Marketplace/Open VSX publish). +# Trigger manually from Actions → Publish Extension → Run workflow to retry a +# failed registry. Supply the existing release tag and select only the registry +# that needs a retry; the workflow checks out and stamps that exact tag. name: Publish Extension @@ -22,6 +23,21 @@ on: release: types: [published] workflow_dispatch: + inputs: + tag: + description: Existing release tag to retry (for example, v2.2.1) + required: true + type: string + publish_vscode: + description: Publish to VS Code Marketplace + required: true + default: true + type: boolean + publish_open_vsx: + description: Publish to Open VSX Registry + required: true + default: true + type: boolean permissions: contents: write # needed by softprops/action-gh-release to attach the .vsix asset @@ -29,9 +45,20 @@ permissions: jobs: publish: runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: refs/tags/${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Verify release tag checkout + run: | + HEAD_COMMIT="$(git rev-parse HEAD)" + TAG_COMMIT="$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" + test "$HEAD_COMMIT" = "$TAG_COMMIT" - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -45,9 +72,12 @@ jobs: # into package.json so vsce packages the right number — no human bump, no # tag-vs-package.json mismatch possible. Build-time only (not committed). - name: Set version from release tag - if: github.event_name == 'release' run: | - TAG="${{ github.event.release.tag_name }}" + TAG="$RELEASE_TAG" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: $TAG" >&2 + exit 1 + fi VER="${TAG#v}" npm version "$VER" --no-git-tag-version --allow-same-version echo "package.json version set to $VER (from release $TAG)" @@ -59,11 +89,13 @@ jobs: run: npx -y @vscode/vsce package --out claude-code-usage.vsix - name: Publish to VS Code Marketplace + if: github.event_name == 'release' || inputs.publish_vscode run: npx -y @vscode/vsce publish --packagePath claude-code-usage.vsix --pat "$VSCE_PAT" env: VSCE_PAT: ${{ secrets.VSCE_PAT }} - name: Publish to Open VSX Registry + if: github.event_name == 'release' || inputs.publish_open_vsx run: npx -y ovsx publish claude-code-usage.vsix --pat "$OVSX_PAT" env: OVSX_PAT: ${{ secrets.OVSX_PAT }} diff --git a/.gitignore b/.gitignore index 6d3cf5f4..07d38c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,9 @@ CLAUDE.local.md # ARCHITECTURE.md is the technical map the @ccu-bot automation + contributors # read (product strategy is kept out of it, in docs/ARCHITECTURE-private.md). docs/ + +# Agent-local state and in-repository worktrees are never source artifacts. +.claude/ +.agents/ +.codex/ +.worktrees/ diff --git a/.vscodeignore b/.vscodeignore index e5c279f5..59553de0 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,9 +1,14 @@ .vscode/** .vscode-test/** .claude/** +.agents/** +.codex/** +.worktrees/** .github/** src/** out/test/** +AGENTS.md +AGENTS.zh-CN.md CLAUDE.md CLAUDE.local.md CONTRIBUTING.md @@ -25,4 +30,11 @@ node_modules/** tsconfig.json tslint.json **/*.ts.map -**/*.js.map \ No newline at end of file +**/*.js.map +**/.env +**/.env.* +**/secrets.json +**/*.pem +**/*.key +**/*.p12 +**/*.pfx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1c85cf63 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# AGENTS.md + +Repository guidance for OpenAI Codex and other agentic contributors. A faithful +Simplified-Chinese review copy lives in [AGENTS.zh-CN.md](AGENTS.zh-CN.md). + +## Product identity and scope + +- Claude Code Usage is a VS Code extension that reads Claude Code's local JSONL + usage logs and displays exact token totals, cost estimates, and OAuth quota. +- The product remains Claude-only in v2.2.1. Codex is a development tool in + this release, not a completed usage provider or a reason to rename the product. +- 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 conversation JSONL files. Treat credential handling as security-sensitive + and preserve the existing reviewed behavior. +- Add no new runtime dependencies in v2.2.1. + +## Architecture boundaries + +- `src/extension.ts`: activation, commands, refresh orchestration, watcher, + coalescing, settings changes, and diagnostic output. +- `src/dataLoader.ts`: JSONL discovery/parsing, deduplication, attribution, + content analysis, and usage aggregation. +- `src/settings.ts`: the `SETTINGS` catalog and `SettingsStore`; do not scatter + direct configuration reads. +- `src/statusBar.ts`: status-bar token/cost/quota/context presentation. +- `src/webview.ts`: dashboard HTML and client behavior. +- `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 + that change is submitted for maintainer review, also provide a faithful + Simplified-Chinese review companion. + +## Safety and privacy invariants + +- Never upload prompt text, response text, raw JSONL lines, absolute paths, raw + session IDs, credentials, or local usernames. New v2.2.1 performance + diagnostics must not log them either; do not broaden older diagnostic output + without an explicit privacy review. +- Advice and optimizer network calls remain explicit user actions and may send + only the documented digest or text the user pasted. +- New settings default to existing behavior. Experimental or approximate + features default off. +- Do not read secret or credential files merely to diagnose a feature. Use + redacted metadata and fixtures. +- Do not hand-edit generated files in `out/`; edit `src/` and compile. + +## Development and tests + +```bash +npm ci +npm run compile +npm test +npx @vscode/vsce package +``` + +- Product TypeScript tests use `node:test` and live in `src/test/*.test.ts`. + GitHub automation uses focused ESM tests in `.github/scripts/*.test.mjs`. +- Use red-green TDD for every behavior change: add a focused failing test, run + it, implement the smallest change, then run the focused and full suites. +- Keep behavior tests focused and use `camelCase.test.ts` names. A repository + policy test may group closely related repository/package invariants. +- 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. + +## Localization and review documents + +- Every user-facing string goes through `I18n` with all eight UI locales: + `en`, `de-DE`, `zh-TW`, `zh-CN`, `ja`, `ko`, `pt-BR`, and `id`. +- Update all seven README files together: `README.md`, `README-en.md`, + `README-zh-CN.md`, `README-zh-TW.md`, `README-ja.md`, `README-ko.md`, and + `README-id.md`. +- For every artifact submitted for maintainer review—including a spec, + implementation plan, design, release checklist, policy, or substantial + contributor-document change—provide a faithful Chinese sibling or review + companion and present the Chinese link before the English link. +- Do not force-add ignored private documents unless the maintainer explicitly + requested that exact review artifact. + +## Git and release discipline + +- Preserve unrelated work in a dirty tree. Use a clean `codex/` branch or an + isolated worktree for implementation. +- 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`. +- 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 + original PR branch and then merge it. Put extra tests, refactors, or hardening + in a follow-up PR that builds on the merged contribution. +- If a contribution needs revision but has meaningful value, review it + constructively and prefer an author update. With explicit authorization, + adjust the original PR branch and then merge it instead of replacing it. +- Close a contributor PR without merging only in the exceptional case where no + meaningful contribution can be salvaged, such as an empty PR, spam, or an + irrelevant change. Explain the reason publicly and respectfully. +- Do not push, open a pull request, merge, or publish a release without explicit + maintainer approval. +- Do not bump `package.json` or create release tags manually. Release Drafter + prepares a draft; publishing that reviewed draft creates the tag, and the + publish workflow stamps the package version from it. + +## GitHub text attribution + +Maintainer-reviewed text generated with Codex ends with exactly: + +```md +--- +🤖 Generated with [OpenAI Codex](https://developers.openai.com/codex/) +``` + +An unreviewed automated Codex first pass uses exactly: + +```md +--- +🤖 Generated by [OpenAI Codex](https://developers.openai.com/codex/) as an automated first pass — not a maintainer decision. +``` + +- Attribution must name the actual generator. The current first-pass runner + defaults to DeepSeek and must not be relabelled Codex. +- The wrapper, not model output, owns the trusted footer and emits exactly one. +- Credit Claude Code and OpenAI Codex as development tools in all seven README + files. Do not put tools in Release Drafter's human contributor list and do + not invent a `Co-Authored-By` identity. +- Keep controlled comment-only first-pass automation separate from the + privileged maintainer-only mention workflow. diff --git a/AGENTS.zh-CN.md b/AGENTS.zh-CN.md new file mode 100644 index 00000000..c4bd5214 --- /dev/null +++ b/AGENTS.zh-CN.md @@ -0,0 +1,109 @@ +# AGENTS.md(简体中文审阅版) + +本文件是根目录 [AGENTS.md](AGENTS.md) 的忠实中文副本,供维护者快速审阅; +执行约束以两份文件共同表达的同一规则为准。 + +## 产品定位与范围 + +- Claude Code Usage 是一个 VS Code 扩展,读取 Claude Code 本地 JSONL 用量 + 日志,展示精确 token 总量、成本估算和 OAuth 配额。 +- v2.2.1 保持 Claude-only。Codex 在这个版本中是开发工具,不是已经完成的 + 用量 provider,也不构成产品改名的理由。 +- 优先保证 token 归因准确,不追求账单级精度。精确总量、明确标注的估算、 + 某一时点的配额观测必须保持为不同概念。 +- 扩展保持 local-first、轻量和 read-mostly。绝不修改 Claude 对话 JSONL; + credential 处理属于安全敏感范围,只保留已经审阅的既有行为。 +- v2.2.1 不新增 runtime dependency。 + +## 架构边界 + +- `src/extension.ts`:激活、命令、刷新调度、watcher、合并刷新、设置变化和诊断输出。 +- `src/dataLoader.ts`:JSONL 发现/解析、去重、归因、内容分析和用量聚合。 +- `src/settings.ts`:`SETTINGS` catalog 与 `SettingsStore`;不要散落直接配置读取。 +- `src/statusBar.ts`:状态栏 token、成本、配额和 context 展示。 +- `src/webview.ts`:dashboard HTML 与客户端行为。 +- `src/i18n.ts`:八个 UI locale 的全部用户可见文案。 +- `src/types.ts`:共享 contract。 +- 改变模块职责或数据流前先读 `ARCHITECTURE.md`;若该变化需要维护者审阅, + 同时提供忠实的简体中文 review companion。 + +## 安全与隐私不变量 + +- 绝不上传 prompt、response、原始 JSONL 行、绝对路径、原始 session ID、 + credential 或本地用户名。v2.2.1 新增的性能诊断也不得记录这些内容;未经过 + 明确隐私审阅,不扩大历史诊断输出范围。 +- Advice 和 optimizer 的网络调用必须由用户明确触发,只能发送文档约定的摘要 + 或用户亲自粘贴的文字。 +- 新设置默认保持既有行为;实验性或近似功能默认关闭。 +- 不要仅为了诊断功能而读取 secret 或 credential 文件;使用脱敏 metadata 与 fixture。 +- 不要手改 `out/` 生成物;修改 `src/` 后编译。 + +## 开发与测试 + +```bash +npm ci +npm run compile +npm test +npx @vscode/vsce package +``` + +- 产品 TypeScript 测试使用 `node:test`,放在 `src/test/*.test.ts`;GitHub 自动化的 + ESM 测试放在 `.github/scripts/*.test.mjs`。 +- 每项行为变更遵循 red-green TDD:先加聚焦的失败测试并运行,再做最小实现, + 然后运行聚焦测试和完整测试。 +- 行为测试保持聚焦,文件名使用 `camelCase.test.ts`;repository policy test 可以 + 合并紧密相关的仓库/打包不变量。 +- 用户可见变更需要更新 `CHANGELOG.md` 和对应文档。 +- UI 变更还要用 F5 Extension Development Host smoke test;候选版本在条件允许时 + 需要在 macOS 和 Linux 安装 VSIX 做 smoke test。 + +## 本地化与审阅文档 + +- 每个用户可见字符串都通过 `I18n` 覆盖八个 UI locale:`en`、`de-DE`、 + `zh-TW`、`zh-CN`、`ja`、`ko`、`pt-BR`、`id`。 +- 七份 README 必须一起更新:`README.md`、`README-en.md`、`README-zh-CN.md`、 + `README-zh-TW.md`、`README-ja.md`、`README-ko.md`、`README-id.md`。 +- 每份交给维护者审阅的 artifact(包括 spec、implementation plan、design、 + release checklist、policy 或大幅 contributor 文档变更),都提供忠实中文副本 + 或中文 review companion,并确保交付时中文链接排在英文链接之前。 +- 除非维护者明确要求那一份审阅材料,不要强制加入被 ignore 的私人文档。 + +## Git 与发布纪律 + +- 脏工作区里的无关改动必须保留;实现应使用干净的 `codex/` 分支或隔离 worktree。 +- tracked 文件默认模式为 `100644` 且使用 LF。只有确实由操作系统直接执行的程序 + 才能用 `100755`,并要登记到仓库 policy 测试;提交前用 `git ls-files --stage` 核对。 +- commit 保持聚焦;仓库通过已审阅 PR squash-merge 到 `main`。 +- 严禁把贡献者 PR 的改动吸收到维护者 PR 后,再以 superseded/已吸收为由直接关闭 + 贡献者 PR。必须优先合并贡献者的原始 PR;如确需修改,应先获得明确授权,在该原始 + PR 分支上修改后再合并。额外测试、重构或加固应作为建立在已合并贡献之上的后续 PR。 +- PR 不够合理但仍有实际价值时,应友善提出修改意见并优先请作者更新;经明确授权后, + 也可以直接在原 PR 分支调整,并在修改后合并,不要用维护者 PR 取代贡献者记录。 +- 不合并而关闭贡献者 PR 只限于确实无可保留价值的极少数情况,例如空 PR、spam 或 + 与项目完全无关的改动;关闭时必须公开、尊重地说明原因。 +- 未经维护者明确批准,不得推送、创建 PR、合并或发布 Release。 +- 不得手动修改 `package.json` 版本或创建发布 tag。Release Drafter 维护 draft; + 发布已审阅 draft 后才创建 tag,publish workflow 再从 tag 写入包版本。 + +## GitHub 文本归因 + +经过维护者审阅的 Codex 文本必须以以下内容准确结尾: + +```md +--- +🤖 Generated with [OpenAI Codex](https://developers.openai.com/codex/) +``` + +未经审阅的 Codex 自动 first pass 必须使用: + +```md +--- +🤖 Generated by [OpenAI Codex](https://developers.openai.com/codex/) as an automated first pass — not a maintainer decision. +``` + +- 归因必须写实际 generator。当前 first-pass runner 默认使用 DeepSeek,不得改标 Codex。 +- trusted footer 由 wrapper 而不是模型输出负责,并且只能出现一次。 +- 在七份 README 中把 Claude Code 与 OpenAI Codex 记为开发工具;不得把工具放入 + Release Drafter 的人类 contributor 列表,也不得编造 `Co-Authored-By` 身份。 +- comment-only 的受控 first-pass automation 与 maintainer-only 的高权限 mention + workflow 必须保持分离。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0ceacf..09c00545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,59 @@ All notable changes to this fork compared to upstream [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage) (last upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangelog.com). -## [2.2.0] — Unreleased +## [2.2.1] — Unreleased + +### Added +- **Bahasa Indonesia (`id`) (#76)** — the extension's eighth UI language covers + the dashboard, status bar, settings, and AI-advice demo, with a dedicated + `README-id.md`. Thanks [@projectronic](https://github.com/projectronic). +- **Sessions: all sessions + filters (#73)** — the Sessions tab can show all + sessions and adds persisted time-range, project, and model filters. Thanks + [@Carl723000](https://github.com/Carl723000). +- **Reset countdown formats (#75, closes #74)** — quota reset countdowns can use + decimal, whole-unit, or local clock/date formats. Thanks + [@projectronic](https://github.com/projectronic). +- **Indonesian timezone presets (#77)** — the timezone picker now includes WIB, + WITA, and WIT presets. Thanks [@projectronic](https://github.com/projectronic). + +### Changed +- **Codex maintenance handoff** — `AGENTS.md` is now the canonical repository + policy with a Simplified-Chinese review copy. Claude Code and OpenAI Codex + are credited as development tools, separately from human contributors. + +### Fixed +- **High-CPU refresh mitigation (#70)** — polling now always honors the + configured 30–3600 second `refreshInterval`; file watching is quiet-debounce + only and adds 60/120/300-second choices. First-timestamp reads stop after the + first valid timestamp and run with at most eight readers. +- **Date labels (#54, PR #71)** — daily rows on the first of a month remain + daily labels, while monthly keys no longer shift one month backward in + negative-UTC zones. Thanks [@YuboZhang](https://github.com/YuboZhang). +- **Automatic first-pass language (#72)** — repository bot replies now default + to English, using English-first bilingual output only for Chinese authors. +- **Cold-start refresh failures** — an incomplete first scan now clears the + loading state and shows a localized retry/diagnostic message while preserving + an existing successful snapshot on later transient failures. + +### Diagnostics +- **Anonymous refresh timings** — Show Diagnostic Logs now reports trigger, + file/change/reuse/removal counts, bytes, parsed lines, watcher/coalescing + counts, and manifest/read-parse/aggregate-render/total timings. It never logs + prompts, paths, session IDs, credentials, or raw JSONL lines. + +> This is a mitigation pending Linux prerelease validation. Issue #70 remains +> open until the reporter confirms the result; the per-file incremental index +> is tracked separately. + +### Security +- Hardened GitHub first-pass automation with truthful per-tier provider attribution, + one code-owned footer, fail-closed PR diff handling, and bounded base-repository file reads + with traversal, symlink, hidden-path, and secret-path denial. +- Manual publish retries now require an explicit existing release tag, check out + and verify its fully qualified `refs/tags/` commit, and can target only the + registry that needs recovery. + +## [2.2.0] — 2026-07-07 ### Added - **`tokenDecimalPlaces`** (default 1, 0–2) — decimals for the *compact* token diff --git a/CLAUDE.md b/CLAUDE.md index 6ffd4510..1d1af4ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,90 +1,15 @@ # CLAUDE.md -Guidance for Claude Code (and human contributors) working in this repository. - -## Project Overview - -"Claude Code Usage" is a VS Code extension that monitors Claude Code token -usage and cost estimates in the status bar, with a detailed dashboard webview. -It reads Claude Code's local `.jsonl` conversation logs and an OAuth-backed -real `/usage` quota. - -**Positioning (keep this in mind for every change):** Claude-only, lightweight, -**token-precision over cost-precision**. Cost figures are estimates derived from -public per-million-token rates — the product value is helping users *see where -tokens go* and *use Claude Code better* (incl. the AI advice feature). Not a -billing tool, not a multi-provider monitor. - -## Architecture (`src/`) - -- **`extension.ts`** — main class, activation, refresh orchestration. Owns the - adaptive refresh cadence (activity-aware: ~15 s while logs are being written, - the user's interval when idle), the re-entrancy guard + coalescing, the - `fs.watch` recursive file watcher, and the diagnostic `OutputChannel`. -- **`dataLoader.ts`** — finds + parses `.jsonl` records; dedup (keeps the - higher-token record on a hash collision); aggregation (today / month / all - time / sessions / projects / branches); content analysis; diagnostic stats. -- **`statusBar.ts`** — the two status-bar items (cost + quota). Quota tooltip is - an HTML progress bar; `liveWindows()` drops windows whose reset time has - passed so a stale value never lingers. -- **`webview.ts`** — the dashboard (tabs, charts, tables). Large single file: - server-rendered HTML + an inlined client script. Cost charts render as - gridded stacked compositions. -- **`pricing.ts`** — pricing table (Anthropic + reference rates for OpenAI / - Gemini / DeepSeek / Kimi / GLM / Qwen), family-aware fallback, LiteLLM - runtime refresh. -- **`claudeApiClient.ts`** — OAuth credential read + token refresh + `/usage` - fetch. Routes through the system `curl` binary because Anthropic's edge - rejects Node's TLS fingerprint. -- **`advisor.ts`** + **`adviceDemoSample.ts`** — the opt-in AI advice feature - and its localised static demo. -- **`i18n.ts`** — translations for, and selectable UI languages (package.json - enum + settings dropdown), all seven: en / de-DE / zh-TW / zh-CN / ja / ko / - pt-BR. Keep the three in sync when adding a language: `SupportedLanguage` - (types.ts), the `package.json` enum, and `settings.ts` `enumValues`. -- **`types.ts`** — shared interfaces. - -## Build & Release - -This repo is built with a **portable Node** (no global install assumed) and -`@vscode/vsce`. Common commands: - -```bash -npm run compile # tsc -> out/ -npm run watch # tsc --watch -npx @vscode/vsce package # build a .vsix -``` - -- **F5** launches the Extension Development Host for manual testing. -- **Releases are merge-and-auto-release.** As labelled PRs merge to `main`, - `.github/workflows/release-drafter.yml` keeps a **draft GitHub Release** up to - date (categorised notes + next semver version from the PR labels). To ship, a - maintainer reviews that draft and clicks **Publish** — which creates the tag - and triggers `.github/workflows/publish.yml`. Publish checks out the released - commit, stamps `package.json`'s version *from the release tag* (no hand-bump), - compiles, packages, publishes to the VS Code Marketplace + Open VSX, and - attaches the `.vsix`. So to release: just **Publish the draft Release** — no - manual `version` bump, no `git tag`. -- Versioning follows semver and is driven by PR labels: `fix`/none → patch - (`2.0.x`); `feature` → minor (`2.x.0`); `breaking` → major. - -## Conventions - -- **Commit hygiene:** one meaningful commit per logical change; avoid a stream - of "fix the previous fix" commits. RC branches squash-merge to `main`. -- **Tests:** a `node:test` suite runs against compiled output (`npm test`; see - `src/test/` and CONTRIBUTING). Pure-logic modules (pricing, aggregation, - timezone/date keys, dedup, share-card, heatmap, conversation-log, mini-markdown) - are the high-value targets and now carry most of the coverage. **One concern per - test file, named `camelCase.test.ts`** to match the module (e.g. `pricing.test.ts`, - `dateKeys.test.ts`) — don't add dotted `x.y.test.ts` files; fold new cases into - the existing file for that area. -- **Data is read-only:** the extension never writes to `~/.claude/`. - -## Documentation Maintenance - -When changing user-facing behaviour, update `README.md` (the canonical, fullest -doc) and keep the language editions reasonably in sync: -`README-en.md` (concise), `README-zh-CN.md` (full translation — highest-traffic -locale), `README-zh-TW.md` / `README-ja.md` / `README-ko.md` (concise). -`CHANGELOG.md` is the authoritative change record. +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`. + +Claude-specific compatibility notes: + +- The product remains Claude-only in v2.2.1; Codex is a development tool. +- Claude conversation JSONL is read-only. OAuth credential refresh is a + separate, existing security-sensitive behavior; do not broaden it casually. +- JSONL activity may tune the quota-cache TTL, but polling always follows + `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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3dfb85b3..fbe99153 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,29 +93,43 @@ tags: Because changes ship by **merging** your PR — not by re-applying it — your commit authorship and the PR's *merged* status are preserved. -## Automated first-pass replies (@ccu-bot) - -New issues and pull requests may get an **automated first-pass reply** from -`@ccu-bot` — an assistant run via [Claude Code](https://github.com/anthropics/claude-code-action) -that reads the project's architecture docs to help triage faster. - -What to expect and its limits: - -- Every automated comment is clearly labelled **"🤖 Automated first-pass reply - (via Claude Code)"** and ends with a note that it is model-generated and a - **maintainer reviews everything** — it is not a decision. -- It reads `ARCHITECTURE.md`, `CLAUDE.md`, `CONTRIBUTING.md` and, when those - aren't enough, the relevant repository files. It should **ask rather than - guess** when unsure — if it ever states something wrong, just correct it; a - maintainer will confirm. -- It only **comments**. It does not label, close, merge, approve, or push. For - PRs it reads the diff via the API and **never runs your code**. -- You can summon it in a comment with `@ccu-bot ...` (maintainer-gated). -- The whole thing is **off unless the maintainer enables it** (a repo variable - kill switch), and the model may be a third-party Anthropic-compatible one, so - "Claude Code" refers to the tooling, not necessarily Anthropic's Claude. - -Treat its replies as a helpful starting point, not authority. +## Repository assistants and truthful attribution + +### Controlled automatic first pass + +New issues and external pull requests may receive one controlled, comment-only +first pass when the maintainer enables the kill switch. The current runner uses +an Anthropic Messages-compatible transport and may truthfully identify a tier as +DeepSeek or Claude. Cheap and escalation tiers are configured and attributed +independently; the footer names the provider whose reply became the final body. + +The runner checks out only the base repository, never executes contributor code, +reads at most six allowlisted text files within byte budgets, and can only post +one comment. Public input remains untrusted and model output may be wrong. A +missing or empty PR diff stops the workflow without posting a review. + +Codex automatic attribution is not enabled in v2.2.1. It requires a separately +implemented and trusted OpenAI/Codex transport; a model name or repository +variable alone cannot enable it. + +### Maintainer-reviewed Codex text + +A maintainer-reviewed issue comment or pull-request response drafted with Codex +uses: + +```md +--- +🤖 Generated with [OpenAI Codex](https://developers.openai.com/codex/) +``` + +This means the maintainer reviewed the text. It does not add a synthetic +`Co-Authored-By` identity or imply that the automatic runner used Codex. + +### Maintainer-only mention agent + +`.github/workflows/claude.yml` is a separate, maintainer-gated Claude Code +workflow with repository write permissions. v2.2.1 does not migrate this privileged workflow to Codex. Treat every assistant reply as a starting point, +not repository authority; the maintainer makes the final decision. ## Code of conduct diff --git a/README-en.md b/README-en.md index f316e924..2951d52a 100644 --- a/README-en.md +++ b/README-en.md @@ -1,6 +1,6 @@ # Claude Code Usage -🌐 **Language**: [🏠 Main](README.md) | **English** | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) +🌐 **Language**: [🏠 Main](README.md) | **English** | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | [Bahasa Indonesia](README-id.md) --- @@ -50,7 +50,7 @@ Also on the [Open VSX Registry](https://open-vsx.org/extension/GrowthJack/claude Open Settings (`Ctrl+,`) and search for **`Claude Code Usage`**. All settings are optional. The most useful: -- `language` — UI language (`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`). +- `language` — UI language (`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`). - `timezone` — IANA timezone for date display (e.g. `Asia/Hong_Kong`). - `usageLimitTracking` — show the real 5h / weekly quota indicator. - `showCost` / `showContext` — toggle the cost item and the context-window fill indicator (like `/context`) in the status bar. @@ -70,10 +70,22 @@ See the [full settings table in the main README](README.md#configuration). **Token counts lower than your provider's dashboard** — some proxies / dynamic workflows write per-agent records to sub-directories that may be incomplete. Your actual spend is on your provider's billing page. Native workflow attribution is planned. +**High CPU or sluggish refresh on a large history (Linux included)** +- V2.2.1 removes the hidden 8-second active polling override and bounds the + first-timestamp scan. Until you install it, set **Live refresh delay** to + **Off**, set **Refresh interval** to **300–900 seconds**, and optionally turn + **Content analysis** off. Turning Dashboard auto-refresh off by itself does + not stop status-bar parsing. +- If V2.2.1 still runs hot, open **Show Diagnostic Logs** and attach only the + anonymous `refresh:` lines to issue #70; they contain counts and timings, not + prompts, paths, session IDs, credentials, or raw log lines. + ## Credits Forked from [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage). MIT-licensed. Community contributions credited in [CHANGELOG.md](CHANGELOG.md). Many code changes drafted with [Claude Code](https://claude.com/claude-code). +Development-tool credit: repository maintenance uses both [Claude Code](https://claude.com/claude-code) and [OpenAI Codex](https://developers.openai.com/codex/). This credits tools separately from human contributors; Codex is not added to Release Drafter's contributor list and receives no fabricated `Co-Authored-By` identity. + **Issues, PRs and ideas are warmly welcomed** — that's how the project grows. ## License diff --git a/README-id.md b/README-id.md new file mode 100644 index 00000000..0b6e4649 --- /dev/null +++ b/README-id.md @@ -0,0 +1,83 @@ +# Claude Code Usage + +🌐 **Bahasa**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | **Bahasa Indonesia** + +--- + +**Pelatih Claude Code Anda di status bar.** Bukan alat billing. Bukan monitor multi-provider. Pelacak token yang fokus, dan memakai AI untuk membantu Anda memakai Claude Code dengan lebih baik. + +> **Apa ini:** monitor status bar VS Code yang membaca log percakapan Claude Code lokal Anda dan menampilkan estimasi penggunaan serta biaya **berbasis token** — plus penasihat AI opsional yang menyarankan cara memperbaiki prompt Anda dan mengurangi pemborosan. + +> **ini _Bukanlah_:** alat billing. Semua angka adalah estimasi berdasarkan tarif publik per-juta-token. Rujuk ke akun Anthropic Anda untuk biaya yang sebenarnya. + +> Screenshot berasal dari UI berbahasa Inggris. Lihat [README utama](README.md) untuk referensi fitur lengkap. + +## Screenshot + +### Status bar + +![Status bar](images/v2-status-bar-en.png) + +Arahkan kursor ke indikator kuota untuk melihat rinciannya: + +![Quota tooltip](images/v2-quota-en.png) + +### Dashboard + +![Dashboard](images/v2-dashboard-en.png) + +## Fitur + +- **Status bar** — biaya hari ini, biaya sesi saat ini, dan kuota 5-jam / mingguan yang sebenarnya (`5h:N% wk:N%`) dibaca dari sesi OAuth Claude Code sendiri. Tanpa konfigurasi. +- **Tab dashboard** — Hari Ini / Bulan Ini / Sepanjang Waktu, plus **Sesi / Proyek / Konten / Branch**, semuanya bisa diurutkan. +- **Grafik komposisi biaya bertumpuk** dengan sumbu Y dan garis referensi — lihat sekilas berapa banyak dari tiap hari / bulan yang terpakai untuk masukan, keluaran, cache-write, dan cache-read. +- **Tab Konten** — memperkirakan konten mana yang menghabiskan token Anda (prompt Anda vs. hasil tool vs. output / pemikiran asisten). +- **Saran AI** (opsional) — mengirim ringkasan penggunaan plus sampel prompt Anda ke API yang kompatibel dengan OpenAI (DeepSeek V4 Pro secara default) dan menyarankan penulisan ulang yang konkret. Gunakan API key Anda sendiri, atau pratinjau demo statis terlebih dahulu. +- **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. + +## Instalasi + +Cari **`Claude Code Usage`** di tampilan Extensions (`Ctrl+Shift+X`), atau: + +``` +ext install GrowthJack.claude-code-usage +``` + +Juga tersedia di [Open VSX Registry](https://open-vsx.org/extension/GrowthJack/claude-code-usage) untuk Cursor / Windsurf. + +## Konfigurasi + +Buka Settings (`Ctrl+,`) dan cari **`Claude Code Usage`**. Semua pengaturan bersifat opsional. Yang paling berguna: + +- `language` — bahasa UI (`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`). +- `timezone` — zona waktu IANA untuk tampilan tanggal (mis. `Asia/Jakarta`). +- `usageLimitTracking` — tampilkan indikator kuota 5 jam / mingguan yang sebenarnya. +- `showCost` / `showContext` — nyalakan/matikan item biaya dan indikator pengisian jendela konteks (seperti `/context`) di status bar. +- Setiap item status bar ini bisa dimatikan sendiri — atur `usageLimitTracking`, `showCost`, atau `showContext` ke `false` untuk menyembunyikan salah satunya saja. +- `advice.apiKey` — API key untuk fitur saran AI (kompatibel dengan OpenAI). +- `dashboardAutoRefresh` — nyalakan/matikan penyegaran otomatis dashboard (bisa juga di-toggle di header dashboard). + +Lihat [tabel pengaturan lengkap di README utama](README.md#configuration). + +## Pemecahan masalah + +**"No Claude Code Data"** — pastikan Claude Code sudah terpasang dan pernah dipakai minimal sekali; periksa pengaturan `dataDirectory` (deteksi otomatis mencari di `~/.claude/projects`). + +**Kuota menampilkan `5h:--% wk:--%`** — login ke Claude Code sekali; extension ini membaca `~/.claude/.credentials.json` secara baca-saja. + +**Riwayat penggunaan bulan-bulan lama hilang** — Claude Code menghapus log yang lebih lama dari `cleanupPeriodDays` (default 30). Untuk menyimpan lebih lama, atur `{ "cleanupPeriodDays": 365 }` di `~/.claude/settings.json`. Log yang sudah terhapus tidak bisa dipulihkan. + +**Jumlah token lebih rendah dari dashboard provider Anda** — beberapa proxy / dynamic workflow menulis catatan per-agent ke sub-direktori yang mungkin tidak lengkap. Pengeluaran sebenarnya ada di halaman billing provider Anda. Atribusi workflow native sedang direncanakan. + +## Kredit + +Fork dari [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage). Berlisensi MIT. Kontribusi komunitas dicatat di [CHANGELOG.md](CHANGELOG.md). Banyak perubahan kode disusun dengan bantuan [Claude Code](https://claude.com/claude-code). + +Kredit alat pengembangan: pemeliharaan repositori menggunakan [Claude Code](https://claude.com/claude-code) dan [OpenAI Codex](https://developers.openai.com/codex/). Kredit alat ini dipisahkan dari kontributor manusia; Codex tidak ditambahkan ke daftar kontributor manusia Release Drafter dan tidak diberi identitas `Co-Authored-By` yang dibuat-buat. + +**Issue, PR, dan ide sangat kami sambut** — begitulah proyek ini berkembang. + +## Lisensi + +[MIT](LICENSE) diff --git a/README-ja.md b/README-ja.md index fbbee143..2ea4a025 100644 --- a/README-ja.md +++ b/README-ja.md @@ -1,6 +1,6 @@ # Claude Code 使用量モニター -🌐 **言語**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | **日本語** | [한국어](README-ko.md) +🌐 **言語**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | **日本語** | [한국어](README-ko.md) | [Bahasa Indonesia](README-id.md) --- @@ -50,7 +50,7 @@ Cursor / Windsurf 向けに [Open VSX Registry](https://open-vsx.org/extension/G 設定(`Ctrl+,`)を開き **`Claude Code Usage`** を検索。すべて任意です。よく使うもの: -- `language` — UI 言語(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`)。 +- `language` — UI 言語(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`)。 - `timezone` — 日付表示用の IANA タイムゾーン(例 `Asia/Tokyo`)。 - `usageLimitTracking` — 実際の 5 時間 / 週間クォータ表示。 - `showCost` / `showContext` — ステータスバーのコスト表示と、コンテキストウィンドウ使用率(`/context` 風)の切り替え。 @@ -70,10 +70,21 @@ Cursor / Windsurf 向けに [Open VSX Registry](https://open-vsx.org/extension/G **トークン数がプロバイダーのダッシュボードより少ない** — 一部のプロキシ / 動的ワークフローはエージェントごとの記録をサブディレクトリに書き込み、不完全な場合があります。実際の消費はプロバイダーの請求ページをご確認ください。ネイティブのワークフロー帰属は計画中です。 +**履歴が大きい環境で CPU 使用率が高い/更新が遅い(Linux を含む)** +- V2.2.1 では active 時に隠れて 8 秒へ短縮されるポーリングを廃止し、最初の + timestamp 読み取りを制限します。導入前は **Live refresh delay** を **Off**、 + **Refresh interval** を **300–900 秒**にし、必要なら **Content analysis** も + Off にしてください。Dashboard auto-refresh だけを Off にしても、ステータス + バー用の解析は止まりません。 +- V2.2.1 でも高負荷が続く場合は **Show Diagnostic Logs** の匿名 `refresh:` 行だけを + issue #70 に添付してください。prompt、path、session ID、credential、raw log は含みません。 + ## クレジット [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage) からフォーク。MIT ライセンス。コミュニティの貢献は [CHANGELOG.md](CHANGELOG.md) に記載。多くのコード変更は [Claude Code](https://claude.com/claude-code) の支援で作成されました。 +開発ツールのクレジット:リポジトリの保守には [Claude Code](https://claude.com/claude-code) と [OpenAI Codex](https://developers.openai.com/codex/) の両方を使用しています。これは人間のコントリビューターとは別のツール表記であり、Codex を Release Drafter のコントリビューター一覧に加えたり、架空の `Co-Authored-By` ID を付けたりしません。 + **Issue・PR・アイデアを歓迎します** —— それがプロジェクトの成長につながります。 ## ライセンス diff --git a/README-ko.md b/README-ko.md index 1d52e626..35f44b2b 100644 --- a/README-ko.md +++ b/README-ko.md @@ -1,6 +1,6 @@ # Claude Code 사용량 모니터 -🌐 **언어**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | **한국어** +🌐 **언어**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | **한국어** | [Bahasa Indonesia](README-id.md) --- @@ -50,7 +50,7 @@ Cursor / Windsurf용으로 [Open VSX Registry](https://open-vsx.org/extension/Gr 설정(`Ctrl+,`)을 열고 **`Claude Code Usage`**를 검색하세요. 모두 선택 사항입니다. 가장 유용한 것: -- `language` — UI 언어(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`). +- `language` — UI 언어(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`). - `timezone` — 날짜 표시용 IANA 시간대(예: `Asia/Seoul`). - `usageLimitTracking` — 실제 5시간 / 주간 쿼터 표시. - `showCost` / `showContext` — 상태 표시줄의 비용 항목과 컨텍스트 윈도우 사용률(`/context` 유사) 표시 전환. @@ -70,10 +70,20 @@ Cursor / Windsurf용으로 [Open VSX Registry](https://open-vsx.org/extension/Gr **토큰 수가 프로바이더 대시보드보다 적음** — 일부 프록시 / 동적 워크플로는 에이전트별 기록을 하위 디렉터리에 쓰며 불완전할 수 있습니다. 실제 지출은 프로바이더 청구 페이지를 확인하세요. 네이티브 워크플로 귀속 기능은 계획 중입니다. +**큰 기록에서 CPU 사용률이 높거나 새로 고침이 느림(Linux 포함)** +- V2.2.1은 active 상태에서 숨겨진 8초 폴링 강제를 제거하고 첫 timestamp 읽기를 + 제한합니다. 설치 전에는 **Live refresh delay**를 **Off**, **Refresh interval**을 + **300–900초**로 설정하고 필요하면 **Content analysis**도 끄세요. Dashboard + auto-refresh만 끄면 상태 표시줄용 로그 파싱은 계속됩니다. +- V2.2.1에서도 높은 사용률이 계속되면 **Show Diagnostic Logs**의 익명 `refresh:` + 줄만 issue #70에 첨부하세요. prompt, path, session ID, credential, raw log는 포함되지 않습니다. + ## 크레딧 [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage)에서 포크. MIT 라이선스. 커뮤니티 기여는 [CHANGELOG.md](CHANGELOG.md)에 명시. 많은 코드 변경은 [Claude Code](https://claude.com/claude-code)의 도움으로 작성되었습니다. +개발 도구 크레딧: 저장소 유지보수에는 [Claude Code](https://claude.com/claude-code)와 [OpenAI Codex](https://developers.openai.com/codex/)를 함께 사용합니다. 이는 사람 기여자와 분리된 도구 표기이며, Codex를 Release Drafter의 사람 기여자 목록에 넣거나 허위 `Co-Authored-By` 신원을 부여하지 않습니다. + **이슈, PR, 아이디어를 환영합니다** —— 그것이 프로젝트가 성장하는 방식입니다. ## 라이선스 diff --git a/README-zh-CN.md b/README-zh-CN.md index 7c0ca1ea..1c4ef0f0 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -1,6 +1,6 @@ # Claude Code 使用量监控 -🌐 **语言**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | **简体中文** | [日本語](README-ja.md) | [한국어](README-ko.md) +🌐 **语言**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | **简体中文** | [日本語](README-ja.md) | [한국어](README-ko.md) | [Bahasa Indonesia](README-id.md) --- @@ -60,6 +60,20 @@ AI 建议生成的是一份 **Markdown 文档**,用文字展示比截图更直 --- +## 2.2 新功能 + +- **用量分享卡**(可选,`enableShareCard`):一张可配置的单页 SVG 用量卡——自选时间范围 × 范围(总体 / 工程 / 会话)× 展示哪些指标,以及主题(**Claude 经典橙** / **奶油** / **极光暗色** / **自动**),可选带上 GitHub 头像与名称。自包含、可复现;提示词、路径、ID 一律不出本机。中文语言下使用 万/亿 单位。 +- **只读会话查看器**(会话标签,**默认开启**):每行的「查看」按钮以只读方式重开某个历史会话的提示词与 Markdown 渲染的回答,帮你回忆内容,而**不必**像「恢复」那样把它重新塞回模型上下文。思考与工具调用收进折叠区,默认展示最近若干轮。 +- **Token 热力图**(可选,`showHeatmap`):All 标签顶部的 GitHub 风格年度 token 热力图,并可**导出 / 发布到你的 GitHub 主页**(自包含 SVG,一键复制 Markdown 嵌入代码)。 +- **实验性洞察**(可选,`showInsights`,Content 标签):基于本地日志的启发式估算(均标注为估算)——**缓存损耗账单**(模型切换 / 空闲导致重写缓存的花费)、**各模型缓存保温时长**、**大单轮**、**你的活跃时段**、以及**技能 ROI**(每美元产出的输出 token)。 +- **最贵的 10 条消息**(可选,`showCostliestMessages`):按成本排序单轮对话,区分**缓存未命中**与长回答,附缓存命中率与距上一轮的间隔。 +- **会话「活跃」列**:每个会话的估算实际操作时长(空闲间隔按 1.5 小时封顶),比原始的首尾时间跨度更有意义。 +- **缓存命中率列**:All-time(按月)与本月(按日)表格及其下钻均新增该列,逐行可见缓存效率。 +- **实时刷新延迟**(`fileWatchSeconds`:关 / 1 / 2 / 5 / 10 / 20 / 30 秒)取代原来的开 / 关切换;只重读**本地**日志。 +- **时区感知分桶**:Today / 日 / 月 / 小时 的统计都以你配置的 IANA 时区为准(下拉含完整 UTC 偏移),彼此之间以及与 Anthropic 控制台保持一致。 +- **升级后「新功能」提示**:每次首次运行新的 major.minor 版本时给一次可关闭的提示,让可选功能不被埋没。 +- **修复**:Sonnet 5 正确上报 1M 上下文窗口(#50);日志带 TTL 拆分时,1 小时缓存写入按 2× 基础输入计价(#62);多窗口下后台窗口在获得焦点时刷新、不再显示过期数据(#55);时区设置改为校验过的下拉框,非法值不再拖垮仪表板(#51);德语(de-DE)与巴西葡语(pt-BR)在各处均可选。 + ## 2.1 新功能 - **会话:恢复 / 拷贝 / 删除**:每行可拷贝会话 ID、**恢复**(本工程会话优先在官方 Claude Code 扩展的新 tab 打开;其他工程的会话在其所在目录开终端执行 `claude --resume `,因为官方 in-tab 恢复用的是当前工作区目录、找不到跨工程会话)、或**删除**(移至回收站,需确认)。并新增「当前工程 / 全部」筛选,默认当前工程。 @@ -120,7 +134,7 @@ ext install GrowthJack.claude-code-usage | 设置 | 默认 | 作用 | |---|---|---| -| `language` | `"auto"` | 界面语言:`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`。 | +| `language` | `"auto"` | 界面语言:`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`。 | | `dataDirectory` | `""` | 自定义 Claude 数据目录;留空 = 自动检测。 | | `advice.apiKey` | `""` | AI 建议 + 用量优化器的 API key(留空则 AI 建议会打开 demo)。 | @@ -172,8 +186,14 @@ ext install GrowthJack.claude-code-usage **`Get AI Usage Advice` 显示 demo 而非真实建议** - AI 建议需要 key。若 `claudeCodeUsage.advice.apiKey` 为空,命令会打开一份手写 demo(文件名带 `…-DEMO-…`,顶部有醒目横幅)而不调用任何 API。在设置中填入 key 即可获得真实建议。 -**大历史下刷新缓慢** -- 加载器每 25 个文件让出一次事件循环;空闲 tick 跳过重算。如仍有问题,提高 `refreshInterval` 或将 `enableContentAnalysis` 设为 `false`。 +**大历史下 CPU 占用高或刷新缓慢(包括 Linux)** +- V2.2.1 移除了 active 状态下隐藏的 8 秒轮询覆盖,并限制首时间戳扫描。 + 在安装 V2.2.1 前,可先把**实时刷新延迟**设为**关闭**,把**刷新间隔**设为 + **300–900 秒**,并视需要关闭**内容分析**。只关闭“仪表盘自动刷新”并不会 + 停止状态栏所需的日志解析。 +- 若 V2.2.1 仍持续高占用,请运行 **Show Diagnostic Logs**,只把匿名的 + `refresh:` 行附到 issue #70;其中只有计数和耗时,不含提示词、路径、 + session ID、凭证或原始日志行。 **历史记录消失或缺少早期月份** - Claude Code 会自动删除超过 `cleanupPeriodDays`(默认 **30 天**)的对话日志。已删除的记录无法恢复。要保留更多历史,在 `~/.claude/settings.json` 中添加: @@ -188,6 +208,8 @@ ext install GrowthJack.claude-code-usage 由 [**@Carl723000**](https://github.com/Carl723000) 维护 —— 最早从 [@jack21](https://github.com/jack21) 的原始项目 [`ClaudeCodeUsage`](https://github.com/jack21) fork 而来,现在也是上游组织 [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage) 的 owner 之一、负责后续维护。MIT 授权。本文档「新功能」里的 2.x 内容由 @Carl723000 借助 [Claude Code](https://claude.com/claude-code) 完成,已在 2.0 基础上推进许多 —— 见 [CHANGELOG.md](CHANGELOG.md)。 +开发工具致谢:仓库维护同时使用了 [Claude Code](https://claude.com/claude-code) 和 [OpenAI Codex](https://developers.openai.com/codex/)。这只记录开发工具,与人类贡献者身份分开;Codex 不会进入 Release Drafter 的人类 contributor 列表,也不会获得伪造的 `Co-Authored-By` 身份。 + 已并入的上游贡献者 PR / issue: - [@Dobidop](https://github.com/Dobidop) —— [PR #9](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/pull/9),读取真实 `/usage` 数据的 OAuth 方案;配额指示器据此改编。 diff --git a/README-zh-TW.md b/README-zh-TW.md index 63cb19fa..4b61cb1c 100644 --- a/README-zh-TW.md +++ b/README-zh-TW.md @@ -1,6 +1,6 @@ # Claude Code 使用量監控 -🌐 **語言**: [🏠 Main](README.md) | [English](README-en.md) | **繁體中文** | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) +🌐 **語言**: [🏠 Main](README.md) | [English](README-en.md) | **繁體中文** | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | [Bahasa Indonesia](README-id.md) --- @@ -50,7 +50,7 @@ ext install GrowthJack.claude-code-usage 開啟設定(`Ctrl+,`)並搜尋 **`Claude Code Usage`**。所有設定皆為選用,最常用的: -- `language` — 介面語言(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`)。 +- `language` — 介面語言(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`)。 - `timezone` — 日期顯示用的 IANA 時區(如 `Asia/Hong_Kong`)。 - `usageLimitTracking` — 顯示真實的 5 小時 / 每週配額指示器。 - `showCost` / `showContext` — 切換狀態列的成本項目與上下文視窗佔用指示器(類似 `/context`)。 @@ -70,10 +70,21 @@ ext install GrowthJack.claude-code-usage **Token 數低於供應商後台** — 部分代理 / 動態工作流會將各 agent 記錄寫入子目錄,可能不完整。實際消費請以供應商帳單為準。原生工作流歸因功能規劃中。 +**大量歷史資料下 CPU 佔用高或重新整理緩慢(包括 Linux)** +- V2.2.1 移除了 active 狀態下隱藏的 8 秒輪詢覆蓋,並限制首時間戳掃描。 + 安裝 V2.2.1 前,可先把**即時重新整理延遲**設為**關閉**,把**重新整理間隔** + 設為 **300–900 秒**,並視需要關閉**內容分析**。只關閉儀表板自動重新整理 + 並不會停止狀態列所需的日誌解析。 +- 若 V2.2.1 仍持續高佔用,請執行 **Show Diagnostic Logs**,只把匿名的 + `refresh:` 行附到 issue #70;其中只有計數與耗時,不含提示詞、路徑、 + session ID、憑證或原始日誌行。 + ## 致謝 Fork 自 [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage)。MIT 授權。社群貢獻致謝見 [CHANGELOG.md](CHANGELOG.md)。許多程式碼改動由 [Claude Code](https://claude.com/claude-code) 協助起草。 +開發工具致謝:repository 維護同時使用 [Claude Code](https://claude.com/claude-code) 與 [OpenAI Codex](https://developers.openai.com/codex/)。這只記錄開發工具,與人類貢獻者身分分開;Codex 不會列入 Release Drafter 的人類 contributor 名單,也不會使用虛構的 `Co-Authored-By` 身分。 + **歡迎提出 Issue、PR 與想法** —— 這正是專案成長的方式。 ## 授權 diff --git a/README.md b/README.md index c299a921..707220ee 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ use Claude Code better. [繁體中文](README-zh-TW.md) · [简体中文](README-zh-CN.md) · [日本語](README-ja.md) · -[한국어](README-ko.md) +[한국어](README-ko.md) · +[Bahasa Indonesia](README-id.md) --- @@ -99,6 +100,47 @@ consent prompt. --- +## What's new in 2.2 + +- **Usage share card** (opt-in, `enableShareCard`) — a themed, configurable + one-page SVG of your usage: pick a range × scope (overall / project / session) + × which metrics to show, and a theme (**Claude Classic** / **Cream** / + **Aurora Dark** / **Auto**), with an optional GitHub avatar + name. + Self-contained and deterministic; no prompts, paths or ids ever leave your + machine. Chinese locales use 万/亿 units. +- **Read-only conversation viewer** (Sessions tab, **on by default**) — a "view" + button re-opens a past session's prompts and Markdown-rendered answers so you + can jog your memory **without** loading it back into the model's context + (unlike resume). Thinking and tool traffic sit behind toggles; opens on the + last rounds. +- **Token heatmap** (opt-in, `showHeatmap`) — a GitHub-style yearly token heatmap + on the All tab, plus **Export / Publish to your GitHub profile** as a + self-contained SVG with a one-click Markdown embed. +- **Experimental insights** (opt-in, `showInsights`, Content tab) — labelled + estimates from your local logs: a **cache-churn bill** ($ spent re-writing + cache after model switches / idle gaps), **cache warmth by model**, **big + one-shot turns**, **your active hours**, and **skill ROI** (output tokens + returned per $). +- **Top-10 costliest messages** (opt-in, `showCostliestMessages`) — ranks single + turns by cost, splitting a **cache miss** from a long answer, with the + cache-hit rate and the time since the last turn. +- **Sessions "Active" column** — estimated hands-on time per session (idle gaps + capped at 1.5 h), far more meaningful than the raw first-to-last span. +- **Cache-hit-rate column** in the All-time (monthly) and This-month (daily) + tables and their drill-downs, so cache efficiency is visible per row. +- **Live-refresh delay** (`fileWatchSeconds`: Off / 1 / 2 / 5 / 10 / 20 / 30 s) + replaces the on/off live-watch toggle; it only re-reads your **local** logs. +- **Timezone-aware bucketing** — Today / day / month / hour totals all key off + your configured IANA zone (full UTC-offset dropdown), so they agree with each + other and the Anthropic console. +- **"What's new" prompt after upgrades** — a single dismissible nudge the first + time you run a new major.minor version, so opt-in features stay discoverable. +- **Fixes** — Sonnet 5 reports a 1M context window (#50); 1-hour cache writes are + priced at 2× base input when the log carries the split (#62); background + windows refresh on focus instead of going stale (#55); the Timezone setting is + a validated dropdown so a bad value can't crash the dashboard (#51); German + (de-DE) and Brazilian Portuguese (pt-BR) are selectable everywhere. + ## What's new in 2.1 - **Sessions: resume / copy / delete** — each row can copy the session id, @@ -220,7 +262,7 @@ for **`Claude Code Usage`**: | Setting | Default | What it does | |---|---|---| -| `language` | `"auto"` | UI language: `auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`. | +| `language` | `"auto"` | UI language: `auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`. | | `dataDirectory` | `""` | Custom Claude data dir; empty = auto-detect. | | `advice.apiKey` | `""` | API key for AI advice + the Usage Optimizer (empty = advice opens a demo instead). | @@ -299,10 +341,15 @@ authoritative. command opens a hand-written demo (filename-marked `…-DEMO-…`, with a prominent banner) instead of calling any API. Add a key in Settings to get real advice. -**Sluggish refresh on large histories** -- 2.0 yields to the event loop every 25 files; idle ticks skip recompute. - If you still hit issues, raise `refreshInterval` or set - `enableContentAnalysis` to `false`. +**High CPU or sluggish refresh on a large history (Linux included)** +- V2.2.1 removes the hidden 8-second active polling override and bounds the + first-timestamp scan. Until you install it, set **Live refresh delay** to + **Off**, set **Refresh interval** to **300–900 seconds**, and optionally turn + **Content analysis** off. Turning Dashboard auto-refresh off by itself does + not stop status-bar parsing. +- If V2.2.1 still runs hot, open **Show Diagnostic Logs** and attach only the + anonymous `refresh:` lines to issue #70; they contain counts and timings, not + prompts, paths, session IDs, credentials, or raw log lines. **Usage history disappears or is missing older months** - Claude Code automatically deletes conversation logs older than @@ -339,6 +386,12 @@ MIT-licensed. The 2.x work documented here (everything under "What's new") is by @Carl723000 with [Claude Code](https://claude.com/claude-code); it has grown well beyond the 2.0 baseline — see [CHANGELOG.md](CHANGELOG.md). +Development-tool credit: repository maintenance uses both +[Claude Code](https://claude.com/claude-code) and +[OpenAI Codex](https://developers.openai.com/codex/). This credits the tools +separately from human contributors: Codex is not added to Release Drafter's +contributor list, and no fabricated `Co-Authored-By` identity is used for it. + Contributors whose upstream PRs / issues are incorporated here: - [@Dobidop](https://github.com/Dobidop) — diff --git a/docs/superpowers/plans/2026-07-17-codex-takeover-hygiene.zh-CN.md b/docs/superpowers/plans/2026-07-17-codex-takeover-hygiene.zh-CN.md new file mode 100644 index 00000000..f913d2c7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-codex-takeover-hygiene.zh-CN.md @@ -0,0 +1,808 @@ +# Codex 接手与仓库卫生 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不改变 v2.2.1 产品行为的前提下,建立 Codex 可依赖的仓库级指导、中文审阅约定、换行/文件模式防护、忽略与 VSIX 打包边界,并如实致谢 Claude Code 与 OpenAI Codex 两种开发工具。 + +**已批准设计:** [V2.2.1 稳定化设计(简体中文)](../specs/2026-07-17-v2.2.1-stabilization-design.zh-CN.md) + +**Architecture:** 根目录 `AGENTS.md` 是唯一 canonical 规范源,`AGENTS.zh-CN.md` 是供维护者审阅的忠实中文副本,`CLAUDE.md` 收敛为指向 canonical 规则的 Claude Code 兼容入口;可执行约束由 `src/test/repositoryPolicy.test.ts` 固化。`.gitattributes`、`.gitignore` 与 `.vscodeignore` 分别负责文本规范、本地状态隔离和扩展包边界,README 只记录工具致谢,不把工具伪装成人类贡献者。 + +**Tech Stack:** Markdown、Git attributes/ignore 规则、TypeScript 5.8、Node.js 20 `node:test`、`@vscode/vsce`、ZIP 文件清单审计。 + +## Global Constraints + +- v2.2.1 保持 Claude-only;Codex 在本计划中只是开发工具,不是已经完成的用量 provider。 +- 不新增 runtime dependency。 +- 精确用量与估算洞察继续分离;不得把本计划包装成产品能力变化。 +- 所有用户可见字符串必须覆盖现有七个 UI locale:`en`、`de-DE`、`zh-TW`、`zh-CN`、`ja`、`ko`、`pt-BR`。 +- README 改动必须同时更新现有六份文件:`README.md`、`README-en.md`、`README-zh-CN.md`、`README-zh-TW.md`、`README-ja.md`、`README-ko.md`。 +- 任何交给维护者审阅的 artifact(包括 spec、plan、design、release checklist、policy 或大幅文档变更)都必须提供忠实中文副本或中文 review companion,交付时中文链接排在英文链接之前。 +- 扩展不得写入 Claude JSONL 日志;本计划及 v2.2.1 新增的性能诊断不得记录 prompt、绝对路径、session ID、credential 或原始 JSONL 行。不得把这一新增约束误写成对所有历史诊断的既成事实。 +- push、创建 PR、merge 和发布 Release 都必须先得到维护者明确批准。 +- Release Drafter 的 draft Release 是版本来源;不得手动改版本或创建发布 tag。 +- 默认 tracked 文件模式为 `100644`;除非文件确实由操作系统直接执行且测试明确登记,否则不得提交 `100755`。 +- 每项行为约束都先写失败测试,再做最小实现;完成前 `npm test` 必须零失败。 + +### Codex Desktop 命令启动方式 + +本文中的 `npm ...` / `npx ...` 是 CI 与普通开发 shell 的 canonical 命令。当前 +Codex Desktop shell 的 `PATH` 可能不含 `node`、`npm`、`npx`;在这种环境中先设置: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +``` + +然后使用下面的固定、离线等价命令,不临时下载 VSCE: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js +"$CCU_NODE" node_modules/@vscode/vsce/vsce ls --no-dependencies +``` + +每个 `Run: npm test` 在 Codex Desktop 中都必须执行前两行;`npx @vscode/vsce` +的清单检查使用第三行。Codex 环境没有 npm 时,完整临时打包必须先执行编译, +再使用 Task 4 给出的 pinned VSCE `pack()` 等价命令;不能直接运行会调用 npm +prepublish 的 `vsce package`。每个执行步骤都视为新的 shell;凡后文单独出现 +`"$CCU_NODE"`,执行者必须在同一个 code block/command 前重新写入上述绝对路径。 + +## File map + +- Create: `AGENTS.md` — Codex 与其他 agent 的英文规范源。 +- Create: `AGENTS.zh-CN.md` — 与 `AGENTS.md` 同义的维护者审阅副本。 +- Modify: `CLAUDE.md` — 删除陈旧/冲突规则,改为 canonical AGENTS 的 Claude Code 兼容入口。 +- Create: `.gitattributes` — LF 与二进制文件分类。 +- Modify: `.gitignore` — 排除 agent 本地状态与仓库内 worktree。 +- Modify: `.vscodeignore` — 防止 agent 指令、本地状态、源码、测试与 workflow 源进入 VSIX。 +- Create: `src/test/repositoryPolicy.test.ts` — 仓库规范、打包边界、工具致谢和 locale 数量的回归测试。 +- Modify: `README.md`, `README-en.md`, `README-zh-CN.md`, `README-zh-TW.md`, `README-ja.md`, `README-ko.md` — 分语言添加开发工具致谢。 +- Modify: `CHANGELOG.md` — 记录已发布的 2.2.0 日期和 v2.2.1 接手/工具归因变化。 +- Modify: `.github/PULL_REQUEST_TEMPLATE.md` — 把错误的“六种语言”修正为七个 UI locale。 + +--- + +### Task 1: 建立 canonical AGENTS 指导和中文审阅副本 + +**Files:** +- Create: `AGENTS.md` +- Create: `AGENTS.zh-CN.md` +- Modify: `CLAUDE.md` +- Create: `src/test/repositoryPolicy.test.ts` + +**Interfaces:** +- Consumes: `ARCHITECTURE.md` 的模块边界、`CONTRIBUTING.md` 的测试/发布流程、已批准的 v2.2.1 stabilization spec。 +- Produces: `AGENTS.md` 中稳定的产品约束、测试命令、文档同步规则、GitHub attribution 与远程操作审批边界;`repoFile(relativePath: string): string` 供后续任务的同一测试文件复用。 + +- [ ] **Step 1: 确认干净基线** + +Run: `npm test` + +Expected: TypeScript 编译成功,现有 114 项 `node:test` 测试全部通过,进程退出码为 0。 + +- [ ] **Step 2: 写 canonical 指导的失败测试** + +Create `src/test/repositoryPolicy.test.ts` with: + +```ts +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const REPO_ROOT = resolve(__dirname, '..', '..'); + +function repoFile(relativePath: string): string { + return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8'); +} + +test('AGENTS is the canonical Codex repository policy', () => { + const agents = repoFile('AGENTS.md'); + assert.match(agents, /Claude-only in v2\.2\.1/); + assert.match(agents, /no new runtime dependencies/i); + assert.match(agents, /all seven UI locales/i); + assert.match(agents, /push, open a pull request, merge, or publish a release/i); + assert.match(agents, /Generated with \[OpenAI Codex\]/); +}); + +test('AGENTS links a faithful Simplified-Chinese review copy', () => { + const agents = repoFile('AGENTS.md'); + const chinese = repoFile('AGENTS.zh-CN.md'); + assert.match(agents, /AGENTS\.zh-CN\.md/); + assert.match(chinese, /v2\.2\.1 保持 Claude-only/); + assert.match(chinese, /中文链接排在英文链接之前/); + assert.match(chinese, /推送、创建 PR、合并或发布 Release/); +}); + +test('CLAUDE is a compatibility entry point, not a conflicting policy source', () => { + const claude = repoFile('CLAUDE.md'); + assert.match(claude, /AGENTS\.md.*canonical repository policy/); + assert.match(claude, /polling always follows\s+`refreshInterval`/); + assert.doesNotMatch(claude, /activity-aware: ~15 s|never writes to `~\/\.claude\/`/); +}); +``` + +- [ ] **Step 3: 运行测试并确认它因文件缺失而失败** + +Run: `npm test` + +Expected: FAIL;`repositoryPolicy.test.js` 报 `ENOENT`,缺少 `AGENTS.md`。 + +- [ ] **Step 4: 创建英文 canonical AGENTS** + +Create `AGENTS.md` with exactly: + +````md +# AGENTS.md + +Repository guidance for OpenAI Codex and other agentic contributors. A faithful +Simplified-Chinese review copy lives in [AGENTS.zh-CN.md](AGENTS.zh-CN.md). + +## Product identity and scope + +- Claude Code Usage is a VS Code extension that reads Claude Code's local JSONL + usage logs and displays exact token totals, cost estimates, and OAuth quota. +- The product remains Claude-only in v2.2.1. Codex is a development tool in + this release, not a completed usage provider or a reason to rename the product. +- 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 conversation JSONL files. Treat credential handling as security-sensitive + and preserve the existing reviewed behavior. +- Add no new runtime dependencies in v2.2.1. + +## Architecture boundaries + +- `src/extension.ts`: activation, commands, refresh orchestration, watcher, + coalescing, settings changes, and diagnostic output. +- `src/dataLoader.ts`: JSONL discovery/parsing, deduplication, attribution, + content analysis, and usage aggregation. +- `src/settings.ts`: the `SETTINGS` catalog and `SettingsStore`; do not scatter + direct configuration reads. +- `src/statusBar.ts`: status-bar token/cost/quota/context presentation. +- `src/webview.ts`: dashboard HTML and client behavior. +- `src/i18n.ts`: all user-facing copy for all seven UI locales. +- `src/types.ts`: shared contracts. +- Read `ARCHITECTURE.md` before changing module ownership or the data flow. If + that change is submitted for maintainer review, also provide a faithful + Simplified-Chinese review companion. + +## Safety and privacy invariants + +- Never upload prompt text, response text, raw JSONL lines, absolute paths, raw + session IDs, credentials, or local usernames. New v2.2.1 performance + diagnostics must not log them either; do not broaden older diagnostic output + without an explicit privacy review. +- Advice and optimizer network calls remain explicit user actions and may send + only the documented digest or text the user pasted. +- New settings default to existing behavior. Experimental or approximate + features default off. +- Do not read secret or credential files merely to diagnose a feature. Use + redacted metadata and fixtures. +- Do not hand-edit generated files in `out/`; edit `src/` and compile. + +## Development and tests + +```bash +npm ci +npm run compile +npm test +npx @vscode/vsce package +``` + +- Product TypeScript tests use `node:test` and live in `src/test/*.test.ts`. + GitHub automation uses focused ESM tests in `.github/scripts/*.test.mjs`. +- Use red-green TDD for every behavior change: add a focused failing test, run + it, implement the smallest change, then run the focused and full suites. +- Keep behavior tests focused and use `camelCase.test.ts` names. A repository + policy test may group closely related repository/package invariants. +- 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. + +## Localization and review documents + +- Every user-facing string goes through `I18n` with all seven UI locales: + `en`, `de-DE`, `zh-TW`, `zh-CN`, `ja`, `ko`, and `pt-BR`. +- Update all six README files together: `README.md`, `README-en.md`, + `README-zh-CN.md`, `README-zh-TW.md`, `README-ja.md`, and `README-ko.md`. +- For every artifact submitted for maintainer review—including a spec, + implementation plan, design, release checklist, policy, or substantial + contributor-document change—provide a faithful Chinese sibling or review + companion and present the Chinese link before the English link. +- Do not force-add ignored private documents unless the maintainer explicitly + requested that exact review artifact. + +## Git and release discipline + +- Preserve unrelated work in a dirty tree. Use a clean `codex/` branch or an + isolated worktree for implementation. +- 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`. +- Do not push, open a pull request, merge, or publish a release without explicit + maintainer approval. +- Do not bump `package.json` or create release tags manually. Release Drafter + prepares a draft; publishing that reviewed draft creates the tag, and the + publish workflow stamps the package version from it. + +## GitHub text attribution + +Maintainer-reviewed text generated with Codex ends with exactly: + +```md +--- +🤖 Generated with [OpenAI Codex](https://developers.openai.com/codex/) +``` + +An unreviewed automated Codex first pass uses exactly: + +```md +--- +🤖 Generated by [OpenAI Codex](https://developers.openai.com/codex/) as an automated first pass — not a maintainer decision. +``` + +- Attribution must name the actual generator. The current first-pass runner + defaults to DeepSeek and must not be relabelled Codex. +- The wrapper, not model output, owns the trusted footer and emits exactly one. +- Credit Claude Code and OpenAI Codex as development tools in all six README + files. Do not put tools in Release Drafter's human contributor list and do + not invent a `Co-Authored-By` identity. +- Keep controlled comment-only first-pass automation separate from the + privileged maintainer-only mention workflow. +```` + +- [ ] **Step 5: 创建忠实的简体中文副本** + +Create `AGENTS.zh-CN.md` with exactly: + +````md +# AGENTS.md(简体中文审阅版) + +本文件是根目录 [AGENTS.md](AGENTS.md) 的忠实中文副本,供维护者快速审阅; +执行约束以两份文件共同表达的同一规则为准。 + +## 产品定位与范围 + +- Claude Code Usage 是一个 VS Code 扩展,读取 Claude Code 本地 JSONL 用量 + 日志,展示精确 token 总量、成本估算和 OAuth 配额。 +- v2.2.1 保持 Claude-only。Codex 在这个版本中是开发工具,不是已经完成的 + 用量 provider,也不构成产品改名的理由。 +- 优先保证 token 归因准确,不追求账单级精度。精确总量、明确标注的估算、 + 某一时点的配额观测必须保持为不同概念。 +- 扩展保持 local-first、轻量和 read-mostly。绝不修改 Claude 对话 JSONL; + credential 处理属于安全敏感范围,只保留已经审阅的既有行为。 +- v2.2.1 不新增 runtime dependency。 + +## 架构边界 + +- `src/extension.ts`:激活、命令、刷新调度、watcher、合并刷新、设置变化和诊断输出。 +- `src/dataLoader.ts`:JSONL 发现/解析、去重、归因、内容分析和用量聚合。 +- `src/settings.ts`:`SETTINGS` catalog 与 `SettingsStore`;不要散落直接配置读取。 +- `src/statusBar.ts`:状态栏 token、成本、配额和 context 展示。 +- `src/webview.ts`:dashboard HTML 与客户端行为。 +- `src/i18n.ts`:七个 UI locale 的全部用户可见文案。 +- `src/types.ts`:共享 contract。 +- 改变模块职责或数据流前先读 `ARCHITECTURE.md`;若该变化需要维护者审阅, + 同时提供忠实的简体中文 review companion。 + +## 安全与隐私不变量 + +- 绝不上传 prompt、response、原始 JSONL 行、绝对路径、原始 session ID、 + credential 或本地用户名。v2.2.1 新增的性能诊断也不得记录这些内容;未经过 + 明确隐私审阅,不扩大历史诊断输出范围。 +- Advice 和 optimizer 的网络调用必须由用户明确触发,只能发送文档约定的摘要 + 或用户亲自粘贴的文字。 +- 新设置默认保持既有行为;实验性或近似功能默认关闭。 +- 不要仅为了诊断功能而读取 secret 或 credential 文件;使用脱敏 metadata 与 fixture。 +- 不要手改 `out/` 生成物;修改 `src/` 后编译。 + +## 开发与测试 + +```bash +npm ci +npm run compile +npm test +npx @vscode/vsce package +``` + +- 产品 TypeScript 测试使用 `node:test`,放在 `src/test/*.test.ts`;GitHub 自动化的 + ESM 测试放在 `.github/scripts/*.test.mjs`。 +- 每项行为变更遵循 red-green TDD:先加聚焦的失败测试并运行,再做最小实现, + 然后运行聚焦测试和完整测试。 +- 行为测试保持聚焦,文件名使用 `camelCase.test.ts`;repository policy test 可以 + 合并紧密相关的仓库/打包不变量。 +- 用户可见变更需要更新 `CHANGELOG.md` 和对应文档。 +- UI 变更还要用 F5 Extension Development Host smoke test;候选版本在条件允许时 + 需要在 macOS 和 Linux 安装 VSIX 做 smoke test。 + +## 本地化与审阅文档 + +- 每个用户可见字符串都通过 `I18n` 覆盖七个 UI locale:`en`、`de-DE`、 + `zh-TW`、`zh-CN`、`ja`、`ko`、`pt-BR`。 +- 六份 README 必须一起更新:`README.md`、`README-en.md`、`README-zh-CN.md`、 + `README-zh-TW.md`、`README-ja.md`、`README-ko.md`。 +- 每份交给维护者审阅的 artifact(包括 spec、implementation plan、design、 + release checklist、policy 或大幅 contributor 文档变更),都提供忠实中文副本 + 或中文 review companion,并在交付时先放中文链接。 +- 除非维护者明确要求那一份审阅材料,不要强制加入被 ignore 的私人文档。 + +## Git 与发布纪律 + +- 脏工作区里的无关改动必须保留;实现应使用干净的 `codex/` 分支或隔离 worktree。 +- tracked 文件默认模式为 `100644` 且使用 LF。只有确实由操作系统直接执行的程序 + 才能用 `100755`,并要登记到仓库 policy 测试;提交前用 `git ls-files --stage` 核对。 +- commit 保持聚焦;仓库通过已审阅 PR squash-merge 到 `main`。 +- 未经维护者明确批准,不得推送、创建 PR、合并或发布 Release。 +- 不得手动修改 `package.json` 版本或创建发布 tag。Release Drafter 维护 draft; + 发布已审阅 draft 后才创建 tag,publish workflow 再从 tag 写入包版本。 + +## GitHub 文本归因 + +经过维护者审阅的 Codex 文本必须以以下内容准确结尾: + +```md +--- +🤖 Generated with [OpenAI Codex](https://developers.openai.com/codex/) +``` + +未经审阅的 Codex 自动 first pass 必须使用: + +```md +--- +🤖 Generated by [OpenAI Codex](https://developers.openai.com/codex/) as an automated first pass — not a maintainer decision. +``` + +- 归因必须写实际 generator。当前 first-pass runner 默认使用 DeepSeek,不得改标 Codex。 +- trusted footer 由 wrapper 而不是模型输出负责,并且只能出现一次。 +- 在六份 README 中把 Claude Code 与 OpenAI Codex 记为开发工具;不得把工具放入 + Release Drafter 的人类 contributor 列表,也不得编造 `Co-Authored-By` 身份。 +- comment-only 的受控 first-pass automation 与 maintainer-only 的高权限 mention + workflow 必须保持分离。 +```` + +- [ ] **Step 6: 将 CLAUDE.md 收敛为兼容入口** + +Replace `CLAUDE.md` with exactly: + +```md +# CLAUDE.md + +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`. + +Claude-specific compatibility notes: + +- The product remains Claude-only in v2.2.1; Codex is a development tool. +- Claude conversation JSONL is read-only. OAuth credential refresh is a + separate, existing security-sensitive behavior; do not broaden it casually. +- JSONL activity may tune the quota-cache TTL, but polling always follows + `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. +``` + +- [ ] **Step 7: 运行聚焦测试并确认通过** + +Run: `npm run compile && node --test out/test/repositoryPolicy.test.js` + +Expected: 3 tests,3 pass,0 fail。 + +- [ ] **Step 8: 检查中英文关键约束是否成对出现** + +Run: `rg -n "Claude-only|runtime dependenc|seven UI locales|maintainer approval|OpenAI Codex" AGENTS.md && rg -n "Claude-only|runtime dependency|七个 UI locale|维护者明确批准|OpenAI Codex" AGENTS.zh-CN.md` + +Expected: 两个命令都命中五类约束;无缺项。 + +- [ ] **Step 9: 提交 agent 指导** + +```bash +git add AGENTS.md AGENTS.zh-CN.md CLAUDE.md src/test/repositoryPolicy.test.ts +git commit -m "docs: establish Codex repository guidance" +``` + +Expected: 一个只包含两份 agent 指导、Claude Code 兼容入口和 policy test 源文件的 commit;`out/` 生成物保持 ignored 且不提交。 + +--- + +### Task 2: 固化 LF、文件模式、ignore 与 VSIX 排除边界 + +**Files:** +- Create: `.gitattributes` +- Modify: `.gitignore:27` +- Modify: `.vscodeignore:1-28` +- Modify: `src/test/repositoryPolicy.test.ts` + +**Interfaces:** +- Consumes: Task 1 的 `REPO_ROOT` 与 `repoFile(relativePath: string): string`。 +- Produces: LF 规则、二进制扩展名、agent 本地目录 ignore 集合、VSIX forbidden-path 集合,以及 tracked mode 回归检查。 + +- [ ] **Step 1: 在测试文件中加入失败的仓库卫生断言** + +Add this import to `src/test/repositoryPolicy.test.ts`: + +```ts +import { execFileSync } from 'node:child_process'; +``` + +Append exactly: + +```ts +function activePatterns(relativePath: string): Set { + return new Set( + repoFile(relativePath) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')), + ); +} + +test('line endings and tracked file modes are repository-safe', () => { + const attributes = repoFile('.gitattributes'); + assert.match(attributes, /^\* text=auto eol=lf$/m); + for (const binary of ['*.png binary', '*.jpg binary', '*.jpeg binary', '*.gif binary', '*.webp binary', '*.ico binary', '*.vsix binary']) { + assert.match(attributes, new RegExp(`^${binary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm')); + } + + const badModes = execFileSync('git', ['ls-files', '--stage'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + .trim() + .split('\n') + .filter(Boolean) + .filter((line) => !line.startsWith('100644 ')); + assert.deepEqual(badModes, []); +}); + +test('git and VSIX ignores exclude private and development-only material', () => { + const gitIgnore = activePatterns('.gitignore'); + for (const pattern of ['out', 'node_modules', '*.vsix', '.env', '.env.*', 'secrets.json', 'CLAUDE.local.md', 'docs/', '.claude/', '.agents/', '.codex/', '.worktrees/']) { + assert.ok(gitIgnore.has(pattern), `.gitignore missing ${pattern}`); + } + + const vscodeIgnore = activePatterns('.vscodeignore'); + for (const pattern of ['.github/**', 'src/**', 'out/test/**', 'AGENTS.md', 'AGENTS.zh-CN.md', 'CLAUDE.md', 'CLAUDE.local.md', 'CONTRIBUTING.md', 'docs/**', '.claude/**', '.agents/**', '.codex/**', '.worktrees/**', '.env', '**/.env', '**/.env.*', '**/secrets.json', '**/*.pem', '**/*.key', '**/*.p12', '**/*.pfx']) { + assert.ok(vscodeIgnore.has(pattern), `.vscodeignore missing ${pattern}`); + } +}); +``` + +- [ ] **Step 2: 运行聚焦测试并确认新断言失败** + +Run: `npm run compile && node --test out/test/repositoryPolicy.test.js` + +Expected: FAIL;首先报告 `.gitattributes` 的 `ENOENT`。 + +- [ ] **Step 3: 添加 LF 与二进制规则** + +Create `.gitattributes` with exactly: + +```gitattributes +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.vsix binary +``` + +- [ ] **Step 4: 隔离 agent 本地状态和仓库内 worktree** + +Append to `.gitignore`: + +```gitignore + +# Agent-local state and in-repository worktrees are never source artifacts. +.claude/ +.agents/ +.codex/ +.worktrees/ +``` + +- [ ] **Step 5: 扩大 VSIX 指令与本地状态排除集合** + +Insert after `.claude/**` in `.vscodeignore`: + +```gitignore +.agents/** +.codex/** +.worktrees/** +``` + +Insert after `out/test/**`: + +```gitignore +AGENTS.md +AGENTS.zh-CN.md +``` + +Append the explicit secret/key package denylist: + +```gitignore +**/.env +**/.env.* +**/secrets.json +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +``` + +- [ ] **Step 6: 规范当前 tracked 文件模式** + +Run: `git ls-files --stage | awk '$1 != "100644" { print $4 }'` + +Expected: 干净 v2.2.0 基线无输出。若出现文件,停止本 task,逐个核对精确输出并在计划中登记真实 executable allowlist;不得用目录或 glob 批量改 mode。 + +- [ ] **Step 7: 运行聚焦测试和 whitespace 检查** + +Run: `npm run compile && node --test out/test/repositoryPolicy.test.js` + +Expected: 5 tests,5 pass,0 fail。 + +Run: `git diff --check` + +Expected: 无输出,退出码 0。 + +- [ ] **Step 8: 提交仓库卫生防护** + +```bash +git add .gitattributes .gitignore .vscodeignore src/test/repositoryPolicy.test.ts +git commit -m "chore: protect repository and package boundaries" +``` + +Expected: commit 不包含任何本地 secret、`.vsix` 或 agent 状态文件。 + +--- + +### Task 3: 在六份 README 如实记录工具致谢并修正 locale 数量 + +**Files:** +- Modify: `README.md:338-340` +- Modify: `README-en.md:73-76` +- Modify: `README-zh-CN.md:187-200` +- Modify: `README-zh-TW.md:73-76` +- Modify: `README-ja.md:73-76` +- Modify: `README-ko.md:73-76` +- Modify: `CHANGELOG.md:1-8` +- Modify: `.github/PULL_REQUEST_TEMPLATE.md:31-35` +- Modify: `src/test/repositoryPolicy.test.ts` + +**Interfaces:** +- Consumes: Task 1 的 README 同步规则与“工具不是人类 contributor”边界。 +- Produces: 六份 README 中一致的 OpenAI Codex tool-credit 链接;PR checklist 的七 locale 约束。 + +- [ ] **Step 1: 添加失败的多语言文档断言** + +Append to `src/test/repositoryPolicy.test.ts`: + +```ts +test('all six README files credit both development tools', () => { + const readmes = [ + 'README.md', + 'README-en.md', + 'README-zh-CN.md', + 'README-zh-TW.md', + 'README-ja.md', + 'README-ko.md', + ]; + for (const readme of readmes) { + const body = repoFile(readme); + assert.match(body, /https:\/\/claude\.com\/claude-code/, `${readme} missing Claude Code credit`); + assert.match(body, /https:\/\/developers\.openai\.com\/codex\//, `${readme} missing OpenAI Codex credit`); + } +}); + +test('pull request checklist names the actual seven UI locales', () => { + const packageJson = JSON.parse(repoFile('package.json')) as { + contributes: { configuration: { properties: Record } }; + }; + const languageValues = packageJson.contributes.configuration.properties['claudeCodeUsage.language'].enum ?? []; + assert.equal(languageValues.filter((value) => value !== 'auto').length, 7); + + const template = repoFile('.github/PULL_REQUEST_TEMPLATE.md'); + assert.match(template, /all seven UI locales/); + assert.doesNotMatch(template, /all six languages/); + assert.match(template, /all six README editions/); +}); + +test('changelog records the released baseline and the v2.2.1 tooling transition', () => { + const changelog = repoFile('CHANGELOG.md'); + assert.match(changelog, /^## \[2\.2\.1\] — Unreleased$/m); + assert.match(changelog, /^## \[2\.2\.0\] — 2026-07-07$/m); + assert.match(changelog, /OpenAI Codex/); + assert.doesNotMatch(changelog, /^## \[2\.2\.0\] — Unreleased$/m); +}); +``` + +- [ ] **Step 2: 运行聚焦测试并确认 Codex credit 缺失** + +Run: `npm run compile && node --test out/test/repositoryPolicy.test.js` + +Expected: FAIL;`all six README files credit both development tools` 首先指出 `README.md missing OpenAI Codex credit`。 + +- [ ] **Step 3: 更新英文主 README 与英文精简 README** + +In `README.md`, insert after the paragraph ending with `see [CHANGELOG.md](CHANGELOG.md).`: + +```md + +Development-tool credit: repository maintenance uses both +[Claude Code](https://claude.com/claude-code) and +[OpenAI Codex](https://developers.openai.com/codex/). This credits the tools +separately from human contributors: Codex is not added to Release Drafter's +contributor list, and no fabricated `Co-Authored-By` identity is used for it. +``` + +In `README-en.md`, insert after the existing Credits paragraph: + +```md + +Development-tool credit: repository maintenance uses both [Claude Code](https://claude.com/claude-code) and [OpenAI Codex](https://developers.openai.com/codex/). This credits tools separately from human contributors; Codex is not added to Release Drafter's contributor list and receives no fabricated `Co-Authored-By` identity. +``` + +- [ ] **Step 4: 更新简体与繁体中文 README** + +In `README-zh-CN.md`, insert after the opening Credits paragraph ending in `CHANGELOG.md`: + +```md + +开发工具致谢:仓库维护同时使用了 [Claude Code](https://claude.com/claude-code) 和 [OpenAI Codex](https://developers.openai.com/codex/)。这只记录开发工具,与人类贡献者身份分开;Codex 不会进入 Release Drafter 的人类 contributor 列表,也不会获得伪造的 `Co-Authored-By` 身份。 +``` + +In `README-zh-TW.md`, insert after the existing Credits paragraph: + +```md + +開發工具致謝:repository 維護同時使用 [Claude Code](https://claude.com/claude-code) 與 [OpenAI Codex](https://developers.openai.com/codex/)。這只記錄開發工具,與人類貢獻者身分分開;Codex 不會列入 Release Drafter 的人類 contributor 名單,也不會使用虛構的 `Co-Authored-By` 身分。 +``` + +- [ ] **Step 5: 更新日文与韩文 README** + +In `README-ja.md`, insert after the existing Credits paragraph: + +```md + +開発ツールのクレジット:リポジトリの保守には [Claude Code](https://claude.com/claude-code) と [OpenAI Codex](https://developers.openai.com/codex/) の両方を使用しています。これは人間のコントリビューターとは別のツール表記であり、Codex を Release Drafter のコントリビューター一覧に加えたり、架空の `Co-Authored-By` ID を付けたりしません。 +``` + +In `README-ko.md`, insert after the existing Credits paragraph: + +```md + +개발 도구 크레딧: 저장소 유지보수에는 [Claude Code](https://claude.com/claude-code)와 [OpenAI Codex](https://developers.openai.com/codex/)를 함께 사용합니다. 이는 사람 기여자와 분리된 도구 표기이며, Codex를 Release Drafter의 사람 기여자 목록에 넣거나 허위 `Co-Authored-By` 신원을 부여하지 않습니다. +``` + +- [ ] **Step 6: 修正 PR template 的 locale checklist** + +Replace: + +```md +- [ ] User-facing strings go through `I18n` with **all six languages** filled +``` + +with: + +```md +- [ ] User-facing strings go through `I18n` with **all seven UI locales** filled +- [ ] Behaviour/documentation changes update **all six README editions** together +``` + +- [ ] **Step 7: 修正发布基线并建立 v2.2.1 CHANGELOG 区段** + +Change `## [2.2.0] — Unreleased` to `## [2.2.0] — 2026-07-07`, then insert before it: + +```md +## [2.2.1] — Unreleased + +### Changed +- **Codex maintenance handoff** — `AGENTS.md` is now the canonical repository + policy with a Simplified-Chinese review copy. Claude Code and OpenAI Codex + are credited as development tools, separately from human contributors. +``` + +Later v2.2.1 plans append their own `Fixed`, `Diagnostics`, or `Security` bullets under this single header; they must not create a second `2.2.1` section. + +- [ ] **Step 8: 运行聚焦测试并检查六份文件同步性** + +Run: `npm run compile && node --test out/test/repositoryPolicy.test.js` + +Expected: 8 tests,8 pass,0 fail。 + +Run: `for file in README.md README-en.md README-zh-CN.md README-zh-TW.md README-ja.md README-ko.md; do rg -n "developers.openai.com/codex/" "$file" || exit 1; done` + +Expected: 每个 README 恰好至少出现一条 OpenAI Codex tool-credit 命中;任何文件无命中都会令检查不完整。 + +- [ ] **Step 9: 提交多语言工具致谢** + +```bash +git add README.md README-en.md README-zh-CN.md README-zh-TW.md README-ja.md README-ko.md CHANGELOG.md .github/PULL_REQUEST_TEMPLATE.md src/test/repositoryPolicy.test.ts +git commit -m "docs: credit Codex development tooling" +``` + +Expected: commit 同时包含六份 README,不修改 `.github/release-drafter.yml`,也不添加任何 `Co-Authored-By` trailer。 + +--- + +### Task 4: 执行完整测试与实际 VSIX 清单审计 + +**Files:** +- Verify only: all tracked source and generated test output +- Produce outside repository: `/tmp/claude-code-usage-policy-review.vsix` +- Produce outside repository: `/tmp/claude-code-usage-policy-review.files` + +**Interfaces:** +- Consumes: Tasks 1-3 的 policy tests、`.vscodeignore` 和 README 文档。 +- Produces: 可附在 PR/release checklist 的零失败测试结果与不含私有/开发文件的真实 VSIX 清单证据。 + +- [ ] **Step 1: 运行完整编译测试套件** + +Run: `npm test` + +Expected: 编译退出码 0;全部 `node:test` 通过,`fail 0`。 + +- [ ] **Step 2: 生成候选 VSIX 到明确的临时路径** + +Run: `npx @vscode/vsce package --out /tmp/claude-code-usage-policy-review.vsix` + +Codex Desktop equivalent: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" -e 'require("./node_modules/@vscode/vsce/out/package").pack({ cwd: process.cwd(), packagePath: "/tmp/claude-code-usage-policy-review.vsix", dependencies: false }).catch((error) => { console.error(error); process.exit(1); })' +``` + +Expected: 编译和 pinned VSCE pack 均退出 0,并写入 `/tmp/claude-code-usage-policy-review.vsix`;`dependencies: false` 只跳过缺失 npm 的 dependency/prepublish 探测,前一行显式编译不能省略。文件名故意不冒充尚未由 Release Drafter 发布的 v2.2.1,且不得在仓库根目录留下 `*.vsix`。 + +- [ ] **Step 3: 导出并检查真实包清单** + +Run: `unzip -Z1 /tmp/claude-code-usage-policy-review.vsix | sort > /tmp/claude-code-usage-policy-review.files` + +Expected: 退出码 0,清单非空。 + +Run: + +```bash +if rg -n '^extension/(AGENTS(?:\.zh-CN)?\.md|CLAUDE(?:\.local)?\.md|CONTRIBUTING\.md|docs/|\.github/|src/|out/test/|(?:.*/)?\.env(?:\..*)?$|(?:.*/)?secrets\.json$|\.claude/|\.agents/|\.codex/|\.worktrees/|.*\.(?:pem|key|p12|pfx)$)' /tmp/claude-code-usage-policy-review.files; then + exit 1 +fi +``` + +Expected: 无 forbidden-path 输出,命令退出码 0。 + +Run: + +```bash +for required in extension/package.json extension/out/extension.js extension/readme.md extension/LICENSE.txt extension/icon.png; do + rg -Fqx "$required" /tmp/claude-code-usage-policy-review.files || exit 1 + test "$(rg -Fxc "$required" /tmp/claude-code-usage-policy-review.files)" -eq 1 || exit 1 +done +``` + +Expected: 五个 required artifact 各存在且恰好命中一次;任一缺失或重复都会退出 1。 + +- [ ] **Step 4: 检查 diff、文件模式与工作树** + +Run: `git diff --check upstream/main...HEAD` + +Expected: 无输出,退出码 0。 + +Run: `git ls-files --stage | awk '$1 != "100644" { print }'` + +Expected: 无输出。 + +Run: `git status --short` + +Expected: 无输出;临时 VSIX 和清单都位于 `/tmp`,不污染工作树。 + +- [ ] **Step 5: 记录验证证据,不制造空 commit** + +在 PR 或 release checklist 中记录 `npm test` 的通过摘要、VSIX 路径、forbidden-path 检查无输出和 required artifact 五项命中。该步骤不修改仓库,因此不创建空 commit。 diff --git a/docs/superpowers/plans/2026-07-17-github-attribution-and-automation-hardening.zh-CN.md b/docs/superpowers/plans/2026-07-17-github-attribution-and-automation-hardening.zh-CN.md new file mode 100644 index 00000000..9eeb5e33 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-github-attribution-and-automation-hardening.zh-CN.md @@ -0,0 +1,907 @@ +# GitHub 归因与自动化加固 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 issue/PR first-pass 自动回复按“实际产生最终正文的 tier/provider”确定性添加且只添加一个可信 footer;同时把环境、PR diff、仓库文件读取、symlink、secret path 与 GitHub POST payload 收紧为 fail closed,并保持高权限 mention agent 的权限边界不变。 + +**已批准设计:** [V2.2.1 稳定化设计(简体中文)](../specs/2026-07-17-v2.2.1-stabilization-design.zh-CN.md) + +**Architecture:** 把 provider/transport 校验、footer 包装、环境 preflight 和安全 repo reader 提取到 `.github/scripts/first-pass-lib.mjs`,用 Node 原生测试覆盖。`.github/scripts/first-pass.mjs` 仍使用现有 Anthropic Messages-compatible transport,只负责两级模型编排和 GitHub comment I/O;它分别校验 cheap/pro provider,并把最终被采用回复对应的 provider 传给 trusted wrapper。当前 transport 只允许 DeepSeek 或 Claude 自动归因;Codex 自动归因只有在未来真正实现并锁定 OpenAI/Codex transport 后才能启用。两个 comment-only workflow 只读 base repository;`.github/workflows/claude.yml` 完全不修改。 + +**Tech Stack:** Node.js 20 ESM、`node:test`、GitHub Actions YAML、现有 Anthropic Messages-compatible HTTP endpoint、GitHub Issues Comments REST API。 + +## Global Constraints + +- Attribution 描述的是实际生成最终正文的 provider,不是 workflow 名称、客户端或曾经尝试过但未采用的 tier。 +- cheap 与 pro 必须分别配置、分别校验:`CCU_BOT_GENERATOR` 对应 `CCU_BOT_MODEL`,`CCU_BOT_GENERATOR_PRO` 对应 `CCU_BOT_MODEL_PRO`。 +- 当前 runner 的 transport 是代码内固定的 `anthropic-messages`,不是可由仓库变量伪造的字符串;它只允许 `deepseek` 和 `claude`。任何 `codex` 配置都必须在发请求或发 comment 前失败。 +- 未来只有实现、测试并锁定真实的 `openai-responses-codex` transport 后,automatic Codex footer 才可启用;本计划仅为 pure policy 写一条 future-contract test,不实现该 transport。 +- 维护者审阅过的 Codex 文本使用 `Generated with`;自动 first pass 使用 `Generated by ... as an automated first pass — not a maintainer decision.`。二者不可混用。 +- trusted wrapper 而不是模型 prompt 添加 footer;模型输出中的 header/footer/disclaimer 即使藏在 blockquote、list、code fence、不同大小写或不同链接写法中也要剥离,最终 comment 只能有一个精确 suffix。 +- first-pass workflow 只读 base repository、永不 checkout 或执行 PR head、只创建一条 comment,不 label/close/approve/merge/push。 +- PR diff 获取失败或为空时停止,不发布无 diff 支撑的 review。 +- 每次 runner 建立一个共享 repo-read session;grounding docs 与模型后续请求共同使用同一去重集合和预算。整个 comment 生命周期最多读取 6 个唯一文件、每文件 16,000 bytes、总计 60,000 bytes;预算按 UTF-8 bytes 而不是 JavaScript 字符数计算。 +- readable path 是 strict allowlist:仅显式 root docs/config 与 `src/**`;同时应用 secret denylist。拒绝 hidden component、traversal、absolute path、NUL、symlink、root escape 与非文本扩展名。 +- 公共 issue/PR 输入不能宣称为“prompt-injection safe”;准确表述为固定 runner 限制了模型能力,但模型输出仍可能错误。 +- v2.2.1 不新增 runtime dependency;自动化测试只使用 Node 20 内置模块。 +- `.github/workflows/claude.yml` 是独立的 maintainer-only 高权限 workflow;不得改 action、权限、trigger 或 allowlist,也不在 v2.2.1 迁移到 Codex。 +- 本中文版计划是本轮 `CONTRIBUTING.md` 英文变更的中文审阅伴随说明。以后任何需要维护者审阅的 policy/contributor 文档,都应同时提供中文 sibling 或等价的中文 review companion。 +- README tool credit 由 `2026-07-17-codex-takeover-hygiene.zh-CN.md` 负责;本计划不重复修改六份 README。 +- `CHANGELOG.md` 已由前置 hygiene 计划建立 `## [2.2.1] — Unreleased`;本计划只能向该节追加,不得再建同名版本节。 +- 每项行为变化遵循 red-green TDD;提交前所有 TypeScript 与 automation ESM tests 必须零失败。 + +## Codex Desktop command bootstrap + +Codex Desktop 的 shell 可能没有 `node`、`npm` 或 `npx`。以下路径是本工作区已验证的 runtime;计划中的 `node ...` 是 CI/普通终端写法,Codex 执行时使用对应的精确命令,不得临时 `npx` 下载工具: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js .github/scripts/*.test.mjs +"$CCU_NODE" node_modules/@vscode/vsce/vsce ls --no-dependencies +``` + +若另一个环境有 npm,`npm test` 仍是 canonical aggregate command。任何临时 VSIX 都使用不冒充正式版本的 `/tmp/ccu-automation-audit.vsix`,不写入仓库;正式 packaging gate 由 takeover-hygiene 计划负责。 +每个执行步骤都视为新的 shell;凡后文单独出现 `"$CCU_NODE"`,必须在同一个 code block/command 前重新赋值为上述绝对路径。 + +## File map + +- Create: `.github/scripts/first-pass-lib.mjs` — transport/provider、format、preflight 与 repo-read 纯策略。 +- Create: `.github/scripts/first-pass.test.mjs` — provider/tier、trusted footer、安全读取与 preflight 单元测试。 +- Create: `.github/scripts/automationPolicy.test.mjs` — runner/workflow/CONTRIBUTING/CHANGELOG 静态边界测试。 +- Modify: `.github/scripts/first-pass.mjs` — 接入纯策略、记录实际 final tier/provider、加入 AGENTS grounding、拒绝空 PR diff、POST `commentBody`。 +- Modify: `.github/workflows/issue-first-pass.yml` — 准确安全说明、cheap/pro generator 配置、固定 checkout action。 +- Modify: `.github/workflows/pr-first-pass.yml` — 同上,并让 diff 获取 fail closed。 +- Modify: `package.json` — 把 `.github/scripts/*.test.mjs` 纳入现有 `npm test`。 +- Modify: `CONTRIBUTING.md` — 分开说明 current automatic first pass、maintainer-reviewed Codex 文本和 privileged mention agent。 +- Modify: `CHANGELOG.md` — 向现有 v2.2.1 Unreleased 节追加 automation hardening。 +- Verify only: `.github/workflows/claude.yml` — 由 policy test 与 git diff 双重锁定,绝不修改。 + +--- + +### Task 1: 定义 transport-aware、per-tier 的 truthful attribution + +**Files:** +- Create: `.github/scripts/first-pass-lib.mjs` +- Create: `.github/scripts/first-pass.test.mjs` +- Modify: `package.json:137-142` + +**Interfaces:** +- Consumes: 单个 tier 的 generator ID、单个 model ID、可信的代码内 transport ID。 +- Produces: `resolveGeneratorAttribution(rawId, modelName, transport): GeneratorAttribution`;`formatAutomatedComment(reply, { kind, generator }): string`。 + +- [ ] **Step 1: 写 provider/transport/footer 的失败测试** + +Create `.github/scripts/first-pass.test.mjs` with these mandatory cases: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatAutomatedComment, + resolveGeneratorAttribution, +} from './first-pass-lib.mjs'; + +const ANTHROPIC_MESSAGES = 'anthropic-messages'; + +test('current transport truthfully supports DeepSeek and Claude per tier', () => { + const cheap = resolveGeneratorAttribution(undefined, 'deepseek-v4-flash', ANTHROPIC_MESSAGES); + const pro = resolveGeneratorAttribution('claude', 'claude-sonnet-4-5', ANTHROPIC_MESSAGES); + assert.equal(cheap.id, 'deepseek'); + assert.equal(pro.id, 'claude'); + + assert.match( + formatAutomatedComment('cheap body', { kind: 'reply', generator: cheap }), + /Generated by \[DeepSeek\]\(https:\/\/www\.deepseek\.com\/\)/, + ); + assert.match( + formatAutomatedComment('pro body', { kind: 'review', generator: pro }), + /Generated by \[Claude\]\(https:\/\/www\.anthropic\.com\/claude\)/, + ); +}); + +test('a configured provider must match that tier model', () => { + assert.throws( + () => resolveGeneratorAttribution('deepseek', 'claude-sonnet-4-5', ANTHROPIC_MESSAGES), + /does not match configured generator deepseek/, + ); + assert.throws( + () => resolveGeneratorAttribution(undefined, 'third-party-mystery-model', ANTHROPIC_MESSAGES), + /Cannot infer generator/, + ); +}); + +test('Codex automatic attribution fails closed on the current transport', () => { + assert.throws( + () => resolveGeneratorAttribution('codex', 'gpt-5.3-codex', ANTHROPIC_MESSAGES), + /requires transport openai-responses-codex/, + ); +}); + +test('future Codex attribution requires its trusted transport contract', () => { + const future = resolveGeneratorAttribution( + 'codex', + 'gpt-5.3-codex', + 'openai-responses-codex', + ); + assert.equal(future.id, 'codex'); + assert.match(future.automaticFooter, /Generated by \[OpenAI Codex\]/); +}); +``` + +Add an adversarial spoof test; every variant must disappear and the exact trusted suffix must occur once: + +```js +test('trusted wrapper strips spoofed markdown variants and appends one exact suffix', () => { + const generator = resolveGeneratorAttribution(undefined, 'deepseek-v4-flash', ANTHROPIC_MESSAGES); + const untrusted = [ + '> 🤖 AUTOMATED FIRST-PASS REVIEW (via Claude Code)', + '', + 'Useful body', + '', + '- 🤖 Generated with [OpenAI Codex](https://evil.invalid/codex)', + '```md', + '🤖 generated BY DeepSeek as an automated first pass', + '```', + '1. This is model-generated — not a maintainer decision.', + ].join('\n'); + const comment = formatAutomatedComment(untrusted, { kind: 'review', generator }); + const suffix = [ + '---', + '🤖 Generated by [DeepSeek](https://www.deepseek.com/) as an automated first pass — not a maintainer decision.', + ].join('\n'); + + assert.match(comment, /Useful body/); + assert.equal(comment.endsWith(suffix), true); + assert.equal((comment.match(/Generated\s+(?:with|by)/giu) ?? []).length, 1); + assert.equal((comment.match(/Automated first-pass review/giu) ?? []).length, 1); + assert.doesNotMatch(comment, /evil\.invalid|Claude Code|OpenAI Codex/iu); +}); +``` + +- [ ] **Step 2: 运行测试并确认 module 尚不存在** + +Run: `node --test .github/scripts/first-pass.test.mjs` + +Codex exact: `CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node; "$CCU_NODE" --test .github/scripts/first-pass.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `first-pass-lib.mjs`。 + +- [ ] **Step 3: 实现 generator registry 与 transport gate** + +Create `.github/scripts/first-pass-lib.mjs`. The registry and resolver must have this shape: + +```js +const GENERATORS = Object.freeze({ + deepseek: Object.freeze({ + id: 'deepseek', + label: 'DeepSeek', + url: 'https://www.deepseek.com/', + modelPattern: /deepseek/i, + transports: new Set(['anthropic-messages']), + }), + claude: Object.freeze({ + id: 'claude', + label: 'Claude', + url: 'https://www.anthropic.com/claude', + modelPattern: /(?:^|[-_/])claude(?:$|[-_/])/i, + transports: new Set(['anthropic-messages']), + }), + codex: Object.freeze({ + id: 'codex', + label: 'OpenAI Codex', + url: 'https://developers.openai.com/codex/', + modelPattern: /(?:^|[-_/])codex(?:$|[-_/])/i, + transports: new Set(['openai-responses-codex']), + }), +}); + +function inferGenerator(modelName) { + const matches = Object.values(GENERATORS).filter((item) => item.modelPattern.test(modelName)); + if (matches.length !== 1) { + throw new Error(`Cannot infer generator from model ${modelName}`); + } + return matches[0].id; +} + +export function resolveGeneratorAttribution(rawId, rawModelName, transport) { + const modelName = String(rawModelName || '').trim(); + if (!modelName) throw new Error('A model id is required for generator attribution'); + const id = String(rawId || inferGenerator(modelName)).trim().toLowerCase(); + const generator = GENERATORS[id]; + if (!generator) throw new Error(`Unsupported generator: ${id}`); + if (!generator.modelPattern.test(modelName)) { + throw new Error(`Model ${modelName} does not match configured generator ${id}`); + } + if (!generator.transports.has(transport)) { + const required = [...generator.transports].join(' or '); + throw new Error(`Generator ${id} requires transport ${required}; received ${transport}`); + } + return Object.freeze({ + id: generator.id, + label: generator.label, + url: generator.url, + transport, + modelName, + automaticFooter: + `🤖 Generated by [${generator.label}](${generator.url}) ` + + 'as an automated first pass — not a maintainer decision.', + }); +} +``` + +Important: runner 不能从 env 接受 `openai-responses-codex`;future-contract test 只证明 pure policy 的最低门槛,不代表 transport 已实现。 + +- [ ] **Step 4: 实现抗 spoof 的 trusted wrapper** + +Implement these rules in `first-pass-lib.mjs`: + +1. Normalize CRLF to LF. +2. For inspection only, repeatedly remove Markdown blockquote/list/fence prefixes, collapse Markdown links to visible labels, remove emphasis/backticks, and lowercase. +3. Remove any line whose visible form begins with `automated first-pass`, `generated with`, `generated by`, or the old model-generated maintainer disclaimer. The leading robot emoji is optional. +4. Remove separators/fence-only lines orphaned directly around a removed spoof line; preserve ordinary code fences and body content elsewhere. +5. Reject an empty sanitized body. +6. Return exact header + body + one `---` + `generator.automaticFooter`. +7. As a final invariant, count case-insensitive `generated with|generated by` markers in the completed comment and throw unless the count is exactly one and the comment ends with the exact trusted suffix. + +The formatter output is: + +```js +return [ + `🤖 Automated first-pass ${kind}`, + '', + body, + '', + '---', + generator.automaticFooter, +].join('\n'); +``` + +- [ ] **Step 5: 运行聚焦测试并确认通过** + +Run: `node --test .github/scripts/first-pass.test.mjs` + +Expected: provider/transport、mismatch、future contract 与 spoof cases 全部 pass。 + +- [ ] **Step 6: 将 automation tests 纳入默认 test command** + +Replace `package.json` test script with: + +```json +"test": "npm run compile && node --test out/test/*.test.js .github/scripts/*.test.mjs" +``` + +Codex exact aggregate: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js .github/scripts/*.test.mjs +``` + +Expected: all suites pass, `fail 0`。 + +- [ ] **Step 7: 提交 pure attribution policy** + +```bash +git add package.json .github/scripts/first-pass-lib.mjs .github/scripts/first-pass.test.mjs +git commit -m "test: define truthful automation attribution" +``` + +Expected: commit 不触碰任何 workflow。 + +--- + +### Task 2: 实现 strict readable-root allowlist 与 byte-accurate repo reader + +**Files:** +- Modify: `.github/scripts/first-pass-lib.mjs` +- Modify: `.github/scripts/first-pass.test.mjs` + +**Interfaces:** +- Consumes: `process.env` 形状、model-requested repo-relative path 列表。 +- Produces: `validateFirstPassEnvironment(env)`;`isAllowedRepoPath(raw)`;`createRepoReadSession(options)`,其单个 session 的 `read(wanted)` 可被同一 runner 多次调用但共享去重集合与 hard budgets。 + +- [ ] **Step 1: 写 preflight、allowlist 与完整 budgets 的失败测试** + +Add fixtures using `mkdtempSync`, `mkdirSync`, `writeFileSync`, `symlinkSync`, and `rmSync` from `node:fs`. + +Required assertions: + +```js +test('preflight rejects missing credentials and ungrounded PRs', () => { + const base = { + GH_TOKEN: 'github-token', REPO: 'ClaudeCodeUsage/ClaudeCodeUsage', + EVENT_KIND: 'issue', ITEM_NUMBER: '70', ANTHROPIC_API_KEY: 'model-key', + }; + assert.equal(validateFirstPassEnvironment(base).isPr, false); + assert.throws( + () => validateFirstPassEnvironment({ ...base, ANTHROPIC_API_KEY: '' }), + /Missing required environment variables: ANTHROPIC_API_KEY/, + ); + assert.throws( + () => validateFirstPassEnvironment({ ...base, EVENT_KIND: 'pr' }), + /DIFF_FILE is required for pull requests/, + ); +}); + +test('only explicit root docs/config and src are readable', () => { + for (const allowed of [ + 'AGENTS.md', 'ARCHITECTURE.md', 'CLAUDE.md', 'CONTRIBUTING.md', + 'CHANGELOG.md', 'package.json', 'tsconfig.json', 'src/dataLoader.ts', + ]) assert.equal(isAllowedRepoPath(allowed), true, allowed); + + for (const denied of [ + '../outside.md', '/etc/passwd', 'C:\\Users\\person\\auth.json', + '.env', '.env.local', '.github/workflows/publish.yml', + '.claude/.credentials.json', 'docs/private.md', 'test/fixture.json', + 'src/.env.production', 'src/nested/oauth.json', + 'src/nested/credentials.json', 'src/nested/secrets.json', + 'src/nested/private.pem', 'src/nested/token.txt', 'src/code.bin', + ]) assert.equal(isAllowedRepoPath(denied), false, denied); +}); +``` + +Add one budget test that creates at least 8 allowed `src/*.txt` files and asserts all of the following in one deterministic run: + +- duplicate requests are de-duplicated before counting; first occurrence wins; +- only the first 6 unique allowed paths are included; +- a 20,000-byte file contributes at most 16,000 bytes; +- six large files together contribute at most 60,000 payload bytes; +- multibyte Chinese/emoji content is truncated on a valid UTF-8 boundary, never ends with `�`, and bytes—not JS characters—consume the budgets; +- a symlinked file、指向仓库外的 symlinked directory,以及指向仓库内部另一路径的 symlinked parent 都 absent; +- secret-denied and outside-root content never appears. + +先用一个 session 做单次 structured-return 断言,再用同一个 session 连续模拟 grounding 与 requested-source 两次读取,使 budgets test 精确覆盖 runner 生命周期: + +```js +const reader = createRepoReadSession({ repoRoot }); +const result = reader.read(wanted); +assert.deepEqual(result.files.map((file) => file.path), expectedFirstSixUnique); +assert.ok(result.files.every((file) => file.bytes <= 16_000)); +assert.ok(result.totalBytes <= 60_000); +assert.equal(result.files.some((file) => file.text.endsWith('\uFFFD')), false); +assert.equal(result.text.includes('outside secret'), false); + +const perRun = createRepoReadSession({ repoRoot }); +const grounding = perRun.read(['AGENTS.md', 'ARCHITECTURE.md', 'CLAUDE.md', 'CONTRIBUTING.md']); +const requested = perRun.read([ + 'AGENTS.md', // duplicate: neither a new slot nor new bytes + 'src/a.txt', 'src/b.txt', 'src/c.txt', 'src/d.txt', 'src/e.txt', 'src/f.txt', +]); +assert.equal(grounding.files.length, 4); +assert.ok(requested.files.length <= 2); +assert.ok(perRun.snapshot().filesRead <= 6); +assert.ok(perRun.snapshot().totalBytes <= 60_000); +``` + +- [ ] **Step 2: 运行测试并确认新 exports 缺失** + +Run: `node --test .github/scripts/first-pass.test.mjs` + +Expected: FAIL with missing export or unimplemented budget behavior。 + +- [ ] **Step 3: 实现路径策略,先 allowlist 再 denylist** + +Use explicit roots, not extension-only permission: + +```js +const ALLOWED_ROOT_FILES = new Set([ + 'AGENTS.md', 'ARCHITECTURE.md', 'CLAUDE.md', 'CONTRIBUTING.md', + 'CHANGELOG.md', 'package.json', 'tsconfig.json', +]); +const ALLOWED_SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|ya?ml|txt)$/i; +const DENIED_COMPONENT = /^(?:\..+|(?:auth|oauth|token|credentials?|secrets?)(?:\..*)?|.*\.(?:pem|key|p12|pfx))$/i; +``` + +`normalizedRepoPath` must: + +- normalize `\` to `/` only for validation, then reject Windows absolute paths; +- reject empty/NUL/absolute/`.`/`..`/empty components; +- reject every hidden component, not just a hidden basename; +- allow exact `ALLOWED_ROOT_FILES`, otherwise require `src/` plus allowed extension; +- apply secret patterns to every component and to secret-like basenames such as `oauth.json`, `token.txt`, `credentials.json`, `secrets.json` and key/certificate extensions. + +- [ ] **Step 4: 实现 stable-dedup、symlink-safe、UTF-8 byte budgets** + +Implementation requirements: + +1. Normalize and validate all requested paths. +2. `createRepoReadSession()` owns `seenPaths`、`filesRead` 与 `totalBytes`。每次 `read()` 都按 insertion order 规范化并查同一 `seenPaths`;已见路径不重复返回、不消耗 slot/bytes。hard maxima 在 session 生命周期内累计,不能由第二次调用重置。 +3. Resolve the real repo root and verify lexical containment. For every candidate, split the normalized relative path and `lstat` every prefix from repo root through the leaf; reject if **any** component is a symlink, including a parent symlink that points back inside the same repository. Require the leaf to be a regular file, then `realpath` and verify real containment. On the Ubuntu Actions runtime, open with `O_RDONLY | O_NOFOLLOW`, `fstat` the descriptor, and verify the opened descriptor still corresponds to the validated real file before reading, so component replacement between check/open also fails closed. +4. Read at most `min(fileBudget, remainingBudget)` bytes from that descriptor. Never call `readFileSync(..., 'utf8').slice(...)`, because that counts UTF-16 code units and can load an unbounded file. +5. Backtrack a truncated buffer to the last complete UTF-8 code point before decoding. Report the bytes actually retained. +6. Catch missing/unreadable/replaced files and skip them; never include error details containing absolute paths or file contents. +7. Each `read()` returns only newly admitted files as `{ text, files: [{ path, text, bytes }], totalBytes }`; `totalBytes` is the session cumulative total. `snapshot()` returns cumulative `{ filesRead, totalBytes }` without content. Runner uses `.text`, tests use metadata. + +Keep `maxFiles=6`, `fileBudget=16_000`, `totalBudget=60_000` as hard maxima even if caller supplies larger options; tests may only lower them. + +- [ ] **Step 5: 实现 environment preflight** + +`validateFirstPassEnvironment` must require `GH_TOKEN`, `REPO`, `EVENT_KIND`, `ITEM_NUMBER`, and `ANTHROPIC_API_KEY`; validate `owner/name` and positive item number; allow only `issue|pr`; and require `DIFF_FILE` for PR. Do not accept generator/transport claims here—Task 1 resolver owns those checks. + +- [ ] **Step 6: 运行聚焦与 aggregate tests** + +Run: + +```bash +node --test .github/scripts/first-pass.test.mjs +npm test +``` + +Codex exact: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js .github/scripts/*.test.mjs +``` + +Expected: cap/UTF-8/dedup/symlink/secret cases 全部 pass;两次 `read()` 合计仍不超过 6 files/60,000 bytes,`fail 0`。 + +- [ ] **Step 7: 提交安全 reader 与 preflight** + +```bash +git add .github/scripts/first-pass-lib.mjs .github/scripts/first-pass.test.mjs +git commit -m "fix: harden first-pass inputs and repo reads" +``` + +--- + +### Task 3: 接入实际 final tier/provider,并让 runner/workflows fail closed + +**Files:** +- Modify: `.github/scripts/first-pass-lib.mjs` +- Modify: `.github/scripts/first-pass.test.mjs` +- Modify: `.github/scripts/first-pass.mjs:1-240` +- Create: `.github/scripts/automationPolicy.test.mjs` +- Modify: `.github/workflows/issue-first-pass.yml:1-50` +- Modify: `.github/workflows/pr-first-pass.yml:1-59` +- Verify only: `.github/workflows/claude.yml` + +**Interfaces:** +- Consumes: Task 1-2 pure policies;`CCU_BOT_GENERATOR`/`CCU_BOT_MODEL` cheap pair;`CCU_BOT_GENERATOR_PRO`/`CCU_BOT_MODEL_PRO` pro pair。 +- Produces: null-aware `parseFirstPassResponse(raw)`、pure `chooseFinalReply(cheap, pro?)`、最终采用正文对应的 `finalGenerator` 和唯一 trusted `commentBody`。 + +- [ ] **Step 1: 写 runner/workflow 静态失败测试** + +Create `.github/scripts/automationPolicy.test.mjs`. In addition to least-privilege and diff gates, include these exact assertions: + +```js +test('runner attributes the tier whose reply becomes final and posts only commentBody', () => { + const runner = read('.github/scripts/first-pass.mjs'); + assert.match(runner, /const TRANSPORT = 'anthropic-messages'/); + assert.match(runner, /resolveGeneratorAttribution\(env\.CCU_BOT_GENERATOR, model, TRANSPORT\)/); + assert.match(runner, /resolveGeneratorAttribution\(env\.CCU_BOT_GENERATOR_PRO, modelPro, TRANSPORT\)/); + assert.match(runner, /parseFirstPassResponse/); + assert.match(runner, /chooseFinalReply/); + assert.match(runner, /formatAutomatedComment\(reply, \{ kind, generator: finalGenerator \}\)/); + assert.match(runner, /JSON\.stringify\(\{\s*body:\s*commentBody\s*\}\)/); + assert.doesNotMatch(runner, /JSON\.stringify\(\{\s*body:\s*reply\s*\}\)/); + assert.doesNotMatch(runner, /CCU_BOT_TRANSPORT|prompt-injection safe|injection-safe/i); +}); + +test('comment-only workflows configure cheap and pro independently', () => { + for (const workflow of [ + read('.github/workflows/issue-first-pass.yml'), + read('.github/workflows/pr-first-pass.yml'), + ]) { + assert.match(workflow, /CCU_BOT_GENERATOR:/); + assert.match(workflow, /CCU_BOT_GENERATOR_PRO:/); + assert.match(workflow, /contents: read/); + assert.doesNotMatch(workflow, /contents: write/); + assert.doesNotMatch(workflow, /CCU_BOT_TRANSPORT/); + } +}); +``` + +Also retain/add tests that: + +- runner grounds on `# AGENTS.md` and delegates formatting to code; +- issue/PR workflows use the repository-approved pinned checkout SHA; +- PR diff command has no `|| true` and is followed by `test -s /tmp/pr.diff`; +- `.github/workflows/claude.yml` still contains its current `contents: write`, `anthropics/claude-code-action@v1`, and owner/member/collaborator gate. + +- [ ] **Step 2: 运行 policy test 并确认旧 runner 失败** + +Run: `node --test .github/scripts/automationPolicy.test.mjs` + +Expected: FAIL on missing per-tier generator, `commentBody`, AGENTS grounding and diff gate。 + +- [ ] **Step 3: 接入 fixed transport 与 per-tier resolver** + +At runner startup, import Task 1-2 functions and use: + +```js +const TRANSPORT = 'anthropic-messages'; +const model = env.CCU_BOT_MODEL || 'deepseek-v4-flash'; +const modelPro = env.CCU_BOT_MODEL_PRO || 'deepseek-v4-pro'; + +let preflight; +let cheapGenerator; +let proGenerator; +try { + preflight = validateFirstPassEnvironment(env); + cheapGenerator = resolveGeneratorAttribution(env.CCU_BOT_GENERATOR, model, TRANSPORT); + proGenerator = resolveGeneratorAttribution(env.CCU_BOT_GENERATOR_PRO, modelPro, TRANSPORT); +} catch (error) { + fail(`Invalid first-pass configuration: ${error.message}`); +} +``` + +There is deliberately no `CCU_BOT_TRANSPORT` env. With this code, setting either generator to `codex` fails before any model or GitHub call. DeepSeek cheap + Claude pro (or the inverse) is allowed only when each model ID matches its own provider. + +- [ ] **Step 4: 用同一个 safe-reader session 加载 grounding docs 和 requested source** + +Runner startup 只创建一次 `const repoReader = createRepoReadSession({ repoRoot: REPO_ROOT })`。用 `repoReader.read([...]).text` 读取 `AGENTS.md`、`ARCHITECTURE.md`、`CLAUDE.md` 与 `CONTRIBUTING.md` 并在 prompt 中标记各节;后续升级请求必须复用同一个 `repoReader`。删除旧 local extension-only `ALLOWED_EXT` 和 local `readRepoFiles` implementation。`automationPolicy.test.mjs` 静态断言 runner 只出现一次 `createRepoReadSession(`,并且 grounding/requested-source 都调用同一标识符的 `.read()`;Task 2 的运行时测试负责证明累计上限。 + +For PRs, read the workflow-created diff with a separate fixed-path bounded helper after preflight; reject missing/empty diff. Never let model-requested paths name `DIFF_FILE` or `.github/**`. + +- [ ] **Step 5: 让 prompt 只请求 body** + +Remove `via Claude Code`, footer text, generator names and maintainer disclaimer from `buildSystem`. Require body markdown only, preceded by the existing control block. Keep same-language, grounded-answer and ask-when-unknown requirements. The prompt is not a security boundary; code adds the header/footer. + +- [ ] **Step 6: 修复 null-aware reply parsing,并记录实际 final tier/provider** + +先把现有 `tag()` / `parse()` 移到 `first-pass-lib.mjs` 并导出。`tag()` 找不到标签时返回 `null`,找到空标签时返回空字符串;reply fallback 必须检查 null,不能使用 truthiness: + +```js +export function parseFirstPassResponse(raw) { + const taggedReply = tag(raw, 'reply'); + const fallback = raw.replace(/[\s\S]*?<\/control>/i, '').trim(); + const reply = (taggedReply !== null ? taggedReply : fallback).trim(); + let control = { answerable: true, want_files: [] }; + const taggedControl = tag(raw, 'control'); + if (taggedControl) { + try { + const value = JSON.parse(taggedControl); + control = { + answerable: value.answerable !== false, + want_files: Array.isArray(value.want_files) ? value.want_files : [], + }; + } catch { + // Fail closed on escalation: malformed control never grants more files. + } + } + return { reply, ...control }; +} + +export function chooseFinalReply(cheap, pro) { + if (pro && typeof pro.reply === 'string' && pro.reply.trim() !== '') { + return { reply: pro.reply.trim(), generator: pro.generator }; + } + if (cheap && typeof cheap.reply === 'string' && cheap.reply.trim() !== '') { + return { reply: cheap.reply.trim(), generator: cheap.generator }; + } + throw new Error('No non-empty model reply'); +} +``` + +在 `.github/scripts/first-pass.test.mjs` 增加可运行测试(静态 regex 不算替代): + +```js +test('empty tagged pro reply preserves the cheap body and attribution', () => { + const cheap = resolveGeneratorAttribution('deepseek', 'deepseek-v4-flash', 'anthropic-messages'); + const pro = resolveGeneratorAttribution('claude', 'claude-sonnet-4-6', 'anthropic-messages'); + const parsedPro = parseFirstPassResponse('{"answerable":true} '); + assert.equal(parsedPro.reply, ''); + const selected = chooseFinalReply({ reply: 'cheap body', generator: cheap }, { + reply: parsedPro.reply, + generator: pro, + }); + assert.equal(selected.reply, 'cheap body'); + assert.equal(selected.generator.id, 'deepseek'); +}); + +test('no non-empty tier can never reach the GitHub formatter', () => { + const cheap = resolveGeneratorAttribution('deepseek', 'deepseek-v4-flash', 'anthropic-messages'); + assert.throws( + () => chooseFinalReply({ reply: ' ', generator: cheap }), + /No non-empty model reply/ + ); +}); +``` + +再用下面的 orchestration 取代旧 truthy parser/finalGenerator 分支: + +```js +let selected; +try { + const first = parseFirstPassResponse(await askModel(model, buildSystem(false), buildUser(''))); + let proCandidate; + + if (!first.answerable) { + const extra = repoReader.read(first.want_files).text; + const second = parseFirstPassResponse( + await askModel(modelPro, buildSystem(true), buildUser(extra), true) + ); + proCandidate = { reply: second.reply, generator: proGenerator }; + } + selected = chooseFinalReply( + { reply: first.reply, generator: cheapGenerator }, + proCandidate + ); +} catch (error) { + fail(`Model call failed: ${error.message}`); +} +const { reply, generator: finalGenerator } = selected; +``` + +Invariant: ` ` 是“显式空正文”,不能 fallback 成字面 XML;pro 被调用但返回空正文时,cheap 正文与 cheap attribution 一起保留。cheap/pro 都没有非空正文时,`chooseFinalReply()` 在任何 GitHub POST 前抛错,runner fail closed。 + +Before the GitHub request: + +```js +let commentBody; +try { + commentBody = formatAutomatedComment(reply, { kind, generator: finalGenerator }); +} catch (error) { + fail(`First-pass formatting failed: ${error.message}`); +} +``` + +The only allowed POST payload is: + +```js +body: JSON.stringify({ body: commentBody }), +``` + +- [ ] **Step 7: 更新两个 workflows 的 per-tier variables 与安全表述** + +Both workflow env blocks add: + +```yaml +CCU_BOT_GENERATOR: ${{ vars.CCU_BOT_GENERATOR }} +CCU_BOT_GENERATOR_PRO: ${{ vars.CCU_BOT_GENERATOR_PRO }} +``` + +Comments must say both vars are optional and inferred only for recognized DeepSeek/Claude model IDs; explicit values are `deepseek` or `claude`. Do not mention automatic Codex as configurable in v2.2.1 and do not add a transport variable. + +Replace overclaiming safety comments with: public text remains untrusted; fixed runner has no tools/credentials, reads only validated base-repo text, and model output can still be wrong. + +Use the repository-approved pinned checkout SHA. In PR workflow replace the diff step with: + +```yaml +run: | + gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} > /tmp/pr.diff + test -s /tmp/pr.diff +``` + +- [ ] **Step 8: 运行语法、policy 与 aggregate tests** + +```bash +node --check .github/scripts/first-pass-lib.mjs +node --check .github/scripts/first-pass.mjs +node --test .github/scripts/first-pass.test.mjs .github/scripts/automationPolicy.test.mjs +npm test +``` + +Codex exact: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" --check .github/scripts/first-pass-lib.mjs +"$CCU_NODE" --check .github/scripts/first-pass.mjs +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js .github/scripts/*.test.mjs +``` + +Expected: zero failures。 + +- [ ] **Step 9: 确认 privileged workflow 未改** + +Run: `git diff -- .github/workflows/claude.yml` + +Expected: no output。若出现任何输出,停止并恢复本 task 对该文件的改动,不要“顺便”迁移。 + +- [ ] **Step 10: 提交 runner/workflow 加固** + +```bash +git add .github/scripts/first-pass-lib.mjs .github/scripts/first-pass.test.mjs \ + .github/scripts/first-pass.mjs .github/scripts/automationPolicy.test.mjs \ + .github/workflows/issue-first-pass.yml .github/workflows/pr-first-pass.yml +git commit -m "fix: harden first-pass GitHub automation" +``` + +Expected: commit 不含 `.github/workflows/claude.yml`。 + +--- + +### Task 4: 更新 contributor 边界和现有 v2.2.1 changelog + +**Files:** +- Modify: `CONTRIBUTING.md:91-118` +- Modify: `CHANGELOG.md` +- Modify: `.github/scripts/automationPolicy.test.mjs` + +**Review-language note:** 本计划的本节是 `CONTRIBUTING.md` 英文替换内容的中文审阅伴随说明:自动 first pass 目前只能真实标注 DeepSeek/Claude;Codex automatic 尚未启用;维护者审阅的 Codex 文本可以使用 approved `Generated with` footer;高权限 Claude mention agent 完全独立。未来对此 policy 做需要维护者审阅的实质改动时,必须再次提供中文 sibling 或中文 review companion。 + +- [ ] **Step 1: 写 CONTRIBUTING/CHANGELOG 失败测试** + +Append: + +```js +test('CONTRIBUTING distinguishes current automatic, reviewed Codex, and privileged agent text', () => { + const contributing = read('CONTRIBUTING.md'); + assert.match(contributing, /### Controlled automatic first pass/); + assert.match(contributing, /DeepSeek or Claude/); + assert.match(contributing, /Codex automatic attribution is not enabled in v2\.2\.1/); + assert.match(contributing, /Generated with \[OpenAI Codex\]/); + assert.match(contributing, /### Maintainer-only mention agent/); + assert.match(contributing, /does not migrate this privileged workflow to Codex/); +}); + +test('automation hardening is appended to the one existing v2.2.1 section', () => { + const changelog = read('CHANGELOG.md'); + assert.equal((changelog.match(/^## \[2\.2\.1\] — Unreleased$/gm) ?? []).length, 1); + assert.match(changelog, /truthful per-tier provider attribution/); + assert.match(changelog, /bounded base-repository file reads/); +}); +``` + +- [ ] **Step 2: 运行 test 并确认旧文档失败** + +Run: `node --test .github/scripts/automationPolicy.test.mjs` + +Expected: FAIL on missing new headings/text。 + +- [ ] **Step 3: 替换 CONTRIBUTING 自动回复章节** + +Write concise English policy with these exact facts: + +````md +## Repository assistants and truthful attribution + +### Controlled automatic first pass + +New issues and external pull requests may receive one controlled, comment-only +first pass when the maintainer enables the kill switch. The current runner uses +an Anthropic Messages-compatible transport and may truthfully identify a tier as +DeepSeek or Claude. Cheap and escalation tiers are configured and attributed +independently; the footer names the provider whose reply became the final body. + +The runner checks out only the base repository, never executes contributor code, +reads at most six allowlisted text files within byte budgets, and can only post +one comment. Public input remains untrusted and model output may be wrong. A +missing or empty PR diff stops the workflow without posting a review. + +Codex automatic attribution is not enabled in v2.2.1. It requires a separately +implemented and trusted OpenAI/Codex transport; a model name or repository +variable alone cannot enable it. + +### Maintainer-reviewed Codex text + +A maintainer-reviewed issue comment or pull-request response drafted with Codex +uses: + +```md +--- +🤖 Generated with [OpenAI Codex](https://developers.openai.com/codex/) +``` + +This means the maintainer reviewed the text. It does not add a synthetic +`Co-Authored-By` identity or imply that the automatic runner used Codex. + +### Maintainer-only mention agent + +`.github/workflows/claude.yml` is a separate, maintainer-gated Claude Code +workflow with repository write permissions. v2.2.1 does not migrate this +privileged workflow to Codex. Treat every assistant reply as a starting point, +not repository authority; the maintainer makes the final decision. +```` + +Do not say that current automatic Codex is available, and do not call Codex/Claude a human contributor. Tool attribution belongs in footer and project contribution credit, not a fake commit identity. + +- [ ] **Step 4: 向已有 v2.2.1 Unreleased 节追加 changelog** + +Under the existing `## [2.2.1] — Unreleased`, merge into an existing `### Security` heading if one exists; otherwise create that subsection exactly once. Add: + +```md +- Hardened GitHub first-pass automation with truthful per-tier provider attribution, + one code-owned footer, fail-closed PR diff handling, and bounded base-repository + file reads with traversal, symlink, hidden-path, and secret-path denial. +``` + +Do not edit the v2.2.0 section/date and do not create another v2.2.1 header. + +- [ ] **Step 5: 运行聚焦和 aggregate tests** + +Run: + +```bash +node --test .github/scripts/automationPolicy.test.mjs +npm test +``` + +Expected: all pass。 + +- [ ] **Step 6: 提交 contributor-facing policy** + +```bash +git add CONTRIBUTING.md CHANGELOG.md .github/scripts/automationPolicy.test.mjs +git commit -m "docs: clarify repository automation boundaries" +``` + +Expected: commit 不修改 README、Release Drafter 或 privileged workflow。 + +--- + +### Task 5: 完整验证、package manifest 和安全差异审计 + +**Files:** +- Verify only: `.github/scripts/*.mjs`, `.github/workflows/*.yml`, `CONTRIBUTING.md`, `CHANGELOG.md`, `package.json`, `.github/workflows/claude.yml` + +- [ ] **Step 1: 运行完整 suite** + +Normal shell: `npm test` + +Codex exact: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js .github/scripts/*.test.mjs +``` + +Expected: compile success, `fail 0`。 + +- [ ] **Step 2: 检查 automation JavaScript 语法** + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +for file in .github/scripts/*.mjs; do "$CCU_NODE" --check "$file"; done +``` + +Expected: no syntax errors。 + +Validate the two edited workflow files as YAML without adding a dependency: + +```bash +ruby -e 'require "yaml"; ARGV.each { |path| raise path unless YAML.parse_file(path) }' .github/workflows/issue-first-pass.yml .github/workflows/pr-first-pass.yml +``` + +Expected: no parser error。This is a syntax gate; the Node policy tests remain authoritative for GitHub-specific permission and expression invariants. + +- [ ] **Step 3: 静态审计 transport、POST payload 与错误归因** + +```bash +rg -n "via Claude Code|prompt-injection safe|injection-safe|CCU_BOT_TRANSPORT|JSON.stringify\(\{ body: reply \}\)|\|\| true" .github/scripts/first-pass.mjs .github/workflows/issue-first-pass.yml .github/workflows/pr-first-pass.yml +``` + +Expected: no output (`rg` exit 1)。 + +Run exact footer tests again; they are the authority for spoof variants and unique suffix: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" --test .github/scripts/first-pass.test.mjs +``` + +- [ ] **Step 4: 审计 workflow 权限与 privileged workflow 不变性** + +```bash +git diff upstream/main...HEAD -- .github/workflows/claude.yml +rg -n "contents: (read|write)|pull-requests: write|issues: write" .github/workflows/issue-first-pass.yml .github/workflows/pr-first-pass.yml .github/workflows/claude.yml +``` + +Expected: first command no output;两个 first-pass workflow 只有 `contents: read` 及 comment 权限;只有 `claude.yml` 保持现有高权限。 + +- [ ] **Step 5: 检查 package manifest,不产生 provider-branded artifact** + +Run: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/@vscode/vsce/vsce ls --no-dependencies +``` + +Expected: no `.env*`, credential/key/certificate, `.claude/`, `.agents/`, `.codex/`, `.worktrees/` or review-plan files in package list。若完整 release gate 需要临时 VSIX,只可写 `/tmp/ccu-automation-audit.vsix`;不得使用版本号或 `Claude` / `Codex` 品牌名冒充正式 artifact,也不得把 VSIX 加入 git。 + +- [ ] **Step 6: 检查 diff 和工作树** + +```bash +git diff --check upstream/main...HEAD +git status --short +``` + +Expected: first command no output;完成所有计划提交后,second command no output。任何 `.github/workflows/claude.yml` diff、tracked VSIX、secret-like file 或重复 `v2.2.1` header 都是 release blocker。 diff --git a/docs/superpowers/plans/2026-07-17-v2.2.1-incremental-index.zh-CN.md b/docs/superpowers/plans/2026-07-17-v2.2.1-incremental-index.zh-CN.md new file mode 100644 index 00000000..3581af57 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-v2.2.1-incremental-index.zh-CN.md @@ -0,0 +1,1670 @@ +# V2.2.1 ClaudeUsageIndex 增量索引 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 用 extension 生命周期内的 per-file `ClaudeUsageIndex` 取代每次变化后重读全部 JSONL,使 unchanged 文件零重读,同时保证增量输出与 fresh cold load 深度相等。 + +**已批准设计:** [V2.2.1 稳定化设计(简体中文)](../specs/2026-07-17-v2.2.1-stabilization-design.zh-CN.md) + +**Architecture:** 先在未改生产代码时冻结 V2.2.0 的 legacy golden 输出;然后把“单文件解析贡献”“跨文件全局聚合”“cold adapter”作为一个原子、始终可编译的重构提交,并用 golden 证明结果不变。随后让 `ClaudeUsageIndex` 以 path/size/mtime/dev/ino manifest 与外部 attribution dependency fingerprints 缓存单文件贡献及最近一次 aggregate;watcher 文件名只作安全 hint,startup/manual/每 30 分钟做权威 reconciliation。changed/new 文件在 V2.2.1 仍整文件重读,deleted/truncated/replaced 文件立即失效;基础 `refresh()` 与 single-flight coalescing 分两个提交。extension 在被动 trigger 且 `dataChanged=false` 时跳过 heavy usage breakdown/webview,但继续更新 quota/status;manual/settings/pricing/workspace 仍用 cached records 刷新 presentation。 + +**Tech Stack:** TypeScript 5.8、Node.js 标准库、`node:test`、VS Code Extension API;依赖前置计划 `2026-07-17-v2.2.1-refresh-mitigation.zh-CN.md` 提供的 manifest、refresh trigger 和匿名 diagnostics 接口。 + +## Global Constraints + +- 目标分支为 `codex/v2.2.1-stabilization`,基线为已发布的 `v2.2.0`。 +- 必须先完成并通过 `2026-07-17-v2.2.1-refresh-mitigation.zh-CN.md`;本计划直接消费其中的 `UsageFileFingerprint`、`UsageManifest`、`scanUsageManifest()`、`RefreshTrigger` 和 `LoadUsageDiagnostics`。 +- V2.2.1 仍是 Claude-only;不得加入 Codex ingestion、provider UI 或跨设备同步。 +- 不增加 runtime dependency。 +- cache 只存在内存;不得写入磁盘、`globalState`、workspaceState 或日志。 +- manifest fingerprint 精确包含 path、size、mtime、dev、ino(平台无 dev/ino 时回退 size+mtime)。 +- unchanged 文件复用贡献;changed/new 文件整文件重读;deleted 文件移除贡献。 +- truncation、inode change 与 atomic replacement 必须失效;V2.2.1 不实现 append byte-offset/tail parser。 +- watcher filename 只是 hint;startup、manual 与每 30 分钟 reconciliation 以完整 manifest 为权威。 +- parsing contract、attribution rule、`analyzeContent` 和 content-analysis window 必须进入 cache key。 +- `windowDays` 在 request 边界统一规范化为 `Math.min(365, Math.max(1, Math.round(value)))`;contribution key、aggregate key、cutoff 和 equality test 必须消费同一个规范化值。 +- 全局 placeholder/real 去重、session title、用户消息计数、workflow/subagent attribution 与 content calibration 的结果不得变化。 +- 文件顺序必须复刻 V2.2.0:按每个文件“首个合法 timestamp”(不是最小 timestamp)做稳定排序;相同/无效 timestamp 保留 manifest discovery order,禁止增加 path/`localeCompare()` tie-break。 +- `agent-*.meta.json` 与 `/workflows/scripts` 是 JSONL 的外部 attribution dependency;其 fingerprints 变化必须让输出失效,workflow-name cache key 必须包含 `scriptsDir + workflowId`。 +- unchanged JSONL、unchanged dependencies 且 analysis 未到下一条 expiry 时必须复用最后一次 source aggregate,返回 `dataChanged=false`。被动的 poll/watch/focus/credentials 触发不得重跑 usage breakdown 或 webview render;quota/status 仍可用 cached usage 更新,manual/settings/pricing/workspace 等显式触发仍要刷新 presentation。 +- index 更新必须事务化:读/解析失败、hint 非 `ENOENT` 错误或 read-after-stat race 都不得部分提交 `entries`、manifest 或 aggregate。 +- cached contribution 与 `lastAggregate` 视为不可变;diagnostics 包装不得改写 cached records/contentAnalysis,下游只读消费。测试在 strict fixture 中 deep-freeze cached result 后执行一次 unchanged refresh。 +- JSONL read 必须在 read 后 `fstat()` 验证 identity/size/mtime;发生竞态只重试一次,第二次仍变化则本轮失败并保留旧 snapshot。 +- hint 必须 containment-check 到 resolved `/projects`,用 `lstat()` 拒绝 symlink;`ENOENT` 只表示删除,其他错误 fail closed。 +- cache 的确定性内存上界为“当前 manifest 的每个 JSONL 一份 compact contribution + 当前外部 dependency fingerprint + 一份 aggregate”;不得缓存 raw JSONL/raw parsed object,删除/replace/`clear()` 后不得保留旧 contribution 的强引用。analysis 只保留 parser 当时 window cutoff 之后的 compact events;每文件 prompt candidates 最多保留 legacy 所需的最后 600 条。skill/command candidates 必须保留该 coverage window 内的全部 compact 候选,legacy first-5000 cap 只在全局 replay/finalize 应用,否则 cutoff 前进后第 5001+ 项无法补位。analysis line 对象数不得超过该文件 `linesParsed`,嵌套候选数与实际 parsed content blocks 线性对应。 +- 诊断不得包含 prompt、绝对路径、session ID、credential 或原始 JSONL 行。 +- 现有 loader 语义诊断与 `log` 输出必须保留:`rejected` reason、dedup replace/skip、kept、user prompts 与 model count/token summaries 不能被性能诊断取代。 +- 不纳入 PR #73 的 1000-session 渲染;不借此重构 webview。 +- 每项行为变化先 RED、再 GREEN;`npm test` 必须零失败。 +- 若 cold-versus-incremental deep equality 未通过,则 V2.2.1 只交付前置 mitigation,保持 #70 开放,不合入本索引。 + +--- + +## Codex Desktop 命令运行时 + +本机 Codex Desktop 的非交互 shell 可能没有 `node/npm/npx`。计划中的 `npm test` 是 CI/普通终端的 canonical 命令;在本 worktree 执行时使用下面的精确等价命令,不允许临时下载未锁定版本: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js +"$CCU_NODE" node_modules/@vscode/vsce/vsce ls --no-dependencies +``` + +每个 focused `node --test ...` 命令都可机械替换为 `"$CCU_NODE" --test ...`;若前置 automation 计划已经创建 `.github/scripts/*.test.mjs`,再用同一 runtime 执行 `"$CCU_NODE" --test .github/scripts/*.test.mjs`。`vsce` 只使用仓库已有的 `node_modules/@vscode/vsce/vsce`。 +每个执行步骤都视为新的 shell;凡后文单独出现 `"$CCU_NODE"`,必须在同一个 code block/command 前重新赋值为上述绝对路径。 + +## 文件职责 + +- Create: `src/claudeUsageContribution.ts` — 单文件 parser 的稳定、可缓存贡献类型;不执行跨文件去重或 title 覆盖。 +- Create: `src/claudeUsageAggregate.ts` — 按旧顺序执行跨文件 dedup、title、analysis、calibration 和 attribution 聚合。 +- Create: `src/claudeUsageIndex.ts` — manifest/reuse/invalidation/single-flight/pending/reconciliation。 +- Create: `src/test/claudeUsageContribution.test.ts` — 单文件 parser 契约与 incomplete final line 测试。 +- Create: `src/test/claudeUsageAggregate.test.ts` — 跨文件 placeholder、late title、workflow/subagent 与 content analysis 测试。 +- Create: `src/test/claudeUsageIndex.test.ts` — unchanged/change/new/delete/truncate/inode/replace、cache-key、single-flight 与 30 分钟 reconciliation。 +- Create: `src/test/incrementalEquality.test.ts` — 每一步增量结果对 fresh cold load 的 `deepStrictEqual`。 +- Create: `src/test/usageIndexFixture.ts` — 只供测试使用的确定性多 session/workflow fixture writer。 +- Create: `src/test/v220LegacyCorpus.ts` — 在生产重构前固定的 V2.2.0 golden corpus 与显式 expected projection。 +- Modify: `src/test/dataLoader.test.ts` — 先加入 legacy golden characterization,再允许生产重构。 +- Modify: `src/dataLoader.ts:108-426,522-876,2166-2215` — 将 parser/aggregate 委托给新模块,同时保留 public 聚合 API。 +- Modify: `src/extension.ts:39-97,1040-1204` — extension 拥有一个 index,传 watcher hints,启动 30 分钟 reconciliation,保留现有 coalescing。 +- Modify: `src/refreshDiagnostics.ts` — loader/index 指标增加 changed/reused/removed,并继续只格式化匿名数字。 +- Modify: `CHANGELOG.md` 与六份 `README*.md` — 只有全部 equality/performance gate 通过后,才把 mitigation 文案升级为 per-file root fix 描述。 + +### Task 0: 在任何生产重构前冻结 V2.2.0 legacy golden + +**Files:** +- Create: `src/test/v220LegacyCorpus.ts` +- Modify: `src/test/dataLoader.test.ts` + +**Interfaces:** +- Consumes: 原样的 `ClaudeDataLoader.loadUsageRecords()`;本任务禁止修改 `src/dataLoader.ts`。 +- Produces: `createV220LegacyCorpus(): Promise` 与一个完全显式的 `expectedProjection`,供 Task 2 的原子重构继续运行。 + +- [ ] **Step 1: 建立同时覆盖旧顺序与跨文件状态的 deterministic corpus** + +`src/test/v220LegacyCorpus.ts` 必须写出以下文件,创建顺序就是预期 discovery order;所有 timestamp、UUID、message/request/tool id 固定: + +```ts +export interface V220LegacyCorpus { + root: string; + cleanup(): Promise; + expectedProjection: { + records: Array>; + contentAnalysis: Record; + logLines: string[]; + }; +} + +export async function createV220LegacyCorpus(): Promise { + // 1. 00-invalid.jsonl: 第一行非法 JSON,第二个合法 timestamp 为 0;证明 firstValidTimestamp。 + // 2. 01-a.jsonl 与 02-b.jsonl: first timestamp 完全相同;证明稳定 discovery order。 + // 3. 两文件共享 m1-r1:先 0-token placeholder,后 four-bucket real record。 + // 4. 子 agent JSONL 与 sibling agent-*.meta.json;另建 workflows/scripts/review-wf_same.js。 + // 5. title 单独晚到:ai-title 后 custom-title,证明 truthy custom title 覆盖且不写 undefined。 + // 6. 同 prompt 在 retry window 内重放,expected 只含一个 synthetic user record。 + // 7. assistant tool_use(id=tool-global,name=Skill,skill=review) 在一个文件, + // user tool_result(tool_use_id=tool-global) 在后一个文件;同一 user line 放两个 text blocks。 + // 8. thinking、tool call/result、command echo 与 calibration record 都在 30-day window 内。 + // expectedProjection 显式列出 records 顺序/字段、categories 的 token/chars/count、 + // recentPrompts 两项、toolResultBreakdown、skillUses、thinking maps、calibration 及两条 loader log。 + // 函数用 mkdtemp/mkdir/writeFile 写完上述八组 literal JSONL 后返回 root、 + // rm(root, { recursive: true, force: true }) cleanup 与手工列出的 expectedProjection。 +} +``` + +expected 不得通过再次调用 production helper 计算;token 估算数值用短固定 ASCII 文本手工列出,保证它真的是 golden,而不是 tautology。评审时逐项对照上述八组输入;函数若没有真实 return,TypeScript compile 会失败,不能进入 Step 3。 + +- [ ] **Step 2: 添加对完整 legacy projection 与 log 的 characterization test** + +```ts +test('v2.2.0 legacy golden fixes ordering, dedup, titles, retry, attribution and analysis', async () => { + const corpus = await createV220LegacyCorpus(); + try { + const logLines: string[] = []; + const loaded = await ClaudeDataLoader.loadUsageRecords(corpus.root, { + analyzeContent: true, + windowDays: 30, + log: (line) => logLines.push(line), + }); + assert.deepStrictEqual(projectLegacyResult(loaded, logLines), corpus.expectedProjection); + } finally { + await corpus.cleanup(); + } +}); +``` + +`projectLegacyResult()` 只删除运行时无关对象原型并把 `Date` 转 ISO;禁止排序数组、删除字段或重新计算 expected。 + +- [ ] **Step 3: 在未改生产代码时运行并固化 GREEN** + +Run: `npm run compile && node --test out/test/dataLoader.test.js` + +Codex exact: `CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node; "$CCU_NODE" node_modules/typescript/bin/tsc -p ./ && "$CCU_NODE" --test out/test/dataLoader.test.js` + +Expected: PASS。若失败,先修 fixture/expected,使其精确描述当前 V2.2.0;不得为迁就 expected 修改生产代码。 + +- [ ] **Step 4: 提交纯 characterization** + +```bash +git add src/test/v220LegacyCorpus.ts src/test/dataLoader.test.ts +git commit -m "test: characterize v2.2.0 loader semantics" +``` + +### Task 1: 准备 compact 单文件贡献(原子重构的 buildable 前半) + +Task 1 与 Task 2 是一个原子 review/commit 单元。Task 1 只新增独立、可编译的新模块和测试,不移动/删除旧 loader helper、不修改 `src/dataLoader.ts`、不提交;Task 2 在同一工作会话接入 aggregate/cold adapter、跑 legacy golden 后一次性提交全部文件。这样任何可见 commit 都是 buildable,且仓库中不存在“旧 loader 已拆掉、全局 aggregator 尚未接好”的中间状态。 + +**Files:** +- Create: `src/claudeUsageContribution.ts` +- Create: `src/test/claudeUsageContribution.test.ts` +- Create: `src/test/usageIndexFixture.ts` + +**Interfaces:** +- Consumes: `UsageFileFingerprint`、当前 `validateUsageRecord`、prompt dedup、session-path 解析规则;Task 1 暂时复制 file-local helper,旧 loader 保持完全原样。 +- Produces: `ContributionCacheKey`、`ClaudeFileContribution`、`parseClaudeUsageContent(fingerprint, content, options): ClaudeFileContribution`;稳定文件读取 wrapper 到 Task 3 再加入 read-after-`fstat()`。 + +- [ ] **Step 1: 写出单文件贡献与 incomplete-tail 的 RED 测试** + +```ts +// src/test/claudeUsageContribution.test.ts +import { after, test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { appendFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { parseClaudeUsageContent } from '../claudeUsageContribution'; + +const roots: string[] = []; +after(async () => Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })))); + +async function fingerprint(filePath: string) { + const value = await stat(filePath); + return { + path: filePath, + size: value.size, + mtimeMs: value.mtimeMs, + discoveryIndex: 0, + dev: value.dev, + ino: value.ino, + }; +} + +test('single-file contribution preserves candidates, titles and normalized analysis lines', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-contribution-')); + roots.push(root); + const dir = path.join(root, 'projects', '-work-demo'); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, 'session-a.jsonl'); + await writeFile(file, [ + JSON.stringify({ type: 'custom-title', customTitle: 'Visible title', timestamp: '2026-07-17T00:00:00.000Z' }), + JSON.stringify({ type: 'user', uuid: 'u1', timestamp: '2026-07-17T00:01:00.000Z', cwd: '/work/demo', message: { role: 'user', content: 'hello' } }), + JSON.stringify({ timestamp: '2026-07-17T00:02:00.000Z', requestId: 'r1', message: { id: 'm1', model: 'claude-sonnet-4-6', usage: { input_tokens: 10, output_tokens: 2 } } }), + ].join('\n') + '\n'); + const result = parseClaudeUsageContent(await fingerprint(file), await readFile(file, 'utf8'), { + analyzeContent: true, + windowDays: 30, + nowMs: Date.parse('2026-07-17T12:00:00.000Z'), + }); + assert.equal(result.recordCandidates.length, 2); + assert.deepEqual(result.titles, [{ sessionId: 'session-a', kind: 'custom', value: 'Visible title', line: 0 }]); + assert.equal(result.analysisLines.length, 1); + assert.equal(result.linesParsed, 3); +}); + +test('an incomplete final line is ignored then appears after the next full reread', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-tail-')); + roots.push(root); + const dir = path.join(root, 'projects', '-work-demo'); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, 'session-tail.jsonl'); + const complete = JSON.stringify({ timestamp: '2026-07-17T00:00:00.000Z', requestId: 'r1', message: { id: 'm1', model: 'claude-sonnet-4-6', usage: { input_tokens: 1, output_tokens: 1 } } }); + const second = JSON.stringify({ timestamp: '2026-07-17T00:01:00.000Z', requestId: 'r2', message: { id: 'm2', model: 'claude-sonnet-4-6', usage: { input_tokens: 2, output_tokens: 2 } } }); + await writeFile(file, `${complete}\n${second.slice(0, -2)}`); + const first = parseClaudeUsageContent(await fingerprint(file), await readFile(file, 'utf8'), { analyzeContent: false, windowDays: 30, nowMs: Date.parse('2026-07-17T12:00:00.000Z') }); + assert.equal(first.recordCandidates.length, 1); + await appendFile(file, `${second.slice(-2)}\n`); + const reread = parseClaudeUsageContent(await fingerprint(file), await readFile(file, 'utf8'), { analyzeContent: false, windowDays: 30, nowMs: Date.parse('2026-07-17T12:00:00.000Z') }); + assert.equal(reread.recordCandidates.length, 2); +}); +``` + +- [ ] **Step 2: 运行 RED,确认贡献模块不存在** + +Run: `npm run compile` + +Expected: FAIL,包含 `TS2307: Cannot find module '../claudeUsageContribution'`。 + +- [ ] **Step 3: 建立精确贡献类型,并把现有逐行分支机械迁入 parser** + +```ts +// src/claudeUsageContribution.ts — public contract +import { ClaudeUsageRecord } from './types'; +import { UsageFileFingerprint } from './claudeUsageFiles'; + +export const PARSER_CONTRACT_VERSION = 1; +export const ATTRIBUTION_CONTRACT_VERSION = 1; + +export function normalizeClaudeUsageRequest(request: T): T { + return { + ...request, + windowDays: Math.min(365, Math.max(1, Math.round(request.windowDays))), + }; +} + +export interface ContributionCacheKey { + parserVersion: number; + attributionVersion: number; + analyzeContent: boolean; + windowDays: number; +} + +export interface RecordCandidate { + record: ClaudeUsageRecord; + uniqueHash: string | null; + line: number; +} + +export interface TitleCandidate { + sessionId: string; + kind: 'ai' | 'custom'; + value: string; + line: number; +} + +export interface AnalysisDelta { + key: string; + estimatedTokens: number; + charCount: number; + count: number; +} + +export interface NormalizedAnalysisLine { + timestamp: string | null; + uuid: string | null; + sessionId: string; + categoryDeltas: AnalysisDelta[]; + toolDeltas: AnalysisDelta[]; + toolUses: { id: string; name: string; skill?: string }[]; + toolResults: { + toolUseId: string; + estimatedTokens: number; + charCount: number; + count: number; + }[]; + prompts: { cwd: string; text: string }[]; + thinking?: { tokens: number; total: number; hidden: boolean }; + commandUses: { name: string; estimatedTokens: number }[]; +} + +export interface ClaudeFileContribution { + fingerprint: UsageFileFingerprint; + cacheKey: ContributionCacheKey; + // V2.2.0 sortFilesByTimestamp() uses the first valid timestamp, not min(). + // 0 means no valid timestamp and therefore sorts first; stable input order + // resolves ties. + firstValidTimestampMs: number; + /** Earliest timestamp retained for content-analysis replay. */ + analysisCoveredFromMs: number; + recordCandidates: RecordCandidate[]; + titles: TitleCandidate[]; + analysisLines: NormalizedAnalysisLine[]; + bytesRead: number; + linesParsed: number; + parseErrors: number; + rejected: Record; + attributionDependencies: AttributionDependencyRef[]; +} + +export interface AttributionDependencyRef { + kind: 'agent-meta' | 'workflow-scripts'; + path: string; + workflowId?: string; +} + +export interface AttributionDependencyFingerprint extends AttributionDependencyRef { + exists: boolean; + size: number; + mtimeMs: number; + dev: number; + ino: number; + directorySignature?: string; +} + +export interface ParseClaudeUsageOptions { + analyzeContent: boolean; + windowDays: number; + nowMs: number; +} + +export function parseClaudeUsageContent( + fingerprint: UsageFileFingerprint, + content: string, + options: ParseClaudeUsageOptions +): ClaudeFileContribution { + const normalized = normalizeClaudeUsageRequest(options); + const lines = content.split('\n'); + const cacheKey: ContributionCacheKey = { + parserVersion: PARSER_CONTRACT_VERSION, + attributionVersion: ATTRIBUTION_CONTRACT_VERSION, + analyzeContent: normalized.analyzeContent, + windowDays: normalized.windowDays, + }; + const contribution: ClaudeFileContribution = { + fingerprint, + cacheKey, + firstValidTimestampMs: 0, + analysisCoveredFromMs: normalized.nowMs - cacheKey.windowDays * 86_400_000, + recordCandidates: [], + titles: [], + analysisLines: [], + bytesRead: Buffer.byteLength(content, 'utf8'), + linesParsed: 0, + parseErrors: 0, + rejected: {}, + attributionDependencies: deriveAttributionDependencyPaths(fingerprint.path), + }; + + // 将 dataLoader 当前每个 file 内的 sessionInfo、isSubagentFile、agentInfo、 + // recentPrompts 初始化移到这里。下面循环严格保留当前分支顺序:analysis → + // title → agent task → genuine user prompt → validate → attribution tags。 + for (let line = 0; line < lines.length; line += 1) { + if (lines[line].trim() === '') continue; + contribution.linesParsed += 1; + let parsed: unknown; + try { parsed = JSON.parse(lines[line]); } catch { + contribution.parseErrors += 1; + continue; + } + // 遇到第一个可解析且 timestamp 合法的对象时只赋值一次 + // firstValidTimestampMs;不得取整文件 min(timestamp)。 + // normalizeAnalysisLine 只保存 analysisCoveredFromMs 之后的上方 compact + // deltas,不保存 raw line;usage record candidates 仍覆盖完整历史。 + // 同一 user line 的每个 text block 各追加到 prompts[],不能覆盖成单个 prompt。 + // tool_use 与 tool_result 只保存 id/name/skill 和 token/chars/count;映射留给 + // Task 2 的 global replay,使 tool id 可以跨文件关联。 + // title/candidate 的具体字段赋值逐字沿用 dataLoader 当前实现。 + } + // 保留每文件最后 600 个 prompt candidates;保留 coverage window 内全部 + // compact skill/command candidates。first-5000 只能在 global replay/finalize + // 应用,这样 cutoff 前进后原第 5001+ 个候选可以正确补位。 + return contribution; +} +``` + +迁移规则是机械性的:`createUniqueHash()`、`parseSessionInfo()`、`isSyntheticUserText()` 在新模块先复制为 file-local helper,Task 1 不删除旧实现。`readAgentType()` / `resolveWorkflowName()` 不在 parser 内固化最终字符串:parser 只记录 agent id、workflow id、`metaPath` 与 `scriptsDir`;Task 2 的全局 attribution resolver 根据 dependency snapshot 解析。`ClaudeDataLoader.tokenSum()` 仍保留给现有 attribution API 使用,新模块另用同一四桶求和函数。每个 accepted usage 进入 `recordCandidates`,而不是立刻做跨文件 `processedHashes` 去重。 + +workflow-name resolver 的 per-refresh cache 必须使用复合 key,不能再只按 `workflowId`: + +```ts +const workflowCacheKey = `${scriptsDir}\0${workflowId}`; +const cached = workflowNameCache.get(workflowCacheKey); +// ... resolve -.js inside this exact scriptsDir ... +workflowNameCache.set(workflowCacheKey, resolvedName); +``` + +`NormalizedAnalysisLine[]` 的 replay 顺序是“稳定 file order,再按 line”;`toolUses` 先更新一个全局 `toolIdToName/skillByToolId`,后文件的 `toolResults` 才查这个全局表。不得在单文件 parser 中把未知 tool id 永久降级为 `unknown`。 + +`src/test/usageIndexFixture.ts` 在本任务中先定义聚合测试需要的三个稳定 constructor;后续任务沿用这些命名: + +```ts +export function usageCandidate( + timestamp: string, + uniqueHash: string, + inputTokens: number, + outputTokens: number +): RecordCandidate; +export function titleCandidate( + sessionId: string, + kind: 'ai' | 'custom', + value: string +): TitleCandidate; +export function contribution( + filePath: string, + firstValidTimestampMs: number, + records: RecordCandidate[], + titles?: TitleCandidate[] +): ClaudeFileContribution; +``` + +三个 constructor 使用固定 model/request/message id,返回值必须补齐 `ClaudeFileContribution` 的 `cacheKey`、固定 `analysisCoveredFromMs`、空 `analysisLines`、`bytesRead=0`、`linesParsed=records.length`、`parseErrors=0`;其中 `contribution()` 的 `fingerprint` 必须显式包含 `discoveryIndex`(默认取传入/派生的稳定序号,单文件 fixture 可为 `0`),不得依赖缺失字段或类型断言绕过前置 manifest contract。 + +- [ ] **Step 4: 运行单文件 GREEN、真实多 text/tool-result fixture 与内存内容扫描** + +Run: `npm run compile && node --test out/test/claudeUsageContribution.test.js` + +Expected: PASS;除现有 2 tests 外,再包含:同一 user line 的两个 text block 产生两个 `prompts`;tool result 保留精确 `estimatedTokens/charCount/count`;`firstValidTimestampMs` 取首个合法值而不是最小值;window cutoff 之前的 analysis event 不进入 contribution;贡献中没有 raw content。 + +Run: `rg -n "rawLine|rawJson|content: content|JSON\.stringify\(parsed\)" src/claudeUsageContribution.ts` + +Expected: 无缓存原始行的命中;用于既有 token 估算的局部 `JSON.stringify(block.input || {})` 不计入此禁令。 + +- [ ] **Step 5: 保持 worktree buildable,但不创建中间 commit** + +Run: `git diff --check && npm run compile` + +Expected: exit 0。此时只允许新增文件;立即继续 Task 2,Task 2 Step 5 才提交 Task 1 + Task 2 的完整原子边界。 + +### Task 2: 原子接入跨文件聚合器与 cold adapter + +**Files:** +- Create: `src/claudeUsageAggregate.ts` +- Create: `src/test/claudeUsageAggregate.test.ts` +- Create: `src/claudeUsageContribution.ts`(Task 1 尚未提交) +- Create: `src/test/claudeUsageContribution.test.ts`(Task 1 尚未提交) +- Create: `src/test/usageIndexFixture.ts`(Task 1 尚未提交) +- Modify: `src/dataLoader.ts:522-876` +- Modify: `src/refreshDiagnostics.ts` + +**Interfaces:** +- Consumes: `ClaudeFileContribution[]`,顺序由 `firstValidTimestampMs` 的稳定排序决定;相同/无效值保留 manifest discovery order。 +- Produces: `aggregateClaudeContributions(contributions, options): { records; contentAnalysis; diagnostics }`,以及无缓存的 `loadClaudeUsageCold()`。 + +- [ ] **Step 1: 写跨文件 placeholder、late title 与 workflow attribution 的 RED 测试** + +```ts +// src/test/claudeUsageAggregate.test.ts +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { aggregateClaudeContributions } from '../claudeUsageAggregate'; +import { contribution, usageCandidate, titleCandidate } from './usageIndexFixture'; + +test('global aggregation keeps the real proxy record and applies a late custom title', () => { + const placeholder = usageCandidate('2026-07-17T00:00:00.000Z', 'm1-r1', 0, 0); + const real = usageCandidate('2026-07-17T00:01:00.000Z', 'm1-r1', 100, 20); + const result = aggregateClaudeContributions([ + contribution('/virtual/a.jsonl', 1, [placeholder]), + contribution('/virtual/b.jsonl', 2, [real], [titleCandidate('session-a', 'custom', 'Latest')]), + ], { analyzeContent: false, windowDays: 30, nowMs: Date.parse('2026-07-17T12:00:00Z') }); + const usage = result.records.filter((record) => !record._isUserPrompt); + assert.equal(usage.length, 1); + assert.equal(usage[0].message.usage.input_tokens, 100); + assert.equal(usage[0]._sessionTitle, 'Latest'); +}); + +test('workflow and subagent fields survive global aggregation', () => { + const candidate = usageCandidate('2026-07-17T00:00:00.000Z', 'agent-r1', 5, 1); + candidate.record._sessionId = 'parent-session'; + candidate.record._agentId = 'agent-a'; + candidate.record._agentType = 'workflow-subagent'; + candidate.record._workflowId = 'wf_123'; + candidate.record._workflowName = 'review'; + const result = aggregateClaudeContributions( + [contribution('/virtual/agent.jsonl', 1, [candidate])], + { analyzeContent: false, windowDays: 30, nowMs: Date.parse('2026-07-17T12:00:00Z') } + ); + assert.deepEqual( + result.records.map((record) => [record._sessionId, record._agentType, record._workflowName]), + [['parent-session', 'workflow-subagent', 'review']] + ); +}); +``` + +- [ ] **Step 2: 运行 RED,确认全局聚合器不存在** + +Run: `npm run compile` + +Expected: FAIL,包含 `TS2307: Cannot find module '../claudeUsageAggregate'`。 + +- [ ] **Step 3: 实现稳定顺序、全局 dedup/title/analysis/calibration** + +```ts +// src/claudeUsageAggregate.ts — core control flow +import { ClaudeUsageRecord, ContentAnalysis } from './types'; +import { readFile } from 'node:fs/promises'; +import { + AttributionDependencyFingerprint, + ClaudeFileContribution, + normalizeClaudeUsageRequest, + parseClaudeUsageContent, +} from './claudeUsageContribution'; +import { scanUsageManifest } from './claudeUsageFiles'; +import { LoadUsageDiagnostics } from './refreshDiagnostics'; + +export interface AggregateOptions { + analyzeContent: boolean; + windowDays: number; + nowMs: number; +} + +export interface AggregatedClaudeUsage { + records: ClaudeUsageRecord[]; + contentAnalysis: ContentAnalysis | null; + diagnostics: LoadUsageDiagnostics; +} + +export interface ColdLoadRequest { + dataRoots: readonly string[]; + analyzeContent: boolean; + windowDays: number; + nowMs: number; +} + +function tokenSum(record: ClaudeUsageRecord): number { + const usage = record.message.usage; + return (usage.input_tokens || 0) + (usage.output_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0); +} + +export function aggregateClaudeContributions( + input: readonly ClaudeFileContribution[], + options: AggregateOptions +): AggregatedClaudeUsage { + const normalized = normalizeClaudeUsageRequest(options); + // Array.prototype.sort is stable in the supported Node runtime. Do not add + // a path/localeCompare tie-break: V2.2.0 preserves discovery order on ties. + const contributions = [...input].sort( + (a, b) => a.firstValidTimestampMs - b.firstValidTimestampMs + ); + const records: ClaudeUsageRecord[] = []; + const processedHashes = new Map(); + const aiTitles = new Map(); + const customTitles = new Map(); + const analysis = normalized.analyzeContent + ? createGlobalAnalysisAccumulator(normalized.nowMs - normalized.windowDays * 86_400_000) + : null; + + for (const contribution of contributions) { + for (const title of [...contribution.titles].sort((a, b) => a.line - b.line)) { + (title.kind === 'custom' ? customTitles : aiTitles).set(title.sessionId, title.value); + } + for (const candidate of [...contribution.recordCandidates].sort((a, b) => a.line - b.line)) { + // Cached contributions are immutable; title attachment happens on a copy. + const record = { ...candidate.record }; + if (candidate.uniqueHash && processedHashes.has(candidate.uniqueHash)) { + const oldIndex = processedHashes.get(candidate.uniqueHash)!; + if (tokenSum(record) > tokenSum(records[oldIndex])) { + records[oldIndex] = record; + } + } else { + records.push(record); + if (candidate.uniqueHash) processedHashes.set(candidate.uniqueHash, records.length - 1); + } + } + if (analysis) replayNormalizedAnalysis(contribution.analysisLines, analysis); + } + + for (const record of records) { + if (!record._sessionId) continue; + const title = customTitles.get(record._sessionId) || aiTitles.get(record._sessionId); + if (title) record._sessionTitle = title; + } + const contentAnalysis = analysis ? finalizeGlobalAnalysis(analysis, records) : null; + return { + records, + contentAnalysis, + diagnostics: { + filesDiscovered: contributions.length, + filesChanged: contributions.length, + filesReused: 0, + filesRemoved: 0, + filesFailed: 0, + bytesRead: contributions.reduce((sum, item) => sum + item.bytesRead, 0), + linesParsed: contributions.reduce((sum, item) => sum + item.linesParsed, 0), + manifestMs: 0, + readParseMs: 0, + }, + }; +} + +export async function loadClaudeUsageCold( + request: ColdLoadRequest +): Promise { + const normalized = normalizeClaudeUsageRequest(request); + const manifestStarted = performance.now(); + const manifest = await scanUsageManifest(normalized.dataRoots); + const manifestMs = performance.now() - manifestStarted; + const readParseStarted = performance.now(); + const contributions = [] as ClaudeFileContribution[]; + for (const fingerprint of manifest.entries.values()) { + const content = await readFile(fingerprint.path, 'utf8'); + contributions.push(parseClaudeUsageContent(fingerprint, content, normalized)); + } + const result = aggregateClaudeContributions(contributions, normalized); + result.diagnostics.manifestMs = manifestMs; + result.diagnostics.readParseMs = performance.now() - readParseStarted; + return result; +} +``` + +`normalizeClaudeUsageRequest()` 是唯一窗口规范化入口。`parseClaudeUsageContent()`、`aggregateClaudeContributions()`、`loadClaudeUsageCold()` 与 Task 3 index/cache key 都必须消费其结果;不得在 cold aggregate 继续使用 raw `59.6`。在 aggregate/equality tests 增加同一 corpus 的 `windowDays=59.6` 与 `60` 冷加载深度相等断言,并让两者都与 incremental 输出相等。 + +同一步先把前置计划的 `LoadUsageDiagnostics` 扩为 index 共用的完整数字契约;`RefreshDiagnostic` 删除重复声明,继续继承这些字段: + +```ts +export interface LoadUsageDiagnostics { + filesDiscovered: number; + filesChanged: number; + filesReused: number; + filesRemoved: number; + filesFailed: number; + bytesRead: number; + linesParsed: number; + manifestMs: number; + readParseMs: number; +} +``` + +`createGlobalAnalysisAccumulator()`、`replayNormalizedAnalysis()` 与 `finalizeGlobalAnalysis()` 要机械承接现有 `newAnalysisAcc()` / `analyzeLine()` / `finalizeAnalysis()` 的全局状态:UUID dedup、tool-use-id lookup、global first-5000 skill cap、global last-600 prompt cap、thinking maps 和 calibration 都只能在这里执行一次。replay 先按当前 aggregate cutoff 跳过已过期 event,再对每一行依次应用 `toolUses`、`toolResults`、`prompts[]`、thinking/command deltas;`toolIdToName` 与 `skillByToolId` 在整个 aggregate 中共享,因此 earlier file 的 tool use 可归因 later file 的 result。不得在 per-file 层先截 skill、先 finalize 后做近似相加。 + +外部 attribution 使用明确的 async 前置接口,aggregate 本身仍为纯同步函数: + +```ts +export async function resolveContributionAttribution( + contribution: ClaudeFileContribution, + dependencySnapshot: readonly AttributionDependencyFingerprint[], + caches: { agentType: Map; workflowName: Map } +): Promise; +``` + +`loadClaudeUsageCold()` 对每个 parsed contribution fingerprint dependencies,调用 resolver 后才 aggregate;Task 3 把这条路径收敛到与 `readStableContribution()` 相同的 stable dependency helper,验证 dependency pre/post snapshot 并在 race 时用 fresh resolver cache 重试。resolver 返回新的 compact contribution,不修改 cache 中旧对象;workflow cache key 必须是 `scriptsDir + '\0' + workflowId`。 + +同一步保留现有 loader 语义诊断,而不是只返回新的 I/O 计数: + +```ts +export interface LoaderSemanticDiagnostics { + files: number; + linesScanned: number; + parseErrors: number; + rejected: Record; + replacedByDedup: number; + skippedByDedup: number; + kept: number; + userPrompts: number; + models: Record; +} +``` + +`ClaudeDataLoader.loadUsageRecords(..., { log })` 继续输出原有两条 `loader:` 行,字段与 model summary 排序不变;`LoadUsageDiagnostics` 是附加的匿名性能数字,不得覆盖 `LoaderSemanticDiagnostics`。 + +`ClaudeDataLoader.loadUsageRecords()` 暂时保持 cold 行为:扫描 manifest → 每个 fingerprint 只 `readFile()` 一次并调 `parseClaudeUsageContent()` → 调 `aggregateClaudeContributions()`。这样 Task 2 尚未引入 cache,却已验证新边界不改结果;首 timestamp 直接从同一 content 计算,不得为排序再读一次文件。 + +- [ ] **Step 4: 运行 GREEN 与当前完整 suite,拒绝语义漂移** + +Run: `npm run compile && node --test out/test/claudeUsageContribution.test.js out/test/claudeUsageAggregate.test.js out/test/dataLoader.test.js` + +Expected: PASS,0 failures。 + +Run: `npm test` + +Expected: exit 0;所有原有 114 tests、新增 parser/aggregate tests 与 Task 0 legacy golden 均通过。另加一项 cross-file replay test:tool use 在 contribution A、result 在 B 时,result 归入真实 tool/skill 且 chars/count/token 与 golden 相同;另加两个 session 使用相同 `wf_same` 但不同 `scriptsDir` 的测试,workflow name 不能串 cache。 + +- [ ] **Step 5: 提交无缓存的 parser/aggregate 边界** + +```bash +git add src/claudeUsageContribution.ts src/claudeUsageAggregate.ts src/refreshDiagnostics.ts src/dataLoader.ts \ + src/test/claudeUsageContribution.test.ts src/test/claudeUsageAggregate.test.ts src/test/usageIndexFixture.ts \ + src/test/v220LegacyCorpus.ts src/test/dataLoader.test.ts +git commit -m "refactor: aggregate cached Claude contributions" +``` + +### Task 3: 实现 per-file manifest cache 与所有失效路径 + +**Files:** +- Create: `src/claudeUsageIndex.ts` +- Create: `src/test/claudeUsageIndex.test.ts` +- Modify: `src/test/usageIndexFixture.ts` +- Modify: `src/claudeUsageAggregate.ts` +- Modify: `src/claudeUsageContribution.ts` +- Modify: `src/refreshDiagnostics.ts` + +**Interfaces:** +- Consumes: Task 1/2 parser/aggregator,前置计划的 manifest scanner/fingerprint comparator。 +- Produces: 不带 coalescing 的基础 `ClaudeUsageIndex.refresh(request): Promise`、`clear(): void`、changed/reused/removed/bytes/lines、`dataChanged` 与 bounded debug snapshot;Task 4 只在此接口外加 coalescing。 + +- [ ] **Step 1: 写 unchanged/change/new/delete/truncate/replace 与 cache-key RED 测试** + +```ts +// src/test/claudeUsageIndex.test.ts — representative assertions +import { after, test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { appendFile, rename, rm, truncate, utimes, writeFile } from 'node:fs/promises'; +import { ClaudeUsageIndex } from '../claudeUsageIndex'; +import { createUsageCorpus } from './usageIndexFixture'; + +const cleanups: Array<() => Promise> = []; +after(async () => Promise.all(cleanups.map((cleanup) => cleanup()))); + +test('cold reads all files, unchanged reads none, and one changed file rereads once', async () => { + const corpus = await createUsageCorpus(); + cleanups.push(corpus.cleanup); + let reads = 0; + let aggregates = 0; + const index = new ClaudeUsageIndex({ + onFileRead: () => { reads += 1; }, + onAggregate: () => { aggregates += 1; }, + }); + const cold = await index.refresh(corpus.request('startup')); + assert.equal(reads, corpus.files.length); + assert.equal(cold.diagnostics.filesChanged, corpus.files.length); + reads = 0; + aggregates = 0; + const unchanged = await index.refresh(corpus.request('poll')); + assert.equal(reads, 0); + assert.equal(aggregates, 0); + assert.equal(unchanged.dataChanged, false); + assert.equal(unchanged.diagnostics.filesReused, corpus.files.length); + await appendFile(corpus.files[0], corpus.usageLine('new-message')); + const changed = await index.refresh(corpus.request('poll')); + assert.equal(reads, 1); + assert.equal(changed.diagnostics.filesChanged, 1); +}); + +test('delete, truncate and atomic replacement invalidate prior contributions', async () => { + const corpus = await createUsageCorpus(); + cleanups.push(corpus.cleanup); + const index = new ClaudeUsageIndex(); + await index.refresh(corpus.request('startup')); + + await rm(corpus.files[1]); + const deleted = await index.refresh(corpus.request('manual')); + assert.equal(deleted.diagnostics.filesRemoved, 1); + + await truncate(corpus.files[0], 0); + const truncated = await index.refresh(corpus.request('manual')); + assert.equal(truncated.diagnostics.filesChanged, 1); + assert.equal(truncated.records.some((record) => record._sessionId === corpus.sessionIds[0]), false); + + const originalStat = await corpus.stat(corpus.files[2]); + const replacement = `${corpus.files[2]}.replacement`; + await writeFile(replacement, await corpus.sameLengthReplacement()); + await utimes(replacement, originalStat.atime, originalStat.mtime); + await rename(replacement, corpus.files[2]); + const replaced = await index.refresh(corpus.request('manual')); + assert.equal(replaced.diagnostics.filesChanged, 1); +}); + +test('analysis setting and window are explicit cache invalidators', async () => { + const corpus = await createUsageCorpus(); + cleanups.push(corpus.cleanup); + const index = new ClaudeUsageIndex(); + await index.refresh(corpus.request('startup', { analyzeContent: false, windowDays: 30 })); + const enabled = await index.refresh(corpus.request('settings', { analyzeContent: true, windowDays: 30 })); + assert.equal(enabled.diagnostics.filesChanged, corpus.files.length); + const wider = await index.refresh(corpus.request('settings', { analyzeContent: true, windowDays: 60 })); + assert.equal(wider.diagnostics.filesChanged, corpus.files.length); + const normalizedSame = await index.refresh(corpus.request('poll', { analyzeContent: true, windowDays: 59.6 })); + assert.equal(normalizedSame.diagnostics.filesChanged, 0); +}); + +test('full reconciliation reorders equal timestamps without rereading JSONL', async () => { + const corpus = await createUsageCorpus({ includeEqualTimestampDedupTie: true }); + cleanups.push(corpus.cleanup); + let reverseDiscovery = false; + let reads = 0; + let aggregates = 0; + const index = new ClaudeUsageIndex({ + onFileRead: () => { reads += 1; }, + onAggregate: () => { aggregates += 1; }, + scanManifest: async (roots) => { + const scanned = await scanUsageManifest(roots); + if (!reverseDiscovery) return scanned; + const reversed = [...scanned.entries.values()].reverse().map((entry, discoveryIndex) => [ + entry.path, + { ...entry, discoveryIndex }, + ] as const); + return { ...scanned, entries: new Map(reversed) }; + }, + }); + const first = await index.refresh(corpus.request('startup', { forceReconcile: true })); + reads = 0; + aggregates = 0; + reverseDiscovery = true; + const reordered = await index.refresh(corpus.request('manual', { forceReconcile: true })); + assert.equal(reads, 0); + assert.equal(aggregates, 1); + assert.notDeepEqual(reordered.records, first.records); + assert.deepEqual(reordered.records, await corpus.expectedRecordsForReversedTieOrder()); +}); + +test('skill candidates beyond 5000 refill the global cap after expiry', async () => { + const corpus = await createUsageCorpus({ + includeContentAnalysis: true, + includeSkillExpiryOverflow: true, + }); + cleanups.push(corpus.cleanup); + let reads = 0; + const index = new ClaudeUsageIndex({ onFileRead: () => { reads += 1; } }); + const initialNow = Date.parse('2026-07-17T12:00:00.000Z'); + await index.refresh(corpus.request('startup', { + analyzeContent: true, + windowDays: 30, + nowMs: initialNow, + })); + assert.ok(index.debugSnapshot().skillCandidates > 5000); + + reads = 0; + const advancedRequest = corpus.request('poll', { + analyzeContent: true, + windowDays: 30, + nowMs: initialNow + 24 * 60 * 60 * 1000, + }); + const advanced = await index.refresh(advancedRequest); + assert.equal(reads, 0); + const cold = await loadClaudeUsageCold(advancedRequest); + assert.deepStrictEqual(advanced.contentAnalysis, cold.contentAnalysis); +}); + +test('metadata and workflow-script dependencies invalidate unchanged JSONL', async () => { + const corpus = await createUsageCorpus({ includeWorkflowSubagent: true }); + cleanups.push(corpus.cleanup); + const index = new ClaudeUsageIndex(); + await index.refresh(corpus.request('startup')); + await corpus.rewriteAgentMeta('Explore'); + const metaChanged = await index.refresh(corpus.request('poll')); + assert.equal(metaChanged.records.find((r) => r._agentId)?._agentType, 'Explore'); + assert.equal(metaChanged.dataChanged, true); + await corpus.renameWorkflowScript('review-v2'); + const scriptChanged = await index.refresh(corpus.request('poll')); + assert.equal(scriptChanged.records.find((r) => r._workflowId)?._workflowName, 'review-v2'); +}); + +test('dependency races retry with fresh resolver state and atomically cache the stable snapshot', async () => { + for (const kind of ['meta', 'workflow'] as const) { + const corpus = await createUsageCorpus({ includeWorkflowSubagent: true }); + cleanups.push(corpus.cleanup); + let mutated = false; + const index = new ClaudeUsageIndex({ + afterAttributionResolve: async () => { + if (mutated) return; + mutated = true; + if (kind === 'meta') await corpus.rewriteAgentMeta('Explore'); + else await corpus.renameWorkflowScript('review-raced'); + }, + }); + const request = corpus.request('startup', { forceReconcile: true }); + const raced = await index.refresh(request); + const cold = await loadClaudeUsageCold(corpus.request('manual', { forceReconcile: true })); + assert.deepStrictEqual(raced.records, cold.records); + assert.deepStrictEqual(raced.contentAnalysis, cold.contentAnalysis); + const unchanged = await index.refresh(corpus.request('poll')); + assert.equal(unchanged.dataChanged, false); + } +}); + +test('same workflow id in two script directories never shares a resolver cache entry', async () => { + const corpus = await createUsageCorpus({ includeDuplicateWorkflowIdsInTwoSessions: true }); + cleanups.push(corpus.cleanup); + const result = await new ClaudeUsageIndex().refresh(corpus.request('startup')); + assert.deepStrictEqual( + result.records.filter((r) => r._workflowId === 'wf_same').map((r) => r._workflowName), + ['review-a', 'review-b'] + ); +}); + +test('a failed or racing read commits no partial cache state', async () => { + const corpus = await createUsageCorpus(); + cleanups.push(corpus.cleanup); + const index = new ClaudeUsageIndex({ afterFileRead: corpus.replaceDuringFirstRead }); + const before = index.debugSnapshot(); + await assert.rejects(index.refresh(corpus.request('startup')), /changed while reading/); + assert.deepStrictEqual(index.debugSnapshot(), before); +}); +``` + +- [ ] **Step 2: 运行 RED,确认 index 尚不存在** + +Run: `npm run compile` + +Expected: FAIL,包含 `TS2307: Cannot find module '../claudeUsageIndex'`。 + +- [ ] **Step 3: 实现内存 entry map 与精确 fingerprint/cache-key 比较** + +```ts +// src/claudeUsageIndex.ts — cache contract and runOnce core +import * as path from 'node:path'; +import { + aggregateClaudeContributions, + AggregatedClaudeUsage, + loadClaudeUsageCold, +} from './claudeUsageAggregate'; +import { + ContributionCacheKey, + ClaudeFileContribution, + AttributionDependencyFingerprint, + normalizeClaudeUsageRequest, + parseClaudeUsageContent, + PARSER_CONTRACT_VERSION, + ATTRIBUTION_CONTRACT_VERSION, +} from './claudeUsageContribution'; +import { + diffUsageManifests, + sameFingerprint, + scanUsageManifest, + UsageFileFingerprint, +} from './claudeUsageFiles'; +import { RefreshTrigger } from './refreshPolicy'; + +export interface ClaudeUsageRefreshRequest { + dataRoots: readonly string[]; + trigger: RefreshTrigger; + analyzeContent: boolean; + windowDays: number; + nowMs: number; + forceReconcile?: boolean; + hintedPaths?: readonly string[]; +} + +export interface IndexedClaudeUsage extends AggregatedClaudeUsage { + dataChanged: boolean; +} +export { loadClaudeUsageCold }; + +interface CachedEntry { + fingerprint: UsageFileFingerprint; + cacheKey: ContributionCacheKey; + contribution: ClaudeFileContribution; + dependencies: AttributionDependencyFingerprint[]; +} + +export interface ClaudeUsageIndexHooks { + onFileRead?: (filePath: string) => void; + now?: () => number; + beforeManifestScan?: () => Promise; + onFullManifestScan?: () => void; + afterFileRead?: (filePath: string) => Promise | void; + afterAttributionResolve?: (filePath: string) => Promise | void; + onAggregate?: () => void; + scanManifest?: typeof scanUsageManifest; +} + +function normalizeRefreshRequest(request: ClaudeUsageRefreshRequest): ClaudeUsageRefreshRequest { + const normalized = normalizeClaudeUsageRequest(request); + return { + ...normalized, + dataRoots: normalized.dataRoots.map((root) => path.resolve(root)), + hintedPaths: normalized.hintedPaths ? [...new Set(normalized.hintedPaths)] : undefined, + }; +} + +function sameKey(a: ContributionCacheKey, b: ContributionCacheKey): boolean { + return a.parserVersion === b.parserVersion && + a.attributionVersion === b.attributionVersion && + a.analyzeContent === b.analyzeContent && a.windowDays === b.windowDays; +} + +export class ClaudeUsageIndex { + private entries = new Map(); + private lastManifest = null as Awaited> | null; + private lastAggregate: IndexedClaudeUsage | null = null; + private lastAggregateKey = ''; + private lastManifestOrderKey = ''; + private nextAnalysisExpiryAt = Number.POSITIVE_INFINITY; + private resolvedRootsKey = ''; + private lastFullReconciliationAt = 0; + + constructor(private readonly hooks: ClaudeUsageIndexHooks = {}) {} + + clear(): void { + this.entries.clear(); + this.lastManifest = null; + this.lastAggregate = null; + this.lastAggregateKey = ''; + this.lastManifestOrderKey = ''; + this.nextAnalysisExpiryAt = Number.POSITIVE_INFINITY; + this.resolvedRootsKey = ''; + this.lastFullReconciliationAt = 0; + } + + debugSnapshot(): { + entries: number; + dependencies: number; + recordCandidates: number; + analysisEvents: number; + promptCandidates: number; + skillCandidates: number; + rawJsonBytes: 0; + hasAggregate: boolean; + } { + const contributions = [...this.entries.values()].map((entry) => entry.contribution); + return { + entries: this.entries.size, + dependencies: [...this.entries.values()].reduce((n, e) => n + e.dependencies.length, 0), + recordCandidates: contributions.reduce((n, c) => n + c.recordCandidates.length, 0), + analysisEvents: contributions.reduce((n, c) => n + c.analysisLines.length, 0), + promptCandidates: contributions.reduce( + (n, c) => n + c.analysisLines.reduce((m, line) => m + line.prompts.length, 0), 0 + ), + skillCandidates: contributions.reduce( + (n, c) => n + c.analysisLines.reduce( + (m, line) => m + line.toolUses.filter((tool) => tool.skill !== undefined).length + + line.commandUses.length, + 0 + ), 0 + ), + rawJsonBytes: 0, + hasAggregate: this.lastAggregate !== null, + }; + } + + // Task 3 deliberately has no inFlight/pending state. Task 4 wraps this + // one-pass method with coalescing after its behavior is independently green. + async refresh(request: ClaudeUsageRefreshRequest): Promise { + return this.runOnce(normalizeRefreshRequest(request)); + } + + private async runOnce(request: ClaudeUsageRefreshRequest): Promise { + const rootsKey = request.dataRoots.map((root) => path.resolve(root)).join('\0'); + const rootsChanged = this.resolvedRootsKey !== '' && rootsKey !== this.resolvedRootsKey; + const manifestStarted = performance.now(); + const { manifest, usedFullScan } = await buildManifestForRequest( + rootsChanged ? null : this.lastManifest, + request, + this.lastFullReconciliationAt, + this.hooks + ); + const manifestMs = performance.now() - manifestStarted; + const delta = diffUsageManifests(rootsChanged ? null : this.lastManifest, manifest); + const manifestOrderKey = [...manifest.entries.values()] + .map((entry) => `${entry.discoveryIndex}\0${entry.path}`) + .join('\0'); + const manifestOrderChanged = manifestOrderKey !== this.lastManifestOrderKey; + const readParseStarted = performance.now(); + const cacheKey: ContributionCacheKey = { + parserVersion: PARSER_CONTRACT_VERSION, + attributionVersion: ATTRIBUTION_CONTRACT_VERSION, + analyzeContent: request.analyzeContent, + windowDays: request.windowDays, + }; + let bytesRead = 0; + let linesParsed = 0; + let changed = 0; + let reused = 0; + const requestedAnalysisCutoff = request.nowMs - cacheKey.windowDays * 86_400_000; + // Transaction: nextEntries shares immutable old contributions, but no + // live field changes until every changed file/dependency is stable. + const nextEntries = new Map(rootsChanged ? [] : this.entries); + for (const removed of delta.removed) nextEntries.delete(removed.path); + for (const fingerprint of manifest.entries.values()) { + const cached = this.entries.get(fingerprint.path); + const dependencies = await fingerprintAttributionDependencies(fingerprint.path); + if (cached && sameFingerprint(cached.fingerprint, fingerprint) && + sameKey(cached.cacheKey, cacheKey) && sameDependencies(cached.dependencies, dependencies) && + (!request.analyzeContent || + requestedAnalysisCutoff >= cached.contribution.analysisCoveredFromMs)) { + reused += 1; + continue; + } + this.hooks.onFileRead?.(fingerprint.path); + const stable = await readStableContribution( + fingerprint, cacheKey, request.nowMs, dependencies, this.hooks + ); + nextEntries.set(fingerprint.path, { + fingerprint, + cacheKey, + contribution: stable.contribution, + dependencies: stable.dependencies, + }); + bytesRead += stable.contribution.bytesRead; + linesParsed += stable.contribution.linesParsed; + changed += 1; + } + const aggregateKey = `${rootsKey}\0${cacheKey.analyzeContent}\0${cacheKey.windowDays}`; + const hasSemanticDelta = changed > 0 || delta.removed.length > 0 || manifestOrderChanged || + aggregateKey !== this.lastAggregateKey || request.nowMs >= this.nextAnalysisExpiryAt; + if (!hasSemanticDelta && this.lastAggregate) { + this.entries = nextEntries; + this.lastManifest = manifest; + this.lastManifestOrderKey = manifestOrderKey; + this.resolvedRootsKey = rootsKey; + if (usedFullScan) this.lastFullReconciliationAt = request.nowMs; + return withRefreshDiagnostics(this.lastAggregate, { + filesDiscovered: manifest.entries.size, filesChanged: 0, filesReused: reused, + filesRemoved: 0, filesFailed: 0, bytesRead: 0, linesParsed: 0, manifestMs, + readParseMs: performance.now() - readParseStarted, + }, false); + } + this.hooks.onAggregate?.(); + const aggregated = aggregateClaudeContributions( + [...manifest.entries.keys()].map((filePath) => nextEntries.get(filePath)!.contribution), + { analyzeContent: request.analyzeContent, windowDays: cacheKey.windowDays, nowMs: request.nowMs } + ) as IndexedClaudeUsage; + aggregated.diagnostics = { + ...aggregated.diagnostics, + filesDiscovered: manifest.entries.size, + filesChanged: changed, + filesReused: reused, + filesRemoved: delta.removed.length, + filesFailed: 0, + bytesRead, + linesParsed, + manifestMs, + readParseMs: performance.now() - readParseStarted, + }; + aggregated.dataChanged = true; + // Commit all state only after parsing + aggregate succeeds. + this.entries = nextEntries; + this.lastManifest = manifest; + this.lastManifestOrderKey = manifestOrderKey; + this.lastAggregate = aggregated; + this.lastAggregateKey = aggregateKey; + this.nextAnalysisExpiryAt = computeNextAnalysisExpiry(nextEntries, request.nowMs, cacheKey.windowDays); + this.resolvedRootsKey = rootsKey; + if (usedFullScan) this.lastFullReconciliationAt = request.nowMs; + return aggregated; + } +} +``` + +`withRefreshDiagnostics()` 必须返回新的顶层 result 和新的 diagnostics 对象(`{ ...cached, diagnostics: { ...metrics }, dataChanged: false }`),不得给 `lastAggregate` 或其 records/contentAnalysis 赋值;这些 payload 由下游只读复用。 + +`readStableContribution()` 返回 `{ contribution, dependencies: stableDependencies }`,caller 只能缓存这个返回的 dependency snapshot,不能缓存调用前的 tentative snapshot。每次 attempt 必须 `open(path, 'r')`,先 `handle.stat()`,再 `handle.readFile({ encoding: 'utf8' })`,调用 JSONL test hook 后再 `handle.stat()`;pre/post 与 manifest fingerprint 任一不相同就关闭 handle 并重试。随后以 attempt-local、全新的 `{ agentType, workflowName }` resolver caches 解析 attribution,调用 dependency-race test hook,再 fingerprint dependencies;若 pre/post dependency snapshot 不同,丢弃 contribution 和该 attempt 的 resolver caches,用新 snapshot 从 parse/resolve 完整重试一次。第二次 JSONL 或 dependency 仍变化时抛出匿名 `UsageFileChangedDuringReadError`,整轮 transaction 回滚。`finally` 总是 close;raw string 只作为 `parseClaudeUsageContent()` 的局部参数,返回前释放,不能写入 contribution/hook。parser 使用 request 的固定 `nowMs` 写入 `analysisCoveredFromMs`;向前推进的 cutoff 可复用已有 superset,窗口扩大或时钟回拨到 coverage 之前则整文件重读,绝不能在缺少历史 analysis events 时假装 cache hit。 + +`fingerprintAttributionDependencies()` 对 `agent-*.meta.json` 保存 file fingerprint;对 workflow 保存 `scriptsDir` 与“匹配当前 workflow id 的 script filename + 每个匹配文件 fingerprint”排序后的 `directorySignature`。resolver cache key 精确为 `scriptsDir + '\0' + workflowId`。meta/script 缺失使用 `exists=false` fingerprint,因此后续创建也能使 cache 失效。对 changed entry 要在 JSONL parse/attribution resolve 后再 fingerprint dependencies 一次;前后不同则与 JSONL race 使用同一“只重试一次、仍变化则整轮回滚”规则。retry 必须用新的 resolver caches,最终把 stable post snapshot 与对应 contribution 一起返回,避免“旧 attribution + 新 fingerprint”。cold adapter 使用同一稳定 dependency-attribution helper,而不是较弱的单次读取路径。 + +基础 `refresh()` 同时实现 safe hint/full reconciliation(coalescing 留给 Task 4): + +```ts +function isContainedJsonlHint(candidate: string, roots: readonly string[]): boolean { + const absolute = path.resolve(candidate); + return roots.some((root) => { + const projects = path.resolve(root, 'projects'); + const relative = path.relative(projects, absolute); + return relative !== '' && !relative.startsWith(`..${path.sep}`) && + relative !== '..' && !path.isAbsolute(relative) && absolute.endsWith('.jsonl'); + }); +} +``` + +只有 containment 通过才 `lstat()`。symlink/非 regular file 直接拒绝并转 full reconciliation;`ENOENT` 可从 candidate manifest 删除;任何其他 error 终止本轮并保留旧 snapshot。startup/manual/`forceReconcile`、无 hint、距 `lastFullReconciliationAt >= 1_800_000` 都 full scan。局部 hint 只允许更新/删除 `lastManifest.entries` 已知的 path;更新已知 key 必须直接 `Map.set()` 保持原 slot/discoveryIndex,不能先 delete 再 append。若安全 hint 指向此前未知的新 JSONL,必须立即转 full reconciliation,由 native directory enumeration 赋予权威 `discoveryIndex`,不能把新文件随手 append 到 map。dependency fingerprints 即使 JSONL 未 hint 也要检查;它们数量受当前 cached contributions 限制。 + +`buildManifestForRequest()` 使用 `hooks.scanManifest ?? scanUsageManifest`;hook 仅用于 deterministic test,不改变生产 scanner。每次 authoritative scan 都计算 ordered manifest signature。指纹内容完全不变但 `discoveryIndex`/map order 变化时,复用所有 contribution(JSONL read=0)但必须重新 aggregate,并在成功 transaction 中提交新的 order key;相同/无效首 timestamp 的 tie 因而继续精确遵守 V2.2.0 discovery order。 + +`computeNextAnalysisExpiry()` 取所有合法 analysis timestamp 中最小的 `timestamp + normalizedWindowDays * 86_400_000` 且严格大于 `nowMs` 的值;analysis disabled 时为 `Infinity`。这样普通 unchanged poll 不 aggregate;真正跨过 content window 边界时可以零 JSONL read 地重算一次。 + +同一步在 `src/test/usageIndexFixture.ts` 追加 corpus writer 的精确 public contract: + +```ts +export interface UsageCorpus { + root: string; + files: string[]; + sessionIds: string[]; + totalJsonlBytes: number; + cleanup: () => Promise; + request: ( + trigger: RefreshTrigger, + overrides?: Partial + ) => ClaudeUsageRefreshRequest; + usageLine: (id: string) => string; + stat: typeof import('node:fs/promises').stat; + sameLengthReplacement: () => Promise; + appendCompleteUsage: () => Promise; + addSessionFile: () => Promise; + addEqualTimestampSessionFile: () => Promise; + addInvalidTimestampSessionFile: () => Promise; + deleteSessionFile: () => Promise; + truncateAndRewriteSession: () => Promise; + atomicReplaceSession: () => Promise; + writeIncompleteTail: () => Promise; + completeTail: () => Promise; + addLateCustomTitle: () => Promise; + rewriteAgentMeta: (agentType: string) => Promise; + renameWorkflowScript: (name: string) => Promise; + replaceDuringFirstRead: (filePath: string) => Promise; + mutateWithoutReturningHint: () => Promise; + expectedRecordsForReversedTieOrder: () => Promise; +} + +export function createUsageCorpus(options?: { + includeProxyPlaceholderPair?: boolean; + includeLateCustomTitle?: boolean; + includeWorkflowSubagent?: boolean; + includeContentAnalysis?: boolean; + includeSkillExpiryOverflow?: boolean; + includeDuplicateWorkflowIdsInTwoSessions?: boolean; +}): Promise; +``` + +corpus writer 使用 `mkdtemp(path.join(os.tmpdir(), 'ccu-index-'))`,只在该目录下建立 `projects/-work-demo/...`;所有时间戳/model/request/message id 固定,`cleanup()` 仅 `rm(root, { recursive: true, force: true })`。 + +`includeSkillExpiryOverflow` 写入至少 6005 个按 legacy replay order 排列的 compact skill/command events:前 1005 个会在测试推进一天后越过 30-day cutoff,其余仍在窗口内。fixture 必须使 cold 在两个 `nowMs` 上都执行真实 global first-5000,而 index 前进 cutoff 时 JSONL read 为 0;这专门防止 parser 层截断候选。 + +`sameFingerprint()` 已覆盖 size decrease、mtime/size change 与 dev/ino identity change。atomic replacement 测试创建 same-size replacement、把 mtime 还原为原值,再 `rename()` 覆盖;macOS/Linux 上先 `assert.notEqual(after.ino, before.ino)`,再证明 record 内容变化且 `filesChanged=1`,不能只断言计数。若 `dev/ino` 真为 0,测试用 `t.skip('platform does not expose file identity')` 明示跳过,另测 size+mtime fallback。 + +同一测试文件还必须加入以下保守边界: + +- hinted path 在任一 resolved `/projects` 外:不读取该 path,转 full scan。 +- hinted path 是 symlink:`lstat().isSymbolicLink()` 后拒绝,不 follow。 +- hinted path `ENOENT`:作为 delete;`EACCES/EIO/EMFILE`:reject refresh,旧 debug snapshot/records 保持不变。 +- meta edit/delete/create 与 workflow script rename/delete/create 均在 JSONL unchanged 时更新 attribution。 +- 200 次 same-path replace + refresh 后 `debugSnapshot().entries === manifest.entries.size`,dependency 数不超过每个 subagent 两项,`rawJsonBytes===0`;删除全部文件后 entry=0,`clear()` 后 aggregate/dependency=0。 +- 另建一个至少 100,000 行、含窗口外历史与密集 prompt/tool events 的 fixture:`analysisEvents <= linesParsed`、`promptCandidates <= files * 600`,skill/command candidates 精确等于 coverage window 内 fixture 的 compact candidates(允许超过 5000,但不得复制 raw input);窗口外 analysis 不保留,raw JSON bytes 为 0;unchanged refresh 的 aggregate hook 仍为 0。此确定性对象数门不可用 wall-clock 或 GC skip 代替。 +- deep-freeze 首次返回的 records/contentAnalysis,再做 unchanged refresh;diagnostics overlay 仍成功且 cached aggregate 内容/引用未被内部改写。 +- 用 `WeakRef` 记录一次被替换 contribution;在 `node --expose-gc` 可用时循环 `global.gc()` 后断言最终可回收。GC test 若 runtime 不暴露 `global.gc` 明示 skip;确定性 entry/raw bound 绝不能 skip。 + +- [ ] **Step 4: 运行 GREEN 并确认 unchanged 的 bytes/lines 都为 0** + +Run: `npm run compile && node --test out/test/claudeUsageIndex.test.js` + +Expected: PASS;unchanged case 的 `filesChanged=0`、`bytesRead=0`、`linesParsed=0`、`dataChanged=false`、aggregate hook=0;windowDays `59.6` 与 `60` 使用同一 key;跨 analysis expiry 时 JSONL read=0 但 aggregate hook=1;100,000-line fixture 满足 analysis/prompt/skill 的确定性线性对象数上界。 + +Run: `node --expose-gc --test out/test/claudeUsageIndex.test.js` + +Codex exact: `CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node; "$CCU_NODE" --expose-gc --test out/test/claudeUsageIndex.test.js` + +Expected: PASS;entry-count hard bound 必过,支持显式 GC 时旧 contribution 的 WeakRef 最终清空。 + +- [ ] **Step 5: 提交 index 基础与失效规则** + +```bash +git add src/claudeUsageIndex.ts src/claudeUsageAggregate.ts src/claudeUsageContribution.ts \ + src/refreshDiagnostics.ts src/test/claudeUsageIndex.test.ts src/test/usageIndexFixture.ts +git commit -m "perf: cache Claude usage per file" +``` + +### Task 4: 只为基础 refresh 加 single-flight coalescing + +**Files:** +- Modify: `src/claudeUsageIndex.ts` +- Modify: `src/test/claudeUsageIndex.test.ts` + +**Interfaces:** +- Consumes: Task 3 已独立验证的单次 `runOnce()`、safe hints 与 30 分钟 full reconciliation。 +- Produces: index 内部一个 in-flight + 一个 unioned pending request;本任务不改 watcher、timer、extension render 或 loader ownership。 + +- [ ] **Step 1: 先为 one-in-flight + one-pending 语义写 RED 测试** + +把以下测试追加到 `src/test/claudeUsageIndex.test.ts`: + +```ts +test('concurrent refreshes run one in-flight pass and one merged pending pass', async () => { + const corpus = await createUsageCorpus(); + cleanups.push(corpus.cleanup); + let scans = 0; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const index = new ClaudeUsageIndex({ + beforeManifestScan: async () => { scans += 1; if (scans === 1) await gate; }, + }); + const first = index.refresh(corpus.request('poll')); + const second = index.refresh(corpus.request('watch', { hintedPaths: [corpus.files[0]] })); + const third = index.refresh(corpus.request('manual', { forceReconcile: true })); + release(); + await Promise.all([first, second, third]); + assert.equal(scans, 2); +}); + +``` + +- [ ] **Step 2: 运行 RED,确认 refresh/hook/pending 尚未实现** + +Run: `npm run compile && node --test --test-name-pattern="concurrent refreshes" out/test/claudeUsageIndex.test.js` + +Expected: test FAIL,第二/第三请求尚未被 merge,scan 次数大于 2 或返回值语义不符合下述 contract;compile 本身应成功。 + +- [ ] **Step 3: 只实现 index 双层防重入** + +`ClaudeUsageIndex` 增加以下精确状态和合并规则: + +```ts +private inFlight: Promise | null = null; +private pending: ClaudeUsageRefreshRequest | null = null; +private lastFullReconciliationAt = 0; +private static readonly RECONCILE_MS = 30 * 60 * 1000; + +async refresh(request: ClaudeUsageRefreshRequest): Promise { + if (this.inFlight) { + this.pending = this.pending ? mergeRequests(this.pending, request) : request; + return this.inFlight; + } + this.inFlight = this.runLoop(request); + try { return await this.inFlight; } finally { + this.inFlight = null; + this.pending = null; + } +} + +private async runLoop(initial: ClaudeUsageRefreshRequest): Promise { + let request: ClaudeUsageRefreshRequest | null = initial; + let result!: IndexedClaudeUsage; + while (request) { + result = await this.runOnce(request); + request = this.pending; + this.pending = null; + } + return result; +} +``` + +`mergeRequests()` 使用 `mergeRefreshTrigger()`,`forceReconcile` 取 OR,`hintedPaths` 用 `Set` union,最新 request 的 `analyzeContent`、规范化 `windowDays`、`nowMs` 与 resolved `dataRoots` 覆盖旧值。若 pending request 的 resolved roots 与 in-flight 不同,pending 保留新 roots 并强制 reconciliation;绝不把两个 root 的 hints 混用。 + +所有等待同一个 in-flight 的 caller 都等待 `runLoop()` 完成并收到最后一次 follow-up result;index 在 finally 前执行合并后的唯一 follow-up,并把 `lastAggregate` 留在 follow-up 的结果。follow-up 失败时 `refresh()` reject,Task 3 的 transaction 保证首轮已提交 snapshot 不被半写;`pending` 在成功/失败路径都清空。这里不得重复实现 Task 3 的 manifest/hint/reconciliation 逻辑。 + +- [ ] **Step 4: 运行 GREEN 与错误路径 coalescing test** + +Run: `npm run compile && node --test out/test/claudeUsageIndex.test.js out/test/refreshPolicy.test.js` + +Expected: PASS;concurrent case `scans=2`。另测 20 个 watcher request 仍只有一次 pending pass、hint union 无重复;pending pass 抛错后 `inFlight===null`、`pending===null`,下一次 refresh 可正常运行。 + +- [ ] **Step 5: 提交纯 index coalescing** + +```bash +git add src/claudeUsageIndex.ts src/test/claudeUsageIndex.test.ts +git commit -m "perf: coalesce usage index refreshes" +``` + +### Task 5: 接入 extension,并证明每条增量路径与 fresh cold load 相等 + +**Files:** +- Create: `src/test/incrementalEquality.test.ts` +- Modify: `src/test/usageIndexFixture.ts` +- Modify: `src/claudeUsageIndex.ts` +- Modify: `src/extension.ts:39-97,790-843,924-954,1040-1215` +- Modify: `src/refreshPolicy.ts` +- Modify: `src/test/refreshPolicy.test.ts` + +**Interfaces:** +- Consumes: `ClaudeUsageIndex.refresh()` 与 `loadClaudeUsageCold(request)`。 +- Produces: resolved-root ownership、watch hint union、30 分钟 timer、passive unchanged heavy-render skip,以及 cold/incremental `records` 和 `contentAnalysis` 的 `assert.deepStrictEqual` release gate。 + +- [ ] **Step 1: 写完整 mutation 序列的 RED equality test** + +```ts +// src/test/incrementalEquality.test.ts +import { after, test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { ClaudeUsageIndex, loadClaudeUsageCold } from '../claudeUsageIndex'; +import { createUsageCorpus } from './usageIndexFixture'; + +const cleanups: Array<() => Promise> = []; +after(async () => Promise.all(cleanups.map((cleanup) => cleanup()))); + +test('incremental output equals a fresh cold load after every supported mutation', async () => { + const corpus = await createUsageCorpus({ + includeProxyPlaceholderPair: true, + includeLateCustomTitle: true, + includeWorkflowSubagent: true, + includeContentAnalysis: true, + }); + cleanups.push(corpus.cleanup); + const index = new ClaudeUsageIndex(); + const steps: Array<() => Promise> = [ + async () => undefined, + corpus.appendCompleteUsage, + corpus.addSessionFile, + corpus.deleteSessionFile, + corpus.truncateAndRewriteSession, + corpus.atomicReplaceSession, + corpus.writeIncompleteTail, + corpus.completeTail, + corpus.addLateCustomTitle, + () => corpus.rewriteAgentMeta('Explore'), + () => corpus.renameWorkflowScript('review-v2'), + ]; + for (const mutate of steps) { + await mutate(); + const request = corpus.request('manual', { + analyzeContent: true, + windowDays: 30, + nowMs: Date.parse('2026-07-17T12:00:00.000Z'), + forceReconcile: true, + }); + const incremental = await index.refresh(request); + const cold = await loadClaudeUsageCold(request); + assert.deepStrictEqual(incremental.records, cold.records); + assert.deepStrictEqual(incremental.contentAnalysis, cold.contentAnalysis); + } +}); + +test('cold and incremental paths share the normalized 59.6-day window', async () => { + const corpus = await createUsageCorpus({ includeContentAnalysis: true }); + cleanups.push(corpus.cleanup); + const raw = corpus.request('manual', { analyzeContent: true, windowDays: 59.6 }); + const rounded = corpus.request('manual', { analyzeContent: true, windowDays: 60 }); + const coldRaw = await loadClaudeUsageCold(raw); + const coldRounded = await loadClaudeUsageCold(rounded); + assert.deepStrictEqual(coldRaw.records, coldRounded.records); + assert.deepStrictEqual(coldRaw.contentAnalysis, coldRounded.contentAnalysis); + const indexed = await new ClaudeUsageIndex().refresh(raw); + assert.deepStrictEqual(indexed.records, coldRounded.records); + assert.deepStrictEqual(indexed.contentAnalysis, coldRounded.contentAnalysis); +}); + +test('a valid hint equals cold, and a missed hint is recovered by reconciliation', async () => { + const corpus = await createUsageCorpus({ includeContentAnalysis: true }); + cleanups.push(corpus.cleanup); + let nowMs = Date.parse('2026-07-17T12:00:00Z'); + const index = new ClaudeUsageIndex(); + await index.refresh(corpus.request('startup', { nowMs, forceReconcile: true })); + + await corpus.appendCompleteUsage(); + const hinted = await index.refresh(corpus.request('watch', { + nowMs, + hintedPaths: [corpus.files[0]], + })); + assert.deepStrictEqual(hinted.records, (await loadClaudeUsageCold(corpus.request('manual', { nowMs }))).records); + + await corpus.mutateWithoutReturningHint(); + await index.refresh(corpus.request('watch', { nowMs, hintedPaths: [corpus.files[1]] })); + nowMs += 30 * 60 * 1000 + 1; + const reconciled = await index.refresh(corpus.request('watch', { nowMs, hintedPaths: [corpus.files[1]] })); + const cold = await loadClaudeUsageCold(corpus.request('manual', { nowMs })); + assert.deepStrictEqual(reconciled.records, cold.records); + assert.deepStrictEqual(reconciled.contentAnalysis, cold.contentAnalysis); +}); + +test('a hinted new file forces authoritative discovery order before equality', async () => { + const corpus = await createUsageCorpus({ includeContentAnalysis: true }); + cleanups.push(corpus.cleanup); + let fullScans = 0; + const index = new ClaudeUsageIndex({ onFullManifestScan: () => { fullScans += 1; } }); + await index.refresh(corpus.request('startup', { forceReconcile: true })); + fullScans = 0; + + const equalTimestampFile = await corpus.addEqualTimestampSessionFile(); + const equalHinted = await index.refresh(corpus.request('watch', { + hintedPaths: [equalTimestampFile], + })); + assert.equal(fullScans, 1); + const equalCold = await loadClaudeUsageCold(corpus.request('manual')); + assert.deepStrictEqual(equalHinted.records, equalCold.records); + assert.deepStrictEqual(equalHinted.contentAnalysis, equalCold.contentAnalysis); + + const invalidTimestampFile = await corpus.addInvalidTimestampSessionFile(); + const invalidHinted = await index.refresh(corpus.request('watch', { + hintedPaths: [invalidTimestampFile], + })); + assert.equal(fullScans, 2); + const invalidCold = await loadClaudeUsageCold(corpus.request('manual')); + assert.deepStrictEqual(invalidHinted.records, invalidCold.records); + assert.deepStrictEqual(invalidHinted.contentAnalysis, invalidCold.contentAnalysis); +}); +``` + +- [ ] **Step 2: 运行完整 equality gate;有差异才按 diff 修生产路径** + +Run: `npm run compile && node --test out/test/incrementalEquality.test.js` + +Expected: Tasks 0–4 正确时可以直接 PASS;不得伪造“应先失败”。若 FAIL,错误 diff 必须指向具体 record/title/analysis 字段,禁止在测试中排序、删字段或更新 golden 来掩盖。 + +- [ ] **Step 3: 接入 extension ownership,并区分 passive skip 与显式 presentation refresh** + +先给 extension 的刷新入口一个无自由变量的 contract,并扩展前置 mitigation 的 `RefreshRequest` / `RefreshSingleFlight` 以原样传递它: + +```ts +interface UsageRefreshOptions { + forceReconcile?: boolean; + hintedPaths?: readonly string[]; +} + +private async refreshData( + forceReload: boolean = false, + trigger: RefreshTrigger = 'poll', + options: UsageRefreshOptions = {} +): Promise { + const request = this.refreshGate.request(forceReload, trigger, options); + if (request === null) { + this.coalescedTriggersSinceRefresh += 1; + return; + } + await this.runRefresh(request); +} +``` + +`RefreshRequest` 增加 `forceReconcile: boolean` 和 `hintedPaths: readonly string[]`;active 期间 merge 时 `forceReload`/`forceReconcile` 取 OR、hints 做 stable `Set` union、trigger 继续使用 `mergeRefreshTrigger()`。不得让 mitigation 的外层 gate 丢失 timer flag 或 watcher hints,index 内层 coalescing 仍负责文件级 transaction。 + +`runRefresh(request)` 先把配置解析成 canonical resolved roots;raw `dataDirectory === ''` 不参与 cache clear/key 比较: + +```ts +const resolvedRoots = dataDirectory.trim() !== '' + ? [path.resolve(dataDirectory)] + : ClaudeDataLoader.getClaudePaths().map((root) => path.resolve(root)); +const loaded = await this.usageIndex.refresh({ + dataRoots: resolvedRoots, + trigger: request.trigger, + analyzeContent: config.enableContentAnalysis, + windowDays: config.advicePromptWindowDays, + nowMs: Date.now(), + forceReconcile: request.trigger === 'startup' || request.trigger === 'manual' || + request.forceReconcile, + hintedPaths: request.hintedPaths, +}); +if (shouldSkipUsageBreakdownRender(request.trigger, loaded.dataChanged)) { + // Quota/context/status-bar refresh still runs from cached usage before this + // return; only heavy usage breakdown and webview render are skipped. + this.updateStatusFromCachedUsageAndQuota(loaded.records); + this.appendRefreshDiagnostics(loaded.diagnostics); + return; +} +``` + +`shouldSkipUsageBreakdownRender(trigger, dataChanged)` 只在 `!dataChanged` 且 trigger 属于 `poll|watch|focus|credentials` 时返回 true;startup/manual/settings/pricing/workspace 都返回 false。`src/test/refreshPolicy.test.ts` 注入 source-aggregate、breakdown、webview-render 与 status counters:第二次 unchanged poll 的 fileRead/source-aggregate/breakdown/webview 都为 0,但 status 可更新;manual/settings/pricing/workspace 即使 index data 未变也必须刷新 presentation。配置事件不得基于 raw `''` 调 `usageIndex.clear()`;index 只比较上述 `resolvedRootsKey`。 + +示例中的 `updateStatusFromCachedUsageAndQuota()` 表示从现有 `refreshData()` 中提取的 quota/context/status-bar 更新块,不是新的 API 或网络请求;实现时若保留 inline 代码也可以,但必须发生在 heavy breakdown/webview 的 early return 之前。 + +watcher callback 把 containment 尚未信任的 absolute candidate 加到 `pendingWatchPaths`,debounce 时 stable union 后执行 `void this.refreshData(false, 'watch', { hintedPaths: [...this.pendingWatchPaths] })` 并清空集合;安全判断只在 index。manual call site 保持 `void this.refreshData(true, 'manual')`,由 trigger 自动要求 authoritative scan。独立 timer 每 30 分钟明确调用 `void this.refreshData(false, 'poll', { forceReconcile: true })`,`dispose()` clear timer。activation 的 `'startup'` 同样自动 authoritative scan。删除 extension 里前置 mitigation 的重复 manifest scan/cache,匿名 diagnostics 直接消费 index 计数,同时保留 loader semantic log。 + +`src/test/refreshPolicy.test.ts` 必须覆盖:manual 即使 options 为空也传 `forceReconcile=true`;30-minute poll 显式传 true;两个 queued watcher request 的 hints 做去重 union;普通 poll 不被误标为 force reconciliation。测试直接断言送入 fake index 的 request,不依赖 VS Code host。 + +若 equality diff 出现,修复只能发生在 production parser/aggregate/index。排序固定为对 contribution 做 stable `firstValidTimestampMs` comparator、candidate/title 仅按原 line replay;禁止 path/`localeCompare()` tie-break。incomplete tail、four-bucket dedup、truthy custom title、固定 `nowMs` cutoff/calibration 均以 Task 0 golden 为准。 + +- [ ] **Step 4: 运行 equality、全部数据测试与完整 suite** + +Run: `npm run compile && node --test out/test/incrementalEquality.test.js out/test/claudeUsageIndex.test.js out/test/claudeUsageAggregate.test.js out/test/claudeUsageContribution.test.js` + +Expected: PASS,0 failures;known valid-hint、equal/invalid timestamp 的 hinted-new-file full reconciliation,以及 30-minute missed-hint recovery 都与 cold 深度相等;atomic replacement 内容而非仅计数相等。 + +Run: `npm test` + +Expected: exit 0;原有 114 tests 和全部新增 tests 均通过。 + +- [ ] **Step 5: 提交 equality gate** + +```bash +git add src/extension.ts src/refreshPolicy.ts src/test/refreshPolicy.test.ts \ + src/test/incrementalEquality.test.ts src/test/usageIndexFixture.ts \ + src/claudeUsageIndex.ts src/claudeUsageAggregate.ts src/claudeUsageContribution.ts +git commit -m "test: prove incremental usage equality" +``` + +### Task 6: Linux 负载证据、六 README 与 release gate + +**Files:** +- Modify: `CHANGELOG.md` +- Modify: `README.md` +- Modify: `README-en.md` +- Modify: `README-zh-TW.md` +- Modify: `README-zh-CN.md` +- Modify: `README-ja.md` +- Modify: `README-ko.md` + +**Interfaces:** +- Consumes: index 的匿名 `filesChanged/filesReused/filesRemoved/bytesRead/linesParsed`。 +- Produces: 非 timing-flaky 的 I/O 断言、六语言 per-file cache 说明,以及 prerelease 是否关闭 #70 的证据门。 + +- [ ] **Step 1: 运行已接入 index 的 corpus I/O gate** + +Run: `npm run compile && node --test --test-name-pattern="cold reads all files" out/test/claudeUsageIndex.test.js` + +Expected: PASS。此处是 release gate,不伪造 RED;RED 已在 Task 3 新 index 不存在时完成。 + +- [ ] **Step 2: 固定非耗时型性能验收,不使用脆弱的 wall-clock 阈值** + +最终测试断言必须是: + +```ts +assert.equal(cold.diagnostics.filesChanged, corpus.files.length); +assert.ok(cold.diagnostics.bytesRead >= corpus.totalJsonlBytes); +assert.equal(unchanged.diagnostics.filesChanged, 0); +assert.equal(unchanged.diagnostics.filesReused, corpus.files.length); +assert.equal(unchanged.diagnostics.bytesRead, 0); +assert.equal(unchanged.diagnostics.linesParsed, 0); +assert.equal(oneChanged.diagnostics.filesChanged, 1); +assert.equal(oneChanged.diagnostics.filesReused, corpus.files.length - 1); +assert.ok(oneChanged.diagnostics.bytesRead < cold.diagnostics.bytesRead); +``` + +Run: `npm run compile && node --test --test-name-pattern="cold reads all files" out/test/claudeUsageIndex.test.js` + +Expected: PASS,0 failures。 + +- [ ] **Step 3: 只有 equality gate 全绿后,更新 CHANGELOG 和六语言 README** + +`CHANGELOG.md` 在前置 mitigation 条目后增加: + +```md +- **Incremental per-file usage index (#70)** — unchanged JSONL files now reuse + their in-memory parsed contribution; only new/changed files are reread. + Delete, truncate, inode-change and atomic-replace invalidation is covered by + cold-versus-incremental deep-equality tests. The cache is never persisted. +``` + +六份 README 的 high-CPU 段落都加入同义说明;简体中文精确文本为: + +```md +- V2.2.1 使用仅存在于内存的逐文件索引:未变化的 JSONL 直接复用解析贡献, + 只有新增或变化的文件会整文件重读;删除、截断和原子替换都会失效。索引不写入 + 磁盘,也不改变用量口径。若 Linux 上仍持续高占用,请把匿名 `refresh:` 行和 + Extension Host CPU profile 附到 issue #70。 +``` + +其余五份必须表达同样四点:memory-only、unchanged reuse、changed full reread、Linux 仍需 profile;不得写“所有 Linux 高 CPU 均已修复”。 + +- [ ] **Step 4: 执行 release suite、package 内容与 prerelease 实机门** + +Run: `npm test` + +Expected: exit 0,0 failures。 + +Run: `node node_modules/@vscode/vsce/vsce ls --no-dependencies` + +Codex exact: `CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node; "$CCU_NODE" node_modules/@vscode/vsce/vsce ls --no-dependencies` + +Expected: exit 0;输出不含 `.github/`、`src/`、`out/test/`、`docs/`、`CLAUDE.md`、`AGENTS.md`、`.env`、`*.map`。 + +macOS + Linux prerelease 固定核对: + +```text +1. Cold startup:refresh 行显示 changed=文件总数、reused=0。 +2. 不写日志的下一次 poll:changed=0、bytes=0、lines=0。 +3. 向一个活动 session 写入:changed=1,其余均 reused。 +4. 删除测试 session:removed=1,UI totals 精确减少。 +5. 连续写入超过 quiet delay:没有并行 refresh,最多一个 pending follow-up。 +6. 运行 31 分钟或注入 clock:完整 reconciliation 能发现故意丢弃的 watcher event。 +7. 只上传匿名 refresh 行;另附 Extension Host CPU profile,不上传 JSONL。 +``` + +只有两平台 smoke 正常、Linux CPU 明显回落且 equality suite 全绿,才在 #70 标记 fixed。否则回退本计划的 index commits,保留前置 mitigation,#70 继续开放。 + +- [ ] **Step 5: 提交文档与 release evidence** + +```bash +git add CHANGELOG.md README.md README-en.md README-zh-TW.md README-zh-CN.md README-ja.md README-ko.md +git commit -m "docs: document incremental usage indexing" +``` + +## 完成定义 + +- cold start 每个 JSONL 恰好整文件读取一次;首 timestamp 不造成第二次全文件读取。 +- unchanged refresh 的 JSONL `bytesRead=0`、`linesParsed=0`。 +- unchanged passive `poll|watch|focus|credentials` refresh 的 parser read、source aggregate 与 extension usage breakdown/webview 调用次数都为 0,返回 `dataChanged=false`;quota/status 仍可更新,manual/settings/pricing/workspace 仍刷新 presentation。 +- 一个 changed/new 文件只整文件读取该文件;V2.2.1 没有 append-offset 状态。 +- delete/truncate/inode change/atomic replace 都移除旧贡献并重算 totals。 +- watcher burst 只形成现有 single-flight 后的一个 pending follow-up;hint 丢失最多 30 分钟后由 reconciliation 恢复。 +- analysis 开关/window、parser version、attribution version 改变会明确失效。 +- `agent-*.meta.json` / workflow scripts 的 create/edit/delete 会使 attribution 失效;同 workflow id 的不同 `scriptsDir` 不串 cache。 +- placeholder/real dedup、late custom title、workflow/subagent attribution、message count、content analysis 和 calibration 与 fresh cold load `deepStrictEqual`。 +- cache 仅内存存在并满足 entry/raw/GC bound;性能诊断只含匿名数字,同时旧 loader rejection/dedup/model log 完整保留。 +- data roots 只比较 canonical resolved roots;hint 越界/symlink/non-ENOENT 错误 fail closed,任何失败不产生部分 snapshot。 +- 未触及 Codex provider、跨设备同步、append tail cache 或 1000-session UI。 +- 完整 test/package/prerelease gate 全部通过;否则不合入 index,V2.2.1 只发布低风险 mitigation。 diff --git a/docs/superpowers/plans/2026-07-17-v2.2.1-refresh-mitigation.zh-CN.md b/docs/superpowers/plans/2026-07-17-v2.2.1-refresh-mitigation.zh-CN.md new file mode 100644 index 00000000..55204767 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-v2.2.1-refresh-mitigation.zh-CN.md @@ -0,0 +1,1561 @@ +# V2.2.1 刷新降载与匿名诊断 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不改变默认刷新体验和 Claude 用量结果的前提下,移除 active 8 秒轮询覆盖,加入最长 300 秒的 watcher quiet-debounce、匿名阶段诊断,以及流式且最多 8 路并发的首时间戳读取,作为 issue #70 在 V2.2.1 的低风险缓解。 + +**已批准设计:** [V2.2.1 稳定化设计(简体中文)](../specs/2026-07-17-v2.2.1-stabilization-design.zh-CN.md) + +**Architecture:** 把纯刷新策略、JSONL manifest/排序、诊断格式化从 VS Code 生命周期代码中拆出,使它们可由 `node:test` 直接验证。每次刷新只扫描一次 manifest,并把同一 manifest 传给 loader;poll 始终使用用户的 `refreshInterval`,watcher 只负责 quiet debounce,但仍更新 activity 时间供 quota TTL 使用。loader 返回纯数字诊断,由 extension 补齐 trigger、聚合/渲染耗时及 watcher/coalescing 计数后写入 OutputChannel。 + +**Tech Stack:** TypeScript 5.8、Node.js 20+ 标准库(`node:fs`、`node:string_decoder`、`node:test`)、VS Code Extension API;不增加运行时依赖。 + +## Global Constraints + +- **前置条件:** 必须先执行并合入 `docs/superpowers/plans/2026-07-17-codex-takeover-hygiene.zh-CN.md`;本计划依赖其中已经建立的唯一 `## [2.2.1] — Unreleased` CHANGELOG 标题、package ignore 审计和 Codex 维护约定。 +- 目标分支为 `codex/v2.2.1-stabilization`,基线为已发布的 `v2.2.0`。 +- V2.2.1 仍然只读取 Claude Code;本计划不加入 Codex provider。 +- 不增加任何 runtime dependency。 +- `refreshInterval` 的合法范围仍是 30–3600 秒;poll 不得在 active 状态下缩短为 8 秒。 +- `fileWatchSeconds` 只表示 quiet debounce;保留默认值 `2`,并新增 `60`、`120`、`300` 秒选项。 +- 诊断日志不得包含 prompt、绝对路径、session ID、credential 或原始 JSONL 行。 +- 精确用量与估算 insight 的口径均不得变化。 +- 新设置保留当前默认行为;不得静默迁移既有用户的刷新节奏。 +- 新增 UI 文案必须覆盖现有七个 locale;秒数枚举标签沿用 `settings.ts` 已有的技术英文约定。 +- README 改动必须同步六份:`README.md`、`README-en.md`、`README-zh-TW.md`、`README-zh-CN.md`、`README-ja.md`、`README-ko.md`。 +- 每项行为变化先 RED、再 GREEN;交付前 `npm test` 必须零失败。 +- 只有 prerelease 在 Linux 复现者机器上验证后,才能声称 #70 已解决;否则发布说明只能称为 mitigation,并保持 #70 开放。 + +### Codex Desktop 命令环境 + +Codex Desktop 的非交互 shell 可能没有 `node`、`npm`、`npx`。计划中的 `npm run compile` / `npm test` 是 CI 与普通开发机的规范命令;在当前 Codex 工作区必须使用下面的固定等价命令,不得临时下载未锁定版本: + +```bash +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js +"$CCU_NODE" node_modules/@vscode/vsce/vsce ls --no-dependencies +``` + +每个执行步骤都视为新的 shell;凡后文单独出现 `"$CCU_NODE"`,必须在同一个 code block/command 前重新赋值为上述绝对路径。 + +--- + +## 文件职责 + +- Create: `src/refreshPolicy.ts` — poll 间隔、live-refresh 枚举、并发触发合并优先级,全部为纯函数。 +- Create: `src/claudeUsageFiles.ts` — JSONL 发现/stat manifest、manifest diff、流式首时间戳读取和上限为 8 的并发排序。 +- Create: `src/refreshDiagnostics.ts` — 只接受匿名数字的诊断类型和确定性日志格式化。 +- Create: `src/test/refreshPolicy.test.ts` — 刷新 cadence、quiet-debounce 选项和 pending trigger 合并测试。 +- Create: `src/test/claudeUsageFiles.test.ts` — manifest、流式读取和并发上限测试。 +- Create: `src/test/refreshDiagnostics.test.ts` — 匿名诊断输出契约测试。 +- Modify: `src/settings.ts:367-417` — `fileWatchSeconds` 新选项。 +- Modify: `src/i18n.ts:1745-1970` — 七个现有 locale 的 quiet-debounce 帮助文字(英语在 `settings.ts`,其余六种在此文件)。 +- Modify: `src/types.ts:265-320` — `ExtensionConfig` 保持相同字段,补充 quiet-debounce 语义注释。 +- Modify: `src/dataLoader.ts:1-69,522-876,975-1014,2843-2872` — 复用已扫描 manifest、移除全文件首时间戳 pass、返回 I/O 诊断。 +- Modify: `src/extension.ts:39-97,120-127,670-954,1040-1204` — 删除 active cadence,传递 refresh trigger,收集 watcher/coalescing 和阶段耗时。 +- Modify: `CHANGELOG.md:7` — 新增 V2.2.1 mitigation 条目,不宣称 root fix。 +- Modify: `README.md:302-305`、`README-en.md:63-71`、`README-zh-TW.md`、`README-zh-CN.md:175-176`、`README-ja.md`、`README-ko.md` — 写入 #70 临时 workaround。 + +### Task 1: 用纯函数锁定 poll 与 quiet-debounce 语义 + +**Files:** +- Create: `src/refreshPolicy.ts` +- Create: `src/test/refreshPolicy.test.ts` +- Modify: `src/settings.ts:378-397` +- Modify: `src/i18n.ts:1745-1970` +- Modify: `src/types.ts:265-320` +- Modify: `src/extension.ts:85-94,899-954` + +**Interfaces:** +- Consumes: `ExtensionConfig.refreshInterval: number`、`ExtensionConfig.fileWatchSeconds: number`。 +- Produces: `LIVE_REFRESH_SECONDS`、`pollIntervalMs(refreshIntervalSeconds: number): number`、`RefreshTrigger`、`mergeRefreshTrigger(current: RefreshTrigger | null, incoming: RefreshTrigger): RefreshTrigger`。 + +- [ ] **Step 1: 写出会失败的 cadence 与枚举测试** + +```ts +// src/test/refreshPolicy.test.ts +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { + LIVE_REFRESH_SECONDS, + mergeRefreshTrigger, + pollIntervalMs, +} from '../refreshPolicy'; + +test('poll interval always honors refreshInterval and never applies an active override', () => { + assert.equal(pollIntervalMs(30), 30_000); + assert.equal(pollIntervalMs(60), 60_000); + assert.equal(pollIntervalMs(300), 300_000); + assert.equal(pollIntervalMs(900), 900_000); + assert.equal(pollIntervalMs(3600), 3_600_000); +}); + +test('live refresh keeps the 2-second default choices and adds long quiet delays', () => { + assert.deepEqual(LIVE_REFRESH_SECONDS, ['0', '1', '2', '5', '10', '20', '30', '60', '120', '300']); +}); + +test('coalescing retains the strongest pending trigger', () => { + assert.equal(mergeRefreshTrigger(null, 'poll'), 'poll'); + assert.equal(mergeRefreshTrigger('poll', 'watch'), 'watch'); + assert.equal(mergeRefreshTrigger('watch', 'focus'), 'focus'); + assert.equal(mergeRefreshTrigger('settings', 'manual'), 'manual'); + assert.equal(mergeRefreshTrigger('manual', 'poll'), 'manual'); +}); +``` + +- [ ] **Step 2: 运行 RED,确认缺少纯策略模块** + +Run: `npm run compile` + +Expected: FAIL,包含 `TS2307: Cannot find module '../refreshPolicy'`。 + +- [ ] **Step 3: 实现纯策略并让设置表复用同一组选项** + +```ts +// src/refreshPolicy.ts +export const LIVE_REFRESH_SECONDS = [ + '0', '1', '2', '5', '10', '20', '30', '60', '120', '300', +] as const; + +export type RefreshTrigger = + | 'startup' + | 'poll' + | 'credentials' + | 'watch' + | 'focus' + | 'workspace' + | 'settings' + | 'pricing' + | 'manual'; + +const TRIGGER_PRIORITY: Record = { + startup: 0, + poll: 1, + credentials: 2, + watch: 3, + focus: 4, + workspace: 5, + settings: 6, + pricing: 7, + manual: 8, +}; + +export function pollIntervalMs(refreshIntervalSeconds: number): number { + const seconds = Number.isFinite(refreshIntervalSeconds) + ? Math.min(3600, Math.max(30, refreshIntervalSeconds)) + : 60; + return seconds * 1000; +} + +export function mergeRefreshTrigger( + current: RefreshTrigger | null, + incoming: RefreshTrigger +): RefreshTrigger { + if (current === null) return incoming; + return TRIGGER_PRIORITY[incoming] > TRIGGER_PRIORITY[current] ? incoming : current; +} +``` + +```ts +// src/settings.ts — replace only fileWatchSeconds.enumValues/enumLabels +enumValues: [...LIVE_REFRESH_SECONDS], +enumLabels: ['Off', '1s', '2s', '5s', '10s', '20s', '30s', '60s', '120s', '300s'], +``` + +在 `src/settings.ts` 顶部加入: + +```ts +import { LIVE_REFRESH_SECONDS } from './refreshPolicy'; +``` + +把 `src/types.ts` 中 `fileWatchSeconds` 的注释改成明确契约: + +```ts +// Quiet period after the last JSONL watcher event; 0 disables watching. +fileWatchSeconds: number; +``` + +同时把设置帮助明确为 quiet debounce。英语在 `src/settings.ts` 使用下面的完整文字;`src/i18n.ts` 的德语、繁中、简中、日语、韩语、葡萄牙语条目分别替换为对应文字,七个 locale 都必须说明“最后一次事件后等待”,不能继续写成模糊的“新活动后多久刷新”: + +```ts +// src/settings.ts +help: 'Wait after the last local JSONL change before refreshing (quiet debounce; each new event restarts the delay). No API call is made; quota fetches are throttled separately. Off disables watching, and 60–300s is the lowest-CPU option for large histories.', +``` + +```ts +// src/i18n.ts — exact SETTINGS_I18N fileWatchSeconds.help replacements +de: 'Wartezeit nach der letzten lokalen JSONL-Änderung vor der Aktualisierung (Ruhe-Debounce; jedes neue Ereignis startet die Wartezeit neu). Es erfolgt kein API-Aufruf; Kontingent-Abfragen werden separat gedrosselt. „Aus“ deaktiviert die Überwachung, 60–300 s schont große Verläufe am stärksten.' +zh-TW: '在最後一次本機 JSONL 變更後等待多久才重新整理(安靜期去抖;每個新事件都會重新計時)。不會呼叫 API;配額查詢另行節流。「關閉」會停用監控,60–300 秒最適合降低大量歷史資料的 CPU 負載。' +zh-CN: '在最后一次本地 JSONL 变更后等待多久再刷新(静默期防抖;每个新事件都会重新计时)。不会调用 API;配额查询单独节流。“关闭”会停用监控,60–300 秒最适合降低大历史的 CPU 负载。' +ja: '最後のローカル JSONL 変更から更新まで待つ時間です(quiet debounce。新しいイベントごとに待ち時間をリセット)。API は呼び出さず、クォータ取得は別に抑制されます。Off で監視を無効化し、大きな履歴では 60~300 秒が最も CPU 負荷を抑えます。' +ko: '마지막 로컬 JSONL 변경 후 새로 고침까지 기다리는 시간입니다(quiet debounce, 새 이벤트마다 대기 시간이 다시 시작됨). API를 호출하지 않으며 할당량 조회는 별도로 제한됩니다. 끄기는 감시를 비활성화하고, 큰 기록에서는 60~300초가 CPU 부담을 가장 줄입니다.' +pt-BR: 'Tempo de espera após a última alteração JSONL local antes de atualizar (quiet debounce; cada novo evento reinicia a espera). Nenhuma API é chamada; as consultas de cota são limitadas separadamente. “Desligado” desativa o monitoramento, e 60–300 s reduz mais a CPU em históricos grandes.' +``` + +上面是翻译内容清单,不是可直接粘贴的对象字面量:实现者要替换 `SETTINGS_I18N` 中现有六个 `fileWatchSeconds.help` 字符串,并保留各语言已有的 `label`。 + +把 `src/extension.ts` 的 auto-refresh 改为固定服从配置。**保留** `lastActivityAt`、`isActive()` 和 JSONL watcher 中的 `this.lastActivityAt = Date.now()`;它们仍供 `maybeFetchUsageLimits()` 的 60/120 秒 quota TTL 使用,只删除 poll 对 activity 的依赖: + +```ts +import { pollIntervalMs } from './refreshPolicy'; + +private startAutoRefresh(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + } + const gen = ++this.refreshGen; + const tick = (): void => { + if (gen !== this.refreshGen) { + return; + } + const intervalMs = pollIntervalMs(this.getConfiguration().refreshInterval); + this.refreshTimer = setTimeout(() => { + // Task 4 才增加显式 trigger 参数;此任务先保持现有签名可编译。 + this.refreshData().finally(() => { + if (gen === this.refreshGen) { + tick(); + } + }); + }, intervalMs); + }; + tick(); +} +``` + +- [ ] **Step 4: 运行 GREEN 并确认旧的 8 秒路径已消失** + +Run: `npm run compile && node --test out/test/refreshPolicy.test.js` + +Expected: PASS,3 tests、0 failures。 + +Run: `rg -n "this\.isActive\(\) \? Math\.min|Math\.min\(base, 8000\)|active.*8.*poll" src/extension.ts` + +Expected: 无输出,退出码 1。不要搜索裸 `8000`:quota 的冷启动重试仍合法使用 8 秒;`lastActivityAt` / `isActive()` 也必须继续存在。 + +Run: `rg -n "lastActivityAt|isActive\(\).*60000.*120000|const ttl = this\.isActive" src/extension.ts` + +Expected: 可见 watcher activity 更新、`isActive()` 定义和 quota TTL 使用;poll timer 中不可见 `isActive()`。 + +- [ ] **Step 5: 提交 cadence 变更** + +```bash +git add src/refreshPolicy.ts src/test/refreshPolicy.test.ts src/settings.ts src/i18n.ts src/types.ts src/extension.ts +git commit -m "fix: honor configured refresh interval" +``` + +### Task 2: 一次 manifest 扫描与有界首时间戳读取 + +**Files:** +- Create: `src/claudeUsageFiles.ts` +- Create: `src/test/claudeUsageFiles.test.ts` + +**Interfaces:** +- Consumes: Claude data root 列表;每个 root 下读取 `projects/**/*.jsonl`。 +- Produces: `UsageFileFingerprint`、`UsageManifest`、`ManifestDelta`、`isMissingPathError()`、`scanUsageManifest()`、`diffUsageManifests()`、`mapWithConcurrency()`、`readEarliestTimestamp()`、`sortUsageFilesByEarliestTimestamp()`。 + +- [ ] **Step 1: 写 manifest、短路读取和并发上限的失败测试** + +```ts +// src/test/claudeUsageFiles.test.ts +import { after, test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readdir, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + diffUsageManifests, + isMissingPathError, + mapWithConcurrency, + readEarliestTimestamp, + scanUsageManifest, + sortUsageFilesByEarliestTimestamp, +} from '../claudeUsageFiles'; + +const roots: string[] = []; +after(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function fixture(): Promise<{ root: string; file: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-files-')); + roots.push(root); + const project = path.join(root, 'projects', '-tmp-demo'); + await mkdir(project, { recursive: true }); + return { root, file: path.join(project, 'a.jsonl') }; +} + +test('manifest scanning tolerates only disappearance races', () => { + assert.equal(isMissingPathError(Object.assign(new Error('gone'), { code: 'ENOENT' })), true); + assert.equal(isMissingPathError(Object.assign(new Error('denied'), { code: 'EACCES' })), false); + assert.equal(isMissingPathError(Object.assign(new Error('io'), { code: 'EIO' })), false); + assert.equal(isMissingPathError(new Error('unknown')), false); +}); + +test('manifest scanning fails closed on permission errors but tolerates stat ENOENT', async () => { + const { root, file } = await fixture(); + await writeFile(file, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + const readDirectory = (dir: string) => readdir(dir, { withFileTypes: true }); + await assert.rejects( + () => scanUsageManifest([root], { + readdir: readDirectory, + stat: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); }, + }), + (error: NodeJS.ErrnoException) => error.code === 'EACCES' + ); + const missing = await scanUsageManifest([root], { + readdir: readDirectory, + stat: async () => { throw Object.assign(new Error('gone'), { code: 'ENOENT' }); }, + }); + assert.equal(missing.entries.size, 0); +}); + +test('manifest diff reports changed, reused, and removed without exposing them in counters', async () => { + const { root, file } = await fixture(); + await writeFile(file, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + const first = await scanUsageManifest([root]); + const cold = diffUsageManifests(null, first); + assert.equal(cold.changed.length, 1); + assert.equal(cold.reused.length, 0); + + const second = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(first, second).reused.length, 1); + + await writeFile(file, '{"timestamp":"2026-01-02T00:00:00.000Z"}\nlonger\n'); + const third = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(second, third).changed.length, 1); + + const replacement = `${file}.replacement`; + await writeFile(replacement, '{"timestamp":"2026-01-03T00:00:00.000Z"}\nlonger\n'); + await rename(replacement, file); + const fourth = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(third, fourth).changed.length, 1); + + await rm(file); + const fifth = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(fourth, fifth).removed.length, 1); +}); + +test('inode detects an atomic replacement with unchanged size and mtime', async (t) => { + const { root, file } = await fixture(); + const original = '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'; + const replacementBody = '{"timestamp":"2026-01-02T00:00:00.000Z"}\n'; + assert.equal(Buffer.byteLength(original), Buffer.byteLength(replacementBody)); + await writeFile(file, original); + const beforeManifest = await scanUsageManifest([root]); + const before = await stat(file); + + const replacement = `${file}.replacement`; + await writeFile(replacement, replacementBody); + await utimes(replacement, before.atime, before.mtime); + const prepared = await stat(replacement); + assert.equal(prepared.size, before.size); + if (prepared.mtimeMs !== before.mtimeMs) { + t.skip('filesystem cannot preserve the exact mtime for this identity fixture'); + return; + } + await rename(replacement, file); + const after = await stat(file); + if (!(before.dev > 0 && before.ino > 0 && after.dev > 0 && after.ino > 0 && + (before.dev !== after.dev || before.ino !== after.ino))) { + t.skip('filesystem does not expose a reliable changed file identity'); + return; + } + + const afterManifest = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(beforeManifest, afterManifest).changed.length, 1); +}); + +test('earliest timestamp stops after the first valid JSONL record', async () => { + const { file } = await fixture(); + const first = '{"timestamp":"2026-02-03T04:05:06.000Z"}\n'; + await writeFile(file, first + 'x'.repeat(512 * 1024)); + const result = await readEarliestTimestamp(file); + assert.equal(result.timestampMs, Date.parse('2026-02-03T04:05:06.000Z')); + assert.ok(result.bytesRead < 64 * 1024, `read ${result.bytesRead} bytes`); +}); + +test('a valid first JSONL record larger than 1 MiB keeps its timestamp', async () => { + const { file } = await fixture(); + const body = JSON.stringify({ + timestamp: '2026-02-03T04:05:06.000Z', + message: { content: '中🙂'.repeat(180_000) }, + }) + '\n'; + await writeFile(file, body + '{"timestamp":"2026-03-01T00:00:00.000Z"}\n'); + const result = await readEarliestTimestamp(file); + assert.equal(result.timestampMs, Date.parse('2026-02-03T04:05:06.000Z')); + assert.ok(result.bytesRead > 1024 * 1024); +}); + +test('timestamp probing is bounded when no valid timestamp exists', async () => { + const { file } = await fixture(); + const content = 'not-json\n'.repeat(150_000); + await writeFile(file, content); + await assert.rejects( + () => readEarliestTimestamp(file), + { name: 'TimestampProbeLimitError' } + ); +}); + +test('mapWithConcurrency never starts more than eight readers', async () => { + let active = 0; + let peak = 0; + const result = await mapWithConcurrency( + Array.from({ length: 32 }, (_, i) => i), + 8, + async (value) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setImmediate(resolve)); + active -= 1; + return value * 2; + } + ); + assert.equal(peak, 8); + assert.deepEqual(result, Array.from({ length: 32 }, (_, i) => i * 2)); +}); + +test('equal or invalid timestamps preserve v2.2.0 discovery order', async () => { + const { root } = await fixture(); + const project = path.join(root, 'projects', '-tmp-demo'); + const z = path.join(project, 'z.jsonl'); + const a = path.join(project, 'a.jsonl'); + const invalid = path.join(project, 'invalid.jsonl'); + const invalid2 = path.join(project, 'invalid-2.jsonl'); + await writeFile(z, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + await writeFile(a, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + await writeFile(invalid, '{"timestamp":"not-a-date"}\n'); + await writeFile(invalid2, 'not-json\n'); + + const make = async (file: string, discoveryIndex: number) => { + const fileStat = await stat(file); + return { + path: file, + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + discoveryIndex, + dev: fileStat.dev > 0 && fileStat.ino > 0 ? fileStat.dev : undefined, + ino: fileStat.dev > 0 && fileStat.ino > 0 ? fileStat.ino : undefined, + }; + }; + + const equal = await sortUsageFilesByEarliestTimestamp([ + await make(z, 0), + await make(a, 1), + ]); + assert.deepEqual(equal.files, [z, a]); // do not add a path/name tie-breaker + + const invalidPair = await sortUsageFilesByEarliestTimestamp([ + await make(invalid, 0), + await make(invalid2, 1), + ]); + assert.deepEqual(invalidPair.files, [invalid, invalid2]); +}); +``` + +- [ ] **Step 2: 运行 RED,确认文件层接口尚不存在** + +Run: `npm run compile` + +Expected: FAIL,包含 `TS2307: Cannot find module '../claudeUsageFiles'`。 + +- [ ] **Step 3: 实现 manifest、fingerprint 比较与流式排序** + +```ts +// src/claudeUsageFiles.ts +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; + +export interface UsageFileFingerprint { + path: string; + size: number; + mtimeMs: number; + /** Original v2.2.0 recursive discovery position; tie ordering depends on it. */ + discoveryIndex: number; + dev?: number; + ino?: number; +} + +export interface UsageManifest { + entries: ReadonlyMap; + scannedAtMs: number; + scanDurationMs: number; +} + +export interface ManifestDelta { + changed: UsageFileFingerprint[]; + reused: UsageFileFingerprint[]; + removed: UsageFileFingerprint[]; +} + +export function isMissingPathError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'; +} + +export interface UsageFileSystem { + readdir(dir: string): Promise; + stat(filePath: string): Promise; +} + +const DEFAULT_USAGE_FILE_SYSTEM: UsageFileSystem = { + readdir: (dir) => fs.promises.readdir(dir, { withFileTypes: true }), + stat: (filePath) => fs.promises.stat(filePath), +}; + +async function walkJsonl( + dir: string, + output: string[], + io: UsageFileSystem +): Promise { + let entries: fs.Dirent[]; + try { + entries = await io.readdir(dir); + } catch (error) { + if (isMissingPathError(error)) return; + throw error; + } + // Do not sort readdir results. v2.2.0 used native recursive discovery order, + // and stable Array.sort preserved that order for equal/invalid timestamps. + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walkJsonl(fullPath, output, io); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + output.push(fullPath); + } + } +} + +export async function scanUsageManifest( + dataRoots: readonly string[], + io: UsageFileSystem = DEFAULT_USAGE_FILE_SYSTEM +): Promise { + const started = performance.now(); + const paths: string[] = []; + for (const root of dataRoots) { + await walkJsonl(path.join(root, 'projects'), paths, io); + } + const entries = new Map(); + for (const [discoveryIndex, filePath] of paths.entries()) { + try { + const stat = await io.stat(filePath); + const hasStableIdentity = stat.dev > 0 && stat.ino > 0; + entries.set(filePath, { + path: filePath, + size: stat.size, + mtimeMs: stat.mtimeMs, + discoveryIndex, + dev: hasStableIdentity ? stat.dev : undefined, + ino: hasStableIdentity ? stat.ino : undefined, + }); + } catch (error) { + // A file may disappear between readdir and stat. Permission and I/O + // failures are not an empty corpus: abort this reconciliation instead. + if (!isMissingPathError(error)) throw error; + } + } + return { entries, scannedAtMs: Date.now(), scanDurationMs: performance.now() - started }; +} + +export function sameFingerprint(a: UsageFileFingerprint, b: UsageFileFingerprint): boolean { + const identityComparable = (a.dev ?? 0) > 0 && (a.ino ?? 0) > 0 && + (b.dev ?? 0) > 0 && (b.ino ?? 0) > 0; + return a.path === b.path && a.size === b.size && a.mtimeMs === b.mtimeMs && + (!identityComparable || (a.dev === b.dev && a.ino === b.ino)); +} + +export function diffUsageManifests( + previous: UsageManifest | null, + current: UsageManifest +): ManifestDelta { + if (!previous) { + return { changed: [...current.entries.values()], reused: [], removed: [] }; + } + const changed: UsageFileFingerprint[] = []; + const reused: UsageFileFingerprint[] = []; + for (const entry of current.entries.values()) { + const old = previous.entries.get(entry.path); + (old && sameFingerprint(old, entry) ? reused : changed).push(entry); + } + const removed = [...previous.entries.values()].filter((entry) => !current.entries.has(entry.path)); + return { changed, reused, removed }; +} + +export async function mapWithConcurrency( + values: readonly T[], + limit: number, + worker: (value: T, index: number) => Promise +): Promise { + if (!Number.isInteger(limit) || limit < 1) { + throw new RangeError('limit must be a positive integer'); + } + const output = new Array(values.length); + let next = 0; + const run = async (): Promise => { + while (next < values.length) { + const index = next++; + output[index] = await worker(values[index], index); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run)); + return output; +} + +function timestampFromLine(line: string): number | null { + if (line.trim() === '') return null; + try { + const value = JSON.parse(line) as { timestamp?: unknown }; + if (typeof value.timestamp !== 'string') return null; + const timestampMs = Date.parse(value.timestamp); + return Number.isNaN(timestampMs) ? null : timestampMs; + } catch { + return null; + } +} + +export async function readEarliestTimestamp( + filePath: string +): Promise<{ timestampMs: number; bytesRead: number }> { + // Never cap an in-progress JSONL record: legitimate snapshot/tool/prompt + // records may exceed 1 MiB. Only completed invalid records consume the + // fail-closed invalid-prefix budget. + const MAX_COMPLETED_INVALID_PREFIX_BYTES = 1024 * 1024; + const stream = fs.createReadStream(filePath, { + highWaterMark: 4096, + start: 0, + }); + const decoder = new StringDecoder('utf8'); + let lineParts: string[] = []; + let bytesRead = 0; + let completedInvalidBytes = 0; + try { + for await (const chunk of stream) { + const buffer = chunk as Buffer; + bytesRead += buffer.byteLength; + const pieces = decoder.write(buffer).split('\n'); + lineParts.push(pieces.shift()!); + while (pieces.length > 0) { + const line = lineParts.join('').replace(/\r$/, ''); + lineParts = [pieces.shift()!]; + const timestampMs = timestampFromLine(line); + if (timestampMs !== null) return { timestampMs, bytesRead }; + completedInvalidBytes += Buffer.byteLength(`${line}\n`, 'utf8'); + if (completedInvalidBytes > MAX_COMPLETED_INVALID_PREFIX_BYTES) { + const error = new Error('invalid timestamp prefix limit exceeded'); + error.name = 'TimestampProbeLimitError'; + throw error; + } + } + } + const finalDecoded = decoder.end(); + if (finalDecoded !== '') lineParts.push(finalDecoded); + const pending = lineParts.join(''); + const finalTimestamp = timestampFromLine(pending); + if (finalTimestamp !== null) return { timestampMs: finalTimestamp, bytesRead }; + completedInvalidBytes += Buffer.byteLength(pending, 'utf8'); + if (completedInvalidBytes > MAX_COMPLETED_INVALID_PREFIX_BYTES) { + const error = new Error('invalid timestamp prefix limit exceeded'); + error.name = 'TimestampProbeLimitError'; + throw error; + } + return { timestampMs: 0, bytesRead }; + } catch (error) { + stream.destroy(); + throw error; + } +} + +export async function sortUsageFilesByEarliestTimestamp( + entries: readonly UsageFileFingerprint[] +): Promise<{ files: string[]; bytesRead: number }> { + const stamped = await mapWithConcurrency(entries, 8, async (entry) => ({ + file: entry.path, + discoveryIndex: entry.discoveryIndex, + ...(await readEarliestTimestamp(entry.path)), + })); + stamped.sort((a, b) => + a.timestampMs - b.timestampMs || a.discoveryIndex - b.discoveryIndex + ); + return { + files: stamped.map((item) => item.file), + bytesRead: stamped.reduce((sum, item) => sum + item.bytesRead, 0), + }; +} +``` + +`lineParts` 只在遇到 newline 时 join,避免对超大合法首 record 每 4 KiB 重复复制整条字符串造成 O(n²) CPU;返回/失败后不得把这些 parts 存入 cache。 + +- [ ] **Step 4: 运行 GREEN 并重复并发测试以排除偶发超限** + +Run: `npm run compile && node --test out/test/claudeUsageFiles.test.js && node --test out/test/claudeUsageFiles.test.js` + +Expected: 两次均 PASS,9 tests、0 failures(不提供可靠 inode 的文件系统可 skip identity case);`ENOENT` 是唯一可忽略 scanner errno,`EACCES` test 必须 reject;同时间戳和非法时间戳测试不得按路径名重排;超过 1 MiB 的完整合法首行仍取得原 timestamp。只有已经完整读完且无合法 timestamp 的累计 invalid records 超过 1 MiB 才 reject 并保持旧 snapshot;绝不能截断当前 record 或降级成错误的 `timestampMs=0` 后提交。 + +- [ ] **Step 5: 提交文件发现与有界排序模块** + +```bash +git add src/claudeUsageFiles.ts src/test/claudeUsageFiles.test.ts +git commit -m "perf: bound JSONL timestamp scanning" +``` + +### Task 3: loader 复用 manifest 并返回匿名 I/O 指标 + +**Files:** +- Create: `src/refreshDiagnostics.ts` +- Create: `src/test/refreshDiagnostics.test.ts` +- Modify: `src/dataLoader.ts:1-69,522-876,975-1014,2843-2872` +- Modify: `src/test/dataLoader.test.ts` + +**Interfaces:** +- Consumes: Task 2 的 `UsageManifest`、`scanUsageManifest()`、`sortUsageFilesByEarliestTimestamp()`。 +- Produces: `LoadUsageDiagnostics`、扩展后的 `loadUsageRecords(..., options.manifest)` 返回值、`formatRefreshDiagnostic(metrics): string`。 + +- [ ] **Step 1: 写 loader 只扫描一次及诊断不含敏感字段的失败测试** + +```ts +// append to src/test/dataLoader.test.ts +import { after } from 'node:test'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { scanUsageManifest } from '../claudeUsageFiles'; + +const tempRoots: string[] = []; +after(async () => Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))); + +test('loadUsageRecords uses the injected manifest instead of rescanning dataDirectory', async () => { + const manifestRoot = await mkdtemp(path.join(os.tmpdir(), 'ccu-loader-manifest-')); + const emptyArgumentRoot = await mkdtemp(path.join(os.tmpdir(), 'ccu-loader-empty-')); + tempRoots.push(manifestRoot, emptyArgumentRoot); + const project = path.join(manifestRoot, 'projects', '-tmp-project'); + await mkdir(project, { recursive: true }); + await mkdir(path.join(emptyArgumentRoot, 'projects'), { recursive: true }); + const line = JSON.stringify({ + timestamp: '2026-07-17T00:00:00.000Z', + requestId: 'request-secret', + message: { + id: 'message-secret', + model: 'claude-sonnet-4-6', + usage: { input_tokens: 10, output_tokens: 2 }, + }, + }) + '\n'; + await writeFile(path.join(project, 'session-secret.jsonl'), line); + const manifest = await scanUsageManifest([manifestRoot]); + + // Passing an empty, different root is deliberate: if the loader ignores the + // injected manifest and performs its own scan, this assertion returns zero. + const loaded = await ClaudeDataLoader.loadUsageRecords(emptyArgumentRoot, { + analyzeContent: false, + manifest, + }); + assert.equal(loaded.records.length, 1); + assert.equal(loaded.diagnostics.filesDiscovered, 1); + assert.equal(loaded.diagnostics.bytesRead >= Buffer.byteLength(line), true); + assert.equal(loaded.diagnostics.linesParsed, 1); + assert.equal(loaded.diagnostics.filesFailed, 0); +}); + +test('a file that disappears after manifest scan marks the load incomplete', async () => { + const manifestRoot = await mkdtemp(path.join(os.tmpdir(), 'ccu-loader-race-')); + tempRoots.push(manifestRoot); + const project = path.join(manifestRoot, 'projects', '-tmp-project'); + await mkdir(project, { recursive: true }); + const file = path.join(project, 'vanishing.jsonl'); + await writeFile(file, '{"timestamp":"2026-07-17T00:00:00.000Z"}\n'); + const manifest = await scanUsageManifest([manifestRoot]); + await rm(file); + const loaded = await ClaudeDataLoader.loadUsageRecords(manifestRoot, { + analyzeContent: false, + manifest, + }); + assert.equal(loaded.diagnostics.filesFailed, 1); +}); +``` + +```ts +// src/test/refreshDiagnostics.test.ts +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { formatRefreshDiagnostic } from '../refreshDiagnostics'; + +test('refresh diagnostics contain only stage names and anonymous numeric counters', () => { + const line = formatRefreshDiagnostic({ + trigger: 'watch', + filesDiscovered: 389, + filesChanged: 1, + filesReused: 388, + filesRemoved: 0, + filesFailed: 0, + bytesRead: 4096, + linesParsed: 17, + watcherEvents: 42, + coalescedTriggers: 3, + manifestMs: 12.34, + readParseMs: 45.67, + aggregateRenderMs: 8.9, + totalMs: 67.01, + }); + assert.equal( + line, + 'refresh: trigger=watch files(discovered=389 changed=1 reused=388 removed=0 failed=0) ' + + 'io(bytes=4096 lines=17) events(watcher=42 coalesced=3) ' + + 'ms(manifest=12.3 read-parse=45.7 aggregate-render=8.9 total=67.0)' + ); + assert.equal(/[/\\]|secret|session|prompt|credential|\.jsonl/i.test(line), false); +}); +``` + +- [ ] **Step 2: 运行 RED,确认 loader 结果尚无 diagnostics** + +Run: `npm run compile` + +Expected: FAIL,至少包含 `Property 'diagnostics' does not exist` 和缺少 `refreshDiagnostics` 模块。 + +- [ ] **Step 3: 加入匿名类型、复用 manifest 并移除旧全文件时间戳实现** + +```ts +// src/refreshDiagnostics.ts +import { RefreshTrigger } from './refreshPolicy'; + +export interface LoadUsageDiagnostics { + filesDiscovered: number; + filesFailed: number; + bytesRead: number; + linesParsed: number; + readParseMs: number; +} + +export interface RefreshDiagnostic extends LoadUsageDiagnostics { + trigger: RefreshTrigger; + filesChanged: number; + filesReused: number; + filesRemoved: number; + watcherEvents: number; + coalescedTriggers: number; + manifestMs: number; + aggregateRenderMs: number; + totalMs: number; +} + +const ms = (value: number): string => value.toFixed(1); + +export function formatRefreshDiagnostic(value: RefreshDiagnostic): string { + return `refresh: trigger=${value.trigger} ` + + `files(discovered=${value.filesDiscovered} changed=${value.filesChanged} ` + + `reused=${value.filesReused} removed=${value.filesRemoved} failed=${value.filesFailed}) ` + + `io(bytes=${value.bytesRead} lines=${value.linesParsed}) ` + + `events(watcher=${value.watcherEvents} coalesced=${value.coalescedTriggers}) ` + + `ms(manifest=${ms(value.manifestMs)} read-parse=${ms(value.readParseMs)} ` + + `aggregate-render=${ms(value.aggregateRenderMs)} total=${ms(value.totalMs)})`; +} +``` + +在 `src/dataLoader.ts` 导入 Task 2/3 接口,删除文件顶部的本地 `findJsonlFiles()`,并把 loader 签名改为: + +```ts +import { + scanUsageManifest, + sortUsageFilesByEarliestTimestamp, + UsageManifest, +} from './claudeUsageFiles'; +import { LoadUsageDiagnostics } from './refreshDiagnostics'; + +static async loadUsageRecords( + dataDirectory?: string, + options?: { + analyzeContent?: boolean; + windowDays?: number; + log?: (line: string) => void; + manifest?: UsageManifest; + } +): Promise<{ + records: ClaudeUsageRecord[]; + contentAnalysis: ContentAnalysis | null; + diagnostics: LoadUsageDiagnostics; +}> { + const readParseStarted = performance.now(); + let bytesRead = 0; + let linesParsed = 0; + let filesFailed = 0; + const emptyDiagnostics = (): LoadUsageDiagnostics => ({ + filesDiscovered: 0, + filesFailed, + bytesRead, + linesParsed, + readParseMs: performance.now() - readParseStarted, + }); + try { + const claudePaths = dataDirectory ? [dataDirectory] : this.getClaudePaths(); + const manifest = options?.manifest ?? await scanUsageManifest(claudePaths); + const sorted = await sortUsageFilesByEarliestTimestamp([...manifest.entries.values()]); + bytesRead += sorted.bytesRead; + const sortedFiles = sorted.files; + + // 保留下方现有逐行解析、去重、title、analysis 与 attribution 逻辑; + // 读取每个文件后立即累计实际 UTF-8 字节数: + for (const file of sortedFiles) { + try { + const content = await readFile(file, 'utf-8'); + bytesRead += Buffer.byteLength(content, 'utf8'); + const lines = content.trim().split('\n').filter((line) => line.trim() !== ''); + for (const line of lines) { + linesParsed += 1; + // 此处继续执行当前 JSON.parse/validate/dedup/analysis 分支,顺序不变。 + } + } catch { + // Preserve the old continue-on-one-bad-file behavior, but make the + // incomplete load explicit so extension never commits its manifest. + filesFailed += 1; + } + } + + // 在现有 finalizeAnalysis/calibration 完成后返回: + return { + records, + contentAnalysis, + diagnostics: { + filesDiscovered: manifest.entries.size, + filesFailed, + bytesRead, + linesParsed, + readParseMs: performance.now() - readParseStarted, + }, + }; + } catch (error) { + filesFailed = Math.max(filesFailed, 1); + console.error('Error loading usage records:', error); + return { records: [], contentAnalysis: null, diagnostics: emptyDiagnostics() }; + } +} +``` + +机械删除 `src/dataLoader.ts` 的旧 `getEarliestTimestamp()` / `sortFilesByTimestamp()`,并让兼容 API 复用 manifest: + +```ts +static async getLatestModifiedTime(dataDirectory?: string): Promise { + try { + const roots = dataDirectory ? [dataDirectory] : this.getClaudePaths(); + const manifest = await scanUsageManifest(roots); + let latest = 0; + for (const entry of manifest.entries.values()) { + latest = Math.max(latest, entry.mtimeMs); + } + return latest; + } catch { + return 0; + } +} +``` + +实现时不得复制第二套逐行 parser;应把当前 `src/dataLoader.ts:545-870` 的主体原地保留,仅替换它的文件列表来源、计数点和返回结构。现有 `stats`(`parseErrors`、`rejected`、dedup、kept、user prompts、model summaries)和它通过 `options.log` 输出的诊断必须原样保留;`LoadUsageDiagnostics` 是额外的匿名性能计数,不能取代现有格式漂移诊断。 + +- [ ] **Step 4: 运行 focused GREEN 与完整 loader 回归** + +Run: `npm run compile && node --test out/test/claudeUsageFiles.test.js out/test/refreshDiagnostics.test.js out/test/dataLoader.test.js` + +Expected: PASS,所有列出的测试 0 failures。 + +Run: `rg -n "getEarliestTimestamp|sortFilesByTimestamp|Promise\.all\(\s*files\.map" src/dataLoader.ts` + +Expected: 无输出,退出码 1。 + +- [ ] **Step 5: 提交单扫描 loader 与匿名诊断类型** + +```bash +git add src/dataLoader.ts src/refreshDiagnostics.ts src/test/dataLoader.test.ts src/test/refreshDiagnostics.test.ts +git commit -m "perf: reuse JSONL manifest during loads" +``` + +### Task 4: 把 trigger、quiet watcher 和阶段诊断接入 extension + +**Files:** +- Modify: `src/extension.ts:39-97,120-127,181-184,670-954,1040-1204` +- Modify: `src/test/refreshPolicy.test.ts` + +**Interfaces:** +- Consumes: `RefreshTrigger`、`mergeRefreshTrigger()`、`scanUsageManifest()`、`diffUsageManifests()`、`formatRefreshDiagnostic()`、loader 的 `diagnostics`。 +- Produces: `refreshData(forceReload?: boolean, trigger?: RefreshTrigger)`、`RefreshSingleFlight`;watcher burst 只保留一个 quiet timer;每个实际 refresh 输出一条匿名阶段日志。 + +- [ ] **Step 1: 先补上 watcher burst 与 pending trigger 的 RED 契约** + +在 `src/refreshPolicy.ts` 增加导出声明前,先把以下测试追加到 `src/test/refreshPolicy.test.ts`: + +```ts +import { + QuietDebounce, + RefreshSingleFlight, + shouldCommitUsageLoad, + shouldReloadUsage, +} from '../refreshPolicy'; + +test('quiet debounce collapses a write burst into one callback', () => { + const scheduled: Array<() => void> = []; + const delays: number[] = []; + const cleared: number[] = []; + const debounce = new QuietDebounce( + (callback, ms) => { + scheduled.push(callback); + delays.push(ms); + return scheduled.length as unknown as NodeJS.Timeout; + }, + (handle) => { cleared.push(handle as unknown as number); } + ); + let fired = 0; + debounce.push(2_000, () => { fired += 1; }); + debounce.push(300_000, () => { fired += 1; }); + debounce.push(60_000, () => { fired += 1; }); + assert.deepEqual(delays, [2_000, 300_000, 60_000]); + assert.deepEqual(cleared, [1, 2]); + scheduled[2](); + assert.equal(fired, 1); +}); + +test('single-flight collapses a burst to one strongest follow-up', () => { + const gate = new RefreshSingleFlight(); + assert.deepEqual(gate.request(false, 'poll'), { forceReload: false, trigger: 'poll' }); + for (let i = 0; i < 50; i += 1) { + assert.equal(gate.request(false, 'watch'), null); + } + assert.equal(gate.request(true, 'manual'), null); + assert.deepEqual(gate.complete(), { forceReload: true, trigger: 'manual' }); + + // The returned follow-up owns the gate; a trigger arriving before it runs is + // queued for one later pass instead of starting in parallel. + assert.equal(gate.request(false, 'focus'), null); + assert.deepEqual(gate.complete(), { forceReload: false, trigger: 'focus' }); + assert.equal(gate.complete(), null); + assert.deepEqual(gate.request(false, 'poll'), { forceReload: false, trigger: 'poll' }); +}); + +test('a successfully loaded empty corpus reuses its non-null manifest', () => { + assert.equal(shouldReloadUsage({ + forceReload: false, + directoryChanged: false, + hasLoadedManifest: true, + changedFiles: 0, + removedFiles: 0, + }), false); + assert.equal(shouldReloadUsage({ + forceReload: false, + directoryChanged: false, + hasLoadedManifest: false, + changedFiles: 0, + removedFiles: 0, + }), true); +}); + +test('an incomplete file load cannot commit the new manifest', () => { + assert.equal(shouldCommitUsageLoad(0), true); + assert.equal(shouldCommitUsageLoad(1), false); +}); +``` + +- [ ] **Step 2: 运行 RED,确认 quiet-debounce helper 尚未定义** + +Run: `npm run compile` + +Expected: FAIL,包含缺少 `QuietDebounce` / `RefreshSingleFlight` 导出的错误。 + +- [ ] **Step 3: 实现 debounce helper 并接入 extension 的单次 manifest/诊断路径** + +```ts +// append to src/refreshPolicy.ts +export class QuietDebounce { + private handle: NodeJS.Timeout | undefined; + + constructor( + private readonly schedule: (callback: () => void, ms: number) => NodeJS.Timeout = setTimeout, + private readonly cancel: (handle: NodeJS.Timeout) => void = clearTimeout + ) {} + + push(ms: number, callback: () => void): void { + if (this.handle) this.cancel(this.handle); + this.handle = this.schedule(() => { + this.handle = undefined; + callback(); + }, ms); + } + + clear(): void { + if (this.handle) this.cancel(this.handle); + this.handle = undefined; + } +} + +export interface RefreshRequest { + forceReload: boolean; + trigger: RefreshTrigger; +} + +export function shouldReloadUsage(value: { + forceReload: boolean; + directoryChanged: boolean; + hasLoadedManifest: boolean; + changedFiles: number; + removedFiles: number; +}): boolean { + return value.forceReload || value.directoryChanged || !value.hasLoadedManifest || + value.changedFiles > 0 || value.removedFiles > 0; +} + +export function shouldCommitUsageLoad(filesFailed: number): boolean { + return Number.isInteger(filesFailed) && filesFailed === 0; +} + +export class RefreshSingleFlight { + private active = false; + private pendingForceReload = false; + private pendingTrigger: RefreshTrigger | null = null; + + request(forceReload: boolean, trigger: RefreshTrigger): RefreshRequest | null { + if (!this.active) { + this.active = true; + return { forceReload, trigger }; + } + this.pendingForceReload ||= forceReload; + this.pendingTrigger = mergeRefreshTrigger(this.pendingTrigger, trigger); + return null; + } + + complete(): RefreshRequest | null { + if (this.pendingTrigger === null) { + this.active = false; + return null; + } + const next = { + forceReload: this.pendingForceReload, + trigger: this.pendingTrigger, + }; + this.pendingForceReload = false; + this.pendingTrigger = null; + // Keep active=true: caller runs `next` directly without calling request(). + return next; + } +} +``` + +在 `src/extension.ts` 加入并初始化这些字段;从 `cache` 中的 manifest 只保存匿名比较所需对象,不持久化: + +```ts +import { + diffUsageManifests, + scanUsageManifest, + UsageManifest, +} from './claudeUsageFiles'; +import { + mergeRefreshTrigger, + QuietDebounce, + RefreshRequest, + RefreshSingleFlight, + RefreshTrigger, + shouldCommitUsageLoad, + shouldReloadUsage, +} from './refreshPolicy'; +import { formatRefreshDiagnostic } from './refreshDiagnostics'; + +private readonly watchDebounce = new QuietDebounce(); +private readonly refreshGate = new RefreshSingleFlight(); +private watcherEventsSinceRefresh = 0; +private coalescedTriggersSinceRefresh = 0; + +// cache object gains this field and initial value: +manifest: UsageManifest | null; +// ... +manifest: null, +``` + +watch callback 必须只计数、quiet debounce、带上 trigger,不能改变 poll cadence: + +```ts +this.fileWatcher = fs.watch(projectsDir, { recursive: true }, (_event, filename) => { + if (!filename || !String(filename).endsWith('.jsonl')) return; + // Activity remains authoritative for quota TTL only; poll cadence ignores it. + this.lastActivityAt = Date.now(); + this.watcherEventsSinceRefresh += 1; + // Read current settings for every event. Never close over the config captured + // when the watcher was first attached: changing 2s -> 300s must take effect. + const delaySeconds = this.getConfiguration().fileWatchSeconds; + if (!(delaySeconds > 0)) { + this.stopFileWatching(); + return; + } + this.watchDebounce.push(delaySeconds * 1000, () => { + void this.refreshData(false, 'watch'); + }); +}); +``` + +`stopFileWatching()` 调用 `this.watchDebounce.clear()`,并删除旧 `watchDebounceTimer` 字段。`onConfigurationChanged()` 无条件先 `stopFileWatching()`,再刷新并重新创建 watcher;不能只在 raw `config.dataDirectory` 与已经 resolve 的 `cache.dataDirectory` 相等/不等时决定重启。动态读取 delay 与强制重启共同保证 2 秒切换到 300 秒或 Off 会立即生效。 + +所有现有 `refreshData` call site 必须按下表逐一转换;`forceReload=true` 表示重读并重新聚合,低频的 pricing/workspace/settings 允许为正确性付出一次全读,credentials 只失效 quota、不得无条件重读 JSONL: + +| Call site | 精确调用 | force 语义 | +|---|---|---| +| activation / constructor (`~126`) | `this.refreshData(false, 'startup').then(() => this.startFileWatching())` | 冷 cache 由 `manifest === null` 自动触发读取 | +| manual command (`~183`) | `void this.refreshData(true, 'manual')` | 用户明确要求强制重读,且始终更新 webview | +| pricing refresh (`~223`) | `void this.refreshData(true, 'pricing')` | 新价格必须重新计算;删除 `cache.lastUpdate = new Date(0)` hack | +| workspace folders (`~696`) | `void this.refreshData(true, 'workspace')` | workspace filter/context 与 breakdown 必须重算 | +| settings/config (`~781`) | `this.stopFileWatching(); void this.refreshData(true, 'settings').then(() => this.startFileWatching())` | 包括 content analysis、grouping、data directory 与 watcher delay;全部重新聚合 | +| JSONL watcher (`~820`) | `void this.refreshData(false, 'watch')` | manifest 有变化才重读 | +| credentials watcher (`~875`) | `void this.refreshData(false, 'credentials')` | 只让 quota TTL 失效;JSONL 不变时走短路 | +| window focus (`~917`) | `void this.refreshData(false, 'focus')` | catch-up scan,不强制全读 | +| poll timer (`~946`) | `this.refreshData(false, 'poll').finally(...)` | 始终按配置 cadence,manifest 不变时短路 | + +`onConfigurationChanged()` 不再拿 raw `config.dataDirectory` 与 resolved cache path 比较,也不以 `records=[]` 作为强制信号;resolved directory 是否变化在 `refreshData()` 找到目录后判断。 + +把 `refreshData` 拆成入队 wrapper 与只由 gate 调用的实际执行函数: + +```ts +private async refreshData( + forceReload: boolean = false, + trigger: RefreshTrigger = 'poll' +): Promise { + const request = this.refreshGate.request(forceReload, trigger); + if (request === null) { + this.coalescedTriggersSinceRefresh += 1; + return; + } + await this.runRefresh(request); +} +``` + +把实际 manifest/loader/返回路径改成以下确定流程;现有 quota/UI 聚合代码留在标出的阶段中: + +```ts +private async runRefresh(request: RefreshRequest): Promise { + const totalStarted = performance.now(); + const watcherEvents = this.watcherEventsSinceRefresh; + const coalescedTriggers = this.coalescedTriggersSinceRefresh; + this.watcherEventsSinceRefresh = 0; + this.coalescedTriggersSinceRefresh = 0; + let aggregateRenderMs = 0; + try { + const config = this.getConfiguration(); + const updateWebview = request.trigger === 'manual' || config.dashboardAutoRefresh; + // 保留现有 asynchronous quota fetch 和 dataDirectory 查找。 + const dataDirectory = await ClaudeDataLoader.findClaudeDataDirectory( + config.dataDirectory || undefined + ); + if (!dataDirectory) { + // 保留现有无数据 UI 分支。 + return; + } + + const manifestStarted = performance.now(); + const manifest = await scanUsageManifest([dataDirectory]); + const delta = diffUsageManifests(this.cache.manifest, manifest); + const manifestMs = performance.now() - manifestStarted; + const dirChanged = this.cache.dataDirectory !== dataDirectory; + const needFullRefresh = shouldReloadUsage({ + forceReload: request.forceReload, + directoryChanged: dirChanged, + hasLoadedManifest: this.cache.manifest !== null, + changedFiles: delta.changed.length, + removedFiles: delta.removed.length, + }); + + if (!needFullRefresh) { + // 保留现有 cached context 更新。 + // 先保存成功 reconciliation,再输出诊断,再返回;合法空 corpus + // 也会在第二次刷新命中此路径,不能因 records.length===0 反复全读。 + this.cache.manifest = manifest; + this.cache.dataDirectory = dataDirectory; + this.outputChannel.appendLine(formatRefreshDiagnostic({ + trigger: request.trigger, + filesDiscovered: manifest.entries.size, + filesChanged: 0, + filesReused: delta.reused.length, + filesRemoved: 0, + filesFailed: 0, + bytesRead: 0, + linesParsed: 0, + watcherEvents, + coalescedTriggers, + manifestMs, + readParseMs: 0, + aggregateRenderMs: 0, + totalMs: performance.now() - totalStarted, + })); + return; + } + + const loaded = await ClaudeDataLoader.loadUsageRecords(dataDirectory, { + analyzeContent: config.enableContentAnalysis, + windowDays: config.advicePromptWindowDays, + manifest, + log: (line) => this.outputChannel.appendLine( + `[${new Date().toLocaleTimeString(undefined, { hour12: false })}] ${line}` + ), + }); + if (!shouldCommitUsageLoad(loaded.diagnostics.filesFailed)) { + // A partial corpus must never become the new cache baseline. Keep the + // previous records + manifest so the next trigger retries the same file. + 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, + watcherEvents, + coalescedTriggers, + manifestMs, + readParseMs: loaded.diagnostics.readParseMs, + aggregateRenderMs: 0, + totalMs: performance.now() - totalStarted, + })); + return; + } + const aggregateStarted = performance.now(); + // 原有 records/contentAnalysis cache 赋值和所有聚合/UI 更新放在这里。 + // 冷启动 loading 判断改为 previous manifest===null,不能用 records.length。 + // records===[] 是合法成功结果:更新 no-data UI,但不要提前 return。 + aggregateRenderMs = performance.now() - aggregateStarted; + this.cache.manifest = manifest; + this.cache.dataDirectory = dataDirectory; + this.cache.lastUpdate = new Date(); + // 必须在 records===[] 的成功分支返回之前输出这一行。 + 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, + watcherEvents, + coalescedTriggers, + manifestMs, + readParseMs: loaded.diagnostics.readParseMs, + aggregateRenderMs, + totalMs: performance.now() - totalStarted, + })); + } catch { + // Scanner/reconciliation failures (EACCES/EIO included) are failed + // attempts, never an empty corpus. Cache assignments occur only on the + // successful paths above, so the previous snapshot remains authoritative. + this.outputChannel.appendLine(formatRefreshDiagnostic({ + trigger: request.trigger, + filesDiscovered: 0, + filesChanged: 0, + filesReused: 0, + filesRemoved: 0, + filesFailed: 1, + bytesRead: 0, + linesParsed: 0, + watcherEvents, + coalescedTriggers, + manifestMs: 0, + readParseMs: 0, + aggregateRenderMs: 0, + totalMs: performance.now() - totalStarted, + })); + } finally { + const next = this.refreshGate.complete(); + if (next !== null) { + setTimeout(() => void this.runRefresh(next), 0); + } + } +} +``` + +删除旧 `isRefreshing` / `pendingRefresh` / `pendingManual` 字段;`RefreshSingleFlight.pendingTrigger` 明确以 `null` 表示没有 follow-up,不能初始化成 `'poll'`。不得把 manifest 写入 `globalState`、磁盘或日志。loader 成功后,必须先完成 records/contentAnalysis/UI、保存 manifest、输出 diagnostics,才允许结束合法的空 records 路径。loader outer catch 必须令 `filesFailed >= 1`;scanner/loader 的任何非 `ENOENT` 失败都只产生匿名 failed diagnostic,保持旧 records/manifest/dataDirectory,不呈现 no-data,不提交 candidate manifest,并由下一 trigger 重试。 + +在 `src/test/refreshPolicy.test.ts` 增加不依赖 VS Code host 的 transaction-seam 测试:预置可识别的旧 manifest/records,让 scanner 抛 `EACCES`,断言 candidate commit callback 未调用且旧对象引用未变;随后让 scanner 成功,断言下一次 trigger 再次扫描且可提交。这样测试的是“失败不推进 baseline”,不是只测试 errno helper。 + +- [ ] **Step 4: 运行 GREEN、完整测试并人工核对 trigger call sites** + +Run: `npm run compile && node --test out/test/refreshPolicy.test.js out/test/refreshDiagnostics.test.js out/test/claudeUsageFiles.test.js out/test/dataLoader.test.js` + +Expected: PASS,0 failures。 + +Run: `rg -n "refreshData\(" src/extension.ts` + +Expected: 只出现表中九个 call site 和方法定义;可见 `'startup'`、`'poll'`、`'credentials'`、`'watch'`、`'focus'`、`'workspace'`、`'settings'`、`'pricing'`、`'manual'` 各自的显式调用,不得残留无参数调用。 + +Run: `rg -n "needFullRefresh.*records\.length|records\.length === 0.*needFullRefresh|pendingTrigger:\s*RefreshTrigger\s*=\s*'poll'" src/extension.ts src/refreshPolicy.ts` + +Expected: 无输出,退出码 1;合法空 corpus 由 non-null manifest 表示已经加载,pending trigger 由 `null` 表示不存在。 + +- [ ] **Step 5: 提交 extension 集成** + +```bash +git add src/extension.ts src/refreshPolicy.ts src/test/refreshPolicy.test.ts +git commit -m "perf: coalesce watched refresh diagnostics" +``` + +### Task 5: 六语言 workaround、CHANGELOG 与发布验证 + +**Files:** +- Modify: `CHANGELOG.md:7` +- Modify: `README.md:302-305` +- Modify: `README-en.md:63-71` +- Modify: `README-zh-TW.md` +- Modify: `README-zh-CN.md:175-176` +- Modify: `README-ja.md` +- Modify: `README-ko.md` + +**Interfaces:** +- Consumes: Task 1–4 的实际行为与诊断字段。 +- Produces: 六语言一致的 #70 临时处理说明,以及只称为 mitigation 的 V2.2.1 CHANGELOG。 + +- [ ] **Step 1: 先写会失败的文档一致性检查** + +Run: + +```bash +for file in README.md README-en.md README-zh-TW.md README-zh-CN.md README-ja.md README-ko.md; do + rg -q "300[–-]900" "$file" || exit 1 + rg -q "#70" "$file" || exit 1 +done +``` + +Expected: FAIL;至少一份 README 尚无 Live refresh Off + 300–900 秒 workaround。 + +- [ ] **Step 2: 在六份 README 的 Troubleshooting 写入对应语言的精确说明** + +`README.md` 与 `README-en.md` 使用: + +```md +**High CPU or sluggish refresh on a large history (Linux included)** +- V2.2.1 removes the hidden 8-second active polling override and bounds the + first-timestamp scan. Until you install it, set **Live refresh delay** to + **Off**, set **Refresh interval** to **300–900 seconds**, and optionally turn + **Content analysis** off. Turning Dashboard auto-refresh off by itself does + not stop status-bar parsing. +- If V2.2.1 still runs hot, open **Show Diagnostic Logs** and attach only the + anonymous `refresh:` lines to issue #70; they contain counts and timings, not + prompts, paths, session IDs, credentials, or raw log lines. +``` + +`README-zh-CN.md` 使用: + +```md +**大历史下 CPU 占用高或刷新缓慢(包括 Linux)** +- V2.2.1 移除了 active 状态下隐藏的 8 秒轮询覆盖,并限制首时间戳扫描。 + 在安装 V2.2.1 前,可先把**实时刷新延迟**设为**关闭**,把**刷新间隔**设为 + **300–900 秒**,并视需要关闭**内容分析**。只关闭“仪表盘自动刷新”并不会 + 停止状态栏所需的日志解析。 +- 若 V2.2.1 仍持续高占用,请运行 **Show Diagnostic Logs**,只把匿名的 + `refresh:` 行附到 issue #70;其中只有计数和耗时,不含提示词、路径、 + session ID、凭证或原始日志行。 +``` + +`README-zh-TW.md` 使用: + +```md +**大量歷史資料下 CPU 佔用高或重新整理緩慢(包括 Linux)** +- V2.2.1 移除了 active 狀態下隱藏的 8 秒輪詢覆蓋,並限制首時間戳掃描。 + 安裝 V2.2.1 前,可先把**即時重新整理延遲**設為**關閉**,把**重新整理間隔** + 設為 **300–900 秒**,並視需要關閉**內容分析**。只關閉儀表板自動重新整理 + 並不會停止狀態列所需的日誌解析。 +- 若 V2.2.1 仍持續高佔用,請執行 **Show Diagnostic Logs**,只把匿名的 + `refresh:` 行附到 issue #70;其中只有計數與耗時,不含提示詞、路徑、 + session ID、憑證或原始日誌行。 +``` + +`README-ja.md` 使用: + +```md +**履歴が大きい環境で CPU 使用率が高い/更新が遅い(Linux を含む)** +- V2.2.1 では active 時に隠れて 8 秒へ短縮されるポーリングを廃止し、最初の + timestamp 読み取りを制限します。導入前は **Live refresh delay** を **Off**、 + **Refresh interval** を **300–900 秒**にし、必要なら **Content analysis** も + Off にしてください。Dashboard auto-refresh だけを Off にしても、ステータス + バー用の解析は止まりません。 +- V2.2.1 でも高負荷が続く場合は **Show Diagnostic Logs** の匿名 `refresh:` 行だけを + issue #70 に添付してください。prompt、path、session ID、credential、raw log は含みません。 +``` + +`README-ko.md` 使用: + +```md +**큰 기록에서 CPU 사용률이 높거나 새로 고침이 느림(Linux 포함)** +- V2.2.1은 active 상태에서 숨겨진 8초 폴링 강제를 제거하고 첫 timestamp 읽기를 + 제한합니다. 설치 전에는 **Live refresh delay**를 **Off**, **Refresh interval**을 + **300–900초**로 설정하고 필요하면 **Content analysis**도 끄세요. Dashboard + auto-refresh만 끄면 상태 표시줄용 로그 파싱은 계속됩니다. +- V2.2.1에서도 높은 사용률이 계속되면 **Show Diagnostic Logs**의 익명 `refresh:` + 줄만 issue #70에 첨부하세요. prompt, path, session ID, credential, raw log는 포함되지 않습니다. +``` + +- [ ] **Step 3: 在既有 V2.2.1 标题下追加准确、不夸大的条目** + +前置 hygiene 计划已经创建唯一的 `## [2.2.1] — Unreleased`。不得再插入第二个版本标题;在该标题下保留既有 `### Changed`,再追加以下 `### Fixed`、`### Diagnostics` 和 gate 说明: + +```md +### Fixed +- **High-CPU refresh mitigation (#70)** — polling now always honors the + configured 30–3600 second `refreshInterval`; file watching is quiet-debounce + only and adds 60/120/300-second choices. First-timestamp reads stop after the + first valid timestamp and run with at most eight readers. + +### Diagnostics +- **Anonymous refresh timings** — Show Diagnostic Logs now reports trigger, + file/change/reuse/removal counts, bytes, parsed lines, watcher/coalescing + counts, and manifest/read-parse/aggregate-render/total timings. It never logs + prompts, paths, session IDs, credentials, or raw JSONL lines. + +> This is a mitigation pending Linux prerelease validation. Issue #70 remains +> open until the reporter confirms the result; the per-file incremental index +> is tracked separately. +``` + +Run: `test "$(rg -c '^## \[2\.2\.1\] — Unreleased$' CHANGELOG.md)" -eq 1` + +Expected: exit 0;V2.2.1 标题恰好一个,已发布的 V2.2.0 日期保持 `2026-07-07`。 + +- [ ] **Step 4: 运行六语言检查、完整测试和 package 内容检查** + +Run: + +```bash +for file in README.md README-en.md README-zh-TW.md README-zh-CN.md README-ja.md README-ko.md; do + rg -q "300|300–900" "$file" || exit 1 + rg -q "#70" "$file" || exit 1 +done +CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node +"$CCU_NODE" node_modules/typescript/bin/tsc -p ./ +"$CCU_NODE" --test out/test/*.test.js +``` + +Expected: README loop exit 0;compile 与完整测试 exit 0,0 failures(基线 114 tests,加上本计划新增测试)。普通开发机/CI 可用等价的 `npm test`。 + +Run: `CCU_NODE=/Users/carl/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node; "$CCU_NODE" node_modules/@vscode/vsce/vsce ls --no-dependencies` + +Expected: exit 0;输出不含 `.github/`、`src/`、`out/test/`、`docs/`、`CLAUDE.md`、`AGENTS.md`、`.env`、`*.map`。 + +Linux prerelease 验证使用固定步骤,不上传原始日志: + +```text +1. 安装 V2.2.1 prerelease VSIX,重载 Extension Host。 +2. Live refresh delay 保持默认 2s,Refresh interval 分别验证 60s 与 300s。 +3. 连续运行 Claude Code 10 分钟,并同时记录 extension-host CPU。 +4. 运行 “Claude Code Usage: Show Diagnostic Logs”。 +5. 只复制 refresh: 行到 issue #70;确认行内没有 path、prompt、session ID 或 JSONL。 +6. 若仍高 CPU,采集 Extension Host CPU profile,并保持 #70 开放。 +``` + +- [ ] **Step 5: 提交文档并记录 mitigation gate** + +```bash +git add CHANGELOG.md README.md README-en.md README-zh-TW.md README-zh-CN.md README-ja.md README-ko.md +git commit -m "docs: document v2.2.1 CPU mitigation" +``` + +## 完成定义 + +- `refreshInterval=300` 时,无论 watcher 最近是否活动,poll 都恰好等待 300 秒。 +- watcher 事件只重置 quiet timer;60/120/300 秒选项可选,默认仍是 2 秒。 +- 一个实际刷新最多产生一个 pending follow-up;并发触发不会并行全盘读取。 +- manifest 在一次刷新中只扫描一次,并复用于 loader。 +- 只有 `ENOENT` 可作为扫描期消失竞态处理;`EACCES`/`EPERM`/`EIO`/未知 scanner error 与任何 loader failure 都保留旧 snapshot,下一 trigger 重试。 +- 为排序读取首时间戳时,最多 8 个 reader 并发,并在首个合法 timestamp 后停止。 +- 诊断只含枚举 trigger、数字计数与耗时;敏感字段不可进入类型或 formatter。 +- 六份 README 与 CHANGELOG 明确这是 #70 的 mitigation;Linux 验证前不关闭 issue。 +- `npm test`(或上述 bundled-node 等价命令)和本地锁定的 `@vscode/vsce ls` release gate 全部通过。 diff --git a/docs/superpowers/specs/2026-07-17-codex-provider-foundation-design.md b/docs/superpowers/specs/2026-07-17-codex-provider-foundation-design.md new file mode 100644 index 00000000..acc8ca6c --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-codex-provider-foundation-design.md @@ -0,0 +1,198 @@ +# Codex Provider Foundation Design + +**Date:** 2026-07-17 +**Status:** Approved by the maintainer on 2026-07-17; implementation starts after v2.2.1 +**Reference:** [CodexBar](https://github.com/steipete/CodexBar) +**Chinese review copy:** [简体中文版](2026-07-17-codex-provider-foundation-design.zh-CN.md) + +## Objective + +Add Codex as a read-only, explicitly separated usage source without weakening +Claude accuracy or implying unsupported cost/quota precision. The architecture +must support future providers while keeping provider-specific semantics inside +adapters. + +## Product decision + +The existing extension remains Claude Code Usage during the beta. Internally it +gains a provider-neutral core and a Codex adapter behind an experimental flag. +The dashboard exposes Claude, Codex, and Compare views. A later adoption review +decides between a unified product name and two thin Marketplace extensions +sharing one core. + +## Architecture + +```text +ClaudeLogAdapter ─┐ + ├─ NormalizedUsageEvent ─ Aggregator ─ Claude | Codex | Compare +CodexAdapter ─────┘ + +ClaudeLimitSource ─┐ + ├─ ProviderLimitSnapshot ─ provider-specific quota UI +CodexLimitSource ──┘ +``` + +Historical token facts, point-in-time quota snapshots, and monetary estimates +are different contracts. They are not combined into one large snapshot. + +```ts +interface NormalizedUsageEvent { + provider: 'claude' | 'codex'; + sourceKind: 'local-jsonl' | 'otel' | 'cli-rpc' | 'oauth'; + schemaVariant: string; + timestamp: number; + sessionId: string; + parentSessionId?: string; + projectPath?: string; + model?: string; + tokens: { + inputTotal: number; + uncachedInput?: number; + cacheRead?: number; + cacheWrite?: number; + outputTotal: number; + reasoningOutput?: number; + }; + cost?: { + usd: number; + basis: 'reported' | 'api-equivalent-estimate'; + pricingVersion: string; + }; + confidence: 'exact' | 'estimated' | 'partial' | 'unknown'; + qualityFlags: string[]; +} +``` + +`ProviderLimitSnapshot` stores observation time, source, windows, reset times, +and confidence. Quota values never participate in cross-device or +cross-provider summation. + +## Provider semantics + +- Claude input, cache-read, and cache-write buckets are mutually exclusive and + additive. +- Codex `cached_input` is contained within input, and reasoning output is + contained within output. They are never added twice. +- Repeated Codex cumulative counters use lineage/high-water logic rather than + line-by-line summation. +- Fork and subagent sessions preserve parent baseline and parent identifiers. +- Missing parents and unknown schema variants produce quality flags; the UI + fails visibly to partial/unknown rather than silently inventing precision. +- Source product is identified by adapter/path/schema, not by model name. + +## Codex ingestion stages + +### Experimental local adapter + +- Discover versioned local session/rollout files under `CODEX_HOME` without + reading `auth.json`, internal account databases, or browser state. +- Maintain fixtures for CLI, IDE/Desktop, resume, archive, fork/subagent, + compaction, truncation, and interleaved counters. +- Treat the local rollout format as an unstable interface with schema guards + and diagnostics. +- Use the v2.2.1 per-file index and a single-flight scan executor. + +### Optional supported telemetry path + +OpenAI's opt-in OTel output is a future advanced source. It remains disabled by +default, keeps prompt logging off, and requires an explicit local collector or +bridge. It does not replace the zero-configuration local beta until the user +experience is practical. + +## UI and aggregation + +Compare may aggregate only metrics with compatible meaning: + +- total tokens; +- output tokens; +- sessions; +- active time. + +Provider-specific views retain: + +- cache composition and efficiency; +- quota windows; +- monetary cost or API-equivalent estimates; +- message/turn counts; +- source-specific workflow diagnostics. + +Subscription usage never appears as actual dollar spend. Unknown model prices +remain unavailable instead of falling back to a guessed family price. + +## CodexBar lessons adopted + +CodexBar is used as an architectural reference, not copied wholesale: + +- provider descriptors and ordered fetch strategies; +- per-source outcomes and diagnostics; +- per-file `mtime + size + parsed contribution` caching; +- single-flight/coalesced expensive scans; +- separate local token/cost and remote quota pipelines; +- explicit confidence and quality flags; +- fork/interleaved-counter fixtures. + +The concrete references are CodexBar's +[provider authoring guide](https://github.com/steipete/CodexBar/blob/main/docs/provider.md), +[refresh loop](https://github.com/steipete/CodexBar/blob/main/docs/refresh-loop.md), +and its large-corpus performance investigation in +[issue #1392](https://github.com/steipete/CodexBar/issues/1392). The latter is +also a warning not to guess the final hotspot: metadata validation, rather +than JSON parsing, dominated one of its largest profiles. + +Its browser-cookie scraping, private backend calls, direct auth-file handling, +large unified snapshot, and implicit subscription-cost assumptions are not +adopted. Any later substantive code port must preserve CodexBar's MIT notice. + +## Opt-in cross-device sync + +Cross-device sync is scheduled only after the Codex beta and normalized schema +are stable, provisionally v2.4.x. + +- Use VS Code's built-in GitHub authentication provider for identity; do not + embed an OAuth client secret in the extension. +- GitHub authentication and storage are separate. A dedicated sync service is + preferred; private Gist is at most an advanced experiment, not the default + database. +- Sync only aggregates keyed by device, provider, day, and model. +- Never upload prompts, responses, raw logs, paths, credentials, raw session + IDs, or local usernames. +- Use idempotent upsert with `schemaVersion`, pseudonymous device ID, revision, + and deletion tombstones. +- Quota sync stores only the latest observation and never sums devices. +- Consent is off by default and includes a first-upload preview, data export, + remote deletion, device revocation, and an immediate disable switch. +- Retention, encryption, account deletion, endpoint ownership, incident + response, and privacy copy are release gates, not post-launch follow-ups. + +## Version sequence + +1. v2.2.1: stabilize scanning and repository/process safety. +2. v2.3: introduce provider contracts and refactor the Claude adapter without + changing results. +3. v2.3.x: ship the read-only Codex beta and Compare view. +4. v2.4: ship a small provider-neutral local achievement set. +5. v2.4.x: preview opt-in GitHub-authenticated aggregate sync after privacy and + deletion controls pass review. +6. v2.5+: consider telemetry, dynamic pricing, and explicit safe writes as + separate projects. + +## Acceptance criteria for Codex beta + +- Fixture coverage across every supported Codex surface and lifecycle event. +- Incremental and cold scans produce identical normalized totals. +- Provider totals match their source-native totals or visibly degrade with a + quality flag. +- No duplicate cached-input or reasoning-output counting. +- No network request when sync, telemetry, and remote quota are off. +- No credential file access. +- Compare view never sums cost or quota. +- Diagnostic export contains no prompt, raw path, or raw session identifier. + +## Non-goals + +- Immediate product rename; +- feature parity for unsupported Codex quota or subscription cost; +- automatic reading of OpenAI credentials; +- raw-log cloud sync; +- GitHub Gist as the production sync database; +- achievements before provider semantics stabilize. diff --git a/docs/superpowers/specs/2026-07-17-codex-provider-foundation-design.zh-CN.md b/docs/superpowers/specs/2026-07-17-codex-provider-foundation-design.zh-CN.md new file mode 100644 index 00000000..74cf4ba3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-codex-provider-foundation-design.zh-CN.md @@ -0,0 +1,157 @@ +# Codex Provider 基础设计 + +**日期:** 2026-07-17 +**状态:** 维护者已于 2026-07-17 批准;v2.2.1 之后开始实施 +**参考:** [CodexBar](https://github.com/steipete/CodexBar) +**英文原版:** [English](2026-07-17-codex-provider-foundation-design.md) + +## 目标 + +将 Codex 作为只读且显式分离的用量来源加入系统,同时不削弱 Claude 数据的准确性,也不暗示系统能够提供尚未支持的成本/配额精度。架构必须能够支持未来的 provider,并把 provider 特有语义封装在 adapter 内部。 + +## 产品决策 + +beta 期间,现有扩展仍保持 Claude Code Usage 的产品名称。内部将获得 provider-neutral core,并在实验性 flag 后加入 Codex adapter。dashboard 提供 Claude、Codex 和 Compare 三种视图。后续 adoption review 再决定是采用统一产品名称,还是发布两个共享同一 core 的轻量 Marketplace 扩展。 + +## 架构 + +```text +ClaudeLogAdapter ─┐ + ├─ NormalizedUsageEvent ─ Aggregator ─ Claude | Codex | Compare +CodexAdapter ─────┘ + +ClaudeLimitSource ─┐ + ├─ ProviderLimitSnapshot ─ provider-specific quota UI +CodexLimitSource ──┘ +``` + +历史 token 事实、时间点配额快照和货币估算属于不同契约。它们不会合并为一个大型 snapshot。 + +```ts +interface NormalizedUsageEvent { + provider: 'claude' | 'codex'; + sourceKind: 'local-jsonl' | 'otel' | 'cli-rpc' | 'oauth'; + schemaVariant: string; + timestamp: number; + sessionId: string; + parentSessionId?: string; + projectPath?: string; + model?: string; + tokens: { + inputTotal: number; + uncachedInput?: number; + cacheRead?: number; + cacheWrite?: number; + outputTotal: number; + reasoningOutput?: number; + }; + cost?: { + usd: number; + basis: 'reported' | 'api-equivalent-estimate'; + pricingVersion: string; + }; + confidence: 'exact' | 'estimated' | 'partial' | 'unknown'; + qualityFlags: string[]; +} +``` + +`ProviderLimitSnapshot` 存储观测时间、来源、窗口、重置时间和置信度。配额值绝不参与跨设备或跨 provider 求和。 + +## Provider 语义 + +- Claude 的 input、cache-read 和 cache-write bucket 互斥且可相加。 +- Codex 的 `cached_input` 包含在 input 内,reasoning output 包含在 output 内。绝不重复相加。 +- 重复出现的 Codex 累计计数器使用 lineage/high-water 逻辑,而不是逐行求和。 +- fork 和 subagent session 保留 parent baseline 与 parent identifier。 +- 缺失 parent 和未知 schema variant 会产生 quality flag;UI 明确降级为 partial/unknown,而不是静默虚构精度。 +- source product 通过 adapter/path/schema 识别,而不是通过 model name 识别。 + +## Codex 摄取阶段 + +### 实验性本地 adapter + +- 在 `CODEX_HOME` 下发现带版本的本地 session/rollout 文件,但不读取 `auth.json`、内部账户数据库或浏览器状态。 +- 为 CLI、IDE/Desktop、resume、archive、fork/subagent、compaction、truncation 和 interleaved counter 维护 fixture。 +- 把本地 rollout 格式视为不稳定接口,并设置 schema guard 和诊断。 +- 使用 v2.2.1 的逐文件索引以及 single-flight scan executor。 + +### 可选的受支持遥测路径 + +OpenAI 的 opt-in OTel 输出是未来的高级数据源。它默认保持禁用、保持关闭 prompt logging,并要求用户明确配置本地 collector 或 bridge。在用户体验达到实用水平之前,它不会取代零配置的本地 beta。 + +## UI 与聚合 + +Compare 只能聚合语义兼容的指标: + +- total tokens; +- output tokens; +- sessions; +- active time。 + +provider 特有视图保留: + +- cache composition 与 efficiency; +- quota windows; +- monetary cost 或 API-equivalent estimates; +- message/turn counts; +- source-specific workflow diagnostics。 + +订阅用量绝不显示为实际美元支出。未知 model 的价格显示为不可用,而不是回退到猜测的 family price。 + +## 采用的 CodexBar 经验 + +CodexBar 作为架构参考使用,不会被整体复制: + +- provider descriptor 与有序 fetch strategy; +- 每个 source 的 outcome 与 diagnostics; +- 逐文件 `mtime + size + parsed contribution` 缓存; +- single-flight/coalesced expensive scan; +- 分离的本地 token/cost pipeline 与远程 quota pipeline; +- 显式 confidence 与 quality flag; +- fork/interleaved-counter fixture。 + +具体参考包括 CodexBar 的 [provider authoring guide](https://github.com/steipete/CodexBar/blob/main/docs/provider.md)、[refresh loop](https://github.com/steipete/CodexBar/blob/main/docs/refresh-loop.md),以及 [issue #1392](https://github.com/steipete/CodexBar/issues/1392) 中的大型数据集性能调查。后者也提醒我们不能臆测最终热点:在其最大规模的 profile 之一中,占主导的是 metadata validation,而不是 JSON parsing。 + +不采用其中的 browser-cookie scraping、private backend call、直接处理 auth-file、大型统一 snapshot,以及隐含的 subscription-cost assumption。任何后续实质性的代码移植都必须保留 CodexBar 的 MIT notice。 + +## Opt-in 跨设备同步 + +跨设备同步只安排在 Codex beta 和 normalized schema 稳定之后,暂定为 v2.4.x。 + +- 使用 VS Code 内置的 GitHub authentication provider 进行身份认证;不在扩展中嵌入 OAuth client secret。 +- GitHub authentication 与 storage 相互分离。优先采用专用 sync service;private Gist 最多只作为高级实验,不作为默认数据库。 +- 只同步以 device、provider、day 和 model 为键的聚合数据。 +- 绝不上传 prompt、response、raw log、path、credential、raw session ID 或本地 username。 +- 使用带有 `schemaVersion`、pseudonymous device ID、revision 和 deletion tombstone 的幂等 upsert。 +- quota sync 只存储最新观测,绝不跨设备求和。 +- consent 默认关闭,并提供首次上传预览、数据导出、远程删除、设备撤销以及即时禁用开关。 +- retention、encryption、account deletion、endpoint ownership、incident response 和 privacy copy 是发布门槛,不是发布后的补做事项。 + +## 版本顺序 + +1. v2.2.1:稳定扫描机制与仓库/流程安全。 +2. v2.3:引入 provider contract,并在不改变结果的前提下重构 Claude adapter。 +3. v2.3.x:发布只读 Codex beta 和 Compare 视图。 +4. v2.4:发布一组小型、provider-neutral 的本地成就。 +5. v2.4.x:在 privacy 和 deletion control 通过审阅后,预览 opt-in、经 GitHub 认证的聚合同步。 +6. v2.5+:将 telemetry、dynamic pricing 和显式安全写入分别作为独立项目评估。 + +## Codex beta 验收标准 + +- fixture 覆盖每个受支持的 Codex surface 和 lifecycle event。 +- 增量扫描与冷扫描产生一致的 normalized total。 +- provider total 与其 source-native total 一致,或通过 quality flag 明确降级。 +- 不重复计算 cached-input 或 reasoning-output。 +- 当 sync、telemetry 和 remote quota 均关闭时,不发起网络请求。 +- 不访问 credential file。 +- Compare 视图绝不合计 cost 或 quota。 +- 诊断导出不包含 prompt、raw path 或 raw session identifier。 + +## 非目标 + +- 立即更改产品名称; +- 为不受支持的 Codex quota 或 subscription cost 提供功能对等; +- 自动读取 OpenAI credential; +- 将 raw log 同步到云端; +- 将 GitHub Gist 作为生产同步数据库; +- 在 provider 语义稳定前实现成就功能。 diff --git a/docs/superpowers/specs/2026-07-17-v2.2.1-stabilization-design.md b/docs/superpowers/specs/2026-07-17-v2.2.1-stabilization-design.md new file mode 100644 index 00000000..8710c4d1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-v2.2.1-stabilization-design.md @@ -0,0 +1,213 @@ +# V2.2.1 Stabilization Design + +**Date:** 2026-07-17 +**Status:** Approved by the maintainer on 2026-07-17 +**Target:** `codex/v2.2.1-stabilization`, based on `upstream/main` at `v2.2.0` +**Chinese review copy:** [简体中文版](2026-07-17-v2.2.1-stabilization-design.zh-CN.md) + +## Objective + +Ship a focused v2.2.1 that restores a trustworthy development baseline, reduces +the high-CPU refresh path reported in issue #70, lands the already-reviewed +small bug fixes, and makes Codex-assisted maintenance attribution explicit. +The release must preserve Claude usage totals and the project's local-first, +read-mostly trust model. + +## Constraints + +- Keep the product Claude-only in v2.2.1. The Codex usage adapter starts after + this release. +- Add no runtime dependency. +- Exact usage totals and estimated insights remain separate. +- Never log prompt content, absolute paths, session IDs, credentials, or raw + JSONL lines in performance diagnostics. +- New settings preserve current behavior by default. Existing users are not + silently migrated to a different refresh cadence. +- All user-facing strings go through all seven UI locales. +- README changes update the six current README files together; Indonesian is + added to that rule only if PR #76 lands. +- Any artifact that needs maintainer review—including a spec, implementation + plan, design, release checklist, policy, or substantial documentation + change—has a faithful Simplified-Chinese companion or review note, and the + Chinese link is presented first. +- Every behavior change follows a red-green TDD cycle and the full compiled + `node:test` suite must pass before release. + +## Baseline and evidence + +The release baseline is the clean v2.2.0 tree, not the old local feature +branch. On that baseline, compile succeeds and 114 tests pass. + +The v2.2.0 refresh path has three independently verified costs: + +1. A JSONL watcher event marks the extension active for 60 seconds. During + that window, polling is forced to 8 seconds and ignores the configured + 30–3600 second `refreshInterval`. +2. Every poll recursively enumerates and stats all JSONL files. When any file + changed, the loader enumerates again, reads every file once to discover its + earliest timestamp, and reads every file again to parse usage. +3. The timestamp pass uses unbounded `Promise.all`, creating avoidable decode, + allocation, and garbage-collection pressure. + +The maintainer's current corpus has 389 JSONL files totalling about 532 MiB. +A changing refresh therefore requests roughly 1.04 GiB of logical JSONL reads. +This proves that the path is hazardous, but it does not prove that it is the +only hotspot on the Linux reporter's machine; the issue still needs a CPU +profile and prerelease validation. + +## Release structure + +V2.2.1 is delivered as focused, independently reviewable changes. + +### Change 0: Codex takeover and packaging safety + +- Replace the inaccurate root `AGENTS.md` with instructions derived from the + v2.2 architecture and contribution rules. +- Keep the product identity as Claude Code Usage; document Codex as a + development tool, not a completed provider integration. +- Restore the v2.2 `.gitignore` and `.vscodeignore` protections for private + documents, local guidance, secrets, tests, and GitHub workflow sources. +- Add line-ending and file-mode guidance so a future add does not recreate the + current CRLF/executable-bit contamination. +- Record that push, PR, merge, and release operations require maintainer + approval. + +### Change A: Low-risk CPU mitigation and diagnostics + +Polling and watch semantics are separated: + +- `refreshInterval` always controls fallback polling. Active usage no longer + silently replaces it with an 8-second interval. +- `fileWatchSeconds` means quiet debounce only: refresh after no JSONL write has + arrived for the selected duration. +- Add 60, 120, and 300 second live-refresh choices. Keep the existing 2-second + default to avoid an unannounced behavior migration. +- Continuous writes coalesce into one pending refresh rather than creating a + second immediate full scan after every in-flight scan. +- Manual refresh remains an explicit full reconciliation. + +The diagnostic log gains anonymous stage measurements: + +- trigger: startup, watch, poll, focus, settings, or manual; +- files discovered, changed, reused, and removed; +- bytes read and lines parsed; +- manifest, read/parse, aggregate/render, and total duration; +- watcher event and coalescing counts. + +`getEarliestTimestamp` reads only enough of the file to find the first valid +timestamp and uses at most eight concurrent readers. It must never read the +entire corpus solely to sort files. + +Until v2.2.1 is installed, the documented workaround for #70 is: + +1. Set Live refresh delay to Off. +2. Set Refresh interval to 300–900 seconds. +3. Optionally turn Content analysis off. + +Turning Dashboard auto-refresh off alone is not advertised as a CPU fix, +because status-bar parsing still runs. + +### Change B: In-memory per-file usage index + +The root fix is an in-memory `ClaudeUsageIndex` owned by the extension. It uses +a manifest entry containing path, size, modification time, device, and inode +when available. + +- Cold start reads each file once. +- An unchanged file reuses its parsed contribution. +- A changed or new file is re-read in full in v2.2.1. Byte-offset tail parsing + is deliberately deferred until the simpler cache is proven correct. +- Deleted files remove their previous contribution. +- Truncation, inode change, or atomic replacement invalidates the file entry. +- Watch filenames are hints only; manifest reconciliation is authoritative. +- Cache data stays in memory and is never persisted. +- Content-analysis window, parsing contract, and attribution-rule changes have + explicit cache-version invalidators. +- Startup, manual refresh, and a 30-minute periodic reconciliation recover from + dropped watcher events. + +The index produces the same normalized records as a cold full load. Cross-file +deduplication, session titles, user-message counting, workflow/subagent +attribution, and content-analysis calibration remain global aggregation steps. + +If Change B does not pass cold-versus-incremental equality tests in time, +v2.2.1 may ship Change A as a mitigation. In that case issue #70 remains open +and the release notes must not claim that the root cause is resolved. + +### Change C: Existing issue and PR queue + +The merge/review order is: + +1. PR #71 after UTC/month-first regression tests and a v2.2.1 CHANGELOG entry; + then close issue #54. +2. Reduce PR #72 to the bot-language change only. +3. Approve external CI for PR #77 and merge the timezone addition. +4. Fix PR #75's weekly-reset clock semantics and CHANGELOG placement, then + close issue #74. +5. Review PR #76 with a native Indonesian reviewer and i18n key-parity tests. +6. Split PR #73 into release-documentation and session-UX work. The 1000-row + session change does not enter v2.2.1 before #70 is validated. + +## Codex attribution and automation boundary + +Codex-authored GitHub text ends with: + +```md +--- +🤖 Generated with [OpenAI Codex](https://developers.openai.com/codex/) +``` + +An unreviewed automated first pass uses the stronger wording: + +```md +--- +🤖 Generated by [OpenAI Codex](https://developers.openai.com/codex/) as an automated first pass — not a maintainer decision. +``` + +Attribution must describe the actual generator. The current first-pass script +uses an Anthropic-compatible API with DeepSeek as its default, so it must not +be relabelled Codex without changing the runner. The wrapper, not the model +prompt, deterministically adds exactly one trusted footer. + +V2.2.1 documents the distinction between: + +- maintainer-reviewed Codex-assisted comments; +- controlled, comment-only first-pass automation; and +- the privileged maintainer-only mention agent that can write repository + content. + +Codex/OpenAI receives development-tool credit in all README language versions. +It is not added to Release Drafter's human contributor list and no fabricated +`Co-Authored-By` identity is used. + +## Verification and release gate + +Automated tests cover: + +- cadence always respecting `refreshInterval`; +- watcher burst coalescing and the new debounce choices; +- bounded first-timestamp reads; +- cold load, unchanged refresh, one changed file, new/delete/truncate/replace; +- an incomplete final JSONL line completed on the next refresh; +- global placeholder/real-record deduplication and late title updates; +- workflow and subagent attribution equality; +- content-analysis invalidation; +- deep equality between incremental output and a fresh cold load; +- deterministic, provider-correct GitHub attribution footers. + +Release evidence requires: + +- TypeScript compile exit 0; +- full `node:test` suite with zero failures; +- a packaged VSIX whose file list excludes private docs, local instructions, + workflow source, and test output; +- F5/installed-VSIX smoke tests on macOS and Linux when available; +- a prerelease VSIX offered to the #70 reporter with diagnostic instructions. + +## Non-goals + +- Codex usage ingestion or multi-provider UI; +- achievements, telemetry, cloud sync, or automatic memory writes; +- PR #73's 1000-row sessions rendering; +- persistent caching of prompt-bearing data; +- claiming Linux `fs.watch` is the root cause without a profile. diff --git a/docs/superpowers/specs/2026-07-17-v2.2.1-stabilization-design.zh-CN.md b/docs/superpowers/specs/2026-07-17-v2.2.1-stabilization-design.zh-CN.md new file mode 100644 index 00000000..fd6fc68e --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-v2.2.1-stabilization-design.zh-CN.md @@ -0,0 +1,160 @@ +# V2.2.1 稳定化设计 + +**日期:** 2026-07-17 +**状态:** 维护者已于 2026-07-17 批准 +**目标:** `codex/v2.2.1-stabilization`,基于 `upstream/main` 的 `v2.2.0` +**英文原版:** [English](2026-07-17-v2.2.1-stabilization-design.md) + +## 目标 + +发布一个聚焦的 v2.2.1:恢复可信的开发基线,降低 issue #70 所报告的高 CPU 刷新路径开销,合入已经审阅的小型缺陷修复,并明确标注由 Codex 辅助完成的维护工作。此版本必须保持 Claude 用量总计不变,并维持项目本地优先、以读取为主的信任模型。 + +## 约束 + +- v2.2.1 中产品仍仅支持 Claude。Codex 用量适配器在此版本之后启动开发。 +- 不增加运行时依赖。 +- 精确用量总计与估算型洞察继续分开处理。 +- 性能诊断绝不记录提示词内容、绝对路径、会话 ID、凭据或原始 JSONL 行。 +- 新设置默认保持当前行为。不得在未告知的情况下将现有用户迁移到不同的刷新节奏。 +- 所有面向用户的字符串都必须覆盖全部七种 UI locale。 +- README 变更必须同时更新当前六份 README 文件;只有 PR #76 合入后,才把印度尼西亚语加入这条规则。 +- 任何需要维护者审阅的 artifact(包括 spec、implementation plan、design、release checklist、policy 或大幅文档变更)都必须提供忠实的简体中文副本或中文审阅说明,并优先展示中文链接。 +- 每项行为变更都遵循红—绿 TDD 循环;发布前必须通过完整的已编译 `node:test` 测试套件。 + +## 基线与证据 + +发布基线是干净的 v2.2.0 代码树,而不是旧的本地功能分支。在该基线上,编译成功且 114 项测试全部通过。 + +v2.2.0 的刷新路径存在三项已独立验证的开销: + +1. JSONL watcher 事件会把扩展标记为活跃 60 秒。在此期间,轮询被强制设为 8 秒,并忽略已配置的 30–3600 秒 `refreshInterval`。 +2. 每次轮询都会递归枚举所有 JSONL 文件并执行 stat。只要任意文件发生变化,loader 就会再次枚举;先读取每个文件以发现最早时间戳,再次读取每个文件以解析用量。 +3. 时间戳读取阶段使用无界 `Promise.all`,造成原本可以避免的解码、内存分配和垃圾回收压力。 + +维护者当前的数据集包含 389 个 JSONL 文件,总计约 532 MiB。因此,只要发生变化,一次刷新就会请求约 1.04 GiB 的逻辑 JSONL 读取量。这证明该路径具有风险,但不能证明它是 Linux 报告者机器上的唯一热点;该 issue 仍需 CPU profile 和预发布验证。 + +## 发布结构 + +V2.2.1 由若干聚焦且可独立审阅的变更组成。 + +### 变更 0:Codex 接手与打包安全 + +- 用根据 v2.2 架构和贡献规则整理的指引,替换根目录中不准确的 `AGENTS.md`。 +- 保持产品身份为 Claude Code Usage;将 Codex 记录为开发工具,而不是已经完成的 provider 集成。 +- 恢复 v2.2 的 `.gitignore` 和 `.vscodeignore` 保护,覆盖私有文档、本地指引、密钥、测试以及 GitHub workflow 源文件。 +- 增加换行符和文件模式指引,避免未来执行 add 时重现当前的 CRLF/可执行位污染。 +- 记录 push、PR、merge 和 release 操作都需要维护者批准。 + +### 变更 A:低风险 CPU 缓解与诊断 + +将轮询语义与 watch 语义分离: + +- `refreshInterval` 始终控制兜底轮询。活跃使用状态不再静默地把它替换为 8 秒间隔。 +- `fileWatchSeconds` 只表示静默 debounce:在所选时长内未再发生 JSONL 写入后才刷新。 +- 增加 60、120 和 300 秒的实时刷新选项。保留现有 2 秒默认值,避免未经说明的行为迁移。 +- 连续写入合并为一次待执行刷新,而不是在每次进行中的扫描结束后再立即触发第二次完整扫描。 +- 手动刷新仍是显式的完整 reconciliation。 + +诊断日志增加匿名阶段测量: + +- 触发源:startup、watch、poll、focus、settings 或 manual; +- 已发现、已变更、已复用和已删除的文件数量; +- 读取的字节数与解析的行数; +- manifest、read/parse、aggregate/render 以及总耗时; +- watcher 事件数和合并次数。 + +`getEarliestTimestamp` 只读取足以找到首个有效时间戳的文件内容,并且最多使用八个并发 reader。绝不能仅为了排序文件而读取整个数据集。 + +在安装 v2.2.1 之前,#70 的文档化临时解决方案为: + +1. 将 Live refresh delay 设置为 Off。 +2. 将 Refresh interval 设置为 300–900 秒。 +3. 可选:关闭 Content analysis。 + +不应仅把关闭 Dashboard auto-refresh 宣传为 CPU 修复,因为状态栏解析仍会运行。 + +### 变更 B:内存中的逐文件用量索引 + +根本修复方案是由扩展持有的内存内 `ClaudeUsageIndex`。它使用包含 path、size、modification time,以及在可用时包含 device 和 inode 的 manifest entry。 + +- 冷启动时每个文件只读取一次。 +- 未发生变化的文件复用其已解析贡献值。 +- 在 v2.2.1 中,发生变化或新建的文件会被完整重读。基于字节偏移量的尾部解析有意推迟,待这个更简单的缓存经验证正确后再实施。 +- 文件被删除时,移除其先前的贡献值。 +- 文件截断、inode 变化或原子替换会使相应文件条目失效。 +- watch 文件名只作为提示;manifest reconciliation 才是权威来源。 +- 缓存数据只保留在内存中,绝不持久化。 +- 内容分析窗口、解析契约和归属规则的变化都有显式的 cache-version invalidator。 +- 启动、手动刷新,以及每 30 分钟一次的周期性 reconciliation 用于从丢失的 watcher 事件中恢复。 + +该索引产生的 normalized records 必须与冷启动完整加载一致。跨文件去重、会话标题、用户消息计数、workflow/subagent 归属以及内容分析校准仍作为全局聚合步骤执行。 + +如果变更 B 无法及时通过 cold-versus-incremental equality tests,v2.2.1 可以仅发布变更 A 作为缓解措施。此时 issue #70 必须保持打开,release notes 也不得声称根因已解决。 + +### 变更 C:现有 issue 与 PR 队列 + +合入/审阅顺序为: + +1. PR #71:补齐 UTC/月份优先回归测试以及一条 v2.2.1 CHANGELOG 记录后合入;随后关闭 issue #54。 +2. 将 PR #72 缩减为仅包含 bot 语言变更。 +3. 批准 PR #77 的外部 CI,并合入 timezone 新增内容。 +4. 修复 PR #75 的每周重置时钟语义以及 CHANGELOG 放置位置,然后关闭 issue #74。 +5. 由印度尼西亚语母语审阅者审阅 PR #76,并增加 i18n key-parity tests。 +6. 将 PR #73 拆分为发布文档工作与 session UX 工作。在 #70 验证完成之前,1000 行 session 变更不进入 v2.2.1。 + +## Codex 署名与自动化边界 + +由 Codex 撰写的 GitHub 文本以以下内容结尾: + +```md +--- +🤖 Generated with [OpenAI Codex](https://developers.openai.com/codex/) +``` + +未经审阅的自动化 first pass 使用语气更强的以下表述: + +```md +--- +🤖 Generated by [OpenAI Codex](https://developers.openai.com/codex/) as an automated first pass — not a maintainer decision. +``` + +署名必须描述实际生成器。当前 first-pass 脚本使用兼容 Anthropic 的 API,并以 DeepSeek 为默认值,因此在不更换 runner 的情况下不得把它重新标记为 Codex。可信 footer 必须由 wrapper 而不是模型 prompt 以确定性方式添加,并确保恰好出现一次。 + +V2.2.1 记录以下三者的区别: + +- 经维护者审阅、由 Codex 辅助完成的评论; +- 受控且只能发表评论的 first-pass 自动化; +- 仅限维护者使用、可写入仓库内容的高权限 mention agent。 + +Codex/OpenAI 在所有 README 语言版本中获得开发工具署名。它不加入 Release Drafter 的人类贡献者名单,也不使用虚构的 `Co-Authored-By` 身份。 + +## 验证与发布门槛 + +自动化测试覆盖: + +- 刷新节奏始终遵守 `refreshInterval`; +- watcher burst 合并与新增 debounce 选项; +- 有界的首个时间戳读取; +- 冷加载、未变化刷新、单文件变化、新建/删除/截断/替换; +- 不完整的最后一行 JSONL 在下次刷新时补全; +- 全局 placeholder/真实记录去重以及延迟标题更新; +- workflow 和 subagent 归属一致性; +- 内容分析失效处理; +- 增量输出与全新冷加载之间的深度相等; +- 确定性且 provider 正确的 GitHub 署名 footer。 + +发布证据要求: + +- TypeScript 编译退出码为 0; +- 完整 `node:test` 测试套件零失败; +- 打包后的 VSIX 文件列表不包含私有文档、本地指引、workflow 源文件和测试输出; +- 在可用时,分别在 macOS 和 Linux 上完成 F5/已安装 VSIX 冒烟测试; +- 向 #70 报告者提供预发布 VSIX 以及诊断说明。 + +## 非目标 + +- Codex 用量摄取或多 provider UI; +- 成就、遥测、云同步或自动写入 memory; +- PR #73 的 1000 行 sessions 渲染; +- 对含提示词数据进行持久缓存; +- 在缺少 profile 的情况下声称 Linux `fs.watch` 是根因。 diff --git a/package.json b/package.json index 993a812d..8a4f5c6e 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "zh-CN", "ja", "ko", - "pt-BR" + "pt-BR", + "id" ], "default": "auto", "description": "Display language (auto-detection or manual selection). All other settings now live in the dashboard's ⚙ Settings tab (Show Usage Details)." @@ -125,7 +126,7 @@ "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", - "test": "npm run compile && node --test out/test/*.test.js" + "test": "npm run compile && node --test out/test/*.test.js .github/scripts/*.test.mjs" }, "devDependencies": { "@types/node": "24.0.15", diff --git a/src/adviceDemoSample.ts b/src/adviceDemoSample.ts index 40ff323c..d76ed6e7 100644 --- a/src/adviceDemoSample.ts +++ b/src/adviceDemoSample.ts @@ -371,6 +371,61 @@ Por quê: dá ao Claude uma única decisão a tomar, não uma dúzia. essa linha", ele custa ~1/15 de Sonnet 4.6 com qualidade suficiente. `; +const ID = `## Ringkasan + +Dalam 30 hari terakhir, **hasil asisten dan hasil tool mendominasi +pengeluaran Anda** (≈68% gabungan), sementara prompt Anda sendiri hanya +menyumbang ≈11%. Polanya: prompt pendek dan eksploratif seperti *"coba lihat +dulu..."* memicu banyak panggilan Read/Grep yang menggelembungkan token +keluaran. + +Tuas utamanya adalah **presisi prompt**, bukan "pakai model yang lebih kecil". + +## Penulisan ulang konkret + +### 1. Kerangka "eksplorasi dulu" + +**Sebelum:** *"Coba lihat modul auth dan kasih tahu apa yang terjadi."* + +**Sesudah:** *"Di \`src/auth/session.ts\`, \`refreshToken()\` mengembalikan 401 +sesekali di production. Baca file ini beserta pemanggil langsungnya, lalu +usulkan perbaikan. Jangan rangkum seluruh modul."* + +Kenapa: menyatakan *gejala*, menyebutkan *cakupan*, menekan output ringkasan +yang tidak diinginkan. + +### 2. Pergeseran cakupan di tengah tugas + +**Sebelum:** *"Terus bisa pastikan testnya masih lolos? Tambahkan test untuk +kasus edge yang kita bahas?"* + +**Sesudah:** *"Tambahkan **satu** test untuk kasus array kosong di +\`parseConfig\`. Jalankan hanya \`npm test -- parseConfig.test.ts\`. Jangan +tambahkan test yang tidak terkait."* + +Kenapa: membatasi perubahan, perintah test, dan area yang tersentuh. + +### 3. "Pakai penilaianmu sendiri" + +**Sebelum:** *"Refactor ini terserah menurutmu yang paling rapi."* + +**Sesudah:** *"Ekstrak logika retry dari \`processBatch\` ke helper +\`retryWithBackoff\`. Biarkan sisanya tidak berubah. Jangan ganti nama +variabel."* + +Kenapa: memberi Claude satu batas keputusan, bukan selusin. + +## Penghematan lebih kecil + +- **Proporsi cache hit 41%** — bagus. Jangan rusak dengan mengedit CLAUDE.md + di tengah sesi; edit di antara sesi saja. +- **Rata-rata konteks di akhir sesi: 89k token.** Sesi di atas ~140k jadi + jauh lebih mahal per giliran secara eksponensial — mulai sesi baru lebih awal. +- **Haiku 4.5 muncul di 0% sesi Anda.** Untuk tugas reformat / komentar / + "jelaskan baris ini" yang sepele, biayanya ~1/15 dari Sonnet 4.6 dengan + kualitas yang tetap memadai. +`; + const BODIES: Record = { 'en': EN, 'zh-CN': ZH_CN, @@ -378,7 +433,8 @@ const BODIES: Record = { 'ja': JA, 'ko': KO, 'de-DE': DE, - 'pt-BR': PT_BR + 'pt-BR': PT_BR, + 'id': ID }; /** Demo body for the user's UI language; falls back to English. */ diff --git a/src/claudeUsageFiles.ts b/src/claudeUsageFiles.ts new file mode 100644 index 00000000..0d381956 --- /dev/null +++ b/src/claudeUsageFiles.ts @@ -0,0 +1,219 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; + +export interface UsageFileFingerprint { + path: string; + size: number; + mtimeMs: number; + /** Original v2.2.0 recursive discovery position; tie ordering depends on it. */ + discoveryIndex: number; + dev?: number; + ino?: number; +} + +export interface UsageManifest { + entries: ReadonlyMap; + scannedAtMs: number; + scanDurationMs: number; +} + +export interface ManifestDelta { + changed: UsageFileFingerprint[]; + reused: UsageFileFingerprint[]; + removed: UsageFileFingerprint[]; +} + +export function isMissingPathError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'; +} + +export interface UsageFileSystem { + readdir(dir: string): Promise; + stat(filePath: string): Promise; +} + +const DEFAULT_USAGE_FILE_SYSTEM: UsageFileSystem = { + readdir: (dir) => fs.promises.readdir(dir, { withFileTypes: true }), + stat: (filePath) => fs.promises.stat(filePath), +}; + +async function walkJsonl( + dir: string, + output: string[], + io: UsageFileSystem, +): Promise { + let entries: fs.Dirent[]; + try { + entries = await io.readdir(dir); + } catch (error) { + if (isMissingPathError(error)) return; + throw error; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walkJsonl(fullPath, output, io); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + output.push(fullPath); + } + } +} + +export async function scanUsageManifest( + dataRoots: readonly string[], + io: UsageFileSystem = DEFAULT_USAGE_FILE_SYSTEM, +): Promise { + const started = performance.now(); + const paths: string[] = []; + for (const root of dataRoots) { + await walkJsonl(path.join(root, 'projects'), paths, io); + } + const entries = new Map(); + for (const [discoveryIndex, filePath] of paths.entries()) { + try { + const fileStat = await io.stat(filePath); + const hasStableIdentity = fileStat.dev > 0 && fileStat.ino > 0; + entries.set(filePath, { + path: filePath, + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + discoveryIndex, + dev: hasStableIdentity ? fileStat.dev : undefined, + ino: hasStableIdentity ? fileStat.ino : undefined, + }); + } catch (error) { + if (!isMissingPathError(error)) throw error; + } + } + return { + entries, + scannedAtMs: Date.now(), + scanDurationMs: performance.now() - started, + }; +} + +export function sameFingerprint( + a: UsageFileFingerprint, + b: UsageFileFingerprint, +): boolean { + const identityComparable = (a.dev ?? 0) > 0 && (a.ino ?? 0) > 0 && + (b.dev ?? 0) > 0 && (b.ino ?? 0) > 0; + return a.path === b.path && a.size === b.size && a.mtimeMs === b.mtimeMs && + (!identityComparable || (a.dev === b.dev && a.ino === b.ino)); +} + +export function diffUsageManifests( + previous: UsageManifest | null, + current: UsageManifest, +): ManifestDelta { + if (!previous) { + return { changed: [...current.entries.values()], reused: [], removed: [] }; + } + const changed: UsageFileFingerprint[] = []; + const reused: UsageFileFingerprint[] = []; + for (const entry of current.entries.values()) { + const old = previous.entries.get(entry.path); + (old && sameFingerprint(old, entry) ? reused : changed).push(entry); + } + const removed = [...previous.entries.values()].filter((entry) => !current.entries.has(entry.path)); + return { changed, reused, removed }; +} + +export async function mapWithConcurrency( + values: readonly T[], + limit: number, + worker: (value: T, index: number) => Promise, +): Promise { + if (!Number.isInteger(limit) || limit < 1) { + throw new RangeError('limit must be a positive integer'); + } + const output = new Array(values.length); + let next = 0; + const run = async (): Promise => { + while (next < values.length) { + const index = next++; + output[index] = await worker(values[index], index); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run)); + return output; +} + +function timestampFromLine(line: string): number | null { + if (line.trim() === '') return null; + try { + const value = JSON.parse(line) as { timestamp?: unknown }; + if (typeof value.timestamp !== 'string') return null; + const timestampMs = Date.parse(value.timestamp); + return Number.isNaN(timestampMs) ? null : timestampMs; + } catch { + return null; + } +} + +export async function readEarliestTimestamp( + filePath: string, +): Promise<{ timestampMs: number; bytesRead: number }> { + const MAX_COMPLETED_INVALID_PREFIX_BYTES = 1024 * 1024; + const stream = fs.createReadStream(filePath, { + highWaterMark: 4096, + start: 0, + }); + const decoder = new StringDecoder('utf8'); + let lineParts: string[] = []; + let bytesRead = 0; + let completedInvalidBytes = 0; + try { + for await (const chunk of stream) { + const buffer = chunk as Buffer; + bytesRead += buffer.byteLength; + const pieces = decoder.write(buffer).split('\n'); + lineParts.push(pieces.shift()!); + while (pieces.length > 0) { + const line = lineParts.join('').replace(/\r$/, ''); + lineParts = [pieces.shift()!]; + const timestampMs = timestampFromLine(line); + if (timestampMs !== null) return { timestampMs, bytesRead }; + completedInvalidBytes += Buffer.byteLength(`${line}\n`, 'utf8'); + if (completedInvalidBytes > MAX_COMPLETED_INVALID_PREFIX_BYTES) { + const error = new Error('invalid timestamp prefix limit exceeded'); + error.name = 'TimestampProbeLimitError'; + throw error; + } + } + } + const finalDecoded = decoder.end(); + if (finalDecoded !== '') lineParts.push(finalDecoded); + const pending = lineParts.join(''); + const finalTimestamp = timestampFromLine(pending); + if (finalTimestamp !== null) return { timestampMs: finalTimestamp, bytesRead }; + completedInvalidBytes += Buffer.byteLength(pending, 'utf8'); + if (completedInvalidBytes > MAX_COMPLETED_INVALID_PREFIX_BYTES) { + const error = new Error('invalid timestamp prefix limit exceeded'); + error.name = 'TimestampProbeLimitError'; + throw error; + } + return { timestampMs: 0, bytesRead }; + } catch (error) { + stream.destroy(); + throw error; + } +} + +export async function sortUsageFilesByEarliestTimestamp( + entries: readonly UsageFileFingerprint[], +): Promise<{ files: string[]; bytesRead: number }> { + const stamped = await mapWithConcurrency(entries, 8, async (entry) => ({ + file: entry.path, + discoveryIndex: entry.discoveryIndex, + ...(await readEarliestTimestamp(entry.path)), + })); + stamped.sort((a, b) => + a.timestampMs - b.timestampMs || a.discoveryIndex - b.discoveryIndex, + ); + return { + files: stamped.map((item) => item.file), + bytesRead: stamped.reduce((sum, item) => sum + item.bytesRead, 0), + }; +} diff --git a/src/dataLoader.ts b/src/dataLoader.ts index b3f982a0..23698d7e 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -10,6 +10,12 @@ import { dayKeyInZone, monthKeyInZone } from './dateKeys'; import { I18n } from './i18n'; import { DayUsage } from './heatmap'; import { ShareInput, ShareRange, rangeLabel as shareRangeLabel } from './shareCard'; +import { + scanUsageManifest, + sortUsageFilesByEarliestTimestamp, + UsageManifest, +} from './claudeUsageFiles'; +import { LoadUsageDiagnostics } from './refreshDiagnostics'; import { AttributionEntry, AttributionScope, @@ -41,33 +47,6 @@ const USER_HOME_DIR = os.homedir(); const XDG_CONFIG_DIR = process.env.XDG_CONFIG_HOME || path.join(USER_HOME_DIR, '.config'); const DEFAULT_CLAUDE_CONFIG_PATH = path.join(XDG_CONFIG_DIR, 'claude'); -// Native file search function to replace tinyglobby -async function findJsonlFiles(dir: string): Promise { - const files: string[] = []; - - async function searchRecursively(currentDir: string) { - try { - const entries = await fs.promises.readdir(currentDir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(currentDir, entry.name); - - if (entry.isDirectory()) { - await searchRecursively(fullPath); - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(fullPath); - } - } - } catch (error) { - // Ignore permission errors and continue - console.warn(`Cannot read directory ${currentDir}:`, error); - } - } - - await searchRecursively(dir); - return files; -} - // Identify usage records by structural shape only. // // Previously we dropped any record whose secondary fields had an unexpected @@ -521,27 +500,42 @@ export class ClaudeDataLoader { static async loadUsageRecords( dataDirectory?: string, - options?: { analyzeContent?: boolean; windowDays?: number; log?: (line: string) => void } - ): Promise<{ records: ClaudeUsageRecord[]; contentAnalysis: ContentAnalysis | null }> { + options?: { + analyzeContent?: boolean; + windowDays?: number; + log?: (line: string) => void; + manifest?: UsageManifest; + } + ): Promise<{ + records: ClaudeUsageRecord[]; + contentAnalysis: ContentAnalysis | null; + diagnostics: LoadUsageDiagnostics; + }> { const analyzeContent = options?.analyzeContent !== false; // default true // How many days back the content analysis (and its prompt sample) looks. // Configurable via advice.promptWindowDays; defaults to 30. const windowDays = Math.min(365, Math.max(1, Math.round(options?.windowDays ?? 30))); const windowMs = windowDays * 24 * 60 * 60 * 1000; const log = options?.log; + const readParseStarted = performance.now(); + let filesDiscovered = 0; + let filesFailed = 0; + let bytesRead = 0; + let linesParsed = 0; + const diagnostics = (): LoadUsageDiagnostics => ({ + filesDiscovered, + filesFailed, + bytesRead, + linesParsed, + readParseMs: performance.now() - readParseStarted, + }); try { const claudePaths = dataDirectory ? [dataDirectory] : this.getClaudePaths(); - const allFiles: string[] = []; - - for (const claudePath of claudePaths) { - const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); - if (fs.existsSync(claudeDir)) { - const files = await findJsonlFiles(claudeDir); - allFiles.push(...files); - } - } - - const sortedFiles = await this.sortFilesByTimestamp(allFiles); + const manifest = options?.manifest ?? await scanUsageManifest(claudePaths); + filesDiscovered = manifest.entries.size; + const sorted = await sortUsageFilesByEarliestTimestamp([...manifest.entries.values()]); + bytesRead += sorted.bytesRead; + const sortedFiles = sorted.files; // hash → records[] index. Some proxies (mimo / CC Switch) write two // records per message: a tokens=0 placeholder when streaming starts, // and the real values when the response finishes. Both records share @@ -582,6 +576,7 @@ export class ClaudeDataLoader { for (const file of sortedFiles) { try { const content = await readFile(file, 'utf-8'); + bytesRead += Buffer.byteLength(content, 'utf8'); const lines = content .trim() .split('\n') @@ -621,6 +616,7 @@ export class ClaudeDataLoader { // count (see promptDedup.ts). const recentPrompts = new Map(); for (const line of lines) { + linesParsed += 1; stats.linesScanned += 1; try { const parsed = JSON.parse(line) as unknown; @@ -795,6 +791,7 @@ export class ClaudeDataLoader { } } } catch (fileError) { + filesFailed += 1; console.warn(`Failed to read file ${file}:`, fileError); } @@ -868,10 +865,11 @@ export class ClaudeDataLoader { contentAnalysis.calibration = { realOutputTokens, realInputSideTokens }; } } - return { records, contentAnalysis }; + return { records, contentAnalysis, diagnostics: diagnostics() }; } catch (error) { + filesFailed = Math.max(filesFailed, 1); console.error('Error loading usage records:', error); - return { records: [], contentAnalysis: null }; + return { records: [], contentAnalysis: null, diagnostics: diagnostics() }; } } @@ -972,47 +970,6 @@ export class ClaudeDataLoader { return (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0); } - private static async getEarliestTimestamp(filePath: string): Promise { - try { - const content = await readFile(filePath, 'utf-8'); - const lines = content.trim().split('\n'); - - for (const line of lines) { - if (line.trim() === '') continue; - - try { - const json = JSON.parse(line) as Record; - if (typeof json.timestamp === 'string') { - const date = new Date(json.timestamp); - if (!isNaN(date.getTime())) { - return date; - } - } - } catch { - // Skip invalid lines - } - } - - return null; - } catch { - return null; - } - } - - private static async sortFilesByTimestamp(files: string[]): Promise { - const filesWithTimestamps = await Promise.all( - files.map(async (file) => { - const timestamp = await this.getEarliestTimestamp(file); - return { - file, - timestamp: timestamp || new Date(0), - }; - }) - ); - - return filesWithTimestamps.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()).map((item) => item.file); - } - static calculateUsageData(records: ClaudeUsageRecord[]): UsageData { const data: UsageData = { totalInputTokens: 0, @@ -2119,9 +2076,11 @@ export class ClaudeDataLoader { * Group records by their source session (.jsonl file) and aggregate usage per session. * Returns sessions with billable usage, sorted by most recent activity first. * @param records All loaded usage records - * @param limit Maximum number of sessions to return (default 50) + * @param limit Maximum number of sessions to return. Default 1000 — a DOM-safety + * 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 = 50): SessionUsage[] { + static getSessionBreakdown(records: ClaudeUsageRecord[], limit: number = 1000): SessionUsage[] { const recordsBySession: Record = {}; for (const record of records) { @@ -2846,24 +2805,11 @@ export class ClaudeDataLoader { */ static async getLatestModifiedTime(dataDirectory?: string): Promise { try { - const claudePaths = dataDirectory ? [dataDirectory] : this.getClaudePaths(); + const roots = dataDirectory ? [dataDirectory] : this.getClaudePaths(); + const manifest = await scanUsageManifest(roots); let latest = 0; - for (const claudePath of claudePaths) { - const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); - if (!fs.existsSync(claudeDir)) { - continue; - } - const files = await findJsonlFiles(claudeDir); - for (const file of files) { - try { - const stat = await fs.promises.stat(file); - if (stat.mtimeMs > latest) { - latest = stat.mtimeMs; - } - } catch { - // Ignore unreadable files. - } - } + for (const entry of manifest.entries.values()) { + latest = Math.max(latest, entry.mtimeMs); } return latest; } catch { diff --git a/src/extension.ts b/src/extension.ts index 8d050bf1..b0e9e980 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,6 +22,23 @@ import { buildAdviceSummary } from './adviceSummary'; import { getDemoBody } from './adviceDemoSample'; import { ClaudeApiUsageResponse, ContentAnalysis, ExtensionConfig } from './types'; import { SettingsStore } from './settings'; +import { + diffUsageManifests, + scanUsageManifest, + UsageManifest, +} from './claudeUsageFiles'; +import { + commitRefreshSnapshot, + pollIntervalMs, + QuietDebounce, + RefreshRequest, + RefreshSingleFlight, + RefreshTrigger, + reportColdRefreshFailure, + shouldCommitUsageLoad, + shouldReloadUsage, +} from './refreshPolicy'; +import { formatRefreshDiagnostic } from './refreshDiagnostics'; // One-line "what's new" per major.minor, shown once after an upgrade (see // maybeAnnounceWhatsNew) so users discover new — including opt-in — features. @@ -45,7 +62,10 @@ export class ClaudeCodeUsageExtension { private settings: SettingsStore; private refreshTimer: NodeJS.Timeout | undefined; private fileWatcher: fs.FSWatcher | undefined; - private watchDebounceTimer: NodeJS.Timeout | undefined; + private readonly watchDebounce = new QuietDebounce(); + private readonly refreshGate = new RefreshSingleFlight(); + private watcherEventsSinceRefresh = 0; + private coalescedTriggersSinceRefresh = 0; private watchedDir: string | null = null; // Watches ~/.claude/.credentials.json so an account switch is reflected // promptly instead of after a full quota TTL (#45). @@ -54,6 +74,7 @@ export class ClaudeCodeUsageExtension { private cache: { records: any[]; contentAnalysis: ContentAnalysis | null; + manifest: UsageManifest | null; lastUpdate: Date; dataDirectory: string | null; usageLimits: ClaudeApiUsageResponse | null; @@ -63,6 +84,7 @@ export class ClaudeCodeUsageExtension { } = { records: [], contentAnalysis: null, + manifest: null, lastUpdate: new Date(0), dataDirectory: null, usageLimits: null, @@ -72,19 +94,8 @@ export class ClaudeCodeUsageExtension { }; private outputChannel: vscode.OutputChannel; - // Re-entrancy guard (PR #20 by @nickearnshaw). The auto-refresh timer and - // file watcher can both fire while a slow reload is still in flight. Without - // this, reloads pile up and keep re-asserting the "Loading…" spinner. - private isRefreshing: boolean = false; - // Coalesce: a trigger that arrives mid-load sets this so we run exactly one - // more refresh after the current one finishes, instead of dropping the event - // (which starved updates during rapid ultracode/sub-agent writes). - private pendingRefresh: boolean = false; - // True when a coalesced refresh was a manual one, so the follow-up forces a full reload. - private pendingManual: boolean = false; - // Epoch ms of the last observed .jsonl change. Drives activity-aware - // refresh cadence: while Claude Code is actively writing we refresh faster - // (~15 s, quota cache 20 s); when idle we fall back to the user's interval. + // Epoch ms of the last observed .jsonl change. It only tunes quota-cache TTL; + // local polling always follows the configured refreshInterval. private lastActivityAt: number = 0; // Generation token for the self-rescheduling refresh timer. Bumped each time // startAutoRefresh runs so any older timer chain (e.g. left mid-flight by a @@ -123,7 +134,7 @@ export class ClaudeCodeUsageExtension { this.loadConfiguration(); this.loadPersistedQuota(); this.startAutoRefresh(); - this.refreshData().then(() => this.startFileWatching()); + this.refreshData(false, 'startup').then(() => this.startFileWatching()); this.startCredentialsWatching(); this.startWindowFocusRefresh(); this.maybeAnnounceWhatsNew(); @@ -180,7 +191,7 @@ export class ClaudeCodeUsageExtension { vscode.commands.registerCommand('claudeCodeUsage.refresh', () => { // Manual refresh always updates the dashboard even when // dashboardAutoRefresh is off. - this.refreshData(true); + void this.refreshData(true, 'manual'); }), vscode.commands.registerCommand('claudeCodeUsage.showDetails', () => { this.webviewProvider.show(); @@ -219,8 +230,7 @@ export class ClaudeCodeUsageExtension { const result = await fetchLatestPricing(); vscode.window.showInformationMessage(`${I18n.t.popup.pricingUpdated} (${result.updated})`); // Force a full recompute so the new prices take effect. - this.cache.lastUpdate = new Date(0); - this.refreshData(); + void this.refreshData(true, 'pricing'); } catch (error) { const message = error instanceof Error ? error.message : String(error); vscode.window.showErrorMessage(`${I18n.t.popup.pricingUpdateFailed}: ${message}`); @@ -675,7 +685,7 @@ export class ClaudeCodeUsageExtension { I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); I18n.setCompactNumbers(config.compactNumbers); I18n.setTimezone(config.timezone); - this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat); // Listen for configuration changes vscode.workspace.onDidChangeConfiguration(e => { @@ -693,7 +703,7 @@ export class ClaudeCodeUsageExtension { this.cache.usageLimitsBackoffUntil = new Date(0); this.cache.usageLimitsFailStreak = 0; this.quotaColdRetryDone = false; - this.refreshData(); + void this.refreshData(true, 'workspace'); }); } @@ -717,6 +727,7 @@ export class ClaudeCodeUsageExtension { showOpusWeekly: s.get('showOpusWeekly'), showResetInStatusBar: s.get('showResetInStatusBar'), quotaFiveHourOnly: s.get('quotaFiveHourOnly'), + resetCountdownFormat: s.get<'decimal' | 'units' | 'clock'>('resetCountdownFormat'), usageLimitTracking: s.get('usageLimitTracking'), adviceApiKey: s.get('advice.apiKey'), adviceApiUrl: s.get('advice.apiUrl'), @@ -743,14 +754,14 @@ export class ClaudeCodeUsageExtension { // /usage fetch (the full reload path), else the quota stays empty until the // next tick. 'showCost', 'showContext', 'statusBarMetric', - 'showOpusWeekly', 'quotaFiveHourOnly', 'showResetInStatusBar', + 'showOpusWeekly', 'quotaFiveHourOnly', 'showResetInStatusBar', 'resetCountdownFormat', ]); /** Dashboard Settings change — status-bar-only toggles apply in place, others reload. */ private onSettingsChangedFromPanel(key?: string): void { 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.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat); this.statusBar.updateQuota(this.cache.usageLimits ?? null); return; } @@ -764,21 +775,14 @@ export class ClaudeCodeUsageExtension { I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); I18n.setCompactNumbers(config.compactNumbers); I18n.setTimezone(config.timezone); - this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat); // Restart auto-refresh with new interval this.startAutoRefresh(); - // Clear cache if data directory changed - if (config.dataDirectory !== this.cache.dataDirectory) { - this.cache.records = []; - this.cache.lastUpdate = new Date(0); - this.cache.dataDirectory = config.dataDirectory; - this.stopFileWatching(); - } - - // Refresh data immediately, then (re-)attach the file watcher. - this.refreshData().then(() => this.startFileWatching()); + // Apply watcher-delay changes immediately, then refresh and re-attach. + this.stopFileWatching(); + void this.refreshData(true, 'settings').then(() => this.startFileWatching()); } /** @@ -807,18 +811,18 @@ export class ClaudeCodeUsageExtension { if (!filename || !String(filename).endsWith('.jsonl')) { return; } - // Mark activity so the polling timer and quota cache switch to the - // faster "active" cadence. This fires for sub-agent / workflow files - // too, since fs.watch is recursive. + // Activity remains authoritative for quota TTL only; poll cadence + // always follows refreshInterval. this.lastActivityAt = Date.now(); - // Debounce: Claude Code writes lines in bursts and the file mtime - // changes for every line. - if (this.watchDebounceTimer) { - clearTimeout(this.watchDebounceTimer); + this.watcherEventsSinceRefresh += 1; + const delaySeconds = this.getConfiguration().fileWatchSeconds; + if (!(delaySeconds > 0)) { + this.stopFileWatching(); + return; } - this.watchDebounceTimer = setTimeout(() => { - this.refreshData(); - }, config.fileWatchSeconds * 1000); + this.watchDebounce.push(delaySeconds * 1000, () => { + void this.refreshData(false, 'watch'); + }); }); this.watchedDir = projectsDir; } catch { @@ -827,10 +831,7 @@ export class ClaudeCodeUsageExtension { } private stopFileWatching(): void { - if (this.watchDebounceTimer) { - clearTimeout(this.watchDebounceTimer); - this.watchDebounceTimer = undefined; - } + this.watchDebounce.clear(); if (this.fileWatcher) { try { this.fileWatcher.close(); @@ -872,7 +873,7 @@ export class ClaudeCodeUsageExtension { // the next refresh bypasses the TTL and refetches with the new token. // The api client's own 429 cool-down still protects the endpoint. this.cache.usageLimitsLastUpdate = new Date(0); - this.refreshData(); + void this.refreshData(false, 'credentials'); }, 800); }); } catch { @@ -914,7 +915,7 @@ export class ClaudeCodeUsageExtension { if (state.focused && !wasFocused) { this.startAutoRefresh(); // reset a cadence that may have been throttled this.startFileWatching(); // re-attach the watcher if it was dropped - this.refreshData(); // catch up now (updates the status bar immediately) + void this.refreshData(false, 'focus'); // catch up now } wasFocused = state.focused; }) @@ -926,24 +927,14 @@ export class ClaudeCodeUsageExtension { clearTimeout(this.refreshTimer); this.refreshTimer = undefined; } - // Self-rescheduling timer with an activity-aware interval: while Claude - // Code is actively writing logs we tick every ~15 s (matching the user's - // expectation that ultracode / high-consumption runs update promptly); - // when idle we use the user's configured interval (min 30 s). fs.watch - // already covers near-real-time status-bar cost updates during activity — - // this floor guarantees the quota also refreshes during sustained writes - // where the debounce never settles. const gen = ++this.refreshGen; const tick = (): void => { if (gen !== this.refreshGen) { return; // superseded by a newer startAutoRefresh — stop this chain } - const base = Math.max(this.getConfiguration().refreshInterval * 1000, 30000); - // ~8 s while active: high-consumption models (Fable 5) move the numbers - // fast enough that 15 s reads as laggy. - const intervalMs = this.isActive() ? Math.min(base, 8000) : base; + const intervalMs = pollIntervalMs(this.getConfiguration().refreshInterval); this.refreshTimer = setTimeout(() => { - this.refreshData().finally(() => { + this.refreshData(false, 'poll').finally(() => { if (gen === this.refreshGen) { tick(); } @@ -1037,31 +1028,46 @@ export class ClaudeCodeUsageExtension { return expired(u.five_hour) || expired(u.seven_day) || expired(u.seven_day_opus); } - private async refreshData(manualTrigger: boolean = false): Promise { - if (this.isRefreshing) { - // Coalesce: remember that another refresh was requested and run exactly - // one more after the current finishes (see finally). Dropping the event - // outright starved updates during rapid ultracode / sub-agent writes. - this.pendingRefresh = true; - if (manualTrigger) { this.pendingManual = true; } + private async refreshData( + forceReload: boolean = false, + trigger: RefreshTrigger = 'poll' + ): Promise { + const request = this.refreshGate.request(forceReload, trigger); + if (request === null) { + this.coalescedTriggersSinceRefresh += 1; return; } - this.isRefreshing = true; + await this.runRefresh(request); + } + + private handleColdRefreshFailure(updateWebview: boolean): void { + reportColdRefreshFailure({ + hasLoadedManifest: this.cache.manifest !== null, + updateWebview, + error: I18n.t.statusBar.refreshFailed, + onStatusError: (error) => { + this.statusBar.updateUsageData(null, null, error); + this.statusBar.updateContext(null); + }, + onWebviewError: (error) => { + this.webviewProvider.updateData(null, null, null, null, [], [], [], error, null); + }, + }); + } + + private async runRefresh(request: RefreshRequest): Promise { + const totalStarted = performance.now(); + const watcherEvents = this.watcherEventsSinceRefresh; + const coalescedTriggers = this.coalescedTriggersSinceRefresh; + this.watcherEventsSinceRefresh = 0; + this.coalescedTriggersSinceRefresh = 0; + let updateWebview = request.trigger === 'manual'; try { const config = this.getConfiguration(); - // When dashboard auto-refresh is off, auto-triggers (timer + fs.watch) - // skip the webview update entirely; the status bar still refreshes so - // today's cost / quota stay live. Manual command always refreshes - // everything so the user can force-update on demand. - const updateWebview = manualTrigger || config.dashboardAutoRefresh; - - // Quota is account-level, decoupled from local data. Fire it without - // awaiting so a slow/cold OAuth fetch (curl can take seconds, or fail - // outright on a fresh window's flaky network) never delays the local - // cost figures — the cause of "usage not showing the first time I open - // VS Code". On a cold start with no quota yet, do ONE gentle retry after - // ~8 s; beyond that the regular ticks take over. We deliberately do not - // retry-storm: repeated /usage hits are what trigger the 429 cool-down. + updateWebview = updateWebview || config.dashboardAutoRefresh; + + // Account quota is independent from local JSONL. Do not let a slow OAuth + // request delay the local usage refresh. this.maybeFetchUsageLimits(config).then((limits) => { this.statusBar.updateQuota(limits); this.webviewProvider.updateQuota(limits); @@ -1078,11 +1084,9 @@ export class ClaudeCodeUsageExtension { } }); - // Find Claude data directory const dataDirectory = await ClaudeDataLoader.findClaudeDataDirectory( config.dataDirectory || undefined ); - if (!dataDirectory) { const error = 'Claude data directory not found. Please check your configuration.'; this.statusBar.updateUsageData(null, null, error); @@ -1090,21 +1094,39 @@ export class ClaudeCodeUsageExtension { if (updateWebview) { this.webviewProvider.updateData(null, null, null, null, [], [], [], error, null); } + this.outputChannel.appendLine(formatRefreshDiagnostic({ + trigger: request.trigger, + filesDiscovered: 0, + filesChanged: 0, + filesReused: 0, + filesRemoved: 0, + filesFailed: 0, + bytesRead: 0, + linesParsed: 0, + watcherEvents, + coalescedTriggers, + manifestMs: 0, + readParseMs: 0, + aggregateRenderMs: 0, + totalMs: performance.now() - totalStarted, + })); return; } - // Skip the heavy recompute when nothing has changed since the last load — - // this avoids pointless work (and CPU spikes) while you are not running code. - const latestMtime = await ClaudeDataLoader.getLatestModifiedTime(dataDirectory); - const dirChanged = this.cache.dataDirectory !== dataDirectory; - // A manual refresh always reloads from disk (a delete doesn't bump mtimes). - const needFullRefresh = - manualTrigger || dirChanged || this.cache.records.length === 0 || latestMtime > this.cache.lastUpdate.getTime(); + const manifestStarted = performance.now(); + const manifest = await scanUsageManifest([dataDirectory]); + const delta = diffUsageManifests(this.cache.manifest, manifest); + const manifestMs = performance.now() - manifestStarted; + const directoryChanged = this.cache.dataDirectory !== dataDirectory; + const needFullRefresh = shouldReloadUsage({ + forceReload: request.forceReload, + directoryChanged, + hasLoadedManifest: this.cache.manifest !== null, + changedFiles: delta.changed.length, + removedFiles: delta.removed.length, + }); if (!needFullRefresh) { - // Idle: logs unchanged. Quota was already refreshed above. Still - // recompute the context indicator from cache so its 5-hour recency - // guard can hide it once the session goes stale. this.statusBar.updateContext( ClaudeDataLoader.getCurrentContextInfo( this.cache.records, @@ -1112,14 +1134,30 @@ export class ClaudeCodeUsageExtension { config.contextWindowOverride ) ); + this.cache.manifest = manifest; + this.cache.dataDirectory = dataDirectory; + this.outputChannel.appendLine(formatRefreshDiagnostic({ + trigger: request.trigger, + filesDiscovered: manifest.entries.size, + filesChanged: 0, + filesReused: delta.reused.length, + filesRemoved: 0, + filesFailed: 0, + bytesRead: 0, + linesParsed: 0, + watcherEvents, + coalescedTriggers, + manifestMs, + readParseMs: 0, + aggregateRenderMs: 0, + totalMs: performance.now() - totalStarted, + })); return; } - // Only show the full-screen spinner on the very first load (cold cache, - // nothing on screen yet). Background refreshes keep existing dashboard - // visible and swap in fresh data when ready — avoiding panel flicker - // on every file-watch tick during active use. (PR #20, @nickearnshaw) - if (this.cache.records.length === 0) { + // A non-null manifest, not records.length, distinguishes a cold load from + // a successfully loaded empty corpus. + if (this.cache.manifest === null) { this.statusBar.setLoading(true); if (updateWebview) { this.webviewProvider.setLoading(true); @@ -1129,17 +1167,35 @@ export class ClaudeCodeUsageExtension { const loaded = await ClaudeDataLoader.loadUsageRecords(dataDirectory, { analyzeContent: config.enableContentAnalysis, windowDays: config.advicePromptWindowDays, - log: (line) => - this.outputChannel.appendLine( - `[${new Date().toLocaleTimeString(undefined, { hour12: false })}] ${line}` - ) + manifest, + log: (line) => this.outputChannel.appendLine( + `[${new Date().toLocaleTimeString(undefined, { hour12: false })}] ${line}` + ), }); + if (!shouldCommitUsageLoad(loaded.diagnostics.filesFailed)) { + this.handleColdRefreshFailure(updateWebview); + 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, + watcherEvents, + coalescedTriggers, + manifestMs, + readParseMs: loaded.diagnostics.readParseMs, + aggregateRenderMs: 0, + totalMs: performance.now() - totalStarted, + })); + return; + } + + const aggregateStarted = performance.now(); const records = loaded.records; const contentAnalysis = loaded.contentAnalysis; - this.cache.records = records; - this.cache.contentAnalysis = contentAnalysis; - this.cache.lastUpdate = new Date(); - this.cache.dataDirectory = dataDirectory; if (records.length === 0) { const error = 'No usage records found. Make sure Claude Code is running.'; @@ -1148,58 +1204,86 @@ export class ClaudeCodeUsageExtension { if (updateWebview) { this.webviewProvider.updateData(null, null, null, null, [], [], [], error, dataDirectory); } - return; + } else { + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const sessionData = ClaudeDataLoader.getCurrentSessionData(records, workspacePath); + const todayData = ClaudeDataLoader.getTodayData(records); + const workspaceTodayData = workspacePath + ? ClaudeDataLoader.getTodayData(ClaudeDataLoader.filterByWorkspace(records, workspacePath)) + : null; + const monthData = ClaudeDataLoader.getThisMonthData(records); + const allTimeData = ClaudeDataLoader.getAllTimeData(records); + const dailyDataForMonth = ClaudeDataLoader.getDailyDataForMonth(records); + const dailyDataForAllTime = ClaudeDataLoader.getDailyDataForAllTime(records); + const hourlyDataForToday = ClaudeDataLoader.getHourlyDataForToday(records); + const sessionBreakdown = ClaudeDataLoader.getSessionBreakdown(records); + const projectBreakdown = ClaudeDataLoader.getProjectBreakdown(records, undefined, config.projectGroupingMode); + const branchBreakdown = ClaudeDataLoader.getBranchBreakdown(records); + const workflowBreakdown = ClaudeDataLoader.getWorkflowBreakdown(records); + const costliestMessages = ClaudeDataLoader.getCostliestMessages(records); + + this.statusBar.updateUsageData(todayData, workspaceTodayData, undefined, undefined, monthData); + this.statusBar.updateContext( + ClaudeDataLoader.getCurrentContextInfo(records, workspacePath, config.contextWindowOverride) + ); + if (updateWebview) { + this.webviewProvider.updateData(sessionData, todayData, monthData, allTimeData, dailyDataForMonth, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages); + } } - // Calculate usage data - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - // "Current session" (per-workspace) is still computed for the webview. - const sessionData = ClaudeDataLoader.getCurrentSessionData(records, workspacePath); - const todayData = ClaudeDataLoader.getTodayData(records); - // Status-bar secondary number: today's cost for the current workspace. - const workspaceTodayData = workspacePath - ? ClaudeDataLoader.getTodayData(ClaudeDataLoader.filterByWorkspace(records, workspacePath)) - : null; - const monthData = ClaudeDataLoader.getThisMonthData(records); - const allTimeData = ClaudeDataLoader.getAllTimeData(records); - const dailyDataForMonth = ClaudeDataLoader.getDailyDataForMonth(records); - const dailyDataForAllTime = ClaudeDataLoader.getDailyDataForAllTime(records); - const hourlyDataForToday = ClaudeDataLoader.getHourlyDataForToday(records); - const sessionBreakdown = ClaudeDataLoader.getSessionBreakdown(records); - const projectBreakdown = ClaudeDataLoader.getProjectBreakdown(records, undefined, config.projectGroupingMode); - const branchBreakdown = ClaudeDataLoader.getBranchBreakdown(records); - const workflowBreakdown = ClaudeDataLoader.getWorkflowBreakdown(records); - const costliestMessages = ClaudeDataLoader.getCostliestMessages(records); - - // Update UI. Quota is pushed asynchronously by the fire-and-forget fetch - // above; passing undefined leaves the quota item untouched here. - this.statusBar.updateUsageData(todayData, workspaceTodayData, undefined, undefined, monthData); - this.statusBar.updateContext( - ClaudeDataLoader.getCurrentContextInfo(records, workspacePath, config.contextWindowOverride) + const aggregateRenderMs = performance.now() - aggregateStarted; + commitRefreshSnapshot( + manifest, + { records, contentAnalysis }, + loaded.diagnostics.filesFailed, + (nextManifest, snapshot) => { + this.cache.records = snapshot.records; + this.cache.contentAnalysis = snapshot.contentAnalysis; + this.cache.manifest = nextManifest; + this.cache.dataDirectory = dataDirectory; + this.cache.lastUpdate = new Date(); + } ); - if (updateWebview) { - this.webviewProvider.updateData(sessionData, todayData, monthData, allTimeData, dailyDataForMonth, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages); - } - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - console.error('Error refreshing Claude Code usage data:', error); - - this.statusBar.updateUsageData(null, null, errorMessage); - this.statusBar.updateContext(null); - if (manualTrigger || this.getConfiguration().dashboardAutoRefresh) { - this.webviewProvider.updateData(null, null, null, null, [], [], [], errorMessage, null); - } + 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, + watcherEvents, + coalescedTriggers, + manifestMs, + readParseMs: loaded.diagnostics.readParseMs, + aggregateRenderMs, + totalMs: performance.now() - totalStarted, + })); + } catch { + // Keep the previous records and manifest authoritative. The next trigger + // retries scanner/reconciliation failures instead of presenting no data. + this.handleColdRefreshFailure(updateWebview); + this.outputChannel.appendLine(formatRefreshDiagnostic({ + trigger: request.trigger, + filesDiscovered: 0, + filesChanged: 0, + filesReused: 0, + filesRemoved: 0, + filesFailed: 1, + bytesRead: 0, + linesParsed: 0, + watcherEvents, + coalescedTriggers, + manifestMs: 0, + readParseMs: 0, + aggregateRenderMs: 0, + totalMs: performance.now() - totalStarted, + })); } finally { - this.isRefreshing = false; - // If triggers arrived mid-load, run one more (background) refresh to pick - // up the changes they signalled. The pendingRefresh flag collapses any - // number of dropped triggers into a single follow-up. - if (this.pendingRefresh) { - this.pendingRefresh = false; - const manual = this.pendingManual; - this.pendingManual = false; - setTimeout(() => this.refreshData(manual), 0); + const next = this.refreshGate.complete(); + if (next !== null) { + setTimeout(() => void this.runRefresh(next), 0); } } } diff --git a/src/i18n.ts b/src/i18n.ts index 23600346..1579cf6d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -6,6 +6,7 @@ export interface Translations { noData: string; notRunning: string; error: string; + refreshFailed: string; currentSession: string; }; popup: { @@ -144,6 +145,10 @@ export interface Translations { resumeInvalid: string; sessionFilterCurrent: string; sessionFilterAll: string; + sessionRangeToday: string; + sessionRange7d: string; + sessionRange30d: string; + sessionModelAll: string; deleteSession: string; deleteSessionConfirm: string; deleteSessionDetail: string; @@ -202,6 +207,7 @@ const translations: Record = { noData: 'No Claude Code Data', notRunning: 'Claude Code Not Running', error: 'Error', + refreshFailed: 'Usage refresh failed. Retry or check diagnostic logs.', currentSession: 'Session', }, popup: { @@ -349,6 +355,10 @@ const translations: Record = { resumeInvalid: 'Invalid session id — cannot resume.', sessionFilterCurrent: 'Current project', sessionFilterAll: 'All', + sessionRangeToday: 'Today', + sessionRange7d: '7 days', + sessionRange30d: '30 days', + sessionModelAll: 'All models', deleteSession: 'Delete session', deleteSessionConfirm: 'Delete session "{name}"?', deleteSessionDetail: 'Its conversation log moves to the trash (recoverable). The extension is otherwise read-only.', @@ -423,6 +433,7 @@ const translations: Record = { noData: "Keine Claude Code Daten", notRunning: "Claude Code nicht erreichbar", error: "Fehler", + refreshFailed: "Aktualisierung fehlgeschlagen. Erneut versuchen oder Diagnoselogs prüfen.", currentSession: "Session", }, popup: { @@ -570,6 +581,10 @@ const translations: Record = { resumeInvalid: 'Ungültige Sitzungs-ID — Fortsetzen nicht möglich.', sessionFilterCurrent: 'Aktuelles Projekt', sessionFilterAll: 'Alle', + sessionRangeToday: 'Heute', + sessionRange7d: '7 Tage', + sessionRange30d: '30 Tage', + sessionModelAll: 'Alle Modelle', deleteSession: 'Sitzung löschen', deleteSessionConfirm: 'Sitzung "{name}" löschen?', deleteSessionDetail: 'Das Gesprächsprotokoll wandert in den Papierkorb (wiederherstellbar). Die Erweiterung ist ansonsten schreibgeschützt.', @@ -647,6 +662,7 @@ const translations: Record = { noData: '無 Claude Code 資料', notRunning: 'Claude Code 未執行', error: '錯誤', + refreshFailed: '使用量重新整理失敗。請重試或查看診斷日誌。', currentSession: '當前會話', }, popup: { @@ -790,6 +806,10 @@ const translations: Record = { resumeInvalid: '無效的會話 ID,無法恢復。', sessionFilterCurrent: '目前專案', sessionFilterAll: '全部', + sessionRangeToday: '今天', + sessionRange7d: '7 天', + sessionRange30d: '30 天', + sessionModelAll: '全部模型', deleteSession: '刪除會話', deleteSessionConfirm: '刪除會話「{name}」?', deleteSessionDetail: '對話記錄會移至垃圾桶(可復原)。此擴充功能其餘部分為唯讀。', @@ -864,6 +884,7 @@ const translations: Record = { noData: '无 Claude Code 数据', notRunning: 'Claude Code 未运行', error: '错误', + refreshFailed: '用量刷新失败。请重试或查看诊断日志。', currentSession: '当前会话', }, popup: { @@ -1007,6 +1028,10 @@ const translations: Record = { resumeInvalid: '无效的会话 ID,无法恢复。', sessionFilterCurrent: '当前工程', sessionFilterAll: '全部', + sessionRangeToday: '今天', + sessionRange7d: '7 天', + sessionRange30d: '30 天', + sessionModelAll: '全部模型', deleteSession: '删除会话', deleteSessionConfirm: '删除会话「{name}」?', deleteSessionDetail: '对话日志将移至回收站(可恢复)。本扩展其余部分为只读。', @@ -1081,6 +1106,7 @@ const translations: Record = { noData: 'Claude Code データなし', notRunning: 'Claude Code 未実行', error: 'エラー', + refreshFailed: '使用量の更新に失敗しました。再試行するか診断ログを確認してください。', currentSession: '現在のセッション', }, popup: { @@ -1228,6 +1254,10 @@ const translations: Record = { resumeInvalid: '無効なセッションIDのため再開できません。', sessionFilterCurrent: '現在のプロジェクト', sessionFilterAll: 'すべて', + sessionRangeToday: '今日', + sessionRange7d: '7日間', + sessionRange30d: '30日間', + sessionModelAll: 'すべてのモデル', deleteSession: 'セッションを削除', deleteSessionConfirm: 'セッション「{name}」を削除しますか?', deleteSessionDetail: '会話ログはゴミ箱に移動します(復元可能)。この拡張機能は他の部分では読み取り専用です。', @@ -1303,6 +1333,7 @@ const translations: Record = { noData: 'Claude Code 데이터 없음', notRunning: 'Claude Code 실행되지 않음', error: '오류', + refreshFailed: '사용량 새로 고침에 실패했습니다. 다시 시도하거나 진단 로그를 확인하세요.', currentSession: '현재 세션', }, popup: { @@ -1450,6 +1481,10 @@ const translations: Record = { resumeInvalid: '잘못된 세션 ID입니다 — 재개할 수 없습니다.', sessionFilterCurrent: '현재 프로젝트', sessionFilterAll: '전체', + sessionRangeToday: '오늘', + sessionRange7d: '7일', + sessionRange30d: '30일', + sessionModelAll: '전체 모델', deleteSession: '세션 삭제', deleteSessionConfirm: '세션 "{name}"을(를) 삭제할까요?', deleteSessionDetail: '대화 로그가 휴지통으로 이동합니다(복구 가능). 확장 프로그램은 그 외에는 읽기 전용입니다.', @@ -1525,6 +1560,7 @@ const translations: Record = { noData: 'Sem dados do Claude Code', notRunning: 'Claude Code não está em execução', error: 'Erro', + refreshFailed: 'Falha ao atualizar o uso. Tente novamente ou verifique os logs de diagnóstico.', currentSession: 'Sessão', }, popup: { @@ -1672,6 +1708,10 @@ const translations: Record = { resumeInvalid: 'ID de sessão inválido — não é possível retomar.', sessionFilterCurrent: 'Projeto atual', sessionFilterAll: 'Todos', + sessionRangeToday: 'Hoje', + sessionRange7d: '7 dias', + sessionRange30d: '30 dias', + sessionModelAll: 'Todos os modelos', deleteSession: 'Excluir sessão', deleteSessionConfirm: 'Excluir a sessão "{name}"?', deleteSessionDetail: 'O log da conversa vai para a lixeira (recuperável). A extensão é somente leitura no restante.', @@ -1740,6 +1780,233 @@ const translations: Record = { decimalPlaces: 'Casas decimais', }, }, + id: { + statusBar: { + loading: 'Memuat...', + noData: 'Tidak Ada Data Claude Code', + notRunning: 'Claude Code Tidak Berjalan', + error: 'Error', + refreshFailed: 'Gagal menyegarkan penggunaan. Coba lagi atau periksa log diagnostik.', + currentSession: 'Sesi', + }, + popup: { + title: 'Claude Code Usage', + currentSession: 'Sesi Saat Ini', + today: 'Hari Ini', + thisMonth: 'Bulan Ini', + allTime: 'Sepanjang Waktu', + workspaceToday: 'Proyek ini', + refresh: 'Segarkan', + autoRefresh: 'Segarkan otomatis', + settings: 'Pengaturan', + settingsTab: 'Pengaturan', + settingsIntro: + 'Pengaturan sekarang ada di sini. Hanya bahasa, direktori data, dan API key yang tetap berada di Pengaturan VS Code (agar dapat disinkronkan). Perubahan langsung diterapkan.', + settingsResetAll: 'Kembalikan semua ke default', + settingsGroupGeneral: 'Umum', + settingsGroupFeatures: 'Fitur opsional', + settingsGroupStatusBar: 'Status bar', + settingsGroupData: 'Data & penyegaran', + settingsGroupAdvice: 'Saran AI & Optimizer', + totalTokens: 'Total Token', + inputTokens: 'Token Masukan', + outputTokens: 'Token Keluaran', + cacheCreation: 'Cache Masukan (Miss)', + cacheRead: 'Cache Masukan (Hit)', + cost: 'Biaya', + messages: 'Pesan', + modelBreakdown: 'Penggunaan Model', + dailyBreakdown: 'Penggunaan Harian', + monthlyBreakdown: 'Penggunaan Bulanan', + hourlyBreakdown: 'Penggunaan per Jam', + sessions: 'Sesi', + sessionBreakdown: 'Penggunaan Sesi', + project: 'Proyek', + startTime: 'Waktu Mulai', + duration: 'Durasi', + activeDuration: 'Aktif', + activeDurationHelp: + 'Perkiraan waktu penggunaan aktif — jumlah jeda antar-giliran, dengan setiap jeda idle dibatasi maksimal 1,5 jam agar jeda panjang tidak menggelembungkan angkanya, sementara waktu membaca / meninjau tetap terhitung. (Durasi adalah rentang penuh dari awal hingga akhir.)', + hour: 'Jam', + projects: 'Proyek', + projectBreakdown: 'Penggunaan Proyek', + fullPath: 'Path Lengkap', + peakContext: 'Konteks Puncak', + tokenComposition: 'Komposisi Token', + lastActive: 'Terakhir Aktif', + pricing: 'Harga', + refreshPricing: 'Segarkan Harga Token', + pricingUpdated: 'Harga diperbarui', + pricingUpdateFailed: 'Gagal memperbarui harga', + sortHint: 'Klik judul kolom untuk mengurutkan', + quota: 'Kuota', + quotaWindow: 'Periode', + quotaLimit: 'Batas', + quota5h: '5 Jam', + quotaWeekly: 'Mingguan', + quotaHint: 'Data nyata dari /usage Anthropic.', + contextWindow: 'Jendela konteks', + contextHint: 'Tugas baru → /clear', + contextHintCompact: 'Tugas sama → /compact', + contextLeft: 'Konteks tersisa', + contentAnalysis: 'Konten', + estimatedNote: 'Diperkirakan dari panjang teks — proporsi relatif dapat diandalkan, angka absolut bersifat perkiraan.', + calibratedNote: 'Terkalibrasi: proporsi per kategori dari panjang teks, diskalakan ke total token yang benar-benar ditagih (sisi keluaran / sisi masukan + cache-write). Aktifkan/nonaktifkan dengan analysis.calibrate.', + calibratedTokens: 'Token terkalibrasi', + thinkingTokensCalibrated: 'token pemikiran sebenarnya (terkalibrasi)', + byTool: 'Hasil Tool per Tool', + catUserPrompts: 'Prompt Anda', + catAssistantText: 'Respons asisten', + catAssistantThinking: 'Pemikiran asisten', + catToolCalls: 'Panggilan tool', + catToolResults: 'Hasil tool', + estTokens: 'Token perkiraan', + share: 'Proporsi', + resets: 'Reset', + cacheHitRate: 'Tingkat Cache Hit', + last30days: '30 hari terakhir', + branches: 'Branch', + branchBreakdown: 'Penggunaan Branch', + branch: 'Branch', + workflows: 'Workflow', + workflowBreakdown: 'Penggunaan Workflow', + workflowName: 'Workflow', + model: 'Model', + agents: 'Agen', + agent: 'Agen', + workflowsThisMonth: 'Workflow bulan ini', + workflowCostShare: 'proporsi dari biaya bulan ini', + workflowCacheHint: + 'Tingkat cache hit = cache read ÷ semua token sisi masukan. Workflow native Claude memakai ulang prompt cache lintas agen (tingkat tinggi); provider tanpa cache lintas-agen menunjukkan ~0% — workflow yang sama jadi jauh lebih mahal di sana.', + adhocBadge: 'subagent (ad-hoc)', + workflowModeBadge: 'workflow', + workflowModeHint: + '"workflow" = direktori proses dynamic-workflow di disk; "subagent (ad-hoc)" = pemanggilan Task-tool biasa. Tingkat effort (ultracode/xhigh) tidak tercatat di log, jadi kedua badge ini tidak mengklaim salah satunya.', + workflowNativeHint: + 'Ultracode native Claude sering menyimpan orkestrasinya di sesi utama (tanpa file agen), sehingga muncul di Sesi / Pelacakan Penggunaan, bukan sebagai baris di sini. Proses yang menulis file agen menampilkan biaya Claude-nya di baris orkestrasi. (Direncanakan untuk rilis mendatang.)', + orchestration: 'orkestrasi sesi utama', + commonTaskPrefix: 'Teks tugas bersama', + thinkingShare: 'Pemikiran %', + effortHint: 'Proporsi pemikiran tinggi — pertimbangkan /effort high alih-alih xhigh untuk tugas seperti ini.', + thinkingHidden: 'Pemikiran aktif, tetapi model ini (mis. Fable 5 / Opus 4.8) tidak menampilkan teks penalarannya, sehingga proporsinya tidak dapat diukur — nilai sebenarnya lebih tinggi dari yang ditampilkan.', + thinkingHiddenShort: 'tersembunyi', + quotaWarnBanner: + 'Hanya {remaining}% tersisa untuk periode 5 jam Anda. Sebuah proses workflow bisa memakai porsi besar — pertimbangkan menunggu reset: proses yang terinterupsi kehilangan prompt cache-nya dan diulang dengan biaya ~40% lebih mahal.', + dismiss: 'Tutup', + attribution: 'Pelacakan Penggunaan', + attrDisclaimer: + 'Perkiraan, berdasarkan sesi lokal di mesin ini — tidak termasuk perangkat lain atau claude.ai. Ini adalah karakteristik independen dari penggunaan Anda, bukan rincian lengkap.', + attrLargeContext: '{pct}% penggunaan Anda berada di konteks >150k', + attrLargeContextShort: 'konteks >150k', + attrLargeContextHint: + 'Sesi yang lebih panjang lebih mahal meski sudah di-cache. Gunakan /compact di tengah tugas, /clear saat beralih ke tugas baru.', + attrLongSessions: '{pct}% penggunaan Anda berasal dari sesi yang aktif 8+ jam', + attrLongSessionsShort: 'sesi 8 jam+', + attrLongSessionsHint: + 'Ini sering kali sesi latar belakang/loop. Penggunaan berkelanjutan bisa cepat menumpuk, jadi pastikan itu memang disengaja.', + attrSubagentHeavy: '{pct}% penggunaan Anda berasal dari sesi dengan banyak subagent', + attrSubagentHeavyShort: 'Sesi dengan banyak subagent', + attrSubagentHeavyHint: + 'Setiap subagent menjalankan requestnya sendiri. Berhati-hatilah saat memunculkannya — dan pertimbangkan model yang lebih murah untuk subagent yang lebih sederhana.', + attrWorkflows: '{pct}% penggunaan Anda berasal dari proses workflow', + attrWorkflowsShort: 'Proses workflow', + attrWorkflowsHint: 'Lihat tab Workflow untuk detail per proses dan tingkat cache hit.', + attrSkillChar: '{pct}% penggunaan Anda berasal dari {name}', + attrSkillCharHint: 'Skill yang berat bisa dipersempit cakupannya atau dijalankan dengan model yang lebih murah lewat frontmatter skill.', + attrPluginChar: '{pct}% penggunaan Anda berasal dari plugin "{name}"', + attrPluginCharHint: + 'Tinjau apa yang disumbang plugin ini — agen, skill, dan tool MCP-nya semuanya turut menghitung ke batas Anda.', + attrSkills: 'Skill', + attrSubagents: 'Subagent', + attrPlugins: 'Plugin', + attrModels: 'Model', + attrShare: '% penggunaan', + count: 'Jumlah', + scopeDay: 'Hari', + scopeWeek: 'Minggu', + scopeMonth: 'Bulan', + attrTodayPointer: 'Detail: tab Konten', + sessionTitle: 'Sesi', + sessionActions: 'Aksi', + copySessionId: 'Salin ID sesi', + viewConversation: 'Lihat percakapan ini (baca-saja) — baca ulang prompt Anda dan jawaban model tanpa memuatnya kembali ke konteks.', + copyPath: 'Salin path', + resumeSession: 'Lanjutkan percakapan ini — membuka kembali di Claude Code (proyek yang sama) atau terminal lewat "claude --resume", sehingga Anda bisa melanjutkan dari titik terakhir.', + resumeInvalid: 'ID sesi tidak valid — tidak dapat dilanjutkan.', + sessionFilterCurrent: 'Proyek saat ini', + sessionFilterAll: 'Semua', + sessionRangeToday: 'Hari ini', + sessionRange7d: '7 hari', + sessionRange30d: '30 hari', + sessionModelAll: 'Semua model', + deleteSession: 'Hapus sesi', + deleteSessionConfirm: 'Hapus sesi "{name}"?', + deleteSessionDetail: 'Log percakapannya dipindahkan ke sampah (dapat dipulihkan). Selebihnya extension ini bersifat baca-saja.', + deleteSessionYes: 'Hapus', + deleteSessionNotFound: 'File log sesi tidak ditemukan.', + deleteSessionDone: '"{name}" dihapus (dipindahkan ke sampah).', + getAdvice: 'Dapatkan Saran AI', + adviceCardTitle: 'Saran AI', + adviceCardDesc: + 'Kirim ringkasan penggunaan Anda + sampel prompt Anda sendiri ke model Anda dan dapatkan tips konkret untuk menulis instruksi yang lebih jelas dan mengurangi pemborosan.', + optimizerTitle: 'Pengoptimal Penggunaan', + optimizerDesc: + 'Ubah permintaan yang masih kasar dan belum rapi menjadi prompt bersih yang bisa langsung ditempel ke Claude Code — plus saran effort / thinking / model untuk tugas tersebut.', + optimizerHowto: + 'Ketik atau tempel draf Anda di bawah, centang penyesuaian opsional yang diinginkan, lalu klik Optimalkan. Hanya teks yang Anda tempel yang dikirim ke model Anda — tidak pernah ke Claude Code atau terminal Anda.', + optimizerConsent: + 'Usage Optimizer mengirim teks yang Anda tempel ke model API yang telah dikonfigurasi. Tidak ada yang dikirim ke Claude Code dan tidak ada yang diketik ke terminal. Lanjutkan?', + optimizerEnableBtn: 'Aktifkan di pengaturan', + optimizerPlaceholder: 'Tempel draf prompt untuk dioptimalkan…', + optimizerRun: 'Optimalkan', + optimizerRunning: 'Mengoptimalkan…', + optimizerCopy: 'Salin prompt', + optimizerCopied: 'Disalin', + optimizerResolve: 'Tandai referensi yang ambigu', + optimizerResolveHint: + "Minta model menunjukkan referensi yang ambigu (mis. 'ini', 'file itu', 'bug itu') lalu memperjelasnya atau menandai asumsi yang jelas.", + optimizerDistil: 'Ringkas teks tempelan yang panjang', + optimizerDistilHint: + 'Jika draf Anda menempel log / kode / dokumen yang panjang, ringkas menjadi hanya yang dibutuhkan Claude.', + optimizerAesthetic: 'Sarankan arah gaya', + optimizerAestheticHint: + 'Untuk tugas UI / visual / tulisan, usulkan satu arah gaya yang konkret agar hasilnya tidak generik.', + optimizerPromptHeading: 'Prompt teroptimasi', + optimizerSettingsHeading: 'Pengaturan proses yang disarankan', + experimentalBadge: 'eksperimental', + adviceNeedsKey: 'Atur API key di Pengaturan untuk menggunakan saran AI.', + adviceGenerating: 'Membuat saran penggunaan…', + adviceFailed: 'Gagal mendapatkan saran', + adviceScopeOverall: 'Keseluruhan (semua proyek)', + adviceScopePrompt: 'Pilih fokus saran yang diinginkan', + adviceDemoButton: 'Pratinjau demo', + adviceDemoNotice: + '# DEMO — Pratinjau Saran Penggunaan AI\n\n' + + '> **File ini adalah demo statis, bukan saran sungguhan.**\n' + + '> Teks di bawah ini ditulis manual untuk menunjukkan jenis keluaran\n' + + '> yang dihasilkan fitur ini. Ini **bukan** berdasarkan data penggunaan\n' + + '> Claude Code Anda yang sebenarnya — tidak ada yang dikirim ke API mana pun untuk menghasilkan ini.\n\n' + + '### Untuk mendapatkan saran nyata dan personal berdasarkan penggunaan ANDA:\n\n' + + '1. Buka Pengaturan (`Ctrl+,` / `Cmd+,`)\n' + + '2. Cari **`claudeCodeUsage.advice.apiKey`**\n' + + '3. Tempel API key yang kompatibel dengan OpenAI — DeepSeek langsung berfungsi\n' + + ' ([deepseek.com](https://platform.deepseek.com))\n' + + '4. Jalankan ulang **`Claude Code Usage: Get AI Usage Advice`**', + costComposition: 'Komposisi Biaya', + date: 'Tanggal', + yesterday: 'Kemarin', + dataDirectory: 'Direktori Data', + noDataMessage: 'Tidak ada data penggunaan yang ditemukan. Pastikan Claude Code sudah berjalan dan dikonfigurasi dengan benar.', + errorMessage: 'Gagal memuat data penggunaan. Periksa kembali konfigurasi Anda.', + }, + settings: { + title: 'Pengaturan Claude Code Usage', + refreshInterval: 'Interval Penyegaran (detik)', + dataDirectory: 'Path Direktori Data', + language: 'Bahasa', + decimalPlaces: 'Angka Desimal', + }, + }, }; // Per-setting label / help translations for the dashboard ⚙ Settings panel. @@ -1768,10 +2035,11 @@ const SETTINGS_I18N: Partial Quota: reset countdown format, #74): +// decimal → "4.8h" / "1.6d" (default, most compact) +// units → "4h 48m" / "1d 14h" (whole hour/minute or day/hour units) +// clock → the actual local time / date the window resets ("18:20" / "2026-07-22") +export type ResetCountdownFormat = 'decimal' | 'units' | 'clock'; + export interface QuotaStatusOptions { showReset: boolean; // showResetInStatusBar (default false) fiveHourOnly: boolean; // quotaFiveHourOnly (default false) showOpusWeekly: boolean; // existing opt-in weekly Opus cap + resetFormat?: ResetCountdownFormat; // resetCountdownFormat (default 'decimal') now?: number; // for the countdown; defaults to Date.now() } -/** Compact single-unit time-to-reset, e.g. "4.8h" (< 24h) or "1.6d" (>= 24h). +/** Zero-padded "HH:MM" in local time. */ +function localTime(d: Date): string { + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + return `${h}:${m}`; +} + +/** "YYYY-MM-DD" in local time (not UTC, so it matches what the user's clock reads). */ +function localDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** Whole hour/minute or day/hour units, e.g. "4h 48m" (< 24h) or "1d 14h" (>= 24h). */ +function unitsReset(ms: number): string { + const totalMinutes = Math.floor(ms / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours >= 24) { + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return `${days}d ${remHours}h`; + } + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +/** Compact time-to-reset in the given format (default 'decimal'): + * decimal → "4.8h" (< 24h) or "1.6d" (>= 24h) + * units → "4h 48m" (< 24h) or "1d 14h" (>= 24h) + * clock → "18:20" local time (< 24h) or "2026-07-22" local date (>= 24h) * Empty string for an unparseable reset time. */ -export function compactReset(resetsAt: string, now: number = Date.now()): string { +export function compactReset( + resetsAt: string, + now: number = Date.now(), + format: ResetCountdownFormat = 'decimal' +): string { const t = Date.parse(resetsAt); if (isNaN(t)) { return ''; } const ms = t - now; + // clock shows the absolute moment the window resets, so it stays meaningful + // even for an already-passed reset (unlike the relative decimal/units forms). + if (format === 'clock') { + const target = new Date(t); + return ms / 3_600_000 >= 24 ? localDate(target) : localTime(target); + } if (ms <= 0) { - return '0h'; + return format === 'units' ? '0m' : '0h'; + } + if (format === 'units') { + return unitsReset(ms); } const hours = ms / 3_600_000; return hours < 24 ? `${hours.toFixed(1)}h` : `${(hours / 24).toFixed(1)}d`; @@ -54,7 +105,7 @@ export function formatQuotaStatusText(live: LiveQuotaWindows | null, opts: Quota } let s = `${label} ${Math.round(w.utilization)}%`; if (opts.showReset) { - const r = compactReset(w.resets_at, now); + const r = compactReset(w.resets_at, now, opts.resetFormat); if (r) { s += ` ↻${r}`; } diff --git a/src/refreshDiagnostics.ts b/src/refreshDiagnostics.ts new file mode 100644 index 00000000..fe566f97 --- /dev/null +++ b/src/refreshDiagnostics.ts @@ -0,0 +1,33 @@ +import { RefreshTrigger } from './refreshPolicy'; + +export interface LoadUsageDiagnostics { + filesDiscovered: number; + filesFailed: number; + bytesRead: number; + linesParsed: number; + readParseMs: number; +} + +export interface RefreshDiagnostic extends LoadUsageDiagnostics { + trigger: RefreshTrigger; + filesChanged: number; + filesReused: number; + filesRemoved: number; + watcherEvents: number; + coalescedTriggers: number; + manifestMs: number; + aggregateRenderMs: number; + totalMs: number; +} + +const ms = (value: number): string => value.toFixed(1); + +export function formatRefreshDiagnostic(value: RefreshDiagnostic): string { + return `refresh: trigger=${value.trigger} ` + + `files(discovered=${value.filesDiscovered} changed=${value.filesChanged} ` + + `reused=${value.filesReused} removed=${value.filesRemoved} failed=${value.filesFailed}) ` + + `io(bytes=${value.bytesRead} lines=${value.linesParsed}) ` + + `events(watcher=${value.watcherEvents} coalesced=${value.coalescedTriggers}) ` + + `ms(manifest=${ms(value.manifestMs)} read-parse=${ms(value.readParseMs)} ` + + `aggregate-render=${ms(value.aggregateRenderMs)} total=${ms(value.totalMs)})`; +} diff --git a/src/refreshPolicy.ts b/src/refreshPolicy.ts new file mode 100644 index 00000000..9b1dc1b0 --- /dev/null +++ b/src/refreshPolicy.ts @@ -0,0 +1,137 @@ +export const LIVE_REFRESH_SECONDS = [ + '0', '1', '2', '5', '10', '20', '30', '60', '120', '300', +] as const; + +export type RefreshTrigger = + | 'startup' + | 'poll' + | 'credentials' + | 'watch' + | 'focus' + | 'workspace' + | 'settings' + | 'pricing' + | 'manual'; + +const TRIGGER_PRIORITY: Record = { + startup: 0, + poll: 1, + credentials: 2, + watch: 3, + focus: 4, + workspace: 5, + settings: 6, + pricing: 7, + manual: 8, +}; + +export function pollIntervalMs(refreshIntervalSeconds: number): number { + const seconds = Number.isFinite(refreshIntervalSeconds) + ? Math.min(3600, Math.max(30, refreshIntervalSeconds)) + : 60; + return seconds * 1000; +} + +export function mergeRefreshTrigger( + current: RefreshTrigger | null, + incoming: RefreshTrigger, +): RefreshTrigger { + if (current === null) return incoming; + return TRIGGER_PRIORITY[incoming] > TRIGGER_PRIORITY[current] ? incoming : current; +} + +export class QuietDebounce { + private handle: NodeJS.Timeout | undefined; + + constructor( + private readonly schedule: (callback: () => void, ms: number) => NodeJS.Timeout = setTimeout, + private readonly cancel: (handle: NodeJS.Timeout) => void = clearTimeout + ) {} + + push(ms: number, callback: () => void): void { + if (this.handle) this.cancel(this.handle); + this.handle = this.schedule(() => { + this.handle = undefined; + callback(); + }, ms); + } + + clear(): void { + if (this.handle) this.cancel(this.handle); + this.handle = undefined; + } +} + +export interface RefreshRequest { + forceReload: boolean; + trigger: RefreshTrigger; +} + +export function shouldReloadUsage(value: { + forceReload: boolean; + directoryChanged: boolean; + hasLoadedManifest: boolean; + changedFiles: number; + removedFiles: number; +}): boolean { + return value.forceReload || value.directoryChanged || !value.hasLoadedManifest || + value.changedFiles > 0 || value.removedFiles > 0; +} + +export function shouldCommitUsageLoad(filesFailed: number): boolean { + return Number.isInteger(filesFailed) && filesFailed === 0; +} + +export function commitRefreshSnapshot( + manifest: TManifest, + records: TRecords, + filesFailed: number, + commit: (manifest: TManifest, records: TRecords) => void, +): boolean { + if (!shouldCommitUsageLoad(filesFailed)) return false; + commit(manifest, records); + return true; +} + +export function reportColdRefreshFailure(value: { + hasLoadedManifest: boolean; + updateWebview: boolean; + error: string; + onStatusError: (error: string) => void; + onWebviewError: (error: string) => void; +}): boolean { + if (value.hasLoadedManifest) return false; + value.onStatusError(value.error); + if (value.updateWebview) value.onWebviewError(value.error); + return true; +} + +export class RefreshSingleFlight { + private active = false; + private pendingForceReload = false; + private pendingTrigger: RefreshTrigger | null = null; + + request(forceReload: boolean, trigger: RefreshTrigger): RefreshRequest | null { + if (!this.active) { + this.active = true; + return { forceReload, trigger }; + } + this.pendingForceReload ||= forceReload; + this.pendingTrigger = mergeRefreshTrigger(this.pendingTrigger, trigger); + return null; + } + + complete(): RefreshRequest | null { + if (this.pendingTrigger === null) { + this.active = false; + return null; + } + const next = { + forceReload: this.pendingForceReload, + trigger: this.pendingTrigger, + }; + this.pendingForceReload = false; + this.pendingTrigger = null; + return next; + } +} diff --git a/src/settings.ts b/src/settings.ts index 73a7f4e4..335a0b2a 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { LIVE_REFRESH_SECONDS } from './refreshPolicy'; // Single source of truth for every user setting (V2.1: "settings in the // dashboard"). Most settings moved OUT of VS Code's Settings UI to keep it @@ -78,11 +79,14 @@ const CURATED_ZONES: { zone: string; city: string }[] = [ { zone: 'Asia/Kolkata', city: 'Kolkata' }, { zone: 'Asia/Dhaka', city: 'Dhaka' }, { zone: 'Asia/Bangkok', city: 'Bangkok' }, + { zone: 'Asia/Jakarta', city: 'Jakarta (WIB)' }, { zone: 'Asia/Shanghai', city: 'Shanghai' }, { zone: 'Asia/Hong_Kong', city: 'Hong Kong' }, { zone: 'Asia/Singapore', city: 'Singapore' }, + { zone: 'Asia/Makassar', city: 'Makassar (WITA)' }, { zone: 'Asia/Tokyo', city: 'Tokyo' }, { zone: 'Asia/Seoul', city: 'Seoul' }, + { zone: 'Asia/Jayapura', city: 'Jayapura (WIT)' }, { zone: 'Australia/Sydney', city: 'Sydney' }, { zone: 'Pacific/Auckland', city: 'Auckland' }, ]; @@ -155,7 +159,7 @@ export const SETTINGS: SettingDef[] = [ group: 'general', label: 'Display language', help: 'UI language. "auto" follows VS Code.', - enumValues: ['auto', 'en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR'], + enumValues: ['auto', 'en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id'], }, { key: 'decimalPlaces', @@ -352,6 +356,18 @@ export const SETTINGS: SettingDef[] = [ label: 'Quota: show reset countdown', help: 'Append a compact reset countdown in the status bar (5h 6% ↻4.8h). Off keeps it clean (5h 6% · wk 1%); the tooltip always shows full reset times.', }, + { + // Format of the reset countdown appended by showResetInStatusBar (#74). + key: 'resetCountdownFormat', + type: 'enum', + default: 'decimal', + storage: 'state', + group: 'statusBar', + label: 'Quota: reset countdown format', + help: 'Only applies when "Quota: show reset countdown" is on. Decimal (4.8h / 1.6d), whole units (4h 48m / 1d 14h), or your computer\'s local clock time / date (18:20 / 2026-07-22).', + enumValues: ['decimal', 'units', 'clock'], + enumLabels: ['Decimal (4.8h / 1.6d)', 'Units (4h 48m / 1d 14h)', 'Local time (18:20 / 2026-07-22)'], + }, { key: 'workflowQuotaWarnPercent', type: 'number', @@ -391,9 +407,9 @@ export const SETTINGS: SettingDef[] = [ storage: 'state', group: 'data', label: 'Live refresh delay', - help: 'How soon the dashboard refreshes after new activity. This only re-reads your LOCAL log files (no API call — the quota fetch is throttled separately). "Off" disables live watching; a longer delay is lighter on CPU.', - enumValues: ['0', '1', '2', '5', '10', '20', '30'], - enumLabels: ['Off', '1s', '2s', '5s', '10s', '20s', '30s'], + help: 'Wait after the last local JSONL change before refreshing (quiet debounce; each new event restarts the delay). No API call is made; quota fetches are throttled separately. Off disables watching, and 60–300s is the lowest-CPU option for large histories.', + enumValues: [...LIVE_REFRESH_SECONDS], + enumLabels: ['Off', '1s', '2s', '5s', '10s', '20s', '30s', '60s', '120s', '300s'], }, { // V2.2: positive wording, replacing the old double-negative diff --git a/src/statusBar.ts b/src/statusBar.ts index 8bd2a392..2b0a05c3 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { ClaudeApiUsageResponse, ClaudeUsageLimit, ContextWindowInfo, UsageData } from './types'; import { I18n } from './i18n'; -import { formatQuotaStatusText, worstShownUtilisation, QuotaStatusOptions } from './quotaFormat'; +import { formatQuotaStatusText, worstShownUtilisation, QuotaStatusOptions, ResetCountdownFormat } from './quotaFormat'; export class StatusBarManager { private statusBarItem: vscode.StatusBarItem; @@ -20,6 +20,7 @@ export class StatusBarManager { // Quota display preferences. private quotaFiveHourOnly: boolean = false; // show only the 5h window private showResetInBar: boolean = false; // append reset countdown to the bar + private resetCountdownFormat: ResetCountdownFormat = 'decimal'; // style of that countdown (#74) constructor() { this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); @@ -56,7 +57,8 @@ export class StatusBarManager { metric: 'cost' | 'monthly-cost' | 'tokens' = 'cost', showOpusWeekly: boolean = false, quotaFiveHourOnly: boolean = false, - showResetInBar: boolean = false + showResetInBar: boolean = false, + resetCountdownFormat: ResetCountdownFormat = 'decimal' ): void { this.showCost = showCost; this.showContext = showContext; @@ -65,6 +67,7 @@ export class StatusBarManager { this.showOpusWeekly = showOpusWeekly; this.quotaFiveHourOnly = quotaFiveHourOnly; this.showResetInBar = showResetInBar; + this.resetCountdownFormat = resetCountdownFormat; if (!showContext) { this.contextItem.hide(); } @@ -240,7 +243,8 @@ export class StatusBarManager { const opts: QuotaStatusOptions = { showReset: this.showResetInBar, fiveHourOnly: this.quotaFiveHourOnly, - showOpusWeekly: this.showOpusWeekly + showOpusWeekly: this.showOpusWeekly, + resetFormat: this.resetCountdownFormat }; const text = formatQuotaStatusText(live, opts); if (!text) { diff --git a/src/test/claudeUsageFiles.test.ts b/src/test/claudeUsageFiles.test.ts new file mode 100644 index 00000000..b15ae3f6 --- /dev/null +++ b/src/test/claudeUsageFiles.test.ts @@ -0,0 +1,193 @@ +import { after, test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readdir, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + diffUsageManifests, + isMissingPathError, + mapWithConcurrency, + readEarliestTimestamp, + scanUsageManifest, + sortUsageFilesByEarliestTimestamp, +} from '../claudeUsageFiles'; + +const roots: string[] = []; +after(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function fixture(): Promise<{ root: string; file: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-files-')); + roots.push(root); + const project = path.join(root, 'projects', '-tmp-demo'); + await mkdir(project, { recursive: true }); + return { root, file: path.join(project, 'a.jsonl') }; +} + +test('manifest scanning tolerates only disappearance races', () => { + assert.equal(isMissingPathError(Object.assign(new Error('gone'), { code: 'ENOENT' })), true); + assert.equal(isMissingPathError(Object.assign(new Error('denied'), { code: 'EACCES' })), false); + assert.equal(isMissingPathError(Object.assign(new Error('io'), { code: 'EIO' })), false); + assert.equal(isMissingPathError(new Error('unknown')), false); +}); + +test('manifest scanning fails closed on permission errors but tolerates stat ENOENT', async () => { + const { root, file } = await fixture(); + await writeFile(file, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + const readDirectory = (dir: string) => readdir(dir, { withFileTypes: true }); + await assert.rejects( + () => scanUsageManifest([root], { + readdir: readDirectory, + stat: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); }, + }), + (error: NodeJS.ErrnoException) => error.code === 'EACCES', + ); + const missing = await scanUsageManifest([root], { + readdir: readDirectory, + stat: async () => { throw Object.assign(new Error('gone'), { code: 'ENOENT' }); }, + }); + assert.equal(missing.entries.size, 0); +}); + +test('manifest diff reports changed, reused, and removed without exposing them in counters', async () => { + const { root, file } = await fixture(); + await writeFile(file, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + const first = await scanUsageManifest([root]); + const cold = diffUsageManifests(null, first); + assert.equal(cold.changed.length, 1); + assert.equal(cold.reused.length, 0); + + const second = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(first, second).reused.length, 1); + + await writeFile(file, '{"timestamp":"2026-01-02T00:00:00.000Z"}\nlonger\n'); + const third = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(second, third).changed.length, 1); + + const replacement = `${file}.replacement`; + await writeFile(replacement, '{"timestamp":"2026-01-03T00:00:00.000Z"}\nlonger\n'); + await rename(replacement, file); + const fourth = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(third, fourth).changed.length, 1); + + await rm(file); + const fifth = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(fourth, fifth).removed.length, 1); +}); + +test('inode detects an atomic replacement with unchanged size and mtime', async (t) => { + const { root, file } = await fixture(); + const original = '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'; + const replacementBody = '{"timestamp":"2026-01-02T00:00:00.000Z"}\n'; + assert.equal(Buffer.byteLength(original), Buffer.byteLength(replacementBody)); + await writeFile(file, original); + const beforeManifest = await scanUsageManifest([root]); + const before = await stat(file); + + const replacement = `${file}.replacement`; + await writeFile(replacement, replacementBody); + await utimes(replacement, before.atime, before.mtime); + const prepared = await stat(replacement); + assert.equal(prepared.size, before.size); + if (prepared.mtimeMs !== before.mtimeMs) { + t.skip('filesystem cannot preserve the exact mtime for this identity fixture'); + return; + } + await rename(replacement, file); + const afterStat = await stat(file); + if (!(before.dev > 0 && before.ino > 0 && afterStat.dev > 0 && afterStat.ino > 0 && + (before.dev !== afterStat.dev || before.ino !== afterStat.ino))) { + t.skip('filesystem does not expose a reliable changed file identity'); + return; + } + + const afterManifest = await scanUsageManifest([root]); + assert.equal(diffUsageManifests(beforeManifest, afterManifest).changed.length, 1); +}); + +test('earliest timestamp stops after the first valid JSONL record', async () => { + const { file } = await fixture(); + const first = '{"timestamp":"2026-02-03T04:05:06.000Z"}\n'; + await writeFile(file, first + 'x'.repeat(512 * 1024)); + const result = await readEarliestTimestamp(file); + assert.equal(result.timestampMs, Date.parse('2026-02-03T04:05:06.000Z')); + assert.ok(result.bytesRead < 64 * 1024, `read ${result.bytesRead} bytes`); +}); + +test('a valid first JSONL record larger than 1 MiB keeps its timestamp', async () => { + const { file } = await fixture(); + const body = JSON.stringify({ + timestamp: '2026-02-03T04:05:06.000Z', + message: { content: '中🙂'.repeat(180_000) }, + }) + '\n'; + await writeFile(file, body + '{"timestamp":"2026-03-01T00:00:00.000Z"}\n'); + const result = await readEarliestTimestamp(file); + assert.equal(result.timestampMs, Date.parse('2026-02-03T04:05:06.000Z')); + assert.ok(result.bytesRead > 1024 * 1024); +}); + +test('timestamp probing is bounded when no valid timestamp exists', async () => { + const { file } = await fixture(); + const content = 'not-json\n'.repeat(150_000); + await writeFile(file, content); + await assert.rejects( + () => readEarliestTimestamp(file), + { name: 'TimestampProbeLimitError' }, + ); +}); + +test('mapWithConcurrency never starts more than eight readers', async () => { + let active = 0; + let peak = 0; + const result = await mapWithConcurrency( + Array.from({ length: 32 }, (_, i) => i), + 8, + async (value) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setImmediate(resolve)); + active -= 1; + return value * 2; + }, + ); + assert.equal(peak, 8); + assert.deepEqual(result, Array.from({ length: 32 }, (_, i) => i * 2)); +}); + +test('equal or invalid timestamps preserve v2.2.0 discovery order', async () => { + const { root } = await fixture(); + const project = path.join(root, 'projects', '-tmp-demo'); + const z = path.join(project, 'z.jsonl'); + const a = path.join(project, 'a.jsonl'); + const invalid = path.join(project, 'invalid.jsonl'); + const invalid2 = path.join(project, 'invalid-2.jsonl'); + await writeFile(z, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + await writeFile(a, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + await writeFile(invalid, '{"timestamp":"not-a-date"}\n'); + await writeFile(invalid2, 'not-json\n'); + + const make = async (file: string, discoveryIndex: number) => { + const fileStat = await stat(file); + return { + path: file, + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + discoveryIndex, + dev: fileStat.dev > 0 && fileStat.ino > 0 ? fileStat.dev : undefined, + ino: fileStat.dev > 0 && fileStat.ino > 0 ? fileStat.ino : undefined, + }; + }; + + const equal = await sortUsageFilesByEarliestTimestamp([ + await make(z, 0), + await make(a, 1), + ]); + assert.deepEqual(equal.files, [z, a]); + + const invalidPair = await sortUsageFilesByEarliestTimestamp([ + await make(invalid, 0), + await make(invalid2, 1), + ]); + assert.deepEqual(invalidPair.files, [invalid, invalid2]); +}); diff --git a/src/test/dataLoader.test.ts b/src/test/dataLoader.test.ts index b56992f8..697c5bf2 100644 --- a/src/test/dataLoader.test.ts +++ b/src/test/dataLoader.test.ts @@ -1,9 +1,19 @@ -import { test } from 'node:test'; +import { after, test } from 'node:test'; import * as assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { scanUsageManifest } from '../claudeUsageFiles'; import { ClaudeDataLoader } from '../dataLoader'; import { ClaudeUsageRecord } from '../types'; +const tempRoots: string[] = []; + +after(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))); +}); + function record(model: string): ClaudeUsageRecord { return { timestamp: new Date().toISOString(), @@ -20,3 +30,50 @@ test('getCurrentContextInfo reports a 1M window for Sonnet 5', () => { assert.equal(info!.windowTokens, 1_000_000); assert.equal(info!.estimated, false); }); + +test('an injected manifest is authoritative and returns anonymous load counters', async () => { + const manifestRoot = await mkdtemp(path.join(os.tmpdir(), 'ccu-loader-manifest-')); + const emptyArgumentRoot = await mkdtemp(path.join(os.tmpdir(), 'ccu-loader-empty-')); + tempRoots.push(manifestRoot, emptyArgumentRoot); + const project = path.join(manifestRoot, 'projects', '-tmp-project'); + await mkdir(project, { recursive: true }); + const line = JSON.stringify({ + type: 'assistant', + timestamp: '2026-07-17T00:00:00.000Z', + requestId: 'request-1', + message: { + id: 'message-1', + model: 'claude-sonnet-4-5', + usage: { input_tokens: 10, output_tokens: 2 }, + }, + }) + '\n'; + await writeFile(path.join(project, 'session.jsonl'), line); + const manifest = await scanUsageManifest([manifestRoot]); + + const loaded = await ClaudeDataLoader.loadUsageRecords(emptyArgumentRoot, { + analyzeContent: false, + manifest, + }); + + assert.equal(loaded.records.length, 1); + assert.equal(loaded.diagnostics.filesDiscovered, 1); + assert.ok(loaded.diagnostics.bytesRead >= Buffer.byteLength(line, 'utf8')); + assert.equal(loaded.diagnostics.linesParsed, 1); + assert.equal(loaded.diagnostics.filesFailed, 0); +}); + +test('a file that disappears after manifest scan marks the load incomplete', async () => { + const manifestRoot = await mkdtemp(path.join(os.tmpdir(), 'ccu-loader-race-')); + tempRoots.push(manifestRoot); + const project = path.join(manifestRoot, 'projects', '-tmp-project'); + await mkdir(project, { recursive: true }); + const file = path.join(project, 'vanishing.jsonl'); + await writeFile(file, '{"timestamp":"2026-07-17T00:00:00.000Z"}\n'); + const manifest = await scanUsageManifest([manifestRoot]); + await rm(file); + const loaded = await ClaudeDataLoader.loadUsageRecords(manifestRoot, { + analyzeContent: false, + manifest, + }); + assert.equal(loaded.diagnostics.filesFailed, 1); +}); diff --git a/src/test/quotaFormat.test.ts b/src/test/quotaFormat.test.ts index 18127bee..f0258c87 100644 --- a/src/test/quotaFormat.test.ts +++ b/src/test/quotaFormat.test.ts @@ -53,6 +53,36 @@ test('compactReset: hours under a day, days beyond, 0h past, "" invalid', () => assert.equal(compactReset('not-a-date', NOW), ''); }); +test('compactReset: "units" format gives whole hour/minute or day/hour', () => { + assert.equal(compactReset(at(4.8 * H), NOW, 'units'), '4h 48m'); + assert.equal(compactReset(at(38.4 * H), NOW, 'units'), '1d 14h'); + assert.equal(compactReset(at(0.5 * H), NOW, 'units'), '30m'); + assert.equal(compactReset(at(-1 * H), NOW, 'units'), '0m'); + assert.equal(compactReset('not-a-date', NOW, 'units'), ''); +}); + +test('compactReset: "clock" format gives local time (< 24h) or local date (>= 24h)', () => { + const soon = new Date(NOW + 4.8 * H); + const later = new Date(NOW + 38.4 * H); + const pad = (n: number) => String(n).padStart(2, '0'); + const expectedTime = `${pad(soon.getHours())}:${pad(soon.getMinutes())}`; + const expectedDate = `${later.getFullYear()}-${pad(later.getMonth() + 1)}-${pad(later.getDate())}`; + assert.equal(compactReset(at(4.8 * H), NOW, 'clock'), expectedTime); + assert.equal(compactReset(at(38.4 * H), NOW, 'clock'), expectedDate); + assert.equal(compactReset('not-a-date', NOW, 'clock'), ''); +}); + +test('showResetInStatusBar honours resetFormat', () => { + const s = formatQuotaStatusText(live, { + showReset: true, + fiveHourOnly: false, + showOpusWeekly: false, + resetFormat: 'units', + now: NOW, + }); + assert.equal(s, '5h 6% ↻4h 48m | wk 1% ↻1d 14h'); +}); + test('worstShownUtilisation honours fiveHourOnly and showOpusWeekly', () => { assert.equal(worstShownUtilisation(live, { showReset: false, fiveHourOnly: false, showOpusWeekly: false }), 6); assert.equal(worstShownUtilisation(live, { showReset: false, fiveHourOnly: false, showOpusWeekly: true }), 12); diff --git a/src/test/refreshDiagnostics.test.ts b/src/test/refreshDiagnostics.test.ts new file mode 100644 index 00000000..a8262847 --- /dev/null +++ b/src/test/refreshDiagnostics.test.ts @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { formatRefreshDiagnostic } from '../refreshDiagnostics'; + +test('refresh diagnostics contain only stage names and anonymous numeric counters', () => { + const line = formatRefreshDiagnostic({ + trigger: 'watch', + filesDiscovered: 389, + filesChanged: 1, + filesReused: 388, + filesRemoved: 0, + filesFailed: 0, + bytesRead: 4096, + linesParsed: 17, + watcherEvents: 42, + coalescedTriggers: 3, + manifestMs: 12.34, + readParseMs: 45.67, + aggregateRenderMs: 8.9, + totalMs: 67.01, + }); + assert.equal( + line, + 'refresh: trigger=watch files(discovered=389 changed=1 reused=388 removed=0 failed=0) ' + + 'io(bytes=4096 lines=17) events(watcher=42 coalesced=3) ' + + 'ms(manifest=12.3 read-parse=45.7 aggregate-render=8.9 total=67.0)' + ); + assert.equal(/[/\\]|secret|session|prompt|credential|\.jsonl/i.test(line), false); +}); diff --git a/src/test/refreshPolicy.test.ts b/src/test/refreshPolicy.test.ts new file mode 100644 index 00000000..14145dba --- /dev/null +++ b/src/test/refreshPolicy.test.ts @@ -0,0 +1,155 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { + commitRefreshSnapshot, + LIVE_REFRESH_SECONDS, + mergeRefreshTrigger, + pollIntervalMs, + QuietDebounce, + RefreshSingleFlight, + reportColdRefreshFailure, + shouldCommitUsageLoad, + shouldReloadUsage, +} from '../refreshPolicy'; + +test('poll interval always honors refreshInterval and never applies an active override', () => { + assert.equal(pollIntervalMs(30), 30_000); + assert.equal(pollIntervalMs(60), 60_000); + assert.equal(pollIntervalMs(300), 300_000); + assert.equal(pollIntervalMs(900), 900_000); + assert.equal(pollIntervalMs(3600), 3_600_000); +}); + +test('live refresh keeps the 2-second default choices and adds long quiet delays', () => { + assert.deepEqual(LIVE_REFRESH_SECONDS, ['0', '1', '2', '5', '10', '20', '30', '60', '120', '300']); +}); + +test('coalescing retains the strongest pending trigger', () => { + assert.equal(mergeRefreshTrigger(null, 'poll'), 'poll'); + assert.equal(mergeRefreshTrigger('poll', 'watch'), 'watch'); + assert.equal(mergeRefreshTrigger('watch', 'focus'), 'focus'); + assert.equal(mergeRefreshTrigger('settings', 'manual'), 'manual'); + assert.equal(mergeRefreshTrigger('manual', 'poll'), 'manual'); +}); + +test('quiet debounce collapses a write burst into one callback', () => { + const scheduled: Array<() => void> = []; + const delays: number[] = []; + const cleared: number[] = []; + const debounce = new QuietDebounce( + (callback, ms) => { + scheduled.push(callback); + delays.push(ms); + return scheduled.length as unknown as NodeJS.Timeout; + }, + (handle) => { cleared.push(handle as unknown as number); } + ); + let fired = 0; + debounce.push(2_000, () => { fired += 1; }); + debounce.push(300_000, () => { fired += 1; }); + debounce.push(60_000, () => { fired += 1; }); + assert.deepEqual(delays, [2_000, 300_000, 60_000]); + assert.deepEqual(cleared, [1, 2]); + scheduled[2](); + assert.equal(fired, 1); +}); + +test('single-flight collapses a burst to one strongest follow-up', () => { + const gate = new RefreshSingleFlight(); + assert.deepEqual(gate.request(false, 'poll'), { forceReload: false, trigger: 'poll' }); + for (let i = 0; i < 50; i += 1) { + assert.equal(gate.request(false, 'watch'), null); + } + assert.equal(gate.request(true, 'manual'), null); + assert.deepEqual(gate.complete(), { forceReload: true, trigger: 'manual' }); + + // The returned follow-up owns the gate; a trigger arriving before it runs is + // queued for one later pass instead of starting in parallel. + assert.equal(gate.request(false, 'focus'), null); + assert.deepEqual(gate.complete(), { forceReload: false, trigger: 'focus' }); + assert.equal(gate.complete(), null); + assert.deepEqual(gate.request(false, 'poll'), { forceReload: false, trigger: 'poll' }); +}); + +test('a successfully loaded empty corpus reuses its non-null manifest', () => { + assert.equal(shouldReloadUsage({ + forceReload: false, + directoryChanged: false, + hasLoadedManifest: true, + changedFiles: 0, + removedFiles: 0, + }), false); + assert.equal(shouldReloadUsage({ + forceReload: false, + directoryChanged: false, + hasLoadedManifest: false, + changedFiles: 0, + removedFiles: 0, + }), true); +}); + +test('an incomplete file load cannot commit the new manifest', () => { + assert.equal(shouldCommitUsageLoad(0), true); + assert.equal(shouldCommitUsageLoad(1), false); +}); + +test('a scanner failure preserves the old snapshot and the next trigger retries', async () => { + const oldManifest = { marker: 'old-manifest' }; + const oldRecords = [{ marker: 'old-records' }]; + let currentManifest = oldManifest; + let currentRecords = oldRecords; + let scans = 0; + let failScan = true; + + const refresh = async (): Promise => { + scans += 1; + if (failScan) { + const error = new Error('permission denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; + } + commitRefreshSnapshot( + { marker: 'new-manifest' }, + [{ marker: 'new-records' }], + 0, + (manifest, records) => { + currentManifest = manifest; + currentRecords = records; + } + ); + }; + + await assert.rejects(refresh, { code: 'EACCES' }); + assert.equal(currentManifest, oldManifest); + assert.equal(currentRecords, oldRecords); + failScan = false; + await refresh(); + assert.equal(scans, 2); + assert.equal(currentManifest.marker, 'new-manifest'); + assert.equal(currentRecords[0].marker, 'new-records'); +}); + +test('cold refresh failure clears loading through status and optional webview error paths', () => { + const calls: string[] = []; + assert.equal(reportColdRefreshFailure({ + hasLoadedManifest: false, + updateWebview: true, + error: 'localized refresh failure', + onStatusError: (error) => calls.push(`status:${error}`), + onWebviewError: (error) => calls.push(`webview:${error}`), + }), true); + assert.deepEqual(calls, [ + 'status:localized refresh failure', + 'webview:localized refresh failure', + ]); + + calls.length = 0; + assert.equal(reportColdRefreshFailure({ + hasLoadedManifest: true, + updateWebview: true, + error: 'do not replace warm snapshot', + onStatusError: (error) => calls.push(error), + onWebviewError: (error) => calls.push(error), + }), false); + assert.deepEqual(calls, []); +}); diff --git a/src/test/repositoryPolicy.test.ts b/src/test/repositoryPolicy.test.ts new file mode 100644 index 00000000..da14ca62 --- /dev/null +++ b/src/test/repositoryPolicy.test.ts @@ -0,0 +1,126 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const REPO_ROOT = resolve(__dirname, '..', '..'); + +function repoFile(relativePath: string): string { + return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8'); +} + +function activePatterns(relativePath: string): Set { + return new Set( + repoFile(relativePath) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')), + ); +} + +test('AGENTS is the canonical Codex repository policy', () => { + const agents = repoFile('AGENTS.md'); + assert.match(agents, /Claude-only in v2\.2\.1/); + assert.match(agents, /no new runtime dependencies/i); + assert.match(agents, /all eight UI locales/i); + assert.match(agents, /push, open a pull request, merge, or publish a release/i); + assert.match(agents, /Generated with \[OpenAI Codex\]/); +}); + +test('AGENTS links a faithful Simplified-Chinese review copy', () => { + const agents = repoFile('AGENTS.md'); + const chinese = repoFile('AGENTS.zh-CN.md'); + assert.match(agents, /AGENTS\.zh-CN\.md/); + assert.match(chinese, /v2\.2\.1 保持 Claude-only/); + assert.match(chinese, /中文链接排在英文链接之前/); + assert.match(chinese, /推送、创建 PR、合并或发布 Release/); +}); + +test('contributor pull requests retain their merged attribution', () => { + const agents = repoFile('AGENTS.md'); + const chinese = repoFile('AGENTS.zh-CN.md'); + assert.match(agents, /Never copy.*contributor pull request.*close.*superseded/is); + assert.match(agents, /merge the\s+contributor's original pull request/i); + assert.match(agents, /needs revision.*original PR branch.*then merge/is); + assert.match(agents, /Close.*without merging.*only.*no\s+meaningful contribution.*empty.*spam.*irrelevant/is); + assert.match(chinese, /严禁.*贡献者 PR.*吸收.*关闭/is); + assert.match(chinese, /合并贡献者的原始 PR/); + assert.match(chinese, /不够合理.*原 PR 分支.*修改后合并/is); + assert.match(chinese, /不合并而关闭.*无可保留价值.*空 PR.*spam.*无关/is); +}); + +test('CLAUDE is a compatibility entry point, not a conflicting policy source', () => { + const claude = repoFile('CLAUDE.md'); + assert.match(claude, /AGENTS\.md.*canonical repository policy/); + assert.match(claude, /polling always follows\s+`refreshInterval`/); + assert.doesNotMatch(claude, /activity-aware: ~15 s|never writes to `~\/\.claude\/`/); +}); + +test('line endings and tracked file modes are repository-safe', () => { + const attributes = repoFile('.gitattributes'); + assert.match(attributes, /^\* text=auto eol=lf$/m); + for (const binary of ['*.png binary', '*.jpg binary', '*.jpeg binary', '*.gif binary', '*.webp binary', '*.ico binary', '*.vsix binary']) { + assert.match(attributes, new RegExp(`^${binary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm')); + } + + const badModes = execFileSync('git', ['ls-files', '--stage'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + .trim() + .split('\n') + .filter(Boolean) + .filter((line) => !line.startsWith('100644 ')); + assert.deepEqual(badModes, []); +}); + +test('git and VSIX ignores exclude private and development-only material', () => { + const gitIgnore = activePatterns('.gitignore'); + for (const pattern of ['out', 'node_modules', '*.vsix', '.env', '.env.*', 'secrets.json', 'CLAUDE.local.md', 'docs/', '.claude/', '.agents/', '.codex/', '.worktrees/']) { + assert.ok(gitIgnore.has(pattern), `.gitignore missing ${pattern}`); + } + + const vscodeIgnore = activePatterns('.vscodeignore'); + for (const pattern of ['.github/**', 'src/**', 'out/test/**', 'AGENTS.md', 'AGENTS.zh-CN.md', 'CLAUDE.md', 'CLAUDE.local.md', 'CONTRIBUTING.md', 'docs/**', '.claude/**', '.agents/**', '.codex/**', '.worktrees/**', '.env', '**/.env', '**/.env.*', '**/secrets.json', '**/*.pem', '**/*.key', '**/*.p12', '**/*.pfx']) { + assert.ok(vscodeIgnore.has(pattern), `.vscodeignore missing ${pattern}`); + } +}); + +test('all seven README files credit both development tools', () => { + const readmes = [ + 'README.md', + 'README-en.md', + 'README-zh-CN.md', + 'README-zh-TW.md', + 'README-ja.md', + 'README-ko.md', + 'README-id.md', + ]; + for (const readme of readmes) { + const body = repoFile(readme); + assert.match(body, /https:\/\/claude\.com\/claude-code/, `${readme} missing Claude Code credit`); + assert.match(body, /https:\/\/developers\.openai\.com\/codex\//, `${readme} missing OpenAI Codex credit`); + } +}); + +test('pull request checklist names the actual eight UI locales', () => { + const packageJson = JSON.parse(repoFile('package.json')) as { + contributes: { configuration: { properties: Record } }; + }; + const languageValues = packageJson.contributes.configuration.properties['claudeCodeUsage.language'].enum ?? []; + assert.equal(languageValues.filter((value) => value !== 'auto').length, 8); + + const template = repoFile('.github/PULL_REQUEST_TEMPLATE.md'); + assert.match(template, /all eight UI locales/); + assert.doesNotMatch(template, /all seven languages/); + assert.match(template, /all seven README editions/); +}); + +test('changelog records the released baseline and the v2.2.1 tooling transition', () => { + const changelog = repoFile('CHANGELOG.md'); + assert.match(changelog, /^## \[2\.2\.1\] — Unreleased$/m); + assert.match(changelog, /^## \[2\.2\.0\] — 2026-07-07$/m); + assert.match(changelog, /OpenAI Codex/); + assert.doesNotMatch(changelog, /^## \[2\.2\.0\] — Unreleased$/m); +}); diff --git a/src/test/usageDateLabels.test.ts b/src/test/usageDateLabels.test.ts new file mode 100644 index 00000000..c790edd5 --- /dev/null +++ b/src/test/usageDateLabels.test.ts @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { formatUsageDate, shortUsageDate } from '../usageDateLabels'; + +test('the first day of a month remains a daily label outside monthly views', () => { + assert.equal(shortUsageDate('2026-07-01'), '7/1'); + assert.equal( + formatUsageDate('2026-07-01', 'en-US', { year: 'numeric', month: 'numeric', day: 'numeric' }), + '7/1/2026' + ); +}); + +test('monthly views explicitly render month labels', () => { + assert.equal(shortUsageDate('2026-07-01', true), '2026/07'); + assert.equal(formatUsageDate('2026-07-01', 'en-US', {}, true), 'July 2026'); +}); + +test('date keys are rendered verbatim instead of shifting in behind-UTC zones', () => { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + timeZone: 'America/New_York', + }; + assert.equal(formatUsageDate('2026-06-01', 'en-US', options, true), 'June 2026'); +}); diff --git a/src/types.ts b/src/types.ts index a147250a..d46ec5a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -287,6 +287,8 @@ export interface ExtensionConfig { // Quota status-bar display (V2.2): inline reset countdown; 5h-only. showResetInStatusBar: boolean; quotaFiveHourOnly: boolean; + // Format of the reset countdown appended to the quota item (issue #74). + resetCountdownFormat: 'decimal' | 'units' | 'clock'; // Fetch real 5-hour / weekly limit utilisation via Claude Code's OAuth session. usageLimitTracking: boolean; // LLM "usage advice" feature (OpenAI-compatible endpoint, e.g. DeepSeek). @@ -314,9 +316,7 @@ export interface ExtensionConfig { // - 'folder' group by the heuristic top-level project folder only // - 'flat' no grouping; every working directory is its own row projectGroupingMode: 'git' | 'folder' | 'flat'; - // Live refresh delay (seconds) after new activity in the LOCAL log files. - // 0 = off (fall back to the interval-based refresh). Values: 0/1/2/5/10/20/30. - // Only re-reads local files — the /usage quota fetch is throttled separately. + // Quiet period after the last JSONL watcher event; 0 disables watching. fileWatchSeconds: number; // Skip the dashboard webview on auto-refreshes (status bar still updates). // Use when the constantly-reloading dashboard interferes with reading @@ -335,7 +335,7 @@ export interface ModelPricing { cache_read_input_token_cost?: number; } -export type SupportedLanguage = 'en' | "de-DE" | 'zh-TW' | 'zh-CN' | 'ja' | 'ko' | 'pt-BR'; +export type SupportedLanguage = 'en' | "de-DE" | 'zh-TW' | 'zh-CN' | 'ja' | 'ko' | 'pt-BR' | 'id'; // One multi-agent run. Two kinds (verified on disk 2026-06-12): // - a dynamic-workflow run (wf_ dir; trigger word "ultracode"), or diff --git a/src/usageDateLabels.ts b/src/usageDateLabels.ts new file mode 100644 index 00000000..17ea4024 --- /dev/null +++ b/src/usageDateLabels.ts @@ -0,0 +1,24 @@ +export function shortUsageDate(dateString: string, monthly = false): string { + const [year, month, day] = dateString.split('-').map(Number); + if (monthly) { + return `${year}/${String(month).padStart(2, '0')}`; + } + return `${month}/${day}`; +} + +export function formatUsageDate( + dateString: string, + locale: string, + dailyOptions: Intl.DateTimeFormatOptions, + monthly = false, +): string { + // Usage keys are already bucketed in the configured zone. Render the key + // itself in UTC so negative offsets cannot roll it into the previous day or + // month (#54), and use an explicit monthly flag so July 1 remains a day. + const [year, month, day] = dateString.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day, 12)); + const options: Intl.DateTimeFormatOptions = monthly + ? { year: 'numeric', month: 'long' } + : dailyOptions; + return date.toLocaleDateString(locale, { ...options, timeZone: 'UTC' }); +} diff --git a/src/webview.ts b/src/webview.ts index d0531a5b..2f7e9900 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -9,6 +9,7 @@ import { DEFAULT_SECTIONS, ShareSections, buildShareCardData, shareCardFilename import { renderShareCardSvg, ShareCardTheme } from './shareCardSvg'; import { parseConversation } from './conversationLog'; import { renderConversationViewer } from './conversationViewerHtml'; +import { formatUsageDate, shortUsageDate } from './usageDateLabels'; import * as os from 'os'; import * as path from 'path'; import * as https from 'https'; @@ -1416,7 +1417,7 @@ export class UsageWebviewProvider { ${this.renderCompositionChart( [...this.dailyDataForAllTime] .sort((a, b) => a.date.localeCompare(b.date)) - .map((d) => ({ label: this.getShortDate(d.date), data: d.data, key: d.date })) + .map((d) => ({ label: this.getShortDate(d.date, true), data: d.data, key: d.date })) )}
@@ -1439,7 +1440,7 @@ export class UsageWebviewProvider { .map( ({ date, data }) => ` - ${this.formatDate(date)} + ${this.formatDate(date, true)} ${I18n.formatCurrency(data.totalCost)} ${I18n.formatNumber(data.totalInputTokens)} ${I18n.formatNumber(data.totalOutputTokens)} @@ -1709,6 +1710,8 @@ export class UsageWebviewProvider { // first→last span. Computed once for the whole table. const activeMap = ClaudeDataLoader.activeDurationBySession(this.allRecords); let rows = ''; + // Distinct short model names across all sessions → the model filter dropdown. + const modelSet = new Set(); this.sessionBreakdown.forEach((s) => { const d = s.data; const foreign = workspacePath @@ -1745,9 +1748,13 @@ export class UsageWebviewProvider { : thinkingHidden ? t.thinkingHiddenShort : '-'; + // Distinct short model names for this session → the model filter matches on these. + const rowModels = [...new Set(Object.keys(d.modelBreakdown || {}).map((m) => this.shortModelName(m)))]; + rowModels.forEach((m) => modelSet.add(m)); rows += ' '' + label + ''; - // Filter the list to the current project (default) or all. + // Filter bar above the table: time range · project · model. All three narrow the + // list client-side; the list now defaults to ALL sessions and ALL projects. const total = this.sessionBreakdown.length; const foreignCount = total - currentCount; const showToggle = !!workspacePath && currentCount > 0 && foreignCount > 0; - const filterBar = showToggle - ? '
' + - '' + - '' + - '
' - : ''; + const modelOptions = [...modelSet] + .sort() + .map((m) => '') + .join(''); + const filterBar = + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + (showToggle + ? '
' + + '' + + '' + + '
' + : '') + + (modelOptions + ? '' + : '') + + '
'; return ( '
' + '

' + t.sessionBreakdown + '

' + '

' + t.sortHint + '

' + filterBar + - '
' + + '
' + '
' + '' + '' + @@ -3228,7 +3253,7 @@ export class UsageWebviewProvider { private renderAllTimeChart(): string { const sortedData = [...this.dailyDataForAllTime].sort((a, b) => a.date.localeCompare(b.date)); - return this.renderMainCostChart(sortedData); + return this.renderMainCostChart(sortedData, true); } /** @@ -3266,7 +3291,7 @@ export class UsageWebviewProvider { ); } - private renderMainCostChart(sortedData: { date: string; data: UsageData }[]): string { + private renderMainCostChart(sortedData: { date: string; data: UsageData }[], monthly = false): string { if (sortedData.length === 0) { return '
No data available
'; } @@ -3293,7 +3318,7 @@ export class UsageWebviewProvider { 'data-cost-output="' + cb.output + '" ' + 'data-cost-cachewrite="' + cb.cacheWrite + '" ' + 'data-cost-cacheread="' + cb.cacheRead + '" ' + - 'title="' + this.escapeHtml(this.formatDate(date)) + ': ' + I18n.formatCurrency(data.totalCost) + '">' + + 'title="' + this.escapeHtml(this.formatDate(date, monthly)) + ': ' + I18n.formatCurrency(data.totalCost) + '">' + costStack(data, barHeight) + '' + '' @@ -3302,7 +3327,7 @@ export class UsageWebviewProvider { .join(''); const xlabels = sortedData - .map(({ date }) => '
' + this.getShortDate(date) + '
') + .map(({ date }) => '
' + this.getShortDate(date, monthly) + '
') .join(''); return ( @@ -3387,25 +3412,12 @@ export class UsageWebviewProvider { ); } - private getShortDate(dateString: string): string { - // Parse 'YYYY-MM-DD' textually — new Date('YYYY-MM-DD') is UTC midnight, - // which shifts the displayed day back by one in negative-UTC timezones. - const [y, m, d] = dateString.split('-').map(Number); - // Month-only dates (first of month) label as YYYY/MM for monthly charts. - if (dateString.endsWith('-01')) { - return `${y}/${String(m).padStart(2, '0')}`; - } - return `${m}/${d}`; + private getShortDate(dateString: string, monthly = false): string { + return shortUsageDate(dateString, monthly); } - private formatDate(dateString: string): string { - const date = new Date(dateString); - // Check if this is a month-only date (ends with -01) - if (dateString.endsWith('-01')) { - return date.toLocaleDateString(I18n.getLocale(), I18n.dateFormatOptions({ year: 'numeric', month: 'long' })); - } - // Standard date formatting for daily data, locale + timezone aware. - return date.toLocaleDateString(I18n.getLocale(), I18n.dateFormatOptions()); + private formatDate(dateString: string, monthly = false): string { + return formatUsageDate(dateString, I18n.getLocale(), I18n.dateFormatOptions(), monthly); } private getStyles(): string { @@ -3794,11 +3806,17 @@ export class UsageWebviewProvider { border-color: var(--vscode-errorForeground, #f44336); } - /* Sessions "current project / all" filter. */ + /* Sessions filter bar: time range · project · model. */ + .sess-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0 0 10px; + } .sess-filter { display: flex; gap: 6px; - margin: 0 0 10px; } .sess-filter-btn { background: none; @@ -3814,8 +3832,13 @@ export class UsageWebviewProvider { color: var(--vscode-button-foreground); border-color: var(--vscode-button-background); } - .session-list.show-current .session-foreign { - display: none; + .sess-model-select { + background: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; } /* iOS-style auto-refresh toggle. The label/switch sit next to the other @@ -5392,28 +5415,81 @@ function viewConversation(btn) { }); } -function applySessionFilter(mode) { +function ccuSessState() { + var pb = document.querySelector('.sess-filter[data-group="project"] .sess-filter-btn.active'); + var rb = document.querySelector('.sess-filter[data-group="range"] .sess-filter-btn.active'); + var ms = document.querySelector('.sess-model-select'); + return { + project: pb ? pb.getAttribute('data-mode') : 'all', + range: rb ? rb.getAttribute('data-range') : 'all', + model: ms ? ms.value : 'all' + }; +} +function applySessionFilters() { var list = document.getElementById('sessionList'); if (!list) { return; } - list.classList.toggle('show-current', mode === 'current'); - document.querySelectorAll('.sess-filter-btn').forEach(function(b) { - b.classList.toggle('active', b.getAttribute('data-mode') === mode); + var st = ccuSessState(); + var now = Date.now(); + var threshold = st.range === 'today' ? new Date().setHours(0, 0, 0, 0) + : st.range === '7' ? now - 7 * 86400000 + : st.range === '30' ? now - 30 * 86400000 + : 0; + list.querySelectorAll('tr.sort-row').forEach(function(tr) { + var okProject = st.project === 'all' || !tr.classList.contains('session-foreign'); + var okTime = st.range === 'all' || Number(tr.getAttribute('data-sort-time')) >= threshold; + var okModel = st.model === 'all' || (tr.getAttribute('data-models') || '').split('|').indexOf(st.model) !== -1; + tr.style.display = (okProject && okTime && okModel) ? '' : 'none'; }); } +// One handler per filter group; all persist and re-apply the combined filter. +function ccuSetActive(btn) { + var group = btn.parentNode; + if (!group) { return; } + group.querySelectorAll('.sess-filter-btn').forEach(function(b) { b.classList.remove('active'); }); + btn.classList.add('active'); +} function sessionFilter(btn) { if (!btn) { return; } - var mode = btn.getAttribute('data-mode'); - applySessionFilter(mode); - // Persist so the full-document reload (e.g. after a delete) keeps the filter. - try { localStorage.setItem('ccu.sessionFilter', mode); } catch (e) {} + ccuSetActive(btn); + try { localStorage.setItem('ccu.sessionFilter', btn.getAttribute('data-mode')); } catch (e) {} + applySessionFilters(); +} +function sessionRange(btn) { + if (!btn) { return; } + ccuSetActive(btn); + try { localStorage.setItem('ccu.sessionRange', btn.getAttribute('data-range')); } catch (e) {} + applySessionFilters(); +} +function sessionModel(sel) { + if (!sel) { return; } + try { localStorage.setItem('ccu.sessionModel', sel.value); } catch (e) {} + applySessionFilters(); } function restoreSessionFilter() { + // Restore each dimension from localStorage (survives the post-delete reload), + // then apply once. Defaults (nothing stored) = All range, All projects, All models. try { var mode = localStorage.getItem('ccu.sessionFilter'); - if (mode && document.querySelector('.sess-filter-btn[data-mode="' + mode + '"]')) { - applySessionFilter(mode); + if (mode) { + var pb = document.querySelector('.sess-filter[data-group="project"] .sess-filter-btn[data-mode="' + mode + '"]'); + if (pb) { ccuSetActive(pb); } + } + var range = localStorage.getItem('ccu.sessionRange'); + if (range) { + var rb = document.querySelector('.sess-filter[data-group="range"] .sess-filter-btn[data-range="' + range + '"]'); + if (rb) { ccuSetActive(rb); } + } + var model = localStorage.getItem('ccu.sessionModel'); + if (model) { + var ms = document.querySelector('.sess-model-select'); + if (ms) { + for (var i = 0; i < ms.options.length; i++) { + if (ms.options[i].value === model) { ms.value = model; break; } + } + } } } catch (e) {} + applySessionFilters(); } function copyPath(btn, e) {