@codedology/code-forge 0.8.0 → 0.10.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 (64) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +46 -7
  3. package/docs/reference/blocks.json +618 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +21 -7
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +29 -4
  14. package/skill/references/security.md +5 -5
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +4 -2
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +300 -33
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/review-file.mjs +264 -13
  23. package/src/cli/spawn.mjs +44 -3
  24. package/src/config/validate.mjs +320 -5
  25. package/src/doctor/index.mjs +7 -5
  26. package/src/doctor/local.mjs +116 -8
  27. package/src/doctor/probes.mjs +38 -0
  28. package/src/engines/builders/codex.mjs +44 -4
  29. package/src/engines/sandbox-dirs.mjs +257 -0
  30. package/src/keys/store.mjs +128 -21
  31. package/src/ledger/write.mjs +7 -1
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/answers.mjs +445 -0
  38. package/src/review/deletion.mjs +106 -0
  39. package/src/review/engine.mjs +117 -31
  40. package/src/review/fixloop.mjs +70 -28
  41. package/src/review/gate-check.mjs +378 -80
  42. package/src/review/lenses/A.md +1 -0
  43. package/src/review/lenses/B.md +1 -0
  44. package/src/review/lenses/full.md +1 -0
  45. package/src/review/lenses/quick.md +1 -0
  46. package/src/review/lenses/recheck.md +1 -0
  47. package/src/review/reopen.mjs +256 -0
  48. package/src/review/route.mjs +232 -0
  49. package/src/review/schema-fallback.mjs +77 -22
  50. package/src/review/session-retry.mjs +44 -9
  51. package/src/review/stop-exits.mjs +15 -0
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block.mjs +13 -3
  56. package/src/state/config-snapshot.mjs +39 -0
  57. package/src/state/run.mjs +4 -0
  58. package/src/util/forbidden.mjs +11 -1
  59. package/src/util/tmp.mjs +16 -4
  60. package/src/worker/engine.mjs +75 -9
  61. package/src/worker/loop.mjs +82 -15
  62. package/src/worker/queue.mjs +265 -10
  63. package/src/worker/review-state.mjs +346 -51
  64. package/src/worker/ticket.mjs +9 -1
package/src/cli/keys.mjs CHANGED
@@ -5,43 +5,96 @@
5
5
  * message would be masked once the chain registered it.
6
6
  *
7
7
  * keys list
8
- * keys set <name> [--op <ref>] hidden prompt, or read once from 1Password and cache 8 h
8
+ * keys set <name> [--op <ref> [--cache-hours <n>]]
9
+ * hidden prompt, or read once from 1Password and cache it
9
10
  * keys test <name> [--ref <ref>] resolve through the chain (no prompt)
10
11
  * keys remove <name>
11
12
  *
13
+ * Cache time (B84): `--cache-hours` (only with `--op`; a typed key never expires), else
14
+ * `keys.cache_hours` from the `.code-forge.yml` of the project root of the current directory, else
15
+ * 8 h — whole hours from 1 to 24. `keys set --op` and `keys test` read that config; a missing file
16
+ * is the default, a file that cannot be loaded is the default with one WARN line. An invalid
17
+ * `--cache-hours`, or an invalid `keys.cache_hours` for `set --op`, exits 2 before any 1Password
18
+ * call; `keys test` warns once and uses 8 h for an invalid `keys.cache_hours` (as `jev ask` does).
19
+ * A cached entry is never
20
+ * trusted for longer than those hours (`store.read`).
21
+ *
22
+ * Expiry warning (B85): `keys list` keeps its stdout table and writes one stderr line per key with
23
+ * less than 1 hour left — `WARN <name> expires in <m> min; re-link: code-forge keys set <name> --op
24
+ * <ref>` — naming the `op://` reference from the index row, or a placeholder for a row written
25
+ * before the index carried it.
26
+ *
12
27
  * An `--op`/`--ref` value may be a 1Password item ID or item link instead of `op://vault/item/field`:
