claude-dev-env 8.8.0 → 8.9.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/skills/privacy-hygiene/SKILL.md +1 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
- package/docs/second-claude-account.md +8 -1
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
- package/hooks/session/skill_loaded_reminder.py +199 -0
- package/hooks/session/test_skill_loaded_reminder.py +206 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +26 -1
- package/rules/prefer-existing-tools.md +35 -0
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/claude_account_choice.py +51 -6
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +15 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +25 -0
- package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +1 -0
- package/scripts/gh_artifact_upload.py +56 -3
- package/scripts/private_term_scan.py +135 -0
- package/scripts/private_terms.py +87 -0
- package/scripts/repository_checks/config/constants.py +3 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/repository_checks/tracked_private_terms.py +57 -0
- package/scripts/test_claude_account_choice.py +28 -0
- package/scripts/test_durable_post_lint.py +90 -0
- package/scripts/tests/test_gh_artifact_upload.py +75 -0
- package/scripts/tests/test_match_exemptions.py +2 -2
- package/scripts/tests/test_private_term_scan.py +176 -0
- package/scripts/tests/test_private_terms.py +59 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_runner.py +20 -0
- package/scripts/tests/test_tracked_private_terms.py +71 -0
- package/scripts/tests/test_tracked_secrets.py +1 -1
|
@@ -32,6 +32,7 @@ Surfaces:
|
|
|
32
32
|
1. **Write / Edit / MultiEdit** — payload text about to land on disk (via PreToolUse dispatcher)
|
|
33
33
|
2. **Durable posts** — `gh pr/issue create|comment|edit|review` bodies and GitHub MCP body/comment fields (Bash and PowerShell)
|
|
34
34
|
3. **git commit** — staged blob text (non-exempt paths) on Bash and PowerShell, including `git.exe` and flag forms (`--no-verify`, `-c`, `-C`). Commit message bodies (`-m` / `-F`) are out of scope for the automated gate
|
|
35
|
+
4. **CI** — the `committed-tree` job in `.github/workflows/ci-tests.yml` runs [gitleaks](https://github.com/gitleaks/gitleaks) over every commit the pull request adds, test files included. The repository-root `.gitleaks.toml` adds three rules to the gitleaks defaults: a user folder path that names a person, an RFC 1918 address, and an email other than a noreply or example address. To clear a fixture that must stay, add its gitleaks fingerprint to `.gitleaksignore`
|
|
35
36
|
|
|
36
37
|
## Sweep procedure
|
|
37
38
|
|
|
@@ -100,6 +100,7 @@ def _linter_arguments(
|
|
|
100
100
|
all_arguments.extend(["--body-file", str(arguments.body_file)])
|
|
101
101
|
if getattr(arguments, "head", None) is not None:
|
|
102
102
|
all_arguments.extend(["--head-branch", arguments.head])
|
|
103
|
+
all_arguments.extend(["--repository", arguments.repo])
|
|
103
104
|
return all_arguments
|
|
104
105
|
|
|
105
106
|
|
|
@@ -198,6 +198,8 @@ def test_actions_run_exact_linter_then_gh_arguments(
|
|
|
198
198
|
"--body-file",
|
|
199
199
|
BODY_FILENAME,
|
|
200
200
|
*head_arguments,
|
|
201
|
+
"--repository",
|
|
202
|
+
all_cli_arguments[all_cli_arguments.index("--repo") + 1],
|
|
201
203
|
]
|
|
202
204
|
assert exit_code == 0
|
|
203
205
|
assert _calls(command_runner) == [expected_linter, expected_gh]
|
|
@@ -46,5 +46,12 @@ An unreadable main meter never picks main.
|
|
|
46
46
|
|
|
47
47
|
```
|
|
48
48
|
python packages/claude-dev-env/scripts/claude_account_choice.py
|
|
49
|
-
{"account": "second", "config_dir": "C:\\Users\\me\\.claude-profiles\\ev", "reason": "..."
|
|
49
|
+
{"account": "second", "config_dir": "C:\\Users\\me\\.claude-profiles\\ev", "reason": "...",
|
|
50
|
+
"meters": {"main": {"session_used_percent": 12.0, "session_resets_at": "...",
|
|
51
|
+
"weekly_used_percent": 34.0, "weekly_resets_at": "..."},
|
|
52
|
+
"second": null}}
|
|
50
53
|
```
|
|
54
|
+
|
|
55
|
+
The `meters` object carries each account's used percent and reset time for the
|
|
56
|
+
5-hour window and the week. An account whose meter could not be read shows `null`.
|
|
57
|
+
A usage report reads both accounts from this one call.
|
package/hooks/hooks.json
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
"description": "Code standards enforcement, safety guards, and development workflow hooks",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Agent|Task",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
5
15
|
{
|
|
6
16
|
"matcher": "multi_agent_v1__spawn_agent",
|
|
7
17
|
"hooks": [
|
|
@@ -51,6 +61,11 @@
|
|
|
51
61
|
"type": "command",
|
|
52
62
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/style_reminder_prompt.py",
|
|
53
63
|
"timeout": 10
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"type": "command",
|
|
67
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
|
|
68
|
+
"timeout": 10
|
|
54
69
|
}
|
|
55
70
|
]
|
|
56
71
|
}
|
|
@@ -95,6 +110,16 @@
|
|
|
95
110
|
"timeout": 10
|
|
96
111
|
}
|
|
97
112
|
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"matcher": "compact",
|
|
116
|
+
"hooks": [
|
|
117
|
+
{
|
|
118
|
+
"type": "command",
|
|
119
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
|
|
120
|
+
"timeout": 10
|
|
121
|
+
}
|
|
122
|
+
]
|
|
98
123
|
}
|
|
99
124
|
],
|
|
100
125
|
"SessionEnd": [
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Texts and names for the skill_loaded_reminder hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ALL_SELF_LOADING_SUBAGENT_TYPES",
|
|
7
|
+
"ALL_SUBAGENT_TOOL_NAMES",
|
|
8
|
+
"ASSISTANT_ENTRY_TYPE",
|
|
9
|
+
"COMPACT_BOUNDARY_SUBTYPE",
|
|
10
|
+
"COMPACTION_REMINDER",
|
|
11
|
+
"COMPACTION_SOURCE",
|
|
12
|
+
"NOT_LOADED_REMINDER",
|
|
13
|
+
"POTETO_MODE_SKILL_NAME",
|
|
14
|
+
"PRE_TOOL_USE_EVENT_NAME",
|
|
15
|
+
"PROMPT_SEPARATOR",
|
|
16
|
+
"SESSION_START_EVENT_NAME",
|
|
17
|
+
"SKILL_TOOL_NAME",
|
|
18
|
+
"SLASH_COMMAND_MARKER",
|
|
19
|
+
"SUBAGENT_PROMPT_PREFIX",
|
|
20
|
+
"TOOL_USE_BLOCK_TYPE",
|
|
21
|
+
"USER_ENTRY_TYPE",
|
|
22
|
+
"USER_PROMPT_SUBMIT_EVENT_NAME",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
26
|
+
SESSION_START_EVENT_NAME = "SessionStart"
|
|
27
|
+
USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
|
|
28
|
+
COMPACTION_SOURCE = "compact"
|
|
29
|
+
|
|
30
|
+
ALL_SUBAGENT_TOOL_NAMES = frozenset({"Agent", "Task"})
|
|
31
|
+
ALL_SELF_LOADING_SUBAGENT_TYPES = frozenset({"pstack:poteto-agent", "poteto-agent"})
|
|
32
|
+
|
|
33
|
+
POTETO_MODE_SKILL_NAME = "pstack:poteto-mode"
|
|
34
|
+
SKILL_TOOL_NAME = "Skill"
|
|
35
|
+
SLASH_COMMAND_MARKER = "<command-name>/pstack:poteto-mode</command-name>"
|
|
36
|
+
PROMPT_SEPARATOR = "\n\n"
|
|
37
|
+
|
|
38
|
+
ASSISTANT_ENTRY_TYPE = "assistant"
|
|
39
|
+
USER_ENTRY_TYPE = "user"
|
|
40
|
+
TOOL_USE_BLOCK_TYPE = "tool_use"
|
|
41
|
+
COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
|
|
42
|
+
|
|
43
|
+
SUBAGENT_PROMPT_PREFIX = (
|
|
44
|
+
"Before any other work, invoke the pstack:poteto-mode skill with the Skill tool. "
|
|
45
|
+
"Every prompt you write for a subagent opens with the same instruction."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
NOT_LOADED_REMINDER = (
|
|
49
|
+
"The pstack:poteto-mode skill is not loaded in this context. "
|
|
50
|
+
"Your next tool call is the Skill tool with skill pstack:poteto-mode, before any other "
|
|
51
|
+
"tool call."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
COMPACTION_REMINDER = (
|
|
55
|
+
"The context was just compacted and the pstack:poteto-mode skill rules were dropped. "
|
|
56
|
+
"Your next tool call is the Skill tool with skill pstack:poteto-mode, before any other "
|
|
57
|
+
"tool call. Then re-read the request that started this session and continue."
|
|
58
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse, SessionStart and UserPromptSubmit hook. Keeps poteto-mode loaded in every context.
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
Agent tool call, prompt lacks the skill -> prompt opens with "invoke pstack:poteto-mode"
|
|
7
|
+
context compacted mid-run -> "invoke pstack:poteto-mode again"
|
|
8
|
+
user turn, skill not loaded since compacting -> "invoke pstack:poteto-mode now"
|
|
9
|
+
user turn, skill already loaded -> nothing
|
|
10
|
+
|
|
11
|
+
A session that loaded the skill hears nothing more until a compaction drops it.
|
|
12
|
+
The hook prints its output and stops.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if _hooks_dir not in sys.path:
|
|
24
|
+
sys.path.insert(0, _hooks_dir)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.skill_loaded_reminder_constants import (
|
|
27
|
+
ALL_SELF_LOADING_SUBAGENT_TYPES,
|
|
28
|
+
ALL_SUBAGENT_TOOL_NAMES,
|
|
29
|
+
ASSISTANT_ENTRY_TYPE,
|
|
30
|
+
COMPACT_BOUNDARY_SUBTYPE,
|
|
31
|
+
COMPACTION_REMINDER,
|
|
32
|
+
COMPACTION_SOURCE,
|
|
33
|
+
NOT_LOADED_REMINDER,
|
|
34
|
+
POTETO_MODE_SKILL_NAME,
|
|
35
|
+
PRE_TOOL_USE_EVENT_NAME,
|
|
36
|
+
PROMPT_SEPARATOR,
|
|
37
|
+
SESSION_START_EVENT_NAME,
|
|
38
|
+
SKILL_TOOL_NAME,
|
|
39
|
+
SLASH_COMMAND_MARKER,
|
|
40
|
+
SUBAGENT_PROMPT_PREFIX,
|
|
41
|
+
TOOL_USE_BLOCK_TYPE,
|
|
42
|
+
USER_ENTRY_TYPE,
|
|
43
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
44
|
+
)
|
|
45
|
+
from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
|
|
46
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
47
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def subagent_input_with_poteto_mode(
|
|
51
|
+
all_tool_input_fields: dict[str, object],
|
|
52
|
+
) -> dict[str, object] | None:
|
|
53
|
+
"""Return the Agent tool input with the poteto-mode instruction first, or None to leave it.
|
|
54
|
+
|
|
55
|
+
::
|
|
56
|
+
|
|
57
|
+
{"prompt": "Reply leaf."} -> {"prompt": "Before any ...\\n\\nReply leaf."}
|
|
58
|
+
{"prompt": "Invoke pstack:poteto-mode, then ..."} -> None
|
|
59
|
+
{"subagent_type": "pstack:poteto-agent", ...} -> None, that agent loads the skill itself
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
all_tool_input_fields: The Agent tool input the session is about to send.
|
|
63
|
+
"""
|
|
64
|
+
prompt = all_tool_input_fields.get("prompt")
|
|
65
|
+
if not isinstance(prompt, str) or POTETO_MODE_SKILL_NAME in prompt:
|
|
66
|
+
return None
|
|
67
|
+
if all_tool_input_fields.get("subagent_type") in ALL_SELF_LOADING_SUBAGENT_TYPES:
|
|
68
|
+
return None
|
|
69
|
+
return {**all_tool_input_fields, "prompt": SUBAGENT_PROMPT_PREFIX + PROMPT_SEPARATOR + prompt}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _invokes_poteto_mode(all_entry_fields: dict[str, object]) -> bool:
|
|
73
|
+
message = all_entry_fields.get("message")
|
|
74
|
+
if not isinstance(message, dict):
|
|
75
|
+
return False
|
|
76
|
+
content_blocks = message.get("content")
|
|
77
|
+
if all_entry_fields.get("type") == USER_ENTRY_TYPE and isinstance(content_blocks, str):
|
|
78
|
+
return SLASH_COMMAND_MARKER in content_blocks
|
|
79
|
+
if all_entry_fields.get("type") != ASSISTANT_ENTRY_TYPE or not isinstance(content_blocks, list):
|
|
80
|
+
return False
|
|
81
|
+
return any(
|
|
82
|
+
isinstance(each_block, dict)
|
|
83
|
+
and each_block.get("type") == TOOL_USE_BLOCK_TYPE
|
|
84
|
+
and each_block.get("name") == SKILL_TOOL_NAME
|
|
85
|
+
and isinstance(each_block.get("input"), dict)
|
|
86
|
+
and each_block["input"].get("skill") == POTETO_MODE_SKILL_NAME
|
|
87
|
+
for each_block in content_blocks
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _marker_entry(transcript_line: str) -> dict[str, object] | None:
|
|
92
|
+
if (
|
|
93
|
+
COMPACT_BOUNDARY_SUBTYPE not in transcript_line
|
|
94
|
+
and POTETO_MODE_SKILL_NAME not in transcript_line
|
|
95
|
+
):
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
parsed_entry = json.loads(transcript_line)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return None
|
|
101
|
+
return parsed_entry if isinstance(parsed_entry, dict) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_loaded_after(all_entry_fields: dict[str, object], was_loaded: bool) -> bool:
|
|
105
|
+
if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE:
|
|
106
|
+
return False
|
|
107
|
+
return was_loaded or _invokes_poteto_mode(all_entry_fields)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_poteto_mode_loaded(all_transcript_lines: Iterable[str]) -> bool:
|
|
111
|
+
"""Return True when the transcript invoked the skill after its last compaction.
|
|
112
|
+
|
|
113
|
+
::
|
|
114
|
+
|
|
115
|
+
Skill(pstack:poteto-mode) ... Read ... Edit -> True
|
|
116
|
+
/pstack:poteto-mode typed as a command ... Read -> True
|
|
117
|
+
Skill(pstack:poteto-mode) ... compact_boundary ... Read -> False
|
|
118
|
+
Read ... Edit -> False
|
|
119
|
+
|
|
120
|
+
Only lines naming the skill or a compaction are parsed, so a long transcript
|
|
121
|
+
reads fast.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
all_transcript_lines: The session transcript, one JSON entry per line.
|
|
125
|
+
"""
|
|
126
|
+
is_loaded = False
|
|
127
|
+
for each_line in all_transcript_lines:
|
|
128
|
+
all_entry_fields = _marker_entry(each_line)
|
|
129
|
+
if all_entry_fields is not None:
|
|
130
|
+
is_loaded = _is_loaded_after(all_entry_fields, is_loaded)
|
|
131
|
+
return is_loaded
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_loaded_in_transcript(transcript_path: object) -> bool:
|
|
135
|
+
if not isinstance(transcript_path, str):
|
|
136
|
+
return False
|
|
137
|
+
try:
|
|
138
|
+
with open(
|
|
139
|
+
transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
|
|
140
|
+
) as transcript:
|
|
141
|
+
return is_poteto_mode_loaded(transcript)
|
|
142
|
+
except OSError:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
147
|
+
"""Return the reminder a session event carries, or None when the skill is in view.
|
|
148
|
+
|
|
149
|
+
::
|
|
150
|
+
|
|
151
|
+
SessionStart, source compact -> COMPACTION_REMINDER
|
|
152
|
+
UserPromptSubmit, transcript lacks the skill -> NOT_LOADED_REMINDER
|
|
153
|
+
UserPromptSubmit, transcript loaded the skill -> None
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
all_hook_fields: The parsed hook input.
|
|
157
|
+
"""
|
|
158
|
+
event_name = all_hook_fields.get("hook_event_name")
|
|
159
|
+
if event_name == SESSION_START_EVENT_NAME:
|
|
160
|
+
return COMPACTION_REMINDER if all_hook_fields.get("source") == COMPACTION_SOURCE else None
|
|
161
|
+
if event_name != USER_PROMPT_SUBMIT_EVENT_NAME:
|
|
162
|
+
return None
|
|
163
|
+
if _is_loaded_in_transcript(all_hook_fields.get("transcript_path")):
|
|
164
|
+
return None
|
|
165
|
+
return NOT_LOADED_REMINDER
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_subagent_spawn(all_hook_fields: dict[str, object]) -> bool:
|
|
169
|
+
return (
|
|
170
|
+
all_hook_fields.get("hook_event_name") == PRE_TOOL_USE_EVENT_NAME
|
|
171
|
+
and all_hook_fields.get("tool_name") in ALL_SUBAGENT_TOOL_NAMES
|
|
172
|
+
and isinstance(all_hook_fields.get("tool_input"), dict)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> None:
|
|
177
|
+
"""Rewrite a subagent prompt, or print the reminder its event needs, or stay quiet."""
|
|
178
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
179
|
+
if hook_payload is None:
|
|
180
|
+
return
|
|
181
|
+
if _is_subagent_spawn(hook_payload):
|
|
182
|
+
rewritten_input = subagent_input_with_poteto_mode(hook_payload["tool_input"])
|
|
183
|
+
if rewritten_input is not None:
|
|
184
|
+
write_pre_tool_use_allow_to_stdout(rewritten_input)
|
|
185
|
+
return
|
|
186
|
+
reminder = reminder_for(hook_payload)
|
|
187
|
+
if reminder is None:
|
|
188
|
+
return
|
|
189
|
+
reminder_output = {
|
|
190
|
+
"hookSpecificOutput": {
|
|
191
|
+
"hookEventName": hook_payload["hook_event_name"],
|
|
192
|
+
"additionalContext": reminder,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
sys.stdout.write(json.dumps(reminder_output) + "\n")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Tests for skill_loaded_reminder. Checks every fresh context gets poteto-mode, and loaded ones hear nothing."""
|
|
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
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
10
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
11
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
12
|
+
if each_sys_path_entry not in sys.path:
|
|
13
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
14
|
+
|
|
15
|
+
import skill_loaded_reminder as reminder
|
|
16
|
+
from hooks_constants.skill_loaded_reminder_constants import (
|
|
17
|
+
COMPACTION_REMINDER,
|
|
18
|
+
NOT_LOADED_REMINDER,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
SKILL_CALL_LINE = json.dumps(
|
|
22
|
+
{
|
|
23
|
+
"type": "assistant",
|
|
24
|
+
"message": {
|
|
25
|
+
"role": "assistant",
|
|
26
|
+
"content": [
|
|
27
|
+
{
|
|
28
|
+
"type": "tool_use",
|
|
29
|
+
"name": "Skill",
|
|
30
|
+
"input": {"skill": "pstack:poteto-mode"},
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
TYPED_COMMAND_LINE = json.dumps(
|
|
37
|
+
{
|
|
38
|
+
"type": "user",
|
|
39
|
+
"message": {
|
|
40
|
+
"role": "user",
|
|
41
|
+
"content": "<command-name>/pstack:poteto-mode</command-name>",
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
COMPACT_BOUNDARY_LINE = json.dumps({"type": "system", "subtype": "compact_boundary"})
|
|
46
|
+
READ_CALL_LINE = json.dumps(
|
|
47
|
+
{
|
|
48
|
+
"type": "assistant",
|
|
49
|
+
"message": {
|
|
50
|
+
"content": [{"type": "tool_use", "name": "Read", "input": {"file_path": "a.py"}}]
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
TOOL_RESULT_QUOTING_THE_SKILL_LINE = json.dumps(
|
|
55
|
+
{
|
|
56
|
+
"type": "user",
|
|
57
|
+
"message": {
|
|
58
|
+
"content": [
|
|
59
|
+
{
|
|
60
|
+
"type": "tool_result",
|
|
61
|
+
"content": 'SKILL = "pstack:poteto-mode" and <command-name>/pstack:poteto-mode</command-name>',
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_main(hook_payload: object) -> str:
|
|
70
|
+
"""Return the text main() prints for the given hook payload."""
|
|
71
|
+
stdin_text = hook_payload if isinstance(hook_payload, str) else json.dumps(hook_payload)
|
|
72
|
+
captured_stdout = StringIO()
|
|
73
|
+
fake_stdin = TextIOWrapper(BytesIO(stdin_text.encode("utf-8")), encoding="utf-8")
|
|
74
|
+
with patch("sys.stdin", fake_stdin), patch("sys.stdout", captured_stdout):
|
|
75
|
+
reminder.main()
|
|
76
|
+
return captured_stdout.getvalue()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _agent_call(tool_input: dict[str, object], tool_name: str = "Agent") -> dict[str, object]:
|
|
80
|
+
return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _user_turn(transcript_path: Path) -> dict[str, object]:
|
|
84
|
+
return {"hook_event_name": "UserPromptSubmit", "transcript_path": str(transcript_path)}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
|
|
88
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
89
|
+
transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
|
|
90
|
+
return transcript_path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestSubagentSpawn:
|
|
94
|
+
def test_a_plain_subagent_prompt_opens_with_the_poteto_mode_invocation(self) -> None:
|
|
95
|
+
emitted = json.loads(
|
|
96
|
+
_run_main(
|
|
97
|
+
_agent_call(
|
|
98
|
+
{
|
|
99
|
+
"description": "Find callers",
|
|
100
|
+
"subagent_type": "Explore",
|
|
101
|
+
"prompt": "List every caller of main.",
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
107
|
+
rewritten_input = hook_output["updatedInput"]
|
|
108
|
+
assert hook_output["permissionDecision"] == "allow"
|
|
109
|
+
assert rewritten_input["prompt"].startswith(
|
|
110
|
+
"Before any other work, invoke the pstack:poteto-mode skill with the Skill tool."
|
|
111
|
+
)
|
|
112
|
+
assert rewritten_input["prompt"].endswith("\n\nList every caller of main.")
|
|
113
|
+
assert rewritten_input["subagent_type"] == "Explore"
|
|
114
|
+
assert rewritten_input["description"] == "Find callers"
|
|
115
|
+
|
|
116
|
+
def test_the_legacy_task_tool_name_is_rewritten_too(self) -> None:
|
|
117
|
+
emitted = json.loads(_run_main(_agent_call({"prompt": "Go."}, tool_name="Task")))
|
|
118
|
+
assert "pstack:poteto-mode" in emitted["hookSpecificOutput"]["updatedInput"]["prompt"]
|
|
119
|
+
|
|
120
|
+
def test_a_prompt_that_already_names_the_skill_is_left_alone(self) -> None:
|
|
121
|
+
assert _run_main(_agent_call({"prompt": "Invoke pstack:poteto-mode first. Then go."})) == ""
|
|
122
|
+
|
|
123
|
+
def test_a_poteto_agent_is_left_alone_because_it_loads_the_skill_itself(self) -> None:
|
|
124
|
+
assert (
|
|
125
|
+
_run_main(_agent_call({"subagent_type": "pstack:poteto-agent", "prompt": "Go."})) == ""
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def test_another_tool_is_left_alone(self) -> None:
|
|
129
|
+
assert _run_main(_agent_call({"prompt": "Go."}, tool_name="Bash")) == ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestSubagentInputWithPotetoMode:
|
|
133
|
+
def test_every_other_input_field_is_kept(self) -> None:
|
|
134
|
+
rewritten_input = reminder.subagent_input_with_poteto_mode(
|
|
135
|
+
{"prompt": "Go.", "model": "sonnet", "run_in_background": True}
|
|
136
|
+
)
|
|
137
|
+
assert rewritten_input is not None
|
|
138
|
+
assert rewritten_input["model"] == "sonnet"
|
|
139
|
+
assert rewritten_input["run_in_background"] is True
|
|
140
|
+
|
|
141
|
+
def test_an_input_with_no_prompt_is_left_alone(self) -> None:
|
|
142
|
+
assert reminder.subagent_input_with_poteto_mode({"description": "Go"}) is None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestIsPotetoModeLoaded:
|
|
146
|
+
def test_a_skill_call_loads_it(self) -> None:
|
|
147
|
+
assert reminder.is_poteto_mode_loaded([READ_CALL_LINE, SKILL_CALL_LINE, READ_CALL_LINE])
|
|
148
|
+
|
|
149
|
+
def test_a_typed_slash_command_loads_it(self) -> None:
|
|
150
|
+
assert reminder.is_poteto_mode_loaded([TYPED_COMMAND_LINE, READ_CALL_LINE])
|
|
151
|
+
|
|
152
|
+
def test_a_compaction_after_the_skill_call_drops_it(self) -> None:
|
|
153
|
+
assert not reminder.is_poteto_mode_loaded(
|
|
154
|
+
[SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_a_skill_call_after_the_compaction_loads_it_again(self) -> None:
|
|
158
|
+
assert reminder.is_poteto_mode_loaded(
|
|
159
|
+
[SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, SKILL_CALL_LINE]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def test_a_plain_user_prompt_does_not_load_it(self) -> None:
|
|
163
|
+
plain_prompt_line = json.dumps(
|
|
164
|
+
{"type": "user", "message": {"content": "Read pstack:poteto-mode docs."}}
|
|
165
|
+
)
|
|
166
|
+
assert not reminder.is_poteto_mode_loaded([plain_prompt_line])
|
|
167
|
+
|
|
168
|
+
def test_a_tool_result_that_quotes_the_skill_name_does_not_load_it(self) -> None:
|
|
169
|
+
assert not reminder.is_poteto_mode_loaded([TOOL_RESULT_QUOTING_THE_SKILL_LINE])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestReminderFor:
|
|
173
|
+
def test_a_user_turn_in_a_session_that_never_loaded_the_skill_gets_the_reminder(
|
|
174
|
+
self, tmp_path: Path
|
|
175
|
+
) -> None:
|
|
176
|
+
transcript_path = _write_transcript(tmp_path, [READ_CALL_LINE])
|
|
177
|
+
emitted = json.loads(_run_main(_user_turn(transcript_path)))
|
|
178
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
179
|
+
assert hook_output["hookEventName"] == "UserPromptSubmit"
|
|
180
|
+
assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
|
|
181
|
+
assert "pstack:poteto-mode" in NOT_LOADED_REMINDER
|
|
182
|
+
|
|
183
|
+
def test_a_user_turn_in_a_session_that_loaded_the_skill_prints_nothing(
|
|
184
|
+
self, tmp_path: Path
|
|
185
|
+
) -> None:
|
|
186
|
+
transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, READ_CALL_LINE])
|
|
187
|
+
assert _run_main(_user_turn(transcript_path)) == ""
|
|
188
|
+
|
|
189
|
+
def test_a_user_turn_with_no_transcript_yet_gets_the_reminder(self, tmp_path: Path) -> None:
|
|
190
|
+
assert (
|
|
191
|
+
reminder.reminder_for(_user_turn(tmp_path / "not-written-yet.jsonl"))
|
|
192
|
+
== NOT_LOADED_REMINDER
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def test_session_start_after_compaction_tells_the_session_to_invoke_it_again(self) -> None:
|
|
196
|
+
emitted = json.loads(_run_main({"hook_event_name": "SessionStart", "source": "compact"}))
|
|
197
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
198
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
199
|
+
assert hook_output["additionalContext"] == COMPACTION_REMINDER
|
|
200
|
+
assert "pstack:poteto-mode" in COMPACTION_REMINDER
|
|
201
|
+
|
|
202
|
+
def test_a_resumed_session_keeps_its_context_and_prints_nothing(self) -> None:
|
|
203
|
+
assert _run_main({"hook_event_name": "SessionStart", "source": "resume"}) == ""
|
|
204
|
+
|
|
205
|
+
def test_a_payload_that_is_not_json_prints_nothing(self) -> None:
|
|
206
|
+
assert _run_main("not json") == ""
|
package/package.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
## When this applies
|
|
4
4
|
|
|
5
5
|
Use this rule for a GitHub issue, pull request, comment, or review created
|
|
6
|
-
through `gh` or a GitHub MCP post tool.
|
|
6
|
+
through `gh` or a GitHub MCP post tool. The image section also covers a PNG a
|
|
7
|
+
commit adds and a file a release upload sends.
|
|
7
8
|
|
|
8
9
|
## Rule
|
|
9
10
|
|
|
@@ -26,6 +27,24 @@ Handle text and binary content differently:
|
|
|
26
27
|
under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints a permanent download
|
|
27
28
|
URL. Put that URL in the post.
|
|
28
29
|
|
|
30
|
+
## Optimize every image before it reaches GitHub
|
|
31
|
+
|
|
32
|
+
The helper shrinks a PNG with [oxipng](https://github.com/oxipng/oxipng) before
|
|
33
|
+
it uploads, and prints the size before and after. Oxipng is lossless.
|
|
34
|
+
`--strip none` keeps every chunk, and `--nb --nc` keep the bit depth and the
|
|
35
|
+
color type, so a reader that checks the image mode sees the same file shape.
|
|
36
|
+
|
|
37
|
+
Any other route to GitHub runs the same pass first: a PNG a commit adds, and a
|
|
38
|
+
file sent with `gh release upload`.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
oxipng --opt 4 --strip none --nb --nc <file> [<file> ...]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `Binary optimization` check fails a pull request whose changed PNG files
|
|
45
|
+
still shrink under that pass. A fixture whose exact bytes a test pins takes
|
|
46
|
+
`binary-optimizer=keep` in `.gitattributes`, and the check skips it.
|
|
47
|
+
|
|
29
48
|
## Volatile paths that must not appear in a post body
|
|
30
49
|
|
|
31
50
|
- A job scratch directory: `.claude-profile-a/jobs/`
|
|
@@ -46,5 +65,11 @@ server write. Pass the matching action and body file. Use `pr-create`,
|
|
|
46
65
|
`pr-edit`, `pr-comment`, `pr-review`, `issue-create`, `issue-edit`,
|
|
47
66
|
`issue-comment`, or `github-mcp-post`.
|
|
48
67
|
|
|
68
|
+
Pass `--repository <owner>/<name>` for the repository the post targets. A
|
|
69
|
+
post may name a private organization only inside a repository that
|
|
70
|
+
organization owns. The linter holds the names as digests, so it reports the
|
|
71
|
+
line that names one without printing the name. Describe the organization in
|
|
72
|
+
general terms and drop the link.
|
|
73
|
+
|
|
49
74
|
The linter reads the body file and reports a volatile local path without
|
|
50
75
|
printing the body. Fix the body and rerun the linter before posting.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Prefer Existing Tools
|
|
2
|
+
|
|
3
|
+
**When this applies:** Before you build a tool, check, scanner, script, or library of your own.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Search in this order, and stop at the first fit:
|
|
8
|
+
|
|
9
|
+
1. **Internal code.** Search the repository and its shared packages for a tool that already does the job. Reuse or extend it.
|
|
10
|
+
2. **An established open-source option.** Search for a plugin, package, or tool that already does the job.
|
|
11
|
+
3. **Your own code.** Build it only when steps 1 and 2 find nothing that fits.
|
|
12
|
+
|
|
13
|
+
Use an open-source option only when it meets all four conditions:
|
|
14
|
+
|
|
15
|
+
- It is secure: it has an active security process and no open critical advisory.
|
|
16
|
+
- It is well known in its field.
|
|
17
|
+
- It is widely used: many stars, downloads, or dependents.
|
|
18
|
+
- It is maintained: it had a release or commit in the last year.
|
|
19
|
+
|
|
20
|
+
Add only what the option lacks. Put custom rules in its own configuration format. Do not wrap it in a new layer of your own code.
|
|
21
|
+
|
|
22
|
+
## Report the choice
|
|
23
|
+
|
|
24
|
+
Tell the user which tool you chose before the work is marked ready. Give its name, link, license, and one number that shows how widely it is used. When you build your own code, name what you searched and why each candidate did not fit.
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
Code of your own is code you maintain alone. A trusted tool carries fixes and new patterns from its community, and its users find its defects first.
|
|
29
|
+
|
|
30
|
+
## Sibling rules
|
|
31
|
+
|
|
32
|
+
| Rule | Role |
|
|
33
|
+
|---|---|
|
|
34
|
+
| [`explore-thoroughly.md`](explore-thoroughly.md) | Read what exists before you propose a change |
|
|
35
|
+
| [`verify-before-asking.md`](verify-before-asking.md) | Answer a question with a tool before you ask it |
|
|
@@ -33,7 +33,7 @@ The driving account is the one that opened the pull request. Where the agent com
|
|
|
33
33
|
|
|
34
34
|
`.github/workflows/review-closure.yml` runs it here on a push to a pull request, on a submitted or dismissed review, and on a review comment. Each run reports on the pull request's head commit, so a finding posted after the last push still turns the check red.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
A private repository that installs this package runs the same command from its own workflow, against the revision of this package that its workflow pins.
|
|
37
37
|
|
|
38
38
|
## Sibling rules
|
|
39
39
|
|