claude-dev-env 8.98.4 → 8.98.7
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.
- package/.agents/skills/pr-lifecycle/SKILL.md +4 -4
- package/hooks/blocking/open_tasks_stop_gate.py +62 -9
- package/hooks/blocking/open_tasks_transcript.py +71 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +36 -10
- package/hooks/blocking/reply_length_gate.py +11 -2
- package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +135 -2
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +5 -0
- package/hooks/blocking/test_reply_length_gate.py +11 -0
- package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +24 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +10 -1
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
- package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/session/artifact_template_pointer.py +26 -3
- package/hooks/session/test_artifact_template_pointer.py +16 -0
- package/hooks/test_transcript_skill_scan.py +30 -0
- package/hooks/transcript_skill_scan.py +72 -1
- package/package.json +1 -1
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +6 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_durable_post_lint.py +176 -3
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -79,7 +79,7 @@ def test_should_allow_when_every_open_task_is_reported_as_blocked_or_open(
|
|
|
79
79
|
{"id": "3", "subject": "Write tests", "status": "completed"},
|
|
80
80
|
],
|
|
81
81
|
)
|
|
82
|
-
reply_text = "Tests written.\nBlocked: Rotate the deploy key - the owner
|
|
82
|
+
reply_text = "Tests written.\nBlocked: Rotate the deploy key - the owner pastes the secret\n- Open: #2 - checks running"
|
|
83
83
|
assert run_gate(monkeypatch, capsys, reply_text) == (0, "")
|
|
84
84
|
|
|
85
85
|
|
|
@@ -238,7 +238,7 @@ def test_should_allow_cursor_when_the_last_assistant_text_reports_the_item(
|
|
|
238
238
|
[
|
|
239
239
|
cursor_todo_record(all_todos, False, "Working."),
|
|
240
240
|
{"role": "assistant", "message": {"content": [
|
|
241
|
-
{"type": "text", "text": "Blocked: rotate the deploy key - the owner
|
|
241
|
+
{"type": "text", "text": "Blocked: rotate the deploy key - the owner pastes the org secret"}
|
|
242
242
|
]}},
|
|
243
243
|
],
|
|
244
244
|
)
|
|
@@ -510,3 +510,136 @@ def test_should_mark_the_right_item_for_a_done_index_after_a_continuation(
|
|
|
510
510
|
assert "Submit both APKs" in reason_text
|
|
511
511
|
assert "Read the schema" not in reason_text
|
|
512
512
|
assert "Set rows in Neon" not in reason_text
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
DECISION_BLOCKED_REPLY = "Branch pushed.\nBlocked: #ask-1 - your OK to merge the app branch first."
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def user_prompt_record(prompt_text: str) -> dict[str, object]:
|
|
519
|
+
return {"type": "user", "message": {"role": "user", "content": prompt_text}}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def tool_call_record(tool_name: str) -> dict[str, object]:
|
|
523
|
+
tool_use = {"type": "tool_use", "id": "q1", "name": tool_name, "input": {}}
|
|
524
|
+
return {"type": "assistant", "message": {"role": "assistant", "content": [tool_use]}}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_should_block_a_decision_blocked_line_with_no_card_this_turn(
|
|
528
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
529
|
+
) -> None:
|
|
530
|
+
record_ask("Merge the release branch")
|
|
531
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch")])
|
|
532
|
+
reason_text = block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
|
|
533
|
+
assert "AskUserQuestion" in reason_text
|
|
534
|
+
assert "your OK to merge" in reason_text
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@pytest.mark.parametrize(
|
|
538
|
+
"card_tool_name",
|
|
539
|
+
["AskUserQuestion", "mcp__hearthbot__ask_decision", "mcp__hearthbot__request_decision"],
|
|
540
|
+
)
|
|
541
|
+
def test_should_allow_a_decision_blocked_line_after_a_card_this_turn(
|
|
542
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, card_tool_name: str
|
|
543
|
+
) -> None:
|
|
544
|
+
record_ask("Merge the release branch")
|
|
545
|
+
transcript_path = write_records(
|
|
546
|
+
tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch"), tool_call_record(card_tool_name)]
|
|
547
|
+
)
|
|
548
|
+
assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def test_should_block_a_decision_blocked_line_when_the_card_came_in_an_earlier_turn(
|
|
552
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
553
|
+
) -> None:
|
|
554
|
+
record_ask("Merge the release branch")
|
|
555
|
+
transcript_path = write_records(
|
|
556
|
+
tmp_path / "s.jsonl",
|
|
557
|
+
[
|
|
558
|
+
tool_call_record("AskUserQuestion"),
|
|
559
|
+
user_prompt_record("Merge the release branch"),
|
|
560
|
+
],
|
|
561
|
+
)
|
|
562
|
+
assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def test_should_allow_a_blocked_line_for_an_input_the_user_supplies(
|
|
566
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
567
|
+
) -> None:
|
|
568
|
+
record_ask("Rotate the deploy key")
|
|
569
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Rotate the deploy key")])
|
|
570
|
+
reply_text = "Key script ready.\nBlocked: #ask-1 - the new deploy key secret, pasted into the session"
|
|
571
|
+
assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_should_keep_the_card_through_stop_hook_feedback(
|
|
575
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
576
|
+
) -> None:
|
|
577
|
+
record_ask("Merge the release branch")
|
|
578
|
+
transcript_path = write_records(
|
|
579
|
+
tmp_path / "s.jsonl",
|
|
580
|
+
[
|
|
581
|
+
user_prompt_record("Merge the release branch"),
|
|
582
|
+
tool_call_record("AskUserQuestion"),
|
|
583
|
+
{"type": "user", "isMeta": True, "message": {"content": "Stop hook feedback"}},
|
|
584
|
+
],
|
|
585
|
+
)
|
|
586
|
+
assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def test_should_ignore_a_card_call_copied_into_an_api_request_blob(
|
|
590
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
591
|
+
) -> None:
|
|
592
|
+
record_ask("Merge the release branch")
|
|
593
|
+
blob_record = {**tool_call_record("AskUserQuestion"), "type": "api-request-blob"}
|
|
594
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch"), blob_record])
|
|
595
|
+
assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_should_allow_an_input_blocked_line_that_says_whether(
|
|
599
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
600
|
+
) -> None:
|
|
601
|
+
record_ask("Merge the release branch")
|
|
602
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch")])
|
|
603
|
+
input_reply = "Branch pushed.\nBlocked: #ask-1 - paste the log showing whether the migration ran."
|
|
604
|
+
assert run_gate(monkeypatch, capsys, input_reply, transcript_path) == (0, "")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def test_should_start_a_new_turn_at_an_image_only_prompt(
|
|
608
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
609
|
+
) -> None:
|
|
610
|
+
record_ask("Merge the release branch")
|
|
611
|
+
image_prompt = {"type": "user", "message": {"role": "user", "content": [{"type": "image", "source": {}}]}}
|
|
612
|
+
transcript_path = write_records(
|
|
613
|
+
tmp_path / "s.jsonl",
|
|
614
|
+
[user_prompt_record("Merge the release branch"), tool_call_record("AskUserQuestion"), image_prompt],
|
|
615
|
+
)
|
|
616
|
+
assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def test_should_allow_a_decision_blocked_line_after_a_codex_request_user_input_call(
|
|
620
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
621
|
+
) -> None:
|
|
622
|
+
record_ask("Merge the release branch")
|
|
623
|
+
user_message = {"type": "response_item", "payload": {"type": "message", "role": "user", "content": []}}
|
|
624
|
+
card_call = {"type": "response_item", "payload": {"type": "function_call", "name": "request_user_input", "arguments": "{}"}}
|
|
625
|
+
transcript_path = write_records(tmp_path / "rollout.jsonl", [user_message, card_call])
|
|
626
|
+
assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def test_should_allow_a_blocked_line_for_a_device_step_the_user_takes(
|
|
630
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
631
|
+
) -> None:
|
|
632
|
+
record_ask("Install the runner")
|
|
633
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Install the runner")])
|
|
634
|
+
reply_text = "Installer built.\nBlocked: #ask-1 - run the installer on your PC and paste the log"
|
|
635
|
+
assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def test_should_leave_a_cursor_decision_blocked_line_alone(
|
|
639
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
640
|
+
) -> None:
|
|
641
|
+
transcript_path = write_cursor_transcript(
|
|
642
|
+
tmp_path / "cursor.jsonl",
|
|
643
|
+
[{"role": "assistant", "message": {"content": [{"type": "text", "text": DECISION_BLOCKED_REPLY}]}}],
|
|
644
|
+
)
|
|
645
|
+
assert run_cursor_gate(monkeypatch, capsys, transcript_path) == {}
|
|
@@ -103,6 +103,11 @@ def test_executable_word_lists_skips_a_comment_and_resumes_on_the_next_line() ->
|
|
|
103
103
|
def test_executable_word_lists_joins_a_line_continuation() -> None:
|
|
104
104
|
assert gate.executable_word_lists("git \\\npush origin") == [["git", "push", "origin"]]
|
|
105
105
|
assert gate.executable_word_lists("git\\\n#note; git push") == [["git#note"], ["git", "push"]]
|
|
106
|
+
assert gate.executable_word_lists("echo a\\\\\n#; git push") == [["echo", "a\\"]]
|
|
107
|
+
assert gate.executable_word_lists('"gi\\\nt" push origin') == [["git", "push", "origin"]]
|
|
108
|
+
assert gate.executable_word_lists('echo "a\\\\\nb"') == [["echo", "a\\\nb"]]
|
|
109
|
+
assert gate.executable_word_lists('g"i\\\nt" push origin') == [["git", "push", "origin"]]
|
|
110
|
+
assert gate.executable_word_lists("echo 'a\\\nb'") == [["echo", "a\\\nb"]]
|
|
106
111
|
|
|
107
112
|
|
|
108
113
|
def test_executable_word_lists_keeps_the_other_segments_beside_a_trailing_backslash() -> None:
|
|
@@ -1065,3 +1065,14 @@ def test_should_read_gh_comment_body_and_skip_pr_create() -> None:
|
|
|
1065
1065
|
assert reply_length_gate.github_comment_texts("Bash", {"command": flagged_command}) == [f"note=x. {LONG_GITHUB_COMMENT}"]
|
|
1066
1066
|
chained_command = f"git push && {comment_command}"
|
|
1067
1067
|
assert reply_length_gate.github_comment_texts("Bash", {"command": chained_command}) == [LONG_GITHUB_COMMENT]
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def test_should_report_an_over_long_comment_text_with_the_comment_instruction() -> None:
|
|
1071
|
+
violation = reply_length_gate.comment_length_violation(LONG_GITHUB_COMMENT)
|
|
1072
|
+
assert violation is not None
|
|
1073
|
+
assert violation.endswith(reply_length_gate.COMMENT_RETRY_INSTRUCTION)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def test_should_pass_a_comment_text_whose_long_part_sits_in_a_details_block() -> None:
|
|
1077
|
+
comment_text = "Fixed.\n<details><summary>Log</summary>\n" + LONG_GITHUB_COMMENT + "\n</details>"
|
|
1078
|
+
assert reply_length_gate.comment_length_violation(comment_text) is None
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Behavior tests for the gate that holds a review thread resolve until a reply posts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
11
|
+
|
|
12
|
+
from blocking import resolve_thread_reply_gate as gate
|
|
13
|
+
from hooks_constants.resolve_thread_reply_gate_constants import MISSING_REPLY_REASON
|
|
14
|
+
from transcript_skill_scan import TranscriptToolCall
|
|
15
|
+
|
|
16
|
+
GATE_PATH = Path(__file__).resolve().parent / "resolve_thread_reply_gate.py"
|
|
17
|
+
RESOLVE_TOOL_NAME = "mcp__github__resolve_review_thread"
|
|
18
|
+
REPLY_TOOL_NAME = "mcp__github__add_reply_to_pull_request_comment"
|
|
19
|
+
HOOK_DENIAL_TEXT = "PreToolUse:mcp__github__add_reply_to_pull_request_comment hook error: too many sentences"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _prompt(text: str) -> str:
|
|
23
|
+
return json.dumps({"type": "user", "message": {"content": text}})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _call(tool_use_id: str, tool_name: str, tool_input: dict[str, object] | None = None) -> str:
|
|
27
|
+
tool_use = {"type": "tool_use", "id": tool_use_id, "name": tool_name, "input": tool_input or {}}
|
|
28
|
+
return json.dumps({"type": "assistant", "message": {"content": [tool_use]}})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _result(tool_use_id: str, is_error: bool = False, text: str = "ok") -> str:
|
|
32
|
+
tool_result = {"type": "tool_result", "tool_use_id": tool_use_id, "is_error": is_error, "content": text}
|
|
33
|
+
return json.dumps({"type": "user", "message": {"content": [tool_result]}})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
|
|
37
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
38
|
+
transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
|
|
39
|
+
return transcript_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_payload(transcript_path: Path) -> dict[str, object]:
|
|
43
|
+
return {
|
|
44
|
+
"hook_event_name": "PreToolUse",
|
|
45
|
+
"tool_name": RESOLVE_TOOL_NAME,
|
|
46
|
+
"tool_input": {"threadId": "PRRT_1"},
|
|
47
|
+
"transcript_path": str(transcript_path),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _deny_reason(decision: dict[str, object] | None) -> str | None:
|
|
52
|
+
if decision is None:
|
|
53
|
+
return None
|
|
54
|
+
hook_output = decision["hookSpecificOutput"]
|
|
55
|
+
assert isinstance(hook_output, dict)
|
|
56
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
57
|
+
return str(hook_output["permissionDecisionReason"])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def should_allow_a_resolve_after_a_reply_that_posted(tmp_path: Path) -> None:
|
|
61
|
+
transcript_path = _write_transcript(
|
|
62
|
+
tmp_path,
|
|
63
|
+
[
|
|
64
|
+
_prompt("Address the review threads."),
|
|
65
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
66
|
+
_result("reply-1"),
|
|
67
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def should_deny_a_resolve_after_a_reply_a_hook_refused(tmp_path: Path) -> None:
|
|
74
|
+
transcript_path = _write_transcript(
|
|
75
|
+
tmp_path,
|
|
76
|
+
[
|
|
77
|
+
_prompt("Address the review threads."),
|
|
78
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "A long reply."}),
|
|
79
|
+
_result("reply-1", is_error=True, text=HOOK_DENIAL_TEXT),
|
|
80
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def should_deny_a_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
87
|
+
transcript_path = _write_transcript(
|
|
88
|
+
tmp_path,
|
|
89
|
+
[_prompt("Address the review threads."), _call("resolve-1", RESOLVE_TOOL_NAME)],
|
|
90
|
+
)
|
|
91
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def should_deny_a_second_resolve_after_one_reply(tmp_path: Path) -> None:
|
|
95
|
+
transcript_path = _write_transcript(
|
|
96
|
+
tmp_path,
|
|
97
|
+
[
|
|
98
|
+
_prompt("Address the review threads."),
|
|
99
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
100
|
+
_result("reply-1"),
|
|
101
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
102
|
+
_result("resolve-1"),
|
|
103
|
+
_call("resolve-2", RESOLVE_TOOL_NAME),
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def should_allow_a_second_resolve_after_two_replies(tmp_path: Path) -> None:
|
|
110
|
+
transcript_path = _write_transcript(
|
|
111
|
+
tmp_path,
|
|
112
|
+
[
|
|
113
|
+
_prompt("Address the review threads."),
|
|
114
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
115
|
+
_result("reply-1"),
|
|
116
|
+
_call("reply-2", REPLY_TOOL_NAME, {"body": "Fixed in `def5678`."}),
|
|
117
|
+
_result("reply-2"),
|
|
118
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
119
|
+
_result("resolve-1"),
|
|
120
|
+
_call("resolve-2", RESOLVE_TOOL_NAME),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def should_deny_a_resolve_whose_reply_came_before_the_last_prompt(tmp_path: Path) -> None:
|
|
127
|
+
transcript_path = _write_transcript(
|
|
128
|
+
tmp_path,
|
|
129
|
+
[
|
|
130
|
+
_prompt("Reply on the thread."),
|
|
131
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
132
|
+
_result("reply-1"),
|
|
133
|
+
_prompt("Now resolve the other thread."),
|
|
134
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def should_allow_a_retry_after_a_resolve_error(tmp_path: Path) -> None:
|
|
141
|
+
transcript_path = _write_transcript(
|
|
142
|
+
tmp_path,
|
|
143
|
+
[
|
|
144
|
+
_prompt("Address the review threads."),
|
|
145
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
146
|
+
_result("reply-1"),
|
|
147
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
148
|
+
_result("resolve-1", is_error=True, text="Bad gateway"),
|
|
149
|
+
_call("resolve-2", RESOLVE_TOOL_NAME),
|
|
150
|
+
],
|
|
151
|
+
)
|
|
152
|
+
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def should_allow_a_resolve_when_the_transcript_cannot_be_read(tmp_path: Path) -> None:
|
|
156
|
+
assert gate.decision_for(_resolve_payload(tmp_path / "missing.jsonl")) is None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def should_ignore_tools_other_than_a_resolve(tmp_path: Path) -> None:
|
|
160
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Reply on the thread.")])
|
|
161
|
+
payload = _resolve_payload(transcript_path) | {"tool_name": REPLY_TOOL_NAME}
|
|
162
|
+
assert gate.decision_for(payload) is None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def should_read_a_resolve_tool_from_any_mcp_server() -> None:
|
|
166
|
+
assert gate.is_resolve_call("mcp__github_enterprise__resolve_review_thread")
|
|
167
|
+
assert not gate.is_resolve_call("mcp__github__resolve_review_threads")
|
|
168
|
+
assert not gate.is_resolve_call(None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def should_count_a_gh_api_reply_command_as_a_reply() -> None:
|
|
172
|
+
reply_command = "gh api repos/o/r/pulls/7/comments/99/replies -f body=Fixed."
|
|
173
|
+
assert gate.is_reply_call(TranscriptToolCall("Bash", {"command": reply_command}, True))
|
|
174
|
+
graphql_command = "gh api graphql -f query='mutation { addPullRequestReviewThreadReply(input: {}) { comment { id } } }'"
|
|
175
|
+
assert gate.is_reply_call(TranscriptToolCall("Bash", {"command": graphql_command}, True))
|
|
176
|
+
assert gate.is_reply_call(TranscriptToolCall(REPLY_TOOL_NAME, {}, True))
|
|
177
|
+
assert not gate.is_reply_call(TranscriptToolCall("mcp__github__add_issue_comment", {}, True))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def should_report_a_posted_reply_from_transcript_lines() -> None:
|
|
181
|
+
all_lines = [_prompt("Go."), _call("reply-1", REPLY_TOOL_NAME), _result("reply-1")]
|
|
182
|
+
assert gate.has_posted_reply_since_last_resolve(all_lines)
|
|
183
|
+
assert not gate.has_posted_reply_since_last_resolve(all_lines[:2])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def should_print_the_deny_when_run_as_a_hook(tmp_path: Path) -> None:
|
|
187
|
+
transcript_path = _write_transcript(
|
|
188
|
+
tmp_path,
|
|
189
|
+
[_prompt("Address the review threads."), _call("resolve-1", RESOLVE_TOOL_NAME)],
|
|
190
|
+
)
|
|
191
|
+
completed_process = subprocess.run(
|
|
192
|
+
[sys.executable, str(GATE_PATH)],
|
|
193
|
+
input=json.dumps(_resolve_payload(transcript_path)),
|
|
194
|
+
capture_output=True,
|
|
195
|
+
text=True,
|
|
196
|
+
check=False,
|
|
197
|
+
)
|
|
198
|
+
assert completed_process.returncode == 0
|
|
199
|
+
assert _deny_reason(json.loads(completed_process.stdout)) == MISSING_REPLY_REASON
|
|
@@ -17,6 +17,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
17
17
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
18
18
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
19
19
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
20
|
+
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on a thread that posted after the last user prompt and that no successful resolve has spent. Each successful resolve spends one posted reply. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
|
|
20
21
|
- `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or test files.
|
|
21
22
|
- `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
|
|
22
23
|
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
|
|
@@ -39,6 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
39
40
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
40
41
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
41
42
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
43
|
+
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `mcp__.*__resolve_review_thread`, timeout `10` seconds in `hooks.json`.
|
|
42
44
|
- `blocking/verify_before_commit_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell`, timeout `30` seconds in `hooks.json`.
|
|
43
45
|
- `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
|
|
44
46
|
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
@@ -53,6 +55,7 @@ Preconditions:
|
|
|
53
55
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
54
56
|
|
|
55
57
|
- **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`.
|
|
58
|
+
- **Resolve reply.** Input is a transcript where a thread reply came back refused, or with no reply, or with one reply already spent on a resolve, then a resolve call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_resolve_thread_reply_gate.py -q`. The adjacent test observes a deny whose reason says to post the reply, confirm it posted, then resolve. A reply that posted before the resolve passes, and an unreadable transcript passes.
|
|
56
59
|
- **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. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
|
|
57
60
|
- **Open tasks.** Input is a session whose task list still has "rotate the deploy key" pending and a closing reply of "Done.". Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes a `block` decision that names the item; a reply line `Blocked: rotate the deploy key - <what the user must give>` passes. The same file covers an interrupted ask, a continuation plan list that leaves items out, `Done:` and `Dropped:` lines, and a carried item that a later list marks completed.
|
|
58
61
|
- **Ask recorder.** Input is a `UserPromptSubmit` payload with a work prompt, a question, a `<task-notification>` envelope, and a polite request. Run `python -m pytest packages/claude-dev-env/hooks/session/test_open_task_ask_recorder.py -q`. The adjacent test observes `ask-1` and `ask-2` for the work prompt and the request, no ask for the other two, and empty stdout.
|
package/hooks/hooks.json
CHANGED
|
@@ -102,6 +102,16 @@
|
|
|
102
102
|
}
|
|
103
103
|
]
|
|
104
104
|
},
|
|
105
|
+
{
|
|
106
|
+
"matcher": "mcp__.*__resolve_review_thread",
|
|
107
|
+
"hooks": [
|
|
108
|
+
{
|
|
109
|
+
"type": "command",
|
|
110
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/resolve_thread_reply_gate.py",
|
|
111
|
+
"timeout": 10
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
},
|
|
105
115
|
{
|
|
106
116
|
"matcher": "Bash|PowerShell",
|
|
107
117
|
"hooks": [
|
|
@@ -9,6 +9,7 @@ __all__ = [
|
|
|
9
9
|
"ARTIFACT_QUICKSTART_ACTION",
|
|
10
10
|
"ARTIFACT_TEMPLATE_PATH",
|
|
11
11
|
"ARTIFACT_TOOL_NAME",
|
|
12
|
+
"PACKAGE_ROOT_DIRECTORY",
|
|
12
13
|
"HTML_PLAN_TEMPLATE_README_PATH",
|
|
13
14
|
"POINTER_TEXT_TEMPLATE",
|
|
14
15
|
"SKILL_NAME_INPUT_KEY",
|
|
@@ -23,7 +24,8 @@ ARTIFACT_QUICKSTART_ACTION = "quickstart"
|
|
|
23
24
|
SKILL_TOOL_NAME = "Skill"
|
|
24
25
|
SKILL_NAME_INPUT_KEY = "skill"
|
|
25
26
|
ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
|
|
26
|
-
|
|
27
|
+
PACKAGE_ROOT_DIRECTORY = Path(__file__).resolve().parents[2]
|
|
28
|
+
TEMPLATES_DIRECTORY = PACKAGE_ROOT_DIRECTORY / "docs" / "templates"
|
|
27
29
|
ARTIFACT_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "README.md"
|
|
28
30
|
HTML_PLAN_TEMPLATE_README_PATH = TEMPLATES_DIRECTORY / "html-plan" / "README.md"
|
|
29
31
|
STATUS_FLOWCHART_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "status-flowchart.html"
|
|
@@ -69,6 +69,30 @@ BLOCK_REASON_SUFFIX = (
|
|
|
69
69
|
"\nKeep working each one now. End the reply with one line per item you cannot finish this turn:"
|
|
70
70
|
" 'Blocked: <item> - <what the user must give>' when only the user can unblock it, or"
|
|
71
71
|
" 'Open: <item> - <the step you take next>' when you are still working it."
|
|
72
|
+
" When the user must decide, choose, or approve, ask on a question card (AskUserQuestion,"
|
|
73
|
+
" or mcp__hearthbot__ask_decision / request_decision) this turn, then add the Blocked line."
|
|
74
|
+
)
|
|
75
|
+
BLOCKED_REPORT_KIND = "blocked"
|
|
76
|
+
REPORT_KIND_GROUP_INDEX = 1
|
|
77
|
+
DECISION_WORD_PATTERN = (
|
|
78
|
+
r"\b(?:ok|okay|approve[sd]?|approval|go-?ahead|green\s+light|sign-?off|decide[sd]?|decision"
|
|
79
|
+
r"|choose|choice|permission|consent|say-?so|your\s+call|prefer(?:s|ence)?)\b"
|
|
80
|
+
)
|
|
81
|
+
DECISION_CARD_TOOL_PATTERN = (
|
|
82
|
+
r"(?:^|__)AskUserQuestion$|hearthbot__(?:ask|request)_decision$|^request_user_input(?:_async)?$"
|
|
83
|
+
)
|
|
84
|
+
ALL_SKIPPED_PROMPT_FLAGS = ("isMeta", "isSidechain", "isCompactSummary")
|
|
85
|
+
USER_ROLE = "user"
|
|
86
|
+
USER_RECORD_TYPE = "user"
|
|
87
|
+
CODEX_MESSAGE_TYPE = "message"
|
|
88
|
+
CODEX_CARD_EVENT = "card"
|
|
89
|
+
CODEX_PROMPT_EVENT = "prompt"
|
|
90
|
+
STOP_HOOK_FEEDBACK_PREFIX = "Stop hook feedback"
|
|
91
|
+
DECISION_LINE_TEMPLATE = "\n- {line}"
|
|
92
|
+
DECISION_BLOCK_REASON_PREFIX = "This reply asks the user for a decision in a Blocked line, with no question card this turn:"
|
|
93
|
+
DECISION_BLOCK_REASON_SUFFIX = (
|
|
94
|
+
"\nPut the decision on a question card now: call AskUserQuestion, or mcp__hearthbot__ask_decision or"
|
|
95
|
+
" mcp__hearthbot__request_decision, with two or three choices. Then end the reply with the Blocked line."
|
|
72
96
|
)
|
|
73
97
|
ASK_CLOSE_SUFFIX = (
|
|
74
98
|
"\nClose a finished ask with a 'Done: #ask-N' line. Close an ask or a carried plan item the user"
|
|
@@ -32,12 +32,21 @@ SEPARATOR_PIECE = "separator"
|
|
|
32
32
|
COMMENT_PIECE = "comment"
|
|
33
33
|
LINE_CONTINUATION_PIECE = "continuation"
|
|
34
34
|
UNCLOSED_QUOTE_PIECE = "unclosed"
|
|
35
|
+
DOUBLE_QUOTE = '"'
|
|
36
|
+
LINE_END = "\n"
|
|
37
|
+
ESCAPED_CHARACTER_GROUP = 1
|
|
38
|
+
DOUBLE_QUOTED_ESCAPE_PATTERN = re.compile(r"\\(.)", re.DOTALL)
|
|
39
|
+
ALL_PIECE_REPLACEMENT_TEXTS: dict[str | None, str] = {
|
|
40
|
+
SEPARATOR_PIECE: SEGMENT_BREAK,
|
|
41
|
+
COMMENT_PIECE: REMOVED_TEXT,
|
|
42
|
+
LINE_CONTINUATION_PIECE: REMOVED_TEXT,
|
|
43
|
+
}
|
|
35
44
|
COMMAND_PIECE_PATTERN = re.compile(
|
|
36
45
|
r"'[^']*'"
|
|
37
46
|
r'|"(?:\\.|[^"\\])*"'
|
|
38
47
|
rf"|(?P<{LINE_CONTINUATION_PIECE}>\\\n)"
|
|
39
48
|
rf"|\\.[^{re.escape(COMMAND_WHITESPACE + COMMAND_SEPARATORS)}'\"\\]*"
|
|
40
|
-
rf"|(?P<{COMMENT_PIECE}>(?<![^{re.escape(COMMAND_WHITESPACE + COMMAND_SEPARATORS)}])
|
|
49
|
+
rf"|(?P<{COMMENT_PIECE}>(?<![^{re.escape(COMMAND_WHITESPACE + COMMAND_SEPARATORS)}])#[^\n]*)"
|
|
41
50
|
rf"|(?P<{SEPARATOR_PIECE}>[{re.escape(COMMAND_SEPARATORS)}])"
|
|
42
51
|
rf"|(?P<{UNCLOSED_QUOTE_PIECE}>['\"])"
|
|
43
52
|
r"|.",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Constants for the gate that holds a review thread resolve until a reply on it posts."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from hooks_constants.reply_length_gate_constants import (
|
|
6
|
+
MAXIMUM_SENTENCE_COUNT,
|
|
7
|
+
MAXIMUM_WORDS_PER_SENTENCE,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
RESOLVE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__resolve_review_thread$")
|
|
11
|
+
ALL_REPLY_TOOL_NAME_SUFFIXES = ("__add_reply_to_pull_request_comment",)
|
|
12
|
+
SHELL_COMMAND_KEY = "command"
|
|
13
|
+
SHELL_REPLY_COMMAND_PATTERN = re.compile(
|
|
14
|
+
r"\bgh\s+api\b.*(?:/comments/\d+/replies\b|addPullRequestReviewThreadReply)", re.DOTALL
|
|
15
|
+
)
|
|
16
|
+
MISSING_REPLY_REASON = (
|
|
17
|
+
"This review thread has no reply that posted since the last user prompt and the last resolve."
|
|
18
|
+
" Post the reply with add_reply_to_pull_request_comment, confirm its result came back without"
|
|
19
|
+
" an error, then resolve the thread. A reply that a hook refused did not post; shorten it to"
|
|
20
|
+
f" at most {MAXIMUM_SENTENCE_COUNT} sentences of at most {MAXIMUM_WORDS_PER_SENTENCE} words each,"
|
|
21
|
+
" footer included, and send it again."
|
|
22
|
+
)
|
|
@@ -47,3 +47,15 @@ def test_every_exported_name_resolves_to_a_module_attribute() -> None:
|
|
|
47
47
|
]
|
|
48
48
|
|
|
49
49
|
assert all_missing_names == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_every_template_path_sits_under_the_package_root() -> None:
|
|
53
|
+
package_root = artifact_template_pointer_constants.PACKAGE_ROOT_DIRECTORY
|
|
54
|
+
|
|
55
|
+
assert (package_root / "hooks" / "hooks_constants").is_dir()
|
|
56
|
+
for each_path in (
|
|
57
|
+
artifact_template_pointer_constants.ARTIFACT_TEMPLATE_PATH,
|
|
58
|
+
artifact_template_pointer_constants.HTML_PLAN_TEMPLATE_README_PATH,
|
|
59
|
+
artifact_template_pointer_constants.STATUS_FLOWCHART_TEMPLATE_PATH,
|
|
60
|
+
):
|
|
61
|
+
assert each_path.relative_to(package_root).parts[:2] == ("docs", "templates")
|
|
@@ -37,6 +37,7 @@ from hooks_constants.artifact_template_pointer_constants import (
|
|
|
37
37
|
ARTIFACT_TEMPLATE_PATH,
|
|
38
38
|
ARTIFACT_TOOL_NAME,
|
|
39
39
|
HTML_PLAN_TEMPLATE_README_PATH,
|
|
40
|
+
PACKAGE_ROOT_DIRECTORY,
|
|
40
41
|
POINTER_TEXT_TEMPLATE,
|
|
41
42
|
SKILL_NAME_INPUT_KEY,
|
|
42
43
|
SKILL_TOOL_NAME,
|
|
@@ -53,6 +54,28 @@ from hooks_constants.spawn_readiness_hook_constants import (
|
|
|
53
54
|
TOOL_INPUT_KEY,
|
|
54
55
|
TOOL_NAME_KEY,
|
|
55
56
|
)
|
|
57
|
+
from open_task_asks import claude_home
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def installed_template_path(package_template_path: Path) -> Path:
|
|
61
|
+
"""Return the template path that exists where this hook runs.
|
|
62
|
+
|
|
63
|
+
A source checkout keeps ``docs`` beside ``hooks``. An install puts hooks
|
|
64
|
+
under the agents home and docs under the Claude home, so the same relative
|
|
65
|
+
path is read from the Claude home there::
|
|
66
|
+
|
|
67
|
+
<checkout>/packages/claude-dev-env/docs/templates/x.md -> kept
|
|
68
|
+
~/.agents/docs/templates/x.md (missing) -> ~/.claude/docs/templates/x.md
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
package_template_path: A template path under the package root.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The package path when it exists, else the same relative path under the Claude home.
|
|
75
|
+
"""
|
|
76
|
+
if package_template_path.exists():
|
|
77
|
+
return package_template_path
|
|
78
|
+
return claude_home() / package_template_path.relative_to(PACKAGE_ROOT_DIRECTORY)
|
|
56
79
|
|
|
57
80
|
|
|
58
81
|
def starts_an_artifact_page(tool_name: object, tool_input: object) -> bool:
|
|
@@ -85,9 +108,9 @@ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object]
|
|
|
85
108
|
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
86
109
|
HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
|
|
87
110
|
ADDITIONAL_CONTEXT_KEY: POINTER_TEXT_TEMPLATE.format(
|
|
88
|
-
path_of_status_flowchart=STATUS_FLOWCHART_TEMPLATE_PATH,
|
|
89
|
-
path_of_plan_readme=HTML_PLAN_TEMPLATE_README_PATH,
|
|
90
|
-
path_of_page_readme=ARTIFACT_TEMPLATE_PATH,
|
|
111
|
+
path_of_status_flowchart=installed_template_path(STATUS_FLOWCHART_TEMPLATE_PATH),
|
|
112
|
+
path_of_plan_readme=installed_template_path(HTML_PLAN_TEMPLATE_README_PATH),
|
|
113
|
+
path_of_page_readme=installed_template_path(ARTIFACT_TEMPLATE_PATH),
|
|
91
114
|
),
|
|
92
115
|
}
|
|
93
116
|
}
|
|
@@ -3,6 +3,8 @@ import subprocess
|
|
|
3
3
|
import sys
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
6
8
|
_SESSION_DIR = Path(__file__).resolve().parent
|
|
7
9
|
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
8
10
|
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
@@ -99,3 +101,17 @@ def test_empty_stdin_stays_quiet() -> None:
|
|
|
99
101
|
[sys.executable, str(HOOK_SCRIPT)], input="", capture_output=True, text=True, check=True
|
|
100
102
|
)
|
|
101
103
|
assert completed.stdout == ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_installed_template_path_should_keep_a_package_path_that_exists() -> None:
|
|
107
|
+
assert artifact_template_pointer.installed_template_path(ARTIFACT_TEMPLATE_PATH) == ARTIFACT_TEMPLATE_PATH
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_installed_template_path_should_read_the_claude_home_when_the_package_path_is_missing(
|
|
111
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
112
|
+
) -> None:
|
|
113
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
|
|
114
|
+
missing_package_path = artifact_template_pointer.PACKAGE_ROOT_DIRECTORY / "docs" / "templates" / "absent" / "README.md"
|
|
115
|
+
assert artifact_template_pointer.installed_template_path(missing_package_path) == (
|
|
116
|
+
tmp_path / "docs" / "templates" / "absent" / "README.md"
|
|
117
|
+
)
|