claude-dev-env 8.96.7 → 8.97.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 +2 -1
- package/hooks/blocking/send_user_file_worktree_root_gate.py +143 -0
- package/hooks/blocking/test_send_user_file_worktree_root_gate.py +126 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/features/session-context.md +4 -0
- package/hooks/hooks.json +20 -0
- package/hooks/hooks_constants/plugin_session_baseline_constants.py +50 -0
- package/hooks/hooks_constants/send_user_file_worktree_root_gate_constants.py +36 -0
- package/hooks/plugin_session_baseline.py +190 -0
- package/hooks/session/plugin_change_warning.py +90 -0
- package/hooks/session/plugin_session_baseline_writer.py +53 -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_plugin_session_baseline.py +98 -0
- 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
|
@@ -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,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,216 @@
|
|
|
1
|
+
"""Tests for plugin_change_warning: the UserPromptSubmit plugin restart warning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
14
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
15
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
16
|
+
if each_sys_path_entry not in sys.path:
|
|
17
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
18
|
+
|
|
19
|
+
import plugin_change_warning
|
|
20
|
+
import plugin_session_baseline
|
|
21
|
+
import plugin_session_baseline_writer
|
|
22
|
+
|
|
23
|
+
SESSION_ID = "session-two"
|
|
24
|
+
DEEPSTACK_ID = "deepstack@deepstack"
|
|
25
|
+
PSTACK_ID = "pstack@pstack-claude"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _plugin_entry(version: str, install_path: str, last_updated: str) -> dict[str, str]:
|
|
29
|
+
return {
|
|
30
|
+
"scope": "user",
|
|
31
|
+
"installPath": install_path,
|
|
32
|
+
"version": version,
|
|
33
|
+
"installedAt": "2026-10-21T19:00:00.000Z",
|
|
34
|
+
"lastUpdated": last_updated,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_installed_plugins(
|
|
39
|
+
config_directory: Path, all_plugins: dict[str, list[dict[str, str]]]
|
|
40
|
+
) -> None:
|
|
41
|
+
plugins_directory = config_directory / "plugins"
|
|
42
|
+
plugins_directory.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
(plugins_directory / "installed_plugins.json").write_text(
|
|
44
|
+
json.dumps({"version": 2, "plugins": all_plugins}), encoding="utf-8"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_main(main_function: object, payload: dict[str, str]) -> str:
|
|
49
|
+
payload_bytes = json.dumps(payload).encode("utf-8")
|
|
50
|
+
captured_stdout = io.StringIO()
|
|
51
|
+
with (
|
|
52
|
+
patch("sys.stdin", io.TextIOWrapper(io.BytesIO(payload_bytes))),
|
|
53
|
+
patch("sys.stdout", captured_stdout),
|
|
54
|
+
):
|
|
55
|
+
main_function()
|
|
56
|
+
return captured_stdout.getvalue()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def start_session(source: str) -> None:
|
|
60
|
+
_run_main(
|
|
61
|
+
plugin_session_baseline_writer.main,
|
|
62
|
+
{"session_id": SESSION_ID, "hook_event_name": "SessionStart", "source": source},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def submit_prompt() -> dict[str, object] | None:
|
|
67
|
+
hook_output = _run_main(
|
|
68
|
+
plugin_change_warning.main,
|
|
69
|
+
{
|
|
70
|
+
"session_id": SESSION_ID,
|
|
71
|
+
"hook_event_name": "UserPromptSubmit",
|
|
72
|
+
"prompt": "run /deepstack-mode",
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
return json.loads(hook_output) if hook_output else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def config_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
80
|
+
claude_config_directory = tmp_path / "config"
|
|
81
|
+
state_directory = tmp_path / "state"
|
|
82
|
+
state_directory.mkdir()
|
|
83
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config_directory))
|
|
84
|
+
monkeypatch.setattr(
|
|
85
|
+
plugin_session_baseline.tempfile, "gettempdir", lambda: str(state_directory)
|
|
86
|
+
)
|
|
87
|
+
return claude_config_directory
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _warning_text(hook_output: dict[str, object] | None) -> str:
|
|
91
|
+
assert hook_output is not None
|
|
92
|
+
system_message = hook_output["systemMessage"]
|
|
93
|
+
assert isinstance(system_message, str)
|
|
94
|
+
hook_specific_output = hook_output["hookSpecificOutput"]
|
|
95
|
+
assert isinstance(hook_specific_output, dict)
|
|
96
|
+
assert hook_specific_output["hookEventName"] == "UserPromptSubmit"
|
|
97
|
+
assert hook_specific_output["additionalContext"] == system_message
|
|
98
|
+
return system_message
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_a_changed_version_should_warn_once(config_directory: Path) -> None:
|
|
102
|
+
write_installed_plugins(
|
|
103
|
+
config_directory,
|
|
104
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
105
|
+
)
|
|
106
|
+
start_session("startup")
|
|
107
|
+
write_installed_plugins(
|
|
108
|
+
config_directory,
|
|
109
|
+
{PSTACK_ID: [_plugin_entry("1.1.0", "/cache/pstack/1.1.0", "b")]},
|
|
110
|
+
)
|
|
111
|
+
warning_text = _warning_text(submit_prompt())
|
|
112
|
+
assert PSTACK_ID in warning_text
|
|
113
|
+
assert "Restart Claude Code" in warning_text
|
|
114
|
+
assert submit_prompt() is None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_a_plugin_new_since_start_should_warn(config_directory: Path) -> None:
|
|
118
|
+
write_installed_plugins(
|
|
119
|
+
config_directory,
|
|
120
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
121
|
+
)
|
|
122
|
+
start_session("startup")
|
|
123
|
+
write_installed_plugins(
|
|
124
|
+
config_directory,
|
|
125
|
+
{
|
|
126
|
+
PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
|
|
127
|
+
DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "b")],
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
warning_text = _warning_text(submit_prompt())
|
|
131
|
+
assert DEEPSTACK_ID in warning_text
|
|
132
|
+
assert PSTACK_ID not in warning_text
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_a_changed_install_path_should_warn(config_directory: Path) -> None:
|
|
136
|
+
write_installed_plugins(
|
|
137
|
+
config_directory,
|
|
138
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/old", "a")]},
|
|
139
|
+
)
|
|
140
|
+
start_session("startup")
|
|
141
|
+
write_installed_plugins(
|
|
142
|
+
config_directory,
|
|
143
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/new", "a")]},
|
|
144
|
+
)
|
|
145
|
+
assert PSTACK_ID in _warning_text(submit_prompt())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_a_bare_last_updated_refresh_should_stay_quiet(config_directory: Path) -> None:
|
|
149
|
+
write_installed_plugins(
|
|
150
|
+
config_directory,
|
|
151
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
152
|
+
)
|
|
153
|
+
start_session("startup")
|
|
154
|
+
write_installed_plugins(
|
|
155
|
+
config_directory,
|
|
156
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "later")]},
|
|
157
|
+
)
|
|
158
|
+
assert submit_prompt() is None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_an_install_then_compact_then_prompt_should_still_warn(
|
|
162
|
+
config_directory: Path,
|
|
163
|
+
) -> None:
|
|
164
|
+
write_installed_plugins(
|
|
165
|
+
config_directory,
|
|
166
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
167
|
+
)
|
|
168
|
+
start_session("startup")
|
|
169
|
+
write_installed_plugins(
|
|
170
|
+
config_directory,
|
|
171
|
+
{
|
|
172
|
+
PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
|
|
173
|
+
DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "b")],
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
start_session("compact")
|
|
177
|
+
assert DEEPSTACK_ID in _warning_text(submit_prompt())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_a_second_plugin_change_should_warn_only_for_that_plugin(
|
|
181
|
+
config_directory: Path,
|
|
182
|
+
) -> None:
|
|
183
|
+
write_installed_plugins(
|
|
184
|
+
config_directory,
|
|
185
|
+
{
|
|
186
|
+
PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
|
|
187
|
+
DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "a")],
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
start_session("startup")
|
|
191
|
+
write_installed_plugins(
|
|
192
|
+
config_directory,
|
|
193
|
+
{
|
|
194
|
+
PSTACK_ID: [_plugin_entry("1.1.0", "/cache/pstack/1.1.0", "b")],
|
|
195
|
+
DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "a")],
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
assert PSTACK_ID in _warning_text(submit_prompt())
|
|
199
|
+
write_installed_plugins(
|
|
200
|
+
config_directory,
|
|
201
|
+
{
|
|
202
|
+
PSTACK_ID: [_plugin_entry("1.2.0", "/cache/pstack/1.2.0", "c")],
|
|
203
|
+
DEEPSTACK_ID: [_plugin_entry("3.1.0", "/cache/deepstack/3.1.0", "c")],
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
second_warning_text = _warning_text(submit_prompt())
|
|
207
|
+
assert DEEPSTACK_ID in second_warning_text
|
|
208
|
+
assert PSTACK_ID not in second_warning_text
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_a_session_without_a_baseline_should_stay_quiet(config_directory: Path) -> None:
|
|
212
|
+
write_installed_plugins(
|
|
213
|
+
config_directory,
|
|
214
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
215
|
+
)
|
|
216
|
+
assert submit_prompt() is None
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tests for plugin_session_baseline_writer: the SessionStart plugin baseline hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
14
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
15
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
16
|
+
if each_sys_path_entry not in sys.path:
|
|
17
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
18
|
+
|
|
19
|
+
import plugin_session_baseline
|
|
20
|
+
import plugin_session_baseline_writer
|
|
21
|
+
|
|
22
|
+
SESSION_ID = "session-one"
|
|
23
|
+
PLUGIN_ID = "deepstack@deepstack"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _plugin_entry(version: str, install_path: str, last_updated: str) -> dict[str, str]:
|
|
27
|
+
return {
|
|
28
|
+
"scope": "user",
|
|
29
|
+
"installPath": install_path,
|
|
30
|
+
"version": version,
|
|
31
|
+
"installedAt": "2026-10-21T19:00:00.000Z",
|
|
32
|
+
"lastUpdated": last_updated,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_installed_plugins(
|
|
37
|
+
config_directory: Path, all_plugins: dict[str, list[dict[str, str]]]
|
|
38
|
+
) -> None:
|
|
39
|
+
plugins_directory = config_directory / "plugins"
|
|
40
|
+
plugins_directory.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
(plugins_directory / "installed_plugins.json").write_text(
|
|
42
|
+
json.dumps({"version": 2, "plugins": all_plugins}), encoding="utf-8"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_writer(session_id: str, source: str) -> str:
|
|
47
|
+
payload_text = json.dumps(
|
|
48
|
+
{"session_id": session_id, "hook_event_name": "SessionStart", "source": source}
|
|
49
|
+
)
|
|
50
|
+
captured_stdout = io.StringIO()
|
|
51
|
+
with (
|
|
52
|
+
patch("sys.stdin", io.TextIOWrapper(io.BytesIO(payload_text.encode("utf-8")))),
|
|
53
|
+
patch("sys.stdout", captured_stdout),
|
|
54
|
+
):
|
|
55
|
+
plugin_session_baseline_writer.main()
|
|
56
|
+
return captured_stdout.getvalue()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def config_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
61
|
+
claude_config_directory = tmp_path / "config"
|
|
62
|
+
state_directory = tmp_path / "state"
|
|
63
|
+
state_directory.mkdir()
|
|
64
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config_directory))
|
|
65
|
+
monkeypatch.setattr(
|
|
66
|
+
plugin_session_baseline.tempfile, "gettempdir", lambda: str(state_directory)
|
|
67
|
+
)
|
|
68
|
+
return claude_config_directory
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_startup_should_record_each_plugin_version_and_install_path(
|
|
72
|
+
config_directory: Path,
|
|
73
|
+
) -> None:
|
|
74
|
+
write_installed_plugins(
|
|
75
|
+
config_directory,
|
|
76
|
+
{PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
|
|
77
|
+
)
|
|
78
|
+
assert run_writer(SESSION_ID, "startup") == ""
|
|
79
|
+
baseline_record = plugin_session_baseline.read_baseline_record(SESSION_ID)
|
|
80
|
+
assert baseline_record == {
|
|
81
|
+
"plugins": {
|
|
82
|
+
PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/deepstack/1.0.0"}]
|
|
83
|
+
},
|
|
84
|
+
"warned": [],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("source", ["resume", "clear", "compact"])
|
|
89
|
+
def test_a_later_source_should_leave_an_existing_baseline_alone(
|
|
90
|
+
config_directory: Path, source: str
|
|
91
|
+
) -> None:
|
|
92
|
+
write_installed_plugins(
|
|
93
|
+
config_directory,
|
|
94
|
+
{PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
|
|
95
|
+
)
|
|
96
|
+
run_writer(SESSION_ID, "startup")
|
|
97
|
+
baseline_path = plugin_session_baseline.baseline_record_path(SESSION_ID)
|
|
98
|
+
baseline_text = baseline_path.read_text(encoding="utf-8")
|
|
99
|
+
write_installed_plugins(
|
|
100
|
+
config_directory,
|
|
101
|
+
{PLUGIN_ID: [_plugin_entry("2.0.0", "/cache/deepstack/2.0.0", "b")]},
|
|
102
|
+
)
|
|
103
|
+
run_writer(SESSION_ID, source)
|
|
104
|
+
assert baseline_path.read_text(encoding="utf-8") == baseline_text
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_resume_without_a_baseline_should_write_one(config_directory: Path) -> None:
|
|
108
|
+
write_installed_plugins(
|
|
109
|
+
config_directory,
|
|
110
|
+
{PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
|
|
111
|
+
)
|
|
112
|
+
run_writer(SESSION_ID, "resume")
|
|
113
|
+
baseline_record = plugin_session_baseline.read_baseline_record(SESSION_ID)
|
|
114
|
+
assert baseline_record is not None
|
|
115
|
+
assert PLUGIN_ID in baseline_record["plugins"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_a_payload_without_a_session_id_should_write_nothing(
|
|
119
|
+
config_directory: Path, tmp_path: Path
|
|
120
|
+
) -> None:
|
|
121
|
+
write_installed_plugins(
|
|
122
|
+
config_directory,
|
|
123
|
+
{PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
|
|
124
|
+
)
|
|
125
|
+
run_writer("", "startup")
|
|
126
|
+
assert list((tmp_path / "state").iterdir()) == []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_a_missing_installed_plugins_file_should_record_an_empty_baseline(
|
|
130
|
+
config_directory: Path,
|
|
131
|
+
) -> None:
|
|
132
|
+
run_writer(SESSION_ID, "startup")
|
|
133
|
+
assert plugin_session_baseline.read_baseline_record(SESSION_ID) == {
|
|
134
|
+
"plugins": {},
|
|
135
|
+
"warned": [],
|
|
136
|
+
}
|
|
@@ -29,6 +29,8 @@ UNMEASURED_REASON_BY_SCRIPT = {
|
|
|
29
29
|
"routing/thread_spawn_pace_hook.py": "quotes a usage reading capped by its own constant",
|
|
30
30
|
"session/issue_tracker_session_starter.py": "emits only for a repository the user's "
|
|
31
31
|
"project registry names by absolute path",
|
|
32
|
+
"session/plugin_change_warning.py": "emits only after a plugin changes on disk mid-session, "
|
|
33
|
+
"once per plugin, naming plugin ids",
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
|