claude-dev-env 2.26.0 → 2.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/clean-coder.md +95 -33
- package/.agents/agents/code-quality-agent.md +85 -24
- package/.agents/agents/pr-description-writer.md +2 -2
- package/.agents/agents/test_agent_frontmatter.py +626 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +47 -1
- package/.agents/skills/hitl/SKILL.md +45 -0
- package/.agents/skills/pr-plain-language-cleanup/SKILL.md +17 -0
- package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install.agents-home.test.mjs +199 -8
- package/bin/install.mjs +15 -8
- package/bin/install.test.mjs +82 -2
- package/commands/sr-loop.md +10 -1
- package/docs/codex-compatibility.md +19 -0
- package/hooks/blocking/luna_fast_mode_gate.py +160 -0
- package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
- package/hooks/hooks.json +10 -5
- package/hooks/hooks_constants/AGENTS.md +1 -1
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
- package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
- package/hooks/session/AGENTS.md +2 -2
- package/hooks/validators/AGENTS.md +2 -1
- package/hooks/validators/conftest.py +21 -4
- package/hooks/validators/mypy_integration.py +77 -16
- package/hooks/validators/run_all_validators.py +2 -35
- package/hooks/validators/system_temporary_roots.py +90 -0
- package/hooks/validators/test_directory_exemption_constants.py +1 -1
- package/hooks/validators/test_mypy_integration.py +169 -0
- package/hooks/validators/test_system_temporary_roots.py +93 -0
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +99 -12
- package/scripts/tests/test_codex_compat_materializer.py +70 -19
|
@@ -38,6 +38,20 @@ codex_enforcer_script_relative_path = "hooks/blocking/code_rules_enforcer.py"
|
|
|
38
38
|
codex_enforcer_script_name = "code_rules_enforcer.py"
|
|
39
39
|
codex_enforcer_path_suffix = path_separator + codex_enforcer_script_relative_path
|
|
40
40
|
codex_hook_command_token_pattern = r'''(?:"[^"]*"|'[^']*'|\S+)'''
|
|
41
|
+
codex_hook_records_field_name = "hooks"
|
|
42
|
+
codex_hook_command_field_name = "command"
|
|
43
|
+
codex_hook_matcher_field_name = "matcher"
|
|
44
|
+
codex_retired_hook_relative_path = "session" + path_separator + "untracked_repo_detector.py"
|
|
45
|
+
codex_retired_hook_commands = frozenset(
|
|
46
|
+
(each_prefix + codex_retired_hook_relative_path).casefold()
|
|
47
|
+
for each_prefix in (
|
|
48
|
+
"", "hooks/", "${CLAUDE_PLUGIN_ROOT}/hooks/", "$CODEX_HOME/hooks/",
|
|
49
|
+
"${CODEX_HOME}/hooks/", "%CODEX_HOME%/hooks/", "$env:CODEX_HOME/hooks/",
|
|
50
|
+
"${env:CODEX_HOME}/hooks/", "~/.codex/hooks/",
|
|
51
|
+
"$HOME/.codex/hooks/", "${HOME}/.codex/hooks/"
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
codex_default_home_hook_path_suffix = "/.codex/hooks/" + codex_retired_hook_relative_path
|
|
41
55
|
codex_hook_merge_action = "merge"
|
|
42
56
|
codex_hook_timeout_seconds = 60
|
|
43
57
|
codex_hook_dependency_manifest = (
|
|
@@ -627,11 +641,11 @@ def _source_codex_enforcer_hook(
|
|
|
627
641
|
if not _is_code_rules_enforcer_hook(each_hook):
|
|
628
642
|
continue
|
|
629
643
|
resolved_hook = dict(each_hook)
|
|
630
|
-
resolved_hook[
|
|
644
|
+
resolved_hook[codex_hook_command_field_name] = command
|
|
631
645
|
return resolved_hook
|
|
632
646
|
return {
|
|
633
647
|
"type": "command",
|
|
634
|
-
|
|
648
|
+
codex_hook_command_field_name: command,
|
|
635
649
|
"timeout": codex_hook_timeout_seconds,
|
|
636
650
|
}
|
|
637
651
|
|
|
@@ -647,7 +661,7 @@ def _hook_records(raw_hooks: object) -> list[dict[str, object]]:
|
|
|
647
661
|
|
|
648
662
|
def _is_code_rules_enforcer_hook(all_hook_record: dict[str, object]) -> bool:
|
|
649
663
|
"""Report whether a hook record names the focused code-rules enforcer."""
|
|
650
|
-
command = str(all_hook_record.get(
|
|
664
|
+
command = str(all_hook_record.get(codex_hook_command_field_name, ""))
|
|
651
665
|
normalized_command = command.replace("\\", path_separator)
|
|
652
666
|
all_command_tokens = (
|
|
653
667
|
each_token.strip("\"'")
|
|
@@ -660,6 +674,64 @@ def _is_code_rules_enforcer_hook(all_hook_record: dict[str, object]) -> bool:
|
|
|
660
674
|
)
|
|
661
675
|
|
|
662
676
|
|
|
677
|
+
def _is_retired_codex_hook_command(command: object) -> bool:
|
|
678
|
+
"""Report whether a command names a known retired hook path."""
|
|
679
|
+
if not isinstance(command, str):
|
|
680
|
+
return False
|
|
681
|
+
normalized_command = command.replace("\\", path_separator)
|
|
682
|
+
return any(
|
|
683
|
+
(
|
|
684
|
+
each_token.strip("\"'").casefold() in codex_retired_hook_commands
|
|
685
|
+
or each_token.strip("\"'").casefold().endswith(codex_default_home_hook_path_suffix)
|
|
686
|
+
)
|
|
687
|
+
for each_token in re.findall(codex_hook_command_token_pattern, normalized_command)
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _prune_retired_codex_hooks(
|
|
692
|
+
all_target_manifest: dict[str, object],
|
|
693
|
+
) -> dict[str, object]:
|
|
694
|
+
"""Remove known retired hook groups from every valid event list."""
|
|
695
|
+
all_events = all_target_manifest.get(codex_hook_records_field_name)
|
|
696
|
+
if not isinstance(all_events, dict):
|
|
697
|
+
return dict(all_target_manifest)
|
|
698
|
+
event_groups_by_name: dict[str, object] = {}
|
|
699
|
+
for each_event_name, each_raw_groups in all_events.items():
|
|
700
|
+
if not isinstance(each_raw_groups, list):
|
|
701
|
+
event_groups_by_name[each_event_name] = each_raw_groups
|
|
702
|
+
continue
|
|
703
|
+
all_kept_groups: list[object] = []
|
|
704
|
+
for each_group in each_raw_groups:
|
|
705
|
+
if not isinstance(each_group, dict):
|
|
706
|
+
all_kept_groups.append(each_group)
|
|
707
|
+
continue
|
|
708
|
+
all_raw_hooks = each_group.get(codex_hook_records_field_name)
|
|
709
|
+
if not isinstance(all_raw_hooks, list):
|
|
710
|
+
all_kept_groups.append(each_group)
|
|
711
|
+
continue
|
|
712
|
+
if not isinstance(each_group.get(codex_hook_matcher_field_name), str):
|
|
713
|
+
all_kept_groups.append(each_group)
|
|
714
|
+
continue
|
|
715
|
+
copied_group = dict(each_group)
|
|
716
|
+
copied_group[codex_hook_records_field_name] = [
|
|
717
|
+
each_hook
|
|
718
|
+
for each_hook in all_raw_hooks
|
|
719
|
+
if not (
|
|
720
|
+
isinstance(each_hook, dict)
|
|
721
|
+
and _is_retired_codex_hook_command(
|
|
722
|
+
each_hook.get(codex_hook_command_field_name)
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
]
|
|
726
|
+
if not all_raw_hooks or copied_group[codex_hook_records_field_name]:
|
|
727
|
+
all_kept_groups.append(copied_group)
|
|
728
|
+
if all_kept_groups:
|
|
729
|
+
event_groups_by_name[each_event_name] = all_kept_groups
|
|
730
|
+
pruned_manifest = dict(all_target_manifest)
|
|
731
|
+
pruned_manifest[codex_hook_records_field_name] = event_groups_by_name
|
|
732
|
+
return pruned_manifest
|
|
733
|
+
|
|
734
|
+
|
|
663
735
|
def _codex_enforcer_hooks(all_manifest: object) -> tuple[dict[str, object], ...]:
|
|
664
736
|
"""Return enforcer records from the target apply-patch matcher."""
|
|
665
737
|
if not isinstance(all_manifest, dict):
|
|
@@ -674,7 +746,13 @@ def _codex_enforcer_hooks(all_manifest: object) -> tuple[dict[str, object], ...]
|
|
|
674
746
|
for each_entry in all_pre_tool_use:
|
|
675
747
|
if not isinstance(each_entry, dict) or each_entry.get("matcher") != codex_hook_matcher:
|
|
676
748
|
continue
|
|
677
|
-
|
|
749
|
+
try:
|
|
750
|
+
all_hook_records = _hook_records(
|
|
751
|
+
each_entry.get(codex_hook_records_field_name, [])
|
|
752
|
+
)
|
|
753
|
+
except MaterializerError:
|
|
754
|
+
continue
|
|
755
|
+
for each_hook in all_hook_records:
|
|
678
756
|
if _is_code_rules_enforcer_hook(each_hook):
|
|
679
757
|
all_enforcer_hooks.append(each_hook)
|
|
680
758
|
return tuple(all_enforcer_hooks)
|
|
@@ -699,43 +777,52 @@ def _merge_codex_hook_manifest(
|
|
|
699
777
|
all_target_manifest: dict[str, object], all_focused_hook: dict[str, object]
|
|
700
778
|
) -> dict[str, object]:
|
|
701
779
|
"""Preserve target hook order while merging one deterministic enforcer entry."""
|
|
702
|
-
all_events = all_target_manifest.get(
|
|
780
|
+
all_events = all_target_manifest.get(codex_hook_records_field_name, {})
|
|
703
781
|
if not isinstance(all_events, dict):
|
|
704
782
|
raise MaterializerError("Codex hook manifest requires a hooks object")
|
|
705
783
|
all_pre_tool_use = all_events.get(codex_hook_event_name, [])
|
|
706
784
|
if not isinstance(all_pre_tool_use, list):
|
|
707
785
|
raise MaterializerError("Codex hook manifest requires a PreToolUse list")
|
|
708
|
-
merged_pre_tool_use: list[
|
|
786
|
+
merged_pre_tool_use: list[object] = []
|
|
709
787
|
merged_apply_patch_entry: dict[str, object] | None = None
|
|
710
788
|
merged_hooks: list[dict[str, object]] = []
|
|
711
789
|
for each_entry in all_pre_tool_use:
|
|
712
790
|
if not isinstance(each_entry, dict):
|
|
713
|
-
|
|
791
|
+
merged_pre_tool_use.append(each_entry)
|
|
792
|
+
continue
|
|
714
793
|
copied_entry = dict(each_entry)
|
|
715
794
|
if copied_entry.get("matcher") != codex_hook_matcher:
|
|
716
795
|
merged_pre_tool_use.append(copied_entry)
|
|
717
796
|
continue
|
|
718
|
-
all_hook_records =
|
|
797
|
+
all_hook_records = copied_entry.get(codex_hook_records_field_name, [])
|
|
798
|
+
if not isinstance(all_hook_records, list) or not all(
|
|
799
|
+
isinstance(each_hook, dict) for each_hook in all_hook_records
|
|
800
|
+
):
|
|
801
|
+
merged_pre_tool_use.append(copied_entry)
|
|
802
|
+
continue
|
|
719
803
|
if merged_apply_patch_entry is None:
|
|
720
804
|
merged_apply_patch_entry = copied_entry
|
|
721
805
|
merged_hooks = [
|
|
722
806
|
each_hook for each_hook in all_hook_records if not _is_code_rules_enforcer_hook(each_hook)
|
|
723
807
|
]
|
|
724
|
-
merged_apply_patch_entry[
|
|
808
|
+
merged_apply_patch_entry[codex_hook_records_field_name] = merged_hooks
|
|
725
809
|
merged_pre_tool_use.append(merged_apply_patch_entry)
|
|
726
810
|
else:
|
|
727
811
|
merged_hooks.extend(
|
|
728
812
|
each_hook for each_hook in all_hook_records if not _is_code_rules_enforcer_hook(each_hook)
|
|
729
813
|
)
|
|
730
814
|
if merged_apply_patch_entry is None:
|
|
731
|
-
merged_apply_patch_entry = {
|
|
815
|
+
merged_apply_patch_entry = {
|
|
816
|
+
"matcher": codex_hook_matcher,
|
|
817
|
+
codex_hook_records_field_name: merged_hooks,
|
|
818
|
+
}
|
|
732
819
|
merged_pre_tool_use.append(merged_apply_patch_entry)
|
|
733
820
|
merged_hooks.append(all_focused_hook)
|
|
734
821
|
merged_events = dict(all_events)
|
|
735
822
|
merged_events[codex_hook_event_name] = merged_pre_tool_use
|
|
736
823
|
merged_manifest = dict(all_target_manifest)
|
|
737
|
-
merged_manifest[
|
|
738
|
-
return merged_manifest
|
|
824
|
+
merged_manifest[codex_hook_records_field_name] = merged_events
|
|
825
|
+
return _prune_retired_codex_hooks(merged_manifest)
|
|
739
826
|
|
|
740
827
|
|
|
741
828
|
def _build_codex_hook_projection(config: MaterializerConfig) -> PlannedFile | None:
|
|
@@ -2,7 +2,7 @@ import json
|
|
|
2
2
|
import subprocess
|
|
3
3
|
import sys
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any, cast
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
import tomllib
|
|
@@ -30,6 +30,23 @@ from codex_compat_materializer import (
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _required_manifest_path(config: MaterializerConfig) -> Path:
|
|
34
|
+
assert config.manifest_path is not None
|
|
35
|
+
return config.manifest_path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _manifest_files(manifest_path: Path) -> dict[str, dict[str, object]]:
|
|
39
|
+
return cast(dict[str, dict[str, object]], load_manifest(manifest_path)["files"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _hook_group(matcher: str, *all_hook_commands: str) -> dict[str, object]:
|
|
43
|
+
return {"matcher": matcher, "hooks": [{"type": "command", "command": each_command} for each_command in all_hook_commands]}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _hook_commands(manifest: dict[str, Any], event_name: str) -> list[str]:
|
|
47
|
+
return [each_hook["command"] for each_group in manifest["hooks"][event_name] for each_hook in each_group["hooks"]]
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
def test_conversion_escapes_toml_content() -> None:
|
|
34
51
|
agent = parse_frontmatter(Path("agent.md"), '---\nname: sample\ndescription: "say \\"hi\\""\ntools: ["Read", "Write"]\n---\n', "agent.md")
|
|
35
52
|
converted = convert_agent(agent)
|
|
@@ -196,7 +213,7 @@ def test_case_folded_target_collisions_include_existing_and_manifest(tmp_path: P
|
|
|
196
213
|
def test_manifest_target_collision_is_rejected(tmp_path: Path) -> None:
|
|
197
214
|
config = MaterializerConfig(tmp_path / "source", tmp_path / "target", should_apply=True)
|
|
198
215
|
planned = [PlannedFile("a.md", "Luna.toml", "managed", "hash")]
|
|
199
|
-
atomic_write(config
|
|
216
|
+
atomic_write(_required_manifest_path(config), '{"files": {"luna.TOML": {}}, "version": 1}\n')
|
|
200
217
|
with pytest.raises(MaterializerError):
|
|
201
218
|
publish_plan(config, planned)
|
|
202
219
|
|
|
@@ -206,12 +223,12 @@ def test_first_run_manifest_target_collision_is_rejected(tmp_path: Path) -> None
|
|
|
206
223
|
target = tmp_path / "target"
|
|
207
224
|
source.mkdir()
|
|
208
225
|
config = MaterializerConfig(source, target, should_apply=True)
|
|
209
|
-
planned = [PlannedFile("a.md", config.
|
|
226
|
+
planned = [PlannedFile("a.md", _required_manifest_path(config).name, "managed", "hash")]
|
|
210
227
|
|
|
211
228
|
with pytest.raises(MaterializerError, match="compatibility manifest"):
|
|
212
229
|
publish_plan(config, planned)
|
|
213
230
|
|
|
214
|
-
assert not config.
|
|
231
|
+
assert not _required_manifest_path(config).exists()
|
|
215
232
|
assert not config.target_root.exists()
|
|
216
233
|
|
|
217
234
|
|
|
@@ -238,11 +255,11 @@ def test_dry_run_is_non_mutating_and_writes_are_idempotent(tmp_path: Path) -> No
|
|
|
238
255
|
planned, report = build_plan(apply_config)
|
|
239
256
|
publish_plan(apply_config, planned, report)
|
|
240
257
|
first = (target / "Luna.toml").read_text(encoding="utf-8")
|
|
241
|
-
first_manifest = apply_config.
|
|
258
|
+
first_manifest = _required_manifest_path(apply_config).read_bytes()
|
|
242
259
|
planned, report = build_plan(apply_config)
|
|
243
260
|
second_report = publish_plan(apply_config, planned, report)
|
|
244
261
|
assert (target / "Luna.toml").read_text(encoding="utf-8") == first
|
|
245
|
-
assert apply_config.
|
|
262
|
+
assert _required_manifest_path(apply_config).read_bytes() == first_manifest
|
|
246
263
|
assert second_report.written == 0
|
|
247
264
|
assert second_report.deleted == 0
|
|
248
265
|
assert second_report.errors == 0
|
|
@@ -292,13 +309,13 @@ def test_manifest_is_published_last_and_previous_manifest_survives_failure(tmp_p
|
|
|
292
309
|
target = tmp_path / "target"
|
|
293
310
|
source.mkdir()
|
|
294
311
|
config = MaterializerConfig(source, target, should_apply=True)
|
|
295
|
-
manifest = config
|
|
312
|
+
manifest = _required_manifest_path(config)
|
|
296
313
|
manifest.parent.mkdir()
|
|
297
314
|
atomic_write(manifest, '{"files": {}, "version": 1}\n')
|
|
298
315
|
planned = [PlannedFile("agent.md", "Luna.toml", "managed", "hash")]
|
|
299
316
|
with pytest.raises(RuntimeError):
|
|
300
317
|
publish_plan(config, planned, failure_injector=lambda _: (_ for _ in ()).throw(RuntimeError("stop")))
|
|
301
|
-
assert
|
|
318
|
+
assert _manifest_files(manifest) == {}
|
|
302
319
|
assert not (target / "Luna.toml").exists()
|
|
303
320
|
|
|
304
321
|
|
|
@@ -399,7 +416,7 @@ def test_report_contains_deterministic_category_details_and_error_count(
|
|
|
399
416
|
assert (target / "Luna.toml").read_text(encoding="utf-8") == "new"
|
|
400
417
|
|
|
401
418
|
(target / "Luna.toml").write_text("changed", encoding="utf-8")
|
|
402
|
-
atomic_write(config
|
|
419
|
+
atomic_write(_required_manifest_path(config), json.dumps({"version": 1, "files": {
|
|
403
420
|
"Luna.toml": {"hash": hash_content(managed_content)}
|
|
404
421
|
}}))
|
|
405
422
|
stale_report = publish_plan(pruning_config, [])
|
|
@@ -407,7 +424,7 @@ def test_report_contains_deterministic_category_details_and_error_count(
|
|
|
407
424
|
assert stale_report.details["stale_managed"] == []
|
|
408
425
|
|
|
409
426
|
(target / "Luna.toml").write_text(managed_content, encoding="utf-8")
|
|
410
|
-
atomic_write(config
|
|
427
|
+
atomic_write(_required_manifest_path(config), json.dumps({"version": 1, "files": {
|
|
411
428
|
"Luna.toml": {"hash": hash_content(managed_content)}
|
|
412
429
|
}}))
|
|
413
430
|
deleted_report = publish_plan(pruning_config, [])
|
|
@@ -423,7 +440,7 @@ def test_report_contains_deterministic_category_details_and_error_count(
|
|
|
423
440
|
assert collision_report.details["conflicted"] == ["Nova.toml"]
|
|
424
441
|
|
|
425
442
|
manifest = {"version": 1, "files": {"Missing.toml": {"hash": hash_content("missing")}}}
|
|
426
|
-
atomic_write(config
|
|
443
|
+
atomic_write(_required_manifest_path(config), json.dumps(manifest))
|
|
427
444
|
error_report = publish_plan(pruning_config, [])
|
|
428
445
|
assert error_report.errors == 1
|
|
429
446
|
assert error_report.error_details == ["missing managed path: Missing.toml"]
|
|
@@ -582,7 +599,7 @@ def test_reapply_rewrites_a_managed_file_whose_source_description_changed(
|
|
|
582
599
|
assert 'description = "Light"' not in published_text
|
|
583
600
|
assert refreshed_report.written == 1
|
|
584
601
|
assert refreshed_report.conflicted == 0
|
|
585
|
-
assert
|
|
602
|
+
assert _manifest_files(_required_manifest_path(config))["Luna.toml"]["hash"] == hash_content(
|
|
586
603
|
published_text
|
|
587
604
|
)
|
|
588
605
|
|
|
@@ -624,7 +641,7 @@ def test_crash_orphan_matching_the_plan_is_adopted_into_the_manifest(tmp_path: P
|
|
|
624
641
|
assert adoption_report.errors == 0
|
|
625
642
|
assert adoption_report.details["adopted"] == ["Nova.toml"]
|
|
626
643
|
assert (target / "Nova.toml").read_text(encoding="utf-8") == orphan_content
|
|
627
|
-
assert
|
|
644
|
+
assert _manifest_files(_required_manifest_path(config))["Nova.toml"]["hash"] == hash_content(
|
|
628
645
|
orphan_content
|
|
629
646
|
)
|
|
630
647
|
|
|
@@ -806,7 +823,7 @@ def test_build_plan_publishes_owned_agents_projection_and_tracks_drift(
|
|
|
806
823
|
|
|
807
824
|
projected_path = target / "AGENTS.md"
|
|
808
825
|
first_projection = projected_path.read_text(encoding="utf-8")
|
|
809
|
-
manifest_record =
|
|
826
|
+
manifest_record = _manifest_files(_required_manifest_path(config))["AGENTS.md"]
|
|
810
827
|
assert manifest_record["ownership"] == "codex-compat"
|
|
811
828
|
assert manifest_record["source"] == "rules/failure-blast-radius.md"
|
|
812
829
|
|
|
@@ -871,6 +888,7 @@ def _prepare_projection_fixture(
|
|
|
871
888
|
existing_hooks = {
|
|
872
889
|
"hooks": {
|
|
873
890
|
"PreToolUse": [
|
|
891
|
+
{"matcher": "apply_patch"},
|
|
874
892
|
{
|
|
875
893
|
"matcher": "Write|Edit",
|
|
876
894
|
"hooks": [{"type": "command", "command": "python existing.py"}],
|
|
@@ -894,6 +912,28 @@ def _prepare_projection_fixture(
|
|
|
894
912
|
],
|
|
895
913
|
},
|
|
896
914
|
],
|
|
915
|
+
"SessionStart": [_hook_group(
|
|
916
|
+
"",
|
|
917
|
+
"python3 %CODEX_HOME%\\hooks\\session\\untracked_repo_detector.py",
|
|
918
|
+
"python3 $env:CODEX_HOME/hooks/session/untracked_repo_detector.py",
|
|
919
|
+
"python3 ${env:CODEX_HOME}/hooks/session/untracked_repo_detector.py",
|
|
920
|
+
"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/fix_worktree_hookspath.py",
|
|
921
|
+
)],
|
|
922
|
+
"UserPromptSubmit": [_hook_group(
|
|
923
|
+
"",
|
|
924
|
+
"python3 /home/example/custom/hooks/session/untracked_repo_detector.py",
|
|
925
|
+
"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py.bak",
|
|
926
|
+
)],
|
|
927
|
+
"ConfigChange": [
|
|
928
|
+
None,
|
|
929
|
+
{"matcher": "partial"},
|
|
930
|
+
{"matcher": "malformed", "hooks": "not a list"},
|
|
931
|
+
{"matcher": "malformed-hook", "hooks": [{"command": 7}]},
|
|
932
|
+
{"hooks": [{"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py"}]},
|
|
933
|
+
_hook_group(
|
|
934
|
+
"retired", "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py"
|
|
935
|
+
),
|
|
936
|
+
],
|
|
897
937
|
"Bash": [{"matcher": "", "hooks": []}],
|
|
898
938
|
}
|
|
899
939
|
}
|
|
@@ -938,7 +978,8 @@ def _assert_projection_and_manifest(
|
|
|
938
978
|
projected_instruction = (target / "AGENTS.md").read_text(encoding="utf-8")
|
|
939
979
|
assert "Three real attempts, then park." in projected_instruction
|
|
940
980
|
manifest = json.loads((target / "hooks.json").read_text(encoding="utf-8"))
|
|
941
|
-
assert manifest["hooks"]["PreToolUse"][0]
|
|
981
|
+
assert manifest["hooks"]["PreToolUse"][0]["matcher"] == "apply_patch"
|
|
982
|
+
assert manifest["hooks"]["PreToolUse"][1] == existing_hooks["hooks"]["PreToolUse"][1]
|
|
942
983
|
apply_patch_entries = [
|
|
943
984
|
each_entry
|
|
944
985
|
for each_entry in manifest["hooks"]["PreToolUse"]
|
|
@@ -953,7 +994,15 @@ def _assert_projection_and_manifest(
|
|
|
953
994
|
f'python3 "{target / "hooks" / "blocking" / "code_rules_enforcer.py"}"'
|
|
954
995
|
)
|
|
955
996
|
assert managed_command in apply_patch_commands
|
|
956
|
-
|
|
997
|
+
expected_commands_by_event = {
|
|
998
|
+
"SessionStart": ["python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/fix_worktree_hookspath.py"],
|
|
999
|
+
"UserPromptSubmit": ["python3 /home/example/custom/hooks/session/untracked_repo_detector.py", "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py.bak"],
|
|
1000
|
+
}
|
|
1001
|
+
for each_event_name, expected_commands in expected_commands_by_event.items():
|
|
1002
|
+
assert _hook_commands(manifest, each_event_name) == expected_commands
|
|
1003
|
+
assert manifest["hooks"]["ConfigChange"] == existing_hooks["hooks"]["ConfigChange"][:5]
|
|
1004
|
+
assert manifest["hooks"]["Bash"] == existing_hooks["hooks"]["Bash"]
|
|
1005
|
+
manifest_record = _manifest_files(target / ".codex-compat-manifest.json")
|
|
957
1006
|
assert manifest_record["AGENTS.md"]["ownership"] == "codex-compat"
|
|
958
1007
|
assert manifest_record["hooks.json"]["source"] == "hooks/hooks.json"
|
|
959
1008
|
|
|
@@ -984,12 +1033,14 @@ def _assert_detached_enforcer_behavior(target: Path) -> None:
|
|
|
984
1033
|
assert allow_run.returncode == 0
|
|
985
1034
|
assert allow_run.stdout == ""
|
|
986
1035
|
|
|
1036
|
+
allow_tool_input = allow_payload["tool_input"]
|
|
1037
|
+
assert isinstance(allow_tool_input, dict)
|
|
1038
|
+
allow_command = allow_tool_input["command"]
|
|
1039
|
+
assert isinstance(allow_command, str)
|
|
987
1040
|
deny_payload = {
|
|
988
1041
|
**allow_payload,
|
|
989
1042
|
"tool_input": {
|
|
990
|
-
"command":
|
|
991
|
-
"AssetItemBlocked", "RuntimeError"
|
|
992
|
-
)
|
|
1043
|
+
"command": allow_command.replace("AssetItemBlocked", "RuntimeError")
|
|
993
1044
|
},
|
|
994
1045
|
}
|
|
995
1046
|
deny_run = subprocess.run(
|