claude-dev-env 1.83.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 (165) 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/reviews_disabled.py +2 -0
  5. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  6. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  7. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  9. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  10. package/agents/clean-coder.md +3 -0
  11. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  12. package/bin/install.mjs +16 -8
  13. package/bin/install.test.mjs +17 -6
  14. package/commands/CLAUDE.md +0 -1
  15. package/docs/CODE_RULES.md +1 -1
  16. package/hooks/blocking/CLAUDE.md +0 -4
  17. package/hooks/blocking/code_rules_constants_config.py +5 -0
  18. package/hooks/blocking/code_rules_docstrings.py +116 -6
  19. package/hooks/blocking/code_rules_enforcer.py +4 -0
  20. package/hooks/blocking/code_rules_magic_values.py +5 -0
  21. package/hooks/blocking/code_rules_naming_collection.py +11 -6
  22. package/hooks/blocking/code_rules_shared.py +34 -0
  23. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  24. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  25. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  26. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  27. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  28. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  29. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  30. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  31. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  32. package/hooks/hooks_constants/CLAUDE.md +0 -3
  33. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  34. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  35. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  37. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  38. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  39. package/hooks/workflow/CLAUDE.md +2 -8
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -1
  42. package/rules/plain-illustrative-docstrings.md +35 -1
  43. package/skills/CLAUDE.md +6 -1
  44. package/skills/autoconverge/SKILL.md +38 -57
  45. package/skills/autoconverge/reference/closing-report.md +6 -6
  46. package/skills/autoconverge/reference/convergence.md +17 -15
  47. package/skills/autoconverge/reference/gotchas.md +6 -3
  48. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  49. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  50. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  51. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  52. package/skills/autoconverge/workflow/converge.mjs +70 -73
  53. package/skills/autoconverge/workflow/render_report.py +7 -11
  54. package/skills/bugteam/CLAUDE.md +1 -1
  55. package/skills/bugteam/PROMPTS.md +7 -6
  56. package/skills/bugteam/SKILL.md +25 -72
  57. package/skills/bugteam/reference/CLAUDE.md +1 -3
  58. package/skills/bugteam/reference/README.md +1 -1
  59. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  60. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  61. package/skills/bugteam/reference/team-setup.md +8 -7
  62. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  63. package/skills/bugteam/scripts/README.md +0 -4
  64. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  65. package/skills/code/SKILL.md +2 -0
  66. package/skills/copilot-review/CLAUDE.md +1 -1
  67. package/skills/copilot-review/SKILL.md +25 -23
  68. package/skills/findbugs/CLAUDE.md +2 -2
  69. package/skills/findbugs/SKILL.md +22 -83
  70. package/skills/fixbugs/SKILL.md +2 -4
  71. package/skills/log-audit/CLAUDE.md +20 -0
  72. package/skills/log-audit/SKILL.md +68 -0
  73. package/skills/log-audit/reference/CLAUDE.md +9 -0
  74. package/skills/log-audit/reference/charter.md +52 -0
  75. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  76. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  77. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  78. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  79. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  80. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  81. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  82. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  83. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  84. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  85. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  86. package/skills/monitor-open-prs/SKILL.md +2 -2
  87. package/skills/post-audit-findings/SKILL.md +84 -0
  88. package/skills/pr-converge/CLAUDE.md +2 -0
  89. package/skills/pr-converge/SKILL.md +72 -59
  90. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  91. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  92. package/skills/pr-converge/reference/examples.md +5 -5
  93. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  94. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  95. package/skills/pr-converge/reference/per-tick.md +24 -45
  96. package/skills/pr-converge/reference/state-schema.md +15 -0
  97. package/skills/pr-converge/scripts/README.md +3 -5
  98. package/skills/pr-fix-protocol/SKILL.md +70 -0
  99. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  100. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  101. package/skills/pr-scope-resolve/SKILL.md +48 -0
  102. package/skills/qbug/CLAUDE.md +4 -4
  103. package/skills/qbug/SKILL.md +46 -144
  104. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  105. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  106. package/skills/reviewer-gates/SKILL.md +96 -0
  107. package/skills/session-log/CLAUDE.md +7 -7
  108. package/skills/session-log/SKILL.md +27 -44
  109. package/skills/test_markdown_link_integrity.py +103 -0
  110. package/commands/doc-gist.md +0 -16
  111. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  112. package/hooks/blocking/conftest.py +0 -30
  113. package/hooks/blocking/md_path_exemptions.py +0 -224
  114. package/hooks/blocking/md_to_html_blocker.py +0 -155
  115. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  116. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  117. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  118. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  119. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  120. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  121. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  122. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  123. package/hooks/workflow/md_to_html_companion.py +0 -358
  124. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  125. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  126. package/skills/bugteam/reference/audit-contract.md +0 -163
  127. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  128. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  129. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  130. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  131. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  132. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  133. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  134. package/skills/doc-gist/CLAUDE.md +0 -25
  135. package/skills/doc-gist/SKILL.md +0 -97
  136. package/skills/doc-gist/references/CLAUDE.md +0 -9
  137. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  138. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  139. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  140. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  141. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  142. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  143. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  144. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  145. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  146. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  147. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  148. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  149. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  150. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  151. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  152. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  153. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  154. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  155. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  156. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  157. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  158. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  159. package/skills/doc-gist/references/examples/README.md +0 -5
  160. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  161. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  162. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  163. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  164. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  165. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -177,6 +177,39 @@ def test_advisory_should_flag_outer_constants_after_nested_def() -> None:
