claude-dev-env 8.59.0 → 8.61.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.
@@ -3,8 +3,9 @@ name: orchestrator
3
3
  description: >-
4
4
  Coordinate user goals, parent tasks, workers, evidence, and recovery.
5
5
  Triggers: /orchestrator, orchestrate, operate like a coordinator,
6
- track my goals, coordinate workers, retain goals across compaction.
7
- disable-model-invocation: true
6
+ track my goals, coordinate workers, retain goals across compaction,
7
+ before spawning a subagent, thread, or session, or starting work that
8
+ spans several turns.
8
9
  ---
9
10
 
10
11
  # Orchestrator
@@ -46,6 +47,7 @@ If a run is already active, retain its follow-up entry and answer without replac
46
47
  Create durable run state when work spans turns, has several goals, delegates, or waits on an external result.
47
48
  An explicit advisor-only request can restrict execution to workers for that run.
48
49
  For Claude Projects facts or reported coordinator mechanisms, read [platform evidence](reference/platform-evidence.md).
50
+ Before spawning a subagent, thread, or session, read [oversee delegated work](reference/oversee-delegated-work.md).
49
51
 
50
52
  ## Process
51
53
 
@@ -135,6 +137,7 @@ Report the result, evidence, and any remaining limit.
135
137
  | `.claude/CLAUDE.md` | Claude instruction import. |
136
138
  | `reference/run-state.md` | Goal records, task authority, follow list, and active-root registry. |
137
139
  | `reference/recovery.md` | Cold-start and compaction recovery. |
140
+ | `reference/oversee-delegated-work.md` | Spawner review of delegated work before it reaches the user. |
138
141
  | `reference/platform-evidence.md` | Official Projects sources and coordinator report boundaries. |
139
142
  | `reference/scheduling.md` | Optional existing gate commands and owned wake lifecycle. |
140
143
  | `reference/consult-the-orchestrator.md` | Executor consults and four-signal replies. |
@@ -0,0 +1,12 @@
1
+ # Oversee delegated work
2
+
3
+ Every agent that spawns a subagent, thread, or session owns that work until it reaches the user.
4
+ Agents make mistakes. The spawner is a fresh second set of eyes that catches and corrects them before they reach the user.
5
+ The `spawn_oversight_hook` adds this duty to every spawn, at every level of the tree.
6
+
7
+ - Brief each agent with the user's own words, the standards that apply, and the acceptance check.
8
+ - While it runs, read its progress. Wake a quiet agent with one concrete next step.
9
+ - Before its output reaches the user, check it against the user's words and standards yourself.
10
+ - Send the agent a correction when it misses, and check the corrected output the same way.
11
+ - Give each piece of a multi-piece task its own reviewer or helper.
12
+ - Show the user you are involved: say what you checked and what you corrected.
@@ -22,6 +22,11 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small
22
22
 
23
23
  ### Pull request submission rules
24
24
 
25
+ **Prove the change where it runs before you open the pull request.** The body carries a
26
+ "Proof in practice" section with each command you ran and its quoted output. A hook denies
27
+ a new pull request without one. Read `~/.claude/docs/rule-guides/proof-before-pull-request.md`
28
+ before you plan the proof run.
29
+
25
30
  **Open every pull request ready for review.** Pass `--draft` only when the owner asks
26
31
  for a draft.
27
32
 
@@ -124,9 +124,10 @@ test('seeds one editable policy and one native Codex routing hook', () => {
124
124
  group => group.matcher === 'multi_agent_v1__spawn_agent',
125
125
  );
126
126
  assert.equal(firstRoutingGroups.length, 1);
127
- assert.equal(firstRoutingGroups[0].hooks.length, 2);
127
+ assert.equal(firstRoutingGroups[0].hooks.length, 3);
128
128
  assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
129
129
  assert.match(firstRoutingGroups[0].hooks[1].command, /spawn_readiness_hook\.py/);
130
+ assert.match(firstRoutingGroups[0].hooks[2].command, /spawn_oversight_hook\.py/);
130
131
  const firstSpawnPromptGroups = firstCodexHooks.hooks.PreToolUse.filter(
131
132
  group => group.matcher === 'Agent|Task',
132
133
  );
@@ -65,6 +65,12 @@ export const DECLARED_PROFILE_SETTINGS = Object.freeze([
65
65
  + '(mcp__claude-code-remote__set_session_title or mcp__ccd_session_mgmt__set_session_title) is allowed, '
66
66
  + 'and so is a live test run of the account broker with '
67
67
  + '`python ~/.claude/scripts/account_broker.py run`: the user installed both tools for these sessions',
68
+ 'Moving instruction files out of the always-loaded rules folder of a Claude config directory '
69
+ + '(`~/.claude/rules` or a profile\'s `rules` folder) into a sibling on-demand folder in the same '
70
+ + 'config directory, and moving them back, is allowed when the task is to trim session-start context: '
71
+ + 'each file keeps its contents and stays on disk, and the move reverses with one more move. '
72
+ + 'This covers a plain move or rename with an index file that says when to open each moved file. '
73
+ + 'It does not cover deleting or editing a rule, or any change to settings, hooks or permissions.',
68
74
  ],
69
75
  },
70
76
  ]);
