claude-dev-env 8.8.0 → 8.10.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/skills/privacy-hygiene/SKILL.md +1 -0
  2. package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
  3. package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
  4. package/bin/install-constants.mjs +5 -0
  5. package/bin/install.cursor-rules.test.mjs +6 -0
  6. package/bin/install.mjs +2 -2
  7. package/docs/second-claude-account.md +8 -1
  8. package/hooks/hooks.json +37 -0
  9. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +70 -0
  10. package/hooks/session/skill_loaded_reminder.py +214 -0
  11. package/hooks/session/test_skill_loaded_reminder.py +253 -0
  12. package/package.json +1 -1
  13. package/rules/durable-post-artifacts.md +26 -1
  14. package/rules/prefer-existing-tools.md +35 -0
  15. package/rules/review-closure-is-a-check.md +1 -1
  16. package/scripts/claude_account_choice.py +51 -6
  17. package/scripts/dev_env_scripts_constants/claude_account_constants.py +15 -0
  18. package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
  19. package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
  20. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +25 -0
  21. package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
  22. package/scripts/durable_post_lint.py +47 -1
  23. package/scripts/durable_post_lint_config/config/constants.py +1 -0
  24. package/scripts/gh_artifact_upload.py +56 -3
  25. package/scripts/private_term_scan.py +135 -0
  26. package/scripts/private_terms.py +87 -0
  27. package/scripts/repository_checks/config/constants.py +3 -0
  28. package/scripts/repository_checks/runner.py +7 -0
  29. package/scripts/repository_checks/tracked_private_terms.py +57 -0
  30. package/scripts/test_claude_account_choice.py +28 -0
  31. package/scripts/test_durable_post_lint.py +90 -0
  32. package/scripts/tests/test_gh_artifact_upload.py +75 -0
  33. package/scripts/tests/test_match_exemptions.py +2 -2
  34. package/scripts/tests/test_private_term_scan.py +176 -0
  35. package/scripts/tests/test_private_terms.py +59 -0
  36. package/scripts/tests/test_repository_policy.py +2 -0
  37. package/scripts/tests/test_runner.py +20 -0
  38. package/scripts/tests/test_tracked_private_terms.py +71 -0
  39. 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]
@@ -179,3 +179,8 @@ export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
179
179
  export const PYTHON_PROBE_TIMEOUT_MILLISECONDS = 10_000;
180
180
  export const ADDITIONAL_SETTINGS_BLOB_PREFIX = 'settings-';
181
181
  export const CODEX_NATIVE_ROUTING_MATCHER = 'multi_agent_v1__spawn_agent';
182
+ export const CODEX_SUBAGENT_PROMPT_MATCHER = 'Agent|Task';
183
+ export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
184
+ CODEX_NATIVE_ROUTING_MATCHER,
185
+ CODEX_SUBAGENT_PROMPT_MATCHER,
186
+ ]);
@@ -148,6 +148,12 @@ test('seeds one editable policy and one native Codex routing hook', () => {
148
148
  assert.equal(firstRoutingGroups.length, 1);
149
149
  assert.equal(firstRoutingGroups[0].hooks.length, 1);
150
150
  assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
151
+ const firstSpawnPromptGroups = firstCodexHooks.hooks.PreToolUse.filter(
152
+ group => group.matcher === 'Agent|Task',
153
+ );
154
+ assert.equal(firstSpawnPromptGroups.length, 1);
155
+ assert.match(firstSpawnPromptGroups[0].hooks[0].command, /skill_loaded_reminder\.py/);
156
+ assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse']);
151
157
  assert.equal(
152
158
  firstCodexHooks.hooks.PreToolUse.some(group => group.matcher === 'Write|Edit|MultiEdit|apply_patch'),
153
159
  false,
package/bin/install.mjs CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  SUBAGENT_MODEL_POLICY_FILE_NAME,
39
39
  WINDOWS_PYTHON_LAUNCHER_COMMAND,
40
40
  PYTHON_PROBE_TIMEOUT_MILLISECONDS,
41
- CODEX_NATIVE_ROUTING_MATCHER,
41
+ ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS,
42
42
  } from './install-constants.mjs';
43
43
  import {
44
44
  installPstackPlugin,
@@ -2102,7 +2102,7 @@ function selectCodexNativeHooksConfig(hooksConfig) {
2102
2102
  ...hooksConfig,
2103
2103
  hooks: {
2104
2104
  PreToolUse: Array.isArray(preToolUseGroups)
2105
- ? preToolUseGroups.filter(group => group?.matcher === CODEX_NATIVE_ROUTING_MATCHER)
2105
+ ? preToolUseGroups.filter(group => ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS.has(group?.matcher))
2106
2106
  : [],
2107
2107
  },
2108
2108
  };
@@ -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,28 @@
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
+ ]
123
+ }
124
+ ],
125
+ "SubagentStart": [
126
+ {
127
+ "matcher": "workflow-subagent",
128
+ "hooks": [
129
+ {
130
+ "type": "command",
131
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
132
+ "timeout": 10
133
+ }
134
+ ]
98
135
  }
99
136
  ],
