@ngockhoale/ukit 3.4.46 → 3.4.48

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +13 -5
  2. package/manifests/documentation.yaml +22 -0
  3. package/manifests/platform.full.yaml +27 -0
  4. package/package.json +4 -1
  5. package/scripts/dev/capture-omp-stall.mjs +246 -0
  6. package/scripts/dev/worktree-preflight.mjs +20 -1
  7. package/scripts/docs/render-instructions.mjs +29 -5
  8. package/src/core/agentRuntime/supervisor.js +15 -1
  9. package/src/core/diffPlan.js +38 -12
  10. package/src/core/executionContracts.js +67 -16
  11. package/src/core/metadata.js +4 -0
  12. package/src/core/runInstallPipeline.js +19 -2
  13. package/src/core/runtimeConfig.js +1 -1
  14. package/src/diagnostics/ompStallSnapshot.js +199 -0
  15. package/src/index/playbookRegistry.js +22 -3
  16. package/src/index/taskRouting.js +2 -2
  17. package/template_project/.claude/agents/bug-debugger.md +1 -0
  18. package/template_project/.claude/agents/code-reviewer.md +1 -0
  19. package/template_project/.claude/agents/feature-implementer.md +1 -0
  20. package/template_project/.claude/agents/handoff-planner.md +1 -0
  21. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  22. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  23. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  24. package/template_project/.claude/hooks/skill-router.sh +147 -6
  25. package/template_project/.claude/settings.json +8 -8
  26. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  27. package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
  28. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  29. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  30. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  31. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
  32. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  33. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  34. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  35. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +80 -3
  37. package/template_project/.omp/agents/bug-debugger.md +1 -0
  38. package/template_project/.omp/agents/code-reviewer.md +1 -0
  39. package/template_project/.omp/agents/feature-implementer.md +1 -0
  40. package/template_project/.omp/agents/handoff-planner.md +1 -0
  41. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  42. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  43. package/template_project/ukit/storage/config.json +1 -1
  44. package/scripts/bench/data-foundation.mjs +0 -929
  45. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  46. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  47. package/scripts/bench/goldTasks.json +0 -38
  48. package/scripts/bench/memory-ablation.mjs +0 -495
  49. package/scripts/bench/memory-baseline.mjs +0 -596
  50. package/scripts/bench/memory-bench.mjs +0 -661
  51. package/scripts/bench/memory-canary.mjs +0 -331
  52. package/scripts/bench/memory-corpus.mjs +0 -354
  53. package/scripts/bench/memory-gate.mjs +0 -389
  54. package/scripts/bench/memory-metrics.mjs +0 -179
  55. package/scripts/bench/outline-savings.mjs +0 -107
  56. package/scripts/bench/parallel-agents.mjs +0 -224
  57. package/scripts/bench/recorder-overhead.mjs +0 -204
  58. package/scripts/bench/runGold.mjs +0 -238
  59. package/scripts/bench/sqlite-spike.mjs +0 -451
  60. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  61. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  62. package/scripts/bench/v3-ceremony.mjs +0 -220
  63. package/scripts/perf/audit-perf.mjs +0 -1195
  64. package/scripts/perf/diff-perf-findings.mjs +0 -136
  65. package/scripts/perf/perf-findings.json +0 -474
  66. package/scripts/perf/perf-measure.md +0 -271
