@lyupro/codex-bridge 0.6.10 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +16 -4
  3. package/cli/active-hosts.mjs +44 -0
  4. package/cli/command-registry.mjs +18 -2
  5. package/cli/dispatcher-model-check.mjs +61 -0
  6. package/cli/doctor-host-side.mjs +117 -110
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor.mjs +19 -4
  9. package/cli/handback-witness-check.mjs +30 -20
  10. package/cli/hosts.mjs +2 -10
  11. package/cli/install-owners.mjs +1 -4
  12. package/cli/install-record.mjs +7 -1
  13. package/cli/inventory-confirm.mjs +75 -0
  14. package/cli/inventory-transition.mjs +4 -2
  15. package/cli/manifest.mjs +6 -6
  16. package/package.json +2 -2
  17. package/src/agents/codex-advisor.md +25 -119
  18. package/src/agents/codex-build.md +15 -133
  19. package/src/agents/codex-review.md +10 -109
  20. package/src/agents/codex-scout.md +11 -130
  21. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  22. package/src/home/hooks/live-runs.mjs +0 -41
  23. package/src/home/hooks/order-gate.mjs +59 -40
  24. package/src/home/hooks/reply-guard.mjs +52 -65
  25. package/src/home/hooks/reply-identity.mjs +64 -0
  26. package/src/home/hooks/reply-verdicts.mjs +31 -27
  27. package/src/home/hooks/reply-witness.mjs +48 -0
  28. package/src/home/lib/claude-layout.mjs +14 -0
  29. package/src/home/lib/dispatcher-call.mjs +171 -0
  30. package/src/home/lib/dispatcher-command.mjs +18 -56
  31. package/src/home/lib/dispatcher-gate.mjs +4 -0
  32. package/src/home/lib/dispatcher-model.mjs +112 -0
  33. package/src/home/lib/dispatcher-order.mjs +21 -22
  34. package/src/home/lib/dispatcher-pin.mjs +60 -0
  35. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  36. package/src/home/lib/dispatcher-stop.mjs +11 -2
  37. package/src/home/lib/handback-witness.mjs +137 -48
  38. package/src/home/lib/home-registry.mjs +7 -0
  39. package/src/home/lib/install-owner-roots.mjs +55 -0
  40. package/src/home/lib/meta/paths.mjs +1 -1
  41. package/src/home/lib/meta/reply.mjs +2 -1
  42. package/src/home/lib/model-family.mjs +38 -0
  43. package/src/home/lib/observation-ledger.mjs +145 -0
  44. package/src/home/lib/order-schema.mjs +204 -0
  45. package/src/home/lib/order-values.mjs +46 -0
  46. package/src/home/lib/runner/args.mjs +140 -231
  47. package/src/home/lib/runner/attach.mjs +3 -3
  48. package/src/home/lib/runner/continuation.mjs +174 -192
  49. package/src/home/lib/runner/launcher.mjs +9 -9
  50. package/src/home/lib/runner/order-options.mjs +30 -0
  51. package/src/home/lib/runner/order-owner.mjs +2 -1
  52. package/src/home/lib/runner/pass-gate.mjs +11 -9
  53. package/src/home/lib/runner/preflight.mjs +4 -3
  54. package/src/home/lib/runner/scope-check.mjs +11 -10
  55. package/src/home/lib/runner/task-input.mjs +23 -10
  56. package/src/home/lib/subagent-model-env.mjs +44 -0
  57. package/src/home/lib/task-header.mjs +158 -127
  58. package/src/home/lib/untyped-stop.mjs +30 -0
  59. package/src/home/lib/required-inputs.mjs +0 -301
  60. /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
