@bglocation/tune-context 2.3.0 → 2.4.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.3.0",
13
+ "version": "2.4.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.3.0",
5
+ "version": "2.4.0",
6
6
  "author": {
7
7
  "name": "Szymon Walczak",
8
8
  "url": "https://bglocation.dev"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,119 @@ 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.4.0] — 2026-07-31
9
+
10
+ ### Added
11
+
12
+ - **`tune-context experiment` — the second arm** (TASK-087, EPIC-009). Measuring
13
+ one arm is not measuring: until now there was no way to run the same work
14
+ *without* the doctrine and without RAG, so every figure the reports produced
15
+ was absolute and had nothing to be compared against. `experiment set --tc off
16
+ --rag off` switches off hook registrations, the managed `CLAUDE.md` block, the
17
+ doctrine skills and the agents (the full arm, PO decision) plus the project
18
+ `.mcp.json`; `restore` puts every byte back. Each `set` appends to an
19
+ append-only ledger (`<config dir>/tune-context/experiment.jsonl`) — the source
20
+ of truth an analyzer uses to assign a session to an arm.
21
+ - **`restore` refuses rather than clobbers.** Every touched path is fingerprinted
22
+ and copied aside *before* it is modified — so a `set` interrupted halfway is
23
+ still reversible — and `restore` compares each path against what `set` left
24
+ there. A file edited during the arm (a permission added to `settings.json`,
25
+ text written below the `CLAUDE.md` marker — the TASK-082 scenario) aborts the
26
+ whole restore and reports **what** diverged, not just which file: the first
27
+ differing line with a preview of both sides for a text file, per-file counts
28
+ and names for a directory, plus the path to the saved copy. Refusal is the
29
+ correct outcome here, not a failure.
30
+ - **`verify` understands the off arm.** With an arm set it reports "off" instead
31
+ of a broken install — and stays RED when the arm did *not* fully land, e.g. a
32
+ leftover hook registration still firing. Reporting a correctly disabled
33
+ doctrine as breakage would be the EPIC-006 false-red regression pointing the
34
+ other way. `init` now refuses to run while an arm is set: it would re-install
35
+ what the arm parked and leave `restore` with fingerprints it never wrote.
36
+
37
+ - **Rows say which arm they belong to** (TASK-088, EPIC-009). `prompt-metrics`
38
+ rows from a branch shaped `exp/<task>/<arm>` now carry `exp_task`, `exp_arm`,
39
+ `exp_arm_source`, `exp_ledger_arm` and `pass_order`. The branch says which arm
40
+ was *intended*, the ledger which was *set* — and when they disagree the tool
41
+ does **not** pick a winner: the arm is left unresolved with the reason on the
42
+ row (`conflict:ledger`, `conflict:no-ledger-entry`, `conflict:branch-changed`),
43
+ because silently assigning a run to an arm is exactly the false saving the
44
+ paired measurement exists to prevent. The branch parser is strict for the same
45
+ reason: `exp/task-089/off-by-one` and `feat/exp/task-089/on` resolve to nothing
46
+ rather than to the nearest matching arm.
47
+ - **`pass_order` on the row.** The second implementation of the same task is
48
+ cheaper for a reason that has nothing to do with the arm — the solution is
49
+ already known — and that effect runs in the same direction as the one being
50
+ measured. Recorded per (task, arm) from the start time, it is what lets the
51
+ order effect be subtracted from the arm effect instead of being counted as one.
52
+
53
+ - **`restore` ends a measurement run even when it moved nothing.** The switch
54
+ only ever turns things *off*, so the `on` arm parks no files — and `restore`
55
+ used to refuse there, ledger entry included. That left the `on` arm's window
56
+ open forever: a follow-up a week later, in the same worktree on the same
57
+ `exp/` branch, still resolved to `branch+ledger` and joined the sample, while
58
+ the identical work after an `off` arm was flagged and excluded. A leak on one
59
+ side only is worse than a leak on both, because the two sides are what the
60
+ epic compares. Now `restore` closes the ledger window whether or not it had
61
+ bytes to put back, and refuses only when there is no open arm to close.
62
+
63
+ ### Fixed
64
+
65
+ - **A cost that is not a number is no longer counted as a cost.** `NaN` is
66
+ neither `null` nor `undefined`, so a row carrying one passed every "is there a
67
+ price here?" check, entered the priced count, and turned the whole run's total
68
+ into `$NaN` — every other row's money gone while the counter still claimed to
69
+ have priced them. The published amount is now refused unless it is finite, and
70
+ the roll-up checks each class, not only the total. The way in is closed too:
71
+ the price, tier and geo tables are looked up by *own* key, so a `service_tier`
72
+ spelled `constructor` reads as unknown instead of handing back a function from
73
+ `Object.prototype`.
74
+
75
+ ### Notes
76
+
77
+ - `rag off` covers the **project** `.mcp.json` only. `~/.claude.json` is rewritten
78
+ by Claude Code while it runs, so the switch never touches the user scope —
79
+ `experiment status` says this explicitly instead of implying RAG is globally
80
+ off.
81
+ - The arm belongs to the **session**: `settings.json` and MCP servers load at
82
+ startup, so a `set` applies from the next Claude Code start. Both `set` and
83
+ `status` say so; nothing here restarts anything for you.
84
+
85
+ ## [2.3.1] — 2026-07-28
86
+
87
+ ### Fixed
88
+
89
+ - **The context reminder no longer goes quiet for 120k tokens at a stretch.** A
90
+ middle rung at **1.5× the threshold** (180k by default) joins the existing
91
+ 120k and 240k, cutting the worst-case silence between notices from 120k to
92
+ 60k. Reported from use: 120k is often too early to close a work thread, while
93
+ 240k is far enough away that a session has to be dragged out to reach it, and
94
+ 180k is where a phase boundary tends to land. The ladder is expressed as
95
+ multipliers (1×/1.5×/2×), so `TUNE_CONTEXT_REMIND_TOKENS` still scales all
96
+ three — a 400k threshold gives 400k/600k/800k.
97
+ - **Each rung is now louder than the last, instead of repeating itself.** The
98
+ reminder's mid-task sentence is what escalates: wait for a natural break at
99
+ 120k, raise it at the next pause anyway at 180k, say it this turn at 240k. A
100
+ third identical notice would just be the same coin flipped again. This also
101
+ fixes a real defect the third rung would otherwise have introduced — the
102
+ second notice used to call itself "second and final", which on a three-rung
103
+ ladder is false, and Claude weighs exactly that ("nothing more is coming")
104
+ when deciding whether to stay quiet.
105
+ - **Docs describing the reminder as firing once.** `README.md` and the
106
+ `tune-context` skill both still said "a single short note" / "fires once per
107
+ crossing" — wrong since 2.1.0 added the second notice, not just since this
108
+ release.
109
+
110
+ ### Notes
111
+
112
+ - The level-1 text is byte-identical to previous releases. A session live across
113
+ the upgrade whose state already recorded the old final notice earns one extra
114
+ notice, once, and then goes quiet — the two-rung and three-rung ladders write
115
+ the same number for different meanings, and a permanent ladder-version field
116
+ was not worth one notice.
117
+ - This release touches `hooks/`, so **re-run `tune-context init`** (or the
118
+ dev-loop sync) after upgrading — `npm i -g` does not refresh hooks already
119
+ copied into `~/.claude/hooks/`. `tune-context verify` reports the drift.
120
+
8
121
  ## [2.3.0] — 2026-07-28
9
122
 
10
123
  ### Added
@@ -428,6 +541,8 @@ action needed on upgrade.
428
541
  (`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
429
542
  suite, and the adoption report (`eval/adoption-report.mjs`).
430
543
 
544
+ [2.4.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.4.0
545
+ [2.3.1]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.1
431
546
  [2.3.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.0
432
547
  [2.2.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.2.0
433
548
  [2.1.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.1.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)
20
+ ├── eval/ # adoption-report.mjs (the rag-usage analog), prompt-metrics.mjs, pricing.mjs
21
21
  └── scripts/ # verify-pack, smoke-init, sync-tune-context (dev/maintenance)
22
22
  ```
23
23
 
@@ -217,23 +217,27 @@ many tokens it is re-sending, and Claude Code has no timer or context-threshold
217
217
  hook event. So `context-reminder.mjs` (registered under `UserPromptSubmit`)
218
218
  supplies the missing measurement: it reads the tail of the session transcript,
219
219
  sums the tokens actually re-sent, and once that passes **120,000** it injects a
220
- single short note suggesting `/compact`.
220
+ short note suggesting `/compact`.
221
221
 
222
222
  - **The note goes to Claude, not to your screen.** Hooks can only reach you
223
223
  through the model — a `systemMessage` on this event was measured and does not
224
224
  render — so what you actually see is Claude bringing it up in its reply.
225
- - **The model picks the moment.** The note tells Claude to stay quiet mid-task
226
- and raise it at the first natural break: the hook can measure size, but only
227
- the model can see whether you're mid-refactor.
228
- - **Twice per crossing at most.** Once at the threshold, once more at **240,000**
229
- if the context keeps growing — because a note that lands mid-task can be
230
- correctly absorbed and then forgotten. Measured on one session: it fired at
231
- 166k, Claude stayed silent, and nothing spoke again until 346k. The second
232
- notice halves that blind spot; there is deliberately no third.
225
+ - **The model picks the moment — at first.** The hook can measure size, but only
226
+ Claude can see whether you're mid-refactor, so the first note tells it to stay
227
+ quiet mid-task and raise it at the next natural break. That deference runs out:
228
+ see the ladder below. A session that never reaches a "natural break" is exactly
229
+ the one paying the most for the wait.
230
+ - **Three notes per crossing at most — 120,000 / 180,000 / 240,000.** A note that
231
+ lands mid-task can be correctly absorbed and then forgotten. Measured on one
232
+ session: it fired at 166k, Claude stayed silent, and nothing spoke again until
233
+ 346k. Each rung halves that blind spot, and each one is *louder* than the last:
234
+ at 120k Claude is told to wait for a natural break, at 180k to raise it at the
235
+ next pause anyway, at 240k to say it this turn whatever it is doing. There is
236
+ deliberately no fourth — the reminder costs tokens on the prompt it appears in.
233
237
  - **Raise it for a large context window:** `export TUNE_CONTEXT_REMIND_TOKENS=400000`.
234
238
  120k is ~60% of a standard 200k window; the transcript doesn't record the window
235
- size, so this can't be auto-scaled. Both levels scale with the override — the
236
- second notice is always at twice the threshold.
239
+ size, so this can't be auto-scaled. The whole ladder scales with the override —
240
+ the rungs are always 1×, 1.5× and 2× the threshold (400k/600k/800k here).
237
241
  - **Turn it off:** delete the `UserPromptSubmit` entry from `settings.json`.
238
242
 
239
243
  Depth lives in the `phase-workflow` and `token-efficiency` skills (both installed
@@ -328,19 +332,124 @@ instead of printing a number the doubling would have faked.
328
332
  `~/.claude/tune-context/` (or the `CLAUDE_CONFIG_DIR` override) and records only
329
333
  tool names, file paths, and skill/agent names — never full Bash command strings.
330
334
 
331
- **Honest scope:** this is a *behavioral proxy*, not a bill. Token cost lands only
332
- in OTEL (`CLAUDE_CODE_ENABLE_TELEMETRY=1` → `claude_code.cost.usage`, needs a
333
- collector), never a hook; and caveman is an output style with no tool call, so it
335
+ **Honest scope:** this is a *behavioral proxy*, not a bill. The hook log carries
336
+ no tokens, so dollars come from `prompt-metrics` below (or from OTEL, which is
337
+ Anthropic's own figure); and caveman is an output style with no tool call, so it
334
338
  can't be counted this way. Doctrine applied as *method* — narrow reads,
335
339
  contracts-first edits, delegation — is only partly visible too: no `Skill` call,
336
340
  but its trace shows up in the report's edit:read ratio, so the verdict never
337
341
  reads zero `Skill` calls as "unused."
338
342
 
343
+ ### Per-prompt metrics from transcripts (`eval/prompt-metrics.mjs`)
344
+
345
+ The adoption report reads *our* hook log. `prompt-metrics` reads the transcripts
346
+ **Claude Code writes itself** (`<config dir>/projects/<slug>/<session>.jsonl`) and
347
+ emits one JSONL row per prompt — time, tokens split by cache class, model,
348
+ effort, tools, delegations, and cost in dollars (`eval/pricing.mjs`):
349
+
350
+ ```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
354
+ ```
355
+
356
+ Rows go to stdout, a run summary to stderr — so a pipe stays clean. Two things
357
+ that source makes possible and a hook cannot: it works for **past** sessions, and
358
+ it works when tune-context is switched **off** (a disabled hook records nothing,
359
+ and that is exactly the arm a comparison needs).
360
+
361
+ Honest scope, printed on every run:
362
+
363
+ - **`list_cost_usd` is API list price, not your bill.** A Pro/Max subscription
364
+ charges a flat fee and consumes plan limits; nothing here is billed per token.
365
+ The figure answers *"what would this work have cost through the API"* — which
366
+ is the right yardstick for comparing two arms, and the wrong number to
367
+ reconcile against a statement. The field is named so the qualifier travels
368
+ with the data.
369
+ - **Cache-aware, because a naive token sum is off by ~10×.** Cache reads cost
370
+ 0.1× and writes 1.25×/2×; the local run carries 4.18 **billion** cache-read
371
+ tokens against 521 k plain input ones — 99.9% of the figure is cache and
372
+ output. The total never appears without its per-class split, and a model the
373
+ table doesn't know yields `null` plus an `UNPRICED` count, never a zero.
374
+ - **Prices are data, dated, and expire.** `eval/pricing.mjs` holds one table
375
+ taken from the published pricing page with its retrieval date, storing base
376
+ input/output only and deriving the three cache columns from the documented
377
+ 1.25×/2×/0.1× multipliers — a test asserts the derivation reproduces every
378
+ published row. Service tier (batch −50%), fast mode, and `inference_geo`
379
+ (us +10%) are applied; the claude-sonnet-5 introductory price ends in the
380
+ table on 2026-08-31. Rows are priced at the **prompt's own timestamp**, so
381
+ 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.
387
+ - **Queue wait is not work.** `queued_s` (submission → first assistant entry) is
388
+ reported apart from `work_s`, because a prompt sent while the agent is busy
389
+ waits in a queue and that is the user's time. `max_gap_s` exposes the same
390
+ thing *inside* a turn — a tool call blocked on a permission prompt lands in
391
+ `work_s` otherwise.
392
+ - **Model and effort change mid-session** (measured), so both ride on every row,
393
+ with a per-model token split when a single prompt used more than one.
394
+ - Entries that can't be attributed to a prompt are **counted and reported**,
395
+ never silently dropped. Same for the two ways the figure can be too low:
396
+ `usage.iterations` reporting more than the `usage` we price from (3 turns
397
+ locally), and web searches, which are billed per request ($10/1000) rather
398
+ than per token and so sit outside the token total. Web *fetch* is free.
399
+
400
+ ### The other arm (`tune-context experiment`)
401
+
402
+ A cost figure with nothing to compare it against is a number, not a measurement.
403
+ This switch turns the doctrine **off** so the same work can be run twice —
404
+ `tc={on,off}` × `rag={on,off}` — and records which arm was in force when:
405
+
406
+ ```bash
407
+ tune-context experiment status # what is set, and what the config actually shows
408
+ tune-context experiment set --tc off --rag off --note "pair 1"
409
+ tune-context experiment restore # byte for byte, or a refusal
410
+ ```
411
+
412
+ - **`tc off` is the full arm** (PO decision, EPIC-009): hook registrations in
413
+ `settings.json`, the managed block in the project `CLAUDE.md`, the doctrine
414
+ skills, the agents. Four interventions in one variable on purpose — at small
415
+ *n* the biggest effect is the easiest to detect; splitting them is the *next*
416
+ experiment.
417
+ - **`rag off` means this project's `.mcp.json`, and nothing else.** The user
418
+ scope (`~/.claude.json`) is rewritten by Claude Code while it runs, so the
419
+ switch never touches it — `status` says so out loud instead of letting you
420
+ assume RAG is off globally. Check `claude mcp list` before a measured run.
421
+ - **Reversibility comes before the change.** Every touched path is fingerprinted
422
+ and copied aside *before* it is modified, so a `set` that dies halfway is still
423
+ undoable. `restore` compares each path against what `set` left there and
424
+ **refuses** if anything changed in between, naming the file *and the
425
+ difference* (first differing line for a text file, changed/added/removed files
426
+ for a directory, and where the saved copy is) — the working session between the
427
+ two commands is exactly when you might add a permission or write below the
428
+ `CLAUDE.md` marker, and silently erasing that is worse than refusing.
429
+ - **The ledger is the source of truth**
430
+ (`<config dir>/tune-context/experiment.jsonl`, append-only). An analyzer
431
+ assigns a session to an arm by timestamp; detecting the arm from a transcript
432
+ is a cross-check, never the verdict. Every `set` appends — including one that
433
+ changes nothing, because a run whose ledger is silent at its start cannot be
434
+ assigned to an arm at all.
435
+ - **The arm belongs to the session.** `settings.json` and MCP servers are read at
436
+ startup, so it applies from the next Claude Code start; flipping it mid-session
437
+ produces a mixed session, which the analysis throws away.
438
+ - `tune-context verify` reads the arm and reports **"off"** rather than a broken
439
+ install — and stays RED if the arm did not fully land. `init` refuses to run
440
+ while an arm is set: it would re-install what the arm parked and leave
441
+ `restore` with nothing it recognises.
442
+
443
+ The measurement protocol that uses this — paired runs on `exp/<task>/<arm>`
444
+ branches, frozen prompt script, verdict gates — is
445
+ [COST_EVALUATION_PROCEDURE.md](COST_EVALUATION_PROCEDURE.md).
446
+
339
447
  ### Getting real cost (OTEL) — outside tune-context
340
448
 
341
449
  tune-context does not set this up for you (deliberate — see TASK-062: it needs
342
- infra this tool stays zero-infra without). If you want actual dollars instead of
343
- the behavioral proxy above:
450
+ infra this tool stays zero-infra without). `prompt-metrics` above computes cost
451
+ from a public price list, which is an *independent* calculation, not Anthropic's
452
+ invoice — OTEL is the way to check it against the real one:
344
453
 
345
454
  ```bash
346
455
  export CLAUDE_CODE_ENABLE_TELEMETRY=1 # off by default
@@ -30,6 +30,15 @@ import {
30
30
  mergePermissions,
31
31
  } from '../cli/generate.mjs';
32
32
  import { staleDoctrineEntries, verify } from '../cli/verify.mjs';
33
+ import {
34
+ ExperimentError,
35
+ experimentPaths,
36
+ experimentRestore,
37
+ experimentSet,
38
+ observeArm,
39
+ readLedger,
40
+ readState,
41
+ } from '../cli/experiment.mjs';
33
42
 
34
43
  const HOOK_EVENTS = [
35
44
  { event: 'PreCompact', scriptName: 'pre-compact-snapshot.mjs' },
@@ -58,6 +67,9 @@ function usage() {
58
67
  Usage:
59
68
  tune-context init [--cwd <dir>] [--force-claude-md]
60
69
  tune-context verify [--cwd <dir>]
70
+ tune-context experiment status [--cwd <dir>]
71
+ tune-context experiment set --tc <on|off> --rag <on|off> [--note "..."] [--cwd <dir>]
72
+ tune-context experiment restore
61
73
  tune-context --help
62
74
  tune-context --version
63
75
 
@@ -66,12 +78,76 @@ Usage:
66
78
  match this package. Run it after upgrading: an upgrade
67
79
  does not refresh copies an earlier init made.
68
80
 
81
+ experiment A/B switch for measuring the doctrine against its own
82
+ absence (EPIC-009). \`set\` disables what the arm asks for
83
+ and records how to put it back; \`restore\` puts it back
84
+ byte for byte, or refuses if a touched file changed since.
85
+ Both --tc and --rag are required for \`set\`: an arm that
86
+ was only half-stated is an arm nobody can reproduce.
87
+
69
88
  --force-claude-md Regenerate a project CLAUDE.md whose hand-written content
70
89
  still sits inside the managed block. Discards that content.
71
90
  Without it, such a file is left untouched.
72
91
  `;
73
92
  }
74
93
 
94
+ // Flags that consume the token after them. Listed once so the positional
95
+ // scanner below cannot mistake a flag's VALUE for a subcommand — `experiment
96
+ // --cwd /tmp/x status` must find `status`, not `/tmp/x`.
97
+ const VALUE_FLAGS = new Set(['--cwd', '--tc', '--rag', '--note']);
98
+
99
+ /**
100
+ * @returns {string|{error: string}} the value after `name`, or undefined if the
101
+ * flag is absent. Undefined (last arg) and another flag both count as "no
102
+ * value given" — resolve(undefined) used to throw a raw ERR_INVALID_ARG_TYPE
103
+ * with a Node stacktrace instead of a usage message.
104
+ */
105
+ function flagValue(args, name) {
106
+ const idx = args.indexOf(name);
107
+ if (idx === -1) return undefined;
108
+ const value = args[idx + 1];
109
+ if (value === undefined || value.startsWith('-')) return { error: `Missing value for ${name}` };
110
+ return value;
111
+ }
112
+
113
+ function positionals(args) {
114
+ const out = [];
115
+ for (let i = 0; i < args.length; i += 1) {
116
+ if (VALUE_FLAGS.has(args[i])) {
117
+ i += 1;
118
+ continue;
119
+ }
120
+ if (!args[i].startsWith('-')) out.push(args[i]);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ const EXPERIMENT_SUBCOMMANDS = ['status', 'set', 'restore'];
126
+
127
+ /**
128
+ * `experiment` argument shape. `set` demands BOTH --tc and --rag: an arm stated
129
+ * by halves is an arm nobody can reproduce from the ledger, and inferring the
130
+ * unstated half from the current config would write a value nobody chose.
131
+ */
132
+ function parseExperiment(rest, cwd) {
133
+ const [sub] = positionals(rest);
134
+ if (sub === undefined) return { mode: 'error', message: `Missing experiment subcommand: ${EXPERIMENT_SUBCOMMANDS.join(', ')}` };
135
+ if (!EXPERIMENT_SUBCOMMANDS.includes(sub)) return { mode: 'error', message: `Unknown experiment subcommand: ${sub}` };
136
+ if (sub !== 'set') return { mode: 'experiment', sub, cwd };
137
+
138
+ const values = {};
139
+ for (const flag of ['--tc', '--rag', '--note']) {
140
+ const value = flagValue(rest, flag);
141
+ if (value?.error) return { mode: 'error', message: value.error };
142
+ values[flag] = value;
143
+ }
144
+ for (const flag of ['--tc', '--rag']) {
145
+ if (values[flag] === undefined) return { mode: 'error', message: `experiment set requires ${flag} <on|off>` };
146
+ if (values[flag] !== 'on' && values[flag] !== 'off') return { mode: 'error', message: `${flag} must be on or off, got: ${values[flag]}` };
147
+ }
148
+ return { mode: 'experiment', sub, cwd, tc: values['--tc'], rag: values['--rag'], note: values['--note'] ?? '' };
149
+ }
150
+
75
151
  // Pure so it is unit-testable without spawning a process. `init` is the ONLY
76
152
  // accepted command — no bare-args shortcut (PO decision 2026-07-25, TASK-078):
77
153
  // README never once documents calling this without it, and accepting anything
@@ -86,32 +162,48 @@ export function parseArgs(argv) {
86
162
  // `verify` joins `init` as an accepted command (TASK-081). It writes nothing,
87
163
  // which is the point: init syncs before it verifies, so init can never
88
164
  // observe the drift an upgrade leaves behind — only a check that runs on its
89
- // own can. The TASK-078 rule still holds: an unknown command fails loudly.
90
- if (command !== 'init' && command !== 'verify') {
165
+ // own can. `experiment` joins them as the A/B switch (TASK-087). The TASK-078
166
+ // rule still holds: an unknown command fails loudly.
167
+ const commands = ['init', 'verify', 'experiment'];
168
+ if (!commands.includes(command)) {
91
169
  return {
92
170
  mode: 'error',
93
- message: command === undefined ? 'Missing command: init or verify' : `Unknown command: ${command}`,
171
+ message: command === undefined ? `Missing command: ${commands.join(', ')}` : `Unknown command: ${command}`,
94
172
  };
95
173
  }
174
+
175
+ const cwdValue = flagValue(rest, '--cwd');
176
+ if (cwdValue?.error) return { mode: 'error', message: cwdValue.error };
177
+ const cwd = cwdValue === undefined ? process.cwd() : resolve(cwdValue);
178
+
179
+ if (command === 'experiment') return parseExperiment(rest, cwd);
96
180
  // Opt-in only, and never inferred: this is the one flag that can destroy
97
181
  // content the tool otherwise refuses to touch (TASK-082).
98
- const forceClaudeMd = rest.includes('--force-claude-md');
99
- const cwdFlagIdx = rest.indexOf('--cwd');
100
- if (cwdFlagIdx === -1) return { mode: command, cwd: process.cwd(), forceClaudeMd };
101
- const value = rest[cwdFlagIdx + 1];
102
- // Undefined (last arg) or another flag (e.g. `--cwd --foo`) both mean "no
103
- // value was actually given" — resolve(undefined) used to throw a raw
104
- // ERR_INVALID_ARG_TYPE with a Node stacktrace instead of a usage message.
105
- if (value === undefined || value.startsWith('-')) {
106
- return { mode: 'error', message: 'Missing value for --cwd' };
107
- }
108
- return { mode: command, cwd: resolve(value), forceClaudeMd };
182
+ return { mode: command, cwd, forceClaudeMd: rest.includes('--force-claude-md') };
109
183
  }
110
184
 
111
185
  function runInit(cwd, { forceClaudeMd = false } = {}) {
112
186
  const templatesDir = join(pkgRoot, 'skills', 'tune-context', 'templates');
113
187
  const { claudeDir } = claudeDirs();
114
188
 
189
+ // An experiment arm is the one state in which init must NOT run. It would
190
+ // re-install everything the arm parked — turning the config back on while the
191
+ // ledger still says off (a mislabelled measurement), and putting content back
192
+ // where `restore` then finds a fingerprint it did not write, so the only way
193
+ // out would be refusal. Blocking here keeps both the arm and the undo intact.
194
+ const state = readState(claudeDir);
195
+ if (state) {
196
+ process.stderr.write(`tune-context init refused — experiment arm tc=${state.arm.tc} rag=${state.arm.rag} has been set (since ${state.startedAt}).
197
+
198
+ init would re-install what the arm parked, and \`experiment restore\` would then
199
+ refuse, because the files no longer match what it saved.
200
+
201
+ Run \`tune-context experiment restore\` first, then init.
202
+ `);
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+
115
207
  const stack = detectStack(cwd);
116
208
  const existing = detectExistingConfig(cwd);
117
209
 
@@ -226,6 +318,11 @@ function runVerify(cwd) {
226
318
  const detection = { stack: detectStack(cwd), mcpServers: detectMcpServers(cwd) };
227
319
  const permissionsAllow = buildPermissionsAllow(detection);
228
320
 
321
+ // The arm comes from the ledger state, never from guessing at the config:
322
+ // "the doctrine is missing" and "the doctrine was switched off on purpose"
323
+ // look identical on disk, and only one of them is a broken install.
324
+ const arm = readState(claudeDir)?.arm ?? null;
325
+
229
326
  const result = verify({
230
327
  claudeDir,
231
328
  userSettingsPath: existing.userSettingsJson,
@@ -233,9 +330,14 @@ function runVerify(cwd) {
233
330
  expectedHookCommands: HOOK_EVENTS,
234
331
  expectedPermissions: permissionsAllow,
235
332
  pkgRoot,
333
+ arm,
236
334
  });
237
335
 
238
336
  console.log(`tune-context verify — ${cwd}\n`);
337
+ if (arm) {
338
+ console.log(`Experiment arm: tc=${arm.tc} rag=${arm.rag} — the checks below are read against that arm, not against a full install.
339
+ This is the pre-run check from COST_EVALUATION_PROCEDURE §4: it must say the arm you meant to set, BEFORE the first prompt.\n`);
340
+ }
239
341
  console.log(`Verify: ${result.ok ? 'OK' : 'FAILED'}`);
240
342
  for (const check of result.checks) {
241
343
  const mark = check.unknown ? '?' : check.ok ? '✓' : '✗';
@@ -245,7 +347,129 @@ function runVerify(cwd) {
245
347
  // never ran init, this prints a screen of ✗ and, without the line below,
246
348
  // nothing that says the whole list is one command away from green.
247
349
  if (!result.ok) {
248
- console.log('\nNext: `tune-context init` writes what is missing and refreshes what drifted. It never edits content outside the managed block.');
350
+ console.log(
351
+ arm
352
+ ? '\nNext: `tune-context experiment restore` — while an arm is set, init is refused, and a red check here means the arm did not fully land.'
353
+ : '\nNext: `tune-context init` writes what is missing and refreshes what drifted. It never edits content outside the managed block.',
354
+ );
355
+ process.exitCode = 1;
356
+ }
357
+ }
358
+
359
+ // Said the same way everywhere: an arm is a property of the SESSION, not of the
360
+ // prompt. settings.json and MCP servers are read when Claude Code starts, so a
361
+ // switch flipped mid-session produces a mixed session — the one sample class
362
+ // EPIC-009 throws away rather than assigns.
363
+ const RESTART_NOTE = `The arm applies from the NEXT Claude Code start. settings.json and MCP servers are read at
364
+ startup, so a session already running is unaffected — close it before the measured run.`;
365
+
366
+ const MCP_SCOPE_NOTE = `MCP scope: only this project's .mcp.json is switched. The USER scope (~/.claude.json) is
367
+ rewritten by Claude Code while it runs, so this switch never touches it — a user-scope
368
+ semantic-search server stays available in a \`rag off\` arm. Check with \`claude mcp list\`
369
+ before the run, or the arm is mislabelled.`;
370
+
371
+ function armLine(observed) {
372
+ return `tc=${observed.tc} rag=${observed.rag}`;
373
+ }
374
+
375
+ function runExperimentStatus(cwd) {
376
+ const { claudeDir } = claudeDirs();
377
+ const { statePath, ledgerPath } = experimentPaths(claudeDir);
378
+ const state = readState(claudeDir);
379
+ const ledger = readLedger(claudeDir);
380
+ const observed = observeArm({ claudeDir, cwd, pkgRoot });
381
+
382
+ console.log(`tune-context experiment status — ${cwd}\n`);
383
+
384
+ const last = ledger[ledger.length - 1];
385
+ console.log('Ledger (source of truth for assigning a session to an arm):');
386
+ console.log(` ${ledgerPath}`);
387
+ if (!last) {
388
+ console.log(' no entries yet — no run has been labelled through this switch');
389
+ } else {
390
+ console.log(` last: ${last.event ?? 'set'} tc=${last.tc} rag=${last.rag} at ${last.ts}${last.note ? ` — "${last.note}"` : ''} (${ledger.length} entr${ledger.length === 1 ? 'y' : 'ies'})`);
391
+ }
392
+
393
+ console.log('\nActive switch state:');
394
+ console.log(
395
+ state
396
+ ? ` arm tc=${state.arm.tc} rag=${state.arm.rag} set at ${state.startedAt}; ${state.entries.length} path(s) parked, restorable from ${statePath}`
397
+ : ' none — this switch has nothing parked, so `restore` has nothing to put back',
398
+ );
399
+
400
+ console.log('\nObserved in the config (cross-check, NOT the source of truth):');
401
+ console.log(` ${armLine(observed)}`);
402
+ for (const c of observed.components) {
403
+ const mark = c.state === 'on' ? '✓' : c.state === 'off' ? '·' : '!';
404
+ console.log(` ${mark} ${c.label} — ${c.detail}`);
405
+ }
406
+ console.log(
407
+ ` ${observed.mcp.servers.length ? '✓' : '·'} ${observed.mcp.path} — ${observed.mcp.servers.length ? `${observed.mcp.servers.length} server(s): ${observed.mcp.servers.join(', ')}` : 'absent or empty'}`,
408
+ );
409
+
410
+ // The two must agree before a measured run: the ledger is what the analyzer
411
+ // reads, the config is what the session actually gets.
412
+ if (state && (state.arm.tc !== observed.tc || state.arm.rag !== observed.rag)) {
413
+ console.log(`\n! The set arm (tc=${state.arm.tc} rag=${state.arm.rag}) and the observed config (${armLine(observed)}) DISAGREE.
414
+ Something changed the config after \`set\` — do not measure in this state; \`restore\` and set again.`);
415
+ }
416
+
417
+ console.log(`\n${MCP_SCOPE_NOTE}`);
418
+ console.log(`\n${RESTART_NOTE}`);
419
+ }
420
+
421
+ function runExperimentSet(cwd, { tc, rag, note }) {
422
+ const { claudeDir } = claudeDirs();
423
+ const result = experimentSet({ claudeDir, cwd, pkgRoot, tc, rag, note });
424
+
425
+ console.log(`tune-context experiment set — tc=${tc} rag=${rag}${note ? ` — "${note}"` : ''}\n`);
426
+ if (result.alreadySet) {
427
+ console.log('Config unchanged: this arm was already set. The ledger entry is written anyway — a run whose\n ledger is silent at its start cannot be assigned to an arm at all.');
428
+ } else if (result.changes.length === 0) {
429
+ console.log('Config unchanged: nothing had to be switched off for this arm. Ledger entry written.');
430
+ } else {
431
+ console.log(`Switched off (${result.changes.length}):`);
432
+ for (const change of result.changes) console.log(` - ${change}`);
433
+ console.log(`\nUndo: \`tune-context experiment restore\` — byte for byte from ${result.statePath},
434
+ or a refusal naming the file if one of those paths changed in the meantime.`);
435
+ }
436
+
437
+ for (const warning of result.warnings) console.log(`\n! ${warning}`);
438
+
439
+ console.log(`\n${RESTART_NOTE}`);
440
+ console.log(`\n${MCP_SCOPE_NOTE}`);
441
+ console.log('\nNext: `tune-context verify` — confirm the arm BEFORE the first prompt, not after the run.');
442
+ if (result.warnings.length) process.exitCode = 1;
443
+ }
444
+
445
+ function runExperimentRestore() {
446
+ const { claudeDir } = claudeDirs();
447
+ const result = experimentRestore({ claudeDir });
448
+ console.log('tune-context experiment restore\n');
449
+ if (result.parked) {
450
+ console.log(`Restored ${result.restored.length} path(s), verified against the fingerprints taken before the change:`);
451
+ for (const r of result.restored) console.log(` - ${r.path}`);
452
+ } else {
453
+ // The `on` arm parks nothing, so this is its normal ending — and not an
454
+ // empty one: the ledger entry is what tells the analyzer the run is over,
455
+ // instead of letting the last `set` label every later session (TASK-088).
456
+ console.log('Nothing had been parked — this arm changed no files. The ledger entry below is the point of\n the command: it closes the measurement window, so work done from now on is not read as part of the run.');
457
+ }
458
+ console.log(`\nLedger: arm back to tc=${result.armBefore.tc} rag=${result.armBefore.rag} at ${result.ledgerEntry.ts}.`);
459
+ console.log(`\n${RESTART_NOTE}`);
460
+ }
461
+
462
+ function runExperiment(parsed) {
463
+ try {
464
+ if (parsed.sub === 'status') runExperimentStatus(parsed.cwd);
465
+ else if (parsed.sub === 'set') runExperimentSet(parsed.cwd, parsed);
466
+ else runExperimentRestore();
467
+ } catch (error) {
468
+ // Operator-facing conditions (arm already set, nothing to restore, a file
469
+ // changed under us) are answers, not crashes: message + next step + exit 1,
470
+ // never a stacktrace. Anything else is a real bug and is rethrown.
471
+ if (!(error instanceof ExperimentError)) throw error;
472
+ process.stderr.write(`tune-context experiment ${parsed.sub}: ${error.message}\n${error.hint ? `\n ${error.hint}\n` : ''}`);
249
473
  process.exitCode = 1;
250
474
  }
251
475
  }
@@ -265,6 +489,8 @@ export function main() {
265
489
  process.exitCode = 1;
266
490
  } else if (parsed.mode === 'verify') {
267
491
  runVerify(parsed.cwd);
492
+ } else if (parsed.mode === 'experiment') {
493
+ runExperiment(parsed);
268
494
  } else {
269
495
  runInit(parsed.cwd, { forceClaudeMd: parsed.forceClaudeMd });
270
496
  }