claude-dev-env 8.80.0 → 8.82.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 (27) hide show
  1. package/hooks/blocking/final_reply_gate.py +77 -0
  2. package/hooks/blocking/pr_lifecycle_skill_gate.py +13 -3
  3. package/hooks/blocking/pull_request_proof.py +42 -13
  4. package/hooks/blocking/pull_request_proof_edit_gate.py +180 -0
  5. package/hooks/blocking/reply_length_gate.py +114 -17
  6. package/hooks/blocking/test_final_reply_gate.py +94 -0
  7. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
  8. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -0
  9. package/hooks/blocking/test_pull_request_proof.py +7 -0
  10. package/hooks/blocking/test_pull_request_proof_edit_gate.py +199 -0
  11. package/hooks/blocking/test_reply_length_gate.py +208 -0
  12. package/hooks/features/conduct-gates.md +7 -3
  13. package/hooks/features/write-edit-blocking.md +4 -1
  14. package/hooks/hooks.json +13 -2
  15. package/hooks/hooks_constants/final_reply_gate_constants.py +10 -0
  16. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  17. package/hooks/hooks_constants/pull_request_proof_constants.py +22 -3
  18. package/hooks/hooks_constants/reply_length_gate_constants.py +56 -1
  19. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +9 -6
  20. package/hooks/hooks_constants/test_reply_length_gate_constants.py +2 -2
  21. package/hooks/hooks_constants/visual_reply_rules_constants.py +2 -0
  22. package/hooks/test_visual_reply_rules.py +21 -0
  23. package/hooks/visual_reply_rules.py +37 -5
  24. package/package.json +1 -1
  25. package/scripts/policy_lint/adapter_configuration.py +1 -0
  26. package/scripts/policy_lint/config/constants.py +1 -0
  27. package/scripts/tests/test_adapter_configuration.py +8 -0
@@ -0,0 +1,94 @@
1
+ import io
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import final_reply_gate
8
+
9
+ GUESSED_REPLY = "My guess is one to two hours if those builds pass first time."
10
+ UNCONFIRMED_REPLY = "Merge queue: I didn\u2019t confirm this one."
11
+ MEASURED_REPLY = "The last queue build took 39 minutes, run 18239174012."
12
+
13
+
14
+ def run_gate(
15
+ monkeypatch: pytest.MonkeyPatch,
16
+ capsys: pytest.CaptureFixture[str],
17
+ reply_text: str,
18
+ transcript_path: Path | None = None,
19
+ ) -> tuple[int, str]:
20
+ hook_input: dict[str, object] = {
21
+ "hook_event_name": "Stop",
22
+ "stop_hook_active": False,
23
+ "last_assistant_message": reply_text,
24
+ }
25
+ if transcript_path is not None:
26
+ hook_input["transcript_path"] = str(transcript_path)
27
+ monkeypatch.setattr(
28
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
29
+ )
30
+ exit_code = final_reply_gate.main()
31
+ return exit_code, capsys.readouterr().out
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
36
+ monkeypatch.setenv("HOME", str(tmp_path))
37
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
38
+ monkeypatch.delenv("CLAUDE_REPLY_BANNED_WORDS_PATH", raising=False)
39
+
40
+
41
+ def write_tool_result_transcript(transcript_path: Path, result_text: str) -> Path:
42
+ tool_result = {"type": "tool_result", "tool_use_id": "t1", "content": result_text}
43
+ transcript_path.write_text(
44
+ json.dumps({"type": "user", "message": {"content": [tool_result]}}), encoding="utf-8"
45
+ )
46
+ return transcript_path
47
+
48
+
49
+ @pytest.mark.parametrize(
50
+ ("reply_text", "quoted_text"),
51
+ [
52
+ (GUESSED_REPLY, 'Banned word "guess"'),
53
+ (UNCONFIRMED_REPLY, 'No tool validated this: "Merge queue: I didn\u2019t confirm this one."'),
54
+ ("It should take about 40 minutes.", "It should take about 40 minutes."),
55
+ ],
56
+ )
57
+ def test_should_block_a_closing_reply_that_guesses(
58
+ monkeypatch: pytest.MonkeyPatch,
59
+ capsys: pytest.CaptureFixture[str],
60
+ reply_text: str,
61
+ quoted_text: str,
62
+ ) -> None:
63
+ exit_code, stdout_text = run_gate(monkeypatch, capsys, reply_text)
64
+ decision = json.loads(stdout_text)
65
+ assert exit_code == 0
66
+ assert decision["decision"] == "block"
67
+ assert quoted_text in decision["reason"]
68
+ assert decision["reason"].endswith("Rewrite the reply without it.")
69
+
70
+
71
+ def test_should_block_a_run_no_tool_result_shows(
72
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
73
+ ) -> None:
74
+ transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "run 18239170000 ok")
75
+ _, stdout_text = run_gate(monkeypatch, capsys, MEASURED_REPLY, transcript_path)
76
+ assert 'cites "run 18239174012"' in json.loads(stdout_text)["reason"]
77
+
78
+
79
+ def test_should_allow_a_run_a_tool_result_shows(
80
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
81
+ ) -> None:
82
+ transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "run 18239174012 took 39m")
83
+ assert run_gate(monkeypatch, capsys, MEASURED_REPLY, transcript_path) == (0, "")
84
+
85
+
86
+ def test_should_allow_a_closing_reply_with_no_message(
87
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
88
+ ) -> None:
89
+ monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b'{"hook_event_name": "Stop"}')))
90
+ assert (final_reply_gate.main(), capsys.readouterr().out) == (0, "")
91
+
92
+
93
+ def test_should_return_none_for_a_measured_reply_without_a_transcript() -> None:
94
+ assert final_reply_gate.final_reply_violation(MEASURED_REPLY, None) is None
@@ -117,6 +117,14 @@ def test_subagent_without_invocation_is_denied(tmp_path: Path) -> None:
117
117
  _assert_denied(payload)
