@bglocation/tune-context 2.5.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +138 -22
- package/bin/tune-context +1 -1
- package/bin/tune-context.mjs +167 -51
- package/cli/archive.mjs +322 -0
- package/cli/entry-point.mjs +73 -0
- package/cli/experiment.mjs +33 -1
- package/cli/version.mjs +118 -0
- package/eval/adoption-report.mjs +11 -5
- package/eval/analyze.mjs +141 -38
- package/eval/prompt-metrics.mjs +39 -18
- package/eval/report.mjs +56 -45
- package/eval/verdict.mjs +357 -33
- package/package.json +1 -1
- package/skills/token-efficiency/SKILL.md +1 -1
- package/skills/tune-context/SKILL.md +4 -2
package/bin/tune-context.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
}
|
package/cli/archive.mjs
ADDED
|
@@ -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
|
+
}
|