simt-dev 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +158 -872
  2. package/README.md +19 -133
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  6. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  7. 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
  8. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  9. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  10. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  11. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  12. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  13. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  14. package/package.json +10 -2
  15. package/rules/core.md +7 -0
  16. package/rules/mdc/slice-commit.mdc +57 -4
  17. package/src/agenthooks/prompt.mjs +5 -2
  18. package/src/agenthooks/shim.mjs +1 -1
  19. package/src/api.mjs +29 -0
  20. package/src/argv.mjs +29 -3
  21. package/src/bench/adapters.json +16 -0
  22. package/src/bench/corpus.mjs +150 -0
  23. package/src/bench/grade.mjs +23 -49
  24. package/src/bench/mine.mjs +199 -35
  25. package/src/bench/probes.mjs +99 -62
  26. package/src/bench/process.mjs +128 -0
  27. package/src/bench/report.mjs +17 -4
  28. package/src/bench/run.mjs +242 -142
  29. package/src/brief/assemble.mjs +38 -3
  30. package/src/ci/workflow.mjs +30 -7
  31. package/src/cli.mjs +45 -0
  32. package/src/commands/agent.mjs +3 -4
  33. package/src/commands/agenthook.mjs +4 -5
  34. package/src/commands/bench.mjs +190 -17
  35. package/src/commands/brief.mjs +5 -4
  36. package/src/commands/ci.mjs +2 -1
  37. package/src/commands/compare.mjs +42 -42
  38. package/src/commands/completions.mjs +2 -6
  39. package/src/commands/decide.mjs +4 -4
  40. package/src/commands/distill.mjs +4 -3
  41. package/src/commands/doctor.mjs +11 -7
  42. package/src/commands/evidence.mjs +6 -9
  43. package/src/commands/explain.mjs +0 -1
  44. package/src/commands/gate.mjs +16 -12
  45. package/src/commands/graph.mjs +2 -3
  46. package/src/commands/harness.mjs +3 -2
  47. package/src/commands/help.mjs +125 -17
  48. package/src/commands/hooks.mjs +100 -30
  49. package/src/commands/ignore.mjs +2 -1
  50. package/src/commands/impact.mjs +4 -7
  51. package/src/commands/index.mjs +273 -188
  52. package/src/commands/init.mjs +2 -1
  53. package/src/commands/lease.mjs +6 -7
  54. package/src/commands/lessons.mjs +10 -9
  55. package/src/commands/local.mjs +3 -3
  56. package/src/commands/map.mjs +14 -9
  57. package/src/commands/mcp.mjs +0 -1
  58. package/src/commands/pack.mjs +9 -6
  59. package/src/commands/plan.mjs +6 -5
  60. package/src/commands/plugin.mjs +2 -2
  61. package/src/commands/prbody.mjs +3 -3
  62. package/src/commands/repoindex.mjs +4 -3
  63. package/src/commands/review.mjs +2 -2
  64. package/src/commands/rules.mjs +33 -16
  65. package/src/commands/shared.mjs +2 -3
  66. package/src/commands/skills.mjs +28 -26
  67. package/src/commands/skillscompile.mjs +4 -5
  68. package/src/commands/skillsinstall.mjs +2 -2
  69. package/src/commands/slice.mjs +21 -10
  70. package/src/commands/spec.mjs +151 -0
  71. package/src/commands/status.mjs +4 -7
  72. package/src/commands/sync.mjs +13 -11
  73. package/src/commands/tests.mjs +6 -9
  74. package/src/commands/tokens.mjs +2 -2
  75. package/src/commands/undo.mjs +5 -15
  76. package/src/commands/upgrade.mjs +7 -3
  77. package/src/commands/why.mjs +9 -8
  78. package/src/compare/row.mjs +0 -2
  79. package/src/context.mjs +64 -0
  80. package/src/decisions/index-doc.mjs +46 -0
  81. package/src/diff.mjs +1 -1
  82. package/src/distill/distill.mjs +14 -1
  83. package/src/distill/engine.mjs +5 -1
  84. package/src/graph/load.mjs +4 -1
  85. package/src/hooks/capability.mjs +3 -3
  86. package/src/hooks/dispatcher.mjs +198 -43
  87. package/src/hooks/gates.mjs +0 -43
  88. package/src/hooks/resolve.mjs +2 -7
  89. package/src/hooks/shim.mjs +5 -2
  90. package/src/identity.mjs +29 -1
  91. package/src/journal.mjs +78 -9
  92. package/src/ledger/index.mjs +30 -1
  93. package/src/local/client.mjs +4 -6
  94. package/src/locate.mjs +33 -9
  95. package/src/manifest/schema.mjs +213 -0
  96. package/src/mcp/resources.mjs +7 -10
  97. package/src/mcp/server.mjs +70 -16
  98. package/src/mcp/tools.mjs +45 -7
  99. package/src/measure/session.mjs +3 -2
  100. package/src/node-version.mjs +37 -0
  101. package/src/output/index.mjs +111 -3
  102. package/src/packs/manage.mjs +17 -3
  103. package/src/packs.mjs +20 -2
  104. package/src/projection/lock.mjs +11 -1
  105. package/src/projection/manifest.mjs +20 -34
  106. package/src/projection/rules.mjs +4 -3
  107. package/src/repo-root.mjs +19 -0
  108. package/src/resolve/fingerprint.mjs +19 -15
  109. package/src/review/critic.mjs +2 -1
  110. package/src/toolwords.mjs +3 -0
  111. package/src/workspace.mjs +55 -10
  112. package/docs/decisions/README.md +0 -600
