@lyupro/codex-bridge 0.1.2 → 0.1.3

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ 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.1.3] - 2026-08-05
8
+
9
+ ### Added
10
+
11
+ - Dispatcher calls are refused before a subagent exists. A `PreToolUse` gate rejects a `codex-*` call whose task text carries no order label — and, for `codex-build`, no scope — or whose value is still a template placeholder. Until this release the missing input was discovered by the runner, after the subagent had been created and paid for out of the Claude subscription.
12
+ - `codex-build` declares the outcome of its own run in a required `outcome` field. A run that could not do the work reports `fail`, and the verdict goes red with the reason even when the worktree is clean and verification is green — the shape that used to answer `OK`, because an empty delta is a legitimate outcome the runner cannot tell apart from a failure. Whether a run owed that declaration is read from the response schema it was handed, so runs recorded before this release keep the verdict of their own day.
13
+
14
+ ### Changed
15
+
16
+ - Every dispatcher's description and prompt lists the inputs it requires, filled in at install time from the same table the gate enforces, and the runner's own refusals name the input, an example value and what to do about it. A caller no longer learns the contract by being refused.
17
+ - A failed `codex-build` reports what it left in the worktree, the way a quota-exhausted run already did. "The work was not done" is not "the tree is clean", and where no snapshot exists the line says so instead of claiming there were no changes.
18
+ - The installation record stores a list of hooks rather than a single hook, so a host can register more than one. Records written by earlier versions are read as a one-element list.
19
+
7
20
  ## [0.1.2] - 2026-08-04
8
21
 
9
22
  ### Fixed
