@bglocation/tune-context 2.4.0 → 2.5.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": "2.5.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": "2.5.0",
6
6
  "author": {
7
7
  "name": "Szymon Walczak",
8
8
  "url": "https://bglocation.dev"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,120 @@ 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
+ ## [2.5.0] — 2026-09-03
9
+
10
+ ### Fixed
11
+
12
+ - **`prompt-metrics` charged 45% of all cache-read tokens twice** (found while
13
+ reviewing TASK-089). A transcript repeats entries verbatim: 11 296 of 36 463
14
+ local entries (31%) carry a `uuid` already written *earlier in the same file*,
15
+ 6 739 of them assistant turns with a `usage` block, the copies differing only
16
+ in a cosmetic `slug`. The collector summed both. Local run total drops from
17
+ $4 566 to $2 323 — an inflation of **1.97×**, several times the effect
18
+ EPIC-009 is trying to measure. Repeats are now skipped (scoped to one file:
19
+ zero `uuid`s are shared between files) and counted on stderr, so the drop
20
+ explains itself rather than looking like a different tool.
21
+ **Every cost figure this project produced before 2026-08-02 is too high**;
22
+ `COST_EVALUATION_PROCEDURE.md` §11a carries the correction, including the
23
+ delegation baseline, which moves from ~0.7 to ~0.3 per session. The frozen
24
+ §10 thresholds are deliberately left alone — the correction makes the
25
+ delegation gate stricter relative to the norm, and refitting a threshold to
26
+ new data is the thing that section forbids.
27
+
28
+ - **`prompt-metrics` billed one API response as many** (TASK-092, reported by a
29
+ Claude Code session working in a different project). One response reaches the
30
+ transcript as *several* entries — one per content block: thinking, text, each
31
+ `tool_use` — every one with its own `uuid` but carrying an **identical copy of
32
+ the same `usage` block**. The `uuid` dedup above cannot see this class by
33
+ construction: those uuids differ by design. Over 19 transcripts, 11 295
34
+ entries for 5 954 responses. `usage` is now keyed to the RESPONSE
35
+ (`message.id`, falling back to `requestId`) and the extra entries are counted
36
+ on stderr. On the same frozen transcripts the run total drops **$1 372.69 →
37
+ $679.35 (2.02×)**, on top of the 1.97× above.
38
+ The inflation is **not flat** across token classes (cache-read 1.82×, output
39
+ 2.17×, cache-write 2.39×, input 2.45×) and does **not** cancel in a ratio of
40
+ two arms: the factor *is* the number of content blocks per turn — how many
41
+ tools the model reached for, which is the variable the doctrine sets out to
42
+ move. Per-session spread 1.66×–2.24×.
43
+ The guard sits **after** the `tool_use` loop, because those blocks are *not*
44
+ duplicated (6 403 blocks, 6 403 distinct ids): skipping whole entries would
45
+ have dropped ~2/3 of every tool call and gutted the adoption half of the
46
+ analysis. All three turn counters are guarded — `api_turns`, `sidechain_turns`
47
+ and the unattributed bucket — and an entry with neither `message.id` nor
48
+ `requestId` is **billed** and reported, never assumed unique.
49
+ **Every cost figure this project produced before 2026-09-03 is too high
50
+ again**, including the frozen EPIC-009 rows under `docs/` and the TASK-090
51
+ verdict computed from them. The skew correlates with the arm, so that verdict
52
+ rests on numbers that have to be recomputed before it means anything.
53
+
54
+ - **The unattributed bucket dropped tool calls and server-tool cost**
55
+ (TASK-093). An entry whose `parentUuid` chain does not resolve never reaches
56
+ the row path, so its `tool_use` blocks reached no counter at all, and its
57
+ `usage.server_tool_use` was dropped by `addUsage()`, which copies token fields
58
+ only. The two are different in kind: the tool calls are an **adoption**
59
+ denominator, not money — a tool call is paid for in tokens, and those were
60
+ always billed here — while web search is billed **per request** ($10/1000), so
61
+ counting the entry's tokens does not cover it. Measured: 1 tool call in 66 765
62
+ entries, and **0** web searches in the whole local corpus, on 30 061 entries
63
+ that carry a `server_tool_use` block — the cost hole is dormant, not closed.
64
+ The bucket now carries `tool_calls`, `agent_spawns`, `web_search_requests` and
65
+ `web_fetch_requests`, printed on stderr only when non-zero, and an
66
+ unattributed subagent spawn is named as UNCAPTURED cost (class 3) rather than
67
+ going missing from the one counter that sizes that blind spot. `server_tool_use`
68
+ is counted once per response (it rides inside `usage`, so it *is* duplicated);
69
+ the `tool_use` blocks are counted per entry (they are not). Reconciled against
70
+ an independent counter over three populations: tool calls now agree **to the
71
+ unit** (6 403 / 99 / 18) without the manual correction the TASK-092 review
72
+ still needed.
73
+
74
+ ### Added
75
+
76
+ - **`eval/report.mjs` — the measurement as one self-contained HTML file**
77
+ (TASK-091, EPIC-009). `node eval/report.mjs --aggregate aggregate.json --out
78
+ report.html`: data embedded as JSON, charts as inline SVG, **no network and no
79
+ build step** — it opens from `file://` on a machine with no internet, which is
80
+ where it will actually be read. The failure it is built against is not a wrong
81
+ number but a right number about the wrong thing: a chart persuades harder than
82
+ the caveat under it, so **when a TASK-090 gate holds the arm-comparison chart
83
+ is not drawn at all** — not greyed out, not captioned. The per-pair costs stay
84
+ in a table, because those are what a repeat run is planned from. The order
85
+ effect is printed the same size and in the same place as the arm effect (§11b),
86
+ a token count never appears without its five classes, the two pools are
87
+ rendered by two calls that share no accumulator, and the honesty counters —
88
+ rejected rows with reasons, sessions dropped for switching arm, the delegation
89
+ cost that is a floor rather than a total — are a section rather than a footer.
90
+ Visuals follow the `dataviz` skill: palette validated in light and dark,
91
+ zero-baseline axes, hairline chrome. 22 tests, one group per acceptance
92
+ criterion, and the load-bearing one asserts the **absence** of a chart.
93
+
94
+ - **`eval/analyze.mjs` — the aggregate per arm** (TASK-089, EPIC-009). Rows are
95
+ data; this is the step between them and the sentence "that arm cost less" —
96
+ and it deliberately stops before the sentence, because the thresholds that
97
+ would justify it are frozen by the PO in TASK-090. The paired measurement
98
+ (branch `exp/<task>/<arm>`) and ordinary work (attributed by the TASK-087
99
+ ledger) come out as two objects with **different shapes** and no total above
100
+ them, so the two populations cannot be added by accident. Per arm: prompts,
101
+ sessions, pairs, cost as median **and** spread (a single mean hides the one
102
+ prompt that read 200 k tokens), `work_s` rather than time-since-submission,
103
+ tokens always in five classes, tools, delegations, model/effort distribution,
104
+ and `pass_order` per pair so the order effect can be subtracted from the arm
105
+ effect (§11b).
106
+ - **Discarding is output, not silence.** Every row lands in an arm or in a named
107
+ counter, and `rows_in = rows_used + rows_rejected` is asserted by a test. A
108
+ session whose rows disagree about the arm is dropped **whole** (the arm is a
109
+ property of the session: `settings.json` and MCP load at startup), a branch
110
+ that mimics the convention without matching it goes to a counter rather than
111
+ to the nearest plausible arm, and an unpriced row leaves the cost but stays in
112
+ the sample.
113
+ - **Subagent cost is now measured from below** (TASK-089 criterion 8). The
114
+ result of an `Agent` call carries its own `usage` and `resolvedModel`, so each
115
+ delegation is priced with the existing table and rides on the row as
116
+ `delegations[]` — deduplicated by `agentId`, because the identical entry is
117
+ written to several transcript files and without that the figure comes out 2×.
118
+ It is a **lower bound** and is never added to the parent session's cost; the
119
+ spawns that left no priced result are counted beside it, since that part is
120
+ cost we have no figure for at all.
121
+
8
122
  ## [2.4.0] — 2026-07-31
9
123
 
10
124
  ### Added
@@ -541,6 +655,7 @@ action needed on upgrade.
541
655
  (`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
542
656
  suite, and the adoption report (`eval/adoption-report.mjs`).
543
657
 
658
+ [2.5.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.5.0
544
659
  [2.4.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.4.0
545
660
  [2.3.1]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.1
546
661
  [2.3.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.0
package/README.md CHANGED
@@ -17,7 +17,7 @@ tune-context/
17
17
  ├── agents/ # explore-cheap, reviewer (model right-sizing)
18
18
  ├── hooks/ # PreCompact state-doc, SessionStart re-inject, adoption log, context reminder + hooks.json
19
19
  ├── bin/ + cli/ # `tune-context init` — deterministic detect → generate → verify
20
- ├── eval/ # adoption-report.mjs (the rag-usage analog), prompt-metrics.mjs, pricing.mjs
20
+ ├── eval/ # adoption-report.mjs (the rag-usage analog), prompt-metrics.mjs, pricing.mjs, arm.mjs, analyze.mjs, verdict.mjs, report.mjs
21
21
  └── scripts/ # verify-pack, smoke-init, sync-tune-context (dev/maintenance)
22
22
  ```
23
23
 
@@ -366,6 +366,13 @@ Honest scope, printed on every run:
366
366
  is the right yardstick for comparing two arms, and the wrong number to
367
367
  reconcile against a statement. The field is named so the qualifier travels
368
368
  with the data.
369
+ - **Repeated entries are skipped, and that changed every figure.** A transcript
370
+ writes some entries twice — 11 296 of 36 463 local ones (31%) repeat a `uuid`
371
+ already written *in the same file*, 6 739 of them assistant turns with a
372
+ `usage` block, the copies identical apart from a cosmetic `slug`. Summing both
373
+ charged 45% of all cache-read tokens twice: run totals from before 2026-08-02
374
+ are inflated ~1.97×. The skip is scoped to one file (no `uuid` is shared
375
+ between files) and the count is printed on every run.
369
376
  - **Cache-aware, because a naive token sum is off by ~10×.** Cache reads cost
370
377
  0.1× and writes 1.25×/2×; the local run carries 4.18 **billion** cache-read
371
378
  tokens against 521 k plain input ones — 99.9% of the figure is cache and
@@ -379,11 +386,16 @@ Honest scope, printed on every run:
379
386
  (us +10%) are applied; the claude-sonnet-5 introductory price ends in the
380
387
  table on 2026-08-31. Rows are priced at the **prompt's own timestamp**, so
381
388
  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.
389
+ - **Subagent cost is measured from below, not captured.** Subagent turns never
390
+ reach the parent transcript (zero sidechain entries over 34 local ones), but
391
+ the **result** of an `Agent` call carries its own `usage` and `resolvedModel`
392
+ — so each delegation is priced with the same table and rides on the row as
393
+ `delegations[]`, deduplicated by `agentId` because the entry is written twice.
394
+ That figure is a **lower bound**
395
+ (`cache_read` grows linearly with the subagent's tool calls, which looks like
396
+ its last turn rather than a sum) and is **never added** to the row's own cost.
397
+ `agent_spawns` is still counted next to it: the spawns that left no priced
398
+ result are cost there is no figure for at all.
387
399
  - **Queue wait is not work.** `queued_s` (submission → first assistant entry) is
388
400
  reported apart from `work_s`, because a prompt sent while the agent is busy
389
401
  waits in a queue and that is the user's time. `max_gap_s` exposes the same
@@ -444,6 +456,71 @@ The measurement protocol that uses this — paired runs on `exp/<task>/<arm>`
444
456
  branches, frozen prompt script, verdict gates — is
445
457
  [COST_EVALUATION_PROCEDURE.md](COST_EVALUATION_PROCEDURE.md).
446
458
 
459
+ ### Per-arm aggregate (`eval/analyze.mjs`)
460
+
461
+ Rows are data, not an answer. `analyze` turns a stream of them into one
462
+ aggregate per arm — and stops one step short of ruling which arm won, because
463
+ the thresholds that would do the ruling are frozen by the PO, not by the model:
464
+
465
+ ```bash
466
+ node eval/prompt-metrics.mjs > rows.jsonl
467
+ node eval/analyze.mjs --rows rows.jsonl > aggregate.json # summary on stderr
468
+ ```
469
+
470
+ - **Two populations, never one number.** The paired measurement (branch
471
+ `exp/<task>/<arm>`, everything else held constant) and ordinary daily work
472
+ (attributed by the ledger, nothing held constant) come out as two separate
473
+ objects with different shapes. There is no total above them and no list of
474
+ pools to fold over — adding them has to be written out by hand and meant.
475
+ - **Discards are the output too.** Every row lands either in an arm or in a
476
+ named counter, and `rows_in = rows_used + rows_rejected` is asserted by a
477
+ test. A session whose rows disagree about the arm is dropped **whole** —
478
+ `settings.json` and MCP servers load at startup, so a session that switched
479
+ arms was never in one — and a branch that *mimics* `exp/<task>/<arm>` without
480
+ matching it (`exp/task-089`, `exp/`) goes to a counter, never to the nearest
481
+ plausible arm.
482
+ - **Median and spread, not the mean alone.** One 44-hour turn or one prompt that
483
+ read a 200 k-token file moves a mean of six and leaves the median untouched;
484
+ the gap between the two is itself the finding, so both are reported with
485
+ p25/p75, IQR and the extremes.
486
+ - **An unpriced row leaves the cost, not the sample.** It stays in `n_prompts`,
487
+ sits outside the total, and the pool says `cost_complete: false` with the
488
+ model that caused it named.
489
+ - **`pass_order` per pair.** The second implementation of a task is cheaper for
490
+ a reason that has nothing to do with the arm, and it pushes the same way as
491
+ the effect being looked for. The per-pair, per-arm cost is carried so the two
492
+ can be separated arithmetically (procedure §11b).
493
+
494
+ ### The report (`eval/report.mjs`)
495
+
496
+ One HTML file — data embedded as JSON, charts as inline SVG, no network and no
497
+ build step, so it opens from `file://` on a machine with nothing installed:
498
+
499
+ ```bash
500
+ node eval/analyze.mjs --rows rows.jsonl > aggregate.json
501
+ node eval/report.mjs --aggregate aggregate.json --out report.html
502
+ node eval/report.mjs --aggregate aggregate.json --rejected-runs journal.json --out report.html
503
+ ```
504
+
505
+ `--rejected-runs` carries what the run journal recorded as §7 invalidations
506
+ (`[{ session, task, arm, prompts, reason }]`). **Omitting it is not the same as
507
+ passing `[]`**: the first says nobody asked the journal, and the verdict is held
508
+ for exactly that reason — a rejection share whose denominator is unknown is a
509
+ quietly dropped sample wearing a passing gate.
510
+
511
+ - **A withheld verdict is the page, not a footnote.** When a gate holds, the
512
+ arm-comparison chart is *not drawn at all* — not greyed out, not captioned. Two
513
+ bars side by side assert "this is a comparison" more loudly than any caption
514
+ can withdraw it. The per-pair costs stay in a table: those are what a repeat
515
+ run is planned from.
516
+ - **The order effect is printed the same size as the arm effect.** A page that
517
+ shows one large and the other in small print has already made the claim §11b
518
+ refuses.
519
+ - **Tokens never appear without their five classes**, the two pools are never
520
+ added, and the honesty counters — rejected rows and why, sessions dropped for
521
+ switching arm mid-flight, the delegation cost that is a floor and not a total —
522
+ are a section of their own rather than a footer.
523
+
447
524
  ### Getting real cost (OTEL) — outside tune-context
448
525
 
449
526
  tune-context does not set this up for you (deliberate — see TASK-062: it needs
@@ -190,7 +190,7 @@ export function observeArm({ claudeDir, cwd, pkgRoot }) {
190
190
 
191
191
  const claudeMdPath = join(cwd, 'CLAUDE.md');
192
192
  const claudeMd = readText(claudeMdPath);
193
- const hasBlock = claudeMd !== null && claudeMd.includes(START_MARKER) && claudeMd.includes(END_MARKER);
193
+ const hasBlock = claudeMd !== null && [START_MARKER, END_MARKER].every((m) => claudeMd.includes(m));
194
194
 
195
195
  const skills = deployed(claudeDir, pkgRoot, 'skills');
196
196
  const agents = deployed(claudeDir, pkgRoot, 'agents');
@@ -276,7 +276,7 @@ function planMutations({ claudeDir, cwd, pkgRoot, tc, rag }) {
276
276
 
277
277
  const claudeMdPath = join(cwd, 'CLAUDE.md');
278
278
  const claudeMd = readText(claudeMdPath);
279
- if (claudeMd !== null && claudeMd.includes(START_MARKER) && claudeMd.includes(END_MARKER)) {
279
+ if (claudeMd !== null && [START_MARKER, END_MARKER].every((m) => claudeMd.includes(m))) {
280
280
  mutations.push({
281
281
  label: 'managed block removed from the project CLAUDE.md',
282
282
  path: claudeMdPath,
@@ -539,6 +539,6 @@ function main() {
539
539
  process.stdout.write(`${formatReport(aggregate(records))}\n`);
540
540
  }
541
541
 
542
- if (process.argv[1] && process.argv[1].endsWith('adoption-report.mjs')) {
542
+ if (process.argv[1]?.endsWith('adoption-report.mjs')) {
543
543
  main();
544
544
  }