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
@@ -0,0 +1,54 @@
1
+ """Behavior tests for the code_rules_magic_values check module.
2
+
3
+ A diagram-first docstring may carry a bare number on an illustrative row
4
+ (``at line 2``). That number is prose, not a code literal, so the magic-value
5
+ check skips docstring rows while still flagging a real magic number in an
6
+ actual code statement.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
15
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
16
+ if _BLOCKING_DIRECTORY not in sys.path:
17
+ sys.path.insert(0, _BLOCKING_DIRECTORY)
18
+ if _HOOKS_DIRECTORY not in sys.path:
19
+ sys.path.insert(0, _HOOKS_DIRECTORY)
20
+
21
+ from code_rules_magic_values import check_magic_values # noqa: E402
22
+
23
+ PRODUCTION_FILE_PATH = "packages/app/rendering.py"
24
+
25
+
26
+ def test_magic_value_check_skips_bare_number_inside_docstring_illustration() -> None:
27
+ source = (
28
+ "def summarize(record):\n"
29
+ ' """Summarize how a run ended.\n'
30
+ "\n"
31
+ " A finished run shows a clean outcome::\n"
32
+ "\n"
33
+ " marked in-flight at row 2\n"
34
+ ' """\n'
35
+ " return record\n"
36
+ )
37
+ issues = check_magic_values(source, PRODUCTION_FILE_PATH)
38
+ assert issues == [], f"Expected no magic-value issue on an illustrative row, got: {issues}"
39
+
40
+
41
+ def test_magic_value_check_still_flags_real_magic_number_in_code_statement() -> None:
42
+ source = (
43
+ "def summarize(record):\n"
44
+ ' """Summarize how a run ended.\n'
45
+ "\n"
46
+ " marked in-flight at row 2\n"
47
+ ' """\n'
48
+ " threshold = record.score * 42\n"
49
+ " return threshold\n"
50
+ )
51
+ issues = check_magic_values(source, PRODUCTION_FILE_PATH)
52
+ assert any(issue.endswith("Magic value 42 - extract to named constant") for issue in issues), (
53
+ f"Expected the real magic number 42 to stay flagged, got: {issues}"
54
+ )
@@ -142,12 +142,6 @@ def test_path_exempts_existing_session_env_cleanup_definition_site() -> None:
142
142
  assert path_is_exempt("packages/claude-dev-env/hooks/session/session_env_cleanup.py")
143
143
 
144
144
 
145
- def test_path_exempts_existing_md_to_html_test_support_definition_site() -> None:
146
- assert path_is_exempt(
147
- "packages/claude-dev-env/hooks/blocking/_md_to_html_blocker_test_support.py"
148
- )
149
-
150
-
151
145
  def test_path_exempts_existing_teardown_worktrees_definition_site() -> None:
152
146
  assert path_is_exempt(
153
147
  "packages/claude-dev-env/skills/_shared/pr-loop/scripts/teardown_worktrees.py"
@@ -0,0 +1,184 @@
1
+ """Unit tests: persistent-agent state keys leave both pr-converge hooks unchanged.
2
+
3
+ pr-converge-state.json carries `agents_session_id` and `persistent_agents`
4
+ (the persistent per-step agent map with a `fix_executor` entry). Neither the
5
+ enforcer (`pr_converge_bugteam_enforcer`) nor the tracker
6
+ (`pr_converge_bugteam_skill_tracker`) reads these keys, so their allow/block
7
+ decisions and stamped fields match a state file without them, and the tracker
8
+ preserves the keys unchanged on its stamp write.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ import io
15
+ import json
16
+ import pathlib
17
+ import sys
18
+ from types import ModuleType
19
+ from typing import Any
20
+ from unittest import mock
21
+
22
+ import pytest
23
+
24
+ _BLOCKING_DIR = pathlib.Path(__file__).parent
25
+ _HOOKS_TREE = _BLOCKING_DIR.parent
26
+ _LIFECYCLE_DIR = _HOOKS_TREE / "lifecycle"
27
+ for each_path in (str(_BLOCKING_DIR), str(_HOOKS_TREE)):
28
+ if each_path not in sys.path:
29
+ sys.path.insert(0, each_path)
30
+
31
+
32
+ def _load_hook_module(module_name: str, module_path: pathlib.Path) -> ModuleType:
33
+ hook_spec = importlib.util.spec_from_file_location(module_name, module_path)
34
+ assert hook_spec is not None
35
+ assert hook_spec.loader is not None
36
+ loaded_module = importlib.util.module_from_spec(hook_spec)
37
+ hook_spec.loader.exec_module(loaded_module)
38
+ return loaded_module
39
+
40
+
41
+ enforcer_module = _load_hook_module(
42
+ "pr_converge_bugteam_enforcer_state_tolerance_target",
43
+ _BLOCKING_DIR / "pr_converge_bugteam_enforcer.py",
44
+ )
45
+ tracker_module = _load_hook_module(
46
+ "pr_converge_bugteam_skill_tracker_state_tolerance_target",
47
+ _LIFECYCLE_DIR / "pr_converge_bugteam_skill_tracker.py",
48
+ )
49
+
50
+ from hooks_constants.pr_converge_bugteam_enforcer_constants import (
51
+ BUGTEAM_PHASE,
52
+ CLAUDE_JOB_DIR_ENV_VAR,
53
+ PR_CONVERGE_STATE_FILENAME,
54
+ )
55
+
56
+ _HEAD_SHA_CURRENT = "abc123def456abc123def456abc123def456abcd"
57
+ _TICK_CURRENT = 7
58
+ _AUDIT_PROMPT = "Run the bugteam audit and report findings against the A-J categories."
59
+ _AGENTS_SESSION_ID = "session-0123456789abcdef"
60
+ _FIX_EXECUTOR_AGENT_ID = "prc-fix-42"
61
+
62
+
63
+ def _persistent_agent_state_keys() -> dict[str, Any]:
64
+ return {
65
+ "agents_session_id": _AGENTS_SESSION_ID,
66
+ "persistent_agents": {
67
+ "fix_executor": {
68
+ "agent_id": _FIX_EXECUTOR_AGENT_ID,
69
+ "created_tick": 2,
70
+ "last_used_tick": 6,
71
+ }
72
+ },
73
+ }
74
+
75
+
76
+ def _bugteam_phase_state(**overrides: Any) -> dict[str, Any]:
77
+ baseline_state: dict[str, Any] = {
78
+ "phase": BUGTEAM_PHASE,
79
+ "current_head": _HEAD_SHA_CURRENT,
80
+ "tick_count": _TICK_CURRENT,
81
+ "bugteam_skill_invoked_at_head": None,
82
+ "bugteam_skill_invoked_at_tick": None,
83
+ }
84
+ baseline_state.update(_persistent_agent_state_keys())
85
+ baseline_state.update(overrides)
86
+ return baseline_state
87
+
88
+
89
+ def _write_state(state_directory: pathlib.Path, state: dict[str, Any]) -> pathlib.Path:
90
+ state_path = state_directory / PR_CONVERGE_STATE_FILENAME
91
+ state_path.write_text(json.dumps(state), encoding="utf-8")
92
+ return state_path
93
+
94
+
95
+ def _read_state(state_directory: pathlib.Path) -> dict[str, Any]:
96
+ state_path = state_directory / PR_CONVERGE_STATE_FILENAME
97
+ return json.loads(state_path.read_text(encoding="utf-8"))
98
+
99
+
100
+ def _clean_coder_audit_payload() -> dict[str, Any]:
101
+ return {
102
+ "tool_name": "Agent",
103
+ "tool_input": {
104
+ "subagent_type": "clean-coder",
105
+ "prompt": _AUDIT_PROMPT,
106
+ "description": "Audit the PR",
107
+ },
108
+ }
109
+
110
+
111
+ def _bugteam_skill_payload() -> dict[str, Any]:
112
+ return {
113
+ "tool_name": "Skill",
114
+ "tool_input": {"skill": "bugteam", "args": "https://github.com/o/r/pull/1"},
115
+ }
116
+
117
+
118
+ def _run_enforcer_main_with_io(input_text: str) -> str:
119
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
120
+ with mock.patch("sys.stdout", new_callable=io.StringIO) as captured_stdout:
121
+ try:
122
+ enforcer_module.main()
123
+ except SystemExit:
124
+ pass
125
+ return captured_stdout.getvalue()
126
+
127
+
128
+ def _run_tracker_main_with_io(input_text: str) -> None:
129
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
130
+ with mock.patch("sys.stdout", new_callable=io.StringIO):
131
+ with mock.patch("sys.stderr", new_callable=io.StringIO):
132
+ try:
133
+ tracker_module.main()
134
+ except SystemExit:
135
+ pass
136
+
137
+
138
+ @pytest.fixture()
139
+ def claude_job_directory(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
140
+ monkeypatch.setenv(CLAUDE_JOB_DIR_ENV_VAR, str(tmp_path))
141
+ return tmp_path
142
+
143
+
144
+ def test_should_block_enforcer_when_persistent_agent_keys_present_without_skill(
145
+ claude_job_directory: pathlib.Path,
146
+ ) -> None:
147
+ _write_state(claude_job_directory, _bugteam_phase_state())
148
+ captured_output = _run_enforcer_main_with_io(json.dumps(_clean_coder_audit_payload()))
149
+ deny_payload = json.loads(captured_output)
150
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
151
+
152
+
153
+ def test_should_allow_enforcer_when_persistent_agent_keys_present_with_skill_recorded(
154
+ claude_job_directory: pathlib.Path,
155
+ ) -> None:
156
+ _write_state(
157
+ claude_job_directory,
158
+ _bugteam_phase_state(
159
+ bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
160
+ bugteam_skill_invoked_at_tick=_TICK_CURRENT,
161
+ ),
162
+ )
163
+ captured_output = _run_enforcer_main_with_io(json.dumps(_clean_coder_audit_payload()))
164
+ assert captured_output == ""
165
+
166
+
167
+ def test_should_stamp_tracker_fields_when_persistent_agent_keys_present(
168
+ claude_job_directory: pathlib.Path,
169
+ ) -> None:
170
+ _write_state(claude_job_directory, _bugteam_phase_state())
171
+ _run_tracker_main_with_io(json.dumps(_bugteam_skill_payload()))
172
+ updated_state = _read_state(claude_job_directory)
173
+ assert updated_state["bugteam_skill_invoked_at_head"] == _HEAD_SHA_CURRENT
174
+ assert updated_state["bugteam_skill_invoked_at_tick"] == _TICK_CURRENT
175
+
176
+
177
+ def test_should_preserve_persistent_agent_keys_on_tracker_stamp_write(
178
+ claude_job_directory: pathlib.Path,
179
+ ) -> None:
180
+ _write_state(claude_job_directory, _bugteam_phase_state())
181
+ _run_tracker_main_with_io(json.dumps(_bugteam_skill_payload()))
182
+ updated_state = _read_state(claude_job_directory)
183
+ assert updated_state["agents_session_id"] == _AGENTS_SESSION_ID
184
+ assert updated_state["persistent_agents"] == _persistent_agent_state_keys()["persistent_agents"]
@@ -638,10 +638,16 @@ def test_dispatcher_write_applies_both_groups() -> None:
638
638
 
639
639
 
640
640
  def test_dispatcher_edit_applies_both_groups() -> None:
641
- """Edit tool triggers both Group A and Group B hooks through the dispatcher."""
641
+ """Edit triggers Group A, Group B, and the Edit-scoped entry through the
642
+ dispatcher.
643
+ """
642
644
  all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
643
- assert len(all_edit_entries) == 19, (
644
- f"Edit tool must apply to all 19 hosted hooks, got {len(all_edit_entries)}"
645
+ all_edit_script_paths = {each_entry.script_relative_path for each_entry in all_edit_entries}
646
+ assert "blocking/stale_comment_reference_blocker.py" in all_edit_script_paths, (
647
+ "stale_comment_reference_blocker belongs in the Edit applicable set"
648
+ )
649
+ assert len(all_edit_entries) == 20, (
650
+ f"expected 20 Edit entries, got {len(all_edit_entries)}"
645
651
  )
646
652
 
647
653
 
@@ -0,0 +1,230 @@
1
+ """Unit tests for reviewer_spawn_gate PreToolUse hook."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import pathlib
7
+ import sys
8
+ from contextlib import redirect_stderr, redirect_stdout
9
+
10
+ import pytest
11
+
12
+ _HOOK_DIR = pathlib.Path(__file__).parent
13
+ if str(_HOOK_DIR) not in sys.path:
14
+ sys.path.insert(0, str(_HOOK_DIR))
15
+
16
+ hook_spec = importlib.util.spec_from_file_location(
17
+ "reviewer_spawn_gate",
18
+ _HOOK_DIR / "reviewer_spawn_gate.py",
19
+ )
20
+ assert hook_spec is not None
21
+ assert hook_spec.loader is not None
22
+ hook_module = importlib.util.module_from_spec(hook_spec)
23
+ hook_spec.loader.exec_module(hook_module)
24
+
25
+ _matches_copilot_trigger = hook_module._matches_copilot_trigger
26
+ _matches_bugbot_trigger = hook_module._matches_bugbot_trigger
27
+ _reviewer_trigger = hook_module._reviewer_trigger
28
+
29
+ _SENTINEL_MARKER = hook_module.GATE_SENTINEL_MARKER
30
+ _AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME = hook_module.AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME
31
+
32
+ _COPILOT_TRIGGER_COMMAND = (
33
+ f"{_SENTINEL_MARKER} gh api repos/o/r/pulls/1/requested_reviewers "
34
+ f'-f "reviewers[]=copilot-pull-request-reviewer[bot]"'
35
+ )
36
+ _BUGBOT_TRIGGER_COMMAND = f'{_SENTINEL_MARKER} python post_fix_reply.py --body "bugbot run"'
37
+
38
+
39
+ def _write_fake_availability_script(tmp_path: pathlib.Path, exit_code: int) -> pathlib.Path:
40
+ fake_script_path = tmp_path / "fake_reviewer_availability.py"
41
+ fake_script_path.write_text(f"import sys\nsys.exit({exit_code})\n", encoding="utf-8")
42
+ return fake_script_path
43
+
44
+
45
+ def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
46
+ captured_stdout = io.StringIO()
47
+ captured_stderr = io.StringIO()
48
+ exit_code = 0
49
+ sys.stdin = io.StringIO(stdin_text)
50
+ try:
51
+ with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr):
52
+ try:
53
+ hook_module.main()
54
+ except SystemExit as exit_signal:
55
+ exit_code = exit_signal.code if isinstance(exit_signal.code, int) else 0
56
+ finally:
57
+ sys.stdin = sys.__stdin__
58
+ return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
59
+
60
+
61
+ def _run_hook(hook_input: dict) -> tuple[str, int]:
62
+ stdout_text, _stderr_text, exit_code = _run_hook_with_stdin_text(json.dumps(hook_input))
63
+ return stdout_text, exit_code
64
+
65
+
66
+ def test_matches_copilot_trigger_true_for_request_reviewers_command() -> None:
67
+ assert _matches_copilot_trigger(_COPILOT_TRIGGER_COMMAND)
68
+
69
+
70
+ def test_matches_copilot_trigger_false_for_unrelated_command() -> None:
71
+ assert not _matches_copilot_trigger("gh pr list")
72
+
73
+
74
+ def test_matches_bugbot_trigger_true_for_rerun_comment_command() -> None:
75
+ assert _matches_bugbot_trigger(_BUGBOT_TRIGGER_COMMAND)
76
+
77
+
78
+ def test_matches_bugbot_trigger_false_for_unrelated_command() -> None:
79
+ assert not _matches_bugbot_trigger("python post_fix_reply.py --body 'looks good'")
80
+
81
+
82
+ def test_reviewer_trigger_returns_none_for_unrecognized_command() -> None:
83
+ assert _reviewer_trigger("gh pr list") is None
84
+
85
+
86
+ def test_main_allows_copilot_shaped_command_with_no_sentinel_marker(
87
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
88
+ ) -> None:
89
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=3)
90
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
91
+ stdout_text, exit_code = _run_hook(
92
+ {
93
+ "tool_name": "Bash",
94
+ "tool_input": {
95
+ "command": (
96
+ "gh api repos/o/r/pulls/1/requested_reviewers "
97
+ '-f "reviewers[]=copilot-pull-request-reviewer[bot]"'
98
+ )
99
+ },
100
+ }
101
+ )
102
+ assert exit_code == 0
103
+ assert stdout_text == ""
104
+
105
+
106
+ def test_main_allows_copilot_trigger_when_available(
107
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
108
+ ) -> None:
109
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=0)
110
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
111
+ stdout_text, exit_code = _run_hook(
112
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
113
+ )
114
+ assert exit_code == 0
115
+ assert stdout_text == ""
116
+
117
+
118
+ def test_main_denies_copilot_trigger_when_down(
119
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
120
+ ) -> None:
121
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=3)
122
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
123
+ stdout_text, exit_code = _run_hook(
124
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
125
+ )
126
+ assert exit_code == 0
127
+ response_payload = json.loads(stdout_text)
128
+ decision_block = response_payload["hookSpecificOutput"]
129
+ assert decision_block["permissionDecision"] == "deny"
130
+ assert "GitHub Copilot" in decision_block["permissionDecisionReason"]
131
+
132
+
133
+ def test_main_allows_copilot_trigger_on_unexpected_exit_code(
134
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
135
+ ) -> None:
136
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=2)
137
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
138
+ stdout_text, exit_code = _run_hook(
139
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
140
+ )
141
+ assert exit_code == 0
142
+ assert stdout_text == ""
143
+
144
+
145
+ def test_main_allows_copilot_trigger_when_availability_script_crashes(
146
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
147
+ ) -> None:
148
+ fake_script_path = tmp_path / "fake_reviewer_availability.py"
149
+ fake_script_path.write_text(
150
+ "import module_that_does_not_exist_anywhere\n", encoding="utf-8"
151
+ )
152
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
153
+ stdout_text, exit_code = _run_hook(
154
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
155
+ )
156
+ assert exit_code == 0
157
+ assert stdout_text == ""
158
+
159
+
160
+ def test_main_allows_bugbot_trigger_when_available(
161
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
162
+ ) -> None:
163
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=0)
164
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
165
+ stdout_text, exit_code = _run_hook(
166
+ {"tool_name": "Bash", "tool_input": {"command": _BUGBOT_TRIGGER_COMMAND}}
167
+ )
168
+ assert exit_code == 0
169
+ assert stdout_text == ""
170
+
171
+
172
+ def test_main_denies_bugbot_trigger_when_opted_out(
173
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
174
+ ) -> None:
175
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=3)
176
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
177
+ stdout_text, exit_code = _run_hook(
178
+ {"tool_name": "Bash", "tool_input": {"command": _BUGBOT_TRIGGER_COMMAND}}
179
+ )
180
+ assert exit_code == 0
181
+ response_payload = json.loads(stdout_text)
182
+ decision_block = response_payload["hookSpecificOutput"]
183
+ assert decision_block["permissionDecision"] == "deny"
184
+ assert "Cursor Bugbot" in decision_block["permissionDecisionReason"]
185
+
186
+
187
+ def test_main_allows_marker_command_with_no_recognized_trigger(
188
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
189
+ ) -> None:
190
+ fake_script_path = _write_fake_availability_script(tmp_path, exit_code=3)
191
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(fake_script_path))
192
+ stdout_text, exit_code = _run_hook(
193
+ {"tool_name": "Bash", "tool_input": {"command": f"{_SENTINEL_MARKER} gh pr list"}}
194
+ )
195
+ assert exit_code == 0
196
+ assert stdout_text == ""
197
+
198
+
199
+ def test_main_allows_when_availability_script_missing(
200
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
201
+ ) -> None:
202
+ missing_script_path = tmp_path / "does_not_exist.py"
203
+ monkeypatch.setenv(_AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, str(missing_script_path))
204
+ stdout_text, exit_code = _run_hook(
205
+ {"tool_name": "Bash", "tool_input": {"command": _COPILOT_TRIGGER_COMMAND}}
206
+ )
207
+ assert exit_code == 0
208
+ assert stdout_text == ""
209
+
210
+
211
+ def test_main_allows_non_bash_tool() -> None:
212
+ stdout_text, exit_code = _run_hook(
213
+ {"tool_name": "Write", "tool_input": {"content": _COPILOT_TRIGGER_COMMAND}}
214
+ )
215
+ assert exit_code == 0
216
+ assert stdout_text == ""
217
+
218
+
219
+ def test_main_with_empty_stdin_exits_silently() -> None:
220
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("")
221
+ assert exit_code == 0
222
+ assert stdout_text == ""
223
+ assert stderr_text == ""
224
+
225
+
226
+ def test_main_with_invalid_json_stdin_exits_silently() -> None:
227
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("{broken")
228
+ assert exit_code == 0
229
+ assert stdout_text == ""
230
+ assert stderr_text == ""
@@ -10,11 +10,11 @@ each real hook script through its production ``__main__`` stdin path over a
10
10
  corpus that pins those fail-soft edges plus a representative allow payload, so a