@@ -1,111 +1,111 @@
1
- /** Reports whether what this package installed into the host still matches the package. */
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
- import {
5
- buildInstallPlan,
6
- contentFingerprint,
7
- fileFingerprint,
8
- } from './manifest.mjs';
9
- import { plannedContent } from './copy.mjs';
10
- import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
11
- import { readRulesRegistry } from './rules-owners.mjs';
12
- import { parseFrontmatter } from './frontmatter.mjs';
13
- import { check } from './doctor-format.mjs';
14
-
15
- export async function exists(target) {
16
- try {
17
- await fs.access(target);
18
- return true;
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
- export async function isFile(target) {
25
- try {
26
- return (await fs.stat(target)).isFile();
27
- } catch {
28
- return false;
29
- }
30
- }
31
-
32
- export async function agentsCheck(host, record) {
33
- if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
34
- const plan = await buildInstallPlan(host);
35
- const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
36
- const mismatches = [];
37
- const unreadable = [];
38
- for (const item of agents) {
39
- const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
40
- if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
41
- const expectedName = path.basename(item.source, '.md');
42
- try {
43
- const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
44
- if (!frontmatter) throw new Error('frontmatter is missing');
45
- if (frontmatter.name !== expectedName) {
46
- throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
47
- }
48
- } catch (err) {
49
- unreadable.push(`${path.basename(item.target)} (${err.message})`);
50
- }
51
- }
52
- // The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
53
- // advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
54
- // the package command — in an installed definition: doctor kept exit 0 while every delegation
55
- // would stop on a permission prompt. A definition that is not this package's is not judged healthy.
56
- if (unreadable.length) {
57
- return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
58
- }
59
- return mismatches.length
60
- ? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
61
- : check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
62
- }
63
-
64
- export async function conventionsCheck(host) {
65
- const file = host.brandConventionsPath;
66
- let content;
67
- try {
68
- content = await fs.readFile(file, 'utf8');
69
- } catch (err) {
70
- if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
71
- return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
72
- }
73
- return content.trim()
74
- ? check('conventions', 'ok', `${file} (found)`)
75
- : check('conventions', 'warn', `${file} (found but empty)`);
76
- }
77
-
78
- export async function rulesCheck(host, record) {
79
- if (!record) return check('rules', 'warn', 'cannot check before installation');
80
- if (!record.rules) {
81
- return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
82
- }
83
- let registry;
84
- try {
85
- registry = await readRulesRegistry(host);
86
- } catch (err) {
87
- // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
88
- return check('rules', 'fail', err.message);
89
- }
90
- const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
91
- const fingerprint = await fileFingerprint(record.rules.path);
92
- if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
93
- return fingerprint === record.rules.fingerprint
94
- ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
95
- : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
96
- }
97
-
98
- export async function permissionsCheck(host) {
99
- try {
100
- const status = await inspectPermissions(host.settingsPath);
101
- // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
102
- // doctor does not turn a healthy installation red merely because hardening was not requested.
103
- // The ask count is part of the line because a full set shadowed by `ask` reads as working and
104
- // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
105
- const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
106
- return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
107
- `${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
108
- } catch (err) {
109
- return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
110
- }
111
- }
1
+ /** Reports whether what this package installed into the host still matches the package. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ buildInstallPlan,
6
+ contentFingerprint,
7
+ fileFingerprint,
8
+ } from './manifest.mjs';
9
+ import { plannedContent } from './copy.mjs';
10
+ import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
11
+ import { readRulesRegistry } from './rules-owners.mjs';
12
+ import { parseFrontmatter } from '../src/home/lib/frontmatter.mjs';
13
+ import { check } from './doctor-format.mjs';
14
+
15
+ export async function exists(target) {
16
+ try {
17
+ await fs.access(target);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export async function isFile(target) {
25
+ try {
26
+ return (await fs.stat(target)).isFile();
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export async function agentsCheck(host, record) {
33
+ if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
34
+ const plan = await buildInstallPlan(host);
35
+ const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
36
+ const mismatches = [];
37
+ const unreadable = [];
38
+ for (const item of agents) {
39
+ const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
40
+ if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
41
+ const expectedName = path.basename(item.source, '.md');
42
+ try {
43
+ const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
44
+ if (!frontmatter) throw new Error('frontmatter is missing');
45
+ if (frontmatter.name !== expectedName) {
46
+ throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
47
+ }
48
+ } catch (err) {
49
+ unreadable.push(`${path.basename(item.target)} (${err.message})`);
50
+ }
51
+ }
52
+ // The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
53
+ // advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
54
+ // the package command — in an installed definition: doctor kept exit 0 while every delegation
55
+ // would stop on a permission prompt. A definition that is not this package's is not judged healthy.
56
+ if (unreadable.length) {
57
+ return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
58
+ }
59
+ return mismatches.length
60
+ ? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
61
+ : check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
62
+ }
63
+
64
+ export async function conventionsCheck(host) {
65
+ const file = host.brandConventionsPath;
66
+ let content;
67
+ try {
68
+ content = await fs.readFile(file, 'utf8');
69
+ } catch (err) {
70
+ if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
71
+ return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
72
+ }
73
+ return content.trim()
74
+ ? check('conventions', 'ok', `${file} (found)`)
75
+ : check('conventions', 'warn', `${file} (found but empty)`);
76
+ }
77
+
78
+ export async function rulesCheck(host, record) {
79
+ if (!record) return check('rules', 'warn', 'cannot check before installation');
80
+ if (!record.rules) {
81
+ return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
82
+ }
83
+ let registry;
84
+ try {
85
+ registry = await readRulesRegistry(host);
86
+ } catch (err) {
87
+ // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
88
+ return check('rules', 'fail', err.message);
89
+ }
90
+ const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
91
+ const fingerprint = await fileFingerprint(record.rules.path);
92
+ if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
93
+ return fingerprint === record.rules.fingerprint
94
+ ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
95
+ : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
96
+ }
97
+
98
+ export async function permissionsCheck(host) {
99
+ try {
100
+ const status = await inspectPermissions(host.settingsPath);
101
+ // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
102
+ // doctor does not turn a healthy installation red merely because hardening was not requested.
103
+ // The ask count is part of the line because a full set shadowed by `ask` reads as working and
104
+ // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
105
+ const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
106
+ return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
107
+ `${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
108
+ } catch (err) {
109
+ return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
110
+ }
111
+ }
package/cli/doctor.mjs CHANGED
@@ -20,12 +20,15 @@ import {
20
20
  import { hookChecks } from './doctor-hooks.mjs';
21
21
  import { contractStatus, readHostContract } from './host-contract.mjs';
22
22
  import { handbackWitnessStatus } from './handback-witness-check.mjs';
23
+ import { dispatcherModelChecks } from './dispatcher-model-check.mjs';
23
24
  import { dispatcherContractStatus } from './dispatcher-contract.mjs';
24
25
  import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
25
26
  import { PROBE_COMMAND } from './host-contract.mjs';
26
27
  import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
28
+ import { readDispatcherModel } from '../src/home/lib/dispatcher-model.mjs';
27
29
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
28
30
  import { readHostObservations } from '../src/home/lib/host-observations.mjs';
31
+ import { activeHostVersions } from './active-hosts.mjs';
29
32
  import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
30
33
  import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
31
34
 
@@ -83,8 +86,11 @@ export async function diagnose({
83
86
  currentPackage,
84
87
  contractRecord,
85
88
  handbackWitnessRecord,
89
+ dispatcherModelRecord,
86
90
  dispatcherContractRecord,
87
91
  hostVersion,
92
+ activeHosts: injectedActiveHosts,
93
+ now,
88
94
  observations,
89
95
  } = {}) {
90
96
  const checks = [sourceCheck()];
@@ -147,6 +153,10 @@ export async function diagnose({
147
153
  const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
148
154
  const observedVersions = sessionHostVersions(hostObservations);
149
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 }));
150
160
  checks.push(sessionHostCheck(observedVersions, hostObservations));
151
161
  const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
152
162
  const hostContract = contractStatus({
@@ -158,11 +168,16 @@ export async function diagnose({
158
168
  : hostContract.state === 'ignored' ? 'fail' : 'warn';
159
169
  checks.push(check('hostContract', hostContractStatus, hostContract.message));
160
170
  const witness = handbackWitnessStatus({
161
- record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
162
- hostVersion: detectedHostVersion,
171
+ record: witnessRecord,
172
+ activeHosts,
163
173
  stateDir,
164
174
  });
165
- checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
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,
180
+ }));
166
181
  const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
167
182
  checks.push(...dispatcherContractChecks({
168
183
  record: dispatcherRecord,
@@ -190,7 +205,7 @@ export async function diagnose({
190
205
  exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
191
206
  || hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
192
207
  || projectRuns.status === 'fail'
193
- || checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
208
+ || checks.some((item) => /^(hook|dispatcherContract|dispatcherModel):/.test(item.key) && item.status === 'fail')
194
209
  || checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
195
210
  checks,
196
211
  record,
@@ -1,31 +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
+ import { entryState } from '../src/home/lib/observation-ledger.mjs';
4
5
 
5
- export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
6
+ export function handbackWitnessStatus({ record, activeHosts, stateDir }) {
6
7
  if (record?.corrupt) {
7
8
  return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
8
9
  }
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]}.` };
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.`;
22
20
  }
23
- const [newest, iso] = Object.entries(record.lastSeen).sort((left, right) => right[1].localeCompare(left[1]))[0];
24
- if (newest) {
21
+ const violations = entries.filter((entry) => entryState(entry) === 'violation'
22
+ && activeHosts.includes(entry.lastObservation.data.hostVersion));
23
+ if (violations.length > 0) {
24
+ const details = violations.map((entry) => {
25
+ const { data, detail, at } = entry.lastViolation;
26
+ return `host ${data.hostVersion}: ${data.cause}${data.agentType ? ` / ${data.agentType}` : ''}: ${detail} at ${at}`;
27
+ });
25
28
  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.`,
29
+ state: 'violation',
30
+ message: `Dispatcher violation(s): ${details.join('; ')}; do not trust a dispatcher answer from that time; see Plan_62 D15.${history}`,
28
31
  };
29
32
  }
30
- return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
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 };
31
41
  }
package/cli/hosts.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
5
+ import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
6
+ import { claudePaths } from '../src/home/lib/claude-layout.mjs';
6
7
 
7
8
  function repositoryRoot(start) {
8
9
  let current = path.resolve(start);
@@ -14,15 +15,6 @@ function repositoryRoot(start) {
14
15
  }
15
16
  }
16
17
 
17
- function claudePaths(root) {
18
- return {
19
- agentsDir: path.join(root, 'agents', 'codex-bridge'),
20
- commandsDir: path.join(root, 'commands', 'codex-bridge'),
21
- legacyAgentsDir: path.join(root, 'agents', 'codex'),
22
- legacyCommandsDir: path.join(root, 'commands', 'codex'),
23
- };
24
- }
25
-
26
18
  export function resolveHost({
27
19
  scope = 'user',
28
20
  host,
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { createHash } from 'node:crypto';
13
13
  import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
14
+ import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
14
15
  import {
15
16
  fileEntry,
16
17
  INSTALL_METHOD_COPY,
@@ -20,10 +21,6 @@ import {
20
21
 
21
22
  const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
22
23
 
23
- export function isFormat2(parsed) {
24
- return isObject(parsed) && parsed.format === 2;
25
- }
26
-
27
24
  export function imageFingerprint(image) {
28
25
  const brand = image.fingerprints?.brand;
29
26
  if (brand === undefined) return null;
@@ -4,7 +4,8 @@ import { randomUUID } from 'node:crypto';
4
4
  import { createHomeWriter } from '../src/home/lib/home-write.mjs';
5
5
  import { readJsonFile } from '../src/home/lib/json-file.mjs';
6
6
  import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
7
- import { isFormat2, ownerView, validateFormat2, withOwner, withoutOwner } from './install-owners.mjs';
7
+ import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
8
+ import { ownerView, validateFormat2, withOwner, withoutOwner } from './install-owners.mjs';
8
9
 
9
10
  /**
10
11
  * The record's own spelling of the field saying how the package was installed, and its one value.
@@ -306,6 +307,11 @@ async function publishInstallRecord(host, next) {
306
307
  await writer.rename('install-record', temporary, target);
307
308
  }
308
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
+
309
315
  /**
310
316
  * Publishes the record whole through a temporary and a rename: a direct write left a half-written
311
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,9 +3,10 @@
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
- import { isFormat2 } from './install-owners.mjs';
9
+ import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
9
10
  import { readInstallRecordFile } from './install-record.mjs';
10
11
  import { normalizedRulesOwner, readRulesRegistry } from './rules-owners.mjs';
11
12
 
@@ -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/cli/manifest.mjs CHANGED
@@ -4,14 +4,13 @@ import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
7
- import {
8
- renderRequiredInputSummary,
9
- renderRequiredInputs,
10
- } from '../src/home/lib/required-inputs.mjs';
7
+ import { renderRequiredInputSummary } from '../src/home/lib/dispatcher-call.mjs';
11
8
  import { renderNoSelfExecution } from '../src/home/lib/no-self-execution.mjs';
9
+ import { renderDispatcherProtocol } from '../src/home/lib/dispatcher-protocol.mjs';
10
+ import { ORDER_AGENTS } from '../src/home/lib/order-schema.mjs';
12
11
  import { renderStopSummary } from '../src/home/lib/stop-contract.mjs';
13
12
  import { readJsonFile } from '../src/home/lib/json-file.mjs';
14
- import { normalizeFrontmatter } from './frontmatter.mjs';
13
+ import { normalizeFrontmatter } from '../src/home/lib/frontmatter.mjs';
15
14
  import {
16
15
  INSTALL_RECORD_NAME,
17
16
  RULES_NAME,
@@ -77,7 +76,8 @@ export function replacePlaceholders(content, installationRoot) {
77
76
  const replaced = content
78
77
  .replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(installationRoot)))
79
78
  .replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
80
- .replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
79
+ .replaceAll('{{CODEX_DISPATCHER_PROTOCOL}}', ORDER_AGENTS.includes(agentType)
80
+ ? renderDispatcherProtocol(agentType) : '{{CODEX_DISPATCHER_PROTOCOL}}')
81
81
  .replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
82
82
  .replaceAll('{{CODEX_STOP_SUMMARY}}', renderStopSummary());
83
83
  return normalizeFrontmatter(replaced);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.10",
4
- "description": "Claude Code-to-Codex delegation with 7 guards, 2848 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",