@codedology/code-forge 0.15.1 → 0.15.3

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 (63) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +154 -1
  5. package/docs/reference/config.md +27 -14
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +57 -2
  8. package/schema/outcome.schema.json +45 -0
  9. package/schema/pipeline.schema.json +30 -0
  10. package/scripts/first-use.mjs +8 -0
  11. package/scripts/gen-config-doc.mjs +1 -1
  12. package/skill/references/code.md +15 -0
  13. package/skill/references/ledger.md +13 -1
  14. package/skill/scripts/forge +1 -1
  15. package/src/cli/block.mjs +146 -30
  16. package/src/cli/context.mjs +17 -7
  17. package/src/cli/gates.mjs +76 -10
  18. package/src/cli/help.mjs +4 -0
  19. package/src/cli/outcome.mjs +87 -0
  20. package/src/cli/pipeline.mjs +61 -0
  21. package/src/cli/proof.mjs +10 -7
  22. package/src/cli/report.mjs +53 -6
  23. package/src/cli/review-file.mjs +168 -38
  24. package/src/cli/run.mjs +3 -2
  25. package/src/cli/sweep.mjs +28 -0
  26. package/src/cli/validate.mjs +6 -0
  27. package/src/config/schema-compile.mjs +6 -4
  28. package/src/config/validate.mjs +83 -1
  29. package/src/context/pack.mjs +53 -3
  30. package/src/doctor/local.mjs +22 -4
  31. package/src/engines/builders/codex.mjs +27 -7
  32. package/src/engines/builders/grok.mjs +11 -5
  33. package/src/engines/codex-home.mjs +18 -15
  34. package/src/engines/sentinel.mjs +11 -0
  35. package/src/gates/detect.mjs +6 -3
  36. package/src/gates/run.mjs +295 -27
  37. package/src/ledger/export.mjs +1 -1
  38. package/src/ledger/report.mjs +143 -5
  39. package/src/outcome/outcome.mjs +108 -0
  40. package/src/pipeline/pipeline.mjs +112 -0
  41. package/src/proof/red-green.mjs +41 -13
  42. package/src/proof/runners.mjs +28 -4
  43. package/src/proof/worktree.mjs +25 -15
  44. package/src/review/agent-waiver.mjs +18 -4
  45. package/src/review/engine.mjs +25 -5
  46. package/src/review/gate-check.mjs +39 -1
  47. package/src/review/lenses/full.md +1 -0
  48. package/src/review/lenses/quick.md +1 -0
  49. package/src/review/packet.mjs +34 -6
  50. package/src/review/related.mjs +112 -0
  51. package/src/review/reopen.mjs +4 -3
  52. package/src/review/session-retry.mjs +59 -0
  53. package/src/session/facts.mjs +18 -4
  54. package/src/session/spawn.mjs +54 -3
  55. package/src/state/block.mjs +101 -34
  56. package/src/state/config-snapshot.mjs +13 -3
  57. package/src/state/run.mjs +3 -2
  58. package/src/util/exec.mjs +48 -31
  59. package/src/util/reaper.mjs +238 -8
  60. package/src/worker/engine.mjs +7 -3
  61. package/src/worker/loop.mjs +67 -14
  62. package/src/worker/queue.mjs +106 -23
  63. package/src/worker/watch.mjs +2 -2
@@ -92,7 +92,7 @@ import { ownsFile } from '../state/registry.mjs';
92
92
  import { sameStamp, treeStamp } from '../state/block.mjs';
93
93
  import { verifyRow } from '../state/signer.mjs';
94
94
  import { tierFor } from '../proof/tiers.mjs';
95
- import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
95
+ import { DELETED_HASH, contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
96
96
  import { verifyResult } from '../worker/queue.mjs';
97
97
  import { stopExitsLine } from './stop-exits.mjs';
98
98
  import { checkFileForm, checkFindingId } from './verb-forms.mjs';
@@ -915,6 +915,44 @@ export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwn
915
915
  return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
916
916
  }
917
917
 
918
+ /** Safety check for npm locks excluded from model review; other formats are not parsed.
919
+ * @param {string} repoRoot @param {string} file @param {Record<string, any> | null} cfg
920
+ * @returns {string | null} refusal detail, or null for a safe (or deleted) lock
921
+ */
922
+ export function lockfileProblem(repoRoot, file, cfg) {
923
+ if (!['package-lock.json', 'npm-shrinkwrap.json'].includes(path.posix.basename(file))) return null;
924
+ let lock;
925
+ try {
926
+ const bytes = readRegularFileNoFollow(repoRoot, file);
927
+ if (bytes === null) return null;
928
+ lock = JSON.parse(bytes.toString('utf8'));
929
+ } catch { return 'cannot read valid lock JSON'; }
930
+ if (!lock || typeof lock !== 'object' || Array.isArray(lock)) return 'expected a lock object';
931
+ const hosts = cfg?.review?.lockfile_registries ?? ['registry.npmjs.org'];
932
+ /** @param {any} entries @param {string} prefix @param {boolean} recursive @returns {string | null} */
933
+ function checkEntries(entries, prefix, recursive) {
934
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return `${prefix} must be an object`;
935
+ for (const [name, pkg] of Object.entries(entries)) {
936
+ const entry = `${prefix}.${name}`;
937
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) return `${entry} must be an object`;
938
+ if (pkg.link !== true && pkg.resolved !== undefined) {
939
+ let url;
940
+ try { url = new URL(pkg.resolved); } catch { return `${entry}.resolved must be an HTTPS URL on an allowed host`; }
941
+ if (url.protocol !== 'https:' || !hosts.includes(url.host)) return `${entry}.resolved must be HTTPS on review.lockfile_registries`;
942
+ if (typeof pkg.integrity !== 'string' || !pkg.integrity.trim()) return `${entry} has no integrity`;
943
+ }
944
+ if (recursive && pkg.dependencies !== undefined) {
945
+ const problem = checkEntries(pkg.dependencies, `${entry}.dependencies`, true);
946
+ if (problem) return problem;
947
+ }
948
+ }
949
+ return null;
950
+ }
951
+ const problem = checkEntries(lock.packages ?? {}, 'packages', false);
952
+ if (problem) return problem;
953
+ return lock.dependencies === undefined ? null : checkEntries(lock.dependencies, 'dependencies', true);
954
+ }
955
+
918
956
  /**
919
957
  * Every changed tracked file since `base` (deletions included) ∪ every untracked, not ignored
920
958
  * file, through `repoRelativePath`, sorted — the set {@link blockFileSet} filters to the block.
@@ -19,6 +19,7 @@ Code or tests beyond what the change needs (unused options, extra layers, specul
19
19
  ## Rules for every answer
20
20
 
21
21
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
22
+ - A finding that claims something is missing or wrong across the project must say in `evidence` which related file it could not see, e.g. "assumes no rule in src/styles/base.css".
22
23
  - A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
23
24
  - You have no tools and no project files. The packet is everything you get.
24
25
  - `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
@@ -17,6 +17,7 @@ Code or tests beyond what the change needs (unused options, extra layers, specul
17
17
  ## Rules for every answer
18
18
 
19
19
  - Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
20
+ - A finding that claims something is missing or wrong across the project must say in `evidence` which related file it could not see, e.g. "assumes no rule in src/styles/base.css".
20
21
  - You have no tools and no project files. The packet is everything you get.
21
22
  - `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
22
23
  - Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
@@ -10,7 +10,8 @@
10
10
  * ## project rules the rules digest (≤ 60 lines)
11
11
  * ## facts the facts-sheet excerpt
12
12
  * ## context the current file (whole, or hunk windows — `context.mjs`)
13
- * ## paired optional paired-file names and summary (B97), dropped first over budget
13
+ * ## related files (not shown) optional related paths only, dropped first over budget
14
+ * ## paired optional paired-file names and summary (B97), dropped next over budget
14
15
  * ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
15
16
  * ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
16
17
  *
@@ -21,7 +22,7 @@
21
22
  * Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
22
23
  * bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
23
24
  * section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
24
- * ± `min_context_lines` (`minimal`) after dropping paired, then the digest is trimmed from the end; the diff is
25
+ * ± `min_context_lines` (`minimal`) after dropping related files, then paired, then the digest is trimmed from the end; the diff is
25
26
  * never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
26
27
  * size (`diffOnlyTokens`, `split_required`) and never trimmed.
27
28
  *
@@ -38,6 +39,7 @@ import { fileURLToPath } from 'node:url';
38
39
  import { exec } from '../util/exec.mjs';
39
40
  import { assertInsideRoot, assertRowPath, gitChildEnv, readRegularFileNoFollow, WorkerError } from '../worker/ticket.mjs';
40
41
  import { buildContext, parseDiff, splitLines } from './context.mjs';
42
+ import { relatedFiles } from './related.mjs';
41
43
 
42
44
  export const LENSES = Object.freeze(['quick', 'full', 'recheck', 'A', 'B', 'judge']);
43
45
 
@@ -137,6 +139,7 @@ export function assertPacketPath(repoRoot, file) {
137
139
  * @property {import('./context.mjs').Hunk[]} hunks
138
140
  * @property {number} plusCount
139
141
  * @property {number} minusCount
142
+ * @property {string[]} [related] - related file names, never their content.
140
143
  */
