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.
- package/docs/codex-compatibility.md +4 -2
- package/hooks/blocking/code_rules_enforcer.py +83 -0
- package/hooks/blocking/codex_apply_patch.py +238 -0
- package/hooks/blocking/plain_language_blocker.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +173 -0
- package/hooks/blocking/test_plain_language_blocker.py +119 -0
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +26 -0
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +455 -17
- package/scripts/tests/test_codex_compat_materializer.py +328 -3
|
@@ -9,14 +9,14 @@ import os
|
|
|
9
9
|
import re
|
|
10
10
|
import stat
|
|
11
11
|
import tempfile
|
|
12
|
-
import
|
|
12
|
+
from collections.abc import Callable, Iterable
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import
|
|
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) ->
|
|
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) ->
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
515
|
-
raise
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
966
|
-
except (OSError, RuntimeError, ValueError)
|
|
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
|
|
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
|
-
|
|
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,
|