@lyupro/codex-bridge 0.6.8 → 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 (93) hide show
  1. package/CHANGELOG.md +109 -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 +91 -0
  25. package/cli/lifecycle-lock.mjs +335 -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 +2 -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/terminal-question.mjs +41 -0
  46. package/cli/uninstall-purge.mjs +139 -0
  47. package/cli/uninstall-rules.mjs +72 -0
  48. package/cli/uninstall.mjs +208 -127
  49. package/cli/unlock-lifecycle.mjs +102 -0
  50. package/cli/unlock.mjs +7 -4
  51. package/cli/update.mjs +102 -27
  52. package/package.json +4 -3
  53. package/scripts/home-write-audit.mjs +286 -0
  54. package/scripts/home-write-inventory.mjs +49 -0
  55. package/scripts/home-write-lexer.mjs +182 -0
  56. package/scripts/isolated-roots.mjs +6 -1
  57. package/scripts/open-work/check.mjs +180 -0
  58. package/scripts/open-work/live-thresholds.mjs +155 -0
  59. package/scripts/open-work/register.mjs +56 -0
  60. package/scripts/root-guard/root-entries.mjs +15 -0
  61. package/scripts/run-tests.mjs +26 -1
  62. package/src/agents/codex-advisor.md +5 -2
  63. package/src/home/conventions.md +9 -0
  64. package/src/home/hooks/guard-tries.mjs +6 -4
  65. package/src/home/hooks/order-gate.mjs +2 -8
  66. package/src/home/hooks/reply-guard.mjs +2 -7
  67. package/src/home/hooks/worktree-witness.mjs +6 -6
  68. package/src/home/lib/agents.mjs +1 -1
  69. package/src/home/lib/atomic-json.mjs +24 -6
  70. package/src/home/lib/config-edit.mjs +25 -10
  71. package/src/home/lib/dispatcher-state.mjs +25 -10
  72. package/src/home/lib/file-lock.mjs +41 -10
  73. package/src/home/lib/handback-witness.mjs +7 -4
  74. package/src/home/lib/home-registry.mjs +211 -0
  75. package/src/home/lib/home-write.mjs +189 -0
  76. package/src/home/lib/hook-diagnostics.mjs +20 -0
  77. package/src/home/lib/host-observations.mjs +7 -4
  78. package/src/home/lib/meta/advice-status.mjs +2 -2
  79. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  80. package/src/home/lib/meta/events.mjs +2 -1
  81. package/src/home/lib/meta/paths.mjs +24 -24
  82. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  83. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  84. package/src/home/lib/meta/verdict.mjs +14 -16
  85. package/src/home/lib/run-config.mjs +10 -1
  86. package/src/home/lib/runner/git-paths.mjs +74 -0
  87. package/src/home/lib/runner/git-state.mjs +38 -34
  88. package/src/home/lib/runner/launcher.mjs +2 -2
  89. package/src/home/lib/runner/preflight.mjs +7 -0
  90. package/src/home/lib/runner/prompts.mjs +35 -2
  91. package/src/home/lib/runner/schemas.mjs +4 -1
  92. package/src/home/lib/runner/scope-check.mjs +29 -20
  93. package/src/home/lib/runner/worker.mjs +3 -3
package/cli/prune.mjs CHANGED
@@ -1,11 +1,10 @@
1
1
  /** Confirms, executes, and reports the destructive actions in a prune plan. */
2
2
  import fs from 'node:fs';
3
- import path from 'node:path';
4
- import readline from 'node:readline/promises';
5
- import { normalizePath } from '../src/home/hooks/live-runs.mjs';
3
+ import { inspectSegments } from './link-segments.mjs';
6
4
  import { parsePruneArgs } from './prune-args.mjs';
7
5
  import { prunePlan } from './prune-plan.mjs';
8
6
  import { recursiveSize } from './runs-inventory.mjs';
7
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
9
8
 
