claude-dev-env 7.0.0 → 7.1.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 +189 -0
  6. package/.agents/skills/session-continuity/SKILL.md +114 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +375 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +303 -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 +78 -0
  20. package/bin/install-session-continuity.test.mjs +42 -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
@@ -14,7 +14,7 @@ import {
14
14
  } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { join, dirname } from 'node:path';
17
- import { pathToFileURL } from 'node:url';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
18
 
19
19
  import {
20
20
  collectPackageSourceConflicts,
@@ -42,14 +42,17 @@ import {
42
42
  caseOnlyRenameSourceName,
43
43
  retiredManagedHookRelativePaths,
44
44
  pruneRetiredHookEntriesFromSettings,
45
+ settingsHookCommandsAtPath,
46
+ retiredHookPathsNamedByCommands,
47
+ writeRetiredHookStandIns,
48
+ ownedHookRootComparisonKeys,
49
+ commandNamesOwnedHookScript,
50
+ hookCommandComparisonSpelling,
51
+ detectPython,
45
52
  retainNewestRunBackupOnly,
46
53
  refreshInstalledPstackPluginManifest,
47
54
  } from './install.mjs';
48
55
  import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
49
- import {
50
- expandHomeDirectoryTokens,
51
- expandHomeDirectoryTokensInSettings,
52
- } from './expand_home_directory_tokens.mjs';
53
56
 
54
57
 
55
58
  test('installer reports the authoritative Git hook names', () => {
@@ -348,115 +351,6 @@ test('interpreterCommandFromPath quotes an interpreter path that contains a spac
348
351
  });
349
352
 
350
353
 
351
- test('expandHomeDirectoryTokens expands $HOME, ${HOME}, and ~/', () => {
352
- assert.equal(
353
- expandHomeDirectoryTokens(
354
- 'python $HOME/.claude/hooks/session/fix_worktree_hookspath.py',
355
- 'C:\\Users\\x',
356
- ),
357
- 'python C:/Users/x/.claude/hooks/session/fix_worktree_hookspath.py',
358
- );
359
- assert.equal(
360
- expandHomeDirectoryTokens('python ${HOME}/.claude/hooks/a.py', '/home/x'),
361
- 'python /home/x/.claude/hooks/a.py',
362
- );
363
- assert.equal(
364
- expandHomeDirectoryTokens('python ~/.claude/hooks/a.py', '/home/x'),
365
- 'python /home/x/.claude/hooks/a.py',
366
- );
367
- assert.equal(
368
- expandHomeDirectoryTokens('echo $HOMEPATH', 'C:/Users/x'),
369
- 'echo $HOMEPATH',
370
- );
371
- });
372
-
373
-
374
- test('expandHomeDirectoryTokens inserts dollar characters in home paths literally', () => {
375
- const homeWithReplaceMetacharacters = 'C:/Users/$&evil$1';
376
- assert.equal(
377
- expandHomeDirectoryTokens('python $HOME/.claude/a.py', homeWithReplaceMetacharacters),
378
- 'python C:/Users/$&evil$1/.claude/a.py',
379
- );
380
- assert.equal(
381
- expandHomeDirectoryTokens('python ${HOME}/.claude/a.py', homeWithReplaceMetacharacters),
382
- 'python C:/Users/$&evil$1/.claude/a.py',
383
- );
384
- assert.equal(
385
- expandHomeDirectoryTokens('python ~/.claude/a.py', homeWithReplaceMetacharacters),
386
- 'python C:/Users/$&evil$1/.claude/a.py',
387
- );
388
- });
389
-
390
-
391
- test('expandHomeDirectoryTokens expands ${HOME} before $HOME so braces stay intact', () => {
392
- assert.equal(
393
- expandHomeDirectoryTokens('python ${HOME}/.claude/a.py', '/home/x'),
394
- 'python /home/x/.claude/a.py',
395
- );
396
- });
397
-
398
-
399
- test('expandHomeDirectoryTokens strips trailing slashes from the home directory', () => {
400
- assert.equal(
401
- expandHomeDirectoryTokens('python $HOME/.claude/a.py', 'C:/Users/x/'),
402
- 'python C:/Users/x/.claude/a.py',
403
- );
404
- });
405
-
406
-
407
- test('expandHomeDirectoryTokensInSettings skips non-array hook event values', () => {
408
- const settings = {
409
- hooks: {
410
- SessionStart: 'not-an-array',
411
- PreToolUse: [
412
- {
413
- matcher: 'Write',
414
- hooks: [{ type: 'command', command: 'python $HOME/.claude/hooks/a.py' }],
415
- },
416
- ],
417
- },
418
- };
419
- expandHomeDirectoryTokensInSettings(settings, 'C:/Users/x');
420
- assert.equal(settings.hooks.SessionStart, 'not-an-array');
421
- assert.equal(
422
- settings.hooks.PreToolUse[0].hooks[0].command,
423
- 'python C:/Users/x/.claude/hooks/a.py',
424
- );
425
- });
426
-
427
-
428
- test('expandHomeDirectoryTokensInSettings rewrites hooks and statusLine', () => {
429
- const settings = {
430
- hooks: {
431
- SessionStart: [
432
- {
433
- matcher: '',
434
- hooks: [
435
- {
436
- type: 'command',
437
- command: 'python $HOME/.claude/hooks/session/fix_worktree_hookspath.py',
438
- },
439
- ],
440
- },
441
- ],
442
- },
443
- statusLine: {
444
- type: 'command',
445
- command: 'python "$HOME/.claude/statusline-command.py"',
446
- },
447
- };
448
- expandHomeDirectoryTokensInSettings(settings, 'C:/Users/x');
449
- assert.equal(
450
- settings.hooks.SessionStart[0].hooks[0].command,
451
- 'python C:/Users/x/.claude/hooks/session/fix_worktree_hookspath.py',
452
- );
453
- assert.equal(
454
- settings.statusLine.command,
455
- 'python "C:/Users/x/.claude/statusline-command.py"',
456
- );
457
- });
458
-
459
-
460
354
  test('mergeHooksIntoSettings expands residual $HOME in preserved user hooks', () => {
461
355
  const hooksConfig = {
462
356
  hooks: {
@@ -920,6 +814,19 @@ test('commandReferencesManagedHook matches a managed script followed by a whites
920
814
  });
921
815
 
922
816
 
817
+ test('commandReferencesManagedHook matches a shipped script name under a hooks root this installer never writes', () => {
818
+ const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
819
+
820
+ assert.ok(
821
+ commandReferencesManagedHook(
822
+ 'python3 "/other-tool/.claude/hooks/blocking/code_rules_enforcer.py"',
823
+ managedPaths,
824
+ ),
825
+ 'the merge matcher reads the /.claude/hooks/ tail alone, so another tool\'s copy of a shipped script name is inside the managed set',
826
+ );
827
+ });
828
+
829
+
923
830
  test('commandReferencesManagedHook matches the rewritten inline validators-runner hook that carries no script tail', () => {
924
831
  const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
925
832
  const rewrittenInlineCommand =
@@ -1266,6 +1173,35 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
1266
1173
  'session/untracked_repo_detector.py',
1267
1174
  'session/gh_pr_author_session_cleanup.py',
1268
1175
  'observability/pr_description_writer_spawn_tracker.py',
1176
+ 'blocking/plain_language_blocker.py',
1177
+ 'lifecycle/config_change_guard.py',
1178
+ 'blocking/docstring_rule_gate_count_blocker.py',
1179
+ 'blocking/write_existing_file_blocker.py',
1180
+ 'blocking/sensitive_file_protector.py',
1181
+ 'blocking/pii_prevention_blocker.py',
1182
+ 'blocking/duplicate_rmtree_helper_blocker.py',
1183
+ 'blocking/claude_md_orphan_file_blocker.py',
1184
+ 'blocking/package_inventory_stale_blocker.py',
1185
+ 'blocking/env_var_table_code_drift_blocker.py',
1186
+ 'blocking/pytest_testpaths_orphan_blocker.py',
1187
+ 'blocking/destructive_command_blocker.py',
1188
+ 'blocking/shell_substitution_blocker.py',
1189
+ 'blocking/piped_pytest_blocker.py',
1190
+ 'blocking/cursor_cli_python_misfire_blocker.py',
1191
+ 'blocking/unscoped_search_blocker.py',
1192
+ 'blocking/nas_ssh_binary_enforcer.py',
1193
+ 'blocking/block_main_commit.py',
1194
+ 'blocking/session_edit_stage_gate.py',
1195
+ 'blocking/bash_pre_tool_use_dispatcher.py',
1196
+ 'blocking/stop_dispatcher.py',
1197
+ 'blocking/bot_mention_comment_blocker.py',
1198
+ 'blocking/fable_spawn_gate.py',
1199
+ 'blocking/luna_fast_mode_gate.py',
1200
+ 'blocking/orchestrator_refresh_reschedule_gate.py',
1201
+ 'blocking/ask_user_question_shape_blocker.py',
1202
+ 'blocking/send_user_file_open_locally_blocker.py',
1203
+ 'blocking/question_to_user_enforcer.py',
1204
+ 'blocking/session_handoff_blocker.py',
1269
1205
  ]);
1270
1206
  const shippedHooks = JSON.parse(
1271
1207
  readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
@@ -1417,7 +1353,7 @@ test('mergeHooksIntoSettings is idempotent when run twice against an already-upd
1417
1353
  });
1418
1354
 
1419
1355
 
1420
- test('shipped hooks.json matches the dispatcher design: dispatchers registered, run_all_validators retained, no folded hook standalone', () => {
1356
+ test('shipped hooks.json keeps only nonblocking policy handlers and lifecycle hooks', () => {
1421
1357
  const shippedHooksConfig = JSON.parse(
1422
1358
  readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
1423
1359
  );
@@ -1429,8 +1365,8 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
1429
1365
 
1430
1366
  assert.equal(
1431
1367
  countManagedRunAllValidatorsHooks(shippedHooksConfig),
1432
- 1,
1433
- 'shipped hooks.json must retain the inline run_all_validators runner on the write path',
1368
+ 0,
1369
+ 'shipped hooks.json must retire the inline run_all_validators runner from the write path',
1434
1370
  );
1435
1371
 
1436
1372
  const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
@@ -1438,6 +1374,12 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
1438
1374
  .flatMap(group => group.hooks.map(hook => hook.command))
1439
1375
  .filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
1440
1376
  assert.equal(postDispatcherCommands.length, 1, 'shipped hooks.json must register the PostToolUse dispatcher exactly once');
1377
+ const allCommands = Object.values(shippedHooksConfig.hooks).flatMap(matcherGroups => (
1378
+ matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command))
1379
+ ));
1380
+ for (const retiredPath of RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS) {
1381
+ assert.ok(allCommands.every(command => !command.includes(retiredPath)), retiredPath);
1382
+ }
1441
1383
 
1442
1384
  const allSessionStartCommands = (shippedHooksConfig.hooks.SessionStart || [])
1443
1385
  .flatMap(group => group.hooks.map(hook => hook.command));
@@ -2184,6 +2126,22 @@ function createSettingsFixture(settings, indentWidth) {
2184
2126
  }
2185
2127
 
2186
2128
 
2129
+ /**
2130
+ * Build the ownership one sandbox's own ~/.claude/hooks root supplies.
2131
+ *
2132
+ * A retirement walk that falls back to its own defaults reads the installed hook
2133
+ * roots and the home directory of the machine running the test, so the answer
2134
+ * changes with the host. Handing every walk a sandbox ownership keeps the test
2135
+ * reading the fixture it wrote and nothing else.
2136
+ *
2137
+ * @param {string} sandboxRoot The sandbox home the `$HOME` and `~` spellings expand to.
2138
+ * @returns {{allOwnedHookRootKeys: Set<string>, homeDirectory: string}} The ownership.
2139
+ */
2140
+ function sandboxClaudeHooksOwnership(sandboxRoot) {
2141
+ return sandboxHookOwnership(sandboxRoot, [join(sandboxRoot, '.claude', 'hooks')]);
2142
+ }
2143
+
2144
+
2187
2145
  test('pruneRetiredHookEntriesFromSettings removes the retired entry and keeps the user-authored one', () => {
2188
2146
  const retiredCommand = `python3 "$HOME/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}"`;
2189
2147
  const userCommand = 'python3 my_own_gate.py --user-authored';
@@ -2206,7 +2164,9 @@ test('pruneRetiredHookEntriesFromSettings removes the retired entry and keeps th
2206
2164
  }, 4);
2207
2165
  try {
2208
2166
  const removedCount = pruneRetiredHookEntriesFromSettings(
2209
- settingsPath, new Set([RETIRED_HOOK_RELATIVE_PATH]),
2167
+ settingsPath,
2168
+ new Set([RETIRED_HOOK_RELATIVE_PATH]),
2169
+ sandboxClaudeHooksOwnership(sandboxRoot),
2210
2170
  );
2211
2171
 
2212
2172
  assert.equal(removedCount, 2, 'both entries running the retired script are removed');
@@ -2251,7 +2211,9 @@ test('pruneRetiredHookEntriesFromSettings keeps every settings shape the install
2251
2211
  }, 4);
2252
2212
  try {
2253
2213
  const removedCount = pruneRetiredHookEntriesFromSettings(
2254
- settingsPath, new Set([RETIRED_HOOK_RELATIVE_PATH]),
2214
+ settingsPath,
2215
+ new Set([RETIRED_HOOK_RELATIVE_PATH]),
2216
+ sandboxClaudeHooksOwnership(sandboxRoot),
2255
2217
  );
2256
2218
 
2257
2219
  assert.equal(removedCount, 1, 'the one entry running the retired script is the only removal');
@@ -2392,7 +2354,9 @@ test('pruneRetiredHookEntriesFromSettings leaves settings.json byte-identical wh
2392
2354
  const bytesBefore = readFileSync(settingsPath, 'utf8');
2393
2355
 
2394
2356
  const removedCount = pruneRetiredHookEntriesFromSettings(
2395
- settingsPath, new Set([RETIRED_HOOK_RELATIVE_PATH]),
2357
+ settingsPath,
2358
+ new Set([RETIRED_HOOK_RELATIVE_PATH]),
2359
+ sandboxClaudeHooksOwnership(sandboxRoot),
2396
2360
  );
2397
2361
 
2398
2362
  assert.equal(removedCount, 0, 'no entry runs a retired script');
@@ -2407,6 +2371,361 @@ test('pruneRetiredHookEntriesFromSettings leaves settings.json byte-identical wh
2407
2371
  });
2408
2372
 
2409
2373
 
2374
+ const ALL_HOST_CONFIGURATION_INDENT_WIDTHS = [2, 4];
2375
+
2376
+
2377
+ /**
2378
+ * Build a settings fixture whose one matcher group registers a retired hook
2379
+ * beside a user-authored entry, so a prune removes one entry and rewrites the file.
2380
+ *
2381
+ * @param {string} relativePath The retired hook path the registration runs.
2382
+ * @returns {object} A settings object holding both entries.
2383
+ */
2384
+ function settingsRegisteringRetiredAndUserHooks(relativePath) {
2385
+ return {
2386
+ hooks: {
2387
+ PreToolUse: [{
2388
+ matcher: 'Write|Edit',
2389
+ hooks: [
2390
+ { type: 'command', command: `python3 "$HOME/.claude/hooks/${relativePath}"` },
2391
+ { type: 'command', command: 'python3 my_own_gate.py --user-authored' },
2392
+ ],
2393
+ }],
2394
+ },
2395
+ };
2396
+ }
2397
+
2398
+
2399
+ test('pruneRetiredHookEntriesFromSettings writes back the indentation the host configuration file already used', () => {
2400
+ for (const indentWidth of ALL_HOST_CONFIGURATION_INDENT_WIDTHS) {
2401
+ const { settingsPath, sandboxRoot } = createSettingsFixture(
2402
+ settingsRegisteringRetiredAndUserHooks(RETIRED_HOOK_RELATIVE_PATH), indentWidth,
2403
+ );
2404
+ try {
2405
+ const removedCount = pruneRetiredHookEntriesFromSettings(
2406
+ settingsPath,
2407
+ new Set([RETIRED_HOOK_RELATIVE_PATH]),
2408
+ sandboxClaudeHooksOwnership(sandboxRoot),
2409
+ );
2410
+
2411
+ assert.equal(removedCount, 1, `the retired entry leaves the ${indentWidth}-space file`);
2412
+ const settingsText = readFileSync(settingsPath, 'utf8');
2413
+ assert.equal(
2414
+ settingsText,
2415
+ JSON.stringify(JSON.parse(settingsText), null, indentWidth) + '\n',
2416
+ `a file written with ${indentWidth} spaces is handed back with ${indentWidth} spaces`,
2417
+ );
2418
+ } finally {
2419
+ rmSync(sandboxRoot, { recursive: true, force: true });
2420
+ }
2421
+ }
2422
+ });
2423
+
2424
+
2425
+ const SECOND_RETIRED_HOOK_RELATIVE_PATH = 'blocking/retired_second_gate.py';
2426
+
2427
+
2428
+ /**
2429
+ * Build a sandbox holding a ~/.claude/hooks root and a settings.json beside it.
2430
+ *
2431
+ * @param {object} settings The settings object to serialize.
2432
+ * @returns {{sandboxRoot: string, hooksRoot: string, settingsPath: string}} The sandbox paths.
2433
+ */
2434
+ function createRetiredHookSandbox(settings) {
2435
+ const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-retired-hook-stand-in-'));
2436
+ const hooksRoot = join(sandboxRoot, '.claude', 'hooks');
2437
+ mkdirSync(hooksRoot, { recursive: true });
2438
+ const settingsPath = join(sandboxRoot, 'settings.json');
2439
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
2440
+ return { sandboxRoot, hooksRoot, settingsPath };
2441
+ }
2442
+
2443
+
2444
+ /**
2445
+ * Build a settings fixture registering one retired hook command.
2446
+ *
2447
+ * @param {string} relativePath The retired hook path the registration runs.
2448
+ * @returns {object} A settings object holding that registration and a user entry.
2449
+ */
2450
+ function settingsRegistering(relativePath) {
2451
+ return {
2452
+ hooks: {
2453
+ PreToolUse: [{
2454
+ matcher: 'AskUserQuestion',
2455
+ hooks: [
2456
+ { type: 'command', command: `python3 "$HOME/.claude/hooks/${relativePath}"` },
2457
+ { type: 'command', command: 'python3 my_own_gate.py --user-authored' },
2458
+ ],
2459
+ }],
2460
+ },
2461
+ };
2462
+ }
2463
+
2464
+
2465
+ test('retiredHookPathsNamedByCommands names only the retired scripts a live registration runs', () => {
2466
+ const { sandboxRoot, settingsPath } = createRetiredHookSandbox(
2467
+ settingsRegistering(RETIRED_HOOK_RELATIVE_PATH),
2468
+ );
2469
+ try {
2470
+ const namedPaths = retiredHookPathsNamedByCommands(
2471
+ settingsHookCommandsAtPath(settingsPath),
2472
+ new Set([RETIRED_HOOK_RELATIVE_PATH, SECOND_RETIRED_HOOK_RELATIVE_PATH]),
2473
+ sandboxClaudeHooksOwnership(sandboxRoot),
2474
+ );
2475
+
2476
+ assert.deepEqual(
2477
+ [...namedPaths],
2478
+ [RETIRED_HOOK_RELATIVE_PATH],
2479
+ 'the registered retired script is named and the unregistered one is left out',
2480
+ );
2481
+ } finally {
2482
+ rmSync(sandboxRoot, { recursive: true, force: true });
2483
+ }
2484
+ });
2485
+
2486
+
2487
+ test('settingsHookCommandsAtPath reads no command from a settings file it cannot use', () => {
2488
+ const { sandboxRoot, settingsPath } = createRetiredHookSandbox({ hooks: {} });
2489
+ try {
2490
+ writeFileSync(settingsPath, '{ this is not JSON');
2491
+
2492
+ assert.deepEqual(
2493
+ settingsHookCommandsAtPath(settingsPath),
2494
+ [],
2495
+ 'settings the installer cannot parse hold no command',
2496
+ );
2497
+ assert.deepEqual(
2498
+ settingsHookCommandsAtPath(join(sandboxRoot, 'absent.json')),
2499
+ [],
2500
+ 'a settings file that is not there holds no command',
2501
+ );
2502
+ assert.equal(
2503
+ retiredHookPathsNamedByCommands(
2504
+ [],
2505
+ new Set([RETIRED_HOOK_RELATIVE_PATH]),
2506
+ sandboxClaudeHooksOwnership(sandboxRoot),
2507
+ ).size,
2508
+ 0,
2509
+ 'with no command to read, no retired script is named',
2510
+ );
2511
+ } finally {
2512
+ rmSync(sandboxRoot, { recursive: true, force: true });
2513
+ }
2514
+ });
2515
+
2516
+
2517
+ test('a stale registration keeps working because the retired hook leaves an inert stand-in behind', () => {
2518
+ const { sandboxRoot, hooksRoot, settingsPath } = createRetiredHookSandbox(
2519
+ settingsRegistering(RETIRED_HOOK_RELATIVE_PATH),
2520
+ );
2521
+ try {
2522
+ const namedPaths = retiredHookPathsNamedByCommands(
2523
+ settingsHookCommandsAtPath(settingsPath),
2524
+ new Set([RETIRED_HOOK_RELATIVE_PATH, SECOND_RETIRED_HOOK_RELATIVE_PATH]),
2525
+ sandboxClaudeHooksOwnership(sandboxRoot),
2526
+ );
2527
+
2528
+ const standInPaths = writeRetiredHookStandIns(hooksRoot, namedPaths);
2529
+
2530
+ const standInPath = join(hooksRoot, ...RETIRED_HOOK_RELATIVE_PATH.split('/'));
2531
+ assert.deepEqual(standInPaths, [standInPath], 'one stand-in is written, for the registered path');
2532
+ assert.equal(
2533
+ existsSync(join(hooksRoot, ...SECOND_RETIRED_HOOK_RELATIVE_PATH.split('/'))),
2534
+ false,
2535
+ 'a retired path no registration names gets no stand-in',
2536
+ );
2537
+
2538
+ const standInRun = spawnSync(detectPython(), [standInPath], { encoding: 'utf8' });
2539
+
2540
+ assert.equal(standInRun.status, 0, 'the stand-in exits 0, so a stale registration allows the tool');
2541
+ assert.equal(standInRun.stdout, '', 'the stand-in writes nothing to stdout');
2542
+ assert.equal(standInRun.stderr, '', 'the stand-in writes nothing to stderr');
2543
+ } finally {
2544
+ rmSync(sandboxRoot, { recursive: true, force: true });
2545
+ }
2546
+ });
2547
+
2548
+
2549
+ test('writeRetiredHookStandIns leaves a hook file the prune could not move exactly as it stands', () => {
2550
+ const { sandboxRoot, hooksRoot } = createRetiredHookSandbox({ hooks: {} });
2551
+ try {
2552
+ const survivingPath = join(hooksRoot, ...RETIRED_HOOK_RELATIVE_PATH.split('/'));
2553
+ mkdirSync(dirname(survivingPath), { recursive: true });
2554
+ const survivingContents = 'raise SystemExit(2)\n';
2555
+ writeFileSync(survivingPath, survivingContents);
2556
+
2557
+ const standInPaths = writeRetiredHookStandIns(
2558
+ hooksRoot, new Set([RETIRED_HOOK_RELATIVE_PATH]),
2559
+ );
2560
+
2561
+ assert.deepEqual(standInPaths, [], 'a path still holding a file gets no stand-in');
2562
+ assert.equal(
2563
+ readFileSync(survivingPath, 'utf8'),
2564
+ survivingContents,
2565
+ 'the file a failed prune left behind keeps its own bytes',
2566
+ );
2567
+ } finally {
2568
+ rmSync(sandboxRoot, { recursive: true, force: true });
2569
+ }
2570
+ });
2571
+
2572
+
2573
+ const AGENTS_HOOKS_ROOT_SEGMENTS = ['.agents', 'hooks'];
2574
+ const CODEX_HOOKS_ROOT_SEGMENTS = ['.codex', 'hooks'];
2575
+ const FOREIGN_HOOKS_ROOT_SEGMENTS = ['other-tool', '.claude', 'hooks'];
2576
+
2577
+
2578
+ /**
2579
+ * Build the ownership one retirement walk reads for one sandbox.
2580
+ *
2581
+ * @param {string} sandboxRoot The sandbox home the `$HOME` spelling expands to.
2582
+ * @param {string[]} allHookRootPaths The hooks directories the installer owns there.
2583
+ * @returns {{allOwnedHookRootKeys: Set<string>, homeDirectory: string}} The ownership.
2584
+ */
2585
+ function sandboxHookOwnership(sandboxRoot, allHookRootPaths) {
2586
+ return {
2587
+ allOwnedHookRootKeys: ownedHookRootComparisonKeys(allHookRootPaths),
2588
+ homeDirectory: sandboxRoot,
2589
+ };
2590
+ }
2591
+
2592
+
2593
+ /**
2594
+ * Build the command one hook registration runs for a script under a hooks root.
2595
+ *
2596
+ * @param {string} hooksRoot The hooks directory the registration names.
2597
+ * @param {string} relativePath The script path under that directory.
2598
+ * @returns {string} The registration command.
2599
+ */
2600
+ function hookCommandUnder(hooksRoot, relativePath) {
2601
+ const scriptPath = join(hooksRoot, ...relativePath.split('/')).replace(/\\/g, '/');
2602
+ return `python3 "${scriptPath}"`;
2603
+ }
2604
+
2605
+
2606
+ test('a retired registration spelled under the agents hooks root is named, because both roots are owned', () => {
2607
+ const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-owned-hook-roots-'));
2608
+ try {
2609
+ const claudeHooksRoot = join(sandboxRoot, '.claude', 'hooks');
2610
+ const agentsHooksRoot = join(sandboxRoot, ...AGENTS_HOOKS_ROOT_SEGMENTS);
2611
+ const codexHooksRoot = join(sandboxRoot, ...CODEX_HOOKS_ROOT_SEGMENTS);
2612
+
2613
+ const namedPaths = retiredHookPathsNamedByCommands(
2614
+ [
2615
+ hookCommandUnder(agentsHooksRoot, RETIRED_HOOK_RELATIVE_PATH),
2616
+ hookCommandUnder(codexHooksRoot, SECOND_RETIRED_HOOK_RELATIVE_PATH),
2617
+ ],
2618
+ new Set([RETIRED_HOOK_RELATIVE_PATH, SECOND_RETIRED_HOOK_RELATIVE_PATH]),
2619
+ sandboxHookOwnership(
2620
+ sandboxRoot, [claudeHooksRoot, agentsHooksRoot, codexHooksRoot],
2621
+ ),
2622
+ );
2623
+
2624
+ assert.deepEqual(
2625
+ [...namedPaths].sort(),
2626
+ [RETIRED_HOOK_RELATIVE_PATH, SECOND_RETIRED_HOOK_RELATIVE_PATH].sort(),
2627
+ 'a registration under any owned hooks root names its retired script',
2628
+ );
2629
+ } finally {
2630
+ rmSync(sandboxRoot, { recursive: true, force: true });
2631
+ }
2632
+ });
2633
+
2634
+
2635
+ test('a retired registration reaching an owned root through a junction is named', () => {
2636
+ const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-owned-hook-junction-'));
2637
+ try {
2638
+ const agentsHooksRoot = join(sandboxRoot, ...AGENTS_HOOKS_ROOT_SEGMENTS);
2639
+ const claudeHooksRoot = join(sandboxRoot, '.claude', 'hooks');
2640
+ mkdirSync(agentsHooksRoot, { recursive: true });
2641
+ mkdirSync(dirname(claudeHooksRoot), { recursive: true });
2642
+ symlinkSync(agentsHooksRoot, claudeHooksRoot, 'junction');
2643
+
2644
+ const namedPaths = retiredHookPathsNamedByCommands(
2645
+ [hookCommandUnder(agentsHooksRoot, RETIRED_HOOK_RELATIVE_PATH)],
2646
+ new Set([RETIRED_HOOK_RELATIVE_PATH]),
2647
+ sandboxHookOwnership(sandboxRoot, [claudeHooksRoot]),
2648
+ );
2649
+
2650
+ assert.deepEqual(
2651
+ [...namedPaths],
2652
+ [RETIRED_HOOK_RELATIVE_PATH],
2653
+ 'the junction and the directory it reaches are one owned hooks root',
2654
+ );
2655
+ } finally {
2656
+ rmSync(sandboxRoot, { recursive: true, force: true });
2657
+ }
2658
+ });
2659
+
2660
+
2661
+ test('a registration under a hooks root the installer does not own is never named or pruned', () => {
2662
+ const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-foreign-hook-root-'));
2663
+ try {
2664
+ const claudeHooksRoot = join(sandboxRoot, '.claude', 'hooks');
2665
+ const foreignHooksRoot = join(sandboxRoot, ...FOREIGN_HOOKS_ROOT_SEGMENTS);
2666
+ const foreignCommand = hookCommandUnder(foreignHooksRoot, RETIRED_HOOK_RELATIVE_PATH);
2667
+ const settingsPath = join(sandboxRoot, 'settings.json');
2668
+ writeFileSync(settingsPath, JSON.stringify({
2669
+ hooks: {
2670
+ PreToolUse: [{
2671
+ matcher: '*',
2672
+ hooks: [{ type: 'command', command: foreignCommand }],
2673
+ }],
2674
+ },
2675
+ }, null, 4) + '\n');
2676
+ const ownership = sandboxHookOwnership(sandboxRoot, [claudeHooksRoot]);
2677
+
2678
+ const namedPaths = retiredHookPathsNamedByCommands(
2679
+ [foreignCommand], new Set([RETIRED_HOOK_RELATIVE_PATH]), ownership,
2680
+ );
2681
+ const removedCount = pruneRetiredHookEntriesFromSettings(
2682
+ settingsPath, new Set([RETIRED_HOOK_RELATIVE_PATH]), ownership,
2683
+ );
2684
+
2685
+ assert.equal(namedPaths.size, 0, 'a foreign registration earns no stand-in');
2686
+ assert.equal(removedCount, 0, 'a foreign registration is never pruned');
2687
+ assert.equal(
2688
+ JSON.parse(readFileSync(settingsPath, 'utf8')).hooks.PreToolUse[0].hooks[0].command,
2689
+ foreignCommand,
2690
+ 'the foreign entry stays exactly as the other tool wrote it',
2691
+ );
2692
+ } finally {
2693
+ rmSync(sandboxRoot, { recursive: true, force: true });
2694
+ }
2695
+ });
2696
+
2697
+
2698
+ const SANDBOX_HOME_DIRECTORY = process.platform === 'win32'
2699
+ ? 'c:/cdev-sandbox-home'
2700
+ : '/cdev-sandbox-home';
2701
+
2702
+
2703
+ test('a hook command spelled with a leading tilde expands to the home directory the tilde stands for', () => {
2704
+ const tildeCommand = `python3 ~/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}`;
2705
+
2706
+ const comparisonSpelling = hookCommandComparisonSpelling(
2707
+ tildeCommand, SANDBOX_HOME_DIRECTORY,
2708
+ );
2709
+
2710
+ assert.equal(
2711
+ comparisonSpelling,
2712
+ `python3 ${SANDBOX_HOME_DIRECTORY}/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}`,
2713
+ 'the home directory replaces the tilde rather than following it',
2714
+ );
2715
+ assert.ok(
2716
+ commandNamesOwnedHookScript(
2717
+ tildeCommand,
2718
+ RETIRED_HOOK_RELATIVE_PATH,
2719
+ sandboxHookOwnership(
2720
+ SANDBOX_HOME_DIRECTORY,
2721
+ [join(SANDBOX_HOME_DIRECTORY, '.claude', 'hooks')],
2722
+ ),
2723
+ ),
2724
+ 'the expanded command reaches the owned hooks root',
2725
+ );
2726
+ });
2727
+
2728
+
2410
2729
  const PRIOR_RUN_BACKUP_NAMES = ['2020-01-01T00-00-00-000Z', '2021-06-15T12-30-45-123Z'];
2411
2730
  const THIS_RUN_BACKUP_NAME = '2026-07-25T18-04-11-923Z';
2412
2731
  const MOVED_SKILL_BACKUP_SEGMENTS = ['skills', 'demo'];
@@ -2578,3 +2897,64 @@ test('the pstack step reports no path for a skills root without pstack', () => {
2578
2897
  rmSync(skillsRoot, { recursive: true, force: true });
2579
2898
  }
2580
2899
  });
2900
+
2901
+ const RETIRED_SKILLS = ['pr-small-cl', 'session-log', 'session-tidy'];
2902
+
2903
+ test('each core skill has a shipped entry point', () => {
2904
+ for (const skillName of CORE_SKILLS) {
2905
+ assert.ok(existsSync(new URL(`../.agents/skills/${skillName}/SKILL.md`, import.meta.url)), skillName);
2906
+ }
2907
+ });
2908
+
2909
+ test('retired skills leave install groups and stay in the cleanup registry', () => {
2910
+ const selectedSkills = Object.values(INSTALL_GROUPS).flatMap(group => group.skills ?? []);
2911
+ assert.equal(Object.hasOwn(INSTALL_GROUPS, 'journal'), false);
2912
+ for (const skillName of RETIRED_SKILLS) {
2913
+ assert.equal(selectedSkills.includes(skillName), false, skillName);
2914
+ assert.ok(EVER_SHIPPED_SKILL_NAMES.has(skillName), skillName);
2915
+ }
2916
+ });
2917
+
2918
+ test('README and vault guidance use current session tools', () => {
2919
+ const readme = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8');
2920
+ const vaultRule = readFileSync(new URL('../rules/vault-context.md', import.meta.url), 'utf8');
2921
+ assert.doesNotMatch(readme, /--only journal|session-log|session-tidy/);
2922
+ assert.doesNotMatch(vaultRule, /`session-log`|`recall`/);
2923
+ assert.match(vaultRule, /obsidian MCP tools/);
2924
+ assert.match(vaultRule, /project.*frontmatter/);
2925
+ });
2926
+
2927
+ test('the help output states that the installer reads only flags', () => {
2928
+ const helpRun = spawnSync(
2929
+ process.execPath,
2930
+ [fileURLToPath(new URL('./install.mjs', import.meta.url)), '--help'],
2931
+ { encoding: 'utf8', env: process.env },
2932
+ );
2933
+
2934
+ assert.equal(helpRun.status, 0, helpRun.stderr);
2935
+ assert.match(helpRun.stdout, /reads only flags/i);
2936
+ assert.match(helpRun.stdout, /bare path argument carries no meaning/i);
2937
+ });
2938
+
2939
+
2940
+
2941
+ test('pruneRetiredHookEntriesFromSettings warns and returns 0 when the host configuration file cannot be read', () => {
2942
+ const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-unreadable-host-configuration-'));
2943
+ const unreadableConfigurationPath = join(sandboxRoot, 'hooks.json');
2944
+ mkdirSync(unreadableConfigurationPath);
2945
+ try {
2946
+ const { returnedValue, allWarnings } = captureWarnings(() => (
2947
+ pruneRetiredHookEntriesFromSettings(
2948
+ unreadableConfigurationPath,
2949
+ new Set([RETIRED_HOOK_RELATIVE_PATH]),
2950
+ sandboxClaudeHooksOwnership(sandboxRoot),
2951
+ )
2952
+ ));
2953
+
2954
+ assert.equal(returnedValue, 0, 'an unreadable file removes no entry and ends the walk of that file');
2955
+ assert.equal(allWarnings.length, 1, 'the unreadable file is reported once');
2956
+ assert.match(allWarnings[0], /hooks\.json/, 'the warning names the file it left alone');
2957
+ } finally {
2958
+ rmSync(sandboxRoot, { recursive: true, force: true });
2959
+ }
2960
+ });