@lyupro/codex-bridge 0.6.8 → 0.6.10

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 (128) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +92 -0
  25. package/cli/lifecycle-lock.mjs +222 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +22 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/settings-merge.mjs +3 -29
  46. package/cli/terminal-question.mjs +41 -0
  47. package/cli/uninstall-purge.mjs +139 -0
  48. package/cli/uninstall-rules.mjs +72 -0
  49. package/cli/uninstall.mjs +208 -127
  50. package/cli/unlock-lifecycle.mjs +102 -0
  51. package/cli/unlock.mjs +7 -4
  52. package/cli/update.mjs +102 -27
  53. package/package.json +4 -3
  54. package/scripts/home-write-audit.mjs +286 -0
  55. package/scripts/home-write-inventory.mjs +59 -0
  56. package/scripts/home-write-lexer.mjs +182 -0
  57. package/scripts/isolated-roots.mjs +6 -1
  58. package/scripts/open-work/check.mjs +180 -0
  59. package/scripts/open-work/live-thresholds.mjs +155 -0
  60. package/scripts/open-work/register.mjs +56 -0
  61. package/scripts/root-guard/root-entries.mjs +15 -0
  62. package/scripts/run-tests.mjs +26 -1
  63. package/src/agents/codex-advisor.md +23 -10
  64. package/src/agents/codex-build.md +24 -14
  65. package/src/agents/codex-review.md +17 -10
  66. package/src/agents/codex-scout.md +22 -12
  67. package/src/home/conventions.md +9 -0
  68. package/src/home/hooks/guard-tries.mjs +6 -4
  69. package/src/home/hooks/order-gate.mjs +27 -19
  70. package/src/home/hooks/reply-guard.mjs +2 -7
  71. package/src/home/hooks/reply-parser.mjs +1 -1
  72. package/src/home/hooks/worktree-witness.mjs +6 -6
  73. package/src/home/lib/agents.mjs +1 -1
  74. package/src/home/lib/atomic-json.mjs +24 -6
  75. package/src/home/lib/command-path.mjs +32 -0
  76. package/src/home/lib/config-edit.mjs +25 -10
  77. package/src/home/lib/dispatcher-command.mjs +4 -2
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +7 -4
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +7 -4
  85. package/src/home/lib/kernel-lock.mjs +171 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/chain.mjs +19 -8
  89. package/src/home/lib/meta/context-only.mjs +43 -0
  90. package/src/home/lib/meta/events.mjs +16 -3
  91. package/src/home/lib/meta/launch-rows.mjs +23 -0
  92. package/src/home/lib/meta/paths.mjs +24 -24
  93. package/src/home/lib/meta/reply.mjs +37 -3
  94. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  95. package/src/home/lib/meta/run-state.mjs +3 -0
  96. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  97. package/src/home/lib/meta/verdict.mjs +30 -24
  98. package/src/home/lib/required-inputs.mjs +48 -8
  99. package/src/home/lib/run-codex.mjs +6 -3
  100. package/src/home/lib/run-config.mjs +10 -1
  101. package/src/home/lib/runner/advise-carry.mjs +2 -1
  102. package/src/home/lib/runner/args.mjs +16 -5
  103. package/src/home/lib/runner/attach.mjs +21 -14
  104. package/src/home/lib/runner/build-evidence.mjs +29 -0
  105. package/src/home/lib/runner/codex-availability.mjs +50 -0
  106. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  107. package/src/home/lib/runner/continuation.mjs +60 -8
  108. package/src/home/lib/runner/exit-codes.mjs +13 -0
  109. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  110. package/src/home/lib/runner/flag-scan.mjs +165 -0
  111. package/src/home/lib/runner/git-paths.mjs +74 -0
  112. package/src/home/lib/runner/git-state.mjs +37 -66
  113. package/src/home/lib/runner/launcher.mjs +233 -273
  114. package/src/home/lib/runner/order-claim.mjs +84 -0
  115. package/src/home/lib/runner/order-owner.mjs +18 -0
  116. package/src/home/lib/runner/pass-gate.mjs +109 -0
  117. package/src/home/lib/runner/preflight.mjs +22 -18
  118. package/src/home/lib/runner/prompts.mjs +42 -4
  119. package/src/home/lib/runner/question-kind.mjs +41 -0
  120. package/src/home/lib/runner/run-env.mjs +2 -1
  121. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  122. package/src/home/lib/runner/schemas.mjs +4 -1
  123. package/src/home/lib/runner/scope-check.mjs +55 -28
  124. package/src/home/lib/runner/task-input.mjs +11 -2
  125. package/src/home/lib/runner/worker-admission.mjs +43 -0
  126. package/src/home/lib/runner/worker.mjs +19 -18
  127. package/src/home/lib/task-header.mjs +127 -0
  128. package/src/home/lib/write-meta.mjs +17 -5
@@ -23,6 +23,27 @@ function retentionReply(runDir) {
23
23
  return `Retention: freed ${formatBytes(bytes)} from ${runs} runs older than ${days} days`;
24
24
  }
25
25
 
