@bglocation/tune-context 2.4.0 → 3.0.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.
@@ -16,10 +16,11 @@
16
16
  // "No model in the loop" is the feature here, not a gap — but the gap has to be
17
17
  // stated, or a CLI user cannot know what they did not get. Run the skill
18
18
  // (/tune-context) for the full pass.
19
- import { readFileSync, realpathSync } from 'node:fs';
20
- import { dirname, join, resolve } from 'node:path';
19
+ import { basename, dirname, join, resolve } from 'node:path';
21
20
  import { fileURLToPath } from 'node:url';
22
21
  import { claudeDirs, detectExistingConfig, detectMcpServers, detectRejectedMcpServers, detectStack } from '../cli/detect.mjs';
22
+ import { isEntryPoint } from '../cli/entry-point.mjs';
23
+ import { readVersion } from '../cli/version.mjs';
23
24
  import {
24
25
  buildPermissionsAllow,
25
26
  generateProjectClaudeMd,
@@ -30,6 +31,7 @@ import {
30
31
  mergePermissions,
31
32
  } from '../cli/generate.mjs';
32
33
  import { staleDoctrineEntries, verify } from '../cli/verify.mjs';
34
+ import { archiveExperiment } from '../cli/archive.mjs';
33
35
  import {
34
36
  ExperimentError,
35
37
  experimentPaths,
@@ -51,14 +53,44 @@ const HOOK_EVENTS = [
51
53
  const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
52
54
 
53
55
  /**
54
- * Version straight from the shipped package.json — one source of truth, the
55
- * same file verify:plugin cross-checks against plugin.json/marketplace.json,
56
- * so `--version` can never drift from what the manifests claim. Both channels
57
- * ship it: npm always includes package.json in the tarball, and the plugin
58
- * channel is a git clone of this repo (guarded in verify-plugin's REQUIRE).
56
+ * `tune-context eval <step>` — the cost measurement's meter, reachable from any
57
+ * directory (EPIC-010). Before this it existed only as `node eval/<file>.mjs`, a
58
+ * relative path that resolves in a checkout of this repo and nowhere else, so
59
+ * the switch (`experiment`, from PATH) and the meter (from cwd) could come from
60
+ * two different versions of the code.
61
+ *
62
+ * A step's name IS its module's name — `eval/<step>.mjs` — and that is the whole
63
+ * mapping (PO decision 1): no aliases, because an alias is a second name for
64
+ * the same thing, and deriving the path from the name leaves no table in which
65
+ * one could be added. Dispatch and usage are both built from this object.
59
66
  */
60
- export function readVersion() {
61
- return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version;
67
+ const EVAL_STEPS = {
68
+ 'prompt-metrics': 'transcripts → JSONL rows, one per prompt, priced',
69
+ analyze: 'rows → one aggregate per arm; stops before a verdict',
70
+ verdict: 'aggregate → the gates and the finding, as JSON',
71
+ report: 'aggregate → one self-contained HTML page',
72
+ 'adoption-report': 'hook log → which levers the model reaches for',
73
+ };
74
+
75
+ /** One line per step, aligned — the same listing in both usage texts. */
76
+ function evalStepLines(indent) {
77
+ const width = Math.max(...Object.keys(EVAL_STEPS).map((s) => s.length));
78
+ return Object.entries(EVAL_STEPS)
79
+ .map(([step, what]) => `${indent}${step.padEnd(width)} ${what}`)
80
+ .join('\n');
81
+ }
82
+
83
+ function evalUsage() {
84
+ return `tune-context eval — the cost measurement (EPIC-009), from any directory.
85
+
86
+ Usage:
87
+ tune-context eval <step> [flags]
88
+
89
+ Steps:
90
+ ${evalStepLines(' ')}
91
+
92
+ Every flag after the step belongs to the step: tune-context eval <step> --help.
93
+ `;
62
94
  }
63
95
 
64
96
  function usage() {
@@ -70,6 +102,8 @@ Usage:
70
102
  tune-context experiment status [--cwd <dir>]
71
103
  tune-context experiment set --tc <on|off> --rag <on|off> [--note "..."] [--cwd <dir>]
72
104
  tune-context experiment restore
105
+ tune-context experiment archive --out <dir>
106
+ tune-context eval <step> [step flags]
73
107
  tune-context --help
74
108
  tune-context --version
75
109
 
@@ -84,6 +118,15 @@ Usage:
84
118
  byte for byte, or refuses if a touched file changed since.
85
119
  Both --tc and --rag are required for \`set\`: an arm that
86
120
  was only half-stated is an arm nobody can reproduce.
121
+ \`archive\` copies the sessions on exp/ branches and the
122
+ ledger out of reach of Claude Code's retention, append-only
123
+ — run it after every arm. Refused inside a git work tree
124
+ unless ignored: a transcript is the whole conversation.
125
+
126
+ eval The meter of that experiment, one step at a time, from
127
+ any directory:
128
+ ${evalStepLines(' ')}
129
+ Flags belong to the step: tune-context eval <step> --help.
87
130
 
88
131
  --force-claude-md Regenerate a project CLAUDE.md whose hand-written content
89
132
  still sits inside the managed block. Discards that content.
@@ -94,7 +137,7 @@ Usage:
94
137
  // Flags that consume the token after them. Listed once so the positional
95
138
  // scanner below cannot mistake a flag's VALUE for a subcommand — `experiment
96
139
  // --cwd /tmp/x status` must find `status`, not `/tmp/x`.
97
- const VALUE_FLAGS = new Set(['--cwd', '--tc', '--rag', '--note']);
140
+ const VALUE_FLAGS = new Set(['--cwd', '--tc', '--rag', '--note', '--out']);
98
141
 
99
142
  /**
100
143
  * @returns {string|{error: string}} the value after `name`, or undefined if the
@@ -122,7 +165,7 @@ function positionals(args) {
122
165
  return out;
123
166
  }
124
167
 
125
- const EXPERIMENT_SUBCOMMANDS = ['status', 'set', 'restore'];
168
+ const EXPERIMENT_SUBCOMMANDS = ['status', 'set', 'restore', 'archive'];
126
169
 
127
170
  /**
128
171
  * `experiment` argument shape. `set` demands BOTH --tc and --rag: an arm stated
@@ -133,6 +176,14 @@ function parseExperiment(rest, cwd) {
133
176
  const [sub] = positionals(rest);
134
177
  if (sub === undefined) return { mode: 'error', message: `Missing experiment subcommand: ${EXPERIMENT_SUBCOMMANDS.join(', ')}` };
135
178
  if (!EXPERIMENT_SUBCOMMANDS.includes(sub)) return { mode: 'error', message: `Unknown experiment subcommand: ${sub}` };
179
+ if (sub === 'archive') {
180
+ // Required, never defaulted: where whole conversations get copied to is
181
+ // the one choice this command must not make on the operator's behalf.
182
+ const out = flagValue(rest, '--out');
183
+ if (out?.error) return { mode: 'error', message: out.error };
184
+ if (out === undefined) return { mode: 'error', message: 'experiment archive requires --out <dir>' };
185
+ return { mode: 'experiment', sub, cwd, out: resolve(out) };
186
+ }
136
187
  if (sub !== 'set') return { mode: 'experiment', sub, cwd };
137
188
 
138
189
  const values = {};
@@ -148,12 +199,37 @@ function parseExperiment(rest, cwd) {
148
199
  return { mode: 'experiment', sub, cwd, tc: values['--tc'], rag: values['--rag'], note: values['--note'] ?? '' };
149
200
  }
150
201
 
202
+ /**
203
+ * `eval` argument shape: the step, and everything after it VERBATIM. The step's
204
+ * own module parses the rest with its own parser — the one `node eval/<step>.mjs`
205
+ * uses — so the two doors cannot disagree about a flag. A second parser here
206
+ * would be a second implementation of every step's contract.
207
+ */
208
+ function parseEval(rest) {
209
+ const [step, ...args] = rest;
210
+ if (step === '--help' || step === '-h') return { mode: 'eval-help' };
211
+ // Missing step is an error, not a listing: the TASK-078 rule for a missing
212
+ // command, one level down. A flag in the step's slot means the same thing.
213
+ if (step === undefined || step.startsWith('-')) {
214
+ return { mode: 'error', message: `Missing eval step: ${Object.keys(EVAL_STEPS).join(', ')}`, usage: evalUsage };
215
+ }
216
+ // hasOwn, not `in`: `tune-context eval constructor` must not find a step on
217
+ // Object.prototype.
218
+ if (!Object.hasOwn(EVAL_STEPS, step)) return { mode: 'error', message: `Unknown eval step: ${step}`, usage: evalUsage };
219
+ return { mode: 'eval', step, args };
220
+ }
221
+
151
222
  // Pure so it is unit-testable without spawning a process. `init` is the ONLY
152
223
  // accepted command — no bare-args shortcut (PO decision 2026-07-25, TASK-078):
153
224
  // README never once documents calling this without it, and accepting anything
154
225
  // un-checked is exactly how a subcommand typo used to silently run init
155
226
  // against the wrong repo instead of failing loud.
156
227
  export function parseArgs(argv) {
228
+ // First, before the global flags below. After the word `eval` every flag
229
+ // belongs to the step: `tune-context eval prompt-metrics --help` must print
230
+ // that step's usage, not this CLI's, and a step's `--cwd` must not be eaten
231
+ // as ours (TASK-095).
232
+ if (argv[0] === 'eval') return parseEval(argv.slice(1));
157
233
  if (argv.includes('--help') || argv.includes('-h')) return { mode: 'help' };
158
234
  // Before the command check, like --help: `--version` is a query about the
159
235
  // tool, not an operation on a repo, so it must not require a subcommand.
@@ -168,7 +244,8 @@ export function parseArgs(argv) {
168
244
  if (!commands.includes(command)) {
169
245
  return {
170
246
  mode: 'error',
171
- message: command === undefined ? `Missing command: ${commands.join(', ')}` : `Unknown command: ${command}`,
247
+ // `eval` is named too: it is accepted, just recognised above, first.
248
+ message: command === undefined ? `Missing command: ${[...commands, 'eval'].join(', ')}` : `Unknown command: ${command}`,
172
249
  };
173
250
  }
174
251
 
@@ -459,10 +536,54 @@ function runExperimentRestore() {
459
536
  console.log(`\n${RESTART_NOTE}`);
460
537
  }
461
538
 
462
- function runExperiment(parsed) {
539
+ /** Shell-quoted only when it has to be, so a printed command can be pasted as is. */
540
+ function shq(path) {
541
+ return /^[\w@%+=:,./-]+$/.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`;
542
+ }
543
+
544
+ async function runExperimentArchive({ out }) {
545
+ const { claudeDir } = claudeDirs();
546
+ const r = await archiveExperiment({ claudeDir, out });
547
+ const t = r.transcripts;
548
+ console.log(`tune-context experiment archive — ${r.out}\n`);
549
+ console.log(`Transcripts on exp/ branches, from ${r.liveProjects}:`);
550
+ console.log(` ${t.new.length} new, ${t.extended.length} extended, ${t.unchanged.length} unchanged, ${t.kept.length} kept only here`);
551
+ for (const f of t.new) console.log(` + ${f.path} ${f.exp_branches.join(', ')}`);
552
+ for (const f of t.extended) console.log(` ~ ${f.path} — grew by ${f.bytes_added.toLocaleString('en-US')} byte(s), the old copy was a prefix of it`);
553
+ for (const path of t.kept) console.log(` · ${path} — gone from the config dir (retention); this is the only copy`);
554
+ if (r.naturalSkipped > 0) {
555
+ console.log(` not archived: ${r.naturalSkipped} transcript(s) with no exp/ branch — natural work is not evidence, and it is your whole history`);
556
+ }
557
+ if (t.new.length + t.extended.length + t.unchanged.length + t.kept.length === 0) {
558
+ console.log(`\n! No transcript on an exp/ branch in ${r.liveProjects}. Is CLAUDE_CONFIG_DIR the one the measured sessions ran under?`);
559
+ }
560
+
561
+ // Every archive path comes from the archive: its layout is defined once, in cli/archive.mjs.
562
+ const p = r.paths;
563
+ console.log(
564
+ r.ledger
565
+ ? `\nLedger: ${r.ledger.kind}${r.ledger.kind === 'extended' ? ` (+${r.ledger.bytes_added.toLocaleString('en-US')} byte(s))` : ''}, ${r.ledger.entries} entr${r.ledger.entries === 1 ? 'y' : 'ies'} → ${p.ledger}`
566
+ : `\n! Ledger: none at ${r.ledgerPath}. Recomputing this archive gives exp_arm_source: branch, which the paired pool does not accept.`,
567
+ );
568
+ console.log(`Manifest: ${p.manifest}`);
569
+ console.log(`Checksums: ${p.sums} — check with: (cd ${shq(r.out)} && shasum -a 256 -c ${shq(basename(p.sums))})`);
570
+
571
+ console.log(`
572
+ ! A transcript is the whole conversation: contents of the files read, command output, possibly secrets.
573
+ Keep ${r.out} out of every repository — this command refuses a git work tree path that is not ignored.`);
574
+
575
+ const ledgerFlag = r.ledger ? ` --ledger ${shq(p.ledger)}` : '';
576
+ console.log(`
577
+ Recompute from the archive — works after Claude Code has deleted the originals:
578
+ tune-context eval prompt-metrics --dir ${shq(p.projects)}${ledgerFlag} > rows.jsonl
579
+ tune-context eval analyze --rows rows.jsonl${ledgerFlag} > aggregate.json`);
580
+ }
581
+
582
+ async function runExperiment(parsed) {
463
583
  try {
464
584
  if (parsed.sub === 'status') runExperimentStatus(parsed.cwd);
465
585
  else if (parsed.sub === 'set') runExperimentSet(parsed.cwd, parsed);
586
+ else if (parsed.sub === 'archive') await runExperimentArchive(parsed);
466
587
  else runExperimentRestore();
467
588
  } catch (error) {
468
589
  // Operator-facing conditions (arm already set, nothing to restore, a file
@@ -474,9 +595,31 @@ function runExperiment(parsed) {
474
595
  }
475
596
  }
476
597
 
477
- export function main() {
598
+ /**
599
+ * Hand the step its arguments, untouched. The module is imported only now, so
600
+ * `init`, `verify` and `experiment` never pay for parsing ~200 KB of eval/.
601
+ *
602
+ * Allowed in any experiment arm, unlike init: a step reads transcripts, rows or
603
+ * logs and writes only to stdout or a file it was given — never the config
604
+ * that an arm parks.
605
+ *
606
+ * The exit code is the step's (`process.exitCode`). An exception that escapes
607
+ * the step's own main() is a bug and goes up as one, the same rule as
608
+ * runExperiment: operator-facing conditions are already answered inside the
609
+ * step, with a message and exit 1.
610
+ */
611
+ async function runEval({ step, args }) {
612
+ const { main: stepMain } = await import(new URL(`../eval/${step}.mjs`, import.meta.url));
613
+ await stepMain(args);
614
+ }
615
+
616
+ export async function main() {
478
617
  const parsed = parseArgs(process.argv.slice(2));
479
- if (parsed.mode === 'help') {
618
+ if (parsed.mode === 'eval') {
619
+ await runEval(parsed);
620
+ } else if (parsed.mode === 'eval-help') {
621
+ process.stdout.write(evalUsage());
622
+ } else if (parsed.mode === 'help') {
480
623
  console.log(usage());
481
624
  } else if (parsed.mode === 'version') {
482
625
  // Bare number, no prefix or banner — this is what a script greps.
@@ -484,49 +627,22 @@ export function main() {
484
627
  } else if (parsed.mode === 'error') {
485
628
  // Usage on stderr + exit 1, never a stacktrace, and — because this whole
486
629
  // branch returns before runInit() is ever called — no config file is
487
- // touched on an invalid invocation (TASK-078).
488
- process.stderr.write(`${parsed.message}\n\n${usage()}`);
630
+ // touched on an invalid invocation (TASK-078). An `eval` mistake gets the
631
+ // list of steps rather than the whole CLI's usage.
632
+ process.stderr.write(`${parsed.message}\n\n${(parsed.usage ?? usage)()}`);
489
633
  process.exitCode = 1;
490
634
  } else if (parsed.mode === 'verify') {
491
635
  runVerify(parsed.cwd);
492
636
  } else if (parsed.mode === 'experiment') {
493
- runExperiment(parsed);
637
+ await runExperiment(parsed);
494
638
  } else {
495
639
  runInit(parsed.cwd, { forceClaudeMd: parsed.forceClaudeMd });
496
640
  }
497
641
  }
498
642
 
499
- /**
500
- * True only when THIS file is the process entry point, so importing it for
501
- * parseArgs/main (tests, or the extensionless bin/tune-context wrapper) never
502
- * runs the CLI as a side effect of the import.
503
- *
504
- * Deliberately NOT a filename-suffix test on argv[1]. npm installs the
505
- * package.json `bin` entry as a symlink named `tune-context` — no extension —
506
- * so `argv[1].endsWith('tune-context.mjs')` was false for every real npm-channel
507
- * invocation: main() never ran and the CLI exited 0 in total silence. That
508
- * shipped in 2.0.0 (see the symlink test in test/bin-tune-context.test.mjs);
509
- * the whole suite missed it because tests and smoke:init both spawn the full
510
- * `.../bin/tune-context.mjs` path, which is the one shape a user never uses.
511
- *
512
- * Comparing resolved real paths holds for all four shapes: direct `.mjs`
513
- * invocation, the npm symlink (resolves THROUGH the link to this file), the
514
- * extensionless wrapper (a different real path, so this stays false and the
515
- * wrapper's own main() call is the single entry — no double run), and `import`
516
- * from a test (argv[1] is the test runner). realpathSync on both sides also
517
- * survives a symlinked package dir, as `npm link` and nvm both produce.
518
- */
519
- function isEntryPoint() {
520
- if (!process.argv[1]) return false;
521
- try {
522
- return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
523
- } catch {
524
- // argv[1] can name something unresolvable (deleted, or a bare `-`); a CLI
525
- // that cannot identify its own entry point must stay silent, not crash.
526
- return false;
527
- }
528
- }
529
-
530
- if (isEntryPoint()) {
531
- main();
643
+ // Run only when this file IS the entry point, never as a side effect of an
644
+ // import (tests, the extensionless wrapper). Why that is a realpath comparison
645
+ // and not a filename test — the 2.0.0 silent CLI — is in cli/entry-point.mjs.
646
+ if (isEntryPoint(import.meta.url)) {
647
+ await main();
532
648
  }
@@ -0,0 +1,322 @@
1
+ // tune-context experiment archive — the series' transcripts and ledger, kept
2
+ // past Claude Code's retention (TASK-098, EPIC-010 PO decision 5).
3
+ //
4
+ // A required meter stamp (TASK-097) closes the door on old ROWS, so the only way
5
+ // back to old data is old TRANSCRIPTS recomputed by a meter one trusts. Those
6
+ // live in <config>/projects and Claude Code deletes them after
7
+ // `cleanupPeriodDays` (30 by default). EPIC-009 found its meter bug after that
8
+ // window: the voided verdict could never be recomputed. Transcripts alone are
9
+ // not enough either — a row's arm comes from the branch AND the ledger
10
+ // (`branch+ledger`), so the ledger travels with them.
11
+ //
12
+ // Four rules, and they are the design:
13
+ //
14
+ // 1. **Append-only.** A file identical to the live one is skipped; a live file
15
+ // that EXTENDS the archived one byte for byte (a resumed session, a ledger
16
+ // that grew) replaces it; any other difference refuses the whole run and
17
+ // leaves the archive untouched — the discipline `experiment restore` keeps.
18
+ // 2. **What retention deleted stays.** A file gone from the config dir is kept
19
+ // and still listed: that is what the archive is for.
20
+ // 3. **Experiment sessions only.** A transcript qualifies by one entry on a
21
+ // branch claiming the convention (`exp/…`), deliberately broader than
22
+ // arm.mjs's strict parser — an off-convention branch is archived too, so a
23
+ // recount rejects it by name instead of losing it. Natural work is not
24
+ // archived: it is not evidence, and an archive of it would be an archive of
25
+ // the user's whole Claude Code history, a different privacy class.
26
+ // 4. **Never where git would publish it.** A transcript is the whole
27
+ // conversation — file contents, command output, possibly secrets — and
28
+ // this repo is public. A destination inside a git work tree that git does
29
+ // not ignore is refused, with no flag to override it.
30
+ import { createHash } from 'node:crypto';
31
+ import { execFileSync, spawnSync } from 'node:child_process';
32
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
33
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
34
+ import { ExperimentError, experimentPaths, readLedgerFile } from './experiment.mjs';
35
+ import { gitEnv, meterStamp } from './version.mjs';
36
+
37
+ // The two files that describe the archive, next to the data they describe.
38
+ const MANIFEST = 'manifest.json';
39
+ const SUMS = 'SHA256SUMS';
40
+
41
+ /** A branch name that claims the experiment convention, valid or not. */
42
+ function claimsExperiment(branch) {
43
+ if (typeof branch !== 'string') return false;
44
+ const b = branch.trim();
45
+ return b === 'exp' || b.startsWith('exp/');
46
+ }
47
+
48
+ /** What a transcript says about itself: the exp/ branches and the Claude Code
49
+ * versions on its entries. Every line is parsed — a quick substring filter
50
+ * would skip a file that wrote its JSON differently, and silently. */
51
+ function describeTranscript(buffer, parseEntry) {
52
+ const branches = new Set();
53
+ const versions = new Set();
54
+ for (const line of buffer.toString('utf8').split('\n')) {
55
+ const entry = parseEntry(line);
56
+ if (entry === null) continue;
57
+ if (claimsExperiment(entry.gitBranch)) branches.add(entry.gitBranch);
58
+ if (typeof entry.version === 'string') versions.add(entry.version);
59
+ }
60
+ return { exp_branches: [...branches].sort(), cc_versions: [...versions].sort() };
61
+ }
62
+
63
+ const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
64
+
65
+ /**
66
+ * How the live bytes relate to the archived ones: the same, an append, or a
67
+ * change — and for a change, the byte and line where the two first part.
68
+ */
69
+ function compareBytes(archived, live) {
70
+ if (archived.equals(live)) return { kind: 'unchanged' };
71
+ if (live.length > archived.length && live.subarray(0, archived.length).equals(archived)) {
72
+ return { kind: 'extended', bytesAdded: live.length - archived.length };
73
+ }
74
+ const n = Math.min(archived.length, live.length);
75
+ let at = 0;
76
+ while (at < n && archived[at] === live[at]) at += 1;
77
+ let line = 1;
78
+ for (let i = 0; i < at; i += 1) if (archived[i] === 0x0a) line += 1;
79
+ return { kind: 'changed', byte: at, line };
80
+ }
81
+
82
+ /** The nearest part of `path` that exists: the path itself, or a parent. */
83
+ function nearestExisting(path) {
84
+ let probe = path;
85
+ while (!existsSync(probe)) probe = dirname(probe);
86
+ return probe;
87
+ }
88
+
89
+ /**
90
+ * `path` resolved through every symlink on the part of it that exists — it may
91
+ * not exist yet. Every guard below compares these: two names for one directory
92
+ * (a symlinked config dir, macOS's /var → /private/var) must not pass for two.
93
+ */
94
+ function realPath(path) {
95
+ const probe = nearestExisting(path);
96
+ return join(realpathSync(probe), relative(probe, path));
97
+ }
98
+
99
+ /** A directory with a `.git` somewhere above it — read off disk, for when git itself is missing. */
100
+ function insideGitTreeOnDisk(dir) {
101
+ for (let d = dir; ; d = dirname(d)) {
102
+ if (existsSync(join(d, '.git'))) return true;
103
+ if (dirname(d) === d) return false;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Rule 4. `destinations` are every path the archive will hold, relative to
109
+ * `out` — all of them checked, not the directory: git matches `archive/` only
110
+ * against files inside a directory that does not exist yet, and `*.json` would
111
+ * ignore the manifest while publishing every transcript.
112
+ */
113
+ function refuseIfPublishable(out, destinations) {
114
+ const realOut = realPath(out);
115
+ const realProbe = nearestExisting(realOut);
116
+
117
+ const env = gitEnv();
118
+ let topDir;
119
+ try {
120
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: realProbe, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
121
+ topDir = realpathSync(top.trim());
122
+ } catch (e) {
123
+ if (e.code !== 'ENOENT') return; // not a work tree: nothing here to publish it
124
+ if (!insideGitTreeOnDisk(realProbe)) return;
125
+ throw new ExperimentError(
126
+ `refusing to archive into ${out}: it is inside a git work tree, and without git there is no telling whether it is ignored`,
127
+ 'transcripts are whole conversations — choose a directory outside any repository',
128
+ );
129
+ }
130
+
131
+ const rels = destinations.map((d) => relative(topDir, join(realOut, d)));
132
+ // NUL-separated both ways: without -z git C-quotes a path that is not plain
133
+ // ASCII ("archiwum-\305\274…"), it never equals the name asked about, and an
134
+ // ignored directory reads as exposed. --stdin keeps a long series off argv.
135
+ const check = spawnSync('git', ['check-ignore', '--stdin', '-z'], {
136
+ cwd: topDir,
137
+ env,
138
+ input: rels.map((r) => `${r}\0`).join(''),
139
+ encoding: 'utf8',
140
+ });
141
+ // 0: some ignored, 1: none ignored; anything else is git failing to answer,
142
+ // and an unanswered question is not a yes.
143
+ const ignored = check.status === 0 || check.status === 1 ? new Set(check.stdout.split('\0').filter(Boolean)) : new Set();
144
+ const exposed = rels.filter((r) => !ignored.has(r));
145
+ if (exposed.length === 0) return;
146
+ throw new ExperimentError(
147
+ `refusing to archive into ${out}: it is inside the git work tree ${topDir} and git does not ignore` +
148
+ ` ${exposed.length} of the ${rels.length} file(s) it would hold (first: ${exposed[0]})`,
149
+ 'A transcript is the whole conversation: file contents, command output, possibly secrets. Add the directory to' +
150
+ ' .gitignore, or archive outside any repository. There is no flag to override this.',
151
+ );
152
+ }
153
+
154
+ function writeAtomically(path, data) {
155
+ mkdirSync(dirname(path), { recursive: true });
156
+ const tmp = `${path}.tmp-${process.pid}`;
157
+ writeFileSync(tmp, data);
158
+ renameSync(tmp, path);
159
+ }
160
+
161
+ const within = (parent, child) => {
162
+ const rel = relative(parent, child);
163
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
164
+ };
165
+
166
+ /**
167
+ * Archive every experiment transcript and the ledger from `claudeDir` into
168
+ * `out`, in the config dir's own layout — `projects/<slug>/<session>.jsonl`,
169
+ * `tune-context/experiment.jsonl` — plus `manifest.json` and `SHA256SUMS`
170
+ * (checkable with `shasum -a 256 -c`, no code of ours needed).
171
+ *
172
+ * Reads everything first, decides, checks, and only then writes: a refusal
173
+ * leaves the archive exactly as it was. Writes nothing in the config dir, the
174
+ * ledger or the switch state, so it runs in any arm.
175
+ *
176
+ * @returns {Promise<object>} what happened to each file, the manifest, and
177
+ * `paths` — where the archive keeps what, so no caller re-derives its layout.
178
+ * @throws {ExperimentError} a destination git would publish, an archived file
179
+ * the live one changed other than by appending, or `out` in a place that
180
+ * would feed the archive back into the next measurement.
181
+ */
182
+ export async function archiveExperiment({ claudeDir, out, now = new Date() }) {
183
+ // The transcript reader is prompt-metrics' own — one definition of where
184
+ // transcripts are and how a line parses. Loaded here, not at the top, so the
185
+ // rest of the CLI never pays for eval/.
186
+ const { findTranscripts, parseEntry } = await import('../eval/prompt-metrics.mjs');
187
+ const outDir = resolve(out);
188
+ const liveProjects = join(claudeDir, 'projects');
189
+ const archProjects = join(outDir, 'projects');
190
+ // The archive keeps the config dir's own layout, so the ledger sits where
191
+ // experimentPaths() puts it — one definition of that place.
192
+ const liveLedger = experimentPaths(claudeDir).ledgerPath;
193
+ const ledgerRel = relative(claudeDir, liveLedger);
194
+ const archLedger = join(outDir, ledgerRel);
195
+
196
+ // Inside <config>/projects the archive would be read back by the next
197
+ // prompt-metrics run as live transcripts, every session counted twice; the
198
+ // config dir itself would make the archive the live ledger's own copy.
199
+ // Compared by real path: a symlinked config dir is still that directory.
200
+ const realOut = realPath(outDir);
201
+ if (realOut === realPath(claudeDir) || within(realPath(liveProjects), realOut)) {
202
+ throw new ExperimentError(
203
+ `refusing to archive into ${outDir}: inside the config dir's projects/ the archive would be measured again as live transcripts`,
204
+ 'choose a directory outside the config dir, or anywhere in it except projects/',
205
+ );
206
+ }
207
+ if (existsSync(outDir) && !statSync(outDir).isDirectory()) {
208
+ throw new ExperimentError(`refusing to archive into ${outDir}: it is a file, not a directory`);
209
+ }
210
+
211
+ // 1. Read the live side.
212
+ const live = new Map();
213
+ let natural = 0;
214
+ for (const path of findTranscripts(liveProjects)) {
215
+ const buffer = readFileSync(path);
216
+ const info = describeTranscript(buffer, parseEntry);
217
+ if (info.exp_branches.length === 0) {
218
+ natural += 1;
219
+ continue;
220
+ }
221
+ live.set(relative(liveProjects, path), { buffer, info });
222
+ }
223
+
224
+ // 2. Decide against what the archive already holds. Nothing is written yet.
225
+ // One rule for every file, transcript or ledger: new, unchanged, extended —
226
+ // or a conflict, which refuses the run.
227
+ const conflicts = [];
228
+ const decide = (path, bytes, extra = {}) => {
229
+ const dest = join(outDir, path);
230
+ const cmp = existsSync(dest) ? compareBytes(readFileSync(dest), bytes) : { kind: 'new' };
231
+ if (cmp.kind === 'changed') {
232
+ conflicts.push({ path, ...cmp });
233
+ return null;
234
+ }
235
+ return { path, kind: cmp.kind, bytesAdded: cmp.bytesAdded ?? 0, buffer: bytes, ...extra };
236
+ };
237
+ const kept = (path) => ({ path, kind: 'kept', bytesAdded: 0, buffer: readFileSync(join(outDir, path)) });
238
+
239
+ const files = [];
240
+ for (const [rel, { buffer, info }] of live) {
241
+ const file = decide(`projects/${rel}`, buffer, { info });
242
+ if (file) files.push(file);
243
+ }
244
+ for (const dest of findTranscripts(archProjects)) {
245
+ const rel = relative(archProjects, dest);
246
+ if (live.has(rel)) continue;
247
+ const file = kept(`projects/${rel}`);
248
+ files.push({ ...file, info: describeTranscript(file.buffer, parseEntry) });
249
+ }
250
+ let ledger = null;
251
+ if (existsSync(liveLedger)) ledger = decide(ledgerRel, readFileSync(liveLedger));
252
+ else if (existsSync(archLedger)) ledger = kept(ledgerRel);
253
+
254
+ // 3. Check, still before any write.
255
+ const destinations = [...files.map((f) => f.path), ...conflicts.map((c) => c.path), MANIFEST, SUMS];
256
+ if (ledger || existsSync(liveLedger)) destinations.push(ledgerRel);
257
+ refuseIfPublishable(outDir, [...new Set(destinations)]);
258
+
259
+ if (conflicts.length > 0) {
260
+ throw new ExperimentError(
261
+ `refusing to archive: ${conflicts.length} file(s) changed other than by appending — nothing was written\n` +
262
+ conflicts.map((c) => ` ${c.path} — first difference at byte ${c.byte} (line ${c.line})`).join('\n'),
263
+ 'A transcript or the ledger only ever grows. A file changed in the middle is a different file under the same' +
264
+ ' name or a damaged copy — compare the two by hand, or archive into a new directory. The archive is as it was.',
265
+ );
266
+ }
267
+
268
+ // 4. Write: data first, then the two files that describe it.
269
+ for (const f of ledger ? [...files, ledger] : files) {
270
+ if (f.kind === 'new' || f.kind === 'extended') writeAtomically(join(outDir, f.path), f.buffer);
271
+ }
272
+
273
+ // By code unit, as `LC_ALL=C sort` would: the same order on every machine,
274
+ // whatever its collation.
275
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
276
+ const transcripts = files
277
+ .map((f) => ({
278
+ path: f.path,
279
+ sha256: sha256(f.buffer),
280
+ bytes: f.buffer.length,
281
+ exp_branches: f.info.exp_branches,
282
+ cc_versions: f.info.cc_versions,
283
+ // false: retention removed it from the config dir, and this is the only copy.
284
+ in_config_dir: f.kind !== 'kept',
285
+ }))
286
+ .sort(byPath);
287
+ const ledgerEntry = ledger && {
288
+ path: ledgerRel,
289
+ sha256: sha256(ledger.buffer),
290
+ bytes: ledger.buffer.length,
291
+ entries: readLedgerFile(archLedger).length,
292
+ in_config_dir: ledger.kind !== 'kept',
293
+ };
294
+ const manifest = {
295
+ schema: 'tune-context/archive@1',
296
+ archived_at: now.toISOString(),
297
+ // Which code wrote the archive, in the meter's own grammar (TASK-097).
298
+ archived_by: meterStamp(),
299
+ transcripts,
300
+ ledger: ledgerEntry,
301
+ };
302
+ writeAtomically(join(outDir, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
303
+ const sums = [...transcripts, ...(ledgerEntry ? [ledgerEntry] : [])].sort(byPath);
304
+ writeAtomically(join(outDir, SUMS), sums.map((f) => `${f.sha256} ${f.path}\n`).join(''));
305
+
306
+ const of = (kind) => files.filter((f) => f.kind === kind);
307
+ return {
308
+ out: outDir,
309
+ paths: { projects: archProjects, ledger: archLedger, manifest: join(outDir, MANIFEST), sums: join(outDir, SUMS) },
310
+ liveProjects,
311
+ transcripts: {
312
+ new: of('new').map((f) => ({ path: f.path, exp_branches: f.info.exp_branches })),
313
+ extended: of('extended').map((f) => ({ path: f.path, bytes_added: f.bytesAdded })),
314
+ unchanged: of('unchanged').map((f) => f.path),
315
+ kept: of('kept').map((f) => f.path),
316
+ },
317
+ naturalSkipped: natural,
318
+ ledger: ledger ? { kind: ledger.kind, entries: ledgerEntry.entries, bytes_added: ledger.bytesAdded ?? 0 } : null,
319
+ ledgerPath: liveLedger,
320
+ manifest,
321
+ };
322
+ }