claude-dev-env 8.71.1 → 8.72.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/build-eval/SKILL.md +3 -0
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +1 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +9 -1
- package/hooks/blocking/headless_claude_broker_gate.py +14 -1
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +2 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +46 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -3
- package/hooks/conftest.py +18 -0
- package/hooks/features/session-context.md +1 -3
- package/hooks/features/write-edit-blocking.md +3 -6
- package/hooks/hooks.json +0 -5
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +7 -1
- package/hooks/hooks_constants/mod_handoff.py +91 -0
- package/hooks/hooks_constants/mod_handoff_constants.py +38 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -1
- package/hooks/hooks_constants/test_mod_handoff.py +100 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +2 -23
- package/hooks/session/advisor_rules_prompt.py +5 -1
- package/hooks/session/orchestrator_auto_starter.py +5 -1
- package/hooks/session/task_tool_prompt.py +5 -1
- package/hooks/session/test_advisor_rules_prompt.py +11 -0
- package/hooks/session/test_orchestrator_auto_starter.py +22 -0
- package/hooks/session/test_task_tool_prompt.py +15 -0
- package/hooks/session/test_working_style_prompt.py +15 -0
- package/hooks/session/working_style_prompt.py +5 -1
- package/package.json +1 -1
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +24 -0
- package/scripts/review_closure_model.py +48 -12
- package/scripts/test_files/review_closure/codex_comments.json +1266 -0
- package/scripts/tests/test_review_closure_model.py +130 -0
- package/hooks/advisory/migration_safety_advisor.py +0 -74
- package/hooks/advisory/test_migration_safety_advisor.py +0 -204
- package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +0 -22
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +0 -7
- package/hooks/session/issue_tracker_session_starter.py +0 -136
- package/hooks/session/test_issue_tracker_session_starter.py +0 -106
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
3
5
|
import sys
|
|
4
6
|
from datetime import datetime
|
|
5
7
|
from pathlib import Path
|
|
@@ -10,6 +12,7 @@ SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
|
|
|
10
12
|
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
11
13
|
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
12
14
|
|
|
15
|
+
import review_closure as command
|
|
13
16
|
import review_closure_model as model
|
|
14
17
|
from dev_env_scripts_constants.review_closure_constants import (
|
|
15
18
|
APPROVALS_CHECK_NAME,
|
|
@@ -567,3 +570,130 @@ def should_read_a_person_carrying_a_repository_marker_as_a_comment() -> None:
|
|
|
567
570
|
)
|
|
568
571
|
|
|
569
572
|
assert not comment.is_notice
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
CASES = json.loads(
|
|
576
|
+
(Path(__file__).resolve().parents[1] / "test_files/review_closure/codex_comments.json")
|
|
577
|
+
.read_text(encoding="utf-8")
|
|
578
|
+
)
|
|
579
|
+
DRIVERS = frozenset({CASES[0]["driver_login"]})
|
|
580
|
+
SLUG = "jl-cmd-projects/claude-dev-env"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _comments(case: dict) -> tuple[model.TopLevelComment, ...]:
|
|
584
|
+
return tuple(model.parse_top_level_comment(record) for record in case["comments"])
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@pytest.mark.parametrize("case", CASES, ids=lambda case: str(case["number"]))
|
|
588
|
+
def should_replay_clean_queue_comments_through_the_command(
|
|
589
|
+
case: dict, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
590
|
+
) -> None:
|
|
591
|
+
pr = {"number": case["number"], "head": {"sha": case["head"]},
|
|
592
|
+
"user": {"login": next(iter(DRIVERS))}}
|
|
593
|
+
monkeypatch.setattr(command, "github_token", lambda: "fixture-token")
|
|
594
|
+
monkeypatch.setattr(command, "read_pull_request", lambda *_: pr)
|
|
595
|
+
monkeypatch.setattr(command, "read_review_threads", lambda *_: ())
|
|
596
|
+
monkeypatch.setattr(command, "read_top_level_comments", lambda *_: _comments(case))
|
|
597
|
+
monkeypatch.setattr(command, "read_approvals_conclusion", lambda *_: None)
|
|
598
|
+
|
|
599
|
+
assert command.main([SLUG, str(case["number"])]) == 0
|
|
600
|
+
assert capsys.readouterr().out.startswith(f"CLOSED {SLUG}#{case['number']}")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@pytest.mark.parametrize("body", ["@codex review", "@codex security review"])
|
|
604
|
+
def should_treat_a_command_only_request_as_a_notice(body: str) -> None:
|
|
605
|
+
record = copy.deepcopy(CASES[0]["comments"][0])
|
|
606
|
+
record["body"] = body
|
|
607
|
+
|
|
608
|
+
assert model.parse_top_level_comment(record).is_notice
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def should_not_credit_a_driver_review_request_as_a_bug_reply() -> None:
|
|
612
|
+
request = copy.deepcopy(CASES[0]["comments"][0])
|
|
613
|
+
request["user"]["login"] = next(iter(DRIVERS))
|
|
614
|
+
bug = copy.deepcopy(request)
|
|
615
|
+
bug["user"]["login"] = "maintainer"
|
|
616
|
+
bug["body"] = "The rollback loses the saved profile."
|
|
617
|
+
bug["created_at"] = bug["updated_at"] = "2026-10-06T11:00:00Z"
|
|
618
|
+
comments = tuple(model.parse_top_level_comment(record) for record in (bug, request))
|
|
619
|
+
|
|
620
|
+
assert len(model.top_level_findings(comments, DRIVERS)) == 1
|
|
621
|
+
assert model.latest_driver_comment_time(comments, DRIVERS) is None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@pytest.mark.parametrize("user_type,login", [
|
|
625
|
+
("User", "chatgpt-codex-connector[bot]"),
|
|
626
|
+
("Bot", "untrusted[bot]"),
|
|
627
|
+
("User", "maintainer"),
|
|
628
|
+
])
|
|
629
|
+
@pytest.mark.parametrize("record_index", [1, 2])
|
|
630
|
+
@pytest.mark.parametrize("case", CASES, ids=lambda case: str(case["number"]))
|
|
631
|
+
def should_retain_a_notice_pasted_by_an_untrusted_author(
|
|
632
|
+
user_type: str, login: str, record_index: int, case: dict
|
|
633
|
+
) -> None:
|
|
634
|
+
record = copy.deepcopy(case["comments"][record_index])
|
|
635
|
+
record["user"] = {"type": user_type, "login": login}
|
|
636
|
+
comment = model.parse_top_level_comment(record)
|
|
637
|
+
|
|
638
|
+
assert not comment.is_notice
|
|
639
|
+
assert len(model.top_level_findings((comment,), DRIVERS)) == 1
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@pytest.mark.parametrize("record_index", [0, 1, 2])
|
|
643
|
+
@pytest.mark.parametrize("case", CASES, ids=lambda case: str(case["number"]))
|
|
644
|
+
def should_retain_a_command_or_notice_with_an_appended_finding(record_index: int, case: dict) -> None:
|
|
645
|
+
record = copy.deepcopy(case["comments"][record_index])
|
|
646
|
+
record["body"] += "\n\n[P1] The rollback loses the saved profile."
|
|
647
|
+
comment = model.parse_top_level_comment(record)
|
|
648
|
+
|
|
649
|
+
assert not comment.is_notice
|
|
650
|
+
assert len(model.top_level_findings((comment,), DRIVERS)) == 1
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@pytest.mark.parametrize("case", CASES, ids=lambda case: str(case["number"]))
|
|
654
|
+
def should_retain_a_finding_inside_the_clean_review_sentence(case: dict) -> None:
|
|
655
|
+
record = copy.deepcopy(case["comments"][2])
|
|
656
|
+
record["body"] = record["body"].replace(
|
|
657
|
+
"Didn't find any major issues.", "Didn't find any major issues. [P1] The rollback loses a profile."
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
assert not model.parse_top_level_comment(record).is_notice
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def should_retain_a_finding_inside_pasted_notice_metadata() -> None:
|
|
664
|
+
record = copy.deepcopy(CASES[0]["comments"][2])
|
|
665
|
+
record["body"] = record["body"].replace(
|
|
666
|
+
"</details>", "[P1] The rollback loses the saved profile.\n</details>"
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
assert not model.parse_top_level_comment(record).is_notice
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def should_keep_an_inline_finding_open_beside_clean_notices() -> None:
|
|
673
|
+
thread = model.ReviewThread(
|
|
674
|
+
subject="bin/install.mjs", is_resolved=False, is_outdated=False,
|
|
675
|
+
all_comments=(model.ReviewComment("reviewer", "[P1] The rollback loses a profile."),),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
findings = model.all_open_findings((thread,), _comments(CASES[0]), DRIVERS, None)
|
|
679
|
+
|
|
680
|
+
assert len(findings) == 1
|
|
681
|
+
assert findings[0].subject == "bin/install.mjs"
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def should_keep_a_top_level_bug_open_after_a_stale_clean_verdict() -> None:
|
|
685
|
+
bug = copy.deepcopy(CASES[0]["comments"][0])
|
|
686
|
+
bug["user"]["login"] = "maintainer"
|
|
687
|
+
bug["body"] = "[P1] The rollback loses the saved profile."
|
|
688
|
+
clean = copy.deepcopy(CASES[0]["comments"][2])
|
|
689
|
+
clean["body"] = clean["body"].replace("e123a99375", "aaaaaaaaaa")
|
|
690
|
+
comments = tuple(model.parse_top_level_comment(record) for record in (bug, clean))
|
|
691
|
+
|
|
692
|
+
assert len(model.top_level_findings(comments, DRIVERS)) == 1
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def should_keep_a_blocking_approval_open_beside_clean_notices() -> None:
|
|
696
|
+
findings = model.all_open_findings((), _comments(CASES[0]), DRIVERS, "failure")
|
|
697
|
+
|
|
698
|
+
assert len(findings) == 1
|
|
699
|
+
assert findings[0].subject == "Claude Approvals"
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Advisory hook: warn when Django migrations contain unsafe operations."""
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import re
|
|
6
|
-
import sys
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
try:
|
|
10
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
11
|
-
if _hooks_dir not in sys.path:
|
|
12
|
-
sys.path.insert(0, _hooks_dir)
|
|
13
|
-
|
|
14
|
-
from hooks_constants.migration_safety_advisor_constants import (
|
|
15
|
-
MULTI_EDIT_TOOL_NAME,
|
|
16
|
-
)
|
|
17
|
-
from hooks_constants.multi_edit_reconstruction import joined_new_strings
|
|
18
|
-
except ImportError as import_error:
|
|
19
|
-
raise ImportError(
|
|
20
|
-
"migration_safety_advisor: cannot import its sibling modules; "
|
|
21
|
-
"ensure the hooks directory is importable."
|
|
22
|
-
) from import_error
|
|
23
|
-
|
|
24
|
-
MIGRATION_PATH_PATTERN = re.compile(r"[/\\]migrations[/\\]\d{4}_\w+\.py$")
|
|
25
|
-
UNSAFE_OPERATIONS = ["RemoveField", "RenameField", "DeleteModel", "RenameModel"]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _resolve_content(tool_name: str, all_tool_input: dict) -> str:
|
|
29
|
-
"""Return the text a Write, Edit, or MultiEdit payload introduces."""
|
|
30
|
-
if tool_name == MULTI_EDIT_TOOL_NAME:
|
|
31
|
-
return joined_new_strings(all_tool_input)
|
|
32
|
-
return all_tool_input.get("content", "") or all_tool_input.get("new_string", "")
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def main() -> None:
|
|
36
|
-
try:
|
|
37
|
-
hook_input = json.load(sys.stdin)
|
|
38
|
-
except json.JSONDecodeError:
|
|
39
|
-
sys.exit(0)
|
|
40
|
-
|
|
41
|
-
tool_name = hook_input.get("tool_name", "")
|
|
42
|
-
tool_input = hook_input.get("tool_input", {})
|
|
43
|
-
file_path = tool_input.get("file_path", "")
|
|
44
|
-
|
|
45
|
-
if not MIGRATION_PATH_PATTERN.search(file_path):
|
|
46
|
-
sys.exit(0)
|
|
47
|
-
|
|
48
|
-
content = _resolve_content(tool_name, tool_input)
|
|
49
|
-
found_unsafe = [op for op in UNSAFE_OPERATIONS if op in content]
|
|
50
|
-
|
|
51
|
-
if found_unsafe:
|
|
52
|
-
operations = ", ".join(found_unsafe)
|
|
53
|
-
advisory_message = (
|
|
54
|
-
f"MIGRATION SAFETY: Contains {operations}. "
|
|
55
|
-
"After launch, model changes stay backwards-compatible, because running "
|
|
56
|
-
"instances keep reading the old schema until the deployment finishes. "
|
|
57
|
-
"Check that this migration keeps those instances working."
|
|
58
|
-
)
|
|
59
|
-
advisory_payload = {
|
|
60
|
-
"systemMessage": advisory_message,
|
|
61
|
-
"hookSpecificOutput": {
|
|
62
|
-
"hookEventName": "PreToolUse",
|
|
63
|
-
"permissionDecision": "allow",
|
|
64
|
-
"permissionDecisionReason": advisory_message,
|
|
65
|
-
"additionalContext": advisory_message,
|
|
66
|
-
},
|
|
67
|
-
}
|
|
68
|
-
print(json.dumps(advisory_payload))
|
|
69
|
-
|
|
70
|
-
sys.exit(0)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if __name__ == "__main__":
|
|
74
|
-
main()
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for migration_safety_advisor through its production entry paths."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
_ADVISORY_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
-
_HOOKS_DIRECTORY = _ADVISORY_DIRECTORY.parent
|
|
13
|
-
_ADVISOR_SCRIPT = _ADVISORY_DIRECTORY / "migration_safety_advisor.py"
|
|
14
|
-
_DISPATCHER_SCRIPT = _HOOKS_DIRECTORY / "blocking" / "pre_tool_use_dispatcher.py"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _run_hook(script_path: Path, payload: dict[str, object]) -> dict[str, object] | None:
|
|
18
|
-
completed_process = subprocess.run(
|
|
19
|
-
[sys.executable, str(script_path)],
|
|
20
|
-
check=False,
|
|
21
|
-
input=json.dumps(payload),
|
|
22
|
-
capture_output=True,
|
|
23
|
-
text=True,
|
|
24
|
-
encoding="utf-8",
|
|
25
|
-
)
|
|
26
|
-
assert completed_process.returncode == 0
|
|
27
|
-
if not completed_process.stdout.strip():
|
|
28
|
-
return None
|
|
29
|
-
parsed_payload = json.loads(completed_process.stdout)
|
|
30
|
-
assert isinstance(parsed_payload, dict)
|
|
31
|
-
return parsed_payload
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _build_edit_payload(file_path: Path, new_string: str) -> dict[str, object]:
|
|
35
|
-
return {
|
|
36
|
-
"tool_name": "Edit",
|
|
37
|
-
"tool_input": {
|
|
38
|
-
"file_path": str(file_path),
|
|
39
|
-
"old_string": "operations = []",
|
|
40
|
-
"new_string": new_string,
|
|
41
|
-
},
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _build_control_payload(tool_name: str, file_path: Path, content: str) -> dict[str, object]:
|
|
46
|
-
if tool_name == "Write":
|
|
47
|
-
tool_input: dict[str, object] = {
|
|
48
|
-
"file_path": str(file_path),
|
|
49
|
-
"content": content,
|
|
50
|
-
}
|
|
51
|
-
else:
|
|
52
|
-
tool_input = {
|
|
53
|
-
"file_path": str(file_path),
|
|
54
|
-
"edits": [{"old_string": "operations = []", "new_string": content}],
|
|
55
|
-
}
|
|
56
|
-
return {"tool_name": tool_name, "tool_input": tool_input}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _prepare_fresh_test_pair(migration_path: Path) -> None:
|
|
60
|
-
"""Write a migration module and its paired test file."""
|
|
61
|
-
migration_path.parent.mkdir(parents=True)
|
|
62
|
-
migration_path.write_text("operations = []\n", encoding="utf-8")
|
|
63
|
-
test_path = migration_path.with_name(f"test_{migration_path.stem}.py")
|
|
64
|
-
test_path.write_text("def test_migration_contract():\n assert True\n", encoding="utf-8")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _permission_fields(parsed_payload: dict[str, object]) -> dict[str, object]:
|
|
68
|
-
hook_specific = parsed_payload.get("hookSpecificOutput", {})
|
|
69
|
-
assert isinstance(hook_specific, dict)
|
|
70
|
-
return hook_specific
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _text_field(field_by_key: dict[str, object], field_name: str) -> str:
|
|
74
|
-
field_value = field_by_key.get(field_name)
|
|
75
|
-
assert isinstance(field_value, str)
|
|
76
|
-
return field_value
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_standalone_advisor_warns_for_unsafe_migration_edit(tmp_path: Path) -> None:
|
|
80
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
81
|
-
payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
|
|
82
|
-
|
|
83
|
-
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
84
|
-
assert advisory_payload is not None
|
|
85
|
-
permission_fields = _permission_fields(advisory_payload)
|
|
86
|
-
|
|
87
|
-
assert permission_fields["permissionDecision"] == "allow"
|
|
88
|
-
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
89
|
-
assert "MIGRATION SAFETY" in _text_field(advisory_payload, "systemMessage")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def test_dispatcher_surfaces_migration_warning_for_edit(tmp_path: Path) -> None:
|
|
93
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
94
|
-
_prepare_fresh_test_pair(migration_path)
|
|
95
|
-
payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
|
|
96
|
-
|
|
97
|
-
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
98
|
-
assert dispatched_payload is not None
|
|
99
|
-
permission_fields = _permission_fields(dispatched_payload)
|
|
100
|
-
|
|
101
|
-
assert permission_fields["permissionDecision"] == "allow"
|
|
102
|
-
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
103
|
-
assert "MIGRATION SAFETY" in _text_field(dispatched_payload, "systemMessage")
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def test_dispatcher_keeps_migration_advisor_write_excluded(tmp_path: Path) -> None:
|
|
107
|
-
"""Write stays out of the migration advisor's roster; only Edit and MultiEdit reach it."""
|
|
108
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
109
|
-
_prepare_fresh_test_pair(migration_path)
|
|
110
|
-
payload = _build_control_payload(
|
|
111
|
-
"Write",
|
|
112
|
-
migration_path,
|
|
113
|
-
'operations = [migrations.RemoveField("name")]',
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
117
|
-
serialized_payload = json.dumps(dispatched_payload or {})
|
|
118
|
-
|
|
119
|
-
assert "MIGRATION SAFETY" not in serialized_payload
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def test_dispatcher_extends_migration_advisor_to_multi_edit(tmp_path: Path) -> None:
|
|
123
|
-
"""A MultiEdit reaches the migration advisor the same way an Edit does."""
|
|
124
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
125
|
-
_prepare_fresh_test_pair(migration_path)
|
|
126
|
-
payload = _build_control_payload(
|
|
127
|
-
"MultiEdit",
|
|
128
|
-
migration_path,
|
|
129
|
-
'operations = [migrations.RemoveField("name")]',
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
133
|
-
assert dispatched_payload is not None
|
|
134
|
-
serialized_payload = json.dumps(dispatched_payload)
|
|
135
|
-
|
|
136
|
-
assert "MIGRATION SAFETY" in serialized_payload
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _build_multi_edit_payload(
|
|
140
|
-
file_path: Path, all_edits: list[dict[str, str]]
|
|
141
|
-
) -> dict[str, object]:
|
|
142
|
-
return {
|
|
143
|
-
"tool_name": "MultiEdit",
|
|
144
|
-
"tool_input": {"file_path": str(file_path), "edits": all_edits},
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def test_standalone_advisor_warns_for_unsafe_operation_in_second_multi_edit(
|
|
149
|
-
tmp_path: Path,
|
|
150
|
-
) -> None:
|
|
151
|
-
"""An unsafe operation in the second edit of a MultiEdit is caught, not only the first.
|
|
152
|
-
|
|
153
|
-
migration_safety_advisor has no tool_name gate today: it reads
|
|
154
|
-
tool_input.get("new_string"), a key a MultiEdit payload never carries at
|
|
155
|
-
the top level. Widening its roster entry alone would silently do
|
|
156
|
-
nothing on a real MultiEdit call.
|
|
157
|
-
"""
|
|
158
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
159
|
-
payload = _build_multi_edit_payload(
|
|
160
|
-
migration_path,
|
|
161
|
-
[
|
|
162
|
-
{"old_string": "operations = []", "new_string": "operations = [migrations.AddField()]"},
|
|
163
|
-
{
|
|
164
|
-
"old_string": "migrations.AddField()",
|
|
165
|
-
"new_string": 'migrations.AddField(), migrations.RemoveField("name")',
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
171
|
-
assert advisory_payload is not None
|
|
172
|
-
permission_fields = _permission_fields(advisory_payload)
|
|
173
|
-
|
|
174
|
-
assert permission_fields["permissionDecision"] == "allow"
|
|
175
|
-
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def test_standalone_advisor_reads_a_write_payloads_content_field(tmp_path: Path) -> None:
|
|
179
|
-
"""_resolve_content's non-MultiEdit branch reads a Write payload's "content" key."""
|
|
180
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
181
|
-
payload: dict[str, object] = {
|
|
182
|
-
"tool_name": "Write",
|
|
183
|
-
"tool_input": {
|
|
184
|
-
"file_path": str(migration_path),
|
|
185
|
-
"content": 'operations = [migrations.RemoveField("name")]',
|
|
186
|
-
},
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
190
|
-
assert advisory_payload is not None
|
|
191
|
-
assert "RemoveField" in _text_field(
|
|
192
|
-
_permission_fields(advisory_payload), "additionalContext"
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def test_standalone_advisor_is_silent_for_a_clean_multi_edit(tmp_path: Path) -> None:
|
|
197
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
198
|
-
payload = _build_multi_edit_payload(
|
|
199
|
-
migration_path,
|
|
200
|
-
[{"old_string": "operations = []", "new_string": "operations = [migrations.AddField()]"}],
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
204
|
-
assert advisory_payload is None
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Constants for the repository-gated issue-tracker SessionStart starter.
|
|
2
|
-
|
|
3
|
-
Default off. Set CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED to 1/true/yes/on.
|
|
4
|
-
Even when enabled, injects only when the session cwd's git root is registered
|
|
5
|
-
in ~/.claude/project-paths.json. Missing registry fails closed (no inject).
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
ISSUE_TRACKER_SESSION_STARTER_ENABLED_ENV_VAR: str = (
|
|
11
|
-
"CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED"
|
|
12
|
-
)
|
|
13
|
-
ALL_ISSUE_TRACKER_STARTER_ENABLED_ENV_VALUES: frozenset[str] = frozenset(
|
|
14
|
-
{"1", "true", "yes", "on"}
|
|
15
|
-
)
|
|
16
|
-
ISSUE_TRACKER_STARTER_TIMEOUT_MILLISECONDS: int = 50
|
|
17
|
-
|
|
18
|
-
ISSUE_TRACKER_SESSION_START_DIRECTIVE: str = (
|
|
19
|
-
"SessionStart issue-tracker opt-in is active for this registered repository. "
|
|
20
|
-
"Load the issue-tracker skill when the session needs one GitHub issue action "
|
|
21
|
-
"(open epic, file sub-issue, update status, refresh checklist, close sub-issue)."
|
|
22
|
-
)
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""SessionStart hook — repository-gated issue-tracker context via shared injector.
|
|
3
|
-
|
|
4
|
-
Default off. When CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED is set and the
|
|
5
|
-
session cwd's git root appears in ~/.claude/project-paths.json, emit an
|
|
6
|
-
additionalContext directive for the issue-tracker skill. Missing registry or
|
|
7
|
-
unregistered repos fail closed with no output.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import json
|
|
13
|
-
import os
|
|
14
|
-
import sys
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
18
|
-
if _hooks_dir not in sys.path:
|
|
19
|
-
sys.path.insert(0, _hooks_dir)
|
|
20
|
-
|
|
21
|
-
from hooks_constants.issue_tracker_session_starter_constants import ( # noqa: E402
|
|
22
|
-
ALL_ISSUE_TRACKER_STARTER_ENABLED_ENV_VALUES,
|
|
23
|
-
ISSUE_TRACKER_SESSION_START_DIRECTIVE,
|
|
24
|
-
ISSUE_TRACKER_SESSION_STARTER_ENABLED_ENV_VAR,
|
|
25
|
-
ISSUE_TRACKER_STARTER_TIMEOUT_MILLISECONDS,
|
|
26
|
-
)
|
|
27
|
-
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
28
|
-
read_hook_input_dictionary_from_stdin,
|
|
29
|
-
)
|
|
30
|
-
from hooks_constants.project_paths_reader import ( # noqa: E402
|
|
31
|
-
find_git_root,
|
|
32
|
-
load_registry,
|
|
33
|
-
registry_contains_path,
|
|
34
|
-
)
|
|
35
|
-
from hooks_constants.session_start_injector import ( # noqa: E402
|
|
36
|
-
InjectorConfiguration,
|
|
37
|
-
build_additional_context_payload,
|
|
38
|
-
inject_session_start_context,
|
|
39
|
-
)
|
|
40
|
-
from hooks_constants.session_start_injector_constants import ( # noqa: E402
|
|
41
|
-
ALL_KNOWN_SESSION_START_SOURCES,
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def issue_tracker_session_starter_enabled_in_environment() -> bool:
|
|
46
|
-
"""Return True only when the opt-in env var holds an enabled value."""
|
|
47
|
-
raw_setting = os.environ.get(ISSUE_TRACKER_SESSION_STARTER_ENABLED_ENV_VAR, "")
|
|
48
|
-
return raw_setting.strip().lower() in ALL_ISSUE_TRACKER_STARTER_ENABLED_ENV_VALUES
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def repository_is_registered(
|
|
52
|
-
working_directory: str,
|
|
53
|
-
registry_by_name: dict[str, str],
|
|
54
|
-
) -> bool:
|
|
55
|
-
"""Return True when cwd git root is a value in the project-paths registry.
|
|
56
|
-
|
|
57
|
-
Missing registry, missing git root, or unregistered root fail closed.
|
|
58
|
-
|
|
59
|
-
Args:
|
|
60
|
-
working_directory: Session cwd used to locate the git root.
|
|
61
|
-
registry_by_name: Name-to-path map; empty map fails closed.
|
|
62
|
-
"""
|
|
63
|
-
if not registry_by_name:
|
|
64
|
-
return False
|
|
65
|
-
git_root = find_git_root(working_directory)
|
|
66
|
-
if git_root is None:
|
|
67
|
-
return False
|
|
68
|
-
return registry_contains_path(registry_by_name, git_root)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def build_issue_tracker_injector_configuration(
|
|
72
|
-
timeout_milliseconds: int,
|
|
73
|
-
) -> InjectorConfiguration:
|
|
74
|
-
"""Build injector config with the issue-tracker directive per known source.
|
|
75
|
-
|
|
76
|
-
Args:
|
|
77
|
-
timeout_milliseconds: Injector budget; zero or less yields timeout status.
|
|
78
|
-
"""
|
|
79
|
-
context_by_source = {
|
|
80
|
-
each_source: ISSUE_TRACKER_SESSION_START_DIRECTIVE
|
|
81
|
-
for each_source in ALL_KNOWN_SESSION_START_SOURCES
|
|
82
|
-
}
|
|
83
|
-
return InjectorConfiguration(
|
|
84
|
-
is_enabled=True,
|
|
85
|
-
timeout_milliseconds=timeout_milliseconds,
|
|
86
|
-
context_by_source=context_by_source,
|
|
87
|
-
default_context_for_unknown="",
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def run_issue_tracker_session_starter(
|
|
92
|
-
payload_by_key: dict[str, object],
|
|
93
|
-
is_enabled: bool,
|
|
94
|
-
is_repository_eligible: bool,
|
|
95
|
-
timeout_milliseconds: int,
|
|
96
|
-
) -> dict[str, object]:
|
|
97
|
-
"""Return additionalContext when opt-in and repo gate both pass, else empty.
|
|
98
|
-
|
|
99
|
-
Args:
|
|
100
|
-
payload_by_key: Parsed SessionStart payload.
|
|
101
|
-
is_enabled: When False, return empty without calling the injector.
|
|
102
|
-
is_repository_eligible: When False, return empty without calling the injector.
|
|
103
|
-
timeout_milliseconds: Injector timeout budget.
|
|
104
|
-
|
|
105
|
-
Returns:
|
|
106
|
-
``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
|
|
107
|
-
when injected, else ``{}``.
|
|
108
|
-
"""
|
|
109
|
-
if not is_enabled or not is_repository_eligible:
|
|
110
|
-
return {}
|
|
111
|
-
configuration = build_issue_tracker_injector_configuration(timeout_milliseconds)
|
|
112
|
-
injection_result = inject_session_start_context(payload_by_key, configuration)
|
|
113
|
-
return build_additional_context_payload(injection_result)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def main() -> None:
|
|
117
|
-
"""Emit issue-tracker additionalContext when opt-in and registry gate pass."""
|
|
118
|
-
payload_by_key = read_hook_input_dictionary_from_stdin()
|
|
119
|
-
if payload_by_key is None:
|
|
120
|
-
return
|
|
121
|
-
is_enabled = issue_tracker_session_starter_enabled_in_environment()
|
|
122
|
-
is_repository_eligible = (
|
|
123
|
-
repository_is_registered(os.getcwd(), load_registry()) if is_enabled else False
|
|
124
|
-
)
|
|
125
|
-
payload = run_issue_tracker_session_starter(
|
|
126
|
-
payload_by_key,
|
|
127
|
-
is_enabled,
|
|
128
|
-
is_repository_eligible,
|
|
129
|
-
ISSUE_TRACKER_STARTER_TIMEOUT_MILLISECONDS,
|
|
130
|
-
)
|
|
131
|
-
if payload:
|
|
132
|
-
print(json.dumps(payload))
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if __name__ == "__main__":
|
|
136
|
-
main()
|