Skip to content

v1.14.0 ·

The cost report that was billing another project

The problem

aidlc cost <instance> reports numbers nobody believes. For interactive-cli-menu:

Corrected 2026-08-31. The table below is what the report showed at ideation and is left as the problem statement. Both halves of it turned out to be wrong, in ways this instance then fixed: $485.99 included 574 entries belonging to another repository, and the single-phase breakdown was the phaseWindows defect. The instance now reports $146.91 spread across all seven phases.

interactive-cli-menu (full-feature)
  implementation   $485.99   9.4h
  deployment       no data   8.2h
  design           no data   25.7h
  ideation         no data   4.1h
  requirements     no data   2.3h
  testing          no data   7m
  total: $485.99  time: 49.7h

Three things are wrong with that table, and the roadmap item only identified one of them.

1. The time column measures waiting, not working. 49.7h is the claim window. Real turn activity was on the order of hours; the largest single gap was 24.4h, overnight, because a gate transition asked a question and the answer came the next day. Anyone reading "49.7h" concludes the lifecycle is slower than it is.

2. Every dollar is attributed to one phase, and it is the wrong one. The five no data rows are not missing data — the money that belongs to them is sitting in the implementation row. phaseWindows() (recorder.ts:348-365) reads rec.timestamp from transition records, but the field is named at; an as unknown as cast hides the mismatch. Every transition is skipped, the function returns one window covering all time, and all four call sites seed it with 'implementation'. 18 of the 19 instances on the current transition format have 100% of their priced usage mislabelled. The 3 legacy instances that predate the field rename are the only ones reporting correctly — the code works precisely where the data is old.

3. Some instances report nothing at all. aidlc cost sync refuses: agent-id session, 5 candidate records — ambiguous, not guessing. That refusal is correct in spirit but wrong in mechanism: candidates are selected by file mtime against the claim interval, so it both misses files that hold relevant records and rejects the ordinary case of a long session spanning several rotated transcripts.

What is not wrong, contrary to the roadmap item's headline: the dollar amounts. The item claims cost is "28× too high" because it is measured over a wide window. It is not. recorder.ts:506-543 buckets each usage record by its own timestamp and prices the token counts the transcript already carries; there is no term that scales with window width. An idle gap contains no records by definition, so excluding it changes nothing. Correcting this before writing requirements is the point of measuring first — it converts the largest claimed part of the work into no work at all.

How it could be solved

Option A — Gap threshold over record timestamps

Sum inter-record gaps below a cutoff; treat anything above it as idle. What the roadmap item proposed, with the cutoff to be derived from measured data.

Measured and rejected. Across 13,516 gaps the distribution decays smoothly with no bimodal trough, and the total swings 2.6× across the plausible range (24.15h at 60s → 62.06h at 1800s) with no principled place to cut. guards.mjs's 25% threshold worked because its two populations were disjoint; these are not. Any number chosen here would be picked and described as derived — the project has already paid for that once ("the 5-minute figure was picked for the probe, not derived"). Cheap to build, permanently arguable, and it invites re-litigation every time someone dislikes a number.

Option B — Structural turn boundaries from promptId (recommended)

Group usage records by the turn the platform already identifies. promptId is absent from usage-bearing records but reachable from all of them by walking parentUuid: 13,574 of 13,638 records (99.5%) resolve to one of 270 turns, with no heuristic and no threshold.

The number is reproducible rather than tuned, which is the whole argument for it. It also gives G5 a natural metric — assert the 99.5% coverage, and downgrade fidelity when it falls.

One caveat, measured: promptId alone is not an active-time measure. Summing first-to-last record time per turn yields 198.91h, more than any threshold produces, because one turn can stay open across a long human pause (p99 duration 17.5h; max 18h — a turn that waited overnight on a permission prompt is one long turn). The fix is to sum the union of per-record spans within a turn, or to apply a small gap cap inside structural boundaries, where the cut is far less sensitive because it never has to separate "thinking" from "gone for the day". Choosing between those two is the one question left for design, and unlike Option A it is a bounded choice inside a correct frame.

Risk: depends on undocumented fields (C4), mitigated by fidelity-based visible degradation.

Option C — Fix the phase collapse only, defer active time

Ship G2 and G4 — a one-line field-name fix plus the candidate-selection fix — and leave the time column claim-scoped with a footnote saying so.

Genuinely tempting, and it is the highest value-per-hour on the board: the phase collapse corrupts 100% of the per-phase breakdown across 18 instances and the fix is trivial. But it leaves the headline complaint standing, and the item is a Do now precisely because "what did this cost" is the product. Best treated as sequencing inside Option B rather than an alternative to it.

Recommendation — confirmed 2026-08-30

Option B, sequenced so Option C lands first. Fix the phase collapse and candidate selection early — they are small, independently valuable, and independently verifiable — then build turn-scoped active time on promptId behind the provider seam. Reject Option A outright and record why, so the threshold question is closed rather than reopened in design.

The item estimated 3-4 days. Findings 1 and 3 remove the dollar re-derivation and the threshold derivation, and Finding 4 replaces the hardest remaining judgement with a structural fact. This should land smaller than estimated — and the largest single improvement to the report is a corrected field name.

How AIDLC solves it

Part 0 — cross-repo contamination. locateByInterval walked every project under ~/.claude/projects with no repository check, and projectClaudeCodeRecords — which encodes the correct rule and explains this exact hazard in its own docblock — sat 200 lines above, uncalled. 711 entries / 262.5M tokens had already been attributed from an unrelated repo. New packages/cli/src/cost/provenance.ts classifies an entry backwards from its cursor; packages/cli/src/cost/purge.ts removed them, purging only on positive evidence.

Part 1 — phase attribution. phaseWindows read rec.timestamp behind an as unknown as cast while TransitionRecord declares at. Every transition was skipped, so one window covered all time and all four call sites seeded 'implementation' — 18 of 19 instances had 100% of their priced usage mislabelled. The cast is gone, so a renamed field is now a compile error.

Part 2 — re-derivation. 4,134 entries relabelled from ledger data alone; 6 split from transcripts into 1,868 per-record entries; 917 records moved to unattributed because they fell inside no claim or two. Splits are bounded by each entry's own coverage window — one entry's cursor sits in a 633-record transcript while the entry covers 80 of them, so splitting the whole file would have inflated it 23×.

Part 3 — turn-scoped active time. active_ms on each entry, the union of [parent, own] record spans, computed per bucket at capture.

Part 4 — candidate selection. By record timestamp, not file mtime. Several transcripts for one long session is normal, not ambiguity. aidlc cost sync now emits zero ambiguous skips.