@lyupro/codex-bridge 0.6.9 → 0.6.11

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 (88) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +15 -4
  3. package/cli/dispatcher-model-check.mjs +59 -0
  4. package/cli/doctor-host-side.mjs +2 -1
  5. package/cli/doctor-installation.mjs +111 -111
  6. package/cli/doctor.mjs +13 -5
  7. package/cli/handback-witness-check.mjs +24 -18
  8. package/cli/hosts.mjs +2 -10
  9. package/cli/install-owners.mjs +1 -4
  10. package/cli/install-record.mjs +2 -1
  11. package/cli/inventory-transition.mjs +1 -1
  12. package/cli/lifecycle-lock-inspect.mjs +3 -2
  13. package/cli/lifecycle-lock.mjs +19 -132
  14. package/cli/manifest.mjs +6 -6
  15. package/cli/read.mjs +20 -0
  16. package/cli/settings-merge.mjs +3 -29
  17. package/package.json +2 -2
  18. package/scripts/home-write-inventory.mjs +12 -2
  19. package/src/agents/codex-advisor.md +37 -121
  20. package/src/agents/codex-build.md +27 -135
  21. package/src/agents/codex-review.md +19 -111
  22. package/src/agents/codex-scout.md +21 -130
  23. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  24. package/src/home/hooks/live-runs.mjs +0 -41
  25. package/src/home/hooks/order-gate.mjs +80 -47
  26. package/src/home/hooks/reply-guard.mjs +52 -65
  27. package/src/home/hooks/reply-identity.mjs +64 -0
  28. package/src/home/hooks/reply-parser.mjs +1 -1
  29. package/src/home/hooks/reply-verdicts.mjs +31 -27
  30. package/src/home/hooks/reply-witness.mjs +48 -0
  31. package/src/home/lib/claude-layout.mjs +14 -0
  32. package/src/home/lib/command-path.mjs +32 -0
  33. package/src/home/lib/dispatcher-call.mjs +171 -0
  34. package/src/home/lib/dispatcher-command.mjs +18 -54
  35. package/src/home/lib/dispatcher-gate.mjs +4 -0
  36. package/src/home/lib/dispatcher-model.mjs +112 -0
  37. package/src/home/lib/dispatcher-order.mjs +21 -22
  38. package/src/home/lib/dispatcher-pin.mjs +60 -0
  39. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  40. package/src/home/lib/dispatcher-stop.mjs +11 -2
  41. package/src/home/lib/handback-witness.mjs +137 -48
  42. package/src/home/lib/home-registry.mjs +7 -0
  43. package/src/home/lib/install-owner-roots.mjs +53 -0
  44. package/src/home/lib/kernel-lock.mjs +171 -0
  45. package/src/home/lib/meta/chain.mjs +19 -8
  46. package/src/home/lib/meta/context-only.mjs +43 -0
  47. package/src/home/lib/meta/events.mjs +14 -2
  48. package/src/home/lib/meta/launch-rows.mjs +23 -0
  49. package/src/home/lib/meta/paths.mjs +1 -1
  50. package/src/home/lib/meta/reply.mjs +39 -4
  51. package/src/home/lib/meta/run-state.mjs +3 -0
  52. package/src/home/lib/meta/verdict.mjs +16 -8
  53. package/src/home/lib/model-family.mjs +38 -0
  54. package/src/home/lib/observation-ledger.mjs +145 -0
  55. package/src/home/lib/order-schema.mjs +204 -0
  56. package/src/home/lib/order-values.mjs +46 -0
  57. package/src/home/lib/run-codex.mjs +6 -3
  58. package/src/home/lib/runner/advise-carry.mjs +2 -1
  59. package/src/home/lib/runner/args.mjs +140 -220
  60. package/src/home/lib/runner/attach.mjs +24 -17
  61. package/src/home/lib/runner/build-evidence.mjs +29 -0
  62. package/src/home/lib/runner/codex-availability.mjs +50 -0
  63. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  64. package/src/home/lib/runner/continuation.mjs +174 -140
  65. package/src/home/lib/runner/exit-codes.mjs +13 -0
  66. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  67. package/src/home/lib/runner/flag-scan.mjs +165 -0
  68. package/src/home/lib/runner/git-state.mjs +0 -33
  69. package/src/home/lib/runner/launcher.mjs +237 -277
  70. package/src/home/lib/runner/order-claim.mjs +84 -0
  71. package/src/home/lib/runner/order-options.mjs +30 -0
  72. package/src/home/lib/runner/order-owner.mjs +19 -0
  73. package/src/home/lib/runner/pass-gate.mjs +111 -0
  74. package/src/home/lib/runner/preflight.mjs +26 -28
  75. package/src/home/lib/runner/prompts.mjs +7 -2
  76. package/src/home/lib/runner/question-kind.mjs +41 -0
  77. package/src/home/lib/runner/run-env.mjs +2 -1
  78. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  79. package/src/home/lib/runner/scope-check.mjs +31 -12
  80. package/src/home/lib/runner/task-input.mjs +34 -12
  81. package/src/home/lib/runner/worker-admission.mjs +43 -0
  82. package/src/home/lib/runner/worker.mjs +19 -18
  83. package/src/home/lib/subagent-model-env.mjs +44 -0
  84. package/src/home/lib/task-header.mjs +158 -0
  85. package/src/home/lib/untyped-stop.mjs +30 -0
  86. package/src/home/lib/write-meta.mjs +17 -5
  87. package/src/home/lib/required-inputs.mjs +0 -261
  88. /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