100
137
  "SessionEnd": [
@@ -0,0 +1,70 @@
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
+ "ASSISTANT_ENTRY_TYPE",
8
+ "COMPACT_BOUNDARY_SUBTYPE",
9
+ "COMPACTION_REMINDER",
10
+ "COMPACTION_SOURCE",
11
+ "NOT_LOADED_REMINDER",
12
+ "POTETO_MODE_SKILL_NAME",
13
+ "PRE_TOOL_USE_EVENT_NAME",
14
+ "PROMPT_SEPARATOR",
15
+ "SESSION_START_EVENT_NAME",
16
+ "SKILL_TOOL_NAME",
17
+ "SLASH_COMMAND_MARKER",
18
+ "SUBAGENT_START_EVENT_NAME",
19
+ "CLAUDE_SUBAGENT_PROMPT_PREFIX",
20
+ "CODEX_SUBAGENT_PROMPT_PREFIX",
21
+ "ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME",
22
+ "TOOL_USE_BLOCK_TYPE",
23
+ "USER_ENTRY_TYPE",
24
+ "USER_PROMPT_SUBMIT_EVENT_NAME",
25
+ "WORKFLOW_SUBAGENT_TYPE",
26
+ ]
27
+
28
+ PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
29
+ SESSION_START_EVENT_NAME = "SessionStart"
30
+ USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
31
+ COMPACTION_SOURCE = "compact"
32
+ SUBAGENT_START_EVENT_NAME = "SubagentStart"
33
+ WORKFLOW_SUBAGENT_TYPE = "workflow-subagent"
34
+
35
+ ALL_SELF_LOADING_SUBAGENT_TYPES = frozenset({"pstack:poteto-agent", "poteto-agent"})
36
+
37
+ POTETO_MODE_SKILL_NAME = "pstack:poteto-mode"
38
+ SKILL_TOOL_NAME = "Skill"
39
+ SLASH_COMMAND_MARKER = "<command-name>/pstack:poteto-mode</command-name>"
40
+ PROMPT_SEPARATOR = "\n\n"
41
+
42
+ ASSISTANT_ENTRY_TYPE = "assistant"
43
+ USER_ENTRY_TYPE = "user"
44
+ TOOL_USE_BLOCK_TYPE = "tool_use"
45
+ COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
46
+
47
+ CLAUDE_SUBAGENT_PROMPT_PREFIX = (
48
+ "Before any other work, invoke the pstack:poteto-mode skill with the Skill tool. "
49
+ "Every prompt you write for a subagent opens with the same instruction."
50
+ )
51
+
52
+ CODEX_SUBAGENT_PROMPT_PREFIX = "$pstack:poteto-mode"
53
+
54
+ ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME = {
55
+ "Agent": ("prompt", CLAUDE_SUBAGENT_PROMPT_PREFIX),
56
+ "Task": ("prompt", CLAUDE_SUBAGENT_PROMPT_PREFIX),
57
+ "spawn_agent": ("message", CODEX_SUBAGENT_PROMPT_PREFIX),
58
+ }
59
+
60
+ NOT_LOADED_REMINDER = (
61
+ "The pstack:poteto-mode skill is not loaded in this context. "
62
+ "Your next tool call is the Skill tool with skill pstack:poteto-mode, before any other "
63
+ "tool call."
64
+ )
65
+
66
+ COMPACTION_REMINDER = (
67
+ "The context was just compacted and the pstack:poteto-mode skill rules were dropped. "
68
+ "Your next tool call is the Skill tool with skill pstack:poteto-mode, before any other "
69
+ "tool call. Then re-read the request that started this session and continue."
70
+ )
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Hook that keeps poteto-mode loaded in every session, subagent and workflow helper.
3
+
4
+ ::
5
+
6
+ Agent or Codex spawn_agent, skill not named -> prompt opens with "invoke pstack:poteto-mode"
7
+ Workflow script helper starts -> "invoke pstack:poteto-mode now"
8
+ context compacted mid-run -> "invoke pstack:poteto-mode again"
9
+ user turn, skill not loaded since compacting -> "invoke pstack:poteto-mode now"
10
+ user turn, skill already loaded -> nothing
11
+
12
+ A session that loaded the skill hears nothing more until a compaction drops it.
13
+ The hook prints its output and stops.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from collections.abc import Iterable
21
+ from pathlib import Path
22
+
23
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
24
+ if _hooks_dir not in sys.path:
25
+ sys.path.insert(0, _hooks_dir)
26
+
27
+ from hooks_constants.skill_loaded_reminder_constants import (
28
+ ALL_SELF_LOADING_SUBAGENT_TYPES,
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_START_EVENT_NAME,
41
+ ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME,
42
+ TOOL_USE_BLOCK_TYPE,
43
+ USER_ENTRY_TYPE,
44
+ USER_PROMPT_SUBMIT_EVENT_NAME,
45
+ WORKFLOW_SUBAGENT_TYPE,
46
+ )
47
+ from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
48
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
49
+ from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
50
+
51
+
52
+ def subagent_input_with_poteto_mode(
53
+ tool_name: str,
54
+ all_tool_input_fields: dict[str, object],
55
+ ) -> dict[str, object] | None:
56
+ """Return the spawn input with the poteto-mode invocation first, or None to leave it.
57
+
58
+ ::
59
+
60
+ Agent {"prompt": "Reply leaf."} -> {"prompt": "Before any ...\\n\\nReply leaf."}
61
+ spawn_agent {"message": "Fix it."} -> {"message": "$pstack:poteto-mode\\n\\nFix it."}
62
+ Agent {"prompt": "Invoke pstack:poteto-mode, then ..."} -> None
63
+ Agent {"subagent_type": "pstack:poteto-agent", ...} -> None, it loads the skill
64
+
65
+ Claude Code spawns through Agent or Task and Codex through spawn_agent, so
66
+ each tool name carries its own prompt field and invocation text.
67
+
68
+ Args:
69
+ tool_name: The spawn tool the session called.
70
+ all_tool_input_fields: The spawn input the session is about to send.
71
+ """
72
+ field_name, invocation_prefix = ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME[tool_name]
73
+ prompt = all_tool_input_fields.get(field_name)
74
+ if not isinstance(prompt, str) or POTETO_MODE_SKILL_NAME in prompt:
75
+ return None
76
+ if all_tool_input_fields.get("subagent_type") in ALL_SELF_LOADING_SUBAGENT_TYPES:
77
+ return None
78
+ return {**all_tool_input_fields, field_name: invocation_prefix + PROMPT_SEPARATOR + prompt}
79
+
80
+
81
+ def _invokes_poteto_mode(all_entry_fields: dict[str, object]) -> bool:
82
+ message = all_entry_fields.get("message")
83
+ if not isinstance(message, dict):
84
+ return False
85
+ content_blocks = message.get("content")
86
+ if all_entry_fields.get("type") == USER_ENTRY_TYPE and isinstance(content_blocks, str):
87
+ return SLASH_COMMAND_MARKER in content_blocks
88
+ if all_entry_fields.get("type") != ASSISTANT_ENTRY_TYPE or not isinstance(content_blocks, list):
89
+ return False
90
+ return any(
91
+ isinstance(each_block, dict)
92
+ and each_block.get("type") == TOOL_USE_BLOCK_TYPE
93
+ and each_block.get("name") == SKILL_TOOL_NAME
94
+ and isinstance(each_block.get("input"), dict)
95
+ and each_block["input"].get("skill") == POTETO_MODE_SKILL_NAME
96
+ for each_block in content_blocks
97
+ )
98
+
99
+
100
+ def _marker_entry(transcript_line: str) -> dict[str, object] | None:
101
+ if (
102
+ COMPACT_BOUNDARY_SUBTYPE not in transcript_line
103
+ and POTETO_MODE_SKILL_NAME not in transcript_line
104
+ ):
105
+ return None
106
+ try:
107
+ parsed_entry = json.loads(transcript_line)
108
+ except json.JSONDecodeError:
109
+ return None
110
+ return parsed_entry if isinstance(parsed_entry, dict) else None
111
+
112
+
113
+ def _is_loaded_after(all_entry_fields: dict[str, object], was_loaded: bool) -> bool:
114
+ if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE:
115
+ return False
116
+ return was_loaded or _invokes_poteto_mode(all_entry_fields)
117
+
118
+
119
+ def is_poteto_mode_loaded(all_transcript_lines: Iterable[str]) -> bool:
120
+ """Return True when the transcript invoked the skill after its last compaction.
121
+
122
+ ::
123
+
124
+ Skill(pstack:poteto-mode) ... Read ... Edit -> True
125
+ /pstack:poteto-mode typed as a command ... Read -> True
126
+ Skill(pstack:poteto-mode) ... compact_boundary ... Read -> False
127
+ Read ... Edit -> False
128
+
129
+ Only lines naming the skill or a compaction are parsed, so a long transcript
130
+ reads fast.
131
+
132
+ Args:
133
+ all_transcript_lines: The session transcript, one JSON entry per line.
134
+ """
135
+ is_loaded = False
136
+ for each_line in all_transcript_lines:
137
+ all_entry_fields = _marker_entry(each_line)
138
+ if all_entry_fields is not None:
139
+ is_loaded = _is_loaded_after(all_entry_fields, is_loaded)
140
+ return is_loaded
141
+
142
+
143
+ def _is_loaded_in_transcript(transcript_path: object) -> bool:
144
+ if not isinstance(transcript_path, str):
145
+ return False
146
+ try:
147
+ with open(
148
+ transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
149
+ ) as transcript:
150
+ return is_poteto_mode_loaded(transcript)
151
+ except OSError:
152
+ return False
153
+
154
+
155
+ def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
156
+ """Return the reminder a session event carries, or None when the skill is in view.
157
+
158
+ ::
159
+
160
+ SessionStart, source compact -> COMPACTION_REMINDER
161
+ SubagentStart, workflow-subagent -> NOT_LOADED_REMINDER
162
+ UserPromptSubmit, transcript lacks the skill -> NOT_LOADED_REMINDER
163
+ UserPromptSubmit, transcript loaded the skill -> None
164
+
165
+ Args:
166
+ all_hook_fields: The parsed hook input.
167
+ """
168
+ event_name = all_hook_fields.get("hook_event_name")
169
+ if event_name == SESSION_START_EVENT_NAME:
170
+ return COMPACTION_REMINDER if all_hook_fields.get("source") == COMPACTION_SOURCE else None
171
+ if event_name == SUBAGENT_START_EVENT_NAME:
172
+ is_workflow_helper = all_hook_fields.get("agent_type") == WORKFLOW_SUBAGENT_TYPE
173
+ return NOT_LOADED_REMINDER if is_workflow_helper else None
174
+ if event_name != USER_PROMPT_SUBMIT_EVENT_NAME:
175
+ return None
176
+ if _is_loaded_in_transcript(all_hook_fields.get("transcript_path")):
177
+ return None
178
+ return NOT_LOADED_REMINDER
179
+
180
+
181
+ def _is_subagent_spawn(all_hook_fields: dict[str, object]) -> bool:
182
+ return (
183
+ all_hook_fields.get("hook_event_name") == PRE_TOOL_USE_EVENT_NAME
184
+ and all_hook_fields.get("tool_name") in ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME
185
+ and isinstance(all_hook_fields.get("tool_input"), dict)
186
+ )
187
+
188
+
189
+ def main() -> None:
190
+ """Rewrite a subagent prompt, or print the reminder its event needs, or stay quiet."""
191
+ hook_payload = read_hook_input_dictionary_from_stdin()
192
+ if hook_payload is None:
193
+ return
194
+ if _is_subagent_spawn(hook_payload):
195
+ rewritten_input = subagent_input_with_poteto_mode(
196
+ hook_payload["tool_name"], hook_payload["tool_input"]
197
+ )
198
+ if rewritten_input is not None:
199
+ write_pre_tool_use_allow_to_stdout(rewritten_input)
200
+ return
201
+ reminder = reminder_for(hook_payload)
202
+ if reminder is None:
203
+ return
204
+ reminder_output = {
205
+ "hookSpecificOutput": {
206
+ "hookEventName": hook_payload["hook_event_name"],
207
+ "additionalContext": reminder,
208
+ }
209
+ }
210
+ sys.stdout.write(json.dumps(reminder_output) + "\n")
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
@@ -0,0 +1,253 @@
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
+ "Agent", {"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("Agent", {"description": "Go"}) is None
143
+
144
+ def test_a_codex_spawn_input_with_only_items_is_left_alone(self) -> None:
145
+ assert (
146
+ reminder.subagent_input_with_poteto_mode(
147
+ "spawn_agent", {"items": [{"type": "text", "text": "Go."}]}
148
+ )
149
+ is None
150
+ )
151
+
152
+
153
+ class TestCodexSpawn:
154
+ def test_a_codex_spawn_message_opens_with_the_skill_mention(self) -> None:
155
+ emitted = json.loads(
156
+ _run_main(
157
+ _agent_call(
158
+ {"message": "Fix the failing test.", "model": "gpt-6-luna"},
159
+ tool_name="spawn_agent",
160
+ )
161
+ )
162
+ )
163
+ rewritten_input = emitted["hookSpecificOutput"]["updatedInput"]
164
+ assert rewritten_input["message"] == "$pstack:poteto-mode\n\nFix the failing test."
165
+ assert rewritten_input["model"] == "gpt-6-luna"
166
+ assert "prompt" not in rewritten_input
167
+
168
+ def test_a_codex_spawn_message_that_already_mentions_the_skill_is_left_alone(self) -> None:
169
+ assert (
170
+ _run_main(
171
+ _agent_call({"message": "$pstack:poteto-mode\n\nFix it."}, tool_name="spawn_agent")
172
+ )
173
+ == ""
174
+ )
175
+
176
+
177
+ class TestIsPotetoModeLoaded:
178
+ def test_a_skill_call_loads_it(self) -> None:
179
+ assert reminder.is_poteto_mode_loaded([READ_CALL_LINE, SKILL_CALL_LINE, READ_CALL_LINE])
180
+
181
+ def test_a_typed_slash_command_loads_it(self) -> None:
182
+ assert reminder.is_poteto_mode_loaded([TYPED_COMMAND_LINE, READ_CALL_LINE])
183
+
184
+ def test_a_compaction_after_the_skill_call_drops_it(self) -> None:
185
+ assert not reminder.is_poteto_mode_loaded(
186
+ [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE]
187
+ )
188
+
189
+ def test_a_skill_call_after_the_compaction_loads_it_again(self) -> None:
190
+ assert reminder.is_poteto_mode_loaded(
191
+ [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, SKILL_CALL_LINE]
192
+ )
193
+
194
+ def test_a_plain_user_prompt_does_not_load_it(self) -> None:
195
+ plain_prompt_line = json.dumps(
196
+ {"type": "user", "message": {"content": "Read pstack:poteto-mode docs."}}
197
+ )
198
+ assert not reminder.is_poteto_mode_loaded([plain_prompt_line])
199
+
200
+ def test_a_tool_result_that_quotes_the_skill_name_does_not_load_it(self) -> None:
201
+ assert not reminder.is_poteto_mode_loaded([TOOL_RESULT_QUOTING_THE_SKILL_LINE])
202
+
203
+
204
+ class TestReminderFor:
205
+ def test_a_user_turn_in_a_session_that_never_loaded_the_skill_gets_the_reminder(
206
+ self, tmp_path: Path
207
+ ) -> None:
208
+ transcript_path = _write_transcript(tmp_path, [READ_CALL_LINE])
209
+ emitted = json.loads(_run_main(_user_turn(transcript_path)))
210
+ hook_output = emitted["hookSpecificOutput"]
211
+ assert hook_output["hookEventName"] == "UserPromptSubmit"
212
+ assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
213
+ assert "pstack:poteto-mode" in NOT_LOADED_REMINDER
214
+
215
+ def test_a_user_turn_in_a_session_that_loaded_the_skill_prints_nothing(
216
+ self, tmp_path: Path
217
+ ) -> None:
218
+ transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, READ_CALL_LINE])
219
+ assert _run_main(_user_turn(transcript_path)) == ""
220
+
221
+ def test_a_user_turn_with_no_transcript_yet_gets_the_reminder(self, tmp_path: Path) -> None:
222
+ assert (
223
+ reminder.reminder_for(_user_turn(tmp_path / "not-written-yet.jsonl"))
224
+ == NOT_LOADED_REMINDER
225
+ )
226
+
227
+ def test_session_start_after_compaction_tells_the_session_to_invoke_it_again(self) -> None:
228
+ emitted = json.loads(_run_main({"hook_event_name": "SessionStart", "source": "compact"}))
229
+ hook_output = emitted["hookSpecificOutput"]
230
+ assert hook_output["hookEventName"] == "SessionStart"
231
+ assert hook_output["additionalContext"] == COMPACTION_REMINDER
232
+ assert "pstack:poteto-mode" in COMPACTION_REMINDER
233
+
234
+ def test_a_resumed_session_keeps_its_context_and_prints_nothing(self) -> None:
235
+ assert _run_main({"hook_event_name": "SessionStart", "source": "resume"}) == ""
236
+
237
+ def test_a_workflow_helper_starting_is_told_to_invoke_the_skill(self) -> None:
238
+ emitted = json.loads(
239
+ _run_main({"hook_event_name": "SubagentStart", "agent_type": "workflow-subagent"})
240
+ )
241
+ hook_output = emitted["hookSpecificOutput"]
242
+ assert hook_output["hookEventName"] == "SubagentStart"
243
+ assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
244
+
245
+ def test_an_agent_tool_helper_starting_prints_nothing_since_its_prompt_carries_the_line(
246
+ self,
247
+ ) -> None:
248
+ assert (
249
+ _run_main({"hook_event_name": "SubagentStart", "agent_type": "general-purpose"}) == ""
250
+ )
251
+
252
+ def test_a_payload_that_is_not_json_prints_nothing(self) -> None:
253
+ assert _run_main("not json") == ""