claude-dev-env 8.68.4 → 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 (84) hide show
  1. package/.agents/skills/correction/SKILL.md +21 -7
  2. package/.agents/skills/correction/playbooks/fix.md +14 -0
  3. package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
  4. package/.agents/skills/second-account-workers/SKILL.md +21 -3
  5. package/docs/rule-guides/features-start-with-an-eval.md +40 -0
  6. package/hooks/blocking/followup_pr_dedupe.py +55 -5
  7. package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
  8. package/hooks/blocking/pull_request_proof.py +86 -20
  9. package/hooks/blocking/test_artifact_look_gate.py +1 -0
  10. package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
  11. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
  12. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
  13. package/hooks/blocking/test_pull_request_proof.py +124 -1
  14. package/hooks/features/conduct-gates.md +1 -1
  15. package/hooks/features/write-edit-blocking.md +3 -5
  16. package/hooks/hooks.json +1 -1
  17. package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
  18. package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
  19. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
  20. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
  21. package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
  22. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
  23. package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
  24. package/hooks/test_transcript_skill_scan.py +75 -1
  25. package/hooks/transcript_skill_scan.py +87 -15
  26. package/package.json +1 -1
  27. package/rules/anti-corollary-tests.md +2 -2
  28. package/rules/archiving-agent-config.md +2 -2
  29. package/rules/asd-ste100-language.md +2 -2
  30. package/rules/bdd.md +1 -1
  31. package/rules/build-needs-a-user.md +1 -1
  32. package/rules/cleanup-temp-files.md +3 -3
  33. package/rules/code-standards.md +1 -1
  34. package/rules/correction-lens.md +3 -3
  35. package/rules/destructive-commands.md +3 -3
  36. package/rules/doc-inventory-integrity.md +1 -1
  37. package/rules/docstring-prose-matches-implementation.md +1 -1
  38. package/rules/explore-thoroughly.md +1 -1
  39. package/rules/failure-blast-radius.md +1 -1
  40. package/rules/falsify-before-green.md +2 -2
  41. package/rules/features-start-with-an-eval.md +9 -0
  42. package/rules/filesystem-search.md +3 -3
  43. package/rules/flag-non-breaking-findings.md +2 -2
  44. package/rules/long-horizon-autonomy.md +2 -2
  45. package/rules/memory-stores-durable-facts.md +2 -2
  46. package/rules/no-contrast-framing.md +1 -1
  47. package/rules/no-cross-skill-duplicate-helpers.md +1 -1
  48. package/rules/orphan-css-class.md +1 -1
  49. package/rules/paired-test-coverage.md +1 -1
  50. package/rules/plain-illustrative-docstrings.md +1 -1
  51. package/rules/prefer-existing-tools.md +2 -2
  52. package/rules/proof-before-pull-request.md +1 -1
  53. package/rules/request-scope.md +1 -1
  54. package/rules/research-mode.md +2 -2
  55. package/rules/retired-hook-prose.md +1 -1
  56. package/rules/session-title.md +1 -1
  57. package/rules/shell-invocation.md +2 -2
  58. package/rules/testing.md +1 -1
  59. package/rules/verify-before-asking.md +3 -3
  60. package/rules/verify-runtime-state.md +2 -2
  61. package/rules/windows-filesystem-safe.md +1 -1
  62. package/rules/workers-done-before-complete.md +2 -2
  63. package/rules/workflow-substitution-slots.md +1 -1
  64. package/scripts/account_broker.py +10 -3
  65. package/scripts/account_broker_support.py +7 -3
  66. package/scripts/claude_account_worker.py +24 -5
  67. package/scripts/claude_account_worker_process.py +5 -0
  68. package/scripts/claude_account_worker_report.py +16 -1
  69. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
  70. package/scripts/test_account_broker.py +38 -0
  71. package/scripts/test_account_broker_support.py +24 -0
  72. package/scripts/test_claude_account_worker.py +47 -0
  73. package/scripts/test_claude_account_worker_process.py +20 -0
  74. package/scripts/test_claude_account_worker_report.py +15 -0
  75. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
  76. package/scripts/tests/test_rule_load_scopes.py +1 -0
  77. package/hooks/advisory/conftest.py +0 -10
  78. package/hooks/advisory/refactor_guard.py +0 -385
  79. package/hooks/advisory/refactor_guard_test_support.py +0 -46
  80. package/hooks/advisory/test_refactor_guard.py +0 -148
  81. package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
  82. package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
  83. package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
  84. package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
