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
@@ -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
  };