claude-dev-env 8.96.8 → 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.
@@ -318,6 +318,23 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
318
318
  return { host, executable, status: 'installed', warning: null };
319
319
  }
320
320
 
321
+ /**
322
+ * Build the notice that tells the user to restart each session the install reached.
323
+ *
324
+ * A running Claude Code or Codex session keeps the plugin files it loaded at
325
+ * start, so a plugin installed or updated under it stays unavailable there
326
+ * until the session restarts.
327
+ *
328
+ * @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins the install wrote.
329
+ * @param {ReadonlyArray<string>} allInstalledHosts The hosts the install reached.
330
+ * @returns {string} The restart notice.
331
+ */
332
+ export function restartNotice(allSpecs, allInstalledHosts) {
333
+ const pluginLabels = allSpecs.map(eachSpec => eachSpec.label).join(', ');
334
+ return `Restart each open ${allInstalledHosts.join(' and ')} session to load ${pluginLabels}. `
335
+ + 'A running session keeps the plugin version it loaded at start.';
336
+ }
337
+
321
338
  /**
322
339
  * Install a set of plugins from one marketplace into each host's own home.
323
340
  *
@@ -334,7 +351,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
334
351
  * `cmd.exe`'s command-not-found exit code on Windows, and both read as
335
352
  * skipped. When a host cannot replace a plugin folder because something holds
336
353
  * it, the warning names each process that holds it, so the user knows what to
337
- * close before running the install again.
354
+ * close before running the install again. When any host installs, the step
355
+ * prints one restart notice, because an open session keeps the plugin
356
+ * version it loaded at start.
338
357
  *
339
358
  * @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
340
359
  * @param {object} [options] Install targets.
@@ -342,8 +361,8 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
342
361
  * @param {string} [options.codexHome] The Codex home to install into.
343
362
  * @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
344
363
  * @param {Record<string, string|undefined>} [options.environment] The process environment.
345
- * @param {object} [dependencies] Seams for the command runner and the folder locker search.
346
- * @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
364
+ * @param {object} [dependencies] Seams for the command runner, the folder locker search, and the line writer.
365
+ * @returns {{status: string, hosts: object[], warning: string|null, restartNotice: string|null}} The outcome per host.
347
366
  */
348
367
  export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
349
368
  const environment = options.environment ?? process.env;
@@ -365,7 +384,15 @@ export function installMarketplacePlugins(allSpecs, options = {}, dependencies =
365
384
  const installedCount = hostOutcomes.filter(outcome => outcome.status === 'installed').length;
366
385
  const status = failedCount > 0 ? 'failed' : (installedCount > 0 ? 'installed' : 'skipped');
367
386
  const warnings = hostOutcomes.map(outcome => outcome.warning).filter(Boolean);
368
- return { status, hosts: hostOutcomes, warning: warnings.join(' ') || null };
387
+ const allInstalledHosts = hostOutcomes
388
+ .filter(outcome => outcome.status === 'installed')
389
+ .map(outcome => outcome.host);
390
+ const notice = allInstalledHosts.length > 0 ? restartNotice(allSpecs, allInstalledHosts) : null;
391
+ if (notice) {
392
+ const writeLine = dependencies.writeLine ?? (line => console.log(line));
393
+ writeLine(notice);
394
+ }
395
+ return { status, hosts: hostOutcomes, warning: warnings.join(' ') || null, restartNotice: notice };
369
396
  }
370
397
 
371
398
  /**
@@ -17,6 +17,7 @@ import {
17
17
  removeOrphanedPluginVersions,
18
18
  removeStaleMarketplaceClone,
19
19
  removeRetiredMarketplacePlugins,
20
+ restartNotice,
20
21
  shouldInstallMarketplacePlugin,
21
22
  } from './install-pstack-plugin.mjs';
22
23
 
@@ -500,3 +501,25 @@ test('should delete a half-made marketplace clone before the shared marketplace
500
501
  test('should report no removal when no stale marketplace clone exists', () => {
501
502
  assert.equal(removeStaleMarketplaceClone(join(tmpdir(), 'cde-no-such-clone')), false);
502
503
  });
504
+
505
+ test('an install prints one restart notice that names each plugin and host', () => {
506
+ const allLoggedLines = [];
507
+ const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
508
+ ...recordingRunner(),
509
+ writeLine: line => allLoggedLines.push(line),
510
+ });
511
+ assert.deepEqual(allLoggedLines, [restartNotice([PSTACK_PLUGIN_SPEC], ['claude', 'codex'])]);
512
+ assert.equal(outcome.restartNotice, allLoggedLines[0]);
513
+ assert.match(allLoggedLines[0], /Restart each open claude and codex session to load Pstack\./);
514
+ });
515
+
516
+ test('a run that installs nothing prints no restart notice', () => {
517
+ const allLoggedLines = [];
518
+ const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
519
+ ...recordingRunner(() => ({ status: null, error: Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) })),
520
+ writeLine: line => allLoggedLines.push(line),
521
+ });
522
+ assert.equal(outcome.status, 'skipped');
523
+ assert.deepEqual(allLoggedLines, []);
524
+ assert.equal(outcome.restartNotice, null);
525
+ });
@@ -35,11 +35,12 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
35
35
  mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
36
36
 
37
37
  const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
38
- assert.equal(userPromptHooks.length, 4);
38
+ assert.equal(userPromptHooks.length, 5);
39
39
  assert.equal(userPromptHooks[0].command, userCommand);
40
40
  assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
41
41
  assert.match(userPromptHooks[2].command, /lifecycle\/nested_project_hooks\.py$/);
42
42
  assert.match(userPromptHooks[3].command, /blocking\/chat_reply_gate\.py$/);
43
+ assert.match(userPromptHooks[4].command, /session\/plugin_change_warning\.py$/);
43
44
  assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
44
45
 
45
46
  const firstMerge = JSON.stringify(settings);
@@ -10,6 +10,8 @@ This family injects reminders and task guidance when an agent starts, resumes, s
10
10
  - `session/working_style_prompt.py` injects the session's working style at session start.
11
11
  - `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
12
12
  - `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
13
+ - `session/plugin_session_baseline_writer.py` records each plugin's `version` and `installPath` from `installed_plugins.json` to a per-session file in the system temp folder. It writes on `startup`, or when the session has no baseline yet, and leaves an existing baseline alone on `resume`, `clear`, and `compact`.
14
+ - `session/plugin_change_warning.py` compares `installed_plugins.json` with that baseline before each prompt. It names each plugin whose version or install path changed, or that the baseline lacks, once per session, and tells the user to restart Claude Code.
13
15
  - `session/artifact_template_pointer.py` routes an agent that starts an artifact page by scope. A status or progress report prefers the status flowchart from `docs/templates/artifact-page/status-flowchart.html`, unless the person asks for another shape. One flow or one comparison is an inline SVG flowchart with minimal text. A multi-part plan or evidence is an html-plan page from `docs/templates/html-plan/README.md`. When the request names the older artifact page template, it points at `docs/templates/artifact-page/README.md`, which names the tracker template and the diagram template and which ask each fits.
14
16
  - `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
15
17
 
@@ -18,6 +20,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
18
20
  - `session/skill_loaded_reminder.py` runs on `PreToolUse` matcher `Agent|Task` at `10` seconds, `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
19
21
  - `session/compaction_recall_prompt.py` runs on `SessionStart` matcher `compact` at `10` seconds in `hooks.json`. The installer also writes it to the Codex `hooks.json`, the only `SessionStart` hook Codex gets.
20
22
  - `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, and `session/orchestrator_auto_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
23
+ - `session/plugin_session_baseline_writer.py` runs on `SessionStart`, matcher empty, at `10` seconds, so it sees every source. `session/plugin_change_warning.py` runs on `UserPromptSubmit`, matcher empty, at `10` seconds in `hooks.json`.
21
24
  - `session/artifact_template_pointer.py` runs on `PreToolUse` matcher `Artifact|Skill` at `10` seconds in `hooks.json`. It speaks only on an `Artifact` call with action `quickstart` or a `Skill` call that loads `artifact-design`.
22
25
  - `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
23
26
 
@@ -33,6 +36,7 @@ Preconditions:
33
36
  - **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
34
37
  - **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
35
38
  - **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
39
+ - **Plugin restart warning.** Input is a SessionStart event, an `installed_plugins.json` change, and a prompt. Run `python -m pytest packages/claude-dev-env/hooks/session/test_plugin_session_baseline_writer.py packages/claude-dev-env/hooks/session/test_plugin_change_warning.py packages/claude-dev-env/hooks/test_plugin_session_baseline.py -q`. The tests observe one warning for a changed version, a changed install path, and a plugin new since start, silence on a `lastUpdated` refresh and on a second prompt, and a warning that still fires after a `compact` SessionStart.
36
40
  - **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the status flowchart path first, the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
37
41
  - **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
38
42
 
package/hooks/hooks.json CHANGED
@@ -313,6 +313,11 @@
313
313
  "type": "command",
314
314
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
315
315
  "timeout": 10
316
+ },
317
+ {
318
+ "type": "command",
319
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_change_warning.py",
320
+ "timeout": 10
316
321
  }
317
322
  ]
318
323
  }
@@ -356,6 +361,11 @@
356
361
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/orchestrator_auto_starter.py",
357
362
  "timeout": 10
358
363
  },
364
+ {
365
+ "type": "command",
366
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_session_baseline_writer.py",
367
+ "timeout": 10
368
+ },
359
369
  {
360
370
  "type": "command",
361
371
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
@@ -0,0 +1,50 @@
1
+ """Constants for the plugin session baseline and the plugin change warning hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "BASELINE_PLUGINS_KEY",
7
+ "BASELINE_RECORD_FILE_TEMPLATE",
8
+ "BASELINE_RECORD_TEMPORARY_PREFIX",
9
+ "BASELINE_TEXT_ENCODING",
10
+ "BASELINE_WARNED_KEY",
11
+ "CLAUDE_CONFIG_DIR_ENV_VAR",
12
+ "DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME",
13
+ "INSTALLED_PLUGINS_FILE_NAME",
14
+ "INSTALLED_PLUGINS_RECORD_KEY",
15
+ "INSTALL_PATH_FIELD",
16
+ "PLUGINS_DIRECTORY_NAME",
17
+ "PLUGIN_CHANGE_WARNING_TEMPLATE",
18
+ "PLUGIN_ID_SEPARATOR",
19
+ "SESSION_ID_FIELD",
20
+ "SESSION_SOURCE_FIELD",
21
+ "STARTUP_SOURCE",
22
+ "USER_PROMPT_SUBMIT_EVENT_NAME",
23
+ "VERSION_FIELD",
24
+ ]
25
+
26
+ CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR"
27
+ DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME = ".claude"
28
+ PLUGINS_DIRECTORY_NAME = "plugins"
29
+ INSTALLED_PLUGINS_FILE_NAME = "installed_plugins.json"
30
+ INSTALLED_PLUGINS_RECORD_KEY = "plugins"
31
+ VERSION_FIELD = "version"
32
+ INSTALL_PATH_FIELD = "installPath"
33
+
34
+ SESSION_ID_FIELD = "session_id"
35
+ SESSION_SOURCE_FIELD = "source"
36
+ STARTUP_SOURCE = "startup"
37
+ USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
38
+
39
+ BASELINE_RECORD_FILE_TEMPLATE = "plugin-session-baseline-{session_file_id}.json"
40
+ BASELINE_RECORD_TEMPORARY_PREFIX = "plugin-session-baseline-"
41
+ BASELINE_TEXT_ENCODING = "utf-8"
42
+ BASELINE_PLUGINS_KEY = "plugins"
43
+ BASELINE_WARNED_KEY = "warned"
44
+
45
+ PLUGIN_ID_SEPARATOR = ", "
46
+ PLUGIN_CHANGE_WARNING_TEMPLATE = (
47
+ "Plugin files changed after this session started: {plugin_ids}."
48
+ " This session keeps the plugin versions it loaded at start, so their new skills,"
49
+ " commands, and hooks stay unavailable here. Restart Claude Code to load them."
50
+ )
@@ -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
 
@@ -0,0 +1,98 @@
1
+ """Behavior tests for the plugin session baseline record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ import plugin_session_baseline
11
+
12
+ PLUGIN_ID = "pstack@pstack-claude"
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def isolated_temp_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
17
+ monkeypatch.setattr(
18
+ plugin_session_baseline.tempfile, "gettempdir", lambda: str(tmp_path)
19
+ )
20
+
21
+
22
+ def test_installed_plugins_path_should_follow_the_config_directory(
23
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
24
+ ) -> None:
25
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
26
+ assert plugin_session_baseline.installed_plugins_path() == (
27
+ tmp_path / "plugins" / "installed_plugins.json"
28
+ )
29
+
30
+
31
+ def test_baseline_record_path_should_name_the_session_in_the_temp_folder(
32
+ tmp_path: Path,
33
+ ) -> None:
34
+ assert plugin_session_baseline.baseline_record_path("a/b c") == (
35
+ tmp_path / "plugin-session-baseline-abc.json"
36
+ )
37
+
38
+
39
+ def test_current_plugin_snapshot_should_keep_only_version_and_install_path(
40
+ tmp_path: Path,
41
+ ) -> None:
42
+ record_path = tmp_path / "installed_plugins.json"
43
+ record_path.write_text(
44
+ json.dumps(
45
+ {
46
+ "plugins": {
47
+ PLUGIN_ID: [
48
+ {
49
+ "scope": "user",
50
+ "installPath": "/cache/pstack/1.0.0",
51
+ "version": "1.0.0",
52
+ "lastUpdated": "2026-10-21T21:26:12.000Z",
53
+ }
54
+ ]
55
+ }
56
+ }
57
+ ),
58
+ encoding="utf-8",
59
+ )
60
+ assert plugin_session_baseline.current_plugin_snapshot(record_path) == {
61
+ PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/pstack/1.0.0"}]
62
+ }
63
+
64
+
65
+ def test_current_plugin_snapshot_should_read_a_malformed_record_as_empty(
66
+ tmp_path: Path,
67
+ ) -> None:
68
+ record_path = tmp_path / "installed_plugins.json"
69
+ record_path.write_text("{not json", encoding="utf-8")
70
+ assert plugin_session_baseline.current_plugin_snapshot(record_path) == {}
71
+
72
+
73
+ def test_read_baseline_record_should_return_what_the_writer_stored() -> None:
74
+ snapshot = {PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/pstack/1.0.0"}]}
75
+ plugin_session_baseline.write_baseline_record("session", snapshot, [PLUGIN_ID])
76
+ assert plugin_session_baseline.read_baseline_record("session") == {
77
+ "plugins": snapshot,
78
+ "warned": [PLUGIN_ID],
79
+ }
80
+
81
+
82
+ def test_all_changed_plugin_ids_should_name_changed_and_new_plugins_only() -> None:
83
+ unchanged_entries = [{"version": "1.0.0", "installPath": "/cache/a/1.0.0"}]
84
+ baseline_snapshot = {
85
+ "a@m": unchanged_entries,
86
+ "b@m": [{"version": "1.0.0", "installPath": "/cache/b/1.0.0"}],
87
+ }
88
+ current_snapshot = {
89
+ "a@m": unchanged_entries,
90
+ "b@m": [{"version": "2.0.0", "installPath": "/cache/b/2.0.0"}],
91
+ "c@m": [{"version": "1.0.0", "installPath": "/cache/c/1.0.0"}],
92
+ }
93
+ assert plugin_session_baseline.all_changed_plugin_ids(
94
+ baseline_snapshot, current_snapshot
95
+ ) == [
96
+ "b@m",
97
+ "c@m",
98
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.96.8",
3
+ "version": "8.97.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {