@lyupro/codex-bridge 0.6.10 → 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 (57) hide show
  1. package/CHANGELOG.md +81 -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/manifest.mjs +6 -6
  13. package/package.json +2 -2
  14. package/src/agents/codex-advisor.md +25 -119
  15. package/src/agents/codex-build.md +15 -133
  16. package/src/agents/codex-review.md +10 -109
  17. package/src/agents/codex-scout.md +11 -130
  18. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  19. package/src/home/hooks/live-runs.mjs +0 -41
  20. package/src/home/hooks/order-gate.mjs +59 -40
  21. package/src/home/hooks/reply-guard.mjs +52 -65
  22. package/src/home/hooks/reply-identity.mjs +64 -0
  23. package/src/home/hooks/reply-verdicts.mjs +31 -27
  24. package/src/home/hooks/reply-witness.mjs +48 -0
  25. package/src/home/lib/claude-layout.mjs +14 -0
  26. package/src/home/lib/dispatcher-call.mjs +171 -0
  27. package/src/home/lib/dispatcher-command.mjs +18 -56
  28. package/src/home/lib/dispatcher-gate.mjs +4 -0
  29. package/src/home/lib/dispatcher-model.mjs +112 -0
  30. package/src/home/lib/dispatcher-order.mjs +21 -22
  31. package/src/home/lib/dispatcher-pin.mjs +60 -0
  32. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  33. package/src/home/lib/dispatcher-stop.mjs +11 -2
  34. package/src/home/lib/handback-witness.mjs +137 -48
  35. package/src/home/lib/home-registry.mjs +7 -0
  36. package/src/home/lib/install-owner-roots.mjs +53 -0
  37. package/src/home/lib/meta/paths.mjs +1 -1
  38. package/src/home/lib/meta/reply.mjs +2 -1
  39. package/src/home/lib/model-family.mjs +38 -0
  40. package/src/home/lib/observation-ledger.mjs +145 -0
  41. package/src/home/lib/order-schema.mjs +204 -0
  42. package/src/home/lib/order-values.mjs +46 -0
  43. package/src/home/lib/runner/args.mjs +140 -231
  44. package/src/home/lib/runner/attach.mjs +3 -3
  45. package/src/home/lib/runner/continuation.mjs +174 -192
  46. package/src/home/lib/runner/launcher.mjs +9 -9
  47. package/src/home/lib/runner/order-options.mjs +30 -0
  48. package/src/home/lib/runner/order-owner.mjs +2 -1
  49. package/src/home/lib/runner/pass-gate.mjs +11 -9
  50. package/src/home/lib/runner/preflight.mjs +4 -3
  51. package/src/home/lib/runner/scope-check.mjs +11 -10
  52. package/src/home/lib/runner/task-input.mjs +23 -10
  53. package/src/home/lib/subagent-model-env.mjs +44 -0
  54. package/src/home/lib/task-header.mjs +158 -127
  55. package/src/home/lib/untyped-stop.mjs +30 -0
  56. package/src/home/lib/required-inputs.mjs +0 -301
  57. /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
