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
@@ -13,455 +13,28 @@
13
13
  import { execFile, spawn } from 'node:child_process';
14
14
  import { createHash } from 'node:crypto';
15
15
  import { existsSync } from 'node:fs';
16
- import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
17
- import { homedir, platform as osPlatform, tmpdir } from 'node:os';
16
+ import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
18
  import { basename, dirname, join, resolve } from 'node:path';
19
19
  import { promisify } from 'node:util';
20
20
 
21
- import { CONFIG_DIR } from '../name.mjs';
22
- import { SCHEMA_VERSION } from '../output/index.mjs';
21
+ import { STATE_DIR } from '../name.mjs';
22
+ import { readManifest } from '../projection/manifest.mjs';
23
+ import { packageVersion } from '../package-version.mjs';
24
+ import { TASK_SCHEMA_VERSION, normaliseTask } from '../measure/task-schema.mjs';
25
+ import { isTestPath } from '../testing/paths.mjs';
26
+ import { cleanupReports, dedupeSorted, readReports } from '../testing/parsers.mjs';
27
+ import { detectTestRunner } from '../testing/runners.mjs';
28
+ import { resolveVaultDir } from '../testing/vault.mjs';
23
29
 
24
30
  const exec = promisify(execFile);
25
-
26
- /** Default target task count when mining a repository's history. */
27
31
  export const DEFAULT_MINE_TARGET = 20;
28
32
 
