@lyupro/codex-bridge 0.6.6 → 0.6.8

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 (55) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +7 -5
  3. package/bin/codex-bridge.mjs +186 -178
  4. package/cli/dispatcher-contract-record.mjs +51 -0
  5. package/cli/dispatcher-contract.mjs +90 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor-runs.mjs +55 -55
  9. package/cli/doctor.mjs +53 -5
  10. package/cli/handback-witness-check.mjs +31 -0
  11. package/cli/hook-targets.mjs +14 -6
  12. package/cli/hook.mjs +32 -23
  13. package/cli/host-contract.mjs +145 -135
  14. package/cli/install.mjs +23 -17
  15. package/cli/launcher-probe.mjs +38 -0
  16. package/cli/probe-contract.mjs +55 -70
  17. package/cli/probe-rig.mjs +53 -0
  18. package/cli/probe-target.mjs +33 -0
  19. package/cli/run-launcher.mjs +17 -0
  20. package/cli/session-hosts.mjs +51 -0
  21. package/cli/settings-merge.mjs +42 -35
  22. package/cli/uninstall.mjs +16 -3
  23. package/cli/update.mjs +45 -16
  24. package/package.json +4 -4
  25. package/scripts/run-tests.mjs +7 -0
  26. package/src/agents/codex-advisor.md +3 -4
  27. package/src/agents/codex-build.md +5 -5
  28. package/src/agents/codex-review.md +3 -3
  29. package/src/agents/codex-scout.md +3 -4
  30. package/src/home/hooks/dispatcher-gate.mjs +132 -0
  31. package/src/home/hooks/guard-tries.mjs +5 -6
  32. package/src/home/hooks/live-runs.mjs +1 -0
  33. package/src/home/hooks/order-gate.mjs +20 -5
  34. package/src/home/hooks/reply-guard.mjs +95 -10
  35. package/src/home/hooks/reply-verdicts.mjs +5 -0
  36. package/src/home/hooks/worktree-lock.mjs +108 -107
  37. package/src/home/lib/atomic-json.mjs +21 -0
  38. package/src/home/lib/brand-home.mjs +6 -0
  39. package/src/home/lib/dispatcher-command.mjs +79 -0
  40. package/src/home/lib/dispatcher-gate.mjs +95 -0
  41. package/src/home/lib/dispatcher-order.mjs +30 -8
  42. package/src/home/lib/dispatcher-state.mjs +111 -0
  43. package/src/home/lib/dispatcher-stop.mjs +52 -0
  44. package/src/home/lib/handback-witness.mjs +70 -0
  45. package/src/home/lib/hook-definitions.mjs +10 -0
  46. package/src/home/lib/hook-entry.mjs +20 -0
  47. package/src/home/lib/host-observations.mjs +57 -0
  48. package/src/home/lib/host-version.mjs +27 -0
  49. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  50. package/src/home/lib/required-inputs.mjs +1 -1
  51. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  52. package/src/home/lib/runner/git-state.mjs +1 -1
  53. package/src/home/lib/runner/preflight.mjs +6 -3
  54. package/src/home/lib/runner/prompts.mjs +8 -7
  55. package/src/home/lib/runner/schemas.mjs +33 -7
@@ -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
@@ -16,7 +16,15 @@ import {
16
16
  rulesCheck,
17
17
  } from './doctor-installation.mjs';
18
18
  import { hookChecks } from './doctor-hooks.mjs';
19
- import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
19
+ import { contractStatus, 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';
26
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
27
+ import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
20
28
  import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
21
29
 
22
30
  export { renderDoctor };
@@ -52,13 +60,30 @@ export function probeCodexBridge() {
52
60
  return { available: true, value: (result.stdout || result.stderr).trim() };
53
61
  }
54
62
 
63
+ // Plan_62 D19: `changed` fails because the dispatcher gate rests on that contract; everything short of a
64
+ // measured break only warns. A corrupt record gets one line: the probe refuses to overwrite it.
65
+ const DISPATCHER_CONTRACT_STATUS = { verified: 'ok', changed: 'fail' };
66
+
67
+ function dispatcherContractChecks({ record, version, stateDir }) {
68
+ if (record?.corrupt) {
69
+ return [check('dispatcherContract', 'warn',
70
+ `The dispatcher contract record is unreadable; delete ${path.join(stateDir, DISPATCHER_CONTRACT_FILE)}, then run ${PROBE_COMMAND}.`)];
71
+ }
72
+ return dispatcherContractStatus({ record, version }).map(({ contract, state, message }) =>
73
+ check(`dispatcherContract:${contract}`, DISPATCHER_CONTRACT_STATUS[state] ?? 'warn', message));
74
+ }
75
+
55
76
  export async function diagnose({
56
77
  host,
57
78
  codexProbe = probeCodex,
58
79
  bridgeProbe = probeCodexBridge,
80
+ launcherProbe,
59
81
  currentPackage,
60
82
  contractRecord,
83
+ handbackWitnessRecord,
84
+ dispatcherContractRecord,
61
85
  hostVersion,
86
+ observations,
62
87
  } = {}) {
63
88
  const checks = [sourceCheck()];
64
89
  const hostExists = await exists(host.root);
@@ -101,15 +126,36 @@ export async function diagnose({
101
126
  checks.push(await permissionsCheck(host));
102
127
  const bridge = bridgeProbe();
103
128
  checks.push(bridgeCommandCheck(bridge));
104
- checks.push(...await hookChecks(host, record, () => bridge, ownPackage, packageSource().kind));
129
+ checks.push(...await hookChecks(host, record, launcherProbe));
130
+ const stateDir = brandStateDir(host.brandRoot);
131
+ const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
132
+ const observedVersions = sessionHostVersions(hostObservations);
133
+ const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
134
+ checks.push(sessionHostCheck(observedVersions, hostObservations));
135
+ const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
105
136
  const hostContract = contractStatus({
106
- record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
107
- version: hostVersion === undefined ? detectHostVersion() : hostVersion,
137
+ record: refusalRecord,
138
+ version: detectedHostVersion,
108
139
  });
109
140
  const hostContractStatus = hostContract.state === 'verified'
110
141
  ? 'ok'
111
142
  : hostContract.state === 'ignored' ? 'fail' : 'warn';
112
143
  checks.push(check('hostContract', hostContractStatus, hostContract.message));
144
+ const witness = handbackWitnessStatus({
145
+ record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
146
+ hostVersion: detectedHostVersion,
147
+ stateDir,
148
+ });
149
+ checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
150
+ const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
151
+ checks.push(...dispatcherContractChecks({
152
+ record: dispatcherRecord,
153
+ version: detectedHostVersion,
154
+ stateDir,
155
+ }));
156
+ for (const version of observedVersions.filter((item) => item !== detectedHostVersion)) {
157
+ checks.push(otherHostCheck({ version, contractRecord: refusalRecord, dispatcherRecord }));
158
+ }
113
159
  const retention = retentionCheck(host);
114
160
  checks.push(retention);
115
161
  const conventions = await conventionsCheck(host);
@@ -127,7 +173,9 @@ export async function diagnose({
127
173
  return {
128
174
  exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
129
175
  || hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
130
- || projectRuns.status === 'fail' ? 1 : 0,
176
+ || projectRuns.status === 'fail'
177
+ || checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
178
+ || checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
131
179
  checks,
132
180
  record,
133
181
  missingFiles,
@@ -0,0 +1,31 @@
1
+ /** Surfaces handback drift; 2026-09-25 established exact host transcript versions as identity. */
2
+ import path from 'node:path';
3
+ import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
4
+
5
+ export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
6
+ if (record?.corrupt) {
7
+ return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
8
+ }
9
+ if (record?.alarms?.length > 0) {
10
+ const alarm = record.alarms.at(-1);
11
+ return {
12
+ state: 'alarm',
13
+ message: `${record.alarms.length} dispatcher alarm(s); newest on ${alarm.hostVersion ?? 'unknown host'}: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
14
+ };
15
+ }
16
+ if (!record?.lastSeen || Object.keys(record.lastSeen).length === 0) {
17
+ return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
18
+ }
19
+
20
+ if (record.lastSeen[hostVersion]) {
21
+ return { state: 'seen', message: `Handback contract last seen on host ${hostVersion} at ${record.lastSeen[hostVersion]}.` };
22
+ }
23
+ const [newest, iso] = Object.entries(record.lastSeen).sort((left, right) => right[1].localeCompare(left[1]))[0];
24
+ if (newest) {
25
+ return {
26
+ state: 'stale',
27
+ message: `Handback contract last seen on host ${newest} at ${iso}, not yet on host ${hostVersion}; it is recorded the next time a dispatcher runs in an interactive session on that host.`,
28
+ };
29
+ }
30
+ return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
31
+ }
@@ -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
  }
@@ -1,135 +1,145 @@
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
- }
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. The
10
+ * 2026-09-25 incident ran VS Code host 2.1.282 beside CLI host 2.1.281, so each version keeps
11
+ * its own verdict and probing either must preserve the other.
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+ import { randomUUID } from 'node:crypto';
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
18
+ import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
19
+ import { readJsonFile } from '../src/home/lib/json-file.mjs';
20
+
21
+ export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
22
+
23
+ /**
24
+ * The command that runs the probe, kept here so every message gains it in one edit.
25
+ *
26
+ * It stayed unnamed in the messages until the command existed: a package that sends an operator to
27
+ * a command answering `unknown doctor option` has spent its credibility on the first line they
28
+ * read. The advice arrived in the same change as the command, and only in the states a probe can
29
+ * actually change — a message that advises a run which would tell you nothing new is noise.
30
+ *
31
+ * Derived rather than spelled out: Plan_17 §5 found installer and hook lists drifting apart once
32
+ * the same name lived in two places. `CLI_NAMES[0]` is the canonical spelling.
33
+ */
34
+ export const PROBE_COMMAND = `${CLI_NAMES[0]} doctor --probe-contract`;
35
+
36
+ export function hostContractPath(host) {
37
+ if (!host?.brandRoot) throw new Error('host has no brand installation root');
38
+ return path.join(host.brandRoot, HOST_CONTRACT_RECORD_NAME);
39
+ }
40
+
41
+ export async function readHostContract(host) {
42
+ const target = hostContractPath(host);
43
+ try {
44
+ const record = await readJsonFile(target);
45
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
46
+ if (record.hosts && typeof record.hosts === 'object' && !Array.isArray(record.hosts)) return record;
47
+ if (typeof record.version === 'string' && record.version) {
48
+ return { hosts: { [record.version]: { result: record.result, checkedAt: record.checkedAt } } };
49
+ }
50
+ return null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export async function writeHostContract(host, { version, result, now = new Date() }) {
57
+ if (typeof version !== 'string' || !version) {
58
+ throw new Error('host contract version must be a non-empty string');
59
+ }
60
+ if (result !== 'honored' && result !== 'ignored') {
61
+ throw new Error('host contract result must be honored or ignored');
62
+ }
63
+
64
+ const target = hostContractPath(host);
65
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
66
+ const previous = await readHostContract(host);
67
+ const cutoff = now.getTime() - 180 * 24 * 60 * 60 * 1000;
68
+ const hosts = Object.fromEntries(Object.entries(previous?.hosts ?? {}).filter(([, entry]) =>
69
+ Number.isFinite(Date.parse(entry?.checkedAt)) && Date.parse(entry.checkedAt) >= cutoff));
70
+ hosts[version] = { result, checkedAt: now.toISOString() };
71
+ const record = { hosts };
72
+ await fs.mkdir(host.brandRoot, { recursive: true });
73
+ try {
74
+ await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
75
+ await fs.rename(temporary, target);
76
+ } finally {
77
+ await fs.rm(temporary, { force: true });
78
+ }
79
+ }
80
+
81
+ export function parseHostVersion(text) {
82
+ if (typeof text !== 'string') return null;
83
+ return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
84
+ }
85
+
86
+ export function detectHostVersion({ run = spawnSync, executable = 'claude' } = {}) {
87
+ try {
88
+ const result = run(executable, ['--version'], {
89
+ encoding: 'utf8',
90
+ shell: false,
91
+ timeout: 5000,
92
+ windowsHide: true,
93
+ });
94
+ if (!result || result.error || result.status !== 0) return null;
95
+ return parseHostVersion(result.stdout);
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ export function contractStatus({ record, version }) {
102
+ // Loose equality on purpose: a caller that forgot the field hands over `undefined`, and the
103
+ // stale branch below would then report "the current host is undefined" as if it had measured it.
104
+ if (version == null) {
105
+ return {
106
+ state: 'unknown-host',
107
+ message: 'The host version could not be read, so the refusal contract cannot be judged.',
108
+ };
109
+ }
110
+ if (!record) {
111
+ return {
112
+ state: 'unverified',
113
+ message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
114
+ };
115
+ }
116
+ const entry = record.hosts?.[version];
117
+ if (!entry && Object.keys(record.hosts ?? {}).length) {
118
+ const [otherVersion] = Object.entries(record.hosts).sort((a, b) => Date.parse(b[1]?.checkedAt) - Date.parse(a[1]?.checkedAt))[0];
119
+ return {
120
+ state: 'stale',
121
+ message: `The refusal contract was probed on host ${otherVersion}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
122
+ };
123
+ }
124
+ if (!entry) return { state: 'unverified', message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.` };
125
+ if (entry.result === 'ignored') {
126
+ const guards = HOOK_DEFINITIONS
127
+ .filter((definition) => definition.event === 'PreToolUse')
128
+ .map((definition) => definition.name)
129
+ .join(', ');
130
+ return {
131
+ state: 'ignored',
132
+ message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
133
+ };
134
+ }
135
+ if (entry.result === 'honored') {
136
+ return {
137
+ state: 'verified',
138
+ message: `Host ${version} honours hook refusals (verified ${entry.checkedAt}).`,
139
+ };
140
+ }
141
+ return {
142
+ state: 'unverified',
143
+ message: `The refusal contract record is invalid (host ${version}); run ${PROBE_COMMAND}.`,
144
+ };
145
+ }