simt-dev 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  13. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  18. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -10,30 +10,39 @@
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
  *
32
- * Covers the five ecosystems named in plan 0003 §5 R01:
40
+ * Covers the ecosystems named in plan 0003 §5 R01:
33
41
  * - `test/`, `tests/`, `spec/`, `__tests__/`, `src/test/`
34
42
  * - `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`
35
43
  * - `*_test.go` (Go)
36
44
  * - `test_*.py` (pytest)
45
+ * - `tests/*.rs` (Cargo integration tests)
37
46
  * - `*Test.java`, `*Tests.java`, `*Test.kt`, `*Tests.kt` (Gradle / Maven)
38
47
  *
39
48
  * @param {string} relPath forward-slash repository-relative path
@@ -259,13 +268,81 @@ export function parseGoTestJson(text) {
259
268
  };
260
269
  }
261
270
 
271
+ /**
272
+ * Parse libtest's text output, which `cargo test` prints for every test binary.
273
+ *
274
+ * A line is a result only when it has the `test <name> ... <status>` shape.
275
+ * Harness summaries (`test result: ok.`), compile output and doctest prose are
276
+ * not results. `ignored` is neither a pass nor a failure: counting it as one
277
+ * would make a skipped test look like evidence the change works.
278
+ *
279
+ * @param {string} text
280
+ * @returns {{passed: string[], failed: string[]}}
281
+ */
282
+ export function parseCargoTestOutput(text) {
283
+ const passed = [];
284
+ const failed = [];
285
+ for (const line of String(text ?? '').split('\n')) {
286
+ const match = /^test\s+(.+?)\s+\.\.\.\s+(ok|FAILED|ignored)\s*$/.exec(line.trimEnd());
287
+ if (!match || match[2] === 'ignored') continue;
288
+ (match[2] === 'ok' ? passed : failed).push(match[1]);
289
+ }
290
+ return {
291
+ passed: dedupeSorted(passed),
292
+ failed: dedupeSorted(failed),
293
+ };
294
+ }
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
+
262
339
  /**
263
340
  * Detect the test runner for a repository and build the command for a set of
264
341
  * changed test files.
265
342
  *
266
343
  * @param {string} root
267
344
  * @param {string[]} testFiles
268
- * @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>}
269
346
  */
270
347
  export async function detectTestRunner(root, testFiles) {
271
348
  const files = [...testFiles].sort();
@@ -291,6 +368,22 @@ export async function detectTestRunner(root, testFiles) {
291
368
  parse: (stdout) => parseVitestJson(stdout),
292
369
  };
293
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
+ }
294
387
  return {
295
388
  runner: 'node-test',
296
389
  displayCmd: `node --test ${files.join(' ')}`,
@@ -302,6 +395,27 @@ export async function detectTestRunner(root, testFiles) {
302
395
  };
303
396
  }
304
397
 
