@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +5 -5
  3. package/bin/codex-bridge.mjs +181 -178
  4. package/cli/dispatcher-contract-record.mjs +42 -0
  5. package/cli/dispatcher-contract.mjs +89 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-runs.mjs +55 -55
  8. package/cli/doctor.mjs +39 -3
  9. package/cli/handback-witness-check.mjs +39 -0
  10. package/cli/hook-targets.mjs +14 -6
  11. package/cli/hook.mjs +32 -23
  12. package/cli/host-contract.mjs +136 -135
  13. package/cli/install.mjs +9 -14
  14. package/cli/launcher-probe.mjs +38 -0
  15. package/cli/probe-contract.mjs +31 -62
  16. package/cli/probe-rig.mjs +53 -0
  17. package/cli/run-launcher.mjs +17 -0
  18. package/cli/settings-merge.mjs +42 -35
  19. package/cli/uninstall.mjs +15 -3
  20. package/cli/update.mjs +45 -16
  21. package/package.json +4 -4
  22. package/src/agents/codex-advisor.md +3 -4
  23. package/src/agents/codex-build.md +5 -5
  24. package/src/agents/codex-review.md +3 -3
  25. package/src/agents/codex-scout.md +3 -4
  26. package/src/home/hooks/dispatcher-gate.mjs +119 -0
  27. package/src/home/hooks/guard-tries.mjs +5 -6
  28. package/src/home/hooks/live-runs.mjs +1 -0
  29. package/src/home/hooks/order-gate.mjs +20 -5
  30. package/src/home/hooks/reply-guard.mjs +75 -10
  31. package/src/home/hooks/reply-verdicts.mjs +5 -0
  32. package/src/home/hooks/worktree-lock.mjs +108 -107
  33. package/src/home/lib/atomic-json.mjs +21 -0
  34. package/src/home/lib/brand-home.mjs +6 -0
  35. package/src/home/lib/dispatcher-command.mjs +79 -0
  36. package/src/home/lib/dispatcher-gate.mjs +95 -0
  37. package/src/home/lib/dispatcher-order.mjs +30 -8
  38. package/src/home/lib/dispatcher-state.mjs +111 -0
  39. package/src/home/lib/dispatcher-stop.mjs +52 -0
  40. package/src/home/lib/handback-witness.mjs +65 -0
  41. package/src/home/lib/hook-definitions.mjs +10 -0
  42. package/src/home/lib/hook-entry.mjs +20 -0
  43. package/src/home/lib/meta/advice-status.mjs +3 -7
  44. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  45. package/src/home/lib/required-inputs.mjs +1 -1
  46. package/src/home/lib/runner/advise-carry.mjs +50 -0
  47. package/src/home/lib/runner/attach.mjs +11 -7
  48. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  49. package/src/home/lib/runner/continuation.mjs +3 -1
  50. package/src/home/lib/runner/git-state.mjs +1 -1
  51. package/src/home/lib/runner/launcher.mjs +14 -13
  52. package/src/home/lib/runner/preflight.mjs +6 -3
  53. package/src/home/lib/runner/prompts.mjs +9 -8
  54. package/src/home/lib/runner/schemas.mjs +33 -7
