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
package/src/bench/run.mjs CHANGED
@@ -1,22 +1,21 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
4
3
  import { tmpdir } from 'node:os';
5
4
  import { dirname, join, resolve, sep } from 'node:path';
6
5
  import { fileURLToPath } from 'node:url';
7
6
 
7
+ import { assembleBrief } from '../brief/assemble.mjs';
8
+ import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
8
9
  import { loadRegistry } from '../graph/load.mjs';
9
10
  import { extractOpenedSkills, matchSkillsForPaths } from '../graph/compile.mjs';
10
- import { matchGlob } from '../index/govern.mjs';
11
- import { formatLessonParagraph, listPromotedLessons } from '../memory/lessons.mjs';
12
11
  import { MANIFEST_FILE, STATE_DIR } from '../name.mjs';
12
+ import { LOCAL_DIR } from '../state.mjs';
13
13
  import { readManifest } from '../projection/manifest.mjs';
14
14
  import { plan } from '../projection/project.mjs';
15
15
  import { parse as parseRegion } from '../projection/markers.mjs';
16
- import { fingerprint } from '../resolve/fingerprint.mjs';
17
- import { rankSkills, tokenize } from '../resolve/resolve.mjs';
18
16
  import { gradeWorkspace } from './grade.mjs';
19
17
  import { resolveVaultDir } from './mine.mjs';
18
+ import { spawnBounded } from './process.mjs';
20
19
  import { loadTasks } from './tasks.mjs';
21
20
  import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
22
21
  import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
@@ -53,48 +52,80 @@ function sanitizedEnv(extra = {}) {
53
52
  return env;
54
53
  }
55
54
 
56
- function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
57
- return new Promise((resolveOutcome) => {
58
- const child = spawn(cmd, args, {
59
- cwd,
60
- env: sanitizedEnv(env),
61
- stdio: ['pipe', 'pipe', 'pipe'],
62
- });
63
- const out = [];
64
- const err = [];
65
- let timedOut = false;
66
- const timer =
67
- timeoutMs > 0
68
- ? setTimeout(() => {
69
- timedOut = true;
70
- child.kill('SIGKILL');
71
- }, timeoutMs)
72
- : null;
73
-
74
- child.stdout.on('data', (chunk) => out.push(chunk));
75
- child.stderr.on('data', (chunk) => err.push(chunk));
76
- child.on('error', (error) => {
77
- if (timer) clearTimeout(timer);
78
- resolveOutcome({
79
- code: 1,
80
- timedOut,
81
- stdout: Buffer.concat(out),
82
- stderr: Buffer.from(String(error.message ?? error)),
83
- });
84
- });
85
- child.on('close', (code) => {
86
- if (timer) clearTimeout(timer);
87
- resolveOutcome({
88
- code: timedOut ? 124 : (code ?? 1),
89
- timedOut,
90
- stdout: Buffer.concat(out),
91
- stderr: Buffer.concat(err),
92
- });
93
- });
55
+ /**
56
+ * What every benchmarked agent is allowed to inherit from the operator's shell.
57
+ *
58
+ * `PATH` and the locale are what a CLI needs to run at all. Nothing here names a
59
+ * credential or a state directory: those come from the adapter's own `env` list.
60
+ */
61
+ const ENV_BASELINE = [
62
+ 'PATH',
63
+ 'LANG',
64
+ 'LC_ALL',
65
+ 'LC_CTYPE',
66
+ 'TZ',
67
+ 'TERM',
68
+ 'SHELL',
69
+ 'TMPDIR',
70
+ 'TEMP',
71
+ 'TMP',
72
+ 'SystemRoot',
73
+ 'WINDIR',
74
+ 'USER',
75
+ ];
76
+
77
+ /** The isolation levels a run may record; `sandbox` is declared, not implemented. */
78
+ const ISOLATION_LEVELS = new Set(['none', 'env', 'sandbox']);
94
79
 
95
- if (input !== null) child.stdin.end(input);
96
- else child.stdin.end();
97
- });
80
+ /**
81
+ * The environment an arm's agent sees: the baseline, the adapter's declared keys,
82
+ * and nothing else.
83
+ *
84
+ * The agent used to inherit everything the operator had — `SIMT_DEV_VAULT_DIR`,
85
+ * `XDG_CACHE_HOME`, `HOME`, and every credential in the shell. A run that can read
86
+ * the vault can read the hidden tests it is graded against, and a run sharing the
87
+ * operator's HOME writes into their own configuration, so the number it produced
88
+ * described the machine rather than the arm (review 0003 Q49). `home` is the run's
89
+ * own slot, which is removed when the run ends.
90
+ *
91
+ * @param {object} adapter an entry from `adapters.json`
92
+ * @param {{home?: string, extra?: Record<string, string>, base?: NodeJS.ProcessEnv}} [options]
93
+ * @returns {Record<string, string>}
94
+ */
95
+ export function agentEnv(adapter, { home, extra = {}, base = process.env } = {}) {
96
+ const allowed = new Set([...ENV_BASELINE, ...(adapter?.env ?? [])]);
97
+ const env = {};
98
+ for (const key of allowed) {
99
+ if (typeof base[key] === 'string') env[key] = base[key];
100
+ }
101
+ for (const [key, value] of Object.entries(extra ?? {})) {
102
+ if (typeof value === 'string') env[key] = value;
103
+ }
104
+ if (typeof home === 'string' && home.length > 0) env.HOME = home;
105
+ env.SIMT_DEV_WORKSPACE = 'off';
106
+ for (const key of Object.keys(env)) {
107
+ if (key.startsWith('NODE_TEST')) delete env[key];
108
+ }
109
+ return env;
110
+ }
111
+
112
+ /**
113
+ * The isolation an adapter's runs provide, as the record states it.
114
+ *
115
+ * `none` is a built-in that scripts the workspace, `env` is a CLI driven with a
116
+ * scrubbed environment and a slot-local HOME, and `sandbox` is reserved for a
117
+ * real filesystem sandbox — nothing claims it yet, so a record never says it.
118
+ *
119
+ * @param {object} adapter
120
+ * @returns {'none'|'env'|'sandbox'}
121
+ */
122
+ export function adapterIsolation(adapter) {
123
+ const declared = adapter?.isolation;
124
+ return ISOLATION_LEVELS.has(declared) ? declared : 'none';
125
+ }
126
+
127
+ function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
128
+ return spawnBounded(cmd, args, { cwd, env: sanitizedEnv(env), input, timeoutMs });
98
129
  }
