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.
Files changed (77) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
  2. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
  3. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
  6. package/bin/codex-skill-load-block.mjs +45 -0
  7. package/bin/codex-skill-load-block.test.mjs +72 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +53 -31
  10. package/bin/install.test.mjs +37 -0
  11. package/bin/install.transaction.test.mjs +29 -7
  12. package/bin/seed-codex-pstack-models.mjs +27 -0
  13. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  14. package/docs/CODE_RULES.md +5 -1
  15. package/hooks/blocking/code_rules_enforcer.py +1 -3
  16. package/hooks/blocking/code_rules_magic_values.py +1 -3
  17. package/hooks/blocking/code_rules_paired_test.py +5 -6
  18. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  19. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  20. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  21. package/hooks/followup_ledger.py +8 -1
  22. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  23. package/hooks/hooks.json +15 -0
  24. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  25. package/hooks/hooks_constants/messages.py +0 -1
  26. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  27. package/hooks/hooks_constants/test_messages.py +1 -7
  28. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  29. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  30. package/hooks/test_followup_ledger.py +18 -0
  31. package/package.json +2 -1
  32. package/pstack-codex-models.md +37 -0
  33. package/rules/bdd.md +1 -1
  34. package/rules/build-needs-a-user.md +53 -0
  35. package/rules/code-standards.md +1 -1
  36. package/rules/flag-non-breaking-findings.md +7 -0
  37. package/rules/paired-test-coverage.md +4 -2
  38. package/rules/testing.md +5 -1
  39. package/scripts/cde_lint.py +47 -1
  40. package/scripts/policy_lint/adapter_callers.py +124 -0
  41. package/scripts/policy_lint/adapter_pairing.py +4 -2
  42. package/scripts/policy_lint/adapter_support.py +4 -3
  43. package/scripts/policy_lint/adapters.py +15 -0
  44. package/scripts/policy_lint/check_catalog.py +24 -0
  45. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  46. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  47. package/scripts/policy_lint/config/constants.py +18 -0
  48. package/scripts/policy_lint/engine.py +9 -3
  49. package/scripts/policy_lint/model.py +6 -2
  50. package/scripts/policy_lint/registry.py +6 -0
  51. package/scripts/policy_lint/selection.py +2 -0
  52. package/scripts/tests/test_cde_lint.py +50 -0
  53. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  54. package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
  55. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  56. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  57. package/scripts/tests/test_policy_lint_selection.py +35 -0
  58. package/hooks/blocking/tdd_enforcer.py +0 -195
  59. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  60. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  61. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  62. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  63. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  64. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  65. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  66. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  67. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  68. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  69. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  70. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  73. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  74. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  75. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  76. package/scripts/codex_compat_watcher.py +0 -502
  77. 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"
@@ -1,6 +1,5 @@
1
1
  """User-facing notice messages for blocking hooks."""
2
2
 
3
- USER_FACING_TDD_NOTICE = "TDD gate held - writing the failing test first..."
4
3
  USER_FACING_ASKUSERQUESTION_NOTICE = (
5
4
  "Agent asked the user in prose - rerouting through AskUserQuestion..."
6
5
  )
@@ -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 and intent-only-ending Stop hooks are removed; their
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()