13
28
  * it is resolved to `op://<vaultId>/<itemId>/<fieldId>` first (`op item get`, B25).
14
29
  *
15
30
  * Every name is validated before any store call, on every subcommand.
16
31
  */
17
32
 
33
+ import path from 'node:path';
18
34
  import { writeSafe } from '../util/redact.mjs';
35
+ import { DEFAULT_CONFIG_FILENAME, loadConfigFile, projectRootFor } from '../config/load.mjs';
19
36
  import { describeOpItem, isOpRef, OP_APPROVE_NOTE, OP_MESSAGES, opItemIdFromInput, toOpRef } from '../keys/onepassword.mjs';
20
- import { assertKeyName, createDefaultKeyStore, parseRef, readOpAndCache, resolveKey } from '../keys/store.mjs';
37
+ import { assertKeyName, CACHE_HOURS_RULE, cacheHoursFrom, createDefaultKeyStore, DEFAULT_CACHE_HOURS, isCacheHours, isTime, parseRef, readOpAndCache, resolveKey, shellWord } from '../keys/store.mjs';
38
+
39
+ const USAGE = 'usage: code-forge keys list | set <name> [--op <item-id|link|op://ref> [--cache-hours <1-24>]] | test <name> [--ref <ref>] | remove <name>\n';
21
40
 
22
- const USAGE = 'usage: code-forge keys list | set <name> [--op <item-id|link|op://ref>] | test <name> [--ref <ref>] | remove <name>\n';
41
+ /** The flags each subcommand accepts (list and remove take none); each at most once, any order. */
42
+ const FLAGS = /** @type {Record<string, string[]>} */ ({ list: [], set: ['--op', '--cache-hours'], test: ['--ref'], remove: [] });
43
+
44
+ /**
45
+ * @param {string} text
46
+ * @returns {number | null} the whole number of hours `text` spells (digits only) within the
47
+ * bounds, else null.
48
+ */
49
+ export function parseCacheHours(text) {
50
+ if (!/^[0-9]{1,3}$/.test(text)) {
51
+ return null;
52
+ }
53
+ const hours = Number(text);
54
+ return isCacheHours(hours) ? hours : null;
55
+ }
23
56
 
24
- /** The one flag each subcommand accepts (list and remove take none). */
25
- const FLAGS = /** @type {Record<string, string|null>} */ ({ list: null, set: '--op', test: '--ref', remove: null });
57
+ /** Below this much time left, `keys list` warns (B85). */
58
+ const WARN_BELOW_MS = 3_600_000;
59
+
60
+ /**
61
+ * @param {{name: string, exp: number|null, ref?: string}} row
62
+ * @param {number} now
63
+ * @returns {string|null} the stderr warning for a key with less than 1 hour left, else null (also
64
+ * for an `exp` that is not a valid time).
65
+ */
66
+ export function expiryWarning(row, now) {
67
+ if (!isTime(row.exp) || row.exp <= now || row.exp - now >= WARN_BELOW_MS) {
68
+ return null;
69
+ }
70
+ // whole minutes left, never 0 and never 60 (under 1 hour is at most 59 min 59 s)
71
+ const minutes = Math.max(1, Math.floor((row.exp - now) / 60_000));
72
+ const ref = typeof row.ref === 'string' ? shellWord(row.ref) : '<your 1Password reference>';
73
+ return `WARN ${row.name} expires in ${minutes} min; re-link: code-forge keys set ${row.name} --op ${ref}\n`;
74
+ }
26
75
 
27
76
  /**
28
77
  * @param {number|null} exp
29
78
  * @param {number} now
30
- * @returns {string}
79
+ * @returns {string} `unknown` for an `exp` that is not a valid time (a hand-edited index).
31
80
  */
32
81
  export function formatExpiry(exp, now) {
33
82
  if (exp === null) {
34
83
  return 'never';
35
84
  }
85
+ if (!isTime(exp)) {
86
+ return 'unknown';
87
+ }
36
88
  return exp <= now ? 'expired' : new Date(exp).toISOString();
37
89
  }
38
90
 
39
91
  /**
40
- * Strict parse: `<sub> [<name>] [<flag> <value>]`, in that order. A flag with no value, a value
41
- * that looks like a flag, a name that looks like a flag, an unknown flag or an extra argument is
42
- * a usage error — never silently reinterpreted.
92
+ * Strict parse: `<sub> [<name>] [<flag> <value>]...` — the name first, then the subcommand's flags,
93
+ * each at most once, in any order. A flag with no value, a value that looks like a flag, a name
94
+ * that looks like a flag, an unknown or repeated flag, an extra argument, or `--cache-hours`
95
+ * without `--op` (a typed key never expires) is a usage error — never silently reinterpreted.
43
96
  * @param {string[]} args
44
- * @returns {{sub: string, name?: string, flagArg?: string} | null}
97
+ * @returns {{sub: string, name?: string, flagArg?: string, cacheHoursArg?: string} | null}
45
98
  */
46
99
  export function parseKeysArgs(args) {
47
100
  const [sub, ...rest] = args;
@@ -51,17 +104,77 @@ export function parseKeysArgs(args) {
51
104
  if (sub === 'list') {
52
105
  return rest.length === 0 ? { sub } : null;
53
106
  }
54
- const [name, flagName, flagArg, ...extra] = rest;
55
- if (typeof name !== 'string' || name.startsWith('-') || extra.length > 0) {
107
+ const [name, ...flags] = rest;
108
+ if (typeof name !== 'string' || name.startsWith('-') || flags.length % 2 !== 0) {
56
109
  return null;
57
110
  }
58
- if (flagName === undefined) {
59
- return { sub, name };
111
+ /** @type {Record<string, string>} */
112
+ const values = {};
113
+ for (let i = 0; i < flags.length; i += 2) {
114
+ const flagName = flags[i];
115
+ const value = flags[i + 1];
116
+ if (!FLAGS[sub].includes(flagName) || Object.hasOwn(values, flagName) || value.length === 0 || value.startsWith('-')) {
117
+ return null;
118
+ }
119
+ values[flagName] = value;
60
120
  }
61
- if (flagName !== FLAGS[sub] || typeof flagArg !== 'string' || flagArg.length === 0 || flagArg.startsWith('-')) {
121
+ if (values['--cache-hours'] !== undefined && values['--op'] === undefined) {
62
122
  return null;
63
123
  }
64
- return { sub, name, flagArg };
124
+ const flagArg = sub === 'set' ? values['--op'] : values['--ref'];
125
+ return {
126
+ sub,
127
+ name,
128
+ ...(flagArg !== undefined ? { flagArg } : {}),
129
+ ...(values['--cache-hours'] !== undefined ? { cacheHoursArg: values['--cache-hours'] } : {}),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * The cache time for `keys set --op` / `keys test`: `--cache-hours`, else `keys.cache_hours` from
135
+ * the project config, else 8. An invalid flag fails closed; an invalid `keys.cache_hours` fails
136
+ * closed when `strict` (`set --op`) and otherwise (`test`) gives the default with one warning. A
137
+ * config that cannot be loaded gives the default with one warning (`keys` must keep working beside
138
+ * an unrelated config error).
139
+ * @param {string|undefined} cacheHoursArg
140
+ * @param {string} cwd
141
+ * @param {typeof loadConfigFile} loadConfig
142
+ * @param {boolean} strict - true for `set --op`: an invalid `keys.cache_hours` refuses.
143
+ * @returns {Promise<{ok: true, hours: number, from: string|null, warning: string|null} | {ok: false, message: string}>}
144
+ * `from` names where hours other than the default came from.
145
+ */
146
+ async function cacheHoursFor(cacheHoursArg, cwd, loadConfig, strict) {
147
+ let fromFlag = null;
148
+ if (cacheHoursArg !== undefined) {
149
+ fromFlag = parseCacheHours(cacheHoursArg);
150
+ if (fromFlag === null) {
151
+ return { ok: false, message: `keys: --cache-hours must be ${CACHE_HOURS_RULE}\n` };
152
+ }
153
+ }
154
+ const loaded = await loadConfig(path.join(projectRootFor(cwd), DEFAULT_CONFIG_FILENAME));
155
+ /** @type {string|null} */
156
+ let warning = null;
157
+ /** @type {Record<string, any>} */
158
+ let cfg = {};
159
+ if (loaded.ok) {
160
+ cfg = loaded.config ?? {};
161
+ } else if (loaded.error !== 'not-found') {
162
+ // the error class only, never the loader's message; the WARN names the hours really used
163
+ const used = fromFlag !== null ? `${fromFlag} h from --cache-hours` : `the default ${DEFAULT_CACHE_HOURS} h`;
164
+ warning = `WARN keys: ${DEFAULT_CONFIG_FILENAME} cannot be loaded (${loaded.error}); caching for ${used}\n`;
165
+ }
166
+ const fromConfig = cacheHoursFrom(cfg);
167
+ if (fromConfig.problem !== null) {
168
+ if (strict) {
169
+ return { ok: false, message: `keys: ${fromConfig.problem} (${DEFAULT_CONFIG_FILENAME})\n` };
170
+ }
171
+ // fromConfig.hours is already the default here
172
+ warning = `WARN keys: ${fromConfig.problem} (${DEFAULT_CONFIG_FILENAME}); caching for the default ${fromConfig.hours} h\n`;
173
+ }
174
+ if (fromFlag !== null) {
175
+ return { ok: true, hours: fromFlag, from: '--cache-hours', warning };
176
+ }
177
+ return { ok: true, hours: fromConfig.hours, from: fromConfig.source === 'config' ? 'keys.cache_hours' : null, warning };
65
178
  }
66
179
 
67
180
  /** @param {string} name */
@@ -82,9 +195,11 @@ async function askHidden(name) {
82
195
  * @param {typeof import('../util/exec.mjs').exec} [deps.opExec] - runs `op item get` for an item ID or link.
83
196
  * @param {NodeJS.ProcessEnv} [deps.env]
84
197
  * @param {number} [deps.now]
198
+ * @param {string} [deps.cwd] - the project config is read at the project root of this directory.
199
+ * @param {typeof loadConfigFile} [deps.loadConfig]
85
200
  * @returns {Promise<number>}
86
201
  */
87
- export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, opExec, env = process.env, now = Date.now() }) {
202
+ export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, opExec, env = process.env, now = Date.now(), cwd = process.cwd(), loadConfig = loadConfigFile }) {
88
203
  const out = (/** @type {string} */ s) => writeSafe(stdout, s);
89
204
  const err = (/** @type {string} */ s) => writeSafe(stderr, s);
90
205
 
@@ -93,7 +208,7 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
93
208
  err(USAGE);
94
209
  return 2;
95
210
  }
96
- const { sub, name } = parsed;
211
+ const { sub, name, cacheHoursArg } = parsed;
97
212
  let { flagArg } = parsed;
98
213
  try {
99
214
  if (name !== undefined) {
@@ -103,6 +218,21 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
103
218
  err(`keys: ${e.message}\n`);
104
219
  return 2;
105
220
  }
221
+ // B84: the cache time is settled before any 1Password call (`set --op` and `test` may cache);
222
+ // only `set --op` refuses an invalid keys.cache_hours, `test` warns once and uses 8 h
223
+ /** @type {{hours: number, from: string|null}} */
224
+ let cache = { hours: DEFAULT_CACHE_HOURS, from: null };
225
+ if (sub === 'test' || (sub === 'set' && flagArg !== undefined)) {
226
+ const found = await cacheHoursFor(cacheHoursArg, cwd, loadConfig, sub === 'set');
227
+ if ('message' in found) {
228
+ err(found.message);
229
+ return 2;
230
+ }
231
+ if (found.warning !== null) {
232
+ err(found.warning);
233
+ }
234
+ cache = found;
235
+ }
106
236
  const fullRef = flagArg !== undefined && isOpRef(flagArg);
107
237
  const itemId = flagArg !== undefined && !fullRef ? opItemIdFromInput(flagArg) : null;
108
238
  if (sub === 'set' && flagArg !== undefined && !fullRef && itemId === null) {
@@ -147,6 +277,10 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
147
277
  for (const row of rows) {
148
278
  out(`${row.name}\t${row.source}\t${row.backend}\t${formatExpiry(row.exp, now)}\n`);
149
279
  }
280
+ for (const row of rows) {
281
+ const warning = expiryWarning(row, now);
282
+ if (warning !== null) err(warning);
283
+ }
150
284
  return 0;
151
285
  }
152
286
 
@@ -157,7 +291,7 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
157
291
  }
158
292
 
159
293
  if (sub === 'test') {
160
- const res = await resolveKey(name, { store, ref: flagArg, env, opRead, now });
294
+ const res = await resolveKey(name, { store, ref: flagArg, env, opRead, now, cacheHours: cache.hours });
161
295
  for (const e of res.errors) {
162
296
  err(`${name}: ${e}\n`);
163
297
  }
@@ -170,7 +304,7 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
170
304
  }
171
305
 
172
306
  if (flagArg !== undefined) {
173
- const res = await readOpAndCache(name, flagArg, { store, opRead, now });
307
+ const res = await readOpAndCache(name, flagArg, { store, opRead, now, cacheHours: cache.hours });
174
308
  for (const e of res.errors) {
175
309
  err(`${name}: ${e}\n`);
176
310
  }
@@ -178,7 +312,8 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
178
312
  if (res.value === null) {
179
313
  return 1;
180
314
  }
181
- out(`stored ${name} in ${store.writer} (source=op, expires=${formatExpiry(res.exp, now)})\n`);
315
+ const from = cache.from === null ? '' : ` (cache ${cache.hours} h from ${cache.from})`;
316
+ out(`stored ${name} in ${store.writer} (source=op, expires=${formatExpiry(res.exp, now)})${from}\n`);
182
317
  return 0;
183
318
  }
184
319
 
package/src/cli/proof.mjs CHANGED
@@ -16,6 +16,9 @@
16
16
  * which this block must hold; exit 0 proven, 1 not proven or refused).
17
17
  * The signed row carries `covers` (B20): the reverted sources, or
18
18
  * the test file for `assertion-deletion`.
19
+ * B87: a test file a `proof.runners` entry of the run snapshot matches
20
+ * runs with that entry's argv and is read from its JUnit report; the
21
+ * row and the output then carry `runner`, `env_keys` and `failed_tests`.
19
22
  *
20
23
  * `export` reads `proof.export.{link_dirs,copy_untracked}` from the workspace's `.code-forge.yml`
21
24
  * (defaults §1.3) and writes a signed `proof` row `isolation: export`; `lock`/`unlock` write
@@ -35,12 +38,14 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
35
38
  import { validateConfig } from '../config/validate.mjs';
36
39
  import { appendRow } from '../ledger/write.mjs';
37
40
  import { parseFlags } from '../state/cli-args.mjs';
41
+ import { proofRunners } from '../state/config-snapshot.mjs';
38
42
  import { StateError } from '../state/paths.mjs';
39
43
  import { readRun } from '../state/run.mjs';
40
44
  import { redactJSON, writeSafe } from '../util/redact.mjs';
41
45
  import { DEFAULT_COPY_UNTRACKED, DEFAULT_LINK_DIRS, buildExport, exportDirFor, recordProof, removeExport } from '../proof/export.mjs';
42
46
  import { acquireProofLock, lockHolder, releaseProofLock } from '../proof/lock.mjs';
43
47
  import { MECHANISMS, readJournal, restoreJournal, runRedGreen } from '../proof/red-green.mjs';
48
+ import { adapterPrecheck, runnerCovers, runnerHook, runnerTimeoutMs, selectRunner } from '../proof/runners.mjs';
44
49
  import { tierFor } from '../proof/tiers.mjs';
45
50
  import { blockTree } from '../proof/worktree.mjs';
46
51
  import { blockFileSet, repoRelativePath } from '../review/gate-check.mjs';
@@ -83,13 +88,15 @@ async function assertTestFile(dir, file) {
83
88
 
84
89
  /**
85
90
  * The test command filtered to one test: `node --test [--test-name-pattern ^<case>$] <file>`, run
86
- * with the Node that runs this CLI. Other stacks (Pest, Vitest…) have no filtered command here yet.
91
+ * with the Node that runs this CLI. Other stacks need a `proof.runners` entry (B87).
87
92
  * @param {string} file @param {string | undefined} testCase
88
93
  * @returns {string[]}
89
94
  * @throws {StateError} `unsupported-test`
90
95
  */
91
96
  function filteredTestArgv(file, testCase) {
92
- if (!NODE_TEST_FILE.test(file)) throw new StateError('unsupported-test', 'only node:test files (.mjs, .cjs, .js) have a filtered test command in this version');
97
+ if (!NODE_TEST_FILE.test(file)) {
98
+ throw new StateError('unsupported-test', 'only node:test files (.mjs, .cjs, .js) have a filtered test command in this version — add a proof.runners entry for it (see proof.md §2)');
99
+ }
93
100
  const pattern = testCase === undefined ? [] : ['--test-name-pattern', `^${testCase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`];
94
101
  return [process.execPath, '--test', ...pattern, file];
95
102
  }
@@ -178,11 +185,14 @@ async function exportAndRecord({ runId, record, blockId, block, place, config, w
178
185
 
179
186
  /**
180
187
  * @param {string[]} args
181
- * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
188
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown},
189
+ * precheck?: (entry: import('../proof/runners.mjs').RunnerEntry) => Promise<void>,
190
+ * hook?: (entry: import('../proof/runners.mjs').RunnerEntry, test: {file: string, testCase?: string}) => import('../proof/runners.mjs').RunnerHook | null}} [deps] -
191
+ * `precheck` and `hook` are test-only seams for the runner adapter's precheck and hook (B87)
182
192
  * @returns {Promise<number>}
183
193
  */
184
194
  export async function runProof(args, deps = {}) {
185
- const { stdout = process.stdout, stderr = process.stderr } = deps;
195
+ const { stdout = process.stdout, stderr = process.stderr, precheck = adapterPrecheck, hook = runnerHook } = deps;
186
196
  const out = (/** @type {object} */ value) => writeSafe(stdout, `${redactJSON(value)}\n`);
187
197
  const err = (/** @type {string} */ s) => writeSafe(stderr, s);
188
198
  const [sub, ...rest] = args;
@@ -270,9 +280,17 @@ export async function runProof(args, deps = {}) {
270
280
  const testCase = sep < 0 ? undefined : flags.test.slice(sep + 2);
271
281
  if (testCase === '') throw new StateError('usage', '--test <file>::<case> needs a case name after ::');
272
282
  const file = normalizeTestPath(sep < 0 ? flags.test : flags.test.slice(0, sep));
273
- const argv = filteredTestArgv(file, testCase);
274
283
  const runId = flags.run;
275
284
  const record = await readRun(runId);
285
+ // B87: the runner comes from the run snapshot only, never the live file; no entry matches ⇒
286
+ // the built-in node:test command, or the refusal
287
+ const runners = proofRunners(record);
288
+ const entry = selectRunner(runners, file);
289
+ const runner = entry === null ? null : (hook(entry, { file, testCase }) ?? null);
290
+ // a matched entry is run by its hook or not at all: never a node:test fallback (no row, no precheck)
291
+ if (entry !== null && runner === null) throw new StateError('usage', `the ${entry.adapter} runner for ${file} has no hook; no proof was run`);
292
+ // exactly one of the two: the built-in argv without a runner, the runner (its own argv) with one
293
+ const how = runner === null || entry === null ? { argv: filteredTestArgv(file, testCase) } : { runner, timeoutMs: runnerTimeoutMs(entry) };
276
294
  const block = record.blocks?.[blockId];
277
295
  if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
278
296
  const place = await proofPlace(runId, record, blockId, block);
@@ -292,17 +310,20 @@ export async function runProof(args, deps = {}) {
292
310
  owned: block.owned_files,
293
311
  matchOwned: (f) => ownsFile(block.owned_files, f, block.owned_kinds),
294
312
  });
295
- const sources = mechanism === 'assertion-deletion' ? [] : changed.map((c) => c.file).filter((f) => f !== file && !TEST_PATH.test(f));
313
+ // B87: a file a configured runner matches is a test too, never a source
314
+ const sources = mechanism === 'assertion-deletion' ? [] : changed.map((c) => c.file).filter((f) => f !== file && !TEST_PATH.test(f) && !runnerCovers(runners, f));
296
315
  if (mechanism === 'revert' && sources.length === 0) {
297
316
  throw new StateError('usage', `block ${blockId} changed no non-test source file to revert — use --mechanism assertion-deletion`);
298
317
  }
318
+ // B87: the adapter's precheck runs before the export is built and before any file is reverted
319
+ if (entry !== null) await precheck(entry);
299
320
  const exp = isolation === 'export' ? await exportAndRecord({ runId, record, blockId, block, place, config, writeRow }) : null;
300
321
  if (exp) await assertTestFile(exp.dir, file); // the test must also be where it runs (owned, or tracked at base)
301
322
  const result = await runRedGreen({
302
323
  workDir: exp ? exp.dir : place.dir,
303
324
  repoDir: place.dir,
304
325
  base: place.base,
305
- argv,
326
+ ...how,
306
327
  test: { file, ...(testCase === undefined ? {} : { case: testCase }), characterization: mechanism === 'assertion-deletion' },
307
328
  sources,
308
329
  isolation,
@@ -317,6 +338,11 @@ export async function runProof(args, deps = {}) {
317
338
  // still lands, `proven: false` and `red_kind: null`, and the verb exits 1.
318
339
  const red = result.red ?? null;
319
340
  const proven = result.proven === true && red !== null;
341
+ // B87: only when a runner really ran, names only — its adapter, the bounded failed test names
342
+ // and the env variable names (row and JSON alike); `failed_ids` stays in memory, and env
343
+ // values are never shown
344
+ const runnerFields =
345
+ runner === null ? {} : { runner: runner.adapter, failed_tests: red !== null && 'failed_tests' in red ? red.failed_tests : [], env_keys: [...runner.env_keys] };
320
346
  await recordProof({
321
347
  runId,
322
348
  writeRow,
@@ -332,6 +358,7 @@ export async function runProof(args, deps = {}) {
332
358
  proven,
333
359
  failed: red?.failed ?? null,
334
360
  covers,
361
+ ...runnerFields,
335
362
  },
336
363
  });
337
364
  out({
@@ -343,6 +370,7 @@ export async function runProof(args, deps = {}) {
343
370
  red_kind: red?.red_kind ?? null,
344
371
  green: result.green ?? null,
345
372
  proven,
373
+ ...runnerFields,
346
374
  ...(exp ? { export: { dir: exp.dir, restored: exp.restored, untracked: exp.untracked } } : {}),
347
375
  });
348
376
  return proven ? 0 : 1;