claude-dev-env 1.82.0 → 1.84.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 (217) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/audit-contract.md +24 -12
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/README.md +1 -0
  5. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  9. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  10. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  11. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  12. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  13. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  14. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  15. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  17. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  18. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  19. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  20. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  21. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  22. package/agents/clean-coder.md +3 -0
  23. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  24. package/bin/install.mjs +16 -8
  25. package/bin/install.test.mjs +17 -6
  26. package/commands/CLAUDE.md +0 -1
  27. package/docs/CODE_RULES.md +1 -1
  28. package/hooks/blocking/CLAUDE.md +2 -4
  29. package/hooks/blocking/code_rules_constants_config.py +164 -1
  30. package/hooks/blocking/code_rules_docstrings.py +428 -15
  31. package/hooks/blocking/code_rules_enforcer.py +33 -0
  32. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  33. package/hooks/blocking/code_rules_magic_values.py +5 -0
  34. package/hooks/blocking/code_rules_naming_collection.py +152 -6
  35. package/hooks/blocking/code_rules_shared.py +34 -0
  36. package/hooks/blocking/code_rules_string_magic.py +68 -0
  37. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  38. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  39. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  40. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  41. package/hooks/blocking/state_description_blocker.py +96 -5
  42. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  43. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  45. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  46. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  47. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  48. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  49. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  50. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  53. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  54. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  55. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  56. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  57. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  58. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  60. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  61. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  63. package/hooks/blocking/test_state_description_blocker.py +135 -0
  64. package/hooks/hooks.json +5 -0
  65. package/hooks/hooks_constants/CLAUDE.md +3 -4
  66. package/hooks/hooks_constants/blocking_check_limits.py +46 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  68. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  70. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  71. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  72. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  73. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  74. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  75. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  76. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  77. package/hooks/workflow/CLAUDE.md +2 -8
  78. package/package.json +1 -1
  79. package/rules/CLAUDE.md +5 -2
  80. package/rules/claude-md-orphan-file.md +5 -0
  81. package/rules/docstring-prose-matches-implementation.md +10 -1
  82. package/rules/env-var-table-code-drift.md +5 -0
  83. package/rules/es-exe-file-search.md +17 -0
  84. package/rules/no-historical-clutter.md +12 -1
  85. package/rules/orphan-css-class.md +5 -0
  86. package/rules/package-inventory-stale-entry.md +10 -0
  87. package/rules/paired-test-coverage.md +5 -0
  88. package/rules/plain-illustrative-docstrings.md +40 -1
  89. package/rules/verify-before-asking.md +7 -0
  90. package/rules/verify-runtime-state.md +40 -0
  91. package/rules/windows-filesystem-safe.md +8 -0
  92. package/rules/workers-done-before-complete.md +33 -0
  93. package/rules/workflow-substitution-slots.md +5 -0
  94. package/skills/CLAUDE.md +7 -2
  95. package/skills/autoconverge/SKILL.md +48 -61
  96. package/skills/autoconverge/reference/closing-report.md +6 -6
  97. package/skills/autoconverge/reference/convergence.md +17 -15
  98. package/skills/autoconverge/reference/gotchas.md +6 -3
  99. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  100. package/skills/autoconverge/workflow/converge.contract.test.mjs +153 -10
  101. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +55 -19
  102. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  103. package/skills/autoconverge/workflow/converge.mjs +167 -106
  104. package/skills/autoconverge/workflow/render_report.py +7 -11
  105. package/skills/bugteam/CLAUDE.md +1 -1
  106. package/skills/bugteam/PROMPTS.md +7 -6
  107. package/skills/bugteam/SKILL.md +25 -72
  108. package/skills/bugteam/reference/CLAUDE.md +1 -3
  109. package/skills/bugteam/reference/README.md +1 -1
  110. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  111. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  112. package/skills/bugteam/reference/team-setup.md +8 -7
  113. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  114. package/skills/bugteam/scripts/README.md +0 -4
  115. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  116. package/skills/code/SKILL.md +2 -0
  117. package/skills/copilot-review/CLAUDE.md +1 -1
  118. package/skills/copilot-review/SKILL.md +25 -23
  119. package/skills/everything-search/SKILL.md +5 -0
  120. package/skills/findbugs/CLAUDE.md +2 -2
  121. package/skills/findbugs/SKILL.md +22 -83
  122. package/skills/fixbugs/SKILL.md +2 -4
  123. package/skills/log-audit/CLAUDE.md +20 -0
  124. package/skills/log-audit/SKILL.md +68 -0
  125. package/skills/log-audit/reference/CLAUDE.md +9 -0
  126. package/skills/log-audit/reference/charter.md +52 -0
  127. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  128. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  129. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  130. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  131. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  132. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  133. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  134. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  135. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  136. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  137. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  138. package/skills/monitor-open-prs/SKILL.md +2 -2
  139. package/skills/post-audit-findings/SKILL.md +84 -0
  140. package/skills/pr-converge/CLAUDE.md +2 -0
  141. package/skills/pr-converge/SKILL.md +72 -59
  142. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  143. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  144. package/skills/pr-converge/reference/examples.md +5 -5
  145. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  146. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  147. package/skills/pr-converge/reference/per-tick.md +24 -45
  148. package/skills/pr-converge/reference/state-schema.md +15 -0
  149. package/skills/pr-converge/scripts/README.md +3 -5
  150. package/skills/pr-fix-protocol/SKILL.md +70 -0
  151. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  152. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  153. package/skills/pr-scope-resolve/SKILL.md +48 -0
  154. package/skills/qbug/CLAUDE.md +4 -4
  155. package/skills/qbug/SKILL.md +46 -144
  156. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  157. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  158. package/skills/reviewer-gates/SKILL.md +96 -0
  159. package/skills/session-log/CLAUDE.md +7 -7
  160. package/skills/session-log/SKILL.md +27 -44
  161. package/skills/test_markdown_link_integrity.py +103 -0
  162. package/commands/doc-gist.md +0 -16
  163. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  164. package/hooks/blocking/conftest.py +0 -30
  165. package/hooks/blocking/md_path_exemptions.py +0 -224
  166. package/hooks/blocking/md_to_html_blocker.py +0 -155
  167. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  168. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  169. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  170. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  171. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  172. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  173. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  174. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  175. package/hooks/workflow/md_to_html_companion.py +0 -358
  176. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  177. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  178. package/skills/bugteam/reference/audit-contract.md +0 -163
  179. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  180. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  181. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  182. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  183. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  184. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  185. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  186. package/skills/doc-gist/CLAUDE.md +0 -25
  187. package/skills/doc-gist/SKILL.md +0 -97
  188. package/skills/doc-gist/references/CLAUDE.md +0 -9
  189. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  190. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  191. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  192. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  193. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  194. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  195. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  196. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  197. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  198. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  199. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  200. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  201. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  202. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  203. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  204. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  205. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  206. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  207. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  208. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  209. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  210. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  211. package/skills/doc-gist/references/examples/README.md +0 -5
  212. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  213. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  214. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  215. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  216. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  217. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -133,3 +133,215 @@ def test_path_contains_glob_metacharacters_accepts_windows_paths_with_parens() -
