@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.
@@ -0,0 +1,73 @@
1
+ // What every runnable module needs at its entry, answered once — shared by bin/
2
+ // and by every runnable module in eval/ (TASK-095): "was this file run, or
3
+ // imported?", how a step ends when its reader leaves early, and reading stdin.
4
+ //
5
+ // One implementation on purpose. eval/ used to answer it with
6
+ // `process.argv[1]?.endsWith('<name>.mjs')`, the exact test that shipped 2.0.0
7
+ // with a silent CLI. Harmless there only for as long as nobody called those
8
+ // scripts through a symlink — and `tune-context eval` exists precisely so that
9
+ // people stop calling them by path.
10
+ import { realpathSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ /**
14
+ * True only when the module at `moduleUrl` (pass `import.meta.url`) is the
15
+ * process entry point, so importing it for its functions (tests, the
16
+ * extensionless bin/tune-context wrapper, the `tune-context eval` dispatcher)
17
+ * never runs its CLI as a side effect of the import.
18
+ *
19
+ * Deliberately NOT a filename-suffix test on argv[1]. npm installs the
20
+ * package.json `bin` entry as a symlink named `tune-context` — no extension —
21
+ * so `argv[1].endsWith('tune-context.mjs')` was false for every real npm-channel
22
+ * invocation: main() never ran and the CLI exited 0 in total silence. That
23
+ * shipped in 2.0.0 (see the symlink test in test/bin-tune-context.test.mjs);
24
+ * the whole suite missed it because tests and smoke:init both spawned the full
25
+ * `.../bin/tune-context.mjs` path, which is the one shape a user never uses.
26
+ *
27
+ * Comparing resolved real paths holds for all four shapes: direct `.mjs`
28
+ * invocation, the npm symlink (resolves THROUGH the link to the file), the
29
+ * extensionless wrapper or the dispatcher (a different real path, so this stays
30
+ * false and the caller's own main() call is the single entry — no double run),
31
+ * and `import` from a test (argv[1] is the test runner). realpathSync on both
32
+ * sides also survives a symlinked package dir, as `npm link` and nvm both
33
+ * produce.
34
+ */
35
+ export function isEntryPoint(moduleUrl) {
36
+ if (!process.argv[1]) return false;
37
+ try {
38
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(moduleUrl));
39
+ } catch {
40
+ // argv[1] can name something unresolvable (deleted, or a bare `-`); a CLI
41
+ // that cannot identify its own entry point must stay silent, not crash.
42
+ return false;
43
+ }
44
+ }
45
+
46
+ let pipeGuarded = false;
47
+
48
+ /**
49
+ * A reader that leaves early — `| head`, a pager quit after one screen — is a
50
+ * normal end for a step writing to stdout, not a crash. Without a listener the
51
+ * EPIPE becomes an uncaught 'error' and a stacktrace once the output outgrows
52
+ * the pipe buffer (64 KB): a long report page, an aggregate of many tasks. Any
53
+ * other stdout error still goes up.
54
+ *
55
+ * Every eval step's main() calls this first, so both doors end the same way —
56
+ * one guard instead of a copy in the steps somebody happened to pipe (TASK-094
57
+ * covered prompt-metrics; report and analyze had none). Idempotent: one
58
+ * listener per process, however many mains run in it.
59
+ */
60
+ export function endQuietlyOnClosedPipe() {
61
+ if (pipeGuarded) return;
62
+ pipeGuarded = true;
63
+ process.stdout.on('error', (e) => {
64
+ if (e.code !== 'EPIPE') throw e;
65
+ });
66
+ }
67
+
68
+ /** All of stdin as text — for a step reading its input from a pipe. */
69
+ export async function readStdin() {
70
+ const chunks = [];
71
+ for await (const chunk of process.stdin) chunks.push(chunk);
72
+ return Buffer.concat(chunks).toString('utf8');
73
+ }
@@ -430,7 +430,16 @@ export function appendLedger(claudeDir, entry) {
430
430
 
431
431
  /** Parsed ledger, oldest first. Unparseable lines are skipped, never guessed at. */
432
432
  export function readLedger(claudeDir) {
433
- const raw = readText(experimentPaths(claudeDir).ledgerPath);
433
+ return readLedgerFile(experimentPaths(claudeDir).ledgerPath);
434
+ }
435
+
436
+ /**
437
+ * The same reading for a ledger that is not the config dir's own — an
438
+ * archive's (TASK-098), handed to prompt-metrics/analyze with `--ledger`. One
439
+ * parser, so a ledger cannot mean one thing live and another in the archive.
440
+ */
441
+ export function readLedgerFile(path) {
442
+ const raw = readText(path);
434
443
  if (raw === null) return [];
435
444
  return raw
436
445
  .split('\n')
@@ -445,6 +454,29 @@ export function readLedger(claudeDir) {
445
454
  .filter(Boolean);
446
455
  }
447
456
 
457
+ /**
458
+ * The ledger an eval step assigns arms by — prompt-metrics and analyze, one
459
+ * rule for both: `--ledger <path>` when it was given, else the config dir's own.
460
+ *
461
+ * An explicit path must be a file. Read as no ledger (missing) or as an empty
462
+ * one (a directory), it would turn every experiment row into `branch` — the
463
+ * source the paired pool refuses — and nothing would say why (TASK-098).
464
+ *
465
+ * @returns {{ledger: object[], ledgerAvailable: boolean}} what annotateRows()
466
+ * and analyze() take.
467
+ * @throws {ExperimentError} an explicit path that is missing or not a file.
468
+ */
469
+ export function readStepLedger(explicitPath, claudeDir) {
470
+ if (explicitPath !== undefined) {
471
+ const stat = statSync(explicitPath, { throwIfNoEntry: false });
472
+ if (stat === undefined) throw new ExperimentError(`no ledger at ${explicitPath} (--ledger)`);
473
+ if (!stat.isFile()) throw new ExperimentError(`--ledger ${explicitPath} is not a file`);
474
+ }
475
+ const path = explicitPath ?? experimentPaths(claudeDir).ledgerPath;
476
+ const ledgerAvailable = existsSync(path);
477
+ return { ledger: ledgerAvailable ? readLedgerFile(path) : [], ledgerAvailable };
478
+ }
479
+
448
480
  /**
449
481
  * Switch to `{tc, rag}`, recording everything needed to switch back.
450
482
  *
@@ -0,0 +1,118 @@
1
+ // Which code is this? Two answers, for two readers (TASK-097).
2
+ //
3
+ // readVersion() the release number, for `tune-context --version` — a script
4
+ // greps it, and the plugin manifests are cross-checked against
5
+ // it (verify:plugin).
6
+ // meterStamp() the CODE that computed a measurement, stamped on every
7
+ // artifact of the eval chain: rows (`meter_version`), the
8
+ // aggregate (`analyzed_by`), the verdict (`judged_by`).
9
+ //
10
+ // They differ on purpose. EPIC-009's verdict was voided because its rows came
11
+ // from a meter with a bug (TASK-092, costs inflated 2.02x) and nothing in the
12
+ // frozen files said which code wrote them. A release number alone would not
13
+ // have said it either: under `npm link`, which the runbook prescribed for that
14
+ // series, the number changes only at release — a fix committed before the
15
+ // release carries the same number as the bug it fixes.
16
+ import { execFileSync } from 'node:child_process';
17
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
+
23
+ /**
24
+ * Paths whose state is the meter's state. Coarse on purpose: a change to a hook
25
+ * eval never imports also makes the stamp `.dirty`. A false "dirty" holds a
26
+ * verdict that could have been issued; a false "clean" issues one from code
27
+ * nobody can rebuild — the cheaper error is the first. epics/, tasks/ and
28
+ * docs/ do not touch the meter and do not dirty the stamp.
29
+ */
30
+ const METER_PATHS = ['eval', 'cli', 'hooks', 'bin', 'package.json'];
31
+
32
+ /**
33
+ * Version straight from the shipped package.json — one source of truth, the
34
+ * same file verify:plugin cross-checks against plugin.json/marketplace.json,
35
+ * so `--version` can never drift from what the manifests claim. Both channels
36
+ * ship it: npm always includes package.json in the tarball, and the plugin
37
+ * channel is a git clone of this repo (guarded in verify-plugin's REQUIRE).
38
+ */
39
+ export function readVersion(pkgRoot = PKG_ROOT) {
40
+ return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version;
41
+ }
42
+
43
+ /**
44
+ * The environment for a git that must answer about `cwd` and nothing else:
45
+ * GIT_* variables dropped. Inside a git hook they point at the hook's own
46
+ * repository (GIT_DIR, GIT_INDEX_FILE), and git would read that one instead.
47
+ */
48
+ export function gitEnv() {
49
+ return Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
50
+ }
51
+
52
+ function git(cwd, args) {
53
+ return execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
54
+ }
55
+
56
+ /**
57
+ * The stamp for the package at `pkgRoot`, computed now. Grammar (number
58
+ * illustrative):
59
+ *
60
+ * 2.6.0 pkgRoot is not the TOP of a git repo: an npm install,
61
+ * including a local node_modules inside someone else's
62
+ * repo. A registry version is immutable, so the number
63
+ * names the code, and `npx -p @bglocation/tune-context@2.6.0`
64
+ * rebuilds it on demand.
65
+ * 2.6.0+<sha> a checkout of this repo (`npm link`, the plugin's
66
+ * clone, `node eval/…`), clean in METER_PATHS. The first
67
+ * 12 characters of HEAD, always 12: a shorter prefix
68
+ * can turn ambiguous as the repo grows.
69
+ * 2.6.0+<sha>.dirty changes in METER_PATHS, untracked files included.
70
+ * 2.6.0+unknown the top of a repo, but git is missing or failed.
71
+ *
72
+ * "The top", compared by realpath: inside someone else's repo, their HEAD would
73
+ * pass for ours. No `.git` at pkgRoot settles it without spawning git — which
74
+ * is also how a missing git still tells an install from a checkout.
75
+ *
76
+ * Uncached, for tests that change a repository between calls. Everything else
77
+ * uses meterStamp().
78
+ */
79
+ export function readMeterStamp(pkgRoot = PKG_ROOT) {
80
+ const version = readVersion(pkgRoot);
81
+ if (!existsSync(join(pkgRoot, '.git'))) return version;
82
+ try {
83
+ if (realpathSync(git(pkgRoot, ['rev-parse', '--show-toplevel'])) !== realpathSync(pkgRoot)) return version;
84
+ const sha = git(pkgRoot, ['rev-parse', 'HEAD']).slice(0, 12);
85
+ const dirty = git(pkgRoot, ['status', '--porcelain', '--untracked-files=all', '--', ...METER_PATHS]) !== '';
86
+ return `${version}+${sha}${dirty ? '.dirty' : ''}`;
87
+ } catch {
88
+ return `${version}+unknown`;
89
+ }
90
+ }
91
+
92
+ const stamps = new Map();
93
+
94
+ /**
95
+ * The stamp, computed once per process (and per package root): every artifact
96
+ * one process writes carries the same stamp even if the tree changes while it
97
+ * runs — the code that is running did not change with it.
98
+ */
99
+ export function meterStamp(pkgRoot = PKG_ROOT) {
100
+ if (!stamps.has(pkgRoot)) stamps.set(pkgRoot, readMeterStamp(pkgRoot));
101
+ return stamps.get(pkgRoot);
102
+ }
103
+
104
+ const STAMP = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+(?:[0-9a-f]{12}(\.dirty)?|(unknown)))?$/;
105
+
106
+ /**
107
+ * Why a stamp does not name code anyone can rebuild — or null when it does.
108
+ * A message rather than a boolean, because the verdict has to say which of the
109
+ * three it was.
110
+ */
111
+ export function stampProblem(stamp) {
112
+ if (typeof stamp !== 'string' || stamp === '') return 'no stamp at all';
113
+ const m = STAMP.exec(stamp);
114
+ if (m === null) return 'not a meter stamp';
115
+ if (m[1]) return "computed from uncommitted changes in the meter's paths — that code exists nowhere else";
116
+ if (m[2]) return 'computed from a checkout git could not read — the commit is unknown';
117
+ return null;
118
+ }
@@ -19,6 +19,7 @@
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { parseArgs } from 'node:util';
22
+ import { endQuietlyOnClosedPipe, isEntryPoint } from '../cli/entry-point.mjs';
22
23
  import { claudeBaseDir } from '../hooks/config-dir.mjs';
23
24
  import { classifyAvailability, probeProjectMcp } from './mcp-probe.mjs';
24
25
 
@@ -76,7 +77,7 @@ Options:
76
77
  -h, --help Show this help
77
78
 
78
79
  The log is written by the adoption-log.mjs PostToolUse hook (installed by
79
- tune-context). Cost in dollars is NOT here — see eval/prompt-metrics.mjs.
80
+ tune-context). Cost in dollars is NOT here — see tune-context eval prompt-metrics.
80
81
  `.trim();
81
82
 
82
83
  export function parseLog(text) {
@@ -496,7 +497,7 @@ export function formatReport(a) {
496
497
  }
497
498
  L.push('');
498
499
  L.push('Cost (dollars) is not in the hook log. Two independent routes to it:');
499
- L.push(' eval/prompt-metrics.mjs — prices the transcript, works with hooks OFF too.');
500
+ L.push(' tune-context eval prompt-metrics — prices the transcript, works with hooks OFF too.');
500
501
  L.push(' OTEL: CLAUDE_CODE_ENABLE_TELEMETRY=1 + an OTLP collector →');
501
502
  L.push(' metric `claude_code.cost.usage` (direct) / `claude_code.token.usage`.');
502
503
  L.push(' caveman (output style) leaves no tool call — not measured here.');
@@ -504,11 +505,16 @@ export function formatReport(a) {
504
505
  return L.join('\n');
505
506
  }
506
507
 
507
- function main() {
508
+ /**
509
+ * The CLI. `args` is everything after the script name, or after
510
+ * `tune-context eval adoption-report` — one parser behind both doors (EPIC-010).
511
+ */
512
+ export function main(args = process.argv.slice(2)) {
513
+ endQuietlyOnClosedPipe();
508
514
  let logPath;
509
515
  try {
510
516
  const { values } = parseArgs({
511
- args: process.argv.slice(2),
517
+ args,
512
518
  options: {
513
519
  log: { type: 'string' },
514
520
  help: { type: 'boolean', short: 'h', default: false },
@@ -539,6 +545,6 @@ function main() {
539
545
  process.stdout.write(`${formatReport(aggregate(records))}\n`);
540
546
  }
541
547
 
542
- if (process.argv[1]?.endsWith('adoption-report.mjs')) {
548
+ if (isEntryPoint(import.meta.url)) {
543
549
  main();
544
550
  }
package/eval/analyze.mjs CHANGED
@@ -29,7 +29,9 @@
29
29
  import { existsSync, readFileSync } from 'node:fs';
30
30
  import { parseArgs } from 'node:util';
31
31
  import { claudeBaseDir } from '../hooks/config-dir.mjs';
32
- import { experimentPaths, readLedger } from '../cli/experiment.mjs';
32
+ import { endQuietlyOnClosedPipe, isEntryPoint, readStdin } from '../cli/entry-point.mjs';
33
+ import { readStepLedger } from '../cli/experiment.mjs';
34
+ import { meterStamp, stampProblem } from '../cli/version.mjs';
33
35
  import { ledgerArmAt, parseArmBranch, rowTimestamp } from './arm.mjs';
34
36
  import { TOKEN_CLASS_FIELDS, formatUsd, summarizeCost } from './pricing.mjs';
35
37
 
@@ -40,15 +42,25 @@ const USAGE = `
40
42
  analyze — per-arm aggregate from prompt-metrics rows (TASK-089).
41
43
 
42
44
  Usage:
43
- analyze [--rows <path>]...
45
+ analyze [--rows <path>]... [--ledger <path>]
44
46
 
45
47
  Options:
46
- --rows <path> A JSONL file written by prompt-metrics. Repeatable.
47
- (default: read from stdin)
48
- -h, --help Show this help
48
+ --rows <path> A JSONL file written by prompt-metrics. Repeatable.
49
+ (default: read from stdin)
50
+ --ledger <path> The experiment ledger natural work is attributed by
51
+ (default: <config dir>/tune-context/experiment.jsonl). For
52
+ recomputing an archive. A path that does not exist, or is
53
+ not a file, is an error.
54
+ -h, --help Show this help
49
55
 
50
56
  Writes the aggregate as JSON to stdout and a short summary to stderr. It does
51
- NOT rule on which arm is cheaper — that is TASK-090.
57
+ NOT rule on which arm is cheaper — that is tune-context eval verdict.
58
+
59
+ Every row must carry meter_version, the stamp prompt-metrics puts on it
60
+ (EPIC-010). One row without it refuses the WHOLE input: exit 1, nothing on
61
+ stdout. Rows from before that contract are not used — recompute them from the
62
+ transcripts or from an archive. Rows from different meters are aggregated, and
63
+ the verdict holds on its meter_mixed gate.
52
64
  `.trim();
53
65
 
54
66
  // ─── numbers ────────────────────────────────────────────────────────────────
@@ -459,6 +471,9 @@ function pairedPool(rows) {
459
471
  return {
460
472
  pool: 'paired',
461
473
  source: 'branch exp/<task>/<arm> (TASK-088), reconciled against the TASK-087 ledger',
474
+ // What the verdict's meter_mixed gate reads: the stamps of the rows the
475
+ // verdict is built on, and no others.
476
+ meter_versions: meterVersions(rows),
462
477
  arms: armsOf(rows, pairsPerArm),
463
478
  pairs,
464
479
  n_pairs_complete: complete.length,
@@ -477,14 +492,37 @@ function naturalPool(rows) {
477
492
  return {
478
493
  pool: 'natural',
479
494
  source: 'TASK-087 ledger, by timestamp',
495
+ meter_versions: meterVersions(rows),
480
496
  arms: armsOf(rows),
481
497
  };
482
498
  }
483
499
 
500
+ /**
501
+ * Rows per meter stamp, `{ '<stamp>': n }`, keys sorted so two runs over the
502
+ * same rows serialize to the same bytes — by code unit, the order the verdict's
503
+ * meter_mixed gate lists the same stamps in. localeCompare would tie the bytes
504
+ * to the machine's collation and put `-rc.1` where the gate puts `+<sha>`.
505
+ */
506
+ function meterVersions(rows) {
507
+ const counts = new Map();
508
+ for (const row of rows) counts.set(row.meter_version, (counts.get(row.meter_version) ?? 0) + 1);
509
+ return Object.fromEntries([...counts.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
510
+ }
511
+
512
+ /** True for a row that says which meter computed it (TASK-097). */
513
+ export function isStamped(row) {
514
+ return typeof row?.meter_version === 'string' && row.meter_version !== '';
515
+ }
516
+
484
517
  /**
485
518
  * Rows in, aggregate out. Pure: no disk, no clock, no ambient state.
486
519
  *
487
- * @param rows prompt-metrics rows that have been through annotateRows().
520
+ * @param rows prompt-metrics rows that have been through annotateRows(), each
521
+ * carrying `meter_version`. A row without one is input from before the
522
+ * measurement contract (EPIC-010, PO decision 4) — not a measurement by
523
+ * another meter — and this throws rather than aggregate it, the same
524
+ * layer-boundary rule classifyRow() applies to an unannotated row. The
525
+ * CLI checks first, so an operator sees file and line, not this.
488
526
  * @param ledger / ledgerAvailable the TASK-087 ledger, for natural work only.
489
527
  * Paired rows carry their arm already; ordinary work has no marker but
490
528
  * this one.
@@ -492,8 +530,19 @@ function naturalPool(rows) {
492
530
  * in no row at all. It is not derivable from rows — that is the point of
493
531
  * it — so it is passed through, and stays **null** when the caller did
494
532
  * not supply it. Null means "not reported", never zero.
533
+ * @param analyzedBy the stamp of the code running this function, from the
534
+ * caller (no ambient state here, like report's `generatedAt`). Null when
535
+ * not given, and the verdict's meter_mixed gate holds on a step that left
536
+ * no stamp.
495
537
  */
496
- export function analyze(rows, { ledger = [], ledgerAvailable = false, unattributed = null } = {}) {
538
+ export function analyze(rows, { ledger = [], ledgerAvailable = false, unattributed = null, analyzedBy = null } = {}) {
539
+ const unstamped = rows.filter((row) => !isStamped(row)).length;
540
+ if (unstamped > 0) {
541
+ throw new TypeError(
542
+ `analyze: ${unstamped} of ${rows.length} row(s) carry no meter_version — rows from before EPIC-010 are not used;` +
543
+ ' recompute them from the transcripts or from an archive with prompt-metrics',
544
+ );
545
+ }
497
546
  const classified = rows.map((row) => ({ row, verdict: classifyRow(row, { ledger, ledgerAvailable }) }));
498
547
  const mixed = mixedArmSessions(classified);
499
548
 
@@ -534,7 +583,13 @@ export function analyze(rows, { ledger = [], ledgerAvailable = false, unattribut
534
583
 
535
584
  const used = pools.paired.length + pools.natural.length;
536
585
  return {
537
- schema: 'tune-context/analyze@1',
586
+ // @2 since EPIC-010: the stamps below are required, and verdict and report
587
+ // refuse an @1 aggregate rather than judge numbers nobody can trace.
588
+ schema: 'tune-context/analyze@2',
589
+ analyzed_by: analyzedBy,
590
+ // Every row that came in, by the meter that computed it — rejected rows
591
+ // included, because they were input too. The pools carry their own.
592
+ meter_versions: meterVersions(rows),
538
593
  // No cost field at this level, on purpose. There is nothing here to add the
539
594
  // pools into, and no array of pools to fold over — see the file header.
540
595
  paired: pairedPool(pools.paired),
@@ -580,6 +635,23 @@ function armLine(a) {
580
635
  */
581
636
  export function formatAnalysis(agg) {
582
637
  const L = [`analyze: ${agg.counters.rows_in} row(s) in, ${agg.counters.rows_used} used, ${agg.counters.rows_rejected} rejected`];
638
+ // Named, never just counted: different stamps do not stop the aggregate
639
+ // (PO decision 2), and this line is where an operator sees them before the
640
+ // verdict holds on them.
641
+ const stamps = Object.entries(agg.meter_versions);
642
+ // What it predicts, it predicts from what the meter_mixed gate reads, as far
643
+ // as analyze can know it: the PAIRED rows' stamps and its own (the verdict's
644
+ // comes later). Natural work never reaches the verdict — a stranger there is
645
+ // named on this line and holds nothing. A missing analyze stamp is its own
646
+ // problem, not a second meter.
647
+ const chain = [...new Set([...Object.keys(agg.paired.meter_versions ?? {}), agg.analyzed_by].filter(Boolean))];
648
+ const unnamed = chain.filter((stamp) => stampProblem(stamp) !== null);
649
+ L.push(
650
+ ` meter: rows ${stamps.length === 0 ? '—' : stamps.map(([stamp, n]) => `${stamp} ×${n}`).join(', ')}; analyzed by ${agg.analyzed_by ?? 'NO STAMP'}` +
651
+ (chain.length > 1 ? ' — MORE THAN ONE METER: the verdict holds on meter_mixed' : '') +
652
+ (unnamed.length > 0 ? ` — ${unnamed.join(', ')} names no code: the verdict holds on meter_mixed` : '') +
653
+ (agg.analyzed_by ? '' : ' — the verdict holds on meter_mixed until analyze is run through its CLI'),
654
+ );
583
655
  L.push(' paired measurement (TASK-088 — the only pool a verdict may be built on):');
584
656
  for (const arm of ARMS) L.push(armLine(agg.paired.arms[arm]));
585
657
  for (const pair of agg.paired.pairs) {
@@ -607,44 +679,49 @@ export function formatAnalysis(agg) {
607
679
  } else {
608
680
  L.push(` unattributed transcript entries: ${agg.counters.unattributed.entries ?? agg.counters.unattributed}`);
609
681
  }
610
- L.push(' this is an aggregate, NOT a verdict — thresholds and gates are TASK-090.');
682
+ L.push(' this is an aggregate, NOT a verdict — the gates and their thresholds are tune-context eval verdict.');
611
683
  return L.join('\n');
612
684
  }
613
685
 
614
686
  // ─── CLI ────────────────────────────────────────────────────────────────────
615
687
 
616
- async function readStdin() {
617
- const chunks = [];
618
- for await (const chunk of process.stdin) chunks.push(chunk);
619
- return Buffer.concat(chunks).toString('utf8');
620
- }
621
-
622
688
  /** JSONL → rows, malformed lines counted rather than fatal — the same rule the
623
689
  * collector applies to transcripts, for the same reason: the file may be being
624
- * appended to while we read it. */
690
+ * appended to while we read it. `lines[i]` is the 1-based line `rows[i]` came
691
+ * from, so a refusal can point at the line instead of at the file. */
625
692
  export function parseRows(text) {
626
693
  const rows = [];
694
+ const lines = [];
627
695
  let malformed = 0;
628
- for (const line of text.split('\n')) {
629
- if (line.trim() === '') continue;
696
+ text.split('\n').forEach((line, i) => {
697
+ if (line.trim() === '') return;
630
698
  try {
631
699
  const row = JSON.parse(line);
632
- if (row && typeof row === 'object' && !Array.isArray(row)) rows.push(row);
633
- else malformed++;
700
+ if (row && typeof row === 'object' && !Array.isArray(row)) {
701
+ rows.push(row);
702
+ lines.push(i + 1);
703
+ } else malformed++;
634
704
  } catch {
635
705
  malformed++;
636
706
  }
637
- }
638
- return { rows, malformed };
707
+ });
708
+ return { rows, lines, malformed };
639
709
  }
640
710
 
641
- async function main() {
642
- let text;
711
+ /**
712
+ * The CLI. `args` is everything after the script name, or after
713
+ * `tune-context eval analyze` — one parser behind both doors (EPIC-010).
714
+ */
715
+ export async function main(args = process.argv.slice(2)) {
716
+ endQuietlyOnClosedPipe();
717
+ let sources;
718
+ let ledger;
643
719
  try {
644
720
  const { values } = parseArgs({
645
- args: process.argv.slice(2),
721
+ args,
646
722
  options: {
647
723
  rows: { type: 'string', multiple: true },
724
+ ledger: { type: 'string' },
648
725
  help: { type: 'boolean', short: 'h', default: false },
649
726
  },
650
727
  strict: true,
@@ -653,6 +730,11 @@ async function main() {
653
730
  process.stdout.write(`${USAGE}\n`);
654
731
  return;
655
732
  }
733
+ // The ledger is read here rather than inside analyze() for the same reason
734
+ // prompt-metrics reads it in its CLI: it is machine state, and a pure
735
+ // function that reaches for the disk cannot be tested against someone
736
+ // else's run. One reader for both steps, --ledger included (TASK-098).
737
+ ledger = readStepLedger(values.ledger, claudeBaseDir());
656
738
  const paths = values.rows ?? [];
657
739
  const missing = paths.filter((p) => !existsSync(p));
658
740
  if (missing.length > 0) {
@@ -660,30 +742,51 @@ async function main() {
660
742
  process.exitCode = 1;
661
743
  return;
662
744
  }
663
- text = paths.length > 0 ? paths.map((p) => readFileSync(p, 'utf8')).join('\n') : await readStdin();
745
+ // One source per file (or stdin), parsed apart, so a refusal can name the
746
+ // file AND the line. The rows come out in the same order as before.
747
+ sources = paths.length > 0 ? paths.map((p) => ({ name: p, text: readFileSync(p, 'utf8') })) : [{ name: 'stdin', text: await readStdin() }];
664
748
  } catch (e) {
665
749
  process.stderr.write(`analyze: ${e.message}\n`);
666
750
  process.exitCode = 1;
667
751
  return;
668
752
  }
669
753
 
670
- const { rows, malformed } = parseRows(text);
754
+ const rows = [];
755
+ const unstamped = [];
756
+ let malformed = 0;
757
+ for (const { name, text } of sources) {
758
+ const parsed = parseRows(text);
759
+ malformed += parsed.malformed;
760
+ parsed.rows.forEach((row, i) => {
761
+ rows.push(row);
762
+ if (!isStamped(row)) unstamped.push(`${name}:${parsed.lines[i]}`);
763
+ });
764
+ }
671
765
  if (malformed > 0) process.stderr.write(`analyze: ${malformed} malformed line(s) skipped\n`);
672
766
 
673
- // The ledger is read here rather than inside analyze() for the same reason
674
- // prompt-metrics reads it in its CLI: it is machine state, and a pure function
675
- // that reaches for the disk cannot be tested against someone else's run.
676
- const ledgerPath = experimentPaths(claudeBaseDir()).ledgerPath;
677
- const ledgerAvailable = existsSync(ledgerPath);
678
- const agg = analyze(rows, {
679
- ledger: ledgerAvailable ? readLedger(claudeBaseDir()) : [],
680
- ledgerAvailable,
681
- });
767
+ // PO decision 4 (EPIC-010): a row without a stamp is input from before the
768
+ // measurement contract, not a measurement by another meter. The WHOLE input
769
+ // is refused, not the row: a file mixing old and new rows is a pasted-together
770
+ // input, and skipping the old ones with a counter would hand the verdict an
771
+ // aggregate that looks complete. Nothing goes to stdout, so nothing
772
+ // downstream can pick up half an answer.
773
+ if (unstamped.length > 0) {
774
+ process.stderr.write(
775
+ `analyze: refusing the whole input — ${unstamped.length} of ${rows.length} row(s) carry no meter_version` +
776
+ ` (first: ${unstamped.slice(0, 3).join(', ')})\n` +
777
+ ' recompute the rows from the transcripts or from an archive: tune-context eval prompt-metrics …\n' +
778
+ ' (EPIC-010: unstamped data is not used — a row without a stamp predates the measurement contract)\n',
779
+ );
780
+ process.exitCode = 1;
781
+ return;
782
+ }
783
+
784
+ const agg = analyze(rows, { ...ledger, analyzedBy: meterStamp() });
682
785
 
683
786
  process.stdout.write(`${JSON.stringify(agg, null, 2)}\n`);
684
787
  process.stderr.write(`${formatAnalysis(agg)}\n`);
685
788
  }
686
789
 
687
- if (process.argv[1]?.endsWith('analyze.mjs')) {
790
+ if (isEntryPoint(import.meta.url)) {
688
791
  await main();
689
792
  }