Repository navigation
fix(turn-budget): one turn, one number — hook, bell and panel parity - #68
Conversation
Live audit of session 67105434 showed both numbers were correct for different turns; the notification never named its turn. Canon gains isMainChainAssistantLine (hook/CLI/panel exclude sidechain+synthetic by one definition), the incident carries turnNumber+turnStartTs so the bell reads 'Turn budget · turn 8 (07:08) · 15.1M / 15.0M', the panel's Re-read rows show the turn start time, and parity is pinned by a batch-feed test plus a new --audit CLI (parity OK on 19+278+82 live turns). Co-Authored-By: Claude Code <noreply@anthropic.com>
Top rows (the biggest turns — the ones worth matching against a bell notification) were exactly the ones whose turn start time got truncated away. Co-Authored-By: Claude Code <noreply@anthropic.com>
Red→green: a session with a compaction boundary lost its pre-compact reread rows on 'Current' (the 15M turns of session 67105434 were invisible — phase snapshot only), and the bell's 15.1M read as a mismatch against the panel's final 15.46M. Now phaseInfo.rereadAll keeps every reread injection session-wide (tagged phaseNumber, rendered with a ph badge), ChatHistory merges it into the panel input, and the notification says '15.1M at crossing / 15.0M' so the edge-triggered snapshot is not mistaken for the turn's final sum. Tests 955/955; CLI audit parity OK (19 turns). Co-Authored-By: Claude Code <noreply@anthropic.com>
toLocaleTimeString without hour12 renders "05:03 PM" under en-US (CI)
but "17:03" under ru, so the startup catch-up message broke the
/turn N (\d{2}:\d{2})/ assertions only in CI. hour12: false makes the
bell message deterministic in every locale.
Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow
left a comment
There was a problem hiding this comment.
Verdict: changes requested
The accounting core is solid. Verified: the canon filter matches the detector predicate; the --audit indexing is correct (detTotals[0] is the pre-first-boundary prefix, one push per boundary plus the final push, so detTotals[i+1] is the final total of turn i+1); the batch-feed parity test exercises real prefixes; the ChatHistory merge of rereadAll cannot double-count (reread is excluded from totalEstimatedTokens, the pill total comes from lastAssistantTotalTokens). The jsonl.test.ts fixture change is correct and required: after #67 mixed relay+text is user text (the whole-content rule makes isUserChunkLine true), so both edited tests fail on current main — the pure-relay fixture restores their original intent.
Two findings, both on the core deliverable of this PR — matching a bell notification to a panel row:
-
Turn numbering bases diverge (inline, loopDetection.ts). The detector increments turnNumber on canon isTurnBoundary = user messages + compaction summaries. The panel and chat Turn N number only user messages + teammate relays (aiTurnIndex(userCount + relayCount); jsonl.ts turnCount explicitly gives compaction no number). A compaction before the crossing turn inflates the bell number by the compaction count; teammate relays deflate it. Session 67105434 — the session that motivated this PR — had compaction phases, so the mismatch is guaranteed in exactly the target scenario. The canon already provides isTranscriptTurnLine as the chat Turn N semantics; number the incident with it while keeping the spend-bucket reset on isTurnBoundary.
-
Row time not locale-pinned (inline, RereadSection.tsx). Commit d92e999 pinned the bell time to 24h with hour12:false for a locale-independent message, but the panel row renders without it — in a 12h locale the panel shows 7:08 PM against the bell 07:08. Reuse the same options.
Notes, no change required: the --audit comment claims the detector path is exactly what FileWatcher streams, but it feeds raw JSONL lines rather than ParsedMessage — accounting-equivalent, wording only. The new parity tests cannot catch finding 1: the fixture has one turn and no compaction or relay, where both numbering bases coincide. Consider a preview-layer test that mixed relay+text IS picked as firstUserMessage — the #59 canon is currently untested at that layer after this fixture swap.
…inned to 24h Review findings on PR #68: 1. loopDetection.ts numbered incidents with isTurnBoundary (user+compact), but the chat Turn N chips count transcript turns (user+relay) minus sidechain and compact summaries — a compaction before the crossing inflated the bell number, a relay deflated it. Number with the chip canon (isTranscriptTurnLine && !isSidechain && !isCompactSummary); the spend bucket still resets on the hook boundary. Regression tests cover both directions. 2. RereadSection rendered row time without hour12:false while d92e999 pinned the bell time to 24h — in a 12h locale the panel showed 7:08 PM against the bell 07:08. Same options now. Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow
left a comment
There was a problem hiding this comment.
Verdict: approved
Re-review of 7feaa7b. Both findings from the previous round are addressed exactly along the suggested direction, and the new tests pin the semantics.
-
Turn numbering (loopDetection.ts): the detector now numbers turns with isTranscriptTurnLine minus isSidechain/isCompactSummary guards — the jsonl turnCount / chat chip canon — while the spend bucket still resets on isTurnBoundary, carrying the number and start timestamp across the reset. Verified the ordering: numbering increments before the bucket swap so the fresh bucket carries the incremented number; a relay advances the number without resetting the bucket; a compaction resets spend without advancing the number (the guards are needed because isUserChunkLine returns true for masquerading compact summaries — the code comment documents this). Compaction no longer inflates the bell number, relays no longer deflate it; the number now matches the panel Turn N and chat chips in the compaction and team-relay scenarios that motivated the PR.
-
Row time (RereadSection.tsx): hour12:false added — the panel row and the bell now render identical locale-independent times.
New tests cover both semantics with correct arithmetic: the relay test pins number 2 with spend flowing across it (6M + 6M = 12M in one bucket), the compaction test pins number 1 with the pre-compact 9.5M dropped and the crossing at 10.5M post-compact. Shared re-export of isTranscriptTurnLine verified.
Residual notes from the previous round remain optional and non-blocking: the --audit wording about the detector path, and a preview-layer test that mixed relay+text is picked as firstUserMessage. No changes required.
… bell's 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 <noreply@anthropic.com>
* fix(turn): compaction is not a turn — one numbering everywhere - 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 <hhru session> → 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 <file> --turn N): cycle_motif / probe_no_progress findings and the «цикл или длинный ход» verdict Co-Authored-By: Claude Code <noreply@anthropic.com> * chore(ci): retrigger workflow * feat(session:sums): machine-readable rollup — session → turns → rounds - pnpm session:sums <file.jsonl> 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 <noreply@anthropic.com> * chore(ci): add workflow_dispatch trigger * fix(watcher): deterministic 24h clock in turn-budget label — CI locale printed AM/PM * docs(turn): turnSpendStats slices number by the ledger canon, not the bell's 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 <noreply@anthropic.com> * fix(cli): guard session:sums main() with isDirectRun — no side effects on import Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(cli): deep-dive --turn resolves the turn by ledger index, not array position Co-Authored-By: Claude Code <noreply@anthropic.com> * test(turn): a sidechain user line opens no number — parity with chat chips Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: axisrow <axisrow@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com>
Problem
Session 67105434 showed
Turn budget · 15.1M / 15.0Min the notification bell, while the Visible Context panel showedRe-read · Turn 13 · 60 rq · 7 425 348 tok. Half the spend seemed to vanish between the two surfaces, and there was no way to tell which turn each number belonged to.Live-file audit (scripts/turn-accounting canon): both numbers were correct — for different turns. 15.1M was the running total at the moment turn 3/8 crossed the 15M budget (final sums 15.46M / 15.63M, both in earlier context phases, so the panel — which shows the current phase — couldn't show them). 7.43M is the panel's Turn 13, exact to the token. The notification simply never named its turn.
Changes
scripts/turn-accounting.mjs): newisMainChainAssistantLine(same semantics asloopDetection.ts:314);analyzeTurnapplies it, the CLI walker (turnSpendStats.ts feedLine) imports it — the hook, the detector and the panel now exclude sidechain/synthetic lines by the same definition.loopDetection.ts,FileWatcher.ts):TurnBudgetIncidentcarriesturnNumber+turnStartTs; the message readsTurn budget · turn 8 (07:08) · 15.1M / 15.0M — …(falls back tocurrent turnwhen the file opened mid-turn).contextTracker.ts,RereadSection.tsx):RereadInjection.turnStartTs→ each Re-read row shows the turn's start time next toTurn N, so a notification and a panel row can be matched unambiguously.turnAccounting.parity.test.tsnow assertsanalyzeTurn(hook) ==TurnBudgetDetector.feedfed in batches of 1/3/5 at every prefix (the live FileWatcher shape) ==sumTurnReread(panel), plus sidechain exclusion everywhere. Detector got acurrentTurnTotalseam for it.--auditCLI:pnpm turn-spend:stats -- --audit <file.jsonl>prints the three accountings side by side per turn and a parity verdict. Live runs:parity OKon 19 + 278 + 82 turns across three sessions, including the 15.46M / 15.63M turns.Number inventory (what is covered where)
turn-accounting.mjs(single canon)--audit(this PR)TurnBudgetDetector(same canon, edge snapshot)Fixes the "where did 15M go" confusion; the numbers themselves were never wrong.