29
33
  /**
30
- * Classify whether a repository-relative path is a test file.
31
- *
32
- * Covers the ecosystems named in plan 0003 §5 R01:
33
- * - `test/`, `tests/`, `spec/`, `__tests__/`, `src/test/`
34
- * - `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`
35
- * - `*_test.go` (Go)
36
- * - `test_*.py` (pytest)
37
- * - `tests/*.rs` (Cargo integration tests)
38
- * - `*Test.java`, `*Tests.java`, `*Test.kt`, `*Tests.kt` (Gradle / Maven)
39
- *
40
- * @param {string} relPath forward-slash repository-relative path
41
- * @returns {boolean}
42
- */
43
- export function isTestPath(relPath) {
44
- const norm = String(relPath).replaceAll('\\', '/').replace(/^\.\//, '');
45
- if (!norm) return false;
46
- const segments = norm.split('/');
47
- const base = segments[segments.length - 1];
48
-
49
- if (
50
- norm.startsWith('test/') ||
51
- norm.startsWith('tests/') ||
52
- norm.startsWith('spec/') ||
53
- norm.startsWith('__tests__/') ||
54
- norm.startsWith('src/test/') ||
55
- segments.includes('__tests__') ||
56
- segments.includes('test') ||
57
- segments.includes('tests') ||
58
- segments.includes('spec')
59
- ) {
60
- return true;
61
- }
62
-
63
- if (/\.(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) return true;
64
- if (/_(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) return true;
65
- if (/^test_[^/]+\.py$/.test(base)) return true;
66
- if (/Tests?\.(?:java|kt)$/.test(base)) return true;
67
-
68
- return false;
69
- }
70
-
71
- /**
72
- * Parse TAP version 13 output from `node --test --test-reporter=tap`.
73
- *
74
- * Leaf test cases carry `type: 'test'` in their YAML block; suite headers carry
75
- * `type: 'suite'` and are used only to build `parent > child` qualified IDs.
76
- *
77
- * @param {string} text
78
- * @returns {{passed: string[], failed: string[]}}
79
- */
80
- export function parseTapOutput(text) {
81
- const passed = [];
82
- const failed = [];
83
- const lines = String(text ?? '').split('\n');
84
- const stack = [];
85
- let pending = null;
86
-
87
- const flush = () => {
88
- if (!pending) return;
89
- if (pending.type !== 'suite' && !pending.hadChild && !pending.directive) {
90
- const id = [...pending.ancestors, pending.name].join(' > ');
91
- if (pending.ok) passed.push(id);
92
- else failed.push(id);
93
- }
94
- pending = null;
95
- };
96
-
97
- for (const raw of lines) {
98
- const sub = /^(\s*)# Subtest:\s+(.*)$/.exec(raw);
99
- if (sub) {
100
- flush();
101
- const indent = sub[1].length;
102
- while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
103
- stack.pop();
104
- }
105
- if (stack.length > 0) {
106
- stack[stack.length - 1].hadChild = true;
107
- }
108
- stack.push({ indent, name: sub[2].trim(), hadChild: false });
109
- continue;
110
- }
111
-
112
- const res = /^(\s*)(not ok|ok)\s+\d+(?:\s+-\s+(.*?))?(?:\s+#\s*(SKIP|TODO)\b.*)?$/i.exec(raw);
113
- if (res) {
114
- flush();
115
- const indent = res[1].length;
116
- while (stack.length > 0 && stack[stack.length - 1].indent > indent) {
117
- stack.pop();
118
- }
119
- let hadChild = false;
120
- if (stack.length > 0 && stack[stack.length - 1].indent === indent) {
121
- hadChild = stack.pop().hadChild;
122
- }
123
- const ancestors = stack.map((s) => s.name);
124
- const ok = res[2].toLowerCase() === 'ok';
125
- const name = (res[3] ?? '').trim();
126
- const directive = res[4] ? res[4].toUpperCase() : null;
127
- if (name) {
128
- pending = { indent, ok, name, ancestors, hadChild, directive, type: null };
129
- }
130
- continue;
131
- }
132
-
133
- if (pending) {
134
- const typeMatch = /^\s*type:\s*['"]?(test|suite)['"]?\s*$/.exec(raw);
135
- if (typeMatch) {
136
- pending.type = typeMatch[1];
137
- } else if (/^\s*\.\.\.\s*$/.test(raw)) {
138
- flush();
139
- }
140
- }
141
- }
142
- flush();
143
-
144
- return {
145
- passed: dedupeSorted(passed),
146
- failed: dedupeSorted(failed),
147
- };
148
- }
149
-
150
- /**
151
- * Parse Vitest `--reporter=json` output.
152
- *
153
- * @param {string} text
154
- * @returns {{passed: string[], failed: string[]}}
155
- */
156
- export function parseVitestJson(text) {
157
- const passed = [];
158
- const failed = [];
159
- const raw = String(text ?? '').trim();
160
- const start = raw.indexOf('{');
161
- const end = raw.lastIndexOf('}');
162
- if (start === -1 || end === -1 || end < start) return { passed, failed };
163
-
164
- let doc;
165
- try {
166
- doc = JSON.parse(raw.slice(start, end + 1));
167
- } catch {
168
- return { passed, failed };
169
- }
170
-
171
- for (const suite of doc.testResults ?? []) {
172
- for (const assertion of suite.assertionResults ?? []) {
173
- const parts = [...(assertion.ancestorTitles ?? []), assertion.title].filter(Boolean);
174
- const id = parts.length > 0 ? parts.join(' > ') : (assertion.fullName ?? '').trim();
175
- if (!id) continue;
176
- if (assertion.status === 'passed') passed.push(id);
177
- else if (assertion.status === 'failed') failed.push(id);
178
- }
179
- }
180
-
181
- return {
182
- passed: dedupeSorted(passed),
183
- failed: dedupeSorted(failed),
184
- };
185
- }
186
-
187
- /**
188
- * Parse JUnit XML output (used by pytest `--junitxml`, Gradle and Maven).
189
- *
190
- * @param {string} xml
191
- * @returns {{passed: string[], failed: string[]}}
192
- */
193
- export function parseJunitXml(xml) {
194
- const passed = [];
195
- const failed = [];
196
- const source = String(xml ?? '');
197
- const tagRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
198
-
199
- for (const match of source.matchAll(tagRe)) {
200
- const attrs = match[1] ?? '';
201
- const inner = match[2] ?? '';
202
- const name = xmlAttr(attrs, 'name');
203
- const classname = xmlAttr(attrs, 'classname');
204
- if (!name) continue;
205
- if (/<skipped\b/i.test(inner)) continue;
206
-
207
- const id = classname ? `${classname} > ${name}` : name;
208
- if (/<(?:failure|error)\b/i.test(inner)) failed.push(id);
209
- else passed.push(id);
210
- }
211
-
212
- return {
213
- passed: dedupeSorted(passed),
214
- failed: dedupeSorted(failed),
215
- };
216
- }
217
-
218
- function xmlAttr(attrs, key) {
219
- const m = new RegExp(`\\b${key}=["']([^"']*)["']`).exec(attrs);
220
- if (!m) return '';
221
- return m[1]
222
- .replaceAll('&quot;', '"')
223
- .replaceAll('&apos;', "'")
224
- .replaceAll('&lt;', '<')
225
- .replaceAll('&gt;', '>')
226
- .replaceAll('&amp;', '&')
227
- .trim();
228
- }
229
-
230
- /**
231
- * Parse `go test -json` JSONL event stream.
232
- *
233
- * Only leaf test events (carrying `Test`) are recorded; package-level summary
234
- * events without `Test` are ignored.
235
- *
236
- * @param {string} text
237
- * @returns {{passed: string[], failed: string[]}}
238
- */
239
- export function parseGoTestJson(text) {
240
- const passed = [];
241
- const failed = [];
242
- for (const line of String(text ?? '').split('\n')) {
243
- const trimmed = line.trim();
244
- if (!trimmed.startsWith('{')) continue;
245
- let ev;
246
- try {
247
- ev = JSON.parse(trimmed);
248
- } catch {
249
- continue;
250
- }
251
- if (typeof ev.Test !== 'string' || ev.Test.length === 0) continue;
252
- const id = ev.Package ? `${ev.Package} > ${ev.Test}` : ev.Test;
253
- if (ev.Action === 'pass') passed.push(id);
254
- else if (ev.Action === 'fail') failed.push(id);
255
- }
256
-
257
- return {
258
- passed: dedupeSorted(passed),
259
- failed: dedupeSorted(failed),
260
- };
261
- }
262
-
263
- /**
264
- * Parse libtest's text output, which `cargo test` prints for every test binary.
265
- *
266
- * A line is a result only when it has the `test <name> ... <status>` shape.
267
- * Harness summaries (`test result: ok.`), compile output and doctest prose are
268
- * not results. `ignored` is neither a pass nor a failure: counting it as one
269
- * would make a skipped test look like evidence the change works.
270
- *
271
- * @param {string} text
272
- * @returns {{passed: string[], failed: string[]}}
273
- */
274
- export function parseCargoTestOutput(text) {
275
- const passed = [];
276
- const failed = [];
277
- for (const line of String(text ?? '').split('\n')) {
278
- const match = /^test\s+(.+?)\s+\.\.\.\s+(ok|FAILED|ignored)\s*$/.exec(line.trimEnd());
279
- if (!match || match[2] === 'ignored') continue;
280
- (match[2] === 'ok' ? passed : failed).push(match[1]);
281
- }
282
- return {
283
- passed: dedupeSorted(passed),
284
- failed: dedupeSorted(failed),
285
- };
286
- }
287
-
288
- /**
289
- * Detect the test runner for a repository and build the command for a set of
290
- * changed test files.
291
- *
292
- * @param {string} root
293
- * @param {string[]} testFiles
294
- * @returns {Promise<{runner: string, displayCmd: string, buildExec: (worktree: string) => {cmd: string, args: string[], reportPath?: string}, parse: (stdout: string, reportText?: string) => {passed: string[], failed: string[]}}|null>}
295
- */
296
- export async function detectTestRunner(root, testFiles) {
297
- const files = [...testFiles].sort();
298
-
299
- if (existsSync(join(root, 'package.json'))) {
300
- let pkg = {};
301
- try {
302
- pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
303
- } catch {
304
- pkg = {};
305
- }
306
- const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
307
- const testScript = String(pkg.scripts?.test ?? '');
308
- if (deps.vitest || /\bvitest\b/.test(testScript)) {
309
- const bin = join('node_modules', '.bin', 'vitest');
310
- return {
311
- runner: 'vitest',
312
- displayCmd: `vitest run --reporter=json ${files.join(' ')}`,
313
- buildExec: (wt) => ({
314
- cmd: existsSync(join(wt, bin)) ? join(wt, bin) : 'vitest',
315
- args: ['run', '--reporter=json', ...files],
316
- }),
317
- parse: (stdout) => parseVitestJson(stdout),
318
- };
319
- }
320
- return {
321
- runner: 'node-test',
322
- displayCmd: `node --test ${files.join(' ')}`,
323
- buildExec: () => ({
324
- cmd: process.execPath,
325
- args: ['--test', '--test-reporter=tap', ...files],
326
- }),
327
- parse: (stdout) => parseTapOutput(stdout),
328
- };
329
- }
330
-
331
- if (existsSync(join(root, 'Cargo.toml'))) {
332
- // Cargo has no "run this file" argument. An integration test is a target
333
- // named after its file in `tests/`, so it can be selected with `--test`.
334
- // A source file carries inline unit tests and can only be reached by the
335
- // crate's test binary, so one such file means the whole crate runs.
336
- const targets = dedupeSorted(
337
- files.filter((f) => /^tests\/[^/]+\.rs$/.test(f)).map((f) => basename(f, '.rs')),
338
- );
339
- const integrationOnly = files.length > 0 && files.every((f) => /^tests\/[^/]+\.rs$/.test(f));
340
- const args = ['test'];
341
- if (integrationOnly) {
342
- for (const target of targets) args.push('--test', target);
343
- }
344
- return {
345
- runner: 'cargo-test',
346
- displayCmd: ['cargo', ...args].join(' '),
347
- buildExec: () => ({ cmd: 'cargo', args }),
348
- parse: (stdout) => parseCargoTestOutput(stdout),
349
- };
350
- }
351
-
352
- if (existsSync(join(root, 'go.mod'))) {
353
- const pkgs = dedupeSorted(
354
- files.map((f) => {
355
- const dir = dirname(f);
356
- return dir === '.' ? './...' : `./${dir}`;
357
- }),
358
- );
359
- return {
360
- runner: 'go-test',
361
- displayCmd: `go test -json ${pkgs.join(' ')}`,
362
- buildExec: () => ({
363
- cmd: 'go',
364
- args: ['test', '-json', ...pkgs],
365
- }),
366
- parse: (stdout) => parseGoTestJson(stdout),
367
- };
368
- }
369
-
370
- if (
371
- existsSync(join(root, 'pyproject.toml')) ||
372
- existsSync(join(root, 'requirements.txt')) ||
373
- existsSync(join(root, 'setup.py'))
374
- ) {
375
- return {
376
- runner: 'pytest',
377
- displayCmd: `pytest ${files.join(' ')}`,
378
- buildExec: (wt) => {
379
- const reportPath = join(wt, '.bench-junit.xml');
380
- return {
381
- cmd: 'pytest',
382
- args: [`--junitxml=${reportPath}`, ...files],
383
- reportPath,
384
- };
385
- },
386
- parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
387
- };
388
- }
389
-
390
- if (existsSync(join(root, 'build.gradle.kts')) || existsSync(join(root, 'build.gradle'))) {
391
- const classes = files
392
- .map((f) => basename(f).replace(/\.(?:java|kt)$/, ''))
393
- .filter(Boolean);
394
- const filterArgs = classes.flatMap((c) => ['--tests', `*${c}*`]);
395
- return {
396
- runner: 'gradle',
397
- displayCmd: `./gradlew test ${filterArgs.join(' ')}`.trim(),
398
- buildExec: (wt) => ({
399
- cmd: existsSync(join(wt, 'gradlew')) ? join(wt, 'gradlew') : 'gradle',
400
- args: ['test', ...filterArgs],
401
- reportDir: join(wt, 'build', 'test-results', 'test'),
402
- }),
403
- parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
404
- };
405
- }
406
-
407
- if (existsSync(join(root, 'pom.xml'))) {
408
- const classes = files
409
- .map((f) => basename(f).replace(/\.(?:java|kt)$/, ''))
410
- .filter(Boolean);
411
- const arg = classes.length > 0 ? [`-Dtest=${classes.join(',')}`] : [];
412
- return {
413
- runner: 'maven',
414
- displayCmd: `mvn test ${arg.join(' ')}`.trim(),
415
- buildExec: () => ({
416
- cmd: 'mvn',
417
- args: ['test', ...arg],
418
- }),
419
- parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
420
- };
421
- }
422
-
423
- return null;
424
- }
425
-
426
- /**
427
- * Resolve the hidden verifier vault root outside every worktree.
428
- *
429
- * Precedence:
430
- * 1. `SIMT_DEV_VAULT_DIR` (explicit override for hermetic tests)
431
- * 2. `$XDG_CACHE_HOME/simt-dev/vault/<repo-id>`
432
- * 3. `~/Library/Caches/simt-dev/vault/<repo-id>` on macOS, or `~/.cache/simt-dev/vault/<repo-id>`
433
- *
434
- * @param {string} root
435
- * @param {{env?: NodeJS.ProcessEnv, home?: string, platform?: string}} [options]
436
- * @returns {string}
437
- */
438
- export function resolveVaultDir(root, { env = process.env, home = homedir(), platform = osPlatform() } = {}) {
439
- const repoId = repoIdentifier(root);
440
- if (env.SIMT_DEV_VAULT_DIR) {
441
- return join(resolve(env.SIMT_DEV_VAULT_DIR), repoId);
442
- }
443
- const cacheBase = env.XDG_CACHE_HOME
444
- ? resolve(env.XDG_CACHE_HOME)
445
- : platform === 'darwin'
446
- ? join(home, 'Library', 'Caches')
447
- : join(home, '.cache');
448
- return join(cacheBase, CONFIG_DIR, 'vault', repoId);
449
- }
450
-
451
- function repoIdentifier(root) {
452
- const full = resolve(root);
453
- const name = basename(full).replace(/[^A-Za-z0-9._-]/g, '-') || 'repo';
454
- const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
455
- return `${name}-${digest}`;
456
- }
457
-
458
- /**
459
- * Compute diff stats (`files`, `linesAdded`, `linesRemoved`) from a unified diff buffer.
460
- *
461
- * @param {Buffer|string} patch
462
- * @param {number} fileCount
463
- * @returns {{files: number, linesAdded: number, linesRemoved: number}}
34
+ * Test paths excluded by default, overridable via `simt.json` `bench.mine.exclude`
35
+ * (review 0003 Q48: the pack integration test was hardcoded here, a repo fact, not a miner fact).
464
36
  */
37
+ const DEFAULT_EXCLUDES = ['test/pack.test.mjs'];
465
38
  export function patchStats(patch, fileCount) {
466
39
  const text = Buffer.isBuffer(patch) ? patch.toString('utf8') : String(patch ?? '');
467
40
  let linesAdded = 0;
@@ -519,7 +92,11 @@ export async function listCandidates(root) {
519
92
 
520
93
  const cleanSubject = (subject ?? '').trim();
521
94
  const cleanBody = (body ?? '').trim();
522
- const prompt = cleanBody ? `${cleanSubject}\n\n${cleanBody}` : cleanSubject;
95
+ // Subject only by default. A commit body usually narrates the fix ("use x * x * x
96
+ // here"), which hands the agent the answer and measures nothing; a repository whose
97
+ // messages are curated can ask for the whole message with `bench.mine.prompt: "body"`
98
+ // (review 0003 Q49).
99
+ const prompt = cleanSubject;
523
100
 
524
101
  candidates.push({
525
102
  sha: sha.trim(),
@@ -584,15 +161,16 @@ function runProcess(cmd, args, { cwd, input, env, timeoutMs = 15000 } = {}) {
584
161
 
585
162
  async function executeRunner(runner, worktree, env, timeoutMs) {
586
163
  const spec = runner.buildExec(worktree);
587
- if (spec.reportPath && existsSync(spec.reportPath)) {
588
- await rm(spec.reportPath, { force: true });
164
+ // Remove what a previous run left behind before running: a stale report
165
+ // would parse as this run's result, and a directory report accumulates
166
+ // one XML per class across runs.
167
+ for (const p of spec.reportPaths ?? []) {
168
+ if (existsSync(p)) await rm(p, { recursive: true, force: true });
589
169
  }
590
170
  const res = await runProcess(spec.cmd, spec.args, { cwd: worktree, env, timeoutMs });
591
- let reportText = '';
592
- if (spec.reportPath && existsSync(spec.reportPath)) {
593
- reportText = await readFile(spec.reportPath, 'utf8').catch(() => '');
594
- }
171
+ const reportText = await readReports(spec.reportPaths);
595
172
  const parsed = runner.parse(res.stdout, reportText);
173
+ await cleanupReports(spec.reportPaths);
596
174
  return {
597
175
  code: res.code,
598
176
  timedOut: res.timedOut,
@@ -606,10 +184,20 @@ async function executeRunner(runner, worktree, env, timeoutMs) {
606
184
  * twice, dropping any candidate whose test outcomes are not identical across both
607
185
  * passes.
608
186
  */
609
- async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 } = {}) {
610
- // Skip commits that modify more than 4 test files or run `npm pack` integration
611
- // builds, which are sweeping renames rather than self-contained benchmark tasks.
612
- if (candidate.testFiles.length > 4 || candidate.testFiles.includes('test/pack.test.mjs')) {
187
+ async function validateCandidate(
188
+ root,
189
+ worktree,
190
+ candidate,
191
+ { timeoutMs = 5000, exclude = DEFAULT_EXCLUDES, promptMode = 'subject' } = {},
192
+ ) {
193
+ // Skip commits that modify more than 4 test files, or that touch a path
194
+ // the repository's manifest excludes (`simt.json` bench.mine.exclude,
195
+ // defaulting to the pack integration test the committed corpus was mined
196
+ // around): sweeping renames rather than self-contained benchmark tasks.
197
+ if (
198
+ candidate.testFiles.length > 4 ||
199
+ candidate.testFiles.some((f) => exclude.includes(f))
200
+ ) {
613
201
  return null;
614
202
  }
615
203
 
@@ -682,17 +270,23 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
682
270
  const p2p = gold1.passed.filter((id) => redPassedSet.has(id) && !redFailedSet.has(id));
683
271
  if (f2p.length === 0) return null;
684
272
 
685
- const shortSha = candidate.sha.slice(0, 7);
686
- const id = `t-${shortSha}-01`;
273
+ // 12 hex chars of the source commit: the 7-char form plus a fixed `-01`
274
+ // suffix could not disambiguate two tasks from the same commit (review 0003
275
+ // Q48). Collisions are checked at write time, where the corpus is visible.
276
+ const id = `t-${candidate.sha.slice(0, 12)}-01`;
687
277
  const gold = patchStats(goldPatch, candidate.implFiles.length);
688
278
 
689
279
  return {
690
280
  task: {
691
- schemaVersion: SCHEMA_VERSION,
281
+ schemaVersion: TASK_SCHEMA_VERSION,
692
282
  id,
693
283
  base: candidate.base,
694
284
  sourceCommit: candidate.sha,
695
- prompt: candidate.prompt,
285
+ prompt:
286
+ promptMode === 'body' && candidate.body
287
+ ? `${candidate.subject}\n\n${candidate.body}`
288
+ : candidate.subject,
289
+ promptBasis: promptMode === 'body' && candidate.body ? 'body' : 'subject',
696
290
  testCmd: runner.displayCmd,
697
291
  f2p,
698
292
  p2p,
@@ -713,8 +307,12 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
713
307
  * temporary worktrees until `target` valid tasks are collected, and written
714
308
  * deterministically to disk.
715
309
  *
310
+ * Writing is append-only: existing task files are left alone unless `replace`
311
+ * is set, which restores the old "reflect exactly this history" semantics by
312
+ * deleting files the current history does not reproduce (review 0003 Q48).
313
+ *
716
314
  * @param {string} root
717
- * @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
315
+ * @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, replace?: boolean, env?: NodeJS.ProcessEnv}} [options]
718
316
  */
719
317
  export async function mineTasks(
720
318
  root,
@@ -723,6 +321,7 @@ export async function mineTasks(
723
321
  target = DEFAULT_MINE_TARGET,
724
322
  concurrency = 4,
725
323
  timeoutMs = 5000,
324
+ replace = false,
726
325
  env = process.env,
727
326
  } = {},
728
327
  ) {
@@ -732,12 +331,20 @@ export async function mineTasks(
732
331
  return { ok: false, error: listed.error };
733
332
  }
734
333
 
735
- const tasksDir = join(absRoot, '.simt', 'bench', 'tasks');
334
+ // Repository-declared mining config (review 0003 Q48): an explicit option
335
+ // wins, then the manifest, then the default.
336
+ const { manifest } = await readManifest(absRoot);
337
+ const benchCfg = manifest?.bench?.mine ?? {};
338
+ const effectiveTimeout = benchCfg.timeoutMs ?? timeoutMs;
339
+ const exclude = benchCfg.exclude ?? DEFAULT_EXCLUDES;
340
+ const promptMode = benchCfg.prompt === 'body' ? 'body' : 'subject';
341
+
342
+ const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
736
343
  const vaultRoot = resolveVaultDir(absRoot, { env });
737
344
 
738
345
  if (!apply) {
739
346
  const preview = listed.candidates.slice(0, target).map((c) => ({
740
- id: `t-${c.sha.slice(0, 7)}-01`,
347
+ id: `t-${c.sha.slice(0, 12)}-01`,
741
348
  base: c.base,
742
349
  sourceCommit: c.sha,
743
350
  subject: c.subject,
@@ -790,7 +397,11 @@ export async function mineTasks(
790
397
  cursor += 1;
791
398
  if (index >= listed.candidates.length) break;
792
399
  const candidate = listed.candidates[index];
793
- const res = await validateCandidate(absRoot, wt, candidate, { timeoutMs });
400
+ const res = await validateCandidate(absRoot, wt, candidate, {
401
+ timeoutMs: effectiveTimeout,
402
+ exclude,
403
+ promptMode,
404
+ });
794
405
  if (res) {
795
406
  validated.push({ index, ...res });
796
407
  }
@@ -805,28 +416,68 @@ export async function mineTasks(
805
416
  const selected = validated.slice(0, target);
806
417
 
807
418
  await mkdir(tasksDir, { recursive: true });
808
- await mkdir(vaultRoot, { recursive: true });
809
-
810
- // Remove stale task files when re-mining so the directory reflects the
811
- // deterministic set for this history.
812
- const existing = await readdir(tasksDir).catch(() => []);
813
- const keepNames = new Set(selected.map((entry) => `${entry.task.id}.json`));
814
- for (const name of existing) {
815
- if (name.endsWith('.json') && !keepNames.has(name)) {
816
- await rm(join(tasksDir, name), { force: true });
419
+ await mkdir(vaultRoot, { recursive: true, mode: 0o700 });
420
+ // The hidden tests are the answer key: 0700 keeps every other local account out.
421
+ // `mkdir` alone does not tighten a vault an earlier run created.
422
+ await chmod(vaultRoot, 0o700).catch(() => {});
423
+
424
+ // Mining is append-only (review 0003 Q48: `mineTasks(target 3)` once
425
+ // deleted all 20 committed tasks and wrote 3 others). `replace` restores
426
+ // the old "reflect exactly this history" semantics, and only it deletes.
427
+ if (replace) {
428
+ const existing = await readdir(tasksDir).catch(() => []);
429
+ const keepNames = new Set(selected.map((entry) => `${entry.task.id}.json`));
430
+ for (const name of existing) {
431
+ if (name.endsWith('.json') && !keepNames.has(name)) {
432
+ await rm(join(tasksDir, name), { force: true });
433
+ }
817
434
  }
818
435
  }
819
436
 
820
437
  for (const entry of selected) {
821
438
  const taskPath = join(tasksDir, `${entry.task.id}.json`);
822
- await writeFile(taskPath, `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
439
+ // An id belongs to exactly one source commit. If a file with this id
440
+ // holds a different commit, the 12-char prefix collided and guessing
441
+ // which corpus the reader wants is worse than refusing.
442
+ if (existsSync(taskPath) && !replace) {
443
+ const prior = JSON.parse(await readFile(taskPath, 'utf8'));
444
+ if (prior.sourceCommit !== entry.task.sourceCommit) {
445
+ return {
446
+ ok: false,
447
+ applied: false,
448
+ error: `id collision: ${taskPath} holds task from ${prior.sourceCommit}, mine produced ${entry.task.sourceCommit}; rerun with --replace to take over the corpus`,
449
+ };
450
+ }
451
+ }
452
+ // The miner is the writer, so it writes the schema a reader validates: a task file
453
+ // that only becomes valid on load is a file two readers can disagree about.
454
+ await writeFile(
455
+ taskPath,
456
+ `${JSON.stringify(normaliseTask(entry.task, { source: taskPath }), null, 2)}\n`,
457
+ 'utf8',
458
+ );
823
459
 
824
460
  const taskVault = join(vaultRoot, entry.task.id);
825
- await mkdir(taskVault, { recursive: true });
461
+ await mkdir(taskVault, { recursive: true, mode: 0o700 });
462
+ await chmod(taskVault, 0o700).catch(() => {});
826
463
  await writeFile(join(taskVault, 'test.patch'), entry.testPatch);
827
464
  await writeFile(join(taskVault, 'gold.patch'), entry.goldPatch);
828
465
  }
829
466
 
467
+ // The corpus records where it came from: which commit was HEAD when the
468
+ // mine ran, the target, and the simt version that validated the tasks.
469
+ // No timestamp — a re-mine at the same HEAD is byte-identical.
470
+ const { stdout: headSha } = await exec('git', ['rev-parse', 'HEAD'], { cwd: absRoot });
471
+ await writeFile(
472
+ join(dirname(tasksDir), 'corpus.json'),
473
+ `${JSON.stringify(
474
+ { minedFrom: headSha.trim(), target, simtVersion: packageVersion() },
475
+ null,
476
+ 2,
477
+ )}\n`,
478
+ 'utf8',
479
+ );
480
+
830
481
  return {
831
482
  ok: true,
832
483
  applied: true,
@@ -844,11 +495,6 @@ export async function mineTasks(
844
495
  await rm(sandbox, { recursive: true, force: true }).catch(() => null);
845
496
  }
846
497
  }
847
-
848
- function dedupeSorted(items) {
849
- return [...new Set(items)].sort();
850
- }
851
-
852
498
  function sameList(a, b) {
853
499
  if (a.length !== b.length) return false;
854
500
  for (let i = 0; i < a.length; i += 1) {