133
133
  assert common.path_contains_glob_metacharacters("C:/Users/Jon (Admin)/project") is False
134
134
  assert common.path_contains_glob_metacharacters("C:/Projects/a,b/file.py") is False
135
135
 
136
+
137
+ def test_get_mode_to_preserve_returns_existing_file_mode(tmp_path: Path) -> None:
138
+ """When the file exists, the actual filesystem mode must be returned (not the default)."""
139
+ target_path = tmp_path / "settings.json"
140
+ target_path.write_text("{}", encoding="utf-8")
141
+ actual_filesystem_mode = target_path.stat().st_mode & 0o777
142
+ returned_mode = common.get_mode_to_preserve(target_path)
143
+ assert returned_mode == actual_filesystem_mode
144
+
145
+
146
+ def test_get_mode_to_preserve_returns_secure_default_when_file_missing(
147
+ tmp_path: Path,
148
+ ) -> None:
149
+ """A missing settings file must fall back to the secure 0o600 default mode."""
150
+ missing_settings_path = tmp_path / "no_such_file.json"
151
+ returned_mode = common.get_mode_to_preserve(missing_settings_path)
152
+ assert returned_mode == 0o600
153
+
154
+
155
+ def test_write_atomically_with_mode_raises_oserror_when_open_fails(
156
+ tmp_path: Path,
157
+ ) -> None:
158
+ """OSError from os.open must propagate to the caller."""
159
+ target_path = tmp_path / "subdirectory" / "missing" / "settings.json.tmp"
160
+ with pytest.raises(OSError):
161
+ common.write_atomically_with_mode(target_path, "payload", file_mode=0o600)
162
+
163
+
164
+ def test_write_atomically_with_mode_unlinks_temp_when_fdopen_raises(
165
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
166
+ ) -> None:
167
+ """A failure inside os.fdopen must remove the on-disk temp file.
168
+
169
+ The file exists the moment os.open returns; closing only the raw
170
+ descriptor would leave an empty temp sibling on disk after the
171
+ exception propagates to the caller.
172
+ """
173
+
174
+ def failing_fdopen(*_args: object, **_kwargs: object) -> object:
175
+ raise MemoryError("fdopen failure")
176
+
177
+ monkeypatch.setattr(common.os, "fdopen", failing_fdopen)
178
+ target_path = tmp_path / "settings.json.tmp"
179
+ with pytest.raises(MemoryError):
180
+ common.write_atomically_with_mode(target_path, "payload", file_mode=0o600)
181
+ assert not target_path.exists(), (
182
+ "the temp file created by os.open must be unlinked before re-raising"
183
+ )
184
+
185
+
186
+ def test_save_settings_warns_on_stderr_when_temp_unlink_fails(
187
+ tmp_path: Path,
188
+ monkeypatch: pytest.MonkeyPatch,
189
+ capsys: pytest.CaptureFixture[str],
190
+ ) -> None:
191
+ """A swallowed unlink in the finally block must surface to stderr.
192
+
193
+ Forces a write success followed by os.replace failure so the temp file
194
+ survives into the finally branch, then makes Path.unlink raise.
195
+ """
196
+ settings_path = tmp_path / "settings.json"
197
+ settings_path.write_text('{"existing": true}\n', encoding="utf-8")
198
+
199
+ def failing_replace(*_args: object, **_kwargs: object) -> None:
200
+ raise OSError("replace blocked by AV")
201
+
202
+ def failing_unlink(_self: Path, *_args: object, **_kwargs: object) -> None:
203
+ raise PermissionError("temp file held by AV")
204
+
205
+ monkeypatch.setattr(common.os, "replace", failing_replace)
206
+ monkeypatch.setattr(Path, "unlink", failing_unlink)
207
+ with pytest.raises(SystemExit):
208
+ common.save_settings(settings_path, {"new_key": "value"})
209
+ captured = capsys.readouterr()
210
+ assert ".tmp" in captured.err
211
+ assert "PermissionError" in captured.err or "held by AV" in captured.err
212
+
213
+
214
+ def test_save_settings_finally_skips_unlink_when_no_temp_was_created(
215
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
216
+ ) -> None:
217
+ """When this invocation never created the temp file, finally must not unlink it."""
218
+ settings_path = tmp_path / "settings.json"
219
+ settings_path.write_text('{"existing": true}\n', encoding="utf-8")
220
+
221
+ unlink_call_paths: list[Path] = []
222
+ original_unlink = Path.unlink
223
+
224
+ def recording_unlink(unlink_target: Path, *args: object, **kwargs: object) -> None:
225
+ unlink_call_paths.append(unlink_target)
226
+ original_unlink(unlink_target, *args, **kwargs)
227
+
228
+ def write_raises(*_args: object, **_kwargs: object) -> None:
229
+ raise FileExistsError("another writer's temp")
230
+
231
+ monkeypatch.setattr(common, "write_atomically_with_mode", write_raises)
232
+ monkeypatch.setattr(Path, "unlink", recording_unlink)
233
+ with pytest.raises(SystemExit):
234
+ common.save_settings(settings_path, {"new_key": "value"})
235
+ assert not any(
236
+ ".tmp." in each_path.name for each_path in unlink_call_paths
237
+ ), "finally must not unlink a temp file this invocation never created"
238
+
239
+
240
+ def test_exit_with_error_prints_prefixed_message_and_exits_nonzero(
241
+ capsys: pytest.CaptureFixture[str],
242
+ ) -> None:
243
+ with pytest.raises(SystemExit) as raised_exit:
244
+ common.exit_with_error("boom")
245
+ assert raised_exit.value.code == 1
246
+ assert "Error: boom" in capsys.readouterr().err
247
+
248
+
249
+ def test_build_permission_rules_builds_one_rule_per_tool() -> None:
250
+ built_rules = common.build_permission_rules("/proj", ("Edit", "Read"))
251
+ assert built_rules == ["Edit(/proj/.claude/**)", "Read(/proj/.claude/**)"]
252
+
253
+
254
+ def test_build_agent_config_deny_rule_embeds_tool_path_and_pattern() -> None:
255
+ built_rule = common.build_agent_config_deny_rule("Write", "/proj", "hooks/**")
256
+ assert built_rule == "Write(/proj/.claude/hooks/**)"
257
+
258
+
259
+ def test_remove_matching_entries_from_list_filters_in_place_and_counts() -> None:
260
+ all_entries: list[object] = ["keep", "drop", "drop", 7]
261
+ removed_count = common.remove_matching_entries_from_list(
262
+ all_entries, lambda each_entry: each_entry == "drop"
263
+ )
264
+ assert removed_count == 2
265
+ assert all_entries == ["keep", 7]
266
+
267
+
268
+ def test_load_settings_returns_empty_dict_when_file_missing(tmp_path: Path) -> None:
269
+ assert common.load_settings(tmp_path / "absent.json") == {}
270
+
271
+
272
+ def test_load_settings_parses_existing_json_object(tmp_path: Path) -> None:
273
+ settings_path = tmp_path / "settings.json"
274
+ settings_path.write_text('{"permissions": {"allow": []}}', encoding="utf-8")
275
+ assert common.load_settings(settings_path) == {"permissions": {"allow": []}}
276
+
277
+
278
+ def test_load_settings_exits_when_file_holds_invalid_json(
279
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
280
+ ) -> None:
281
+ settings_path = tmp_path / "settings.json"
282
+ settings_path.write_text("not json", encoding="utf-8")
283
+ with pytest.raises(SystemExit):
284
+ common.load_settings(settings_path)
285
+ assert "not valid JSON" in capsys.readouterr().err
286
+
287
+
288
+ def test_serialize_settings_to_json_text_sorts_keys() -> None:
289
+ serialized_text = common.serialize_settings_to_json_text({"b": 1, "a": 2})
290
+ assert serialized_text.index('"a"') < serialized_text.index('"b"')
291
+ assert json.loads(serialized_text) == {"a": 2, "b": 1}
292
+
293
+
294
+ def test_append_if_missing_appends_only_new_values() -> None:
295
+ all_entries: list[object] = ["existing"]
296
+ assert common.append_if_missing(all_entries, "fresh") is True
297
+ assert common.append_if_missing(all_entries, "fresh") is False
298
+ assert all_entries == ["existing", "fresh"]
299
+
300
+
301
+ def test_ensure_dict_section_creates_missing_and_returns_existing() -> None:
302
+ all_settings: dict[str, object] = {}
303
+ created_section = common.ensure_dict_section(all_settings, "permissions")
304
+ assert created_section == {}
305
+ assert all_settings["permissions"] is created_section
306
+ assert common.ensure_dict_section(all_settings, "permissions") is created_section
307
+
308
+
309
+ def test_ensure_dict_section_exits_on_non_dict_value(
310
+ capsys: pytest.CaptureFixture[str],
311
+ ) -> None:
312
+ all_settings: dict[str, object] = {"permissions": "oops"}
313
+ with pytest.raises(SystemExit):
314
+ common.ensure_dict_section(all_settings, "permissions")
315
+ assert "not a JSON object" in capsys.readouterr().err
316
+
317
+
318
+ def test_ensure_list_entry_creates_missing_and_returns_existing() -> None:
319
+ permissions_section: dict[str, object] = {}
320
+ created_entry = common.ensure_list_entry(permissions_section, "allow")
321
+ assert created_entry == []
322
+ assert permissions_section["allow"] is created_entry
323
+ assert common.ensure_list_entry(permissions_section, "allow") is created_entry
324
+
325
+
326
+ def test_ensure_list_entry_exits_on_non_list_value(
327
+ capsys: pytest.CaptureFixture[str],
328
+ ) -> None:
329
+ permissions_section: dict[str, object] = {"allow": {}}
330
+ with pytest.raises(SystemExit):
331
+ common.ensure_list_entry(permissions_section, "allow")
332
+ assert "not a JSON array" in capsys.readouterr().err
333
+
334
+
335
+ def test_prune_empty_list_then_empty_section_removes_empty_structures() -> None:
336
+ all_settings: dict[str, object] = {"permissions": {"allow": []}}
337
+ common.prune_empty_list_then_empty_section(all_settings, "permissions", "allow")
338
+ assert all_settings == {}
339
+
340
+
341
+ def test_prune_empty_list_then_empty_section_keeps_populated_structures() -> None:
342
+ all_settings: dict[str, object] = {
343
+ "permissions": {"allow": ["Edit(/proj/.claude/**)"], "deny": []}
344
+ }
345
+ common.prune_empty_list_then_empty_section(all_settings, "permissions", "deny")
346
+ assert all_settings == {"permissions": {"allow": ["Edit(/proj/.claude/**)"]}}
347
+
@@ -355,6 +355,24 @@ def test_is_trust_entry_rejects_cross_project_path_suffix_collision() -> None:
355
355
  )