package/README.md CHANGED
@@ -17,7 +17,8 @@ node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without h
17
17
  ```
18
18
 
19
19
  `install` copies the package into the host's `agents/codex/`, its slash commands into
20
- `commands/codex/`, and registers the stop hook in `settings.json` — **merged**, so hooks that
20
+ `commands/codex/`, and registers both the stop hook and the pre-dispatch order gate in
21
+ `settings.json` — **merged**, so hooks that
21
22
  are already there survive, and the file is backed up before every write. Run it twice and the
22
23
  second run does nothing. `--dry-run` prints the plan and touches nothing.
23
24
 
package/cli/doctor.mjs CHANGED
@@ -2,7 +2,13 @@
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { fileFingerprint, readInstallRecord, packageInfo } from './manifest.mjs';
5
+ import {
6
+ fileFingerprint,
7
+ HOOK_DEFINITIONS,
8
+ readInstallRecord,
9
+ packageInfo,
10
+ } from './manifest.mjs';
11
+ import { commandFor, inspectHook } from './settings-merge.mjs';
6
12
  import { readRulesRegistry } from './rules-owners.mjs';
7
13
  import { runsRoot } from '../src/runner/runs-root.mjs';
8
14
  import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
@@ -53,22 +59,6 @@ function projectRunsCheck() {
53
59
  return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
54
60
  }
55
61
 
56
- function hookCommands(value) {
57
- if (Array.isArray(value)) return value.flatMap(hookCommands);
58
- if (value && typeof value === 'object') {
59
- const own = value.type === 'command' && typeof value.command === 'string' ? [value.command] : [];
60
- return [...own, ...Object.values(value).filter((child) => child !== value.command).flatMap(hookCommands)];
61
- }
62
- return [];
63
- }
64
-
65
- function commandReferences(command, expected) {
66
- const normalizedExpected = path.resolve(expected).split(path.sep).join('/');
67
- const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)]
68
- .map((match) => match[1] ?? match[2] ?? match[3]);
69
- return tokens.some((token) => path.resolve(token).split(path.sep).join('/') === normalizedExpected);
70
- }
71
-
72
62
  export function probeCodex() {
73
63
  const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
74
64
  const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
@@ -79,23 +69,30 @@ export function probeCodex() {
79
69
  return { available: true, value: (result.stdout || result.stderr).trim() };
80
70
  }
81
71
 
82
- async function hookCheck(host, record) {
83
- if (!record) return check('hook', 'warn', 'cannot check before installation');
84
- let settings;
85
- try {
86
- settings = JSON.parse(await fs.readFile(host.settingsPath, 'utf8'));
87
- } catch (err) {
88
- const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
89
- return check('hook', 'warn', value);
90
- }
91
- const registration = settings?.hooks?.SubagentStop;
92
- if (!registration) return check('hook', 'warn', 'SubagentStop is not registered');
93
- const expected = path.resolve(host.root, record.hook.path);
94
- const commands = hookCommands(registration);
95
- const pointsToGuard = commands.some((command) => commandReferences(command, expected));
96
- return pointsToGuard
97
- ? check('hook', 'ok', `SubagentStop -> ${expected}`)
98
- : check('hook', 'warn', 'SubagentStop does not point to the installed reply-guard.mjs');
72
+ async function hookChecks(host, record) {
73
+ return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
74
+ const key = `hook:${definition.event}`;
75
+ if (!record) return check(key, 'warn', 'cannot check before installation');
76
+ const recorded = record.hooks.find((hook) => hook.event === definition.event);
77
+ if (!recorded) {
78
+ return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
79
+ }
80
+ const expected = path.resolve(host.root, recorded.path);
81
+ try {
82
+ const state = await inspectHook(host.settingsPath, {
83
+ event: definition.event,
84
+ matcher: definition.matcher,
85
+ command: commandFor(expected),
86
+ });
87
+ if (state.present) {
88
+ return check(key, 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected}`);
89
+ }
90
+ return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file}`);
91
+ } catch (err) {
92
+ const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
93
+ return check(key, 'warn', `${definition.event} hook: ${value}`);
94
+ }
95
+ }));
99
96
  }
100
97
 
101
98
  async function rulesCheck(host, record) {
@@ -155,7 +152,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
155
152
  ));
156
153
  const rules = await rulesCheck(host, record);
157
154
  checks.push(rules);
158
- checks.push(await hookCheck(host, record));
155
+ checks.push(...await hookChecks(host, record));
159
156
 
160
157
  const codex = codexProbe();
161
158
  checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
package/cli/install.mjs CHANGED
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import {
5
5
  buildInstallPlan,
6
6
  fileFingerprint,
7
+ HOOK_DEFINITIONS,
8
+ installedHookPath,
7
9
  packageInfo,
8
10
  readInstallRecord,
9
11
  recordMatchesPackage,
@@ -12,7 +14,7 @@ import {
12
14
  writeInstallRecord,
13
15
  } from './manifest.mjs';
14
16
  import { copyPlannedFile, targetMatches } from './copy.mjs';
15
- import { inspectHook, mergeHook } from './settings-merge.mjs';
17
+ import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
16
18
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
17
19
 
18
20
  async function targetExists(target) {
@@ -25,6 +27,24 @@ async function targetExists(target) {
25
27
  }
26
28
  }
27
29
 
30
+ function hookTargets(host) {
31
+ return HOOK_DEFINITIONS.map((definition) => {
32
+ const target = installedHookPath(host, definition);
33
+ return {
34
+ definition,
35
+ target,
36
+ relative: path.relative(host.root, target).split(path.sep).join('/'),
37
+ spec: { event: definition.event, matcher: definition.matcher, command: commandFor(target) },
38
+ };
39
+ });
40
+ }
41
+
42
+ function recordHasHooks(record, targets) {
43
+ return Boolean(record)
44
+ && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
45
+ hook.event === definition.event && hook.path === relative));
46
+ }
47
+
28
48
  export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
29
49
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
30
50
  await readRulesRegistry(host);
@@ -32,10 +52,14 @@ export async function install({ host, dryRun = false, force = false, packageRoot
32
52
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
33
53
  const currentPackage = await packageInfo(packageRoot);
34
54
  const record = await readInstallRecord(host);
35
- const inspectedHook = await inspectHook(
36
- host.settingsPath,
37
- path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
38
- );
55
+ const targets = hookTargets(host);
56
+ for (const target of targets) {
57
+ if (!plan.some((item) => item.relativeToHost === target.relative)) {
58
+ throw new Error(`install plan does not contain hooks/${target.definition.file}`);
59
+ }
60
+ }
61
+ const inspectedHooks = await Promise.all(targets.map(({ spec }) =>
62
+ inspectHook(host.settingsPath, spec)));
39
63
  const states = await Promise.all(plan.map(async (item) => ({
40
64
  item,
41
65
  exists: await targetExists(item.target),
@@ -68,7 +92,8 @@ export async function install({ host, dryRun = false, force = false, packageRoot
68
92
  const sameRecord = recordMatchesPackage(record, plan, currentPackage,
69
93
  new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
70
94
  { path: rule.target, fingerprint: ruleState.fingerprint });
71
- if (!changedFiles.length && !changedRule && inspectedHook.present && sameRecord) {
95
+ if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
96
+ && recordHasHooks(record, targets) && sameRecord) {
72
97
  if (!dryRun) await addRulesOwner(host);
73
98
  return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
74
99
  }
@@ -79,7 +104,12 @@ export async function install({ host, dryRun = false, force = false, packageRoot
79
104
  if (changedRule) {
80
105
  lines.push(`${ruleState.exists ? 'Would overwrite' : 'Would create'} ${rule.target}`);
81
106
  }
82
- lines.push(inspectedHook.present ? 'Hook is already registered.' : 'Would register SubagentStop hook.');
107
+ inspectedHooks.forEach((state, index) => {
108
+ const { definition } = targets[index];
109
+ lines.push(state.present
110
+ ? `${definition.event} hook is already registered.`
111
+ : `Would register ${definition.event} hook for matcher ${definition.matcher}.`);
112
+ });
83
113
  lines.push('Would write installation record.');
84
114
  return { exitCode: 0, output: lines.join('\n') };
85
115
  }
@@ -95,15 +125,16 @@ export async function install({ host, dryRun = false, force = false, packageRoot
95
125
  for (const seed of seedPlan(host, packageRoot)) {
96
126
  if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.agentsDir);
97
127
  }
98
- const hookResult = await mergeHook(
99
- host.settingsPath,
100
- path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
101
- inspectedHook,
102
- );
103
- const hookRelative = plan.find((item) => path.basename(item.target) === 'reply-guard.mjs')?.relativeToHost;
104
- if (!hookRelative) throw new Error('install plan does not contain hooks/reply-guard.mjs');
128
+ const hookResults = [];
129
+ for (const { spec } of targets) hookResults.push(await mergeHook(host.settingsPath, spec));
105
130
  const fingerprints = Object.fromEntries(await Promise.all(plan.map(async (item) =>
106
131
  [item.relativeToHost, await fileFingerprint(item.target)])));
132
+ const hooks = targets.map(({ definition, relative }, index) => {
133
+ const prior = record?.hooks?.find((hook) => hook.event === definition.event);
134
+ const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
135
+ return createdGroup ? { event: definition.event, path: relative, createdGroup: true }
136
+ : { event: definition.event, path: relative };
137
+ });
107
138
  await writeInstallRecord(host, {
108
139
  ...currentPackage,
109
140
  installedAt: new Date().toISOString(),
@@ -111,7 +142,10 @@ export async function install({ host, dryRun = false, force = false, packageRoot
111
142
  files: plan.map((item) => item.relativeToHost),
112
143
  fingerprints,
113
144
  rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
114
- hook: { event: 'SubagentStop', path: hookRelative, createdGroup: hookResult.createdGroup || record?.hook?.createdGroup || false },
145
+ hooks,
115
146
  });
116
- return { exitCode: 0, output: `Installed ${plan.length + 1} files and registered the SubagentStop hook.` };
147
+ return {
148
+ exitCode: 0,
149
+ output: `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`,
150
+ };
117
151
  }
package/cli/manifest.mjs CHANGED
@@ -3,6 +3,15 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
+ // The hook table lives under src/ because the installed gate reads it too: cli/ is not copied
7
+ // into a host, so a definition kept here would be invisible to the very hook it describes.
8
+ import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
9
+ import {
10
+ renderRequiredInputSummary,
11
+ renderRequiredInputs,
12
+ } from '../src/required-inputs.mjs';
13
+
14
+ export { HOOK_DEFINITIONS };
6
15
 
7
16
  const HERE = path.dirname(fileURLToPath(import.meta.url));
8
17
  export const PACKAGE_ROOT = path.resolve(HERE, '..');
@@ -29,7 +38,11 @@ export const INSTALL_TABLE = Object.freeze([
29
38
  const posix = (value) => value.split(path.sep).join('/');
30
39
 
31
40
  export function replacePlaceholders(content, agentsDir) {
32
- return content.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)));
41
+ const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
42
+ return content
43
+ .replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)))
44
+ .replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
45
+ .replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType));
33
46
  }
34
47
 
35
48
  export async function packageInfo(packageRoot = PACKAGE_ROOT) {
@@ -41,6 +54,10 @@ export function installRecordPath(host) {
41
54
  return path.join(host.agentsDir, INSTALL_RECORD_NAME);
42
55
  }
43
56
 
57
+ export function installedHookPath(host, definition) {
58
+ return path.join(host.agentsDir, 'hooks', definition.file);
59
+ }
60
+
44
61
  /** Where each seeded file comes from and where it goes; contents are copied verbatim. */
45
62
  export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
46
63
  return SEEDED_SOURCES.map((source) => ({
@@ -136,21 +153,38 @@ export function validateInstallRecord(record) {
136
153
  throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
137
154
  }
138
155
  }
139
- if (!record.hook || typeof record.hook !== 'object' || Array.isArray(record.hook)) {
140
- throw new Error('installation record hook must be an object');
156
+ if (record.hooks !== undefined && record.hook !== undefined) {
157
+ throw new Error('installation record must use hooks instead of hook');
141
158
  }
142
- if (record.hook.event !== 'SubagentStop' || typeof record.hook.path !== 'string' || !record.hook.path) {
143
- throw new Error('installation record hook must identify SubagentStop and its path');
159
+ const hooks = record.hooks !== undefined ? record.hooks : record.hook !== undefined ? [record.hook] : null;
160
+ if (!Array.isArray(hooks) || !hooks.length) {
161
+ throw new Error('installation record hooks must be a non-empty list');
144
162
  }
145
- if (path.isAbsolute(record.hook.path) || record.hook.path.split(/[\\/]/).includes('..')) {
146
- throw new Error('installation record hook path must stay relative to the host root');
147
- }
148
- if (path.basename(record.hook.path) !== 'reply-guard.mjs' || !record.files.includes(record.hook.path)) {
149
- throw new Error('installation record hook path must name the installed reply-guard.mjs');
163
+ for (const hook of hooks) {
164
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
165
+ throw new Error('installation record hook must be an object');
166
+ }
167
+ const definition = HOOK_DEFINITIONS.find((entry) => entry.event === hook.event);
168
+ if (!definition || typeof hook.path !== 'string' || !hook.path) {
169
+ throw new Error('installation record hook must identify a supported event and its path');
170
+ }
171
+ if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
172
+ throw new Error('installation record hook path must stay relative to the host root');
173
+ }
174
+ if (path.basename(hook.path) !== definition.file || !record.files.includes(hook.path)) {
175
+ throw new Error(`installation record hook path must name the installed ${definition.file}`);
176
+ }
150
177
  }
151
178
  return record;
152
179
  }
153
180
 
181
+ export function normalizeInstallRecord(record) {
182
+ validateInstallRecord(record);
183
+ if (record.hooks !== undefined) return record;
184
+ const { hook, ...withoutLegacyHook } = record;
185
+ return { ...withoutLegacyHook, hooks: [hook] };
186
+ }
187
+
154
188
  export async function readInstallRecord(host) {
155
189
  let raw;
156
190
  try {
@@ -165,7 +199,7 @@ export async function readInstallRecord(host) {
165
199
  } catch (err) {
166
200
  throw new Error(`invalid installation record JSON: ${err.message}`);
167
201
  }
168
- const record = validateInstallRecord(parsed);
202
+ const record = normalizeInstallRecord(parsed);
169
203
  // Earlier versions installed the config as a package file and recorded it. Dropping it here
170
204
  // is what stops the next update from calling the operator's edited config an orphan and
171
205
  // deleting it, and it needs no migration step: the record is rewritten on the next write.
@@ -180,8 +214,8 @@ export async function readInstallRecord(host) {
180
214
  }
181
215
 
182
216
  export async function writeInstallRecord(host, record) {
183
- validateInstallRecord(record);
184
- await fs.writeFile(installRecordPath(host), `${JSON.stringify(record, null, 2)}\n`);
217
+ const normalized = normalizeInstallRecord(record);
218
+ await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
185
219
  }
186
220
 
187
221
  export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
@@ -1,14 +1,31 @@
1
- /** Merges and removes the codex-bridge SubagentStop hook without disturbing host settings. */
1
+ /** Merges and removes named codex-bridge hooks without disturbing host settings. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
 
6
- const EVENT = 'SubagentStop';
6
+ const LEGACY_SPEC = { event: 'SubagentStop', matcher: '*' };
7
7
 
8
- function commandFor(guardPath) {
8
+ export function commandFor(guardPath) {
9
9
  return `node "${path.resolve(guardPath)}"`;
10
10
  }
11
11
 
12
+ function normalizeSpec(specOrPath) {
13
+ if (typeof specOrPath === 'string') {
14
+ return { ...LEGACY_SPEC, command: commandFor(specOrPath) };
15
+ }
16
+ if (!specOrPath || typeof specOrPath !== 'object'
17
+ || typeof specOrPath.event !== 'string'
18
+ || typeof specOrPath.matcher !== 'string'
19
+ || typeof specOrPath.command !== 'string') {
20
+ throw new TypeError('hook spec must contain event, matcher, and command strings');
21
+ }
22
+ return {
23
+ event: specOrPath.event,
24
+ matcher: specOrPath.matcher,
25
+ command: specOrPath.command,
26
+ };
27
+ }
28
+
12
29
  async function readSettings(settingsPath) {
13
30
  try {
14
31
  const raw = await fs.readFile(settingsPath, 'utf8');
@@ -27,8 +44,8 @@ async function readSettings(settingsPath) {
27
44
  }
28
45
  }
29
46
 
30
- function groups(settings) {
31
- const value = settings?.hooks?.[EVENT];
47
+ function groups(settings, event) {
48
+ const value = settings?.hooks?.[event];
32
49
  return Array.isArray(value) ? value : [];
33
50
  }
34
51
 
@@ -40,12 +57,14 @@ function groupHooks(group) {
40
57
  return Array.isArray(group?.hooks) ? group.hooks : [];
41
58
  }
42
59
 
43
- export async function inspectHook(settingsPath, guardPath) {
60
+ export async function inspectHook(settingsPath, specOrPath) {
44
61
  const state = await readSettings(settingsPath);
45
- const command = commandFor(guardPath);
46
- const present = groups(state.settings).some((group) =>
47
- groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command));
48
- return { ...state, command, present };
62
+ const spec = normalizeSpec(specOrPath);
63
+ const present = groups(state.settings, spec.event)
64
+ .filter((group) => group?.matcher === spec.matcher)
65
+ .some((group) => groupHooks(group)
66
+ .some((hook) => hook?.type === 'command' && hook.command === spec.command));
67
+ return { ...state, ...spec, present };
49
68
  }
50
69
 
51
70
  function backupName(settingsPath) {
@@ -66,35 +85,38 @@ async function atomicWrite(settingsPath, settings, previous) {
66
85
  }
67
86
  }
68
87
 
69
- export async function mergeHook(settingsPath, guardPath, inspected) {
70
- const state = inspected || await inspectHook(settingsPath, guardPath);
88
+ export async function mergeHook(settingsPath, specOrPath, inspected) {
89
+ const spec = normalizeSpec(specOrPath);
90
+ const state = inspected || await inspectHook(settingsPath, spec);
71
91
  if (state.present) return { changed: false, createdGroup: false };
72
92
  const settings = structuredClone(state.settings);
73
93
  settings.hooks ??= {};
74
- if (!Array.isArray(settings.hooks[EVENT])) settings.hooks[EVENT] = [];
75
- let group = settings.hooks[EVENT].find((entry) => entry?.matcher === '*');
94
+ if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
95
+ let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
76
96
  const createdGroup = !group;
77
97
  if (!group) {
78
- group = { matcher: '*', hooks: [] };
79
- settings.hooks[EVENT].push(group);
98
+ group = { matcher: spec.matcher, hooks: [] };
99
+ settings.hooks[spec.event].push(group);
80
100
  }
81
101
  if (!Array.isArray(group.hooks)) group.hooks = [];
82
- group.hooks.push(ownHook(state.command));
102
+ group.hooks.push(ownHook(spec.command));
83
103
  await atomicWrite(settingsPath, settings, state);
84
104
  return { changed: true, createdGroup };
85
105
  }
86
106
 
87
- export async function removeHook(settingsPath, guardPath, { createdGroup = false } = {}) {
88
- const state = await inspectHook(settingsPath, guardPath);
107
+ export async function removeHook(settingsPath, specOrPath, { createdGroup = false } = {}) {
108
+ const spec = normalizeSpec(specOrPath);
109
+ const state = await inspectHook(settingsPath, spec);
89
110
  if (!state.present) return { changed: false };
90
111
  const settings = structuredClone(state.settings);
91
- const eventGroups = groups(settings);
112
+ const eventGroups = groups(settings, spec.event);
92
113
  for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
93
114
  const group = eventGroups[index];
115
+ if (group?.matcher !== spec.matcher) continue;
94
116
  if (!Array.isArray(group?.hooks)) continue;
95
117
  group.hooks = group.hooks.filter((hook) =>
96
- !(hook?.type === 'command' && hook.command === state.command));
97
- if (createdGroup && group.matcher === '*' && group.hooks.length === 0) eventGroups.splice(index, 1);
118
+ !(hook?.type === 'command' && hook.command === spec.command));
119
+ if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
98
120
  }
99
121
  await atomicWrite(settingsPath, settings, state);
100
122
  return { changed: true };
package/cli/uninstall.mjs CHANGED
@@ -1,8 +1,13 @@
1
1
  /** Uninstalls only recorded codex-bridge files while preserving host data and foreign files. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { fileFingerprint, installRecordPath, readInstallRecord } from './manifest.mjs';
5
- import { removeHook } from './settings-merge.mjs';
4
+ import {
5
+ fileFingerprint,
6
+ HOOK_DEFINITIONS,
7
+ installRecordPath,
8
+ readInstallRecord,
9
+ } from './manifest.mjs';
10
+ import { commandFor, removeHook } from './settings-merge.mjs';
6
11
  import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
7
12
 
8
13
  async function removeEmpty(directory) {
@@ -25,6 +30,10 @@ function remainingOwnersText(count) {
25
30
  return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
26
31
  }
27
32
 
33
+ function hookDefinition(event) {
34
+ return HOOK_DEFINITIONS.find((definition) => definition.event === event);
35
+ }
36
+
28
37
  export async function uninstall({ host, dryRun = false } = {}) {
29
38
  // Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
30
39
  let registry = null;
@@ -61,14 +70,24 @@ export async function uninstall({ host, dryRun = false } = {}) {
61
70
  }
62
71
  }
63
72
  }
64
- lines.push('Would remove the SubagentStop hook and installation record.');
73
+ for (const hook of record.hooks) {
74
+ const definition = hookDefinition(hook.event);
75
+ lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
76
+ }
77
+ lines.push('Would remove the installation record.');
65
78
  lines.push(preservation);
66
79
  return { exitCode: 0, output: lines.join('\n') };
67
80
  }
68
81
 
69
- await removeHook(host.settingsPath, path.join(host.root, record.hook.path), {
70
- createdGroup: record.hook.createdGroup === true,
71
- });
82
+ for (const hook of record.hooks) {
83
+ const definition = hookDefinition(hook.event);
84
+ const target = path.join(host.root, hook.path);
85
+ await removeHook(host.settingsPath, {
86
+ event: hook.event,
87
+ matcher: definition.matcher,
88
+ command: commandFor(target),
89
+ }, { createdGroup: hook.createdGroup === true });
90
+ }
72
91
  let ownership = null;
73
92
  if (!registryError) {
74
93
  try {
package/cli/update.mjs CHANGED
@@ -5,13 +5,15 @@ import { install } from './install.mjs';
5
5
  import {
6
6
  buildInstallPlan,
7
7
  fileFingerprint,
8
+ HOOK_DEFINITIONS,
9
+ installedHookPath,
8
10
  packageInfo,
9
11
  readInstallRecord,
10
12
  recordMatchesPackage,
11
13
  rulesPlan,
12
14
  } from './manifest.mjs';
13
15
  import { targetMatches } from './copy.mjs';
14
- import { inspectHook } from './settings-merge.mjs';
16
+ import { commandFor, inspectHook } from './settings-merge.mjs';
15
17
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
16
18
 
17
19
  async function exists(target) {
@@ -37,6 +39,23 @@ function classifyOrphan(state, record) {
37
39
  return 'modified';
38
40
  }
39
41
 
42
+ function hookTargets(host) {
43
+ return HOOK_DEFINITIONS.map((definition) => {
44
+ const target = installedHookPath(host, definition);
45
+ return {
46
+ definition,
47
+ relative: path.relative(host.root, target).split(path.sep).join('/'),
48
+ spec: { event: definition.event, matcher: definition.matcher, command: commandFor(target) },
49
+ };
50
+ });
51
+ }
52
+
53
+ function recordHasHooks(record, targets) {
54
+ return Boolean(record)
55
+ && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
56
+ hook.event === definition.event && hook.path === relative));
57
+ }
58
+
40
59
  /**
41
60
  * What the run actually did, counted by outcome. `Updated codex-bridge.` alone told the operator
42
61
  * nothing about the files that were rewritten or removed on their behalf.
@@ -62,7 +81,7 @@ function conflictOutput(conflicts, legacy, dryRun) {
62
81
  return [heading, ...lines].join('\n');
63
82
  }
64
83
 
65
- function dryRunOutput(states, hookPresent, legacy) {
84
+ function dryRunOutput(states, hookStates, legacy) {
66
85
  const lines = [];
67
86
  for (const state of states) {
68
87
  if (state.status === 'outdated') lines.push(`Would update ${state.relative}.`);
@@ -72,7 +91,10 @@ function dryRunOutput(states, hookPresent, legacy) {
72
91
  if (state.status === 'orphaned' && state.exists) lines.push(`Would remove ${state.relative}.`);
73
92
  if (state.status === 'modified' && !state.item) lines.push(`Would preserve modified orphan ${state.relative}.`);
74
93
  }
75
- if (!hookPresent) lines.push('Would register SubagentStop hook.');
94
+ hookStates.forEach(({ state, target }) => {
95
+ const { definition } = target;
96
+ if (!state.present) lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher}.`);
97
+ });
76
98
  lines.push('Would write installation record with new fingerprints.');
