@lyupro/codex-bridge 0.6.14 → 0.6.16

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 (62) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/cli/command-registry.mjs +1 -1
  4. package/cli/guidance-render.mjs +57 -0
  5. package/cli/guidance-topics.mjs +8 -0
  6. package/cli/guidance.mjs +9 -10
  7. package/package.json +2 -2
  8. package/scripts/home-write-inventory.mjs +2 -0
  9. package/scripts/run-tests.mjs +44 -0
  10. package/src/claude/rules/core.md +5 -4
  11. package/src/home/guidance/order.md +15 -14
  12. package/src/home/guidance/records.md +3 -1
  13. package/src/home/hooks/dispatcher-gate.mjs +8 -2
  14. package/src/home/hooks/order-gate.mjs +16 -15
  15. package/src/home/lib/dispatcher-call.mjs +27 -24
  16. package/src/home/lib/dispatcher-command.mjs +12 -6
  17. package/src/home/lib/dispatcher-gate.mjs +20 -16
  18. package/src/home/lib/dispatcher-model.mjs +7 -8
  19. package/src/home/lib/dispatcher-pin.mjs +7 -6
  20. package/src/home/lib/dispatcher-protocol.mjs +76 -4
  21. package/src/home/lib/dispatcher-state.mjs +4 -3
  22. package/src/home/lib/install-owner-roots.mjs +57 -55
  23. package/src/home/lib/meta/advisor-task.mjs +21 -12
  24. package/src/home/lib/meta/paths.mjs +6 -1
  25. package/src/home/lib/meta/reply.mjs +1 -10
  26. package/src/home/lib/meta/unavailable-rows.mjs +11 -0
  27. package/src/home/lib/order-diagnostics/cli-flags.mjs +56 -0
  28. package/src/home/lib/order-diagnostics/filesystem.mjs +18 -0
  29. package/src/home/lib/order-diagnostics/host-state.mjs +263 -0
  30. package/src/home/lib/order-diagnostics/internal.mjs +82 -0
  31. package/src/home/lib/order-diagnostics/run-state.mjs +58 -0
  32. package/src/home/lib/order-diagnostics.mjs +48 -0
  33. package/src/home/lib/order-rules/call.mjs +130 -0
  34. package/src/home/lib/order-rules/header-grants.mjs +175 -0
  35. package/src/home/lib/order-rules/header.mjs +206 -0
  36. package/src/home/lib/order-rules/task-document.mjs +80 -0
  37. package/src/home/lib/order-rules.mjs +77 -0
  38. package/src/home/lib/order-schema.mjs +206 -200
  39. package/src/home/lib/order-text-record.mjs +104 -0
  40. package/src/home/lib/order-values.mjs +46 -46
  41. package/src/home/lib/runner/advise-carry.mjs +9 -7
  42. package/src/home/lib/runner/args.mjs +23 -26
  43. package/src/home/lib/runner/codex-availability.mjs +10 -6
  44. package/src/home/lib/runner/continuation.mjs +158 -174
  45. package/src/home/lib/runner/launcher.mjs +19 -17
  46. package/src/home/lib/runner/order-claim.mjs +15 -9
  47. package/src/home/lib/runner/order-options.mjs +3 -2
  48. package/src/home/lib/runner/order-owner.mjs +9 -7
  49. package/src/home/lib/runner/pass-gate.mjs +14 -18
  50. package/src/home/lib/runner/preflight.mjs +25 -35
  51. package/src/home/lib/runner/prompts.mjs +2 -2
  52. package/src/home/lib/runner/question-kind.mjs +4 -2
  53. package/src/home/lib/runner/retired-roots.mjs +18 -9
  54. package/src/home/lib/runner/runs-root.mjs +3 -3
  55. package/src/home/lib/runner/sandbox-diagnosis.mjs +17 -17
  56. package/src/home/lib/runner/sandbox-probe.mjs +18 -27
  57. package/src/home/lib/runner/scope-check.mjs +30 -23
  58. package/src/home/lib/runner/task-file.mjs +6 -4
  59. package/src/home/lib/runner/task-input.mjs +12 -16
  60. package/src/home/lib/runner/worker-admission.mjs +5 -2
  61. package/src/home/lib/subagent-model-env.mjs +5 -6
  62. package/src/home/lib/task-header.mjs +159 -158
@@ -2,9 +2,14 @@
2
2
  import { CLI_NAMES } from './cli-names.mjs';
3
3
  import { parseDispatcherCall } from './dispatcher-call.mjs';
