@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.
- package/CHANGELOG.md +491 -336
- package/README.md +152 -152
- package/bin/codex-bridge.mjs +188 -174
- package/cli/doctor-format.mjs +23 -0
- package/cli/doctor-hooks.mjs +114 -0
- package/cli/doctor-installation.mjs +111 -0
- package/cli/doctor-runs.mjs +55 -0
- package/cli/doctor.mjs +35 -277
- package/cli/host-contract.mjs +135 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install.mjs +31 -8
- package/cli/probe-contract.mjs +174 -0
- package/cli/settings-merge.mjs +55 -10
- package/package.json +49 -49
- package/scripts/run-tests.mjs +34 -2
- package/scripts/suite-count.mjs +65 -0
- package/src/agents/codex-build.md +265 -265
- package/src/agents/codex-review.md +219 -219
- package/src/agents/codex-scout.md +232 -232
- package/src/home/hooks/shell-write-intent.mjs +120 -0
- package/src/home/hooks/worktree-lock.mjs +109 -77
- package/src/home/hooks/worktree-witness.mjs +113 -0
- package/src/home/lib/brand-home.mjs +39 -0
- package/src/home/lib/heartbeat.mjs +4 -1
- package/src/home/lib/hook-definitions.mjs +10 -1
- package/src/home/lib/meta/reply.mjs +27 -0
- package/src/home/lib/run-config.mjs +18 -6
- package/src/home/lib/runner/codex-args.mjs +154 -0
- package/src/home/lib/runner/codex-cmd.mjs +6 -130
- package/src/home/lib/runner/launcher.mjs +18 -30
- package/src/home/lib/runner/sandbox-flags.mjs +27 -0
- package/src/home/lib/runner/worker-order.mjs +56 -0
- package/src/home/lib/write-meta.mjs +11 -3
|
@@ -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 {
|
|
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 {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
||
|
|
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 =
|
|
39
|
-
|
|
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({
|
|
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 {
|
|
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:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
}
|