@lyupro/codex-bridge 0.4.0 → 0.5.1
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 +63 -0
- package/README.md +112 -96
- package/bin/codex-bridge.mjs +11 -7
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +56 -12
- package/cli/hook-targets.mjs +39 -0
- package/cli/hook.mjs +34 -0
- package/cli/hosts.mjs +28 -4
- package/cli/install-record.mjs +287 -0
- package/cli/install.mjs +67 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +88 -173
- package/cli/projects.mjs +20 -5
- package/cli/remove-layout.mjs +59 -0
- package/cli/rules-owners.mjs +24 -2
- package/cli/settings-merge.mjs +91 -7
- package/cli/uninstall.mjs +47 -32
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +15 -13
- package/src/agents/codex-review.md +13 -9
- package/src/agents/codex-scout.md +14 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +20 -10
- package/src/hooks/reply-guard.mjs +1 -58
- package/src/hooks/stop-guard.mjs +71 -0
- package/src/runner/args.mjs +10 -1
- package/src/runner/attach.mjs +9 -2
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +15 -13
- package/src/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +4 -0
package/cli/uninstall.mjs
CHANGED
|
@@ -1,31 +1,27 @@
|
|
|
1
|
-
/** Uninstalls only recorded
|
|
1
|
+
/** Uninstalls only recorded files from both roots while preserving host data and foreign files. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {
|
|
5
5
|
definitionForRecordedHook,
|
|
6
6
|
fileFingerprint,
|
|
7
7
|
installRecordPath,
|
|
8
|
+
legacyInstallRecordPath,
|
|
8
9
|
readInstallRecord,
|
|
10
|
+
recordTarget,
|
|
9
11
|
} from './manifest.mjs';
|
|
10
12
|
import { removePermissionRules } from './permissions.mjs';
|
|
11
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
commandFor,
|
|
15
|
+
removeHook,
|
|
16
|
+
withSettingsRun,
|
|
17
|
+
} from './settings-merge.mjs';
|
|
12
18
|
import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function removeEmptyParents(target, boundary) {
|
|
23
|
-
let current = path.dirname(target);
|
|
24
|
-
while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
|
|
25
|
-
await removeEmpty(current);
|
|
26
|
-
current = path.dirname(current);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
19
|
+
import {
|
|
20
|
+
claudeBoundary,
|
|
21
|
+
removeEmpty,
|
|
22
|
+
removeEmptyLayout,
|
|
23
|
+
removeEmptyParents,
|
|
24
|
+
} from './remove-layout.mjs';
|
|
29
25
|
|
|
30
26
|
function remainingOwnersText(count) {
|
|
31
27
|
return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
|
|
@@ -37,6 +33,23 @@ function permissionOutput(host, removed, dryRun) {
|
|
|
37
33
|
return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
|
|
38
34
|
}
|
|
39
35
|
|
|
36
|
+
function displayFile(file) {
|
|
37
|
+
return `${file.root}/${file.path}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hookRemovalSpec(host, hook) {
|
|
41
|
+
const definition = definitionForRecordedHook(hook);
|
|
42
|
+
const target = recordTarget(host, hook);
|
|
43
|
+
const full = commandFor(target);
|
|
44
|
+
const short = `codex-bridge hook ${definition.name}`;
|
|
45
|
+
return {
|
|
46
|
+
event: hook.event,
|
|
47
|
+
matcher: definition.matcher,
|
|
48
|
+
command: hook.command || full,
|
|
49
|
+
alternateCommands: [full, short],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
41
54
|
// Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
|
|
42
55
|
let registry = null;
|
|
@@ -50,13 +63,13 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
|
50
63
|
const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
|
|
51
64
|
const record = await readInstallRecord(host);
|
|
52
65
|
const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
|
|
53
|
-
+ `configuration in ${
|
|
66
|
+
+ `configuration in ${host.brandConfigPath} are preserved.`;
|
|
54
67
|
if (!record) {
|
|
55
68
|
return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
if (dryRun) {
|
|
59
|
-
const lines = [permissionLine, ...record.files.map((file) => `Would remove ${file}`)];
|
|
72
|
+
const lines = [permissionLine, ...record.files.map((file) => `Would remove ${displayFile(file)}`)];
|
|
60
73
|
if (record.rules) {
|
|
61
74
|
if (registryError) {
|
|
62
75
|
lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
|
|
@@ -81,19 +94,15 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
|
81
94
|
const definition = definitionForRecordedHook(hook);
|
|
82
95
|
lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
|
|
83
96
|
}
|
|
84
|
-
lines.push('Would remove the installation record.');
|
|
97
|
+
lines.push('Would remove the installation record from the brand root.');
|
|
85
98
|
lines.push(preservation);
|
|
86
99
|
return { exitCode: 0, output: lines.join('\n') };
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
for (const hook of record.hooks) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
event: hook.event,
|
|
94
|
-
matcher: definition.matcher,
|
|
95
|
-
command: commandFor(target),
|
|
96
|
-
}, { createdGroup: hook.createdGroup === true });
|
|
103
|
+
await removeHook(host.settingsPath, hookRemovalSpec(host, hook), {
|
|
104
|
+
createdGroup: hook.createdGroup === true,
|
|
105
|
+
});
|
|
97
106
|
}
|
|
98
107
|
let ownership = null;
|
|
99
108
|
if (!registryError) {
|
|
@@ -123,18 +132,24 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
|
123
132
|
}
|
|
124
133
|
}
|
|
125
134
|
}
|
|
126
|
-
for (const
|
|
127
|
-
const target =
|
|
135
|
+
for (const file of record.files) {
|
|
136
|
+
const target = recordTarget(host, file);
|
|
128
137
|
await fs.rm(target, { force: true });
|
|
129
|
-
const boundary =
|
|
138
|
+
const boundary = file.root === 'brand'
|
|
139
|
+
? host.brandRoot
|
|
140
|
+
: claudeBoundary(host, target);
|
|
130
141
|
await removeEmptyParents(target, boundary);
|
|
131
142
|
}
|
|
132
143
|
await removeEmpty(host.commandsDir);
|
|
133
144
|
await fs.rm(installRecordPath(host), { force: true });
|
|
145
|
+
await fs.rm(legacyInstallRecordPath(host), { force: true });
|
|
134
146
|
await removeEmpty(host.agentsDir);
|
|
147
|
+
await removeEmptyLayout(host.legacyAgentsDir);
|
|
148
|
+
await removeEmptyLayout(host.legacyCommandsDir);
|
|
149
|
+
await removeEmpty(host.brandRoot);
|
|
135
150
|
return {
|
|
136
151
|
exitCode: 0,
|
|
137
|
-
output: [
|
|
152
|
+
output: ['Uninstalled codex-bridge.', permissionLine, ...rulesOutput, preservation].join('\n'),
|
|
138
153
|
};
|
|
139
154
|
}
|
|
140
155
|
|
package/cli/update.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Updates a recorded
|
|
1
|
+
/** Updates a recorded installation without overwriting unapproved host changes. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { install } from './install.mjs';
|
|
@@ -7,14 +7,26 @@ import {
|
|
|
7
7
|
fileFingerprint,
|
|
8
8
|
HOOK_DEFINITIONS,
|
|
9
9
|
installedHookPath,
|
|
10
|
+
legacyInstallRecordPath,
|
|
10
11
|
packageInfo,
|
|
12
|
+
recordFileKey,
|
|
11
13
|
readInstallRecord,
|
|
12
14
|
recordMatchesPackage,
|
|
15
|
+
recordTarget,
|
|
13
16
|
rulesPlan,
|
|
14
17
|
} from './manifest.mjs';
|
|
15
18
|
import { targetMatches } from './copy.mjs';
|
|
16
|
-
import {
|
|
19
|
+
import { hookTargets } from './hook-targets.mjs';
|
|
20
|
+
import { definitionForRecordedHook, fileEntry, fingerprintFor } from './install-record.mjs';
|
|
21
|
+
import {
|
|
22
|
+
commandFor,
|
|
23
|
+
hookRegistration,
|
|
24
|
+
inspectHook,
|
|
25
|
+
removeHook,
|
|
26
|
+
withSettingsRun,
|
|
27
|
+
} from './settings-merge.mjs';
|
|
17
28
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
29
|
+
import { claudeBoundary, removeEmptyLayout, removeEmptyParents } from './remove-layout.mjs';
|
|
18
30
|
|
|
19
31
|
async function exists(target) {
|
|
20
32
|
try {
|
|
@@ -25,7 +37,19 @@ async function exists(target) {
|
|
|
25
37
|
}
|
|
26
38
|
}
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
/** Drops the previous layout's record and its emptied directories; safe to run when both are gone. */
|
|
41
|
+
async function retireLegacyLayout(host) {
|
|
42
|
+
await fs.rm(legacyInstallRecordPath(host), { force: true });
|
|
43
|
+
await removeEmptyLayout(host.legacyAgentsDir);
|
|
44
|
+
await removeEmptyLayout(host.legacyCommandsDir);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function displayFile(file) {
|
|
48
|
+
const entry = fileEntry(file);
|
|
49
|
+
return `${entry.root}/${entry.path}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function classifyPlanned(state) {
|
|
29
53
|
if (!state.recorded) return 'new';
|
|
30
54
|
if (!state.exists) return 'missing';
|
|
31
55
|
if (state.matchesPackage) return 'up-to-date';
|
|
@@ -33,40 +57,24 @@ function classifyPlanned(state, record) {
|
|
|
33
57
|
return 'modified';
|
|
34
58
|
}
|
|
35
59
|
|
|
36
|
-
function classifyOrphan(state
|
|
60
|
+
function classifyOrphan(state) {
|
|
37
61
|
if (!state.exists) return 'orphaned';
|
|
38
|
-
if (
|
|
62
|
+
if (state.recordedFingerprint === state.fingerprint) return 'orphaned';
|
|
39
63
|
return 'modified';
|
|
40
64
|
}
|
|
41
65
|
|
|
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
66
|
function recordHasHooks(record, targets) {
|
|
54
67
|
return Boolean(record)
|
|
55
68
|
&& targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
|
|
56
|
-
hook.event === definition.event && hook.path === relative));
|
|
69
|
+
hook.event === definition.event && hook.root === 'brand' && hook.path === relative));
|
|
57
70
|
}
|
|
58
71
|
|
|
59
|
-
/**
|
|
60
|
-
* What the run actually did, counted by outcome. `Updated codex-bridge.` alone told the operator
|
|
61
|
-
* nothing about the files that were rewritten or removed on their behalf.
|
|
62
|
-
*/
|
|
63
72
|
function appliedOutput(states) {
|
|
64
73
|
const count = (status) => states.filter((state) => state.status === status).length;
|
|
65
74
|
const parts = [
|
|
66
75
|
[count('outdated'), 'updated'],
|
|
67
76
|
[count('new'), 'added'],
|
|
68
77
|
[count('missing'), 'restored'],
|
|
69
|
-
// A modified orphan is preserved rather than rewritten, so it is not counted here.
|
|
70
78
|
[states.filter((state) => state.status === 'modified' && state.item).length, 'overwritten'],
|
|
71
79
|
[states.filter((state) => state.status === 'orphaned' && state.exists).length, 'removed'],
|
|
72
80
|
].filter(([total]) => total > 0).map(([total, label]) => `${total} ${label}`);
|
|
@@ -81,7 +89,7 @@ function conflictOutput(conflicts, legacy, dryRun) {
|
|
|
81
89
|
return [heading, ...lines].join('\n');
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
function dryRunOutput(states, hookStates, legacy) {
|
|
92
|
+
function dryRunOutput(states, hookStates, legacy, oldHooks) {
|
|
85
93
|
const lines = [];
|
|
86
94
|
for (const state of states) {
|
|
87
95
|
if (state.status === 'outdated') lines.push(`Would update ${state.relative}.`);
|
|
@@ -91,18 +99,47 @@ function dryRunOutput(states, hookStates, legacy) {
|
|
|
91
99
|
if (state.status === 'orphaned' && state.exists) lines.push(`Would remove ${state.relative}.`);
|
|
92
100
|
if (state.status === 'modified' && !state.item) lines.push(`Would preserve modified orphan ${state.relative}.`);
|
|
93
101
|
}
|
|
102
|
+
// previousHooks() returns {hook, definition, spec}, and reading .event off that wrapper printed
|
|
103
|
+
// "the previous undefined hook registration" five times during the Plan_25 migration — the one
|
|
104
|
+
// dry run where the operator most needed to see which registrations were about to be taken away.
|
|
105
|
+
oldHooks.forEach(({ hook, definition }) => lines.push(
|
|
106
|
+
`Would remove the previous ${hook.event} hook registration for matcher ${definition.matcher}.`,
|
|
107
|
+
));
|
|
94
108
|
hookStates.forEach(({ state, target }) => {
|
|
95
|
-
const { definition } = target;
|
|
96
|
-
if (!state.present)
|
|
109
|
+
const { definition, registration } = target;
|
|
110
|
+
if (!state.present) {
|
|
111
|
+
lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
|
|
112
|
+
}
|
|
97
113
|
});
|
|
98
|
-
lines.push('Would write installation record with new fingerprints.');
|
|
114
|
+
lines.push('Would write installation record with new fingerprints in both roots.');
|
|
99
115
|
if (legacy && states.some((state) => state.status === 'modified')) {
|
|
100
116
|
lines.push('Installation record has no fingerprints; differing files are treated as modified.');
|
|
101
117
|
}
|
|
102
118
|
return lines.join('\n');
|
|
103
119
|
}
|
|
104
120
|
|
|
105
|
-
|
|
121
|
+
function previousHooks(host, record, targets) {
|
|
122
|
+
return record.hooks.filter((hook) => {
|
|
123
|
+
const current = targets.find(({ definition, relative }) =>
|
|
124
|
+
definition.event === hook.event && hook.root === 'brand' && relative === hook.path);
|
|
125
|
+
return !current;
|
|
126
|
+
}).map((hook) => {
|
|
127
|
+
const definition = definitionForRecordedHook(hook);
|
|
128
|
+
const target = recordTarget(host, hook);
|
|
129
|
+
return {
|
|
130
|
+
hook,
|
|
131
|
+
definition,
|
|
132
|
+
spec: {
|
|
133
|
+
event: hook.event,
|
|
134
|
+
matcher: definition.matcher,
|
|
135
|
+
command: hook.command || commandFor(target),
|
|
136
|
+
alternateCommands: [commandFor(target), `codex-bridge hook ${definition.name}`],
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function updateInRun({ host, dryRun = false, force = false, packageRoot, env = process.env } = {}) {
|
|
106
143
|
await readRulesRegistry(host);
|
|
107
144
|
const record = await readInstallRecord(host);
|
|
108
145
|
if (!record) {
|
|
@@ -111,20 +148,20 @@ export async function update({ host, dryRun = false, force = false, packageRoot
|
|
|
111
148
|
|
|
112
149
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
113
150
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
114
|
-
const planned = new Map(plan.map((item) => [item
|
|
151
|
+
const planned = new Map(plan.map((item) => [recordFileKey(item), item]));
|
|
115
152
|
const plannedStates = await Promise.all(plan.map(async (item) => {
|
|
116
|
-
const relative = item
|
|
153
|
+
const relative = displayFile(item);
|
|
117
154
|
const targetExists = await exists(item.target);
|
|
118
155
|
const state = {
|
|
119
156
|
relative,
|
|
120
157
|
item,
|
|
121
|
-
recorded: record.files.
|
|
122
|
-
recordedFingerprint: record
|
|
158
|
+
recorded: record.files.some((file) => recordFileKey(file) === recordFileKey(item)),
|
|
159
|
+
recordedFingerprint: fingerprintFor(record, item),
|
|
123
160
|
exists: targetExists,
|
|
124
|
-
matchesPackage: targetExists && await targetMatches(item, host.
|
|
161
|
+
matchesPackage: targetExists && await targetMatches(item, host.brandRoot),
|
|
125
162
|
fingerprint: targetExists ? await fileFingerprint(item.target) : null,
|
|
126
163
|
};
|
|
127
|
-
return { ...state, status: classifyPlanned(state
|
|
164
|
+
return { ...state, status: classifyPlanned(state) };
|
|
128
165
|
}));
|
|
129
166
|
const ruleExists = await exists(rule.target);
|
|
130
167
|
const ruleState = {
|
|
@@ -133,22 +170,24 @@ export async function update({ host, dryRun = false, force = false, packageRoot
|
|
|
133
170
|
recorded: Boolean(record.rules),
|
|
134
171
|
recordedFingerprint: record.rules?.fingerprint,
|
|
135
172
|
exists: ruleExists,
|
|
136
|
-
matchesPackage: ruleExists && await targetMatches(rule, host.
|
|
173
|
+
matchesPackage: ruleExists && await targetMatches(rule, host.brandRoot),
|
|
137
174
|
fingerprint: ruleExists ? await fileFingerprint(rule.target) : null,
|
|
138
175
|
};
|
|
139
|
-
ruleState.status = classifyPlanned(ruleState
|
|
176
|
+
ruleState.status = classifyPlanned(ruleState);
|
|
140
177
|
const orphanStates = await Promise.all(record.files
|
|
141
|
-
.filter((
|
|
142
|
-
.map(async (
|
|
143
|
-
const target =
|
|
178
|
+
.filter((file) => !planned.has(recordFileKey(file)))
|
|
179
|
+
.map(async (file) => {
|
|
180
|
+
const target = recordTarget(host, file);
|
|
144
181
|
const targetExists = await exists(target);
|
|
145
182
|
const state = {
|
|
146
|
-
relative,
|
|
183
|
+
relative: displayFile(file),
|
|
184
|
+
entry: fileEntry(file),
|
|
147
185
|
item: null,
|
|
148
186
|
exists: targetExists,
|
|
187
|
+
recordedFingerprint: fingerprintFor(record, file),
|
|
149
188
|
fingerprint: targetExists ? await fileFingerprint(target) : null,
|
|
150
189
|
};
|
|
151
|
-
return { ...state, status: classifyOrphan(state
|
|
190
|
+
return { ...state, status: classifyOrphan(state) };
|
|
152
191
|
}));
|
|
153
192
|
const states = [...plannedStates, ruleState, ...orphanStates];
|
|
154
193
|
const conflicts = states.filter((state) => state.status === 'modified' || state.status === 'missing');
|
|
@@ -157,29 +196,51 @@ export async function update({ host, dryRun = false, force = false, packageRoot
|
|
|
157
196
|
return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
|
|
158
197
|
}
|
|
159
198
|
|
|
160
|
-
const
|
|
199
|
+
const currentPackage = await packageInfo(packageRoot);
|
|
200
|
+
const targets = hookTargets(host, env, currentPackage.version);
|
|
161
201
|
const inspectedHooks = await Promise.all(targets.map(async (target) => ({
|
|
162
202
|
target,
|
|
163
203
|
state: await inspectHook(host.settingsPath, target.spec),
|
|
164
204
|
})));
|
|
165
|
-
const
|
|
205
|
+
const oldHooks = previousHooks(host, record, targets);
|
|
166
206
|
const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
|
|
167
|
-
new Map(plannedStates.map((state) => [state.
|
|
207
|
+
new Map(plannedStates.map((state) => [recordFileKey(state.item), state.fingerprint])),
|
|
168
208
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
169
|
-
const changed = states.some((state) => state.status !== 'up-to-date');
|
|
209
|
+
const changed = states.some((state) => state.status !== 'up-to-date') || oldHooks.length > 0;
|
|
170
210
|
if (!changed && inspectedHooks.every(({ state }) => state.present)
|
|
171
211
|
&& recordHasHooks(record, targets) && recordCurrent) {
|
|
172
|
-
if (!dryRun)
|
|
212
|
+
if (!dryRun) {
|
|
213
|
+
await addRulesOwner(host);
|
|
214
|
+
// An update that installed the new layout but stopped before retiring the old one used to
|
|
215
|
+
// land here forever after: everything it compares is current, so it reported "up to date"
|
|
216
|
+
// while agents/codex sat next to agents/codex-bridge. Retirement is idempotent, so running
|
|
217
|
+
// it on this path costs nothing when there is nothing left to retire.
|
|
218
|
+
await retireLegacyLayout(host);
|
|
219
|
+
}
|
|
173
220
|
return { exitCode: 0, output: 'codex-bridge is up to date' };
|
|
174
221
|
}
|
|
175
222
|
if (dryRun) {
|
|
176
|
-
return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy) };
|
|
223
|
+
return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy, oldHooks) };
|
|
177
224
|
}
|
|
178
225
|
|
|
179
226
|
for (const state of orphanStates) {
|
|
180
|
-
if (state.status
|
|
227
|
+
if (state.status !== 'orphaned' || !state.exists) continue;
|
|
228
|
+
const target = recordTarget(host, state.entry);
|
|
229
|
+
await fs.rm(target, { force: true });
|
|
230
|
+
const boundary = state.entry.root === 'brand' ? host.brandRoot : claudeBoundary(host, target);
|
|
231
|
+
await removeEmptyParents(target, boundary);
|
|
232
|
+
}
|
|
233
|
+
for (const { spec, hook } of oldHooks) {
|
|
234
|
+
await removeHook(host.settingsPath, spec, { createdGroup: hook.createdGroup === true });
|
|
181
235
|
}
|
|
182
|
-
const installed = await install({ host, force: true, packageRoot });
|
|
236
|
+
const installed = await install({ host, force: true, packageRoot, env });
|
|
183
237
|
if (installed.exitCode !== 0) return installed;
|
|
238
|
+
await retireLegacyLayout(host);
|
|
184
239
|
return { exitCode: 0, output: appliedOutput(states) };
|
|
185
240
|
}
|
|
241
|
+
|
|
242
|
+
export async function update(options = {}) {
|
|
243
|
+
const host = options?.host;
|
|
244
|
+
if (!host?.settingsPath) return updateInRun(options);
|
|
245
|
+
return withSettingsRun(host.settingsPath, () => updateInRun(options));
|
|
246
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 5 guards, 604 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Names every root outside the repository that the suite must never run against for real. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A fixture that does not name a root falls back to one of these variables, so the suite runner
|
|
5
|
+
* replaces all of them and the isolation test asserts each one. Two incidents put entries here:
|
|
6
|
+
* `~/.codex` collected a rules file on 2026-08-03, and `~/.lyupro/.codex-bridge` collected a
|
|
7
|
+
* config, conventions and an obsolete-file fixture on 2026-08-11, where the stray config then
|
|
8
|
+
* stood in the way of the seeded-file migration the release was about.
|
|
9
|
+
*
|
|
10
|
+
* It sits in its own module because the isolation test imports it, and importing the runner would
|
|
11
|
+
* start a second suite inside the first.
|
|
12
|
+
*/
|
|
13
|
+
export const ISOLATED_ROOTS = Object.freeze(['CODEX_HOME', 'CODEX_BRIDGE_HOME']);
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runs the test suite with
|
|
2
|
+
* Runs the test suite with every root it can install into replaced by a throwaway one.
|
|
3
3
|
*
|
|
4
4
|
* The installer writes its execpolicy file under `$CODEX_HOME/rules`, so a test that builds a host
|
|
5
5
|
* without naming a Codex home installs into the real one: on 2026-08-03 the suite dropped
|
|
6
6
|
* `codex-bridge.rules` into the operator's `~/.codex/rules/` and the next run failed on the file it
|
|
7
7
|
* had left behind. Passing the isolation through the one command that starts the suite is what
|
|
8
8
|
* makes it unforgettable — a fixture can omit it, this cannot.
|
|
9
|
+
*
|
|
10
|
+
* Plan_25 gave the package a second root outside the repository, and this file was not extended
|
|
11
|
+
* with it: on 2026-08-11 the suite wrote config.json, conventions.md and a lib/obsolete.txt fixture
|
|
12
|
+
* straight into the operator's real `~/.lyupro/.codex-bridge/`, where the stray config then stood
|
|
13
|
+
* in the way of the very migration the release was about. Roots live in one list here so a third
|
|
14
|
+
* one cannot be added without this file being the place it is declared.
|
|
9
15
|
*/
|
|
10
16
|
import fs from 'node:fs';
|
|
11
17
|
import os from 'node:os';
|
|
12
18
|
import path from 'node:path';
|
|
13
19
|
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { ISOLATED_ROOTS } from './isolated-roots.mjs';
|
|
14
21
|
|
|
15
|
-
const
|
|
22
|
+
const created = ISOLATED_ROOTS.map((name) => [
|
|
23
|
+
name,
|
|
24
|
+
fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
|
|
25
|
+
]);
|
|
16
26
|
const pattern = process.argv[2] || 'tests/**/*.test.mjs';
|
|
17
27
|
|
|
18
28
|
try {
|
|
19
29
|
const result = spawnSync(process.execPath, ['--test', pattern], {
|
|
20
30
|
stdio: 'inherit',
|
|
21
|
-
env: { ...process.env,
|
|
31
|
+
env: { ...process.env, ...Object.fromEntries(created) },
|
|
22
32
|
});
|
|
23
33
|
process.exitCode = result.status ?? 1;
|
|
24
34
|
} finally {
|
|
25
|
-
fs.rmSync(
|
|
35
|
+
for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
|
|
26
36
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codex-build
|
|
3
|
-
description: Имплементация ВНЕ подписки Claude — правки кода, новые модули, рефакторинг по образцу, починка тестов. Работу выполняет Codex CLI (подписка ChatGPT) с правом записи в репозиторий строго в пределах обязательного `scope`, Claude платит только за постановку задачи и ≤5 строк выжимки. Отчёт и полный лог кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. Ревью и приёмку делает отдельный агент Claude, не этот. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
|
|
3
|
+
description: Имплементация ВНЕ подписки Claude — правки кода, новые модули, рефакторинг по образцу, починка тестов. Работу выполняет Codex CLI (подписка ChatGPT) с правом записи в репозиторий строго в пределах обязательного `scope`, Claude платит только за постановку задачи и ≤5 строк выжимки. Отчёт и полный лог кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. Ревью и приёмку делает отдельный агент Claude, не этот. {{CODEX_REQUIRED_INPUTS_SUMMARY}} {{CODEX_STOP_SUMMARY}}
|
|
4
4
|
model: haiku
|
|
5
5
|
tools: Bash
|
|
6
6
|
---
|
|
@@ -35,15 +35,15 @@ job is to report the run status honestly, including failure.
|
|
|
35
35
|
reuse an order id from another order — the runner chains runs by that label, and a made-up label
|
|
36
36
|
is how a repeat run hides. If the orchestrator did not give a required input, do not guess: start
|
|
37
37
|
the runner without its flag and return the runner's refusal exactly.
|
|
38
|
-
- `continue` —
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
- `continue` — when the task text contains a line beginning with the `continue:` label, pass the
|
|
39
|
+
bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
|
|
40
|
+
or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
|
|
41
|
+
assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
|
|
42
|
+
output and stop; do not issue or invent another continuation. If no such grant line is present,
|
|
43
|
+
the flag must not be present.
|
|
44
|
+
- Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:` (by default, the slug is
|
|
45
|
+
taken from the order id), `verify: <verification command>` (for example `npm test`,
|
|
46
|
+
`tsc --noEmit`).
|
|
47
47
|
|
|
48
48
|
## The only thing you do
|
|
49
49
|
|
|
@@ -61,8 +61,10 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
|
|
|
61
61
|
decides, which is the intended default — a placeholder copied from this template is refused before
|
|
62
62
|
Codex starts.
|
|
63
63
|
|
|
64
|
-
If the
|
|
65
|
-
|
|
64
|
+
If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
|
|
65
|
+
flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
|
|
66
|
+
the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
|
|
67
|
+
present in the command.
|
|
66
68
|
No value
|
|
67
69
|
is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
|
|
68
70
|
the
|
|
@@ -132,7 +134,7 @@ are designed for interactive work and cause harm in a delegated run — a failin
|
|
|
132
134
|
and the task
|
|
133
135
|
was not done; without them, 23k and the task was done. The flags affect one call; `~/.codex` is
|
|
134
136
|
unchanged.
|
|
135
|
-
The operator switches the mode through `/codex:env` (file
|
|
137
|
+
The operator switches the mode through `/codex-bridge:env` (file `~/.lyupro/.codex-bridge/config.json`), and it
|
|
136
138
|
is recorded in the run's `meta.json` — do not guess it or add flags yourself.
|
|
137
139
|
`--dangerously-bypass-approvals-and-sandbox` is never used. `--model` is not
|
|
138
140
|
passed: model IDs are volatile. The script fixes this once, so silent
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codex-review
|
|
3
|
-
description: Независимое второе мнение по коду от другой модели — ревью незакоммиченных правок, ветки или коммита силами Codex CLI (подписка ChatGPT). Не заменяет приёмку на стороне Claude, а дополняет её взглядом со стороны. Выдаёт строгий JSON с severity и confidence по каждой находке, в чат — ≤5 строк со счётчиком по важности. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
|
|
3
|
+
description: Независимое второе мнение по коду от другой модели — ревью незакоммиченных правок, ветки или коммита силами Codex CLI (подписка ChatGPT). Не заменяет приёмку на стороне Claude, а дополняет её взглядом со стороны. Выдаёт строгий JSON с severity и confidence по каждой находке, в чат — ≤5 строк со счётчиком по важности. {{CODEX_REQUIRED_INPUTS_SUMMARY}} {{CODEX_STOP_SUMMARY}}
|
|
4
4
|
model: haiku
|
|
5
5
|
tools: Bash
|
|
6
6
|
---
|
|
@@ -34,12 +34,14 @@ opinion, not a verdict.
|
|
|
34
34
|
another order — the runner chains runs by that label, and a made-up label is how a repeat run
|
|
35
35
|
hides. If the orchestrator did not give a required input, do not guess — start the runner without
|
|
36
36
|
its flag and return the runner's refusal verbatim.
|
|
37
|
-
- `continue`
|
|
38
|
-
|
|
37
|
+
- `continue` — when the task text contains a line beginning with the `continue:` label, pass the
|
|
38
|
+
bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
|
|
39
|
+
or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
|
|
39
40
|
assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
|
|
40
|
-
output and stop; do not issue or invent another continuation.
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
output and stop; do not issue or invent another continuation. If no such grant line is present,
|
|
42
|
+
the flag must not be present.
|
|
43
|
+
- Optional: review focus as text ("look for races and error handling"), `slug:` (by default, the
|
|
44
|
+
slug is taken from the order id), `effort: <none|minimal|low|medium|high|xhigh|max>`.
|
|
43
45
|
|
|
44
46
|
## The only thing you do
|
|
45
47
|
|
|
@@ -61,8 +63,10 @@ line. To get the verdict, run the **identical command a second time** — same `
|
|
|
61
63
|
`--slug`, same flags. That second call does not start a second run and costs no quota: it attaches
|
|
62
64
|
to the run already in flight, prints `ATTACH=<path>`, blocks until the verdict exists and prints it.
|
|
63
65
|
|
|
64
|
-
If the
|
|
65
|
-
|
|
66
|
+
If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
|
|
67
|
+
flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
|
|
68
|
+
the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
|
|
69
|
+
present in the command at all.
|
|
66
70
|
|
|
67
71
|
Background execution (`run_in_background`, `&`, `nohup`) is prohibited. Interruption is no longer a
|
|
68
72
|
problem worth handling: if the attaching call is killed by a time ceiling, repeat it — every repeat
|
|
@@ -114,7 +118,7 @@ properties that broke the contract.
|
|
|
114
118
|
Regular `codex exec` follows the schema (verified during scouting), so the review uses it: with
|
|
115
119
|
`--ignore-user-config` (structurally read-only, half the startup ballast), `--sandbox
|
|
116
120
|
read-only`, `--disable hooks --disable plugins` (operator extensions do not affect the run;
|
|
117
|
-
the default, switched through `/codex:env`), and without `--model` — model IDs are volatile. The
|
|
121
|
+
the default, switched through `/codex-bridge:env`), and without `--model` — model IDs are volatile. The
|
|
118
122
|
runner determines the review scope
|
|
119
123
|
itself through git: the exact file list and diff command go into the prompt and are saved in the run
|
|
120
124
|
folder's
|