4
4
  import { ORDER_AGENTS } from './order-schema.mjs';
5
+ import { orderRuleMessage } from './order-rules.mjs';
6
+ import { orderDiagnosticMessage } from './order-diagnostics.mjs';
7
+ import { checkOutgoingRefusal } from './order-text-record.mjs';
5
8
 
6
9
  function refusal(reason) {
7
- return { refusal: `Refused: ${reason}.` };
10
+ const text = orderDiagnosticMessage('dispatcher-refusal', { reason });
11
+ checkOutgoingRefusal(text, 'refusal');
12
+ return { refusal: text };
8
13
  }
9
14
 
10
15
  function assembleRunCommand(agentType, taskFile) {
@@ -15,7 +20,7 @@ function assembleRunCommand(agentType, taskFile) {
15
20
  /** Plan_63 D11: prompt templates and the gate must use the same command assembler. */
16
21
  export function renderRunCommandTemplate(agentType) {
17
22
  if (typeof agentType !== 'string' || !ORDER_AGENTS.includes(agentType)) {
18
- throw new Error(`unknown dispatcher agent ${JSON.stringify(agentType)}`);
23
+ throw new Error(orderDiagnosticMessage('dispatcher-agent-input-known', { agentType }));
19
24
  }
20
25
  return assembleRunCommand(agentType, '<task-file path from the orchestrator>');
21
26
  }
@@ -23,15 +28,16 @@ export function renderRunCommandTemplate(agentType) {
23
28
  /** Builds the only permitted run command directly from the immutable order transcript. */
24
29
  export function canonicalRunCommand(agentType, promptText) {
25
30
  if (typeof agentType !== 'string' || !ORDER_AGENTS.includes(agentType)) {
26
- return refusal(`unknown dispatcher agent ${JSON.stringify(agentType)}`);
31
+ return refusal(orderDiagnosticMessage('dispatcher-agent-input-known', { agentType }));
27
32
  }
28
- if (typeof promptText !== 'string') return refusal('prompt text is missing or is not text');
33
+ if (typeof promptText !== 'string') return refusal(orderDiagnosticMessage('dispatcher-prompt-text-required'));
29
34
 
30
35
  // Plan_63 D9, OW-054: the call cannot switch continuation or bypass order ownership.
31
36
  const { inputs, problems } = parseDispatcherCall(agentType, promptText);
32
37
  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('; ')}`);
38
+ const reasons = problems.map(({ line, reason }) => line === null ? reason
39
+ : orderRuleMessage('dispatcher-call-refusal', { part: 'command-line', line, reason }));
40
+ return refusal(orderRuleMessage('dispatcher-call-refusal', { part: 'command', reasons: reasons.join('; ') }));
35
41
  }
36
42
 
37
43
  return { command: assembleRunCommand(agentType, inputs.get('task file')) };
@@ -2,6 +2,8 @@
2
2
  import { canonicalRunCommand, sameCommand } from './dispatcher-command.mjs';
3
3
  import { ownTranscriptPath, transcriptPrompt } from './dispatcher-order.mjs';
4
4
  import { HANDBACK_TOOL } from './hook-definitions.mjs';
5
+ import { dispatcherDenial, dispatcherProtocolMessage } from './dispatcher-protocol.mjs';
6
+ import { orderDiagnosticMessage } from './order-diagnostics.mjs';
5
7
 
6
8
  export { HANDBACK_TOOL };
7
9
 
@@ -10,7 +12,9 @@ export function gateOrder(payload) {
10
12
  const prompt = transcriptPath ? transcriptPrompt(transcriptPath) : null;
11
13
  if (!transcriptPath || !prompt) {
12
14
  return {
13
- refusal: `the order could not be read from the agent transcript ${transcriptPath || '(none)'}`,
15
+ refusal: orderDiagnosticMessage('dispatcher-order-transcript-readable', {
16
+ transcriptPath: transcriptPath || '(none)',
17
+ }),
14
18
  };
15
19
  }
16
20
  return canonicalRunCommand(payload.agent_type, prompt);
@@ -36,9 +40,7 @@ export function isFinalOutput(output) {
36
40
  }
37
41
 
38
42
  function denial(message, order) {
39
- const prefix = `codex-bridge dispatcher gate: ${message}`;
40
- if (typeof order?.command !== 'string' || order.command.length === 0) return prefix;
41
- return `${prefix}\n${order.command}\nRun it exactly, in the foreground; repeating it attaches to the same run.`;
43
+ return dispatcherDenial(message, order?.command);
42
44
  }
43
45
 
44
46
  function deny(message, order, stateUpdate) {
@@ -57,43 +59,45 @@ export function decidePreToolUse({ payload, order, state }) {
57
59
  // after the handback and wrote into a tree the caller believed settled; a substituted FAIL followed by a
58
60
  // late run would repeat exactly that, so nothing — not even the canonical command — runs past delivery.
59
61
  if (state?.handback === 'delivered') {
60
- return deny('the handback was already delivered; this dispatcher is finished and may not act further.');
62
+ return deny(dispatcherProtocolMessage('handback-delivered'));
61
63
  }
62
64
  if (toolName === 'Bash') {
63
65
  if (order?.refusal) {
64
- return deny('the order could not be read; nothing may run, so hand back now.', order);
66
+ return deny(dispatcherProtocolMessage('order-unreadable'), order);
65
67
  }
66
68
  if (order?.command && sameCommand(payload.tool_input?.command, order.command)
67
69
  && payload.tool_input?.run_in_background !== true) return { kind: 'pass' };
68
- return deny('only the canonical runner command may run in the foreground.', order);
70
+ return deny(dispatcherProtocolMessage('canonical-foreground'), order);
69
71
  }
70
72
 
71
73
  if (toolName === HANDBACK_TOOL) {
72
- if (order?.refusal) return allow(`FAIL — dispatcher gate: ${order.refusal}`);
74
+ if (order?.refusal) return allow(orderDiagnosticMessage('dispatcher-order-refusal', { refusal: order.refusal }));
73
75
  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
+ return allow(orderDiagnosticMessage('dispatcher-receipt-conflict', {
77
+ runReceipt: state.runReceipt, runReceiptConflict: state.runReceiptConflict,
78
+ }));
76
79
  }
77
80
  if (typeof state?.runnerOutput === 'string' && state.runnerOutput.length > 0) {
78
- if (state.runnerFinal) return allow(state.runnerOutput);
79
- return deny('the run has started without a verdict; repeat the exact command to attach and wait.', order);
81
+ if (state.runnerFinal) return allow(orderDiagnosticMessage('dispatcher-runner-output',
82
+ { output: state.runnerOutput }));
83
+ return deny(dispatcherProtocolMessage('verdict-pending'), order);
80
84
  }
81
85
 
82
86
  const attempts = Number.isInteger(state?.handbackAttempts) ? state.handbackAttempts : 0;
83
87
  const handbackAttempts = attempts + 1;
84
88
  if (handbackAttempts === 1) {
85
- return deny('the dispatcher handed back without a runner verdict; run the command first.', order,
89
+ return deny(dispatcherProtocolMessage('runner-verdict-required'), order,
86
90
  { handbackAttempts });
87
91
  }
88
92
  return {
89
93
  kind: 'allow',
90
- message: 'FAIL — dispatcher did not delegate: it handed back without running `' + order.command + '`',
94
+ message: orderDiagnosticMessage('dispatcher-did-not-delegate', { command: order.command }),
91
95
  stateUpdate: { handbackAttempts, handback: 'delivered' },
92
96
  };
93
97
  }
94
98
 
95
99
  if (order?.refusal) {
96
- return deny('the order could not be read; nothing may run, so hand back now.', order);
100
+ return deny(dispatcherProtocolMessage('order-unreadable'), order);
97
101
  }
98
- return deny('only the canonical runner command may run before handback.', order);
102
+ return deny(dispatcherProtocolMessage('canonical-before-handback'), order);
99
103
  }
@@ -7,6 +7,7 @@ import { readDispatcherPin } from './dispatcher-pin.mjs';
7
7
  import { parseJsonText } from './json-file.mjs';
8
8
  import { compareModelFamilies } from './model-family.mjs';
9
9
  import { normalizeLedger, readLedgerFile, recordObservation } from './observation-ledger.mjs';
10
+ import { orderDiagnosticMessage } from './order-diagnostics.mjs';
10
11
 
11
12
  export const DISPATCHER_MODEL_FILE = 'dispatcher-model.json';
12
13
 
@@ -46,13 +47,9 @@ export function modelObservation({ agentType, hostVersion, pin, models, complete
46
47
  ...(pin.family === null ? { pinReasons: pin.reasons } : {}),
47
48
  ...(incompleteMatch ? { complete: false } : {}),
48
49
  };
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`;
50
+ const detail = orderDiagnosticMessage('dispatcher-model-observation', {
51
+ verdict, agentType, models: parsed.join(', '), pinFamily: pin.family, incompleteMatch,
52
+ });
56
53
  return { key: `${hostVersion || 'unknown'}|${agentType}`, verdict, detail, data };
57
54
  }
58
55
 
@@ -107,6 +104,8 @@ export async function observeDispatcherModelStop({ brandRoot, stateDir, input, h
107
104
  // D4: a diagnostic write failure must not block the stop or hide an observed quota violation.
108
105
  }
109
106
  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.`
107
+ ? orderDiagnosticMessage('dispatcher-model-stop-warning', {
108
+ agentType: input.agent_type, models: observation.data.parsed.join(', '), pinFamily: observation.data.pinFamily,
109
+ })
111
110
  : null;
112
111
  }
@@ -10,6 +10,7 @@ import { CLI_NAMES } from './cli-names.mjs';
10
10
  import { parseFrontmatter } from './frontmatter.mjs';
11
11
  import { readOwnerRoots } from './install-owner-roots.mjs';
12
12
  import { modelFamily } from './model-family.mjs';
13
+ import { orderDiagnosticMessage } from './order-diagnostics.mjs';
13
14
 
14
15
  export function readDispatcherPin({ brandRoot, agentType }) {
15
16
  const owners = readOwnerRoots({ brandRoot });
@@ -23,8 +24,8 @@ export function readDispatcherPin({ brandRoot, agentType }) {
23
24
  try {
24
25
  content = fs.readFileSync(file, 'utf8');
25
26
  } catch (error) {
26
- const problem = error.code === 'ENOENT' ? 'is missing' : 'cannot be read';
27
- reasons.push(`Dispatcher file "${file}" ${problem}; repair with ${CLI_NAMES[0]} install --host "${root}".`);
27
+ const problem = error.code === 'ENOENT' ? 'missing' : 'unreadable';
28
+ reasons.push(orderDiagnosticMessage('dispatcher-pin-file-readable', { file, problem, cli: CLI_NAMES[0], root }));
28
29
  continue;
29
30
  }
30
31
 
@@ -35,16 +36,16 @@ export function readDispatcherPin({ brandRoot, agentType }) {
35
36
  frontmatter = null;
36
37
  }
37
38
  if (frontmatter === null) {
38
- reasons.push(`The frontmatter in "${file}" is unreadable.`);
39
+ reasons.push(orderDiagnosticMessage('dispatcher-pin-frontmatter-readable', { file }));
39
40
  continue;
40
41
  }
41
42
  if (frontmatter.name !== agentType) {
42
- reasons.push(`Dispatcher file "${file}" does not declare the required frontmatter name "${agentType}".`);
43
+ reasons.push(orderDiagnosticMessage('dispatcher-pin-agent-name', { file, agentType }));
43
44
  continue;
44
45
  }
45
46
  const family = modelFamily(frontmatter.model);
46
47
  if (family === null) {
47
- reasons.push(`Dispatcher file "${file}" has no parseable model family in its frontmatter model.`);
48
+ reasons.push(orderDiagnosticMessage('dispatcher-pin-model-family', { file }));
48
49
  continue;
49
50
  }
50
51
  pins.push({ root, family });
@@ -52,7 +53,7 @@ export function readDispatcherPin({ brandRoot, agentType }) {
52
53
 
53
54
  if (new Set(pins.map(({ family }) => family)).size > 1) {
54
55
  const disagreement = pins.map(({ root, family }) => `"${root}" -> ${family}`).join(', ');
55
- reasons.push(`Installed dispatcher model families disagree across owners: ${disagreement}.`);
56
+ reasons.push(orderDiagnosticMessage('dispatcher-pin-owner-agreement', { disagreement }));
56
57
  }
57
58
  return reasons.length
58
59
  ? { family: null, reasons }
@@ -5,10 +5,63 @@
5
5
  */
6
6
  import { renderRunCommandTemplate } from './dispatcher-command.mjs';
7
7
  import { TASK_FILE_INPUT } from './dispatcher-call.mjs';
8
+ import { orderDiagnosticMessage } from './order-diagnostics.mjs';
9
+ import { checkedOrderText } from './order-text-record.mjs';
10
+
11
+ // Plan_79 D8: prompt and gate quote the same obligations, including corrective deny sentences.
12
+ export const DISPATCHER_PROTOCOL = Object.freeze({
13
+ 'handback-delivered': Object.freeze({
14
+ data: Object.freeze([]),
15
+ message: () => 'the handback was already delivered; this dispatcher is finished and may not act further.',
16
+ }),
17
+ 'order-unreadable': Object.freeze({
18
+ data: Object.freeze([]),
19
+ message: () => 'the order could not be read; nothing may run, so hand back now.',
20
+ }),
21
+ 'canonical-foreground': Object.freeze({
22
+ data: Object.freeze([]),
23
+ message: () => 'only the canonical runner command may run in the foreground.',
24
+ }),
25
+ 'verdict-pending': Object.freeze({
26
+ data: Object.freeze([]),
27
+ message: () => 'the run has started without a verdict; repeat the exact command to attach and wait.',
28
+ }),
29
+ 'runner-verdict-required': Object.freeze({
30
+ data: Object.freeze([]),
31
+ message: () => 'the dispatcher handed back without a runner verdict; run the command first.',
32
+ }),
33
+ 'canonical-before-handback': Object.freeze({
34
+ data: Object.freeze([]),
35
+ message: () => 'only the canonical runner command may run before handback.',
36
+ }),
37
+ 'command-repeat': Object.freeze({
38
+ data: Object.freeze([]),
39
+ message: () => 'Run it exactly, in the foreground; repeating it attaches to the same run.',
40
+ }),
41
+ 'denial': Object.freeze({
42
+ data: Object.freeze(['command']),
43
+ message: ({ reason, command }) => `codex-bridge dispatcher gate: ${reason}` +
44
+ (typeof command === 'string' && command.length > 0
45
+ ? `\n${command}\n${dispatcherProtocolMessage('command-repeat')}` : ''),
46
+ }),
47
+ });
48
+
49
+ export function dispatcherProtocolMessage(key, values = {}) {
50
+ if (!Object.hasOwn(DISPATCHER_PROTOCOL, key)) {
51
+ throw new Error(orderDiagnosticMessage('dispatcher-protocol-key-known', { key }));
52
+ }
53
+ const entry = DISPATCHER_PROTOCOL[key];
54
+ return checkedOrderText('dispatcherProtocolMessage', key, values, entry.data, entry.message);
55
+ }
56
+
57
+ // D8: command is data; the reason and command-repeat correction must already be checked text.
58
+ export function dispatcherDenial(reason, command) {
59
+ return dispatcherProtocolMessage('denial', { reason, command });
60
+ }
8
61
 
9
62
  export function renderDispatcherProtocol(agentType) {
10
63
  const command = renderRunCommandTemplate(agentType);
11
- return `## Required dispatcher input
64
+ return String(`## Required dispatcher input
12
65
 
13
66
  The call is exactly one line: \`${TASK_FILE_INPUT.label}: <task-file path from the orchestrator>\`.
14
67
  The path must be absolute and name a file the orchestrator wrote with its file tool.
@@ -49,6 +102,16 @@ ${command}
49
102
  \`\`\`
50
103
 
51
104
  Replace the placeholder with the path from the call, nothing else.
105
+ The gate requires these instructions:
106
+
107
+ - ${dispatcherProtocolMessage('canonical-before-handback')}
108
+ - ${dispatcherProtocolMessage('canonical-foreground')}
109
+ - ${dispatcherProtocolMessage('command-repeat')}
110
+
111
+ If the order cannot be read, the gate's correction is:
112
+
113
+ - ${dispatcherProtocolMessage('order-unreadable')}
114
+
52
115
  The first call starts the run and returns at once with \`RUN=<path> order-id=<id>\` and a
53
116
  \`STARTED\` line. To get the verdict, run the identical command again. That call does not start
54
117
  a second run or cost quota: it attaches to the run already in flight, prints
@@ -58,8 +121,14 @@ which is normal, not a hang. If the ceiling kills it, run the identical command
58
121
  it attaches to the same run and keeps waiting.
59
122
 
60
123
  Background execution (\`run_in_background\`, \`&\`, \`nohup\`) is prohibited, and so is inventing
61
- a report from memory. Never add a flag: the host allows exactly the one canonical command
62
- and refuses every other.
124
+ a report from memory. Never add a flag.
125
+ If you try to return before the runner's verdict, the gate's correction is:
126
+
127
+ - ${dispatcherProtocolMessage('runner-verdict-required')}
128
+
129
+ If the first call has only started the run, the gate's correction is:
130
+
131
+ - ${dispatcherProtocolMessage('verdict-pending')}
63
132
 
64
133
  A runner refusal — an order id collision, a header problem or a grant problem — is the whole
65
134
  answer: return it as \`FAIL\` with the runner's text. Never retry with a changed command or another
@@ -87,5 +156,8 @@ by checking \`status.json\` manually.
87
156
  Inventing any outcome the runner did not print is equally prohibited. On 2026-08-13 a dispatcher said
88
157
  \`FAIL — could not get the Codex run result because of an architectural environment limitation\` while
89
158
  that run's \`status.json\` already said \`state=finished\`, \`status=OK\`.
90
- `;
159
+ After delivering the handback, the gate refuses every further action:
160
+
161
+ - ${dispatcherProtocolMessage('handback-delivered')}
162
+ `);
91
163
  }
@@ -6,6 +6,7 @@ import { writeHomeJsonAtomic } from './atomic-json.mjs';
6
6
  import { parseJsonText } from './json-file.mjs';
7
7
  import { withHomeFileLock } from './file-lock.mjs';
8
8
  import { stateDirWriter } from './home-write.mjs';
9
+ import { orderDiagnosticMessage } from './order-diagnostics.mjs';
9
10
 
10
11
  // Plan_65 B5: every write goes through the registry id so purge accounts for records, locks and
11
12
  // temporaries alike.
@@ -14,7 +15,7 @@ const ARTIFACT = 'dispatcher-state';
14
15
  function assertIdentity(sessionId, agentId) {
15
16
  if (typeof sessionId !== 'string' || sessionId.length === 0
16
17
  || typeof agentId !== 'string' || agentId.length === 0) {
17
- throw new TypeError('sessionId and agentId must be non-empty strings');
18
+ throw new TypeError(orderDiagnosticMessage('dispatcher-state-identity-required'));
18
19
  }
19
20
  }
20
21
 
@@ -57,7 +58,7 @@ function clockValue(ids) {
57
58
 
58
59
 
59
60
  export async function updateDispatcherState(ids, mutate) {
60
- if (typeof mutate !== 'function') throw new TypeError('mutate must be a function');
61
+ if (typeof mutate !== 'function') throw new TypeError(orderDiagnosticMessage('dispatcher-state-mutator-required'));
61
62
  const file = dispatcherStatePath(ids);
62
63
  const { sessionId, agentId } = ids;
63
64
  const writer = stateDirWriter(ids.stateDir);
@@ -69,7 +70,7 @@ export async function updateDispatcherState(ids, mutate) {
69
70
  const result = await mutate(current);
70
71
  const candidate = result === undefined ? current : result;
71
72
  if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
72
- throw new TypeError('mutate must produce a JSON record object');
73
+ throw new TypeError(orderDiagnosticMessage('dispatcher-state-record-required'));
73
74
  }
74
75
  const record = {
75
76
  ...candidate,
@@ -1,55 +1,57 @@
1
- /**
2
- * Reads which host roots own this home from the installation record, read-only, for hooks.
3
- * Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
4
- */
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { homeArtifact } from './home-registry.mjs';
8
- import { parseJsonText } from './json-file.mjs';
9
-
10
- const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
11
-
12
- export function isFormat2(parsed) {
13
- return isObject(parsed) && parsed.format === 2;
14
- }
15
-
16
- export function readOwnerRoots({ brandRoot }) {
17
- const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
18
- let raw;
19
- try {
20
- raw = fs.readFileSync(file, 'utf8');
21
- } catch (error) {
22
- return error.code === 'ENOENT'
23
- ? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
24
- : { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
25
- }
26
-
27
- let parsed;
28
- try {
29
- parsed = parseJsonText(file, raw);
30
- } catch {
31
- return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
32
- }
33
- if (!isFormat2(parsed)) {
34
- return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
35
- }
36
- // Plan_67 R2: confirmation refuses ownerless records; enrollment must come first.
37
- if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
38
- return { problem: 'no-owners', detail: `Installation record at ${file} has no owners.` };
39
- }
40
- if (parsed.inventory !== 'complete') {
41
- // Plan_67 D11: match cli/inventory-confirm.mjs INVENTORY_CONFIRM_COMMAND; hooks cannot import cli/.
42
- return { problem: 'inventory-incomplete', detail: `Installation record at ${file} has an incomplete inventory; if the recorded hosts are all the hosts using this home, run codex-bridge inventory confirm.` };
43
- }
44
- if (Object.hasOwn(parsed, 'legacy')) {
45
- return { problem: 'legacy-partition', detail: `Installation record at ${file} contains a legacy partition.` };
46
- }
47
- const roots = [];
48
- for (const owner of Object.values(parsed.owners)) {
49
- if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
50
- return { problem: 'malformed-owner', detail: `Installation record at ${file} has an owner without a non-empty root.` };
51
- }
52
- roots.push(owner.root);
53
- }
54
- return { roots: roots.sort() };
55
- }
1
+ /**
2
+ * Reads which host roots own this home from the installation record, read-only, for hooks.
3
+ * Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { CLI_NAMES } from './cli-names.mjs';
8
+ import { homeArtifact } from './home-registry.mjs';
9
+ import { parseJsonText } from './json-file.mjs';
10
+ import { orderDiagnosticMessage } from './order-diagnostics.mjs';
11
+
12
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
13
+
14
+ export function isFormat2(parsed) {
15
+ return isObject(parsed) && parsed.format === 2;
16
+ }
17
+
18
+ export function readOwnerRoots({ brandRoot }) {
19
+ const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
20
+ const detail = (problem) => orderDiagnosticMessage('install-record-owners-readable', { file, problem, cli: CLI_NAMES[0] });
21
+ let raw;
22
+ try {
23
+ raw = fs.readFileSync(file, 'utf8');
24
+ } catch (error) {
25
+ return error.code === 'ENOENT'
26
+ ? { problem: 'missing', detail: detail('missing') }
27
+ : { problem: 'unreadable', detail: detail('unreadable') };
28
+ }
29
+
30
+ let parsed;
31
+ try {
32
+ parsed = parseJsonText(file, raw);
33
+ } catch {
34
+ return { problem: 'unreadable', detail: detail('invalid-json') };
35
+ }
36
+ if (!isFormat2(parsed)) {
37
+ return { problem: 'not-format-2', detail: detail('not-format-2') };
38
+ }
39
+ // Plan_67 R2: confirmation refuses ownerless records; enrollment must come first.
40
+ if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
41
+ return { problem: 'no-owners', detail: detail('no-owners') };
42
+ }
43
+ if (parsed.inventory !== 'complete') {
44
+ return { problem: 'inventory-incomplete', detail: detail('inventory-incomplete') };
45
+ }
46
+ if (Object.hasOwn(parsed, 'legacy')) {
47
+ return { problem: 'legacy-partition', detail: detail('legacy-partition') };
48
+ }
49
+ const roots = [];
50
+ for (const owner of Object.values(parsed.owners)) {
51
+ if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
52
+ return { problem: 'malformed-owner', detail: detail('malformed-owner') };
53
+ }
54
+ roots.push(owner.root);
55
+ }
56
+ return { roots: roots.sort() };
57
+ }
@@ -1,9 +1,12 @@
1
1
  /** Parses blind advisor task choices so Plan_59 D5 rejects biased or ambiguous inputs before review. */
2
+ import { orderRuleMessage } from '../order-rules.mjs';
3
+ import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
4
+
2
5
  const OPTION = /^\s*-\s+([a-z0-9][a-z0-9-]*):\s*(.*)$/;
3
6
  const PREFERENCE = /recommend|prefer|рекоменд|предпочт|\(✓\)|★/iu;
4
7
 
5
8
  function sections(text) {
6
- if (typeof text !== 'string') throw new TypeError('Advisor task text must be a string.');
9
+ if (typeof text !== 'string') throw new TypeError(orderDiagnosticMessage('advisor-task-text-required'));
7
10
  const found = { options: { header: null, lines: [] }, paths: { header: null, lines: [] } };
8
11
  let current = null;
9
12
  for (const [index, value] of text.split(/\r\n|\n|\r/).entries()) {
@@ -39,31 +42,37 @@ export function parseAdvisorTask(text) {
39
42
  return { options: optionsFrom(options.lines), paths: pathsFrom(paths.lines) };
40
43
  }
41
44
 
42
- function at(line) {
43
- return `line ${line.number} ${JSON.stringify(line.value)}`;
44
- }
45
-
46
45
  export function advisorTaskRefusal(text) {
47
46
  const { options, paths } = sections(text);
48
- if (!options.header) return 'Options: add a "## Options" section with at least two options.';
47
+ if (!options.header) return orderRuleMessage('advisor-task-options', { violation: 'missing' });
49
48
  const ids = new Set();
50
49
  for (const line of options.lines) {
51
50
  const preference = line.value.match(PREFERENCE);
52
51
  if (preference) {
53
- return `Options ${at(line)}: remove preference marker ${JSON.stringify(preference[0])} (D5).`;
52
+ return orderRuleMessage('advisor-task-options', {
53
+ violation: 'preference', lineNo: line.number, line: line.value, preference: preference[0],
54
+ });
54
55
  }
55
56
  if (!/^\s*[-*+](?:\s|$)/.test(line.value)) continue;
56
57
  const match = line.value.match(OPTION);
57
58
  if (!match) {
58
- return `Options ${at(line)}: use "- option-id: description" with id matching [a-z0-9][a-z0-9-]*.`;
59
+ return orderRuleMessage('advisor-task-options', {
60
+ violation: 'option-shape', lineNo: line.number, line: line.value,
61
+ });
59
62
  }
60
- if (ids.has(match[1])) return `Options ${at(line)}: replace duplicate option id "${match[1]}".`;
63
+ if (ids.has(match[1])) return orderRuleMessage('advisor-task-options', {
64
+ violation: 'duplicate', lineNo: line.number, line: line.value, optionId: match[1],
65
+ });
61
66
  ids.add(match[1]);
62
67
  }
63
- if (ids.size < 2) return `Options ${at(options.header)}: provide at least two distinct options.`;
64
- if (!paths.header) return 'Paths: add a "## Paths" section with repository-relative paths.';
68
+ if (ids.size < 2) return orderRuleMessage('advisor-task-options', {
69
+ violation: 'too-few', lineNo: options.header.number, line: options.header.value,
70
+ });
71
+ if (!paths.header) return orderRuleMessage('advisor-task-paths', { violation: 'missing' });
65
72
  if (!pathsFrom(paths.lines).length) {
66
- return `Paths ${at(paths.header)}: add at least one repository-relative path as a list item.`;
73
+ return orderRuleMessage('advisor-task-paths', {
74
+ violation: 'empty', lineNo: paths.header.number, line: paths.header.value,
75
+ });
67
76
  }
68
77
  return null;
69
78
  }
@@ -89,7 +89,12 @@ export const changedPaths = (before, after) => {
89
89
  };
90
90
 
91
91
  /** Directories a delegated build has no business editing, whatever the task says. */
92
- export const SERVICE_RE = /^(\.omx|\.omc|\.claude|\.codex|\.git|node_modules)\//;
92
+ export const SERVICE_FOLDERS = Object.freeze([
93
+ '.omx', '.omc', '.claude', '.codex', '.git', 'node_modules',
94
+ ]);
95
+ export const SERVICE_RE = new RegExp(
96
+ `^(${SERVICE_FOLDERS.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})/`,
97
+ );
93
98
 
94
99
  /**
95
100
  * Paths arrive from two sides that spell them differently: git prints repo-relative with
@@ -16,6 +16,7 @@ import { changedPaths, line, readText } from './paths.mjs';
16
16
  import { splitRunChanges } from './environment.mjs';
17
17
  import { scoutCoverage } from './verdict.mjs';
18
18
  import { orderInputName } from '../order-schema.mjs';
19
+ import { unavailableRows } from './unavailable-rows.mjs';
19
20
 
20
21
  /** What the run itself changed, and what the tooling around it changed while it worked. */
21
22
  const runChanges = (runDir) =>
@@ -235,16 +236,6 @@ export function limitReply(ctx, meta) {
235
236
  return rows;
236
237
  }
237
238
 
238
- /** Plan_60 D2: pre-start and post-start refusals share the same handoff contract. */
239
- export function unavailableRows(signal) {
240
- return [
241
- 'UNAVAILABLE — Codex is not available (signed out); ' +
242
- 'hand the task to the next executor, do not retry',
243
- `Signal: ${line(signal, 170)}`,
244
- 'Operator check: codex --version; codex login status (sign in with codex login)',
245
- ];
246
- }
247
-
248
239
  export function unavailableReply(ctx, meta) {
249
240
  const rows = unavailableRows(meta.reason);
250
241
  if (ctx.agent === 'codex-build') {
@@ -0,0 +1,11 @@
1
+ /** Plan_60 D2: pre-start and post-start refusals share the same UNAVAILABLE handoff rows. */
2
+ import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
3
+ import { line } from './paths.mjs';
4
+
5
+ export function unavailableRows(signal) {
6
+ return [
7
+ orderDiagnosticMessage('codex-unavailable-handoff', { variant: 'handoff' }),
8
+ orderDiagnosticMessage('codex-unavailable-handoff', { variant: 'signal', signal: line(signal, 170) }),
9
+ orderDiagnosticMessage('codex-unavailable-handoff', { variant: 'operator-check' }),
10
+ ];
11
+ }