claude-dev-env 1.83.0 → 1.85.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 (186) hide show
  1. package/_shared/pr-loop/audit-contract.md +24 -12
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +8 -0
  6. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  7. package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
  8. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  9. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  13. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
  14. package/agents/clean-coder.md +3 -0
  15. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  16. package/bin/install.mjs +16 -8
  17. package/bin/install.test.mjs +17 -6
  18. package/commands/CLAUDE.md +0 -1
  19. package/docs/CODE_RULES.md +1 -1
  20. package/hooks/blocking/CLAUDE.md +0 -4
  21. package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
  22. package/hooks/blocking/code_rules_constants_config.py +5 -0
  23. package/hooks/blocking/code_rules_docstrings.py +133 -19
  24. package/hooks/blocking/code_rules_enforcer.py +4 -0
  25. package/hooks/blocking/code_rules_imports_logging.py +10 -6
  26. package/hooks/blocking/code_rules_magic_values.py +5 -0
  27. package/hooks/blocking/code_rules_naming_collection.py +15 -8
  28. package/hooks/blocking/code_rules_paired_test.py +3 -2
  29. package/hooks/blocking/code_rules_shared.py +34 -0
  30. package/hooks/blocking/code_rules_string_magic.py +1 -1
  31. package/hooks/blocking/code_rules_unused_imports.py +2 -2
  32. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  33. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  36. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
  37. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  38. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  39. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  40. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  41. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  42. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  43. package/hooks/hooks_constants/CLAUDE.md +0 -3
  44. package/hooks/hooks_constants/blocking_check_limits.py +4 -0
  45. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  46. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  47. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  48. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  49. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  50. package/hooks/workflow/CLAUDE.md +2 -8
  51. package/package.json +1 -1
  52. package/rules/CLAUDE.md +1 -1
  53. package/rules/docstring-prose-matches-implementation.md +1 -1
  54. package/rules/env-var-table-code-drift.md +1 -1
  55. package/rules/package-inventory-stale-entry.md +1 -1
  56. package/rules/paired-test-coverage.md +1 -1
  57. package/rules/plain-illustrative-docstrings.md +35 -1
  58. package/skills/CLAUDE.md +6 -1
  59. package/skills/_shared/pr-loop/CLAUDE.md +1 -0
  60. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  61. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  62. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
  63. package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
  64. package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
  65. package/skills/autoconverge/SKILL.md +82 -58
  66. package/skills/autoconverge/reference/closing-report.md +6 -6
  67. package/skills/autoconverge/reference/convergence.md +17 -15
  68. package/skills/autoconverge/reference/gotchas.md +6 -3
  69. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  70. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  71. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  72. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  73. package/skills/autoconverge/workflow/converge.mjs +71 -74
  74. package/skills/autoconverge/workflow/render_report.py +7 -11
  75. package/skills/bugteam/CLAUDE.md +1 -1
  76. package/skills/bugteam/PROMPTS.md +7 -6
  77. package/skills/bugteam/SKILL.md +25 -72
  78. package/skills/bugteam/reference/CLAUDE.md +1 -3
  79. package/skills/bugteam/reference/README.md +1 -1
  80. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  81. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  82. package/skills/bugteam/reference/team-setup.md +8 -7
  83. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  84. package/skills/bugteam/scripts/README.md +0 -4
  85. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  86. package/skills/code/SKILL.md +2 -0
  87. package/skills/copilot-review/CLAUDE.md +1 -1
  88. package/skills/copilot-review/SKILL.md +25 -23
  89. package/skills/findbugs/CLAUDE.md +2 -2
  90. package/skills/findbugs/SKILL.md +22 -83
  91. package/skills/fixbugs/SKILL.md +2 -4
  92. package/skills/log-audit/CLAUDE.md +20 -0
  93. package/skills/log-audit/SKILL.md +68 -0
  94. package/skills/log-audit/reference/CLAUDE.md +9 -0
  95. package/skills/log-audit/reference/charter.md +52 -0
  96. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  97. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  98. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  99. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  100. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  101. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  102. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  103. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  104. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  105. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  106. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  107. package/skills/monitor-open-prs/SKILL.md +2 -2
  108. package/skills/post-audit-findings/SKILL.md +84 -0
  109. package/skills/pr-converge/CLAUDE.md +2 -0
  110. package/skills/pr-converge/SKILL.md +99 -60
  111. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  112. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  113. package/skills/pr-converge/reference/examples.md +5 -5
  114. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  115. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  116. package/skills/pr-converge/reference/per-tick.md +24 -45
  117. package/skills/pr-converge/reference/state-schema.md +27 -0
  118. package/skills/pr-converge/scripts/README.md +3 -5
  119. package/skills/pr-fix-protocol/SKILL.md +70 -0
  120. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  121. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  122. package/skills/pr-scope-resolve/SKILL.md +48 -0
  123. package/skills/qbug/CLAUDE.md +4 -4
  124. package/skills/qbug/SKILL.md +46 -144
  125. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  126. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  127. package/skills/reviewer-gates/SKILL.md +96 -0
  128. package/skills/session-log/CLAUDE.md +7 -7
  129. package/skills/session-log/SKILL.md +27 -44
  130. package/skills/test_markdown_link_integrity.py +103 -0
  131. package/commands/doc-gist.md +0 -16
  132. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  133. package/hooks/blocking/conftest.py +0 -30
  134. package/hooks/blocking/md_path_exemptions.py +0 -224
  135. package/hooks/blocking/md_to_html_blocker.py +0 -155
  136. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  137. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  138. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  139. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  140. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  141. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  142. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  143. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  144. package/hooks/workflow/md_to_html_companion.py +0 -358
  145. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  146. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  147. package/skills/bugteam/reference/audit-contract.md +0 -163
  148. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  149. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  150. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  151. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  152. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  153. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  154. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  155. package/skills/doc-gist/CLAUDE.md +0 -25
  156. package/skills/doc-gist/SKILL.md +0 -97
  157. package/skills/doc-gist/references/CLAUDE.md +0 -9
  158. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  159. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  160. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  161. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  162. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  163. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  164. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  165. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  166. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  167. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  168. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  169. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  170. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  171. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  172. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  173. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  174. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  175. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  176. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  177. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  178. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  179. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  180. package/skills/doc-gist/references/examples/README.md +0 -5
  181. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  182. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  183. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  184. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  185. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  186. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -160,6 +160,24 @@ def test_path_contains_glob_metacharacters_local_tuple_uses_all_collection_prefi
