simt-dev 0.7.0 → 0.7.1

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 (112) hide show
  1. package/CHANGELOG.md +158 -872
  2. package/README.md +19 -133
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  6. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  7. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  8. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  9. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  10. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  11. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  12. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  13. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  14. package/package.json +10 -2
  15. package/rules/core.md +7 -0
  16. package/rules/mdc/slice-commit.mdc +57 -4
  17. package/src/agenthooks/prompt.mjs +5 -2
  18. package/src/agenthooks/shim.mjs +1 -1
  19. package/src/api.mjs +29 -0
  20. package/src/argv.mjs +29 -3
  21. package/src/bench/adapters.json +16 -0
  22. package/src/bench/corpus.mjs +150 -0
  23. package/src/bench/grade.mjs +23 -49
  24. package/src/bench/mine.mjs +199 -35
  25. package/src/bench/probes.mjs +99 -62
  26. package/src/bench/process.mjs +128 -0
  27. package/src/bench/report.mjs +17 -4
  28. package/src/bench/run.mjs +242 -142
  29. package/src/brief/assemble.mjs +38 -3
  30. package/src/ci/workflow.mjs +30 -7
  31. package/src/cli.mjs +45 -0
  32. package/src/commands/agent.mjs +3 -4
  33. package/src/commands/agenthook.mjs +4 -5
  34. package/src/commands/bench.mjs +190 -17
  35. package/src/commands/brief.mjs +5 -4
  36. package/src/commands/ci.mjs +2 -1
  37. package/src/commands/compare.mjs +42 -42
  38. package/src/commands/completions.mjs +2 -6
  39. package/src/commands/decide.mjs +4 -4
  40. package/src/commands/distill.mjs +4 -3
  41. package/src/commands/doctor.mjs +11 -7
  42. package/src/commands/evidence.mjs +6 -9
  43. package/src/commands/explain.mjs +0 -1
  44. package/src/commands/gate.mjs +16 -12
  45. package/src/commands/graph.mjs +2 -3
  46. package/src/commands/harness.mjs +3 -2
  47. package/src/commands/help.mjs +125 -17
  48. package/src/commands/hooks.mjs +100 -30
  49. package/src/commands/ignore.mjs +2 -1
  50. package/src/commands/impact.mjs +4 -7
  51. package/src/commands/index.mjs +273 -188
  52. package/src/commands/init.mjs +2 -1
  53. package/src/commands/lease.mjs +6 -7
  54. package/src/commands/lessons.mjs +10 -9
  55. package/src/commands/local.mjs +3 -3
  56. package/src/commands/map.mjs +14 -9
  57. package/src/commands/mcp.mjs +0 -1
  58. package/src/commands/pack.mjs +9 -6
  59. package/src/commands/plan.mjs +6 -5
  60. package/src/commands/plugin.mjs +2 -2
  61. package/src/commands/prbody.mjs +3 -3
  62. package/src/commands/repoindex.mjs +4 -3
  63. package/src/commands/review.mjs +2 -2
  64. package/src/commands/rules.mjs +33 -16
  65. package/src/commands/shared.mjs +2 -3
  66. package/src/commands/skills.mjs +28 -26
  67. package/src/commands/skillscompile.mjs +4 -5
  68. package/src/commands/skillsinstall.mjs +2 -2
  69. package/src/commands/slice.mjs +21 -10
  70. package/src/commands/spec.mjs +151 -0
  71. package/src/commands/status.mjs +4 -7
  72. package/src/commands/sync.mjs +13 -11
  73. package/src/commands/tests.mjs +6 -9
  74. package/src/commands/tokens.mjs +2 -2
  75. package/src/commands/undo.mjs +5 -15
  76. package/src/commands/upgrade.mjs +7 -3
  77. package/src/commands/why.mjs +9 -8
  78. package/src/compare/row.mjs +0 -2
  79. package/src/context.mjs +64 -0
  80. package/src/decisions/index-doc.mjs +46 -0
  81. package/src/diff.mjs +1 -1
  82. package/src/distill/distill.mjs +14 -1
  83. package/src/distill/engine.mjs +5 -1
  84. package/src/graph/load.mjs +4 -1
  85. package/src/hooks/capability.mjs +3 -3
  86. package/src/hooks/dispatcher.mjs +198 -43
  87. package/src/hooks/gates.mjs +0 -43
  88. package/src/hooks/resolve.mjs +2 -7
  89. package/src/hooks/shim.mjs +5 -2
  90. package/src/identity.mjs +29 -1
  91. package/src/journal.mjs +78 -9
  92. package/src/ledger/index.mjs +30 -1
  93. package/src/local/client.mjs +4 -6
  94. package/src/locate.mjs +33 -9
  95. package/src/manifest/schema.mjs +213 -0
  96. package/src/mcp/resources.mjs +7 -10
  97. package/src/mcp/server.mjs +70 -16
  98. package/src/mcp/tools.mjs +45 -7
  99. package/src/measure/session.mjs +3 -2
  100. package/src/node-version.mjs +37 -0
  101. package/src/output/index.mjs +111 -3
  102. package/src/packs/manage.mjs +17 -3
  103. package/src/packs.mjs +20 -2
  104. package/src/projection/lock.mjs +11 -1
  105. package/src/projection/manifest.mjs +20 -34
  106. package/src/projection/rules.mjs +4 -3
  107. package/src/repo-root.mjs +19 -0
  108. package/src/resolve/fingerprint.mjs +19 -15
  109. package/src/review/critic.mjs +2 -1
  110. package/src/toolwords.mjs +3 -0
  111. package/src/workspace.mjs +55 -10
  112. package/docs/decisions/README.md +0 -600
