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.
Files changed (42) 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.cursor-rules.test.mjs +2 -1
  9. package/docs/rule-guides/code-standards.md +1 -1
  10. package/docs/rule-guides/destructive-commands.md +1 -1
  11. package/hooks/blocking/pr_lifecycle_skill_gate.py +190 -0
  12. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +163 -0
  13. package/hooks/hooks.json +25 -0
  14. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +33 -0
  15. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +0 -10
  16. package/hooks/hooks_constants/spawn_readiness_hook_constants.py +30 -12
  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/routing/spawn_readiness_hook.py +118 -46
  21. package/hooks/routing/test_spawn_readiness_hook.py +113 -21
  22. package/hooks/routing/test_spawn_readiness_steps.py +2 -4
  23. package/hooks/session/skill_loaded_reminder.py +4 -51
  24. package/hooks/session/test_skill_loaded_reminder.py +4 -0
  25. package/hooks/test_transcript_skill_scan.py +58 -0
  26. package/hooks/transcript_skill_scan.py +93 -0
  27. package/package.json +1 -1
  28. package/rules/flag-non-breaking-findings.md +2 -2
  29. package/rules/shell-invocation.md +2 -14
  30. package/rules/skill-pointers.md +3 -0
  31. package/scripts/policy_lint/adapter_configuration.py +5 -0
  32. package/scripts/policy_lint/config/constants.py +1 -0
  33. package/scripts/tests/test_adapter_configuration.py +8 -0
  34. package/scripts/tests/test_banned_prose_words.py +1 -1
  35. package/scripts/tests/test_rule_load_scopes.py +1 -7
  36. package/rules/agent-merges-its-own-green-pull-request.md +0 -55
  37. package/rules/ci-owns-the-gate.md +0 -101
  38. package/rules/durable-post-artifacts.md +0 -76
  39. package/rules/gh-cli-conventions.md +0 -36
  40. package/rules/git-workflow.md +0 -121
  41. package/rules/re-stage-before-commit.md +0 -14
  42. 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 [`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
@@ -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 deny messages and log fields.
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
- PERMISSION_DECISION_KEY = "permissionDecision"
84
- PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
85
- PERMISSION_DENY = "deny"
94
+ ADDITIONAL_CONTEXT_KEY = "additionalContext"
86
95
  MISSING_INVESTIGATION_REASON = (
87
- "Investigate the request before this spawn. Read the files, threads, or "
88
- "sources it names, so the brief and the agent count fit the task. Run the "
89
- "reads in a message before the spawn."
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
- "Interview the user before this spawn. Ask your scope, requirements, and "
93
- "goals questions through AskUserQuestion, a decision card, or an "
94
- "interactive widget, and spawn after the answer. When the request "
95
- "already settles scope, add a brief line that starts with "
96
- f'"{SCOPE_SETTLED_PREFIX}" and names the reason.'
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"
@@ -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"