claude-dev-env 1.93.1 → 1.95.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 (262) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +35 -27
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
  6. package/_shared/pr-loop/CLAUDE.md +1 -0
  7. package/_shared/pr-loop/audit-contract.md +1 -1
  8. package/_shared/pr-loop/gh-payloads.md +3 -3
  9. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  10. package/_shared/pr-loop/scripts/README.md +2 -2
  11. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
  13. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  14. package/_shared/pr-loop/state-schema.md +3 -22
  15. package/bin/CLAUDE.md +11 -2
  16. package/bin/ever-shipped-skills.mjs +70 -0
  17. package/bin/install.mjs +138 -13
  18. package/bin/install.prune.test.mjs +457 -0
  19. package/bin/install.test.mjs +10 -4
  20. package/docs/CODE_RULES.md +1 -1
  21. package/hooks/blocking/CLAUDE.md +2 -0
  22. package/hooks/blocking/code_rules_enforcer.py +4 -0
  23. package/hooks/blocking/code_rules_shared.py +82 -0
  24. package/hooks/blocking/code_rules_test_layout.py +9 -3
  25. package/hooks/blocking/pii_commit_command.py +398 -0
  26. package/hooks/blocking/pii_payload_scan.py +133 -0
  27. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  28. package/hooks/blocking/plain_language_blocker.py +138 -4
  29. package/hooks/blocking/sensitive_file_protector.py +114 -48
  30. package/hooks/blocking/tdd_enforcer.py +9 -2
  31. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  32. package/hooks/blocking/test_code_rules_shared.py +181 -0
  33. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  34. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  35. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  36. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  38. package/hooks/hooks_constants/CLAUDE.md +4 -2
  39. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  40. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  41. package/hooks/hooks_constants/local_identity.py +65 -15
  42. package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
  43. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  44. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  45. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  46. package/hooks/hooks_constants/test_local_identity.py +68 -0
  47. package/hooks/pyproject.toml +75 -4
  48. package/hooks/validators/CLAUDE.md +1 -1
  49. package/hooks/validators/README.md +2 -0
  50. package/hooks/validators/python_style_checks.py +114 -136
  51. package/hooks/validators/python_style_helpers.py +95 -0
  52. package/hooks/validators/test_python_style_checks.py +0 -164
  53. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  54. package/hooks/validators/test_python_style_fixes.py +251 -0
  55. package/hooks/validators/test_python_style_helpers.py +125 -0
  56. package/package.json +1 -1
  57. package/rules/CLAUDE.md +1 -0
  58. package/rules/anti-corollary-tests.md +69 -0
  59. package/rules/bdd.md +1 -3
  60. package/rules/code-reviews.md +1 -1
  61. package/rules/gh-paginate.md +1 -1
  62. package/rules/plain-language.md +2 -0
  63. package/scripts/CLAUDE.md +10 -1
  64. package/scripts/Get-SessionAccount.ps1 +210 -0
  65. package/scripts/tests/CLAUDE.md +15 -1
  66. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  67. package/skills/CLAUDE.md +6 -27
  68. package/skills/anthropic-plan/SKILL.md +1 -13
  69. package/skills/autoconverge/CLAUDE.md +5 -2
  70. package/skills/autoconverge/SKILL.md +112 -387
  71. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  72. package/skills/autoconverge/reference/closing-report.md +9 -2
  73. package/skills/autoconverge/reference/convergence.md +26 -22
  74. package/skills/autoconverge/reference/headless-safety.md +44 -0
  75. package/skills/autoconverge/reference/multi-pr.md +88 -0
  76. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  77. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  78. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  79. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  80. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  81. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  82. package/skills/autoconverge/workflow/converge.mjs +286 -96
  83. package/skills/bugteam/CLAUDE.md +1 -1
  84. package/skills/bugteam/CONSTRAINTS.md +3 -3
  85. package/skills/bugteam/PROMPTS.md +8 -19
  86. package/skills/bugteam/SKILL.md +13 -14
  87. package/skills/bugteam/reference/CLAUDE.md +1 -1
  88. package/skills/bugteam/reference/README.md +2 -3
  89. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  90. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  91. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  92. package/skills/bugteam/reference/team-setup.md +11 -11
  93. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  94. package/skills/bugteam/scripts/README.md +20 -8
  95. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  96. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  97. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  98. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  99. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  100. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  101. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  102. package/skills/closeout/SKILL.md +153 -0
  103. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  104. package/skills/closeout/reference/issue-body-templates.md +108 -0
  105. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  106. package/skills/copilot-finding-triage/SKILL.md +80 -28
  107. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  108. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  109. package/skills/everything-search/SKILL.md +1 -2
  110. package/skills/grokify/SKILL.md +58 -0
  111. package/skills/grokify/templates/handoff-template.md +92 -0
  112. package/skills/orchestrator/SKILL.md +35 -25
  113. package/skills/orchestrator-refresh/SKILL.md +15 -11
  114. package/skills/pr-converge/CLAUDE.md +1 -1
  115. package/skills/pr-converge/SKILL.md +132 -233
  116. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  117. package/skills/pr-converge/reference/examples.md +37 -25
  118. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  119. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  120. package/skills/pr-converge/reference/per-tick.md +64 -41
  121. package/skills/pr-converge/reference/state-schema.md +27 -2
  122. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  123. package/skills/pr-converge/scripts/README.md +4 -3
  124. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  125. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  126. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  127. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  128. package/skills/pr-converge/scripts/conftest.py +8 -0
  129. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  130. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  131. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  132. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  133. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  134. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  135. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  136. package/skills/pr-fix-protocol/SKILL.md +20 -20
  137. package/skills/privacy-hygiene/SKILL.md +4 -3
  138. package/skills/reviewer-gates/SKILL.md +26 -8
  139. package/skills/session-log/SKILL.md +11 -12
  140. package/skills/session-tidy/SKILL.md +40 -30
  141. package/skills/skill-builder/CLAUDE.md +13 -10
  142. package/skills/skill-builder/SKILL.md +31 -31
  143. package/skills/skill-builder/references/CLAUDE.md +10 -6
  144. package/skills/skill-builder/references/delegation-map.md +13 -4
  145. package/skills/skill-builder/references/description-field.md +113 -0
  146. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  147. package/skills/skill-builder/references/skill-modularity.md +113 -0
  148. package/skills/skill-builder/references/skill-types.md +4 -0
  149. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  150. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  151. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  152. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  153. package/skills/skill-builder/workflows/new-skill.md +34 -10
  154. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  155. package/skills/team-advisor/SKILL.md +15 -11
  156. package/skills/usage-pause/SKILL.md +10 -14
  157. package/system-prompts/software-engineer.xml +7 -6
  158. package/hooks/validators/test_verify_paths.py +0 -32
  159. package/hooks/validators/verify_paths.py +0 -57
  160. package/skills/bdd-protocol/CLAUDE.md +0 -26
  161. package/skills/bdd-protocol/SKILL.md +0 -30
  162. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  163. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  164. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  165. package/skills/bg-agent/CLAUDE.md +0 -17
  166. package/skills/bg-agent/SKILL.md +0 -69
  167. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  168. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  169. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  170. package/skills/caveman/CLAUDE.md +0 -15
  171. package/skills/caveman/SKILL.md +0 -38
  172. package/skills/code/CLAUDE.md +0 -17
  173. package/skills/code/SKILL.md +0 -178
  174. package/skills/deep-research/CLAUDE.md +0 -17
  175. package/skills/deep-research/SKILL.md +0 -80
  176. package/skills/findbugs/CLAUDE.md +0 -20
  177. package/skills/findbugs/SKILL.md +0 -241
  178. package/skills/fixbugs/CLAUDE.md +0 -19
  179. package/skills/fixbugs/SKILL.md +0 -142
  180. package/skills/gh-paginate/CLAUDE.md +0 -18
  181. package/skills/gh-paginate/SKILL.md +0 -84
  182. package/skills/gotcha/CLAUDE.md +0 -33
  183. package/skills/gotcha/SKILL.md +0 -73
  184. package/skills/implement/CLAUDE.md +0 -27
  185. package/skills/implement/SKILL.md +0 -66
  186. package/skills/implement/scripts/CLAUDE.md +0 -22
  187. package/skills/implement/scripts/append_note.py +0 -133
  188. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  189. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  190. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  191. package/skills/implement/scripts/test_append_note.py +0 -191
  192. package/skills/log-audit/CLAUDE.md +0 -20
  193. package/skills/log-audit/SKILL.md +0 -66
  194. package/skills/log-audit/reference/CLAUDE.md +0 -9
  195. package/skills/log-audit/reference/charter.md +0 -52
  196. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  197. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  198. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  199. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  200. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  201. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  202. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  203. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  204. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  205. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  206. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  207. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  208. package/skills/logifix/CLAUDE.md +0 -36
  209. package/skills/logifix/SKILL.md +0 -69
  210. package/skills/logifix/scripts/CLAUDE.md +0 -16
  211. package/skills/logifix/scripts/logifix.ps1 +0 -205
  212. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  213. package/skills/monitor-open-prs/SKILL.md +0 -88
  214. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  215. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  216. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  217. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  218. package/skills/post-audit-findings/SKILL.md +0 -80
  219. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  220. package/skills/pr-consistency-audit/SKILL.md +0 -116
  221. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  222. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  223. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  224. package/skills/pr-review-responder/CLAUDE.md +0 -35
  225. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  226. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  227. package/skills/pr-review-responder/README.md +0 -168
  228. package/skills/pr-review-responder/SKILL.md +0 -203
  229. package/skills/pr-review-responder/TESTING.md +0 -350
  230. package/skills/pr-scope-resolve/SKILL.md +0 -45
  231. package/skills/pre-compact/CLAUDE.md +0 -24
  232. package/skills/pre-compact/SKILL.md +0 -134
  233. package/skills/qbug/CLAUDE.md +0 -40
  234. package/skills/qbug/SKILL.md +0 -387
  235. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  236. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  237. package/skills/refine/CLAUDE.md +0 -44
  238. package/skills/refine/SKILL.md +0 -262
  239. package/skills/refine/templates/CLAUDE.md +0 -17
  240. package/skills/refine/templates/implementation-notes-template.html +0 -56
  241. package/skills/refine/templates/plan-template.md +0 -60
  242. package/skills/research-mode/CLAUDE.md +0 -35
  243. package/skills/research-mode/SKILL.md +0 -53
  244. package/skills/structure-prompt/CLAUDE.md +0 -42
  245. package/skills/structure-prompt/SKILL.md +0 -44
  246. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  247. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  248. package/skills/structure-prompt/reference/block-classification.md +0 -27
  249. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  250. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  251. package/skills/structure-prompt/reference/cleanup.md +0 -33
  252. package/skills/structure-prompt/reference/constraints.md +0 -33
  253. package/skills/structure-prompt/reference/directives.md +0 -37
  254. package/skills/structure-prompt/reference/examples.md +0 -72
  255. package/skills/structure-prompt/reference/instantiation.md +0 -51
  256. package/skills/structure-prompt/reference/output-contract.md +0 -72
  257. package/skills/structure-prompt/reference/per-category.md +0 -23
  258. package/skills/structure-prompt/reference/persona.md +0 -38
  259. package/skills/structure-prompt/reference/research.md +0 -33
  260. package/skills/structure-prompt/reference/structure.md +0 -28
  261. package/skills/verified-build/CLAUDE.md +0 -33
  262. package/skills/verified-build/SKILL.md +0 -33