177
177
  )
178
178
 
179
179
 
180
+ def test_scan_function_body_constants_skips_docstring_contrast_line() -> None:
181
+ source = (
182
+ "def demo(label):\n"
183
+ ' """Flag a boolean assignment whose target and callee clash.\n'
184
+ "\n"
185
+ " ::\n"
186
+ "\n"
187
+ " FLAG: ready = make_stale(...)\n"
188
+ ' """\n'
189
+ " return label\n"
190
+ )
191
+ advisory_issues = code_rules_enforcer._scan_function_body_constants(source)
192
+ assert advisory_issues == [], (
193
+ f"Expected no function-local constant issue on an illustrative row, got: {advisory_issues}"
194
+ )
195
+
196
+
197
+ def test_scan_function_body_constants_still_flags_real_function_body_constant() -> None:
198
+ source = (
199
+ "def demo(label):\n"
200
+ ' """Flag a boolean assignment whose target and callee clash.\n'
201
+ "\n"
202
+ " FLAG: ready = make_stale(...)\n"
203
+ ' """\n'
204
+ " SOME_CONST = 3\n"
205
+ " return SOME_CONST\n"
206
+ )
207
+ advisory_issues = code_rules_enforcer._scan_function_body_constants(source)
208
+ assert any("SOME_CONST" in issue for issue in advisory_issues), (
209
+ f"Expected the real function-body constant SOME_CONST to stay flagged, got: {advisory_issues}"
210
+ )
211
+
212
+
180
213
  def test_check_constants_outside_config_flags_annotated_assignment() -> None:
181
214
  source = "TEXT_FILE_ENCODING: str = 'utf-8'\n"
182
215
  issues = code_rules_enforcer.check_constants_outside_config(
@@ -18,14 +18,37 @@ from code_rules_enforcer import ( # noqa: E402
18
18
  )
19
19
  from code_rules_shared import ( # noqa: E402
20
20
  changed_line_numbers,
21
+ docstring_line_numbers,
21
22
  )
22
23
 
23
24
  code_rules_enforcer = SimpleNamespace(
24
25
  changed_line_numbers=changed_line_numbers,
26
+ docstring_line_numbers=docstring_line_numbers,
25
27
  prior_and_post_edit_content=prior_and_post_edit_content,
26
28
  )
27
29
 
28
30
 
31
+ def test_docstring_line_numbers_spans_a_multi_line_function_docstring() -> None:
32
+ source = (
33
+ "def describe():\n"
34
+ ' """First row.\n'
35
+ "\n"
36
+ " Third row.\n"
37
+ ' """\n'
38
+ " return 1\n"
39
+ )
40
+ assert code_rules_enforcer.docstring_line_numbers(source) == {2, 3, 4, 5}
41
+
42
+
43
+ def test_docstring_line_numbers_excludes_real_code_lines() -> None:
44
+ source = "def compute():\n counter = 41\n return counter\n"
45
+ assert code_rules_enforcer.docstring_line_numbers(source) == set()
46
+
47
+
48
+ def test_docstring_line_numbers_returns_empty_set_on_syntax_error() -> None:
49
+ assert code_rules_enforcer.docstring_line_numbers("def broken(:\n") == set()
50
+
51
+
29
52
  def test_readable_prior_yields_consistent_prior_and_reconstruction(tmp_path) -> None:
30
53
  """When the prior reads cleanly, the helper returns the same prior content it
31
54
  reconstructed the post-edit view from, so the two never diverge across two
@@ -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"]
@@ -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:
@@ -24,7 +24,6 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
24
24
  | `dead_dataclass_field_constants.py` | Patterns for detecting unused dataclass fields |
25
25
  | `dead_module_constant_constants.py` | Patterns for detecting unexported `UPPER_SNAKE` constants in `*_constants.py` modules |
26
26
  | `destructive_command_segment_constants.py` | The list of destructive shell command patterns the blocker matches |
27
- | `doc_gist_auto_publish_constants.py` | Sentinel marker and URL patterns for the doc-gist auto-publish hook |
28
27
  | `docstring_rule_gate_count_blocker_constants.py` | Target rule basename, spelled-out-number lookup, count-clause and `check_*` validator patterns, and block-message text for the docstring-rule gate-count staleness blocker |
29
28
  | `duplicate_function_body_constants.py` | Hashing and comparison config for the duplicate-body check |
30
29
  | `duplicate_rmtree_helper_blocker_constants.py` | Sanctioned Windows-safe rmtree helper names, the definition pattern, and the exempt-path set for the duplicate-rmtree-helper blocker |
@@ -34,10 +33,8 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
34
33
  | `hook_block_logger.py` | `log_hook_block()` — shared fail-safe logger every blocking hook calls to append a JSON record of each block decision to `~/.claude/logs/hook-blocks.log` |
35
34
  | `hook_log_extractor_constants.py` | Neon table name, offset state file path, timeouts, and outcome-type mapping for the hook-log extractor |
36
35
  | `hook_prose_detector_consistency_constants.py` | Trigger patterns and corrective messages for the hook-prose consistency checker |
37
- | `html_companion_constants.py` | Blocked URL schemes and other config for the `.md`-to-`.html` companion hook |
38
36
  | `inline_tuple_string_magic_constants.py` | Patterns for detecting magic strings in inline tuple literals |
39
37
  | `js_conventions_constants.py` | Banned identifier set, boolean-prefix pattern, and declaration/JSDoc patterns for the JavaScript convention checks |
40
- | `md_to_html_blocker_constants.py` | Path exemptions and trigger patterns for the markdown-to-html blocker |
41
38
  | `messages.py` | Short user-facing notice strings shown when a Stop hook redirects agent behavior |
42
39
  | `multi_edit_reconstruction.py` | `apply_edits()` / `edits_for_tool()` — shared helpers that reconstruct the post-edit content of an Edit or MultiEdit, imported by the blockers that judge post-edit content |
43
40
  | `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
@@ -218,6 +218,9 @@ DOCSTRING_RUNON_SENTENCE_WORD_LIMIT: int = 30
218
218
  MAX_DOCSTRING_RUNON_SENTENCE_ISSUES: int = 5
219
219
  ALL_DOCSTRING_RUNON_JOINER_MARKERS: tuple[str, ...] = ("—", " -- ", ";")
220
220
  DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"(?<=[.!?])\s+")
221
+ DOCSTRING_NARRATIVE_PROSE_LINE_LIMIT: int = 6
222
+ DOCSTRING_NARRATIVE_LINE_JOIN_SEPARATOR: str = " "
223
+ MAX_DOCSTRING_PROSE_WALL_ISSUES: int = 5
221
224
 
222
225
  ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
223
226
  "no consumer reads",
@@ -22,8 +22,8 @@ ALL_CONFIG_DIRECTORY_NAMES = frozenset(
22
22
  "pr_converge_skill_constants",
23
23
  "pr_converge_scripts_constants",
24
24
  "bugteam_scripts_constants",
25
- "doc_gist_scripts_constants",
26
25
  "implement_scripts_constants",
26
+ "log_audit_constants",
27
27
  "dev_env_scripts_constants",
28
28
  }
29
29
  )
@@ -19,7 +19,6 @@ ALL_EXEMPT_PATH_FRAGMENTS: tuple[str, ...] = (
19
19
  "windows_safe_rmtree.py",
20
20
  "windows_filesystem.py",
21
21
  "session_env_cleanup.py",
22
- "_md_to_html_blocker_test_support.py",
23
22
  "teardown_worktrees.py",
24
23
  )
25
24
 
@@ -62,9 +62,4 @@ ALL_POST_HOSTED_HOOK_ENTRIES: tuple[PostHostedHookEntry, ...] = (
62
62
  script_relative_path="workflow/auto_formatter.py",
63
63
  is_blocking=False,
64
64
  ),
65
- PostHostedHookEntry(
66
- script_relative_path="workflow/doc_gist_auto_publish.py",
67
- extra_argument_relative_paths=(PLUGIN_ROOT_PLACEHOLDER,),
68
- is_blocking=False,
69
- ),
70
65
  )
@@ -168,7 +168,7 @@ class PostDispatcherDecision:
168
168
  all_block_reasons: All block reasons from blocking hooks, in run order.
169
169
  all_non_block_stdout: Stdout from hooks that did not emit a block
170
170
  decision, concatenated in run order. Preserved so informational
171
- output (such as the doc-gist htmlpreview URL) reaches the harness
171
+ output (such as an auto-formatter notice) reaches the harness
172
172
  on both the allow and block paths.
173
173
  """
174
174
 
@@ -5,11 +5,11 @@ hook as its own subprocess (the production path), records each hook's block
5
5
  decision, computes the expected aggregate, then runs the dispatcher on the same
6
6
  payload and asserts an equal block-or-allow decision and the union of reasons.
7
7
 
8
- Three focused tests pin the side-effecting behavior the dispatcher must not
8
+ Two focused tests pin the side-effecting behavior the dispatcher must not
9
9
  change: the formatter formats only on a Write of a file git does not yet track
10
- and never blocks; the type-checker still blocks on a real type error when run
11
- through the dispatcher; and non-block stdout from side-effect hooks (such as
12
- the doc-gist htmlpreview URL) survives on both the allow and block paths.
10
+ and never blocks; and the type-checker still blocks on a real type error when
11
+ run through the dispatcher. Two more tests pin that non-block stdout from a
12
+ side-effect hook survives on both the allow and block paths.
13
13
 
14
14
  Crash and early-exit tests exercise the aggregator directly: an early hook
15
15
  crash before mypy does not drop mypy's block, a non-blocking hook crash leaves
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import importlib.util
22
22
  import json
23
+ import os
23
24
  import subprocess
24
25
  import sys
25
26
  from pathlib import Path
@@ -33,9 +34,6 @@ _VALIDATION_DIR_STR = str(Path(__file__).resolve().parent)
33
34
  if _VALIDATION_DIR_STR not in sys.path:
34
35
  sys.path.insert(0, _VALIDATION_DIR_STR)
35
36
 
36
- from hooks_constants.doc_gist_auto_publish_constants import ( # noqa: E402, I001
37
- HOOK_SUBPROCESS_TIMEOUT_SECONDS as DOC_GIST_TIMEOUT_SECONDS,
38
- )
39
37
  from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402, I001
40
38
  ALL_POST_HOSTED_HOOK_ENTRIES,
41
39
  BLOCK_DECISION,
@@ -95,6 +93,26 @@ def _resolve_extra_arguments(each_entry: PostHostedHookEntry) -> list[str]:
95
93
  return resolved_arguments
96
94
 
97
95
 
96
+ def _environment_without_git_hook_variables() -> dict[str, str]:
97
+ """Return the process environment with git's hook-scoped variables removed.
98
+
99
+ Git exports variables such as GIT_DIR and GIT_INDEX_FILE to hook
100
+ processes. When this suite runs under a git hook (the pre-commit gate
101
+ replays staged test files), those variables leak into the hook
102
+ subprocesses and point their project discovery at the wrong checkout.
103
+ Stripping them restores the cwd-based discovery the hooks use in
104
+ production.
105
+
106
+ Returns:
107
+ A copy of the environment without any GIT_-prefixed variable.
108
+ """
109
+ return {
110
+ each_name: each_value
111
+ for each_name, each_value in os.environ.items()
112
+ if not each_name.startswith("GIT_")
113
+ }
114
+
115
+
98
116
  def _run_hook_subprocess(
99
117
  each_entry: PostHostedHookEntry, payload_text: str
100
118
  ) -> subprocess.CompletedProcess[str]:
@@ -116,6 +134,7 @@ def _run_hook_subprocess(
116
134
  capture_output=True,
117
135
  text=True,
118
136
  encoding="utf-8",
137
+ env=_environment_without_git_hook_variables(),
119
138
  )
120
139
 
121
140
 
@@ -135,6 +154,7 @@ def _run_dispatcher(payload_text: str) -> subprocess.CompletedProcess[str]:
135
154
  capture_output=True,
136
155
  text=True,
137
156
  encoding="utf-8",
157
+ env=_environment_without_git_hook_variables(),
138
158
  )
