claude-dev-env 8.10.0 → 8.11.1

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 (81) 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 +233 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
  6. package/bin/codex-skill-load-block.mjs +93 -0
  7. package/bin/codex-skill-load-block.test.mjs +134 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +55 -31
  10. package/bin/install.test.mjs +57 -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/pii_prevention_blocker_parts/repository_exemption.py +3 -3
  19. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  20. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  21. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  22. package/hooks/blocking/tests/test_repository_exemption.py +7 -0
  23. package/hooks/followup_ledger.py +8 -1
  24. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  25. package/hooks/hooks.json +15 -0
  26. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  27. package/hooks/hooks_constants/messages.py +0 -1
  28. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  29. package/hooks/hooks_constants/test_messages.py +1 -7
  30. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  31. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  32. package/hooks/test_followup_ledger.py +18 -0
  33. package/package.json +2 -1
  34. package/pstack-codex-models.md +37 -0
  35. package/rules/bdd.md +1 -1
  36. package/rules/build-needs-a-user.md +53 -0
  37. package/rules/code-standards.md +1 -1
  38. package/rules/flag-non-breaking-findings.md +7 -0
  39. package/rules/paired-test-coverage.md +4 -2
  40. package/rules/testing.md +5 -1
  41. package/scripts/cde_lint.py +47 -1
  42. package/scripts/policy_lint/adapter_callers.py +124 -0
  43. package/scripts/policy_lint/adapter_pairing.py +4 -2
  44. package/scripts/policy_lint/adapter_support.py +4 -3
  45. package/scripts/policy_lint/adapters.py +15 -0
  46. package/scripts/policy_lint/check_catalog.py +24 -0
  47. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  48. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  49. package/scripts/policy_lint/config/constants.py +18 -0
  50. package/scripts/policy_lint/engine.py +9 -3
  51. package/scripts/policy_lint/model.py +6 -2
  52. package/scripts/policy_lint/registry.py +6 -0
  53. package/scripts/policy_lint/selection.py +2 -0
  54. package/scripts/repository_checks/tracked_private_terms.py +15 -1
  55. package/scripts/tests/test_cde_lint.py +50 -0
  56. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  57. package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
  58. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  59. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  60. package/scripts/tests/test_policy_lint_selection.py +35 -0
  61. package/scripts/tests/test_tracked_private_terms.py +35 -0
  62. package/hooks/blocking/tdd_enforcer.py +0 -195
  63. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  64. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  65. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  66. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  67. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  68. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  69. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  70. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  73. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  74. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  75. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  76. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  77. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  78. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  79. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  80. package/scripts/codex_compat_watcher.py +0 -502
  81. package/scripts/tests/test_codex_compat_watcher.py +0 -599
