claude-dev-env 8.93.2 → 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.
@@ -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,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` 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
+ - `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 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.
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 each level of the tree oversees the level below it. It emits `additionalContext` only and never blocks.
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
@@ -250,7 +250,7 @@
250
250
  ]
251
251
  },
252
252
  {
253
- "matcher": "mcp__hearthbot__start_rc_session",
253
+ "matcher": "mcp__hearthbot__start_rc_session|mcp__hearthbot__start_remote_control",
254
254
  "hooks": [
255
255
  {
256
256
  "type": "command",
@@ -1,8 +1,9 @@
1
- """Shared main loop for PreToolUse hooks that only add context.
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. Exit status is always 0, so the tool call runs; a
5
- decide function that raises leaves the call with no output.
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 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.
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
- 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"
18
+ ALL_OUTCOME_MARKERS = ("outcome:",)
19
+ ALL_BLOCKER_CLAUSE_MARKERS = ("on a blocker:", "at a blocker:")
20
+ MARKER_DECORATION_CHARACTERS = "*_` \t"
20
21
 
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
- }
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: tell the spawning session it owns the work it delegates.
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``, 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.
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": "Do X."} -> additionalContext: the directive
16
- actions trigger {"method": "run_workflow",
17
- "inputs": {"prompt": "Do X."}}
18
- -> additionalContext: the directive
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
- -> no output
21
- Bash {"command": "ls"} -> no output
22
- Agent "not an object" -> no output
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
- ALL_SPAWN_TOOL_NAMES,
42
- DISPATCH_INPUTS_KEY,
43
- DISPATCH_METHOD_INPUT_KEY,
44
- DISPATCH_PROMPT_INPUT_KEY,
45
- DISPATCH_RUN_WORKFLOW_METHOD,
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
- WORKFLOW_DISPATCH_TOOL_NAME,
63
+ WORKFLOW_TOOL_NAME,
51
64
  )
65
+ from spawn_brief_text import spawn_brief_text
52
66
 
53
67
 
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.
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
- tool_name: The tool the session called.
59
- all_tool_input_fields: The tool input.
78
+ brief: The text the spawn hands its agents.
79
+ all_markers: Lowercase marker strings such as ``"outcome:"``.
60
80
  """
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
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 decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object] | None:
74
- """Return the oversight directive for a spawn call, else None.
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
- all_hook_fields: The parsed PreToolUse payload.
105
+ brief: The text the spawn hands its agents.
78
106
  """
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
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 call spawns none.
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 tool_name == WORKFLOW_DISPATCH_TOOL_NAME:
125
- return _dispatch_prompt(all_tool_input_fields)
126
- if tool_name not in ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME:
127
- return None
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
- if tool_name == WORKFLOW_TOOL_NAME:
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 SPAWN_OVERSIGHT_DIRECTIVE
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
- def _expected_output() -> dict[str, object]:
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
- @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()
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
- 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()
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 test_a_read_only_helper_spawn_gets_the_directive() -> None:
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) == _expected_output()
148
+ assert MISSING_OUTCOME_REASON in _deny_reason(spawn_oversight_hook.decide_hook_output(payload))
54
149
 
55
150
 
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()
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 test_script_prints_the_directive_for_a_spawn() -> None:
192
+ def _run_script(stdin_text: str) -> str:
86
193
  completed = subprocess.run(
87
194
  [sys.executable, str(HOOK_SCRIPT)],
88
- input=json.dumps({"tool_name": "Agent", "tool_input": {"prompt": "Do X."}}),
195
+ input=stdin_text,
89
196
  capture_output=True,
90
197
  text=True,
91
198
  check=True,
92
199
  )
93
- assert json.loads(completed.stdout) == _expected_output()
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
- 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 == ""
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
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.93.2",
3
+ "version": "8.94.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {