@lyupro/codex-bridge 0.6.9 → 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 (52) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/cli/lifecycle-lock-inspect.mjs +3 -2
  4. package/cli/lifecycle-lock.mjs +19 -132
  5. package/cli/read.mjs +20 -0
  6. package/cli/settings-merge.mjs +3 -29
  7. package/package.json +2 -2
  8. package/scripts/home-write-inventory.mjs +12 -2
  9. package/src/agents/codex-advisor.md +22 -12
  10. package/src/agents/codex-build.md +24 -14
  11. package/src/agents/codex-review.md +17 -10
  12. package/src/agents/codex-scout.md +22 -12
  13. package/src/home/hooks/order-gate.mjs +25 -11
  14. package/src/home/hooks/reply-parser.mjs +1 -1
  15. package/src/home/lib/command-path.mjs +32 -0
  16. package/src/home/lib/dispatcher-command.mjs +4 -2
  17. package/src/home/lib/kernel-lock.mjs +171 -0
  18. package/src/home/lib/meta/chain.mjs +19 -8
  19. package/src/home/lib/meta/context-only.mjs +43 -0
  20. package/src/home/lib/meta/events.mjs +14 -2
  21. package/src/home/lib/meta/launch-rows.mjs +23 -0
  22. package/src/home/lib/meta/reply.mjs +37 -3
  23. package/src/home/lib/meta/run-state.mjs +3 -0
  24. package/src/home/lib/meta/verdict.mjs +16 -8
  25. package/src/home/lib/required-inputs.mjs +48 -8
  26. package/src/home/lib/run-codex.mjs +6 -3
  27. package/src/home/lib/runner/advise-carry.mjs +2 -1
  28. package/src/home/lib/runner/args.mjs +16 -5
  29. package/src/home/lib/runner/attach.mjs +21 -14
  30. package/src/home/lib/runner/build-evidence.mjs +29 -0
  31. package/src/home/lib/runner/codex-availability.mjs +50 -0
  32. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  33. package/src/home/lib/runner/continuation.mjs +60 -8
  34. package/src/home/lib/runner/exit-codes.mjs +13 -0
  35. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  36. package/src/home/lib/runner/flag-scan.mjs +165 -0
  37. package/src/home/lib/runner/git-state.mjs +0 -33
  38. package/src/home/lib/runner/launcher.mjs +233 -273
  39. package/src/home/lib/runner/order-claim.mjs +84 -0
  40. package/src/home/lib/runner/order-owner.mjs +18 -0
  41. package/src/home/lib/runner/pass-gate.mjs +109 -0
  42. package/src/home/lib/runner/preflight.mjs +22 -25
  43. package/src/home/lib/runner/prompts.mjs +7 -2
  44. package/src/home/lib/runner/question-kind.mjs +41 -0
  45. package/src/home/lib/runner/run-env.mjs +2 -1
  46. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  47. package/src/home/lib/runner/scope-check.mjs +29 -11
  48. package/src/home/lib/runner/task-input.mjs +11 -2
  49. package/src/home/lib/runner/worker-admission.mjs +43 -0
  50. package/src/home/lib/runner/worker.mjs +19 -18
  51. package/src/home/lib/task-header.mjs +127 -0
  52. package/src/home/lib/write-meta.mjs +17 -5
@@ -38,13 +38,17 @@ files, do not run grep, do not retell the report, and do not reason about the ta
38
38
  hides. If the orchestrator did not give a required input, do not guess — start the runner without
39
39
  its flag and return the runner's refusal verbatim.
40
40
  - The sub-questions are NOT yours to pass. They live in the task file the orchestrator wrote, under
41
- a `Questions` heading, one Markdown list item each. You never read that file, never edit it and
42
- never put a question on the command line: a question is free prose, and free prose in an argument
41
+ a `Questions` heading, one Markdown list item each. A question may carry the orderer's
42
+ `[context-only]` marker; the dispatcher never adds, removes or repairs it. You never read that
43
+ file, never edit it and never put a question on the command line: a question is free prose, and
44
+ free prose in an argument
43
45
  makes the host stop applying the operator's permission rule, which is how a delegation dies on a
44
46
  refusal. If the file holds no question, start the runner anyway and return its refusal verbatim.
45
- - `continue` — when the task text contains a line beginning with the `continue:` label, pass the
46
- bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
47
- or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
47
+ - `continue` — when the orchestrator's call carries a line beginning with `continue:` or `retry:`,
48
+ pass the bare `--continue` flag even when its run name or reason looks malformed. A `retry:`
49
+ grant lets the orchestrator repeat a failed pass (FAIL, LIMIT, UNAVAILABLE) of the same order
50
+ once; it is not the next pass. Do not inspect, repair, or swallow this grant; pass it through
51
+ and let the runner issue the refusal. A continuation is
48
52
  assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
49
53
  output and stop; do not issue or invent another continuation. If no such grant line is present,
50
54
  the flag must not be present.
@@ -85,8 +89,9 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
85
89
  decides, which is the intended default — a placeholder copied from this template is refused before
86
90
  Codex starts.
87
91
 
88
- If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
89
- flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
92
+ If the orchestrator's call carries a line beginning with `continue:` or `retry:`, add the bare
93
+ `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair, or
94
+ swallow the line;
90
95
  the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
91
96
  present in the command.
92
97
  No value
@@ -170,6 +175,7 @@ confidence), `unknowns[]`, and `report_markdown`. An uncovered subquestion or an
170
175
  evidence is FAIL;
171
176
  the runner prints the line `Coverage: N/M subquestions`. The runner expands the last field into
172
177
  `report.md`.
178
+ A `[context-only]` subquestion is answered with `startup:<source>` evidence.
173
179
  An empty `answer` is FAIL with a path to the log, not a reason to choose different flags.
174
180
 
175
181
  ## The script determines status, not you
@@ -180,9 +186,13 @@ An empty `answer` is FAIL with a path to the log, not a reason to choose differe
180
186
  - `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
181
187
  task was not
182
188
  completed; this is not a task failure and not a reason to restart.
189
+ - `UNAVAILABLE` — Codex is missing or signed out (checked before start, or recognised from the CLI's own
190
+ sign-in refusal after start). The task was not attempted or could not run; this is not a task failure and
191
+ not a reason to restart — return the block as is, the orchestrator hands the task to the next executor.
183
192
 
184
- The script return code mirrors the status: `0` / `1` / `3`. A nonzero code is not a reason to retry,
193
+ The script return code mirrors the status: `0` / `1` / `3` / `5`. A nonzero code is not a reason to retry,
185
194
  not a reason to change the command, and not a reason to investigate on your own.
195
+ Code `5` means `UNAVAILABLE`.
186
196
 
187
197
  The run folder contains `status.json` (`running` / `finished` / `failed` / `abandoned`) and the
188
198
  runner pid.
@@ -194,10 +204,10 @@ which is the runner telling you the answer is on disk already.
194
204
 
195
205
  ## Codex is unavailable
196
206
 
197
- The runner checks `codex --version` before starting. If the binary is missing or authentication
198
- failed,
199
- it prints a ready-made FAIL with a verification command for the operator. Your job is to return that
200
- output.
207
+ The runner looks `codex` up on PATH and asks `codex login status` before starting. A missing binary
208
+ or the measured "Not logged in" answer prints a ready-made `UNAVAILABLE` block (first line
209
+ `UNAVAILABLE — `, exit 5, no run folder). An unclear probe prints an ordinary refusal starting
210
+ `Codex CLI unavailable:` (exit 1). Return that output verbatim.
201
211
  Performing the task manually instead of Codex is prohibited for any reason it fails.
202
212
 
203
213
  ## What a violation looks like
@@ -20,15 +20,16 @@ import {
20
20
  diagnoseInput,
21
21
  extractValue,
22
22
  missingInputs,
23
- parseContinuationGrant,
23
+ parseGrant,
24
24
  shellUnsafeInputs,
25
25
  } from '../lib/required-inputs.mjs';
26
26
  import { SUBAGENT_TOOLS } from '../lib/hook-definitions.mjs';
27
27
  import { taskFingerprint } from '../lib/meta/chain.mjs';
28
- import { conflictingOrderOwner } from '../lib/runner/order-owner.mjs';
28
+ import { conflictingOrderOwner, orderOwnerConflictText } from '../lib/runner/order-owner.mjs';
29
29
  import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
30
30
  import { runsRoot } from '../lib/runner/runs-root.mjs';