118
118
 
119
119
 
120
+ def test_transcript_paths_should_add_the_subagent_transcript_after_the_session(tmp_path: Path) -> None:
121
+ session_path = _transcript(tmp_path)
122
+ subagent_path = _subagent_transcript(tmp_path, "a8865a3f3c71595ce", None)
123
+ payload = {"transcript_path": str(session_path), "agent_id": "a8865a3f3c71595ce"}
124
+ assert gate.transcript_paths(payload) == [str(session_path), str(subagent_path)]
125
+ assert gate.transcript_paths({"transcript_path": str(session_path)}) == [str(session_path)]
126
+
127
+
120
128
  def test_missing_subagent_transcript_keeps_session_deny(tmp_path: Path) -> None:
121
129
  payload = _payload("git push", _transcript(tmp_path))
122
130
  payload["agent_id"] = "a8865a3f3c71595ce"
@@ -806,6 +806,7 @@ def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
806
806
 
807
807
  ALL_RETAINED_PATHS = {
808
808
  "blocking/context_budget_blocker.py",
809
+ "blocking/pull_request_proof_edit_gate.py",
809
810
  }
810
811
  ALL_RETIRED_PATHS = {
811
812
  "validation/hook_format_validator.py",
@@ -333,3 +333,10 @@ def test_dispatch_that_names_no_head_or_runs_no_workflow_should_not_count_as_a_c
333
333
  for each_payload in all_payloads:
334
334
  assert gate.missing_proof_reason(each_payload) is None
335
335
  assert not gate.is_feature_pull_request(each_payload)
336
+
337
+
338
+ def test_is_create_call_should_name_shell_and_mcp_creates() -> None:
339
+ assert gate.is_create_call(_shell("gh pr create --title t --body b"))
340
+ assert gate.is_create_call(_mcp_create("b"))
341
+ assert not gate.is_create_call(_shell("gh pr edit 7 --body b"))
342
+ assert not gate.is_create_call({"tool_name": "Read", "tool_input": {"file_path": "a.py"}})
@@ -0,0 +1,199 @@
1
+ """Behavior tests for the gate that holds an edit to the proof gate until its named route is taken."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
8
+ if str(HOOKS_DIRECTORY) not in sys.path:
9
+ sys.path.insert(0, str(HOOKS_DIRECTORY))
10
+
11
+ from blocking import pull_request_proof_edit_gate as gate
12
+ from hooks_constants.pull_request_proof_constants import (
13
+ GATE_EDIT_BEFORE_ROUTE_REASON,
14
+ UNREADABLE_CHANGES_REASON,
15
+ )
16
+
17
+
18
+ def _transcript(tmp_path: Path, all_entries: list[dict[str, object]]) -> str:
19
+ transcript_path = tmp_path / "session.jsonl"
20
+ transcript_path.write_text(
21
+ "\n".join(json.dumps(each_entry) for each_entry in all_entries) + "\n", encoding="utf-8"
22
+ )
23
+ return str(transcript_path)
24
+
25
+
26
+ def _tool_use(tool_use_id: object, name: str, tool_input: dict[str, object]) -> dict[str, object]:
27
+ return {
28
+ "type": "assistant",
29
+ "message": {"content": [{"type": "tool_use", "id": tool_use_id, "name": name, "input": tool_input}]},
30
+ }
31
+
32
+
33
+ def _tool_result(tool_use_id: object, content: object, is_error: bool) -> dict[str, object]:
34
+ return {
35
+ "type": "user",
36
+ "message": {
37
+ "content": [
38
+ {"type": "tool_result", "tool_use_id": tool_use_id, "content": content, "is_error": is_error}
39
+ ]
40
+ },
41
+ }
42
+
43
+
44
+ SHELL_CREATE_INPUT = {"command": "gh pr create --title t --body-file body.md"}
45
+ DENIED_CREATE_ENTRIES = [
46
+ _tool_use("create-1", "Bash", SHELL_CREATE_INPUT),
47
+ _tool_result("create-1", UNREADABLE_CHANGES_REASON, True),
48
+ ]
49
+
50
+
51
+ def _edit_payload(transcript_path: str, file_path: str) -> dict[str, object]:
52
+ return {
53
+ "tool_name": "Edit",
54
+ "tool_input": {"file_path": file_path, "old_string": "a", "new_string": "b"},
55
+ "transcript_path": transcript_path,
56
+ }
57
+
58
+
59
+ GATE_FILE_PATH = "C:/workspace/x/packages/claude-dev-env/hooks/blocking/pull_request_proof.py"
60
+ GATE_CONSTANTS_PATH = "/w/packages/claude-dev-env/hooks/hooks_constants/pull_request_proof_constants.py"
61
+
62
+
63
+ def test_should_deny_an_edit_to_the_gate_after_its_deny_with_no_passing_create(tmp_path: Path) -> None:
64
+ transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
65
+ assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) == GATE_EDIT_BEFORE_ROUTE_REASON
66
+
67
+
68
+ def test_should_deny_an_edit_to_the_gate_constants_after_its_deny(tmp_path: Path) -> None:
69
+ transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
70
+ payload = _edit_payload(transcript_path, GATE_CONSTANTS_PATH)
71
+ payload["tool_name"] = "Write"
72
+ assert gate.gate_edit_reason(payload) == GATE_EDIT_BEFORE_ROUTE_REASON
73
+
74
+
75
+ def test_should_read_a_deny_carried_as_text_blocks(tmp_path: Path) -> None:
76
+ all_text_blocks = [{"type": "text", "text": "Error: " + UNREADABLE_CHANGES_REASON}]
77
+ transcript_path = _transcript(
78
+ tmp_path,
79
+ [_tool_use("create-1", "Bash", SHELL_CREATE_INPUT), _tool_result("create-1", all_text_blocks, True)],
80
+ )
81
+ assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) == GATE_EDIT_BEFORE_ROUTE_REASON
82
+
83
+
84
+ def test_should_allow_a_gate_edit_once_a_later_create_passed(tmp_path: Path) -> None:
85
+ transcript_path = _transcript(
86
+ tmp_path,
87
+ [
88
+ *DENIED_CREATE_ENTRIES,
89
+ _tool_use("create-2", "mcp__github__create_pull_request", {"owner": "o", "repo": "r", "body": "b"}),
90
+ _tool_result("create-2", "https://github.com/o/r/pull/7", False),
91
+ ],
92
+ )
93
+ assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) is None
94
+
95
+
96
+ def test_should_deny_again_when_a_deny_follows_the_passing_create(tmp_path: Path) -> None:
97
+ transcript_path = _transcript(
98
+ tmp_path,
99
+ [
100
+ _tool_use("create-1", "Bash", SHELL_CREATE_INPUT),
101
+ _tool_result("create-1", "https://github.com/o/r/pull/7", False),
102
+ *DENIED_CREATE_ENTRIES,
103
+ ],
104
+ )
105
+ assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) == GATE_EDIT_BEFORE_ROUTE_REASON
106
+
107
+
108
+ def test_should_allow_an_edit_to_another_file_after_the_deny(tmp_path: Path) -> None:
109
+ transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
110
+ assert gate.gate_edit_reason(_edit_payload(transcript_path, "/w/hooks/blocking/other_gate.py")) is None
111
+
112
+
113
+ def test_should_allow_a_gate_edit_in_a_session_this_gate_never_denied(tmp_path: Path) -> None:
114
+ transcript_path = _transcript(
115
+ tmp_path,
116
+ [
117
+ _tool_use("read-1", "Read", {"file_path": GATE_CONSTANTS_PATH}),
118
+ _tool_result("read-1", UNREADABLE_CHANGES_REASON, False),
119
+ _tool_use("other-1", "Bash", {"command": "pytest"}),
120
+ _tool_result("other-1", "1 failed", True),
121
+ ],
122
+ )
123
+ assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) is None
124
+
125
+
126
+ def test_should_allow_a_gate_edit_without_a_readable_transcript(tmp_path: Path) -> None:
127
+ payload = _edit_payload(str(tmp_path / "missing.jsonl"), GATE_FILE_PATH)
128
+ assert gate.gate_edit_reason(payload) is None
129
+
130
+
131
+ def test_should_deny_an_apply_patch_to_the_edit_gate_after_its_deny(tmp_path: Path) -> None:
132
+ transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
133
+ patch_text = (
134
+ "*** Begin Patch\n*** Update File: hooks/blocking/pull_request_proof_edit_gate.py\n"
135
+ "@@\n-a\n+b\n*** End Patch\n"
136
+ )
137
+ payload = {
138
+ "tool_name": "apply_patch",
139
+ "tool_input": {"command": patch_text},
140
+ "cwd": str(tmp_path),
141
+ "transcript_path": transcript_path,
142
+ }
143
+ assert gate.gate_edit_reason(payload) == GATE_EDIT_BEFORE_ROUTE_REASON
144
+
145
+
146
+ def test_should_ignore_an_errored_result_of_another_tool_that_names_the_guide(tmp_path: Path) -> None:
147
+ transcript_path = _transcript(
148
+ tmp_path,
149
+ [
150
+ _tool_use("cat-1", "Bash", {"command": "cat proof-before-pull-request.md"}),
151
+ _tool_result("cat-1", "cat: " + UNREADABLE_CHANGES_REASON, True),
152
+ ],
153
+ )
154
+ assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) is None
155
+
156
+
157
+ def test_should_skip_malformed_transcript_entries_and_keep_the_deny(tmp_path: Path) -> None:
158
+ transcript_path = _transcript(
159
+ tmp_path,
160
+ [
161
+ {"type": "user", "message": "plain text"},
162
+ _tool_use(["not", "a", "string"], "Bash", SHELL_CREATE_INPUT),
163
+ _tool_result(["not", "a", "string"], "done", False),
164
+ *DENIED_CREATE_ENTRIES,
165
+ ],
166
+ )
167
+ assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) == GATE_EDIT_BEFORE_ROUTE_REASON
168
+
169
+
170
+ def test_should_deny_a_multi_edit_of_a_case_variant_windows_path_from_the_agent_transcript(tmp_path: Path) -> None:
171
+ transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
172
+ payload = {
173
+ "tool_name": "MultiEdit",
174
+ "tool_input": {"file_path": "C:\\workspace\\x\\hooks\\blocking\\Pull_Request_Proof.PY", "edits": []},
175
+ "agent_transcript_path": transcript_path,
176
+ }
177
+ assert gate.gate_edit_reason(payload) == GATE_EDIT_BEFORE_ROUTE_REASON
178
+
179
+
180
+ def test_should_deny_a_subagent_edit_after_a_deny_in_its_own_transcript(tmp_path: Path) -> None:
181
+ session_path = _transcript(tmp_path, [])
182
+ subagent_directory = tmp_path / "session" / "subagents"
183
+ subagent_directory.mkdir(parents=True)
184
+ (subagent_directory / "agent-a8865a3f3c71595ce.jsonl").write_text(
185
+ "\n".join(json.dumps(each_entry) for each_entry in DENIED_CREATE_ENTRIES) + "\n", encoding="utf-8"
186
+ )
187
+ payload = _edit_payload(session_path, GATE_FILE_PATH)
188
+ payload["agent_id"] = "a8865a3f3c71595ce"
189
+ assert gate.gate_edit_reason(payload) == GATE_EDIT_BEFORE_ROUTE_REASON
190
+
191
+
192
+ def test_should_allow_an_apply_patch_the_patch_parser_cannot_resolve(tmp_path: Path) -> None:
193
+ payload = {
194
+ "tool_name": "apply_patch",
195
+ "tool_input": {"command": "*** Begin Patch\n*** Add File: a\x00b.py\n+x\n*** End Patch\n"},
196
+ "cwd": str(tmp_path),
197
+ "transcript_path": _transcript(tmp_path, DENIED_CREATE_ENTRIES),
198
+ }
199
+ assert gate.gate_edit_reason(payload) is None
@@ -726,3 +726,211 @@ def test_should_allow_a_reply_with_no_causal_claim(
726
726
  monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The runner restarted. Also, so far so good."}
727
727
  )
728
728
  assert exit_code == 0
729
+
730
+
731
+ UPDATE_TOOL_NAME = "mcp__hearthbot__update_message"
732
+ QUESTION_CARD_TOOL_NAME = "AskUserQuestion"
733
+ RUN_LINK_REPLY = (
734
+ "The last build passed in [run 18239174012]"
735
+ "(https://github.com/owner/repo/actions/runs/18239174012)."
736
+ )
737
+
738
+
739
+ def write_tool_result_transcript(transcript_path: Path, result_text: str, is_error: bool = False) -> Path:
740
+ all_entries = [
741
+ {"type": "user", "message": {"content": "how close are we"}},
742
+ {
743
+ "type": "assistant",
744
+ "message": {"content": [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]},
745
+ },
746
+ {
747
+ "type": "user",
748
+ "message": {
749
+ "content": [
750
+ {
751
+ "type": "tool_result",
752
+ "tool_use_id": "t1",
753
+ "is_error": is_error,
754
+ "content": [{"type": "text", "text": result_text}],
755
+ }
756
+ ]
757
+ },
758
+ },
759
+ ]
760
+ transcript_path.write_text(
761
+ "\n".join(json.dumps(each_entry) for each_entry in all_entries), encoding="utf-8"
762
+ )
763
+ return transcript_path
764
+
765
+
766
+ @pytest.mark.parametrize("tool_name", [REPLY_TOOL_NAME, POST_TOOL_NAME, UPDATE_TOOL_NAME])
767
+ def test_should_deny_an_estimate_and_quote_it(
768
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tool_name: str
769
+ ) -> None:
770
+ exit_code, stderr_text = run_gate(
771
+ monkeypatch, capsys, tool_name, {"text": "My estimate is one to two hours."}
772
+ )
773
+ assert exit_code == 2
774
+ assert 'No tool validated this: "My estimate is one to two hours."' in stderr_text
775
+
776
+
777
+ @pytest.mark.parametrize(
778
+ "reply_text",
779
+ [
780
+ "I didn\u2019t confirm this one.",
781
+ "I have not checked the queue.",
782
+ "I assumed the build passed.",
783
+ "Run 18239174012 is unverified.",
784
+ "About 20 minutes left.",
785
+ "It will take two hours.",
786
+ "ETA is 3-4 hours.",
787
+ "Estimate from the last check is two builds.",
788
+ "I did not run the suite.",
789
+ "The tests have not yet been run.",
790
+ "The job takes 39 minutes.",
791
+ "The migration takes seventy minutes.",
792
+ "I was not able to verify the fix.",
793
+ "The fix has not been validated.",
794
+ "The fix was not verified in production.",
795
+ "The suite takes about 90 seconds.",
796
+ ],
797
+ )
798
+ def test_should_deny_each_unvalidated_claim_shape(
799
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
800
+ ) -> None:
801
+ exit_code, stderr_text = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
802
+ assert exit_code == 2
803
+ assert "No tool validated this" in stderr_text
804
+
805
+
806
+ @pytest.mark.parametrize(
807
+ "reply_text",
808
+ [
809
+ "Two builds are left in the queue.",
810
+ "I will check the queue again within 15 minutes.",
811
+ "The last queue build took 39 minutes.",
812
+ "The gate now refuses `guess` and `assumed`.",
813
+ "The job takes 39 minutes, see run 18239174012.",
814
+ "The hook cannot run on Windows.",
815
+ "The crash is not a test problem.",
816
+ "That is not the check that failed.",
817
+ "The migration did not run out of memory.",
818
+ "We did not run into any errors.",
819
+ "This is not something to check before the release.",
820
+ "The result is not what we tested for.",
821
+ "The migration did not run on the replica.",
822
+ "The old parser did not validate the header length.",
823
+ "The job has not run yet.",
824
+ "The config file is not checked in.",
825
+ "The release branch is not checked out.",
826
+ ],
827
+ )
828
+ def test_should_allow_a_reply_with_no_unvalidated_claim(
829
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
830
+ ) -> None:
831
+ assert run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text}) == (0, "")
832
+
833
+
834
+ def test_should_deny_a_run_no_tool_result_shows(
835
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
836
+ ) -> None:
837
+ transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "run 18239170000 ok")
838
+ exit_code, stderr_text = run_gate(
839
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": RUN_LINK_REPLY}, transcript_path
840
+ )
841
+ assert exit_code == 2
842
+ assert 'cites "run 18239174012"' in stderr_text
843
+
844
+
845
+ def test_should_deny_a_run_only_an_error_result_shows(
846
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
847
+ ) -> None:
848
+ transcript_path = write_tool_result_transcript(
849
+ tmp_path / "s.jsonl", "run 18239174012 ok", is_error=True
850
+ )
851
+ exit_code, stderr_text = run_gate(
852
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": RUN_LINK_REPLY}, transcript_path
853
+ )
854
+ assert exit_code == 2
855
+ assert "no tool result in this session shows it" in stderr_text
856
+
857
+
858
+ def test_should_allow_a_run_a_tool_result_shows(
859
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
860
+ ) -> None:
861
+ transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "run 18239174012 took 39m")
862
+ assert run_gate(
863
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": RUN_LINK_REPLY}, transcript_path
864
+ ) == (0, "")
865
+
866
+
867
+ def test_should_deny_a_question_card_that_admits_a_skipped_check(
868
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
869
+ ) -> None:
870
+ card = {
871
+ "questions": [
872
+ {
873
+ "question": "Ship the gate now?",
874
+ "header": "Ship",
875
+ "options": [
876
+ {"label": "Ship", "description": "I did not test the Stop hook."},
877
+ {"label": "Wait", "description": "Run the suite first."},
878
+ ],
879
+ }
880
+ ]
881
+ }
882
+ exit_code, stderr_text = run_gate(monkeypatch, capsys, QUESTION_CARD_TOOL_NAME, card)
883
+ assert exit_code == 2
884
+ assert "I did not test the Stop hook." in stderr_text
885
+
886
+
887
+ def test_should_allow_a_question_card_with_no_unvalidated_claim(
888
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
889
+ ) -> None:
890
+ card = {
891
+ "questions": [
892
+ {
893
+ "question": "Which gate design?",
894
+ "header": "Design",
895
+ "options": [{"label": "Fold in", "description": "One gate checks every reply."}],
896
+ }
897
+ ]
898
+ }
899
+ assert run_gate(monkeypatch, capsys, QUESTION_CARD_TOOL_NAME, card) == (0, "")
900
+
901
+
902
+ def test_should_collect_each_question_card_prose_field() -> None:
903
+ card = {
904
+ "questions": [
905
+ {"question": "Q?", "header": "H", "options": [{"label": "L", "description": "D"}, "x"]},
906
+ "not a question",
907
+ ]
908
+ }
909
+ assert reply_length_gate.all_question_card_texts(card) == ["Q?", "H", "L", "D"]
910
+
911
+
912
+ def test_should_quote_the_hedged_sentence() -> None:
913
+ assert reply_length_gate.unvalidated_claim_violation("Done. It should take a few minutes.") == (
914
+ 'No tool validated this: "It should take a few minutes.". Run the check yourself or through'
915
+ " a subagent, then state what the run, log line, output, or file and line shows, or leave"
916
+ " the claim out."
917
+ )
918
+
919
+
920
+ def test_should_skip_the_citation_check_without_a_transcript() -> None:
921
+ assert reply_length_gate.unseen_citation_violation(RUN_LINK_REPLY, None) is None
922
+
923
+
924
+ def test_should_leave_the_transcript_unread_when_the_text_cites_nothing(
925
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
926
+ ) -> None:
927
+ all_read_paths: list[object] = []
928
+ monkeypatch.setattr(reply_length_gate, "successful_result_text", all_read_paths.append)
929
+ transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "nothing cited")
930
+ assert reply_length_gate.unseen_citation_violation("Two builds left.", transcript_path) is None
931
+ assert all_read_paths == []
932
+
933
+
934
+ def test_should_split_prose_sentences_outside_fenced_blocks() -> None:
935
+ reply_text = "First one. Second one?\n```\nskipped. code\n```\nThird"
936
+ assert reply_length_gate.prose_sentences(reply_text) == ["First one.", "Second one?", "", "Third"]
@@ -4,8 +4,9 @@ This family stops a chat reply, edited message, or tool call when its payload br
4
4
 
5
5
  ## Checks
6
6
 
7
- - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
8
- - The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, claims labeled as unchecked, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
7
+ - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
8
+ - `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with.
9
+ - The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
9
10
  - `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
10
11
  - `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
11
12
  - `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
@@ -18,7 +19,8 @@ This family stops a chat reply, edited message, or tool call when its payload br
18
19
 
19
20
  ## When it fires
20
21
 
21
- - `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
22
+ - `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision|AskUserQuestion|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
23
+ - `blocking/final_reply_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
22
24
  - The claim judge runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision`, timeout `30` seconds in `hooks.json`.
23
25
  - `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
24
26
  - `blocking/issue_close_handoff_gate.py` runs on `PreToolUse`, matcher `mcp__.*__(add_issue_comment|issue_write|update_issue_comment)`, timeout `10` seconds in `hooks.json`.
@@ -37,6 +39,7 @@ Preconditions:
37
39
  - Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
38
40
 
39
41
  - **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
42
+ - **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes.
40
43
  - **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Fact with no source: ...`. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
41
44
  - **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
42
45
  - **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
@@ -53,6 +56,7 @@ Preconditions:
53
56
  - `blocking/step_note_gate.py` is off by default and polls for the transcript record. Subagent calls and unreadable records pass.
54
57
  - `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
55
58
  - The reply gate counts each nonempty list line as a sentence. Link targets and code spans add no words. Its banned words come from `reply-banned-words.json` in the Claude home, or the file `CLAUDE_REPLY_BANNED_WORDS_PATH` names, with built-in defaults.
59
+ - The final reply gate blocks the stop and returns its reason to the model, which rewrites the reply. Claude Code ends the turn anyway after eight blocks in a row. The cited number checks in both gates skip when the transcript cannot be read.
56
60
  - The edit marker gate scans replacement cards as well as the message text.
57
61
  - The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
58
62
  - The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
@@ -7,12 +7,14 @@ This family decides whether an agent's file mutation can run.
7
7
  - `blocking/pre_tool_use_dispatcher.py` runs its native description check and returns one permission decision.
8
8
  - `blocking/state_description_blocker.py` rejects historical or comparative prose in supported comments, docstrings, and Markdown.
9
9
  - `blocking/context_budget_blocker.py` rejects a write that grows a skill entry, `AGENTS.md`, `CLAUDE.md`, or rule file past its context budget.
10
+ - `blocking/pull_request_proof_edit_gate.py` rejects an edit to the pull request proof gate, its constants, this edit gate, or the dispatcher roster while the proof gate's last deny of a create call in the session has no passing create call after it.
10
11
 
11
12
  ## When it fires
12
13
 
13
14
  - `blocking/pre_tool_use_dispatcher.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds in `hooks.json`.
14
15
  - `blocking/state_description_blocker.py` runs through the dispatcher's native evaluator on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. Its evaluator checks `Write`, `Edit`, and `MultiEdit` payloads and skips `apply_patch`.
15
16
  - `blocking/context_budget_blocker.py` runs inside that dispatcher as a blocking hosted entry on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. It reads `.claude/context-budget.json` from the edited file's own repository.
17
+ - `blocking/pull_request_proof_edit_gate.py` runs inside that dispatcher as a blocking hosted entry on `PreToolUse` for `Write`, `Edit`, `MultiEdit`, and `apply_patch`, timeout `60` seconds. It reads the session transcript the payload names and, for a subagent, its own transcript.
16
18
 
17
19
  ## Proving it
18
20
 
@@ -22,11 +24,12 @@ Preconditions:
22
24
 
23
25
  - **Dispatcher decision.** Input is a clean Write payload. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py -q`. The adjacent test checks that the dispatcher allows clean writes and carries denials from its checks.
24
26
  - **Context budget gate.** Input is a Write of the orchestrator skill at commit `3f2c3ef`, then at `98f45b1`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_context_budget_blocker.py -q`. The adjacent test observes a denial naming "Oversee delegated work" for the first and an allow for the second.
27
+ - **Proof edit gate.** Input is a transcript with a proof gate deny, then an Edit of `pull_request_proof.py`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pull_request_proof_edit_gate.py -q`. The adjacent test observes a denial before a passing create call and an allow after one.
25
28
  - **Description gate.** Input is a Write containing historical prose in a Python comment. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_state_description_blocker.py -q`. The adjacent test observes a denial reason.
26
29
 
27
30
  ## Gotchas
28
31
 
29
- - `ALL_HOSTED_HOOK_ENTRIES` lists the blocking context budget gate. The dispatcher calls the description evaluator through its native hook table.
32
+ - `ALL_HOSTED_HOOK_ENTRIES` lists the blocking context budget gate and the proof edit gate. The dispatcher calls the description evaluator through its native hook table.
30
33
  - The context budget gate denies only what an edit adds. A repository with no policy file uses built-in kinds and treats the file's current text as its baseline entry, so a file under its line limit may grow to the limit and a file over it may only shrink. The CI ratchet runs in `scripts/policy_lint/adapter_context_budget.py`, never in the hook. See the [context budget reference](../../.agents/skills/build-eval/reference/context-budget.md).
31
34
  - The dispatcher runs hosted entries in roster order. A hosted advisory crash stays silent; a native blocking check can deny the write.
32
35
  - `scripts/policy_lint/adapter_detectors.py` also loads `blocking/state_description_blocker.py`. Check that adapter when changing its evaluator.