@@ -0,0 +1,37 @@
1
+ Back to the [rule entry](../../rules/proof-before-pull-request.md).
2
+
3
+ # Proof before a pull request
4
+
5
+ **When this applies:** Every session that builds a change and opens a pull request, in every project.
6
+
7
+ ## Rule
8
+
9
+ Before you open a pull request, prove the change does its job where it will run.
10
+
11
+ What counts as proof:
12
+ - You ran the changed thing the way its user runs it, and you saw the effect it exists to produce. A hook fires in a live Claude Code session and changes what that session does. A script runs from its launcher on inputs like the ones it will meet. A skill or rule changes what a fresh agent does with an ordinary request.
13
+ - You saw the failure first. Run the same scenario without your change and record what goes wrong. The proof is the difference between the two runs.
14
+ - You covered the conditions the change will meet. That includes the case it should leave alone, and for session behavior a fresh session, a resumed session, a later turn, and a session after compaction.
15
+ - The session under test got an ordinary request, worded as a task the way Jon would ask for it.
16
+
17
+ Unit tests are welcome as extra evidence. The live run is the proof. A test you edited until it passed proves nothing.
18
+
19
+ For a change to Claude Code behavior, such as a hook, skill, rule, setting, prompt or output style:
20
+ - Spawn every session through the account broker, with the stream saved to a file:
21
+ python ~/.claude/scripts/account_broker.py run --product claude --report <broker-report.json> -- claude -p "<request>" --model claude-opus-5-5 --effort <level> --output-format stream-json --verbose > <stream.jsonl>
22
+ - Run each later turn the same way with `--resume <session id>`. The broker resumes a session on the account that started it when that account has room.
23
+ - Keep `--model` and `--effort` on every call. Without them the headless run uses claude-sonnet-5-5. Confirm the served model in the result's `modelUsage`.
24
+ - Start the broker from your own shell, with your environment as is. The broker reads the account meters with your session's credentials and starts Claude without your session's variables.
25
+ - Leave out `--bare` and `claude plugin eval`. Both drop the hooks and settings the user runs with.
26
+ - Read the broker report for the account it chose. Read the stream for the hook events and their output, `permission_denials`, the final result, and the token usage on the result event.
27
+ - When the broker exits 3 with action "wait", no account has room. Treat that as a blocked proof and report its reason and `resets_at`. Spawn Claude only through the broker.
28
+
29
+ Report it in the pull request body under "Proof in practice". List each command you ran, quote the output lines that show the effect, state the difference between the runs with and without the change, and name anything still unproven.
30
+
31
+ When you cannot prove it, leave the pull request unopened. Tell Jon in three short sentences what you could not run and which tool would make it possible. A blocked proof is an acceptable result, because it shows where tooling is missing.
32
+
33
+ ## Enforcement
34
+
35
+ `hooks/blocking/pull_request_proof.py` reads the body of each new pull request before it opens. `pr_lifecycle_skill_gate.py` runs it on `gh pr create`, the `pull_request.py create` script, and each `mcp__*__create_pull_request` tool. The gate denies the call when the body has no "Proof in practice" heading, or when the text under that heading names no command in backticks. A `gh pr create` with no `--body` or `--body-file` is denied with a request for the body. Edits, merges, and pushes pass.
36
+
37
+ The gate checks that the section is there and names a command. Whether the run proves the change stays a judgment for the author and the reviewer.
@@ -79,7 +79,12 @@ def _option_value(all_arguments: list[str], all_option_names: frozenset[str]) ->
79
79
  return None
80
80
 
81
81
 
82
- def _body_from_arguments(all_arguments: list[str], working_directory: str) -> str | None:
82
+ def body_from_arguments(all_arguments: list[str], working_directory: str) -> str | None:
83
+ """Return the body a gh-style argument list passes, or None when it passes none.
84
+
85
+ ``--body`` text comes back as given. A ``--body-file`` path is read from
86
+ the working directory. Standard input and an unreadable file give None.
87
+ """
83
88
  inline_body = _option_value(all_arguments, ALL_BODY_OPTIONS)
84
89
  if inline_body is not None:
85
90
  return inline_body
@@ -125,7 +130,7 @@ def _segment_draft(all_segment_tokens: list[str], working_directory: str) -> Pul
125
130
  program, all_arguments = segment_program_and_arguments(all_segment_tokens)
126
131
  if program != "gh" or all_arguments[: len(ALL_GH_CREATE_WORDS)] != ALL_GH_CREATE_WORDS:
127
132
  return None
128
- body = _body_from_arguments(all_arguments, working_directory)
133
+ body = body_from_arguments(all_arguments, working_directory)
129
134
  repository = _repository_from_arguments(all_arguments, working_directory)
130
135
  if body is None or repository is None:
131
136
  return None
