claude-dev-env 2.20.0 → 2.21.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.
@@ -9,14 +9,14 @@ import os
9
9
  import re
10
10
  import stat
11
11
  import tempfile
12
- import tomllib
12
+ from collections.abc import Callable, Iterable
13
13
  from dataclasses import dataclass, field
14
14
  from pathlib import Path
15
- from typing import Callable, Iterable
15
+ from typing import NoReturn
16
16
 
17
+ import tomllib
17
18
  import yaml
18
19
 
19
-
20
20
  ManagedContent = str | bytes
21
21
 
22
22
  path_separator = "/"
@@ -27,6 +27,87 @@ publish_plan_max_positional_arguments = 3
27
27
  publish_plan_failure_injector_position = 2
28
28
  frontmatter_unsupported_fields = ("tools", "model", "color")
29
29
  instruction_alias_filenames = frozenset({"AGENTS.md", "CLAUDE.md"})
30
+ failure_blast_radius_rule_relative_path = "rules/failure-blast-radius.md"
31
+ codex_instruction_target_path = "AGENTS.md"
32
+ codex_instruction_section_heading = "## Excerpt for repository-instruction sessions"
33
+ codex_hook_manifest_source_path = "hooks/hooks.json"
34
+ codex_hook_manifest_target_path = "hooks.json"
35
+ codex_hook_event_name = "PreToolUse"
36
+ codex_hook_matcher = "apply_patch"
37
+ codex_enforcer_script_relative_path = "hooks/blocking/code_rules_enforcer.py"
38
+ codex_enforcer_script_name = "code_rules_enforcer.py"
39
+ codex_enforcer_path_suffix = path_separator + codex_enforcer_script_relative_path
40
+ codex_hook_command_token_pattern = r'''(?:"[^"]*"|'[^']*'|\S+)'''
41
+ codex_hook_merge_action = "merge"
42
+ codex_hook_timeout_seconds = 60
43
+ codex_hook_dependency_manifest = (
44
+ "hooks/blocking/__init__.py",
45
+ "hooks/blocking/code_rules_annotations_length.py",
46
+ "hooks/blocking/code_rules_banned_identifiers.py",
47
+ "hooks/blocking/code_rules_blast_radius.py",
48
+ "hooks/blocking/code_rules_boolean_mustcheck.py",
49
+ "hooks/blocking/code_rules_command_dispatch.py",
50
+ "hooks/blocking/code_rules_comments.py",
51
+ "hooks/blocking/code_rules_constants_config.py",
52
+ "hooks/blocking/codex_apply_patch.py",
53
+ "hooks/blocking/code_rules_dead_argparse_argument.py",
54
+ "hooks/blocking/code_rules_dead_config_field.py",
55
+ "hooks/blocking/code_rules_dead_dataclass_field.py",
56
+ "hooks/blocking/code_rules_dead_module_constant.py",
57
+ "hooks/blocking/code_rules_dead_split_branch.py",
58
+ "hooks/blocking/code_rules_docstrings.py",
59
+ "hooks/blocking/code_rules_duplicate_body.py",
60
+ "hooks/blocking/code_rules_enforcer.py",
61
+ "hooks/blocking/code_rules_imports_logging.py",
62
+ "hooks/blocking/code_rules_js_conventions.py",
63
+ "hooks/blocking/code_rules_magic_values.py",
64
+ "hooks/blocking/code_rules_mock_completeness.py",
65
+ "hooks/blocking/code_rules_naming_collection.py",
66
+ "hooks/blocking/code_rules_optional_params.py",
67
+ "hooks/blocking/code_rules_orphan_css_class.py",
68
+ "hooks/blocking/code_rules_paired_test.py",
69
+ "hooks/blocking/code_rules_path_utils.py",
70
+ "hooks/blocking/code_rules_paths_syspath.py",
71
+ "hooks/blocking/code_rules_probe_chains.py",
72
+ "hooks/blocking/code_rules_probe_detection.py",
73
+ "hooks/blocking/code_rules_probe_recording.py",
74
+ "hooks/blocking/code_rules_scope_binding.py",
75
+ "hooks/blocking/code_rules_shared.py",
76
+ "hooks/blocking/code_rules_string_magic.py",
77
+ "hooks/blocking/code_rules_test_assertions.py",
78
+ "hooks/blocking/code_rules_test_branching_except.py",
79
+ "hooks/blocking/code_rules_test_isolation.py",
80
+ "hooks/blocking/code_rules_test_layout.py",
81
+ "hooks/blocking/code_rules_type_escape.py",
82
+ "hooks/blocking/code_rules_typeddict_stub.py",
83
+ "hooks/blocking/code_rules_unused_imports.py",
84
+ "hooks/hooks_constants/__init__.py",
85
+ "hooks/hooks_constants/any_type_config.py",
86
+ "hooks/hooks_constants/banned_identifiers_constants.py",
87
+ "hooks/hooks_constants/blast_radius_constants.py",
88
+ "hooks/hooks_constants/blocking_check_limits.py",
89
+ "hooks/hooks_constants/code_rules_enforcer_constants.py",
90
+ "hooks/hooks_constants/code_rules_path_utils_constants.py",
91
+ "hooks/hooks_constants/command_dispatch_constants.py",
92
+ "hooks/hooks_constants/dead_argparse_argument_constants.py",
93
+ "hooks/hooks_constants/dead_config_field_constants.py",
94
+ "hooks/hooks_constants/dead_dataclass_field_constants.py",
95
+ "hooks/hooks_constants/dead_module_constant_constants.py",
96
+ "hooks/hooks_constants/duplicate_function_body_constants.py",
97
+ "hooks/hooks_constants/hardcoded_user_path_constants.py",
98
+ "hooks/hooks_constants/harness_scratchpad_constants.py",
99
+ "hooks/hooks_constants/hook_block_logger.py",
100
+ "hooks/hooks_constants/inline_tuple_string_magic_constants.py",
101
+ "hooks/hooks_constants/js_conventions_constants.py",
102
+ "hooks/hooks_constants/orphan_css_class_constants.py",
103
+ "hooks/hooks_constants/paired_test_coverage_constants.py",
104
+ "hooks/hooks_constants/setup_project_paths_constants.py",
105
+ "hooks/hooks_constants/stuttering_check_config.py",
106
+ "hooks/hooks_constants/stuttering_import_binding_constants.py",
107
+ "hooks/hooks_constants/sys_path_insert_constants.py",
108
+ "hooks/hooks_constants/test_layout_constants.py",
109
+ "hooks/hooks_constants/unused_module_import_constants.py",
110
+ )
30
111
  full_prune_opt_in_flag = "--allow-prune-all"
31
112
  unreadable_source_root_message = (
32
113
  "source root is missing or is not a directory, so nothing was planned or changed; "
@@ -50,6 +131,10 @@ class MaterializerError(ValueError):
50
131
  """Raised when a materialization request cannot be safely planned."""
51
132
 
52
133
 
134
+ class MaterializerRunFatal(MaterializerError):
135
+ """Raised when invalid materializer state stops the whole run."""
136
+
137
+
53
138
  class ArgumentParserError(ValueError):
54
139
  """Raised when command-line arguments cannot be parsed."""
55
140
 
@@ -57,7 +142,7 @@ class ArgumentParserError(ValueError):
57
142
  class MaterializerArgumentParser(argparse.ArgumentParser):
58
143
  """Parse materializer arguments while keeping errors in the JSON contract."""
59
144
 
60
- def error(self, message: str) -> None:
145
+ def error(self, message: str) -> NoReturn:
61
146
  """Raise a reportable parser error instead of writing process output."""
62
147
  raise ArgumentParserError(message)
63
148
 
@@ -407,6 +492,302 @@ def convert_agent(agent: ClaudeAgent) -> str:
407
492
  return content
408
493
 
409
494
 
495
+ def render_codex_failure_blast_radius(rule_content: str) -> str:
496
+ """Extract the repository-instruction contract from the canonical rule.
497
+
498
+ Args:
499
+ rule_content: Canonical failure blast-radius rule text.
500
+
501
+ Returns:
502
+ The fenced repository-instruction excerpt with a trailing newline.
503
+
504
+ Raises:
505
+ MaterializerError: If the canonical rule lacks the required excerpt.
506
+ """
507
+ heading_start = rule_content.find(codex_instruction_section_heading)
508
+ if heading_start < 0:
509
+ raise MaterializerError("failure blast-radius rule requires a Codex excerpt")
510
+ fence_start = rule_content.find("```", heading_start)
511
+ if fence_start < 0:
512
+ raise MaterializerError("failure blast-radius rule requires a Codex excerpt")
513
+ content_start = rule_content.find(line_separator, fence_start)
514
+ fence_end = rule_content.find(line_separator + "```", content_start + 1)
515
+ if content_start < 0 or fence_end < 0:
516
+ raise MaterializerError("failure blast-radius rule requires a complete Codex excerpt")
517
+ return rule_content[content_start + len(line_separator) : fence_end].rstrip() + line_separator
518
+
519
+
520
+ def _build_codex_instruction_projection(config: MaterializerConfig) -> PlannedFile | None:
521
+ """Build the managed AGENTS.md projection when the canonical rule is present."""
522
+ source_path = config.source_root / failure_blast_radius_rule_relative_path
523
+ if not source_path.exists() and not _is_reparse_point(source_path):
524
+ return None
525
+ source_path = _validated_source_file(config, failure_blast_radius_rule_relative_path, "failure blast-radius rule")
526
+ rule_content = source_path.read_text(encoding="utf-8")
527
+ projected_content = render_codex_failure_blast_radius(rule_content)
528
+ return PlannedFile(
529
+ failure_blast_radius_rule_relative_path,
530
+ codex_instruction_target_path,
531
+ projected_content,
532
+ hash_content(projected_content),
533
+ )
534
+
535
+
536
+ def _read_json_object(file_path: Path, description: str) -> dict[str, object]:
537
+ """Read one JSON object and report the required content shape."""
538
+ try:
539
+ parsed_json = json.loads(file_path.read_text(encoding="utf-8"))
540
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
541
+ raise MaterializerError(f"{description} requires readable UTF-8 content: {file_path}") from error
542
+ if not isinstance(parsed_json, dict):
543
+ raise MaterializerError(f"{description} must be a JSON object: {file_path}")
544
+ if not all(isinstance(each_key, str) for each_key in parsed_json):
545
+ raise MaterializerError(f"{description} requires string keys: {file_path}")
546
+ return {each_key: each_record for each_key, each_record in parsed_json.items()}
547
+
548
+
549
+ def _validated_source_file(
550
+ config: MaterializerConfig, relative_path: str, description: str
551
+ ) -> Path:
552
+ """Resolve one source file while rejecting reparse points and escapes."""
553
+ source_path = config.source_root.joinpath(*relative_path.split(path_separator))
554
+ current_path = config.source_root
555
+ for each_part in relative_path.split(path_separator):
556
+ current_path /= each_part
557
+ if current_path.exists() and _is_reparse_point(current_path):
558
+ raise MaterializerRunFatal(
559
+ f"{description} source reparse point is not allowed: {relative_path}"
560
+ )
561
+ resolved_path = _validate_containment(config.source_root, source_path)
562
+ if not resolved_path.is_file():
563
+ raise MaterializerError(f"{description} source file is missing: {relative_path}")
564
+ return resolved_path
565
+
566
+
567
+ def _validated_agent_iterable(candidate: object) -> tuple[ClaudeAgent, ...]:
568
+ """Validate the optional agent iterable used by the legacy call form."""
569
+ if not isinstance(candidate, Iterable):
570
+ raise TypeError("agent collection requires an iterable")
571
+ all_candidate_agents = tuple(candidate)
572
+ if not all(isinstance(each_agent, ClaudeAgent) for each_agent in all_candidate_agents):
573
+ raise TypeError("agent collection requires ClaudeAgent entries")
574
+ return all_candidate_agents
575
+
576
+
577
+ def _validated_planned_file_iterable(candidate: object) -> tuple[PlannedFile, ...]:
578
+ """Validate planned files passed through the legacy publication call form."""
579
+ if not isinstance(candidate, Iterable):
580
+ raise TypeError("planned files require an iterable")
581
+ all_candidate_files = tuple(candidate)
582
+ if not all(isinstance(each_file, PlannedFile) for each_file in all_candidate_files):
583
+ raise TypeError("planned files require PlannedFile entries")
584
+ return all_candidate_files
585
+
586
+
587
+ def _find_codex_hook_source(config: MaterializerConfig) -> Path | None:
588
+ """Find the source hook manifest alongside the compatibility sources."""
589
+ all_candidates = (
590
+ config.source_root / codex_hook_manifest_source_path,
591
+ config.source_root / Path(codex_hook_manifest_source_path).name,
592
+ )
593
+ for each_path in all_candidates:
594
+ if each_path.exists() or _is_reparse_point(each_path):
595
+ relative_path = each_path.relative_to(config.source_root).as_posix()
596
+ return _validated_source_file(config, relative_path, "Codex hook manifest")
597
+ return None
598
+
599
+
600
+ def _resolved_codex_enforcer_command(config: MaterializerConfig) -> str:
601
+ """Build the target-root command for the focused Codex enforcer."""
602
+ script_path = (config.target_root / codex_enforcer_script_relative_path).resolve()
603
+ return f'python3 "{script_path}"'
604
+
605
+
606
+ def _source_codex_enforcer_hook(
607
+ all_source_manifest: dict[str, object], command: str
608
+ ) -> dict[str, object]:
609
+ """Read the source enforcer hook shape and resolve its target command."""
610
+ all_events = all_source_manifest.get("hooks")
611
+ if not isinstance(all_events, dict):
612
+ raise MaterializerError("source Codex hook manifest requires a hooks object")
613
+ all_pre_tool_use = all_events.get(codex_hook_event_name)
614
+ if not isinstance(all_pre_tool_use, list):
615
+ raise MaterializerError("source Codex hook manifest requires a PreToolUse list")
616
+ for each_entry in all_pre_tool_use:
617
+ if not isinstance(each_entry, dict) or each_entry.get("matcher") != codex_hook_matcher:
618
+ continue
619
+ all_hook_records = each_entry.get("hooks")
620
+ if not isinstance(all_hook_records, list):
621
+ continue
622
+ for each_hook in all_hook_records:
623
+ if not isinstance(each_hook, dict):
624
+ continue
625
+ if not _is_code_rules_enforcer_hook(each_hook):
626
+ continue
627
+ resolved_hook = dict(each_hook)
628
+ resolved_hook["command"] = command
629
+ return resolved_hook
630
+ return {
631
+ "type": "command",
632
+ "command": command,
633
+ "timeout": codex_hook_timeout_seconds,
634
+ }
635
+
636
+
637
+ def _hook_records(raw_hooks: object) -> list[dict[str, object]]:
638
+ """Validate and copy a Codex hook-record list."""
639
+ if not isinstance(raw_hooks, list):
640
+ raise MaterializerError("Codex hook manifest entry requires a hooks list")
641
+ if not all(isinstance(each_hook, dict) for each_hook in raw_hooks):
642
+ raise MaterializerError("Codex hook manifest requires valid hook entries")
643
+ return [dict(each_hook) for each_hook in raw_hooks]
644
+
645
+
646
+ def _is_code_rules_enforcer_hook(all_hook_record: dict[str, object]) -> bool:
647
+ """Report whether a hook record names the focused code-rules enforcer."""
648
+ command = str(all_hook_record.get("command", ""))
649
+ normalized_command = command.replace("\\", path_separator)
650
+ all_command_tokens = (
651
+ each_token.strip("\"'")
652
+ for each_token in re.findall(codex_hook_command_token_pattern, normalized_command)
653
+ )
654
+ return any(
655
+ each_token in (codex_enforcer_script_name, codex_enforcer_script_relative_path)
656
+ or each_token.endswith(codex_enforcer_path_suffix)
657
+ for each_token in all_command_tokens
658
+ )
659
+
660
+
661
+ def _codex_enforcer_hooks(all_manifest: object) -> tuple[dict[str, object], ...]:
662
+ """Return enforcer records from the target apply-patch matcher."""
663
+ if not isinstance(all_manifest, dict):
664
+ return ()
665
+ all_events = all_manifest.get("hooks")
666
+ if not isinstance(all_events, dict):
667
+ return ()
668
+ all_pre_tool_use = all_events.get(codex_hook_event_name)
669
+ if not isinstance(all_pre_tool_use, list):
670
+ return ()
671
+ all_enforcer_hooks: list[dict[str, object]] = []
672
+ for each_entry in all_pre_tool_use:
673
+ if not isinstance(each_entry, dict) or each_entry.get("matcher") != codex_hook_matcher:
674
+ continue
675
+ for each_hook in _hook_records(each_entry.get("hooks", [])):
676
+ if _is_code_rules_enforcer_hook(each_hook):
677
+ all_enforcer_hooks.append(each_hook)
678
+ return tuple(all_enforcer_hooks)
679
+
680
+
681
+ def _has_modified_codex_enforcer_hook(
682
+ current_bytes: bytes, planned_content: ManagedContent
683
+ ) -> bool:
684
+ """Report whether an existing enforcer record differs from the plan."""
685
+ try:
686
+ current_manifest = json.loads(current_bytes.decode("utf-8"))
687
+ planned_manifest = json.loads(content_to_bytes(planned_content).decode("utf-8"))
688
+ except (UnicodeDecodeError, json.JSONDecodeError):
689
+ return True
690
+ current_hooks = _codex_enforcer_hooks(current_manifest)
691
+ if not current_hooks:
692
+ return False
693
+ return current_hooks != _codex_enforcer_hooks(planned_manifest)
694
+
695
+
696
+ def _merge_codex_hook_manifest(
697
+ all_target_manifest: dict[str, object], all_focused_hook: dict[str, object]
698
+ ) -> dict[str, object]:
699
+ """Preserve target hook order while merging one deterministic enforcer entry."""
700
+ all_events = all_target_manifest.get("hooks", {})
701
+ if not isinstance(all_events, dict):
702
+ raise MaterializerError("Codex hook manifest requires a hooks object")
703
+ all_pre_tool_use = all_events.get(codex_hook_event_name, [])
704
+ if not isinstance(all_pre_tool_use, list):
705
+ raise MaterializerError("Codex hook manifest requires a PreToolUse list")
706
+ merged_pre_tool_use: list[dict[str, object]] = []
707
+ merged_apply_patch_entry: dict[str, object] | None = None
708
+ merged_hooks: list[dict[str, object]] = []
709
+ for each_entry in all_pre_tool_use:
710
+ if not isinstance(each_entry, dict):
711
+ raise MaterializerRunFatal("Codex hook manifest requires valid matcher entries")
712
+ copied_entry = dict(each_entry)
713
+ if copied_entry.get("matcher") != codex_hook_matcher:
714
+ merged_pre_tool_use.append(copied_entry)
715
+ continue
716
+ all_hook_records = _hook_records(copied_entry.get("hooks", []))
717
+ if merged_apply_patch_entry is None:
718
+ merged_apply_patch_entry = copied_entry
719
+ merged_hooks = [
720
+ each_hook for each_hook in all_hook_records if not _is_code_rules_enforcer_hook(each_hook)
721
+ ]
722
+ merged_apply_patch_entry["hooks"] = merged_hooks
723
+ merged_pre_tool_use.append(merged_apply_patch_entry)
724
+ else:
725
+ merged_hooks.extend(
726
+ each_hook for each_hook in all_hook_records if not _is_code_rules_enforcer_hook(each_hook)
727
+ )
728
+ if merged_apply_patch_entry is None:
729
+ merged_apply_patch_entry = {"matcher": codex_hook_matcher, "hooks": merged_hooks}
730
+ merged_pre_tool_use.append(merged_apply_patch_entry)
731
+ merged_hooks.append(all_focused_hook)
732
+ merged_events = dict(all_events)
733
+ merged_events[codex_hook_event_name] = merged_pre_tool_use
734
+ merged_manifest = dict(all_target_manifest)
735
+ merged_manifest["hooks"] = merged_events
736
+ return merged_manifest
737
+
738
+
739
+ def _build_codex_hook_projection(config: MaterializerConfig) -> PlannedFile | None:
740
+ """Build an additive managed hooks.json projection when its source exists."""
741
+ source_path = _find_codex_hook_source(config)
742
+ if source_path is None:
743
+ return None
744
+ source_manifest = _read_json_object(source_path, "source Codex hook manifest")
745
+ target_path = config.target_root / codex_hook_manifest_target_path
746
+ target_manifest = (
747
+ _read_json_object(target_path, "target Codex hook manifest")
748
+ if target_path.is_file()
749
+ else {"hooks": {}}
750
+ )
751
+ focused_hook = _source_codex_enforcer_hook(
752
+ source_manifest, _resolved_codex_enforcer_command(config)
753
+ )
754
+ projected_manifest = _merge_codex_hook_manifest(target_manifest, focused_hook)
755
+ projected_content = json.dumps(projected_manifest, ensure_ascii=False, indent=manifest_indentation_width) + line_separator
756
+ return PlannedFile(
757
+ codex_hook_manifest_source_path,
758
+ codex_hook_manifest_target_path,
759
+ projected_content,
760
+ hash_content(projected_content),
761
+ action=codex_hook_merge_action,
762
+ )
763
+
764
+
765
+ def _build_codex_hook_dependency_projection(
766
+ config: MaterializerConfig,
767
+ ) -> list[PlannedFile]:
768
+ """Build the reviewed source files required by the target enforcer."""
769
+ all_dependencies: list[PlannedFile] = []
770
+ for each_relative_path in codex_hook_dependency_manifest:
771
+ source_path = _validated_source_file(
772
+ config, each_relative_path, "reviewed Codex hook dependency"
773
+ )
774
+ try:
775
+ dependency_content = source_path.read_bytes()
776
+ except OSError as error:
777
+ raise MaterializerError(
778
+ f"reviewed Codex hook dependency has unreadable bytes: {each_relative_path}"
779
+ ) from error
780
+ all_dependencies.append(
781
+ PlannedFile(
782
+ each_relative_path,
783
+ each_relative_path,
784
+ dependency_content,
785
+ hash_content(dependency_content),
786
+ )
787
+ )
788
+ return all_dependencies
789
+
790
+
410
791
  def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
411
792
  """Discover and parse Markdown agents below the source root.
412
793
 
@@ -437,14 +818,17 @@ def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
437
818
  if _is_reparse_point(each_path):
438
819
  raise MaterializerError(f"source reparse point is not allowed: {each_path}")
439
820
  relative_source = each_path.relative_to(config.source_root).as_posix()
821
+ if relative_source == failure_blast_radius_rule_relative_path:
822
+ _validate_containment(config.source_root, each_path)
823
+ continue
440
824
  _validate_containment(config.source_root, each_path)
441
825
  all_agents.append(parse_frontmatter(each_path, each_path.read_text(encoding="utf-8"), relative_source))
442
826
  return all_agents
443
827
 
444
828
 
445
- def _case_fold_collision_error(target_relative_path: str) -> MaterializerError:
829
+ def _case_fold_collision_error(target_relative_path: str) -> MaterializerRunFatal:
446
830
  """Build the error for two target names that differ only by letter case."""
447
- return MaterializerError(f"case-fold collision: {target_relative_path}")
831
+ return MaterializerRunFatal(f"case-fold collision: {target_relative_path}")
448
832
 
449
833
 
450
834
  def _validate_orphan_target_is_adoptable(
@@ -479,6 +863,13 @@ def _validate_orphan_target_is_adoptable(
479
863
  raise MaterializerError(unmanaged_target_message.format(path=target_relative_path))
480
864
 
481
865
 
866
+ def _configured_manifest_path(config: MaterializerConfig) -> Path:
867
+ """Return the manifest path established during configuration validation."""
868
+ if config.manifest_path is None:
869
+ raise MaterializerError("compatibility manifest path requires configuration")
870
+ return config.manifest_path
871
+
872
+
482
873
  def _build_plan(config: MaterializerConfig, all_agents: Iterable[ClaudeAgent]) -> tuple[list[PlannedFile], MaterializationReport]:
483
874
  """Build planned agent publications and their report.
484
875
 
@@ -495,7 +886,8 @@ def _build_plan(config: MaterializerConfig, all_agents: Iterable[ClaudeAgent]) -
495
886
  report = MaterializationReport()
496
887
  planned: list[PlannedFile] = []
497
888
  target_by_name: dict[str, str] = {}
498
- previous_records = _manifest_record_by_path(load_manifest(config.manifest_path))
889
+ manifest_path = _configured_manifest_path(config)
890
+ previous_records = _manifest_record_by_path(load_manifest(manifest_path))
499
891
  existing_by_name = {
500
892
  each_path.relative_to(config.target_root).as_posix().casefold(): each_path
501
893
  for each_path in config.target_root.rglob("*")
@@ -506,16 +898,45 @@ def _build_plan(config: MaterializerConfig, all_agents: Iterable[ClaudeAgent]) -
506
898
  target_relative_path = _normalize_relative_path(each_agent.name + toml_suffix)
507
899
  folded_path = target_relative_path.casefold()
508
900
  if folded_path in target_by_name:
509
- raise _case_fold_collision_error(target_relative_path)
901
+ raise MaterializerRunFatal(f"case-fold collision: {target_relative_path}")
510
902
  content = convert_agent(each_agent)
511
903
  _validate_orphan_target_is_adoptable(config, existing_by_name.get(folded_path), target_relative_path, content)
512
904
  target_by_name[folded_path] = target_relative_path
513
905
  target_path = validate_target_path(config.target_root, target_relative_path)
514
- if _casefold_normalized_path(target_path) == _casefold_normalized_path(config.manifest_path):
515
- raise MaterializerError("planned target collides with compatibility manifest")
906
+ if _casefold_normalized_path(target_path) == _casefold_normalized_path(manifest_path):
907
+ raise MaterializerRunFatal("planned target collides with compatibility manifest")
516
908
  planned.append(PlannedFile(source_identity, target_relative_path, content, hash_content(content)))
517
909
  report.unsupported += len(each_agent.unsupported)
518
910
  report.details["unsupported"].extend(f"{source_identity}:{each_key}" for each_key in each_agent.unsupported)
911
+ codex_instruction = _build_codex_instruction_projection(config)
912
+ if codex_instruction is not None:
913
+ folded_path = codex_instruction.target_relative_path.casefold()
914
+ if folded_path in target_by_name:
915
+ raise _case_fold_collision_error(codex_instruction.target_relative_path)
916
+ _validate_orphan_target_is_adoptable(
917
+ config,
918
+ existing_by_name.get(folded_path),
919
+ codex_instruction.target_relative_path,
920
+ codex_instruction.content,
921
+ )
922
+ validate_target_path(config.target_root, codex_instruction.target_relative_path)
923
+ planned.append(codex_instruction)
924
+ target_by_name[folded_path] = codex_instruction.target_relative_path
925
+ codex_hooks = _build_codex_hook_projection(config)
926
+ if codex_hooks is not None:
927
+ all_hook_dependencies = _build_codex_hook_dependency_projection(config)
928
+ for each_dependency in all_hook_dependencies:
929
+ folded_dependency_path = each_dependency.target_relative_path.casefold()
930
+ if folded_dependency_path in target_by_name:
931
+ raise _case_fold_collision_error(each_dependency.target_relative_path)
932
+ validate_target_path(config.target_root, each_dependency.target_relative_path)
933
+ target_by_name[folded_dependency_path] = each_dependency.target_relative_path
934
+ planned.append(each_dependency)
935
+ folded_path = codex_hooks.target_relative_path.casefold()
936
+ if folded_path in target_by_name:
937
+ raise _case_fold_collision_error(codex_hooks.target_relative_path)
938
+ validate_target_path(config.target_root, codex_hooks.target_relative_path)
939
+ planned.append(codex_hooks)
519
940
  report.planned_files = planned
520
941
  return planned, report
521
942
 
@@ -542,7 +963,11 @@ def build_plan(config: MaterializerConfig, *all_arguments: object, **all_keyword
542
963
  if supplied_agents is not None:
543
964
  raise TypeError("build_plan received duplicate all_agents")
544
965
  supplied_agents = all_arguments[0]
545
- discovered_agents = discover_agents(config) if supplied_agents is None else supplied_agents
966
+ discovered_agents = (
967
+ discover_agents(config)
968
+ if supplied_agents is None
969
+ else _validated_agent_iterable(supplied_agents)
970
+ )
546
971
  return _build_plan(config, discovered_agents)
547
972
 
548
973
 
@@ -750,6 +1175,7 @@ def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile,
750
1175
 
751
1176
  target absent -> ok: publish
752
1177
  on-disk bytes == planned bytes -> ok: unchanged, no write
1178
+ _has_modified_codex_enforcer_hook -> flag: conflicted, preserved untouched
753
1179
  on-disk hash == manifest hash -> ok: publish, the tool owns these bytes
754
1180
  on-disk hash != manifest hash -> flag: conflicted, preserved untouched
755
1181
 
@@ -770,6 +1196,11 @@ def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile,
770
1196
  if current_bytes == content_to_bytes(planned_file.content):
771
1197
  _record_matching_target(report, planned_file.target_relative_path, previous_record)
772
1198
  return target_path, current_bytes, False
1199
+ if planned_file.action == codex_hook_merge_action:
1200
+ if _has_modified_codex_enforcer_hook(current_bytes, planned_file.content):
1201
+ _record_target_conflict(report, planned_file.target_relative_path, previous_record)
1202
+ return target_path, current_bytes, False
1203
+ return target_path, current_bytes, True
773
1204
  if _is_pristine_managed(previous_record, current_bytes):
774
1205
  return target_path, current_bytes, True
775
1206
  _record_target_conflict(report, planned_file.target_relative_path, previous_record)
@@ -845,9 +1276,10 @@ def _publish_planned_targets(
845
1276
  all_backups: dict[Path, bytes | None],
846
1277
  failure_injector: Callable[[str], None] | None,
847
1278
  ) -> None:
1279
+ manifest_path = _configured_manifest_path(config)
848
1280
  for each_planned_file in all_planned_files:
849
1281
  target_path, current_bytes, is_publishable = _record_target_state(config, each_planned_file, all_previous_records, report)
850
- if _casefold_normalized_path(target_path) == _casefold_normalized_path(config.manifest_path):
1282
+ if _casefold_normalized_path(target_path) == _casefold_normalized_path(manifest_path):
851
1283
  raise MaterializerError("planned target collides with compatibility manifest")
852
1284
  if not is_publishable:
853
1285
  continue
@@ -950,7 +1382,8 @@ def _publish_plan(
950
1382
  publication.planned_files = all_planned_files
951
1383
  if not config.should_apply:
952
1384
  return publication
953
- previous_manifest = load_manifest(config.manifest_path)
1385
+ manifest_path = _configured_manifest_path(config)
1386
+ previous_manifest = load_manifest(manifest_path)
954
1387
  previous_records = _manifest_record_by_path(previous_manifest)
955
1388
  _validate_full_prune_consent(config, all_planned_files, previous_records)
956
1389
  backups: dict[Path, bytes | None] = {}
@@ -962,10 +1395,10 @@ def _publish_plan(
962
1395
  config, all_planned_files, previous_records, publication, backups, failure_injector
963
1396
  )
964
1397
  _remove_stale_files(config, previous_records, all_planned_files, publication, backups)
965
- save_manifest(config.manifest_path, _build_manifest(all_planned_files), failure_injector)
966
- except (OSError, RuntimeError, ValueError) as error:
1398
+ save_manifest(manifest_path, _build_manifest(all_planned_files), failure_injector)
1399
+ except (OSError, RuntimeError, ValueError):
967
1400
  _rollback_publication(backups, publication, initial_written, initial_deleted)
968
- raise error
1401
+ raise
969
1402
  _sort_report_details(publication)
970
1403
  return publication
971
1404
 
@@ -1003,7 +1436,10 @@ def publish_plan(config: MaterializerConfig, *all_arguments: object, **all_keywo
1003
1436
  raise TypeError("publish_plan received duplicate failure injector")
1004
1437
  failure_injector = all_arguments[2]
1005
1438
  publication = report if isinstance(report, MaterializationReport) else MaterializationReport()
1006
- return _publish_plan(config, planned_files, publication, failure_injector)
1439
+ all_planned_files = _validated_planned_file_iterable(planned_files)
1440
+ if failure_injector is not None and not callable(failure_injector):
1441
+ raise TypeError("failure injector requires a callable")
1442
+ return _publish_plan(config, all_planned_files, publication, failure_injector)
1007
1443
 
1008
1444
 
1009
1445
  def _redact_private_paths(
@@ -1086,6 +1522,8 @@ def main(*all_arguments: object) -> int:
1086
1522
  should_apply = options.should_apply
1087
1523
  source_root = options.source_root
1088
1524
  target_root = options.target_root
1525
+ if source_root is None or target_root is None:
1526
+ raise TypeError("materializer roots are required")
1089
1527
  config = MaterializerConfig(
1090
1528
  source_root,
1091
1529
  target_root,