@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/CHANGELOG.md +26 -0
- package/README.md +2 -1
- package/cli/doctor.mjs +37 -35
- package/cli/install.mjs +50 -16
- package/cli/manifest.mjs +49 -13
- package/cli/settings-merge.mjs +58 -22
- package/cli/uninstall.mjs +25 -6
- package/cli/update.mjs +33 -7
- package/package.json +1 -1
- package/src/agents/codex-build.md +19 -17
- package/src/agents/codex-review.md +17 -5
- package/src/agents/codex-scout.md +18 -12
- package/src/hook-definitions.mjs +36 -0
- package/src/hooks/live-runs.mjs +98 -0
- package/src/hooks/order-gate.mjs +68 -0
- package/src/hooks/reply-guard.mjs +43 -20
- package/src/hooks/worktree-lock.mjs +70 -0
- package/src/meta/outcome.mjs +65 -0
- package/src/meta/reply.mjs +23 -0
- package/src/meta/verdict.mjs +8 -0
- package/src/required-inputs.mjs +147 -0
- package/src/runner/args.mjs +13 -5
- package/src/runner/continuation.mjs +107 -0
- package/src/runner/launcher.mjs +12 -40
- package/src/runner/prompts.mjs +9 -0
- package/src/runner/schemas.mjs +23 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
-
|
|
26
|
-
|
|
27
|
-
runner chains runs by
|
|
28
|
-
|
|
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
|
-
-
|
|
23
|
-
|
|
24
|
-
runner chains runs by
|
|
25
|
-
orchestrator did not give
|
|
26
|
-
|
|
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
|
|
32
|
-
add or guess it yourself.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
172
|
-
const
|
|
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: ${
|
|
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
|
+
}
|