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.
@@ -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, 1);
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 a row in the table
58
- above for every form the list carries.
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 deny messages and log fields.
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
- PERMISSION_DECISION_KEY = "permissionDecision"
84
- PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
85
- PERMISSION_DENY = "deny"
94
+ ADDITIONAL_CONTEXT_KEY = "additionalContext"
86
95
  MISSING_INVESTIGATION_REASON = (
87
- "Investigate the request before this spawn. Read the files, threads, or "
88
- "sources it names, so the brief and the agent count fit the task. Run the "
89
- "reads in a message before the spawn."
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
- "Interview the user before this spawn. Ask your scope, requirements, and "
93
- "goals questions through AskUserQuestion, a decision card, or an "
94
- "interactive widget, and spawn after the answer. When the request "
95
- "already settles scope, add a brief line that starts with "
96
- f'"{SCOPE_SETTLED_PREFIX}" and names the reason.'
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: hold an agent spawn until the session has read and asked.
2
+ """PreToolUse hook: remind a session to read and ask before it spawns agents.
3
3
 
4
- Registered on ``Agent``, ``Task``, and ``mcp__hearthbot__start_thread_session``.
5
- It reads the session transcript and checks two things since the user's request:
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 -> deny: investigate first
10
- no interactive question, then an answer -> deny: interview first
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; the call runs
14
+ both found -> no output
15
+ Codex payload (turn_id), no settled line -> context: the Codex reminder
13
16
 
14
- ``spawn_readiness_steps`` reads the transcript and finds the request. Four
15
- cases pass without a check: a call from inside a subagent, a read-only
16
- subagent type, a tool input that is not an object, and a transcript the hook
17
- cannot read. The last one is logged.
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 scope_settled_line(tool_name: str, all_tool_input_fields: dict[str, object]) -> str | None:
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
- tool_name: The spawn tool, which decides the brief field.
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 _deny_output(reason: str) -> dict[str, object]:
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
- PERMISSION_DECISION_KEY: PERMISSION_DENY,
133
- PERMISSION_DECISION_REASON_KEY: reason,
192
+ ADDITIONAL_CONTEXT_KEY: context,
134
193
  }
135
194
  }
136
195
 
137
196
 
138
- def _is_checked_spawn(all_hook_fields: dict[str, object]) -> bool:
139
- tool_input = all_hook_fields.get(TOOL_INPUT_KEY)
140
- return (
141
- all_hook_fields.get(TOOL_NAME_KEY) in ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME
142
- and isinstance(tool_input, dict)
143
- and not all_hook_fields.get(AGENT_ID_KEY)
144
- and tool_input.get(SUBAGENT_TYPE_INPUT_KEY) not in ALL_READ_ONLY_SUBAGENT_TYPES
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 let the call run, else the deny JSON output.
226
+ None to stay quiet, else the reminder as additionalContext output.
156
227
  """
157
- if not _is_checked_spawn(all_hook_fields):
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
- all_transcript_lines = _transcript_lines(all_hook_fields.get(TRANSCRIPT_PATH_KEY))
163
- if all_transcript_lines is None:
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 = readiness_gaps(session_steps(all_transcript_lines, tool_use_id))
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
- deny_reason = REASON_SEPARATOR.join(all_gaps)
174
- log_hook_block(Path(__file__).name, PRE_TOOL_USE_EVENT_NAME, deny_reason, tool_name=tool_name)
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 a deny when the spawn is not ready.
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; a deny travels in the JSON output.
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 _deny_reason(stdout: str) -> str:
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
- assert decision["permissionDecision"] == "deny"
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
- @pytest.mark.parametrize("tool_name", ["Agent", "Task", THREAD_SPAWN_TOOL_NAME])
122
- def test_should_let_the_spawn_run_after_a_read_a_question_and_a_reply(
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 test_should_deny_a_spawn_with_no_read_since_the_request(tmp_path: Path) -> None:
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 _deny_reason(_spawn(tmp_path, transcript)) == MISSING_INVESTIGATION_REASON
142
+ assert _reminder(_spawn(tmp_path, transcript)) == MISSING_INVESTIGATION_REASON
131
143
 
132
144
 
133
- def test_should_deny_a_spawn_with_no_question_to_the_user(tmp_path: Path) -> None:
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 _deny_reason(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
147
+ assert _reminder(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
136
148
 
137
149
 
138
- def test_should_deny_a_spawn_whose_question_has_no_reply_yet(tmp_path: Path) -> None:
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 _deny_reason(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
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 = _deny_reason(_spawn(tmp_path, [_user("Build the hook.")]))
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 _deny_reason(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
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 _deny_reason(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
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 _deny_reason(_spawn(tmp_path, transcript)) == MISSING_INTERVIEW_REASON
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 = _deny_reason(_spawn(tmp_path, [_user("Fix the typo.")], brief=brief))
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 test_should_deny_a_scope_settled_line_with_no_reason(tmp_path: Path) -> None:
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 = _deny_reason(_spawn(tmp_path, transcript, brief="Fix it.\nScope settled:"))
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
- tool_input = {"instructions": f"Do X.\n {SETTLED_LINE}"}
272
- assert (
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.41.0",
3
+ "version": "8.42.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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`` carries one row for each name
12
- here, and a test holds the two in step.
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 test_every_form_carries_a_row_in_the_rule_document() -> None:
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
  )