From 03f95cd6761110e563b6c2705ba7a2c0218938d8 Mon Sep 17 00:00:00 2001 From: Atanas Oreshkov Date: Thu, 22 Oct 2026 21:13:57 +0300 Subject: [PATCH 1/3] token-audit 0.2.2: no raw timestamps, clean --json titles, count id-less sidechain usage - Timestamps that are not ISO date-times are counted in a warning and dropped, instead of reaching the text tables and the session header unsanitised. - --json titles and action names go through the same control/C1/bidi strip as prompts and commands. - Sidechain usage without message.id is keyed by line, as on the main path. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++++++ test/token-audit/cli.test.mjs | 23 +++++++++++++++---- test/token-audit/transcript.test.mjs | 17 ++++++++++++++ token-audit/.claude-plugin/plugin.json | 2 +- token-audit/skills/token-audit/reference.md | 6 +++-- .../skills/token-audit/scripts/audit.mjs | 4 +++- .../skills/token-audit/scripts/lib/format.mjs | 23 +++++++++++-------- .../token-audit/scripts/lib/transcript.mjs | 21 ++++++++++++----- 8 files changed, 83 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e755c10..f9758af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## token-audit 0.2.2 + +- A record whose `timestamp` is not an ISO date and time is counted in one warning and + left out, as a malformed `version` already was. Its first characters went straight + into the `started` and `when` columns and the session header, newlines and escapes + included. +- `--json` titles and action names lose control, C1 and bidi characters, as prompts and + commands already did. `JSON.stringify` escapes only C0 controls. +- Subagent usage written into a parent transcript without a message id is counted in the + subagent and session totals instead of being dropped. +- The `--json` shape is unchanged. + ## review-kit 0.2.1 - A run whose reporter gave no answer (skipped, or died on an API error) now says so in diff --git a/test/token-audit/cli.test.mjs b/test/token-audit/cli.test.mjs index 9dcab2f..103aa2a 100644 --- a/test/token-audit/cli.test.mjs +++ b/test/token-audit/cli.test.mjs @@ -294,7 +294,8 @@ test('control, escape and bidi characters from transcripts never reach the outpu { type: 'user', message: { content: `prompt ${HOSTILE}` }, timestamp: '2026-09-01T10:00:00Z' }, { type: 'assistant', message: { id: 'm1', content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: `echo ${HOSTILE}` } }], usage: { input_tokens: 5, output_tokens: 5 } } }, { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: 'r'.repeat(500) }] } }, - { type: 'assistant', message: { id: 'm2', content: [], usage: { input_tokens: 1, output_tokens: 1 } } }, + { type: 'user', message: { content: `later ${HOSTILE}` }, timestamp: `2026-09-01T11:00\n${HOSTILE}` }, + { type: 'assistant', message: { id: 'm2', content: [{ type: 'tool_use', id: 't2', name: `Tool ${HOSTILE}`, input: {} }], usage: { input_tokens: 1, output_tokens: 1 } } }, ] fs.writeFileSync(path.join(proj, 's.jsonl'), lines.map(l => JSON.stringify(l)).join('\n')) for (const cmd of ['rank', 'session', 'categories']) { @@ -303,11 +304,23 @@ test('control, escape and bidi characters from transcripts never reach the outpu assert.doesNotMatch(out, CONTROLS, `${cmd}: ${JSON.stringify(out)}`) for (const leftover of ['[31m', '8;;', '[0m']) assert.ok(!out.includes(leftover), `${cmd}: ${leftover} left in ${JSON.stringify(out)}`) } - const j = JSON.parse(run(['session', 's', '--project', proj, '--json'], { env: {}, cwd: t.dir }).stdout) - for (const s of [...j.prompts.map(p => p.text), ...j.results.map(r => r.what)]) { - assert.doesNotMatch(s, CONTROLS, JSON.stringify(s)) - } + const json = args => JSON.parse(run([...args, '--project', proj, '--json'], { env: {}, cwd: t.dir }).stdout) + const j = json(['session', 's']) + const r = json(['rank']) + const c = json(['categories', 's']) + const texts = [ + ...j.prompts.map(p => p.text), ...j.results.map(x => x.what), ...j.results.map(x => x.key), ...j.actions.map(x => x.key), + j.session.title, ...r.sessions.map(s => s.title), c.session.title, + ] + for (const s of texts) assert.doesNotMatch(s, CONTROLS, JSON.stringify(s)) assert.match(j.prompts[0].text, /^prompt a red link bell c1 evil$/) + assert.equal(j.session.title, 'title a red link bell c1 evil') + assert.equal(r.sessions[0].title, j.session.title) + assert.ok(j.actions.some(x => x.key === 'Tool a red link bell c1 evil'), JSON.stringify(j.actions)) + // The hostile timestamp is dropped and counted, never printed. + assert.equal(j.session.last, '2026-09-01T10:00:00Z') + assert.equal(j.prompts.find(p => p.text.startsWith('later')).ts, null) + assert.ok(j.warnings.includes('1 record(s) with a timestamp of unknown shape; their timestamp was ignored'), JSON.stringify(j.warnings)) } finally { t.done() } diff --git a/test/token-audit/transcript.test.mjs b/test/token-audit/transcript.test.mjs index 88d2461..0505478 100644 --- a/test/token-audit/transcript.test.mjs +++ b/test/token-audit/transcript.test.mjs @@ -87,6 +87,23 @@ test('versions of an unknown shape are counted, not kept', () => { assert.equal(p.stats.badVersions, 2) }) +test('timestamps of an unknown shape are counted, not kept', () => { + const rec = timestamp => JSON.stringify({ type: 'user', timestamp, message: { content: 'hi' } }) + const p = parseTranscript([ + rec('2026-09-01T10:00\n\u001b[2Jfake'), rec('2026-09-01T10:00:00.123Z'), rec(42), rec('2026-09-01T11:00:00+02:00'), rec('soon'), + ]) + assert.equal(p.stats.first, '2026-09-01T10:00:00.123Z') + assert.equal(p.stats.last, '2026-09-01T11:00:00+02:00') + assert.deepEqual(p.turns.map(t => t.ts), [null, '2026-09-01T10:00:00.123Z', null, '2026-09-01T11:00:00+02:00', null]) + assert.equal(p.stats.badTimestamps, 3) +}) + +test('sidechain usage without a message id is kept, not dropped', () => { + const side = id => JSON.stringify({ type: 'assistant', isSidechain: true, message: { ...(id ? { id } : {}), usage: { output_tokens: 7 } } }) + const p = parseTranscript([side(null), side(null), side('s1'), side('s1')]) + assert.deepEqual(p.sidechain.map(u => u.out), [7, 7, 7]) +}) + test('tolerates missing fields and unknown record types', () => { const p = parseTranscript([ '{"type":"assistant"}', diff --git a/token-audit/.claude-plugin/plugin.json b/token-audit/.claude-plugin/plugin.json index c627de1..84723fa 100644 --- a/token-audit/.claude-plugin/plugin.json +++ b/token-audit/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "token-audit", "displayName": "Token Audit", - "version": "0.2.1", + "version": "0.2.2", "description": "Ranks a project's Claude Code sessions by token use and breaks the costliest down by action, large tool result and prompt, with habits to change. Reads local session transcripts; needs Node 22+.", "author": { "name": "Atanas Oreshkov", diff --git a/token-audit/skills/token-audit/reference.md b/token-audit/skills/token-audit/reference.md index 093d038..1d08578 100644 --- a/token-audit/skills/token-audit/reference.md +++ b/token-audit/skills/token-audit/reference.md @@ -55,8 +55,10 @@ rest, and `--json` lists them all. ## Limits - Result sizes and carry are estimates; totals are not. -- Titles, prompts and commands are shown without terminal escapes, control characters - or bidi controls, so they cannot recolour, rewrite or reorder the output. +- Titles, prompts, commands and action names are shown without terminal escapes, control + characters or bidi controls, in text and in `--json`, so they cannot recolour, rewrite or + reorder the output. A timestamp that is not an ISO date and time is counted in a warning + and left out. - Only this machine's transcripts. For ongoing or team-wide tracking, Claude Code's OpenTelemetry export (`claude_code.token.usage`, `api_request` and `tool_result` events) is the supported source; `/usage` shows billing figures. diff --git a/token-audit/skills/token-audit/scripts/audit.mjs b/token-audit/skills/token-audit/scripts/audit.mjs index ac565be..3e7df4b 100644 --- a/token-audit/skills/token-audit/scripts/audit.mjs +++ b/token-audit/skills/token-audit/scripts/audit.mjs @@ -43,7 +43,7 @@ export function run(argv, { env = process.env, cwd = process.cwd(), read = readL stdout.push(USAGE) return { code: 0, stdout: stdout.join('\n'), stderr: '' } } - const ctx = { ...opts, config: configDir(opts.configDir, env), cwd, read, warnings: [], versions: new Set(), bad: 0, badVersions: 0, noUsage: 0 } + const ctx = { ...opts, config: configDir(opts.configDir, env), cwd, read, warnings: [], versions: new Set(), bad: 0, badVersions: 0, badTimestamps: 0, noUsage: 0 } const result = COMMANDS[opts.command](ctx) stdout.push(result) stderr.push(...finishWarnings(ctx).map(w => `token-audit: warning: ${w}`)) @@ -120,6 +120,7 @@ function load(ctx, file, options) { for (const v of parsed.stats.versions) ctx.versions.add(v) ctx.bad += parsed.stats.badLines ctx.badVersions += parsed.stats.badVersions + ctx.badTimestamps += parsed.stats.badTimestamps ctx.noUsage += parsed.stats.noUsage return parsed } @@ -178,6 +179,7 @@ function finishWarnings(ctx) { } if (ctx.bad) w.push(`${ctx.bad} unparsable transcript line(s) skipped`) if (ctx.badVersions) w.push(`${ctx.badVersions} record(s) with a version of unknown shape; their version was ignored`) + if (ctx.badTimestamps) w.push(`${ctx.badTimestamps} record(s) with a timestamp of unknown shape; their timestamp was ignored`) if (ctx.noUsage) w.push(`${ctx.noUsage} assistant record(s) without usage data skipped`) return w } diff --git a/token-audit/skills/token-audit/scripts/lib/format.mjs b/token-audit/skills/token-audit/scripts/lib/format.mjs index f4d1a88..eae3ac5 100644 --- a/token-audit/skills/token-audit/scripts/lib/format.mjs +++ b/token-audit/skills/token-audit/scripts/lib/format.mjs @@ -30,12 +30,15 @@ const fits = (t, n) => t.length <= n && Buffer.byteLength(t) <= n // joiner is a format character too, but it holds emoji together, so it stays. const BIDI = /[؜‎‏‪-‮⁦-⁩]/g // Whole escape sequences go first, so no `[31m` is left behind; other controls become -// spaces and collapse with the rest of the whitespace. Then at most n characters and n -// UTF-8 bytes, `...` included when cut, or always when `cut` says the text was already -// shortened. The cut falls between graphemes, so an emoji or an accented letter stays whole. +// spaces and collapse with the rest of the whitespace. JSON.stringify escapes C0 controls +// but not C1 or bidi ones, so --json text goes through this too. +export const clean = s => stripVTControlCharacters(String(s ?? '')).replace(/\p{Cc}/gu, ' ').replace(BIDI, '') + .replace(/\s+/g, ' ').trim().toWellFormed() +// Cleaned, then at most n characters and n UTF-8 bytes, `...` included when cut, or always +// when `cut` says the text was already shortened. The cut falls between graphemes, so an +// emoji or an accented letter stays whole. export const clip = (s, n, cut = false) => { - const t = stripVTControlCharacters(String(s ?? '')).replace(/\p{Cc}/gu, ' ').replace(BIDI, '') - .replace(/\s+/g, ' ').trim().toWellFormed() + const t = clean(s) if (!cut && fits(t, n)) return t if (fits(`${t}...`, n)) return `${t}...` let kept = '' @@ -90,7 +93,7 @@ export function rankJson(d) { const sessionRow = s => ({ rank: s.rank, id: s.id, - title: s.title, + title: clean(s.title), first: s.first, last: s.last, calls: s.totals.all.calls, @@ -141,7 +144,7 @@ export function sessionJson(d) { return { format: FORMAT, command: 'session', - session: { id: d.id, title: d.title, first: d.first, last: d.last, versions: d.versions }, + session: { id: d.id, title: clean(d.title), first: d.first, last: d.last, versions: d.versions }, weights: d.weights, totals: { calls: t.main.calls, input: round(t.main.in), cacheWrite: round(t.main.cw), cacheRead: round(t.main.cr), @@ -155,11 +158,11 @@ export function sessionJson(d) { }, parallelCalls: a.parallelCalls, actions: a.actions.slice(0, d.top.actions).map(x => ({ - key: x.key, category: x.category, uses: x.uses, tokens: round(x.tokens), share: round4(share(x.tokens, a.grand)), + key: clean(x.key), category: x.category, uses: x.uses, tokens: round(x.tokens), share: round4(share(x.tokens, a.grand)), output: round(x.output), resultTokensEst: round(x.resultTokens), carryEst: round(x.carry), })), results: a.results.slice(0, d.top.results).map(x => ({ - key: x.key, tokensEst: round(x.tokens), carryEst: round(x.carry), what: clip(x.what, COMMAND_WIDTH), + key: clean(x.key), tokensEst: round(x.tokens), carryEst: round(x.carry), what: clip(x.what, COMMAND_WIDTH), })), prompts: a.turns.slice(0, d.top.prompts).map(x => ({ text: clip(x.text, PROMPT_WIDTH, x.cut), ts: x.ts, calls: x.calls, tokens: round(x.tokens) })), warnings: d.warnings, @@ -209,7 +212,7 @@ export function categoriesJson(d) { return { format: FORMAT, command: 'categories', - session: { id: d.id, title: d.title }, + session: { id: d.id, title: clean(d.title) }, totals: { calls: a.totals.main.calls, total: round(a.totals.main.total) }, parallelCalls: a.parallelCalls, categories: a.categories.map(c => ({ name: c.name, uses: c.uses, tokens: round(c.tokens), share: round4(c.share) })), diff --git a/token-audit/skills/token-audit/scripts/lib/transcript.mjs b/token-audit/skills/token-audit/scripts/lib/transcript.mjs index 9708ddb..0532e29 100644 --- a/token-audit/skills/token-audit/scripts/lib/transcript.mjs +++ b/token-audit/skills/token-audit/scripts/lib/transcript.mjs @@ -16,6 +16,11 @@ const PROMPT_SNIPPET = 110 // tag). Anything else is counted and left out rather than printed. const VERSION = /^\d{1,4}(\.\d{1,6}){1,3}([-+][0-9A-Za-z.-]{1,32})?$/ +// An ISO 8601 date and time, as transcripts record it (2026-09-01T10:00:00.000Z). Anything +// else is counted and left out rather than printed. +const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?$/ +const timestamp = v => (typeof v === 'string' && TIMESTAMP.test(v) ? v : null) + // Returns { calls, toolUses, turns, sidechain, stats }. // - calls: one per API response (deduped by message.id), in order of first appearance. // - toolUses: Map tool_use id -> { id, name, input, callIdx, result }. @@ -31,7 +36,7 @@ export function parseTranscript(lines, { sidechain = false } = {}) { const turns = [] const versions = new Set() const stats = { - lines: 0, badLines: 0, badVersions: 0, assistantRecords: 0, noUsage: 0, compactions: 0, + lines: 0, badLines: 0, badVersions: 0, badTimestamps: 0, assistantRecords: 0, noUsage: 0, compactions: 0, title: '', first: null, last: null, cwd: null, versions: [], } let segment = 0 @@ -55,9 +60,13 @@ export function parseTranscript(lines, { sidechain = false } = {}) { if (VERSION.test(o.version)) versions.add(o.version) else stats.badVersions++ } - if (typeof o.timestamp === 'string') { - if (!stats.first) stats.first = o.timestamp - stats.last = o.timestamp + if (o.timestamp != null) { + const ts = timestamp(o.timestamp) + if (!ts) stats.badTimestamps++ + else { + if (!stats.first) stats.first = ts + stats.last = ts + } } if (!stats.cwd && typeof o.cwd === 'string') stats.cwd = o.cwd @@ -88,7 +97,7 @@ export function parseTranscript(lines, { sidechain = false } = {}) { const usage = readUsage(msg.usage) if (!usage) stats.noUsage++ if (o.isSidechain && !sidechain) { - if (usage && msg.id) sideById.set(msg.id, usage) + if (usage) sideById.set(typeof msg.id === 'string' ? msg.id : `#${stats.lines}`, usage) break } const id = typeof msg.id === 'string' ? msg.id : `#${stats.lines}` @@ -129,7 +138,7 @@ function readUser(o, { calls, toolUses, turns, segment, turn }) { if (o.isMeta || o.isCompactSummary) return turn const text = promptText(content) if (text == null) return turn - const next = { text: text.slice(0, PROMPT_SNIPPET), cut: text.length > PROMPT_SNIPPET, ts: typeof o.timestamp === 'string' ? o.timestamp : null } + const next = { text: text.slice(0, PROMPT_SNIPPET), cut: text.length > PROMPT_SNIPPET, ts: timestamp(o.timestamp) } turns.push(next) return next } From cf6399ecaa8b9c4cac151a8583bb01d8a206682b Mon Sep 17 00:00:00 2001 From: Atanas Oreshkov Date: Thu, 22 Oct 2026 21:15:19 +0300 Subject: [PATCH 2/3] review-kit 0.2.2: bound every reporter and planner cause - A written:false detail goes through causeOf(), as the 0.2.1 notes promised. - The planner's error goes through causeOf() like every other catch. - A verify-only report says the earlier coverage is not available to it instead of guessing where it is recorded. - seeded-defects-real-run: a failed scaffold cleans up its workspace. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ review-kit/.claude-plugin/plugin.json | 2 +- review-kit/agents/reporter.md | 3 ++- review-kit/workflows/review.js | 8 +++++--- scripts/seeded-defects-real-run.mjs | 5 ++++- test/review.test.mjs | 22 ++++++++++++++++++++++ 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9758af..e30f686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## review-kit 0.2.2 + +- A reporter that answers `written: false` has its `detail` kept on one line and bounded + in `reportError` and the log, as 0.2.1 already promised. Only a thrown error was. +- A planner error is kept on one line and bounded in the log and in `detail`, like every + other cause. +- A verify-only report says that the earlier run's coverage gaps are not available to it + and are in that run's own result, instead of guessing where they are recorded. +- `scripts/seeded-defects-real-run.mjs`: a failed scaffold deletes the scratch workspace + (unless `--keep`) and leaves no empty results folder. + ## token-audit 0.2.2 - A record whose `timestamp` is not an ISO date and time is counted in one warning and diff --git a/review-kit/.claude-plugin/plugin.json b/review-kit/.claude-plugin/plugin.json index 5198206..23f1851 100644 --- a/review-kit/.claude-plugin/plugin.json +++ b/review-kit/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "review-kit", "displayName": "Review Kit", - "version": "0.2.1", + "version": "0.2.2", "description": "Multi-lens code review as a dynamic workflow: plans the lenses that fit the repo, fans out read-only reviewers per module x lens, adversarially verifies each finding, and writes one ranked, deduplicated report.", "author": { "name": "Atanas Oreshkov", diff --git a/review-kit/agents/reporter.md b/review-kit/agents/reporter.md index 8e12f57..98ada0a 100644 --- a/review-kit/agents/reporter.md +++ b/review-kit/agents/reporter.md @@ -59,7 +59,8 @@ these headings, in this order, so tools that read the report can find each secti selected, from the numbers given in the prompt, and the cause of each failure the prompt lists. State them plainly; a reader must not mistake this report for full coverage when it is not. Pairs not selected in a run that continues an earlier one are named as such, - not as gaps of that earlier run, when the prompt says so. + not as gaps of that earlier run, when the prompt says so. Never say where coverage you + were not given is recorded. Keep every section heading even when it is empty, and say in one line that it is empty. Never write a refuted or dismissed finding into the report, in any section: one that diff --git a/review-kit/workflows/review.js b/review-kit/workflows/review.js index 9b8982d..8f7185c 100644 --- a/review-kit/workflows/review.js +++ b/review-kit/workflows/review.js @@ -907,7 +907,9 @@ function reportPrompt(payload) { VERIFY_ONLY ? 'Verify-only run: no reviewer ran, so this run adds no module x lens coverage. ' + VERIFY_ONLY.length + ' findings an earlier run left unverified were sent to verifiers. ' + - 'The earlier run\'s own coverage gaps are not repeated here; say so in Coverage gaps.' + 'This run was given none of the earlier run\'s coverage. In Coverage gaps, say that the ' + + 'earlier run\'s coverage gaps are not available to this run and are in that run\'s own ' + + 'result; do not name a file or any other place they might be recorded.' : 'Module x lens pairs: ' + (c.unselectedPairs.length ? c.planned + ' selected of ' + (c.planned + c.unselectedPairs.length) + ' applicable' : c.planned + ' applicable') + ', ' + c.reviewed + ' reviewed successfully, ' + @@ -1104,7 +1106,7 @@ if (SUPPLIED) { } catch (err) { // Most likely an unresolvable agentType: the plugin is not loaded, or the // agents live somewhere else and need agentPrefix set. - planError = err && err.message ? err.message : String(err) + planError = causeOf(err) } if (!raw) { @@ -1676,7 +1678,7 @@ async function finish(plan, everyFinding, reviewCoverage) { written = true } else { reportError = answer - ? 'the reporter could not write the file' + (answer.detail ? ': ' + answer.detail : '') + ? 'the reporter could not write the file' + (answer.detail ? ': ' + causeOf(answer.detail) : '') : NO_ANSWER } } catch (err) { diff --git a/scripts/seeded-defects-real-run.mjs b/scripts/seeded-defects-real-run.mjs index acaacf8..57a609f 100644 --- a/scripts/seeded-defects-real-run.mjs +++ b/scripts/seeded-defects-real-run.mjs @@ -74,7 +74,6 @@ const graders = readdirSync(join(CASE, 'graders')) const stamp = new Date().toISOString().replace(/[:.]/g, '-') const OUT = join(PLUGIN, 'evals', 'results', 'real-run-' + stamp) -mkdirSync(OUT, { recursive: true }) // Outside review-kit/: writes inside a --plugin-dir are refused as sensitive. const WORK = mkdtempSync(join(tmpdir(), 'review-kit-real-run-')) @@ -82,8 +81,12 @@ console.log('workspace: ' + WORK) const scaffold = spawnSync('bash', [join(CASE, 'scaffold.sh').replace(/\\/g, '/')], { cwd: WORK, stdio: 'inherit' }) if (scaffold.status !== 0) { console.error('scaffold failed (exit ' + scaffold.status + (scaffold.error ? ', ' + scaffold.error.message : '') + ')') + // Nothing ran, so no results folder was made; the workspace goes as it would after a run. + if (KEEP) console.error('workspace kept: ' + WORK) + else rmSync(WORK, { recursive: true, force: true }) process.exit(2) } +mkdirSync(OUT, { recursive: true }) // The prompt goes in on stdin, not as an argument: it has newlines, which a // `.cmd` shim run through cmd.exe would split. diff --git a/test/review.test.mjs b/test/review.test.mjs index 695e1ba..2eab65d 100644 --- a/test/review.test.mjs +++ b/test/review.test.mjs @@ -241,6 +241,14 @@ test('planner that throws stops the run with planner-unavailable', async () => { assert.equal(of('reviewer').length, 0) }) +test('a planner error reaches the result and the log on one line and bounded', async () => { + const { result, logs } = await run({}, { ...quiet, planner: () => { throw new Error('no such agent\n' + 'x'.repeat(400)) } }) + assert.equal(result.reason, 'planner-unavailable') + assert.match(result.detail, /^no such agent\\u000ax+\.\.\.$/) + assert.ok(result.detail.length <= 303, String(result.detail.length)) + assert.ok(logs.some(l => l.includes(result.detail))) +}) + test('planner that returns null stops the run with planner-unavailable', async () => { const { result } = await run({}, { ...quiet, planner: () => null }) assert.equal(result.reason, 'planner-unavailable') @@ -847,6 +855,17 @@ test('a reporter whose write was denied is not read as a written report', async assert.ok(logs.some(l => l.includes('no file was written'))) }) +test('a denied write\'s detail reaches the result on one line and bounded', async () => { + const { result, logs } = await run({ modules: ['core'], lenses: ['security'] }, { + ...quiet, + reporter: () => ({ written: false, detail: 'denied\n' + 'x'.repeat(400) }), + }) + assert.match(result.reportError, /^the reporter could not write the file: denied\\u000ax+\.\.\.$/) + const detail = result.reportError.slice('the reporter could not write the file: '.length) + assert.ok(detail.length <= 303, String(detail.length)) + assert.ok(logs.every(l => !l.includes('\n'))) +}) + test('a free-text reporter reply is not evidence that the file exists', async () => { const { result } = await run({ modules: ['core'], lenses: ['security'] }, { ...quiet, @@ -1329,6 +1348,9 @@ test('verifyOnly sends every listed finding to a verifier and runs no planner or const prompt = of('reporter')[0].prompt assert.match(prompt, /Mode: verify-only/) assert.match(prompt, /Verify-only run: no reviewer ran/) + // The earlier coverage is not passed on, so the reporter must not guess where it is. + assert.match(prompt, /given none of the earlier run's coverage/) + assert.match(prompt, /do not name a file/) assert.doesNotMatch(prompt, /Module x lens pairs:/) // The old verifyState is not passed on to the verifier as part of the claim. assert.doesNotMatch(of('verifier')[0].prompt, /over-budget/) From 89ec82cb362185adf3b9428076e8f02a363657e7 Mon Sep 17 00:00:00 2001 From: Atanas Oreshkov Date: Thu, 22 Oct 2026 21:18:09 +0300 Subject: [PATCH 3/3] token-meter 0.2.2: log every swallowed hook error, keep a hidden band hidden - refreshBranch lets a repo/cwd failure reach `guarded`, which logs it and keeps the branch last read. - A failed $.store.get is logged and leaves isHidden as it is. - turn.step's effort write goes through `guarded` instead of an empty catch. - Tests for failing store.set/store.get, repo reads and measurements. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++ token-meter/.claude-plugin/plugin.json | 2 +- token-meter/hooks/register.tsx | 29 +++++--- token-meter/tests/token-meter.test.tsx | 94 +++++++++++++++++++++++++- 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e30f686..6749a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## token-meter 0.2.2 + +- A failed repository read during a branch refresh is logged to the debug log and keeps + the branch last read, instead of being dropped silently and clearing the branch. +- A failed read of the hidden setting is logged, and the band stays as it is instead of + coming back after the user hid it. +- A failed effort write in `turn.step` is logged instead of dropped. The request still + goes on unchanged. +- The debug lines no longer name the plugin themselves: Claude Code already prefixes it. +- Tests: a failing store write and read, a failing repository read, and a measurement + that cannot be read, each with its debug line. + ## review-kit 0.2.2 - A reporter that answers `written: false` has its `detail` kept on one line and bounded diff --git a/token-meter/.claude-plugin/plugin.json b/token-meter/.claude-plugin/plugin.json index 5bd0563..7af2d94 100644 --- a/token-meter/.claude-plugin/plugin.json +++ b/token-meter/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "token-meter", "displayName": "Token Meter", - "version": "0.2.1", + "version": "0.2.2", "description": "A band above the prompt with the folder and git branch, model and effort, context use, rate limits and cost, and an alert line past 90%. A mod: needs Claude Code 2.1.287+ in a terminal or the Desktop app.", "author": { "name": "Atanas Oreshkov", diff --git a/token-meter/hooks/register.tsx b/token-meter/hooks/register.tsx index bc1ec06..d6a0ddd 100644 --- a/token-meter/hooks/register.tsx +++ b/token-meter/hooks/register.tsx @@ -51,10 +51,11 @@ const readBranch = async ($: EngineInterface): Promise => { return null } -// Never rejects, so the clock's `void refreshBranch($)` leaves nothing unhandled +// Never rejects, so the clock's `void refreshBranch($)` leaves nothing unhandled. A failed +// read is logged and keeps the branch last read. const refreshBranch = ($: EngineInterface): Promise => guarded($, 'branch refresh', async () => { - const name = await readBranch($).catch(() => null) + const name = await readBranch($) if ((await read($, branch)) !== name) await update($, branch, () => name) }) @@ -67,14 +68,25 @@ const setHidden = async ($: EngineInterface, hidden: boolean): Promise => try { await $.store.set(HIDDEN_KEY, hidden) } catch (error) { - $.ui.log(`token-meter: band ${hidden ? 'hidden' : 'shown'} for this session only: ${String(error)}`, { to: 'debug' }) + $.ui.log(`band ${hidden ? 'hidden' : 'shown'} for this session only: ${String(error)}`, { to: 'debug' }) + } +} + +// null when the store cannot be read: the band then stays as $.state has it, rather than +// coming back after the user hid it +const readHidden = async ($: EngineInterface): Promise => { + try { + return (await $.store.get(HIDDEN_KEY)) === true + } catch (error) { + $.ui.log(`hidden setting not read, band left as it is: ${String(error)}`, { to: 'debug' }) + return null } } const reload = async ($: EngineInterface): Promise => { await guarded($, 'reload', async () => { - const hidden = (await $.store.get(HIDDEN_KEY).catch(() => undefined)) === true - if ((await read($, isHidden)) !== hidden) await update($, isHidden, () => hidden) + const hidden = await readHidden($) + if (hidden !== null && (await read($, isHidden)) !== hidden) await update($, isHidden, () => hidden) await record($, await $.session.usage()) }) await refreshBranch($) @@ -116,11 +128,10 @@ export const register: Register = on => { on('turn.step', async function* ($, e, next) { if (e.agentId === undefined) { const level = e.effort === undefined ? null : String(e.effort) - try { + // Logged, not thrown: a failed write must not hold up the request + await guarded($, 'effort', async () => { if ((await read($, effort)) !== level) await update($, effort, () => level) - } catch { - // A failed write must not hold up the request - } + }) } return yield* next(e) }) diff --git a/token-meter/tests/token-meter.test.tsx b/token-meter/tests/token-meter.test.tsx index 2c5074e..a41aac4 100644 --- a/token-meter/tests/token-meter.test.tsx +++ b/token-meter/tests/token-meter.test.tsx @@ -42,12 +42,12 @@ const stubSession = ( cwd = 'C:\\work\\ai-tools', root = 'C:\\work\\ai-tools', files: Record = { 'C:/work/ai-tools/.git/HEAD': 'ref: refs/heads/main\n' }, - repo: typeof REPO | null = REPO, + repo: typeof REPO | null | (() => typeof REPO | null) = REPO, ) => { on('session.cwd', () => ({ value: cwd })) on('session.root', () => ({ value: root })) on('session.model', () => ({ value: 'claude-opus-5-5' })) - on('session.repo', () => ({ value: repo })) + on('session.repo', () => ({ value: typeof repo === 'function' ? repo() : repo })) const reads: string[] = [] on('fs.read', ($, e) => { // The engine hands the hook the path resolved in the platform's own spelling: on @@ -412,6 +412,49 @@ describe('hooks', () => { expect(reads).toEqual([]) }) + test('a failed repository read keeps the branch last read, and logs why', async ($, on) => { + mock.clock(on, { now: NOW }) + let failing = false + stubSession(on, undefined, undefined, undefined, () => { + if (failing) throw new Error('repo unavailable') + return REPO + }) + on('ui.status', () => ({ value: undefined })) + on('session.measure', ($, e) => ({ changed: e.changed })) + const logs: string[] = [] + on('ui.log', ($, e) => { + logs.push(e.text) + return { value: undefined } + }) + + await $.session.measure({ ...CALM, changed: ['context'] }) + failing = true + await $.session.measure({ ...CALM, changed: ['context'] }) + const ui = await $.ui.mount({ ...BAND, surface: 'terminal' }) + expect(await ui.find({ type: 'Text', text: /⎇ main$/ })).toBeDefined() + expect(logs).toHaveLength(1) + expect(logs[0]).toMatch(/^branch refresh failed: /) + }) + + test('a measurement that cannot be read is logged, still reads the branch, and passes the result on', async ($, on) => { + mock.clock(on, { now: NOW }) + const { reads } = stubSession(on) + on('ui.status', () => ({ value: undefined })) + on('session.measure', ($, e) => ({ changed: e.changed })) + const logs: { text: string; to: string }[] = [] + on('ui.log', ($, e) => { + logs.push({ text: e.text, to: e.to }) + return { value: undefined } + }) + + // A shape the engine's types rule out, so the figures cannot be read + const broken = { ...CALM, rateLimits: null as unknown as typeof CALM.rateLimits } + expect(await $.session.measure({ ...broken, changed: ['context'] })).toEqual({ changed: ['context'] }) + expect(logs.map(l => l.to)).toEqual(['debug']) + expect(logs[0]?.text).toMatch(/^measurement failed: /) + expect(reads.some(r => r.endsWith('/.git/HEAD'))).toBe(true) + }) + test('the band yields to a survey', async ($, on) => { mock.clock(on, { now: NOW }) stubSession(on) @@ -513,6 +556,53 @@ describe('hooks', () => { } }) + test('a failed store write still hides the band for this session, and logs why', async ($, on) => { + mock.clock(on, { now: NOW }) + stubSession(on) + on('ui.status', () => ({ value: undefined })) + on('session.measure', ($, e) => ({ changed: e.changed })) + on('ui.render', ENGINE_BAND) + on('store.get', () => ({ value: undefined })) + on('store.set', () => { throw new Error('store locked') }) + const logs: { text: string; to: string }[] = [] + on('ui.log', ($, e) => { + logs.push({ text: e.text, to: e.to }) + return { value: undefined } + }) + + await $.session.measure({ ...CALM, changed: ['context'] }) + expect((await $.command.run({ ...COMMAND, args: 'hide' })).text).toBe('band hidden') + const ui = await $.ui.mount({ ...BAND, surface: 'terminal' }) + expect(await ui.find({ type: 'Text', text: 'drawn by Claude Code' })).toBeDefined() + expect(logs.map(l => l.to)).toEqual(['debug']) + expect(logs[0]?.text).toMatch(/for this session only/) + }) + + test('a failed store read leaves a hidden band hidden, and logs why', async ($, on) => { + mock.clock(on, { now: NOW }) + stubSession(on) + on('ui.status', () => ({ value: undefined })) + on('session.usage', () => ({ value: { startedAt: 0, ...CALM } })) + on('session.measure', ($, e) => ({ changed: e.changed })) + on('classic.SessionStart', () => ({})) + on('ui.render', ENGINE_BAND) + on('store.get', () => { throw new Error('store locked') }) + on('store.set', () => ({ value: undefined })) + const logs: string[] = [] + on('ui.log', ($, e) => { + logs.push(e.text) + return { value: undefined } + }) + + await $.session.measure({ ...CALM, changed: ['context'] }) + await $.command.run({ ...COMMAND, args: 'hide' }) + await $.classic.SessionStart({ source: 'resume' }) + const ui = await $.ui.mount({ ...BAND, surface: 'terminal' }) + expect(await ui.find({ type: 'Text', text: 'drawn by Claude Code' })).toBeDefined() + expect(logs).toHaveLength(1) + expect(logs[0]).toMatch(/hidden setting not read/) + }) + test('/token-meter hide and show toggle the band', async ($, on) => { mock.clock(on, { now: NOW }) mock.store(on)