claude-dev-env 2.31.0 → 2.31.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 (47) hide show
  1. package/docs/CODE_RULES.md +2 -1
  2. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  3. package/hooks/blocking/code_rules_enforcer.py +32 -14
  4. package/hooks/blocking/code_rules_shared.py +64 -14
  5. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  6. package/hooks/blocking/tdd_enforcer.py +8 -4
  7. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  8. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  9. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  10. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  11. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  12. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -2
  13. package/hooks/blocking/test_state_description_blocker.py +99 -53
  14. package/hooks/blocking/test_tdd_enforcer.py +64 -0
  15. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  16. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  17. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  18. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  19. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  20. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  21. package/hooks/validators/abbreviation_checks.py +3 -3
  22. package/hooks/validators/code_quality_checks.py +4 -4
  23. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  24. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  25. package/hooks/validators/config/validator_base_constants.py +9 -0
  26. package/hooks/validators/fast_save_validators.py +23 -80
  27. package/hooks/validators/pr_reference_checks.py +2 -2
  28. package/hooks/validators/python_antipattern_checks.py +3 -3
  29. package/hooks/validators/python_style_checks.py +3 -2
  30. package/hooks/validators/react_checks.py +2 -9
  31. package/hooks/validators/run_all_validators.py +190 -145
  32. package/hooks/validators/security_checks.py +3 -3
  33. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  34. package/hooks/validators/test_fast_save_validators.py +16 -16
  35. package/hooks/validators/test_hook_timing_harness.py +10 -1
  36. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  37. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  38. package/hooks/validators/test_validator_base.py +51 -2
  39. package/hooks/validators/todo_checks.py +2 -2
  40. package/hooks/validators/type_safety_checks.py +3 -3
  41. package/hooks/validators/useless_test_checks.py +3 -3
  42. package/hooks/validators/validator_base.py +73 -1
  43. package/package.json +1 -1
  44. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  45. package/scripts/grok_headless_runner.py +15 -12
  46. package/scripts/shared_tree_paths.py +70 -0
  47. package/scripts/test_shared_tree_paths.py +143 -0
@@ -1,4 +1,11 @@
1
- """Tests for state_description_blocker hook."""
1
+ """Tests for state_description_blocker hook.
2
+
3
+ Every payload here names an absolute file under this repository and carries
4
+ the repository root as its ``cwd``, so each decision belongs to this
5
+ checkout rather than to the directory pytest runs from. The one exception is
6
+ the ephemeral-path test, which keeps its scratch target outside the
7
+ repository root on purpose.
8
+ """
2
9
 
3
10
  import json
4
11
  import os
@@ -26,6 +33,28 @@ HOOK_SCRIPT_PATH = os.path.join(
26
33
  os.path.dirname(__file__), "state_description_blocker.py"
27
34
  )
28
35
 
36
+ _REPOSITORY_ROOT = str(Path(__file__).resolve().parents[4])
37
+
38
+
39
+ def _repository_file_path(relative_path: str) -> str:
40
+ """Return the absolute path of a repository-relative file in this checkout.
41
+
42
+ The hook resolves a bare relative path against the process working
43
+ directory, so a payload carrying one answers to the directory pytest was
44
+ invoked from. An absolute path under the repository root, paired with a
45
+ payload ``cwd`` naming that root, keeps the decision stable for a
46
+ checkout under a temporary root as well.
47
+
48
+ Args:
49
+ relative_path: The path of the target file relative to the repository
50
+ root.
51
+
52
+ Returns:
53
+ The absolute path of that file inside this repository.
54
+ """
55
+ return str(Path(_REPOSITORY_ROOT) / relative_path)
56
+
57
+
29
58
  CLEAN_PYTHON = "x = 1 # Uses a default timeout"
30
59
  CLEAN_MD = "# Config\n\nThe API uses port 8080."
31
60
  CLEAN_COMMENT = "# Configured with a 30-second timeout"
