claude-dev-env 8.59.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/orchestrator/SKILL.md +5 -2
- package/.agents/skills/orchestrator/reference/oversee-delegated-work.md +12 -0
- package/bin/install.cursor-rules.test.mjs +2 -1
- package/hooks/features/spawn-routing.md +5 -1
- package/hooks/hooks.json +30 -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_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
|
@@ -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
|
|
|
@@ -135,6 +137,7 @@ Report the result, evidence, and any remaining limit.
|
|
|
135
137
|
| `.claude/CLAUDE.md` | Claude instruction import. |
|
|
136
138
|
| `reference/run-state.md` | Goal records, task authority, follow list, and active-root registry. |
|
|
137
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. |
|
|
138
141
|
| `reference/platform-evidence.md` | Official Projects sources and coordinator report boundaries. |
|
|
139
142
|
| `reference/scheduling.md` | Optional existing gate commands and owned wake lifecycle. |
|
|
140
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.
|
|
@@ -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
|
);
|
|
@@ -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
|
},
|
|
@@ -159,6 +174,21 @@
|
|
|
159
174
|
"type": "command",
|
|
160
175
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
161
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
|
|
162
192
|
}
|
|
163
193
|
]
|
|
164
194
|
},
|
|
@@ -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,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 == ""
|