@lyupro/codex-bridge 0.5.2 → 0.5.4

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.
@@ -0,0 +1,55 @@
1
+ /** Reports the state of run storage on this machine: location, live count, retention. */
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
5
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
6
+ import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
7
+ import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
8
+ import { STOP_COMMAND_TEMPLATE } from '../src/home/lib/stop-contract.mjs';
9
+ import { check } from './doctor-format.mjs';
10
+
11
+ export function retentionCheck(host) {
12
+ try {
13
+ const notice = retentionNotice(readRunConfig(host.brandConfigPath));
14
+ return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
15
+ } catch (err) {
16
+ return check('retention', 'fail', `invalid configuration: ${err.message}`);
17
+ }
18
+ }
19
+
20
+ /**
21
+ * The runner asks git for the repository root before it picks a runs folder, so doctor has to
22
+ * ask the same question: run from `src/home/lib/runner`, a plain cwd would name the folder `runner` and
23
+ * report a location no run will ever use.
24
+ */
25
+ function repoRoot(cwd) {
26
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
27
+ return top.status === 0 && top.stdout.trim() ? top.stdout.trim() : cwd;
28
+ }
29
+
30
+ /**
31
+ * A marker that cannot be read is exactly what doctor exists to report, so it is caught here.
32
+ * Left to propagate it would kill the whole diagnosis and hide the seven checks around it.
33
+ */
34
+ export function projectRunsCheck() {
35
+ let resolved;
36
+ try {
37
+ resolved = resolveProjectRunsDir(runsRoot(), repoRoot(process.cwd()), { create: false });
38
+ } catch (err) {
39
+ return check('projectRuns', 'fail', err.message);
40
+ }
41
+ const note = resolved.reason === 'created' ? 'not created yet' : resolved.reason;
42
+ return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
43
+ }
44
+
45
+ export function liveRunsCheck() {
46
+ let count;
47
+ try {
48
+ count = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true }).length;
49
+ } catch (err) {
50
+ return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
51
+ }
52
+ if (!count) return check('liveRuns', 'ok', '0 runs working right now');
53
+ const noun = count === 1 ? 'run' : 'runs';
54
+ return check('liveRuns', 'warn', `${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`);
55
+ }
package/cli/doctor.mjs CHANGED
@@ -1,79 +1,24 @@
1
1
  /** Diagnoses a Claude Code host without modifying it. */
2
- import fs from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
5
- import { spawn, spawnSync } from 'node:child_process';
6
- import {
7
- buildInstallPlan,
8
- contentFingerprint,
9
- fileFingerprint,
10
- HOOK_DEFINITIONS,
11
- readInstallRecord,
12
- packageInfo,
13
- } from './manifest.mjs';
14
- import { plannedContent } from './copy.mjs';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { readInstallRecord, packageInfo } from './manifest.mjs';
15
6
  import { recordTarget } from './install-record.mjs';
16
- import { inspectPermissions } from './permissions.mjs';
17
- import { commandFor, inspectHook } from './settings-merge.mjs';
18
- import { readRulesRegistry } from './rules-owners.mjs';
19
- import { parseFrontmatter } from './frontmatter.mjs';
20
- import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
21
7
  import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
22
- import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
23
- import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
24
- import { STOP_COMMAND_TEMPLATE } from '../src/home/lib/stop-contract.mjs';
25
-
26
- const WARNING = '\u001b[33m';
27
- const RESET = '\u001b[0m';
28
-
29
- async function exists(target) {
30
- try {
31
- await fs.access(target);
32
- return true;
33
- } catch {
34
- return false;
35
- }
36
- }
37
-
38
- async function isFile(target) {
39
- try {
40
- return (await fs.stat(target)).isFile();
41
- } catch {
42
- return false;
43
- }
44
- }
45
-
46
- async function agentsCheck(host, record) {
47
- if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
48
- const plan = await buildInstallPlan(host);
49
- const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
50
- const mismatches = [];
51
- const unreadable = [];
52
- for (const item of agents) {
53
- const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
54
- if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
55
- const expectedName = path.basename(item.source, '.md');
56
- try {
57
- const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
58
- if (!frontmatter) throw new Error('frontmatter is missing');
59
- if (frontmatter.name !== expectedName) {
60
- throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
61
- }
62
- } catch (err) {
63
- unreadable.push(`${path.basename(item.target)} (${err.message})`);
64
- }
65
- }
66
- // The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
67
- // advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
68
- // the package command — in an installed definition: doctor kept exit 0 while every delegation
69
- // would stop on a permission prompt. A definition that is not this package's is not judged healthy.
70
- if (unreadable.length) {
71
- return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
72
- }
73
- return mismatches.length
74
- ? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
75
- : check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
76
- }
8
+ import { check, renderDoctor } from './doctor-format.mjs';
9
+ import {
10
+ agentsCheck,
11
+ conventionsCheck,
12
+ exists,
13
+ isFile,
14
+ permissionsCheck,
15
+ rulesCheck,
16
+ } from './doctor-installation.mjs';
17
+ import { hookChecks } from './doctor-hooks.mjs';
18
+ import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
19
+ import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
20
+
21
+ export { renderDoctor };
77
22
 
78
23
  function bridgeCommandCheck(result) {
79
24
  return result.available
@@ -81,33 +26,6 @@ function bridgeCommandCheck(result) {
81
26
  : check('command', 'warn', `codex-bridge does not resolve on PATH (${result.value}); run npm i -g @lyupro/codex-bridge`);
82
27
  }
83
28
 
84
- function check(key, status, value) {
85
- return { key, status, value };
86
- }
87
-
88
- function retentionCheck(host) {
89
- try {
90
- const notice = retentionNotice(readRunConfig(host.brandConfigPath));
91
- return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
92
- } catch (err) {
93
- return check('retention', 'fail', `invalid configuration: ${err.message}`);
94
- }
95
- }
96
-
97
- async function conventionsCheck(host) {
98
- const file = host.brandConventionsPath;
99
- let content;
100
- try {
101
- content = await fs.readFile(file, 'utf8');
102
- } catch (err) {
103
- if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
104
- return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
105
- }
106
- return content.trim()
107
- ? check('conventions', 'ok', `${file} (found)`)
108
- : check('conventions', 'warn', `${file} (found but empty)`);
109
- }
110
-
111
29
  /**
112
30
  * Which copy of the package is answering. Plan_19 gives the CLI a second name, and a global install
113
31
  * puts a second copy of the package on the machine beside any clone. `update` copies host files
@@ -129,43 +47,6 @@ function sourceCheck() {
129
47
  return check('source', 'ok', `${source.root} (${source.kind === 'installed copy' ? 'installed package' : source.kind})`);
130
48
  }
131
49
 
132
- /**
133
- * The runner asks git for the repository root before it picks a runs folder, so doctor has to
134
- * ask the same question: run from `src/home/lib/runner`, a plain cwd would name the folder `runner` and
135
- * report a location no run will ever use.
136
- */
137
- function repoRoot(cwd) {
138
- const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
139
- return top.status === 0 && top.stdout.trim() ? top.stdout.trim() : cwd;
140
- }
141
-
142
- /**
143
- * A marker that cannot be read is exactly what doctor exists to report, so it is caught here.
144
- * Left to propagate it would kill the whole diagnosis and hide the seven checks around it.
145
- */
146
- function projectRunsCheck() {
147
- let resolved;
148
- try {
149
- resolved = resolveProjectRunsDir(runsRoot(), repoRoot(process.cwd()), { create: false });
150
- } catch (err) {
151
- return check('projectRuns', 'fail', err.message);
152
- }
153
- const note = resolved.reason === 'created' ? 'not created yet' : resolved.reason;
154
- return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
155
- }
156
-
157
- function liveRunsCheck() {
158
- let count;
159
- try {
160
- count = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true }).length;
161
- } catch (err) {
162
- return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
163
- }
164
- if (!count) return check('liveRuns', 'ok', '0 runs working right now');
165
- const noun = count === 1 ? 'run' : 'runs';
166
- return check('liveRuns', 'warn', `${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`);
167
- }
168
-
169
50
  export function probeCodex() {
170
51
  const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
171
52
  const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
@@ -186,136 +67,14 @@ export function probeCodexBridge() {
186
67
  return { available: true, value: (result.stdout || result.stderr).trim() };
187
68
  }
188
69
 
189
- function hookVersion(command, record, bridgeResult) {
190
- if (command.startsWith('codex-bridge hook ')) {
191
- return bridgeResult.available
192
- ? `global command ${bridgeResult.value}`
193
- : `global command unavailable (${bridgeResult.value})`;
194
- }
195
- return `installed copy ${record.name}@${record.version}`;
196
- }
197
-
198
- function probedVersion(result) {
199
- return result.available ? result.value.match(/(\d+\.\d+\.\d+(?:[-+][^\s]+)?)\s*$/)?.[1] : null;
200
- }
201
-
202
- function startHook(target) {
203
- return new Promise((resolve) => {
204
- const child = spawn(process.execPath, [target], {
205
- stdio: ['ignore', 'ignore', 'pipe'],
206
- windowsHide: true,
207
- });
208
- let stderr = '';
209
- child.stderr.setEncoding('utf8');
210
- child.stderr.on('data', (chunk) => { stderr += chunk; });
211
- child.on('error', (error) => resolve({ status: null, stderr: error.message }));
212
- child.on('close', (status) => resolve({ status, stderr }));
213
- });
214
- }
215
-
216
- function startFailure(definition, result) {
217
- if (result.status === 0) return null;
218
- const lines = result.stderr.trim().split(/\r?\n/);
219
- const resolver = lines.find((line) => /^Error \[ERR_MODULE_NOT_FOUND\]|Cannot find module/.test(line));
220
- return `${definition.file} did not start: ${resolver || lines.find(Boolean) || `exit code ${result.status}`}`;
221
- }
222
-
223
- async function hookChecks(host, record, bridgeProbe, ownPackage) {
224
- let bridgeResult;
225
- const bridge = () => {
226
- bridgeResult ??= bridgeProbe();
227
- return bridgeResult;
228
- };
229
- const starts = record ? await Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
230
- const recorded = record.hooks.find((hook) => hook.event === definition.event
231
- && path.basename(hook.path) === definition.file);
232
- return recorded ? startHook(recordTarget(host, recorded)) : null;
233
- })) : [];
234
- return Promise.all(HOOK_DEFINITIONS.map(async (definition, index) => {
235
- const key = `hook:${definition.event}`;
236
- if (!record) return check(key, 'warn', 'cannot check before installation');
237
- // The file, not just the event: PreToolUse carries two hooks of this package, and matching
238
- // by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
239
- // hook line is the only place an operator can see WHICH file a matcher was registered for,
240
- // so a wrong name here is worse than no line at all.
241
- const recorded = record.hooks.find((hook) => hook.event === definition.event
242
- && path.basename(hook.path) === definition.file);
243
- if (!recorded) {
244
- return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
245
- }
246
- const expected = recordTarget(host, recorded);
247
- const fullCommand = commandFor(expected);
248
- const shortCommand = `codex-bridge hook ${definition.name}`;
249
- try {
250
- const state = await inspectHook(host.settingsPath, {
251
- event: definition.event,
252
- matcher: definition.matcher,
253
- command: recorded.command || fullCommand,
254
- alternateCommands: [fullCommand, shortCommand],
255
- });
256
- if (state.present) {
257
- const command = state.matchedCommand || recorded.command || fullCommand;
258
- const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
259
- const reason = form === 'short'
260
- ? 'PATH command uses the globally installed package'
261
- : 'full path uses the copy placed by the last install';
262
- const problems = [startFailure(definition, starts[index])].filter(Boolean);
263
- const commandVersion = form === 'short' ? bridge() : null;
264
- const globalVersion = probedVersion(commandVersion || { available: false });
265
- if (globalVersion && globalVersion !== ownPackage.version) {
266
- problems.push(`global PATH package version ${globalVersion} differs from ${packageSource().kind} version ${ownPackage.version}`);
267
- }
268
- const health = problems.length ? `; ${problems.join('; ')}` : '';
269
- return check(key, problems.length ? 'warn' : 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected} (${form} command; ${reason}; ${hookVersion(command, record, commandVersion)}${health})`);
270
- }
271
- const command = recorded.command || fullCommand;
272
- const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
273
- const failure = startFailure(definition, starts[index]);
274
- const commandVersion = form === 'short' ? bridge() : null;
275
- 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}` : ''})`);
276
- } catch (err) {
277
- const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
278
- return check(key, 'warn', `${definition.event} hook: ${value}`);
279
- }
280
- }));
281
- }
282
-
283
- async function rulesCheck(host, record) {
284
- if (!record) return check('rules', 'warn', 'cannot check before installation');
285
- if (!record.rules) {
286
- return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
287
- }
288
- let registry;
289
- try {
290
- registry = await readRulesRegistry(host);
291
- } catch (err) {
292
- // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
293
- return check('rules', 'fail', err.message);
294
- }
295
- const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
296
- const fingerprint = await fileFingerprint(record.rules.path);
297
- if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
298
- return fingerprint === record.rules.fingerprint
299
- ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
300
- : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
301
- }
302
-
303
- async function permissionsCheck(host) {
304
- try {
305
- const status = await inspectPermissions(host.settingsPath);
306
- // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
307
- // doctor does not turn a healthy installation red merely because hardening was not requested.
308
- // The ask count is part of the line because a full set shadowed by `ask` reads as working and
309
- // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
310
- const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
311
- return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
312
- `${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
313
- } catch (err) {
314
- return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
315
- }
316
- }
317
-
318
- export async function diagnose({ host, codexProbe = probeCodex, bridgeProbe = probeCodexBridge, currentPackage } = {}) {
70
+ export async function diagnose({
71
+ host,
72
+ codexProbe = probeCodex,
73
+ bridgeProbe = probeCodexBridge,
74
+ currentPackage,
75
+ contractRecord,
76
+ hostVersion,
77
+ } = {}) {
319
78
  const checks = [sourceCheck()];
320
79
  const hostExists = await exists(host.root);
321
80
  checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
@@ -357,7 +116,15 @@ export async function diagnose({ host, codexProbe = probeCodex, bridgeProbe = pr
357
116
  checks.push(await permissionsCheck(host));
358
117
  const bridge = bridgeProbe();
359
118
  checks.push(bridgeCommandCheck(bridge));
360
- checks.push(...await hookChecks(host, record, () => bridge, ownPackage));
119
+ checks.push(...await hookChecks(host, record, () => bridge, ownPackage, packageSource().kind));
120
+ const hostContract = contractStatus({
121
+ record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
122
+ version: hostVersion === undefined ? detectHostVersion() : hostVersion,
123
+ });
124
+ const hostContractStatus = hostContract.state === 'verified'
125
+ ? 'ok'
126
+ : hostContract.state === 'ignored' ? 'fail' : 'warn';
127
+ checks.push(check('hostContract', hostContractStatus, hostContract.message));
361
128
  const retention = retentionCheck(host);
362
129
  checks.push(retention);
363
130
  const conventions = await conventionsCheck(host);
@@ -374,19 +141,10 @@ export async function diagnose({ host, codexProbe = probeCodex, bridgeProbe = pr
374
141
 
375
142
  return {
376
143
  exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
377
- || retention.status === 'fail' || conventions.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
144
+ || hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
145
+ || projectRuns.status === 'fail' ? 1 : 0,
378
146
  checks,
379
147
  record,
380
148
  missingFiles,
381
149
  };
382
150
  }
383
-
384
- export function renderDoctor(result) {
385
- return result.checks.map(({ key, status, value }) => {
386
- if ((key === 'agents' || key === 'command') && status !== 'ok') return value;
387
- const rendered = `[${status}] ${key}: ${value}`;
388
- return ['retention', 'conventions', 'permissions', 'liveRuns'].includes(key) && status === 'warn'
389
- ? `${WARNING}${rendered}${RESET}`
390
- : rendered;
391
- }).join('\n');
392
- }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Records whether the installed Claude Code host still honours hook refusals.
3
+ *
4
+ * Every guard here refuses work with `permissionDecision: "deny"`, and the sibling key of that same
5
+ * object died quietly: a neighbouring package established that `"allow"` stopped being honoured
6
+ * somewhere between host 2.1.119 and 2.1.231, with no error printed on the way. Nothing in this
7
+ * package would notice `deny` going the same way — the suite checks that a hook RETURNED a refusal,
8
+ * never that the host APPLIED it. The record is bound to a host version because that is what
9
+ * changes underneath an installation: the host updates itself, the package does not.
10
+ */
11
+ import { spawnSync } from 'node:child_process';
12
+ import { randomUUID } from 'node:crypto';
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
16
+ import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
17
+ import { readJsonFile } from '../src/home/lib/json-file.mjs';
18
+
19
+ export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
20
+
21
+ /**
22
+ * The command that runs the probe, kept here so every message gains it in one edit.
23
+ *
24
+ * It stayed unnamed in the messages until the command existed: a package that sends an operator to
25
+ * a command answering `unknown doctor option` has spent its credibility on the first line they
26
+ * read. The advice arrived in the same change as the command, and only in the states a probe can
27
+ * actually change — a message that advises a run which would tell you nothing new is noise.
28
+ *
29
+ * Derived rather than spelled out: Plan_17 §5 found installer and hook lists drifting apart once
30
+ * the same name lived in two places. `CLI_NAMES[0]` is the canonical spelling.
31
+ */
32
+ export const PROBE_COMMAND = `${CLI_NAMES[0]} doctor --probe-contract`;
33
+
34
+ export function hostContractPath(host) {
35
+ if (!host?.brandRoot) throw new Error('host has no brand installation root');
36
+ return path.join(host.brandRoot, HOST_CONTRACT_RECORD_NAME);
37
+ }
38
+
39
+ export async function readHostContract(host) {
40
+ const target = hostContractPath(host);
41
+ try {
42
+ const record = await readJsonFile(target);
43
+ if (!record || typeof record !== 'object' || Array.isArray(record)
44
+ || typeof record.version !== 'string' || !record.version) return null;
45
+ return {
46
+ version: record.version,
47
+ checkedAt: record.checkedAt,
48
+ result: record.result,
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export async function writeHostContract(host, { version, result, now = new Date() }) {
56
+ if (typeof version !== 'string' || !version) {
57
+ throw new Error('host contract version must be a non-empty string');
58
+ }
59
+ if (result !== 'honored' && result !== 'ignored') {
60
+ throw new Error('host contract result must be honored or ignored');
61
+ }
62
+
63
+ const target = hostContractPath(host);
64
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
65
+ const record = { version, result, checkedAt: now.toISOString() };
66
+ await fs.mkdir(host.brandRoot, { recursive: true });
67
+ try {
68
+ await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
69
+ await fs.rename(temporary, target);
70
+ } finally {
71
+ await fs.rm(temporary, { force: true });
72
+ }
73
+ }
74
+
75
+ export function parseHostVersion(text) {
76
+ if (typeof text !== 'string') return null;
77
+ return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
78
+ }
79
+
80
+ export function detectHostVersion({ run = spawnSync } = {}) {
81
+ try {
82
+ const result = run('claude', ['--version'], {
83
+ encoding: 'utf8',
84
+ shell: false,
85
+ timeout: 5000,
86
+ });
87
+ if (!result || result.error || result.status !== 0) return null;
88
+ return parseHostVersion(result.stdout);
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export function contractStatus({ record, version }) {
95
+ // Loose equality on purpose: a caller that forgot the field hands over `undefined`, and the
96
+ // stale branch below would then report "the current host is undefined" as if it had measured it.
97
+ if (version == null) {
98
+ return {
99
+ state: 'unknown-host',
100
+ message: 'The host version could not be read, so the refusal contract cannot be judged.',
101
+ };
102
+ }
103
+ if (!record) {
104
+ return {
105
+ state: 'unverified',
106
+ message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
107
+ };
108
+ }
109
+ if (record.version !== version) {
110
+ return {
111
+ state: 'stale',
112
+ message: `The refusal contract was probed on host ${record.version}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
113
+ };
114
+ }
115
+ if (record.result === 'ignored') {
116
+ const guards = HOOK_DEFINITIONS
117
+ .filter((definition) => definition.event === 'PreToolUse')
118
+ .map((definition) => definition.name)
119
+ .join(', ');
120
+ return {
121
+ state: 'ignored',
122
+ message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
123
+ };
124
+ }
125
+ if (record.result === 'honored') {
126
+ return {
127
+ state: 'verified',
128
+ message: `Host ${version} honours hook refusals (verified ${record.checkedAt}).`,
129
+ };
130
+ }
131
+ return {
132
+ state: 'unverified',
133
+ message: `The refusal contract record is invalid (host ${version}); run ${PROBE_COMMAND}.`,
134
+ };
135
+ }
package/cli/hosts.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
5
6
 
6
7
  function repositoryRoot(start) {
7
8
  let current = path.resolve(start);
@@ -35,9 +36,9 @@ export function resolveHost({
35
36
  // Plan_25 moves package-owned runtime files out of foreign Claude settings, whose absolute
36
37
  // hook paths caused the package-file-layout incident. Keep the brand root overrideable so tests
37
38
  // and later installation steps never need to know the operator's home directory.
38
- const resolvedBrandRoot = path.resolve(
39
- brandRoot || process.env.CODEX_BRIDGE_HOME || path.join(homedir, '.lyupro', '.codex-bridge'),
40
- );
39
+ const resolvedBrandRoot = brandRoot
40
+ ? path.resolve(brandRoot)
41
+ : resolveBrandHome({ homedir, env: process.env }).root;
41
42
  const brandPaths = {
42
43
  brandRoot: resolvedBrandRoot,
43
44
  brandHooksDir: path.join(resolvedBrandRoot, 'hooks'),
package/cli/install.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from './settings-merge.mjs';
27
27
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
28
28
  import { addPermissionRules } from './permissions.mjs';
29
+ import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
29
30
  import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
30
31
 
31
32
  const WARNING = '\u001b[33m';
@@ -56,6 +57,10 @@ function retentionOutput(line, output) {
56
57
  return `${output}\n${line}`;
57
58
  }
58
59
 
60
+ function contractOutput(status, output) {
61
+ return status.state === 'verified' ? output : `${output}\n${status.message}`;
62
+ }
63
+
59
64
  async function migrateLegacySeed(host, seed) {
60
65
  if (await targetExists(seed.target)) return;
61
66
  const legacyName = path.basename(seed.target) === 'config.json' ? 'run-config.json' : path.basename(seed.target);
@@ -73,10 +78,22 @@ async function migrateLegacySeed(host, seed) {
73
78
  await fs.rm(legacy, { force: true });
74
79
  }
75
80
 
76
- async function installInRun({ host, dryRun = false, force = false, packageRoot, env = process.env } = {}) {
81
+ async function installInRun({
82
+ host,
83
+ dryRun = false,
84
+ force = false,
85
+ packageRoot,
86
+ env = process.env,
87
+ contractRecord,
88
+ hostVersion,
89
+ } = {}) {
77
90
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
78
91
  await readRulesRegistry(host);
79
92
  const configuredRetentionLine = retentionLine(host);
93
+ const hostContract = contractStatus({
94
+ record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
95
+ version: hostVersion === undefined ? detectHostVersion() : hostVersion,
96
+ });
80
97
  const plan = await buildInstallPlan(host, packageRoot);
81
98
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
82
99
  const currentPackage = await packageInfo(packageRoot);
@@ -112,7 +129,7 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
112
129
  const files = conflicts.map(({ item }) => ` ${item.relativeToRoot || item.name}`).join('\n');
113
130
  return {
114
131
  exitCode: 1,
115
- output: retentionOutput(configuredRetentionLine, `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`),
132
+ output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`)),
116
133
  };
117
134
  }
118
135
 
@@ -124,7 +141,10 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
124
141
  if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
125
142
  && recordHasHooks(record, targets) && sameRecord) {
126
143
  if (!dryRun) await addRulesOwner(host);
127
- return { exitCode: 0, output: retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.') };
144
+ return {
145
+ exitCode: 0,
146
+ output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')),
147
+ };
128
148
  }
129
149
 
130
150
  if (dryRun) {
@@ -140,7 +160,7 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
140
160
  : `Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
141
161
  });
142
162
  lines.push('Would write installation record in the brand root.');
143
- return { exitCode: 0, output: retentionOutput(configuredRetentionLine, lines.join('\n')) };
163
+ return { exitCode: 0, output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))) };
144
164
  }
145
165
 
146
166
  // Claim ownership of the shared rules file before writing anything. Claiming it last meant a
@@ -196,10 +216,13 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
196
216
  });
197
217
  return {
198
218
  exitCode: 0,
199
- output: retentionOutput(
200
- configuredRetentionLine,
201
- `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`
202
- + `\nGranted ${permissionResult.added} permission rule(s) in ${host.settingsPath} so the host runs the package command without asking.`,
219
+ output: contractOutput(
220
+ hostContract,
221
+ retentionOutput(
222
+ configuredRetentionLine,
223
+ `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`
224
+ + `\nGranted ${permissionResult.added} permission rule(s) in ${host.settingsPath} so the host runs the package command without asking.`,
225
+ ),
203
226
  ),
204
227
  };
205
228
  }