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
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import {
5
5
  FOLDED_HOOK_RELATIVE_PATHS,
6
+ RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS,
6
7
  mergeHooksIntoSettings,
7
8
  } from './install.mjs';
8
9
 
@@ -15,6 +16,37 @@ const allRetiredWriteHooks = [
15
16
  'blocking/hook_prose_detector_consistency.py',
16
17
  'blocking/workflow_substitution_slot_blocker.py',
17
18
  ];
19
+ const allRetiredPolicyRegistrations = [
20
+ 'blocking/plain_language_blocker.py',
21
+ 'lifecycle/config_change_guard.py',
22
+ 'blocking/docstring_rule_gate_count_blocker.py',
23
+ 'blocking/write_existing_file_blocker.py',
24
+ 'blocking/sensitive_file_protector.py',
25
+ 'blocking/pii_prevention_blocker.py',
26
+ 'blocking/duplicate_rmtree_helper_blocker.py',
27
+ 'blocking/claude_md_orphan_file_blocker.py',
28
+ 'blocking/package_inventory_stale_blocker.py',
29
+ 'blocking/env_var_table_code_drift_blocker.py',
30
+ 'blocking/pytest_testpaths_orphan_blocker.py',
31
+ 'blocking/destructive_command_blocker.py',
32
+ 'blocking/shell_substitution_blocker.py',
33
+ 'blocking/piped_pytest_blocker.py',
34
+ 'blocking/cursor_cli_python_misfire_blocker.py',
35
+ 'blocking/unscoped_search_blocker.py',
36
+ 'blocking/nas_ssh_binary_enforcer.py',
37
+ 'blocking/block_main_commit.py',
38
+ 'blocking/session_edit_stage_gate.py',
39
+ 'blocking/bash_pre_tool_use_dispatcher.py',
40
+ 'blocking/stop_dispatcher.py',
41
+ 'blocking/bot_mention_comment_blocker.py',
42
+ 'blocking/fable_spawn_gate.py',
43
+ 'blocking/luna_fast_mode_gate.py',
44
+ 'blocking/orchestrator_refresh_reschedule_gate.py',
45
+ 'blocking/ask_user_question_shape_blocker.py',
46
+ 'blocking/send_user_file_open_locally_blocker.py',
47
+ 'blocking/question_to_user_enforcer.py',
48
+ 'blocking/session_handoff_blocker.py',
49
+ ];
18
50
  const sourceHooks = JSON.parse(readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8'));
19
51
  const installedRoot = '/fixture-home/.claude';
20
52
  const foreignCommand = 'python /unmanaged/keep_this_hook.py';
@@ -22,7 +54,11 @@ const foreignSessionCommand = 'node /unmanaged/session.js';
22
54
 
23
55
  function legacySettings() {
24
56
  return {
25
- permissions: { allow: ['Read'] },
57
+ permissions: {
58
+ allow: ['Read'],
59
+ deny: ['Bash(git push --force)'],
60
+ ask: ['Bash(git push)'],
61
+ },
26
62
  hooks: {
27
63
  PreToolUse: [{
28
64
  matcher: 'Write|Edit|MultiEdit|apply_patch',
@@ -33,6 +69,11 @@ function legacySettings() {
33
69
  command: `python ${installedRoot}/hooks/${relativePath}`,
34
70
  timeout: 10,
35
71
  })),
72
+ ...allRetiredPolicyRegistrations.map(relativePath => ({
73
+ type: 'command',
74
+ command: `python ${installedRoot}/hooks/${relativePath}`,
75
+ timeout: 10,
76
+ })),
36
77
  ],
37
78
  }],
38
79
  SessionStart: [{
@@ -54,18 +95,29 @@ test('every retired write registration already has an installer-owned prune path
54
95
  }
55
96
  });
56
97
 
98
+ test('every retired policy registration has an installer-owned prune path', () => {
99
+ for (const relativePath of allRetiredPolicyRegistrations) {
100
+ assert.ok(RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS.has(relativePath), relativePath);
101
+ }
102
+ });
103
+
57
104
  test('upgrade prunes legacy direct file checks while retaining foreign hooks', () => {
58
105
  const settings = legacySettings();
59
106
  mergeHooksIntoSettings(settings, sourceHooks, installedRoot, 'python3');
60
107
  const commands = allCommands(settings);
61
- for (const relativePath of allRetiredWriteHooks) {
108
+ for (const relativePath of [...allRetiredWriteHooks, ...allRetiredPolicyRegistrations]) {
62
109
  assert.ok(!commands.some(command => command.includes(relativePath)), relativePath);
63
110
  }
64
111
  assert.ok(commands.includes(foreignCommand));
65
112
  assert.ok(commands.includes(foreignSessionCommand));
66
- assert.deepEqual(settings.permissions, { allow: ['Read'] });
113
+ assert.deepEqual(settings.permissions, {
114
+ allow: ['Read'],
115
+ deny: ['Bash(git push --force)'],
116
+ ask: ['Bash(git push)'],
117
+ });
118
+ assert.ok(commands.every(command => !command.includes('/blocking/stop_dispatcher.py')));
67
119
  assert.ok(commands.some(command => command.includes('/blocking/pre_tool_use_dispatcher.py')));
68
- assert.ok(commands.some(command => command.includes('/blocking/bash_pre_tool_use_dispatcher.py')));
120
+ assert.ok(commands.every(command => !command.includes('/blocking/bash_pre_tool_use_dispatcher.py')));
69
121
  });
70
122
 
71
123
  test('reinstall does not restore retired direct checks or duplicate foreign hooks', () => {
@@ -244,6 +244,16 @@ test('install.mjs --help documents profile target selection', () => {
244
244
  assert.match(result.stdout, /targetIdentity|target identity|per target/i);
245
245
  });
246
246
 
247
+ test('install.mjs --help states that a positional path argument carries no meaning', () => {
248
+ const result = spawnSync(process.execPath, [INSTALL_MODULE_PATH, '--help'], {
249
+ encoding: 'utf8',
250
+ env: process.env,
251
+ });
252
+ assert.equal(result.status, 0, result.stderr);
253
+ assert.match(result.stdout, /reads only flags/i);
254
+ assert.match(result.stdout, /path argument/i);
255
+ });
256
+
247
257
  test('install.mjs wires select-install-targets for multi-profile entry', () => {
248
258
  const source = readFileSync(INSTALL_MODULE_PATH, 'utf8');
249
259
  assert.match(source, /select-install-targets\.mjs/);
@@ -13,6 +13,7 @@ import {
13
13
  import { tmpdir } from 'node:os';
14
14
  import { join, dirname, basename } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { detectPython } from './install.mjs';
16
17
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
17
18
  const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
18
19
  const INSTALLER_PROCESS_TIMEOUT_MS = 60_000;
@@ -84,6 +85,8 @@ const STALE_RULE_FILE_NAME = 'retired-rule.md';
84
85
  const STALE_SHARED_FILE_SEGMENTS = ['pr-loop', 'retired_shared.md'];
85
86
  const STALE_SHARED_SKILL_FILE_SEGMENTS = [SHARED_DIRECTORY_NAME, 'retired_skill_shared.md'];
86
87
  const RETIRED_HOOK_RELATIVE_SEGMENTS = ['blocking', 'retired_gate.py'];
88
+ const RETIRED_HOOK_SEEDED_CONTENTS = 'a gate an earlier revision shipped\n';
89
+ const FOLDED_RETIRED_HOOK_RELATIVE_SEGMENTS = ['blocking', 'ask_user_question_shape_blocker.py'];
87
90
  const USER_HOOK_COMMAND = 'python3 my_own_gate.py --user-authored';
88
91
  const RETIRED_HOOK_EVENT_TYPE = 'PreToolUse';
89
92
  const DROPPED_HOOK_EVENT_TYPE = 'PreCompact';
@@ -177,7 +180,7 @@ function createSandbox(homeDirectoryPrefix = 'cdev-prune-home-') {
177
180
  return { homeDirectory, claudeDirectory, skillsDirectory, manifestPath };
178
181
  }
179
182
 
180
- test('a core install runs the spaced-path Write dispatcher and captures its red denial', () => {
183
+ test('a core install runs the spaced-path Write dispatcher and allows the write', () => {
181
184
  const sandbox = createSandbox('cdev prune & (home)-');
182
185
  try {
183
186
  runInstaller(sandbox.homeDirectory, ['--only', 'core']);
@@ -204,12 +207,8 @@ test('a core install runs the spaced-path Write dispatcher and captures its red
204
207
  });
205
208
 
206
209
  assert.equal(dispatcherRun.status, 0, dispatcherRun.stderr);
207
- const shownRedDecision = JSON.parse(dispatcherRun.stdout.trim());
208
- assert.equal(shownRedDecision.hookSpecificOutput.permissionDecision, 'deny');
209
- assert.match(
210
- shownRedDecision.hookSpecificOutput.permissionDecisionReason,
211
- /BLOCKED: Write on existing file/,
212
- );
210
+ assert.equal(dispatcherRun.stdout.trim(), '', 'no hosted hook denies a Write');
211
+ assert.equal(readFileSync(protectedFilePath, 'utf8'), 'existing content\n');
213
212
  } finally {
214
213
  rmSync(sandbox.homeDirectory, { recursive: true, force: true });
215
214
  }
@@ -958,7 +957,7 @@ function seedRetiredManagedHook(sandbox) {
958
957
  const retiredHookPath = join(
959
958
  sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, ...RETIRED_HOOK_RELATIVE_SEGMENTS,
960
959
  );
961
- writeFileWithParents(retiredHookPath, 'a gate an earlier revision shipped\n');
960
+ writeFileWithParents(retiredHookPath, RETIRED_HOOK_SEEDED_CONTENTS);
962
961
  appendManifestFiles(sandbox.manifestPath, [retiredHookPath]);
963
962
  const retiredHookCommand = `python3 "${retiredHookPath.replace(/\\/g, '/')}"`;
964
963
  const lookalikeHookCommand = `python3 "${retiredHookPath.replace(/\\/g, '/')}.bak"`;
@@ -1058,7 +1057,7 @@ test('a full reinstall leaves a manifest-recorded path under no managed root in
1058
1057
  }
1059
1058
  });
1060
1059
 
1061
- test('a reinstall removes a retired post-commit shim and preserves a foreign git hook', () => {
1060
+ test('a reinstall regenerates a stale post-commit shim and preserves a foreign git hook', () => {
1062
1061
  const sandbox = createSandbox();
1063
1062
  try {
1064
1063
  runInstaller(sandbox.homeDirectory, []);
@@ -1068,18 +1067,18 @@ test('a reinstall removes a retired post-commit shim and preserves a foreign git
1068
1067
  const postCommitShimPath = join(gitHooksDirectory, POST_COMMIT_HOOK_NAME);
1069
1068
  const foreignHookPath = join(gitHooksDirectory, FOREIGN_HOOK_NAME);
1070
1069
  const foreignHookContentBytes = Buffer.from('foreign hook content\n', 'utf8');
1071
- writeFileWithParents(postCommitShimPath, 'generated by an earlier install\n');
1070
+ const staleShimContent = 'generated by an earlier install\n';
1071
+ writeFileWithParents(postCommitShimPath, staleShimContent);
1072
1072
  writeFileWithParents(foreignHookPath, foreignHookContentBytes);
1073
1073
  appendManifestFiles(sandbox.manifestPath, [postCommitShimPath]);
1074
1074
 
1075
1075
  runInstaller(sandbox.homeDirectory, []);
1076
1076
 
1077
1077
  const freshManifest = readManifest(sandbox.manifestPath);
1078
- assert.equal(existsSync(postCommitShimPath), false);
1078
+ assert.notEqual(readFileSync(postCommitShimPath, 'utf8'), staleShimContent);
1079
1079
  assert.deepEqual(readFileSync(foreignHookPath), foreignHookContentBytes);
1080
- assert.equal(freshManifest.files.includes(postCommitShimPath), false);
1081
1080
  assert.equal(freshManifest.files.includes(foreignHookPath), false);
1082
- for (const gitHookName of [PRE_COMMIT_HOOK_NAME, PRE_PUSH_HOOK_NAME]) {
1081
+ for (const gitHookName of [PRE_COMMIT_HOOK_NAME, PRE_PUSH_HOOK_NAME, POST_COMMIT_HOOK_NAME]) {
1083
1082
  const generatedShimPath = join(gitHooksDirectory, gitHookName);
1084
1083
  assert.equal(existsSync(generatedShimPath), true);
1085
1084
  assert.equal(freshManifest.files.includes(generatedShimPath), true);
@@ -1089,7 +1088,7 @@ test('a reinstall removes a retired post-commit shim and preserves a foreign git
1089
1088
  }
1090
1089
  });
1091
1090
 
1092
- test('a full reinstall drops a retired hook script and its settings entry in one run', () => {
1091
+ test('a full reinstall retires a hook script and leaves an inert stand-in the open session can still run', () => {
1093
1092
  const sandbox = createSandbox();
1094
1093
  try {
1095
1094
  runInstaller(sandbox.homeDirectory, []);
@@ -1097,9 +1096,26 @@ test('a full reinstall drops a retired hook script and its settings entry in one
1097
1096
  retiredHookPath, retiredHookCommand, lookalikeHookCommand,
1098
1097
  } = seedRetiredManagedHook(sandbox);
1099
1098
 
1100
- runInstaller(sandbox.homeDirectory, []);
1099
+ const installOutput = runInstallerReadingBothStreams(sandbox.homeDirectory, []);
1101
1100
 
1102
- assert.equal(existsSync(retiredHookPath), false, 'the retired hook script leaves ~/.claude/hooks');
1101
+ assert.notEqual(
1102
+ readFileSync(retiredHookPath, 'utf8'),
1103
+ RETIRED_HOOK_SEEDED_CONTENTS,
1104
+ 'the retired gate itself no longer runs at that path',
1105
+ );
1106
+ const standInRun = spawnSync(detectPython(), [retiredHookPath], { encoding: 'utf8' });
1107
+ assert.equal(
1108
+ standInRun.status,
1109
+ 0,
1110
+ 'a session holding the old registration runs the stand-in and gets an allow',
1111
+ );
1112
+ assert.equal(standInRun.stdout, '', 'the stand-in writes nothing to stdout');
1113
+ assert.equal(standInRun.stderr, '', 'the stand-in writes nothing to stderr');
1114
+ assert.match(
1115
+ installOutput,
1116
+ /Restart open sessions: this install retired blocking\/retired_gate\.py/,
1117
+ 'the run names the retired registration and asks for a restart',
1118
+ );
1103
1119
  assert.equal(
1104
1120
  prunedBackupContains(
1105
1121
  sandbox.claudeDirectory, join(HOOKS_DIRECTORY_NAME, ...RETIRED_HOOK_RELATIVE_SEGMENTS),
@@ -1123,6 +1139,59 @@ test('a full reinstall drops a retired hook script and its settings entry in one
1123
1139
  true,
1124
1140
  'a user command whose path is the retired tail plus a suffix names another file and stays',
1125
1141
  );
1142
+
1143
+ runInstaller(sandbox.homeDirectory, []);
1144
+
1145
+ assert.equal(
1146
+ existsSync(retiredHookPath),
1147
+ false,
1148
+ 'the next run finds no registration naming the stand-in, so the stand-in leaves too',
1149
+ );
1150
+ } finally {
1151
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1152
+ }
1153
+ });
1154
+
1155
+ test('a full reinstall leaves a stand-in for a registration the package itself stopped shipping', () => {
1156
+ const sandbox = createSandbox();
1157
+ try {
1158
+ runInstaller(sandbox.homeDirectory, []);
1159
+ const retiredHookPath = join(
1160
+ sandbox.claudeDirectory,
1161
+ HOOKS_DIRECTORY_NAME,
1162
+ ...FOLDED_RETIRED_HOOK_RELATIVE_SEGMENTS,
1163
+ );
1164
+ writeFileWithParents(retiredHookPath, RETIRED_HOOK_SEEDED_CONTENTS);
1165
+ appendManifestFiles(sandbox.manifestPath, [retiredHookPath]);
1166
+ const retiredHookCommand = `python3 "${retiredHookPath.replace(/\\/g, '/')}"`;
1167
+ const settings = readSettings(sandbox.claudeDirectory);
1168
+ settings.hooks[RETIRED_HOOK_EVENT_TYPE].push({
1169
+ matcher: 'AskUserQuestion',
1170
+ hooks: [{ type: 'command', command: retiredHookCommand }],
1171
+ });
1172
+ writeFileSync(
1173
+ join(sandbox.claudeDirectory, SETTINGS_FILE_NAME),
1174
+ JSON.stringify(settings, null, 4) + '\n',
1175
+ );
1176
+
1177
+ const installOutput = runInstallerReadingBothStreams(sandbox.homeDirectory, []);
1178
+
1179
+ assert.equal(
1180
+ allHookCommands(readSettings(sandbox.claudeDirectory)).includes(retiredHookCommand),
1181
+ false,
1182
+ 'the registration the package stopped shipping leaves settings.json',
1183
+ );
1184
+ const standInRun = spawnSync(detectPython(), [retiredHookPath], { encoding: 'utf8' });
1185
+ assert.equal(
1186
+ standInRun.status,
1187
+ 0,
1188
+ 'a session still holding that registration runs a stand-in and gets an allow',
1189
+ );
1190
+ assert.match(
1191
+ installOutput,
1192
+ /Restart open sessions: this install retired blocking\/ask_user_question_shape_blocker\.py/,
1193
+ 'the run names the registration the merge would otherwise drop in silence',
1194
+ );
1126
1195
  } finally {
1127
1196
  rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1128
1197
  }
@@ -1228,7 +1297,23 @@ test('a full reinstall removes retired pull request hooks and preserves foreign
1228
1297
 
1229
1298
  const freshManifest = readManifest(sandbox.manifestPath);
1230
1299
  const allCommands = allHookCommands(readSettings(sandbox.claudeDirectory));
1231
- for (const retiredPath of retiredPaths) {
1300
+ const registeredRetiredPaths = new Set(RETIRED_PULL_REQUEST_REGISTRATION_PATHS);
1301
+ for (const relativePath of RETIRED_PULL_REQUEST_HOOK_RELATIVE_PATHS) {
1302
+ const retiredPath = join(
1303
+ sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, ...relativePath.split('/'),
1304
+ );
1305
+ if (registeredRetiredPaths.has(relativePath)) {
1306
+ assert.equal(
1307
+ spawnSync(detectPython(), [retiredPath], { encoding: 'utf8' }).status,
1308
+ 0,
1309
+ `${relativePath} keeps an inert stand-in while a session still registers it`,
1310
+ );
1311
+ assert.ok(
1312
+ freshManifest.files.includes(retiredPath),
1313
+ `${relativePath} is recorded, so a later run retires the stand-in`,
1314
+ );
1315
+ continue;
1316
+ }
1232
1317
  assert.equal(existsSync(retiredPath), false, `${retiredPath} leaves installed hooks`);
1233
1318
  assert.equal(freshManifest.files.includes(retiredPath), false);
1234
1319
  }
@@ -1242,6 +1327,131 @@ test('a full reinstall removes retired pull request hooks and preserves foreign
1242
1327
  }
1243
1328
  });
1244
1329
 
1330
+
1331
+ const CODEX_DIRECTORY_NAME = '.codex';
1332
+ const CODEX_HOOKS_CONFIGURATION_FILE_NAME = 'hooks.json';
1333
+ const FOREIGN_TOOL_DIRECTORY_NAME = 'other-tool';
1334
+ const FOREIGN_CODEX_HOOK_COMMAND = 'python3 my_own_codex_gate.py --user-authored';
1335
+
1336
+
1337
+ /**
1338
+ * Build the command one hook registration runs for a script under a hooks root.
1339
+ *
1340
+ * @param {string} hooksRoot The hooks directory the registration names.
1341
+ * @param {string[]} relativeSegments The script path segments under that directory.
1342
+ * @returns {string} The registration command.
1343
+ */
1344
+ function hookCommandUnder(hooksRoot, relativeSegments) {
1345
+ return `python3 "${join(hooksRoot, ...relativeSegments).replace(/\\/g, '/')}"`;
1346
+ }
1347
+
1348
+
1349
+ /**
1350
+ * Write a Codex hooks file registering one retired script beside a foreign entry.
1351
+ *
1352
+ * @param {{homeDirectory: string}} sandbox The sandbox paths.
1353
+ * @returns {{codexHooksPath: string, codexRetiredCommand: string}} The file and its retired entry.
1354
+ */
1355
+ function seedCodexHookRegistrations(sandbox) {
1356
+ const codexDirectory = join(sandbox.homeDirectory, CODEX_DIRECTORY_NAME);
1357
+ const codexHooksPath = join(codexDirectory, CODEX_HOOKS_CONFIGURATION_FILE_NAME);
1358
+ const codexRetiredCommand = hookCommandUnder(
1359
+ join(codexDirectory, HOOKS_DIRECTORY_NAME), RETIRED_HOOK_RELATIVE_SEGMENTS,
1360
+ );
1361
+ writeFileWithParents(codexHooksPath, JSON.stringify({
1362
+ hooks: {
1363
+ [RETIRED_HOOK_EVENT_TYPE]: [{
1364
+ matcher: 'Bash',
1365
+ hooks: [
1366
+ { type: 'command', command: codexRetiredCommand },
1367
+ { type: 'command', command: FOREIGN_CODEX_HOOK_COMMAND },
1368
+ ],
1369
+ }],
1370
+ },
1371
+ }, null, 4) + '\n');
1372
+ return { codexHooksPath, codexRetiredCommand };
1373
+ }
1374
+
1375
+
1376
+ test('a full reinstall prunes a retired registration the Codex hooks file holds and leaves a stand-in', () => {
1377
+ const sandbox = createSandbox();
1378
+ try {
1379
+ runInstaller(sandbox.homeDirectory, []);
1380
+ const retiredHookPath = join(
1381
+ sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, ...RETIRED_HOOK_RELATIVE_SEGMENTS,
1382
+ );
1383
+ writeFileWithParents(retiredHookPath, RETIRED_HOOK_SEEDED_CONTENTS);
1384
+ appendManifestFiles(sandbox.manifestPath, [retiredHookPath]);
1385
+ const { codexHooksPath, codexRetiredCommand } = seedCodexHookRegistrations(sandbox);
1386
+
1387
+ const installOutput = runInstallerReadingBothStreams(sandbox.homeDirectory, []);
1388
+
1389
+ const codexCommands = allHookCommands(JSON.parse(readFileSync(codexHooksPath, 'utf8')));
1390
+ assert.equal(
1391
+ codexCommands.includes(codexRetiredCommand),
1392
+ false,
1393
+ 'the Codex registration naming the retired script is pruned like a settings.json one',
1394
+ );
1395
+ assert.equal(
1396
+ codexCommands.includes(FOREIGN_CODEX_HOOK_COMMAND),
1397
+ true,
1398
+ 'the user-authored Codex entry sharing the matcher group survives',
1399
+ );
1400
+ assert.equal(
1401
+ spawnSync(detectPython(), [retiredHookPath], { encoding: 'utf8' }).status,
1402
+ 0,
1403
+ 'the Codex registration alone earns the retired path an inert stand-in',
1404
+ );
1405
+ assert.match(
1406
+ installOutput,
1407
+ /Restart open sessions: this install retired blocking\/retired_gate\.py/,
1408
+ 'the run names the retired registration and asks for a restart',
1409
+ );
1410
+ } finally {
1411
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1412
+ }
1413
+ });
1414
+
1415
+
1416
+ test('a full reinstall leaves a registration under a hooks root the installer does not own', () => {
1417
+ const sandbox = createSandbox();
1418
+ try {
1419
+ runInstaller(sandbox.homeDirectory, []);
1420
+ seedRetiredManagedHook(sandbox);
1421
+ const foreignHooksRoot = join(
1422
+ sandbox.homeDirectory, FOREIGN_TOOL_DIRECTORY_NAME, '.claude', HOOKS_DIRECTORY_NAME,
1423
+ );
1424
+ const foreignHookPath = join(foreignHooksRoot, ...RETIRED_HOOK_RELATIVE_SEGMENTS);
1425
+ const foreignHookBytes = Buffer.from('another tool owns this gate\n', 'utf8');
1426
+ writeFileWithParents(foreignHookPath, foreignHookBytes);
1427
+ const foreignCommand = hookCommandUnder(foreignHooksRoot, RETIRED_HOOK_RELATIVE_SEGMENTS);
1428
+ const settings = readSettings(sandbox.claudeDirectory);
1429
+ settings.hooks[RETIRED_HOOK_EVENT_TYPE].push({
1430
+ matcher: 'Bash',
1431
+ hooks: [{ type: 'command', command: foreignCommand }],
1432
+ });
1433
+ writeFileSync(
1434
+ join(sandbox.claudeDirectory, SETTINGS_FILE_NAME),
1435
+ JSON.stringify(settings, null, 4) + '\n',
1436
+ );
1437
+
1438
+ runInstaller(sandbox.homeDirectory, []);
1439
+
1440
+ assert.equal(
1441
+ allHookCommands(readSettings(sandbox.claudeDirectory)).includes(foreignCommand),
1442
+ true,
1443
+ 'a registration whose hooks root the installer does not own keeps its entry',
1444
+ );
1445
+ assert.deepEqual(
1446
+ readFileSync(foreignHookPath),
1447
+ foreignHookBytes,
1448
+ 'the foreign script keeps its own bytes, so no stand-in replaced it',
1449
+ );
1450
+ } finally {
1451
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1452
+ }
1453
+ });
1454
+
1245
1455
  test('a full reinstall removes a retired hook entry under an event type the current config leaves out', () => {
1246
1456
  const sandbox = createSandbox();
1247
1457
  try {