Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Codex MCP entries no longer emit the unsupported `id` setting; reinstalling repairs recorded APM-owned entries while preserving registry identity, user settings, and ownership safeguards. -- by @Pybsama (#3157)
- Git marketplace installs accept versioned plugin directories while rejecting file, symlink, and submodule responses during directory validation. -- by @Pybsama (#3156)
- `apm install` and `apm update` no longer fail with `Object of type mappingproxy is not JSON serializable` when a hook carries nested metadata such as `env`; Claude, Codex, Gemini and Antigravity hook files keep that metadata as plain JSON. (by @abhishekacharya200, with regression coverage from @MohammedAlkindi in #3199, closes #3167) (#3211)
- `apm install` now writes untyped flat command hooks as Claude `"type": "command"` handlers, including alongside Claude-shaped hook files. Explicit types and other targets are unchanged; reinstall to refresh existing entries. -- by @Parth-Vasave (#3153, closes #3130)

## [0.34.0] - 2026-10-16

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,12 @@ Supported targets and where the integrator writes:
APM parses the source into vendor-neutral hook intent, then each target
integrator renders its native schema. Flat command entries become Claude's
required `{ "matcher": "*", "hooks": [...] }` entries in
`.claude/settings.json`. For Codex, flat command entries become intermediate
hook groups containing a nested `hooks` array in `.codex/hooks.json`. Kiro
receives its current v1 standalone schema:
`.claude/settings.json`; a flat command entry that omits `type` receives
Claude's required `"type": "command"`, while explicit handler types are kept
and other targets are unchanged. This default applies only to flat command
entries, not handlers already inside nested Claude groups. For Codex, flat command entries become
intermediate hook groups containing a nested `hooks` array in
`.codex/hooks.json`. Kiro receives its current v1 standalone schema:
`{ "version": "v1", "hooks": [{ "name", "trigger", "matcher", "action" }] }`.
Kiro trigger names are PascalCase and command timeouts remain in seconds.
For Claude, Codex, Gemini, and Antigravity, other handler and hook-group
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ becomes `PostToolUse` in Claude) and rewrites path variables
the correct target-specific form. Kiro materializes one JSON document per
hook action under `.kiro/hooks/`.

For Claude, APM wraps flat command entries in `{ "matcher", "hooks": [...] }`
groups in `.claude/settings.json` and adds the required `"type": "command"`
when a flat command entry omits `type`. Explicit handler types are kept.
Handlers already inside nested Claude groups and other targets are unchanged.

For Codex, APM wraps flat command entries in hook groups containing a
nested `hooks` array in `.codex/hooks.json`.
Claude, Codex, Gemini, and Antigravity preserve nested handler and
Expand Down
17 changes: 17 additions & 0 deletions scripts/architecture_linter/checks/mutation_hook_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ def _check_neutral_hook_contract(provider: FactsProvider) -> Iterable[Violation]
*_nhc_cursor_preflight_unconditional(provider, rule_id),
*_nhc_contract_vocabulary(provider, rule_id),
*_nhc_command_keys(provider, rule_id),
*_nhc_legacy_native_shapes(provider, rule_id),
*_nhc_file_routing(provider, rule_id),
*_nhc_config_writes(provider, rule_id),
*_nhc_ownership_markers(provider, rule_id),
Expand Down Expand Up @@ -762,6 +763,22 @@ def _nhc_command_keys(provider: FactsProvider, rule_id: str) -> tuple[Violation,
return tuple(findings)


def _nhc_legacy_native_shapes(provider: FactsProvider, rule_id: str) -> tuple[Violation, ...]:
"""Ownership matching must obtain legacy shapes from the native renderer."""
facts, failures = checked_facts(provider, _HOOK_INTEGRATOR, rule_id, require_python=True)
if failures:
return failures
return _require(
any(
call.scope == "_integrate_merged_hooks" and call.qualname == "_legacy_hook_entries"
for call in facts.calls
),
rule_id,
_HOOK_INTEGRATOR,
"legacy hook shapes must route through _legacy_hook_entries",
)


def _nhc_file_routing(provider: FactsProvider, rule_id: str) -> tuple[Violation, ...]:
"""Per-file hook routing must not be gated by ``dep_targets_active``."""
findings: list[Violation] = []
Expand Down
13 changes: 6 additions & 7 deletions src/apm_cli/integration/hook_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from apm_cli.integration.hook_cursor_preflight import preflight_cursor_hooks
from apm_cli.integration.hook_file_routing import filter_hook_files_for_target
from apm_cli.integration.hook_native_formats import (
_legacy_hook_entries,
_to_antigravity_hook_entries,
_to_claude_hook_entries,
_to_codex_hook_entries,
Expand Down Expand Up @@ -1362,20 +1363,18 @@ def _integrate_merged_hooks(
event_name = event_map.get(raw_event_name, raw_event_name)
if event_name not in json_config[container]:
json_config[container][event_name] = []
legacy_content_keys: set[str] = set()
legacy_content_keys = {
self._hook_entry_content_key(entry)
for entry in _legacy_hook_entries(entries, config.target_key)
if isinstance(entry, dict)
}
if config.target_key == "claude":
entries = _to_claude_hook_entries(entries)
elif config.target_key == "cursor":
entries = _to_cursor_hook_entries(
entries, event_name, foreign=raw_event_name in event_map
)
elif config.target_key == "codex":
# Match owned flat entries from installs before Codex nesting.
legacy_content_keys = {
self._hook_entry_content_key(entry)
for entry in entries
if isinstance(entry, dict)
}
entries = _to_codex_hook_entries(entries)
elif config.target_key == "gemini":
entries = _to_gemini_hook_entries(entries)
Expand Down
33 changes: 31 additions & 2 deletions src/apm_cli/integration/hook_native_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,37 @@ def _to_gemini_hook_entries(entries: list) -> list:
)


def _to_claude_hook_entries(entries: list) -> list:
"""Render portable bindings in Claude's nested matcher schema."""
def _legacy_hook_entries(entries: list, target: str) -> list:
"""Render old native shapes for ownership matching, never for deployment."""
if target == "claude":
return _to_claude_hook_entries(entries, default_handler_type=False)
# Codex installs before native nesting wrote the original flat entries.
return entries if target == "codex" else []


def _with_claude_default_handler_type(entry: object) -> object:
"""Supply Claude's required ``type`` for an untyped flat command entry.

Only flat entries that the neutral grammar reads as a command handler are
defaulted; explicit types, nested groups, and non-command entries pass
through unchanged.
"""
if not isinstance(entry, dict) or "type" in entry or isinstance(entry.get("hooks"), list):
return entry
if not isinstance(_handler_to_ir(entry, None).command, str):
return entry
return {"type": "command", **entry}


def _to_claude_hook_entries(entries: list, *, default_handler_type: bool = True) -> list:
"""Render portable bindings in Claude's nested matcher schema.

``default_handler_type=False`` reproduces the output of installs before
untyped flat command entries were typed, so owned entries they wrote can
still be matched on reinstall.
"""
if default_handler_type:
entries = [_with_claude_default_handler_type(entry) for entry in entries]
return _render_nested_document(
_entries_to_ir(entries),
timeout_milliseconds=False,
Expand Down
25 changes: 24 additions & 1 deletion tests/integration/test_architecture_contract_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@
_RULES_BY_ID = {rule.id: rule for rule in registered_rules()}


def test_legacy_hook_shapes_boundary_rejects_cleanup_bypass() -> None:
"""A second legacy-shape policy cannot silently bypass the native owner."""
root = Path(__file__).parents[2]
path = "src/apm_cli/integration/hook_integrator.py"
source = (root / path).read_text(encoding="utf-8")
old = "_legacy_hook_entries(entries, config.target_key)"
assert old in source
rule_id = "mutation_writes.neutral_hook_contract"
result = run_selected_rules(
root, (rule_id,), source_overrides={path: source.replace(old, "[]", 1)}
)
assert any(
finding.rule_id == rule_id and "_legacy_hook_entries" in finding.message
for finding in result.violations
)


@pytest.mark.parametrize(
("path", "old", "new"),
[
Expand Down Expand Up @@ -297,13 +314,19 @@ def test_integrator_writes_nested_hook_metadata_to_native_json(
assert source.read_bytes() == original


def test_claude_edge_nests_flat_portable_hook_entries(tmp_path: Path) -> None:
@pytest.mark.parametrize("typed", [True, False], ids=["explicit-type", "default-type"])
def test_claude_edge_nests_flat_portable_hook_entries(tmp_path: Path, typed: bool) -> None:
"""Claude output must wrap portable handlers in matcher/hooks entries."""
from apm_cli.integration.hook_integrator import HookIntegrator
from apm_cli.integration.targets import KNOWN_TARGETS

(tmp_path / ".claude").mkdir()
package_info = _write_portable_hook_package(tmp_path)
if not typed:
hook_path = package_info.install_path / ".apm/hooks/check.json"
hook = json.loads(hook_path.read_text(encoding="utf-8"))
hook["hooks"]["PreToolUse"][0].pop("type")
hook_path.write_text(json.dumps(hook), encoding="utf-8")

HookIntegrator().integrate_hooks_for_target(
KNOWN_TARGETS["claude"],
Expand Down
94 changes: 94 additions & 0 deletions tests/integration/test_required_lifecycle_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1726,6 +1726,100 @@ def test_required_cursor_metadata_survives_install_compile_update(
assert (consumer.root / relative).read_bytes() == data


@pytest.mark.parametrize("with_native_group", [False, True], ids=["standalone", "merged"])
def test_required_claude_untyped_hook_upgrade_preserves_ownership_and_converges(
tmp_path: Path, apm_binary_path: Path, with_native_group: bool
) -> None:
"""Real installs heal pre-fix handlers without executing or claiming user hooks."""
scenario = _new_scenario(tmp_path / "claude-untyped", apm_binary_path)
source = _publish(scenario, "flat-kit", hook_command="echo native")
hook_root = source.repository.worktree / ".apm" / "hooks"
if not with_native_group:
(hook_root / "pretool.json").unlink()
command = "echo must-not-run > hook-executed"
flat = {"command": command, "matcher": "Write", "timeout": 10}
(hook_root / "flat.json").write_text(
json.dumps({"hooks": {"preToolUse": [flat]}}), encoding="ascii"
)
commit = scenario.repositories.commit(source.repository, message="publish untyped flat hook")
consumer = scenario.consumers.create(
"claude-consumer",
dependencies=({**source.dependency, "ref": commit.sha},),
targets=("claude",),
)
scenario.consumers.add_hook(
consumer, "root", {"hooks": {"PreToolUse": [{"command": "echo root"}]}}
)
settings_path = consumer.root / ".claude/settings.json"
sidecar_path = consumer.root / ".claude/apm-hooks.json"
settings_path.parent.mkdir()
user_group = {"matcher": "Read", "hooks": [{"type": "prompt", "prompt": "Keep user hook"}]}
settings_path.write_text(
json.dumps(
{"permissions": {"deny": ["Bash(rm:*)"]}, "hooks": {"PreToolUse": [user_group]}}
),
encoding="ascii",
)
sentinel = consumer.root / ".codex/hooks.json"
sentinel.parent.mkdir()
sentinel.write_bytes(b'{"hooks": {}, "user": "unchanged"}\n')
authored = ArtifactSnapshot.capture(consumer.root / ".apm")

_run_success(
scenario,
consumer,
(*_INSTALL_ARGS, "--target", "claude"),
environment=source.environment,
scenario_id="claude-untyped-install",
)
expected_settings = json.loads(settings_path.read_text(encoding="utf-8"))
expected_sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
groups = expected_settings["hooks"]["PreToolUse"]
handlers = [handler for group in groups for handler in group["hooks"]]
assert {"type": "command", "command": command, "timeout": 10} in handlers
assert {"type": "command", "command": "echo root"} in handlers
assert all(handler["type"] in {"command", "prompt"} for handler in handlers)
assert user_group in groups
assert len(groups) == 3 + int(with_native_group)
assert {entry["_apm_source"] for entry in expected_sidecar["PreToolUse"]} == {
f"{_OWNER}/flat-kit",
"_local/claude-consumer",
}
assert all(entry["hooks"] != user_group["hooks"] for entry in expected_sidecar["PreToolUse"])

# Recreate the previous renderer's untyped output and a renamed root source.
legacy_settings = json.loads(settings_path.read_text(encoding="utf-8"))
legacy_sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
for entries in (legacy_settings["hooks"]["PreToolUse"], legacy_sidecar["PreToolUse"]):
for entry in entries:
for handler in entry["hooks"]:
if handler.get("command") in {command, "echo root"}:
handler.pop("type")
if entry.get("_apm_source") == "_local/claude-consumer":
entry["_apm_source"] = "old-checkout-name"
settings_path.write_text(json.dumps(legacy_settings), encoding="ascii")
sidecar_path.write_text(json.dumps(legacy_sidecar), encoding="ascii")

baseline = None
for cycle in range(2):
_run_success(
scenario,
consumer,
(*_INSTALL_ARGS, "--target", "claude"),
environment=source.environment,
scenario_id=f"claude-untyped-reinstall-{cycle}",
)
assert json.loads(settings_path.read_text(encoding="utf-8")) == expected_settings
assert json.loads(sidecar_path.read_text(encoding="utf-8")) == expected_sidecar
assert sentinel.read_bytes() == b'{"hooks": {}, "user": "unchanged"}\n'
assert not (consumer.root / "hook-executed").exists()
assert_unchanged(authored, ArtifactSnapshot.capture(consumer.root / ".apm"))
snapshot = LifecycleStateSnapshot.capture(consumer.root, targets=("claude",))
if baseline is not None:
_assert_same_state(baseline, snapshot)
baseline = snapshot


@pytest.mark.parametrize("alias", [".safe", "safe.", "foo..bar", "my-skill.v2"])
def test_required_reinstall_is_byte_idempotent_across_durable_state(
tmp_path: Path,
Expand Down
Loading
Loading