77
99
  if (legacy && states.some((state) => state.status === 'modified')) {
78
100
  lines.push('Installation record has no fingerprints; differing files are treated as modified.');
@@ -135,19 +157,23 @@ export async function update({ host, dryRun = false, force = false, packageRoot
135
157
  return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
136
158
  }
137
159
 
138
- const guardPath = path.join(host.agentsDir, 'hooks', 'reply-guard.mjs');
139
- const inspectedHook = await inspectHook(host.settingsPath, guardPath);
160
+ const targets = hookTargets(host);
161
+ const inspectedHooks = await Promise.all(targets.map(async (target) => ({
162
+ target,
163
+ state: await inspectHook(host.settingsPath, target.spec),
164
+ })));
140
165
  const currentPackage = await packageInfo(packageRoot);
141
166
  const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
142
167
  new Map(plannedStates.map((state) => [state.relative, state.fingerprint])),
143
168
  { path: rule.target, fingerprint: ruleState.fingerprint });
144
169
  const changed = states.some((state) => state.status !== 'up-to-date');
145
- if (!changed && inspectedHook.present && recordCurrent) {
170
+ if (!changed && inspectedHooks.every(({ state }) => state.present)
171
+ && recordHasHooks(record, targets) && recordCurrent) {
146
172
  if (!dryRun) await addRulesOwner(host);
147
173
  return { exitCode: 0, output: 'codex-bridge is up to date' };
148
174
  }
149
175
  if (dryRun) {
150
- return { exitCode: 0, output: dryRunOutput(states, inspectedHook.present, legacy) };
176
+ return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy) };
151
177
  }
