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.
- package/.agents/skills/correction/SKILL.md +21 -7
- package/.agents/skills/correction/playbooks/fix.md +14 -0
- package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
- package/.agents/skills/second-account-workers/SKILL.md +21 -3
- package/docs/rule-guides/features-start-with-an-eval.md +40 -0
- package/hooks/blocking/followup_pr_dedupe.py +55 -5
- package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
- package/hooks/blocking/pull_request_proof.py +86 -20
- package/hooks/blocking/test_artifact_look_gate.py +1 -0
- package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
- package/hooks/blocking/test_pull_request_proof.py +124 -1
- package/hooks/features/conduct-gates.md +1 -1
- package/hooks/features/write-edit-blocking.md +3 -5
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
- package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
- package/hooks/test_transcript_skill_scan.py +75 -1
- package/hooks/transcript_skill_scan.py +87 -15
- package/package.json +1 -1
- package/rules/anti-corollary-tests.md +2 -2
- package/rules/archiving-agent-config.md +2 -2
- package/rules/asd-ste100-language.md +2 -2
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +1 -1
- package/rules/cleanup-temp-files.md +3 -3
- package/rules/code-standards.md +1 -1
- package/rules/correction-lens.md +3 -3
- package/rules/destructive-commands.md +3 -3
- package/rules/doc-inventory-integrity.md +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/explore-thoroughly.md +1 -1
- package/rules/failure-blast-radius.md +1 -1
- package/rules/falsify-before-green.md +2 -2
- package/rules/features-start-with-an-eval.md +9 -0
- package/rules/filesystem-search.md +3 -3
- package/rules/flag-non-breaking-findings.md +2 -2
- package/rules/long-horizon-autonomy.md +2 -2
- package/rules/memory-stores-durable-facts.md +2 -2
- package/rules/no-contrast-framing.md +1 -1
- package/rules/no-cross-skill-duplicate-helpers.md +1 -1
- package/rules/orphan-css-class.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/plain-illustrative-docstrings.md +1 -1
- package/rules/prefer-existing-tools.md +2 -2
- package/rules/proof-before-pull-request.md +1 -1
- package/rules/request-scope.md +1 -1
- package/rules/research-mode.md +2 -2
- package/rules/retired-hook-prose.md +1 -1
- package/rules/session-title.md +1 -1
- package/rules/shell-invocation.md +2 -2
- package/rules/testing.md +1 -1
- package/rules/verify-before-asking.md +3 -3
- package/rules/verify-runtime-state.md +2 -2
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/account_broker.py +10 -3
- package/scripts/account_broker_support.py +7 -3
- package/scripts/claude_account_worker.py +24 -5
- package/scripts/claude_account_worker_process.py +5 -0
- package/scripts/claude_account_worker_report.py +16 -1
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
- package/scripts/test_account_broker.py +38 -0
- package/scripts/test_account_broker_support.py +24 -0
- package/scripts/test_claude_account_worker.py +47 -0
- package/scripts/test_claude_account_worker_process.py +20 -0
- package/scripts/test_claude_account_worker_report.py +15 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
- package/scripts/tests/test_rule_load_scopes.py +1 -0
- package/hooks/advisory/conftest.py +0 -10
- package/hooks/advisory/refactor_guard.py +0 -385
- package/hooks/advisory/refactor_guard_test_support.py +0 -46
- package/hooks/advisory/test_refactor_guard.py +0 -148
- package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
- package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
- package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
- 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
|
|
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
|
|
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/
|
|
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
|
|
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.
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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:
|
|
@@ -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
|
|
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)
|