139
159
 
140
160
 
@@ -267,10 +287,10 @@ def _post_tool_use_dispatcher_harness_timeout_seconds() -> int:
267
287
  def _hosted_hooks_worst_case_internal_seconds() -> int:
268
288
  """Return the summed worst-case internal subprocess budget of the hosted hooks.
269
289
 
270
- The three hosted hooks run sequentially in one dispatcher process. The
271
- type-checker, the formatter (its slower JS path), and the doc-gist publisher
272
- each carry their own internal subprocess timeout; their sum is the dispatcher
273
- process's worst-case runtime when each hook runs to its own ceiling.
290
+ The hosted hooks run sequentially in one dispatcher process. The
291
+ type-checker and the formatter (its slower JS path) each carry their own
292
+ internal subprocess timeout; their sum is the dispatcher process's
293
+ worst-case runtime when each hook runs to its own ceiling.
274
294
 
275
295
  Returns:
276
296
  The summed worst-case internal subprocess seconds across the hosted hooks.
@@ -281,21 +301,17 @@ def _hosted_hooks_worst_case_internal_seconds() -> int:
281
301
  auto_formatter.PYTHON_FORMAT_TIMEOUT_SECONDS,
282
302
  auto_formatter.JS_FORMAT_TIMEOUT_SECONDS,
283
303
  )
284
- return (
285
- mypy_validator.MYPY_TIMEOUT_SECONDS
286
- + slowest_formatter_seconds
287
- + DOC_GIST_TIMEOUT_SECONDS
288
- )
304
+ return mypy_validator.MYPY_TIMEOUT_SECONDS + slowest_formatter_seconds
289
305
 
290
306
 
291
307
  def test_dispatcher_harness_timeout_clears_summed_hosted_hook_budgets() -> None:
292
308
  """The dispatcher harness timeout exceeds the summed worst-case hosted-hook budget.