@@ -1,220 +1,140 @@
1
- /**
2
- * Reads the command line of a run and refuses it before anything is spent.
3
- *
4
- * Every check here happens before the run folder exists and before a single token of someone
5
- * else's quota is touched, and every refusal is an exit code rather than a message: a
6
- * dispatcher branches on it.
7
- */
8
- import fs from 'node:fs';
9
- import path from 'node:path';
10
- import { AGENTS } from '../write-meta.mjs';
11
- import { isAbsoluteTaskFilePath, requiredInputsFor } from '../required-inputs.mjs';
12
- import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
13
- import { parseTaskDocument } from './task-file.mjs';
14
-
15
- export class RunnerUsageError extends Error {
16
- // 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
17
- // host or the worktree passes 1: the order was fine, nothing about it needs changing, and
18
- // Plan_58 moved two such refusals here from a code path that already answered 1.
19
- constructor(message, exitCode = 2) {
20
- super(message);
21
- this.exitCode = exitCode;
22
- }
23
- }
24
-
25
- export function die(message, exitCode = 2) {
26
- console.error(`run-codex: ${message}`);
27
- throw new RunnerUsageError(message, exitCode);
28
- }
29
-
30
- export function readTaskDocument(opts) {
31
- const stdinText = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8');
32
- if (opts['task-file'] !== undefined) {
33
- if (stdinText.trim()) {
34
- die('task text was supplied through both stdin and --task-file; choose exactly one channel');
35
- }
36
- const taskFile = opts['task-file'];
37
- // The 2026-08-15 incident resolved a relative order against the repository cwd and ran an
38
- // unrelated task.md. Refuse the supplied value instead of silently selecting another file.
39
- if (!isAbsoluteTaskFilePath(taskFile)) {
40
- die(`--task-file must be an absolute path; got ${JSON.stringify(taskFile)}`);
41
- }
42
- let fileText;
43
- try {
44
- fileText = fs.readFileSync(taskFile, 'utf8');
45
- } catch (err) {
46
- die(`task file from --task-file could not be read: ${err.message}`);
47
- }
48
- if (!fileText.trim()) die(`task file from --task-file is empty: ${taskFile}`);
49
- let document;
50
- try {
51
- document = parseTaskDocument(fileText);
52
- } catch (err) {
53
- die(`${taskFile}: ${err.message}`);
54
- }
55
- if (document.questions.length && opts.questions?.length) {
56
- die('questions were supplied through both --question and the task file; choose exactly one channel');
57
- }
58
- if (document.verify !== undefined && opts.verify !== undefined) {
59
- die('verification command was supplied through both --verify and the task file; choose exactly one channel');
60
- }
61
- return document;
62
- }
63
- if (!stdinText.trim()) die('task text on stdin is empty');
64
- try {
65
- return parseTaskDocument(stdinText);
66
- } catch (err) {
67
- die(err.message);
68
- }
69
- }
70
-
71
- function requiredInput(agentType, label) {
72
- return requiredInputsFor(agentType).find((entry) => entry.label === label);
73
- }
74
-
75
- // Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
76
- // anything else stops the run. The permissive reading this replaces — "not 0/false/no means
77
- // yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
78
- // continue>"`) into a silent opt-in, and a real run started on someone else's quota. A flag
79
- // whose whole point is that a human decided it must never be switched on by a leftover
80
- // template.
81
- const BOOLEAN_FLAGS = new Set(['continue', 'no-wait']);
82
- const REPEATABLE_FLAGS = new Set(['question']);
83
- // Plan_42 keeps free text in the task file because these command-line values otherwise disable
84
- // the host's standing permission before the runner can spend quota.
85
- const SHELL_CHECKED_FLAGS = Object.freeze([
86
- 'order-id',
87
- 'slug',
88
- 'scope',
89
- 'scope-new',
90
- 'repo',
91
- 'agent',
92
- 'phase',
93
- 'effort',
94
- 'changeset',
95
- 'task-file',
96
- ]);
97
- const BOOLEAN_YES = /^(1|true|yes)$/i;
98
- const BOOLEAN_NO = /^(0|false|no)$/i;
99
-
100
- export function parseArgs(argv) {
101
- const opts = {};
102
- for (let i = 0; i < argv.length; i += 1) {
103
- const key = argv[i];
104
- if (!key.startsWith('--')) die(`unexpected argument: ${key}`);
105
- const name = key.slice(2);
106
- // Plan_56 D10 requires --changeset: a stale reviewer flag must not silently select uncommitted work.
107
- if (name === 'mode') die('unknown flag: --mode; use --changeset instead');
108
- const value = argv[i + 1];
109
- if (BOOLEAN_FLAGS.has(name)) {
110
- if (value !== undefined && !value.startsWith('--')) {
111
- if (!BOOLEAN_YES.test(value) && !BOOLEAN_NO.test(value)) {
112
- die(
113
- `--${name} takes no value, or one of 1/true/yes/0/false/no; got ${JSON.stringify(value)}. ` +
114
- 'A placeholder left in from the prompt template is not consent.',
115
- );
116
- }
117
- opts[name] = BOOLEAN_YES.test(value);
118
- i += 1;
119
- } else {
120
- opts[name] = true;
121
- }
122
- continue;
123
- }
124
- // A flag name where a value belongs means the value was left out: `--question --continue`
125
- // otherwise records `--continue` as the question and passes every later check.
126
- if (value === undefined || value.startsWith('--')) die(`missing value for ${key}`);
127
- if (REPEATABLE_FLAGS.has(name)) {
128
- if (!opts.questions) opts.questions = [];
129
- opts.questions.push(value);
130
- i += 1;
131
- continue;
132
- }
133
- opts[name] = value;
134
- i += 1;
135
- }
136
- for (const name of SHELL_CHECKED_FLAGS) {
137
- if (opts[name] === undefined) continue;
138
- const sequence = firstShellUnsafeSequence(opts[name]);
139
- if (sequence !== null) {
140
- die(
141
- `--${name} contains forbidden shell sequence ${JSON.stringify(sequence)}; ` +
142
- 'put free text in the task file and pass only a short command-line value.',
143
- );
144
- }
145
- }
146
- if (!opts.agent) die('--agent is required');
147
- if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
148
- opts.orderId = String(opts['order-id'] ?? '').trim();
149
- opts.noWait = Boolean(opts['no-wait']);
150
- if (opts.noWait && opts.continue) {
151
- die('--no-wait cannot be combined with --continue: checking an existing run must never authorize a new one.');
152
- }
153
- if (!opts.orderId) {
154
- const orderInput = requiredInput(opts.agent, 'order id');
155
- die(
156
- `--order-id is required: ${orderInput.source} supplies the ${orderInput.label}; the runner will not invent one. ` +
157
- 'A repeat of the same order should come back with --continue. If this appeared right after a package update, ' +
158
- 'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update. ' +
159
- `Example: --order-id "${orderInput.example}". Action: get the value from ${orderInput.source}, pass it as --order-id, and retry.`,
160
- );
161
- }
162
- if (opts.agent === 'codex-scout') {
163
- // The requirement itself moved to the launcher, which is the first point that has seen both
164
- // sources: questions now arrive either as flags or as a section of the task file, and this
165
- // check runs before either file or stdin has been read. Only the shape of a supplied flag is
166
- // still judged here.
167
- if (opts.questions?.some((question) => !String(question).trim())) {
168
- die('--question must not be empty for codex-scout; no quota was spent');
169
- }
170
- }
171
- // Left unset when not given, rather than defaulted here: the role's configured profile is
172
- // what fills the gap, and a default applied this early would always win over it.
173
- // Plan_56 step 3: support belongs to the live catalogue on write and Codex at run start.
174
- if (opts.effort !== undefined && (!opts.effort || /\s/.test(opts.effort))) {
175
- die(`--effort must be a non-empty single word with no whitespace; got ${JSON.stringify(opts.effort)}`);
176
- }
177
- opts.repo = path.resolve(opts.repo || process.cwd());
178
- const slugSource = opts.slug ? '--slug' : '--order-id';
179
- opts.slug = (opts.slug || opts.orderId).replace(/[^A-Za-z0-9._-]+/g, '-');
180
- // Plan_29 incident: the generic `build` slug made an honest order inherit a days-old chain;
181
- // reject a value with no alphanumeric anchor instead of creating an empty or dot-only folder.
182
- if (!/[A-Za-z0-9]/.test(opts.slug)) {
183
- die(
184
- `${slugSource} produces an unusable run folder name after sanitization: ` +
185
- `${JSON.stringify(opts.slug)} must contain a letter or digit.`,
186
- );
187
- }
188
- opts.changeset = opts.changeset || 'uncommitted';
189
- // A writing run without a declared scope is how `!Plans/*.md` got edited by a run that was
190
- // told in prose not to touch them: prose does not bind, a file list does. Required rather
191
- // than defaulted to "everything", and checked here — before the folder exists and before a
192
- // single token of someone else's quota is spent.
193
- const declaredScopePatterns = String(opts.scope || '')
194
- .split(',')
195
- .map((p) => p.trim())
196
- .filter(Boolean);
197
- opts.scopeNewPatterns = String(opts['scope-new'] || '')
198
- .split(',')
199
- .map((p) => p.trim())
200
- .filter(Boolean);
201
- // Plan_27 keeps ordinary patterns strict: only paths named explicitly as new may be absent.
202
- opts.scopePatterns = [...declaredScopePatterns, ...opts.scopeNewPatterns];
203
- // Only the writing agent creates files, so only its scope may name one that does not exist yet.
204
- // Accepting the flag elsewhere would hand scout and review a way to waive the check for nothing.
205
- if (opts.agent !== 'codex-build' && opts.scopeNewPatterns.length) {
206
- die(
207
- `--scope-new is only for codex-build: ${opts.agent} does not create files. ` +
208
- 'Action: drop --scope-new and pass every path through --scope.',
209
- );
210
- }
211
- if (opts.agent === 'codex-build' && !declaredScopePatterns.length) {
212
- const scopeInput = requiredInput(opts.agent, 'scope');
213
- die(
214
- `--scope is required for codex-build: ${scopeInput.source} supplies the ${scopeInput.label}. ` +
215
- `${scopeInput.explanation} Example: --scope "${scopeInput.example}". ` +
216
- `Action: get the value from ${scopeInput.source}, pass it as --scope, and retry.`,
217
- );
218
- }
219
- return opts;
220
- }
1
+ /**
2
+ * Reads the command line of a run and refuses it before anything is spent.
3
+ *
4
+ * Every check here happens before the run folder exists and before a single token of someone
5
+ * else's quota is touched, and every refusal is an exit code rather than a message: a
6
+ * dispatcher branches on it.
7
+ */
8
+ import { EXIT } from './exit-codes.mjs';
9
+ import fs from 'node:fs';
10
+ import { AGENTS } from '../write-meta.mjs';
11
+ import { isAbsoluteTaskFilePath } from '../order-values.mjs';
12
+ import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
13
+ import { parseTaskDocument } from './task-file.mjs';
14
+ import { parseTaskHeader, taskHeaderRefusal } from '../task-header.mjs';
15
+ import { renderOrderHeaderHelp } from '../order-schema.mjs';
16
+
17
+ export class RunnerUsageError extends Error {
18
+ // 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
19
+ // host or the worktree passes 1: the order was fine, nothing about it needs changing, and
20
+ // Plan_58 moved two such refusals here from a code path that already answered 1.
21
+ constructor(message, exitCode = EXIT.USAGE) {
22
+ super(message);
23
+ this.exitCode = exitCode;
24
+ }
25
+ }
26
+
27
+ export function die(message, exitCode = EXIT.USAGE) {
28
+ console.error(`run-codex: ${message}`);
29
+ throw new RunnerUsageError(message, exitCode);
30
+ }
31
+
32
+ export function readTaskDocument(opts) {
33
+ const refuse = (message) => die([message, renderOrderHeaderHelp(opts.agent)].filter(Boolean).join('\n'));
34
+ const stdinText = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8');
35
+ if (opts['task-file'] !== undefined) {
36
+ if (stdinText.trim()) {
37
+ refuse('task text was supplied through both stdin and --task-file; choose exactly one channel');
38
+ }
39
+ const taskFile = opts['task-file'];
40
+ // The 2026-08-15 incident resolved a relative order against the repository cwd and ran an
41
+ // unrelated task.md. Refuse the supplied value instead of silently selecting another file.
42
+ if (!isAbsoluteTaskFilePath(taskFile)) {
43
+ refuse(`--task-file must be an absolute path; got ${JSON.stringify(taskFile)}`);
44
+ }
45
+ let fileText;
46
+ try {
47
+ fileText = fs.readFileSync(taskFile, 'utf8');
48
+ } catch (err) {
49
+ refuse(`task file from --task-file could not be read: ${err.message}`);
50
+ }
51
+ if (!fileText.trim()) refuse(`task file from --task-file is empty: ${taskFile}`);
52
+ // Plan_75 D5, 2026-10-03 20:42: validate raw metadata before sections can hide a grant.
53
+ const parsed = parseTaskHeader(fileText);
54
+ const refusal = taskHeaderRefusal(parsed);
55
+ if (refusal) refuse(`${taskFile}: ${refusal}`);
56
+ if (!parsed.body.trim()) refuse(`task file from --task-file is empty: ${taskFile}`);
57
+ let document;
58
+ try {
59
+ document = parseTaskDocument(parsed.body);
60
+ } catch (err) {
61
+ refuse(`${taskFile}: ${err.message}`);
62
+ }
63
+ return { ...document, header: parsed };
64
+ }
65
+ if (!stdinText.trim()) refuse('task text on stdin is empty');
66
+ const parsed = parseTaskHeader(stdinText);
67
+ const refusal = taskHeaderRefusal(parsed);
68
+ if (refusal) refuse(refusal);
69
+ if (!parsed.body.trim()) refuse('task text on stdin is empty');
70
+ try {
71
+ return { ...parseTaskDocument(parsed.body), header: parsed };
72
+ } catch (err) {
73
+ refuse(err.message);
74
+ }
75
+ }
76
+
77
+ // Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
78
+ // anything else stops the run. The permissive reading this replaces — "not 0/false/no means
79
+ // yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
80
+ // continue>"`) into a silent opt-in, and a real run started on someone else's quota. A flag
81
+ // whose whole point is that a human decided it must never be switched on by a leftover
82
+ // template.
83
+ const BOOLEAN_FLAGS = new Set(['no-wait']);
84
+ // Plan_42 keeps free text in the task file because these command-line values otherwise disable
85
+ // the host's standing permission before the runner can spend quota.
86
+ const SHELL_CHECKED_FLAGS = Object.freeze([
87
+ 'agent',
88
+ 'task-file',
89
+ ]);
90
+ const BOOLEAN_YES = /^(1|true|yes)$/i;
91
+ const BOOLEAN_NO = /^(0|false|no)$/i;
92
+
93
+ export function parseArgs(argv) {
94
+ const opts = {};
95
+ for (let i = 0; i < argv.length; i += 1) {
96
+ const key = argv[i];
97
+ if (!key.startsWith('--')) die(`unexpected argument: ${key}`);
98
+ const name = key.slice(2);
99
+ if (!['agent', 'task-file', 'no-wait'].includes(name)) {
100
+ const agent = opts.agent ?? argv[argv.indexOf('--agent') + 1];
101
+ const help = Object.hasOwn(AGENTS, agent) ? renderOrderHeaderHelp(agent) : '';
102
+ die(`unknown flag --${name}: codex-bridge run takes only --agent, --task-file and --no-wait; ` +
103
+ 'the order belongs in the task-file header' + (help ? `\n${help}` : ''));
104
+ }
105
+ const value = argv[i + 1];
106
+ if (BOOLEAN_FLAGS.has(name)) {
107
+ if (value !== undefined && !value.startsWith('--')) {
108
+ if (!BOOLEAN_YES.test(value) && !BOOLEAN_NO.test(value)) {
109
+ die(
110
+ `--${name} takes no value, or one of 1/true/yes/0/false/no; got ${JSON.stringify(value)}. ` +
111
+ 'A placeholder left in from the prompt template is not consent.',
112
+ );
113
+ }
114
+ opts[name] = BOOLEAN_YES.test(value);
115
+ i += 1;
116
+ } else {
117
+ opts[name] = true;
118
+ }
119
+ continue;
120
+ }
121
+ // A flag name where a value belongs means the value was left out.
122
+ if (value === undefined || value.startsWith('--')) die(`missing value for ${key}`);
123
+ opts[name] = value;
124
+ i += 1;
125
+ }
126
+ for (const name of SHELL_CHECKED_FLAGS) {
127
+ if (opts[name] === undefined) continue;
128
+ const sequence = firstShellUnsafeSequence(opts[name]);
129
+ if (sequence !== null) {
130
+ die(
131
+ `--${name} contains forbidden shell sequence ${JSON.stringify(sequence)}; ` +
132
+ 'put free text in the task file and pass only a short command-line value.',
133
+ );
134
+ }
135
+ }
136
+ if (!opts.agent) die('--agent is required');
137
+ if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
138
+ opts.noWait = Boolean(opts['no-wait']);
139
+ return opts;
140
+ }
@@ -1,12 +1,13 @@
1
1
  /** Finds a live run for an order and waits for its verdict when a repeated launcher call attaches. */