99
130
 
100
131
  function getByPath(obj, pathStr) {
@@ -119,7 +150,7 @@ function firstNumeric(obj, paths = []) {
119
150
  function emptyUsage() {
120
151
  return {
121
152
  usage: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
122
- costUsd: 0.0,
153
+ costUsd: null,
123
154
  };
124
155
  }
125
156
 
@@ -163,7 +194,7 @@ function parseJsonObject(text) {
163
194
  * @param {string|object} adapterOrId
164
195
  * @param {string} stdoutText
165
196
  * @param {string} [analyticsText]
166
- * @returns {{usage: {input: number, cacheWrite: number, cacheRead: number, output: number, turns: number, toolCalls: number}, costUsd: number}}
197
+ * @returns {{usage: {input: number, cacheWrite: number, cacheRead: number, output: number, turns: number, toolCalls: number}, costUsd: number|null}}
167
198
  */
168
199
  export function parseAdapterUsage(adapterOrId, stdoutText = '', analyticsText = '') {
169
200
  const result = emptyUsage();
@@ -192,7 +223,7 @@ export function parseAdapterUsage(adapterOrId, stdoutText = '', analyticsText =
192
223
  result.usage.output = firstNumeric(obj, fields.output) ?? 0;
193
224
  result.usage.turns = firstNumeric(obj, fields.turns) ?? 0;
194
225
  result.usage.toolCalls = firstNumeric(obj, fields.toolCalls) ?? 0;
195
- result.costUsd = firstNumeric(obj, fields.costUsd) ?? 0.0;
226
+ result.costUsd = firstNumeric(obj, fields.costUsd) ?? null;
196
227
 
197
228
  // Gemini CLI also nests per-model token totals under `stats.models.<name>.tokens`.
198
229
  const models = obj?.stats?.models;
@@ -265,7 +296,7 @@ export function parseAdapterUsage(adapterOrId, stdoutText = '', analyticsText =
265
296
  }
266
297
  }
267
298
 
268
- result.costUsd = Number(result.costUsd.toFixed(6));
299
+ if (result.costUsd !== null) result.costUsd = Number(result.costUsd.toFixed(6));
269
300
  return result;
270
301
  }
271
302
 
@@ -467,55 +498,29 @@ async function applyProjectionMode(workDir, mode) {
467
498
  }
468
499
  }
469
500
 
470
- async function writeTaskBrief(workDir, task) {
471
- const prompt = String(task?.prompt ?? '');
472
- const taskTokens = tokenize(prompt);
473
- const taskTokenSet = new Set(taskTokens);
474
- const [registry, fp, lessons] = await Promise.all([
475
- loadRegistry().catch(() => ({ skills: [] })),
476
- fingerprint(workDir).catch(() => ({ stacks: [], facts: new Set() })),
477
- listPromotedLessons(workDir).catch(() => []),
478
- ]);
479
- const skillsList = Array.isArray(registry.skills)
480
- ? registry.skills
481
- : Object.values(registry.skills ?? {});
482
- const { selected = [] } = rankSkills(skillsList, {
483
- taskTokens,
484
- paths: [],
485
- repoStack: fp.stack ?? [],
486
- });
487
- const ranked = selected.slice(0, 5);
488
-
489
- const taskPaths = Array.isArray(task?.paths) ? task.paths : [];
490
- const matchingLessons = lessons.filter((lesson) => {
491
- const lessonGlobs = Array.isArray(lesson.paths) ? lesson.paths : [];
492
- if (taskPaths.some((p) => lessonGlobs.some((glob) => matchGlob(glob, p)))) return true;
493
- if (lessonGlobs.some((glob) => prompt.includes(glob.replace(/\/\*\*$/, '')))) return true;
494
- const kw = Array.isArray(lesson.keywords) ? lesson.keywords : [];
495
- return kw.some((k) => taskTokenSet.has(String(k).toLowerCase()));
496
- });
497
-
498
- const lines = [
499
- `# Task Brief: ${task?.id ?? 'task'}`,
500
- '',
501
- `Prompt: ${prompt}`,
502
- '',
503
- '## Ranked Skills',
504
- ...(ranked.length > 0
505
- ? ranked.map((r) => `- ${r.skill.name} (score ${r.score}): ${r.skill.description ?? ''}`)
506
- : ['- (none matched)']),
507
- ...(matchingLessons.length > 0
508
- ? [
509
- '',
510
- '## Lessons',
511
- ...matchingLessons.map((l) => `- ${formatLessonParagraph(l)}`),
512
- ]
513
- : []),
514
- '',
515
- ];
516
- const briefPath = join(workDir, STATE_DIR, 'brief.md');
517
- await mkdir(dirname(briefPath), { recursive: true });
518
- await writeFile(briefPath, lines.join('\n'), 'utf8');
501
+ /**
502
+ * The `brief` arm's context: the packet `simt brief` delivers, assembled by the same
503
+ * function the command and the prompt-submit hook call.
504
+ *
505
+ * The arm used to re-implement the packet from `registry.skills` — a property the
506
+ * registry does not have, so every run reported "(none matched)" while the real
507
+ * command resolved skills — and wrote it to a file no agent was told to read. An arm
508
+ * meant to measure the packet measured a different artefact (review 0003 Q49).
509
+ */
510
+ async function buildBriefPrefix(workDir, task) {
511
+ // A packet is built from git's answer; a directory that is not a repository has none,
512
+ // and asking would print a failure into a bench log for no measurement.
513
+ if (!existsSync(join(workDir, '.git'))) return '';
514
+ const assembled = await assembleBrief({
515
+ root: workDir,
516
+ task: String(task?.prompt ?? ''),
517
+ paths: '',
518
+ budget: DEFAULT_BRIEF_BYTES,
519
+ build: true,
520
+ federate: false,
521
+ }).catch(() => null);
522
+ if (!assembled || assembled.error || assembled.cold) return '';
523
+ return assembled.packed?.text ?? '';
519
524
  }
520
525
 
521
526
  /**
@@ -524,9 +529,10 @@ async function writeTaskBrief(workDir, task) {
524
529
  * @param {string} workDir
525
530
  * @param {{name: string, kind?: string, base?: string|null, write?: Record<string, string>, remove?: string[], env?: Record<string, string>}} armSpec
526
531
  * @param {object} [task]
527
- * @returns {Promise<{env: Record<string, string>}>}
532
+ * @returns {Promise<{env: Record<string, string>, promptPrefix: string}>}
528
533
  */
529
534
  export async function materialiseArm(workDir, armSpec, task = {}) {
535
+ let promptPrefix = '';
530
536
  const ablate = armSpec.ablate ?? parseAblationArmName(armSpec.name)?.ablate ?? null;
531
537
  const variant = armSpec.variant ?? parseVariantArmName(armSpec.name)?.variant ?? null;
532
538
  const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
@@ -542,7 +548,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
542
548
  await applyProjectionMode(workDir, 'kernel');
543
549
  } else if (base === 'brief') {
544
550
  await applyProjectionMode(workDir, 'full');
545
- await writeTaskBrief(workDir, task);
551
+ promptPrefix = await buildBriefPrefix(workDir, task);
546
552
  }
547
553
 
548
554
  for (const relPath of armSpec.remove ?? []) {
@@ -556,7 +562,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
556
562
  await writeFile(target, String(content), 'utf8');
557
563
  }
558
564
 
559
- return { env: { ...(armSpec.env ?? {}) } };
565
+ return { env: { ...(armSpec.env ?? {}) }, promptPrefix };
560
566
  }
561
567
 
562
568
  /**
@@ -569,7 +575,7 @@ export async function materialiseArm(workDir, armSpec, task = {}) {
569
575
  * @param {object} armSpec
570
576
  * @param {object} [task]
571
577
  * @param {{parentDir?: string}} [options]
572
- * @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string>}>}
578
+ * @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string>, promptPrefix: string}>}
573
579
  */
574
580
  export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}, { parentDir } = {}) {
575
581
  const slotDir = await mkdtemp(join(parentDir ?? tmpdir(), 'bench-run-'));
@@ -618,12 +624,18 @@ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}
618
624
  }