293
309
 
294
- The three hosted hooks run sequentially under one harness timeout. When the
310
+ The hosted hooks run sequentially under one harness timeout. When the
295
311
  harness timeout does not clear their summed worst-case internal budgets, a
296
312
  near-full slow type-check run consumes the budget and the harness kills the
297
- dispatcher before the formatter and doc-gist publisher get their turn. The
298
- harness timeout must exceed the sum so every hosted hook runs to completion.
313
+ dispatcher before the formatter gets its turn. The harness timeout must
314
+ exceed the sum so every hosted hook runs to completion.
299
315
  """
300
316
  harness_timeout_seconds = _post_tool_use_dispatcher_harness_timeout_seconds()
301
317
  summed_internal_seconds = _hosted_hooks_worst_case_internal_seconds()
@@ -303,7 +319,7 @@ def test_dispatcher_harness_timeout_clears_summed_hosted_hook_budgets() -> None:
303
319
  "PostToolUse dispatcher harness timeout "
304
320
  f"({harness_timeout_seconds}s) must exceed the summed worst-case internal "
305
321
  f"budgets of the hosted hooks ({summed_internal_seconds}s), or a slow "
306
- "type-check run starves the formatter and doc-gist publisher."
322
+ "type-check run starves the formatter."
307
323
  )
308
324
 
309
325
 
@@ -315,14 +331,14 @@ def test_clean_edit_of_plain_text_allows() -> None:
315
331
 
316
332
 
317
333
  def test_clean_write_of_nonexistent_path_allows() -> None:
318
- """Dispatcher allows a Write whose path does not exist (mypy and doc-gist skip)."""
334
+ """Dispatcher allows a Write whose path does not exist (mypy skips)."""
319
335
  missing_path = str(_VALIDATION_DIR / "does_not_exist_dispatcher_probe.txt")
320
336
  payload_text = _write_payload(missing_path, "hello world\n")
321
337
  _assert_dispatcher_matches_individual_hooks(payload_text)
322
338
 
323
339
 
324
- def test_edit_of_non_html_skips_doc_gist_allows() -> None:
325
- """Dispatcher allows an Edit of an existing non-HTML file with no sentinel."""
340
+ def test_edit_of_non_html_allows() -> None:
341
+ """Dispatcher allows an Edit of an existing non-HTML file."""
326
342
  existing_path = str(Path(__file__).resolve())
327
343
  payload_text = _edit_payload(existing_path, "old", "new")
328
344
  _assert_dispatcher_matches_individual_hooks(payload_text)
@@ -433,10 +449,10 @@ def test_formatter_formats_only_untracked_write_and_never_blocks(tmp_path: Path)
433
449
  def test_non_block_stdout_preserved_in_aggregator_allow_path() -> None:
434
450
  """Aggregator preserves non-block hook stdout on the allow path.
