From 187f60c3f34915fcbc753d1788200d0e245756e3 Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 9 Oct 2026 20:31:33 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix(turn):=20compaction=20is=20not=20a=20tu?= =?UTF-8?q?rn=20=E2=80=94=20one=20numbering=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - red: TurnBudgetDetector numbered the post-compact segment as a new turn (expected 3 to be 2) — the bell's turn number diverged from the app's UserChunk numbering after the first compaction; turnSpendStats slices and session turn indexes drifted the same way - fix: isTurnNumberLine (real user lines only) in the canonical accounting core (turn-accounting.mjs + .d.mts declarations + hook re-export + eslint import allowlist); TurnBudgetDetector keeps the number across a compaction; turnSpendStats prints bucket numbers; buildLedger and the deep-dive preview already skip compact markers - audit: pnpm turn-spend:stats --audit → parity OK (47 turns) - mutation: manual — reverting the detector fix turns the parity test red - includes the cycle diagnostics built on this numbering (pnpm analyze:session --turn N): cycle_motif / probe_no_progress findings and the «цикл или длинный ход» verdict Co-Authored-By: Claude Code --- eslint.config.js | 1 + scripts/turn-accounting.d.mts | 4 + scripts/turn-accounting.mjs | 8 + scripts/turn-budget-hook.d.mts | 3 + scripts/turn-budget-hook.mjs | 2 + src/cli/analyzeSession.ts | 264 ++++++++++++++++++++- src/cli/turnSpendStats.ts | 20 +- src/shared/turnAccounting.ts | 1 + test/main/cli/analyzeSession.cycle.test.ts | 143 +++++++++++ 9 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 test/main/cli/analyzeSession.cycle.test.ts diff --git a/eslint.config.js b/eslint.config.js index c2afce83..8f8443a9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -561,6 +561,7 @@ export default defineConfig([ 'isTeammateRelayLine', 'isTranscriptTurnLine', 'isTurnBoundary', + 'isTurnNumberLine', 'isUserChunkLine', 'lastAssistantTotalTokens', 'readConfig', diff --git a/scripts/turn-accounting.d.mts b/scripts/turn-accounting.d.mts index 98e97aa2..1e25f8c8 100644 --- a/scripts/turn-accounting.d.mts +++ b/scripts/turn-accounting.d.mts @@ -68,6 +68,10 @@ export declare function isTranscriptTurnLine(m: AccountableMessage): boolean; /** Turn boundary: user-initiated message or compaction marker. */ export declare function isTurnBoundary(m: AccountableMessage): boolean; +/** Which boundary consumes a turn number: real user messages only (compact + * resets the bucket but opens no turn — app parity, CompactChunk). */ +export declare function isTurnNumberLine(m: AccountableMessage): boolean; + /** Main-chain assistant line — the only lines billed to the turn's re-read * (sidechain/synthetic excluded; detector and panel see the same set). */ export declare function isMainChainAssistantLine(m: AccountableMessage): boolean; diff --git a/scripts/turn-accounting.mjs b/scripts/turn-accounting.mjs index 068cbb32..79fe27f7 100644 --- a/scripts/turn-accounting.mjs +++ b/scripts/turn-accounting.mjs @@ -206,6 +206,14 @@ export function isTurnBoundary(m) { return isUserChunkLine(m) || m.isCompactSummary === true; } +/** Which boundary CONSUMES a turn number: real user messages only. A + * compaction marker resets the budget bucket (isTurnBoundary) but opens no + * turn — app parity: CompactChunk is a structural boundary, the post-compact + * segment keeps the current number until the next real user message. */ +export function isTurnNumberLine(m) { + return isUserChunkLine(m) && m.isCompactSummary !== true; +} + /** Main-chain assistant line — the only lines whose usage is billed to the * turn's re-read. Sidechain (subagent) rounds run in their own context and * synthetic lines carry no real request; the detector (loopDetection.ts) diff --git a/scripts/turn-budget-hook.d.mts b/scripts/turn-budget-hook.d.mts index 763f1fdb..0b516370 100644 --- a/scripts/turn-budget-hook.d.mts +++ b/scripts/turn-budget-hook.d.mts @@ -47,6 +47,9 @@ export declare function isRealUserLine(m: AccountableMessage): boolean; /** Turn boundary: real user message or compaction marker. */ export declare function isTurnBoundary(m: AccountableMessage): boolean; +/** Which boundary consumes a turn number: real user messages only. */ +export declare function isTurnNumberLine(m: AccountableMessage): boolean; + /** Main-chain assistant line — billed to the turn's re-read. */ export declare function isMainChainAssistantLine(m: AccountableMessage): boolean; diff --git a/scripts/turn-budget-hook.mjs b/scripts/turn-budget-hook.mjs index 31ed50e0..3e43bb49 100644 --- a/scripts/turn-budget-hook.mjs +++ b/scripts/turn-budget-hook.mjs @@ -51,6 +51,7 @@ import { isMainChainAssistantLine, isRealUserLine, isTurnBoundary, + isTurnNumberLine, lastAssistantTotalTokens, } from './turn-accounting.mjs'; @@ -63,6 +64,7 @@ export { isMainChainAssistantLine, isRealUserLine, isTurnBoundary, + isTurnNumberLine, lastAssistantTotalTokens, }; diff --git a/src/cli/analyzeSession.ts b/src/cli/analyzeSession.ts index ac0bee07..7e0ed709 100644 --- a/src/cli/analyzeSession.ts +++ b/src/cli/analyzeSession.ts @@ -9,6 +9,7 @@ * pnpm analyze:session --project --last [flags] * Flags: * --rounds N rounds table length (default 20) + * --turn N deep-dive into turn N: verdict «цикл или длинный ход», findings, rounds * --subagent-min-minutes N slow-subagent threshold (default 5) * --min-severity S low | medium | high (default low = all) * --breakdown per-model token/cost breakdown @@ -115,7 +116,9 @@ export type FindingType = | 'long_turn' | 'loop_streak' | 'stall_streak' - | 'wait_loop'; + | 'wait_loop' + | 'cycle_motif' + | 'probe_no_progress'; export interface Finding { type: FindingType; @@ -426,6 +429,7 @@ export function buildLedger(allMessages: ParsedMessage[]): SessionLedger { minTs = Math.min(minTs, msg.timestamp.getTime()); maxTs = Math.max(maxTs, msg.timestamp.getTime()); + if (msg.isCompactSummary) continue; // structural message — not a turn (app numbering, jsonl.ts:545) if (isParsedUserChunkMessage(msg)) { newTurn(msg.timestamp); continue; @@ -736,6 +740,7 @@ export function computeFindings( } const order = { high: 0, medium: 1, low: 2 } as const; + findings.push(...detectCycleFindings(messages, ledger, results)); findings.sort((a, b) => order[b.severity] - order[a.severity] || b.tokensWasted - a.tokensWasted); return findings; } @@ -745,6 +750,187 @@ export function short(s: string, n: number): string { return flat.length <= n ? flat : `${flat.slice(0, n - 1)}…`; } +// ============================================================================= +// Cycle detection — «цикл или длинный ход» +// ============================================================================= + +// ponytail: calibration knobs — tune after live runs on real sessions +const CYCLE = { minLen: 3, maxLen: 5, minRepeats: 3 } as const; + +// Edit/Write results are "updated successfully" by construction — a repeated +// successful mutation is not a probe; probes return DATA (Bash/Read/Grep/…) +const MUTATING_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']); + +/** first + last non-empty line — cheap discriminator (pytest tail, refusal + * reason); full texts differ in noise while meaning stays the same */ +// ponytail: upgrade to fuzzy compare if false positives show up +function resultSignature(text: string): string { + const lines = text + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + // last line only — the verdict line (pytest tail, refusal summary); first + // lines are banners (config/vacancy echo) that differ across retries. + // Volatile ids (run uuids, hashes) are stripped — they differ per attempt. + return (lines[lines.length - 1] ?? '').replace( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, + '' + ); +} + +interface CycleRound { + turnIndex: number; + contextSize: number; + timestamp: Date; + calls: { toolName: string; key: string; sig: string; tok: number }[]; +} + +/** + * Findings invisible to the streak/stall walks: a repeating SEQUENCE of + * distinct calls (edit → lint → test → battle run) where context keeps + * growing — diagnose-fix-verify churn. Plus the no-progress signal: a probe + * call re-run with an identical result signature. Called from + * computeFindings so every consumer (text + json) gets them. + */ +export function detectCycleFindings( + messages: ParsedMessage[], + ledger: SessionLedger, + results: Map +): Finding[] { + const findings: Finding[] = []; + // same round source as buildLedger (dedup + filters), retry copies dropped + // on both sides — positional zip with ledger.rounds; drift → skip whole + // detection (ponytail: the lists have matched on every real session so far) + const retryCopies = getRetryCopyMessageIds(messages); + const msgs = deduplicateByRequestId(messages).filter( + (m) => + m.type === 'assistant' && + !m.isSidechain && + !!m.usage && + m.model !== '' && + !retryCopies.has(m.uuid) + ); + const ledgerRounds = ledger.rounds.filter((r) => !r.isRetryCopy); + if (msgs.length !== ledgerRounds.length) return findings; + + const rounds: CycleRound[] = msgs.map((m, i) => ({ + turnIndex: ledgerRounds[i].turnIndex, + contextSize: ledgerRounds[i].contextSize, + timestamp: ledgerRounds[i].timestamp, + calls: m.toolCalls.map((c) => { + const res = results.get(c.id); + const text = res ? resultText(res.content) : ''; + return { + toolName: c.name, + key: bashStem(normalizeCallKey(c.name, c.input)), + sig: res ? resultSignature(text) : '', + tok: res ? estimateTokens(text) : 0, + }; + }), + })); + + const byTurn = new Map(); + for (const r of rounds) { + const list = byTurn.get(r.turnIndex); + if (list) list.push(r); + else byTurn.set(r.turnIndex, [r]); + } + + for (const [turnIndex, rs] of byTurn) { + // motif = a window of L rounds repeating consecutively ≥ CYCLE.minRepeats; + // longest L wins, one pass at that L per turn (no re-scan of consumed + // regions at smaller L — same cycle, not a second finding) + for (let L = CYCLE.maxLen; L >= CYCLE.minLen; L--) { + let hit = false; + let i = 0; + while (i + L * CYCLE.minRepeats <= rs.length) { + let reps = 1; + const eq = (a: number, b: number): boolean => { + for (let k = 0; k < L; k++) { + if ( + rs[a + k].calls.length !== rs[b + k].calls.length || + rs[a + k].calls.some((c, j) => c.key !== rs[b + k].calls[j].key) + ) { + return false; + } + } + return true; + }; + while (i + (reps + 1) * L <= rs.length && eq(i, i + reps * L)) reps += 1; + if (reps >= CYCLE.minRepeats) { + hit = true; + const motif = rs + .slice(i, i + L) + .map((r) => short(r.calls.map((c) => c.key).join('+') || '—', 24)) + .join(' → '); + const wasted = rs.slice(i + L, i + reps * L).reduce((s, r) => s + r.contextSize, 0); + findings.push({ + type: 'cycle_motif', + severity: reps >= 4 ? 'high' : 'medium', + tokensWasted: wasted, + turnIndex, + summary: `cycle: ${motif} — x${reps} repeats re-read ~${formatTokensCompact(wasted)} tok ${hhmm(rs[i].timestamp)}–${hhmm(rs[i + reps * L - 1].timestamp)}`, + }); + i += reps * L; + } else { + i += 1; + } + } + if (hit) break; + } + + // probe with the same result: same call key re-run, identical first+last + // line — the loop re-asked its question without learning anything + const byKey = new Map(); + for (const r of rs) { + for (const c of r.calls) { + if (!c.sig || MUTATING_TOOLS.has(c.toolName)) continue; + const list = byKey.get(c.key); + const entry = { sig: c.sig, ts: r.timestamp, tok: c.tok }; + if (list) list.push(entry); + else byKey.set(c.key, [entry]); + } + } + for (const [key, list] of byKey) { + if (list.length < 2) continue; + let best = { len: 1, start: 0, end: 0 }; + let run = 1; + for (let j = 1; j < list.length; j++) { + run = list[j].sig && list[j].sig === list[j - 1].sig ? run + 1 : 1; + if (run > best.len) best = { len: run, start: j - run + 1, end: j }; + } + if (best.len < 2) continue; + const repeats = list.slice(best.start + 1, best.end + 1); + findings.push({ + type: 'probe_no_progress', + severity: 'medium', + tokensWasted: repeats.reduce((s, e) => s + e.tok, 0), + turnIndex, + summary: `probe no progress: ${short(key, 60)} — same result x${best.len}: ${short(list[best.start].sig, 70)}`, + }); + } + } + return findings; +} + +export type TurnVerdict = 'cycle-churning' | 'cycle-progressing' | 'long-turn' | 'normal'; + +/** «Цикл или длинный ход?» — the deep-dive verdict, pure function of findings + * + round stats. Churning beats progressing: same-result probes mean parts of + * the cycle stopped converging even if the motif is legitimate work. */ +export function turnVerdict( + findings: Finding[], + rounds: number, + activeMinutes: number +): TurnVerdict { + const types = new Set(findings.map((f) => f.type)); + if (types.has('probe_no_progress')) return 'cycle-churning'; + if (types.has('cycle_motif')) return 'cycle-progressing'; + // ponytail: 30 rounds is a working guess; activeMinutes reuses the long-turn knob + if (rounds >= 30 || activeMinutes >= WASTE_THRESHOLDS.longTurnActiveMinutes) return 'long-turn'; + return 'normal'; +} + // ============================================================================= // CLI plumbing // ============================================================================= @@ -762,6 +948,7 @@ interface CliOpts { noCost: boolean; since?: Date; until?: Date; + turn?: number; error?: string; } @@ -789,6 +976,11 @@ export function parseArgs(argv: string[]): CliOpts { i = next; continue; } + if (a === '--turn') { + opts.turn = parseInt(value, 10) || undefined; + i = next; + continue; + } if (a === '--subagent-min-minutes') { opts.subagentMinMinutes = parseInt(value, 10) || 5; i = next; @@ -900,6 +1092,71 @@ const hhmm = (d: Date): string => export const dur = (ms: number): string => `${Math.floor(ms / 3600000)}h ${String(Math.floor((ms % 3600000) / 60000)).padStart(2, '0')}m`; +const VERDICT_LABEL: Record = { + 'cycle-churning': + 'ЦИКЛ С ВРАЩЕНИЕМ — участки цикла крутятся без продвижения (проба возвращает тот же результат)', + 'cycle-progressing': 'ЦИКЛ С ПРОДВИЖЕНИЕМ — diagnose-fix-verify, витки отвечают на вопросы', + 'long-turn': 'ДЛИННЫЙ ХОД — много работы, повторяющихся последовательностей нет', + normal: 'ОБЫЧНЫЙ ХОД', +}; + +function printTurnDeepDive( + ledger: SessionLedger, + findings: Finding[], + messages: ParsedMessage[], + turnNo: number, + opts: CliOpts +): void { + const turn = ledger.turns[turnNo - 1]; + if (!turn) { + console.error(`no turn #${turnNo} (session has ${ledger.turns.length})`); + process.exitCode = 1; + return; + } + const rs = ledger.rounds.filter((r) => r.turnIndex === turnNo); + const tf = findings.filter((f) => f.turnIndex === turnNo); + const user = (() => { + let n = 0; + for (const m of messages) { + if (m.isCompactSummary) continue; // same numbering as buildLedger + if (isParsedUserChunkMessage(m) && ++n === turnNo) return m; + } + return null; + })(); + const preview = user + ? short(typeof user.content === 'string' ? user.content : JSON.stringify(user.content), 110) + : '(no user text)'; + const first = rs[0]; + const last = rs[rs.length - 1]; + const span = first ? last.timestamp.getTime() - turn.start.getTime() : 0; + const verdict = turnVerdict(tf, rs.length, turn.activeMinutes); + + console.log(`=== TURN ${turnNo} ===`); + console.log(`user : ${preview}`); + console.log( + `span : ${dur(span)} wall, active ${turn.activeMinutes}m, ${rs.length} rounds, ${rs.reduce((s, r) => s + r.tools.length, 0)} tool calls` + ); + if (first && last) { + console.log( + `ctx : ${fmt(first.contextSize)} → ${fmt(last.contextSize)} (+${fmt(last.contextSize - first.contextSize)})` + ); + } + console.log(`verdict: ${VERDICT_LABEL[verdict]}`); + console.log('findings:'); + if (tf.length === 0) { + console.log(' (none)'); + } + for (const f of tf) { + console.log(` [${f.severity}] ${f.type}: ${f.summary}`); + } + console.log(`rounds (last ${Math.min(opts.rounds, rs.length)} of ${rs.length}):`); + for (const r of rs.slice(-opts.rounds)) { + console.log( + ` ${padL(String(r.index), 4)} ${pad(hhmm(r.timestamp), 6)} ${padL(fmt(r.contextSize), 9)} ${padL((r.contextDelta >= 0 ? '+' : '') + fmt(r.contextDelta), 9)} ${short(r.tools.join(','), 48)}` + ); + } +} + function printReport( file: string, ledger: SessionLedger, @@ -1034,6 +1291,7 @@ async function main(): Promise { '', 'flags:', ' --rounds N rounds table length (default 20)', + ' --turn N deep-dive into turn N: verdict «цикл или длинный ход», findings, rounds', ' --subagent-min-minutes N slow-subagent threshold in minutes (default 5)', ' --min-severity S low | medium | high (default low = all)', ' --breakdown per-model token/cost breakdown', @@ -1086,6 +1344,10 @@ async function main(): Promise { const messages = await parseJsonlFile(sessionFile); const ledger = filterLedgerByDate(buildLedger(messages), opts.since, opts.until); const findings = computeFindings(messages, ledger, opts.since, opts.until); + if (opts.turn) { + printTurnDeepDive(ledger, findings, messages, opts.turn, opts); + return; + } let subagents: Process[] = []; try { diff --git a/src/cli/turnSpendStats.ts b/src/cli/turnSpendStats.ts index 4a869150..5c8945b9 100644 --- a/src/cli/turnSpendStats.ts +++ b/src/cli/turnSpendStats.ts @@ -21,6 +21,7 @@ import { inputSideTokens, isMainChainAssistantLine, isTurnBoundary, + isTurnNumberLine, } from '../../scripts/turn-budget-hook.mjs'; import { takeFlagValue, wantsHelp } from './args'; @@ -123,10 +124,14 @@ async function auditFile(filePath: string): Promise { }); // turn slices (1-based numbering, same as the detector's turnNumber): - // turn 1 opens at the FIRST boundary; metadata before it is not a turn + // turn 1 opens at the FIRST boundary; metadata before it is not a turn. + // Numbering parity: only real user lines consume a number (isTurnNumberLine); + // a compaction marker splits the slice but prints the SAME bucket number. const slices: string[][] = []; + const sliceTurnNos: number[] = []; let cur: string[] = []; let sawBoundary = false; + let turnNo = 0; for (const line of lines) { let m: Record = {}; try { @@ -135,13 +140,20 @@ async function auditFile(filePath: string): Promise { continue; } if (isTurnBoundary(m)) { - if (sawBoundary && cur.length) slices.push(cur); + if (sawBoundary && cur.length) { + slices.push(cur); + sliceTurnNos.push(turnNo); + } cur = []; sawBoundary = true; } + if (isTurnNumberLine(m)) turnNo += 1; if (sawBoundary) cur.push(line); } - if (cur.length) slices.push(cur); + if (cur.length) { + slices.push(cur); + sliceTurnNos.push(turnNo); + } // detector path (the live bell): line-by-line feed, exactly what FileWatcher // streams; snapshot each turn's running total right before its boundary resets @@ -184,7 +196,7 @@ async function auditFile(filePath: string): Promise { } })(); console.log( - String(i + 1).padStart(4), + String(sliceTurnNos[i]).padStart(4), startTs.padEnd(24), fmtK(hook).padStart(11), fmtK(bell).padStart(10), diff --git a/src/shared/turnAccounting.ts b/src/shared/turnAccounting.ts index 3f13e9a8..ae038493 100644 --- a/src/shared/turnAccounting.ts +++ b/src/shared/turnAccounting.ts @@ -20,6 +20,7 @@ export { isTeammateRelayLine, isTranscriptTurnLine, isTurnBoundary, + isTurnNumberLine, isUserChunkLine, lastAssistantTotalTokens, SYSTEM_OUTPUT_TAGS, diff --git a/test/main/cli/analyzeSession.cycle.test.ts b/test/main/cli/analyzeSession.cycle.test.ts new file mode 100644 index 00000000..022c99c0 --- /dev/null +++ b/test/main/cli/analyzeSession.cycle.test.ts @@ -0,0 +1,143 @@ +/** + * Cycle diagnostics — «цикл или длинный ход» verdict for a turn: + * detectCycleFindings (cycle_motif / probe_no_progress) + turnVerdict. + */ +import { describe, expect, it } from 'vitest'; + +import { buildLedger, computeFindings, turnVerdict } from '../../../src/cli/analyzeSession'; +import type { ParsedMessage } from '../../../src/main/types'; + +let seq = 0; +const base = { + parentUuid: null, + timestamp: new Date('2026-09-20T10:00:00Z'), + content: '' as never, + toolCalls: [] as never, + toolResults: [] as never, + isSidechain: false, + isMeta: false, +}; +const msg = (o: object): ParsedMessage => ({ uuid: `u${++seq}`, ...base, ...o }) as ParsedMessage; +const usage = (ctx: number) => ({ + input_tokens: ctx, + output_tokens: 50, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +}); +/** one round = assistant msg with a single Bash call; result lands in a meta user msg */ +const round = (i: number, cmd: string, result?: string): ParsedMessage[] => { + const t = Date.parse('2026-09-20T10:00:00Z') + i * 60_000; + const out: ParsedMessage[] = [ + msg({ + type: 'assistant', + timestamp: new Date(t), + model: 'm1', + usage: usage(1000 + 100 * i), + toolCalls: [{ id: `c${i}`, name: 'Bash', input: { command: cmd }, isTask: false }], + }), + ]; + if (result !== undefined) { + out.push( + msg({ + type: 'user', + isMeta: true, + timestamp: new Date(t + 30_000), + content: [{ type: 'tool_result', tool_use_id: `c${i}`, content: result } as never], + toolResults: [{ toolUseId: `c${i}`, content: result, isError: false }], + }) + ); + } + return out; +}; +const turn = (specs: { cmd: string; result?: string }[]): ParsedMessage[] => [ + msg({ type: 'user', content: 'fix it' }), + ...specs.flatMap((s, i) => round(i, s.cmd, s.result)), +]; + +describe('detectCycleFindings', () => { + it('flags a repeating sequence of distinct calls as cycle_motif', () => { + const specs = ['edit', 'lint', 'battle']; + const messages = turn([...specs, ...specs, ...specs]); + const findings = computeFindings(messages, buildLedger(messages)); + const motif = findings.find((f) => f.type === 'cycle_motif'); + expect(motif).toBeDefined(); + expect(motif?.severity).toBe('medium'); // x3 — not yet high + // wasted = context of repeated windows (rounds 3..8): Σ(1000+100i), i=3..8 + expect(motif?.tokensWasted).toBe(9300); + }); + + it('short alternating sequences stay silent', () => { + const messages = turn([{ cmd: 'a' }, { cmd: 'b' }, { cmd: 'a' }, { cmd: 'b' }]); + const findings = computeFindings(messages, buildLedger(messages)); + expect(findings.some((f) => f.type === 'cycle_motif')).toBe(false); + expect(findings.some((f) => f.type === 'probe_no_progress')).toBe(false); + }); + + it('flags a probe re-run with the same result as probe_no_progress', () => { + const messages = turn([ + { cmd: 'probe', result: 'same line' }, + { cmd: 'fix one' }, + { cmd: 'probe', result: 'same line' }, + { cmd: 'fix two' }, + { cmd: 'probe', result: 'same line' }, + ]); + const findings = computeFindings(messages, buildLedger(messages)); + const probe = findings.find((f) => f.type === 'probe_no_progress'); + expect(probe).toBeDefined(); + expect(probe?.summary).toContain('same result x3'); + expect(probe?.tokensWasted).toBeGreaterThan(0); + }); + + it('linear distinct work stays silent', () => { + const messages = turn( + Array.from({ length: 10 }, (_, i) => ({ cmd: `step ${i}`, result: `done ${i}` })) + ); + const findings = computeFindings(messages, buildLedger(messages)); + expect(findings.some((f) => f.type === 'cycle_motif')).toBe(false); + expect(findings.some((f) => f.type === 'probe_no_progress')).toBe(false); + }); +}); + +describe('turnVerdict', () => { + const F = (type: string): never => + ({ + type, + severity: 'medium', + tokensWasted: 0, + summary: '', + }) as never; + + it('probe_no_progress dominates — cycle with churning', () => { + expect(turnVerdict([F('cycle_motif'), F('probe_no_progress')], 40, 50)).toBe('cycle-churning'); + }); + + it('motif without probe repeats — cycle with progress', () => { + expect(turnVerdict([F('cycle_motif')], 40, 50)).toBe('cycle-progressing'); + }); + + it('no cycles but heavy — long turn', () => { + expect(turnVerdict([], 40, 50)).toBe('long-turn'); + expect(turnVerdict([], 35, 2)).toBe('long-turn'); + }); + + it('light and linear — normal', () => { + expect(turnVerdict([], 5, 2)).toBe('normal'); + }); +}); + +describe('buildLedger turn numbering parity', () => { + it('a compaction summary opens no turn (app numbering parity)', () => { + const messages = [ + msg({ type: 'user', content: 'one' }), + msg({ type: 'assistant', model: 'm1', usage: usage(100) }), + msg({ + type: 'user', + isMeta: false, + isCompactSummary: true, + content: 'This session is being continued…', + }), + msg({ type: 'user', content: 'two' }), + ]; + expect(buildLedger(messages).turns).toHaveLength(2); + }); +}); From e4e1fe74c29ce11260dfa649f266ffa02174ded5 Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 9 Oct 2026 21:02:49 +0800 Subject: [PATCH 2/9] chore(ci): retrigger workflow From f809ade9e9fdba867303088aa749290fad95bee1 Mon Sep 17 00:00:00 2001 From: axisrow Date: Sat, 10 Oct 2026 14:57:56 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(session:sums):=20machine-readable=20ro?= =?UTF-8?q?llup=20=E2=80=94=20session=20=E2=86=92=20turns=20=E2=86=92=20ro?= =?UTF-8?q?unds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pnpm session:sums prints raw-number JSON: session totals, per-turn sums (rounds count, inputSide/output/billedTotal, hookSpent via the hook's analyzeTurn — GUI/bell parity), per-round sums, explicit checksums (rounds=turns=session, ledger-vs-hook mismatches listed) and cycle attribution (tragedy: input-side of cycle-verdict turns) - splitSessionPath exported from analyzeSession — reused, not copied - audit: hhru session 3766d55e — checksums all true, hookParity 0 mismatches across 47 turns, jq sum 60 259 545 == session total; turn 46 15 495 691 (cycle-churning); tragedy 31.6M / 52.5% of the session Co-Authored-By: Claude Code --- package.json | 1 + src/cli/analyzeSession.ts | 2 +- src/cli/sessionSums.ts | 189 ++++++++++++++++++++++++++++++ test/main/cli/sessionSums.test.ts | 131 +++++++++++++++++++++ 4 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 src/cli/sessionSums.ts create mode 100644 test/main/cli/sessionSums.test.ts diff --git a/package.json b/package.json index bc9969cd..a86751d6 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "analyze:sessions": "tsx src/cli/sessionInventory.ts", "loops:import": "tsx src/cli/importLoops.ts", "turn-spend:stats": "tsx src/cli/turnSpendStats.ts", + "session:sums": "tsx src/cli/sessionSums.ts", "standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts", "standalone:start": "node dist-standalone/index.cjs" }, diff --git a/src/cli/analyzeSession.ts b/src/cli/analyzeSession.ts index 7e0ed709..a7d5462f 100644 --- a/src/cli/analyzeSession.ts +++ b/src/cli/analyzeSession.ts @@ -1062,7 +1062,7 @@ export function resolveProjectDir(arg: string): string { return path.join(projectsRoot, encodePath(path.resolve(arg))); } -function splitSessionPath(file: string): { projectId: string; sessionId: string } { +export function splitSessionPath(file: string): { projectId: string; sessionId: string } { const rel = path.relative(getProjectsBasePath(), path.resolve(file)); const [projectId, sessionId] = rel.split(path.sep); return { projectId, sessionId: sessionId ? extractSessionId(sessionId) : '' }; diff --git a/src/cli/sessionSums.ts b/src/cli/sessionSums.ts new file mode 100644 index 00000000..fc9512a1 --- /dev/null +++ b/src/cli/sessionSums.ts @@ -0,0 +1,189 @@ +/** + * session:sums — machine-readable token rollup: session → turns → rounds, + * with checksums and cycle attribution. Turns carry both the ledger sums + * (per-round, requestId-deduped) and the hook's own analyzeTurn spend + * (keep-newest input-side) so the numbers can be checked against the GUI + * panel and the bell in one place. + * + * Usage: pnpm session:sums (JSON on stdout, raw numbers) + */ +import { parseJsonlFile } from '@main/utils/jsonl'; +import { analyzeTurn, isTurnBoundary, isTurnNumberLine } from '@shared/turnAccounting'; +import * as fs from 'fs'; + +import { + buildLedger, + computeFindings, + type Finding, + type SessionLedger, + splitSessionPath, + turnVerdict, +} from './analyzeSession'; + +interface RollupRound { + index: number; + input: number; + cacheRead: number; + cacheWrite: number; + output: number; + inputSide: number; + billedTotal: number; +} + +const CYCLE_TYPES = new Set(['cycle_motif', 'probe_no_progress']); + +/** Pure rollup: ledger + findings + per-turn hook spend → JSON-ready shape. */ +export function buildSums( + ledger: SessionLedger, + findings: Finding[], + hookSpent: Map +): Record { + const roundRollup = (r: { + index: number; + inputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + outputTokens: number; + }): RollupRound => ({ + index: r.index, + input: r.inputTokens, + cacheRead: r.cacheReadTokens, + cacheWrite: r.cacheCreationTokens, + output: r.outputTokens, + inputSide: r.inputTokens + r.cacheReadTokens + r.cacheCreationTokens, + billedTotal: r.inputTokens + r.cacheReadTokens + r.cacheCreationTokens + r.outputTokens, + }); + + const roundsByTurn = new Map[]>(); + for (const r of ledger.rounds) { + const list = roundsByTurn.get(r.turnIndex); + const roll = roundRollup(r); + if (list) list.push(roll); + else roundsByTurn.set(r.turnIndex, [roll]); + } + const findingsByTurn = new Map(); + for (const f of findings) { + if (f.turnIndex === undefined) continue; + const list = findingsByTurn.get(f.turnIndex); + if (list) list.push(f); + else findingsByTurn.set(f.turnIndex, [f]); + } + + let turnsInputSide = 0; + let turnsOutput = 0; + const turns = ledger.turns.map((t) => { + const rs = roundsByTurn.get(t.index) ?? []; + const inputSide = rs.reduce((s, r) => s + r.inputSide, 0); + const output = rs.reduce((s, r) => s + r.output, 0); + turnsInputSide += inputSide; + turnsOutput += output; + const tf = findingsByTurn.get(t.index) ?? []; + const cycleTypes = tf.filter((f) => CYCLE_TYPES.has(f.type)).map((f) => f.type); + return { + index: t.index, + rounds: rs.length, + activeMinutes: t.activeMinutes, + inputSide, + output, + billedTotal: inputSide + output, + hookSpent: hookSpent.get(t.index) ?? 0, + cycles: { + verdict: turnVerdict(tf, rs.length, t.activeMinutes), + tokensWasted: tf + .filter((f) => CYCLE_TYPES.has(f.type)) + .reduce((s, f) => s + f.tokensWasted, 0), + types: cycleTypes, + }, + }; + }); + + const t = ledger.totals; + const sessionInputSide = t.inputTokens + t.cacheReadTokens + t.cacheCreationTokens; + const sessionBilled = sessionInputSide + t.outputTokens; + const roundsEqualTurns = turns.every((tn) => { + const rs = roundsByTurn.get(tn.index) ?? []; + return ( + rs.reduce((s, r) => s + r.inputSide, 0) === tn.inputSide && + rs.reduce((s, r) => s + r.output, 0) === tn.output + ); + }); + const hookMismatches = turns + .filter((tn) => tn.inputSide !== tn.hookSpent) + .map((tn) => ({ turn: tn.index, ledger: tn.inputSide, hook: tn.hookSpent })); + // «сколько сожжено циклами»: вся inputSide ходов с cycle-вердиктом — + // консервативная оценка сверху (в re-read валюте бюджета), не штраф находок + const cycleWasted = turns.reduce( + (s, tn) => (tn.cycles.verdict.startsWith('cycle-') ? s + tn.inputSide : s), + 0 + ); + + return { + session: { + input: t.inputTokens, + cacheRead: t.cacheReadTokens, + cacheWrite: t.cacheCreationTokens, + output: t.outputTokens, + inputSide: sessionInputSide, + billedTotal: sessionBilled, + }, + turns, + checksums: { + roundsSumEqualsTurnsSum: roundsEqualTurns, + turnsSumEqualsSession: turnsInputSide === sessionInputSide && turnsOutput === t.outputTokens, + hookParity: { match: hookMismatches.length === 0, mismatches: hookMismatches }, + }, + tragedy: { + cycleTokensWasted: cycleWasted, + cycleTurns: turns.filter((tn) => tn.cycles.verdict.startsWith('cycle-')).length, + shareOfSession: sessionBilled > 0 ? cycleWasted / sessionBilled : 0, + }, + }; +} + +/** Hook accounting per turn: split raw lines at isTurnBoundary (chronological + * slices, like turnSpendStats), number slices by isTurnNumberLine (compact + * keeps the current number), sum analyzeTurn per number. */ +export function hookSpentByTurn(rawLines: string[]): Map { + const spent = new Map(); + const add = (no: number, lines: string[]): void => { + if (lines.length === 0) return; + const { spent: s } = analyzeTurn([...lines].reverse()); + spent.set(no, (spent.get(no) ?? 0) + s); + }; + let turnNo = 0; + let cur: string[] = []; + let sawBoundary = false; + for (const line of rawLines) { + let m: Record = {}; + try { + m = JSON.parse(line) as Record; + } catch { + continue; + } + if (isTurnBoundary(m)) { + if (sawBoundary) add(turnNo, cur); + cur = []; + sawBoundary = true; + } + if (isTurnNumberLine(m)) turnNo += 1; + if (sawBoundary) cur.push(line); + } + add(turnNo, cur); + return spent; +} + +async function main(): Promise { + const file = process.argv[2]; + if (!file || !fs.existsSync(file)) { + console.error('usage: pnpm session:sums '); + process.exitCode = 1; + return; + } + const messages = await parseJsonlFile(file); + const ledger = buildLedger(messages); + const findings = computeFindings(messages, ledger); + const rawLines = fs.readFileSync(file, 'utf8').split('\n'); + const sums = buildSums(ledger, findings, hookSpentByTurn(rawLines)); + console.log(JSON.stringify({ ...splitSessionPath(file), ...sums }, null, 2)); +} +void main(); diff --git a/test/main/cli/sessionSums.test.ts b/test/main/cli/sessionSums.test.ts new file mode 100644 index 00000000..1dc702d7 --- /dev/null +++ b/test/main/cli/sessionSums.test.ts @@ -0,0 +1,131 @@ +/** + * session:sums tests — rollup checksums, hook parity, cycle attribution. + * Fixtures go through a temp .jsonl so raw and parsed shapes stay in sync. + */ +import { mkdtemp, rm, writeFile, readFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import * as path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildLedger, computeFindings } from '../../../src/cli/analyzeSession'; +import { buildSums, hookSpentByTurn } from '../../../src/cli/sessionSums'; +import { parseJsonlFile } from '../../../src/main/utils/jsonl'; + +let dir: string | null = null; +afterEach(async () => { + if (dir) { + await rm(dir, { recursive: true, force: true }); + dir = null; + } +}); + +const T = '2026-09-20T10:00:00Z'; +const user = (n: number, text: string): Record => ({ + type: 'user', + uuid: `u${n}`, + timestamp: T, + isMeta: false, + message: { content: text }, +}); +const meta = (n: number, id: string, result: string): Record => ({ + type: 'user', + uuid: `u${n}`, + timestamp: T, + isMeta: true, + message: { + content: [{ type: 'tool_result', tool_use_id: id, content: result }], + }, +}); +const ai = ( + n: number, + id: string, + input: number, + output: number, + calls: { id: string; command: string }[] = [] +): Record => ({ + type: 'assistant', + uuid: `a${n}`, + timestamp: T, + message: { + id, + model: 'm1', + usage: { + input_tokens: input, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + output_tokens: output, + }, + content: calls.map((c) => ({ + type: 'tool_use', + id: c.id, + name: 'Bash', + input: { command: c.command }, + })), + }, +}); + +async function writeSession(lines: Record[]): Promise { + dir = await mkdtemp(path.join(tmpdir(), 'devtools-sums-')); + const file = path.join(dir, 's.jsonl'); + await writeFile(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + return file; +} + +async function sumsFor(lines: Record[]) { + const file = await writeSession(lines); + const messages = await parseJsonlFile(file); + const ledger = buildLedger(messages); + const findings = computeFindings(messages, ledger); + const raw = (await readFile(file, 'utf8')).split('\n'); + return buildSums(ledger, findings, hookSpentByTurn(raw)); +} + +describe('session:sums', () => { + it('checksums hold: rounds = turns = session; hook parity matches', async () => { + const sums = await sumsFor([ + user(1, 'fix it'), + ai(2, 'msg1', 100, 10), + ai(3, 'msg2', 200, 20), + user(4, 'go on'), + ai(5, 'msg3', 300, 30), + ]); + expect(sums.turns).toHaveLength(2); + expect(sums.session).toMatchObject({ inputSide: 600, billedTotal: 660 }); + const t1 = (sums.turns as { index: number; inputSide: number; rounds: number }[])[0]; + expect(t1).toMatchObject({ index: 1, rounds: 2, inputSide: 300 }); + expect(sums.checksums).toEqual({ + roundsSumEqualsTurnsSum: true, + turnsSumEqualsSession: true, + hookParity: { match: true, mismatches: [] }, + }); + expect(sums.tragedy).toMatchObject({ cycleTokensWasted: 0, cycleTurns: 0 }); + }); + + it('a same-result probe turn is attributed as churning with its input-side', async () => { + const sums = await sumsFor([ + user(1, 'run probe'), + ai(2, 'msg1', 1000, 10, [{ id: 'c1', command: 'probe' }]), + meta(3, 'c1', 'same line'), + ai(4, 'msg2', 1100, 10), + ai(5, 'msg3', 1200, 10, [{ id: 'c2', command: 'probe' }]), + meta(6, 'c2', 'same line'), + ai(7, 'msg4', 1300, 10), + ai(8, 'msg5', 1400, 10, [{ id: 'c3', command: 'probe' }]), + meta(9, 'c3', 'same line'), + ]); + const turns = sums.turns as { + cycles: { verdict: string; tokensWasted: number }; + inputSide: number; + }[]; + expect(turns[0].cycles.verdict).toBe('cycle-churning'); + expect(sums.tragedy).toMatchObject({ + cycleTurns: 1, + cycleTokensWasted: turns[0].inputSide, + }); + expect(sums.checksums).toMatchObject({ + roundsSumEqualsTurnsSum: true, + turnsSumEqualsSession: true, + }); + }); +}); From 676becac72bf1990e7f87ccc4d83b7d2c2555dcf Mon Sep 17 00:00:00 2001 From: axisrow Date: Sat, 10 Oct 2026 15:14:48 +0800 Subject: [PATCH 4/9] chore(ci): add workflow_dispatch trigger --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 778fc68d..803dc25c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: ci: From c7820dc9a5f1e8d567bb4dff37d44166eef3e42c Mon Sep 17 00:00:00 2001 From: axisrow Date: Sat, 10 Oct 2026 15:20:12 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(watcher):=20deterministic=2024h=20clock?= =?UTF-8?q?=20in=20turn-budget=20label=20=E2=80=94=20CI=20locale=20printed?= =?UTF-8?q?=20AM/PM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/infrastructure/FileWatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index f1b2446e..dbcd6593 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -112,7 +112,7 @@ function detectorIncidentToError(args: { ? ` (${new Date(budget.turnStartTs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', - hour12: false, + hourCycle: 'h23', // deterministic 24h — CI locale would print "06:46 AM" })})` : '') : 'current turn'; From bd7c38ada78bc241d77be79802a9e1920a2f7dbb Mon Sep 17 00:00:00 2001 From: axisrow Date: Sat, 10 Oct 2026 23:52:54 +0800 Subject: [PATCH 6/9] docs(turn): turnSpendStats slices number by the ledger canon, not the bell's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-#68-merge the detector (bell) numbers by transcript canon — teammate relays advance it — while buildLedger and the calibration CLIs number real user lines only. The slice-builder comment claimed detector parity; state the canon it actually implements. Co-Authored-By: Claude Code --- src/cli/turnSpendStats.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/turnSpendStats.ts b/src/cli/turnSpendStats.ts index 5c8945b9..45e8fd5b 100644 --- a/src/cli/turnSpendStats.ts +++ b/src/cli/turnSpendStats.ts @@ -123,10 +123,11 @@ async function auditFile(filePath: string): Promise { return { ...m, usage: m.usage ?? inner?.usage, model: inner?.model, toolCalls: [] }; }); - // turn slices (1-based numbering, same as the detector's turnNumber): + // turn slices (1-based numbering, the ledger's canon — real user lines via + // isTurnNumberLine, the same set buildLedger numbers; NOT the bell's + // turnNumber, which transcript canon and teammate relays advance): // turn 1 opens at the FIRST boundary; metadata before it is not a turn. - // Numbering parity: only real user lines consume a number (isTurnNumberLine); - // a compaction marker splits the slice but prints the SAME bucket number. + // A compaction marker splits the slice but prints the SAME bucket number. const slices: string[][] = []; const sliceTurnNos: number[] = []; let cur: string[] = []; From d99fc7c386dbeea324f712fd8d037a0c84fd91b9 Mon Sep 17 00:00:00 2001 From: axisrow Date: Sat, 10 Oct 2026 23:58:10 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(cli):=20guard=20session:sums=20main()?= =?UTF-8?q?=20with=20isDirectRun=20=E2=80=94=20no=20side=20effects=20on=20?= =?UTF-8?q?import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- src/cli/sessionSums.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/sessionSums.ts b/src/cli/sessionSums.ts index fc9512a1..f22fe8a4 100644 --- a/src/cli/sessionSums.ts +++ b/src/cli/sessionSums.ts @@ -19,6 +19,7 @@ import { splitSessionPath, turnVerdict, } from './analyzeSession'; +import { isDirectRun } from './args'; interface RollupRound { index: number; @@ -186,4 +187,6 @@ async function main(): Promise { const sums = buildSums(ledger, findings, hookSpentByTurn(rawLines)); console.log(JSON.stringify({ ...splitSessionPath(file), ...sums }, null, 2)); } -void main(); +if (isDirectRun(import.meta.url)) { + void main(); +} From 19a105844a5cb762f8368bda1894854967bc7560 Mon Sep 17 00:00:00 2001 From: axisrow Date: Sat, 10 Oct 2026 23:58:18 +0800 Subject: [PATCH 8/9] fix(cli): deep-dive --turn resolves the turn by ledger index, not array position Co-Authored-By: Claude Code --- src/cli/analyzeSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/analyzeSession.ts b/src/cli/analyzeSession.ts index a7d5462f..1fffce0f 100644 --- a/src/cli/analyzeSession.ts +++ b/src/cli/analyzeSession.ts @@ -1107,7 +1107,7 @@ function printTurnDeepDive( turnNo: number, opts: CliOpts ): void { - const turn = ledger.turns[turnNo - 1]; + const turn = ledger.turns.find((t) => t.index === turnNo); if (!turn) { console.error(`no turn #${turnNo} (session has ${ledger.turns.length})`); process.exitCode = 1; From ffb91fbde67394c42c3c05c3c7921d8e573a576d Mon Sep 17 00:00:00 2001 From: axisrow Date: Sat, 10 Oct 2026 23:58:23 +0800 Subject: [PATCH 9/9] =?UTF-8?q?test(turn):=20a=20sidechain=20user=20line?= =?UTF-8?q?=20opens=20no=20number=20=E2=80=94=20parity=20with=20chat=20chi?= =?UTF-8?q?ps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- test/main/utils/loopDetection.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/main/utils/loopDetection.test.ts b/test/main/utils/loopDetection.test.ts index 6282291d..9468e72b 100644 --- a/test/main/utils/loopDetection.test.ts +++ b/test/main/utils/loopDetection.test.ts @@ -453,4 +453,20 @@ describe('TurnBudgetDetector', () => { // the pre-compact 9.5M is gone expect(incident?.spent).toBe(10_500_000); }); + + it('a sidechain user line opens no turn (subagent prompts are not chips)', () => { + const det = new TurnBudgetDetector(); + const taskPrompt = { + type: 'user', + isMeta: false, + isSidechain: true, + content: 'find the parser bug', + timestamp: '2026-01-01T10:05:00Z', + } as unknown as ParsedMessage; + // app canon: only main-chain user lines are Turn chips — the Task + // subagent's opening prompt must not advance the number (relay axis is + // covered above: a relay DOES open a turn) + const incident = det.feed('s', [userTurn, taskPrompt, big('a', 12_000_000)], budget); + expect(incident?.turnNumber).toBe(1); + }); });