@@ -0,0 +1,457 @@
1
+ import { test, after } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { execFileSync } from 'node:child_process';
4
+ import {
5
+ mkdtempSync,
6
+ mkdirSync,
7
+ writeFileSync,
8
+ existsSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ rmSync,
12
+ cpSync,
13
+ } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join, dirname, basename } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
19
+ const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
20
+ const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
21
+ const EXCLUDED_PACKAGE_COPY_DIRECTORY = 'node_modules';
22
+
23
+ const RETIRED_SKILL_DIRECTORIES = [
24
+ 'findbugs',
25
+ 'fixbugs',
26
+ 'pr-scope-resolve',
27
+ 'post-audit-findings',
28
+ 'pr-consistency-audit',
29
+ 'bdd-protocol',
30
+ ];
31
+ const PERSONAL_SKILL_DIRECTORIES = ['balatro', 'midjourney-sref', 'credit-card-picker'];
32
+ const SHIPPED_SKILL_DIRECTORY = 'autoconverge';
33
+ const PRUNED_BACKUP_DIRECTORY_NAME = '.claude-dev-env-pruned';
34
+ const SKIP_PRUNE_NOTICE_MARKER = 'Skipping retired-skill prune';
35
+ const DEPENDENCY_STUB_PACKAGE_SEGMENTS = ['@jl-cmd', 'prompt-generator'];
36
+
37
+ /**
38
+ * Create a stub node_modules tree the installer can resolve the declared
39
+ * dependency package from, and return its root for the child's NODE_PATH.
40
+ *
41
+ * The installer resolves `@jl-cmd/prompt-generator/package.json`; a lone
42
+ * package.json at that path is enough for `createRequire.resolve` to succeed.
43
+ * The stub makes the dependency resolvable inside the sandbox so a full install
44
+ * exercises the real prune path. It does not prove the real private package
45
+ * resolves in a real install — CI cannot host that package — only that the
46
+ * resolved-dependency branch prunes as designed.
47
+ *
48
+ * @param {string} homeDirectory The sandbox home the stub is nested under.
49
+ * @returns {string} The stub modules root to place on the child's NODE_PATH.
50
+ */
51
+ let isolatedInstallerPath = null;
52
+ let isolatedPackageCopyRoot = null;
53
+
54
+ /**
55
+ * Copy the package into a temp directory without node_modules and return the
56
+ * copy's installer path.
57
+ *
58
+ * The installer resolves its declared dependencies with Node's regular
59
+ * node_modules walk starting at its own file, so the real installer under the
60
+ * repo always finds `@jl-cmd/prompt-generator` once `npm install` has run —
61
+ * removing NODE_PATH from the child cannot make the dependency unresolvable
62
+ * there. Running the copy makes resolution genuinely fail through the
63
+ * installer's own catch path: no node_modules sits in the copy's ancestry and
64
+ * the child gets no NODE_PATH. The copy is created once and shared by every
65
+ * unresolved-dependency install; installs write only into their sandbox HOME.
66
+ *
67
+ * @returns {string} The path to `bin/install.mjs` inside the package copy.
68
+ */
69
+ function ensureIsolatedInstallerPath() {
70
+ if (isolatedInstallerPath !== null) return isolatedInstallerPath;
71
+ isolatedPackageCopyRoot = mkdtempSync(join(tmpdir(), 'cdev-prune-package-'));
72
+ cpSync(PACKAGE_DIRECTORY, isolatedPackageCopyRoot, {
73
+ recursive: true,
74
+ filter: sourcePath => basename(sourcePath) !== EXCLUDED_PACKAGE_COPY_DIRECTORY,
75
+ });
76
+ isolatedInstallerPath = join(isolatedPackageCopyRoot, 'bin', 'install.mjs');
77
+ return isolatedInstallerPath;
78
+ }
79
+
80
+ after(() => {
81
+ if (isolatedPackageCopyRoot !== null) {
82
+ rmSync(isolatedPackageCopyRoot, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ function ensureDependencyStub(homeDirectory) {
87
+ const stubModulesRoot = join(homeDirectory, 'dependency-stub-modules');
88
+ const stubPackageDirectory = join(stubModulesRoot, ...DEPENDENCY_STUB_PACKAGE_SEGMENTS);
89
+ mkdirSync(stubPackageDirectory, { recursive: true });
90
+ writeFileSync(
91
+ join(stubPackageDirectory, 'package.json'),
92
+ JSON.stringify({
93
+ name: DEPENDENCY_STUB_PACKAGE_SEGMENTS.join('/'),
94
+ version: '1.0.0',
95
+ description: 'sandbox dependency stub',
96
+ }) + '\n',
97
+ );
98
+ return stubModulesRoot;
99
+ }
100
+
101
+ /**
102
+ * Report whether a retired skill directory landed under the prune backup root.
103
+ *
104
+ * @param {string} claudeDirectory The sandbox ~/.claude directory.
105
+ * @param {string} skillName The retired skill directory name to look for.
106
+ * @returns {boolean} True when a timestamped backup holds the skill directory.
107
+ */
108
+ function prunedBackupContains(claudeDirectory, skillName) {
109
+ const backupRoot = join(claudeDirectory, PRUNED_BACKUP_DIRECTORY_NAME);
110
+ if (!existsSync(backupRoot)) return false;
111
+ return readdirSync(backupRoot, { withFileTypes: true })
112
+ .filter(entry => entry.isDirectory())
113
+ .some(timestampDir => existsSync(join(backupRoot, timestampDir.name, skillName)));
114
+ }
115
+
116
+ /**
117
+ * Create an isolated ~/.claude sandbox and return the paths a prune test reads.
118
+ *
119
+ * The returned home directory becomes the installer's HOME, so every file the
120
+ * install writes and every skill directory the prune inspects stays inside the
121
+ * temp tree and never touches the machine's real ~/.claude.
122
+ *
123
+ * @returns {{homeDirectory: string, claudeDirectory: string, skillsDirectory: string, manifestPath: string}}
124
+ */
125
+ function createSandbox() {
126
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-prune-home-'));
127
+ const claudeDirectory = join(homeDirectory, '.claude');
128
+ const skillsDirectory = join(claudeDirectory, 'skills');
129
+ mkdirSync(skillsDirectory, { recursive: true });
130
+ const manifestPath = join(claudeDirectory, '.claude-dev-env-manifest.json');
131
+ return { homeDirectory, claudeDirectory, skillsDirectory, manifestPath };
132
+ }
133
+
134
+ /**
135
+ * Plant a skill directory under the sandbox with a single marker file.
136
+ *
137
+ * A personal directory is planted with no ``SKILL.md`` so it never looks like a
138
+ * shipped skill; a retired directory is planted with a ``SKILL.md`` so it mirrors
139
+ * a real skill the package once installed and later dropped.
140
+ *
141
+ * @param {string} skillsDirectory The sandbox skills directory.
142
+ * @param {string} skillName The directory name to plant.
143
+ * @param {boolean} withSkillManifest Whether to write a ``SKILL.md`` marker.
144
+ */
145
+ function plantSkillDirectory(skillsDirectory, skillName, withSkillManifest) {
146
+ const skillDirectory = join(skillsDirectory, skillName);
147
+ mkdirSync(skillDirectory, { recursive: true });
148
+ const markerName = withSkillManifest ? 'SKILL.md' : 'notes.md';
149
+ writeFileSync(join(skillDirectory, markerName), `seeded ${skillName}\n`);
150
+ }
151
+
152
+ /**
153
+ * Run the real installer against the sandbox home and return its stdout.
154
+ *
155
+ * The declared dependency package is resolvable by default: the child's
156
+ * NODE_PATH points at a sandbox stub so `createRequire.resolve` finds it and the
157
+ * full-install prune runs. Passing ``{ dependencyResolvable: false }`` runs the
158
+ * installer from an isolated package copy with no node_modules in its ancestry
159
+ * and no NODE_PATH, so the dependency fails to resolve and the installer skips
160
+ * the prune.
161
+ *
162
+ * @param {string} homeDirectory The sandbox home the installer writes into.
163
+ * @param {string[]} extraArguments Installer arguments (for example ``['--only', 'core']``).
164
+ * @param {{dependencyResolvable?: boolean}} options Whether the dependency resolves.
165
+ * @returns {string} The installer's stdout.
166
+ */
167
+ function runInstaller(homeDirectory, extraArguments, options = {}) {
168
+ const dependencyResolvable = options.dependencyResolvable !== false;
169
+ const childEnvironment = {
170
+ ...process.env,
171
+ HOME: homeDirectory,
172
+ USERPROFILE: homeDirectory,
173
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
174
+ };
175
+ let installerPath;
176
+ if (dependencyResolvable) {
177
+ childEnvironment.NODE_PATH = ensureDependencyStub(homeDirectory);
178
+ installerPath = INSTALLER_PATH;
179
+ } else {
180
+ delete childEnvironment.NODE_PATH;
181
+ installerPath = ensureIsolatedInstallerPath();
182
+ }
183
+ return execFileSync('node', [installerPath, ...extraArguments], {
184
+ cwd: dirname(installerPath),
185
+ encoding: 'utf8',
186
+ env: childEnvironment,
187
+ });
188
+ }
189
+
190
+ function readManifest(manifestPath) {
191
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
192
+ }
193
+
194
+ test('a full reinstall over a pre-manifest dirty tree prunes retired skills and keeps personal ones', () => {
195
+ const sandbox = createSandbox();
196
+ try {
197
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
198
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
199
+ }
200
+ for (const personalSkill of PERSONAL_SKILL_DIRECTORIES) {
201
+ plantSkillDirectory(sandbox.skillsDirectory, personalSkill, false);
202
+ }
203
+ assert.equal(existsSync(sandbox.manifestPath), false, 'sandbox starts with no manifest');
204
+
205
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
206
+
207
+ assert.equal(
208
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
209
+ false,
210
+ 'the resolvable-dependency install runs the prune rather than skipping it',
211
+ );
212
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
213
+ assert.equal(
214
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
215
+ false,
216
+ `retired skill ${retiredSkill} should be pruned`,
217
+ );
218
+ assert.equal(
219
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
220
+ true,
221
+ `retired skill ${retiredSkill} should be moved to the prune backup`,
222
+ );
223
+ }
224
+ for (const personalSkill of PERSONAL_SKILL_DIRECTORIES) {
225
+ assert.equal(
226
+ existsSync(join(sandbox.skillsDirectory, personalSkill)),
227
+ true,
228
+ `personal skill ${personalSkill} should survive`,
229
+ );
230
+ }
231
+ assert.equal(
232
+ existsSync(join(sandbox.skillsDirectory, SHIPPED_SKILL_DIRECTORY)),
233
+ true,
234
+ 'shipped skills should install',
235
+ );
236
+ const manifest = readManifest(sandbox.manifestPath);
237
+ assert.ok(Array.isArray(manifest.skills), 'manifest gains a skills array');
238
+ assert.ok(manifest.skills.includes(SHIPPED_SKILL_DIRECTORY), 'manifest skills lists shipped skills');
239
+ assert.equal(manifest.skills.includes('_shared'), false, 'manifest skills omits _shared');
240
+ assert.equal(manifest.skills.includes('__pycache__'), false, 'manifest skills omits __pycache__');
241
+ } finally {
242
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ test('a full reinstall over an old-format manifest without a skills key still prunes via the ever-shipped fallback', () => {
247
+ const sandbox = createSandbox();
248
+ try {
249
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
250
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
251
+ }
252
+ writeFileSync(
253
+ sandbox.manifestPath,
254
+ JSON.stringify({
255
+ package: 'claude-dev-env',
256
+ version: '0.0.0',
257
+ installedAt: new Date().toISOString(),
258
+ files: [join(sandbox.skillsDirectory, 'findbugs', 'SKILL.md')],
259
+ }, null, 2) + '\n',
260
+ );
261
+
262
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
263
+
264
+ assert.equal(
265
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
266
+ false,
267
+ 'the resolvable-dependency install runs the prune rather than skipping it',
268
+ );
269
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
270
+ assert.equal(
271
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
272
+ false,
273
+ `retired skill ${retiredSkill} should be pruned via the ever-shipped fallback`,
274
+ );
275
+ assert.equal(
276
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
277
+ true,
278
+ `retired skill ${retiredSkill} should be moved to the prune backup`,
279
+ );
280
+ }
281
+ const manifest = readManifest(sandbox.manifestPath);
282
+ assert.ok(Array.isArray(manifest.skills), 'the reinstall writes the skills key onto the old-format manifest');
283
+ } finally {
284
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ test('a full reinstall prunes a manifest-recorded skill absent from the package and keeps a personal directory', () => {
289
+ const sandbox = createSandbox();
290
+ try {
291
+ const retiredManifestSkill = 'ghost-skill';
292
+ const personalSkill = 'balatro';
293
+ plantSkillDirectory(sandbox.skillsDirectory, retiredManifestSkill, true);
294
+ plantSkillDirectory(sandbox.skillsDirectory, personalSkill, false);
295
+ writeFileSync(
296
+ sandbox.manifestPath,
297
+ JSON.stringify({
298
+ package: 'claude-dev-env',
299
+ version: '0.0.0',
300
+ installedAt: new Date().toISOString(),
301
+ files: [],
302
+ skills: [SHIPPED_SKILL_DIRECTORY, retiredManifestSkill],
303
+ }, null, 2) + '\n',
304
+ );
305
+
306
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
307
+
308
+ assert.equal(
309
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
310
+ false,
311
+ 'the resolvable-dependency install runs the prune rather than skipping it',
312
+ );
313
+ assert.equal(
314
+ existsSync(join(sandbox.skillsDirectory, retiredManifestSkill)),
315
+ false,
316
+ 'a skill the prior manifest recorded but the package no longer ships is pruned',
317
+ );
318
+ assert.equal(
319
+ prunedBackupContains(sandbox.claudeDirectory, retiredManifestSkill),
320
+ true,
321
+ 'the manifest-recorded retired skill is moved to the prune backup',
322
+ );
323
+ assert.equal(
324
+ existsSync(join(sandbox.skillsDirectory, personalSkill)),
325
+ true,
326
+ 'a personal directory in neither the manifest nor the ever-shipped set survives',
327
+ );
328
+ } finally {
329
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
330
+ }
331
+ });
332
+
333
+ test('a scoped --only install leaves retired skills in place because prune runs on full installs only', () => {
334
+ const sandbox = createSandbox();
335
+ try {
336
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
337
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
338
+ }
339
+
340
+ runInstaller(sandbox.homeDirectory, ['--only', 'core']);
341
+
342
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
343
+ assert.equal(
344
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
345
+ true,
346
+ `retired skill ${retiredSkill} should survive a scoped install`,
347
+ );
348
+ assert.equal(
349
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
350
+ false,
351
+ `retired skill ${retiredSkill} should not be moved to backup by a scoped install`,
352
+ );
353
+ }
354
+ } finally {
355
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
356
+ }
357
+ });
358
+
359
+ test('a full reinstall keeps pr-fix-protocol because the package ships it again', () => {
360
+ const sandbox = createSandbox();
361
+ try {
362
+ plantSkillDirectory(sandbox.skillsDirectory, 'pr-fix-protocol', true);
363
+ writeFileSync(join(sandbox.skillsDirectory, 'pr-fix-protocol', 'SKILL.md'), 'stale seeded copy\n');
364
+
365
+ const installerOutput = runInstaller(sandbox.homeDirectory, []);
366
+
367
+ assert.equal(
368
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
369
+ false,
370
+ 'the resolvable-dependency install runs the prune rather than skipping it',
371
+ );
372
+ const restoredSkillPath = join(sandbox.skillsDirectory, 'pr-fix-protocol', 'SKILL.md');
373
+ assert.equal(existsSync(restoredSkillPath), true, 'pr-fix-protocol survives and is reinstalled');
374
+ assert.notEqual(
375
+ readFileSync(restoredSkillPath, 'utf8'),
376
+ 'stale seeded copy\n',
377
+ 'the shipped pr-fix-protocol overwrites the stale seeded copy',
378
+ );
379
+ } finally {
380
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
381
+ }
382
+ });
383
+
384
+ test('a full reinstall with an unresolved dependency skips the prune and leaves retired skills untouched', () => {
385
+ const sandbox = createSandbox();
386
+ try {
387
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
388
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
389
+ }
390
+
391
+ const installerOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: false });
392
+
393
+ assert.equal(
394
+ installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
395
+ true,
396
+ 'the installer logs a notice that it is skipping the prune',
397
+ );
398
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
399
+ assert.equal(
400
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
401
+ true,
402
+ `retired skill ${retiredSkill} should survive when a dependency is unresolved`,
403
+ );
404
+ assert.equal(
405
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
406
+ false,
407
+ `retired skill ${retiredSkill} should not be moved to backup when the prune is skipped`,
408
+ );
409
+ }
410
+ } finally {
411
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
412
+ }
413
+ });
414
+
415
+ test('the prune skip is scoped: once the dependency resolves a later full install prunes normally', () => {
416
+ const sandbox = createSandbox();
417
+ try {
418
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
419
+ plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
420
+ }
421
+
422
+ const skippedOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: false });
423
+ assert.equal(
424
+ skippedOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
425
+ true,
426
+ 'the unresolved-dependency install skips the prune',
427
+ );
428
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
429
+ assert.equal(
430
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
431
+ true,
432
+ `retired skill ${retiredSkill} should still be present after the skipped prune`,
433
+ );
434
+ }
435
+
436
+ const resolvedOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: true });
437
+ assert.equal(
438
+ resolvedOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
439
+ false,
440
+ 'the resolved-dependency install runs the prune, proving the skip is not a permanent disable',
441
+ );
442
+ for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
443
+ assert.equal(
444
+ existsSync(join(sandbox.skillsDirectory, retiredSkill)),
445
+ false,
446
+ `retired skill ${retiredSkill} should be pruned once the dependency resolves`,
447
+ );
448
+ assert.equal(
449
+ prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
450
+ true,
451
+ `retired skill ${retiredSkill} should be moved to the prune backup on the resolved install`,
452
+ );
453
+ }
454
+ } finally {
455
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
456
+ }
457
+ });
@@ -637,6 +637,12 @@ test('commandReferencesManagedHook leaves an unmanaged inline -c command that im
637
637
  });