619
625
  }
620
626
 
621
- const { env } = await materialiseArm(workDir, armSpec, task);
622
-
623
627
  await runCmd('git', ['init', '-q'], { cwd: workDir });
624
628
  await runCmd('git', ['config', 'user.name', 'bench'], { cwd: workDir });
625
629
  await runCmd('git', ['config', 'user.email', 'bench@local'], { cwd: workDir });
626
630
  await runCmd('git', ['config', 'commit.gpgsign', 'false'], { cwd: workDir });
631
+
632
+ // The repository exists before an arm materialises: the `brief` arm assembles its
633
+ // packet from git's answer, which a directory that is not yet a repository cannot give.
634
+ const { env, promptPrefix } = await materialiseArm(workDir, armSpec, task);
635
+ // Assembling a packet writes tool state. It belongs to no arm, and leaving it in the
636
+ // base commit would make the `brief` workspace differ by an index as well as by context.
637
+ await rm(join(workDir, LOCAL_DIR), { recursive: true, force: true }).catch(() => {});
638
+
627
639
  await runCmd('git', ['add', '-A'], { cwd: workDir });
628
640
  const commitRes = await runCmd(
629
641
  'git',
@@ -638,7 +650,7 @@ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}
638
650
  const headRes = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: workDir });
639
651
  const initialHead = headRes.stdout.toString('utf8').trim();