@@ -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()
@@ -0,0 +1,251 @@
1
+ """Behavior tests for the nested project hooks forwarder.
2
+
3
+ Each case builds a session directory that holds child checkouts, runs the hook
4
+ script the way Claude Code does (payload on stdin, ``CLAUDE_PROJECT_DIR`` set
5
+ to the session directory), and reads the exit code and output it produces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import pathlib
13
+ import subprocess
14
+ import sys
15
+
16
+ import pytest
17
+
18
+ HOOK_SCRIPT_PATH = pathlib.Path(__file__).parent / "nested_project_hooks.py"
19
+ BLOCKING_EXIT_CODE = 2
20
+
21
+
22
+ def _make_checkout(
23
+ session_directory: pathlib.Path,
24
+ checkout_name: str,
25
+ all_hook_groups_by_event: dict[str, list[dict[str, object]]],
26
+ ) -> pathlib.Path:
27
+ checkout_directory = session_directory / checkout_name
28
+ (checkout_directory / ".git").mkdir(parents=True)
29
+ settings_directory = checkout_directory / ".claude"
30
+ settings_directory.mkdir()
31
+ (settings_directory / "settings.json").write_text(
32
+ json.dumps({"hooks": all_hook_groups_by_event}), encoding="utf-8"
33
+ )
34
+ return checkout_directory
35
+
36
+
37
+ def _command_group(matcher: str, command: str) -> dict[str, object]:
38
+ return {"matcher": matcher, "hooks": [{"type": "command", "command": command}]}
39
+
40
+
41
+ def _run_hook(
42
+ session_directory: pathlib.Path, payload: dict[str, object]
43
+ ) -> subprocess.CompletedProcess[str]:
44
+ hook_environment = dict(os.environ)
45
+ hook_environment["CLAUDE_PROJECT_DIR"] = str(session_directory)
46
+ return subprocess.run(
47
+ [sys.executable, str(HOOK_SCRIPT_PATH)],
48
+ input=json.dumps(payload),
49
+ capture_output=True,
50
+ text=True,
51
+ env=hook_environment,
52
+ check=False,
53
+ timeout=60,
54
+ )
55
+
56
+
57
+ def _pre_tool_use_payload(tool_name: str) -> dict[str, object]:
58
+ return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": {}}
59
+
60
+
61
+ def _session_start_payload() -> dict[str, object]:
62
+ return {"hook_event_name": "SessionStart", "source": "startup"}
63
+
64
+
65
+ def test_should_run_a_child_session_start_hook_inside_that_child(
66
+ tmp_path: pathlib.Path,
67
+ ) -> None:
68
+ record_path = tmp_path / "record.txt"
69
+ checkout_directory = _make_checkout(
70
+ tmp_path / "session",
71
+ "alpha",
72
+ {
73
+ "SessionStart": [
74
+ _command_group(
75
+ "",
76
+ f'echo "$CLAUDE_PROJECT_DIR|$(pwd)" > "{record_path}"',
77
+ )
78
+ ]
79
+ },
80
+ )
81
+
82
+ completed_process = _run_hook(tmp_path / "session", _session_start_payload())
83
+
84
+ assert completed_process.returncode == 0
85
+ resolved_checkout = str(checkout_directory.resolve())
86
+ project_directory, working_directory = (
87
+ record_path.read_text(encoding="utf-8").strip().split("|")
88
+ )
89
+ assert str(pathlib.Path(project_directory).resolve()) == resolved_checkout
90
+ assert str(pathlib.Path(working_directory).resolve()) == resolved_checkout
91
+
92
+
93
+ def test_should_carry_child_session_start_output_as_additional_context(
94
+ tmp_path: pathlib.Path,
95
+ ) -> None:
96
+ _make_checkout(
97
+ tmp_path,
98
+ "alpha",
99
+ {"SessionStart": [_command_group("", "echo alpha is ready")]},
100
+ )
101
+ _make_checkout(
102
+ tmp_path,
103
+ "beta",
104
+ {"SessionStart": [_command_group("", "echo beta is ready")]},
105
+ )
106
+
107
+ completed_process = _run_hook(tmp_path, _session_start_payload())
108
+
109
+ assert completed_process.returncode == 0
110
+ hook_output = json.loads(completed_process.stdout)
111
+ additional_context = hook_output["hookSpecificOutput"]["additionalContext"]
112
+ assert hook_output["hookSpecificOutput"]["hookEventName"] == "SessionStart"
113
+ assert "alpha is ready" in additional_context
114
+ assert "beta is ready" in additional_context
115
+ assert additional_context.index("alpha") < additional_context.index("beta")
116
+
117
+
118
+ def test_should_block_a_tool_call_when_a_child_hook_exits_two(
119
+ tmp_path: pathlib.Path,
120
+ ) -> None:
121
+ _make_checkout(
122
+ tmp_path,
123
+ "alpha",
124
+ {"PreToolUse": [_command_group("Bash", "echo the gate refused this >&2; exit 2")]},
125
+ )
126
+
127
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
128
+
129
+ assert completed_process.returncode == BLOCKING_EXIT_CODE
130
+ assert "the gate refused this" in completed_process.stderr
131
+
132
+
133
+ def test_should_pass_a_child_deny_decision_through(tmp_path: pathlib.Path) -> None:
134
+ deny_output = {
135
+ "hookSpecificOutput": {
136
+ "hookEventName": "PreToolUse",
137
+ "permissionDecision": "deny",
138
+ "permissionDecisionReason": "no receipt for this commit",
139
+ }
140
+ }
141
+ _make_checkout(
142
+ tmp_path,
143
+ "alpha",
144
+ {
145
+ "PreToolUse": [
146
+ _command_group(
147
+ "Bash|mcp__github__merge_pull_request",
148
+ f"echo '{json.dumps(deny_output)}'",
149
+ )
150
+ ]
151
+ },
152
+ )
153
+
154
+ completed_process = _run_hook(
155
+ tmp_path, _pre_tool_use_payload("mcp__github__merge_pull_request")
156
+ )
157
+
158
+ assert completed_process.returncode == 0
159
+ assert json.loads(completed_process.stdout) == deny_output
160
+
161
+
162
+ def test_should_not_forward_a_child_allow_decision(tmp_path: pathlib.Path) -> None:
163
+ allow_output = {
164
+ "hookSpecificOutput": {
165
+ "hookEventName": "PreToolUse",
166
+ "permissionDecision": "allow",
167
+ }
168
+ }
169
+ _make_checkout(
170
+ tmp_path,
171
+ "alpha",
172
+ {"PreToolUse": [_command_group("", f"echo '{json.dumps(allow_output)}'")]},
173
+ )
174
+
175
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
176
+
177
+ assert completed_process.returncode == 0
178
+ assert completed_process.stdout.strip() == ""
179
+
180
+
181
+ def test_should_skip_a_child_hook_whose_matcher_names_another_tool(
182
+ tmp_path: pathlib.Path,
183
+ ) -> None:
184
+ _make_checkout(
185
+ tmp_path,
186
+ "alpha",
187
+ {"PreToolUse": [_command_group("Write|Edit", "exit 2")]},
188
+ )
189
+
190
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
191
+
192
+ assert completed_process.returncode == 0
193
+
194
+
195
+ def test_should_do_nothing_when_the_session_directory_is_a_checkout(
196
+ tmp_path: pathlib.Path,
197
+ ) -> None:
198
+ (tmp_path / ".git").mkdir()
199
+ _make_checkout(
200
+ tmp_path,
201
+ "alpha",
202
+ {"PreToolUse": [_command_group("", "exit 2")]},
203
+ )
204
+
205
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
206
+
207
+ assert completed_process.returncode == 0
208
+ assert completed_process.stdout.strip() == ""
209
+
210
+
211
+ def test_should_skip_a_child_directory_that_is_not_a_checkout(
212
+ tmp_path: pathlib.Path,
213
+ ) -> None:
214
+ checkout_directory = _make_checkout(
215
+ tmp_path,
216
+ "alpha",
217
+ {"PreToolUse": [_command_group("", "exit 2")]},
218
+ )
219
+ (checkout_directory / ".git").rmdir()
220
+
221
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
222
+
223
+ assert completed_process.returncode == 0
224
+
225
+
226
+ def test_should_let_the_call_through_when_a_child_hook_fails_to_start(
227
+ tmp_path: pathlib.Path,
228
+ ) -> None:
229
+ _make_checkout(
230
+ tmp_path,
231
+ "alpha",
232
+ {"PreToolUse": [_command_group("", "a-command-that-does-not-exist")]},
233
+ )
234
+
235
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
236
+
237
+ assert completed_process.returncode == 0
238
+
239
+
240
+ @pytest.mark.parametrize("unreadable_settings", ["{not json", "[]", '{"hooks": []}'])
241
+ def test_should_skip_a_child_whose_settings_cannot_be_read(
242
+ tmp_path: pathlib.Path, unreadable_settings: str
243
+ ) -> None:
244
+ checkout_directory = _make_checkout(tmp_path, "alpha", {})
245
+ (checkout_directory / ".claude" / "settings.json").write_text(
246
+ unreadable_settings, encoding="utf-8"
247
+ )
248
+
249
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
250
+
251
+ assert completed_process.returncode == 0
@@ -1,6 +1,7 @@
1
1
  """Behavior tests for the non-breaking-finding follow-up ledger."""
2
2
 
3
3
  import json
4
+ import subprocess
4
5
  import sys
5
6
  from pathlib import Path
6
7
 
@@ -82,6 +83,23 @@ def test_record_followup_finding_writes_one_json_object_per_line(tmp_path: Path)
82
83
  assert json.loads(all_lines[0])["rule_id"] == "instruction-mode"
83
84
 
84
85
 
86
+ def test_a_recorded_ledger_stays_out_of_git_status(tmp_path: Path) -> None:
87
+ subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
88
+
89
+ record_followup_finding(
90
+ tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
91
+ )
92
+
93
+ status_result = subprocess.run(
94
+ ["git", "-C", str(tmp_path), "status", "--porcelain", "--untracked-files=all"],
95
+ check=True,
96
+ capture_output=True,
97
+ text=True,
98
+ )
99
+ assert followup_ledger_path(tmp_path).is_file()
100
+ assert status_result.stdout == ""
101
+
102
+
85
103
  def test_record_followup_finding_swallows_a_write_failure(tmp_path: Path) -> None:
86
104
  blocking_file = tmp_path / ".claude"
87
105
  blocking_file.write_text("not a directory", encoding="utf-8")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.10.0",
3
+ "version": "8.11.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "_shared/",
28
28
  "audit-rubrics/",
29
29
  "codex-rules/",
30
+ "pstack-codex-models.md",
30
31
  "AGENTS.md",
31
32
  ".claude/CLAUDE.md",
32
33
  "!**/__pycache__/**",