@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +88 -84
  3. package/bin/codex-bridge.mjs +38 -11
  4. package/cli/copy.mjs +7 -6
  5. package/cli/doctor.mjs +73 -12
  6. package/cli/hook-targets.mjs +39 -0
  7. package/cli/hook.mjs +34 -0
  8. package/cli/hosts.mjs +28 -4
  9. package/cli/install-record.mjs +287 -0
  10. package/cli/install.mjs +73 -39
  11. package/cli/invoked-directly.mjs +37 -0
  12. package/cli/manifest.mjs +90 -164
  13. package/cli/permissions.mjs +179 -0
  14. package/cli/projects.mjs +20 -5
  15. package/cli/prune-args.mjs +1 -1
  16. package/cli/remove-layout.mjs +59 -0
  17. package/cli/rules-owners.mjs +6 -8
  18. package/cli/runs-inventory.mjs +2 -1
  19. package/cli/settings-merge.mjs +182 -50
  20. package/cli/stop.mjs +33 -20
  21. package/cli/uninstall.mjs +64 -36
  22. package/cli/unlock.mjs +239 -0
  23. package/cli/update.mjs +109 -48
  24. package/package.json +2 -2
  25. package/scripts/check-file-size.mjs +8 -10
  26. package/scripts/isolated-roots.mjs +13 -0
  27. package/scripts/run-tests.mjs +14 -4
  28. package/src/agents/codex-build.md +17 -13
  29. package/src/agents/codex-review.md +15 -9
  30. package/src/agents/codex-scout.md +16 -11
  31. package/src/commands/env.md +4 -4
  32. package/src/commands/usage.md +8 -6
  33. package/src/heartbeat.mjs +1 -1
  34. package/src/hook-definitions.mjs +17 -4
  35. package/src/hooks/guard-tries.mjs +59 -0
  36. package/src/hooks/live-runs.mjs +31 -20
  37. package/src/hooks/order-gate.mjs +7 -2
  38. package/src/hooks/reply-guard.mjs +4 -60
  39. package/src/hooks/stop-guard.mjs +71 -0
  40. package/src/json-file.mjs +30 -0
  41. package/src/meta/chain.mjs +8 -6
  42. package/src/meta/paths.mjs +5 -9
  43. package/src/meta/pre-start.mjs +30 -0
  44. package/src/meta/run-state.mjs +31 -16
  45. package/src/process-identity.mjs +140 -0
  46. package/src/required-inputs.mjs +42 -0
  47. package/src/run-config.mjs +4 -8
  48. package/src/runner/args.mjs +26 -3
  49. package/src/runner/attach.mjs +32 -19
  50. package/src/runner/codex-cmd.mjs +1 -1
  51. package/src/runner/continuation.mjs +34 -3
  52. package/src/runner/launcher.mjs +54 -28
  53. package/src/runner/project-dir.mjs +4 -3
  54. package/src/runner/scope-check.mjs +159 -0
  55. package/src/runner/worker.mjs +13 -5
  56. package/src/stop-contract.mjs +15 -0
  57. package/src/write-meta.mjs +12 -1
  58. 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 codex-bridge installation without overwriting unapproved host changes. */
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 { commandFor, inspectHook } from './settings-merge.mjs';
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
- function classifyPlanned(state, record) {
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, record) {
60
+ function classifyOrphan(state) {
37
61
  if (!state.exists) return 'orphaned';
38
- if (record.fingerprints?.[state.relative] === state.fingerprint) return 'orphaned';
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) lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher}.`);
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
- export async function update({ host, dryRun = false, force = false, packageRoot } = {}) {
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.relativeToHost, 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.relativeToHost;
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.includes(relative),
122
- recordedFingerprint: record.fingerprints?.[relative],
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.agentsDir),
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, record) };
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.agentsDir),
173
+ matchesPackage: ruleExists && await targetMatches(rule, host.brandRoot),
137
174
  fingerprint: ruleExists ? await fileFingerprint(rule.target) : null,
138
175
  };
139
- ruleState.status = classifyPlanned(ruleState, record);
176
+ ruleState.status = classifyPlanned(ruleState);
140
177
  const orphanStates = await Promise.all(record.files
141
- .filter((relative) => !planned.has(relative))
142
- .map(async (relative) => {
143
- const target = path.join(host.root, relative);
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, record) };
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 targets = hookTargets(host);
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 currentPackage = await packageInfo(packageRoot);
205
+ const oldHooks = previousHooks(host, record, targets);
166
206
  const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
167
- new Map(plannedStates.map((state) => [state.relative, state.fingerprint])),
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) await addRulesOwner(host);
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 === 'orphaned' && state.exists) await fs.rm(path.join(host.root, state.relative));
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.3.1",
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.",
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 `*`, sweeps 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 = JSON.parse(raw);
37
+ parsed = await readJsonFile(configPath);
43
38
  } catch (err) {
44
- throw new Error(`${configPath} is not valid JSON: ${err.message}`);
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 `*`) sweeps a category; a specific path (no `*`) names one file. */
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']);
@@ -1,26 +1,36 @@
1
1
  /**
2
- * Runs the test suite with a Codex home of its own.
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 codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-home-'));
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, CODEX_HOME: codexHome },
31
+ env: { ...process.env, ...Object.fromEntries(created) },
22
32
  });
23
33
  process.exitCode = result.status ?? 1;
24
34
  } finally {
25
- fs.rmSync(codexHome, { recursive: true, force: true });
35
+ for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
26
36
  }