diff --git a/.gitignore b/.gitignore index 282b612..73d1412 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /scratch/ .DS_Store +__pycache__/ +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md index fa69c5f..774ffd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,10 +31,17 @@ paad/ │ │ └── SKILL.md ← /paad:pushback skill │ └── vibe/ │ └── SKILL.md ← /paad:vibe skill +├── scripts/ +│ ├── build-kiro-power.py ← generates the Kiro power from SKILL.md (single source) +│ └── kiro-keywords.yaml ← Kiro-only keyword sidecar (per-skill + power-level) +├── POWER.md ← generated: Kiro power manifest (run `make kiro`) +├── steering/ ← generated: one inclusion:manual steering file per shipped skill ├── CLAUDE.md ← this file └── README.md ``` +`POWER.md` and `steering/` are generated by `make kiro` from the canonical `plugins/paad/skills/*/SKILL.md`; do not hand-edit them (the `make check-kiro` drift check enforces this). + ## Key conventions - **Marketplace name**: `paad` @@ -53,11 +60,15 @@ paad/ 6. Bump the version in both `plugins/paad/.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` 7. Update `README.md` to document the new skill under "Available Skills", including argument syntax in the heading 8. Add the new skill to `paad:help` — both the overview table and a detailed help section +9. Add an entry for the new skill in `scripts/kiro-keywords.yaml` under `skills:` (otherwise the Kiro power generator WARNS and the skill won't surface in the power's keyword set). Note: `help` and `makefile` are intentionally excluded from the power and have no steering file. +10. Run `make kiro` to regenerate the Kiro power, then commit the updated `POWER.md` and `steering/`. The `make check-kiro` drift check (part of `make test`) enforces that the committed power is regenerated, not stale or hand-edited. ## Modifying an existing skill When changing a skill's behavior, arguments, or output, review `plugins/paad/skills/help/SKILL.md` and update the corresponding help text to match. +After editing a `SKILL.md`, run `make kiro` to regenerate the Kiro power and commit the updated `POWER.md` and `steering/`. The `make check-kiro` drift check will fail otherwise. + ## Digraph requirements Every skill (except `paad:help`) must include at least one graphviz digraph (`\`\`\`dot` block) that visualizes the skill's decision points and flow. Digraphs must be: diff --git a/Makefile b/Makefile index 08090e2..1e3d0ed 100644 --- a/Makefile +++ b/Makefile @@ -2,14 +2,23 @@ SKILLS_DIR := plugins/paad/skills SKILL_DIRS := $(wildcard $(SKILLS_DIR)/*) SKILL_NAMES := $(notdir $(SKILL_DIRS)) -.PHONY: help test validate check-versions check-digraphs check-help check-readme check-frontmatter +.PHONY: help test validate check-versions check-digraphs check-help check-readme check-frontmatter kiro check-kiro check-kiro-frontmatter help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}' -test: validate check-versions check-digraphs check-help check-readme check-frontmatter ## Run all checks +test: validate check-versions check-digraphs check-help check-readme check-frontmatter check-kiro check-kiro-frontmatter ## Run all checks @echo "All checks passed." +kiro: ## Regenerate the Kiro power (POWER.md + steering/) + @python3 scripts/build-kiro-power.py + +check-kiro: ## Fail if the committed Kiro power is stale or hand-edited (ignores provenance stamp) + @python3 scripts/build-kiro-power.py --check + +check-kiro-frontmatter: ## Lint that POWER.md + steering/*.md have frontmatter as the literal first line + @python3 scripts/build-kiro-power.py --lint + validate: ## Validate marketplace and all plugins @claude plugin validate . @for dir in plugins/*/; do \ diff --git a/POWER.md b/POWER.md new file mode 100644 index 0000000..03f73be --- /dev/null +++ b/POWER.md @@ -0,0 +1,28 @@ +--- +name: paad +displayName: PAAD — Architecture, Review & Quality Skills +description: Multi-agent architecture analysis, code review, accessibility, and quality workflows. +keywords: [architecture, code review, accessibility, requirements, code quality] +author: Ovid +version: 1.11.0 +--- + +# PAAD — Kiro Power + +paad — impractical tools for software architecture, code quality, and development workflows. + +Each skill is exposed as a manual steering file the agent loads on demand. Pick the one that matches your request from the list below. + +## When to load steering files + +Each skill is a manual steering file — load the one matching the user's request (type `/` in chat to pick it, or reference `#`): + +- **#agentic-a11y** — Comprehensive multi-agent accessibility audit of user-facing code — supports web, mobile (iOS/Android/React Native/Flutter), desktop, CLI, and games — dispatches specialists for screen readers, vision, motor, cognitive, and multimedia concerns, verifies findings, and produces an actionable report with WCAG 2.2 AA/AAA ratings +- **#agentic-architecture** — Multi-agent architecture analysis — dispatches specialists for structure, coupling, integration, error handling, and security, verifies findings, and produces a comprehensive report of strengths and flaws with evidence +- **#agentic-review** — Use when reviewing current branch for bugs before pushing or merging, when wanting a thorough multi-agent review of local changes, or when preparing work for human review +- **#alignment** — Check that requirements, designs, and implementation plans are aligned — finds coverage gaps, scope creep, and design mismatches, then rewrites tasks in TDD red/green/refactor format +- **#fix-architecture** — Guided fixing of architectural flaws from an agentic-architecture report — validates findings, writes tests, applies fixes with developer approval, and tracks status in the report +- **#pushback** — Push back on specs, PRDs, requirements, and design documents — finds unrelated features, oversized scope, contradictions, feasibility issues, scope imbalance, omissions, ambiguity, and security concerns, with source control reality checks +- **#vibe** — Safe vibe coding with TDD guardrails — for small fixes and quick changes where you want speed but not recklessness. Enforces red/green/refactor, checks for architecture issues, reusable components, and test infrastructure before diving in. + + diff --git a/README.md b/README.md index 28a2b45..8716963 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,26 @@ cp -r kiro_and_antigravity/skills/.kiro/skills/pushback .cursor/skills/ ### Kiro +There are two ways to install PAAD into Kiro. The **power** is an experimental one-click channel that installs straight from this GitHub repository. The **manual copy** is the established method when you want file-level control over exactly which skills land in your project. + +#### Power (one-click install) + +In Kiro, choose **Add Custom Power → Import from GitHub** and use the repository URL: + +``` +https://github.com/Ovid/paad +``` + +Kiro reads the root `POWER.md` and the `steering/` directory. Each PAAD skill becomes a manual steering file, which you invoke through Kiro's native `/` slash-command list (or by typing `#`, for example `#pushback`). When PAAD is updated, you pull the changes in with Kiro's **refresh from remote**. + +This install path is newly added and experimental, so a real install test is appreciated. The manual copy below remains the established method for file-level control. + +**No arguments.** Kiro slash commands take no arguments. Where a Claude Code skill accepts a path or scope (for example `/paad:agentic-architecture src/`), in the Kiro power you instead invoke the steering file and then state the scope in your chat message — for example, after invoking `agentic-architecture`, say "review `src/`". The skill reads the scope from your message rather than from a command argument. + +**Trust note.** Importing the power from a GitHub URL injects steering files that dispatch multi-agent workflows — the same trust boundary as installing the Claude Code plugin. Install it only from a source you trust. + +#### Manual copy + All skills (bash/zsh): ```bash @@ -341,14 +361,19 @@ This validates the marketplace and plugin structure, then runs consistency check Individual checks can also be run separately: ```bash -make check-versions # marketplace.json ↔ plugin.json version sync -make check-digraphs # every skill (except help) has a digraph -make check-help # every skill is documented in paad:help -make check-readme # every skill is documented in README.md -make check-frontmatter # SKILL.md frontmatter is valid, folder name matches -make validate # claude plugin validate on marketplace + plugins +make check-versions # marketplace.json ↔ plugin.json version sync +make check-digraphs # every skill (except help) has a digraph +make check-help # every skill is documented in paad:help +make check-readme # every skill is documented in README.md +make check-frontmatter # SKILL.md frontmatter is valid, folder name matches +make validate # claude plugin validate on marketplace + plugins +make kiro # regenerate the Kiro power (POWER.md + steering/) +make check-kiro # fail if the committed Kiro power is stale or hand-edited +make check-kiro-frontmatter # lint that POWER.md + steering/*.md lead with frontmatter ``` +`make test` also runs `check-kiro` and `check-kiro-frontmatter`, so a stale or hand-edited Kiro power fails the suite. + ## Contributing 1. Fork the repository and create a feature branch. diff --git a/docs/plans/2026-06-30-paad-kiro-power-design.md b/docs/plans/2026-06-30-paad-kiro-power-design.md new file mode 100644 index 0000000..e18dc91 --- /dev/null +++ b/docs/plans/2026-06-30-paad-kiro-power-design.md @@ -0,0 +1,542 @@ +# Design: Bundle PAAD skills into a Kiro Power + +**Date:** 2026-06-30 +**Status:** Design v3 — revised after a second pushback review reconciling the design with the +*already-shipping* Kiro/Antigravity path (`scripts/convert_skills.py` → `kiro_and_antigravity/`). +Coexistence, shared-generator, makefile-exclusion, and versioning findings folded in below. +**Goal:** Distribute PAAD's skills to Kiro IDE users as an installable Kiro "power," maintained alongside the existing Claude Code plugin from a single source of truth. + +## Background + +PAAD is a Claude Code plugin marketplace (`github.com/Ovid/paad`) distributing 9 skills: +`agentic-a11y`, `agentic-architecture`, `agentic-review`, `alignment`, `fix-architecture`, +`help`, `makefile`, `pushback`, `vibe`. Each skill is a pure prompt/instruction skill — a +`SKILL.md` with `name` + `description` frontmatter, markdown instructions, and a graphviz +digraph. No executable tools or MCP servers. + +A Kiro **power** bundles tools, workflows, and best practices that the Kiro IDE activates +on-demand. Verified structure (from https://kiro.dev/docs/powers/create/ and +https://kiro.dev/docs/steering/): + +- **POWER.md** (required) — frontmatter (`name`, `displayName`, `description`, `keywords`, + `author`) + onboarding/steering body. Power-level `keywords` drive when the power activates. +- **mcp.json** (optional) — MCP server config. **Not needed** for PAAD (no tools/servers). +- **steering/** (optional) — workflow-specific markdown guidance files. + +Powers install via the Kiro IDE, kiro.dev, or GitHub repo URLs ("Add Custom Power → Import +from GitHub" / "Local Directory" with an absolute path to the power directory). + +**Verified install constraint (load-bearing):** for GitHub-URL installs, *"The power must have +a valid `POWER.md` file in the repository root"* (https://kiro.dev/docs/powers/installation/). +There is **no subdirectory/path support** for GitHub imports. Kiro consumes the committed +files directly (it does not build from source) and updates by refreshing from the remote repo. +This single fact drives the distribution decision below. + +**Verified discovery mechanism:** manual steering files are discovered two native ways +(https://kiro.dev/docs/steering/): typing `#steering-file-name` in chat, and — importantly — +*"Manual steering files also appear as slash commands — type `/` in chat to see and select +them."* The native `/` slash-command list is the discovery primitive; we do **not** need to +invent a POWER.md dispatcher to surface `#names`. + +## Existing Kiro path (this design SUPPLEMENTS it — does not replace it yet) + +PAAD **already ships a Kiro distribution path**, and this power is an *additional, experimental* +channel that coexists with it — not a replacement (decided: pushback Option C): + +- `scripts/convert_skills.py` generates `kiro_and_antigravity/skills/.kiro/skills//SKILL.md` + (Kiro *skills*) plus `.agent/skills//SKILL.md` (Antigravity wrappers), committed to the repo. +- `README.md` documents manual install: users copy `.kiro/skills/*` into their own workspace's + `.kiro/skills/`. These are *copy-source* files, inert in the paad repo until copied. + +**Coexistence stance:** the power is the experimental one-click-install channel; the manual +`.kiro/skills/` copy path remains for users who want file-level control. The legacy path may be +retired later once the power is proven — until then **both are maintained**. + +**No double-load risk:** the legacy artifacts sit at a *nested* path +(`kiro_and_antigravity/skills/.kiro/skills/`), not the workspace-root `.kiro/` that Kiro scans, and +a power import reads only `POWER.md` + `steering/`. Both activating at once requires a user to +deliberately install the power *and* hand-copy the legacy skills into their own workspace — their +choice, not a repo defect. (A 10-second sanity glance during the test install confirms this.) + +**One shared generator core (decided: pushback Issue [2] Option A):** the power generator and +`convert_skills.py` MUST share one body-cleaning core so the two outputs never diverge in *content*, +only in *wrapper*. See "Generator behavior" — the power reuses `convert_skills.py`'s section-exclusion +set verbatim. + +## Decisions (from brainstorming, revised after pushback) + +1. **Goal:** Reach Kiro users — a real, maintained second distribution channel. +2. **Granularity:** One `paad` power; each skill becomes a steering file (one install, one + identity). +3. **Sync model:** Generated from `SKILL.md` (single source of truth). Kiro artifacts are + generated and committed, never hand-edited. +4. **Inclusion mode:** All steering files use `inclusion: manual` — surfaced as native `/` + slash commands (and `#` references), the truest equivalent of deliberate `/paad:` + invocation, with no skill-level auto-firing of a gated multi-agent audit. +5. **Distribution = single repo, `POWER.md` at the existing `paad` repo root (RECOMMENDED — + revised twice).** + - v1 was `powers/paad/` subdirectory — *rejected*: GitHub-URL installs require `POWER.md` + at the **repository root** (verified), and a subdirectory cannot be installed. + - v2 was a dedicated `Ovid/paad-kiro` repo — *workable, and the safe fallback (see below), but + not the default*: it adds a second repo, a submodule/checkout, and a cross-repo publish push. + - **v3 (CHOSEN): generate `POWER.md` + `steering/` directly into the existing `paad` repo + root.** Kiro's only documented requirement is a valid `POWER.md` at the repo root; `paad` + already is a repo root. Kiro users install `github.com/Ovid/paad`; Kiro reads `POWER.md` + + `steering/`. + - **Why single repo wins:** one place to edit and commit, **shared git history** (so provenance + and the drift check are trivial — no cross-repo stamp needed to span histories), no submodule, + no publish push. The daily simplicity dividend is real and recurring. + - **The one risk, and why it's acceptable:** no *observed* published power shares its root with + unrelated files (the official catalog organizes powers as subdirs). But that is absence of + observation, not a documented prohibition — the docs state only the POWER.md-at-root + *requirement*, no exclusivity rule, and a GitHub-URL import that rejected repos for carrying a + README would reject nearly every real repo. The risk is low **and cheaply verified**: one test + install before relying on it (see "Must-resolve"). + - **Fallback if that test fails:** the dedicated `Ovid/paad-kiro` repo (v2) — a clean + POWER.md-at-root repo matching the catalog convention exactly. The same generator output ships + there instead, with a `paad@` provenance stamp to bridge the now-separate histories. Adopt + only if the busy-root test actually fails. + +## Verified: Kiro steering inclusion modes + +Frontmatter **must be the very first content** in the file — no blank lines or content before it. + +| Mode | Syntax | Activation | +|---|---|---| +| always | `inclusion: always` | Every interaction | +| fileMatch | `inclusion: fileMatch` + `fileMatchPattern: "..."` | When editing matching files | +| **manual** | `inclusion: manual` | Only when user types `#steering-file-name` | +| auto | `inclusion: auto` + `name:` + `description:` | When the request matches `description` | + +PAAD uses **manual** for all steering files (decision 4). `auto` was considered (it is the +native analog of Claude Code's description-based skill activation) but rejected because PAAD's +gated workflows should not auto-fire from conversation alone. Manual files are invoked +deliberately via the native `/` slash-command list or `#` — no dispatcher needed. + +**Residual noise caveat (from pushback):** the *power itself* still activates on its +power-level `keywords` (POWER.md frontmatter). So a broad aggregate keyword set re-introduces +"noisy activation" at the power level — the very thing manual mode avoids at the skill level. +This is a real tension, not fully eliminated; mitigation is a *curated, narrow* aggregate +keyword set (see open questions), not a naive union of every skill's keywords. Even when the +power activates, no gated workflow runs until the user explicitly invokes its slash command. + +## Repo layout (single repo) + +Everything lives in `github.com/Ovid/paad`. `POWER.md` + `steering/` sit at the root +(required for GitHub-URL install) and are generated; only `SKILL.md` is hand-edited. + +``` +paad/ ← Kiro installs this repo; reads root POWER.md + steering/ +├── POWER.md ← GENERATED, root (required for GitHub-URL install) +├── steering/ ← GENERATED +│ ├── agentic-a11y.md +│ ├── agentic-architecture.md +│ ├── agentic-review.md +│ ├── alignment.md +│ ├── fix-architecture.md +│ ├── pushback.md +│ └── vibe.md ← 7 files: all skills EXCEPT help AND makefile (edge cases) +├── plugins/paad/skills/*/SKILL.md ← canonical source (unchanged, hand-edited) +├── kiro_and_antigravity/ ← legacy Kiro/Antigravity copy-source (coexists; see above) +├── scripts/ +│ ├── convert_skills.py ← EXISTING: SKILL.md → .kiro/skills + .agent/skills (legacy) +│ └── build-kiro-power.py ← power generator; shares body-cleaning core with the above +├── .claude-plugin/ ← Claude Code marketplace (Kiro ignores) +├── CLAUDE.md, README.md, docs/ ← (Kiro ignores) +``` + +Workflow: edit a `SKILL.md` → run the generator (`make kiro`) → commit `POWER.md` + `steering/` +in the same repo. No second repo, no push elsewhere, no submodule. Kiro users who already +installed the power get updates via Kiro's "refresh from remote." + +**Fallback (dedicated repo, v2):** if the pre-build test install shows Kiro rejects a busy root, +publish the same generated `POWER.md` + `steering/` to a dedicated `github.com/Ovid/paad-kiro` repo +(clean root) instead, with a `paad@` provenance stamp to bridge the separate histories. Only if +the test fails. + +## Claude Code coexistence check (no conflict) + +Verified against https://code.claude.com/docs/en/plugins. The key structural fact that makes +the single-repo plan safe: **the Claude Code plugin is not at the repo root — it lives at `plugins/paad/`.** +So the repo root is purely a *marketplace* root, whose only artifact Claude Code reads is +`.claude-plugin/marketplace.json`. + +- **Reserved names are scoped to the *plugin* root, not the repo root.** Claude Code recognizes + `skills/`, `commands/`, `agents/`, `hooks/`, `monitors/`, `bin/`, `.mcp.json`, `.lsp.json`, + `settings.json`, and `.claude-plugin/` **at the plugin root** (`plugins/paad/`). None of these + is `POWER.md` or `steering/`, and none is read from the marketplace repo root anyway. +- **`POWER.md` + `steering/` at the repo root collide with nothing.** They sit alongside + `.claude-plugin/`, `plugins/`, `README.md`, `docs/` — all ignored by the other tool. Claude + scans `plugins/paad/` for plugin components; Kiro reads root `POWER.md` + `steering/`. Fully + orthogonal directories. +- **The `paad` name reused by both** (Kiro power `name: paad`, Claude plugin `name: paad`) is + fine — different tools, different namespaces; it's intentional shared branding. +- **Two residual empirical checks** (folded into "must-resolve" below): + 1. Run `claude plugin validate .` *after* adding root `POWER.md` + `steering/` to confirm + marketplace validation tolerates the extra root entries (expected to pass — marketplaces + routinely carry README/LICENSE/docs at root). + 2. ~~`keywords`-in-frontmatter tolerance~~ — **RESOLVED 2026-06-30**: testing showed + `claude plugin validate` doesn't inspect `SKILL.md` frontmatter at all (it passes even on + bogus keys), so the question is moot — we use a sidecar and leave `SKILL.md` untouched. + See "Keywords source: sidecar file" above. + +## The mapping (per skill) + +| SKILL.md | → | Kiro power | +|---|---|---| +| `skills//SKILL.md` body | → | `steering/.md` body | +| frontmatter `name`, `description` | → | aggregated into POWER.md "when to load" mapping | +| keywords (from **sidecar**, see below) | → | aggregated into POWER.md `keywords` | +| ```` ```dot ```` digraphs in RETAINED sections | → | copied verbatim (agent guidance — valid markdown) | +| ```` ```dot ```` digraphs authored inside EXCLUDED sections (e.g. `## Pre-flight Checks`) | → | dropped along with the section (ACCEPTED, matches legacy Kiro output; see note below) | +| `$ARGUMENTS` usage | → | rewritten to "the user may provide a path/scope" prose | + +**Digraph copying is section-conditional (ACCEPTED, guarded).** "Digraphs copied verbatim" +applies *within retained sections only* — the transform never rewrites a `` ```dot `` block, but a +digraph authored inside an EXCLUDED section is dropped with that section. In the real skills this +splits the 7 steering files: `alignment`, `pushback`, `vibe` author their digraph in the intro (a +retained region) and keep it; the four multi-agent skills (`agentic-a11y`, `agentic-architecture`, +`agentic-review`, `fix-architecture`) author their digraph inside `## Pre-flight Checks` (excluded), +so their steering files ship WITHOUT a digraph. This is deliberate and matches the legacy Kiro +output; a guard test (`test_kiro_power_steering.py`) pins which steering files do/don't carry a +`` ```dot `` block so the behavior can only change consciously. + +### Keywords source: sidecar file (DECIDED — was: SKILL.md frontmatter) + +Keywords live in a generator-side sidecar `scripts/kiro-keywords.yaml`, keyed by skill name — +**not** in `SKILL.md` frontmatter. + +```yaml +# scripts/kiro-keywords.yaml +agentic-architecture: [architecture, coupling, structure, design review, tech debt] +pushback: [pushback, spec review, requirements, scope, feasibility] +# ...one line per skill +``` + +**Why the sidecar, not frontmatter (resolved 2026-06-30 by test — pushback SERIOUS-5):** +- `claude plugin validate` was run with `keywords` added, and again with a deliberately *bogus* + frontmatter key. **Both passed** — proving `validate` only checks `plugin.json` and does + **not** inspect `SKILL.md` frontmatter at all. So validation can never confirm that an unknown + frontmatter key is runtime-safe; there is no clean green signal to rely on. +- Keywords are a *Kiro* concern, not a PAAD-skill concern — keeping them in the generator keeps + `SKILL.md` byte-for-byte untouched (zero risk to the Claude plugin) and is cleaner separation. +- Single-source-of-truth is preserved: the sidecar is the canonical source for the *Kiro-only* + keyword metadata, versioned next to the generator that consumes it. +- The generator **warns** if a skill in `skills/` has no entry in the sidecar (catches drift + when a new skill is added). + +## Generated POWER.md shape + +```yaml +--- +name: paad +displayName: PAAD — Architecture, Review & Quality Skills +description: Multi-agent architecture analysis, code review, accessibility, and quality workflows. +keywords: [architecture, review, accessibility, a11y, pushback, vibe, alignment, ...] +author: Ovid +--- +``` + +Body: **onboarding** (what PAAD is) plus a generated **"When to load steering files" +mapping** — the agent-facing routing pattern the Kiro docs describe (workflow → file). This is +derived from each skill's `name` + `description` (the `paad:help` content, generated, so it +cannot drift). Discovery for the *user* is the native `/` slash-command list, not a hand-rolled +index — the POWER.md mapping exists so Kiro's agent loads the right steering file per request, +not as a user-facing `#name` directory (correcting the v1 "dispatcher" framing). + +## Generated steering file shape + +```yaml +--- +inclusion: manual +--- +``` +…followed by the SKILL.md body (with `$ARGUMENTS` and cross-skill-reference transforms). +The `inclusion: manual` block MUST be the literal first content — no blank line before it. + +## Generator behavior + +`scripts/build-kiro-power.*` reads `plugins/paad/skills/*/SKILL.md` and writes `POWER.md` + +`steering/` at the repo root: + +1. **Per skill →** `steering/.md`: prepend `inclusion: manual` frontmatter, then the + body run through the **shared body-cleaning core** (the same logic `convert_skills.py` uses), + with transforms: + - **Strip non-portable orchestration sections** — reuse `convert_skills.py`'s exclusion set + verbatim: `["Arguments", "Input Resolution", "Pre-flight Checks", "Document classification"]`. + These are Claude-Code-specific scaffolding (e.g. the "search your system prompt for your model + ID" probe, `$ARGUMENTS` resolution logic) that misfires in Kiro. **This closes pushback + Issue [2]:** the power's transforms must not be thinner than the tool already shipping working + Kiro output. A test asserts no `## Pre-flight`/`## Input Resolution` survives in `steering/`. + - `$ARGUMENTS` → an explicit instruction to the user to **name the scope in their chat message** + (e.g. "After invoking this guide, state the path or scope you want reviewed, such as `src/`"). + (**CONFIRMED parity loss — pushback MODERATE-8, RESOLVED 2026-06-30:** Kiro's docs are explicit + that invoking a manual steering file just *"adds the file's contents to your current conversation + context"* — there is **no** argument/parameter/`$ARGUMENTS` mechanism on slash commands at all. + So this is not a "test whether it works" item; it is a known, permanent limitation. The prose + transform must therefore *prompt* the user for scope rather than imply automatic capture, and the + power README documents it. Source: https://kiro.dev/docs/chat/slash-commands/ , + https://kiro.dev/docs/steering/) + - cross-skill refs ("run `/paad:agentic-architecture`") → "use the `/agentic-architecture` + slash command" (or `#agentic-architecture`) + - digraphs copied verbatim *within retained sections* — a digraph authored inside an excluded + section (e.g. `## Pre-flight Checks`) is dropped with that section (ACCEPTED; see the mapping-table note) +2. **Aggregate →** `POWER.md`: frontmatter (`name`, `displayName`, `description`, aggregated + `keywords` from `scripts/kiro-keywords.yaml`, `author`) + onboarding + the generated + "when to load steering files" mapping. +3. **Provenance stamp:** embed the source commit into the generated output (e.g. a + `` line in `POWER.md`). In the + single-repo model the shared git history already records provenance, so this is a nice-to-have + that makes a freshly-installed power self-describing; it becomes *load-bearing* only in the + dedicated-repo fallback (where histories are separate). (Note: the generator reads the SHA from + git at run time, then writes otherwise-deterministic output — keep the stamp out of the + idempotency `git diff` comparison, or the drift check will flip on every commit.) + +### Edge cases + +- A skill with no entry in `scripts/kiro-keywords.yaml` → generator **warns** (so a new skill + is not shipped with no way to surface in the power's keyword set). +- `help` skill → becomes the POWER.md index itself; **no separate steering file** (redundant + in Kiro — the power's onboarding *is* the help). +- `makefile` skill → **excluded** (no steering file), matching the legacy `convert_skills.py` + `skip_names = ["makefile", "help"]`. Decided: pushback Issue [4] Option B — keep the two outputs' + skill sets identical; makefile was intentionally omitted from the shipping Kiro path and the power + follows suit. Net: **7 steering files** (all skills except `help` and `makefile`). +- Cross-skill references rewritten from `/paad:` to `#`. + +## Testing / validation + +- **Idempotency:** running the generator twice produces no diff. +- **CI/Make drift check:** regenerate and `git diff --exit-code -- POWER.md steering/` — fails + if anyone hand-edited the generated files or forgot to regenerate after a `SKILL.md` change. + Same repo, so this is a trivial one-line check that mechanically enforces single-source-of-truth + (exclude the provenance stamp from the comparison — see Generator behavior #3). (In the + dedicated-repo fallback this runs against the `paad-kiro` checkout instead.) +- **Claude side:** `claude plugin validate .` and `claude plugin validate ./plugins/paad` + remain the source of truth for the plugin. +- **Kiro side:** lightweight YAML-frontmatter lint enforcing the "frontmatter is first line" + rule for steering files and POWER.md. + +## Out of scope (YAGNI) + +- No `mcp.json` (PAAD skills have no tools/servers). +- No `auto`/`fileMatch` inclusion modes (manual only). +- No per-skill inclusion-mode field (all manual; revisit only if a skill genuinely warrants + seamless activation). + +## Must-resolve before build (from pushback) + +- ~~Verify `keywords` frontmatter tolerance~~ **DONE (2026-06-30)** — tested; `claude plugin + validate` ignores `SKILL.md` frontmatter entirely, so we use a `scripts/kiro-keywords.yaml` + sidecar and leave `SKILL.md` untouched. (SERIOUS-5 closed) +- ~~**Measure steering/POWER.md size against Kiro's actual limit**~~ **RESOLVED (2026-06-30, + docs + search).** No per-file size cap is documented. Kiro manages size via the *context window*: + it auto-summarizes at ~80% of the model limit, with ~50–60K tokens (~200–240K chars) of working + context (kirodotdev/Kiro #4162 is an open feature request for context-size monitoring → no hard + enforcement today). PAAD's largest skill (`agentic-a11y` ≈ 392 lines) is far under that, and + because every steering file is `inclusion: manual`, only the single invoked file enters context — + never the ~2,237-line aggregate. The "one steering file per skill" mapping holds; no splitting + needed. Source: https://kiro.dev/docs/steering/ , https://github.com/kirodotdev/Kiro/issues/4162 +- **Curate the power-level keyword set** (narrow, not a union) to limit noisy power activation + while staying discoverable. Resolve before build, not after. (MODERATE-7) +- ~~**Pick the generator runtime**~~ **RESOLVED (pushback Issue [2])** — Python, reusing + `scripts/convert_skills.py`'s body-cleaning core (`build-kiro-power.py`). No new runtime; the two + generators share one transform core so legacy and power outputs can never diverge in content. +- **Root-file tolerance — one quick pre-build test gate for the single-repo plan (low risk).** + - *Requirement confirmed:* https://kiro.dev/docs/powers/installation/ — "The power must have a valid + `POWER.md` file in the repository root"; no subdirectory/path support. (The `kirodotdev/powers` + monorepo's powers-in-subdirs does NOT contradict this — it has no root POWER.md and is the + first-party *catalog*, consumed via Kiro's built-in browser, not "Import from GitHub URL.") + - *Why low risk:* docs state only the POWER.md-at-root *requirement*, **no exclusivity rule**; a + GitHub-URL import that rejected repos for carrying a README/LICENSE would reject nearly every real + repo, and the catalog repo's own root carries `README`, `CONTRIBUTING`, `.github/`, `.kiro/`. The + "every published power has a minimal root" observation (verified via + `gh api repos/kirodotdev/powers/contents/...` 2026-06-30: `stripe`, `power-builder`) reflects how + the *catalog* organizes subdirs, not an enforced minimalism — absence of observation, not + prohibition. + - *The gate:* before relying on single-repo, do one test install of `github.com/Ovid/paad` and + confirm Kiro reads `POWER.md` + `steering/` despite `.claude-plugin/`, `plugins/`, `docs/`, + `kiro_and_antigravity/`, `scripts/`, `CLAUDE.md` at root. **If it fails → dedicated `Ovid/paad-kiro` + repo fallback (Decision #5).** Source: https://github.com/kirodotdev/powers. + +## Implementation plan + +Ordered so the cheap kill-switch checks (Phase 0) run before any code is written, and each later +phase has a concrete done-condition. Phases 1–3 are the build; 4–5 wire up safety and docs. + +### Phase 0 — Resolve the gates (no code yet) + +These are the "Must-resolve before build" items; none requires writing the generator first. + +1. **Busy-root test install (the one real risk).** Hand-author a *minimal throwaway* `POWER.md` + + `steering/one-file.md` at the `paad` repo root **on a scratch branch**, push, and install + `github.com/Ovid/paad` into Kiro via "Add Custom Power → Import from GitHub." + - **Pass** → Kiro reads `POWER.md` + `steering/` despite `.claude-plugin/`, `plugins/`, `docs/`, + etc. at root → proceed single-repo. + - **Fail** → switch to the dedicated `Ovid/paad-kiro` repo fallback (Decision #5) before Phase 1. + - **Also confirm the POWER.md `version` frontmatter key is tolerated.** During this real Kiro + install, verify Kiro accepts the `version:` key on POWER.md frontmatter. (We deliberately used a + sidecar rather than adding unknown keys on the *Claude* side because validators may reject them, + but nobody has verified Kiro's tolerance of `version:` on POWER.md.) **If Kiro rejects it → drop + the `version` key from the generated POWER.md frontmatter.** + - Delete the scratch branch either way. +2. **Curate the power-level keyword set** (narrow, not a union of all skills' keywords) — draft + `scripts/kiro-keywords.yaml` now; it is an input to Phase 1, not an output. +3. **Re-run `claude plugin validate .` and `claude plugin validate ./plugins/paad`** to capture the + green baseline *before* adding any root files, so a later failure is attributable. + +**Gate:** do not start Phase 1 until #1 has a verdict and #2 exists. + +### Phase 1 — Generator (`scripts/build-kiro-power.py`) + +1. **Refactor the shared body-cleaning core out of `convert_skills.py`** into an importable function + (section-exclusion set `["Arguments", "Input Resolution", "Pre-flight Checks", "Document + classification"]`, `/paad:`-line handling, path neutralization). Both generators import it — this + is pushback Issue [2] Option A; the two outputs must never diverge in body content. + - **Legacy regression guard (alignment Issue [1]):** *before* refactoring, snapshot the current + `kiro_and_antigravity/` tree; *after* refactoring, regenerate via `convert_skills.py` and + `git diff --exit-code` against the snapshot. **Done-condition: zero diff** — the shared-core + extraction must not alter a single byte of the shipping legacy/Antigravity output. +2. **`build-kiro-power.py` reads `plugins/paad/skills/*/SKILL.md`** and, per skill (skipping `help` + and `makefile`), writes `steering/.md`: `inclusion: manual` frontmatter as the literal first + line, then the cleaned body with the power-specific transforms (`$ARGUMENTS` → prose prompt; + cross-skill refs → `#`; digraphs verbatim within retained sections — a digraph inside an + excluded section is dropped with it, ACCEPTED). +3. **Aggregate `POWER.md`**: frontmatter (`name`, `displayName`, `description`, curated `keywords` + from the sidecar, `author`, version mirrored from `plugin.json`) + **onboarding generated from the + `help` skill's content** (so it can't drift — alignment Issue [3]) + the generated "when to load + steering files" mapping (derived from each skill's `name`/`description`). +4. **Provenance stamp** (``), read from git at run time and + **excluded from the idempotency diff**. +5. **Warn** on any skill in `skills/` missing a `kiro-keywords.yaml` entry. + +**Done when:** running it produces `POWER.md` + 7 `steering/*.md`; a second run yields no diff +(idempotency, stamp excluded); the legacy regression guard (#1) shows zero diff; **and** +`claude plugin validate .` + `claude plugin validate ./plugins/paad` still pass with the new root +files present (alignment Issue [2] — the Claude-side complement to Phase 0.3's baseline). + +### Phase 2 — Make target + drift check + +1. `make kiro` runs the generator. +2. Drift check: `make kiro && git diff --exit-code -- POWER.md steering/` (stamp excluded). Wire it + into whatever CI exists, or document it as a pre-commit step if none. + +**Done when:** a deliberate hand-edit to a generated file makes the drift check fail. + +### Phase 3 — Frontmatter lint + +Lightweight check that every `steering/*.md` and `POWER.md` has YAML frontmatter as the **literal +first content** (no leading blank line) — the one Kiro hard rule. Done when it flags a file with a +blank line before its frontmatter. + +### Phase 4 — Docs + +1. **`README.md`**: add a Kiro *power* install section (install `github.com/Ovid/paad`) alongside the + existing manual `.kiro/skills/` copy section — frame the power as the experimental one-click + channel, the copy path as the existing one (Issue [1] Option C coexistence). +2. **Power README / onboarding**: document the `$ARGUMENTS` limitation (user states scope in chat) + and the security/trust note (installing injects steering that dispatches multi-agent workflows). +3. **`CLAUDE.md`**: add "regenerate the Kiro power after editing a `SKILL.md`" to the skill-change + checklist so the power can't silently drift. + +### Phase 5 — Ship + +Bump `plugin.json` (+ sync `marketplace.json`), regenerate, commit `POWER.md` + `steering/` + +generator + sidecar + docs together, and do a final real install from the pushed repo to confirm +end-to-end. + +### TDD task breakdown (code phases) + +Phase 0 (empirical gates) and Phases 4–5 (docs/ship) are not code and stay as written above. The +code-producing units of Phases 1–3 are recast here in red/green/refactor form (alignment skill, +mandatory step). All tests are generator unit tests run against the real `plugins/paad/skills/` tree +or small fixtures. + +#### Task: Shared body-cleaning core (Phase 1.1) + +**Requirement:** Issue [2] Option A (one transform core) + Issue [1] (legacy output unchanged). + +- **RED** — Write a test that snapshots `convert_skills.py`'s current output for one representative + skill (e.g. `pushback`, which has the excluded `Input Resolution`/`Pre-flight Checks` sections), + then asserts the *extracted* `clean_body()` function reproduces that exact output. Expected failure: + the function doesn't exist yet (ImportError). *If it passes unexpectedly:* the core was already + extracted — verify it's actually shared, not duplicated. +- **GREEN** — Extract `clean_body()` from the inline logic in `convert_skills.py` and have + `convert_skills.py` call it. Minimal: no new behavior, just a lifted function. +- **REFACTOR** — Add the full-tree golden guard (snapshot `kiro_and_antigravity/` → regenerate → + `git diff --exit-code`). Look to consolidate the section-exclusion list into one named constant + both generators import (no copy-paste of the four header names). + +#### Task: Steering file generation (Phase 1.2) + +**Requirement:** Decisions 2 & 4 (one steering file per skill, `inclusion: manual`); edge cases +(skip `help` + `makefile`); the mapping (`$ARGUMENTS`→prose, cross-refs→`#name`, digraphs verbatim +within retained sections — a digraph inside an excluded section is dropped with it, ACCEPTED). + +- **RED** — Write tests asserting: (a) exactly 7 `steering/*.md` are produced (not 8, not 9); + (b) `help.md` and `makefile.md` are absent; (c) each file's **first line** is `---` then + `inclusion: manual` (no leading blank); (d) a fixture containing `$ARGUMENTS` and `/paad:vibe` + yields the prose-scope prompt and `#vibe` respectively; (e) a ```dot block survives verbatim. + Expected failure: generator doesn't exist. +- **GREEN** — Implement per-skill emission with the three transforms. Simplest mapping that passes. +- **REFACTOR** — Extract the frontmatter-prepend and transform steps into named helpers; ensure the + power transforms layer *on top of* the shared `clean_body()` rather than re-implementing any of it. + +#### Task: POWER.md aggregation (Phase 1.3–1.5) + +**Requirement:** "Generated POWER.md shape"; help-sourced onboarding (Issue [3]); keyword sidecar + +missing-entry warning; version mirrors `plugin.json`. + +- **RED** — Tests asserting: (a) frontmatter has `name/displayName/description/keywords/author` and a + `version` equal to `plugin.json`'s; (b) onboarding text is derived from the `help` skill content + (assert a known phrase from `help/SKILL.md` appears, proving it's sourced, not hand-written); + (c) `keywords` equals the curated sidecar values, not a union of every skill's; (d) a skill with no + sidecar entry emits a warning. Expected failure: aggregator absent. +- **GREEN** — Build `POWER.md` from the sidecar + `help` content + `plugin.json` version. +- **REFACTOR** — Move the version read and git-SHA stamp behind small helpers; confirm the stamp is + injected *after* the content used for the idempotency comparison so the drift check stays stable. + +#### Task: Idempotency + drift check (Phase 2) + +**Requirement:** Testing/validation — "running the generator twice produces no diff"; `make kiro` + +`git diff --exit-code` enforce single-source-of-truth. + +- **RED** — Test that runs the generator twice and asserts the second run produces no change to + `POWER.md` + `steering/` (with the provenance stamp normalized/excluded). Expected failure: + non-deterministic output (e.g. dict ordering, or the raw SHA stamp flipping the diff). +- **GREEN** — Make output deterministic (sorted iteration, stable formatting); exclude the stamp from + the compared content. +- **REFACTOR** — Wire the `make kiro && git diff --exit-code` target; share the "compare ignoring + stamp" logic between the idempotency test and the drift check so they can't disagree. + +#### Task: Frontmatter-first lint (Phase 3) + +**Requirement:** Kiro hard rule — frontmatter must be the literal first content. + +- **RED** — Test the linter against two fixtures: one valid (`---` on line 1) and one with a leading + blank line; assert pass and fail respectively. Expected failure: linter absent. +- **GREEN** — Implement the check over `steering/*.md` + `POWER.md`. +- **REFACTOR** — Reuse the same frontmatter-parse helper the generator uses for writing, so "what we + write" and "what we lint" share one definition of valid frontmatter. + +## Open questions (lower stakes) + +- Does Kiro render/respect graphviz `dot` blocks in steering, or are they inert text? (Inert + is acceptable — they're agent guidance, not user-facing — but worth confirming.) +- **Versioning/update story for the Kiro side (RESOLVED — pushback Issue [5] Option A).** + `POWER.md` mirrors the **`plugin.json`** version (the precedence source per CLAUDE.md), written + automatically by the generator so it cannot drift and is never a third hand-maintained source. + This disambiguates "matching version" — note `marketplace.json` (currently 1.0.0) and + `plugin.json` (currently 1.11.0) are already out of sync, so the power explicitly follows + `plugin.json`, not `marketplace.json`. Updates reach Kiro users via "refresh from remote." +- **Security/trust note** for the power README: installing from a GitHub URL injects steering + that dispatches multi-agent workflows — same trust boundary as the Claude plugin. + +## Sources + +- https://kiro.dev/docs/powers/create/ +- https://kiro.dev/docs/powers/ +- https://kiro.dev/docs/powers/installation/ +- https://kiro.dev/docs/steering/ +- https://code.claude.com/docs/en/plugins (Claude Code coexistence check) +- https://github.com/kirodotdev/powers/blob/main/power-builder/POWER.md diff --git a/kiro_and_antigravity/skills/.kiro/skills/alignment/SKILL.md b/kiro_and_antigravity/skills/.kiro/skills/alignment/SKILL.md index 25367f5..6d2cfa0 100644 --- a/kiro_and_antigravity/skills/.kiro/skills/alignment/SKILL.md +++ b/kiro_and_antigravity/skills/.kiro/skills/alignment/SKILL.md @@ -179,9 +179,14 @@ Create the `.reviews/alignment/` directory if it doesn't exist. **If documents came from conversation history:** Ask: "The documents aren't saved to files yet. Where should I write them?" Suggest a reasonable path based on project structure. -### Step 2: TDD task rewrite (mandatory) +### Step 2: TDD task rewrite (when applicable) -Once alignment is confirmed, rewrite all action items in red/green/refactor format. This is not optional — it produces better implementations. +Once alignment is confirmed, check whether tasks should be rewritten in red/green/refactor format. **Skip this step if:** + +- Tasks are already in red/green/refactor format +- Tasks don't involve code implementation (e.g., infrastructure provisioning, documentation, design work, data migrations, manual processes) + +If neither condition applies, rewrite action items in red/green/refactor format — it produces better implementations. **Why this works:** diff --git a/scripts/build-kiro-power.py b/scripts/build-kiro-power.py new file mode 100644 index 0000000..e8eed21 --- /dev/null +++ b/scripts/build-kiro-power.py @@ -0,0 +1,809 @@ +#!/usr/bin/env python3 +"""Generate the Kiro power's `steering/` files from the PAAD skills. + +Each in-scope `plugins/paad/skills//SKILL.md` becomes a +`steering/.md` whose body is the source SKILL.md run through the SHARED +body-cleaning core (`skill_body.clean_body`) plus three POWER-SPECIFIC +transforms layered on top: + + 1. Cross-skill refs `/paad:` -> `#` (injected as `clean_body`'s + `paad_ref_transform`, *replacing* the legacy line-stripping — the line is + kept, only the token is rewritten). + 2. Surviving prose `$ARGUMENTS` -> a prompt telling the user to state the + scope in their chat message. Kiro slash commands have NO argument + mechanism, so the prose must prompt, not imply auto-capture. Applied AFTER + `clean_body` because it is power-specific, not part of the shared core. + 3. ```dot digraphs pass through verbatim — `clean_body` already preserves + them, and transform #2 deliberately skips fenced `dot` blocks so digraph + `$ARGUMENTS` node labels are not rewritten. + +The source SKILL.md's own `name:`/`description:` frontmatter is STRIPPED; each +steering file is prefixed with the literal `---\ninclusion: manual\n---` +frontmatter as its first content (no leading blank line, no BOM). The +`name:`/`description:` metadata is surfaced in POWER.md, not here. + +Running this script regenerates the whole Kiro power: the aggregated `POWER.md` +manifest at the repo root plus one `steering/.md` per in-scope skill. The +`--check` mode first fails if any in-scope skill is missing a `kiro-keywords.yaml` +`skills:` entry (so a new skill cannot ship without a keyword decision), then +regenerates both in memory and reports any on-disk file that has drifted from +what the generator would produce now (excluding the floating provenance stamp), +so `make check-kiro` enforces single-source-of-truth. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +# Make the shared body-cleaning core importable regardless of cwd. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from skill_body import SKIP_NAMES, clean_body # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent +SOURCE_DIR = REPO_ROOT / "plugins" / "paad" / "skills" +STEERING_DIR = REPO_ROOT / "steering" +PLUGIN_JSON = REPO_ROOT / "plugins" / "paad" / ".claude-plugin" / "plugin.json" +SIDECAR = Path(__file__).resolve().parent / "kiro-keywords.yaml" +POWER_MD = REPO_ROOT / "POWER.md" + +STEERING_FRONTMATTER = "---\ninclusion: manual\n---\n" + +# A self-contained ```dot ... ``` fenced block. Used to protect digraphs from +# the cross-skill-ref and `$ARGUMENTS` transforms (digraphs are copied +# verbatim). The open fence tolerates trailing characters (e.g. a stray +# "```dot " with trailing spaces) so such a block is still stashed rather than +# silently rewritten. +_DOT_BLOCK = re.compile(r"```dot[^\n]*\n.*?\n```", re.DOTALL) + +# Leading YAML frontmatter (`---\n...\n---`) at the very start of a body. +# `clean_body` retains whatever precedes the first `##`, which includes the +# source SKILL.md's `name:`/`description:` frontmatter. Steering files replace +# that with their own `inclusion: manual` frontmatter, so we strip it here. +_LEADING_FRONTMATTER = re.compile(r"\A---\n.*?\n---\n", re.DOTALL) + + +def rewrite_cross_skill_refs(text): + """Power `paad_ref_transform`: rewrite `/paad:` -> `#`. + + Unlike the legacy `strip_paad_references`, this keeps the surrounding line + and only rewrites the reference token, so cross-skill mentions become Kiro + `#` steering references. + """ + return re.sub(r"/paad:([a-z0-9-]+)", r"#\1", text) + + +def strip_leading_frontmatter(body): + """Remove the source SKILL.md's leading `---...---` frontmatter block. + + `clean_body` keeps the source's `name:`/`description:` frontmatter (it + precedes the first `##`); steering files replace it with their own + `inclusion: manual` frontmatter, so we drop the original here. The body then + starts at the skill's `# Title` h1. + """ + return _LEADING_FRONTMATTER.sub("", body, count=1) + + +def apply_outside_dot_blocks(text, transform): + """Apply `transform` to `text` everywhere EXCEPT inside ```dot blocks. + + ```dot digraphs are copied verbatim, so any power transform that rewrites + tokens (cross-skill refs, `$ARGUMENTS`) must skip them. This stashes each + fenced `dot` block behind a NUL sentinel, runs `transform` on the remainder, + then restores the blocks byte-for-byte. + """ + # NUL is never valid in hand-authored markdown; if it appeared, the stash + # sentinel would collide and `_restore` would fail opaquely. Fail loudly. + assert "\x00" not in text, "input contains a NUL byte; not valid markdown" + + stash = [] + + def _stash(match): + stash.append(match.group(0)) + return f"\x00DOT{len(stash) - 1}\x00" + + protected = _DOT_BLOCK.sub(_stash, text) + protected = transform(protected) + + def _restore(match): + return stash[int(match.group(1))] + + return re.sub(r"\x00DOT(\d+)\x00", _restore, protected) + + +def _rewrite_arguments_tokens(text): + """Rewrite `$ARGUMENTS` tokens in `text` (assumes dot blocks already removed). + + Most `$ARGUMENTS` lives in excluded sections and is already gone; this + handles whatever survives in retained prose. + """ + scope_prose = ( + "the path or scope you state in your chat message (for example, `src/`)" + ) + + # Special-case the `If no `$ARGUMENTS` provided` idiom (the real vibe.md + # phrasing): a blunt noun substitution yields the ungrammatical "If no the + # path or scope ... provided". Rewrite the whole clause as a clean + # conditional that still prompts the user for scope. + text = re.sub( + r"If no `?\$ARGUMENTS`?\s+(?:is\s+|are\s+|was\s+)?provided", + "If you don't state a scope in your chat message", + text, + ) + + # Replace any remaining `$ARGUMENTS` (optionally backtick-wrapped) with the + # prose noun phrase. + return re.sub(r"`?\$ARGUMENTS`?", scope_prose, text) + + +def rewrite_arguments_to_scope_prompt(body): + """Rewrite surviving prose `$ARGUMENTS` tokens into a scope prompt. + + Kiro manual steering files have no argument mechanism, so any `$ARGUMENTS` + that survives `clean_body` (most live in excluded sections and are already + gone) must prompt the user to state the scope in their chat message rather + than imply automatic capture. + + Digraph `$ARGUMENTS` (inside ```dot blocks) is left untouched — digraphs are + copied verbatim. + """ + return apply_outside_dot_blocks(body, _rewrite_arguments_tokens) + + +def prepend_steering_frontmatter(body): + """Prefix `body` with the literal `inclusion: manual` frontmatter. + + The result's first content is `---\\ninclusion: manual\\n---` — no leading + blank line, no BOM — followed by exactly one blank line and then the body's + `# Title`. Leading whitespace left by frontmatter-stripping is trimmed so the + separator is never doubled. + """ + return STEERING_FRONTMATTER + "\n" + body.lstrip("\n") + + +def build_steering_file(source_content, skill_name): + """Return the full `steering/.md` content for one source skill. + + Pure (string in -> string out). The power transforms layer ON TOP of the + shared `clean_body()` — section exclusion, path neutralization, and + whitespace collapsing all come from `clean_body`; this function only adds + the power-specific cross-ref / `$ARGUMENTS` / frontmatter handling. + """ + body = clean_body(source_content, paad_ref_transform=rewrite_cross_skill_refs) + body = strip_leading_frontmatter(body) + # `clean_body` only applies `paad_ref_transform` to `##` sections, so a + # `/paad:` in the pre-`##` intro (e.g. fix-architecture) would survive. + # Re-run the rewrite over the whole body to catch those — but skip ```dot + # blocks, which are copied verbatim. Idempotent: a `#name` already rewritten + # is left unchanged. + body = apply_outside_dot_blocks(body, rewrite_cross_skill_refs) + body = rewrite_arguments_to_scope_prompt(body) + return prepend_steering_frontmatter(body) + + +def _in_scope_skill_names(source_dir): + return sorted( + p.name + for p in Path(source_dir).iterdir() + if p.is_dir() + and p.name not in SKIP_NAMES + and (p / "SKILL.md").exists() + ) + + +def generate(source_dir=SOURCE_DIR, out_dir=STEERING_DIR): + """Write one `steering/.md` per in-scope skill into `out_dir`.""" + source_dir = Path(source_dir) + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + for name in _in_scope_skill_names(source_dir): + source = (source_dir / name / "SKILL.md").read_text(encoding="utf-8") + (out_dir / f"{name}.md").write_text( + build_steering_file(source, name), encoding="utf-8" + ) + print(f"Wrote steering/{name}.md") + + +# =========================================================================== +# POWER.md aggregation (Task 3) +# +# POWER.md is the power's single manifest at the repo root. It aggregates the 7 +# in-scope skills into one Kiro power: +# * frontmatter — `name`/`version` READ from plugin.json (can't drift), +# `keywords` = the curated sidecar `power:` list, the rest design constants. +# * onboarding — SOURCED from `help/SKILL.md` so it can never drift from the +# Claude Code help text. +# * a generated "When to load steering files" routing list — one `#` +# entry per in-scope skill, derived from that skill's frontmatter. +# +# `build_power_md()` is PURE and deterministic (no git, no file I/O). The +# orchestrator `generate_power()` appends the git provenance stamp AFTER that +# deterministic content, so Task 4's drift check can compare the stamp-free body. +# =========================================================================== + +# Design "Generated POWER.md shape" constants — these do NOT come from any file +# (unlike `name`/`version`/`keywords`), so they live as generator constants. +POWER_DISPLAY_NAME = "PAAD — Architecture, Review & Quality Skills" +POWER_DESCRIPTION = ( + "Multi-agent architecture analysis, code review, accessibility, and " + "quality workflows." +) +POWER_AUTHOR = "Ovid" + +# Leading YAML frontmatter (`---\n...\n---`) anchored at the VERY START of a +# file: line 1 is exactly `---`, then the captured YAML body, then a closing +# `---\n` delimiter. This is the single definition of "valid leading +# frontmatter" — it is what the generator PARSES out of a source SKILL.md +# (`read_skill_frontmatter`) AND what the frontmatter-first lint enforces on the +# generated steering/*.md + POWER.md. Sharing one regex is the REFACTOR seam: +# "what we parse as valid frontmatter" and "what we lint" can never diverge. +_SKILL_FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL) + + +def match_leading_frontmatter(text): + """Return the `re.Match` for the leading `---...---` block, or None. + + The single shared identifier of "valid leading frontmatter": the block must + be the literal first content (anchored at `\\A`, so `---` is line 1 with no + leading blank line, whitespace, or BOM) and must be terminated by a closing + `---\\n`. Group 1 is the YAML body between the delimiters. + + Reused by both `read_skill_frontmatter` (the generator's PARSE side) and the + frontmatter-first lint, so the writer and the linter agree by construction. + """ + return _SKILL_FRONTMATTER.match(text) + + +def read_plugin_meta(plugin_json_path=PLUGIN_JSON): + """Read `name` and `version` from plugin.json. + + POWER.md's `name`/`version` are sourced here so they can never drift from + the Claude Code plugin manifest (the design's "version mirrors plugin.json"). + """ + data = json.loads(Path(plugin_json_path).read_text(encoding="utf-8")) + return {"name": data["name"], "version": data["version"]} + + +def load_sidecar(sidecar_path=SIDECAR): + """Parse the Kiro keyword sidecar (`kiro-keywords.yaml`) via PyYAML.""" + return yaml.safe_load(Path(sidecar_path).read_text(encoding="utf-8")) + + +def read_skill_frontmatter(source_content): + """Parse a SKILL.md's leading `---...---` frontmatter into a dict. + + Used to derive each routing entry's `name`/`description` from the source + skill (drift-proof — descriptions are never hand-copied into POWER.md). + """ + match = match_leading_frontmatter(source_content) + if not match: + return {} + return yaml.safe_load(match.group(1)) or {} + + +def missing_sidecar_entries(skill_names, sidecar): + """Return in-scope skills with no entry in the sidecar's `skills:` map. + + Pure and testable: feed a sidecar missing an entry and assert the result. + Sorted for deterministic warnings. + """ + present = set((sidecar.get("skills") or {}).keys()) + return sorted(name for name in skill_names if name not in present) + + +def warn_missing_sidecar_entries(skill_names, sidecar): + """Emit a stderr warning for any in-scope skill missing a sidecar entry. + + A newly added skill cannot ship without an explicit keyword decision: the + warning forces the author to add a `skills:` entry (Design: "Edge cases"). + """ + missing = missing_sidecar_entries(skill_names, sidecar) + if missing: + print( + "WARNING: skills missing from kiro-keywords.yaml `skills:` map: " + + ", ".join(missing), + file=sys.stderr, + ) + return missing + + +def _power_frontmatter(plugin_meta, sidecar): + """Build the POWER.md YAML frontmatter block (literal first content). + + `name`/`version` come from plugin.json; `keywords` is the sidecar `power:` + list VERBATIM (not a union of per-skill keywords); the rest are constants. + """ + keywords = ", ".join(sidecar["power"]) + return ( + "---\n" + f"name: {plugin_meta['name']}\n" + f"displayName: {POWER_DISPLAY_NAME}\n" + f"description: {POWER_DESCRIPTION}\n" + f"keywords: [{keywords}]\n" + f"author: {POWER_AUTHOR}\n" + f"version: {plugin_meta['version']}\n" + "---\n" + ) + + +# Short Kiro-context framing appended after the help-sourced tagline. POWER.md's +# detailed skill directory is the generated "When to load steering files" +# routing list (frontmatter-derived, `#`), NOT the verbatim Claude Code +# help overview — so the onboarding stays concise and Claude-Code-isms (the +# `/paad:` skill list, the `makefile` line) never leak into the power. +POWER_FRAMING = ( + "Each skill is exposed as a manual steering file the agent loads on " + "demand. Pick the one that matches your request from the list below." +) + + +def _help_tagline(help_content): + """Extract just the help OVERVIEW TAGLINE from `help/SKILL.md`. + + The tagline is the first non-empty line of the fenced overview block inside + the `## Overview (no arguments)` section. Sourcing the tagline (rather than + hand-writing it) keeps POWER.md's onboarding drift-proof, while DROPPING the + rest of the verbatim overview block (the `/paad:` skill list + `makefile` + line) that is Claude-Code-specific and wrong for the Kiro power context. + + Falls back to the first non-empty line of the content if the expected + markers are absent (keeps the builder robust + deterministic). + """ + after = help_content.split("## Overview (no arguments)", 1) + region = after[1] if len(after) > 1 else help_content + # Tolerate a language hint on the opening fence (e.g. ```text) and anchor to + # the FIRST fence in the Overview region. `[^\n]*` mirrors `_DOT_BLOCK`'s + # language tolerance; a bare-fence-only pattern would silently match the + # WRONG fence if the help block ever gained a language hint, corrupting the + # tagline into the literal `---` frontmatter marker. + fence = re.search(r"```[^\n]*\n(.*?)\n```", region, re.DOTALL) + block = fence.group(1) if fence else region + for line in block.splitlines(): + if line.strip(): + return line.strip() + return region.strip() + + +def _onboarding(help_content): + """Build the trimmed, Kiro-appropriate onboarding. + + The help tagline (sourced verbatim, drift-proof) plus a short framing + sentence — NOT the verbatim help overview block. The detailed skill listing + is the generated routing list, so it is never duplicated here. + """ + return _help_tagline(help_content) + "\n\n" + POWER_FRAMING + + +def _routing_list(source_dir): + """Build the "When to load steering files" routing list. + + One entry per in-scope skill (sorted), each `**#** — ` + derived from that skill's frontmatter (drift-proof). + """ + source_dir = Path(source_dir) + lines = [ + "## When to load steering files", + "", + "Each skill is a manual steering file — load the one matching the " + "user's request (type `/` in chat to pick it, or reference `#`):", + "", + ] + for name in _in_scope_skill_names(source_dir): + source = (source_dir / name / "SKILL.md").read_text(encoding="utf-8") + fm = read_skill_frontmatter(source) + description = fm.get("description", "").strip() + lines.append(f"- **#{name}** — {description}") + return "\n".join(lines) + + +def build_power_md(source_dir, sidecar, plugin_meta, help_content): + """Return the POWER.md content (PURE, deterministic, no git). + + Reads the in-scope SKILL.md files (via `_routing_list`) to derive the + routing entries, but performs NO git calls and NO writes — given the same + inputs it always returns byte-identical output. + + Composed of: + 1. frontmatter (name/version from plugin_meta, keywords from sidecar). + 2. onboarding sourced from `help_content`. + 3. a generated "When to load steering files" routing list. + + The provenance stamp is NOT included — the orchestrator appends it AFTER + this content so Task 4's drift check can compare a stamp-free body. + """ + frontmatter = _power_frontmatter(plugin_meta, sidecar) + onboarding = _onboarding(help_content) + routing = _routing_list(source_dir) + + return ( + frontmatter + + "\n" + + "# PAAD — Kiro Power\n" + + "\n" + + onboarding + + "\n" + + "\n" + + routing + + "\n" + ) + + +def git_short_sha(): + """Return the repo's current short commit SHA (orchestrator-only). + + Isolated behind a helper so the pure builder stays git-free and tests can + monkeypatch it for a deterministic provenance stamp. + """ + return subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +def provenance_stamp(short_sha): + """Return the provenance stamp line for `short_sha`. + + A single, clearly-separable HTML comment appended as POWER.md's FINAL line. + Injecting it AFTER the comparable content lets Task 4's drift check exclude + it. + """ + return f"" + + +# =========================================================================== +# Drift / idempotency (Task 4) +# +# The committed POWER.md's stamp embeds HEAD-at-generation-time, so it ALWAYS +# lags HEAD by one commit and a naive `git diff --exit-code` is forever dirty. +# The drift check therefore compares a STAMP-FREE view of POWER.md. The same +# `without_stamp` helper is shared by the idempotency test and the drift check +# so they cannot disagree (the REFACTOR seam), and the comparison itself is a +# PURE `find_drift(generated, ondisk)` over in-memory `{name: content}` maps. +# =========================================================================== + +# Matches the provenance-stamp line anchored on the stable, content-free prefix +# (so a changed short SHA never registers as drift) AND on end-of-text (`\Z`): +# the stamp is contractually the FINAL line, so anchoring there means a body line +# that merely STARTS with the stamp literal mid-document is never stripped. +_STAMP_LINE = re.compile( + r"\n?\Z" +) + + +def without_stamp(text): + """Return `text` with the provenance-stamp line removed. + + Shared by the idempotency test and the drift check: a POWER.md that differs + from a freshly-generated one ONLY in its embedded short SHA must compare + equal, because the committed stamp always lags HEAD by one commit. Text with + no stamp is returned unchanged (idempotent). + """ + return _STAMP_LINE.sub("", text) + + +def find_drift(generated, ondisk): + """Return the sorted names of files that have really drifted. + + PURE comparison of two `{name: content}` maps (the freshly-generated + contents vs. the on-disk contents). For each generated file: + + * POWER.md is compared with the provenance stamp normalized away + (`without_stamp`), so a stamp-only delta is NOT drift. + * Any other file is compared byte-for-byte. + * A generated file missing entirely from `ondisk` counts as drift. + + Returns the drifted file names sorted (deterministic). An empty list means + the on-disk tree matches what the generator would produce now. + + Orphans: any on-disk `steering/*.md` with no generator counterpart is also + drift (the generators only WRITE, never DELETE, so a removed/renamed skill + leaves a stale steering file the generator would never produce). POWER.md is + always regenerated, so the orphan sweep is steering-only. + """ + def _norm(name, content): + return without_stamp(content) if name == "POWER.md" else content + + drifted = [] + for name, gen_content in generated.items(): + if name not in ondisk: + drifted.append(name) + continue + if _norm(name, gen_content) != _norm(name, ondisk[name]): + drifted.append(name) + + # Flag stale on-disk steering files the generator would never produce. + for name in ondisk: + if ( + name not in generated + and name.startswith("steering/") + and name.endswith(".md") + ): + drifted.append(name) + + return sorted(drifted) + + +def _load_power_inputs(source_dir): + """Read the three POWER.md inputs: plugin meta, sidecar, and help content. + + Shared by `generate_power` (the writer) and `_generate_in_memory` (the drift + check) so the two paths can never diverge in HOW they load inputs. PURE reads + only — no warnings, no writes; those side-effects stay in `generate_power`. + """ + source_dir = Path(source_dir) + plugin_meta = read_plugin_meta() + sidecar = load_sidecar() + help_content = (source_dir / "help" / "SKILL.md").read_text(encoding="utf-8") + return plugin_meta, sidecar, help_content + + +def _generate_in_memory(source_dir): + """Return the `{relative-name: content}` map the generator WOULD write. + + Builds POWER.md (with the current provenance stamp) and every in-scope + `steering/.md` purely in memory — no writes — so the drift check never + mutates the working tree. + """ + source_dir = Path(source_dir) + + plugin_meta, sidecar, help_content = _load_power_inputs(source_dir) + + power_body = build_power_md( + source_dir=source_dir, + sidecar=sidecar, + plugin_meta=plugin_meta, + help_content=help_content, + ) + power = power_body + "\n" + provenance_stamp(git_short_sha()) + "\n" + + generated = {"POWER.md": power} + for name in _in_scope_skill_names(source_dir): + source = (source_dir / name / "SKILL.md").read_text(encoding="utf-8") + generated[f"steering/{name}.md"] = build_steering_file(source, name) + return generated + + +def _steering_file_names(root): + """Return the on-disk `steering/.md` relative names under `root`, sorted. + + The single enumeration of "which steering files exist on disk". The drift + check (`_read_ondisk`) and the frontmatter-first lint (`lint_frontmatter_first`) + MUST agree on which files constitute the power; sharing one glob means they + can never silently diverge. Returns an empty list if `steering/` is absent. + """ + steering_dir = Path(root) / "steering" + if not steering_dir.is_dir(): + return [] + return [f"steering/{path.name}" for path in sorted(steering_dir.glob("*.md"))] + + +def _read_ondisk(root, names): + """Read the on-disk content for the generator's files PLUS every steering file. + + Returns a `{name: content}` map. The explicit `names` (the generator's + outputs) are read so a missing one is reported as drift; in ADDITION, every + actual `steering/*.md` under `root` is enumerated so a stale ORPHAN (a + steering file with no generator counterpart) is visible to `find_drift`. A + file that does not exist is simply omitted. + """ + root = Path(root) + ondisk = {} + for name in names: + path = root / name + if path.exists(): + ondisk[name] = path.read_text(encoding="utf-8") + + # Enumerate the real steering tree (shared helper) so orphaned files (never + # (re)generated) become visible — the generators only write, never delete. + for name in _steering_file_names(root): + if name not in ondisk: + ondisk[name] = (root / name).read_text(encoding="utf-8") + + return ondisk + + +def check_sidecar_completeness(source_dir=SOURCE_DIR): + """Return in-scope skills missing a `kiro-keywords.yaml` `skills:` entry. + + Reuses `missing_sidecar_entries` (the logic behind `warn_missing_sidecar_entries`) + but is called on the `--check` path: unlike the non-fatal `make kiro` warning, + a non-empty result here MUST fail `check-kiro` so a new skill committed without + a sidecar keyword decision never passes `make test` green. All in-scope skills + currently have entries, so this returns `[]` on the clean tree. + """ + source_dir = Path(source_dir) + sidecar = load_sidecar() + return missing_sidecar_entries(_in_scope_skill_names(source_dir), sidecar) + + +def check_drift(source_dir=SOURCE_DIR, root=REPO_ROOT): + """Return the sorted names of committed power files that have drifted. + + Regenerates POWER.md + every steering file IN MEMORY from `source_dir`, reads + the matching on-disk files under `root` (plus every on-disk `steering/*.md` + so orphans are caught), and compares them with `find_drift` (excluding the + provenance stamp). An empty list means the committed power is up to date; a + non-empty list names the stale, hand-edited, or orphaned files. + """ + generated = _generate_in_memory(source_dir) + ondisk = _read_ondisk(root, generated.keys()) + return find_drift(generated, ondisk) + + +# =========================================================================== +# Frontmatter-first lint (Task 5) +# +# Kiro has ONE hard rule for steering files and POWER.md: the YAML frontmatter +# must be the LITERAL first content of the file. A leading blank line, leading +# whitespace, or a UTF-8 BOM before `---` makes Kiro silently ignore the file — +# a stray blank line disables a skill with no error. The generator already +# WRITES files that satisfy this; this lint enforces it on the on-disk tree so a +# hand-edit (or a future generator regression) is caught loudly. +# +# REFACTOR seam: the pure check reuses `match_leading_frontmatter` — the SAME +# matcher the generator uses to PARSE source frontmatter — so "what we write and +# parse as valid frontmatter" and "what we lint" share one definition. +# =========================================================================== + + +def frontmatter_first_violation(text): + """Return a reason string if `text`'s frontmatter is not the literal first + content, else None. + + PURE and unit-testable against fixture strings. Valid means: `---` on line 1 + (no leading blank line, whitespace, or UTF-8 BOM), a closing `---\\n` + delimiter, and parseable YAML between the delimiters. The leading-block + identification is delegated to `match_leading_frontmatter` (the shared seam), + so this lint and the generator's parser agree by construction. + """ + if text.startswith(""): + return "starts with a UTF-8 BOM before the frontmatter `---`" + + match = match_leading_frontmatter(text) + if not match: + first_line = text.split("\n", 1)[0] + if first_line != "---": + return ( + "frontmatter is not the first content " + f"(line 1 is {first_line!r}, expected '---')" + ) + return "frontmatter `---` block is missing its closing `---` delimiter" + + try: + parsed = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + return f"frontmatter YAML is not parseable: {exc}" + # Real steering/POWER frontmatter is always a YAML mapping (`name:`, + # `inclusion:`, ...). A scalar or list parses cleanly but is unusable + # frontmatter, so reject it. (Lint-side only — the shared matcher and + # `read_skill_frontmatter` are unchanged; real frontmatter never trips this.) + if not isinstance(parsed, dict): + return "frontmatter is not a YAML mapping" + return None + + +def lint_frontmatter_first(root=REPO_ROOT): + """Return sorted `": "` strings for frontmatter-first violations. + + Scans the on-disk root `POWER.md` plus every `steering/*.md` under `root` + (via the shared `_steering_file_names`, the SAME enumeration `_read_ondisk` + uses, so orphan steering files are linted too) and applies + `frontmatter_first_violation` to each. An empty list means every power file + has frontmatter as its literal first content. + """ + root = Path(root) + violations = [] + + targets = ["POWER.md"] + _steering_file_names(root) + + for name in targets: + path = root / name + if not path.exists(): + continue + text = path.read_text(encoding="utf-8") + reason = frontmatter_first_violation(text) + if reason is not None: + violations.append(f"{name}: {reason}") + + return sorted(violations) + + +def generate_power(source_dir=SOURCE_DIR, out_path=POWER_MD): + """Write POWER.md: the pure deterministic content + the provenance stamp. + + Loads plugin.json, the sidecar, and `help/SKILL.md`; warns about any + in-scope skill missing a sidecar entry; builds the deterministic content; + then appends the git provenance stamp as the final line and writes the file. + """ + source_dir = Path(source_dir) + out_path = Path(out_path) + + plugin_meta, sidecar, help_content = _load_power_inputs(source_dir) + + warn_missing_sidecar_entries(_in_scope_skill_names(source_dir), sidecar) + + content = build_power_md( + source_dir=source_dir, + sidecar=sidecar, + plugin_meta=plugin_meta, + help_content=help_content, + ) + # Stamp is appended AFTER the comparable content (REFACTOR seam): one blank + # line then the stamp as the clearly-separable final line. + stamped = content + "\n" + provenance_stamp(git_short_sha()) + "\n" + out_path.write_text(stamped, encoding="utf-8") + print(f"Wrote {out_path.name}") + + +def _main(argv): + """CLI entry point. `--check` reports drift; `--lint` enforces the Kiro + frontmatter-first rule; otherwise regenerate the power. + + `--check` exits non-zero (1) with a sidecar-specific message when any + in-scope skill has no `kiro-keywords.yaml` `skills:` entry, OR with a + per-file message when the committed POWER.md / steering files are stale or + hand-edited (ignoring the provenance stamp). It exits 0 when the sidecar is + complete and the tree is clean. + + `--lint` exits non-zero (1) with a per-file message when any on-disk + POWER.md / steering file does NOT have its YAML frontmatter as the literal + first content (a leading blank line, BOM, or missing closing `---`), and + exits 0 when every file is clean. + + The default (no args) regenerates the steering files and POWER.md. + """ + if "--lint" in argv: + violations = lint_frontmatter_first() + if violations: + print( + "FRONTMATTER LINT: the Kiro frontmatter-first rule is violated. " + "Frontmatter MUST be the literal first content (`---` on line 1, " + "no leading blank line or BOM). Offending files:", + file=sys.stderr, + ) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + print("Kiro frontmatter-first rule satisfied (no violations).") + return 0 + + if "--check" in argv: + missing = check_sidecar_completeness() + if missing: + print( + "SIDECAR: in-scope skills have no entry in " + "scripts/kiro-keywords.yaml `skills:` map. Every shipped skill " + "needs an explicit keyword decision — add a `skills:` entry. " + "Missing skills: " + ", ".join(missing), + file=sys.stderr, + ) + return 1 + + drifted = check_drift() + if drifted: + print( + "DRIFT: the committed Kiro power is stale or hand-edited. " + "Run `make kiro` to regenerate. Drifted files:", + file=sys.stderr, + ) + for name in drifted: + print(f" - {name}", file=sys.stderr) + return 1 + print("Kiro power is up to date (no drift).") + return 0 + + generate() + generate_power() + print("Steering + POWER.md generation complete.") + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/scripts/convert_skills.py b/scripts/convert_skills.py index 9f71e86..17e84b1 100755 --- a/scripts/convert_skills.py +++ b/scripts/convert_skills.py @@ -1,9 +1,13 @@ #!/usr/bin/env python3 -import os import re +import sys from pathlib import Path +# Make the shared body-cleaning core importable regardless of cwd. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from skill_body import SKIP_NAMES, clean_body # noqa: E402 + # Paths relative to repository root SOURCE_DIR = "plugins/paad/skills" TARGET_DIR = "kiro_and_antigravity/skills" @@ -16,11 +20,8 @@ def convert_skills(): kiro_skills_root.mkdir(parents=True, exist_ok=True) agent_skills_root.mkdir(parents=True, exist_ok=True) - skip_names = ["makefile", "help"] - unwanted_headers = ["Arguments", "Input Resolution", "Pre-flight Checks", "Document classification"] - for skill_path in Path(SOURCE_DIR).iterdir(): - if not skill_path.is_dir() or skill_path.name in skip_names: + if not skill_path.is_dir() or skill_path.name in SKIP_NAMES: continue skill_file = skill_path / "SKILL.md" @@ -38,46 +39,10 @@ def convert_skills(): skill_name = name_match.group(1).strip() if name_match else skill_path.name description = desc_match.group(1).strip() if desc_match else "" - # Split into sections by headers (##) - # We use a non-capturing group for the split but keep the header as part of the next chunk - # Actually splitting by \n## works better if we prepend \n to content - parts = re.split(r'\n(##+ .*)', content) - - # parts[0] is everything before the first ## - cleaned_content = parts[0] - - # Process header/body pairs - for i in range(1, len(parts), 2): - header_line = parts[i] - body = parts[i+1] - - header_text = re.sub(r'^##+\s*', '', header_line).strip() - - # Skip unwanted sections - if any(uh in header_text for uh in unwanted_headers): - continue - - # Neutralize "paad/" paths to ".reviews/" or ".reports/" - body = body.replace("paad/architecture-reviews/", ".reviews/architecture/") - body = body.replace("paad/code-reviews/", ".reviews/code/") - body = body.replace("paad/pushback-reviews/", ".reviews/pushback/") - body = body.replace("paad/alignment-reviews/", ".reviews/alignment/") - body = body.replace("paad/", ".reviews/") - - # Remove entire lines containing /paad: (usually follow-up suggestions or command examples) - body = re.sub(r'^.*\/paad:[a-z0-9-]+.*$', '', body, flags=re.MULTILINE) - - # Additional cleanup for any remaining /paad: mentions just in case - body = re.sub(r'\(?/paad:[a-z0-9-]+\)?', '', body) - - # Clean up trailing whitespace and excessive newlines - body = body.rstrip() + "\n" - - cleaned_content += "\n" + header_line + body - - # Final cleanup for consecutive empty lines - cleaned_content = re.sub(r'\n{3,}', '\n\n', cleaned_content).strip() + "\n" - + # Clean the body using the shared core (section split + exclusion + + # path neutralization + legacy /paad: stripping + whitespace collapse). + cleaned_content = clean_body(content) + # Write Kiro Skill kiro_skill_dir = kiro_skills_root / skill_path.name kiro_skill_dir.mkdir(exist_ok=True) diff --git a/scripts/kiro-keywords.yaml b/scripts/kiro-keywords.yaml new file mode 100644 index 0000000..a936b02 --- /dev/null +++ b/scripts/kiro-keywords.yaml @@ -0,0 +1,29 @@ +# Kiro power keyword metadata — Kiro-only, consumed by scripts/build-kiro-power.py. +# +# These keywords live HERE, not in SKILL.md frontmatter: keywords are a Kiro concern, +# and keeping them out of SKILL.md leaves the Claude Code plugin byte-for-byte untouched. +# `claude plugin validate` does not inspect SKILL.md frontmatter, so a sidecar is the only +# place a Kiro-only key can be validated/owned. (Design: "Keywords source: sidecar file".) + +# Curated, NARROW keyword set written verbatim into POWER.md frontmatter `keywords`. +# This drives *power-level* activation. It is deliberately a hand-picked subset, NOT the +# union of every skill's keywords, to limit noisy power activation while staying +# discoverable. (Design: "Residual noise caveat" + "Curate the power-level keyword set".) +power: + - architecture + - code review + - accessibility + - requirements + - code quality + +# Per-skill keywords — one entry per SHIPPED steering file (excludes help + makefile). +# The generator WARNS if a shipped skill has no entry here, so a newly added skill cannot +# ship without an explicit decision about how it surfaces. (Design: "Edge cases" — warn.) +skills: + agentic-a11y: [accessibility, a11y, screen reader, WCAG] + agentic-architecture: [architecture, coupling, structure, tech debt] + agentic-review: [code review, branch review, pre-merge] + alignment: [alignment, requirements, design, coverage] + fix-architecture: [fix architecture, remediation, flaws] + pushback: [pushback, spec review, scope, feasibility] + vibe: [vibe coding, quick fix, TDD] diff --git a/scripts/skill_body.py b/scripts/skill_body.py new file mode 100644 index 0000000..423a84f --- /dev/null +++ b/scripts/skill_body.py @@ -0,0 +1,126 @@ +"""Shared body-cleaning core for the PAAD skill generators. + +This module owns the *single* definition of WHICH sections are kept or dropped +when a `plugins/paad/skills//SKILL.md` is transformed into a copy-source +skill. Two generators consume it: + + * `convert_skills.py` -> the legacy Kiro/Antigravity tree (strips + cross-skill `/paad:` references entirely). + * `build-kiro-power.py` -> the Kiro power (written in a later task; will + rewrite `/paad:` references to `#` instead + of stripping them). + +Both share the section-splitting, section-exclusion, path-neutralization, and +whitespace-collapsing behavior so the two generators can never disagree about +which sections survive. The only intended difference is the per-section +`/paad:` handling, which is injected via `paad_ref_transform`. + +`clean_body()` is intentionally pure (string in -> string out, no file I/O) so +it is directly unit-testable. +""" + +import re + +# Single source of truth for the section headers that are dropped from every +# generated skill. Both generators import this; do NOT copy-paste the names. +EXCLUDED_SECTIONS = [ + "Arguments", + "Input Resolution", + "Pre-flight Checks", + "Document classification", +] + +# Single source of truth for the skills excluded from BOTH generators. `help` +# becomes POWER.md's index (and the legacy tree's, via convert_skills.py); +# `makefile` is intentionally omitted to keep the two generators' skill sets +# byte-for-byte identical. Both generators and the golden-output test import +# this; do NOT copy-paste the names — the design REQUIRES the two generators' +# skill sets stay identical, and a duplicated literal has no parity guard. +SKIP_NAMES = frozenset({"help", "makefile"}) + + +def neutralize_paad_paths(body): + """Rewrite `paad/...` output paths to neutral `.reviews/...` paths. + + Shared by both generators: the copy-source skills must not reference the + plugin's own `paad/` review directories. NOTE: this is NOT a seam — path + neutralization is fixed and identical for both generators. Only + `paad_ref_transform` (the `/paad:` handling) is meant to vary between them. + """ + body = body.replace("paad/architecture-reviews/", ".reviews/architecture/") + body = body.replace("paad/code-reviews/", ".reviews/code/") + body = body.replace("paad/pushback-reviews/", ".reviews/pushback/") + body = body.replace("paad/alignment-reviews/", ".reviews/alignment/") + body = body.replace("paad/", ".reviews/") + return body + + +def strip_paad_references(body): + """Legacy `/paad:` handling: drop whole lines, then any stray mentions. + + This is the default `paad_ref_transform` and reproduces `convert_skills.py` + exactly. The power generator will supply a different transform that rewrites + `/paad:` to `#` instead of removing it. + """ + # Remove entire lines containing /paad: (usually follow-up suggestions or + # command examples). + body = re.sub(r'^.*\/paad:[a-z0-9-]+.*$', '', body, flags=re.MULTILINE) + # Additional cleanup for any remaining /paad: mentions just in case. + body = re.sub(r'\(?/paad:[a-z0-9-]+\)?', '', body) + return body + + +def clean_body(content, excluded_sections=None, + paad_ref_transform=strip_paad_references): + """Transform a raw SKILL.md `content` string into a cleaned skill body. + + `excluded_sections` defaults to the module-level `EXCLUDED_SECTIONS` via a + None sentinel (avoids the mutable-default-argument pitfall). + + The pipeline is: + 1. Split into sections by `##`+ headers. + 2. Drop sections whose header matches any `excluded_sections` entry. + 3. For each kept section: neutralize `paad/` paths, apply + `paad_ref_transform` to its `/paad:` references, and trim trailing + whitespace. + 4. Collapse runs of 3+ newlines and ensure a single trailing newline. + + Steps 1-2 (which sections survive) are shared and fixed across generators; + only `paad_ref_transform` is meant to vary. + """ + if excluded_sections is None: + excluded_sections = EXCLUDED_SECTIONS + + # Split into sections by headers (##). The capturing group keeps each + # header line as its own element so we can re-pair headers with bodies. + parts = re.split(r'\n(##+ .*)', content) + + # parts[0] is everything before the first ##. + cleaned_content = parts[0] + + # Process header/body pairs. + for i in range(1, len(parts), 2): + header_line = parts[i] + body = parts[i + 1] + + header_text = re.sub(r'^##+\s*', '', header_line).strip() + + # Skip unwanted sections. + if any(uh in header_text for uh in excluded_sections): + continue + + # Neutralize "paad/" paths to ".reviews/". + body = neutralize_paad_paths(body) + + # Apply the generator-specific /paad: reference handling. + body = paad_ref_transform(body) + + # Clean up trailing whitespace and excessive newlines. + body = body.rstrip() + "\n" + + cleaned_content += "\n" + header_line + body + + # Final cleanup for consecutive empty lines. + cleaned_content = re.sub(r'\n{3,}', '\n\n', cleaned_content).strip() + "\n" + + return cleaned_content diff --git a/steering/agentic-a11y.md b/steering/agentic-a11y.md new file mode 100644 index 0000000..7e3eac6 --- /dev/null +++ b/steering/agentic-a11y.md @@ -0,0 +1,350 @@ +--- +inclusion: manual +--- + +# Accessibility Audit + +Multi-agent accessibility audit of user-facing code across any platform. Dispatches specialist agents in parallel — each focused on a different disability category — verifies findings to filter false positives, maps every issue to specific accessibility criteria, and produces a persistent report with concrete fix recommendations. + +**Conformance target:** WCAG 2.2 AA as baseline (applied via WCAG2ICT for non-web platforms). AAA criteria are flagged as bonus recommendations. Platform-specific guidelines (Apple HIG Accessibility, Android Accessibility, Xbox Accessibility Guidelines) are referenced where applicable. + +**This is a technique skill.** Follow the phases in order. Do not skip verification. + +## Phase 1: Reconnaissance + +Run these steps and collect results: + +### 1. Platform detection + +Classify the project into one or more platforms from the table above. A project may span multiple platforms (e.g., a web app with a CLI tool, or a React Native app with web support). Record all detected platforms — specialists will receive this classification. + +### 2. Tech stack identification + +Per detected platform: + +- **Web:** Frameworks (React, Vue, Angular, Svelte, Next.js, etc.), CSS approach (Tailwind, CSS Modules, styled-components, etc.), component libraries (MUI, Radix, Headless UI, Bootstrap — note built-in a11y support) +- **iOS:** SwiftUI vs UIKit vs mixed, any a11y wrapper libraries +- **Android:** Jetpack Compose vs View system vs mixed, any a11y libraries +- **React Native:** version, any a11y libraries (e.g., `react-native-a11y`) +- **Flutter:** version, any a11y packages +- **Desktop:** framework and toolkit +- **CLI:** argument parser, output formatting library, terminal UI library (if any) +- **Game:** engine, UI system (in-engine UI, HTML overlay, custom) + +### 3. Inventory user-facing code + +Collect all files that produce user-facing output or handle user interaction, grouped by platform. + +### 4. Check for existing a11y tooling + +- **Web:** `eslint-plugin-jsx-a11y`, `eslint-plugin-vuejs-accessibility`, `axe-core`, `pa11y`, Lighthouse configs +- **iOS:** Accessibility Inspector usage, XCTest accessibility tests +- **Android:** Accessibility Scanner, Espresso accessibility checks, `AccessibilityChecks.enable()` +- **React Native:** `@testing-library/react-native` accessibility queries +- **Flutter:** `SemanticsDebugger`, accessibility-related widget tests +- **CLI:** structured output tests, `--no-color` support tests +- **Game:** accessibility options menu, remapping tests +- **CI:** any automated a11y checks in CI pipeline +- Note what's present — specialists should not re-flag issues that existing tooling already catches, unless the tooling is misconfigured or findings are suppressed + +### 5. Scan for steering files + +`CLAUDE.md`, `AGENTS.md`, any a11y-specific documentation or guidelines. + +### 6. Estimate scope size + +- **Small:** <20 user-facing files +- **Medium:** 20-100 user-facing files +- **Large:** 100+ user-facing files + +### 7. Build manifest + +Files to audit, grouped for specialists, annotated with detected platform(s). + +**Steering file caveat:** Include in every agent prompt: "Steering files (CLAUDE.md, etc.) describe conventions but may be stale. If you find a contradiction between steering files and actual code, flag it as a finding." + +## Phase 2: Specialist Audit (Parallel) + +Dispatch these agents simultaneously using the Agent tool. Each receives: the file manifest, detected platform(s), steering file contents, existing a11y tooling notes, and their specialist focus. + +### Core specialists (always dispatched) + +| Agent | Focus | Who it helps | +|-------|-------|-------------| +| **Screen Reader & Assistive Tech** | Programmatic exposure of UI semantics so assistive technologies can convey structure, content, and state to users | Blind and low-vision users using screen readers (VoiceOver, TalkBack, NVDA, JAWS, Orca) | +| **Visual & Color** | Sufficient contrast, independence from color alone, support for magnification/text scaling, visible focus/selection indicators | Low-vision users, colorblind users | +| **Keyboard & Motor** | Complete operability without fine motor control — keyboard, switch, sip-and-puff, eye tracking, voice control — with adequate target sizes and no traps | Motor-impaired users, switch access users, sip-and-puff users, voice control users | +| **Cognitive & Learning** | Consistent, predictable interfaces with clear feedback, error recovery, plain language, and no unnecessary cognitive load | Users with cognitive disabilities, learning disabilities, attention disorders, memory impairments | +| **Multimedia & Temporal** | Alternatives for audio/visual content, safe motion/animation, adequate time, no seizure triggers | Deaf/hard-of-hearing users, vestibular disorders, photosensitive epilepsy, users who need more time | + +### Conditional specialist + +Dispatch when the platform has framework-specific a11y pitfalls (web frameworks, SwiftUI, Compose, Flutter, React Native, game engines): + +| Agent | Focus | Input | +|-------|-------|-------| +| **Platform-Specific Patterns** | Framework/platform-specific a11y pitfalls, misuse of platform a11y APIs, navigation/routing a11y, dynamic content patterns | File manifest + platform/framework detection results | + +### Agent prompt template + +Each specialist agent prompt must include: +- The list of files to audit (from their manifest group) +- Contents of files in their audit scope +- The detected platform(s) +- Steering file contents with the staleness caveat +- Existing a11y tooling notes +- Instruction: "You are an accessibility specialist focused on [FOCUS AREA]. The detected platform(s) for this project: [PLATFORMS]. Your goal is to find accessibility barriers that affect [USER GROUP]. For each finding report: file:line, what's wrong, which accessibility criterion it violates (WCAG 2.2 criterion where applicable, or platform-specific guideline), the conformance level (A/AA/AAA), who is affected and how, a concrete code-level fix, and your confidence (0-100). Only report findings with confidence >= 60." + +### Platform-specific checks per specialist + +Include the relevant platform section(s) in each specialist's prompt based on detected platform(s). + +#### Screen Reader & Assistive Tech + +**Web:** (1) Correct ARIA roles, states, and properties on custom components; (2) semantic HTML (headings, landmarks, lists, tables) over generic `div`/`span`; (3) heading hierarchy without skipped levels; (4) meaningful alt text (not filenames or "image"); (5) programmatically associated form labels; (6) `aria-live` regions for dynamic content; (7) link/button purpose clear from text or accessible name; (8) status messages via `role="status"` or `aria-live`. + +**iOS:** (1) `accessibilityLabel` set on all interactive and meaningful elements; (2) `accessibilityTraits` / SwiftUI `.accessibilityAddTraits()` correctly applied (`.button`, `.header`, `.image`, etc.); (3) `accessibilityHint` for non-obvious actions; (4) `isAccessibilityElement` grouping — containers group related info, decorative elements excluded; (5) `accessibilityValue` for sliders/progress; (6) `UIAccessibility.post(.screenChanged, ...)` and `.layoutChanged` notifications for dynamic updates; (7) custom actions via `accessibilityCustomActions`; (8) SwiftUI: `.accessibilityElement(children: .combine)` for logical grouping. + +**Android:** (1) `contentDescription` on all interactive and meaningful elements; (2) `importantForAccessibility` set correctly — decorative elements marked `no`; (3) `labelFor` connecting labels to inputs; (4) `accessibilityLiveRegion` for dynamic content; (5) `accessibilityHeading` on section headers; (6) Compose: `semantics { }` block with appropriate properties, `contentDescription`, `heading()`, `stateDescription`; (7) custom `AccessibilityNodeInfo` actions where needed; (8) `ViewCompat.setAccessibilityDelegate` for custom views. + +**React Native:** (1) `accessibilityLabel` on all interactive elements; (2) `accessibilityRole` set correctly (`button`, `header`, `link`, `image`, etc.); (3) `accessibilityState` for toggles, disabled, expanded states; (4) `accessibilityHint` for non-obvious actions; (5) `accessibilityLiveRegion` for dynamic updates; (6) `accessibilityElementsHidden` for decorative content; (7) `AccessibilityInfo.announceForAccessibility()` for dynamic state changes. + +**Flutter:** (1) `Semantics` widget wrapping meaningful UI with `label`, `button`, `header`, `image` properties; (2) `excludeSemantics: true` on decorative elements; (3) `MergeSemantics` for logically grouped content; (4) `SemanticsService.announce()` for dynamic updates; (5) `tooltip` properties on `IconButton` and similar; (6) `semanticsLabel` on `Text` where displayed text differs from meaning. + +**CLI:** (1) Structured, parseable output (not just visual formatting with boxes/lines); (2) information not conveyed solely by position/layout — screen readers linearize output; (3) progress indication via text updates, not just spinners or progress bars that rely on cursor repositioning; (4) error messages written to stderr with clear text (not just color/emoji). + +**Game:** (1) UI elements have text alternatives available for screen reader narration mode; (2) menu items narrated with name, type, and state; (3) game state changes announced; (4) narration option in settings if not always-on. + +#### Visual & Color + +**Web:** Contrast ratios — AA: 4.5:1 normal text, 3:1 large text (18pt+/14pt+ bold), 3:1 UI components and graphics. AAA: 7:1/4.5:1. Check CSS custom properties and theme values. Flag color-only meaning (error states, status indicators, required fields, links distinguished only by color). Reflow at 320px width. Text spacing override support. `prefers-contrast` media query support. + +**iOS:** (1) Dynamic Type support (`UIFontMetrics`, SwiftUI `.dynamicTypeSize`), test with all type sizes including accessibility sizes; (2) Bold Text support (`UIAccessibility.isBoldTextEnabled`); (3) Increase Contrast (`UIAccessibility.isDarkerSystemColorsEnabled`); (4) Reduce Transparency support; (5) sufficient contrast ratios in custom themes (same ratios as web); (6) color-only information has shape/icon/text alternatives; (7) SF Symbols accessibility variants. + +**Android:** (1) `sp` units for text (scales with user preference); (2) test at 200% font scale; (3) High Contrast Text setting support; (4) sufficient contrast in custom themes; (5) color-only meaning has alternatives; (6) Magnification gesture compatibility (no content hidden at 200%+ zoom); (7) custom views render correctly with font scaling. + +**React Native:** (1) Font scaling support — not disabled via `allowFontScaling={false}` unless justified; (2) layout accommodates scaled text without overflow/truncation; (3) contrast ratios on all custom-themed elements; (4) color-only meaning has alternatives. + +**Flutter:** (1) `MediaQuery.textScaleFactorOf(context)` respected — UI tested at 2.0 scale; (2) contrast ratios met in custom `ThemeData`; (3) color-only meaning has alternatives; (4) `MediaQuery.boldTextOf(context)` support; (5) `MediaQuery.highContrastOf(context)` support. + +**CLI:** (1) `--no-color` flag or `NO_COLOR` env var support; (2) information not conveyed by color alone — use labels, prefixes, symbols alongside color (e.g., `[ERROR]` not just red text); (3) supports `TERM` detection for capability; (4) works in high-contrast terminal themes. + +**Game:** (1) Colorblind mode(s) — at minimum deuteranopia (red-green), ideally also protanopia and tritanopia; (2) UI element outlines/patterns supplement color; (3) font size options or UI scaling; (4) high contrast UI option; (5) brightness/gamma controls; (6) important game elements distinguishable by shape, not just color. + +#### Keyboard & Motor + +**Web:** (1) Every interactive element reachable and activatable via keyboard; (2) no keyboard traps (focus can escape every modal, dropdown, overlay); (3) logical focus order matching visual layout; (4) visible focus indicators (not suppressed by `outline: none` without replacement); (5) skip navigation links; (6) custom widgets follow WAI-ARIA Authoring Practices keyboard patterns; (7) click targets at least 24x24 CSS px (AA), recommend 44x44 (AAA); (8) drag-and-drop has click/keyboard alternative; (9) single-character shortcuts remappable or only active on focus; (10) `pointer-events`, `user-select` not disabling expected interactions. + +**iOS:** (1) Full Keyboard Access support — all actions reachable via external keyboard; (2) Switch Control compatibility — all interactive elements in accessibility tree with correct order; (3) AssistiveTouch custom gesture alternatives for complex gestures; (4) minimum 44x44pt tap targets (Apple HIG); (5) no gesture-only actions without alternatives (e.g., swipe-to-delete must have edit button); (6) custom gesture recognizers don't block Switch Control; (7) `accessibilityActivationPoint` set for non-standard hit areas. + +**Android:** (1) Full keyboard navigation — `android:focusable`, logical `nextFocusDown/Up/Left/Right`; (2) Switch Access compatibility — all interactive elements focusable with adequate ordering; (3) minimum 48x48dp touch targets (Material Design); (4) no gesture-only actions without alternatives; (5) `android:importantForAccessibility` not hiding interactive elements; (6) custom views implement `onKeyDown`/`onKeyUp` for keyboard users; (7) TalkBack gesture alternatives for complex interactions. + +**React Native:** (1) `accessible={true}` on all interactive elements; (2) `accessibilityActions` for custom actions; (3) `onMagicTap`, `onEscape` handlers where appropriate (iOS); (4) minimum touch target sizes (48x48dp Android / 44x44pt iOS); (5) gesture-based interactions have tap alternatives. + +**Flutter:** (1) All interactive widgets have `Semantics` with tap/long press actions; (2) minimum touch targets via `MaterialTapTargetSize.padded`; (3) `FocusNode` and `FocusTraversalGroup` for logical keyboard order; (4) `RawKeyboardListener`/`KeyboardListener` for keyboard shortcuts; (5) no `IgnorePointer`/`AbsorbPointer` hiding accessible interactions; (6) custom `GestureDetector` actions have keyboard alternatives. + +**CLI:** (1) Keyboard-only by nature, but check: interactive prompts (e.g., fuzzy finders, multi-select) navigable with arrow keys and enter; (2) Ctrl-C always exits; (3) no mouse-only interactions in TUI; (4) tab completion where appropriate; (5) long-running operations cancellable via keyboard. + +**Game:** (1) Fully remappable controls; (2) multiple input device support (keyboard, mouse, controller, touch); (3) one-handed mode or alternative layouts; (4) no quick-time events without alternatives or adjustable timing; (5) auto-aim/aim assist option; (6) adjustable input sensitivity/dead zones; (7) hold-vs-toggle options for sustained inputs; (8) copilot/co-pilot mode if multiplayer. + +#### Cognitive & Learning + +**All platforms:** (1) Consistent navigation patterns across screens/pages; (2) consistent identification of common elements; (3) clear error messages identifying the problem and suggesting a fix; (4) labels and instructions on all form inputs; (5) predictable behavior — no unexpected context changes on focus or input; (6) adequate time — adjustable/extendable timeouts, warnings before expiry; (7) help in a consistent, findable location; (8) no unnecessary re-entry of previously provided information; (9) accessible authentication — no cognitive function tests (CAPTCHAs) without alternatives. + +**Web additionally:** (1) `lang` attribute on `` and `lang` attributes on content in other languages; (2) multiple ways to find pages (nav, search, sitemap); (3) breadcrumbs or location indicator. + +**iOS/Android additionally:** (1) Back navigation always works predictably; (2) undo support for destructive actions; (3) confirmation dialogs for irreversible operations. + +**CLI additionally:** (1) `--help` with clear, complete documentation; (2) confirmation prompts for destructive commands (with `--yes`/`-y` to skip); (3) meaningful exit codes; (4) consistent flag naming conventions (e.g., `--verbose` not `-v` in one command and `--debug` in another). + +**Game additionally:** (1) Tutorial or onboarding; (2) difficulty options including easy/story mode; (3) objective tracking/quest log; (4) adjustable game speed; (5) pause in single-player; (6) clear visual/audio feedback for actions; (7) option to simplify UI. + +#### Multimedia & Temporal + +**All platforms:** (1) Pre-recorded video has captions; (2) pre-recorded audio has transcript; (3) no content flashes more than 3 times per second; (4) auto-playing media has pause/stop control; (5) moving/blinking/scrolling content can be paused; (6) no time limits, or limits are adjustable/extendable with warning. + +**Web additionally:** `prefers-reduced-motion` media query respected — all CSS animations/transitions have reduced or disabled alternatives. Audio descriptions for pre-recorded video (AA). `