31
31
  import { parseTaskDocument } from '../lib/runner/task-file.mjs';
32
+ import { parseTaskHeader, taskHeaderRefusal } from '../lib/task-header.mjs';
32
33
 
33
34
  const GUARDED = new Set(Object.keys(AGENTS));
34
35
  /**
@@ -115,11 +116,29 @@ const readStatus = (file) => {
115
116
 
116
117
  const orderId = extractValue(toolInput.prompt, 'order id');
117
118
  const taskFile = extractValue(toolInput.prompt, 'task file');
118
- if (!orderId || !taskFile || parseContinuationGrant(toolInput.prompt)) pass();
119
+ // Plan_75 D1, TradeForge capacity incident: retries keep their order; the runner owns conflicting-grant refusal.
120
+ const grant = parseGrant(toolInput.prompt);
121
+ if (!orderId || !taskFile || grant?.kind || grant?.error) pass();
122
+
123
+ let rawTask;
124
+ try {
125
+ rawTask = fs.readFileSync(taskFile, 'utf8');
126
+ } catch {
127
+ pass();
128
+ }
129
+ // Plan_75 D5, 2026-10-03 20:42: malformed metadata must not disappear into the disk-state guard.
130
+ const parsed = parseTaskHeader(rawTask);
131
+ const headerRefusal = taskHeaderRefusal(parsed);
132
+ if (headerRefusal) deny(headerRefusal);
133
+ let taskHash;
134
+ try {
135
+ taskHash = taskFingerprint(parseTaskDocument(parsed.body).task);
136
+ } catch {
137
+ // A malformed Questions or Verify section is the runner's refusal to issue, with its own text.
138
+ pass();
139
+ }
119
140
 
120
141
  try {
121
- const taskDocument = parseTaskDocument(fs.readFileSync(taskFile, 'utf8'));
122
- const taskHash = taskFingerprint(taskDocument.task);
123
142
  const projectRunsDir = resolveProjectRunsDir(runsRoot(), input.cwd, { create: false }).dir;
124
143
  // Per folder, not per directory: a run folder created a moment before its status.json, or any
125
144
  // leftover beside the runs, would otherwise throw out of the whole check and silently disarm the
@@ -131,12 +150,7 @@ try {
131
150
  const owner = conflictingOrderOwner(runs, orderId, taskHash);
132
151
  if (owner) {
133
152
  const ownerDir = path.join(projectRunsDir, owner.run);
134
- deny(
135
- `Order gate denied the Agent call because order id ${JSON.stringify(orderId)} already belongs ` +
136
- `to run folder ${ownerDir} (slug ${owner.status.slug}, started_at ${owner.status.started_at}) ` +
137
- 'with a different task. Use a new order id, or provide an explicit continuation grant if ' +
138
- 'this is another pass of that order.',
139
- );
153
+ deny(orderOwnerConflictText(owner, ownerDir, orderId));
140
154
  }
141
155
  } catch {
142
156
  // The 2026-08-15 collision guard is diagnostic: uncertain disk state must not block real work.
@@ -1,5 +1,5 @@
1
1
  /** Parses dispatcher replies into guard facts. */
2
- const STATUSES = ['OK', 'FAIL', 'LIMIT'];
2
+ const STATUSES = ['OK', 'FAIL', 'LIMIT', 'UNAVAILABLE'];
3
3
 
4
4
  /**
5
5
  * Either line names the run folder. `RUN=` is printed by the call that starts a run, `ATTACH=`
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Finds a command on PATH the way the host shell would, without starting it.
3
+ * The runner and CLI share this home module because the home never imports cli/ (Plan_60 D2).
4
+ */
5
+ import { existsSync, statSync } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export function envValue(env, name) {
9
+ const key = Object.keys(env).find((entry) => entry.toLowerCase() === name);
10
+ return key ? env[key] : '';
11
+ }
12
+
13
+ function pathValue(env) {
14
+ return envValue(env, 'path');
15
+ }
16
+
17
+ export function resolveCommandOnPath(name, env = process.env) {
18
+ const extensions = process.platform === 'win32'
19
+ ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
20
+ : [''];
21
+ for (const directory of pathValue(env).split(path.delimiter).filter(Boolean)) {
22
+ for (const extension of extensions) {
23
+ const candidate = path.join(path.resolve(directory), `${name}${extension}`);
24
+ try {
25
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
26
+ } catch {
27
+ continue;
28
+ }
29
+ }
30
+ }
31
+ return null;
32
+ }
@@ -4,7 +4,7 @@ import {
4
4
  extractValue,
5
5
  isAbsoluteTaskFilePath,
6
6
  isInputPlaceholder,
7
- parseContinuationGrant,
7
+ parseGrant,
8
8
  REQUIRED_INPUTS,
9
9
  } from './required-inputs.mjs';
