@lyupro/codex-bridge 0.6.7 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/bin/codex-bridge.mjs +35 -172
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +21 -11
  8. package/cli/dispatcher-contract.mjs +6 -5
  9. package/cli/doctor-host-side.mjs +110 -0
  10. package/cli/doctor-installation.mjs +111 -111
  11. package/cli/doctor.mjs +35 -7
  12. package/cli/handback-witness-check.mjs +10 -18
  13. package/cli/home-inspection.mjs +122 -0
  14. package/cli/home-plan-execute.mjs +140 -0
  15. package/cli/home-removal-plan.mjs +61 -0
  16. package/cli/hook-recognizer.mjs +78 -0
  17. package/cli/host-contract.mjs +36 -21
  18. package/cli/host-inspection.mjs +156 -0
  19. package/cli/host-removal.mjs +91 -0
  20. package/cli/image-evidence.mjs +44 -0
  21. package/cli/install-owners.mjs +244 -0
  22. package/cli/install-record.mjs +71 -9
  23. package/cli/install.mjs +101 -77
  24. package/cli/inventory-removal.mjs +84 -0
  25. package/cli/inventory-transition.mjs +66 -0
  26. package/cli/lifecycle-lock-clear.mjs +104 -0
  27. package/cli/lifecycle-lock-inspect.mjs +91 -0
  28. package/cli/lifecycle-lock.mjs +335 -0
  29. package/cli/lifecycle-transaction.mjs +57 -0
  30. package/cli/link-segments.mjs +45 -0
  31. package/cli/model-set.mjs +4 -3
  32. package/cli/model-speed.mjs +9 -2
  33. package/cli/model.mjs +5 -3
  34. package/cli/owner-record.mjs +40 -0
  35. package/cli/owner-sync-apply.mjs +108 -0
  36. package/cli/owner-sync.mjs +115 -0
  37. package/cli/probe-contract.mjs +26 -10
  38. package/cli/probe-target.mjs +33 -0
  39. package/cli/projects.mjs +5 -2
  40. package/cli/prune-args.mjs +9 -4
  41. package/cli/prune.mjs +23 -72
  42. package/cli/purge-live-runs.mjs +98 -0
  43. package/cli/purge-preflight.mjs +103 -0
  44. package/cli/read.mjs +2 -1
  45. package/cli/record-removal.mjs +43 -0
  46. package/cli/removal-outcomes.mjs +115 -0
  47. package/cli/removal-plan.mjs +187 -0
  48. package/cli/remove-layout.mjs +40 -1
  49. package/cli/rules-owners.mjs +4 -2
  50. package/cli/session-hosts.mjs +51 -0
  51. package/cli/terminal-question.mjs +41 -0
  52. package/cli/uninstall-purge.mjs +139 -0
  53. package/cli/uninstall-rules.mjs +72 -0
  54. package/cli/uninstall.mjs +208 -126
  55. package/cli/unlock-lifecycle.mjs +102 -0
  56. package/cli/unlock.mjs +7 -4
  57. package/cli/update.mjs +102 -27
  58. package/package.json +4 -3
  59. package/scripts/home-write-audit.mjs +286 -0
  60. package/scripts/home-write-inventory.mjs +49 -0
  61. package/scripts/home-write-lexer.mjs +182 -0
  62. package/scripts/isolated-roots.mjs +6 -1
  63. package/scripts/open-work/check.mjs +180 -0
  64. package/scripts/open-work/live-thresholds.mjs +155 -0
  65. package/scripts/open-work/register.mjs +56 -0
  66. package/scripts/root-guard/root-entries.mjs +15 -0
  67. package/scripts/run-tests.mjs +33 -1
  68. package/src/agents/codex-advisor.md +5 -2
  69. package/src/home/conventions.md +9 -0
  70. package/src/home/hooks/dispatcher-gate.mjs +15 -2
  71. package/src/home/hooks/guard-tries.mjs +6 -4
  72. package/src/home/hooks/order-gate.mjs +2 -8
  73. package/src/home/hooks/reply-guard.mjs +24 -9
  74. package/src/home/hooks/worktree-witness.mjs +6 -6
  75. package/src/home/lib/agents.mjs +1 -1
  76. package/src/home/lib/atomic-json.mjs +24 -6
  77. package/src/home/lib/config-edit.mjs +25 -10
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +33 -25
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +60 -0
  85. package/src/home/lib/host-version.mjs +27 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/events.mjs +2 -1
  89. package/src/home/lib/meta/paths.mjs +24 -24
  90. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  91. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  92. package/src/home/lib/meta/verdict.mjs +14 -16
  93. package/src/home/lib/run-config.mjs +10 -1
  94. package/src/home/lib/runner/git-paths.mjs +74 -0
  95. package/src/home/lib/runner/git-state.mjs +38 -34
  96. package/src/home/lib/runner/launcher.mjs +2 -2
  97. package/src/home/lib/runner/preflight.mjs +7 -0
  98. package/src/home/lib/runner/prompts.mjs +35 -2
  99. package/src/home/lib/runner/schemas.mjs +4 -1
  100. package/src/home/lib/runner/scope-check.mjs +29 -20
  101. package/src/home/lib/runner/worker.mjs +3 -3
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Decides, from gathered facts, what happens to every file of the package home.
3
+ *
4
+ * Plan_65 D12 uses one plan for uninstall and purge: decisions precede any deletion,
5
+ * so edited images and entries the inspection could not read stay visible to the caller.
6
+ * The record has its own operation because removing one owner can preserve the shared record.
7
+ */
8
+ import {
9
+ HOME_ARTIFACTS,
10
+ HOME_DIRECTORIES,
11
+ classifyHomePath,
12
+ homeArtifact,
13
+ } from '../src/home/lib/home-registry.mjs';
14
+
15
+ const byRelative = (left, right) => (
16
+ left.relative < right.relative ? -1 : left.relative > right.relative ? 1 : 0
17
+ );
18
+
19
+ // The record changes only after the host is detached (cli/uninstall.mjs, hostSide.detached), so
20
+ // every operation that changes it carries that dependency and nothing else does.
21
+ function recordOutcome(operation, reason, blocked = false) {
22
+ const dependsOnDetach = operation === 'delete' || operation === 'remove-current-owner';
23
+ return { operation, reason, dependsOnDetach, blocked };
24
+ }
25
+
26
+ // The command that asked for the plan. Not "mode": that word already meant six things in this
27
+ // package, and a guard (tests/mode-is-not-a-name.test.mjs) keeps it from meaning a seventh.
28
+ function assertCommand(command) {
29
+ if (command !== 'uninstall' && command !== 'purge') throw new TypeError(`Invalid removal command: ${command}`);
30
+ }
31
+
32
+ function metadata(relative, imageMembers) {
33
+ const match = classifyHomePath(relative, { imageMembers });
34
+ return match
35
+ ? { ...match, removal: homeArtifact(match.id).removal }
36
+ : { id: null, role: null, removal: null };
37
+ }
38
+
39
+ function fileDecision(file, command, imageEvidence, imagePolicy) {
40
+ if (file.id === 'install-image') {
41
+ if (!imagePolicy.remove) return { action: 'keep', reason: imagePolicy.reason };
42
+ if (file.role !== 'primary') return { action: 'remove', reason: imagePolicy.reason };
43
+ const evidence = imageEvidence.get(file.relative);
44
+ if (!evidence) throw new TypeError(`Missing image evidence for ${file.relative}`);
45
+ if (!['remove', 'keep', 'missing'].includes(evidence.verdict)) {
46
+ throw new TypeError(`Invalid image evidence for ${file.relative}`);
47
+ }
48
+ return {
49
+ action: evidence.verdict,
50
+ reason: evidence.verdict === 'remove' ? `evidence: ${evidence.reason}` : evidence.reason,
51
+ };
52
+ }
53
+ if (file.id === 'install-record') {
54
+ // D12 item 2 reserves the record operation for B16c; D8/D11 keep its lifecycle sides.
55
+ if (file.role === 'primary' || file.role === 'atomic-temporary') return null;
56
+ if (file.role === 'lock') return { action: 'keep', reason: 'lifecycle lock' };
57
+ if (file.role === 'clear-gate') return { action: 'keep', reason: 'clear queue' };
58
+ }
59
+ if (file.removal === 'purge-only') {
60
+ return command === 'purge'
61
+ ? { action: 'remove', reason: 'purge' }
62
+ : { action: 'keep', reason: 'purge-only' };
63
+ }
64
+ if (file.removal === 'protected') return { action: 'keep', reason: 'protected' };
65
+ throw new TypeError(`Unsupported home file classification for ${file.relative}`);
66
+ }
67
+
68
+ function ancestorDecision(relative, inspection) {
69
+ const isAncestor = (entry) => entry.relative === '' || relative.startsWith(`${entry.relative}/`);
70
+ // D12 item 1: a path hidden by an ancestor is uninspected, not absent.
71
+ for (const [entries, reason] of [
72
+ [inspection.links, 'link at'],
73
+ [inspection.errors, 'unreadable ancestor'],
74
+ [inspection.unknown, 'unknown ancestor'],
75
+ ]) {
76
+ const ancestor = entries.find(isAncestor);
77
+ if (ancestor) return { action: 'blocked', reason: `${reason} ${ancestor.relative}` };
78
+ }
79
+ return { action: 'missing', reason: 'missing' };
80
+ }
81
+
82
+ export function planHomeFiles({ command, inspection, imageMembers, imageEvidence, imagePolicy }) {
83
+ assertCommand(command);
84
+ if (inspection.root === 'missing') return { rows: [], blocked: false };
85
+ if (inspection.root === 'error') return { rows: [], blocked: true };
86
+ if (inspection.root !== 'present') throw new TypeError(`Invalid home root state: ${inspection.root}`);
87
+
88
+ const rowsByPath = new Map();
89
+ const add = (relative, classification, decision) => {
90
+ if (!rowsByPath.has(relative)) rowsByPath.set(relative, { relative, ...classification, ...decision });
91
+ };
92
+ for (const entry of inspection.links) {
93
+ add(entry.relative, metadata(entry.relative, imageMembers), { action: 'blocked', reason: 'link' });
94
+ }
95
+ for (const entry of inspection.errors) {
96
+ add(entry.relative, metadata(entry.relative, imageMembers), {
97
+ action: 'blocked', reason: `unreadable: ${entry.code}`,
98
+ });
99
+ }
100
+ for (const entry of inspection.unknown) {
101
+ add(entry.relative, { id: null, role: null, removal: null }, {
102
+ action: 'keep', reason: entry.kind === 'directory' ? 'unknown directory' : 'unknown',
103
+ });
104
+ }
105
+ for (const file of inspection.files) {
106
+ if (rowsByPath.has(file.relative)) continue;
107
+ const decision = fileDecision(file, command, imageEvidence, imagePolicy);
108
+ if (decision) add(file.relative, { id: file.id, role: file.role, removal: file.removal }, decision);
109
+ }
110
+
111
+ const expected = new Set(imageMembers);
112
+ for (const artifact of HOME_ARTIFACTS) {
113
+ if (artifact.id === 'install-image' || artifact.id === 'install-record') continue;
114
+ if (Array.isArray(artifact.primary)) {
115
+ for (const relative of artifact.primary) expected.add(relative);
116
+ }
117
+ }
118
+ const inspected = new Set([
119
+ ...inspection.files, ...inspection.links, ...inspection.errors,
120
+ ...inspection.unknown, ...inspection.directories,
121
+ ].map((entry) => entry.relative));
122
+ for (const relative of expected) {
123
+ if (!inspected.has(relative)) {
124
+ add(relative, metadata(relative, imageMembers), ancestorDecision(relative, inspection));
125
+ }
126
+ }
127
+ const rows = [...rowsByPath.values()].sort(byRelative);
128
+ return { rows, blocked: rows.some((row) => row.action === 'blocked') };
129
+ }
130
+
131
+ export function planRecordOperation({ command, recordState, format2, ownerKey, imagePolicy }) {
132
+ assertCommand(command);
133
+ if (!['missing', 'corrupt', 'valid'].includes(recordState)) {
134
+ throw new TypeError(`Invalid installation record state: ${recordState}`);
135
+ }
136
+ if (recordState === 'missing') {
137
+ return recordOutcome('none', 'no installation record');
138
+ }
139
+ // D12 item 4: an unreadable record blocks removal rather than becoming an absent record.
140
+ if (recordState === 'corrupt') {
141
+ return recordOutcome('retain', 'unreadable installation record', true);
142
+ }
143
+ if (command === 'purge' || imagePolicy.remove) {
144
+ return recordOutcome('delete', command === 'purge' ? 'purge' : 'last owner');
145
+ }
146
+ if (Object.keys(format2.owners).length === 0) {
147
+ return recordOutcome('retain', 'no host is recorded as using it');
148
+ }
149
+ if (Object.hasOwn(format2.owners, ownerKey)) {
150
+ return recordOutcome('remove-current-owner', imagePolicy.reason);
151
+ }
152
+ return recordOutcome('retain', 'not an owner of this home');
153
+ }
154
+
155
+ export function planHomeDirectories({ command, inspection, imageMembers, imagePolicy }) {
156
+ if (inspection.root !== 'present') return [];
157
+ const decide = (relative) => {
158
+ if (command === 'purge') return { relative, action: 'remove-if-empty', reason: 'purge' };
159
+ if (!imagePolicy.remove) return { relative, action: 'keep', reason: imagePolicy.reason };
160
+ const imageDirectory = relative === '' || (
161
+ !HOME_DIRECTORIES.includes(relative)
162
+ && imageMembers.some((member) => member.startsWith(`${relative}/`))
163
+ );
164
+ // D12 item 1: even image ancestors can only be removed once they are empty.
165
+ return imageDirectory
166
+ ? { relative, action: 'remove-if-empty', reason: 'image' }
167
+ : { relative, action: 'keep', reason: 'holds purge-only data' };
168
+ };
169
+ return [...inspection.directories.map((entry) => decide(entry.relative)), decide('')];
170
+ }
171
+
172
+ export function planHomeRemoval(facts) {
173
+ const files = planHomeFiles(facts);
174
+ const record = planRecordOperation(facts);
175
+ const directories = planHomeDirectories(facts);
176
+ const rowsByPath = new Map(files.rows.map((row) => [row.relative, row]));
177
+ for (const file of facts.inspection.files) {
178
+ if (file.id !== 'install-record' || file.role !== 'atomic-temporary' || rowsByPath.has(file.relative)) continue;
179
+ rowsByPath.set(file.relative, {
180
+ relative: file.relative, id: file.id, role: file.role, removal: file.removal,
181
+ action: record.operation === 'delete' ? 'remove' : 'keep',
182
+ reason: record.operation === 'delete' ? 'record deleted' : 'record in use',
183
+ });
184
+ }
185
+ const rows = [...rowsByPath.values()].sort(byRelative);
186
+ return { rows, directories, record, blocked: files.blocked || record.blocked };
187
+ }
@@ -1,6 +1,10 @@
1
- /** Removes emptied directories of an installed layout without walking out of the package's own. */
1
+ /**
2
+ * Removes emptied directories of an installed layout without walking out of the package's own.
3
+ * Home folders use the adapter and link check (Plan_65 D3, D4 item 4); host folders keep the raw route.
4
+ */
2
5
  import fs from 'node:fs/promises';
3
6
  import path from 'node:path';
7
+ import { inspectSegments } from './link-segments.mjs';
4
8
 
5
9
  /**
6
10
  * These four helpers existed as two copies — one in uninstall.mjs, one in update.mjs — and the
@@ -57,3 +61,38 @@ export function claudeBoundary(host, target) {
57
61
  export async function removeEmptyLayout(directory) {
58
62
  await removeEmpty(directory);
59
63
  }
64
+
65
+ // The segment check runs before every rmdir, not once per walk: on Windows rmdir on a junction
66
+ // removes the junction itself, and a folder above the target may be a link even when the target's
67
+ // own folder is not. A folder already gone keeps the walk going — its parent may still be empty.
68
+ export async function removeEmptyHomeParents(writer, id, target, homeRoot) {
69
+ const root = path.resolve(homeRoot);
70
+ let current = path.dirname(path.resolve(target));
71
+ for (;;) {
72
+ const relative = path.relative(root, current);
73
+ if (!relative || path.isAbsolute(relative) || relative === '..'
74
+ || relative.startsWith(`..${path.sep}`)) break;
75
+
76
+ const finding = inspectSegments(current, root);
77
+ if (finding.kind !== 'missing') {
78
+ if (finding.kind !== 'clear') return { kept: finding };
79
+ try {
80
+ if ((await fs.readdir(current)).length !== 0) break;
81
+ await writer.rmdir(id, current);
82
+ } catch (error) {
83
+ if (error.code !== 'ENOENT') throw error;
84
+ }
85
+ }
86
+ current = path.dirname(current);
87
+ }
88
+ return { kept: null };
89
+ }
90
+
91
+ export async function removeEmptyHome(writer, id, homeRoot) {
92
+ // Plan_65 D4 item 1 trusts the root as written, including a deliberately relocated home.
93
+ try {
94
+ if ((await fs.readdir(homeRoot)).length === 0) await writer.rmdir(id, homeRoot);
95
+ } catch (error) {
96
+ if (error.code !== 'ENOENT') throw error;
97
+ }
98
+ }
@@ -4,7 +4,8 @@ import { randomUUID } from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
6
6
  import { readJsonFile } from '../src/home/lib/json-file.mjs';
7
- import { withFileLock } from '../src/home/lib/file-lock.mjs';
7
+ import { withOutsideFileLock } from '../src/home/lib/file-lock.mjs';
8
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
8
9
  export { isLockTaken } from '../src/home/lib/file-lock.mjs';
9
10
 
10
11
  export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
@@ -76,7 +77,8 @@ function registryLockPath(host) {
76
77
  }
77
78
 
78
79
  async function withRegistryLock(host, action) {
79
- return withFileLock(registryLockPath(host), action, { description: 'rules ownership registry' });
80
+ const writer = createHomeWriter({ root: host.brandRoot });
81
+ return withOutsideFileLock(writer, registryLockPath(host), action, { description: 'rules ownership registry' });
80
82
  }
81
83
 
82
84
  export async function readRulesRegistry(host) {
@@ -0,0 +1,51 @@
1
+ /** Judges every observed session host after the 2026-09-25 PATH host identity incident. */
2
+ import { contractStatus, PROBE_COMMAND } from './host-contract.mjs';
3
+ import { DISPATCHER_CONTRACTS, dispatcherContractStatus } from './dispatcher-contract.mjs';
4
+
5
+ export function sessionHostVersions(observations) {
6
+ return Object.entries(observations?.hosts ?? {})
7
+ .map(([version, entry]) => ({ version, timestamp: Date.parse(entry?.lastSeen) }))
8
+ .filter(({ timestamp }) => Number.isFinite(timestamp))
9
+ .sort((a, b) => b.timestamp - a.timestamp)
10
+ .map(({ version }) => version);
11
+ }
12
+
13
+ export function sessionHostCheck(versions, observations) {
14
+ if (!versions.length) {
15
+ return {
16
+ key: 'sessionHost',
17
+ status: 'warn',
18
+ value: 'No Claude Code session has been observed by this installation yet; host contracts are judged once a session runs a shell command.',
19
+ };
20
+ }
21
+ const newest = versions[0];
22
+ const lastSeen = new Date(observations.hosts[newest].lastSeen).toISOString();
23
+ const others = versions.slice(1);
24
+ return {
25
+ key: 'sessionHost',
26
+ status: 'ok',
27
+ value: `Sessions run on host ${newest} (last seen ${lastSeen})${others.length ? `; also seen: ${others.join(', ')}` : ''}`,
28
+ };
29
+ }
30
+
31
+ export function otherHostCheck({ version, contractRecord, dispatcherRecord }) {
32
+ const refusal = contractStatus({ record: contractRecord, version });
33
+ const dispatchers = dispatcherContractStatus({ record: dispatcherRecord, version });
34
+ const key = `otherHost:${version}`;
35
+ if (refusal.state === 'ignored' || dispatchers.some(({ state }) => state === 'changed')) {
36
+ const changed = dispatchers.filter(({ state }) => state === 'changed').map(({ contract }) => contract);
37
+ return { key, status: 'fail', value: [refusal.state === 'ignored' ? 'refusal contract ignored' : '', ...changed].filter(Boolean).join('; ') };
38
+ }
39
+ if (refusal.state === 'verified' && dispatchers.every(({ state }) => state === 'verified')) {
40
+ return { key, status: 'ok', value: `Host ${version} refusal and dispatcher contracts are verified.` };
41
+ }
42
+ const missing = [];
43
+ if (refusal.state !== 'verified') missing.push('refusal');
44
+ missing.push(...dispatchers.filter(({ state }) => state !== 'verified').map(({ contract }) => contract));
45
+ const probe = `${PROBE_COMMAND} --probe-executable <path to a ${version} executable>`;
46
+ return {
47
+ key,
48
+ status: 'warn',
49
+ value: `Host ${version} contracts not yet measured: ${missing.join(', ')}; run ${probe}.`,
50
+ };
51
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one terminal yes/no question. Plan_65 D9 needs three outcomes kept apart, because each leads
3
+ * to a different action: 'yes', 'no' (a no, a bare Enter, or input that ended — EOF is no answer,
4
+ * never consent) and 'cancel' (Ctrl+C: the caller exits 130 and changes nothing). prune's private
5
+ * copy folded all of them into "no"; a second copy in install would have drifted from it.
6
+ */
7
+ import readline from 'node:readline/promises';
8
+
9
+ // One spelling only. A guard that answers to several option names is a guard with several ways
10
+ // to be switched off by accident.
11
+ export function isInteractive(options) {
12
+ if (options.isTTY !== undefined) return Boolean(options.isTTY);
13
+ return Boolean((options.stdin || process.stdin).isTTY);
14
+ }
15
+
16
+ function outcome(answer) {
17
+ if (answer === 'cancel') return 'cancel';
18
+ return answer === true || (typeof answer === 'string' && /^(y|yes)$/i.test(answer.trim()))
19
+ ? 'yes'
20
+ : 'no';
21
+ }
22
+
23
+ /** `options.prompt` is the test seam: it may answer a boolean, a string, or the string 'cancel'. */
24
+ export async function askYesNo(question, options) {
25
+ if (options.prompt !== undefined) return outcome(await options.prompt(question));
26
+
27
+ const input = options.stdin || process.stdin;
28
+ const output = options.promptOutput || process.stderr;
29
+ const interfaceHandle = readline.createInterface({ input, output });
30
+ const ended = new Promise((resolve) => {
31
+ interfaceHandle.once('SIGINT', () => resolve('cancel'));
32
+ interfaceHandle.once('close', () => resolve('no'));
33
+ });
34
+ // A closed interface rejects the pending question; the close itself already decided the answer.
35
+ const answered = interfaceHandle.question(`${question} [y/N] `).then(outcome, () => ended);
36
+ try {
37
+ return await Promise.race([answered, ended]);
38
+ } finally {
39
+ interfaceHandle.close();
40
+ }
41
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Uninstalls one host and purges the package home behind one preflight.
3
+ * Plan_65 D12 items 5-7 and D14 item 1 require both consents and authorization before any
4
+ * host mutation; D10 item 4 forbids removing the home until this host's hooks are gone.
5
+ */
6
+ import fs from 'node:fs';
7
+ import { uninstall, removePermissions, stillAttachedLine } from './uninstall.mjs';
8
+ import { legacyInstallRecordPath, readInstallRecord } from './manifest.mjs';
9
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
10
+ import { inspectHost } from './host-inspection.mjs';
11
+ import { removeHostSide } from './host-removal.mjs';
12
+ import { withSettingsRun } from './settings-merge.mjs';
13
+ import { withLifecycle } from './lifecycle-transaction.mjs';
14
+ import { readRulesRegistry } from './rules-owners.mjs';
15
+ import { rulesDryRunLines, removeRulesForHost } from './uninstall-rules.mjs';
16
+ import { removeEmpty, removeEmptyLayout } from './remove-layout.mjs';
17
+ import { recordHomeWriter, removeOutside } from './record-removal.mjs';
18
+ import { runPurgePreflight, diagnosePurge } from './purge-preflight.mjs';
19
+ import { executeHomePlan } from './home-plan-execute.mjs';
20
+ import { inspectHome } from './home-inspection.mjs';
21
+ import {
22
+ purgeDryRunLines, purgeDryRunExitCode, outcomeLines, outcomeExitCode,
23
+ } from './removal-outcomes.mjs';
24
+
25
+ const ACCOUNTED = new Set(['kept', 'blocked', 'failed']);
26
+
27
+ /**
28
+ * Plan_65 D12 item 7: a run started during purge (D14 item 3) or any other writer can recreate a registry
29
+ * file after its removal, and "Purged" would then be a false report. One more pass names every registry
30
+ * file present that the outcomes did not already name as kept. It does not defend against a deliberate
31
+ * swap of a folder between check and removal (D4 item 6); that limit is documented, not compensated.
32
+ */
33
+ export function appearedLines(host, plan, outcomes) {
34
+ const inspection = inspectHome(host.brandRoot, { imageMembers: plan.imageMembers });
35
+ if (inspection.root === 'missing') return [];
36
+ if (inspection.root === 'error') {
37
+ return [`Could not re-check ${host.brandRoot} after purge (${inspection.rootCode}).`];
38
+ }
39
+ const named = new Set(outcomes.filter((entry) => entry.kind !== 'directory' && ACCOUNTED.has(entry.result))
40
+ .map((entry) => entry.relative));
41
+ return inspection.files.filter((file) => !named.has(file.relative)).map((file) =>
42
+ `Appeared during purge: brand/${file.relative}; run uninstall --purge again once nothing writes to the home.`);
43
+ }
44
+
45
+ async function purgeInRun(options, ticket) {
46
+ const { host } = options;
47
+ let registry = null;
48
+ let registryError = null;
49
+ try {
50
+ registry = await readRulesRegistry(host);
51
+ } catch (err) {
52
+ registryError = err;
53
+ }
54
+ const preflight = await runPurgePreflight({
55
+ host, ticket, packageRoot: options.packageRoot,
56
+ ...(options.liveRunCheck !== undefined ? { liveRunCheck: options.liveRunCheck } : {}),
57
+ options: { ...options, candidates: options.candidates ?? registry?.owners ?? [] },
58
+ });
59
+ if (preflight.verdict === 'refused') {
60
+ return { exitCode: 1, output: ['Purge refused; nothing was changed.', ...preflight.lines].join('\n') };
61
+ }
62
+ if (preflight.verdict === 'cancelled') {
63
+ return { exitCode: 130, output: 'Cancelled; nothing was changed.' };
64
+ }
65
+ const { plan, authorization } = preflight;
66
+ const owner = plan.format2?.owners[normalizeRepoPath(host.root)] ?? null;
67
+ const inspection = await inspectHost(host, { owner });
68
+ const record = await readInstallRecord(host);
69
+ const writer = record ? recordHomeWriter(host, record.files) : null;
70
+ const permissionLine = await removePermissions(host, inspection, false);
71
+ const hostSide = await removeHostSide(host, inspection, { owner });
72
+ if (!hostSide.detached) {
73
+ return {
74
+ exitCode: 1,
75
+ output: [stillAttachedLine(host), permissionLine, ...hostSide.lines,
76
+ `Left ${host.brandRoot} untouched: purge removes the home only after this host's hooks are gone.`]
77
+ .join('\n'),
78
+ };
79
+ }
80
+ const rules = await removeRulesForHost({ host, record, registry, registryError, detached: true, writer });
81
+ const { outcomes } = await executeHomePlan(host, plan, {
82
+ detached: true, imageMembers: plan.imageMembers, authorization,
83
+ });
84
+ await removeEmpty(host.commandsDir);
85
+ if (record) await removeOutside(writer, legacyInstallRecordPath(host));
86
+ await removeEmpty(host.agentsDir);
87
+ await removeEmptyLayout(host.legacyAgentsDir);
88
+ await removeEmptyLayout(host.legacyCommandsDir);
89
+ const appeared = appearedLines(host, plan, outcomes);
90
+ return {
91
+ exitCode: appeared.length ? 1 : outcomeExitCode(outcomes),
92
+ output: ['Purged codex-bridge.', permissionLine, ...hostSide.lines, ...rules.lines,
93
+ ...outcomeLines(outcomes, { host }), ...appeared].join('\n'),
94
+ };
95
+ }
96
+
97
+ async function purgeDryRun(options) {
98
+ const { host } = options;
99
+ let registry = null;
100
+ let registryError = null;
101
+ try {
102
+ registry = await readRulesRegistry(host);
103
+ } catch (err) {
104
+ registryError = err;
105
+ }
106
+ const diagnosis = await diagnosePurge({
107
+ host, packageRoot: options.packageRoot,
108
+ ...(options.liveRunCheck !== undefined ? { liveRunCheck: options.liveRunCheck } : {}),
109
+ });
110
+ // D12: a corrupt inventory is a diagnosis, never a throwing host-record read.
111
+ const corrupt = diagnosis.plan.recordState === 'corrupt';
112
+ const owner = corrupt ? null : diagnosis.plan.format2?.owners[normalizeRepoPath(host.root)] ?? null;
113
+ const inspection = await inspectHost(host, { owner });
114
+ const record = corrupt ? null : await readInstallRecord(host);
115
+ const permissionLine = await removePermissions(host, inspection, true);
116
+ const hostSide = await removeHostSide(host, inspection, { owner, dryRun: true });
117
+ const lines = [permissionLine, ...hostSide.lines];
118
+ if (!hostSide.detached) {
119
+ lines.push(`Would leave ${host.brandRoot} untouched: this host's hooks could not be removed.`);
120
+ }
121
+ lines.push(...await rulesDryRunLines({ host, record, registry, registryError, detached: hostSide.detached }));
122
+ lines.push(...purgeDryRunLines(diagnosis, { host }));
123
+ return { exitCode: hostSide.detached ? purgeDryRunExitCode(diagnosis) : 1, output: lines.join('\n') };
124
+ }
125
+
126
+ async function missingHome(options) {
127
+ const result = await uninstall(options);
128
+ return { ...result, output: `${result.output}\nNothing to purge: ${options.host.brandRoot} does not exist.` };
129
+ }
130
+
131
+ export async function purge(options) {
132
+ const { host } = options;
133
+ if (options.dryRun) return withSettingsRun(host.settingsPath, () => purgeDryRun(options));
134
+ if (!fs.existsSync(host.brandRoot)) return missingHome(options);
135
+ return withLifecycle(host, 'purge', (ticket) => {
136
+ if (ticket === undefined) return missingHome(options);
137
+ return withSettingsRun(host.settingsPath, () => purgeInRun(options, ticket));
138
+ }, { waitMs: options.lifecycleWaitMs });
139
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Decides and performs what happens to the Codex rules file when a host leaves the package.
3
+ * Plan_65 B20a: uninstall and purge detach hosts alike, so sharing rules ownership and fingerprint
4
+ * decisions prevents their behavior and output from drifting apart.
5
+ */
6
+ import { fileFingerprint } from './manifest.mjs';
7
+ import { removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
8
+ import { removeOutside } from './record-removal.mjs';
9
+
10
+ export function remainingOwnersText(count) {
11
+ return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
12
+ }
13
+
14
+ export async function rulesDryRunLines({ host, record, registry, registryError, detached }) {
15
+ const lines = [];
16
+ if (!record?.rules || !detached) return lines;
17
+ if (registryError) {
18
+ lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; \
19
+ ownership is unknown.`);
20
+ } else {
21
+ const remainingOwners = remainingRulesOwners(registry, host);
22
+ const currentFingerprint = await fileFingerprint(record.rules.path);
23
+ if (remainingOwners?.length) {
24
+ lines.push(`Would leave ${record.rules.path} because ${remainingOwnersText(remainingOwners.length)}.`);
25
+ } else if (currentFingerprint === record.rules.fingerprint) {
26
+ lines.push(`Would remove ${record.rules.path}; no other owners remain and its fingerprint is unchanged.`);
27
+ } else if (currentFingerprint !== null) {
28
+ lines.push(`Would leave ${record.rules.path} because its contents changed after installation.`);
29
+ } else {
30
+ lines.push(`Would leave ${record.rules.path} because it is already absent.`);
31
+ }
32
+ if (!registry) {
33
+ lines.push(`Warning: the rules ownership registry was missing; \
34
+ other installations may use ${record.rules.path}.`);
35
+ }
36
+ }
37
+ return lines;
38
+ }
39
+
40
+ export async function removeRulesForHost({ host, record, registry, registryError, detached, writer }) {
41
+ const detachedRecord = record && detached ? record : null;
42
+ let ownership = null;
43
+ if (detachedRecord && !registryError) {
44
+ try {
45
+ ownership = await removeRulesOwner(host);
46
+ } catch (err) {
47
+ registryError = err;
48
+ }
49
+ }
50
+ const lines = [];
51
+ if (detachedRecord?.rules) {
52
+ if (registryError) {
53
+ lines.push(`Left ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
54
+ } else {
55
+ if (ownership?.owners.length) {
56
+ lines.push(`Left ${record.rules.path} because ${remainingOwnersText(ownership.owners.length)}.`);
57
+ } else {
58
+ const currentFingerprint = await fileFingerprint(record.rules.path);
59
+ if (currentFingerprint === record.rules.fingerprint) {
60
+ await removeOutside(writer, record.rules.path);
61
+ } else if (currentFingerprint !== null) {
62
+ lines.push(`Left ${record.rules.path} because its contents changed after installation.`);
63
+ }
64
+ }
65
+ if (!registry) {
66
+ lines.push(`Warning: the rules ownership registry was missing; \
67
+ other installations may use ${record.rules.path}.`);
68
+ }
69
+ }
70
+ }
71
+ return { lines, registryError };
72
+ }