@bglocation/tune-context 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "tune-context",
11
11
  "source": "./",
12
12
  "description": "Context-efficiency configurator for Claude Code: doctrine skills (token-efficiency, caveman, cdd, phase-workflow) plus a tune-context configurator that scaffolds a lean CLAUDE.md, subagents, settings and hooks.",
13
- "version": "2.4.0",
13
+ "version": "3.0.0",
14
14
  "author": {
15
15
  "name": "Szymon Walczak",
16
16
  "url": "https://bglocation.dev"
@@ -2,7 +2,7 @@
2
2
  "name": "tune-context",
3
3
  "displayName": "tune-context",
4
4
  "description": "Context-efficiency configurator for Claude Code: doctrine skills (token-efficiency, caveman, cdd, phase-workflow) plus a tune-context configurator that scaffolds a lean CLAUDE.md, subagents, settings and hooks.",
5
- "version": "2.4.0",
5
+ "version": "3.0.0",
6
6
  "author": {
7
7
  "name": "Szymon Walczak",
8
8
  "url": "https://bglocation.dev"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,199 @@ All notable changes to `@bglocation/tune-context` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0] — 2026-10-15
9
+
10
+ EPIC-010 — the meter of the cost measurement comes from the same installed
11
+ version as the switch, and every artifact names the code that computed it.
12
+ **The major bump is for one deliberately breaking change** — the eval data
13
+ contract (see Changed): rows without a meter stamp and `analyze@1` aggregates
14
+ are refused. Everything else is additive.
15
+
16
+ ### Added
17
+
18
+ - **`tune-context eval <step>`** (TASK-095) — `prompt-metrics`, `analyze`,
19
+ `verdict`, `report`, `adoption-report`, named after their modules, no aliases,
20
+ from any directory. Until now the meter existed only as `node eval/<file>.mjs`,
21
+ a relative path that resolves in a checkout of this repo and nowhere else, so
22
+ the switch (from `PATH`) and the meter (from `cwd`) could be two versions.
23
+ Everything after the step goes verbatim to the step's own parser:
24
+ `tune-context eval prompt-metrics --help` prints the step's usage. The two
25
+ doors print the same bytes, held to a baseline written before the refactor.
26
+ - **`tune-context eval verdict`** (TASK-096) — the verdict as JSON on stdout (the
27
+ object the report embeds) and one line per gate on stderr, ending in the
28
+ finding. Exit 0 for every verdict, a withheld one included. `verdict` and
29
+ `report` read their input through one function, so the page cannot accept what
30
+ the verdict refuses.
31
+ - **The meter stamp** (TASK-097) — rows carry `meter_version`, the aggregate
32
+ `analyzed_by`, the verdict `judged_by`: the bare release number for an npm
33
+ install, `<version>+<sha12>` for a checkout, `.dirty` for uncommitted changes in
34
+ `eval/ cli/ hooks/ bin/ package.json`, `+unknown` where git could not answer.
35
+ A version number alone would not do: under `npm link` a fix committed before
36
+ the release carries the number of the bug it fixes. `prompt-metrics` prints
37
+ `meter <stamp>` as the first line of its summary.
38
+ - **A seventh verdict gate, `meter_mixed`** (frozen by the PO on 2026-10-15) — the
39
+ whole chain (paired rows, `analyzed_by`, `judged_by`) must carry exactly one
40
+ stamp that names code; `.dirty`, `+unknown`, a second stamp or a step with none
41
+ hold the verdict, and the reason names the step that brought the stranger.
42
+ The six earlier thresholds are untouched.
43
+ - **`tune-context experiment archive --out <dir>`** (TASK-098) — the series'
44
+ transcripts (every session on an `exp/…` branch) and the ledger, copied out of
45
+ reach of Claude Code's retention, with `manifest.json` and `SHA256SUMS`
46
+ (`shasum -a 256 -c`). Append-only: a grown file replaces its copy, any other
47
+ change refuses the run. A destination inside a git work tree that git does not
48
+ ignore is refused — a transcript is the whole conversation.
49
+ - **`--ledger <path>`** for `prompt-metrics` and `analyze`, so an archive
50
+ recounts as `branch+ledger` on any machine.
51
+
52
+ ### Changed
53
+
54
+ - **The eval data contract — deliberately breaking** (EPIC-010, PO decision 4).
55
+ Schemas are now `tune-context/analyze@2`, `tune-context/verdict@2` and
56
+ `tune-context/verdict-thresholds@2`. `analyze` refuses a whole input containing
57
+ a single row without `meter_version` (exit 1, nothing on stdout, file and line
58
+ on stderr), and `verdict`/`report` refuse an `analyze@1` aggregate. Scripts
59
+ that feed rows or aggregates written by an earlier version get exit 1. That is
60
+ the point: unstamped data cannot say which meter computed it, and the way back
61
+ to it is recomputing the transcripts — from an `experiment archive`, with the
62
+ current version or with `npx -p @bglocation/tune-context@<version>`.
63
+ - **The cost evaluation runbook and procedure** run the series from a registry
64
+ release (`npm link` only for an unreleased fix, from a clean commit), record
65
+ the meter stamp before the first pair, and archive after every arm.
66
+ - **Skills** — `token-efficiency` and `tune-context` point at
67
+ `tune-context eval adoption-report` instead of `node eval/…`, which never
68
+ resolved outside this repo, and `tune-context` names
69
+ `tune-context eval prompt-metrics` as the source of token cost, where it said
70
+ only OTEL with a collector had it. Skills are copies: **re-run
71
+ `tune-context init` after upgrading** to get them.
72
+ - **`adoption-report` points at `tune-context eval prompt-metrics`** (TASK-100) —
73
+ in `--help` and in the report's closing note on cost, instead of
74
+ `eval/prompt-metrics.mjs`, a path that resolves only in a checkout of this repo.
75
+ Likewise `analyze` — its `--help` and the last line of its summary — points at
76
+ `tune-context eval verdict` instead of a task number that is not in the package.
77
+
78
+ ### Fixed
79
+
80
+ - **A reader that leaves early is a normal end for every eval step** — `report`
81
+ writing a long page, or `analyze` an aggregate of many tasks, into `| head`
82
+ ended in an `EPIPE` stacktrace; only `prompt-metrics` and `verdict` had a
83
+ guard. One guard now, called by each step.
84
+ - **`report --out` into a directory that does not exist** is one line and exit 1,
85
+ not a stacktrace.
86
+
87
+ ## [2.5.0] — 2026-09-03
88
+
89
+ ### Fixed
90
+
91
+ - **`prompt-metrics` charged 45% of all cache-read tokens twice** (found while
92
+ reviewing TASK-089). A transcript repeats entries verbatim: 11 296 of 36 463
93
+ local entries (31%) carry a `uuid` already written *earlier in the same file*,
94
+ 6 739 of them assistant turns with a `usage` block, the copies differing only
95
+ in a cosmetic `slug`. The collector summed both. Local run total drops from
96
+ $4 566 to $2 323 — an inflation of **1.97×**, several times the effect
97
+ EPIC-009 is trying to measure. Repeats are now skipped (scoped to one file:
98
+ zero `uuid`s are shared between files) and counted on stderr, so the drop
99
+ explains itself rather than looking like a different tool.
100
+ **Every cost figure this project produced before 2026-08-02 is too high**;
101
+ `COST_EVALUATION_PROCEDURE.md` §11a carries the correction, including the
102
+ delegation baseline, which moves from ~0.7 to ~0.3 per session. The frozen
103
+ §10 thresholds are deliberately left alone — the correction makes the
104
+ delegation gate stricter relative to the norm, and refitting a threshold to
105
+ new data is the thing that section forbids.
106
+
107
+ - **`prompt-metrics` billed one API response as many** (TASK-092, reported by a
108
+ Claude Code session working in a different project). One response reaches the
109
+ transcript as *several* entries — one per content block: thinking, text, each
110
+ `tool_use` — every one with its own `uuid` but carrying an **identical copy of
111
+ the same `usage` block**. The `uuid` dedup above cannot see this class by
112
+ construction: those uuids differ by design. Over 19 transcripts, 11 295
113
+ entries for 5 954 responses. `usage` is now keyed to the RESPONSE
114
+ (`message.id`, falling back to `requestId`) and the extra entries are counted
115
+ on stderr. On the same frozen transcripts the run total drops **$1 372.69 →
116
+ $679.35 (2.02×)**, on top of the 1.97× above.
117
+ The inflation is **not flat** across token classes (cache-read 1.82×, output
118
+ 2.17×, cache-write 2.39×, input 2.45×) and does **not** cancel in a ratio of
119
+ two arms: the factor *is* the number of content blocks per turn — how many
120
+ tools the model reached for, which is the variable the doctrine sets out to
121
+ move. Per-session spread 1.66×–2.24×.
122
+ The guard sits **after** the `tool_use` loop, because those blocks are *not*
123
+ duplicated (6 403 blocks, 6 403 distinct ids): skipping whole entries would
124
+ have dropped ~2/3 of every tool call and gutted the adoption half of the
125
+ analysis. All three turn counters are guarded — `api_turns`, `sidechain_turns`
126
+ and the unattributed bucket — and an entry with neither `message.id` nor
127
+ `requestId` is **billed** and reported, never assumed unique.
128
+ **Every cost figure this project produced before 2026-09-03 is too high
129
+ again**, including the frozen EPIC-009 rows under `docs/` and the TASK-090
130
+ verdict computed from them. The skew correlates with the arm, so that verdict
131
+ rests on numbers that have to be recomputed before it means anything.
132
+
133
+ - **The unattributed bucket dropped tool calls and server-tool cost**
134
+ (TASK-093). An entry whose `parentUuid` chain does not resolve never reaches
135
+ the row path, so its `tool_use` blocks reached no counter at all, and its
136
+ `usage.server_tool_use` was dropped by `addUsage()`, which copies token fields
137
+ only. The two are different in kind: the tool calls are an **adoption**
138
+ denominator, not money — a tool call is paid for in tokens, and those were
139
+ always billed here — while web search is billed **per request** ($10/1000), so
140
+ counting the entry's tokens does not cover it. Measured: 1 tool call in 66 765
141
+ entries, and **0** web searches in the whole local corpus, on 30 061 entries
142
+ that carry a `server_tool_use` block — the cost hole is dormant, not closed.
143
+ The bucket now carries `tool_calls`, `agent_spawns`, `web_search_requests` and
144
+ `web_fetch_requests`, printed on stderr only when non-zero, and an
145
+ unattributed subagent spawn is named as UNCAPTURED cost (class 3) rather than
146
+ going missing from the one counter that sizes that blind spot. `server_tool_use`
147
+ is counted once per response (it rides inside `usage`, so it *is* duplicated);
148
+ the `tool_use` blocks are counted per entry (they are not). Reconciled against
149
+ an independent counter over three populations: tool calls now agree **to the
150
+ unit** (6 403 / 99 / 18) without the manual correction the TASK-092 review
151
+ still needed.
152
+
153
+ ### Added
154
+
155
+ - **`eval/report.mjs` — the measurement as one self-contained HTML file**
156
+ (TASK-091, EPIC-009). `node eval/report.mjs --aggregate aggregate.json --out
157
+ report.html`: data embedded as JSON, charts as inline SVG, **no network and no
158
+ build step** — it opens from `file://` on a machine with no internet, which is
159
+ where it will actually be read. The failure it is built against is not a wrong
160
+ number but a right number about the wrong thing: a chart persuades harder than
161
+ the caveat under it, so **when a TASK-090 gate holds the arm-comparison chart
162
+ is not drawn at all** — not greyed out, not captioned. The per-pair costs stay
163
+ in a table, because those are what a repeat run is planned from. The order
164
+ effect is printed the same size and in the same place as the arm effect (§11b),
165
+ a token count never appears without its five classes, the two pools are
166
+ rendered by two calls that share no accumulator, and the honesty counters —
167
+ rejected rows with reasons, sessions dropped for switching arm, the delegation
168
+ cost that is a floor rather than a total — are a section rather than a footer.
169
+ Visuals follow the `dataviz` skill: palette validated in light and dark,
170
+ zero-baseline axes, hairline chrome. 22 tests, one group per acceptance
171
+ criterion, and the load-bearing one asserts the **absence** of a chart.
172
+
173
+ - **`eval/analyze.mjs` — the aggregate per arm** (TASK-089, EPIC-009). Rows are
174
+ data; this is the step between them and the sentence "that arm cost less" —
175
+ and it deliberately stops before the sentence, because the thresholds that
176
+ would justify it are frozen by the PO in TASK-090. The paired measurement
177
+ (branch `exp/<task>/<arm>`) and ordinary work (attributed by the TASK-087
178
+ ledger) come out as two objects with **different shapes** and no total above
179
+ them, so the two populations cannot be added by accident. Per arm: prompts,
180
+ sessions, pairs, cost as median **and** spread (a single mean hides the one
181
+ prompt that read 200 k tokens), `work_s` rather than time-since-submission,
182
+ tokens always in five classes, tools, delegations, model/effort distribution,
183
+ and `pass_order` per pair so the order effect can be subtracted from the arm
184
+ effect (§11b).
185
+ - **Discarding is output, not silence.** Every row lands in an arm or in a named
186
+ counter, and `rows_in = rows_used + rows_rejected` is asserted by a test. A
187
+ session whose rows disagree about the arm is dropped **whole** (the arm is a
188
+ property of the session: `settings.json` and MCP load at startup), a branch
189
+ that mimics the convention without matching it goes to a counter rather than
190
+ to the nearest plausible arm, and an unpriced row leaves the cost but stays in
191
+ the sample.
192
+ - **Subagent cost is now measured from below** (TASK-089 criterion 8). The
193
+ result of an `Agent` call carries its own `usage` and `resolvedModel`, so each
194
+ delegation is priced with the existing table and rides on the row as
195
+ `delegations[]` — deduplicated by `agentId`, because the identical entry is
196
+ written to several transcript files and without that the figure comes out 2×.
197
+ It is a **lower bound** and is never added to the parent session's cost; the
198
+ spawns that left no priced result are counted beside it, since that part is
199
+ cost we have no figure for at all.
200
+
8
201
  ## [2.4.0] — 2026-07-31
9
202
 
10
203
  ### Added
@@ -541,6 +734,8 @@ action needed on upgrade.
541
734
  (`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
542
735
  suite, and the adoption report (`eval/adoption-report.mjs`).
543
736
 
737
+ [3.0.0]: https://gitlab.com/bglocation/tune-context/-/tags/v3.0.0
738
+ [2.5.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.5.0
544
739
  [2.4.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.4.0
545
740
  [2.3.1]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.1
546
741
  [2.3.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.0
package/README.md CHANGED
@@ -6,7 +6,8 @@ scaffolds a cost-optimal, high-quality setup (lean CLAUDE.md + skills + subagent
6
6
  + settings + hooks + MCP).
7
7
 
8
8
  **Self-contained** — zero dependency on any host repo. Ships two ways: a Claude
9
- Code **plugin** and an **npm CLI** (`tune-context init`), both under
9
+ Code **plugin** and an **npm CLI** (`tune-context init`; plus `verify`, and
10
+ `experiment` and `eval` for measuring the doctrine's cost), both under
10
11
  `@bglocation`. Incubated inside the [`code-search-mcp`](https://gitlab.com/bglocation/code-search-mcp)
11
12
  project, now extracted to its own repository.
12
13
 
@@ -16,9 +17,9 @@ tune-context/
16
17
  ├── skills/ # token-efficiency, caveman, cdd, phase-workflow, tune-context (+ templates/)
17
18
  ├── agents/ # explore-cheap, reviewer (model right-sizing)
18
19
  ├── hooks/ # PreCompact state-doc, SessionStart re-inject, adoption log, context reminder + hooks.json
19
- ├── bin/ + cli/ # `tune-context init` — deterministic detect → generate → verify
20
- ├── eval/ # adoption-report.mjs (the rag-usage analog), prompt-metrics.mjs, pricing.mjs
21
- └── scripts/ # verify-pack, smoke-init, sync-tune-context (dev/maintenance)
20
+ ├── bin/ + cli/ # `tune-context init | verify | experiment | eval` — deterministic, no model in the loop
21
+ ├── eval/ # adoption-report.mjs (the rag-usage analog) + mcp-probe.mjs, prompt-metrics.mjs, pricing.mjs, arm.mjs, analyze.mjs, verdict.mjs, report.mjs
22
+ └── scripts/ # verify-pack, verify-plugin, smoke-init, sync-tune-context (dev/maintenance)
22
23
  ```
23
24
 
24
25
  ## Quick start
@@ -165,7 +166,7 @@ claude --plugin-dir /path/to/tune-context # or a local che
165
166
  ```
166
167
 
167
168
  The plugin manifest (`.claude-plugin/plugin.json`) and hook wiring
168
- (`hooks/hooks.json`, via `${CLAUDE_PLUGIN_ROOT}`) register the four hook events
169
+ (`hooks/hooks.json`, via `${CLAUDE_PLUGIN_ROOT}`) register the five hook events
169
170
  automatically; `bin/tune-context` is added to `PATH` while the plugin is enabled.
170
171
  The marketplace catalog is [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json).
171
172
 
@@ -246,7 +247,7 @@ by `tune-context init`), not here.
246
247
  ## Measuring adoption (does the doctrine actually get used?)
247
248
  `hooks/adoption-log.mjs` logs one JSONL line per event to
248
249
  `~/.claude/tune-context/adoption.jsonl` by default (under `CLAUDE_CONFIG_DIR`, the
249
- override's `tune-context/adoption.jsonl` — see above), and `eval/adoption-report.mjs`
250
+ override's `tune-context/adoption.jsonl` — see above), and `tune-context eval adoption-report`
250
251
  turns that into a report — code search (semantic RAG vs the Grep tool, with
251
252
  shell-plumbing greps kept out of the RAG share), doctrine skills, subagents, and
252
253
  re-read churn — closing with an *earned* verdict: withheld below a PO-frozen floor of
@@ -321,10 +322,10 @@ instead of printing a number the doubling would have faked.
321
322
  `~/.claude/tune-context/adoption.jsonl` by default (created on first write; under
322
323
  `CLAUDE_CONFIG_DIR` it's the override's `tune-context/adoption.jsonl` instead).
323
324
 
324
- 4. **Read the report** any time, from this checkout (or the installed package):
325
+ 4. **Read the report** any time, from any directory:
325
326
  ```bash
326
- node eval/adoption-report.mjs # default log location
327
- node eval/adoption-report.mjs --log <path>
327
+ tune-context eval adoption-report # default log location
328
+ tune-context eval adoption-report --log <path>
328
329
  ```
329
330
 
330
331
  **Stop / opt out:** delete the `PostToolUse` + `SubagentStart` entries from
@@ -340,7 +341,45 @@ contracts-first edits, delegation — is only partly visible too: no `Skill` cal
340
341
  but its trace shows up in the report's edit:read ratio, so the verdict never
341
342
  reads zero `Skill` calls as "unused."
342
343
 
343
- ### Per-prompt metrics from transcripts (`eval/prompt-metrics.mjs`)
344
+ ### The meter, from any directory (`tune-context eval`)
345
+
346
+ The cost measurement has two halves: the **switch** (`tune-context experiment`,
347
+ below) and the **meter** — `prompt-metrics → analyze → verdict → report`. Both
348
+ are one command, so both come from one installed version:
349
+
350
+ ```bash
351
+ tune-context eval --help # the steps
352
+ tune-context eval <step> --help # that step's flags
353
+ ```
354
+
355
+ The step names are the module names (`eval/<step>.mjs`), one to one, no aliases.
356
+ Everything after the step belongs to the step — `tune-context eval
357
+ prompt-metrics --help` prints the step's usage, not the CLI's.
358
+
359
+ **Every artifact names the code that computed it.** Rows carry `meter_version`,
360
+ the aggregate `analyzed_by`, the verdict `judged_by` — a *meter stamp*, which
361
+ identifies code rather than a release number:
362
+
363
+ | stamp | when |
364
+ | --- | --- |
365
+ | `3.0.0` | an npm install — immutable on the registry, rebuilt on demand with `npx -p @bglocation/tune-context@3.0.0` |
366
+ | `3.0.0+0123456789ab` | a checkout of this repo, clean in `eval/ cli/ hooks/ bin/ package.json` |
367
+ | `3.0.0+0123456789ab.dirty` | the same with uncommitted changes there (untracked files included) |
368
+ | `3.0.0+unknown` | a checkout git could not read |
369
+
370
+ A release number alone is not enough: under `npm link` a fix committed before
371
+ the release carries the same number as the bug it fixes, which is how EPIC-009's
372
+ verdict ended up computed by a meter nobody could name. Rows without a stamp —
373
+ anything from before this contract — are refused, not converted: the way back to
374
+ old data is recomputing the transcripts (see
375
+ [`experiment archive`](#keeping-the-evidence-tune-context-experiment-archive)).
376
+
377
+ *Developer path, from a checkout of this repo only:* `node eval/<step>.mjs` is
378
+ the same code behind a second door — same parser, same bytes on stdout — and
379
+ stamps `<version>+<sha>` (`.dirty` while you have uncommitted changes, which
380
+ holds every verdict by design). A measurement runs through `tune-context eval`.
381
+
382
+ ### Per-prompt metrics from transcripts (`tune-context eval prompt-metrics`)
344
383
 
345
384
  The adoption report reads *our* hook log. `prompt-metrics` reads the transcripts
346
385
  **Claude Code writes itself** (`<config dir>/projects/<slug>/<session>.jsonl`) and
@@ -348,11 +387,14 @@ emits one JSONL row per prompt — time, tokens split by cache class, model,
348
387
  effort, tools, delegations, and cost in dollars (`eval/pricing.mjs`):
349
388
 
350
389
  ```bash
351
- node eval/prompt-metrics.mjs # every transcript
352
- node eval/prompt-metrics.mjs --transcript <path.jsonl> # one session
353
- node eval/prompt-metrics.mjs --dir <dir> > prompts.jsonl # JSONL on stdout
390
+ tune-context eval prompt-metrics # every transcript
391
+ tune-context eval prompt-metrics --transcript <path.jsonl> # one session
392
+ tune-context eval prompt-metrics --dir <dir> > prompts.jsonl # JSONL on stdout
354
393
  ```
355
394
 
395
+ The first line of the summary on stderr is `meter <stamp>` — the line a
396
+ measurement series copies into its run journal before the first pair.
397
+
356
398
  Rows go to stdout, a run summary to stderr — so a pipe stays clean. Two things
357
399
  that source makes possible and a hook cannot: it works for **past** sessions, and
358
400
  it works when tune-context is switched **off** (a disabled hook records nothing,
@@ -366,6 +408,13 @@ Honest scope, printed on every run:
366
408
  is the right yardstick for comparing two arms, and the wrong number to
367
409
  reconcile against a statement. The field is named so the qualifier travels
368
410
  with the data.
411
+ - **Repeated entries are skipped, and that changed every figure.** A transcript
412
+ writes some entries twice — 11 296 of 36 463 local ones (31%) repeat a `uuid`
413
+ already written *in the same file*, 6 739 of them assistant turns with a
414
+ `usage` block, the copies identical apart from a cosmetic `slug`. Summing both
415
+ charged 45% of all cache-read tokens twice: run totals from before 2026-08-02
416
+ are inflated ~1.97×. The skip is scoped to one file (no `uuid` is shared
417
+ between files) and the count is printed on every run.
369
418
  - **Cache-aware, because a naive token sum is off by ~10×.** Cache reads cost
370
419
  0.1× and writes 1.25×/2×; the local run carries 4.18 **billion** cache-read
371
420
  tokens against 521 k plain input ones — 99.9% of the figure is cache and
@@ -379,11 +428,16 @@ Honest scope, printed on every run:
379
428
  (us +10%) are applied; the claude-sonnet-5 introductory price ends in the
380
429
  table on 2026-08-31. Rows are priced at the **prompt's own timestamp**, so
381
430
  re-running the report later yields the same figure.
382
- - **Subagent cost is uncaptured, not zero.** Measured over 29 local transcripts:
383
- 21 `Agent` calls, **zero** sidechain entries — subagent turns never reach the
384
- parent transcript. So `agent_spawns` is always counted, and `sidechain_turns`
385
- is counted too: if a future Claude Code starts writing them, the number stops
386
- being 0 with no code change.
431
+ - **Subagent cost is measured from below, not captured.** Subagent turns never
432
+ reach the parent transcript (zero sidechain entries over 34 local ones), but
433
+ the **result** of an `Agent` call carries its own `usage` and `resolvedModel`
434
+ — so each delegation is priced with the same table and rides on the row as
435
+ `delegations[]`, deduplicated by `agentId` because the entry is written twice.
436
+ That figure is a **lower bound**
437
+ (`cache_read` grows linearly with the subagent's tool calls, which looks like
438
+ its last turn rather than a sum) and is **never added** to the row's own cost.
439
+ `agent_spawns` is still counted next to it: the spawns that left no priced
440
+ result are cost there is no figure for at all.
387
441
  - **Queue wait is not work.** `queued_s` (submission → first assistant entry) is
388
442
  reported apart from `work_s`, because a prompt sent while the agent is busy
389
443
  waits in a queue and that is the user's time. `max_gap_s` exposes the same
@@ -444,6 +498,145 @@ The measurement protocol that uses this — paired runs on `exp/<task>/<arm>`
444
498
  branches, frozen prompt script, verdict gates — is
445
499
  [COST_EVALUATION_PROCEDURE.md](COST_EVALUATION_PROCEDURE.md).
446
500
 
501
+ ### Keeping the evidence (`tune-context experiment archive`)
502
+
503
+ Claude Code deletes transcripts after `cleanupPeriodDays` (30 by default). A
504
+ meter bug found after that cannot be fixed by recomputing — it voids the series.
505
+ So the series' transcripts are archived **after every arm**, together with the
506
+ ledger that assigns them to arms:
507
+
508
+ ```bash
509
+ tune-context experiment archive --out ~/tune-context-archive/series-1
510
+ ```
511
+
512
+ - **What goes in:** every transcript with at least one entry on an `exp/…`
513
+ branch — including branches that only mimic the convention, so a recount can
514
+ reject them by name — and the ledger, in the config dir's own layout, plus
515
+ `manifest.json` (hash, size, branches, Claude Code versions per file) and
516
+ `SHA256SUMS`, checkable with `shasum -a 256 -c SHA256SUMS`. Ordinary work is
517
+ not archived: it is not evidence, and it is your whole history.
518
+ - **Append-only.** An unchanged file is skipped; a file that grew (a resumed
519
+ session, a longer ledger) replaces its copy and is named; any other change
520
+ refuses the whole run and leaves the archive as it was. A file Claude Code has
521
+ since deleted stays — that is what the archive is for. Re-running costs
522
+ nothing.
523
+ - **A transcript is the whole conversation** — contents of the files read,
524
+ command output, possibly secrets. A destination inside a git work tree that
525
+ git does not ignore is refused, with no flag to override it: add it to
526
+ `.gitignore` or archive outside any repository.
527
+
528
+ Recomputing from the archive works after the originals are gone. `--ledger`
529
+ points both steps at the archived ledger — without it the rows come out
530
+ `exp_arm_source: branch`, which the paired pool does not accept:
531
+
532
+ ```bash
533
+ tune-context eval prompt-metrics --dir ~/tune-context-archive/series-1/projects \
534
+ --ledger ~/tune-context-archive/series-1/tune-context/experiment.jsonl > rows.jsonl
535
+ tune-context eval analyze --rows rows.jsonl \
536
+ --ledger ~/tune-context-archive/series-1/tune-context/experiment.jsonl > aggregate.json
537
+ ```
538
+
539
+ To recompute with the meter that computed the original rows instead of the
540
+ current one, run every step with that version:
541
+ `npx -p @bglocation/tune-context@<version> tune-context eval …`.
542
+
543
+ ### Per-arm aggregate (`tune-context eval analyze`)
544
+
545
+ Rows are data, not an answer. `analyze` turns a stream of them into one
546
+ aggregate per arm — and stops one step short of ruling which arm won, because
547
+ the thresholds that would do the ruling are frozen by the PO, not by the model:
548
+
549
+ ```bash
550
+ tune-context eval prompt-metrics > rows.jsonl
551
+ tune-context eval analyze --rows rows.jsonl > aggregate.json # summary on stderr
552
+ tune-context eval prompt-metrics | tune-context eval analyze > aggregate.json # or through a pipe
553
+ ```
554
+
555
+ - **No stamp, no aggregate.** One row without `meter_version` refuses the
556
+ **whole** input — exit 1, nothing on stdout, file and line of the first three
557
+ on stderr. A file mixing old and new rows is a pasted-together input, and
558
+ skipping the old ones would hand the verdict an aggregate that looks complete.
559
+ Rows from *different* meters are aggregated (the per-pair costs stay visible
560
+ for planning a repeat); the verdict holds on them.
561
+
562
+ - **Two populations, never one number.** The paired measurement (branch
563
+ `exp/<task>/<arm>`, everything else held constant) and ordinary daily work
564
+ (attributed by the ledger, nothing held constant) come out as two separate
565
+ objects with different shapes. There is no total above them and no list of
566
+ pools to fold over — adding them has to be written out by hand and meant.
567
+ - **Discards are the output too.** Every row lands either in an arm or in a
568
+ named counter, and `rows_in = rows_used + rows_rejected` is asserted by a
569
+ test. A session whose rows disagree about the arm is dropped **whole** —
570
+ `settings.json` and MCP servers load at startup, so a session that switched
571
+ arms was never in one — and a branch that *mimics* `exp/<task>/<arm>` without
572
+ matching it (`exp/task-089`, `exp/`) goes to a counter, never to the nearest
573
+ plausible arm.
574
+ - **Median and spread, not the mean alone.** One 44-hour turn or one prompt that
575
+ read a 200 k-token file moves a mean of six and leaves the median untouched;
576
+ the gap between the two is itself the finding, so both are reported with
577
+ p25/p75, IQR and the extremes.
578
+ - **An unpriced row leaves the cost, not the sample.** It stays in `n_prompts`,
579
+ sits outside the total, and the pool says `cost_complete: false` with the
580
+ model that caused it named.
581
+ - **`pass_order` per pair.** The second implementation of a task is cheaper for
582
+ a reason that has nothing to do with the arm, and it pushes the same way as
583
+ the effect being looked for. The per-pair, per-arm cost is carried so the two
584
+ can be separated arithmetically (procedure §11b).
585
+
586
+ ### The verdict as data (`tune-context eval verdict`)
587
+
588
+ May this run say "one arm is cheaper"? Seven gates answer, each with its own
589
+ frozen threshold, and every gate is evaluated every time — a run that fixes one
590
+ problem and reruns should not pay a run to learn about the second:
591
+
592
+ ```bash
593
+ tune-context eval verdict --aggregate aggregate.json --rejected-runs journal.json > verdict.json
594
+ ```
595
+
596
+ stdout is the verdict as JSON — the same object the report embeds — and stderr
597
+ one line per gate (measured against threshold, and why it holds) ending in the
598
+ finding: `withheld: N gate(s) hold — …`, `no-difference` or `cheaper-arm: <arm>`.
599
+ Exit 0 for every verdict, a withheld one included; exit 1 only for input that
600
+ cannot be read.
601
+
602
+ `--rejected-runs` carries what the run journal recorded as §7 invalidations
603
+ (`[{ session, task, arm, prompts, reason }]`). **Omitting it is not the same as
604
+ passing `[]`**: the first says nobody asked the journal, and the verdict is held
605
+ for exactly that reason — a rejection share whose denominator is unknown is a
606
+ quietly dropped sample wearing a passing gate.
607
+
608
+ The seventh gate, `meter_mixed` (frozen by the PO on 2026-10-15), passes only
609
+ when the whole chain — the paired rows, `analyzed_by`, `judged_by` — carries
610
+ exactly one stamp, and one that names code: not `.dirty`, not `+unknown`. A
611
+ series therefore runs from a release, or from a clean commit.
612
+
613
+ ### The report (`tune-context eval report`)
614
+
615
+ One HTML file — data embedded as JSON, charts as inline SVG, no network and no
616
+ build step, so it opens from `file://` on a machine with nothing installed:
617
+
618
+ ```bash
619
+ tune-context eval report --aggregate aggregate.json --out report.html
620
+ tune-context eval report --aggregate aggregate.json --rejected-runs journal.json --out report.html
621
+ ```
622
+
623
+ It reads its input exactly as `eval verdict` does — one reader, so the page can
624
+ never accept what the verdict refuses — and names the stamp of every step of the
625
+ chain in its header. `--rejected-runs` means the same as above.
626
+
627
+ - **A withheld verdict is the page, not a footnote.** When a gate holds, the
628
+ arm-comparison chart is *not drawn at all* — not greyed out, not captioned. Two
629
+ bars side by side assert "this is a comparison" more loudly than any caption
630
+ can withdraw it. The per-pair costs stay in a table: those are what a repeat
631
+ run is planned from.
632
+ - **The order effect is printed the same size as the arm effect.** A page that
633
+ shows one large and the other in small print has already made the claim §11b
634
+ refuses.
635
+ - **Tokens never appear without their five classes**, the two pools are never
636
+ added, and the honesty counters — rejected rows and why, sessions dropped for
637
+ switching arm mid-flight, the delegation cost that is a floor and not a total —
638
+ are a section of their own rather than a footer.
639
+
447
640
  ### Getting real cost (OTEL) — outside tune-context
448
641
 
449
642
  tune-context does not set this up for you (deliberate — see TASK-062: it needs
package/bin/tune-context CHANGED
@@ -8,4 +8,4 @@
8
8
  // process.argv.
9
9
  import { main } from './tune-context.mjs';
10
10
 
11
- main();
11
+ await main();