@@ -46,7 +75,11 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
46
75
 
47
76
 
48
77
  class _RunHook:
49
- """Helper to test the hook via subprocess."""
78
+ """Run the hook as a subprocess on a payload anchored in this repository.
79
+
80
+ The payload names the repository root as the session working directory,
81
+ so the hook treats each target inside it as a real production file.
82
+ """
50
83
 
51
84
  def __call__(
52
85
  self,
@@ -55,7 +88,9 @@ class _RunHook:
55
88
  *,
56
89
  is_prose_style_enabled: bool = True,
57
90
  ) -> subprocess.CompletedProcess:
58
- payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
91
+ payload = json.dumps(
92
+ {"tool_name": tool_name, "tool_input": tool_input, "cwd": _REPOSITORY_ROOT}
93
+ )
59
94
  environment_by_key = os.environ.copy()
60
95
  if is_prose_style_enabled:
61
96
  environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
@@ -81,7 +116,7 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
81
116
  result = _run_hook(
82
117
  "Write",
83
118
  {
84
- "file_path": "src/main.py",
119
+ "file_path": _repository_file_path("src/main.py"),
85
120
  "content": VIOLATION_INSTEAD_OF_COMMENT,
86
121
  },
87
122
  is_prose_style_enabled=False,
@@ -92,9 +127,10 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
92
127
  {
93
128
  "tool_name": "Write",
94
129
  "tool_input": {
95
- "file_path": "src/main.py",
130
+ "file_path": _repository_file_path("src/main.py"),
96
131
  "content": VIOLATION_INSTEAD_OF_COMMENT,
97
132
  },
133
+ "cwd": _REPOSITORY_ROOT,
98
134
  }
99
135
  )
100
136
  assert deny_reason is not None
@@ -105,7 +141,7 @@ def test_block_clean_python_comment_passes():
105
141
  result = _run_hook(
106
142
  "Write",
107
143
  {
108
- "file_path": "src/main.py",
144
+ "file_path": _repository_file_path("src/main.py"),
109
145
  "content": CLEAN_PYTHON,
110
146
  },
111
147
  )
@@ -117,7 +153,7 @@ def test_block_clean_markdown_passes():
117
153
  result = _run_hook(
118
154
  "Write",
119
155
  {
120
- "file_path": "docs/README.md",
156
+ "file_path": _repository_file_path("docs/README.md"),
121
157
  "content": CLEAN_MD,
122
158
  },
123
159
  )
@@ -129,7 +165,7 @@ def test_block_clean_comment_passes():
129
165
  result = _run_hook(
130
166
  "Write",
131
167
  {
132
- "file_path": "src/config.py",
168
+ "file_path": _repository_file_path("src/config.py"),
133
169
  "content": CLEAN_COMMENT,
134
170
  },
135
171
  )
@@ -141,7 +177,7 @@ def test_block_irrelevant_file_passes():
141
177
  result = _run_hook(
142
178
  "Write",
143
179
  {
144
- "file_path": "data.txt",
180
+ "file_path": _repository_file_path("data.txt"),
145
181
  "content": VIOLATION_INSTEAD_OF_COMMENT,
146
182
  },
147
183
  )
@@ -153,7 +189,7 @@ def test_block_empty_content_passes():
153
189
  result = _run_hook(
154
190
  "Write",
155
191
  {
156
- "file_path": "src/main.py",
192
+ "file_path": _repository_file_path("src/main.py"),
157
193
  "content": "",
158
194
  },
159
195
  )
@@ -177,7 +213,7 @@ def test_detects_instead_of_in_comment():
177
213
  result = _run_hook(
178
214
  "Write",
179
215
  {
180
- "file_path": "src/main.py",
216
+ "file_path": _repository_file_path("src/main.py"),
181
217
  "content": VIOLATION_INSTEAD_OF_COMMENT,
182
218
  },
183
219
  )
