claude-dev-env 8.41.0 → 8.42.1
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/bin/install.cursor-rules.test.mjs +2 -1
- package/docs/rule-guides/no-contrast-framing.md +2 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/spawn_readiness_hook_constants.py +30 -12
- package/hooks/routing/spawn_readiness_hook.py +118 -46
- package/hooks/routing/test_spawn_readiness_hook.py +113 -21
- package/hooks/routing/test_spawn_readiness_steps.py +2 -4
- package/package.json +1 -1
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
- package/scripts/tests/test_contrast_framing.py +1 -1
|
@@ -122,8 +122,9 @@ test('seeds one editable policy and one native Codex routing hook', () => {
|
|
|
122
122
|
group => group.matcher === 'multi_agent_v1__spawn_agent',
|
|
123
123
|
);
|
|
124
124
|
assert.equal(firstRoutingGroups.length, 1);
|
|
125
|
-
assert.equal(firstRoutingGroups[0].hooks.length,
|
|
125
|
+
assert.equal(firstRoutingGroups[0].hooks.length, 2);
|
|
126
126
|
assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
|
|
127
|
+
assert.match(firstRoutingGroups[0].hooks[1].command, /spawn_readiness_hook\.py/);
|
|
127
128
|
const firstSpawnPromptGroups = firstCodexHooks.hooks.PreToolUse.filter(
|
|
128
129
|
group => group.matcher === 'Agent|Task',
|
|
129
130
|
);
|
|
@@ -54,8 +54,8 @@ file and the line.
|
|
|
54
54
|
|
|
55
55
|
The pattern list lives in
|
|
56
56
|
`scripts/dev_env_scripts_constants/contrast_framing_constants.py`, and both
|
|
57
|
-
lints read that one list. A synchronization test requires
|
|
58
|
-
|
|
57
|
+
lints read that one list. A synchronization test requires the rule file
|
|
58
|
+
`rules/no-contrast-framing.md` to name every form the list carries.
|
|
59
59
|
|
|
60
60
|
A chat reply reaches no check, so the same list is what the writer reads the
|
|
61
61
|
sentence against: a comma followed by `not`, a `rather than`, a `not just`, a
|
package/hooks/hooks.json
CHANGED
|
@@ -24,6 +24,21 @@
|
|
|
24
24
|
"type": "command",
|
|
25
25
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/routing/subagent_model_routing.mjs\"",
|
|
26
26
|
"timeout": 10
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
31
|
+
"timeout": 10
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"matcher": "Workflow|mcp__github__actions_run_trigger",
|
|
37
|
+
"hooks": [
|
|
38
|
+
{
|
|
39
|
+
"type": "command",
|
|
40
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
41
|
+
"timeout": 10
|
|
27
42
|
}
|
|
28
43
|
]
|
|
29
44
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Groups: the spawn tools it checks and their brief fields, the transcript entry
|
|
4
4
|
shapes it reads, the tool names that count as a read or a question, the
|
|
5
|
-
scope-settled line, and the
|
|
5
|
+
scope-settled line, and the reminder messages and log fields.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -15,11 +15,22 @@ AGENT_ID_KEY = "agent_id"
|
|
|
15
15
|
SUBAGENT_TYPE_INPUT_KEY = "subagent_type"
|
|
16
16
|
|
|
17
17
|
THREAD_SPAWN_TOOL_NAME = "mcp__hearthbot__start_thread_session"
|
|
18
|
+
CODEX_SPAWN_TOOL_NAME = "multi_agent_v1__spawn_agent"
|
|
19
|
+
WORKFLOW_TOOL_NAME = "Workflow"
|
|
20
|
+
WORKFLOW_SCRIPT_PATH_INPUT_KEY = "scriptPath"
|
|
18
21
|
ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME = {
|
|
19
22
|
"Agent": "prompt",
|
|
20
23
|
"Task": "prompt",
|
|
21
24
|
THREAD_SPAWN_TOOL_NAME: "instructions",
|
|
25
|
+
CODEX_SPAWN_TOOL_NAME: "message",
|
|
26
|
+
WORKFLOW_TOOL_NAME: "script",
|
|
22
27
|
}
|
|
28
|
+
WORKFLOW_DISPATCH_TOOL_NAME = "mcp__github__actions_run_trigger"
|
|
29
|
+
DISPATCH_METHOD_INPUT_KEY = "method"
|
|
30
|
+
DISPATCH_RUN_WORKFLOW_METHOD = "run_workflow"
|
|
31
|
+
DISPATCH_INPUTS_KEY = "inputs"
|
|
32
|
+
DISPATCH_PROMPT_INPUT_KEY = "prompt"
|
|
33
|
+
CODEX_TURN_ID_KEY = "turn_id"
|
|
23
34
|
ALL_READ_ONLY_SUBAGENT_TYPES = frozenset({"Explore", "Plan", "claude-code-guide"})
|
|
24
35
|
|
|
25
36
|
ENTRY_TYPE_KEY = "type"
|
|
@@ -80,20 +91,26 @@ SCOPE_SETTLED_PREFIX = "Scope settled:"
|
|
|
80
91
|
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
81
92
|
HOOK_SPECIFIC_OUTPUT_KEY = "hookSpecificOutput"
|
|
82
93
|
HOOK_EVENT_NAME_KEY = "hookEventName"
|
|
83
|
-
|
|
84
|
-
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
85
|
-
PERMISSION_DENY = "deny"
|
|
94
|
+
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
86
95
|
MISSING_INVESTIGATION_REASON = (
|
|
87
|
-
"
|
|
88
|
-
"sources
|
|
89
|
-
"
|
|
96
|
+
"This spawn comes before any read of the request. Read the files, threads, "
|
|
97
|
+
"or sources the request names, then check that the brief and the agent "
|
|
98
|
+
"count fit the task."
|
|
90
99
|
)
|
|
91
100
|
MISSING_INTERVIEW_REASON = (
|
|
92
|
-
"
|
|
93
|
-
"goals questions through AskUserQuestion, a
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
f'"{SCOPE_SETTLED_PREFIX}" and names the
|
|
101
|
+
"This spawn comes before an answered question to the user. Ask the open "
|
|
102
|
+
"scope, requirements, and goals questions through AskUserQuestion, a "
|
|
103
|
+
"decision card, or an interactive widget. When the request already "
|
|
104
|
+
"settles scope, add a brief line that starts with "
|
|
105
|
+
f'"{SCOPE_SETTLED_PREFIX}" and names the defaults you chose.'
|
|
106
|
+
)
|
|
107
|
+
CODEX_TRANSCRIPT_REMINDER = (
|
|
108
|
+
"This hook reads no Codex transcript, because Codex documents that format "
|
|
109
|
+
"as unstable for hooks. Before this spawn, read what the request names, "
|
|
110
|
+
"size the agents to the independent pieces, and ask the user the open "
|
|
111
|
+
"scope questions. When the request already settles scope, add a brief "
|
|
112
|
+
f'line that starts with "{SCOPE_SETTLED_PREFIX}" and names the defaults '
|
|
113
|
+
"you chose."
|
|
97
114
|
)
|
|
98
115
|
REASON_SEPARATOR = " "
|
|
99
116
|
|
|
@@ -108,4 +125,5 @@ LOG_TOOL_USE_ID_KEY = "tool_use_id"
|
|
|
108
125
|
LOG_OUTCOME_KEY = "outcome"
|
|
109
126
|
LOG_SCOPE_SETTLED_LINE_KEY = "scope_settled_line"
|
|
110
127
|
OUTCOME_SCOPE_SETTLED = "scope_settled"
|
|
128
|
+
OUTCOME_REMINDED = "reminded"
|
|
111
129
|
OUTCOME_TRANSCRIPT_UNREADABLE = "transcript_unreadable"
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook:
|
|
2
|
+
"""PreToolUse hook: remind a session to read and ask before it spawns agents.
|
|
3
3
|
|
|
4
|
-
Registered on ``Agent``, ``Task``,
|
|
5
|
-
|
|
4
|
+
Registered on ``Agent``, ``Task``, ``mcp__hearthbot__start_thread_session``,
|
|
5
|
+
``multi_agent_v1__spawn_agent``, ``Workflow``, and a workflow dispatch whose
|
|
6
|
+
inputs carry a ``prompt``. It reads the session transcript and checks two
|
|
7
|
+
things since the user's request:
|
|
6
8
|
|
|
7
9
|
::
|
|
8
10
|
|
|
9
|
-
no read step after the request ->
|
|
10
|
-
no interactive question, then an answer ->
|
|
11
|
+
no read step after the request -> context: investigate first
|
|
12
|
+
no interactive question, then an answer -> context: interview first
|
|
11
13
|
brief line "Scope settled: <reason>" -> interview check passes, logged
|
|
12
|
-
both found -> no output
|
|
14
|
+
both found -> no output
|
|
15
|
+
Codex payload (turn_id), no settled line -> context: the Codex reminder
|
|
13
16
|
|
|
14
|
-
``
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
The output is ``additionalContext`` alone, so the spawn runs and keeps its
|
|
18
|
+
normal permission flow. ``spawn_readiness_steps`` reads the transcript and
|
|
19
|
+
finds the request. Four cases pass without a check: a call from inside a
|
|
20
|
+
subagent, a read-only subagent type, a tool input that is not an object, and a
|
|
21
|
+
transcript the hook cannot read. The last one is logged.
|
|
18
22
|
"""
|
|
19
23
|
|
|
20
24
|
from __future__ import annotations
|
|
@@ -31,13 +35,19 @@ routing_directory = str(Path(__file__).resolve().parent)
|
|
|
31
35
|
if routing_directory not in sys.path:
|
|
32
36
|
sys.path.insert(0, routing_directory)
|
|
33
37
|
|
|
34
|
-
from hooks_constants.hook_block_logger import log_hook_block
|
|
35
38
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
36
39
|
from hooks_constants.spawn_readiness_hook_constants import (
|
|
40
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
37
41
|
AGENT_ID_KEY,
|
|
38
42
|
ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME,
|
|
39
43
|
ALL_READ_ONLY_SUBAGENT_TYPES,
|
|
44
|
+
CODEX_TRANSCRIPT_REMINDER,
|
|
45
|
+
CODEX_TURN_ID_KEY,
|
|
40
46
|
DECISION_LOG_RELATIVE_PATH,
|
|
47
|
+
DISPATCH_INPUTS_KEY,
|
|
48
|
+
DISPATCH_METHOD_INPUT_KEY,
|
|
49
|
+
DISPATCH_PROMPT_INPUT_KEY,
|
|
50
|
+
DISPATCH_RUN_WORKFLOW_METHOD,
|
|
41
51
|
HOOK_EVENT_NAME_KEY,
|
|
42
52
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
43
53
|
LOG_APPEND_MODE,
|
|
@@ -48,11 +58,9 @@ from hooks_constants.spawn_readiness_hook_constants import (
|
|
|
48
58
|
LOG_TOOL_NAME_KEY,
|
|
49
59
|
LOG_TOOL_USE_ID_KEY,
|
|
50
60
|
MISSING_INTERVIEW_REASON,
|
|
61
|
+
OUTCOME_REMINDED,
|
|
51
62
|
OUTCOME_SCOPE_SETTLED,
|
|
52
63
|
OUTCOME_TRANSCRIPT_UNREADABLE,
|
|
53
|
-
PERMISSION_DECISION_KEY,
|
|
54
|
-
PERMISSION_DECISION_REASON_KEY,
|
|
55
|
-
PERMISSION_DENY,
|
|
56
64
|
PRE_TOOL_USE_EVENT_NAME,
|
|
57
65
|
REASON_SEPARATOR,
|
|
58
66
|
SCOPE_SETTLED_PREFIX,
|
|
@@ -63,11 +71,67 @@ from hooks_constants.spawn_readiness_hook_constants import (
|
|
|
63
71
|
TRANSCRIPT_DECODE_ERRORS,
|
|
64
72
|
TRANSCRIPT_ENCODING,
|
|
65
73
|
TRANSCRIPT_PATH_KEY,
|
|
74
|
+
WORKFLOW_DISPATCH_TOOL_NAME,
|
|
75
|
+
WORKFLOW_SCRIPT_PATH_INPUT_KEY,
|
|
76
|
+
WORKFLOW_TOOL_NAME,
|
|
66
77
|
)
|
|
67
78
|
from spawn_readiness_steps import readiness_gaps, session_steps
|
|
68
79
|
|
|
69
80
|
|
|
70
|
-
def
|
|
81
|
+
def _workflow_script(all_tool_input_fields: dict[str, object]) -> str:
|
|
82
|
+
script = all_tool_input_fields.get(ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME[WORKFLOW_TOOL_NAME])
|
|
83
|
+
if isinstance(script, str):
|
|
84
|
+
return script
|
|
85
|
+
script_path = all_tool_input_fields.get(WORKFLOW_SCRIPT_PATH_INPUT_KEY)
|
|
86
|
+
if not isinstance(script_path, str) or not script_path:
|
|
87
|
+
return ""
|
|
88
|
+
try:
|
|
89
|
+
return Path(script_path).read_text(
|
|
90
|
+
encoding=TRANSCRIPT_ENCODING, errors=TRANSCRIPT_DECODE_ERRORS
|
|
91
|
+
)
|
|
92
|
+
except OSError:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _dispatch_prompt(all_tool_input_fields: dict[str, object]) -> str | None:
|
|
97
|
+
if all_tool_input_fields.get(DISPATCH_METHOD_INPUT_KEY) != DISPATCH_RUN_WORKFLOW_METHOD:
|
|
98
|
+
return None
|
|
99
|
+
workflow_inputs = all_tool_input_fields.get(DISPATCH_INPUTS_KEY)
|
|
100
|
+
if not isinstance(workflow_inputs, dict):
|
|
101
|
+
return None
|
|
102
|
+
prompt = workflow_inputs.get(DISPATCH_PROMPT_INPUT_KEY)
|
|
103
|
+
return prompt if isinstance(prompt, str) else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def spawn_brief(tool_name: str, all_tool_input_fields: dict[str, object]) -> str | None:
|
|
107
|
+
"""Return the brief a spawn call hands its agents, or None when the call spawns none.
|
|
108
|
+
|
|
109
|
+
::
|
|
110
|
+
|
|
111
|
+
Agent {"prompt": "Do X."} -> "Do X."
|
|
112
|
+
Workflow {"scriptPath": "w.js"} -> the text of w.js
|
|
113
|
+
actions trigger {"method": "run_workflow",
|
|
114
|
+
"inputs": {"prompt": "Do X."}} -> "Do X."
|
|
115
|
+
actions trigger {"method": "cancel_workflow_run"} -> None
|
|
116
|
+
Agent {"subagent_type": "Explore", "prompt": ...} -> None
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
tool_name: The tool the session called.
|
|
120
|
+
all_tool_input_fields: The tool input.
|
|
121
|
+
"""
|
|
122
|
+
if tool_name == WORKFLOW_DISPATCH_TOOL_NAME:
|
|
123
|
+
return _dispatch_prompt(all_tool_input_fields)
|
|
124
|
+
if tool_name not in ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME:
|
|
125
|
+
return None
|
|
126
|
+
if all_tool_input_fields.get(SUBAGENT_TYPE_INPUT_KEY) in ALL_READ_ONLY_SUBAGENT_TYPES:
|
|
127
|
+
return None
|
|
128
|
+
if tool_name == WORKFLOW_TOOL_NAME:
|
|
129
|
+
return _workflow_script(all_tool_input_fields)
|
|
130
|
+
brief = all_tool_input_fields.get(ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME[tool_name])
|
|
131
|
+
return brief if isinstance(brief, str) else ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def scope_settled_line(brief: str) -> str | None:
|
|
71
135
|
"""Return the brief's "Scope settled:" line when it names a reason, else None.
|
|
72
136
|
|
|
73
137
|
::
|
|
@@ -77,12 +141,8 @@ def scope_settled_line(tool_name: str, all_tool_input_fields: dict[str, object])
|
|
|
77
141
|
"Do X.\\nScope settled:" -> None
|
|
78
142
|
|
|
79
143
|
Args:
|
|
80
|
-
|
|
81
|
-
all_tool_input_fields: The spawn input.
|
|
144
|
+
brief: The text the spawn hands its agents.
|
|
82
145
|
"""
|
|
83
|
-
brief = all_tool_input_fields.get(ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME.get(tool_name, ""))
|
|
84
|
-
if not isinstance(brief, str):
|
|
85
|
-
return None
|
|
86
146
|
for each_line in brief.splitlines():
|
|
87
147
|
stripped_line = each_line.strip()
|
|
88
148
|
stated_reason = stripped_line.removeprefix(SCOPE_SETTLED_PREFIX).strip()
|
|
@@ -125,24 +185,35 @@ def _transcript_lines(transcript_path: object) -> list[str] | None:
|
|
|
125
185
|
return None
|
|
126
186
|
|
|
127
187
|
|
|
128
|
-
def
|
|
188
|
+
def _context_output(context: str) -> dict[str, object]:
|
|
129
189
|
return {
|
|
130
190
|
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
131
191
|
HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
|
|
132
|
-
|
|
133
|
-
PERMISSION_DECISION_REASON_KEY: reason,
|
|
192
|
+
ADDITIONAL_CONTEXT_KEY: context,
|
|
134
193
|
}
|
|
135
194
|
}
|
|
136
195
|
|
|
137
196
|
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
197
|
+
def _transcript_gaps(
|
|
198
|
+
all_hook_fields: dict[str, object], tool_use_id: str | None
|
|
199
|
+
) -> list[str] | None:
|
|
200
|
+
if all_hook_fields.get(CODEX_TURN_ID_KEY):
|
|
201
|
+
return [CODEX_TRANSCRIPT_REMINDER]
|
|
202
|
+
all_transcript_lines = _transcript_lines(all_hook_fields.get(TRANSCRIPT_PATH_KEY))
|
|
203
|
+
if all_transcript_lines is None:
|
|
204
|
+
return None
|
|
205
|
+
return readiness_gaps(session_steps(all_transcript_lines, tool_use_id))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _gaps_after_scope_settled(
|
|
209
|
+
all_gaps: list[str], brief: str, tool_name: str, tool_use_id: str | None
|
|
210
|
+
) -> list[str]:
|
|
211
|
+
settled_line = scope_settled_line(brief)
|
|
212
|
+
all_settled_gaps = {MISSING_INTERVIEW_REASON, CODEX_TRANSCRIPT_REMINDER}
|
|
213
|
+
if settled_line is None or not all_settled_gaps.intersection(all_gaps):
|
|
214
|
+
return all_gaps
|
|
215
|
+
_log_decision(tool_name, tool_use_id, OUTCOME_SCOPE_SETTLED, settled_line)
|
|
216
|
+
return [each_gap for each_gap in all_gaps if each_gap not in all_settled_gaps]
|
|
146
217
|
|
|
147
218
|
|
|
148
219
|
def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object] | None:
|
|
@@ -152,34 +223,35 @@ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object]
|
|
|
152
223
|
all_hook_fields: The parsed PreToolUse payload.
|
|
153
224
|
|
|
154
225
|
Returns:
|
|
155
|
-
None to
|
|
226
|
+
None to stay quiet, else the reminder as additionalContext output.
|
|
156
227
|
"""
|
|
157
|
-
|
|
228
|
+
tool_name = all_hook_fields.get(TOOL_NAME_KEY)
|
|
229
|
+
tool_input = all_hook_fields.get(TOOL_INPUT_KEY)
|
|
230
|
+
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
231
|
+
return None
|
|
232
|
+
if all_hook_fields.get(AGENT_ID_KEY):
|
|
233
|
+
return None
|
|
234
|
+
brief = spawn_brief(tool_name, tool_input)
|
|
235
|
+
if brief is None:
|
|
158
236
|
return None
|
|
159
|
-
tool_name = str(all_hook_fields[TOOL_NAME_KEY])
|
|
160
237
|
raw_tool_use_id = all_hook_fields.get(TOOL_USE_ID_KEY)
|
|
161
238
|
tool_use_id = raw_tool_use_id if isinstance(raw_tool_use_id, str) else None
|
|
162
|
-
|
|
163
|
-
if
|
|
239
|
+
all_gaps = _transcript_gaps(all_hook_fields, tool_use_id)
|
|
240
|
+
if all_gaps is None:
|
|
164
241
|
_log_decision(tool_name, tool_use_id, OUTCOME_TRANSCRIPT_UNREADABLE, None)
|
|
165
242
|
return None
|
|
166
|
-
all_gaps =
|
|
167
|
-
settled_line = scope_settled_line(tool_name, all_hook_fields[TOOL_INPUT_KEY])
|
|
168
|
-
if settled_line is not None and MISSING_INTERVIEW_REASON in all_gaps:
|
|
169
|
-
all_gaps.remove(MISSING_INTERVIEW_REASON)
|
|
170
|
-
_log_decision(tool_name, tool_use_id, OUTCOME_SCOPE_SETTLED, settled_line)
|
|
243
|
+
all_gaps = _gaps_after_scope_settled(all_gaps, brief, tool_name, tool_use_id)
|
|
171
244
|
if not all_gaps:
|
|
172
245
|
return None
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return _deny_output(deny_reason)
|
|
246
|
+
_log_decision(tool_name, tool_use_id, OUTCOME_REMINDED, None)
|
|
247
|
+
return _context_output(REASON_SEPARATOR.join(all_gaps))
|
|
176
248
|
|
|
177
249
|
|
|
178
250
|
def main() -> int:
|
|
179
|
-
"""Read the PreToolUse payload and print
|
|
251
|
+
"""Read the PreToolUse payload and print the reminder when the spawn is not ready.
|
|
180
252
|
|
|
181
253
|
Returns:
|
|
182
|
-
0 in every case;
|
|
254
|
+
0 in every case; the reminder travels in the JSON output.
|
|
183
255
|
"""
|
|
184
256
|
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
185
257
|
if hook_payload is None:
|
|
@@ -8,12 +8,15 @@ import pytest
|
|
|
8
8
|
|
|
9
9
|
import spawn_readiness_hook
|
|
10
10
|
from hooks_constants.spawn_readiness_hook_constants import (
|
|
11
|
+
CODEX_TRANSCRIPT_REMINDER,
|
|
11
12
|
MISSING_INTERVIEW_REASON,
|
|
12
13
|
MISSING_INVESTIGATION_REASON,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
HOOK_SCRIPT = Path(__file__).resolve().parent / "spawn_readiness_hook.py"
|
|
16
17
|
THREAD_SPAWN_TOOL_NAME = "mcp__hearthbot__start_thread_session"
|
|
18
|
+
CODEX_SPAWN_TOOL_NAME = "multi_agent_v1__spawn_agent"
|
|
19
|
+
DISPATCH_TOOL_NAME = "mcp__github__actions_run_trigger"
|
|
17
20
|
SETTLED_LINE = "Scope settled: the request names the file, the fix, and the test."
|
|
18
21
|
|
|
19
22
|
|
|
@@ -63,6 +66,12 @@ INTERVIEWED_TRANSCRIPT = [_user("Build the hook."), READ_STEP, QUESTION_STEP, RE
|
|
|
63
66
|
def _spawn_input(tool_name: str, brief: str = "Do the task.") -> dict[str, object]:
|
|
64
67
|
if tool_name == THREAD_SPAWN_TOOL_NAME:
|
|
65
68
|
return {"title": "Task", "instructions": brief}
|
|
69
|
+
if tool_name == CODEX_SPAWN_TOOL_NAME:
|
|
70
|
+
return {"message": brief}
|
|
71
|
+
if tool_name == "Workflow":
|
|
72
|
+
return {"script": brief}
|
|
73
|
+
if tool_name == DISPATCH_TOOL_NAME:
|
|
74
|
+
return {"method": "run_workflow", "workflow_id": "build.yml", "inputs": {"prompt": brief}}
|
|
66
75
|
return {"description": "Task", "prompt": brief}
|
|
67
76
|
|
|
68
77
|
|
|
@@ -104,11 +113,11 @@ def _spawn(
|
|
|
104
113
|
)
|
|
105
114
|
|
|
106
115
|
|
|
107
|
-
def
|
|
116
|
+
def _reminder(stdout: str) -> str:
|
|
108
117
|
decision = json.loads(stdout)["hookSpecificOutput"]
|
|
118
|
+
assert set(decision) == {"hookEventName", "additionalContext"}
|
|
109
119
|
assert decision["hookEventName"] == "PreToolUse"
|
|
110
|
-
|
|
111
|
-
return decision["permissionDecisionReason"]
|
|
120
|
+
return decision["additionalContext"]
|
|
112
121
|
|
|
113
122
|
|
|
114
123
|
def _decision_log(tmp_path: Path) -> list[dict[str, object]]:
|
|
@@ -118,30 +127,33 @@ def _decision_log(tmp_path: Path) -> list[dict[str, object]]:
|
|
|
118
127
|
]
|
|
119
128
|
|
|
120
129
|
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
ALL_TRANSCRIPT_SPAWN_TOOL_NAMES = ["Agent", "Task", THREAD_SPAWN_TOOL_NAME, "Workflow", DISPATCH_TOOL_NAME]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize("tool_name", ALL_TRANSCRIPT_SPAWN_TOOL_NAMES)
|
|
134
|
+
def test_should_stay_quiet_after_a_read_a_question_and_a_reply(
|
|
123
135
|
tmp_path: Path, tool_name: str
|
|
124
136
|
) -> None:
|
|
125
137
|
assert _spawn(tmp_path, INTERVIEWED_TRANSCRIPT, tool_name) == ""
|
|
126
138
|
|
|
127
139
|
|
|
128
|
-
def
|
|
140
|
+
def test_should_remind_a_spawn_with_no_read_since_the_request(tmp_path: Path) -> None:
|
|
129
141
|
transcript = [_user("Build the hook."), QUESTION_STEP, REPLY_STEP]
|
|
130
|
-
assert
|
|
142
|
+
assert _reminder(_spawn(tmp_path, transcript)) == MISSING_INVESTIGATION_REASON
|
|
131
143
|
|
|
132
144
|
|
|
133
|
-
def
|
|
145
|
+
def test_should_remind_a_spawn_with_no_question_to_the_user(tmp_path: Path) -> None:
|
|
134
146
|
transcript = [_user("Build the hook."), READ_STEP]
|
|
135
|
-
assert
|
|
147
|
+
assert _reminder(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
|
|
136
148
|
|
|
137
149
|
|
|
138
|
-
def
|
|
150
|
+
def test_should_remind_a_spawn_whose_question_has_no_reply_yet(tmp_path: Path) -> None:
|
|
139
151
|
transcript = [_user("Build the hook."), READ_STEP, QUESTION_STEP]
|
|
140
|
-
assert
|
|
152
|
+
assert _reminder(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
|
|
141
153
|
|
|
142
154
|
|
|
143
155
|
def test_should_name_both_gaps_when_the_session_did_neither(tmp_path: Path) -> None:
|
|
144
|
-
reason =
|
|
156
|
+
reason = _reminder(_spawn(tmp_path, [_user("Build the hook.")]))
|
|
145
157
|
assert reason == f"{MISSING_INVESTIGATION_REASON} {MISSING_INTERVIEW_REASON}"
|
|
146
158
|
|
|
147
159
|
|
|
@@ -150,7 +162,7 @@ def test_should_count_a_plain_chat_question_as_no_interview(
|
|
|
150
162
|
) -> None:
|
|
151
163
|
statement = _tool_use("mcp__hearthbot__post_message", {"text": "Which repo should this land in?"})
|
|
152
164
|
transcript = [_user("Build the hook."), READ_STEP, statement, REPLY_STEP, READ_STEP]
|
|
153
|
-
assert
|
|
165
|
+
assert _reminder(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
|
|
154
166
|
|
|
155
167
|
|
|
156
168
|
def test_should_count_an_answered_ask_user_question_as_the_interview(tmp_path: Path) -> None:
|
|
@@ -182,13 +194,13 @@ def test_should_count_an_answered_decision_card_as_the_interview(tmp_path: Path)
|
|
|
182
194
|
def test_should_skip_an_agent_relay_as_the_reply(tmp_path: Path) -> None:
|
|
183
195
|
relay = _queued('<relay from="coordinator"><note>Keep going.</note></relay>')
|
|
184
196
|
transcript = [_user("Build the hook."), READ_STEP, QUESTION_STEP, relay]
|
|
185
|
-
assert
|
|
197
|
+
assert _reminder(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
|
|
186
198
|
|
|
187
199
|
|
|
188
200
|
def test_should_require_a_new_interview_for_a_follow_up_request(tmp_path: Path) -> None:
|
|
189
201
|
earlier_spawn = _tool_use("Agent", {"prompt": "Build the hook."})
|
|
190
202
|
transcript = [*INTERVIEWED_TRANSCRIPT, earlier_spawn, _user("Now add a second hook."), READ_STEP]
|
|
191
|
-
assert
|
|
203
|
+
assert _reminder(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
|
|
192
204
|
|
|
193
205
|
|
|
194
206
|
def test_should_treat_two_messages_in_a_row_after_a_question_as_one_reply(
|
|
@@ -210,13 +222,13 @@ def test_should_pass_the_interview_on_a_scope_settled_line_and_log_it(tmp_path:
|
|
|
210
222
|
|
|
211
223
|
def test_should_still_require_the_read_with_a_scope_settled_line(tmp_path: Path) -> None:
|
|
212
224
|
brief = f"Fix the typo.\n{SETTLED_LINE}"
|
|
213
|
-
reason =
|
|
225
|
+
reason = _reminder(_spawn(tmp_path, [_user("Fix the typo.")], brief=brief))
|
|
214
226
|
assert reason == MISSING_INVESTIGATION_REASON
|
|
215
227
|
|
|
216
228
|
|
|
217
|
-
def
|
|
229
|
+
def test_should_remind_on_a_scope_settled_line_with_no_reason(tmp_path: Path) -> None:
|
|
218
230
|
transcript = [_user("Fix the typo."), READ_STEP]
|
|
219
|
-
reason =
|
|
231
|
+
reason = _reminder(_spawn(tmp_path, transcript, brief="Fix it.\nScope settled:"))
|
|
220
232
|
assert reason == MISSING_INTERVIEW_REASON
|
|
221
233
|
|
|
222
234
|
|
|
@@ -268,11 +280,91 @@ def test_should_let_the_spawn_run_and_log_when_the_transcript_is_unreadable(
|
|
|
268
280
|
|
|
269
281
|
|
|
270
282
|
def test_should_return_the_scope_settled_line_from_the_brief() -> None:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
spawn_readiness_hook.scope_settled_line(THREAD_SPAWN_TOOL_NAME, tool_input) == SETTLED_LINE
|
|
283
|
+
brief = spawn_readiness_hook.spawn_brief(
|
|
284
|
+
THREAD_SPAWN_TOOL_NAME, {"instructions": f"Do X.\n {SETTLED_LINE}"}
|
|
274
285
|
)
|
|
286
|
+
assert brief is not None
|
|
287
|
+
assert spawn_readiness_hook.scope_settled_line(brief) == SETTLED_LINE
|
|
275
288
|
|
|
276
289
|
|
|
277
290
|
def test_should_ignore_a_tool_it_does_not_check() -> None:
|
|
278
291
|
assert spawn_readiness_hook.decide_hook_output({"tool_name": "Read", "tool_input": {}}) is None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@pytest.mark.parametrize("tool_name", ALL_TRANSCRIPT_SPAWN_TOOL_NAMES)
|
|
295
|
+
def test_should_remind_every_spawn_surface_that_skipped_both_steps(
|
|
296
|
+
tmp_path: Path, tool_name: str
|
|
297
|
+
) -> None:
|
|
298
|
+
reminder = _reminder(_spawn(tmp_path, [_user("Build the hook.")], tool_name))
|
|
299
|
+
assert reminder == f"{MISSING_INVESTIGATION_REASON} {MISSING_INTERVIEW_REASON}"
|
|
300
|
+
[log_record] = _decision_log(tmp_path)
|
|
301
|
+
assert log_record["outcome"] == "reminded"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_should_read_a_workflow_script_from_its_path(tmp_path: Path) -> None:
|
|
305
|
+
script_path = tmp_path / "fan_out.js"
|
|
306
|
+
script_path.write_text(f"const brief = `Fix it.\n{SETTLED_LINE}`\n", encoding="utf-8")
|
|
307
|
+
stdout = _run_hook(
|
|
308
|
+
tmp_path,
|
|
309
|
+
{
|
|
310
|
+
"tool_name": "Workflow",
|
|
311
|
+
"tool_input": {"scriptPath": str(script_path)},
|
|
312
|
+
"transcript_path": str(_write_transcript(tmp_path, [_user("Fix it."), READ_STEP])),
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
assert stdout == ""
|
|
316
|
+
[log_record] = _decision_log(tmp_path)
|
|
317
|
+
assert log_record["outcome"] == "scope_settled"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.mark.parametrize(
|
|
321
|
+
"tool_input",
|
|
322
|
+
[
|
|
323
|
+
{"method": "cancel_workflow_run", "run_id": 7},
|
|
324
|
+
{"method": "run_workflow", "workflow_id": "lint.yml", "inputs": {"ref": "main"}},
|
|
325
|
+
{"method": "run_workflow", "workflow_id": "lint.yml"},
|
|
326
|
+
],
|
|
327
|
+
)
|
|
328
|
+
def test_should_ignore_a_workflow_call_that_starts_no_agent(
|
|
329
|
+
tmp_path: Path, tool_input: dict[str, object]
|
|
330
|
+
) -> None:
|
|
331
|
+
stdout = _run_hook(
|
|
332
|
+
tmp_path,
|
|
333
|
+
{
|
|
334
|
+
"tool_name": DISPATCH_TOOL_NAME,
|
|
335
|
+
"tool_input": tool_input,
|
|
336
|
+
"transcript_path": str(_write_transcript(tmp_path, [_user("Build the hook.")])),
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
assert stdout == ""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.mark.parametrize("tool_name", [CODEX_SPAWN_TOOL_NAME, "Agent"])
|
|
343
|
+
def test_should_give_a_codex_spawn_the_codex_reminder_without_reading_the_transcript(
|
|
344
|
+
tmp_path: Path, tool_name: str
|
|
345
|
+
) -> None:
|
|
346
|
+
stdout = _run_hook(
|
|
347
|
+
tmp_path,
|
|
348
|
+
{
|
|
349
|
+
"tool_name": tool_name,
|
|
350
|
+
"turn_id": "turn_1",
|
|
351
|
+
"tool_input": _spawn_input(tool_name),
|
|
352
|
+
"transcript_path": str(_write_transcript(tmp_path, INTERVIEWED_TRANSCRIPT)),
|
|
353
|
+
},
|
|
354
|
+
)
|
|
355
|
+
assert _reminder(stdout) == CODEX_TRANSCRIPT_REMINDER
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_should_quiet_a_codex_spawn_whose_brief_settles_scope(tmp_path: Path) -> None:
|
|
359
|
+
stdout = _run_hook(
|
|
360
|
+
tmp_path,
|
|
361
|
+
{
|
|
362
|
+
"tool_name": CODEX_SPAWN_TOOL_NAME,
|
|
363
|
+
"turn_id": "turn_1",
|
|
364
|
+
"tool_input": _spawn_input(CODEX_SPAWN_TOOL_NAME, f"Fix it.\n{SETTLED_LINE}"),
|
|
365
|
+
"transcript_path": None,
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
assert stdout == ""
|
|
369
|
+
[log_record] = _decision_log(tmp_path)
|
|
370
|
+
assert log_record["outcome"] == "scope_settled"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
|
+
from hooks_constants.spawn_readiness_hook_constants import MISSING_INVESTIGATION_REASON
|
|
3
4
|
from spawn_readiness_steps import SessionStep, readiness_gaps, session_steps
|
|
4
5
|
|
|
5
6
|
|
|
@@ -81,7 +82,4 @@ def test_should_start_the_span_at_the_request_before_an_answered_question() -> N
|
|
|
81
82
|
SessionStep.QUESTION,
|
|
82
83
|
SessionStep.USER_MESSAGE,
|
|
83
84
|
]
|
|
84
|
-
assert readiness_gaps(all_steps) == [
|
|
85
|
-
"Investigate the request before this spawn. Read the files, threads, or sources it names, "
|
|
86
|
-
"so the brief and the agent count fit the task. Run the reads in a message before the spawn."
|
|
87
|
-
]
|
|
85
|
+
assert readiness_gaps(all_steps) == [MISSING_INVESTIGATION_REASON]
|
package/package.json
CHANGED
|
@@ -8,8 +8,8 @@ against a rejected reading::
|
|
|
8
8
|
"precision matters more than coverage" -> comparative-ranking
|
|
9
9
|
ok: "the function runs more than 30 lines"
|
|
10
10
|
|
|
11
|
-
The rule file ``rules/no-contrast-framing.md``
|
|
12
|
-
|
|
11
|
+
The rule file ``rules/no-contrast-framing.md`` names each form here in
|
|
12
|
+
backticks, and a test holds the two in step.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
@@ -89,7 +89,7 @@ def test_form_named_rejects_an_unknown_name() -> None:
|
|
|
89
89
|
form_named("no-such-form")
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def
|
|
92
|
+
def test_every_form_is_named_in_the_rule_document() -> None:
|
|
93
93
|
rule_text = (PACKAGE_ROOT / CONTRAST_FRAMING_RULE_DOCUMENT).read_text(
|
|
94
94
|
encoding="utf-8"
|
|
95
95
|
)
|