160
160
  )
161
161
 
162
162
 
163
+ def test_is_valid_project_root_detects_git_marker(tmp_path: Path) -> None:
164
+ git_project_root = tmp_path / "git_project"
165
+ (git_project_root / ".git").mkdir(parents=True)
166
+ assert common.is_valid_project_root(git_project_root) is True
167
+
168
+
169
+ def test_is_valid_project_root_detects_claude_marker(tmp_path: Path) -> None:
170
+ claude_project_root = tmp_path / "claude_project"
171
+ (claude_project_root / ".claude").mkdir(parents=True)
172
+ assert common.is_valid_project_root(claude_project_root) is True
173
+
174
+
175
+ def test_is_valid_project_root_rejects_unmarked_directory(tmp_path: Path) -> None:
176
+ unmarked_directory = tmp_path / "no_marker"
177
+ unmarked_directory.mkdir()
178
+ assert common.is_valid_project_root(unmarked_directory) is False
179
+
180
+
163
181
  def test_is_valid_project_root_uses_extracted_directory_marker_constants() -> None:
164
182
  """is_valid_project_root must reference extracted constants, not inline string literals."""
165
183
  source_text = inspect.getsource(common.is_valid_project_root)
@@ -9,6 +9,7 @@ Covers:
9
9
 
10
10
  import importlib.util
11
11
  import inspect
12
+ import os
12
13
  import subprocess
13
14
  import sys