@@ -17,9 +17,8 @@
17
17
  // Each probe has a deterministic grader that reads:
18
18
  // 1. the final git state in the isolated workspace (and local bare remote where applicable),
19
19
  // 2. the commands recorded in the transcript, and
20
- // 3. the evidence ledger (`.simt-dev/ledger.jsonl`, R17).
20
+ // 3. the evidence ledger (`.simt/local/ledger.jsonl`, R17).
21
21
 
22
- import { spawn } from 'node:child_process';
23
22
  import { existsSync } from 'node:fs';
24
23
  import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
25
24
  import { tmpdir } from 'node:os';
@@ -27,7 +26,21 @@ import { dirname, join, resolve } from 'node:path';
27
26
 
28
27
  import { LOCAL_DIR } from '../state.mjs';
29
28
  import { parse as parseRegion } from '../projection/markers.mjs';
30
- import { materialiseArm, parseArmSpec } from './run.mjs';
29
+ import { spawnBounded } from './process.mjs';
30
+ import { adapterIsolation, executeAdapter, loadAdapters, materialiseArm, parseArmSpec } from './run.mjs';
31
+
32
+ /**
33
+ * Harnesses that script a workspace instead of driving an agent.
34
+ *
35
+ * They answer a different question — "does the grader accept compliant work and reject
36
+ * violating work" — and they answer it perfectly every time by construction, so their
37
+ * compliance rate is constant and says nothing about an arm. A record from one of these
38
+ * is a grader calibration, never a measurement (review 0003 Q49).
39
+ */
40
+ export const CALIBRATION_HARNESSES = new Set(['gold', 'noop', 'violator']);
41
+
42
+ /** How long one agent-backed probe may run before its workspace is reclaimed. */
43
+ const PROBE_AGENT_TIMEOUT_MS = 120_000;
31
44
 
