@lyupro/codex-bridge 0.6.8 → 0.6.10

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 (128) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  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 +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +92 -0
  25. package/cli/lifecycle-lock.mjs +222 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +22 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/settings-merge.mjs +3 -29
  46. package/cli/terminal-question.mjs +41 -0
  47. package/cli/uninstall-purge.mjs +139 -0
  48. package/cli/uninstall-rules.mjs +72 -0
  49. package/cli/uninstall.mjs +208 -127
  50. package/cli/unlock-lifecycle.mjs +102 -0
  51. package/cli/unlock.mjs +7 -4
  52. package/cli/update.mjs +102 -27
  53. package/package.json +4 -3
  54. package/scripts/home-write-audit.mjs +286 -0
  55. package/scripts/home-write-inventory.mjs +59 -0
  56. package/scripts/home-write-lexer.mjs +182 -0
  57. package/scripts/isolated-roots.mjs +6 -1
  58. package/scripts/open-work/check.mjs +180 -0
  59. package/scripts/open-work/live-thresholds.mjs +155 -0
  60. package/scripts/open-work/register.mjs +56 -0
  61. package/scripts/root-guard/root-entries.mjs +15 -0
  62. package/scripts/run-tests.mjs +26 -1
  63. package/src/agents/codex-advisor.md +23 -10
  64. package/src/agents/codex-build.md +24 -14
  65. package/src/agents/codex-review.md +17 -10
  66. package/src/agents/codex-scout.md +22 -12
  67. package/src/home/conventions.md +9 -0
  68. package/src/home/hooks/guard-tries.mjs +6 -4
  69. package/src/home/hooks/order-gate.mjs +27 -19
  70. package/src/home/hooks/reply-guard.mjs +2 -7
  71. package/src/home/hooks/reply-parser.mjs +1 -1
  72. package/src/home/hooks/worktree-witness.mjs +6 -6
  73. package/src/home/lib/agents.mjs +1 -1
  74. package/src/home/lib/atomic-json.mjs +24 -6
  75. package/src/home/lib/command-path.mjs +32 -0
  76. package/src/home/lib/config-edit.mjs +25 -10
  77. package/src/home/lib/dispatcher-command.mjs +4 -2
  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 +7 -4
  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 +7 -4
  85. package/src/home/lib/kernel-lock.mjs +171 -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/chain.mjs +19 -8
  89. package/src/home/lib/meta/context-only.mjs +43 -0
  90. package/src/home/lib/meta/events.mjs +16 -3
  91. package/src/home/lib/meta/launch-rows.mjs +23 -0
  92. package/src/home/lib/meta/paths.mjs +24 -24
  93. package/src/home/lib/meta/reply.mjs +37 -3
  94. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  95. package/src/home/lib/meta/run-state.mjs +3 -0
  96. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  97. package/src/home/lib/meta/verdict.mjs +30 -24
  98. package/src/home/lib/required-inputs.mjs +48 -8
  99. package/src/home/lib/run-codex.mjs +6 -3
  100. package/src/home/lib/run-config.mjs +10 -1
  101. package/src/home/lib/runner/advise-carry.mjs +2 -1
  102. package/src/home/lib/runner/args.mjs +16 -5
  103. package/src/home/lib/runner/attach.mjs +21 -14
  104. package/src/home/lib/runner/build-evidence.mjs +29 -0
  105. package/src/home/lib/runner/codex-availability.mjs +50 -0
  106. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  107. package/src/home/lib/runner/continuation.mjs +60 -8
  108. package/src/home/lib/runner/exit-codes.mjs +13 -0
  109. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  110. package/src/home/lib/runner/flag-scan.mjs +165 -0
  111. package/src/home/lib/runner/git-paths.mjs +74 -0
  112. package/src/home/lib/runner/git-state.mjs +37 -66
  113. package/src/home/lib/runner/launcher.mjs +233 -273
  114. package/src/home/lib/runner/order-claim.mjs +84 -0
  115. package/src/home/lib/runner/order-owner.mjs +18 -0
  116. package/src/home/lib/runner/pass-gate.mjs +109 -0
  117. package/src/home/lib/runner/preflight.mjs +22 -18
  118. package/src/home/lib/runner/prompts.mjs +42 -4
  119. package/src/home/lib/runner/question-kind.mjs +41 -0
  120. package/src/home/lib/runner/run-env.mjs +2 -1
  121. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  122. package/src/home/lib/runner/schemas.mjs +4 -1
  123. package/src/home/lib/runner/scope-check.mjs +55 -28
  124. package/src/home/lib/runner/task-input.mjs +11 -2
  125. package/src/home/lib/runner/worker-admission.mjs +43 -0
  126. package/src/home/lib/runner/worker.mjs +19 -18
  127. package/src/home/lib/task-header.mjs +127 -0
  128. package/src/home/lib/write-meta.mjs +17 -5
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Answers whether any recorded Codex run may still be executing, failing closed on anything unreadable.
3
+ *
4
+ * Plan_65 D22 item 5: purge removes the home image a live run executes from. The hook scanner's
5
+ * skipped read errors and fresh-heartbeat requirement cannot prove that image is unused: a hung
6
+ * run still holds it. Purge therefore keeps uncertainty visible and uses the single liveness judge.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
11
+ import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
12
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
13
+ import { STOP_COMMAND } from '../src/home/lib/stop-contract.mjs';
14
+
15
+ function recordError(unknown, file, error) {
16
+ unknown.push({ path: file, reason: error.code || error.message });
17
+ }
18
+
19
+ // ENOENT is the only proof of absence: a root never created holds no runs, and a project folder
20
+ // pruned between the root listing and its own is gone too. Every other code stays a finding.
21
+ function readEntries(directory, unknown) {
22
+ try {
23
+ return fs.readdirSync(directory, { withFileTypes: true });
24
+ } catch (error) {
25
+ if (error.code !== 'ENOENT') recordError(unknown, directory, error);
26
+ return [];
27
+ }
28
+ }
29
+
30
+ function isDirectory(entry, file, unknown) {
31
+ if (entry.isDirectory()) return true;
32
+ if (!entry.isSymbolicLink()) return false;
33
+ try {
34
+ // Plan_65 D4 forbids deleting through links, not reading their run records.
35
+ return fs.statSync(file).isDirectory();
36
+ } catch (error) {
37
+ if (error.code !== 'ENOENT') recordError(unknown, file, error);
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function inspectRun(runDir, identityOptions, blocked, unknown) {
43
+ const statusPath = path.join(runDir, 'status.json');
44
+ let status;
45
+ try {
46
+ status = readJsonFileSync(statusPath);
47
+ } catch (error) {
48
+ // Lifecycle step 10 writes status first; old folders without it are not recorded runs.
49
+ if (error.cause instanceof SyntaxError) {
50
+ unknown.push({ path: statusPath, reason: 'invalid JSON' });
51
+ } else if (error.code !== 'ENOENT') {
52
+ recordError(unknown, statusPath, error);
53
+ }
54
+ return;
55
+ }
56
+ if (!status || typeof status !== 'object' || Array.isArray(status)) {
57
+ unknown.push({ path: statusPath, reason: 'status.json is not a plain object' });
58
+ return;
59
+ }
60
+ if (status.state !== 'running') return;
61
+ try {
62
+ if (runLiveness({ ...identityOptions, runDir, status }).processMayBeAlive === true) {
63
+ blocked.push({
64
+ runDir,
65
+ agent: status.agent,
66
+ slug: status.slug,
67
+ stop: `${STOP_COMMAND} "${runDir}"`,
68
+ });
69
+ }
70
+ } catch (error) {
71
+ recordError(unknown, statusPath, error);
72
+ }
73
+ }
74
+
75
+ export function inspectLiveRuns({ root = runsRoot(), ...identityOptions } = {}) {
76
+ const blocked = [];
77
+ const unknown = [];
78
+ for (const project of readEntries(root, unknown)) {
79
+ const projectDir = path.join(root, project.name);
80
+ if (!isDirectory(project, projectDir, unknown)) continue;
81
+ for (const run of readEntries(projectDir, unknown)) {
82
+ const runDir = path.join(projectDir, run.name);
83
+ if (isDirectory(run, runDir, unknown)) inspectRun(runDir, identityOptions, blocked, unknown);
84
+ }
85
+ }
86
+ const verdict = blocked.length ? 'blocked' : unknown.length ? 'unknown' : 'clear';
87
+ return { verdict, blocked, unknown };
88
+ }
89
+
90
+ export function liveRunLines(result) {
91
+ if (result.verdict === 'clear') return [];
92
+ return [
93
+ ...result.blocked.map(({ runDir, stop }) => `Live run ${path.basename(runDir)}: ${stop}`),
94
+ ...result.unknown.map(({ path: file, reason }) =>
95
+ `Could not read ${file} (${reason}): purge cannot prove no run is live.`),
96
+ ];
97
+ }
98
+
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Holds purge's one set of refusal checks: under a live ticket it asks both consents and mints
3
+ * the executor's authorization; for a dry run it only reports.
4
+ *
5
+ * Plan_65 D12 items 5-6 and D14 require one ordered check inside the lifecycle transaction:
6
+ * live runs, installation inventory, inventory consent, then separate data consent. The executor
7
+ * consumes its result instead of checking again, so permission cannot outlive or change its plan.
8
+ */
9
+ import path from 'node:path';
10
+ import { assertLiveTicket } from './lifecycle-transaction.mjs';
11
+ import { askPurgeConsent } from './inventory-removal.mjs';
12
+ import { inspectLiveRuns, liveRunLines } from './purge-live-runs.mjs';
13
+ import { buildHomeRemovalPlan } from './home-removal-plan.mjs';
14
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
15
+
16
+ const authorizations = new WeakMap();
17
+
18
+ // D14 item 2: this is THE live-run step, reached only as the default liveRunCheck of
19
+ // runPurgePreflight and diagnosePurge. Plan_74 replaces it alone with the admission gate.
20
+ export function checkLiveRuns() {
21
+ return inspectLiveRuns();
22
+ }
23
+
24
+ function freezePlan(value) {
25
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
26
+ for (const child of Object.values(value)) freezePlan(child);
27
+ Object.freeze(value);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ const refused = (line) => ({ verdict: 'refused', lines: [line] });
33
+
34
+ export function planRefusals(plan, host) {
35
+ if (plan.homeRoot === 'error') {
36
+ return [`Could not read ${host.brandRoot}: purge cannot inventory the home.`];
37
+ }
38
+ if (plan.recordState === 'corrupt') {
39
+ return [`The installation record of ${host.brandRoot} is unreadable: purge cannot tell who uses this home.`];
40
+ }
41
+
42
+ const ownerKey = normalizeRepoPath(host.root);
43
+ const otherOwners = plan.recordState === 'valid'
44
+ ? Object.keys(plan.format2.owners).filter((root) => root !== ownerKey)
45
+ : [];
46
+ return otherOwners.map((root) => `${root} is recorded as using ${host.brandRoot}; uninstall it first.`);
47
+ }
48
+
49
+ export function purgeDataFiles(plan) {
50
+ return plan.rows.filter((row) => row.removal === 'purge-only' && row.action === 'remove')
51
+ .map((row) => row.relative).sort();
52
+ }
53
+
54
+ export async function diagnosePurge({ host, packageRoot, liveRunCheck = checkLiveRuns }) {
55
+ const liveRuns = await liveRunCheck();
56
+ const plan = await buildHomeRemovalPlan({
57
+ command: 'purge', host, packageRoot, imagePolicy: { remove: true, reason: 'purge' },
58
+ });
59
+ return {
60
+ refusals: [...liveRunLines(liveRuns), ...planRefusals(plan, host)], plan, dataFiles: purgeDataFiles(plan),
61
+ };
62
+ }
63
+
64
+ export async function runPurgePreflight({ host, ticket, packageRoot, options = {}, liveRunCheck = checkLiveRuns }) {
65
+ assertLiveTicket(ticket, host);
66
+ const liveRuns = await liveRunCheck();
67
+ if (liveRuns.verdict !== 'clear') return { verdict: 'refused', lines: liveRunLines(liveRuns) };
68
+
69
+ const plan = await buildHomeRemovalPlan({
70
+ command: 'purge', host, packageRoot, imagePolicy: { remove: true, reason: 'purge' },
71
+ });
72
+ const lines = planRefusals(plan, host);
73
+ if (lines.length) return { verdict: 'refused', lines };
74
+
75
+ const ownerKey = normalizeRepoPath(host.root);
76
+ const inventoryConsent = await askPurgeConsent(host, 'inventory', options);
77
+ if (inventoryConsent === 'cancel') return { verdict: 'cancelled' };
78
+ if (inventoryConsent === 'no') {
79
+ return refused(`Purge needs your confirmation that no other host uses ${host.brandRoot}.`);
80
+ }
81
+
82
+ const dataFiles = purgeDataFiles(plan);
83
+ const dataConsent = await askPurgeConsent(host, 'data', options, dataFiles);
84
+ if (dataConsent === 'cancel') return { verdict: 'cancelled' };
85
+ if (dataConsent === 'no') {
86
+ return refused(`Purge needs your consent to delete your data in ${host.brandRoot}.`);
87
+ }
88
+
89
+ freezePlan(plan);
90
+ const authorization = Object.freeze({});
91
+ authorizations.set(authorization, { home: path.resolve(host.brandRoot), ownerKey, ticket, plan });
92
+ return { verdict: 'authorized', plan, authorization };
93
+ }
94
+
95
+ export function consumePurgeAuthorization(authorization, host, plan) {
96
+ const entry = authorizations.get(authorization);
97
+ if (!entry) throw new Error('purge requires its preflight authorization (Plan_65 D12 item 6)');
98
+ // D14: every attempt consumes the capability, including a stale ticket or wrong plan/home.
99
+ authorizations.delete(authorization);
100
+ assertLiveTicket(entry.ticket, host);
101
+ if (path.resolve(host.brandRoot) !== entry.home) throw new Error('purge authorization belongs to a different home');
102
+ if (entry.plan !== plan) throw new Error('purge authorization was granted for a different plan');
103
+ }
package/cli/read.mjs CHANGED
@@ -1,12 +1,15 @@
1
1
  /** Renders a run's structured Codex transport as a readable event-by-event report. */
2
+ import path from 'node:path';
3
+ import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
2
4
  import { readEvents } from '../src/home/lib/meta/events.mjs';
5
+ import { safeSlice } from '../src/home/lib/meta/paths.mjs';
3
6
  import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
4
7
  import { resolveRunFolder } from './run-lookup.mjs';
5
8
 
6
9
  const result = (exitCode, output) => ({ exitCode, output });
7
10
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
8
11
 
9
- const inline = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
12
+ const inline = (value, max = 300) => safeSlice(String(value ?? '').replace(/\s+/g, ' ').trim(), max);
10
13
 
11
14
  function compactJson(value, max = 300) {
12
15
  let text;
@@ -91,6 +94,24 @@ export function read({ run, cwd = process.cwd(), runsRootPath = runsRoot() } = {
91
94
  // run from before the event stream existed has no file, while a run killed before the CLI
92
95
  // spoke has an empty one. Reporting both as "predates this change" would hide the second.
93
96
  if (!eventData.hasStream) {
97
+ let state;
98
+ try {
99
+ state = readJsonFileSync(path.join(lookup.runDir, 'status.json'))?.state;
100
+ } catch {
101
+ // The 2026-09-06 refusal needs its state; unreadable status keeps the legacy explanation.
102
+ }
103
+ if (state === 'aborted_pre_start') {
104
+ return result(
105
+ 1,
106
+ `Run ${lookup.runDir} was refused before Codex started (state aborted_pre_start), so it has no events.jsonl.`,
107
+ );
108
+ }
109
+ if (state === 'running') {
110
+ return result(
111
+ 1,
112
+ `Run ${lookup.runDir} has no events.jsonl yet: Codex has not started; repeat the command shortly.`,
113
+ );
114
+ }
94
115
  return result(1, `Run ${lookup.runDir} has no events.jsonl; it predates the event stream.`);
95
116
  }
96
117
  if (!eventData.hasEvents) {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Removes files listed in an installation record through the home adapter, so uninstall and update
3
+ * cannot remove an unregistered path from the package home.
4
+ */
5
+ import fs from 'node:fs/promises';
6
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
7
+ import { recordTarget } from './install-record.mjs';
8
+ import { claudeBoundary, removeEmptyParents, removeEmptyHomeParents } from './remove-layout.mjs';
9
+ import { inspectSegments } from './link-segments.mjs';
10
+
11
+ export function recordHomeWriter(host, files) {
12
+ const imageMembers = files.filter((entry) => entry.root === 'brand').map((entry) => entry.path);
13
+ return createHomeWriter({ root: host.brandRoot, imageMembers });
14
+ }
15
+
16
+ export async function removeRecordedFile(host, writer, entry) {
17
+ const target = recordTarget(host, entry);
18
+ if (entry.root === 'brand') {
19
+ // Plan_65 D4 item 4 covers unlink too: a real file can sit beneath a junction.
20
+ const finding = inspectSegments(target, host.brandRoot);
21
+ if (finding.kind !== 'clear' && finding.kind !== 'missing') return { kept: finding };
22
+ if (finding.kind === 'clear') {
23
+ try {
24
+ await writer.unlink('install-image', target);
25
+ } catch (err) {
26
+ if (err.code !== 'ENOENT') throw err;
27
+ }
28
+ }
29
+ // A file already gone still gets its emptied parents taken down: a repeated uninstall after a
30
+ // partial one must finish the folders the first run left. The segments were just checked, so a
31
+ // link the walk finds now is a race; it stops the walk, and the file's own outcome is reported.
32
+ await removeEmptyHomeParents(writer, 'install-image', target, host.brandRoot);
33
+ } else {
34
+ await removeOutside(writer, target);
35
+ await removeEmptyParents(target, claudeBoundary(host, target));
36
+ }
37
+ return { kept: null };
38
+ }
39
+
40
+ export async function removeOutside(writer, target) {
41
+ writer.assertOutside(target);
42
+ await fs.rm(target, { force: true });
43
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Turns home removal outcomes, or a plan for a dry run, into the lines and exit code the operator sees.
3
+ *
4
+ * Plan_65 D12 item 7 requires actual counts and named keeps: planned removal must not claim
5
+ * that edited image files disappeared, or confuse removing an owner with deleting its record.
6
+ */
7
+ import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
8
+
9
+ const unsafeReason = (reason) => reason.startsWith('link') || reason.startsWith('unreadable');
10
+ // An image file kept by content evidence is named; one kept by policy (another owner, an incomplete
11
+ // inventory) is not — that would print ~90 lines the caller explains in one sentence.
12
+ const EVIDENCE_KEEP = new Set(['changed', 'link']);
13
+ const namedKeep = (entry) => unsafeReason(entry.reason) || entry.id === null
14
+ || (entry.id === 'install-image' && EVIDENCE_KEEP.has(entry.reason));
15
+ const artifactsLine = () => `Run artifacts in ${runsRoot()} are outside uninstall and stay.`;
16
+
17
+ function fileLines(files, host, dryRun) {
18
+ const lines = [];
19
+ const removing = dryRun ? 'remove' : 'removed';
20
+ const verb = dryRun ? 'Would remove' : 'Removed';
21
+ const removed = files.filter((entry) => entry.result === removing);
22
+ const images = removed.filter((entry) => entry.id === 'install-image').length;
23
+ const data = removed.length - images;
24
+ if (images > 0) lines.push(`${verb} ${images} image file(s) from ${host.brandRoot}.`);
25
+ if (data > 0) lines.push(`${verb} ${data} data file(s) from ${host.brandRoot}.`);
26
+ for (const entry of files) {
27
+ if ((entry.result === 'kept' || entry.result === 'blocked') && namedKeep(entry)) {
28
+ lines.push(`${dryRun ? 'Would leave' : 'Left'} brand/${entry.relative} (${entry.reason})`);
29
+ }
30
+ }
31
+ const retained = files.filter((entry) => entry.result === 'kept' && entry.reason === 'purge-only');
32
+ if (retained.length > 0) {
33
+ const paths = retained.map((entry) => entry.relative).sort().join(', ');
34
+ const location = `in ${host.brandRoot} for --purge: ${paths}`;
35
+ lines.push(`${dryRun ? 'Would keep' : 'Kept'} ${retained.length} operator file(s) ${location}`);
36
+ }
37
+ for (const entry of files) {
38
+ if (entry.result === 'failed') lines.push(`Could not remove brand/${entry.relative}: ${entry.reason}`);
39
+ }
40
+ return lines;
41
+ }
42
+
43
+ function recordLines(record, host) {
44
+ if (record.result === 'removed') return [`Removed the installation record of ${host.brandRoot}.`];
45
+ if (record.result === 'failed') return [`Could not remove brand/${record.relative}: ${record.reason}`];
46
+ if (record.result !== 'kept') return [];
47
+ if (record.reason === 'this host removed from the record') {
48
+ return [`Removed ${host.root} from the installation record of ${host.brandRoot}; the shared image stays.`];
49
+ }
50
+ if (record.reason === 'this host is still attached') {
51
+ return [`Kept ${host.root} in the installation record because its hooks could not be removed.`];
52
+ }
53
+ return [`Kept the installation record of ${host.brandRoot} (${record.reason}).`];
54
+ }
55
+
56
+ export function outcomeLines(outcomes, { host }) {
57
+ const files = outcomes.filter((entry) => entry.kind === 'file');
58
+ const record = outcomes.find((entry) => entry.kind === 'record');
59
+ const lines = fileLines(files, host, false);
60
+ if (record) lines.push(...recordLines(record, host));
61
+ for (const entry of outcomes.filter((candidate) => candidate.kind === 'directory')) {
62
+ if (entry.relative === '' && entry.result === 'removed') {
63
+ lines.push(`Removed ${host.brandRoot}.`);
64
+ } else if (entry.result === 'kept') {
65
+ lines.push(`Left brand/${entry.relative}/ (${entry.reason})`);
66
+ } else if (entry.result === 'failed') {
67
+ lines.push(`Could not remove brand/${entry.relative}/: ${entry.reason}`);
68
+ }
69
+ }
70
+ lines.push(artifactsLine());
71
+ return lines;
72
+ }
73
+
74
+ export function outcomeExitCode(outcomes) {
75
+ return outcomes.some((entry) => entry.result === 'blocked' || entry.result === 'failed'
76
+ || (entry.result === 'kept' && unsafeReason(entry.reason))) ? 1 : 0;
77
+ }
78
+
79
+ export function planExitCode(plan) {
80
+ return plan.blocked || plan.rows.some((row) => (row.action === 'keep' || row.action === 'blocked')
81
+ && unsafeReason(row.reason)) ? 1 : 0;
82
+ }
83
+
84
+ export function planLines(plan, { host, detached = true }) {
85
+ const files = plan.rows.map((row) => ({ ...row, result: row.action === 'keep' ? 'kept' : row.action }));
86
+ const lines = fileLines(files, host, true);
87
+ if (plan.record.dependsOnDetach && detached === false) {
88
+ lines.push(`Would keep ${host.root} in the installation record because its hooks could not be removed.`);
89
+ } else if (plan.record.operation === 'delete') {
90
+ lines.push(`Would remove the installation record of ${host.brandRoot}.`);
91
+ } else if (plan.record.operation === 'remove-current-owner') {
92
+ lines.push(`Would remove ${host.root} from the installation record of ${host.brandRoot}; the shared image stays.`);
93
+ } else if (plan.record.operation === 'retain') {
94
+ lines.push(`Would keep the installation record of ${host.brandRoot} (${plan.record.reason})`);
95
+ }
96
+ lines.push(artifactsLine());
97
+ return lines;
98
+ }
99
+
100
+ export function purgeDryRunLines({ refusals, plan, dataFiles }, { host }) {
101
+ if (plan.homeRoot === 'missing' && refusals.length === 0) {
102
+ return [`Nothing to purge: ${host.brandRoot} does not exist.`, artifactsLine()];
103
+ }
104
+ const lines = [];
105
+ if (refusals.length) lines.push('A real purge would refuse:', ...refusals.map((line) => ` ${line}`));
106
+ const question = `A real purge would ask two questions: whether any other host uses ${host.brandRoot}`;
107
+ lines.push(`${question}, and whether to delete your data in it.`);
108
+ if (dataFiles.length) lines.push(`Your data it would ask about: ${dataFiles.join(', ')}`);
109
+ lines.push('If you confirm both questions:', ...planLines(plan, { host }));
110
+ return lines;
111
+ }
112
+
113
+ export function purgeDryRunExitCode({ refusals, plan }) {
114
+ return refusals.length ? 1 : planExitCode(plan);
115
+ }
@@ -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) {