@lyupro/codex-bridge 0.3.1 → 0.5.0
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 +92 -0
- package/README.md +88 -84
- package/bin/codex-bridge.mjs +38 -11
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +73 -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 +73 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +90 -164
- package/cli/permissions.mjs +179 -0
- package/cli/projects.mjs +20 -5
- package/cli/prune-args.mjs +1 -1
- package/cli/remove-layout.mjs +59 -0
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +182 -50
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +64 -36
- package/cli/unlock.mjs +239 -0
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/check-file-size.mjs +8 -10
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +17 -13
- package/src/agents/codex-review.md +15 -9
- package/src/agents/codex-scout.md +16 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/heartbeat.mjs +1 -1
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +31 -20
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/reply-guard.mjs +4 -60
- package/src/hooks/stop-guard.mjs +71 -0
- package/src/json-file.mjs +30 -0
- package/src/meta/chain.mjs +8 -6
- package/src/meta/paths.mjs +5 -9
- package/src/meta/pre-start.mjs +30 -0
- package/src/meta/run-state.mjs +31 -16
- package/src/process-identity.mjs +140 -0
- package/src/required-inputs.mjs +42 -0
- package/src/run-config.mjs +4 -8
- package/src/runner/args.mjs +26 -3
- package/src/runner/attach.mjs +32 -19
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +54 -28
- package/src/runner/project-dir.mjs +4 -3
- package/src/runner/scope-check.mjs +159 -0
- package/src/runner/worker.mjs +13 -5
- package/src/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +12 -1
- package/cli/sweep.mjs +0 -144
package/cli/unlock.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Closes dead-pid running records without deleting any run artifacts. */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/heartbeat.mjs';
|
|
6
|
+
import { markAbandoned } from '../src/meta/run-state.mjs';
|
|
7
|
+
import {
|
|
8
|
+
IDENTITY_ALIVE,
|
|
9
|
+
IDENTITY_UNVERIFIED,
|
|
10
|
+
processIdentity,
|
|
11
|
+
} from '../src/process-identity.mjs';
|
|
12
|
+
import { git } from '../src/runner/git-state.mjs';
|
|
13
|
+
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
14
|
+
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
15
|
+
import { readJson } from '../src/write-meta.mjs';
|
|
16
|
+
import { renderTable } from './table.mjs';
|
|
17
|
+
|
|
18
|
+
const CLOSED_COLUMNS = [
|
|
19
|
+
{ key: 'project', label: 'project' },
|
|
20
|
+
{ key: 'run', label: 'run' },
|
|
21
|
+
{ key: 'state', label: 'state' },
|
|
22
|
+
];
|
|
23
|
+
const RECORD_COLUMNS = [
|
|
24
|
+
{ key: 'project', label: 'project', truncate: null },
|
|
25
|
+
{ key: 'run', label: 'run', truncate: null },
|
|
26
|
+
{ key: 'age', label: 'age', truncate: null },
|
|
27
|
+
{ key: 'silence', label: 'silent for', truncate: null },
|
|
28
|
+
{ key: 'identity', label: 'identity', truncate: null },
|
|
29
|
+
{ key: 'state', label: 'state', truncate: null },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function result(exitCode, output) {
|
|
33
|
+
return { exitCode, output };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const positional = [];
|
|
38
|
+
let all = false;
|
|
39
|
+
for (const arg of argv) {
|
|
40
|
+
if (arg === '--all') {
|
|
41
|
+
if (all) return { error: 'codex-bridge unlock accepts --all only once.' };
|
|
42
|
+
all = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (typeof arg !== 'string' || arg.startsWith('-')) {
|
|
46
|
+
return { error: `codex-bridge unlock: unknown option "${arg}".` };
|
|
47
|
+
}
|
|
48
|
+
positional.push(arg);
|
|
49
|
+
}
|
|
50
|
+
if (all && positional.length) {
|
|
51
|
+
return { error: 'codex-bridge unlock accepts either --all or one project name.' };
|
|
52
|
+
}
|
|
53
|
+
if (positional.length > 1) {
|
|
54
|
+
return { error: 'codex-bridge unlock accepts at most one project name.' };
|
|
55
|
+
}
|
|
56
|
+
const project = positional[0] || null;
|
|
57
|
+
if (project && (path.isAbsolute(project) || path.dirname(project) !== '.')) {
|
|
58
|
+
return { error: 'codex-bridge unlock requires a bare project name.' };
|
|
59
|
+
}
|
|
60
|
+
return { all, project };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function directories(root) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
66
|
+
.filter((entry) => entry.isDirectory())
|
|
67
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function allProjects(root) {
|
|
74
|
+
return directories(root).map((entry) => ({ name: entry.name, dir: path.join(root, entry.name) }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function namedProject(root, projectName) {
|
|
78
|
+
const entry = directories(root).find((candidate) => candidate.name === projectName);
|
|
79
|
+
if (!entry) return { error: `codex-bridge unlock: unknown project "${projectName}".` };
|
|
80
|
+
return [{ name: entry.name, dir: path.join(root, entry.name) }];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function currentRepository(cwd) {
|
|
84
|
+
const result = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
85
|
+
const output = String(result.stdout ?? '').trim();
|
|
86
|
+
return result.status === 0 && output ? output : cwd;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function selectedProjects(root, parsed, cwd) {
|
|
90
|
+
if (parsed.all) return allProjects(root);
|
|
91
|
+
if (parsed.project) return namedProject(root, parsed.project);
|
|
92
|
+
const resolved = resolveProjectRunsDir(root, currentRepository(cwd), { create: false });
|
|
93
|
+
return [{ name: resolved.name, dir: resolved.dir }];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function unlockPlan(argv = [], options = {}) {
|
|
97
|
+
const parsed = parseArgs(argv);
|
|
98
|
+
if (parsed.error) return parsed;
|
|
99
|
+
const root = path.resolve(options.runsRootPath || runsRoot());
|
|
100
|
+
const projects = selectedProjects(root, parsed, options.cwd || process.cwd());
|
|
101
|
+
if (projects.error) return projects;
|
|
102
|
+
return {
|
|
103
|
+
root,
|
|
104
|
+
all: parsed.all,
|
|
105
|
+
project: parsed.project,
|
|
106
|
+
currentRepository: !parsed.all && !parsed.project,
|
|
107
|
+
projects,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function timestamp(value) {
|
|
112
|
+
const parsed = Date.parse(value || '');
|
|
113
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runAge(startedAt, now) {
|
|
117
|
+
const started = timestamp(startedAt);
|
|
118
|
+
return formatSilence(started === null ? null : Math.max(0, now - started));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function identityOptions(runDir, status, options, now) {
|
|
122
|
+
return {
|
|
123
|
+
runDir,
|
|
124
|
+
status,
|
|
125
|
+
now,
|
|
126
|
+
kill: options.kill,
|
|
127
|
+
probe: options.probe,
|
|
128
|
+
processStartProbe: options.processStartProbe,
|
|
129
|
+
commandRunner: options.commandRunner,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function inspectRuns(project, options) {
|
|
134
|
+
const now = options.now ?? Date.now();
|
|
135
|
+
const records = [];
|
|
136
|
+
for (const entry of directories(project.dir)) {
|
|
137
|
+
const dir = path.join(project.dir, entry.name);
|
|
138
|
+
const status = readJson(path.join(dir, 'status.json'));
|
|
139
|
+
if (!status || status.state !== 'running') continue;
|
|
140
|
+
const silenceAge = heartbeatAge(dir, now);
|
|
141
|
+
records.push({
|
|
142
|
+
project: project.name,
|
|
143
|
+
run: entry.name,
|
|
144
|
+
age: runAge(status.started_at, now),
|
|
145
|
+
silence: formatSilence(silenceAge),
|
|
146
|
+
silenceAge,
|
|
147
|
+
identity: processIdentity(identityOptions(dir, status, options, now)),
|
|
148
|
+
state: status.state,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return records;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function closeDeadRuns(plan) {
|
|
155
|
+
const closed = [];
|
|
156
|
+
const failures = [];
|
|
157
|
+
for (const project of plan.projects) {
|
|
158
|
+
try {
|
|
159
|
+
// No worktree snapshot is passed, and that is the choice rather than an omission: `stop`
|
|
160
|
+
// closes one named run and knows which repository to look at, while an unlock over the
|
|
161
|
+
// store would have to run git in every repository ever pointed at. The runs it closes died
|
|
162
|
+
// long ago, so a current tree would describe today's work, not their abandoned run.
|
|
163
|
+
for (const change of markAbandoned(project.dir)) {
|
|
164
|
+
closed.push({ project: project.name, run: change.run, state: change.state });
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
failures.push({ project: project.name, message: error.message });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { closed, failures };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function reportRows(records, closed) {
|
|
174
|
+
const closedByRun = new Map(closed.map((change) => [`${change.project}\u0000${change.run}`, change.state]));
|
|
175
|
+
return records.map((record) => ({
|
|
176
|
+
...record,
|
|
177
|
+
state: closedByRun.get(`${record.project}\u0000${record.run}`) || record.state,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderReport(plan, records, closed, failures, options) {
|
|
182
|
+
const scope = plan.all ? 'all projects' : plan.project ? `project "${plan.project}"` : 'the current repository';
|
|
183
|
+
const rows = reportRows(records, closed);
|
|
184
|
+
const stalled = records.filter((record) => (
|
|
185
|
+
record.identity === IDENTITY_ALIVE
|
|
186
|
+
&& Number.isFinite(record.silenceAge)
|
|
187
|
+
&& record.silenceAge > HEARTBEAT_STALE_MS
|
|
188
|
+
));
|
|
189
|
+
const alive = records.filter((record) => record.identity === IDENTITY_ALIVE);
|
|
190
|
+
const unverified = records.filter((record) => record.identity === IDENTITY_UNVERIFIED);
|
|
191
|
+
const lines = [`Unlock of ${scope} completed.`];
|
|
192
|
+
if (closed.length) {
|
|
193
|
+
lines.push(`Closed ${closed.length} running record${closed.length === 1 ? '' : 's'}:`);
|
|
194
|
+
lines.push(renderTable(CLOSED_COLUMNS, closed, options));
|
|
195
|
+
} else {
|
|
196
|
+
lines.push('Nothing to close; the store is unchanged.');
|
|
197
|
+
}
|
|
198
|
+
if (rows.length) {
|
|
199
|
+
lines.push('Running records inspected:');
|
|
200
|
+
lines.push(renderTable(RECORD_COLUMNS, rows, options));
|
|
201
|
+
} else {
|
|
202
|
+
lines.push('Running records inspected: none.');
|
|
203
|
+
}
|
|
204
|
+
if (alive.length) {
|
|
205
|
+
lines.push(
|
|
206
|
+
`Refused to close ${alive.length} confirmed-alive run${alive.length === 1 ? '' : 's'}; `
|
|
207
|
+
+ 'use codex-bridge stop <run> instead.',
|
|
208
|
+
);
|
|
209
|
+
for (const run of alive) lines.push(` codex-bridge stop ${run.run}`);
|
|
210
|
+
}
|
|
211
|
+
if (unverified.length) {
|
|
212
|
+
lines.push(
|
|
213
|
+
`Left ${unverified.length} run${unverified.length === 1 ? '' : 's'} untouched because `
|
|
214
|
+
+ 'process identity is unverified.',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (stalled.length) {
|
|
218
|
+
lines.push(`Left ${stalled.length} live-pid run${stalled.length === 1 ? '' : 's'} with stale heartbeats untouched:`);
|
|
219
|
+
lines.push(renderTable(RECORD_COLUMNS, stalled, options));
|
|
220
|
+
} else {
|
|
221
|
+
lines.push('Live-pid runs with stale heartbeats left untouched: none.');
|
|
222
|
+
}
|
|
223
|
+
if (failures.length) {
|
|
224
|
+
lines.push('Some project records could not be unlocked:');
|
|
225
|
+
for (const failure of failures) lines.push(` ${failure.project}: ${failure.message}`);
|
|
226
|
+
}
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function unlock(argv = [], options = {}) {
|
|
231
|
+
const plan = unlockPlan(argv, options);
|
|
232
|
+
if (plan.error) return result(2, plan.error);
|
|
233
|
+
const records = plan.projects.flatMap((project) => inspectRuns(project, options));
|
|
234
|
+
const { closed, failures } = closeDeadRuns(plan);
|
|
235
|
+
return result(
|
|
236
|
+
failures.length ? 1 : 0,
|
|
237
|
+
renderReport(plan, records, closed, failures, options),
|
|
238
|
+
);
|
|
239
|
+
}
|
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.0",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 5 guards, 603 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* error mode, 2 internal error (config missing/malformed, or an exclude entry has no
|
|
14
14
|
* matching exclusionRationale).
|
|
15
15
|
*
|
|
16
|
-
* Every exclude entry is classified as either a "pattern" (contains a `*`,
|
|
16
|
+
* Every exclude entry is classified as either a "pattern" (contains a `*`, covers a
|
|
17
17
|
* whole category — e.g. `**\/node_modules/**`) or a "specific path" (no `*`, names one
|
|
18
18
|
* file). Specific paths must carry an exclusionRationale entry; patterns are exempt. This
|
|
19
19
|
* is the structural guard against silently hiding a file in exclude without saying why.
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs/promises';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { readJsonFile } from '../src/json-file.mjs';
|
|
24
25
|
|
|
25
26
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
export const REPO_ROOT = path.resolve(HERE, '..');
|
|
@@ -31,17 +32,14 @@ const VALID_MODES = ['error', 'warning'];
|
|
|
31
32
|
|
|
32
33
|
/** Reads and structurally validates .file-size-limit.json. Throws on anything wrong. */
|
|
33
34
|
export async function loadConfig(configPath = CONFIG_PATH) {
|
|
34
|
-
let raw;
|
|
35
|
-
try {
|
|
36
|
-
raw = await fs.readFile(configPath, 'utf8');
|
|
37
|
-
} catch (err) {
|
|
38
|
-
throw new Error(`cannot read ${configPath}: ${err.message}`);
|
|
39
|
-
}
|
|
40
35
|
let parsed;
|
|
41
36
|
try {
|
|
42
|
-
parsed =
|
|
37
|
+
parsed = await readJsonFile(configPath);
|
|
43
38
|
} catch (err) {
|
|
44
|
-
|
|
39
|
+
if (err.cause) {
|
|
40
|
+
throw new Error(`${configPath} is not valid JSON: ${err.cause.message}`, { cause: err.cause });
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`cannot read ${configPath}: ${err.message}`);
|
|
45
43
|
}
|
|
46
44
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
47
45
|
throw new Error(`${configPath} must contain a JSON object`);
|
|
@@ -69,7 +67,7 @@ export async function loadConfig(configPath = CONFIG_PATH) {
|
|
|
69
67
|
return parsed;
|
|
70
68
|
}
|
|
71
69
|
|
|
72
|
-
/** A pattern (contains `*`)
|
|
70
|
+
/** A pattern (contains `*`) covers a category; a specific path (no `*`) names one file. */
|
|
73
71
|
export function isSpecificExclude(entry) {
|
|
74
72
|
return !entry.includes('*');
|
|
75
73
|
}
|
|
@@ -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
|
}
|