14
15
  import unittest.mock
@@ -93,6 +94,13 @@ def stage_file(repository_root: Path, relative_path: str) -> None:
93
94
  run_git_in_repository(repository_root, "add", "--", relative_path)
94
95
 
95
96
 
97
+ @pytest.fixture(autouse=True)
98
+ def isolated_git_environment(monkeypatch: pytest.MonkeyPatch) -> None:
99
+ for each_variable_name in list(os.environ):
100
+ if each_variable_name.startswith("GIT_"):
101
+ monkeypatch.delenv(each_variable_name, raising=False)
102
+
103
+
96
104
  @pytest.fixture()
97
105
  def temporary_git_repository(tmp_path: Path) -> Path:
98
106
  repository_root = tmp_path / "repository_under_test"
@@ -320,6 +328,35 @@ def test_main_staged_mode_exits_zero_when_nothing_staged(
320
328
  assert exit_code == 0
321
329
 
322
330
 
331
+ def test_main_staged_mode_passes_when_only_terminology_findings_exist(
332
+ temporary_git_repository: Path,
333
+ monkeypatch: pytest.MonkeyPatch,
334
+ ) -> None:
335
+ write_file(temporary_git_repository / "quota.py", "first_count = 1\n")
336
+ commit_all_files(temporary_git_repository, "initial")
337
+ write_file(
338
+ temporary_git_repository / "quota.py",
339
+ "first_count = 1\npremium_interactions = 1\n",
340
+ )
341
+ write_file(
342
+ temporary_git_repository / "README.md",
343
+ "The premium-request budget gates the run.\n",
344
+ )
345
+ stage_file(temporary_git_repository, "quota.py")
346
+ stage_file(temporary_git_repository, "README.md")
347
+
348
+ monkeypatch.chdir(temporary_git_repository)
349
+ terminology_findings = gate_module.staged_terminology_findings(
350
+ temporary_git_repository
351
+ )
352
+ exit_code = gate_module.main(["--staged"])
353
+
354
+ assert any(
355
+ "premium-request" in each_finding for each_finding in terminology_findings
356
+ )
357
+ assert exit_code == 0
358
+
359
+
323
360
  def test_added_lines_for_staged_file_returns_empty_for_modified_file_with_no_additions(
324
361
  temporary_git_repository: Path,
325
362
  ) -> None:
@@ -120,3 +120,17 @@ def test_cli_main_supports_bugteam_reviewer(
120
120
  ) -> None:
121
121
  monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
122
122
  assert reviews_disabled.main(["--reviewer", "bugteam"]) == 0
123
+
124
+
125
+ def test_cli_main_supports_copilot_reviewer(
126
+ monkeypatch: pytest.MonkeyPatch,
127
+ ) -> None:
128
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
129
+ assert reviews_disabled.main(["--reviewer", "copilot"]) == 0
130
+
131
+
132
+ def test_cli_main_returns_one_for_copilot_when_only_bugbot_disabled(
133
+ monkeypatch: pytest.MonkeyPatch,
134
+ ) -> None:
135
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
136
+ assert reviews_disabled.main(["--reviewer", "copilot"]) == 1
@@ -23,13 +23,22 @@ main = sweep_module.main
23
23
  parse_added_lines = sweep_module._parse_added_lines
24
24
 
25
25
 
26
+ _git_environment = sweep_module._git_environment
27
+
28
+
26
29
  def _init_git_repository(repository_path: Path) -> None:
27
30
  for each_command in (
28
31
  ["git", "init"],
29
32
  ["git", "config", "user.email", "test@example.com"],
30
33
  ["git", "config", "user.name", "Test"],
31
34
  ):
32
- subprocess.run(each_command, cwd=repository_path, check=True, capture_output=True)
35
+ subprocess.run(
36
+ each_command,
37
+ cwd=repository_path,
38
+ check=True,
39
+ capture_output=True,
40
+ env=_git_environment(),
41
+ )
33
42
 
34
43
 
35
44
  CODE_AND_PROSE_DIFF = (
@@ -220,17 +229,86 @@ def test_does_not_flag_ordinary_sentence_sharing_loop_variable_prefix() -> None:
220
229
 
221
230
 
222
231
  def test_flags_near_miss_inside_code_comment() -> None:
232
+ diff = (
233
+ "diff --git a/api/quota.mjs b/api/quota.mjs\n"
234
+ "--- a/api/quota.mjs\n"
235
+ "+++ b/api/quota.mjs\n"
236
+ "@@ -0,0 +1,2 @@\n"
237
+ "+const premium_interactions = 5\n"
238
+ "+// the premium-request path resets the counter\n"
239
+ )
240
+ findings = sweep_diff(diff)
241
+ assert len(findings) == 1
242
+ assert "premium-request" in findings[0]
243
+
244
+
245
+ def test_flags_near_miss_inside_code_string_literal() -> None:
246
+ diff = (
247
+ "diff --git a/api/quota.mjs b/api/quota.mjs\n"
248
+ "--- a/api/quota.mjs\n"
249
+ "+++ b/api/quota.mjs\n"
250
+ "@@ -0,0 +1,2 @@\n"
251
+ "+const premium_interactions = 5\n"
252
+ '+throw new Error("the premium-request budget")\n'
253
+ )
254
+ findings = sweep_diff(diff)
255
+ assert len(findings) == 1
256
+ assert "premium-request" in findings[0]
257
+
258
+
259
+ def test_does_not_flag_prose_inside_code_string_literal() -> None:
260
+ diff = (
261
+ "diff --git a/api/quota.py b/api/quota.py\n"
262
+ "--- a/api/quota.py\n"
263
+ "+++ b/api/quota.py\n"
264
+ "@@ -0,0 +1,2 @@\n"
265
+ "+premium_interactions = 5\n"
266
+ '+message = "the premium-request budget"\n'
267
+ )
268
+ assert sweep_diff(diff) == []
269
+
270
+
271
+ def test_does_not_flag_prose_inside_code_docstring() -> None:
223
272
  diff = (
224
273
  "diff --git a/api/quota.py b/api/quota.py\n"
225
274
  "--- a/api/quota.py\n"
226
275
  "+++ b/api/quota.py\n"
227
276
  "@@ -0,0 +1,2 @@\n"
228
277
  "+premium_interactions = 5\n"
229
- "+# the premium-request path resets the counter\n"
278
+ '+ """Gate the run on the premium-request budget."""\n'
279
+ )
280
+ assert sweep_diff(diff) == []
281
+
282
+
283
+ def test_does_not_flag_prose_in_test_file() -> None:
284
+ diff = (
285
+ "diff --git a/api/quota.test.mjs b/api/quota.test.mjs\n"
286
+ "--- a/api/quota.test.mjs\n"
287
+ "+++ b/api/quota.test.mjs\n"
288
+ "@@ -0,0 +1,2 @@\n"
289
+ "+const premium_interactions = 5\n"
290
+ "+// the premium-request path resets the counter\n"
291
+ )
292
+ assert sweep_diff(diff) == []
293
+
294
+
295
+ def test_flags_prose_in_test_named_markdown_doc() -> None:
296
+ diff = (
297
+ "diff --git a/api/quota.py b/api/quota.py\n"
298
+ "--- a/api/quota.py\n"
299
+ "+++ b/api/quota.py\n"
300
+ "@@ -0,0 +1,1 @@\n"
301
+ "+premium_interactions = 5\n"
302
+ "diff --git a/docs/test_plan.md b/docs/test_plan.md\n"
303
+ "--- a/docs/test_plan.md\n"
304
+ "+++ b/docs/test_plan.md\n"
305
+ "@@ -0,0 +1,1 @@\n"
306
+ "+The premium-request budget gates the run.\n"
230
307
  )
231
308
  findings = sweep_diff(diff)
232
309
  assert len(findings) == 1
233
310
  assert "premium-request" in findings[0]
311
+ assert "test_plan.md" in findings[0]
234
312
 
235
313
 
236
314
  def test_no_findings_when_no_multiword_identifier_introduced() -> None:
@@ -273,7 +351,13 @@ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
273
351
  (tmp_path / "README.md").write_text(
274
352
  "The premium-request budget gates the run.\n", encoding="utf-8"
275
353
  )
276
- subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True, capture_output=True)
354
+ subprocess.run(
355
+ ["git", "add", "-A"],
356
+ cwd=tmp_path,
357
+ check=True,
358
+ capture_output=True,
359
+ env=_git_environment(),
360
+ )
277
361
  findings = staged_terminology_findings(tmp_path)
278
362
  assert any("premium-request" in each_finding for each_finding in findings)
279
363
 
@@ -281,7 +365,13 @@ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
281
365
  def test_staged_terminology_findings_empty_when_clean(tmp_path: Path) -> None:
282
366
  _init_git_repository(tmp_path)
283
367
  (tmp_path / "README.md").write_text("Nothing notable here.\n", encoding="utf-8")
284
- subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True, capture_output=True)
368
+ subprocess.run(
369
+ ["git", "add", "-A"],
370
+ cwd=tmp_path,
371
+ check=True,
372
+ capture_output=True,
373
+ env=_git_environment(),
374
+ )
285
375
  assert staged_terminology_findings(tmp_path) == []
286
376
 
287
377
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: clean-coder
3
3
  description: "Use PROACTIVELY for ALL code generation — feature development, bug fixes, refactoring, hook creation, automation scripts, and any task that produces code. Internalizes CODE_RULES.md and the 8-dimension readability standard so thoroughly that /check finds zero issues. The definitive code-writing agent."
4
+ model: opus
4
5
  tools: Read, Write, Edit, Bash, Grep, Glob, Task, Skill
5
6
  color: green
6
7
  ---
@@ -436,6 +437,8 @@ This default is overridden by explicit user instruction such as "refactor this e
436
437
 
437
438
  Docstrings on functions, methods, classes, and modules are encouraged for public APIs. The self-documenting-names gate inspects inline `#` and block `#` comments only; docstrings are exempt from that gate.
438
439
 
440
+ Write a behavior-illustrating docstring in the diagram-first shape: one summary line, then a `::` literal block or a doctest that shows a concrete input and its marked outcome (with `ok:` / `flag:` contrast lines where a pass-and-fail pair helps), then a couple of short neutral prose lines, then the `Args:` / `Returns:` sections. Two hooks back this: `check_docstring_runon_sentence` flags a run-on prose wall, and `check_docstring_prose_wall_without_illustration` flags a narrative that runs long with no diagram. See `../rules/plain-illustrative-docstrings.md` for the shape and a worked example.
441
+
439
442
  ## Audit Awareness
