claude-dev-env 2.4.0 → 2.5.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 (195) hide show
  1. package/CLAUDE.md +53 -49
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +2 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +0 -1
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/test_agent_frontmatter.py +67 -18
  31. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  32. package/bin/CLAUDE.md +68 -5
  33. package/bin/ever-shipped-skills.mjs +1 -0
  34. package/bin/install-constants.mjs +88 -0
  35. package/bin/install.mjs +1138 -114
  36. package/bin/install.prune.test.mjs +869 -19
  37. package/bin/install.test.mjs +906 -2
  38. package/commands/implement.md +1 -1
  39. package/commands/right-size.md +1 -1
  40. package/docs/CLAUDE.md +1 -0
  41. package/docs/host-pool-health-monitor.md +102 -0
  42. package/docs/references/CLAUDE.md +4 -2
  43. package/docs/references/advisor-tool.md +13 -0
  44. package/docs/references/code-review-enforcement.md +10 -0
  45. package/docs/references/team-advisor-skill.md +14 -0
  46. package/hooks/blocking/CLAUDE.md +1 -0
  47. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  48. package/hooks/blocking/code_review_push_gate.py +9 -4
  49. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  50. package/hooks/blocking/config/__init__.py +5 -5
  51. package/hooks/blocking/config/code_review_enforcement_constants.py +4 -1
  52. package/hooks/blocking/config/test_code_review_enforcement_constants.py +5 -0
  53. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  54. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  55. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  56. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  57. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  58. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  59. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  60. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  61. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  62. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  63. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  64. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  65. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  66. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  67. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  68. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  69. package/hooks/git-hooks/test_pre_push.py +25 -0
  70. package/hooks/hooks.json +10 -0
  71. package/hooks/hooks_constants/CLAUDE.md +2 -1
  72. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  73. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  74. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  75. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  76. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  77. package/hooks/pyproject.toml +1 -0
  78. package/hooks/validators/CLAUDE.md +1 -0
  79. package/hooks/validators/config/__init__.py +0 -0
  80. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  81. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  82. package/hooks/validators/conftest.py +4 -0
  83. package/hooks/validators/ruff_integration.py +49 -5
  84. package/hooks/validators/run_all_validators.py +206 -9
  85. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  86. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  87. package/hooks/validators/test_ruff_integration.py +92 -1
  88. package/hooks/validators/test_run_all_validators.py +115 -68
  89. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  90. package/package.json +10 -2
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +45 -44
  93. package/rules/state-what-is.md +25 -0
  94. package/rules/verified-commit-gate-skip.md +1 -1
  95. package/scripts/CLAUDE.md +1 -0
  96. package/scripts/Capture-PoolHealth.ps1 +410 -0
  97. package/scripts/_code_review_test_support.py +404 -0
  98. package/scripts/claude_chain_runner.py +141 -1
  99. package/scripts/conftest.py +16 -1
  100. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  101. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  102. package/scripts/resolve_worker_spawn.py +626 -626
  103. package/scripts/spawn_grok_batch.py +672 -672
  104. package/scripts/test_claude_chain_runner.py +131 -0
  105. package/scripts/test_invoke_code_review_chain.py +70 -0
  106. package/scripts/test_invoke_code_review_cli.py +192 -0
  107. package/scripts/test_invoke_code_review_contract.py +256 -0
  108. package/scripts/test_invoke_code_review_git.py +123 -0
  109. package/scripts/test_invoke_code_review_mode.py +99 -0
  110. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  111. package/skills/CLAUDE.md +2 -0
  112. package/skills/auditing-claude-config/SKILL.md +114 -114
  113. package/skills/autoconverge/SKILL.md +427 -427
  114. package/skills/autoconverge/reference/convergence.md +24 -3
  115. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  116. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  117. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  118. package/skills/autoconverge/workflow/converge.mjs +167 -0
  119. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  120. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  121. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  122. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  123. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  124. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  125. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  126. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  127. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  128. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  129. package/skills/closeout/SKILL.md +33 -50
  130. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  131. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  132. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  133. package/skills/condensing-instructions/SKILL.md +81 -0
  134. package/skills/copilot-review/SKILL.md +119 -119
  135. package/skills/e-code-review/SKILL.md +52 -0
  136. package/skills/e-code-review/reference/fix.md +54 -0
  137. package/skills/e-code-review/reference/loop.md +43 -0
  138. package/skills/e-code-review/reference/low.md +57 -0
  139. package/skills/e-code-review/reference/medium.md +153 -0
  140. package/skills/e-code-review/reference/xhigh.md +182 -0
  141. package/skills/e-simplify/SKILL.md +97 -0
  142. package/skills/issue-tracker/SKILL.md +92 -0
  143. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  144. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  145. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  146. package/skills/orchestrator/SKILL.md +162 -21
  147. package/skills/orchestrator/scripts/status_gate.py +625 -0
  148. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  149. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  150. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  151. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  152. package/skills/orchestrator-refresh/SKILL.md +110 -35
  153. package/skills/plan-to-pr/SKILL.md +155 -0
  154. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  155. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  156. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  157. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  158. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  159. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  160. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  161. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  162. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  163. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  164. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  165. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  166. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  167. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  168. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  169. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  170. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  171. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  172. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  173. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  174. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  175. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  176. package/skills/pr-converge/SKILL.md +472 -469
  177. package/skills/pr-converge/reference/examples.md +3 -3
  178. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  179. package/skills/pr-converge/reference/ground-rules.md +7 -4
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  181. package/skills/pr-converge/reference/per-tick.md +5 -5
  182. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  183. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  184. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  185. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  186. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  187. package/skills/pr-fix-protocol/SKILL.md +1 -1
  188. package/skills/privacy-hygiene/SKILL.md +68 -68
  189. package/skills/prototype/workflows/promotion.md +1 -1
  190. package/skills/release-notes-html/SKILL.md +164 -0
  191. package/skills/task-build/CLAUDE.md +8 -7
  192. package/skills/task-build/SKILL.md +16 -8
  193. package/skills/task-build/reference/tool-routing.md +19 -0
  194. package/scripts/test_invoke_code_review.py +0 -966
  195. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -1,6 +1,6 @@
1
1
  import { test, after } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { execFileSync } from 'node:child_process';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
4
  import {
5
5
  mkdtempSync,
6
6
  mkdirSync,
@@ -31,8 +31,37 @@ const RETIRED_SKILL_DIRECTORIES = [
31
31
  const PERSONAL_SKILL_DIRECTORIES = ['balatro', 'midjourney-sref', 'credit-card-picker'];
32
32
  const SHIPPED_SKILL_DIRECTORY = 'autoconverge';
33
33
  const PRUNED_BACKUP_DIRECTORY_NAME = '.claude-dev-env-pruned';
34
- const SKIP_PRUNE_NOTICE_MARKER = 'Skipping retired-skill prune';
34
+ const SKIP_PRUNE_NOTICE_MARKER = 'Skipping retired-skill and stale-file prune';
35
+ const STALE_SKILL_FILE_RELATIVE_SEGMENTS = ['scripts', 'retired_module.py'];
36
+ const RUNTIME_ARTIFACT_RELATIVE_SEGMENTS = ['scripts', '__pycache__', 'helper.cpython-312.pyc'];
35
37
  const DEPENDENCY_STUB_PACKAGE_SEGMENTS = ['@jl-cmd', 'prompt-generator'];
38
+ const DEPENDENCY_SUPPLIED_SKILL_DIRECTORY = 'dependency-supplied-skill';
39
+ const SCOPED_GROUP_SKILL_DIRECTORY = 'orchestrator';
40
+ const PRIOR_RUN_BACKUP_DIRECTORY_NAMES = [
41
+ '2020-01-01T00-00-00-000Z',
42
+ '2021-06-15T12-30-45-123Z',
43
+ '2022-11-30T23-59-59-999Z',
44
+ ];
45
+ const SKILLS_DIRECTORY_NAME = 'skills';
46
+ const HOOKS_DIRECTORY_NAME = 'hooks';
47
+ const SETTINGS_FILE_NAME = 'settings.json';
48
+ const MANIFEST_FILE_NAME = '.claude-dev-env-manifest.json';
49
+ const CLAUDE_HUB_FILE_NAME = 'CLAUDE.md';
50
+ const RULES_DIRECTORY_NAME = 'rules';
51
+ const SHARED_DIRECTORY_NAME = '_shared';
52
+ const STALE_RULE_FILE_NAME = 'retired-rule.md';
53
+ const STALE_SHARED_FILE_SEGMENTS = ['pr-loop', 'retired_shared.md'];
54
+ const STALE_SHARED_SKILL_FILE_SEGMENTS = [SHARED_DIRECTORY_NAME, 'retired_skill_shared.md'];
55
+ const RETIRED_HOOK_RELATIVE_SEGMENTS = ['blocking', 'retired_gate.py'];
56
+ const USER_HOOK_COMMAND = 'python3 my_own_gate.py --user-authored';
57
+ const RETIRED_HOOK_EVENT_TYPE = 'PreToolUse';
58
+ const DROPPED_HOOK_EVENT_TYPE = 'PreCompact';
59
+ const RETIRED_HOOK_MATCHER = 'Write|Edit|MultiEdit';
60
+ const UNMANAGED_SIBLING_DIRECTORY = 'my-notes';
61
+ const NESTED_SKILL_DIRECTORY = 'foo';
62
+ const NESTED_SKILL_FILE_SEGMENTS = [NESTED_SKILL_DIRECTORY, 'scripts', 'a.py'];
63
+ const MYPY_INI_FILE_NAME = '.mypy.ini';
64
+ const SKIPPED_RECORD_SUMMARY_MARKER = 'manifest record(s) skipped';
36
65
 
37
66
  /**
38
67
  * Create a stub node_modules tree the installer can resolve the declared
@@ -99,18 +128,33 @@ function ensureDependencyStub(homeDirectory) {
99
128
  }
100
129
 
101
130
  /**
102
- * Report whether a retired skill directory landed under the prune backup root.
131
+ * Report whether a path under one managed root landed in a run backup.
132
+ *
133
+ * Each run backup mirrors ~/.claude, so a moved skill directory and a moved skill
134
+ * file both sit under `<timestamp>/skills/`, and content from another managed root
135
+ * sits under that root's own name.
103
136
  *
104
137
  * @param {string} claudeDirectory The sandbox ~/.claude directory.
105
- * @param {string} skillName The retired skill directory name to look for.
106
- * @returns {boolean} True when a timestamped backup holds the skill directory.
138
+ * @param {string} rootRelativePath The path to look for, relative to ~/.claude.
139
+ * @returns {boolean} True when a timestamped backup holds the path.
107
140
  */
108
- function prunedBackupContains(claudeDirectory, skillName) {
141
+ function prunedBackupContains(claudeDirectory, rootRelativePath) {
109
142
  const backupRoot = join(claudeDirectory, PRUNED_BACKUP_DIRECTORY_NAME);
110
143
  if (!existsSync(backupRoot)) return false;
111
144
  return readdirSync(backupRoot, { withFileTypes: true })
112
145
  .filter(entry => entry.isDirectory())
113
- .some(timestampDir => existsSync(join(backupRoot, timestampDir.name, skillName)));
146
+ .some(timestampDir => existsSync(join(backupRoot, timestampDir.name, rootRelativePath)));
147
+ }
148
+
149
+ /**
150
+ * Report whether a skill path landed in a run backup under the skills root.
151
+ *
152
+ * @param {string} claudeDirectory The sandbox ~/.claude directory.
153
+ * @param {string} skillsRelativePath The path to look for, relative to ~/.claude/skills.
154
+ * @returns {boolean} True when a timestamped backup holds the path.
155
+ */
156
+ function prunedSkillBackupContains(claudeDirectory, skillsRelativePath) {
157
+ return prunedBackupContains(claudeDirectory, join(SKILLS_DIRECTORY_NAME, skillsRelativePath));
114
158
  }
115
159
 
116
160
  /**
@@ -165,6 +209,22 @@ function plantSkillDirectory(skillsDirectory, skillName, withSkillManifest) {
165
209
  * @returns {string} The installer's stdout.
166
210
  */
167
211
  function runInstaller(homeDirectory, extraArguments, options = {}) {
212
+ const { installerPath, childEnvironment } = resolveInstallerInvocation(homeDirectory, options);
213
+ return execFileSync('node', [installerPath, ...extraArguments], {
214
+ cwd: dirname(installerPath),
215
+ encoding: 'utf8',
216
+ env: childEnvironment,
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Build the installer path and child environment one sandbox run uses.
222
+ *
223
+ * @param {string} homeDirectory The sandbox home the installer writes into.
224
+ * @param {{dependencyResolvable?: boolean}} options Whether the dependency resolves.
225
+ * @returns {{installerPath: string, childEnvironment: object}} The invocation inputs.
226
+ */
227
+ function resolveInstallerInvocation(homeDirectory, options) {
168
228
  const dependencyResolvable = options.dependencyResolvable !== false;
169
229
  const childEnvironment = {
170
230
  ...process.env,
@@ -172,25 +232,302 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
172
232
  USERPROFILE: homeDirectory,
173
233
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
174
234
  };
175
- let installerPath;
176
235
  if (dependencyResolvable) {
177
236
  childEnvironment.NODE_PATH = ensureDependencyStub(homeDirectory);
178
- installerPath = INSTALLER_PATH;
179
- } else {
180
- delete childEnvironment.NODE_PATH;
181
- installerPath = ensureIsolatedInstallerPath();
237
+ return { installerPath: INSTALLER_PATH, childEnvironment };
182
238
  }
183
- return execFileSync('node', [installerPath, ...extraArguments], {
239
+ delete childEnvironment.NODE_PATH;
240
+ return { installerPath: ensureIsolatedInstallerPath(), childEnvironment };
241
+ }
242
+
243
+ /**
244
+ * Run the real installer against the sandbox home and return both output streams.
245
+ *
246
+ * A warning the installer prints reaches stderr, so a test asserting that a run
247
+ * warns about nothing reads the two streams together.
248
+ *
249
+ * @param {string} homeDirectory The sandbox home the installer writes into.
250
+ * @param {string[]} extraArguments Installer arguments (for example ``['--uninstall']``).
251
+ * @returns {string} The child's stdout and stderr, joined.
252
+ */
253
+ function runInstallerReadingBothStreams(homeDirectory, extraArguments) {
254
+ const { installerPath, childEnvironment } = resolveInstallerInvocation(homeDirectory, {});
255
+ const completedRun = spawnSync('node', [installerPath, ...extraArguments], {
184
256
  cwd: dirname(installerPath),
185
257
  encoding: 'utf8',
186
258
  env: childEnvironment,
187
259
  });
260
+ assert.equal(completedRun.status, 0, `the installer run exited with ${completedRun.status}`);
261
+ return `${completedRun.stdout}${completedRun.stderr}`;
188
262
  }
189
263
 
190
264
  function readManifest(manifestPath) {
191
265
  return JSON.parse(readFileSync(manifestPath, 'utf8'));
192
266
  }
193
267
 
268
+ /**
269
+ * Write a file, creating the directories leading to it.
270
+ *
271
+ * @param {string} filePath The absolute file path to write.
272
+ * @param {string} contents The file contents.
273
+ */
274
+ function writeFileWithParents(filePath, contents) {
275
+ mkdirSync(dirname(filePath), { recursive: true });
276
+ writeFileSync(filePath, contents);
277
+ }
278
+
279
+ /**
280
+ * Seed a shipped skill with a stale file the prior manifest records and a runtime
281
+ * artifact it does not, so one run exercises both sides of the manifest diff.
282
+ *
283
+ * @param {{skillsDirectory: string, manifestPath: string}} sandbox The sandbox paths.
284
+ * @returns {{staleFilePath: string, runtimeArtifactPath: string}} The seeded paths.
285
+ */
286
+ function seedStaleSkillFile(sandbox) {
287
+ const staleFilePath = join(
288
+ sandbox.skillsDirectory, SHIPPED_SKILL_DIRECTORY, ...STALE_SKILL_FILE_RELATIVE_SEGMENTS,
289
+ );
290
+ const runtimeArtifactPath = join(
291
+ sandbox.skillsDirectory, SHIPPED_SKILL_DIRECTORY, ...RUNTIME_ARTIFACT_RELATIVE_SEGMENTS,
292
+ );
293
+ writeFileWithParents(staleFilePath, 'a module an earlier revision shipped\n');
294
+ writeFileWithParents(runtimeArtifactPath, 'compiled bytecode no install ever wrote\n');
295
+ const manifest = readManifest(sandbox.manifestPath);
296
+ manifest.files.push(staleFilePath);
297
+ writeFileSync(sandbox.manifestPath, JSON.stringify(manifest, null, 2) + '\n');
298
+ return { staleFilePath, runtimeArtifactPath };
299
+ }
300
+
301
+ /**
302
+ * Plant one run backup directory per name an earlier install would have written,
303
+ * each holding a marker file.
304
+ *
305
+ * @param {string} claudeDirectory The sandbox ~/.claude directory.
306
+ * @returns {string} The pruned-backup directory the planted runs sit under.
307
+ */
308
+ function plantPriorRunBackups(claudeDirectory) {
309
+ const prunedBackupDirectory = join(claudeDirectory, PRUNED_BACKUP_DIRECTORY_NAME);
310
+ for (const runDirectoryName of PRIOR_RUN_BACKUP_DIRECTORY_NAMES) {
311
+ const runDirectory = join(prunedBackupDirectory, runDirectoryName);
312
+ mkdirSync(runDirectory, { recursive: true });
313
+ writeFileSync(join(runDirectory, 'recovered.md'), `content ${runDirectoryName} holds\n`);
314
+ }
315
+ return prunedBackupDirectory;
316
+ }
317
+
318
+ /**
319
+ * List the run backup directory names sitting under the pruned-backup directory.
320
+ *
321
+ * @param {string} claudeDirectory The sandbox ~/.claude directory.
322
+ * @returns {string[]} The directory names, in readdir order.
323
+ */
324
+ function listRunBackupDirectoryNames(claudeDirectory) {
325
+ const prunedBackupDirectory = join(claudeDirectory, PRUNED_BACKUP_DIRECTORY_NAME);
326
+ if (!existsSync(prunedBackupDirectory)) return [];
327
+ return readdirSync(prunedBackupDirectory, { withFileTypes: true })
328
+ .filter(entry => entry.isDirectory())
329
+ .map(entry => entry.name);
330
+ }
331
+
332
+ /**
333
+ * Append absolute paths to the sandbox manifest's file list.
334
+ *
335
+ * @param {string} manifestPath The sandbox manifest path.
336
+ * @param {string[]} recordedPaths The absolute paths to record.
337
+ */
338
+ function appendManifestFiles(manifestPath, recordedPaths) {
339
+ const manifest = readManifest(manifestPath);
340
+ manifest.files.push(...recordedPaths);
341
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
342
+ }
343
+
344
+ /**
345
+ * Build an absolute path under this platform's system root that no test creates.
346
+ *
347
+ * The path names a real, protected location so the containment guard is
348
+ * exercised against the shape a malformed record takes on this host. Nothing
349
+ * writes it and nothing removes it: the guard skips the record before the
350
+ * uninstall reaches any filesystem call.
351
+ *
352
+ * @returns {string} The absolute system-root path to record.
353
+ */
354
+ function systemRootPathNeverWritten() {
355
+ const windowsSystemRoot = process.env.SystemRoot || 'C:\\Windows';
356
+ return process.platform === 'win32'
357
+ ? join(windowsSystemRoot, 'claude-dev-env-never-written.txt')
358
+ : '/etc/claude-dev-env-never-written.txt';
359
+ }
360
+
361
+ test('a full reinstall moves a manifest-recorded skill file the package no longer ships and keeps runtime artifacts', () => {
362
+ const sandbox = createSandbox();
363
+ try {
364
+ runInstaller(sandbox.homeDirectory, []);
365
+ const { staleFilePath, runtimeArtifactPath } = seedStaleSkillFile(sandbox);
366
+
367
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
368
+
369
+ assert.equal(
370
+ existsSync(staleFilePath),
371
+ false,
372
+ 'the file the prior manifest recorded and this run no longer writes leaves the skill',
373
+ );
374
+ assert.equal(
375
+ prunedSkillBackupContains(
376
+ sandbox.claudeDirectory,
377
+ join(SHIPPED_SKILL_DIRECTORY, ...STALE_SKILL_FILE_RELATIVE_SEGMENTS),
378
+ ),
379
+ true,
380
+ 'the stale file lands under its mirrored relative path in the prune backup',
381
+ );
382
+ assert.equal(
383
+ existsSync(runtimeArtifactPath),
384
+ true,
385
+ 'a __pycache__ artifact no install ever wrote stays in place',
386
+ );
387
+ assert.match(
388
+ installerOutput,
389
+ /skills: \d+ files \(\d+ new, \d+ updated, 1 stale moved aside\)/,
390
+ 'the summary line reports the stale count',
391
+ );
392
+ } finally {
393
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
394
+ }
395
+ });
396
+
397
+ test('a stale file whose move fails stays on the manifest record for the next run to retry', () => {
398
+ const sandbox = createSandbox();
399
+ try {
400
+ runInstaller(sandbox.homeDirectory, []);
401
+ const { staleFilePath } = seedStaleSkillFile(sandbox);
402
+ writeFileSync(
403
+ join(sandbox.claudeDirectory, PRUNED_BACKUP_DIRECTORY_NAME),
404
+ 'a file standing where the backup root belongs\n',
405
+ );
406
+
407
+ runInstaller(sandbox.homeDirectory, []);
408
+
409
+ assert.equal(existsSync(staleFilePath), true, 'the failed move leaves the file in the skill');
410
+ const recordedFiles = readManifest(sandbox.manifestPath).files;
411
+ assert.equal(
412
+ recordedFiles.filter(recordedPath => recordedPath === staleFilePath).length,
413
+ 1,
414
+ 'the fresh manifest carries the failed path exactly once',
415
+ );
416
+ } finally {
417
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
418
+ }
419
+ });
420
+
421
+ test('a scoped --only install leaves a manifest-recorded stale skill file in place', () => {
422
+ const sandbox = createSandbox();
423
+ try {
424
+ runInstaller(sandbox.homeDirectory, []);
425
+ const { staleFilePath } = seedStaleSkillFile(sandbox);
426
+
427
+ runInstaller(sandbox.homeDirectory, ['--only', 'core']);
428
+
429
+ assert.equal(
430
+ existsSync(staleFilePath),
431
+ true,
432
+ 'the stale-file prune runs on full installs only',
433
+ );
434
+ assert.equal(
435
+ prunedSkillBackupContains(
436
+ sandbox.claudeDirectory,
437
+ join(SHIPPED_SKILL_DIRECTORY, ...STALE_SKILL_FILE_RELATIVE_SEGMENTS),
438
+ ),
439
+ false,
440
+ 'a scoped install moves nothing to the prune backup',
441
+ );
442
+ } finally {
443
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
444
+ }
445
+ });
446
+
447
+ test('a scoped --only install keeps prior manifest file entries it did not itself write', () => {
448
+ const sandbox = createSandbox();
449
+ try {
450
+ runInstaller(sandbox.homeDirectory, []);
451
+ const { staleFilePath } = seedStaleSkillFile(sandbox);
452
+ const filesBeforeScopedInstall = readManifest(sandbox.manifestPath).files;
453
+
454
+ runInstaller(sandbox.homeDirectory, ['--only', 'core']);
455
+
456
+ const recordedFiles = new Set(readManifest(sandbox.manifestPath).files);
457
+ assert.equal(
458
+ recordedFiles.has(staleFilePath),
459
+ true,
460
+ 'the entry a later full install needs to spot the stale file stays on the record',
461
+ );
462
+ const droppedEntries = filesBeforeScopedInstall
463
+ .filter(priorPath => !recordedFiles.has(priorPath));
464
+ assert.deepEqual(droppedEntries, [], 'a scoped install drops no prior entry');
465
+ } finally {
466
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
467
+ }
468
+ });
469
+
470
+ test('a full install after a scoped one still moves the stale file the scoped run carried forward', () => {
471
+ const sandbox = createSandbox();
472
+ try {
473
+ runInstaller(sandbox.homeDirectory, []);
474
+ const { staleFilePath } = seedStaleSkillFile(sandbox);
475
+ runInstaller(sandbox.homeDirectory, ['--only', 'core']);
476
+
477
+ runInstaller(sandbox.homeDirectory, []);
478
+
479
+ assert.equal(existsSync(staleFilePath), false, 'the carried-forward entry reaches the next full diff');
480
+ assert.equal(
481
+ prunedSkillBackupContains(
482
+ sandbox.claudeDirectory,
483
+ join(SHIPPED_SKILL_DIRECTORY, ...STALE_SKILL_FILE_RELATIVE_SEGMENTS),
484
+ ),
485
+ true,
486
+ 'the stale file lands in the prune backup',
487
+ );
488
+ } finally {
489
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
490
+ }
491
+ });
492
+
493
+ test('one install run collects retired skill directories and stale files under a single timestamped backup root', () => {
494
+ const sandbox = createSandbox();
495
+ try {
496
+ runInstaller(sandbox.homeDirectory, []);
497
+ const retiredSkillName = 'ghost-skill';
498
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkillName, true);
499
+ const { staleFilePath } = seedStaleSkillFile(sandbox);
500
+ const manifest = readManifest(sandbox.manifestPath);
501
+ manifest.skills.push(retiredSkillName);
502
+ writeFileSync(sandbox.manifestPath, JSON.stringify(manifest, null, 2) + '\n');
503
+
504
+ runInstaller(sandbox.homeDirectory, []);
505
+
506
+ const backupRoot = join(sandbox.claudeDirectory, PRUNED_BACKUP_DIRECTORY_NAME);
507
+ const allTimestampDirectories = readdirSync(backupRoot, { withFileTypes: true })
508
+ .filter(entry => entry.isDirectory())
509
+ .map(entry => entry.name);
510
+ assert.equal(allTimestampDirectories.length, 1, 'one run leaves one recovery point');
511
+ const runBackupRoot = join(backupRoot, allTimestampDirectories[0]);
512
+ assert.equal(
513
+ existsSync(join(runBackupRoot, SKILLS_DIRECTORY_NAME, retiredSkillName)),
514
+ true,
515
+ 'the retired skill directory sits under the shared root, mirroring ~/.claude/skills',
516
+ );
517
+ assert.equal(
518
+ existsSync(join(
519
+ runBackupRoot, SKILLS_DIRECTORY_NAME, SHIPPED_SKILL_DIRECTORY,
520
+ ...STALE_SKILL_FILE_RELATIVE_SEGMENTS,
521
+ )),
522
+ true,
523
+ 'the stale file mirrors its own path under the same shared root',
524
+ );
525
+ assert.equal(existsSync(staleFilePath), false, 'the stale file left the installed skill');
526
+ } finally {
527
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
528
+ }
529
+ });
530
+
194
531
  test('a full reinstall over a pre-manifest dirty tree prunes retired skills and keeps personal ones', () => {
195
532
  const sandbox = createSandbox();
196
533
  try {
@@ -216,7 +553,7 @@ test('a full reinstall over a pre-manifest dirty tree prunes retired skills and
216
553
  `retired skill ${retiredSkill} should be pruned`,
217
554
  );
218
555
  assert.equal(
219
- prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
556
+ prunedSkillBackupContains(sandbox.claudeDirectory, retiredSkill),
220
557
  true,
221
558
  `retired skill ${retiredSkill} should be moved to the prune backup`,
222
559
  );
@@ -273,7 +610,7 @@ test('a full reinstall over an old-format manifest without a skills key still pr
273
610
  `retired skill ${retiredSkill} should be pruned via the ever-shipped fallback`,
274
611
  );
275
612
  assert.equal(
276
- prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
613
+ prunedSkillBackupContains(sandbox.claudeDirectory, retiredSkill),
277
614
  true,
278
615
  `retired skill ${retiredSkill} should be moved to the prune backup`,
279
616
  );
@@ -316,7 +653,7 @@ test('a full reinstall prunes a manifest-recorded skill absent from the package
316
653
  'a skill the prior manifest recorded but the package no longer ships is pruned',
317
654
  );
318
655
  assert.equal(
319
- prunedBackupContains(sandbox.claudeDirectory, retiredManifestSkill),
656
+ prunedSkillBackupContains(sandbox.claudeDirectory, retiredManifestSkill),
320
657
  true,
321
658
  'the manifest-recorded retired skill is moved to the prune backup',
322
659
  );
@@ -346,7 +683,7 @@ test('a scoped --only install leaves retired skills in place because prune runs
346
683
  `retired skill ${retiredSkill} should survive a scoped install`,
347
684
  );
348
685
  assert.equal(
349
- prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
686
+ prunedSkillBackupContains(sandbox.claudeDirectory, retiredSkill),
350
687
  false,
351
688
  `retired skill ${retiredSkill} should not be moved to backup by a scoped install`,
352
689
  );
@@ -402,7 +739,7 @@ test('a full reinstall with an unresolved dependency skips the prune and leaves
402
739
  `retired skill ${retiredSkill} should survive when a dependency is unresolved`,
403
740
  );
404
741
  assert.equal(
405
- prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
742
+ prunedSkillBackupContains(sandbox.claudeDirectory, retiredSkill),
406
743
  false,
407
744
  `retired skill ${retiredSkill} should not be moved to backup when the prune is skipped`,
408
745
  );
@@ -412,6 +749,86 @@ test('a full reinstall with an unresolved dependency skips the prune and leaves
412
749
  }
413
750
  });
414
751
 
752
+ test('a full install that holds the prune keeps prior manifest file entries it did not itself write', () => {
753
+ const sandbox = createSandbox();
754
+ try {
755
+ runInstaller(sandbox.homeDirectory, []);
756
+ const { staleFilePath } = seedStaleSkillFile(sandbox);
757
+ const filesBeforeHeldPrune = readManifest(sandbox.manifestPath).files;
758
+
759
+ const installerOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: false });
760
+
761
+ assert.equal(
762
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
763
+ true,
764
+ 'the unresolved-dependency install holds both prunes',
765
+ );
766
+ const recordedFiles = new Set(readManifest(sandbox.manifestPath).files);
767
+ assert.equal(
768
+ recordedFiles.has(staleFilePath),
769
+ true,
770
+ 'the entry a later pruning install needs to spot the stale file stays on the record',
771
+ );
772
+ const droppedEntries = filesBeforeHeldPrune.filter(priorPath => !recordedFiles.has(priorPath));
773
+ assert.deepEqual(droppedEntries, [], 'a held prune drops no prior file entry');
774
+ } finally {
775
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
776
+ }
777
+ });
778
+
779
+ test('a full install that holds the prune keeps prior manifest skill names it did not itself install', () => {
780
+ const sandbox = createSandbox();
781
+ try {
782
+ runInstaller(sandbox.homeDirectory, []);
783
+ plantSkillDirectory(sandbox.skillsDirectory, DEPENDENCY_SUPPLIED_SKILL_DIRECTORY, true);
784
+ const manifest = readManifest(sandbox.manifestPath);
785
+ manifest.skills.push(DEPENDENCY_SUPPLIED_SKILL_DIRECTORY);
786
+ writeFileSync(sandbox.manifestPath, JSON.stringify(manifest, null, 2) + '\n');
787
+
788
+ const installerOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: false });
789
+
790
+ assert.equal(
791
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
792
+ true,
793
+ 'the unresolved-dependency install holds both prunes',
794
+ );
795
+ const recordedSkills = new Set(readManifest(sandbox.manifestPath).skills);
796
+ assert.equal(
797
+ recordedSkills.has(DEPENDENCY_SUPPLIED_SKILL_DIRECTORY),
798
+ true,
799
+ 'the name a later pruning install needs to spot the retired skill stays on the record',
800
+ );
801
+ assert.equal(
802
+ recordedSkills.has(SHIPPED_SKILL_DIRECTORY),
803
+ true,
804
+ 'the held run still records the skills it installed itself',
805
+ );
806
+ } finally {
807
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
808
+ }
809
+ });
810
+
811
+ test('a scoped --only install records the skill names it installed', () => {
812
+ const sandbox = createSandbox();
813
+ try {
814
+ runInstaller(sandbox.homeDirectory, ['--only', 'core']);
815
+
816
+ const recordedSkills = new Set(readManifest(sandbox.manifestPath).skills);
817
+ assert.equal(
818
+ existsSync(join(sandbox.skillsDirectory, SCOPED_GROUP_SKILL_DIRECTORY)),
819
+ true,
820
+ 'the scoped install writes the group skill',
821
+ );
822
+ assert.equal(
823
+ recordedSkills.has(SCOPED_GROUP_SKILL_DIRECTORY),
824
+ true,
825
+ 'the skill the scoped run installed reaches the record uninstall reads',
826
+ );
827
+ } finally {
828
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
829
+ }
830
+ });
831
+
415
832
  test('the prune skip is scoped: once the dependency resolves a later full install prunes normally', () => {
416
833
  const sandbox = createSandbox();
417
834
  try {
@@ -446,7 +863,7 @@ test('the prune skip is scoped: once the dependency resolves a later full instal
446
863
  `retired skill ${retiredSkill} should be pruned once the dependency resolves`,
447
864
  );
448
865
  assert.equal(
449
- prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
866
+ prunedSkillBackupContains(sandbox.claudeDirectory, retiredSkill),
450
867
  true,
451
868
  `retired skill ${retiredSkill} should be moved to the prune backup on the resolved install`,
452
869
  );
@@ -455,3 +872,436 @@ test('the prune skip is scoped: once the dependency resolves a later full instal
455
872
  rmSync(sandbox.homeDirectory, { recursive: true, force: true });
456
873
  }
457
874
  });
875
+
876
+ test('an install that prunes leaves its own run backup as the only one under the pruned-backup directory', () => {
877
+ const sandbox = createSandbox();
878
+ try {
879
+ runInstaller(sandbox.homeDirectory, []);
880
+ seedStaleSkillFile(sandbox);
881
+ plantPriorRunBackups(sandbox.claudeDirectory);
882
+
883
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
884
+
885
+ const remainingBackupNames = listRunBackupDirectoryNames(sandbox.claudeDirectory);
886
+ assert.equal(remainingBackupNames.length, 1, 'the newest run backup is the one that stays');
887
+ assert.equal(
888
+ PRIOR_RUN_BACKUP_DIRECTORY_NAMES.includes(remainingBackupNames[0]),
889
+ false,
890
+ 'the survivor is the run this install wrote',
891
+ );
892
+ assert.match(
893
+ installerOutput,
894
+ /Prune backups: 3 older run backup\(s\) removed/,
895
+ 'the install reports how many older backups it retired',
896
+ );
897
+ } finally {
898
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
899
+ }
900
+ });
901
+
902
+ test('an install whose only move is a retired skill directory still retires the older run backups', () => {
903
+ const sandbox = createSandbox();
904
+ const retiredSkillName = RETIRED_SKILL_DIRECTORIES[0];
905
+ try {
906
+ runInstaller(sandbox.homeDirectory, []);
907
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkillName, true);
908
+ plantPriorRunBackups(sandbox.claudeDirectory);
909
+
910
+ runInstaller(sandbox.homeDirectory, []);
911
+
912
+ const remainingBackupNames = listRunBackupDirectoryNames(sandbox.claudeDirectory);
913
+ assert.equal(remainingBackupNames.length, 1, 'the retired-skill move is enough to retire the rest');
914
+ assert.equal(
915
+ PRIOR_RUN_BACKUP_DIRECTORY_NAMES.includes(remainingBackupNames[0]),
916
+ false,
917
+ 'the survivor is the run this install wrote',
918
+ );
919
+ assert.equal(
920
+ prunedSkillBackupContains(sandbox.claudeDirectory, retiredSkillName),
921
+ true,
922
+ 'the surviving backup holds the retired skill directory the run moved',
923
+ );
924
+ } finally {
925
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
926
+ }
927
+ });
928
+
929
+ test('an install that prunes nothing leaves every existing run backup in place', () => {
930
+ const sandbox = createSandbox();
931
+ try {
932
+ runInstaller(sandbox.homeDirectory, []);
933
+ plantPriorRunBackups(sandbox.claudeDirectory);
934
+
935
+ runInstaller(sandbox.homeDirectory, []);
936
+
937
+ assert.deepEqual(
938
+ listRunBackupDirectoryNames(sandbox.claudeDirectory).sort(),
939
+ [...PRIOR_RUN_BACKUP_DIRECTORY_NAMES].sort(),
940
+ 'a run with no backup root of its own keeps every recovery point the user has',
941
+ );
942
+ } finally {
943
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
944
+ }
945
+ });
946
+
947
+ /**
948
+ * Seed a file under one managed root that a prior install recorded and the
949
+ * package no longer ships.
950
+ *
951
+ * @param {{claudeDirectory: string, manifestPath: string}} sandbox The sandbox paths.
952
+ * @param {string[]} homeRelativeSegments The path segments under ~/.claude.
953
+ * @returns {string} The seeded absolute path.
954
+ */
955
+ function seedStaleFileUnderManagedRoot(sandbox, homeRelativeSegments) {
956
+ const stalePath = join(sandbox.claudeDirectory, ...homeRelativeSegments);
957
+ writeFileWithParents(stalePath, `content ${homeRelativeSegments.join('/')} once held\n`);
958
+ appendManifestFiles(sandbox.manifestPath, [stalePath]);
959
+ return stalePath;
960
+ }
961
+
962
+ /**
963
+ * Read the sandbox settings.json.
964
+ *
965
+ * @param {string} claudeDirectory The sandbox ~/.claude directory.
966
+ * @returns {object} The parsed settings object.
967
+ */
968
+ function readSettings(claudeDirectory) {
969
+ return JSON.parse(readFileSync(join(claudeDirectory, SETTINGS_FILE_NAME), 'utf8'));
970
+ }
971
+
972
+ /**
973
+ * List every hook command the settings file holds, across all event types.
974
+ *
975
+ * @param {object} settings The parsed settings object.
976
+ * @returns {string[]} Each hook command in the file.
977
+ */
978
+ function allHookCommands(settings) {
979
+ return Object.values(settings.hooks || {})
980
+ .flat()
981
+ .flatMap(group => group.hooks || [])
982
+ .map(hook => hook.command);
983
+ }
984
+
985
+ /**
986
+ * Plant a hook script a prior install recorded and the settings entries that run
987
+ * it: one in a live matcher group beside a user-authored entry, and one under an
988
+ * event type the current hooks.json leaves out.
989
+ *
990
+ * @param {{claudeDirectory: string, manifestPath: string}} sandbox The sandbox paths.
991
+ * @returns {{retiredHookPath: string, retiredHookCommand: string, lookalikeHookCommand: string}}
992
+ * The planted script, its command, and a user command whose path is that tail plus a suffix.
993
+ */
994
+ function seedRetiredManagedHook(sandbox) {
995
+ const retiredHookPath = join(
996
+ sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, ...RETIRED_HOOK_RELATIVE_SEGMENTS,
997
+ );
998
+ writeFileWithParents(retiredHookPath, 'a gate an earlier revision shipped\n');
999
+ appendManifestFiles(sandbox.manifestPath, [retiredHookPath]);
1000
+ const retiredHookCommand = `python3 "${retiredHookPath.replace(/\\/g, '/')}"`;
1001
+ const lookalikeHookCommand = `python3 "${retiredHookPath.replace(/\\/g, '/')}.bak"`;
1002
+ const settings = readSettings(sandbox.claudeDirectory);
1003
+ const liveGroup = settings.hooks[RETIRED_HOOK_EVENT_TYPE]
1004
+ .find(group => group.matcher === RETIRED_HOOK_MATCHER);
1005
+ liveGroup.hooks.push({ type: 'command', command: retiredHookCommand });
1006
+ liveGroup.hooks.push({ type: 'command', command: USER_HOOK_COMMAND });
1007
+ liveGroup.hooks.push({ type: 'command', command: lookalikeHookCommand });
1008
+ settings.hooks[DROPPED_HOOK_EVENT_TYPE] = [
1009
+ { matcher: '*', hooks: [{ type: 'command', command: retiredHookCommand }] },
1010
+ ];
1011
+ writeFileSync(
1012
+ join(sandbox.claudeDirectory, SETTINGS_FILE_NAME),
1013
+ JSON.stringify(settings, null, 4) + '\n',
1014
+ );
1015
+ return { retiredHookPath, retiredHookCommand, lookalikeHookCommand };
1016
+ }
1017
+
1018
+ test('a full reinstall moves a stale file out of every managed root into that root\'s backup folder', () => {
1019
+ const sandbox = createSandbox();
1020
+ try {
1021
+ runInstaller(sandbox.homeDirectory, []);
1022
+ const staleRulePath = seedStaleFileUnderManagedRoot(
1023
+ sandbox, [RULES_DIRECTORY_NAME, STALE_RULE_FILE_NAME],
1024
+ );
1025
+ const staleSharedPath = seedStaleFileUnderManagedRoot(
1026
+ sandbox, [SHARED_DIRECTORY_NAME, ...STALE_SHARED_FILE_SEGMENTS],
1027
+ );
1028
+ const staleSkillSharedPath = seedStaleFileUnderManagedRoot(
1029
+ sandbox, [SKILLS_DIRECTORY_NAME, ...STALE_SHARED_SKILL_FILE_SEGMENTS],
1030
+ );
1031
+
1032
+ runInstaller(sandbox.homeDirectory, []);
1033
+
1034
+ for (const movedPath of [staleRulePath, staleSharedPath, staleSkillSharedPath]) {
1035
+ assert.equal(existsSync(movedPath), false, `${movedPath} leaves its managed root`);
1036
+ }
1037
+ assert.equal(
1038
+ prunedBackupContains(
1039
+ sandbox.claudeDirectory, join(RULES_DIRECTORY_NAME, STALE_RULE_FILE_NAME),
1040
+ ),
1041
+ true,
1042
+ 'the rules file mirrors its path under the rules folder of the backup',
1043
+ );
1044
+ assert.equal(
1045
+ prunedBackupContains(
1046
+ sandbox.claudeDirectory, join(SHARED_DIRECTORY_NAME, ...STALE_SHARED_FILE_SEGMENTS),
1047
+ ),
1048
+ true,
1049
+ 'the ~/.claude/_shared file mirrors its path under the _shared folder of the backup',
1050
+ );
1051
+ assert.equal(
1052
+ prunedSkillBackupContains(
1053
+ sandbox.claudeDirectory, join(...STALE_SHARED_SKILL_FILE_SEGMENTS),
1054
+ ),
1055
+ true,
1056
+ 'the ~/.claude/skills/_shared file lands under the skills folder, so neither root prunes the other\'s content',
1057
+ );
1058
+ assert.equal(
1059
+ prunedBackupContains(
1060
+ sandbox.claudeDirectory, join(SHARED_DIRECTORY_NAME, ...STALE_SHARED_SKILL_FILE_SEGMENTS),
1061
+ ),
1062
+ false,
1063
+ 'the skills _shared file reaches the _shared root backup through no second move',
1064
+ );
1065
+ } finally {
1066
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1067
+ }
1068
+ });
1069
+
1070
+ test('a full reinstall leaves a manifest-recorded path under no managed root in place', () => {
1071
+ const sandbox = createSandbox();
1072
+ try {
1073
+ runInstaller(sandbox.homeDirectory, []);
1074
+ const topLevelPaths = [
1075
+ join(sandbox.claudeDirectory, CLAUDE_HUB_FILE_NAME),
1076
+ join(sandbox.claudeDirectory, SETTINGS_FILE_NAME),
1077
+ join(sandbox.claudeDirectory, MANIFEST_FILE_NAME),
1078
+ ];
1079
+ appendManifestFiles(sandbox.manifestPath, topLevelPaths);
1080
+
1081
+ runInstaller(sandbox.homeDirectory, []);
1082
+
1083
+ for (const topLevelPath of topLevelPaths) {
1084
+ assert.equal(existsSync(topLevelPath), true, `${topLevelPath} stays at the top level`);
1085
+ assert.equal(
1086
+ prunedBackupContains(sandbox.claudeDirectory, basename(topLevelPath)),
1087
+ false,
1088
+ `${topLevelPath} reaches no run backup`,
1089
+ );
1090
+ }
1091
+ } finally {
1092
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1093
+ }
1094
+ });
1095
+
1096
+ test('a full reinstall drops a retired hook script and its settings entry in one run', () => {
1097
+ const sandbox = createSandbox();
1098
+ try {
1099
+ runInstaller(sandbox.homeDirectory, []);
1100
+ const {
1101
+ retiredHookPath, retiredHookCommand, lookalikeHookCommand,
1102
+ } = seedRetiredManagedHook(sandbox);
1103
+
1104
+ runInstaller(sandbox.homeDirectory, []);
1105
+
1106
+ assert.equal(existsSync(retiredHookPath), false, 'the retired hook script leaves ~/.claude/hooks');
1107
+ assert.equal(
1108
+ prunedBackupContains(
1109
+ sandbox.claudeDirectory, join(HOOKS_DIRECTORY_NAME, ...RETIRED_HOOK_RELATIVE_SEGMENTS),
1110
+ ),
1111
+ true,
1112
+ 'the retired hook script lands under the hooks folder of the backup',
1113
+ );
1114
+ const settingsCommands = allHookCommands(readSettings(sandbox.claudeDirectory));
1115
+ assert.equal(
1116
+ settingsCommands.includes(retiredHookCommand),
1117
+ false,
1118
+ 'no settings entry is left pointing at the script the run removed',
1119
+ );
1120
+ assert.equal(
1121
+ settingsCommands.includes(USER_HOOK_COMMAND),
1122
+ true,
1123
+ 'the user-authored entry sharing the matcher group survives untouched',
1124
+ );
1125
+ assert.equal(
1126
+ settingsCommands.includes(lookalikeHookCommand),
1127
+ true,
1128
+ 'a user command whose path is the retired tail plus a suffix names another file and stays',
1129
+ );
1130
+ } finally {
1131
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1132
+ }
1133
+ });
1134
+
1135
+ test('a full reinstall removes a retired hook entry under an event type the current config leaves out', () => {
1136
+ const sandbox = createSandbox();
1137
+ try {
1138
+ runInstaller(sandbox.homeDirectory, []);
1139
+ const { retiredHookCommand } = seedRetiredManagedHook(sandbox);
1140
+
1141
+ runInstaller(sandbox.homeDirectory, []);
1142
+
1143
+ const settings = readSettings(sandbox.claudeDirectory);
1144
+ assert.equal(
1145
+ allHookCommands(settings).includes(retiredHookCommand),
1146
+ false,
1147
+ 'the entry under the dropped event type goes with the script',
1148
+ );
1149
+ assert.equal(
1150
+ Object.hasOwn(settings.hooks, DROPPED_HOOK_EVENT_TYPE),
1151
+ false,
1152
+ 'the event type left with no entries is dropped',
1153
+ );
1154
+ } finally {
1155
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1156
+ }
1157
+ });
1158
+
1159
+ test('an uninstall skips a manifest record outside ~/.claude and still removes the managed records', () => {
1160
+ const sandbox = createSandbox();
1161
+ const outsideDirectory = mkdtempSync(join(tmpdir(), 'cdev-outside-home-'));
1162
+ try {
1163
+ runInstaller(sandbox.homeDirectory, []);
1164
+ const outsideFilePath = join(outsideDirectory, 'user-file.txt');
1165
+ writeFileSync(outsideFilePath, 'a file no install ever wrote\n');
1166
+ const managedFilePath = join(sandbox.skillsDirectory, SHIPPED_SKILL_DIRECTORY, 'SKILL.md');
1167
+ assert.equal(existsSync(managedFilePath), true, 'the install wrote the managed file the purge targets');
1168
+ appendManifestFiles(
1169
+ sandbox.manifestPath,
1170
+ [outsideFilePath, systemRootPathNeverWritten()],
1171
+ );
1172
+
1173
+ runInstaller(sandbox.homeDirectory, ['--uninstall']);
1174
+
1175
+ assert.equal(existsSync(outsideFilePath), true, 'a record outside ~/.claude leaves its file untouched');
1176
+ assert.equal(existsSync(managedFilePath), false, 'every legitimate record is still removed');
1177
+ assert.equal(existsSync(sandbox.manifestPath), false, 'the uninstall runs to the end and clears the manifest');
1178
+ } finally {
1179
+ rmSync(outsideDirectory, { recursive: true, force: true });
1180
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1181
+ }
1182
+ });
1183
+
1184
+ test('an uninstall removes the home-directory .mypy.ini the install wrote and skips only a genuinely foreign record', () => {
1185
+ const sandbox = createSandbox();
1186
+ try {
1187
+ runInstaller(sandbox.homeDirectory, []);
1188
+ const mypyIniPath = join(sandbox.homeDirectory, MYPY_INI_FILE_NAME);
1189
+ assert.equal(
1190
+ existsSync(mypyIniPath),
1191
+ true,
1192
+ 'the install writes the mypy configuration in the home directory and records it',
1193
+ );
1194
+ const foreignRecordPath = systemRootPathNeverWritten();
1195
+ appendManifestFiles(sandbox.manifestPath, [foreignRecordPath]);
1196
+
1197
+ const installerOutput = runInstallerReadingBothStreams(
1198
+ sandbox.homeDirectory, ['--uninstall'],
1199
+ );
1200
+
1201
+ assert.equal(
1202
+ existsSync(mypyIniPath),
1203
+ false,
1204
+ 'the uninstall removes the one file the install writes outside ~/.claude',
1205
+ );
1206
+ assert.equal(
1207
+ installerOutput.includes(`skipping ${mypyIniPath}`),
1208
+ false,
1209
+ 'the containment guard raises no warning about a path the installer itself writes',
1210
+ );
1211
+ assert.match(
1212
+ installerOutput,
1213
+ /1 manifest record\(s\) skipped/,
1214
+ 'the record naming a path no install wrote is the only one skipped',
1215
+ );
1216
+ assert.equal(existsSync(foreignRecordPath), false, 'the skipped record never existed on disk');
1217
+ assert.equal(
1218
+ existsSync(sandbox.manifestPath),
1219
+ false,
1220
+ 'the uninstall runs to the end and clears the manifest',
1221
+ );
1222
+ } finally {
1223
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1224
+ }
1225
+ });
1226
+
1227
+ test('a second install records the home-directory .mypy.ini it finds already configured so the uninstall still removes it', () => {
1228
+ const sandbox = createSandbox();
1229
+ try {
1230
+ runInstaller(sandbox.homeDirectory, []);
1231
+ const mypyIniPath = join(sandbox.homeDirectory, MYPY_INI_FILE_NAME);
1232
+
1233
+ runInstaller(sandbox.homeDirectory, []);
1234
+
1235
+ assert.equal(
1236
+ readManifest(sandbox.manifestPath).files.includes(mypyIniPath),
1237
+ true,
1238
+ 'the run that finds the file already configured records it on the fresh manifest',
1239
+ );
1240
+
1241
+ const installerOutput = runInstallerReadingBothStreams(
1242
+ sandbox.homeDirectory, ['--uninstall'],
1243
+ );
1244
+
1245
+ assert.equal(
1246
+ existsSync(mypyIniPath),
1247
+ false,
1248
+ 'the uninstall reads the record and removes the mypy configuration',
1249
+ );
1250
+ assert.equal(
1251
+ installerOutput.includes(mypyIniPath),
1252
+ false,
1253
+ 'the containment guard raises no warning naming the mypy configuration',
1254
+ );
1255
+ } finally {
1256
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1257
+ }
1258
+ });
1259
+
1260
+ test('an uninstall of a clean install skips no manifest record at all', () => {
1261
+ const sandbox = createSandbox();
1262
+ try {
1263
+ runInstaller(sandbox.homeDirectory, []);
1264
+
1265
+ const installerOutput = runInstallerReadingBothStreams(
1266
+ sandbox.homeDirectory, ['--uninstall'],
1267
+ );
1268
+
1269
+ assert.equal(
1270
+ installerOutput.includes(SKIPPED_RECORD_SUMMARY_MARKER),
1271
+ false,
1272
+ 'every record a plain install writes passes the containment guard',
1273
+ );
1274
+ } finally {
1275
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1276
+ }
1277
+ });
1278
+
1279
+ test('an uninstall removes the nested directories its records emptied and keeps unmanaged siblings', () => {
1280
+ const sandbox = createSandbox();
1281
+ try {
1282
+ runInstaller(sandbox.homeDirectory, []);
1283
+ const nestedFilePath = join(sandbox.skillsDirectory, ...NESTED_SKILL_FILE_SEGMENTS);
1284
+ writeFileWithParents(nestedFilePath, 'a module an earlier install wrote\n');
1285
+ appendManifestFiles(sandbox.manifestPath, [nestedFilePath]);
1286
+ const unmanagedDirectory = join(sandbox.claudeDirectory, UNMANAGED_SIBLING_DIRECTORY);
1287
+ mkdirSync(unmanagedDirectory, { recursive: true });
1288
+ writeFileSync(join(unmanagedDirectory, 'note.md'), 'a file the user authored\n');
1289
+
1290
+ runInstaller(sandbox.homeDirectory, ['--uninstall']);
1291
+
1292
+ assert.equal(
1293
+ existsSync(join(sandbox.skillsDirectory, NESTED_SKILL_DIRECTORY, 'scripts')),
1294
+ false,
1295
+ 'the directory the removed file emptied is gone',
1296
+ );
1297
+ assert.equal(
1298
+ existsSync(join(sandbox.skillsDirectory, NESTED_SKILL_DIRECTORY)),
1299
+ false,
1300
+ 'the walk climbs to the skill directory the nested removal emptied',
1301
+ );
1302
+ assert.equal(existsSync(sandbox.claudeDirectory), true, 'the managed home itself stays');
1303
+ assert.equal(existsSync(unmanagedDirectory), true, 'a directory the installer never wrote stays');
1304
+ } finally {
1305
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1306
+ }
1307
+ });