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.
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +1 -0
- package/docs/rule-guides/asd-ste100-language.md +10 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +9 -1
- package/hooks/blocking/headless_claude_broker_gate.py +14 -1
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +2 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +46 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -0
- package/hooks/conftest.py +18 -0
- package/hooks/features/session-context.md +1 -3
- package/hooks/hooks.json +0 -5
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +7 -1
- package/hooks/hooks_constants/mod_handoff.py +91 -0
- package/hooks/hooks_constants/mod_handoff_constants.py +38 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -1
- package/hooks/hooks_constants/test_mod_handoff.py +100 -0
- package/hooks/session/advisor_rules_prompt.py +5 -1
- package/hooks/session/orchestrator_auto_starter.py +5 -1
- package/hooks/session/task_tool_prompt.py +5 -1
- package/hooks/session/test_advisor_rules_prompt.py +11 -0
- package/hooks/session/test_orchestrator_auto_starter.py +22 -0
- package/hooks/session/test_task_tool_prompt.py +15 -0
- package/hooks/session/test_working_style_prompt.py +15 -0
- package/hooks/session/working_style_prompt.py +5 -1
- package/package.json +1 -1
- package/rules/asd-ste100-language.md +1 -1
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +24 -0
- package/scripts/review_closure_model.py +48 -12
- package/scripts/test_files/review_closure/codex_comments.json +1266 -0
- package/scripts/tests/test_review_closure_model.py +130 -0
- package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +0 -22
- package/hooks/session/issue_tracker_session_starter.py +0 -136
- 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([
|
package/bin/install.test.mjs
CHANGED
|
@@ -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`,
|
|
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
|
|
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",
|