claude-dev-env 8.72.0 → 8.72.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/install.mjs +1 -0
  2. package/bin/install.test.mjs +1 -0
  3. package/docs/rule-guides/asd-ste100-language.md +10 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +9 -1
  5. package/hooks/blocking/headless_claude_broker_gate.py +14 -1
  6. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +2 -0
  7. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +46 -0
  8. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -0
  9. package/hooks/conftest.py +18 -0
  10. package/hooks/features/session-context.md +1 -3
  11. package/hooks/hooks.json +0 -5
  12. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  13. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +7 -1
  14. package/hooks/hooks_constants/mod_handoff.py +91 -0
  15. package/hooks/hooks_constants/mod_handoff_constants.py +38 -0
  16. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -1
  17. package/hooks/hooks_constants/test_mod_handoff.py +100 -0
  18. package/hooks/session/advisor_rules_prompt.py +5 -1
  19. package/hooks/session/orchestrator_auto_starter.py +5 -1
  20. package/hooks/session/task_tool_prompt.py +5 -1
  21. package/hooks/session/test_advisor_rules_prompt.py +11 -0
  22. package/hooks/session/test_orchestrator_auto_starter.py +22 -0
  23. package/hooks/session/test_task_tool_prompt.py +15 -0
  24. package/hooks/session/test_working_style_prompt.py +15 -0
  25. package/hooks/session/working_style_prompt.py +5 -1
  26. package/package.json +1 -1
  27. package/rules/asd-ste100-language.md +1 -1
  28. package/scripts/dev_env_scripts_constants/review_closure_constants.py +24 -0
  29. package/scripts/review_closure_model.py +48 -12
  30. package/scripts/test_files/review_closure/codex_comments.json +1266 -0
  31. package/scripts/tests/test_review_closure_model.py +130 -0
  32. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +0 -22
  33. package/hooks/session/issue_tracker_session_starter.py +0 -136
  34. package/hooks/session/test_issue_tracker_session_starter.py +0 -106
package/bin/install.mjs CHANGED
@@ -1247,6 +1247,7 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1247
1247
  'blocking/send_user_file_open_locally_blocker.py',
1248
1248
  'blocking/question_to_user_enforcer.py',
1249
1249
  'blocking/session_handoff_blocker.py',
1250
+ 'session/issue_tracker_session_starter.py',
1250
1251
  ]);
1251
1252
 
1252
1253
  export const MOVED_HOOK_RELATIVE_PATHS = new Set([
@@ -1230,6 +1230,7 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
1230
1230
  'blocking/send_user_file_open_locally_blocker.py',
1231
1231
  'blocking/question_to_user_enforcer.py',
1232
1232
  'blocking/session_handoff_blocker.py',
1233
+ 'session/issue_tracker_session_starter.py',
1233
1234
  ]);
1234
1235
  const shippedHooks = JSON.parse(
1235
1236
  readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
@@ -28,6 +28,7 @@ definitions and dictionary decisions.
28
28
  - Use inclusive, neutral language.
29
29
  - Use periods, commas, colons, and bullets to show structure.
30
30
  - Preserve exact quoted labels, identifiers, formulas, titles, and interface text.
31
+ - Say each fact once in content read together. Preserve accessible text alternatives for pictures and essential prerequisites or warnings at each point of use.
31
32
  - Send the reader only a result, a blocker, or a question. Leave out a line that says nothing is needed from them, and leave out which agent, session, or coordinator did the work.
32
33
  - Use `WARNING` for a risk of injury or death. Use `CAUTION` for a risk of equipment, tool, or machine damage. State the command or condition first, then state the result.
33
34
  - Aim for 20 words or fewer in a procedure sentence when the technical content allows.
@@ -38,5 +39,14 @@ and durable repository text. Named contracts can add behavior-specific structure
38
39
  question routing, current-state documentation, completion, docstring, or publication rules.
39
40
  Those contracts use this policy for their language.
40
41
 
42
+ ## Repeated-points check
43
+
44
+ Run this check on each reply or page you draft, before you send it. Run it also when asked to check text for repeated points.
45
+
46
+ 1. List each fact the text states, with where it appears. Include each fact that a picture, diagram, or table shows: its labels, its order, what moves, and the end state it draws.
47
+ 2. Mark repeated facts within content read together. Exclude accessibility text, alternate representations, and prerequisites or warnings needed at each point of use.
48
+ 3. Keep each marked fact in the one place that shows it best. Delete each other statement of it, or rewrite that line to carry a new fact. Outside these exceptions, a bullet under a picture keeps only what the picture cannot show, such as a cause, a condition, a command, or a warning.
49
+ 4. Keep routine self-check results silent. When asked to review repeated points, report each restatement you removed, or report that each fact appears once.
50
+
41
51
  Treat automated output and language checks as drafting aids. A responsible human verifies
42
52
  technical accuracy, terminology, safety, confidentiality, and intended meaning.
@@ -41,6 +41,7 @@ from hooks_constants.hosted_hook_runner import (
41
41
  HostedHookRun,
42
42
  resolved_hook_script_path,
43
43
  )
44
+ from hooks_constants.mod_handoff import is_mod_plugin_enabled
44
45
 
45
46
  _PERMISSION_DECISION_KEY = "permissionDecision"
46
47
  _PERMISSION_REASON_KEY = "permissionDecisionReason"
@@ -87,11 +88,18 @@ class _ParsedHookDecision:
87
88
 
88
89
 
89
90
  def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
90
- """Return the ordered hosted-hook entries that apply to tool_name."""
91
+ """Return the ordered hosted-hook entries that apply to tool_name.
92
+
93
+ An entry whose mod plugin is on drops out, because the mod does its work.
94
+ """
91
95
  return [
92
96
  each_entry
93
97
  for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
94
98
  if tool_name in each_entry.applicable_tool_names
99
+ and not (
100
+ each_entry.replaced_by_plugin_name is not None
101
+ and is_mod_plugin_enabled(each_entry.replaced_by_plugin_name)
102
+ )
95
103
  ]
96
104
 
97
105
 
@@ -9,6 +9,9 @@ The broker form runs ``python``, so its ``-- claude -p ...`` tail passes.
9
9
  Interactive ``claude`` and its subcommands, such as ``claude plugin eval`` or
10
10
  ``claude --version``, pass.
11
11
 
12
+ A Claude Code cloud session, where ``CLAUDE_CODE_REMOTE`` is ``true``, has one
13
+ account, so the gate passes every command there.
14
+
12
15
  Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
13
16
  PowerShell tools.
14
17
  """
@@ -16,6 +19,7 @@ PowerShell tools.
16
19
  from __future__ import annotations
17
20
 
18
21
  import json
22
+ import os
19
23
  import sys
20
24
  from pathlib import Path
21
25
 
@@ -34,6 +38,8 @@ try:
34
38
  BROKER_COMMAND_DENY_REASON,
35
39
  CLAUDE_PROFILE_LAUNCHER_PREFIX,
36
40
  CLAUDE_PROGRAM_NAME,
41
+ CLOUD_SESSION_ENV_TRUE_VALUE,
42
+ CLOUD_SESSION_ENV_VAR,
37
43
  GATE_HOOK_NAME,
38
44
  PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX,
39
45
  )
@@ -113,8 +119,15 @@ def starts_unbrokered_headless_claude(command: str) -> bool:
113
119
  )
114
120
 
115
121
 
122
+ def is_cloud_session() -> bool:
123
+ """Return True when this hook runs inside a Claude Code cloud session."""
124
+ return os.environ.get(CLOUD_SESSION_ENV_VAR, "").strip().lower() == CLOUD_SESSION_ENV_TRUE_VALUE
125
+
126
+
116
127
  def main() -> None:
117
- """Deny a headless Claude start that skips the broker, or stay quiet."""
128
+ """Deny a headless Claude start that skips the broker outside a cloud session, or stay quiet."""
129
+ if is_cloud_session():
130
+ return
118
131
  hook_payload = read_hook_input_dictionary_from_stdin()
119
132
  if hook_payload is None:
120
133
  return
@@ -31,9 +31,11 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
31
31
  BashHostedHookEntry(
32
32
  script_relative_path="blocking/headless_claude_broker_gate.py",
33
33
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
34
+ replaced_by_plugin_name="shell-guards",
34
35
  ),
35
36
  BashHostedHookEntry(
36
37
  script_relative_path="blocking/gh_global_account_switch_gate.py",
37
38
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
39
+ replaced_by_plugin_name="shell-guards",
38
40
  ),
39
41
  )
@@ -251,3 +251,49 @@ def test_emit_decision_sends_a_rewrite_through_the_permission_prompt(
251
251
  assert hook_specific["updatedInput"] == {
252
252
  "command": "MSYS2_ARG_CONV_EXCL='*' git show HEAD:a.py"
253
253
  }
254
+
255
+
256
+ def _enable_shell_guards(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
257
+ """Turn the shell-guards plugin on in a temp user settings file."""
258
+ user_directory = tmp_path / "user-config"
259
+ user_directory.mkdir()
260
+ (user_directory / "settings.json").write_text(
261
+ json.dumps({"enabledPlugins": {"shell-guards@mods-marketplace": True}}), encoding="utf-8"
262
+ )
263
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(user_directory))
264
+ monkeypatch.delenv("CLAUDE_CODE_REMOTE", raising=False)
265
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path))
266
+
267
+
268
+ def test_should_drop_the_gates_the_shell_guards_mod_holds_while_it_is_on(
269
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
270
+ ) -> None:
271
+ _enable_shell_guards(tmp_path, monkeypatch)
272
+ all_script_paths = [each_entry.script_relative_path for each_entry in select_applicable_entries(BASH_TOOL_NAME)]
273
+ assert all_script_paths == ["blocking/msys_rev_path_rewriter.py"]
274
+
275
+
276
+ def test_should_pass_a_headless_claude_command_while_the_shell_guards_mod_is_on(
277
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
278
+ ) -> None:
279
+ _enable_shell_guards(tmp_path, monkeypatch)
280
+ completed = subprocess.run(
281
+ [sys.executable, _DISPATCHER_SCRIPT],
282
+ check=False,
283
+ input=_bash_payload("claude -p 'say hello'"),
284
+ capture_output=True,
285
+ text=True,
286
+ encoding="utf-8",
287
+ env={**os.environ},
288
+ )
289
+ assert _decision_from_stdout(completed.stdout) == ("", "")
290
+
291
+
292
+ def test_should_deny_a_headless_claude_command_while_no_mod_is_on(
293
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
294
+ ) -> None:
295
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
296
+ monkeypatch.delenv("CLAUDE_CODE_REMOTE", raising=False)
297
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path))
298
+ completed = _run_process(_DISPATCHER_SCRIPT, _bash_payload("claude -p 'say hello'"))
299
+ assert _decision_from_stdout(completed.stdout)[0] == "deny"
@@ -86,3 +86,15 @@ def test_broker_command_payload_emits_nothing() -> None:
86
86
 
87
87
  def test_other_tool_payload_emits_nothing() -> None:
88
88
  assert _stdout_from_main({"tool_name": "Write", "tool_input": {"command": 'claude -p "task"'}}) == ""
89
+
90
+
91
+ def test_should_pass_bare_headless_claude_in_a_cloud_session(monkeypatch: pytest.MonkeyPatch) -> None:
92
+ monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
93
+ assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}) == ""
94
+
95
+
96
+ def test_should_report_a_cloud_session_only_when_the_marker_is_true(monkeypatch: pytest.MonkeyPatch) -> None:
97
+ monkeypatch.setenv("CLAUDE_CODE_REMOTE", "false")
98
+ assert gate.is_cloud_session() is False
99
+ monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
100
+ assert gate.is_cloud_session() is True
package/hooks/conftest.py CHANGED
@@ -22,6 +22,10 @@ _CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
22
22
  "hooks_constants.harness_scratchpad_constants"
23
23
  ).CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME
24
24
 
25
+ _CLOUD_SESSION_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
26
+ "hooks_constants.headless_claude_broker_gate_constants"
27
+ ).CLOUD_SESSION_ENV_VAR
28
+
25
29
  _ROOT_ANCHORED_PROBE_PATH = "/tmp/scratch.py"
26
30
 
27
31
 
@@ -40,6 +44,20 @@ def isolate_from_live_session_scratchpad(monkeypatch: pytest.MonkeyPatch) -> Non
40
44
  monkeypatch.delenv(_CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME, raising=False)
41
45
 
42
46
 
47
+ @pytest.fixture(autouse=True)
48
+ def isolate_from_live_cloud_session(monkeypatch: pytest.MonkeyPatch) -> None:
49
+ """Remove the cloud session marker before each hooks test.
50
+
51
+ A Claude Code cloud session exports CLAUDE_CODE_REMOTE=true, and gates
52
+ that stand aside in cloud would then pass every case. Tests that exercise the
53
+ cloud path set the variable themselves.
54
+
55
+ Args:
56
+ monkeypatch: Pytest's environment-variable patcher, torn down after the test.
57
+ """
58
+ monkeypatch.delenv(_CLOUD_SESSION_ENVIRONMENT_VARIABLE_NAME, raising=False)
59
+
60
+
43
61
  @pytest.fixture
44
62
  def ephemeral_exempt_off(monkeypatch: pytest.MonkeyPatch) -> None:
45
63
  """Turn off the ephemeral scratch-path exemption for the current test.
@@ -9,14 +9,13 @@ This family injects reminders and task guidance when an agent starts, resumes, s
9
9
  - `session/working_style_prompt.py` injects the session's working style at session start.
10
10
  - `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
11
11
  - `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
12
- - `session/issue_tracker_session_starter.py` injects issue tracker guidance when enabled for a registered repository.
13
12
  - `session/artifact_template_pointer.py` points an agent that starts an artifact page at `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
13
  - `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
14
 
16
15
  ## When it fires
17
16
 
18
17
  - `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
- - `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, `session/orchestrator_auto_starter.py`, and `session/issue_tracker_session_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
18
+ - `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`.
20
19
  - `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`.
21
20
  - `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
22
21
 
@@ -31,7 +30,6 @@ Preconditions:
31
30
  - **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.
32
31
  - **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.
33
32
  - **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.
34
- - **Issue tracker start.** Input is SessionStart with its opt-in flag and a registered checkout. Run `python -m pytest packages/claude-dev-env/hooks/session/test_issue_tracker_session_starter.py -q`. The adjacent test observes issue tracker context.
35
33
  - **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 html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
36
34
  - **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.
37
35
 
package/hooks/hooks.json CHANGED
@@ -279,11 +279,6 @@
279
279
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/orchestrator_auto_starter.py",
280
280
  "timeout": 10
281
281
  },
282
- {
283
- "type": "command",
284
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/issue_tracker_session_starter.py",
285
- "timeout": 10
286
- },
287
282
  {
288
283
  "type": "command",
289
284
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  from dataclasses import dataclass
11
11
 
12
+ from hooks_constants.mod_handoff_constants import SHELL_GUARDS_PLUGIN_NAME
13
+
12
14
  __all__ = [
13
15
  "DENY_DECISION",
14
16
  "ASK_DECISION",
@@ -48,10 +50,14 @@ class BashHostedHookEntry:
48
50
  script_relative_path: Hook path relative to the hooks/ directory.
49
51
  applicable_tool_names: Tool names this hook runs for. The dispatcher
50
52
  skips the hook when the payload's tool is not in this set.
53
+ replaced_by_plugin_name: The plugin whose mod holds this hook. The
54
+ dispatcher skips the hook while that plugin is on, so a session
55
+ runs the hook or the mod and never both.
51
56
  """
52
57
 
53
58
  script_relative_path: str
54
59
  applicable_tool_names: frozenset[str]
60
+ replaced_by_plugin_name: str | None = None
55
61
 
56
62
 
57
63
  ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
@@ -62,9 +68,11 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
62
68
  BashHostedHookEntry(
63
69
  script_relative_path="blocking/headless_claude_broker_gate.py",
64
70
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
71
+ replaced_by_plugin_name=SHELL_GUARDS_PLUGIN_NAME,
65
72
  ),
66
73
  BashHostedHookEntry(
67
74
  script_relative_path="blocking/gh_global_account_switch_gate.py",
68
75
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
76
+ replaced_by_plugin_name=SHELL_GUARDS_PLUGIN_NAME,
69
77
  ),
70
78
  )
@@ -3,7 +3,9 @@
3
3
  Holds the Claude program name and its profile-launcher prefix, the Windows
4
4
  launcher suffixes stripped before the name check, the two print-mode flags, the
5
5
  hook name the block log records, and the deny message that names the broker
6
- command to run instead.
6
+ command to run instead. It also names the environment variable a Claude Code
7
+ cloud session sets, where the gate stands aside because the session has one
8
+ account.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
@@ -16,6 +18,8 @@ __all__ = [
16
18
  "PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX",
17
19
  "GATE_HOOK_NAME",
18
20
  "BROKER_COMMAND_DENY_REASON",
21
+ "CLOUD_SESSION_ENV_VAR",
22
+ "CLOUD_SESSION_ENV_TRUE_VALUE",
19
23
  ]
20
24
 
21
25
  CLAUDE_PROGRAM_NAME: str = "claude"
@@ -24,6 +28,8 @@ ALL_WINDOWS_LAUNCHER_SUFFIXES: tuple[str, ...] = (".cmd", ".exe", ".ps1", ".bat"
24
28
  ALL_PRINT_MODE_FLAGS: frozenset[str] = frozenset({"-p", "--print"})
25
29
  PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX: str = "--print="
26
30
  GATE_HOOK_NAME: str = "headless_claude_broker_gate.py"
31
+ CLOUD_SESSION_ENV_VAR: str = "CLAUDE_CODE_REMOTE"
32
+ CLOUD_SESSION_ENV_TRUE_VALUE: str = "true"
27
33
  BROKER_COMMAND_DENY_REASON: str = (
28
34
  "BLOCKED: Start a headless Claude session through the account broker. "
29
35
  "Run: python \"$HOME/.claude/scripts/account_broker.py\" run --product claude "
@@ -0,0 +1,91 @@
1
+ """Tell a settings hook whether the mod plugin that holds its work is on.
2
+
3
+ Claude Code reads ``enabledPlugins`` from the user settings file, then the
4
+ project's ``.claude/settings.json``, then its ``.claude/settings.local.json``,
5
+ and a later file overrides an earlier one. A key names a plugin as
6
+ ``<plugin>@<marketplace>``, so a match on the plugin name holds wherever the
7
+ plugin ships.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ from hooks_constants.mod_handoff_constants import (
17
+ ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER,
18
+ CLOUD_SESSION_ENV_TRUE_VALUE,
19
+ CLOUD_SESSION_ENV_VAR,
20
+ CLAUDE_CONFIG_DIR_ENV_VAR,
21
+ CLAUDE_PROJECT_DIR_ENV_VAR,
22
+ DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME,
23
+ ENABLED_PLUGINS_SETTINGS_KEY,
24
+ PLUGIN_MARKETPLACE_SEPARATOR,
25
+ PROJECT_SETTINGS_DIRECTORY_NAME,
26
+ USER_SETTINGS_FILE_NAME,
27
+ )
28
+
29
+
30
+ def all_settings_paths_in_precedence_order() -> list[Path]:
31
+ """Return the user, project and local settings files, lowest precedence first."""
32
+ configured_directory = os.environ.get(CLAUDE_CONFIG_DIR_ENV_VAR, "").strip()
33
+ user_directory = (
34
+ Path(configured_directory)
35
+ if configured_directory
36
+ else Path.home() / DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME
37
+ )
38
+ all_paths = [user_directory / USER_SETTINGS_FILE_NAME]
39
+ project_directory = os.environ.get(CLAUDE_PROJECT_DIR_ENV_VAR, "").strip() or os.getcwd()
40
+ project_settings_directory = Path(project_directory) / PROJECT_SETTINGS_DIRECTORY_NAME
41
+ all_paths.extend(
42
+ project_settings_directory / each_file_name
43
+ for each_file_name in ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER
44
+ )
45
+ return all_paths
46
+
47
+
48
+ def plugin_state_in_settings_file(settings_path: Path, plugin_name: str) -> bool | None:
49
+ """Return the file's on or off setting for plugin_name, or None when it names none.
50
+
51
+ Args:
52
+ settings_path: A Claude Code settings file.
53
+ plugin_name: The plugin name before the ``@marketplace`` part of the key.
54
+ """
55
+ try:
56
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
57
+ except (OSError, ValueError):
58
+ return None
59
+ if not isinstance(settings, dict):
60
+ return None
61
+ enabled_plugins = settings.get(ENABLED_PLUGINS_SETTINGS_KEY)
62
+ if not isinstance(enabled_plugins, dict):
63
+ return None
64
+ all_states = [
65
+ each_state
66
+ for each_key, each_state in enabled_plugins.items()
67
+ if isinstance(each_key, str)
68
+ and isinstance(each_state, bool)
69
+ and each_key.split(PLUGIN_MARKETPLACE_SEPARATOR, 1)[0] == plugin_name
70
+ ]
71
+ if not all_states:
72
+ return None
73
+ return any(all_states)
74
+
75
+
76
+ def is_mod_plugin_enabled(plugin_name: str) -> bool:
77
+ """Return True when the settings files leave plugin_name on outside a cloud session.
78
+
79
+ A cloud session runs no mods, so it keeps every hook whatever the settings say.
80
+
81
+ Args:
82
+ plugin_name: The plugin that holds the mod replacing the calling hook.
83
+ """
84
+ if os.environ.get(CLOUD_SESSION_ENV_VAR, "").strip().lower() == CLOUD_SESSION_ENV_TRUE_VALUE:
85
+ return False
86
+ is_enabled = False
87
+ for each_path in all_settings_paths_in_precedence_order():
88
+ state = plugin_state_in_settings_file(each_path, plugin_name)
89
+ if state is not None:
90
+ is_enabled = state
91
+ return is_enabled
@@ -0,0 +1,38 @@
1
+ """Names a settings hook reads to tell whether a mod plugin holds its work.
2
+
3
+ A converted hook stays silent when the plugin that holds its mod is on in
4
+ ``enabledPlugins``, so a session runs the hook or the mod and never both.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = [
10
+ "ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER",
11
+ "CLOUD_SESSION_ENV_TRUE_VALUE",
12
+ "CLOUD_SESSION_ENV_VAR",
13
+ "CLAUDE_CONFIG_DIR_ENV_VAR",
14
+ "CLAUDE_PROJECT_DIR_ENV_VAR",
15
+ "DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME",
16
+ "ENABLED_PLUGINS_SETTINGS_KEY",
17
+ "PLUGIN_MARKETPLACE_SEPARATOR",
18
+ "PROJECT_SETTINGS_DIRECTORY_NAME",
19
+ "SESSION_PROMPTS_PLUGIN_NAME",
20
+ "SHELL_GUARDS_PLUGIN_NAME",
21
+ "USER_SETTINGS_FILE_NAME",
22
+ ]
23
+
24
+ ENABLED_PLUGINS_SETTINGS_KEY = "enabledPlugins"
25
+ CLOUD_SESSION_ENV_VAR = "CLAUDE_CODE_REMOTE"
26
+ CLOUD_SESSION_ENV_TRUE_VALUE = "true"
27
+ PLUGIN_MARKETPLACE_SEPARATOR = "@"
28
+ SHELL_GUARDS_PLUGIN_NAME = "shell-guards"
29
+ SESSION_PROMPTS_PLUGIN_NAME = "session-prompts"
30
+ CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR"
31
+ CLAUDE_PROJECT_DIR_ENV_VAR = "CLAUDE_PROJECT_DIR"
32
+ DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME = ".claude"
33
+ PROJECT_SETTINGS_DIRECTORY_NAME = ".claude"
34
+ USER_SETTINGS_FILE_NAME = "settings.json"
35
+ ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER: tuple[str, ...] = (
36
+ "settings.json",
37
+ "settings.local.json",
38
+ )
@@ -12,7 +12,7 @@ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSH
12
12
  BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
13
13
 
14
14
 
15
- def test_roster_hosts_the_msys_rewriter_the_broker_gate_and_the_gh_account_gate() -> None:
15
+ def test_roster_hosts_the_three_gates_and_hands_two_to_the_shell_guards_mod() -> None:
16
16
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
17
17
  BashHostedHookEntry(
18
18
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -21,9 +21,11 @@ def test_roster_hosts_the_msys_rewriter_the_broker_gate_and_the_gh_account_gate(
21
21
  BashHostedHookEntry(
22
22
  script_relative_path="blocking/headless_claude_broker_gate.py",
23
23
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
24
+ replaced_by_plugin_name="shell-guards",
24
25
  ),
25
26
  BashHostedHookEntry(
26
27
  script_relative_path="blocking/gh_global_account_switch_gate.py",
27
28
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
29
+ replaced_by_plugin_name="shell-guards",
28
30
  ),
29
31
  )
@@ -0,0 +1,100 @@
1
+ """Behavioral tests for the mod handoff check that silences a converted hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
12
+ if str(_HOOKS_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(_HOOKS_ROOT))
14
+
15
+ from hooks_constants.mod_handoff import (
16
+ all_settings_paths_in_precedence_order,
17
+ is_mod_plugin_enabled,
18
+ plugin_state_in_settings_file,
19
+ )
20
+
21
+
22
+ @pytest.fixture
23
+ def settings_homes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
24
+ """Point the user config and project directories at empty temp folders."""
25
+ user_directory = tmp_path / "user-config"
26
+ project_directory = tmp_path / "project"
27
+ (project_directory / ".claude").mkdir(parents=True)
28
+ user_directory.mkdir()
29
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(user_directory))
30
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(project_directory))
31
+ monkeypatch.delenv("CLAUDE_CODE_REMOTE", raising=False)
32
+ return user_directory, project_directory
33
+
34
+
35
+ def _write_enabled_plugins(settings_path: Path, enabled_plugins: object) -> None:
36
+ settings_path.write_text(json.dumps({"enabledPlugins": enabled_plugins}), encoding="utf-8")
37
+
38
+
39
+ def test_should_list_user_then_project_then_local_settings(
40
+ settings_homes: tuple[Path, Path],
41
+ ) -> None:
42
+ user_directory, project_directory = settings_homes
43
+ assert all_settings_paths_in_precedence_order() == [
44
+ user_directory / "settings.json",
45
+ project_directory / ".claude" / "settings.json",
46
+ project_directory / ".claude" / "settings.local.json",
47
+ ]
48
+
49
+
50
+ def test_should_match_the_plugin_under_any_marketplace(tmp_path: Path) -> None:
51
+ settings_path = tmp_path / "settings.json"
52
+ _write_enabled_plugins(settings_path, {"shell-guards@any-marketplace": True})
53
+ assert plugin_state_in_settings_file(settings_path, "shell-guards") is True
54
+
55
+
56
+ def test_should_report_no_state_for_a_plugin_the_file_never_names(tmp_path: Path) -> None:
57
+ settings_path = tmp_path / "settings.json"
58
+ _write_enabled_plugins(settings_path, {"shell-guards-extra@market": True})
59
+ assert plugin_state_in_settings_file(settings_path, "shell-guards") is None
60
+
61
+
62
+ def test_should_report_no_state_for_a_missing_or_broken_file(tmp_path: Path) -> None:
63
+ broken_path = tmp_path / "broken.json"
64
+ broken_path.write_text("{not json", encoding="utf-8")
65
+ assert plugin_state_in_settings_file(tmp_path / "missing.json", "shell-guards") is None
66
+ assert plugin_state_in_settings_file(broken_path, "shell-guards") is None
67
+
68
+
69
+ def test_should_stay_off_when_no_settings_file_names_the_plugin(
70
+ settings_homes: tuple[Path, Path],
71
+ ) -> None:
72
+ assert is_mod_plugin_enabled("session-prompts") is False
73
+
74
+
75
+ def test_should_turn_on_from_the_user_settings(settings_homes: tuple[Path, Path]) -> None:
76
+ user_directory, _ = settings_homes
77
+ _write_enabled_plugins(user_directory / "settings.json", {"session-prompts@market": True})
78
+ assert is_mod_plugin_enabled("session-prompts") is True
79
+
80
+
81
+ def test_should_let_local_project_settings_turn_a_user_plugin_off(
82
+ settings_homes: tuple[Path, Path],
83
+ ) -> None:
84
+ user_directory, project_directory = settings_homes
85
+ _write_enabled_plugins(user_directory / "settings.json", {"session-prompts@market": True})
86
+ _write_enabled_plugins(
87
+ project_directory / ".claude" / "settings.local.json", {"session-prompts@market": False}
88
+ )
89
+ assert is_mod_plugin_enabled("session-prompts") is False
90
+
91
+
92
+ def test_should_keep_the_hook_in_a_cloud_session_with_the_plugin_on(
93
+ settings_homes: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch,
94
+ ) -> None:
95
+ _, project_directory = settings_homes
96
+ _write_enabled_plugins(
97
+ project_directory / ".claude" / "settings.json", {"session-prompts@market": True}
98
+ )
99
+ monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
100
+ assert is_mod_plugin_enabled("session-prompts") is False
@@ -29,6 +29,8 @@ from hooks_constants.advisor_rules_prompt_constants import (
29
29
  DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME,
30
30
  USER_SETTINGS_FILE_NAME,
31
31
  )
32
+ from hooks_constants.mod_handoff import is_mod_plugin_enabled
33
+ from hooks_constants.mod_handoff_constants import SESSION_PROMPTS_PLUGIN_NAME
32
34
 
33
35
 
34
36
  def user_settings_path() -> Path:
@@ -58,7 +60,9 @@ def is_advisor_configured(settings_path: Path) -> bool:
58
60
 
59
61
 
60
62
  def main() -> None:
61
- """Emit the advisor guidance as SessionStart additionalContext when the advisor is on."""
63
+ """Emit the advisor guidance as SessionStart additionalContext when the advisor is on and the session-prompts mod is off."""
64
+ if is_mod_plugin_enabled(SESSION_PROMPTS_PLUGIN_NAME):
65
+ return
62
66
  if is_advisor_disabled_by_environment():
63
67
  return
64
68
  if not is_advisor_configured(user_settings_path()):
@@ -19,6 +19,8 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
19
  if _hooks_dir not in sys.path:
20
20
  sys.path.insert(0, _hooks_dir)
21
21
 
22
+ from hooks_constants.mod_handoff import is_mod_plugin_enabled
23
+ from hooks_constants.mod_handoff_constants import SESSION_PROMPTS_PLUGIN_NAME
22
24
  from hooks_constants.orchestrator_auto_starter_constants import ( # noqa: E402
23
25
  ALL_ORCHESTRATOR_STARTER_ENABLED_ENV_VALUES,
24
26
  ORCHESTRATOR_AUTO_STARTER_ENABLED_ENV_VAR,
@@ -88,7 +90,9 @@ def run_orchestrator_auto_starter(
88
90
 
89
91
 
90
92
  def main() -> None:
91
- """Emit orchestrator additionalContext when opt-in is enabled; else exit 0."""
93
+ """Emit orchestrator additionalContext when opt-in is enabled and the session-prompts mod is off; else exit 0."""
94
+ if is_mod_plugin_enabled(SESSION_PROMPTS_PLUGIN_NAME):
95
+ return
92
96
  payload_by_key = read_hook_input_dictionary_from_stdin()
93
97
  if payload_by_key is None:
94
98
  return
@@ -14,6 +14,8 @@ import sys
14
14
 
15
15
  import _path_setup # noqa: F401
16
16
 
17
+ from hooks_constants.mod_handoff import is_mod_plugin_enabled
18
+ from hooks_constants.mod_handoff_constants import SESSION_PROMPTS_PLUGIN_NAME
17
19
  from hooks_constants.task_tool_prompt_constants import TASK_TOOL_DIRECTIVE
18
20
 
19
21
 
@@ -23,7 +25,9 @@ def build_session_directive() -> str:
23
25
 
24
26
 
25
27
  def main() -> None:
26
- """Emit the task-tool directive as SessionStart additionalContext."""
28
+ """Emit the task-tool directive as SessionStart additionalContext, unless the session-prompts mod is on."""
29
+ if is_mod_plugin_enabled(SESSION_PROMPTS_PLUGIN_NAME):
30
+ return
27
31
  payload = {
28
32
  "hookSpecificOutput": {
29
33
  "hookEventName": "SessionStart",