152
178
 
153
179
  for (const state of orphanStates) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Delegate implementation, reconnaissance and code review from Claude Code to the Codex CLI: dispatcher agents, a runner that survives a dropped call, and a verdict that checks the report against the worktree.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-build
3
- description: Имплементация ВНЕ подписки Claude — правки кода, новые модули, рефакторинг по образцу, починка тестов. Работу выполняет Codex CLI (подписка ChatGPT) с правом записи в репозиторий строго в пределах обязательного `scope`, Claude платит только за постановку задачи и ≤5 строк выжимки. Отчёт и полный лог кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. Ревью и приёмку делает отдельный агент Claude, не этот.
3
+ description: Имплементация ВНЕ подписки Claude — правки кода, новые модули, рефакторинг по образцу, починка тестов. Работу выполняет Codex CLI (подписка ChatGPT) с правом записи в репозиторий строго в пределах обязательного `scope`, Claude платит только за постановку задачи и ≤5 строк выжимки. Отчёт и полный лог кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. Ревью и приёмку делает отдельный агент Claude, не этот. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
4
4
  model: haiku
5
5
  tools: Bash
6
6
  ---
@@ -19,19 +19,19 @@ diff, and do not reason about the task.
19
19
  Your
20
20
  job is to report the run status honestly, including failure.
