claude-dev-env 8.69.0 → 8.69.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.
Files changed (82) hide show
  1. package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
  2. package/.agents/skills/second-account-workers/SKILL.md +21 -3
  3. package/docs/rule-guides/features-start-with-an-eval.md +40 -0
  4. package/hooks/blocking/followup_pr_dedupe.py +55 -5
  5. package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
  6. package/hooks/blocking/pull_request_proof.py +86 -20
  7. package/hooks/blocking/test_artifact_look_gate.py +1 -0
  8. package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
  9. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
  10. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
  11. package/hooks/blocking/test_pull_request_proof.py +124 -1
  12. package/hooks/features/conduct-gates.md +1 -1
  13. package/hooks/features/write-edit-blocking.md +3 -5
  14. package/hooks/hooks.json +1 -1
  15. package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
  16. package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
  17. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
  18. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
  19. package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
  20. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
  21. package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
  22. package/hooks/test_transcript_skill_scan.py +75 -1
  23. package/hooks/transcript_skill_scan.py +87 -15
  24. package/package.json +1 -1
  25. package/rules/anti-corollary-tests.md +2 -2
  26. package/rules/archiving-agent-config.md +2 -2
  27. package/rules/asd-ste100-language.md +2 -2
  28. package/rules/bdd.md +1 -1
  29. package/rules/build-needs-a-user.md +1 -1
  30. package/rules/cleanup-temp-files.md +3 -3
  31. package/rules/code-standards.md +1 -1
  32. package/rules/correction-lens.md +3 -3
  33. package/rules/destructive-commands.md +3 -3
  34. package/rules/doc-inventory-integrity.md +1 -1
  35. package/rules/docstring-prose-matches-implementation.md +1 -1
  36. package/rules/explore-thoroughly.md +1 -1
  37. package/rules/failure-blast-radius.md +1 -1
  38. package/rules/falsify-before-green.md +2 -2
  39. package/rules/features-start-with-an-eval.md +9 -0
  40. package/rules/filesystem-search.md +3 -3
  41. package/rules/flag-non-breaking-findings.md +2 -2
  42. package/rules/long-horizon-autonomy.md +2 -2
  43. package/rules/memory-stores-durable-facts.md +2 -2
  44. package/rules/no-contrast-framing.md +1 -1
  45. package/rules/no-cross-skill-duplicate-helpers.md +1 -1
  46. package/rules/orphan-css-class.md +1 -1
  47. package/rules/paired-test-coverage.md +1 -1
  48. package/rules/plain-illustrative-docstrings.md +1 -1
  49. package/rules/prefer-existing-tools.md +2 -2
  50. package/rules/proof-before-pull-request.md +1 -1
  51. package/rules/request-scope.md +1 -1
  52. package/rules/research-mode.md +2 -2
  53. package/rules/retired-hook-prose.md +1 -1
  54. package/rules/session-title.md +1 -1
  55. package/rules/shell-invocation.md +2 -2
  56. package/rules/testing.md +1 -1
  57. package/rules/verify-before-asking.md +3 -3
  58. package/rules/verify-runtime-state.md +2 -2
  59. package/rules/windows-filesystem-safe.md +1 -1
  60. package/rules/workers-done-before-complete.md +2 -2
  61. package/rules/workflow-substitution-slots.md +1 -1
  62. package/scripts/account_broker.py +10 -3
  63. package/scripts/account_broker_support.py +7 -3
  64. package/scripts/claude_account_worker.py +24 -5
  65. package/scripts/claude_account_worker_process.py +5 -0
  66. package/scripts/claude_account_worker_report.py +16 -1
  67. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
  68. package/scripts/test_account_broker.py +38 -0
  69. package/scripts/test_account_broker_support.py +24 -0
  70. package/scripts/test_claude_account_worker.py +47 -0
  71. package/scripts/test_claude_account_worker_process.py +20 -0
  72. package/scripts/test_claude_account_worker_report.py +15 -0
  73. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
  74. package/scripts/tests/test_rule_load_scopes.py +1 -0
  75. package/hooks/advisory/conftest.py +0 -10
  76. package/hooks/advisory/refactor_guard.py +0 -385
  77. package/hooks/advisory/refactor_guard_test_support.py +0 -46
  78. package/hooks/advisory/test_refactor_guard.py +0 -148
  79. package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
  80. package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
  81. package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
  82. package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
@@ -25,7 +25,9 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small
25
25
  **Prove the change where it runs before you open the pull request.** The body carries a
