claude-dev-env 8.40.1 → 8.42.0
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/agents/test_agent_frontmatter.py +17 -14
- package/.agents/agents-archived/clean-coder.md +4 -4
- package/.agents/agents-archived/pr-description-writer.md +1 -1
- package/.agents/skills/pr-lifecycle/SKILL.md +470 -0
- package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/ever-shipped-skills.test.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +2 -1
- package/docs/rule-guides/code-standards.md +1 -1
- package/docs/rule-guides/destructive-commands.md +1 -1
- package/hooks/blocking/pr_lifecycle_skill_gate.py +190 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +163 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +33 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +0 -10
- package/hooks/hooks_constants/spawn_readiness_hook_constants.py +30 -12
- package/hooks/hooks_constants/test_pr_lifecycle_skill_gate_constants.py +12 -0
- package/hooks/hooks_constants/test_transcript_skill_scan_constants.py +7 -0
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +7 -0
- package/hooks/routing/spawn_readiness_hook.py +118 -46
- package/hooks/routing/test_spawn_readiness_hook.py +113 -21
- package/hooks/routing/test_spawn_readiness_steps.py +2 -4
- package/hooks/session/skill_loaded_reminder.py +4 -51
- package/hooks/session/test_skill_loaded_reminder.py +4 -0
- package/hooks/test_transcript_skill_scan.py +58 -0
- package/hooks/transcript_skill_scan.py +93 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +2 -2
- package/rules/shell-invocation.md +2 -14
- package/rules/skill-pointers.md +3 -0
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_banned_prose_words.py +1 -1
- package/scripts/tests/test_rule_load_scopes.py +1 -7
- package/rules/agent-merges-its-own-green-pull-request.md +0 -55
- package/rules/ci-owns-the-gate.md +0 -101
- package/rules/durable-post-artifacts.md +0 -76
- package/rules/gh-cli-conventions.md +0 -36
- package/rules/git-workflow.md +0 -121
- package/rules/re-stage-before-commit.md +0 -14
- package/rules/review-closure-is-a-check.md +0 -54
|
@@ -32,7 +32,7 @@ A file left in the OS temp directory or under `$CLAUDE_JOB_DIR` is cleaned by th
|
|
|
32
32
|
|
|
33
33
|
The permission matcher reads the raw command string. A destructive literal carried only as data still sits in that string, so it can push the command out of an allowed shape and into a prompt even though the shell never executes it. This covers a commit message, a PR or issue body, an echoed string, a `python -c` or `node -e` or `awk` argument, and a heredoc.
|
|
34
34
|
|
|
35
|
-
- Bodies that describe destructive-command behavior go in a file passed by path, such as `git commit -F <file>` or `gh … --body-file <file>`. See [
|
|
35
|
+
- Bodies that describe destructive-command behavior go in a file passed by path, such as `git commit -F <file>` or `gh … --body-file <file>`. See [gh CLI conventions](../../.agents/skills/pr-lifecycle/SKILL.md#gh-cli-conventions). Never `git commit -m` or `gh … -b`.
|
|
36
36
|
- To exercise or verify a hook, run the committed test suite with `python -m pytest <test_file>`, which passes the command strings as in-language data. Never an inline `python -c` harness.
|
|
37
37
|
|
|
38
38
|
## Every subagent prompt carries the rule
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Deny pull request lifecycle actions until their skill appears in the transcript."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if hooks_directory not in sys.path:
|
|
13
|
+
sys.path.insert(0, hooks_directory)
|
|
14
|
+
|
|
15
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
16
|
+
ALL_API_ACTION_NAMES,
|
|
17
|
+
ALL_COMMAND_PREFIX_WORDS,
|
|
18
|
+
ALL_GITHUB_MCP_TOOL_SUFFIXES,
|
|
19
|
+
ALL_PREFIX_OPTIONS_WITH_VALUE,
|
|
20
|
+
ALL_SLASH_COMMAND_MARKERS,
|
|
21
|
+
ALL_TRANSCRIPT_PATH_FIELDS,
|
|
22
|
+
COMMAND_SEPARATORS,
|
|
23
|
+
COMMAND_WHITESPACE,
|
|
24
|
+
DENY_DECISION,
|
|
25
|
+
DENY_REASON,
|
|
26
|
+
ENVIRONMENT_ASSIGNMENT_PATTERN,
|
|
27
|
+
GH_API_COMMAND,
|
|
28
|
+
GH_OPTIONS_WITH_VALUE,
|
|
29
|
+
GH_PULL_REQUEST_COMMAND,
|
|
30
|
+
GIT_ACTIONS,
|
|
31
|
+
GIT_GLOBAL_OPTIONS_WITH_VALUE,
|
|
32
|
+
HOOK_EVENT_NAME,
|
|
33
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
34
|
+
MERGE_PATH_SUFFIX,
|
|
35
|
+
OPTION_AND_VALUE_WORD_COUNT,
|
|
36
|
+
PERMISSION_DECISION_KEY,
|
|
37
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
38
|
+
SHELL_TOOL_NAMES,
|
|
39
|
+
SKILL_NAME,
|
|
40
|
+
)
|
|
41
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
42
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
43
|
+
from transcript_skill_scan import is_skill_loaded_after_last_compaction
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _command_segments(command: str) -> list[list[str]]:
|
|
47
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=COMMAND_SEPARATORS)
|
|
48
|
+
lexer.whitespace = COMMAND_WHITESPACE
|
|
49
|
+
lexer.whitespace_split = True
|
|
50
|
+
all_segments: list[list[str]] = []
|
|
51
|
+
each_segment: list[str] = []
|
|
52
|
+
try:
|
|
53
|
+
all_words = list(lexer)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return []
|
|
56
|
+
for each_word in all_words:
|
|
57
|
+
if each_word[0] in COMMAND_SEPARATORS:
|
|
58
|
+
all_segments.append(each_segment)
|
|
59
|
+
each_segment = []
|
|
60
|
+
continue
|
|
61
|
+
each_segment.append(each_word)
|
|
62
|
+
if each_segment:
|
|
63
|
+
all_segments.append(each_segment)
|
|
64
|
+
return [each_group for each_group in all_segments if each_group]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _next_command_word(
|
|
68
|
+
all_words: list[str], start: int, all_options_with_value: frozenset[str]
|
|
69
|
+
) -> tuple[str | None, int]:
|
|
70
|
+
index = start
|
|
71
|
+
while index < len(all_words):
|
|
72
|
+
each_word = all_words[index]
|
|
73
|
+
if each_word in all_options_with_value:
|
|
74
|
+
index += OPTION_AND_VALUE_WORD_COUNT
|
|
75
|
+
continue
|
|
76
|
+
if each_word.startswith("-"):
|
|
77
|
+
index += 1
|
|
78
|
+
continue
|
|
79
|
+
return each_word, index + 1
|
|
80
|
+
return None, index
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_git_action(all_words: list[str]) -> bool:
|
|
84
|
+
action, _ = _next_command_word(all_words, 1, GIT_GLOBAL_OPTIONS_WITH_VALUE)
|
|
85
|
+
return action in GIT_ACTIONS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_gh_action(all_words: list[str]) -> bool:
|
|
89
|
+
action, next_index = _next_command_word(all_words, 1, GH_OPTIONS_WITH_VALUE)
|
|
90
|
+
if action == GH_PULL_REQUEST_COMMAND:
|
|
91
|
+
subcommand, _ = _next_command_word(all_words, next_index, GH_OPTIONS_WITH_VALUE)
|
|
92
|
+
return subcommand is not None
|
|
93
|
+
if action != GH_API_COMMAND:
|
|
94
|
+
return False
|
|
95
|
+
all_api_arguments = all_words[next_index:]
|
|
96
|
+
if any(each_name in each_word for each_word in all_api_arguments for each_name in ALL_API_ACTION_NAMES):
|
|
97
|
+
return True
|
|
98
|
+
return any(
|
|
99
|
+
each_word.split("?", 1)[0].rstrip("/").endswith(MERGE_PATH_SUFFIX)
|
|
100
|
+
for each_word in all_api_arguments
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _words_from_executable(all_words: list[str]) -> list[str]:
|
|
105
|
+
index = 0
|
|
106
|
+
while index < len(all_words):
|
|
107
|
+
each_word = all_words[index]
|
|
108
|
+
if re.match(ENVIRONMENT_ASSIGNMENT_PATTERN, each_word):
|
|
109
|
+
index += 1
|
|
110
|
+
continue
|
|
111
|
+
if each_word not in ALL_COMMAND_PREFIX_WORDS:
|
|
112
|
+
return all_words[index:]
|
|
113
|
+
index += 1
|
|
114
|
+
while index < len(all_words) and all_words[index].startswith("-"):
|
|
115
|
+
is_valued = all_words[index] in ALL_PREFIX_OPTIONS_WITH_VALUE
|
|
116
|
+
index += OPTION_AND_VALUE_WORD_COUNT if is_valued else 1
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _matches_shell_command(command: str) -> bool:
|
|
121
|
+
for each_segment in _command_segments(command):
|
|
122
|
+
all_command_words = _words_from_executable(each_segment)
|
|
123
|
+
if not all_command_words:
|
|
124
|
+
continue
|
|
125
|
+
executable = all_command_words[0].lower()
|
|
126
|
+
if executable in {"git", "git.exe"} and _is_git_action(all_command_words):
|
|
127
|
+
return True
|
|
128
|
+
if executable in {"gh", "gh.exe"} and _is_gh_action(all_command_words):
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_governed_action(all_payload_fields: dict[str, object]) -> bool:
|
|
134
|
+
tool_name = all_payload_fields.get("tool_name")
|
|
135
|
+
if not isinstance(tool_name, str):
|
|
136
|
+
return False
|
|
137
|
+
if tool_name.endswith(ALL_GITHUB_MCP_TOOL_SUFFIXES):
|
|
138
|
+
return True
|
|
139
|
+
if tool_name not in SHELL_TOOL_NAMES:
|
|
140
|
+
return False
|
|
141
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
142
|
+
return isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str) and _matches_shell_command(tool_input["command"])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _transcript_load_status(path: str) -> bool | None:
|
|
146
|
+
try:
|
|
147
|
+
with open(path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
148
|
+
return is_skill_loaded_after_last_compaction(
|
|
149
|
+
transcript, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
|
|
150
|
+
)
|
|
151
|
+
except OSError:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
156
|
+
"""Return a deny for a governed action with readable, unloaded transcripts.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
160
|
+
"""
|
|
161
|
+
if not _is_governed_action(all_payload_fields):
|
|
162
|
+
return None
|
|
163
|
+
paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
|
|
164
|
+
readable_paths = [path for path in paths if isinstance(path, str) and path]
|
|
165
|
+
if not readable_paths:
|
|
166
|
+
return None
|
|
167
|
+
statuses = [_transcript_load_status(path) for path in readable_paths]
|
|
168
|
+
if any(status is None or status for status in statuses):
|
|
169
|
+
return None
|
|
170
|
+
return {
|
|
171
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
172
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
173
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
174
|
+
PERMISSION_DECISION_REASON_KEY: DENY_REASON,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> int:
|
|
180
|
+
"""Read one payload, print a deny when needed, and exit zero."""
|
|
181
|
+
payload = read_hook_input_dictionary_from_stdin()
|
|
182
|
+
if payload is not None:
|
|
183
|
+
decision = decision_for(payload)
|
|
184
|
+
if decision is not None:
|
|
185
|
+
sys.stdout.write(json.dumps(decision) + "\n")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
sys.exit(main())
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Behavior tests for the pull request lifecycle PreToolUse gate."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from io import BytesIO, StringIO, TextIOWrapper
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
10
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
12
|
+
|
|
13
|
+
from blocking import pr_lifecycle_skill_gate as gate
|
|
14
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import DENY_REASON
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _transcript(tmp_path: Path, skill_name: str | None = None, compact: bool = False) -> Path:
|
|
18
|
+
lines = [json.dumps({"type": "user", "message": {"content": "Start."}})]
|
|
19
|
+
if skill_name:
|
|
20
|
+
lines.append(json.dumps({"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": skill_name}}]}}))
|
|
21
|
+
if compact:
|
|
22
|
+
lines.append(json.dumps({"subtype": "compact_boundary"}))
|
|
23
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
24
|
+
transcript_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
25
|
+
return transcript_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _payload(command: str, path: Path, tool_name: str = "Bash") -> dict[str, object]:
|
|
29
|
+
return {"tool_name": tool_name, "tool_input": {"command": command}, "transcript_path": str(path)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run_main(payload: object) -> tuple[int, str]:
|
|
33
|
+
stdin_text = payload if isinstance(payload, str) else json.dumps(payload)
|
|
34
|
+
output = StringIO()
|
|
35
|
+
input_stream = TextIOWrapper(BytesIO(stdin_text.encode("utf-8")), encoding="utf-8")
|
|
36
|
+
with patch("sys.stdin", input_stream), patch("sys.stdout", output):
|
|
37
|
+
exit_code = gate.main()
|
|
38
|
+
return exit_code, output.getvalue()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _assert_denied(payload: dict[str, object]) -> None:
|
|
42
|
+
assert gate.decision_for(payload) == {
|
|
43
|
+
"hookSpecificOutput": {
|
|
44
|
+
"hookEventName": "PreToolUse",
|
|
45
|
+
"permissionDecision": "deny",
|
|
46
|
+
"permissionDecisionReason": DENY_REASON,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_unloaded_pull_request_command_is_denied(tmp_path: Path) -> None:
|
|
52
|
+
exit_code, output = _run_main(_payload("gh pr create", _transcript(tmp_path)))
|
|
53
|
+
assert exit_code == 0
|
|
54
|
+
decision = json.loads(output)["hookSpecificOutput"]
|
|
55
|
+
assert decision["hookEventName"] == "PreToolUse"
|
|
56
|
+
assert decision["permissionDecision"] == "deny"
|
|
57
|
+
assert "Skill tool" in decision["permissionDecisionReason"]
|
|
58
|
+
assert "same command again" in decision["permissionDecisionReason"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_loaded_skill_allows_action_silently(tmp_path: Path) -> None:
|
|
62
|
+
path = _transcript(tmp_path, "plugin:pr-lifecycle")
|
|
63
|
+
assert _run_main(_payload("gh pr create", path)) == (0, "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_user_slash_command_loads_skill(tmp_path: Path) -> None:
|
|
67
|
+
path = tmp_path / "slash.jsonl"
|
|
68
|
+
path.write_text(json.dumps({"type": "user", "message": {"content": "<command-name>/pr-lifecycle</command-name>"}}) + "\n", encoding="utf-8")
|
|
69
|
+
assert _run_main(_payload("git commit", path)) == (0, "")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_compaction_requires_another_invocation(tmp_path: Path) -> None:
|
|
73
|
+
_assert_denied(_payload("git push", _transcript(tmp_path, "pr-lifecycle", compact=True)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_agent_transcript_can_supply_invocation(tmp_path: Path) -> None:
|
|
77
|
+
session_path = _transcript(tmp_path)
|
|
78
|
+
agent_path = tmp_path / "agent.jsonl"
|
|
79
|
+
agent_path.write_text(json.dumps({"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": "pr-lifecycle"}}]}}) + "\n", encoding="utf-8")
|
|
80
|
+
payload = _payload("git push", session_path)
|
|
81
|
+
payload["agent_transcript_path"] = str(agent_path)
|
|
82
|
+
assert gate.decision_for(payload) is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_unreadable_or_missing_transcript_allows_silently(tmp_path: Path) -> None:
|
|
86
|
+
payload = _payload("gh pr create", tmp_path / "missing.jsonl")
|
|
87
|
+
assert _run_main(payload) == (0, "")
|
|
88
|
+
payload.pop("transcript_path")
|
|
89
|
+
assert _run_main(payload) == (0, "")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_action_command_shapes(tmp_path: Path) -> None:
|
|
93
|
+
path = _transcript(tmp_path)
|
|
94
|
+
commands = (
|
|
95
|
+
"git commit",
|
|
96
|
+
"git -C some/path commit -F message.txt",
|
|
97
|
+
"git --git-dir=.git push origin head",
|
|
98
|
+
"gh pr create",
|
|
99
|
+
"gh -R owner/repo pr --repo owner/repo merge",
|
|
100
|
+
"gh api -X PUT repos/owner/repo/pulls/1/merge",
|
|
101
|
+
"gh api graphql -f query='mutation { enablePullRequestAutoMerge }'",
|
|
102
|
+
"gh api graphql -f name=auto-merge",
|
|
103
|
+
"echo done; gh pr edit",
|
|
104
|
+
"GIT_TRACE=1 git push",
|
|
105
|
+
"env -u GH_TOKEN gh pr merge 1",
|
|
106
|
+
"env --unset=GH_TOKEN GH_HOST=github.com gh pr create",
|
|
107
|
+
"sudo git push",
|
|
108
|
+
"(git push)",
|
|
109
|
+
)
|
|
110
|
+
for command in commands:
|
|
111
|
+
_assert_denied(_payload(command, path, "PowerShell"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_non_action_command_shapes(tmp_path: Path) -> None:
|
|
115
|
+
path = _transcript(tmp_path)
|
|
116
|
+
commands = (
|
|
117
|
+
"git status",
|
|
118
|
+
"git commit-tree 123",
|
|
119
|
+
"git log --grep push",
|
|
120
|
+
"echo 'gh pr create'",
|
|
121
|
+
"gh api repos/owner/repo/pulls/1/mergeability",
|
|
122
|
+
"gh issue create --body-file message.txt",
|
|
123
|
+
"gh pr",
|
|
124
|
+
"GH_TOKEN=x gh issue list",
|
|
125
|
+
"env -u GH_TOKEN git status",
|
|
126
|
+
)
|
|
127
|
+
for command in commands:
|
|
128
|
+
assert gate.decision_for(_payload(command, path)) is None, command
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_matching_github_mcp_tools_are_denied(tmp_path: Path) -> None:
|
|
132
|
+
path = _transcript(tmp_path)
|
|
133
|
+
for suffix in ("create_pull_request", "merge_pull_request", "enable_pr_auto_merge", "update_pull_request"):
|
|
134
|
+
payload = _payload("", path, "mcp__github__" + suffix)
|
|
135
|
+
_assert_denied(payload)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_malformed_payload_exits_zero_silently() -> None:
|
|
139
|
+
assert _run_main("bad json") == (0, "")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_hook_has_its_own_pre_tool_use_registration() -> None:
|
|
143
|
+
registration_path = HOOKS_DIRECTORY / "hooks.json"
|
|
144
|
+
all_registration_fields = json.loads(registration_path.read_text(encoding="utf-8"))
|
|
145
|
+
all_groups = all_registration_fields["hooks"]["PreToolUse"]
|
|
146
|
+
matching_groups = [
|
|
147
|
+
each_group
|
|
148
|
+
for each_group in all_groups
|
|
149
|
+
if each_group["matcher"]
|
|
150
|
+
== "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)"
|
|
151
|
+
]
|
|
152
|
+
assert matching_groups == [
|
|
153
|
+
{
|
|
154
|
+
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
|
|
155
|
+
"hooks": [
|
|
156
|
+
{
|
|
157
|
+
"type": "command",
|
|
158
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_lifecycle_skill_gate.py",
|
|
159
|
+
"timeout": 10,
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
]
|
package/hooks/hooks.json
CHANGED
|
@@ -24,6 +24,21 @@
|
|
|
24
24
|
"type": "command",
|
|
25
25
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/routing/subagent_model_routing.mjs\"",
|
|
26
26
|
"timeout": 10
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
31
|
+
"timeout": 10
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"matcher": "Workflow|mcp__github__actions_run_trigger",
|
|
37
|
+
"hooks": [
|
|
38
|
+
{
|
|
39
|
+
"type": "command",
|
|
40
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
41
|
+
"timeout": 10
|
|
27
42
|
}
|
|
28
43
|
]
|
|
29
44
|
},
|
|
@@ -47,6 +62,16 @@
|
|
|
47
62
|
}
|
|
48
63
|
]
|
|
49
64
|
},
|
|
65
|
+
{
|
|
66
|
+
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
|
|
67
|
+
"hooks": [
|
|
68
|
+
{
|
|
69
|
+
"type": "command",
|
|
70
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_lifecycle_skill_gate.py",
|
|
71
|
+
"timeout": 10
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
},
|
|
50
75
|
{
|
|
51
76
|
"matcher": "EnterWorktree",
|
|
52
77
|
"hooks": [
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Constants for the pull request lifecycle skill gate."""
|
|
2
|
+
|
|
3
|
+
HOOK_EVENT_NAME = "PreToolUse"
|
|
4
|
+
HOOK_SPECIFIC_OUTPUT_KEY = "hookSpecificOutput"
|
|
5
|
+
PERMISSION_DECISION_KEY = "permissionDecision"
|
|
6
|
+
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
7
|
+
DENY_DECISION = "deny"
|
|
8
|
+
DENY_REASON = (
|
|
9
|
+
"Invoke the pr-lifecycle skill with the Skill tool, then run the same command again."
|
|
10
|
+
)
|
|
11
|
+
SKILL_NAME = "pr-lifecycle"
|
|
12
|
+
ALL_SLASH_COMMAND_MARKERS = ("<command-name>/pr-lifecycle</command-name>",)
|
|
13
|
+
SHELL_TOOL_NAMES = frozenset({"Bash", "PowerShell"})
|
|
14
|
+
ALL_GITHUB_MCP_TOOL_SUFFIXES = (
|
|
15
|
+
"__create_pull_request",
|
|
16
|
+
"__merge_pull_request",
|
|
17
|
+
"__enable_pr_auto_merge",
|
|
18
|
+
"__update_pull_request",
|
|
19
|
+
)
|
|
20
|
+
ALL_TRANSCRIPT_PATH_FIELDS = ("transcript_path", "agent_transcript_path")
|
|
21
|
+
COMMAND_SEPARATORS = ";|&\n()"
|
|
22
|
+
COMMAND_WHITESPACE = " \t\r"
|
|
23
|
+
OPTION_AND_VALUE_WORD_COUNT = 2
|
|
24
|
+
GIT_GLOBAL_OPTIONS_WITH_VALUE = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"})
|
|
25
|
+
GH_OPTIONS_WITH_VALUE = frozenset({"-R", "--repo", "--hostname", "--config", "-H", "--header", "-X", "--method", "-F", "--field", "-f", "--raw-field", "--input", "--jq", "-q", "--template", "-t", "--cache"})
|
|
26
|
+
GIT_ACTIONS = frozenset({"commit", "push"})
|
|
27
|
+
GH_PULL_REQUEST_COMMAND = "pr"
|
|
28
|
+
GH_API_COMMAND = "api"
|
|
29
|
+
MERGE_PATH_SUFFIX = "/merge"
|
|
30
|
+
ALL_API_ACTION_NAMES = ("auto-merge", "enablePullRequestAutoMerge")
|
|
31
|
+
ALL_COMMAND_PREFIX_WORDS = frozenset({"env", "sudo", "command", "exec", "nohup", "time"})
|
|
32
|
+
ALL_PREFIX_OPTIONS_WITH_VALUE = frozenset({"-u", "--unset", "-C", "--chdir", "-g", "--group", "--user"})
|
|
33
|
+
ENVIRONMENT_ASSIGNMENT_PATTERN = r"^[A-Za-z_][A-Za-z0-9_]*="
|
|
@@ -4,8 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
6
|
"ALL_SELF_LOADING_SUBAGENT_TYPES",
|
|
7
|
-
"ASSISTANT_ENTRY_TYPE",
|
|
8
|
-
"COMPACT_BOUNDARY_SUBTYPE",
|
|
9
7
|
"COMPACTION_REMINDER",
|
|
10
8
|
"COMPACTION_SOURCE",
|
|
11
9
|
"NOT_LOADED_REMINDER",
|
|
@@ -13,14 +11,11 @@ __all__ = [
|
|
|
13
11
|
"PRE_TOOL_USE_EVENT_NAME",
|
|
14
12
|
"PROMPT_SEPARATOR",
|
|
15
13
|
"SESSION_START_EVENT_NAME",
|
|
16
|
-
"SKILL_TOOL_NAME",
|
|
17
14
|
"ALL_SLASH_COMMAND_MARKERS",
|
|
18
15
|
"SUBAGENT_START_EVENT_NAME",
|
|
19
16
|
"CLAUDE_SUBAGENT_PROMPT_PREFIX",
|
|
20
17
|
"CODEX_SUBAGENT_PROMPT_PREFIX",
|
|
21
18
|
"ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME",
|
|
22
|
-
"TOOL_USE_BLOCK_TYPE",
|
|
23
|
-
"USER_ENTRY_TYPE",
|
|
24
19
|
"USER_PROMPT_SUBMIT_EVENT_NAME",
|
|
25
20
|
"WORKFLOW_SUBAGENT_TYPE",
|
|
26
21
|
]
|
|
@@ -42,11 +37,6 @@ ALL_SLASH_COMMAND_MARKERS = (
|
|
|
42
37
|
)
|
|
43
38
|
PROMPT_SEPARATOR = "\n\n"
|
|
44
39
|
|
|
45
|
-
ASSISTANT_ENTRY_TYPE = "assistant"
|
|
46
|
-
USER_ENTRY_TYPE = "user"
|
|
47
|
-
TOOL_USE_BLOCK_TYPE = "tool_use"
|
|
48
|
-
COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
|
|
49
|
-
|
|
50
40
|
CLAUDE_SUBAGENT_PROMPT_PREFIX = (
|
|
51
41
|
"Before any other work, invoke the poteto-mode skill with the Skill tool. "
|
|
52
42
|
"Every prompt you write for a subagent opens with the same instruction."
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Groups: the spawn tools it checks and their brief fields, the transcript entry
|
|
4
4
|
shapes it reads, the tool names that count as a read or a question, the
|
|
5
|
-
scope-settled line, and the
|
|
5
|
+
scope-settled line, and the reminder messages and log fields.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -15,11 +15,22 @@ AGENT_ID_KEY = "agent_id"
|
|
|
15
15
|
SUBAGENT_TYPE_INPUT_KEY = "subagent_type"
|
|
16
16
|
|
|
17
17
|
THREAD_SPAWN_TOOL_NAME = "mcp__hearthbot__start_thread_session"
|
|
18
|
+
CODEX_SPAWN_TOOL_NAME = "multi_agent_v1__spawn_agent"
|
|
19
|
+
WORKFLOW_TOOL_NAME = "Workflow"
|
|
20
|
+
WORKFLOW_SCRIPT_PATH_INPUT_KEY = "scriptPath"
|
|
18
21
|
ALL_BRIEF_FIELDS_BY_SPAWN_TOOL_NAME = {
|
|
19
22
|
"Agent": "prompt",
|
|
20
23
|
"Task": "prompt",
|
|
21
24
|
THREAD_SPAWN_TOOL_NAME: "instructions",
|
|
25
|
+
CODEX_SPAWN_TOOL_NAME: "message",
|
|
26
|
+
WORKFLOW_TOOL_NAME: "script",
|
|
22
27
|
}
|
|
28
|
+
WORKFLOW_DISPATCH_TOOL_NAME = "mcp__github__actions_run_trigger"
|
|
29
|
+
DISPATCH_METHOD_INPUT_KEY = "method"
|
|
30
|
+
DISPATCH_RUN_WORKFLOW_METHOD = "run_workflow"
|
|
31
|
+
DISPATCH_INPUTS_KEY = "inputs"
|
|
32
|
+
DISPATCH_PROMPT_INPUT_KEY = "prompt"
|
|
33
|
+
CODEX_TURN_ID_KEY = "turn_id"
|
|
23
34
|
ALL_READ_ONLY_SUBAGENT_TYPES = frozenset({"Explore", "Plan", "claude-code-guide"})
|
|
24
35
|
|
|
25
36
|
ENTRY_TYPE_KEY = "type"
|
|
@@ -80,20 +91,26 @@ SCOPE_SETTLED_PREFIX = "Scope settled:"
|
|
|
80
91
|
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
81
92
|
HOOK_SPECIFIC_OUTPUT_KEY = "hookSpecificOutput"
|
|
82
93
|
HOOK_EVENT_NAME_KEY = "hookEventName"
|
|
83
|
-
|
|
84
|
-
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
85
|
-
PERMISSION_DENY = "deny"
|
|
94
|
+
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
86
95
|
MISSING_INVESTIGATION_REASON = (
|
|
87
|
-
"
|
|
88
|
-
"sources
|
|
89
|
-
"
|
|
96
|
+
"This spawn comes before any read of the request. Read the files, threads, "
|
|
97
|
+
"or sources the request names, then check that the brief and the agent "
|
|
98
|
+
"count fit the task."
|
|
90
99
|
)
|
|
91
100
|
MISSING_INTERVIEW_REASON = (
|
|
92
|
-
"
|
|
93
|
-
"goals questions through AskUserQuestion, a
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
f'"{SCOPE_SETTLED_PREFIX}" and names the
|
|
101
|
+
"This spawn comes before an answered question to the user. Ask the open "
|
|
102
|
+
"scope, requirements, and goals questions through AskUserQuestion, a "
|
|
103
|
+
"decision card, or an interactive widget. When the request already "
|
|
104
|
+
"settles scope, add a brief line that starts with "
|
|
105
|
+
f'"{SCOPE_SETTLED_PREFIX}" and names the defaults you chose.'
|
|
106
|
+
)
|
|
107
|
+
CODEX_TRANSCRIPT_REMINDER = (
|
|
108
|
+
"This hook reads no Codex transcript, because Codex documents that format "
|
|
109
|
+
"as unstable for hooks. Before this spawn, read what the request names, "
|
|
110
|
+
"size the agents to the independent pieces, and ask the user the open "
|
|
111
|
+
"scope questions. When the request already settles scope, add a brief "
|
|
112
|
+
f'line that starts with "{SCOPE_SETTLED_PREFIX}" and names the defaults '
|
|
113
|
+
"you chose."
|
|
97
114
|
)
|
|
98
115
|
REASON_SEPARATOR = " "
|
|
99
116
|
|
|
@@ -108,4 +125,5 @@ LOG_TOOL_USE_ID_KEY = "tool_use_id"
|
|
|
108
125
|
LOG_OUTCOME_KEY = "outcome"
|
|
109
126
|
LOG_SCOPE_SETTLED_LINE_KEY = "scope_settled_line"
|
|
110
127
|
OUTCOME_SCOPE_SETTLED = "scope_settled"
|
|
128
|
+
OUTCOME_REMINDED = "reminded"
|
|
111
129
|
OUTCOME_TRANSCRIPT_UNREADABLE = "transcript_unreadable"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from hooks_constants import pr_lifecycle_skill_gate_constants as constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_gate_targets_the_named_skill_and_pre_tool_use() -> None:
|
|
5
|
+
assert constants.SKILL_NAME == "pr-lifecycle"
|
|
6
|
+
assert constants.HOOK_EVENT_NAME == "PreToolUse"
|
|
7
|
+
assert constants.DENY_DECISION == "deny"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_gate_covers_shells_and_pull_request_tools() -> None:
|
|
11
|
+
assert constants.SHELL_TOOL_NAMES == {"Bash", "PowerShell"}
|
|
12
|
+
assert len(constants.ALL_GITHUB_MCP_TOOL_SUFFIXES) == 4
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from hooks_constants import transcript_skill_scan_constants as constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_transcript_entry_names() -> None:
|
|
5
|
+
assert constants.COMPACT_BOUNDARY_SUBTYPE == "compact_boundary"
|
|
6
|
+
assert constants.SKILL_TOOL_NAME == "Skill"
|
|
7
|
+
assert constants.TOOL_USE_BLOCK_TYPE == "tool_use"
|