@@ -191,7 +227,7 @@ def test_detects_previously_in_comment():
191
227
  result = _run_hook(
192
228
  "Write",
193
229
  {
194
- "file_path": "src/config.py",
230
+ "file_path": _repository_file_path("src/config.py"),
195
231
  "content": VIOLATION_PREVIOUSLY_COMMENT,
196
232
  },
197
233
  )
@@ -205,7 +241,7 @@ def test_detects_now_uses_in_comment():
205
241
  result = _run_hook(
206
242
  "Write",
207
243
  {
208
- "file_path": "src/client.py",
244
+ "file_path": _repository_file_path("src/client.py"),
209
245
  "content": VIOLATION_NOW_USES_COMMENT,
210
246
  },
211
247
  )
@@ -219,7 +255,7 @@ def test_detects_instead_of_in_markdown():
219
255
  result = _run_hook(
220
256
  "Write",
221
257
  {
222
- "file_path": "docs/api.md",
258
+ "file_path": _repository_file_path("docs/api.md"),
223
259
  "content": VIOLATION_MD_INSTEAD,
224
260
  },
225
261
  )
@@ -232,7 +268,7 @@ def test_detects_previously_in_markdown():
232
268
  result = _run_hook(
233
269
  "Write",
234
270
  {
235
- "file_path": "docs/config.md",
271
+ "file_path": _repository_file_path("docs/config.md"),
236
272
  "content": VIOLATION_MD_PREVIOUSLY,
237
273
  },
238
274
  )
@@ -245,7 +281,7 @@ def test_detects_now_uses_in_markdown():
245
281
  result = _run_hook(
246
282
  "Write",
247
283
  {
248
- "file_path": "docs/auth.md",
284
+ "file_path": _repository_file_path("docs/auth.md"),
249
285
  "content": VIOLATION_MD_NOW_USES,
250
286
  },
251
287
  )
@@ -271,7 +307,7 @@ def test_detects_edit_new_string():
271
307
  result = _run_hook(
272
308
  "Edit",
273
309
  {
274
- "file_path": "src/main.py",
310
+ "file_path": _repository_file_path("src/main.py"),
275
311
  "old_string": "old_comment",
276
312
  "new_string": VIOLATION_PREVIOUSLY_COMMENT,
277
313
  },
@@ -286,7 +322,7 @@ def test_clean_edit_passes():
286
322
  result = _run_hook(
287
323
  "Edit",
288
324
  {
289
- "file_path": "src/main.py",
325
+ "file_path": _repository_file_path("src/main.py"),
290
326
  "old_string": "x = 1",
291
327
  "new_string": CLEAN_PYTHON,
292
328
  },
@@ -300,7 +336,7 @@ def test_detects_multi_edit_second_new_string() -> None:
300
336
  result = _run_hook(
301
337
  "MultiEdit",
302
338
  {
303
- "file_path": "src/main.py",
339
+ "file_path": _repository_file_path("src/main.py"),
304
340
  "edits": [
305
341
  {"old_string": "a = 1", "new_string": CLEAN_PYTHON},
306
342
  {"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT},
@@ -317,7 +353,7 @@ def test_clean_multi_edit_passes() -> None:
317
353
  result = _run_hook(
318
354
  "MultiEdit",
319
355
  {
320
- "file_path": "src/main.py",
356
+ "file_path": _repository_file_path("src/main.py"),
321
357
  "edits": [
322
358
  {"old_string": "a = 1", "new_string": CLEAN_PYTHON},
323
359
  {"old_string": "b = 2", "new_string": CLEAN_COMMENT},
@@ -339,7 +375,7 @@ def test_multi_edit_new_strings_join_on_a_separator_not_concatenation() -> None:
339
375
  result = _run_hook(
340
376
  "MultiEdit",
341
377
  {
342
- "file_path": "src/main.py",
378
+ "file_path": _repository_file_path("src/main.py"),
343
379
  "edits": [
344
380
  {"old_string": "a = 1", "new_string": "# describes the previous"},
345
381
  {"old_string": "b = 2", "new_string": "ly implemented step."},
@@ -354,7 +390,7 @@ def test_multi_edit_with_a_single_edit_still_denies() -> None:
354
390
  result = _run_hook(
355
391
  "MultiEdit",
356
392
  {
357
- "file_path": "src/main.py",
393
+ "file_path": _repository_file_path("src/main.py"),
358
394
  "edits": [{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT}],
359
395
  },
360
396
  )
@@ -367,7 +403,7 @@ def test_system_message_and_suppress_output():
367
403
  result = _run_hook(
368
404
  "Write",
369
405
  {
370
- "file_path": "src/main.py",
406
+ "file_path": _repository_file_path("src/main.py"),
371
407
  "content": VIOLATION_INSTEAD_OF_COMMENT,
372
408
  },
373
409
  )
@@ -382,7 +418,7 @@ def test_detects_no_longer_in_comment():
382
418
  result = _run_hook(
383
419
  "Write",
384
420
  {
385
- "file_path": "src/config.py",
421
+ "file_path": _repository_file_path("src/config.py"),
386
422
  "content": "# No longer supports legacy mode",
387
423
  },
388
424
  )
@@ -396,7 +432,7 @@ def test_detects_used_to_in_comment():
396
432
  result = _run_hook(
397
433
  "Write",
398
434
  {
399
- "file_path": "src/config.py",
435
+ "file_path": _repository_file_path("src/config.py"),
400
436
  "content": "# Used to be hardcoded",
401
437
  },
402
438
  )
@@ -410,7 +446,7 @@ def test_detects_switched_to_in_comment():
410
446
  result = _run_hook(
411
447
  "Write",
412
448
  {
413
- "file_path": "src/config.py",
449
+ "file_path": _repository_file_path("src/config.py"),
414
450
  "content": "# Switched to async processing",
415
451
  },
416
452
  )
@@ -428,7 +464,7 @@ def test_detects_comment_with_close_block_token():
428
464
  result = _run_hook(
429
465
  "Write",
430
466
  {
431
- "file_path": "src/config.py",
467
+ "file_path": _repository_file_path("src/config.py"),
432
468
  "content": VIOLATION_COMMENT_WITH_CLOSE_BLOCK,
433
469
  },
434
470
  )
@@ -445,7 +481,7 @@ def test_detects_inline_trailing_comment():
445
481
  result = _run_hook(
446
482
  "Write",
447
483
  {
448
- "file_path": "src/main.py",
484
+ "file_path": _repository_file_path("src/main.py"),
449
485
  "content": "max_retries = 3 # No longer needed since async retry handles it",
450
486
  },
451
487
  )
@@ -461,7 +497,7 @@ def test_ignores_c_preprocessor_directive():
461
497
  result = _run_hook(
462
498
  "Write",
463
499
  {
464
- "file_path": "src/config.h",
500
+ "file_path": _repository_file_path("src/config.h"),
465
501
  "content": '#error "This code previously used replaced API"',
466
502
  },
467
503
  )
@@ -477,7 +513,7 @@ def test_ignores_hash_in_javascript_inline():
477
513
  result = _run_hook(
478
514
  "Write",
479
515
  {
480
- "file_path": "src/main.js",
516
+ "file_path": _repository_file_path("src/main.js"),
481
517
  "content": 'const selector = "#originally-dark" // Use dark as default',
482
518
  },
483
519
  )
@@ -493,7 +529,7 @@ def test_ignores_double_slash_in_js_url():
493
529
  result = _run_hook(
494
530
  "Write",
495
531
  {
496
- "file_path": "src/fetch.ts",
532
+ "file_path": _repository_file_path("src/fetch.ts"),
497
533
  "content": 'fetch("https://api.example.com/replaces")',
498
534
  },
499
535
  )
@@ -509,7 +545,7 @@ def test_block_comment_continuation_with_nested_glob():
509
545
  result = _run_hook(
510
546
  "Write",
511
547
  {
512
- "file_path": "src/cache.ts",
548
+ "file_path": _repository_file_path("src/cache.ts"),
513
549
  "content": content,
514
550
  },
515
551
  )
@@ -526,7 +562,7 @@ def test_detects_inline_after_url_on_same_line():
526
562
  result = _run_hook(
527
563
  "Write",
528
564
  {
529
- "file_path": "src/fetch.ts",
565
+ "file_path": _repository_file_path("src/fetch.ts"),
530
566
  "content": 'const url = "https://api.com"; // no longer used',
531
567
  },
532
568
  )
@@ -543,7 +579,7 @@ def test_ignores_code_before_block_comment():
543
579
  result = _run_hook(
544
580
  "Write",
545
581
  {
546
- "file_path": "src/cache.ts",
582
+ "file_path": _repository_file_path("src/cache.ts"),
547
583
  "content": "cache.replaces(old); /* Use fresh cache */",
548
584
  },
549
585
  )
@@ -559,7 +595,7 @@ def test_ignores_url_with_double_slash_in_python():
559
595
  result = _run_hook(
560
596
  "Write",
561
597
  {
562
- "file_path": "src/main.py",
598
+ "file_path": _repository_file_path("src/main.py"),
563
599
  "content": 'url = "https://api.example.com/replaces/v1"',
564
600
  },
565
601
  )
@@ -574,7 +610,7 @@ def test_ignores_instead_of_in_code_string_with_inline_comment():
574
610
  result = _run_hook(
575
611
  "Write",
576
612
  {
577
- "file_path": "src/main.py",
613
+ "file_path": _repository_file_path("src/main.py"),
578
614
  "content": "msg = 'instead of' # Use the default timeout",
579
615
  },
580
616
  )
@@ -590,7 +626,7 @@ def test_ignores_glob_pattern_with_star_in_python():
590
626
  result = _run_hook(
591
627
  "Write",
592
628
  {
593
- "file_path": "src/main.py",
629
+ "file_path": _repository_file_path("src/main.py"),
594
630
  "content": content,
595
631
  },
596
632
  )
@@ -606,7 +642,7 @@ def test_ignores_comment_with_glob_pattern():
606
642
  result = _run_hook(
607
643
  "Write",
608
644
  {
609
- "file_path": "src/main.py",
645
+ "file_path": _repository_file_path("src/main.py"),
610
646
  "content": content,
611
647
  },
612
648
  )
@@ -621,7 +657,7 @@ def test_single_line_block_comment_closes():
621
657
  result = _run_hook(
622
658
  "Write",
623
659
  {
624
- "file_path": "src/cache.ts",
660
+ "file_path": _repository_file_path("src/cache.ts"),
625
661
  "content": content,
626
662
  },
627
663
  )
@@ -639,7 +675,7 @@ def test_block_comment_with_url_closes_correctly():
639
675
  result = _run_hook(
640
676
  "Write",
641
677
  {
642
- "file_path": "src/cache.ts",
678
+ "file_path": _repository_file_path("src/cache.ts"),
643
679
  "content": content,
644
680
  },
645
681
  )
@@ -656,7 +692,7 @@ def test_same_line_block_comment_with_trailing_inline():
656
692
  result = _run_hook(
657
693
  "Write",
658
694
  {
659
- "file_path": "src/fetch.ts",
695
+ "file_path": _repository_file_path("src/fetch.ts"),
660
696
  "content": content,
661
697
  },
662
698
  )
@@ -674,7 +710,7 @@ def test_multi_line_block_comment_close_with_trailing_inline():
674
710
  result = _run_hook(
675
711
  "Write",
676
712
  {
677
- "file_path": "src/cache.ts",
713
+ "file_path": _repository_file_path("src/cache.ts"),
678
714
  "content": content,
679
715
  },
680
716
  )
@@ -691,7 +727,7 @@ def test_detects_earliest_inline_marker_in_php():
691
727
  result = _run_hook(
692
728
  "Write",
693
729
  {
694
- "file_path": "src/index.php",
730
+ "file_path": _repository_file_path("src/index.php"),
695
731
  "content": 'echo $x; // previously used #tag',
696
732
  },
697
733
  )
@@ -705,7 +741,7 @@ def test_additional_context_contains_examples():
705
741
  result = _run_hook(
706
742
  "Write",
707
743
  {
708
- "file_path": "src/main.py",
744
+ "file_path": _repository_file_path("src/main.py"),
709
745
  "content": VIOLATION_INSTEAD_OF_COMMENT,
710
746
  },
711
747
  )
@@ -750,7 +786,13 @@ def test_handles_non_dict_tool_input():
750
786
  def test_handles_non_string_tool_name():
751
787
  """A tool_name that is not a string should exit cleanly — the hook must
752
788
  guard against tool_name not being a string."""
753
- payload = json.dumps({"tool_name": 123, "tool_input": {"file_path": "src/main.py"}})
789
+ payload = json.dumps(
790
+ {
791
+ "tool_name": 123,
792
+ "tool_input": {"file_path": _repository_file_path("src/main.py")},
793
+ "cwd": _REPOSITORY_ROOT,
794
+ }
795
+ )
754
796
  result = subprocess.run(
755
797
  [sys.executable, HOOK_SCRIPT_PATH],
756
798
  input=payload,
@@ -774,7 +816,7 @@ def test_detects_used_to_in_python_module_docstring() -> None:
774
816
  result = _run_hook(
775
817
  "Write",
776
818
  {
777
- "file_path": "tests/test_exports.py",
819
+ "file_path": _repository_file_path("tests/test_exports.py"),
778
820
  "content": written_source,
779
821
  },
780
822
  )
@@ -795,7 +837,7 @@ def test_detects_previously_in_python_function_docstring() -> None:
795
837
  result = _run_hook(
796
838
  "Write",
797
839
  {
798
- "file_path": "src/configuration.py",
840
+ "file_path": _repository_file_path("src/configuration.py"),
799
841
  "content": written_source,
800
842
  },
801
843
  )
@@ -812,7 +854,7 @@ def test_ignores_transitional_phrase_in_regular_python_string() -> None:
812
854
  result = _run_hook(
813
855
  "Write",
814
856
  {
815
- "file_path": "src/messages.py",
857
+ "file_path": _repository_file_path("src/messages.py"),
816
858
  "content": 'PADDING_NOTE = "used to pad with a flat sleep"\n',
817
859
  },
818
860
  )
@@ -831,7 +873,7 @@ def test_ignores_quoted_mention_inside_python_docstring() -> None:
831
873
  result = _run_hook(
832
874
  "Write",
833
875
  {
834
- "file_path": "src/scanner.py",
876
+ "file_path": _repository_file_path("src/scanner.py"),
835
877
  "content": written_source,
836
878
  },
837
879
  )
@@ -847,7 +889,7 @@ def test_detects_docstring_at_start_of_unparseable_edit_fragment() -> None:
847
889
  result = _run_hook(
848
890
  "Edit",
849
891
  {
850
- "file_path": "src/exports.py",
892
+ "file_path": _repository_file_path("src/exports.py"),
851
893
  "old_string": "placeholder",
852
894
  "new_string": fragment,
853
895
  },
@@ -869,7 +911,7 @@ def test_detects_docstring_after_def_header_in_unparseable_fragment() -> None:
869
911
  result = _run_hook(
870
912
  "Edit",
871
913
  {
872
- "file_path": "src/reporting.py",
914
+ "file_path": _repository_file_path("src/reporting.py"),
873
915
  "old_string": "placeholder",
874
916
  "new_string": fragment,
875
917
  },
@@ -888,7 +930,7 @@ def test_ignores_assigned_triple_quoted_string_in_unparseable_fragment() -> None
888
930
  result = _run_hook(
889
931
  "Edit",
890
932
  {
891
- "file_path": "src/fixtures.py",
933
+ "file_path": _repository_file_path("src/fixtures.py"),
892
934
  "old_string": "placeholder",
893
935
  "new_string": fragment,
894
936
  },
@@ -907,7 +949,11 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
907
949
  """
908
950
  deny_payload = {
909
951
  "tool_name": "Write",
910
- "tool_input": {"file_path": "src/main.py", "content": VIOLATION_INSTEAD_OF_COMMENT},
952
+ "tool_input": {
953
+ "file_path": _repository_file_path("src/main.py"),
954
+ "content": VIOLATION_INSTEAD_OF_COMMENT,
955
+ },
956
+ "cwd": _REPOSITORY_ROOT,
911
957
  }
912
958
  native_hook = NativeHook(evaluate=evaluate, build_deny_payload=build_deny_payload)
913
959
 
@@ -1374,3 +1374,67 @@ def test_should_deny_apply_patch_that_adds_test_and_production_file_in_one_trans
1374
1374
  completed = _run_hook_with_payload(_make_apply_patch_payload(sandbox, patch_command))
1375
1375
 
1376
1376
  assert _decision_from(completed) == "deny"
1377
+
1378
+
1379
+ def test_should_stay_exempt_for_scratch_outside_repository_root(
1380
+ monkeypatch: pytest.MonkeyPatch,
1381
+ ) -> None:
1382
+ """Repo-root fix: a genuine scratch file outside the repository stays exempt."""
1383
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
1384
+ repository_root = "/tmp/pr-alpha"
1385
+ payload = {
1386
+ "tool_name": "Write",
1387
+ "tool_input": {
1388
+ "file_path": "/tmp/scratch/one_off.py",
1389
+ "content": _BEHAVIOR_BEARING_CONTENT,
1390
+ },
1391
+ "cwd": repository_root,
1392
+ }
1393
+ completed = _run_hook_with_payload(payload)
1394
+ assert _decision_from(completed) != "deny", (
1395
+ f"TDD enforcer must not deny scratch outside the repository, got: {completed.stdout!r}"
1396
+ )
1397
+
1398
+
1399
+ def test_should_stay_exempt_for_unrelated_worktree_sibling_under_tmp(
1400
+ monkeypatch: pytest.MonkeyPatch,
1401
+ ) -> None:
1402
+ """Repo-root fix: a sibling sharing the root's name prefix is not inside it.
1403
+
1404
+ Containment is judged by path segment, so /tmp/pr-alpha-sibling is outside
1405
+ /tmp/pr-alpha however much of the name the two share.
1406
+ """
1407
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
1408
+ repository_root = "/tmp/pr-alpha"
1409
+ payload = {
1410
+ "tool_name": "Write",
1411
+ "tool_input": {
1412
+ "file_path": "/tmp/pr-alpha-sibling/hooks/orders.py",
1413
+ "content": _BEHAVIOR_BEARING_CONTENT,
1414
+ },
1415
+ "cwd": repository_root,
1416
+ }
1417
+ completed = _run_hook_with_payload(payload)
1418
+ assert _decision_from(completed) != "deny", (
1419
+ f"TDD enforcer must not deny an unrelated sibling worktree, got: {completed.stdout!r}"
1420
+ )
1421
+
1422
+
1423
+ def test_should_deny_production_file_inside_repository_root_under_tmp(
1424
+ monkeypatch: pytest.MonkeyPatch,
1425
+ ) -> None:
1426
+ """Repo-root fix: a production file inside a repository under /tmp still needs a test."""
1427
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
1428
+ repository_root = "/tmp/pr-alpha"
1429
+ payload = {
1430
+ "tool_name": "Write",
1431
+ "tool_input": {
1432
+ "file_path": f"{repository_root}/hooks/blocking/orders.py",
1433
+ "content": _BEHAVIOR_BEARING_CONTENT,
1434
+ },
1435
+ "cwd": repository_root,
1436
+ }
1437
+ completed = _run_hook_with_payload(payload)
1438
+ assert _decision_from(completed) == "deny", (
1439
+ f"TDD enforcer must gate a repository file under /tmp, got: {completed.stdout!r}"
1440
+ )
@@ -7,6 +7,13 @@ The tracker records one fact per session: the session spawned the
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from hooks_constants.session_edit_stage_gate_constants import (
14
+ SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
15
+ )
16
+
10
17
  PR_DESCRIPTION_WRITER_SUBAGENT_TYPE: str = "pr-description-writer"
11
18
 
12
19
  ALL_AGENT_SPAWN_TOOL_NAMES: frozenset[str] = frozenset({"Agent", "Task"})
@@ -37,3 +44,30 @@ CORRECTIVE_MESSAGE: str = (
37
44
  " 2. Append the trailing comment `# pr-description-skip` to the command when "
38
45
  "you are writing the body yourself on purpose."
39
46
  )
47
+
48
+
49
+ def spawn_marker_path(session_id: str, temp_directory: Path | None = None) -> Path | None:
50
+ """Return this session's marker file path, or None when it names no session.
51
+
52
+ The tracker writes this path and the gate reads it, so the file name lives
53
+ in one place. A session id that sanitizes to nothing yields no path, so
54
+ every marker on disk belongs to one real session and a marker a crashed
55
+ session leaves behind is inert.
56
+
57
+ Args:
58
+ session_id: Raw ``session_id`` from the hook payload.
59
+ temp_directory: The directory holding the marker. Defaults to the
60
+ system temp directory.
61
+
62
+ Returns:
63
+ Absolute path to this session's marker file, or None when the payload
64
+ names no session this hook can key on.
65
+ """
66
+ sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
67
+ if not sanitized_session_id:
68
+ return None
69
+ file_name = (
70
+ f"{SPAWN_MARKER_FILE_PREFIX}{sanitized_session_id}{SPAWN_MARKER_FILE_SUFFIX}"
71
+ )
72
+ resolved_temp_directory = temp_directory or Path(tempfile.gettempdir())
73
+ return resolved_temp_directory / file_name
@@ -6,7 +6,7 @@ find a named budget helper that omits a reachable subprocess timeout.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from hooks_constants.multi_edit_reconstruction import apply_edits
9
+ from hooks_constants.multi_edit_reconstruction import apply_edits, edits_for_tool
10
10
 
11
11
  __all__ = [
12
12
  "existing_file_content",
@@ -35,7 +35,7 @@ def reconstructed_edit_content(all_tool_input_fields: dict[str, object]) -> str:
35
35
  existing_content = existing_file_content(file_path)
36
36
  if existing_content is None or old_string not in existing_content:
37
37
  return ""
38
- return existing_content.replace(old_string, new_string, 1)
38
+ return apply_edits(existing_content, edits_for_tool("Edit", all_tool_input_fields))
39
39
 
40
40
 
41
41
  def reconstructed_multi_edit_content(
@@ -69,6 +69,7 @@ def test_apply_edits_mixes_replace_all_and_single_edits_in_one_list() -> None:
69
69
  ]
70
70
  assert apply_edits("a b a b", all_edits) == "A B A b"
71
71
 
72
+
72
73
  def test_joined_new_strings_joins_every_new_string_on_a_newline() -> None:
73
74
  """Each edit's introduced text reaches the scanner as its own line."""
74
75
  tool_input = {