Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# 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
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
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
Expand Down
2 changes: 1 addition & 1 deletion review-kit/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion review-kit/agents/reporter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions review-kit/workflows/review.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, ' +
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion scripts/seeded-defects-real-run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,19 @@ 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-'))

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.
Expand Down
22 changes: 22 additions & 0 deletions test/review.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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/)
Expand Down
23 changes: 18 additions & 5 deletions test/token-audit/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand All @@ -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()
}
Expand Down
17 changes: 17 additions & 0 deletions test/token-audit/transcript.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}',
Expand Down
2 changes: 1 addition & 1 deletion token-audit/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions token-audit/skills/token-audit/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion token-audit/skills/token-audit/scripts/audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`))
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
23 changes: 13 additions & 10 deletions token-audit/skills/token-audit/scripts/lib/format.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -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) })),
Expand Down
Loading
Loading