@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
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Admits a worker and records its identity under its order claim before any paid work starts.
3
+ * Plan_60 D4/D4c, A4 r4: a launcher killed after spawn frees the claim before recording the
4
+ * detached worker, so a second launcher could abandon its run and start another paid run.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { readJsonFileSync } from '../json-file.mjs';
9
+ import { writeStatus } from '../meta/run-state.mjs';
10
+ import { acquireOrderClaim } from './order-claim.mjs';
11
+
12
+ export async function admitWorker({ runDir, orderId, timing, platform, createServer }) {
13
+ const claim = await acquireOrderClaim({
14
+ projectRunsRoot: path.dirname(runDir),
15
+ orderId,
16
+ role: 'worker',
17
+ ...(timing !== undefined ? { timing } : {}),
18
+ ...(platform !== undefined ? { platform } : {}),
19
+ ...(createServer !== undefined ? { createServer } : {}),
20
+ });
21
+ try {
22
+ if (claim.busy) return { admitted: false, reason: 'claim-timeout', holder: claim.holder };
23
+ let status = null;
24
+ try {
25
+ status = readJsonFileSync(path.join(runDir, 'status.json'));
26
+ } catch {
27
+ // Plan_60 A4 r4: an unreadable run cannot authorize paid work after a launcher dies.
28
+ }
29
+ if (status?.state !== 'running' || fs.existsSync(path.join(runDir, 'meta.json'))) {
30
+ return { admitted: false, reason: 'closed', state: status?.state ?? null };
31
+ }
32
+ // The launcher cannot know this detached process's clock origin; takeover must record the
33
+ // worker's own identity before it starts producing the run's artifacts.
34
+ writeStatus(runDir, {
35
+ pid: process.pid,
36
+ runner_pid: process.pid,
37
+ process_started_at: performance.timeOrigin,
38
+ });
39
+ return { admitted: true };
40
+ } finally {
41
+ await claim.release?.();
42
+ }
43
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * It takes its whole order from worker.json, written by launcher.mjs into the run folder —
5
5
  * after the split that file is the only connection between the two halves. Read here:
6
- * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
6
+ * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`, `order_id`. Written there: those six plus
7
7
  * `phase`, `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
8
8
  * months later does. The launcher must write the selected phase's budget into worker.json before
9
9
  * spawning this half, so the worker never re-reads config.json and one run cannot acquire two
@@ -13,11 +13,14 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { readJsonFileSync } from '../json-file.mjs';
16
- import { collect, exitCodeFor, AGENTS } from '../write-meta.mjs';
16
+ import { collect, exitCodeFor, AGENTS, writeFailure } from '../write-meta.mjs';
17
17
  import { writeStatus } from '../meta/run-state.mjs';
18
18
  import { setRun, emitReply } from './run-context.mjs';
19
- import { git, headSha, branchName, worktreeSnapshot, findFakeDone } from './git-state.mjs';
19
+ import { writeBuildAfter } from './build-evidence.mjs';
20
20
  import { runCodex } from './codex-cmd.mjs';
21
+ import { admitWorker } from './worker-admission.mjs';
22
+ import { orderClaimHolderText } from './order-claim.mjs';
23
+ import { EXIT } from './exit-codes.mjs';
21
24
 
22
25
  /**
23
26
  * Runs the order in runDir and answers into reply.txt, never to a console — nothing here
@@ -27,13 +30,18 @@ export async function worker(runDir) {
27
30
  const cfg = readJsonFileSync(path.join(runDir, 'worker.json'));
28
31
  const repoRoot = cfg.repo;
29
32
  setRun(runDir, cfg.agent);
30
- // The launcher cannot know this detached process's clock origin; takeover must record the
31
- // worker's own identity before it starts producing the run's artifacts.
32
- writeStatus(runDir, {
33
- pid: process.pid,
34
- runner_pid: process.pid,
35
- process_started_at: performance.timeOrigin,
36
- });
33
+ const admission = await admitWorker({ runDir, orderId: cfg.order_id });
34
+ if (!admission.admitted) {
35
+ // Plan_60 A4 r4: a replacement launcher may have closed this run while the worker was starting.
36
+ if (admission.reason === 'claim-timeout') {
37
+ const { reply } = writeFailure(
38
+ runDir, cfg.agent, 'run worker was not admitted: the order claim stayed busy',
39
+ [`Claim held by ${orderClaimHolderText(admission.holder)}`, 'Codex was not started; quota was not spent'],
40
+ );
41
+ emitReply(reply);
42
+ }
43
+ process.exit(EXIT.FAIL);
44
+ }
37
45
 
38
46
  const run = await runCodex(
39
47
  cfg.args,
@@ -52,14 +60,7 @@ export async function worker(runDir) {
52
60
  stdio_drained: run.stdioDrained,
53
61
  });
54
62
 
55
- if (cfg.agent === 'codex-build') {
56
- fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${cfg.is_git_repo ? headSha(repoRoot) : ''}\n`);
57
- fs.writeFileSync(path.join(runDir, 'branch-after.txt'), `${cfg.is_git_repo ? branchName(repoRoot) : ''}\n`);
58
- fs.writeFileSync(path.join(runDir, 'git-after.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
59
- fs.writeFileSync(path.join(runDir, 'state-after.txt'), worktreeSnapshot(repoRoot));
60
- fs.writeFileSync(path.join(runDir, 'diff.stat'), git(repoRoot, ['-c', 'core.quotepath=false', 'diff', '--stat']).stdout || '');
61
- fs.writeFileSync(path.join(runDir, 'flags.txt'), findFakeDone(repoRoot));
62
- }
63
+ if (cfg.agent === 'codex-build') writeBuildAfter({ runDir, repoRoot, isGitRepo: cfg.is_git_repo });
63
64
 
64
65
  // Human-readable report is carried inside the schema: a read-only scout cannot write
65
66
  // files itself, so the runner unpacks it.
@@ -0,0 +1,44 @@
1
+ /** decides whether the host's subagent-model environment overrides a dispatcher's frontmatter pin
2
+ * Plan_67 D6 (b) and the host documentation facts recorded in its Council (2026-09-25):
3
+ * env moved below frontmatter in 2.1.251; FORCE=1 overrides it starting with 2.1.257.
4
+ * Unknown host versions are not evidence for a pre-start refusal.
5
+ */
6
+ import { modelFamily } from './model-family.mjs';
7
+
8
+ // Host contract facts, not package versions or volatile model identifiers (Plan_67 D6 (b)).
9
+ export const FRONTMATTER_PRECEDENCE_VERSION = '2.1.251';
10
+ export const SUBAGENT_MODEL_FORCE_VERSION = '2.1.257';
11
+
12
+ function versionParts(value) {
13
+ if (typeof value !== 'string' || !/^\d+\.\d+\.\d+(?:\.\d+)*$/.test(value)) return null;
14
+ return value.split('.').map(BigInt);
15
+ }
16
+
17
+ function compareVersions(left, right) {
18
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
19
+ const difference = (left[index] ?? 0n) - (right[index] ?? 0n);
20
+ if (difference !== 0n) return difference < 0n ? -1 : 1;
21
+ }
22
+ return 0;
23
+ }
24
+
25
+ export function envOverridesPin({ env, hostVersion }) {
26
+ const model = env.CLAUDE_CODE_SUBAGENT_MODEL;
27
+ if (typeof model !== 'string' || model.length === 0) return false;
28
+ const version = versionParts(hostVersion);
29
+ if (version === null) return false;
30
+ return compareVersions(version, versionParts(FRONTMATTER_PRECEDENCE_VERSION)) < 0
31
+ || (env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE === '1'
32
+ && compareVersions(version, versionParts(SUBAGENT_MODEL_FORCE_VERSION)) >= 0);
33
+ }
34
+
35
+ export function envModelProblem({ env, hostVersion, pinFamily }) {
36
+ if (!envOverridesPin({ env, hostVersion })) return null;
37
+ const model = env.CLAUDE_CODE_SUBAGENT_MODEL;
38
+ const family = modelFamily(model);
39
+ if (family === null || pinFamily === null || family === pinFamily) return null;
40
+ return `Order gate denied the Agent call because CLAUDE_CODE_SUBAGENT_MODEL=${JSON.stringify(model)} `
41
+ + `overrides the installed dispatcher pin ${pinFamily} on host ${hostVersion}. `
42
+ + `Unset CLAUDE_CODE_SUBAGENT_MODEL or set it to the pinned family ${pinFamily}. `
43
+ + 'The run folder was not created; quota was not spent.';
44
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Reads the orchestrator's metadata header at the top of a task file — the one place a grant or an
3
+ * advice line may stand.
4
+ *
5
+ * Plan_75 D5: on 2026-10-03 20:42 wrapped prose in a task file began a line with `continue:`, and
6
+ * the runner, which looked for grants on any line, refused the order over its own prose. Grants and
7
+ * advice had two parsers and the task hash a third rule; this module is the single reader, and a
8
+ * line below the header that still looks like real metadata is refused rather than silently obeyed
9
+ * or silently ignored.
10
+ */
11
+ import path from 'node:path';
12
+ import { splitGrantValue } from './order-values.mjs';
13
+ import { ALL_ORDER_LABELS } from './order-schema.mjs';
14
+
15
+ const BARE_RUN = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
16
+ const MISPLACED_GRANT = /^[A-Za-z0-9._-]+(?:$| — | - |:)/;
17
+
18
+ function validateGrant(value) {
19
+ const grant = splitGrantValue(value);
20
+ return grant && BARE_RUN.test(grant.run)
21
+ ? null
22
+ : 'grant must name a bare run folder (not . or ..) and a non-placeholder reason';
23
+ }
24
+
25
+ function descriptor(label, validate, misplaced) {
26
+ const spellings = [`${label}[ \\t]*[:=]`];
27
+ for (const wrapper of ['\\*', '_', '`', '\\*\\*', '__']) {
28
+ spellings.push(`${wrapper}${label}${wrapper}[ \\t]*[:=]`);
29
+ spellings.push(`${wrapper}${label}[ \\t]*[:=]${wrapper}`);
30
+ }
31
+ return Object.freeze({
32
+ label,
33
+ validate,
34
+ misplaced,
35
+ shape: new RegExp(
36
+ // Plan_75 D5: try a wrapper before treating its first star as a list marker.
37
+ `^[ \\t]*(?:[-*][ \\t]*)??(?:${spellings.join('|')})[ \\t]*(.*?)[ \\t]*$`,
38
+ 'i',
39
+ ),
40
+ repairHint: `move it into the header at the top of the file, spelled ${label}: <value>; `
41
+ + 'if it is an example, quote it with > or reword it inside a sentence',
42
+ });
43
+ }
44
+
45
+ // Plan_75 D5, 2026-10-03 20:42: one registry prevents wrapped task prose becoming a grant.
46
+ const LABELS = Object.freeze([
47
+ // Plan_75 D5: empty advice closes the strict header but must still explain the missing value.
48
+ descriptor('advice', (value) => value.trim() && !/[\r\n]/.test(value)
49
+ ? null : 'advice value must be non-empty and single line',
50
+ (value) => !value || /^\S+$/.test(value)
51
+ || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)),
52
+ descriptor('continue', validateGrant, (value) => MISPLACED_GRANT.test(value)),
53
+ descriptor('retry', validateGrant, (value) => MISPLACED_GRANT.test(value)),
54
+ ]);
55
+
56
+ const HEADER_LABELS = Object.freeze([...ALL_ORDER_LABELS, 'advice']);
57
+ const GUARDED_LABELS = Object.freeze(LABELS.map(({ label }) => label));
58
+ const ORDER_ONLY_LABELS = Object.freeze(HEADER_LABELS.filter((label) => !GUARDED_LABELS.includes(label)));
59
+ const HEADER_LINE = new RegExp(`^(${HEADER_LABELS.join('|')}):[ \\t]*(\\S.*?)[ \\t]*$`);
60
+
61
+ function normalizedHeaderLabel(line) {
62
+ const colon = line.indexOf(':');
63
+ if (colon === -1) return null;
64
+ return line.slice(0, colon).trim().toLowerCase()
65
+ .replace(/^[-*][ \t]+/, '')
66
+ .replace(/^[*`_]+|[*`_]+$/g, '')
67
+ .replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim();
68
+ }
69
+
70
+ export function parseTaskHeader(text) {
71
+ const lines = text.replace(/^\uFEFF/, '').split(/\r\n|\n|\r/);
72
+ const fields = {};
73
+ const entries = [];
74
+ const problems = [];
75
+ let headerEnd = 0;
76
+
77
+ for (const line of lines) {
78
+ const match = line.match(HEADER_LINE);
79
+ if (!match) {
80
+ // Plan_63 D1/D3: a misspelled or unknown order line must not silently turn into task prose. The
81
+ // header still closes here (Plan_75 D5), and grant/advice spellings stay with the misplaced guard.
82
+ const label = normalizedHeaderLabel(line);
83
+ const rawLabel = line.slice(0, line.indexOf(':')).trim();
84
+ let reason = null;
85
+ if (ORDER_ONLY_LABELS.includes(label)) {
86
+ reason = line.trimEnd() === `${label}:` ? `header label "${label}" has an empty value`
87
+ : `write the header label exactly as "${label}:"`;
88
+ } else if (!GUARDED_LABELS.includes(label) && entries.length && /^[a-z][a-z _-]*:[ \t]*\S/.test(line)) {
89
+ reason = `unknown header label "${rawLabel}"; known labels: ${HEADER_LABELS.join(', ')}`;
90
+ }
91
+ if (reason) problems.push({ lineNo: headerEnd + 1, line, reason });
92
+ break;
93
+ }
94
+ const [, label, value] = match;
95
+ const entry = { label, value, lineNo: headerEnd + 1, line };
96
+ if (Object.hasOwn(fields, label)) {
97
+ problems.push({ lineNo: entry.lineNo, line, reason: `duplicate ${label} label` });
98
+ } else {
99
+ fields[label] = value;
100
+ }
101
+ entries.push(entry);
102
+ headerEnd++;
103
+ }
104
+
105
+ // Plan_75 D5: conflicting authorization is a label-presence error even if a value is invalid.
106
+ if (Object.hasOwn(fields, 'continue') && Object.hasOwn(fields, 'retry')) {
107
+ const conflict = entries.find(({ label }) => label === 'retry');
108
+ problems.push({
109
+ lineNo: conflict.lineNo,
110
+ line: conflict.line,
111
+ reason: 'continue and retry cannot both be present; keep exactly one grant label',
112
+ });
113
+ }
114
+
115
+ let grant = null;
116
+ let advice = null;
117
+ for (const entry of entries) {
118
+ const definition = LABELS.find(({ label }) => label === entry.label);
119
+ if (!definition) continue;
120
+ const reason = definition.validate(entry.value);
121
+ if (reason) {
122
+ problems.push({ lineNo: entry.lineNo, line: entry.line, reason: `${entry.label}: ${reason}` });
123
+ } else if (entry.value !== fields[entry.label]) {
124
+ continue;
125
+ } else if (entry.label === 'advice') {
126
+ advice ??= entry.value;
127
+ } else {
128
+ grant ??= { kind: entry.label, ...splitGrantValue(entry.value) };
129
+ }
130
+ }
131
+
132
+ // Plan_75 D5, 2026-10-03 20:42: fences are examples, not an escape from the misplaced guard.
133
+ for (let index = headerEnd; index < lines.length; index++) {
134
+ for (const definition of LABELS) {
135
+ const match = lines[index].match(definition.shape);
136
+ if (!match || !definition.misplaced(match[1])) continue;
137
+ const reason = match[1] ? `misplaced ${definition.label} metadata`
138
+ : `${definition.label} value must be non-empty and single line`;
139
+ problems.push({
140
+ lineNo: index + 1,
141
+ line: lines[index],
142
+ reason: `${reason}; ${definition.repairHint}`,
143
+ });
144
+ }
145
+ }
146
+
147
+ problems.sort((a, b) => a.lineNo - b.lineNo);
148
+ const parsed = { fields, grant, advice, body: lines.slice(headerEnd).join('\n'), problems };
149
+ // Plan_63 C1: retain source locations for schema diagnostics without changing serialized results.
150
+ Object.defineProperty(parsed, 'entries', { value: entries });
151
+ return parsed;
152
+ }
153
+
154
+ export function taskHeaderRefusal(parsed) {
155
+ if (!parsed.problems.length) return null;
156
+ return parsed.problems.map(({ lineNo, line, reason }) => `line ${lineNo}: ${reason}: ${line}`)
157
+ .concat('The run folder was not created; quota was not spent.').join('\n');
158
+ }
@@ -0,0 +1,30 @@
1
+ /** decides whether a subagent stop without agent_type was a Codex dispatcher, from evidence bound to that stop
2
+ * Plan_67 D9: all 13 missing-type alarms across nine host versions were noise; require evidence
3
+ * for this stop instead of assuming every untyped subagent is a dispatcher.
4
+ */
5
+ import { CLI_NAMES } from './cli-names.mjs';
6
+ import { ORDER_AGENTS } from './order-schema.mjs';
7
+
8
+ export function untypedStopEvidence({ state, toolUses }) {
9
+ if (state && typeof state === 'object' && !Array.isArray(state) && !state.corrupt
10
+ && typeof state.agentType === 'string' && ORDER_AGENTS.includes(state.agentType)
11
+ && Array.isArray(state.seenToolUseIds) && state.seenToolUseIds.length > 0) {
12
+ return { dispatcher: true, via: 'gate-state', agentType: state.agentType };
13
+ }
14
+
15
+ if (toolUses !== null) {
16
+ for (const toolUse of toolUses) {
17
+ if (toolUse?.name !== 'Bash' || typeof toolUse.command !== 'string'
18
+ || /[;&|`$<>\r\n]/.test(toolUse.command)) continue;
19
+ const tokens = toolUse.command.trim().split(/\s+/);
20
+ if (!CLI_NAMES.includes(tokens[0]) || tokens[1] !== 'run') continue;
21
+ for (let index = 2; index < tokens.length - 1; index += 1) {
22
+ if (tokens[index] === '--agent' && ORDER_AGENTS.includes(tokens[index + 1])) {
23
+ return { dispatcher: true, via: 'run-command', runAgent: tokens[index + 1] };
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ return { dispatcher: false, reason: toolUses === null ? 'transcript-unreadable' : 'no-evidence' };
30
+ }
@@ -19,6 +19,7 @@
19
19
  * run-state.mjs keeps status.json honest, verdict.mjs decides OK/FAIL/LIMIT, reply.mjs
20
20
  * renders the lines. Importers name this file and nothing below it.
21
21
  */
22
+ import { EXIT } from './runner/exit-codes.mjs';
22
23
  import fs from 'node:fs';
23
24
  import path from 'node:path';
24
25
  import { fileURLToPath } from 'node:url';
@@ -28,7 +29,7 @@ import { splitRunChanges } from './meta/environment.mjs';
28
29
  import { readEvents } from './meta/events.mjs';
29
30
  import { writeStatus } from './meta/run-state.mjs';
30
31
  import { resolveStatus } from './meta/verdict.mjs';
31
- import { AGENTS, failReply, limitReply, withProfileRow } from './meta/reply.mjs';
32
+ import { AGENTS, failReply, limitReply, unavailableReply, withProfileRow } from './meta/reply.mjs';
32
33
  import { withLaunchRows } from './meta/launch-rows.mjs';
33
34
 
34
35
  function readRunnerVersion() {
@@ -164,7 +165,13 @@ export function collect(runDir, agent, exitCode) {
164
165
  const reply =
165
166
  withProfileRow(
166
167
  withLaunchRows(
167
- status === 'OK' ? cfg.reply(ctx) : status === 'LIMIT' ? limitReply(ctx, meta) : failReply(ctx, meta),
168
+ status === 'OK'
169
+ ? cfg.reply(ctx)
170
+ : status === 'LIMIT'
171
+ ? limitReply(ctx, meta)
172
+ : status === 'UNAVAILABLE'
173
+ ? unavailableReply(ctx, meta)
174
+ : failReply(ctx, meta),
168
175
  runDir,
169
176
  ),
170
177
  meta,
@@ -173,8 +180,13 @@ export function collect(runDir, agent, exitCode) {
173
180
  return { meta, reply: reply.join('\n') };
174
181
  }
175
182
 
176
- /** Exit code mirrors the status so an orchestrator can branch without parsing text. */
177
- export const exitCodeFor = (status) => (status === 'OK' ? 0 : status === 'LIMIT' ? 3 : 1);
183
+ /**
184
+ * Exit code numbers live in runner/exit-codes.mjs.
185
+ * USAGE is the launcher's argument error and never comes from here.
186
+ */
187
+ export const exitCodeFor = (status) =>
188
+ (status === 'OK' ? EXIT.OK : status === 'LIMIT' ? EXIT.LIMIT :
189
+ status === 'UNAVAILABLE' ? EXIT.UNAVAILABLE : EXIT.FAIL);
178
190
 
179
191
  const invokedDirectly =
180
192
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
@@ -183,7 +195,7 @@ if (invokedDirectly) {
183
195
  const [runDir, agent, exitCode] = process.argv.slice(2);
184
196
  if (!runDir || !agent) {
185
197
  console.error('usage: node write-meta.mjs <runDir> <agent> <exitCode>');
186
- process.exit(1);
198
+ process.exit(EXIT.FAIL);
187
199
  }
188
200
  const { meta, reply } = collect(runDir, agent, exitCode);
189
201
  console.log(reply);
@@ -1,261 +0,0 @@
1
- import path from 'node:path';
2
- import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
3
-
4
- /**
5
- * Defines the inputs the orchestrator must give each Codex dispatcher.
6
- *
7
- * This contract belongs beside the gate because the dispatcher prompt was the only place that
8
- * named order id and scope, so the caller launched codex-build without either value and the
9
- * runner refused the call before Codex could do any work. Pass 2 can render the same entries
10
- * into agent instructions without creating a second list that can drift.
11
- */
12
-
13
- const PLACEHOLDER_VALUES = new Set(['todo', 'tbd', 'label', 'none', 'order id', 'scope', 'xxx']);
14
-
15
- const freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
16
-
17
- export const CONTINUATION_INPUT = Object.freeze({
18
- label: 'continue',
19
- source: 'the orchestrator',
20
- explanation:
21
- 'The run folder this pass is ordered to continue, followed by why the orchestrator is spending another pass. A continuation is assigned by the orchestrator; after a verdict, the dispatcher returns it and stops.',
22
- example: '2026-08-05_092913_plan14-build — LIMIT at step 3, tests unwritten',
23
- conditional: 'when --continue is passed',
24
- });
25
-
26
- /**
27
- * The task statement reaches the runner as a file because every other channel puts the invocation
28
- * back into the multi-line form that no permission rule can cover — a heredoc on stdin, or a
29
- * quoted argument the shell mangles. The orchestrator writes that file with its own file tool,
30
- * which never crosses the shell at all.
31
- *
32
- * It is listed as a required input, not merely explained in the prompt body, because the rendered
33
- * summary is the ONLY part of this contract the orchestrator ever reads. Left out of the list, a
34
- * dispatcher that was given no path filled the gap itself: on 2026-08-15 codex-build wrote the file
35
- * with `cat > … << 'EOF'` and earned exactly the permission window the file was introduced to end.
36
- * The 2026-08-15 relative-path incident also made the repository cwd silently choose a different
37
- * task.md, so both the producer gate and runner use this cross-platform absolute-path contract.
38
- */
39
- export const TASK_FILE_INPUT = Object.freeze({
40
- label: 'task file',
41
- source: 'the orchestrator',
42
- explanation: 'Absolute path to a file holding the task statement verbatim, written by the orchestrator with its file tool. The dispatcher passes it as --task-file and never creates, reads or rewrites it: writing it from the shell reintroduces the permission prompt this flag exists to remove. Given no path, start the runner without the flag and return its refusal.',
43
- example: 'C:/Users/me/AppData/Local/Temp/claude/<session>/scratchpad/task-plan-13.md',
44
- });
45
-
46
- export const REQUIRED_INPUTS = Object.freeze({
47
- 'codex-scout': freezeEntries([
48
- {
49
- label: 'order id',
50
- source: 'the orchestrator',
51
- explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
52
- example: 'plan-13-scout-20260804',
53
- },
54
- TASK_FILE_INPUT,
55
- CONTINUATION_INPUT,
56
- ]),
57
- 'codex-build': freezeEntries([
58
- {
59
- label: 'order id',
60
- source: 'the orchestrator',
61
- explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
62
- example: 'plan-13-build-20260804',
63
- },
64
- {
65
- label: 'scope',
66
- source: 'the orchestrator',
67
- explanation: 'Comma-separated globs relative to the repository root, listing every file the run may touch — including each caller of what changes, not only the file being edited. Anything outside the list fails the run.',
68
- example: 'src/home/lib/runner/**,tests/runner/**',
69
- },
70
- TASK_FILE_INPUT,
71
- CONTINUATION_INPUT,
72
- ]),
73
- 'codex-review': freezeEntries([
74
- {
75
- label: 'order id',
76
- source: 'the orchestrator',
77
- explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
78
- example: 'plan-13-review-20260804',
79
- },
80
- TASK_FILE_INPUT,
81
- CONTINUATION_INPUT,
82
- ]),
83
- // Plan_59 D7: the caller must name the phase and authorize the continued decision pass.
84
- 'codex-advisor': freezeEntries([
85
- {
86
- label: 'order id',
87
- source: 'the orchestrator',
88
- explanation: 'The label this design order is known by. Keep the same order id for scope and advise so phase 2 can settle the risks predicted in phase 1.',
89
- example: 'plan-59-advisor-20260922',
90
- },
91
- TASK_FILE_INPUT,
92
- CONTINUATION_INPUT,
93
- {
94
- label: 'phase',
95
- source: 'the orchestrator',
96
- explanation: 'Pass scope first to predict risks and check the reading boundary, then advise with --continue and a continuation grant naming the scope run of the same order.',
97
- example: 'scope',
98
- },
99
- ]),
100
- });
101
-
102
- const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
103
-
104
- function cleanValue(value) {
105
- if (typeof value !== 'string') return '';
106
- return value.trim().replace(/^([`'\"])(.*)\1$/, '$2').trim();
107
- }
108
-
109
- export function isAbsoluteTaskFilePath(value) {
110
- const cleaned = cleanValue(value);
111
- return path.posix.isAbsolute(cleaned) || path.win32.isAbsolute(cleaned);
112
- }
113
-
114
- export function isPlaceholder(value) {
115
- const cleaned = cleanValue(value);
116
- if (!cleaned) return true;
117
- if (cleaned.startsWith('<') && cleaned.endsWith('>')) return true;
118
- return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
119
- }
120
-
121
- // Plan_59: scope is a real phase value, while it remains a placeholder for existing inputs.
122
- export const isInputPlaceholder = (value, label) =>
123
- !(label === 'phase' && cleanValue(value) === 'scope') && isPlaceholder(value);
124
-
125
- export function extractValue(promptText, label) {
126
- const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
127
- const flag = `--${label.replaceAll(/\s+/g, '-')}`;
128
- const direct = new RegExp(
129
- `(?:^|\\r?\\n)\\s*(?:[-*]\\s*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)\\s*(?::|=|—|-)\\s*([^\\r\\n]*)`,
130
- 'im',
131
- );
132
- const directMatch = promptText.match(direct);
133
- if (directMatch) return cleanValue(directMatch[1]);
134
-
135
- const flagPattern = new RegExp(
136
- `${escapeRegExp(flag)}(?:=|\\s+)(?:"([^"]*)"|'([^']*)'|([^\\s\\r\\n]+))`,
137
- 'i',
138
- );
139
- const flagMatch = promptText.match(flagPattern);
140
- if (!flagMatch) return null;
141
- return cleanValue(flagMatch[1] ?? flagMatch[2] ?? flagMatch[3]);
142
- }
143
-
144
- const MAX_DIAGNOSIS_LINE_LENGTH = 160;
145
-
146
- function readableDiagnosisLine(line) {
147
- const trimmed = line.trim();
148
- if (trimmed.length <= MAX_DIAGNOSIS_LINE_LENGTH) return trimmed;
149
- return `${trimmed.slice(0, MAX_DIAGNOSIS_LINE_LENGTH - 1)}…`;
150
- }
151
-
152
- /** Explains the Plan_28 incident without widening the strict input parser's accepted spellings. */
153
- export function diagnoseInput(promptText, label) {
154
- if (typeof label !== 'string' || !label.trim()) return null;
155
- const prompt = typeof promptText === 'string' ? promptText : '';
156
- const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
157
- const candidatePattern = new RegExp(
158
- `^[ \\t]*(?:[-*][ \\t]*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)(?=$|[^A-Za-z0-9_])[^\\r\\n]*`,
159
- 'i',
160
- );
161
- const candidateLine = prompt.split(/\r?\n/).find((line) => candidatePattern.test(line));
162
- if (candidateLine === undefined) return null;
163
-
164
- const value = extractValue(candidateLine, label);
165
- if (value !== null && !isInputPlaceholder(value, label)) {
166
- if (label === TASK_FILE_INPUT.label && !isAbsoluteTaskFilePath(value)) {
167
- return { line: readableDiagnosisLine(candidateLine), reason: `value \`${value}\` is not an absolute path` };
168
- }
169
- return null;
170
- }
171
-
172
- const line = readableDiagnosisLine(candidateLine);
173
- if (value !== null && isInputPlaceholder(value, label)) {
174
- const displayedValue = value.trim();
175
- if (!displayedValue) return { line, reason: 'value is empty; replace it with a concrete value' };
176
- return {
177
- line,
178
- reason: `value \`${displayedValue}\` is a placeholder; replace it with a concrete value`,
179
- };
180
- }
181
-
182
- const labelTail = candidateLine.match(
183
- new RegExp(`^[ \\t]*(?:[-*][ \\t]*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)([^\\r\\n]*)$`, 'i'),
184
- )?.[1] ?? '';
185
- if (labelTail.includes(':')) {
186
- return { line, reason: `expected \`${label}:\` with nothing between the label and the colon` };
187
- }
188
- return { line, reason: `expected \`${label}: value\` with a separator immediately after the label` };
189
- }
190
-
191
- /** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
192
- export function parseContinuationGrant(promptText) {
193
- const prompt = typeof promptText === 'string' ? promptText : '';
194
- const value = extractValue(prompt, CONTINUATION_INPUT.label);
195
- if (isPlaceholder(value)) return null;
196
-
197
- for (const separator of [/\s+—\s+/, /\s+-\s+/, /\s*:\s*/]) {
198
- const match = value.match(new RegExp(`^(.+?)${separator.source}(.+)$`));
199
- if (!match) continue;
200
- const run = cleanValue(match[1]);
201
- const reason = cleanValue(match[2]);
202
- if (isPlaceholder(run) || isPlaceholder(reason)) return null;
203
- return { run, reason };
204
- }
205
- return null;
206
- }
207
-
208
- /** Returns the immutable input entries for one dispatcher type. */
209
- export function requiredInputsFor(agentType) {
210
- return REQUIRED_INPUTS[agentType] || [];
211
- }
212
-
213
- /** Returns required entries whose value is absent or is still an obvious template placeholder. */
214
- export function missingInputs(agentType, promptText) {
215
- const prompt = typeof promptText === 'string' ? promptText : '';
216
- return requiredInputsFor(agentType).filter((entry) => {
217
- if (entry.conditional) return false;
218
- const value = extractValue(prompt, entry.label);
219
- if (isInputPlaceholder(value, entry.label)) return true;
220
- return entry === TASK_FILE_INPUT && !isAbsoluteTaskFilePath(value);
221
- });
222
- }
223
-
224
- /** Finds labelled command-line values that would disable the host's standing permission. */
225
- export function shellUnsafeInputs(agentType, promptText) {
226
- const prompt = typeof promptText === 'string' ? promptText : '';
227
- return requiredInputsFor(agentType).flatMap((entry) => {
228
- if (entry.conditional) return [];
229
- const value = extractValue(prompt, entry.label);
230
- if (value === null || isInputPlaceholder(value, entry.label)) return [];
231
- const sequence = firstShellUnsafeSequence(value);
232
- return sequence === null ? [] : [{ entry, sequence }];
233
- });
234
- }
235
-
236
- /** Renders the compact contract the orchestrator sees while choosing a dispatcher. */
237
- export function renderRequiredInputSummary(agentType) {
238
- const entries = requiredInputsFor(agentType);
239
- if (!entries.length) return '';
240
- const labels = entries.map((entry) => {
241
- const label = `\`${entry.label}\``;
242
- return entry.conditional ? `${label} (${entry.conditional})` : label;
243
- });
244
- // Commas until the last pair: the list grew to three entries when the continuation grant
245
- // joined it, and "a and b and c" reads as a stutter in the description the orchestrator
246
- // sees while choosing a dispatcher.
247
- const rendered = labels.length > 1
248
- ? `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
249
- : labels[0];
250
- return `Requires ${entries[0].source}-provided ${rendered}.`;
251
- }
252
-
253
- /** Renders the same contract for pass 2 agent instructions. */
254
- export function renderRequiredInputs(agentType) {
255
- return requiredInputsFor(agentType)
256
- .map((entry) => {
257
- const condition = entry.conditional ? ` Condition: ${entry.conditional}.` : '';
258
- return `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.${condition}`;
259
- })
260
- .join('\n');
261
- }
File without changes