440
443
 
441
444
  Code clean-coder writes will be audited later against the A–P bug categories from `code-quality-agent`. The hooks listed in this file enforce the Category J slice at write time, but A–I and K–P surface only in audit. For each category's full rubric, sub-bucket decomposition, and concrete checks, see `../audit-rubrics/category_rubrics/` (relative to this agent file). While generating code, anticipate the full A–P surface so the first write clears every audit category.
@@ -28,7 +28,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
28
28
  | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. The exception-guard shape of this drift — a docstring that promises a malformed payload `resolves to None` while a payload subscript (`payload["key"]`, `float(payload["key"])`) sits outside the try/except whose handler returns None, so a present-but-malformed payload raises rather than resolving to None — is gated deterministically at Write/Edit time by `check_docstring_unguarded_malformed_payload_claim`, so the audit lane focuses on the wider Raises/None-on-failure claims the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
29
29
  | O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
30
30
  | O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
31
- | O9 | Python docstring plainness for a general developer | A changed module / class / public-function docstring's narrative prose — the summary and description before the first `Args:` / `Returns:` / `Raises:` / `Yields:` section — reads plainly and paints a concrete scene a general developer follows on first read. Flag a narrative that stacks abstract machinery nouns into a wall (`the SIGINT install/restore/installability check, the atexit terminal-record registration, and the interrupted-run finalizer`), that defines a thing by what it is not (`the non-promoter-specific machinery`), or that runs one sentence long while joining clauses with an em-dash or a semicolon. The deterministic run-on mark is gated at Write/Edit time by `check_docstring_runon_sentence` in `code_rules_docstrings.py`, so this lane carries the illustrative-and-brief judgment the gate cannot: whether a stranger to the code pictures the moment, the input, and the outcome after one read. See `../../rules/plain-illustrative-docstrings.md`. |
31
+ | O9 | Python docstring plainness for a general developer | A changed module / class / public-function docstring's narrative prose — the summary and description before the first `Args:` / `Returns:` / `Raises:` / `Yields:` section — reads plainly and paints a concrete scene a general developer follows on first read. Flag a narrative that stacks abstract machinery nouns into a wall (`the SIGINT install/restore/installability check, the atexit terminal-record registration, and the interrupted-run finalizer`), that defines a thing by what it is not (`the non-promoter-specific machinery`), or that runs one sentence long while joining clauses with an em-dash or a semicolon. The diagram-first shape carries this best: a summary line, then a `::` example block or a doctest that shows a concrete input and its marked outcome, then a couple of short prose lines. The deterministic run-on mark is gated at Write/Edit time by `check_docstring_runon_sentence` in `code_rules_docstrings.py`, and a narrative that runs more than six prose lines with no such block is gated by `check_docstring_prose_wall_without_illustration` in the same module, so this lane carries the judgment the gates cannot: whether a stranger to the code pictures the moment, the input, and the outcome after one read, and whether the diagram truly illustrates — a real input, a marked outcome, an `ok:` / `flag:` contrast a reader learns from. See `../../rules/plain-illustrative-docstrings.md`. |
32
32
 