141
144
 
142
145
  /**
@@ -171,7 +174,19 @@ export async function readFileDiff({ repoRoot, file, base }) {
171
174
  const res = await exec(argv, { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS, ...(kind === 'new' ? { okExitCodes: [0, 1] } : {}) });
172
175
  if (res.result !== 'ok') throw new PacketError('git', `git diff failed for ${rel} (exit ${res.code})`);
173
176
  const content = onDisk ? readRepoText(repoRoot, rel) : null;
174
- return { file: rel, kind, diffText: res.stdout, content, ...parseDiff(res.stdout) };
177
+ const related = res.stdout.trim().length === 0 ? [] : await relatedFiles({
178
+ repoRoot, file: rel,
179
+ listFiles: async () => {
180
+ const tracked = await exec(['git', 'ls-files', '-z'], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS });
181
+ return tracked.result === 'ok' ? tracked.stdout.split('\0').filter(Boolean) : [];
182
+ },
183
+ readFile: (full) => {
184
+ const candidate = path.relative(repoRoot, full).split(path.sep).join('/');
185
+ if (isSecretLike(candidate)) return null;
186
+ return readRepoText(repoRoot, assertPacketPath(repoRoot, candidate));
187
+ },
188
+ });
189
+ return { file: rel, kind, diffText: res.stdout, content, related, ...parseDiff(res.stdout) };
175
190
  }
176
191
 
177
192
  /**
@@ -206,7 +221,13 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
206
221
  let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
207
222
  let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
208
223
  let pairedSection = boundedPaired(paired, budgetFor(cfg, 'full_in'));
209
- let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection);
224
+ const names = ['quick', 'full'].includes(lens) ? (diff.related ?? []) : [];
225
+ let relatedSection = names.length > 0 ? `## related files (not shown)\n${names.join('\n')}` : '';
226
+ let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection, relatedSection);
227
+ if (estimateTokens(text) > limit && relatedSection.length > 0) {
228
+ relatedSection = '';
229
+ text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection);
230
+ }
210
231
  if (estimateTokens(text) > limit && pairedSection.length > 0) {
211
232
  pairedSection = '';
212
233
  text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
@@ -234,7 +255,12 @@ export function assembleLightPacket({ diffs, cfg, rulesDigest = '', factsExcerpt
234
255
  const context = (mode) => diffs.map((diff) => `### ${diff.file}\n${buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode }).text}`).join('\n\n');
235
256
  let contextMode = 'auto';
236
257
  let ctx = context(contextMode);
237
- let text = render(lens, digest, factsExcerpt, ctx, diffText);
258
+ const shown = new Set(diffs.map((diff) => diff.file));
259
+ const related = [...new Set(diffs.flatMap((diff) => diff.related ?? []))].filter((file) => !shown.has(file)).sort().slice(0, 12);
260
+ let text = render(lens, digest, factsExcerpt, ctx, diffText, '', '', related.length > 0 ? `## related files (not shown)\n${related.join('\n')}` : '');
261
+ if (estimateTokens(text) > budget && related.length > 0) {
262
+ text = render(lens, digest, factsExcerpt, ctx, diffText);
263
+ }
238
264
  if (estimateTokens(text) > budget) {
239
265
  contextMode = 'minimal';
240
266
  ctx = context(contextMode);
@@ -293,8 +319,9 @@ function diffSectionOf(diff) {
293
319
  * @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
294
320
  * @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
295
321
  * @param {string} [paired] - the bounded `## paired` section (heading included).
322
+ * @param {string} [related] - the names-only related files section (heading included).
296
323
  */