26
26
  "Proof in practice" section with each command you ran and its quoted output. A hook denies
27
27
  a new pull request without one. Read `~/.claude/docs/rule-guides/proof-before-pull-request.md`
28
- before you plan the proof run.
28
+ before you plan the proof run. A `feat` pull request also needs an "Eval" section and a
29
+ session that invoked `/claude-api build-eval`; read
30
+ `~/.claude/docs/rule-guides/features-start-with-an-eval.md` before you build the feature.
29
31
 
30
32
  **Open every pull request ready for review.** Pass `--draft` only when the owner asks
31
33
  for a draft.
@@ -3,7 +3,8 @@ name: second-account-workers
3
3
  description: >-
4
4
  Run local headless Claude workers through the account picker.
5
5
  Triggers: spawn a worker on the second account or another extra account, offload a worker,
6
- use a named Claude profile, save main account usage.
6
+ use a named Claude profile, save main account usage, run a local orchestrator that spawns
7
+ and watches Claude workers.
7
8
  ---
8
9
 
9
10
  # Extra account workers
@@ -22,8 +23,9 @@ JSON list of profile names, with the first choice first. Names use letters,
22
23
  digits, hyphens, and underscores. The worker uses the existing second profile
23
24
  when the file is absent. When the file exists, its list sets the full order.
24
25
  Keep `main` and `wait` out of the list because they name picker decisions.
25
- The picker ranks main and every extra profile by room left and picks the one
26
- with the most.
26
+ The picker keeps each account that has room and gives the job to the first one
27
+ listed in `claude-account-order.json` in the main Claude home. Unlisted accounts
28
+ follow, by most room left.
27
29
  For direct picker calls, `--second-config-dir` sets the first extra profile and
28
30
  each `--extra-config-dir` adds another in the order given.
29
31
  Picker JSON prints `config_dir` for the choice and meters under `main`, `second`,
@@ -47,6 +49,13 @@ worker_exit=$?
47
49
  cat "<report>"
