@codedology/code-forge 0.9.0 → 0.11.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 (62) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +49 -9
  3. package/docs/reference/blocks.json +587 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/SKILL.md +1 -1
  8. package/skill/references/adapters/claude-code.md +1 -1
  9. package/skill/references/adapters/solo.md +2 -2
  10. package/skill/references/adapters/subprocess.md +12 -2
  11. package/skill/references/code.md +14 -7
  12. package/skill/references/ledger.md +2 -2
  13. package/skill/references/proof.md +77 -2
  14. package/skill/references/review.md +39 -7
  15. package/skill/references/security.md +4 -4
  16. package/skill/scripts/forge +1 -1
  17. package/skill/templates/coder-brief.md +6 -6
  18. package/src/autopilot/binnacle.mjs +9 -3
  19. package/src/cli/block.mjs +262 -31
  20. package/src/cli/jev.mjs +22 -3
  21. package/src/cli/keys.mjs +156 -21
  22. package/src/cli/proof.mjs +35 -7
  23. package/src/cli/review-file.mjs +17 -11
  24. package/src/cli/spawn.mjs +44 -3
  25. package/src/config/validate.mjs +320 -5
  26. package/src/doctor/index.mjs +2 -1
  27. package/src/doctor/local.mjs +27 -6
  28. package/src/doctor/probes.mjs +38 -0
  29. package/src/engines/builders/codex.mjs +44 -4
  30. package/src/engines/sandbox-dirs.mjs +257 -0
  31. package/src/keys/store.mjs +128 -21
  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/engine.mjs +141 -42
  38. package/src/review/fixloop.mjs +75 -23
  39. package/src/review/gate-check.mjs +3 -2
  40. package/src/review/lenses/A.md +2 -0
  41. package/src/review/lenses/B.md +2 -0
  42. package/src/review/lenses/full.md +2 -0
  43. package/src/review/lenses/quick.md +1 -0
  44. package/src/review/lenses/recheck.md +2 -0
  45. package/src/review/packet.mjs +48 -14
  46. package/src/review/paired.mjs +80 -0
  47. package/src/review/reopen.mjs +4 -6
  48. package/src/review/reviewed-lines.mjs +94 -0
  49. package/src/review/route.mjs +232 -0
  50. package/src/review/schema-fallback.mjs +77 -22
  51. package/src/review/session-retry.mjs +44 -9
  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-end.mjs +10 -6
  56. package/src/state/block.mjs +13 -3
  57. package/src/state/config-snapshot.mjs +39 -0
  58. package/src/state/run.mjs +4 -0
  59. package/src/util/forbidden.mjs +2 -2
  60. package/src/worker/engine.mjs +43 -15
  61. package/src/worker/loop.mjs +98 -15
  62. 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;
@@ -10,8 +10,8 @@
10
10
  * status, kind: answers, file: source, by_file, answers}
11
11
  * review-file <path> --block <id> --fresh B75: re-run a cached result that never counted
12
12
  *
13
- * The same content enqueued again after an `unavailable` result is a fresh attempt (`retry: true`),
14
- * not the old result.
13
+ * The same content enqueued again after an `unavailable` or round-1 `split_required` result is a
14
+ * fresh attempt (`retry: true`), not the old result.
15
15
  * A done result that is not approved and carries open findings adds `fix_list` (what the coder
16
16
  * fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
17
17
  * §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) and (B73)
@@ -28,8 +28,9 @@
28
28
  * down on two polls in a row, and a worker whose pid is alive with a fresh heartbeat is never down
29
29
  * (`liveWorker`, B30). Output is one JSON line on stdout.
30
30
  * B75: `--fresh` drops the cached done marker of the file's current content and queues the ticket
31
- * again ONLY when that result never counted as a review: `stale`, `unavailable`, or `refused` with
32
- * `other-run`, `context-unreadable` or a `worktree-*` reason (an allow-list, fail closed). Any
31
+ * again ONLY when that result never counted as a review: `stale`, `unavailable`, round-1
32
+ * `split_required`, or `refused` with `other-run`, `context-unreadable` or a `worktree-*` reason
33
+ * (an allow-list, fail closed). Any
33
34
  * other cached result — a round ran (findings, approved, stopped), `review-state-lost`, an answers
