@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.16] - 2026-10-14
8
+
9
+ ### Changed
10
+
11
+ - `codex-bridge guidance order` now prints every rule the runner, the order gate and the dispatcher enforce on a
12
+ task file, under "Rules the runner enforces", straight from the registry that also builds each refusal. A rule
13
+ could previously be enforced without being described anywhere, and an orchestrator learned it only from a
14
+ refusal. The topic's own text keeps the examples and the meaning of the grants, not copies of these rules.
15
+ - The rules core installed into the Claude Code host now says to run `codex-bridge guidance order` before every
16
+ task file. Run `codex-bridge update` to refresh the installed core.
17
+ - The `records` topic describes `task.md` (the assembled input Codex receives), `raw.log` (optional transport
18
+ evidence) and the required fields of `review.json`. The list of shell-unsafe sequences in `order` is printed
19
+ from the code that refuses them, not copied by hand.
20
+ - The dispatcher prompts and the dispatcher gate take their instructions from one catalog, so the reason a call is
21
+ denied is a phrase the dispatcher prompt already contains. Installed prompts change wording after
22
+ `codex-bridge update`; the gate's refusal texts are unchanged.
23
+
24
+ ### Fixed
25
+
26
+ - The test-time check that every refusal text comes from the registry can no longer lose a violation when its
27
+ journal cannot be written: the process stops with exit code 70 and leaves a marker the test runner fails on.
28
+ This affects only the package's own test suite.
29
+
30
+ ## [0.6.15] - 2026-10-12
31
+
32
+ ### Fixed
33
+
34
+ - The `codex-bridge guidance order` topic and the instruction given to Codex itself now name all six folders a
35
+ build scope may never include, `.omc/` among them. Both had dropped it, so an orchestrator could be refused for a
36
+ folder its guidance never mentioned, and Codex was never told the folder was off limits. The scope check, the
37
+ verdict and the Codex instruction now share one list, and a repository test fails when any complete list in the
38
+ docs falls out of step with it.
39
+
7
40
  ## [0.6.14] - 2026-10-11
8
41
 
9
42
  ### Added
package/README.md CHANGED
@@ -62,7 +62,7 @@ Run records in `~/.lyupro/.codex-bridge/runs/` (or `~/.claude/codex-runs/` befor
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **3654 automated tests: 3651 passing and 3 skipped**.
65
+ The current suite contains **4262 automated tests: 4259 passing and 3 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
@@ -283,7 +283,7 @@ export const COMMANDS = [
283
283
  usage: ['codex-bridge guidance [<topic>]'],
284
284
  section: 'public',
285
285
  async handler(argv, io) {
286
- const result = guidance(argv);
286
+ const result = await guidance(argv);
287
287
  if (result.exitCode === 2) io.error(result.output);
288
288
  else io.log(result.output);
289
289
  return result.exitCode;
@@ -0,0 +1,57 @@
1
+ /** Plan_79 B1: guidance derives owned values from the installed home, never a second list. */
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { GUIDANCE_TOPICS } from './guidance-topics.mjs';
5
+
6
+ const codeList = (values) => values.map((value) =>
7
+ value.includes('`') ? '`` ' + value + ' ``' : '`' + value + '`').join(', ');
8
+
9
+ function renderError(topic, placeholder, message) {
10
+ return new Error(`Cannot render guidance topic "${topic}" placeholder "${placeholder}": ${message}`);
11
+ }
12
+
13
+ export async function renderGuidance(text, { root, topic }) {
14
+ const values = new Map();
15
+ for (const [placeholder, name] of text.matchAll(/\{\{([^{}]*)\}\}/g)) {
16
+ if (values.has(placeholder)) continue;
17
+ try {
18
+ // The 2026-08-26 seeded-config incident requires file URLs rooted in the installed home.
19
+ if (name === 'shell-unsafe-sequences') {
20
+ const { SHELL_UNSAFE_SEQUENCES } = await import(pathToFileURL(path.join(root, 'lib', 'shell-unsafe.mjs')).href);
21
+ values.set(placeholder, codeList(SHELL_UNSAFE_SEQUENCES));
22
+ } else if (name === 'review-required-fields') {
23
+ const { SCHEMAS } = await import(pathToFileURL(path.join(root, 'lib', 'runner', 'schemas.mjs')).href);
24
+ const review = SCHEMAS['codex-review'];
25
+ values.set(placeholder, `Top-level fields: ${codeList(review.required)}; finding fields: ${codeList(review.properties.findings.items.required)}`);
26
+ } else if (name.startsWith('order-rules:')) {
27
+ const ruleTopic = name.slice('order-rules:'.length);
28
+ if (ruleTopic !== 'all' && !Object.hasOwn(GUIDANCE_TOPICS, ruleTopic)) {
29
+ throw new Error(`unknown order rule topic "${ruleTopic}"`);
30
+ }
31
+ const { ORDER_RULES } = await import(pathToFileURL(path.join(root, 'lib', 'order-rules.mjs')).href);
32
+ const rules = Object.entries(ORDER_RULES);
33
+ const items = (owner) => rules.filter(([, rule]) => rule.topic === owner)
34
+ .map(([key, rule]) => `- \`${key}\`: ${rule.rule}`).join('\n');
35
+ const block = ruleTopic === 'all'
36
+ ? Object.keys(GUIDANCE_TOPICS).map((owner) => {
37
+ const list = items(owner);
38
+ return list ? `Topic \`${owner}\`:\n${list}` : '';
39
+ }).filter(Boolean).join('\n\n')
40
+ : items(ruleTopic);
41
+ if (!block) throw new Error('no order rules to render');
42
+ values.set(placeholder, block);
43
+ } else {
44
+ throw new Error('unknown placeholder');
45
+ }
46
+ } catch (error) {
47
+ throw renderError(topic, placeholder, error.message);
48
+ }
49
+ }
50
+ const rendered = text.replace(/\{\{([^{}]*)\}\}/g, (placeholder) => values.get(placeholder));
51
+ const unresolved = rendered.indexOf('{{');
52
+ if (unresolved !== -1) {
53
+ const placeholder = rendered.slice(unresolved).split(/\r?\n/, 1)[0];
54
+ throw renderError(topic, placeholder, 'unresolved placeholder');
55
+ }
56
+ return rendered;
57
+ }
@@ -0,0 +1,8 @@
1
+ /** Defines the registered guidance topics and their summaries. */
2
+ export const GUIDANCE_TOPICS = Object.freeze({
3
+ order: 'Task file, header labels, and scope.',
4
+ advisor: 'Scope and advise phases, and the continue grant.',
5
+ budget: 'Run deadline and slicing.',
6
+ records: 'Finding and reading runs.',
7
+ concurrency: 'Parallel and writing runs.',
8
+ });
package/cli/guidance.mjs CHANGED
@@ -2,14 +2,8 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
5
-
6
- export const GUIDANCE_TOPICS = Object.freeze({
7
- order: 'Task file, header labels, and scope.',
8
- advisor: 'Scope and advise phases, and the continue grant.',
9
- budget: 'Run deadline and slicing.',
10
- records: 'Finding and reading runs.',
11
- concurrency: 'Parallel and writing runs.',
12
- });
5
+ import { renderGuidance } from './guidance-render.mjs';
6
+ import { GUIDANCE_TOPICS } from './guidance-topics.mjs';
13
7
 
14
8
  const topicList = () => [
15
9
  'Topics:',
@@ -18,7 +12,7 @@ const topicList = () => [
18
12
  'Usage: codex-bridge guidance [<topic>]',
19
13
  ].join('\n');
20
14
 
21
- export function guidance(argv, { env = process.env, homedir } = {}) {
15
+ export async function guidance(argv, { env = process.env, homedir } = {}) {
22
16
  if (argv.length === 0) return { exitCode: 0, output: topicList() };
23
17
  if (argv.length > 1) {
24
18
  return { exitCode: 2, output: `codex-bridge guidance accepts at most one topic.\n${topicList()}` };
@@ -31,12 +25,17 @@ export function guidance(argv, { env = process.env, homedir } = {}) {
31
25
  // The 2026-08-26 seeded-config incident forbids reading installed files from the clone.
32
26
  const { root } = resolveBrandHome({ homedir, env });
33
27
  const file = path.join(root, 'guidance', `${topic}.md`);
28
+ let text;
34
29
  try {
35
- return { exitCode: 0, output: fs.readFileSync(file, 'utf8') };
30
+ text = fs.readFileSync(file, 'utf8');
36
31
  } catch (error) {
37
32
  return {
38
33
  exitCode: 1,
39
34
  output: `Cannot read guidance file ${file}: ${error.message}\nrun codex-bridge update`,
40
35
  };
41
36
  }
37
+ return renderGuidance(text, { root, topic }).then(
38
+ (output) => ({ exitCode: 0, output }),
39
+ (error) => ({ exitCode: 1, output: `${error.message}\nrun codex-bridge update` }),
40
+ );
42
41
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.14",
4
- "description": "Claude Code-to-Codex delegation with 8 guards, 3654 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.16",
4
+ "description": "Claude Code-to-Codex delegation with 8 guards, 4262 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -46,6 +46,8 @@ export const HOME_WRITE_INVENTORY = Object.freeze([
46
46
  outside('src/home/lib/host-version.mjs', 'open', 1, 'Read-only open of a run transcript tail with the r flag.'),
47
47
  outside('src/home/lib/load-witness.mjs', 'openSync', 1, 'Read-only open with the r flag, reading at most the shared core byte ceiling plus one sentinel byte (Plan_64 D15 / review B8); all witness state writes use the registered load-witness artifact.'),
48
48
  outside('src/home/lib/meta/run-state.mjs', 'writeFileSync', 3, 'Writes status.json and meta.json inside run folders under the runs root.'),
49
+ outside('src/home/lib/order-text-record.mjs', 'mkdirSync', 2, 'Plan_79 D5/D6/D9 creates the outgoing/parameter journal under CODEX_BRIDGE_TEST_TMP and, only on journal failure, its sibling fault directory outside the throwaway root.'),
50
+ outside('src/home/lib/order-text-record.mjs', 'appendFileSync', 2, 'Plan_79 D5/D6/D9 appends outgoing/parameter violations under CODEX_BRIDGE_TEST_TMP and, only on journal failure, the same entries plus the error to a sibling fault marker.'),
49
51
  outside('src/home/lib/run-codex.mjs', 'appendFileSync', 1, 'Appends a crash diagnostic to stderr.log inside a run folder.'),
50
52
  outside('src/home/lib/retention.mjs', 'fs-binding-escape', 1, 'The injected filesystem falls back to fs while cleaning run folders under the runs root, which is operator data outside the home writer even when it is runs/ of the home (Plan_77).'),
51
53
  outside('src/home/lib/retention.mjs', 'unlinkSync', 1, 'Removes transport files inside run folders under the runs root, which is operator data outside the home writer even when it is runs/ of the home (Plan_77).'),
@@ -31,6 +31,41 @@ import { auditWrites } from './home-write-audit.mjs';
31
31
  import { HOME_WRITE_INVENTORY } from './home-write-inventory.mjs';
32
32
  import { readmeText, suiteCountMismatch } from './suite-count.mjs';
33
33
  import { snapshotRoot, addedEntries } from './root-guard/root-entries.mjs';
34
+ import { orderTextFaultDirectory } from '../src/home/lib/order-text-record.mjs';
35
+
36
+ // Plan_79 B5b2b D5/D6: migration is complete, so every journal line is fatal.
37
+ export function inspectOutgoingRefusals(directory, report) {
38
+ let fatal = 0;
39
+ if (fs.existsSync(directory)) {
40
+ for (const file of fs.readdirSync(directory)) {
41
+ const lines = fs.readFileSync(path.join(directory, file), 'utf8').split(/\r?\n/).filter(Boolean);
42
+ for (const line of lines) {
43
+ report(`run-tests: refusal text outside the registry: ${line}\n`);
44
+ fatal++;
45
+ }
46
+ }
47
+ }
48
+ return fatal;
49
+ }
50
+
51
+ // Plan_79 D9: a child whose fault exit is expected must still leave runner-visible evidence.
52
+ export function inspectOrderTextFaults(directory, report) {
53
+ let fatal = 0;
54
+ if (fs.existsSync(directory)) {
55
+ for (const file of fs.readdirSync(directory)) {
56
+ const lines = fs.readFileSync(path.join(directory, file), 'utf8').split(/\r?\n/).filter(Boolean);
57
+ if (!lines.length) lines.push(`empty fault marker: ${file}`);
58
+ for (const line of lines) {
59
+ report(`run-tests: refusal check could not journal: ${line}\n`);
60
+ fatal++;
61
+ }
62
+ }
63
+ }
64
+ return fatal;
65
+ }
66
+
67
+ // Importing the journal checker in isolated tests must not launch another suite.
68
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
34
69
 
35
70
  // One pattern only: on 2026-09-25 three files passed as three arguments ran just the first, and a
36
71
  // verification reported green for two files it never touched. Refuse before any root is created.
@@ -76,6 +111,13 @@ try {
76
111
  });
77
112
  const additions = addedEntries(before, snapshotRoot(repositoryRoot));
78
113
  process.exitCode = result.status ?? 1;
114
+ // Plan_79 D5: inspect before cleanup, even when every expected refusal test passed.
115
+ const refusalJournal = path.join(testTmpRoot, 'order-text-violations');
116
+ const fatalRefusals = inspectOutgoingRefusals(refusalJournal,
117
+ (line) => process.stderr.write(line));
118
+ const fatalFaults = inspectOrderTextFaults(orderTextFaultDirectory(testTmpRoot),
119
+ (line) => process.stderr.write(line));
120
+ if ((fatalRefusals || fatalFaults) && process.exitCode === 0) process.exitCode = 1;
79
121
  for (const name of additions) {
80
122
  process.stderr.write(`run-tests: the suite left ${name} in the repository root (or something else created it while the suite ran)\n`);
81
123
  }
@@ -96,5 +138,7 @@ try {
96
138
  } finally {
97
139
  for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
98
140
  fs.rmSync(testTmpRoot, { recursive: true, force: true });
141
+ fs.rmSync(orderTextFaultDirectory(testTmpRoot), { recursive: true, force: true });
99
142
  if (tapFile) fs.rmSync(path.dirname(tapFile), { recursive: true, force: true });
100
143
  }
144
+ }
@@ -9,10 +9,11 @@ code: a `scope` phase, then `advise`).
9
9
 
10
10
  - The call is one line, `task file: <absolute path>`. Labels go in the task-file header, free text in its body. A
11
11
  refused call costs nothing: read the refusal, fix the task file, call again.
12
- - Before writing a task file, run `codex-bridge guidance <topic>` and follow it: `order` (header labels, scope),
13
- `advisor` (two phases, the `continue:` grant), `budget` (run deadline, slicing), `records` (finding and reading
14
- runs), `concurrency` (parallel and writing runs). `codex-bridge guidance` lists the topics. If it fails, stop and
15
- show its output to the operator — never guess the contract.
12
+ - Before every task file, run `codex-bridge guidance order` and follow it: it lists the enforced rules.
13
+ Other topics, via `codex-bridge guidance <topic>`: `advisor` (two phases, the `continue:` grant),
14
+ `budget` (run deadline, slicing), `records` (finding and reading runs), `concurrency` (parallel and writing
15
+ runs); `codex-bridge guidance` lists them. If it fails, stop and show its output to the operator — never guess
16
+ the contract.
16
17
  - Every `codex-build` task file carries exactly one `advice:` line.
17
18
  - A writing run has a hard deadline: slice the task before the launch, not after a failure. After a deadline `FAIL`,
18
19
  look at `git status` before deciding anything.
@@ -4,14 +4,11 @@ Write a task file that gives each dispatcher the complete order and the build's
4
4
  ## Call and header
5
5
 
6
6
  The call is exactly one line: `task file: <task-file path from the orchestrator>`.
7
- The path must be absolute and name a file the orchestrator wrote with its file tool.
8
7
  The dispatcher never creates, reads or rewrites it. The whole order lives in that file's header.
9
8
  The runner checks the header, never the dispatcher. Free text belongs in the task file.
10
9
 
11
10
  Start at the first line with consecutive lowercase `label: value` lines, then a blank line and the task body.
12
- Labels must use their exact spelling, start at column one and have a non-empty value; duplicates are refused.
13
11
  The header ends at the first line that is not a recognized label line, including a blank line.
14
- Do not put grants or advice below the header, even in a code fence; quote examples with `>` or reword them inside a sentence.
15
12
 
16
13
  | Agent | Required labels | Conditional labels | Optional labels |
17
14
  | --- | --- | --- | --- |
@@ -20,23 +17,23 @@ Do not put grants or advice below the header, even in a code fence; quote exampl
20
17
  | codex-review | `order id` | `continue`, `retry` | `repository`, `slug`, `effort`, `changeset` |
21
18
  | codex-advisor | `order id`, `phase` | `continue`, `retry` | `repository`, `slug`, `effort` |
22
19
 
23
- Both grant labels are conditional when this pass continues or repeats a named run. Keep exactly one grant label:
24
- `continue` names the continued run; `retry` repeats a failed pass and does not authorize the next pass.
25
- Each value names a bare run folder (not a path, `.` or `..`) and a non-placeholder reason, separated by ` — `, ` - ` or `:`.
20
+ Both grant labels are conditional when this pass continues or repeats a named run: `continue` names the continued run; `retry` repeats a failed pass and does not authorize the next pass.
26
21
  For advisor, pass `scope` first, then `advise` with a `continue:` grant naming the scope run of the same order.
27
22
 
28
23
  `repository` is the repository path; `slug` names this pass without replacing the order id.
29
24
  The runner checks accepted `effort` values. `changeset` is `uncommitted`, `base:<branch>` or `commit:<sha>`.
30
- Keep header values short and free of forbidden shell sequences. Empty or placeholder values are refused;
31
- `phase: scope` and `effort: none` are real values.
25
+ Forbidden shell sequences are {{shell-unsafe-sequences}}.
26
+ `phase: scope` and `effort: none` are real values, not placeholders.
32
27
 
33
28
  ## File boundary
34
29
 
35
30
  `scope`: 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.
36
31
 
37
32
  `scope new`: Comma-separated globs for new files this build may create, relative to the repository root.
38
- `scope` must declare at least one existing path pattern; `scope new` does not replace it.
39
- Never name service folders in scope: `.git/`, `.claude/`, `.codex/`, `.omx/`, `node_modules/`.
33
+ Paths listed in `scope new` are already inside the run's boundary, so they are not repeated in `scope`.
34
+ <!-- service-folders:begin -->
35
+ Never name service folders in scope: `.git/`, `.claude/`, `.codex/`, `.omx/`, `.omc/`, `node_modules/`.
36
+ <!-- service-folders:end -->
40
37
 
41
38
  Example build header:
42
39
 
@@ -50,15 +47,19 @@ advice: docs-only
50
47
 
51
48
  ## Advice
52
49
 
53
- Every build task header contains exactly one `advice:` line. Its value is an absolute path to an existing run folder
54
- whose `meta.json` identifies a `codex-advisor` advise pass with status `OK`, or exactly `mechanical`, `revert`,
55
- `docs-only`, or `test-only`. Anything else is refused before the run starts.
50
+ Every build task header contains exactly one `advice:` line.
56
51
 
57
52
  ## Verify
58
53
 
59
- Put the verification command in the task file under a `## Verify` heading, one line.
54
+ Put the verification command in the task file under a `## Verify` heading.
60
55
  The dispatcher never reads that file and never puts the command on the command line.
61
56
 
57
+ ## Rules the runner enforces
58
+
59
+ These are the rules a pre-start refusal names.
60
+
61
+ {{order-rules:all}}
62
+
62
63
  ## Free refusals
63
64
 
64
65
  A pre-start refusal creates no run folder and spends no quota; the same order id remains reusable for the corrected order.
@@ -16,10 +16,12 @@ A started run records its lifecycle and output in its run folder:
16
16
 
17
17
  - `meta.json`: agent, phase and final status; use it to identify an advisor advise pass before citing it in `advice:`.
18
18
  - `status.json`: lifecycle state (`running`, `finished`, `failed` or `abandoned`) and runner PID.
19
+ - `task.md`: the task the runner assembled for the executor, not a byte copy of the orchestrator's task file.
19
20
  - `result.json`: structured scout, build or advisor result; build includes `summary`, `changes`, `verify_command`, `verify_passed`, `leftovers` and `report_markdown`.
20
- - `review.json`: the review agent's structured result instead of `result.json`.
21
+ - `review.json`: the review agent's structured result instead of `result.json`. {{review-required-fields}}.
21
22
  - `report.md`: the full report; the runner expands a build result's `report_markdown` into this file.
22
23
  - `events.jsonl` and `stderr.log`: transport evidence for progress, deadline or startup failures.
24
+ - `raw.log`: transport evidence when present; retention may remove it.
23
25
 
24
26
  A failed or interrupted run may have no complete result. Read the recorded status and available evidence rather than treating a missing report as an empty successful run.
25
27
 
@@ -20,14 +20,20 @@ import {
20
20
  } from '../lib/dispatcher-state.mjs';
21
21
  import { recordInterceptedAttempt, witnessHostVersion } from '../lib/handback-witness.mjs';
22
22
  import { observeSessionHost } from '../lib/host-observations.mjs';
23
+ import { orderDiagnosticMessage } from '../lib/order-diagnostics.mjs';
24
+ import { checkOutgoingRefusal } from '../lib/order-text-record.mjs';
23
25
 
24
26
  function emit(decision, toolInput) {
25
27
  const hookSpecificOutput = {
26
28
  hookEventName: 'PreToolUse',
27
29
  permissionDecision: decision.kind,
28
30
  };
29
- if (decision.kind === 'deny') hookSpecificOutput.permissionDecisionReason = decision.reason;
31
+ if (decision.kind === 'deny') {
32
+ checkOutgoingRefusal(decision.reason, 'deny');
33
+ hookSpecificOutput.permissionDecisionReason = decision.reason;
34
+ }
30
35
  if (decision.kind === 'allow') {
36
+ checkOutgoingRefusal(decision.message, 'handback');
31
37
  hookSpecificOutput.updatedInput = { ...toolInput, message: decision.message };
32
38
  }
33
39
  process.stdout.write(`${JSON.stringify({ hookSpecificOutput })}\n`);
@@ -93,7 +99,7 @@ async function main() {
93
99
  } catch (error) {
94
100
  decision = {
95
101
  kind: 'deny',
96
- reason: `codex-bridge dispatcher gate: internal error: ${errorMessage(error)}`,
102
+ reason: orderDiagnosticMessage('dispatcher-gate-internal-error', { message: errorMessage(error) }),
97
103
  };
98
104
  }
99
105
 
@@ -30,6 +30,9 @@ import { runsRoot } from '../lib/runner/runs-root.mjs';
30
30
  import { parseTaskDocument } from '../lib/runner/task-file.mjs';
31
31
  import { parseTaskHeader, taskHeaderRefusal } from '../lib/task-header.mjs';
32
32
  import { envModelProblem } from '../lib/subagent-model-env.mjs';
33
+ import { orderRuleMessage } from '../lib/order-rules.mjs';
34
+ import { orderDiagnosticMessage } from '../lib/order-diagnostics.mjs';
35
+ import { checkOutgoingRefusal } from '../lib/order-text-record.mjs';
33
36
 
34
37
  const GUARDED = new Set(Object.keys(AGENTS));
35
38
  /**
@@ -41,10 +44,15 @@ const SUBAGENT_TOOL_NAMES = new Set(SUBAGENT_TOOLS);
41
44
 
42
45
  let systemMessage;
43
46
  const pass = () => {
44
- if (systemMessage) process.stdout.write(JSON.stringify({ systemMessage }));
47
+ if (systemMessage) {
48
+ checkOutgoingRefusal(systemMessage, 'system-message');
49
+ process.stdout.write(JSON.stringify({ systemMessage }));
50
+ }
45
51
  process.exit(0);
46
52
  };
47
53
  const deny = (reason) => {
54
+ checkOutgoingRefusal(reason, 'deny');
55
+ if (systemMessage) checkOutgoingRefusal(systemMessage, 'system-message');
48
56
  process.stdout.write(JSON.stringify({
49
57
  ...(systemMessage ? { systemMessage } : {}),
50
58
  hookSpecificOutput: {
@@ -75,11 +83,8 @@ if (!GUARDED.has(toolInput.subagent_type)) pass();
75
83
  // Plan_67 D6: one installed model contract; refuse even matching aliases before prompt exits.
76
84
  const agentType = toolInput.subagent_type;
77
85
  if (Object.hasOwn(toolInput, 'model')) {
78
- deny(
79
- "Order gate denied the Agent call because it passes `model`: a dispatcher's model is set in one place, "
80
- + `its installed agent file. Call ${agentType} without model. `
81
- + 'The run folder was not created; quota was not spent.',
82
- );
86
+ deny([orderRuleMessage('agent-call-model', { agentType }),
87
+ orderDiagnosticMessage('run-not-created')].join(' '));
83
88
  }
84
89
  try {
85
90
  const hostVersion = await transcriptHostVersion(input.transcript_path).catch(() => null);
@@ -93,9 +98,9 @@ try {
93
98
  const latch = modelLatch({ ledger: readDispatcherModel({ stateDir: BRAND_STATE_DIR }), agentType });
94
99
  if (latch.active) {
95
100
  const { data, at } = latch.violation;
96
- systemMessage = `codex-bridge: the last ${agentType} ran on ${data.parsed.join(', ')} `
97
- + `while the installed contract pins ${data.pinFamily} (${data.hostVersion ?? 'unknown'}, ${at}); `
98
- + 'the cause is outside this call — another hook or the host; run codex-bridge doctor.';
101
+ systemMessage = orderDiagnosticMessage('dispatcher-model-latch', {
102
+ agentType, models: data.parsed.join(', '), pinFamily: data.pinFamily, hostVersion: data.hostVersion, at,
103
+ });
99
104
  }
100
105
  } catch {
101
106
  // D4 (a): a corrupt ledger must not turn a diagnostic warning into a refusal.
@@ -111,11 +116,7 @@ if (typeof toolInput.prompt !== 'string') pass();
111
116
  const teammateField = ['name', 'team_name']
112
117
  .find((key) => typeof toolInput[key] === 'string' && toolInput[key].trim());
113
118
  if (teammateField) {
114
- deny(
115
- `Order gate denied the Agent call because it passes \`${teammateField}\`: a dispatcher launched as a ` +
116
- 'teammate escapes the dispatcher gate, whose agent_type becomes the teammate name. Call ' +
117
- `${toolInput.subagent_type} with subagent_type only, without name or team_name.`,
118
- );
119
+ deny(orderRuleMessage('agent-call-teammate', { field: teammateField, agentType: toolInput.subagent_type }));
119
120
  }
120
121
 
121
122
  // Plan_76 D1: exact registry labels prevent the scope-new and repository-prose incident of 2026-10-04.
@@ -144,7 +145,7 @@ const headerRefusal = taskHeaderRefusal(parsed);
144
145
  if (headerRefusal) deny(headerRefusal);
145
146
  const { order, problems } = orderFromHeader(toolInput.subagent_type, parsed);
146
147
  if (problems.length) {
147
- deny(`${renderOrderProblems(toolInput.subagent_type, problems)}\nThe run folder was not created; quota was not spent.`);
148
+ deny([renderOrderProblems(toolInput.subagent_type, problems), orderDiagnosticMessage('run-not-created')].join('\n'));
148
149
  }
149
150
  const orderId = order.get('order id');
150
151
  // Plan_63 D9 / OW-054: only a validated header grant reaches the runner's grant rules.
@@ -15,10 +15,12 @@ import {
15
15
  isInputPlaceholder,
16
16
  } from './order-values.mjs';
17
17
  import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
18
+ import { orderRuleMessage } from './order-rules.mjs';
19
+ import { orderDiagnosticMessage } from './order-diagnostics.mjs';
18
20
 
19
21
  export const TASK_FILE_INPUT = Object.freeze({
20
22
  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.',
23
+ explanation: orderRuleMessage('dispatcher-task-file-help'),
22
24
  example: 'C:/Users/me/AppData/Local/Temp/claude/<session>/scratchpad/task-plan-13.md',
23
25
  });
24
26
 
@@ -28,7 +30,7 @@ export function callInputsFor(agentType) {
28
30
 
29
31
  const knownLabels = new Set(ALL_ORDER_LABELS);
30
32
 
31
- const freeTextReason = 'not a `label: value` line; free text belongs in the task file';
33
+ const freeTextReason = orderRuleMessage('dispatcher-call-shape', { violation: 'free-text' });
32
34
 
33
35
  function normaliseLabel(label) {
34
36
  return label.toLowerCase().replace(/^[-*] /, '').replace(/[*`]/g, '')
@@ -51,8 +53,7 @@ function shellRewrittenSequence(value) {
51
53
  }
52
54
 
53
55
  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
+ return orderRuleMessage('dispatcher-call-shape', { violation: 'rewritten', label, sequence });
56
57
  }
57
58
 
58
59
  /** Returns the accepted label values and every refusal; one reader for the gate, the command and the transcript. */
@@ -64,7 +65,7 @@ export function parseDispatcherCall(agentType, promptText) {
64
65
  const acceptedLines = new Map();
65
66
 
66
67
  if (!ORDER_AGENTS.includes(agentType)) {
67
- problems.push({ line: null, text: '', reason: `unknown dispatcher agent ${JSON.stringify(agentType)}` });
68
+ problems.push({ line: null, text: '', reason: orderDiagnosticMessage('dispatcher-agent-input-known', { agentType }) });
68
69
  return { inputs, problems };
69
70
  }
70
71
 
@@ -78,13 +79,13 @@ export function parseDispatcherCall(agentType, promptText) {
78
79
  const value = tail.trim();
79
80
 
80
81
  if (labels.has(label) && !value) {
81
- problems.push({ line, text, reason: `label \`${label}\` has an empty value` });
82
+ problems.push({ line, text, reason: orderRuleMessage('dispatcher-call-shape', { violation: 'empty', label }) });
82
83
  return;
83
84
  }
84
85
  if (labels.has(label) && /^[ \t]+/.test(tail)) {
85
86
  if (inputs.has(label)) {
86
87
  const first = acceptedLines.get(label).line;
87
- problems.push({ line, text, reason: `label \`${label}\` is given twice (line ${first} and line ${line})` });
88
+ problems.push({ line, text, reason: orderRuleMessage('dispatcher-call-shape', { violation: 'duplicate', label, first, line }) });
88
89
  return;
89
90
  }
90
91
  inputs.set(label, value);
@@ -95,29 +96,30 @@ export function parseDispatcherCall(agentType, promptText) {
95
96
  const normalised = label === null ? null : normaliseLabel(label);
96
97
  let reason = freeTextReason;
97
98
  if (labels.has(normalised)) {
98
- reason = `write the label exactly as \`${normalised}:\``;
99
+ reason = orderRuleMessage('dispatcher-call-shape', { violation: 'spelling', label: normalised });
99
100
  } else if (knownLabels.has(normalised)) {
100
- reason = `label \`${normalised}\` moved to the task-file header; the call is only \`task file: <absolute path>\``;
101
+ reason = orderRuleMessage('dispatcher-call-shape', { violation: 'header', label: normalised });
101
102
  }
102
103
  problems.push({ line, text, reason });
103
104
  });
104
105
 
105
106
  for (const entry of entries) {
106
107
  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
+ problems.push({ line: null, text: '', label: entry.label,
109
+ reason: orderRuleMessage('dispatcher-call-shape', { violation: 'missing', label: entry.label }) });
108
110
  }
109
111
  }
110
112
  for (const [label, value] of inputs) {
111
113
  const location = acceptedLines.get(label);
112
114
  if (isInputPlaceholder(value, label)) {
113
- problems.push({ ...location, reason: `label \`${label}\` is still a placeholder` });
115
+ problems.push({ ...location, reason: orderRuleMessage('dispatcher-call-shape', { violation: 'placeholder', label }) });
114
116
  }
115
117
  if (label === 'task file' && !isAbsoluteTaskFilePath(value)) {
116
- problems.push({ ...location, reason: 'label `task file` must be an absolute path' });
118
+ problems.push({ ...location, reason: orderRuleMessage('dispatcher-call-shape', { violation: 'absolute' }) });
117
119
  }
118
120
  const sequence = unsafeSequence(value);
119
121
  if (sequence !== null) {
120
- const reason = `label \`${label}\` contains ${JSON.stringify(sequence)}; put free text in the task file`;
122
+ const reason = orderRuleMessage('dispatcher-call-shape', { violation: 'unsafe', label, sequence });
121
123
  problems.push({ ...location, reason });
122
124
  } else {
123
125
  const expanding = shellRewrittenSequence(value);
@@ -129,17 +131,20 @@ export function parseDispatcherCall(agentType, promptText) {
129
131
 
130
132
  /** Names each refused line and the agent's header template, so one free refusal carries its own repair. */
131
133
  export function renderCallRefusal(agentType, problems) {
132
- const lines = ['Order gate denied the Agent call: the call text must be only `label: value` lines.'];
134
+ const lines = [orderRuleMessage('dispatcher-call-refusal', { part: 'intro' })];
133
135
  const entries = callInputsFor(agentType);
134
136
  for (const { line, text, reason, label } of problems) {
135
- const entry = line === null && reason === `missing required label \`${label}\``
137
+ const entry = line === null && reason === orderRuleMessage('dispatcher-call-shape', { violation: 'missing', label })
136
138
  ? 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
+ const detail = entry ? orderRuleMessage('dispatcher-call-refusal', {
140
+ part: 'detail', explanation: entry.explanation, label: entry.label, example: entry.example,
141
+ }) : '';
142
+ lines.push(line === null ? orderRuleMessage('dispatcher-call-refusal', { part: 'problem', reason, detail })
143
+ : orderRuleMessage('dispatcher-call-refusal', { part: 'line', line, text, reason }));
139
144
  }
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.');
145
+ lines.push(orderRuleMessage('dispatcher-call-refusal', { part: 'call' }),
146
+ orderRuleMessage('dispatcher-call-refusal', { part: 'header' }),
147
+ ...renderOrderHeaderHelp(agentType).split('\n'), orderRuleMessage('dispatcher-call-refusal', { part: 'free-text' }));
143
148
  return lines.join('\n');
144
149
  }
145
150
 
@@ -159,13 +164,11 @@ function conditionFor(entry) {
159
164
 
160
165
  /** Keeps the caller's short prompt aligned with the header registry, including grant conditions. */
161
166
  export function renderRequiredInputSummary(agentType) {
162
- if (!ORDER_AGENTS.includes(agentType)) return '';
167
+ if (!ORDER_AGENTS.includes(agentType)) return orderRuleMessage('dispatcher-call-summary', { variant: 'unknown-agent' });
163
168
  const entries = headerInputsFor(agentType);
164
169
  const required = entries.filter(({ required }) => required).map(({ label }) => `\`${label}\``);
165
170
  const conditional = entries.filter(({ conditional }) => conditional)
166
171
  .map((entry) => `\`${entry.label}\` (${conditionFor(entry)})`);
167
172
  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.';
173
+ return orderRuleMessage('dispatcher-call-summary', { variant: 'labels', required, conditional, optional });
171
174
  }