@bglocation/tune-context 2.3.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +78 -0
- package/README.md +111 -6
- package/bin/tune-context.mjs +241 -15
- package/cli/experiment.mjs +674 -0
- package/cli/managed-block.mjs +24 -0
- package/cli/verify.mjs +51 -4
- package/eval/adoption-report.mjs +8 -6
- package/eval/arm.mjs +242 -0
- package/eval/pricing.mjs +430 -0
- package/eval/prompt-metrics.mjs +718 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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,83 @@ 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
|
+
|
|
8
85
|
## [2.3.1] — 2026-07-28
|
|
9
86
|
|
|
10
87
|
### Fixed
|
|
@@ -464,6 +541,7 @@ action needed on upgrade.
|
|
|
464
541
|
(`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
|
|
465
542
|
suite, and the adoption report (`eval/adoption-report.mjs`).
|
|
466
543
|
|
|
544
|
+
[2.4.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.4.0
|
|
467
545
|
[2.3.1]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.1
|
|
468
546
|
[2.3.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.0
|
|
469
547
|
[2.2.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.2.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
|
|
|
@@ -332,19 +332,124 @@ instead of printing a number the doubling would have faked.
|
|
|
332
332
|
`~/.claude/tune-context/` (or the `CLAUDE_CONFIG_DIR` override) and records only
|
|
333
333
|
tool names, file paths, and skill/agent names — never full Bash command strings.
|
|
334
334
|
|
|
335
|
-
**Honest scope:** this is a *behavioral proxy*, not a bill.
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
338
338
|
can't be counted this way. Doctrine applied as *method* — narrow reads,
|
|
339
339
|
contracts-first edits, delegation — is only partly visible too: no `Skill` call,
|
|
340
340
|
but its trace shows up in the report's edit:read ratio, so the verdict never
|
|
341
341
|
reads zero `Skill` calls as "unused."
|
|
342
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
|
+
|
|
343
447
|
### Getting real cost (OTEL) — outside tune-context
|
|
344
448
|
|
|
345
449
|
tune-context does not set this up for you (deliberate — see TASK-062: it needs
|
|
346
|
-
infra this tool stays zero-infra without).
|
|
347
|
-
|
|
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:
|
|
348
453
|
|
|
349
454
|
```bash
|
|
350
455
|
export CLAUDE_CODE_ENABLE_TELEMETRY=1 # off by default
|
package/bin/tune-context.mjs
CHANGED
|
@@ -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.
|
|
90
|
-
|
|
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 ?
|
|
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
|
-
|
|
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(
|
|
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
|
}
|