@@ -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
- function hookVersion(command, record, bridgeResult) {
10
- if (command.startsWith('codex-bridge hook ')) {
11
- return bridgeResult.available
12
- ? `global command ${bridgeResult.value}`
13
- : `global command unavailable (${bridgeResult.value})`;
14
- }
15
- return `installed copy ${record.name}@${record.version}`;
16
- }
17
-
18
- function probedVersion(result) {
19
- return result.available ? result.value.match(/(\d+\.\d+\.\d+(?:[-+][^\s]+)?)\s*$/)?.[1] : null;
20
- }
21
-
22
- function startHook(target) {
23
- return new Promise((resolve) => {
24
- const child = spawn(process.execPath, [target], {
25
- stdio: ['ignore', 'ignore', 'pipe'],
26
- windowsHide: true,
27
- });
28
- let stderr = '';
29
- child.stderr.setEncoding('utf8');
30
- child.stderr.on('data', (chunk) => { stderr += chunk; });
31
- child.on('error', (error) => resolve({ status: null, stderr: error.message }));
32
- child.on('close', (status) => resolve({ status, stderr }));
33
- });
34
- }
35
-
36
- function startFailure(definition, result) {
37
- if (result.status === 0) return null;
38
- const lines = result.stderr.trim().split(/\r?\n/);
39
- const resolver = lines.find((line) => /^Error \[ERR_MODULE_NOT_FOUND\]|Cannot find module/.test(line));
40
- return `${definition.file} did not start: ${resolver || lines.find(Boolean) || `exit code ${result.status}`}`;
41
- }
42
-
43
- /**
44
- * `sourceKind` arrives as an argument rather than being read here, because the function that knows
45
- * which copy of the package is answering lives beside `diagnose`. Importing it back would make this
46
- * module and its caller depend on each other, and a cycle between a check and its orchestrator is
47
- * how one of them ends up half-initialised at import time.
48
- */
49
- export async function hookChecks(host, record, bridgeProbe, ownPackage, sourceKind) {
50
- let bridgeResult;
51
- const bridge = () => {
52
- bridgeResult ??= bridgeProbe();
53
- return bridgeResult;
54
- };
55
- const starts = record ? await Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
56
- const recorded = record.hooks.find((hook) => hook.event === definition.event
57
- && path.basename(hook.path) === definition.file);
58
- return recorded ? startHook(recordTarget(host, recorded)) : null;
59
- })) : [];
60
- return Promise.all(HOOK_DEFINITIONS.map(async (definition, index) => {
61
- const key = `hook:${definition.event}`;
62
- if (!record) return check(key, 'warn', 'cannot check before installation');
63
- // The file, not just the event: PreToolUse carries two hooks of this package, and matching
64
- // by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
65
- // hook line is the only place an operator can see WHICH file a matcher was registered for,
66
- // so a wrong name here is worse than no line at all.
67
- const recorded = record.hooks.find((hook) => hook.event === definition.event
68
- && path.basename(hook.path) === definition.file);
69
- if (!recorded) {
70
- return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
71
- }
72
- const expected = recordTarget(host, recorded);
73
- const fullCommand = commandFor(expected);
74
- const shortCommand = `codex-bridge hook ${definition.name}`;
75
- try {
76
- const state = await inspectHook(host.settingsPath, {
77
- event: definition.event,
78
- matcher: definition.matcher,
79
- command: recorded.command || fullCommand,
80
- alternateCommands: [fullCommand, shortCommand],
81
- });
82
- if (state.present) {
83
- const recordedMatcher = state.matchedMatcher;
84
- const command = state.matchedCommand || recorded.command || fullCommand;
85
- const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
86
- const reason = form === 'short'
87
- ? 'PATH command uses the globally installed package'
88
- : 'full path uses the copy placed by the last install';
89
- const problems = [startFailure(definition, starts[index])].filter(Boolean);
90
- // The matcher printed is the one the host recorded, never the one this package declares.
91
- // Until 2026-08-23 the line stated the registry's own value, so a host stuck on the
92
- // pre-shell matcher read as [ok] while the worktree lock never saw a Bash command — the
93
- // same shape as the 2026-08-10 defect, where doctor counted files instead of
94
- // reading them. A check that compares the package to itself cannot fail.
95
- if (recordedMatcher !== definition.matcher) problems.push(`recorded matcher ${recordedMatcher} differs from expected ${definition.matcher}; run codex-bridge update --force`);
96
- const commandVersion = form === 'short' ? bridge() : null;
97
- const globalVersion = probedVersion(commandVersion || { available: false });
98
- if (globalVersion && globalVersion !== ownPackage.version) {
99
- problems.push(`global PATH package version ${globalVersion} differs from ${sourceKind} version ${ownPackage.version}`);
100
- }
101
- const health = problems.length ? `; ${problems.join('; ')}` : '';
102
- return check(key, problems.length ? 'warn' : 'ok', `${definition.event} matcher ${recordedMatcher} -> ${expected} (${form} command; ${reason}; ${hookVersion(command, record, commandVersion)}${health})`);
103
- }
104
- const command = recorded.command || fullCommand;
105
- const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
106
- const failure = startFailure(definition, starts[index]);
107
- const commandVersion = form === 'short' ? bridge() : null;
108
- return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file} (${form} command; ${hookVersion(command, record, commandVersion)}${failure ? `; ${failure}` : ''})`);
109
- } catch (err) {
110
- const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
111
- return check(key, 'warn', `${definition.event} hook: ${value}`);
112
- }
113
- }));
114
- }
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
+ }
@@ -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, () => bridge, ownPackage, packageSource().kind));
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: hostVersion === undefined ? detectHostVersion() : hostVersion,
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' ? 1 : 0,
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
+ }
@@ -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
- * `packageVersion` is what keeps the registration honest: the short command form is only chosen
13
- * when the codex-bridge on PATH reports this exact version, because on 2026-08-11 an install from
14
- * the clone wrote `codex-bridge hook <name>` against a global 0.4.0 that has no such subcommand
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, packageVersion = null) {
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, env, packageVersion);
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 without changing the guard's stdin or process lifecycle. */
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 { fileURLToPath, pathToFileURL } from 'node:url';
4
- import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
5
7
 
6
- const HOOKS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'home', 'hooks');
7
- const VALID_NAMES = HOOK_DEFINITIONS.map(({ name }) => name);
8
+ export const HOOK_LAUNCHER_PROTOCOL = 1;
8
9
 
9
- function validNames() {
10
- return VALID_NAMES.join(', ');
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
- const detail = Array.isArray(args) && args.length
21
- ? `accepts exactly one hook name; received ${args.length}.`
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 definition = HOOK_DEFINITIONS.find(({ name }) => name === args[0]);
27
- if (!definition) return usageError(io, `unknown hook name "${args[0]}".`);
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
- // Plan_25 replaces absolute paths in foreign settings.json with this command. Importing the
30
- // proven top-level script keeps its stdin reader and process.exit() contract intact; exporting
31
- // guard functions would be a second implementation of the live hook boundary.
32
- await import(pathToFileURL(path.join(HOOKS_DIR, definition.file)).href);
33
- return typeof process.exitCode === 'number' ? process.exitCode : 0;
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
  }