@bglocation/tune-context 2.3.1 → 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.
@@ -0,0 +1,674 @@
1
+ // tune-context experiment — the second arm (TASK-087).
2
+ //
3
+ // Measuring one arm is not measuring. Everything else in eval/ reads what a
4
+ // session DID; this is the only module that changes what a session HAS, so it
5
+ // is also the only one that can break a live install. Three rules follow from
6
+ // that, and they are the whole design:
7
+ //
8
+ // 1. **Reversibility before mutation.** Every touched path is fingerprinted
9
+ // and copied into a backup dir, and the state file naming it is on disk,
10
+ // BEFORE the path is modified. A `set` killed halfway through still leaves
11
+ // a `restore` that works — the acceptance criterion, not a nicety.
12
+ // 2. **`restore` may never eat somebody else's work.** A whole working
13
+ // session sits between `set` and `restore`; the user may well have added a
14
+ // permission to settings.json or written below CLAUDE.md's marker (exactly
15
+ // the TASK-082 scenario). So restore compares each path against the
16
+ // fingerprint `set` left behind and REFUSES on any mismatch, naming the
17
+ // file. Refusing is the correct outcome here, not a failure.
18
+ // 3. **The ledger is the source of truth, detection is the cross-check.**
19
+ // `observeArm()` reads the live config, but an analyzer assigns a session
20
+ // to an arm by timestamp against the ledger. Absence of RAG calls in a
21
+ // transcript never proved RAG's absence (TASK-070/083) — and absence of a
22
+ // hook registration does not prove the arm either: a plugin-channel
23
+ // install registers hooks somewhere this switch cannot see.
24
+ //
25
+ // What `tc off` covers is a PO decision (EPIC-009, 2026-07-30): hooks
26
+ // registered in settings.json, the managed block in the project CLAUDE.md,
27
+ // doctrine skills, agents. Four interventions deliberately mixed into one
28
+ // variable — at small n the biggest effect is the easiest one to detect;
29
+ // splitting them is a separate experiment AFTER the first results.
30
+ //
31
+ // What `rag off` covers: the PROJECT `.mcp.json`, and nothing else. The user
32
+ // scope (`~/.claude.json`) is rewritten by Claude Code while it runs, so a
33
+ // switch that edited it would be overwritten, or would corrupt live state.
34
+ // `status` says this out loud rather than letting the operator assume RAG is
35
+ // globally off.
36
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
37
+ import { basename, dirname, join } from 'node:path';
38
+ import { END_MARKER, START_MARKER, removeManagedBlock } from './managed-block.mjs';
39
+ import { doctrineEntries } from './sync-doctrine.mjs';
40
+ import { fingerprint } from './verify.mjs';
41
+
42
+ const STATE_VERSION = 1;
43
+
44
+ /**
45
+ * Operator-facing failure: a condition the user can act on (arm already set,
46
+ * nothing to restore, a file changed under us). Carries a `hint` so the CLI can
47
+ * print the next step instead of a stacktrace — an instrument that mutates a
48
+ * live config owes the reader a way out.
49
+ */
50
+ export class ExperimentError extends Error {
51
+ constructor(message, hint) {
52
+ super(message);
53
+ this.name = 'ExperimentError';
54
+ this.hint = hint;
55
+ }
56
+ }
57
+
58
+ /** All experiment state lives under the same `tune-context/` dir the hooks already use. */
59
+ export function experimentPaths(claudeDir) {
60
+ const dir = join(claudeDir, 'tune-context');
61
+ return {
62
+ dir,
63
+ statePath: join(dir, 'experiment-state.json'),
64
+ ledgerPath: join(dir, 'experiment.jsonl'),
65
+ backupDir: join(dir, 'experiment-backup'),
66
+ };
67
+ }
68
+
69
+ function readJson(path) {
70
+ try {
71
+ return JSON.parse(readFileSync(path, 'utf8'));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function writeJson(path, obj) {
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`);
80
+ }
81
+
82
+ function readText(path) {
83
+ try {
84
+ return readFileSync(path, 'utf8');
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /** The hook SCRIPT names this package deploys — what a settings.json command would name. */
91
+ function hookScriptNames(pkgRoot) {
92
+ return doctrineEntries(pkgRoot)
93
+ .filter((e) => e.kind === 'hooks' && e.name.endsWith('.mjs'))
94
+ .map((e) => e.name);
95
+ }
96
+
97
+ /** Deployed-doctrine entries of one kind, split into what is on disk and what is not. */
98
+ function deployed(claudeDir, pkgRoot, kind) {
99
+ const names = doctrineEntries(pkgRoot)
100
+ .filter((e) => e.kind === kind)
101
+ .map((e) => e.name);
102
+ const present = names.filter((name) => existsSync(join(claudeDir, kind, name)));
103
+ return { names, present, missing: names.filter((n) => !present.includes(n)) };
104
+ }
105
+
106
+ /**
107
+ * Does this settings.json command fire one of OUR hook scripts?
108
+ *
109
+ * One definition, used by both the reader (`hookRegistrations`) and the writer
110
+ * (`stripHookRegistrations`) — two spellings of "ours" would eventually disagree,
111
+ * and the way they would disagree is: something reported as removed that is still
112
+ * registered, i.e. an off arm that is not off.
113
+ *
114
+ * Matching is by SCRIPT NAME anywhere in the command, deliberately looser than
115
+ * verify's resolve-the-path cross-check: a registration that merely *might* fire
116
+ * our hook has to go, whatever dir it points at.
117
+ */
118
+ function firesOurHook(command, scriptNames) {
119
+ return typeof command === 'string' && scriptNames.some((name) => command.includes(name));
120
+ }
121
+
122
+ /** Every `{event, command}` in a settings object naming one of `scriptNames`. */
123
+ function hookRegistrations(settings, scriptNames) {
124
+ const found = [];
125
+ for (const [event, groups] of Object.entries(settings?.hooks || {})) {
126
+ for (const group of groups || []) {
127
+ for (const hook of group?.hooks || []) {
128
+ if (firesOurHook(hook.command, scriptNames)) found.push({ event, command: hook.command });
129
+ }
130
+ }
131
+ }
132
+ return found;
133
+ }
134
+
135
+ /**
136
+ * Drop every hook registration naming one of this package's scripts (see
137
+ * firesOurHook for what counts as ours). Groups emptied by the removal are
138
+ * dropped, and so is an event left with no groups — restore puts the file back
139
+ * byte for byte from the backup, so tidying costs nothing.
140
+ *
141
+ * Entries belonging to anyone else are never touched.
142
+ *
143
+ * @returns {{settings: object, removed: string[]}} a NEW settings object
144
+ */
145
+ export function stripHookRegistrations(settings, scriptNames) {
146
+ const next = structuredClone(settings ?? {});
147
+ const removed = [];
148
+ for (const [event, groups] of Object.entries(next.hooks || {})) {
149
+ const kept = [];
150
+ for (const group of groups || []) {
151
+ if (!Array.isArray(group?.hooks)) {
152
+ kept.push(group); // not a shape we wrote — not ours to judge
153
+ continue;
154
+ }
155
+ const hooks = group.hooks.filter((hook) => {
156
+ const ours = firesOurHook(hook.command, scriptNames);
157
+ if (ours) removed.push(`${event}: ${hook.command}`);
158
+ return !ours;
159
+ });
160
+ if (hooks.length) kept.push({ ...group, hooks });
161
+ }
162
+ if (kept.length) next.hooks[event] = kept;
163
+ else delete next.hooks[event];
164
+ }
165
+ if (next.hooks && Object.keys(next.hooks).length === 0) delete next.hooks;
166
+ return { settings: next, removed };
167
+ }
168
+
169
+ /**
170
+ * What the live configuration currently looks like — the CROSS-CHECK, never the
171
+ * verdict. Read-only.
172
+ *
173
+ * Each component reports `on` / `off` / `partial` / `unknown`; `tc` is `on` only
174
+ * when all the observable ones are, `off` only when none is, and `mixed`
175
+ * otherwise. `mixed` is a real and useful answer: a half-applied `set`, or a
176
+ * `tune-context init` run during an off arm, both land there, and both
177
+ * invalidate a measurement run.
178
+ *
179
+ * `unknown` covers the one case that must not be mistaken for `off`: a package
180
+ * root that lists no skills (or no agents) at all. Nothing deployed out of
181
+ * nothing shipped says nothing about the arm — counting it as `off` would drag
182
+ * a perfectly on config to `mixed` and stall a run over a phantom.
183
+ *
184
+ * @returns {{tc: 'on'|'off'|'mixed'|'unknown', rag: 'on'|'off', components: object[], mcp: object}}
185
+ */
186
+ export function observeArm({ claudeDir, cwd, pkgRoot }) {
187
+ const settingsPath = join(claudeDir, 'settings.json');
188
+ const scriptNames = hookScriptNames(pkgRoot);
189
+ const registrations = hookRegistrations(readJson(settingsPath), scriptNames);
190
+
191
+ const claudeMdPath = join(cwd, 'CLAUDE.md');
192
+ const claudeMd = readText(claudeMdPath);
193
+ const hasBlock = claudeMd !== null && [START_MARKER, END_MARKER].every((m) => claudeMd.includes(m));
194
+
195
+ const skills = deployed(claudeDir, pkgRoot, 'skills');
196
+ const agents = deployed(claudeDir, pkgRoot, 'agents');
197
+
198
+ const mcpPath = join(cwd, '.mcp.json');
199
+ const mcpServers = Object.keys(readJson(mcpPath)?.mcpServers || {});
200
+
201
+ const tri = (present, total) => (total === 0 ? 'unknown' : present === 0 ? 'off' : present === total ? 'on' : 'partial');
202
+ const deployedDetail = ({ present, names }) => (names.length === 0 ? 'this package deploys none' : `${present.length}/${names.length} deployed`);
203
+
204
+ const components = [
205
+ {
206
+ kind: 'hooks',
207
+ // Same rule as skills/agents below: with no script names to look for,
208
+ // "none registered" is a vacuous truth, not an observation.
209
+ label: `hook registrations in ${settingsPath}`,
210
+ state: scriptNames.length === 0 ? 'unknown' : registrations.length ? 'on' : 'off',
211
+ detail: scriptNames.length === 0 ? 'this package ships no hook scripts' : registrations.length ? `${registrations.length} registered` : 'none registered',
212
+ },
213
+ {
214
+ kind: 'claude-md',
215
+ label: `managed block in ${claudeMdPath}`,
216
+ state: hasBlock ? 'on' : 'off',
217
+ detail: claudeMd === null ? 'no CLAUDE.md in this project' : hasBlock ? 'present' : 'absent',
218
+ },
219
+ {
220
+ kind: 'skills',
221
+ label: `doctrine skills in ${join(claudeDir, 'skills')}`,
222
+ state: tri(skills.present.length, skills.names.length),
223
+ detail: deployedDetail(skills),
224
+ },
225
+ {
226
+ kind: 'agents',
227
+ label: `agents in ${join(claudeDir, 'agents')}`,
228
+ state: tri(agents.present.length, agents.names.length),
229
+ detail: deployedDetail(agents),
230
+ },
231
+ ];
232
+
233
+ const states = components.map((c) => c.state).filter((s) => s !== 'unknown');
234
+ const tc = states.length === 0 ? 'unknown' : states.every((s) => s === 'on') ? 'on' : states.every((s) => s === 'off') ? 'off' : 'mixed';
235
+
236
+ return {
237
+ tc,
238
+ rag: mcpServers.length ? 'on' : 'off',
239
+ components,
240
+ mcp: { path: mcpPath, servers: mcpServers, exists: existsSync(mcpPath) },
241
+ };
242
+ }
243
+
244
+ /**
245
+ * The paths `set` would touch to reach the requested arm, each with the mutation
246
+ * that gets it there. Only ever turns things OFF: re-enabling is `restore`'s job,
247
+ * because only restore knows the exact bytes that were there before.
248
+ */
249
+ function planMutations({ claudeDir, cwd, pkgRoot, tc, rag }) {
250
+ const mutations = [];
251
+
252
+ if (tc === 'off') {
253
+ const settingsPath = join(claudeDir, 'settings.json');
254
+ const settings = readJson(settingsPath);
255
+ const scriptNames = hookScriptNames(pkgRoot);
256
+ const registrations = hookRegistrations(settings, scriptNames);
257
+ if (registrations.length) {
258
+ mutations.push({
259
+ label: `${registrations.length} hook registration(s) removed from settings.json`,
260
+ path: settingsPath,
261
+ apply: () => {
262
+ // Re-read at apply time: minutes may pass between planning and
263
+ // applying when an earlier mutation is slow, and clobbering an
264
+ // unrelated edit made in between is the one thing this module must
265
+ // never do. If the re-read comes back unparseable, stop — writing the
266
+ // `{}` that a null would strip down to is the very clobber this
267
+ // re-read exists to prevent, and the backup already taken is the only
268
+ // remaining copy.
269
+ const current = readJson(settingsPath);
270
+ if (current === null) throw new Error(`${settingsPath} is no longer readable JSON — refusing to overwrite it`);
271
+ const { settings: stripped } = stripHookRegistrations(current, scriptNames);
272
+ writeJson(settingsPath, stripped);
273
+ },
274
+ });
275
+ }
276
+
277
+ const claudeMdPath = join(cwd, 'CLAUDE.md');
278
+ const claudeMd = readText(claudeMdPath);
279
+ if (claudeMd !== null && [START_MARKER, END_MARKER].every((m) => claudeMd.includes(m))) {
280
+ mutations.push({
281
+ label: 'managed block removed from the project CLAUDE.md',
282
+ path: claudeMdPath,
283
+ apply: () => {
284
+ const { content } = removeManagedBlock(readText(claudeMdPath));
285
+ writeFileSync(claudeMdPath, content);
286
+ },
287
+ });
288
+ }
289
+
290
+ for (const kind of ['skills', 'agents']) {
291
+ for (const name of deployed(claudeDir, pkgRoot, kind).present) {
292
+ const path = join(claudeDir, kind, name);
293
+ mutations.push({ label: `${kind}/${name} parked`, path, apply: () => rmSync(path, { recursive: true, force: true }) });
294
+ }
295
+ }
296
+ }
297
+
298
+ if (rag === 'off') {
299
+ const mcpPath = join(cwd, '.mcp.json');
300
+ if (existsSync(mcpPath)) {
301
+ mutations.push({ label: 'project .mcp.json parked', path: mcpPath, apply: () => rmSync(mcpPath, { force: true }) });
302
+ }
303
+ }
304
+
305
+ return mutations;
306
+ }
307
+
308
+ export function readState(claudeDir) {
309
+ return readJson(experimentPaths(claudeDir).statePath);
310
+ }
311
+
312
+ const PREVIEW_MAX = 80;
313
+
314
+ function preview(line) {
315
+ if (line === undefined) return '(nothing at this line)';
316
+ const trimmed = line.trimEnd();
317
+ return trimmed.length > PREVIEW_MAX ? `${trimmed.slice(0, PREVIEW_MAX)}…` : trimmed || '(blank line)';
318
+ }
319
+
320
+ /** Every file under `root`, as sorted paths relative to it. */
321
+ function listFiles(root) {
322
+ const out = [];
323
+ const walk = (dir, rel) => {
324
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
325
+ const next = rel ? `${rel}/${entry.name}` : entry.name;
326
+ if (entry.isDirectory()) walk(join(dir, entry.name), next);
327
+ else out.push(next);
328
+ }
329
+ };
330
+ walk(root, '');
331
+ return out.sort();
332
+ }
333
+
334
+ /**
335
+ * Where two texts stop agreeing, trimming the common head and the common tail.
336
+ *
337
+ * Not a diff algorithm and not trying to be one: it answers "from which line do
338
+ * they differ, and how many lines does each side hold there", which is what a
339
+ * refusal message needs. An LCS would find the prettiest alignment of an edit
340
+ * scattered through a file; it would not change the decision the reader has to
341
+ * make, and it is a lot of code to carry for a message printed rarely.
342
+ */
343
+ function textDifference(current, saved) {
344
+ const a = current.split('\n');
345
+ const b = saved.split('\n');
346
+ let start = 0;
347
+ while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
348
+ let endA = a.length - 1;
349
+ let endB = b.length - 1;
350
+ while (endA >= start && endB >= start && a[endA] === b[endB]) {
351
+ endA -= 1;
352
+ endB -= 1;
353
+ }
354
+ return { line: start + 1, dropped: endA - start + 1, returned: endB - start + 1, nowLine: a[start], savedLine: b[start] };
355
+ }
356
+
357
+ /**
358
+ * What `restore` would replace, in words — the "and the difference" half of the
359
+ * task's requirement, so a refusal names not just WHICH file diverged but WHAT
360
+ * would be lost by overwriting it.
361
+ *
362
+ * Compares the live path against the saved copy, in that direction on purpose:
363
+ * the saved copy is what restore would write, so lines only in the live file are
364
+ * the ones about to disappear. That is the number a reader is deciding on.
365
+ *
366
+ * @returns {string[]} lines for the message; empty when there is nothing useful
367
+ * to say (a path that is simply gone, or one with no saved copy at all)
368
+ */
369
+ function describeDifference(livePath, backupPath) {
370
+ if (!backupPath || !existsSync(backupPath)) return [];
371
+ if (!existsSync(livePath)) return ['restoring would put the saved copy back where it was'];
372
+
373
+ const liveIsDir = statSync(livePath).isDirectory();
374
+ const savedIsDir = statSync(backupPath).isDirectory();
375
+ // A path that changed KIND has no line diff and no file list — and walking it
376
+ // as if it had one throws ENOTDIR, which would replace this whole refusal
377
+ // (including the line naming the backups) with a stacktrace.
378
+ if (liveIsDir !== savedIsDir) {
379
+ return [`${liveIsDir ? 'a directory' : 'a file'} is there now, ${savedIsDir ? 'a directory' : 'a file'} in the saved copy`];
380
+ }
381
+
382
+ if (liveIsDir) {
383
+ const live = new Set(listFiles(livePath));
384
+ const saved = new Set(listFiles(backupPath));
385
+ const onlyLive = [...live].filter((f) => !saved.has(f));
386
+ const onlySaved = [...saved].filter((f) => !live.has(f));
387
+ const changed = [...live].filter((f) => saved.has(f) && fingerprint(join(livePath, f)) !== fingerprint(join(backupPath, f)));
388
+ const show = (names) => `${names.slice(0, 3).join(', ')}${names.length > 3 ? `, +${names.length - 3} more` : ''}`;
389
+ return [
390
+ `restoring would drop ${onlyLive.length} file(s) only in the current copy${onlyLive.length ? `: ${show(onlyLive)}` : ''}`,
391
+ ` overwrite ${changed.length} changed file(s)${changed.length ? `: ${show(changed)}` : ''}`,
392
+ ` and bring back ${onlySaved.length} file(s)${onlySaved.length ? `: ${show(onlySaved)}` : ''}`,
393
+ ];
394
+ }
395
+
396
+ const current = readFileSync(livePath);
397
+ const saved = readFileSync(backupPath);
398
+ // A NUL byte means splitting on newlines would produce noise, not lines.
399
+ if (current.includes(0) || saved.includes(0)) {
400
+ return [`binary content differs: ${current.length} byte(s) now, ${saved.length} in the saved copy`];
401
+ }
402
+
403
+ const diff = textDifference(current.toString('utf8'), saved.toString('utf8'));
404
+ return [
405
+ `first difference at line ${diff.line}: restoring would drop ${diff.dropped} line(s) and bring back ${diff.returned}`,
406
+ ` now: ${preview(diff.nowLine)}`,
407
+ ` saved: ${preview(diff.savedLine)}`,
408
+ ];
409
+ // Note for the reader of this message, stated once in the hint below rather
410
+ // than repeated per entry: the comparison is against the PRE-experiment copy,
411
+ // which is what restore would write — so part of every difference shown is the
412
+ // arm's own change (the managed block it removed, the registrations it
413
+ // stripped), not the user's edit. Diffing against what `set` left behind would
414
+ // isolate the edit exactly, but only by keeping a second copy of every touched
415
+ // path for the length of a working session. The numbers above are what restore
416
+ // would do; the qualifier keeps them from being read as "your edit was 7 lines".
417
+ }
418
+
419
+ /**
420
+ * Append one line to the append-only ledger. `{ts, tc, rag, note}` is the shape
421
+ * TASK-087 specifies and is always present; `event` is a superset field so an
422
+ * analyzer can tell a `set` from a `restore` without parsing prose.
423
+ */
424
+ export function appendLedger(claudeDir, entry) {
425
+ const { ledgerPath } = experimentPaths(claudeDir);
426
+ mkdirSync(dirname(ledgerPath), { recursive: true });
427
+ appendFileSync(ledgerPath, `${JSON.stringify(entry)}\n`);
428
+ return entry;
429
+ }
430
+
431
+ /** Parsed ledger, oldest first. Unparseable lines are skipped, never guessed at. */
432
+ export function readLedger(claudeDir) {
433
+ const raw = readText(experimentPaths(claudeDir).ledgerPath);
434
+ if (raw === null) return [];
435
+ return raw
436
+ .split('\n')
437
+ .filter((line) => line.trim())
438
+ .map((line) => {
439
+ try {
440
+ return JSON.parse(line);
441
+ } catch {
442
+ return null;
443
+ }
444
+ })
445
+ .filter(Boolean);
446
+ }
447
+
448
+ /**
449
+ * Switch to `{tc, rag}`, recording everything needed to switch back.
450
+ *
451
+ * Ordering is load-bearing:
452
+ * state file → ledger entry → mutations, each mutation backed up and recorded
453
+ * before it runs.
454
+ *
455
+ * The ledger entry is written BEFORE the first mutation on purpose. A `set` that
456
+ * dies halfway leaves a config that is genuinely half-off, and a session started
457
+ * in that window really is a mixed session — the ledger entry plus the later
458
+ * restore entry bracket exactly that window, which is what lets an analyzer
459
+ * throw the session out instead of silently averaging it in.
460
+ *
461
+ * @param {'on'|'off'} tc
462
+ * @param {'on'|'off'} rag
463
+ * @returns {{arm, armBefore, ledgerEntry, changes: string[], statePath: string|null, alreadySet: boolean, warnings: string[]}}
464
+ * @throws {ExperimentError} when a DIFFERENT arm is already set — nesting states
465
+ * would lose the original bytes, which is the one failure restore cannot survive.
466
+ */
467
+ export function experimentSet({ claudeDir, cwd, pkgRoot, tc, rag, note = '', now = new Date() }) {
468
+ const { statePath, backupDir } = experimentPaths(claudeDir);
469
+ const arm = { tc, rag };
470
+ const existing = readState(claudeDir);
471
+
472
+ if (existing && (existing.arm.tc !== tc || existing.arm.rag !== rag)) {
473
+ throw new ExperimentError(
474
+ `arm tc=${existing.arm.tc} rag=${existing.arm.rag} is already set (since ${existing.startedAt})`,
475
+ 'run `tune-context experiment restore` first — setting a second arm on top would lose the bytes the first one saved',
476
+ );
477
+ }
478
+
479
+ // Re-setting the arm already in force: no config change, but the ledger entry
480
+ // still goes in. That is not redundancy — a run whose ledger has no entry at
481
+ // its start cannot be assigned to an arm at all, and "the arm did not change"
482
+ // is exactly the case an operator hits when the second pass is `on`.
483
+ if (existing) {
484
+ const ledgerEntry = appendLedger(claudeDir, { ts: now.toISOString(), event: 'set', tc, rag, note });
485
+ return { arm, armBefore: existing.armBefore, ledgerEntry, changes: [], statePath, alreadySet: true, warnings: [] };
486
+ }
487
+
488
+ const observed = observeArm({ claudeDir, cwd, pkgRoot });
489
+ const warnings = [];
490
+ const mutations = planMutations({ claudeDir, cwd, pkgRoot, tc, rag });
491
+
492
+ // Asked for an arm we cannot bring about: `set` only ever turns things off, so
493
+ // `--tc on` while the doctrine is missing records an arm the config does not
494
+ // have. Say so loudly — this is the pre-run check in COST_EVALUATION_PROCEDURE
495
+ // §4 catching a mixed arm before the first prompt, not after the run.
496
+ if (tc === 'on' && observed.tc !== 'on') {
497
+ warnings.push(
498
+ `ledger now says tc=on, but the config observes tc=${observed.tc} — run \`tune-context init\` (or \`experiment restore\`) before the session, or the run is mislabelled`,
499
+ );
500
+ }
501
+ if (rag === 'on' && observed.rag !== 'on') {
502
+ warnings.push(`ledger now says rag=on, but this project has no .mcp.json server — the run would be mislabelled`);
503
+ }
504
+
505
+ // What the config looked like BEFORE this switch touched it — this is what
506
+ // `restore` writes back into the ledger. It can legitimately be `mixed`, and
507
+ // that value is recorded as-is rather than rounded to `on`: a config that was
508
+ // already half-configured cannot label a run, and an analyzer must be able to
509
+ // see that instead of inheriting a tidy-looking lie.
510
+ const armBefore = { tc: observed.tc, rag: observed.rag };
511
+ const ledgerEntry = appendLedger(claudeDir, { ts: now.toISOString(), event: 'set', tc, rag, note });
512
+
513
+ if (mutations.length === 0) {
514
+ // Nothing to undo, so no state file: `restore` must be able to say "nothing
515
+ // was changed" instead of pretending to have work to do.
516
+ return { arm, armBefore, ledgerEntry, changes: [], statePath: null, alreadySet: false, warnings };
517
+ }
518
+
519
+ const state = { version: STATE_VERSION, startedAt: now.toISOString(), cwd, claudeDir, arm, armBefore, note, entries: [] };
520
+ writeJson(statePath, state);
521
+
522
+ const changes = [];
523
+ for (const [i, mutation] of mutations.entries()) {
524
+ const before = fingerprint(mutation.path);
525
+ const backup = before === null ? null : join(backupDir, `${String(i + 1).padStart(2, '0')}-${basename(mutation.path)}`);
526
+ if (backup) {
527
+ mkdirSync(backupDir, { recursive: true });
528
+ cpSync(mutation.path, backup, { recursive: true });
529
+ }
530
+ // Recorded BEFORE the mutation runs. If apply() throws — no write
531
+ // permission, a locked file — the entry is already on disk with its backup,
532
+ // so restore can still undo it. `pending` marks the one window in which the
533
+ // path's content is not yet known.
534
+ const entry = { label: mutation.label, path: mutation.path, backup, before, after: null, pending: true };
535
+ state.entries.push(entry);
536
+ writeJson(statePath, state);
537
+
538
+ mutation.apply();
539
+
540
+ entry.after = fingerprint(mutation.path);
541
+ entry.pending = false;
542
+ writeJson(statePath, state);
543
+ changes.push(mutation.label);
544
+ }
545
+
546
+ return { arm, armBefore, ledgerEntry, changes, statePath, alreadySet: false, warnings };
547
+ }
548
+
549
+ /** The last ledger entry if it leaves an arm IN FORCE, else null. */
550
+ function openLedgerArm(claudeDir) {
551
+ const ledger = readLedger(claudeDir);
552
+ const last = ledger[ledger.length - 1];
553
+ return last && last.event !== 'restore' ? last : null;
554
+ }
555
+
556
+ /**
557
+ * Put back exactly what `set` moved — or refuse.
558
+ *
559
+ * Verification runs over EVERY entry before a single byte is written, and any
560
+ * mismatch aborts the whole restore. Partial restores are worse than none: they
561
+ * leave a config no state file describes.
562
+ *
563
+ * `pending` entries (see experimentSet) are exempt from the comparison. Their
564
+ * content is genuinely unknown — the mutation may have half-landed — and the
565
+ * window they cover is the inside of one process, not a working session, so
566
+ * there is no user edit to protect there.
567
+ *
568
+ * It is also how a measurement window ENDS, parked files or not — see the
569
+ * no-state branch below.
570
+ *
571
+ * @returns {{restored: object[], armBefore, ledgerEntry, statePath, parked: boolean}}
572
+ * `parked: false` when there was nothing to put back and this only closed the
573
+ * ledger window.
574
+ * @throws {ExperimentError} when no arm is open at all, or when a touched path
575
+ * changed after `set` — the refusal is the feature.
576
+ */
577
+ export function experimentRestore({ claudeDir, note = '', now = new Date() }) {
578
+ const { statePath, backupDir } = experimentPaths(claudeDir);
579
+ const state = readState(claudeDir);
580
+ if (!state) {
581
+ // Nothing was parked — but an arm can still be OPEN in the ledger, and that
582
+ // is the normal end of an `on` run: the switch only ever turns things off,
583
+ // so `set --tc on --rag on` writes a ledger entry and touches no file.
584
+ //
585
+ // Refusing here left that window open forever. TASK-088 leans on `restore`
586
+ // to close it ("no arm is in force from here on"), so without this every
587
+ // later prompt on an exp/ branch — a follow-up in the same worktree a week
588
+ // after the run — kept resolving to `branch+ledger`, the one state the
589
+ // procedure calls fit for analysis, and joined the sample in silence. The
590
+ // off arm was protected by its state file, so the contamination was
591
+ // ONE-SIDED: flagged on one arm, invisible on the other. An asymmetric leak
592
+ // between the two things being compared is worse than the leak.
593
+ const open = openLedgerArm(claudeDir);
594
+ if (open) {
595
+ // tc/rag mirror the `set` this closes, because nothing was changed:
596
+ // the config after is the config before. (`ledgerArmAt` reads no arm off a
597
+ // `restore` entry at all — the fields are here for a human reading the
598
+ // file, and the shape has to stay `{ts, tc, rag, note}`.)
599
+ const ledgerEntry = appendLedger(claudeDir, {
600
+ ts: now.toISOString(),
601
+ event: 'restore',
602
+ tc: open.tc,
603
+ rag: open.rag,
604
+ note: note || 'measurement window closed; nothing had been parked',
605
+ });
606
+ return { restored: [], armBefore: { tc: open.tc, rag: open.rag }, ledgerEntry, statePath, parked: false };
607
+ }
608
+ throw new ExperimentError(
609
+ `no experiment state at ${statePath} and no open arm in the ledger — nothing was restored`,
610
+ 'a `restore` closes what a `tune-context experiment set` opened; there is no such entry to close',
611
+ );
612
+ }
613
+
614
+ // Content matching `before` is NOT drift: that path is already back where it
615
+ // started. Two ways to get there, and both must stay restorable — a restore
616
+ // that died partway through (cpSync hit a permission wall on entry 4 of 7) and
617
+ // is being retried, and a user who put one file back by hand. Comparing only
618
+ // against `after` made the first case permanently unrestorable: every retry
619
+ // would refuse over the entries the previous attempt had already fixed.
620
+ const drifted = state.entries
621
+ .filter((e) => !e.pending)
622
+ .map((e) => ({ entry: e, current: fingerprint(e.path) }))
623
+ .filter(({ entry, current }) => current !== entry.after && current !== entry.before);
624
+
625
+ if (drifted.length) {
626
+ const lines = drifted.flatMap(({ entry, current }) => {
627
+ const what = current === null ? 'deleted since `set`' : entry.after === null ? 're-created since `set`' : 'edited since `set`';
628
+ return [
629
+ ` ${entry.path} — ${what}`,
630
+ ...describeDifference(entry.path, entry.backup).map((line) => ` ${line}`),
631
+ ` saved copy: ${entry.backup ?? '(none — the file did not exist before the experiment)'}`,
632
+ ];
633
+ });
634
+ throw new ExperimentError(
635
+ `refusing to restore: ${drifted.length} path(s) changed after \`set\`\n${lines.join('\n')}`,
636
+ 'Each difference is measured against the pre-experiment copy — the bytes restore would write — so part of it is the arm\'s own ' +
637
+ 'change, not yours. Restoring would erase the rest without a trace: merge what you want to keep out of the saved copies by hand, ' +
638
+ `then delete ${statePath} to end the experiment — or re-run \`tune-context init\` to regenerate the managed parts.`,
639
+ );
640
+ }
641
+
642
+ const restored = [];
643
+ const failures = [];
644
+ for (const entry of state.entries) {
645
+ rmSync(entry.path, { recursive: true, force: true });
646
+ if (entry.backup) cpSync(entry.backup, entry.path, { recursive: true });
647
+ const now_ = fingerprint(entry.path);
648
+ if (now_ !== entry.before) failures.push(`${entry.path} — restored content does not match what was saved`);
649
+ restored.push({ path: entry.path, label: entry.label });
650
+ }
651
+
652
+ if (failures.length) {
653
+ // Backups are deliberately NOT deleted here: they are the only remaining
654
+ // copy of whatever did not come back.
655
+ throw new ExperimentError(
656
+ `restore finished with ${failures.length} mismatch(es):\n${failures.map((f) => ` ${f}`).join('\n')}`,
657
+ `the backup copies are kept at ${backupDir} — compare by hand before removing ${statePath}`,
658
+ );
659
+ }
660
+
661
+ const armBefore = state.armBefore;
662
+ const ledgerEntry = appendLedger(claudeDir, {
663
+ ts: now.toISOString(),
664
+ event: 'restore',
665
+ tc: armBefore.tc,
666
+ rag: armBefore.rag,
667
+ note: note || `restored arm tc=${state.arm.tc} rag=${state.arm.rag}`,
668
+ });
669
+
670
+ rmSync(backupDir, { recursive: true, force: true });
671
+ rmSync(statePath, { force: true });
672
+
673
+ return { restored, armBefore, ledgerEntry, statePath, parked: true };
674
+ }