@lyupro/codex-bridge 0.6.11 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.12] - 2026-10-07
8
+
9
+ ### Added
10
+
11
+ - `codex-bridge inventory confirm [--scope user|project] [--host <path>] [--dry-run]` lets the
12
+ operator declare that the recorded hosts are all the hosts using this home. An old installation
13
+ record migrated to format 2 without a terminal stays `inventory: incomplete`: later updates do
14
+ not ask again, and since 0.6.11 every dispatcher model observation leaves the pin undetermined.
15
+ The package cannot prove that no unrecorded host uses the home; only the operator can.
16
+ The command shows the home, recorded host roots and Codex rules registry hints, marking missing
17
+ folders because the registry can retain deleted scratchpads. Enroll an unlisted host with
18
+ `codex-bridge install --host "<path>"` before confirming. A terminal "yes" changes only
19
+ `inventory` to `complete` and removes `legacy`; owners, fingerprints and every other field are
20
+ left as read. This permits ordinary shared-image cleanup when the last recorded host uninstalls;
21
+ it does not record a model match or clear a violation. No, EOF or a non-TTY exits 1 without changes;
22
+ Ctrl+C exits 130; a valid dry run shows the declaration without writing and exits 0. Confirmation
23
+ and an already-complete inventory without `legacy` exit 0. The `doctor` owner rows and the
24
+ incomplete-inventory pin reason now name this command.
25
+
26
+ ### Changed
27
+
28
+ - `doctor` judges handback witness and dispatcher model state for every host whose dispatchers
29
+ ran in the last 24 hours. On 2026-10-07 two VS Code windows ran 2.1.291 and 2.1.292: an observation
30
+ on the non-newest host appeared only as history, and a model violation there would have printed
31
+ `ok`. Active witness violations identify their host with `host <version>:`, and each active
32
+ host's model entries have their own rows with that prefix. A model `fail` on any active host
33
+ reaches exit code 1. Older unresolved entries remain in one history sentence, without active
34
+ warning rows. The newest-host selector still serves contracts, `otherHost:*` and install.
35
+
36
+ ### Fixed
37
+
38
+ - Dispatcher model rows no longer print a doubled period after a pin reason: the live `doctor`
39
+ output had added its own period to a reason that already ended with one.
40
+
7
41
  ## [0.6.11] - 2026-10-07
8
42
 
9
43
  ### Changed
package/README.md CHANGED
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **3199 automated tests: 3198 passing and 1 skipped**.
65
+ The current suite contains **3267 automated tests: 3266 passing and 1 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
@@ -93,7 +93,7 @@ Output on a healthy host (trimmed):
93
93
 
94
94
  `doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. It judges contracts against the newest host version observed in a session transcript, not `claude` on `PATH`: on 2026-09-25 the VS Code extension ran sessions on 2.1.282 while `PATH` resolved to 2.1.281, and the extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` were inherited by every descendant `claude`. The transcript entry's `version` identifies the host. The dispatcher gate records it once per session in `state/host-observations.json` (sessions kept 7 days, hosts 30); `doctor` and `install` judge the newest observed host. `doctor` prints `sessionHost` and one `otherHost:<version>` line for each other observed version, with `ok`, `warn` and a probe command, or `fail` with exit 1. If no session has been observed, they say so instead of using `PATH`. Refusal and dispatcher contract records keep one verdict per host version, so a measurement of version V applies to every host of version V; older single-version records count only for their recorded version.
95
95
 
96
- `doctor` also reports `handbackWitness`, `dispatcherModel:<type>` rows, and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. The witness warns only for unresolved causes on the current observed session host; later disproving evidence clears the warning while retaining history. Intercepted handbacks are not recovery evidence, and old untyped alarms without dispatcher evidence remain unverified history. A stop missing `agent_type` alarms only with dispatcher evidence for that stop. Dispatcher model rows compare transcript-observed families with the installed contract: a latest violation is `fail` with exit 1, uncertainty is `warn`, and a match, including recovery, is `ok`; no observations is also `ok`. Other hosts and recovered incidents are history, not permanent warnings. See [Response guard](docs/overview.md#response-guard) for the evidence rules. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
96
+ `doctor` also reports `handbackWitness`, `dispatcherModel:<type>` rows, and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. Witness and model checks cover every host whose dispatchers ran in the last 24 hours; two VS Code windows on 2.1.291 and 2.1.292 on 2026-10-07 showed why the newest host alone could hide an active host's violation in history. The witness warns for unresolved causes on any active host; later disproving evidence clears the warning while retaining history. Intercepted handbacks are not recovery evidence, and old untyped alarms without dispatcher evidence remain unverified history. A stop missing `agent_type` alarms only with dispatcher evidence for that stop. Each active host's model entries have their own rows prefixed `host <version>:`, comparing transcript-observed families with the installed contract: a latest violation on any active host is `fail` with exit 1, uncertainty is `warn`, and a match, including recovery, is `ok`; no model entries for active hosts is also `ok`. Hosts outside the 24-hour window and recovered incidents remain history, not permanent warnings. Contracts, `otherHost:*` and install still use the newest observed host selector. See [Response guard](docs/overview.md#response-guard) for the evidence rules. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
97
97
 
98
98
  If a session answers `Agent type 'codex-build' not found`, a run can still be started without the dispatcher — the agent is a wrapper that types one command:
99
99
 
@@ -141,6 +141,7 @@ Transport files from runs at least **30 days** old are pruned automatically when
141
141
  | `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
142
142
  | `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
143
143
  | `uninstall [--scope user\|project] [--host <path>] [--dry-run] [--purge]` | Remove recorded package files while preserving run artifacts; `--purge` also deletes your data and the home after one preflight and two consents (see [Uninstall and purge](docs/overview.md#uninstall-and-purge)). |
144
+ | `inventory confirm [--scope user\|project] [--host <path>] [--dry-run]` | Confirm in a terminal that the recorded hosts are all the hosts using this home; until then the dispatcher model pin stays undetermined and `doctor` names this command. Changes only the inventory mark of the installation record. |
144
145
  | `doctor [--scope user\|project] [--host <path>] [--probe-contract] [--probe-executable <path>]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` targets the newest observed host, accepting `CLAUDE_CODE_EXECPATH` or `claude` on `PATH` only when each reports that version with `--version`; use `--probe-executable <path>` to name an executable (`--host` selects the Claude Code configuration). The probe prints its target and records nothing if the completed host run reports another transcript version. |
145
146
  | `run --agent <type> --task-file "<abs path>" [--no-wait]` | Start or attach to a delegated run through the permission-stable package command. The task-file header carries the whole order; its body carries the statement, scout questions and verification command. Its path must be absolute and use forward slashes; `$`, doubled backslashes and a final backslash are refused. Every other runner flag is a free refusal; stdin is not an input channel. |
146
147
  | `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
@@ -0,0 +1,44 @@
1
+ /** Names the hosts whose dispatchers ran in the last 24 hours, the hosts whose witness and model state is active;
2
+ * Plan_67 D12: concurrent VS Code hosts 2.1.291 and 2.1.292 on 2026-10-07 must both be judged, regardless of session liveness.
3
+ */
4
+ export const ACTIVE_HOST_WINDOW_MS = 24 * 60 * 60 * 1000;
5
+
6
+ export function activeHostVersions({ observations, witnessRecord, modelRecord, now = new Date() }) {
7
+ const currentTime = new Date(now).getTime();
8
+ const activity = new Map();
9
+ function recordActivity(version, at) {
10
+ if (typeof version !== 'string' || !version || version === 'unknown') return;
11
+ if (typeof at !== 'string'
12
+ || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(at)) return;
13
+ const time = Date.parse(at);
14
+ const date = Date.parse(`${at.slice(0, 10)}T00:00:00Z`);
15
+ if (!Number.isFinite(time) || !Number.isFinite(date)
16
+ || new Date(date).toISOString().slice(0, 10) !== at.slice(0, 10)) return;
17
+ // Plan_67 R2: future evidence must not hide a valid past source for the same host.
18
+ if (time > currentTime) return;
19
+ if (!activity.has(version) || time > activity.get(version)) activity.set(version, time);
20
+ }
21
+ function ledgerActivity(ledger) {
22
+ if (!ledger || ledger.corrupt) return;
23
+ for (const entry of Object.values(ledger.entries || {})) {
24
+ if (!entry || entry.corrupt || entry.lastObservation?.corrupt) continue;
25
+ const observation = entry.lastObservation;
26
+ recordActivity(observation?.data?.hostVersion, observation?.at);
27
+ }
28
+ }
29
+ if (observations && !observations.corrupt) {
30
+ for (const [version, host] of Object.entries(observations.hosts || {})) {
31
+ if (host && !host.corrupt) recordActivity(version, host.lastSeen);
32
+ }
33
+ for (const session of Object.values(observations.sessions || {})) {
34
+ if (session && !session.corrupt) recordActivity(session.version, session.at);
35
+ }
36
+ }
37
+ if (witnessRecord && !witnessRecord.corrupt) {
38
+ for (const [version, at] of Object.entries(witnessRecord.intercepted || {})) recordActivity(version, at);
39
+ ledgerActivity(witnessRecord.ledger);
40
+ }
41
+ ledgerActivity(modelRecord);
42
+ return [...activity].filter(([, at]) => currentTime - at >= 0 && currentTime - at <= ACTIVE_HOST_WINDOW_MS)
43
+ .sort((a, b) => b[1] - a[1]).map(([version]) => version);
44
+ }
@@ -9,7 +9,8 @@ import { resolveProbeTarget } from './probe-target.mjs';
9
9
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
10
10
  import { hook } from './hook.mjs';
11
11
  import { resolveHost } from './hosts.mjs';
12
- import { install } from './install.mjs';
12
+ import { install } from './install.mjs';
13
+ import { inventoryConfirm } from './inventory-confirm.mjs';
13
14
  import { model } from './model.mjs';
14
15
  import { projects } from './projects.mjs';
15
16
  import { read } from './read.mjs';
@@ -25,7 +26,8 @@ import { update } from './update.mjs';
25
26
  export function commandOptions(command, argv) {
26
27
  const options = {};
27
28
  const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
28
- : command === 'uninstall' ? new Set(['--dry-run', '--purge'])
29
+ : command === 'uninstall' ? new Set(['--dry-run', '--purge'])
30
+ : command === 'inventory' ? new Set(['--dry-run'])
29
31
  : command === 'doctor' ? new Set(['--probe-contract']) : new Set();
30
32
  const flagNames = new Map([
31
33
  ['--dry-run', 'dryRun'],
@@ -123,6 +125,20 @@ export const COMMANDS = [
123
125
  },
124
126
  },
125
127
  {
128
+ name: 'inventory',
129
+ summary: 'Confirm that the recorded hosts are all the hosts using this home',
130
+ usage: ['codex-bridge inventory confirm [--scope user|project] [--host <path>] [--dry-run]'],
131
+ section: 'public',
132
+ async handler(argv, io) {
133
+ if (argv[0] !== 'confirm') throw new Error(`unknown inventory action "${argv[0] ?? ''}"`);
134
+ const options = commandOptions('inventory', argv.slice(1));
135
+ const host = resolveHost(options);
136
+ const result = await inventoryConfirm({ host, dryRun: options.dryRun });
137
+ io.log(result.output);
138
+ return result.exitCode;
139
+ },
140
+ },
141
+ {
126
142
  name: 'doctor',
127
143
  summary: 'Diagnose the selected Claude Code host (--probe-contract measures on a live host)',
128
144
  usage: ['codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract] [--probe-executable <path>]'],
@@ -1,4 +1,4 @@
1
- /** turns the dispatcher-model ledger into doctor rows; Plan_67 D4/D8 and D8 clarification 2026-10-07. */
1
+ /** turns the dispatcher-model ledger into doctor rows; Plan_67 D4/D8/D12 judge every recently active host. */
2
2
  import path from 'node:path';
3
3
  import { DISPATCHER_MODEL_FILE } from '../src/home/lib/dispatcher-model.mjs';
4
4
  import { entryState } from '../src/home/lib/observation-ledger.mjs';
@@ -12,48 +12,50 @@ function entryCheck(entry) {
12
12
  const observation = entry.lastObservation;
13
13
  const { data, verdict, at } = observation;
14
14
  const key = `dispatcherModel:${data.agentType}`;
15
+ const prefix = `host ${data.hostVersion}: `;
15
16
  const state = entryState(entry);
16
17
  // D4: only the latest confirmed violation fails; D8 keeps uncertainty from erasing history.
17
18
  if (verdict === 'violation') {
18
- return check(key, 'fail', `${observationSummary(observation)}; Claude quota was spent on this dispatcher; see Plan_67.`);
19
+ return check(key, 'fail', `${prefix}${observationSummary(observation)}; Claude quota was spent on this dispatcher; see Plan_67.`);
19
20
  }
20
21
  if (verdict === 'undetermined') {
21
22
  const reason = data.pinReasons?.length
22
- ? data.pinReasons.join('; ') : 'no model family could be read from the transcript';
23
+ // Reasons are sentences ending in a period; the row adds its own (live doctor printed "record..").
24
+ ? data.pinReasons.map((text) => text.replace(/\.$/, '')).join('; ') : 'no model family could be read from the transcript';
23
25
  const unresolved = state === 'violation'
24
26
  ? `; earlier violation (${observationSummary(entry.lastViolation)}) is not yet disproved` : '';
25
- return check(key, 'warn', `${data.agentType}: model observation undetermined at ${at}: ${reason}${unresolved}.`);
27
+ return check(key, 'warn', `${prefix}${data.agentType}: model observation undetermined at ${at}: ${reason}${unresolved}.`);
26
28
  }
27
29
  const history = state === 'recovered'
28
30
  ? `; recovered from earlier violation (${observationSummary(entry.lastViolation)})` : '';
29
- return check(key, 'ok', `${observationSummary(observation)}; matches the installed contract${history}.`);
31
+ return check(key, 'ok', `${prefix}${observationSummary(observation)}; matches the installed contract${history}.`);
30
32
  }
31
33
 
32
- export function dispatcherModelChecks({ record, hostVersion, stateDir }) {
34
+ export function dispatcherModelChecks({ record, activeHosts, stateDir }) {
33
35
  if (record?.corrupt) {
34
36
  return [check('dispatcherModel', 'warn',
35
37
  `The dispatcher model record is unreadable; delete ${path.join(stateDir, DISPATCHER_MODEL_FILE)} to reset it.`)];
36
38
  }
37
39
  const entries = Object.values(record?.entries || {});
38
- const current = entries.filter((entry) => entry.lastObservation.data.hostVersion === hostVersion);
40
+ const current = entries.filter((entry) => activeHosts.includes(entry.lastObservation.data.hostVersion));
39
41
  const rows = current.map(entryCheck);
40
42
  if (!rows.length) {
41
- rows.push(check('dispatcherModel', 'ok', 'Not observed yet — recorded when a dispatcher stops on this host.'));
43
+ rows.push(check('dispatcherModel', 'ok', 'Not observed in the last 24 hours — recorded when a dispatcher stops.'));
42
44
  }
43
45
  const hosts = new Map();
44
46
  for (const entry of entries) {
45
47
  const version = entry.lastObservation.data.hostVersion;
46
- if (version === hostVersion) continue;
48
+ if (activeHosts.includes(version)) continue;
47
49
  const counts = hosts.get(version) || { entries: 0, violations: 0 };
48
50
  counts.entries += 1;
49
51
  if (entryState(entry) === 'violation') counts.violations += 1;
50
52
  hosts.set(version, counts);
51
53
  }
52
- // D8: other hosts are one history sentence, never additional active warning rows.
54
+ // D12: inactive hosts are one history sentence, never additional active warning rows.
53
55
  if (hosts.size) {
54
56
  const history = [...hosts].map(([version, counts]) =>
55
57
  `${version ?? 'unknown host'}: ${counts.entries} entries, ${counts.violations} unresolved violations`);
56
- rows[0].value += ` History on other hosts (historical, not current): ${history.join('; ')}.`;
58
+ rows[0].value += ` History on hosts without dispatcher activity in the last 24 hours (historical, not current): ${history.join('; ')}.`;
57
59
  }
58
60
  return rows;
59
- }
61
+ }
@@ -1,111 +1,117 @@
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 { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
14
- import { imageFingerprint } from './install-owners.mjs';
15
-
16
- // Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
17
- export function ownerEntry(rawRecord, host) {
18
- if (rawRecord === null || !isFormat2(rawRecord)) return null;
19
- return rawRecord.owners[normalizeRepoPath(host.root)] ?? null;
20
- }
21
-
22
- export function hostSideCheck(host, inspection) {
23
- if (inspection.settingsError) {
24
- return check('host files', 'fail', `settings could not be read: ${inspection.settingsError}`);
25
- }
26
- if (!hasPackageMarks(inspection)) {
27
- return check('host files', 'warn', `no codex-bridge files or hooks in ${host.root}`);
28
- }
29
-
30
- const kept = inspection.files.filter((file) => file.disposition === 'keep'
31
- && ['changed', 'unknown'].includes(file.reason));
32
- const counts = `${inspection.files.length} package file(s) and ${inspection.hooks.length} own hook(s) in ${host.root}`;
33
- const keptText = kept.map((file) => `${file.relativeToHost} (${file.reason})`).join(', ');
34
- return kept.length
35
- ? check('host files', 'warn', `${counts}; kept by uninstall: ${keptText}`)
36
- : check('host files', 'ok', counts);
37
- }
38
-
39
- async function homeImageExists(host) {
40
- try {
41
- await fs.access(path.join(host.brandRoot, 'lib'));
42
- return true;
43
- } catch (error) {
44
- if (error.code === 'ENOENT') return false;
45
- throw error;
46
- }
47
- }
48
-
49
- export async function homeOwnersCheck(host, rawRecord, inspection) {
50
- if (rawRecord === null) {
51
- return await homeImageExists(host)
52
- ? check('home owners', 'warn', `no installation record in ${host.brandRoot}; the image has no record`)
53
- : check('home owners', 'ok', `no installation record in ${host.brandRoot}`);
54
- }
55
- if (!isFormat2(rawRecord)) {
56
- return check(
57
- 'home owners',
58
- 'warn',
59
- 'old record format; the hosts using this home are unknown until the next install or update',
60
- );
61
- }
62
-
63
- const roots = Object.values(rawRecord.owners).map((owner) => owner.root);
64
- const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
65
- const hostRecorded = Object.hasOwn(rawRecord.owners, normalizeRepoPath(host.root));
66
- const unrecordedMarks = hasPackageMarks(inspection) && !hostRecorded;
67
- const parts = [
68
- `recorded: ${roots.length ? roots.join(', ') : 'none'}`,
69
- inventoryComplete ? 'inventory complete' : 'inventory incomplete: an old record did not name every host',
70
- ...(unrecordedMarks
71
- ? [`${host.root} has package files or hooks but is not recorded: run codex-bridge install --host "${host.root}"`]
72
- : []),
73
- ];
74
- return check('home owners', inventoryComplete && !unrecordedMarks ? 'ok' : 'warn', parts.join('; '));
75
- }
76
-
77
- export function ownersInSyncCheck(rawRecord) {
78
- if (rawRecord === null || !isFormat2(rawRecord)) {
79
- return check('owners in sync', 'ok', 'no format-2 record; nothing to compare');
80
- }
81
-
82
- const owners = Object.values(rawRecord.owners);
83
- const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
84
- if (!owners.length) {
85
- return check('owners in sync', 'warn',
86
- `no recorded owners${inventoryComplete ? '' : '; inventory incomplete'}`);
87
- }
88
-
89
- const fingerprint = imageFingerprint(rawRecord.image);
90
- const parts = [];
91
- const unstamped = [];
92
- for (const owner of owners) {
93
- if (owner.imageFingerprint != null) {
94
- if (owner.imageFingerprint === fingerprint) continue;
95
- } else if (owner.version === rawRecord.image.version) {
96
- unstamped.push(owner.root);
97
- continue;
98
- }
99
- // 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".
100
- const why = owner.version === rawRecord.image.version
101
- ? `verified against an earlier image of ${owner.version}`
102
- : `recorded ${owner.version}, image ${rawRecord.image.version}`;
103
- parts.push(`${owner.root} (${why}): run codex-bridge update --host "${owner.root}"`);
104
- }
105
- if (unstamped.length) parts.push(`not yet verified by image fingerprint: ${unstamped.join(', ')}`);
106
- if (!inventoryComplete) parts.push('inventory incomplete');
107
- const warning = parts.length > 0;
108
- if (!warning) parts.push(`all ${owners.length} owner(s) verified against the current image`);
109
- return check('owners in sync', warning ? 'warn' : 'ok',
110
- `${parts.join('; ')} (recorded verification; host files and reachability not inspected)`);
111
- }
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 { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
14
+ import { imageFingerprint } from './install-owners.mjs';
15
+ import { INVENTORY_CONFIRM_COMMAND } from './inventory-confirm.mjs';
16
+
17
+ const inventoryRepair = `; run ${INVENTORY_CONFIRM_COMMAND} if the recorded hosts are all of them`;
18
+
19
+ const ownerRepair = '; install into each host first: run codex-bridge install --host "<path>"';
20
+
21
+ // Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
22
+ export function ownerEntry(rawRecord, host) {
23
+ if (rawRecord === null || !isFormat2(rawRecord)) return null;
24
+ return rawRecord.owners[normalizeRepoPath(host.root)] ?? null;
25
+ }
26
+
27
+ export function hostSideCheck(host, inspection) {
28
+ if (inspection.settingsError) {
29
+ return check('host files', 'fail', `settings could not be read: ${inspection.settingsError}`);
30
+ }
31
+ if (!hasPackageMarks(inspection)) {
32
+ return check('host files', 'warn', `no codex-bridge files or hooks in ${host.root}`);
33
+ }
34
+
35
+ const kept = inspection.files.filter((file) => file.disposition === 'keep'
36
+ && ['changed', 'unknown'].includes(file.reason));
37
+ const counts = `${inspection.files.length} package file(s) and ${inspection.hooks.length} own hook(s) in ${host.root}`;
38
+ const keptText = kept.map((file) => `${file.relativeToHost} (${file.reason})`).join(', ');
39
+ return kept.length
40
+ ? check('host files', 'warn', `${counts}; kept by uninstall: ${keptText}`)
41
+ : check('host files', 'ok', counts);
42
+ }
43
+
44
+ async function homeImageExists(host) {
45
+ try {
46
+ await fs.access(path.join(host.brandRoot, 'lib'));
47
+ return true;
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') return false;
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ export async function homeOwnersCheck(host, rawRecord, inspection) {
55
+ if (rawRecord === null) {
56
+ return await homeImageExists(host)
57
+ ? check('home owners', 'warn', `no installation record in ${host.brandRoot}; the image has no record`)
58
+ : check('home owners', 'ok', `no installation record in ${host.brandRoot}`);
59
+ }
60
+ if (!isFormat2(rawRecord)) {
61
+ return check(
62
+ 'home owners',
63
+ 'warn',
64
+ 'old record format; the hosts using this home are unknown until the next install or update',
65
+ );
66
+ }
67
+
68
+ const roots = Object.values(rawRecord.owners).map((owner) => owner.root);
69
+ const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
70
+ const hostRecorded = Object.hasOwn(rawRecord.owners, normalizeRepoPath(host.root));
71
+ const unrecordedMarks = hasPackageMarks(inspection) && !hostRecorded;
72
+ const parts = [
73
+ `recorded: ${roots.length ? roots.join(', ') : 'none'}`,
74
+ inventoryComplete ? 'inventory complete'
75
+ : `inventory incomplete: an old record did not name every host${roots.length ? inventoryRepair : ownerRepair}`,
76
+ ...(unrecordedMarks
77
+ ? [`${host.root} has package files or hooks but is not recorded: run codex-bridge install --host "${host.root}"`]
78
+ : []),
79
+ ];
80
+ return check('home owners', inventoryComplete && !unrecordedMarks ? 'ok' : 'warn', parts.join('; '));
81
+ }
82
+
83
+ export function ownersInSyncCheck(rawRecord) {
84
+ if (rawRecord === null || !isFormat2(rawRecord)) {
85
+ return check('owners in sync', 'ok', 'no format-2 record; nothing to compare');
86
+ }
87
+
88
+ const owners = Object.values(rawRecord.owners);
89
+ const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
90
+ if (!owners.length) {
91
+ return check('owners in sync', 'warn',
92
+ `no recorded owners${inventoryComplete ? '' : `; inventory incomplete${ownerRepair}`}`);
93
+ }
94
+
95
+ const fingerprint = imageFingerprint(rawRecord.image);
96
+ const parts = [];
97
+ const unstamped = [];
98
+ for (const owner of owners) {
99
+ if (owner.imageFingerprint != null) {
100
+ if (owner.imageFingerprint === fingerprint) continue;
101
+ } else if (owner.version === rawRecord.image.version) {
102
+ unstamped.push(owner.root);
103
+ continue;
104
+ }
105
+ // 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".
106
+ const why = owner.version === rawRecord.image.version
107
+ ? `verified against an earlier image of ${owner.version}`
108
+ : `recorded ${owner.version}, image ${rawRecord.image.version}`;
109
+ parts.push(`${owner.root} (${why}): run codex-bridge update --host "${owner.root}"`);
110
+ }
111
+ if (unstamped.length) parts.push(`not yet verified by image fingerprint: ${unstamped.join(', ')}`);
112
+ if (!inventoryComplete) parts.push(`inventory incomplete${inventoryRepair}`);
113
+ const warning = parts.length > 0;
114
+ if (!warning) parts.push(`all ${owners.length} owner(s) verified against the current image`);
115
+ return check('owners in sync', warning ? 'warn' : 'ok',
116
+ `${parts.join('; ')} (recorded verification; host files and reachability not inspected)`);
117
+ }
package/cli/doctor.mjs CHANGED
@@ -19,15 +19,16 @@ import {
19
19
  } from './doctor-installation.mjs';
20
20
  import { hookChecks } from './doctor-hooks.mjs';
21
21
  import { contractStatus, readHostContract } from './host-contract.mjs';
22
- import { handbackWitnessStatus } from './handback-witness-check.mjs';
22
+ import { handbackWitnessStatus } from './handback-witness-check.mjs';
23
23
  import { dispatcherModelChecks } from './dispatcher-model-check.mjs';
24
24
  import { dispatcherContractStatus } from './dispatcher-contract.mjs';
25
25
  import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
26
26
  import { PROBE_COMMAND } from './host-contract.mjs';
27
- import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
27
+ import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
28
28
  import { readDispatcherModel } from '../src/home/lib/dispatcher-model.mjs';
29
29
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
30
30
  import { readHostObservations } from '../src/home/lib/host-observations.mjs';
31
+ import { activeHostVersions } from './active-hosts.mjs';
31
32
  import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
32
33
  import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
33
34
 
@@ -84,10 +85,12 @@ export async function diagnose({
84
85
  launcherProbe,
85
86
  currentPackage,
86
87
  contractRecord,
87
- handbackWitnessRecord,
88
+ handbackWitnessRecord,
88
89
  dispatcherModelRecord,
89
90
  dispatcherContractRecord,
90
91
  hostVersion,
92
+ activeHosts: injectedActiveHosts,
93
+ now,
91
94
  observations,
92
95
  } = {}) {
93
96
  const checks = [sourceCheck()];
@@ -150,6 +153,10 @@ export async function diagnose({
150
153
  const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
151
154
  const observedVersions = sessionHostVersions(hostObservations);
152
155
  const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
156
+ const witnessRecord = handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord;
157
+ const modelRecord = dispatcherModelRecord === undefined ? readDispatcherModel({ stateDir }) : dispatcherModelRecord;
158
+ const activeHosts = injectedActiveHosts ?? (hostVersion !== undefined ? [hostVersion].filter(Boolean)
159
+ : activeHostVersions({ observations: hostObservations, witnessRecord, modelRecord, now }));
153
160
  checks.push(sessionHostCheck(observedVersions, hostObservations));
154
161
  const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
155
162
  const hostContract = contractStatus({
@@ -161,15 +168,15 @@ export async function diagnose({
161
168
  : hostContract.state === 'ignored' ? 'fail' : 'warn';
162
169
  checks.push(check('hostContract', hostContractStatus, hostContract.message));
163
170
  const witness = handbackWitnessStatus({
164
- record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
165
- hostVersion: detectedHostVersion,
171
+ record: witnessRecord,
172
+ activeHosts,
166
173
  stateDir,
167
174
  });
168
- checks.push(check('handbackWitness', ['violation', 'unreadable'].includes(witness.state) ? 'warn' : 'ok', witness.message));
169
- checks.push(...dispatcherModelChecks({
170
- record: dispatcherModelRecord === undefined ? readDispatcherModel({ stateDir }) : dispatcherModelRecord,
171
- hostVersion: detectedHostVersion,
172
- stateDir,
175
+ checks.push(check('handbackWitness', ['violation', 'unreadable'].includes(witness.state) ? 'warn' : 'ok', witness.message));
176
+ checks.push(...dispatcherModelChecks({
177
+ record: modelRecord,
178
+ activeHosts,
179
+ stateDir,
173
180
  }));
174
181
  const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
175
182
  checks.push(...dispatcherContractChecks({
@@ -1,37 +1,41 @@
1
- /** Surfaces handback drift; 2026-09-25 established exact host transcript versions as identity. */
1
+ /** Surfaces handback drift; Plan_67 D12 judges every host with recent dispatcher activity. */
2
2
  import path from 'node:path';
3
3
  import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
4
4
  import { entryState } from '../src/home/lib/observation-ledger.mjs';
5
5
 
6
- export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
6
+ export function handbackWitnessStatus({ record, activeHosts, stateDir }) {
7
7
  if (record?.corrupt) {
8
8
  return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
9
9
  }
10
10
  const entries = Object.values(record?.ledger?.entries || {});
11
+ let history = '';
12
+ const recovered = entries.filter((entry) => entryState(entry) === 'recovered').length;
13
+ const historical = entries.filter((entry) => entryState(entry) === 'violation'
14
+ && !activeHosts.includes(entry.lastObservation.data.hostVersion)).length;
15
+ const legacy = record?.legacy || [];
16
+ if (recovered || historical || legacy.length) {
17
+ const untyped = legacy.filter((entry) => entry.disposition === 'legacy-untyped-unverified').length;
18
+ const unclassified = legacy.filter((entry) => entry.disposition === 'unclassified').length;
19
+ history = ` History: ${recovered} recovered entries; ${historical} unresolved entries on hosts without dispatcher activity in the last 24 hours (history); ${untyped} older alarms without dispatcher evidence, kept for history (legacy-untyped-unverified); ${unclassified} unclassified legacy entries.`;
20
+ }
11
21
  const violations = entries.filter((entry) => entryState(entry) === 'violation'
12
- && entry.lastObservation.data.hostVersion === hostVersion);
22
+ && activeHosts.includes(entry.lastObservation.data.hostVersion));
13
23
  if (violations.length > 0) {
14
24
  const details = violations.map((entry) => {
15
25
  const { data, detail, at } = entry.lastViolation;
16
- return `${data.cause}${data.agentType ? ` / ${data.agentType}` : ''}: ${detail} at ${at}`;
26
+ return `host ${data.hostVersion}: ${data.cause}${data.agentType ? ` / ${data.agentType}` : ''}: ${detail} at ${at}`;
17
27
  });
18
28
  return {
19
29
  state: 'violation',
20
- message: `Dispatcher violation(s) on host ${hostVersion ?? 'unknown host'}: ${details.join('; ')}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
30
+ message: `Dispatcher violation(s): ${details.join('; ')}; do not trust a dispatcher answer from that time; see Plan_62 D15.${history}`,
21
31
  };
22
32
  }
23
- const intercepted = record?.intercepted?.[hostVersion || 'unknown'];
24
- let message = intercepted
25
- ? `Newest intercepted handback on host ${hostVersion ?? 'unknown host'} at ${intercepted}.`
26
- : 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.';
27
- const recovered = entries.filter((entry) => entryState(entry) === 'recovered').length;
28
- const historical = entries.filter((entry) => entryState(entry) === 'violation'
29
- && entry.lastObservation.data.hostVersion !== hostVersion).length;
30
- const legacy = record?.legacy || [];
31
- if (recovered || historical || legacy.length) {
32
- const untyped = legacy.filter((entry) => entry.disposition === 'legacy-untyped-unverified').length;
33
- const unclassified = legacy.filter((entry) => entry.disposition === 'unclassified').length;
34
- message += ` History: ${recovered} recovered entries; ${historical} unresolved entries on other hosts (historical, not current); ${untyped} older alarms without dispatcher evidence, kept for history (legacy-untyped-unverified); ${unclassified} unclassified legacy entries.`;
35
- }
36
- return { state: 'ok', message };
33
+ const intercepted = activeHosts.filter((host) => record?.intercepted?.[host])
34
+ .map((host) => `host ${host} at ${record.intercepted[host]}`);
35
+ const message = !activeHosts.length
36
+ ? 'No dispatcher activity in the last 24 hours.'
37
+ : intercepted.length
38
+ ? `Newest intercepted handback on ${intercepted.join('; ')}.`
39
+ : 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.';
40
+ return { state: 'ok', message: message + history };
37
41
  }
@@ -307,6 +307,11 @@ async function publishInstallRecord(host, next) {
307
307
  await writer.rename('install-record', temporary, target);
308
308
  }
309
309
 
310
+ /** Plan_67 D11 / A4: changing inventory must not rebuild owners or hide fingerprint lag. */
311
+ export function publishInstallRecordOnly(host, next) {
312
+ return publishInstallRecord(host, next);
313
+ }
314
+
310
315
  /**
311
316
  * Publishes the record whole through a temporary and a rename: a direct write left a half-written
312
317
  * record for every host sharing the home if the process died mid-write (advice A3, risk r3).
@@ -0,0 +1,75 @@
1
+ /** Lets the operator declare a shared home's installation inventory complete, the one statement the package cannot prove.
2
+ * Plan_67 D11, OW-047: only the operator can account for unrecorded hosts using this home.
3
+ */
4
+ import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
5
+ import { publishInstallRecordOnly, readInstallRecordFile } from './install-record.mjs';
6
+ import { validateFormat2 } from './install-owners.mjs';
7
+ import { registryHintLines } from './inventory-transition.mjs';
8
+ import { withLifecycle } from './lifecycle-transaction.mjs';
9
+ import { readRulesRegistry } from './rules-owners.mjs';
10
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
11
+
12
+ export const INVENTORY_CONFIRM_COMMAND = 'codex-bridge inventory confirm';
13
+
14
+ const question = 'Are these all the hosts using this home?';
15
+ const updateHint = 'Run codex-bridge update to migrate the installation record.';
16
+
17
+ async function confirmInRun(host, dryRun, questionOptions) {
18
+ let record;
19
+ try {
20
+ record = await readInstallRecordFile(host);
21
+ if (!isFormat2(record)) {
22
+ return { exitCode: 1, output: `No format-2 installation record was found. ${updateHint}` };
23
+ }
24
+ validateFormat2(record);
25
+ } catch (error) {
26
+ return { exitCode: 1, output: `Invalid installation record: ${error.message} ${updateHint}` };
27
+ }
28
+ const owners = Object.values(record.owners);
29
+ if (owners.length === 0) {
30
+ return { exitCode: 1, output: 'no recorded owners; install into each host first' };
31
+ }
32
+ if (record.inventory === 'complete' && !Object.hasOwn(record, 'legacy')) {
33
+ return { exitCode: 0, output: 'The inventory is already complete.' };
34
+ }
35
+
36
+ const screen = [
37
+ `Home: ${host.brandRoot}`,
38
+ 'Recorded hosts:',
39
+ ...owners.map((owner) => ` ${owner.root}`),
40
+ ...registryHintLines(host, (await readRulesRegistry(host))?.owners ?? []),
41
+ "Confirming lets the last recorded host's uninstall remove the shared image. "
42
+ + 'A host that uses this home but is not listed must be enrolled first: '
43
+ + 'codex-bridge install --host "<path>".',
44
+ ].join('\n');
45
+ if (dryRun) return { exitCode: 0, output: `${screen}\nDry run: nothing changed.` };
46
+ const prompt = `${screen}\n${question}`;
47
+ if (!isInteractive(questionOptions)) {
48
+ return { exitCode: 1, output: `${prompt}\nRun this command in a terminal to answer.` };
49
+ }
50
+ const answer = await askYesNo(prompt, questionOptions);
51
+ if (answer === 'cancel') {
52
+ return { exitCode: 130, output: `${prompt}\nCancelled; nothing was changed.` };
53
+ }
54
+ if (answer !== 'yes') {
55
+ return { exitCode: 1, output: `${prompt}\nNothing changed: the inventory stays incomplete.` };
56
+ }
57
+
58
+ // A4 pre-mortem: the raw fields are evidence of lag, not material to regenerate on confirmation.
59
+ const next = { ...record, inventory: 'complete' };
60
+ delete next.legacy;
61
+ await publishInstallRecordOnly(host, next);
62
+ return { exitCode: 0, output: `${prompt}\nRecorded the inventory as complete.` };
63
+ }
64
+
65
+ export async function inventoryConfirm({ host, dryRun = false, ...questionOptions }) {
66
+ const action = (ticket) => {
67
+ // Plan_67 R2: a first install may create the home after the unlocked missing-home check.
68
+ if (!dryRun && ticket === undefined) {
69
+ return { exitCode: 1, output: `No format-2 installation record was found. ${updateHint}` };
70
+ }
71
+ return confirmInRun(host, dryRun, questionOptions);
72
+ };
73
+ // Plan_67 D11: dry-run only reads; a real declaration holds the lifecycle lock before reading.
74
+ return dryRun ? action() : withLifecycle(host, 'inventory', action);
75
+ }
@@ -3,6 +3,7 @@
3
3
  * store nothing about a refusal.
4
4
  */
5
5
  import fs from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { askYesNo, isInteractive } from './terminal-question.mjs';
8
9
  import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
@@ -36,7 +37,8 @@ export function registryHintLines(host, candidates) {
36
37
  const otherCandidates = candidates.filter((root) => root !== owner);
37
38
  return [
38
39
  'Codex rules registry host roots are a hint only; they may be stale or belong to another home:',
39
- ...(otherCandidates.length ? otherCandidates.map((root) => ` ${root}`) : [' none found']),
40
+ ...(otherCandidates.length ? otherCandidates.map((root) =>
41
+ ` ${root}${existsSync(root) ? '' : ' (folder does not exist)'}`) : [' none found']),
40
42
  ];
41
43
  }
42
44
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.11",
4
- "description": "Claude Code-to-Codex delegation with 7 guards, 3199 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.12",
4
+ "description": "Claude Code-to-Codex delegation with 7 guards, 3267 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -1,53 +1,55 @@
1
- /**
2
- * Reads which host roots own this home from the installation record, read-only, for hooks.
3
- * Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
4
- */
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { homeArtifact } from './home-registry.mjs';
8
- import { parseJsonText } from './json-file.mjs';
9
-
10
- const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
11
-
12
- export function isFormat2(parsed) {
13
- return isObject(parsed) && parsed.format === 2;
14
- }
15
-
16
- export function readOwnerRoots({ brandRoot }) {
17
- const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
18
- let raw;
19
- try {
20
- raw = fs.readFileSync(file, 'utf8');
21
- } catch (error) {
22
- return error.code === 'ENOENT'
23
- ? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
24
- : { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
25
- }
26
-
27
- let parsed;
28
- try {
29
- parsed = parseJsonText(file, raw);
30
- } catch {
31
- return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
32
- }
33
- if (!isFormat2(parsed)) {
34
- return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
35
- }
36
- if (parsed.inventory !== 'complete') {
37
- return { problem: 'inventory-incomplete', detail: `Installation record at ${file} has an incomplete inventory.` };
38
- }
39
- if (Object.hasOwn(parsed, 'legacy')) {
40
- return { problem: 'legacy-partition', detail: `Installation record at ${file} contains a legacy partition.` };
41
- }
42
- if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
43
- return { problem: 'no-owners', detail: `Installation record at ${file} has no owners.` };
44
- }
45
- const roots = [];
46
- for (const owner of Object.values(parsed.owners)) {
47
- if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
48
- return { problem: 'malformed-owner', detail: `Installation record at ${file} has an owner without a non-empty root.` };
49
- }
50
- roots.push(owner.root);
51
- }
52
- return { roots: roots.sort() };
53
- }
1
+ /**
2
+ * Reads which host roots own this home from the installation record, read-only, for hooks.
3
+ * Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { homeArtifact } from './home-registry.mjs';
8
+ import { parseJsonText } from './json-file.mjs';
9
+
10
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
11
+
12
+ export function isFormat2(parsed) {
13
+ return isObject(parsed) && parsed.format === 2;
14
+ }
15
+
16
+ export function readOwnerRoots({ brandRoot }) {
17
+ const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
18
+ let raw;
19
+ try {
20
+ raw = fs.readFileSync(file, 'utf8');
21
+ } catch (error) {
22
+ return error.code === 'ENOENT'
23
+ ? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
24
+ : { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
25
+ }
26
+
27
+ let parsed;
28
+ try {
29
+ parsed = parseJsonText(file, raw);
30
+ } catch {
31
+ return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
32
+ }
33
+ if (!isFormat2(parsed)) {
34
+ return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
35
+ }
36
+ // Plan_67 R2: confirmation refuses ownerless records; enrollment must come first.
37
+ if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
38
+ return { problem: 'no-owners', detail: `Installation record at ${file} has no owners.` };
39
+ }
40
+ if (parsed.inventory !== 'complete') {
41
+ // Plan_67 D11: match cli/inventory-confirm.mjs INVENTORY_CONFIRM_COMMAND; hooks cannot import cli/.
42
+ return { problem: 'inventory-incomplete', detail: `Installation record at ${file} has an incomplete inventory; if the recorded hosts are all the hosts using this home, run codex-bridge inventory confirm.` };
43
+ }
44
+ if (Object.hasOwn(parsed, 'legacy')) {
45
+ return { problem: 'legacy-partition', detail: `Installation record at ${file} contains a legacy partition.` };
46
+ }
47
+ const roots = [];
48
+ for (const owner of Object.values(parsed.owners)) {
49
+ if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
50
+ return { problem: 'malformed-owner', detail: `Installation record at ${file} has an owner without a non-empty root.` };
51
+ }
52
+ roots.push(owner.root);
53
+ }
54
+ return { roots: roots.sort() };
55
+ }