638
638
 
639
639
 
640
+
641
+ function isPreToolUseDispatcherCommand(command) {
642
+ // Basename-anchored match: bash_pre_tool_use_dispatcher.py must not count.
643
+ return /(?:^|[/\\])pre_tool_use_dispatcher\.py(?![A-Za-z0-9_])/.test(command);
644
+ }
645
+
640
646
  function countManagedRunAllValidatorsHooks(settings) {
641
647
  const writeEditGroups = (settings.hooks.PreToolUse || []).filter(
642
648
  group => group.matcher === 'Write|Edit'
@@ -996,7 +1002,7 @@ test('mergeHooksIntoSettings into old folded-hooks settings yields exactly one d
996
1002
  const allPreToolUseGroups = settings.hooks.PreToolUse || [];
997
1003
  const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
998
1004
 
999
- const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
1005
+ const allDispatcherCommands = allHookCommands.filter(isPreToolUseDispatcherCommand);
1000
1006
  assert.equal(allDispatcherCommands.length, 1, 'exactly one dispatcher entry must be present');
1001
1007
 
1002
1008
  for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
@@ -1029,7 +1035,7 @@ test('mergeHooksIntoSettings is idempotent when run twice against an already-upd
1029
1035
  const allPreToolUseGroups = settings.hooks.PreToolUse || [];
1030
1036
  const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
1031
1037
 
1032
- const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
1038
+ const allDispatcherCommands = allHookCommands.filter(isPreToolUseDispatcherCommand);
1033
1039
  assert.equal(allDispatcherCommands.length, 1, 'dispatcher must appear exactly once after two merges');
1034
1040
  assert.equal(countManagedRunAllValidatorsHooks(settings), 1, 'run_all_validators must appear exactly once after two merges');
1035
1041
  });
@@ -1042,7 +1048,7 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
1042
1048
 
1043
1049
  const allPreToolUseGroups = shippedHooksConfig.hooks.PreToolUse || [];
1044
1050
  const allPreCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
1045
- const preDispatcherCommands = allPreCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
1051
+ const preDispatcherCommands = allPreCommands.filter(isPreToolUseDispatcherCommand);
1046
1052
  assert.equal(preDispatcherCommands.length, 1, 'shipped hooks.json must register the PreToolUse dispatcher exactly once');
1047
1053
 
1048
1054
  assert.equal(
@@ -1222,6 +1228,6 @@ test('mergeHooksIntoSettings prunes the inline run_all_validators runner when th
1222
1228
  }
1223
1229
 
1224
1230
  const dispatcherGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit|MultiEdit');
1225
- const dispatcherCommands = dispatcherGroup.hooks.filter(hook => hook.command.includes('pre_tool_use_dispatcher.py'));
1231
+ const dispatcherCommands = dispatcherGroup.hooks.filter(hook => isPreToolUseDispatcherCommand(hook.command));
1226
1232
  assert.equal(dispatcherCommands.length, 1, 'the PreToolUse dispatcher must remain exactly once');
1227
1233
  });
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
94
94
 
95
95
  ## 11. ENFORCEMENT SURFACES
96
96
 
97
- ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit. The diagram-first docstring standard (a summary line, then a `::` example or doctest, then a couple of short prose lines) lives in `packages/claude-dev-env/rules/plain-illustrative-docstrings.md`, enforced by the `check_docstring_runon_sentence` and `check_docstring_prose_wall_without_illustration` backstop hooks and Category O9 audit.
97
+ ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit. The diagram-first docstring standard (a summary line, then a `::` example or doctest, then a couple of short prose lines) lives in `packages/claude-dev-env/rules/plain-illustrative-docstrings.md`, enforced by the `check_docstring_runon_sentence` and `check_docstring_prose_wall_without_illustration` backstop hooks and Category O9 audit.
@@ -81,6 +81,8 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
81
81
  | `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
82
82
  | `nas_ssh_binary_enforcer.py` | PreToolUse (Bash) | A bare `ssh`/`scp`/`sftp` command word targeting the NAS (Git Bash's MSYS ssh stalls on an interactive password prompt), or the full `System32/OpenSSH` binary to that host without `-o BatchMode=yes` |
83
83
  | `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory (or a parent skill's `SKILL.md` Layout table mapping the `scripts/` subdirectory) names two or more sibling files but no entry for the new file |
84
+ | `pii_commit_command.py` | library | Token-aware git-commit detection reused by `pii_prevention_blocker.py` |
85
+ | `pii_payload_scan.py` | library | Write/Edit and durable post-body PII evaluation reused by `pii_prevention_blocker.py` |
84
86
  | `pii_prevention_blocker.py` | PreToolUse (Write/Edit/MultiEdit/Bash/PowerShell/MCP GitHub) | Content that carries high-confidence personal data or secrets (real emails, home-dir paths, private IPs, credential material) on write, durable GitHub posts, or staged commit paths |
85
87
  | `pii_scanner.py` | library | Pure text scanners shared by `pii_prevention_blocker.py` |
86
88
  | `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
@@ -158,6 +158,7 @@ from code_rules_shared import ( # noqa: E402
158
158
  is_ephemeral_script_path,
159
159
  is_hook_infrastructure,
160
160
  is_test_file,
161
+ is_under_session_scratchpad,
161
162
  )
162
163
  from code_rules_string_magic import ( # noqa: E402
163
164
  check_inline_literal_collections,
@@ -1104,6 +1105,9 @@ def main(all_arguments: list[str]) -> None:
1104
1105
  tool_input = pretooluse_payload.get("tool_input", {})
1105
1106
  file_path = tool_input.get("file_path", "")
1106
1107
 
1108
+ if is_under_session_scratchpad(file_path, pretooluse_payload):
1109
+ sys.exit(0)
1110
+
1107
1111
  runs_full_verdict = _is_validated_target(file_path)
1108
1112
  if not runs_full_verdict and not _is_hook_infrastructure_python_target(file_path):
1109
1113
  sys.exit(0)
@@ -4,6 +4,7 @@ import ast
4
4
  import difflib
5
5
  import os
6
6
  import sys
7
+ import tempfile
7
8
  from collections.abc import Collection, Iterator
8
9
  from pathlib import Path
9
10
 
@@ -29,6 +30,13 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
29
30
  LEADING_DRIVE_LETTER_PATTERN,
30
31
  STRICT_TEST_FILE_BASENAME_PATTERN,
31
32
  )
33
+ from hooks_constants.harness_scratchpad_constants import ( # noqa: E402
34
+ HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
35
+ HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT,
36
+ HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX,
37
+ HOOK_PAYLOAD_SESSION_ID_KEY,
38
+ HOOK_PAYLOAD_WORKING_DIRECTORY_KEY,
39
+ )
32
40
  from hooks_constants.unused_module_import_constants import ( # noqa: E402
33
41
  TYPE_CHECKING_IDENTIFIER,
34
42
  )
@@ -267,6 +275,80 @@ def is_ephemeral_script_path(file_path: str) -> bool:
267
275
  return False
268
276
 
269
277
 
278
+ def _resolve_session_scratchpad_root(hook_payload: dict) -> str | None:
279
+ """Rebuild the harness session scratchpad directory from a PreToolUse payload.
280
+
281
+ ::
282
+
283
+ payload.cwd = /home/user/project payload.session_id = 5f2c...
284
+ | |
285
+ <tempdir>/claude-<uid>/-home-user-project/5f2c.../scratchpad
286
+ | |
287
+ os.getuid() cwd with each "/" turned to "-"
288
+
289
+ No environment variable carries this path, so it is rebuilt from the three
290
+ signals a hook can read: the POSIX user id and the ``cwd`` and ``session_id``
291
+ fields the harness puts in every PreToolUse payload. The rebuilt directory
292
+ is returned only when it exists on disk, so a wrong guess or a non-POSIX
293
+ platform yields None and the gates keep full enforcement.
294
+
295
+ Args:
296
+ hook_payload: The PreToolUse payload carrying ``cwd`` and ``session_id``.
297
+
298
+ Returns:
299
+ The scratchpad directory path when it exists on disk, else None.
300
+ """
301
+ get_user_id = getattr(os, "getuid", None)
302
+ if get_user_id is None:
303
+ return None
304
+ session_id = hook_payload.get(HOOK_PAYLOAD_SESSION_ID_KEY, "")
305
+ working_directory = hook_payload.get(HOOK_PAYLOAD_WORKING_DIRECTORY_KEY, "")
306
+ if not isinstance(session_id, str) or not session_id:
307
+ return None
308
+ if not isinstance(working_directory, str) or not working_directory:
309
+ return None
310
+ mangled_working_directory = working_directory.replace("\\", "/").replace(
311
+ "/", HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT
312
+ )
313
+ user_directory_name = f"{HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX}{get_user_id()}"
314
+ scratchpad_root = os.path.join(
315
+ tempfile.gettempdir(),
316
+ user_directory_name,
317
+ mangled_working_directory,
318
+ session_id,
319
+ HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
320
+ )
321
+ if not os.path.isdir(scratchpad_root):
322
+ return None
323
+ return scratchpad_root
324
+
325
+
326
+ def is_under_session_scratchpad(file_path: str, hook_payload: dict) -> bool:
327
+ """Return True when file_path resolves under the harness session scratchpad.
328
+
329
+ One-off scripts written to the session scratchpad are throwaway tooling
330
+ outside every repo, so the TDD and CODE_RULES gates skip them. Both
331
+ file_path and the rebuilt scratchpad root are resolved through the real
332
+ filesystem (symlinks followed) before the containment test, so a symlink
333
+ into the scratchpad exempts and a symlink out of it does not.
334
+
335
+ Args:
336
+ file_path: The path the write targets.
337
+ hook_payload: The PreToolUse payload carrying ``cwd`` and ``session_id``.
338
+
339
+ Returns:
340
+ True when file_path's real path sits at or under the session scratchpad.
341
+ """
342
+ if not file_path:
343
+ return False
344
+ scratchpad_root = _resolve_session_scratchpad_root(hook_payload)
345
+ if scratchpad_root is None:
346
+ return False
347
+ real_target = os.path.realpath(file_path)
348
+ real_root = os.path.realpath(scratchpad_root)
349
+ return real_target == real_root or real_target.startswith(real_root + os.sep)
350
+
351
+
270
352
  def is_migration_file(file_path: str) -> bool:
271
353
  """Check if file is a Django migration (must be self-contained)."""
272
354
  path_lower = file_path.lower().replace("\\", "/")
@@ -133,7 +133,11 @@ def _referenced_names(tree: ast.Module) -> set[str]:
133
133
  }
134
134
  return load_names | literal_values
135
135
 
136
- def _dead_constant_messages(tree: ast.Module, referenced_names: set[str]) -> list[str]:
136
+ def _dead_constant_messages(
137
+ tree: ast.Module,
138
+ referenced_names: set[str],
139
+ maximum_issues: int,
140
+ ) -> list[str]:
137
141
  """Return one message per module constant absent from the referenced set."""
138
142
  issues: list[str] = []
139
143
  for each_name, each_line in _module_constant_targets(tree):
@@ -142,7 +146,7 @@ def _dead_constant_messages(tree: ast.Module, referenced_names: set[str]) -> lis
142
146
  issues.append(
143
147
  f"Line {each_line}: constant {each_name!r} - {DEAD_TEST_CONSTANT_GUIDANCE}"
144
148
  )
145
- if len(issues) >= MAX_TEST_LAYOUT_ISSUES:
149
+ if len(issues) >= maximum_issues:
146
150
  break
147
151
  return issues
148
152
 
@@ -174,7 +178,9 @@ def check_dead_test_module_constant(content: str, file_path: str) -> list[str]:
174
178
  tree = _parse_module(content)
175
179
  if tree is None:
176
180
  return []
177
- return _dead_constant_messages(tree, _referenced_names(tree))
181
+ return _dead_constant_messages(
182
+ tree, _referenced_names(tree), MAX_TEST_LAYOUT_ISSUES
183
+ )
178
184
 
179
185
  def _node_names_fixture(node: ast.AST) -> bool:
180
186
  """Return whether one decorator sub-node spells the pytest fixture marker."""