@lyupro/codex-bridge 0.6.5 → 0.6.7
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 +60 -0
- package/README.md +5 -5
- package/bin/codex-bridge.mjs +181 -178
- package/cli/dispatcher-contract-record.mjs +42 -0
- package/cli/dispatcher-contract.mjs +89 -0
- package/cli/doctor-hooks.mjs +104 -114
- package/cli/doctor-runs.mjs +55 -55
- package/cli/doctor.mjs +39 -3
- package/cli/handback-witness-check.mjs +39 -0
- package/cli/hook-targets.mjs +14 -6
- package/cli/hook.mjs +32 -23
- package/cli/host-contract.mjs +136 -135
- package/cli/install.mjs +9 -14
- package/cli/launcher-probe.mjs +38 -0
- package/cli/probe-contract.mjs +31 -62
- package/cli/probe-rig.mjs +53 -0
- package/cli/run-launcher.mjs +17 -0
- package/cli/settings-merge.mjs +42 -35
- package/cli/uninstall.mjs +15 -3
- package/cli/update.mjs +45 -16
- package/package.json +4 -4
- package/src/agents/codex-advisor.md +3 -4
- package/src/agents/codex-build.md +5 -5
- package/src/agents/codex-review.md +3 -3
- package/src/agents/codex-scout.md +3 -4
- package/src/home/hooks/dispatcher-gate.mjs +119 -0
- package/src/home/hooks/guard-tries.mjs +5 -6
- package/src/home/hooks/live-runs.mjs +1 -0
- package/src/home/hooks/order-gate.mjs +20 -5
- package/src/home/hooks/reply-guard.mjs +75 -10
- package/src/home/hooks/reply-verdicts.mjs +5 -0
- package/src/home/hooks/worktree-lock.mjs +108 -107
- package/src/home/lib/atomic-json.mjs +21 -0
- package/src/home/lib/brand-home.mjs +6 -0
- package/src/home/lib/dispatcher-command.mjs +79 -0
- package/src/home/lib/dispatcher-gate.mjs +95 -0
- package/src/home/lib/dispatcher-order.mjs +30 -8
- package/src/home/lib/dispatcher-state.mjs +111 -0
- package/src/home/lib/dispatcher-stop.mjs +52 -0
- package/src/home/lib/handback-witness.mjs +65 -0
- package/src/home/lib/hook-definitions.mjs +10 -0
- package/src/home/lib/hook-entry.mjs +20 -0
- package/src/home/lib/meta/advice-status.mjs +3 -7
- package/src/home/lib/meta/advice-verdict.mjs +37 -21
- package/src/home/lib/required-inputs.mjs +1 -1
- package/src/home/lib/runner/advise-carry.mjs +50 -0
- package/src/home/lib/runner/attach.mjs +11 -7
- package/src/home/lib/runner/codex-cmd.mjs +3 -2
- package/src/home/lib/runner/continuation.mjs +3 -1
- package/src/home/lib/runner/git-state.mjs +1 -1
- package/src/home/lib/runner/launcher.mjs +14 -13
- package/src/home/lib/runner/preflight.mjs +6 -3
- package/src/home/lib/runner/prompts.mjs +9 -8
- package/src/home/lib/runner/schemas.mjs +33 -7
package/cli/doctor-hooks.mjs
CHANGED
|
@@ -1,114 +1,104 @@
|
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
}
|
|
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
|
+
import { probeHookLauncher } from './launcher-probe.mjs';
|
|
9
|
+
|
|
10
|
+
function hookVersion(record) {
|
|
11
|
+
return `installed copy ${record.name}@${record.version}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function startHook(target) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(process.execPath, [target], {
|
|
17
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
});
|
|
20
|
+
let stderr = '';
|
|
21
|
+
child.stderr.setEncoding('utf8');
|
|
22
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
23
|
+
child.on('error', (error) => resolve({ status: null, stderr: error.message }));
|
|
24
|
+
child.on('close', (status) => resolve({ status, stderr }));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function startFailure(definition, result) {
|
|
29
|
+
if (result.status === 0) return null;
|
|
30
|
+
const lines = result.stderr.trim().split(/\r?\n/);
|
|
31
|
+
const resolver = lines.find((line) => /^Error \[ERR_MODULE_NOT_FOUND\]|Cannot find module/.test(line));
|
|
32
|
+
return `${definition.file} did not start: ${resolver || lines.find(Boolean) || `exit code ${result.status}`}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `sourceKind` arrives as an argument rather than being read here, because the function that knows
|
|
37
|
+
* which copy of the package is answering lives beside `diagnose`. Importing it back would make this
|
|
38
|
+
* module and its caller depend on each other, and a cycle between a check and its orchestrator is
|
|
39
|
+
* how one of them ends up half-initialised at import time.
|
|
40
|
+
*/
|
|
41
|
+
export async function hookChecks(host, record, launcherProbe = probeHookLauncher) {
|
|
42
|
+
const launcher = launcherProbe({ env: process.env, brandRoot: host.brandRoot });
|
|
43
|
+
const starts = record ? await Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
|
|
44
|
+
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
45
|
+
&& path.basename(hook.path) === definition.file);
|
|
46
|
+
return recorded ? startHook(recordTarget(host, recorded)) : null;
|
|
47
|
+
})) : [];
|
|
48
|
+
return Promise.all(HOOK_DEFINITIONS.map(async (definition, index) => {
|
|
49
|
+
const key = `hook:${definition.event}`;
|
|
50
|
+
if (!record) return check(key, 'warn', 'cannot check before installation');
|
|
51
|
+
// The file, not just the event: PreToolUse carries two hooks of this package, and matching
|
|
52
|
+
// by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
|
|
53
|
+
// hook line is the only place an operator can see WHICH file a matcher was registered for,
|
|
54
|
+
// so a wrong name here is worse than no line at all.
|
|
55
|
+
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
56
|
+
&& path.basename(hook.path) === definition.file);
|
|
57
|
+
if (!recorded) {
|
|
58
|
+
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
59
|
+
}
|
|
60
|
+
const expected = recordTarget(host, recorded);
|
|
61
|
+
const fullCommand = commandFor(expected);
|
|
62
|
+
const shortCommand = `codex-bridge hook ${definition.name}`;
|
|
63
|
+
try {
|
|
64
|
+
const state = await inspectHook(host.settingsPath, {
|
|
65
|
+
event: definition.event,
|
|
66
|
+
matcher: definition.matcher,
|
|
67
|
+
command: recorded.command || fullCommand,
|
|
68
|
+
alternateCommands: [fullCommand, shortCommand],
|
|
69
|
+
});
|
|
70
|
+
if (state.present) {
|
|
71
|
+
const recordedMatcher = state.matchedMatcher;
|
|
72
|
+
const command = state.matchedCommand || recorded.command || fullCommand;
|
|
73
|
+
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
74
|
+
const reason = form === 'short'
|
|
75
|
+
? launcher.ok
|
|
76
|
+
? 'codex-bridge on PATH launches guards from this home'
|
|
77
|
+
: `short command but ${launcher.reason}: the command on PATH runs its own package copy, not this home, and refuses names it does not know; run codex-bridge update`
|
|
78
|
+
: launcher.ok
|
|
79
|
+
? 'a short command is now safe; run codex-bridge update'
|
|
80
|
+
: 'full path uses the copy placed by the last install';
|
|
81
|
+
const problems = [startFailure(definition, starts[index])].filter(Boolean);
|
|
82
|
+
// The matcher printed is the one the host recorded, never the one this package declares.
|
|
83
|
+
// Until 2026-08-23 the line stated the registry's own value, so a host stuck on the
|
|
84
|
+
// pre-shell matcher read as [ok] while the worktree lock never saw a Bash command — the
|
|
85
|
+
// same shape as the 2026-08-10 defect, where doctor counted files instead of
|
|
86
|
+
// reading them. A check that compares the package to itself cannot fail.
|
|
87
|
+
if (recordedMatcher !== definition.matcher) problems.push(`recorded matcher ${recordedMatcher} differs from expected ${definition.matcher}; run codex-bridge update --force`);
|
|
88
|
+
const health = problems.length ? `; ${problems.join('; ')}` : '';
|
|
89
|
+
const status = form === 'short' && !launcher.ok ? 'fail'
|
|
90
|
+
: form === 'path' && launcher.ok || problems.length ? 'warn' : 'ok';
|
|
91
|
+
return check(key, status, `${definition.event} matcher ${recordedMatcher} -> ${expected} (${form} command; ${reason}${form === 'path' ? `; ${hookVersion(record)}` : ''}${health})`);
|
|
92
|
+
}
|
|
93
|
+
const command = recorded.command || fullCommand;
|
|
94
|
+
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
95
|
+
const failure = startFailure(definition, starts[index]);
|
|
96
|
+
// An entry missing from settings is a registration problem whatever the launcher can do;
|
|
97
|
+
// update restores it in the form the proof allows.
|
|
98
|
+
return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file} (${form} command${form === 'path' ? `; ${hookVersion(record)}` : ''}${failure ? `; ${failure}` : ''})`);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
|
|
101
|
+
return check(key, 'warn', `${definition.event} hook: ${value}`);
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
}
|
package/cli/doctor-runs.mjs
CHANGED
|
@@ -1,55 +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
|
-
}
|
|
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', windowsHide: true });
|
|
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
|
@@ -17,6 +17,12 @@ import {
|
|
|
17
17
|
} from './doctor-installation.mjs';
|
|
18
18
|
import { hookChecks } from './doctor-hooks.mjs';
|
|
19
19
|
import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
|
|
20
|
+
import { handbackWitnessStatus } from './handback-witness-check.mjs';
|
|
21
|
+
import { dispatcherContractStatus } from './dispatcher-contract.mjs';
|
|
22
|
+
import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
|
|
23
|
+
import { PROBE_COMMAND } from './host-contract.mjs';
|
|
24
|
+
import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
|
|
25
|
+
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
20
26
|
import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
|
|
21
27
|
|
|
22
28
|
export { renderDoctor };
|
|
@@ -52,12 +58,28 @@ export function probeCodexBridge() {
|
|
|
52
58
|
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
// Plan_62 D19: `changed` fails because the dispatcher gate rests on that contract; everything short of a
|
|
62
|
+
// measured break only warns. A corrupt record gets one line: the probe refuses to overwrite it.
|
|
63
|
+
const DISPATCHER_CONTRACT_STATUS = { verified: 'ok', changed: 'fail' };
|
|
64
|
+
|
|
65
|
+
function dispatcherContractChecks({ record, version, stateDir }) {
|
|
66
|
+
if (record?.corrupt) {
|
|
67
|
+
return [check('dispatcherContract', 'warn',
|
|
68
|
+
`The dispatcher contract record is unreadable; delete ${path.join(stateDir, DISPATCHER_CONTRACT_FILE)}, then run ${PROBE_COMMAND}.`)];
|
|
69
|
+
}
|
|
70
|
+
return dispatcherContractStatus({ record, version }).map(({ contract, state, message }) =>
|
|
71
|
+
check(`dispatcherContract:${contract}`, DISPATCHER_CONTRACT_STATUS[state] ?? 'warn', message));
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
export async function diagnose({
|
|
56
75
|
host,
|
|
57
76
|
codexProbe = probeCodex,
|
|
58
77
|
bridgeProbe = probeCodexBridge,
|
|
78
|
+
launcherProbe,
|
|
59
79
|
currentPackage,
|
|
60
80
|
contractRecord,
|
|
81
|
+
handbackWitnessRecord,
|
|
82
|
+
dispatcherContractRecord,
|
|
61
83
|
hostVersion,
|
|
62
84
|
} = {}) {
|
|
63
85
|
const checks = [sourceCheck()];
|
|
@@ -101,15 +123,28 @@ export async function diagnose({
|
|
|
101
123
|
checks.push(await permissionsCheck(host));
|
|
102
124
|
const bridge = bridgeProbe();
|
|
103
125
|
checks.push(bridgeCommandCheck(bridge));
|
|
104
|
-
checks.push(...await hookChecks(host, record,
|
|
126
|
+
checks.push(...await hookChecks(host, record, launcherProbe));
|
|
127
|
+
const detectedHostVersion = hostVersion === undefined ? detectHostVersion() : hostVersion;
|
|
105
128
|
const hostContract = contractStatus({
|
|
106
129
|
record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
|
|
107
|
-
version:
|
|
130
|
+
version: detectedHostVersion,
|
|
108
131
|
});
|
|
109
132
|
const hostContractStatus = hostContract.state === 'verified'
|
|
110
133
|
? 'ok'
|
|
111
134
|
: hostContract.state === 'ignored' ? 'fail' : 'warn';
|
|
112
135
|
checks.push(check('hostContract', hostContractStatus, hostContract.message));
|
|
136
|
+
const stateDir = brandStateDir(host.brandRoot);
|
|
137
|
+
const witness = handbackWitnessStatus({
|
|
138
|
+
record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
|
|
139
|
+
hostVersion: detectedHostVersion,
|
|
140
|
+
stateDir,
|
|
141
|
+
});
|
|
142
|
+
checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
|
|
143
|
+
checks.push(...dispatcherContractChecks({
|
|
144
|
+
record: dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord,
|
|
145
|
+
version: detectedHostVersion,
|
|
146
|
+
stateDir,
|
|
147
|
+
}));
|
|
113
148
|
const retention = retentionCheck(host);
|
|
114
149
|
checks.push(retention);
|
|
115
150
|
const conventions = await conventionsCheck(host);
|
|
@@ -127,7 +162,8 @@ export async function diagnose({
|
|
|
127
162
|
return {
|
|
128
163
|
exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
|
|
129
164
|
|| hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
|
|
130
|
-
|| projectRuns.status === 'fail'
|
|
165
|
+
|| projectRuns.status === 'fail'
|
|
166
|
+
|| checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail') ? 1 : 0,
|
|
131
167
|
checks,
|
|
132
168
|
record,
|
|
133
169
|
missingFiles,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Surfaces handback drift because the 2026-09-17..24 contract break went unnoticed for a week. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
|
|
4
|
+
|
|
5
|
+
function versionParts(value) {
|
|
6
|
+
if (typeof value !== 'string') return null;
|
|
7
|
+
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:\s|$)/);
|
|
8
|
+
return match ? { version: match[0].trim(), patch: match[3] } : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
|
|
12
|
+
if (record?.corrupt) {
|
|
13
|
+
return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
|
|
14
|
+
}
|
|
15
|
+
if (record?.alarms?.length > 0) {
|
|
16
|
+
const alarm = record.alarms.at(-1);
|
|
17
|
+
return {
|
|
18
|
+
state: 'alarm',
|
|
19
|
+
message: `${record.alarms.length} dispatcher alarm(s); newest: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (!record?.lastSeen) {
|
|
23
|
+
return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const seen = versionParts(record.lastSeen.sdkVersion);
|
|
27
|
+
const host = versionParts(hostVersion);
|
|
28
|
+
// Plan_62 D9 pairs observed SDK 0.3.281 inside Claude Code 2.1.281 hooks; matching patches are current.
|
|
29
|
+
if (seen && host && seen.patch !== host.patch) {
|
|
30
|
+
return {
|
|
31
|
+
state: 'stale',
|
|
32
|
+
message: `Last seen on SDK ${record.lastSeen.sdkVersion} at ${record.lastSeen.at}; current host ${hostVersion}; run any dispatcher once in an interactive session to observe it again.`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
state: 'seen',
|
|
37
|
+
message: `Handback contract last seen on SDK ${record.lastSeen.sdkVersion} (host ${hostVersion}) at ${record.lastSeen.at}.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
package/cli/hook-targets.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Describes where each guard is installed and how its registration will be spelled. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { HOOK_DEFINITIONS, installedHookPath } from './manifest.mjs';
|
|
4
|
+
import { probeHookLauncher } from './launcher-probe.mjs';
|
|
4
5
|
import { commandFor, hookRegistration } from './settings-merge.mjs';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -9,15 +10,15 @@ import { commandFor, hookRegistration } from './settings-merge.mjs';
|
|
|
9
10
|
* list that decides what goes into the operator's settings.json is the same shape of defect
|
|
10
11
|
* Plan_19 had to reconcile between the installer and the hooks.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* and took every guard on the machine down with it.
|
|
13
|
+
* The short command form requires proof that the PATH launcher dispatches from this home. On
|
|
14
|
+
* 2026-08-11, checking only that a command existed registered hooks against a release without the
|
|
15
|
+
* hook subcommand; on 2026-09-24, matching versions also failed for unreleased clone code.
|
|
16
16
|
*/
|
|
17
|
-
export function hookTargets(host, env = process.env
|
|
17
|
+
export function hookTargets(host, env = process.env) {
|
|
18
|
+
const probe = probeHookLauncher({ env, brandRoot: host.brandRoot });
|
|
18
19
|
return HOOK_DEFINITIONS.map((definition) => {
|
|
19
20
|
const target = installedHookPath(host, definition);
|
|
20
|
-
const registration = hookRegistration(definition.name, target,
|
|
21
|
+
const registration = hookRegistration(definition.name, target, probe);
|
|
21
22
|
const fallback = commandFor(target);
|
|
22
23
|
const alternate = registration.command === fallback
|
|
23
24
|
? `codex-bridge hook ${definition.name}`
|
|
@@ -37,3 +38,10 @@ export function hookTargets(host, env = process.env, packageVersion = null) {
|
|
|
37
38
|
};
|
|
38
39
|
});
|
|
39
40
|
}
|
|
41
|
+
|
|
42
|
+
export function recordHasHooks(record, targets) {
|
|
43
|
+
return Boolean(record)
|
|
44
|
+
&& targets.every(({ definition, relative, spec }) => record.hooks?.some((hook) =>
|
|
45
|
+
hook.event === definition.event && hook.root === 'brand' && hook.path === relative
|
|
46
|
+
&& hook.command === spec.command));
|
|
47
|
+
}
|
package/cli/hook.mjs
CHANGED
|
@@ -1,34 +1,43 @@
|
|
|
1
|
-
/** Dispatches a named host hook
|
|
1
|
+
/** Dispatches a named host hook from the installed brand home. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
2
4
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
|
|
5
7
|
|
|
6
|
-
const
|
|
7
|
-
const VALID_NAMES = HOOK_DEFINITIONS.map(({ name }) => name);
|
|
8
|
+
export const HOOK_LAUNCHER_PROTOCOL = 1;
|
|
8
9
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function usageError(io, message) {
|
|
14
|
-
io.error(`codex-bridge hook ${message} Valid hook names: ${validNames()}.`);
|
|
15
|
-
return 2;
|
|
10
|
+
function reportFailure(io, name, reason) {
|
|
11
|
+
io.error(`codex-bridge hook: guard "${name}" did not run: ${reason}. Run codex-bridge doctor.`);
|
|
12
|
+
return 1;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
export async function hook(args = [], io = console) {
|
|
19
16
|
if (!Array.isArray(args) || args.length !== 1) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
: 'requires exactly one hook name.';
|
|
23
|
-
return usageError(io, detail);
|
|
17
|
+
io.error('Usage: codex-bridge hook <name> | codex-bridge hook --home');
|
|
18
|
+
return 1;
|
|
24
19
|
}
|
|
25
20
|
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
21
|
+
const home = resolveBrandHome({ homedir: os.homedir(), env: process.env });
|
|
22
|
+
if (args[0] === '--home') {
|
|
23
|
+
io.log(JSON.stringify({ protocol: HOOK_LAUNCHER_PROTOCOL, dispatch: 'home', homeRoot: home.root }));
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
if (!args[0] || args[0].startsWith('-')) {
|
|
27
|
+
io.error('Usage: codex-bridge hook <name> | codex-bridge hook --home');
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const name = args[0];
|
|
32
|
+
const entry = path.join(home.root, 'lib', 'hook-entry.mjs');
|
|
33
|
+
if (!fs.existsSync(entry)) {
|
|
34
|
+
io.error(`codex-bridge hook: guard "${name}" did not run: codex-bridge is not installed in ${home.root}. Run codex-bridge install.`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const { runHook } = await import(pathToFileURL(entry).href);
|
|
39
|
+
return await runHook(name, io);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return reportFailure(io, name, error?.message ?? String(error));
|
|
42
|
+
}
|
|
34
43
|
}
|