@@ -14,7 +14,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
14
14
  from blocking import followup_pr_dedupe as gate_dedupe
15
15
  from blocking import pr_lifecycle_skill_gate as gate
16
16
  from hooks_constants.pr_lifecycle_skill_gate_constants import DENY_REASON
17
- from hooks_constants.pull_request_proof_constants import MISSING_PROOF_REASON
17
+ from hooks_constants.pull_request_proof_constants import MISSING_BUILD_EVAL_REASON, MISSING_PROOF_REASON
18
18
 
19
19
  PROVEN_FOLLOWUP_BODY = (
20
20
  "Follow-up to #1731\n\n## Existing work\nNothing found in open or merged pull requests.\n\n"
@@ -225,11 +225,11 @@ def test_hook_has_its_own_pre_tool_use_registration() -> None:
225
225
  each_group
226
226
  for each_group in all_groups
227
227
  if each_group["matcher"]
228
- == "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)"
228
+ == "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)"
229
229
  ]
230
230
  assert matching_groups == [
231
231
  {
232
- "matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
232
+ "matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)",
233
233
  "hooks": [
234
234
  {
235
235
  "type": "command",
@@ -283,3 +283,117 @@ def test_loaded_skill_still_denies_a_new_pull_request_without_proof(tmp_path: Pa
283
283
  exit_code, output = _run_main(_payload("gh pr create --body 'Adds a gate.'", _transcript(tmp_path, "pr-lifecycle")))
284
284
  assert exit_code == 0
285
285
  assert json.loads(output)["hookSpecificOutput"]["permissionDecisionReason"] == MISSING_PROOF_REASON
286
+
287
+
288
+ EVALUATED_BODY = (
289
+ "## Existing work\nNothing found in open or merged pull requests.\n\n"
290
+ "## Proof in practice\nRan `python probe.py`.\n\n"
291
+ "## Eval\nTen labeled cases, exact-match grader. Ran `python eval.py`: 10/10.\n"
292
+ )
293
+ SKILL_LOAD_LINE = json.dumps(
294
+ {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": "pr-lifecycle"}}]}}
295
+ )
296
+ BUILD_EVAL_LINE = json.dumps(
297
+ {
298
+ "type": "assistant",
299
+ "message": {
300
+ "content": [{"type": "tool_use", "name": "Skill", "input": {"skill": "claude-api", "args": "build-eval"}}]
301
+ },
302
+ }
303
+ )
304
+
305
+
306
+ def _transcript_with_lines(tmp_path: Path, file_name: str, all_lines: list[str]) -> Path:
307
+ transcript_path = tmp_path / file_name
308
+ transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
309
+ return transcript_path
310
+
311
+
312
+ def _create_payload(tmp_path: Path, title: str, transcript_path: Path) -> dict[str, object]:
313
+ return {
314
+ "tool_name": "mcp__github__create_pull_request",
315
+ "tool_input": {"owner": "o", "repo": "r", "title": title, "body": EVALUATED_BODY},
316
+ "transcript_path": str(transcript_path),
317
+ "cwd": _checkout_without_visible_changes(tmp_path),
318
+ }
319
+
320
+
321
+ def _assert_denied_with(payload: dict[str, object], reason: str) -> None:
322
+ decision = gate.decision_for(payload)
323
+ assert decision is not None
324
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
325
+ assert decision["hookSpecificOutput"]["permissionDecisionReason"] == reason
326
+
327
+
328
+ def test_feature_pull_request_without_build_eval_invocation_should_be_denied(tmp_path: Path) -> None:
329
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
330
+ _assert_denied_with(_create_payload(tmp_path, "feat: add a gate", transcript_path), MISSING_BUILD_EVAL_REASON)
331
+
332
+
333
+ def test_feature_pull_request_with_build_eval_invocation_should_pass(tmp_path: Path) -> None:
334
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE, SKILL_LOAD_LINE])
335
+ assert gate.decision_for(_create_payload(tmp_path, "feat: add a gate", transcript_path)) is None
336
+
337
+
338
+ def test_non_feature_pull_request_without_build_eval_invocation_should_pass(tmp_path: Path) -> None:
339
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
340
+ assert gate.decision_for(_create_payload(tmp_path, "fix: repair a gate", transcript_path)) is None
341
+
342
+
343
+ def test_feature_pull_request_with_no_readable_transcript_should_pass(tmp_path: Path) -> None:
344
+ payload = _create_payload(tmp_path, "feat: add a gate", tmp_path / "missing.jsonl")
345
+ assert gate.decision_for(payload) is None
346
+
347
+
348
+ def test_build_eval_check_should_ignore_an_unreadable_transcript_beside_a_readable_one(tmp_path: Path) -> None:
349
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
350
+ payload = _create_payload(tmp_path, "feat: add a gate", transcript_path)
351
+ payload["agent_transcript_path"] = str(tmp_path / "missing-agent.jsonl")
352
+ _assert_denied_with(payload, MISSING_BUILD_EVAL_REASON)
353
+
354
+
355
+ def test_build_eval_invocation_in_the_agent_transcript_should_pass(tmp_path: Path) -> None:
356
+ session_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
357
+ agent_path = _transcript_with_lines(tmp_path, "agent.jsonl", [BUILD_EVAL_LINE])
358
+ payload = _create_payload(tmp_path, "feat: add a gate", session_path)
359
+ payload["agent_transcript_path"] = str(agent_path)
360
+ assert gate.decision_for(payload) is None
361
+
362
+
363
+ def _dispatch_payload(tmp_path: Path, all_inputs: dict[str, object], transcript_path: Path) -> dict[str, object]:
364
+ return {
365
+ "tool_name": "mcp__github__actions_run_trigger",
366
+ "tool_input": {
367
+ "method": "run_workflow",
368
+ "owner": "o",
369
+ "repo": "r",
370
+ "workflow_id": "open.yml",
371
+ "ref": "main",
372
+ "inputs": all_inputs,
373
+ },
374
+ "transcript_path": str(transcript_path),
375
+ "cwd": _checkout_without_visible_changes(tmp_path),
376
+ }
377
+
378
+
379
+ def test_feature_dispatch_without_build_eval_invocation_should_be_denied(tmp_path: Path) -> None:
380
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
381
+ all_inputs = {"head": "main", "title": "feat: add a gate", "body": EVALUATED_BODY}
382
+ _assert_denied_with(_dispatch_payload(tmp_path, all_inputs, transcript_path), MISSING_BUILD_EVAL_REASON)
383
+
384
+
385
+ def test_feature_dispatch_with_build_eval_invocation_should_pass(tmp_path: Path) -> None:
386
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE, SKILL_LOAD_LINE])
387
+ all_inputs = {"head": "main", "title": "feat: add a gate", "body": EVALUATED_BODY}
388
+ assert gate.decision_for(_dispatch_payload(tmp_path, all_inputs, transcript_path)) is None
389
+
390
+
391
+ def test_pull_request_dispatch_without_the_skill_loaded_should_be_denied(tmp_path: Path) -> None:
392
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE])
393
+ all_inputs = {"head": "main", "title": "fix: repair a gate", "body": EVALUATED_BODY}
394
+ _assert_denied_with(_dispatch_payload(tmp_path, all_inputs, transcript_path), DENY_REASON)
395
+
396
+
397
+ def test_dispatch_that_opens_no_pull_request_should_pass_without_the_skill(tmp_path: Path) -> None:
398
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE])
399
+ assert gate.decision_for(_dispatch_payload(tmp_path, {"work_order": "wo-1"}, transcript_path)) is None
@@ -724,10 +724,7 @@ def test_folded_write_edit_hooks_have_no_standalone_hooks_json_entry() -> None:
724
724
  for each_group in hooks_configuration["hooks"]["PreToolUse"]
725
725
  for each_hook in each_group["hooks"]
726
726
  ]
727
- folded_script_relative_paths = (
728
- "advisory/refactor_guard.py",
729
- "advisory/migration_safety_advisor.py",
730
- )
727
+ folded_script_relative_paths = ("advisory/migration_safety_advisor.py",)
731
728
  for each_script_path in folded_script_relative_paths:
732
729
  matching_commands = [
733
730
  each_command
@@ -808,7 +805,6 @@ def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
808
805
 
809
806
 
810
807
  ALL_RETAINED_PATHS = {
811
- "advisory/refactor_guard.py",
812
808
  "advisory/migration_safety_advisor.py",
813
809
  }
814
810
  ALL_RETIRED_PATHS = {
@@ -1,4 +1,4 @@
1
- """Behavior tests for the proof-in-practice and existing-work checks on a new pull request."""
1
+ """Behavior tests for the proof-in-practice, existing-work and eval checks on a new pull request."""
2
2
 
3
3
  import subprocess
4
4
  import sys
@@ -11,6 +11,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
11
11
  from blocking import pull_request_proof as gate
12
12
  from hooks_constants.pull_request_proof_constants import (
13
13
  MISSING_BODY_REASON,
14
+ MISSING_EVAL_SECTION_REASON,
14
15
  MISSING_EXISTING_WORK_REASON,
15
16
  MISSING_PROOF_REASON,
16
17
  UNREADABLE_CHANGES_REASON,
@@ -210,3 +211,125 @@ def test_should_read_the_head_flag_of_a_shell_create(tmp_path: Path) -> None:
210
211
  reason = gate.missing_look_reason(payload)
211
212
  assert reason is not None
212
213
  assert "page.html" in reason
214
+
215
+
216
+ EVALUATED_BODY = SEARCHED_BODY + "\n## Eval\nTen labeled cases, exact-match grader. Ran `python eval.py`: 10/10.\n"
217
+ EVAL_WITHOUT_COMMAND_BODY = SEARCHED_BODY + "\n## Eval\nTen labeled cases, all pass.\n"
218
+
219
+
220
+ def _titled_mcp_create(title: object, body: object) -> dict[str, object]:
221
+ payload = _mcp_create(body)
222
+ payload["tool_input"]["title"] = title
223
+ return payload
224
+
225
+
226
+ def test_feature_mcp_create_without_an_eval_section_should_be_denied() -> None:
227
+ payload = _titled_mcp_create("feat: add a gate", SEARCHED_BODY)
228
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
229
+
230
+
231
+ def test_feature_mcp_create_with_an_eval_section_naming_a_command_should_pass() -> None:
232
+ assert gate.missing_eval_section_reason(_titled_mcp_create("feat: add a gate", EVALUATED_BODY)) is None
233
+
234
+
235
+ def test_feature_mcp_create_with_an_eval_heading_and_no_backtick_should_be_denied() -> None:
236
+ payload = _titled_mcp_create("feat: add a gate", EVAL_WITHOUT_COMMAND_BODY)
237
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
238
+
239
+
240
+ def test_eval_section_should_stop_at_a_heading_of_the_same_level() -> None:
241
+ body = SEARCHED_BODY + "\n## Evals\nCases listed.\n\n## Notes\nRan `python eval.py`.\n"
242
+ payload = _titled_mcp_create("feat: add a gate", body)
243
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
244
+
245
+
246
+ def test_fix_and_docs_titles_without_an_eval_section_should_pass() -> None:
247
+ for each_title in ("fix: repair a gate", "docs: describe a gate"):
248
+ payload = _titled_mcp_create(each_title, SEARCHED_BODY)
249
+ assert gate.missing_eval_section_reason(payload) is None, each_title
250
+ assert not gate.is_feature_pull_request(payload), each_title
251
+
252
+
253
+ def test_feature_titles_should_count_as_features() -> None:
254
+ for each_title in ("feat: x", "feat(hooks)!: x", "FEAT: x", "FEAT(x)!: y", " feat!: x"):
255
+ assert gate.is_feature_pull_request(_titled_mcp_create(each_title, SEARCHED_BODY)), each_title
256
+
257
+
258
+ def test_non_feature_titles_should_not_count_as_features() -> None:
259
+ for each_title in ("featured: x", "feature: x", "chore: feat: x", None, 7):
260
+ assert not gate.is_feature_pull_request(_titled_mcp_create(each_title, SEARCHED_BODY)), each_title
261
+
262
+
263
+ def test_gh_create_title_flag_should_be_read(tmp_path: Path) -> None:
264
+ (tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
265
+ payload = _shell('gh pr create --title "feat: x" --body-file b.md', tmp_path)
266
+ assert gate.is_feature_pull_request(payload)
267
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
268
+
269
+
270
+ def test_gh_create_title_assignment_should_be_read(tmp_path: Path) -> None:
271
+ (tmp_path / "b.md").write_text(EVALUATED_BODY, encoding="utf-8")
272
+ payload = _shell("gh pr create --title=feat:x --body-file b.md", tmp_path)
273
+ assert gate.is_feature_pull_request(payload)
274
+ assert gate.missing_eval_section_reason(payload) is None
275
+
276
+
277
+ def test_pull_request_script_create_title_should_be_read(tmp_path: Path) -> None:
278
+ (tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
279
+ payload = _shell(
280
+ 'python pull_request.py create --title "feat(x): y" --body-file b.md --repo o/r', tmp_path
281
+ )
282
+ assert gate.is_feature_pull_request(payload)
283
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
284
+
285
+
286
+ def test_gh_create_without_a_title_should_not_count_as_a_feature(tmp_path: Path) -> None:
287
+ (tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
288
+ payload = _shell("gh pr create --body-file b.md", tmp_path)
289
+ assert not gate.is_feature_pull_request(payload)
290
+ assert gate.missing_eval_section_reason(payload) is None
291
+
292
+
293
+ def test_feature_create_with_an_unreadable_body_should_leave_the_deny_to_the_proof_check() -> None:
294
+ payload = _shell('gh pr create --title "feat: x" --fill')
295
+ assert gate.missing_eval_section_reason(payload) is None
296
+ assert gate.missing_proof_reason(payload) == MISSING_BODY_REASON
297
+
298
+
299
+ def _dispatch(all_inputs: dict[str, object], method: str = "run_workflow") -> dict[str, object]:
300
+ return {
301
+ "tool_name": "mcp__github__actions_run_trigger",
302
+ "tool_input": {
303
+ "method": method,
304
+ "owner": "o",
305
+ "repo": "r",
306
+ "workflow_id": "open.yml",
307
+ "ref": "main",
308
+ "inputs": all_inputs,
309
+ },
310
+ }
311
+
312
+
313
+ def test_pull_request_dispatch_without_proof_should_be_denied() -> None:
314
+ payload = _dispatch({"head": "feature", "title": "fix: y", "body": UNPROVEN_BODY})
315
+ assert gate.missing_proof_reason(payload) == MISSING_PROOF_REASON
316
+
317
+
318
+ def test_pull_request_dispatch_with_no_body_input_should_be_denied() -> None:
319
+ assert gate.missing_proof_reason(_dispatch({"head": "feature", "title": "fix: y"})) == MISSING_PROOF_REASON
320
+
321
+
322
+ def test_feature_dispatch_without_an_eval_section_should_be_denied() -> None:
323
+ payload = _dispatch({"head": "feature", "title": "feat: x", "body": SEARCHED_BODY})
324
+ assert gate.is_feature_pull_request(payload)
325
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
326
+
327
+
328
+ def test_dispatch_that_names_no_head_or_runs_no_workflow_should_not_count_as_a_create() -> None:
329
+ all_payloads = [
330
+ _dispatch({"title": "feat: x", "body": UNPROVEN_BODY}),
331
+ _dispatch({"head": "feature", "title": "feat: x", "body": UNPROVEN_BODY}, "rerun_workflow_run"),
332
+ ]
333
+ for each_payload in all_payloads:
334
+ assert gate.missing_proof_reason(each_payload) is None
335
+ assert not gate.is_feature_pull_request(each_payload)
@@ -24,7 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
24
24
  - `blocking/session_title_stop_gate.py` runs on `Stop`, no matcher, timeout `10` seconds in `hooks.json`.
25
25
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
26
26
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
27
- - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
27
+ - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
28
28
  - `blocking/artifact_look_gate.py` runs on `PreToolUse`, matcher `Artifact|Skill`, timeout `10` seconds in `hooks.json`. It speaks only on an `Artifact` publish of a `.html` or `.htm` file.
29
29
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
30
30
 
@@ -1,18 +1,17 @@
1
1
  # Write and Edit blocking
2
2
 
3
- This family decides whether an agent's file mutation can run. It also warns about broad refactors and unsafe migration edits before the agent changes a file.
3
+ This family decides whether an agent's file mutation can run. It also warns about unsafe migration edits before the agent changes a file.
4
4
 
5
5
  ## Checks
6
6
 
7
7
  - `blocking/pre_tool_use_dispatcher.py` combines hosted advisory output with its native description check and returns one permission decision.
8
- - `advisory/refactor_guard.py` warns when an Edit or MultiEdit appears to rename code beyond the current change.
9
8
  - `advisory/migration_safety_advisor.py` warns when an Edit or MultiEdit adds an unsafe Django migration operation.
10
9
  - `blocking/state_description_blocker.py` rejects historical or comparative prose in supported comments, docstrings, and Markdown.
11
10
 
12
11
  ## When it fires
13
12
 
14
13
  - `blocking/pre_tool_use_dispatcher.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds in `hooks.json`.
15
- - `advisory/refactor_guard.py` and `advisory/migration_safety_advisor.py` run inside that dispatcher on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. `ALL_HOSTED_HOOK_ENTRIES` selects `Edit` and `MultiEdit` for each.
14
+ - `advisory/migration_safety_advisor.py` runs inside that dispatcher on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. `ALL_HOSTED_HOOK_ENTRIES` selects `Edit` and `MultiEdit` for it.
16
15
  - `blocking/state_description_blocker.py` runs through the dispatcher's native evaluator on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. Its evaluator checks `Write`, `Edit`, and `MultiEdit` payloads and skips `apply_patch`.
17
16
 
18
17
  ## Proving it
@@ -22,13 +21,12 @@ Preconditions:
22
21
  - Run each command from the repository root with Python and pytest available. The tests use disposable paths where they need files.
23
22
 
24
23
  - **Dispatcher decision.** Input is a clean Write payload. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py -q`. The adjacent test checks that the dispatcher allows clean writes and carries denials from its checks.
25
- - **Refactor warning.** Input is a MultiEdit whose second edit renames a function. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_refactor_guard.py -q`. The adjacent test reports both function names in the warning.
26
24
  - **Migration warning.** Input is an Edit containing `migrations.RemoveField`. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_migration_safety_advisor.py -q`. The adjacent test observes an allow decision with a migration warning.
27
25
  - **Description gate.** Input is a Write containing historical prose in a Python comment. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_state_description_blocker.py -q`. The adjacent test observes a denial reason.
28
26
 
29
27
  ## Gotchas
30
28
 
31
- - `ALL_HOSTED_HOOK_ENTRIES` lists only the two nonblocking advisors. The dispatcher calls the description evaluator through its native hook table.
29
+ - `ALL_HOSTED_HOOK_ENTRIES` lists only the nonblocking migration advisor. The dispatcher calls the description evaluator through its native hook table.
32
30
  - The dispatcher runs hosted entries in roster order. A hosted advisory crash stays silent; a native blocking check can deny the write.
33
31
  - `scripts/policy_lint/adapter_detectors.py` also loads `blocking/state_description_blocker.py`. Check that adapter when changing its evaluator.
34
32
  - `bin/install.mjs` names folded and retired script paths so reinstall can remove stale registrations.
package/hooks/hooks.json CHANGED
@@ -88,7 +88,7 @@
88
88
  ]
89
89
  },
90
90
  {
91
- "matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
91
+ "matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)",
92
92
  "hooks": [
93
93
  {
94
94
  "type": "command",
@@ -28,6 +28,5 @@ UNSEEN_PAGE_REASON_TEMPLATE = (
28
28
  "headless browser, for example `chromium --headless --screenshot=page.png "
29
29
  "--window-size=800,900 file://{page_path}`, then open page.png with the "
30
30
  "Read tool and check it against the ask. Fix what the picture shows, take "
31
- "a new screenshot, and publish again. Attach that screenshot when you "
32
- "report the page, so the person who asked sees the same proof."
31
+ "a new screenshot, and publish again."
33
32
  )
@@ -17,3 +17,7 @@ DUPLICATE_FOLLOWUP_REASON_TEMPLATE = (
17
17
  " ({open_url}). Add this finding to the open follow-up pull request #{open_number}."
18
18
  " One combined follow-up pull request per parent pull request."
19
19
  )
20
+ ALL_TITLE_OPTIONS = frozenset({"-t", "--title"})
21
+ WORKFLOW_DISPATCH_TOOL_SUFFIX = "__actions_run_trigger"
22
+ RUN_WORKFLOW_METHOD = "run_workflow"
23
+ ALL_PULL_REQUEST_DISPATCH_INPUT_KEYS = ("head", "title")
@@ -39,3 +39,6 @@ ALL_PYTHON_EXECUTABLES = frozenset({"python", "python3", "python.exe", "python3.
39
39
  PULL_REQUEST_SCRIPT_NAME = "pull_request.py"
40
40
  PATH_SEPARATOR_PATTERN = r"[\\/]"
41
41
  PYTHON_OPTIONS_WITH_VALUE = frozenset({"-X", "-W"})
42
+ ALL_BUILD_EVAL_SKILL_NAMES = ("claude-api",)
43
+ BUILD_EVAL_ARGUMENT_WORD = "build-eval"
44
+ BUILD_EVAL_COMMAND_MARKER = "<command-name>/claude-api</command-name>"
@@ -83,11 +83,6 @@ class HostedHookEntry:
83
83
 
84
84
 
85
85
  ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
86
- HostedHookEntry(
87
- script_relative_path="advisory/refactor_guard.py",
88
- applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
89
- is_blocking=False,
90
- ),
91
86
  HostedHookEntry(
92
87
  script_relative_path="advisory/migration_safety_advisor.py",
93
88
  applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
@@ -8,7 +8,6 @@ ALL_GH_CREATE_WORDS = ["pr", "create"]
8
8
  PULL_REQUEST_SCRIPT_NAME = "pull_request.py"
9
9
  PULL_REQUEST_SCRIPT_CREATE_WORD = "create"
10
10
  ALL_PYTHON_PROGRAM_NAMES = frozenset({"python", "python3", "py"})
11
- CREATE_PULL_REQUEST_TOOL_SUFFIX = "__create_pull_request"
12
11
  PROOF_GUIDE_PATH = "~/.claude/docs/rule-guides/proof-before-pull-request.md"
13
12
  MISSING_BODY_REASON = (
14
13
  "Pass the pull request body with --body or --body-file so this gate can read its"
@@ -64,3 +63,23 @@ UNREADABLE_CHANGES_REASON = (
64
63
  " from a checkout of the head branch with origin fetched, or put a screenshot"
65
64
  " link in the 'Proof in practice' section. Read " + PROOF_GUIDE_PATH + " for the full rule."
66
65
  )
66
+ FEATURE_TITLE_PATTERN = r"^\s*feat(\([^)]*\))?!?:"
67
+ EVAL_HEADING_PATTERN = r"^(#{1,6})[ \t]+evals?[ \t]*#*[ \t]*$"
68
+ EVAL_GUIDE_PATH = "~/.claude/docs/rule-guides/features-start-with-an-eval.md"
69
+ MISSING_EVAL_SECTION_REASON = (
70
+ "This feature pull request has no 'Eval' section naming its eval. A new feature is"
71
+ " built against an eval. Invoke the Skill tool with skill 'claude-api' and args"
72
+ " 'build-eval', build or reuse the eval it leads you to, and run it. Then add an"
73
+ " 'Eval' heading to the body that names the eval's cases and grader, gives the"
74
+ " command that runs it in backticks, and quotes its result. Read "
75
+ + EVAL_GUIDE_PATH
76
+ + " for the full rule."
77
+ )
78
+ MISSING_BUILD_EVAL_REASON = (
79
+ "This feature pull request comes from a session that never invoked /claude-api"
80
+ " build-eval. A new feature starts with that command. Invoke the Skill tool with"
81
+ " skill 'claude-api' and args 'build-eval', check the eval named in the 'Eval'"
82
+ " section with it, then run the same command again. Read "
83
+ + EVAL_GUIDE_PATH
84
+ + " for the full rule."
85
+ )
@@ -37,21 +37,14 @@ def test_roster_keeps_only_nonblocking_edit_advisors() -> None:
37
37
  all_script_paths = tuple(
38
38
  each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
39
39
  )
40
- assert all_script_paths == (
41
- "advisory/refactor_guard.py",
42
- "advisory/migration_safety_advisor.py",
43
- )
40
+ assert all_script_paths == ("advisory/migration_safety_advisor.py",)
44
41
  assert all(not each_entry.is_blocking for each_entry in ALL_HOSTED_HOOK_ENTRIES)
45
42
 
46
43
 
47
44
  def test_advisors_apply_to_edit_and_multi_edit() -> None:
48
- for each_script_path in (
49
- "advisory/refactor_guard.py",
50
- "advisory/migration_safety_advisor.py",
51
- ):
52
- entry = _entry_for(each_script_path)
53
- assert entry is not None
54
- assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME})
45
+ entry = _entry_for("advisory/migration_safety_advisor.py")
46
+ assert entry is not None
47
+ assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME})
55
48
 
56
49
 
57
50
  def test_every_hosted_script_path_exists_under_the_hooks_root() -> None:
@@ -5,3 +5,4 @@ COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
5
5
  SKILL_TOOL_NAME = "Skill"
6
6
  TOOL_USE_BLOCK_TYPE = "tool_use"
7
7
  USER_ENTRY_TYPE = "user"
8
+ COMMAND_ARGUMENTS_PATTERN = r"<command-args>(.*?)</command-args>"
@@ -8,7 +8,11 @@ HOOKS_DIRECTORY = Path(__file__).resolve().parent
8
8
  if str(HOOKS_DIRECTORY) not in sys.path:
9
9
  sys.path.insert(0, str(HOOKS_DIRECTORY))
10
10
 
11
- from transcript_skill_scan import is_skill_loaded_after_last_compaction, skill_invocation_status
11
+ from transcript_skill_scan import (
12
+ invokes_skill_with_argument,
13
+ is_skill_loaded_after_last_compaction,
14
+ skill_invocation_status,
15
+ )
12
16
 
13
17
 
14
18
  def _skill_entry(name: str) -> str:
@@ -56,3 +60,73 @@ def test_invocation_status_separates_ever_invoked_from_loaded_now() -> None:
56
60
  assert skill_invocation_status([_skill_entry("pr-lifecycle")], ("pr-lifecycle",), ()) == (True, True)
57
61
  assert skill_invocation_status([_skill_entry("pr-lifecycle"), compaction], ("pr-lifecycle",), ()) == (True, False)
58
62
  assert skill_invocation_status([compaction, _skill_entry("other")], ("pr-lifecycle",), ()) == (False, False)
63
+
64
+
65
+ BUILD_EVAL_MARKER = "<command-name>/claude-api</command-name>"
66
+
67
+
68
+ def _skill_entry_with_arguments(name: str, arguments: object) -> str:
69
+ return json.dumps(
70
+ {
71
+ "type": "assistant",
72
+ "message": {
73
+ "content": [{"type": "tool_use", "name": "Skill", "input": {"skill": name, "args": arguments}}]
74
+ },
75
+ }
76
+ )
77
+
78
+
79
+ def _invokes_build_eval(lines: list[str]) -> bool:
80
+ return invokes_skill_with_argument(lines, ("claude-api",), "build-eval", BUILD_EVAL_MARKER)
81
+
82
+
83
+ def test_claude_api_skill_with_build_eval_argument_should_count() -> None:
84
+ assert _invokes_build_eval([_skill_entry_with_arguments("claude-api", "build-eval")])
85
+
86
+
87
+ def test_build_eval_argument_followed_by_more_words_should_count() -> None:
88
+ assert _invokes_build_eval([_skill_entry_with_arguments("claude-api", "build-eval extra words")])
89
+
90
+
91
+ def test_plugin_prefixed_claude_api_with_build_eval_should_count() -> None:
92
+ assert _invokes_build_eval([_skill_entry_with_arguments("plugin:claude-api", "build-eval")])
93
+
94
+
95
+ def test_claude_api_with_another_argument_should_not_count() -> None:
96
+ assert not _invokes_build_eval([_skill_entry_with_arguments("claude-api", "migrate")])
97
+ assert not _invokes_build_eval([_skill_entry_with_arguments("claude-api", "migrate build-eval")])
98
+ assert not _invokes_build_eval([_skill_entry_with_arguments("claude-api", None)])
99
+ assert not _invokes_build_eval([_skill_entry("claude-api")])
100
+
101
+
102
+ def test_build_eval_skill_alone_should_not_count() -> None:
103
+ assert not _invokes_build_eval([_skill_entry("build-eval")])
104
+ assert not _invokes_build_eval([_skill_entry_with_arguments("build-eval", "build-eval")])
105
+
106
+
107
+ def test_user_claude_api_build_eval_command_should_count() -> None:
108
+ command_text = BUILD_EVAL_MARKER + "<command-args>build-eval for the gate</command-args>"
109
+ assert _invokes_build_eval([json.dumps({"type": "user", "message": {"content": command_text}})])
110
+
111
+
112
+ def test_user_claude_api_command_with_another_argument_should_not_count() -> None:
113
+ command_text = BUILD_EVAL_MARKER + "<command-args>migrate</command-args>"
114
+ assert not _invokes_build_eval([json.dumps({"type": "user", "message": {"content": command_text}})])
115
+ assert not _invokes_build_eval([json.dumps({"type": "user", "message": {"content": BUILD_EVAL_MARKER}})])
116
+
117
+
118
+ def test_quoted_build_eval_command_in_a_tool_result_should_not_count() -> None:
119
+ command_text = BUILD_EVAL_MARKER + "<command-args>build-eval</command-args>"
120
+ quoted_result = json.dumps(
121
+ {"type": "user", "message": {"content": [{"type": "tool_result", "content": command_text}]}}
122
+ )
123
+ assert not _invokes_build_eval([quoted_result])
124
+
125
+
126
+ def test_build_eval_invocation_before_a_compact_boundary_should_still_count() -> None:
127
+ lines = [
128
+ _skill_entry_with_arguments("claude-api", "build-eval"),
129
+ json.dumps({"type": "system", "subtype": "compact_boundary"}),
130
+ _skill_entry("other"),
131
+ ]
132
+ assert _invokes_build_eval(lines)