26
+ function attemptReply(runDir) {
27
+ const retryOf = readJson(path.join(runDir, 'status.json'))?.retry_of;
28
+ if (typeof retryOf !== 'string' || !retryOf.trim()) return null;
29
+ const runsRoot = path.dirname(runDir);
30
+ const key = (name) => process.platform === 'win32' ? name.toLowerCase() : name;
31
+ const seen = new Set([key(path.basename(runDir))]);
32
+ let previous = retryOf;
33
+ let attempt = 1;
34
+ // Plan_75 D1, 2026-10-03 TradeForge: show retries without trusting broken provenance links.
35
+ for (let hops = 0; hops < 50; hops += 1) {
36
+ if (typeof previous !== 'string' || !previous || /[/\\]/.test(previous)
37
+ || previous === '.' || previous === '..' || seen.has(key(previous))) break;
38
+ const status = readJson(path.join(runsRoot, previous, 'status.json'));
39
+ if (!status) break;
40
+ seen.add(key(previous));
41
+ attempt += 1;
42
+ previous = status.retry_of;
43
+ }
44
+ return `Attempt: ${attempt} of this pass — retry of ${retryOf}`;
45
+ }
46
+
26
47
  // Five copies of Retention missed writeFailure(), so both reply assemblers share this point.
27
48
  // The 2026-09-16 dead Codex sandbox incident is why the launcher probes the sandbox at all.
28
49
  export function launchRows(runDir) {
@@ -33,6 +54,8 @@ export function launchRows(runDir) {
33
54
  const reason = line(probe.reason, 160) || 'no reason recorded';
34
55
  rows.push(`Sandbox probe: inconclusive — ${reason} The run started without a sandbox check.`);
35
56
  }
57
+ const attempt = attemptReply(runDir);
58
+ if (attempt) rows.push(attempt);
36
59
  return rows;
37
60
  }
38
61
 
@@ -3,11 +3,21 @@
3
3
  *
4
4
  * Two sides spell the same file differently — git prints repo-relative paths with forward
5
5
  * slashes, Codex writes whatever it likes — so nothing above this module compares a path
6
- * before it has been through here. This is also the only module that touches the
7
- * filesystem for reading artifacts: everything else receives text, JSON or a byte count from it.
6
+ * before it has been through here. Strict snapshot judgement belongs to run-snapshots.mjs.
8
7
  */
9
8
  import fs from 'node:fs';
10
9
  import { readJsonFileSync } from '../json-file.mjs';
10
+ import { compareSnapshots, decodeSnapshot } from './snapshot-format.mjs';
11
+
12
+ /**
13
+ * `slice` counts UTF-16 units and can leave half of an emoji or a rare CJK character at the cut;
14
+ * a lone surrogate in a reply is the same kind of broken text Plan_68 D6 refuses from the model.
15
+ */
16
+ export const safeSlice = (value, max) => {
17
+ const text = String(value ?? '').toWellFormed();
18
+ const result = text.slice(0, max);
19
+ return result.isWellFormed() ? result : result.slice(0, -1);
20
+ };
11
21
 
12
22
  /** One line, no newlines, bounded length — a five-line reply must stay five lines. */