33
33
  ---
34
34
 
package/bin/install.mjs CHANGED
@@ -294,10 +294,13 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
294
294
  }
295
295
 
296
296
  /**
297
- * Hook script paths that were folded into the PreToolUse dispatcher in Stage 1.
298
- * These entries no longer appear in hooks.json but must still be recognized as
299
- * managed so a reinstall from an older settings shape prunes them and they do
300
- * not double-run alongside the dispatcher.
297
+ * PreToolUse hook script paths the installer manages even though hooks.json
298
+ * carries no standalone entry for them. Most were folded into the PreToolUse
299
+ * dispatcher in Stage 1; md_to_html_blocker is a retired hook with no script on
300
+ * disk. Each path stays in this set so a reinstall from an older settings shape
301
+ * prunes its standalone entry — a folded hook would otherwise double-run
302
+ * alongside the dispatcher, and the retired hook's entry would point at a
303
+ * missing script.
301
304
  */
302
305
  export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
303
306
  'blocking/write_existing_file_blocker.py',
@@ -316,18 +319,23 @@ export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
316
319
  'blocking/pytest_testpaths_orphan_blocker.py',
317
320
  'blocking/open_questions_in_plans_blocker.py',
318
321
  'blocking/plain_language_blocker.py',
322
+ 'blocking/md_to_html_blocker.py',
319
323
  ]);
320
324
 
321
325
  /**
322
- * Hook script paths that were folded into the PostToolUse dispatcher. These
323
- * after-write hooks no longer appear in hooks.json but must still be recognized
324
- * as managed so a reinstall from an older settings shape prunes them and they do
325
- * not double-run alongside the PostToolUse dispatcher.
326
+ * After-write hook script paths the installer manages even though hooks.json
327
+ * carries no standalone entry for them. mypy_validator and auto_formatter run
328
+ * hosted inside the PostToolUse dispatcher; doc_gist_auto_publish and
329
+ * md_to_html_companion are retired hooks with no script on disk. Each path stays
330
+ * in this set so a reinstall from an older settings shape prunes its standalone
331
+ * entry — a hosted hook would otherwise double-run alongside the dispatcher, and
332
+ * a retired hook's entry would point at a missing script.
326
333
  */