@@ -0,0 +1,64 @@
1
+ /** Decides which run a dispatcher reply answers for, from the gate's receipt (Plan_63 D10; 2026-09-24 stranger-run incident). */
2
+ import path from 'node:path';
3
+ import { readJsonFileSync } from '../lib/json-file.mjs';
4
+ import { normalizePath } from './live-runs.mjs';
5
+ import { MAX_STATE_BLOCKS } from './guard-tries.mjs';
6
+ import {
7
+ noReceiptReason,
8
+ noReceiptStop,
9
+ receiptConflictReason,
10
+ receiptConflictStop,
11
+ receiptMismatchReason,
12
+ receiptMismatchStop,
13
+ } from './reply-verdicts.mjs';
14
+
15
+ export function readReceiptEvidence(state) {
16
+ return {
17
+ receipt: typeof state?.runReceipt === 'string' && state.runReceipt.length > 0 ? state.runReceipt : null,
18
+ receiptConflict: typeof state?.runReceiptConflict === 'string' && state.runReceiptConflict.length > 0
19
+ ? state.runReceiptConflict : null,
20
+ };
21
+ }
22
+
23
+ export function missingIdsAlarm(agentType) {
24
+ return `codex-bridge: the host did not report session_id and agent_id for dispatcher ${agentType} — whose run this answer quotes cannot be checked; run codex-bridge doctor.`;
25
+ }
26
+
27
+ // Plan_63 D10: a recent stranger is never evidence for this reply; only the gate receipt is.
28
+ // Form checks stay in the caller before this decision, preserving their softer try budget.
29
+ export function decideReplyIdentity({ runDir, hasIds, receipt, receiptConflict, agentType }) {
30
+ if (hasIds && receiptConflict) {
31
+ return { block: {
32
+ reason: receiptConflictReason(receipt, receiptConflict),
33
+ stop: receiptConflictStop(MAX_STATE_BLOCKS, agentType, receipt, receiptConflict),
34
+ } };
35
+ }
36
+
37
+ if (!runDir) {
38
+ if (!(hasIds && receipt)) {
39
+ return { block: { reason: noReceiptReason, stop: noReceiptStop(MAX_STATE_BLOCKS, agentType) } };
40
+ }
41
+ let discoveredStatus = null;
42
+ try {
43
+ discoveredStatus = readJsonFileSync(path.join(receipt, 'status.json'));
44
+ } catch {
45
+ discoveredStatus = null;
46
+ }
47
+ return { runDir: receipt, discoveredRun: true, discoveredStatus };
48
+ }
49
+
50
+ if (hasIds) {
51
+ if (!receipt) {
52
+ return { block: { reason: noReceiptReason, stop: noReceiptStop(MAX_STATE_BLOCKS, agentType) } };
53
+ }
54
+ if (normalizePath(runDir) !== normalizePath(receipt)) {
55
+ return { block: {
56
+ reason: receiptMismatchReason(runDir, receipt),
57
+ stop: receiptMismatchStop(MAX_STATE_BLOCKS, agentType, runDir, receipt),
58
+ } };
59
+ }
60
+ }
61
+ // Review 2026-10-05_201513_plan63-r-review-20261005 and Plan_66 H2: missing ids alarm
62
+ // in the caller; named folders retain disk checks because host contract loss must never block work.
63
+ return { runDir, discoveredRun: false, discoveredStatus: null };
64
+ }
@@ -39,7 +39,7 @@ export const stopText = (headline, observed, runStatus, folder) => {
39
39
  'The run will close itself: the worker outlives every caller and completes meta.json and ' +
40
40
  'status.json without the dispatcher. Wait for state to change and reread status.json — the ' +
41
41
  'verdict will be there; there is no need to ask the dispatcher again. Repeating the same ' +
42
- 'command with the same --order-id attaches to this run rather than starting another.',
42
+ 'command with the same task file attaches to this run rather than starting another.',
43
43
  ].join(' ');
44
44
  };
45
45
 
@@ -62,18 +62,29 @@ export const handbackDemandReason =
62
62
  'exactly the command its refusal quotes and hand back again; the gate replaces the message with the ' +
63
63
  "runner's output.";
64
64
 
65
- export const noRecentRunReason =
66
- 'Contract violated: the response has no RUN= or ATTACH= line, and no recent run for this ' +
67
- 'dispatcher was found on disk. Run codex-bridge run and return its stdout verbatim; your own ' +
68
- 'analysis instead of Codex is prohibited in all outcomes.';
65
+ export const noReceiptReason =
66
+ 'Contract violated: no run receipt is recorded for this dispatcher. Run the canonical ' +
67
+ "codex-bridge run command and return that run's stdout verbatim; another run on disk cannot identify this run.";
69
68
 
70
- export const noRecentRunStop = (maxBlocks, agentType, runsDir) => stopText(
69
+ export const noReceiptStop = (maxBlocks, agentType) =>
71
70
  `The reply guard stopped the session: the dispatcher responded ${maxBlocks} times ` +
72
- 'without naming a run and no recent matching run was found on disk.',
73
- `No run for agent ${agentType} was found in the last 24 hours.`,
74
- null,
75
- runsDir,
76
- );
71
+ `without a run receipt for agent ${agentType}. The answer was not checked.`;
72
+
73
+ export const receiptMismatchReason = (runDir, receipt) =>
74
+ `Contract violated: the response names run folder ${runDir}, but this dispatcher's run receipt ` +
75
+ `names ${receipt}. Run the canonical codex-bridge run command and return that run's stdout verbatim.`;
76
+
77
+ export const receiptMismatchStop = (maxBlocks, agentType, runDir, receipt) =>
78
+ `The reply guard stopped the session: dispatcher ${agentType} returned run folder ${runDir} ` +
79
+ `${maxBlocks} times while its run receipt names ${receipt}. The answer was not checked.`;
80
+
81
+ export const receiptConflictReason = (receipt, conflict) =>
82
+ `Contract violated: this dispatcher's run receipt names ${fact(receipt)}, but a contradictory ` +
83
+ `receipt names ${conflict}. Run the canonical codex-bridge run command and return that run's stdout verbatim.`;
84
+
85
+ export const receiptConflictStop = (maxBlocks, agentType, receipt, conflict) =>
86
+ `The reply guard stopped the session: dispatcher ${agentType} responded ${maxBlocks} times ` +
87
+ `with conflicting run receipts ${fact(receipt)} and ${conflict}. The answer was not checked.`;
77
88
 
78
89
  export const omittedSiblingReason = (runDir, dir, status) =>
79
90
  `Contract violated: the response names ${runDir} but omits live run folder ${dir}; report every ` +
@@ -87,17 +98,10 @@ export const omittedSiblingStop = (maxBlocks, dir, status) => stopText(
87
98
  dir,
88
99
  );