13
23
  export const line = (value, max = 200) => {
@@ -15,7 +25,7 @@ export const line = (value, max = 200) => {
15
25
  if (text.length <= max) return text;
16
26
 
17
27
  const ellipsis = '...';
18
- if (max <= ellipsis.length) return text.slice(0, Math.max(0, max));
28
+ if (max <= ellipsis.length) return safeSlice(text, Math.max(0, max));
19
29
 
20
30
  const contentLimit = max - ellipsis.length;
21
31
  const boundary = text.lastIndexOf(' ', contentLimit);
@@ -24,11 +34,11 @@ export const line = (value, max = 200) => {
24
34
  // where a word ended. Dropping a trailing number here as well cost `All 298 12345678 rest`
25
35
  // its 298 — a number that fitted whole — because the digit test compared the limit rather
26
36
  // than the point the text was actually cut at.
27
- if (boundary > 0) return `${text.slice(0, boundary)}${ellipsis}`;
37
+ if (boundary > 0) return `${safeSlice(text, boundary)}${ellipsis}`;
28
38
 
29
39
  // No space to retreat to: the limit lands inside a single long word. Only here can the cut
30
40
  // land mid-number, so only here is a partial number dropped.
31
- return `${text.slice(0, contentLimit).replace(/\d+$/, '')}${ellipsis}`;
41
+ return `${safeSlice(text, contentLimit).replace(/\d+$/, '')}${ellipsis}`;
32
42
  };
33
43
 
34
44
  export const readText = (file) => {
@@ -60,32 +70,22 @@ export const size = (file) => {
60
70
  };
61
71
 
62
72
  /**
63
- * Snapshot lines are `<added>\t<deleted>\t<path>` for tracked files and `U\t<bytes>\t<path>`
64
- * for untracked ones — see worktreeSnapshot() in runner/git-state.mjs. Path last, state first.
73
+ * Tolerant display reader for reply, run-state and write-meta; invalid snapshots display
74
+ * an empty Map. Strict judgement goes through run-snapshots.mjs.
65
75
  */
66
76
  export const snapshotMap = (text) => {
67
- const map = new Map();
68
- for (const row of text.split(/\r?\n/).filter(Boolean)) {
69
- const parts = row.split('\t');
70
- if (parts.length < 3) continue;
71
- map.set(parts.slice(2).join('\t').trim(), `${parts[0]}\t${parts[1]}`);
72
- }
73
- return map;
77
+ const snapshot = decodeSnapshot(text);
78
+ return snapshot.ok ? snapshot.rows : new Map();
74
79
  };
75
80
 
76
81
  /**
77
- * Paths Codex actually touched, by content rather than by porcelain letter: a file that
78
- * was already ` M` stays ` M` after further edits, so status codes would report zero
79
- * work. Line counts (or size, for untracked files) do change.
82
+ * Tolerant display reader for reply, run-state and write-meta; invalid comparisons display
83
+ * no changes. Strict judgement goes through run-snapshots.mjs. Snapshot states measure
84
+ * edits even when a file's porcelain letter stays unchanged.
80
85
  */
81
86
  export const changedPaths = (before, after) => {
82
- const was = snapshotMap(before);
83
- const now = snapshotMap(after);
84
- const paths = [];
85
- for (const [file, state] of now) if (was.get(file) !== state) paths.push(file);
86
- // A path that dropped out was touched too: Codex restored it to its committed state.
87
- for (const file of was.keys()) if (!now.has(file)) paths.push(file);
88
- return paths;
87
+ const comparison = compareSnapshots(before, after);
88
+ return comparison.ok ? comparison.changed : [];
89
89
  };
90
90
 
91
91
  /** Directories a delegated build has no business editing, whatever the task says. */
@@ -2,7 +2,8 @@
2
2
  * Renders the reply lines a dispatcher is allowed to return, one format per agent.
3
3
  *
4
4
  * AGENTS adds reply strategies to the shared agent registry: for each one it describes how
5
- * to tell the result file is filled in and which reply strategy below renders it. FAIL and LIMIT bypass the per-agent strategy — a run that
5
+ * to tell the result file is filled in and which reply strategy below renders it.
6
+ * FAIL, LIMIT and UNAVAILABLE bypass the per-agent strategy — a run that
6
7
  * produced nothing has nothing agent-specific left to say.
7
8
  *
8
9
  * The lines built here ARE the reply. Agents forward this text verbatim instead of
@@ -24,6 +25,11 @@ const runChanges = (runDir) =>
24
25
 
25
26
  const readCommand = (runDir) => `codex-bridge read ${runDir}`;
26
27
 
28
+ function stripVerdict(value) {
29
+ // Run 2026-10-01_114244_plan72-r3q-live-probe-20261001 repeated OK because the model prefixed its summary.
30
+ return String(value ?? '').replace(/^(?:OK|FAIL|LIMIT|UNAVAILABLE)(?: — | - |: )/, '');
31
+ }
32
+
27
33
  export const AGENTS = {
28
34
  'codex-scout': {
29
35
  ...AGENT_DEFINITIONS['codex-scout'],
@@ -54,7 +60,7 @@ function scoutReply(ctx) {
54
60
  const unknowns = (r.unknowns || []).filter(Boolean);
55
61
  const coverage = scoutCoverage(ctx.runDir, r);
56
62
  return [
57
- `OK — ${line(r.answer, 160)}`,
63
+ `OK — ${line(stripVerdict(r.answer), 160)}`,
58
64
  // Any explicit question gets a coverage line, including a valid one-question order.
59
65
  ...(coverage ? [`Coverage: ${coverage}`] : []),
60
66
  `Key finding: ${top ? `${line(top.fact, 130)} (${line(top.where, 60)})` : 'no findings listed'}`,
@@ -68,6 +74,7 @@ function buildReply(ctx) {
68
74
  const { work: touchedPaths, environment } = runChanges(ctx.runDir);
69
75
  const paths = touchedPaths.slice(0, 3).join(', ');
70
76
  const flags = readText(path.join(ctx.runDir, 'flags.txt')).split(/\r?\n/).filter(Boolean);
77
+ const gaps = readText(path.join(ctx.runDir, 'flags-coverage.txt')).split(/\r?\n/).filter((gap) => gap.trim());
71
78
  // A multi-line verification is several commands, and collapsing them into one 60-character
72
79
  // line cut the last one mid-word: the reply named a command nobody could run.
73
80
  const commands = String(r.verify_command ?? '')
@@ -90,7 +97,7 @@ function buildReply(ctx) {
90
97
  ? `${paths ? `${paths} · ` : ''}changes were made by an earlier run of this task`
91
98
  : paths || 'worktree untouched';
92
99
  return [
93
- `OK — ${line(r.summary, 300)}`,
100
+ `OK — ${line(stripVerdict(r.summary), 300)}`,
94
101
  `Files: ${touchedPaths.length} changed · ${files}`,
95
102
  // Only when something outside the run wrote to the tree. Subtracting those paths from the
96
103
  // verdict without naming them would hide a real edit behind a pattern.
@@ -99,6 +106,9 @@ function buildReply(ctx) {
99
106
  : []),
100
107
  `Verification: ${verify} — ${verdict}`,
101
108
  `Flags: ${flags.length ? `${flags.length} TODO/skip — ${line(flags.slice(0, 3).join(' | '), 140)}` : 'none'}`,
109
+ ...(gaps.length
110
+ ? [`Flags coverage: incomplete — ${line(gaps[0], 120)}${gaps.length > 1 ? ` (+${gaps.length - 1} more)` : ''}`]
111
+ : []),
102
112
  `Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
103
113
  ];
104
114
  }
@@ -223,3 +233,27 @@ export function limitReply(ctx, meta) {
223
233
  rows.push(`Log: ${readCommand(ctx.runDir)}`);
224
234
  return rows;
225
235
  }
236
+
237
+ /** Plan_60 D2: pre-start and post-start refusals share the same handoff contract. */
238
+ export function unavailableRows(signal) {
239
+ return [
240
+ 'UNAVAILABLE — Codex is not available (missing or signed out); ' +
241
+ 'hand the task to the next executor, do not retry',
242
+ `Signal: ${line(signal, 170)}`,
243
+ 'Operator check: codex --version; codex login status (sign in with codex login)',
244
+ ];
245
+ }
246
+
247
+ export function unavailableReply(ctx, meta) {
248
+ const rows = unavailableRows(meta.reason);
249
+ if (ctx.agent === 'codex-build') {
250
+ const { work: touched } = runChanges(ctx.runDir);
251
+ rows.push(
252
+ `Worktree: ${
253
+ touched.length ? `has unfinished changes (${touched.length}), see git-after.txt` : 'no new changes'
254
+ }`,
255
+ );
256
+ }
257
+ rows.push(`Log: ${readCommand(ctx.runDir)}`);
258
+ return rows;
259
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Read a run's before/after snapshots and compare them strictly.
3
+ * On 2026-09-30 git-quoted Cyrillic names made an in-scope edit look out of scope;
4
+ * missing or untrustworthy snapshots must refuse judgement, never imply a clean tree.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { compareSnapshots } from './snapshot-format.mjs';
9
+
10
+ export function readSnapshot(file) {
11
+ try {
12
+ return fs.readFileSync(file, 'utf8');
13
+ } catch (error) {
14
+ if (error.code === 'ENOENT') return null;
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ export function runSnapshotChanges(runDir) {
20
+ return compareSnapshots(
21
+ readSnapshot(path.join(runDir, 'state-before.txt')),
22
+ readSnapshot(path.join(runDir, 'state-after.txt')),
23
+ );
24
+ }
25
+
26
+ export function snapshotRefusal(comparison) {
27
+ const issue = comparison.side ? `${comparison.side} ${comparison.issue}` : comparison.issue;
28
+ return `worktree snapshots cannot be compared (${issue}): ${comparison.detail}`;
29
+ }
@@ -12,6 +12,7 @@ import path from 'node:path';
12
12
  import { changedPaths, line, normalizePath, readJson, readText, size } from './paths.mjs';
13
13
  import { withLaunchRows } from './launch-rows.mjs';
14
14
  import { runLiveness } from './run-liveness.mjs';
15
+ import { removeFlagBaselineCopies } from '../runner/flag-baseline.mjs';
15
16
 
16
17
  /**
17
18
  * Run state on disk, merged over whatever is already there. A killed runner leaves no
@@ -115,6 +116,8 @@ export function markAbandoned(runsRoot, currentTree) {
115
116
  }
116
117
  try {
117
118
  writeStatus(runDir, patch);
119
+ // Plan_60 D3a: a dead worker cannot remove its start copies; keep the manifest for inspection.
120
+ if (patch.state === 'abandoned') removeFlagBaselineCopies(runDir);
118
121
  changed.push({ run: entry.name, state: patch.state });
119
122
  } catch {
120
123
  // A folder that cannot be written to is not worth failing a fresh run over.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Serialize, validate and compare worktree snapshots; owns the version rule.
3
+ *
4
+ * On 2026-09-30 git's quoted Cyrillic names made an in-scope edit look out of scope
5
+ * and hid a new Cyrillic file. JSON names preserve exact spelling; mixed versions
6
+ * must refuse comparison rather than repeat that incident.
7
+ */
8
+ import { parseJsonText } from '../json-file.mjs';
9
+
10
+ export const SNAPSHOT_V2_HEADER = '# codex-bridge-state-v2';
11
+
12
+ // `unreadable`: listed but not readable (a Windows file held by another process gives EBUSY/EPERM).
13
+ const STATE_RE = /^(?:\d+\t\d+|-\t-|U\t(?:\d+:[0-9a-fA-F]{64}|missing|unreadable))$/;
14
+ const LEGACY_ROW_RE = /^(?:\d+\t\d+|-\t-|U\t\d+)\t\S.*$/;
15
+ const validState = (state) => typeof state === 'string' && STATE_RE.exec(state)?.[0] === state;
16
+ const malformed = (detail) => ({ ok: false, issue: 'malformed', detail });
17
+
18
+ export function encodeSnapshot(rows) {
19
+ const paths = new Set();
20
+ const ordered = [];
21
+ for (const row of rows) {
22
+ if (!row || typeof row.path !== 'string' || !validState(row.state)) {
23
+ throw new TypeError('Snapshot rows require a string path and a valid v2 state.');
24
+ }
25
+ if (paths.has(row.path)) throw new TypeError(`Duplicate snapshot path: ${JSON.stringify(row.path)}`);
26
+ paths.add(row.path);
27
+ ordered.push({ path: row.path, state: row.state });
28
+ }
29
+ ordered.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
30
+ return [SNAPSHOT_V2_HEADER, ...ordered.map(({ path, state }) => `${state}\t${JSON.stringify(path)}`)]
31
+ .join('\n') + '\n';
32
+ }
33
+
34
+ export function decodeSnapshot(text) {
35
+ if (text === null) return { ok: false, issue: 'missing', detail: 'Snapshot file is absent.' };
36
+ if (typeof text !== 'string') return malformed('Snapshot text must be a string or null.');
37
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
38
+ const version = lines[0] === SNAPSHOT_V2_HEADER ? 2 : 1;
39
+ if (version === 2) lines.shift();
40
+ // Only the final line terminator is framing; an empty interior row is damaged data.
41
+ if (lines.at(-1) === '') lines.pop();
42
+ const rows = new Map();
43
+ for (const [index, line] of lines.entries()) {
44
+ const location = `Snapshot line ${index + (version === 2 ? 2 : 1)}`;
45
+ let path;
46
+ let state;
47
+ if (version === 2) {
48
+ const fields = line.split('\t');
49
+ if (fields.length !== 3) return malformed(`${location} must have exactly three tab fields.`);
50
+ state = `${fields[0]}\t${fields[1]}`;
51
+ if (!validState(state)) return malformed(`${location} has an invalid v2 state.`);
52
+ try {
53
+ path = parseJsonText(location, fields[2]);
54
+ } catch {
55
+ return malformed(`${location} has an invalid JSON path.`);
56
+ }
57
+ if (typeof path !== 'string') return malformed(`${location} path must be a JSON string.`);
58
+ } else {
59
+ // The legacy launcher wrote `${snapshot}\n`, so a clean tree is the text "\n" and the old
60
+ // reader skipped blank rows. Refusing them failed every build that started on a clean tree.
61
+ if (line === '') continue;
62
+ if (!LEGACY_ROW_RE.test(line)) return malformed(`${location} has an invalid legacy row.`);
63
+ const fields = line.split('\t');
64
+ state = `${fields[0]}\t${fields[1]}`;
65
+ path = fields.slice(2).join('\t').trim();
66
+ if (path.startsWith('"')) {
67
+ return {
68
+ ok: false,
69
+ issue: 'legacy-quoted-name',
70
+ detail: `${location} contains a git-quoted legacy name whose original spelling is lost.`,
71
+ };
72
+ }
73
+ }
74
+ if (rows.has(path)) return malformed(`${location} repeats path ${JSON.stringify(path)}.`);
75
+ rows.set(path, state);
76
+ }
77
+ return { ok: true, version, rows };
78
+ }
79
+
80
+ export function compareSnapshots(beforeText, afterText) {
81
+ const before = decodeSnapshot(beforeText);
82
+ if (!before.ok) return { ...before, side: 'before' };
83
+ const after = decodeSnapshot(afterText);
84
+ if (!after.ok) return { ...after, side: 'after' };
85
+ if (before.version !== after.version) {
86
+ return {
87
+ ok: false,
88
+ issue: 'incompatible-versions',
89
+ detail: 'The run started under another package version; restart the run before comparing snapshots.',
90
+ };
91
+ }
92
+ const changed = [];
93
+ for (const [path, state] of after.rows) if (before.rows.get(path) !== state) changed.push(path);
94
+ for (const path of before.rows.keys()) if (!after.rows.has(path)) changed.push(path);
95
+ return { ok: true, changed };
96
+ }
@@ -10,7 +10,6 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import {
12
12
  SERVICE_RE,
13
- changedPaths,
14
13
  declaredHits,
15
14
  displayPath,
16
15
  expandDeclared,
@@ -29,6 +28,9 @@ import { startupGap } from './startup.mjs';
29
28
  import { transportGap } from './transport.mjs';
30
29
  import { reasonFrom } from './reason.mjs';
31
30
  import { adviceGap } from './advice-status.mjs';
31
+ import { allStartupContext, contextOnlyGap } from './context-only.mjs';
32
+ import { compareSnapshots } from './snapshot-format.mjs';
33
+ import { readSnapshot, runSnapshotChanges, snapshotRefusal } from './run-snapshots.mjs';
32
34
 
33
35
  // How much prose an answer must carry once coordinates and paths are subtracted. Measured
34
36
  // on the scout run of 2026-07-30 that replied with a table of `file.ts:60-79` rows: every
@@ -79,16 +81,14 @@ const WRONG_WORK = 'wrong work was done: ';
79
81
  */
80
82
  export function reportVersusWork(runDir, result, ctx) {
81
83
  const declared = (result?.changes || []).flatMap((c) => expandDeclared(c?.file));
82
- const after = readText(path.join(runDir, 'state-after.txt'));
84
+ const comparison = runSnapshotChanges(runDir);
85
+ const broken = (reason) => ({ ok: false, carried: false, reason });
86
+ if (!comparison.ok) return broken(snapshotRefusal(comparison));
83
87
  // Only the run's own work is compared: a report that names nothing is honest when the sole
84
88
  // change in the tree was written by the tooling around the run.
85
- const touched = splitRunChanges(
86
- runDir,
87
- changedPaths(readText(path.join(runDir, 'state-before.txt')), after),
88
- ).work;
89
+ const touched = splitRunChanges(runDir, comparison.changed).work;
89
90
  const show = (list) => list.slice(0, 3).join(', ');
90
91
  const agreed = (carried = false) => ({ ok: true, carried, reason: null });
91
- const broken = (reason) => ({ ok: false, carried: false, reason });
92
92
 
93
93
  if (!declared.length && !touched.length) return agreed();
94
94
  if (!declared.length) {
@@ -110,8 +110,11 @@ export function reportVersusWork(runDir, result, ctx) {
110
110
 
111
111
  const baseline = chainBaseline(ctx?.runsRoot, ctx?.repo, ctx?.slug, ctx?.taskHash, ctx?.orderId);
112
112
  if (baseline !== null) {
113
- const accumulated = splitRunChanges(runDir, changedPaths(baseline, after)).work;
114
- if (declared.some((d) => declaredHits(d, accumulated))) return agreed(true);
113
+ const chain = compareSnapshots(baseline, readSnapshot(path.join(runDir, 'state-after.txt')));
114
+ if (chain.ok) {
115
+ const accumulated = splitRunChanges(runDir, chain.changed).work;
116
+ if (declared.some((d) => declaredHits(d, accumulated))) return agreed(true);
117
+ }
115
118
  }
116
119
 
117
120
  return broken(
@@ -154,7 +157,7 @@ function substanceLength(text) {
154
157
 
155
158
  const questionsOf = (runDir) => {
156
159
  const parsed = readJson(path.join(runDir, 'questions.json'));
157
- return Array.isArray(parsed) ? parsed.filter((q) => q && q.id) : [];
160
+ return Array.isArray(parsed) ? parsed : [];
158
161
  };
159
162
 
160
163
  /** Answers indexed by question id, first one wins; ids come back in whatever case Codex used. */
@@ -173,8 +176,8 @@ const answersById = (result) => {
173
176
  * single-answer check. Every branch here exists because a schema alone accepted the run that
174
177
  * answered in coordinates.
175
178
  */
176
- function scoutCoverageGap(runDir, result) {
177
- const questions = questionsOf(runDir);
179
+ function scoutCoverageGap(orderedQuestions, result) {
180
+ const questions = orderedQuestions.filter((q) => q && q.id);
178
181
  const show = (list) => list.slice(0, 8).join(', ');
179
182
 
180
183
  if (!questions.length) {
@@ -209,7 +212,7 @@ function scoutCoverageGap(runDir, result) {
209
212
 
210
213
  /** `1/1 sub-questions`, or null for legacy runs with no questions.json artifact. */
211
214
  export function scoutCoverage(runDir, result) {
212
- const questions = questionsOf(runDir);
215
+ const questions = questionsOf(runDir).filter((q) => q && q.id);
213
216
  if (!questions.length) return null;
214
217
  const byId = answersById(result);
215
218
  const answered = questions.filter((q) => String(byId.get(q.id)?.answer || '').trim()).length;
@@ -286,9 +289,10 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
286
289
  // This branch already knows why the run died: the CLI said so itself, with a status. Letting
287
290
  // the ordered reason speak here would answer a transport question with the model's older
288
291
  // complaint about the task — a LIMIT that never mentions quota is the same "verdict says the
289
- // wrong thing" defect this module exists to remove.
292
+ // wrong thing" defect this module exists to remove. Plan_60 D2: UNAVAILABLE only on a measured
293
+ // signed-out sample (events.mjs); an unfamiliar failure stays FAIL with its raw CLI text.
290
294
  return {
291
- status: transportError.quota ? 'LIMIT' : 'FAIL',
295
+ status: transportError.quota ? 'LIMIT' : transportError.unavailable ? 'UNAVAILABLE' : 'FAIL',
292
296
  reason: transportError.reason,
293
297
  };
294
298
  }
@@ -316,19 +320,25 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
316
320
  // Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
317
321
  // only when questions.json is absent — see scoutCoverageGap().
318
322
  if (agent === 'codex-scout') {
323
+ const questions = questionsOf(runDir);
319
324
  // The 2026-09-16 dead sandbox produced nonempty evidence without reading code. Require an
320
325
  // execution fact first: checking whether evidence paths existed falsely rejected 64/151 runs.
321
326
  // Only a run that left a stream can prove it: an archived run from before `--json` has none and
322
327
  // is judged by the contract of its day; a `--json` run missing its stream already failed above.
323
- if (eventData.hasStream && eventData.commands_executed === 0) {
328
+ // Plan_60 D1 exempts only orders whose every question asks about handed startup context.
329
+ if (eventData.hasStream && eventData.commands_executed === 0 && !allStartupContext(questions)) {
324
330
  return {
325
331
  status: 'FAIL',
326
332
  reason: 'scout executed no command, so no answer rests on reading the code; '
327
333
  + 'check stderr.log for sandbox refusals before changing the order',
328
334
  };
329
335
  }
330
- const gap = scoutCoverageGap(runDir, result);
336
+ const gap = scoutCoverageGap(questions, result);
331
337
  if (gap) return { status: 'FAIL', reason: gap };
338
+ const contextGap = contextOnlyGap({
339
+ questions, result, commandsExecuted: eventData.hasStream ? eventData.commands_executed : null,
340
+ });
341
+ if (contextGap) return { status: 'FAIL', reason: contextGap };
332
342
  }
333
343
  // Plan_59 D3/D4/D5/D10: a schema-valid agreement still needs evidence and decision checks.
334
344
  // Place the advisor judge before the build-only scope check, alongside the other agent contracts.
@@ -349,13 +359,9 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
349
359
  // Environment writes are subtracted first: attributing them to the run failed an honest
350
360
  // pass for a file it never opened. What was subtracted is reported, not swallowed —
351
361
  // see the "Environment" line in reply.mjs and environment_changes in meta.json.
352
- const { work } = splitRunChanges(
353
- runDir,
354
- changedPaths(
355
- readText(path.join(runDir, 'state-before.txt')),
356
- readText(path.join(runDir, 'state-after.txt')),
357
- ),
358
- );
362
+ const comparison = runSnapshotChanges(runDir);
363
+ if (!comparison.ok) return { status: 'FAIL', reason: snapshotRefusal(comparison) };
364
+ const { work } = splitRunChanges(runDir, comparison.changed);
359
365
  const strays = outOfScope(work, patterns);
360
366
  if (strays.length) {
361
367
  return {
@@ -23,6 +23,15 @@ export const CONTINUATION_INPUT = Object.freeze({
23
23
  conditional: 'when --continue is passed',
24
24
  });
25
25
 
26
+ export const RETRY_INPUT = Object.freeze({
27
+ label: 'retry',
28
+ source: 'the orchestrator',
29
+ explanation:
30
+ 'The failed run this pass repeats, followed by why the orchestrator pays for the same pass again. A retry repeats that failed pass under its own order; it does not authorize the next pass.',
31
+ example: '2026-10-03_172017_cc-d66-advisor — model at capacity, same pass again',
32
+ conditional: 'when --continue is passed',
33
+ });
34
+
26
35
  /**
27
36
  * The task statement reaches the runner as a file because every other channel puts the invocation
28
37
  * back into the multi-line form that no permission rule can cover — a heredoc on stdin, or a
@@ -53,6 +62,7 @@ export const REQUIRED_INPUTS = Object.freeze({
53
62
  },
54
63
  TASK_FILE_INPUT,
55
64
  CONTINUATION_INPUT,
65
+ RETRY_INPUT,
56
66
  ]),
57
67
  'codex-build': freezeEntries([
58
68
  {
@@ -69,6 +79,7 @@ export const REQUIRED_INPUTS = Object.freeze({
69
79
  },
70
80
  TASK_FILE_INPUT,
71
81
  CONTINUATION_INPUT,
82
+ RETRY_INPUT,
72
83
  ]),
73
84
  'codex-review': freezeEntries([
74
85
  {
@@ -79,6 +90,7 @@ export const REQUIRED_INPUTS = Object.freeze({
79
90
  },
80
91
  TASK_FILE_INPUT,
81
92
  CONTINUATION_INPUT,
93
+ RETRY_INPUT,
82
94
  ]),
83
95
  // Plan_59 D7: the caller must name the phase and authorize the continued decision pass.
84
96
  'codex-advisor': freezeEntries([
@@ -90,6 +102,7 @@ export const REQUIRED_INPUTS = Object.freeze({
90
102
  },
91
103
  TASK_FILE_INPUT,
92
104
  CONTINUATION_INPUT,
105
+ RETRY_INPUT,
93
106
  {
94
107
  label: 'phase',
95
108
  source: 'the orchestrator',
@@ -122,13 +135,15 @@ export function isPlaceholder(value) {
122
135
  export const isInputPlaceholder = (value, label) =>
123
136
  !(label === 'phase' && cleanValue(value) === 'scope') && isPlaceholder(value);
124
137
 
125
- export function extractValue(promptText, label) {
138
+ // Plan_75 D5: dispatcher prompts still need one spelling of a labelled line for reading values.
139
+ const labelledLine = (label) => {
126
140
  const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
141
+ return `(?:[-*]\\s*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)\\s*(?::|=|—|-)`;
142
+ };
143
+
144
+ export function extractValue(promptText, label) {
127
145
  const flag = `--${label.replaceAll(/\s+/g, '-')}`;
128
- const direct = new RegExp(
129
- `(?:^|\\r?\\n)\\s*(?:[-*]\\s*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)\\s*(?::|=|—|-)\\s*([^\\r\\n]*)`,
130
- 'im',
131
- );
146
+ const direct = new RegExp(`(?:^|\\r?\\n)\\s*${labelledLine(label)}\\s*([^\\r\\n]*)`, 'im');
132
147
  const directMatch = promptText.match(direct);
133
148
  if (directMatch) return cleanValue(directMatch[1]);
134
149
 
@@ -188,10 +203,15 @@ export function diagnoseInput(promptText, label) {
188
203
  return { line, reason: `expected \`${label}: value\` with a separator immediately after the label` };
189
204
  }
190
205
 
191
- /** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
192
- export function parseContinuationGrant(promptText) {
206
+ // Plan_75 D1, TradeForge capacity incident (2026-10-03): both grants need the same run/reason grammar.
207
+ function parseLabelledGrant(promptText, label) {
193
208
  const prompt = typeof promptText === 'string' ? promptText : '';
194
- const value = extractValue(prompt, CONTINUATION_INPUT.label);
209
+ const value = extractValue(prompt, label);
210
+ return splitGrantValue(value);
211
+ }
212
+
213
+ // Plan_75 D5, 2026-10-03 20:42: header parsing must reuse the existing grant value grammar.
214
+ export function splitGrantValue(value) {
195
215
  if (isPlaceholder(value)) return null;
196
216
 
197
217
  for (const separator of [/\s+—\s+/, /\s+-\s+/, /\s*:\s*/]) {
@@ -205,6 +225,26 @@ export function parseContinuationGrant(promptText) {
205
225
  return null;
206
226
  }
207
227
 
228
+ /** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
229
+ export function parseContinuationGrant(promptText) {
230
+ return parseLabelledGrant(promptText, CONTINUATION_INPUT.label);
231
+ }
232
+
233
+ export function parseRetryGrant(promptText) {
234
+ return parseLabelledGrant(promptText, RETRY_INPUT.label);
235
+ }
236
+
237
+ export function parseGrant(promptText) {
238
+ const continuation = parseContinuationGrant(promptText);
239
+ const retry = parseRetryGrant(promptText);
240
+ if (continuation && retry) {
241
+ return { error: 'exactly one grant per task: remove either the continue: or the retry: line' };
242
+ }
243
+ if (continuation) return { kind: 'continue', ...continuation };
244
+ if (retry) return { kind: 'retry', ...retry };
245
+ return null;
246
+ }
247
+
208
248
  /** Returns the immutable input entries for one dispatcher type. */
209
249
  export function requiredInputsFor(agentType) {
210
250
  return REQUIRED_INPUTS[agentType] || [];
@@ -54,6 +54,7 @@ import { setWorkerDir, getRun, emitReply } from './runner/run-context.mjs';
54
54
  import { launcher } from './runner/launcher.mjs';
55
55
  import { worker } from './runner/worker.mjs';
56
56
  import { RunnerUsageError } from './runner/args.mjs';
57
+ import { EXIT } from './runner/exit-codes.mjs';
57
58
 
58
59
  export { parseArgs } from './runner/args.mjs';
59
60
  export { runsPrefixInside, worktreeSnapshot } from './runner/git-state.mjs';
@@ -88,18 +89,20 @@ export function installRunnerCrashBoundary() {
88
89
  `Log: codex-bridge read ${currentRun}`,
89
90
  ]);
90
91
  emitReply(reply);
91
- process.exit(1);
92
+ process.exit(EXIT.FAIL);
92
93
  } catch {
93
94
  // Falls through to the bare reply below: the disk itself is not cooperating.
94
95
  }
95
96
  }
96
97
  emitReply(
97
98
  [
98
- `FAIL — Codex runner crashed before creating the run folder: ${String(err.message).replace(/\s+/g, ' ').slice(0, 150)}`,
99
+ `FAIL — Codex runner crashed before creating the run folder: ${
100
+ String(err.message).replace(/\s+/g, ' ').slice(0, 150)
101
+ }`,
99
102
  'No artifacts; nothing to inspect',
100
103
  ].join('\n'),
101
104
  );
102
- process.exit(1);
105
+ process.exit(EXIT.FAIL);
103
106
  });
104
107
  }
105
108
 
@@ -53,7 +53,16 @@ export function readRunConfig(file = CONFIG_PATH) {
53
53
  try {
54
54
  parsed = readJsonFileSync(file);
55
55
  } catch (err) {
56
- if (err.code) return { ...DEFAULTS };
56
+ // Only a missing file means "no config". Every error code used to mean that, so a config the
57
+ // run could not open (EACCES, EISDIR, EPERM) ran every role on default models with nobody told
58
+ // (Plan_69 stage 0) — the one reader of an operator file that treated failure as absence.
59
+ if (err.code === 'ENOENT') return { ...DEFAULTS };
60
+ if (err.code) {
61
+ throw new Error(
62
+ `${file} cannot be read (${err.code}). Fix its permissions or type, or delete it — without it, ` +
63
+ 'the default mode applies: hooks and plugins are disabled.',
64
+ );
65
+ }
57
66
  throw new Error(
58
67
  `${file} cannot be parsed as JSON (${err.cause?.message || err.message}). Fix or delete the file — without it, ` +
59
68
  'the default mode applies: hooks and plugins are disabled.',