Token-ROI Phase 1 Spec — core + turn report¶
Read docs/METRICS-STANDARD.md first (formulas §2, savings §5, turn line §10). Phase 1 delivers the per-turn pipeline end-to-end: parse Claude session JSONL → compute ETC + savings → append unified ledger → print one-line turn report. Attribution/gate/report verbs are Phase 2 — do NOT build them.
Solution layout (repo root)¶
TokenRoi.sln
src/TokenRoi.Core/ netstandard-free, net8.0 (or highest installed SDK; check dotnet --list-sdks)
src/TokenRoi.Cli/ console, System.CommandLine or manual arg parse (keep deps minimal)
tests/TokenRoi.Tests/ xunit + Bogus (mocked-coverage mandate: every Core class tested, no I/O in unit tests — inject readers)
pricing.json rate card (see below)
Core classes¶
UsageBlockrecord: InputTokens, OutputTokens, CacheCreationInputTokens, CacheReadInputTokens, Model, Timestamp.ClaudeSessionParser- Input: path to a session
.jsonl(Claude Code format: one JSON object per line; assistant events havetype:"assistant",message.usage{input_tokens,output_tokens,cache_creation_input_tokens,cache_read_input_tokens},message.model, plus top-levelcwd,gitBranch,sessionId,timestamp). ParseLastTurn(stream): a "turn" = contiguous tail of events from the LASTtype:"user"event that is a real user prompt (not tool_result — user events whosemessage.contentis an array containingtool_resultitems are NOT prompt boundaries) through end of file; aggregate all assistant usage blocks in that span (main thread; sidechain/subagent files are separate — ignore).ParseSession(stream): all turns + session metadata (cwd, gitBranch, sessionId).- Must be resilient: skip malformed lines, missing usage blocks, unknown fields (forward-compat).
EtCalculatorEt(UsageBlock)= 1.0·input + 1.0·cacheCreate + 0.1·cacheRead + 4.0·outputCostUsd(UsageBlock, PricingTable)= input×inRate + cacheCreate×cacheWriteRate + cacheRead×cacheReadRate + output×outRate (per MTok). Unknown model → nearest prefix match (e.g.claude-sonnet-*), elseconfidence:"estimated"with fallback rate + warning.PricingTableloaded frompricing.json. Seed rates ($/MTok, in/cacheWrite/cacheRead/out):- claude-fable-5 & claude-opus-*: 15 / 18.75 / 1.5 / 75
- claude-sonnet-*: 3 / 3.75 / 0.3 / 15
- claude-haiku-*: 1 / 1.25 / 0.1 / 5
- gpt-5: 1.25 / — / 0.125 / 10 ; gemini-: 2 / — / 0.5 / 12 ; grok-: 3 / — / 0.75 / 15 ; ollama/: 0 (tracked tokens only)
- Comment in file: VERIFY before trusting dollar outputs; rates change.
GovernorReader: parseC:\Users\fives\.claude\usage-governor\state.json→ { tier, pctUsed, projectedPct }. Weekly pct: if a weekly field exists use it, else null → printwk —. Tolerate file missing (all null).CavemanReader: latest entry for a sessionId from.caveman-history.jsonl(path configurable) → estSavedTokens/estSavedUsd. MODELED bucket.SavingsCalculator:- MEASURED: cacheReadSavings = (inRate − cacheReadRate) × cacheReadTokens.
- MODELED: caveman est (from reader).
- Output: two decimals, separate fields — never summed.
LedgerWriter: append JSONL to%LOCALAPPDATA%\TokenRoi\ledger.jsonl:{ts, machine, tool:"claude-code", model, in, out, cacheRead, cacheCreate, costUsd, confidence:"exact", sessionId, cwd, branch, ticket?}. Ticket = regex\b([A-Z]{2,6}-\d+)\bover gitBranch, else null. Create dir if missing. Append-only, one File.AppendAllText with newline (atomic enough for single writer).
CLI verbs¶
token-roi turn [--session <path>] [--project-dir <dir>] [--no-ledger]- No --session: find newest
*.jsonlunderC:\Users\fives\.claude\projects\<encoded-project-dir>\for --project-dir (encode: full path,:→-(drop),\→-, i.e. C:\Users\fives\source\repos →C--Users-fives-source-repos), else newest across all projects. - Prints EXACTLY (suppress zero/null segments):
⛽ $0.19 ET 12.4k (in 8.1k cr 3.2k out 1.1k) | 5h 34% YEL wk 61% | cache 78% | saved $0.04 meas + ~$0.11 est | WW-42cr= cache_read; cache % = cacheRead/(input+cacheRead); tier 3-letter (GRN/YEL/RED/BLK); k-format 1 decimal.
- Also appends ledger row unless --no-ledger.
token-roi scan --session <path>: whole-session totals, same line format prefixedΣ session.
Tests (xunit + Bogus)¶
- Parser: turn boundary correctness (tool_result user events NOT boundaries), malformed line resilience, empty file, usage aggregation across multi-assistant-message turn. Use inline JSONL string fixtures built with Bogus for field values.
- EtCalculator: formula exactness, prefix model matching, unknown model fallback.
- SavingsCalculator: measured vs modeled never summed (assert distinct fields).
- LedgerWriter: ticket regex extraction (WW-42, ABC-123, no match), row schema.
- GovernorReader/CavemanReader: missing file → nulls, not throw.
- Turn line formatter: golden-string tests incl. zero-suppression cases.
Build gates (must pass before you finish)¶
dotnet build clean, dotnet test green. Run token-roi turn against a REAL session file from C:\Users\fives.claude\projects\C--Users-fives-source-repos\ (pick newest) and include its output line in your report. Handle UTF-8 BOM and very large session files (stream, don't ReadAllText).
Style: file-scoped namespaces, nullable enabled, no external deps beyond System.CommandLine (optional), xunit, Bogus. README.md stub linking docs/METRICS-STANDARD.md.