claude-dev-env 8.10.0 → 8.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
- package/bin/codex-skill-load-block.mjs +45 -0
- package/bin/codex-skill-load-block.test.mjs +72 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.mjs +53 -31
- package/bin/install.test.mjs +37 -0
- package/bin/install.transaction.test.mjs +29 -7
- package/bin/seed-codex-pstack-models.mjs +27 -0
- package/bin/seed-codex-pstack-models.test.mjs +72 -0
- package/docs/CODE_RULES.md +5 -1
- package/hooks/blocking/code_rules_enforcer.py +1 -3
- package/hooks/blocking/code_rules_magic_values.py +1 -3
- package/hooks/blocking/code_rules_paired_test.py +5 -6
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
- package/hooks/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
- package/hooks/hooks_constants/messages.py +0 -1
- package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
- package/hooks/hooks_constants/test_messages.py +1 -7
- package/hooks/lifecycle/nested_project_hooks.py +395 -0
- package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
- package/hooks/test_followup_ledger.py +18 -0
- package/package.json +2 -1
- package/pstack-codex-models.md +37 -0
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +53 -0
- package/rules/code-standards.md +1 -1
- package/rules/flag-non-breaking-findings.md +7 -0
- package/rules/paired-test-coverage.md +4 -2
- package/rules/testing.md +5 -1
- package/scripts/cde_lint.py +47 -1
- package/scripts/policy_lint/adapter_callers.py +124 -0
- package/scripts/policy_lint/adapter_pairing.py +4 -2
- package/scripts/policy_lint/adapter_support.py +4 -3
- package/scripts/policy_lint/adapters.py +15 -0
- package/scripts/policy_lint/check_catalog.py +24 -0
- package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
- package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
- package/scripts/policy_lint/config/constants.py +18 -0
- package/scripts/policy_lint/engine.py +9 -3
- package/scripts/policy_lint/model.py +6 -2
- package/scripts/policy_lint/registry.py +6 -0
- package/scripts/policy_lint/selection.py +2 -0
- package/scripts/tests/test_cde_lint.py +50 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
- package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
- package/scripts/tests/test_policy_lint_selection.py +35 -0
- package/hooks/blocking/tdd_enforcer.py +0 -195
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
- package/hooks/blocking/test_tdd_enforcer.py +0 -1506
- package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
- package/scripts/codex_compat_watcher.py +0 -502
- package/scripts/tests/test_codex_compat_watcher.py +0 -599
package/hooks/hooks.json
CHANGED
|
@@ -51,6 +51,16 @@
|
|
|
51
51
|
"timeout": 25
|
|
52
52
|
}
|
|
53
53
|
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"matcher": "*",
|
|
57
|
+
"hooks": [
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
|
|
61
|
+
"timeout": 120
|
|
62
|
+
}
|
|
63
|
+
]
|
|
54
64
|
}
|
|
55
65
|
],
|
|
56
66
|
"UserPromptSubmit": [
|
|
@@ -108,6 +118,11 @@
|
|
|
108
118
|
"type": "command",
|
|
109
119
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/issue_tracker_session_starter.py",
|
|
110
120
|
"timeout": 10
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"type": "command",
|
|
124
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
|
|
125
|
+
"timeout": 1800
|
|
111
126
|
}
|
|
112
127
|
]
|
|
113
128
|
},
|
|
@@ -6,6 +6,8 @@ __all__ = [
|
|
|
6
6
|
"ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS",
|
|
7
7
|
"LEDGER_ENCODING",
|
|
8
8
|
"LEDGER_APPEND_MODE",
|
|
9
|
+
"LEDGER_IGNORE_FILE_NAME",
|
|
10
|
+
"LEDGER_IGNORE_TEXT",
|
|
9
11
|
"RULE_ID_KEY",
|
|
10
12
|
"FILE_PATH_KEY",
|
|
11
13
|
"MESSAGE_KEY",
|
|
@@ -24,6 +26,8 @@ __all__ = [
|
|
|
24
26
|
ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS: tuple[str, ...] = (".claude", "followups", "smells.jsonl")
|
|
25
27
|
LEDGER_ENCODING: str = "utf-8"
|
|
26
28
|
LEDGER_APPEND_MODE: str = "a"
|
|
29
|
+
LEDGER_IGNORE_FILE_NAME: str = ".gitignore"
|
|
30
|
+
LEDGER_IGNORE_TEXT: str = "*\n"
|
|
27
31
|
|
|
28
32
|
RULE_ID_KEY: str = "rule_id"
|
|
29
33
|
FILE_PATH_KEY: str = "file_path"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Configuration constants for the ``nested_project_hooks`` forwarder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE: str = "CLAUDE_PROJECT_DIR"
|
|
6
|
+
CHECKOUT_MARKER_NAME: str = ".git"
|
|
7
|
+
SETTINGS_DIRECTORY_NAME: str = ".claude"
|
|
8
|
+
SETTINGS_FILE_NAME: str = "settings.json"
|
|
9
|
+
SETTINGS_ENCODING: str = "utf-8"
|
|
10
|
+
|
|
11
|
+
HOOKS_KEY: str = "hooks"
|
|
12
|
+
MATCHER_KEY: str = "matcher"
|
|
13
|
+
HOOK_TYPE_KEY: str = "type"
|
|
14
|
+
COMMAND_HOOK_TYPE: str = "command"
|
|
15
|
+
COMMAND_KEY: str = "command"
|
|
16
|
+
TIMEOUT_KEY: str = "timeout"
|
|
17
|
+
DEFAULT_HOOK_TIMEOUT_SECONDS: float = 600.0
|
|
18
|
+
ALL_MATCH_EVERYTHING_MATCHERS: frozenset[str] = frozenset({"", "*"})
|
|
19
|
+
|
|
20
|
+
HOOK_EVENT_NAME_KEY: str = "hook_event_name"
|
|
21
|
+
PRE_TOOL_USE_EVENT: str = "PreToolUse"
|
|
22
|
+
SESSION_START_EVENT: str = "SessionStart"
|
|
23
|
+
ALL_MATCH_TARGET_KEYS_BY_EVENT: dict[str, str] = {
|
|
24
|
+
PRE_TOOL_USE_EVENT: "tool_name",
|
|
25
|
+
SESSION_START_EVENT: "source",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ALL_SHELL_PROGRAM_NAMES: tuple[str, ...] = ("bash", "sh")
|
|
29
|
+
SHELL_COMMAND_FLAG: str = "-c"
|
|
30
|
+
|
|
31
|
+
BLOCKING_EXIT_CODE: int = 2
|
|
32
|
+
HOOK_SPECIFIC_OUTPUT_KEY: str = "hookSpecificOutput"
|
|
33
|
+
HOOK_EVENT_NAME_OUTPUT_KEY: str = "hookEventName"
|
|
34
|
+
PERMISSION_DECISION_KEY: str = "permissionDecision"
|
|
35
|
+
ALL_FORWARDED_PERMISSION_DECISIONS: frozenset[str] = frozenset({"deny", "ask"})
|
|
36
|
+
ADDITIONAL_CONTEXT_KEY: str = "additionalContext"
|
|
37
|
+
CONTEXT_SECTION_SEPARATOR: str = "\n\n"
|
|
@@ -4,17 +4,11 @@ from hooks_constants import messages
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def test_module_carries_no_hedging_or_intent_ending_notices() -> None:
|
|
7
|
-
"""The hedging
|
|
8
|
-
dedicated notices no longer live here."""
|
|
7
|
+
"""The module carries no hedging or intent-only-ending notice."""
|
|
9
8
|
assert not hasattr(messages, "USER_FACING_NOTICE")
|
|
10
9
|
assert not hasattr(messages, "USER_FACING_INTENT_ENDING_NOTICE")
|
|
11
10
|
|
|
12
11
|
|
|
13
|
-
def test_user_facing_tdd_notice_is_nonempty_string() -> None:
|
|
14
|
-
assert isinstance(messages.USER_FACING_TDD_NOTICE, str)
|
|
15
|
-
assert messages.USER_FACING_TDD_NOTICE
|
|
16
|
-
|
|
17
|
-
|
|
18
12
|
def test_user_facing_askuserquestion_notice_is_nonempty_string() -> None:
|
|
19
13
|
assert isinstance(messages.USER_FACING_ASKUSERQUESTION_NOTICE, str)
|
|
20
14
|
assert messages.USER_FACING_ASKUSERQUESTION_NOTICE
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart and PreToolUse hook: run the hooks of checkouts nested one level down.
|
|
3
|
+
|
|
4
|
+
Claude Code reads project hooks from ``<project>/.claude/settings.json`` of the
|
|
5
|
+
directory a session starts in. A cloud session that holds several repository
|
|
6
|
+
checkouts starts in their parent directory, which has no settings of its own,
|
|
7
|
+
and ``--add-dir`` loads no hooks from the checkouts. Their start hooks and tool
|
|
8
|
+
guards then never run. This hook runs them::
|
|
9
|
+
|
|
10
|
+
session directory (no .git, no .claude/settings.json)
|
|
11
|
+
|-- checkout-a/.claude/settings.json hooks run with CLAUDE_PROJECT_DIR=checkout-a
|
|
12
|
+
`-- checkout-b/.claude/settings.json hooks run with CLAUDE_PROJECT_DIR=checkout-b
|
|
13
|
+
|
|
14
|
+
Each child hook receives the same stdin payload, runs inside its checkout, and
|
|
15
|
+
keeps its own matcher and timeout. A child ``exit 2`` blocks the tool call and a
|
|
16
|
+
child ``deny`` or ``ask`` decision passes through; a child ``allow`` is dropped,
|
|
17
|
+
so no child widens permissions for the session. SessionStart output from every
|
|
18
|
+
child joins into one ``additionalContext``.
|
|
19
|
+
|
|
20
|
+
When the session directory is itself a checkout or holds its own settings,
|
|
21
|
+
Claude Code already runs those hooks, and this hook exits at once.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from collections.abc import Iterable
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
37
|
+
if _hooks_dir not in sys.path:
|
|
38
|
+
sys.path.insert(0, _hooks_dir)
|
|
39
|
+
|
|
40
|
+
from hooks_constants.nested_project_hooks_constants import (
|
|
41
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
42
|
+
ALL_FORWARDED_PERMISSION_DECISIONS,
|
|
43
|
+
ALL_MATCH_EVERYTHING_MATCHERS,
|
|
44
|
+
BLOCKING_EXIT_CODE,
|
|
45
|
+
CHECKOUT_MARKER_NAME,
|
|
46
|
+
COMMAND_HOOK_TYPE,
|
|
47
|
+
COMMAND_KEY,
|
|
48
|
+
CONTEXT_SECTION_SEPARATOR,
|
|
49
|
+
DEFAULT_HOOK_TIMEOUT_SECONDS,
|
|
50
|
+
HOOK_EVENT_NAME_KEY,
|
|
51
|
+
HOOK_EVENT_NAME_OUTPUT_KEY,
|
|
52
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
53
|
+
HOOK_TYPE_KEY,
|
|
54
|
+
HOOKS_KEY,
|
|
55
|
+
ALL_MATCH_TARGET_KEYS_BY_EVENT,
|
|
56
|
+
MATCHER_KEY,
|
|
57
|
+
PERMISSION_DECISION_KEY,
|
|
58
|
+
PRE_TOOL_USE_EVENT,
|
|
59
|
+
PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
60
|
+
SESSION_START_EVENT,
|
|
61
|
+
SETTINGS_DIRECTORY_NAME,
|
|
62
|
+
SETTINGS_ENCODING,
|
|
63
|
+
SETTINGS_FILE_NAME,
|
|
64
|
+
SHELL_COMMAND_FLAG,
|
|
65
|
+
ALL_SHELL_PROGRAM_NAMES,
|
|
66
|
+
TIMEOUT_KEY,
|
|
67
|
+
)
|
|
68
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class NestedHook:
|
|
73
|
+
"""One command hook a nested checkout declares for the current event."""
|
|
74
|
+
|
|
75
|
+
project_directory: Path
|
|
76
|
+
command: str
|
|
77
|
+
timeout_seconds: float
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class HookVerdict:
|
|
82
|
+
"""What this hook hands back to Claude Code."""
|
|
83
|
+
|
|
84
|
+
exit_code: int
|
|
85
|
+
stdout_text: str
|
|
86
|
+
stderr_text: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _settings_path(directory: Path) -> Path:
|
|
90
|
+
return directory / SETTINGS_DIRECTORY_NAME / SETTINGS_FILE_NAME
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_nested_projects(session_directory: Path) -> list[Path]:
|
|
94
|
+
"""Return the child checkouts whose project hooks Claude Code skipped.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
session_directory: The directory the session started in.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Each immediate child holding both a ``.git`` entry and a
|
|
101
|
+
``.claude/settings.json`` file, sorted by name. Empty when the
|
|
102
|
+
session directory is a checkout or has settings of its own.
|
|
103
|
+
"""
|
|
104
|
+
if (session_directory / CHECKOUT_MARKER_NAME).exists():
|
|
105
|
+
return []
|
|
106
|
+
if _settings_path(session_directory).is_file():
|
|
107
|
+
return []
|
|
108
|
+
try:
|
|
109
|
+
all_children = sorted(session_directory.iterdir())
|
|
110
|
+
except OSError:
|
|
111
|
+
return []
|
|
112
|
+
return [
|
|
113
|
+
each_child
|
|
114
|
+
for each_child in all_children
|
|
115
|
+
if each_child.is_dir()
|
|
116
|
+
and (each_child / CHECKOUT_MARKER_NAME).exists()
|
|
117
|
+
and _settings_path(each_child).is_file()
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _read_hook_groups(project_directory: Path, event_name: str) -> list[object]:
|
|
122
|
+
try:
|
|
123
|
+
settings_text = _settings_path(project_directory).read_text(encoding=SETTINGS_ENCODING)
|
|
124
|
+
settings = json.loads(settings_text)
|
|
125
|
+
except (OSError, json.JSONDecodeError):
|
|
126
|
+
return []
|
|
127
|
+
if not isinstance(settings, dict):
|
|
128
|
+
return []
|
|
129
|
+
hooks_by_event = settings.get(HOOKS_KEY)
|
|
130
|
+
if not isinstance(hooks_by_event, dict):
|
|
131
|
+
return []
|
|
132
|
+
all_groups = hooks_by_event.get(event_name)
|
|
133
|
+
if not isinstance(all_groups, list):
|
|
134
|
+
return []
|
|
135
|
+
return all_groups
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def matcher_selects(matcher: object, match_target: str) -> bool:
|
|
139
|
+
"""Report whether a settings matcher selects this event's target.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
matcher: The ``matcher`` value of one hook group, possibly absent.
|
|
143
|
+
match_target: The tool name for PreToolUse, the start source for
|
|
144
|
+
SessionStart.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True for an absent, empty, or ``*`` matcher, and for a matcher whose
|
|
148
|
+
pattern matches the whole target. An invalid pattern matches only
|
|
149
|
+
its exact text.
|
|
150
|
+
"""
|
|
151
|
+
if matcher is None:
|
|
152
|
+
return True
|
|
153
|
+
if not isinstance(matcher, str):
|
|
154
|
+
return False
|
|
155
|
+
if matcher in ALL_MATCH_EVERYTHING_MATCHERS:
|
|
156
|
+
return True
|
|
157
|
+
try:
|
|
158
|
+
return re.fullmatch(matcher, match_target) is not None
|
|
159
|
+
except re.error:
|
|
160
|
+
return matcher == match_target
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _command_hook(project_directory: Path, hook_fields: object) -> NestedHook | None:
|
|
164
|
+
if not isinstance(hook_fields, dict):
|
|
165
|
+
return None
|
|
166
|
+
if hook_fields.get(HOOK_TYPE_KEY) != COMMAND_HOOK_TYPE:
|
|
167
|
+
return None
|
|
168
|
+
command = hook_fields.get(COMMAND_KEY)
|
|
169
|
+
if not isinstance(command, str) or not command.strip():
|
|
170
|
+
return None
|
|
171
|
+
timeout_value = hook_fields.get(TIMEOUT_KEY)
|
|
172
|
+
timeout_seconds = DEFAULT_HOOK_TIMEOUT_SECONDS
|
|
173
|
+
if isinstance(timeout_value, (int, float)) and timeout_value > 0:
|
|
174
|
+
timeout_seconds = float(timeout_value)
|
|
175
|
+
return NestedHook(project_directory, command, timeout_seconds)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _selected_hook_entries(
|
|
179
|
+
project_directory: Path, event_name: str, match_target: str
|
|
180
|
+
) -> list[object]:
|
|
181
|
+
all_hook_entries: list[object] = []
|
|
182
|
+
for each_group in _read_hook_groups(project_directory, event_name):
|
|
183
|
+
if not isinstance(each_group, dict):
|
|
184
|
+
continue
|
|
185
|
+
if not matcher_selects(each_group.get(MATCHER_KEY), match_target):
|
|
186
|
+
continue
|
|
187
|
+
all_group_hooks = each_group.get(HOOKS_KEY)
|
|
188
|
+
if isinstance(all_group_hooks, list):
|
|
189
|
+
all_hook_entries.extend(all_group_hooks)
|
|
190
|
+
return all_hook_entries
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def collect_nested_hooks(
|
|
194
|
+
session_directory: Path, event_name: str, match_target: str
|
|
195
|
+
) -> list[NestedHook]:
|
|
196
|
+
"""Return every nested command hook that this event selects, in order.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
session_directory: The directory the session started in.
|
|
200
|
+
event_name: The hook event being handled.
|
|
201
|
+
match_target: The value the event's matchers compare against.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The selected command hooks, checkout by checkout in name order and
|
|
205
|
+
in declaration order inside each checkout.
|
|
206
|
+
"""
|
|
207
|
+
all_candidates = (
|
|
208
|
+
_command_hook(each_project, each_entry)
|
|
209
|
+
for each_project in find_nested_projects(session_directory)
|
|
210
|
+
for each_entry in _selected_hook_entries(each_project, event_name, match_target)
|
|
211
|
+
)
|
|
212
|
+
return [each_candidate for each_candidate in all_candidates if each_candidate is not None]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _shell_program() -> str | None:
|
|
216
|
+
for each_name in ALL_SHELL_PROGRAM_NAMES:
|
|
217
|
+
found_path = shutil.which(each_name)
|
|
218
|
+
if found_path:
|
|
219
|
+
return found_path
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _hook_environment(project_directory: Path) -> dict[str, str]:
|
|
224
|
+
return {**os.environ, PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE: str(project_directory)}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def run_nested_hook(
|
|
228
|
+
nested_hook: NestedHook, payload_text: str
|
|
229
|
+
) -> subprocess.CompletedProcess[str] | None:
|
|
230
|
+
"""Run one nested hook the way Claude Code would run it in its own checkout.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
nested_hook: The hook to run.
|
|
234
|
+
payload_text: The stdin payload Claude Code handed this hook.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The completed process, or None when no shell exists, the command
|
|
238
|
+
cannot start, or it outlives its timeout.
|
|
239
|
+
"""
|
|
240
|
+
shell_program = _shell_program()
|
|
241
|
+
if shell_program is None:
|
|
242
|
+
return None
|
|
243
|
+
try:
|
|
244
|
+
return subprocess.run(
|
|
245
|
+
[shell_program, SHELL_COMMAND_FLAG, nested_hook.command],
|
|
246
|
+
input=payload_text,
|
|
247
|
+
capture_output=True,
|
|
248
|
+
text=True,
|
|
249
|
+
cwd=nested_hook.project_directory,
|
|
250
|
+
env=_hook_environment(nested_hook.project_directory),
|
|
251
|
+
timeout=nested_hook.timeout_seconds,
|
|
252
|
+
check=False,
|
|
253
|
+
creationflags=hidden_window_creation_flags(),
|
|
254
|
+
)
|
|
255
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _parsed_hook_output(stdout_text: str) -> dict[str, object] | None:
|
|
260
|
+
try:
|
|
261
|
+
parsed_output = json.loads(stdout_text)
|
|
262
|
+
except json.JSONDecodeError:
|
|
263
|
+
return None
|
|
264
|
+
if not isinstance(parsed_output, dict):
|
|
265
|
+
return None
|
|
266
|
+
return parsed_output
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _permission_decision(all_output_fields: dict[str, object]) -> object:
|
|
270
|
+
specific_output = all_output_fields.get(HOOK_SPECIFIC_OUTPUT_KEY)
|
|
271
|
+
if not isinstance(specific_output, dict):
|
|
272
|
+
return None
|
|
273
|
+
return specific_output.get(PERMISSION_DECISION_KEY)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def pre_tool_use_verdict(
|
|
277
|
+
all_results: list[subprocess.CompletedProcess[str]],
|
|
278
|
+
) -> HookVerdict:
|
|
279
|
+
"""Fold nested PreToolUse results into the one verdict Claude Code reads.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
all_results: The completed nested hooks, in run order.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
A blocking exit carrying the child's stderr when any child exits 2,
|
|
286
|
+
else the first child ``deny`` or ``ask`` output, else an empty pass.
|
|
287
|
+
"""
|
|
288
|
+
for each_result in all_results:
|
|
289
|
+
if each_result.returncode == BLOCKING_EXIT_CODE:
|
|
290
|
+
return HookVerdict(BLOCKING_EXIT_CODE, "", each_result.stderr)
|
|
291
|
+
for each_result in all_results:
|
|
292
|
+
if each_result.returncode != 0:
|
|
293
|
+
continue
|
|
294
|
+
parsed_output = _parsed_hook_output(each_result.stdout)
|
|
295
|
+
if parsed_output is None:
|
|
296
|
+
continue
|
|
297
|
+
if _permission_decision(parsed_output) in ALL_FORWARDED_PERMISSION_DECISIONS:
|
|
298
|
+
return HookVerdict(0, json.dumps(parsed_output), "")
|
|
299
|
+
return HookVerdict(0, "", "")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _session_start_context(stdout_text: str) -> str:
|
|
303
|
+
parsed_output = _parsed_hook_output(stdout_text)
|
|
304
|
+
if parsed_output is None:
|
|
305
|
+
return stdout_text.strip()
|
|
306
|
+
specific_output = parsed_output.get(HOOK_SPECIFIC_OUTPUT_KEY)
|
|
307
|
+
if not isinstance(specific_output, dict):
|
|
308
|
+
return ""
|
|
309
|
+
context_text = specific_output.get(ADDITIONAL_CONTEXT_KEY)
|
|
310
|
+
return context_text.strip() if isinstance(context_text, str) else ""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _non_empty_sections(all_sections: Iterable[str]) -> list[str]:
|
|
314
|
+
return [each_section for each_section in all_sections if each_section]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def session_start_verdict(
|
|
318
|
+
all_results: list[subprocess.CompletedProcess[str]],
|
|
319
|
+
) -> HookVerdict:
|
|
320
|
+
"""Join nested SessionStart output into one additional-context block.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
all_results: The completed nested hooks, in run order.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
A pass whose stdout carries every child's context, or an empty pass
|
|
327
|
+
when no child printed any.
|
|
328
|
+
"""
|
|
329
|
+
all_context_sections = _non_empty_sections(
|
|
330
|
+
_session_start_context(each_result.stdout) for each_result in all_results
|
|
331
|
+
)
|
|
332
|
+
stderr_text = CONTEXT_SECTION_SEPARATOR.join(
|
|
333
|
+
_non_empty_sections(each_result.stderr.strip() for each_result in all_results)
|
|
334
|
+
)
|
|
335
|
+
if not all_context_sections:
|
|
336
|
+
return HookVerdict(0, "", stderr_text)
|
|
337
|
+
hook_output = {
|
|
338
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
339
|
+
HOOK_EVENT_NAME_OUTPUT_KEY: SESSION_START_EVENT,
|
|
340
|
+
ADDITIONAL_CONTEXT_KEY: CONTEXT_SECTION_SEPARATOR.join(all_context_sections),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return HookVerdict(0, json.dumps(hook_output), stderr_text)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _completed_results(
|
|
347
|
+
all_nested_hooks: list[NestedHook], payload_text: str
|
|
348
|
+
) -> list[subprocess.CompletedProcess[str]]:
|
|
349
|
+
all_outcomes = (run_nested_hook(each_hook, payload_text) for each_hook in all_nested_hooks)
|
|
350
|
+
return [each_outcome for each_outcome in all_outcomes if each_outcome is not None]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def forward_nested_hooks(session_directory: Path, payload_text: str) -> HookVerdict:
|
|
354
|
+
"""Run the nested checkouts' hooks for one payload and fold their results.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
session_directory: The directory the session started in.
|
|
358
|
+
payload_text: The stdin payload Claude Code handed this hook.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
The verdict for Claude Code. An empty pass for an unreadable payload
|
|
362
|
+
or an event this hook does not forward.
|
|
363
|
+
"""
|
|
364
|
+
payload = _parsed_hook_output(payload_text)
|
|
365
|
+
if payload is None:
|
|
366
|
+
return HookVerdict(0, "", "")
|
|
367
|
+
event_name = payload.get(HOOK_EVENT_NAME_KEY)
|
|
368
|
+
if not isinstance(event_name, str) or event_name not in ALL_MATCH_TARGET_KEYS_BY_EVENT:
|
|
369
|
+
return HookVerdict(0, "", "")
|
|
370
|
+
match_target = payload.get(ALL_MATCH_TARGET_KEYS_BY_EVENT[event_name])
|
|
371
|
+
all_nested_hooks = collect_nested_hooks(
|
|
372
|
+
session_directory, event_name, match_target if isinstance(match_target, str) else ""
|
|
373
|
+
)
|
|
374
|
+
all_results = _completed_results(all_nested_hooks, payload_text)
|
|
375
|
+
if event_name == PRE_TOOL_USE_EVENT:
|
|
376
|
+
return pre_tool_use_verdict(all_results)
|
|
377
|
+
return session_start_verdict(all_results)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def main() -> None:
|
|
381
|
+
"""Entry point: read the payload, forward it, and exit with the verdict."""
|
|
382
|
+
session_directory_text = os.environ.get(PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
383
|
+
if not session_directory_text:
|
|
384
|
+
sys.exit(0)
|
|
385
|
+
payload_text = sys.stdin.read()
|
|
386
|
+
verdict = forward_nested_hooks(Path(session_directory_text), payload_text)
|
|
387
|
+
if verdict.stdout_text:
|
|
388
|
+
sys.stdout.write(verdict.stdout_text)
|
|
389
|
+
if verdict.stderr_text:
|
|
390
|
+
sys.stderr.write(verdict.stderr_text)
|
|
391
|
+
sys.exit(verdict.exit_code)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == "__main__":
|
|
395
|
+
main()
|