34
35
  * result, an unknown status or reason, an unreadable marker — is refused `fresh-needs-reopen`
35
36
  * (exit 1, `exits: [block reopen, block waive]`, the marker kept byte-for-byte); a
@@ -129,8 +130,9 @@ const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
129
130
 
130
131
  /**
131
132
  * B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
132
- * result that never counted as a review — `stale`, `unavailable`, or `refused` with a reason that
133
- * is not a verdict (`other-run`, `context-unreadable`, `worktree-*`) — whose marker names the same
133
+ * result that never counted as a review — `stale`, `unavailable`, round-1 `split_required`, or
134
+ * `refused` with a reason that is not a verdict (`other-run`, `context-unreadable`, `worktree-*`)
135
+ * — whose marker names the same
134
136
  * status. `tampered` for `refused review-state-tampered`; `counted` for everything else (a round
135
137
  * ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
136
138
  * result).
@@ -145,7 +147,7 @@ export function freshVerdict(text, result) {
145
147
  return 'counted';
146
148
  }
147
149
  if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
148
- if (result.status === 'stale' || result.status === 'unavailable') return 'rerun';
150
+ if (result.status === 'stale' || result.status === 'unavailable' || result.status === 'split_required') return 'rerun';
149
151
  if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
150
152
  if (result.reason === 'review-state-tampered') return 'tampered';
151
153
  return FRESH_REFUSED.has(result.reason) || result.reason.startsWith('worktree-') ? 'rerun' : 'counted';
@@ -166,10 +168,11 @@ export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
166
168
  * crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
167
169
  * `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
168
170
  * the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
169
- * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
171
+ * B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
172
+ * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
170
173
  * @returns {number} the exit code.
171
174
  */
172
- function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf }) {
175
+ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
173
176
  const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
174
177
  const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
175
178
  /** @type {'rerun' | 'tampered' | 'counted'} */
@@ -210,7 +213,7 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
210
213
  // dropped: queue it again now (a crash right here leaves a pending ticket with no marker, which
211
214
  // the next review-file queues anyway); gone: another caller took the marker, a normal enqueue
212
215
  const queued = enqueue({ repoRoot, run, block, file, ...where });
213
- say(outcome === 'dropped' ? { ...queued, fresh: true } : queued);
216
+ say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
214
217
  return 0;
215
218
  }
216
219
  if (verdict === 'tampered') {
@@ -223,7 +226,7 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
223
226
  ticket,
224
227
  file,
225
228
  exits: [...FRESH_EXITS],
226
- message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
229
+ message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable, round-1 split_required or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
227
230
  });
228
231
  return 1;
229
232
  }
@@ -410,6 +413,9 @@ export async function runReviewFile(args, deps = {}) {
410
413
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
411
414
  if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
412
415
  const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
416
+ if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'split_required') {
417
+ return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
418
+ }
413
419
  if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
414
420
  // `unavailable` is never approval AND never final: drop the done marker so the worker runs
415
421
  // the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
package/src/cli/spawn.mjs CHANGED
@@ -6,6 +6,12 @@
6
6
  * on stdout; the `level=… provider=… model=… effort=… fallback_step=…` line goes to stderr.
7
7
  * Exit codes: 0 ok / started, 1 failed (timeout, bad output, refused), 2 usage, 3 unavailable
8
8
  * (every step of the ladder reported unavailability).
9
+ *
10
+ * B93: with `--role coder --run <id>` of a recorded run, `coder.sandbox` in the live config must
11
+ * equal the run-start config snapshot's: a value that differs refuses the session
12
+ * (`sandbox-changed`, exit 1) before anything is spawned. Other roles never read the key and are
13
+ * not checked. A record from before snapshots, a run id with no record, or no `--run` uses the
14
+ * live config. A record that cannot be read is `run-unreadable` (exit 2).
9
15
  */
10
16
 
11
17
  import { readFile } from 'node:fs/promises';
@@ -14,6 +20,8 @@ import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
14
20
  import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
15
21
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
16
22
  import { StateError } from '../state/paths.mjs';
23
+ import { readRun } from '../state/run.mjs';
24
+ import { canonicalJSON, coderSandbox } from '../state/config-snapshot.mjs';
17
25
  import { writeSafe } from '../util/redact.mjs';
18
26
 
19
27
  /**
@@ -46,6 +54,36 @@ export function exitCodeFor(result) {
46
54
  return result.status === 'unavailable' ? 3 : 1;
47
55
  }
48
56
 
57
+ /** The SessionError codes that are a refusal (exit 1), not a usage error (exit 2). */
58
+ const REFUSED_CODES = new Set(['forbidden', 'sandbox-changed', 'sandbox-dir-refused']);
59
+
60
+ /**
61
+ * B93: for `--role coder --run <id>`, the live config, after checking that its `coder.sandbox`
62
+ * equals the run-start snapshot's (the key is fixed for the run). The value is the same, so the
63
+ * live config is what the session builds from. A run id with no record, or a record from before
64
+ * snapshots (no run-start value), uses the live config unchecked.
65
+ * @param {Record<string, any>} live @param {string} runId
66
+ * @returns {Promise<Record<string, any>>}
67
+ * @throws {SessionError} `sandbox-changed` when the live value differs from the snapshot's;
68
+ * `run-unreadable` when the run record exists but cannot be read, parsed or understood.
69
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
70
+ */
71
+ async function checkRunSandbox(live, runId) {
72
+ let pinned;
73
+ try {
74
+ pinned = coderSandbox(await readRun(runId));
75
+ } catch (err) {
76
+ if (err instanceof StateError && err.code === 'no-run') return live; // no recorded run: the live config
77
+ if (err instanceof StateError) throw err; // e.g. config-snapshot: named, exit 2
78
+ throw new SessionError('run-unreadable', `run-unreadable: the record of run ${runId} cannot be read, so coder.sandbox cannot be checked against the run start`);
79
+ }
80
+ if (!pinned.snapshot) return live; // a run from before snapshots: no run-start value to hold to
81
+ if (canonicalJSON(pinned.sandbox ?? null) !== canonicalJSON(live?.coder?.sandbox ?? null)) {
82
+ throw new SessionError('sandbox-changed', `sandbox-changed: coder.sandbox in the live config differs from the run ${runId} snapshot — put it back, or end the run and start a new one`);
83
+ }
84
+ return live;
85
+ }
86
+
49
87
  /**
50
88
  * @param {string[]} args
51
89
  * @param {import('../session/spawn.mjs').SessionDeps & {stdout?: {write: (s: string) => unknown}}} [deps]
@@ -67,9 +105,12 @@ export async function runSpawn(args, deps = {}) {
67
105
  writeSafe(stderr, `spawn: ${loaded.message}\n`);
68
106
  return 2;
69
107
  }
70
- const cfg = loaded.config;
71
108
  const timeout = intFlag(flags.timeout, 'timeout');
72
109
  const schema = typeof flags.schema === 'string' ? await readJSONFlag(flags.schema, 'schema') : undefined;
110
+ // the run id is checked (and its temp root made) before the run record is read
111
+ const runRootDir = typeof flags.run === 'string' ? runRootFor(flags.run, loaded.config?.tmp?.root) : undefined;
112
+ // B93: only a coder reads coder.sandbox, so only a coder session is held to the run-start value
113
+ const cfg = typeof flags.run === 'string' && flags.role === 'coder' ? await checkRunSandbox(loaded.config, flags.run) : loaded.config;
73
114
  const result = await spawnSession(
74
115
  {
75
116
  cfg,
@@ -80,7 +121,7 @@ export async function runSpawn(args, deps = {}) {
80
121
  schema,
81
122
  timeoutMs: timeout === undefined ? undefined : timeout * 1000,
82
123
  background: flags.background === true,
83
- runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
124
+ runRoot: runRootDir,
84
125
  run: typeof flags.run === 'string' ? flags.run : undefined,
85
126
  block: typeof flags.block === 'string' ? flags.block : undefined,
86
127
  slug: slugFor(cfg, root),
@@ -93,7 +134,7 @@ export async function runSpawn(args, deps = {}) {
93
134
  } catch (err) {
94
135
  if (err instanceof StateError || err instanceof SessionError) {
95
136
  writeSafe(stderr, `spawn: ${/** @type {Error} */ (err).message}\n`);
96
- return err instanceof SessionError && err.code === 'forbidden' ? 1 : 2;
137
+ return err instanceof SessionError && REFUSED_CODES.has(err.code) ? 1 : 2;
97
138
  }
98
139
  throw err;
99
140
  }