@lyupro/codex-bridge 0.6.7 → 0.6.9

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 (101) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/bin/codex-bridge.mjs +35 -172
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +21 -11
  8. package/cli/dispatcher-contract.mjs +6 -5
  9. package/cli/doctor-host-side.mjs +110 -0
  10. package/cli/doctor-installation.mjs +111 -111
  11. package/cli/doctor.mjs +35 -7
  12. package/cli/handback-witness-check.mjs +10 -18
  13. package/cli/home-inspection.mjs +122 -0
  14. package/cli/home-plan-execute.mjs +140 -0
  15. package/cli/home-removal-plan.mjs +61 -0
  16. package/cli/hook-recognizer.mjs +78 -0
  17. package/cli/host-contract.mjs +36 -21
  18. package/cli/host-inspection.mjs +156 -0
  19. package/cli/host-removal.mjs +91 -0
  20. package/cli/image-evidence.mjs +44 -0
  21. package/cli/install-owners.mjs +244 -0
  22. package/cli/install-record.mjs +71 -9
  23. package/cli/install.mjs +101 -77
  24. package/cli/inventory-removal.mjs +84 -0
  25. package/cli/inventory-transition.mjs +66 -0
  26. package/cli/lifecycle-lock-clear.mjs +104 -0
  27. package/cli/lifecycle-lock-inspect.mjs +91 -0
  28. package/cli/lifecycle-lock.mjs +335 -0
  29. package/cli/lifecycle-transaction.mjs +57 -0
  30. package/cli/link-segments.mjs +45 -0
  31. package/cli/model-set.mjs +4 -3
  32. package/cli/model-speed.mjs +9 -2
  33. package/cli/model.mjs +5 -3
  34. package/cli/owner-record.mjs +40 -0
  35. package/cli/owner-sync-apply.mjs +108 -0
  36. package/cli/owner-sync.mjs +115 -0
  37. package/cli/probe-contract.mjs +26 -10
  38. package/cli/probe-target.mjs +33 -0
  39. package/cli/projects.mjs +5 -2
  40. package/cli/prune-args.mjs +9 -4
  41. package/cli/prune.mjs +23 -72
  42. package/cli/purge-live-runs.mjs +98 -0
  43. package/cli/purge-preflight.mjs +103 -0
  44. package/cli/read.mjs +2 -1
  45. package/cli/record-removal.mjs +43 -0
  46. package/cli/removal-outcomes.mjs +115 -0
  47. package/cli/removal-plan.mjs +187 -0
  48. package/cli/remove-layout.mjs +40 -1
  49. package/cli/rules-owners.mjs +4 -2
  50. package/cli/session-hosts.mjs +51 -0
  51. package/cli/terminal-question.mjs +41 -0
  52. package/cli/uninstall-purge.mjs +139 -0
  53. package/cli/uninstall-rules.mjs +72 -0
  54. package/cli/uninstall.mjs +208 -126
  55. package/cli/unlock-lifecycle.mjs +102 -0
  56. package/cli/unlock.mjs +7 -4
  57. package/cli/update.mjs +102 -27
  58. package/package.json +4 -3
  59. package/scripts/home-write-audit.mjs +286 -0
  60. package/scripts/home-write-inventory.mjs +49 -0
  61. package/scripts/home-write-lexer.mjs +182 -0
  62. package/scripts/isolated-roots.mjs +6 -1
  63. package/scripts/open-work/check.mjs +180 -0
  64. package/scripts/open-work/live-thresholds.mjs +155 -0
  65. package/scripts/open-work/register.mjs +56 -0
  66. package/scripts/root-guard/root-entries.mjs +15 -0
  67. package/scripts/run-tests.mjs +33 -1
  68. package/src/agents/codex-advisor.md +5 -2
  69. package/src/home/conventions.md +9 -0
  70. package/src/home/hooks/dispatcher-gate.mjs +15 -2
  71. package/src/home/hooks/guard-tries.mjs +6 -4
  72. package/src/home/hooks/order-gate.mjs +2 -8
  73. package/src/home/hooks/reply-guard.mjs +24 -9
  74. package/src/home/hooks/worktree-witness.mjs +6 -6
  75. package/src/home/lib/agents.mjs +1 -1
  76. package/src/home/lib/atomic-json.mjs +24 -6
  77. package/src/home/lib/config-edit.mjs +25 -10
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +33 -25
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +60 -0
  85. package/src/home/lib/host-version.mjs +27 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/events.mjs +2 -1
  89. package/src/home/lib/meta/paths.mjs +24 -24
  90. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  91. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  92. package/src/home/lib/meta/verdict.mjs +14 -16
  93. package/src/home/lib/run-config.mjs +10 -1
  94. package/src/home/lib/runner/git-paths.mjs +74 -0
  95. package/src/home/lib/runner/git-state.mjs +38 -34
  96. package/src/home/lib/runner/launcher.mjs +2 -2
  97. package/src/home/lib/runner/preflight.mjs +7 -0
  98. package/src/home/lib/runner/prompts.mjs +35 -2
  99. package/src/home/lib/runner/schemas.mjs +4 -1
  100. package/src/home/lib/runner/scope-check.mjs +29 -20
  101. package/src/home/lib/runner/worker.mjs +3 -3
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Lists commands and how each is invoked (Plan_71 D1, advice A1: registry-dispatch).
3
+ * model -h refused help and global help omitted its actions (2026-09-29) because
4
+ * dispatch and usage had separate sources. One registry keeps invocation and help together.
5
+ */
6
+ import { diagnose, renderDoctor } from './doctor.mjs';
7
+ import { probeContract } from './probe-contract.mjs';
8
+ import { resolveProbeTarget } from './probe-target.mjs';
9
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
10
+ import { hook } from './hook.mjs';
11
+ import { resolveHost } from './hosts.mjs';
12
+ import { install } from './install.mjs';
13
+ import { model } from './model.mjs';
14
+ import { projects } from './projects.mjs';
15
+ import { read } from './read.mjs';
16
+ import { runCodex } from './run-launcher.mjs';
17
+ import { permissions } from './permissions.mjs';
18
+ import { prune } from './prune.mjs';
19
+ import { stop } from './stop.mjs';
20
+ import { unlock } from './unlock.mjs';
21
+ import { uninstall } from './uninstall.mjs';
22
+ import { purge } from './uninstall-purge.mjs';
23
+ import { update } from './update.mjs';
24
+
25
+ export function commandOptions(command, argv) {
26
+ const options = {};
27
+ const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
28
+ : command === 'uninstall' ? new Set(['--dry-run', '--purge'])
29
+ : command === 'doctor' ? new Set(['--probe-contract']) : new Set();
30
+ const flagNames = new Map([
31
+ ['--dry-run', 'dryRun'],
32
+ ['--purge', 'purge'],
33
+ ['--force', 'force'],
34
+ ['--probe-contract', 'probeContract'],
35
+ ]);
36
+ for (let index = 0; index < argv.length; index += 1) {
37
+ const arg = argv[index];
38
+ if (booleanFlags.has(arg)) {
39
+ options[flagNames.get(arg)] = true;
40
+ continue;
41
+ }
42
+ if (arg !== '--scope' && arg !== '--host' && !(command === 'doctor' && arg === '--probe-executable')) throw new Error(`unknown ${command} option "${arg}"`);
43
+ const value = argv[index + 1];
44
+ if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
45
+ options[arg === '--scope' ? 'scope' : arg === '--host' ? 'host' : 'probeExecutable'] = value;
46
+ index += 1;
47
+ }
48
+ return options;
49
+ }
50
+
51
+ export const COMMANDS = [
52
+ {
53
+ name: 'install',
54
+ summary: 'Install codex-bridge into the selected Claude Code host',
55
+ usage: ['codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]'],
56
+ section: 'public',
57
+ async handler(argv, io) {
58
+ const options = commandOptions('install', argv);
59
+ const host = resolveHost(options);
60
+ const result = await install({ host, dryRun: options.dryRun, force: options.force });
61
+ io.log(result.output);
62
+ return result.exitCode;
63
+ },
64
+ },
65
+ {
66
+ name: 'update',
67
+ summary: 'Update a recorded codex-bridge installation',
68
+ usage: ['codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]'],
69
+ section: 'public',
70
+ async handler(argv, io) {
71
+ const options = commandOptions('update', argv);
72
+ const host = resolveHost(options);
73
+ const result = await update({ host, dryRun: options.dryRun, force: options.force });
74
+ io.log(result.output);
75
+ return result.exitCode;
76
+ },
77
+ },
78
+ {
79
+ name: 'permissions',
80
+ summary: 'Show, add, or remove optional shell permission rules',
81
+ usage: ['codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]'],
82
+ section: 'public',
83
+ actions: {
84
+ add: {
85
+ summary: 'Add optional shell permission rules',
86
+ usage: ['codex-bridge permissions add [--scope user|project] [--host <path>]'],
87
+ },
88
+ remove: {
89
+ summary: 'Remove optional shell permission rules',
90
+ usage: ['codex-bridge permissions remove [--scope user|project] [--host <path>]'],
91
+ },
92
+ },
93
+ async handler(argv, io) {
94
+ let action;
95
+ let optionArgs = argv;
96
+ if (argv[0] && !argv[0].startsWith('-')) {
97
+ action = argv[0];
98
+ optionArgs = argv.slice(1);
99
+ }
100
+ if (action && !['add', 'remove'].includes(action)) {
101
+ throw new Error(`unknown permissions action "${action}"`);
102
+ }
103
+ const options = commandOptions('permissions', optionArgs);
104
+ const host = resolveHost(options);
105
+ const result = await permissions({ host, action });
106
+ io.log(result.output);
107
+ return result.exitCode;
108
+ },
109
+ },
110
+ {
111
+ name: 'uninstall',
112
+ summary: 'Remove installed files while preserving run artifacts',
113
+ usage: ['codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run] [--purge]'],
114
+ section: 'public',
115
+ async handler(argv, io) {
116
+ const options = commandOptions('uninstall', argv);
117
+ const host = resolveHost(options);
118
+ const result = options.purge
119
+ ? await purge({ host, dryRun: options.dryRun })
120
+ : await uninstall({ host, dryRun: options.dryRun });
121
+ io.log(result.output);
122
+ return result.exitCode;
123
+ },
124
+ },
125
+ {
126
+ name: 'doctor',
127
+ summary: 'Diagnose the selected Claude Code host (--probe-contract measures on a live host)',
128
+ usage: ['codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract] [--probe-executable <path>]'],
129
+ section: 'public',
130
+ async handler(argv, io) {
131
+ const options = commandOptions('doctor', argv);
132
+ if (options.probeExecutable && !options.probeContract) throw new Error('--probe-executable requires --probe-contract');
133
+ const host = resolveHost(options);
134
+ // The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
135
+ // measured. Printing the diagnosis first and the measurement after would answer one question
136
+ // twice in one output, with the older answer on top (Plan_52 D25).
137
+ let probe = null;
138
+ if (options.probeContract) {
139
+ const target = resolveProbeTarget({ stateDir: brandStateDir(host.brandRoot), executable: options.probeExecutable });
140
+ if (!target.error) io.log(`probe: target ${target.version} at ${target.executable} (${target.source})`);
141
+ probe = await probeContract({ host, target });
142
+ io.log(`probe: ${probe.message}`);
143
+ for (const [name, verdict] of Object.entries(probe.dispatcher ?? {})) {
144
+ io.log(`probe: ${name} ${verdict.result} — ${verdict.detail}`);
145
+ }
146
+ }
147
+ const result = await diagnose({ host });
148
+ io.log(renderDoctor(result));
149
+ // An inconclusive probe wrote nothing and measured nothing; exiting 0 would let a failed
150
+ // measurement pass silently in a script (Plan_52 D26).
151
+ return probe && probe.state === 'inconclusive' ? 2 : result.exitCode;
152
+ },
153
+ },
154
+ {
155
+ name: 'run',
156
+ summary: 'Start or attach to a delegated Codex run',
157
+ usage: ['codex-bridge run <runner options> --task-file <path>'],
158
+ section: 'public',
159
+ async handler(argv, io) {
160
+ return runCodex(argv);
161
+ },
162
+ },
163
+ {
164
+ name: 'model',
165
+ summary: 'Show, set or list the models delegated roles run on',
166
+ usage: ['codex-bridge model'],
167
+ section: 'public',
168
+ actions: {
169
+ list: {
170
+ summary: 'List the live model catalogue',
171
+ usage: ['codex-bridge model list'],
172
+ },
173
+ set: {
174
+ summary: 'Set the model and optional effort for a role',
175
+ usage: [
176
+ 'codex-bridge model set <role> <model> [effort]',
177
+ 'codex-bridge model set <role> --model <model> [--effort <effort>]',
178
+ ],
179
+ },
180
+ unset: {
181
+ summary: 'Unset the model override for a role',
182
+ usage: ['codex-bridge model unset <role>'],
183
+ },
184
+ speed: {
185
+ summary: 'Preview, pin (confirm) or unpin the accelerated speed tier of a role',
186
+ usage: [
187
+ 'codex-bridge model speed <role> <tier>',
188
+ 'codex-bridge model speed <role> <tier> confirm',
189
+ 'codex-bridge model speed <role> unset',
190
+ ],
191
+ },
192
+ },
193
+ async handler(argv, io) {
194
+ const result = await model(argv);
195
+ io.log(result.output);
196
+ return result.exitCode;
197
+ },
198
+ },
199
+ {
200
+ name: 'projects',
201
+ summary: 'List projects or runs from the run store',
202
+ usage: ['codex-bridge projects [<name>] [--json]'],
203
+ section: 'public',
204
+ async handler(argv, io) {
205
+ const result = projects(argv);
206
+ io.log(result.output);
207
+ return result.exitCode;
208
+ },
209
+ },
210
+ {
211
+ name: 'prune',
212
+ summary: 'Remove archived transport, or purge selected run folders',
213
+ usage: [
214
+ 'codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]',
215
+ 'codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]',
216
+ ],
217
+ section: 'public',
218
+ async handler(argv, io) {
219
+ const result = await prune(argv);
220
+ io.log(result.output);
221
+ return result.exitCode;
222
+ },
223
+ },
224
+ {
225
+ name: 'unlock',
226
+ summary: 'Close running records whose runner is gone, or show the lifecycle lock',
227
+ usage: ['codex-bridge unlock [<project>|--all]', 'codex-bridge unlock --lifecycle [--clear]'],
228
+ section: 'public',
229
+ async handler(argv, io) {
230
+ const result = await unlock(argv);
231
+ io.log(result.output);
232
+ return result.exitCode;
233
+ },
234
+ },
235
+ {
236
+ name: 'read',
237
+ summary: "Render a run's structured event stream",
238
+ usage: ['codex-bridge read <run>'],
239
+ section: 'public',
240
+ async handler(argv, io) {
241
+ if (argv.length !== 1) {
242
+ io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
243
+ return 2;
244
+ }
245
+ const result = read({ run: argv[0] });
246
+ io.log(result.output);
247
+ return result.exitCode;
248
+ },
249
+ },
250
+ {
251
+ name: 'stop',
252
+ summary: 'Stop a running Codex run and record FAIL',
253
+ usage: ['codex-bridge stop <run>'],
254
+ section: 'public',
255
+ async handler(argv, io) {
256
+ if (argv.length !== 1) {
257
+ io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
258
+ return 2;
259
+ }
260
+ const result = await stop({ run: argv[0] });
261
+ io.log(result.output);
262
+ return result.exitCode;
263
+ },
264
+ },
265
+ {
266
+ name: 'hook',
267
+ summary: 'Dispatch a registered guard with stdin unchanged',
268
+ usage: ['codex-bridge hook <name>'],
269
+ section: 'machine',
270
+ async handler(argv, io) {
271
+ return hook(argv, io);
272
+ },
273
+ },
274
+ {
275
+ name: 'sweep',
276
+ summary: 'Renamed to codex-bridge unlock',
277
+ usage: [],
278
+ section: 'renamed',
279
+ async handler(argv, io) {
280
+ io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
281
+ return 2;
282
+ },
283
+ },
284
+ ];
package/cli/copy.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { classifyHomePath } from '../src/home/lib/home-registry.mjs';
6
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
5
7
  import { replacePlaceholders } from './manifest.mjs';