398
+ if (existsSync(join(root, 'Cargo.toml'))) {
399
+ // Cargo has no "run this file" argument. An integration test is a target
400
+ // named after its file in `tests/`, so it can be selected with `--test`.
401
+ // A source file carries inline unit tests and can only be reached by the
402
+ // crate's test binary, so one such file means the whole crate runs.
403
+ const targets = dedupeSorted(
404
+ files.filter((f) => /^tests\/[^/]+\.rs$/.test(f)).map((f) => basename(f, '.rs')),
405
+ );
406
+ const integrationOnly = files.length > 0 && files.every((f) => /^tests\/[^/]+\.rs$/.test(f));
407
+ const args = ['test'];
408
+ if (integrationOnly) {
409
+ for (const target of targets) args.push('--test', target);
410
+ }
411
+ return {
412
+ runner: 'cargo-test',
413
+ displayCmd: ['cargo', ...args].join(' '),
414
+ buildExec: () => ({ cmd: 'cargo', args }),
415
+ parse: (stdout) => parseCargoTestOutput(stdout),
416
+ };
417
+ }
418
+
305
419
  if (existsSync(join(root, 'go.mod'))) {
306
420
  const pkgs = dedupeSorted(
307
421
  files.map((f) => {
@@ -333,7 +447,7 @@ export async function detectTestRunner(root, testFiles) {
333
447
  return {
334
448
  cmd: 'pytest',
335
449
  args: [`--junitxml=${reportPath}`, ...files],
336
- reportPath,
450
+ reportPaths: [reportPath],
337
451
  };
338
452
  },
339
453
  parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
@@ -351,7 +465,8 @@ export async function detectTestRunner(root, testFiles) {
351
465
  buildExec: (wt) => ({
352
466
  cmd: existsSync(join(wt, 'gradlew')) ? join(wt, 'gradlew') : 'gradle',
353
467
  args: ['test', ...filterArgs],
354
- 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')],
355
470
  }),
356
471
  parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
357
472
  };
@@ -365,9 +480,11 @@ export async function detectTestRunner(root, testFiles) {
365
480
  return {
366
481
  runner: 'maven',
367
482
  displayCmd: `mvn test ${arg.join(' ')}`.trim(),
368
- buildExec: () => ({
483
+ buildExec: (wt) => ({
369
484
  cmd: 'mvn',
370
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')],
371
488
  }),
372
489
  parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
373
490
  };
@@ -401,9 +518,32 @@ export function resolveVaultDir(root, { env = process.env, home = homedir(), pla
401
518
  return join(cacheBase, CONFIG_DIR, 'vault', repoId);
402
519
  }
403
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
+ */
404
534
  function repoIdentifier(root) {
405
535
  const full = resolve(root);
406
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
+ }
407
547
  const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
408
548
  return `${name}-${digest}`;
409
549
  }
@@ -472,7 +612,11 @@ export async function listCandidates(root) {
472
612
 
473
613
  const cleanSubject = (subject ?? '').trim();
474
614
  const cleanBody = (body ?? '').trim();
475
- 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;
476
620
 
477
621
  candidates.push({
478
622
  sha: sha.trim(),
@@ -537,14 +681,14 @@ function runProcess(cmd, args, { cwd, input, env, timeoutMs = 15000 } = {}) {
537
681
 
538
682
  async function executeRunner(runner, worktree, env, timeoutMs) {
539
683
  const spec = runner.buildExec(worktree);
540
- if (spec.reportPath && existsSync(spec.reportPath)) {
541
- 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 });
542
689
  }
543
690
  const res = await runProcess(spec.cmd, spec.args, { cwd: worktree, env, timeoutMs });
544
- let reportText = '';
545
- if (spec.reportPath && existsSync(spec.reportPath)) {
546
- reportText = await readFile(spec.reportPath, 'utf8').catch(() => '');
547
- }
691
+ const reportText = await readReports(spec.reportPaths);
548
692
  const parsed = runner.parse(res.stdout, reportText);
549
693
  return {
550
694
  code: res.code,
@@ -559,10 +703,20 @@ async function executeRunner(runner, worktree, env, timeoutMs) {
559
703
  * twice, dropping any candidate whose test outcomes are not identical across both
560
704
  * passes.
561
705
  */
562
- async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 } = {}) {
563
- // Skip commits that modify more than 4 test files or run `npm pack` integration
564
- // builds, which are sweeping renames rather than self-contained benchmark tasks.
565
- 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
+ ) {
566
720
  return null;
567
721
  }
568
722
 
@@ -635,8 +789,10 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
635
789
  const p2p = gold1.passed.filter((id) => redPassedSet.has(id) && !redFailedSet.has(id));
636
790
  if (f2p.length === 0) return null;
637
791
 
638
- const shortSha = candidate.sha.slice(0, 7);
639
- 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`;
640
796
  const gold = patchStats(goldPatch, candidate.implFiles.length);
641
797
 
642
798
  return {
@@ -645,7 +801,11 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
645
801
  id,
646
802
  base: candidate.base,
647
803
  sourceCommit: candidate.sha,
648
- 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',
649
809
  testCmd: runner.displayCmd,
650
810
  f2p,
651
811
  p2p,
@@ -666,8 +826,12 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
666
826
  * temporary worktrees until `target` valid tasks are collected, and written
667
827
  * deterministically to disk.
668
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
+ *
669
833
  * @param {string} root
670
- * @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]
671
835
  */
672
836
  export async function mineTasks(
673
837
  root,
@@ -676,6 +840,7 @@ export async function mineTasks(
676
840
  target = DEFAULT_MINE_TARGET,
677
841
  concurrency = 4,
678
842
  timeoutMs = 5000,
843
+ replace = false,
679
844
  env = process.env,
680
845
  } = {},
681
846
  ) {
@@ -685,12 +850,20 @@ export async function mineTasks(
685
850
  return { ok: false, error: listed.error };
686
851
  }
687
852
 
688
- 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');
689
862
  const vaultRoot = resolveVaultDir(absRoot, { env });
690
863
 
691
864
  if (!apply) {
692
865
  const preview = listed.candidates.slice(0, target).map((c) => ({
693
- id: `t-${c.sha.slice(0, 7)}-01`,
866
+ id: `t-${c.sha.slice(0, 12)}-01`,
694
867
  base: c.base,
695
868
  sourceCommit: c.sha,
696
869
  subject: c.subject,
@@ -743,7 +916,11 @@ export async function mineTasks(
743
916
  cursor += 1;
744
917
  if (index >= listed.candidates.length) break;
745
918
  const candidate = listed.candidates[index];
746
- const res = await validateCandidate(absRoot, wt, candidate, { timeoutMs });
919
+ const res = await validateCandidate(absRoot, wt, candidate, {
920
+ timeoutMs: effectiveTimeout,
921
+ exclude,
922
+ promptMode,
923
+ });
747
924
  if (res) {
748
925
  validated.push({ index, ...res });
749
926
  }
@@ -758,28 +935,62 @@ export async function mineTasks(
758
935
  const selected = validated.slice(0, target);
759
936
 
760
937
  await mkdir(tasksDir, { recursive: true });
761
- await mkdir(vaultRoot, { recursive: true });
762
-
763
- // Remove stale task files when re-mining so the directory reflects the
764
- // deterministic set for this history.
765
- const existing = await readdir(tasksDir).catch(() => []);
766
- const keepNames = new Set(selected.map((entry) => `${entry.task.id}.json`));
767
- for (const name of existing) {
768
- if (name.endsWith('.json') && !keepNames.has(name)) {
769
- 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
+ }
770
953
  }
771
954
  }
772
955
 
773
956
  for (const entry of selected) {
774
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
+ }
775
971
  await writeFile(taskPath, `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
776
972
 
777
973
  const taskVault = join(vaultRoot, entry.task.id);
778
- await mkdir(taskVault, { recursive: true });
974
+ await mkdir(taskVault, { recursive: true, mode: 0o700 });
975
+ await chmod(taskVault, 0o700).catch(() => {});
779
976
  await writeFile(join(taskVault, 'test.patch'), entry.testPatch);
780
977
  await writeFile(join(taskVault, 'gold.patch'), entry.goldPatch);
781
978
  }
782
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
+
783
994
  return {
784
995
  ok: true,
785
996
  applied: true,