@@ -0,0 +1,150 @@
1
+ // Sealed export / import of the mined benchmark corpus (review 0003 Q48).
2
+ //
3
+ // The vault is keyed on the repository's root commit, outside every checkout, so
4
+ // a clone of the repository cannot read the hidden test patches the miner wrote:
5
+ // the committed corpus was unrunnable from any machine that was not the one that
6
+ // mined it. A sealed document carries the task descriptors and both patches for
7
+ // every task in a single file, so `bench export` on one machine and `bench import`
8
+ // on another reproduce the corpus exactly.
9
+ //
10
+ // The document is deterministic: no timestamp, tasks sorted by id, canonical
11
+ // (key-sorted) JSON, and a sha256 seal over everything but the seal itself. Two
12
+ // exports of the same corpus are byte-identical, and a reader can verify the
13
+ // seal before trusting a single byte of it.
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import { chmod, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
17
+ import { dirname, join, resolve } from 'node:path';
18
+
19
+ import { STATE_DIR } from '../name.mjs';
20
+ import { packageVersion } from '../package-version.mjs';
21
+ import { resolveVaultDir } from './mine.mjs';
22
+
23
+ /** The document kind stamp, so a reader can refuse a file that is not a corpus. */
24
+ export const CORPUS_KIND = 'simt-bench-corpus';
25
+
26
+ /** The document schema version. Bump when the shape changes, not when content does. */
27
+ export const CORPUS_SCHEMA_VERSION = 1;
28
+
29
+ /**
30
+ * Canonical serialisation: object keys sorted, no whitespace. Arrays keep their
31
+ * order (the caller sorts them). This is what the seal hashes, so both the
32
+ * exporter and the importer must reduce the payload to exactly this form.
33
+ *
34
+ * @param {*} value a JSON value
35
+ * @returns {string}
36
+ */
37
+ function canonical(value) {
38
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
39
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
40
+ const keys = Object.keys(value).sort();
41
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
42
+ }
43
+
44
+ /**
45
+ * @param {{tasks: object[]}} payload the document minus its seal
46
+ * @returns {string} hex sha256 of the canonical payload
47
+ */
48
+ function sealOf(payload) {
49
+ return createHash('sha256').update(canonical(payload)).digest('hex');
50
+ }
51
+
52
+ function payloadOf(doc) {
53
+ const { seal, ...payload } = doc;
54
+ void seal;
55
+ return payload;
56
+ }
57
+
58
+ /**
59
+ * Read every mined task and its hidden patches from `root` and assemble the
60
+ * sealed corpus document (no file written; the command decides where it goes).
61
+ *
62
+ * @param {string} root
63
+ * @param {{env?: NodeJS.ProcessEnv}} [options]
64
+ * @returns {Promise<object>} the sealed document
65
+ */
66
+ export async function buildCorpusDocument(root, { env = process.env } = {}) {
67
+ const absRoot = resolve(root);
68
+ const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
69
+ const vaultRoot = resolveVaultDir(absRoot, { env });
70
+
71
+ const names = (await readdir(tasksDir).catch(() => [])).filter((f) => f.endsWith('.json')).sort();
72
+ const tasks = [];
73
+ for (const name of names) {
74
+ const task = JSON.parse(await readFile(join(tasksDir, name), 'utf8'));
75
+ const testPatch = await readFile(join(vaultRoot, task.id, 'test.patch'))
76
+ .then((b) => b.toString('base64'))
77
+ .catch(() => '');
78
+ const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'))
79
+ .then((b) => b.toString('base64'))
80
+ .catch(() => '');
81
+ tasks.push({ id: task.id, task, testPatch, goldPatch });
82
+ }
83
+ tasks.sort((a, b) => a.id.localeCompare(b.id));
84
+
85
+ const corpus = await readFile(join(dirname(tasksDir), 'corpus.json'), 'utf8')
86
+ .then((t) => JSON.parse(t))
87
+ .catch(() => null);
88
+
89
+ const payload = {
90
+ schemaVersion: CORPUS_SCHEMA_VERSION,
91
+ kind: CORPUS_KIND,
92
+ minedFrom: corpus?.minedFrom ?? null,
93
+ simtVersion: corpus?.simtVersion ?? packageVersion(),
94
+ taskCount: tasks.length,
95
+ tasks,
96
+ };
97
+ return { ...payload, seal: sealOf(payload) };
98
+ }
99
+
100
+ /**
101
+ * Verify a corpus document's seal. Returns a reason string when the document is
102
+ * not a trusted corpus; `null` when it is.
103
+ *
104
+ * @param {object} doc
105
+ * @returns {string|null}
106
+ */
107
+ export function verifyCorpusDocument(doc) {
108
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return 'not a corpus document';
109
+ if (doc.kind !== CORPUS_KIND) return `unexpected kind \`${doc.kind}\` (want \`${CORPUS_KIND}\`)`;
110
+ if (doc.schemaVersion !== CORPUS_SCHEMA_VERSION) {
111
+ return `unsupported schemaVersion \`${doc.schemaVersion}\` (want ${CORPUS_SCHEMA_VERSION})`;
112
+ }
113
+ const payload = payloadOf(doc);
114
+ if (sealOf(payload) !== doc.seal) return 'seal mismatch: the document was modified after it was sealed';
115
+ if (!Array.isArray(payload.tasks)) return 'missing task list';
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Write a verified corpus document into `root`: task descriptors to
121
+ * `.simt/bench/tasks/` and the hidden patches to the vault. Idempotent — a task
122
+ * already present is overwritten with the document's copy, which is the point of
123
+ * importing a known corpus.
124
+ *
125
+ * @param {string} root
126
+ * @param {object} doc a document that passed `verifyCorpusDocument`
127
+ * @param {{env?: NodeJS.ProcessEnv, apply?: boolean}} [options]
128
+ * @returns {Promise<{applied: boolean, tasks: number, tasksDir: string, vaultRoot: string}>}
129
+ */
130
+ export async function writeCorpusDocument(root, doc, { env = process.env, apply = true } = {}) {
131
+ const absRoot = resolve(root);
132
+ const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
133
+ const vaultRoot = resolveVaultDir(absRoot, { env });
134
+
135
+ if (apply) await mkdir(tasksDir, { recursive: true });
136
+ for (const entry of doc.tasks) {
137
+ if (apply) {
138
+ await writeFile(join(tasksDir, `${entry.id}.json`), `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
139
+ const taskVault = join(vaultRoot, entry.id);
140
+ // An imported corpus restores an answer key, so it gets the same 0700 the miner
141
+ // gives one (review 0003 Q49): the import must not be the cheaper way in.
142
+ await mkdir(taskVault, { recursive: true, mode: 0o700 });
143
+ await chmod(taskVault, 0o700).catch(() => {});
144
+ await writeFile(join(taskVault, 'test.patch'), Buffer.from(entry.testPatch, 'base64'));
145
+ await writeFile(join(taskVault, 'gold.patch'), Buffer.from(entry.goldPatch, 'base64'));
146
+ }
147
+ }
148
+
149
+ return { applied: apply, tasks: doc.tasks.length, tasksDir, vaultRoot };
150
+ }
@@ -1,11 +1,12 @@
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 { detectTestRunner, readReports } from './mine.mjs';
9
+ import { spawnBounded } from './process.mjs';
9
10
 
10
11
  const DEFAULT_GRADE_TIMEOUT_MS = 45_000;
11
12
 
@@ -17,48 +18,8 @@ function sanitizedEnv(extra = {}) {
17
18
  return env;
18
19
  }
19
20
 
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
- });
21
+ function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_GRADE_TIMEOUT_MS } = {}) {
22
+ return spawnBounded(cmd, args, { cwd, env: env ?? sanitizedEnv(), input, timeoutMs });
62
23
  }
63
24
 
64
25
  /**
@@ -136,8 +97,10 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
136
97
  readFile(join(taskVaultDir, 'gold.patch')).catch(() => Buffer.alloc(0)),
137
98
  ]);
138
99
 
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.
100
+ // Intent-to-add every untracked file so a file an agent created is part of the diff:
101
+ // `git diff` reports tracked changes only and would not count a new file at all.
102
+ // Nothing is removed — an arm's untracked file is an edit outside the blast radius,
103
+ // which is what it is.
141
104
  await runCmd('git', ['add', '-N', '.'], { cwd: workDir });
142
105
  const [diffNamesRes, diffPatchRes] = await Promise.all([
143
106
  runCmd('git', ['diff', '--name-only', initialHead], { cwd: workDir }),
@@ -173,6 +136,7 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
173
136
  resolved: false,
174
137
  gatesPassed: false,
175
138
  diff,
139
+ f2pPassed: [],
176
140
  goldFiles: [...goldFiles],
177
141
  agentPatch: diffPatchRes.stdout,
178
142
  };
@@ -184,16 +148,23 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
184
148
  resolved: false,
185
149
  gatesPassed: false,
186
150
  diff,
151
+ f2pPassed: [],
187
152
  goldFiles: [...goldFiles],
188
153
  agentPatch: diffPatchRes.stdout,
189
154
  };
190
155
  }
191
156
 
192
157
  const execSpec = runner.buildExec(workDir);
158
+ // Same stale-report hazard as the miner: a report left by a prior run in
159
+ // this workspace would be graded as this agent's result (review 0003 Q48).
160
+ for (const p of execSpec.reportPaths ?? []) {
161
+ if (existsSync(p)) await rm(p, { recursive: true, force: true });
162
+ }
193
163
  const runRes = await runCmd(execSpec.cmd, execSpec.args, { cwd: workDir, timeoutMs });
194
- const reportText = execSpec.reportPath
195
- ? await readFile(execSpec.reportPath, 'utf8').catch(() => '')
196
- : '';
164
+ // readReports walks the report *directories* Gradle and Maven write (one
165
+ // XML per test class); the old single-file read saw none of them, so the
166
+ // grader resolved on an empty parse.
167
+ const reportText = await readReports(execSpec.reportPaths);
197
168
  const parsed = runner.parse(runRes.stdout.toString('utf8'), reportText);
198
169
  const testRun = {
199
170
  code: runRes.code,
@@ -226,6 +197,9 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
226
197
  resolved,
227
198
  gatesPassed,
228
199
  diff,
200
+ // Which fail-to-pass tests actually passed. `resolved` is a boolean, so a regrade
201
+ // could only compare the verdict; the set is what a reader can check (review Q59).
202
+ f2pPassed: Array.isArray(task.f2p) ? task.f2p.filter((id) => passedSet.has(id)) : [],
229
203
  goldFiles: [...goldFiles],
230
204
  agentPatch: diffPatchRes.stdout,
231
205
  };
@@ -10,22 +10,30 @@
10
10
  // outside every worktree (RepoTrials / PatchGym), while deterministic task
11
11
  // descriptors live in `.simt/bench/tasks/<id>.json`.
12
12
 
13
- import { execFile, spawn } from 'node:child_process';
13
+ import { execFile, execFileSync, 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';
16
+ import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
17
17
  import { homedir, platform as osPlatform, 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';
21
+ import { CONFIG_DIR, STATE_DIR } from '../name.mjs';
22
22
  import { SCHEMA_VERSION } from '../output/index.mjs';
23
+ import { readManifest } from '../projection/manifest.mjs';
24
+ import { packageVersion } from '../package-version.mjs';
23
25
 
24
26
  const exec = promisify(execFile);
25
27
 
26
28
  /** Default target task count when mining a repository's history. */
27
29
  export const DEFAULT_MINE_TARGET = 20;
28
30
 
31
+ /**
32
+ * Test paths excluded by default, overridable via `simt.json` `bench.mine.exclude`
33
+ * (review 0003 Q48: the pack integration test was hardcoded here, a repo fact, not a miner fact).
34
+ */
35
+ const DEFAULT_EXCLUDES = ['test/pack.test.mjs'];
36
+
29
37
  /**
30
38
  * Classify whether a repository-relative path is a test file.
31
39
  *
@@ -285,13 +293,56 @@ export function parseCargoTestOutput(text) {
285
293
  };
286
294
  }
287
295
 
296
+ /**
297
+ * Read every JUnit XML a run left behind, from report files or report
298
+ * *directories* (review 0003 Q48: Gradle writes one XML per class under
299
+ * `build/test-results/test/`, Maven under `target/surefire-reports/`; reading
300
+ * a single file saw none of them, so both graders read nothing). Directories
301
+ * are walked recursively in sort order so the concatenation is deterministic.
302
+ *
303
+ * @param {string[]} paths report files and/or directories
304
+ * @returns {Promise<string>} the concatenated XML, '' when nothing is there
305
+ */
306
+ export async function readReports(paths) {
307
+ const chunks = [];
308
+ for (const p of paths ?? []) {
309
+ let isDir = false;
310
+ try {
311
+ isDir = (await stat(p)).isDirectory();
312
+ } catch {
313
+ continue;
314
+ }
315
+ if (isDir) {
316
+ const files = await listXml(p);
317
+ for (const file of files) {
318
+ chunks.push(await readFile(file, 'utf8').catch(() => ''));
319
+ }
320
+ } else {
321
+ chunks.push(await readFile(p, 'utf8').catch(() => ''));
322
+ }
323
+ }
324
+ return chunks.join('\n');
325
+ }
326
+
327
+ /** Every `*.xml` under `dir`, sorted, for a deterministic read order. */
328
+ async function listXml(dir) {
329
+ const out = [];
330
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
331
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
332
+ const full = join(dir, entry.name);
333
+ if (entry.isDirectory()) out.push(...(await listXml(full)));
334
+ else if (entry.name.endsWith('.xml')) out.push(full);
335
+ }
336
+ return out;
337
+ }
338
+
288
339
  /**
289
340
  * Detect the test runner for a repository and build the command for a set of
290
341
  * changed test files.
291
342
  *
292
343
  * @param {string} root
293
344
  * @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>}
345
+ * @returns {Promise<{runner: string, displayCmd: string, buildExec: (worktree: string) => {cmd: string, args: string[], reportPaths?: string[]}, parse: (stdout: string, reportText?: string) => {passed: string[], failed: string[]}}|null>}
295
346
  */
296
347
  export async function detectTestRunner(root, testFiles) {
297
348
  const files = [...testFiles].sort();
@@ -317,6 +368,22 @@ export async function detectTestRunner(root, testFiles) {
317
368
  parse: (stdout) => parseVitestJson(stdout),
318
369
  };
319
370
  }
371
+ // Jest emits the same `testResults`/`assertionResults` JSON shape as
372
+ // Vitest (Vitest copied it), so the JSON parser serves both (review 0003
373
+ // Q48: a jest repository fell through to `node --test` and parsed TAP
374
+ // out of JSON).
375
+ if (deps.jest || /\bjest\b/.test(testScript)) {
376
+ const bin = join('node_modules', '.bin', 'jest');
377
+ return {
378
+ runner: 'jest',
379
+ displayCmd: `jest --json ${files.join(' ')}`,
380
+ buildExec: (wt) => ({
381
+ cmd: existsSync(join(wt, bin)) ? join(wt, bin) : 'jest',
382
+ args: ['--json', ...files],
383
+ }),
384
+ parse: (stdout) => parseVitestJson(stdout),
385
+ };
386
+ }
320
387
  return {
321
388
  runner: 'node-test',
322
389
  displayCmd: `node --test ${files.join(' ')}`,
@@ -380,7 +447,7 @@ export async function detectTestRunner(root, testFiles) {
380
447
  return {
381
448
  cmd: 'pytest',
382
449
  args: [`--junitxml=${reportPath}`, ...files],
383
- reportPath,
450
+ reportPaths: [reportPath],
384
451
  };
385
452
  },
386
453
  parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
@@ -398,7 +465,8 @@ export async function detectTestRunner(root, testFiles) {
398
465
  buildExec: (wt) => ({
399
466
  cmd: existsSync(join(wt, 'gradlew')) ? join(wt, 'gradlew') : 'gradle',
400
467
  args: ['test', ...filterArgs],
401
- reportDir: join(wt, 'build', 'test-results', 'test'),
468
+ // One XML per test class; readReports walks the directory.
469
+ reportPaths: [join(wt, 'build', 'test-results', 'test')],
402
470
  }),
403
471
  parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
404
472
  };
@@ -412,9 +480,11 @@ export async function detectTestRunner(root, testFiles) {
412
480
  return {
413
481
  runner: 'maven',
414
482
  displayCmd: `mvn test ${arg.join(' ')}`.trim(),
415
- buildExec: () => ({
483
+ buildExec: (wt) => ({
416
484
  cmd: 'mvn',
417
485
  args: ['test', ...arg],
486
+ // Surefire writes one XML per class here; never read before (review 0003 Q48).
487
+ reportPaths: [join(wt, 'target', 'surefire-reports')],
418
488
  }),
419
489
  parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
420
490
  };
@@ -448,9 +518,32 @@ export function resolveVaultDir(root, { env = process.env, home = homedir(), pla
448
518
  return join(cacheBase, CONFIG_DIR, 'vault', repoId);
449
519
  }
450
520
 
521
+ /**
522
+ * The stable identity a repository's vault is keyed on: the basename for a
523
+ * human-readable prefix, and the root commit (first commit in history) for the
524
+ * digest. The root commit is identical in every clone and survives remote
525
+ * renames, so the vault lands in the same directory on every machine with the
526
+ * same history (review 0003 Q48: the absolute-path key made the committed
527
+ * corpus unrunnable from any other checkout). Outside git it degrades to the
528
+ * absolute-path digest: a root with no history has no better identity, and the
529
+ * vault path must never fail because git is absent.
530
+ *
531
+ * Sync, like package-version.mjs: the vault path is computed on every read, and
532
+ * a path that throws is worse than one that is occasionally less specific.
533
+ */
451
534
  function repoIdentifier(root) {
452
535
  const full = resolve(root);
453
536
  const name = basename(full).replace(/[^A-Za-z0-9._-]/g, '-') || 'repo';
537
+ try {
538
+ const sha = execFileSync('git', ['rev-list', '--max-parents=0', 'HEAD'], {
539
+ cwd: root,
540
+ encoding: 'utf8',
541
+ stdio: ['ignore', 'pipe', 'ignore'],
542
+ }).trim().split(/\s/)[0];
543
+ if (/^[0-9a-f]{40}$/.test(sha)) return `${name}-root-${sha.slice(0, 8)}`;
544
+ } catch {
545
+ // Not a git root (or no commits): fall through to the path digest.
546
+ }
454
547
  const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
455
548
  return `${name}-${digest}`;
456
549
  }
@@ -519,7 +612,11 @@ export async function listCandidates(root) {
519
612
 
520
613
  const cleanSubject = (subject ?? '').trim();
521
614
  const cleanBody = (body ?? '').trim();
522
- const prompt = cleanBody ? `${cleanSubject}\n\n${cleanBody}` : cleanSubject;
615
+ // Subject only by default. A commit body usually narrates the fix ("use x * x * x
616
+ // here"), which hands the agent the answer and measures nothing; a repository whose
617
+ // messages are curated can ask for the whole message with `bench.mine.prompt: "body"`
618
+ // (review 0003 Q49).
619
+ const prompt = cleanSubject;
523
620
 
524
621
  candidates.push({
525
622
  sha: sha.trim(),
@@ -584,14 +681,14 @@ function runProcess(cmd, args, { cwd, input, env, timeoutMs = 15000 } = {}) {
584
681
 
585
682
  async function executeRunner(runner, worktree, env, timeoutMs) {
586
683
  const spec = runner.buildExec(worktree);
587
- if (spec.reportPath && existsSync(spec.reportPath)) {
588
- await rm(spec.reportPath, { force: true });
684
+ // Remove what a previous run left behind before running: a stale report
685
+ // would parse as this run's result, and a directory report accumulates
686
+ // one XML per class across runs.
687
+ for (const p of spec.reportPaths ?? []) {
688
+ if (existsSync(p)) await rm(p, { recursive: true, force: true });
589
689
  }
590
690
  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
- }
691
+ const reportText = await readReports(spec.reportPaths);
595
692
  const parsed = runner.parse(res.stdout, reportText);
596
693
  return {
597
694
  code: res.code,
@@ -606,10 +703,20 @@ async function executeRunner(runner, worktree, env, timeoutMs) {
606
703
  * twice, dropping any candidate whose test outcomes are not identical across both
607
704
  * passes.
608
705
  */
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')) {
706
+ async function validateCandidate(
707
+ root,
708
+ worktree,
709
+ candidate,
710
+ { timeoutMs = 5000, exclude = DEFAULT_EXCLUDES, promptMode = 'subject' } = {},
711
+ ) {
712
+ // Skip commits that modify more than 4 test files, or that touch a path
713
+ // the repository's manifest excludes (`simt.json` bench.mine.exclude,
714
+ // defaulting to the pack integration test the committed corpus was mined
715
+ // around): sweeping renames rather than self-contained benchmark tasks.
716
+ if (
717
+ candidate.testFiles.length > 4 ||
718
+ candidate.testFiles.some((f) => exclude.includes(f))
719
+ ) {
613
720
  return null;
614
721
  }
615
722
 
@@ -682,8 +789,10 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
682
789
  const p2p = gold1.passed.filter((id) => redPassedSet.has(id) && !redFailedSet.has(id));
683
790
  if (f2p.length === 0) return null;
684
791
 
685
- const shortSha = candidate.sha.slice(0, 7);
686
- const id = `t-${shortSha}-01`;
792
+ // 12 hex chars of the source commit: the 7-char form plus a fixed `-01`
793
+ // suffix could not disambiguate two tasks from the same commit (review 0003
794
+ // Q48). Collisions are checked at write time, where the corpus is visible.
795
+ const id = `t-${candidate.sha.slice(0, 12)}-01`;
687
796
  const gold = patchStats(goldPatch, candidate.implFiles.length);
688
797
 
689
798
  return {
@@ -692,7 +801,11 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
692
801
  id,
693
802
  base: candidate.base,
694
803
  sourceCommit: candidate.sha,
695
- prompt: candidate.prompt,
804
+ prompt:
805
+ promptMode === 'body' && candidate.body
806
+ ? `${candidate.subject}\n\n${candidate.body}`
807
+ : candidate.subject,
808
+ promptBasis: promptMode === 'body' && candidate.body ? 'body' : 'subject',
696
809
  testCmd: runner.displayCmd,
697
810
  f2p,
698
811
  p2p,
@@ -713,8 +826,12 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
713
826
  * temporary worktrees until `target` valid tasks are collected, and written
714
827
  * deterministically to disk.
715
828
  *
829
+ * Writing is append-only: existing task files are left alone unless `replace`
830
+ * is set, which restores the old "reflect exactly this history" semantics by
831
+ * deleting files the current history does not reproduce (review 0003 Q48).
832
+ *
716
833
  * @param {string} root
717
- * @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
834
+ * @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, replace?: boolean, env?: NodeJS.ProcessEnv}} [options]
718
835
  */
719
836
  export async function mineTasks(
720
837
  root,
@@ -723,6 +840,7 @@ export async function mineTasks(
723
840
  target = DEFAULT_MINE_TARGET,
724
841
  concurrency = 4,
725
842
  timeoutMs = 5000,
843
+ replace = false,
726
844
  env = process.env,
727
845
  } = {},
728
846
  ) {
@@ -732,12 +850,20 @@ export async function mineTasks(
732
850
  return { ok: false, error: listed.error };
733
851
  }
734
852
 
735
- const tasksDir = join(absRoot, '.simt', 'bench', 'tasks');
853
+ // Repository-declared mining config (review 0003 Q48): an explicit option
854
+ // wins, then the manifest, then the default.
855
+ const { manifest } = await readManifest(absRoot);
856
+ const benchCfg = manifest?.bench?.mine ?? {};
857
+ const effectiveTimeout = benchCfg.timeoutMs ?? timeoutMs;
858
+ const exclude = benchCfg.exclude ?? DEFAULT_EXCLUDES;
859
+ const promptMode = benchCfg.prompt === 'body' ? 'body' : 'subject';
860
+
861
+ const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
736
862
  const vaultRoot = resolveVaultDir(absRoot, { env });
737
863
 
738
864
  if (!apply) {
739
865
  const preview = listed.candidates.slice(0, target).map((c) => ({
740
- id: `t-${c.sha.slice(0, 7)}-01`,
866
+ id: `t-${c.sha.slice(0, 12)}-01`,
741
867
  base: c.base,
742
868
  sourceCommit: c.sha,
743
869
  subject: c.subject,
@@ -790,7 +916,11 @@ export async function mineTasks(
790
916
  cursor += 1;
791
917
  if (index >= listed.candidates.length) break;
792
918
  const candidate = listed.candidates[index];
793
- const res = await validateCandidate(absRoot, wt, candidate, { timeoutMs });
919
+ const res = await validateCandidate(absRoot, wt, candidate, {
920
+ timeoutMs: effectiveTimeout,
921
+ exclude,
922
+ promptMode,
923
+ });
794
924
  if (res) {
795
925
  validated.push({ index, ...res });
796
926
  }
@@ -805,28 +935,62 @@ export async function mineTasks(
805
935
  const selected = validated.slice(0, target);
806
936
 
807
937
  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 });
938
+ await mkdir(vaultRoot, { recursive: true, mode: 0o700 });
939
+ // The hidden tests are the answer key: 0700 keeps every other local account out.
940
+ // `mkdir` alone does not tighten a vault an earlier run created.
941
+ await chmod(vaultRoot, 0o700).catch(() => {});
942
+
943
+ // Mining is append-only (review 0003 Q48: `mineTasks(target 3)` once
944
+ // deleted all 20 committed tasks and wrote 3 others). `replace` restores
945
+ // the old "reflect exactly this history" semantics, and only it deletes.
946
+ if (replace) {
947
+ const existing = await readdir(tasksDir).catch(() => []);
948
+ const keepNames = new Set(selected.map((entry) => `${entry.task.id}.json`));
949
+ for (const name of existing) {
950
+ if (name.endsWith('.json') && !keepNames.has(name)) {
951
+ await rm(join(tasksDir, name), { force: true });
952
+ }
817
953
  }
818
954
  }
819
955
 
820
956
  for (const entry of selected) {
821
957
  const taskPath = join(tasksDir, `${entry.task.id}.json`);
958
+ // An id belongs to exactly one source commit. If a file with this id
959
+ // holds a different commit, the 12-char prefix collided and guessing
960
+ // which corpus the reader wants is worse than refusing.
961
+ if (existsSync(taskPath) && !replace) {
962
+ const prior = JSON.parse(await readFile(taskPath, 'utf8'));
963
+ if (prior.sourceCommit !== entry.task.sourceCommit) {
964
+ return {
965
+ ok: false,
966
+ applied: false,
967
+ error: `id collision: ${taskPath} holds task from ${prior.sourceCommit}, mine produced ${entry.task.sourceCommit}; rerun with --replace to take over the corpus`,
968
+ };
969
+ }
970
+ }
822
971
  await writeFile(taskPath, `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
823
972
 
824
973
  const taskVault = join(vaultRoot, entry.task.id);
825
- await mkdir(taskVault, { recursive: true });
974
+ await mkdir(taskVault, { recursive: true, mode: 0o700 });
975
+ await chmod(taskVault, 0o700).catch(() => {});
826
976
  await writeFile(join(taskVault, 'test.patch'), entry.testPatch);
827
977
  await writeFile(join(taskVault, 'gold.patch'), entry.goldPatch);
828
978
  }
829
979
 
980
+ // The corpus records where it came from: which commit was HEAD when the
981
+ // mine ran, the target, and the simt version that validated the tasks.
982
+ // No timestamp — a re-mine at the same HEAD is byte-identical.
983
+ const { stdout: headSha } = await exec('git', ['rev-parse', 'HEAD'], { cwd: absRoot });
984
+ await writeFile(
985
+ join(dirname(tasksDir), 'corpus.json'),
986
+ `${JSON.stringify(
987
+ { minedFrom: headSha.trim(), target, simtVersion: packageVersion() },
988
+ null,
989
+ 2,
990
+ )}\n`,
991
+ 'utf8',
992
+ );
993
+
830
994
  return {
831
995
  ok: true,
832
996
  applied: true,