@lyupro/codex-bridge 0.5.3 → 0.5.5

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.
@@ -1,174 +1,188 @@
1
- #!/usr/bin/env node
2
- /** Dispatches codex-bridge CLI arguments to focused command modules. */
3
- import { diagnose, renderDoctor } from '../cli/doctor.mjs';
4
- import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
5
- import { hook } from '../cli/hook.mjs';
6
- import { resolveHost } from '../cli/hosts.mjs';
7
- import { install } from '../cli/install.mjs';
8
- import { projects } from '../cli/projects.mjs';
9
- import { read } from '../cli/read.mjs';
10
- import { runCodexCommand } from '../src/home/lib/run-codex.mjs';
11
- import { packageInfo } from '../cli/manifest.mjs';
12
- import { permissions } from '../cli/permissions.mjs';
13
- import { prune } from '../cli/prune.mjs';
14
- import { stop } from '../cli/stop.mjs';
15
- import { unlock } from '../cli/unlock.mjs';
16
- import { uninstall } from '../cli/uninstall.mjs';
17
- import { update } from '../cli/update.mjs';
18
-
19
- export const HELP = `codex-bridge — Claude Code dispatchers for Codex
20
-
21
- Usage:
22
- codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
23
- codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
24
- codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
25
- codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
26
- codex-bridge doctor [--scope user|project] [--host <path>]
27
- codex-bridge run <runner options> --task-file <path>
28
- codex-bridge projects [<name>] [--json]
29
- codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
30
- codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
31
- codex-bridge unlock [<project>|--all]
32
- codex-bridge read <run>
33
- codex-bridge stop <run>
34
- codex-bridge hook <name>
35
- codexb <same command forms as codex-bridge>
36
- codex-bridge --help
37
- codex-bridge --version
38
-
39
- Commands:
40
- install Install codex-bridge into the selected Claude Code host
41
- update Update a recorded codex-bridge installation
42
- permissions Show, add, or remove optional shell permission rules
43
- uninstall Remove installed files while preserving run artifacts
44
- doctor Diagnose the selected Claude Code host
45
- run Start or attach to a delegated Codex run
46
- projects List projects or runs from the run store
47
- prune Remove archived transport, or purge selected run folders
48
- unlock Close running records whose runner is gone
49
- read Render a run's structured event stream
50
- stop Stop a running Codex run and record FAIL
51
-
52
- Hook dispatch:
53
- codex-bridge hook <name> Dispatch a registered guard with stdin unchanged`;
54
-
55
- const HOOK_COMMAND = 'hook';
56
-
57
- function commandOptions(command, argv) {
58
- const options = {};
59
- const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
60
- : command === 'uninstall' ? new Set(['--dry-run']) : new Set();
61
- const flagNames = new Map([
62
- ['--dry-run', 'dryRun'],
63
- ['--force', 'force'],
64
- ]);
65
- for (let index = 0; index < argv.length; index += 1) {
66
- const arg = argv[index];
67
- if (booleanFlags.has(arg)) {
68
- options[flagNames.get(arg)] = true;
69
- continue;
70
- }
71
- if (arg !== '--scope' && arg !== '--host') throw new Error(`unknown ${command} option "${arg}"`);
72
- const value = argv[index + 1];
73
- if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
74
- options[arg === '--scope' ? 'scope' : 'host'] = value;
75
- index += 1;
76
- }
77
- return options;
78
- }
79
-
80
- export async function main(argv, io = console) {
81
- const [command, ...rest] = argv;
82
- if (!command || command === '--help' || command === '-h') {
83
- io.log(HELP);
84
- return 0;
85
- }
86
- if (command === '--version' || command === '-v') {
87
- io.log((await packageInfo()).version);
88
- return 0;
89
- }
90
- if (command === 'run') return runCodexCommand(rest);
91
- if (command === 'doctor') {
92
- const host = resolveHost(commandOptions(command, rest));
93
- const result = await diagnose({ host });
94
- io.log(renderDoctor(result));
95
- return result.exitCode;
96
- }
97
- if (command === 'stop') {
98
- if (rest.length !== 1) {
99
- io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
100
- return 2;
101
- }
102
- const result = await stop({ run: rest[0] });
103
- io.log(result.output);
104
- return result.exitCode;
105
- }
106
- if (command === HOOK_COMMAND) return hook(rest, io);
107
- if (command === 'read') {
108
- if (rest.length !== 1) {
109
- io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
110
- return 2;
111
- }
112
- const result = read({ run: rest[0] });
113
- io.log(result.output);
114
- return result.exitCode;
115
- }
116
- if (command === 'projects') {
117
- const result = projects(rest);
118
- io.log(result.output);
119
- return result.exitCode;
120
- }
121
- if (command === 'prune') {
122
- const result = await prune(rest);
123
- io.log(result.output);
124
- return result.exitCode;
125
- }
126
- if (command === 'sweep') {
127
- io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
128
- return 2;
129
- }
130
- if (command === 'unlock') {
131
- const result = unlock(rest);
132
- io.log(result.output);
133
- return result.exitCode;
134
- }
135
- if (command === 'permissions') {
136
- let action;
137
- let optionArgs = rest;
138
- if (rest[0] && !rest[0].startsWith('-')) {
139
- action = rest[0];
140
- optionArgs = rest.slice(1);
141
- }
142
- if (action && !['add', 'remove'].includes(action)) {
143
- throw new Error(`unknown permissions action "${action}"`);
144
- }
145
- const options = commandOptions(command, optionArgs);
146
- const host = resolveHost(options);
147
- const result = await permissions({ host, action });
148
- io.log(result.output);
149
- return result.exitCode;
150
- }
151
- if (command === 'install' || command === 'update' || command === 'uninstall') {
152
- const options = commandOptions(command, rest);
153
- const host = resolveHost(options);
154
- const handlers = {
155
- install: () => install({ host, dryRun: options.dryRun, force: options.force }),
156
- update: () => update({ host, dryRun: options.dryRun, force: options.force }),
157
- uninstall: () => uninstall({ host, dryRun: options.dryRun }),
158
- };
159
- const result = await handlers[command]();
160
- io.log(result.output);
161
- return result.exitCode;
162
- }
163
- io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
164
- return 2;
165
- }
166
-
167
- if (isInvokedDirectly(process.argv[1], import.meta.url)) {
168
- main(process.argv.slice(2))
169
- .then((exitCode) => { process.exitCode = exitCode; })
170
- .catch((err) => {
171
- console.error(`codex-bridge: ${err.message}`);
172
- process.exitCode = 2;
173
- });
174
- }
1
+ #!/usr/bin/env node
2
+ /** Dispatches codex-bridge CLI arguments to focused command modules. */
3
+ import { diagnose, renderDoctor } from '../cli/doctor.mjs';
4
+ import { probeContract } from '../cli/probe-contract.mjs';
5
+ import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
6
+ import { hook } from '../cli/hook.mjs';
7
+ import { resolveHost } from '../cli/hosts.mjs';
8
+ import { install } from '../cli/install.mjs';
9
+ import { projects } from '../cli/projects.mjs';
10
+ import { read } from '../cli/read.mjs';
11
+ import { runCodexCommand } from '../src/home/lib/run-codex.mjs';
12
+ import { packageInfo } from '../cli/manifest.mjs';
13
+ import { permissions } from '../cli/permissions.mjs';
14
+ import { prune } from '../cli/prune.mjs';
15
+ import { stop } from '../cli/stop.mjs';
16
+ import { unlock } from '../cli/unlock.mjs';
17
+ import { uninstall } from '../cli/uninstall.mjs';
18
+ import { update } from '../cli/update.mjs';
19
+
20
+ export const HELP = `codex-bridge — Claude Code dispatchers for Codex
21
+
22
+ Usage:
23
+ codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
24
+ codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
25
+ codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
26
+ codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
27
+ codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract]
28
+ codex-bridge run <runner options> --task-file <path>
29
+ codex-bridge projects [<name>] [--json]
30
+ codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
31
+ codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
32
+ codex-bridge unlock [<project>|--all]
33
+ codex-bridge read <run>
34
+ codex-bridge stop <run>
35
+ codex-bridge hook <name>
36
+ codexb <same command forms as codex-bridge>
37
+ codex-bridge --help
38
+ codex-bridge --version
39
+
40
+ Commands:
41
+ install Install codex-bridge into the selected Claude Code host
42
+ update Update a recorded codex-bridge installation
43
+ permissions Show, add, or remove optional shell permission rules
44
+ uninstall Remove installed files while preserving run artifacts
45
+ doctor Diagnose the selected Claude Code host (--probe-contract measures on a live host)
46
+ run Start or attach to a delegated Codex run
47
+ projects List projects or runs from the run store
48
+ prune Remove archived transport, or purge selected run folders
49
+ unlock Close running records whose runner is gone
50
+ read Render a run's structured event stream
51
+ stop Stop a running Codex run and record FAIL
52
+
53
+ Hook dispatch:
54
+ codex-bridge hook <name> Dispatch a registered guard with stdin unchanged`;
55
+
56
+ const HOOK_COMMAND = 'hook';
57
+
58
+ export function commandOptions(command, argv) {
59
+ const options = {};
60
+ const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
61
+ : command === 'uninstall' ? new Set(['--dry-run'])
62
+ : command === 'doctor' ? new Set(['--probe-contract']) : new Set();
63
+ const flagNames = new Map([
64
+ ['--dry-run', 'dryRun'],
65
+ ['--force', 'force'],
66
+ ['--probe-contract', 'probeContract'],
67
+ ]);
68
+ for (let index = 0; index < argv.length; index += 1) {
69
+ const arg = argv[index];
70
+ if (booleanFlags.has(arg)) {
71
+ options[flagNames.get(arg)] = true;
72
+ continue;
73
+ }
74
+ if (arg !== '--scope' && arg !== '--host') throw new Error(`unknown ${command} option "${arg}"`);
75
+ const value = argv[index + 1];
76
+ if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
77
+ options[arg === '--scope' ? 'scope' : 'host'] = value;
78
+ index += 1;
79
+ }
80
+ return options;
81
+ }
82
+
83
+ export async function main(argv, io = console) {
84
+ const [command, ...rest] = argv;
85
+ if (!command || command === '--help' || command === '-h') {
86
+ io.log(HELP);
87
+ return 0;
88
+ }
89
+ if (command === '--version' || command === '-v') {
90
+ io.log((await packageInfo()).version);
91
+ return 0;
92
+ }
93
+ if (command === 'run') return runCodexCommand(rest);
94
+ if (command === 'doctor') {
95
+ const options = commandOptions(command, rest);
96
+ const host = resolveHost(options);
97
+ // The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
98
+ // measured. Printing the diagnosis first and the measurement after would answer one question
99
+ // twice in one output, with the older answer on top (Plan_52 D25).
100
+ let probe = null;
101
+ if (options.probeContract) {
102
+ probe = await probeContract({ host });
103
+ io.log(`probe: ${probe.message}`);
104
+ }
105
+ const result = await diagnose({ host });
106
+ io.log(renderDoctor(result));
107
+ // An inconclusive probe wrote nothing and measured nothing; exiting 0 would let a failed
108
+ // measurement pass silently in a script (Plan_52 D26).
109
+ return probe && probe.state === 'inconclusive' ? 2 : result.exitCode;
110
+ }
111
+ if (command === 'stop') {
112
+ if (rest.length !== 1) {
113
+ io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
114
+ return 2;
115
+ }
116
+ const result = await stop({ run: rest[0] });
117
+ io.log(result.output);
118
+ return result.exitCode;
119
+ }
120
+ if (command === HOOK_COMMAND) return hook(rest, io);
121
+ if (command === 'read') {
122
+ if (rest.length !== 1) {
123
+ io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
124
+ return 2;
125
+ }
126
+ const result = read({ run: rest[0] });
127
+ io.log(result.output);
128
+ return result.exitCode;
129
+ }
130
+ if (command === 'projects') {
131
+ const result = projects(rest);
132
+ io.log(result.output);
133
+ return result.exitCode;
134
+ }
135
+ if (command === 'prune') {
136
+ const result = await prune(rest);
137
+ io.log(result.output);
138
+ return result.exitCode;
139
+ }
140
+ if (command === 'sweep') {
141
+ io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
142
+ return 2;
143
+ }
144
+ if (command === 'unlock') {
145
+ const result = unlock(rest);
146
+ io.log(result.output);
147
+ return result.exitCode;
148
+ }
149
+ if (command === 'permissions') {
150
+ let action;
151
+ let optionArgs = rest;
152
+ if (rest[0] && !rest[0].startsWith('-')) {
153
+ action = rest[0];
154
+ optionArgs = rest.slice(1);
155
+ }
156
+ if (action && !['add', 'remove'].includes(action)) {
157
+ throw new Error(`unknown permissions action "${action}"`);
158
+ }
159
+ const options = commandOptions(command, optionArgs);
160
+ const host = resolveHost(options);
161
+ const result = await permissions({ host, action });
162
+ io.log(result.output);
163
+ return result.exitCode;
164
+ }
165
+ if (command === 'install' || command === 'update' || command === 'uninstall') {
166
+ const options = commandOptions(command, rest);
167
+ const host = resolveHost(options);
168
+ const handlers = {
169
+ install: () => install({ host, dryRun: options.dryRun, force: options.force }),
170
+ update: () => update({ host, dryRun: options.dryRun, force: options.force }),
171
+ uninstall: () => uninstall({ host, dryRun: options.dryRun }),
172
+ };
173
+ const result = await handlers[command]();
174
+ io.log(result.output);
175
+ return result.exitCode;
176
+ }
177
+ io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
178
+ return 2;
179
+ }
180
+
181
+ if (isInvokedDirectly(process.argv[1], import.meta.url)) {
182
+ main(process.argv.slice(2))
183
+ .then((exitCode) => { process.exitCode = exitCode; })
184
+ .catch((err) => {
185
+ console.error(`codex-bridge: ${err.message}`);
186
+ process.exitCode = 2;
187
+ });
188
+ }
@@ -0,0 +1,23 @@
1
+ /** Shapes how one diagnosis line looks. */
2
+
3
+ // Built from a character code rather than written as an escape: an escape sequence in this file
4
+ // survives every editor, but not every tool that has rewritten it — a literal escape byte landed
5
+ // here once during the Plan_52 split and is invisible in diffs and greps, which is exactly the kind
6
+ // of character a source file should not carry silently.
7
+ const ESC = String.fromCharCode(27);
8
+ const WARNING = `${ESC}[33m`;
9
+ const RESET = `${ESC}[0m`;
10
+
11
+ export function check(key, status, value) {
12
+ return { key, status, value };
13
+ }
14
+
15
+ export function renderDoctor(result) {
16
+ return result.checks.map(({ key, status, value }) => {
17
+ if ((key === 'agents' || key === 'command') && status !== 'ok') return value;
18
+ const rendered = `[${status}] ${key}: ${value}`;
19
+ return ['retention', 'conventions', 'permissions', 'liveRuns', 'hostContract'].includes(key) && status === 'warn'
20
+ ? `${WARNING}${rendered}${RESET}`
21
+ : rendered;
22
+ }).join('\n');
23
+ }
@@ -0,0 +1,114 @@
1
+ /** Reports the health of every hook this package registered in the host. */
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { HOOK_DEFINITIONS } from './manifest.mjs';
5
+ import { recordTarget } from './install-record.mjs';
6
+ import { commandFor, inspectHook } from './settings-merge.mjs';
7
+ import { check } from './doctor-format.mjs';
8
+
9
+ function hookVersion(command, record, bridgeResult) {
10
+ if (command.startsWith('codex-bridge hook ')) {
11
+ return bridgeResult.available
12
+ ? `global command ${bridgeResult.value}`
13
+ : `global command unavailable (${bridgeResult.value})`;
14
+ }
15
+ return `installed copy ${record.name}@${record.version}`;
16
+ }
17
+
18
+ function probedVersion(result) {
19
+ return result.available ? result.value.match(/(\d+\.\d+\.\d+(?:[-+][^\s]+)?)\s*$/)?.[1] : null;
20
+ }
21
+
22
+ function startHook(target) {
23
+ return new Promise((resolve) => {
24
+ const child = spawn(process.execPath, [target], {
25
+ stdio: ['ignore', 'ignore', 'pipe'],
26
+ windowsHide: true,
27
+ });
28
+ let stderr = '';
29
+ child.stderr.setEncoding('utf8');
30
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
31
+ child.on('error', (error) => resolve({ status: null, stderr: error.message }));
32
+ child.on('close', (status) => resolve({ status, stderr }));
33
+ });
34
+ }
35
+
36
+ function startFailure(definition, result) {
37
+ if (result.status === 0) return null;
38
+ const lines = result.stderr.trim().split(/\r?\n/);
39
+ const resolver = lines.find((line) => /^Error \[ERR_MODULE_NOT_FOUND\]|Cannot find module/.test(line));
40
+ return `${definition.file} did not start: ${resolver || lines.find(Boolean) || `exit code ${result.status}`}`;
41
+ }
42
+
43
+ /**
44
+ * `sourceKind` arrives as an argument rather than being read here, because the function that knows
45
+ * which copy of the package is answering lives beside `diagnose`. Importing it back would make this
46
+ * module and its caller depend on each other, and a cycle between a check and its orchestrator is
47
+ * how one of them ends up half-initialised at import time.
48
+ */
49
+ export async function hookChecks(host, record, bridgeProbe, ownPackage, sourceKind) {
50
+ let bridgeResult;
51
+ const bridge = () => {
52
+ bridgeResult ??= bridgeProbe();
53
+ return bridgeResult;
54
+ };
55
+ const starts = record ? await Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
56
+ const recorded = record.hooks.find((hook) => hook.event === definition.event
57
+ && path.basename(hook.path) === definition.file);
58
+ return recorded ? startHook(recordTarget(host, recorded)) : null;
59
+ })) : [];
60
+ return Promise.all(HOOK_DEFINITIONS.map(async (definition, index) => {
61
+ const key = `hook:${definition.event}`;
62
+ if (!record) return check(key, 'warn', 'cannot check before installation');
63
+ // The file, not just the event: PreToolUse carries two hooks of this package, and matching
64
+ // by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
65
+ // hook line is the only place an operator can see WHICH file a matcher was registered for,
66
+ // so a wrong name here is worse than no line at all.
67
+ const recorded = record.hooks.find((hook) => hook.event === definition.event
68
+ && path.basename(hook.path) === definition.file);
69
+ if (!recorded) {
70
+ return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
71
+ }
72
+ const expected = recordTarget(host, recorded);
73
+ const fullCommand = commandFor(expected);
74
+ const shortCommand = `codex-bridge hook ${definition.name}`;
75
+ try {
76
+ const state = await inspectHook(host.settingsPath, {
77
+ event: definition.event,
78
+ matcher: definition.matcher,
79
+ command: recorded.command || fullCommand,
80
+ alternateCommands: [fullCommand, shortCommand],
81
+ });
82
+ if (state.present) {
83
+ const recordedMatcher = state.matchedMatcher;
84
+ const command = state.matchedCommand || recorded.command || fullCommand;
85
+ const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
86
+ const reason = form === 'short'
87
+ ? 'PATH command uses the globally installed package'
88
+ : 'full path uses the copy placed by the last install';
89
+ const problems = [startFailure(definition, starts[index])].filter(Boolean);
90
+ // The matcher printed is the one the host recorded, never the one this package declares.
91
+ // Until 2026-08-23 the line stated the registry's own value, so a host stuck on the
92
+ // pre-shell matcher read as [ok] while the worktree lock never saw a Bash command — the
93
+ // same shape as the 2026-08-10 defect, where doctor counted files instead of
94
+ // reading them. A check that compares the package to itself cannot fail.
95
+ if (recordedMatcher !== definition.matcher) problems.push(`recorded matcher ${recordedMatcher} differs from expected ${definition.matcher}; run codex-bridge update --force`);
96
+ const commandVersion = form === 'short' ? bridge() : null;
97
+ const globalVersion = probedVersion(commandVersion || { available: false });
98
+ if (globalVersion && globalVersion !== ownPackage.version) {
99
+ problems.push(`global PATH package version ${globalVersion} differs from ${sourceKind} version ${ownPackage.version}`);
100
+ }
101
+ const health = problems.length ? `; ${problems.join('; ')}` : '';
102
+ return check(key, problems.length ? 'warn' : 'ok', `${definition.event} matcher ${recordedMatcher} -> ${expected} (${form} command; ${reason}; ${hookVersion(command, record, commandVersion)}${health})`);
103
+ }
104
+ const command = recorded.command || fullCommand;
105
+ const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
106
+ const failure = startFailure(definition, starts[index]);
107
+ const commandVersion = form === 'short' ? bridge() : null;
108
+ return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file} (${form} command; ${hookVersion(command, record, commandVersion)}${failure ? `; ${failure}` : ''})`);
109
+ } catch (err) {
110
+ const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
111
+ return check(key, 'warn', `${definition.event} hook: ${value}`);
112
+ }
113
+ }));
114
+ }
@@ -0,0 +1,111 @@
1
+ /** Reports whether what this package installed into the host still matches the package. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ buildInstallPlan,
6
+ contentFingerprint,
7
+ fileFingerprint,
8
+ } from './manifest.mjs';
9
+ import { plannedContent } from './copy.mjs';
10
+ import { inspectPermissions } from './permissions.mjs';
11
+ import { readRulesRegistry } from './rules-owners.mjs';
12
+ import { parseFrontmatter } from './frontmatter.mjs';
13
+ import { check } from './doctor-format.mjs';
14
+
15
+ export async function exists(target) {
16
+ try {
17
+ await fs.access(target);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export async function isFile(target) {
25
+ try {
26
+ return (await fs.stat(target)).isFile();
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export async function agentsCheck(host, record) {
33
+ if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
34
+ const plan = await buildInstallPlan(host);
35
+ const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
36
+ const mismatches = [];
37
+ const unreadable = [];
38
+ for (const item of agents) {
39
+ const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
40
+ if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
41
+ const expectedName = path.basename(item.source, '.md');
42
+ try {
43
+ const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
44
+ if (!frontmatter) throw new Error('frontmatter is missing');
45
+ if (frontmatter.name !== expectedName) {
46
+ throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
47
+ }
48
+ } catch (err) {
49
+ unreadable.push(`${path.basename(item.target)} (${err.message})`);
50
+ }
51
+ }
52
+ // The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
53
+ // advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
54
+ // the package command — in an installed definition: doctor kept exit 0 while every delegation
55
+ // would stop on a permission prompt. A definition that is not this package's is not judged healthy.
56
+ if (unreadable.length) {
57
+ return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
58
+ }
59
+ return mismatches.length
60
+ ? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
61
+ : check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
62
+ }
63
+
64
+ export async function conventionsCheck(host) {
65
+ const file = host.brandConventionsPath;
66
+ let content;
67
+ try {
68
+ content = await fs.readFile(file, 'utf8');
69
+ } catch (err) {
70
+ if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
71
+ return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
72
+ }
73
+ return content.trim()
74
+ ? check('conventions', 'ok', `${file} (found)`)
75
+ : check('conventions', 'warn', `${file} (found but empty)`);
76
+ }
77
+
78
+ export async function rulesCheck(host, record) {
79
+ if (!record) return check('rules', 'warn', 'cannot check before installation');
80
+ if (!record.rules) {
81
+ return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
82
+ }
83
+ let registry;
84
+ try {
85
+ registry = await readRulesRegistry(host);
86
+ } catch (err) {
87
+ // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
88
+ return check('rules', 'fail', err.message);
89
+ }
90
+ const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
91
+ const fingerprint = await fileFingerprint(record.rules.path);
92
+ if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
93
+ return fingerprint === record.rules.fingerprint
94
+ ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
95
+ : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
96
+ }
97
+
98
+ export async function permissionsCheck(host) {
99
+ try {
100
+ const status = await inspectPermissions(host.settingsPath);
101
+ // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
102
+ // doctor does not turn a healthy installation red merely because hardening was not requested.
103
+ // The ask count is part of the line because a full set shadowed by `ask` reads as working and
104
+ // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
105
+ const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
106
+ return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
107
+ `${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
108
+ } catch (err) {
109
+ return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
110
+ }
111
+ }