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
phaseWindowsdefect. 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.