@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.
@@ -43,7 +43,9 @@ import { createInterface } from 'node:readline';
43
43
  import { join } from 'node:path';
44
44
  import { parseArgs } from 'node:util';
45
45
  import { claudeBaseDir } from '../hooks/config-dir.mjs';
46
- import { experimentPaths, readLedger } from '../cli/experiment.mjs';
46
+ import { endQuietlyOnClosedPipe, isEntryPoint } from '../cli/entry-point.mjs';
47
+ import { readStepLedger } from '../cli/experiment.mjs';
48
+ import { meterStamp } from '../cli/version.mjs';
47
49
  import { annotateRows, formatArmSummary } from './arm.mjs';
48
50
  import { formatCostLines, formatUsd, priceRow, summarizeCost, webSearchCost } from './pricing.mjs';
49
51
 
@@ -61,7 +63,7 @@ const USAGE = `
61
63
  prompt-metrics — per-prompt time/token/tool metrics from Claude Code transcripts.
62
64
 
63
65
  Usage:
64
- prompt-metrics [--transcript <path>]... [--dir <path>]
66
+ prompt-metrics [--transcript <path>]... [--dir <path>] [--ledger <path>]
65
67
 
66
68
  Options:
67
69
  --transcript <path> A single .jsonl transcript. Repeatable.
@@ -70,6 +72,12 @@ Options:
70
72
  (default when neither is given: <config dir>/projects,
71
73
  where <config dir> is $CLAUDE_CONFIG_DIR if set,
72
74
  else ~/.claude)
75
+ --ledger <path> The experiment ledger to assign arms by (default:
76
+ <config dir>/tune-context/experiment.jsonl). For
77
+ recomputing an archive — \`tune-context experiment
78
+ archive\` keeps the ledger next to the transcripts. A
79
+ path that does not exist, or is not a file, is an
80
+ error.
73
81
  -h, --help Show this help
74
82
 
75
83
  Writes JSONL to stdout, one row per prompt. A run summary — including how many
@@ -90,6 +98,12 @@ These five fields are added here, in the CLI. Rows obtained through the module
90
98
  (aggregate, collectFiles) carry the measurement only — call annotateRows from
91
99
  eval/arm.mjs to add the arm.
92
100
 
101
+ Every row also carries meter_version: the code that computed it — the bare
102
+ release number for an npm install, number+<sha> for a checkout, .dirty for
103
+ uncommitted changes in the meter's paths (EPIC-010). The first line of the
104
+ summary on stderr is that stamp, as \`meter <stamp>\`: it goes into the run
105
+ journal before the first pair. analyze refuses rows without it.
106
+
93
107
  Each row also carries delegations[]: one record per Agent call that returned its
94
108
  own usage, priced with the same table. That figure is a LOWER BOUND on what the
95
109
  subagents cost and is never added to the row's own cost.
@@ -973,14 +987,21 @@ function defaultDir() {
973
987
  return join(claudeBaseDir(), 'projects');
974
988
  }
975
989
 
976
- async function main() {
990
+ /**
991
+ * The CLI. `args` is everything after the script name, or after
992
+ * `tune-context eval prompt-metrics` — one parser behind both doors (EPIC-010).
993
+ */
994
+ export async function main(args = process.argv.slice(2)) {
995
+ endQuietlyOnClosedPipe();
977
996
  let paths;
997
+ let ledger;
978
998
  try {
979
999
  const { values } = parseArgs({
980
- args: process.argv.slice(2),
1000
+ args,
981
1001
  options: {
982
1002
  transcript: { type: 'string', multiple: true },
983
1003
  dir: { type: 'string' },
1004
+ ledger: { type: 'string' },
984
1005
  help: { type: 'boolean', short: 'h', default: false },
985
1006
  },
986
1007
  strict: true,
@@ -989,6 +1010,9 @@ async function main() {
989
1010
  process.stdout.write(`${USAGE}\n`);
990
1011
  return;
991
1012
  }
1013
+ // Read now, before the transcripts: a --ledger that is not a file stops the
1014
+ // run here, with the same message analyze gives (TASK-098).
1015
+ ledger = readStepLedger(values.ledger, claudeBaseDir());
992
1016
  // Both flags together are a UNION, deduplicated, not a precedence rule:
993
1017
  // silently ignoring one of two flags the user passed is how a run measures
994
1018
  // something other than what was asked for.
@@ -1016,11 +1040,6 @@ async function main() {
1016
1040
  return;
1017
1041
  }
1018
1042
 
1019
- // Piping into `head` closes stdout early; that is a normal end, not a crash.
1020
- process.stdout.on('error', (e) => {
1021
- if (e.code !== 'EPIPE') throw e;
1022
- });
1023
-
1024
1043
  const collector = await collectFiles(paths);
1025
1044
  const result = collector.result();
1026
1045
 
@@ -1028,19 +1047,21 @@ async function main() {
1028
1047
  // transcript into rows and has no business knowing about the experiment, and
1029
1048
  // the ledger is machine state rather than transcript content. The split also
1030
1049
  // lets the same rules run over rows collected elsewhere (TASK-088).
1031
- const ledgerPath = experimentPaths(claudeBaseDir()).ledgerPath;
1032
- const ledgerAvailable = existsSync(ledgerPath);
1033
- const rows = annotateRows(result.rows, {
1034
- ledger: ledgerAvailable ? readLedger(claudeBaseDir()) : [],
1035
- ledgerAvailable,
1036
- });
1050
+ const rows = annotateRows(result.rows, ledger);
1037
1051
 
1038
- for (const row of rows) process.stdout.write(`${formatRow(row)}\n`);
1039
- process.stderr.write(`${formatSummary(result)}\n`);
1052
+ // Which code computed the rows rides on every row — added here, with the arm,
1053
+ // because it is a property of the process that ran, not of the transcript
1054
+ // (TASK-097). Last on the row, so nothing that already read a row by position
1055
+ // in a diff moves.
1056
+ const stamp = meterStamp();
1057
+ for (const row of rows) process.stdout.write(`${formatRow({ ...row, meter_version: stamp })}\n`);
1058
+ // First line of the summary, on its own: this is what the operator copies
1059
+ // into the run journal before the first pair.
1060
+ process.stderr.write(`meter ${stamp}\n${formatSummary(result)}\n`);
1040
1061
  const arms = formatArmSummary(rows);
1041
1062
  if (arms !== null) process.stderr.write(`${arms}\n`);
1042
1063
  }
1043
1064
 
1044
- if (process.argv[1]?.endsWith('prompt-metrics.mjs')) {
1065
+ if (isEntryPoint(import.meta.url)) {
1045
1066
  await main();
1046
1067
  }
package/eval/report.mjs CHANGED
@@ -38,11 +38,13 @@
38
38
  // (light: contrast WARN on aqua/yellow/magenta, so both charts ship direct
39
39
  // labels AND a table twin — the documented relief), zero-baseline axes,
40
40
  // hairline chrome, 2px surface gaps instead of borders.
41
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
41
+ import { writeFileSync } from 'node:fs';
42
42
  import { parseArgs } from 'node:util';
43
+ import { endQuietlyOnClosedPipe, isEntryPoint } from '../cli/entry-point.mjs';
44
+ import { meterStamp } from '../cli/version.mjs';
43
45
  import { ARMS } from './analyze.mjs';
44
46
  import { TOKEN_CLASS_FIELDS, formatUsd } from './pricing.mjs';
45
- import { VERDICT_THRESHOLDS, verdict as computeVerdict } from './verdict.mjs';
47
+ import { VERDICT_THRESHOLDS, VerdictInputError, verdict as computeVerdict, inputErrorLine, readVerdictInput } from './verdict.mjs';
46
48
 
47
49
  const USAGE = `
48
50
  report — one self-contained HTML file from an analyze aggregate (TASK-091).
@@ -51,7 +53,7 @@ Usage:
51
53
  report [--aggregate <path>] [--rejected-runs <path>] [--out <path>]
52
54
 
53
55
  Options:
54
- --aggregate <path> JSON written by analyze.mjs (default: read stdin)
56
+ --aggregate <path> JSON written by analyze (default: read stdin)
55
57
  --rejected-runs <path> JSON array of the run journal's §7 rejections,
56
58
  [{ session, task, arm, prompts, reason }]. OMITTING it
57
59
  is not the same as passing a file containing []: the
@@ -438,7 +440,7 @@ function verdictSection(v) {
438
440
  <p class="dim">Pool: paired measurement only (${num(v.n_pairs_used)} usable pair(s)). Natural work is never evidence.
439
441
  ${v.observation_only ? 'This report says "observation", never "proof" — the PO froze a minimum sample, not a level at which it becomes proof.' : ''}</p>
440
442
  </div>
441
- <h2>The six gates</h2>
443
+ <h2>The ${v.gates.length} gates</h2>
442
444
  <ul class="gates">${gates}</ul>
443
445
  </section>`;
444
446
  }
@@ -602,6 +604,22 @@ function thresholdsSection(t) {
602
604
  </section>`;
603
605
  }
604
606
 
607
+ /**
608
+ * Which code computed this page, step by step — rows, aggregate, verdict
609
+ * (EPIC-010). In the header rather than the footer, because EPIC-009's verdict
610
+ * was voided over exactly this question, asked of files that could not answer
611
+ * it. Whether the stamps agree is the meter_mixed gate's business; this line
612
+ * only names them, all of them.
613
+ */
614
+ function meterLine(agg, v) {
615
+ const rows = Object.entries(agg.meter_versions ?? {});
616
+ const stamp = (s) => (s ? `<code>${esc(s)}</code>` : '<span class="flag">no stamp</span>');
617
+ return (
618
+ `<p class="src">meter — rows: ${rows.length === 0 ? '—' : rows.map(([s, n]) => `${stamp(s)} ×${num(n)}`).join(', ')}` +
619
+ ` · analyze: ${stamp(agg.analyzed_by)} · verdict: ${stamp(v.judged_by)}</p>`
620
+ );
621
+ }
622
+
605
623
  // ─── the page ───────────────────────────────────────────────────────────────
606
624
 
607
625
  /** Palette and chrome from the `dataviz` reference instance, as CSS custom
@@ -711,18 +729,18 @@ footer { max-width:980px; margin:0 auto; color:var(--muted); font-size:12px; }
711
729
  * verdict() follow, and the reason a test can render a fabricated run without a
712
730
  * fixture directory.
713
731
  *
714
- * @param agg an aggregate from analyze() (schema tune-context/analyze@1)
732
+ * @param agg an aggregate from analyze() (schema tune-context/analyze@2)
715
733
  * @param verdict the decision from verdict(). Computed by the caller rather
716
734
  * than here, because the journal of §7 rejections is an input this layer
717
735
  * has no way to read — and a verdict computed without it holds gate 6,
718
736
  * which is the correct answer, not a bug to route around.
719
737
  */