640
652
 
641
- return { slotDir, workDir, initialHead, env };
653
+ return { slotDir, workDir, initialHead, env, promptPrefix };
642
654
  }
643
655
 
644
656
  function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
@@ -656,25 +668,75 @@ function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
656
668
  return args;
657
669
  }
658
670
 
659
- async function executeAdapter(adapter, workDir, task, { vaultRoot, model, maxTurns, env, timeoutMs, slotDir }) {
660
- if (adapter.id === 'noop') return { ...parseAdapterUsage(adapter, ''), transcript: '' };
671
+ /**
672
+ * Apply a patch to a workspace, falling back to a three-way merge.
673
+ *
674
+ * The gold oracle and the regrade path land operator-supplied bytes the same way. They
675
+ * used to disagree (`--binary` on one side only), so a patch that applied under one read
676
+ * as unreproducible under the other (review 0003 Q59).
677
+ */
678
+ async function applyPatch(workDir, patch, { timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
679
+ if (!patch || patch.length === 0) return { code: 0 };
680
+ const applied = await runCmd('git', ['apply', '--binary', '-'], {
681
+ cwd: workDir,
682
+ input: patch,
683
+ timeoutMs,
684
+ });
685
+ if (applied.code === 0) return applied;
686
+ return runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: patch, timeoutMs });
687
+ }
688
+
689
+ export async function executeAdapter(
690
+ adapter,
691
+ workDir,
692
+ task,
693
+ { vaultRoot, model, maxTurns = DEFAULT_MAX_TURNS, env, timeoutMs, slotDir, promptPrefix = '' },
694
+ ) {
695
+ if (adapter.id === 'noop') {
696
+ return {
697
+ ...parseAdapterUsage(adapter, ''),
698
+ transcript: '',
699
+ agent: { exitCode: 0, timedOut: false, wallMs: 0 },
700
+ };
701
+ }
661
702
  if (adapter.id === 'gold') {
703
+ const startedAt = Date.now();
662
704
  const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'));
663
- if (goldPatch.length > 0) {
664
- const applyRes = await runCmd('git', ['apply', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
665
- if (applyRes.code !== 0) {
666
- await runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
667
- }
668
- }
669
- return { ...parseAdapterUsage(adapter, ''), transcript: '' };
705
+ await applyPatch(workDir, goldPatch, { timeoutMs });
706
+ return {
707
+ ...parseAdapterUsage(adapter, ''),
708
+ transcript: '',
709
+ agent: { exitCode: 0, timedOut: false, wallMs: Date.now() - startedAt },
710
+ };
670
711
  }
671
712
 
672
713
  const analyticsLog = join(slotDir, 'analytics.jsonl');
673
- const args = buildAdapterArgs(adapter, { prompt: String(task.prompt ?? ''), turns: maxTurns, model, analyticsLog });
674
- const outcome = await runCmd(adapter.bin, args, { cwd: workDir, env, timeoutMs });
714
+ // The arm's context arrives as a prefix rather than a file the agent must be told to
715
+ // read, so the prompt an adapter receives is the whole thing it was given. A `gold`
716
+ // or `noop` run never reaches this line: it scripts the workspace instead.
717
+ const prompt = [String(promptPrefix ?? ''), String(task.prompt ?? '')]
718
+ .filter((part) => part.length > 0)
719
+ .join('\n\n');
720
+ const args = buildAdapterArgs(adapter, { prompt, turns: maxTurns, model, analyticsLog });
721
+ const outcome = await runCmd(adapter.bin, args, {
722
+ cwd: workDir,
723
+ env: agentEnv(adapter, { home: slotDir, extra: env }),
724
+ timeoutMs,
725
+ });
726
+ // A binary that could not start produced no run. Grading its empty workspace would
727
+ // record an unresolved arm instead of the missing harness it is (review 0003 Q47).
728
+ if (outcome.spawnError) {
729
+ throw new Error(`harness ${adapter.id} could not start ${adapter.bin}: ${outcome.spawnError}`);
730
+ }
675
731
  const stdoutText = outcome.stdout.toString('utf8');
676
732
  const analyticsText = adapter.format === 'analytics-log' ? await readFile(analyticsLog, 'utf8').catch(() => '') : '';
677
- return { ...parseAdapterUsage(adapter, stdoutText, analyticsText), transcript: `${stdoutText}\n${analyticsText}` };
733
+ return {
734
+ ...parseAdapterUsage(adapter, stdoutText, analyticsText),
735
+ transcript: `${stdoutText}\n${analyticsText}`,
736
+ // A run's own exit code, deadline and wall time belong in its record: the old one
737
+ // stored none, so a timed-out run was indistinguishable from a completed one.
738
+ agent: { exitCode: outcome.code, timedOut: outcome.timedOut, wallMs: outcome.wallMs },
739
+ };
678
740
  }
679
741
 
680
742
  // loadTasks lives in tasks.mjs (the advice engine became a second reader, so the loader
@@ -717,9 +779,10 @@ export async function runBench(
717
779
  maxTurns = DEFAULT_MAX_TURNS,
718
780
  concurrency = DEFAULT_CONCURRENCY,
719
781
  cwd = process.cwd(),
782
+ adapters: adapterTable,
720
783
  } = {},
721
784
  ) {
722
- const adapters = await loadAdapters();
785
+ const adapters = adapterTable ?? (await loadAdapters());
723
786
  const adapter = adapters[harness];
724
787
  if (!adapter) {
725
788
  throw new Error(
@@ -741,11 +804,15 @@ export async function runBench(
741
804
  throw new Error(
742
805
  filterTaskIds.length > 0
743
806
  ? `no mined tasks matched: ${filterTaskIds.join(', ')}`
744
- : `no mined tasks found in ${STATE_DIR}/bench/tasks`,
807
+ : `no mined tasks found in .simt/bench/tasks`,
745
808
  );
746
809
  }
747
810
 
748
811
  const resolvedModel = model ?? (adapter.kind === 'builtin' ? adapter.id : 'default');
812
+ // A built-in harness scripts the workspace instead of driving an agent, so its
813
+ // numbers calibrate the grader and are never an arm's measurement (review 0003 Q49).
814
+ const measured = adapter.kind !== 'builtin';
815
+ const isolation = adapterIsolation(adapter);
749
816
  const plannedRuns = [];
750
817
  for (const armSpec of armSpecs) {
751
818
  for (let rep = 1; rep <= reps; rep += 1) {
@@ -778,17 +845,19 @@ export async function runBench(
778
845
  harness,
779
846
  model: resolvedModel,
780
847
  rep: item.rep,
781
- resolved: null,
782
- gatesPassed: null,
783
- usage: emptyUsage().usage,
784
- costUsd: 0.0,
848
+ measured,
849
+ isolation, status: 'planned',
850
+ resolved: null,
851
+ gatesPassed: null,
852
+ usage: emptyUsage().usage,
853
+ costUsd: null,
785
854
  diff: { files: 0, outsideBlastRadius: 0 },
786
855
  probe: null,
787
856
  })),
788
857
  };
789
858
  }
790
859
 
791
- const runsBaseDir = join(root, STATE_DIR, 'bench', 'runs');
860
+ const runsBaseDir = join(root, LOCAL_DIR, 'bench', 'runs');
792
861
  for (const armSpec of armSpecs) {
793
862
  for (let rep = 1; rep <= reps; rep += 1) {
794
863
  const runDir = join(runsBaseDir, runIdFor(harness, armSpec.name, rep));
@@ -803,21 +872,25 @@ export async function runBench(
803
872
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-batch-'));
804
873
  try {
805
874
  const records = await mapPool(plannedRuns, concurrency, async (item) => {
806
- const { slotDir, workDir, initialHead, env } = await prepareIsolatedWorkspace(
807
- root,
808
- item.task.base,
809
- item.armSpec,
810
- item.task,
811
- { parentDir },
812
- );
875
+ let slotDir = null;
813
876
  try {
814
- const { usage, costUsd, transcript } = await executeAdapter(adapter, workDir, item.task, {
877
+ const prepared = await prepareIsolatedWorkspace(
878
+ root,
879
+ item.task.base,
880
+ item.armSpec,
881
+ item.task,
882
+ { parentDir },
883
+ );
884
+ slotDir = prepared.slotDir;
885
+ const { workDir, initialHead, env, promptPrefix } = prepared;
886
+ const { usage, costUsd, transcript, agent } = await executeAdapter(adapter, workDir, item.task, {
815
887
  vaultRoot,
816
888
  model: resolvedModel,
817
889
  maxTurns,
818
890
  env,
819
891
  timeoutMs,
820
892
  slotDir,
893
+ promptPrefix,
821
894
  });
822
895
 
823
896
  const grade = await gradeWorkspace(workDir, item.task, {
@@ -840,8 +913,13 @@ export async function runBench(
840
913
  harness,
841
914
  model: resolvedModel,
842
915
  rep: item.rep,
916
+ measured,
917
+ isolation,
918
+ status: 'ok',
919
+ agent,
843
920
  resolved: grade.resolved,
844
921
  gatesPassed: grade.gatesPassed,
922
+ f2pPassed: grade.f2pPassed ?? [],
845
923
  usage,
846
924
  costUsd,
847
925
  diff: grade.diff,
@@ -856,8 +934,33 @@ export async function runBench(
856
934
  ]);
857
935
 
858
936
  return { ...record, runId: item.runId };
937
+ } catch (error) {
938
+ // One failed run is one exclusion, not a lost batch: the old path exited 3 and
939
+ // left a half-written run behind (review 0003 Q47).
940
+ return {
941
+ schemaVersion: 1,
942
+ runId: item.runId,
943
+ task: item.task.id,
944
+ arm: item.armSpec.name,
945
+ harness,
946
+ model: resolvedModel,
947
+ rep: item.rep,
948
+ measured,
949
+ isolation,
950
+ status: 'error',
951
+ error: String(error?.message ?? error),
952
+ resolved: false,
953
+ gatesPassed: false,
954
+ f2pPassed: [],
955
+ usage: emptyUsage().usage,
956
+ costUsd: null,
957
+ diff: { files: 0, outsideBlastRadius: 0 },
958
+ probe: null,
959
+ };
859
960
  } finally {
860
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
961
+ if (slotDir !== null) {
962
+ await rm(slotDir, { recursive: true, force: true }).catch(() => {});
963
+ }
861
964
  }
862
965
  });
863
966
 
@@ -876,8 +979,15 @@ export async function runBench(
876
979
  }
877
980
  }
878
981
 
982
+ /** Two string lists hold the same members, ignoring order and duplicates. */
983
+ function sameStringSet(a, b) {
984
+ const left = [...new Set(a)].sort();
985
+ const right = [...new Set(b)].sort();
986
+ return left.length === right.length && left.every((value, index) => value === right[index]);
987
+ }
988
+
879
989
  async function resolveRegradeTargets(root, target) {
880
- const runsBaseDir = join(root, STATE_DIR, 'bench', 'runs');
990
+ const runsBaseDir = join(root, LOCAL_DIR, 'bench', 'runs');
881
991
  const direct = resolve(root, target);
882
992
  const candidate = existsSync(direct) ? direct : join(runsBaseDir, target);
883
993
 
@@ -933,7 +1043,7 @@ export async function regradeRun(
933
1043
  throw new Error(`no stored task runs found under ${target}`);
934
1044
  }
935
1045
 
936
- const tasksDir = join(root, STATE_DIR, 'bench', 'runs');
1046
+ const tasksDir = join(root, LOCAL_DIR, 'bench', 'runs');
937
1047
  const taskDefsDir = join(root, STATE_DIR, 'bench', 'tasks');
938
1048
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-regrade-'));
939
1049
 
@@ -971,20 +1081,7 @@ export async function regradeRun(
971
1081
  { parentDir },
972
1082
  );
973
1083
  try {
974
- if (agentPatch.length > 0) {
975
- const applyRes = await runCmd('git', ['apply', '--binary', '-'], {
976
- cwd: workDir,
977
- input: agentPatch,
978
- timeoutMs,
979
- });
980
- if (applyRes.code !== 0) {
981
- await runCmd('git', ['apply', '--3way', '-'], {
982
- cwd: workDir,
983
- input: agentPatch,
984
- timeoutMs,
985
- });
986
- }
987
- }
1084
+ await applyPatch(workDir, agentPatch, { timeoutMs });
988
1085
 
989
1086
  const grade = await gradeWorkspace(workDir, task, {
990
1087
  vaultRoot,
@@ -992,9 +1089,12 @@ export async function regradeRun(
992
1089
  timeoutMs,
993
1090
  });
994
1091
 
1092
+ // The verdicts alone cannot tell "the same tests passed" from "the same number
1093
+ // of tests passed"; the fail-to-pass set is compared too (review 0003 Q59).
995
1094
  const reproduced =
996
1095
  grade.resolved === stored.resolved &&
997
1096
  grade.gatesPassed === stored.gatesPassed &&
1097
+ sameStringSet(grade.f2pPassed ?? [], stored.f2pPassed ?? []) &&
998
1098
  grade.diff.files === stored.diff?.files &&
999
1099
  grade.diff.outsideBlastRadius === stored.diff?.outsideBlastRadius;
1000
1100
 
@@ -1002,6 +1102,7 @@ export async function regradeRun(
1002
1102
  ...stored,
1003
1103
  resolved: grade.resolved,
1004
1104
  gatesPassed: grade.gatesPassed,
1105
+ f2pPassed: grade.f2pPassed ?? [],
1005
1106
  diff: grade.diff,
1006
1107
  };
1007
1108