simt-dev 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -0,0 +1,333 @@
1
+ // Experimental arms: what a run is measured against, and how it is materialised.
2
+ //
3
+ // An arm used to be a prefix-parsing chain followed by an if/else over names, so adding
4
+ // one meant editing the runner in two places and the second place was easy to miss. An
5
+ // arm kind is now a record in one registry — `match` recognises its spelling, `describe`
6
+ // says what it is in words a report can print, and `materialise` puts it in a workspace.
7
+ // The custom-arm JSON file is the data format the registry already stores, not a fourth
8
+ // branch beside three named arms (review 0003 Q78).
9
+ import { existsSync } from 'node:fs';
10
+ import { lstat, mkdir, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
11
+ import { dirname, join, resolve, sep } from 'node:path';
12
+
13
+ import { assembleBrief } from '../brief/assemble.mjs';
14
+ import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
15
+ import { MANIFEST_FILE } from '../name.mjs';
16
+ import { readManifest } from '../projection/manifest.mjs';
17
+ import { plan } from '../projection/project.mjs';
18
+ import { parse as parseRegion } from '../projection/markers.mjs';
19
+ import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
20
+ import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
21
+
22
+ /** The arms every repository has without writing a file. */
23
+ export const BUILTIN_ARMS = new Set(['bare', 'current', 'kernel', 'brief']);
24
+
25
+ function assertContained(workDir, relPath) {
26
+ const target = resolve(workDir, relPath);
27
+ if (target !== workDir && !target.startsWith(`${workDir}${sep}`)) {
28
+ throw new Error(`arm path "${relPath}" escapes workspace`);
29
+ }
30
+ return target;
31
+ }
32
+
33
+ async function stripRegionFile(filePath, style) {
34
+ let text;
35
+ try {
36
+ text = await readFile(filePath, 'utf8');
37
+ } catch {
38
+ return false;
39
+ }
40
+ const state = parseRegion(text, style);
41
+ if (state.status !== 'ok') return false;
42
+ const remaining = `${state.before.trimEnd()}\n${state.after.trimStart()}`.trim();
43
+ if (!remaining) {
44
+ await unlink(filePath).catch(() => {});
45
+ } else {
46
+ await writeFile(filePath, `${remaining}\n`, 'utf8');
47
+ }
48
+ return true;
49
+ }
50
+
51
+ async function stripBareArm(workDir) {
52
+ await stripRegionFile(join(workDir, 'AGENTS.md'), 'html');
53
+ await stripRegionFile(join(workDir, '.editorconfig'), 'hash');
54
+ await stripRegionFile(join(workDir, '.gitattributes'), 'hash');
55
+
56
+ const surfaceDirs = ['.cursor/rules', '.claude/rules', '.github/instructions', '.agents/rules'];
57
+ for (const relDir of surfaceDirs) {
58
+ const absDir = join(workDir, relDir);
59
+ const entries = await readdir(absDir).catch(() => []);
60
+ for (const entry of entries) {
61
+ const full = join(absDir, entry);
62
+ const text = await readFile(full, 'utf8').catch(() => null);
63
+ if (text !== null && parseRegion(text, 'html').status === 'ok') {
64
+ await unlink(full).catch(() => {});
65
+ }
66
+ }
67
+ }
68
+
69
+ for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json', 'docs/lessons']) {
70
+ await rm(join(workDir, rel), { recursive: true, force: true }).catch(() => {});
71
+ }
72
+
73
+ if (!existsSync(join(workDir, 'AGENTS.md'))) {
74
+ for (const mirror of ['CLAUDE.md', 'GEMINI.md']) {
75
+ const full = join(workDir, mirror);
76
+ const stat = await lstat(full).catch(() => null);
77
+ if (stat?.isSymbolicLink()) {
78
+ await unlink(full).catch(() => {});
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ async function applyProjectionMode(workDir, mode) {
85
+ const manifestRes = await readManifest(workDir);
86
+ const agentsText = await readFile(join(workDir, 'AGENTS.md'), 'utf8').catch(() => null);
87
+ const hasRegion = agentsText !== null && parseRegion(agentsText, 'html').status === 'ok';
88
+
89
+ // If the repository is neither adopted via manifest nor carrying a generated region,
90
+ // `current` leaves its hand-written files as-is, while `kernel` projects a kernel
91
+ // region only when the repo is adopted or already carries a region.
92
+ if (!manifestRes.exists && !hasRegion) return;
93
+
94
+ const manifestPath = manifestRes.path ?? join(workDir, MANIFEST_FILE);
95
+ const hadManifest = manifestRes.exists;
96
+ const originalManifestBytes = hadManifest ? await readFile(manifestPath, 'utf8').catch(() => null) : null;
97
+
98
+ const baseManifest = manifestRes.data ?? {
99
+ schemaVersion: 1,
100
+ projection: { sha: '0000000000000000000000000000000000000000', sources: [] },
101
+ gates: { preCommit: [], prePush: [] },
102
+ };
103
+ const nextManifest = { ...baseManifest };
104
+ if (mode === 'kernel') nextManifest.mode = 'kernel';
105
+ else delete nextManifest.mode;
106
+
107
+ await writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
108
+
109
+ const p = await plan(workDir);
110
+ for (const target of p.targets ?? []) {
111
+ if (target.action !== 'unchanged' && typeof target.text === 'string') {
112
+ await mkdir(dirname(target.path), { recursive: true });
113
+ await writeFile(target.path, target.text, 'utf8');
114
+ }
115
+ }
116
+
117
+ if (!hadManifest) {
118
+ await unlink(manifestPath).catch(() => {});
119
+ } else if (originalManifestBytes !== null && mode !== 'kernel') {
120
+ await writeFile(manifestPath, originalManifestBytes, 'utf8');
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The `brief` arm's context: the packet `simt brief` delivers, assembled by the same
126
+ * function the command and the prompt-submit hook call.
127
+ *
128
+ * The arm used to re-implement the packet from `registry.skills` — a property the
129
+ * registry does not have, so every run reported "(none matched)" while the real command
130
+ * resolved skills — and wrote it to a file no agent was told to read. An arm meant to
131
+ * measure the packet measured a different artefact (review 0003 Q49).
132
+ */
133
+ async function buildBriefPrefix(workDir, task) {
134
+ // A packet is built from git's answer; a directory that is not a repository has none,
135
+ // and asking would print a failure into a bench log for no measurement.
136
+ if (!existsSync(join(workDir, '.git'))) return '';
137
+ const assembled = await assembleBrief({
138
+ root: workDir,
139
+ task: String(task?.prompt ?? ''),
140
+ paths: '',
141
+ budget: DEFAULT_BRIEF_BYTES,
142
+ build: true,
143
+ federate: false,
144
+ }).catch(() => null);
145
+ if (!assembled || assembled.error || assembled.cold) return '';
146
+ return assembled.packed?.text ?? '';
147
+ }
148
+
149
+ /**
150
+ * The built-in projections, by name. A table rather than a branch: `describe` is what a
151
+ * report prints and what a reader can check, and adding a projection is one entry.
152
+ */
153
+ const ARM_TRANSFORMS = {
154
+ bare: {
155
+ describe: 'the repository with every projected artefact removed',
156
+ run: (workDir) => stripBareArm(workDir),
157
+ },
158
+ current: {
159
+ describe: 'the repository as `simt init` leaves it',
160
+ run: (workDir) => applyProjectionMode(workDir, 'full'),
161
+ },
162
+ kernel: {
163
+ describe: 'the current projection, kernel class only',
164
+ run: (workDir) => applyProjectionMode(workDir, 'kernel'),
165
+ },
166
+ brief: {
167
+ describe: 'the current projection with the `simt brief` packet prepended to the prompt',
168
+ run: (workDir, task) => buildBriefPrefix(workDir, task),
169
+ },
170
+ };
171
+
172
+ async function parseCustomArm(spec, cwd) {
173
+ const eq = spec.indexOf('=');
174
+ if (eq === -1) return null;
175
+ const name = spec.slice(0, eq).trim();
176
+ const relPath = spec.slice(eq + 1).trim();
177
+ if (!name || !/^[A-Za-z0-9._-]+$/.test(name)) {
178
+ throw new Error(`invalid custom arm name "${name}"`);
179
+ }
180
+ if (!relPath) {
181
+ throw new Error(`custom arm "${name}" is missing a JSON file path`);
182
+ }
183
+
184
+ const fullPath = resolve(cwd, relPath);
185
+ let data;
186
+ try {
187
+ data = JSON.parse(await readFile(fullPath, 'utf8'));
188
+ } catch (error) {
189
+ throw new Error(`could not read arm file ${relPath}: ${error.message ?? error}`);
190
+ }
191
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
192
+ throw new Error(`arm file ${relPath} must contain a JSON object`);
193
+ }
194
+ if (data.base !== undefined && data.base !== null && !BUILTIN_ARMS.has(data.base)) {
195
+ throw new Error(`arm file ${relPath} has unknown base "${data.base}"`);
196
+ }
197
+ if (data.write !== undefined && (typeof data.write !== 'object' || data.write === null || Array.isArray(data.write))) {
198
+ throw new Error(`arm file ${relPath} field "write" must be an object mapping paths to strings`);
199
+ }
200
+ if (data.remove !== undefined && !Array.isArray(data.remove)) {
201
+ throw new Error(`arm file ${relPath} field "remove" must be an array of paths`);
202
+ }
203
+ if (data.env !== undefined && (typeof data.env !== 'object' || data.env === null || Array.isArray(data.env))) {
204
+ throw new Error(`arm file ${relPath} field "env" must be an object mapping env keys to strings`);
205
+ }
206
+
207
+ const write = {};
208
+ for (const [key, value] of Object.entries(data.write ?? {})) write[key] = String(value);
209
+ const remove = (data.remove ?? []).map((path) => String(path));
210
+ const env = {};
211
+ for (const [key, value] of Object.entries(data.env ?? {})) env[key] = String(value);
212
+
213
+ return { name, base: data.base ?? null, source: relPath, write, remove, env };
214
+ }
215
+
216
+ /**
217
+ * The arm kinds, in match order. The first kind that recognises a spelling owns it.
218
+ *
219
+ * `match` answers the arm's own fields (or `null`), and `materialise` puts it in a
220
+ * workspace and answers the prompt prefix it contributes. A kind that changes files but
221
+ * says nothing in a prompt returns no prefix.
222
+ */
223
+ const ARM_KINDS = [
224
+ {
225
+ kind: 'ablation',
226
+ match: (spec) => parseAblationArmName(spec),
227
+ describe: (arm) => `the current projection without the \`${arm.ablate?.section ?? 'named'}\` section`,
228
+ materialise: (workDir, arm) => ablateSectionInWorkspace(workDir, arm.ablate),
229
+ },
230
+ {
231
+ kind: 'variant',
232
+ match: (spec) => parseVariantArmName(spec),
233
+ describe: (arm) =>
234
+ `rule slot \`${arm.variant?.slot ?? 'named'}\` set to \`${arm.variant?.variant ?? 'named'}\``,
235
+ materialise: (workDir, arm) => applyVariantInWorkspace(workDir, arm.variant),
236
+ },
237
+ {
238
+ kind: 'builtin',
239
+ match: (spec) => (BUILTIN_ARMS.has(spec) ? { name: spec, base: spec } : null),
240
+ describe: (arm) => ARM_TRANSFORMS[arm.base].describe,
241
+ materialise: (workDir, arm, task) => ARM_TRANSFORMS[arm.base].run(workDir, task),
242
+ },
243
+ {
244
+ kind: 'custom',
245
+ match: parseCustomArm,
246
+ describe: (arm) => `a custom arm from ${arm.source ?? 'a JSON file'}`,
247
+ materialise: (workDir, arm, task) =>
248
+ arm.base ? ARM_TRANSFORMS[arm.base].run(workDir, task) : '',
249
+ },
250
+ ];
251
+
252
+ /**
253
+ * Parse an `--arm` specification into the arm it names.
254
+ *
255
+ * @param {string} rawSpec `bare`, `current`, `kernel`, `brief`, an ablation or variant
256
+ * name, or `name=path/to/arm.json`
257
+ * @param {string} [cwd]
258
+ * @returns {Promise<object>} `{name, kind, base, write, remove, env}`
259
+ */
260
+ export async function parseArmSpec(rawSpec, cwd = process.cwd()) {
261
+ const spec = String(rawSpec ?? '').trim();
262
+ if (!spec) throw new Error('empty --arm specification');
263
+
264
+ for (const entry of ARM_KINDS) {
265
+ const matched = await entry.match(spec, cwd);
266
+ if (!matched) continue;
267
+ return {
268
+ name: matched.name,
269
+ kind: entry.kind,
270
+ base: matched.base ?? null,
271
+ ...(matched.ablate ? { ablate: matched.ablate } : {}),
272
+ ...(matched.variant ? { variant: matched.variant } : {}),
273
+ ...(matched.source ? { source: matched.source } : {}),
274
+ write: matched.write ?? {},
275
+ remove: matched.remove ?? [],
276
+ env: matched.env ?? {},
277
+ };
278
+ }
279
+
280
+ throw new Error(
281
+ `unknown arm "${spec}"; built-in arms are: ${[...BUILTIN_ARMS].join(', ')} (or pass --arm name=path/to/arm.json)`,
282
+ );
283
+ }
284
+
285
+ /**
286
+ * What an arm is, in words a report can print.
287
+ *
288
+ * @param {object} armSpec
289
+ * @returns {string}
290
+ */
291
+ export function describeArm(armSpec) {
292
+ const entry = ARM_KINDS.find((candidate) => candidate.kind === armSpec?.kind);
293
+ if (!entry) return 'an arm this version does not know how to describe';
294
+ return entry.describe(armSpec);
295
+ }
296
+
297
+ /**
298
+ * Materialise an experimental arm in `workDir`.
299
+ *
300
+ * @param {string} workDir
301
+ * @param {object} armSpec
302
+ * @param {object} [task]
303
+ * @returns {Promise<{env: Record<string, string>, promptPrefix: string}>}
304
+ */
305
+ export async function materialiseArm(workDir, armSpec, task = {}) {
306
+ // A spec read from a stored `.arm.json`, or passed by a caller that only knows the
307
+ // arm's name, names no kind. Inferring it here keeps one code path: every arm is a
308
+ // registry entry, and a legacy spec is resolved into one rather than branched around.
309
+ const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
310
+ const entry =
311
+ ARM_KINDS.find((candidate) => candidate.kind === armSpec.kind) ??
312
+ (armSpec.ablate
313
+ ? ARM_KINDS.find((candidate) => candidate.kind === 'ablation')
314
+ : armSpec.variant
315
+ ? ARM_KINDS.find((candidate) => candidate.kind === 'variant')
316
+ : base
317
+ ? ARM_KINDS.find((candidate) => candidate.kind === 'builtin')
318
+ : ARM_KINDS.find((candidate) => candidate.kind === 'custom'));
319
+ const prefix = await entry.materialise(workDir, { ...armSpec, base }, task);
320
+
321
+ for (const relPath of armSpec.remove ?? []) {
322
+ const target = assertContained(workDir, relPath);
323
+ await rm(target, { recursive: true, force: true }).catch(() => {});
324
+ }
325
+
326
+ for (const [relPath, content] of Object.entries(armSpec.write ?? {})) {
327
+ const target = assertContained(workDir, relPath);
328
+ await mkdir(dirname(target), { recursive: true });
329
+ await writeFile(target, String(content), 'utf8');
330
+ }
331
+
332
+ return { env: { ...(armSpec.env ?? {}) }, promptPrefix: typeof prefix === 'string' ? prefix : '' };
333
+ }
@@ -5,39 +5,18 @@ import { join } from 'node:path';
5
5
  import { runGates } from '../hooks/dispatcher.mjs';
6
6
  import { packageVersion } from '../package-version.mjs';
7
7
  import { readManifest } from '../projection/manifest.mjs';
8
- import { detectTestRunner, readReports } from './mine.mjs';
9
- import { spawnBounded } from './process.mjs';
8
+ import { registerOracle } from './graders.mjs';
9
+ import { cleanupReports, readReports } from '../testing/parsers.mjs';
10
+ import { extractPatchFiles } from '../testing/patches.mjs';
11
+ import { detectTestRunner } from '../testing/runners.mjs';
12
+ import { sanitizedEnv, spawnBounded } from '../sys/proc.mjs';
10
13
 
11
14
  const DEFAULT_GRADE_TIMEOUT_MS = 45_000;
12
15
 
13
- function sanitizedEnv(extra = {}) {
14
- const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', ...extra };
15
- for (const key of Object.keys(env)) {
16
- if (key.startsWith('NODE_TEST')) delete env[key];
17
- }
18
- return env;
19
- }
20
-
21
16
  function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_GRADE_TIMEOUT_MS } = {}) {
22
17
  return spawnBounded(cmd, args, { cwd, env: env ?? sanitizedEnv(), input, timeoutMs });
23
18
  }
24
19
 
25
- /**
26
- * Extract repository-relative file paths touched by a unified git diff.
27
- *
28
- * @param {Buffer|string} patch
29
- * @returns {string[]}
30
- */
31
- export function extractPatchFiles(patch) {
32
- const text = Buffer.isBuffer(patch) ? patch.toString('utf8') : String(patch ?? '');
33
- const files = new Set();
34
- for (const line of text.split('\n')) {
35
- const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
36
- if (match) files.add(match[2]);
37
- }
38
- return [...files].sort();
39
- }
40
-
41
20
  async function resetFilesToHead(workDir, initialHead, files) {
42
21
  for (const file of files) {
43
22
  const existsAtHead = await runCmd('git', ['cat-file', '-e', `${initialHead}:${file}`], { cwd: workDir });
@@ -166,6 +145,9 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
166
145
  // grader resolved on an empty parse.
167
146
  const reportText = await readReports(execSpec.reportPaths);
168
147
  const parsed = runner.parse(runRes.stdout.toString('utf8'), reportText);
148
+ // A report directed to the temporary directory belongs to this run; a report inside the
149
+ // workspace goes with the workspace (review 0003 Q79).
150
+ await cleanupReports(execSpec.reportPaths);
169
151
  const testRun = {
170
152
  code: runRes.code,
171
153
  timedOut: runRes.timedOut,
@@ -204,3 +186,7 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
204
186
  agentPatch: diffPatchRes.stdout,
205
187
  };
206
188
  }
189
+
190
+ // A task whose hidden test patch decides it declares the `tests` oracle; the registry is
191
+ // how the runner asks for that verdict without knowing this function exists.
192
+ registerOracle('tests', gradeWorkspace);
@@ -0,0 +1,42 @@
1
+ // Oracle graders: how a task's declared oracle decides whether an attempt resolved it.
2
+ //
3
+ // `resolved` used to be computed by the only grader in the package, so a task could not
4
+ // state what would settle it and a second kind of task had nowhere to land (review 0003
5
+ // Q78). A grader is registered against an oracle type from `task-schema.mjs`; the runner
6
+ // asks the task's oracle, and an oracle type with no grader is refused by name rather
7
+ // than silently graded as if it were a test patch.
8
+ const ORACLE_GRADERS = new Map();
9
+
10
+ /**
11
+ * Register the grader for one oracle type.
12
+ *
13
+ * @param {string} type the oracle type a task declares
14
+ * @param {(workDir: string, task: object, context: object) => Promise<object>} grade
15
+ */
16
+ export function registerOracle(type, grade) {
17
+ ORACLE_GRADERS.set(String(type), grade);
18
+ }
19
+
20
+ /** The oracle types this process can grade, sorted. */
21
+ function oracleTypes() {
22
+ return [...ORACLE_GRADERS.keys()].sort();
23
+ }
24
+
25
+ /**
26
+ * Grade a finished workspace through the task's own oracle.
27
+ *
28
+ * @param {string} workDir
29
+ * @param {object} task a normalised v2 task
30
+ * @param {object} context grader inputs (vault root, initial head, timeout)
31
+ * @returns {Promise<object>}
32
+ */
33
+ export async function gradeWithOracle(workDir, task, context) {
34
+ const type = String(task?.oracle?.type ?? 'tests');
35
+ const grade = ORACLE_GRADERS.get(type);
36
+ if (!grade) {
37
+ throw new Error(
38
+ `no grader for oracle type "${type}"; this package grades: ${oracleTypes().join(', ') || '(none)'}`,
39
+ );
40
+ }
41
+ return grade(workDir, task, context);
42
+ }