435
451
 
436
- A side-effect hook (such as doc_gist_auto_publish) writes informational
437
- text to stdout without emitting a block decision. The aggregator must carry
438
- that text into all_non_block_stdout so the dispatcher can write it to the
439
- real stdout on the allow path.
452
+ A side-effect hook writes informational text to stdout without emitting a
453
+ block decision. The aggregator must carry that text into
454
+ all_non_block_stdout so the dispatcher can write it to the real stdout on
455
+ the allow path.
440
456
  """
441
457
  informational_text = "https://htmlpreview.github.io/?https://gist.github.com/abc/123"
442
458
  all_results = [
@@ -459,7 +475,7 @@ def test_non_block_stdout_preserved_in_aggregator_allow_path() -> None:
459
475
  def test_non_block_stdout_preserved_in_aggregator_block_path() -> None:
460
476
  """Aggregator preserves non-block hook stdout even when another hook blocks.
461
477
 
462
- When mypy_validator blocks and doc_gist_auto_publish wrote informational
478
+ When mypy_validator blocks and a side-effect hook wrote informational
463
479
  text, both the block reason and the informational text survive in the
464
480
  aggregated decision so _emit_block_decision can forward both to stdout.
465
481
  """
@@ -512,9 +528,9 @@ def test_early_hook_crash_does_not_drop_later_blocking_hook_block() -> None:
512
528
  def test_non_blocking_hook_crash_leaves_decision_allow() -> None:
513
529
  """A crash in a non-blocking hook does not change an allow to a block.
514
530
 
515
- A side-effect hook such as auto_formatter or doc_gist_auto_publish carries
516
- is_blocking=False. Its crash must not surface a blocking signal — the
517
- aggregated decision stays allow.
531
+ A side-effect hook such as auto_formatter carries is_blocking=False. Its
532
+ crash must not surface a blocking signal — the aggregated decision stays
533
+ allow.
518
534
  """
519
535
  all_results = [
520
536
  PostHostedHookResult(captured_stdout="", did_crash=True, is_blocking=False),
@@ -566,12 +582,12 @@ def test_block_path_emits_single_parseable_json_object_on_stdout() -> None:
566
582
  )
567
583
  try:
568
584
  payload_text = _write_payload(str(type_error_file), type_error_file.read_text())
569
- direct_block, _direct_reason = _parse_block_decision(
570
- _run_hook_subprocess(ALL_POST_HOSTED_HOOK_ENTRIES[0], payload_text)
571
- )
585
+ completed_direct = _run_hook_subprocess(ALL_POST_HOSTED_HOOK_ENTRIES[0], payload_text)
586
+ direct_block, _direct_reason = _parse_block_decision(completed_direct)
572
587
  assert direct_block, (
573
588
  "Precondition failed: mypy_validator did not block a real type error directly. "
574
- "Is mypy installed and the file inside the git project?"
589
+ "Is mypy installed and the file inside the git project?\n"
590
+ f"stdout: {completed_direct.stdout!r}\nstderr: {completed_direct.stderr!r}"
575
591
  )
576
592
 
577
593
  dispatcher_result = _run_dispatcher(payload_text)