2
+ import { EXIT } from './exit-codes.mjs';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { readJsonFileSync } from '../json-file.mjs';
5
6
  import { workerMayBeAlive } from '../meta/run-liveness.mjs';
6
7
  import { processAlive } from '../process-identity.mjs';
7
8
  import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
8
- import { sameRun } from './continuation.mjs';
9
- import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
9
+ import { readyGrantLines, sameRun } from './continuation.mjs';
10
+ import { conflictingOrderOwner, orderOwnerConflictText, runsForOrder } from './order-owner.mjs';
10
11
 
11
12
  // The attach call is the only process that waits for a worker it did not spawn. Keeping that
12
13
  // wait here leaves the launcher free to return before a caller's time ceiling kills it.
@@ -88,39 +89,39 @@ export async function waitForReply(runDir, workerPid, status) {
88
89
  * What counts as "not finished yet" is the absence of reply.txt, not the absence of meta.json.
89
90
  * The artifact order is meta.json first, reply.txt last, so between the two there is a moment
90
91
  * where the verdict exists and the run is not closed — judged by meta.json, a repeat arriving in
91
- * that window would be refused and sent to the --continue gate instead of being answered. The
92
+ * that window would be refused and sent to the continuation gate instead of being answered. The
92
93
  * promise being kept here is that repeating the command is always safe, and a promise with a
93
94
  * window in it is not one. markAbandoned() closes dead runs with FAIL before the gate, so this
94
95
  * path only covers a live run that has not recorded a verdict yet.
95
96
  *
96
97
  * A repeat that arrives after the verdict is answered from disk rather than refused, for the same
97
- * reason. `--continue` may attach to a run created from its grant: the 2026-09-23 dispatcher
98
+ * reason. A `continue:` grant may attach to a run created from it: the 2026-09-23 dispatcher
98
99
  * incident showed that repeating the identical continuation command must return to that run.
99
100
  */
100
- export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, grantRun, isContinue, noWait } = {}) {
101
+ export async function attach({
102
+ runsRoot, repo, slug, taskHash, orderId, chain, grantRun, retryRun, isContinue, noWait, beforeWait,
103
+ } = {}) {
101
104
  const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
102
105
  const runRecords = runs
103
106
  .map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
104
107
  .filter(({ status }) => status);
105
108
  const sameOrder = runsForOrder(runRecords, orderId);
106
109
  const attachRuns = isContinue
107
- ? sameOrder.filter(({ status }) => grantRun && status.continued_from && sameRun(runsRoot, status.continued_from, grantRun))
110
+ ? sameOrder.filter(({ status }) => retryRun
111
+ ? status.retry_of && sameRun(runsRoot, status.retry_of, retryRun)
112
+ : grantRun && status.continued_from && sameRun(runsRoot, status.continued_from, grantRun))
108
113
  : sameOrder;
109
114
  if (isContinue && !attachRuns.length) return null;
110
115
 
111
116
  const owner = isContinue ? null : conflictingOrderOwner(runRecords, orderId, taskHash);
112
117
  if (owner) {
113
118
  const ownerDir = path.join(runsRoot, owner.run);
114
- console.log(
115
- `Order id collision: ${JSON.stringify(String(orderId ?? ''))} already belongs to run folder ${ownerDir} ` +
116
- `(slug ${owner.status.slug}, started_at ${owner.status.started_at}) with a different task. ` +
117
- 'Pass a new --order-id, or pass --continue if this really is another pass of the same order.',
118
- );
119
- return 2;
119
+ console.log(orderOwnerConflictText(owner, ownerDir, orderId));
120
+ return EXIT.USAGE;
120
121
  }
121
122
 
122
123
  // The run an order is currently about is its newest one, and the chain arrives oldest first.
123
- // Reading it from the end is the whole point: `--continue` adds a second run under the same
124
+ // Reading it from the end is the whole point: a continuation adds a second run under the same
124
125
  // order, and while it was in flight a repeat used to be answered by the first run's reply.txt —
125
126
  // a stale verdict presented as this pass's answer. Found by the Plan_11-2 checklist, 2026-08-04.
126
127
  let candidate = null;
@@ -130,7 +131,12 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
130
131
  if (fs.existsSync(path.join(dir, 'reply.txt'))) {
131
132
  announceSavedReply(dir, orderId, entry.status.started_at);
132
133
  console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
133
- return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
134
+ const meta = readJsonFile(path.join(dir, 'meta.json'));
135
+ // Plan_75 D1, 2026-10-03 TradeForge: a cached failure must name its current repair.
136
+ if (meta?.status !== 'OK' && entry.run === runs.at(-1)) {
137
+ console.log(readyGrantLines(runsRoot, runs));
138
+ }
139
+ return exitCodeFor(meta?.status);
134
140
  }
135
141
  if (workerMayBeAlive({ runDir: dir, status: entry.status })) {
136
142
  candidate = entry;
@@ -140,7 +146,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
140
146
  if (!candidate) {
141
147
  if (!noWait) return null;
142
148
  console.log(`No run exists for order id ${JSON.stringify(String(orderId ?? ''))}; --no-wait never starts a new run.`);
143
- return 4;
149
+ return EXIT.PENDING;
144
150
  }
145
151
 
146
152
  const runDir = path.join(runsRoot, candidate.run);
@@ -148,8 +154,9 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
148
154
  // A distinct call outcome lets the dispatcher inspect disk without mistaking pending for failure.
149
155
  if (noWait) {
150
156
  announcePending(runDir, orderId, candidate.status.started_at);
151
- return 4;
157
+ return EXIT.PENDING;
152
158
  }
159
+ await beforeWait?.();
153
160
  announceLiveAttach(runDir, orderId, candidate.status.started_at);
154
161
  const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
155
162
  if (replyText !== null) {
@@ -174,5 +181,5 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
174
181
  'Any Codex changes remain in the tree — check them with git status',
175
182
  ]);
176
183
  console.log(reply);
177
- return 1;
184
+ return EXIT.FAIL;
178
185
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Writes the evidence a build run leaves of the tree before and after it.
3
+ * The before half was written by the launcher and the after half by the worker, so one artifact pair was
4
+ * named in two files; Plan_60 D3a adds a content baseline and its comparison to both halves.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { git, headSha, branchName, worktreeSnapshot } from './git-state.mjs';
9
+ import { captureFlagBaseline } from './flag-baseline.mjs';
10
+ import { scanRunFlags } from './flag-scan.mjs';
11
+
12
+ export function writeBuildBefore({ runDir, repoRoot, isGitRepo }) {
13
+ fs.writeFileSync(path.join(runDir, 'head-before.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
14
+ fs.writeFileSync(path.join(runDir, 'branch-before.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
15
+ fs.writeFileSync(path.join(runDir, 'git-before.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
16
+ fs.writeFileSync(path.join(runDir, 'state-before.txt'), worktreeSnapshot(repoRoot));
17
+ captureFlagBaseline({ runDir, repoRoot, isGitRepo });
18
+ }
19
+
20
+ export function writeBuildAfter({ runDir, repoRoot, isGitRepo }) {
21
+ fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
22
+ fs.writeFileSync(path.join(runDir, 'branch-after.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
23
+ fs.writeFileSync(path.join(runDir, 'git-after.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
24
+ fs.writeFileSync(path.join(runDir, 'state-after.txt'), worktreeSnapshot(repoRoot));
25
+ fs.writeFileSync(path.join(runDir, 'diff.stat'), git(repoRoot, ['-c', 'core.quotepath=false', 'diff', '--stat']).stdout || '');
26
+ const scan = scanRunFlags({ runDir, repoRoot });
27
+ fs.writeFileSync(path.join(runDir, 'flags.txt'), scan.text);
28
+ fs.writeFileSync(path.join(runDir, 'flags-coverage.txt'), scan.coverage.gaps.map((gap) => `${gap}\n`).join(''));
29
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Decides from conclusive evidence only whether Codex is missing, signed out, available, or cannot be judged.
3
+ * Plan_60 D2: an inconclusive probe must never become "do not retry".
4
+ */
5
+ import { resolveCommandOnPath } from '../command-path.mjs';
6
+ import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
7
+
8
+ // A signed-in answer takes ~200 ms; ten seconds bounds a hung CLI without delaying a healthy start.
9
+ export const PROBE_TIMEOUT_MS = 10_000;
10
+
11
+ function lines(text) {
12
+ return String(text ?? '').split(/[\r\n]+/).map((line) => line.trim()).filter(Boolean);
13
+ }
14
+
15
+ function failureDetail(result, command) {
16
+ return lines(result.stderr).at(-1)
17
+ || lines(result.error?.message).at(-1)
18
+ || `${command} exited ${result.status}`;
19
+ }
20
+
21
+ export async function probeCodexAvailability({
22
+ env = process.env, platform = process.platform,
23
+ run = spawnCaptured, resolve = resolveCommandOnPath, timeoutMs = PROBE_TIMEOUT_MS,
24
+ } = {}) {
25
+ try {
26
+ if (resolve('codex', env) === null) {
27
+ return { state: 'missing', detail: 'codex is not on PATH' };
28
+ }
29
+ const capture = (args) => {
30
+ const spec = codexSpawnSpec(args, platform);
31
+ return run(spec.command, spec.args, { ...spec.options, env, timeout: timeoutMs, encoding: 'utf8' });
32
+ };
33
+ const version = await capture(['--version']);
34
+ if (version.error || version.status !== 0) {
35
+ return { state: 'inconclusive', detail: failureDetail(version, 'codex --version') };
36
+ }
37
+ const login = await capture(['login', 'status']);
38
+ // Plan_60 D2: capture errors invalidate even a familiar line; only a completed probe proves sign-out.
39
+ if (login.error) {
40
+ return { state: 'inconclusive', detail: failureDetail(login, 'codex login status') };
41
+ }
42
+ if (login.status === 0) return { state: 'available' };
43
+ if (login.status === 1 && [...lines(login.stderr), ...lines(login.stdout)].includes('Not logged in')) {
44
+ return { state: 'logged-out', detail: 'codex login status: Not logged in' };
45
+ }
46
+ return { state: 'inconclusive', detail: failureDetail(login, 'codex login status') };
47
+ } catch (error) {
48
+ return { state: 'inconclusive', detail: lines(error?.message).at(-1) || 'codex availability probe failed' };
49
+ }
50
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Invoking the `codex` CLI: starting it, stopping its whole tree, telling whether it is there at
3
- * all, and keeping its JSONL stdout in events.jsonl while stderr stays separate.
2
+ * Invoking the `codex` CLI: starting it, stopping its whole tree, and keeping its JSONL stdout
3
+ * in events.jsonl while stderr stays separate. Availability belongs to codex-availability.mjs.
4
4
  *
5
5
  * Which flags a run gets is the neighbouring codex-args.mjs: this file starts the process and
6
6
  * stops it, and knows nothing about what the arguments mean. The two were one module until it
@@ -336,20 +336,3 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
336
336
  child.on('close', (code) => finish(code, true));
337
337
  });
338
338
  }
339
-
340
- /**
341
- * Codex missing or not authorised is a deterministic FAIL, never a reason to improvise.
342
- * Plan_58, 2026-09-19: return a reason for refusal before registration, like the dead-sandbox
343
- * probe. One no-quota question must not have two answers about whether it leaves a folder.
344
- */
345
- export function codexUnavailableReason() {
346
- const probe = spawnSync(
347
- process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
348
- process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
349
- { encoding: 'utf8', windowsHide: true },
350
- );
351
- if (probe.error || probe.status !== 0) {
352
- return (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
353
- }
354
- return null;
355
- }