48
50
  ```
49
51
 
52
+ Add `--live-log "<events.jsonl>"` to watch the worker while it runs. The worker
53
+ then writes one JSON event per line into that file as it happens. Every event
54
+ carries the `session_id`, from the first startup hook line on. Each `assistant` line names the tool
55
+ calls and text, and the last line is the `result` event. Read new lines with
56
+ `tail -n 20 "<events.jsonl>"` and skip screenshots unless a check needs to see
57
+ the screen.
58
+
50
59
  Read the JSON report and worker log after the process ends. The JSON fields are
51
60
  `account`, `reason`, `exit_code`, `duration_seconds`, `result`, and `is_error`.
52
61
  The runner writes one summary line with the selected account, exit code, and
@@ -83,3 +92,12 @@ Blocked: <open issue, or none>
83
92
 
84
93
  Keep the owned file list closed. Name the worktree path with `--cwd` and use a
85
94
  separate brief and report path for each worker.
95
+
96
+ ## Run a local orchestrator
97
+
98
+ To save usage in a long session, start one local orchestrator worker and let it
99
+ spawn the other workers. Give it its own brief that names each worker brief,
100
+ worktree, report path and live log. It launches each worker with the command
101
+ above in the background, reads the live logs to follow progress, and reads each
102
+ report at the end. Each spawn goes through the picker, so every worker lands on
103
+ the account with the most weekly room at its start.
@@ -0,0 +1,40 @@
1
+ Back to the [rule entry](../../rules/features-start-with-an-eval.md).
2
+
3
+ # Features start with an eval
4
+
5
+ **When this applies:** A user asks for a new feature or a change in behavior, in any project.
6
+
7
+ ## What counts
8
+
9
+ A feature is behavior a user or a caller can see that did not exist before, or that now works differently: a command, a flag, a page, an endpoint, a hook, a rule, a skill, a report, or an integration. A bug fix that restores intended behavior, a rename, a refactor, a docs edit, a question, a test run, and speed work that leaves the output the same are not features.
10
+
11
+ An eval is three things for one feature: a set of input cases with their expected results, a runner that drives the feature through the entry point its users call, and a grader that scores each output. A test suite counts when it does all three for that feature.
12
+
13
+ ## Rule
14
+
15
+ 1. Make the Skill tool call with skill `claude-api` and args `build-eval` your first action on the ask. A user who types `/claude-api build-eval` has made the same call.
16
+ 2. Run the existing-work search from [`explore-thoroughly.md`](../../rules/explore-thoroughly.md) next. The guide's own first step also looks for cases, graders, and runners that exist.
17
+ 3. Follow the guide the skill loads. It asks the user to approve the inputs and the grading method, and to approve the cost before the first paid run. Ask through the session's question tool. Where the host says to keep working while a question waits, continue on the option you recommended and change course if the answer differs.
18
+ 4. Run the eval before the change to get a baseline, build the feature, and run it again.
19
+ 5. Give the pull request an "Eval" section. Name the cases and the grader, give the command that runs the eval in backticks, and quote the baseline and the result.
20
+
21
+ ## An existing feature with no eval
22
+
23
+ When your work touches an existing feature, look for its eval. When it has none, start a separate session that builds it, and keep doing your own work while that session runs. Your own feature still starts with its own eval.
24
+
25
+ - In a Claude project thread, call `send_message` with the coordinator's session id from `get_channel_session_id`, and ask it to start a thread for the eval. A thread session has no `start_thread_session`, so the coordinator starts that thread.
26
+ - In a project's coordinator session, call `start_thread_session` with the brief.
27
+ - In a command-line session, start a headless session through the account broker: `python ~/.claude/scripts/account_broker.py run --product claude --report <report.json> -- claude -p "<brief>" --model <model> --effort <level>`.
28
+
29
+ The brief names the feature, its entry point, and the first step, the `claude-api` skill with args `build-eval`. An Agent-tool subagent is not a separate session. It shares your context window and ends with your turn.
30
+
31
+ You own that session's result. Check its pull request, and link it from your own pull request's "Eval" section.
32
+
33
+ ## Enforcement
34
+
35
+ `hooks/blocking/pull_request_proof.py` reads each new pull request before it opens, through `pr_lifecycle_skill_gate.py`. A new pull request is a `gh pr create` command, a GitHub MCP create call, or a GitHub MCP `run_workflow` dispatch whose inputs name a `head` branch and a `title`, because that workflow opens the pull request. A pull request whose title starts with `feat` (`feat:`, `feat(scope):`, `feat!:`) is denied when:
36
+
37
+ - its body has no "Eval" heading, or the text under that heading names no command in backticks;
38
+ - the session's transcript is readable and shows no `claude-api` Skill call with args `build-eval`, and no `/claude-api build-eval` command.
39
+
40
+ The gate checks that the section and the call are there. Whether the eval measures the feature stays a judgment for the author and the reviewer. The order of the first action and the separate session for an existing feature are not checked by a hook, so the agent applies them.
@@ -19,16 +19,20 @@ from hooks_constants.followup_pr_dedupe_constants import (
19
19
  ALL_BODY_FILE_OPTIONS,
20
20
  ALL_BODY_OPTIONS,
21
21
  ALL_REPOSITORY_OPTIONS,
22
+ ALL_TITLE_OPTIONS,
22
23
  CREATE_PULL_REQUEST_TOOL_SUFFIX,
23
24
  DUPLICATE_FOLLOWUP_REASON_TEMPLATE,
24
25
  FOLLOWUP_PARENT_PATTERN,
25
26
  ALL_GH_CREATE_WORDS,
26
27
  ALL_GIT_REMOTE_COMMAND_WORDS,
28
+ ALL_PULL_REQUEST_DISPATCH_INPUT_KEYS,
27
29
  GITHUB_API_ROOT,
28
30
  GITHUB_REMOTE_PATTERN,
29
31
  OPEN_PULL_REQUESTS_PATH_TEMPLATE,
30
32
  READ_TIMEOUT_SECONDS,
33
+ RUN_WORKFLOW_METHOD,
31
34
  STANDARD_INPUT_PATH,
35
+ WORKFLOW_DISPATCH_TOOL_SUFFIX,
32
36
  )
33
37
  from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
34
38
  from hooks_constants.pytest_invocation import unquoted_token
@@ -99,6 +103,18 @@ def body_from_arguments(all_arguments: list[str], working_directory: str) -> str
99
103
  return None
100
104
 
101
105
 
106
+ def title_from_arguments(all_arguments: list[str]) -> str | None:
107
+ """Return the title a gh-style argument list passes, or None when it passes none.
108
+
109
+ ::
110
+
111
+ ["--title", "feat: x"] -> "feat: x"
112
+ ["-t=fix: y"] -> "fix: y"
113
+ ["--fill"] -> None
114
+ """
115
+ return _option_value(all_arguments, ALL_TITLE_OPTIONS)
116
+
117
+
102
118
  def _origin_repository(working_directory: str) -> tuple[str, str] | None:
103
119
  try:
104
120
  completed = subprocess.run(
@@ -146,19 +162,53 @@ def _shell_draft(command: str, working_directory: str) -> PullRequestDraft | Non
146
162
  return next((each_draft for each_draft in all_drafts if each_draft is not None), None)
147
163
 
148
164
 
149
- def pull_request_draft(all_payload_fields: Mapping[str, object]) -> PullRequestDraft | None:
150
- """Return the pull request a PreToolUse payload is about to open, or None."""
165
+ def _dispatch_fields(all_tool_input_fields: Mapping[str, object]) -> dict[str, object] | None:
166
+ all_inputs = all_tool_input_fields.get("inputs")
167
+ if all_tool_input_fields.get("method") != RUN_WORKFLOW_METHOD or not isinstance(all_inputs, dict):
168
+ return None
169
+ if not all(isinstance(all_inputs.get(each_key), str) for each_key in ALL_PULL_REQUEST_DISPATCH_INPUT_KEYS):
170
+ return None
171
+ return {**all_inputs, "owner": all_tool_input_fields.get("owner"), "repo": all_tool_input_fields.get("repo")}
172
+
173
+
174
+ def create_tool_fields(all_payload_fields: Mapping[str, object]) -> Mapping[str, object] | None:
175
+ """Return the fields a GitHub MCP call that opens a pull request carries, or None.
176
+
177
+ ::
178
+
179
+ create_pull_request {owner, repo, title, body} -> those fields
180
+ actions_run_trigger run_workflow, head and title in -> the inputs, owner and repo
181
+ actions_run_trigger whose inputs name no head -> None
182
+
183
+ A dispatch whose inputs name a head branch and a title runs a workflow
184
+ that opens the pull request, so it counts as a create call.
185
+
186
+ Args:
187
+ all_payload_fields: The parsed PreToolUse input.
188
+
189
+ Returns:
190
+ The owner, repo, title, body and head keys the call names, or None.
191
+ """
151
192
  tool_name = all_payload_fields.get("tool_name")
152
193
  tool_input = all_payload_fields.get("tool_input")
153
194
  if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
154
195
  return None
155
196
  if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
156
- owner, repo, body = (tool_input.get(each_key) for each_key in ("owner", "repo", "body"))
197
+ return tool_input
198
+ return _dispatch_fields(tool_input) if tool_name.endswith(WORKFLOW_DISPATCH_TOOL_SUFFIX) else None
199
+
200
+
201
+ def pull_request_draft(all_payload_fields: Mapping[str, object]) -> PullRequestDraft | None:
202
+ """Return the pull request a PreToolUse payload is about to open, or None."""
203
+ create_fields = create_tool_fields(all_payload_fields)
204
+ if create_fields is not None:
205
+ owner, repo, body = (create_fields.get(each_key) for each_key in ("owner", "repo", "body"))
157
206
  if isinstance(owner, str) and isinstance(repo, str) and isinstance(body, str):
158
207
  return PullRequestDraft(owner, repo, body)
159
208
  return None
160
- command = tool_input.get("command")
161
- if tool_name not in SHELL_TOOL_NAMES or not isinstance(command, str):
209
+ tool_input = all_payload_fields.get("tool_input")
210
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
211
+ if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
162
212
  return None
163
213
  working_directory = all_payload_fields.get("cwd")
164
214
  return _shell_draft(command, working_directory if isinstance(working_directory, str) else "")
@@ -6,6 +6,7 @@ import json
6
6
  import re
7
7
  import shlex
8
8
  import sys
9
+ from collections.abc import Callable, Iterable
9
10
  from pathlib import Path
10
11
 
11
12
  hooks_directory = str(Path(__file__).resolve().parent.parent)
@@ -15,6 +16,7 @@ if hooks_directory not in sys.path:
15
16
  from hooks_constants.pr_lifecycle_skill_gate_constants import (
16
17
  AGENT_ID_FIELD,
17
18
  AGENT_ID_PATTERN,
19
+ ALL_BUILD_EVAL_SKILL_NAMES,
18
20
  ALL_API_ACTION_NAMES,
19
21
  ALL_COMMAND_PREFIX_WORDS,
20
22
  ALL_GITHUB_MCP_TOOL_SUFFIXES,
@@ -23,6 +25,8 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
23
25
  ALL_SHELL_WRAPPER_EXECUTABLES,
24
26
  ALL_SLASH_COMMAND_MARKERS,
25
27
  ALL_TRANSCRIPT_PATH_FIELDS,
28
+ BUILD_EVAL_ARGUMENT_WORD,
29
+ BUILD_EVAL_COMMAND_MARKER,
26
30
  COMMAND_SEPARATORS,
27
31
  COMMAND_WHITESPACE,
28
32
  DENY_DECISION,
@@ -52,13 +56,19 @@ from hooks_constants.hook_specific_output_keys import (
52
56
  )
53
57
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
54
58
  from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
55
- from blocking.followup_pr_dedupe import duplicate_followup_reason
59
+ from blocking.followup_pr_dedupe import create_tool_fields, duplicate_followup_reason
56
60
  from blocking.pull_request_proof import (
61
+ is_feature_pull_request,
62
+ missing_eval_section_reason,
57
63
  missing_existing_work_reason,
58
64
  missing_look_reason,
59
65
  missing_proof_reason,
60
66
  )
61
- from transcript_skill_scan import is_skill_loaded_after_last_compaction
67
+ from hooks_constants.pull_request_proof_constants import MISSING_BUILD_EVAL_REASON
68
+ from transcript_skill_scan import (
69
+ invokes_skill_with_argument,
70
+ is_skill_loaded_after_last_compaction,
71
+ )
62
72
 
63
73
 
64
74
  def _command_segments(command: str) -> list[list[str]]:
@@ -169,7 +179,7 @@ def _is_governed_action(all_payload_fields: dict[str, object]) -> bool:
169
179
  tool_name = all_payload_fields.get("tool_name")
170
180
  if not isinstance(tool_name, str):
171
181
  return False
172
- if tool_name.endswith(ALL_GITHUB_MCP_TOOL_SUFFIXES):
182
+ if tool_name.endswith(ALL_GITHUB_MCP_TOOL_SUFFIXES) or create_tool_fields(all_payload_fields) is not None:
173
183
  return True
174
184
  if tool_name not in SHELL_TOOL_NAMES:
175
185
  return False
@@ -177,16 +187,29 @@ def _is_governed_action(all_payload_fields: dict[str, object]) -> bool:
177
187
  return isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str) and _matches_shell_command(tool_input["command"])
178
188
 
179
189
 
180
- def _transcript_load_status(path: str) -> bool | None:
190
+ def _scanned_transcript(path: str, scan: Callable[[Iterable[str]], bool]) -> bool | None:
181
191
  try:
182
192
  with open(path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
183
- return is_skill_loaded_after_last_compaction(
184
- transcript, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
185
- )
193
+ return scan(transcript)
186
194
  except OSError:
187
195
  return None
188
196
 
189
197
 
198
+ def _shows_skill_load(all_transcript_lines: Iterable[str]) -> bool:
199
+ return is_skill_loaded_after_last_compaction(
200
+ all_transcript_lines, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
201
+ )
202
+
203
+
204
+ def _shows_build_eval(all_transcript_lines: Iterable[str]) -> bool:
205
+ return invokes_skill_with_argument(
206
+ all_transcript_lines,
207
+ ALL_BUILD_EVAL_SKILL_NAMES,
208
+ BUILD_EVAL_ARGUMENT_WORD,
209
+ BUILD_EVAL_COMMAND_MARKER,
210
+ )
211
+
212
+
190
213
  def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | None:
191
214
  session_path = all_payload_fields.get(SESSION_TRANSCRIPT_PATH_FIELD)
192
215
  agent_id = all_payload_fields.get(AGENT_ID_FIELD)
@@ -203,7 +226,17 @@ def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | No
203
226
 
204
227
 
205
228
  def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
206
- """Return a deny for an unloaded skill, a new pull request with no proof or existing-work section, or a second follow-up.
229
+ """Return a deny for the first rule a pull request action breaks, else None.
230
+
231
+ ::
232
+
233
+ pr-lifecycle skill not loaded -> DENY_REASON
234
+ new pull request with no proof section -> MISSING_PROOF_REASON
235
+ new pull request with no existing-work section -> MISSING_EXISTING_WORK_REASON
236
+ feature pull request with no eval section -> MISSING_EVAL_SECTION_REASON
237
+ changed page whose proof shows no picture -> MISSING_LOOK_REASON
238
+ feature pull request, no /claude-api build-eval -> MISSING_BUILD_EVAL_REASON
239
+ second follow-up pull request for one parent -> DUPLICATE_FOLLOWUP_REASON_TEMPLATE
207
240
 
208
241
  Args:
209
242
  all_payload_fields: The parsed PreToolUse input.
@@ -218,23 +251,41 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
218
251
  search_reason = missing_existing_work_reason(all_payload_fields)
219
252
  if search_reason is not None:
220
253
  return _deny(search_reason)
254
+ eval_reason = missing_eval_section_reason(all_payload_fields)
255
+ if eval_reason is not None:
256
+ return _deny(eval_reason)
221
257
  look_reason = missing_look_reason(all_payload_fields)
222
258
  if look_reason is not None:
223
259
  return _deny(look_reason)
260
+ if is_feature_pull_request(all_payload_fields) and _is_build_eval_missing(all_payload_fields):
261
+ return _deny(MISSING_BUILD_EVAL_REASON)
224
262
  duplicate_reason = duplicate_followup_reason(all_payload_fields)
225
263
  return None if duplicate_reason is None else _deny(duplicate_reason)
226
264
 
227
265
 
228
- def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
266
+ def _transcript_paths(all_payload_fields: dict[str, object]) -> list[str]:
229
267
  paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
230
268
  paths.append(_subagent_transcript_path(all_payload_fields))
231
- readable_paths = list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
269
+ return list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
270
+
271
+
272
+ def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
273
+ readable_paths = _transcript_paths(all_payload_fields)
232
274
  if not readable_paths:
233
275
  return False
234
- statuses = [_transcript_load_status(path) for path in readable_paths]
276
+ statuses = [_scanned_transcript(path, _shows_skill_load) for path in readable_paths]
235
277
  return not any(status is None or status for status in statuses)
236
278
 
237
279
 
280
+ def _is_build_eval_missing(all_payload_fields: dict[str, object]) -> bool:
281
+ all_statuses = [
282
+ _scanned_transcript(each_path, _shows_build_eval)
283
+ for each_path in _transcript_paths(all_payload_fields)
284
+ ]
285
+ all_read_statuses = [each_status for each_status in all_statuses if each_status is not None]
286
+ return bool(all_read_statuses) and not any(all_read_statuses)
287
+
288
+
238
289
  def _deny(reason: str) -> dict[str, object]:
239
290
  return {
240
291
  HOOK_SPECIFIC_OUTPUT_KEY: {
@@ -1,4 +1,4 @@
1
- """Deny a new pull request whose body lacks a 'Proof in practice' or an 'Existing work' section."""
1
+ """Deny a new pull request whose body lacks a 'Proof in practice' or an 'Existing work' section, or a feature pull request whose body lacks an 'Eval' section."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -6,13 +6,14 @@ import re
6
6
  import subprocess
7
7
  import sys
8
8
  from collections.abc import Mapping
9
+ from typing import NamedTuple
9
10
  from pathlib import Path
10
11
 
11
12
  hooks_directory = str(Path(__file__).resolve().parent.parent)
12
13
  if hooks_directory not in sys.path:
13
14
  sys.path.insert(0, hooks_directory)
14
15
 
15
- from blocking.followup_pr_dedupe import body_from_arguments
16
+ from blocking.followup_pr_dedupe import body_from_arguments, create_tool_fields, title_from_arguments
16
17
  from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
17
18
  from hooks_constants.pull_request_proof_constants import (
18
19
  ALL_GH_CREATE_WORDS,
@@ -20,16 +21,18 @@ from hooks_constants.pull_request_proof_constants import (
20
21
  ALL_VISIBLE_FILE_SUFFIXES,
21
22
  CHANGED_FILES_SEPARATOR,
22
23
  COMMAND_MARKER,
23
- CREATE_PULL_REQUEST_TOOL_SUFFIX,
24
24
  ALL_DEFAULT_BRANCH_REFERENCES,
25
25
  ALL_HEAD_FLAGS,
26
26
  HEAD_FLAG_ASSIGNMENT_PREFIX,
27
27
  REMOTE_BRANCH_PREFIX,
28
28
  UNREADABLE_CHANGES_REASON,
29
+ EVAL_HEADING_PATTERN,
29
30
  EXISTING_WORK_HEADING_PATTERN,
31
+ FEATURE_TITLE_PATTERN,
30
32
  GIT_TIMEOUT_SECONDS,
31
33
  LINE_SEPARATOR,
32
34
  MISSING_BODY_REASON,
35
+ MISSING_EVAL_SECTION_REASON,
33
36
  MISSING_EXISTING_WORK_REASON,
34
37
  MISSING_LOOK_REASON,
35
38
  MISSING_PROOF_REASON,
@@ -48,6 +51,13 @@ from hooks_constants.shell_command_wrappers import (
48
51
  )
49
52
 
50
53
 
54
+ class CreateCall(NamedTuple):
55
+ """The title and body one pull request create call passes; None marks a value this gate cannot read."""
56
+
57
+ title: str | None
58
+ body: str | None
59
+
60
+
51
61
  def _heading_level(line: str, heading_pattern: str) -> int | None:
52
62
  heading = re.match(heading_pattern, line.strip(), re.IGNORECASE)
53
63
  return None if heading is None else len(heading.group(1))
@@ -120,9 +130,12 @@ def _create_arguments(all_segment_tokens: list[str]) -> list[str] | None:
120
130
  return _script_create_arguments(program, all_arguments)
121
131
 
122
132
 
123
- def _shell_create_bodies(command: str, working_directory: str) -> list[str | None]:
133
+ def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall]:
124
134
  return [
125
- body_from_arguments(all_create_arguments, working_directory)
135
+ CreateCall(
136
+ title_from_arguments(all_create_arguments),
137
+ body_from_arguments(all_create_arguments, working_directory),
138
+ )
126
139
  for each_text in all_wrapped_command_texts(command)
127
140
  for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
128
141
  if (all_create_arguments := _create_arguments(each_segment)) is not None
@@ -139,13 +152,12 @@ def _head_flag_value(all_create_arguments: list[str]) -> str | None:
139
152
 
140
153
 
141
154
  def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
142
- tool_input = all_payload_fields.get("tool_input")
143
- if not isinstance(tool_input, dict):
144
- return None
145
- head_branch = tool_input.get("head")
155
+ create_fields = create_tool_fields(all_payload_fields)
156
+ head_branch = None if create_fields is None else create_fields.get("head")
146
157
  if isinstance(head_branch, str):
147
158
  return head_branch
148
- command = tool_input.get("command")
159
+ tool_input = all_payload_fields.get("tool_input")
160
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
149
161
  if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
150
162
  return None
151
163
  all_head_flag_values = [
@@ -157,23 +169,77 @@ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
157
169
  return next((each_value for each_value in all_head_flag_values if each_value), None)
158
170
 
159
171
 
160
- def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
161
- tool_name = all_payload_fields.get("tool_name")
172
+ def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
173
+ create_fields = create_tool_fields(all_payload_fields)
174
+ if create_fields is not None:
175
+ title = create_fields.get("title")
176
+ body = create_fields.get("body")
177
+ return [
178
+ CreateCall(
179
+ title if isinstance(title, str) else None,
180
+ body if isinstance(body, str) else "",
181
+ )
182
+ ]
162
183
  tool_input = all_payload_fields.get("tool_input")
163
- if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
164
- return []
165
- if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
166
- body = tool_input.get("body")
167
- return [body if isinstance(body, str) else ""]
168
- command = tool_input.get("command")
169
- if tool_name not in SHELL_TOOL_NAMES or not isinstance(command, str):
184
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
185
+ if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
170
186
  return []
171
187
  working_directory = all_payload_fields.get("cwd")
172
- return _shell_create_bodies(
188
+ return _shell_create_calls(
173
189
  command, working_directory if isinstance(working_directory, str) else ""
174
190
  )
175
191
 
176
192
 
193
+ def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
194
+ return [each_call.body for each_call in _create_calls(all_payload_fields)]
195
+
196
+
197
+ def _is_feature_title(title: str | None) -> bool:
198
+ return title is not None and re.match(FEATURE_TITLE_PATTERN, title, re.IGNORECASE) is not None
199
+
200
+
201
+ def is_feature_pull_request(all_payload_fields: Mapping[str, object]) -> bool:
202
+ """Return whether any pull request create call in the payload carries a feature title.
203
+
204
+ ::
205
+
206
+ mcp__github__create_pull_request, title "feat(hooks)!: x" -> True
207
+ gh pr create --title "fix: y" --body-file b.md -> False
208
+ gh pr create --fill -> False
209
+ gh pr edit, git push, any other tool -> False
210
+
211
+ Args:
212
+ all_payload_fields: The parsed PreToolUse input.
213
+ """
214
+ return any(_is_feature_title(each_call.title) for each_call in _create_calls(all_payload_fields))
215
+
216
+
217
+ def missing_eval_section_reason(all_payload_fields: Mapping[str, object]) -> str | None:
218
+ """Return a deny reason when a feature pull request's body has no eval section, else None.
219
+
220
+ ::
221
+
222
+ title "feat: x", body has the section -> None
223
+ title "feat: x", body without it -> MISSING_EVAL_SECTION_REASON
224
+ title "fix: y", body without it -> None
225
+ title "feat: x", gh pr create --fill -> None
226
+
227
+ The section is an 'Eval' heading whose text names at least one command in
228
+ backticks. A create command whose body this gate cannot read passes here,
229
+ because missing_proof_reason denies it first.
230
+
231
+ Args:
232
+ all_payload_fields: The parsed PreToolUse input.
233
+ """
234
+ for each_call in _create_calls(all_payload_fields):
235
+ if each_call.body is None or not _is_feature_title(each_call.title):
236
+ continue
237
+ section = _section_text(each_call.body, EVAL_HEADING_PATTERN)
238
+ if not section or COMMAND_MARKER not in section:
239
+ return MISSING_EVAL_SECTION_REASON
240
+ return None
241
+
242
+
177
243
  def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None:
178
244
  """Return a deny reason when a new pull request's body has no proof, else None.
179
245
 
@@ -101,6 +101,7 @@ def test_should_refuse_a_page_published_without_a_look(tmp_path: Path) -> None:
101
101
  specific_output = hook_output["hookSpecificOutput"]
102
102
  assert specific_output["permissionDecision"] == "deny"
103
103
  assert "--screenshot" in specific_output["permissionDecisionReason"]
104
+ assert "Attach" not in specific_output["permissionDecisionReason"]
104
105
 
105
106
 
106
107
  def test_should_allow_a_page_whose_newer_screenshot_was_read(tmp_path: Path) -> None:
@@ -92,3 +92,36 @@ def test_body_from_arguments_reads_inline_text_and_body_files(tmp_path: Path) ->
92
92
  assert dedupe.body_from_arguments(["--body-file", "body.md"], str(tmp_path)) == "From the file."
93
93
  assert dedupe.body_from_arguments(["--body-file", "-"], str(tmp_path)) is None
94
94
  assert dedupe.body_from_arguments(["--fill"], str(tmp_path)) is None
95
+
96
+
97
+ def test_title_from_arguments_should_read_each_title_flag_shape() -> None:
98
+ assert dedupe.title_from_arguments(["--title", "feat: x", "--fill"]) == "feat: x"
99
+ assert dedupe.title_from_arguments(["-t", "fix: y"]) == "fix: y"
100
+ assert dedupe.title_from_arguments(["--title=feat(x): z"]) == "feat(x): z"
101
+ assert dedupe.title_from_arguments(["--title-file", "t.txt"]) is None
102
+ assert dedupe.title_from_arguments(["--fill"]) is None
103
+
104
+
105
+ def _dispatch_payload(all_inputs: dict[str, object]) -> dict[str, object]:
106
+ return {
107
+ "tool_name": "mcp__github__actions_run_trigger",
108
+ "tool_input": {"method": "run_workflow", "owner": "jl-cmd", "repo": "claude-dev-env", "inputs": all_inputs},
109
+ }
110
+
111
+
112
+ def test_create_tool_fields_should_read_a_pull_request_dispatch_as_a_create() -> None:
113
+ payload = _dispatch_payload({"head": "b", "title": "fix: y", "body": "Follow-up to #1731"})
114
+ assert dedupe.create_tool_fields(payload) == {
115
+ "head": "b",
116
+ "title": "fix: y",
117
+ "body": "Follow-up to #1731",
118
+ "owner": "jl-cmd",
119
+ "repo": "claude-dev-env",
120
+ }
121
+ reason = dedupe.duplicate_followup_reason(payload, _reader([OPEN_FOLLOWUP]))
122
+ assert reason is not None and "#1769" in reason
123
+
124
+
125
+ def test_create_tool_fields_should_skip_a_call_that_opens_no_pull_request() -> None:
126
+ assert dedupe.create_tool_fields(_dispatch_payload({"work_order": "wo-1"})) is None
127
+ assert dedupe.create_tool_fields({"tool_name": "Bash", "tool_input": {"command": "gh pr list"}}) is None