@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.
@@ -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
  }