@lyupro/codex-bridge 0.1.1 → 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/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,7 +30,19 @@ 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 } = {}) {
38
+ // Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
39
+ let registry = null;
40
+ let registryError = null;
41
+ try {
42
+ registry = await readRulesRegistry(host);
43
+ } catch (err) {
44
+ registryError = err;
45
+ }
29
46
  const record = await readInstallRecord(host);
30
47
  const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
31
48
  + `configuration in ${path.join(host.agentsDir, 'run-config.json')} are preserved.`;
@@ -34,38 +51,68 @@ export async function uninstall({ host, dryRun = false } = {}) {
34
51
  if (dryRun) {
35
52
  const lines = record.files.map((file) => `Would remove ${file}`);
36
53
  if (record.rules) {
37
- const registry = await readRulesRegistry(host);
38
- const remainingOwners = remainingRulesOwners(registry, host);
39
- const currentFingerprint = await fileFingerprint(record.rules.path);
40
- if (remainingOwners?.length) {
41
- lines.push(`Would leave ${record.rules.path} because ${remainingOwnersText(remainingOwners.length)}.`);
42
- } else if (currentFingerprint === record.rules.fingerprint) {
43
- lines.push(`Would remove ${record.rules.path}; no other owners remain and its fingerprint is unchanged.`);
44
- } else if (currentFingerprint !== null) {
45
- lines.push(`Would leave ${record.rules.path} because its contents changed after installation.`);
54
+ if (registryError) {
55
+ lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
46
56
  } else {
47
- lines.push(`Would leave ${record.rules.path} because it is already absent.`);
57
+ const remainingOwners = remainingRulesOwners(registry, host);
58
+ const currentFingerprint = await fileFingerprint(record.rules.path);
59
+ if (remainingOwners?.length) {
60
+ lines.push(`Would leave ${record.rules.path} because ${remainingOwnersText(remainingOwners.length)}.`);
61
+ } else if (currentFingerprint === record.rules.fingerprint) {
62
+ lines.push(`Would remove ${record.rules.path}; no other owners remain and its fingerprint is unchanged.`);
63
+ } else if (currentFingerprint !== null) {
64
+ lines.push(`Would leave ${record.rules.path} because its contents changed after installation.`);
65
+ } else {
66
+ lines.push(`Would leave ${record.rules.path} because it is already absent.`);
67
+ }
68
+ if (!registry) {
69
+ lines.push(`Warning: the rules ownership registry was missing; other installations may use ${record.rules.path}.`);
70
+ }
48
71
  }
49
72
  }
50
- 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.');
51
78
  lines.push(preservation);
52
79
  return { exitCode: 0, output: lines.join('\n') };
53
80
  }
54
81
 
55
- await removeHook(host.settingsPath, path.join(host.root, record.hook.path), {
56
- createdGroup: record.hook.createdGroup === true,
57
- });
58
- const ownership = await removeRulesOwner(host);
59
- let rulesOutput;
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
+ }
91
+ let ownership = null;
92
+ if (!registryError) {
93
+ try {
94
+ ownership = await removeRulesOwner(host);
95
+ } catch (err) {
96
+ registryError = err;
97
+ }
98
+ }
99
+ const rulesOutput = [];
60
100
  if (record.rules) {
61
- if (ownership?.owners.length) {
62
- rulesOutput = `Left ${record.rules.path} because ${remainingOwnersText(ownership.owners.length)}.`;
101
+ if (registryError) {
102
+ rulesOutput.push(`Left ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
63
103
  } else {
64
- const currentFingerprint = await fileFingerprint(record.rules.path);
65
- if (currentFingerprint === record.rules.fingerprint) {
66
- await fs.rm(record.rules.path, { force: true });
67
- } else if (currentFingerprint !== null) {
68
- rulesOutput = `Left ${record.rules.path} because its contents changed after installation.`;
104
+ if (ownership?.owners.length) {
105
+ rulesOutput.push(`Left ${record.rules.path} because ${remainingOwnersText(ownership.owners.length)}.`);
106
+ } else {
107
+ const currentFingerprint = await fileFingerprint(record.rules.path);
108
+ if (currentFingerprint === record.rules.fingerprint) {
109
+ await fs.rm(record.rules.path, { force: true });
110
+ } else if (currentFingerprint !== null) {
111
+ rulesOutput.push(`Left ${record.rules.path} because its contents changed after installation.`);
112
+ }
113
+ }
114
+ if (!registry) {
115
+ rulesOutput.push(`Warning: the rules ownership registry was missing; other installations may use ${record.rules.path}.`);
69
116
  }
70
117
  }
71
118
  }
@@ -80,6 +127,6 @@ export async function uninstall({ host, dryRun = false } = {}) {
80
127
  await removeEmpty(host.agentsDir);
81
128
  return {
82
129
  exitCode: 0,
83
- output: [`Uninstalled codex-bridge.`, rulesOutput, preservation].filter(Boolean).join('\n'),
130
+ output: [`Uninstalled codex-bridge.`, ...rulesOutput, preservation].join('\n'),
84
131
  };
85
132
  }
package/cli/update.mjs CHANGED
@@ -5,14 +5,16 @@ 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';
15
- import { addRulesOwner } from './rules-owners.mjs';
16
+ import { commandFor, inspectHook } from './settings-merge.mjs';
17
+ import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
16
18
 
17
19
  async function exists(target) {
18
20
  try {
@@ -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.');
@@ -81,6 +103,7 @@ function dryRunOutput(states, hookPresent, legacy) {
81
103
  }
82
104
 
83
105
  export async function update({ host, dryRun = false, force = false, packageRoot } = {}) {
106
+ await readRulesRegistry(host);
84
107
  const record = await readInstallRecord(host);
85
108
  if (!record) {
86
109
  return { exitCode: 1, output: 'codex-bridge is not installed. Run codex-bridge install first.' };
@@ -134,19 +157,23 @@ export async function update({ host, dryRun = false, force = false, packageRoot
134
157
  return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
135
158
  }
136
159
 
137
- const guardPath = path.join(host.agentsDir, 'hooks', 'reply-guard.mjs');
138
- 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
+ })));
139
165
  const currentPackage = await packageInfo(packageRoot);
140
166
  const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
141
167
  new Map(plannedStates.map((state) => [state.relative, state.fingerprint])),
142
168
  { path: rule.target, fingerprint: ruleState.fingerprint });
143
169
  const changed = states.some((state) => state.status !== 'up-to-date');
144
- if (!changed && inspectedHook.present && recordCurrent) {
170
+ if (!changed && inspectedHooks.every(({ state }) => state.present)
171
+ && recordHasHooks(record, targets) && recordCurrent) {
145
172
  if (!dryRun) await addRulesOwner(host);
146
173
  return { exitCode: 0, output: 'codex-bridge is up to date' };
147
174
  }
148
175
  if (dryRun) {
149
- return { exitCode: 0, output: dryRunOutput(states, inspectedHook.present, legacy) };
176
+ return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy) };
150
177
  }
151
178
 
152
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.1",
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') {