10
10
  import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
@@ -69,7 +69,9 @@ export function canonicalRunCommand(agentType, promptText) {
69
69
  tokens.push('--order-id', quote(inputs.get('order id')));
70
70
  tokens.push('--task-file', quote(inputs.get('task file')));
71
71
  if (inputs.get('effort')) tokens.push('--effort', quote(inputs.get('effort')));
72
- if (parseContinuationGrant(promptText)) tokens.push('--continue');
72
+ // Plan_75 D1, TradeForge capacity incident: forward either grant; the runner owns conflicting-grant refusal.
73
+ const grant = parseGrant(promptText);
74
+ if (grant?.kind || grant?.error) tokens.push('--continue');
73
75
  return { command: tokens.join(' ') };
74
76
  }
75
77
 
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Locks the kernel releases on its holder's death, shared by the lifecycle lock and the order claim.
3
+ * Plan_60 D4: on 2026-09-24 two launchers with one order id started 2.8 seconds apart and both ran
4
+ * and billed; the runner needs the lifecycle lock's primitive and may not import `cli/`.
5
+ * Where the kernel can release a lock on the holder's death, the lock is a kernel object: a named
6
+ * pipe on Windows, an abstract socket on Linux — both reproduced on real machines (hard kill, name
7
+ * free at once, nothing on disk), which matters on a machine that lost power six times in two weeks.
8
+ * The name comes from the directory's dev+ino, not its path: aliases must reach the same claim.
9
+ * A second listen inside the holder must fail, or the platform did not give exclusivity and the
10
+ * call refuses rather than switch protocols (Plan_65 D5/D8). What the holder answers a waiter is
11
+ * diagnosis only — it never grants anything.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import net from 'node:net';
16
+
17
+ const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
18
+
19
+ export function kernelLockStrategy(platform = process.platform) {
20
+ if (platform === 'win32') return 'named-pipe';
21
+ return platform === 'linux' ? 'abstract-socket' : null;
22
+ }
23
+
24
+ export function kernelLockAddress(purpose, digest, platform = process.platform) {
25
+ if (typeof purpose !== 'string' || !/^[a-z][a-z-]*$/.test(purpose)) {
26
+ throw new TypeError('purpose must match ^[a-z][a-z-]*$');
27
+ }
28
+ if (platform === 'win32') return `\\\\.\\pipe\\codex-bridge-${purpose}-${digest}`;
29
+ return platform === 'linux' ? `\0codex-bridge-${purpose}-${digest}` : null;
30
+ }
31
+
32
+ export function directoryDigest(directory, { suffix } = {}) {
33
+ try {
34
+ const stat = fs.statSync(directory, { bigint: true });
35
+ if (!stat.isDirectory()) throw new Error(`${directory} is not a directory`);
36
+ const identity = `${stat.dev}:${stat.ino}`;
37
+ const extra = typeof suffix === 'string' && suffix.length > 0 ? `:${suffix}` : '';
38
+ return createHash('sha256').update(identity + extra).digest('hex');
39
+ } catch (error) {
40
+ if (error?.code === 'ENOENT') return null;
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ function listen(server, address) {
46
+ return new Promise((resolve, reject) => {
47
+ const listening = () => { server.removeListener('error', failed); resolve(); };
48
+ const failed = (error) => { server.removeListener('listening', listening); reject(error); };
49
+ server.once('listening', listening);
50
+ server.once('error', failed);
51
+ try {
52
+ server.listen(address);
53
+ } catch (error) {
54
+ server.removeListener('listening', listening);
55
+ server.removeListener('error', failed);
56
+ reject(error);
57
+ }
58
+ });
59
+ }
60
+
61
+ function closeServer(server) {
62
+ if (!server) return Promise.resolve();
63
+ return new Promise((resolve, reject) => {
64
+ try {
65
+ server.close((error) => {
66
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
67
+ else resolve();
68
+ });
69
+ } catch (error) {
70
+ if (error.code === 'ERR_SERVER_NOT_RUNNING') resolve();
71
+ else reject(error);
72
+ }
73
+ });
74
+ }
75
+
76
+ async function closeQuietly(server) {
77
+ try { await closeServer(server); } catch { /* Preserve the original failure. */ }
78
+ }
79
+
80
+ async function selfCheck(server, address, createServer, label) {
81
+ let probe;
82
+ let detail;
83
+ try {
84
+ probe = createServer();
85
+ await listen(probe, address);
86
+ detail = 'a second listener succeeded';
87
+ } catch (error) {
88
+ if (!error || error.code !== 'EADDRINUSE') detail = (error && (error.code || error.message)) || String(error);
89
+ }
90
+ if (detail === undefined) {
91
+ await closeQuietly(probe);
92
+ return;
93
+ }
94
+ await Promise.all([closeQuietly(probe), closeQuietly(server)]);
95
+ throw new Error(`${label} self-check failed: ${detail}`);
96
+ }
97
+
98
+ export async function tryHoldSocket(address, createServer = net.createServer, label = 'kernel lock') {
99
+ const server = createServer((socket) => socket.destroy());
100
+ try {
101
+ await listen(server, address);
102
+ await selfCheck(server, address, createServer, label);
103
+ let releasePromise;
104
+ return () => (releasePromise ??= closeServer(server));
105
+ } catch (error) {
106
+ await closeQuietly(server);
107
+ if (error?.code === 'EADDRINUSE') return 'in-use';
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ function readSocketHolder(address, timeoutMs, parseAnswer) {
113
+ return new Promise((resolve) => {
114
+ let data = '';
115
+ let settled = false;
116
+ const socket = net.createConnection(address);
117
+ const timer = setTimeout(() => finish(null), timeoutMs);
118
+ const finish = (holder) => {
119
+ if (settled) return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ socket.destroy();
123
+ resolve(holder);
124
+ };
125
+ socket.on('data', (chunk) => {
126
+ data += chunk.toString('utf8');
127
+ const newline = data.indexOf('\n');
128
+ if (newline !== -1) finish(parseAnswer(data.slice(0, newline)));
129
+ });
130
+ socket.once('end', () => finish(parseAnswer(data)));
131
+ socket.once('error', () => finish(null));
132
+ });
133
+ }
134
+
135
+ export async function acquireKernelLock({
136
+ address, holder, waitMs, retryMs, answerTimeoutMs, parseAnswer, label,
137
+ createServer = net.createServer,
138
+ }) {
139
+ const accepted = new Set();
140
+ const onConnection = (socket) => {
141
+ accepted.add(socket);
142
+ socket.once('close', () => accepted.delete(socket));
143
+ socket.end(`${JSON.stringify(holder)}\n`);
144
+ };
145
+ const deadline = Date.now() + waitMs;
146
+
147
+ for (;;) {
148
+ const server = createServer(onConnection);
149
+ try {
150
+ await listen(server, address);
151
+ holder.acquiredAt = new Date().toISOString();
152
+ await selfCheck(server, address, createServer, label);
153
+ server.unref();
154
+ let releasePromise;
155
+ const release = async () => {
156
+ const closing = closeServer(server);
157
+ for (const socket of accepted) socket.destroy();
158
+ await closing;
159
+ };
160
+ return { held: true, release: () => (releasePromise ??= release()) };
161
+ } catch (error) {
162
+ await closeQuietly(server);
163
+ if (!error || error.code !== 'EADDRINUSE') throw error;
164
+ const remaining = deadline - Date.now();
165
+ if (remaining <= 0) break;
166
+ await wait(Math.min(retryMs, remaining));
167
+ }
168
+ }
169
+
170
+ return { held: false, holder: await readSocketHolder(address, answerTimeoutMs, parseAnswer) };
171
+ }
@@ -12,6 +12,10 @@ import { createHash } from 'node:crypto';
12
12
  import { startedRuns } from './pre-start.mjs';
13
13
  import { normalizePath, readJson } from './paths.mjs';
14
14
 
15
+ // Plan_75 D5: 2 hashes the header-free body. Runs without this field used earlier schemes:
16
+ // the whole task text, then the task without grant lines (Plan_75 P1).
17
+ export const TASK_HASH_SCHEME = 2;
18
+
15
19
  /**
16
20
  * What makes two runs the same task when their names disagree.
17
21
  *
@@ -21,6 +25,8 @@ import { normalizePath, readJson } from './paths.mjs';
21
25
  * built to refuse. Whitespace and case are normalized away because the same order re-sent
22
26
  * through a shell is rewrapped, not rewritten; anything more forgiving would tie together
23
27
  * tasks that merely look alike.
28
+ * Plan_75 D5: the runner supplies the header-free body; every body line counts, including
29
+ * prose beginning with a grant label. Stripping it again would silently change identity.
24
30
  */
25
31
  export const taskFingerprint = (taskText) => {
26
32
  const normalized = String(taskText ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
@@ -35,14 +41,19 @@ export const taskFingerprint = (taskText) => {
35
41
  * honestly, changed nothing and was told it had done no work. Folder names rather than
36
42
  * full paths; the current run joins the list as soon as its status.json exists.
37
43
  */
38
- export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
44
+ export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '', grantRun = '') {
39
45
  const wantedRepo = normalizePath(repo);
40
- const wantedSlug = String(slug ?? '').trim().toLowerCase();
41
- const wantedHash = String(taskHash ?? '').trim().toLowerCase();
42
- const wantedOrderId = String(orderId ?? '').trim();
46
+ if (!wantedRepo) return [];
47
+ const namedStatus = typeof grantRun === 'string' && grantRun &&
48
+ !/[\\/]/.test(grantRun) && grantRun !== '.' && grantRun !== '..'
49
+ ? readJson(path.join(runsRoot, grantRun, 'status.json')) : null;
50
+ const namedRun = namedStatus && normalizePath(namedStatus.repo) === wantedRepo ? namedStatus : null;
51
+ const wantedSlugs = new Set([slug, namedRun?.slug].map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean));
52
+ const wantedHashes = new Set([taskHash, namedRun?.task_hash].map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean));
53
+ const wantedOrderIds = new Set([orderId, namedRun?.order_id].map((value) => String(value ?? '').trim()).filter(Boolean));
43
54
  // No handle on the task, or no repo, means nothing to chain — an empty context must find
44
55
  // nothing rather than fall back to "every run in this folder".
45
- if (!wantedRepo || (!wantedSlug && !wantedHash && !wantedOrderId)) return [];
56
+ if (!wantedSlugs.size && !wantedHashes.size && !wantedOrderIds.size) return [];
46
57
  let entries;
47
58
  try {
48
59
  entries = fs.readdirSync(runsRoot, { withFileTypes: true });
@@ -56,9 +67,9 @@ export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
56
67
  if (!status) continue;
57
68
  // Either handle identifies the task. The hash catches a repeat that renamed itself; the
58
69
  // slug still catches the follow-up whose wording the orchestrator edited on purpose.
59
- const sameSlug = Boolean(wantedSlug) && String(status.slug ?? '').trim().toLowerCase() === wantedSlug;
60
- const sameTask = Boolean(wantedHash) && String(status.task_hash ?? '').trim().toLowerCase() === wantedHash;
61
- const sameOrder = Boolean(wantedOrderId) && String(status.order_id ?? '').trim() === wantedOrderId;
70
+ const sameSlug = wantedSlugs.has(String(status.slug ?? '').trim().toLowerCase());
71
+ const sameTask = wantedHashes.has(String(status.task_hash ?? '').trim().toLowerCase());
72
+ const sameOrder = wantedOrderIds.has(String(status.order_id ?? '').trim());
62
73
  if (!sameSlug && !sameTask && !sameOrder) continue;
63
74
  if (normalizePath(status.repo) !== wantedRepo) continue;
64
75
  runs.push({ name: entry.name, at: String(status.started_at || '') });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Judges startup-context references and unread repository coordinates in scout responses.
3
+ * Plan_60 D1: honest answers about handed context need no command, unlike the 2026-09-16
4
+ * dead sandbox; a marker must never excuse invented addresses or an unsourced answer.
5
+ */
6
+ import { QUESTION_KIND } from '../runner/question-kind.mjs';
7
+
8
+ export function allStartupContext(questions) {
9
+ return Array.isArray(questions) && questions.length > 0
10
+ && questions.every((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT);
11
+ }
12
+
13
+ export function contextOnlyGap({ questions, result, commandsExecuted }) {
14
+ const answers = Array.isArray(result?.answers) ? result.answers : [];
15
+ const byId = new Map();
16
+ for (const answer of answers) {
17
+ const id = String(answer?.question_id || '').trim().toUpperCase();
18
+ if (id && !byId.has(id)) byId.set(id, answer);
19
+ }
20
+ const unsourced = (Array.isArray(questions) ? questions : [])
21
+ .filter((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT)
22
+ .filter((question) => {
23
+ const answer = byId.get(String(question.id || '').trim().toUpperCase());
24
+ return !Array.isArray(answer?.evidence)
25
+ || !answer.evidence.some((item) => /^startup:\s*\S/.test(String(item ?? '')));
26
+ })
27
+ .map((question) => question.id);
28
+ if (unsourced.length) {
29
+ return `responses to startup-context questions without a startup:<source> reference: ${unsourced.join(', ')}`;
30
+ }
31
+ if (commandsExecuted !== 0) return null;
32
+ const references = answers.flatMap((answer) => Array.isArray(answer?.evidence) ? answer.evidence : []);
33
+ references.push(...(Array.isArray(result?.findings) ? result.findings : []).map((finding) => finding?.where));
34
+ for (const reference of references) {
35
+ const text = String(reference ?? '');
36
+ if (text.startsWith('startup:')) continue;
37
+ const coordinate = text.match(/\S*\.[A-Za-z0-9]+:\d+/)?.[0];
38
+ if (coordinate) {
39
+ return `scout cited ${coordinate.slice(0, 60)} without executing a command: an address nobody read`;
40
+ }
41
+ }
42
+ return null;
43
+ }
@@ -14,6 +14,15 @@ import { safeSlice } from './paths.mjs';
14
14
  */
15
15
  const LIMIT_RE = /rate[\s_-]*limit|usage[\s_-]*limit|quota|too many requests|\b429\b/i;
16
16
 
17
+ // Plan_60 D2: add a new measured sample only together with its verbatim fixture.
18
+ const UNAVAILABLE_SAMPLES = [
19
+ {
20
+ cli: 'codex-cli 0.159.0',
21
+ measured: '2026-10-03',
22
+ pattern: /^unexpected status 401 Unauthorized: Missing bearer or basic authentication in header\b/,
23
+ },
24
+ ];
25
+
17
26
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
18
27
 
19
28
  const compact = (value, max = 300) =>
@@ -129,6 +138,8 @@ function transportErrorOf(event) {
129
138
  const status = payload.status ?? nested.status ?? null;
130
139
  const marker = [errorType, message].join(' ');
131
140
  const quota = Number(status) === 429 || LIMIT_RE.test(marker);
141
+ const unavailable = !quota && event.type === 'turn.failed' &&
142
+ UNAVAILABLE_SAMPLES.some((sample) => sample.pattern.test(message));
132
143
  const reason = compact(
133
144
  [errorType, message, status === null || status === undefined ? '' : `status ${status}`]
134
145
  .filter(Boolean)
@@ -137,7 +148,7 @@ function transportErrorOf(event) {
137
148
  (typeof event.error?.message === 'string' ? event.error.message : '') ||
138
149
  event.type,
139
150
  );
140
- return { event, payload, status, error_type: errorType, message, quota, reason };
151
+ return { event, payload, status, error_type: errorType, message, quota, unavailable, reason };
141
152
  }
142
153
 
143
154
  /**
@@ -150,7 +161,8 @@ export function readEvents(runDir) {
150
161
  const accounting = usageOf(events);
151
162
  const started = events.find((event) => event.type === 'thread.started' && event.thread_id);
152
163
  const errors = events.map(transportErrorOf).filter(Boolean);
153
- const transport_error = errors.find((error) => error.quota) || errors[0] || null;
164
+ const transport_error = errors.find((error) => error.quota) ||
165
+ errors.find((error) => error.unavailable) || errors[0] || null;
154
166
  return {
155
167
  events,
156
168
  // The file was written, whatever it holds. Pass 2 judges a run by this: a stream the
@@ -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
 
@@ -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
+ }
@@ -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.