claude-dev-env 8.99.1 → 8.100.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/hooks/blocking/merge_review_gate.py +66 -105
- package/hooks/blocking/merge_review_gate_parts/__init__.py +1 -0
- package/hooks/blocking/merge_review_gate_parts/open_request.py +163 -0
- package/hooks/blocking/merge_review_gate_parts/receipts.py +192 -0
- package/hooks/blocking/test_merge_review_gate.py +218 -0
- package/hooks/blocking/test_verify_before_commit_gate.py +56 -1
- package/hooks/blocking/tests/test_open_request.py +38 -0
- package/hooks/blocking/tests/test_receipts.py +57 -0
- package/hooks/blocking/verify_before_commit_gate.py +11 -1
- package/hooks/features/conduct-gates.md +5 -5
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/merge_review_gate_constants.py +46 -1
- package/hooks/hooks_constants/verify_before_commit_gate_constants.py +3 -1
- package/hooks/test_transcript_skill_scan.py +36 -3
- package/hooks/transcript_skill_scan.py +28 -3
- package/package.json +1 -1
- package/scripts/merge_review_receipt.py +56 -4
- package/scripts/test_merge_review_receipt.py +53 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Behavior tests for the clean-review merge gate."""
|
|
2
2
|
|
|
3
|
+
import subprocess
|
|
3
4
|
import sys
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -199,3 +200,220 @@ def test_should_deny_auto_merge_pinned_to_a_newer_head() -> None:
|
|
|
199
200
|
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
200
201
|
command = f"gh pr merge 12 --repo acme/tools --auto --match-head-commit {NEW_HEAD}"
|
|
201
202
|
assert "Review the new head" in _reason(_bash(command))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _git(folder: Path, *all_arguments: str) -> str:
|
|
206
|
+
completed = subprocess.run(
|
|
207
|
+
["git", "-C", str(folder), *all_arguments], capture_output=True, text=True, check=True
|
|
208
|
+
)
|
|
209
|
+
return completed.stdout.strip()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.fixture
|
|
213
|
+
def branch_checkout(tmp_path: Path) -> tuple[Path, str]:
|
|
214
|
+
folder = tmp_path / "checkout"
|
|
215
|
+
folder.mkdir()
|
|
216
|
+
_git(folder, "init", "--quiet", "--initial-branch", "main")
|
|
217
|
+
_git(folder, "remote", "add", "origin", "https://github.com/acme/tools.git")
|
|
218
|
+
_git(folder, "checkout", "--quiet", "-b", "feature")
|
|
219
|
+
_git(
|
|
220
|
+
folder,
|
|
221
|
+
"-c",
|
|
222
|
+
"user.name=t",
|
|
223
|
+
"-c",
|
|
224
|
+
"user.email=t@example.com",
|
|
225
|
+
"commit",
|
|
226
|
+
"--quiet",
|
|
227
|
+
"--allow-empty",
|
|
228
|
+
"-m",
|
|
229
|
+
"first",
|
|
230
|
+
)
|
|
231
|
+
return folder, _git(folder, "rev-parse", "HEAD")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _create_tool(folder: Path, head: str = "feature") -> dict[str, object]:
|
|
235
|
+
return {
|
|
236
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
237
|
+
"tool_input": {"owner": "acme", "repo": "tools", "head": head, "base": "main"},
|
|
238
|
+
"cwd": str(folder),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _bash_in(folder: Path, command: str) -> dict[str, object]:
|
|
243
|
+
return {"tool_name": "Bash", "tool_input": {"command": command}, "cwd": str(folder)}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_should_deny_a_pull_request_open_with_no_review(branch_checkout: tuple[Path, str]) -> None:
|
|
247
|
+
folder, head = branch_checkout
|
|
248
|
+
reason = _reason(_create_tool(folder))
|
|
249
|
+
assert f"no clean review for head {head}" in reason
|
|
250
|
+
assert f"acme/tools open {head} 0" in reason
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_should_allow_a_pull_request_open_reviewed_clean_at_its_head(
|
|
254
|
+
branch_checkout: tuple[Path, str],
|
|
255
|
+
) -> None:
|
|
256
|
+
folder, head = branch_checkout
|
|
257
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
258
|
+
assert _reason(_create_tool(folder)) is None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_should_deny_a_pull_request_open_whose_pass_found_findings(
|
|
262
|
+
branch_checkout: tuple[Path, str],
|
|
263
|
+
) -> None:
|
|
264
|
+
folder, head = branch_checkout
|
|
265
|
+
merge_review_receipt.record_open_review("acme/tools", head, 2)
|
|
266
|
+
assert "no clean review" in _reason(_create_tool(folder))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_should_deny_a_pull_request_open_after_a_push_past_the_review(
|
|
270
|
+
branch_checkout: tuple[Path, str],
|
|
271
|
+
) -> None:
|
|
272
|
+
folder, _head = branch_checkout
|
|
273
|
+
merge_review_receipt.record_open_review("acme/tools", NEW_HEAD, 0)
|
|
274
|
+
assert "no clean review" in _reason(_create_tool(folder))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_should_read_gh_pr_create_with_repo_and_head(branch_checkout: tuple[Path, str]) -> None:
|
|
278
|
+
folder, head = branch_checkout
|
|
279
|
+
command = "gh pr create --repo acme/tools --head feature --title t --body b"
|
|
280
|
+
assert "no clean review" in _reason(_bash_in(folder, command))
|
|
281
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
282
|
+
assert _reason(_bash_in(folder, command)) is None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_should_read_gh_pr_create_repository_from_origin(branch_checkout: tuple[Path, str]) -> None:
|
|
286
|
+
folder, head = branch_checkout
|
|
287
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
288
|
+
assert _reason(_bash_in(folder, "gh pr create --fill")) is None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_should_read_the_pull_request_script_create(branch_checkout: tuple[Path, str]) -> None:
|
|
292
|
+
folder, head = branch_checkout
|
|
293
|
+
command = "python scripts/pull_request.py create --repo acme/tools --head feature"
|
|
294
|
+
assert f"head {head}" in _reason(_bash_in(folder, command))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_should_deny_a_pull_request_open_whose_head_cannot_be_read(tmp_path: Path) -> None:
|
|
298
|
+
reason = _reason(_create_tool(tmp_path, head="missing-branch"))
|
|
299
|
+
assert "names no repository or head commit" in reason
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_should_allow_a_pull_request_open_in_an_exempt_repository(
|
|
303
|
+
branch_checkout: tuple[Path, str], monkeypatch: pytest.MonkeyPatch
|
|
304
|
+
) -> None:
|
|
305
|
+
folder, _head = branch_checkout
|
|
306
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES", "acme/tools")
|
|
307
|
+
assert _reason(_create_tool(folder)) is None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_should_allow_a_merge_pinned_to_the_head_reviewed_clean_before_open() -> None:
|
|
311
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
312
|
+
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
313
|
+
command = f"gh pr merge 12 --repo acme/tools --match-head-commit {CLEAN_HEAD[:7]}"
|
|
314
|
+
assert _reason(_bash(command)) is None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_should_deny_a_merge_pinned_past_the_head_reviewed_before_open() -> None:
|
|
318
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
319
|
+
assert "no clean review" in _reason(_merge_tool(NEW_HEAD))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_should_deny_an_unpinned_merge_with_only_an_open_review() -> None:
|
|
323
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
324
|
+
assert "no clean review" in _reason(_merge_tool())
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_should_not_accept_an_open_review_from_another_repository() -> None:
|
|
328
|
+
merge_review_receipt.record_open_review("acme/other", CLEAN_HEAD, 0)
|
|
329
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_should_accept_an_open_review_recorded_under_the_old_owner() -> None:
|
|
333
|
+
merge_review_receipt.record_open_review("oldowner/tools", CLEAN_HEAD, 0)
|
|
334
|
+
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_should_deny_a_merge_whose_numbered_pass_found_findings_at_the_open_reviewed_head() -> None:
|
|
338
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
339
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2)
|
|
340
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_should_ignore_an_open_receipt_whose_head_is_not_a_commit(receipt_folder: Path) -> None:
|
|
344
|
+
heads_folder = receipt_folder / "heads"
|
|
345
|
+
heads_folder.mkdir()
|
|
346
|
+
(heads_folder / "acme__tools__a.json").write_text('{"head": "a", "findings": 0}', encoding="utf-8")
|
|
347
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_should_check_a_merge_chained_after_a_reviewed_open(
|
|
351
|
+
branch_checkout: tuple[Path, str],
|
|
352
|
+
) -> None:
|
|
353
|
+
folder, head = branch_checkout
|
|
354
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
355
|
+
command = (
|
|
356
|
+
"gh pr create --repo acme/tools --head feature --fill && "
|
|
357
|
+
f"gh pr merge 12 --repo acme/tools --match-head-commit {NEW_HEAD}"
|
|
358
|
+
)
|
|
359
|
+
assert "no clean review" in _reason(_bash_in(folder, command))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_should_read_the_pushed_branch_before_the_local_one(
|
|
363
|
+
branch_checkout: tuple[Path, str],
|
|
364
|
+
) -> None:
|
|
365
|
+
folder, local_head = branch_checkout
|
|
366
|
+
_git(
|
|
367
|
+
folder,
|
|
368
|
+
"-c",
|
|
369
|
+
"user.name=t",
|
|
370
|
+
"-c",
|
|
371
|
+
"user.email=t@example.com",
|
|
372
|
+
"commit",
|
|
373
|
+
"--quiet",
|
|
374
|
+
"--allow-empty",
|
|
375
|
+
"-m",
|
|
376
|
+
"pushed",
|
|
377
|
+
)
|
|
378
|
+
pushed_head = _git(folder, "rev-parse", "HEAD")
|
|
379
|
+
_git(folder, "update-ref", "refs/remotes/origin/feature", pushed_head)
|
|
380
|
+
_git(folder, "reset", "--quiet", "--hard", local_head)
|
|
381
|
+
merge_review_receipt.record_open_review("acme/tools", local_head, 0)
|
|
382
|
+
assert f"head {pushed_head}" in _reason(_create_tool(folder))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_should_not_match_an_open_receipt_on_a_short_pinned_head() -> None:
|
|
386
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
387
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD[:3]))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_should_allow_an_open_in_an_exempt_repository_with_an_unread_head(
|
|
391
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
392
|
+
) -> None:
|
|
393
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES", "acme/tools")
|
|
394
|
+
assert _reason(_create_tool(tmp_path, head="missing-branch")) is None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_should_skip_options_quoted_inside_the_title_or_body(
|
|
398
|
+
branch_checkout: tuple[Path, str],
|
|
399
|
+
) -> None:
|
|
400
|
+
folder, head = branch_checkout
|
|
401
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
402
|
+
command = (
|
|
403
|
+
"gh pr create --repo acme/tools --title 'use -R other/x' "
|
|
404
|
+
f"--body \"curl -H 'Accept: x'; -H {NEW_HEAD}\" --head feature"
|
|
405
|
+
)
|
|
406
|
+
assert _reason(_bash_in(folder, command)) is None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_should_read_the_head_in_the_folder_a_cd_moves_to(
|
|
410
|
+
branch_checkout: tuple[Path, str], tmp_path: Path
|
|
411
|
+
) -> None:
|
|
412
|
+
folder, head = branch_checkout
|
|
413
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
414
|
+
elsewhere = tmp_path / "elsewhere"
|
|
415
|
+
elsewhere.mkdir()
|
|
416
|
+
assert _reason(_bash_in(elsewhere, f'cd "{folder}" && gh pr create --fill')) is None
|
|
417
|
+
assert "names no repository or head commit" in _reason(
|
|
418
|
+
_bash_in(folder, f'cd "{elsewhere}" && gh pr create --repo acme/tools --fill')
|
|
419
|
+
)
|
|
@@ -43,13 +43,32 @@ TOOL_RESULT_ENTRY = {
|
|
|
43
43
|
},
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
REFUSED_TOOL_RESULT_ENTRY = {
|
|
47
|
+
"type": "user",
|
|
48
|
+
"message": {
|
|
49
|
+
"content": [
|
|
50
|
+
{
|
|
51
|
+
"type": "tool_result",
|
|
52
|
+
"tool_use_id": "toolu_1",
|
|
53
|
+
"content": "<tool_use_error>Skill verify cannot be used with Skill tool</tool_use_error>",
|
|
54
|
+
"is_error": True,
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
|
|
47
61
|
def _skill_entry(skill_name: str) -> dict[str, object]:
|
|
48
62
|
return {
|
|
49
63
|
"type": "assistant",
|
|
50
64
|
"message": {
|
|
51
65
|
"content": [
|
|
52
|
-
{
|
|
66
|
+
{
|
|
67
|
+
"type": "tool_use",
|
|
68
|
+
"id": "toolu_1",
|
|
69
|
+
"name": "Skill",
|
|
70
|
+
"input": {"skill": skill_name},
|
|
71
|
+
}
|
|
53
72
|
]
|
|
54
73
|
},
|
|
55
74
|
}
|
|
@@ -197,6 +216,33 @@ def test_docs_only_commit_passes(repository: Path, tmp_path: Path) -> None:
|
|
|
197
216
|
)
|
|
198
217
|
|
|
199
218
|
|
|
219
|
+
def test_data_files_under_docs_folder_pass(repository: Path, tmp_path: Path) -> None:
|
|
220
|
+
(repository / "docs" / "records").mkdir(parents=True)
|
|
221
|
+
(repository / "docs" / "records" / "proof.json").write_text("{}\n", encoding="utf-8")
|
|
222
|
+
(repository / "docs" / "ruleset.yaml").write_text("rules: []\n", encoding="utf-8")
|
|
223
|
+
_git(repository, "add", "-A")
|
|
224
|
+
assert not _is_denied(
|
|
225
|
+
_payload("git commit -m records", repository, _unverified(tmp_path))
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_code_under_docs_folder_is_denied(repository: Path, tmp_path: Path) -> None:
|
|
230
|
+
(repository / "docs").mkdir()
|
|
231
|
+
(repository / "docs" / "conf.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
232
|
+
_git(repository, "add", "-A")
|
|
233
|
+
assert _is_denied(
|
|
234
|
+
_payload("git commit -m conf", repository, _unverified(tmp_path))
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_json_outside_docs_folder_is_denied(repository: Path, tmp_path: Path) -> None:
|
|
239
|
+
(repository / "config.json").write_text("{}\n", encoding="utf-8")
|
|
240
|
+
_git(repository, "add", "-A")
|
|
241
|
+
assert _is_denied(
|
|
242
|
+
_payload("git commit -m config", repository, _unverified(tmp_path))
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
200
246
|
def test_tests_only_commit_passes(repository: Path, tmp_path: Path) -> None:
|
|
201
247
|
(repository / "test_app.py").write_text(
|
|
202
248
|
"def test_value() -> None:\n pass\n", encoding="utf-8"
|
|
@@ -219,6 +265,15 @@ def test_docs_plus_code_commit_is_denied(repository: Path, tmp_path: Path) -> No
|
|
|
219
265
|
)
|
|
220
266
|
|
|
221
267
|
|
|
268
|
+
def test_refused_verify_skill_call_is_denied(repository: Path, tmp_path: Path) -> None:
|
|
269
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
270
|
+
_git(repository, "add", "app.py")
|
|
271
|
+
transcript_path = _transcript(
|
|
272
|
+
tmp_path, PROMPT_ENTRY, _skill_entry("verify"), REFUSED_TOOL_RESULT_ENTRY
|
|
273
|
+
)
|
|
274
|
+
assert _is_denied(_payload("git commit -m change", repository, transcript_path))
|
|
275
|
+
|
|
276
|
+
|
|
222
277
|
def test_verify_skill_call_after_the_last_prompt_passes(
|
|
223
278
|
repository: Path, tmp_path: Path
|
|
224
279
|
) -> None:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Behavioral tests for reading a pull request open from a PreToolUse payload.
|
|
2
|
+
|
|
3
|
+
gh pr create --repo acme/tools --title "--head evil" -> acme/tools, quoted option ignored
|
|
4
|
+
mcp create_pull_request {owner, repo} -> acme/tools, no head
|
|
5
|
+
gh pr merge 12 -> no open
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from blocking.merge_review_gate_parts.open_request import OpenRequest, open_request_from
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _shell_payload(command: str, folder: Path) -> dict[str, object]:
|
|
16
|
+
return {"tool_name": "Bash", "tool_input": {"command": command}, "cwd": str(folder)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_should_read_the_repository_and_ignore_options_quoted_in_the_title(tmp_path: Path) -> None:
|
|
20
|
+
payload = _shell_payload('gh pr create --repo acme/tools --title "--head evil"', tmp_path)
|
|
21
|
+
assert open_request_from(payload) == OpenRequest(owner="acme", repo="tools", head=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_should_read_the_create_tool_with_no_head_branch(tmp_path: Path) -> None:
|
|
25
|
+
payload = {
|
|
26
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
27
|
+
"tool_input": {"owner": "acme", "repo": "tools"},
|
|
28
|
+
"cwd": str(tmp_path),
|
|
29
|
+
}
|
|
30
|
+
assert open_request_from(payload) == OpenRequest(owner="acme", repo="tools", head=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_should_return_none_for_a_command_that_opens_nothing(tmp_path: Path) -> None:
|
|
34
|
+
assert open_request_from(_shell_payload("gh pr merge 12", tmp_path)) is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_should_name_the_slug_from_owner_and_repo() -> None:
|
|
38
|
+
assert OpenRequest(owner="acme", repo="tools", head=None).slug == "acme/tools"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Behavioral tests for the merge review receipt readers.
|
|
2
|
+
|
|
3
|
+
open receipt {head: <full sha>, findings: 0}, head <short sha> -> clean
|
|
4
|
+
open receipt {head: <full sha>, findings: 1}, head <short sha> -> not clean
|
|
5
|
+
numbered receipt path -> lowercased owner and repo
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from blocking.merge_review_gate_parts.receipts import (
|
|
15
|
+
has_clean_open_review,
|
|
16
|
+
open_receipt_path,
|
|
17
|
+
receipt_path,
|
|
18
|
+
same_commit,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def receipt_folder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
26
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_RECEIPT_DIR", str(tmp_path))
|
|
27
|
+
return tmp_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _write_open_receipt(finding_count: int) -> None:
|
|
31
|
+
path = open_receipt_path("acme", "tools", HEAD)
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path.write_text(json.dumps({"head": HEAD, "findings": finding_count}), encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_should_find_a_clean_open_review_by_short_head_under_any_owner() -> None:
|
|
37
|
+
_write_open_receipt(0)
|
|
38
|
+
assert has_clean_open_review("Tools", HEAD[:7]) is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_reject_an_open_review_that_counts_findings() -> None:
|
|
42
|
+
_write_open_receipt(1)
|
|
43
|
+
assert has_clean_open_review("tools", HEAD[:7]) is False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_should_reject_a_head_that_is_not_a_commit() -> None:
|
|
47
|
+
_write_open_receipt(0)
|
|
48
|
+
assert has_clean_open_review("tools", "main") is False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_should_lowercase_owner_and_repo_in_the_numbered_receipt_path(receipt_folder: Path) -> None:
|
|
52
|
+
assert receipt_path("Acme", "Tools", 12) == receipt_folder / "acme__tools__12.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_should_match_commits_by_prefix_in_either_order() -> None:
|
|
56
|
+
assert same_commit(HEAD[:7].upper(), HEAD) is True
|
|
57
|
+
assert same_commit(HEAD, "def5678") is False
|
|
@@ -33,6 +33,8 @@ from hooks_constants.verify_before_commit_gate_constants import (
|
|
|
33
33
|
ALL_ADD_EVERYTHING_OPTIONS,
|
|
34
34
|
ALL_COMMIT_ALL_OPTIONS,
|
|
35
35
|
ALL_COMMIT_VALUED_SHORT_FLAGS,
|
|
36
|
+
ALL_DOCS_DATA_EXTENSIONS,
|
|
37
|
+
ALL_DOCS_DIRECTORY_NAMES,
|
|
36
38
|
ALL_DOCS_EXTENSIONS,
|
|
37
39
|
ALL_GIT_EXECUTABLES,
|
|
38
40
|
ALL_SLASH_COMMAND_MARKERS,
|
|
@@ -176,7 +178,12 @@ def _is_docs_or_test_path(repository_path: str) -> bool:
|
|
|
176
178
|
path = PurePosixPath(repository_path)
|
|
177
179
|
if path.suffix.lower() in ALL_DOCS_EXTENSIONS or path.name in ALL_TEST_FILE_NAMES:
|
|
178
180
|
return True
|
|
179
|
-
|
|
181
|
+
all_directory_parts = path.parts[:-1]
|
|
182
|
+
if path.suffix.lower() in ALL_DOCS_DATA_EXTENSIONS and any(
|
|
183
|
+
each_part in ALL_DOCS_DIRECTORY_NAMES for each_part in all_directory_parts
|
|
184
|
+
):
|
|
185
|
+
return True
|
|
186
|
+
if any(each_part in ALL_TEST_DIRECTORY_NAMES for each_part in all_directory_parts):
|
|
180
187
|
return True
|
|
181
188
|
return (
|
|
182
189
|
path.name.startswith(ALL_TEST_FILE_PREFIXES)
|
|
@@ -245,6 +252,9 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
245
252
|
git commit, code staged, no verify since the last prompt -> DENY_REASON
|
|
246
253
|
git add app.py; git commit, empty index, no verify -> DENY_REASON
|
|
247
254
|
git commit, only docs or test files -> None
|
|
255
|
+
git commit, only data files (.json, .jsonl, .yaml, .yml,
|
|
256
|
+
.csv) under a docs folder -> None
|
|
257
|
+
git commit, a .py file under a docs folder -> DENY_REASON
|
|
248
258
|
git commit after a verify Skill call or a /verify prompt -> None
|
|
249
259
|
git commit in a repository with no verify skill directory -> None
|
|
250
260
|
transcript missing or unreadable, or git fails -> None
|
|
@@ -18,9 +18,9 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
18
18
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
19
19
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
20
20
|
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on that same thread that posted after the last user prompt and that no successful resolve of the thread has spent. A resolve is a `resolve_review_thread` call, which names the thread by `threadID`; a `pull_request_review_write` call with method `resolve_thread`, which names it by `threadId`; a shell command with a `gh api graphql` `resolveReviewThread` mutation, which names it by a literal `PRRT_` id; or a shell call to `pulls/<n>/ccr/comments/<id>/resolve`, which names it by a comment id. A graphql resolve that names no `PRRT_` id is denied. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`, which names its thread by `PRRT_` id. A reply and a resolve by comment id match when they name the same comment, or when a successful tool result in the transcript, such as `pull_request_read` `get_review_comments`, lists the comment under the thread by its `discussion_r` anchor or `databaseId`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
|
|
21
|
-
- `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or
|
|
21
|
+
- `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call whose result comes back without an error, or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, test files, or `.json`, `.jsonl`, `.yaml`, `.yml`, or `.csv` files under a `docs` folder.
|
|
22
22
|
- `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
|
|
23
|
-
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
|
|
23
|
+
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes. It also denies a pull request open, through `gh pr create`, `pull_request.py create` or a `create_pull_request` tool, until a pass recorded as `merge_review_receipt.py <owner/name> open <head> 0` covers the head the pull request opens with. A merge pinned to that same head passes on the open receipt.
|
|
24
24
|
- `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
|
|
25
25
|
- `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
|
|
26
26
|
- `blocking/send_user_file_worktree_root_gate.py` denies a `SendUserFile` call that names a file outside the session's working directory, the `cwd` in the hook payload. The desktop file pane opens a sent file only under that folder and shows "Couldn't find this file" for any other path. The reason lists each outside file and names `<cwd>\.scratch\` as the folder to copy it into.
|
|
@@ -43,7 +43,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
43
43
|
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__resolve_review_thread|mcp__.*__pull_request_review_write`, timeout `10` seconds in `hooks.json`.
|
|
44
44
|
- `blocking/verify_before_commit_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell`, timeout `30` seconds in `hooks.json`.
|
|
45
45
|
- `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
|
|
46
|
-
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
46
|
+
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
47
47
|
- `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
|
|
48
48
|
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
49
49
|
- `blocking/send_user_file_worktree_root_gate.py` runs on `PreToolUse`, matcher `SendUserFile`, timeout `10` seconds in `hooks.json`.
|
|
@@ -68,9 +68,9 @@ Preconditions:
|
|
|
68
68
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
69
69
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
70
70
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
71
|
-
- **Verify before commit.** Input is `git add app.py; git commit -m change` in a temporary repository with `.agents/skills/verify`, and a transcript with no `verify` load after its last prompt. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_commit_gate.py -q`. The adjacent test observes a denial, then a silent allow after a `verify` Skill call or a `/verify` prompt. A load before the last prompt still denies. A docs-only commit, a tests-only commit, and a repository with no verify skill pass.
|
|
71
|
+
- **Verify before commit.** Input is `git add app.py; git commit -m change` in a temporary repository with `.agents/skills/verify`, and a transcript with no `verify` load after its last prompt. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_commit_gate.py -q`. The adjacent test observes a denial, then a silent allow after a `verify` Skill call or a `/verify` prompt. A load before the last prompt still denies. A docs-only commit, a commit of data files under a `docs` folder, a tests-only commit, and a repository with no verify skill pass. A `.py` file under `docs` and a `.json` file outside `docs` still deny.
|
|
72
72
|
- **Eval write.** Input is a Write to `evals/cases.json` with no build-eval call in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_build_eval_before_eval_write_gate.py -q`. The adjacent test observes a denial, then an allow once the call appears; a write outside an eval path passes.
|
|
73
|
-
- **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass.
|
|
73
|
+
- **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass. An open with no clean open receipt at its head is denied; an open reviewed clean at its head, and a merge pinned to that head, pass.
|
|
74
74
|
- **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
|
|
75
75
|
- **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
|
|
76
76
|
- **Sent file root.** Input is a `SendUserFile` call for a file on `Y:\` or in the session scratchpad, with `cwd` set to a worktree. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_send_user_file_worktree_root_gate.py -q`. The adjacent test observes a denial that names the file and `<cwd>\.scratch\`; a file under `<cwd>\.scratch\` passes.
|
package/hooks/hooks.json
CHANGED
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
]
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
|
-
"matcher": "Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)",
|
|
136
|
+
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge)",
|
|
137
137
|
"hooks": [
|
|
138
138
|
{
|
|
139
139
|
"type": "command",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Named constants for merge_review_gate, the clean-review merge gate."""
|
|
1
|
+
"""Named constants for merge_review_gate, the clean-review open and merge gate."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -10,12 +10,39 @@ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
|
10
10
|
|
|
11
11
|
MERGE_TOOL_SUFFIX = "__merge_pull_request"
|
|
12
12
|
AUTO_MERGE_TOOL_SUFFIX = "__enable_pr_auto_merge"
|
|
13
|
+
CREATE_TOOL_SUFFIX = "__create_pull_request"
|
|
13
14
|
ALL_SHELL_TOOL_NAMES = frozenset({"Bash", "PowerShell"})
|
|
14
15
|
|
|
15
16
|
OWNER_FIELD = "owner"
|
|
16
17
|
REPO_FIELD = "repo"
|
|
17
18
|
NUMBER_FIELD = "pullNumber"
|
|
18
19
|
EXPECTED_HEAD_FIELD = "expectedHeadSha"
|
|
20
|
+
HEAD_BRANCH_FIELD = "head"
|
|
21
|
+
CWD_FIELD = "cwd"
|
|
22
|
+
|
|
23
|
+
GH_PR_CREATE_PATTERN = re.compile(r"(?:^|[\s;&|(])gh(?:\.exe)?\s+pr\s+create\b(?P<rest>[^;&|]*)")
|
|
24
|
+
PULL_REQUEST_SCRIPT_CREATE_PATTERN = re.compile(
|
|
25
|
+
r"pull_request\.py[\"']?\s+create\b(?P<rest>[^;&|]*)"
|
|
26
|
+
)
|
|
27
|
+
HEAD_OPTION_PATTERN = re.compile(r"(?<!\S)(?:-H|--head)(?:=|\s+)[\"']?(?P<head>[^\s\"']+)")
|
|
28
|
+
CHANGE_FOLDER_PATTERN = re.compile(
|
|
29
|
+
r"(?:^|[;&|(]\s*)(?:cd|pushd|Set-Location|sl)\s+(?:-Path\s+|-LiteralPath\s+)?"
|
|
30
|
+
r"(?P<folder>\"[^\"]*\"|'[^']*'|[^\s;&|)]+)",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
CREATE_TEXT_OPTION_PATTERN = re.compile(
|
|
34
|
+
r"(?<!\S)(?:-t|--title|-b|--body|-F|--body-file)(?:=|\s+)(?:\"[^\"]*\"|'[^']*'|\S+)"
|
|
35
|
+
)
|
|
36
|
+
ORIGIN_URL_PATTERN = re.compile(
|
|
37
|
+
r"github\.com[:/](?P<owner>[\w.-]+)/(?P<repo>[\w.-]+?)(?:\.git)?/?$"
|
|
38
|
+
)
|
|
39
|
+
GIT_TIMEOUT_SECONDS = 3
|
|
40
|
+
ALL_ORIGIN_URL_ARGUMENTS = ("remote", "get-url", "origin")
|
|
41
|
+
OPEN_HEAD_PATTERN = re.compile(r"[0-9a-fA-F]{7,40}")
|
|
42
|
+
OPEN_RECEIPT_HEAD_PATTERN = re.compile(r"[0-9a-fA-F]{40}")
|
|
43
|
+
REPOSITORY_SLUG_PATTERN = re.compile(r"[\w.-]+/[\w.-]+")
|
|
44
|
+
REMOTE_BRANCH_PREFIX = "origin/"
|
|
45
|
+
CURRENT_HEAD_REFERENCE = "HEAD"
|
|
19
46
|
|
|
20
47
|
GH_PR_MERGE_PATTERN = re.compile(r"(?:^|[\s;&|(])gh(?:\.exe)?\s+pr\s+merge\b(?P<rest>[^;&|]*)")
|
|
21
48
|
GH_API_MERGE_PATTERN = re.compile(
|
|
@@ -44,6 +71,10 @@ DEFAULT_REVIEW_COMMAND = "/code-review high --max-findings all"
|
|
|
44
71
|
RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE = "CLAUDE_MERGE_REVIEW_RECEIPT_DIR"
|
|
45
72
|
ALL_RECEIPT_DIRECTORY_SEGMENTS = (".claude", "merge-reviews")
|
|
46
73
|
RECEIPT_FILE_NAME_TEMPLATE = "{owner}__{repo}__{number}.json"
|
|
74
|
+
OPEN_RECEIPT_DIRECTORY_NAME = "heads"
|
|
75
|
+
OPEN_RECEIPT_FILE_NAME_TEMPLATE = "{owner}__{repo}__{head}.json"
|
|
76
|
+
OPEN_RECEIPT_GLOB_TEMPLATE = "*__{repo}__*.json"
|
|
77
|
+
OPEN_TARGET_WORD = "open"
|
|
47
78
|
REPOSITORY_SEPARATOR = ","
|
|
48
79
|
RECEIPT_HEAD_KEY = "head"
|
|
49
80
|
RECEIPT_FINDINGS_KEY = "findings"
|
|
@@ -84,3 +115,17 @@ UNKNOWN_TARGET_REASON = (
|
|
|
84
115
|
"This merge names no repository and pull request number the review gate can read. "
|
|
85
116
|
"Name both, such as `gh pr merge 12 --repo owner/name --match-head-commit <sha>`."
|
|
86
117
|
)
|
|
118
|
+
NO_CLEAN_OPEN_REVIEW_REASON_TEMPLATE = (
|
|
119
|
+
"{slug} has no clean review for head {head}, which this pull request would open with. "
|
|
120
|
+
"Review the branch diff with `{review_command}`, fix and push every finding, and review "
|
|
121
|
+
"again until a pass reports zero findings. Record that pass with `"
|
|
122
|
+
+ RECORD_COMMAND
|
|
123
|
+
+ " {slug} "
|
|
124
|
+
+ OPEN_TARGET_WORD
|
|
125
|
+
+ " {head} 0`, then open the pull request. The merge accepts that receipt while the "
|
|
126
|
+
"head stays {head}."
|
|
127
|
+
)
|
|
128
|
+
UNKNOWN_OPEN_TARGET_REASON = (
|
|
129
|
+
"This pull request names no repository or head commit the review gate can read. Run it "
|
|
130
|
+
"from the branch checkout, or pass `--repo owner/name` and `--head <pushed branch>`."
|
|
131
|
+
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
DENY_REASON = (
|
|
4
4
|
"This repository has a verify skill, and this commit changes code. Invoke the verify "
|
|
5
5
|
"skill with the Skill tool, run the check it routes you to, then run the same command again. "
|
|
6
|
-
"A commit that changes only docs or tests passes without it."
|
|
6
|
+
"A commit that changes only docs, data files under a docs folder, or tests passes without it."
|
|
7
7
|
)
|
|
8
8
|
SKILL_NAME = "verify"
|
|
9
9
|
ALL_SLASH_COMMAND_MARKERS = ("<command-name>/verify</command-name>",)
|
|
@@ -47,6 +47,8 @@ ALL_DOCS_EXTENSIONS = frozenset(
|
|
|
47
47
|
".ico",
|
|
48
48
|
}
|
|
49
49
|
)
|
|
50
|
+
ALL_DOCS_DIRECTORY_NAMES = frozenset({"docs"})
|
|
51
|
+
ALL_DOCS_DATA_EXTENSIONS = frozenset({".json", ".jsonl", ".yaml", ".yml", ".csv"})
|
|
50
52
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests", "test", "__tests__"})
|
|
51
53
|
ALL_TEST_FILE_PREFIXES = ("test_",)
|
|
52
54
|
ALL_TEST_FILE_SUFFIXES = ("_test.py",)
|
|
@@ -18,9 +18,14 @@ from transcript_skill_scan import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def _skill_entry(name: str) -> str:
|
|
21
|
+
def _skill_entry(name: str, tool_use_id: str = "toolu_skill") -> str:
|
|
22
22
|
return json.dumps(
|
|
23
|
-
{
|
|
23
|
+
{
|
|
24
|
+
"type": "assistant",
|
|
25
|
+
"message": {
|
|
26
|
+
"content": [{"type": "tool_use", "id": tool_use_id, "name": "Skill", "input": {"skill": name}}]
|
|
27
|
+
},
|
|
28
|
+
}
|
|
24
29
|
)
|
|
25
30
|
|
|
26
31
|
|
|
@@ -144,12 +149,40 @@ def _prompt_entry(content: object) -> str:
|
|
|
144
149
|
|
|
145
150
|
|
|
146
151
|
def test_skill_since_last_prompt_survives_tool_results_and_skill_bodies() -> None:
|
|
147
|
-
tool_result = _prompt_entry(
|
|
152
|
+
tool_result = _prompt_entry(
|
|
153
|
+
[{"type": "tool_result", "tool_use_id": "toolu_skill", "content": "Launching skill: verify"}]
|
|
154
|
+
)
|
|
148
155
|
skill_body = json.dumps({"type": "user", "isMeta": True, "message": {"content": "Base directory for this skill"}})
|
|
149
156
|
lines = [_prompt_entry("Commit it."), _skill_entry("verify"), tool_result, skill_body]
|
|
150
157
|
assert is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|
|
151
158
|
|
|
152
159
|
|
|
160
|
+
def test_refused_skill_load_leaves_the_skill_unloaded() -> None:
|
|
161
|
+
refused_result = _prompt_entry(
|
|
162
|
+
[
|
|
163
|
+
{
|
|
164
|
+
"type": "tool_result",
|
|
165
|
+
"tool_use_id": "toolu_skill",
|
|
166
|
+
"content": "<tool_use_error>Skill verify cannot be used with Skill tool</tool_use_error>",
|
|
167
|
+
"is_error": True,
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
lines = [_prompt_entry("Commit it."), _skill_entry("verify"), refused_result]
|
|
172
|
+
assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_skill_call_with_no_result_yet_leaves_the_skill_unloaded() -> None:
|
|
176
|
+
lines = [_prompt_entry("Commit it."), _skill_entry("verify")]
|
|
177
|
+
assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_result_for_another_call_leaves_the_skill_unloaded() -> None:
|
|
181
|
+
other_result = _prompt_entry([{"type": "tool_result", "tool_use_id": "toolu_other", "content": "done"}])
|
|
182
|
+
lines = [_prompt_entry("Commit it."), _skill_entry("verify"), other_result]
|
|
183
|
+
assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|
|
184
|
+
|
|
185
|
+
|
|
153
186
|
def test_next_prompt_drops_a_skill_loaded_in_the_last_turn() -> None:
|
|
154
187
|
lines = [_prompt_entry("First."), _skill_entry("verify"), _prompt_entry([{"type": "text", "text": "Second."}])]
|
|
155
188
|
assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|