claude-dev-env 8.40.0 → 8.41.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.
Files changed (39) hide show
  1. package/.agents/agents/test_agent_frontmatter.py +17 -14
  2. package/.agents/agents-archived/clean-coder.md +4 -4
  3. package/.agents/agents-archived/pr-description-writer.md +1 -1
  4. package/.agents/skills/pr-lifecycle/SKILL.md +470 -0
  5. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  6. package/bin/ever-shipped-skills.mjs +1 -0
  7. package/bin/ever-shipped-skills.test.mjs +4 -0
  8. package/bin/install.mjs +6 -2
  9. package/bin/install.shared-settings.test.mjs +44 -1
  10. package/docs/rule-guides/code-standards.md +1 -1
  11. package/docs/rule-guides/destructive-commands.md +1 -1
  12. package/hooks/blocking/pr_lifecycle_skill_gate.py +190 -0
  13. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +163 -0
  14. package/hooks/hooks.json +10 -0
  15. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +33 -0
  16. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +0 -10
  17. package/hooks/hooks_constants/test_pr_lifecycle_skill_gate_constants.py +12 -0
  18. package/hooks/hooks_constants/test_transcript_skill_scan_constants.py +7 -0
  19. package/hooks/hooks_constants/transcript_skill_scan_constants.py +7 -0
  20. package/hooks/session/skill_loaded_reminder.py +4 -51
  21. package/hooks/session/test_skill_loaded_reminder.py +4 -0
  22. package/hooks/test_transcript_skill_scan.py +58 -0
  23. package/hooks/transcript_skill_scan.py +93 -0
  24. package/package.json +1 -1
  25. package/rules/flag-non-breaking-findings.md +2 -2
  26. package/rules/shell-invocation.md +2 -14
  27. package/rules/skill-pointers.md +3 -0
  28. package/scripts/policy_lint/adapter_configuration.py +5 -0
  29. package/scripts/policy_lint/config/constants.py +1 -0
  30. package/scripts/tests/test_adapter_configuration.py +8 -0
  31. package/scripts/tests/test_banned_prose_words.py +1 -1
  32. package/scripts/tests/test_rule_load_scopes.py +1 -7
  33. package/rules/agent-merges-its-own-green-pull-request.md +0 -55
  34. package/rules/ci-owns-the-gate.md +0 -101
  35. package/rules/durable-post-artifacts.md +0 -76
  36. package/rules/gh-cli-conventions.md +0 -36
  37. package/rules/git-workflow.md +0 -121
  38. package/rules/re-stage-before-commit.md +0 -14
  39. package/rules/review-closure-is-a-check.md +0 -54
@@ -17,7 +17,7 @@ import {
17
17
  import { tmpdir } from 'node:os';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { spawnSync } from 'node:child_process';
20
- import { managedHookScriptRelativePaths, mergeHooksIntoSettings } from './install.mjs';
20
+ import { commandArgumentFromPath, managedHookScriptRelativePaths, mergeHooksIntoSettings } from './install.mjs';
21
21
 
22
22
  const INSTALLER_PATH = fileURLToPath(new URL('./install.mjs', import.meta.url));
23
23
  const PACKAGE_HOOKS_JSON_PATH = fileURLToPath(new URL('../hooks/hooks.json', import.meta.url));
@@ -200,3 +200,46 @@ test('the merge prunes a hook of a root sharing the settings file and keeps one
200
200
  rmSync(sandboxRoot, { recursive: true, force: true });
201
201
  }
202
202
  });
203
+
204
+ test('the merge prunes a quoted hook of a sharing root whose path holds a space', t => {
205
+ const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-shared-settings-space-'));
206
+ try {
207
+ const installRoot = join(sandboxRoot, 'install-root');
208
+ const sharingRoot = join(sandboxRoot, 'Jane Doe', 'profiles', 'second');
209
+ for (const eachRoot of [installRoot, sharingRoot]) mkdirSync(eachRoot, { recursive: true });
210
+ const settingsPath = join(installRoot, 'settings.json');
211
+ writeFileSync(settingsPath, '{}\n');
212
+ try {
213
+ symlinkSync(settingsPath, join(sharingRoot, 'settings.json'), 'file');
214
+ } catch (linkError) {
215
+ t.skip(`this host cannot create a file symlink (${linkError.code})`);
216
+ return;
217
+ }
218
+ const hooksConfig = {
219
+ hooks: {
220
+ SessionStart: [{
221
+ matcher: '',
222
+ hooks: [{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/start.py' }],
223
+ }],
224
+ },
225
+ };
226
+ const forwardSlashed = rootPath => rootPath.replace(/\\/g, '/');
227
+ const sharingHookPath = `${forwardSlashed(sharingRoot)}/hooks/session/start.py`;
228
+ for (const eachPlatform of ['linux', 'win32']) {
229
+ const sharingCommand = `python3 ${commandArgumentFromPath(sharingHookPath, eachPlatform)}`;
230
+ const settings = {
231
+ hooks: {
232
+ SessionStart: [{ matcher: '', hooks: [{ type: 'command', command: sharingCommand }] }],
233
+ },
234
+ };
235
+
236
+ mergeHooksIntoSettings(settings, hooksConfig, forwardSlashed(installRoot), 'python3', settingsPath);
237
+
238
+ const allCommands = settings.hooks.SessionStart.flatMap(group => group.hooks.map(hook => hook.command));
239
+ assert.ok(!allCommands.includes(sharingCommand), `the ${eachPlatform}-quoted sharing root entry is pruned`);
240
+ assert.equal(allCommands.length, 1, `only the install root entry stays after the ${eachPlatform} merge`);
241
+ }
242
+ } finally {
243
+ rmSync(sandboxRoot, { recursive: true, force: true });
244
+ }
245
+ });
@@ -13,7 +13,7 @@ Full text behind [`rules/code-standards.md`](../../rules/code-standards.md), whi
13
13
  | Contract | `docs/CODE_RULES.md` | Full review criteria for PR agents, loaded on demand |
14
14
  | Pointer | `.cursor/BUGBOT.md` | Checked-in file Cursor BugBot reads; points at `CODE_RULES.md` |
15
15
  | Enforcer | `hooks/blocking/code_rules_enforcer.py` | Hand-maintained checks the staged policy lint runs; not generated from the docs |
16
- | Lint | `scripts/cde_lint.py` | Runs the enforcer and the other policy rules over staged or changed files, grading each against the file's prior text; see [`ci-owns-the-gate.md`](../../rules/ci-owns-the-gate.md) for what each selection flag reports |
16
+ | Lint | `scripts/cde_lint.py` | Runs the enforcer and the other policy rules over staged or changed files, grading each against the file's prior text; see [CI Owns the Gate](../../.agents/skills/pr-lifecycle/SKILL.md#ci-owns-the-gate) for what each selection flag reports |
17
17
  | Session rules | `rules/*.md` | Runtime session policy (questions, tasks, shell) |
18
18
 
19
19
  Load `CODE_RULES.md` when reviewing a PR, resolving a policy conflict, or generating code. Prefer linking this ref over restating rules.
@@ -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 [`gh-cli-conventions.md`](../../rules/gh-cli-conventions.md). Never `git commit -m` or `gh … -b`.
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
@@ -47,6 +47,16 @@
47
47
  }
48
48
  ]
49
49
  },
