claude-dev-env 8.58.0 → 8.60.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 +6 -2
- package/.agents/skills/orchestrator/reference/oversee-delegated-work.md +12 -0
- package/_shared/advisor/reference/advisor-block.md +1 -0
- package/bin/install.cursor-rules.test.mjs +2 -1
- 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/features/spawn-routing.md +5 -1
- package/hooks/hooks.json +40 -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/pre_tool_use_context_runner.py +38 -0
- package/hooks/hooks_constants/spawn_oversight_hook_constants.py +40 -0
- package/hooks/hooks_constants/test_issue_close_handoff_gate_constants.py +17 -0
- package/hooks/hooks_constants/test_pre_tool_use_context_runner.py +47 -0
- package/hooks/routing/spawn_oversight_hook.py +98 -0
- package/hooks/routing/spawn_readiness_hook.py +3 -14
- package/hooks/routing/test_spawn_oversight_hook.py +104 -0
- package/hooks/routing/test_spawn_readiness_hook.py +12 -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
|
|
@@ -3,8 +3,9 @@ name: orchestrator
|
|
|
3
3
|
description: >-
|
|
4
4
|
Coordinate user goals, parent tasks, workers, evidence, and recovery.
|
|
5
5
|
Triggers: /orchestrator, orchestrate, operate like a coordinator,
|
|
6
|
-
track my goals, coordinate workers, retain goals across compaction
|
|
7
|
-
|
|
6
|
+
track my goals, coordinate workers, retain goals across compaction,
|
|
7
|
+
before spawning a subagent, thread, or session, or starting work that
|
|
8
|
+
spans several turns.
|
|
8
9
|
---
|
|
9
10
|
|
|
10
11
|
# Orchestrator
|
|
@@ -46,6 +47,7 @@ If a run is already active, retain its follow-up entry and answer without replac
|
|
|
46
47
|
Create durable run state when work spans turns, has several goals, delegates, or waits on an external result.
|
|
47
48
|
An explicit advisor-only request can restrict execution to workers for that run.
|
|
48
49
|
For Claude Projects facts or reported coordinator mechanisms, read [platform evidence](reference/platform-evidence.md).
|
|
50
|
+
Before spawning a subagent, thread, or session, read [oversee delegated work](reference/oversee-delegated-work.md).
|
|
49
51
|
|
|
50
52
|
## Process
|
|
51
53
|
|
|
@@ -101,6 +103,7 @@ Send follow-ups only through a transport authorized by the current runtime and u
|
|
|
101
103
|
|
|
102
104
|
Read the evidence needed for your decision. Keep lengthy output in files and return short evidence pointers.
|
|
103
105
|
Check scope, acceptance results, and unresolved work before accepting a worker's conclusion.
|
|
106
|
+
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
107
|
Use independent verification where the task or repository requires it.
|
|
105
108
|
Checkpoint after decisions and state changes, before waiting, and before a known compaction.
|
|
106
109
|
|
|
@@ -134,6 +137,7 @@ Report the result, evidence, and any remaining limit.
|
|
|
134
137
|
| `.claude/CLAUDE.md` | Claude instruction import. |
|
|
135
138
|
| `reference/run-state.md` | Goal records, task authority, follow list, and active-root registry. |
|
|
136
139
|
| `reference/recovery.md` | Cold-start and compaction recovery. |
|
|
140
|
+
| `reference/oversee-delegated-work.md` | Spawner review of delegated work before it reaches the user. |
|
|
137
141
|
| `reference/platform-evidence.md` | Official Projects sources and coordinator report boundaries. |
|
|
138
142
|
| `reference/scheduling.md` | Optional existing gate commands and owned wake lifecycle. |
|
|
139
143
|
| `reference/consult-the-orchestrator.md` | Executor consults and four-signal replies. |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Oversee delegated work
|
|
2
|
+
|
|
3
|
+
Every agent that spawns a subagent, thread, or session owns that work until it reaches the user.
|
|
4
|
+
Agents make mistakes. The spawner is a fresh second set of eyes that catches and corrects them before they reach the user.
|
|
5
|
+
The `spawn_oversight_hook` adds this duty to every spawn, at every level of the tree.
|
|
6
|
+
|
|
7
|
+
- Brief each agent with the user's own words, the standards that apply, and the acceptance check.
|
|
8
|
+
- While it runs, read its progress. Wake a quiet agent with one concrete next step.
|
|
9
|
+
- Before its output reaches the user, check it against the user's words and standards yourself.
|
|
10
|
+
- Send the agent a correction when it misses, and check the corrected output the same way.
|
|
11
|
+
- Give each piece of a multi-piece task its own reviewer or helper.
|
|
12
|
+
- Show the user you are involved: say what you checked and what you corrected.
|
|
@@ -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.)`
|
|
@@ -124,9 +124,10 @@ test('seeds one editable policy and one native Codex routing hook', () => {
|
|
|
124
124
|
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
125
125
|
);
|
|
126
126
|
assert.equal(firstRoutingGroups.length, 1);
|
|
127
|
-
assert.equal(firstRoutingGroups[0].hooks.length,
|
|
127
|
+
assert.equal(firstRoutingGroups[0].hooks.length, 3);
|
|
128
128
|
assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
|
|
129
129
|
assert.match(firstRoutingGroups[0].hooks[1].command, /spawn_readiness_hook\.py/);
|
|
130
|
+
assert.match(firstRoutingGroups[0].hooks[2].command, /spawn_oversight_hook\.py/);
|
|
130
131
|
const firstSpawnPromptGroups = firstCodexHooks.hooks.PreToolUse.filter(
|
|
131
132
|
group => group.matcher === 'Agent|Task',
|
|
132
133
|
);
|
|
@@ -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.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# Spawn routing
|
|
2
2
|
|
|
3
|
-
This family reminds the agent to gather context before a spawn, adjusts thread requests when usage exceeds pace, and selects an allowed subagent model.
|
|
3
|
+
This family reminds the agent to gather context before a spawn, tells it that it owns the spawned work, adjusts thread requests when usage exceeds pace, and selects an allowed subagent model.
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
7
|
- `routing/spawn_readiness_hook.py` adds context to a spawn that comes before a read step, or before an answered question or a settled-scope line. The spawn still runs.
|
|
8
|
+
- `routing/spawn_oversight_hook.py` adds the orchestrator oversight directive to every spawn: watch the agent, check its output against the user's words and standards, and correct it before the output reaches the user. The spawn still runs.
|
|
8
9
|
- `routing/thread_spawn_pace_hook.py` reshapes a thread request when usage is over pace or unreadable.
|
|
9
10
|
- `routing/subagent_model_pin_hook.py` moves every Agent and Task subagent to Opus. A spawn that names Opus or Fable runs unchanged.
|
|
10
11
|
- `routing/subagent_model_routing.mjs` allows a selected Luna model, remaps eligible requests, and denies unsupported model routing.
|
|
@@ -12,6 +13,7 @@ This family reminds the agent to gather context before a spawn, adjusts thread r
|
|
|
12
13
|
## When it fires
|
|
13
14
|
|
|
14
15
|
- `routing/spawn_readiness_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, and `mcp__hearthbot__start_thread_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
|
|
16
|
+
- `routing/spawn_oversight_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, `mcp__hearthbot__start_thread_session`, and `mcp__hearthbot__start_rc_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
|
|
15
17
|
- `routing/thread_spawn_pace_hook.py` runs on `PreToolUse`, matcher `mcp__hearthbot__start_thread_session`, timeout `30` seconds in `hooks.json`.
|
|
16
18
|
- `routing/subagent_model_pin_hook.py` runs on `PreToolUse`, matcher `Agent|Task`, timeout `10` seconds in `hooks.json`.
|
|
17
19
|
- `routing/subagent_model_routing.mjs` runs on `PreToolUse`, matcher `multi_agent_v1__spawn_agent`, timeout `10` seconds through the quoted Node command in `hooks.json`.
|
|
@@ -23,6 +25,7 @@ Preconditions:
|
|
|
23
25
|
- Run each command from the repository root. Python and pytest cover the two Python scripts; Node covers the model router.
|
|
24
26
|
|
|
25
27
|
- **Spawn readiness.** Input is a spawn after a read, a question, and its answer. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_readiness_hook.py -q`. The adjacent test observes no output for a ready spawn and reminder context for each missing step.
|
|
28
|
+
- **Spawn oversight.** Input is a spawn from each registered tool, a subagent's own spawn, and a non-spawn call. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_oversight_hook.py -q`. The adjacent test observes the directive for every spawn and no output for the other calls.
|
|
26
29
|
- **Thread pace.** Input is a thread spawn with usage over pace. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_thread_spawn_pace_hook.py -q`. The adjacent test observes updated tool input with the selected model, effort, and advisor line.
|
|
27
30
|
- **Model pin.** Input is an Agent spawn that names a Sonnet model. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_subagent_model_pin_hook.py -q`. The adjacent test observes updated tool input with model `opus`.
|
|
28
31
|
- **Model routing.** Input is a `multi_agent_v1__spawn_agent` payload with a model choice. Run `node --test packages/claude-dev-env/hooks/routing/subagent_model_routing.test.mjs`. The adjacent test observes an allow or deny decision with the routed model.
|
|
@@ -30,5 +33,6 @@ Preconditions:
|
|
|
30
33
|
## Gotchas
|
|
31
34
|
|
|
32
35
|
- `routing/spawn_readiness_hook.py` also runs after the pace hook on thread spawns. A reshaped request still gets the readiness check.
|
|
36
|
+
- The oversight hook fires inside subagents too, so each level of the tree oversees the level below it. It emits `additionalContext` only and never blocks.
|
|
33
37
|
- The readiness hook emits `additionalContext` only, so it never blocks. It passes subagent calls, read-only helper types, a non-object tool input, and unreadable transcripts.
|
|
34
38
|
- The model router allows an advisor flag to bypass the Luna restriction. Its ordinary path denies unresolved or unsupported models.
|
package/hooks/hooks.json
CHANGED
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
"type": "command",
|
|
20
20
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/subagent_model_pin_hook.py",
|
|
21
21
|
"timeout": 10
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "command",
|
|
25
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
|
|
26
|
+
"timeout": 10
|
|
22
27
|
}
|
|
23
28
|
]
|
|
24
29
|
},
|
|
@@ -34,6 +39,11 @@
|
|
|
34
39
|
"type": "command",
|
|
35
40
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
36
41
|
"timeout": 10
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"type": "command",
|
|
45
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
|
|
46
|
+
"timeout": 10
|
|
37
47
|
}
|
|
38
48
|
]
|
|
39
49
|
},
|
|
@@ -44,6 +54,11 @@
|
|
|
44
54
|
"type": "command",
|
|
45
55
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
46
56
|
"timeout": 10
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
|
|
61
|
+
"timeout": 10
|
|
47
62
|
}
|
|
48
63
|
]
|
|
49
64
|
},
|
|
@@ -117,6 +132,16 @@
|
|
|
117
132
|
}
|
|
118
133
|
]
|
|
119
134
|
},
|
|
135
|
+
{
|
|
136
|
+
"matcher": "mcp__.*__(add_issue_comment|issue_write|update_issue_comment)",
|
|
137
|
+
"hooks": [
|
|
138
|
+
{
|
|
139
|
+
"type": "command",
|
|
140
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/issue_close_handoff_gate.py",
|
|
141
|
+
"timeout": 10
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
},
|
|
120
145
|
{
|
|
121
146
|
"matcher": "mcp__.*__set_session_title",
|
|
122
147
|
"hooks": [
|
|
@@ -149,6 +174,21 @@
|
|
|
149
174
|
"type": "command",
|
|
150
175
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
151
176
|
"timeout": 10
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"type": "command",
|
|
180
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
|
|
181
|
+
"timeout": 10
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"matcher": "mcp__hearthbot__start_rc_session",
|
|
187
|
+
"hooks": [
|
|
188
|
+
{
|
|
189
|
+
"type": "command",
|
|
190
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
|
|
191
|
+
"timeout": 10
|
|
152
192
|
}
|
|
153
193
|
]
|
|
154
194
|
},
|
|
@@ -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,38 @@
|
|
|
1
|
+
"""Shared main loop for PreToolUse hooks that only add context.
|
|
2
|
+
|
|
3
|
+
Reads the payload from stdin, asks the hook's decide function for an output,
|
|
4
|
+
and writes it as JSON. Exit status is always 0, so the tool call runs; a
|
|
5
|
+
decide function that raises leaves the call with no output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_context_hook(
|
|
18
|
+
decide_hook_output: Callable[[dict[str, object]], dict[str, object] | None],
|
|
19
|
+
) -> int:
|
|
20
|
+
"""Print the decided output for the stdin payload, or nothing.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
decide_hook_output: Maps the parsed payload to an output or None.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
0 in every case; the context travels in the JSON output.
|
|
27
|
+
"""
|
|
28
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
29
|
+
if hook_payload is None:
|
|
30
|
+
return 0
|
|
31
|
+
try:
|
|
32
|
+
hook_output = decide_hook_output(hook_payload)
|
|
33
|
+
except Exception:
|
|
34
|
+
return 0
|
|
35
|
+
if hook_output is not None:
|
|
36
|
+
sys.stdout.write(json.dumps(hook_output))
|
|
37
|
+
sys.stdout.flush()
|
|
38
|
+
return 0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Constants for the spawn oversight PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Groups: the spawn tools it answers, the workflow dispatch that carries an
|
|
4
|
+
agent prompt, the payload keys it reads, and the
|
|
5
|
+
oversight directive it adds to every spawn.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
TOOL_NAME_KEY = "tool_name"
|
|
11
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
12
|
+
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
13
|
+
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
14
|
+
|
|
15
|
+
WORKFLOW_DISPATCH_TOOL_NAME = "mcp__github__actions_run_trigger"
|
|
16
|
+
DISPATCH_METHOD_INPUT_KEY = "method"
|
|
17
|
+
DISPATCH_RUN_WORKFLOW_METHOD = "run_workflow"
|
|
18
|
+
DISPATCH_INPUTS_KEY = "inputs"
|
|
19
|
+
DISPATCH_PROMPT_INPUT_KEY = "prompt"
|
|
20
|
+
|
|
21
|
+
ALL_SPAWN_TOOL_NAMES = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"Agent",
|
|
24
|
+
"Task",
|
|
25
|
+
"Workflow",
|
|
26
|
+
"multi_agent_v1__spawn_agent",
|
|
27
|
+
"mcp__hearthbot__start_thread_session",
|
|
28
|
+
"mcp__hearthbot__start_rc_session",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
SPAWN_OVERSIGHT_DIRECTIVE = (
|
|
33
|
+
"You own the work this spawn starts. Load the orchestrator skill and follow "
|
|
34
|
+
"its oversee delegated work reference. While the agent runs, read its progress "
|
|
35
|
+
"and wake it with one next step when it goes quiet. Before its output "
|
|
36
|
+
"reaches the user, check it against the user's own words and standards, and "
|
|
37
|
+
"send the agent a correction when it misses. Give each piece of a "
|
|
38
|
+
"multi-piece task its own reviewer or helper. Tell the user you are "
|
|
39
|
+
"checking the work."
|
|
40
|
+
)
|
|
@@ -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
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hooks_constants.pre_tool_use_context_runner import run_context_hook
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _feed_stdin(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
|
|
11
|
+
monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(text.encode("utf-8"))))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_prints_the_decided_output(
|
|
15
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
16
|
+
) -> None:
|
|
17
|
+
_feed_stdin(monkeypatch, json.dumps({"tool_name": "Agent"}))
|
|
18
|
+
exit_code = run_context_hook(lambda payload: {"seen": payload["tool_name"]})
|
|
19
|
+
assert exit_code == 0
|
|
20
|
+
assert json.loads(capsys.readouterr().out) == {"seen": "Agent"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_prints_nothing_when_the_decision_is_none(
|
|
24
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
25
|
+
) -> None:
|
|
26
|
+
_feed_stdin(monkeypatch, json.dumps({"tool_name": "Bash"}))
|
|
27
|
+
assert run_context_hook(lambda payload: None) == 0
|
|
28
|
+
assert capsys.readouterr().out == ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_prints_nothing_for_empty_stdin(
|
|
32
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
33
|
+
) -> None:
|
|
34
|
+
_feed_stdin(monkeypatch, "")
|
|
35
|
+
assert run_context_hook(lambda payload: {"never": True}) == 0
|
|
36
|
+
assert capsys.readouterr().out == ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_prints_nothing_and_exits_zero_when_the_decision_raises(
|
|
40
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
41
|
+
) -> None:
|
|
42
|
+
def _raise(payload: dict[str, object]) -> dict[str, object] | None:
|
|
43
|
+
raise KeyError(payload["missing"])
|
|
44
|
+
|
|
45
|
+
_feed_stdin(monkeypatch, json.dumps({"tool_name": "Agent"}))
|
|
46
|
+
assert run_context_hook(_raise) == 0
|
|
47
|
+
assert capsys.readouterr().out == ""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: tell the spawning session it owns the work it delegates.
|
|
3
|
+
|
|
4
|
+
Registered on every tool that starts another agent: ``Agent``, ``Task``,
|
|
5
|
+
``Workflow``, ``multi_agent_v1__spawn_agent``,
|
|
6
|
+
``mcp__hearthbot__start_thread_session``, and
|
|
7
|
+
``mcp__hearthbot__start_rc_session``, plus a workflow dispatch whose inputs
|
|
8
|
+
carry a ``prompt`` for an agent. Each spawn gets the oversight directive
|
|
9
|
+
as ``additionalContext``, so the spawn runs and keeps its normal permission
|
|
10
|
+
flow. A subagent that spawns its own agents gets the same directive, so every
|
|
11
|
+
level of the tree oversees the level below it.
|
|
12
|
+
|
|
13
|
+
::
|
|
14
|
+
|
|
15
|
+
Agent {"prompt": "Do X."} -> additionalContext: the directive
|
|
16
|
+
actions trigger {"method": "run_workflow",
|
|
17
|
+
"inputs": {"prompt": "Do X."}}
|
|
18
|
+
-> additionalContext: the directive
|
|
19
|
+
actions trigger {"method": "cancel_workflow_run"}
|
|
20
|
+
-> no output
|
|
21
|
+
Bash {"command": "ls"} -> no output
|
|
22
|
+
Agent "not an object" -> no output
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
31
|
+
if hooks_root_directory not in sys.path:
|
|
32
|
+
sys.path.insert(0, hooks_root_directory)
|
|
33
|
+
|
|
34
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
35
|
+
HOOK_EVENT_NAME_KEY,
|
|
36
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
37
|
+
)
|
|
38
|
+
from hooks_constants.pre_tool_use_context_runner import run_context_hook
|
|
39
|
+
from hooks_constants.spawn_oversight_hook_constants import (
|
|
40
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
41
|
+
ALL_SPAWN_TOOL_NAMES,
|
|
42
|
+
DISPATCH_INPUTS_KEY,
|
|
43
|
+
DISPATCH_METHOD_INPUT_KEY,
|
|
44
|
+
DISPATCH_PROMPT_INPUT_KEY,
|
|
45
|
+
DISPATCH_RUN_WORKFLOW_METHOD,
|
|
46
|
+
PRE_TOOL_USE_EVENT_NAME,
|
|
47
|
+
SPAWN_OVERSIGHT_DIRECTIVE,
|
|
48
|
+
TOOL_INPUT_KEY,
|
|
49
|
+
TOOL_NAME_KEY,
|
|
50
|
+
WORKFLOW_DISPATCH_TOOL_NAME,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def starts_an_agent(tool_name: object, all_tool_input_fields: dict[str, object]) -> bool:
|
|
55
|
+
"""Return True when the call starts an agent: a spawn tool or a prompt dispatch.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
tool_name: The tool the session called.
|
|
59
|
+
all_tool_input_fields: The tool input.
|
|
60
|
+
"""
|
|
61
|
+
if tool_name in ALL_SPAWN_TOOL_NAMES:
|
|
62
|
+
return True
|
|
63
|
+
if tool_name != WORKFLOW_DISPATCH_TOOL_NAME:
|
|
64
|
+
return False
|
|
65
|
+
if all_tool_input_fields.get(DISPATCH_METHOD_INPUT_KEY) != DISPATCH_RUN_WORKFLOW_METHOD:
|
|
66
|
+
return False
|
|
67
|
+
workflow_inputs = all_tool_input_fields.get(DISPATCH_INPUTS_KEY)
|
|
68
|
+
return isinstance(workflow_inputs, dict) and isinstance(
|
|
69
|
+
workflow_inputs.get(DISPATCH_PROMPT_INPUT_KEY), str
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object] | None:
|
|
74
|
+
"""Return the oversight directive for a spawn call, else None.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
all_hook_fields: The parsed PreToolUse payload.
|
|
78
|
+
"""
|
|
79
|
+
all_tool_input_fields = all_hook_fields.get(TOOL_INPUT_KEY)
|
|
80
|
+
if not isinstance(all_tool_input_fields, dict):
|
|
81
|
+
return None
|
|
82
|
+
if not starts_an_agent(all_hook_fields.get(TOOL_NAME_KEY), all_tool_input_fields):
|
|
83
|
+
return None
|
|
84
|
+
return {
|
|
85
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
86
|
+
HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
|
|
87
|
+
ADDITIONAL_CONTEXT_KEY: SPAWN_OVERSIGHT_DIRECTIVE,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> int:
|
|
93
|
+
"""Run the hook on the stdin payload; always 0."""
|
|
94
|
+
return run_context_hook(decide_hook_output)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
|
@@ -35,7 +35,7 @@ routing_directory = str(Path(__file__).resolve().parent)
|
|
|
35
35
|
if routing_directory not in sys.path:
|
|
36
36
|
sys.path.insert(0, routing_directory)
|
|
37
37
|
|
|
38
|
-
from hooks_constants.
|
|
38
|
+
from hooks_constants.pre_tool_use_context_runner import run_context_hook
|
|
39
39
|
from hooks_constants.spawn_readiness_hook_constants import (
|
|
40
40
|
ADDITIONAL_CONTEXT_KEY,
|
|
41
41
|
AGENT_ID_KEY,
|
|
@@ -250,19 +250,8 @@ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object]
|
|
|
250
250
|
|
|
251
251
|
|
|
252
252
|
def main() -> int:
|
|
253
|
-
"""
|
|
254
|
-
|
|
255
|
-
Returns:
|
|
256
|
-
0 in every case; the reminder travels in the JSON output.
|
|
257
|
-
"""
|
|
258
|
-
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
259
|
-
if hook_payload is None:
|
|
260
|
-
return 0
|
|
261
|
-
hook_output = decide_hook_output(hook_payload)
|
|
262
|
-
if hook_output is not None:
|
|
263
|
-
sys.stdout.write(json.dumps(hook_output))
|
|
264
|
-
sys.stdout.flush()
|
|
265
|
-
return 0
|
|
253
|
+
"""Run the hook on the stdin payload; always 0."""
|
|
254
|
+
return run_context_hook(decide_hook_output)
|
|
266
255
|
|
|
267
256
|
|
|
268
257
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import spawn_oversight_hook
|
|
9
|
+
from hooks_constants.spawn_oversight_hook_constants import SPAWN_OVERSIGHT_DIRECTIVE
|
|
10
|
+
|
|
11
|
+
HOOK_SCRIPT = Path(__file__).resolve().parent / "spawn_oversight_hook.py"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _expected_output() -> dict[str, object]:
|
|
15
|
+
return {
|
|
16
|
+
"hookSpecificOutput": {
|
|
17
|
+
"hookEventName": "PreToolUse",
|
|
18
|
+
"additionalContext": SPAWN_OVERSIGHT_DIRECTIVE,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize(
|
|
24
|
+
"tool_name",
|
|
25
|
+
[
|
|
26
|
+
"Agent",
|
|
27
|
+
"Task",
|
|
28
|
+
"Workflow",
|
|
29
|
+
"multi_agent_v1__spawn_agent",
|
|
30
|
+
"mcp__hearthbot__start_thread_session",
|
|
31
|
+
"mcp__hearthbot__start_rc_session",
|
|
32
|
+
],
|
|
33
|
+
)
|
|
34
|
+
def test_every_spawn_tool_gets_the_oversight_directive(tool_name: str) -> None:
|
|
35
|
+
payload = {"tool_name": tool_name, "tool_input": {"prompt": "Do X."}}
|
|
36
|
+
assert spawn_oversight_hook.decide_hook_output(payload) == _expected_output()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_a_subagent_spawning_its_own_agent_gets_the_directive() -> None:
|
|
40
|
+
payload = {
|
|
41
|
+
"tool_name": "Agent",
|
|
42
|
+
"tool_input": {"prompt": "Do X."},
|
|
43
|
+
"agent_id": "agent_child",
|
|
44
|
+
}
|
|
45
|
+
assert spawn_oversight_hook.decide_hook_output(payload) == _expected_output()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_a_read_only_helper_spawn_gets_the_directive() -> None:
|
|
49
|
+
payload = {
|
|
50
|
+
"tool_name": "Agent",
|
|
51
|
+
"tool_input": {"subagent_type": "Explore", "prompt": "Find X."},
|
|
52
|
+
}
|
|
53
|
+
assert spawn_oversight_hook.decide_hook_output(payload) == _expected_output()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_a_workflow_dispatch_with_an_agent_prompt_gets_the_directive() -> None:
|
|
57
|
+
payload = {
|
|
58
|
+
"tool_name": "mcp__github__actions_run_trigger",
|
|
59
|
+
"tool_input": {"method": "run_workflow", "inputs": {"prompt": "Do X."}},
|
|
60
|
+
}
|
|
61
|
+
assert spawn_oversight_hook.decide_hook_output(payload) == _expected_output()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.parametrize(
|
|
65
|
+
"payload",
|
|
66
|
+
[
|
|
67
|
+
{
|
|
68
|
+
"tool_name": "mcp__github__actions_run_trigger",
|
|
69
|
+
"tool_input": {"method": "cancel_workflow_run", "run_id": 1},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"tool_name": "mcp__github__actions_run_trigger",
|
|
73
|
+
"tool_input": {"method": "run_workflow", "inputs": {"head": "branch"}},
|
|
74
|
+
},
|
|
75
|
+
{"tool_name": "Bash", "tool_input": {"command": "ls"}},
|
|
76
|
+
{"tool_name": "mcp__hearthbot__reply", "tool_input": {"text": "hi"}},
|
|
77
|
+
{"tool_name": "Agent", "tool_input": "not an object"},
|
|
78
|
+
{"tool_input": {"prompt": "Do X."}},
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
def test_other_calls_get_no_output(payload: dict[str, object]) -> None:
|
|
82
|
+
assert spawn_oversight_hook.decide_hook_output(payload) is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_script_prints_the_directive_for_a_spawn() -> None:
|
|
86
|
+
completed = subprocess.run(
|
|
87
|
+
[sys.executable, str(HOOK_SCRIPT)],
|
|
88
|
+
input=json.dumps({"tool_name": "Agent", "tool_input": {"prompt": "Do X."}}),
|
|
89
|
+
capture_output=True,
|
|
90
|
+
text=True,
|
|
91
|
+
check=True,
|
|
92
|
+
)
|
|
93
|
+
assert json.loads(completed.stdout) == _expected_output()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_script_prints_nothing_for_empty_stdin() -> None:
|
|
97
|
+
completed = subprocess.run(
|
|
98
|
+
[sys.executable, str(HOOK_SCRIPT)],
|
|
99
|
+
input="",
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
check=True,
|
|
103
|
+
)
|
|
104
|
+
assert completed.stdout == ""
|
|
@@ -368,3 +368,15 @@ def test_should_quiet_a_codex_spawn_whose_brief_settles_scope(tmp_path: Path) ->
|
|
|
368
368
|
assert stdout == ""
|
|
369
369
|
[log_record] = _decision_log(tmp_path)
|
|
370
370
|
assert log_record["outcome"] == "scope_settled"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_should_print_nothing_for_empty_stdin(tmp_path: Path) -> None:
|
|
374
|
+
completed = subprocess.run(
|
|
375
|
+
[sys.executable, str(HOOK_SCRIPT)],
|
|
376
|
+
input="",
|
|
377
|
+
capture_output=True,
|
|
378
|
+
text=True,
|
|
379
|
+
env={**os.environ, "HOME": str(tmp_path)},
|
|
380
|
+
check=True,
|
|
381
|
+
)
|
|
382
|
+
assert completed.stdout == ""
|
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"],
|