10
9
  function formatBytes(bytes) {
11
10
  if (!Number.isFinite(bytes)) return 'unknown';
@@ -102,64 +101,6 @@ function result(exitCode, text) {
102
101
  return { exitCode, output: text };
103
102
  }
104
103
 
105
- // One spelling only. A guard that answers to several option names is a guard with several ways
106
- // to be switched off by accident.
107
- function tty(options) {
108
- if (options.isTTY !== undefined) return Boolean(options.isTTY);
109
- return Boolean((options.stdin || process.stdin).isTTY);
110
- }
111
-
112
- async function defaultPrompt(question, options = {}) {
113
- const input = options.stdin || process.stdin;
114
- const outputStream = options.promptOutput || process.stderr;
115
- const interfaceHandle = readline.createInterface({ input, output: outputStream });
116
- try {
117
- const answer = await interfaceHandle.question(`${question} [y/N] `);
118
- return /^(y|yes)$/i.test(answer.trim());
119
- } finally {
120
- interfaceHandle.close();
121
- }
122
- }
123
-
124
- function accepted(answer) {
125
- return answer === true || /^(y|yes)$/i.test(String(answer ?? '').trim());
126
- }
127
-
128
- // The package compares paths through one function, and this guard is the last thing standing
129
- // between a bug and rm -r. Raw string comparison is case-sensitive: a runs root spelled
130
- // `C:\Users\...` against a target resolved as `c:\users\...` would put every target "outside the
131
- // root", and the deletion would fail with a reason that names the wrong problem.
132
- function targetInsideRoot(target, root) {
133
- const normalizedRoot = normalizePath(root);
134
- const normalizedTarget = normalizePath(target);
135
- if (!normalizedRoot || !normalizedTarget) return false;
136
- return normalizedTarget !== normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`);
137
- }
138
-
139
- /**
140
- * Refuses a target reached through a link. Independent review found that lexical containment alone
141
- * is satisfiable: replace `<root>/<project>` with a junction to an external directory and a target
142
- * that still spells out as inside the root deletes what is outside it. The package deliberately
143
- * does not resolve paths (`realpath` returns `\\?\` and UNC forms on Windows and creates a new
144
- * class of mismatch), so the check is the cheap half instead: every segment from the root down to
145
- * the target must be a real directory, verified with lstat right before deletion.
146
- */
147
- function linkedSegment(target, root) {
148
- const normalizedRoot = normalizePath(root);
149
- let current = path.resolve(target);
150
- while (normalizePath(current) !== normalizedRoot) {
151
- try {
152
- if (fs.lstatSync(current).isSymbolicLink()) return current;
153
- } catch {
154
- return null; // Already gone: rmSync will report it honestly.
155
- }
156
- const parent = path.dirname(current);
157
- if (parent === current) return null;
158
- current = parent;
159
- }
160
- return null;
161
- }
162
-
163
104
  function currentTargetBytes(target, strategy) {
164
105
  if (strategy === 'purge') {
165
106
  // The inventory's recursive measurement is the same accounting used by projects and keeps
@@ -174,18 +115,21 @@ function currentTargetBytes(target, strategy) {
174
115
  }
175
116
 
176
117
  async function confirmAll(plan, args, options) {
177
- const prompt = options.prompt || defaultPrompt;
178
118
  for (const action of plan.actions) {
179
119
  const question = action.strategy === 'purge'
180
120
  ? `Delete folder ${action.path}?`
181
121
  : `Delete archived transport from ${action.project}/${action.run}?\n ${action.targets.join('\n ')}`;
182
- let answer;
122
+ let outcome;
183
123
  try {
184
- answer = await prompt(question, action, options);
124
+ const prompt = options.prompt;
125
+ outcome = await askYesNo(question, {
126
+ ...options,
127
+ prompt: prompt ? (text) => prompt(text, action, options) : undefined,
128
+ });
185
129
  } catch (err) {
186
130
  return { error: `codex-bridge prune: confirmation failed: ${err.message}` };
187
131
  }
188
- if (!accepted(answer)) return { declined: true };
132
+ if (outcome !== 'yes') return { declined: true };
189
133
  }
190
134
  return { declined: false };
191
135
  }
@@ -196,21 +140,30 @@ function bytesAfterRemoval(values) {
196
140
  : null;
197
141
  }
198
142
 
143
+ /**
144
+ * Uses the shared top-down check before deletion: the old leaf-up walk inspected descendants under
145
+ * a link, and its catch treated every lstat error as a clear path.
146
+ */
199
147
  async function execute(plan, options) {
200
148
  const removed = [];
201
149
  const sizes = [];
202
150
  const failed = [];
203
151
  for (const action of plan.actions) {
204
152
  for (const target of action.targets) {
205
- if (!targetInsideRoot(target, plan.root)) {
153
+ const inspection = inspectSegments(target, plan.root);
154
+ if (inspection.kind === 'outside') {
206
155
  failed.push({ target, error: 'target is outside the configured runs root' });
207
156
  continue;
208
157
  }
209
- const linked = linkedSegment(target, plan.root);
210
- if (linked) {
211
- failed.push({ target, error: `refusing to delete through a link: ${linked}` });
158
+ if (inspection.kind === 'link') {
159
+ failed.push({ target, error: `refusing to delete through a link: ${inspection.at}` });
212
160
  continue;
213
161
  }
162
+ if (inspection.kind === 'error') {
163
+ failed.push({ target, error: `cannot inspect ${inspection.at} (${inspection.code}); nothing deleted` });
164
+ continue;
165
+ }
166
+ // `missing` goes on: rmSync reports the vanished target honestly, as it always did.
214
167
  const bytes = currentTargetBytes(target, action.strategy);
215
168
  try {
216
169
  fs.rmSync(target, { recursive: action.strategy === 'purge', force: false });
@@ -253,7 +206,7 @@ export async function prune(argv = [], options = {}) {
253
206
  const payload = { status: 'completed', ...basePayload(plan), removed: [], bytesFreed: 0, failed: [] };
254
207
  return result(0, output(payload, args.json));
255
208
  }
256
- if (!tty(options)) {
209
+ if (!isInteractive(options)) {
257
210
  const message = 'No TTY: refusing deletion because deletion is an operator action; no files were changed.';
258
211
  const payload = { status: 'refused', ...basePayload(plan), error: message, removed: [], bytesFreed: 0 };
259
212
  return result(1, output(payload, args.json));
@@ -274,5 +227,3 @@ export async function prune(argv = [], options = {}) {
274
227
  ? output(executed.payload, true)
275
228
  : reportLines(executed.payload, executed.payload.status));
276
229
  }
277
-
278
- export { defaultPrompt };
@@ -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,13 @@
1
1
  /** Renders a run's structured Codex transport as a readable event-by-event report. */
2
2
  import { readEvents } from '../src/home/lib/meta/events.mjs';
3
+ import { safeSlice } from '../src/home/lib/meta/paths.mjs';
3
4
  import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
4
5
  import { resolveRunFolder } from './run-lookup.mjs';
5
6
 
6
7
  const result = (exitCode, output) => ({ exitCode, output });
7
8
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
8
9
 
9
- const inline = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
10
+ const inline = (value, max = 300) => safeSlice(String(value ?? '').replace(/\s+/g, ' ').trim(), max);
10
11
 
11
12
  function compactJson(value, max = 300) {
12
13
  let text;
@@ -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
+ }