@lyupro/codex-bridge 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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,25 +19,26 @@ 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.
35
- - `continue` — pass it as the `--continue` flag only if the orchestrator gave it explicitly; do not
36
- add or guess it yourself. The contract is the same kind as `--scope`: if this task (the same
37
- `slug` and the same repository) already had a run, the runner will refuse to start without
38
- `--continue` — before
39
- starting Codex, so no quota is spent. The orchestrator decides on a repeat run; your job is to
40
- pass the flag if given and return the runner's refusal exactly if it is absent.
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
+ - `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
36
+ grant explicitly; do not add or guess it yourself. A continuation is assigned by the
37
+ orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
38
+ do not issue or invent another continuation. The contract is the same kind as `--scope`: if this
39
+ task (the same `slug` and the same repository) already had a run, the runner will refuse to start
40
+ without `--continue` — before starting Codex, so no quota is spent. If the orchestrator did not
41
+ grant a repeat, return the runner's refusal exactly.
41
42
  - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:`, `verify: <verification
42
43
  command>` (for example `npm test`, `tsc --noEmit`).
43
44
 
@@ -57,7 +58,8 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
57
58
  decides, which is the intended default — a placeholder copied from this template is refused before
58
59
  Codex starts.
59
60
 
60
- If the orchestrator gave `continue`, add the bare `--continue` flag to the command, with no value.
61
+ If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
62
+ command, with no value.
61
63
  No value
62
64
  is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
63
65
  the
@@ -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,15 @@ 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.
34
+ - `continue` is conditional — pass the `--continue` flag only when the orchestrator gave the
35
+ matching `continue:` grant explicitly; do not add or guess it yourself. A continuation is
36
+ assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
37
+ output and stop; do not issue or invent another continuation.
29
38
  - Optional: review focus as text ("look for races and error handling"), `slug:`, `effort:
30
39
  <none|minimal|low|medium|high|xhigh|max>`.
31
40
 
@@ -49,6 +58,9 @@ line. To get the verdict, run the **identical command a second time** — same `
49
58
  `--slug`, same flags. That second call does not start a second run and costs no quota: it attaches
50
59
  to the run already in flight, prints `ATTACH=<path>`, blocks until the verdict exists and prints it.
51
60
 
61
+ If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
62
+ command, with no value. If it was not given, the flag must not be present in the command at all.
63
+
52
64
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited. Interruption is no longer a
53
65
  problem worth handling: if the attaching call is killed by a time ceiling, repeat it — every repeat
54
66
  attaches to the same run. A real run takes 20-25 minutes, which is normal, not a hang. Give the
@@ -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,26 +13,31 @@ 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
30
34
  the flag and return its refusal verbatim.
31
- - `continue` — pass it as the `--continue` flag only if the orchestrator gave it explicitly; do not
32
- add or guess it yourself. The contract is strict, like `--scope` in codex-build: if this task
33
- (the same `slug` and the same repository) already had a run, the runner will refuse to start
34
- without `--continue` — before starting Codex, so no quota is spent. The orchestrator decides on a
35
- repeat run.
35
+ - `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
36
+ grant explicitly; do not add or guess it yourself. A continuation is assigned by the
37
+ orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
38
+ do not issue or invent another continuation. The contract is strict, like `--scope` in
39
+ codex-build: if this task (the same `slug` and the same repository) already had a run, the runner
40
+ will refuse to start without `--continue` — before starting Codex, so no quota is spent.
36
41
 
37
42
  ## The only thing you do
38
43
 
@@ -53,7 +58,8 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
53
58
  decides, which is the intended default — a placeholder copied from this template is refused before
54
59
  Codex starts.
55
60
 
56
- If the orchestrator gave `continue`, add the bare `--continue` flag to the command, with no value.
61
+ If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
62
+ command, with no value.
57
63
  No value
58
64
  is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
59
65
  the
