claude-dev-env 8.93.1 → 8.94.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 +1 -0
- package/.agents/skills/usage-pause/SKILL.md +0 -1
- package/hooks/features/spawn-routing.md +5 -5
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/pre_tool_use_context_runner.py +4 -3
- package/hooks/hooks_constants/spawn_oversight_hook_constants.py +23 -17
- package/hooks/hooks_constants/spawn_readiness_hook_constants.py +2 -0
- package/hooks/routing/spawn_brief_text.py +85 -0
- package/hooks/routing/spawn_oversight_hook.py +109 -41
- package/hooks/routing/spawn_readiness_hook.py +7 -44
- package/hooks/routing/test_spawn_brief_text.py +36 -0
- package/hooks/routing/test_spawn_oversight_hook.py +172 -42
- package/hooks/routing/test_spawn_readiness_hook.py +13 -0
- package/package.json +1 -1
|
@@ -97,6 +97,7 @@ Reuse a reachable worker whose context fits. Give a fresh worker the saved assig
|
|
|
97
97
|
Register each delegated task before spawn and set one owner before the worker writes.
|
|
98
98
|
Keep one writer per shared file. Isolate concurrent writers in separate worktrees or output directories.
|
|
99
99
|
Pass the user's relevant words, goal ID, assignment locator, owned files, constraints, and acceptance check.
|
|
100
|
+
State the user's outcome on an `Outcome:` line. Any route you name is a suggested default. Add an `On a blocker:` line: the worker tries other routes that reach the outcome and hands the user a step only when it needs their hands.
|
|
100
101
|
Pass the active standing-instruction loading requirements to every descendant.
|
|
101
102
|
Use [executor consult blocks](reference/executor-consult-block.md) and the [consult contract](reference/consult-the-orchestrator.md).
|
|
102
103
|
Send follow-ups only through a transport authorized by the current runtime and user.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: usage-pause
|
|
3
3
|
description: Pause work until the 5-hour usage window resets, keeping agent contexts warm through staged wakeups. Stops with a warning when the weekly limit is near its cap. Use when the user asks to pause for or wait out a usage limit.
|
|
4
|
-
disable-model-invocation: true
|
|
5
4
|
argument-hint: "[reset time like 10:20pm | duration like 74m]"
|
|
6
5
|
---
|
|
7
6
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Spawn routing
|
|
2
2
|
|
|
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, selects an allowed subagent model, and sends a Haiku spawn to a headless slim-profile run.
|
|
3
|
+
This family reminds the agent to gather context before a spawn, refuses a brief without the user's outcome and a blocker clause, tells it that it owns the spawned work, adjusts thread requests when usage exceeds pace, selects an allowed subagent model, and sends a Haiku spawn to a headless slim-profile run.
|
|
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`
|
|
8
|
+
- `routing/spawn_oversight_hook.py` denies a spawn whose brief has no `Outcome:` line or no `On a blocker:` clause, each with text after it. A spawn that passes gets the orchestrator oversight directive: watch the agent, check its output against the user's words and standards, and correct it before the output reaches the user. `routing/spawn_brief_text.py` reads the brief from each spawn tool for this hook and the readiness hook.
|
|
9
9
|
- `routing/thread_spawn_pace_hook.py` reshapes a thread request when usage is over pace or unreadable.
|
|
10
10
|
- `routing/subagent_model_routing.mjs` allows a selected Luna model, remaps eligible requests, and denies unsupported model routing.
|
|
11
11
|
- `blocking/haiku_spawn_slim_gate.py` denies an Agent, Task, or thread spawn whose `model` names Haiku. The deny reason gives the headless `claude -p` command that loads the slim profile through the account broker.
|
|
@@ -13,7 +13,7 @@ This family reminds the agent to gather context before a spawn, tells it that it
|
|
|
13
13
|
## When it fires
|
|
14
14
|
|
|
15
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`.
|
|
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|mcp__hearthbot__start_remote_control`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
|
|
17
17
|
- `routing/thread_spawn_pace_hook.py` runs on `PreToolUse`, matcher `mcp__hearthbot__start_thread_session`, timeout `30` seconds in `hooks.json`.
|
|
18
18
|
- `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`.
|
|
19
19
|
- `blocking/haiku_spawn_slim_gate.py` runs on `PreToolUse` with matchers `Agent|Task` and `mcp__hearthbot__start_thread_session`, each at `10` seconds in `hooks.json`.
|
|
@@ -25,7 +25,7 @@ Preconditions:
|
|
|
25
25
|
- Run each command from the repository root. Python and pytest cover the two Python scripts; Node covers the model router.
|
|
26
26
|
|
|
27
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
|
|
28
|
+
- **Spawn oversight.** Input is a route-only brief, a brief with both clauses, and briefs with one clause, on each registered tool, plus a non-spawn call. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_oversight_hook.py packages/claude-dev-env/hooks/routing/test_spawn_brief_text.py -q`. The adjacent test observes a deny for each brief missing a clause, the directive for the passing brief, and no output for the other calls.
|
|
29
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.
|
|
30
30
|
- **Haiku slim gate.** Input is a spawn with a Haiku alias or model ID, and spawns on other models or with no model. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_haiku_spawn_slim_gate.py -q`. The adjacent test observes a deny with the headless command for each Haiku spawn and no output for the rest.
|
|
31
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.
|
|
@@ -33,7 +33,7 @@ Preconditions:
|
|
|
33
33
|
## Gotchas
|
|
34
34
|
|
|
35
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
|
|
36
|
+
- The oversight hook fires inside subagents and on read-only helper types too, so every brief at every level carries the outcome and the blocker clause. A `Workflow` call with no script text, such as a saved or resumed run, passes with the directive.
|
|
37
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.
|
|
38
38
|
- The Haiku slim gate reads only the spawn's `model` field. A spawn that names no model passes, even when its agent definition or `CLAUDE_CODE_SUBAGENT_MODEL` resolves it to Haiku.
|
|
39
39
|
- 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
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""Shared main loop for PreToolUse hooks that
|
|
1
|
+
"""Shared main loop for PreToolUse hooks that print a decided JSON output.
|
|
2
2
|
|
|
3
3
|
Reads the payload from stdin, asks the hook's decide function for an output,
|
|
4
|
-
and writes it as JSON.
|
|
5
|
-
decide function that raises leaves the call with no
|
|
4
|
+
and writes it as JSON. The output may add context or deny the call. Exit
|
|
5
|
+
status is always 0; a decide function that raises leaves the call with no
|
|
6
|
+
output.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Constants for the spawn oversight PreToolUse hook.
|
|
2
2
|
|
|
3
|
-
Groups: the
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Groups: the payload keys it reads, the brief markers it requires, the
|
|
4
|
+
refusal it gives a brief without them, and the oversight directive it adds to
|
|
5
|
+
every spawn that passes.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -11,23 +11,29 @@ TOOL_NAME_KEY = "tool_name"
|
|
|
11
11
|
TOOL_INPUT_KEY = "tool_input"
|
|
12
12
|
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
13
13
|
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
14
|
+
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
15
|
+
DENY_DECISION = "deny"
|
|
16
|
+
WORKFLOW_TOOL_NAME = "Workflow"
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
DISPATCH_INPUTS_KEY = "inputs"
|
|
19
|
-
DISPATCH_PROMPT_INPUT_KEY = "prompt"
|
|
18
|
+
ALL_OUTCOME_MARKERS = ("outcome:",)
|
|
19
|
+
ALL_BLOCKER_CLAUSE_MARKERS = ("on a blocker:", "at a blocker:")
|
|
20
|
+
MARKER_DECORATION_CHARACTERS = "*_` \t"
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"Task",
|
|
25
|
-
"Workflow",
|
|
26
|
-
"multi_agent_v1__spawn_agent",
|
|
27
|
-
"mcp__hearthbot__start_thread_session",
|
|
28
|
-
"mcp__hearthbot__start_rc_session",
|
|
29
|
-
}
|
|
22
|
+
MISSING_OUTCOME_REASON = (
|
|
23
|
+
"The brief has no outcome line. Add `Outcome: <what the user has when the "
|
|
24
|
+
"work is done>`."
|
|
30
25
|
)
|
|
26
|
+
MISSING_BLOCKER_CLAUSE_REASON = (
|
|
27
|
+
"The brief has no blocker clause. Add `On a blocker: try other routes that "
|
|
28
|
+
"reach the outcome; hand the user a step only when it needs their hands.` "
|
|
29
|
+
"Name any route as `Suggested route: <route>`, a default the worker may leave."
|
|
30
|
+
)
|
|
31
|
+
REFUSAL_PREFIX = "Spawn refused before the worker starts."
|
|
32
|
+
REFUSAL_SUFFIX = (
|
|
33
|
+
"The worker owns reaching the outcome by any allowed route. Add the "
|
|
34
|
+
"missing lines and call the spawn again."
|
|
35
|
+
)
|
|
36
|
+
REASON_SEPARATOR = " "
|
|
31
37
|
|
|
32
38
|
SPAWN_OVERSIGHT_DIRECTIVE = (
|
|
33
39
|
"You own the work this spawn starts. Load the orchestrator skill and follow "
|
|
@@ -24,6 +24,8 @@ ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME = {
|
|
|
24
24
|
THREAD_SPAWN_TOOL_NAME: "instructions",
|
|
25
25
|
CODEX_SPAWN_TOOL_NAME: "message",
|
|
26
26
|
WORKFLOW_TOOL_NAME: "script",
|
|
27
|
+
"mcp__hearthbot__start_rc_session": "instructions",
|
|
28
|
+
"mcp__hearthbot__start_remote_control": "instructions",
|
|
27
29
|
}
|
|
28
30
|
WORKFLOW_DISPATCH_TOOL_NAME = "mcp__github__actions_run_trigger"
|
|
29
31
|
DISPATCH_METHOD_INPUT_KEY = "method"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Read the brief a spawn call hands its agents, for the spawn hooks that check it.
|
|
2
|
+
|
|
3
|
+
Each spawn tool keeps its brief in its own input field. A ``Workflow`` call
|
|
4
|
+
carries a script or a path to one, and a workflow dispatch carries the brief in
|
|
5
|
+
its ``prompt`` input.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
Agent {"prompt": "Do X."} -> "Do X."
|
|
10
|
+
Workflow {"scriptPath": "w.js"} -> the text of w.js
|
|
11
|
+
actions trigger {"method": "run_workflow",
|
|
12
|
+
"inputs": {"prompt": "Do X."}} -> "Do X."
|
|
13
|
+
actions trigger {"method": "cancel_workflow_run"} -> None
|
|
14
|
+
Bash {"command": "ls"} -> None
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if hooks_root_directory not in sys.path:
|
|
24
|
+
sys.path.insert(0, hooks_root_directory)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.spawn_readiness_hook_constants import (
|
|
27
|
+
ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME,
|
|
28
|
+
DISPATCH_INPUTS_KEY,
|
|
29
|
+
DISPATCH_METHOD_INPUT_KEY,
|
|
30
|
+
DISPATCH_PROMPT_INPUT_KEY,
|
|
31
|
+
DISPATCH_RUN_WORKFLOW_METHOD,
|
|
32
|
+
TRANSCRIPT_DECODE_ERRORS,
|
|
33
|
+
TRANSCRIPT_ENCODING,
|
|
34
|
+
WORKFLOW_DISPATCH_TOOL_NAME,
|
|
35
|
+
WORKFLOW_SCRIPT_PATH_INPUT_KEY,
|
|
36
|
+
WORKFLOW_TOOL_NAME,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _workflow_script(all_tool_input_fields: dict[str, object]) -> str:
|
|
41
|
+
script = all_tool_input_fields.get(ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME[WORKFLOW_TOOL_NAME])
|
|
42
|
+
if isinstance(script, str):
|
|
43
|
+
return script
|
|
44
|
+
script_path = all_tool_input_fields.get(WORKFLOW_SCRIPT_PATH_INPUT_KEY)
|
|
45
|
+
if not isinstance(script_path, str) or not script_path:
|
|
46
|
+
return ""
|
|
47
|
+
try:
|
|
48
|
+
return Path(script_path).read_text(
|
|
49
|
+
encoding=TRANSCRIPT_ENCODING, errors=TRANSCRIPT_DECODE_ERRORS
|
|
50
|
+
)
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _dispatch_prompt(all_tool_input_fields: dict[str, object]) -> str | None:
|
|
56
|
+
if all_tool_input_fields.get(DISPATCH_METHOD_INPUT_KEY) != DISPATCH_RUN_WORKFLOW_METHOD:
|
|
57
|
+
return None
|
|
58
|
+
workflow_inputs = all_tool_input_fields.get(DISPATCH_INPUTS_KEY)
|
|
59
|
+
if not isinstance(workflow_inputs, dict):
|
|
60
|
+
return None
|
|
61
|
+
prompt = workflow_inputs.get(DISPATCH_PROMPT_INPUT_KEY)
|
|
62
|
+
return prompt if isinstance(prompt, str) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def spawn_brief_text(tool_name: object, all_tool_input_fields: dict[str, object]) -> str | None:
|
|
66
|
+
"""Return the brief a spawn call hands its agents, or None when the call spawns none.
|
|
67
|
+
|
|
68
|
+
A spawn tool whose brief field is missing or not a string gives "", so a
|
|
69
|
+
hook can tell an empty brief from a call that spawns nothing.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
tool_name: The tool the session called.
|
|
73
|
+
all_tool_input_fields: The tool input.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The brief text, "" for a spawn with no readable brief, or None.
|
|
77
|
+
"""
|
|
78
|
+
if tool_name == WORKFLOW_DISPATCH_TOOL_NAME:
|
|
79
|
+
return _dispatch_prompt(all_tool_input_fields)
|
|
80
|
+
if not isinstance(tool_name, str) or tool_name not in ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME:
|
|
81
|
+
return None
|
|
82
|
+
if tool_name == WORKFLOW_TOOL_NAME:
|
|
83
|
+
return _workflow_script(all_tool_input_fields)
|
|
84
|
+
brief = all_tool_input_fields.get(ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME[tool_name])
|
|
85
|
+
return brief if isinstance(brief, str) else ""
|
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook:
|
|
2
|
+
"""PreToolUse hook: refuse a spawn brief without the user's outcome and a blocker clause.
|
|
3
3
|
|
|
4
4
|
Registered on every tool that starts another agent: ``Agent``, ``Task``,
|
|
5
5
|
``Workflow``, ``multi_agent_v1__spawn_agent``,
|
|
6
|
-
``mcp__hearthbot__start_thread_session``,
|
|
7
|
-
``
|
|
8
|
-
carry a ``prompt`` for an agent.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
level of the tree oversees the level below it.
|
|
6
|
+
``mcp__hearthbot__start_thread_session``, ``mcp__hearthbot__start_rc_session``,
|
|
7
|
+
and ``mcp__hearthbot__start_remote_control``, plus a workflow dispatch whose
|
|
8
|
+
inputs carry a ``prompt`` for an agent. The brief needs an ``Outcome:`` line
|
|
9
|
+
and an ``On a blocker:`` clause, each with text after it, so the worker owns
|
|
10
|
+
the outcome and any route named is a default.
|
|
12
11
|
|
|
13
12
|
::
|
|
14
13
|
|
|
15
|
-
Agent {"prompt": "
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
Agent {"prompt": "Run X via RC."} -> deny: no outcome, no blocker clause
|
|
15
|
+
Agent {"prompt": "Outcome: ... On a blocker: ..."}
|
|
16
|
+
-> additionalContext: the directive
|
|
17
|
+
Workflow {"name": "saved-review"} -> additionalContext: the directive
|
|
19
18
|
actions trigger {"method": "cancel_workflow_run"}
|
|
20
|
-
|
|
21
|
-
Bash {"command": "ls"}
|
|
22
|
-
Agent "not an object"
|
|
19
|
+
-> no output
|
|
20
|
+
Bash {"command": "ls"} -> no output
|
|
21
|
+
Agent "not an object" -> no output
|
|
22
|
+
|
|
23
|
+
A passing spawn gets the oversight directive, so the spawner owns the work it
|
|
24
|
+
delegates. Subagent and read-only helper spawns are checked the same way. A
|
|
25
|
+
``Workflow`` call with no script text, such as a saved or resumed run, passes
|
|
26
|
+
with the directive.
|
|
23
27
|
"""
|
|
24
28
|
|
|
25
29
|
from __future__ import annotations
|
|
@@ -30,57 +34,85 @@ from pathlib import Path
|
|
|
30
34
|
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
31
35
|
if hooks_root_directory not in sys.path:
|
|
32
36
|
sys.path.insert(0, hooks_root_directory)
|
|
37
|
+
routing_directory = str(Path(__file__).resolve().parent)
|
|
38
|
+
if routing_directory not in sys.path:
|
|
39
|
+
sys.path.insert(0, routing_directory)
|
|
33
40
|
|
|
34
41
|
from hooks_constants.hook_specific_output_keys import (
|
|
35
42
|
HOOK_EVENT_NAME_KEY,
|
|
36
43
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
44
|
+
PERMISSION_DECISION_KEY,
|
|
37
45
|
)
|
|
38
46
|
from hooks_constants.pre_tool_use_context_runner import run_context_hook
|
|
39
47
|
from hooks_constants.spawn_oversight_hook_constants import (
|
|
40
48
|
ADDITIONAL_CONTEXT_KEY,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
ALL_BLOCKER_CLAUSE_MARKERS,
|
|
50
|
+
ALL_OUTCOME_MARKERS,
|
|
51
|
+
DENY_DECISION,
|
|
52
|
+
MARKER_DECORATION_CHARACTERS,
|
|
53
|
+
MISSING_BLOCKER_CLAUSE_REASON,
|
|
54
|
+
MISSING_OUTCOME_REASON,
|
|
55
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
46
56
|
PRE_TOOL_USE_EVENT_NAME,
|
|
57
|
+
REASON_SEPARATOR,
|
|
58
|
+
REFUSAL_PREFIX,
|
|
59
|
+
REFUSAL_SUFFIX,
|
|
47
60
|
SPAWN_OVERSIGHT_DIRECTIVE,
|
|
48
61
|
TOOL_INPUT_KEY,
|
|
49
62
|
TOOL_NAME_KEY,
|
|
50
|
-
|
|
63
|
+
WORKFLOW_TOOL_NAME,
|
|
51
64
|
)
|
|
65
|
+
from spawn_brief_text import spawn_brief_text
|
|
52
66
|
|
|
53
67
|
|
|
54
|
-
def
|
|
55
|
-
"""Return True when
|
|
68
|
+
def has_marked_clause(brief: str, all_markers: tuple[str, ...]) -> bool:
|
|
69
|
+
"""Return True when a marker appears in the brief with text after it on its line.
|
|
70
|
+
|
|
71
|
+
::
|
|
72
|
+
|
|
73
|
+
"Outcome: the test passes." , ("outcome:",) -> True
|
|
74
|
+
"- **Outcome:** the test passes." -> True
|
|
75
|
+
"Do X.\\nOutcome:\\nOn a blocker:" -> False
|
|
56
76
|
|
|
57
77
|
Args:
|
|
58
|
-
|
|
59
|
-
|
|
78
|
+
brief: The text the spawn hands its agents.
|
|
79
|
+
all_markers: Lowercase marker strings such as ``"outcome:"``.
|
|
60
80
|
"""
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
81
|
+
return any(
|
|
82
|
+
_has_text_after_marker(each_line, each_marker)
|
|
83
|
+
for each_line in brief.lower().splitlines()
|
|
84
|
+
for each_marker in all_markers
|
|
70
85
|
)
|
|
71
86
|
|
|
72
87
|
|
|
73
|
-
def
|
|
74
|
-
|
|
88
|
+
def _has_text_after_marker(line: str, marker: str) -> bool:
|
|
89
|
+
all_segments_after_marker = line.split(marker)[1:]
|
|
90
|
+
return any(
|
|
91
|
+
each_segment.strip(MARKER_DECORATION_CHARACTERS)
|
|
92
|
+
for each_segment in all_segments_after_marker
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def brief_gaps(brief: str) -> list[str]:
|
|
97
|
+
"""Return the refusal reason for each required clause the brief lacks.
|
|
98
|
+
|
|
99
|
+
::
|
|
100
|
+
|
|
101
|
+
"Run X on the PC via RC." -> [no outcome, no blocker clause]
|
|
102
|
+
"Outcome: X is done. On a blocker: try ..." -> []
|
|
75
103
|
|
|
76
104
|
Args:
|
|
77
|
-
|
|
105
|
+
brief: The text the spawn hands its agents.
|
|
78
106
|
"""
|
|
79
|
-
|
|
80
|
-
if not
|
|
81
|
-
|
|
82
|
-
if not
|
|
83
|
-
|
|
107
|
+
all_gaps = []
|
|
108
|
+
if not has_marked_clause(brief, ALL_OUTCOME_MARKERS):
|
|
109
|
+
all_gaps.append(MISSING_OUTCOME_REASON)
|
|
110
|
+
if not has_marked_clause(brief, ALL_BLOCKER_CLAUSE_MARKERS):
|
|
111
|
+
all_gaps.append(MISSING_BLOCKER_CLAUSE_REASON)
|
|
112
|
+
return all_gaps
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _directive_output() -> dict[str, object]:
|
|
84
116
|
return {
|
|
85
117
|
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
86
118
|
HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
|
|
@@ -89,6 +121,42 @@ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object]
|
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
|
|
124
|
+
def _deny_output(all_gaps: list[str]) -> dict[str, object]:
|
|
125
|
+
return {
|
|
126
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
127
|
+
HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
|
|
128
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
129
|
+
PERMISSION_DECISION_REASON_KEY: REASON_SEPARATOR.join(
|
|
130
|
+
[REFUSAL_PREFIX, *all_gaps, REFUSAL_SUFFIX]
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object] | None:
|
|
137
|
+
"""Return a deny for a brief that lacks a clause, the directive for one that passes.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
all_hook_fields: The parsed PreToolUse payload.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
None for a call that spawns no agent, else the deny or the directive.
|
|
144
|
+
"""
|
|
145
|
+
all_tool_input_fields = all_hook_fields.get(TOOL_INPUT_KEY)
|
|
146
|
+
if not isinstance(all_tool_input_fields, dict):
|
|
147
|
+
return None
|
|
148
|
+
tool_name = all_hook_fields.get(TOOL_NAME_KEY)
|
|
149
|
+
brief = spawn_brief_text(tool_name, all_tool_input_fields)
|
|
150
|
+
if brief is None:
|
|
151
|
+
return None
|
|
152
|
+
if tool_name == WORKFLOW_TOOL_NAME and not brief:
|
|
153
|
+
return _directive_output()
|
|
154
|
+
all_gaps = brief_gaps(brief)
|
|
155
|
+
if all_gaps:
|
|
156
|
+
return _deny_output(all_gaps)
|
|
157
|
+
return _directive_output()
|
|
158
|
+
|
|
159
|
+
|
|
92
160
|
def main() -> int:
|
|
93
161
|
"""Run the hook on the stdin payload; always 0."""
|
|
94
162
|
return run_context_hook(decide_hook_output)
|
|
@@ -39,15 +39,10 @@ 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,
|
|
42
|
-
ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME,
|
|
43
42
|
ALL_READ_ONLY_SUBAGENT_TYPES,
|
|
44
43
|
CODEX_TRANSCRIPT_REMINDER,
|
|
45
44
|
CODEX_TURN_ID_KEY,
|
|
46
45
|
DECISION_LOG_RELATIVE_PATH,
|
|
47
|
-
DISPATCH_INPUTS_KEY,
|
|
48
|
-
DISPATCH_METHOD_INPUT_KEY,
|
|
49
|
-
DISPATCH_PROMPT_INPUT_KEY,
|
|
50
|
-
DISPATCH_RUN_WORKFLOW_METHOD,
|
|
51
46
|
LOG_APPEND_MODE,
|
|
52
47
|
LOG_LINE_END,
|
|
53
48
|
LOG_OUTCOME_KEY,
|
|
@@ -70,50 +65,22 @@ from hooks_constants.spawn_readiness_hook_constants import (
|
|
|
70
65
|
TRANSCRIPT_ENCODING,
|
|
71
66
|
TRANSCRIPT_PATH_KEY,
|
|
72
67
|
WORKFLOW_DISPATCH_TOOL_NAME,
|
|
73
|
-
WORKFLOW_SCRIPT_PATH_INPUT_KEY,
|
|
74
|
-
WORKFLOW_TOOL_NAME,
|
|
75
68
|
)
|
|
76
69
|
from hooks_constants.hook_specific_output_keys import (
|
|
77
70
|
HOOK_EVENT_NAME_KEY,
|
|
78
71
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
79
72
|
)
|
|
73
|
+
from spawn_brief_text import spawn_brief_text
|
|
80
74
|
from spawn_readiness_steps import readiness_gaps, session_steps
|
|
81
75
|
|
|
82
76
|
|
|
83
|
-
def _workflow_script(all_tool_input_fields: dict[str, object]) -> str:
|
|
84
|
-
script = all_tool_input_fields.get(ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME[WORKFLOW_TOOL_NAME])
|
|
85
|
-
if isinstance(script, str):
|
|
86
|
-
return script
|
|
87
|
-
script_path = all_tool_input_fields.get(WORKFLOW_SCRIPT_PATH_INPUT_KEY)
|
|
88
|
-
if not isinstance(script_path, str) or not script_path:
|
|
89
|
-
return ""
|
|
90
|
-
try:
|
|
91
|
-
return Path(script_path).read_text(
|
|
92
|
-
encoding=TRANSCRIPT_ENCODING, errors=TRANSCRIPT_DECODE_ERRORS
|
|
93
|
-
)
|
|
94
|
-
except OSError:
|
|
95
|
-
return ""
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _dispatch_prompt(all_tool_input_fields: dict[str, object]) -> str | None:
|
|
99
|
-
if all_tool_input_fields.get(DISPATCH_METHOD_INPUT_KEY) != DISPATCH_RUN_WORKFLOW_METHOD:
|
|
100
|
-
return None
|
|
101
|
-
workflow_inputs = all_tool_input_fields.get(DISPATCH_INPUTS_KEY)
|
|
102
|
-
if not isinstance(workflow_inputs, dict):
|
|
103
|
-
return None
|
|
104
|
-
prompt = workflow_inputs.get(DISPATCH_PROMPT_INPUT_KEY)
|
|
105
|
-
return prompt if isinstance(prompt, str) else None
|
|
106
|
-
|
|
107
|
-
|
|
108
77
|
def spawn_brief(tool_name: str, all_tool_input_fields: dict[str, object]) -> str | None:
|
|
109
|
-
"""Return the brief a spawn call hands its agents, or None when the
|
|
78
|
+
"""Return the brief a spawn call hands its agents, or None when the hook skips it.
|
|
110
79
|
|
|
111
80
|
::
|
|
112
81
|
|
|
113
82
|
Agent {"prompt": "Do X."} -> "Do X."
|
|
114
83
|
Workflow {"scriptPath": "w.js"} -> the text of w.js
|
|
115
|
-
actions trigger {"method": "run_workflow",
|
|
116
|
-
"inputs": {"prompt": "Do X."}} -> "Do X."
|
|
117
84
|
actions trigger {"method": "cancel_workflow_run"} -> None
|
|
118
85
|
Agent {"subagent_type": "Explore", "prompt": ...} -> None
|
|
119
86
|
|
|
@@ -121,16 +88,12 @@ def spawn_brief(tool_name: str, all_tool_input_fields: dict[str, object]) -> str
|
|
|
121
88
|
tool_name: The tool the session called.
|
|
122
89
|
all_tool_input_fields: The tool input.
|
|
123
90
|
"""
|
|
124
|
-
if
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if all_tool_input_fields.get(SUBAGENT_TYPE_INPUT_KEY) in ALL_READ_ONLY_SUBAGENT_TYPES:
|
|
91
|
+
if (
|
|
92
|
+
tool_name != WORKFLOW_DISPATCH_TOOL_NAME
|
|
93
|
+
and all_tool_input_fields.get(SUBAGENT_TYPE_INPUT_KEY) in ALL_READ_ONLY_SUBAGENT_TYPES
|
|
94
|
+
):
|
|
129
95
|
return None
|
|
130
|
-
|
|
131
|
-
return _workflow_script(all_tool_input_fields)
|
|
132
|
-
brief = all_tool_input_fields.get(ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME[tool_name])
|
|
133
|
-
return brief if isinstance(brief, str) else ""
|
|
96
|
+
return spawn_brief_text(tool_name, all_tool_input_fields)
|
|
134
97
|
|
|
135
98
|
|
|
136
99
|
def scope_settled_line(brief: str) -> str | None:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from spawn_brief_text import spawn_brief_text
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
("tool_name", "tool_input", "expected_brief"),
|
|
10
|
+
[
|
|
11
|
+
("Agent", {"prompt": "Do X."}, "Do X."),
|
|
12
|
+
("mcp__hearthbot__start_remote_control", {"instructions": "Do Y."}, "Do Y."),
|
|
13
|
+
("multi_agent_v1__spawn_agent", {"message": "Do Z."}, "Do Z."),
|
|
14
|
+
("Agent", {"description": "No prompt"}, ""),
|
|
15
|
+
(
|
|
16
|
+
"mcp__github__actions_run_trigger",
|
|
17
|
+
{"method": "run_workflow", "inputs": {"prompt": "Do W."}},
|
|
18
|
+
"Do W.",
|
|
19
|
+
),
|
|
20
|
+
("mcp__github__actions_run_trigger", {"method": "cancel_workflow_run"}, None),
|
|
21
|
+
("Bash", {"command": "ls"}, None),
|
|
22
|
+
("Workflow", {"name": "saved-review"}, ""),
|
|
23
|
+
],
|
|
24
|
+
)
|
|
25
|
+
def test_should_return_the_brief_each_spawn_tool_carries(
|
|
26
|
+
tool_name: str, tool_input: dict[str, object], expected_brief: str | None
|
|
27
|
+
) -> None:
|
|
28
|
+
assert spawn_brief_text(tool_name, tool_input) == expected_brief
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_should_read_a_workflow_script_file(tmp_path: Path) -> None:
|
|
32
|
+
script_path = tmp_path / "workflow.js"
|
|
33
|
+
script_path.write_text("agent('Outcome: done.')", encoding="utf-8")
|
|
34
|
+
assert spawn_brief_text("Workflow", {"scriptPath": str(script_path)}) == (
|
|
35
|
+
"agent('Outcome: done.')"
|
|
36
|
+
)
|
|
@@ -6,12 +6,63 @@ from pathlib import Path
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
8
|
import spawn_oversight_hook
|
|
9
|
-
from hooks_constants.spawn_oversight_hook_constants import
|
|
9
|
+
from hooks_constants.spawn_oversight_hook_constants import (
|
|
10
|
+
MISSING_BLOCKER_CLAUSE_REASON,
|
|
11
|
+
MISSING_OUTCOME_REASON,
|
|
12
|
+
SPAWN_OVERSIGHT_DIRECTIVE,
|
|
13
|
+
)
|
|
10
14
|
|
|
11
15
|
HOOK_SCRIPT = Path(__file__).resolve().parent / "spawn_oversight_hook.py"
|
|
12
16
|
|
|
17
|
+
ROUTE_ONLY_BRIEF = "Run X on the PC via RC and send me the output."
|
|
18
|
+
GOOD_BRIEF = (
|
|
19
|
+
"Outcome: the user has X's output in this thread. Suggested route: RC on the PC. "
|
|
20
|
+
"On a blocker: try the bridge, a cloud run, or another route that reaches the "
|
|
21
|
+
"outcome; hand the user a step only when it needs their hands."
|
|
22
|
+
)
|
|
23
|
+
OUTCOME_ONLY_BRIEF = (
|
|
24
|
+
"Outcome: the user has X's output in this thread. Suggested route: RC on the PC."
|
|
25
|
+
)
|
|
26
|
+
BLOCKER_ONLY_BRIEF = (
|
|
27
|
+
"Run X on the PC via RC. On a blocker: try the bridge or a cloud run; hand the "
|
|
28
|
+
"user a step only when it needs their hands."
|
|
29
|
+
)
|
|
30
|
+
MARKDOWN_BRIEF = (
|
|
31
|
+
"Fix the parser.\n\n"
|
|
32
|
+
"- **Outcome:** the parser test passes on main.\n"
|
|
33
|
+
"- **On a blocker:** try another route; hand the user a step only when it needs "
|
|
34
|
+
"their hands.\n"
|
|
35
|
+
)
|
|
36
|
+
EMPTY_MARKERS_BRIEF = "Do X.\nOutcome:\nOn a blocker:"
|
|
37
|
+
|
|
38
|
+
ALL_BRIEF_FIELDS_BY_TOOL_NAME = {
|
|
39
|
+
"Agent": "prompt",
|
|
40
|
+
"Task": "prompt",
|
|
41
|
+
"Workflow": "script",
|
|
42
|
+
"multi_agent_v1__spawn_agent": "message",
|
|
43
|
+
"mcp__hearthbot__start_thread_session": "instructions",
|
|
44
|
+
"mcp__hearthbot__start_rc_session": "instructions",
|
|
45
|
+
"mcp__hearthbot__start_remote_control": "instructions",
|
|
46
|
+
}
|
|
47
|
+
WORKFLOW_DISPATCH_TOOL_NAME = "mcp__github__actions_run_trigger"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _spawn_payload(tool_name: str, brief: str) -> dict[str, object]:
|
|
51
|
+
if tool_name == WORKFLOW_DISPATCH_TOOL_NAME:
|
|
52
|
+
return {
|
|
53
|
+
"tool_name": tool_name,
|
|
54
|
+
"tool_input": {"method": "run_workflow", "inputs": {"prompt": brief}},
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
"tool_name": tool_name,
|
|
58
|
+
"tool_input": {ALL_BRIEF_FIELDS_BY_TOOL_NAME[tool_name]: brief},
|
|
59
|
+
}
|
|
13
60
|
|
|
14
|
-
|
|
61
|
+
|
|
62
|
+
ALL_SPAWN_TOOL_NAMES = [*ALL_BRIEF_FIELDS_BY_TOOL_NAME, WORKFLOW_DISPATCH_TOOL_NAME]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _directive_output() -> dict[str, object]:
|
|
15
66
|
return {
|
|
16
67
|
"hookSpecificOutput": {
|
|
17
68
|
"hookEventName": "PreToolUse",
|
|
@@ -20,45 +71,101 @@ def _expected_output() -> dict[str, object]:
|
|
|
20
71
|
}
|
|
21
72
|
|
|
22
73
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
[
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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()
|
|
74
|
+
def _deny_reason(hook_output: dict[str, object] | None) -> str:
|
|
75
|
+
assert hook_output is not None
|
|
76
|
+
hook_specific_output = hook_output["hookSpecificOutput"]
|
|
77
|
+
assert isinstance(hook_specific_output, dict)
|
|
78
|
+
assert hook_specific_output["hookEventName"] == "PreToolUse"
|
|
79
|
+
assert hook_specific_output["permissionDecision"] == "deny"
|
|
80
|
+
reason = hook_specific_output["permissionDecisionReason"]
|
|
81
|
+
assert isinstance(reason, str)
|
|
82
|
+
return reason
|
|
37
83
|
|
|
38
84
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
assert
|
|
85
|
+
@pytest.mark.parametrize("tool_name", ALL_SPAWN_TOOL_NAMES)
|
|
86
|
+
def test_a_route_only_brief_is_refused_on_every_spawn_tool(tool_name: str) -> None:
|
|
87
|
+
reason = _deny_reason(
|
|
88
|
+
spawn_oversight_hook.decide_hook_output(_spawn_payload(tool_name, ROUTE_ONLY_BRIEF))
|
|
89
|
+
)
|
|
90
|
+
assert MISSING_OUTCOME_REASON in reason
|
|
91
|
+
assert MISSING_BLOCKER_CLAUSE_REASON in reason
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("tool_name", ALL_SPAWN_TOOL_NAMES)
|
|
95
|
+
def test_a_brief_with_outcome_and_blocker_clause_passes_with_the_directive(
|
|
96
|
+
tool_name: str,
|
|
97
|
+
) -> None:
|
|
98
|
+
payload = _spawn_payload(tool_name, GOOD_BRIEF)
|
|
99
|
+
assert spawn_oversight_hook.decide_hook_output(payload) == _directive_output()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize("tool_name", ALL_SPAWN_TOOL_NAMES)
|
|
103
|
+
def test_an_outcome_without_a_blocker_clause_is_refused(tool_name: str) -> None:
|
|
104
|
+
reason = _deny_reason(
|
|
105
|
+
spawn_oversight_hook.decide_hook_output(_spawn_payload(tool_name, OUTCOME_ONLY_BRIEF))
|
|
106
|
+
)
|
|
107
|
+
assert MISSING_BLOCKER_CLAUSE_REASON in reason
|
|
108
|
+
assert MISSING_OUTCOME_REASON not in reason
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize("tool_name", ALL_SPAWN_TOOL_NAMES)
|
|
112
|
+
def test_a_blocker_clause_without_an_outcome_is_refused(tool_name: str) -> None:
|
|
113
|
+
reason = _deny_reason(
|
|
114
|
+
spawn_oversight_hook.decide_hook_output(_spawn_payload(tool_name, BLOCKER_ONLY_BRIEF))
|
|
115
|
+
)
|
|
116
|
+
assert MISSING_OUTCOME_REASON in reason
|
|
117
|
+
assert MISSING_BLOCKER_CLAUSE_REASON not in reason
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_markers_with_no_text_after_them_are_refused() -> None:
|
|
121
|
+
reason = _deny_reason(
|
|
122
|
+
spawn_oversight_hook.decide_hook_output(_spawn_payload("Agent", EMPTY_MARKERS_BRIEF))
|
|
123
|
+
)
|
|
124
|
+
assert MISSING_OUTCOME_REASON in reason
|
|
125
|
+
assert MISSING_BLOCKER_CLAUSE_REASON in reason
|
|
46
126
|
|
|
47
127
|
|
|
48
|
-
def
|
|
128
|
+
def test_markdown_bullet_markers_pass() -> None:
|
|
129
|
+
payload = _spawn_payload("Agent", MARKDOWN_BRIEF)
|
|
130
|
+
assert spawn_oversight_hook.decide_hook_output(payload) == _directive_output()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_a_missing_brief_field_is_refused() -> None:
|
|
134
|
+
payload = {"tool_name": "Agent", "tool_input": {"description": "No prompt"}}
|
|
135
|
+
assert MISSING_OUTCOME_REASON in _deny_reason(spawn_oversight_hook.decide_hook_output(payload))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_a_subagent_spawning_its_own_agent_is_checked() -> None:
|
|
139
|
+
payload = {**_spawn_payload("Agent", ROUTE_ONLY_BRIEF), "agent_id": "agent_child"}
|
|
140
|
+
assert MISSING_OUTCOME_REASON in _deny_reason(spawn_oversight_hook.decide_hook_output(payload))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_a_read_only_helper_spawn_is_checked() -> None:
|
|
49
144
|
payload = {
|
|
50
145
|
"tool_name": "Agent",
|
|
51
146
|
"tool_input": {"subagent_type": "Explore", "prompt": "Find X."},
|
|
52
147
|
}
|
|
53
|
-
assert spawn_oversight_hook.decide_hook_output(payload)
|
|
148
|
+
assert MISSING_OUTCOME_REASON in _deny_reason(spawn_oversight_hook.decide_hook_output(payload))
|
|
54
149
|
|
|
55
150
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
151
|
+
def test_a_workflow_script_file_is_read_for_the_markers(tmp_path: Path) -> None:
|
|
152
|
+
script_path = tmp_path / "workflow.js"
|
|
153
|
+
script_path.write_text(f"agent({json.dumps(ROUTE_ONLY_BRIEF)})", encoding="utf-8")
|
|
154
|
+
payload = {"tool_name": "Workflow", "tool_input": {"scriptPath": str(script_path)}}
|
|
155
|
+
assert MISSING_OUTCOME_REASON in _deny_reason(spawn_oversight_hook.decide_hook_output(payload))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.parametrize(
|
|
159
|
+
"tool_input",
|
|
160
|
+
[
|
|
161
|
+
{"name": "saved-review"},
|
|
162
|
+
{"resumeFromRunId": "wf_abc123"},
|
|
163
|
+
{"scriptPath": "/nonexistent/workflow.js"},
|
|
164
|
+
],
|
|
165
|
+
)
|
|
166
|
+
def test_a_workflow_with_no_script_text_gets_the_directive(tool_input: dict[str, object]) -> None:
|
|
167
|
+
payload = {"tool_name": "Workflow", "tool_input": tool_input}
|
|
168
|
+
assert spawn_oversight_hook.decide_hook_output(payload) == _directive_output()
|
|
62
169
|
|
|
63
170
|
|
|
64
171
|
@pytest.mark.parametrize(
|
|
@@ -82,23 +189,46 @@ def test_other_calls_get_no_output(payload: dict[str, object]) -> None:
|
|
|
82
189
|
assert spawn_oversight_hook.decide_hook_output(payload) is None
|
|
83
190
|
|
|
84
191
|
|
|
85
|
-
def
|
|
192
|
+
def _run_script(stdin_text: str) -> str:
|
|
86
193
|
completed = subprocess.run(
|
|
87
194
|
[sys.executable, str(HOOK_SCRIPT)],
|
|
88
|
-
input=
|
|
195
|
+
input=stdin_text,
|
|
89
196
|
capture_output=True,
|
|
90
197
|
text=True,
|
|
91
198
|
check=True,
|
|
92
199
|
)
|
|
93
|
-
|
|
200
|
+
return completed.stdout
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@pytest.mark.parametrize("tool_name", ALL_SPAWN_TOOL_NAMES)
|
|
204
|
+
def test_script_refuses_the_route_only_brief(tool_name: str) -> None:
|
|
205
|
+
stdout = _run_script(json.dumps(_spawn_payload(tool_name, ROUTE_ONLY_BRIEF)))
|
|
206
|
+
assert MISSING_OUTCOME_REASON in _deny_reason(json.loads(stdout))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.parametrize("tool_name", ALL_SPAWN_TOOL_NAMES)
|
|
210
|
+
def test_script_passes_the_good_brief(tool_name: str) -> None:
|
|
211
|
+
stdout = _run_script(json.dumps(_spawn_payload(tool_name, GOOD_BRIEF)))
|
|
212
|
+
assert json.loads(stdout) == _directive_output()
|
|
94
213
|
|
|
95
214
|
|
|
96
215
|
def test_script_prints_nothing_for_empty_stdin() -> None:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
216
|
+
assert _run_script("") == ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_has_marked_clause_should_find_a_marker_mid_line_with_text_after_it() -> None:
|
|
220
|
+
assert spawn_oversight_hook.has_marked_clause("Do X. Outcome: X is done.", ("outcome:",))
|
|
221
|
+
assert not spawn_oversight_hook.has_marked_clause("Do X. Outcome: **", ("outcome:",))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_brief_gaps_should_name_each_missing_clause() -> None:
|
|
225
|
+
assert spawn_oversight_hook.brief_gaps(GOOD_BRIEF) == []
|
|
226
|
+
assert spawn_oversight_hook.brief_gaps(ROUTE_ONLY_BRIEF) == [
|
|
227
|
+
MISSING_OUTCOME_REASON,
|
|
228
|
+
MISSING_BLOCKER_CLAUSE_REASON,
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_a_workflow_script_path_that_cannot_be_opened_gets_the_directive() -> None:
|
|
233
|
+
payload = {"tool_name": "Workflow", "tool_input": {"scriptPath": "bad\u0000path.js"}}
|
|
234
|
+
assert json.loads(_run_script(json.dumps(payload))) == _directive_output()
|
|
@@ -394,3 +394,16 @@ def test_should_print_nothing_for_empty_stdin(tmp_path: Path) -> None:
|
|
|
394
394
|
check=True,
|
|
395
395
|
)
|
|
396
396
|
assert completed.stdout == ""
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_should_skip_a_read_only_helper_brief_and_read_a_remote_control_brief() -> None:
|
|
400
|
+
assert (
|
|
401
|
+
spawn_readiness_hook.spawn_brief("Agent", {"subagent_type": "Explore", "prompt": "Find X."})
|
|
402
|
+
is None
|
|
403
|
+
)
|
|
404
|
+
assert (
|
|
405
|
+
spawn_readiness_hook.spawn_brief(
|
|
406
|
+
"mcp__hearthbot__start_remote_control", {"instructions": "Do X."}
|
|
407
|
+
)
|
|
408
|
+
== "Do X."
|
|
409
|
+
)
|