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.
Files changed (32) hide show
  1. package/.agents/agents/clean-coder.md +95 -33
  2. package/.agents/agents/code-quality-agent.md +85 -24
  3. package/.agents/agents/pr-description-writer.md +2 -2
  4. package/.agents/agents/test_agent_frontmatter.py +626 -0
  5. package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +47 -1
  6. package/.agents/skills/hitl/SKILL.md +45 -0
  7. package/.agents/skills/pr-plain-language-cleanup/SKILL.md +17 -0
  8. package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
  9. package/bin/ever-shipped-skills.mjs +1 -0
  10. package/bin/install.agents-home.test.mjs +199 -8
  11. package/bin/install.mjs +15 -8
  12. package/bin/install.test.mjs +82 -2
  13. package/commands/sr-loop.md +10 -1
  14. package/docs/codex-compatibility.md +19 -0
  15. package/hooks/blocking/luna_fast_mode_gate.py +160 -0
  16. package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
  17. package/hooks/hooks.json +10 -5
  18. package/hooks/hooks_constants/AGENTS.md +1 -1
  19. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
  20. package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
  21. package/hooks/session/AGENTS.md +2 -2
  22. package/hooks/validators/AGENTS.md +2 -1
  23. package/hooks/validators/conftest.py +21 -4
  24. package/hooks/validators/mypy_integration.py +77 -16
  25. package/hooks/validators/run_all_validators.py +2 -35
  26. package/hooks/validators/system_temporary_roots.py +90 -0
  27. package/hooks/validators/test_directory_exemption_constants.py +1 -1
  28. package/hooks/validators/test_mypy_integration.py +169 -0
  29. package/hooks/validators/test_system_temporary_roots.py +93 -0
  30. package/package.json +1 -1
  31. package/scripts/codex_compat_materializer.py +99 -12
  32. 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["command"] = command
644
+ resolved_hook[codex_hook_command_field_name] = command
631
645
  return resolved_hook
632
646
  return {
633
647
  "type": "command",
634
- "command": command,
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("command", ""))
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
- for each_hook in _hook_records(each_entry.get("hooks", [])):
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("hooks", {})
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[dict[str, object]] = []
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
- raise MaterializerRunFatal("Codex hook manifest requires valid matcher entries")
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 = _hook_records(copied_entry.get("hooks", []))
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["hooks"] = merged_hooks
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 = {"matcher": codex_hook_matcher, "hooks": merged_hooks}
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["hooks"] = merged_events
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.manifest_path, '{"files": {"luna.TOML": {}}, "version": 1}\n')
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.manifest_path.name, "managed", "hash")]
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.manifest_path.exists()
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.manifest_path.read_bytes()
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.manifest_path.read_bytes() == first_manifest
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.manifest_path
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 load_manifest(manifest)["files"] == {}
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.manifest_path, json.dumps({"version": 1, "files": {
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.manifest_path, json.dumps({"version": 1, "files": {
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.manifest_path, json.dumps(manifest))
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 load_manifest(config.manifest_path)["files"]["Luna.toml"]["hash"] == hash_content(
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 load_manifest(config.manifest_path)["files"]["Nova.toml"]["hash"] == hash_content(
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 = load_manifest(config.manifest_path)["files"]["AGENTS.md"]
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] == existing_hooks["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
- manifest_record = load_manifest(target / ".codex-compat-manifest.json")["files"]
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": allow_payload["tool_input"]["command"].replace(
991
- "AssetItemBlocked", "RuntimeError"
992
- )
1043
+ "command": allow_command.replace("AssetItemBlocked", "RuntimeError")
993
1044
  },
994
1045
  }
995
1046
  deny_run = subprocess.run(