claude-dev-env 8.58.0 → 8.59.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.
- package/.agents/skills/issue-tracker/SKILL.md +2 -0
- package/.agents/skills/orchestrator/SKILL.md +1 -0
- package/_shared/advisor/reference/advisor-block.md +1 -0
- package/docs/references/advisor-tool.md +1 -1
- package/hooks/blocking/issue_close_handoff_gate.py +72 -0
- package/hooks/blocking/test_issue_close_handoff_gate.py +126 -0
- package/hooks/features/conduct-gates.md +4 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/advisor_rules_prompt_constants.py +1 -0
- package/hooks/hooks_constants/issue_close_handoff_gate_constants.py +46 -0
- package/hooks/hooks_constants/test_issue_close_handoff_gate_constants.py +17 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +1 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -81,6 +81,8 @@ GitHub closes the sub-issue when someone **merges** a pull request into the **de
|
|
|
81
81
|
|
|
82
82
|
**Not enough:** a Development "related" link with no closing keyword. **Not enough:** closing the PR without merging.
|
|
83
83
|
|
|
84
|
+
**A defect found while working the issue stays on the issue.** Keep the issue open and fix the defect through this issue's pull request. A close that routes the defect to another issue or epic leaves the work unfinished, and `hooks/blocking/issue_close_handoff_gate.py` denies it.
|
|
85
|
+
|
|
84
86
|
**Who owns `#N`:** the agent or session writing the PR must use the correct sub-issue number. Nothing in this package verifies the number matches the fix.
|
|
85
87
|
|
|
86
88
|
## Return shape
|
|
@@ -101,6 +101,7 @@ Send follow-ups only through a transport authorized by the current runtime and u
|
|
|
101
101
|
|
|
102
102
|
Read the evidence needed for your decision. Keep lengthy output in files and return short evidence pointers.
|
|
103
103
|
Check scope, acceptance results, and unresolved work before accepting a worker's conclusion.
|
|
104
|
+
Review every close a worker reports. Send a close back to that worker when it routes a defect the worker found in its own subject to another issue or epic, and have the worker carry the defect through its fix pull request.
|
|
104
105
|
Use independent verification where the task or repository requires it.
|
|
105
106
|
Checkpoint after decisions and state changes, before waiting, and before a known compaction.
|
|
106
107
|
|
|
@@ -42,6 +42,7 @@ Subagents inherit the session's advisor tool, so the executor calls it directly.
|
|
|
42
42
|
|
|
43
43
|
> Send your first consult right after orientation and before your first write.
|
|
44
44
|
> Send a completion consult once your writes and test output exist — that consult asks the advisor to hunt for missing requirements, untested behavior, wrong assumptions, unhandled edge cases, evidence gaps, and early completion claims.
|
|
45
|
+
> A defect your task found in its own subject stays with you through its fix pull request; a close that routes it to another issue or epic is an early completion claim.
|
|
45
46
|
> Consult before reaching for any task-list tool — the advisor's plan becomes the task list.
|
|
46
47
|
> Aim for two consults on a normal task: early orientation and completion review. Reserve a third for recovery or reconciliation, and add a consult when a material fork produces new evidence.
|
|
47
48
|
> Embed this line in each consult: `(Advisor: please keep your guidance under 80 words — I need a focused starting point, not a comprehensive plan.)`
|
|
@@ -18,7 +18,7 @@ If the task needs orientation first (find files, fetch a source, see what exists
|
|
|
18
18
|
|
|
19
19
|
Also call:
|
|
20
20
|
|
|
21
|
-
- **When you believe the task is complete.** Before this call, make the deliverable durable: write the file, save the result, commit the change. The call takes time; if the session ends during it, a durable result survives and an unwritten one does not. Ask the advisor to hunt for missing requirements, untested behavior, wrong assumptions, unhandled edge cases, evidence gaps, and early completion claims.
|
|
21
|
+
- **When you believe the task is complete.** Before this call, make the deliverable durable: write the file, save the result, commit the change. The call takes time; if the session ends during it, a durable result survives and an unwritten one does not. Ask the advisor to hunt for missing requirements, untested behavior, wrong assumptions, unhandled edge cases, evidence gaps, and early completion claims. A defect the task found in its own subject stays with the task through its fix pull request, so a close that routes that defect to another issue or epic is an early completion claim.
|
|
22
22
|
- **When stuck** — errors recur, approach does not converge, results do not fit.
|
|
23
23
|
- **When considering a change of approach.**
|
|
24
24
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook that keeps a found defect with the issue that found it.
|
|
3
|
+
|
|
4
|
+
The gate reads GitHub issue comment and issue write calls. It denies a call
|
|
5
|
+
that closes an issue and, in the same text, routes a defect to another issue
|
|
6
|
+
or epic. A close comes from a closed state in the call or from closing words
|
|
7
|
+
in the text. The session that found the defect fixes it through a pull request
|
|
8
|
+
and closes the issue there.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if hooks_root_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, hooks_root_directory)
|
|
19
|
+
|
|
20
|
+
from gh_post_body_texts import extract_mcp_body_texts
|
|
21
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
22
|
+
from hooks_constants.issue_close_handoff_gate_constants import (
|
|
23
|
+
ALL_HANDOFF_PATTERNS,
|
|
24
|
+
ALL_ISSUE_TOOL_SUFFIXES,
|
|
25
|
+
ALLOW_EXIT_CODE,
|
|
26
|
+
BLOCK_EXIT_CODE,
|
|
27
|
+
BODY_TEXT_SEPARATOR,
|
|
28
|
+
CLOSE_DECLARATION_PATTERN,
|
|
29
|
+
CLOSED_STATE,
|
|
30
|
+
HANDOFF_CLOSE_MESSAGE,
|
|
31
|
+
HOOK_EVENT_NAME,
|
|
32
|
+
RETRY_INSTRUCTION,
|
|
33
|
+
STATE_KEY,
|
|
34
|
+
TOOL_INPUT_KEY,
|
|
35
|
+
TOOL_NAME_KEY,
|
|
36
|
+
)
|
|
37
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_handoff_close(all_tool_input: dict[str, object]) -> bool:
|
|
41
|
+
"""Return True when the call closes an issue and routes a defect elsewhere."""
|
|
42
|
+
body_text = BODY_TEXT_SEPARATOR.join(extract_mcp_body_texts(all_tool_input))
|
|
43
|
+
if not any(each_pattern.search(body_text) for each_pattern in ALL_HANDOFF_PATTERNS):
|
|
44
|
+
return False
|
|
45
|
+
is_closed_state = all_tool_input.get(STATE_KEY) == CLOSED_STATE
|
|
46
|
+
return is_closed_state or CLOSE_DECLARATION_PATTERN.search(body_text) is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> int:
|
|
50
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
51
|
+
if hook_input is None:
|
|
52
|
+
return ALLOW_EXIT_CODE
|
|
53
|
+
tool_name = hook_input.get(TOOL_NAME_KEY)
|
|
54
|
+
if not isinstance(tool_name, str) or not tool_name.endswith(ALL_ISSUE_TOOL_SUFFIXES):
|
|
55
|
+
return ALLOW_EXIT_CODE
|
|
56
|
+
tool_input = hook_input.get(TOOL_INPUT_KEY)
|
|
57
|
+
if not isinstance(tool_input, dict) or not is_handoff_close(tool_input):
|
|
58
|
+
return ALLOW_EXIT_CODE
|
|
59
|
+
block_reason = HANDOFF_CLOSE_MESSAGE + RETRY_INSTRUCTION
|
|
60
|
+
log_hook_block(
|
|
61
|
+
Path(__file__).name,
|
|
62
|
+
HOOK_EVENT_NAME,
|
|
63
|
+
block_reason,
|
|
64
|
+
tool_name=tool_name,
|
|
65
|
+
offending_input_preview=str(tool_input),
|
|
66
|
+
)
|
|
67
|
+
sys.stderr.write(block_reason)
|
|
68
|
+
return BLOCK_EXIT_CODE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
sys.exit(main())
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import issue_close_handoff_gate
|
|
8
|
+
|
|
9
|
+
COMMENT_TOOL_NAME = "mcp__github__add_issue_comment"
|
|
10
|
+
WRITE_TOOL_NAME = "mcp__github__issue_write"
|
|
11
|
+
HANDOFF_CLOSE_BODY = (
|
|
12
|
+
"## Fresh-session reading: criteria met, issue closed\n\n"
|
|
13
|
+
"Closing on that basis. Reopen if the comparison matters more.\n\n"
|
|
14
|
+
"Out of scope here and not attributed: the first prompt still grew by 30k."
|
|
15
|
+
" The first-prompt growth belongs to #20."
|
|
16
|
+
)
|
|
17
|
+
REOPEN_BODY = (
|
|
18
|
+
"## Reopened: the reading came before any message\n\n"
|
|
19
|
+
"The growth after the first message moves the open question to #20 for context."
|
|
20
|
+
" The issue stays open until the transcript is read."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_gate(
|
|
25
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
26
|
+
capsys: pytest.CaptureFixture[str],
|
|
27
|
+
tool_name: str,
|
|
28
|
+
tool_input: dict[str, object],
|
|
29
|
+
) -> tuple[int, str]:
|
|
30
|
+
hook_input = {
|
|
31
|
+
"hook_event_name": "PreToolUse",
|
|
32
|
+
"tool_name": tool_name,
|
|
33
|
+
"tool_input": tool_input,
|
|
34
|
+
}
|
|
35
|
+
monkeypatch.setattr(
|
|
36
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
37
|
+
)
|
|
38
|
+
exit_code = issue_close_handoff_gate.main()
|
|
39
|
+
return exit_code, capsys.readouterr().err
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture(autouse=True)
|
|
43
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
44
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
45
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_should_deny_a_closing_comment_that_routes_the_found_defect_to_another_issue(
|
|
49
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
50
|
+
) -> None:
|
|
51
|
+
exit_code, stderr_text = run_gate(
|
|
52
|
+
monkeypatch, capsys, COMMENT_TOOL_NAME, {"issue_number": 10, "body": HANDOFF_CLOSE_BODY}
|
|
53
|
+
)
|
|
54
|
+
assert exit_code == 2
|
|
55
|
+
assert "owns it through the fix pull request" in stderr_text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_should_deny_a_closed_state_write_whose_comment_hands_the_defect_off(
|
|
59
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
60
|
+
) -> None:
|
|
61
|
+
exit_code, _ = run_gate(
|
|
62
|
+
monkeypatch,
|
|
63
|
+
capsys,
|
|
64
|
+
WRITE_TOOL_NAME,
|
|
65
|
+
{"state": "closed", "body": "Done. The leftover growth is tracked in #20."},
|
|
66
|
+
)
|
|
67
|
+
assert exit_code == 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_deny_a_close_that_calls_the_defect_a_separate_finding(
|
|
71
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
72
|
+
) -> None:
|
|
73
|
+
exit_code, _ = run_gate(
|
|
74
|
+
monkeypatch,
|
|
75
|
+
capsys,
|
|
76
|
+
COMMENT_TOOL_NAME,
|
|
77
|
+
{"body": "Closing this issue. The jump is a separate finding for the epic."},
|
|
78
|
+
)
|
|
79
|
+
assert exit_code == 2
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_should_allow_a_comment_that_routes_work_while_the_issue_stays_open(
|
|
83
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
84
|
+
) -> None:
|
|
85
|
+
exit_code, stderr_text = run_gate(monkeypatch, capsys, COMMENT_TOOL_NAME, {"body": REOPEN_BODY})
|
|
86
|
+
assert exit_code == 0
|
|
87
|
+
assert stderr_text == ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_should_allow_a_close_with_a_plain_cross_reference(
|
|
91
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
92
|
+
) -> None:
|
|
93
|
+
exit_code, _ = run_gate(
|
|
94
|
+
monkeypatch,
|
|
95
|
+
capsys,
|
|
96
|
+
WRITE_TOOL_NAME,
|
|
97
|
+
{"state": "closed", "body": "Closes #12. #13 tracks the docs."},
|
|
98
|
+
)
|
|
99
|
+
assert exit_code == 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_allow_a_close_with_no_handoff(
|
|
103
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
104
|
+
) -> None:
|
|
105
|
+
exit_code, _ = run_gate(
|
|
106
|
+
monkeypatch,
|
|
107
|
+
capsys,
|
|
108
|
+
COMMENT_TOOL_NAME,
|
|
109
|
+
{"body": "Fixed by the merged pull request. Closing this issue."},
|
|
110
|
+
)
|
|
111
|
+
assert exit_code == 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_should_ignore_tools_outside_the_issue_set(
|
|
115
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
116
|
+
) -> None:
|
|
117
|
+
exit_code, _ = run_gate(monkeypatch, capsys, "mcp__chat__reply", {"body": HANDOFF_CLOSE_BODY})
|
|
118
|
+
assert exit_code == 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_is_handoff_close_should_need_both_a_close_and_a_handoff() -> None:
|
|
122
|
+
assert issue_close_handoff_gate.is_handoff_close({"body": HANDOFF_CLOSE_BODY})
|
|
123
|
+
assert not issue_close_handoff_gate.is_handoff_close({"body": REOPEN_BODY})
|
|
124
|
+
assert not issue_close_handoff_gate.is_handoff_close(
|
|
125
|
+
{"state": "closed", "body": "Fixed and verified."}
|
|
126
|
+
)
|
|
@@ -6,6 +6,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
6
6
|
|
|
7
7
|
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, and requires a readable link when the text names a pull request number.
|
|
8
8
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
9
|
+
- `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.
|
|
9
10
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
10
11
|
- `blocking/session_title_stop_gate.py` blocks a turn end once when no session title call succeeded in that turn.
|
|
11
12
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
@@ -16,6 +17,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
16
17
|
|
|
17
18
|
- `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message`, timeout `10` seconds in `hooks.json`.
|
|
18
19
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
20
|
+
- `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`.
|
|
19
21
|
- `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
|
|
20
22
|
- `blocking/session_title_stop_gate.py` runs on `Stop`, no matcher, timeout `10` seconds in `hooks.json`.
|
|
21
23
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
@@ -30,6 +32,7 @@ Preconditions:
|
|
|
30
32
|
|
|
31
33
|
- **Reply length.** Input is a reply with four sentences or a plain pull request number. 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 or link reason.
|
|
32
34
|
- **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.
|
|
35
|
+
- **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.
|
|
33
36
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
34
37
|
- **Title at turn end.** Input is a remote session transcript whose last turn set no title. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_stop_gate.py -q`. The adjacent test observes a block; a turn with a successful title call passes.
|
|
35
38
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
@@ -42,4 +45,5 @@ Preconditions:
|
|
|
42
45
|
- `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
|
|
43
46
|
- 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.
|
|
44
47
|
- The edit marker gate scans replacement cards as well as the message text.
|
|
48
|
+
- 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.
|
|
45
49
|
- 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.
|
package/hooks/hooks.json
CHANGED
|
@@ -117,6 +117,16 @@
|
|
|
117
117
|
}
|
|
118
118
|
]
|
|
119
119
|
},
|
|
120
|
+
{
|
|
121
|
+
"matcher": "mcp__.*__(add_issue_comment|issue_write|update_issue_comment)",
|
|
122
|
+
"hooks": [
|
|
123
|
+
{
|
|
124
|
+
"type": "command",
|
|
125
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/issue_close_handoff_gate.py",
|
|
126
|
+
"timeout": 10
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
},
|
|
120
130
|
{
|
|
121
131
|
"matcher": "mcp__.*__set_session_title",
|
|
122
132
|
"hooks": [
|
|
@@ -37,6 +37,7 @@ ALL_ADVISOR_RULE_SENTENCES = (
|
|
|
37
37
|
"This cadence guides planning and leaves the task free to follow its evidence.",
|
|
38
38
|
"Short reactive tasks may use the single consult that best fits the live decision.",
|
|
39
39
|
"Ask the advisor to hunt for missing requirements, untested behavior, wrong assumptions, unhandled edge cases, evidence gaps, and early completion claims.",
|
|
40
|
+
"A defect the task found in its own subject stays with the task through its fix pull request, so a close that routes that defect to another issue or epic is an early completion claim.",
|
|
40
41
|
"Give the advice serious weight.",
|
|
41
42
|
)
|
|
42
43
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Constants for the issue close handoff gate PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
ALL_ISSUE_TOOL_SUFFIXES: tuple[str, ...] = (
|
|
6
|
+
"add_issue_comment",
|
|
7
|
+
"issue_write",
|
|
8
|
+
"update_issue_comment",
|
|
9
|
+
)
|
|
10
|
+
TOOL_NAME_KEY = "tool_name"
|
|
11
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
12
|
+
STATE_KEY = "state"
|
|
13
|
+
CLOSED_STATE = "closed"
|
|
14
|
+
HOOK_EVENT_NAME = "PreToolUse"
|
|
15
|
+
ALLOW_EXIT_CODE = 0
|
|
16
|
+
BLOCK_EXIT_CODE = 2
|
|
17
|
+
BODY_TEXT_SEPARATOR = "\n"
|
|
18
|
+
ISSUE_REFERENCE = r"(?:#|\bissue\s+)\d+"
|
|
19
|
+
CLOSE_DECLARATION_PATTERN = re.compile(
|
|
20
|
+
r"\b(?:clos(?:e|es|ed|ing)\s+(?:this|the|it|on|now|issue)\b"
|
|
21
|
+
r"|issue\s+(?:is\s+)?closed\b)",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
ALL_HANDOFF_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
25
|
+
re.compile(rf"\bbelongs?\s+(?:to|in|with|under)\s+{ISSUE_REFERENCE}", re.IGNORECASE),
|
|
26
|
+
re.compile(
|
|
27
|
+
rf"\b(?:rout(?:e|es|ed|ing)|hand(?:s|ed|ing)?\s+off|mov(?:e|es|ed|ing)"
|
|
28
|
+
rf"|defer(?:s|red|ring)?|punt(?:s|ed|ing)?)\b[^.\n]{{0,80}}?\bto\s+{ISSUE_REFERENCE}",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
),
|
|
31
|
+
re.compile(
|
|
32
|
+
rf"\b(?:tracked|handled|owned|covered|continues)\s+(?:in|by|under)\s+{ISSUE_REFERENCE}",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
),
|
|
35
|
+
re.compile(r"\bseparate\s+finding\b", re.IGNORECASE),
|
|
36
|
+
)
|
|
37
|
+
HANDOFF_CLOSE_MESSAGE = (
|
|
38
|
+
"This close hands a defect found in the issue's subject to another issue."
|
|
39
|
+
" The session that found the defect owns it through the fix pull request."
|
|
40
|
+
" Keep this issue open, fix the defect, and close the issue through that"
|
|
41
|
+
" pull request."
|
|
42
|
+
)
|
|
43
|
+
RETRY_INSTRUCTION = (
|
|
44
|
+
" When the other issue owned the work before this issue found it, link it"
|
|
45
|
+
" as related in a post that does not close this issue."
|
|
46
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from hooks_constants import issue_close_handoff_gate_constants as constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_gate_checks_every_issue_comment_and_write_tool() -> None:
|
|
5
|
+
assert constants.ALL_ISSUE_TOOL_SUFFIXES == (
|
|
6
|
+
"add_issue_comment",
|
|
7
|
+
"issue_write",
|
|
8
|
+
"update_issue_comment",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_gate_exit_codes_match_the_hook_contract() -> None:
|
|
13
|
+
assert (constants.ALLOW_EXIT_CODE, constants.BLOCK_EXIT_CODE) == (0, 2)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_close_declaration_skips_a_reopen_heading() -> None:
|
|
17
|
+
assert constants.CLOSE_DECLARATION_PATTERN.search("## Reopened: the reading") is None
|
package/package.json
CHANGED
|
@@ -131,6 +131,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
131
131
|
hooks/blocking/step_note_gate.py -> exempt
|
|
132
132
|
hooks/blocking/reply_length_gate.py -> exempt
|
|
133
133
|
hooks/blocking/edit_marker_gate.py -> exempt
|
|
134
|
+
hooks/blocking/issue_close_handoff_gate.py -> exempt
|
|
134
135
|
hooks/blocking/verify_before_acting.py -> exempt
|
|
135
136
|
hooks/blocking/session_title_format_gate.py -> exempt
|
|
136
137
|
hooks/blocking/session_title_stop_gate.py -> exempt
|
|
@@ -67,6 +67,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
67
67
|
"blocking/step_note_gate.py",
|
|
68
68
|
"blocking/reply_length_gate.py",
|
|
69
69
|
"blocking/edit_marker_gate.py",
|
|
70
|
+
"blocking/issue_close_handoff_gate.py",
|
|
70
71
|
"blocking/verify_before_acting.py",
|
|
71
72
|
"blocking/session_title_format_gate.py",
|
|
72
73
|
"blocking/session_title_stop_gate.py",
|
|
@@ -82,6 +82,14 @@ def test_new_edit_marker_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
|
82
82
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def test_new_issue_close_handoff_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
86
|
+
current_document = _hook_document(
|
|
87
|
+
["hooks/blocking/issue_close_handoff_gate.py"],
|
|
88
|
+
'{"hooks": {}}',
|
|
89
|
+
)
|
|
90
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
91
|
+
|
|
92
|
+
|
|
85
93
|
def test_new_verify_before_acting_registration_is_clean(tmp_path: Path) -> None:
|
|
86
94
|
current_document = _hook_document(
|
|
87
95
|
["hooks/blocking/verify_before_acting.py"],
|