89
100
 
90
- export const orderMismatchStop = (maxBlocks, mismatch, runStatus, runDir) => stopText(
91
- `The reply guard stopped the session: the dispatcher returned another order's run ${maxBlocks} times.`,
92
- mismatch.observed,
93
- runStatus,
94
- runDir,
95
- );
96
-
97
101
  export const liveRunReason =
98
102
  'Contract violated: status.json says state=running and the process is alive — the run is ' +
99
103
  'not finished, but you are already responding. The STARTED output of the starting call ' +
100
- 'is not a result. Repeat the identical codex-bridge run command, same --order-id, with ' +
104
+ 'is not a result. Repeat the identical codex-bridge run command, same task file, with ' +
101
105
  'timeout 1800000: it attaches to this same run, costs no quota, and prints the verdict.';
102
106
 
103
107
  export const liveRunStop = (maxBlocks, runStatus, runDir) => stopText(
@@ -111,7 +115,7 @@ export const liveRunStop = (maxBlocks, runStatus, runDir) => stopText(
111
115
  export const deadRunReason =
112
116
  'Contract violated: status.json says state=running, but the process with this pid is dead ' +
113
117
  'and meta.json is missing — the run is abandoned. Repeat the identical codex-bridge run ' +
114
- 'command, same --order-id, with timeout of at least 1800000 and return its stdout verbatim.';
118
+ 'command, same task file, with timeout of at least 1800000 and return its stdout verbatim.';
115
119
 
116
120
  export const deadRunStop = (maxBlocks, runStatus, runDir) => stopText(
117
121
  `The reply guard stopped the session: the dispatcher responded ${maxBlocks} times for a run it did not complete.`,
@@ -125,7 +129,7 @@ export const deadRunStop = (maxBlocks, runStatus, runDir) => stopText(
125
129
 
126
130
  export const abandonedRunReason =
127
131
  'Contract violated: status.json says state=abandoned — the runner died without a verdict. ' +
128
- 'Repeat the identical codex-bridge run command, same --order-id, with timeout of at least ' +
132
+ 'Repeat the identical codex-bridge run command, same task file, with timeout of at least ' +
129
133
  '1800000 and return its stdout verbatim.';
130
134
 
131
135
  export const abandonedRunStop = (maxBlocks, runStatus, runDir) => stopText(
@@ -144,7 +148,7 @@ export const missingMetaReason = (runDir) =>
144
148
 
145
149
  export const missingDiscoveredMetaStop = (maxBlocks, discoveredStatus, runDir) => stopText(
146
150
  `The reply guard stopped the session: the dispatcher responded ${maxBlocks} times ` +
147
- 'without naming the recent run found on disk.',
151
+ 'without naming the run its receipt names.',
148
152
  `status.json says state=${fact(discoveredStatus?.state)}, status=${fact(discoveredStatus?.status)}, ` +
149
153
  'but the adjacent meta.json is missing.',
150
154
  discoveredStatus,
@@ -158,8 +162,8 @@ export const statusMismatchReason = (claimed, meta, discoveredStatus, runDir) =>
158
162
  'its output verbatim without substituting your own judgment.';
159
163
 
160
164
  export const statusMismatchStop = (maxBlocks, claimed, meta, discoveredStatus, runDir) => stopText(
161
- `The reply guard stopped the session: the dispatcher contradicted the recent run on disk ${maxBlocks} times.`,
162
- `status.json says state=${fact(discoveredStatus.state)}, status=${fact(discoveredStatus.status)}; ` +
165
+ `The reply guard stopped the session: the dispatcher contradicted the run its receipt names ${maxBlocks} times.`,
166
+ `status.json says state=${fact(discoveredStatus?.state)}, status=${fact(discoveredStatus?.status)}; ` +
163
167
  `meta.json says status=${meta.status}, but the reply says ${claimed}.`,
164
168
  discoveredStatus,
165
169
  runDir,
@@ -167,12 +171,12 @@ export const statusMismatchStop = (maxBlocks, claimed, meta, discoveredStatus, r
167
171
 
168
172
  export const omittedDiscoveredRunReason = (meta, discoveredStatus, runDir) =>
169
173
  `Contract violated: the response omitted RUN=${runDir}; status.json says ` +
170
- `state=${fact(discoveredStatus.state)}, status=${fact(discoveredStatus.status)}, and meta.json ` +
174
+ `state=${fact(discoveredStatus?.state)}, status=${fact(discoveredStatus?.status)}, and meta.json ` +
171
175
  `says status=${fact(meta.status)}. Return the runner stdout verbatim.`;
172
176
 
173
177
  export const omittedDiscoveredRunStop = (maxBlocks, meta, discoveredStatus, runDir) => stopText(
174
- `The reply guard stopped the session: the dispatcher omitted the recent run found on disk ${maxBlocks} times.`,
175
- `status.json says state=${fact(discoveredStatus.state)}, status=${fact(discoveredStatus.status)}; ` +
178
+ `The reply guard stopped the session: the dispatcher omitted the run its receipt names ${maxBlocks} times.`,
179
+ `status.json says state=${fact(discoveredStatus?.state)}, status=${fact(discoveredStatus?.status)}; ` +
176
180
  `meta.json says status=${fact(meta.status)}.`,
177
181
  discoveredStatus,
178
182
  runDir,
@@ -0,0 +1,48 @@
1
+ /** decides which handback-witness observations one subagent stop produces
2
+ * Plan_67 D8 requires recovery evidence for each cause; D9 requires dispatcher evidence for untyped stops.
3
+ */
4
+ import { recordWitnessObservations } from '../lib/handback-witness.mjs';
5
+
6
+ export function untypedStopObservation({ evidence, hostVersion, agentId }) {
7
+ const alarm = evidence.dispatcher === true;
8
+ return {
9
+ observation: {
10
+ cause: 'missing-agent-type', hostVersion, agentType: null,
11
+ verdict: alarm ? 'violation' : 'undetermined',
12
+ detail: alarm ? `host omitted agent_type for agent ${agentId}; evidence: ${evidence.via}`
13
+ : `untyped subagent stop without dispatcher evidence (${evidence.reason})`,
14
+ },
15
+ alarm,
16
+ };
17
+ }
18
+
19
+ export function typedStopObservations({
20
+ agentType, hostVersion, agentId, hasIds, state, toolUses, unseen, transcriptComplete,
21
+ }) {
22
+ const observations = hasIds ? [
23
+ { cause: 'missing-agent-type', hostVersion, agentType: null, verdict: 'match' },
24
+ { cause: 'missing-ids', hostVersion, agentType, verdict: 'match' },
25
+ ] : [{
26
+ cause: 'missing-ids', hostVersion, agentType, verdict: 'violation',
27
+ detail: `host omitted session_id or agent_id for ${agentType}`,
28
+ }];
29
+ if (unseen.length) {
30
+ observations.push({
31
+ cause: 'tools-outside-gate', hostVersion, agentType, verdict: 'violation',
32
+ detail: `${agentType} ${agentId}: ${unseen.map(({ name }) => name).join(', ')} outside the dispatcher gate`,
33
+ });
34
+ } else if (transcriptComplete === true && toolUses !== null && state && !state.corrupt && state.auditAlarmed !== true
35
+ && state.handback === 'delivered' && state.runnerFinal === true && !state.runReceiptConflict) {
36
+ // D8: denied attempts and synthetic gate FAILs (dispatcher-gate.mjs:70-92) cannot clear an audit alarm.
37
+ observations.push({ cause: 'tools-outside-gate', hostVersion, agentType, verdict: 'match' });
38
+ }
39
+ return observations;
40
+ }
41
+
42
+ export async function recordStopWitness({ stateDir, observations }) {
43
+ try {
44
+ await recordWitnessObservations({ stateDir, observations });
45
+ } catch {
46
+ // Plan_66 H2: witness failures never block work.
47
+ }
48
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Names where an installation puts package files under a Claude root — one spelling for the
3
+ * installer and the hooks, Plan_67 D10.
4
+ */
5
+ import path from 'node:path';
6
+
7
+ export function claudePaths(root) {
8
+ return {
9
+ agentsDir: path.join(root, 'agents', 'codex-bridge'),
10
+ commandsDir: path.join(root, 'commands', 'codex-bridge'),
11
+ legacyAgentsDir: path.join(root, 'agents', 'codex'),
12
+ legacyCommandsDir: path.join(root, 'commands', 'codex'),
13
+ };
14
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Owns the task-file-only dispatcher call and its repair instructions.
3
+ * Plan_63 D1/D9, OW-054: order labels in the call must never become runner flags.
4
+ */
5
+ import {
6
+ ALL_ORDER_LABELS,
7
+ CONTINUATION_ORDER_INPUT,
8
+ ORDER_AGENTS,
9
+ RETRY_ORDER_INPUT,
10
+ orderLabelsFor,
11
+ renderOrderHeaderHelp,
12
+ } from './order-schema.mjs';
13
+ import {
14
+ isAbsoluteTaskFilePath,
15
+ isInputPlaceholder,
16
+ } from './order-values.mjs';
17
+ import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
18
+
19
+ export const TASK_FILE_INPUT = Object.freeze({
20
+ label: 'task file',
21
+ explanation: 'Absolute path to a file holding the task statement verbatim, written by the orchestrator with its file tool. The dispatcher passes the path to the runner and never creates, reads or rewrites the file: writing it from the shell reintroduces the permission prompt this file channel exists to remove. Given no path, return the refusal.',
22
+ example: 'C:/Users/me/AppData/Local/Temp/claude/<session>/scratchpad/task-plan-13.md',
23
+ });
24
+
25
+ export function callInputsFor(agentType) {
26
+ return ORDER_AGENTS.includes(agentType) ? [TASK_FILE_INPUT] : [];
27
+ }
28
+
29
+ const knownLabels = new Set(ALL_ORDER_LABELS);
30
+
31
+ const freeTextReason = 'not a `label: value` line; free text belongs in the task file';
32
+
33
+ function normaliseLabel(label) {
34
+ return label.toLowerCase().replace(/^[-*] /, '').replace(/[*`]/g, '')
35
+ .replace(/^_+|_+$/g, '').replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim();
36
+ }
37
+
38
+ function unsafeSequence(value) {
39
+ const shell = firstShellUnsafeSequence(value);
40
+ const other = value.match(/["\r\n]/)?.[0] ?? null;
41
+ if (shell === null) return other;
42
+ if (other === null) return shell;
43
+ return value.indexOf(shell) <= value.indexOf(other) ? shell : other;
44
+ }
45
+
46
+ // Review of 4ed0fd4: the command quotes the path in double quotes, where Bash still expands `$NAME`,
47
+ // folds `\\` into `\` and lets a final `\` escape the closing quote. The gate would validate the
48
+ // literal file while the runner opened another, so any such path is refused, not escaped.
49
+ function shellRewrittenSequence(value) {
50
+ return value.match(/\$|\\\\|\\$/)?.[0] ?? null;
51
+ }
52
+
53
+ function shellRewrittenReason(label, sequence) {
54
+ return `label \`${label}\` contains ${JSON.stringify(sequence)}, which the shell rewrites inside double quotes; `
55
+ + 'write the path with forward slashes and without `$`';
56
+ }
57
+
58
+ /** Returns the accepted label values and every refusal; one reader for the gate, the command and the transcript. */
59
+ export function parseDispatcherCall(agentType, promptText) {
60
+ const entries = callInputsFor(agentType);
61
+ const labels = new Set(entries.map((entry) => entry.label));
62
+ const inputs = new Map();
63
+ const problems = [];
64
+ const acceptedLines = new Map();
65
+
66
+ if (!ORDER_AGENTS.includes(agentType)) {
67
+ problems.push({ line: null, text: '', reason: `unknown dispatcher agent ${JSON.stringify(agentType)}` });
68
+ return { inputs, problems };
69
+ }
70
+
71
+ promptText.split(/\r?\n/).forEach((text, index) => {
72
+ const trimmed = text.trim();
73
+ if (!trimmed) return;
74
+ const line = index + 1;
75
+ const colon = trimmed.indexOf(':');
76
+ const label = colon === -1 ? null : trimmed.slice(0, colon);
77
+ const tail = colon === -1 ? '' : trimmed.slice(colon + 1);
78
+ const value = tail.trim();
79
+
80
+ if (labels.has(label) && !value) {
81
+ problems.push({ line, text, reason: `label \`${label}\` has an empty value` });
82
+ return;
83
+ }
84
+ if (labels.has(label) && /^[ \t]+/.test(tail)) {
85
+ if (inputs.has(label)) {
86
+ const first = acceptedLines.get(label).line;
87
+ problems.push({ line, text, reason: `label \`${label}\` is given twice (line ${first} and line ${line})` });
88
+ return;
89
+ }
90
+ inputs.set(label, value);
91
+ acceptedLines.set(label, { line, text });
92
+ return;
93
+ }
94
+
95
+ const normalised = label === null ? null : normaliseLabel(label);
96
+ let reason = freeTextReason;
97
+ if (labels.has(normalised)) {
98
+ reason = `write the label exactly as \`${normalised}:\``;
99
+ } else if (knownLabels.has(normalised)) {
100
+ reason = `label \`${normalised}\` moved to the task-file header; the call is only \`task file: <absolute path>\``;
101
+ }
102
+ problems.push({ line, text, reason });
103
+ });
104
+
105
+ for (const entry of entries) {
106
+ if (!entry.conditional && !entry.optional && !inputs.has(entry.label)) {
107
+ problems.push({ line: null, text: '', label: entry.label, reason: `missing required label \`${entry.label}\`` });
108
+ }
109
+ }
110
+ for (const [label, value] of inputs) {
111
+ const location = acceptedLines.get(label);
112
+ if (isInputPlaceholder(value, label)) {
113
+ problems.push({ ...location, reason: `label \`${label}\` is still a placeholder` });
114
+ }
115
+ if (label === 'task file' && !isAbsoluteTaskFilePath(value)) {
116
+ problems.push({ ...location, reason: 'label `task file` must be an absolute path' });
117
+ }
118
+ const sequence = unsafeSequence(value);
119
+ if (sequence !== null) {
120
+ const reason = `label \`${label}\` contains ${JSON.stringify(sequence)}; put free text in the task file`;
121
+ problems.push({ ...location, reason });
122
+ } else {
123
+ const expanding = shellRewrittenSequence(value);
124
+ if (expanding !== null) problems.push({ ...location, reason: shellRewrittenReason(label, expanding) });
125
+ }
126
+ }
127
+ return { inputs, problems };
128
+ }
129
+
130
+ /** Names each refused line and the agent's header template, so one free refusal carries its own repair. */
131
+ export function renderCallRefusal(agentType, problems) {
132
+ const lines = ['Order gate denied the Agent call: the call text must be only `label: value` lines.'];
133
+ const entries = callInputsFor(agentType);
134
+ for (const { line, text, reason, label } of problems) {
135
+ const entry = line === null && reason === `missing required label \`${label}\``
136
+ ? entries.find((entry) => entry.label === label) : undefined;
137
+ const detail = entry ? ` — ${entry.explanation} Example: \`${entry.label}: ${entry.example}\`.` : '';
138
+ lines.push(line === null ? `- ${reason}${detail}` : `- line ${line}: \`${text}\` — ${reason}`);
139
+ }
140
+ lines.push('The call is only `task file: <absolute path>`.',
141
+ 'The order goes in the header at the top of that file:',
142
+ ...renderOrderHeaderHelp(agentType).split('\n'), 'Free text belongs in the task file.');
143
+ return lines.join('\n');
144
+ }
145
+
146
+ function headerInputsFor(agentType) {
147
+ const entries = orderLabelsFor(agentType);
148
+ return [
149
+ ...entries.filter(({ required }) => required),
150
+ ...entries.filter(({ conditional }) => conditional),
151
+ ...entries.filter(({ optional }) => optional),
152
+ ];
153
+ }
154
+
155
+ function conditionFor(entry) {
156
+ return [CONTINUATION_ORDER_INPUT, RETRY_ORDER_INPUT]
157
+ .find(({ label }) => label === entry.label)?.conditional;
158
+ }
159
+
160
+ /** Keeps the caller's short prompt aligned with the header registry, including grant conditions. */
161
+ export function renderRequiredInputSummary(agentType) {
162
+ if (!ORDER_AGENTS.includes(agentType)) return '';
163
+ const entries = headerInputsFor(agentType);
164
+ const required = entries.filter(({ required }) => required).map(({ label }) => `\`${label}\``);
165
+ const conditional = entries.filter(({ conditional }) => conditional)
166
+ .map((entry) => `\`${entry.label}\` (${conditionFor(entry)})`);
167
+ const optional = entries.filter(({ optional }) => optional).map(({ label }) => `\`${label}\``);
168
+ return `The call is only \`task file\` (an absolute path). The task-file header requires ${required.join(', ')}; `
169
+ + `conditional labels: ${conditional.join(', ')}; optional labels: ${optional.join(', ')}. `
170
+ + 'Free text belongs in the task file.';
171
+ }
@@ -1,78 +1,40 @@
1
1
  /** Owns the canonical dispatcher command because the 2026-09-23 TradeForge and 2026-09-24 local incidents let haiku ignore its prompt and register two paid runs for one order id. */
2
2
  import { CLI_NAMES } from './cli-names.mjs';
3
- import {
4
- extractValue,
5
- isAbsoluteTaskFilePath,
6
- isInputPlaceholder,
7
- parseGrant,
8
- REQUIRED_INPUTS,
9
- } from './required-inputs.mjs';
10
- import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
3
+ import { parseDispatcherCall } from './dispatcher-call.mjs';
4
+ import { ORDER_AGENTS } from './order-schema.mjs';
11
5
 
12
6
  function refusal(reason) {
13
7
  return { refusal: `Refused: ${reason}.` };
14
8
  }
15
9
 
16
- function unsafeValueReason(value, label) {
17
- const sequence = firstShellUnsafeSequence(value);
18
- if (sequence !== null) return `input "${label}" contains unsafe shell sequence ${JSON.stringify(sequence)}`;
19
- if (value.includes('"') || value.includes('\r') || value.includes('\n')) {
20
- return `input "${label}" contains a double quote or line break`;
21
- }
22
- return null;
10
+ function assembleRunCommand(agentType, taskFile) {
11
+ const tokens = [CLI_NAMES[0], 'run', '--agent', agentType, '--task-file', `"${taskFile}"`];
12
+ return tokens.join(' ');
23
13
  }
24
14
 
25
- function quote(value) {
26
- return `"${value}"`;
15
+ /** Plan_63 D11: prompt templates and the gate must use the same command assembler. */
16
+ export function renderRunCommandTemplate(agentType) {
17
+ if (typeof agentType !== 'string' || !ORDER_AGENTS.includes(agentType)) {
18
+ throw new Error(`unknown dispatcher agent ${JSON.stringify(agentType)}`);
19
+ }
20
+ return assembleRunCommand(agentType, '<task-file path from the orchestrator>');
27
21
  }
28
22
 
29
23
  /** Builds the only permitted run command directly from the immutable order transcript. */
30
24
  export function canonicalRunCommand(agentType, promptText) {
31
- if (typeof agentType !== 'string' || !Object.prototype.hasOwnProperty.call(REQUIRED_INPUTS, agentType)) {
25
+ if (typeof agentType !== 'string' || !ORDER_AGENTS.includes(agentType)) {
32
26
  return refusal(`unknown dispatcher agent ${JSON.stringify(agentType)}`);
33
27
  }
34
28
  if (typeof promptText !== 'string') return refusal('prompt text is missing or is not text');
35
29
 
36
- const inputs = new Map();
37
- for (const entry of REQUIRED_INPUTS[agentType]) {
38
- if (entry.conditional) continue;
39
- const value = extractValue(promptText, entry.label);
40
- if (isInputPlaceholder(value, entry.label)) {
41
- return refusal(`required input "${entry.label}" is missing or is a placeholder`);
42
- }
43
- const unsafe = unsafeValueReason(value, entry.label);
44
- if (unsafe) return refusal(unsafe);
45
- if (entry.label === 'task file' && !isAbsoluteTaskFilePath(value)) {
46
- return refusal('input "task file" must be an absolute path');
47
- }
48
- inputs.set(entry.label, value);
30
+ // Plan_63 D9, OW-054: the call cannot switch continuation or bypass order ownership.
31
+ const { inputs, problems } = parseDispatcherCall(agentType, promptText);
32
+ if (problems.length) {
33
+ const reasons = problems.map(({ line, reason }) => line === null ? reason : `line ${line}: ${reason}`);
34
+ return refusal(`the call text must be only label: value lines: ${reasons.join('; ')}`);
49
35
  }
50
36
 
51
- // The continuation grant never reaches the command line (`--continue` is bare), so its free-text
52
- // reason is not screened: the live grant "…five risks; advise settles them" would have been refused.
53
- const optionalLabels = ['repository', 'scope new', 'slug', 'effort'];
54
- for (const label of optionalLabels) {
55
- const value = extractValue(promptText, label);
56
- if (value === null) continue;
57
- const unsafe = unsafeValueReason(value, label);
58
- if (unsafe) return refusal(unsafe);
59
- inputs.set(label, value);
60
- }
61
-
62
- const tokens = [CLI_NAMES[0], 'run', '--agent', agentType, '--repo', quote(inputs.get('repository') || '.')];
63
- if (agentType === 'codex-advisor') tokens.push('--phase', quote(inputs.get('phase')));
64
- if (agentType === 'codex-build') {
65
- tokens.push('--scope', quote(inputs.get('scope')));
66
- if (inputs.get('scope new')) tokens.push('--scope-new', quote(inputs.get('scope new')));
67
- }
68
- if (inputs.get('slug')) tokens.push('--slug', quote(inputs.get('slug')));
69
- tokens.push('--order-id', quote(inputs.get('order id')));
70
- tokens.push('--task-file', quote(inputs.get('task file')));
71
- if (inputs.get('effort')) tokens.push('--effort', quote(inputs.get('effort')));
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');
75
- return { command: tokens.join(' ') };
37
+ return { command: assembleRunCommand(agentType, inputs.get('task file')) };
76
38
  }
77
39
 
78
40
  /** Compares the proposed command as text, allowing whitespace only around its boundary. */
@@ -70,6 +70,10 @@ export function decidePreToolUse({ payload, order, state }) {
70
70
 
71
71
  if (toolName === HANDBACK_TOOL) {
72
72
  if (order?.refusal) return allow(`FAIL — dispatcher gate: ${order.refusal}`);
73
+ if (typeof state?.runReceiptConflict === 'string' && state.runReceiptConflict.length > 0) {
74
+ return allow('FAIL — dispatcher gate: the runner reported two different run folders for one dispatcher: ' +
75
+ `${state.runReceipt} and ${state.runReceiptConflict}; the answer is not delivered.`);
76
+ }
73
77
  if (typeof state?.runnerOutput === 'string' && state.runnerOutput.length > 0) {
74
78
  if (state.runnerFinal) return allow(state.runnerOutput);
75
79
  return deny('the run has started without a verdict; repeat the exact command to attach and wait.', order);
@@ -0,0 +1,112 @@
1
+ /** records which model family each dispatcher type actually ran on, against the installed contract
2
+ * Plan_67 D4 observes every assistant model at stop; D5 compares families; D10 does not infer host precedence.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { readDispatcherPin } from './dispatcher-pin.mjs';
7
+ import { parseJsonText } from './json-file.mjs';
8
+ import { compareModelFamilies } from './model-family.mjs';
9
+ import { normalizeLedger, readLedgerFile, recordObservation } from './observation-ledger.mjs';
10
+
11
+ export const DISPATCHER_MODEL_FILE = 'dispatcher-model.json';
12
+
13
+ export function transcriptModels(transcriptPath) {
14
+ let source;
15
+ try {
16
+ source = fs.readFileSync(transcriptPath, 'utf8');
17
+ } catch {
18
+ return null;
19
+ }
20
+ const models = [];
21
+ let complete = true;
22
+ for (const line of source.split(/\r?\n/)) {
23
+ if (!line.trim()) continue;
24
+ let record;
25
+ try {
26
+ record = parseJsonText(transcriptPath, line);
27
+ } catch {
28
+ complete = false;
29
+ continue;
30
+ }
31
+ if (record?.type !== 'assistant' && record?.message?.role !== 'assistant') continue;
32
+ if (typeof record.message?.model === 'string') models.push(record.message.model);
33
+ }
34
+ return { models, complete };
35
+ }
36
+
37
+ export function modelObservation({ agentType, hostVersion, pin, models, complete = true }) {
38
+ const comparison = compareModelFamilies({ pinFamily: pin.family, modelIds: models ?? [] });
39
+ const { parsed, unparsed } = comparison;
40
+ // Plan_67 D8: a partial transcript cannot establish recovery, but can still prove a violation.
41
+ const incompleteMatch = comparison.verdict === 'match' && complete !== true;
42
+ const verdict = incompleteMatch ? 'undetermined' : comparison.verdict;
43
+ const data = {
44
+ hostVersion: hostVersion ?? null, agentType, pinFamily: pin.family,
45
+ parsed, unparsed, comparison: 'installed-contract',
46
+ ...(pin.family === null ? { pinReasons: pin.reasons } : {}),
47
+ ...(incompleteMatch ? { complete: false } : {}),
48
+ };
49
+ const detail = verdict === 'violation'
50
+ ? `${agentType} ran on ${parsed.join(', ')} while the installed contract pins ${pin.family}`
51
+ : verdict === 'match'
52
+ ? `${agentType} ran on ${parsed.join(', ')} matching the installed contract pin ${pin.family}`
53
+ : incompleteMatch
54
+ ? `${agentType} model comparison is undetermined: the transcript has unreadable records`
55
+ : `${agentType} model comparison against the installed contract is undetermined`;
56
+ return { key: `${hostVersion || 'unknown'}|${agentType}`, verdict, detail, data };
57
+ }
58
+
59
+ function normalizeDispatcherModel(parsed) {
60
+ const ledger = normalizeLedger(parsed);
61
+ if (!ledger) return null;
62
+ for (const entry of Object.values(ledger.entries)) {
63
+ for (const observation of [entry.lastObservation, entry.lastViolation, entry.lastMatch, ...entry.history]) {
64
+ if (observation === null) continue;
65
+ const data = observation.data;
66
+ if (!data || Object.getPrototypeOf(data) !== Object.prototype
67
+ || !(data.hostVersion === null || typeof data.hostVersion === 'string')
68
+ || typeof data.agentType !== 'string' || data.agentType.length === 0) return null;
69
+ }
70
+ }
71
+ return ledger;
72
+ }
73
+
74
+ export function readDispatcherModel({ stateDir }) {
75
+ return readLedgerFile(path.join(stateDir, DISPATCHER_MODEL_FILE), { normalize: normalizeDispatcherModel });
76
+ }
77
+
78
+ export async function recordDispatcherModel({ stateDir, observation, now = new Date().toISOString() }) {
79
+ return recordObservation({
80
+ stateDir, id: 'dispatcher-model', file: path.join(stateDir, DISPATCHER_MODEL_FILE),
81
+ observation: { ...observation, at: now },
82
+ });
83
+ }
84
+
85
+ export function modelLatch({ ledger, agentType }) {
86
+ let violation = null;
87
+ let matchSeq = 0;
88
+ // D8: seq, rather than timestamps or host versions, decides recovery across hosts of this type.
89
+ for (const entry of Object.values(ledger?.entries ?? {})) {
90
+ if (entry.lastViolation?.data?.agentType === agentType
91
+ && entry.lastViolation.seq > (violation?.seq ?? 0)) violation = entry.lastViolation;
92
+ if (entry.lastMatch?.data?.agentType === agentType) matchSeq = Math.max(matchSeq, entry.lastMatch.seq);
93
+ }
94
+ return { active: violation !== null && violation.seq > matchSeq, violation };
95
+ }
96
+
97
+ export async function observeDispatcherModelStop({ brandRoot, stateDir, input, hostVersion }) {
98
+ const transcript = transcriptModels(input.agent_transcript_path);
99
+ const observation = modelObservation({
100
+ agentType: input.agent_type, hostVersion,
101
+ pin: readDispatcherPin({ brandRoot, agentType: input.agent_type }),
102
+ models: transcript?.models ?? null, complete: transcript?.complete === true,
103
+ });
104
+ try {
105
+ await recordDispatcherModel({ stateDir, observation });
106
+ } catch {
107
+ // D4: a diagnostic write failure must not block the stop or hide an observed quota violation.
108
+ }
109
+ return observation.verdict === 'violation'
110
+ ? `codex-bridge: dispatcher ${input.agent_type} ran on ${observation.data.parsed.join(', ')} while the installed contract pins ${observation.data.pinFamily} — Claude quota was spent on it; run codex-bridge doctor.`
111
+ : null;
112
+ }