@@ -53,6 +53,7 @@ from hooks_constants.hook_specific_output_keys import (
53
53
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
54
54
  from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
55
55
  from blocking.followup_pr_dedupe import duplicate_followup_reason
56
+ from blocking.pull_request_proof import missing_proof_reason
56
57
  from transcript_skill_scan import is_skill_loaded_after_last_compaction
57
58
 
58
59
 
@@ -198,7 +199,7 @@ def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | No
198
199
 
199
200
 
200
201
  def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
201
- """Return a deny for an unloaded skill or a second follow-up pull request for one parent.
202
+ """Return a deny for an unloaded skill, a new pull request with no proof, or a second follow-up.
202
203
 
203
204
  Args:
204
205
  all_payload_fields: The parsed PreToolUse input.
@@ -207,6 +208,9 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
207
208
  return None
208
209
  if _is_skill_unloaded(all_payload_fields):
209
210
  return _deny(DENY_REASON)
211
+ proof_reason = missing_proof_reason(all_payload_fields)
212
+ if proof_reason is not None:
213
+ return _deny(proof_reason)
210
214
  duplicate_reason = duplicate_followup_reason(all_payload_fields)
211
215
  return None if duplicate_reason is None else _deny(duplicate_reason)
212
216
 
@@ -0,0 +1,141 @@
1
+ """Deny a new pull request whose body carries no 'Proof in practice' section."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+
10
+ hooks_directory = str(Path(__file__).resolve().parent.parent)
11
+ if hooks_directory not in sys.path:
12
+ sys.path.insert(0, hooks_directory)
13
+
14
+ from blocking.followup_pr_dedupe import body_from_arguments
15
+ from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
16
+ from hooks_constants.pull_request_proof_constants import (
17
+ ALL_GH_CREATE_WORDS,
18
+ ALL_PYTHON_PROGRAM_NAMES,
19
+ COMMAND_MARKER,
20
+ CREATE_PULL_REQUEST_TOOL_SUFFIX,
21
+ LINE_SEPARATOR,
22
+ MISSING_BODY_REASON,
23
+ MISSING_PROOF_REASON,
24
+ NEXT_HEADING_PATTERN,
25
+ PROOF_HEADING_PATTERN,
26
+ PULL_REQUEST_SCRIPT_CREATE_WORD,
27
+ PULL_REQUEST_SCRIPT_NAME,
28
+ )
29
+ from hooks_constants.pytest_invocation import unquoted_token
30
+ from hooks_constants.shell_command_pipeline import pipeline_segments_for_command
31
+ from hooks_constants.shell_command_segments import token_basename
32
+ from hooks_constants.shell_command_wrappers import (
33
+ all_wrapped_command_texts,
34
+ segment_program_and_arguments,
35
+ )
36
+
37
+
38
+ def _heading_level(line: str, heading_pattern: str) -> int | None:
39
+ heading = re.match(heading_pattern, line.strip(), re.IGNORECASE)
40
+ return None if heading is None else len(heading.group(1))
41
+
42
+
43
+ def _lines_until_heading(all_lines: list[str], heading_level: int) -> list[str]:
44
+ for each_index, each_line in enumerate(all_lines):
45
+ next_level = _heading_level(each_line, NEXT_HEADING_PATTERN)
46
+ if next_level is not None and next_level <= heading_level:
47
+ return all_lines[:each_index]
48
+ return all_lines
49
+
50
+
51
+ def proof_section(body: str) -> str | None:
52
+ """Return the text under the body's 'Proof in practice' heading, or None.
53
+
54
+ ::
55
+
56
+ "## Proof in practice\\nran `x`\\n## Notes" -> "ran `x`"
57
+ "## Summary\\nno proof heading" -> None
58
+
59
+ The section runs to the next heading of the same or a higher level.
60
+ """
61
+ all_lines = body.splitlines()
62
+ for each_index, each_line in enumerate(all_lines):
63
+ heading_level = _heading_level(each_line, PROOF_HEADING_PATTERN)
64
+ if heading_level is not None:
65
+ all_section_lines = _lines_until_heading(all_lines[each_index + 1 :], heading_level)
66
+ return LINE_SEPARATOR.join(all_section_lines).strip()
67
+ return None
68
+
69
+
70
+ def _script_arguments(program: str, all_arguments: list[str]) -> list[str] | None:
71
+ if program == PULL_REQUEST_SCRIPT_NAME:
72
+ return all_arguments
73
+ if program not in ALL_PYTHON_PROGRAM_NAMES or not all_arguments:
74
+ return None
75
+ script_name = token_basename(unquoted_token(all_arguments[0]))
76
+ return all_arguments[1:] if script_name == PULL_REQUEST_SCRIPT_NAME else None
77
+
78
+
79
+ def _script_create_arguments(program: str, all_arguments: list[str]) -> list[str] | None:
80
+ all_script_arguments = _script_arguments(program, all_arguments)
81
+ if all_script_arguments is None or all_script_arguments[:1] != [PULL_REQUEST_SCRIPT_CREATE_WORD]:
82
+ return None
83
+ return all_script_arguments[1:]
84
+
85
+
86
+ def _create_arguments(all_segment_tokens: list[str]) -> list[str] | None:
87
+ program, all_arguments = segment_program_and_arguments(all_segment_tokens)
88
+ if program == "gh" and all_arguments[: len(ALL_GH_CREATE_WORDS)] == ALL_GH_CREATE_WORDS:
89
+ return all_arguments[len(ALL_GH_CREATE_WORDS) :]
90
+ return _script_create_arguments(program, all_arguments)
91
+
92
+
93
+ def _shell_create_bodies(command: str, working_directory: str) -> list[str | None]:
94
+ return [
95
+ body_from_arguments(all_create_arguments, working_directory)
96
+ for each_text in all_wrapped_command_texts(command)
97
+ for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
98
+ if (all_create_arguments := _create_arguments(each_segment)) is not None
99
+ ]
100
+
101
+
102
+ def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
103
+ tool_name = all_payload_fields.get("tool_name")
104
+ tool_input = all_payload_fields.get("tool_input")
105
+ if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
106
+ return []
107
+ if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
108
+ body = tool_input.get("body")
109
+ return [body if isinstance(body, str) else ""]
110
+ command = tool_input.get("command")
111
+ if tool_name not in SHELL_TOOL_NAMES or not isinstance(command, str):
112
+ return []
113
+ working_directory = all_payload_fields.get("cwd")
114
+ return _shell_create_bodies(
115
+ command, working_directory if isinstance(working_directory, str) else ""
116
+ )
117
+
118
+
119
+ def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None:
120
+ """Return a deny reason when a new pull request's body has no proof, else None.
121
+
122
+ ::
123
+
124
+ gh pr create --body-file body.md, body has the section -> None
125
+ mcp__github__create_pull_request, body without it -> MISSING_PROOF_REASON
126
+ gh pr create --fill -> MISSING_BODY_REASON
127
+ gh pr edit, git push, any other tool -> None
128
+
129
+ The proof section is a 'Proof in practice' heading whose text names at
130
+ least one command in backticks.
131
+
132
+ Args:
133
+ all_payload_fields: The parsed PreToolUse input.
134
+ """
135
+ for each_body in _create_bodies(all_payload_fields):
136
+ if each_body is None:
137
+ return MISSING_BODY_REASON
138
+ section = proof_section(each_body)
139
+ if not section or COMMAND_MARKER not in section:
140
+ return MISSING_PROOF_REASON
141
+ return None
@@ -84,3 +84,11 @@ def test_powershell_body_file_is_denied(tmp_path: Path) -> None:
84
84
  }
85
85
  reason = dedupe.duplicate_followup_reason(payload, _reader([OPEN_FOLLOWUP]))
86
86
  assert reason is not None and "pull request #1769" in reason
87
+
88
+
89
+ def test_body_from_arguments_reads_inline_text_and_body_files(tmp_path: Path) -> None:
90
+ (tmp_path / "body.md").write_text("From the file.", encoding="utf-8")
91
+ assert dedupe.body_from_arguments(["--body", "Inline."], "") == "Inline."
92
+ assert dedupe.body_from_arguments(["--body-file", "body.md"], str(tmp_path)) == "From the file."
93
+ assert dedupe.body_from_arguments(["--body-file", "-"], str(tmp_path)) is None
94
+ assert dedupe.body_from_arguments(["--fill"], str(tmp_path)) is None
@@ -13,6 +13,9 @@ if str(HOOKS_DIRECTORY) not in sys.path:
13
13
  from blocking import followup_pr_dedupe as gate_dedupe
14
14
  from blocking import pr_lifecycle_skill_gate as gate
15
15
  from hooks_constants.pr_lifecycle_skill_gate_constants import DENY_REASON
16
+ from hooks_constants.pull_request_proof_constants import MISSING_PROOF_REASON
17
+
18
+ PROVEN_FOLLOWUP_BODY = "Follow-up to #1731\n\n## Proof in practice\nRan `python probe.py`.\n"
16
19
 
17
20
 
18
21
  def _transcript(tmp_path: Path, skill_name: str | None = None, compact: bool = False) -> Path:
@@ -61,7 +64,7 @@ def test_unloaded_pull_request_command_is_denied(tmp_path: Path) -> None:
61
64
 
62
65
  def test_loaded_skill_allows_action_silently(tmp_path: Path) -> None:
63
66
  path = _transcript(tmp_path, "plugin:pr-lifecycle")
64
- assert _run_main(_payload("gh pr create", path)) == (0, "")
67
+ assert _run_main(_payload("gh pr view", path)) == (0, "")
65
68
 
66
69
 
67
70
  def test_user_slash_command_loads_skill(tmp_path: Path) -> None:
@@ -127,7 +130,7 @@ def test_agent_id_with_path_separator_is_not_followed(tmp_path: Path) -> None:
127
130
 
128
131
 
129
132
  def test_unreadable_or_missing_transcript_allows_silently(tmp_path: Path) -> None:
130
- payload = _payload("gh pr create", tmp_path / "missing.jsonl")
133
+ payload = _payload("gh pr view", tmp_path / "missing.jsonl")
131
134
  assert _run_main(payload) == (0, "")
132
135
  payload.pop("transcript_path")
133
136
  assert _run_main(payload) == (0, "")
@@ -237,7 +240,7 @@ def test_hook_has_its_own_pre_tool_use_registration() -> None:
237
240
  def test_loaded_skill_still_denies_a_second_followup_for_one_parent(tmp_path: Path) -> None:
238
241
  payload = {
239
242
  "tool_name": "mcp__github__create_pull_request",
240
- "tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": "Follow-up to #1731"},
243
+ "tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": PROVEN_FOLLOWUP_BODY},
241
244
  "transcript_path": str(_transcript(tmp_path, "pr-lifecycle")),
242
245
  }
243
246
  open_followup = {"number": 1769, "html_url": "https://github.com/jl-cmd/claude-dev-env/pull/1769", "body": "Follow-up to #1731"}
@@ -251,8 +254,14 @@ def test_loaded_skill_still_denies_a_second_followup_for_one_parent(tmp_path: Pa
251
254
  def test_loaded_skill_allows_a_followup_when_the_read_fails(tmp_path: Path) -> None:
252
255
  payload = {
253
256
  "tool_name": "mcp__github__create_pull_request",
254
- "tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": "Follow-up to #1731"},
257
+ "tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": PROVEN_FOLLOWUP_BODY},
255
258
  "transcript_path": str(_transcript(tmp_path, "pr-lifecycle")),
256
259
  }
257
260
  with patch.object(gate_dedupe, "read_open_pull_requests", side_effect=OSError("offline")):
258
261
  assert gate.decision_for(payload) is None
262
+
263
+
264
+ def test_loaded_skill_still_denies_a_new_pull_request_without_proof(tmp_path: Path) -> None:
265
+ exit_code, output = _run_main(_payload("gh pr create --body 'Adds a gate.'", _transcript(tmp_path, "pr-lifecycle")))
266
+ assert exit_code == 0
267
+ assert json.loads(output)["hookSpecificOutput"]["permissionDecisionReason"] == MISSING_PROOF_REASON
@@ -0,0 +1,92 @@
1
+ """Behavior tests for the proof-in-practice check on a new pull request."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
7
+ if str(HOOKS_DIRECTORY) not in sys.path:
8
+ sys.path.insert(0, str(HOOKS_DIRECTORY))
9
+
10
+ from blocking import pull_request_proof as gate
11
+ from hooks_constants.pull_request_proof_constants import MISSING_BODY_REASON, MISSING_PROOF_REASON
12
+
13
+ PROVEN_BODY = "## Summary\nAdds a gate.\n\n## Proof in practice\nRan `python run.py`.\n> denied\n"
14
+ UNPROVEN_BODY = "## Summary\nAdds a gate.\n\n## Verification\nUnit tests pass.\n"
15
+
16
+
17
+ def _shell(command: str, working_directory: Path | None = None) -> dict[str, object]:
18
+ return {
19
+ "tool_name": "Bash",
20
+ "tool_input": {"command": command},
21
+ "cwd": str(working_directory or ""),
22
+ }
23
+
24
+
25
+ def _mcp_create(body: object) -> dict[str, object]:
26
+ return {
27
+ "tool_name": "mcp__github__create_pull_request",
28
+ "tool_input": {"owner": "o", "repo": "r", "title": "t", "body": body},
29
+ }
30
+
31
+
32
+ def test_proof_section_should_stop_at_a_heading_of_the_same_level() -> None:
33
+ body = "### Proof in practice\nran `a`\n#### Child\nkept\n### Local checks\ndropped"
34
+ assert gate.proof_section(body) == "ran `a`\n#### Child\nkept"
35
+
36
+
37
+ def test_proof_section_should_return_none_without_the_heading() -> None:
38
+ assert gate.proof_section(UNPROVEN_BODY) is None
39
+
40
+
41
+ def test_mcp_create_without_proof_should_be_denied() -> None:
42
+ assert gate.missing_proof_reason(_mcp_create(UNPROVEN_BODY)) == MISSING_PROOF_REASON
43
+
44
+
45
+ def test_mcp_create_with_no_body_should_be_denied() -> None:
46
+ assert gate.missing_proof_reason(_mcp_create(None)) == MISSING_PROOF_REASON
47
+
48
+
49
+ def test_mcp_create_with_proof_should_pass() -> None:
50
+ assert gate.missing_proof_reason(_mcp_create(PROVEN_BODY)) is None
51
+
52
+
53
+ def test_heading_with_no_command_should_be_denied() -> None:
54
+ body = "## Proof in practice\nIt works.\n"
55
+ assert gate.missing_proof_reason(_mcp_create(body)) == MISSING_PROOF_REASON
56
+
57
+
58
+ def test_gh_create_body_file_with_proof_should_pass(tmp_path: Path) -> None:
59
+ (tmp_path / "body.md").write_text(PROVEN_BODY, encoding="utf-8")
60
+ payload = _shell("gh pr create --draft --body-file body.md", tmp_path)
61
+ assert gate.missing_proof_reason(payload) is None
62
+
63
+
64
+ def test_gh_create_body_file_without_proof_should_be_denied(tmp_path: Path) -> None:
65
+ (tmp_path / "body.md").write_text(UNPROVEN_BODY, encoding="utf-8")
66
+ payload = _shell("gh pr create -F body.md", tmp_path)
67
+ assert gate.missing_proof_reason(payload) == MISSING_PROOF_REASON
68
+
69
+
70
+ def test_gh_create_with_fill_should_ask_for_a_body() -> None:
71
+ assert gate.missing_proof_reason(_shell("gh pr create --fill")) == MISSING_BODY_REASON
72
+
73
+
74
+ def test_wrapped_gh_create_should_be_read() -> None:
75
+ payload = _shell("bash -c 'gh pr create --body \"no proof\"'")
76
+ assert gate.missing_proof_reason(payload) == MISSING_PROOF_REASON
77
+
78
+
79
+ def test_pull_request_script_create_should_be_read(tmp_path: Path) -> None:
80
+ (tmp_path / "body.md").write_text(UNPROVEN_BODY, encoding="utf-8")
81
+ payload = _shell("python3 scripts/pull_request.py create --body-file body.md", tmp_path)
82
+ assert gate.missing_proof_reason(payload) == MISSING_PROOF_REASON
83
+
84
+
85
+ def test_other_pull_request_commands_should_pass() -> None:
86
+ for each_command in ("gh pr edit 3 --body x", "git push -u origin b", "gh pr view"):
87
+ assert gate.missing_proof_reason(_shell(each_command)) is None
88
+
89
+
90
+ def test_other_tools_should_pass() -> None:
91
+ payload = {"tool_name": "mcp__github__update_pull_request", "tool_input": {"body": "x"}}
92
+ assert gate.missing_proof_reason(payload) is None
@@ -1,10 +1,11 @@
1
1
  # Spawn routing
2
2
 
3
- This family reminds the agent to gather context before a spawn, adjusts thread requests when usage exceeds pace, and selects an allowed subagent model.
3
+ This family reminds the agent to gather context before a spawn, tells it that it owns the spawned work, adjusts thread requests when usage exceeds pace, and selects an allowed subagent model.
4
4
 
5
5
  ## Checks
6
6
 
7
7
  - `routing/spawn_readiness_hook.py` adds context to a spawn that comes before a read step, or before an answered question or a settled-scope line. The spawn still runs.
8
+ - `routing/spawn_oversight_hook.py` adds the orchestrator oversight directive to every spawn: watch the agent, check its output against the user's words and standards, and correct it before the output reaches the user. The spawn still runs.
8
9
  - `routing/thread_spawn_pace_hook.py` reshapes a thread request when usage is over pace or unreadable.
9
10
  - `routing/subagent_model_pin_hook.py` moves every Agent and Task subagent to Opus. A spawn that names Opus or Fable runs unchanged.
10
11
  - `routing/subagent_model_routing.mjs` allows a selected Luna model, remaps eligible requests, and denies unsupported model routing.
@@ -12,6 +13,7 @@ This family reminds the agent to gather context before a spawn, adjusts thread r
12
13
  ## When it fires
13
14
 
14
15
  - `routing/spawn_readiness_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, and `mcp__hearthbot__start_thread_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
16
+ - `routing/spawn_oversight_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, `mcp__hearthbot__start_thread_session`, and `mcp__hearthbot__start_rc_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
15
17
  - `routing/thread_spawn_pace_hook.py` runs on `PreToolUse`, matcher `mcp__hearthbot__start_thread_session`, timeout `30` seconds in `hooks.json`.
16
18
  - `routing/subagent_model_pin_hook.py` runs on `PreToolUse`, matcher `Agent|Task`, timeout `10` seconds in `hooks.json`.
17
19
  - `routing/subagent_model_routing.mjs` runs on `PreToolUse`, matcher `multi_agent_v1__spawn_agent`, timeout `10` seconds through the quoted Node command in `hooks.json`.
@@ -23,6 +25,7 @@ Preconditions:
23
25
  - Run each command from the repository root. Python and pytest cover the two Python scripts; Node covers the model router.
24
26
 
25
27
  - **Spawn readiness.** Input is a spawn after a read, a question, and its answer. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_readiness_hook.py -q`. The adjacent test observes no output for a ready spawn and reminder context for each missing step.
28
+ - **Spawn oversight.** Input is a spawn from each registered tool, a subagent's own spawn, and a non-spawn call. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_oversight_hook.py -q`. The adjacent test observes the directive for every spawn and no output for the other calls.
26
29
  - **Thread pace.** Input is a thread spawn with usage over pace. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_thread_spawn_pace_hook.py -q`. The adjacent test observes updated tool input with the selected model, effort, and advisor line.
27
30
  - **Model pin.** Input is an Agent spawn that names a Sonnet model. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_subagent_model_pin_hook.py -q`. The adjacent test observes updated tool input with model `opus`.
28
31
  - **Model routing.** Input is a `multi_agent_v1__spawn_agent` payload with a model choice. Run `node --test packages/claude-dev-env/hooks/routing/subagent_model_routing.test.mjs`. The adjacent test observes an allow or deny decision with the routed model.
@@ -30,5 +33,6 @@ Preconditions:
30
33
  ## Gotchas
31
34
 
32
35
  - `routing/spawn_readiness_hook.py` also runs after the pace hook on thread spawns. A reshaped request still gets the readiness check.
36
+ - The oversight hook fires inside subagents too, so each level of the tree oversees the level below it. It emits `additionalContext` only and never blocks.
33
37
  - The readiness hook emits `additionalContext` only, so it never blocks. It passes subagent calls, read-only helper types, a non-object tool input, and unreadable transcripts.
34
38
  - The model router allows an advisor flag to bypass the Luna restriction. Its ordinary path denies unresolved or unsupported models.
package/hooks/hooks.json CHANGED
@@ -19,6 +19,11 @@
19
19
  "type": "command",
20
20
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/subagent_model_pin_hook.py",
21
21
  "timeout": 10
22
+ },
23
+ {
24
+ "type": "command",
25
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
26
+ "timeout": 10
22
27
  }