11
11
  conversion that changes any decision is caught.
12
12
 
13
- The deterministic deny payloads for the two Write/Edit blockers whose triggers
14
- need no filesystem or state setup (``md_to_html_blocker``,
15
- ``open_questions_in_plans_blocker``) are exercised here too; each remaining
16
- hook's full deny coverage stays in its own suite, which also drives the real
17
- ``main()`` and so re-proves the decision after the parser swap.
13
+ The deterministic deny payload for the Write/Edit blocker whose trigger
14
+ needs no filesystem or state setup (``open_questions_in_plans_blocker``) is
15
+ exercised here too; each remaining hook's full deny coverage stays in its own
16
+ suite, which also drives the real ``main()`` and so re-proves the decision
17
+ after the parser swap.
18
18
  """
19
19
 
20
20
  import json
@@ -28,7 +28,6 @@ import pytest
28
28
  _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
29
29
 
30
30
  ALL_CONVERTED_HOOK_FILENAMES = (
31
- "md_to_html_blocker.py",
32
31
  "open_questions_in_plans_blocker.py",
33
32
  "claude_md_orphan_file_blocker.py",
34
33
  "pr_converge_bugteam_enforcer.py",
@@ -83,30 +82,6 @@ def test_fail_soft_payload_exits_zero_with_no_decision(hook_filename: str, stdin
83
82
  )
84
83
 
85
84
 
86
- def test_md_to_html_blocker_still_denies_relative_markdown_write() -> None:
87
- payload = json.dumps(
88
- {
89
- "tool_name": "Write",
90
- "tool_input": {"file_path": "notes/topic.md", "content": "# Topic"},
91
- }
92
- )
93
- completed = _run_hook_script("md_to_html_blocker.py", payload)
94
- assert completed.returncode == 0
95
- assert _decision_from_stdout(completed) == "deny"
96
-
97
-
98
- def test_md_to_html_blocker_still_allows_non_markdown_write() -> None:
99
- payload = json.dumps(
100
- {
101
- "tool_name": "Write",
102
- "tool_input": {"file_path": "notes/topic.txt", "content": "plain"},
103
- }
104
- )
105
- completed = _run_hook_script("md_to_html_blocker.py", payload)
106
- assert completed.returncode == 0
107
- assert _decision_from_stdout(completed) is None
108
-
109
-
110
85
  def test_open_questions_blocker_still_denies_plan_with_open_questions(
111
86
  tmp_path: Path,
112
87
  ) -> None: