claude-dev-env 2.19.0 → 2.21.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 (53) hide show
  1. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
  2. package/.agents/skills/e-code-review/SKILL.md +12 -1
  3. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  4. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  5. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  6. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  7. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  8. package/.agents/skills/pr-cleanup/SKILL.md +109 -11
  9. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  11. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  13. package/docs/CODE_RULES.md +2 -0
  14. package/hooks/advisory/conftest.py +10 -0
  15. package/hooks/advisory/refactor_guard.py +250 -144
  16. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  17. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  18. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  19. package/hooks/blocking/block_main_commit.py +66 -33
  20. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  21. package/hooks/blocking/code_rules_enforcer.py +95 -0
  22. package/hooks/blocking/codex_apply_patch.py +238 -0
  23. package/hooks/blocking/test_block_main_commit.py +145 -0
  24. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  25. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +148 -0
  26. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  28. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
  29. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  30. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
  31. package/hooks/git-hooks/AGENTS.md +1 -1
  32. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  33. package/hooks/git-hooks/post_commit.py +160 -51
  34. package/hooks/git-hooks/pre_commit.py +3 -3
  35. package/hooks/git-hooks/test_post_commit.py +203 -0
  36. package/hooks/git-hooks/test_pre_commit.py +2 -2
  37. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  38. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
  39. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  40. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  41. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  42. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  43. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  44. package/hooks/validation/mypy_validator.py +213 -80
  45. package/hooks/validation/test_mypy_validator.py +288 -13
  46. package/hooks/workflow/auto_formatter.py +225 -93
  47. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  48. package/hooks/workflow/test_auto_formatter.py +261 -12
  49. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  50. package/package.json +1 -1
  51. package/rules/failure-blast-radius.md +126 -0
  52. package/scripts/codex_compat_materializer.py +395 -17
  53. package/scripts/tests/test_codex_compat_materializer.py +17 -3
@@ -21,18 +21,34 @@ import sys
21
21
  import tempfile
22
22
  from collections.abc import Generator
23
23
  from pathlib import Path
24
+ from types import ModuleType
24
25
 
25
26
  import pytest
26
27
 
27
28
  HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "auto_formatter.py")
28
- HOOKS_JSON_PATH = os.path.join(
29
- os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "hooks", "hooks.json"
29
+ HOOKS_DIRECTORY_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
30
+ POST_TOOL_USE_DISPATCHER_PATH = os.path.join(
31
+ HOOKS_DIRECTORY_PATH, "validation", "post_tool_use_dispatcher.py"
30
32
  )
33
+ PLUGIN_ROOT_PATH = os.path.dirname(HOOKS_DIRECTORY_PATH)
34
+ HOOKS_JSON_PATH = os.path.join(HOOKS_DIRECTORY_PATH, "hooks.json")
31
35
  POST_TOOL_USE_DISPATCHER_COMMAND_FRAGMENT = "validation/post_tool_use_dispatcher.py"
32
36
  UNUSED_IMPORT_SOURCE = "import os\n\n\nVALUE = 1\n"
33
37
  HOOK_RUN_TIMEOUT_SECONDS = 60
34
38
 
35
39
 
40
+ def build_fixture_git_environment() -> dict[str, str]:
41
+ return {
42
+ each_name: each_value
43
+ for each_name, each_value in os.environ.items()
44
+ if not each_name.upper().startswith("GIT_")
45
+ }
46
+
47
+
48
+ def build_dispatcher_payload(tool_name: str, file_path: Path) -> str:
49
+ return json.dumps({"tool_name": tool_name, "tool_input": {"file_path": str(file_path)}})
50
+
51
+
36
52
  def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
37
53
  try:
38
54
  os.chmod(target_path, stat.S_IWRITE)
@@ -42,13 +58,8 @@ def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
42
58
 
43
59
 
44
60
  def _force_rmtree(target_path: str) -> None:
45
- handler_kw = (
46
- {"onexc": _strip_read_only_and_retry}
47
- if sys.version_info >= (3, 12)
48
- else {"onerror": _strip_read_only_and_retry}
49
- )
50
61
  with contextlib.suppress(OSError):
51
- shutil.rmtree(target_path, **handler_kw)
62
+ shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
52
63
 
53
64
 
54
65
  @functools.lru_cache(maxsize=1)
@@ -67,20 +78,40 @@ def _cleanup_sandbox_parent_directory() -> Generator[None]:
67
78
  @pytest.fixture
68
79
  def git_repository() -> Generator[Path]:
69
80
  repository_path = Path(tempfile.mkdtemp(dir=_get_sandbox_parent_directory()))
70
- subprocess.run(["git", "init"], cwd=repository_path, capture_output=True, check=True)
81
+ subprocess.run(
82
+ ["git", "init"],
83
+ cwd=repository_path,
84
+ capture_output=True,
85
+ check=True,
86
+ env=build_fixture_git_environment(),
87
+ )
71
88
  yield repository_path
72
89
  _force_rmtree(str(repository_path))
73
90
 
74
91
 
75
92
  def _run_hook(tool_name: str, file_path: Path) -> subprocess.CompletedProcess[str]:
76
- hook_input = json.dumps({"tool_name": tool_name, "tool_input": {"file_path": str(file_path)}})
77
93
  return subprocess.run(
78
94
  [sys.executable, HOOK_SCRIPT_PATH],
79
- input=hook_input,
95
+ input=build_dispatcher_payload(tool_name, file_path),
80
96
  capture_output=True,
81
97
  text=True,
82
98
  timeout=HOOK_RUN_TIMEOUT_SECONDS,
83
99
  check=False,
100
+ env=build_fixture_git_environment(),
101
+ )
102
+
103
+
104
+ def _run_post_tool_use_dispatcher(
105
+ tool_name: str, file_path: Path, environment: dict[str, str]
106
+ ) -> subprocess.CompletedProcess[str]:
107
+ return subprocess.run(
108
+ [sys.executable, POST_TOOL_USE_DISPATCHER_PATH, PLUGIN_ROOT_PATH],
109
+ input=build_dispatcher_payload(tool_name, file_path),
110
+ capture_output=True,
111
+ text=True,
112
+ check=False,
113
+ timeout=HOOK_RUN_TIMEOUT_SECONDS,
114
+ env=environment,
84
115
  )
85
116
 
86
117
 
@@ -115,6 +146,7 @@ class TestRuffFixOnNewFiles:
115
146
  cwd=git_repository,
116
147
  capture_output=True,
117
148
  check=True,
149
+ env=build_fixture_git_environment(),
118
150
  )
119
151
 
120
152
  completed_hook = _run_hook("Write", tracked_file)
@@ -131,6 +163,7 @@ def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> No
131
163
  cwd=git_repository,
132
164
  capture_output=True,
133
165
  check=True,
166
+ env=build_fixture_git_environment(),
134
167
  )
135
168
 
136
169
  completed_hook = _run_hook("Write", tracked_file)
@@ -139,7 +172,7 @@ def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> No
139
172
  assert "import os" in tracked_file.read_text(encoding="utf-8")
140
173
 
141
174
 
142
- def _load_auto_formatter_module() -> object:
175
+ def _load_auto_formatter_module() -> ModuleType:
143
176
  module_spec = importlib.util.spec_from_file_location("auto_formatter", HOOK_SCRIPT_PATH)
144
177
  assert module_spec is not None and module_spec.loader is not None
145
178
  auto_formatter_module = importlib.util.module_from_spec(module_spec)
@@ -147,6 +180,222 @@ def _load_auto_formatter_module() -> object:
147
180
  return auto_formatter_module
148
181
 
149
182
 
