claude-dev-env 1.38.1 → 1.40.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 (282) hide show
  1. package/CLAUDE.md +10 -36
  2. package/_shared/pr-loop/audit-reply-template.md +147 -0
  3. package/_shared/pr-loop/fix-protocol.md +25 -4
  4. package/_shared/pr-loop/gh-payloads.md +37 -50
  5. package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
  6. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +199 -0
  7. package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
  8. package/_shared/pr-loop/scripts/post_audit_thread.py +1242 -0
  9. package/_shared/pr-loop/scripts/preflight.py +129 -2
  10. package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
  12. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1116 -0
  13. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
  14. package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
  15. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
  16. package/_shared/pr-loop/state-schema.md +1 -1
  17. package/agents/clean-coder.md +2 -2
  18. package/agents/pr-description-writer.md +150 -52
  19. package/bin/install.mjs +6 -7
  20. package/bin/install.test.mjs +8 -0
  21. package/commands/doc-gist.md +16 -0
  22. package/commands/plan.md +0 -2
  23. package/commands/review-plan.md +1 -1
  24. package/docs/CODE_RULES.md +122 -2
  25. package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
  26. package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
  27. package/hooks/blocking/code_rules_enforcer.py +1143 -129
  28. package/hooks/blocking/convergence_gate_blocker.py +130 -0
  29. package/hooks/blocking/destructive_command_blocker.py +74 -0
  30. package/hooks/blocking/gh_body_arg_blocker.py +30 -0
  31. package/hooks/blocking/md_to_html_blocker.py +119 -0
  32. package/hooks/blocking/pr_description_enforcer.py +57 -22
  33. package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
  34. package/hooks/blocking/test_code_rules_enforcer.py +21 -0
  35. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
  36. package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
  37. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
  38. package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
  39. package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
  40. package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
  41. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  42. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
  44. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
  45. package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
  46. package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
  47. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
  48. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
  49. package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
  50. package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
  51. package/hooks/blocking/test_destructive_command_blocker.py +146 -0
  52. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
  53. package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
  54. package/hooks/blocking/test_md_to_html_blocker.py +317 -0
  55. package/hooks/blocking/test_pr_description_enforcer.py +69 -8
  56. package/hooks/config/any_type_config.py +7 -0
  57. package/hooks/config/banned_identifiers_constants.py +11 -0
  58. package/hooks/config/blocking_check_limits.py +38 -0
  59. package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
  60. package/hooks/config/code_rules_enforcer_constants.py +53 -0
  61. package/hooks/config/convergence_branch_constants.py +9 -0
  62. package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
  63. package/hooks/config/html_companion_constants.py +20 -0
  64. package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
  65. package/hooks/config/pr_description_enforcer_constants.py +14 -0
  66. package/hooks/config/test_banned_identifiers_constants.py +17 -0
  67. package/hooks/hooks.json +28 -20
  68. package/hooks/pyproject.toml +69 -0
  69. package/hooks/validators/mypy_integration.py +47 -1
  70. package/hooks/validators/run_all_validators.py +3 -3
  71. package/hooks/validators/test_mypy_integration.py +50 -1
  72. package/hooks/workflow/doc_gist_auto_publish.py +144 -0
  73. package/hooks/workflow/md_to_html_companion.py +365 -0
  74. package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
  75. package/hooks/workflow/test_md_to_html_companion.py +452 -0
  76. package/package.json +1 -1
  77. package/rules/gh-body-file.md +2 -0
  78. package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
  79. package/scripts/check.ps1 +106 -0
  80. package/scripts/config/timing.py +11 -0
  81. package/scripts/sweep_empty_dirs.py +138 -0
  82. package/scripts/sync_to_cursor/rules.py +1 -1
  83. package/scripts/test_sweep_empty_dirs.py +183 -0
  84. package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
  85. package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
  86. package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
  87. package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
  88. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
  89. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
  90. package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
  91. package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
  92. package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
  93. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
  94. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
  95. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
  96. package/skills/bugteam/CONSTRAINTS.md +21 -22
  97. package/skills/bugteam/EXAMPLES.md +3 -3
  98. package/skills/bugteam/PROMPTS.md +227 -67
  99. package/skills/bugteam/SKILL.md +132 -455
  100. package/skills/bugteam/reference/README.md +1 -1
  101. package/skills/bugteam/reference/audit-and-teammates.md +112 -39
  102. package/skills/bugteam/reference/audit-contract.md +4 -22
  103. package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
  104. package/skills/bugteam/reference/design-rationale.md +2 -2
  105. package/skills/bugteam/reference/github-pr-reviews.md +50 -57
  106. package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
  107. package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
  108. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
  109. package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
  110. package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
  111. package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
  112. package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
  113. package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
  114. package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
  115. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
  116. package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
  117. package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
  118. package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
  119. package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
  120. package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
  121. package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
  122. package/skills/bugteam/reference/team-setup.md +111 -9
  123. package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
  124. package/skills/bugteam/scripts/README.md +60 -0
  125. package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
  126. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
  127. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
  128. package/skills/bugteam/scripts/bugteam_preflight.py +328 -0
  129. package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
  130. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
  131. package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
  132. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
  133. package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
  134. package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
  135. package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
  136. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
  137. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
  138. package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
  139. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
  140. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
  141. package/skills/bugteam/scripts/test_bugteam_preflight.py +309 -0
  142. package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
  143. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
  144. package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
  145. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
  146. package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
  147. package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
  148. package/skills/bugteam/test_skill_additions.py +1 -11
  149. package/skills/code/SKILL.md +176 -0
  150. package/skills/copilot-review/SKILL.md +16 -0
  151. package/skills/doc-gist/SKILL.md +99 -0
  152. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
  153. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
  154. package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
  155. package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
  156. package/skills/doc-gist/references/examples/05-design-system.html +629 -0
  157. package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
  158. package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
  159. package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
  160. package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
  161. package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
  162. package/skills/doc-gist/references/examples/11-status-report.html +528 -0
  163. package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
  164. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
  165. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
  166. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
  167. package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
  168. package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
  169. package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
  170. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
  171. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
  172. package/skills/doc-gist/references/examples/README.md +5 -0
  173. package/skills/doc-gist/scripts/config/__init__.py +0 -0
  174. package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
  175. package/skills/doc-gist/scripts/gist_upload.py +177 -0
  176. package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
  177. package/skills/findbugs/SKILL.md +96 -2
  178. package/skills/monitor-open-prs/SKILL.md +14 -32
  179. package/skills/monitor-open-prs/test_skill_contract.py +0 -11
  180. package/skills/pr-consistency-audit/SKILL.md +112 -0
  181. package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
  182. package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
  183. package/skills/pr-converge/SKILL.md +229 -23
  184. package/skills/pr-converge/config/__init__.py +0 -0
  185. package/skills/pr-converge/config/constants.py +63 -0
  186. package/skills/pr-converge/reference/convergence-gates.md +138 -44
  187. package/skills/pr-converge/reference/examples.md +43 -11
  188. package/skills/pr-converge/reference/fix-protocol.md +6 -5
  189. package/skills/pr-converge/reference/ground-rules.md +5 -3
  190. package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
  191. package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
  192. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
  193. package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
  194. package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
  195. package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
  196. package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
  197. package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
  198. package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
  199. package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
  200. package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
  201. package/skills/pr-converge/reference/per-tick.md +107 -31
  202. package/skills/pr-converge/reference/state-schema.md +22 -1
  203. package/skills/pr-converge/reference/stop-conditions.md +9 -7
  204. package/skills/pr-converge/scripts/README.md +34 -46
  205. package/skills/pr-converge/scripts/check_bugbot_ci.py +279 -0
  206. package/skills/pr-converge/scripts/check_convergence.py +497 -0
  207. package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
  208. package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
  209. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
  210. package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
  211. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
  212. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
  213. package/skills/qbug/SKILL.md +157 -27
  214. package/skills/session-log/SKILL.md +216 -114
  215. package/skills/session-tidy/SKILL.md +1 -1
  216. package/skills/skill-builder/SKILL.md +138 -56
  217. package/skills/skill-builder/references/delegation-map.md +72 -113
  218. package/skills/skill-builder/references/progressive-disclosure.md +122 -0
  219. package/skills/skill-builder/references/self-audit-checklist.md +92 -0
  220. package/skills/skill-builder/references/skill-types.md +228 -0
  221. package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
  222. package/skills/skill-builder/templates/gap-analysis.md +15 -8
  223. package/skills/skill-builder/workflows/improve-skill.md +86 -57
  224. package/skills/skill-builder/workflows/new-skill.md +80 -168
  225. package/skills/skill-builder/workflows/polish-skill.md +78 -54
  226. package/skills/structure-prompt/SKILL.md +50 -0
  227. package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
  228. package/skills/structure-prompt/reference/block-classification.md +27 -0
  229. package/skills/structure-prompt/reference/canonical-case.md +48 -0
  230. package/skills/structure-prompt/reference/citation-depth.md +70 -0
  231. package/skills/structure-prompt/reference/cleanup.md +33 -0
  232. package/skills/structure-prompt/reference/constraints.md +33 -0
  233. package/skills/structure-prompt/reference/directives.md +37 -0
  234. package/skills/structure-prompt/reference/examples.md +72 -0
  235. package/skills/structure-prompt/reference/instantiation.md +51 -0
  236. package/skills/structure-prompt/reference/output-contract.md +72 -0
  237. package/skills/structure-prompt/reference/per-category.md +23 -0
  238. package/skills/structure-prompt/reference/persona.md +38 -0
  239. package/skills/structure-prompt/reference/research.md +33 -0
  240. package/skills/structure-prompt/reference/structure.md +28 -0
  241. package/agents/code-standards-agent.md +0 -93
  242. package/agents/groq-coder.md +0 -113
  243. package/agents/plan-executor.md +0 -226
  244. package/agents/project-docs-analyzer.md +0 -53
  245. package/agents/project-structure-organizer-agent.md +0 -72
  246. package/agents/skill-to-agent-converter.md +0 -370
  247. package/agents/skill-writer-agent.md +0 -470
  248. package/agents/user-docs-writer.md +0 -67
  249. package/agents/workflow-visual-documenter.md +0 -82
  250. package/commands/readability-review.md +0 -20
  251. package/hooks/mypy.ini +0 -2
  252. package/hooks/notification/attention_needed_notify.py +0 -71
  253. package/hooks/notification/claude_notification_handler.py +0 -67
  254. package/hooks/notification/notification_utils.py +0 -267
  255. package/hooks/notification/subagent_complete_notify.py +0 -381
  256. package/hooks/notification/test_attention_needed_notify.py +0 -47
  257. package/hooks/notification/test_claude_notification_handler.py +0 -54
  258. package/hooks/notification/test_notification_utils.py +0 -91
  259. package/hooks/notification/test_subagent_complete_notify.py +0 -79
  260. package/scripts/config/groq_bugteam_config.py +0 -230
  261. package/scripts/config/test_groq_bugteam_config.py +0 -83
  262. package/scripts/config/test_spec_implementer_prompt.py +0 -32
  263. package/scripts/groq_bugteam.README.md +0 -131
  264. package/scripts/groq_bugteam.py +0 -647
  265. package/scripts/groq_bugteam_dotenv.py +0 -40
  266. package/scripts/groq_bugteam_spec.py +0 -226
  267. package/scripts/test_groq_bugteam.py +0 -529
  268. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
  269. package/scripts/test_groq_bugteam_dotenv.py +0 -66
  270. package/scripts/test_groq_bugteam_spec.py +0 -338
  271. package/skills/bugteam/SKILL_EVALS.md +0 -309
  272. package/skills/dream/SKILL.md +0 -118
  273. package/skills/ingest/SKILL.md +0 -40
  274. package/skills/npm-creator/SKILL.md +0 -187
  275. package/skills/readability-review/SKILL.md +0 -127
  276. package/skills/resume-review/SKILL.md +0 -261
  277. package/skills/rule-audit/SKILL.md +0 -307
  278. package/skills/rule-creator/SKILL.md +0 -150
  279. package/skills/searching-obsidian-vault/SKILL.md +0 -131
  280. package/skills/skill-writer/REFERENCE.md +0 -284
  281. package/skills/skill-writer/SKILL.md +0 -222
  282. package/skills/tdd-team/SKILL.md +0 -128
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block gh pr ready until convergence pre-conditions pass.
3
+
4
+ Runs check_convergence.py against the PR and denies the gh pr ready
5
+ call if any condition fails. The agent sees exactly which conditions
6
+ failed and can address them before retrying.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
18
+ direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
19
+ if direct_match:
20
+ return int(direct_match.group(1))
21
+ try:
22
+ completed_process = subprocess.run(
23
+ ["gh", "pr", "view", "--json", "number", "--jq", ".number"],
24
+ capture_output=True,
25
+ text=True,
26
+ cwd=cwd or None,
27
+ check=False,
28
+ )
29
+ except OSError:
30
+ return None
31
+ if completed_process.returncode != 0:
32
+ return None
33
+ try:
34
+ return int(completed_process.stdout.strip())
35
+ except (ValueError, TypeError):
36
+ return None
37
+
38
+
39
+ def _resolve_owner_repo(cwd: str | None) -> tuple[str, str] | None:
40
+ try:
41
+ completed_process = subprocess.run(
42
+ ["gh", "repo", "view", "--json", "owner,name", "--jq", ".owner.login,.name"],
43
+ capture_output=True,
44
+ text=True,
45
+ cwd=cwd or None,
46
+ check=False,
47
+ )
48
+ except OSError:
49
+ return None
50
+ if completed_process.returncode != 0:
51
+ return None
52
+ parts = completed_process.stdout.strip().splitlines()
53
+ if len(parts) <= 1:
54
+ match = re.match(
55
+ r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$",
56
+ completed_process.stdout.strip(),
57
+ )
58
+ if match:
59
+ return match.group(1), match.group(2)
60
+ return None
61
+ return parts[0], parts[1]
62
+
63
+
64
+ def main() -> None:
65
+ check_convergence_script = str(
66
+ Path.home() / ".claude/skills/pr-converge/scripts/check_convergence.py"
67
+ )
68
+
69
+ if not Path(check_convergence_script).is_file():
70
+ sys.exit(0)
71
+
72
+ try:
73
+ hook_input = json.load(sys.stdin)
74
+ except json.JSONDecodeError:
75
+ sys.exit(0)
76
+
77
+ tool_name = hook_input.get("tool_name", "")
78
+ if tool_name != "Bash":
79
+ sys.exit(0)
80
+
81
+ command = hook_input.get("tool_input", {}).get("command", "")
82
+ gh_pr_ready_pattern = re.compile(r"\bgh\s+pr\s+ready\b(?![^&|;\n]*--undo)")
83
+ if not gh_pr_ready_pattern.search(command):
84
+ sys.exit(0)
85
+
86
+ cwd = hook_input.get("tool_input", {}).get("cwd")
87
+ pr_number = _resolve_pr_number(command, cwd)
88
+ if pr_number is None:
89
+ sys.exit(0)
90
+
91
+ owner_repo = _resolve_owner_repo(cwd)
92
+ if owner_repo is None:
93
+ sys.exit(0)
94
+ owner, repo = owner_repo
95
+
96
+ completed_process = subprocess.run(
97
+ [
98
+ sys.executable,
99
+ check_convergence_script,
100
+ "--owner",
101
+ owner,
102
+ "--repo",
103
+ repo,
104
+ "--pr-number",
105
+ str(pr_number),
106
+ ],
107
+ capture_output=True,
108
+ text=True,
109
+ check=False,
110
+ )
111
+
112
+ if completed_process.returncode in (0, 2):
113
+ sys.exit(0)
114
+
115
+ deny_payload = {
116
+ "hookSpecificOutput": {
117
+ "hookEventName": "PreToolUse",
118
+ "permissionDecision": "deny",
119
+ "permissionDecisionReason": (
120
+ "Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
121
+ ),
122
+ }
123
+ }
124
+ print(json.dumps(deny_payload))
125
+ sys.stdout.flush()
126
+ sys.exit(0)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
@@ -9,6 +9,16 @@ import sys
9
9
  import tempfile
10
10
  from pathlib import Path
11
11
 
12
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
13
+ if _hooks_dir not in sys.path:
14
+ sys.path.insert(0, _hooks_dir)
15
+
16
+ from config.convergence_branch_constants import ( # noqa: E402
17
+ ALL_CONVERGENCE_BRANCH_PREFIXES,
18
+ CONVERGENCE_BRANCH_SUFFIX_PATTERN,
19
+ CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
20
+ )
21
+
12
22
  CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
13
23
  GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
14
24
  GH_REDIRECT_ACTIVE_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
@@ -86,6 +96,9 @@ DESTRUCTIVE_BASH_PATTERNS = [
86
96
  (re.compile(r'\bDROP\s+TABLE\b', re.IGNORECASE), "DROP TABLE (destroys database table)"),
87
97
  (re.compile(r'\bDROP\s+DATABASE\b', re.IGNORECASE), "DROP DATABASE (destroys entire database)"),
88
98
  (re.compile(r'\bTRUNCATE\s+TABLE\b', re.IGNORECASE), "TRUNCATE TABLE (removes all table rows)"),
99
+ (re.compile(r'\bgit\s+(?:[^\s]+\s+)*--no-verify\b', re.IGNORECASE), "git --no-verify (skips pre-commit / pre-push hooks; NON-NEGOTIABLE per git-workflow.md)"),
100
+ (re.compile(r'\bgit\s+(?:[^\s]+\s+)*--no-gpg-sign\b', re.IGNORECASE), "git --no-gpg-sign (bypasses commit signing; NON-NEGOTIABLE per git-workflow.md)"),
101
+ (re.compile(r"\bgit\s+-c\s+['\"]?commit\.gpgsign=['\"]?false['\"]?(?!\w)", re.IGNORECASE), "git -c commit.gpgsign=false (bypasses commit signing; NON-NEGOTIABLE per git-workflow.md)"),
89
102
  ]
