simt-dev 0.7.0 → 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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. 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
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. 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
+ }
@@ -1,80 +1,20 @@
1
- import { spawn } from 'node:child_process';
1
+ import { existsSync } from 'node:fs';
2
2
  import { readFile, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
 
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 } from './mine.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';
9
13
 
10
14
  const DEFAULT_GRADE_TIMEOUT_MS = 45_000;
11
15
 
12
- function sanitizedEnv(extra = {}) {
13
- const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', ...extra };
14
- for (const key of Object.keys(env)) {
15
- if (key.startsWith('NODE_TEST')) delete env[key];
16
- }
17
- return env;
18
- }
19
-
20
- function runCmd(cmd, args, { cwd, input = null, timeoutMs = DEFAULT_GRADE_TIMEOUT_MS } = {}) {
21
- return new Promise((resolveOutcome) => {
22
- const child = spawn(cmd, args, {
23
- cwd,
24
- env: sanitizedEnv(),
25
- stdio: ['pipe', 'pipe', 'pipe'],
26
- });
27
- const out = [];
28
- const err = [];
29
- let timedOut = false;
30
- const timer =
31
- timeoutMs > 0
32
- ? setTimeout(() => {
33
- timedOut = true;
34
- child.kill('SIGKILL');
35
- }, timeoutMs)
36
- : null;
37
-
38
- child.stdout.on('data', (chunk) => out.push(chunk));
39
- child.stderr.on('data', (chunk) => err.push(chunk));
40
- child.on('error', (error) => {
41
- if (timer) clearTimeout(timer);
42
- resolveOutcome({
43
- code: 1,
44
- timedOut,
45
- stdout: Buffer.concat(out),
46
- stderr: Buffer.from(String(error.message ?? error)),
47
- });
48
- });
49
- child.on('close', (code) => {
50
- if (timer) clearTimeout(timer);
51
- resolveOutcome({
52
- code: timedOut ? 124 : (code ?? 1),
53
- timedOut,
54
- stdout: Buffer.concat(out),
55
- stderr: Buffer.concat(err),
56
- });
57
- });
58
-
59
- if (input !== null) child.stdin.end(input);
60
- else child.stdin.end();
61
- });
62
- }
63
-
64
- /**
65
- * Extract repository-relative file paths touched by a unified git diff.
66
- *
67
- * @param {Buffer|string} patch
68
- * @returns {string[]}
69
- */
70
- export function extractPatchFiles(patch) {
71
- const text = Buffer.isBuffer(patch) ? patch.toString('utf8') : String(patch ?? '');
72
- const files = new Set();
73
- for (const line of text.split('\n')) {
74
- const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
75
- if (match) files.add(match[2]);
76
- }
77
- return [...files].sort();
16
+ function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_GRADE_TIMEOUT_MS } = {}) {
17
+ return spawnBounded(cmd, args, { cwd, env: env ?? sanitizedEnv(), input, timeoutMs });
78
18
  }
79
19
 
80
20
  async function resetFilesToHead(workDir, initialHead, files) {
@@ -136,8 +76,10 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
136
76
  readFile(join(taskVaultDir, 'gold.patch')).catch(() => Buffer.alloc(0)),
137
77
  ]);
138
78
 
139
- // Remove transient brief file if the `brief` arm or agent left one untracked, so it
140
- // is not counted as an implementation edit outside the task's blast radius.
79
+ // Intent-to-add every untracked file so a file an agent created is part of the diff:
80
+ // `git diff` reports tracked changes only and would not count a new file at all.
81
+ // Nothing is removed — an arm's untracked file is an edit outside the blast radius,
82
+ // which is what it is.
141
83
  await runCmd('git', ['add', '-N', '.'], { cwd: workDir });
142
84
  const [diffNamesRes, diffPatchRes] = await Promise.all([
143
85
  runCmd('git', ['diff', '--name-only', initialHead], { cwd: workDir }),
@@ -173,6 +115,7 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
173
115
  resolved: false,
174
116
  gatesPassed: false,
175
117
  diff,
118
+ f2pPassed: [],
176
119
  goldFiles: [...goldFiles],
177
120
  agentPatch: diffPatchRes.stdout,
178
121
  };
@@ -184,17 +127,27 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
184
127
  resolved: false,
185
128
  gatesPassed: false,
186
129
  diff,
130
+ f2pPassed: [],
187
131
  goldFiles: [...goldFiles],
188
132
  agentPatch: diffPatchRes.stdout,
189
133
  };
190
134
  }
191
135
 
192
136
  const execSpec = runner.buildExec(workDir);
137
+ // Same stale-report hazard as the miner: a report left by a prior run in
138
+ // this workspace would be graded as this agent's result (review 0003 Q48).
139
+ for (const p of execSpec.reportPaths ?? []) {
140
+ if (existsSync(p)) await rm(p, { recursive: true, force: true });
141
+ }
193
142
  const runRes = await runCmd(execSpec.cmd, execSpec.args, { cwd: workDir, timeoutMs });
194
- const reportText = execSpec.reportPath
195
- ? await readFile(execSpec.reportPath, 'utf8').catch(() => '')
196
- : '';
143
+ // readReports walks the report *directories* Gradle and Maven write (one
144
+ // XML per test class); the old single-file read saw none of them, so the
145
+ // grader resolved on an empty parse.
146
+ const reportText = await readReports(execSpec.reportPaths);
197
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);
198
151
  const testRun = {
199
152
  code: runRes.code,
200
153
  timedOut: runRes.timedOut,
@@ -226,7 +179,14 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
226
179
  resolved,
227
180
  gatesPassed,
228
181
  diff,
182
+ // Which fail-to-pass tests actually passed. `resolved` is a boolean, so a regrade
183
+ // could only compare the verdict; the set is what a reader can check (review Q59).
184
+ f2pPassed: Array.isArray(task.f2p) ? task.f2p.filter((id) => passedSet.has(id)) : [],
229
185
  goldFiles: [...goldFiles],
230
186
  agentPatch: diffPatchRes.stdout,
231
187
  };
232
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
+ }