356
356
 
357
357
 
358
+ def test_is_valid_project_root_shared_by_grant_and_revoke_modules(
359
+ tmp_path: Path,
360
+ ) -> None:
361
+ """Grant and revoke import the same helper, so both agree on the marker contract."""
362
+ grant_module = _load_grant_module()
363
+ revoke_module = _load_revoke_module()
364
+ git_marker_project_root = tmp_path / "git_project"
365
+ (git_marker_project_root / ".git").mkdir(parents=True)
366
+ claude_marker_project_root = tmp_path / "claude_project"
367
+ (claude_marker_project_root / ".claude").mkdir(parents=True)
368
+ bare_directory = tmp_path / "no_marker"
369
+ bare_directory.mkdir()
370
+ for each_module in (grant_module, revoke_module):
371
+ assert each_module.is_valid_project_root(git_marker_project_root) is True
372
+ assert each_module.is_valid_project_root(claude_marker_project_root) is True
373
+ assert each_module.is_valid_project_root(bare_directory) is False
374
+
375
+
358
376
  def test_second_grant_is_idempotent_when_no_other_settings_changed(
359
377
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
360
378
  ) -> None:
@@ -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:
@@ -0,0 +1,159 @@
1
+ """Direct unit tests for the unified reviewer_availability pre-check.
2
+
3
+ Every case drives the production ``main`` path through
4
+ ``evaluate_reviewer_availability``. Copilot cases stub only the ``gh``
5
+ subprocess boundary inside ``copilot_quota`` (``_run_gh``), reusing the same
6
+ captured fixture ``test_copilot_quota.py`` drives. Opt-out cases set or clear
7
+ ``CLAUDE_REVIEWS_DISABLED`` directly.
8
+ """
9
+
10
+ import importlib.util
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+ import pytest
17
+
18
+
19
+ def _load_module(module_name: str) -> ModuleType:
20
+ scripts_directory = Path(__file__).parent.parent
21
+ if str(scripts_directory) not in sys.path:
22
+ sys.path.insert(0, str(scripts_directory))
23
+ module_path = scripts_directory / f"{module_name}.py"
24
+ specification = importlib.util.spec_from_file_location(module_name, module_path)
25
+ assert specification is not None
26
+ assert specification.loader is not None
27
+ module = importlib.util.module_from_spec(specification)
28
+ sys.modules[specification.name] = module
29
+ specification.loader.exec_module(module)
30
+ return module
31
+
32
+
33
+ copilot_quota = _load_module("copilot_quota")
34
+ reviewer_availability = _load_module("reviewer_availability")
35
+
36
+ FIXTURE_PATH = Path(__file__).parent / "fixtures" / "copilot_internal_user_jonecho.json"
37
+ AVAILABLE_USER_JSON = FIXTURE_PATH.read_text(encoding="utf-8")
38
+ FAKE_TOKEN_RESULT = (0, "ghp_faketoken_value\n")
39
+
40
+
41
+ def _exhausted_user_json() -> str:
42
+ user = json.loads(AVAILABLE_USER_JSON)
43
+ premium = user["quota_snapshots"]["premium_interactions"]
44
+ premium["remaining"] = 0
45
+ premium["quota_remaining"] = 0.0
46
+ premium["percent_remaining"] = 0.0
47
+ return json.dumps(user)
48
+
49
+
50
+ def _gh_stub(token_result: tuple[int, str], api_result: tuple[int, str]):
51
+ def _fake_run_gh(
52
+ command_arguments: list[str],
53
+ extra_environment: dict[str, str] | None = None,
54
+ ) -> tuple[int, str]:
55
+ if command_arguments and command_arguments[0] == "auth":
56
+ return token_result
57
+ if command_arguments and command_arguments[0] == "api":
58
+ return api_result
59
+ raise AssertionError(f"unexpected gh command {command_arguments}")
60
+
61
+ return _fake_run_gh
62
+
63
+
64
+ @pytest.fixture(autouse=True)
65
+ def _isolate_reviewer_environment(
66
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
67
+ ) -> None:
68
+ monkeypatch.delenv("COPILOT_QUOTA_ACCOUNT", raising=False)
69
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
70
+ monkeypatch.setattr(
71
+ reviewer_availability, "COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH", tmp_path / ".env"
72
+ )
73
+
74
+
75
+ def test_main_reports_copilot_available_when_quota_confirmed_available(
76
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
77
+ ) -> None:
78
+ monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
79
+ monkeypatch.setattr(
80
+ copilot_quota, "_run_gh", _gh_stub(FAKE_TOKEN_RESULT, (0, AVAILABLE_USER_JSON))
81
+ )
82
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
83
+ captured = capsys.readouterr()
84
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_AVAILABLE
85
+ assert "running Copilot" in captured.out
86
+
87
+
88
+ def test_main_reports_copilot_down_when_opted_out_via_env(
89
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
90
+ ) -> None:
91
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
92
+
93
+ def _fail_if_called(
94
+ command_arguments: list[str],
95
+ extra_environment: dict[str, str] | None = None,
96
+ ) -> tuple[int, str]:
97
+ raise AssertionError("gh must not run once copilot is opted out")
98
+
99
+ monkeypatch.setattr(copilot_quota, "_run_gh", _fail_if_called)
100
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
101
+ captured = capsys.readouterr()
102
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
103
+ assert "CLAUDE_REVIEWS_DISABLED" in captured.err
104
+
105
+
106
+ def test_main_reports_copilot_down_when_out_of_quota(
107
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
108
+ ) -> None:
109
+ monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
110
+ monkeypatch.setattr(
111
+ copilot_quota,
112
+ "_run_gh",
113
+ _gh_stub(FAKE_TOKEN_RESULT, (0, _exhausted_user_json())),
114
+ )
115
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
116
+ captured = capsys.readouterr()
117
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
118
+ assert "scenario A" in captured.err
119
+
120
+
121
+ def test_main_reports_copilot_down_when_quota_api_is_down(
122
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
123
+ ) -> None:
124
+ monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
125
+ monkeypatch.setattr(
126
+ copilot_quota, "_run_gh", _gh_stub(FAKE_TOKEN_RESULT, (0, "not json at all"))
127
+ )
128
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
129
+ captured = capsys.readouterr()
130
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
131
+ assert "scenario B" in captured.err
132
+
133
+
134
+ def test_main_reports_copilot_down_when_no_account_configured(
135
+ capsys: pytest.CaptureFixture[str],
136
+ ) -> None:
137
+ exit_code = reviewer_availability.main(["--reviewer", "copilot"])
138
+ captured = capsys.readouterr()
139
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
140
+ assert "scenario C" in captured.err
141
+
142
+
143
+ def test_main_reports_bugbot_available_when_not_opted_out(
144
+ capsys: pytest.CaptureFixture[str],
145
+ ) -> None:
146
+ exit_code = reviewer_availability.main(["--reviewer", "bugbot"])
147
+ captured = capsys.readouterr()
148
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_AVAILABLE
149
+ assert "bugbot" in captured.out
150
+
151
+
152
+ def test_main_reports_bugbot_down_when_opted_out_via_env(
153
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
154
+ ) -> None:
155
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
156
+ exit_code = reviewer_availability.main(["--reviewer", "bugbot"])
157
+ captured = capsys.readouterr()
158
+ assert exit_code == reviewer_availability.EXIT_CODE_REVIEWER_DOWN
159
+ assert "CLAUDE_REVIEWS_DISABLED" in captured.err
@@ -0,0 +1,36 @@
1
+ """Tests for reviewer_availability_constants.py extracted constant set."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _load_constants_module() -> ModuleType:
9
+ module_path = (
10
+ Path(__file__).parent.parent
11
+ / "pr_loop_shared_constants"
12
+ / "reviewer_availability_constants.py"
13
+ )
14
+ specification = importlib.util.spec_from_file_location(
15
+ "pr_loop_shared_constants.reviewer_availability_constants", module_path
16
+ )
17
+ assert specification is not None
18
+ assert specification.loader is not None
19
+ module = importlib.util.module_from_spec(specification)
20
+ specification.loader.exec_module(module)
21
+ return module
22
+
23
+
24
+ constants_module = _load_constants_module()
25
+
26
+
27
+ def test_available_exit_code_matches_the_posix_success_convention() -> None:
28
+ assert constants_module.EXIT_CODE_REVIEWER_AVAILABLE == 0
29
+
30
+
31
+ def test_down_exit_code_is_distinct_from_the_available_exit_code() -> None:
32
+ assert (
33
+ constants_module.EXIT_CODE_REVIEWER_DOWN
34
+ != constants_module.EXIT_CODE_REVIEWER_AVAILABLE
35
+ )
36
+ assert constants_module.EXIT_CODE_REVIEWER_DOWN != 0
@@ -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
@@ -191,7 +191,7 @@ def test_does_not_flag_y_to_ies_plural_variant() -> None:
191
191
  assert sweep_diff(diff) == []
192
192
 
193
193
 
194
- def test_still_flags_non_plural_divergent_tail() -> None:
194
+ def test_does_not_flag_spaced_prose_sharing_identifier_prefix() -> None:
195
195
  diff = (
196
196
  "diff --git a/api/quota.py b/api/quota.py\n"
197
197
  "--- a/api/quota.py\n"
@@ -204,9 +204,19 @@ def test_still_flags_non_plural_divergent_tail() -> None:
204
204
  "@@ -0,0 +1,1 @@\n"
205
205
  "+The premium request budget gates the run.\n"
206
206
  )
207
- findings = sweep_diff(diff)
208
- assert len(findings) == 1
209
- assert "premium request" in findings[0]
207
+ assert sweep_diff(diff) == []
208
+
209
+
210
+ def test_does_not_flag_ordinary_sentence_sharing_loop_variable_prefix() -> None:
211
+ diff = (
212
+ "diff --git a/api/scan.py b/api/scan.py\n"
213
+ "--- a/api/scan.py\n"
214
+ "+++ b/api/scan.py\n"
215
+ "@@ -0,0 +1,2 @@\n"
216
+ "+each_node = walk(tree)\n"
217
+ '+DESCRIPTION = "Each attempt polls the review endpoint once."\n'
218
+ )
219
+ assert sweep_diff(diff) == []
210
220
 
211
221
 
212
222
  def test_flags_near_miss_inside_code_comment() -> None:
@@ -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
  /**