327
334
  export const POST_FOLDED_HOOK_RELATIVE_PATHS = new Set([
328
335
  'validation/mypy_validator.py',
329
336
  'workflow/auto_formatter.py',
330
337
  'workflow/doc_gist_auto_publish.py',
338
+ 'workflow/md_to_html_companion.py',
331
339
  ]);
332
340
 
333
341
  /**
@@ -718,12 +718,13 @@ const OLD_FOLDED_HOOKS_SETTINGS = {
718
718
  };
719
719
 
720
720
 
721
- test('FOLDED_HOOK_RELATIVE_PATHS contains all 16 hooks removed from hooks.json', () => {
722
- assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 16);
721
+ test('FOLDED_HOOK_RELATIVE_PATHS contains all 16 hooks removed from hooks.json plus the retired md_to_html_blocker', () => {
722
+ assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 17);
723
723
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
724
724
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/plain_language_blocker.py'));
725
725
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
726
726
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
727
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/md_to_html_blocker.py'));
727
728
  });
728
729
 
729
730
 
@@ -746,16 +747,25 @@ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher host
746
747
  'blocking/open_questions_in_plans_blocker.py',
747
748
  'blocking/plain_language_blocker.py',
748
749
  ];
750
+ const retiredHooks = [
751
+ 'blocking/md_to_html_blocker.py',
752
+ ];
749
753
  for (const hostedPath of dispatcherHostedHooks) {
750
754
  assert.ok(
751
755
  FOLDED_HOOK_RELATIVE_PATHS.has(hostedPath),
752
756
  `dispatcher-hosted hook ${hostedPath} must be in FOLDED_HOOK_RELATIVE_PATHS so a reinstall prunes its stale standalone entry and it does not double-run`
753
757
  );
754
758
  }
759
+ for (const retiredPath of retiredHooks) {
760
+ assert.ok(
761
+ FOLDED_HOOK_RELATIVE_PATHS.has(retiredPath),
762
+ `retired hook ${retiredPath} must be in FOLDED_HOOK_RELATIVE_PATHS so a reinstall prunes its stale standalone entry pointing at a script no longer on disk`
763
+ );
764
+ }
755
765
  assert.equal(
756
766
  FOLDED_HOOK_RELATIVE_PATHS.size,
757
- dispatcherHostedHooks.length,
758
- 'FOLDED_HOOK_RELATIVE_PATHS must hold exactly the dispatcher-hosted hooks, no more, no fewer'
767
+ dispatcherHostedHooks.length + retiredHooks.length,
768
+ 'FOLDED_HOOK_RELATIVE_PATHS must hold exactly the dispatcher-hosted hooks plus the retired hooks, no more, no fewer'
759
769
  );
760
770
  });
761
771
 
@@ -887,11 +897,12 @@ const OLD_POST_FOLDED_HOOKS_SETTINGS = {
887
897
  };
888
898
 
889
899
 
890
- test('POST_FOLDED_HOOK_RELATIVE_PATHS contains the three after-write hooks folded into the PostToolUse dispatcher', () => {
891
- assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 3);
900
+ test('POST_FOLDED_HOOK_RELATIVE_PATHS contains the after-write hooks and the retired md_to_html_companion', () => {
901
+ assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 4);
892
902
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('validation/mypy_validator.py'));
893
903
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/auto_formatter.py'));
894
904
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/doc_gist_auto_publish.py'));
905
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/md_to_html_companion.py'));
895
906
  });
896
907
 
897
908
 
@@ -7,7 +7,6 @@ Slash-command definitions installed into `~/.claude/commands/` by `bin/install.m
7
7
  | File | Command | What it does |
8
8
  |---|---|---|
9
9
  | `commit.md` | `/commit` | Commits and pushes changes to GitHub |
10
- | `doc-gist.md` | `/doc-gist` | Uploads an HTML file as a secret gist and opens the htmlpreview URL |
11
10
  | `docupdate.md` | `/docupdate` | Updates documentation to match current code state |
12
11
  | `hook-log-extract.md` | `/hook-log-extract` | Extracts and formats hook log entries for a session |
13
12
  | `hook-log-init.md` | `/hook-log-init` | Initializes the Neon Postgres schema that backs the hook-log extractor (one-time per machine) |
@@ -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.
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.
@@ -77,7 +77,6 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
77
77
  | `hedging_language_blocker.py` | Stop | Responses with hedging words (`likely`, `probably`, `appears to`) |
78
78
  | `hook_prose_detector_consistency.py` | PreToolUse (Write/Edit) | Hook docstrings/messages that claim a trigger the detector cannot fire on |
79
79
  | `intent_only_ending_blocker.py` | Stop | Responses that end on a plan or intent without doing the work |
80
- | `md_to_html_blocker.py` | PreToolUse (Write/Edit) | Writing `.md` files when an `.html` companion is required |
81
80
  | `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
82
81
  | `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 |
83
82
  | `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
@@ -107,8 +106,6 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
107
106
  | File | Role |
108
107
  |---|---|
109
108
  | `_gh_body_arg_utils.py` | Parsing helpers for `gh_body_arg_blocker.py` |
110
- | `_md_to_html_blocker_test_support.py` | Test fixtures shared across `md_to_html_blocker` tests |
111
- | `md_path_exemptions.py` | Path exemption logic for `md_to_html_blocker.py` |
112
109
  | `pr_description_body_audit.py` | Body audit logic for `pr_description_enforcer.py` |
113
110
  | `pr_description_command_parser.py` | `gh` command parsing for `pr_description_enforcer.py` |
114
111
  | `pr_description_pr_number.py` | PR number extraction logic |
@@ -119,4 +116,3 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
119
116
 
120
117
  - Tests live beside each hook as `test_<hookname>.py` or `test_<hookname>_<suffix>.py`. Run with `python -m pytest <test_file>`.
121
118
  - Tunable constants live in `hooks_constants/<hook_name>_constants.py`; the verified-commit family uses `blocking/config/verified_commit_constants.py`.
122
- - `conftest.py` gives shared pytest fixtures for the blocking test suite.
@@ -45,11 +45,11 @@ from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E
45
45
  SEPARATOR_CELL_PATTERN,
46
46
  TABLE_ROW_PATTERN,
47
47
  )
48
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
48
49
  from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
49
50
  apply_edits,
50
51
  edits_for_tool,
51
52
  )
52
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
53
53
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
54
54
  read_hook_input_dictionary_from_stdin,
55
55
  )
@@ -23,6 +23,7 @@ from code_rules_path_utils import ( # noqa: E402
23
23
  )
24
24
  from code_rules_shared import ( # noqa: E402
25
25
  _build_parent_map,
26
+ docstring_line_numbers,
26
27
  get_file_extension,
27
28
  is_migration_file,
28
29
  is_test_file,
@@ -110,6 +111,7 @@ def _scan_function_body_constants(content: str) -> list[str]:
110
111
  """
111
112
  advisory_issues: list[str] = []
112
113
  lines = content.split("\n")
114
+ all_docstring_line_numbers = docstring_line_numbers(content)
113
115
  function_indent_stack: list[int] = []
114
116
  constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})(?:\s*:\s*[^=]+)?\s*=\s*[^=]")
115
117
 
@@ -119,6 +121,9 @@ def _scan_function_body_constants(content: str) -> list[str]:
119
121
  if not stripped:
120
122
  continue
121
123
 
124
+ if each_line_number in all_docstring_line_numbers:
125
+ continue
126
+
122
127
  indent = len(each_line) - len(each_line.lstrip())
123
128
 
124
129
  while function_indent_stack and indent <= function_indent_stack[-1] and not stripped.startswith(("#", "@", ")")):