@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
@@ -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 './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
@@ -4,9 +4,11 @@ import { fileURLToPath } from 'node:url';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { packageSource } from './package-source.mjs';
6
6
  import { readInstallRecord, packageInfo } from './manifest.mjs';
7
- import { recordTarget } from './install-record.mjs';
7
+ import { readInstallRecordFile, recordTarget } from './install-record.mjs';
8
8
  import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
9
9
  import { check, renderDoctor } from './doctor-format.mjs';
10
+ import { homeOwnersCheck, hostSideCheck, ownerEntry, ownersInSyncCheck } from './doctor-host-side.mjs';
11
+ import { inspectHost } from './host-inspection.mjs';
10
12
  import {
11
13
  agentsCheck,
12
14
  conventionsCheck,
@@ -16,13 +18,15 @@ import {
16
18
  rulesCheck,
17
19
  } from './doctor-installation.mjs';
18
20
  import { hookChecks } from './doctor-hooks.mjs';
19
- import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
21
+ import { contractStatus, readHostContract } from './host-contract.mjs';
20
22
  import { handbackWitnessStatus } from './handback-witness-check.mjs';
21
23
  import { dispatcherContractStatus } from './dispatcher-contract.mjs';
22
24
  import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
23
25
  import { PROBE_COMMAND } from './host-contract.mjs';
24
26
  import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
25
27
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
28
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
29
+ import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
26
30
  import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
27
31
 
28
32
  export { renderDoctor };
@@ -81,6 +85,7 @@ export async function diagnose({
81
85
  handbackWitnessRecord,
82
86
  dispatcherContractRecord,
83
87
  hostVersion,
88
+ observations,
84
89
  } = {}) {
85
90
  const checks = [sourceCheck()];
86
91
  const hostExists = await exists(host.root);
@@ -105,6 +110,20 @@ export async function diagnose({
105
110
  checks.push(check('installation', 'fail', `broken record: ${err.message}`));
106
111
  }
107
112
 
113
+ let rawRecord = null;
114
+ let rawRecordError = null;
115
+ try {
116
+ rawRecord = await readInstallRecordFile(host);
117
+ } catch (err) {
118
+ rawRecordError = err;
119
+ }
120
+ // The owner row goes into the inspection so "kept by uninstall" names exactly what uninstall would keep.
121
+ const inspection = await inspectHost(host, { owner: ownerEntry(rawRecord, host) });
122
+ checks.push(hostSideCheck(host, inspection));
123
+ checks.push(rawRecordError
124
+ ? check('home owners', 'fail', `broken record: ${rawRecordError.message}`)
125
+ : await homeOwnersCheck(host, rawRecord, inspection));
126
+ if (rawRecordError === null) checks.push(ownersInSyncCheck(rawRecord));
108
127
  const missingFiles = [];
109
128
  if (record) {
110
129
  for (const file of record.files) {
@@ -124,27 +143,35 @@ export async function diagnose({
124
143
  const bridge = bridgeProbe();
125
144
  checks.push(bridgeCommandCheck(bridge));
126
145
  checks.push(...await hookChecks(host, record, launcherProbe));
127
- const detectedHostVersion = hostVersion === undefined ? detectHostVersion() : hostVersion;
146
+ const stateDir = brandStateDir(host.brandRoot);
147
+ const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
148
+ const observedVersions = sessionHostVersions(hostObservations);
149
+ const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
150
+ checks.push(sessionHostCheck(observedVersions, hostObservations));
151
+ const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
128
152
  const hostContract = contractStatus({
129
- record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
153
+ record: refusalRecord,
130
154
  version: detectedHostVersion,
131
155
  });
132
156
  const hostContractStatus = hostContract.state === 'verified'
133
157
  ? 'ok'
134
158
  : hostContract.state === 'ignored' ? 'fail' : 'warn';
135
159
  checks.push(check('hostContract', hostContractStatus, hostContract.message));
136
- const stateDir = brandStateDir(host.brandRoot);
137
160
  const witness = handbackWitnessStatus({
138
161
  record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
139
162
  hostVersion: detectedHostVersion,
140
163
  stateDir,
141
164
  });
142
165
  checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
166
+ const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
143
167
  checks.push(...dispatcherContractChecks({
144
- record: dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord,
168
+ record: dispatcherRecord,
145
169
  version: detectedHostVersion,
146
170
  stateDir,
147
171
  }));
172
+ for (const version of observedVersions.filter((item) => item !== detectedHostVersion)) {
173
+ checks.push(otherHostCheck({ version, contractRecord: refusalRecord, dispatcherRecord }));
174
+ }
148
175
  const retention = retentionCheck(host);
149
176
  checks.push(retention);
150
177
  const conventions = await conventionsCheck(host);
@@ -163,7 +190,8 @@ export async function diagnose({
163
190
  exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
164
191
  || hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
165
192
  || projectRuns.status === 'fail'
166
- || checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail') ? 1 : 0,
193
+ || checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
194
+ || checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
167
195
  checks,
168
196
  record,
169
197
  missingFiles,
@@ -1,13 +1,7 @@
1
- /** Surfaces handback drift because the 2026-09-17..24 contract break went unnoticed for a week. */
1
+ /** Surfaces handback drift; 2026-09-25 established exact host transcript versions as identity. */
2
2
  import path from 'node:path';
3
3
  import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
4
4
 
5
- function versionParts(value) {
6
- if (typeof value !== 'string') return null;
7
- const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:\s|$)/);
8
- return match ? { version: match[0].trim(), patch: match[3] } : null;
9
- }
10
-
11
5
  export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
12
6
  if (record?.corrupt) {
13
7
  return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
@@ -16,24 +10,22 @@ export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
16
10
  const alarm = record.alarms.at(-1);
17
11
  return {
18
12
  state: 'alarm',
19
- message: `${record.alarms.length} dispatcher alarm(s); newest: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
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.`,
20
14
  };
21
15
  }
22
- if (!record?.lastSeen) {
16
+ if (!record?.lastSeen || Object.keys(record.lastSeen).length === 0) {
23
17
  return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
24
18
  }
25
19
 
26
- const seen = versionParts(record.lastSeen.sdkVersion);
27
- const host = versionParts(hostVersion);
28
- // Plan_62 D9 pairs observed SDK 0.3.281 inside Claude Code 2.1.281 hooks; matching patches are current.
29
- if (seen && host && seen.patch !== host.patch) {
20
+ if (record.lastSeen[hostVersion]) {
21
+ return { state: 'seen', message: `Handback contract last seen on host ${hostVersion} at ${record.lastSeen[hostVersion]}.` };
22
+ }
23
+ const [newest, iso] = Object.entries(record.lastSeen).sort((left, right) => right[1].localeCompare(left[1]))[0];
24
+ if (newest) {
30
25
  return {
31
26
  state: 'stale',
32
- message: `Last seen on SDK ${record.lastSeen.sdkVersion} at ${record.lastSeen.at}; current host ${hostVersion}; run any dispatcher once in an interactive session to observe it again.`,
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.`,
33
28
  };
34
29
  }
35
- return {
36
- state: 'seen',
37
- message: `Handback contract last seen on SDK ${record.lastSeen.sdkVersion} (host ${hostVersion}) at ${record.lastSeen.at}.`,
38
- };
30
+ return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
39
31
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Lists what lies in the package home, classified by the home registry, without crossing a link.
3
+ *
4
+ * Plan_65 D4 trusts the root as written: a relocated home or CODEX_BRIDGE_HOME may legitimately
5
+ * reach it through a link. Below that root, links and junctions stay and are named, never read
6
+ * through or removed. Walking ancestors first prevents even an lstat below a discovered link.
7
+ * Only ENOENT means gone; every other read failure must stay visible to a later removal planner.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import {
12
+ HOME_ARTIFACTS,
13
+ HOME_DIRECTORIES,
14
+ classifyHomePath,
15
+ homeArtifact,
16
+ } from '../src/home/lib/home-registry.mjs';
17
+
18
+ function knownDirectories(imageMembers) {
19
+ const directories = new Set(HOME_DIRECTORIES);
20
+ const addAncestors = (relative) => {
21
+ let parent = path.posix.dirname(relative);
22
+ while (parent !== '.' && parent !== '/') {
23
+ directories.add(parent);
24
+ parent = path.posix.dirname(parent);
25
+ }
26
+ };
27
+ for (const artifact of HOME_ARTIFACTS) {
28
+ if (Array.isArray(artifact.primary)) {
29
+ for (const relative of artifact.primary) addAncestors(relative);
30
+ } else {
31
+ directories.add(artifact.primary.dir);
32
+ addAncestors(artifact.primary.dir);
33
+ }
34
+ }
35
+ for (const relative of imageMembers) addAncestors(relative);
36
+ return directories;
37
+ }
38
+
39
+ const byRelative = (left, right) => (
40
+ left.relative < right.relative ? -1 : left.relative > right.relative ? 1 : 0
41
+ );
42
+
43
+ export function inspectHome(brandRoot, {
44
+ imageMembers = [],
45
+ lstat = fs.lstatSync,
46
+ readdir = fs.readdirSync,
47
+ } = {}) {
48
+ const result = {
49
+ root: 'present',
50
+ rootCode: null,
51
+ files: [],
52
+ unknown: [],
53
+ links: [],
54
+ errors: [],
55
+ directories: [],
56
+ };
57
+ try {
58
+ // D4 deliberately follows the root itself, unlike every entry below it.
59
+ if (!fs.statSync(brandRoot).isDirectory()) {
60
+ result.root = 'error';
61
+ result.rootCode = 'ENOTDIR';
62
+ return result;
63
+ }
64
+ } catch (error) {
65
+ result.root = error.code === 'ENOENT' ? 'missing' : 'error';
66
+ result.rootCode = error.code === 'ENOENT' ? null : error.code;
67
+ return result;
68
+ }
69
+
70
+ const known = knownDirectories(imageMembers);
71
+ const recordError = (relative, error) => {
72
+ if (error.code !== 'ENOENT') result.errors.push({ relative, code: error.code });
73
+ };
74
+ const walk = (relative) => {
75
+ const absolute = relative ? path.join(brandRoot, ...relative.split('/')) : brandRoot;
76
+ let names;
77
+ try {
78
+ names = readdir(absolute);
79
+ } catch (error) {
80
+ recordError(relative, error);
81
+ return error.code !== 'ENOENT';
82
+ }
83
+ for (const name of names) {
84
+ const child = relative ? `${relative}/${name}` : name;
85
+ let stats;
86
+ try {
87
+ stats = lstat(path.join(absolute, name));
88
+ } catch (error) {
89
+ recordError(child, error);
90
+ continue;
91
+ }
92
+ if (stats.isSymbolicLink()) {
93
+ result.links.push({ relative: child });
94
+ } else if (stats.isFile()) {
95
+ const match = classifyHomePath(child, { imageMembers });
96
+ if (match) {
97
+ result.files.push({ relative: child, ...match, removal: homeArtifact(match.id).removal });
98
+ } else {
99
+ result.unknown.push({ relative: child, kind: 'file' });
100
+ }
101
+ } else if (stats.isDirectory()) {
102
+ // An undeclared folder is unknown as a whole: its contents cannot be ours, and an
103
+ // operator's large folder (a backup, a checkout) must not be walked to say so.
104
+ if (known.has(child)) {
105
+ if (walk(child)) result.directories.push({ relative: child });
106
+ } else {
107
+ result.unknown.push({ relative: child, kind: 'directory' });
108
+ }
109
+ } else {
110
+ result.unknown.push({ relative: child, kind: 'other' });
111
+ }
112
+ }
113
+ return true;
114
+ };
115
+ walk('');
116
+ for (const list of [result.files, result.unknown, result.links, result.errors]) list.sort(byRelative);
117
+ // Deepest first: the order in which a later empty-folder removal can take them down.
118
+ result.directories.sort((left, right) => (
119
+ right.relative.split('/').length - left.relative.split('/').length || byRelative(left, right)
120
+ ));
121
+ return result;
122
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Carries out a home removal plan and records what happened to every entry.
3
+ *
4
+ * Plan_65 D12 item 7 requires the final message to describe outcomes: claiming removal from
5
+ * intentions hid edited files that uninstall actually kept. D4 forbids following links and
6
+ * recursive removal; segment checks run immediately before each planned removal.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
11
+ import { directoryArtifact } from '../src/home/lib/home-registry.mjs';
12
+ import { inspectSegments } from './link-segments.mjs';
13
+ import { installRecordPath, removeInstallOwner } from './install-record.mjs';
14
+ import { removeEmptyHome } from './remove-layout.mjs';
15
+ import { consumePurgeAuthorization } from './purge-preflight.mjs';
16
+
17
+ function findingReason(finding, root) {
18
+ if (finding.kind === 'link') {
19
+ const relative = path.relative(root, finding.at).split(path.sep).join('/');
20
+ return `link at ${relative}`;
21
+ }
22
+ if (finding.kind === 'error') return `unreadable: ${finding.code}`;
23
+ return finding.kind;
24
+ }
25
+
26
+ const failure = (error) => error.code ?? error.message;
27
+
28
+ async function executeFiles(host, plan, writer, add) {
29
+ for (const row of plan.rows) {
30
+ if (row.action === 'keep' || row.action === 'blocked' || row.action === 'missing') {
31
+ add(row.relative, row.action === 'keep' ? 'kept' : row.action, row.reason, row.id);
32
+ continue;
33
+ }
34
+ if (row.action !== 'remove') throw new TypeError(`Invalid home file action: ${row.action}`);
35
+ const target = path.join(host.brandRoot, row.relative);
36
+ const finding = inspectSegments(target, host.brandRoot);
37
+ if (finding.kind !== 'clear') {
38
+ add(row.relative, finding.kind === 'missing' ? 'missing' : 'kept',
39
+ findingReason(finding, host.brandRoot), row.id);
40
+ continue;
41
+ }
42
+ try {
43
+ await writer.unlink(row.id, target);
44
+ add(row.relative, 'removed', row.reason, row.id);
45
+ } catch (error) {
46
+ // One failed file does not stop the rest: each is reported, and a repeat run retries it.
47
+ add(row.relative, error.code === 'ENOENT' ? 'missing' : 'failed', failure(error), row.id);
48
+ }
49
+ }
50
+ }
51
+
52
+ // After the files, even when one failed: the record is the inventory a repeat run needs, and
53
+ // it changes only for a detached host (cli/uninstall.mjs, D10 item 4).
54
+ async function executeRecord(host, record, writer, detached, add) {
55
+ const recordRelative = '.installed.json';
56
+ if (record.dependsOnDetach && !detached) {
57
+ add(recordRelative, 'kept', 'this host is still attached');
58
+ return;
59
+ }
60
+ if (record.operation === 'retain' || record.operation === 'none') {
61
+ add(recordRelative, record.operation === 'retain' ? 'kept' : 'missing', record.reason);
62
+ return;
63
+ }
64
+ if (record.operation !== 'delete' && record.operation !== 'remove-current-owner') {
65
+ throw new TypeError(`Invalid home record operation: ${record.operation}`);
66
+ }
67
+ const target = installRecordPath(host);
68
+ // D4 also guards the owner update, which reads the existing record before publishing it.
69
+ const finding = inspectSegments(target, host.brandRoot);
70
+ if (finding.kind !== 'clear') {
71
+ add(recordRelative, finding.kind === 'missing' ? 'missing' : 'kept', findingReason(finding, host.brandRoot));
72
+ return;
73
+ }
74
+ try {
75
+ if (record.operation === 'delete') {
76
+ await writer.unlink('install-record', target);
77
+ add(recordRelative, 'removed', record.reason);
78
+ return;
79
+ }
80
+ const updated = await removeInstallOwner(host);
81
+ add(recordRelative, updated === null ? 'missing' : 'kept',
82
+ updated === null ? 'missing' : 'this host removed from the record');
83
+ } catch (error) {
84
+ add(recordRelative, error.code === 'ENOENT' ? 'missing' : 'failed', failure(error));
85
+ }
86
+ }
87
+
88
+ async function executeDirectories(host, directories, writer, imageMembers, add) {
89
+ for (const directory of directories) {
90
+ if (directory.action === 'keep') continue;
91
+ if (directory.action !== 'remove-if-empty') {
92
+ throw new TypeError(`Invalid home directory action: ${directory.action}`);
93
+ }
94
+ const target = path.join(host.brandRoot, directory.relative);
95
+ if (directory.relative !== '') {
96
+ const finding = inspectSegments(target, host.brandRoot);
97
+ if (finding.kind === 'missing') continue;
98
+ if (finding.kind !== 'clear') {
99
+ add(directory.relative, 'kept', findingReason(finding, host.brandRoot));
100
+ continue;
101
+ }
102
+ }
103
+ try {
104
+ if ((await fs.readdir(target)).length !== 0) continue;
105
+ if (directory.relative === '') {
106
+ await removeEmptyHome(writer, 'install-image', host.brandRoot);
107
+ // The helper tolerates absence and a newly non-empty root; only absence proves removal.
108
+ try {
109
+ await fs.lstat(target);
110
+ } catch (error) {
111
+ if (error.code !== 'ENOENT') throw error;
112
+ add('', 'removed', directory.reason);
113
+ }
114
+ } else {
115
+ await writer.rmdir(directoryArtifact(directory.relative, { imageMembers }), target);
116
+ add(directory.relative, 'removed', directory.reason);
117
+ }
118
+ } catch (error) {
119
+ if (error.code !== 'ENOENT') add(directory.relative, 'failed', failure(error));
120
+ }
121
+ }
122
+ }
123
+
124
+ export async function executeHomePlan(host, plan, { detached, imageMembers, authorization }) {
125
+ if (plan.command === 'purge') {
126
+ consumePurgeAuthorization(authorization, host, plan);
127
+ } else if (plan.command !== 'uninstall') {
128
+ throw new TypeError(`Invalid removal command: ${plan.command}`);
129
+ }
130
+
131
+ const writer = createHomeWriter({ root: host.brandRoot, imageMembers });
132
+ const outcomes = [];
133
+ // Each step tags its own outcomes: the renderer must not infer a folder from its position.
134
+ const adder = (kind, fixedId) => (relative, result, reason, id = fixedId) => (
135
+ outcomes.push({ lane: 'home', kind, id, relative, result, reason }));
136
+ await executeFiles(host, plan, writer, adder('file', null));
137
+ await executeRecord(host, plan.record, writer, detached, adder('record', 'install-record'));
138
+ await executeDirectories(host, plan.directories, writer, imageMembers, adder('directory', null));
139
+ return { outcomes };
140
+ }