297
- function render(lensText, digest, facts, context, diffSection, moved = '', paired = '') {
324
+ function render(lensText, digest, facts, context, diffSection, moved = '', paired = '', related = '') {
298
325
  return [
299
326
  '# code-forge review packet',
300
327
  '## lens',
@@ -305,6 +332,7 @@ function render(lensText, digest, facts, context, diffSection, moved = '', paire
305
332
  facts.trim().length > 0 ? facts.trimEnd() : '(none)',
306
333
  '## context',
307
334
  context.length > 0 ? context : '(none)',
335
+ ...(related.length > 0 ? [related] : []),
308
336
  ...(paired.length > 0 ? [paired] : []),
309
337
  ...(moved.length > 0 ? [moved] : []),
310
338
  '## diff',
@@ -0,0 +1,112 @@
1
+ /** Best-effort local import relationships; I/O is supplied by the packet reader. */
2
+ import path from 'node:path';
3
+
4
+ const EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.css', '.scss', '.html', '.htm', '.astro', '.vue'];
5
+ const STYLES = new Set(['.css', '.scss']);
6
+
7
+ /** @param {string} file */
8
+ function safePath(file) {
9
+ return typeof file === 'string' && !path.posix.isAbsolute(file) &&
10
+ !/[\\\x00-\x1f\x7f]/.test(file) &&
11
+ !file.split('/').some((part) => ['', '.', '..', '.git', '.code-forge'].includes(part));
12
+ }
13
+
14
+ /** @param {string} text @param {string} file @returns {string[]} */
15
+ function specifiers(text, file) {
16
+ const ext = path.posix.extname(file).toLowerCase();
17
+ const found = [];
18
+ // CSS and HTML use relative URLs without requiring JS's leading ./ or ../.
19
+ const url = (value) => {
20
+ if (!/^(?:[a-z][a-z\d+.-]*:|\/|[?#])/i.test(value)) found.push(/^\.\.?\//.test(value) ? value : `./${value}`);
21
+ };
22
+ if (!STYLES.has(ext)) {
23
+ // Keep strings as single tokens, discard comments/templates, and never interpret a string's
24
+ // contents as code. This also handles imports in Astro frontmatter and Vue script blocks.
25
+ const tokens = [...text.matchAll(/\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|`(?:\\[\s\S]|[^`\\])*`|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|[\w$]+|[^\s]/g)]
26
+ .map((match) => match[0]).filter((token) => !token.startsWith('//') && !token.startsWith('/*') && !token.startsWith('`'));
27
+ const quoted = (token) => token?.startsWith('"') || token?.startsWith("'");
28
+ for (let i = 0; i < tokens.length; i += 1) {
29
+ const token = tokens[i];
30
+ if (token !== 'import' && token !== 'require') continue;
31
+ if (tokens[i - 1] === '.') continue;
32
+ if (tokens[i + 1] === '(' && quoted(tokens[i + 2]) && tokens[i + 3] === ')') found.push(tokens[i + 2].slice(1, -1));
33
+ else if (token === 'import') {
34
+ if (quoted(tokens[i + 1])) found.push(tokens[i + 1].slice(1, -1));
35
+ else {
36
+ for (let j = i + 1; j < tokens.length && ![';', 'import', 'require', '('].includes(tokens[j]); j += 1) {
37
+ if (tokens[j] === 'from' && quoted(tokens[j + 1])) { found.push(tokens[j + 1].slice(1, -1)); break; }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ if (STYLES.has(ext) || ['.html', '.htm', '.astro', '.vue'].includes(ext)) {
44
+ const markup = text.replace(/\/\*[\s\S]*?\*\/|<!--[\s\S]*?-->/g, '');
45
+ for (const match of markup.matchAll(/@import\s+(?:url\(\s*(?:'([^']+)'|"([^"]+)"|([^\s)]+))\s*\)|'([^']+)'|"([^"]+)")/gi)) {
46
+ url(match.slice(1).find((value) => value !== undefined));
47
+ }
48
+ if (!STYLES.has(ext)) {
49
+ for (const tag of markup.matchAll(/<(link|script)\b[^>]*>/gi)) {
50
+ const attr = tag[1].toLowerCase() === 'link' ? 'href' : 'src';
51
+ const match = tag[0].match(new RegExp(`\\s${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s>]+))`, 'i'));
52
+ if (match) url(match[1] ?? match[2] ?? match[3]);
53
+ }
54
+ }
55
+ }
56
+ return found;
57
+ }
58
+
59
+ /**
60
+ * Names only: outgoing imports, tracked importers (at most 2000 candidates), and a stylesheet's
61
+ * cascade siblings. A missing/unreadable file is not a relationship. No source text is returned.
62
+ * `listFiles` lists tracked repo-relative paths; `readFile` reads an absolute path or returns null.
63
+ * @param {{repoRoot: string, file: string,
64
+ * listFiles: (repoRoot: string) => string[] | Promise<string[]>,
65
+ * readFile: (file: string) => string | null | Promise<string | null>}} opts
66
+ * @returns {Promise<string[]>}
67
+ */
68
+ export async function relatedFiles({ repoRoot, file, listFiles, readFile }) {
69
+ if (!safePath(file) || !EXTENSIONS.includes(path.posix.extname(file).toLowerCase())) return [];
70
+ const cache = new Map();
71
+ const read = async (rel) => {
72
+ if (!safePath(rel)) return null;
73
+ if (!cache.has(rel)) {
74
+ try { cache.set(rel, await readFile(path.resolve(repoRoot, rel))); }
75
+ catch { cache.set(rel, null); }
76
+ }
77
+ return cache.get(rel);
78
+ };
79
+ const imports = async (rel) => {
80
+ const text = await read(rel);
81
+ const resolved = new Set();
82
+ if (text === null) return resolved;
83
+ for (const specifier of specifiers(text, rel)) {
84
+ if (!/^\.\.?\//.test(specifier)) continue;
85
+ const target = path.posix.normalize(path.posix.join(path.posix.dirname(rel), specifier.split(/[?#]/)[0]));
86
+ if (!safePath(target)) continue;
87
+ const candidates = [target];
88
+ if (!path.posix.extname(target)) candidates.push(...EXTENSIONS.map((ext) => target + ext), ...EXTENSIONS.map((ext) => `${target}/index${ext}`));
89
+ for (const candidate of candidates) {
90
+ if (await read(candidate) !== null) { resolved.add(candidate); break; }
91
+ }
92
+ }
93
+ return resolved;
94
+ };
95
+ const related = await imports(file);
96
+ let tracked = [];
97
+ try { tracked = [...new Set(await listFiles(repoRoot))].filter((rel) => safePath(rel) && EXTENSIONS.includes(path.posix.extname(rel).toLowerCase())); }
98
+ catch { /* Outgoing imports still help when listing is unavailable. */ }
99
+ if (tracked.length <= 2000) {
100
+ for (const importer of tracked) {
101
+ if (importer === file) continue;
102
+ const targets = await imports(importer);
103
+ if (!targets.has(file)) continue;
104
+ related.add(importer);
105
+ if (STYLES.has(path.posix.extname(file).toLowerCase())) {
106
+ for (const sibling of targets) if (STYLES.has(path.posix.extname(sibling).toLowerCase())) related.add(sibling);
107
+ }
108
+ }
109
+ }
110
+ related.delete(file);
111
+ return [...related].sort().slice(0, 12);
112
+ }
@@ -192,9 +192,10 @@ export function reopenRowFor(rows, { runId, block, file, key, status, state, seq
192
192
  * contentHash: (file: string) => string,
193
193
  * readFile?: (file: string) => Buffer | null,
194
194
  * clearDone: (file: string) => boolean,
195
+ * enqueue?: (file: string, row: Record<string, any>) => string | undefined,
195
196
  * },
196
197
  * }} opts
197
- * @returns {Promise<{row: Record<string, any>, cleared: boolean, existing: boolean}>}
198
+ * @returns {Promise<{row: Record<string, any>, cleared: boolean, existing: boolean, ticket?: string}>}
198
199
  * @throws {StateError} `usage` or a refusal code (see the module doc).
199
200
  */
200
201
  export async function reopenFile({ runId, id, file, reason, record, rows, key, writeRow, writeSigned, io, agent }) {
@@ -255,7 +256,7 @@ export async function reopenFile({ runId, id, file, reason, record, rows, key, w
255
256
  // so a failed row write leaves at worst a re-run that returns the same stop, never a row with a
256
257
  // stale marker
257
258
  const cleared = io.clearDone(file);
258
- if (earlier !== null) return { row: earlier, cleared, existing: true };
259
+ if (earlier !== null) return { row: earlier, cleared, existing: true, ticket: io.enqueue?.(file, earlier) };
259
260
  const row = { event: REOPENED_EVENT, block: id, file, from, stop_reason: stopReason, at_seq: loaded.seq, from_hash: fromHash, to_hash: current, reason: reason.trim(), by: agent ? 'agent' : 'human', ...(agent ? { verdict } : {}) };
260
261
  /** @type {Record<string, any> | null} */
261
262
  let written = null;
@@ -285,5 +286,5 @@ export async function reopenFile({ runId, id, file, reason, record, rows, key, w
285
286
  if (threw) Object.defineProperty(refused, 'cause', { value: failure, enumerable: false, configurable: true, writable: true });
286
287
  throw refused;
287
288
  }
288
- return { row: written, cleared, existing: false };
289
+ return { row: written, cleared, existing: false, ticket: io.enqueue?.(file, written) };
289
290
  }
@@ -32,10 +32,69 @@
32
32
  */
33
33
 
34
34
  import { logWarning } from '../util/error-log.mjs';
35
+ import { resolveLevel } from '../config/known-ids.mjs';
36
+ import { closedBookRefused, openBookCodexAllowed } from '../config/closed-book.mjs';
35
37
 
36
38
  /** How many times a timed-out session is spawned again. */
37
39
  export const TIMEOUT_RETRIES = 1;
38
40
 
41
+ /**
42
+ * A broken answer gets one retry on its model, then the next distinct, runnable model of the
43
+ * level's ladder. Pin each retry to one model so provider-error fallback cannot consume the
44
+ * ladder before the same-model retry. A valid negative answer stops just like a valid positive.
45
+ * The packet and the original config are unchanged throughout.
46
+ * @param {(opts: Record<string, any>) => Promise<{res: Record<string, any> | null, attempts: number}>} spawn
47
+ * @param {Record<string, any>} opts
48
+ * @param {(res: Record<string, any> | null) => boolean} isBroken
49
+ * @param {(res: Record<string, any> | null, cfg: Record<string, any>) => Promise<unknown>} [onBroken]
50
+ * @param {{res: Record<string, any> | null, attempts: number}} [initial] - reuse a completed
51
+ * attempt, including any provider fallback it already took, rather than spawning it again.
52
+ * @returns {Promise<{res: Record<string, any> | null, attempts: number, to?: BrokenTry['to'], usage: {tokens_in: number | null, tokens_out: number | null}}>}
53
+ */
54
+ export async function spawnWithBrokenFallback(spawn, opts, isBroken, onBroken = async () => {}, initial) {
55
+ const resolved = resolveLevel(opts.cfg, opts.level);
56
+ const models = [resolved, ...resolved.fallback];
57
+ const startAt = initial ? Math.max(0, models.findIndex((entry) => entry.model === initial.res?.model
58
+ && (entry.provider ?? resolved.provider) === initial.res?.provider)) : 0;
59
+ let pending = initial;
60
+ const seen = new Set();
61
+ let attempts = 0;
62
+ /** @type {Record<string, any> | null} */
63
+ let res = null;
64
+ /** @type {BrokenTry['to'] | undefined} */
65
+ let to;
66
+ /** @type {{tokens_in: number | null, tokens_out: number | null}} */
67
+ const usage = { tokens_in: null, tokens_out: null };
68
+ for (const [index, entry] of models.entries()) {
69
+ if (index < startAt) continue;
70
+ const provider = entry.provider ?? resolved.provider;
71
+ const key = `${provider}\0${entry.model}`;
72
+ if (seen.has(key) || closedBookRefused(provider, opts.role, openBookCodexAllowed(opts.cfg))) continue;
73
+ seen.add(key);
74
+ const cfg = { ...opts.cfg, levels: { ...opts.cfg.levels, [opts.level]: {
75
+ provider, model: entry.model, ...(entry.effort !== undefined ? { effort: entry.effort } : {}), fallback: [],
76
+ } } };
77
+ if (index > 0) to = { level: `levels.${opts.level}.fallback`, provider, model: entry.model };
78
+ for (let n = 0; n < 2; n++) {
79
+ try {
80
+ const out = pending ?? await spawn({ ...opts, cfg });
81
+ pending = undefined;
82
+ res = out.res;
83
+ attempts += out.attempts;
84
+ } catch {
85
+ res = null;
86
+ attempts++;
87
+ }
88
+ for (const field of /** @type {const} */ (['tokens_in', 'tokens_out'])) {
89
+ if (typeof res?.usage?.[field] === 'number') usage[field] = (usage[field] ?? 0) + res.usage[field];
90
+ }
91
+ if (!isBroken(res)) return { res, attempts, ...(to ? { to } : {}), usage };
92
+ await onBroken(res, cfg);
93
+ }
94
+ }
95
+ return { res, attempts, ...(to ? { to } : {}), usage };
96
+ }
97
+
39
98
  /**
40
99
  * @typedef {{res: Record<string, any> | null, attempts: number, to: {level: string, provider: string, model: string}}} BrokenTry -
41
100
  * the outcome of a second-level try started by a timed-out attempt (`afterSchemaMiss`).
@@ -777,7 +777,8 @@ function ruleBroken(row, claim, ctx) {
777
777
  }
778
778
 
779
779
  /**
780
- * Validate the delegate's answer (step 3). Only a schema-invalid answer is refused. A row answers
780
+ * Validate the delegate's answer (step 3). Null commands/excerpts are normalized on a copy and
781
+ * their matched claims downgraded; every other schema-invalid answer is refused. A row answers
781
782
  * claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
782
783
  * the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
783
784
  * equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
@@ -794,11 +795,23 @@ function ruleBroken(row, claim, ctx) {
794
795
  * @throws {FactsError} `refused` when the answer does not match the schema.
795
796
  */
796
797
  export function validateFacts(answer, claims, opts = {}) {
797
- if (!validateFile(answer)) {
798
+ const normalized = structuredClone(answer);
799
+ /** @type {Map<object, string[]>} */
800
+ const nullFields = new Map();
801
+ if (normalized && typeof normalized === 'object' && 'facts' in normalized && Array.isArray(normalized.facts)) {
802
+ for (const row of normalized.facts) {
803
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
804
+ const fields = ['command', 'output_excerpt'].filter((field) => row[field] === null);
805
+ if (fields.length === 0) continue;
806
+ for (const field of fields) row[field] = '';
807
+ nullFields.set(row, fields);
808
+ }
809
+ }
810
+ if (!validateFile(normalized)) {
798
811
  const where = (validateFile.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).slice(0, 5);
799
812
  throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
800
813
  }
801
- const rows = /** @type {{facts: Fact[]}} */ (answer).facts;
814
+ const rows = /** @type {{facts: Fact[]}} */ (normalized).facts;
802
815
  /** @type {Fact[]} */
803
816
  const facts = [];
804
817
  /** @type {Downgrade[]} */
@@ -828,7 +841,8 @@ export function validateFacts(answer, claims, opts = {}) {
828
841
  return;
829
842
  }
830
843
  const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
831
- const broken = ruleBroken(row, claim, opts);
844
+ const fields = nullFields.get(row);
845
+ const broken = fields ? `delegate returned null ${fields.join(', ')}` : ruleBroken(row, claim, opts);
832
846
  if (broken) {
833
847
  const reason = redact(broken);
834
848
  downgraded.push({ fact_id: id, reason });
@@ -51,6 +51,7 @@ import { Ajv2020 } from 'ajv/dist/2020.js';
51
51
  import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
52
52
  import { resolveLevel } from '../config/known-ids.mjs';
53
53
  import { compileSchema } from '../config/schema-compile.mjs';
54
+ import { blockTaskLines, ContextError, readBlockBrief } from '../context/pack.mjs';
54
55
  import { buildArgv } from '../engines/builders/index.mjs';
55
56
  import { removeCodexHome } from '../engines/codex-home.mjs';
56
57
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
@@ -61,6 +62,7 @@ import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd
61
62
  import { appendRow, readAllRows } from '../ledger/write.mjs';
62
63
  import { categoryGate, categoryPlan, RECORD_UNKNOWN_MESSAGE } from '../autopilot/limits.mjs';
63
64
  import { StateError } from '../state/paths.mjs';
65
+ import { readRun } from '../state/run.mjs';
64
66
  import { exec } from '../util/exec.mjs';
65
67
  import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
66
68
  import { semaphore } from '../util/locks.mjs';
@@ -68,6 +70,7 @@ import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.m
68
70
  import { asText, stderrTail } from '../util/diagnostic-tail.mjs';
69
71
  import { redact, writeSafe } from '../util/redact.mjs';
70
72
  import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
73
+ import { assertBlockId, mainRootOf } from '../worker/ticket.mjs';
71
74
 
72
75
  export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts', 'delegate']);
73
76
  export const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
@@ -538,6 +541,8 @@ export async function spawnSession(opts, deps = {}) {
538
541
  if (!LEVELS.includes(level)) throw new SessionError('usage', `level must be one of ${LEVELS.join(', ')}`);
539
542
  if (!ROLES.includes(role)) throw new SessionError('usage', `role must be one of ${ROLES.join(', ')}`);
540
543
  if (typeof promptPath !== 'string' || !path.isAbsolute(promptPath)) throw new SessionError('usage', 'promptPath must be an absolute path');
544
+ /** @type {string | null} */
545
+ let coderPrompt = null;
541
546
  const stderr = deps.stderr ?? process.stderr;
542
547
  const runRootDir = opts.runRoot ?? currentRunRoot();
543
548
  if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
@@ -570,6 +575,9 @@ export async function spawnSession(opts, deps = {}) {
570
575
  // in the `finally` below on every other exit path (a throw, a background start)
571
576
  const releaseReservation = gate.release;
572
577
  try {
578
+ // Budget refusals take precedence over task loading, including legacy runs without
579
+ // this block. A failed context load must still release the accepted reservation.
580
+ if (role === 'coder' && opts.block && coderPrompt === null) coderPrompt = await coderPromptFor(opts);
573
581
  const outcome = await runAttempt(step, i + 1 === steps.length, releaseReservation);
574
582
  if (outcome.result) return outcome.result;
575
583
  waitMs = outcome.waitMs;
@@ -602,7 +610,7 @@ export async function spawnSession(opts, deps = {}) {
602
610
  mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
603
611
  let keep = false;
604
612
  try {
605
- const built = buildStep(opts, step, sessionDir, openBook);
613
+ const built = buildStep(opts, step, sessionDir, openBook, coderPrompt);
606
614
  const cli = built.cli;
607
615
  built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
608
616
  if (role === 'coder') {
@@ -659,6 +667,42 @@ export async function spawnSession(opts, deps = {}) {
659
667
  }
660
668
  }
661
669
 
670
+ /**
671
+ * Add the recorded task and completion contract without overwriting the caller's brief.
672
+ * @param {SessionOpts} opts @returns {Promise<string>}
673
+ */
674
+ async function coderPromptFor(opts) {
675
+ let blockId;
676
+ let original;
677
+ try {
678
+ blockId = assertBlockId(opts.block);
679
+ original = readFileSync(opts.promptPath, 'utf8');
680
+ } catch (err) {
681
+ throw new SessionError('usage', err instanceof Error ? err.message : 'the coder brief cannot be read');
682
+ }
683
+ let task = [];
684
+ if (opts.run) {
685
+ let record;
686
+ try {
687
+ record = await readRun(opts.run);
688
+ } catch (err) {
689
+ if (!(err instanceof StateError && err.code === 'no-run')) throw err;
690
+ }
691
+ if (record) {
692
+ const block = record.blocks?.[blockId];
693
+ if (!block) throw new SessionError('usage', `run ${opts.run} has no block ${blockId}`);
694
+ try {
695
+ const repoRoot = await mainRootOf(record.workspace);
696
+ task = blockTaskLines({ brief: readBlockBrief(repoRoot, block), acceptance: block.acceptance }, original);
697
+ } catch (err) {
698
+ if (err instanceof ContextError) throw new SessionError('usage', err.message);
699
+ throw err;
700
+ }
701
+ }
702
+ }
703
+ return `${original.trimEnd()}\n\n${task.length ? `${task.join('\n')}\n\n` : ''}Report the changed files and pass/fail counts. Print TESTS <command> exit=<n> on its own line before the sentinel. On success, print this exact sentinel as your last line:\n===BLOCK ${blockId} COMPLETE===\n`;
704
+ }
705
+
662
706
  /**
663
707
  * B33: a session row's dollars from its tokens and the static price table — `{usd, cost_source:
664
708
  * 'estimated'}`, or `{usd: null, usd_unknown: true}` when the provider/level has no price.
@@ -812,8 +856,9 @@ async function checkGate(opts, deps, writeRow, stderr, budget, estimate) {
812
856
  * @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
813
857
  * @param {string} sessionDir
814
858
  * @param {boolean} openBook - B32: `review.allow_open_book_codex`, read once by `spawnSession`.
859
+ * @param {string | null} [coderPrompt]
815
860
  */
816
- function buildStep(opts, step, sessionDir, openBook) {
861
+ function buildStep(opts, step, sessionDir, openBook, coderPrompt = null) {
817
862
  const closedBook = opts.role !== 'coder';
818
863
  // B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
819
864
  const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
@@ -823,11 +868,17 @@ function buildStep(opts, step, sessionDir, openBook) {
823
868
  if (cli === undefined) throw new SessionError('usage', `provider "${step.provider}" has no CLI mapping`);
824
869
  /** @type {Record<string, any>} */
825
870
  const params = { provider: step.provider, role: opts.role, model: step.model, promptPath: opts.promptPath, cwd };
871
+ if (coderPrompt !== null) {
872
+ params.promptPath = path.join(sessionDir, 'coder-brief.md');
873
+ writeFileSync(params.promptPath, coderPrompt, { mode: 0o600 });
874
+ }
826
875
  if (step.effort) params.effort = step.effort;
827
876
  if (step.flagFallback) params.fallback = [step.flagFallback];
828
877
  if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
829
878
  if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
830
879
  if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
880
+ // The session owns this home: a named run must not use the spawning process's lazy root.
881
+ if (cli === 'codex' && !closedBook) params.runRoot = opts.runRoot ?? currentRunRoot();
831
882
  // B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
832
883
  if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
833
884
  // B93: the Codex coder's sandbox profile; no `coder.sandbox` key builds exactly today's argv
@@ -903,7 +954,7 @@ async function runForeground(built, opts, deps) {
903
954
  });
904
955
  } finally {
905
956
  // B4.1: the Codex coder's per-session CODEX_HOME (rules + auth copy) dies with the session.
906
- if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME);
957
+ if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME, opts.runRoot);
907
958
  }
908
959
  const duration_ms = Date.now() - started;
909
960
  /** @type {string | null} */