50
+ {
51
+ "matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
52
+ "hooks": [
53
+ {
54
+ "type": "command",
55
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_lifecycle_skill_gate.py",
56
+ "timeout": 10
57
+ }
58
+ ]
59
+ },
50
60
  {
51
61
  "matcher": "EnterWorktree",
52
62
  "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."
@@ -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"
@@ -0,0 +1,7 @@
1
+ """Entry fields used by transcript skill scans."""
2
+
3
+ ASSISTANT_ENTRY_TYPE = "assistant"
4
+ COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
5
+ SKILL_TOOL_NAME = "Skill"
6
+ TOOL_USE_BLOCK_TYPE = "tool_use"
7
+ USER_ENTRY_TYPE = "user"
@@ -29,8 +29,6 @@ if _hooks_dir not in sys.path:
29
29
 
30
30
  from hooks_constants.skill_loaded_reminder_constants import (
31
31
  ALL_SELF_LOADING_SUBAGENT_TYPES,
32
- ASSISTANT_ENTRY_TYPE,
33
- COMPACT_BOUNDARY_SUBTYPE,
34
32
  COMPACTION_REMINDER,
35
33
  COMPACTION_SOURCE,
36
34
  NOT_LOADED_REMINDER,
@@ -38,18 +36,16 @@ from hooks_constants.skill_loaded_reminder_constants import (
38
36
  PRE_TOOL_USE_EVENT_NAME,
39
37
  PROMPT_SEPARATOR,
40
38
  SESSION_START_EVENT_NAME,
41
- SKILL_TOOL_NAME,
42
39
  ALL_SLASH_COMMAND_MARKERS,
43
40
  SUBAGENT_START_EVENT_NAME,
44
41
  ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME,
45
- TOOL_USE_BLOCK_TYPE,
46
- USER_ENTRY_TYPE,
47
42
  USER_PROMPT_SUBMIT_EVENT_NAME,
48
43
  WORKFLOW_SUBAGENT_TYPE,
49
44
  )
50
45
  from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
51
46
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
52
47
  from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
48
+ from transcript_skill_scan import skill_invocation_status
53
49
 
54
50
 
55
51
  def subagent_input_with_poteto_mode(
@@ -81,44 +77,6 @@ def subagent_input_with_poteto_mode(
81
77
  return {**all_tool_input_fields, field_name: invocation_prefix + PROMPT_SEPARATOR + prompt}
82
78
 
83
79
 
84
- def _invokes_poteto_mode(all_entry_fields: dict[str, object]) -> bool:
85
- message = all_entry_fields.get("message")
86
- if not isinstance(message, dict):
87
- return False
88
- content_blocks = message.get("content")
89
- if all_entry_fields.get("type") == USER_ENTRY_TYPE and isinstance(content_blocks, str):
90
- return any(each_marker in content_blocks for each_marker in ALL_SLASH_COMMAND_MARKERS)
91
- if all_entry_fields.get("type") != ASSISTANT_ENTRY_TYPE or not isinstance(content_blocks, list):
92
- return False
93
- return any(
94
- isinstance(each_block, dict)
95
- and each_block.get("type") == TOOL_USE_BLOCK_TYPE
96
- and each_block.get("name") == SKILL_TOOL_NAME
97
- and isinstance(each_block.get("input"), dict)
98
- and each_block["input"].get("skill") in ALL_POTETO_MODE_SKILL_NAMES
99
- for each_block in content_blocks
100
- )
101
-
102
-
103
- def _marker_entry(transcript_line: str) -> dict[str, object] | None:
104
- if (
105
- COMPACT_BOUNDARY_SUBTYPE not in transcript_line
106
- and ALL_POTETO_MODE_SKILL_NAMES[0] not in transcript_line
107
- ):
108
- return None
109
- try:
110
- parsed_entry = json.loads(transcript_line)
111
- except json.JSONDecodeError:
112
- return None
113
- return parsed_entry if isinstance(parsed_entry, dict) else None
114
-
115
-
116
- def _is_loaded_after(all_entry_fields: dict[str, object], was_loaded: bool) -> bool:
117
- if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE:
118
- return False
119
- return was_loaded or _invokes_poteto_mode(all_entry_fields)
120
-
121
-
122
80
  def poteto_mode_status(all_transcript_lines: Iterable[str]) -> tuple[bool, bool]:
123
81
  """Report whether the skill was invoked and whether it remains loaded.
124
82
 
@@ -139,14 +97,9 @@ def poteto_mode_status(all_transcript_lines: Iterable[str]) -> tuple[bool, bool]
139
97
  Returns:
140
98
  Whether the skill was invoked at any point and whether it is loaded now.
141
99
  """
142
- was_invoked = False
143
- is_loaded = False
144
- for each_line in all_transcript_lines:
145
- all_entry_fields = _marker_entry(each_line)
146
- if all_entry_fields is not None:
147
- is_loaded = _is_loaded_after(all_entry_fields, is_loaded)
148
- was_invoked = was_invoked or is_loaded
149
- return was_invoked, is_loaded
100
+ return skill_invocation_status(
101
+ all_transcript_lines, ALL_POTETO_MODE_SKILL_NAMES, ALL_SLASH_COMMAND_MARKERS
102
+ )
150
103
 
151
104
 
152
105
  def is_poteto_mode_loaded(all_transcript_lines: Iterable[str]) -> bool:
@@ -200,6 +200,10 @@ class TestIsPotetoModeLoaded:
200
200
  prefixed_command_line = TYPED_COMMAND_LINE.replace("/poteto-mode", "/pstack:poteto-mode")
201
201
  assert reminder.is_poteto_mode_loaded([prefixed_command_line])
202
202
 
203
+ def test_a_different_skill_call_does_not_load_it(self) -> None:
204
+ other_skill_call_line = SKILL_CALL_LINE.replace("poteto-mode", "pr-lifecycle")
205
+ assert not reminder.is_poteto_mode_loaded([other_skill_call_line])
206
+
203
207
  def test_a_compaction_after_the_skill_call_drops_it(self) -> None:
204
208
  assert not reminder.is_poteto_mode_loaded(
205
209
  [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE]