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.
@@ -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);
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook that keeps every SendUserFile path under the session's working directory.
3
+
4
+ The desktop file pane resolves a sent file only under the session's root
5
+ folder, the ``cwd`` in the hook payload. A file elsewhere, such as a network
6
+ share or the session scratchpad, passes SendUserFile's own check and then shows
7
+ "Couldn't find this file" to the user::
8
+
9
+ SendUserFile {"files": ["<cwd>\\.scratch\\a.md"]} -> allowed
10
+ SendUserFile {"files": ["Y:\\share\\a.md"]} -> denied
11
+ SendUserFile {"files": ["<cwd>\\..\\other\\a.md"]} -> denied
12
+ a relative path -> joined to cwd
13
+ no cwd, no files list, or another tool -> allowed
14
+
15
+ Paths compare case-insensitively with Windows rules when ``cwd`` is a Windows
16
+ path, and with POSIX rules otherwise. The deny reason lists each outside file
17
+ and names ``<cwd>\\.scratch\\`` as the folder to copy it into.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ntpath
23
+ import posixpath
24
+ import sys
25
+ from pathlib import Path
26
+ from types import ModuleType
27
+
28
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
29
+ if hooks_root_directory not in sys.path:
30
+ sys.path.insert(0, hooks_root_directory)
31
+
32
+ from hooks_constants.context_budget_constants import CWD_KEY
33
+ from hooks_constants.hook_block_logger import log_hook_block
34
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
35
+ from hooks_constants.send_user_file_worktree_root_gate_constants import (
36
+ ALLOW_EXIT_CODE,
37
+ BLOCK_EXIT_CODE,
38
+ FILES_INPUT_KEY,
39
+ GATE_EVENT_NAME,
40
+ OFFENDING_INPUT_SEPARATOR,
41
+ OUTSIDE_ROOT_HEADER,
42
+ SCRATCH_FOLDER_INSTRUCTION,
43
+ SCRATCH_FOLDER_NAME,
44
+ SEND_USER_FILE_TOOL_NAME,
45
+ WINDOWS_PATH_PATTERN,
46
+ )
47
+ from hooks_constants.spawn_readiness_hook_constants import TOOL_INPUT_KEY, TOOL_NAME_KEY
48
+
49
+
50
+ def _path_rules_for(working_directory: str) -> ModuleType:
51
+ return ntpath if WINDOWS_PATH_PATTERN.match(working_directory) else posixpath
52
+
53
+
54
+ def is_under_working_directory(file_path: str, working_directory: str) -> bool:
55
+ """Return True when the file path names the working directory or a path inside it.
56
+
57
+ >>> is_under_working_directory("C:\\\\wt\\\\.scratch\\\\a.md", "c:/WT")
58
+ True
59
+ >>> is_under_working_directory("Y:\\\\share\\\\a.md", "C:\\\\wt")
60
+ False
61
+
62
+ Args:
63
+ file_path: One path from the SendUserFile ``files`` list.
64
+ working_directory: The session's ``cwd`` from the hook payload.
65
+
66
+ Returns:
67
+ True for the root itself or a path under it after joining and normalizing.
68
+ """
69
+ path_rules = _path_rules_for(working_directory)
70
+ root = path_rules.normcase(path_rules.normpath(working_directory))
71
+ candidate = path_rules.normcase(
72
+ path_rules.normpath(path_rules.join(working_directory, file_path))
73
+ )
74
+ if candidate == root:
75
+ return True
76
+ return candidate.startswith(root.rstrip(path_rules.sep) + path_rules.sep)
77
+
78
+
79
+ def outside_files(tool_input: object, working_directory: str) -> list[str]:
80
+ """Return each SendUserFile path that sits outside the working directory.
81
+
82
+ Args:
83
+ tool_input: The SendUserFile tool input.
84
+ working_directory: The session's ``cwd`` from the hook payload.
85
+
86
+ Returns:
87
+ The outside paths in call order, or an empty list when no files list is present.
88
+ """
89
+ all_files = tool_input.get(FILES_INPUT_KEY) if isinstance(tool_input, dict) else None
90
+ if not isinstance(all_files, list):
91
+ return []
92
+ return [
93
+ each_file
94
+ for each_file in all_files
95
+ if isinstance(each_file, str)
96
+ and not is_under_working_directory(each_file, working_directory)
97
+ ]
98
+
99
+
100
+ def format_block_reason(all_outside_files: list[str], working_directory: str) -> str:
101
+ """Return the deny message that lists the outside files and the folder to copy them into.
102
+
103
+ Args:
104
+ all_outside_files: Paths outside the working directory.
105
+ working_directory: The session's ``cwd`` from the hook payload.
106
+
107
+ Returns:
108
+ The message the session reads when the call is denied.
109
+ """
110
+ path_rules = _path_rules_for(working_directory)
111
+ scratch_folder = path_rules.join(working_directory, SCRATCH_FOLDER_NAME) + path_rules.sep
112
+ file_lines = "".join(f"- {each_file}\n" for each_file in all_outside_files)
113
+ return (
114
+ OUTSIDE_ROOT_HEADER.format(cwd=working_directory)
115
+ + file_lines
116
+ + SCRATCH_FOLDER_INSTRUCTION.format(scratch_folder=scratch_folder)
117
+ )
118
+
119
+
120
+ def main() -> int:
121
+ hook_input = read_hook_input_dictionary_from_stdin()
122
+ if hook_input is None or hook_input.get(TOOL_NAME_KEY) != SEND_USER_FILE_TOOL_NAME:
123
+ return ALLOW_EXIT_CODE
124
+ working_directory = hook_input.get(CWD_KEY)
125
+ if not isinstance(working_directory, str) or not working_directory:
126
+ return ALLOW_EXIT_CODE
127
+ all_outside_files = outside_files(hook_input.get(TOOL_INPUT_KEY), working_directory)
128
+ if not all_outside_files:
129
+ return ALLOW_EXIT_CODE
130
+ block_reason = format_block_reason(all_outside_files, working_directory)
131
+ log_hook_block(
132
+ Path(__file__).name,
133
+ GATE_EVENT_NAME,
134
+ block_reason,
135
+ tool_name=SEND_USER_FILE_TOOL_NAME,
136
+ offending_input_preview=OFFENDING_INPUT_SEPARATOR.join(all_outside_files),
137
+ )
138
+ sys.stderr.write(block_reason)
139
+ return BLOCK_EXIT_CODE
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
@@ -0,0 +1,126 @@
1
+ import io
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import send_user_file_worktree_root_gate
8
+
9
+ WORKTREE_CWD = "C:\\dev\\project\\.claude\\worktrees\\feature-branch"
10
+ SHARE_PATH = "Y:\\shared\\reports\\reflect.md"
11
+ SCRATCHPAD_PATH = "C:\\Users\\example\\AppData\\Local\\Temp\\claude\\scratchpad\\reflect.md"
12
+ SCRATCH_FOLDER_PATH = WORKTREE_CWD + "\\.scratch\\reflect.md"
13
+
14
+
15
+ def run_gate(
16
+ monkeypatch: pytest.MonkeyPatch,
17
+ capsys: pytest.CaptureFixture[str],
18
+ hook_input: dict[str, object],
19
+ ) -> tuple[int, str]:
20
+ monkeypatch.setattr(
21
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
22
+ )
23
+ exit_code = send_user_file_worktree_root_gate.main()
24
+ return exit_code, capsys.readouterr().err
25
+
26
+
27
+ def send_user_file_input(all_files: object, cwd: object = WORKTREE_CWD) -> dict[str, object]:
28
+ return {
29
+ "hook_event_name": "PreToolUse",
30
+ "tool_name": "SendUserFile",
31
+ "cwd": cwd,
32
+ "tool_input": {"files": all_files, "status": "normal", "display": "render"},
33
+ }
34
+
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
38
+ monkeypatch.setenv("HOME", str(tmp_path))
39
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
40
+
41
+
42
+ @pytest.mark.parametrize("outside_path", [SHARE_PATH, SCRATCHPAD_PATH])
43
+ def test_should_deny_a_file_outside_the_working_directory_and_name_the_scratch_folder(
44
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], outside_path: str
45
+ ) -> None:
46
+ exit_code, block_reason = run_gate(monkeypatch, capsys, send_user_file_input([outside_path]))
47
+ assert exit_code == 2
48
+ assert outside_path in block_reason
49
+ assert WORKTREE_CWD + "\\.scratch\\" in block_reason
50
+
51
+
52
+ @pytest.mark.parametrize(
53
+ "inside_path",
54
+ [
55
+ SCRATCH_FOLDER_PATH,
56
+ SCRATCH_FOLDER_PATH.lower(),
57
+ SCRATCH_FOLDER_PATH.replace("\\", "/"),
58
+ ".scratch\\reflect.md",
59
+ WORKTREE_CWD,
60
+ ],
61
+ )
62
+ def test_should_allow_a_file_under_the_working_directory(
63
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], inside_path: str
64
+ ) -> None:
65
+ assert run_gate(monkeypatch, capsys, send_user_file_input([inside_path])) == (0, "")
66
+
67
+
68
+ def test_should_deny_a_path_that_climbs_out_of_the_working_directory(
69
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
70
+ ) -> None:
71
+ climbing_path = WORKTREE_CWD + "\\.scratch\\..\\..\\other-worktree\\reflect.md"
72
+ exit_code, block_reason = run_gate(monkeypatch, capsys, send_user_file_input([climbing_path]))
73
+ assert exit_code == 2
74
+ assert climbing_path in block_reason
75
+
76
+
77
+ def test_should_deny_a_sibling_folder_that_shares_the_working_directory_prefix(
78
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
79
+ ) -> None:
80
+ sibling_path = WORKTREE_CWD + "-copy\\reflect.md"
81
+ exit_code, block_reason = run_gate(monkeypatch, capsys, send_user_file_input([sibling_path]))
82
+ assert exit_code == 2
83
+ assert sibling_path in block_reason
84
+
85
+
86
+ def test_should_name_only_the_outside_files_when_a_call_mixes_both(
87
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
88
+ ) -> None:
89
+ exit_code, block_reason = run_gate(
90
+ monkeypatch, capsys, send_user_file_input([SCRATCH_FOLDER_PATH, SHARE_PATH])
91
+ )
92
+ assert exit_code == 2
93
+ assert SHARE_PATH in block_reason
94
+ assert "- " + SCRATCH_FOLDER_PATH not in block_reason
95
+
96
+
97
+ def test_should_compare_posix_paths_under_a_posix_working_directory(
98
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
99
+ ) -> None:
100
+ posix_cwd = "/home/user/worktree"
101
+ assert run_gate(
102
+ monkeypatch, capsys, send_user_file_input([posix_cwd + "/.scratch/a.md"], posix_cwd)
103
+ ) == (0, "")
104
+ exit_code, block_reason = run_gate(
105
+ monkeypatch, capsys, send_user_file_input(["/tmp/a.md"], posix_cwd)
106
+ )
107
+ assert exit_code == 2
108
+ assert "/tmp/a.md" in block_reason
109
+
110
+
111
+ @pytest.mark.parametrize(
112
+ "hook_input",
113
+ [
114
+ send_user_file_input([SHARE_PATH], cwd=None),
115
+ send_user_file_input([SHARE_PATH], cwd=""),
116
+ send_user_file_input(None),
117
+ send_user_file_input(SHARE_PATH),
118
+ {**send_user_file_input([SHARE_PATH]), "tool_name": "Read"},
119
+ ],
120
+ )
121
+ def test_should_allow_a_call_the_gate_cannot_judge(
122
+ monkeypatch: pytest.MonkeyPatch,
123
+ capsys: pytest.CaptureFixture[str],
124
+ hook_input: dict[str, object],
125
+ ) -> None:
126
+ assert run_gate(monkeypatch, capsys, hook_input) == (0, "")
@@ -20,6 +20,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
20
20
  - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
21
21
  - `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
22
22
  - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
23
+ - `blocking/send_user_file_worktree_root_gate.py` denies a `SendUserFile` call that names a file outside the session's working directory, the `cwd` in the hook payload. The desktop file pane opens a sent file only under that folder and shows "Couldn't find this file" for any other path. The reason lists each outside file and names `<cwd>\.scratch\` as the folder to copy it into.
23
24
 
24
25
  ## When it fires
25
26
 
@@ -39,6 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
39
40
  - `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
40
41
  - `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
41
42
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
43
+ - `blocking/send_user_file_worktree_root_gate.py` runs on `PreToolUse`, matcher `SendUserFile`, timeout `10` seconds in `hooks.json`.
42
44
 
43
45
  ## Proving it
44
46
 
@@ -62,6 +64,7 @@ Preconditions:
62
64
  - **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass.
63
65
  - **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
64
66
  - **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
67
+ - **Sent file root.** Input is a `SendUserFile` call for a file on `Y:\` or in the session scratchpad, with `cwd` set to a worktree. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_send_user_file_worktree_root_gate.py -q`. The adjacent test observes a denial that names the file and `<cwd>\.scratch\`; a file under `<cwd>\.scratch\` passes.
65
68
 
66
69
  ## Gotchas
67
70
 
@@ -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
@@ -214,6 +214,16 @@
214
214
  }
215
215
  ]
216
216
  },
217
+ {
218
+ "matcher": "SendUserFile",
219
+ "hooks": [
220
+ {
221
+ "type": "command",
222
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/send_user_file_worktree_root_gate.py",
223
+ "timeout": 10
224
+ }
225
+ ]
226
+ },
217
227
  {
218
228
  "matcher": "Artifact|Skill",
219
229
  "hooks": [
@@ -303,6 +313,11 @@
303
313
  "type": "command",
304
314
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
305
315
  "timeout": 10
316
+ },
317
+ {
318
+ "type": "command",
319
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_change_warning.py",
320
+ "timeout": 10
306
321
  }
307
322
  ]
308
323
  }
@@ -346,6 +361,11 @@
346
361
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/orchestrator_auto_starter.py",
347
362
  "timeout": 10
348
363
  },
364
+ {
365
+ "type": "command",
366
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_session_baseline_writer.py",
367
+ "timeout": 10
368
+ },
349
369
  {
350
370
  "type": "command",
351
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,36 @@
1
+ """Names and messages for the send_user_file_worktree_root_gate PreToolUse hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ __all__ = [
8
+ "ALLOW_EXIT_CODE",
9
+ "BLOCK_EXIT_CODE",
10
+ "FILES_INPUT_KEY",
11
+ "GATE_EVENT_NAME",
12
+ "OFFENDING_INPUT_SEPARATOR",
13
+ "OUTSIDE_ROOT_HEADER",
14
+ "SCRATCH_FOLDER_NAME",
15
+ "SCRATCH_FOLDER_INSTRUCTION",
16
+ "SEND_USER_FILE_TOOL_NAME",
17
+ "WINDOWS_PATH_PATTERN",
18
+ ]
19
+
20
+ ALLOW_EXIT_CODE = 0
21
+ BLOCK_EXIT_CODE = 2
22
+ SEND_USER_FILE_TOOL_NAME = "SendUserFile"
23
+ FILES_INPUT_KEY = "files"
24
+ GATE_EVENT_NAME = "PreToolUse"
25
+ OFFENDING_INPUT_SEPARATOR = ", "
26
+ SCRATCH_FOLDER_NAME = ".scratch"
27
+ WINDOWS_PATH_PATTERN = re.compile(r"^(?:[A-Za-z]:|\\\\)")
28
+ OUTSIDE_ROOT_HEADER = (
29
+ "The desktop file pane opens a sent file only when it sits under this session's"
30
+ " working directory, {cwd}. These files are outside it, so the user sees"
31
+ ' "Couldn\'t find this file":\n'
32
+ )
33
+ SCRATCH_FOLDER_INSTRUCTION = (
34
+ "\nCopy each file into {scratch_folder} and call SendUserFile with the copied paths."
35
+ " If git does not ignore that folder, add .scratch/ to .git/info/exclude first."
36
+ )