@@ -1,224 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * parallel-agents.mjs — TASK-004 harness.
4
- *
5
- * Measures the *machine-contention* component of N parallel agents by spawning N concurrent
6
- * child processes (default `yarn test:release-core`) per level and recording wall-clock.
7
- * This deliberately does NOT claim to benchmark LLM agent wall-clock (token counts, server
8
- * load and retries make that irreproducible) — see TASK-004 planner note #1.
9
- *
10
- * Usage:
11
- * node scripts/bench/parallel-agents.mjs [--levels 1,3,5,10] [--cmd "<test command>"] [--out <path>]
12
- */
13
-
14
- import fs from 'node:fs';
15
- import os from 'node:os';
16
- import path from 'node:path';
17
- import { spawn } from 'node:child_process';
18
- import { fileURLToPath } from 'node:url';
19
-
20
- const SLOWDOWN_LIMIT = 2.0;
21
-
22
- // BUG-C22-21: a level must never wait forever — one wedged `shell:true` child
23
- // used to leave `settled < n` permanently and hang the whole benchmark
24
- // mid-level. A per-level deadline SIGKILLs unsettled children (their whole
25
- // process group — detached:true puts each `sh -c` in its own group so
26
- // grandchildren die too) and resolves the level marked timedOut.
27
- // UKIT_BENCH_LEVEL_TIMEOUT_MS overrides the budget for tests.
28
- const LEVEL_TIMEOUT_MS = Number(process.env.UKIT_BENCH_LEVEL_TIMEOUT_MS) || 30 * 60 * 1000; // 30 min
29
-
30
- function parseArgs(argv) {
31
- const opts = { levels: '1,3,5,10', cmd: 'yarn test:release-core', out: '.cache/bench/parallel-agents.json' };
32
- for (let i = 0; i < argv.length; i += 1) {
33
- const key = argv[i];
34
- if (key !== '--levels' && key !== '--cmd' && key !== '--out') {
35
- throw new Error(`unknown argument: ${key}`);
36
- }
37
- const val = argv[i + 1];
38
- if (val === undefined || val.startsWith('--')) {
39
- throw new Error(`missing value for ${key}`);
40
- }
41
- opts[key.slice(2)] = val;
42
- i += 1;
43
- }
44
- return opts;
45
- }
46
-
47
- function parseLevels(raw) {
48
- if (!/^\d+(,\d+)*$/.test(raw.trim())) {
49
- throw new Error(`unparseable --levels value: "${raw}" (expected comma-separated positive integers, e.g. 1,3,5,10)`);
50
- }
51
- const levels = raw.split(',').map((s) => parseInt(s, 10));
52
- if (levels.some((n) => n < 1)) throw new Error(`--levels values must be >= 1, got: ${raw}`);
53
- return levels;
54
- }
55
-
56
- function runLevel(n, cmd) {
57
- return new Promise((resolve) => {
58
- const t0 = Date.now();
59
- let live = 0;
60
- let highWater = 0;
61
- let failures = 0;
62
- let settled = 0;
63
- let timedOut = false;
64
- let finished = false;
65
- const children = [];
66
- // BUG-C22-21: per-level deadline — kill stragglers and resolve marked-failed.
67
- const deadline = setTimeout(() => {
68
- timedOut = true;
69
- for (const child of children) {
70
- if (!child.ukitSettled) {
71
- child.ukitSettled = true;
72
- settled += 1;
73
- failures += 1;
74
- try {
75
- process.kill(-child.pid, 'SIGKILL'); // kill the whole process tree
76
- } catch {
77
- try { child.kill('SIGKILL'); } catch { /* already gone */ }
78
- }
79
- }
80
- }
81
- maybeFinish();
82
- }, LEVEL_TIMEOUT_MS);
83
- deadline.unref();
84
- for (let i = 0; i < n; i += 1) {
85
- const child = spawn(cmd, { shell: true, stdio: 'ignore', detached: true });
86
- live += 1;
87
- highWater = Math.max(highWater, live);
88
- children.push(child);
89
- child.on('error', () => {
90
- if (child.ukitSettled) return;
91
- child.ukitSettled = true;
92
- live -= 1;
93
- settled += 1;
94
- failures += 1;
95
- maybeFinish();
96
- });
97
- child.on('exit', (code) => {
98
- if (child.ukitSettled) return;
99
- child.ukitSettled = true;
100
- live -= 1;
101
- settled += 1;
102
- if (code !== 0) failures += 1;
103
- maybeFinish();
104
- });
105
- }
106
- function maybeFinish() {
107
- if (finished) return;
108
- if (settled === n) {
109
- finished = true;
110
- clearTimeout(deadline);
111
- if (timedOut) {
112
- console.error(`[bench] level n=${n}: deadline ${LEVEL_TIMEOUT_MS}ms exceeded — unsettled children killed and counted as failures`);
113
- }
114
- resolve({ n, wallClockMs: Date.now() - t0, failures, concurrencyHighWaterMark: highWater, timedOut });
115
- }
116
- }
117
- });
118
- }
119
-
120
- function fail(msg) {
121
- process.stderr.write(`${msg}\n`);
122
- process.exit(1);
123
- }
124
-
125
- // BUG-C27-24: `wallClockMs === 0` (a sub-ms --cmd) made the base `base` 0 →
126
- // every slowdownFactor NaN → `eligible` empty → `eligible.reduce(..., eligible[0])`
127
- // dereffed undefined and threw a cryptic TypeError AFTER the whole benchmark ran.
128
- // `wallClockMs === 0` is below Date.now() resolution for a spawned child, so it is
129
- // marked unmeasurable and excluded; an empty `eligible` yields recommended=null so
130
- // main() fails with a clear message, and the reduce is seeded with rows[0] so it can
131
- // never deref undefined.
132
- export function computeRecommendation(rows) {
133
- const base = rows[0].wallClockMs;
134
- for (const row of rows) {
135
- row.unmeasurable = row.wallClockMs === 0 || base === 0;
136
- row.slowdownFactor = base === 0 ? NaN : row.wallClockMs / base;
137
- row.perRunCost = row.wallClockMs / row.n;
138
- }
139
- const eligible = rows.filter((r) => !r.unmeasurable && r.slowdownFactor <= SLOWDOWN_LIMIT);
140
- const recommended = eligible.length === 0
141
- ? null
142
- : eligible.reduce((best, r) => (r.perRunCost < best.perRunCost ? r : best), rows[0]).n;
143
- return { recommended, eligible };
144
- }
145
-
146
-
147
- async function main() {
148
- const opts = (() => {
149
- try {
150
- return parseArgs(process.argv.slice(2));
151
- } catch (err) {
152
- fail(err.message);
153
- }
154
- })();
155
- let levels;
156
- try {
157
- levels = parseLevels(opts.levels);
158
- } catch (err) {
159
- fail(err.message);
160
- }
161
-
162
- const cpuCount = os.cpus().length;
163
- const loadAvgStart = os.loadavg();
164
- const startedAt = new Date().toISOString();
165
-
166
- const rows = [];
167
- for (const n of levels) {
168
- // eslint-disable-next-line no-await-in-loop -- levels are measured sequentially on purpose
169
- rows.push(await runLevel(n, opts.cmd));
170
- }
171
- const { recommended } = computeRecommendation(rows);
172
- if (recommended === null) {
173
- fail('no level met slowdown limit (base wall clock 0ms?) — benchmark is unmeasurable; rerun with a slower --cmd');
174
- }
175
-
176
- const loadAvgEnd = os.loadavg();
177
- const finishedAt = new Date().toISOString();
178
-
179
- const result = {
180
- cpuCount,
181
- levels: rows.map(({ n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark, timedOut, unmeasurable }) => ({
182
- n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark, timedOut, unmeasurable,
183
- })),
184
- recommended,
185
- measurementConditions: { loadAvgStart, loadAvgEnd, startedAt, finishedAt, cpuCount },
186
- };
187
-
188
- // Print the human table first so a failed --out write still shows the numbers.
189
- const header = 'n'.padStart(4) + ' ' + 'wallClockMs'.padStart(12) + ' ' + 'slowdownFactor'.padStart(15) + ' ' + 'perRunCost'.padStart(11) + ' ' + 'failures'.padStart(8) + ' ' + 'concurrencyHighWaterMark'.padStart(23);
190
- console.log(header);
191
- for (const r of result.levels) {
192
- console.log(
193
- String(r.n).padStart(4)
194
- + ' ' + String(r.wallClockMs).padStart(12)
195
- + ' ' + (r.unmeasurable ? 'unmeasurable'.padStart(15) : r.slowdownFactor.toFixed(2).padStart(15))
196
- + ' ' + r.perRunCost.toFixed(1).padStart(11)
197
- + ' ' + String(r.failures).padStart(8)
198
- + ' ' + String(r.concurrencyHighWaterMark).padStart(23),
199
- );
200
- }
201
- console.log(`recommended: ${recommended} (best perRunCost with slowdownFactor <= ${SLOWDOWN_LIMIT}) on ${cpuCount} CPUs`);
202
- console.log(`loadAvgStart: [${loadAvgStart.map((v) => v.toFixed(2)).join(', ')}] loadAvgEnd: [${loadAvgEnd.map((v) => v.toFixed(2)).join(', ')}]`);
203
-
204
- // Overwrite (never append), and never leave a partial file behind.
205
- const outPath = path.resolve(opts.out);
206
- try {
207
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
208
- const body = JSON.stringify(result, null, 2) + '\n';
209
- fs.writeFileSync(outPath, body);
210
- } catch (err) {
211
- fail(`cannot write --out ${outPath}: ${err.message}`);
212
- }
213
-
214
- const totalFailures = result.levels.reduce((s, r) => s + r.failures, 0);
215
- if (totalFailures > 0) {
216
- const perLevel = result.levels.filter((r) => r.failures > 0).map((r) => `n=${r.n}: ${r.failures}/${r.n} failed`).join('; ');
217
- fail(`bench command failed: ${perLevel}`);
218
- }
219
- }
220
-
221
- const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
222
- if (isMain) {
223
- main().catch((err) => fail(err && err.message ? err.message : String(err)));
224
- }
@@ -1,204 +0,0 @@
1
- #!/usr/bin/env node
2
- // recorder-overhead.mjs (TASK-001, SPEC §2 G2-FR03) — paired baseline vs
3
- // instrumented overhead measurement for the observer seam. REAL measurement
4
- // on this host; no fabricated targets.
5
- //
6
- // Workload: hook-telemetry-shaped events derived deterministically from the
7
- // seeded corpus (tests/fixtures/observability/corpus.json, seed=42) — one
8
- // event per corpus record, timings taken from the record's own duration so
9
- // the fixture stays the single source of truth.
10
- //
11
- // baseline arm: appendFileSync(JSON.stringify(row)) to a jsonl file —
12
- // the status-quo owner write path (hook-telemetry append).
13
- // instrumented arm: adaptHookTelemetryEvent(row) → recorder.emit() → flush()
14
- // per event into a real temp segment store — the seam a
15
- // hook would run inside its deadline.
16
- //
17
- // Both arms repeat the same event corpus --reps times, interleaved per rep
18
- // (baseline pass, then instrumented pass) so drift hits both arms equally.
19
- //
20
- // node scripts/bench/recorder-overhead.mjs [--json] [--reps N]
21
-
22
- import fs from 'node:fs';
23
- import os from 'node:os';
24
- import path from 'node:path';
25
- import { execSync } from 'node:child_process';
26
- import { fileURLToPath } from 'node:url';
27
-
28
- import { adaptHookTelemetryEvent } from '../../src/core/observability/adapters/hookTelemetryAdapter.js';
29
- import { createAdapterContext } from '../../src/core/observability/adapters/common.js';
30
- import { createRecorder } from '../../src/core/observability/emit/recorder.js';
31
-
32
- const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
33
- const corpusPath = path.join(repoRoot, 'tests/fixtures/observability/corpus.json');
34
-
35
- const args = process.argv.slice(2);
36
- const json = args.includes('--json');
37
- const repsFlag = args.indexOf('--reps');
38
- const REPS = repsFlag !== -1 ? Number.parseInt(args[repsFlag + 1], 10) : 30;
39
- if (!Number.isInteger(REPS) || REPS <= 0) {
40
- console.error('--reps must be a positive integer');
41
- process.exit(2);
42
- }
43
-
44
- function percentile(sorted, p) {
45
- if (sorted.length === 0) return null;
46
- const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
47
- return sorted[Math.max(0, idx)];
48
- }
49
-
50
- function stats(samples) {
51
- const sorted = [...samples].sort((a, b) => a - b);
52
- const n = sorted.length;
53
- const mean = n ? sorted.reduce((s, v) => s + v, 0) / n : 0;
54
- const variance = n ? sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / n : 0;
55
- return {
56
- n,
57
- min: n ? sorted[0] : null,
58
- max: n ? sorted[n - 1] : null,
59
- mean: Number(mean.toFixed(4)),
60
- stdev: Number(Math.sqrt(variance).toFixed(4)),
61
- p50: percentile(sorted, 50),
62
- p95: percentile(sorted, 95),
63
- p99: percentile(sorted, 99),
64
- };
65
- }
66
-
67
- function dirBytes(dir) {
68
- let total = 0;
69
- if (!fs.existsSync(dir)) return 0;
70
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
71
- const p = path.join(dir, entry.name);
72
- if (entry.isDirectory()) total += dirBytes(p);
73
- else total += fs.statSync(p).size;
74
- }
75
- return total;
76
- }
77
-
78
- function gitRevision() {
79
- try {
80
- return execSync('git rev-parse --short HEAD', { cwd: repoRoot, encoding: 'utf8', timeout: 10_000 }).trim();
81
- } catch {
82
- return 'unknown';
83
- }
84
- }
85
-
86
- // --- workload: corpus records → hook-telemetry-shaped rows ------------------
87
- let corpus;
88
- let sourceRecords;
89
- try {
90
- corpus = JSON.parse(fs.readFileSync(corpusPath, 'utf8'));
91
- sourceRecords = corpus.traces.flatMap((t) => t.records);
92
- } catch {
93
- console.error(
94
- `recorder-overhead: cannot load ${corpusPath} — run 'node scripts/bench/data-foundation.mjs --fixture' first`,
95
- );
96
- process.exit(1);
97
- }
98
- const events = sourceRecords.map((r, i) => ({
99
- v: 1,
100
- ts: Date.parse(r.wall_time_utc),
101
- hookEvent: 'PostToolUse',
102
- toolName: typeof r.payload?.tool_name === 'string' ? r.payload.tool_name : 'BenchTool',
103
- toolUseId: `bench-${r.record_id ?? i}`,
104
- hook: 'post-tool-use/bench-hook',
105
- elapsedMs: Number.isFinite(r.payload?.duration_ms) ? r.payload.duration_ms : (i % 53),
106
- outcome: 'ok',
107
- ...(i % 3 === 0 ? { stageMs: i % 7 } : {}),
108
- }));
109
-
110
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ukit-bench-'));
111
- const baselineFile = path.join(tmpRoot, 'baseline.jsonl');
112
- const segmentRoot = path.join(tmpRoot, 'segments');
113
-
114
- const baselineMs = [];
115
- const emitMs = [];
116
- const flushMs = [];
117
-
118
- const heapBefore = process.memoryUsage().heapUsed;
119
- let recordsWritten = 0;
120
- let recordsEmitted = 0;
121
- let lastHealth = null;
122
- const wallStart = Date.now();
123
-
124
- const recorder = createRecorder({
125
- root: segmentRoot,
126
- config: { observability: { stage: 'default' } },
127
- });
128
-
129
- try {
130
- for (let rep = 0; rep < REPS; rep += 1) {
131
- // baseline arm — status-quo owner append path
132
- for (const event of events) {
133
- const line = JSON.stringify(event) + '\n';
134
- const t0 = process.hrtime.bigint();
135
- fs.appendFileSync(baselineFile, line);
136
- baselineMs.push(Number(process.hrtime.bigint() - t0) / 1e6);
137
- }
138
- // instrumented arm — adapt → emit → flush per event (hook-deadline seam)
139
- const ctx = createAdapterContext({ writerId: 'adapter-hook-bench' });
140
- for (const event of events) {
141
- const t0 = process.hrtime.bigint();
142
- const record = adaptHookTelemetryEvent(event, ctx);
143
- if (record) recorder.emit(record);
144
- emitMs.push(Number(process.hrtime.bigint() - t0) / 1e6);
145
-
146
- const f0 = process.hrtime.bigint();
147
- await recorder.flush({ deadlineMs: 100 });
148
- flushMs.push(Number(process.hrtime.bigint() - f0) / 1e6);
149
- }
150
- const h = recorder.health();
151
- lastHealth = h;
152
- recordsEmitted += events.length;
153
- }
154
- } finally {
155
- const heapAfter = process.memoryUsage().heapUsed;
156
- const segmentBytes = dirBytes(segmentRoot);
157
- recordsWritten = lastHealth ? lastHealth.written : 0;
158
-
159
- const report = {
160
- bench: 'recorder-overhead',
161
- host: {
162
- platform: os.platform(),
163
- arch: os.arch(),
164
- release: os.release(),
165
- cpu: os.cpus()?.[0]?.model ?? 'unknown',
166
- node: process.version,
167
- },
168
- revision: gitRevision(),
169
- measured_at: new Date().toISOString(),
170
- workload: {
171
- corpus: path.relative(repoRoot, corpusPath),
172
- seed: corpus.seed,
173
- events_per_rep: events.length,
174
- reps: REPS,
175
- },
176
- reps: REPS,
177
- baseline: stats(baselineMs),
178
- emit: stats(emitMs),
179
- flush: stats(flushMs),
180
- bytesPerRecord: recordsWritten > 0 ? Math.round(segmentBytes / recordsWritten) : null,
181
- heapDeltaBytes: heapAfter - heapBefore,
182
- wall_clock_ms: Date.now() - wallStart,
183
- instrumented: {
184
- events: recordsEmitted,
185
- recordsWritten,
186
- segmentBytes,
187
- health: lastHealth,
188
- },
189
- baselineBytes: fs.existsSync(baselineFile) ? fs.statSync(baselineFile).size : 0,
190
- };
191
-
192
- if (json) {
193
- process.stdout.write(JSON.stringify(report, null, 2) + '\n');
194
- } else {
195
- const f = (v) => (v === null ? 'null' : v.toFixed(3));
196
- console.log(`reps=${report.reps} events/rep=${events.length} node=${report.host.node} rev=${report.revision}`);
197
- console.log(`baseline append ms p50=${f(report.baseline.p50)} p95=${f(report.baseline.p95)} p99=${f(report.baseline.p99)}`);
198
- console.log(`instrumented emit ms p50=${f(report.emit.p50)} p95=${f(report.emit.p95)} p99=${f(report.emit.p99)} stdev=${report.emit.stdev}`);
199
- console.log(`instrumented flush ms p50=${f(report.flush.p50)} p95=${f(report.flush.p95)} p99=${f(report.flush.p99)}`);
200
- console.log(`bytes/record=${report.bytesPerRecord} heapDelta=${report.heapDeltaBytes}B written=${recordsWritten}`);
201
- }
202
-
203
- fs.rmSync(tmpRoot, { recursive: true, force: true });
204
- }
@@ -1,238 +0,0 @@
1
- #!/usr/bin/env node
2
- // Gold-task benchmark harness skeleton (SPEC §10).
3
- // Loads a corpus of {id, prompt, expectedMode, expectedAnchors[], maxTokens}
4
- // cases, runs routeTask + compileContext per case, prints a scorecard with a
5
- // totals block, optionally writes an aggregated JSON scorecard (--json <path>),
6
- // and exits 0 only when every case passes. CI-3xx gates on the JSON summary.
7
-
8
- import fs from 'node:fs/promises';
9
- import path from 'node:path';
10
- import { fileURLToPath } from 'node:url';
11
-
12
- const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
13
- const DEFAULT_CORPUS = path.join(REPO_ROOT, 'scripts', 'bench', 'goldTasks.json');
14
-
15
- const USAGE = 'usage: runGold.mjs [--corpus <path>] [--project <dir>] [--json <path>]';
16
-
17
- function parseArgs(argv) {
18
- let corpusPath = DEFAULT_CORPUS;
19
- let projectRoot = REPO_ROOT;
20
- let jsonPath = null;
21
- const valueFor = (flag, i) => {
22
- const next = argv[i + 1];
23
- if (next === undefined || next.startsWith('--')) {
24
- throw new Error(`missing value for ${flag}`);
25
- }
26
- return next;
27
- };
28
- for (let i = 0; i < argv.length; i += 1) {
29
- if (argv[i] === '--corpus') {
30
- corpusPath = path.resolve(valueFor('--corpus', i));
31
- i += 1;
32
- } else if (argv[i] === '--project') {
33
- projectRoot = path.resolve(valueFor('--project', i));
34
- i += 1;
35
- } else if (argv[i] === '--json') {
36
- jsonPath = path.resolve(valueFor('--json', i));
37
- i += 1;
38
- } else {
39
- throw new Error(`unknown argument: ${argv[i]}`);
40
- }
41
- }
42
- return { corpusPath, projectRoot, jsonPath };
43
- }
44
-
45
- async function loadCorpus(corpusPath) {
46
- const raw = await fs.readFile(corpusPath, 'utf8');
47
- const cases = JSON.parse(raw);
48
- if (!Array.isArray(cases)) {
49
- throw new Error(`bench corpus must be a JSON array: ${corpusPath}`);
50
- }
51
- return cases;
52
- }
53
-
54
- function anchorPathsOf(packet) {
55
- const fromAnchors = (packet.anchors ?? []).map((a) => a.path).filter(Boolean);
56
- const fromEvidence = (packet.evidence ?? []).map((e) => e.path).filter(Boolean);
57
- return new Set([...fromAnchors, ...fromEvidence]);
58
- }
59
-
60
- function normalizeMode(mode) {
61
- return typeof mode === 'string' && mode.trim() !== '' ? mode.trim() : null;
62
- }
63
-
64
- export async function runGoldCase(projectRoot, testCase, { compileContext, routeTask }) {
65
- const failures = [];
66
- const expectedMode = normalizeMode(testCase.expectedMode);
67
- const expectedAnchors = Array.isArray(testCase.expectedAnchors) ? testCase.expectedAnchors : [];
68
- const maxTokens = typeof testCase.maxTokens === 'number' ? testCase.maxTokens : Infinity;
69
-
70
- const routed = routeTask({
71
- prompt: testCase.prompt ?? '',
72
- hasError: testCase.hasError === true,
73
- filesHinted: Array.isArray(testCase.filesHinted) ? testCase.filesHinted : undefined,
74
- flags: testCase.flags && typeof testCase.flags === 'object' ? testCase.flags : {},
75
- });
76
-
77
- const packet = await compileContext(projectRoot, {
78
- prompt: testCase.prompt ?? '',
79
- hasError: testCase.hasError === true,
80
- filesHinted: Array.isArray(testCase.filesHinted) ? testCase.filesHinted : undefined,
81
- flags: testCase.flags && typeof testCase.flags === 'object' ? testCase.flags : {},
82
- });
83
-
84
- if (expectedMode && packet.task_type !== expectedMode) {
85
- failures.push(`mode: expected ${expectedMode}, got ${packet.task_type || '(empty)'}`);
86
- }
87
-
88
- const anchorPaths = anchorPathsOf(packet);
89
- const anchorHits = [];
90
- const anchorMisses = [];
91
- for (const expected of expectedAnchors) {
92
- if (anchorPaths.has(expected)) {
93
- anchorHits.push(expected);
94
- } else {
95
- anchorMisses.push(expected);
96
- failures.push(`anchor missing: ${expected}`);
97
- }
98
- }
99
-
100
- if (packet.budget?.used > maxTokens) {
101
- failures.push(`budget: used ${packet.budget.used} > maxTokens ${maxTokens}`);
102
- }
103
-
104
- return {
105
- id: testCase.id ?? '(unnamed)',
106
- mode: packet.task_type,
107
- expectedMode,
108
- anchors: [...anchorPaths],
109
- expectedAnchors,
110
- anchorHits,
111
- anchorMisses,
112
- budgetUsed: packet.budget?.used ?? 0,
113
- maxTokens,
114
- routedMode: routed.mode,
115
- pass: failures.length === 0,
116
- failures,
117
- };
118
- }
119
-
120
- export async function runGold({ projectRoot, corpusPath } = {}) {
121
- const rootDir = projectRoot ?? REPO_ROOT;
122
- const { compileContext } = await import(path.join(REPO_ROOT, 'src/core/codeintel/compiler.js'));
123
- const { routeTask } = await import(path.join(REPO_ROOT, 'src/core/codeintel/router.js'));
124
- const { buildCodeIndex } = await import(path.join(REPO_ROOT, 'src/index/buildIndex.js'));
125
- const { INDEX_ARTIFACTS, getArtifactPath } = await import(path.join(REPO_ROOT, 'src/index/paths.js'));
126
-
127
- // The bench needs an index to score anchors; build once when missing.
128
- try {
129
- await fs.stat(getArtifactPath(rootDir, INDEX_ARTIFACTS.files));
130
- } catch {
131
- console.log('[bench] index missing — building code index first');
132
- await buildCodeIndex({ rootDir });
133
- }
134
-
135
- const cases = await loadCorpus(corpusPath ?? DEFAULT_CORPUS);
136
- const results = [];
137
- for (const testCase of cases) {
138
- results.push(await runGoldCase(rootDir, testCase, { compileContext, routeTask }));
139
- }
140
- return { results, pass: results.every((r) => r.pass) };
141
- }
142
-
143
- /**
144
- * Aggregate per-case results into a machine-readable summary for CI gating.
145
- * Pooled anchor precision = hits/found, recall = hits/expected; cases with
146
- * empty expectedAnchors contribute 0 to both denominators (excluded, not 1).
147
- * @param {Array<object>} results per-case runGoldCase outputs
148
- * @returns {{total:number, passed:number, passRate:number, modeAccuracy:number|null, anchorPrecision:number|null, anchorRecall:number|null, meanBudgetUsedPct:number|null}}
149
- */
150
- export function summarizeResults(results) {
151
- const total = results.length;
152
- const passed = results.filter((r) => r.pass).length;
153
- const passRate = total === 0 ? 0 : passed / total;
154
-
155
- const withMode = results.filter((r) => r.expectedMode);
156
- const modeCorrect = withMode.filter((r) => r.mode === r.expectedMode).length;
157
- const modeAccuracy = withMode.length === 0 ? null : modeCorrect / withMode.length;
158
-
159
- let hits = 0;
160
- let found = 0;
161
- let expected = 0;
162
- for (const r of results) {
163
- hits += Array.isArray(r.anchorHits) ? r.anchorHits.length : 0;
164
- found += Array.isArray(r.anchors) ? r.anchors.length : 0;
165
- expected += Array.isArray(r.expectedAnchors) ? r.expectedAnchors.length : 0;
166
- }
167
- const anchorPrecision = found === 0 ? null : hits / found;
168
- const anchorRecall = expected === 0 ? null : hits / expected;
169
-
170
- const budgeted = results.filter((r) => typeof r.maxTokens === 'number' && r.maxTokens > 0);
171
- const meanBudgetUsedPct = budgeted.length === 0
172
- ? null
173
- : budgeted.reduce((acc, r) => acc + (r.budgetUsed ?? 0) / r.maxTokens, 0) / budgeted.length;
174
-
175
- return { total, passed, passRate, modeAccuracy, anchorPrecision, anchorRecall, meanBudgetUsedPct };
176
- }
177
-
178
- /**
179
- * Write { generatedAt, results, summary } scorecard JSON to `jsonPath` (pretty-printed).
180
- * @param {string} jsonPath output file path
181
- * @param {Array<object>} results per-case runGoldCase outputs
182
- */
183
- export async function writeScorecardJson(jsonPath, results) {
184
- const payload = {
185
- generatedAt: new Date().toISOString(),
186
- results,
187
- summary: summarizeResults(results),
188
- };
189
- await fs.writeFile(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
190
- }
191
-
192
- function fmtPct(value) {
193
- return value === null ? 'n/a' : `${(value * 100).toFixed(1)}%`;
194
- }
195
-
196
- export function printScorecard(results) {
197
- console.log('id | mode | expected | anchors | budget | verdict');
198
- console.log('---|------|----------|---------|--------|--------');
199
- for (const r of results) {
200
- console.log(
201
- `${r.id} | ${r.mode || '-'} | ${r.expectedMode || '-'} | ${r.anchors.length} | ${r.budgetUsed}/${r.maxTokens === Infinity ? '∞' : r.maxTokens} | ${r.pass ? 'PASS' : 'FAIL'}`,
202
- );
203
- for (const failure of r.failures) {
204
- console.log(` ! ${failure}`);
205
- }
206
- }
207
- const s = summarizeResults(results);
208
- console.log(`scorecard: ${s.passed}/${s.total} passed`);
209
- console.log(
210
- `totals: passRate: ${fmtPct(s.passRate)} | modeAccuracy: ${fmtPct(s.modeAccuracy)} | anchorPrecision: ${fmtPct(s.anchorPrecision)} | anchorRecall: ${fmtPct(s.anchorRecall)} | meanBudgetUsedPct: ${fmtPct(s.meanBudgetUsedPct)}`,
211
- );
212
- }
213
-
214
- const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
215
- if (isMain) {
216
- let parsed;
217
- try {
218
- parsed = parseArgs(process.argv.slice(2));
219
- } catch (error) {
220
- console.error(`${USAGE}\n${error?.message ?? error}`);
221
- process.exit(2);
222
- }
223
- const { corpusPath, projectRoot, jsonPath } = parsed;
224
- try {
225
- const { results, pass } = await runGold({ projectRoot, corpusPath });
226
- printScorecard(results);
227
- if (jsonPath) {
228
- await writeScorecardJson(jsonPath, results);
229
- console.log(`[bench] scorecard JSON written: ${jsonPath}`);
230
- }
231
- if (!pass) {
232
- process.exitCode = 1;
233
- }
234
- } catch (error) {
235
- console.error(`[bench] ${error?.message ?? error}`);
236
- process.exitCode = 1;
237
- }
238
- }