diff --git a/CHANGELOG.md b/CHANGELOG.md index 674411a76..5e09661e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,14 @@ 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) +- 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/CONFORMANCE.json b/CONFORMANCE.json index b6f7e5ad0..281643f57 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -1107,12 +1107,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": 69, + "active": 70, "skipped": 1, "unbound": 0, "xfail": 0 @@ -1136,5 +1147,5 @@ "xfail": 0 } }, - "total_requirements": 98 + "total_requirements": 99 } diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 1d98ff086..87602b965 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 | 69 | 1 | 0 | 0 | +| Consumer | 70 | 1 | 0 | 0 | | Registry | 1 | 0 | 0 | 0 | | Governance | 15 | 0 | 0 | 0 | @@ -125,6 +125,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 d1176f34c..e128bdaca 100644 --- a/docs/public/specs/manifests/openapm-v0.1.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.1.requirements.yml @@ -357,6 +357,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/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 6ad5bfa98..896a2dec8 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -124,9 +124,13 @@ 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. 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 26f76d86f..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 @@ -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} ] @@ -78,6 +79,36 @@ 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 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. `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 +{ + "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 target. Plain `./script.sh` resolves relative to the hook file. If the hook @@ -135,7 +166,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/docs/src/content/docs/reference/targets-matrix.md b/docs/src/content/docs/reference/targets-matrix.md index 65f815700..3ab0859f4 100644 --- a/docs/src/content/docs/reference/targets-matrix.md +++ b/docs/src/content/docs/reference/targets-matrix.md @@ -218,7 +218,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/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 02fb855a7..b1ea7eca9 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 **98 normative statements** indexed in +- OpenAPM v0.1 carries **99 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 @@ -2073,6 +2073,19 @@ first install) as deployed rules; any other `.md` file under `.agents/rules/` MUST NOT be treated as a deployed rule and 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`, `description`, numeric `timeout`, and boolean `enabled`. +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 +and deployment constraints as portable hook inputs. + > **Editorial note.** [req-tg-005](#req-tg-005) names the `antigravity` deploy path > and frontmatter keys in the normative text rather than delegating > them to the non-normative Target Registry companion (contrast @@ -2098,7 +2111,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). --- @@ -2998,6 +3012,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 | @@ -3014,7 +3029,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: 98** (93 MUST, 5 SHOULD). +**Total normative statements: 99** (94 MUST, 5 SHOULD). --- @@ -3036,6 +3051,7 @@ renumbering of conformance classes. | 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-09 | Spec-guardian editorial+defensive fold on the Antigravity instruction-rule contract (no new normative statements; statement count remains 97 (92 MUST, 5 SHOULD)). Section 4.2.1: defined the **auto-detectable** vs **explicit-only** target taxonomy deterministically (a target is auto-detectable when the OpenAPM Target Registry publishes at least one detection predicate) and rewrote the `all` expansion to key off it, naming `agent-skills` and `antigravity` as the v0.1 explicit-only set (with a Section 8.4 cross-reference). [req-tg-001] extended: a target registered without a detection predicate MUST NOT be auto-detected and MUST be excluded from `all`, generalising the prior `agent-skills`-only clause to cover `antigravity`. [req-tg-005] extended: pinned a canonical `globs` representation (YAML scalar for exactly one glob, YAML block sequence for two or more, no frontmatter block when `applyTo` is absent) so deployed-file content hashes are reproducible across implementations; redefined the deduplication scope from "expected Antigravity rule filenames" to filenames derived from the currently-resolved instruction primitives recorded in `apm.lock.yaml` and the manifest, closing a fail-open interpretation where an unrelated `.agents/rules/*.md` file could suppress `AGENTS.md` content; lowercased the `antigravity` identifier and added an editorial note scoping the normative citation of the concrete deploy path. [req-tg-002] subdirectory-partition list updated to include `.agents/rules/`. No normative count change. | | 0.1.12 | 2026-07-10 | Spec-citation fold for inactive-target lockfile reconciliation. Added [req-lk-020] (Section 5.2, consumer MUST): a non-frozen rewrite with a declared target set preserves paths attributable to current, another declared, or implementation-recognized targets that activate outside the manifest; removes prior paths attributable to none of them; applies the same decision to per-entry and top-level deployed-file lists and hash maps; and preserves prior paths when no target set is declared or attribution is indeterminate. Statement count: 97 -> 98 (93 MUST, 5 SHOULD). | +| 0.1.13 | 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. Reconciled with the sibling 0.1.12 amendment (req-lk-020): cumulative statement count 98 -> 99 (94 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 4272dc7b2..162a3acf1 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,13 @@ 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`, 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 `PostFileSave` or `SessionStart`; 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/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..5e6d00ad3 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -3,13 +3,20 @@ 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 import json +import logging import os import re +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -28,6 +35,26 @@ from apm_cli.integration.hook_integrator import HookIntegrator _KIRO_EVENT_MAP = _HOOK_EVENT_MAP["kiro"] +_log = logging.getLogger(__name__) + + +@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. Frozen because a + resolver builds it once and the writer only ever reads it. + """ + + trigger: str + 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: @@ -36,32 +63,46 @@ 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 _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. + + 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") == "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} + return {"type": "command", "command": command} return None @@ -81,30 +122,130 @@ 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, + 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 hook JSON document.""" - when: dict[str, object] = {"type": event_name} - if patterns: - when["patterns"] = patterns - doc = { - "name": name, - "version": "1.0.0", - "when": when, - "then": then, - } - if description: - doc["description"] = description - return doc + """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 _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 = raw.get("trigger") + action = raw.get("action") + if not isinstance(trigger, str) or not isinstance(action, dict): + continue + 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( + # 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, + 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 _write_kiro_hook_docs( +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, +) -> 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) + for raw_event_name, matchers in hooks.items(): + if not isinstance(matchers, list): + continue + trigger = _KIRO_EVENT_MAP.get(raw_event_name, raw_event_name) + for matcher in matchers: + if not isinstance(matcher, dict): + continue + kiro_matcher = _kiro_matcher_from_matcher(matcher) + 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: + continue + resolved.append( + _ResolvedKiroHook( + trigger=trigger, + action=kiro_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, @@ -114,73 +255,63 @@ def _write_kiro_hook_docs( target_paths: list[Path], display_payloads: list, ) -> tuple[int, int, int]: - """Write Kiro hook docs from one source hook file.""" + """Serialize resolved Kiro hooks to one v1 document per hook.""" files_integrated = 0 files_skipped = 0 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): + 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 - event_name = _KIRO_EVENT_MAP.get(raw_event_name, raw_event_name) - event_slug = _safe_hook_slug(event_name) - for matcher in matchers: - if not isinstance(matcher, dict): - continue - patterns = _kiro_patterns_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: - 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, - event_name=event_name, - patterns=patterns, - then=then, - ) - 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, then, rendered - ) - ) + 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 @@ -189,13 +320,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 { @@ -235,6 +366,46 @@ 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, + 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_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, @@ -279,23 +450,24 @@ 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 - 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, + hook_file_name=hook_file.name, ) - written, skipped, adopted = _write_kiro_hook_docs( + written, skipped, adopted = _write_resolved_hooks( integrator, + resolved, hook_file, - rewritten, hooks_dir, project_root, package_name, @@ -308,6 +480,12 @@ 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 (supported: type "command" or type "agent")', + hook_file.name, + ) copied, adopted_scripts = _copy_scripts( integrator, scripts, diff --git a/tests/integration/test_integration_runtime_coverage.py b/tests/integration/test_integration_runtime_coverage.py index 51d3c525c..7e209105c 100644 --- a/tests/integration/test_integration_runtime_coverage.py +++ b/tests/integration/test_integration_runtime_coverage.py @@ -75,100 +75,110 @@ 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_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 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 +186,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 +198,69 @@ 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", + name="my-pkg PreToolUse 1", + trigger="PreToolUse", + matcher="**/*.py", description="A description", - event_name="preToolUse", - patterns=["**/*.py"], - then={"type": "runCommand", "command": "echo hi"}, + action={"type": "command", "command": "echo hi"}, ) - 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", + "description": "A description", + "matcher": "**/*.py", + "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"}, + trigger="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 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"}, + 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"}, ) - assert "description" not in doc + hook = doc["hooks"][0] + assert hook["timeout"] == 15 + assert hook["enabled"] is False # --------------------------------------------------------------------------- @@ -316,7 +345,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 +377,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", + trigger="PreToolUse", + matcher=None, + action={"type": "command", "command": "make test"}, ) expected_filename = ( f"{_safe_hook_slug('my-pkg')}-{_safe_hook_slug('hooks')}-" 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..60556bf08 --- /dev/null +++ b/tests/integration/test_kiro_hook_install_e2e.py @@ -0,0 +1,107 @@ +"""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, + }, + { + "type": "askAgent", + "prompt": "Check this write for policy drift.", + }, + ], + } + ] + } + } + ), + 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" + 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 diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py index b6fb38303..eb124a3b1 100644 --- a/tests/spec_conformance/test_manifest_reqs.py +++ b/tests/spec_conformance/test_manifest_reqs.py @@ -398,6 +398,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', + "numeric `timeout`, and boolean `enabled`", + "MUST NOT emit the superseded `when`/`then` hook shape", + "MUST accept native Kiro v1 hook documents", + ) + + # --- req-cf-001..002 -------------------------------------------------- diff --git a/tests/unit/integration/test_kiro_target.py b/tests/unit/integration/test_kiro_target.py index 138b522e4..a0fcca9a6 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,94 @@ 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", + "description": "Validate the task before execution", + "trigger": "PreTaskExec", + "matcher": "build", + "timeout": 15, + "enabled": False, + "action": { + "type": "command", + "command": "python ${PLUGIN_ROOT}/hooks/check.py", + }, + } + ], + } + ), + 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", + "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", + }, + } + ], + } + + +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