@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +195 -0
- package/README.md +211 -18
- 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 +35 -3
- package/cli/version.mjs +118 -0
- package/eval/adoption-report.mjs +11 -5
- package/eval/analyze.mjs +792 -0
- package/eval/arm.mjs +9 -5
- package/eval/pricing.mjs +32 -1
- package/eval/prompt-metrics.mjs +395 -46
- package/eval/report.mjs +881 -0
- package/eval/verdict.mjs +963 -0
- package/hooks/adoption-log.mjs +1 -1
- package/hooks/context-reminder.mjs +1 -1
- package/hooks/pre-compact-snapshot.mjs +4 -4
- package/hooks/session-start-reinject.mjs +1 -4
- package/package.json +7 -2
- package/skills/token-efficiency/SKILL.md +1 -1
- package/skills/tune-context/SKILL.md +4 -2
|
@@ -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
|
+
}
|
package/cli/experiment.mjs
CHANGED
|
@@ -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 &&
|
|
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 &&
|
|
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
|
-
|
|
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
|
*
|
package/cli/version.mjs
ADDED
|
@@ -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
|
+
}
|
package/eval/adoption-report.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
548
|
+
if (isEntryPoint(import.meta.url)) {
|
|
543
549
|
main();
|
|
544
550
|
}
|