90
103
 
91
104
  def find_destructive_pattern(command: str) -> str | None:
@@ -483,6 +496,52 @@ def _git_reset_hard_allowed_for_command(command: str, current_working_directory:
483
496
  return False
484
497
 
485
498
 
499
+ def _is_convergence_branch(branch: str) -> bool:
500
+ all_convergence_branch_prefixes = ALL_CONVERGENCE_BRANCH_PREFIXES
501
+ for each_prefix in all_convergence_branch_prefixes:
502
+ if branch.startswith(each_prefix):
503
+ return True
504
+ return bool(re.match(CONVERGENCE_BRANCH_SUFFIX_PATTERN, branch))
505
+
506
+
507
+ def _all_refspecs_are_convergence_branches(post_remote_text: str) -> bool:
508
+ if not post_remote_text.strip():
509
+ return False
510
+ is_any_refspec_checked = False
511
+ for each_token in post_remote_text.split():
512
+ if each_token.startswith("-"):
513
+ continue
514
+ is_any_refspec_checked = True
515
+ destination_branch = each_token.split(":")[-1]
516
+ if not _is_convergence_branch(destination_branch):
517
+ return False
518
+ return is_any_refspec_checked
519
+
520
+
521
+ def _force_push_targets_convergence_branch(command: str) -> bool:
522
+ convergence_force_push_detection_pattern = (
523
+ CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN
524
+ )
525
+ is_force_push_found = False
526
+ for each_match in re.finditer(
527
+ convergence_force_push_detection_pattern, command, re.IGNORECASE
528
+ ):
529
+ is_force_push_found = True
530
+ post_push_text = each_match.group(1).strip()
531
+ all_tokens = post_push_text.split()
532
+ remote_index = 1 if all_tokens and all_tokens[0] in ("--force", "-f") else 0
533
+ all_refspec_tokens = [
534
+ token for token in all_tokens[remote_index + 1 :]
535
+ if token not in ("--force", "-f")
536
+ ]
537
+ post_remote_text = " ".join(all_refspec_tokens)
538
+ if not post_remote_text:
539
+ return False
540
+ if not _all_refspecs_are_convergence_branches(post_remote_text):
541
+ return False
542
+ return is_force_push_found
543
+
544
+
486
545
  def main() -> None:
487
546
  try:
488
547
  hook_input = json.load(sys.stdin)
@@ -524,6 +583,21 @@ def main() -> None:
524
583
  if _git_reset_hard_allowed_for_command(command, os.getcwd()):
525
584
  sys.exit(0)
526
585
 
586
+ if (
587
+ matched_description is not None
588
+ and "git push" in matched_description
589
+ and ("force" in matched_description or "-f" in matched_description)
590
+ and _force_push_targets_convergence_branch(command)
591
+ ):
592
+ for each_pattern, each_description in DESTRUCTIVE_BASH_PATTERNS:
593
+ if "git push" in each_description and ("force" in each_description or "-f" in each_description):
594
+ continue
595
+ if each_pattern.search(command):
596
+ matched_description = each_description
597
+ break
598
+ else:
599
+ sys.exit(0)
600
+
527
601
  if matched_description is not None:
528
602
  ask_response = {
529
603
  "hookSpecificOutput": {
@@ -24,6 +24,7 @@ import re
24
24
  import sys
25
25
 
26
26
  from _gh_body_arg_utils import (
27
+ _is_bash_continuation,
27
28
  all_body_flags,
28
29
  all_body_flag_prefixes,
29
30
  get_logical_first_line,
@@ -69,6 +70,32 @@ def _logical_line_has_bare_body_token(logical_line: str) -> bool:
69
70
  return bool(_BARE_BODY_TOKEN_PATTERN.search(logical_line))
70
71
 
71
72
 
73
+ def _has_backtick(command: str) -> bool:
74
+ """Return True if command contains a backtick that is not a bash continuation.
75
+
76
+ Joins all bash `` \\ `` continuation lines so backticks in multi-line body
77
+ values on later non-continuation lines are not missed. Only strips bash
78
+ continuations — this hook runs on the Bash tool, and PowerShell-style
79
+ backtick continuations are not continuation markers in bash.
80
+
81
+ Scans the entire command string for backtick characters, not just --body
82
+ argument content. This is intentionally conservative — any command
83
+ containing backticks should use --body-file regardless of where they
84
+ appear. False positives (backticks in non-body flags) are safe
85
+ over-blocks.
86
+ """
87
+ continuation_separator = " "
88
+ all_joined_lines: list[str] = []
89
+ for each_line in command.splitlines():
90
+ stripped_line = each_line.rstrip()
91
+ if _is_bash_continuation(stripped_line):
92
+ all_joined_lines.append(stripped_line[:-1].rstrip() + continuation_separator)
93
+ continue
94
+ all_joined_lines.append(each_line)
95
+ full_logical_command = "".join(all_joined_lines)
96
+ return "`" in full_logical_command
97
+
98
+
72
99
  def _uses_body_string_arg(command: str) -> bool:
73
100
  """Return True if command calls an affected gh subcommand with --body <string>.
74
101
 
@@ -114,6 +141,9 @@ def main() -> None:
114
141
  if not _uses_body_string_arg(command):
115
142
  sys.exit(0)
116
143
 
144
+ if not _has_backtick(command):
145
+ sys.exit(0)
146
+
117
147
  deny_payload = {
118
148
  "hookSpecificOutput": {
119
149
  "hookEventName": "PreToolUse",
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: blocks Write/Edit targeting .md files, redirecting to .html.
3
+
4
+ HTML preserves spatial structure (diffs, timelines, comparisons, diagrams)
5
+ that markdown flattens. See https://thariqs.github.io/html-effectiveness/
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ from typing import TextIO
12
+
13
+
14
+ _markdown_extension = ".md"
15
+ _html_effectiveness_url = "https://thariqs.github.io/html-effectiveness/"
16
+ _exempt_root_filenames = ("readme.md", "changelog.md")
17
+
18
+
19
+ def _is_exempt_path(file_path: str) -> bool:
20
+ normalized = os.path.normpath(file_path).replace("\\", "/")
21
+ lower_normalized = normalized.lower()
22
+ if "/.claude/" in lower_normalized or lower_normalized.startswith(".claude/"):
23
+ return True
24
+ basename = os.path.basename(normalized)
25
+ if basename.lower() in _exempt_root_filenames:
26
+ directory = os.path.dirname(normalized)
27
+ if directory in ("", "."):
28
+ return True
29
+ return False
30
+
31
+
32
+ def _block_reason(file_path: str) -> str:
33
+ return (
34
+ f"BLOCKED: Write/Edit to .md file '{file_path}' is not permitted. "
35
+ "Use .html files instead for documentation. "
36
+ f"See {_html_effectiveness_url} for why HTML "
37
+ "is more effective than Markdown for structured information."
38
+ )
39
+
40
+
41
+ def _block_context() -> str:
42
+ return (
43
+ "Generate a self-contained .html file instead of .md. "
44
+ "Design freely — HTML can express spatial structure, interactivity, "
45
+ "and visual hierarchy that markdown cannot.\n\n"
46
+ "Reference for HTML effectiveness patterns:\n"
47
+ f"{_html_effectiveness_url}\n"
48
+ "Exceptions (.md still allowed):\n"
49
+ "- Files inside .claude/ directories\n"
50
+ "- README.md and CHANGELOG.md at repo root"
51
+ )
52
+
53
+
54
+ def _block_system_message() -> str:
55
+ return (
56
+ ".md files are blocked in this project — generate a self-contained .html "
57
+ f"file instead. See {_html_effectiveness_url} for "
58
+ "design patterns and examples. Exemptions: .claude/ infrastructure, "
59
+ "README.md, CHANGELOG.md at repo root."
60
+ )
61
+
62
+
63
+ def main() -> None:
64
+ """Read hook input JSON from stdin, deny .md writes or pass through silently.
65
+
66
+ Returns:
67
+ None (exits process).
68
+ """
69
+ try:
70
+ input_data = json.load(sys.stdin)
71
+ except json.JSONDecodeError:
72
+ sys.exit(0)
73
+
74
+ if not isinstance(input_data, dict):
75
+ sys.exit(0)
76
+
77
+ tool_name = input_data.get("tool_name", "")
78
+ if not isinstance(tool_name, str):
79
+ sys.exit(0)
80
+
81
+ tool_input = input_data.get("tool_input", {})
82
+ if not isinstance(tool_input, dict):
83
+ sys.exit(0)
84
+
85
+ if tool_name not in ("Write", "Edit"):
86
+ sys.exit(0)
87
+
88
+ file_path = tool_input.get("file_path", "")
89
+ if not file_path:
90
+ sys.exit(0)
91
+
92
+ if not file_path.lower().endswith(_markdown_extension):
93
+ sys.exit(0)
94
+
95
+ if _is_exempt_path(file_path):
96
+ sys.exit(0)
97
+
98
+ block_payload = {
99
+ "hookSpecificOutput": {
100
+ "hookEventName": "PreToolUse",
101
+ "permissionDecision": "deny",
102
+ "permissionDecisionReason": _block_reason(file_path),
103
+ "additionalContext": _block_context(),
104
+ },
105
+ "systemMessage": _block_system_message(),
106
+ "suppressOutput": True,
107
+ }
108
+
109
+ _emit_hook_result(block_payload, sys.stdout)
110
+ sys.exit(0)
111
+
112
+
113
+ def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
114
+ output_stream.write(json.dumps(all_hook_data) + "\n")
115
+ output_stream.flush()
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
@@ -20,16 +20,30 @@ from _gh_body_arg_utils import (
20
20
  iter_significant_tokens,
21
21
  )
22
22
 
23
- PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
24
- PR_GUIDE_PATH = os.path.join(PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
25
23
 
26
- REQUIRED_PR_SECTION_HEADERS = [
27
- "description",
28
- "why",
29
- "how",
30
- ]
24
+ def _insert_hooks_tree_for_imports() -> None:
25
+ hooks_tree = Path(__file__).resolve().parent.parent
26
+ hooks_tree_string = str(hooks_tree)
27
+ if hooks_tree_string not in sys.path:
28
+ sys.path.insert(0, hooks_tree_string)
29
+
30
+
31
+ _insert_hooks_tree_for_imports()
32
+
33
+ from config.pr_description_enforcer_constants import (
34
+ BLOCKQUOTE_MARKER_PATTERN,
35
+ BOLD_PAIR_PATTERN,
36
+ BULLET_MARKER_PATTERN,
37
+ FENCED_CODE_BLOCK_PATTERN,
38
+ HEADING_LINE_PATTERN,
39
+ INLINE_CODE_PATTERN,
40
+ LINK_TEXT_PATTERN,
41
+ MINIMUM_SUBSTANTIVE_PROSE_CHARS,
42
+ WHITESPACE_RUN_PATTERN,
43
+ )
31
44
 
32
- MINIMUM_PR_BODY_LENGTH = 50
45
+ PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
46
+ PR_GUIDE_PATH = os.path.join(PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
33
47
 
34
48
  VAGUE_LANGUAGE_PATTERN = re.compile(
35
49
  r'\b(fix(?:ed)? (?:bug|issue|it)|update(?:d)? code|minor changes|various (?:fixes|updates|improvements))\b',
@@ -269,23 +283,43 @@ def extract_body_from_command(
269
283
  return result
270
284
 
271
285
 
286
+ def _count_substantive_prose_chars(body: str) -> int:
287
+ """Return the count of prose characters after stripping Markdown ceremony.
288
+
289
+ Removes fenced code, inline code, heading lines, blockquote markers,
290
+ bullet list markers, bold/emphasis markers, and Markdown link targets.
291
+ Collapses internal whitespace so a body of only headers and bullets --
292
+ no real WHY paragraph -- registers as effectively empty.
293
+ """
294
+ body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub('', body)
295
+ body_without_inline_code = INLINE_CODE_PATTERN.sub('', body_without_fences)
296
+ body_without_blockquotes = BLOCKQUOTE_MARKER_PATTERN.sub('', body_without_inline_code)
297
+ body_without_headings = HEADING_LINE_PATTERN.sub('', body_without_blockquotes)
298
+ body_without_bullets = BULLET_MARKER_PATTERN.sub('', body_without_headings)
299
+ body_without_bold = BOLD_PAIR_PATTERN.sub(r'\1', body_without_bullets)
300
+ body_without_emphasis = body_without_bold.replace('*', '')
301
+ body_without_links = LINK_TEXT_PATTERN.sub(r'\1', body_without_emphasis)
302
+ body_collapsed = WHITESPACE_RUN_PATTERN.sub(' ', body_without_links).strip()
303
+ return len(body_collapsed)
304
+
305
+
272
306
  def validate_pr_body(body: str) -> list[str]:
273
- violations = []
274
- body_lower = body.lower()
307
+ """Audit a PR body for substantive-prose and vague-language violations.
275
308
 
276
- missing_required_sections = [
277
- header for header in REQUIRED_PR_SECTION_HEADERS
278
- if f"## {header}" not in body_lower and f"**{header}" not in body_lower
279
- ]
309
+ Args:
310
+ body: The PR body markdown text to audit.
280
311
 
281
- if missing_required_sections:
282
- formatted_sections = ", ".join(f"'{each_section.title()}'" for each_section in missing_required_sections)
283
- violations.append(f"Missing required section(s): {formatted_sections}")
312
+ Returns:
313
+ A list of human-readable violation messages. Empty when the body passes.
314
+ """
315
+ violations = []
284
316
 
285
- stripped_body = re.sub(r'#.*', '', body).strip()
286
- stripped_body = re.sub(r'\*\*.*?\*\*', '', stripped_body).strip()
287
- if len(stripped_body) < MINIMUM_PR_BODY_LENGTH:
288
- violations.append("PR body too short -- provide meaningful context for reviewers")
317
+ substantive_chars = _count_substantive_prose_chars(body)
318
+ if substantive_chars < MINIMUM_SUBSTANTIVE_PROSE_CHARS:
319
+ violations.append(
320
+ "PR body lacks substantive prose -- include a Why paragraph or "
321
+ "substantive explanation, not only headers and bullets"
322
+ )
289
323
 
290
324
  vague_matches = VAGUE_LANGUAGE_PATTERN.findall(body)
291
325
  if vague_matches:
@@ -329,7 +363,8 @@ def main() -> None:
329
363
  pr_guide_reference = f" @{PR_GUIDE_PATH}" if os.path.exists(PR_GUIDE_PATH) else ""
330
364
  denial_reason = (
331
365
  f"BLOCKED: [PR_DESCRIPTION] {violation_list}. "
332
- f"Follow the PR description guide:{pr_guide_reference}"
366
+ f"Use the pr-description-writer agent to author the body in Anthropic claude-code style. "
367
+ f"Guide:{pr_guide_reference}"
333
368
  )
334
369
  result = {
335
370
  "hookSpecificOutput": {
@@ -0,0 +1,131 @@
1
+ """Unit tests for bot-mention-comment-blocker PreToolUse hook."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import pathlib
7
+ import sys
8
+
9
+ _HOOK_DIR = pathlib.Path(__file__).parent
10
+ if str(_HOOK_DIR) not in sys.path:
11
+ sys.path.insert(0, str(_HOOK_DIR))
12
+
13
+ hook_spec = importlib.util.spec_from_file_location(
14
+ "bot_mention_comment_blocker",
15
+ _HOOK_DIR / "bot_mention_comment_blocker.py",
16
+ )
17
+ assert hook_spec is not None
18
+ assert hook_spec.loader is not None
19
+ hook_module = importlib.util.module_from_spec(hook_spec)
20
+ hook_spec.loader.exec_module(hook_module)
21
+
22
+ _detect_bot_mention = hook_module._detect_bot_mention
23
+ _body_contains_token = hook_module._body_contains_token
24
+
25
+ from config.bot_mention_comment_blocker_constants import (
26
+ CORRECTIVE_MESSAGE_COPILOT,
27
+ CORRECTIVE_MESSAGE_CURSOR,
28
+ CURSOR_MENTION_TOKEN,
29
+ )
30
+
31
+
32
+ def test_passes_clean_body() -> None:
33
+ assert _detect_bot_mention("bugbot run") is None
34
+
35
+
36
+ def test_passes_empty_body() -> None:
37
+ assert _detect_bot_mention("") is None
38
+
39
+
40
+ def test_passes_unrelated_body() -> None:
41
+ assert _detect_bot_mention("please review this PR") is None
42
+
43
+
44
+ def test_blocks_cursor_mention() -> None:
45
+ reason = _detect_bot_mention("@cursor bugbot run")
46
+ assert reason is not None
47
+ assert "bugbot run" in reason
48
+
49
+
50
+ def test_blocks_cursor_bracket_mention() -> None:
51
+ reason = _detect_bot_mention("@cursor[bot] bugbot run")
52
+ assert reason is not None
53
+ assert "bugbot run" in reason
54
+
55
+
56
+ def test_blocks_copilot_mention() -> None:
57
+ reason = _detect_bot_mention("@copilot review this")
58
+ assert reason is not None
59
+ assert "copilot-pull-request-reviewer" in reason
60
+
61
+
62
+ def test_returns_cursor_message_for_cursor() -> None:
63
+ assert _detect_bot_mention("@cursor run") == CORRECTIVE_MESSAGE_CURSOR
64
+
65
+
66
+ def test_returns_copilot_message_for_copilot() -> None:
67
+ assert _detect_bot_mention("@copilot help") == CORRECTIVE_MESSAGE_COPILOT
68
+
69
+
70
+ def test_copilot_wins_when_both_present() -> None:
71
+ assert _detect_bot_mention("@cursor and @copilot") == CORRECTIVE_MESSAGE_COPILOT
72
+
73
+
74
+ def test_body_contains_token_case_insensitive() -> None:
75
+ assert _body_contains_token("Hello @CURSOR world", "@cursor")
76
+ assert _body_contains_token("Hello @CoPilot world", "@copilot")
77
+
78
+
79
+ def test_body_contains_token_no_at_sign() -> None:
80
+ assert not _body_contains_token("cursor without at-sign", CURSOR_MENTION_TOKEN)
81
+
82
+
83
+ from unittest import mock
84
+
85
+
86
+ def _run_main_with_io(input_text: str) -> str:
87
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
88
+ with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
89
+ try:
90
+ hook_module.main()
91
+ except SystemExit:
92
+ pass
93
+ return mock_stdout.getvalue()
94
+
95
+
96
+ def test_main_blocks_matching_cursor_comment() -> None:
97
+ hook_input = {
98
+ "tool_name": "mcp__plugin_github_github__add_issue_comment",
99
+ "tool_input": {"body": "@cursor bugbot run"},
100
+ }
101
+ output_text = _run_main_with_io(json.dumps(hook_input))
102
+ output = json.loads(output_text)
103
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
104
+
105
+
106
+ def test_main_passes_wrong_tool_name() -> None:
107
+ hook_input = {
108
+ "tool_name": "some_other_tool",
109
+ "tool_input": {"body": "@cursor bugbot run"},
110
+ }
111
+ assert _run_main_with_io(json.dumps(hook_input)) == ""
112
+
113
+
114
+ def test_main_passes_empty_body() -> None:
115
+ hook_input = {
116
+ "tool_name": "mcp__plugin_github_github__add_issue_comment",
117
+ "tool_input": {"body": ""},
118
+ }
119
+ assert _run_main_with_io(json.dumps(hook_input)) == ""
120
+
121
+
122
+ def test_main_passes_non_matching_body() -> None:
123
+ hook_input = {
124
+ "tool_name": "mcp__plugin_github_github__add_issue_comment",
125
+ "tool_input": {"body": "please review this PR"},
126
+ }
127
+ assert _run_main_with_io(json.dumps(hook_input)) == ""
128
+
129
+
130
+ def test_main_passes_malformed_json() -> None:
131
+ assert _run_main_with_io("not valid json {{{") == ""
@@ -1192,3 +1192,24 @@ def test_sys_path_insert_should_skip_hook_infrastructure_files() -> None:
1192
1192
  assert issues == [], (
1193
1193
  f"Hook infrastructure files are exempt from this rule, got: {issues}"
1194
1194
  )
1195
+
1196
+
1197
+ def test_validate_content_honors_empty_full_file_content_for_thin_wrapper_check() -> None:
1198
+ """An empty `full_file_content` must not be silently replaced with the pre-edit fragment.
1199
+
1200
+ Regression for loop1-8: the `or` short-circuit at the thin-wrapper call
1201
+ site treated `""` identically to `None`, so an Edit collapsing a file to
1202
+ empty was scanned against the pre-edit fragment instead of the empty
1203
+ post-edit content. Mirror the canonical idiom at line 3438.
1204
+ """
1205
+ pre_edit_fragment_with_imports_only = (
1206
+ "from real_module import do_thing\n__all__ = ['do_thing']\n"
1207
+ )
1208
+ issues = code_rules_enforcer.validate_content(
1209
+ pre_edit_fragment_with_imports_only,
1210
+ "/project/src/aliases.py",
1211
+ full_file_content="",
1212
+ )
1213
+ assert not any("thin wrapper" in each.lower() for each in issues), (
1214
+ f"empty post-edit file must not be flagged as a thin wrapper, got: {issues!r}"
1215
+ )