183
+ def test_formatter_eligibility_requires_write_tool_and_untracked_source(
184
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
185
+ ) -> None:
186
+ auto_formatter_module = _load_auto_formatter_module()
187
+ source_file = tmp_path / "new_module.py"
188
+ monkeypatch.setattr(auto_formatter_module, "is_untracked_in_git", lambda _: True)
189
+
190
+ assert auto_formatter_module.is_formatter_eligible("Write", str(source_file))
191
+ assert not auto_formatter_module.is_formatter_eligible("Bash", str(source_file))
192
+
193
+
194
+ def test_formatter_eligibility_protects_hook_tree(
195
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
196
+ ) -> None:
197
+ auto_formatter_module = _load_auto_formatter_module()
198
+ hooks_directory = tmp_path / "hooks"
199
+ protected_file = hooks_directory / "generated.py"
200
+ monkeypatch.setattr(
201
+ auto_formatter_module,
202
+ "HOOKS_DIR",
203
+ f"{hooks_directory}{os.sep}",
204
+ )
205
+
206
+ assert auto_formatter_module.is_protected_path(str(protected_file))
207
+
208
+
209
+ def test_formatter_eligibility_protects_symlinked_hook_tree(
210
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
211
+ ) -> None:
212
+ auto_formatter_module = _load_auto_formatter_module()
213
+ hooks_directory = tmp_path / "hooks"
214
+ hooks_directory.mkdir()
215
+ outside_file = tmp_path / "outside.py"
216
+ outside_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
217
+ symlinked_file = hooks_directory / "linked.py"
218
+ try:
219
+ symlinked_file.symlink_to(outside_file)
220
+ except (OSError, NotImplementedError):
221
+ return
222
+ monkeypatch.setattr(
223
+ auto_formatter_module,
224
+ "HOOKS_DIR",
225
+ f"{hooks_directory}{os.sep}",
226
+ )
227
+ monkeypatch.setattr(auto_formatter_module, "is_untracked_in_git", lambda _: True)
228
+
229
+ assert auto_formatter_module.is_formatter_eligible("Write", str(symlinked_file)) is False
230
+
231
+
232
+ def test_formatter_eligibility_protects_symlink_target_inside_hook_tree(
233
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
234
+ ) -> None:
235
+ auto_formatter_module = _load_auto_formatter_module()
236
+ hooks_directory = tmp_path / "hooks"
237
+ hooks_directory.mkdir()
238
+ protected_file = hooks_directory / "protected.py"
239
+ protected_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
240
+ symlinked_file = tmp_path / "linked.py"
241
+ try:
242
+ symlinked_file.symlink_to(protected_file)
243
+ except (OSError, NotImplementedError):
244
+ return
245
+ monkeypatch.setattr(
246
+ auto_formatter_module,
247
+ "HOOKS_DIR",
248
+ f"{hooks_directory}{os.sep}",
249
+ )
250
+ monkeypatch.setattr(auto_formatter_module, "is_untracked_in_git", lambda _: True)
251
+
252
+ assert auto_formatter_module.is_formatter_eligible("Write", str(symlinked_file)) is False
253
+
254
+
255
+ def test_formatter_diagnostic_stays_on_stderr(
256
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
257
+ ) -> None:
258
+ auto_formatter_module = _load_auto_formatter_module()
259
+
260
+ def return_formatter_failure(
261
+ command: list[str], **_options: object
262
+ ) -> subprocess.CompletedProcess[str]:
263
+ return subprocess.CompletedProcess(
264
+ command,
265
+ 3,
266
+ "formatter stdout",
267
+ "formatter stderr",
268
+ )
269
+
270
+ monkeypatch.setattr(subprocess, "run", return_formatter_failure)
271
+ auto_formatter_module.run_eligible_formatter("broken.py")
272
+
273
+ captured_output = capsys.readouterr()
274
+ assert "formatter stdout" in captured_output.err
275
+ assert "formatter stderr" in captured_output.err
276
+ assert captured_output.out == ""
277
+
278
+
279
+ def test_prettier_command_uses_windows_npx_and_resolved_file_path(
280
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
281
+ ) -> None:
282
+ auto_formatter_module = _load_auto_formatter_module()
283
+ candidate_file = tmp_path / "linked_module.js"
284
+ captured_commands: list[list[str]] = []
285
+
286
+ def capture_formatter_command(
287
+ command: list[str], _file_path: str, _timeout_seconds: int
288
+ ) -> tuple[subprocess.CompletedProcess[str], bool]:
289
+ captured_commands.append(command)
290
+ return subprocess.CompletedProcess(command, 0, "", ""), False
291
+
292
+ monkeypatch.setattr(auto_formatter_module, "_run_command", capture_formatter_command)
293
+
294
+ auto_formatter_module._run_prettier(str(candidate_file))
295
+
296
+ assert captured_commands == [
297
+ [
298
+ auto_formatter_module.NPX_EXECUTABLE,
299
+ "--yes",
300
+ "prettier",
301
+ "--write",
302
+ os.path.realpath(candidate_file),
303
+ ]
304
+ ]
305
+
306
+
307
+ def test_tracked_write_ignores_redirected_git_dir(
308
+ git_repository: Path,
309
+ monkeypatch: pytest.MonkeyPatch,
310
+ tmp_path: Path,
311
+ ) -> None:
312
+ tracked_file = git_repository / "redirected_git_dir.py"
313
+ tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
314
+ subprocess.run(
315
+ ["git", "add", "redirected_git_dir.py"],
316
+ cwd=git_repository,
317
+ capture_output=True,
318
+ check=True,
319
+ env=build_fixture_git_environment(),
320
+ )
321
+ redirected_repository = tmp_path / "redirected_repository"
322
+ redirected_repository.mkdir()
323
+ subprocess.run(
324
+ ["git", "init"],
325
+ cwd=redirected_repository,
326
+ capture_output=True,
327
+ check=True,
328
+ env=build_fixture_git_environment(),
329
+ )
330
+ monkeypatch.setenv("GIT_DIR", str(redirected_repository / ".git"))
331
+
332
+ completed_hook = _run_hook("Write", tracked_file)
333
+
334
+ assert completed_hook.returncode == 0
335
+ assert "import os" in tracked_file.read_text(encoding="utf-8")
336
+
337
+
338
+ def test_dispatcher_preserves_redirected_git_dir_until_formatter_hook(
339
+ git_repository: Path,
340
+ tmp_path: Path,
341
+ ) -> None:
342
+ tracked_file = git_repository / "dispatcher_redirected_git_dir.py"
343
+ tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
344
+ subprocess.run(
345
+ ["git", "add", "dispatcher_redirected_git_dir.py"],
346
+ cwd=git_repository,
347
+ capture_output=True,
348
+ check=True,
349
+ env=build_fixture_git_environment(),
350
+ )
351
+ redirected_repository = tmp_path / "dispatcher_redirected_repository"
352
+ redirected_repository.mkdir()
353
+ subprocess.run(
354
+ ["git", "init"],
355
+ cwd=redirected_repository,
356
+ capture_output=True,
357
+ check=True,
358
+ env=build_fixture_git_environment(),
359
+ )
360
+ dispatcher_environment = os.environ.copy()
361
+ dispatcher_environment["GIT_DIR"] = str(redirected_repository / ".git")
362
+
363
+ completed_dispatcher = _run_post_tool_use_dispatcher(
364
+ "Write", tracked_file, dispatcher_environment
365
+ )
366
+
367
+ assert completed_dispatcher.returncode == 0
368
+ assert "import os" in tracked_file.read_text(encoding="utf-8")
369
+
370
+
371
+ def test_dispatcher_run_uses_hook_timeout(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
372
+ captured_options: dict[str, object] = {}
373
+
374
+ def capture_dispatcher_run(
375
+ command: list[str], **options: object
376
+ ) -> subprocess.CompletedProcess[str]:
377
+ captured_options.update(options)
378
+ return subprocess.CompletedProcess(command, 0, "", "")
379
+
380
+ monkeypatch.setattr(subprocess, "run", capture_dispatcher_run)
381
+
382
+ _run_post_tool_use_dispatcher("Write", tmp_path / "module.py", {})
383
+
384
+ assert captured_options["timeout"] == HOOK_RUN_TIMEOUT_SECONDS
385
+
386
+
387
+ def test_python_formatting_preserves_crlf_line_endings(git_repository: Path) -> None:
388
+ source_file = git_repository / "crlf_module.py"
389
+ source_file.write_bytes(b"x=1\r\ny = 2\r\n")
390
+
391
+ completed_hook = _run_hook("Write", source_file)
392
+
393
+ assert completed_hook.returncode == 0
394
+ formatted_source = source_file.read_bytes()
395
+ assert b"\r\n" in formatted_source
396
+ assert b"\n" not in formatted_source.replace(b"\r\n", b"")
397
+
398
+
150
399
  def _registered_auto_formatter_timeout() -> int:
151
400
  with open(HOOKS_JSON_PATH, encoding="utf-8") as hooks_file:
152
401
  hooks_configuration = json.load(hooks_file)
@@ -0,0 +1,90 @@
1
+ """Behavior tests for investigation tracker reset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import io
7
+ import json
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ import pytest
12
+
13
+
14
+ HOOK_DIRECTORY = Path(__file__).parent
15
+ HOOK_SPEC = importlib.util.spec_from_file_location(
16
+ "investigation_tracker_reset",
17
+ HOOK_DIRECTORY / "investigation_tracker_reset.py",
18
+ )
19
+ assert HOOK_SPEC is not None
20
+ assert HOOK_SPEC.loader is not None
21
+ HOOK_MODULE = importlib.util.module_from_spec(HOOK_SPEC)
22
+ HOOK_SPEC.loader.exec_module(HOOK_MODULE)
23
+
24
+
25
+ def _run_main(input_text: str) -> int | None:
26
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
27
+ try:
28
+ HOOK_MODULE.main()
29
+ except SystemExit as system_exit:
30
+ if system_exit.code not in (None, 0):
31
+ raise
32
+ return system_exit.code if isinstance(system_exit.code, int) else None
33
+ return None
34
+
35
+
36
+ @pytest.mark.parametrize("delegation_tool", ["Agent", "Task", "TeamCreate"])
37
+ def test_main_removes_tracker_after_delegation(
38
+ tmp_path: Path,
39
+ delegation_tool: str,
40
+ ) -> None:
41
+ tracker_path = tmp_path / "investigation-tracker.json"
42
+ tracker_path.write_text("{}", encoding="utf-8")
43
+ HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
44
+
45
+ exit_status = _run_main(json.dumps({"tool_name": delegation_tool}))
46
+
47
+ assert exit_status in (None, 0)
48
+ assert not tracker_path.exists()
49
+
50
+
51
+ @pytest.mark.parametrize("unrelated_tool", ["Read", "Bash", "Grep"])
52
+ def test_main_keeps_tracker_for_unrelated_tool(
53
+ tmp_path: Path,
54
+ unrelated_tool: str,
55
+ ) -> None:
56
+ tracker_path = tmp_path / "investigation-tracker.json"
57
+ tracker_path.write_text("{}", encoding="utf-8")
58
+ HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
59
+
60
+ exit_status = _run_main(json.dumps({"tool_name": unrelated_tool}))
61
+
62
+ assert exit_status in (None, 0)
63
+ assert tracker_path.exists()
64
+
65
+
66
+ @pytest.mark.parametrize("invalid_input", ["not json", json.dumps([])])
67
+ def test_main_ignores_invalid_input(
68
+ tmp_path: Path,
69
+ invalid_input: str,
70
+ ) -> None:
71
+ tracker_path = tmp_path / "investigation-tracker.json"
72
+ tracker_path.write_text("{}", encoding="utf-8")
73
+ HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
74
+
75
+ exit_status = _run_main(invalid_input)
76
+
77
+ assert exit_status in (None, 0)
78
+ assert tracker_path.exists()
79
+
80
+
81
+ def test_main_keeps_running_when_tracker_removal_fails(tmp_path: Path) -> None:
82
+ tracker_path = tmp_path / "investigation-tracker.json"
83
+ tracker_path.write_text("{}", encoding="utf-8")
84
+ HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
85
+
86
+ with mock.patch.object(HOOK_MODULE.os, "remove", side_effect=OSError("locked")):
87
+ exit_status = _run_main(json.dumps({"tool_name": "Agent"}))
88
+
89
+ assert exit_status in (None, 0)
90
+ assert tracker_path.exists()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.19.0",
3
+ "version": "2.21.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,126 @@
1
+ # Failure Blast Radius
2
+
3
+ **When this applies:** Batch code that processes assets, rows, accounts, messages, or files where one member can fail while the others are fine.
4
+
5
+ ## Rule
6
+
7
+ A check that raises decides two things at once: that a condition requires action, and what stops because of it. Name the second one.
8
+
9
+ An exception type ending in `RunFatal` says the whole run stops. An exception type ending in `ItemBlocked` says this one member stops and the batch carries on. Every raise reached through per-member work uses one or the other.
10
+
11
+ Define `RunFatal` outside the `ItemBlocked` inheritance branch. The per-member boundary routes `ItemBlocked` to parking, while a `RunFatal` escalation passes straight through to the run-level branch.
12
+
13
+ ## What ends a run
14
+
15
+ Four failures end a run, and they share one property: continuing compromises delivery integrity.
16
+
17
+ Three of them are declared, and carry a `RunFatal` type:
18
+
19
+ - The source bytes changed under the run.
20
+ - A provenance or digest comparison failed.
21
+ - Authentication is required.
22
+
23
+ The fourth is a runtime crash, such as `TypeError` or `AttributeError`. Its runtime type reaches run-level handling because the per-member boundary recognizes the contract's named types.
24
+
25
+ All remaining failures are member failures. A size mismatch, an optional input error, a path resolution error, or a manifest-field error each stops one member while the batch continues with its other members.
26
+
27
+ ## The boundary
28
+
29
+ Put the `try`/`except` inside the loop body, around the per-member work:
30
+
31
+ ```python
32
+ for each_member in all_members:
33
+ try:
34
+ remaster(each_member)
35
+ except AssetRunFatal:
36
+ raise
37
+ except AssetItemBlocked as failure:
38
+ park(each_member, failure)
39
+ ```
40
+
41
+ The re-raise comes first, sending an escalation directly through the boundary.
42
+
43
+ **The boundary recognizes declared types.** A runtime crash inside member work — a `TypeError`, an `AttributeError` — ends the run because its type identifies a code defect. `except Exception` triggers the rule (`CODE_RULES.md` §31).
44
+
45
+ ## Repair, park, and the deliverable
46
+
47
+ An agent that hits a member failure keeps working the problem. Three things bound how:
48
+
49
+ - **Repair in place.** The current run preserves every completed member.
50
+ - **Three real attempts, then park.** An attempt is a theory of the cause, acted on. Re-running the same code on the same input counts as one attempt. Three theories cover the obvious cause, the second guess, and the cause revealed by the first two attempts. Judgment selects each theory; the third attempt sets the stopping point. After the third theory fails, park the member with its reason and move to the next. Parked members return after the batch.
51
+ - **The batch always reaches a deliverable.** Complete every member that can complete, produce the packaged artifact, then work the parked list. A run with 34 of 37 members complete and 3 parked records progress and continues to delivery.
52
+
53
+ ## Three alike means one cause
54
+
55
+ When three or more members park with the same failure signature — same exception type, same `file:line` — one shared defect affects all three members. Route repair to the shared cause.
56
+
57
+ The run report groups parked members by that signature and names every group of three or more as a suspected shared cause. The raise site provides a reliable grouping key and removes message-text normalization.
58
+
59
+ ## Close the run with every outcome
60
+
61
+ Every issue the run hit gets one line in the closing report: what failed, and how it ended — repaired, worked around, or parked. Members that finished after a repair belong in that list beside the parked ones. A workaround patched past mid-run is the likeliest real defect in the batch, because the closing report becomes its durable record.
62
+
63
+ The report then presents each candidate to the owner for a durable-fix decision, names the fixes the run recommends, and waits for the answer. The next run builds the selected fix; the current run completes its deliverable first.
64
+
65
+ ## Enforcement
66
+
67
+ `code_rules_blast_radius.py` (PreToolUse on Write and Edit, hosted by `code_rules_enforcer.py`) requires each raised type written directly inside a loop body to end in `RunFatal` or `ItemBlocked`. The lexical check covers raises written directly in loop bodies. Shared helpers carry multiple caller contexts, so their callers classify the boundary.
68
+
69
+ Findings use baseline content for each edit. A raise present on disk remains accepted during the edit; the gate evaluates newly written raises.
70
+
71
+ ## Excerpt for repository-instruction sessions
72
+
73
+ Codex reads its repository `AGENTS.md`; this excerpt supplies the standalone failure-handling contract.
74
+
75
+ ```
76
+ Failure handling for this run — from rules/failure-blast-radius.md.
77
+
78
+ Keep solving problems. You own the fix. Each blast radius sets the repair
79
+ scope, and the deliverable remains the run priority.
80
+
81
+ Repair in place and preserve every completed asset in the current run.
82
+
83
+ Three real attempts, then park. An attempt is a theory of the cause, acted
84
+ on. Repeated execution of one theory remains one attempt.
85
+ Three theories cover the obvious cause, the second guess, and the cause
86
+ revealed by the first two attempts. Use your judgment to select each theory;
87
+ the third attempt sets the stopping point. After the third theory fails, park
88
+ the asset with its reason and continue. Parked assets return after the batch.
89
+
90
+ The batch always reaches a deliverable. Finish every asset you can, produce
91
+ the packaged artifact, then work the parked list.
92
+
93
+ When three or more assets fail the same way, one shared defect affects all
94
+ three assets. Route repair to the shared cause.
95
+
96
+ Four things end a run outright: the source bytes changed, a provenance or
97
+ digest mismatch, authentication is required, or the code crashed with a
98
+ runtime failure that requires run-level handling. Work every other failure. The
99
+ named-type boundary defines the accepted exception handling; broad `except`
100
+ handling triggers the rule.
101
+
102
+ When you add a check that raises, name what it stops. End the type in
103
+ RunFatal when the whole run stops, or ItemBlocked when a single asset
104
+ stops. For an asset-level stop, put the handling inside the loop body.
105
+
106
+ Close the run by reporting what broke and what you did about it. Every issue
107
+ gets one line: what failed, and how it ended — repaired, worked around, or
108
+ parked. Include the ones you solved; a workaround you patched past in attempt
109
+ two is the likeliest real defect in the list, because the closing report becomes
110
+ its durable record.
111
+ Present each candidate for the owner's durable-fix decision, state which fixes
112
+ you recommend and why, and wait for the answer. The next run builds the
113
+ selected durable fix after this run completes its deliverable.
114
+
115
+ Report as: N of M complete, K parked, and what you are working now.
116
+ Close with: what broke, how each one ended, and which of them deserve a
117
+ durable fix.
118
+ ```
119
+
120
+ ## Sibling rules
121
+
122
+ | Rule | Role |
123
+ |---|---|
124
+ | [`code-standards.md`](code-standards.md) | `CODE_RULES.md` §9.7 names the boundary that turns a recorded per-member failure into an explicit outcome |
125
+ | [`confirm-implementation-forks.md`](confirm-implementation-forks.md) | A defect correction creates an implementation fork when it routes around parking; surface and decide that fork |
126
+ | [`long-horizon-autonomy.md`](long-horizon-autonomy.md) | A parked member receives an explicit report entry and follow-up |