6
8
 
7
9
  export async function plannedContent(item, installationRoot) {
@@ -25,15 +27,50 @@ export async function targetMatches(item, installationRoot) {
25
27
  }
26
28
  }
27
29
 
28
- export async function copyPlannedFile(item, installationRoot) {
30
+ /**
31
+ * Plan_65 D3: the install image is one registry entry whose exact members come from the plan, and
32
+ * seeds keep their own ids. One place decides which id a planned item writes under, so the
33
+ * installer and every fixture that copies a plan cannot disagree about it.
34
+ */
35
+ export function planHomeWriter(brandRoot, plan) {
36
+ const imageMembers = plan.filter((item) => item.root === 'brand').map((item) => item.relativeToRoot);
37
+ const writer = createHomeWriter({ root: brandRoot, imageMembers });
38
+ const idFor = (item) => {
39
+ if (item.root !== 'brand') return undefined;
40
+ const id = classifyHomePath(item.relativeToRoot, { imageMembers })?.id;
41
+ if (!id) throw new Error(`brand install plan item is not a registered home artifact: ${item.relativeToRoot}`);
42
+ return id;
43
+ };
44
+ return { writer, idFor };
45
+ }
46
+
47
+ export async function copyPlannedFile(item, installationRoot, options = {}) {
48
+ const { writer, id } = options ?? {};
49
+ if (!writer) throw new Error('copyPlannedFile requires a home writer');
50
+ if (id !== undefined) await writer.assertArtifact(id, item.target);
51
+ else await writer.assertOutside(item.target);
52
+
29
53
  const content = await plannedContent(item, installationRoot);
30
- await fs.mkdir(path.dirname(item.target), { recursive: true });
54
+ const operations = id === undefined
55
+ ? {
56
+ mkdir: (...args) => fs.mkdir(...args),
57
+ writeFile: (...args) => fs.writeFile(...args),
58
+ rename: (...args) => fs.rename(...args),
59
+ cleanup: (temporary) => fs.rm(temporary, { force: true }),
60
+ }
61
+ : {
62
+ mkdir: (...args) => writer.mkdir(id, ...args),
63
+ writeFile: (...args) => writer.writeFile(id, ...args),
64
+ rename: (...args) => writer.rename(id, ...args),
65
+ cleanup: (temporary) => writer.unlink(id, temporary),
66
+ };
67
+ await operations.mkdir(path.dirname(item.target), { recursive: true });
31
68
  const temporary = path.join(path.dirname(item.target), `.${path.basename(item.target)}.${randomUUID()}.tmp`);
32
69
  try {
33
- await fs.writeFile(temporary, content, { flag: 'wx' });
34
- await fs.rename(temporary, item.target);
70
+ await operations.writeFile(temporary, content, { flag: 'wx' });
71
+ await operations.rename(temporary, item.target);
35
72
  } catch (err) {
36
- await fs.rm(temporary, { force: true }).catch(() => {});
73
+ await Promise.resolve().then(() => operations.cleanup(temporary)).catch(() => {});
37
74
  throw err;
38
75
  }
39
76
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Keeps the latest conclusive verdict per dispatcher host contract, each bound to the host version it was
3
- * measured on. Its own file rather than a field of .host-contract.json (Plan_62 D14, D19): the refusal
4
- * record is replaced whole by its writer, and an inconclusive probe must never erase an earlier verdict.
2
+ * Keeps the latest conclusive verdict per dispatcher host contract, per host version: on 2026-09-25 the
3
+ * VS Code host 2.1.282 ran beside the CLI host 2.1.281, and probing either must keep the other's verdict.
4
+ * Its own file rather than a field of .host-contract.json (Plan_62 D14, D19), and an inconclusive probe
5
+ * must never erase an earlier verdict.
5
6
  */
6
7
  import fs from 'node:fs';
7
8
  import path from 'node:path';
8
- import { writeJsonAtomic } from '../src/home/lib/atomic-json.mjs';
9
+ import { writeHomeJsonAtomic } from '../src/home/lib/atomic-json.mjs';
10
+ import { stateDirWriter } from '../src/home/lib/home-write.mjs';
9
11
  import { parseJsonText } from '../src/home/lib/json-file.mjs';
10
12
  import { DISPATCHER_CONTRACTS } from './dispatcher-contract.mjs';
11
13
 
@@ -19,9 +21,17 @@ export function readDispatcherContract({ stateDir }) {
19
21
  }
20
22
  try {
21
23
  const record = parseJsonText(file, source);
22
- if (!record || typeof record !== 'object' || Array.isArray(record)
23
- || !record.contracts || typeof record.contracts !== 'object' || Array.isArray(record.contracts)) return { corrupt: true };
24
- return record;
24
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return { corrupt: true };
25
+ if (record.hosts && typeof record.hosts === 'object' && !Array.isArray(record.hosts)) return record;
26
+ if (!record.contracts || typeof record.contracts !== 'object' || Array.isArray(record.contracts)) return { corrupt: true };
27
+ const hosts = {};
28
+ for (const [name, item] of Object.entries(record.contracts)) {
29
+ if (!item || typeof item.version !== 'string' || !item.version) continue;
30
+ hosts[item.version] ??= { contracts: {} };
31
+ const { version: _version, ...entry } = item;
32
+ hosts[item.version].contracts[name] = entry;
33
+ }
34
+ return { hosts };
25
35
  } catch { return { corrupt: true }; }
26
36
  }
27
37
 
@@ -29,14 +39,14 @@ export function writeDispatcherContract({ stateDir, version, verdicts, now = new
29
39
  const file = path.join(stateDir, DISPATCHER_CONTRACT_FILE);
30
40
  const previous = readDispatcherContract({ stateDir });
31
41
  if (previous?.corrupt) throw new Error(`Cannot update corrupt dispatcher contract: ${file}`);
32
- const contracts = { ...(previous?.contracts ?? {}) };
42
+ const contracts = { ...(previous?.hosts?.[version]?.contracts ?? {}) };
33
43
  for (const name of DISPATCHER_CONTRACTS) {
34
44
  const result = verdicts?.[name]?.result;
35
45
  if (result === 'honored' || result === 'changed') {
36
- contracts[name] = { result, version, checkedAt: new Date(now).toISOString(), detail: verdicts[name].detail };
46
+ contracts[name] = { result, checkedAt: new Date(now).toISOString(), detail: verdicts[name].detail };
37
47
  }
38
48
  }
39
- const record = { contracts };
40
- writeJsonAtomic(file, record);
49
+ const record = { hosts: { ...(previous?.hosts ?? {}), [version]: { contracts } } };
50
+ writeHomeJsonAtomic(stateDirWriter(stateDir), 'dispatcher-contract', file, record);
41
51
  return record;
42
52
  }
@@ -1,4 +1,4 @@
1
- /** Judges dispatcher host behaviour so a silent regression cannot leave the Plan_62 D19 gate trusted. */
1
+ /** Judges dispatcher host behaviour per version: the 2026-09-25 incident ran VS Code 2.1.282 beside CLI 2.1.281. */
2
2
  import { parseJsonText } from '../src/home/lib/json-file.mjs';
3
3
  import { PROBE_COMMAND } from './host-contract.mjs';
4
4
 
@@ -68,15 +68,16 @@ export function judgeDispatcherContracts({ entries, hostHealthy, okCommand, fail
68
68
 
69
69
  export function dispatcherContractStatus({ record, version }) {
70
70
  return DISPATCHER_CONTRACTS.map((contract) => {
71
- const item = record?.contracts?.[contract];
71
+ const item = record?.hosts?.[version]?.contracts?.[contract];
72
72
  let state;
73
73
  let message;
74
74
  if (version == null) {
75
75
  state = 'unknown-host'; message = 'Host version is unknown; dispatcher contracts cannot be judged.';
76
76
  } else if (!item) {
77
- state = 'unverified'; message = `Dispatcher contract ${contract} has never been probed on host ${version}; run ${PROBE_COMMAND}.`;
78
- } else if (item.version !== version) {
79
- state = 'stale'; message = `Dispatcher contract ${contract} was recorded for host ${item.version}; run ${PROBE_COMMAND}.`;
77
+ const other = Object.entries(record?.hosts ?? {}).filter(([, host]) => host?.contracts?.[contract])
78
+ .sort((a, b) => Date.parse(b[1].contracts[contract].checkedAt) - Date.parse(a[1].contracts[contract].checkedAt))[0];
79
+ if (other) { state = 'stale'; message = `Dispatcher contract ${contract} was recorded for host ${other[0]}; run ${PROBE_COMMAND}.`; }
80
+ else { state = 'unverified'; message = `Dispatcher contract ${contract} has never been probed on host ${version}; run ${PROBE_COMMAND}.`; }
80
81
  } else if (item.result === 'honored') {
81
82
  state = 'verified'; message = `Dispatcher contract ${contract} is verified on host ${version}.`;
82
83
  } else if (item.result === 'changed') {
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Reports host marks separately from home ownership (Plan_65 D10 item 6): WHAT lies in a host
3
+ * comes from its marks, while WHO uses the home comes only from its owner rows. A single
4
+ * "installation: not installed" line hid a host that plainly carried our agents and hooks, and it
5
+ * never said which hosts the home record names or whether its inventory is complete.
6
+ * Owners in sync keeps lagging hosts visible because install/update named them only once (Plan_65 D11 item 3).
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
11
+ import { check } from './doctor-format.mjs';
12
+ import { hasPackageMarks } from './host-inspection.mjs';
13
+ import { imageFingerprint, isFormat2 } from './install-owners.mjs';
14
+
15
+ // Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
16
+ export function ownerEntry(rawRecord, host) {
17
+ if (rawRecord === null || !isFormat2(rawRecord)) return null;
18
+ return rawRecord.owners[normalizeRepoPath(host.root)] ?? null;
19
+ }
20
+
21
+ export function hostSideCheck(host, inspection) {
22
+ if (inspection.settingsError) {
23
+ return check('host files', 'fail', `settings could not be read: ${inspection.settingsError}`);
24
+ }
25
+ if (!hasPackageMarks(inspection)) {
26
+ return check('host files', 'warn', `no codex-bridge files or hooks in ${host.root}`);
27
+ }
28
+
29
+ const kept = inspection.files.filter((file) => file.disposition === 'keep'
30
+ && ['changed', 'unknown'].includes(file.reason));
31
+ const counts = `${inspection.files.length} package file(s) and ${inspection.hooks.length} own hook(s) in ${host.root}`;
32
+ const keptText = kept.map((file) => `${file.relativeToHost} (${file.reason})`).join(', ');
33
+ return kept.length
34
+ ? check('host files', 'warn', `${counts}; kept by uninstall: ${keptText}`)
35
+ : check('host files', 'ok', counts);
36
+ }
37
+
38
+ async function homeImageExists(host) {
39
+ try {
40
+ await fs.access(path.join(host.brandRoot, 'lib'));
41
+ return true;
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') return false;
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ export async function homeOwnersCheck(host, rawRecord, inspection) {
49
+ if (rawRecord === null) {
50
+ return await homeImageExists(host)
51
+ ? check('home owners', 'warn', `no installation record in ${host.brandRoot}; the image has no record`)
52
+ : check('home owners', 'ok', `no installation record in ${host.brandRoot}`);
53
+ }
54
+ if (!isFormat2(rawRecord)) {
55
+ return check(
56
+ 'home owners',
57
+ 'warn',
58
+ 'old record format; the hosts using this home are unknown until the next install or update',
59
+ );
60
+ }
61
+
62
+ const roots = Object.values(rawRecord.owners).map((owner) => owner.root);
63
+ const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
64
+ const hostRecorded = Object.hasOwn(rawRecord.owners, normalizeRepoPath(host.root));
65
+ const unrecordedMarks = hasPackageMarks(inspection) && !hostRecorded;
66
+ const parts = [
67
+ `recorded: ${roots.length ? roots.join(', ') : 'none'}`,
68
+ inventoryComplete ? 'inventory complete' : 'inventory incomplete: an old record did not name every host',
69
+ ...(unrecordedMarks
70
+ ? [`${host.root} has package files or hooks but is not recorded: run codex-bridge install --host "${host.root}"`]
71
+ : []),
72
+ ];
73
+ return check('home owners', inventoryComplete && !unrecordedMarks ? 'ok' : 'warn', parts.join('; '));
74
+ }
75
+
76
+ export function ownersInSyncCheck(rawRecord) {
77
+ if (rawRecord === null || !isFormat2(rawRecord)) {
78
+ return check('owners in sync', 'ok', 'no format-2 record; nothing to compare');
79
+ }
80
+
81
+ const owners = Object.values(rawRecord.owners);
82
+ const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
83
+ if (!owners.length) {
84
+ return check('owners in sync', 'warn',
85
+ `no recorded owners${inventoryComplete ? '' : '; inventory incomplete'}`);
86
+ }
87
+
88
+ const fingerprint = imageFingerprint(rawRecord.image);
89
+ const parts = [];
90
+ const unstamped = [];
91
+ for (const owner of owners) {
92
+ if (owner.imageFingerprint != null) {
93
+ if (owner.imageFingerprint === fingerprint) continue;
94
+ } else if (owner.version === rawRecord.image.version) {
95
+ unstamped.push(owner.root);
96
+ continue;
97
+ }
98
+ // The same version on both sides is the clone case the stamp exists for; say so instead of "0.6.9, image 0.6.9".
99
+ const why = owner.version === rawRecord.image.version
100
+ ? `verified against an earlier image of ${owner.version}`
101
+ : `recorded ${owner.version}, image ${rawRecord.image.version}`;
102
+ parts.push(`${owner.root} (${why}): run codex-bridge update --host "${owner.root}"`);
103
+ }
104
+ if (unstamped.length) parts.push(`not yet verified by image fingerprint: ${unstamped.join(', ')}`);
105
+ if (!inventoryComplete) parts.push('inventory incomplete');
106
+ const warning = parts.length > 0;
107
+ if (!warning) parts.push(`all ${owners.length} owner(s) verified against the current image`);
108
+ return check('owners in sync', warning ? 'warn' : 'ok',
109
+ `${parts.join('; ')} (recorded verification; host files and reachability not inspected)`);
110
+ }