From fa69e3cb6d2fc2fde69174d25cf95d384754e524 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 00:15:38 +0200 Subject: [PATCH 01/13] fix(hooks): emit Kiro v1 schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/integrations/ide-tool-integration.md | 8 +- .../author-primitives/hooks-and-commands.md | 10 +- .../skills/apm-usage/package-authoring.md | 6 +- src/apm_cli/integration/hook_integrator.py | 51 +++--- .../integration/kiro_hook_integrator.py | 104 ++++++++----- tests/unit/integration/test_kiro_target.py | 145 ++++++++++++++++-- 6 files changed, 244 insertions(+), 80 deletions(-) diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 79497b9f6..eb67c2efd 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -124,9 +124,11 @@ For server installation patterns, registry resolution, and trust model, see [MCP instructions to `.kiro/steering/` and converts `applyTo:` scoping into Kiro steering frontmatter (`inclusion: fileMatch`); unscoped instructions become `inclusion: always`. Skills are copied verbatim to `.kiro/skills/`, hooks -become one JSON file per hook action in `.kiro/hooks/`, and MCP servers are -written to `.kiro/settings/mcp.json` or `~/.kiro/settings/mcp.json` for -`--global`. +become one Kiro v1 JSON file (`version: "v1"` with a `hooks` array) per hook +action in `.kiro/hooks/`, and MCP servers are written to +`.kiro/settings/mcp.json` or `~/.kiro/settings/mcp.json` for `--global`. +APM maps portable events to Kiro's PascalCase triggers and also accepts native +Kiro v1 hook documents for target-specific triggers. This target covers the documented Kiro IDE layout. Kiro CLI configuration differences are tracked separately; see [the targets matrix](../reference/targets-matrix/#kiro). diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index 26f76d86f..a30e3d8c4 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -78,6 +78,14 @@ key is present but not a JSON object fails closed with a warning; a file that parses cleanly but contributes zero entries also logs a warning so authors notice empty merges during development. +Kiro-targeted packages may also use the native Kiro v1 shape. APM accepts +`{"version": "v1", "hooks": [...]}` and deploys each array entry as a +standalone v1 document. Portable Claude and Copilot events are translated to +Kiro's PascalCase `trigger` names, matcher groups become `matcher`, and command +actions become `action: {"type": "command", ...}`. Native-only triggers such +as `PreTaskExec`, `PostTaskExec`, `PostFileCreate`, `PostFileSave`, and +`PostFileDelete` pass through for Kiro. + The `${PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, and `${KIRO_PLUGIN_ROOT}` tokens resolve to the installed package root and are rewritten per target. Plain `./script.sh` resolves relative to the hook file. If the hook @@ -135,7 +143,7 @@ Supported targets and where the integrator writes: | gemini | `.gemini/settings.json` | merged | | codex | `.codex/hooks.json` | merged | | windsurf | `.windsurf/hooks.json` | merged | -| kiro | `.kiro/hooks/---.json` | one file per hook action | +| kiro | `.kiro/hooks/---.json` | one Kiro v1 file per hook action | | opencode | -- not supported -- | silently skipped | Copilot hook files are namespaced with the source package name to avoid diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 8da8aafc6..835e7d2ae 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -129,7 +129,11 @@ APM automatically normalises event names per target (e.g. `postToolUse` becomes `PostToolUse` in Claude) and rewrites path variables (`${PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`) to the correct target-specific form. Kiro materializes one JSON document per -hook action under `.kiro/hooks/`. +hook action under `.kiro/hooks/` using the Kiro v1 shape: top-level +`"version": "v1"`, an array-valued `"hooks"`, PascalCase `trigger`, optional +`matcher`, and an `action` object. Kiro-targeted packages may author that +native v1 shape directly when they need Kiro-only triggers such as +`PreTaskExec` or `PostFileSave`. When a hook command references a script inside `hooks/` or `.apm/hooks/`, APM deploys that hook source bundle so sibling helper files resolve at diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 412a60f74..df675d43f 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -148,22 +148,28 @@ class _MergeHookConfig: "Stop": "SessionEnd", }, "kiro": { - # Copilot / Claude -> Kiro camelCase events - "PreToolUse": "preToolUse", - "preToolUse": "preToolUse", - "PostToolUse": "postToolUse", - "postToolUse": "postToolUse", - "UserPromptSubmit": "promptSubmit", - "userPromptSubmit": "promptSubmit", - "promptSubmit": "promptSubmit", - "Stop": "agentStop", - "stop": "agentStop", - "AgentStop": "agentStop", - "agentStop": "agentStop", - "PreTaskExecution": "preTaskExecution", - "preTaskExecution": "preTaskExecution", - "PostTaskExecution": "postTaskExecution", - "postTaskExecution": "postTaskExecution", + # Copilot / Claude / legacy Kiro -> Kiro v1 PascalCase events + "PreToolUse": "PreToolUse", + "preToolUse": "PreToolUse", + "PostToolUse": "PostToolUse", + "postToolUse": "PostToolUse", + "UserPromptSubmit": "UserPromptSubmit", + "userPromptSubmit": "UserPromptSubmit", + "promptSubmit": "UserPromptSubmit", + "Stop": "Stop", + "stop": "Stop", + "AgentStop": "Stop", + "agentStop": "Stop", + "PreTaskExecution": "PreTaskExec", + "preTaskExecution": "PreTaskExec", + "PreTaskExec": "PreTaskExec", + "PostTaskExecution": "PostTaskExec", + "postTaskExecution": "PostTaskExec", + "PostTaskExec": "PostTaskExec", + "PostFileCreate": "PostFileCreate", + "PostFileSave": "PostFileSave", + "PostFileDelete": "PostFileDelete", + "SessionStart": "SessionStart", }, } @@ -179,7 +185,7 @@ class _MergeHookConfig: "gemini": "PascalCase", "antigravity": "PascalCase", "windsurf": "PascalCase", - "kiro": "camelCase", + "kiro": "PascalCase", } @@ -627,7 +633,7 @@ def find_hook_files(self, package_path: Path) -> list[Path]: return hook_files - def _parse_hook_json(self, hook_file: Path) -> dict | None: + def _parse_hook_json(self, hook_file: Path, *, allow_kiro_v1: bool = False) -> dict | None: """Parse a hook JSON file and return the data dict. Accepts both the wrapped format (``{"hooks": {EventName: [...]}}``) @@ -639,9 +645,10 @@ def _parse_hook_json(self, hook_file: Path) -> dict | None: Args: hook_file: Path to the hook JSON file + allow_kiro_v1: Accept Kiro's v1 array-valued ``hooks`` shape. Returns: - Optional[Dict]: Parsed JSON dict (always wrapped), or None if invalid + Parsed JSON dict, or None if invalid. """ try: with open(hook_file, encoding="utf-8") as f: @@ -661,6 +668,12 @@ def _parse_hook_json(self, hook_file: Path) -> dict | None: sorted(data.keys()), ) data = {"hooks": data} + if ( + allow_kiro_v1 + and data.get("version") == "v1" + and isinstance(data.get("hooks"), list) + ): + return data # Fail closed on malformed shapes where "hooks" is present but not # a dict (e.g. {"hooks": []}). Downstream code calls .items() on # this value and would otherwise raise AttributeError mid-merge. diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index b1a6acfd0..d4b74cf2a 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -36,32 +36,37 @@ def _safe_hook_slug(value: str, fallback: str = "hook") -> str: return safe or fallback -def _kiro_patterns_from_matcher(matcher: dict) -> list[str]: - """Extract Kiro file patterns from an APM hook matcher, if present.""" +def _kiro_matcher_from_matcher(matcher: dict) -> str | None: + """Extract a Kiro v1 matcher from an APM hook matcher, if present.""" patterns = matcher.get("patterns") if isinstance(patterns, str) and patterns.strip(): - return [patterns.strip()] + return patterns.strip() if isinstance(patterns, list): - return [str(item).strip() for item in patterns if str(item).strip()] + values = [str(item).strip() for item in patterns if str(item).strip()] + return "|".join(values) or None matcher_value = matcher.get("matcher") if isinstance(matcher_value, str) and matcher_value.strip(): - return [matcher_value.strip()] - return [] + return matcher_value.strip() + return None -def _kiro_then_from_action(action: dict, command_keys: tuple[str, ...]) -> dict | None: - """Convert one APM hook action to Kiro's ``then`` object.""" +def _kiro_action_from_action(action: dict, command_keys: tuple[str, ...]) -> dict | None: + """Convert one APM hook action to a Kiro v1 action.""" prompt = action.get("prompt") - if action.get("type") == "askAgent" or isinstance(prompt, str): + if action.get("type") in {"agent", "askAgent"} or isinstance(prompt, str): prompt_text = prompt if isinstance(prompt, str) else action.get("command") if isinstance(prompt_text, str) and prompt_text.strip(): - return {"type": "askAgent", "prompt": prompt_text} + return {"type": "agent", "prompt": prompt_text} return None for key in command_keys: command = action.get(key) if isinstance(command, str) and command.strip(): - return {"type": "runCommand", "command": command} + converted = {"type": "command", "command": command} + timeout = action.get("timeout", action.get("timeoutSec")) + if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): + converted["timeout"] = timeout + return converted return None @@ -81,24 +86,39 @@ def _kiro_actions_from_matcher(matcher: dict, command_keys: tuple[str, ...]) -> def _kiro_hook_document( *, name: str, - description: str | None, event_name: str, - patterns: list[str], - then: dict, + matcher: str | None, + action: dict, ) -> dict: - """Build one Kiro hook JSON document.""" - when: dict[str, object] = {"type": event_name} - if patterns: - when["patterns"] = patterns - doc = { + """Build one Kiro v1 hook JSON document.""" + hook = { "name": name, - "version": "1.0.0", - "when": when, - "then": then, + "trigger": event_name, + "action": action, } - if description: - doc["description"] = description - return doc + if matcher: + hook["matcher"] = matcher + return {"version": "v1", "hooks": [hook]} + + +def _normalize_kiro_v1(data: dict) -> dict: + """Convert a native Kiro v1 document to the internal event-map shape.""" + events: dict[str, list[dict]] = {} + for hook in data.get("hooks", []): + if not isinstance(hook, dict): + continue + trigger = hook.get("trigger") + action = hook.get("action") + if not isinstance(trigger, str) or not isinstance(action, dict): + continue + native_action = dict(action) + if isinstance(hook.get("name"), str): + native_action["_kiro_name"] = hook["name"] + matcher: dict[str, object] = {"hooks": [native_action]} + if isinstance(hook.get("matcher"), str): + matcher["matcher"] = hook["matcher"] + events.setdefault(trigger, []).append(matcher) + return {"hooks": events} def _write_kiro_hook_docs( @@ -120,10 +140,6 @@ def _write_kiro_hook_docs( files_adopted = 0 hooks = rewritten.get("hooks", {}) _emit_hook_event_diagnostics(list(hooks.keys()), "kiro", _KIRO_EVENT_MAP) - description = rewritten.get("description") - if not isinstance(description, str) or not description.strip(): - description = None - per_event_counts: dict[str, int] = {} for raw_event_name, matchers in hooks.items(): if not isinstance(matchers, list): @@ -133,19 +149,18 @@ def _write_kiro_hook_docs( for matcher in matchers: if not isinstance(matcher, dict): continue - patterns = _kiro_patterns_from_matcher(matcher) + kiro_matcher = _kiro_matcher_from_matcher(matcher) for action in _kiro_actions_from_matcher(matcher, integrator.HOOK_COMMAND_KEYS): - then = _kiro_then_from_action(action, integrator.HOOK_COMMAND_KEYS) - if then is None: + kiro_action = _kiro_action_from_action(action, integrator.HOOK_COMMAND_KEYS) + if kiro_action is None: continue per_event_counts[event_name] = per_event_counts.get(event_name, 0) + 1 index = per_event_counts[event_name] doc = _kiro_hook_document( - name=f"{package_name} {event_name} {index}", - description=description, + name=action.get("_kiro_name", f"{package_name} {event_name} {index}"), event_name=event_name, - patterns=patterns, - then=then, + matcher=kiro_matcher, + action=kiro_action, ) target_filename = ( f"{_safe_hook_slug(package_name)}-{_safe_hook_slug(hook_file.stem)}-" @@ -178,7 +193,12 @@ def _write_kiro_hook_docs( target_paths.append(target_path) display_payloads.append( _display_payload( - integrator, target_filename, hook_file, event_name, then, rendered + integrator, + target_filename, + hook_file, + event_name, + kiro_action, + rendered, ) ) return files_integrated, files_skipped, files_adopted @@ -189,13 +209,13 @@ def _display_payload( target_filename: str, hook_file: Path, event_name: str, - then: dict, + action: dict, rendered: str, ) -> dict: """Build install-log metadata for one generated Kiro hook file.""" summary = ( - integrator._summarize_command({"command": then.get("command", "")}) - if then.get("type") == "runCommand" + integrator._summarize_command({"command": action.get("command", "")}) + if action.get("type") == "command" else "asks agent" ) return { @@ -279,9 +299,11 @@ def integrate_kiro_hooks( display_payloads: list = [] for hook_file in hook_files: - data = integrator._parse_hook_json(hook_file) + data = integrator._parse_hook_json(hook_file, allow_kiro_v1=True) if data is None: continue + if isinstance(data.get("hooks"), list): + data = _normalize_kiro_v1(data) rewritten, scripts = integrator._rewrite_hooks_data( data, diff --git a/tests/unit/integration/test_kiro_target.py b/tests/unit/integration/test_kiro_target.py index 138b522e4..703f286a5 100644 --- a/tests/unit/integration/test_kiro_target.py +++ b/tests/unit/integration/test_kiro_target.py @@ -10,7 +10,7 @@ from click.testing import CliRunner from apm_cli.cli import cli -from apm_cli.integration.hook_integrator import HookIntegrator +from apm_cli.integration.hook_integrator import _HOOK_EVENT_MAP, HookIntegrator from apm_cli.integration.instruction_integrator import InstructionIntegrator from apm_cli.integration.skill_integrator import SkillIntegrator from apm_cli.integration.targets import KNOWN_TARGETS @@ -104,6 +104,37 @@ def test_kiro_target_profile_matches_ratified_layout() -> None: assert hooks.format_id == "kiro_hooks" +def test_kiro_v1_event_map_uses_canonical_trigger_names() -> None: + event_map = _HOOK_EVENT_MAP["kiro"] + + assert { + source: event_map[source] + for source in ( + "preToolUse", + "PostToolUse", + "promptSubmit", + "agentStop", + "PreTaskExecution", + "PostTaskExecution", + "PostFileCreate", + "PostFileSave", + "PostFileDelete", + "SessionStart", + ) + } == { + "preToolUse": "PreToolUse", + "PostToolUse": "PostToolUse", + "promptSubmit": "UserPromptSubmit", + "agentStop": "Stop", + "PreTaskExecution": "PreTaskExec", + "PostTaskExecution": "PostTaskExec", + "PostFileCreate": "PostFileCreate", + "PostFileSave": "PostFileSave", + "PostFileDelete": "PostFileDelete", + "SessionStart": "SessionStart", + } + + def test_kiro_steering_maps_apply_to_to_file_match(tmp_path: Path) -> None: (tmp_path / ".kiro").mkdir() package_dir = tmp_path / "pkg" @@ -193,12 +224,14 @@ def test_kiro_hooks_expand_each_apm_hook_to_individual_json(tmp_path: Path) -> N "hooks": { "PreToolUse": [ { + "matcher": "fs_write|str_replace", "hooks": [ { "type": "command", "command": "python ${PLUGIN_ROOT}/hooks/check.py", + "timeout": 10, } - ] + ], } ], "UserPromptSubmit": [ @@ -227,22 +260,43 @@ def test_kiro_hooks_expand_each_apm_hook_to_individual_json(tmp_path: Path) -> N assert result.scripts_copied == 2 pre_tool = tmp_path / ".kiro" / "hooks" / "hookify-hooks-pretooluse-1.json" - prompt_submit = tmp_path / ".kiro" / "hooks" / "hookify-hooks-promptsubmit-1.json" + prompt_submit = tmp_path / ".kiro" / "hooks" / "hookify-hooks-userpromptsubmit-1.json" assert pre_tool.exists() assert prompt_submit.exists() pre_data = json.loads(pre_tool.read_text(encoding="utf-8")) - assert pre_data["when"] == {"type": "preToolUse"} - assert pre_data["then"]["type"] == "runCommand" - assert pre_data["then"]["command"] == "python .kiro/hooks/hookify/hooks/check.py" - assert pre_data["description"] == "Validate before tool use" - assert "hooks" not in pre_data + assert pre_data == { + "version": "v1", + "hooks": [ + { + "name": "hookify PreToolUse 1", + "trigger": "PreToolUse", + "matcher": "fs_write|str_replace", + "action": { + "type": "command", + "command": "python .kiro/hooks/hookify/hooks/check.py", + "timeout": 10, + }, + } + ], + } if sys.platform != "win32": assert pre_tool.stat().st_mode & 0o777 == 0o600 prompt_data = json.loads(prompt_submit.read_text(encoding="utf-8")) - assert prompt_data["when"] == {"type": "promptSubmit"} - assert prompt_data["then"]["command"] == "python .kiro/hooks/hookify/hooks/prompt.py" + assert prompt_data == { + "version": "v1", + "hooks": [ + { + "name": "hookify UserPromptSubmit 1", + "trigger": "UserPromptSubmit", + "action": { + "type": "command", + "command": "python .kiro/hooks/hookify/hooks/prompt.py", + }, + } + ], + } if sys.platform != "win32": assert prompt_submit.stat().st_mode & 0o777 == 0o600 @@ -336,13 +390,21 @@ def test_kiro_hooks_convert_prompt_actions_to_ask_agent(tmp_path: Path) -> None: tmp_path, ) - target = tmp_path / ".kiro" / "hooks" / "prompt-hooks-hooks-promptsubmit-1.json" + target = tmp_path / ".kiro" / "hooks" / "prompt-hooks-hooks-userpromptsubmit-1.json" assert result.files_integrated == 1 data = json.loads(target.read_text(encoding="utf-8")) - assert data["when"] == {"type": "promptSubmit"} - assert data["then"] == { - "type": "askAgent", - "prompt": "Review the submitted prompt for policy drift.", + assert data == { + "version": "v1", + "hooks": [ + { + "name": "prompt-hooks UserPromptSubmit 1", + "trigger": "UserPromptSubmit", + "action": { + "type": "agent", + "prompt": "Review the submitted prompt for policy drift.", + }, + } + ], } if sys.platform != "win32": assert target.stat().st_mode & 0o777 == 0o600 @@ -365,3 +427,56 @@ def test_kiro_hooks_skip_when_project_has_no_kiro_dir(tmp_path: Path) -> None: assert result.files_integrated == 0 assert not (tmp_path / ".kiro").exists() + + +def test_kiro_hooks_accept_native_v1_documents(tmp_path: Path) -> None: + (tmp_path / ".kiro").mkdir() + package_dir = tmp_path / "native-hooks" + hooks_dir = package_dir / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "native.json").write_text( + json.dumps( + { + "version": "v1", + "hooks": [ + { + "name": "check task", + "trigger": "PreTaskExec", + "matcher": "build", + "action": { + "type": "command", + "command": "python ${PLUGIN_ROOT}/hooks/check.py", + "timeout": 15, + }, + } + ], + } + ), + encoding="utf-8", + ) + (hooks_dir / "check.py").write_text("# check\n", encoding="utf-8") + + result = HookIntegrator().integrate_hooks_for_target( + KNOWN_TARGETS["kiro"], + _make_package_info(package_dir, "native-hooks"), + tmp_path, + ) + + target = tmp_path / ".kiro" / "hooks" / "native-hooks-native-pretaskexec-1.json" + assert result.files_integrated == 1 + assert result.scripts_copied == 1 + assert json.loads(target.read_text(encoding="utf-8")) == { + "version": "v1", + "hooks": [ + { + "name": "check task", + "trigger": "PreTaskExec", + "matcher": "build", + "action": { + "type": "command", + "command": "python .kiro/hooks/native-hooks/hooks/check.py", + "timeout": 15, + }, + } + ], + } From 4b5bbd25b8f3be4d536190fd232a1575525ca036 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 00:29:37 +0200 Subject: [PATCH 02/13] docs(spec): cite Kiro v1 hook contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + CONFORMANCE.json | 15 +++++++++++-- CONFORMANCE.md | 3 ++- .../manifests/openapm-v0.1.requirements.yml | 4 ++++ docs/src/content/docs/specs/openapm-v0.1.md | 22 ++++++++++++++++--- tests/spec_conformance/test_manifest_reqs.py | 11 ++++++++++ 6 files changed, 50 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d58656897..1ebdc5780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Kiro hook integration now emits the v1 `hooks` array schema, accepts native v1 hook files, and records the contract in `docs/src/content/docs/specs/openapm-v0.1.md`. (#2095) - `apm install host/org/repo/subpath#ref` on an unrecognised self-hosted FQDN no longer fails with a misleading "not accessible or doesn't exist" error; the failure reason now suggests setting `GITLAB_HOST` / `APM_GITLAB_HOSTS` diff --git a/CONFORMANCE.json b/CONFORMANCE.json index 7ad9ac65e..fdd6f6e0c 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -1096,12 +1096,23 @@ "tests": [ "tests/spec_conformance/test_manifest_reqs.py::test_consumer_deploys_antigravity_rules_with_expected_dedup" ] + }, + { + "conformance_class": "consumer", + "id": "req-tg-006", + "keyword": "MUST", + "section": "8.5", + "status": "active", + "test_count": 1, + "tests": [ + "tests/spec_conformance/test_manifest_reqs.py::test_consumer_deploys_kiro_v1_hook_schema" + ] } ], "spec_version": "v0.1.1", "summary_by_class": { "consumer": { - "active": 68, + "active": 69, "skipped": 1, "unbound": 0, "xfail": 0 @@ -1125,5 +1136,5 @@ "xfail": 0 } }, - "total_requirements": 97 + "total_requirements": 98 } diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 4ec00378a..bffe1bc00 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -19,7 +19,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | Class | Active | Skipped | Xfail | Unbound | |-------|-------:|--------:|------:|--------:| | Producer | 12 | 0 | 0 | 0 | -| Consumer | 68 | 1 | 0 | 0 | +| Consumer | 69 | 1 | 0 | 0 | | Registry | 1 | 0 | 0 | 0 | | Governance | 15 | 0 | 0 | 0 | @@ -124,6 +124,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | [req-tg-003](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-003) | MUST | 8.5 | consumer | active | 1 | | [req-tg-004](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-004) | MUST | 4.2.1 | consumer | active | 1 | | [req-tg-005](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-005) | MUST | 8.5 | consumer | active | 1 | +| [req-tg-006](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-006) | MUST | 8.5 | consumer | active | 1 | ## Waivers diff --git a/docs/public/specs/manifests/openapm-v0.1.requirements.yml b/docs/public/specs/manifests/openapm-v0.1.requirements.yml index faea50652..785edc8e2 100644 --- a/docs/public/specs/manifests/openapm-v0.1.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.1.requirements.yml @@ -352,6 +352,10 @@ requirements: keyword: MUST section: "8.5" conformance_class: consumer + - id: req-tg-006 + keyword: MUST + section: "8.5" + conformance_class: consumer - id: req-sc-001 keyword: MUST section: "10.4" diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 9aba1d131..03b5fb4d9 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -136,7 +136,7 @@ between the companion corpus and the implementation. ### 1.3 Document conventions -- OpenAPM v0.1 carries **97 normative statements** indexed in +- OpenAPM v0.1 carries **98 normative statements** indexed in [Appendix C](#appendix-c-index-of-normative-statements). - All on-disk files defined by this specification are **YAML 1.2** parsed under the safe subset defined in @@ -2030,6 +2030,19 @@ treat only the Antigravity target's expected instruction rule filenames as deployed rules; unrelated `.md` files under `.agents/rules/` MUST NOT suppress instruction content in `AGENTS.md`. + +**[req-tg-006]** A conforming **consumer** implementation that deploys +target-native hook documents for Kiro MUST use the Kiro v1 hook schema and +MUST NOT emit a superseded schema. Each generated document MUST contain a +top-level `"version": "v1"` field and an array-valued `"hooks"` field. Each +hook entry MUST contain `name`, a PascalCase `trigger`, and an `action`; it MAY +contain a `matcher`. A command `action` MAY contain a numeric `timeout` field. +A Kiro consumer MUST NOT emit the superseded `when`/`then` hook shape. The +consumer MUST accept native Kiro v1 hook documents for Kiro-targeted packages +and preserve their supported names, triggers, matchers, action fields, and +command timeouts while applying the same path rewriting and deployment +constraints as portable hook inputs. + ### 8.6 Per-target primitive support (informational) The matrix of which primitive types each target supports is @@ -2042,7 +2055,8 @@ without a spec revision. The current matrix is in the companion - Consumer: [req-pr-001](#req-pr-001), [req-pr-002](#req-pr-002), [req-pr-003](#req-pr-003), [req-tg-001](#req-tg-001), [req-tg-002](#req-tg-002), [req-tg-003](#req-tg-003), - [req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005). + [req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005), + [req-tg-006](#req-tg-006). --- @@ -2941,6 +2955,7 @@ renumbering of conformance classes. | [req-tg-003](#req-tg-003) | MUST | 8.5 | consumer | | [req-tg-004](#req-tg-004) | MUST | 4.2.1 | consumer | | [req-tg-005](#req-tg-005) | MUST | 8.5 | consumer | +| [req-tg-006](#req-tg-006) | MUST | 8.5 | consumer | | [req-sc-001](#req-sc-001) | MUST | 10.4 | consumer | | [req-sc-002](#req-sc-002) | MUST | 10.9 | consumer | | [req-sc-003](#req-sc-003) | MUST | 10.3 | consumer | @@ -2957,7 +2972,7 @@ renumbering of conformance classes. | [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer | | [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer | -**Total normative statements: 97** (92 MUST, 5 SHOULD). +**Total normative statements: 98** (93 MUST, 5 SHOULD). --- @@ -2977,6 +2992,7 @@ renumbering of conformance classes. | 0.1.8 | 2026-06-29 | Normative amendment (semver-zero `0.x` minor) to [req-lk-012]: redefined the `deployed_file_hashes` / `local_deployed_file_hashes` domain from "bytes as written to disk" to the *canonical content* -- UTF-8 text (decodable, no NUL byte) is hashed over its `\r\n` -> `\n` normalized form (a lone `\r` is preserved); binary is hashed raw. This makes the per-deployed-file hash platform-invariant so `apm audit --ci` no longer reports a false `content-integrity` drift when a file is checked out with `\r\n` on Windows (`core.autocrlf=true`) and `\n` on POSIX (apm#1952); it harmonizes `content-integrity` with the drift-replay normalizer. Preserving a bare `\r` keeps the carriage-return smuggling vector hash-visible. [req-lk-017] reworded to re-verify against the [req-lk-012] canonical domain rather than raw on-disk bytes (consistency, not a new obligation). Migration: lockfiles whose hashes were recorded on Windows before this amendment carry `\r\n`-domain hashes; one `apm install` re-records them in the canonical domain. No statement-count change (existing MUST modified, none added); 95 (90 MUST, 5 SHOULD). Subject to the Section 9.3 amendment panel + comment window. | | 0.1.9 | 2026-07-04 | Spec-citation fold for network-free lockfile replay (closes the srobroek Mode-B silent-extension gate on the lockfile-seeded resolver cache). Added [req-rs-015] (Section 7.5, consumer MUST): a non-update install (not `apm update` and not `--refresh`/re-resolution) MUST replay a lockfile entry that records a `resolved_commit` by reusing that recorded commit as the resolution result without issuing any network ref-resolution -- no commits-API query, no `git ls-remote`, no clone -- for that entry, PROVIDED drift detection against the manifest reference does not require re-resolution; on drift or under an explicit `apm update`/`--refresh` ([req-rs-011], [req-rs-012]) the consumer MUST re-resolve over the network as usual. The recorded `resolved_commit` is the lockfile's resolution anchor ([req-lk-003]); replaying it is scoped to entries recording a `resolved_commit` WITHOUT a `resolved_tag` (git-literal and untagged-branch entries per [req-rs-003]), leaves content integrity subject to `tree_sha256` ([req-lk-015]) and `resolved_hash` ([req-lk-013]), and defines drift locally as the manifest `ref` no longer being character-equal to the lockfile `resolved_ref`; this makes a warm install of an already-locked reference network-free at the resolution step, extending the reproducible-and-offline resolution guarantee to commit-pinned and branch-tracking entries not covered by the semver-range equivalence of [req-rs-004]. Section 7.11 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 95 -> 96 (91 MUST, 5 SHOULD). Subject to the Section 9.3 amendment panel + comment window. | | 0.1.10 | 2026-07-04 | Spec-citation fold for Antigravity native instruction rules (closes the #1984 Mode-B silent-extension gate). Added [req-tg-005] (Section 8.5, consumer MUST): Antigravity instruction rules are deployed under `.agents/rules/.md`, `applyTo` is rendered as `trigger: glob` plus `globs` (scalar or sequence), and compile-time deduplication only treats expected Antigravity rule filenames as deployed rules so unrelated `.md` files cannot suppress `AGENTS.md` content. Added `antigravity` to the Section 4.2.1 canonical target set and clarified that `all` excludes explicit-only targets. Statement count: 96 -> 97 (92 MUST, 5 SHOULD). | +| 0.1.11 | 2026-07-10 | Spec-citation fold for Kiro v1 hook documents (closes #2071). Added [req-tg-006] (Section 8.5, consumer MUST): Kiro output uses the Kiro v1 schema with top-level `version: "v1"` plus an array-valued `hooks`, PascalCase triggers, optional matchers, v1 actions, and optional numeric command-action timeouts; the superseded `when`/`then` shape is forbidden; and native Kiro v1 inputs are accepted through the same path-rewrite and deployment constraints. Statement count: 97 -> 98 (93 MUST, 5 SHOULD). | Errata (none at publication). diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py index 3dea916b2..d7378ccf2 100644 --- a/tests/spec_conformance/test_manifest_reqs.py +++ b/tests/spec_conformance/test_manifest_reqs.py @@ -389,6 +389,17 @@ def test_consumer_deploys_antigravity_rules_with_expected_dedup(): ) +@pytest.mark.req("req-tg-006") +def test_consumer_deploys_kiro_v1_hook_schema(): + assert_spec_contains( + "target-native hook documents for Kiro MUST use the Kiro v1 hook schema", + 'top-level `"version": "v1"` field and an array-valued `"hooks"` field', + "command `action` MAY contain a numeric `timeout` field", + "MUST NOT emit the superseded `when`/`then` hook shape", + "MUST accept native Kiro v1 hook documents", + ) + + # --- req-cf-001..002 -------------------------------------------------- From 5f8a6b11e026646df82494358617da81e1990984 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 03:38:05 +0200 Subject: [PATCH 03/13] fix(hooks): match Kiro v1 runtime fields Move timeout to the hook entry, preserve native v1 metadata, and add an empirical install regression test. Addresses review panel findings on Kiro runtime correctness and integration coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 +- .../docs/integrations/ide-tool-integration.md | 3 +- .../author-primitives/hooks-and-commands.md | 21 ++++- docs/src/content/docs/specs/openapm-v0.1.md | 8 +- .../skills/apm-usage/package-authoring.md | 7 +- .../integration/kiro_hook_integrator.py | 46 ++++++++-- .../integration/test_kiro_hook_install_e2e.py | 88 +++++++++++++++++++ tests/spec_conformance/test_manifest_reqs.py | 2 +- tests/unit/integration/test_kiro_target.py | 10 ++- 9 files changed, 168 insertions(+), 22 deletions(-) create mode 100644 tests/integration/test_kiro_hook_install_e2e.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebdc5780..7159e38ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Kiro hook integration now emits the v1 `hooks` array schema, accepts native v1 hook files, and records the contract in `docs/src/content/docs/specs/openapm-v0.1.md`. (#2095) +- Kiro hooks now install in the official v1 runtime shape with matcher and + hook-level timeout support, while native v1 files preserve supported + configuration. The contract is recorded in + `docs/src/content/docs/specs/openapm-v0.1.md`. (#2095) - `apm install host/org/repo/subpath#ref` on an unrecognised self-hosted FQDN no longer fails with a misleading "not accessible or doesn't exist" error; the failure reason now suggests setting `GITLAB_HOST` / `APM_GITLAB_HOSTS` diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index eb67c2efd..d1083e2f6 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -128,7 +128,8 @@ become one Kiro v1 JSON file (`version: "v1"` with a `hooks` array) per hook action in `.kiro/hooks/`, and MCP servers are written to `.kiro/settings/mcp.json` or `~/.kiro/settings/mcp.json` for `--global`. APM maps portable events to Kiro's PascalCase triggers and also accepts native -Kiro v1 hook documents for target-specific triggers. +Kiro v1 hook documents for target-specific triggers. Native hook-level +`description`, `timeout`, and `enabled` fields are preserved. This target covers the documented Kiro IDE layout. Kiro CLI configuration differences are tracked separately; see [the targets matrix](../reference/targets-matrix/#kiro). diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index a30e3d8c4..fb4feb160 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -82,9 +82,24 @@ Kiro-targeted packages may also use the native Kiro v1 shape. APM accepts `{"version": "v1", "hooks": [...]}` and deploys each array entry as a standalone v1 document. Portable Claude and Copilot events are translated to Kiro's PascalCase `trigger` names, matcher groups become `matcher`, and command -actions become `action: {"type": "command", ...}`. Native-only triggers such -as `PreTaskExec`, `PostTaskExec`, `PostFileCreate`, `PostFileSave`, and -`PostFileDelete` pass through for Kiro. +actions become `action: {"type": "command", ...}`. Kiro stores `timeout` beside +`action`, not inside it. Native `description`, `timeout`, and `enabled` fields +are preserved. Native-only triggers such as `PreTaskExec`, `PostTaskExec`, +`PostFileCreate`, `PostFileSave`, and `PostFileDelete` pass through for Kiro: + +```json +{ + "version": "v1", + "hooks": [{ + "name": "lint on save", + "trigger": "PostFileSave", + "matcher": "\\.py$", + "action": {"type": "command", "command": "ruff check ."}, + "timeout": 30, + "enabled": true + }] +} +``` The `${PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, and `${KIRO_PLUGIN_ROOT}` tokens resolve to the installed package root and are rewritten per diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 03b5fb4d9..81b3343cb 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -2036,11 +2036,11 @@ target-native hook documents for Kiro MUST use the Kiro v1 hook schema and MUST NOT emit a superseded schema. Each generated document MUST contain a top-level `"version": "v1"` field and an array-valued `"hooks"` field. Each hook entry MUST contain `name`, a PascalCase `trigger`, and an `action`; it MAY -contain a `matcher`. A command `action` MAY contain a numeric `timeout` field. +contain a `matcher`, `description`, numeric `timeout`, and boolean `enabled`. A Kiro consumer MUST NOT emit the superseded `when`/`then` hook shape. The consumer MUST accept native Kiro v1 hook documents for Kiro-targeted packages -and preserve their supported names, triggers, matchers, action fields, and -command timeouts while applying the same path rewriting and deployment +and preserve their supported names, descriptions, triggers, matchers, actions, +timeouts, and enabled state while applying the same path rewriting and deployment constraints as portable hook inputs. ### 8.6 Per-target primitive support (informational) @@ -2992,7 +2992,7 @@ renumbering of conformance classes. | 0.1.8 | 2026-06-29 | Normative amendment (semver-zero `0.x` minor) to [req-lk-012]: redefined the `deployed_file_hashes` / `local_deployed_file_hashes` domain from "bytes as written to disk" to the *canonical content* -- UTF-8 text (decodable, no NUL byte) is hashed over its `\r\n` -> `\n` normalized form (a lone `\r` is preserved); binary is hashed raw. This makes the per-deployed-file hash platform-invariant so `apm audit --ci` no longer reports a false `content-integrity` drift when a file is checked out with `\r\n` on Windows (`core.autocrlf=true`) and `\n` on POSIX (apm#1952); it harmonizes `content-integrity` with the drift-replay normalizer. Preserving a bare `\r` keeps the carriage-return smuggling vector hash-visible. [req-lk-017] reworded to re-verify against the [req-lk-012] canonical domain rather than raw on-disk bytes (consistency, not a new obligation). Migration: lockfiles whose hashes were recorded on Windows before this amendment carry `\r\n`-domain hashes; one `apm install` re-records them in the canonical domain. No statement-count change (existing MUST modified, none added); 95 (90 MUST, 5 SHOULD). Subject to the Section 9.3 amendment panel + comment window. | | 0.1.9 | 2026-07-04 | Spec-citation fold for network-free lockfile replay (closes the srobroek Mode-B silent-extension gate on the lockfile-seeded resolver cache). Added [req-rs-015] (Section 7.5, consumer MUST): a non-update install (not `apm update` and not `--refresh`/re-resolution) MUST replay a lockfile entry that records a `resolved_commit` by reusing that recorded commit as the resolution result without issuing any network ref-resolution -- no commits-API query, no `git ls-remote`, no clone -- for that entry, PROVIDED drift detection against the manifest reference does not require re-resolution; on drift or under an explicit `apm update`/`--refresh` ([req-rs-011], [req-rs-012]) the consumer MUST re-resolve over the network as usual. The recorded `resolved_commit` is the lockfile's resolution anchor ([req-lk-003]); replaying it is scoped to entries recording a `resolved_commit` WITHOUT a `resolved_tag` (git-literal and untagged-branch entries per [req-rs-003]), leaves content integrity subject to `tree_sha256` ([req-lk-015]) and `resolved_hash` ([req-lk-013]), and defines drift locally as the manifest `ref` no longer being character-equal to the lockfile `resolved_ref`; this makes a warm install of an already-locked reference network-free at the resolution step, extending the reproducible-and-offline resolution guarantee to commit-pinned and branch-tracking entries not covered by the semver-range equivalence of [req-rs-004]. Section 7.11 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 95 -> 96 (91 MUST, 5 SHOULD). Subject to the Section 9.3 amendment panel + comment window. | | 0.1.10 | 2026-07-04 | Spec-citation fold for Antigravity native instruction rules (closes the #1984 Mode-B silent-extension gate). Added [req-tg-005] (Section 8.5, consumer MUST): Antigravity instruction rules are deployed under `.agents/rules/.md`, `applyTo` is rendered as `trigger: glob` plus `globs` (scalar or sequence), and compile-time deduplication only treats expected Antigravity rule filenames as deployed rules so unrelated `.md` files cannot suppress `AGENTS.md` content. Added `antigravity` to the Section 4.2.1 canonical target set and clarified that `all` excludes explicit-only targets. Statement count: 96 -> 97 (92 MUST, 5 SHOULD). | -| 0.1.11 | 2026-07-10 | Spec-citation fold for Kiro v1 hook documents (closes #2071). Added [req-tg-006] (Section 8.5, consumer MUST): Kiro output uses the Kiro v1 schema with top-level `version: "v1"` plus an array-valued `hooks`, PascalCase triggers, optional matchers, v1 actions, and optional numeric command-action timeouts; the superseded `when`/`then` shape is forbidden; and native Kiro v1 inputs are accepted through the same path-rewrite and deployment constraints. Statement count: 97 -> 98 (93 MUST, 5 SHOULD). | +| 0.1.11 | 2026-07-10 | Spec-citation fold for Kiro v1 hook documents (closes #2071). Added [req-tg-006] (Section 8.5, consumer MUST): Kiro output uses the Kiro v1 schema with top-level `version: "v1"` plus an array-valued `hooks`, PascalCase triggers, optional matchers, v1 actions, and optional hook-level description, timeout, and enabled fields; the superseded `when`/`then` shape is forbidden; and native Kiro v1 inputs are accepted through the same path-rewrite and deployment constraints. Statement count: 97 -> 98 (93 MUST, 5 SHOULD). | Errata (none at publication). diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 835e7d2ae..38088a24d 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -131,9 +131,10 @@ becomes `PostToolUse` in Claude) and rewrites path variables the correct target-specific form. Kiro materializes one JSON document per hook action under `.kiro/hooks/` using the Kiro v1 shape: top-level `"version": "v1"`, an array-valued `"hooks"`, PascalCase `trigger`, optional -`matcher`, and an `action` object. Kiro-targeted packages may author that -native v1 shape directly when they need Kiro-only triggers such as -`PreTaskExec` or `PostFileSave`. +`matcher`, an `action` object, and optional hook-level `description`, `timeout`, +and `enabled` fields. Kiro-targeted packages may author that native v1 shape +directly when they need Kiro-only triggers such as `PreTaskExec` or +`PostFileSave`; APM preserves those supported hook-level fields. When a hook command references a script inside `hooks/` or `.apm/hooks/`, APM deploys that hook source bundle so sibling helper files resolve at diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index d4b74cf2a..37d56fd86 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import logging import os import re from pathlib import Path @@ -28,6 +29,7 @@ from apm_cli.integration.hook_integrator import HookIntegrator _KIRO_EVENT_MAP = _HOOK_EVENT_MAP["kiro"] +_log = logging.getLogger(__name__) def _safe_hook_slug(value: str, fallback: str = "hook") -> str: @@ -50,23 +52,37 @@ def _kiro_matcher_from_matcher(matcher: dict) -> str | None: return None +def _with_kiro_hook_fields(converted: dict, source: dict) -> dict: + """Carry hook-level Kiro fields beside an internal action.""" + timeout = source.get("timeout", source.get("timeoutSec")) + if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): + converted["_kiro_timeout"] = timeout + for field in ("description", "enabled"): + if field in source: + converted[f"_kiro_{field}"] = source[field] + for field in ("_kiro_description", "_kiro_timeout", "_kiro_enabled"): + if field in source: + converted[field] = source[field] + return converted + + def _kiro_action_from_action(action: dict, command_keys: tuple[str, ...]) -> dict | None: """Convert one APM hook action to a Kiro v1 action.""" prompt = action.get("prompt") if action.get("type") in {"agent", "askAgent"} or isinstance(prompt, str): prompt_text = prompt if isinstance(prompt, str) else action.get("command") if isinstance(prompt_text, str) and prompt_text.strip(): - return {"type": "agent", "prompt": prompt_text} + return _with_kiro_hook_fields( + {"type": "agent", "prompt": prompt_text}, + action, + ) return None for key in command_keys: command = action.get(key) if isinstance(command, str) and command.strip(): converted = {"type": "command", "command": command} - timeout = action.get("timeout", action.get("timeoutSec")) - if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): - converted["timeout"] = timeout - return converted + return _with_kiro_hook_fields(converted, action) return None @@ -91,11 +107,16 @@ def _kiro_hook_document( action: dict, ) -> dict: """Build one Kiro v1 hook JSON document.""" + action = dict(action) hook = { "name": name, "trigger": event_name, "action": action, } + for field in ("description", "timeout", "enabled"): + value = action.pop(f"_kiro_{field}", None) + if value is not None: + hook[field] = value if matcher: hook["matcher"] = matcher return {"version": "v1", "hooks": [hook]} @@ -114,10 +135,18 @@ def _normalize_kiro_v1(data: dict) -> dict: native_action = dict(action) if isinstance(hook.get("name"), str): native_action["_kiro_name"] = hook["name"] + if isinstance(hook.get("description"), str): + native_action["_kiro_description"] = hook["description"] + timeout = hook.get("timeout") + if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): + native_action["_kiro_timeout"] = timeout + if isinstance(hook.get("enabled"), bool): + native_action["_kiro_enabled"] = hook["enabled"] matcher: dict[str, object] = {"hooks": [native_action]} if isinstance(hook.get("matcher"), str): matcher["matcher"] = hook["matcher"] - events.setdefault(trigger, []).append(matcher) + canonical_trigger = _KIRO_EVENT_MAP.get(trigger, trigger) + events.setdefault(canonical_trigger, []).append(matcher) return {"hooks": events} @@ -303,6 +332,11 @@ def integrate_kiro_hooks( if data is None: continue if isinstance(data.get("hooks"), list): + _log.debug( + "Normalizing %d native Kiro v1 hook(s) from %s", + len(data["hooks"]), + hook_file.name, + ) data = _normalize_kiro_v1(data) rewritten, scripts = integrator._rewrite_hooks_data( diff --git a/tests/integration/test_kiro_hook_install_e2e.py b/tests/integration/test_kiro_hook_install_e2e.py new file mode 100644 index 000000000..dbe40f99e --- /dev/null +++ b/tests/integration/test_kiro_hook_install_e2e.py @@ -0,0 +1,88 @@ +"""End-to-end local install coverage for Kiro v1 hook deployment.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +CLI = [sys.executable, "-m", "apm_cli.cli"] + + +def test_install_transforms_kiro_v1_hook_and_preserves_unrelated_config( + tmp_path: Path, +) -> None: + """A real local install writes the Kiro v1 runtime shape without data loss.""" + consumer = tmp_path / "consumer" + package = tmp_path / "kiro-hooks" + hooks_dir = package / "hooks" + consumer_hooks = consumer / ".kiro" / "hooks" + hooks_dir.mkdir(parents=True) + consumer_hooks.mkdir(parents=True) + + (consumer / "apm.yml").write_text( + "name: consumer\nversion: 1.0.0\ndependencies:\n apm: []\n", + encoding="utf-8", + ) + (package / "apm.yml").write_text( + "name: kiro-hooks\nversion: 1.0.0\n", + encoding="utf-8", + ) + (hooks_dir / "check.py").write_text("print('checked')\n", encoding="utf-8") + (hooks_dir / "hooks.json").write_text( + json.dumps( + { + "hooks": { + "preToolUse": [ + { + "matcher": "write", + "hooks": [ + { + "type": "command", + "command": "python ${PLUGIN_ROOT}/hooks/check.py", + "timeout": 12, + } + ], + } + ] + } + } + ), + encoding="utf-8", + ) + unrelated = {"version": "v1", "hooks": [{"name": "user-owned"}]} + unrelated_path = consumer_hooks / "user-hook.json" + unrelated_path.write_text(json.dumps(unrelated), encoding="utf-8") + + result = subprocess.run( + [*CLI, "install", str(package), "--target", "kiro"], + cwd=consumer, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + assert result.returncode == 0, ( + f"install stdout:\n{result.stdout}\ninstall stderr:\n{result.stderr}" + ) + generated = consumer_hooks / "kiro-hooks-hooks-pretooluse-1.json" + assert json.loads(generated.read_text(encoding="utf-8")) == { + "version": "v1", + "hooks": [ + { + "name": "kiro-hooks PreToolUse 1", + "trigger": "PreToolUse", + "matcher": "write", + "action": { + "type": "command", + "command": "python .kiro/hooks/kiro-hooks/hooks/check.py", + }, + "timeout": 12, + } + ], + } + deployed_script = consumer_hooks / "kiro-hooks" / "hooks" / "check.py" + assert deployed_script.read_text(encoding="utf-8") == "print('checked')\n" + assert json.loads(unrelated_path.read_text(encoding="utf-8")) == unrelated diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py index d7378ccf2..c6dee9b69 100644 --- a/tests/spec_conformance/test_manifest_reqs.py +++ b/tests/spec_conformance/test_manifest_reqs.py @@ -394,7 +394,7 @@ def test_consumer_deploys_kiro_v1_hook_schema(): assert_spec_contains( "target-native hook documents for Kiro MUST use the Kiro v1 hook schema", 'top-level `"version": "v1"` field and an array-valued `"hooks"` field', - "command `action` MAY contain a numeric `timeout` field", + "numeric `timeout`, and boolean `enabled`", "MUST NOT emit the superseded `when`/`then` hook shape", "MUST accept native Kiro v1 hook documents", ) diff --git a/tests/unit/integration/test_kiro_target.py b/tests/unit/integration/test_kiro_target.py index 703f286a5..6e926b43d 100644 --- a/tests/unit/integration/test_kiro_target.py +++ b/tests/unit/integration/test_kiro_target.py @@ -275,8 +275,8 @@ def test_kiro_hooks_expand_each_apm_hook_to_individual_json(tmp_path: Path) -> N "action": { "type": "command", "command": "python .kiro/hooks/hookify/hooks/check.py", - "timeout": 10, }, + "timeout": 10, } ], } @@ -441,12 +441,14 @@ def test_kiro_hooks_accept_native_v1_documents(tmp_path: Path) -> None: "hooks": [ { "name": "check task", + "description": "Validate the task before execution", "trigger": "PreTaskExec", "matcher": "build", + "timeout": 15, + "enabled": False, "action": { "type": "command", "command": "python ${PLUGIN_ROOT}/hooks/check.py", - "timeout": 15, }, } ], @@ -470,12 +472,14 @@ def test_kiro_hooks_accept_native_v1_documents(tmp_path: Path) -> None: "hooks": [ { "name": "check task", + "description": "Validate the task before execution", "trigger": "PreTaskExec", "matcher": "build", + "timeout": 15, + "enabled": False, "action": { "type": "command", "command": "python .kiro/hooks/native-hooks/hooks/check.py", - "timeout": 15, }, } ], From b4f49154889e131b120d778921258969f6edac3b Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 03:44:02 +0200 Subject: [PATCH 04/13] docs(hooks): clarify Kiro v1 behavior Clarify hook-level fields and log unsupported native actions. Addresses final panel polish findings without changing the runtime contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- .../producer/author-primitives/hooks-and-commands.md | 9 +++++---- src/apm_cli/integration/kiro_hook_integrator.py | 5 +++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7159e38ab..e451d8d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Kiro hooks now install in the official v1 runtime shape with matcher and hook-level timeout support, while native v1 files preserve supported - configuration. The contract is recorded in - `docs/src/content/docs/specs/openapm-v0.1.md`. (#2095) + configuration. The contract is recorded in the OpenAPM v0.1 specification. + (#2095) - `apm install host/org/repo/subpath#ref` on an unrecognised self-hosted FQDN no longer fails with a misleading "not accessible or doesn't exist" error; the failure reason now suggests setting `GITLAB_HOST` / `APM_GITLAB_HOSTS` diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index fb4feb160..803764bc5 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -82,10 +82,11 @@ Kiro-targeted packages may also use the native Kiro v1 shape. APM accepts `{"version": "v1", "hooks": [...]}` and deploys each array entry as a standalone v1 document. Portable Claude and Copilot events are translated to Kiro's PascalCase `trigger` names, matcher groups become `matcher`, and command -actions become `action: {"type": "command", ...}`. Kiro stores `timeout` beside -`action`, not inside it. Native `description`, `timeout`, and `enabled` fields -are preserved. Native-only triggers such as `PreTaskExec`, `PostTaskExec`, -`PostFileCreate`, `PostFileSave`, and `PostFileDelete` pass through for Kiro: +actions become `action: {"type": "command", ...}`. In Kiro v1, `timeout` is a +hook-level field alongside `action`. Native `description`, `timeout`, and +`enabled` fields are preserved. Native-only triggers such as `PreTaskExec`, +`PostTaskExec`, `PostFileCreate`, `PostFileSave`, and `PostFileDelete` pass +through for Kiro. For example, a Kiro-only linter hook is: ```json { diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index 37d56fd86..7d30a7287 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -60,6 +60,7 @@ def _with_kiro_hook_fields(converted: dict, source: dict) -> dict: for field in ("description", "enabled"): if field in source: converted[f"_kiro_{field}"] = source[field] + # Native v1 normalization has already namespaced these hook-level fields. for field in ("_kiro_description", "_kiro_timeout", "_kiro_enabled"): if field in source: converted[field] = source[field] @@ -182,6 +183,10 @@ def _write_kiro_hook_docs( for action in _kiro_actions_from_matcher(matcher, integrator.HOOK_COMMAND_KEYS): kiro_action = _kiro_action_from_action(action, integrator.HOOK_COMMAND_KEYS) if kiro_action is None: + _log.debug( + "Skipping unsupported Kiro hook action from %s", + hook_file.name, + ) continue per_event_counts[event_name] = per_event_counts.get(event_name, 0) + 1 index = per_event_counts[event_name] From 383d6e080b0c79985c16dbb4ed2ae7cac3cbac5a Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 03:54:07 +0200 Subject: [PATCH 05/13] fix(hooks): warn on unsupported Kiro actions Bound native v1 support to command and agent actions and warn when a file contributes neither. Addresses the final documentation panel findings with regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 ++-- .../author-primitives/hooks-and-commands.md | 4 ++- docs/src/content/docs/specs/openapm-v0.1.md | 6 ++-- .../integration/kiro_hook_integrator.py | 5 +++ tests/unit/integration/test_kiro_target.py | 34 +++++++++++++++++++ 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e451d8d19..d69d29a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Kiro hooks now install in the official v1 runtime shape with matcher and - hook-level timeout support, while native v1 files preserve supported - configuration. The contract is recorded in the OpenAPM v0.1 specification. - (#2095) + hook-level timeout support, while native v1 files preserve descriptions, + timeout values, and `enabled` toggles. The contract is recorded in the + OpenAPM v0.1 specification. (#2095) - `apm install host/org/repo/subpath#ref` on an unrecognised self-hosted FQDN no longer fails with a misleading "not accessible or doesn't exist" error; the failure reason now suggests setting `GITLAB_HOST` / `APM_GITLAB_HOSTS` diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index 803764bc5..63937e294 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -86,7 +86,9 @@ actions become `action: {"type": "command", ...}`. In Kiro v1, `timeout` is a hook-level field alongside `action`. Native `description`, `timeout`, and `enabled` fields are preserved. Native-only triggers such as `PreTaskExec`, `PostTaskExec`, `PostFileCreate`, `PostFileSave`, and `PostFileDelete` pass -through for Kiro. For example, a Kiro-only linter hook is: +through for Kiro. Native v1 inputs support Kiro's `command` and `agent` action +types; files with no supported actions emit a warning. For example, this +Kiro-only hook runs the Ruff Python linter after a file is saved: ```json { diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 81b3343cb..9fff21218 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -2039,9 +2039,9 @@ hook entry MUST contain `name`, a PascalCase `trigger`, and an `action`; it MAY contain a `matcher`, `description`, numeric `timeout`, and boolean `enabled`. A Kiro consumer MUST NOT emit the superseded `when`/`then` hook shape. The consumer MUST accept native Kiro v1 hook documents for Kiro-targeted packages -and preserve their supported names, descriptions, triggers, matchers, actions, -timeouts, and enabled state while applying the same path rewriting and deployment -constraints as portable hook inputs. +and preserve their supported names, descriptions, triggers, matchers, command or +agent actions, timeouts, and enabled state while applying the same path rewriting +and deployment constraints as portable hook inputs. ### 8.6 Per-target primitive support (informational) diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index 7d30a7287..b233bcfca 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -369,6 +369,11 @@ def integrate_kiro_hooks( files_integrated += written files_skipped += skipped files_adopted += adopted + if written + skipped + adopted == 0: + _log.warning( + "Kiro hook file %s contributed no supported command or agent actions", + hook_file, + ) copied, adopted_scripts = _copy_scripts( integrator, scripts, diff --git a/tests/unit/integration/test_kiro_target.py b/tests/unit/integration/test_kiro_target.py index 6e926b43d..a0fcca9a6 100644 --- a/tests/unit/integration/test_kiro_target.py +++ b/tests/unit/integration/test_kiro_target.py @@ -484,3 +484,37 @@ def test_kiro_hooks_accept_native_v1_documents(tmp_path: Path) -> None: } ], } + + +def test_kiro_native_v1_warns_when_no_actions_are_supported( + tmp_path: Path, + caplog, +) -> None: + (tmp_path / ".kiro").mkdir() + package_dir = tmp_path / "unsupported-hooks" + hooks_dir = package_dir / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "native.json").write_text( + json.dumps( + { + "version": "v1", + "hooks": [ + { + "name": "unsupported", + "trigger": "PostFileSave", + "action": {"type": "future-action"}, + } + ], + } + ), + encoding="utf-8", + ) + + result = HookIntegrator().integrate_hooks_for_target( + KNOWN_TARGETS["kiro"], + _make_package_info(package_dir, "unsupported-hooks"), + tmp_path, + ) + + assert result.files_integrated == 0 + assert "contributed no supported command or agent actions" in caplog.text From c6a3ce9f4214173d26c28cfbbee1d6ba8d25df07 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 19:11:37 +0200 Subject: [PATCH 06/13] test(hooks): align merged Kiro runtime coverage Update integration helpers added on main to assert the Kiro v1 matcher, action, and document contracts rather than removed legacy symbols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_integration_runtime_coverage.py | 166 +++++++++--------- 1 file changed, 86 insertions(+), 80 deletions(-) diff --git a/tests/integration/test_integration_runtime_coverage.py b/tests/integration/test_integration_runtime_coverage.py index 51d3c525c..d0144386e 100644 --- a/tests/integration/test_integration_runtime_coverage.py +++ b/tests/integration/test_integration_runtime_coverage.py @@ -75,100 +75,100 @@ def test_dots_and_dashes_preserved(self) -> None: assert _safe_hook_slug("pre-tool.use") == "pre-tool.use" -class TestKiroPatternsFromMatcher: - """Tests for _kiro_patterns_from_matcher().""" +class TestKiroMatcherFromMatcher: + """Tests for _kiro_matcher_from_matcher().""" def test_string_patterns(self) -> None: - """Single string pattern is returned as a list.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + """Single string pattern is returned unchanged.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher_from_matcher - result = _kiro_patterns_from_matcher({"patterns": "**/*.py"}) - assert result == ["**/*.py"] + result = _kiro_matcher_from_matcher({"patterns": "**/*.py"}) + assert result == "**/*.py" def test_list_patterns(self) -> None: - """List of patterns is returned correctly.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + """List patterns are joined into Kiro's matcher expression.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher_from_matcher - result = _kiro_patterns_from_matcher({"patterns": ["*.ts", "*.js"]}) - assert result == ["*.ts", "*.js"] + result = _kiro_matcher_from_matcher({"patterns": ["*.ts", "*.js"]}) + assert result == "*.ts|*.js" def test_list_patterns_filters_empty(self) -> None: """Empty strings in list patterns are filtered out.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher_from_matcher - result = _kiro_patterns_from_matcher({"patterns": ["*.ts", "", "*.js"]}) - assert result == ["*.ts", "*.js"] + result = _kiro_matcher_from_matcher({"patterns": ["*.ts", "", "*.js"]}) + assert result == "*.ts|*.js" def test_matcher_key_fallback(self) -> None: """Falls back to 'matcher' key when 'patterns' is absent.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher_from_matcher - result = _kiro_patterns_from_matcher({"matcher": "src/**"}) - assert result == ["src/**"] + result = _kiro_matcher_from_matcher({"matcher": "src/**"}) + assert result == "src/**" def test_no_patterns_returns_empty(self) -> None: - """Empty matcher dict returns empty list.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + """Empty matcher dict returns None.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher_from_matcher - result = _kiro_patterns_from_matcher({}) - assert result == [] + result = _kiro_matcher_from_matcher({}) + assert result is None def test_empty_string_patterns_returns_empty(self) -> None: - """Empty-string pattern field returns empty list.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + """Empty-string pattern field returns None.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher_from_matcher - result = _kiro_patterns_from_matcher({"patterns": " "}) - assert result == [] + result = _kiro_matcher_from_matcher({"patterns": " "}) + assert result is None -class TestKiroThenFromAction: - """Tests for _kiro_then_from_action().""" +class TestKiroActionFromAction: + """Tests for _kiro_action_from_action().""" def test_ask_agent_type(self) -> None: - """type=askAgent returns askAgent then object with prompt.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + """type=askAgent becomes a Kiro v1 agent action.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_action_from_action - result = _kiro_then_from_action( + result = _kiro_action_from_action( {"type": "askAgent", "prompt": "Do something"}, command_keys=("bash", "command"), ) - assert result == {"type": "askAgent", "prompt": "Do something"} + assert result == {"type": "agent", "prompt": "Do something"} def test_prompt_string_shorthand(self) -> None: - """Dict with 'prompt' key (no type) maps to askAgent.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + """Dict with 'prompt' key (no type) maps to an agent action.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_action_from_action - result = _kiro_then_from_action( + result = _kiro_action_from_action( {"prompt": "Run tests"}, command_keys=("bash", "command"), ) - assert result == {"type": "askAgent", "prompt": "Run tests"} + assert result == {"type": "agent", "prompt": "Run tests"} def test_bash_command_key(self) -> None: - """Dict with 'bash' key maps to runCommand.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + """Dict with 'bash' key maps to a command action.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_action_from_action - result = _kiro_then_from_action( + result = _kiro_action_from_action( {"bash": "npm test"}, command_keys=("bash", "command"), ) - assert result == {"type": "runCommand", "command": "npm test"} + assert result == {"type": "command", "command": "npm test"} def test_command_key(self) -> None: - """Dict with 'command' key maps to runCommand.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + """Dict with 'command' key maps to a command action.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_action_from_action - result = _kiro_then_from_action( + result = _kiro_action_from_action( {"command": "make lint"}, command_keys=("bash", "command"), ) - assert result == {"type": "runCommand", "command": "make lint"} + assert result == {"type": "command", "command": "make lint"} def test_empty_prompt_returns_none(self) -> None: """askAgent with blank prompt returns None.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + from apm_cli.integration.kiro_hook_integrator import _kiro_action_from_action - result = _kiro_then_from_action( + result = _kiro_action_from_action( {"type": "askAgent", "prompt": " "}, command_keys=("bash",), ) @@ -176,9 +176,9 @@ def test_empty_prompt_returns_none(self) -> None: def test_no_matching_key_returns_none(self) -> None: """Dict with no matching command key returns None.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + from apm_cli.integration.kiro_hook_integrator import _kiro_action_from_action - result = _kiro_then_from_action( + result = _kiro_action_from_action( {"unknown_key": "something"}, command_keys=("bash", "command"), ) @@ -188,50 +188,56 @@ def test_no_matching_key_returns_none(self) -> None: class TestKiroHookDocument: """Tests for _kiro_hook_document().""" - def test_builds_document_with_patterns(self) -> None: - """Document includes patterns in 'when' when provided.""" + def test_builds_document_with_matcher(self) -> None: + """Document includes the Kiro v1 matcher and hook-level fields.""" from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document doc = _kiro_hook_document( - name="my-pkg preToolUse 1", - description="A description", - event_name="preToolUse", - patterns=["**/*.py"], - then={"type": "runCommand", "command": "echo hi"}, + name="my-pkg PreToolUse 1", + event_name="PreToolUse", + matcher="**/*.py", + action={ + "type": "command", + "command": "echo hi", + "_kiro_description": "A description", + }, ) - assert doc["name"] == "my-pkg preToolUse 1" - assert doc["version"] == "1.0.0" - assert doc["when"]["type"] == "preToolUse" - assert doc["when"]["patterns"] == ["**/*.py"] - assert doc["then"] == {"type": "runCommand", "command": "echo hi"} - assert doc["description"] == "A description" - - def test_builds_document_without_patterns(self) -> None: - """Document omits 'patterns' from 'when' when list is empty.""" + assert doc == { + "version": "v1", + "hooks": [ + { + "name": "my-pkg PreToolUse 1", + "trigger": "PreToolUse", + "matcher": "**/*.py", + "description": "A description", + "action": {"type": "command", "command": "echo hi"}, + } + ], + } + + def test_builds_document_without_matcher(self) -> None: + """Document omits matcher when no expression is supplied.""" from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document doc = _kiro_hook_document( name="pkg hook 1", - description=None, - event_name="postToolUse", - patterns=[], - then={"type": "askAgent", "prompt": "Check"}, + event_name="PostToolUse", + matcher=None, + action={"type": "agent", "prompt": "Check"}, ) - assert "patterns" not in doc["when"] - assert "description" not in doc + assert "matcher" not in doc["hooks"][0] def test_no_description_omitted(self) -> None: - """None description is omitted from the document.""" + """An absent namespaced description is omitted.""" from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document doc = _kiro_hook_document( name="x", - description=None, - event_name="ev", - patterns=[], - then={"type": "runCommand", "command": "ls"}, + event_name="Event", + matcher=None, + action={"type": "command", "command": "ls"}, ) - assert "description" not in doc + assert "description" not in doc["hooks"][0] # --------------------------------------------------------------------------- @@ -316,7 +322,8 @@ def test_writes_hook_document(self, tmp_path: Path) -> None: json_files = list(hooks_dir.glob("*.json")) assert len(json_files) == 1 doc = json.loads(json_files[0].read_text(encoding="utf-8")) - assert doc["then"]["type"] == "runCommand" + assert doc["version"] == "v1" + assert doc["hooks"][0]["action"]["type"] == "command" def test_adopts_identical_existing_file(self, tmp_path: Path) -> None: """When file already has identical content, it is adopted not re-written.""" @@ -347,11 +354,10 @@ def test_adopts_identical_existing_file(self, tmp_path: Path) -> None: from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document, _safe_hook_slug doc = _kiro_hook_document( - name="my-pkg preToolUse 1", - description=None, - event_name="preToolUse", - patterns=[], - then={"type": "runCommand", "command": "make test"}, + name="my-pkg PreToolUse 1", + event_name="PreToolUse", + matcher=None, + action={"type": "command", "command": "make test"}, ) expected_filename = ( f"{_safe_hook_slug('my-pkg')}-{_safe_hook_slug('hooks')}-" From ee4fbaf5e84542575a7235df4f41aba56b328d42 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 20:03:17 +0200 Subject: [PATCH 07/13] test(hooks): strengthen Kiro v1 install proof Fold panel follow-ups by exercising command and agent actions through the real install flow, clarifying internal transport fields and warnings, and synchronizing user and agent documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 +++---- .../docs/integrations/ide-tool-integration.md | 3 ++- .../author-primitives/hooks-and-commands.md | 3 ++- .../content/docs/reference/targets-matrix.md | 4 +++- .../skills/apm-usage/package-authoring.md | 7 ++++--- .../integration/kiro_hook_integrator.py | 5 ++++- .../integration/test_kiro_hook_install_e2e.py | 21 ++++++++++++++++++- 7 files changed, 39 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3928836..236d7f289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Kiro hooks now install in the official v1 runtime shape with matcher and - hook-level timeout support, while native v1 files preserve descriptions, - timeout values, and `enabled` toggles. The contract is recorded in the - OpenAPM v0.1 specification. (#2095) +- Kiro hooks now support matchers, hook-level timeouts, and `enabled` toggles + through the official v1 runtime shape, while native v1 files preserve their + supported fields. The contract is recorded in the OpenAPM v0.1 + specification. (#2095) ## [0.24.1] - 2026-07-10 diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index d1083e2f6..8c4134df0 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -129,7 +129,8 @@ action in `.kiro/hooks/`, and MCP servers are written to `.kiro/settings/mcp.json` or `~/.kiro/settings/mcp.json` for `--global`. APM maps portable events to Kiro's PascalCase triggers and also accepts native Kiro v1 hook documents for target-specific triggers. Native hook-level -`description`, `timeout`, and `enabled` fields are preserved. +`description`, `timeout`, and `enabled` fields are preserved. Portable +`askAgent` actions become Kiro v1 `agent` actions. This target covers the documented Kiro IDE layout. Kiro CLI configuration differences are tracked separately; see [the targets matrix](../reference/targets-matrix/#kiro). diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index 63937e294..930302eb9 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -84,7 +84,8 @@ standalone v1 document. Portable Claude and Copilot events are translated to Kiro's PascalCase `trigger` names, matcher groups become `matcher`, and command actions become `action: {"type": "command", ...}`. In Kiro v1, `timeout` is a hook-level field alongside `action`. Native `description`, `timeout`, and -`enabled` fields are preserved. Native-only triggers such as `PreTaskExec`, +`enabled` fields are preserved. Portable `askAgent` actions become Kiro v1 +`agent` actions with the prompt preserved. Native-only triggers such as `PreTaskExec`, `PostTaskExec`, `PostFileCreate`, `PostFileSave`, and `PostFileDelete` pass through for Kiro. Native v1 inputs support Kiro's `command` and `agent` action types; files with no supported actions emit a warning. For example, this diff --git a/docs/src/content/docs/reference/targets-matrix.md b/docs/src/content/docs/reference/targets-matrix.md index e091827f5..399a4f208 100644 --- a/docs/src/content/docs/reference/targets-matrix.md +++ b/docs/src/content/docs/reference/targets-matrix.md @@ -207,7 +207,9 @@ Kiro IDE. - **File conventions.** - instructions: `.kiro/steering/.md` with `inclusion: always` or `inclusion: fileMatch` frontmatter - skills: `.kiro/skills//SKILL.md` - - hooks: one JSON file per hook action under `.kiro/hooks/` + - hooks: one Kiro v1 document per hook action under `.kiro/hooks/`, with + top-level `version: "v1"` and a `hooks` array; see + [Hooks and commands](../producer/author-primitives/hooks-and-commands/) - mcp: `.kiro/settings/mcp.json` (project) or `~/.kiro/settings/mcp.json` (user) - **MCP shape.** JSON `mcpServers` entries use `command`/`args`/`env` for stdio and `url`/`headers` for remote servers. Kiro resolves `${VAR}` placeholders at runtime, so APM preserves them rather than writing secrets to disk. - **Scope.** This is the documented Kiro IDE layout only. Kiro CLI differences are tracked separately and are not part of this target. diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 38088a24d..1eb262012 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -132,9 +132,10 @@ the correct target-specific form. Kiro materializes one JSON document per hook action under `.kiro/hooks/` using the Kiro v1 shape: top-level `"version": "v1"`, an array-valued `"hooks"`, PascalCase `trigger`, optional `matcher`, an `action` object, and optional hook-level `description`, `timeout`, -and `enabled` fields. Kiro-targeted packages may author that native v1 shape -directly when they need Kiro-only triggers such as `PreTaskExec` or -`PostFileSave`; APM preserves those supported hook-level fields. +and `enabled` fields. Portable `askAgent` actions become Kiro v1 `agent` +actions. Kiro-targeted packages may author that native v1 shape directly when +they need Kiro-only triggers such as `PreTaskExec` or `PostFileSave`; APM +preserves those supported hook-level fields. When a hook command references a script inside `hooks/` or `.apm/hooks/`, APM deploys that hook source bundle so sibling helper files resolve at diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index b233bcfca..01263f75f 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -31,6 +31,9 @@ _KIRO_EVENT_MAP = _HOOK_EVENT_MAP["kiro"] _log = logging.getLogger(__name__) +# Internal _kiro_* transport keys are injected during normalization and +# consumed by _kiro_hook_document before serialization. + def _safe_hook_slug(value: str, fallback: str = "hook") -> str: """Return a stable lowercase slug for generated Kiro hook filenames.""" @@ -372,7 +375,7 @@ def integrate_kiro_hooks( if written + skipped + adopted == 0: _log.warning( "Kiro hook file %s contributed no supported command or agent actions", - hook_file, + hook_file.name, ) copied, adopted_scripts = _copy_scripts( integrator, diff --git a/tests/integration/test_kiro_hook_install_e2e.py b/tests/integration/test_kiro_hook_install_e2e.py index dbe40f99e..60556bf08 100644 --- a/tests/integration/test_kiro_hook_install_e2e.py +++ b/tests/integration/test_kiro_hook_install_e2e.py @@ -42,7 +42,11 @@ def test_install_transforms_kiro_v1_hook_and_preserves_unrelated_config( "type": "command", "command": "python ${PLUGIN_ROOT}/hooks/check.py", "timeout": 12, - } + }, + { + "type": "askAgent", + "prompt": "Check this write for policy drift.", + }, ], } ] @@ -85,4 +89,19 @@ def test_install_transforms_kiro_v1_hook_and_preserves_unrelated_config( } deployed_script = consumer_hooks / "kiro-hooks" / "hooks" / "check.py" assert deployed_script.read_text(encoding="utf-8") == "print('checked')\n" + generated_agent = consumer_hooks / "kiro-hooks-hooks-pretooluse-2.json" + assert json.loads(generated_agent.read_text(encoding="utf-8")) == { + "version": "v1", + "hooks": [ + { + "name": "kiro-hooks PreToolUse 2", + "trigger": "PreToolUse", + "matcher": "write", + "action": { + "type": "agent", + "prompt": "Check this write for policy drift.", + }, + } + ], + } assert json.loads(unrelated_path.read_text(encoding="utf-8")) == unrelated From a9cecce3507199095053b98874d98c30f0df4bb2 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 20:20:22 +0200 Subject: [PATCH 08/13] docs(hooks): centralize Kiro v1 mappings Fold the final documentation panel follow-ups by linking the overview to the canonical authoring guide and showing a portable matcher input. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/integrations/ide-tool-integration.md | 6 +++--- .../docs/producer/author-primitives/hooks-and-commands.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 8c4134df0..b1735e84f 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -128,9 +128,9 @@ become one Kiro v1 JSON file (`version: "v1"` with a `hooks` array) per hook action in `.kiro/hooks/`, and MCP servers are written to `.kiro/settings/mcp.json` or `~/.kiro/settings/mcp.json` for `--global`. APM maps portable events to Kiro's PascalCase triggers and also accepts native -Kiro v1 hook documents for target-specific triggers. Native hook-level -`description`, `timeout`, and `enabled` fields are preserved. Portable -`askAgent` actions become Kiro v1 `agent` actions. +Kiro v1 hook documents for target-specific triggers. See +[Hooks and commands](../producer/author-primitives/hooks-and-commands/) for +the supported fields and action mappings. This target covers the documented Kiro IDE layout. Kiro CLI configuration differences are tracked separately; see [the targets matrix](../reference/targets-matrix/#kiro). diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index 930302eb9..18ed95543 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -47,6 +47,7 @@ Claude (`PreToolUse`, `PostToolUse`) and Copilot (`preToolUse`, "hooks": { "PreToolUse": [ { + "matcher": "**/*.py", "hooks": [ {"type": "command", "command": "${PLUGIN_ROOT}/scripts/validate.sh", "timeout": 10} ] From 9b794fd5f68f546d2aafe0059bf5a67f9fdb83fd Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 11 Jul 2026 19:20:41 +0200 Subject: [PATCH 09/13] refactor(kiro): consume native v1 directly, drop _kiro_* side channels Re-scope the Kiro hook target as an exact-Kiro v1 integrator. Native Kiro v1 documents are now read and re-emitted directly into Kiro-native results instead of round-tripping through the Claude-shaped event map via internal _kiro_* transport keys. - Remove _normalize_kiro_v1 and _with_kiro_hook_fields (side channels). - _kiro_action_from_action is a pure Kiro-native action converter. - _kiro_hook_document takes explicit native fields (name/trigger/matcher/ description/timeout/enabled), no popping of namespaced keys. - Native path rewrites each command via _rewrite_command_for_target (same security primitive the portable path uses), dedups scripts. - Portable path keeps _rewrite_hooks_data (in-place path rewrite of APM's declared input; identical shape in/out, no vendor intermediate). - Value-safe diagnostics unchanged (filenames/counts only). Update the runtime-coverage unit tests to the new helper signatures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f98f5a49-17a9-4ac9-a4ff-dd2995780839 --- .../integration/kiro_hook_integrator.py | 388 +++++++++++------- .../test_integration_runtime_coverage.py | 35 +- 2 files changed, 271 insertions(+), 152 deletions(-) diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index 01263f75f..9993cb78c 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -3,6 +3,11 @@ Kiro stores each hook as its own JSON document under ``.kiro/hooks/``. This module keeps the target-specific expansion out of ``hook_integrator.py`` so the shared integrator stays under the source-length guardrail. + +Design: the Kiro target consumes APM's declared hook input *directly* into a +Kiro-native result. Native Kiro v1 documents are read and re-emitted without +round-tripping through any foreign (Claude-shaped) event map, and no internal +side-channel keys are used to smuggle Kiro fields across a normalization step. """ from __future__ import annotations @@ -11,6 +16,7 @@ import logging import os import re +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -31,8 +37,24 @@ _KIRO_EVENT_MAP = _HOOK_EVENT_MAP["kiro"] _log = logging.getLogger(__name__) -# Internal _kiro_* transport keys are injected during normalization and -# consumed by _kiro_hook_document before serialization. + +@dataclass +class _ResolvedKiroHook: + """One fully-resolved Kiro v1 hook, ready to serialize. + + Every field already holds its final Kiro-native value (commands are + path-rewritten, triggers canonicalized). There is no side channel: what + is stored here is exactly what is written to disk. + """ + + trigger: str + action: dict + source_action: dict + name: str | None = None + matcher: str | None = None + description: str | None = None + timeout: int | float | None = None + enabled: bool | None = None def _safe_hook_slug(value: str, fallback: str = "hook") -> str: @@ -55,38 +77,32 @@ def _kiro_matcher_from_matcher(matcher: dict) -> str | None: return None -def _with_kiro_hook_fields(converted: dict, source: dict) -> dict: - """Carry hook-level Kiro fields beside an internal action.""" - timeout = source.get("timeout", source.get("timeoutSec")) - if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): - converted["_kiro_timeout"] = timeout - for field in ("description", "enabled"): - if field in source: - converted[f"_kiro_{field}"] = source[field] - # Native v1 normalization has already namespaced these hook-level fields. - for field in ("_kiro_description", "_kiro_timeout", "_kiro_enabled"): - if field in source: - converted[field] = source[field] - return converted +def _numeric_timeout(value: object) -> int | float | None: + """Return a numeric timeout, or None when absent/non-numeric.""" + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + return None def _kiro_action_from_action(action: dict, command_keys: tuple[str, ...]) -> dict | None: - """Convert one APM hook action to a Kiro v1 action.""" + """Convert one APM hook action to a Kiro v1 action. + + Pure mapping: it produces the Kiro-native ``{"type": ...}`` action and + never mutates or annotates the input. Command path rewriting is the + caller's responsibility (it happens before this call for native input and + inside ``_rewrite_hooks_data`` for portable input). + """ prompt = action.get("prompt") if action.get("type") in {"agent", "askAgent"} or isinstance(prompt, str): prompt_text = prompt if isinstance(prompt, str) else action.get("command") if isinstance(prompt_text, str) and prompt_text.strip(): - return _with_kiro_hook_fields( - {"type": "agent", "prompt": prompt_text}, - action, - ) + return {"type": "agent", "prompt": prompt_text} return None for key in command_keys: command = action.get(key) if isinstance(command, str) and command.strip(): - converted = {"type": "command", "command": command} - return _with_kiro_hook_fields(converted, action) + return {"type": "command", "command": command} return None @@ -106,79 +122,104 @@ def _kiro_actions_from_matcher(matcher: dict, command_keys: tuple[str, ...]) -> def _kiro_hook_document( *, name: str, - event_name: str, - matcher: str | None, + trigger: str, action: dict, + matcher: str | None = None, + description: str | None = None, + timeout: int | float | None = None, + enabled: bool | None = None, ) -> dict: - """Build one Kiro v1 hook JSON document.""" - action = dict(action) - hook = { - "name": name, - "trigger": event_name, - "action": action, - } - for field in ("description", "timeout", "enabled"): - value = action.pop(f"_kiro_{field}", None) - if value is not None: - hook[field] = value + """Build one Kiro v1 hook JSON document from explicit native fields.""" + hook: dict[str, object] = {"name": name, "trigger": trigger} + if description is not None: + hook["description"] = description if matcher: hook["matcher"] = matcher + if timeout is not None: + hook["timeout"] = timeout + if enabled is not None: + hook["enabled"] = enabled + hook["action"] = dict(action) return {"version": "v1", "hooks": [hook]} -def _normalize_kiro_v1(data: dict) -> dict: - """Convert a native Kiro v1 document to the internal event-map shape.""" - events: dict[str, list[dict]] = {} - for hook in data.get("hooks", []): - if not isinstance(hook, dict): +def _resolve_native_v1_hooks( + integrator: HookIntegrator, + hooks: list, + *, + package_path: Path, + package_name: str, + root_dir: str | None, + deploy_root: Path | None, + hook_file_dir: Path, +) -> tuple[list[_ResolvedKiroHook], list[tuple[Path, str]]]: + """Resolve native Kiro v1 hooks directly into Kiro-native results.""" + resolved: list[_ResolvedKiroHook] = [] + scripts: list[tuple[Path, str]] = [] + for raw in hooks: + if not isinstance(raw, dict): continue - trigger = hook.get("trigger") - action = hook.get("action") + trigger = raw.get("trigger") + action = raw.get("action") if not isinstance(trigger, str) or not isinstance(action, dict): continue - native_action = dict(action) - if isinstance(hook.get("name"), str): - native_action["_kiro_name"] = hook["name"] - if isinstance(hook.get("description"), str): - native_action["_kiro_description"] = hook["description"] - timeout = hook.get("timeout") - if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): - native_action["_kiro_timeout"] = timeout - if isinstance(hook.get("enabled"), bool): - native_action["_kiro_enabled"] = hook["enabled"] - matcher: dict[str, object] = {"hooks": [native_action]} - if isinstance(hook.get("matcher"), str): - matcher["matcher"] = hook["matcher"] - canonical_trigger = _KIRO_EVENT_MAP.get(trigger, trigger) - events.setdefault(canonical_trigger, []).append(matcher) - return {"hooks": events} - - -def _write_kiro_hook_docs( + kiro_action = _kiro_action_from_action(action, integrator.HOOK_COMMAND_KEYS) + if kiro_action is None: + continue + if kiro_action["type"] == "command": + new_cmd, act_scripts = integrator._rewrite_command_for_target( + kiro_action["command"], + package_path, + package_name, + "kiro", + hook_file_dir=hook_file_dir, + root_dir=root_dir, + deploy_root=deploy_root, + ) + kiro_action = {"type": "command", "command": new_cmd} + scripts.extend(act_scripts) + resolved.append( + _ResolvedKiroHook( + trigger=_KIRO_EVENT_MAP.get(trigger, trigger), + action=kiro_action, + source_action=action, + name=raw.get("name") if isinstance(raw.get("name"), str) else None, + matcher=raw.get("matcher") if isinstance(raw.get("matcher"), str) else None, + description=( + raw.get("description") if isinstance(raw.get("description"), str) else None + ), + timeout=_numeric_timeout(raw.get("timeout")), + enabled=raw.get("enabled") if isinstance(raw.get("enabled"), bool) else None, + ) + ) + return resolved, _dedup_scripts(scripts) + + +def _dedup_scripts(scripts: list[tuple[Path, str]]) -> list[tuple[Path, str]]: + """Drop duplicate (source, target) script pairs, preserving order.""" + seen: set[tuple[str, str]] = set() + unique: list[tuple[Path, str]] = [] + for source, target_rel in scripts: + key = (str(source), target_rel) + if key in seen: + continue + seen.add(key) + unique.append((source, target_rel)) + return unique + + +def _resolve_portable_hooks( integrator: HookIntegrator, - hook_file: Path, rewritten: dict, - hooks_dir: Path, - project_root: Path, - package_name: str, - force: bool, - managed_files: set | None, - diagnostics, - target_paths: list[Path], - display_payloads: list, -) -> tuple[int, int, int]: - """Write Kiro hook docs from one source hook file.""" - files_integrated = 0 - files_skipped = 0 - files_adopted = 0 +) -> list[_ResolvedKiroHook]: + """Resolve APM's portable (path-rewritten) hook map into Kiro-native results.""" + resolved: list[_ResolvedKiroHook] = [] hooks = rewritten.get("hooks", {}) _emit_hook_event_diagnostics(list(hooks.keys()), "kiro", _KIRO_EVENT_MAP) - per_event_counts: dict[str, int] = {} for raw_event_name, matchers in hooks.items(): if not isinstance(matchers, list): continue - event_name = _KIRO_EVENT_MAP.get(raw_event_name, raw_event_name) - event_slug = _safe_hook_slug(event_name) + trigger = _KIRO_EVENT_MAP.get(raw_event_name, raw_event_name) for matcher in matchers: if not isinstance(matcher, dict): continue @@ -186,58 +227,91 @@ def _write_kiro_hook_docs( for action in _kiro_actions_from_matcher(matcher, integrator.HOOK_COMMAND_KEYS): kiro_action = _kiro_action_from_action(action, integrator.HOOK_COMMAND_KEYS) if kiro_action is None: - _log.debug( - "Skipping unsupported Kiro hook action from %s", - hook_file.name, - ) - continue - per_event_counts[event_name] = per_event_counts.get(event_name, 0) + 1 - index = per_event_counts[event_name] - doc = _kiro_hook_document( - name=action.get("_kiro_name", f"{package_name} {event_name} {index}"), - event_name=event_name, - matcher=kiro_matcher, - action=kiro_action, - ) - target_filename = ( - f"{_safe_hook_slug(package_name)}-{_safe_hook_slug(hook_file.stem)}-" - f"{event_slug}-{index}.json" - ) - target_path = hooks_dir / target_filename - ensure_path_within(target_path, hooks_dir) - rel_path = portable_relpath(target_path, project_root) - rendered = json.dumps(doc, indent=2) + "\n" - - if target_path.exists() and target_path.read_text(encoding="utf-8") == rendered: - os.chmod(target_path, 0o600) - files_adopted += 1 - target_paths.append(target_path) - continue - if integrator.check_collision( - target_path, - rel_path, - managed_files, - force, - diagnostics=diagnostics, - ): - files_skipped += 1 continue - - atomic_write_text(target_path, rendered, new_file_mode=0o600) - # Keep existing hook files private after updates too. - os.chmod(target_path, 0o600) - files_integrated += 1 - target_paths.append(target_path) - display_payloads.append( - _display_payload( - integrator, - target_filename, - hook_file, - event_name, - kiro_action, - rendered, + resolved.append( + _ResolvedKiroHook( + trigger=trigger, + action=kiro_action, + source_action=action, + matcher=kiro_matcher, + timeout=_numeric_timeout( + action.get("timeout", action.get("timeoutSec")) + ), ) ) + return resolved + + +def _write_resolved_hooks( + integrator: HookIntegrator, + resolved: list[_ResolvedKiroHook], + hook_file: Path, + hooks_dir: Path, + project_root: Path, + package_name: str, + force: bool, + managed_files: set | None, + diagnostics, + target_paths: list[Path], + display_payloads: list, +) -> tuple[int, int, int]: + """Serialize resolved Kiro hooks to one v1 document per hook.""" + files_integrated = 0 + files_skipped = 0 + files_adopted = 0 + per_event_counts: dict[str, int] = {} + for hook in resolved: + per_event_counts[hook.trigger] = per_event_counts.get(hook.trigger, 0) + 1 + index = per_event_counts[hook.trigger] + event_slug = _safe_hook_slug(hook.trigger) + doc = _kiro_hook_document( + name=hook.name or f"{package_name} {hook.trigger} {index}", + trigger=hook.trigger, + action=hook.action, + matcher=hook.matcher, + description=hook.description, + timeout=hook.timeout, + enabled=hook.enabled, + ) + target_filename = ( + f"{_safe_hook_slug(package_name)}-{_safe_hook_slug(hook_file.stem)}-" + f"{event_slug}-{index}.json" + ) + target_path = hooks_dir / target_filename + ensure_path_within(target_path, hooks_dir) + rel_path = portable_relpath(target_path, project_root) + rendered = json.dumps(doc, indent=2) + "\n" + + if target_path.exists() and target_path.read_text(encoding="utf-8") == rendered: + os.chmod(target_path, 0o600) + files_adopted += 1 + target_paths.append(target_path) + continue + if integrator.check_collision( + target_path, + rel_path, + managed_files, + force, + diagnostics=diagnostics, + ): + files_skipped += 1 + continue + + atomic_write_text(target_path, rendered, new_file_mode=0o600) + # Keep existing hook files private after updates too. + os.chmod(target_path, 0o600) + files_integrated += 1 + target_paths.append(target_path) + display_payloads.append( + _display_payload( + integrator, + target_filename, + hook_file, + hook.trigger, + hook.action, + rendered, + ) + ) return files_integrated, files_skipped, files_adopted @@ -292,6 +366,45 @@ def _copy_scripts( return copy_result.scripts_copied, copy_result.files_adopted +def _resolve_hooks_for_file( + integrator: HookIntegrator, + data: dict, + *, + package_path: Path, + package_name: str, + root_dir: str | None, + deploy_root: Path | None, + hook_file_dir: Path, +) -> tuple[list[_ResolvedKiroHook], list[tuple[Path, str]]]: + """Resolve one parsed hook file into Kiro-native hooks plus scripts to copy.""" + if isinstance(data.get("hooks"), list): + _log.debug( + "Consuming %d native Kiro v1 hook(s) from %s", + len(data["hooks"]), + hook_file_dir.name, + ) + return _resolve_native_v1_hooks( + integrator, + data["hooks"], + package_path=package_path, + package_name=package_name, + root_dir=root_dir, + deploy_root=deploy_root, + hook_file_dir=hook_file_dir, + ) + + rewritten, scripts = integrator._rewrite_hooks_data( + data, + package_path, + package_name, + "kiro", + hook_file_dir=hook_file_dir, + root_dir=root_dir, + deploy_root=deploy_root, + ) + return _resolve_portable_hooks(integrator, rewritten), scripts + + def integrate_kiro_hooks( integrator: HookIntegrator, package_info, @@ -339,27 +452,20 @@ def integrate_kiro_hooks( data = integrator._parse_hook_json(hook_file, allow_kiro_v1=True) if data is None: continue - if isinstance(data.get("hooks"), list): - _log.debug( - "Normalizing %d native Kiro v1 hook(s) from %s", - len(data["hooks"]), - hook_file.name, - ) - data = _normalize_kiro_v1(data) - rewritten, scripts = integrator._rewrite_hooks_data( + resolved, scripts = _resolve_hooks_for_file( + integrator, data, - package_info.install_path, - package_name, - "kiro", - hook_file_dir=hook_file.parent, + package_path=package_info.install_path, + package_name=package_name, root_dir=root_dir, deploy_root=deploy_root_for_rewrite, + hook_file_dir=hook_file.parent, ) - written, skipped, adopted = _write_kiro_hook_docs( + written, skipped, adopted = _write_resolved_hooks( integrator, + resolved, hook_file, - rewritten, hooks_dir, project_root, package_name, diff --git a/tests/integration/test_integration_runtime_coverage.py b/tests/integration/test_integration_runtime_coverage.py index d0144386e..823fc9e6a 100644 --- a/tests/integration/test_integration_runtime_coverage.py +++ b/tests/integration/test_integration_runtime_coverage.py @@ -194,13 +194,10 @@ def test_builds_document_with_matcher(self) -> None: doc = _kiro_hook_document( name="my-pkg PreToolUse 1", - event_name="PreToolUse", + trigger="PreToolUse", matcher="**/*.py", - action={ - "type": "command", - "command": "echo hi", - "_kiro_description": "A description", - }, + description="A description", + action={"type": "command", "command": "echo hi"}, ) assert doc == { "version": "v1", @@ -208,8 +205,8 @@ def test_builds_document_with_matcher(self) -> None: { "name": "my-pkg PreToolUse 1", "trigger": "PreToolUse", - "matcher": "**/*.py", "description": "A description", + "matcher": "**/*.py", "action": {"type": "command", "command": "echo hi"}, } ], @@ -221,24 +218,40 @@ def test_builds_document_without_matcher(self) -> None: doc = _kiro_hook_document( name="pkg hook 1", - event_name="PostToolUse", + trigger="PostToolUse", matcher=None, action={"type": "agent", "prompt": "Check"}, ) assert "matcher" not in doc["hooks"][0] def test_no_description_omitted(self) -> None: - """An absent namespaced description is omitted.""" + """An absent description is omitted.""" from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document doc = _kiro_hook_document( name="x", - event_name="Event", + trigger="Event", matcher=None, action={"type": "command", "command": "ls"}, ) assert "description" not in doc["hooks"][0] + def test_preserves_native_hook_fields(self) -> None: + """Native timeout and enabled fields serialize beside the action.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document + + doc = _kiro_hook_document( + name="native 1", + trigger="PreTaskExec", + matcher="build", + timeout=15, + enabled=False, + action={"type": "command", "command": "make"}, + ) + hook = doc["hooks"][0] + assert hook["timeout"] == 15 + assert hook["enabled"] is False + # --------------------------------------------------------------------------- # kiro_hook_integrator -- full integration flow @@ -355,7 +368,7 @@ def test_adopts_identical_existing_file(self, tmp_path: Path) -> None: doc = _kiro_hook_document( name="my-pkg PreToolUse 1", - event_name="PreToolUse", + trigger="PreToolUse", matcher=None, action={"type": "command", "command": "make test"}, ) From e63ed0d46aaa6b9bd380adf001d4293bd3344322 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 11 Jul 2026 19:23:43 +0200 Subject: [PATCH 10/13] docs(changelog): tie Kiro v1 hooks fix to issue #2071 Consolidate the three Unreleased "Fixed" blocks into one per Keep a Changelog, and record `closes #2071` on the Kiro hooks entry. Apply ruff formatting to the refactored kiro_hook_integrator module. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f98f5a49-17a9-4ac9-a4ff-dd2995780839 --- CHANGELOG.md | 8 +------- src/apm_cli/integration/kiro_hook_integrator.py | 4 +--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa67701f..204236984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,18 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm install --target intellij` now configures JetBrains Copilot MCP support while routing package file primitives through the Copilot profile. (by @sergio-sisternes-epam; closes #1957) (#2041) - -### Fixed - - Azure DevOps marketplace checks now preserve suffix-free `/_git/` URLs and pass Azure CLI bearer authentication through to `git ls-remote`. (closes #2119) - -### Fixed - - Kiro hooks now support matchers, hook-level timeouts, and `enabled` toggles through the official v1 runtime shape, while native v1 files preserve their supported fields. The contract is recorded in the OpenAPM v0.1 - specification. (#2095) + specification. (closes #2071) (#2095) ## [0.24.1] - 2026-07-10 diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index 9993cb78c..d42e625b6 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -234,9 +234,7 @@ def _resolve_portable_hooks( action=kiro_action, source_action=action, matcher=kiro_matcher, - timeout=_numeric_timeout( - action.get("timeout", action.get("timeoutSec")) - ), + timeout=_numeric_timeout(action.get("timeout", action.get("timeoutSec"))), ) ) return resolved From 9d61ce89a94f5e7e2ae71d7b02ec79b58b977f99 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 11 Jul 2026 19:47:10 +0200 Subject: [PATCH 11/13] refactor(hooks): fold review-panel recommendations for Kiro v1 Address in-scope apm-review-panel findings on PR #2095: - kiro_hook_integrator: drop the unused _ResolvedKiroHook.source_action field and freeze the dataclass (value-object safety); document the native-trigger passthrough safety contract; log the hook filename instead of the parent directory; name the two supported action types in the unsupported-actions warning. - docs(hooks-and-commands): correct the Kiro-only trigger set (PreTaskExec/PostTaskExec pass through from portable input; only PostFileCreate/PostFileSave/PostFileDelete/SessionStart are Kiro-only) and note that Kiro matchers are regex, unlike portable glob matchers. - CHANGELOG: reframe the Fixed entry to lead with the v1 interop fix. - tests: add native type:"agent" passthrough coverage for _kiro_action_from_action. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f98f5a49-17a9-4ac9-a4ff-dd2995780839 --- CHANGELOG.md | 8 +++++--- .../author-primitives/hooks-and-commands.md | 15 +++++++++------ .../integration/kiro_hook_integrator.py | 19 ++++++++++++------- .../test_integration_runtime_coverage.py | 10 ++++++++++ 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 204236984..5e09661e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (by @sergio-sisternes-epam; closes #1957) (#2041) - Azure DevOps marketplace checks now preserve suffix-free `/_git/` URLs and pass Azure CLI bearer authentication through to `git ls-remote`. (closes #2119) -- Kiro hooks now support matchers, hook-level timeouts, and `enabled` toggles - through the official v1 runtime shape, while native v1 files preserve their - supported fields. The contract is recorded in the OpenAPM v0.1 +- Kiro hooks now emit the official Kiro v1 runtime shape + (`{"version": "v1", "hooks": [...]}`) that Kiro 1.0 reads natively, replacing + the superseded pre-1.0 schema that Kiro could not consume. Portable hooks gain + matchers, hook-level timeouts, and `enabled` toggles, and native v1 files + preserve their supported fields. The contract is recorded in the OpenAPM v0.1 specification. (closes #2071) (#2095) ## [0.24.1] - 2026-07-10 diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index 18ed95543..3210812f6 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -81,16 +81,19 @@ authors notice empty merges during development. Kiro-targeted packages may also use the native Kiro v1 shape. APM accepts `{"version": "v1", "hooks": [...]}` and deploys each array entry as a -standalone v1 document. Portable Claude and Copilot events are translated to +standalone v1 document. Portable Claude and Copilot events map to Kiro's PascalCase `trigger` names, matcher groups become `matcher`, and command actions become `action: {"type": "command", ...}`. In Kiro v1, `timeout` is a hook-level field alongside `action`. Native `description`, `timeout`, and `enabled` fields are preserved. Portable `askAgent` actions become Kiro v1 -`agent` actions with the prompt preserved. Native-only triggers such as `PreTaskExec`, -`PostTaskExec`, `PostFileCreate`, `PostFileSave`, and `PostFileDelete` pass -through for Kiro. Native v1 inputs support Kiro's `command` and `agent` action -types; files with no supported actions emit a warning. For example, this -Kiro-only hook runs the Ruff Python linter after a file is saved: +`agent` actions with the prompt preserved. `PreTaskExec` and `PostTaskExec` +also pass through from portable input; `PostFileCreate`, `PostFileSave`, +`PostFileDelete`, and `SessionStart` are Kiro-only triggers that require the +native v1 shape. Native v1 inputs support Kiro's `command` and `agent` action +types; files with no supported actions emit a warning. Kiro matchers are regex +patterns (for example `\\.py$`), unlike the glob patterns used in portable hook +matchers. For example, this Kiro-only hook runs the Ruff Python linter after a +file is saved: ```json { diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index d42e625b6..5e6d00ad3 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -38,18 +38,18 @@ _log = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class _ResolvedKiroHook: """One fully-resolved Kiro v1 hook, ready to serialize. Every field already holds its final Kiro-native value (commands are path-rewritten, triggers canonicalized). There is no side channel: what - is stored here is exactly what is written to disk. + is stored here is exactly what is written to disk. Frozen because a + resolver builds it once and the writer only ever reads it. """ trigger: str action: dict - source_action: dict name: str | None = None matcher: str | None = None description: str | None = None @@ -180,9 +180,12 @@ def _resolve_native_v1_hooks( scripts.extend(act_scripts) resolved.append( _ResolvedKiroHook( + # Native triggers pass through unchanged when unmapped. This is + # safe: _safe_hook_slug sanitizes the value for the filename, + # ensure_path_within bounds the output path, and the trigger is + # data-only in the emitted JSON (no execution semantics). trigger=_KIRO_EVENT_MAP.get(trigger, trigger), action=kiro_action, - source_action=action, name=raw.get("name") if isinstance(raw.get("name"), str) else None, matcher=raw.get("matcher") if isinstance(raw.get("matcher"), str) else None, description=( @@ -232,7 +235,6 @@ def _resolve_portable_hooks( _ResolvedKiroHook( trigger=trigger, action=kiro_action, - source_action=action, matcher=kiro_matcher, timeout=_numeric_timeout(action.get("timeout", action.get("timeoutSec"))), ) @@ -373,13 +375,14 @@ def _resolve_hooks_for_file( root_dir: str | None, deploy_root: Path | None, hook_file_dir: Path, + hook_file_name: str, ) -> tuple[list[_ResolvedKiroHook], list[tuple[Path, str]]]: """Resolve one parsed hook file into Kiro-native hooks plus scripts to copy.""" if isinstance(data.get("hooks"), list): _log.debug( "Consuming %d native Kiro v1 hook(s) from %s", len(data["hooks"]), - hook_file_dir.name, + hook_file_name, ) return _resolve_native_v1_hooks( integrator, @@ -459,6 +462,7 @@ def integrate_kiro_hooks( root_dir=root_dir, deploy_root=deploy_root_for_rewrite, hook_file_dir=hook_file.parent, + hook_file_name=hook_file.name, ) written, skipped, adopted = _write_resolved_hooks( integrator, @@ -478,7 +482,8 @@ def integrate_kiro_hooks( files_adopted += adopted if written + skipped + adopted == 0: _log.warning( - "Kiro hook file %s contributed no supported command or agent actions", + "Kiro hook file %s contributed no supported command or agent " + 'actions (supported: type "command" or type "agent")', hook_file.name, ) copied, adopted_scripts = _copy_scripts( diff --git a/tests/integration/test_integration_runtime_coverage.py b/tests/integration/test_integration_runtime_coverage.py index 823fc9e6a..7e209105c 100644 --- a/tests/integration/test_integration_runtime_coverage.py +++ b/tests/integration/test_integration_runtime_coverage.py @@ -134,6 +134,16 @@ def test_ask_agent_type(self) -> None: ) assert result == {"type": "agent", "prompt": "Do something"} + def test_native_agent_type(self) -> None: + """Native Kiro v1 type=agent input passes through unchanged.""" + from apm_cli.integration.kiro_hook_integrator import _kiro_action_from_action + + result = _kiro_action_from_action( + {"type": "agent", "prompt": "Do X"}, + command_keys=("command",), + ) + assert result == {"type": "agent", "prompt": "Do X"} + def test_prompt_string_shorthand(self) -> None: """Dict with 'prompt' key (no type) maps to an agent action.""" from apm_cli.integration.kiro_hook_integrator import _kiro_action_from_action From 12018598e51d279edb2bc7c4bad7c5f45047c9ba Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 11 Jul 2026 20:02:19 +0200 Subject: [PATCH 12/13] docs(spec): normalize req-tg-006 subject binding (spec-guardian F1) Fold the sole fold_now item from the apm-spec-guardian editorial-patch panel: replace the mid-paragraph subject 'A Kiro consumer' with 'The consumer' in req-tg-006 so the requirement uses a consistent subject throughout. Prose-only; no normative surface or count change (still 99 / 94 MUST / 5 SHOULD across all three count sites). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f98f5a49-17a9-4ac9-a4ff-dd2995780839 --- docs/src/content/docs/specs/openapm-v0.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 8abbd5dd0..b1ea7eca9 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -2080,7 +2080,7 @@ MUST NOT emit a superseded schema. Each generated document MUST contain a top-level `"version": "v1"` field and an array-valued `"hooks"` field. Each hook entry MUST contain `name`, a PascalCase `trigger`, and an `action`; it MAY contain a `matcher`, `description`, numeric `timeout`, and boolean `enabled`. -A Kiro consumer MUST NOT emit the superseded `when`/`then` hook shape. The +The consumer MUST NOT emit the superseded `when`/`then` hook shape. The consumer MUST accept native Kiro v1 hook documents for Kiro-targeted packages and preserve their supported names, descriptions, triggers, matchers, command or agent actions, timeouts, and enabled state while applying the same path rewriting From 8363824af62aed4438a07506d3a913bbd02e8dc9 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 11 Jul 2026 20:12:39 +0200 Subject: [PATCH 13/13] docs(apm-guide): correct Kiro-only trigger example (panel fp-doc) PreTaskExec passes through from portable input via _HOOK_EVENT_MAP, so it is not Kiro-only. Replace the example with genuinely Kiro-only triggers (PostFileSave / SessionStart) to match the corrected sibling doc (hooks-and-commands.md) and keep the apm-guide usage resource in sync with the docs corpus. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f98f5a49-17a9-4ac9-a4ff-dd2995780839 --- packages/apm-guide/.apm/skills/apm-usage/package-authoring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 7d2f8eb3a..162a3acf1 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -134,7 +134,7 @@ hook action under `.kiro/hooks/` using the Kiro v1 shape: top-level `matcher`, an `action` object, and optional hook-level `description`, `timeout`, and `enabled` fields. Portable `askAgent` actions become Kiro v1 `agent` actions. Kiro-targeted packages may author that native v1 shape directly when -they need Kiro-only triggers such as `PreTaskExec` or `PostFileSave`; APM +they need Kiro-only triggers such as `PostFileSave` or `SessionStart`; APM preserves those supported hook-level fields. When a hook command references a script inside `hooks/` or `.apm/hooks/`,