simt-dev 0.6.3 → 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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  13. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  18. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. 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
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -17,17 +17,30 @@
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';
26
25
  import { dirname, join, resolve } from 'node:path';
27
26
 
28
- import { STATE_DIR } from '../name.mjs';
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,62 +50,43 @@ 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>>}
93
87
  */
94
88
  export async function readLedgerEntries(workDir) {
95
- const ledgerPath = join(workDir, STATE_DIR, 'ledger.jsonl');
89
+ const ledgerPath = join(workDir, LOCAL_DIR, 'ledger.jsonl');
96
90
  try {
97
91
  const text = await readFile(ledgerPath, 'utf8');
98
92
  const entries = [];
@@ -112,10 +106,10 @@ 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
- const ledgerPath = join(workDir, STATE_DIR, 'ledger.jsonl');
112
+ const ledgerPath = join(workDir, LOCAL_DIR, 'ledger.jsonl');
119
113
  await mkdir(dirname(ledgerPath), { recursive: true });
120
114
  const treeRes = await runGit(['write-tree'], { cwd: workDir });
121
115
  const record = {
@@ -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,13 +1140,12 @@ 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>>}
1150
1147
  */
1151
- export async function loadProbeRecords(root) {
1152
- const probesRoot = join(resolve(root), STATE_DIR, 'bench', 'probes');
1148
+ export async function loadProbeRecords(root) { const probesRoot = join(resolve(root), LOCAL_DIR, 'bench', 'probes');
1153
1149
  if (!existsSync(probesRoot)) return [];
1154
1150
 
1155
1151
  const entries = await readdir(probesRoot, { withFileTypes: true }).catch(() => []);
@@ -1181,13 +1177,16 @@ export async function loadProbeRecords(root) {
1181
1177
  */
1182
1178
  export function formatProbesMarkdown(report) {
1183
1179
  const lines = [
1184
- '| Arm | Probes | Passed | Compliance |',
1185
- '| --- | ---: | ---: | ---: |',
1180
+ '| Arm | Probes | Passed | Compliance | Basis |',
1181
+ '| --- | ---: | ---: | ---: | --- |',
1186
1182
  ];
1187
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).
1188
1186
  for (const arm of report.arms) {
1187
+ const basis = arm.measured ? 'agent' : 'grader calibration';
1189
1188
  lines.push(
1190
- `| ${arm.arm} | ${arm.total} | ${arm.passed} | ${(arm.complianceRate * 100).toFixed(1)}% |`,
1189
+ `| ${arm.arm} | ${arm.total} | ${arm.passed} | ${(arm.complianceRate * 100).toFixed(1)}% | ${basis} |`,
1191
1190
  );
1192
1191
  }
1193
1192
 
@@ -1221,9 +1220,23 @@ export async function runBenchProbes(
1221
1220
  probes: probeFilter = [],
1222
1221
  harness = 'gold',
1223
1222
  reps = 1,
1223
+ model,
1224
+ timeoutMs = PROBE_AGENT_TIMEOUT_MS,
1225
+ adapters: adapterTable,
1224
1226
  } = {},
1225
1227
  ) {
1226
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');
1227
1240
  const selectedProbes =
1228
1241
  Array.isArray(probeFilter) && probeFilter.length > 0
1229
1242
  ? PROBES.filter((p) => probeFilter.includes(p.id))
@@ -1242,7 +1255,7 @@ export async function runBenchProbes(
1242
1255
  for (let rep = 1; rep <= reps; rep += 1) {
1243
1256
  const runDir = join(
1244
1257
  resolvedRoot,
1245
- STATE_DIR,
1258
+ LOCAL_DIR,
1246
1259
  'bench',
1247
1260
  'probes',
1248
1261
  `${harness}-${armSpec.name}-r${rep}`,
@@ -1253,14 +1266,28 @@ export async function runBenchProbes(
1253
1266
  const ws = await prepareProbeWorkspace(probe, { armSpec });
1254
1267
  try {
1255
1268
  let outcome;
1256
- if (harness === 'gold') {
1257
- outcome = await probe.comply(ws.workDir, ws);
1258
- } else if (harness === 'violator' || harness === 'noop') {
1259
- 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);
1260
1276
  } else {
1261
- throw new Error(
1262
- `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 },
1263
1285
  );
1286
+ const transcript = executed.transcript ?? '';
1287
+ outcome = {
1288
+ commands: extractTranscriptCommands({ stdout: transcript }),
1289
+ stdout: transcript,
1290
+ };
1264
1291
  }
1265
1292
 
1266
1293
  const grade = await gradeProbe(probe, {
@@ -1279,7 +1306,10 @@ export async function runBenchProbes(
1279
1306
  adr: probe.adr,
1280
1307
  arm: armSpec.name,
1281
1308
  harness,
1309
+ model: resolvedModel,
1282
1310
  rep,
1311
+ measured,
1312
+ isolation,
1283
1313
  passed: grade.passed,
1284
1314
  reason: grade.reason,
1285
1315
  };
@@ -1301,6 +1331,9 @@ export async function runBenchProbes(
1301
1331
  const byArm = new Map();
1302
1332
  for (const rec of allRecords) {
1303
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;
1304
1337
  const list = byArm.get(rec.arm) ?? [];
1305
1338
  list.push(rec);
1306
1339
  byArm.set(rec.arm, list);
@@ -1314,13 +1347,16 @@ export async function runBenchProbes(
1314
1347
  const complianceRate = total > 0 ? passed / total : 0;
1315
1348
  const results = {};
1316
1349
  for (const r of list) {
1317
- 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 };
1318
1353
  }
1319
1354
  return {
1320
1355
  arm,
1321
1356
  total,
1322
1357
  passed,
1323
1358
  complianceRate,
1359
+ measured: list.every((r) => r.measured === true),
1324
1360
  results,
1325
1361
  };
1326
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
+ }