@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.
@@ -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
+ }
@@ -190,7 +190,7 @@ export function observeArm({ claudeDir, cwd, pkgRoot }) {
190
190
 
191
191
  const claudeMdPath = join(cwd, 'CLAUDE.md');
192
192
  const claudeMd = readText(claudeMdPath);
193
- const hasBlock = claudeMd !== null && claudeMd.includes(START_MARKER) && claudeMd.includes(END_MARKER);
193
+ const hasBlock = claudeMd !== null && [START_MARKER, END_MARKER].every((m) => claudeMd.includes(m));
194
194
 
195
195
  const skills = deployed(claudeDir, pkgRoot, 'skills');
196
196
  const agents = deployed(claudeDir, pkgRoot, 'agents');
@@ -276,7 +276,7 @@ function planMutations({ claudeDir, cwd, pkgRoot, tc, rag }) {
276
276
 
277
277
  const claudeMdPath = join(cwd, 'CLAUDE.md');
278
278
  const claudeMd = readText(claudeMdPath);
279
- if (claudeMd !== null && claudeMd.includes(START_MARKER) && claudeMd.includes(END_MARKER)) {
279
+ if (claudeMd !== null && [START_MARKER, END_MARKER].every((m) => claudeMd.includes(m))) {
280
280
  mutations.push({
281
281
  label: 'managed block removed from the project CLAUDE.md',
282
282
  path: claudeMdPath,
@@ -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] && process.argv[1].endsWith('adoption-report.mjs')) {
548
+ if (isEntryPoint(import.meta.url)) {
543
549
  main();
544
550
  }