@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/CHANGELOG.md +21 -0
- package/README.md +2 -1
- package/cli/doctor.mjs +39 -36
- package/cli/install.mjs +57 -18
- package/cli/manifest.mjs +47 -13
- package/cli/rules-owners.mjs +93 -22
- package/cli/settings-merge.mjs +44 -22
- package/cli/uninstall.mjs +73 -26
- package/cli/update.mjs +35 -8
- package/package.json +1 -1
- package/src/agents/codex-build.md +10 -10
- package/src/agents/codex-review.md +10 -5
- package/src/agents/codex-scout.md +10 -6
- package/src/hook-definitions.mjs +25 -0
- package/src/hooks/order-gate.mjs +68 -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 +104 -0
- package/src/runner/args.mjs +13 -5
- package/src/runner/prompts.mjs +9 -0
- package/src/runner/schemas.mjs +23 -1
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 {
|
|
5
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 (
|
|
62
|
-
rulesOutput
|
|
101
|
+
if (registryError) {
|
|
102
|
+
rulesOutput.push(`Left ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
|
|
63
103
|
} else {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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].
|
|
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,
|
|
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
|
-
|
|
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
|
|
138
|
-
const
|
|
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 &&
|
|
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,
|
|
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.
|
|
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
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
@@ -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
|
+
}
|
package/src/meta/reply.mjs
CHANGED
|
@@ -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',
|
package/src/meta/verdict.mjs
CHANGED
|
@@ -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') {
|