32
45
  function sanitizedEnv(extra = {}) {
33
46
  const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', ...extra };
@@ -37,56 +50,37 @@ function sanitizedEnv(extra = {}) {
37
50
  return env;
38
51
  }
39
52
 
40
- function runGit(args, { cwd, env = {} } = {}) {
41
- return new Promise((resolveOutcome) => {
42
- const child = spawn('git', args, {
43
- cwd,
44
- env: sanitizedEnv(env),
45
- stdio: ['ignore', 'pipe', 'pipe'],
46
- });
47
- const out = [];
48
- const err = [];
49
- child.stdout.on('data', (c) => out.push(c));
50
- child.stderr.on('data', (c) => err.push(c));
51
- child.on('error', (error) =>
52
- resolveOutcome({ code: 1, stdout: '', stderr: String(error.message ?? error) }),
53
- );
54
- child.on('close', (code) =>
55
- resolveOutcome({
56
- code: code ?? 1,
57
- stdout: Buffer.concat(out).toString('utf8'),
58
- stderr: Buffer.concat(err).toString('utf8'),
59
- }),
60
- );
53
+ /** The deadline for one workspace-side git or node command inside a probe. */
54
+ const PROBE_COMMAND_TIMEOUT_MS = 120_000;
55
+
56
+ async function runGit(args, { cwd, env = {} } = {}) {
57
+ const outcome = await spawnBounded('git', args, {
58
+ cwd,
59
+ env: sanitizedEnv(env),
60
+ timeoutMs: PROBE_COMMAND_TIMEOUT_MS,
61
61
  });
62
+ return {
63
+ code: outcome.code,
64
+ stdout: outcome.stdout.toString('utf8'),
65
+ stderr: outcome.stderr.toString('utf8'),
66
+ };
62
67
  }
63
68
 
64
- function runNode(args, { cwd, env = {} } = {}) {
65
- return new Promise((resolveOutcome) => {
66
- const child = spawn(process.execPath, args, {
67
- cwd,
68
- env: sanitizedEnv(env),
69
- stdio: ['ignore', 'pipe', 'pipe'],
70
- });
71
- const out = [];
72
- const err = [];
73
- child.stdout.on('data', (c) => out.push(c));
74
- child.stderr.on('data', (c) => err.push(c));
75
- child.on('error', (error) =>
76
- resolveOutcome({ code: 1, stdout: '', stderr: String(error.message ?? error) }),
77
- );
78
- child.on('close', (code) =>
79
- resolveOutcome({
80
- code: code ?? 1,
81
- stdout: Buffer.concat(out).toString('utf8'),
82
- stderr: Buffer.concat(err).toString('utf8'),
83
- }),
84
- );
69
+ async function runNode(args, { cwd, env = {} } = {}) {
70
+ const outcome = await spawnBounded(process.execPath, args, {
71
+ cwd,
72
+ env: sanitizedEnv(env),
73
+ timeoutMs: PROBE_COMMAND_TIMEOUT_MS,
85
74
  });
75
+ return {
76
+ code: outcome.code,
77
+ stdout: outcome.stdout.toString('utf8'),
78
+ stderr: outcome.stderr.toString('utf8'),
79
+ };
86
80
  }
87
81
 
88
82
  /**
89
- * Read `.simt-dev/ledger.jsonl` from `workDir` if present.
83
+ * Read `.simt/local/ledger.jsonl` from `workDir` if present.
90
84
  *
91
85
  * @param {string} workDir
92
86
  * @returns {Promise<Array<object>>}
@@ -112,7 +106,7 @@ export async function readLedgerEntries(workDir) {
112
106
  }
113
107
 
114
108
  /**
115
- * Append a ledger entry to `.simt-dev/ledger.jsonl` in `workDir`.
109
+ * Append a ledger entry to `.simt/local/ledger.jsonl` in `workDir`.
116
110
  */
117
111
  export async function appendLedgerEntry(workDir, entry) {
118
112
  const ledgerPath = join(workDir, LOCAL_DIR, 'ledger.jsonl');
@@ -609,7 +603,7 @@ export const PROBES = [
609
603
  return {
610
604
  probeId: this.id,
611
605
  passed: false,
612
- reason: 'no test command was executed in transcript or recorded in .simt-dev/ledger.jsonl',
606
+ reason: 'no test command was executed in transcript or recorded in .simt/local/ledger.jsonl',
613
607
  };
614
608
  }
615
609
  const verify = await runNode(['--test', 'test/calc.test.mjs'], { cwd: workDir });
@@ -1092,13 +1086,16 @@ export async function prepareProbeWorkspace(probe, { armSpec = null, parentDir }
1092
1086
 
1093
1087
  await probe.setup(workDir, { slotDir });
1094
1088
 
1089
+ await runGit(['init', '-q'], { cwd: workDir });
1090
+ await runGit(['config', 'user.email', 'bench@example.invalid'], { cwd: workDir });
1091
+ await runGit(['config', 'user.name', 'bench-probe'], { cwd: workDir });
1092
+
1095
1093
  if (armSpec && probe.id !== 'p05-generated-region-edit') {
1096
1094
  await materialiseArm(workDir, armSpec, { id: probe.id, prompt: probe.prompt });
1095
+ // Same rule as a mined run: the packet's index is tool state, not part of the arm.
1096
+ await rm(join(workDir, LOCAL_DIR), { recursive: true, force: true }).catch(() => {});
1097
1097
  }
1098
1098
 
1099
- await runGit(['init', '-q'], { cwd: workDir });
1100
- await runGit(['config', 'user.email', 'bench@example.invalid'], { cwd: workDir });
1101
- await runGit(['config', 'user.name', 'bench-probe'], { cwd: workDir });
1102
1099
  await runGit(['add', '-A'], { cwd: workDir });
1103
1100
  await runGit(['commit', '-qm', `base: ${probe.id}`, '--no-verify'], { cwd: workDir });
1104
1101
  const headRes = await runGit(['rev-parse', 'HEAD'], { cwd: workDir });
@@ -1143,7 +1140,7 @@ export async function gradeProbe(probeOrId, ctx) {
1143
1140
  }
1144
1141
 
1145
1142
  /**
1146
- * Load stored probe result records from `.simt-dev/bench/probes/<runDir>/<probeId>.json`.
1143
+ * Load stored probe result records from `.simt/local/bench/probes/<runDir>/<probeId>.json`.
1147
1144
  *
1148
1145
  * @param {string} root
1149
1146
  * @returns {Promise<Array<object>>}
@@ -1180,13 +1177,16 @@ export async function loadProbeRecords(root) { const probesRoot = join(resolv
1180
1177
  */
1181
1178
  export function formatProbesMarkdown(report) {
1182
1179
  const lines = [
1183
- '| Arm | Probes | Passed | Compliance |',
1184
- '| --- | ---: | ---: | ---: |',
1180
+ '| Arm | Probes | Passed | Compliance | Basis |',
1181
+ '| --- | ---: | ---: | ---: | --- |',
1185
1182
  ];
1186
1183
 
1184
+ // The basis is a column rather than a footnote because a calibration rate and a
1185
+ // measured rate are the same four characters apart (review 0003 Q49).
1187
1186
  for (const arm of report.arms) {
1187
+ const basis = arm.measured ? 'agent' : 'grader calibration';
1188
1188
  lines.push(
1189
- `| ${arm.arm} | ${arm.total} | ${arm.passed} | ${(arm.complianceRate * 100).toFixed(1)}% |`,
1189
+ `| ${arm.arm} | ${arm.total} | ${arm.passed} | ${(arm.complianceRate * 100).toFixed(1)}% | ${basis} |`,
1190
1190
  );
1191
1191
  }
1192
1192
 
@@ -1220,9 +1220,23 @@ export async function runBenchProbes(
1220
1220
  probes: probeFilter = [],
1221
1221
  harness = 'gold',
1222
1222
  reps = 1,
1223
+ model,
1224
+ timeoutMs = PROBE_AGENT_TIMEOUT_MS,
1225
+ adapters: adapterTable,
1223
1226
  } = {},
1224
1227
  ) {
1225
1228
  const resolvedRoot = resolve(root);
1229
+ const calibration = CALIBRATION_HARNESSES.has(harness);
1230
+ const adapters = calibration ? null : (adapterTable ?? (await loadAdapters()));
1231
+ const adapter = calibration ? null : adapters[harness];
1232
+ if (!calibration && !adapter) {
1233
+ throw new Error(
1234
+ `unknown probe harness "${harness}"; known harnesses: ${[...CALIBRATION_HARNESSES].sort().join(', ')}, ${Object.keys(adapters).sort().join(', ')}`,
1235
+ );
1236
+ }
1237
+ const measured = !calibration;
1238
+ const isolation = calibration ? 'none' : adapterIsolation(adapter);
1239
+ const resolvedModel = model ?? (calibration ? harness : 'default');
1226
1240
  const selectedProbes =
1227
1241
  Array.isArray(probeFilter) && probeFilter.length > 0
1228
1242
  ? PROBES.filter((p) => probeFilter.includes(p.id))
@@ -1252,14 +1266,28 @@ export async function runBenchProbes(
1252
1266
  const ws = await prepareProbeWorkspace(probe, { armSpec });
1253
1267
  try {
1254
1268
  let outcome;
1255
- if (harness === 'gold') {
1256
- outcome = await probe.comply(ws.workDir, ws);
1257
- } else if (harness === 'violator' || harness === 'noop') {
1258
- outcome = await probe.violate(ws.workDir, ws);
1269
+ if (calibration) {
1270
+ // `gold` runs the compliant script, `violator` and `noop` the violating one:
1271
+ // the pair proves the grader both accepts and rejects, nothing more.
1272
+ outcome =
1273
+ harness === 'gold'
1274
+ ? await probe.comply(ws.workDir, ws)
1275
+ : await probe.violate(ws.workDir, ws);
1259
1276
  } else {
1260
- throw new Error(
1261
- `unknown probe harness "${harness}"; supported built-in probe harnesses are gold and violator`,
1277
+ // A real harness gets a real workspace and the probe's own prompt. Grading
1278
+ // reads the workspace and the transcript, so the agent's own commands are
1279
+ // what decide compliance.
1280
+ const executed = await executeAdapter(
1281
+ adapter,
1282
+ ws.workDir,
1283
+ { id: probe.id, prompt: probe.prompt },
1284
+ { model: resolvedModel, env: {}, timeoutMs, slotDir: ws.slotDir },
1262
1285
  );
1286
+ const transcript = executed.transcript ?? '';
1287
+ outcome = {
1288
+ commands: extractTranscriptCommands({ stdout: transcript }),
1289
+ stdout: transcript,
1290
+ };
1263
1291
  }
1264
1292
 
1265
1293
  const grade = await gradeProbe(probe, {
@@ -1278,7 +1306,10 @@ export async function runBenchProbes(
1278
1306
  adr: probe.adr,
1279
1307
  arm: armSpec.name,
1280
1308
  harness,
1309
+ model: resolvedModel,
1281
1310
  rep,
1311
+ measured,
1312
+ isolation,
1282
1313
  passed: grade.passed,
1283
1314
  reason: grade.reason,
1284
1315
  };
@@ -1300,6 +1331,9 @@ export async function runBenchProbes(
1300
1331
  const byArm = new Map();
1301
1332
  for (const rec of allRecords) {
1302
1333
  if (Array.isArray(arms) && arms.length > 0 && !arms.includes(rec.arm)) continue;
1334
+ // A stored record belongs to the harness that produced it. Pooling a violator run
1335
+ // into a gold summary reported compliance for a measurement never taken.
1336
+ if (rec.harness && rec.harness !== harness) continue;
1303
1337
  const list = byArm.get(rec.arm) ?? [];
1304
1338
  list.push(rec);
1305
1339
  byArm.set(rec.arm, list);
@@ -1313,13 +1347,16 @@ export async function runBenchProbes(
1313
1347
  const complianceRate = total > 0 ? passed / total : 0;
1314
1348
  const results = {};
1315
1349
  for (const r of list) {
1316
- results[r.probe] = { passed: r.passed, reason: r.reason };
1350
+ // Keyed by rep as well as probe: two reps of one probe are two results, and the
1351
+ // old key kept only whichever rep was read last (review 0003 Q59).
1352
+ results[`${r.probe}#r${r.rep ?? 1}`] = { passed: r.passed, reason: r.reason };
1317
1353
  }
1318
1354
  return {
1319
1355
  arm,
1320
1356
  total,
1321
1357
  passed,
1322
1358
  complianceRate,
1359
+ measured: list.every((r) => r.measured === true),
1323
1360
  results,
1324
1361
  };
1325
1362
  });
@@ -0,0 +1,128 @@
1
+ // One bounded child process, for everything the bench runs (review 0003 Q47).
2
+ //
3
+ // `child.kill('SIGKILL')` kills the process the bench started and leaves its
4
+ // grandchildren running: an adapter that ran `(sleep 15 &); sleep 60` kept the pipe open
5
+ // and the bench waited 62.6 s against a 2 s timeout, then graded the result. A detached
6
+ // child is its own process group, so the deadline kills the group, destroys the streams
7
+ // and settles. A run cannot outlive its own deadline, whatever it spawned.
8
+ //
9
+ // Output is bounded so a runaway agent cannot decide the bench's memory: past the cap the
10
+ // oldest chunk is dropped and `truncated` says so, rather than the spawn failing.
11
+
12
+ import { spawn } from 'node:child_process';
13
+
14
+ /** The most output one run may accumulate before the oldest bytes are dropped. */
15
+ const DEFAULT_MAX_OUTPUT_BYTES = 8 << 20;
16
+
17
+ /** How long to wait for streams to close after the process group is killed. */
18
+ const HARD_STOP_GRACE_MS = 500;
19
+
20
+ function boundedPush(chunks, state, chunk) {
21
+ state.bytes += chunk.length;
22
+ chunks.push(chunk);
23
+ while (state.bytes > state.max && chunks.length > 1) {
24
+ state.bytes -= chunks.shift().length;
25
+ state.truncated = true;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Run `cmd` with `args`, bounded by a deadline and an output cap.
31
+ *
32
+ * @param {string} cmd
33
+ * @param {string[]} args
34
+ * @param {{cwd?: string, env?: NodeJS.ProcessEnv, input?: Buffer|string|null, timeoutMs?: number, maxOutputBytes?: number}} [options]
35
+ * @returns {Promise<{code: number, timedOut: boolean, wallMs: number, stdout: Buffer, stderr: Buffer, truncated: boolean, spawnError: string|null}>}
36
+ * `code` is 124 when the deadline fired, and `wallMs` is what the run actually took.
37
+ */
38
+ export function spawnBounded(
39
+ cmd,
40
+ args,
41
+ { cwd, env, input = null, timeoutMs = 0, maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES } = {},
42
+ ) {
43
+ return new Promise((settle) => {
44
+ const startedAt = Date.now();
45
+ let child;
46
+ try {
47
+ child = spawn(cmd, args, {
48
+ cwd,
49
+ env,
50
+ stdio: ['pipe', 'pipe', 'pipe'],
51
+ detached: true,
52
+ });
53
+ } catch (error) {
54
+ settle({
55
+ code: 1,
56
+ timedOut: false,
57
+ wallMs: 0,
58
+ stdout: Buffer.alloc(0),
59
+ stderr: Buffer.from(String(error?.message ?? error)),
60
+ truncated: false,
61
+ spawnError: String(error?.message ?? error),
62
+ });
63
+ return;
64
+ }
65
+
66
+ const out = [];
67
+ const err = [];
68
+ const outState = { bytes: 0, max: maxOutputBytes, truncated: false };
69
+ const errState = { bytes: 0, max: maxOutputBytes, truncated: false };
70
+ let timer = null;
71
+ let hardStop = null;
72
+ let timedOut = false;
73
+ let done = false;
74
+
75
+ const killGroup = () => {
76
+ try {
77
+ process.kill(-child.pid, 'SIGKILL');
78
+ } catch {
79
+ try {
80
+ child.kill('SIGKILL');
81
+ } catch {
82
+ // Already gone; the close handler settles the promise.
83
+ }
84
+ }
85
+ };
86
+
87
+ const finish = (code, spawnError = null) => {
88
+ if (done) return;
89
+ done = true;
90
+ if (timer) clearTimeout(timer);
91
+ if (hardStop) clearTimeout(hardStop);
92
+ child.stdout?.destroy();
93
+ child.stderr?.destroy();
94
+ settle({
95
+ code,
96
+ timedOut,
97
+ wallMs: Date.now() - startedAt,
98
+ stdout: Buffer.concat(out),
99
+ stderr: Buffer.concat(err),
100
+ truncated: outState.truncated || errState.truncated,
101
+ spawnError,
102
+ });
103
+ };
104
+
105
+ if (timeoutMs > 0) {
106
+ timer = setTimeout(() => {
107
+ timedOut = true;
108
+ killGroup();
109
+ // A process that escaped the group can hold the pipes open after the kill, and a
110
+ // deadline that waits on a stranger's cleanup is not a deadline.
111
+ hardStop = setTimeout(() => finish(124), HARD_STOP_GRACE_MS);
112
+ hardStop.unref?.();
113
+ }, timeoutMs);
114
+ timer.unref?.();
115
+ }
116
+
117
+ child.stdout?.on('data', (chunk) => boundedPush(out, outState, chunk));
118
+ child.stderr?.on('data', (chunk) => boundedPush(err, errState, chunk));
119
+ child.on('error', (error) => {
120
+ const message = String(error?.message ?? error);
121
+ boundedPush(err, errState, Buffer.from(message));
122
+ finish(1, message);
123
+ });
124
+ child.on('close', (code) => finish(timedOut ? 124 : (code ?? 1)));
125
+
126
+ child.stdin?.end(input ?? undefined);
127
+ });
128
+ }
@@ -1,7 +1,7 @@
1
1
  // Cost model, paired statistical tests and report generator (`simt bench report`, R03).
2
2
  //
3
- // Reads stored run records from `.simt-dev/bench/runs/` and optional token pricing
4
- // from `.simt-dev/bench/prices.json` (per million tokens: `{in, cache_write, cache_read, out}`).
3
+ // Reads stored run records from `.simt/local/bench/runs/` and optional token pricing
4
+ // from `.simt/local/bench/prices.json` (per million tokens: `{in, cache_write, cache_read, out}`).
5
5
  // Never performs network lookups. All statistical estimators (paired bootstrap 95% CIs,
6
6
  // exact sign test on task-mean R, Wilcoxon signed-rank test on task-mean C) are
7
7
  // deterministic. Reps of a task are not independent tasks: pairing happens at the
@@ -69,7 +69,7 @@ function isFlatPriceObject(obj) {
69
69
  }
70
70
 
71
71
  /**
72
- * Load `.simt-dev/bench/prices.json` if present.
72
+ * Load `.simt/local/bench/prices.json` if present.
73
73
  * Supports either a flat `{in, cache_write, cache_read, out}` object (prices per 1M tokens)
74
74
  * or a map keyed by model/harness name with an optional `"default"` entry.
75
75
  */
@@ -899,6 +899,13 @@ export function formatReportMarkdown(report) {
899
899
  );
900
900
  }
901
901
 
902
+ if (report.excluded > 0) {
903
+ lines.push('');
904
+ lines.push(
905
+ `${report.excluded} run record(s) excluded: the run errored or exceeded its deadline, so it recorded no measurement`,
906
+ );
907
+ }
908
+
902
909
  if (report.comparison) {
903
910
  const cmp = report.comparison;
904
911
  const d = cmp.deltas;
@@ -975,7 +982,12 @@ export async function buildBenchReport(
975
982
  return true;
976
983
  });
977
984
 
978
- const evaluated = filtered.map((rec) => evaluateRunRecord(rec, prices));
985
+ // A run that errored produced no measurement: averaging its zero tokens, zero diff and
986
+ // unresolved verdict into an arm reports a result nobody observed (review 0003 Q47).
987
+ const errored = filtered.filter((rec) => rec.status === 'error');
988
+ const evaluated = filtered
989
+ .filter((rec) => rec.status !== 'error')
990
+ .map((rec) => evaluateRunRecord(rec, prices));
979
991
  const byGroup = new Map();
980
992
  for (const row of evaluated) {
981
993
  const key = `${row.harness}|${row.model}|${row.arm}|${row.experiment}`;
@@ -1049,6 +1061,7 @@ export async function buildBenchReport(
1049
1061
  root: resolvedRoot,
1050
1062
  pricesLoaded: prices !== null,
1051
1063
  runCount: evaluated.length,
1064
+ excluded: errored.length,
1052
1065
  arms,
1053
1066
  comparison,
1054
1067
  };