21
21
 
22
+ ## Required dispatcher inputs
23
+
24
+ {{CODEX_REQUIRED_INPUTS}}
25
+
22
26
  ## What you receive as input
23
27
 
24
28
  - The task statement: what to change and the completion criteria.
25
29
  - The path to the repository. If none is given, use the current working directory.
26
- - `scope: "<glob,glob>"` — REQUIRED. The list of paths Codex may touch, relative to the
27
- repository root; the runner will not start without it. If the orchestrator did not give it, do not
28
- guess:
29
- start the runner without `--scope` and return its refusal exactly.
30
- - `order id: <label>` — the label the orchestrator issued for this order. Pass it as `--order-id`
31
- exactly as given. Never invent one, never edit one, never reuse one from another order: the
32
- runner chains runs by this label, and a made-up label is how a repeat run hides. Absent means
33
- the same as an absent `--scope`: start the runner without the flag and return its refusal
34
- verbatim.
30
+ - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
31
+ `order id` as `--order-id`, `scope` as `--scope`. Never invent a value, never edit one, never
32
+ reuse an order id from another order — the runner chains runs by that label, and a made-up label
33
+ is how a repeat run hides. If the orchestrator did not give a required input, do not guess: start
34
+ the runner without its flag and return the runner's refusal exactly.
35
35
  - `continue` — pass it as the `--continue` flag only if the orchestrator gave it explicitly; do not
36
36
  add or guess it yourself. The contract is the same kind as `--scope`: if this task (the same
37
37
  `slug` and the same repository) already had a run, the runner will refuse to start without
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-review
3
- description: Независимое второе мнение по коду от другой модели — ревью незакоммиченных правок, ветки или коммита силами Codex CLI (подписка ChatGPT). Не заменяет приёмку на стороне Claude, а дополняет её взглядом со стороны. Выдаёт строгий JSON с severity и confidence по каждой находке, в чат — ≤5 строк со счётчиком по важности.
3
+ description: Независимое второе мнение по коду от другой модели — ревью незакоммиченных правок, ветки или коммита силами Codex CLI (подписка ChatGPT). Не заменяет приёмку на стороне Claude, а дополняет её взглядом со стороны. Выдаёт строгий JSON с severity и confidence по каждой находке, в чат — ≤5 строк со счётчиком по важности. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
4
4
  model: haiku
5
5
  tools: Bash
6
6
  ---
@@ -15,6 +15,10 @@ against the Claude Max quota. Claude still performs acceptance in a separate pas
15
15
  second
16
16
  opinion, not a verdict.
17
17
 
18
+ ## Required dispatcher inputs
19
+
20
+ {{CODEX_REQUIRED_INPUTS}}
21
+
18
22
  ## What you receive as input
19
23
 
20
24
  - What to review. One of these modes:
@@ -22,10 +26,11 @@ opinion, not a verdict.
22
26
  - branch against base — `--mode base:<branch>`;
23
27
  - specific commit — `--mode commit:<sha>`.
24
28
  - The path to the repository. If none is given, use the current working directory.
25
- - `order id: <label>` — the label the orchestrator issued for this order. Pass it as `--order-id`
26
- exactly as given. Never invent one, never edit one, never reuse one from another order: the
27
- runner chains runs by this label, and a made-up label is how a repeat run hides. If it was not
28
- given, start the runner without the flag and return its refusal verbatim.
29
+ - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
30
+ `order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
31
+ another order — the runner chains runs by that label, and a made-up label is how a repeat run
32
+ hides. If the orchestrator did not give a required input, do not guess — start the runner without
33
+ its flag and return the runner's refusal verbatim.
29
34
  - Optional: review focus as text ("look for races and error handling"), `slug:`, `effort:
30
35
  <none|minimal|low|medium|high|xhigh|max>`.
31
36
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-scout
3
- description: Разведка и диагностика ВНЕ подписки Claude — исследование кодовой базы, поиск причины бага, ревью, сбор фактов. Работу выполняет Codex CLI (подписка ChatGPT), Claude платит только за постановку задачи и ≤5 строк выжимки. Строго read-only: писать в репозиторий физически не может. Подробный отчёт кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь.
3
+ description: Разведка и диагностика ВНЕ подписки Claude — исследование кодовой базы, поиск причины бага, ревью, сбор фактов. Работу выполняет Codex CLI (подписка ChatGPT), Claude платит только за постановку задачи и ≤5 строк выжимки. Строго read-only: писать в репозиторий физически не может. Подробный отчёт кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
4
4
  model: haiku
5
5
  tools: Bash
6
6
  ---
@@ -13,17 +13,21 @@ ChatGPT subscription. All heavy work (reading files, reasoning, generating the r
13
13
  on the Codex side. Every extra line you send to chat costs Claude tokens. Therefore: do not read
14
14
  files, do not run grep, do not retell the report, and do not reason about the task.
15
15
 
16
+ ## Required dispatcher inputs
17
+
18
+ {{CODEX_REQUIRED_INPUTS}}
19
+
16
20
  ## What you receive as input
17
21
 
18
22
  - The task statement (what to find out / what to diagnose / what to review).
19
23
  - The path to the repository. If none is given, work in the current working directory.
20
24
  - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>` — Codex reasoning depth.
21
25
  - Optional: `slug: <short-name>` for the run folder.
22
- - `order id: <label>` — the label the orchestrator issued for this order. Pass it as `--order-id`
23
- exactly as given. Never invent one, never edit one, never reuse one from another order: the
24
- runner chains runs by this label, and a made-up label is how a repeat run hides. If the
25
- orchestrator did not give it, do not guess — start the runner without the flag and return its
26
- refusal verbatim, the same way you would with a missing `--scope` in codex-build.
26
+ - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
27
+ `order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
28
+ another order — the runner chains runs by that label, and a made-up label is how a repeat run
29
+ hides. If the orchestrator did not give a required input, do not guess — start the runner without
30
+ its flag and return the runner's refusal verbatim.
27
31
  - `question: <text>` — REQUIRED and repeatable. For every sub-question the orchestrator gave,
28
32
  pass one `--question "<text>"` flag exactly as given. Never invent, merge, reword, or drop a
29
33
  sub-question. If the orchestrator did not give one, do not guess — start the runner without
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Names the hooks this package registers in a host, and the tools they listen to.
3
+ *
4
+ * The gate and the installer would otherwise each carry their own idea of which tool starts a
5
+ * subagent, and the first host that renames it would leave a registered hook listening for a
6
+ * tool that never fires — silence indistinguishable from approval, which is the exact failure
7
+ * the gate exists to end. `cli/manifest.mjs` builds the settings matcher from SUBAGENT_TOOLS
8
+ * and `src/hooks/order-gate.mjs` recognises the same names, so a matcher can never drift away
9
+ * from what the hook actually checks.
10
+ */
11
+
12
+ /**
13
+ * Every name a Claude Code host has used for the tool that launches a subagent. Both are live:
14
+ * this package is installed into hosts of different ages, `Task` is the long-standing name and
15
+ * `Agent` the current one. A hook registered for only one of them is dead weight on the other.
16
+ */
17
+ export const SUBAGENT_TOOLS = Object.freeze(['Agent', 'Task']);
18
+
19
+ /** A PreToolUse matcher is a regular expression over tool names, so alternation covers both. */
20
+ export const SUBAGENT_TOOL_MATCHER = SUBAGENT_TOOLS.join('|');
21
+
22
+ export const HOOK_DEFINITIONS = Object.freeze([
23
+ Object.freeze({ event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
24
+ Object.freeze({ event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
25
+ ]);
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse gate for the Codex dispatchers (codex-scout / codex-build / codex-review).
4
+ *
5
+ * The runner rejects a dispatcher immediately when order id (and scope for codex-build) is
6
+ * absent, but the old requirement lived only in the prompt read by the dispatcher. The caller
7
+ * therefore had no enforced place to provide it and codex-build died before doing work. This
8
+ * gate checks the producer's task text while it can still be corrected.
9
+ *
10
+ * Input is Claude Code's hook JSON on stdin. The last payload is retained for diagnostics so a
11
+ * future host schema change can be inspected instead of guessed at. Any uncertain shape passes
12
+ * silently because a diagnostic guard must never break an unrelated tool call.
13
+ */
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { missingInputs } from '../required-inputs.mjs';
18
+ import { SUBAGENT_TOOLS } from '../hook-definitions.mjs';
19
+
20
+ const HOME = os.homedir();
21
+ const LOG_DIR = path.join(HOME, '.claude', 'logs');
22
+ const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
23
+ /**
24
+ * Both spellings of the subagent-launching tool, from the same list the installer builds its
25
+ * matcher from. Recognising only the name this host happens to use would make the gate silent
26
+ * on every other host, and a gate that is silent for an unknown reason is worse than none.
27
+ */
28
+ const SUBAGENT_TOOL_NAMES = new Set(SUBAGENT_TOOLS);
29
+
30
+ const pass = () => process.exit(0);
31
+
32
+ let input;
33
+ try {
34
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
35
+ } catch {
36
+ pass();
37
+ }
38
+
39
+ try {
40
+ fs.mkdirSync(LOG_DIR, { recursive: true });
41
+ fs.writeFileSync(path.join(LOG_DIR, 'codex-order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
42
+ } catch {
43
+ // Diagnostics are a convenience, never a reason to fail the turn.
44
+ }
45
+
46
+ if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
47
+ if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
48
+
49
+ const toolInput = input.tool_input;
50
+ if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
51
+ if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
52
+
53
+ const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
54
+ if (!missing.length) pass();
55
+
56
+ const reason = [
57
+ 'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
58
+ 'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
59
+ ...missing.map((entry) => `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`),
60
+ ].join('\n');
61
+
62
+ process.stdout.write(JSON.stringify({
63
+ hookSpecificOutput: {
64
+ hookEventName: 'PreToolUse',
65
+ permissionDecision: 'deny',
66
+ permissionDecisionReason: reason,
67
+ },
68
+ }));
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The outcome a build run declared about itself, and whether it was contracted to declare one.
3
+ *
4
+ * The runner cannot infer this. On 2026-08-04 (`2026-08-04_202959_build`) a build was asked to
5
+ * fix a function in a module absent from the checkout and answered `OK — No code change was
6
+ * made because the requested target file is absent`: every artifact was well-formed and a clean
7
+ * tree is a legitimate outcome for "check and fix if broken", so nothing in the verdict could
8
+ * object. Plan_12 moved the outcome from inference to declaration — the executor states it, the
9
+ * runner reads it.
10
+ *
11
+ * The contract marker is the run's own schema.json, not a version number written beside it. The
12
+ * schema IS the contract Codex was handed, so "was this run required to declare an outcome" and
13
+ * "what was it required to answer" cannot drift apart: they are the same file. A run from before
14
+ * this version, or a replay of an archived folder, has no `outcome` in its schema and keeps the
15
+ * verdict it always had.
16
+ */
17
+ import path from 'node:path';
18
+ import { line, readJson } from './paths.mjs';
19
+
20
+ const FIELD = 'outcome';
21
+
22
+ /** The values schemas.mjs allows; anything else in a result.json is a broken answer. */
23
+ const DONE = 'done';
24
+ const FAIL = 'fail';
25
+
26
+ /**
27
+ * Was this run handed a schema that demands an outcome? Read off the run's own schema.json,
28
+ * which the launcher writes before Codex is invoked, so the answer describes the contract this
29
+ * run actually ran under rather than the one the current source happens to define.
30
+ */
31
+ export function requiresOutcome(runDir) {
32
+ const schema = readJson(path.join(runDir, 'schema.json'));
33
+ return Array.isArray(schema?.required) && schema.required.includes(FIELD);
34
+ }
35
+
36
+ /**
37
+ * Why the declared outcome fails the run, or null when it does not. Three ways to fail: the
38
+ * executor said the work was not done; the field is missing although the schema required it
39
+ * (Codex enforces the schema, so this is a hand-edited or truncated result rather than a normal
40
+ * answer); the field holds a value the enum does not define.
41
+ *
42
+ * A declared `done` is not proof of anything — it only stops here. The tree, the scope and the
43
+ * report are checked after this, exactly as before: the field adds a reason to go red, it
44
+ * removes none.
45
+ */
46
+ export function outcomeGap(runDir, result) {
47
+ if (!requiresOutcome(runDir)) return null;
48
+ // Compared exactly, never normalised. Trimming and lower-casing would let `"DONE"` or
49
+ // `" done "` — values the enum forbids and Codex would have rejected — buy their way past
50
+ // this check, and a malformed success is exactly the shape this file exists to stop. A
51
+ // malformed failure loses nothing by the same rule: it still goes red, only by the
52
+ // unknown-value branch.
53
+ const declared = result?.[FIELD];
54
+ if (declared === DONE) return null;
55
+ if (declared === FAIL) {
56
+ // The reason comes from summary rather than a field of its own: summary is already
57
+ // mandatory and already describes what happened, and two fields about one thing drift.
58
+ const stated = line(result?.summary || '', 240);
59
+ return `the task was not done: ${stated || 'build declared fail and gave no summary'}`;
60
+ }
61
+ if (declared === undefined || declared === null || declared === '') {
62
+ return 'result is not filled in: the response contract requires outcome "done" or "fail"';
63
+ }
64
+ return `result declares an unknown outcome “${line(String(declared), 40)}”, expected "done" or "fail"`;
65
+ }
@@ -9,6 +9,7 @@
9
9
  * The lines built here ARE the reply. Agents forward this text verbatim instead of
10
10
  * composing prose, which is what keeps a delegated run at five lines.
11
11
  */
12
+ import fs from 'node:fs';
12
13
  import path from 'node:path';
13
14
  import { changedPaths, line, readText } from './paths.mjs';
14
15
  import { splitRunChanges } from './environment.mjs';
@@ -113,10 +114,32 @@ export function failReply(ctx, meta) {
113
114
  return [
114
115
  `FAIL — ${line(meta.reason, 170)}`,
115
116
  `Artifacts: raw.log ${meta.log_bytes} B · ${path.basename(ctx.resultPath)} ${meta.result_ok ? 'filled' : 'empty or missing'} · exit=${meta.exit}`,
117
+ // A failed build says what it left behind, exactly as a LIMIT does. "The work was not
118
+ // done" is not "the tree is clean": a run can write half a change and then declare fail,
119
+ // and the orchestrator has to know whether there is something to revert before it decides
120
+ // anything else.
121
+ ...(ctx.agent === 'codex-build' ? [`Worktree: ${worktreeState(ctx.runDir)}`] : []),
116
122
  `Log: ${ctx.file('raw.log')}`,
117
123
  ];
118
124
  }
119
125
 
126
+ /**
127
+ * What the run left in the tree, or an admission that nobody knows. Both snapshots are
128
+ * required: a run killed before it wrote state-after.txt has no delta to compute, and
129
+ * printing "no new changes" there would be a claim made out of missing data — the same
130
+ * mistake status.json's `tree_after: false` exists to prevent.
131
+ */
132
+ function worktreeState(runDir) {
133
+ const hasSnapshots = ['state-before.txt', 'state-after.txt'].every((f) =>
134
+ fs.existsSync(path.join(runDir, f)),
135
+ );
136
+ if (!hasSnapshots) return 'unknown — the run left no worktree snapshot, check git status';
137
+ const { work } = runChanges(runDir);
138
+ return work.length
139
+ ? `has unfinished changes (${work.length}): ${line(work.slice(0, 3).join(', '), 120)}`
140
+ : 'no new changes';
141
+ }
142
+
120
143
  export function limitReply(ctx, meta) {
121
144
  const rows = [
122
145
  'LIMIT — ChatGPT quota exhausted, work not completed',
@@ -22,6 +22,7 @@ import {
22
22
  } from './paths.mjs';
23
23
  import { chainBaseline } from './chain.mjs';
24
24
  import { splitRunChanges } from './environment.mjs';
25
+ import { outcomeGap } from './outcome.mjs';
25
26
 
26
27
  /**
27
28
  * Quota exhaustion is a transport error, not a word. Both markers must sit on the same
@@ -306,6 +307,13 @@ export function resolveStatus({ log, logBytes, resultOk, exit, agent, result, ru
306
307
  reason: `verification “${line(result.verify_command || 'no command', 60)}” failed; changes remain in the tree`,
307
308
  };
308
309
  }
310
+ // What the executor says about its own work, after verification and ahead of scope: work
311
+ // that was never done matters more than where it was not done. Only runs whose schema.json
312
+ // demanded the field are judged by it — see outcome.mjs.
313
+ if (agent === 'codex-build') {
314
+ const declared = outcomeGap(runDir, result);
315
+ if (declared) return { status: 'FAIL', reason: declared };
316
+ }
309
317
  // Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
310
318
  // only when questions.json is absent — see scoutCoverageGap().
311
319
  if (agent === 'codex-scout') {
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Defines the inputs the orchestrator must give each Codex dispatcher.
3
+ *
4
+ * This contract belongs beside the gate because the dispatcher prompt was the only place that
5
+ * named order id and scope, so the caller launched codex-build without either value and the
6
+ * runner refused the call before Codex could do any work. Pass 2 can render the same entries
7
+ * into agent instructions without creating a second list that can drift.
8
+ */
9
+
10
+ const PLACEHOLDER_VALUES = new Set(['todo', 'tbd', 'label', 'none', 'order id', 'scope', 'xxx']);
11
+
12
+ const freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
13
+
14
+ export const REQUIRED_INPUTS = Object.freeze({
15
+ 'codex-scout': freezeEntries([
16
+ {
17
+ label: 'order id',
18
+ source: 'the orchestrator',
19
+ explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
20
+ example: 'plan-13-scout-20260804',
21
+ },
22
+ ]),
23
+ 'codex-build': freezeEntries([
24
+ {
25
+ label: 'order id',
26
+ source: 'the orchestrator',
27
+ explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
28
+ example: 'plan-13-build-20260804',
29
+ },
30
+ {
31
+ label: 'scope',
32
+ source: 'the orchestrator',
33
+ explanation: 'Comma-separated globs relative to the repository root, listing every file the run may touch — including each caller of what changes, not only the file being edited. Anything outside the list fails the run.',
34
+ example: 'src/runner/**,tests/runner/**',
35
+ },
36
+ ]),
37
+ 'codex-review': freezeEntries([
38
+ {
39
+ label: 'order id',
40
+ source: 'the orchestrator',
41
+ explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
42
+ example: 'plan-13-review-20260804',
43
+ },
44
+ ]),
45
+ });
46
+
47
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+
49
+ function cleanValue(value) {
50
+ if (typeof value !== 'string') return '';
51
+ return value.trim().replace(/^([`'\"])(.*)\1$/, '$2').trim();
52
+ }
53
+
54
+ function isPlaceholder(value) {
55
+ const cleaned = cleanValue(value);
56
+ if (!cleaned) return true;
57
+ if (cleaned.startsWith('<') && cleaned.endsWith('>')) return true;
58
+ return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
59
+ }
60
+
61
+ function extractValue(promptText, label) {
62
+ const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
63
+ const flag = `--${label.replaceAll(/\s+/g, '-')}`;
64
+ const direct = new RegExp(
65
+ `(?:^|\\r?\\n)\\s*(?:[-*]\\s*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)\\s*(?::|=|—|-)\\s*([^\\r\\n]*)`,
66
+ 'im',
67
+ );
68
+ const directMatch = promptText.match(direct);
69
+ if (directMatch) return cleanValue(directMatch[1]);
70
+
71
+ const flagPattern = new RegExp(
72
+ `${escapeRegExp(flag)}(?:=|\\s+)(?:"([^"]*)"|'([^']*)'|([^\\s\\r\\n]+))`,
73
+ 'i',
74
+ );
75
+ const flagMatch = promptText.match(flagPattern);
76
+ if (!flagMatch) return null;
77
+ return cleanValue(flagMatch[1] ?? flagMatch[2] ?? flagMatch[3]);
78
+ }
79
+
80
+ /** Returns the immutable input entries for one dispatcher type. */
81
+ export function requiredInputsFor(agentType) {
82
+ return REQUIRED_INPUTS[agentType] || [];
83
+ }
84
+
85
+ /** Returns required entries whose value is absent or is still an obvious template placeholder. */
86
+ export function missingInputs(agentType, promptText) {
87
+ const prompt = typeof promptText === 'string' ? promptText : '';
88
+ return requiredInputsFor(agentType).filter((entry) => isPlaceholder(extractValue(prompt, entry.label)));
89
+ }
90
+
91
+ /** Renders the compact contract the orchestrator sees while choosing a dispatcher. */
92
+ export function renderRequiredInputSummary(agentType) {
93
+ const entries = requiredInputsFor(agentType);
94
+ if (!entries.length) return '';
95
+ const labels = entries.map((entry) => `\`${entry.label}\``);
96
+ return `Requires ${entries[0].source}-provided ${labels.join(' and ')}.`;
97
+ }
98
+
99
+ /** Renders the same contract for pass 2 agent instructions. */
100
+ export function renderRequiredInputs(agentType) {
101
+ return requiredInputsFor(agentType)
102
+ .map((entry) => `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`)
103
+ .join('\n');
104
+ }
@@ -8,12 +8,17 @@
8
8
  import path from 'node:path';