@@ -0,0 +1,36 @@
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
+ /**
23
+ * File-writing tools differ across Claude Code hosts just like subagent tools do. Register every
24
+ * host spelling here so the worktree lock receives the edit before it can enter a live runner's
25
+ * before/after snapshot; a list kept only in the hook would leave one host silently unlocked.
26
+ */
27
+ export const WRITE_TOOLS = Object.freeze(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
28
+
29
+ /** A PreToolUse matcher is a regular expression over tool names, so alternation covers all four. */
30
+ export const WRITE_TOOL_MATCHER = WRITE_TOOLS.join('|');
31
+
32
+ export const HOOK_DEFINITIONS = Object.freeze([
33
+ Object.freeze({ event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
34
+ Object.freeze({ event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
35
+ Object.freeze({ event: 'PreToolUse', matcher: WRITE_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
36
+ ]);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shared, fail-open knowledge of live Codex runs.
3
+ *
4
+ * The reply guard and the worktree lock must answer the same question: a status.json is live
5
+ * only while it says `running` and its runner pid accepts signal 0. Keeping that probe and the
6
+ * directory scan here prevents the two protections from disagreeing about a run, which would
7
+ * recreate the incident where the dispatcher reported while another run still edited the tree.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ /** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
13
+ export const isPidAlive = (pid) => {
14
+ if (typeof pid !== 'number') return false;
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ } catch (err) {
19
+ return err.code === 'EPERM';
20
+ }
21
+ };
22
+
23
+ /**
24
+ * Match the package's repository comparison without resolving symlinks: Windows realpath adds
25
+ * `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
26
+ * and callers must pass rather than deny on it.
27
+ */
28
+ export function normalizePath(value) {
29
+ if (typeof value !== 'string' || !value.trim()) return null;
30
+ try {
31
+ let normalized = path.resolve(value).replaceAll('\\', '/').replace(/\/+$/, '');
32
+ if (!normalized) normalized = '/';
33
+ if (process.platform === 'win32') normalized = normalized.toLowerCase();
34
+ return normalized;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function isNonEmptyString(value) {
41
+ return typeof value === 'string' && Boolean(value.trim());
42
+ }
43
+
44
+ function recognizedStatus(status) {
45
+ return Boolean(status)
46
+ && typeof status === 'object'
47
+ && !Array.isArray(status)
48
+ && status.state === 'running'
49
+ && typeof status.pid === 'number'
50
+ && isNonEmptyString(status.agent)
51
+ && isNonEmptyString(status.slug)
52
+ && isNonEmptyString(status.repo)
53
+ && isPidAlive(status.pid);
54
+ }
55
+
56
+ function readStatus(runDir) {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(path.join(runDir, 'status.json'), 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Scan one project's direct run folders; unreadable or unfamiliar entries are ignored. */
65
+ export function liveRuns(runsDir) {
66
+ if (typeof runsDir !== 'string' || !runsDir.trim()) return [];
67
+ let entries;
68
+ try {
69
+ entries = fs.readdirSync(runsDir, { withFileTypes: true });
70
+ } catch {
71
+ return [];
72
+ }
73
+ const result = [];
74
+ for (const entry of entries) {
75
+ if (!entry.isDirectory()) continue;
76
+ const dir = path.join(runsDir, entry.name);
77
+ const status = readStatus(dir);
78
+ if (recognizedStatus(status)) result.push({ dir, status });
79
+ }
80
+ return result;
81
+ }
82
+
83
+ /** Scan every project under the configured runs root for worktree ownership. */
84
+ export function allLiveRuns(runsRoot) {
85
+ if (typeof runsRoot !== 'string' || !runsRoot.trim()) return [];
86
+ let projects;
87
+ try {
88
+ projects = fs.readdirSync(runsRoot, { withFileTypes: true });
89
+ } catch {
90
+ return [];
91
+ }
92
+ const result = [];
93
+ for (const project of projects) {
94
+ if (!project.isDirectory()) continue;
95
+ result.push(...liveRuns(path.join(runsRoot, project.name)));
96
+ }
97
+ return result;
98
+ }
@@ -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
+ }));
@@ -29,6 +29,7 @@
29
29
  import fs from 'node:fs';
30
30
  import os from 'node:os';
31
31
  import path from 'node:path';
32
+ import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
32
33
 
33
34
  const HOME = os.homedir();
34
35
  const LOG_DIR = path.join(HOME, '.claude', 'logs');
@@ -168,8 +169,9 @@ const blockState = (reason, stopReason) => {
168
169
  * verdict is the stdout of the attaching call. Matching only `RUN=` would block every honest
169
170
  * answer given under the new contract as "did not delegate at all".
170
171
  */
171
- const runMatch = reply.match(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/);
172
- const runDir = runMatch ? runMatch[1].trim().replace(/\s+started=\S*$/, '').replace(/[`"'*]+$/g, '') : null;
172
+ const cleanRunDir = (value) => value.trim().replace(/\s+started=\S*$/, '').replace(/[`"'*]+$/g, '');
173
+ const runDirs = [...reply.matchAll(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/g)].map((match) => cleanRunDir(match[1]));
174
+ const runDir = runDirs[0] || null;
173
175
 
174
176
  if (!runDir || !fs.existsSync(runDir)) {
175
177
  blockForm(
@@ -180,22 +182,6 @@ if (!runDir || !fs.existsSync(runDir)) {
180
182
  );
181
183
  }
182
184
 
183
- /**
184
- * Best-effort liveness probe: signal 0 sends nothing, it only tests that the pid exists.
185
- * EPERM means the process is there and owned by someone else — alive for our purposes, and
186
- * the same reading write-meta.mjs uses. Two answers to "is this run still going" would be
187
- * one answer too many.
188
- */
189
- const isPidAlive = (pid) => {
190
- if (typeof pid !== 'number') return false;
191
- try {
192
- process.kill(pid, 0);
193
- return true;
194
- } catch (err) {
195
- return err.code === 'EPERM';
196
- }
197
- };
198
-
199
185
  /**
200
186
  * The text the operator reads when the state budget is gone. Everything in it is quoted
201
187
  * from status.json — the operator is being told to stop, and a stop justified by guesswork
@@ -203,12 +189,12 @@ const isPidAlive = (pid) => {
203
189
  */
204
190
  const fact = (value) => (value === undefined || value === null || value === '' ? 'not recorded' : String(value));
205
191
 
206
- const stopText = (headline, observed, runStatus) =>
192
+ const stopText = (headline, observed, runStatus, folder = runDir) =>
207
193
  [
208
194
  headline,
209
195
  `Run: slug ${fact(runStatus?.slug)}, agent ${fact(runStatus?.agent)}, repository ` +
210
196
  `${fact(runStatus?.repo)}, started ${fact(runStatus?.started_at)}.`,
211
- `Run folder: ${runDir}. Read state from ${path.join(runDir, 'status.json')}; the verdict ` +
197
+ `Run folder: ${folder}. Read state from ${path.join(folder, 'status.json')}; the verdict ` +
212
198
  'will appear in the adjacent meta.json.',
213
199
  observed,
214
200
  `Worktree ${fact(runStatus?.repo)} is busy with this run: do not build, test, or commit until ` +
@@ -220,6 +206,43 @@ const stopText = (headline, observed, runStatus) =>
220
206
  'command with the same --order-id attaches to this run rather than starting another.',
221
207
  ].join(' ');
222
208
 
209
+ /**
210
+ * The dispatcher must name every WRITING run the project has live, not just the one it chose to
211
+ * quote. The sibling scan is disk-only, so silence cannot hide a second runner; a scan failure
212
+ * remains fail-open because an uncertain diagnostic must never deny unrelated work.
213
+ *
214
+ * Only codex-build counts. Scout and review run in a read-only sandbox and leave the worktree
215
+ * alone, and launching them alongside other work is deliberate practice here — blocking a reply
216
+ * over a live reader would spend the state budget, and possibly the session, on a run that
217
+ * threatens nothing. What the 2026-08-05 incident cost was a hidden WRITER: the orchestrator
218
+ * edited files that landed in its before/after snapshot.
219
+ */
220
+ const namedRunDirs = new Set(runDirs.map((dir) => normalizePath(dir)).filter(Boolean));
221
+ let silentLiveSibling = null;
222
+ try {
223
+ silentLiveSibling = liveRuns(path.dirname(runDir)).find((run) => {
224
+ if (run.status.agent !== 'codex-build') return false;
225
+ const normalized = normalizePath(run.dir);
226
+ return normalized && !namedRunDirs.has(normalized);
227
+ });
228
+ } catch {
229
+ silentLiveSibling = null;
230
+ }
231
+ if (silentLiveSibling) {
232
+ const { dir, status } = silentLiveSibling;
233
+ blockState(
234
+ `Contract violated: the response names ${runDir} but omits live run folder ${dir}; report every `
235
+ + `live run with its agent ${status.agent}, slug ${status.slug}, and repository ${status.repo}.`,
236
+ stopText(
237
+ `The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} times `
238
+ + 'while a live sibling Codex run was omitted from its reply.',
239
+ `status.json in ${dir} says state=running; process pid ${fact(status.pid)} is alive.`,
240
+ status,
241
+ dir,
242
+ ),
243
+ );
244
+ }
245
+
223
246
  /**
224
247
  * status.json is written by run-codex.mjs before meta.json exists, so it catches the
225
248
  * dispatcher that reports before its own run is done — meta.json alone cannot, because a
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse lock for file-writing tools in the Claude Code host.
4
+ *
5
+ * A live codex-build runner snapshots its repository before and after editing. Letting the
6
+ * orchestrator write during that window folds unrelated work into the runner's result, exactly
7
+ * as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
8
+ * build run's repository; every malformed or unreadable input passes so diagnostics never break
9
+ * unrelated host work.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { WRITE_TOOLS } from '../hook-definitions.mjs';
14
+ import { runsRoot } from '../runner/runs-root.mjs';
15
+ import { allLiveRuns, normalizePath } from './live-runs.mjs';
16
+
17
+ const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
18
+ const pass = () => process.exit(0);
19
+
20
+ let input;
21
+ try {
22
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
23
+ } catch {
24
+ pass();
25
+ }
26
+
27
+ if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
28
+ if (!WRITE_TOOL_NAMES.has(input.tool_name)) pass();
29
+
30
+ const toolInput = input.tool_input;
31
+ if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
32
+ const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
33
+ const rawPath = toolInput[pathField];
34
+ if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
35
+
36
+ let targetPath;
37
+ try {
38
+ if (path.isAbsolute(rawPath)) targetPath = rawPath;
39
+ else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
40
+ else pass();
41
+ } catch {
42
+ pass();
43
+ }
44
+
45
+ const target = normalizePath(targetPath);
46
+ if (!target) pass();
47
+
48
+ let liveRuns;
49
+ try {
50
+ liveRuns = allLiveRuns(runsRoot());
51
+ } catch {
52
+ pass();
53
+ }
54
+ const owner = liveRuns.find(({ status }) => {
55
+ if (status.agent !== 'codex-build') return false;
56
+ const repository = normalizePath(status.repo);
57
+ return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
58
+ });
59
+ if (!owner) pass();
60
+
61
+ const { status, dir } = owner;
62
+ process.stdout.write(JSON.stringify({
63
+ hookSpecificOutput: {
64
+ hookEventName: 'PreToolUse',
65
+ permissionDecision: 'deny',
66
+ permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
67
+ + `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
68
+ + 'Wait for status.json to change state before editing this repository.',
69
+ },
70
+ }));
@@ -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
+ }