claude-dev-env 8.57.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.
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: correction
3
+ description: Turn a correction the user just made into a short handoff brief they paste into a corrections intake. Use when the user types /correction followed by what the agent got wrong.
4
+ argument-hint: "<what the agent got wrong, in the user's words>"
5
+ ---
6
+
7
+ # /correction
8
+
9
+ The user corrected an agent and wants the fix to stick. Write one brief they can paste into the place that turns corrections into lasting fixes.
10
+
11
+ `$ARGUMENTS` is the user's statement of the correction. Fill the other fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. When the session does not settle a field, write `unknown` there.
12
+
13
+ Print the brief as one fenced `text` block, and nothing after it:
14
+
15
+ ```text
16
+ Correction: <$ARGUMENTS, word for word>
17
+ Asked: <what the user asked for, quoted where a message carries it>
18
+ Agent did: <what the agent did, with the reply, file, or command that shows it>
19
+ Corrected to: <what the user wanted instead, or the fix already made>
20
+ Where: <repository, branch or pull request, files, tool, or thread>
21
+ Evidence: <file:line, log line, or quoted message that shows the miss>
22
+ Repeat: <yes, with the earlier time it happened | no | unknown>
23
+ ```
24
+
25
+ Keep each field to one or two lines. Leave out the layer and the fix design; the intake decides those.
@@ -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.)`
@@ -21,6 +21,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
21
21
  'context7-mcp',
22
22
  'copilot-finding-triage',
23
23
  'copilot-review',
24
+ 'correction',
24
25
  'deep-research',
25
26
  'descriptions',
26
27
  'e-code-review',
@@ -41,6 +41,10 @@ test('EVER_SHIPPED_SKILL_NAMES includes the pull request lifecycle skill', () =>
41
41
  assert.equal(EVER_SHIPPED_SKILL_NAMES.has('pr-lifecycle'), true);
42
42
  });
43
43
 
44
+ test('EVER_SHIPPED_SKILL_NAMES includes the correction skill', () => {
45
+ assert.equal(EVER_SHIPPED_SKILL_NAMES.has('correction'), true);
46
+ });
47
+
44
48
  test('EVER_SHIPPED_SKILL_NAMES includes the windows scheduled task skill', () => {
45
49
  assert.equal(EVER_SHIPPED_SKILL_NAMES.has('windows-scheduled-task'), true);
46
50
  });
@@ -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
 
@@ -16,7 +16,7 @@
16
16
  | Readable text | Text is 16px or larger and uses the full text color. Gray stays on lines and borders only. Text holds a 7:1 contrast ratio against its background. |
17
17
  | Touch targets | Buttons and chips are 40px or taller, with a visible focus ring. |
18
18
  | Copy fallback | The copy button uses the clipboard API and falls back to a hidden text area, then says when the copy is blocked. |
19
- | Review rounds | Parts changed since the viewer's last look carry a thin bar on their left edge, and changed cards a "Changed" label. [review-rounds.md](review-rounds.md) holds the data fields and behavior. |
19
+ | Review rounds | Only the parts changed since the viewer's last look carry a thin bar and a "Changed" label. [review-rounds.md](review-rounds.md) holds the data fields and behavior. |
20
20
 
21
21
  ## House rules for a page built from it
22
22
 
@@ -24,3 +24,13 @@
24
24
  - A page that needs extra files publishes them beside the page through the Artifact `files` field, and links them by relative path.
25
25
  - A page that needs live data or saved answers declares the matching runtime capability. `localStorage` holds only per-viewer conveniences.
26
26
  - Render the page at 400px in light and dark, and exercise every control, before publishing.
27
+
28
+ ## Convert an existing page
29
+
30
+ Ask an agent: "convert <artifact link> to our format."
31
+
32
+ 1. Read the published page with the Artifact tool's `read` action, and keep its data and every working control.
33
+ 2. Map each part to its piece in `template.html`: status groups to board lanes, records to picture cards, filters to tabs and chips, and the data to the JSON data block.
34
+ 3. Apply the house rules above and the template's color tokens.
35
+ 4. Check that text is 16px or larger, text contrast is 7:1 or better, and the page has no horizontal scroll at 390px, in light and dark.
36
+ 5. Publish to the same link as a new version, so the reviewer keeps one URL.
@@ -1,6 +1,6 @@
1
1
  # Marking what changed between review rounds
2
2
 
3
- A page that a reviewer reads more than once shows what changed since their last look. The template marks each changed card and each changed part with a thin bar on its left edge, gives each changed card a small "Changed" label, and gives each changed board chip a dot in the same color. There is no diff and no list of changes.
3
+ A page that a reviewer reads more than once shows what changed since their last look. The template marks only the parts that changed: each one gets a thin bar on its left edge and a small "Changed" label at its start. The card around them stays as it is, and its board chip gets a dot in the same color. There is no diff and no list of changes.
4
4
 
5
5
  ## Data
6
6
 
@@ -8,7 +8,7 @@ A page that a reviewer reads more than once shows what changed since their last
8
8
  |---|---|---|
9
9
  | `round` | top of the data block | The review round this revision answers. Add one each time the page is republished after feedback. |
10
10
  | `changed_round` | an item | The round in which this item last changed. |
11
- | `changed_parts` | an item, optional | The parts that changed: `title`, `next`, `summary`, `steps`, `links`. With no list, only the card is marked. |
11
+ | `changed_parts` | an item | The parts that changed: `title`, `next`, `summary`, `steps`, `links`. An item with no list gets no mark. |
12
12
 
13
13
  When a revision answers feedback, set `round` to the new number and stamp each item it changed with that number and the parts it touched. Leave older stamps in place; they stop showing once the reviewer has seen their round.
14
14
 
@@ -21,6 +21,6 @@ When a revision answers feedback, set `round` to the new number and stamp each i
21
21
 
22
22
  ## Other pages
23
23
 
24
- Any element with `data-changed-round="N"` takes the bar. An element that holds a `data-change-slot` child gets the "Changed" label in that child; other elements get a screen reader label.
24
+ Stamp the smallest element that changed with `data-changed-round="N"`; it takes the bar and the "Changed" label, and a `<details>` element takes the label in its summary. Never stamp a whole card or section. An element with `data-change-mark="dot"` takes the dot and a screen reader label in place of the bar and label.
25
25
 
26
26
  The `--change` and `--change-ink` tokens hold the mark colors in light and dark. Keep the label text at 7:1 or better against `--change`, and the bar at 3:1 or better against `--surface` and `--bg`.
@@ -100,12 +100,9 @@ details.what[open] summary::before { content: "▾" }
100
100
  .step .num { flex: none; width: 30px; height: 30px; border-radius: 50%; background: var(--warn); color: var(--surface); display: grid; place-items: center; font-weight: 800 }
101
101
  .lchip.changed::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--change); flex: none }
102
102
  .changed { box-shadow: inset 4px 0 0 var(--change); padding-left: 12px; border-radius: 4px }
103
- .card.changed { box-shadow: inset 6px 0 0 var(--change); padding-left: 0; border-radius: 16px }
104
- .card.changed .band, .card.changed > .body { padding-left: 18px }
105
- .card.changed .body .changed { padding-left: 12px }
106
103
  .next.changed { border-radius: 12px }
107
104
  .lchip.changed { box-shadow: none; border-radius: 999px; padding-left: 10px; border-color: var(--change); display: inline-flex; align-items: center; gap: 6px }
108
- .pill-changed { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border-radius: 999px; background: var(--change); color: var(--change-ink); font-size: 16px; font-weight: 800 }
105
+ .pill-changed { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border-radius: 999px; background: var(--change); color: var(--change-ink); font-size: 16px; font-weight: 800; margin-right: 8px; vertical-align: middle; flex: none; white-space: nowrap }
109
106
  .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap }
110
107
  .empty { background: var(--surface); border: 2px dashed var(--edge); border-radius: 14px; padding: 20px; font-weight: 600 }
111
108
  @media (hover: hover) { .lchip:hover, .fchip:hover, .tab:hover, .copy:hover { border-color: var(--accent) } }
@@ -336,17 +333,16 @@ function changeBaseline() {
336
333
  return baseline;
337
334
  }
338
335
  function stampChange(node, it, part) {
339
- if (it.changed_round && (!part || [].concat(it.changed_parts || []).includes(part))) node.dataset.changedRound = it.changed_round;
336
+ const parts = [].concat(it.changed_parts || []);
337
+ if (it.changed_round && (part ? parts.includes(part) : parts.length)) node.dataset.changedRound = it.changed_round;
340
338
  return node;
341
339
  }
342
340
  function markChanges() {
343
341
  for (const node of document.querySelectorAll("[data-changed-round]")) {
344
342
  if (Number(node.dataset.changedRound) <= CHANGE_BASELINE || node.classList.contains("changed")) continue;
345
343
  node.classList.add("changed");
346
- const slot = node.querySelector("[data-change-slot]");
347
- if (slot) { slot.prepend(el("span", "pill-changed", "Changed")); continue; }
348
344
  const label = node.querySelector(":scope > summary") || node;
349
- label.prepend(el("span", "sr", "Changed. "));
345
+ label.prepend(node.dataset.changeMark === "dot" ? el("span", "sr", "Changed. ") : el("span", "pill-changed", "Changed"));
350
346
  }
351
347
  }
352
348
  function save() { try { localStorage.setItem("work-tracker-view", JSON.stringify({ tab: state.tab, owner: state.owner })); } catch (e) {} }
@@ -379,6 +375,7 @@ function renderBoard(items) {
379
375
  const chips = el("div", "lchips");
380
376
  for (const it of mine) {
381
377
  const b = stampChange(el("button", "lchip", it.title), it);
378
+ b.dataset.changeMark = "dot";
382
379
  b.setAttribute("aria-label", "Go to " + it.title);
383
380
  b.onclick = () => jumpTo(it);
384
381
  chips.append(b);
@@ -438,10 +435,9 @@ function copyButton(it) {
438
435
  }
439
436
  function itemCard(it) {
440
437
  const key = stageOf(it), s = STAGE_BY_KEY[key], owner = ownerByKey[it.owner] || { name: it.owner };
441
- const card = stampChange(el("article", "card " + key), it);
438
+ const card = el("article", "card " + key);
442
439
  card.id = slug(it);
443
440
  const band = el("div", "band " + key);
444
- band.dataset.changeSlot = "";
445
441
  band.append(el("span", null, s.icon), el("span", null, s.label));
446
442
  const proj = el("span", "proj");
447
443
  proj.append(el("span", null, (owner.icon || "📁")), el("span", null, owner.name));
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.57.0",
3
+ "version": "8.59.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"],