720
738
  export function renderReport(agg, { verdict: v, generatedAt = null } = {}) {
721
- if (!agg || typeof agg !== 'object' || agg.schema !== 'tune-context/analyze@1') {
722
- throw new TypeError('report: expected an aggregate from analyze() (schema tune-context/analyze@1)');
739
+ if (!agg || typeof agg !== 'object' || agg.schema !== 'tune-context/analyze@2') {
740
+ throw new TypeError('report: expected an aggregate from analyze() (schema tune-context/analyze@2)');
723
741
  }
724
- if (v?.schema !== 'tune-context/verdict@1') {
725
- throw new TypeError('report: expected a verdict from verdict() (schema tune-context/verdict@1)');
742
+ if (v?.schema !== 'tune-context/verdict@2') {
743
+ throw new TypeError('report: expected a verdict from verdict() (schema tune-context/verdict@2)');
726
744
  }
727
745
 
728
746
  // Pool-level composition: the two arms of ONE pool added together. The pools
@@ -760,6 +778,7 @@ export function renderReport(agg, { verdict: v, generatedAt = null } = {}) {
760
778
  once with tune-context and RAG on, once with both parked. Generated${generatedAt ? ` ${esc(generatedAt)}` : ''} from
761
779
  an aggregate of ${num(agg.counters.rows_in)} prompt row(s). Costs are <b>list price through the API</b> — what the
762
780
  work would have cost, never money spent.</p>
781
+ ${meterLine(agg, v)}
763
782
  </header>
764
783
  ${verdictSection(v)}
765
784
  ${pairsSection(agg, v)}
@@ -794,17 +813,16 @@ ${thresholdsSection(v.thresholds)}
794
813
 
795
814
  // ─── CLI ────────────────────────────────────────────────────────────────────
796
815
 
797
- async function readStdin() {
798
- const chunks = [];
799
- for await (const chunk of process.stdin) chunks.push(chunk);
800
- return Buffer.concat(chunks).toString('utf8');
801
- }
802
-
803
- async function main() {
816
+ /**
817
+ * The CLI. `args` is everything after the script name, or after
818
+ * `tune-context eval report` — one parser behind both doors (EPIC-010).
819
+ */
820
+ export async function main(args = process.argv.slice(2)) {
821
+ endQuietlyOnClosedPipe();
804
822
  let values;
805
823
  try {
806
824
  ({ values } = parseArgs({
807
- args: process.argv.slice(2),
825
+ args,
808
826
  options: {
809
827
  aggregate: { type: 'string' },
810
828
  'rejected-runs': { type: 'string' },
@@ -823,48 +841,41 @@ async function main() {
823
841
  return;
824
842
  }
825
843
 
826
- for (const [flag, path] of [['--aggregate', values.aggregate], ['--rejected-runs', values['rejected-runs']]]) {
827
- if (path !== undefined && !existsSync(path)) {
828
- process.stderr.write(`report: no file at ${path} (${flag})\n`);
829
- process.exitCode = 1;
830
- return;
831
- }
832
- }
833
-
834
- let agg;
835
- let rejectedRuns;
836
- try {
837
- agg = JSON.parse(values.aggregate ? readFileSync(values.aggregate, 'utf8') : await readStdin());
838
- // Left `undefined` when the flag is absent, and that is the whole point: it
839
- // is what makes gate 6 hold instead of passing on a share whose denominator
840
- // nobody supplied.
841
- if (values['rejected-runs'] !== undefined) {
842
- rejectedRuns = JSON.parse(readFileSync(values['rejected-runs'], 'utf8'));
843
- }
844
- } catch (e) {
845
- process.stderr.write(`report: ${e.message}\n`);
846
- process.exitCode = 1;
847
- return;
848
- }
849
-
844
+ // The same reader and the same boundary as `eval verdict` (TASK-096): one
845
+ // place decides what a readable aggregate and journal are, so this page can
846
+ // never accept an input the verdict refuses — and only those refusals become
847
+ // a message. A TypeError from inside a gate or the page is a bug and keeps its
848
+ // stacktrace, here as there. `rejectedRuns` stays undefined when the flag is
849
+ // absent — that is what makes gate 6 hold instead of passing on a share whose
850
+ // denominator nobody supplied.
850
851
  let html;
851
852
  try {
852
- const v = computeVerdict(agg, { rejectedRuns });
853
+ const { agg, rejectedRuns } = await readVerdictInput({ aggregate: values.aggregate, rejectedRuns: values['rejected-runs'] });
854
+ const v = computeVerdict(agg, { rejectedRuns, judgedBy: meterStamp() });
853
855
  html = renderReport(agg, { verdict: v, generatedAt: new Date().toISOString().slice(0, 10) });
854
856
  } catch (e) {
855
- process.stderr.write(`report: ${e.message}\n`);
857
+ if (!(e instanceof VerdictInputError)) throw e;
858
+ process.stderr.write(`${inputErrorLine('report', e)}\n`);
856
859
  process.exitCode = 1;
857
860
  return;
858
861
  }
859
862
 
860
863
  if (values.out) {
861
- writeFileSync(values.out, html);
864
+ try {
865
+ writeFileSync(values.out, html);
866
+ } catch (e) {
867
+ // Where to write is the operator's input too: a missing directory is a
868
+ // message, not a stacktrace.
869
+ process.stderr.write(`report: cannot write ${values.out}: ${e.message}\n`);
870
+ process.exitCode = 1;
871
+ return;
872
+ }
862
873
  process.stderr.write(`report: wrote ${values.out}\n`);
863
874
  } else {
864
875
  process.stdout.write(html);
865
876
  }
866
877
  }
867
878
 
868
- if (process.argv[1]?.endsWith('report.mjs')) {
879
+ if (isEntryPoint(import.meta.url)) {
869
880
  await main();
870
881
  }