create-agent-rig 0.9.1 → 0.10.0

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 (234) hide show
  1. package/CHANGELOG.md +419 -3
  2. package/README.md +267 -305
  3. package/package.json +8 -14
  4. package/packages/cli/dist/commands/create.js +56 -88
  5. package/packages/cli/dist/commands/doctor.js +213 -0
  6. package/packages/cli/dist/commands/init.js +111 -50
  7. package/packages/cli/dist/commands/integrations.js +468 -0
  8. package/packages/cli/dist/commands/setup-wizard.js +61 -0
  9. package/packages/cli/dist/commands/uninstall.js +1223 -0
  10. package/packages/cli/dist/commands/upgrade.js +472 -90
  11. package/packages/cli/dist/index.js +651 -46
  12. package/packages/cli/dist/integrations/declaration.js +158 -0
  13. package/packages/cli/dist/integrations/doctor-guards.js +126 -0
  14. package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
  15. package/packages/cli/dist/integrations/mcp-json.js +107 -0
  16. package/packages/cli/dist/integrations/memory-doctor.js +124 -0
  17. package/packages/cli/dist/integrations/registry.js +22 -0
  18. package/packages/cli/dist/integrations/spawn.js +228 -0
  19. package/packages/cli/dist/integrations/spec-kit.js +280 -0
  20. package/packages/cli/dist/integrations/verify.js +193 -0
  21. package/packages/cli/dist/integrations/windows-job.js +275 -0
  22. package/packages/cli/dist/lib/elevated-paths.js +74 -0
  23. package/packages/cli/dist/lib/install-set.js +1 -44
  24. package/packages/cli/dist/lib/manifest.js +64 -5
  25. package/packages/cli/dist/lib/prompts.js +0 -23
  26. package/packages/cli/dist/lib/safe-path.js +193 -2
  27. package/packages/cli/dist/lib/safe-text.js +78 -0
  28. package/packages/cli/dist/lib/substitute.js +1 -52
  29. package/packages/cli/dist/lib/summary.js +5 -5
  30. package/packages/cli/dist/templates.js +4 -14
  31. package/scripts/prepare.mjs +28 -12
  32. package/templates/agent-os/subagent-routing.json +4 -4
  33. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
  34. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
  35. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
  36. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  37. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
  38. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
  39. package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
  40. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
  41. package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
  42. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
  43. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
  44. package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
  45. package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
  46. package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
  47. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
  48. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
  49. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
  50. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
  51. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
  52. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +4 -3
  53. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
  54. package/templates/agent-os/universal/.claude/settings.json +0 -8
  55. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
  56. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
  57. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
  58. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  59. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
  60. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
  61. package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
  62. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
  63. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  64. package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
  65. package/templates/agent-os/universal/.codex/hooks.json +0 -10
  66. package/templates/agent-os/universal/AGENTS.md +208 -107
  67. package/templates/agent-os/universal/CLAUDE.md +15 -162
  68. package/templates/agent-os/universal/PLAN.md +17 -10
  69. package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
  70. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
  71. package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
  72. package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
  73. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
  74. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
  75. package/templates/agent-os/universal/layers.json +25 -30
  76. package/templates/hash-history.json +8 -4
  77. package/templates/release-ledger.json +2 -1
  78. package/packages/cli/dist/lib/composition.js +0 -20
  79. package/packages/cli/dist/lib/targets.js +0 -28
  80. package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
  81. package/packages/cli/dist/policy/core/adapter.js +0 -18
  82. package/packages/cli/dist/policy/core/coverage.js +0 -253
  83. package/packages/cli/dist/policy/core/decision-record.js +0 -287
  84. package/packages/cli/dist/policy/core/declaration.js +0 -127
  85. package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
  86. package/packages/cli/dist/policy/core/probe.js +0 -442
  87. package/packages/cli/dist/policy/core/registry.js +0 -115
  88. package/packages/cli/dist/policy/core/validation.js +0 -275
  89. package/packages/cli/dist/policy/core/vocabulary.js +0 -123
  90. package/packages/cli/dist/policy/harness/claude.js +0 -47
  91. package/packages/cli/dist/policy/harness/codex.js +0 -87
  92. package/packages/cli/dist/policy/harness/index.js +0 -15
  93. package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
  94. package/packages/cli/dist/policy/index.js +0 -17
  95. package/templates/agent-os/init/AGENTS.md +0 -201
  96. package/templates/agent-os/init/CLAUDE.md +0 -201
  97. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
  98. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
  99. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
  100. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
  101. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
  102. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
  103. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
  104. package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
  105. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
  106. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
  107. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
  108. package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
  109. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
  110. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
  111. package/templates/skeleton/aws-serverless/README.md +0 -181
  112. package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
  113. package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
  114. package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
  115. package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
  116. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
  117. package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
  118. package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
  119. package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
  120. package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
  121. package/templates/skeleton/aws-serverless/gitignore +0 -56
  122. package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
  123. package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
  124. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
  125. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
  126. package/templates/skeleton/aws-serverless/infra/package.json +0 -18
  127. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
  128. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
  129. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
  130. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
  131. package/templates/skeleton/aws-serverless/package.json +0 -30
  132. package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
  133. package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
  134. package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
  135. package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
  136. package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
  137. package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
  138. package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
  139. package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
  140. package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
  141. package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
  142. package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
  143. package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
  144. package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
  145. package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
  146. package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
  147. package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
  148. package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
  149. package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
  150. package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
  151. package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
  152. package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
  153. package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
  154. package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
  155. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
  156. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
  157. package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
  158. package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
  159. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
  160. package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
  161. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
  162. package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
  163. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
  164. package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
  165. package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
  166. package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
  167. package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
  168. package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
  169. package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
  170. package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
  171. package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
  172. package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
  173. package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
  174. package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
  175. package/templates/skeleton/node-service/README.md +0 -104
  176. package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
  177. package/templates/skeleton/node-service/apps/web/package.json +0 -19
  178. package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
  179. package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
  180. package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
  181. package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
  182. package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
  183. package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
  184. package/templates/skeleton/node-service/eslint.config.mjs +0 -20
  185. package/templates/skeleton/node-service/gitignore +0 -54
  186. package/templates/skeleton/node-service/package.json +0 -30
  187. package/templates/skeleton/node-service/packages/core/package.json +0 -11
  188. package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
  189. package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
  190. package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
  191. package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
  192. package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
  193. package/templates/skeleton/node-service/packages/db/package.json +0 -12
  194. package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
  195. package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
  196. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
  197. package/templates/skeleton/node-service/packages/shared/package.json +0 -11
  198. package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
  199. package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
  200. package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
  201. package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
  202. package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
  203. package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
  204. package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
  205. package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
  206. package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
  207. package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
  208. package/templates/skeleton/node-service/services/api/package.json +0 -17
  209. package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
  210. package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
  211. package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
  212. package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
  213. package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
  214. package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
  215. package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
  216. package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
  217. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
  218. package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
  219. package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
  220. package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
  221. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
  222. package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
  223. package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
  224. package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
  225. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
  226. package/templates/skeleton/node-service/services/worker/package.json +0 -16
  227. package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
  228. package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
  229. package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
  230. package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
  231. package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
  232. package/templates/skeleton/node-service/tsconfig.base.json +0 -15
  233. package/templates/skeleton/node-service/tsconfig.json +0 -13
  234. package/templates/skeleton/node-service/vitest.config.ts +0 -12
@@ -0,0 +1,1223 @@
1
+ import { lstat, readFile, readdir, realpath, rmdir, unlink } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { initManifest } from './init.js';
4
+ import { AGENTS_MD_RESCUE, renderedAgentsMd } from './upgrade.js';
5
+ import { hookFilesReferencedIn } from '../lib/init-settings.js';
6
+ import { ALL_LAYERS, MANIFEST_REL, parseManifest, sha256 } from '../lib/manifest.js';
7
+ import { MAX_PATH_SEGMENTS, exceedsMaxPathSegments, resolveInside } from '../lib/safe-path.js';
8
+ /** A user-facing failure: message is printed as-is, no stack trace. */
9
+ export class UninstallError extends Error {
10
+ }
11
+ /** The reason named for a path discovered changed at apply time, never at plan time. */
12
+ export const CHANGED_SINCE_PLANNING_REASON = 'changed since planning — its bytes no longer match what was planned to be removed';
13
+ const WIRING_PATHS = new Set(['.claude/settings.json', '.codex/hooks.json']);
14
+ /**
15
+ * `.rig/` holds evidence (claims, run state) this command has no ownership
16
+ * evidence for, so {@link removeEmptyParents}'s walk stops here rather than
17
+ * reading or emptying the DIRECTORY itself. That is not a claim that nothing
18
+ * under `.rig/` is ever removed: a FILE under it can still be one of the
19
+ * exact paths {@link rigOwnedPaths} admits (`.rig/revalidation.json`, which
20
+ * `init` installs) and is removed like any other manifest-owned file when its
21
+ * hash matches — this constant only ever gates the directory's own removal.
22
+ */
23
+ const RIG_DIR = '.rig';
24
+ /**
25
+ * Where `rel` lives inside the rig — refused outright if it lands anywhere
26
+ * else. The manifest is committed, so it arrives in pull requests like any
27
+ * other file, and a path it names has to be checked exactly as `upgrade`
28
+ * checks one before writing.
29
+ */
30
+ function onDisk(repoDir, rel) {
31
+ const dest = resolveInside(repoDir, rel);
32
+ if (dest === null) {
33
+ throw new UninstallError(`Refusing to touch "${rel}" — it resolves outside ${repoDir}.`);
34
+ }
35
+ return dest;
36
+ }
37
+ /**
38
+ * Why a path that resolves lexically inside the repo is still refused.
39
+ * Deliberately does NOT say "(symlink)" — `regularFileStatus`'s `'unsafe'`
40
+ * verdict also covers a directory sitting where the manifest expects a
41
+ * plain file, a non-directory ancestor blocking the walk, and a segment
42
+ * whose `realpath` escapes the repository regardless of how `lstat`
43
+ * classifies it (the Windows-junction case above). A symlink is the common
44
+ * case, not the only one this reason is used for, and naming a kind the
45
+ * code has not actually confirmed is the same mistake `hookStillReferencedReason`
46
+ * made hardcoding "edited" (cycle-5 review, security lens advisory 2).
47
+ */
48
+ export const NOT_A_REGULAR_FILE_REASON = 'not a regular file inside the repository — a symlink, a directory (or other non-file entry) ' +
49
+ 'sitting where a plain file belongs, or an ancestor whose real path leaves the repository';
50
+ /**
51
+ * `'ok'`, `'absent'`, or `'unsafe'` — decided with `lstat`, one path segment at
52
+ * a time from the repository root down, so a symlink is caught wherever it
53
+ * sits and never followed to answer the question.
54
+ *
55
+ * `resolveInside` is purely lexical: it refuses `..` and an absolute path, but
56
+ * a manifest path that lands inside the repo lexically can still leave it at
57
+ * runtime if an ANCESTOR directory is a symlink out — `.claude/rules` pointing
58
+ * outside the repo makes `.claude/rules/workflow.md` resolve outside it too,
59
+ * even though the string never left. Reading such a path hashes bytes this
60
+ * command has no evidence for; removing it deletes something outside the repo
61
+ * entirely. So every segment down to the file itself is checked with `lstat`,
62
+ * which — unlike `stat` or a plain `readFile`/`unlink` — never follows the
63
+ * final symlink component, and the file itself must be a regular file too: a
64
+ * symlink sitting exactly at the manifest path is exactly as unsafe to read
65
+ * through and to report as owned.
66
+ *
67
+ * `'absent'` covers a missing ancestor as well as a missing file — both mean
68
+ * "nothing here to remove", which is what the existing `absent` verdict
69
+ * already says.
70
+ *
71
+ * Two INDEPENDENT checks run at every segment, not one: the `lstat`
72
+ * classification (`isSymbolicLink()` / `isDirectory()` / `isFile()`) refuses
73
+ * anything not confirmed to be a plain directory or file, AND, separately,
74
+ * `realpath` on that same segment must still resolve inside `repoDir`. The
75
+ * second check does not read the classification at all — it is what makes
76
+ * the containment guarantee hold even for a reparse-point kind `lstat` does
77
+ * not report as a symlink, whatever that kind turns out to be, rather than
78
+ * resting on one interpretation of one field.
79
+ *
80
+ * ⚠ **What is and is not measured here.** This repository's own development
81
+ * environment cannot create a Windows directory junction, so the claim above
82
+ * — that the containment check does not depend on how `lstat` classifies
83
+ * one — is a property of the CODE (realpath resolution is classification-
84
+ * independent by construction), not a measurement taken on a junction.
85
+ * `packages/cli/test/uninstall.test.ts`'s Windows-only junction tests
86
+ * (`onlyOnWindows`) DO measure the symlink-classification branch, in the
87
+ * `windows-e2e` CI lane only: they pin that Node/libuv reports a junction
88
+ * through `Stats.isSymbolicLink()` on Windows the same way a real symlink is
89
+ * — the same behaviour this repository's own ancestor-escape fixtures
90
+ * elsewhere already rely on (`test/template/*.test.ts`'s
91
+ * `process.platform === 'win32' ? 'junction' : 'dir'` pattern). Neither this
92
+ * repository's tests nor its CI have ever exercised a reparse-point kind
93
+ * `lstat().isSymbolicLink()` reports `false` for while still pointing outside
94
+ * the repository, so no claim is made about that case beyond "the realpath
95
+ * check would still catch it, by construction, if the target actually
96
+ * resolves outside `repoDir`".
97
+ */
98
+ async function regularFileStatus(repoDir, rel) {
99
+ // Lexical containment first, unchanged from before this check existed: a
100
+ // manifest path with `..` or an absolute segment refuses the whole run,
101
+ // exactly as it did when this was `onDisk`'s job alone. Only a path that
102
+ // passes this can even reach the lstat walk below.
103
+ onDisk(repoDir, rel);
104
+ let base;
105
+ try {
106
+ base = await realpath(repoDir);
107
+ }
108
+ catch (error) {
109
+ if (error.code === 'ENOENT')
110
+ return 'absent';
111
+ throw error;
112
+ }
113
+ const segments = rel.split('/');
114
+ let current = repoDir;
115
+ for (const [index, segment] of segments.entries()) {
116
+ current = path.join(current, segment);
117
+ let info;
118
+ try {
119
+ info = await lstat(current);
120
+ }
121
+ catch (error) {
122
+ if (error.code === 'ENOENT')
123
+ return 'absent';
124
+ throw error;
125
+ }
126
+ const isLast = index === segments.length - 1;
127
+ const classifiedSafe = isLast
128
+ ? !info.isSymbolicLink() && info.isFile()
129
+ : !info.isSymbolicLink() && info.isDirectory();
130
+ if (!classifiedSafe)
131
+ return 'unsafe';
132
+ // Classification-independent: whatever this segment reports itself as,
133
+ // its actual target must still resolve inside the repository root.
134
+ let resolved;
135
+ try {
136
+ resolved = await realpath(current);
137
+ }
138
+ catch {
139
+ return 'unsafe';
140
+ }
141
+ if (resolved !== base && !resolved.startsWith(base + path.sep))
142
+ return 'unsafe';
143
+ }
144
+ return 'ok';
145
+ // Unreachable in practice: `segments` always has at least one element
146
+ // (`''.split('/')` is `['']`), and every branch inside the loop above
147
+ // returns before falling through. No trailing throw is needed here — the
148
+ // loop's own `return 'ok'` covers every path that reaches the end of it.
149
+ }
150
+ const normalizeToLF = (content) => content.replace(/\r\n/g, '\n');
151
+ const normalizeToCRLF = (content) => normalizeToLF(content).replace(/\n/g, '\r\n');
152
+ /**
153
+ * True when `current`'s only difference from the recorded hash is its line
154
+ * endings — checked both directions, since the recorded hash was taken of
155
+ * whatever the rig actually wrote, and this command has no other record of
156
+ * those original bytes to compare against.
157
+ *
158
+ * A binary file — one whose bytes do not round-trip through UTF-8 without
159
+ * loss — can never take this branch: decoding it would hash a
160
+ * replacement-character string standing in for bytes that were never text,
161
+ * exactly what ADR-RP-003 forbids. Its own raw-byte hash was already checked
162
+ * and did not match, so it falls through to `modified` instead, the same as
163
+ * `upgrade`'s `isReleasedVersion` treats an invalid-UTF-8 candidate.
164
+ */
165
+ function isLineEndingOnlyMatch(current, recordedHash) {
166
+ const decoded = current.toString('utf8');
167
+ if (!Buffer.from(decoded, 'utf8').equals(current))
168
+ return false;
169
+ return (sha256(normalizeToLF(decoded)) === recordedHash ||
170
+ sha256(normalizeToCRLF(decoded)) === recordedHash);
171
+ }
172
+ /**
173
+ * `segment`, folded to the one spelling every alias of `.git` collapses to:
174
+ * lowercased, an alternate-data-stream suffix (`name::$DATA`, and any other
175
+ * `:stream`) stripped, then trailing dots and spaces stripped — the two
176
+ * characters Windows itself silently drops when it resolves a path segment
177
+ * on disk, so `.git`, `.git.`, `.git ` and `.GIT` all name the same entry
178
+ * there even though they are different strings here.
179
+ */
180
+ function normalizeGitLikeSegment(segment) {
181
+ const withoutStream = segment.split(':')[0] ?? segment;
182
+ // A linear scan, not a regex: `/[. ]+$/` backtracks quadratically on a long
183
+ // run of dots on some engines/inputs (measured: 400,000 dots cost 83s of
184
+ // CPU) — availability only, nothing is removed, but a manifest is
185
+ // committed input and a hostile one must not be able to hang a `--dry-run`.
186
+ let end = withoutStream.length;
187
+ while (end > 0 && (withoutStream[end - 1] === '.' || withoutStream[end - 1] === ' '))
188
+ end--;
189
+ return withoutStream.slice(0, end).toLowerCase();
190
+ }
191
+ /**
192
+ * Refuses the whole run outright when any manifest path — `files` or `kept` —
193
+ * names something under `.git`, checked on EVERY segment (not only the
194
+ * first), so a nested `.git` inside an owned directory is refused exactly as
195
+ * a top-level one is. A manifest is committed, so it is untrusted input the
196
+ * same way a pull request is; a manifest that pairs `.git/hooks/pre-commit`
197
+ * — or an alias {@link normalizeGitLikeSegment} folds to the same thing —
198
+ * with that file's true on-disk hash would otherwise make a confirmed
199
+ * `uninstall` remove the repository's own git state. Checked once, over
200
+ * every key, before anything else runs.
201
+ */
202
+ function refuseGitPaths(manifest) {
203
+ const all = [...Object.keys(manifest.files), ...Object.keys(manifest.kept ?? {})];
204
+ const gitPath = all.find((rel) => rel.split('/').some((s) => normalizeGitLikeSegment(s) === '.git'));
205
+ if (gitPath !== undefined) {
206
+ throw new UninstallError(`${MANIFEST_REL} names "${gitPath}", which resolves to a path under .git. A rig manifest ` +
207
+ 'never legitimately owns anything there — refusing to run.');
208
+ }
209
+ }
210
+ /**
211
+ * Refuses the whole run when a manifest lists the same path under both
212
+ * `files` and `kept`. Nothing this rig ever writes produces that overlap —
213
+ * `planUpgrade` explicitly drops a `kept` path the moment a release vouches
214
+ * for it as one of `files` — so a manifest that has it is corrupt or
215
+ * hand-edited, and letting the reader silently pick a winner (rather than
216
+ * refusing) is exactly how a path ends up removed on one line of the plan
217
+ * while the same plan reports it `preserved` on another.
218
+ */
219
+ function refuseFilesKeptOverlap(manifest) {
220
+ const keptKeys = new Set(Object.keys(manifest.kept ?? {}));
221
+ const overlap = Object.keys(manifest.files).find((rel) => keptKeys.has(rel));
222
+ if (overlap !== undefined) {
223
+ throw new UninstallError(`${MANIFEST_REL} names "${overlap}" under both "files" and "kept" — a manifest this tool ` +
224
+ 'ever wrote never overlaps the two; refusing to run.');
225
+ }
226
+ }
227
+ /**
228
+ * The exact set of paths this release actually installs — the ownership
229
+ * boundary a `remove` verdict is held to, beyond the manifest's own say-so.
230
+ * Read from the same install-set generator `init`/`upgrade` use, never
231
+ * hand-listed: a hand-written list drifts the day the install set changes
232
+ * shape, and a manifest naming a path outside it (a tampered entry, or simply
233
+ * a stale one from a release that installed something this one does not) is
234
+ * not evidence this command can act on.
235
+ *
236
+ * Deliberately the exact `rel`, not its top-level segment: a boundary drawn
237
+ * at the top level (`.claude`, `.rig`, `docs`, `journal`, …) would let a
238
+ * manifest pair almost any path under an owned DIRECTORY with its true hash
239
+ * and have it removed, even though this release never installs that exact
240
+ * path. It also means a path spelled with a Windows alternate-data-stream
241
+ * suffix (`name::$DATA`) is never mistaken for the plain name it addresses on
242
+ * disk — the two are different strings, so the suffixed one is simply absent
243
+ * from this set and falls to `preserved` on that ground alone.
244
+ *
245
+ * Reads `initManifest()` alone — the bare `{ rel, source }` list — never
246
+ * `initInstallSet`, which also RENDERS every template's substituted content.
247
+ * Ownership is a question about which PATHS this release installs, not about
248
+ * what bytes it would write there; coupling it to content rendering means a
249
+ * destructive command's plan can fail on a template error that has nothing
250
+ * to do with what is being deleted.
251
+ *
252
+ * Passes {@link ALL_LAYERS} explicitly (RP-180) rather than taking
253
+ * `initManifest`'s own default (`DEFAULT_LAYERS`, Core only): this set is the
254
+ * ownership BOUNDARY a `remove` verdict is held to, not a description of what
255
+ * a plain `init` would install today. A rig that opted into the workflow
256
+ * layer has those paths in `manifest.files` exactly like any Core path, and
257
+ * this set has to include them or `uninstall` would report every one of them
258
+ * "not a path this release installs" and refuse to ever remove it — a rig
259
+ * that opted in would never be able to fully uninstall. Which layers a GIVEN
260
+ * rig actually has is a manifest question (`manifest.files`/`manifest.kept`,
261
+ * see `docs/decisions/workflow-layer-split.md`, "Interaction with
262
+ * `uninstall`"), not a question this boundary answers.
263
+ */
264
+ async function rigOwnedPaths() {
265
+ const files = await initManifest(ALL_LAYERS);
266
+ return new Set(files.map((f) => f.rel));
267
+ }
268
+ /**
269
+ * The manifest's raw bytes, or `null` when it is genuinely absent — read the
270
+ * same symlink-safe way any other manifest-owned path is: every ancestor
271
+ * segment down to the manifest itself is checked with {@link regularFileStatus}
272
+ * before anything is read, so a symlinked `.claude` cannot make this command
273
+ * read a plausible-looking manifest that sits outside the repository, and
274
+ * cannot make it silently report `noManifest` for a repository that actually
275
+ * has one behind the link either. Refuses rather than guesses either way.
276
+ */
277
+ async function readManifestBytes(repoDir) {
278
+ const status = await regularFileStatus(repoDir, MANIFEST_REL);
279
+ if (status === 'absent')
280
+ return null;
281
+ if (status === 'unsafe') {
282
+ throw new UninstallError(`Refusing to read "${MANIFEST_REL}" — an ancestor directory is a symlink (or another ` +
283
+ 'non-regular entry), so it cannot be trusted.');
284
+ }
285
+ return readFile(onDisk(repoDir, MANIFEST_REL));
286
+ }
287
+ /**
288
+ * Matches any `.mjs` file anywhere under `.claude/hooks/` — deliberately not
289
+ * anchored to the top level, so it also matches `.claude/hooks/lib/hook-input.mjs`.
290
+ * Used only in the `unsafe`-wiring branch below, to seed the closure walk from
291
+ * every owned hook path, not only the ones a readable wiring file happens to
292
+ * name directly.
293
+ */
294
+ const HOOK_REL_PATTERN = /^\.claude\/hooks\/.+\.mjs$/;
295
+ /**
296
+ * A relative ESM import target inside a `.mjs` file — `from './x.mjs'` or
297
+ * `from '../y/z.mjs'`. Only a RELATIVE specifier is ever matched (a bare one
298
+ * names a package, not a file this rig owns), and only one ending in `.mjs` —
299
+ * the shape every hook and lib module in this fleet's own tree uses.
300
+ */
301
+ const RELATIVE_MJS_IMPORT = /from\s+['"](\.\.?\/[^'"]+\.mjs)['"]/g;
302
+ /**
303
+ * Reason named when a hook file is preserved because the wiring file that
304
+ * still calls it is itself preserved. `kind` is not decoration: a hook a
305
+ * `kept` wiring file references was never "preserved as edited" — nobody
306
+ * edited it, the rig never wrote it, and reusing the `'edited'` wording for
307
+ * every kind produced a report that told two contradictory stories about the
308
+ * SAME repository state (`.claude/settings.json` → "user-owned (kept by
309
+ * init)"; two lines away, `guard-bash.mjs` → "referenced by
310
+ * .claude/settings.json, which was preserved as edited"). Reproduced by
311
+ * hand-authoring a wiring `.claude/settings.json`, running `init` over it
312
+ * (which records it under `kept`, never `files`), then `uninstall --yes`.
313
+ */
314
+ export function hookStillReferencedReason(wiringRel, kind) {
315
+ // Exhaustiveness is enforced at COMPILE time via `default`'s
316
+ // `kind satisfies never` — not by leaving `default` off, which was tried
317
+ // and measured to NOT work: a pre-switch runtime guard that narrows
318
+ // `kind` down to the three known members (`if (kind !== 'edited' && …)`)
319
+ // makes the switch exhaustive over the NARROWED type, so a fourth
320
+ // `WiringPreservedKind` member compiled clean under `tsc --strict` with
321
+ // that guard in place — the opposite of what its own comment claimed
322
+ // (code-lens and security-lens review, RP-181, cycle 8). `satisfies never`
323
+ // inside `default` narrows nothing upstream (there is nothing upstream of
324
+ // `default` left to narrow) and fails typecheck the moment a fourth member
325
+ // exists — verified in a sandbox built from this exact file: three
326
+ // members compiles clean, four members raises `TS1360: Type "..." does
327
+ // not satisfy the expected type 'never'`. The `default` RETURNS a named
328
+ // fallback rather than throwing: this function runs from `index.ts`
329
+ // inside `uninstallPayload`, AFTER `applyUninstall` has already deleted
330
+ // files — a throw reachable from here would turn an unreachable-today
331
+ // inconsistency into a post-deletion stack trace with no JSON on stdout,
332
+ // the worst point in the whole command for that to happen. Verified in
333
+ // the same sandbox: an unexpected `kind` at runtime returns the fallback
334
+ // string, never throws, and never interpolates a literal `undefined`
335
+ // into the sentence.
336
+ let why;
337
+ switch (kind) {
338
+ case 'edited':
339
+ why = 'which was preserved as edited';
340
+ break;
341
+ case 'kept':
342
+ why = 'which init found already in place and never took ownership of';
343
+ break;
344
+ case 'unsafe':
345
+ why = 'which could not be safely read (itself a symlink, or reached through one)';
346
+ break;
347
+ default: {
348
+ kind;
349
+ return (`still referenced by ${wiringRel}, for a reason this version does not name — removing ` +
350
+ 'this file would leave it pointing at nothing');
351
+ }
352
+ }
353
+ return `still referenced by ${wiringRel}, ${why} — removing this file would leave it pointing at nothing`;
354
+ }
355
+ /**
356
+ * Reason named when a file is preserved not because a wiring file names it
357
+ * directly, but because a hook file that IS (directly or, itself,
358
+ * transitively) needed by that wiring imports it. Deliberately a different
359
+ * sentence from {@link hookStillReferencedReason}, naming the IMMEDIATE
360
+ * importer rather than only the ultimate wiring path:
361
+ * `.claude/scripts/lib/secrets.mjs` is never mentioned by
362
+ * `.claude/settings.json` at all — only `.claude/hooks/guard-secret-file.mjs`
363
+ * is — so an operator grepping `settings.json` for `secrets.mjs` to
364
+ * understand why it survived would find nothing if this said only "still
365
+ * referenced by .claude/settings.json"; grepping it for
366
+ * `guard-secret-file.mjs` (this reason's `importedByRel`) finds the real
367
+ * connection.
368
+ *
369
+ * Deliberately does NOT say `importedByRel` is itself directly referenced by
370
+ * `wiringRel` — it says only that `wiringRel` needs it "directly or through
371
+ * further imports", which stays true whether `importedByRel` is the hook a
372
+ * wiring file names outright or itself another import one or more hops
373
+ * removed (`.claude/hooks/lib/edit-input.mjs` imports
374
+ * `.claude/scripts/git-env.mjs`, and neither is named in
375
+ * `.claude/settings.json` at all). A reader who needs the next hop finds it
376
+ * on `importedByRel`'s OWN `preserved` entry, which names what needed IT.
377
+ */
378
+ export function hookImportedByReason(importedByRel, wiringRel) {
379
+ return (`imported by ${importedByRel}, itself needed — directly or through further imports — by ` +
380
+ `the still-preserved ${wiringRel}, which is why it survives too. Removing this file would ` +
381
+ `leave ${importedByRel} unable to load`);
382
+ }
383
+ /**
384
+ * Reason named when a file was protected not because it was traced — as a
385
+ * direct reference or as a genuine import — but because SOME OTHER file the
386
+ * walk needed to read in order to keep tracing could not be read at all.
387
+ * `unreadableRel` names that file, not `rel` itself: this file's own need
388
+ * was never confirmed one way or the other, so it is kept purely as a
389
+ * precaution.
390
+ *
391
+ * Deliberately its own, fifth wording rather than reusing
392
+ * {@link hookStillReferencedReason}'s "still referenced by … which was
393
+ * preserved as edited" — that sentence claims a specific, confirmed
394
+ * connection this file does not have. Reusing it made the wording that
395
+ * sounds MOST certain describe the files the command is LEAST sure about: in
396
+ * one reproduced run, 84 owned paths in one preserved-wiring scenario, ~7
397
+ * genuinely referenced by the wiring file (which the command HAD read and
398
+ * could enumerate exactly), ~30 swept in by this precaution and reported
399
+ * with the identical unqualified "still referenced by" sentence — including
400
+ * a TEST FIXTURE (`.agents/skills/new-invariant/guard-invariant.example.test.mjs`)
401
+ * no hook could ever import (UX-lens review, RP-181).
402
+ */
403
+ /**
404
+ * The one fixed prefix every {@link hookUnverifiedReason} string starts
405
+ * with — exported so a caller building a roll-up summary (how many
406
+ * `preserved` paths are genuinely traced versus kept only as a precaution)
407
+ * can tell the two apart from the rendered reason text without a second,
408
+ * independent guess at the wording. One string, defined once; both this
409
+ * module and {@link isUnverifiedReason} read the SAME constant, so they
410
+ * cannot drift apart the way two copies of a fact always eventually do.
411
+ */
412
+ const UNVERIFIED_REASON_PREFIX = 'protected because ';
413
+ export function hookUnverifiedReason(unreadableRel) {
414
+ return (`${UNVERIFIED_REASON_PREFIX}${unreadableRel} could not be read, so every file this release ` +
415
+ 'installs is being kept rather than risk removing one it needs');
416
+ }
417
+ /**
418
+ * True when `reason` is exactly the shape {@link hookUnverifiedReason}
419
+ * produces — used only to build a roll-up summary in the CLI (how many
420
+ * `preserved` paths were genuinely traced versus kept only as a
421
+ * precaution), never to decide protection itself, which has already
422
+ * happened by the time anything calls this.
423
+ */
424
+ export function isUnverifiedReason(reason) {
425
+ return reason.startsWith(UNVERIFIED_REASON_PREFIX);
426
+ }
427
+ /**
428
+ * The ONE reason a protected hook or dependency reports, decided by which
429
+ * evidence for its protection actually exists — centralised so
430
+ * `planUninstall` and the apply-time re-check (through `index.ts`, which
431
+ * carries the same three pieces of evidence across the process boundary in
432
+ * `ApplyUninstallResult.protectedHooksAtApply`) cannot describe the same
433
+ * fact two different ways. Precedence, strongest evidence first: a genuine
434
+ * import trace (`importedByRel`) beats mere caution (`unverifiedBecause`)
435
+ * beats a bare direct reference — a file can end up with more than one kind
436
+ * of evidence over the life of a walk (a precaution sweep can catch a path
437
+ * before a LATER, genuine import trace reaches the same one), and the
438
+ * strongest one is what gets reported.
439
+ */
440
+ export function protectedFileReason(wiringRel, wiringKind, importedByRel, unverifiedBecause) {
441
+ if (importedByRel !== undefined)
442
+ return hookImportedByReason(importedByRel, wiringRel);
443
+ if (unverifiedBecause !== undefined)
444
+ return hookUnverifiedReason(unverifiedBecause);
445
+ return hookStillReferencedReason(wiringRel, wiringKind);
446
+ }
447
+ /**
448
+ * `seedRel` plus everything it (transitively) imports that is itself one of
449
+ * `ownedPaths` — each one added to `protectedHooks` under `wiringRel`, and
450
+ * (for everything except `seedRel` itself) to `importedBy` under whichever
451
+ * file's import target resolved to it, mutating all four maps in place.
452
+ * `unverified` maps a path this pass protected WITHOUT tracing it — see
453
+ * {@link hookUnverifiedReason} — to the file whose own unreadability
454
+ * triggered the sweep that caught it.
455
+ *
456
+ * A directly-wired hook file (`.claude/hooks/guard-bash.mjs`) is not
457
+ * self-contained: it imports `.claude/hooks/lib/hook-input.mjs`,
458
+ * `.claude/scripts/lib/shell-tools.mjs`, `.claude/scripts/stop-flag.mjs` — its
459
+ * own dependencies, which are themselves owned paths a manifest can mark
460
+ * `remove` on their own account. `hookFilesReferencedIn`/{@link HOOK_REL_PATTERN}
461
+ * only ever name the hook files a wiring file calls DIRECTLY; without this
462
+ * walk, a preserved `.claude/settings.json` protects `guard-bash.mjs` itself
463
+ * but not what it imports, and the removal loop deletes `hook-input.mjs` out
464
+ * from under it. `guard-bash.mjs` then dies at module resolution with exit 1
465
+ * on every call — a `PreToolUse` hook that exits non-2 is non-blocking, so
466
+ * the Never tier, the credential guard, `block-no-verify` and the kill switch
467
+ * all go silently inert while `.claude/settings.json` still wires them. This
468
+ * is `safe-path.ts`'s "a brake that looks installed and is not".
469
+ *
470
+ * **Every real READ is gated by {@link regularFileStatus}**, the identical
471
+ * symlink-safe check every other read in this file gets — `onDisk` alone is
472
+ * purely lexical and does not stop a `readFile` from opening whatever the
473
+ * path resolves to. A `.claude/hooks/guard-bash.mjs` swapped for a symlink to
474
+ * `/dev/zero` (or any other unbounded or blocking special file), with the
475
+ * wiring that references it also modified, would otherwise make `readFile`
476
+ * itself hang or exhaust the heap — reachable from a hostile committed
477
+ * manifest through nothing worse than `git clone`, before consent, and
478
+ * fatal to the "`--json` prints exactly one object" promise either way.
479
+ * `protectedHooks.set` already ran for `rel` a line above, so THAT file
480
+ * stays protected regardless of its own status. What that alone cannot do
481
+ * is discover what an unreadable `rel` itself imports — `visited` has
482
+ * already been marked, so nothing else in the walk will try again either.
483
+ *
484
+ * ⚠ **Only `status === 'unsafe'` triggers the superset sweep below —
485
+ * `'absent'` does not, and that distinction is load-bearing, not an
486
+ * oversight.** An earlier version of this function swept on ANY
487
+ * `!== 'ok'` status, `'absent'` included — measured cost: deleting one
488
+ * ordinary, already-uninstalled-by-hand hook file (`block-no-verify.mjs`),
489
+ * with the wiring referencing it preserved, turned 83 planned removals into
490
+ * 40 planned / 43 preserved, on a repository that had done nothing more
491
+ * hostile than turn a hook off. And nothing is bought by sweeping on
492
+ * absence: a file that is not there cannot fail module resolution over an
493
+ * import it cannot make, because the module that would need that import is
494
+ * itself gone. The whole justification for the sweep — a file we cannot
495
+ * READ might import something we cannot discover — simply does not apply to
496
+ * a file that does not exist (code-lens review, RP-181).
497
+ *
498
+ * An earlier version of the `'unsafe'` branch stopped at protecting `rel`
499
+ * alone, on the reasoning that every `.claude/scripts/` dependency the walk
500
+ * needs to reach is also imported by at least one OTHER, ordinarily-readable
501
+ * hook. That reasoning was checked against the shipped import graph and
502
+ * found false: `.claude/scripts/lib/secrets.mjs` has exactly ONE seeder
503
+ * (`guard-secret-file.mjs`), and `.claude/scripts/unattended-flag.mjs` has
504
+ * exactly one (`guard-rulebook.mjs`) — reproduced end to end through a real
505
+ * `git commit` (mode `120000`) and a fresh `git clone`: symlink the sole
506
+ * seeder, preserve the wiring that references it, and `secrets.mjs` survived
507
+ * the uninstall while its only importer died at module resolution, silently
508
+ * inert (security-lens review, RP-181). So the `'unsafe'` branch below does
509
+ * not stop at `rel` alone: it protects the conservative superset — every
510
+ * owned `.mjs` path — the same move `protectedHooksFor` already makes one
511
+ * level up for an unreadable WIRING file, recording each newly-swept path
512
+ * into `unverified` rather than claiming it as confirmed. "Protecting too
513
+ * many is the safe direction" is the same doctrine either way; only where
514
+ * it gets applied, and how honestly it gets reported, changed.
515
+ *
516
+ * Bounded, per `.claude/rules/invariants.md`'s fail-open rule, but precisely:
517
+ * no recursion, and every REAL read happens at most once per owned path —
518
+ * `visited` is checked before reading, and only a resolved import target
519
+ * already IN `ownedPaths` is ever pushed, so a file this release does not
520
+ * ship is never opened. What is NOT bounded by `|ownedPaths|` is transient
521
+ * `queue` length: `!visited.has(resolved)` is checked at PUSH time, and two
522
+ * different files can each name the same not-yet-popped dependency before
523
+ * either is processed, queuing it more than once. Each duplicate costs one
524
+ * cheap pop-and-skip, never a second read — pinned, not merely asserted (see
525
+ * `.claude/rules/invariants.md`, "State the limits — and test them"), by
526
+ * `packages/cli/test/uninstall.test.ts` › "processes 400,000 duplicate
527
+ * import matches to the same owned dependency in bounded time".
528
+ */
529
+ async function protectHookAndDeps(repoDir, ownedPaths, seedRel, wiringRel, protectedHooks, visited, importedBy, unverified) {
530
+ // `[rel, parent]` — `parent` is the file whose import target resolved to
531
+ // `rel`, or `undefined` for `seedRel` itself (a wiring file names it
532
+ // directly; nothing imported it to get here). Recorded into `importedBy`
533
+ // only the FIRST time `rel` is reached, mirroring `protectedHooks`'s own
534
+ // `!has` guard just below — so a file reachable two different ways keeps
535
+ // whichever path found it first, for a reason string that names one real
536
+ // connection rather than every one that happens to exist.
537
+ const queue = [[seedRel, undefined]];
538
+ while (queue.length > 0) {
539
+ const [rel, parent] = queue.pop();
540
+ if (visited.has(rel))
541
+ continue;
542
+ visited.add(rel);
543
+ // `rel` was REACHED — named by a wiring file, or resolved from another
544
+ // file's import — so it is traced, whatever its own status turns out to
545
+ // be, and is never merely "unverified". Together with the sweep's own
546
+ // `!visited.has(owned)` below this makes the wording independent of the
547
+ // order the seeds arrive in: a sweep that ran BEFORE `rel`'s turn is
548
+ // undone here, and one that runs AFTER it never marks `rel` at all.
549
+ // Clearing only on a readable pop (the cycle-8 fix) closed the first
550
+ // half alone — an unreadable seed anywhere but first in the wiring
551
+ // file's text order still re-marked every hook already confirmed
552
+ // (code-, security- and UX-lens review, RP-181, cycle 9).
553
+ unverified.delete(rel);
554
+ if (!protectedHooks.has(rel))
555
+ protectedHooks.set(rel, wiringRel);
556
+ if (parent !== undefined && !importedBy.has(rel))
557
+ importedBy.set(rel, parent);
558
+ const status = await regularFileStatus(repoDir, rel);
559
+ // `'absent'` buys the sweep below nothing: a file that is not there has
560
+ // no imports that can fail to resolve, because the module that would
561
+ // make them is itself gone (code-lens review, RP-181). Only `'unsafe'`
562
+ // — unreadable, not merely missing — triggers it.
563
+ if (status === 'absent')
564
+ continue;
565
+ if (status === 'unsafe') {
566
+ for (const owned of ownedPaths) {
567
+ if (!owned.endsWith('.mjs'))
568
+ continue;
569
+ if (!protectedHooks.has(owned))
570
+ protectedHooks.set(owned, wiringRel);
571
+ // First unreadable file to sweep a given path names the reason;
572
+ // never overwritten by a later sweep, never planted on a path some
573
+ // walk already reached, and cleared the moment one does (above).
574
+ if (!visited.has(owned) && !importedBy.has(owned) && !unverified.has(owned)) {
575
+ unverified.set(owned, rel);
576
+ }
577
+ }
578
+ continue;
579
+ }
580
+ let text;
581
+ try {
582
+ text = await readFile(onDisk(repoDir, rel), 'utf8');
583
+ }
584
+ catch {
585
+ continue; // gone, or unreadable — nothing further to walk from here
586
+ }
587
+ const dir = path.posix.dirname(rel);
588
+ for (const match of text.matchAll(RELATIVE_MJS_IMPORT)) {
589
+ const resolved = path.posix.normalize(path.posix.join(dir, match[1]));
590
+ if (ownedPaths.has(resolved) && !visited.has(resolved))
591
+ queue.push([resolved, rel]);
592
+ }
593
+ }
594
+ }
595
+ async function protectedHooksFor(repoDir, ownedPaths, trackingFor) {
596
+ const protectedHooks = new Map();
597
+ const importedBy = new Map();
598
+ const unverified = new Map();
599
+ const wiringKind = new Map();
600
+ const wiringBytes = new Map();
601
+ const visited = new Set();
602
+ for (const wiringRel of WIRING_PATHS) {
603
+ const tracking = trackingFor(wiringRel);
604
+ if (!tracking.tracked || !ownedPaths.has(wiringRel))
605
+ continue;
606
+ const status = await regularFileStatus(repoDir, wiringRel);
607
+ if (status === 'absent')
608
+ continue;
609
+ if (status === 'unsafe') {
610
+ wiringKind.set(wiringRel, 'unsafe');
611
+ for (const rel of ownedPaths) {
612
+ if (HOOK_REL_PATTERN.test(rel)) {
613
+ await protectHookAndDeps(repoDir, ownedPaths, rel, wiringRel, protectedHooks, visited, importedBy, unverified);
614
+ }
615
+ }
616
+ continue;
617
+ }
618
+ const current = await readFile(onDisk(repoDir, wiringRel));
619
+ wiringBytes.set(wiringRel, current);
620
+ // pristine and NOT always-preserved — it is about to be removed, so
621
+ // nothing downstream needs protecting on its account. A `kept` wiring
622
+ // path never takes this branch: `alwaysPreserved` is true for it
623
+ // unconditionally, hash or no hash.
624
+ if (!tracking.alwaysPreserved && sha256(current) === tracking.recordedHash)
625
+ continue;
626
+ wiringKind.set(wiringRel, tracking.alwaysPreserved ? 'kept' : 'edited');
627
+ for (const hook of hookFilesReferencedIn(current.toString('utf8'))) {
628
+ await protectHookAndDeps(repoDir, ownedPaths, hook, wiringRel, protectedHooks, visited, importedBy, unverified);
629
+ }
630
+ }
631
+ return { protectedHooks, importedBy, unverified, wiringKind, wiringBytes };
632
+ }
633
+ /**
634
+ * What an uninstall would do, decided per file, removing nothing.
635
+ *
636
+ * Ownership evidence is the manifest alone: a path's recorded hash is the only
637
+ * thing that can mark it for removal, and only when the bytes on disk still
638
+ * match it exactly AND the path is one of the EXACT paths this release
639
+ * actually installs. Everything else — absent, edited, kept by init, wiring
640
+ * this rig no longer recognises, a hook a preserved wiring file still calls, a
641
+ * path outside the current install set, or the CRLF/LF twin of what it wrote
642
+ * — is reported and left alone.
643
+ */
644
+ export async function planUninstall(repoDir) {
645
+ const raw = await readManifestBytes(repoDir);
646
+ if (raw === null)
647
+ return { noManifest: true, actions: [], manifestHash: null };
648
+ const manifestHash = sha256(raw);
649
+ const manifest = parseManifest(raw.toString('utf8'));
650
+ if (manifest === null) {
651
+ throw new UninstallError(`${MANIFEST_REL} exists but could not be read as a rig manifest. Refusing to remove anything.`);
652
+ }
653
+ refuseGitPaths(manifest);
654
+ refuseFilesKeptOverlap(manifest);
655
+ const ownedPaths = await rigOwnedPaths();
656
+ // A `kept` wiring path is tracked too, not just a `files` one —
657
+ // `refuseFilesKeptOverlap` above already guarantees the same `rel` never
658
+ // appears in both, so there is no ambiguity about which case applies.
659
+ const trackingFor = (wiringRel) => {
660
+ if (Object.hasOwn(manifest.kept ?? {}, wiringRel)) {
661
+ return { tracked: true, alwaysPreserved: true };
662
+ }
663
+ const recordedHash = manifest.files[wiringRel];
664
+ return recordedHash === undefined
665
+ ? { tracked: false }
666
+ : { tracked: true, alwaysPreserved: false, recordedHash };
667
+ };
668
+ const { protectedHooks, importedBy, unverified, wiringKind, wiringBytes } = await protectedHooksFor(repoDir, ownedPaths, trackingFor);
669
+ const actions = [];
670
+ for (const rel of Object.keys(manifest.files).sort()) {
671
+ const recorded = manifest.files[rel];
672
+ // Checked BEFORE `onDisk` — and so before `onDisk`'s own message, which
673
+ // reads "resolves outside <repoDir>" and would be FALSE for a path this
674
+ // deep, since it never leaves the repository lexically at all. Every
675
+ // path this release actually owns is nowhere near this deep (pinned in
676
+ // `safe-path.test.ts`), so a key past the cap can never have been one of
677
+ // them regardless — reported the same honest, non-aborting way as any
678
+ // other unowned path, with a reason that names the real limit instead of
679
+ // a false one. This does not weaken the `..`/absolute escape check right
680
+ // below: THAT check still aborts the whole run for a genuinely escaping
681
+ // key, exactly as before; a too-deep key is refused by never reaching
682
+ // that check at all, and it is never read, hashed, or written either way
683
+ // — the depth cap and the escape check are independent safety nets, not
684
+ // substitutes for each other.
685
+ if (exceedsMaxPathSegments(rel)) {
686
+ actions.push({
687
+ rel,
688
+ verdict: 'preserved',
689
+ reason: `more than ${MAX_PATH_SEGMENTS} path segments — deeper than any path this release ` +
690
+ 'installs, so it is refused without being resolved on disk at all; if this key is ' +
691
+ `not one you recognise, remove it from ${MANIFEST_REL} by hand`,
692
+ });
693
+ continue;
694
+ }
695
+ // Lexical containment unconditionally, before the ownership check below —
696
+ // a `..`-escaping path is refused outright regardless of whether it
697
+ // happens to be one of the exact paths this release installs.
698
+ onDisk(repoDir, rel);
699
+ if (!ownedPaths.has(rel)) {
700
+ actions.push({
701
+ rel,
702
+ verdict: 'preserved',
703
+ reason: 'not a path this release installs',
704
+ });
705
+ continue;
706
+ }
707
+ const status = await regularFileStatus(repoDir, rel);
708
+ if (status === 'absent') {
709
+ actions.push({ rel, verdict: 'absent' });
710
+ continue;
711
+ }
712
+ if (status === 'unsafe') {
713
+ actions.push({ rel, verdict: 'preserved', reason: NOT_A_REGULAR_FILE_REASON });
714
+ continue;
715
+ }
716
+ // status === 'ok': every ancestor is a real directory and the path itself
717
+ // is a regular file — safe to read and to hash. A wiring file's bytes may
718
+ // already have been read by `protectedHooksFor` above; reuse them rather
719
+ // than reading the same file twice.
720
+ const current = wiringBytes.get(rel) ?? (await readFile(onDisk(repoDir, rel)));
721
+ if (WIRING_PATHS.has(rel)) {
722
+ if (sha256(current) === recorded) {
723
+ actions.push({ rel, verdict: 'remove', recordedHash: recorded });
724
+ }
725
+ else {
726
+ const hooks = [...hookFilesReferencedIn(current.toString('utf8'))].sort();
727
+ actions.push({
728
+ rel,
729
+ verdict: 'preserved',
730
+ reason: "wiring-modified — remove the rig's hook entries by hand" +
731
+ (hooks.length > 0 ? ` (still referenced: ${hooks.join(', ')})` : ''),
732
+ });
733
+ }
734
+ continue;
735
+ }
736
+ const protectingWiring = protectedHooks.get(rel);
737
+ if (protectingWiring !== undefined) {
738
+ actions.push({
739
+ rel,
740
+ verdict: 'preserved',
741
+ // `wiringKind` is set for `protectingWiring` in the SAME iteration
742
+ // of `protectedHooksFor` that populated `protectedHooks` with
743
+ // `rel` — never independently, so this can only be `undefined` if
744
+ // the two maps disagreed with each other, which would itself be
745
+ // the bug to fix.
746
+ reason: protectedFileReason(protectingWiring, wiringKind.get(protectingWiring), importedBy.get(rel), unverified.get(rel)),
747
+ });
748
+ continue;
749
+ }
750
+ const currentHash = sha256(current);
751
+ if (currentHash === recorded) {
752
+ actions.push({ rel, verdict: 'remove', recordedHash: recorded });
753
+ }
754
+ else if (isLineEndingOnlyMatch(current, recorded)) {
755
+ actions.push({ rel, verdict: 'preserved', reason: 'line-endings-only' });
756
+ }
757
+ else {
758
+ actions.push({ rel, verdict: 'preserved', reason: 'modified' });
759
+ }
760
+ }
761
+ for (const rel of Object.keys(manifest.kept ?? {}).sort()) {
762
+ actions.push({ rel, verdict: 'preserved', reason: 'user-owned (kept by init)' });
763
+ }
764
+ // Round 4, blocker 2 (CLI-UX): removing one of CLAUDE.md/AGENTS.md while
765
+ // the other is not a clean removal — preserved as the user's own edit, or
766
+ // already gone — is exactly the moment the rulebook could end up with no
767
+ // readable copy left at all. `upgrade` already narrates the held-back half
768
+ // of this same situation (`heldBack`); `uninstall` did not narrate its
769
+ // own half at all. `init` refuses outright over a pre-existing CLAUDE.md
770
+ // or AGENTS.md (the `MAPS` special case), so neither ever appears under
771
+ // `manifest.kept` — both are always decided by the loop above, never here.
772
+ const claudeAction = actions.find((a) => a.rel === 'CLAUDE.md');
773
+ const agentsAction = actions.find((a) => a.rel === 'AGENTS.md');
774
+ // Round 5 advisory: the `undefined` case previously said "is not tracked
775
+ // by this rig", which reads as "is absent" — it is not. `undefined` here
776
+ // only ever means the manifest never named this path (an old, pre-RP-186
777
+ // manifest missing an entry the current install set always has); the file
778
+ // itself may well be sitting right there. Checked directly rather than
779
+ // guessed at, so the wording says what is actually true.
780
+ const siblingState = async (a, rel) => {
781
+ if (a !== undefined) {
782
+ return a.verdict === 'absent'
783
+ ? 'is already gone'
784
+ : `stays as yours (${a.reason ?? 'edited'})`;
785
+ }
786
+ const status = await regularFileStatus(repoDir, rel);
787
+ return status === 'absent' ? 'is already gone' : 'exists and is yours (untracked by this rig)';
788
+ };
789
+ const notACleanRemoval = (a) => a === undefined || a.verdict === 'preserved' || a.verdict === 'absent';
790
+ if (claudeAction?.verdict === 'remove' && notACleanRemoval(agentsAction)) {
791
+ claudeAction.note =
792
+ `this is the rig's own CLAUDE.md — removing it leaves AGENTS.md, which ` +
793
+ `${await siblingState(agentsAction, 'AGENTS.md')}, as the only rulebook copy`;
794
+ }
795
+ if (agentsAction?.verdict === 'remove' && notACleanRemoval(claudeAction)) {
796
+ agentsAction.note =
797
+ `this is the rig's own AGENTS.md — removing it leaves CLAUDE.md, which ` +
798
+ `${await siblingState(claudeAction, 'CLAUDE.md')}, as the only rulebook copy`;
799
+ }
800
+ // Round 4, blocker 1 (round 5: shares `renderedAgentsMd` with `upgrade.ts`
801
+ // — one implementation of "is this the current rendering", not two that
802
+ // could drift apart): the sibling `upgrade` writes when CLAUDE.md's shim
803
+ // is genuinely held back (`AGENTS_MD_RESCUE`, never recorded in the
804
+ // manifest) is invisible to the loop above — it only ever walks
805
+ // `manifest.files`/`manifest.kept`. Decided the same way as everything
806
+ // else here: rig-owned (removable) only when its bytes are EXACTLY what
807
+ // this release would render for THIS project right now; anything else is
808
+ // the user's, left alone and reported as preserved. Round 5 advisory:
809
+ // annotated in every state, not only `preserved` — a bare `remove` line
810
+ // said nothing about why removing an UNTRACKED path was safe.
811
+ const rescueStatus = await regularFileStatus(repoDir, AGENTS_MD_RESCUE);
812
+ if (rescueStatus === 'unsafe') {
813
+ actions.push({
814
+ rel: AGENTS_MD_RESCUE,
815
+ verdict: 'preserved',
816
+ reason: NOT_A_REGULAR_FILE_REASON,
817
+ });
818
+ }
819
+ else if (rescueStatus === 'ok') {
820
+ const rescueBytes = await readFile(onDisk(repoDir, AGENTS_MD_RESCUE));
821
+ const rendered = await renderedAgentsMd(manifest.project, manifest.layers ?? ALL_LAYERS);
822
+ if (rendered !== null && sha256(rescueBytes) === sha256(Buffer.from(rendered, 'utf8'))) {
823
+ actions.push({
824
+ rel: AGENTS_MD_RESCUE,
825
+ verdict: 'remove',
826
+ recordedHash: sha256(rescueBytes),
827
+ note: 'byte-identical to what this release renders for AGENTS.md right now — safe to remove, and never recorded in the manifest as rig-owned',
828
+ });
829
+ }
830
+ else {
831
+ actions.push({
832
+ rel: AGENTS_MD_RESCUE,
833
+ verdict: 'preserved',
834
+ reason: "not this release's current rendered AGENTS.md — treated as yours",
835
+ });
836
+ }
837
+ }
838
+ // 'absent': nothing to report — this path was never rig-owned, so "not
839
+ // there" is simply not there, same as any other path this release never
840
+ // installs.
841
+ return { noManifest: false, actions, manifestHash };
842
+ }
843
+ /**
844
+ * True when every segment of `relDir`, walked one at a time from `repoDir`,
845
+ * `lstat`s as a real (non-symlink) directory.
846
+ *
847
+ * Mirrors {@link regularFileStatus}'s own per-segment walk, for the same
848
+ * reason: a single `lstat` on the whole joined path has the OS resolve every
849
+ * INTERMEDIATE component transparently — only the final component is left
850
+ * unfollowed — so it would happily walk through a symlinked ancestor to
851
+ * answer "is the last segment a directory". Checking one segment at a time
852
+ * means each `lstat` only ever extends a prefix the previous iteration has
853
+ * already proven is a real directory, so no call in the chain can be resolved
854
+ * through a symlink it did not itself just reject.
855
+ */
856
+ async function isPlainDirectoryChain(repoDir, relDir) {
857
+ let base;
858
+ try {
859
+ base = await realpath(repoDir);
860
+ }
861
+ catch {
862
+ return false;
863
+ }
864
+ let current = repoDir;
865
+ for (const segment of relDir.split('/')) {
866
+ current = path.join(current, segment);
867
+ let info;
868
+ try {
869
+ info = await lstat(current);
870
+ }
871
+ catch {
872
+ return false;
873
+ }
874
+ if (info.isSymbolicLink() || !info.isDirectory())
875
+ return false;
876
+ // Classification-independent, the same second check `regularFileStatus`
877
+ // makes and for the same reason: whatever `lstat` classifies this
878
+ // segment as, its actual target must still resolve inside the
879
+ // repository root.
880
+ let resolved;
881
+ try {
882
+ resolved = await realpath(current);
883
+ }
884
+ catch {
885
+ return false;
886
+ }
887
+ if (resolved !== base && !resolved.startsWith(base + path.sep))
888
+ return false;
889
+ }
890
+ return true;
891
+ }
892
+ /**
893
+ * Removes `rel`'s parent directories while they are empty, stopping at the
894
+ * repository root and never removing `.rig` itself — evidence lives there,
895
+ * and the directory staying in place (even empty) is the rig's own record
896
+ * that it once ran here.
897
+ *
898
+ * Symlink-safe the same way removal itself is: before any directory is read
899
+ * or emptied, {@link isPlainDirectoryChain} re-walks it segment by segment, so
900
+ * an ancestor swapped for a symlink after the file itself was removed is left
901
+ * alone rather than read or emptied through.
902
+ */
903
+ async function removeEmptyParents(repoDir, rel) {
904
+ let dir = path.posix.dirname(rel.split(path.sep).join('/'));
905
+ while (dir !== '.' && dir !== '' && dir !== RIG_DIR) {
906
+ if (!(await isPlainDirectoryChain(repoDir, dir)))
907
+ return;
908
+ const abs = path.join(repoDir, ...dir.split('/'));
909
+ let entries;
910
+ try {
911
+ entries = await readdir(abs);
912
+ }
913
+ catch {
914
+ return;
915
+ }
916
+ if (entries.length > 0)
917
+ return;
918
+ try {
919
+ await rmdir(abs);
920
+ }
921
+ catch {
922
+ return;
923
+ }
924
+ dir = path.posix.dirname(dir);
925
+ }
926
+ }
927
+ /**
928
+ * `null` when the manifest still matches `expectedHash` exactly; otherwise the
929
+ * one sentence explaining why it does not — gone, behind a symlinked ancestor
930
+ * (or another non-regular entry), or simply different bytes now. Never
931
+ * throws: a symlinked ancestor is exactly one of the reasons this reports
932
+ * rather than the caller having to catch {@link UninstallError} itself.
933
+ */
934
+ async function manifestMismatchReason(repoDir, expectedHash) {
935
+ let raw;
936
+ try {
937
+ raw = await readManifestBytes(repoDir);
938
+ }
939
+ catch (error) {
940
+ return error.message;
941
+ }
942
+ if (raw === null)
943
+ return `"${MANIFEST_REL}" is gone — changed since planning`;
944
+ if (sha256(raw) !== expectedHash) {
945
+ return `"${MANIFEST_REL}" changed since planning — its bytes no longer match the plan`;
946
+ }
947
+ return null;
948
+ }
949
+ /**
950
+ * Removes the `remove`-verdict paths in `plan`, then the manifest — but only
951
+ * once every one of them succeeded AND (outside `--detach`) nothing else in
952
+ * the plan is `preserved`, nor turned out to have changed since planning. A
953
+ * `preserved` action, or a path caught changed at apply time, means the rig
954
+ * still owns bytes it did not remove; deleting the manifest anyway would
955
+ * discard the only evidence naming what it still owns, blinding a later
956
+ * `upgrade` — unless `options.detach` says to do exactly that on purpose,
957
+ * leaving those paths for the user instead.
958
+ *
959
+ * Two kinds of "this is not the plan I made" are both re-checked here, never
960
+ * trusted from `plan`, because the window between the plan being shown and
961
+ * this call — a confirmation prompt sits in it — is exactly where either can
962
+ * happen: the manifest's own bytes ({@link manifestMismatchReason}, checked
963
+ * once before the first removal and again immediately before the manifest's
964
+ * own deletion) and each `remove`-verdict file's own bytes (checked
965
+ * immediately before its removal, via `recordedHash`). A symlink appearing
966
+ * where a plain file was planned is a THIRD kind, and gets a different
967
+ * response on purpose: it aborts the whole run rather than skipping one path,
968
+ * because it is the one shape suspicious enough that continuing is the wrong
969
+ * default.
970
+ */
971
+ export async function applyUninstall(repoDir, plan, options = {}) {
972
+ if (plan.noManifest) {
973
+ // `outcome` names one of three end states a REAL run reached; `--dry-run`
974
+ // never reaches one, including here — "nothing installed" is only an end
975
+ // state once a real run has (not) acted on it. See `UninstallOutcome`.
976
+ return options.dryRun === true
977
+ ? { removed: [], manifestRemoved: false }
978
+ : { removed: [], manifestRemoved: false, outcome: 'uninstalled' };
979
+ }
980
+ const toRemove = plan.actions.filter((a) => a.verdict === 'remove');
981
+ if (options.dryRun === true)
982
+ return { removed: [], manifestRemoved: false };
983
+ const detach = options.detach === true;
984
+ // Whether this run, absent any hard failure, would go on to delete the
985
+ // manifest: always true under `--detach` (that is the point of it), and
986
+ // otherwise only when nothing in the plan is `preserved`. A `changed since
987
+ // planning` discovery below can still turn this off for an ordinary run —
988
+ // detach is the only thing that overrides it.
989
+ const wouldDeleteManifest = detach || !plan.actions.some((a) => a.verdict === 'preserved');
990
+ // Checkpoint 1: the manifest itself, before anything is touched at all. A
991
+ // plan built from bytes that no longer exist is not evidence for what
992
+ // follows, so nothing is removed — not even the files a fresh plan would
993
+ // still agree to remove.
994
+ if (plan.manifestHash !== null) {
995
+ const mismatch = await manifestMismatchReason(repoDir, plan.manifestHash);
996
+ if (mismatch !== null) {
997
+ return {
998
+ removed: [],
999
+ manifestRemoved: false,
1000
+ completed: [],
1001
+ remaining: [...toRemove.map((a) => a.rel), ...(wouldDeleteManifest ? [MANIFEST_REL] : [])],
1002
+ error: `Refusing to apply a stale plan: ${mismatch}`,
1003
+ };
1004
+ }
1005
+ }
1006
+ // Re-derived here, not trusted from the plan's own (already-baked-in)
1007
+ // per-file verdicts: a wiring file that was still pristine when
1008
+ // `planUninstall` ran can have been edited, or replaced with a symlink, in
1009
+ // the window since — the confirmation prompt sits in exactly that window —
1010
+ // and hook files sort ahead of the wiring that references them, so the
1011
+ // removal loop below would otherwise unlink them before ever re-examining
1012
+ // it. `toRemove` is where a wiring path's recorded hash comes from here:
1013
+ // present only when the PLAN called it `remove` (pristine, `files`-tracked,
1014
+ // at plan time), which is exactly the case this re-check exists to catch.
1015
+ // A `kept` wiring path is never in `toRemove` — `planUninstall`'s own
1016
+ // `kept` loop always calls it `preserved`, unconditionally, so its hooks
1017
+ // were already protected by the PLAN-TIME call to `protectedHooksFor`
1018
+ // above, and this second pass has nothing further to do on its account.
1019
+ //
1020
+ // `wiringBytes` is deliberately NOT taken from this call: it is the read
1021
+ // this pass itself performs of a wiring file's bytes, at the SAME
1022
+ // too-early point in time the comment below explains — reusing it at the
1023
+ // removal site would just reintroduce the bug this apply-time re-check
1024
+ // exists to close.
1025
+ const ownedPaths = await rigOwnedPaths();
1026
+ const applyTimeTrackingFor = (wiringRel) => {
1027
+ const recordedHash = toRemove.find((a) => a.rel === wiringRel)?.recordedHash;
1028
+ return recordedHash === undefined
1029
+ ? { tracked: false }
1030
+ : { tracked: true, alwaysPreserved: false, recordedHash };
1031
+ };
1032
+ const { protectedHooks: applyTimeProtectedHooks, importedBy: applyTimeImportedBy, unverified: applyTimeUnverified, wiringKind: applyTimeWiringKind, } = await protectedHooksFor(repoDir, ownedPaths, applyTimeTrackingFor);
1033
+ // ⚠ Boundary that `index.ts`'s own outer try/catch around this whole
1034
+ // function relies on: nothing above this line has removed anything, and
1035
+ // nothing above this line is inside a try/catch of its own — an exception
1036
+ // from `rigOwnedPaths()` or the apply-time `protectedHooksFor` pass above
1037
+ // propagates uncaught, and the caller is entitled to assume `removed: []`
1038
+ // when that happens. From here down, every per-file removal is wrapped in
1039
+ // ITS OWN try/catch (immediately below) and this function always RETURNS a
1040
+ // result — real `removed` so far included — rather than throwing. Adding a
1041
+ // throwing call inside or after this loop without also updating it to
1042
+ // return, not throw, would make that caller's `removed: []` a lie.
1043
+ const removeFile = options.removeFile ?? ((absolutePath) => unlink(absolutePath));
1044
+ const removed = [];
1045
+ const changedSincePlanning = [];
1046
+ const protectedHooksAtApply = [];
1047
+ for (let i = 0; i < toRemove.length; i++) {
1048
+ const { rel, recordedHash } = toRemove[i];
1049
+ const protectingWiring = applyTimeProtectedHooks.get(rel);
1050
+ if (protectingWiring !== undefined) {
1051
+ // Discovered only now: the plan said `remove`, but the wiring file
1052
+ // that still calls this hook has since been edited or become a
1053
+ // symlink. Skipped, never removed, reported loudly — this is not a
1054
+ // silent exit 0. Never `'kept'` here specifically: `applyTimeTrackingFor`
1055
+ // above only ever tracks a path that was a plan-time `remove` verdict,
1056
+ // and a `kept` path is never one — ordinarily its hooks were already
1057
+ // protected by the PLAN-TIME call, using the manifest's OWN `kept`
1058
+ // membership, which this re-check does not consult at all. ⚠ Not an
1059
+ // invariant in the one case this re-check exists FOR, though: a `kept`
1060
+ // wiring path that was genuinely ABSENT at plan time (nothing to read,
1061
+ // so the plan-time pass protected nothing on its account) and then
1062
+ // reappears, rewired, in the confirmation-prompt window is exactly the
1063
+ // "changed since planning" shape this whole apply-time pass was built
1064
+ // to catch — and it is not caught here, because this pass is keyed off
1065
+ // `toRemove`, which a `kept` path is never in. Narrow, and no worse
1066
+ // than the pre-existing state (a hook this rig never removes anyway
1067
+ // stays exactly as absent-or-present as it already was), but stated
1068
+ // here rather than left implied.
1069
+ //
1070
+ // ⚠ A second, sibling gap, this one for an EDITED (not `kept`) wiring
1071
+ // file specifically (security-lens review, RP-181, cycle 8): if a
1072
+ // hook a preserved-as-edited wiring file names is genuinely ABSENT at
1073
+ // plan time, its single-seeded dependency is never swept (correctly — an absent
1074
+ // file has nothing to protect a dependency on behalf of), and that
1075
+ // dependency gets an ordinary `remove` verdict. If the hook then
1076
+ // REAPPEARS — as a symlink, or as a legitimate working file that
1077
+ // needs it — in the confirmation-prompt window before apply, nothing
1078
+ // re-examines it: this whole pass only re-derives protection for a
1079
+ // wiring path that was itself a plan-time `remove` verdict, and an
1080
+ // EDITED wiring file never is one. The dependency is removed on
1081
+ // schedule. Judged materially weaker than the hole this apply-time
1082
+ // re-check exists to close (that one needed only a symlink committed
1083
+ // and surviving `git clone`; this one needs WRITE ACCESS to the
1084
+ // working tree in the narrow window between the plan being shown and
1085
+ // `--yes` being answered) and left as a documented limitation rather
1086
+ // than grown into this change: closing it would mean re-deriving
1087
+ // apply-time protection for every wiring path unconditionally, not
1088
+ // only ones already known to be plan-time `remove` verdicts, which is
1089
+ // a wider change than this cycle's fix earns. The same end state also
1090
+ // needs no window at all — hand-delete the hook, uninstall, restore the
1091
+ // hook from git — and `docs/command-contract.md` says so beside this
1092
+ // limitation rather than letting "needs write access" read as its bound.
1093
+ const importer = applyTimeImportedBy.get(rel);
1094
+ const unverifiedBecause = applyTimeUnverified.get(rel);
1095
+ protectedHooksAtApply.push({
1096
+ rel,
1097
+ wiringRel: protectingWiring,
1098
+ // `applyTimeWiringKind` is set for `protectingWiring` in the SAME
1099
+ // iteration of `protectedHooksFor` that populated
1100
+ // `applyTimeProtectedHooks` with `rel` — never independently, so
1101
+ // this can only be `undefined` if the two maps disagreed with each
1102
+ // other, which would itself be the bug to fix (the same reasoning
1103
+ // as the plan-time assertion above).
1104
+ wiringKind: applyTimeWiringKind.get(protectingWiring),
1105
+ ...(importer !== undefined ? { importedBy: importer } : {}),
1106
+ ...(unverifiedBecause !== undefined ? { unverifiedBecause } : {}),
1107
+ });
1108
+ continue;
1109
+ }
1110
+ try {
1111
+ // Re-checked here, not trusted from the plan: the plan can be stale by
1112
+ // the time this runs, and a symlink swapped in after planning is
1113
+ // exactly the case the plan-time check cannot see.
1114
+ const status = await regularFileStatus(repoDir, rel);
1115
+ if (status !== 'ok') {
1116
+ throw new Error(`refusing to remove "${rel}": it is no longer a plain file inside the repository ` +
1117
+ '(a symlink appeared since planning)');
1118
+ }
1119
+ // Content re-checked too, not only the file's TYPE: the symlink check
1120
+ // above cannot see a plain edit, and the confirmation prompt between
1121
+ // the plan and this call is exactly the window one could happen in. A
1122
+ // mismatch is not suspicious the way a symlink is — it is skipped, not
1123
+ // aborted, and the run keeps going.
1124
+ //
1125
+ // Read FRESH here, never from `wiringBytes` — that cache was filled by
1126
+ // `protectedHooksFor` BEFORE this removal loop started, so for
1127
+ // `.claude/settings.json` / `.codex/hooks.json` it is not "immediately
1128
+ // before removal" the way `UninstallAction.recordedHash`'s own contract
1129
+ // promises: it is the whole loop above this file's turn (on the order
1130
+ // of a hundred unlinks plus directory cleanups). An edit landing in
1131
+ // that span would be missed and the modified wiring file deleted —
1132
+ // exactly what this re-check, and `changedSincePlanning`, exist to
1133
+ // prevent. (`wiringBytes` is still used at plan time, in `planUninstall`
1134
+ // above — there it IS the immediate read, since nothing runs between it
1135
+ // and that file's own verdict.)
1136
+ if (recordedHash !== undefined) {
1137
+ const current = await readFile(onDisk(repoDir, rel));
1138
+ if (sha256(current) !== recordedHash) {
1139
+ changedSincePlanning.push(rel);
1140
+ continue;
1141
+ }
1142
+ }
1143
+ await removeFile(onDisk(repoDir, rel));
1144
+ }
1145
+ catch (error) {
1146
+ const stillOwed = toRemove.slice(i).map((a) => a.rel);
1147
+ return {
1148
+ removed,
1149
+ manifestRemoved: false,
1150
+ completed: [...removed],
1151
+ remaining: wouldDeleteManifest ? [...stillOwed, MANIFEST_REL] : stillOwed,
1152
+ error: error.message,
1153
+ ...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
1154
+ ...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
1155
+ };
1156
+ }
1157
+ removed.push(rel);
1158
+ await removeEmptyParents(repoDir, rel);
1159
+ }
1160
+ const stillPreserved = !wouldDeleteManifest || changedSincePlanning.length > 0 || protectedHooksAtApply.length > 0;
1161
+ // Something is still preserved and this is not a detach — the rig remains
1162
+ // installed, on purpose. The manifest is the only record naming what it
1163
+ // still owns, so it is kept even though every removal that WAS planned
1164
+ // (and still matched its recorded hash) just succeeded.
1165
+ if (!detach && stillPreserved) {
1166
+ return {
1167
+ removed,
1168
+ manifestRemoved: false,
1169
+ outcome: 'partial',
1170
+ ...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
1171
+ ...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
1172
+ };
1173
+ }
1174
+ // Checkpoint 2: the manifest again, immediately before deleting it — the
1175
+ // window every removal above could have used. Re-checked the same
1176
+ // symlink-safe, content-verified way as checkpoint 1, not trusted from the
1177
+ // first check: this narrows the window a swap can exploit; it does not
1178
+ // close it entirely — a concurrent swap in the instant between THIS check
1179
+ // and the `unlink` call below is a residual race no check-then-act sequence
1180
+ // over the filesystem can rule out (docs/command-contract.md, "## uninstall
1181
+ // (RP-181)"). On a mismatch here the manifest is kept, never deleted, and
1182
+ // the result is an honest partial one: `completed` names every file that
1183
+ // really was removed, `remaining` names only the manifest.
1184
+ if (plan.manifestHash !== null) {
1185
+ const mismatch = await manifestMismatchReason(repoDir, plan.manifestHash);
1186
+ if (mismatch !== null) {
1187
+ return {
1188
+ removed,
1189
+ manifestRemoved: false,
1190
+ completed: [...removed],
1191
+ remaining: [MANIFEST_REL],
1192
+ error: mismatch,
1193
+ ...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
1194
+ ...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
1195
+ };
1196
+ }
1197
+ }
1198
+ try {
1199
+ await unlink(onDisk(repoDir, MANIFEST_REL));
1200
+ }
1201
+ catch (error) {
1202
+ return {
1203
+ removed,
1204
+ manifestRemoved: false,
1205
+ completed: [...removed],
1206
+ remaining: [MANIFEST_REL],
1207
+ error: error.message,
1208
+ ...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
1209
+ ...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
1210
+ };
1211
+ }
1212
+ // The manifest is often the last file left in `.claude/` — its own removal
1213
+ // is what can finally empty that directory, so the same cleanup runs again
1214
+ // for it.
1215
+ await removeEmptyParents(repoDir, MANIFEST_REL);
1216
+ return {
1217
+ removed,
1218
+ manifestRemoved: true,
1219
+ outcome: detach ? 'detached' : 'uninstalled',
1220
+ ...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
1221
+ ...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
1222
+ };
1223
+ }