claude-dev-env 2.18.0 → 2.20.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 (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -300,6 +300,45 @@ def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
300
300
  assert exit_code == 1
301
301
 
302
302
 
303
+ def test_main_immediate_mode_blocks_staged_rule_violations(
304
+ temporary_git_repository: Path,
305
+ monkeypatch: pytest.MonkeyPatch,
306
+ ) -> None:
307
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
308
+ commit_all_files(temporary_git_repository, "initial")
309
+ write_file(
310
+ temporary_git_repository / "module.py",
311
+ "first_count = 1\n"
312
+ "def compute_total(operand):\n"
313
+ " result = operand + 1\n"
314
+ " return result\n",
315
+ )
316
+ stage_file(temporary_git_repository, "module.py")
317
+
318
+ monkeypatch.chdir(temporary_git_repository)
319
+ exit_code = gate_module.main(["--immediate"])
320
+
321
+ assert exit_code == 1
322
+
323
+
324
+ def test_main_immediate_mode_skips_staged_test_execution(
325
+ temporary_git_repository: Path,
326
+ monkeypatch: pytest.MonkeyPatch,
327
+ ) -> None:
328
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
329
+ commit_all_files(temporary_git_repository, "initial")
330
+ write_file(
331
+ temporary_git_repository / "test_staged_failure.py",
332
+ "def test_intentionally_fails() -> None:\n assert False\n",
333
+ )
334
+ stage_file(temporary_git_repository, "test_staged_failure.py")
335
+
336
+ monkeypatch.chdir(temporary_git_repository)
337
+ exit_code = gate_module.main(["--immediate"])
338
+
339
+ assert exit_code == 0
340
+
341
+
303
342
  def test_main_staged_mode_passes_when_no_staged_violations(
304
343
  temporary_git_repository: Path,
305
344
  monkeypatch: pytest.MonkeyPatch,
@@ -2336,10 +2375,18 @@ def test_parse_arguments_reads_staged_base_and_prefix_flags() -> None:
2336
2375
  assert parsed_arguments.paths == [Path("explicit_file.py")]
2337
2376
 
2338
2377
 
2378
+ def test_parse_arguments_reads_immediate_scope_flag() -> None:
2379
+ parsed_arguments = gate_module.parse_arguments(["--immediate"])
2380
+
2381
+ assert parsed_arguments.immediate is True
2382
+ assert parsed_arguments.staged is False
2383
+
2384
+
2339
2385
  def test_parse_arguments_applies_documented_defaults() -> None:
2340
2386
  parsed_arguments = gate_module.parse_arguments([])
2341
2387
 
2342
2388
  assert parsed_arguments.staged is False
2389
+ assert parsed_arguments.immediate is False
2343
2390
  assert parsed_arguments.base == "origin/main"
2344
2391
  assert parsed_arguments.repo_root is None
2345
2392
  assert parsed_arguments.only_under == []
@@ -30,6 +30,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
30
30
  'build-goal-prompt',
31
31
  'bugteam',
32
32
  'caveman',
33
+ 'cert-classification-rule',
33
34
  'closeout',
34
35
  'code',
35
36
  'comments',
@@ -40,6 +41,9 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
40
41
  'copilot-review',
41
42
  'deep-research',
42
43
  'descriptions',
44
+ 'e-code-review',
45
+ 'e-simplify',
46
+ 'eli5',
43
47
  'emergencies',
44
48
  'everything-search',
45
49
  'findbugs',
@@ -64,6 +68,8 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
64
68
  'midjourney-prompt',
65
69
  'midjourney-submit',
66
70
  'monitor-open-prs',
71
+ 'pr-name-by-capability',
72
+ 'pr-refinement',
67
73
  'orchestrator',
68
74
  'orchestrator-refresh',
69
75
  'plan-to-pr',
@@ -71,6 +77,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
71
77
  'pmid',
72
78
  'pmin',
73
79
  'post-audit-findings',
80
+ 'pr-cleanup',
74
81
  'pr-consistency-audit',
75
82
  'pr-converge',
76
83
  'pr-fix-protocol',
@@ -80,6 +87,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
80
87
  'pr-scope-resolve',
81
88
  'pre-compact',
82
89
  'privacy-hygiene',
90
+ 'prototype',
83
91
  'prompt-generator',
84
92
  'qbug',
85
93
  'rebase',
@@ -90,14 +98,20 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
90
98
  'release-notes-html',
91
99
  'remember',
92
100
  'research-mode',
101
+ 'review-router',
102
+ 'review-tier',
93
103
  'reviewer-gates',
94
104
  'reviews',
95
105
  'run-claude-dev-env',
96
106
  'session-log',
97
107
  'session-tidy',
108
+ 'pr-shared-extraction',
98
109
  'show',
99
110
  'skill-builder',
100
111
  'small-cl',
112
+ 'pr-small-cl',
113
+ 'source-command-logifix',
114
+ 'source-command-sr-loop',
101
115
  'split-pr',
102
116
  'structure-prompt',
103
117
  'task-build',
@@ -170,3 +170,8 @@ export const CURSOR_SYNC_SCRIPT_FILE_NAME = 'sync_to_cursor.py';
170
170
  * Windows Python launcher command the installer may bake into hook settings.
171
171
  */
172
172
  export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
173
+
174
+ /**
175
+ * Maximum time each Python interpreter probe may run during detection.
176
+ */
177
+ export const PYTHON_PROBE_TIMEOUT_MILLISECONDS = 10_000;
@@ -33,6 +33,10 @@ const SHIPPED_SKILL_NAME = 'privacy-hygiene';
33
33
  const ELI5_SKILL_NAME = 'eli5';
34
34
  const SHIPPED_AGENT_FILE_NAME = 'clean-coder.md';
35
35
  const PERSONAL_SKILL_NAME = 'my-notes';
36
+ const SHARED_DIRECTORY_NAME = '_shared';
37
+ const PR_LOOP_DIRECTORY_NAME = 'pr-loop';
38
+ const PREFLIGHT_PROPOSAL_FILE_NAME = 'preflight-proposal.md';
39
+ const PREFLIGHT_PROPOSAL_POINTER = '@~/.claude/_shared/pr-loop/preflight-proposal.md';
36
40
 
37
41
  /**
38
42
  * @param {string} homeDirectory
@@ -53,6 +57,24 @@ function runInstaller(homeDirectory, extraArguments) {
53
57
  });
54
58
  }
55
59
 
60
+ /**
61
+ * @param {{ homeDirectory: string, skillsInstallDirectory: string }} installationPaths
62
+ */
63
+ function assertProposalContractInstallation(installationPaths) {
64
+ const { homeDirectory, skillsInstallDirectory } = installationPaths;
65
+ const allContractPathSegments = [
66
+ SHARED_DIRECTORY_NAME, PR_LOOP_DIRECTORY_NAME, PREFLIGHT_PROPOSAL_FILE_NAME,
67
+ ];
68
+ const installedPointerPath = join(skillsInstallDirectory, ...allContractPathSegments);
69
+ const pointerLine = readFileSync(installedPointerPath, 'utf8').trimEnd().split('\n').at(-1);
70
+ const allTargetSegments = pointerLine.slice('@~/'.length).split('/');
71
+ const installedContractPath = join(homeDirectory, ...allTargetSegments);
72
+ const sourceContractPath = join(PACKAGE_DIRECTORY, ...allContractPathSegments);
73
+
74
+ assert.equal(pointerLine, PREFLIGHT_PROPOSAL_POINTER);
75
+ assert.equal(readFileSync(installedContractPath, 'utf8'), readFileSync(sourceContractPath, 'utf8'));
76
+ }
77
+
56
78
  test('CONTENT_DIRECTORIES omits agents because that tree installs to the agents home', () => {
57
79
  assert.equal(CONTENT_DIRECTORIES.includes(MANAGED_AGENTS_DIRECTORY_NAME), false);
58
80
  });
@@ -106,6 +128,7 @@ test('a full install writes skills and agents under .agents and points .claude a
106
128
  );
107
129
  assert.equal(realpathSync(lookupSkillFile), realpathSync(canonicalSkillFile));
108
130
  assert.equal(realpathSync(lookupEli5SkillFile), realpathSync(canonicalEli5SkillFile));
131
+ assertProposalContractInstallation({ homeDirectory, skillsInstallDirectory });
109
132
  assert.equal(
110
133
  readFileSync(lookupAgentFile, 'utf8'),
111
134
  readFileSync(canonicalAgentFile, 'utf8'),
@@ -23,16 +23,32 @@ import {
23
23
  CODEX_RULES_SHIPPED_FILE_NAME,
24
24
  DEFAULT_CODEX_DIRECTORY_NAME,
25
25
  } from './install-constants.mjs';
26
- import { CONTENT_DIRECTORIES, INSTALL_GROUPS } from './install.mjs';
26
+ import {
27
+ CONTENT_DIRECTORIES,
28
+ detectPython,
29
+ INSTALL_GROUPS,
30
+ pythonFileAndPrefixArguments,
31
+ } from './install.mjs';
27
32
 
28
33
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
29
34
  const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
30
35
  const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
36
+ const INSTALLER_PROCESS_TIMEOUT_MS = 60_000;
31
37
  const SHIPPED_RULES_SOURCE_PATH = join(
32
38
  PACKAGE_DIRECTORY,
33
39
  CODEX_RULES_PACKAGE_DIRECTORY_NAME,
34
40
  CODEX_RULES_SHIPPED_FILE_NAME,
35
41
  );
42
+ const BLOCKING_HOOKS_DIRECTORY = join(PACKAGE_DIRECTORY, 'hooks', 'blocking');
43
+ const PYTHON_PII_SCAN_SCRIPT = [
44
+ 'import json',
45
+ 'import sys',
46
+ 'from dataclasses import asdict',
47
+ 'from pii_scanner import scan_text_for_pii',
48
+ 'all_findings = scan_text_for_pii(sys.stdin.read())',
49
+ "home_path_findings = [asdict(each_finding) for each_finding in all_findings if each_finding.category == 'home-path']",
50
+ 'print(json.dumps(home_path_findings))',
51
+ ].join('\n');
36
52
 
37
53
  function runInstaller(homeDirectory, extraArguments) {
38
54
  return execFileSync('node', [INSTALLER_PATH, ...extraArguments], {
@@ -45,9 +61,44 @@ function runInstaller(homeDirectory, extraArguments) {
45
61
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
46
62
  [CODEX_HOME_ENVIRONMENT_VARIABLE]: join(homeDirectory, DEFAULT_CODEX_DIRECTORY_NAME),
47
63
  },
64
+ timeout: INSTALLER_PROCESS_TIMEOUT_MS,
48
65
  });
49
66
  }
50
67
 
68
+ function scanTextWithProductionPiiScanner(scannedText) {
69
+ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'cdev-pii-scan-'));
70
+ try {
71
+ const detectedPythonCommand = detectPython();
72
+ if (!detectedPythonCommand) {
73
+ throw new Error('No usable Python interpreter found for the PII scanner test');
74
+ }
75
+ const { file, prefixArguments } = pythonFileAndPrefixArguments(
76
+ detectedPythonCommand,
77
+ );
78
+ const scannerJson = execFileSync(
79
+ file,
80
+ [...prefixArguments, '-c', PYTHON_PII_SCAN_SCRIPT],
81
+ {
82
+ cwd: PACKAGE_DIRECTORY,
83
+ encoding: 'utf8',
84
+ env: {
85
+ ...process.env,
86
+ PYTHONPATH: BLOCKING_HOOKS_DIRECTORY,
87
+ CLAUDE_LOCAL_IDENTITY_PATH: join(
88
+ temporaryDirectory,
89
+ 'missing-local-identity.json',
90
+ ),
91
+ },
92
+ input: scannedText,
93
+ timeout: INSTALLER_PROCESS_TIMEOUT_MS,
94
+ },
95
+ );
96
+ return JSON.parse(scannerJson);
97
+ } finally {
98
+ rmSync(temporaryDirectory, { recursive: true, force: true });
99
+ }
100
+ }
101
+
51
102
  test('CONTENT_DIRECTORIES omits codex-rules because that tree installs to the Codex home', () => {
52
103
  assert.equal(CONTENT_DIRECTORIES.includes(CODEX_RULES_PACKAGE_DIRECTORY_NAME), false);
53
104
  });
@@ -123,11 +174,15 @@ test('a full install copies shipped Codex rules and leaves a local default.rules
123
174
  }
124
175
  });
125
176
 
126
- test('a full install Codex rules file contains no personal home path', () => {
127
- const shippedText = readFileSync(SHIPPED_RULES_SOURCE_PATH, 'utf8');
128
- assert.equal(shippedText.includes('Users\\jon'), false);
129
- assert.equal(shippedText.includes('Users/jon'), false);
130
- assert.equal(shippedText.includes('JonEcho'), false);
177
+ test('the shipped Codex rules file has no production-scanner home-path findings', () => {
178
+ const allHomePathFindings = scanTextWithProductionPiiScanner(
179
+ readFileSync(SHIPPED_RULES_SOURCE_PATH, 'utf8'),
180
+ );
181
+ assert.deepEqual(
182
+ allHomePathFindings,
183
+ [],
184
+ `home-path findings: ${JSON.stringify(allHomePathFindings, null, 2)}`,
185
+ );
131
186
  });
132
187
 
133
188
  test('--only journal skips Codex rules; --only core copies them', () => {
package/bin/install.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  CODEX_RULES_PACKAGE_DIRECTORY_NAME,
28
28
  CURSOR_SYNC_SCRIPT_FILE_NAME,
29
29
  WINDOWS_PYTHON_LAUNCHER_COMMAND,
30
+ PYTHON_PROBE_TIMEOUT_MILLISECONDS,
30
31
  } from './install-constants.mjs';
31
32
  import {
32
33
  resolveInstallRoot,
@@ -163,7 +164,7 @@ export const CORE_INCLUDE_DIRECTORIES = [
163
164
  export const CORE_SKILLS = [
164
165
  'orchestrator', 'orchestrator-refresh', 'team-advisor',
165
166
  'grok-spawn',
166
- 'small-cl',
167
+ 'pr-small-cl',
167
168
  'everything-search',
168
169
  'privacy-hygiene',
169
170
  'issue-tracker',
@@ -434,6 +435,42 @@ export function interpreterCommandFromPath(executablePath) {
434
435
  return forwardSlashedPath.includes(' ') ? `"${forwardSlashedPath}"` : forwardSlashedPath;
435
436
  }
436
437
 
438
+ /**
439
+ * Formats a path as one shell argument for the selected platform.
440
+ *
441
+ * @param {string} path Path to format.
442
+ * @param {string} platform Platform whose shell will parse the argument.
443
+ * @returns {string} A quoted shell argument.
444
+ */
445
+ export function commandArgumentFromPath(path, platform = process.platform) {
446
+ const forwardSlashedPath = path.replace(/\\/g, '/');
447
+ if (platform === 'win32') {
448
+ if (/^[A-Za-z0-9_./:@+=,-]+$/.test(forwardSlashedPath)) return forwardSlashedPath;
449
+ return forwardSlashedPath.split('%').map(segment => `"${segment}"`).join('^%');
450
+ }
451
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(forwardSlashedPath)) return forwardSlashedPath;
452
+ return `'${forwardSlashedPath.replace(/'/g, "'\\''")}'`;
453
+ }
454
+
455
+ /**
456
+ * Quotes standalone plugin-root hook paths while preserving inline commands.
457
+ *
458
+ * @param {string} commandString Source hook command.
459
+ * @param {string} pluginRootForward Forward-slash plugin root directory.
460
+ * @returns {string} Rewritten hook command.
461
+ */
462
+ function quoteStandalonePluginRootHookPath(commandString, pluginRootForward) {
463
+ const standalonePathPattern = /(^|\s)(["']?)\$\{CLAUDE_PLUGIN_ROOT\}(\/hooks\/[^\s"']*?\.py)\2(?=$|[\s;&|])/g;
464
+ return commandString.replace(
465
+ standalonePathPattern,
466
+ (_match, argumentPrefix, _sourceQuote, relativeHookPath) => (
467
+ `${argumentPrefix}${commandArgumentFromPath(
468
+ `${pluginRootForward}${relativeHookPath}`,
469
+ )}`
470
+ ),
471
+ );
472
+ }
473
+
437
474
  /**
438
475
  * Picks the interpreter command baked into every managed hook in settings.json.
439
476
  * On win32 the first working candidate is resolved to its absolute
@@ -444,14 +481,14 @@ export function interpreterCommandFromPath(executablePath) {
444
481
  *
445
482
  * @returns {string|null} The interpreter command, or null when none is usable.
446
483
  */
447
- function detectPython() {
484
+ export function detectPython() {
448
485
  const candidates = pythonCandidatesForPlatform(process.platform);
449
486
  for (const { command, versionFlag } of candidates) {
450
487
  try {
451
- const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
488
+ const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
452
489
  if (!version.includes('Python 3.')) continue;
453
490
  if (process.platform !== 'win32') return command;
454
- const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
491
+ const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
455
492
  if (!executablePath || isWindowsStorePythonStub(executablePath)) continue;
456
493
  return interpreterCommandFromPath(executablePath);
457
494
  } catch { /* try next */ }
@@ -1266,11 +1303,13 @@ function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
1266
1303
  * @returns {boolean} True when the command is the inline validators runner.
1267
1304
  */
1268
1305
  export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
1269
- const inlineValidatorRunnerMarker = /sys\.path\.insert\([^)]*\.claude\/hooks[^)]*\)[\s\S]*run_all_validators/;
1270
- return (
1271
- normalizedCommand.includes('/.claude/hooks') &&
1272
- inlineValidatorRunnerMarker.test(normalizedCommand)
1273
- );
1306
+ if (!normalizedCommand.includes('sys.path.insert(')) return false;
1307
+ if (!normalizedCommand.includes('from validators.run_all_validators import main')) return false;
1308
+ const hasLiteralHooksPath = normalizedCommand.includes('/.claude/hooks');
1309
+ const hasShippedBase64Path = normalizedCommand.includes('__claude_dev_env_managed_hooks__');
1310
+ if (!hasLiteralHooksPath && !hasShippedBase64Path) return false;
1311
+ const inlineValidatorRunnerMarker = /sys\.path\.insert\([\s\S]*from validators\.run_all_validators import main/;
1312
+ return inlineValidatorRunnerMarker.test(normalizedCommand);
1274
1313
  }
1275
1314
 
1276
1315
  /**
@@ -1348,6 +1387,7 @@ function startEventFromHookGroupList(settings, eventType) {
1348
1387
  export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
1349
1388
  const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
1350
1389
  const pluginRootForward = pluginRootDir.replace(/\\/g, '/');
1390
+ const encodedPluginHooksPath = Buffer.from(`${pluginRootForward}/hooks`, 'utf8').toString('base64');
1351
1391
  if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
1352
1392
  let groupCount = 0;
1353
1393
  for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
@@ -1356,6 +1396,14 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
1356
1396
  for (const sourceGroup of matcherGroups) {
1357
1397
  const rewrittenHooks = sourceGroup.hooks.map(hook => {
1358
1398
  let command = hook.command;
1399
+ command = command.replace(
1400
+ /r(['"])\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\1/g,
1401
+ () => (
1402
+ `(__import__('base64').b64decode('${encodedPluginHooksPath}').decode(), `
1403
+ + "'__claude_dev_env_managed_hooks__')[0]"
1404
+ ),
1405
+ );
1406
+ command = quoteStandalonePluginRootHookPath(command, pluginRootForward);
1359
1407
  command = command.replace(
1360
1408
  /\$\{CLAUDE_PLUGIN_ROOT\}/g,
1361
1409
  () => pluginRootForward,
@@ -13,10 +13,16 @@ 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 {
17
+ MANAGED_SKILLS_DIRECTORY_NAME,
18
+ PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
19
+ } from './install-constants.mjs';
20
+ import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
16
21
 
17
22
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
18
23
  const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
19
24
  const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
25
+ const INSTALLER_PROCESS_TIMEOUT_MS = 60_000;
20
26
  const EXCLUDED_PACKAGE_COPY_DIRECTORY = 'node_modules';
21
27
 
22
28
  const RETIRED_SKILL_DIRECTORIES = [
@@ -35,7 +41,7 @@ const STALE_SKILL_FILE_RELATIVE_SEGMENTS = ['scripts', 'retired_module.py'];
35
41
  const RUNTIME_ARTIFACT_RELATIVE_SEGMENTS = ['scripts', '__pycache__', 'helper.cpython-312.pyc'];
36
42
  const SCOPED_GROUP_SKILL_DIRECTORY = 'orchestrator';
37
43
  const CORE_REVIEW_GUIDE_SKILL_DIRECTORIES = [
38
- 'small-cl',
44
+ 'pr-small-cl',
39
45
  ];
40
46
  const PRIOR_RUN_BACKUP_DIRECTORY_NAMES = [
41
47
  '2020-01-01T00-00-00-000Z',
@@ -102,8 +108,8 @@ function prunedSkillBackupContains(claudeDirectory, skillsRelativePath) {
102
108
  *
103
109
  * @returns {{homeDirectory: string, claudeDirectory: string, skillsDirectory: string, manifestPath: string}}
104
110
  */
105
- function createSandbox() {
106
- const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-prune-home-'));
111
+ function createSandbox(homeDirectoryPrefix = 'cdev-prune-home-') {
112
+ const homeDirectory = mkdtempSync(join(tmpdir(), homeDirectoryPrefix));
107
113
  const claudeDirectory = join(homeDirectory, '.claude');
108
114
  const skillsDirectory = join(claudeDirectory, 'skills');
109
115
  mkdirSync(skillsDirectory, { recursive: true });
@@ -111,6 +117,61 @@ function createSandbox() {
111
117
  return { homeDirectory, claudeDirectory, skillsDirectory, manifestPath };
112
118
  }
113
119
 
120
+ test('the retired-skill fallback covers every current shipped skill', () => {
121
+ const sourceSkillsDirectory = join(
122
+ PACKAGE_DIRECTORY,
123
+ PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
124
+ MANAGED_SKILLS_DIRECTORY_NAME,
125
+ );
126
+ const allCurrentSkillNames = readdirSync(sourceSkillsDirectory, { withFileTypes: true })
127
+ .filter(eachEntry => eachEntry.isDirectory())
128
+ .filter(eachEntry => existsSync(join(sourceSkillsDirectory, eachEntry.name, 'SKILL.md')))
129
+ .map(eachEntry => eachEntry.name);
130
+ const allUncoveredSkillNames = allCurrentSkillNames.filter(
131
+ eachSkillName => !EVER_SHIPPED_SKILL_NAMES.has(eachSkillName),
132
+ );
133
+
134
+ assert.deepEqual(allUncoveredSkillNames, [], 'every current skill must enter the fallback set');
135
+ });
136
+
137
+ test('a core install runs the spaced-path Write dispatcher and captures its red denial', () => {
138
+ const sandbox = createSandbox('cdev prune & (home)-');
139
+ try {
140
+ runInstaller(sandbox.homeDirectory, ['--only', 'core']);
141
+ const settings = readSettings(sandbox.claudeDirectory);
142
+ const dispatcherHooks = settings.hooks.PreToolUse
143
+ .flatMap(eachGroup => eachGroup.hooks)
144
+ .filter(eachHook => /(?:^|[\\/])pre_tool_use_dispatcher\.py["']?(?:\s|$)/.test(eachHook.command));
145
+ assert.equal(dispatcherHooks.length, 1, 'the core install writes one PreToolUse dispatcher');
146
+
147
+ const protectedFilePath = join(sandbox.homeDirectory, 'protected file.txt');
148
+ writeFileSync(protectedFilePath, 'existing content\n');
149
+ const writePayload = JSON.stringify({
150
+ tool_name: 'Write',
151
+ tool_input: { file_path: protectedFilePath, content: 'replacement content\n' },
152
+ });
153
+ const { childEnvironment } = resolveInstallerInvocation(sandbox.homeDirectory);
154
+ const dispatcherRun = spawnSync(dispatcherHooks[0].command, {
155
+ cwd: dirname(INSTALLER_PATH),
156
+ encoding: 'utf8',
157
+ env: childEnvironment,
158
+ input: `${writePayload}\n`,
159
+ shell: true,
160
+ timeout: 30000,
161
+ });
162
+
163
+ assert.equal(dispatcherRun.status, 0, dispatcherRun.stderr);
164
+ const shownRedDecision = JSON.parse(dispatcherRun.stdout.trim());
165
+ assert.equal(shownRedDecision.hookSpecificOutput.permissionDecision, 'deny');
166
+ assert.match(
167
+ shownRedDecision.hookSpecificOutput.permissionDecisionReason,
168
+ /BLOCKED: Write on existing file/,
169
+ );
170
+ } finally {
171
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
172
+ }
173
+ });
174
+
114
175
  /**
115
176
  * Plant a skill directory under the sandbox with a single marker file.
116
177
  *
@@ -142,6 +203,7 @@ function runInstaller(homeDirectory, extraArguments) {
142
203
  cwd: dirname(installerPath),
143
204
  encoding: 'utf8',
144
205
  env: childEnvironment,
206
+ timeout: INSTALLER_PROCESS_TIMEOUT_MS,
145
207
  });
146
208
  }
147
209