claude-dev-env 8.96.8 → 8.98.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/bin/install-pstack-plugin.mjs +31 -4
- package/bin/install-pstack-plugin.test.mjs +23 -0
- package/bin/install.prompt-context.test.mjs +3 -1
- package/bin/install.settings-defaults.test.mjs +4 -3
- package/docs/agents-guidance/asks.md +6 -1
- package/hooks/blocking/open_tasks_stop_gate.py +128 -34
- package/hooks/blocking/open_tasks_transcript.py +112 -5
- package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
- package/hooks/blocking/python_windows_invocation_gate.py +249 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
- package/hooks/blocking/test_open_tasks_transcript.py +89 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
- package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
- package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
- package/hooks/blocking/verify_before_commit_gate.py +278 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +10 -2
- package/hooks/features/session-context.md +4 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
- package/hooks/hooks_constants/plugin_session_baseline_constants.py +50 -0
- package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
- package/hooks/open_task_asks.py +163 -0
- package/hooks/plugin_session_baseline.py +190 -0
- package/hooks/session/open_task_ask_recorder.py +38 -0
- package/hooks/session/plugin_change_warning.py +90 -0
- package/hooks/session/plugin_session_baseline_writer.py +53 -0
- package/hooks/session/test_open_task_ask_recorder.py +57 -0
- package/hooks/session/test_plugin_change_warning.py +216 -0
- package/hooks/session/test_plugin_session_baseline_writer.py +136 -0
- package/hooks/test_context_budget_hooks.py +2 -0
- package/hooks/test_open_task_asks.py +52 -0
- package/hooks/test_plugin_session_baseline.py +98 -0
- package/hooks/test_transcript_skill_scan.py +24 -0
- package/hooks/transcript_skill_scan.py +54 -3
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +2 -1
|
@@ -12,7 +12,7 @@ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSH
|
|
|
12
12
|
BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def test_roster_hosts_the_five_gates_and_hands_two_to_the_shell_guards_mod() -> None:
|
|
16
16
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
17
17
|
BashHostedHookEntry(
|
|
18
18
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -32,4 +32,8 @@ def test_roster_hosts_the_four_gates_and_hands_two_to_the_shell_guards_mod() ->
|
|
|
32
32
|
script_relative_path="blocking/cloud_graphql_gate.py",
|
|
33
33
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
34
34
|
),
|
|
35
|
+
BashHostedHookEntry(
|
|
36
|
+
script_relative_path="blocking/python_windows_invocation_gate.py",
|
|
37
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
38
|
+
),
|
|
35
39
|
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Constants for the verify-before-commit gate."""
|
|
2
|
+
|
|
3
|
+
DENY_REASON = (
|
|
4
|
+
"This repository has a verify skill, and this commit changes code. Invoke the verify "
|
|
5
|
+
"skill with the Skill tool, run the check it routes you to, then run the same command again. "
|
|
6
|
+
"A commit that changes only docs or tests passes without it."
|
|
7
|
+
)
|
|
8
|
+
SKILL_NAME = "verify"
|
|
9
|
+
ALL_SLASH_COMMAND_MARKERS = ("<command-name>/verify</command-name>",)
|
|
10
|
+
ALL_VERIFY_SKILL_DIRECTORIES = (".claude/skills/verify", ".agents/skills/verify")
|
|
11
|
+
COMMIT_ACTION = "commit"
|
|
12
|
+
ADD_ACTION = "add"
|
|
13
|
+
ALL_GIT_EXECUTABLES = frozenset({"git", "git.exe"})
|
|
14
|
+
GIT_DIRECTORY_OPTION = "-C"
|
|
15
|
+
ALL_COMMIT_ALL_OPTIONS = frozenset({"-a", "--all"})
|
|
16
|
+
COMMIT_ALL_SHORT_FLAG = "a"
|
|
17
|
+
ALL_COMMIT_VALUED_SHORT_FLAGS = frozenset("mFCct")
|
|
18
|
+
ALL_ADD_EVERYTHING_OPTIONS = frozenset({"-A", "--all", "-u", "--update"})
|
|
19
|
+
WHOLE_TREE_PATHSPEC = ":/"
|
|
20
|
+
PATHSPEC_TERMINATOR = "--"
|
|
21
|
+
GIT_TIMEOUT_SECONDS = 5
|
|
22
|
+
ALL_TOP_LEVEL_ARGUMENTS = ("rev-parse", "--show-toplevel")
|
|
23
|
+
ALL_STAGED_PATHS_ARGUMENTS = ("diff", "--cached", "--name-only")
|
|
24
|
+
ALL_TRACKED_CHANGE_ARGUMENTS = ("diff", "--name-only")
|
|
25
|
+
ALL_WORKING_TREE_PATHS_ARGUMENTS = (
|
|
26
|
+
"ls-files",
|
|
27
|
+
"--modified",
|
|
28
|
+
"--others",
|
|
29
|
+
"--deleted",
|
|
30
|
+
"--exclude-standard",
|
|
31
|
+
"--full-name",
|
|
32
|
+
"--",
|
|
33
|
+
)
|
|
34
|
+
ALL_DOCS_EXTENSIONS = frozenset(
|
|
35
|
+
{
|
|
36
|
+
".md",
|
|
37
|
+
".mdx",
|
|
38
|
+
".txt",
|
|
39
|
+
".rst",
|
|
40
|
+
".adoc",
|
|
41
|
+
".png",
|
|
42
|
+
".jpg",
|
|
43
|
+
".jpeg",
|
|
44
|
+
".gif",
|
|
45
|
+
".svg",
|
|
46
|
+
".webp",
|
|
47
|
+
".ico",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests", "test", "__tests__"})
|
|
51
|
+
ALL_TEST_FILE_PREFIXES = ("test_",)
|
|
52
|
+
ALL_TEST_FILE_SUFFIXES = ("_test.py",)
|
|
53
|
+
ALL_TEST_FILE_INFIXES = (".test.", ".spec.")
|
|
54
|
+
ALL_TEST_FILE_NAMES = frozenset({"conftest.py"})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Per-session record of the user's prompts, read by the open tasks Stop gate.
|
|
2
|
+
|
|
3
|
+
Every prompt lands in one file outside the task store::
|
|
4
|
+
|
|
5
|
+
~/.claude/open-task-asks/<session id>.json
|
|
6
|
+
{"prompts": [{"text": "...", "recorded_at": 1761076800.0, "ask_id": "ask-1"}, ...]}
|
|
7
|
+
|
|
8
|
+
A prompt that asks for work gets the next ``ask-N`` id. A question-only
|
|
9
|
+
prompt, an acknowledgement such as ``thanks!``, an empty prompt, and a harness envelope such as
|
|
10
|
+
``<task-notification>`` get ``ask_id`` null, and the gate still reads their
|
|
11
|
+
text when it checks the quote on a ``Dropped:`` line.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import NamedTuple
|
|
22
|
+
|
|
23
|
+
from atomic_file_writer import write_text_atomically
|
|
24
|
+
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
25
|
+
ACKNOWLEDGEMENT_PATTERN,
|
|
26
|
+
ASK_ID_TEMPLATE,
|
|
27
|
+
ASK_PROMPT_ID_KEY,
|
|
28
|
+
ASK_PROMPT_RECORDED_AT_KEY,
|
|
29
|
+
ASK_PROMPT_TEXT_KEY,
|
|
30
|
+
ASKS_DIRECTORY_NAME,
|
|
31
|
+
ASKS_FILE_SUFFIX,
|
|
32
|
+
ASKS_PROMPTS_KEY,
|
|
33
|
+
ASKS_TEMPORARY_PREFIX,
|
|
34
|
+
ASKS_TEMPORARY_SUFFIX,
|
|
35
|
+
CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
36
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
37
|
+
INTERROGATIVE_START_PATTERN,
|
|
38
|
+
POLITE_REQUEST_START_PATTERN,
|
|
39
|
+
QUESTION_END_PATTERN,
|
|
40
|
+
SAFE_SESSION_ID_PATTERN,
|
|
41
|
+
SENTENCE_SPLIT_PATTERN,
|
|
42
|
+
UTF8_ENCODING,
|
|
43
|
+
YES_OR_NO_END_PATTERN,
|
|
44
|
+
)
|
|
45
|
+
from hooks_constants.spawn_readiness_hook_constants import ALL_HARNESS_ENVELOPE_MARKERS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RecordedPrompt(NamedTuple):
|
|
49
|
+
"""One prompt the user sent.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
text: The prompt as the user sent it.
|
|
53
|
+
recorded_at: Seconds since the epoch when the recorder saw it.
|
|
54
|
+
ask_id: ``ask-N`` for a prompt that asks for work, or None.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
text: str
|
|
58
|
+
recorded_at: float
|
|
59
|
+
ask_id: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def claude_home() -> Path:
|
|
63
|
+
"""Return the Claude home directory, honoring ``CLAUDE_CONFIG_DIR``."""
|
|
64
|
+
configured_directory = os.environ.get(CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
65
|
+
if configured_directory:
|
|
66
|
+
return Path(configured_directory)
|
|
67
|
+
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _asks_file(session_id: object) -> Path | None:
|
|
71
|
+
if not isinstance(session_id, str) or not re.fullmatch(SAFE_SESSION_ID_PATTERN, session_id):
|
|
72
|
+
return None
|
|
73
|
+
return claude_home() / ASKS_DIRECTORY_NAME / (session_id + ASKS_FILE_SUFFIX)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_question_only(prompt_text: str) -> bool:
|
|
77
|
+
"""Return True when the prompt asks a question and asks for no work.
|
|
78
|
+
|
|
79
|
+
::
|
|
80
|
+
|
|
81
|
+
"did the export fail?" -> True
|
|
82
|
+
"are you monitoring these? yes or no" -> True
|
|
83
|
+
"can you check the release dates in neon?" -> False
|
|
84
|
+
"Export the APKs and submit them" -> False
|
|
85
|
+
"""
|
|
86
|
+
all_sentences = [
|
|
87
|
+
each_part.strip()
|
|
88
|
+
for each_part in re.split(SENTENCE_SPLIT_PATTERN, prompt_text.strip())
|
|
89
|
+
if each_part.strip()
|
|
90
|
+
]
|
|
91
|
+
if not all_sentences or re.search(POLITE_REQUEST_START_PATTERN, prompt_text, re.IGNORECASE):
|
|
92
|
+
return False
|
|
93
|
+
if all(re.search(QUESTION_END_PATTERN, each_sentence) for each_sentence in all_sentences):
|
|
94
|
+
return True
|
|
95
|
+
return bool(
|
|
96
|
+
re.search(YES_OR_NO_END_PATTERN, all_sentences[-1], re.IGNORECASE)
|
|
97
|
+
and re.search(INTERROGATIVE_START_PATTERN, all_sentences[0], re.IGNORECASE)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_ask(prompt_text: str) -> bool:
|
|
102
|
+
"""Return True when the prompt asks for work the gate should hold the turn for."""
|
|
103
|
+
stripped_text = prompt_text.strip()
|
|
104
|
+
if not stripped_text or stripped_text.startswith(ALL_HARNESS_ENVELOPE_MARKERS):
|
|
105
|
+
return False
|
|
106
|
+
if re.fullmatch(ACKNOWLEDGEMENT_PATTERN, stripped_text, re.IGNORECASE):
|
|
107
|
+
return False
|
|
108
|
+
return not is_question_only(stripped_text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def recorded_prompts(session_id: object) -> list[RecordedPrompt]:
|
|
112
|
+
"""Return every prompt recorded for the session, oldest first."""
|
|
113
|
+
asks_file = _asks_file(session_id)
|
|
114
|
+
if asks_file is None:
|
|
115
|
+
return []
|
|
116
|
+
try:
|
|
117
|
+
all_file_fields = json.loads(asks_file.read_text(encoding=UTF8_ENCODING))
|
|
118
|
+
except (OSError, ValueError):
|
|
119
|
+
return []
|
|
120
|
+
all_entries = (
|
|
121
|
+
all_file_fields.get(ASKS_PROMPTS_KEY) if isinstance(all_file_fields, dict) else None
|
|
122
|
+
)
|
|
123
|
+
if not isinstance(all_entries, list):
|
|
124
|
+
return []
|
|
125
|
+
return [
|
|
126
|
+
_parsed_prompt(each_entry) for each_entry in all_entries if isinstance(each_entry, dict)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parsed_prompt(all_entry_fields: dict[str, object]) -> RecordedPrompt:
|
|
131
|
+
recorded_at = all_entry_fields.get(ASK_PROMPT_RECORDED_AT_KEY)
|
|
132
|
+
ask_id = all_entry_fields.get(ASK_PROMPT_ID_KEY)
|
|
133
|
+
return RecordedPrompt(
|
|
134
|
+
str(all_entry_fields.get(ASK_PROMPT_TEXT_KEY, "")),
|
|
135
|
+
float(recorded_at) if isinstance(recorded_at, (int, float)) else 0.0,
|
|
136
|
+
ask_id if isinstance(ask_id, str) else None,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def record_prompt(session_id: object, prompt_text: str) -> str | None:
|
|
141
|
+
"""Append the prompt to the session's file and return its ``ask-N`` id, or None for no ask."""
|
|
142
|
+
asks_file = _asks_file(session_id)
|
|
143
|
+
if asks_file is None:
|
|
144
|
+
return None
|
|
145
|
+
all_prompts = recorded_prompts(session_id)
|
|
146
|
+
ask_count = sum(1 for each_prompt in all_prompts if each_prompt.ask_id)
|
|
147
|
+
ask_id = ASK_ID_TEMPLATE.format(ask_number=ask_count + 1) if is_ask(prompt_text) else None
|
|
148
|
+
all_prompts.append(RecordedPrompt(prompt_text, time.time(), ask_id))
|
|
149
|
+
serialized_text = json.dumps(
|
|
150
|
+
{ASKS_PROMPTS_KEY: [each_prompt._asdict() for each_prompt in all_prompts]}
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
write_text_atomically(
|
|
154
|
+
asks_file,
|
|
155
|
+
serialized_text,
|
|
156
|
+
UTF8_ENCODING,
|
|
157
|
+
ASKS_TEMPORARY_PREFIX,
|
|
158
|
+
ASKS_TEMPORARY_SUFFIX,
|
|
159
|
+
False,
|
|
160
|
+
)
|
|
161
|
+
except OSError:
|
|
162
|
+
return None
|
|
163
|
+
return ask_id
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Per-session baseline of the installed plugins, read against the live plugin record.
|
|
2
|
+
|
|
3
|
+
The SessionStart baseline writer records each plugin's ``version`` and
|
|
4
|
+
``installPath`` from ``installed_plugins.json`` into one JSON file per session
|
|
5
|
+
in the system temp folder. The UserPromptSubmit warning hook reads the same
|
|
6
|
+
file back, compares it with the live record, and lists the plugin ids it has
|
|
7
|
+
already warned about under ``warned``::
|
|
8
|
+
|
|
9
|
+
plugin-session-baseline-<session id>.json
|
|
10
|
+
{"plugins": {"pstack@pstack-claude": [{"version": "1.0.0",
|
|
11
|
+
"installPath": "/cache/pstack/1.0.0"}]}, "warned": []}
|
|
12
|
+
|
|
13
|
+
Fields such as ``lastUpdated`` stay out of the snapshot, so a refresh that
|
|
14
|
+
changes nothing a session loads reads as unchanged.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from atomic_file_writer import write_text_atomically
|
|
25
|
+
from hooks_constants.atomic_file_writer_constants import ATOMIC_WRITE_TEMPORARY_SUFFIX
|
|
26
|
+
from hooks_constants.plugin_session_baseline_constants import (
|
|
27
|
+
BASELINE_PLUGINS_KEY,
|
|
28
|
+
BASELINE_RECORD_FILE_TEMPLATE,
|
|
29
|
+
BASELINE_RECORD_TEMPORARY_PREFIX,
|
|
30
|
+
BASELINE_TEXT_ENCODING,
|
|
31
|
+
BASELINE_WARNED_KEY,
|
|
32
|
+
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
33
|
+
DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME,
|
|
34
|
+
INSTALLED_PLUGINS_FILE_NAME,
|
|
35
|
+
INSTALLED_PLUGINS_RECORD_KEY,
|
|
36
|
+
INSTALL_PATH_FIELD,
|
|
37
|
+
PLUGINS_DIRECTORY_NAME,
|
|
38
|
+
VERSION_FIELD,
|
|
39
|
+
)
|
|
40
|
+
from hooks_constants.session_file_id import session_file_id
|
|
41
|
+
from json_file_reader import read_json_object
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"all_changed_plugin_ids",
|
|
45
|
+
"baseline_record_path",
|
|
46
|
+
"current_plugin_snapshot",
|
|
47
|
+
"installed_plugins_path",
|
|
48
|
+
"read_baseline_record",
|
|
49
|
+
"write_baseline_record",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
PluginSnapshot = dict[str, list[dict[str, str]]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def installed_plugins_path() -> Path:
|
|
56
|
+
"""Return the installed plugin record under the active Claude config directory."""
|
|
57
|
+
configured_directory = os.environ.get(CLAUDE_CONFIG_DIR_ENV_VAR, "").strip()
|
|
58
|
+
config_directory = (
|
|
59
|
+
Path(configured_directory)
|
|
60
|
+
if configured_directory
|
|
61
|
+
else Path.home() / DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME
|
|
62
|
+
)
|
|
63
|
+
return config_directory / PLUGINS_DIRECTORY_NAME / INSTALLED_PLUGINS_FILE_NAME
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _loaded_fields(each_entry: object) -> dict[str, str] | None:
|
|
67
|
+
if not isinstance(each_entry, dict):
|
|
68
|
+
return None
|
|
69
|
+
version = each_entry.get(VERSION_FIELD)
|
|
70
|
+
install_path = each_entry.get(INSTALL_PATH_FIELD)
|
|
71
|
+
if not isinstance(version, str) or not isinstance(install_path, str):
|
|
72
|
+
return None
|
|
73
|
+
return {VERSION_FIELD: version, INSTALL_PATH_FIELD: install_path}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _sorted_entries(all_entries: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
77
|
+
return sorted(
|
|
78
|
+
all_entries,
|
|
79
|
+
key=lambda each_entry: (
|
|
80
|
+
each_entry[INSTALL_PATH_FIELD],
|
|
81
|
+
each_entry[VERSION_FIELD],
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def current_plugin_snapshot(record_path: Path) -> PluginSnapshot:
|
|
87
|
+
"""Return each plugin id with the version and install path of each of its installs.
|
|
88
|
+
|
|
89
|
+
A missing, unreadable, or malformed record reads as no plugins.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
record_path: The ``installed_plugins.json`` path.
|
|
93
|
+
"""
|
|
94
|
+
installed_record = read_json_object(record_path, BASELINE_TEXT_ENCODING)
|
|
95
|
+
if installed_record is None:
|
|
96
|
+
return {}
|
|
97
|
+
all_plugins = installed_record.get(INSTALLED_PLUGINS_RECORD_KEY)
|
|
98
|
+
if not isinstance(all_plugins, dict):
|
|
99
|
+
return {}
|
|
100
|
+
snapshot: PluginSnapshot = {}
|
|
101
|
+
for each_plugin_id, each_plugin_entries in all_plugins.items():
|
|
102
|
+
if not isinstance(each_plugin_entries, list):
|
|
103
|
+
continue
|
|
104
|
+
all_loaded_entries = [
|
|
105
|
+
loaded_entry
|
|
106
|
+
for loaded_entry in (
|
|
107
|
+
_loaded_fields(each_entry) for each_entry in each_plugin_entries
|
|
108
|
+
)
|
|
109
|
+
if loaded_entry is not None
|
|
110
|
+
]
|
|
111
|
+
snapshot[str(each_plugin_id)] = _sorted_entries(all_loaded_entries)
|
|
112
|
+
return snapshot
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def baseline_record_path(session_id: object) -> Path:
|
|
116
|
+
"""Return the baseline file path for one session.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
120
|
+
"""
|
|
121
|
+
file_name = BASELINE_RECORD_FILE_TEMPLATE.format(
|
|
122
|
+
session_file_id=session_file_id(session_id)
|
|
123
|
+
)
|
|
124
|
+
return Path(tempfile.gettempdir()) / file_name
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read_baseline_record(session_id: object) -> dict[str, object] | None:
|
|
128
|
+
"""Return the session's baseline record, or None when none is readable.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
132
|
+
"""
|
|
133
|
+
baseline_record = read_json_object(
|
|
134
|
+
baseline_record_path(session_id), BASELINE_TEXT_ENCODING
|
|
135
|
+
)
|
|
136
|
+
if baseline_record is None:
|
|
137
|
+
return None
|
|
138
|
+
if not isinstance(baseline_record.get(BASELINE_PLUGINS_KEY), dict):
|
|
139
|
+
return None
|
|
140
|
+
if not isinstance(baseline_record.get(BASELINE_WARNED_KEY), list):
|
|
141
|
+
return None
|
|
142
|
+
return baseline_record
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_baseline_record(
|
|
146
|
+
session_id: object, snapshot: PluginSnapshot, all_warned_plugin_ids: list[str]
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Replace the session's baseline record.
|
|
149
|
+
|
|
150
|
+
A write that fails leaves the file as it was, so the hook never fails the session.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
154
|
+
snapshot: The plugin snapshot the session loaded at start.
|
|
155
|
+
all_warned_plugin_ids: The plugin ids already named in a warning.
|
|
156
|
+
"""
|
|
157
|
+
serialized_record = json.dumps(
|
|
158
|
+
{
|
|
159
|
+
BASELINE_PLUGINS_KEY: snapshot,
|
|
160
|
+
BASELINE_WARNED_KEY: sorted(all_warned_plugin_ids),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
try:
|
|
164
|
+
write_text_atomically(
|
|
165
|
+
baseline_record_path(session_id),
|
|
166
|
+
serialized_record,
|
|
167
|
+
BASELINE_TEXT_ENCODING,
|
|
168
|
+
BASELINE_RECORD_TEMPORARY_PREFIX,
|
|
169
|
+
ATOMIC_WRITE_TEMPORARY_SUFFIX,
|
|
170
|
+
False,
|
|
171
|
+
)
|
|
172
|
+
except (OSError, UnicodeError):
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def all_changed_plugin_ids(
|
|
177
|
+
baseline_snapshot: object, current_snapshot: PluginSnapshot
|
|
178
|
+
) -> list[str]:
|
|
179
|
+
"""Return each plugin id whose installs differ from the baseline, or that the baseline lacks.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
baseline_snapshot: The ``plugins`` value of the baseline record.
|
|
183
|
+
current_snapshot: The live snapshot from ``current_plugin_snapshot``.
|
|
184
|
+
"""
|
|
185
|
+
known_snapshot = baseline_snapshot if isinstance(baseline_snapshot, dict) else {}
|
|
186
|
+
return sorted(
|
|
187
|
+
each_plugin_id
|
|
188
|
+
for each_plugin_id, each_plugin_entries in current_snapshot.items()
|
|
189
|
+
if known_snapshot.get(each_plugin_id) != each_plugin_entries
|
|
190
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""UserPromptSubmit hook that records each prompt for the open tasks Stop gate.
|
|
3
|
+
|
|
4
|
+
A prompt that asks for work becomes ``#ask-N`` in
|
|
5
|
+
``~/.claude/open-task-asks/<session id>.json``. The gate then holds the turn
|
|
6
|
+
until a reply closes it with ``Done: #ask-N``, or with
|
|
7
|
+
``Dropped: #ask-N - "<the user's words>"`` quoting a later prompt. The hook
|
|
8
|
+
prints nothing and always exits 0.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if hooks_root_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, hooks_root_directory)
|
|
19
|
+
|
|
20
|
+
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
21
|
+
ALLOW_EXIT_CODE,
|
|
22
|
+
PROMPT_KEY,
|
|
23
|
+
SESSION_ID_KEY,
|
|
24
|
+
)
|
|
25
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
26
|
+
from open_task_asks import record_prompt
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> int:
|
|
30
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
31
|
+
prompt_value = hook_input.get(PROMPT_KEY) if hook_input is not None else None
|
|
32
|
+
if hook_input is not None and isinstance(prompt_value, str):
|
|
33
|
+
record_prompt(hook_input.get(SESSION_ID_KEY), prompt_value)
|
|
34
|
+
return ALLOW_EXIT_CODE
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
sys.exit(main())
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""UserPromptSubmit hook: tell the user to restart when a plugin changed after start.
|
|
3
|
+
|
|
4
|
+
A running Claude Code session keeps the plugin files it loaded at start. After
|
|
5
|
+
an install or update, ``installed_plugins.json`` names a new ``version`` or
|
|
6
|
+
``installPath``, or a plugin the session never loaded, and its new skills and
|
|
7
|
+
commands stay unavailable until a restart. The hook compares the live record
|
|
8
|
+
with the baseline that ``plugin_session_baseline_writer.py`` wrote at
|
|
9
|
+
SessionStart and names each changed plugin once per session, in a
|
|
10
|
+
``systemMessage`` for the user and in ``additionalContext`` for the agent.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
20
|
+
if _hooks_directory not in sys.path:
|
|
21
|
+
sys.path.insert(0, _hooks_directory)
|
|
22
|
+
|
|
23
|
+
from hooks_constants.plugin_session_baseline_constants import (
|
|
24
|
+
BASELINE_PLUGINS_KEY,
|
|
25
|
+
BASELINE_WARNED_KEY,
|
|
26
|
+
PLUGIN_CHANGE_WARNING_TEMPLATE,
|
|
27
|
+
PLUGIN_ID_SEPARATOR,
|
|
28
|
+
SESSION_ID_FIELD,
|
|
29
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
30
|
+
)
|
|
31
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
32
|
+
from plugin_session_baseline import (
|
|
33
|
+
all_changed_plugin_ids,
|
|
34
|
+
current_plugin_snapshot,
|
|
35
|
+
installed_plugins_path,
|
|
36
|
+
read_baseline_record,
|
|
37
|
+
write_baseline_record,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def plugin_change_output(all_plugin_ids: list[str]) -> dict[str, object]:
|
|
42
|
+
"""Return the hook output that names each changed plugin and asks for a restart.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
all_plugin_ids: The plugin ids to name, in order.
|
|
46
|
+
"""
|
|
47
|
+
warning_text = PLUGIN_CHANGE_WARNING_TEMPLATE.format(
|
|
48
|
+
plugin_ids=PLUGIN_ID_SEPARATOR.join(all_plugin_ids)
|
|
49
|
+
)
|
|
50
|
+
return {
|
|
51
|
+
"systemMessage": warning_text,
|
|
52
|
+
"hookSpecificOutput": {
|
|
53
|
+
"hookEventName": USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
54
|
+
"additionalContext": warning_text,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
"""Warn once per plugin when the live plugin record differs from the session baseline."""
|
|
61
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
62
|
+
if hook_payload is None:
|
|
63
|
+
return
|
|
64
|
+
session_id = hook_payload.get(SESSION_ID_FIELD)
|
|
65
|
+
if not isinstance(session_id, str) or not session_id:
|
|
66
|
+
return
|
|
67
|
+
baseline_record = read_baseline_record(session_id)
|
|
68
|
+
if baseline_record is None:
|
|
69
|
+
return
|
|
70
|
+
baseline_snapshot = baseline_record[BASELINE_PLUGINS_KEY]
|
|
71
|
+
all_warned_plugin_ids = [
|
|
72
|
+
str(each_plugin_id) for each_plugin_id in baseline_record[BASELINE_WARNED_KEY]
|
|
73
|
+
]
|
|
74
|
+
all_new_plugin_ids = [
|
|
75
|
+
each_plugin_id
|
|
76
|
+
for each_plugin_id in all_changed_plugin_ids(
|
|
77
|
+
baseline_snapshot, current_plugin_snapshot(installed_plugins_path())
|
|
78
|
+
)
|
|
79
|
+
if each_plugin_id not in all_warned_plugin_ids
|
|
80
|
+
]
|
|
81
|
+
if not all_new_plugin_ids:
|
|
82
|
+
return
|
|
83
|
+
write_baseline_record(
|
|
84
|
+
session_id, baseline_snapshot, [*all_warned_plugin_ids, *all_new_plugin_ids]
|
|
85
|
+
)
|
|
86
|
+
sys.stdout.write(json.dumps(plugin_change_output(all_new_plugin_ids)) + "\n")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook: record the plugin versions this session loads at start.
|
|
3
|
+
|
|
4
|
+
On ``startup``, and on any source when the session has no baseline yet, the
|
|
5
|
+
hook writes each plugin's ``version`` and ``installPath`` from
|
|
6
|
+
``installed_plugins.json`` to the session's baseline file. On ``resume``,
|
|
7
|
+
``clear``, and ``compact`` it leaves an existing baseline alone, because the
|
|
8
|
+
session keeps the plugin files it loaded at its first start. The
|
|
9
|
+
UserPromptSubmit hook ``plugin_change_warning.py`` compares against this file.
|
|
10
|
+
The hook prints nothing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
19
|
+
if _hooks_directory not in sys.path:
|
|
20
|
+
sys.path.insert(0, _hooks_directory)
|
|
21
|
+
|
|
22
|
+
from hooks_constants.plugin_session_baseline_constants import (
|
|
23
|
+
SESSION_ID_FIELD,
|
|
24
|
+
SESSION_SOURCE_FIELD,
|
|
25
|
+
STARTUP_SOURCE,
|
|
26
|
+
)
|
|
27
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
28
|
+
from plugin_session_baseline import (
|
|
29
|
+
current_plugin_snapshot,
|
|
30
|
+
installed_plugins_path,
|
|
31
|
+
read_baseline_record,
|
|
32
|
+
write_baseline_record,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
"""Write the session's plugin baseline on startup or when none exists yet."""
|
|
38
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
39
|
+
if hook_payload is None:
|
|
40
|
+
return
|
|
41
|
+
session_id = hook_payload.get(SESSION_ID_FIELD)
|
|
42
|
+
if not isinstance(session_id, str) or not session_id:
|
|
43
|
+
return
|
|
44
|
+
is_startup = hook_payload.get(SESSION_SOURCE_FIELD) == STARTUP_SOURCE
|
|
45
|
+
if not is_startup and read_baseline_record(session_id) is not None:
|
|
46
|
+
return
|
|
47
|
+
write_baseline_record(
|
|
48
|
+
session_id, current_plugin_snapshot(installed_plugins_path()), []
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
RECORDER_PATH = Path(__file__).resolve().parent / "open_task_ask_recorder.py"
|
|
7
|
+
SESSION_ID = "s-123"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def submit_prompt(home_directory: Path, prompt_text: str) -> subprocess.CompletedProcess[str]:
|
|
11
|
+
hook_input = {
|
|
12
|
+
"hook_event_name": "UserPromptSubmit",
|
|
13
|
+
"session_id": SESSION_ID,
|
|
14
|
+
"prompt": prompt_text,
|
|
15
|
+
}
|
|
16
|
+
return subprocess.run(
|
|
17
|
+
[sys.executable, str(RECORDER_PATH)],
|
|
18
|
+
input=json.dumps(hook_input),
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
env={
|
|
22
|
+
"HOME": str(home_directory),
|
|
23
|
+
"USERPROFILE": str(home_directory),
|
|
24
|
+
"PATH": "",
|
|
25
|
+
},
|
|
26
|
+
check=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def recorded_ask_ids(home_directory: Path) -> list[str]:
|
|
31
|
+
asks_path = home_directory / ".claude" / "open-task-asks" / f"{SESSION_ID}.json"
|
|
32
|
+
all_prompts = json.loads(asks_path.read_text(encoding="utf-8"))["prompts"]
|
|
33
|
+
return [each_prompt["ask_id"] for each_prompt in all_prompts if each_prompt["ask_id"]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_should_record_each_ask_outside_the_task_store_and_stay_silent(
|
|
37
|
+
tmp_path: Path,
|
|
38
|
+
) -> None:
|
|
39
|
+
first_process = submit_prompt(tmp_path, "Export the OneUI8 and OneUI9 APKs and submit them")
|
|
40
|
+
submit_prompt(tmp_path, "did the export fail?")
|
|
41
|
+
submit_prompt(tmp_path, "<task-notification>\n<task-id>b1</task-id>\n</task-notification>")
|
|
42
|
+
submit_prompt(tmp_path, "can you check the release dates in neon?")
|
|
43
|
+
assert (first_process.returncode, first_process.stdout) == (0, "")
|
|
44
|
+
assert recorded_ask_ids(tmp_path) == ["ask-1", "ask-2"]
|
|
45
|
+
assert not (tmp_path / ".claude" / "tasks").exists()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_should_allow_malformed_input(tmp_path: Path) -> None:
|
|
49
|
+
completed_process = subprocess.run(
|
|
50
|
+
[sys.executable, str(RECORDER_PATH)],
|
|
51
|
+
input="not json",
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
env={"HOME": str(tmp_path), "USERPROFILE": str(tmp_path), "PATH": ""},
|
|
55
|
+
check=False,
|
|
56
|
+
)
|
|
57
|
+
assert (completed_process.returncode, completed_process.stdout) == (0, "")
|