23
28
  ]
24
29
  },
@@ -34,6 +39,11 @@
34
39
  "type": "command",
35
40
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
36
41
  "timeout": 10
42
+ },
43
+ {
44
+ "type": "command",
45
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
46
+ "timeout": 10
37
47
  }
38
48
  ]
39
49
  },
@@ -44,6 +54,11 @@
44
54
  "type": "command",
45
55
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
46
56
  "timeout": 10
57
+ },
58
+ {
59
+ "type": "command",
60
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
61
+ "timeout": 10
47
62
  }
48
63
  ]
49
64
  },
@@ -159,6 +174,21 @@
159
174
  "type": "command",
160
175
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
161
176
  "timeout": 10
177
+ },
178
+ {
179
+ "type": "command",
180
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
181
+ "timeout": 10
182
+ }
183
+ ]
184
+ },
185
+ {
186
+ "matcher": "mcp__hearthbot__start_rc_session",
187
+ "hooks": [
188
+ {
189
+ "type": "command",
190
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
191
+ "timeout": 10
162
192
  }
163
193
  ]
164
194
  },
@@ -0,0 +1,38 @@
1
+ """Shared main loop for PreToolUse hooks that only add context.
2
+
3
+ Reads the payload from stdin, asks the hook's decide function for an output,
4
+ and writes it as JSON. Exit status is always 0, so the tool call runs; a
5
+ decide function that raises leaves the call with no output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from collections.abc import Callable
13
+
14
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
15
+
16
+
17
+ def run_context_hook(
18
+ decide_hook_output: Callable[[dict[str, object]], dict[str, object] | None],
19
+ ) -> int:
20
+ """Print the decided output for the stdin payload, or nothing.
21
+
22
+ Args:
23
+ decide_hook_output: Maps the parsed payload to an output or None.
24
+
25
+ Returns:
26
+ 0 in every case; the context travels in the JSON output.
27
+ """
28
+ hook_payload = read_hook_input_dictionary_from_stdin()
29
+ if hook_payload is None:
30
+ return 0
31
+ try:
32
+ hook_output = decide_hook_output(hook_payload)
33
+ except Exception:
34
+ return 0
35
+ if hook_output is not None:
36
+ sys.stdout.write(json.dumps(hook_output))
37
+ sys.stdout.flush()
38
+ return 0
@@ -0,0 +1,27 @@
1
+ """Constants for the proof-in-practice check on a new pull request."""
2
+
3
+ PROOF_HEADING_PATTERN = r"^(#{1,6})[ \t]+proof in practice[ \t]*#*[ \t]*$"
4
+ NEXT_HEADING_PATTERN = r"^(#{1,6})[ \t]+\S"
5
+ COMMAND_MARKER = "`"
6
+ LINE_SEPARATOR = "\n"
7
+ ALL_GH_CREATE_WORDS = ["pr", "create"]
8
+ PULL_REQUEST_SCRIPT_NAME = "pull_request.py"
9
+ PULL_REQUEST_SCRIPT_CREATE_WORD = "create"
10
+ ALL_PYTHON_PROGRAM_NAMES = frozenset({"python", "python3", "py"})
11
+ CREATE_PULL_REQUEST_TOOL_SUFFIX = "__create_pull_request"
12
+ PROOF_GUIDE_PATH = "~/.claude/docs/rule-guides/proof-before-pull-request.md"
13
+ MISSING_BODY_REASON = (
14
+ "Pass the pull request body with --body or --body-file so this gate can read its"
15
+ " 'Proof in practice' section. Read " + PROOF_GUIDE_PATH + " for what counts as proof."
16
+ )
17
+ MISSING_PROOF_REASON = (
18
+ "This pull request body has no 'Proof in practice' section with the commands you ran."
19
+ " Before you open a pull request, run the changed thing the way its user runs it,"
20
+ " run the same scenario without the change, and quote the output lines that show"
21
+ " the difference. For a hook, skill, rule, setting, prompt or output style, spawn"
22
+ " each test session through the account broker. Then add a 'Proof in practice'"
23
+ " heading to the body that lists each command in backticks, quotes the output, states"
24
+ " the difference between the runs, and names anything still unproven. When you cannot"
25
+ " prove the change, leave the pull request unopened and tell the user what you could"
26
+ " not run. Read " + PROOF_GUIDE_PATH + " for the full rule."
27
+ )
@@ -0,0 +1,40 @@
1
+ """Constants for the spawn oversight PreToolUse hook.
2
+
3
+ Groups: the spawn tools it answers, the workflow dispatch that carries an
4
+ agent prompt, the payload keys it reads, and the
5
+ oversight directive it adds to every spawn.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ TOOL_NAME_KEY = "tool_name"
11
+ TOOL_INPUT_KEY = "tool_input"
12
+ PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
13
+ ADDITIONAL_CONTEXT_KEY = "additionalContext"
14
+
15
+ WORKFLOW_DISPATCH_TOOL_NAME = "mcp__github__actions_run_trigger"
16
+ DISPATCH_METHOD_INPUT_KEY = "method"
17
+ DISPATCH_RUN_WORKFLOW_METHOD = "run_workflow"
18
+ DISPATCH_INPUTS_KEY = "inputs"
19
+ DISPATCH_PROMPT_INPUT_KEY = "prompt"
20
+
21
+ ALL_SPAWN_TOOL_NAMES = frozenset(
22
+ {
23
+ "Agent",
24
+ "Task",
25
+ "Workflow",
26
+ "multi_agent_v1__spawn_agent",
27
+ "mcp__hearthbot__start_thread_session",
28
+ "mcp__hearthbot__start_rc_session",
29
+ }
30
+ )
31
+
32
+ SPAWN_OVERSIGHT_DIRECTIVE = (
33
+ "You own the work this spawn starts. Load the orchestrator skill and follow "
34
+ "its oversee delegated work reference. While the agent runs, read its progress "
35
+ "and wake it with one next step when it goes quiet. Before its output "
36
+ "reaches the user, check it against the user's own words and standards, and "
37
+ "send the agent a correction when it misses. Give each piece of a "
38
+ "multi-piece task its own reviewer or helper. Tell the user you are "
39
+ "checking the work."
40
+ )
@@ -0,0 +1,47 @@
1
+ import io
2
+ import json
3
+ import sys
4
+
5
+ import pytest
6
+
7
+ from hooks_constants.pre_tool_use_context_runner import run_context_hook
8
+
9
+
10
+ def _feed_stdin(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
11
+ monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(text.encode("utf-8"))))
12
+
13
+
14
+ def test_prints_the_decided_output(
15
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
16
+ ) -> None:
17
+ _feed_stdin(monkeypatch, json.dumps({"tool_name": "Agent"}))
18
+ exit_code = run_context_hook(lambda payload: {"seen": payload["tool_name"]})
19
+ assert exit_code == 0
20
+ assert json.loads(capsys.readouterr().out) == {"seen": "Agent"}
21
+
22
+
23
+ def test_prints_nothing_when_the_decision_is_none(
24
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
25
+ ) -> None:
26
+ _feed_stdin(monkeypatch, json.dumps({"tool_name": "Bash"}))
27
+ assert run_context_hook(lambda payload: None) == 0
28
+ assert capsys.readouterr().out == ""
29
+
30
+
31
+ def test_prints_nothing_for_empty_stdin(
32
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
33
+ ) -> None:
34
+ _feed_stdin(monkeypatch, "")
35
+ assert run_context_hook(lambda payload: {"never": True}) == 0
36
+ assert capsys.readouterr().out == ""
37
+
38
+
39
+ def test_prints_nothing_and_exits_zero_when_the_decision_raises(
40
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
41
+ ) -> None:
42
+ def _raise(payload: dict[str, object]) -> dict[str, object] | None:
43
+ raise KeyError(payload["missing"])
44
+
45
+ _feed_stdin(monkeypatch, json.dumps({"tool_name": "Agent"}))
46
+ assert run_context_hook(_raise) == 0
47
+ assert capsys.readouterr().out == ""
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: tell the spawning session it owns the work it delegates.
3
+
4
+ Registered on every tool that starts another agent: ``Agent``, ``Task``,
5
+ ``Workflow``, ``multi_agent_v1__spawn_agent``,
6
+ ``mcp__hearthbot__start_thread_session``, and
7
+ ``mcp__hearthbot__start_rc_session``, plus a workflow dispatch whose inputs
8
+ carry a ``prompt`` for an agent. Each spawn gets the oversight directive
9
+ as ``additionalContext``, so the spawn runs and keeps its normal permission
10
+ flow. A subagent that spawns its own agents gets the same directive, so every
11
+ level of the tree oversees the level below it.
12
+
13
+ ::
14
+
15
+ Agent {"prompt": "Do X."} -> additionalContext: the directive
16
+ actions trigger {"method": "run_workflow",
17
+ "inputs": {"prompt": "Do X."}}
18
+ -> additionalContext: the directive
19
+ actions trigger {"method": "cancel_workflow_run"}
20
+ -> no output
21
+ Bash {"command": "ls"} -> no output
22
+ Agent "not an object" -> no output
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
31
+ if hooks_root_directory not in sys.path:
32
+ sys.path.insert(0, hooks_root_directory)
33
+
34
+ from hooks_constants.hook_specific_output_keys import (
35
+ HOOK_EVENT_NAME_KEY,
36
+ HOOK_SPECIFIC_OUTPUT_KEY,
37
+ )
38
+ from hooks_constants.pre_tool_use_context_runner import run_context_hook
39
+ from hooks_constants.spawn_oversight_hook_constants import (
40
+ ADDITIONAL_CONTEXT_KEY,
41
+ ALL_SPAWN_TOOL_NAMES,
42
+ DISPATCH_INPUTS_KEY,
43
+ DISPATCH_METHOD_INPUT_KEY,
44
+ DISPATCH_PROMPT_INPUT_KEY,
45
+ DISPATCH_RUN_WORKFLOW_METHOD,
46
+ PRE_TOOL_USE_EVENT_NAME,
47
+ SPAWN_OVERSIGHT_DIRECTIVE,
48
+ TOOL_INPUT_KEY,
49
+ TOOL_NAME_KEY,
50
+ WORKFLOW_DISPATCH_TOOL_NAME,
51
+ )
52
+
53
+
54
+ def starts_an_agent(tool_name: object, all_tool_input_fields: dict[str, object]) -> bool:
55
+ """Return True when the call starts an agent: a spawn tool or a prompt dispatch.
56
+
57
+ Args:
58
+ tool_name: The tool the session called.
59
+ all_tool_input_fields: The tool input.
60
+ """
61
+ if tool_name in ALL_SPAWN_TOOL_NAMES:
62
+ return True
63
+ if tool_name != WORKFLOW_DISPATCH_TOOL_NAME:
64
+ return False
65
+ if all_tool_input_fields.get(DISPATCH_METHOD_INPUT_KEY) != DISPATCH_RUN_WORKFLOW_METHOD:
66
+ return False
67
+ workflow_inputs = all_tool_input_fields.get(DISPATCH_INPUTS_KEY)
68
+ return isinstance(workflow_inputs, dict) and isinstance(
69
+ workflow_inputs.get(DISPATCH_PROMPT_INPUT_KEY), str
70
+ )
71
+
72
+
73
+ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object] | None:
74
+ """Return the oversight directive for a spawn call, else None.
75
+
76
+ Args:
77
+ all_hook_fields: The parsed PreToolUse payload.
78
+ """
79
+ all_tool_input_fields = all_hook_fields.get(TOOL_INPUT_KEY)
80
+ if not isinstance(all_tool_input_fields, dict):
81
+ return None
82
+ if not starts_an_agent(all_hook_fields.get(TOOL_NAME_KEY), all_tool_input_fields):
83
+ return None
84
+ return {
85
+ HOOK_SPECIFIC_OUTPUT_KEY: {
86
+ HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
87
+ ADDITIONAL_CONTEXT_KEY: SPAWN_OVERSIGHT_DIRECTIVE,
88
+ }
89
+ }
90
+
91
+
92
+ def main() -> int:
93
+ """Run the hook on the stdin payload; always 0."""
94
+ return run_context_hook(decide_hook_output)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ sys.exit(main())
@@ -35,7 +35,7 @@ routing_directory = str(Path(__file__).resolve().parent)
35
35
  if routing_directory not in sys.path:
36
36
  sys.path.insert(0, routing_directory)
37
37
 
38
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
38
+ from hooks_constants.pre_tool_use_context_runner import run_context_hook
39
39
  from hooks_constants.spawn_readiness_hook_constants import (
40
40
  ADDITIONAL_CONTEXT_KEY,
41
41
  AGENT_ID_KEY,
@@ -250,19 +250,8 @@ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object]
250
250
 
251
251
 
252
252
  def main() -> int:
253
- """Read the PreToolUse payload and print the reminder when the spawn is not ready.
254
-
255
- Returns:
256
- 0 in every case; the reminder travels in the JSON output.
257
- """
258
- hook_payload = read_hook_input_dictionary_from_stdin()
259
- if hook_payload is None:
260
- return 0
261
- hook_output = decide_hook_output(hook_payload)
262
- if hook_output is not None:
263
- sys.stdout.write(json.dumps(hook_output))
264
- sys.stdout.flush()
265
- return 0
253
+ """Run the hook on the stdin payload; always 0."""
254
+ return run_context_hook(decide_hook_output)
266
255
 
267
256
 
268
257
  if __name__ == "__main__":
@@ -0,0 +1,104 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ import spawn_oversight_hook
9
+ from hooks_constants.spawn_oversight_hook_constants import SPAWN_OVERSIGHT_DIRECTIVE
10
+
11
+ HOOK_SCRIPT = Path(__file__).resolve().parent / "spawn_oversight_hook.py"
12
+
13
+
14
+ def _expected_output() -> dict[str, object]:
15
+ return {
16
+ "hookSpecificOutput": {
17
+ "hookEventName": "PreToolUse",
18
+ "additionalContext": SPAWN_OVERSIGHT_DIRECTIVE,
19
+ }
20
+ }
21
+
22
+
23
+ @pytest.mark.parametrize(
24
+ "tool_name",
25
+ [
26
+ "Agent",
27
+ "Task",
28
+ "Workflow",
29
+ "multi_agent_v1__spawn_agent",
30
+ "mcp__hearthbot__start_thread_session",
31
+ "mcp__hearthbot__start_rc_session",
32
+ ],
33
+ )
34
+ def test_every_spawn_tool_gets_the_oversight_directive(tool_name: str) -> None:
35
+ payload = {"tool_name": tool_name, "tool_input": {"prompt": "Do X."}}
36
+ assert spawn_oversight_hook.decide_hook_output(payload) == _expected_output()
37
+
38
+
39
+ def test_a_subagent_spawning_its_own_agent_gets_the_directive() -> None:
40
+ payload = {
41
+ "tool_name": "Agent",
42
+ "tool_input": {"prompt": "Do X."},
43
+ "agent_id": "agent_child",
44
+ }
45
+ assert spawn_oversight_hook.decide_hook_output(payload) == _expected_output()
46
+
47
+
48
+ def test_a_read_only_helper_spawn_gets_the_directive() -> None:
49
+ payload = {
50
+ "tool_name": "Agent",
51
+ "tool_input": {"subagent_type": "Explore", "prompt": "Find X."},
52
+ }
53
+ assert spawn_oversight_hook.decide_hook_output(payload) == _expected_output()
54
+
55
+
56
+ def test_a_workflow_dispatch_with_an_agent_prompt_gets_the_directive() -> None:
57
+ payload = {
58
+ "tool_name": "mcp__github__actions_run_trigger",
59
+ "tool_input": {"method": "run_workflow", "inputs": {"prompt": "Do X."}},
60
+ }
61
+ assert spawn_oversight_hook.decide_hook_output(payload) == _expected_output()
62
+
63
+
64
+ @pytest.mark.parametrize(
65
+ "payload",
66
+ [
67
+ {
68
+ "tool_name": "mcp__github__actions_run_trigger",
69
+ "tool_input": {"method": "cancel_workflow_run", "run_id": 1},
70
+ },
71
+ {
72
+ "tool_name": "mcp__github__actions_run_trigger",
73
+ "tool_input": {"method": "run_workflow", "inputs": {"head": "branch"}},
74
+ },
75
+ {"tool_name": "Bash", "tool_input": {"command": "ls"}},
76
+ {"tool_name": "mcp__hearthbot__reply", "tool_input": {"text": "hi"}},
77
+ {"tool_name": "Agent", "tool_input": "not an object"},
78
+ {"tool_input": {"prompt": "Do X."}},
79
+ ],
80
+ )
81
+ def test_other_calls_get_no_output(payload: dict[str, object]) -> None:
82
+ assert spawn_oversight_hook.decide_hook_output(payload) is None
83
+
84
+
85
+ def test_script_prints_the_directive_for_a_spawn() -> None:
86
+ completed = subprocess.run(
87
+ [sys.executable, str(HOOK_SCRIPT)],
88
+ input=json.dumps({"tool_name": "Agent", "tool_input": {"prompt": "Do X."}}),
89
+ capture_output=True,
90
+ text=True,
91
+ check=True,
92
+ )
93
+ assert json.loads(completed.stdout) == _expected_output()
94
+
95
+
96
+ def test_script_prints_nothing_for_empty_stdin() -> None:
97
+ completed = subprocess.run(
98
+ [sys.executable, str(HOOK_SCRIPT)],
99
+ input="",
100
+ capture_output=True,
101
+ text=True,
102
+ check=True,
103
+ )
104
+ assert completed.stdout == ""
@@ -368,3 +368,15 @@ def test_should_quiet_a_codex_spawn_whose_brief_settles_scope(tmp_path: Path) ->
368
368
  assert stdout == ""
369
369
  [log_record] = _decision_log(tmp_path)
370
370
  assert log_record["outcome"] == "scope_settled"
371
+
372
+
373
+ def test_should_print_nothing_for_empty_stdin(tmp_path: Path) -> None:
374
+ completed = subprocess.run(
375
+ [sys.executable, str(HOOK_SCRIPT)],
376
+ input="",
377
+ capture_output=True,
378
+ text=True,
379
+ env={**os.environ, "HOME": str(tmp_path)},
380
+ check=True,
381
+ )
382
+ assert completed.stdout == ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.59.0",
3
+ "version": "8.61.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,7 @@
1
+ # Proof before a pull request
2
+
3
+ **When:** Before you open a pull request.
4
+
5
+ Run the change where it runs, with and without it, and quote both results under "Proof in practice" in the body. A hook denies a body without one. Unproven: leave it unopened and tell the user.
6
+
7
+ **Full text:** [`docs/rule-guides/proof-before-pull-request.md`](../docs/rule-guides/proof-before-pull-request.md).
@@ -1,12 +1,9 @@
1
1
  # Present questions clearly
2
2
 
3
- Before asking for input, give a short formatted brief in chat. Put the context and the effect of each choice there. Add a visual or artifact only when it helps the decision.
3
+ Before asking, give a short formatted chat brief with context and each choice's effect. Add helpful visuals or artifacts.
4
4
 
5
- For Codex, when a visual comparison helps, discover callable tools for Page creation, visualization upload, and Page opening. Create the comparison, upload its visualization, and open its Page in the current task before asking. If a tool is unavailable, creation, upload, or opening fails, or opening queues, put the comparison in chat before asking. Inline Visualize references appear only in final replies. Use the user's submitted answer for the decision. An async dispatch acknowledgement or saved visual supplies context only. Wait for the answer before work that depends on it.
5
+ For Codex visual comparisons, find Page creation, visualization upload, and Page opening tools. Create the comparison, upload its visualization, and open its Page here before asking. If tools are missing, steps fail, or opening queues, compare in chat before asking. Put inline Visualize references only in final replies. An async acknowledgement or saved visual is context only; wait for and use the submitted answer before dependent work.
6
6
 
7
- Ask one short, self-contained question with two or three clear choices. Use short labels, and keep each supported choice description to one short sentence. Use the same labels in the chat brief, comparison, and picker.
7
+ Ask one short, self-contained question with two or three choices. Give each a short label and one short sentence. Reuse labels in the brief, comparison, and picker.
8
8
 
9
- Discover the current session's native question tool and its schema. Claude may expose `AskUserQuestion`.
10
- Codex may expose `request_user_input_async` or `request_user_input`. Follow the tool's mode restrictions.
11
- Put the question in its declared title or question field and choices in its declared string or object fields.
12
- When no native tool is usable, ask the short question and choices in chat.
9
+ Find the native question tool and schema. Claude may use `AskUserQuestion`; Codex may use `request_user_input_async` or `request_user_input`. Honor mode rules. Put the question in its title or question field and choices in string or object fields. If unusable, ask in chat.
@@ -18,6 +18,7 @@ ALWAYS_ON_RULE_NAMES = frozenset(
18
18
  "filesystem-search.md",
19
19
  "memory-stores-durable-facts.md",
20
20
  "no-contrast-framing.md",
21
+ "proof-before-pull-request.md",
21
22
  "question-presentation.md",
22
23
  "research-mode.md",
23
24
  "request-scope.md",