9
9
  import { AGENTS } from '../write-meta.mjs';
10
10
  import { ALLOWED_EFFORTS } from '../run-config.mjs';
11
+ import { requiredInputsFor } from '../required-inputs.mjs';
11
12
 
12
13
  export function die(message) {
13
14
  console.error(`run-codex: ${message}`);
14
15
  process.exit(2);
15
16
  }
16
17
 
18
+ function requiredInput(agentType, label) {
19
+ return requiredInputsFor(agentType).find((entry) => entry.label === label);
20
+ }
21
+
17
22
  // Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
18
23
  // anything else stops the run. The permissive reading this replaces — "not 0/false/no means
19
24
  // yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
@@ -63,10 +68,12 @@ export function parseArgs(argv) {
63
68
  if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
64
69
  opts.orderId = String(opts['order-id'] ?? '').trim();
65
70
  if (!opts.orderId) {
71
+ const orderInput = requiredInput(opts.agent, 'order id');
66
72
  die(
67
- '--order-id is required: the orchestrator issues the order label and the runner will not invent one. ' +
73
+ `--order-id is required: ${orderInput.source} supplies the ${orderInput.label}; the runner will not invent one. ` +
68
74
  'A repeat of the same order should come back with --continue. If this appeared right after a package update, ' +
69
- 'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update.',
75
+ 'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update. ' +
76
+ `Example: --order-id "${orderInput.example}". Action: get the value from ${orderInput.source}, pass it as --order-id, and retry.`,
70
77
  );
71
78
  }
72
79
  if (opts.agent === 'codex-scout') {
@@ -101,10 +108,11 @@ export function parseArgs(argv) {
101
108
  .map((p) => p.trim())
102
109
  .filter(Boolean);
103
110
  if (opts.agent === 'codex-build' && !opts.scopePatterns.length) {
111
+ const scopeInput = requiredInput(opts.agent, 'scope');
104
112
  die(
105
- '--scope is required for codex-build: comma-separated globs relative to the repo root, ' +
106
- 'e.g. --scope "packages/event-calendar/**,apps/orchestrator/src/maestro/calendar-refresh.ts". ' +
107
- 'Anything outside the list is off limits and fails the run.',
113
+ `--scope is required for codex-build: ${scopeInput.source} supplies the ${scopeInput.label}. ` +
114
+ `${scopeInput.explanation} Example: --scope "${scopeInput.example}". ` +
115
+ `Action: get the value from ${scopeInput.source}, pass it as --scope, and retry.`,
108
116
  );
109
117
  }
110
118
  return opts;
@@ -52,6 +52,15 @@ Working rules:
52
52
  cannot be done, do not pretend it is done; list it in leftovers.
53
53
  - After making changes, run verification: ${opts.verify || 'determine the command from the project'}.
54
54
  Put the command actually run in verify_command and whether it passed in verify_passed.
55
+ - Declare the outcome in outcome: "done" if the work the task asked for was carried out,
56
+ "fail" if it was not — for any reason at all, including an impossible order, a file that
57
+ does not exist, a scope that forbids the only fix, or running out of time. "done" with an
58
+ empty changes[] is correct only when the task asked you to check something and nothing
59
+ needed changing; if you could not do what was asked, "fail" is the answer even when the
60
+ worktree is clean and verification is green. This is not a self-grade: work that was done
61
+ and merely leaves follow-ups is "done" with the follow-ups in leftovers, and partial work
62
+ that satisfies the definition of done is "done" too. On "fail" the first sentence of summary
63
+ states the reason — it is what the orchestrator is shown.
55
64
  - summary and changes describe only changes in this repository for this task. Do not include
56
65
  anything unrelated in them.
57
66
  - Each change is a separate changes[] entry, exactly one file per entry. The file field contains
@@ -51,8 +51,30 @@ const BUILD_SCHEMA = {
51
51
  $schema: 'https://json-schema.org/draft/2020-12/schema',
52
52
  type: 'object',
53
53
  additionalProperties: false,
54
- required: ['summary', 'changes', 'verify_command', 'verify_passed', 'leftovers', 'report_markdown'],
54
+ required: [
55
+ 'outcome',
56
+ 'summary',
57
+ 'changes',
58
+ 'verify_command',
59
+ 'verify_passed',
60
+ 'leftovers',
61
+ 'report_markdown',
62
+ ],
55
63
  properties: {
64
+ // The outcome is declared, never inferred. On 2026-08-04 a build asked to fix a function
65
+ // in a module that does not exist answered `OK — no code change was made`: every artifact
66
+ // was well-formed, the tree was legitimately clean, and the runner has no way to know
67
+ // whether the order required an edit. A string rather than a boolean because the set of
68
+ // outcomes will grow (`blocked` is a candidate) and an enum extends without breaking the
69
+ // contract. Presence of this field in the run's own schema.json is also the marker that
70
+ // says the run was contracted to declare an outcome — see meta/outcome.mjs.
71
+ outcome: {
72
+ type: 'string',
73
+ enum: ['done', 'fail'],
74
+ description:
75
+ 'done — the work the task asked for was carried out. fail — it was not, for any ' +
76
+ 'reason at all (impossible order, missing file, blocked by scope, out of time).',
77
+ },
56
78
  summary: { type: 'string', minLength: 1 },
57
79
  changes: {
58
80
  type: 'array',