claude-dev-env 7.0.0 → 7.2.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 (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +213 -0
  6. package/.agents/skills/session-continuity/SKILL.md +125 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +447 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +88 -0
  20. package/bin/install-session-continuity.test.mjs +55 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
package/bin/install.mjs CHANGED
@@ -11,7 +11,10 @@ import {
11
11
  KNOWN_GIT_HOOK_NAMES,
12
12
  } from './git_hooks_installer.mjs';
13
13
  import { installMypyIniForClaudeHooks } from './install_mypy_ini.mjs';
14
- import { expandHomeDirectoryTokensInSettings } from './expand_home_directory_tokens.mjs';
14
+ import {
15
+ expandHomeDirectoryTokens,
16
+ expandHomeDirectoryTokensInSettings,
17
+ } from './expand_home_directory_tokens.mjs';
15
18
  import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
16
19
  import {
17
20
  managedDenyEntriesFromPackageSettings,
@@ -175,7 +178,6 @@ export const CORE_INCLUDE_DIRECTORIES = [
175
178
  export const CORE_SKILLS = [
176
179
  'orchestrator', 'orchestrator-refresh', 'team-advisor',
177
180
  'grok-spawn',
178
- 'pr-small-cl',
179
181
  'everything-search',
180
182
  'test-runner',
181
183
  'privacy-hygiene',
@@ -321,10 +323,6 @@ export const INSTALL_GROUPS = {
321
323
  includeAllHooks: true,
322
324
  includeCodexRules: true,
323
325
  },
324
- journal: {
325
- description: 'Session logging and memory',
326
- skills: ['session-log', 'session-tidy'],
327
- },
328
326
  ...dependencyDiscovery.groups,
329
327
  };
330
328
 
@@ -1236,6 +1234,35 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1236
1234
  'session/untracked_repo_detector.py',
1237
1235
  'session/gh_pr_author_session_cleanup.py',
1238
1236
  'observability/pr_description_writer_spawn_tracker.py',
1237
+ 'blocking/plain_language_blocker.py',
1238
+ 'lifecycle/config_change_guard.py',
1239
+ 'blocking/docstring_rule_gate_count_blocker.py',
1240
+ 'blocking/write_existing_file_blocker.py',
1241
+ 'blocking/sensitive_file_protector.py',
1242
+ 'blocking/pii_prevention_blocker.py',
1243
+ 'blocking/duplicate_rmtree_helper_blocker.py',
1244
+ 'blocking/claude_md_orphan_file_blocker.py',
1245
+ 'blocking/package_inventory_stale_blocker.py',
1246
+ 'blocking/env_var_table_code_drift_blocker.py',
1247
+ 'blocking/pytest_testpaths_orphan_blocker.py',
1248
+ 'blocking/destructive_command_blocker.py',
1249
+ 'blocking/shell_substitution_blocker.py',
1250
+ 'blocking/piped_pytest_blocker.py',
1251
+ 'blocking/cursor_cli_python_misfire_blocker.py',
1252
+ 'blocking/unscoped_search_blocker.py',
1253
+ 'blocking/nas_ssh_binary_enforcer.py',
1254
+ 'blocking/block_main_commit.py',
1255
+ 'blocking/session_edit_stage_gate.py',
1256
+ 'blocking/bash_pre_tool_use_dispatcher.py',
1257
+ 'blocking/stop_dispatcher.py',
1258
+ 'blocking/bot_mention_comment_blocker.py',
1259
+ 'blocking/fable_spawn_gate.py',
1260
+ 'blocking/luna_fast_mode_gate.py',
1261
+ 'blocking/orchestrator_refresh_reschedule_gate.py',
1262
+ 'blocking/ask_user_question_shape_blocker.py',
1263
+ 'blocking/send_user_file_open_locally_blocker.py',
1264
+ 'blocking/question_to_user_enforcer.py',
1265
+ 'blocking/session_handoff_blocker.py',
1239
1266
  ]);
1240
1267
 
1241
1268
  /**
@@ -1349,15 +1376,26 @@ export function commandReferencesManagedHook(commandString, managedHookRelativeP
1349
1376
  * @returns {boolean} True when the managed tail ends at a path boundary.
1350
1377
  */
1351
1378
  function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
1379
+ return commandHoldsPathAtBoundary(normalizedCommand, `/.claude/hooks/${relativePath}`);
1380
+ }
1381
+
1382
+ /**
1383
+ * Report whether a command holds one path spelling that ends at a path boundary:
1384
+ * end of string, or an argument separator (whitespace, quote, or semicolon).
1385
+ *
1386
+ * @param {string} normalizedCommand Forward-slash-normalized hook command.
1387
+ * @param {string} pathText The path spelling to look for.
1388
+ * @returns {boolean} True when the command holds that path at a boundary.
1389
+ */
1390
+ function commandHoldsPathAtBoundary(normalizedCommand, pathText) {
1352
1391
  const commandArgumentBoundary = /[\s'";]/;
1353
- const managedTail = `/.claude/hooks/${relativePath}`;
1354
- let searchStart = normalizedCommand.indexOf(managedTail);
1392
+ let searchStart = normalizedCommand.indexOf(pathText);
1355
1393
  while (searchStart !== -1) {
1356
- const characterAfterTail = normalizedCommand[searchStart + managedTail.length];
1357
- if (characterAfterTail === undefined || commandArgumentBoundary.test(characterAfterTail)) {
1394
+ const characterAfterPath = normalizedCommand[searchStart + pathText.length];
1395
+ if (characterAfterPath === undefined || commandArgumentBoundary.test(characterAfterPath)) {
1358
1396
  return true;
1359
1397
  }
1360
- searchStart = normalizedCommand.indexOf(managedTail, searchStart + 1);
1398
+ searchStart = normalizedCommand.indexOf(pathText, searchStart + 1);
1361
1399
  }
1362
1400
  return false;
1363
1401
  }
@@ -1564,29 +1602,139 @@ export function retiredManagedHookRelativePaths(
1564
1602
  return retiredRelativePaths;
1565
1603
  }
1566
1604
 
1605
+ const MANAGED_HOOK_ROOT_PATHS = [
1606
+ join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME),
1607
+ join(AGENTS_HOME, MANAGED_HOOKS_DIRECTORY_NAME),
1608
+ INSTALL_ROOT_RESOLUTION.codexHooksInstallDirectory,
1609
+ ];
1610
+
1611
+ const CODEX_HOOKS_CONFIGURATION_FILE_NAME = 'hooks.json';
1612
+
1613
+ const CODEX_HOOKS_CONFIGURATION_PATH = join(
1614
+ dirname(INSTALL_ROOT_RESOLUTION.codexHooksInstallDirectory),
1615
+ CODEX_HOOKS_CONFIGURATION_FILE_NAME,
1616
+ );
1617
+
1567
1618
  /**
1568
- * Report whether a settings.json hook command runs one of the retired managed
1569
- * hook scripts.
1619
+ * Resolve a path through every link on it, or return null when it is missing.
1570
1620
  *
1571
- * The anchored `/.claude/hooks/<relative>` tail is the same test the merge uses to
1572
- * tell this installer's entries from a user's, so a command whose path is a
1573
- * retired tail plus a suffix stays outside the set. The inline validators-runner
1574
- * shape sits outside this test on purpose: it names no script, so no manifest
1575
- * record can retire it, and the merge writes it fresh on every run.
1621
+ * @param {string} filesystemPath The absolute path to resolve.
1622
+ * @returns {string|null} The real path, or null when nothing stands there.
1623
+ */
1624
+ function realPathOrNull(filesystemPath) {
1625
+ try {
1626
+ return realpathSync(filesystemPath);
1627
+ } catch {
1628
+ return null;
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Return comparison keys for each owned hooks directory, including its real path.
1576
1634
  *
1577
- * A command that is not a string belongs to an entry this installer never wrote —
1578
- * a hand-edited settings.json, or a third-party entry carrying another shape — so
1579
- * it names no retired script and its entry stays.
1635
+ * ~/.claude/hooks, the agents-home hooks directory, and ~/.codex/hooks can
1636
+ * point at one directory. Both the given path and its resolved path are keys.
1580
1637
  *
1581
- * @param {unknown} commandString The hook command from settings.json.
1638
+ * @param {string[]} allHookRootPaths The hooks directories this installer writes.
1639
+ * @returns {Set<string>} The comparison key of every owned spelling.
1640
+ */
1641
+ export function ownedHookRootComparisonKeys(allHookRootPaths) {
1642
+ const allRootKeys = new Set();
1643
+ for (const hookRootPath of allHookRootPaths) {
1644
+ if (!hookRootPath) continue;
1645
+ allRootKeys.add(comparisonKeyForPath(hookRootPath));
1646
+ const realHookRootPath = realPathOrNull(hookRootPath);
1647
+ if (realHookRootPath) allRootKeys.add(comparisonKeyForPath(realHookRootPath));
1648
+ }
1649
+ return allRootKeys;
1650
+ }
1651
+
1652
+ /**
1653
+ * Expand `$HOME`, `${HOME}`, `~/`, and `%USERPROFILE%` in a hook command.
1654
+ *
1655
+ * `$HOME`, `${HOME}`, and `~/` go through expandHomeDirectoryTokens, the helper
1656
+ * the settings rewrite already uses, so a tilde is not left in front of home.
1657
+ * `%USERPROFILE%` stays here because this comparison only reads a spelling.
1658
+ *
1659
+ * @param {string} normalizedCommand Forward-slash-normalized hook command.
1660
+ * @param {string} homeDirectory The absolute home directory to substitute.
1661
+ * @returns {string} The command with each home reference expanded.
1662
+ */
1663
+ function expandHomeReferencesInCommand(normalizedCommand, homeDirectory) {
1664
+ const normalizedHome = homeDirectory.replace(/\\/g, '/').replace(/\/+$/, '');
1665
+ const userProfileExpanded = normalizedCommand.replace(
1666
+ /%USERPROFILE%/gi, () => normalizedHome,
1667
+ );
1668
+ return expandHomeDirectoryTokens(userProfileExpanded, homeDirectory);
1669
+ }
1670
+
1671
+ /**
1672
+ * Build the spelling a hook command is compared through: forward-slashed, home
1673
+ * references expanded, and case-folded wherever the filesystem ignores case.
1674
+ *
1675
+ * @param {string} commandString The hook command from a host configuration file.
1676
+ * @param {string} homeDirectory The absolute home directory to substitute.
1677
+ * @returns {string} The command spelling the owned-root test reads.
1678
+ */
1679
+ export function hookCommandComparisonSpelling(commandString, homeDirectory) {
1680
+ const forwardSlashCommand = commandString.replace(/\\/g, '/');
1681
+ const expandedCommand = expandHomeReferencesInCommand(forwardSlashCommand, homeDirectory);
1682
+ return isCaseInsensitiveFilesystem() ? expandedCommand.toLowerCase() : expandedCommand;
1683
+ }
1684
+
1685
+ /**
1686
+ * Report whether a hook command runs one script under a hooks directory this
1687
+ * installer owns.
1688
+ *
1689
+ * @param {unknown} commandString The hook command from a host configuration file.
1690
+ * @param {string} relativePath The script path under a hooks directory.
1691
+ * @param {{allOwnedHookRootKeys: Set<string>, homeDirectory: string}} ownership The owned roots.
1692
+ * @returns {boolean} True when the command runs that script under an owned root.
1693
+ */
1694
+ export function commandNamesOwnedHookScript(commandString, relativePath, ownership) {
1695
+ if (typeof commandString !== 'string') return false;
1696
+ const shouldFoldCase = isCaseInsensitiveFilesystem();
1697
+ const comparisonCommand = hookCommandComparisonSpelling(
1698
+ commandString, ownership.homeDirectory,
1699
+ );
1700
+ const comparisonRelativePath = shouldFoldCase ? relativePath.toLowerCase() : relativePath;
1701
+ for (const ownedRootKey of ownership.allOwnedHookRootKeys) {
1702
+ const ownedScriptPath = `${ownedRootKey}/${comparisonRelativePath}`;
1703
+ if (commandHoldsPathAtBoundary(comparisonCommand, ownedScriptPath)) return true;
1704
+ }
1705
+ return false;
1706
+ }
1707
+
1708
+ /**
1709
+ * Build the ownership one retirement walk reads, filling in this run's own roots.
1710
+ *
1711
+ * @param {{allOwnedHookRootKeys?: Set<string>, homeDirectory?: string}} [options] Test overrides.
1712
+ * @returns {{allOwnedHookRootKeys: Set<string>, homeDirectory: string}} The ownership.
1713
+ */
1714
+ function retiredHookOwnership(options = {}) {
1715
+ return {
1716
+ allOwnedHookRootKeys: options.allOwnedHookRootKeys
1717
+ ?? ownedHookRootComparisonKeys(MANAGED_HOOK_ROOT_PATHS),
1718
+ homeDirectory: options.homeDirectory ?? homedir(),
1719
+ };
1720
+ }
1721
+
1722
+ /**
1723
+ * Report whether a host hook command runs a retired managed script under an
1724
+ * owned hooks directory.
1725
+ *
1726
+ * A path-plus-suffix, a command under another tool's tree, a non-string
1727
+ * command, and the inline validators-runner shape (no script name) are left
1728
+ * in place. The merge rewrites the validators-runner entry on every run.
1729
+ *
1730
+ * @param {unknown} commandString The hook command from a host configuration file.
1582
1731
  * @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
1732
+ * @param {{allOwnedHookRootKeys: Set<string>, homeDirectory: string}} ownership The owned roots.
1583
1733
  * @returns {boolean} True when the command runs a retired managed script.
1584
1734
  */
1585
- function commandReferencesRetiredHook(commandString, retiredHookRelativePaths) {
1586
- if (typeof commandString !== 'string') return false;
1587
- const normalizedCommand = commandString.replace(/\\/g, '/');
1735
+ function commandReferencesRetiredHook(commandString, retiredHookRelativePaths, ownership) {
1588
1736
  for (const relativePath of retiredHookRelativePaths) {
1589
- if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) return true;
1737
+ if (commandNamesOwnedHookScript(commandString, relativePath, ownership)) return true;
1590
1738
  }
1591
1739
  return false;
1592
1740
  }
@@ -1646,15 +1794,18 @@ function groupHookEntries(group) {
1646
1794
  *
1647
1795
  * @param {object} settings The parsed settings.json object (mutated in place).
1648
1796
  * @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
1797
+ * @param {{allOwnedHookRootKeys: Set<string>, homeDirectory: string}} ownership The owned roots.
1649
1798
  * @returns {number} How many hook entries were removed.
1650
1799
  */
1651
- function stripRetiredHookEntries(settings, retiredHookRelativePaths) {
1800
+ function stripRetiredHookEntries(settings, retiredHookRelativePaths, ownership) {
1652
1801
  let removedCount = 0;
1653
1802
  for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
1654
1803
  if (!Array.isArray(matcherGroups)) continue;
1655
1804
  const eventOutcome = retainedMatcherGroups(
1656
1805
  matcherGroups,
1657
- commandString => commandReferencesRetiredHook(commandString, retiredHookRelativePaths),
1806
+ commandString => commandReferencesRetiredHook(
1807
+ commandString, retiredHookRelativePaths, ownership,
1808
+ ),
1658
1809
  );
1659
1810
  removedCount += eventOutcome.removedCount;
1660
1811
  if (eventOutcome.keptGroups.length === 0) {
@@ -1666,35 +1817,256 @@ function stripRetiredHookEntries(settings, retiredHookRelativePaths) {
1666
1817
  return removedCount;
1667
1818
  }
1668
1819
 
1820
+ const DEFAULT_HOST_CONFIGURATION_INDENT = ' ';
1821
+
1669
1822
  /**
1670
- * Remove every settings.json entry that runs a retired managed hook script,
1671
- * writing the file only when an entry left it.
1823
+ * Read the indent the host configuration file already uses.
1672
1824
  *
1673
- * A run that retires no hook leaves settings.json byte-identical, so an install
1674
- * touches the user's settings for a reason a reader can name. A settings file the
1675
- * installer cannot parse is left alone with a warning.
1825
+ * ~/.claude/settings.json uses four spaces and ~/.codex/hooks.json uses two.
1826
+ * Forcing four spaces would rewrite every line of one of those files. The first
1827
+ * indented line supplies the indent, tabs included.
1676
1828
  *
1677
- * @param {string} settingsPath The absolute settings.json path.
1829
+ * @param {string} settingsText The host configuration file as it stands on disk.
1830
+ * @returns {string} The indent one nesting level uses.
1831
+ */
1832
+ function hostConfigurationIndent(settingsText) {
1833
+ const firstIndentedLine = /\n([ \t]+)\S/.exec(settingsText);
1834
+ return firstIndentedLine ? firstIndentedLine[1] : DEFAULT_HOST_CONFIGURATION_INDENT;
1835
+ }
1836
+
1837
+ /**
1838
+ * Remove every entry of one host hook configuration file that runs a retired
1839
+ * managed hook script, writing the file only when an entry left it.
1840
+ *
1841
+ * Claude settings.json and Codex hooks.json share this hook shape. A run that
1842
+ * retires no hook leaves the file byte-identical. A file the installer cannot
1843
+ * read or cannot parse is left alone with a warning, and the prune continues
1844
+ * with the other host configuration file.
1845
+ *
1846
+ * @param {string} settingsPath The absolute host configuration path.
1678
1847
  * @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
1848
+ * @param {{allOwnedHookRootKeys?: Set<string>, homeDirectory?: string}} [options] Test overrides.
1679
1849
  * @returns {number} How many hook entries were removed.
1680
1850
  */
1681
- export function pruneRetiredHookEntriesFromSettings(settingsPath, retiredHookRelativePaths) {
1851
+ export function pruneRetiredHookEntriesFromSettings(
1852
+ settingsPath, retiredHookRelativePaths, options = {},
1853
+ ) {
1682
1854
  if (retiredHookRelativePaths.size === 0) return 0;
1683
1855
  if (!existsSync(settingsPath)) return 0;
1856
+ let settingsText;
1684
1857
  let settings;
1685
1858
  try {
1686
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1687
- } catch (parseError) {
1688
- console.warn(` Warning: leaving settings.json as it stands — the file holds JSON the installer cannot read (${parseError.message})`);
1859
+ settingsText = readFileSync(settingsPath, 'utf8');
1860
+ settings = JSON.parse(settingsText);
1861
+ } catch (readError) {
1862
+ console.warn(` Warning: leaving ${basename(settingsPath)} as it stands — the installer cannot read the file as JSON (${readError.message})`);
1689
1863
  return 0;
1690
1864
  }
1691
1865
  if (!settings.hooks || typeof settings.hooks !== 'object') return 0;
1692
- const removedCount = stripRetiredHookEntries(settings, retiredHookRelativePaths);
1866
+ const removedCount = stripRetiredHookEntries(
1867
+ settings, retiredHookRelativePaths, retiredHookOwnership(options),
1868
+ );
1693
1869
  if (removedCount === 0) return 0;
1694
- writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
1870
+ const settingsIndent = hostConfigurationIndent(settingsText);
1871
+ writeFileSync(settingsPath, JSON.stringify(settings, null, settingsIndent) + '\n');
1695
1872
  return removedCount;
1696
1873
  }
1697
1874
 
1875
+ const RETIRED_HOOK_STAND_IN_MARKER = 'claude-dev-env retired hook stand-in';
1876
+
1877
+ const RETIRED_HOOK_STAND_IN_SOURCE = `"""${RETIRED_HOOK_STAND_IN_MARKER}.
1878
+
1879
+ This install retired the hook that stood here and removed its settings.json
1880
+ registration. A session that started before the install still holds the old
1881
+ registration and runs this path on every matching tool call, so the path keeps an
1882
+ inert module: it does nothing and exits 0, which the harness reads as "allow".
1883
+ Restart the session to pick up the new registration. A later install removes this
1884
+ file once no registration names it.
1885
+ """
1886
+ `;
1887
+
1888
+ const PYTHON_HOOK_SUFFIX = '.py';
1889
+
1890
+
1891
+ /**
1892
+ * List every hook command a settings object holds, across every event type.
1893
+ *
1894
+ * A group with no hooks array contributes nothing. An entry whose command is
1895
+ * absent or is not a string still reaches the caller.
1896
+ *
1897
+ * @param {object} settings The parsed settings.json object.
1898
+ * @returns {unknown[]} Every hook entry's command, in the order settings holds them.
1899
+ */
1900
+ function settingsHookCommands(settings) {
1901
+ const allCommands = [];
1902
+ for (const matcherGroups of Object.values(settings.hooks ?? {})) {
1903
+ if (!Array.isArray(matcherGroups)) continue;
1904
+ for (const group of matcherGroups) {
1905
+ for (const hook of groupHookEntries(group) ?? []) allCommands.push(hook?.command);
1906
+ }
1907
+ }
1908
+ return allCommands;
1909
+ }
1910
+
1911
+
1912
+ /**
1913
+ * Read the hook commands one host configuration file holds, or none when it
1914
+ * cannot be read. Claude settings.json and Codex hooks.json share this shape.
1915
+ *
1916
+ * Call this before the run mutates settings. The hook merge rewrites
1917
+ * settings.json early, and by prune time the file no longer names what a
1918
+ * session started before this install still invokes.
1919
+ *
1920
+ * @param {string} settingsPath The absolute host configuration path.
1921
+ * @returns {unknown[]} Every hook command the file holds.
1922
+ */
1923
+ export function settingsHookCommandsAtPath(settingsPath) {
1924
+ if (!existsSync(settingsPath)) return [];
1925
+ let settings;
1926
+ try {
1927
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1928
+ } catch {
1929
+ return [];
1930
+ }
1931
+ if (!settings?.hooks || typeof settings.hooks !== 'object') return [];
1932
+ return settingsHookCommands(settings);
1933
+ }
1934
+
1935
+
1936
+ /**
1937
+ * Name the retired hook scripts a stale registration still runs.
1938
+ *
1939
+ * The commands come from the reading taken before the run mutated settings.json.
1940
+ * Those paths are where a stand-in belongs.
1941
+ *
1942
+ * @param {unknown[]} allCommands Hook commands read before the run mutated settings.
1943
+ * @param {Set<string>} retiredHookRelativePaths Retired script paths under hooks/.
1944
+ * @param {{allOwnedHookRootKeys?: Set<string>, homeDirectory?: string}} [options] Test overrides.
1945
+ * @returns {Set<string>} The retired paths a command names.
1946
+ */
1947
+ export function retiredHookPathsNamedByCommands(
1948
+ allCommands, retiredHookRelativePaths, options = {},
1949
+ ) {
1950
+ const ownership = retiredHookOwnership(options);
1951
+ const namedPaths = new Set();
1952
+ for (const relativePath of retiredHookRelativePaths) {
1953
+ const namesThisPath = allCommands.some(
1954
+ command => commandNamesOwnedHookScript(command, relativePath, ownership),
1955
+ );
1956
+ if (namesThisPath) namedPaths.add(relativePath);
1957
+ }
1958
+ return namedPaths;
1959
+ }
1960
+
1961
+
1962
+ /**
1963
+ * Write an inert Python module at each retired hook path a stale registration
1964
+ * still runs.
1965
+ *
1966
+ * Deleting a registered script makes python3 exit 2. The harness treats a
1967
+ * PreToolUse exit 2 as a block, so every matching tool call is denied until
1968
+ * the session restarts. An inert module with one docstring exits 0, which the
1969
+ * harness treats as allow.
1970
+ *
1971
+ * An existing file is left in place. Each written path is recorded in the
1972
+ * manifest so a later install removes the stand-in once no registration names
1973
+ * it.
1974
+ *
1975
+ * @param {string} hooksRoot The absolute installed hooks directory.
1976
+ * @param {Set<string>} registeredRetiredRelativePaths Retired paths a registration names.
1977
+ * @returns {string[]} The absolute paths this run wrote a stand-in to.
1978
+ */
1979
+ export function writeRetiredHookStandIns(hooksRoot, registeredRetiredRelativePaths) {
1980
+ const writtenPaths = [];
1981
+ for (const relativePath of registeredRetiredRelativePaths) {
1982
+ if (!relativePath.endsWith(PYTHON_HOOK_SUFFIX)) continue;
1983
+ const standInPath = join(hooksRoot, ...relativePath.split('/'));
1984
+ if (existsSync(standInPath)) continue;
1985
+ mkdirSync(dirname(standInPath), { recursive: true });
1986
+ writeFileSync(standInPath, RETIRED_HOOK_STAND_IN_SOURCE);
1987
+ writtenPaths.push(standInPath);
1988
+ }
1989
+ return writtenPaths;
1990
+ }
1991
+
1992
+
1993
+ /**
1994
+ * List owned hooks directories that exist, one path per distinct real directory.
1995
+ *
1996
+ * On a healthy install the three roots are pointers to one directory. Keying
1997
+ * on the real path writes the stand-in once. A missing root is skipped.
1998
+ *
1999
+ * @param {string[]} allHookRootPaths The hooks directories this installer writes.
2000
+ * @returns {string[]} One path per distinct hooks directory that exists.
2001
+ */
2002
+ function distinctExistingHookRoots(allHookRootPaths) {
2003
+ const allRootKeys = new Set();
2004
+ const allDistinctRoots = [];
2005
+ for (const hookRootPath of allHookRootPaths) {
2006
+ const realHookRootPath = realPathOrNull(hookRootPath);
2007
+ if (!realHookRootPath) continue;
2008
+ const rootKey = comparisonKeyForPath(realHookRootPath);
2009
+ if (allRootKeys.has(rootKey)) continue;
2010
+ allRootKeys.add(rootKey);
2011
+ allDistinctRoots.push(hookRootPath);
2012
+ }
2013
+ return allDistinctRoots;
2014
+ }
2015
+
2016
+ /**
2017
+ * Leave an inert stand-in at each retired path under every owned hooks directory.
2018
+ *
2019
+ * @param {string[]} allHookRootPaths The hooks directories this installer writes.
2020
+ * @param {Set<string>} registeredRetiredRelativePaths Retired paths a registration names.
2021
+ * @returns {string[]} The absolute paths this run wrote a stand-in to.
2022
+ */
2023
+ function writeRetiredHookStandInsAcrossOwnedRoots(
2024
+ allHookRootPaths, registeredRetiredRelativePaths,
2025
+ ) {
2026
+ const allWrittenPaths = [];
2027
+ for (const hooksRoot of distinctExistingHookRoots(allHookRootPaths)) {
2028
+ allWrittenPaths.push(
2029
+ ...writeRetiredHookStandIns(hooksRoot, registeredRetiredRelativePaths),
2030
+ );
2031
+ }
2032
+ return allWrittenPaths;
2033
+ }
2034
+
2035
+ /**
2036
+ * Name each retired registration a stand-in now fills, by its path under hooks/.
2037
+ *
2038
+ * @param {string[]} allHookRootPaths The hooks directories this installer writes.
2039
+ * @param {string[]} standInPaths The stand-ins this run wrote.
2040
+ * @returns {string[]} The sorted relative names, one per registration.
2041
+ */
2042
+ function standInRegistrationNames(allHookRootPaths, standInPaths) {
2043
+ const allNames = new Set();
2044
+ for (const standInPath of standInPaths) {
2045
+ const owningRoot = allHookRootPaths.find(
2046
+ hookRootPath => isInsideDirectory(resolve(standInPath), resolve(hookRootPath)),
2047
+ );
2048
+ if (!owningRoot) continue;
2049
+ allNames.add(relative(owningRoot, standInPath).replace(/\\/g, '/'));
2050
+ }
2051
+ return [...allNames].sort();
2052
+ }
2053
+
2054
+ /**
2055
+ * Print the one-line restart notice naming the registrations this run retired.
2056
+ *
2057
+ * @param {string[]} allHookRootPaths The hooks directories this installer writes.
2058
+ * @param {string[]} standInPaths The stand-ins this run wrote.
2059
+ * @returns {void}
2060
+ */
2061
+ function reportRetiredHookStandIns(allHookRootPaths, standInPaths) {
2062
+ if (standInPaths.length === 0) return;
2063
+ const allNames = standInRegistrationNames(allHookRootPaths, standInPaths);
2064
+ console.log(
2065
+ ` Restart open sessions: this install retired ${allNames.join(', ')} and left an inert stand-in at each path, so a session started before it keeps working until you restart it.`,
2066
+ );
2067
+ }
2068
+
2069
+
1698
2070
  /**
1699
2071
  * Load ~/.claude/settings.json for an in-place merge, or `{}` when absent/empty.
1700
2072
  *
@@ -2000,46 +2372,78 @@ function pruneStaleFilesAcrossManagedRoots(priorInstalledFiles, currentInstalled
2000
2372
  return { prunedCount, skillsPrunedCount, failedPaths };
2001
2373
  }
2002
2374
 
2375
+ /**
2376
+ * Remove every retired registration from each host hook configuration file, and
2377
+ * report each file that lost one.
2378
+ *
2379
+ * Claude Code reads ~/.claude/settings.json and Codex reads ~/.codex/hooks.json.
2380
+ * A session of either host holds the registrations its own file named at start,
2381
+ * so a retirement that reaches only one file leaves the other host's open session
2382
+ * running a script this install moved.
2383
+ *
2384
+ * @param {string} settingsPath The absolute settings.json path.
2385
+ * @param {Set<string>} retiredHookPaths Retired script paths under hooks/.
2386
+ * @returns {void}
2387
+ */
2388
+ function pruneRetiredHookRegistrations(settingsPath, retiredHookPaths) {
2389
+ const allConfigurationPaths = [settingsPath, CODEX_HOOKS_CONFIGURATION_PATH];
2390
+ for (const configurationPath of allConfigurationPaths) {
2391
+ const removedCount = pruneRetiredHookEntriesFromSettings(
2392
+ configurationPath, retiredHookPaths,
2393
+ );
2394
+ if (removedCount === 0) continue;
2395
+ console.log(
2396
+ ` Hook entries: ${removedCount} retired entry(s) removed from ${basename(configurationPath)}`,
2397
+ );
2398
+ }
2399
+ }
2400
+
2003
2401
  /**
2004
2402
  * Run every prune a full install performs, in the order that keeps ~/.claude
2005
2403
  * consistent at each step.
2006
2404
  *
2007
- * The settings entries of retired hooks go before the file move: a settings.json
2008
- * naming a hook script that has already left ~/.claude/hooks makes every session
2009
- * start invoke a missing script, so the reference leaves first and the script
2010
- * follows.
2011
- *
2012
- * Both prunes report how much content reached the run's backup root, and their sum
2013
- * is what backup retention answers to: the sweep of older recovery points runs
2014
- * only once this run holds one of its own.
2405
+ * Retired hook entries leave settings before the script is moved, so a live
2406
+ * session does not invoke a missing file. Stand-ins are written last, after
2407
+ * the move has emptied those paths. Paths come from `priorSettingsHookCommands`,
2408
+ * read before the run mutated settings. The caller records each stand-in in
2409
+ * the manifest so a later install removes it once no registration names it.
2015
2410
  *
2016
2411
  * @param {Set<string>} copiedSkillNames Skill directory names this run wrote.
2017
2412
  * @param {string[]|null} priorManifestSkills The prior manifest's skill names, or null.
2018
2413
  * @param {string[]|null} priorManifestFiles The prior manifest's file list, or null.
2019
2414
  * @param {string[]} installedFiles Every file this run copied.
2020
- * @returns {{prunedCount: number, skillsPrunedCount: number, failedPaths: string[]}}
2021
- * The stale files moved across all roots, the skills root's share, and every
2022
- * path whose move failed.
2415
+ * @param {unknown[]} priorSettingsHookCommands Hook commands read before this run mutated settings.
2416
+ * @returns {{prunedCount: number, skillsPrunedCount: number, failedPaths: string[],
2417
+ * standInPaths: string[]}} The stale files moved across all roots, the skills
2418
+ * root's share, every path whose move failed, and every stand-in written.
2023
2419
  */
2024
2420
  function runFullInstallPrunes(
2025
- copiedSkillNames, priorManifestSkills, priorManifestFiles, installedFiles,
2421
+ copiedSkillNames,
2422
+ priorManifestSkills,
2423
+ priorManifestFiles,
2424
+ installedFiles,
2425
+ priorSettingsHookCommands,
2026
2426
  ) {
2427
+ const hooksRoot = join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME);
2428
+ const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
2027
2429
  const retiredSkillMovedCount = pruneRetiredSkills(copiedSkillNames, priorManifestSkills);
2028
- const removedHookEntryCount = pruneRetiredHookEntriesFromSettings(
2029
- join(CLAUDE_HOME, SETTINGS_FILE_NAME),
2030
- retiredManagedHookRelativePaths(
2031
- priorManifestFiles, installedFiles, join(CLAUDE_HOME, MANAGED_HOOKS_DIRECTORY_NAME),
2032
- ),
2430
+ const retiredHookPaths = retiredManagedHookRelativePaths(
2431
+ priorManifestFiles, installedFiles, hooksRoot,
2033
2432
  );
2034
- if (removedHookEntryCount > 0) {
2035
- console.log(` Hook entries: ${removedHookEntryCount} retired entry(s) removed from settings.json`);
2036
- }
2433
+ const registeredRetiredPaths = retiredHookPathsNamedByCommands(
2434
+ priorSettingsHookCommands, retiredHookPaths,
2435
+ );
2436
+ pruneRetiredHookRegistrations(settingsPath, retiredHookPaths);
2037
2437
  const staleOutcome = pruneStaleFilesAcrossManagedRoots(
2038
2438
  priorManifestFiles, installedFiles, currentRunBackupRoot(),
2039
2439
  );
2440
+ const standInPaths = writeRetiredHookStandInsAcrossOwnedRoots(
2441
+ MANAGED_HOOK_ROOT_PATHS, registeredRetiredPaths,
2442
+ );
2443
+ reportRetiredHookStandIns(MANAGED_HOOK_ROOT_PATHS, standInPaths);
2040
2444
  const didRunMoveContent = retiredSkillMovedCount + staleOutcome.prunedCount > 0;
2041
2445
  retainNewestRunBackupOnly(currentRunBackupRoot(), didRunMoveContent);
2042
- return staleOutcome;
2446
+ return { ...staleOutcome, standInPaths };
2043
2447
  }
2044
2448
 
2045
2449
  /**
@@ -2140,6 +2544,10 @@ function executeInstallPlan(plan) {
2140
2544
  */
2141
2545
  function executeInstallPlanMutations(plan, transactionHelpers) {
2142
2546
  const { throwIfFault, syncWrittenPaths } = transactionHelpers;
2547
+ const priorSettingsHookCommands = [
2548
+ ...settingsHookCommandsAtPath(join(CLAUDE_HOME, SETTINGS_FILE_NAME)),
2549
+ ...settingsHookCommandsAtPath(CODEX_HOOKS_CONFIGURATION_PATH),
2550
+ ];
2143
2551
  const selectedGroups = plan.selectedGroups;
2144
2552
  const priorManifestFiles = plan.priorManifest.files;
2145
2553
  const priorManifestSkills = plan.priorManifest.skills;
@@ -2487,11 +2895,16 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2487
2895
  if (didPruneRun) {
2488
2896
  try {
2489
2897
  const prunes = runFullInstallPrunes(
2490
- copiedSkillNames, priorManifestSkills, priorManifestFiles, allInstalledFiles,
2898
+ copiedSkillNames,
2899
+ priorManifestSkills,
2900
+ priorManifestFiles,
2901
+ allInstalledFiles,
2902
+ priorSettingsHookCommands,
2491
2903
  );
2492
2904
  summary.skills.pruned = prunes.skillsPrunedCount;
2493
2905
  stalePrunedTotal = prunes.prunedCount;
2494
2906
  failedPrunePaths = prunes.failedPaths;
2907
+ allInstalledFiles.push(...prunes.standInPaths);
2495
2908
  didPruneFinish = true;
2496
2909
  } catch (pruneError) {
2497
2910
  console.warn(
@@ -2835,7 +3248,6 @@ ${groupLines}
2835
3248
 
2836
3249
  Examples:
2837
3250
  npx ${PACKAGE_NAME} --only core
2838
- npx ${PACKAGE_NAME} --only core,journal
2839
3251
  npx ${PACKAGE_NAME} --profile editor --only core
2840
3252
  npx ${PACKAGE_NAME} --profiles editor,mel --only core
2841
3253
 
@@ -2845,6 +3257,9 @@ Named profiles resolve under LLM_SETTINGS_PROFILES_ROOT or ~/.claude-profiles/<d
2845
3257
  Codex exec-policy files copy into ~/.codex/rules, or CODEX_HOME/rules when CODEX_HOME is set.
2846
3258
  Cursor rule files generate into ~/.cursor/rules as stem-named mdc files, one per Claude rule.
2847
3259
 
3260
+ The installer reads only flags. A bare path argument carries no meaning, so
3261
+ npx ${PACKAGE_NAME} . lands in the same root as a run with no argument.
3262
+
2848
3263
  Root precedence: --target > CLAUDE_CONFIG_DIR > ~/.claude
2849
3264
  Profile selection (--profile/--profiles) is mutually exclusive with --target.
2850
3265
  Target selection finishes before any mutation. Each target writes its own manifest