diff --git a/.github/ISSUE_TEMPLATE/00-blank.yml b/.github/ISSUE_TEMPLATE/00-blank.yml
new file mode 100644
index 0000000..4c42ecd
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/00-blank.yml
@@ -0,0 +1,35 @@
+name: "Blank issue"
+description: "Open an issue that doesn't fit any of the structured templates below."
+body:
+  - type: textarea
+    id: body
+    attributes:
+      label: Issue
+      description: "Describe what's going on. Include reproduction steps, expected vs actual, and any relevant `anc` output."
+    validations:
+      required: true
+  - type: textarea
+    id: ai-disclosure
+    attributes:
+      label: AI disclosure
+      description: "One sentence: what part of this issue was AI-written, what was human-written?"
+      placeholder: "Entirely human-written."
+    validations:
+      required: true
+  - type: textarea
+    id: agent-instructions
+    attributes:
+      label: For agents
+      description: "Agents: expand this section before filing."
+      value: |
+        <details>
+        <summary>Agent filing instructions</summary>
+
+        1. **Pick a structured template first.** False positives, scoring bugs, and feature requests have dedicated forms — use them when they fit.
+        2. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli "<keywords>"` to check for duplicates.
+        3. **AI disclosure is required.** Fill the field above honestly.
+        4. **Wrong repo?** Spec questions, principle edits, and CLI grading live on [brettdavies/agentnative](https://github.com/brettdavies/agentnative). Site bugs live on [brettdavies/agentnative-site](https://github.com/brettdavies/agentnative-site).
+        5. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for full guidelines.
+        </details>
+    validations:
+      required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index a5375eb..1114a8d 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,8 +1,8 @@
 blank_issues_enabled: false
 contact_links:
   - name: "Spec questions, principle edits, or CLI grading"
-    url: "https://github.com/brettdavies/agentnative-cli/issues/new/choose"
+    url: "https://github.com/brettdavies/agentnative/issues/new/choose"
     about: "For anything about the standard itself, file on the spec repo."
   - name: "Site bugs (rendering, performance, deployment)"
-    url: "https://github.com/brettdavies/agentnative-cli-site/issues/new/choose"
+    url: "https://github.com/brettdavies/agentnative-site/issues/new/choose"
     about: "For bugs on anc.dev, file on the site repo."
diff --git a/.github/ISSUE_TEMPLATE/false-positive.yml b/.github/ISSUE_TEMPLATE/false-positive.yml
index 5c4a23a..31b1600 100644
--- a/.github/ISSUE_TEMPLATE/false-positive.yml
+++ b/.github/ISSUE_TEMPLATE/false-positive.yml
@@ -49,10 +49,10 @@ body:
         <details>
         <summary>Agent filing instructions</summary>
 
-        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli-cli "<keywords>"` to check for duplicates.
+        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli "<keywords>"` to check for duplicates.
         2. **AI disclosure is required.** Fill the field above honestly.
-        3. **Checker bugs only.** For spec questions or principle edits, file on [brettdavies/agentnative-cli](https://github.com/brettdavies/agentnative-cli). For site bugs, file on [brettdavies/agentnative-cli-site](https://github.com/brettdavies/agentnative-cli-site).
-        4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines.
+        3. **Checker bugs only.** For spec questions or principle edits, file on [brettdavies/agentnative](https://github.com/brettdavies/agentnative). For site bugs, file on [brettdavies/agentnative-site](https://github.com/brettdavies/agentnative-site).
+        4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for full guidelines.
         </details>
     validations:
       required: false
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
index a208395..ea94f32 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -35,10 +35,10 @@ body:
         <details>
         <summary>Agent filing instructions</summary>
 
-        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli-cli "<keywords>"` to check for duplicates.
+        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli "<keywords>"` to check for duplicates.
         2. **AI disclosure is required.** Fill the field above honestly.
-        3. **Checker features only.** For spec changes, file on [brettdavies/agentnative-cli](https://github.com/brettdavies/agentnative-cli).
-        4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines.
+        3. **Checker features only.** For spec changes, file on [brettdavies/agentnative](https://github.com/brettdavies/agentnative).
+        4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for full guidelines.
         </details>
     validations:
       required: false
diff --git a/.github/ISSUE_TEMPLATE/grade-a-cli.yml b/.github/ISSUE_TEMPLATE/grade-a-cli.yml
deleted file mode 100644
index 039df70..0000000
--- a/.github/ISSUE_TEMPLATE/grade-a-cli.yml
+++ /dev/null
@@ -1,56 +0,0 @@
-name: "Grade a CLI"
-description: "Report findings from scoring a real CLI against the standard."
-labels: ["cli-grading"]
-body:
-  - type: input
-    id: tool
-    attributes:
-      label: CLI tool
-      description: "Name and version of the tool you scored."
-      placeholder: "ripgrep 14.1.0"
-    validations:
-      required: true
-  - type: textarea
-    id: findings
-    attributes:
-      label: Findings
-      description: |
-        Which principles did it pass, fail, or surprise you on?
-        Focus on cases where the spec got it wrong — a MUST that punishes good design,
-        or a gap where the spec is silent.
-      placeholder: |
-        P3 (Progressive Help): PASS — excellent `--help` hierarchy.
-        P4 (Actionable Errors): FAIL on exit codes — uses 1 for everything.
-        P7 (Bounded Responses): Spec is silent on streaming output — rg streams by default, which is fine for agents but the spec doesn't acknowledge it.
-    validations:
-      required: true
-  - type: textarea
-    id: spec-feedback
-    attributes:
-      label: What should the spec change?
-      description: "Optional. If your grading revealed a spec gap or mistake, describe it."
-    validations:
-      required: false
-  - type: textarea
-    id: ai-disclosure
-    attributes:
-      label: AI disclosure
-      description: "One sentence: what part of this report was AI-written, what was human-written?"
-      placeholder: "Scoring done by hand; write-up drafted with AI assistance."
-    validations:
-      required: true
-  - type: textarea
-    id: agent-instructions
-    attributes:
-      label: For agents
-      description: "Agents: expand this section before filing."
-      value: |
-        <details>
-        <summary>Agent filing instructions</summary>
-
-        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli "<keywords>"` to check for duplicates.
-        2. **AI disclosure is required.** Fill the field above honestly.
-        3. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines.
-        </details>
-    validations:
-      required: false
diff --git a/.github/ISSUE_TEMPLATE/pressure-test.yml b/.github/ISSUE_TEMPLATE/pressure-test.yml
deleted file mode 100644
index 4a74073..0000000
--- a/.github/ISSUE_TEMPLATE/pressure-test.yml
+++ /dev/null
@@ -1,87 +0,0 @@
-name: "Pressure-test a principle"
-description: "Propose a principle edit: merge, split, rewording, or requirement-tier change."
-labels: ["pressure-test"]
-body:
-  - type: dropdown
-    id: principle
-    attributes:
-      label: Principle
-      description: Which principle does this affect?
-      options:
-        - "P1 — Non-Interactive by Default"
-        - "P2 — Structured, Parseable Output"
-        - "P3 — Progressive Help Discovery"
-        - "P4 — Fail Fast with Actionable Errors"
-        - "P5 — Safe Retries and Explicit Mutation Boundaries"
-        - "P6 — Composable and Predictable Command Structure"
-        - "P7 — Bounded, High-Signal Responses"
-        - "Multiple / cross-cutting"
-    validations:
-      required: true
-  - type: dropdown
-    id: change-type
-    attributes:
-      label: Type of change
-      options:
-        - "Rewording (prose clarity, no requirement-tier change)"
-        - "Promotion (SHOULD to MUST, MAY to SHOULD)"
-        - "Demotion (MUST to SHOULD, SHOULD to MAY)"
-        - "Merge (combine two requirements)"
-        - "Split (break a requirement into two)"
-        - "New requirement"
-        - "Remove requirement"
-    validations:
-      required: true
-  - type: textarea
-    id: evidence
-    attributes:
-      label: Evidence
-      description: |
-        Name a real CLI, the specific MUST/SHOULD/MAY, and what happened.
-        The strongest proposals cite a tool where the current spec text produces the wrong outcome.
-      placeholder: |
-        Tool: gh (GitHub CLI)
-        Requirement: P4 MUST "distinct exit codes for distinct failure categories"
-        Observed: gh uses exit code 1 for everything — auth, network, not-found.
-        Problem: The current MUST is correct but underspecified — it doesn't say how many categories are enough.
-    validations:
-      required: true
-  - type: textarea
-    id: proposal
-    attributes:
-      label: Proposed change
-      description: What should the spec say instead? Quote the current text and show the diff.
-    validations:
-      required: true
-  - type: input
-    id: human-reviewer
-    attributes:
-      label: Human reviewer
-      description: "GitHub handle of the human who reviewed and approved this submission."
-      placeholder: "@username"
-    validations:
-      required: true
-  - type: textarea
-    id: ai-disclosure
-    attributes:
-      label: AI disclosure
-      description: "One sentence: what part of this submission was AI-written, what was human-written?"
-      placeholder: "Evidence gathered by hand; proposed wording drafted with Claude and edited."
-    validations:
-      required: true
-  - type: textarea
-    id: agent-instructions
-    attributes:
-      label: For agents
-      description: "Agents: expand this section before filing."
-      value: |
-        <details>
-        <summary>Agent filing instructions</summary>
-
-        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli "<keywords>"` to check for duplicates.
-        2. **AI disclosure is required.** Fill the field above honestly.
-        3. **Human reviewer is required.** A human must review and approve spec change proposals before submission.
-        4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines.
-        </details>
-    validations:
-      required: false
diff --git a/.github/ISSUE_TEMPLATE/scoring-bug.yml b/.github/ISSUE_TEMPLATE/scoring-bug.yml
index 3639a84..9e2d8bd 100644
--- a/.github/ISSUE_TEMPLATE/scoring-bug.yml
+++ b/.github/ISSUE_TEMPLATE/scoring-bug.yml
@@ -39,10 +39,10 @@ body:
         <details>
         <summary>Agent filing instructions</summary>
 
-        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli-cli "<keywords>"` to check for duplicates.
+        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli "<keywords>"` to check for duplicates.
         2. **AI disclosure is required.** Fill the field above honestly.
         3. **Checker bugs only.** For false positives/negatives specifically, use the "False positive / false negative" template instead.
-        4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines.
+        4. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for full guidelines.
         </details>
     validations:
       required: false
diff --git a/.github/ISSUE_TEMPLATE/spec-question.yml b/.github/ISSUE_TEMPLATE/spec-question.yml
deleted file mode 100644
index 43c9633..0000000
--- a/.github/ISSUE_TEMPLATE/spec-question.yml
+++ /dev/null
@@ -1,53 +0,0 @@
-name: "Spec question"
-description: "Ask a question about the agent-native CLI standard."
-labels: ["question"]
-body:
-  - type: dropdown
-    id: principle
-    attributes:
-      label: Principle (if applicable)
-      description: Which principle does this relate to?
-      options:
-        - "P1 — Non-Interactive by Default"
-        - "P2 — Structured, Parseable Output"
-        - "P3 — Progressive Help Discovery"
-        - "P4 — Fail Fast with Actionable Errors"
-        - "P5 — Safe Retries and Explicit Mutation Boundaries"
-        - "P6 — Composable and Predictable Command Structure"
-        - "P7 — Bounded, High-Signal Responses"
-        - "General / cross-cutting"
-    validations:
-      required: true
-  - type: textarea
-    id: question
-    attributes:
-      label: Question
-      description: "What would you like to know about the standard?"
-      placeholder: |
-        Does P4's MUST on exit codes apply to CLIs that use a single non-zero code
-        for all errors but include structured error details in stderr JSON?
-    validations:
-      required: true
-  - type: textarea
-    id: ai-disclosure
-    attributes:
-      label: AI disclosure
-      description: "One sentence: what part of this question was AI-written, what was human-written?"
-      placeholder: "Entirely human-written."
-    validations:
-      required: true
-  - type: textarea
-    id: agent-instructions
-    attributes:
-      label: For agents
-      description: "Agents: expand this section before filing."
-      value: |
-        <details>
-        <summary>Agent filing instructions</summary>
-
-        1. **Search first.** Run `gh search issues --repo brettdavies/agentnative-cli "<keywords>"` to check for duplicates.
-        2. **AI disclosure is required.** Fill the field above honestly.
-        3. See [CONTRIBUTING.md](https://github.com/brettdavies/agentnative-cli/blob/main/CONTRIBUTING.md) for full guidelines.
-        </details>
-    validations:
-      required: false
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 9d917c7..fdead33 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -90,6 +90,8 @@
 
 **Created:**
 
+**Renamed:**
+
 **Deleted:**
 
 ## Key Features
diff --git a/.github/workflows/skill-fixture-drift.yml b/.github/workflows/skill-fixture-drift.yml
new file mode 100644
index 0000000..3d13131
--- /dev/null
+++ b/.github/workflows/skill-fixture-drift.yml
@@ -0,0 +1,28 @@
+# Drift gate for tests/fixtures/skill.json against upstream
+# agentnative-site. Companion to the cargo-level test 12
+# (host_map_matches_site_skill_json) which catches drift between the Rust
+# map and this fixture; this workflow catches drift between the fixture
+# and the canonical site contract.
+#
+# Failure means the site has updated src/data/skill.json without a
+# corresponding fixture refresh. Run `scripts/sync-skill-fixture.sh`
+# locally, review the diff, commit, push.
+
+name: skill-fixture-drift
+
+on:
+  pull_request:
+  push:
+    branches: [main, dev]
+
+permissions:
+  contents: read
+
+jobs:
+  drift-check:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+      - name: Run drift check
+        run: bash scripts/sync-skill-fixture.sh --check
diff --git a/AGENTS.md b/AGENTS.md
index 24c0cc1..e4cdd44 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,17 +28,64 @@ anc . --source
 
 # Suppress inapplicable MUSTs for a categorical exception
 anc . --audit-profile human-tui
+
+# Install the companion skill bundle into your host's skills dir
+anc skill install claude_code             # ~/.claude/skills/agent-native-cli
+anc skill install --dry-run codex         # print resolved git command, don't run
+anc skill install factory --output json   # emit envelope on success and error
 ```
 
 Bare `anc` (no arguments) prints help and exits 2. This is a non-negotiable fork-bomb guard: when agentnative dogfoods
-itself, children spawned without arguments must not recurse into `check .`.
+itself, children spawned without arguments must not recurse into `check .`. Bare `anc skill` likewise prints help and
+exits 2.
+
+## Skill install
+
+`anc skill install <host>` clones the `agentnative-skill` bundle into a host's canonical skills directory. Six hosts
+ship at v0.1: `claude_code`, `codex`, `cursor`, `factory`, `kiro`, `opencode`. `--help` enumerates them; the JSON
+envelope's `host` field reports the chosen one verbatim.
+
+Output envelope (`--output json`) is uniform across success and error and across `--dry-run` and live install:
+
+```json
+{
+  "action": "skill-install",
+  "host": "claude_code",
+  "mode": "dry-run",
+  "command": "git clone --depth 1 <url> <dest>",
+  "destination": "<resolved-dest>",
+  "destination_status": "absent",
+  "status": "success",
+  "would_succeed": true
+}
+```
+
+Field-presence rules: `would_succeed` only on `mode: "dry-run"`; `exit_code` only on `mode: "install"` AND only when
+`git` actually spawned (e.g. `git-not-found` leaves it absent); `reason` only when `status: "error"`, with one of the
+typed values `destination-not-empty` / `destination-is-file` / `home-not-set` / `git-not-found` / `git-clone-failed`.
+`destination_status` is one of `absent` / `empty-dir` / `non-empty-dir` / `file`.
+
+Exit codes follow the P4 convention: `0` for success, `1` for any envelope error (typed `reason` set), `2` for clap
+usage errors (unknown host, missing positional, bare `anc skill`).
+
+The `git clone` invocation runs with named-const hardening (`GIT_HARDEN_FLAGS`, `GIT_HARDEN_ENV_REMOVE`,
+`GIT_HARDEN_ENV_SET` — the last includes `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` to disable
+user-controlled git config, plus `GIT_TERMINAL_PROMPT=0`). No `sh -c`, no `env_clear`. Defense against `insteadOf`
+URL-rewriting comes from disabling user config wholesale, not from a `-c url.<repo>.insteadOf=` flag (which would do the
+opposite of blocking).
+
+The host map (`SkillHost` enum, `KNOWN_HOSTS`, `resolve_host`, `host_envelope_str`) is **build-time-generated** from
+`src/skill_install/skill.json` by `build.rs::emit_skill_hosts`. To add or change a host, edit the JSON (or run `bash
+scripts/sync-skill-fixture.sh` to pull the upstream site contract) and `cargo build` regenerates the Rust map — no hand
+edits to `src/skill_install.rs`. CI's `skill-fixture-drift.yml` runs `--check` on every PR to catch fixture vs upstream
+drift.
 
 ## Agent-facing JSON surface
 
-`anc check <target> --output json` emits a `schema_version: "0.3"` scorecard. The schema is at `0.x` while `anc` is
+`anc check <target> --output json` emits a `schema_version: "0.5"` scorecard. The schema is at `0.x` while `anc` is
 pre-launch — shape may evolve before first public release, when it locks at `1.0`. During `0.x`, additive fields are the
-norm; consumers should feature-detect new keys rather than pinning to an exact value. The current shape includes five
-scorecard-level fields beyond the base `results` / `summary`:
+norm; consumers should feature-detect new keys rather than pinning to an exact value. The current shape includes the
+following scorecard-level fields beyond the base `results` / `summary`:
 
 - `audience` — `"agent-optimized"` / `"mixed"` / `"human-primary"` / `null`. Derived from 4 signal behavioral checks
   (`p1-non-interactive`, `p2-json-output`, `p7-quiet`, `p6-no-color-behavioral`). Informational only; never gates totals
@@ -51,6 +98,21 @@ scorecard-level fields beyond the base `results` / `summary`:
 - `spec_version` — the `agentnative-spec` version this CLI was built against. Sourced at build time from
   `src/principles/spec/VERSION` by `build.rs`; reads `"unknown"` if that file was missing at build time. Pin against
   this to know which spec contract the scorecard's requirement IDs reference.
+- `tool` — `{ name, binary, version }`. Identifies what was scored. `version` is best-effort (manifest field for project
+  mode, `<bin> --version` / `-V` for binary/command mode); `null` when probing fails or is declined by the self-spawn
+  guard. Schema `0.4` addition.
+- `anc` — `{ version, commit }`. Identifies the `anc` build that produced the scorecard. `commit` is `null` for builds
+  outside a Git checkout. Informational, not signed provenance. Schema `0.4` addition.
+- `run` — `{ invocation, started_at, duration_ms, platform: { os, arch } }`. `invocation` reflects what the user typed
+  (captured pre-injection). `started_at` is RFC 3339 UTC. Schema `0.4` addition.
+- `target` — `{ kind, path, command }`. `kind` is `"project"` / `"binary"` / `"command"`. The unused field is always
+  `null`, never missing. Schema `0.4` addition.
+- `badge` — `{ eligible, score_pct, embed_markdown, scorecard_url, badge_url, convention_url }`. Agent-native badge
+  derivation from the live run. `score_pct` is the rounded percent of `pass / (pass + warn + fail)` (Skips and Errors
+  excluded from the ratio). `eligible` is true iff `score_pct >= 80` and a tool slug was derivable. `embed_markdown` is
+  `null` below the floor (do-not-nag contract). `scorecard_url` / `badge_url` are populated whenever a slug exists, even
+  below the floor; `convention_url` always points at `https://anc.dev/badge`. Schema `0.5` addition. The text-mode hint
+  (`--output text`) prints the same embed snippet only when eligible; below-floor runs print nothing badge-related.
 
 `--audit-profile` accepts exactly 4 values: `human-tui`, `file-traversal`, `posix-utility`, `diagnostic-only`. Unknown
 values exit 2 with a structured error. The full per-category mapping of suppressed check IDs is committed to
@@ -111,17 +173,17 @@ cargo test -- --ignored       # fixture tests (slower)
 
 The canonical specification of the 7 agent-readiness principles lives in
 [`brettdavies/agentnative`](https://github.com/brettdavies/agentnative), one file per principle under `principles/`. A
-pinned snapshot is **vendored** into this crate at `src/principles/spec/`, and `build.rs` parses its frontmatter at
-build time to generate the `REQUIREMENTS` slice — IDs in the spec frontmatter are the contract this CLI checks against.
-There is no manual sync of requirement IDs; only the `Check::covers()` declarations are hand-maintained.
+snapshot is **vendored** into this crate at `src/principles/spec/`, and `build.rs` parses its frontmatter at build time
+to generate the `REQUIREMENTS` slice — IDs in the spec frontmatter are the contract this CLI checks against. There is no
+manual sync of requirement IDs; only the `Check::covers()` declarations are hand-maintained.
 
 The `anc` checks in `src/checks/` themselves are derived **manually** from each principle's prose. When a principle's
 spec adds, removes, or reworks a requirement, propagate to the relevant check(s) deliberately.
 
-**Resync cadence:** rerun `scripts/sync-spec.sh` after every new `agentnative-spec` tag. The default `SPEC_REF` in the
-script is the current pin; bump via `SPEC_REF=v0.2.1 scripts/sync-spec.sh` when adopting a newer spec release. The
-companion `repository_dispatch` from the spec's publish workflow is the canonical trigger; if a future GitHub Action
-opens a resync PR automatically, this script becomes that action's body.
+**Resync cadence:** rerun `scripts/sync-spec.sh` after every new `agentnative-spec` tag. The script queries the remote
+for the latest `v*` tag automatically and falls back to a local checkout (`$HOME/dev/agentnative-spec` by default) if
+the remote is unreachable. The companion `repository_dispatch` from the spec's publish workflow is the canonical
+trigger; if a future GitHub Action opens a resync PR automatically, this script becomes that action's body.
 
 For iteration workflow, pressure-test protocol, and per-file structure of the spec itself, see
 [`agentnative:principles/AGENTS.md`](https://github.com/brettdavies/agentnative/blob/main/principles/AGENTS.md). Read
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ea5481..d23a016 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,49 @@
 
 All notable changes to this project will be documented in this file.
 
+## [0.3.0] - 2026-05-01
+
+### Added
+
+- Add four scorecard metadata blocks (`tool`, `anc`, `run`, `target`) to `--output json` — identifies the scored tool/version, the `anc` build that produced the scorecard, the user-typed invocation with timestamp and duration, and the resolved target (project / binary / command). by @brettdavies in [#34](https://github.com/brettdavies/agentnative-cli/pull/34)
+- Add `time = "=0.3.47"` dependency for RFC 3339 timestamps in `run.started_at`.
+- Add `anc skill install <host>` subcommand to install the [`agentnative-skill`](https://github.com/brettdavies/agentnative-skill) bundle into a host's canonical skills directory. Six hosts: `claude_code`, `codex`, `cursor`, `factory`, `kiro`, `opencode`. by @brettdavies in [#35](https://github.com/brettdavies/agentnative-cli/pull/35)
+- Add `--dry-run` flag (P5): prints the resolved `git clone` command without spawning. Captures cleanly via `eval $(anc skill install --dry-run <host>)`.
+- Add `--output {text,json}` flag (P2): JSON envelope is uniform across success and error and across dry-run / live install. Typed `reason` on error (`destination-not-empty`, `destination-is-file`, `home-not-set`, `git-not-found`, `git-clone-failed`).
+- `--output text` now appends an agent-native badge embed hint after the summary line when the tool clears the 80% eligibility floor. Below the floor, nothing badge-related is printed (the convention's "do not nag" rule). by @brettdavies in [#36](https://github.com/brettdavies/agentnative-cli/pull/36)
+- `--output json` scorecard now includes a `badge` block (`eligible`, `score_pct`, `embed_markdown`, `scorecard_url`, `badge_url`, `convention_url`). `embed_markdown` is `null` below the floor; `scorecard_url` / `badge_url` are populated whenever a tool slug exists, since the site renders an SVG for every scored tool.
+- `scripts/sync-dev-after-release.sh` — backports `Cargo.toml` `[package].version`, `Cargo.lock`, and `CHANGELOG.md` from `main` to `dev` after a release tag publishes. Surgical (preserves dev's other Cargo.toml lines), idempotent (re-runs are a no-op when dev is already in sync), and signed via the operator's normal commit signing — satisfies `protect-dev`'s `required_signatures` ruleset without needing a CI bot identity. by @brettdavies in [#37](https://github.com/brettdavies/agentnative-cli/pull/37)
+
+### Changed
+
+- Vendoring now always tracks the latest published spec tag — `SPEC_REF` env override removed. Run `bash scripts/sync-spec.sh` to refresh; no environment configuration required. by @brettdavies in [#33](https://github.com/brettdavies/agentnative-cli/pull/33)
+- Bump scorecard `schema_version` from `"0.3"` to `"0.4"` (additive within the documented `0.x` policy — older consumers feature-detect). by @brettdavies in [#34](https://github.com/brettdavies/agentnative-cli/pull/34)
+- Bump `rust-version` from `1.87` to `1.88` (let-chain stabilization).
+- Bumped scorecard `schema_version` from `"0.4"` to `"0.5"`. Pre-`0.5` consumers feature-detect the new `badge` key and continue to work. by @brettdavies in [#36](https://github.com/brettdavies/agentnative-cli/pull/36)
+- `p7-naked-println` source check now exempts `build.rs` at any crate root. Cargo build scripts use `println!("cargo:…")` directives by protocol; flagging them produces noise without an alternative API. Misnamed `src/build.rs` or `tests/build.rs` files stay flagged. by @brettdavies in [#38](https://github.com/brettdavies/agentnative-cli/pull/38)
+- README refreshed for current state: schema 0.5 with `badge` block, `--audit-profile <CATEGORY>` documented under CLI Reference, `target.path` documented as basename-only (PII-safe), refreshed Example Output to match the live 33-check dogfood and the post-summary badge embed hint. by @brettdavies in [#40](https://github.com/brettdavies/agentnative-cli/pull/40)
+- `--output json` scorecard `anc` block no longer includes a `commit` field. `anc.version` (the crate version pin) remains as the build identity. Removed because the per-build Git SHA capture made cached builds fragile (stale SHAs across local commits) without solving any consumer-facing problem — `anc.version` already identifies the released binary unambiguously, and pre-launch no public consumer of `anc.commit` exists. by @brettdavies in [#47](https://github.com/brettdavies/agentnative-cli/pull/47)
+
+### Fixed
+
+- Eliminated four `.unwrap()` calls on infallible operations across `src/skill_install.rs` and `build.rs`. Replaced with `.expect("…")` naming the upstream contract that guarantees `Some`/`Ok`. No behavior change — these were already infallible; the `expect` messages document why. by @brettdavies in [#38](https://github.com/brettdavies/agentnative-cli/pull/38)
+- `target.path` in `anc check --output json` now emits the basename of the resolved target instead of the canonicalized absolute path, eliminating a home-directory / username PII leak that flowed into committed scorecards, badge URLs, and agent-posted artifacts. Project mode emits the directory name (e.g. `"agentnative-cli"`); binary mode emits the file name (e.g. `"anc"`); command mode unchanged at `null`. No schema bump — value semantics changed, schema shape did not. by @brettdavies in [#39](https://github.com/brettdavies/agentnative-cli/pull/39)
+- Corrected cross-repo URLs in `.github/ISSUE_TEMPLATE/` so contact links and agent-filing instructions point at the right repos. Spec repo references switched from `agentnative-cli` to `agentnative`; site repo references switched from `agentnative-cli-site` to `agentnative-site`; the `agentnative-cli-cli` double-suffix typo in agent gh-search guidance was corrected to `agentnative-cli`. Affects `config.yml`, `false-positive.yml`, `feature-request.yml`, `scoring-bug.yml`. by @brettdavies in [#42](https://github.com/brettdavies/agentnative-cli/pull/42)
+
+### Documentation
+
+- `AGENTS.md` and `src/principles/spec/README.md` updated to reflect the simpler vendor flow. by @brettdavies in [#33](https://github.com/brettdavies/agentnative-cli/pull/33)
+- Document the four new top-level objects in README.md, AGENTS.md, and CLAUDE.md, including the publishing-PII review reminder for `run.invocation` and `target.path`. by @brettdavies in [#34](https://github.com/brettdavies/agentnative-cli/pull/34)
+- Add `## Install the skill` section to README with one-line examples per host and the manual `git clone` fallback for hosts not yet in the binary's map. by @brettdavies in [#35](https://github.com/brettdavies/agentnative-cli/pull/35)
+- `RELEASES.md` § "After publish — sync ``dev`` with the release" documents the backport step, supersedes the prior "never back-merged" rule for these three specific files, and points operators at the script. by @brettdavies in [#37](https://github.com/brettdavies/agentnative-cli/pull/37)
+- Add the `[![agent-native](https://anc.dev/badge/anc.svg)](https://anc.dev/score/anc)` badge plus crates.io and license shields at the top of `README.md`. by @brettdavies in [#40](https://github.com/brettdavies/agentnative-cli/pull/40)
+- Trim `.github/ISSUE_TEMPLATE/` to `false-positive`, `feature-request`, `scoring-bug`, plus a new `00-blank.yml` that lets a Blank issue option sit first in the chooser ahead of the structured forms. Spec-side templates (`pressure-test`, `grade-a-cli`, `spec-question`) were duplicates of the spec repo's set from before the rename — they belong on `brettdavies/agentnative` only, and `config.yml` already redirects there.
+- Add `scripts/SYNCS.md` — cross-repo sync map covering every spec/skill/coverage/release data flow with mechanism, payload, trigger, and drift check per edge. Includes a flowchart of inbound/outbound edges, a release-pipeline sequence diagram, and a cadence summary table reducing the system to "automatic vs manual" per sync point. by @brettdavies in [#41](https://github.com/brettdavies/agentnative-cli/pull/41)
+- `RELEASES.md` § "Releasing dev to main" step 4 replaced with a triple-diff verification block (A: main→release, B: release→dev, C: dev→main) plus a `git cherry HEAD origin/dev` patch-id check. The new flow catches both directions of drift before the release tag goes out — guarded paths leaking IN (the original concern) and missed cherry-picks that should have shipped (the new concern). Discovered during v0.3.0 prep when an ad-hoc triple-diff caught 4 `.github/ISSUE_TEMPLATE/*.yml` files that had drifted on `main` since the v0.1.1 squash. by @brettdavies in [#45](https://github.com/brettdavies/agentnative-cli/pull/45)
+- `RELEASES.md` § "Releasing dev to main" step 4 — expanded the `git cherry` patch-id check comment with squash-merge triage guidance (three expected noise sources, what a real miss looks like, and a two-command triage recipe). Discovered during v0.3.0 prep when the check produced 55 noisy `+` lines that all turned out to be expected; the original comment didn't explain that this is normal in a squash-merge workflow. by @brettdavies in [#46](https://github.com/brettdavies/agentnative-cli/pull/46)
+
+**Full Changelog**: [v0.2.0...v0.3.0](https://github.com/brettdavies/agentnative-cli/compare/v0.2.0...v0.3.0)
+
 ## [0.2.0] - 2026-04-29
 
 ### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 01e0e95..3e0ed99 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,8 +14,8 @@ Design doc: `~/.gstack/projects/brettdavies-agentnative/brett-main-design-202603
 
 ## Skill Routing
 
-When the user's request matches an available skill, ALWAYS invoke it using the Skill
-tool as your FIRST action. Do NOT answer directly, do NOT use other tools first.
+When the user's request matches an available skill, ALWAYS invoke it using the Skill tool as your FIRST action. Do NOT
+answer directly, do NOT use other tools first.
 
 **gstack skills (ideation, planning, shipping, ops):**
 
@@ -39,9 +39,9 @@ For the full routing table, see `~/.claude/skills/docs/workflow-routing.md`.
 
 ## Documented Solutions
 
-`docs/solutions/` (symlink to `~/dev/solutions-docs/`) — searchable archive of past
-solutions and best practices, organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Search
-with `qmd query "<topic>" --collection solutions`. Relevant when implementing or debugging in documented areas.
+`docs/solutions/` (symlink to `~/dev/solutions-docs/`) — searchable archive of past solutions and best practices,
+organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Search with `qmd query "<topic>"
+--collection solutions`. Relevant when implementing or debugging in documented areas.
 
 ## gstack Project History
 
@@ -66,8 +66,8 @@ Key decisions already made:
 
 ## Source Check Convention
 
-Most source checks follow this structure (a few legacy helpers in `output_module.rs` and `error_types.rs` use
-different helper shapes but still satisfy the core contract that `run()` is the sole `CheckResult` constructor):
+Most source checks follow this structure (a few legacy helpers in `output_module.rs` and `error_types.rs` use different
+helper shapes but still satisfy the core contract that `run()` is the sole `CheckResult` constructor):
 
 - **Struct** implements `Check` trait with `id()`, `label()`, `group()`, `layer()`, `applicable()`, `run()`
 - **`check_x()` helper** takes `(source: &str)` (or `(source: &str, file: &str)` when evidence needs file location
@@ -139,21 +139,122 @@ either source.
 Regenerate whenever you add a requirement, change a check's `covers()`, or rename a check ID. The regeneration is a
 deliberate commit, not a build-time artifact — the matrix is citable from outside this repo.
 
-## Scorecard v1.1 Fields
+## Scorecard v0.5 Fields
 
-`src/scorecard/mod.rs` emits `schema_version: "1.1"` with three additions over the v1.0 shape:
+`src/scorecard/mod.rs` emits `schema_version: "0.5"`. The schema evolves additively during the `0.x` pre-launch window —
+consumers feature-detect each addition rather than pinning exact shape. Cumulative history:
 
-- `coverage_summary` — three-way `{must, should, may} × {total, verified}` counts, computed from the checks that
-  actually ran. Populated every run.
+- `0.2` — `coverage_summary` (three-way `{must, should, may} × {total, verified}` counts), `audience`, `audit_profile`.
+- `0.3` — `spec_version` (vendored agentnative-spec version, sourced by `build.rs` from `src/principles/spec/VERSION`).
+- `0.4` — four top-level objects making the scorecard self-describing: `tool`, `anc`, `run`, `target`.
+- `0.5` — `badge` block surfacing agent-native badge eligibility, embed snippet, and badge/scorecard URLs derived from
+  the live run.
+
+Existing field semantics:
+
+- `coverage_summary` — populated every run. Checks suppressed by `--audit-profile` do not count toward `verified`.
 - `audience` — `Option<String>`, derived by `src/scorecard/audience.rs::classify()` from the 4 signal behavioral checks.
-  Emits `"agent-optimized"`, `"mixed"`, `"human-primary"`, or `null` when any signal check is missing from results
-  (including when suppressed by `--audit-profile`). The classifier is read-only over results and never gates totals or
-  exit codes — per CEO review Finding #3, label mismatches are fixed via registry, not classifier logic.
+  Emits `"agent-optimized"`, `"mixed"`, `"human-primary"`, or `null` when any signal check is missing (including
+  `--audit-profile` suppression). Read-only over results; never gates totals or exit codes — per CEO review Finding #3,
+  label mismatches are fixed via registry, not classifier logic.
 - `audit_profile` — `Option<String>`, echoes the applied `--audit-profile` flag value (`"human-tui"`,
   `"file-traversal"`, `"posix-utility"`, `"diagnostic-only"`). `null` when no profile is set.
-
-Consumers (notably the site's `/score/<tool>` page) must feature-detect the new fields — pre-v1.1 scorecards lack
-them. v0.1.2 scorecards carry `audience: null` and `audit_profile: null`; v0.1.3+ populates both.
+- `spec_version` — `&'static str` — the vendored spec version this `anc` build was compiled against.
+
+`0.4` additions (defined as serde-derived sub-structs in `src/scorecard/mod.rs`):
+
+- `tool` — `ToolInfo { name: String, binary: Option<String>, version: Option<String> }`. Built in `main.rs`'s
+  `build_tool_info`. Project mode prefers the manifest version (`Cargo.toml`/`pyproject.toml`); command/binary mode
+  probes `<bin> --version` then `-V` via a fresh `BinaryRunner` with a 2-second timeout. Self-spawn guard compares the
+  resolved binary path against `std::env::current_exe()` — recursion declined → `tool.version: null`.
+- `anc` — `AncInfo { version: &'static str }`. `version` is a build-time constant emitted by `build.rs` into
+  `$OUT_DIR/build_info.rs` (re-exported from `src/build_info.rs`). The `commit` field shipped pre-`0.5` was dropped
+  before the v0.3.0 tag — the version pin is sufficient build identity for scorecard consumers and the
+  `cargo:rerun-if-changed` watches on `.git/` made cached-build SHAs fragile across local commits.
+- `run` — `RunInfo { invocation, started_at, duration_ms, platform: { os, arch } }`. `invocation` is captured **before**
+  `inject_default_subcommand` rewrites argv (so `anc .` records as `"anc ."`, not `"anc check ."`). `started_at` is RFC
+  3339 UTC via the `time` crate (pinned `=0.3.45`). `duration_ms` uses `Instant` for monotonic measurement.
+  `platform.{os,arch}` come from `std::env::consts`.
+- `target` — `TargetInfo { kind: String, path: Option<String>, command: Option<String> }`. `kind` is one of `"project"`,
+  `"binary"`, `"command"`. `path` is the **basename** of the resolved target (directory name in project mode, file name
+  in binary mode) — never the absolute path, which would leak operator PII (home-dir username, org/employer dir
+  structure) into committed scorecards, README badge URLs, and any agent-posted artifact. `null` for `command` mode.
+  Pathological paths where `Path::file_name()` returns `None` (e.g. `/`, `..`) fall back to `null`. The unused field is
+  always `null`, never missing. See `src/main.rs::build_target_info` for the leak-vector rationale; the regression guard
+  lives in `tests/scorecard_schema_v05.rs::schema_v05_target_path_carries_no_separators`.
+
+`0.5` addition (`BadgeInfo` in `src/scorecard/mod.rs`):
+
+- `badge` — `BadgeInfo { eligible, score_pct, embed_markdown, scorecard_url, badge_url, convention_url }`. Computed by
+  `compute_badge(results, tool_name)` from the leaderboard's pass-rate (`pass / (pass + warn + fail)`) — Skips and
+  Errors are excluded from both sides of the ratio. `eligible` is true iff `score_pct >= BADGE_ELIGIBILITY_FLOOR_PCT`
+  (currently `80`) **and** a tool slug was derivable; `embed_markdown` is `Some` only when `eligible` (the do-not-nag
+  contract from the site's badge convention). `scorecard_url` / `badge_url` are populated whenever a slug exists, even
+  below the floor — the site renders an SVG for every scored tool so a regression below the floor shifts color rather
+  than 404s. `convention_url` is the fixed `https://anc.dev/badge` pointer. URLs are anchored at `BADGE_BASE_URL =
+  "https://anc.dev"` so the URL pattern lives in one place. Authority for the floor is the site's published convention
+  (`agentnative-site/content/badge.md`); when the spec convention merges off `feat/badge-claim-convention` it will move
+  into the vendored spec via `sync-spec`. Text mode (`--output text`) appends a post-summary hint via
+  `BadgeInfo::text_hint()` when `eligible`; the same `tool.name` is used for the slug so the JSON `embed_markdown` and
+  the printed hint can never disagree.
+
+Always-present null contract: `tool.version`, `tool.binary`, `target.path`, `target.command` serialize as JSON `null`
+when not applicable, never as missing keys. Consumers can access these paths unconditionally. The exception is
+`audience_reason`, which uses `skip_serializing_if = "Option::is_none"` — its absence carries information (audience has
+a label).
+
+Consumers (notably the site's `/score/<tool>` page) must feature-detect the new fields — pre-`0.4` scorecards lack the
+four metadata blocks; pre-`0.5` scorecards lack `badge`. The site's `agentnative-site/registry.yaml` will eventually
+drop its parallel `version` / `scored_at` fields once consumers read those facts from the scorecard's `tool.version` /
+`run.started_at`. That follow-up lives in the `agentnative-site` repo, not here.
+
+## Skill Install Verb
+
+`anc skill install <host>` ships the `agentnative-skill` bundle into a host's canonical skills directory. The host map
+is **build-time-generated** from `src/skill_install/skill.json` (a verbatim copy of
+`agentnative-site/src/data/skill.json`) into `$OUT_DIR/generated_hosts.rs` by `build.rs::emit_skill_hosts`.
+`src/skill_install.rs` `include!`s the generated file; there is no hand-maintained host enum or destination table. To
+add or change a host, edit `skill.json` (or run `bash scripts/sync-skill-fixture.sh` to pull the upstream site contract)
+and `cargo build` regenerates the Rust map. There is no `skill.json` parsing in production, no HTTPS fetch at runtime,
+and no allowlist validator — the host map is compile-time data.
+
+CI catches drift between the committed fixture and the upstream site contract:
+
+- `scripts/sync-skill-fixture.sh --check` (CI workflow `skill-fixture-drift.yml`) clones the upstream site at
+  `SKILL_SITE_REF` (default `dev`, since the site uses a dev/main forever-branch flow) and `cmp`s the live blob against
+  `src/skill_install/skill.json`. Runs on every PR and on push to main/dev.
+
+Drift between the fixture and the generated Rust map cannot occur within a single build: `cargo:rerun-if-changed` on the
+JSON file forces regeneration whenever the fixture moves. The earlier `host_map_matches_site_skill_json` test (test 12)
+was deleted as provably redundant after this refactor.
+
+The `git clone` invocation runs with named-const hardening that defeats ambient git-config and env subversion. The full
+surface lives in `src/skill_install.rs`:
+
+- `GIT_HARDEN_FLAGS: &[&str]` — five `-c key=value` pairs (`credential.helper=`, `core.askPass=`,
+  `protocol.allow=never`, `protocol.https.allow=always`, `http.followRedirects=false`). Applied via `Command::args`
+  *before* the `clone` subcommand — git's required position for top-level `-c` options.
+- `GIT_HARDEN_ENV_REMOVE: &[&str]` — five env vars stripped via `env_remove` (`GIT_SSH{,_COMMAND}`, `GIT_PROXY_COMMAND`,
+  `GIT_ASKPASS`, `GIT_EXEC_PATH`).
+- `GIT_HARDEN_ENV_SET: &[(&str, &str)]` — three env vars **set** on the spawned process. The
+  `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` pair disables every layer of user-controlled git
+  config — the actual defense against `insteadOf` URL-rewriting attacks (an earlier draft tried `-c
+  url.<repo>.insteadOf=`, which does the *opposite* of blocking and doubles the clone URL). `GIT_TERMINAL_PROMPT=0`
+  blocks credential prompts; git's default-when-unset is to prompt, which is the wrong default for a non-interactive
+  subcommand.
+
+**Rules for changes touching skill install:**
+
+- NEVER call `Command::env_clear()` — it strips PATH and breaks git's helper resolution. Use `env_remove` per var.
+- NEVER use `sh -c` or any shell-mediated invocation. Tokens go directly to `git` via `Command::args`.
+- NEVER reintroduce `skill.json` parsing in production code. The fixture is a build-time codegen input, not a runtime
+  resource.
+- NEVER hand-edit `SkillHost`, `KNOWN_HOSTS`, `resolve_host`, or `host_envelope_str` in `src/skill_install.rs` — those
+  identifiers come from the generated `$OUT_DIR/generated_hosts.rs` and any apparent definition in source is the
+  include! macro. To add a host, edit `src/skill_install/skill.json` (or run the sync script) and rebuild.
+- The codegen rejects malformed install commands at build time. Each `install.<host>` value MUST tokenize as exactly
+  `git clone --depth 1 <url> <dest>` (six whitespace-separated tokens, dest not ending in `.git`). The validation
+  mirrors `agentnative-site/src/build/skill.mjs` so the two binaries reject the same inputs.
 
 ## Dogfooding Safety
 
diff --git a/Cargo.lock b/Cargo.lock
index bcce0ae..9432723 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
 
 [[package]]
 name = "agentnative"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "anyhow",
  "assert_cmd",
@@ -18,7 +18,9 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_yaml",
+ "tempfile",
  "thiserror",
+ "time",
  "toml",
 ]
 
@@ -276,6 +278,15 @@ version = "0.8.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
 
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
 [[package]]
 name = "difflib"
 version = "0.4.0"
@@ -472,6 +483,12 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
 
+[[package]]
+name = "num-conv"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+
 [[package]]
 name = "num-traits"
 version = "0.2.19"
@@ -493,6 +510,12 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
 
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
 [[package]]
 name = "predicates"
 version = "3.1.4"
@@ -760,6 +783,37 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
 [[package]]
 name = "toml"
 version = "0.8.23"
diff --git a/Cargo.toml b/Cargo.toml
index ad0c448..0fb3ffe 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "agentnative"
-version = "0.2.0"
+version = "0.3.0"
 edition = "2024"
 description = "The agent-native CLI linter — check whether your CLI follows agent-readiness principles"
 license = "MIT OR Apache-2.0"
@@ -11,7 +11,7 @@ keywords = ["cli", "linter", "agent", "ast-grep", "developer-tools"]
 categories = ["command-line-utilities", "development-tools"]
 authors = ["Brett Davies <pm8fp22mbg@privaterelay.appleid.com>"]
 readme = "README.md"
-rust-version = "1.87"
+rust-version = "1.88"
 exclude = [
     ".claude/",
     ".context/",
@@ -55,11 +55,18 @@ thiserror = "2"
 # Platform
 libc = "0.2"
 
+# RFC 3339 timestamps for scorecard `run.started_at`. Pinned exact (pre-1.0
+# convention). `formatting` feature enables `OffsetDateTime::format` with the
+# `Rfc3339` well-known description; default `std` feature is required for
+# `OffsetDateTime::now_utc`.
+time = { version = "=0.3.47", default-features = false, features = ["std", "formatting"] }
+
 [build-dependencies]
 # Vendored spec frontmatter parser (build.rs → $OUT_DIR/generated_requirements.rs).
 # serde_yaml is deprecated upstream but still functional; pinned tightly.
 # Re-evaluate (saphyr / yaml-rust2) if cargo-deny flags it.
 serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
 serde_yaml = "=0.9.34"
 
 [dev-dependencies]
@@ -68,6 +75,10 @@ insta = "1"
 predicates = "3"
 # Used by tests/build_parser.rs to exercise the build_support/parser.rs module.
 serde_yaml = "=0.9.34"
+# Tempdir / tempfile fixtures for skill_install destination-check tests.
+# Already a transitive dev-dep via insta — declared here so it's a direct
+# dependency we control.
+tempfile = "3"
 
 [package.metadata.binstall]
 pkg-url = "{ repo }/releases/download/v{ version }/agentnative-{ target }.tar.gz"
diff --git a/README.md b/README.md
index 0b0a7c9..f500f4e 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,13 @@
 # agentnative
 
+[![agent-native](https://anc.dev/badge/anc.svg)](https://anc.dev/score/anc)
+[![Crates.io](https://img.shields.io/crates/v/agentnative.svg)](https://crates.io/crates/agentnative)
+[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue.svg)](#license)
+
 The agent-native CLI linter. Checks whether your CLI follows the 7 agent-readiness principles.
 
+`anc` dogfoods the spec it enforces — the badge above is its own live score.
+
 ## Install
 
 The crate is published as `agentnative`. The binary is called `anc`.
@@ -20,6 +26,42 @@ cargo binstall agentnative
 # https://github.com/brettdavies/agentnative-cli/releases
 ```
 
+## Install the skill
+
+`anc` ships a companion skill bundle (`agentnative-skill`) that teaches AI coding agents how to operate the linter and
+where to apply the principles. Install it with one command per host:
+
+```bash
+anc skill install claude_code   # ~/.claude/skills/agent-native-cli
+anc skill install codex         # ~/.codex/skills/agent-native-cli
+anc skill install cursor        # ~/.cursor/skills/agent-native-cli
+anc skill install factory       # ~/.factory/skills/agent-native-cli   (Factory Droid)
+anc skill install kiro          # ~/.kiro/skills/agent-native-cli      (Kiro)
+anc skill install opencode      # ~/.config/opencode/skills/agent-native-cli
+```
+
+Inspect the resolved command before running it:
+
+```bash
+anc skill install --dry-run claude_code
+# git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git /home/you/.claude/skills/agent-native-cli
+```
+
+JSON output (mode `dry-run` and `install`, success and error) is uniform — agents can rely on the same envelope shape
+across every outcome:
+
+```bash
+anc skill install --dry-run claude_code --output json
+```
+
+If the site adds a host before this `anc` release knows about it, fall back to a manual `git clone`:
+
+```bash
+git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git <host-skills-dir>/agent-native-cli
+```
+
+The host map is hardcoded in this binary; new hosts ship via patch release after the site updates `skill.json`.
+
 ## Quick Start
 
 ```bash
@@ -61,6 +103,8 @@ agentnative checks your CLI against seven agent-readiness principles:
 ```text
 P1 — Non-Interactive by Default
   [PASS] Non-interactive by default (p1-non-interactive)
+  [PASS] Flags advertise env-var bindings in --help (p1-env-hints)
+  [PASS] TTY detection for color output (p1-tty-detection-source)
   [PASS] No interactive prompt dependencies (p1-non-interactive-source)
 
 P3 — Progressive Help
@@ -69,19 +113,30 @@ P3 — Progressive Help
 
 P4 — Actionable Errors
   [PASS] Rejects invalid arguments (p4-bad-args)
+  [PASS] Structured error types (p4-error-types)
+  [PASS] Exit codes use named constants (p4-exit-codes)
   [PASS] No process::exit outside main (p4-process-exit)
+  [PASS] Dedicated error module exists (p4-error-module)
 
 P6 — Composable Structure
   [PASS] Handles SIGPIPE gracefully (p6-sigpipe)
-  [PASS] Respects NO_COLOR (p6-no-color)
+  [PASS] Respects NO_COLOR (p6-no-color-behavioral)
   [PASS] Shell completions support (p6-completions)
 
 Code Quality
   [PASS] No .unwrap() in source (code-unwrap)
 
-30 checks: 26 pass, 2 warn, 0 fail, 2 skip, 0 error
+33 checks: 28 pass, 1 warn, 0 fail, 4 skip, 0 error
+
+🏆 Score: 97% — your tool qualifies for the agent-native badge.
+   Embed in your README:
+     [![agent-native](https://anc.dev/badge/anc.svg)](https://anc.dev/score/anc)
+   Convention: https://anc.dev/badge
 ```
 
+The badge hint appears in `text` output when a tool scores at or above the 80% eligibility floor. Below the floor, `anc`
+prints nothing badge-related — the convention is to surface the embed only when earned.
+
 ## Three Check Layers
 
 agentnative uses three layers to analyze your CLI:
@@ -95,9 +150,9 @@ agentnative uses three layers to analyze your CLI:
 
 ## CLI Reference
 
-When the first non-flag argument is not a recognized subcommand, `check` is inserted automatically. `anc .`,
-`anc -q .`, and `anc --command ripgrep` all resolve to `anc check …`. Bare `anc` (no arguments) still prints help and
-exits 2 — this is deliberate fork-bomb prevention when agentnative dogfoods itself.
+When the first non-flag argument is not a recognized subcommand, `check` is inserted automatically. `anc .`, `anc -q .`,
+and `anc --command ripgrep` all resolve to `anc check …`. Bare `anc` (no arguments) still prints help and exits 2 — this
+is deliberate fork-bomb prevention when agentnative dogfoods itself.
 
 ```text
 Usage: anc check [OPTIONS] [PATH]
@@ -106,26 +161,34 @@ Arguments:
   [PATH]  Path to project directory or binary [default: .]
 
 Options:
-      --command <NAME>         Resolve a command from PATH and run behavioral checks against it
-      --binary                 Run only behavioral checks (skip source analysis)
-      --source                 Run only source checks (skip behavioral)
-      --principle <PRINCIPLE>  Filter checks by principle number (1-7)
-      --output <OUTPUT>        Output format [default: text] [possible values: text, json]
-  -q, --quiet                  Suppress non-essential output
-      --include-tests          Include test code in source analysis
-  -h, --help                   Print help
+      --command <NAME>           Resolve a command from PATH and run behavioral checks against it
+      --binary                   Run only behavioral checks (skip source analysis)
+      --source                   Run only source checks (skip behavioral)
+      --principle <PRINCIPLE>    Filter checks by principle number (1-7)
+      --output <OUTPUT>          Output format [default: text] [possible values: text, json]
+  -q, --quiet                    Suppress non-essential output [env: AGENTNATIVE_QUIET=]
+      --include-tests            Include test code in source analysis
+      --audit-profile <CATEGORY> Exemption category for the target [possible values:
+                                 human-tui, file-traversal, posix-utility, diagnostic-only]
+  -h, --help                     Print help
 ```
 
 `--command` and `[PATH]` are mutually exclusive — pick one. `--command` runs behavioral checks only; source and project
 checks are skipped because there is no source tree to analyze.
 
+`--audit-profile` suppresses checks that legitimately do not apply to a class of tool (e.g., `human-tui` for TUI apps
+like `lazygit` whose contract IS the TTY, `posix-utility` for stdin-primary tools like `cat`/`sed`/`awk`,
+`diagnostic-only` for read-only tools like `nvidia-smi`, `file-traversal` reserved for upcoming subcommand-structure
+relaxations on `fd`/`find`-class tools). Suppressed checks emit `Skip` with structured evidence. The full per-category
+mapping lives in `coverage/matrix.json` under `audit_profiles[]` — agents should read it rather than scrape `--help`.
+
 ### Exit Codes
 
-| Code | Meaning |
-| ---- | ------- |
-| 0 | All checks passed |
-| 1 | Warnings present (no failures) |
-| 2 | Failures, errors, or usage errors |
+| Code | Meaning                           |
+| ---- | --------------------------------- |
+| 0    | All checks passed                 |
+| 1    | Warnings present (no failures)    |
+| 2    | Failures, errors, or usage errors |
 
 Exit 2 covers both check failures (a real `[FAIL]` or `[ERROR]` result) and usage errors (bare `anc`, unknown flag,
 mutually exclusive flags). Agents distinguishing the two should parse `stderr` (usage errors print `Usage:`) or call
@@ -158,11 +221,13 @@ Pre-generated scripts are also available in `completions/`.
 anc check . --output json
 ```
 
-Produces a scorecard (`schema_version: "1.1"`) with results, summary, and coverage against the 7 principles:
+Produces a self-describing scoring run record (`schema_version: "0.5"`) with results, summary, coverage against the 7
+principles, plus contextual metadata identifying which tool was scored, by which `anc` build, on which platform, and
+how:
 
 ```json
 {
-  "schema_version": "1.1",
+  "schema_version": "0.5",
   "results": [
     {
       "id": "p3-help",
@@ -170,24 +235,43 @@ Produces a scorecard (`schema_version: "1.1"`) with results, summary, and covera
       "group": "P3",
       "layer": "behavioral",
       "status": "pass",
-      "evidence": null
+      "evidence": null,
+      "confidence": "high"
     }
   ],
   "summary": {
-    "total": 30,
-    "pass": 26,
-    "warn": 2,
+    "total": 33,
+    "pass": 28,
+    "warn": 1,
     "fail": 0,
-    "skip": 2,
+    "skip": 4,
     "error": 0
   },
   "coverage_summary": {
-    "must": { "total": 23, "verified": 17 },
+    "must":   { "total": 23, "verified": 17 },
     "should": { "total": 16, "verified": 2 },
-    "may":   { "total": 7,  "verified": 0 }
+    "may":    { "total": 7,  "verified": 0 }
   },
   "audience": "agent-optimized",
-  "audit_profile": null
+  "audit_profile": null,
+  "spec_version": "0.3.0",
+  "tool":   { "name": "ripgrep", "binary": "rg",  "version": "ripgrep 15.1.0" },
+  "anc":    { "version": "0.3.0", "commit": "abc1234" },
+  "run":    {
+    "invocation": "anc check --command rg --output json",
+    "started_at": "2026-04-29T16:00:00Z",
+    "duration_ms": 412,
+    "platform":   { "os": "linux", "arch": "x86_64" }
+  },
+  "target": { "kind": "command", "path": null, "command": "rg" },
+  "badge":  {
+    "eligible": true,
+    "score_pct": 97,
+    "embed_markdown": "[![agent-native](https://anc.dev/badge/ripgrep.svg)](https://anc.dev/score/ripgrep)",
+    "scorecard_url":  "https://anc.dev/score/ripgrep",
+    "badge_url":      "https://anc.dev/badge/ripgrep.svg",
+    "convention_url": "https://anc.dev/badge"
+  }
 }
 ```
 
@@ -202,16 +286,46 @@ Produces a scorecard (`schema_version: "1.1"`) with results, summary, and covera
   `audit_profile`'s format within the same JSON document.
 - `audience_reason` — present only when `audience` is `null`. Values: `suppressed` (at least one signal check was masked
   by `--audit-profile`) or `insufficient_signal` (signal check never produced, e.g. source-only run). Additive to schema
-  v1.1; v1.1 consumers feature-detect.
+  `0.2`; older consumers feature-detect.
 - `audit_profile` — echoes the applied `--audit-profile <category>` flag value (`human-tui`, `file-traversal`,
   `posix-utility`, or `diagnostic-only`). `null` when no profile is set. See `coverage/matrix.json` under
   `audit_profiles` for the committed per-category mapping of which check IDs each profile suppresses.
+- `tool` — identifies what was scored. `name` is always present (deterministic from path or command). `binary` is the
+  executable basename when one is located; `null` for project-mode runs without a built artifact. `version` is
+  best-effort: project-mode prefers the manifest version (`Cargo.toml`/`pyproject.toml`), command/binary mode probes
+  `<bin> --version` then `-V`. `null` when probing failed or was declined by the self-spawn guard. The site's
+  `registry.yaml` `version_extract` snippets remain authoritative for tools whose self-report is unreliable. Schema
+  `0.4` addition.
+- `anc` — identifies the `anc` build that produced the scorecard. `version` is the crate version at compile time.
+  `commit` is the short Git SHA at compile time, or `null` for builds outside a Git checkout (e.g., `cargo install` from
+  crates.io). Informational, not a signed provenance signal — pair with a Sigstore-signed release artifact if provenance
+  is required. Schema `0.4` addition.
+- `run` — run-level facts. `invocation` is the user's argv joined with shell-safe quoting, captured **before**
+  default-subcommand injection so it reflects what the user typed (`anc .`, not `anc check .`). `started_at` is RFC 3339
+  UTC. `duration_ms` is wall-clock milliseconds. `platform.os` / `platform.arch` come from `std::env::consts`. Schema
+  `0.4` addition.
+- `target` — what `anc` was pointed at. `kind` is `"project"` (directory), `"binary"` (executable file), or `"command"`
+  (PATH-resolved name from `--command`). `path` is the **basename** of the resolved target (project directory name or
+  binary file name) — never the absolute path, so home-dir usernames and employer directory layouts don't leak into
+  scorecards committed to repos or posted by agents. `command` carries the user-supplied name for command mode. The
+  unused field is always `null`, never missing — consumer code can access both unconditionally. Schema `0.4` addition.
+- `badge` — agent-native badge derivation from the live run. `score_pct` is `pass / (pass + warn + fail)` rounded (Skips
+  and Errors excluded from both sides of the ratio). `eligible` is true iff `score_pct >= 80` **and** a tool slug was
+  derivable. `embed_markdown` is `null` below the floor — the convention is "do not nag" until earned; `scorecard_url`
+  and `badge_url` are populated whenever a slug exists, even below the floor, so the site renders an SVG for every
+  scored tool (a regression below the floor shifts color rather than 404s). `convention_url` always points at
+  `https://anc.dev/badge`. Schema `0.5` addition.
+
+> Publishing a scorecard? `run.invocation` may carry usernames or absolute paths from the machine that produced the
+> scorecard. `target.path` is intentionally the basename only and is safe to commit. Review `run.invocation` before
+> publishing — `anc` does not silently redact, since that would surprise users debugging their own runs.
 
 ## Contributing
 
 ```bash
 git clone https://github.com/brettdavies/agentnative-cli
-cd agentnative
+cd agentnative-cli
+git config core.hooksPath scripts/hooks  # mirror CI locally on every push
 cargo test
 cargo run -- check .
 ```
@@ -220,19 +334,21 @@ cargo run -- check .
 
 Open an issue at
 [github.com/brettdavies/agentnative-cli/issues/new/choose](https://github.com/brettdavies/agentnative-cli/issues/new/choose).
-Seven structured templates cover the common cases:
-
-| Template | Use it when |
-| --- | --- |
-| False positive | A check flagged your CLI but you believe your CLI is doing the right thing. |
-| Scoring bug | Results don't match what the check should be doing (wrong status, miscategorized group/layer, evidence pointing at the wrong line). |
-| Feature request | Missing capability, flag, or output format in the checker itself. |
-| Grade a CLI | Nominate a CLI for an `anc`-graded readiness review. |
-| Pressure test | Challenge a principle or check definition — "this check is too strict / too loose / wrong on this class of CLI." |
-| Spec question | Ambiguity or gap in the 7-principle spec (not the checker). |
-| Something else | Chooser for anything outside the templates above. |
-
-Filing on the right template front-loads the triage context we need and keeps issues out of a single-bucket backlog.
+The chooser surfaces three structured templates plus a blank fallback for everything else:
+
+| Template        | Use it when                                                                                                                         |
+| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| Blank issue     | Anything outside the structured templates below.                                                                                    |
+| False positive  | A check flagged your CLI but you believe your CLI is doing the right thing.                                                         |
+| Scoring bug     | Results don't match what the check should be doing (wrong status, miscategorized group/layer, evidence pointing at the wrong line). |
+| Feature request | Missing capability, flag, or output format in the checker itself.                                                                   |
+
+Spec questions, principle pressure-tests, and CLI grading live on the spec repo —
+[brettdavies/agentnative](https://github.com/brettdavies/agentnative/issues/new/choose). The chooser config redirects
+those automatically. Site bugs (rendering, performance) go to
+[brettdavies/agentnative-site](https://github.com/brettdavies/agentnative-site/issues/new/choose). See
+[CONTRIBUTING.md on the spec repo](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for the full
+cross-repo routing table.
 
 ## License
 
diff --git a/RELEASES.md b/RELEASES.md
index 99dc94d..e24c070 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -12,12 +12,12 @@ feature branch → PR to dev (squash merge)
 
 ## Branches
 
-| Branch | Role | Lifetime | Protection |
-| ------ | ---- | -------- | ---------- |
-| `main` | Production. Only release commits. | Forever. | `.github/rulesets/protect-main.json` |
-| `dev` | Integration. All feature PRs land here. | Forever. Never delete. | `.github/rulesets/protect-dev.json` |
-| `feat/*`, `fix/*`, `chore/*`, `docs/*` | Feature work. | One PR's worth. Auto-deleted on merge. | None — squash into dev freely. |
-| `release/*` | Head of a dev → main PR. | One release's worth. Auto-deleted on merge. | None. |
+| Branch                                 | Role                                    | Lifetime                                    | Protection                           |
+| -------------------------------------- | --------------------------------------- | ------------------------------------------- | ------------------------------------ |
+| `main`                                 | Production. Only release commits.       | Forever.                                    | `.github/rulesets/protect-main.json` |
+| `dev`                                  | Integration. All feature PRs land here. | Forever. Never delete.                      | `.github/rulesets/protect-dev.json`  |
+| `feat/*`, `fix/*`, `chore/*`, `docs/*` | Feature work.                           | One PR's worth. Auto-deleted on merge.      | None — squash into dev freely.       |
+| `release/*`                            | Head of a dev → main PR.                | One release's worth. Auto-deleted on merge. | None.                                |
 
 `dev` is a **forever branch**. Never delete it locally or remotely, even after a `release/* → main` merge. The next
 release cycle reuses the same `dev`. The repo's `deleteBranchOnMerge: true` setting doesn't touch `dev` as long as `dev`
@@ -41,10 +41,9 @@ gh pr create --base dev --title "feat(scope): what changed"
 
 ## Releasing dev to main
 
-Engineering docs (`docs/plans/`, `docs/solutions/`, `docs/brainstorms/`,
-`docs/reviews/`) live on `dev` only. `guard-main-docs.yml` blocks them from reaching `main`, and
-`guard-release-branch.yml` rejects any PR to main whose head isn't `release/*`. Use the release-branch cherry-pick
-pattern:
+Engineering docs (`docs/plans/`, `docs/solutions/`, `docs/brainstorms/`, `docs/reviews/`) live on `dev` only.
+`guard-main-docs.yml` blocks them from reaching `main`, and `guard-release-branch.yml` rejects any PR to main whose head
+isn't `release/*`. Use the release-branch cherry-pick pattern:
 
 **Branch naming**: `release/v<version>` or `release/v<version>-<slug>` (e.g. `release/v0.1.0`,
 `release/v0.2.0-python-checks`). The `v<version>` prefix is required — `scripts/generate-changelog.sh` extracts the
@@ -62,10 +61,59 @@ git log --oneline dev --not origin/main
 # 3. Cherry-pick the ones you want to ship. Docs commits stay on dev.
 git cherry-pick <sha1> <sha2> ...
 
-# 4. Verify no guarded paths leaked through:
-git diff origin/main --stat
-# If anything under docs/plans/, docs/solutions/, or docs/brainstorms/
-# shows up, you cherry-picked a docs commit by mistake — reset and redo.
+# 4. Triple-diff verification — belt-and-suspenders sweep that catches both
+#    directions of drift before the release tag goes out:
+#
+#    A. main → release  (what users will see; the intended ship surface)
+#    B. release → dev   (should be empty for non-doc paths until the
+#                        bump/completions/CHANGELOG commits land, and even
+#                        then should only list those release-prep files —
+#                        anything else is a missed cherry-pick)
+#    C. dev → main      (sanity: phantom commits dev "appears ahead" on
+#                        because cherry-pick rewrites SHAs post-squash)
+git diff origin/main..HEAD --stat                                                # A
+git diff HEAD..origin/dev --name-only | grep -v '^docs/' || echo "(none)"        # B
+git diff origin/dev..origin/main --stat | tail -5                                # C
+#
+# Re-confirm no guarded paths leaked (this caught the original miss class):
+git diff origin/main..HEAD --name-only \
+  | grep -E '^(docs/plans|docs/brainstorms|docs/ideation|docs/reviews|docs/solutions|\.context)' \
+  && echo "LEAKED — reset and redo" || echo "(clean — no guarded paths)"
+#
+# Patch-id cherry check — catches commits on dev that have NO patch-id
+# equivalent on release. The file-level diff in B misses this class when
+# the same content happens to land via a different commit.
+#
+# IMPORTANT: in a squash-merge workflow this output is noisy. Every '+'
+# line needs human triage — it does NOT auto-block the release. Expected
+# sources of '+' lines that are NOT real misses:
+#
+#   1. Historical commits squash-merged in prior releases. The squash
+#      commit on main has a different patch-id than the dev commits it
+#      consolidates, so old commits show as '+' forever. Anything older
+#      than the previous release tag is almost always this.
+#   2. Cherry-picks where conflict resolution stripped guarded paths
+#      (docs/plans, docs/brainstorms, etc.) or otherwise altered the
+#      tree. Same source-code intent, different patch-id.
+#   3. Intentionally skipped commits — docs-only commits, release-prep
+#      backports, revert-and-redo prep steps.
+#
+# A real miss looks like: a recent feat/fix/chore commit on dev whose
+# *file content* is not yet on main. To triage a '+' line:
+#
+#   git show <sha> --stat                       # what did it touch?
+#   git diff origin/main..HEAD -- <those-files> # already on release?
+#
+# If every touched file is guarded (docs/plans/, docs/brainstorms/, etc.)
+# OR the content is already on main via a prior squash, it's a false
+# positive — no action. Otherwise cherry-pick the commit and re-run the
+# triple-diff.
+git cherry HEAD origin/dev | grep '^+' || echo "(none — release is patch-equivalent through dev)"
+#
+# If B lists any non-docs path you didn't expect, fetch dev, identify the
+# commit (`git log dev --not origin/main`), cherry-pick it, re-run the
+# triple-diff. Missed cherry-picks have shipped to main on this and sibling
+# repos before — this step is the cheap way to catch them.
 
 # 5. Bump version in Cargo.toml and commit:
 #    sed -i 's/^version = ".*"/version = "0.2.0"/' Cargo.toml
@@ -76,11 +124,22 @@ git diff origin/main --stat
 ./scripts/generate-completions.sh
 git add completions/ && git commit -m "chore: regenerate shell completions" || true
 
-# 7. Generate CHANGELOG.md (auto-detects version from branch name; CI enforces this):
+# 7. Refresh the skill.json fixture from upstream and review the diff. CI's
+#    skill-fixture-drift workflow runs --check on every PR, but pulling the
+#    latest content here catches any site changes since dev was branched and
+#    avoids tagging a release with the codegen-derived host map one revision
+#    behind upstream:
+bash scripts/sync-skill-fixture.sh && git diff src/skill_install/skill.json
+# The Rust map (SkillHost / KNOWN_HOSTS / resolve_host) regenerates from the
+# JSON automatically on the next `cargo build` — no manual src edits needed.
+git add src/skill_install/skill.json && \
+    git commit -m "chore(skill): refresh fixture for v0.2.0" || true
+
+# 8. Generate CHANGELOG.md (auto-detects version from branch name; CI enforces this):
 ./scripts/generate-changelog.sh
 git add CHANGELOG.md && git commit -m "docs: update CHANGELOG.md for v0.2.0"
 
-# 8. Push and open the PR:
+# 9. Push and open the PR:
 git push -u origin release/v0.2.0
 gh pr create --base main --head release/v0.2.0 --title "release: v0.2.0"
 ```
@@ -112,19 +171,38 @@ git push origin main --tags
 The tag push triggers `.github/workflows/release.yml`, which calls the reusable
 `brettdavies/.github/.github/workflows/rust-release.yml@main` and runs:
 
-| Step | What |
-| ---- | ---- |
-| `check-version` | Verify the tag matches `Cargo.toml` version (gate). |
-| `audit` | `cargo deny check` (license + advisory + ban). |
-| `build` | Cross-compile binaries for 5 targets: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`, `x86_64-pc-windows-msvc`. Each archive includes the `anc` binary, completions, README, and licenses. |
-| `publish-crate` | `cargo publish` to crates.io via Trusted Publishing (OIDC, no static token after first publish). |
-| `release` | Create a **non-draft** GitHub Release with `make_latest: false` — visible immediately (so `cargo-binstall` and `/releases/latest` don't 404 during the bottle-build window) but not yet promoted to "Latest". Includes all 5 archives + `sha256sum.txt`. |
-| `homebrew` | Dispatch `update-formula` to `brettdavies/homebrew-tap` (formula name: `agentnative`, installs `anc`). |
+| Step            | What                                                                                                                                                                                                                                                     |
+| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `check-version` | Verify the tag matches `Cargo.toml` version (gate).                                                                                                                                                                                                      |
+| `audit`         | `cargo deny check` (license + advisory + ban).                                                                                                                                                                                                           |
+| `build`         | Cross-compile binaries for 5 targets: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`, `x86_64-pc-windows-msvc`. Each archive includes the `anc` binary, completions, README, and licenses.       |
+| `publish-crate` | `cargo publish` to crates.io via Trusted Publishing (OIDC, no static token after first publish).                                                                                                                                                         |
+| `release`       | Create a **non-draft** GitHub Release with `make_latest: false` — visible immediately (so `cargo-binstall` and `/releases/latest` don't 404 during the bottle-build window) but not yet promoted to "Latest". Includes all 5 archives + `sha256sum.txt`. |
+| `homebrew`      | Dispatch `update-formula` to `brettdavies/homebrew-tap` (formula name: `agentnative`, installs `anc`).                                                                                                                                                   |
 
 After the homebrew-tap workflow uploads bottles to this repo's release assets, it dispatches `finalize-release` back to
 this repo, which idempotently flips `make_latest: true`. End result: crate on crates.io, GitHub Release marked latest,
 Homebrew formula updated with bottles, all atomically advertised.
 
+### After publish — sync `dev` with the release
+
+Once `finalize-release.yml` has flipped the GitHub Release to `published`, backport the release-bookkeeping files from
+`main` to `dev` so future builds from `dev` report the released version (and so `anc check`'s embedded badge URL points
+at the right slug, not stale `0.1.0`):
+
+```bash
+./scripts/sync-dev-after-release.sh v0.2.0
+git push origin dev
+```
+
+The script surgically updates only `Cargo.toml`'s `[package].version` line (other `Cargo.toml` lines on `dev` —
+post-launch deps, rust-version bumps — are preserved), regenerates `Cargo.lock` via `cargo build --release`, and copies
+`CHANGELOG.md` verbatim from `origin/main`. The single commit lands directly on `dev` (signed via your normal commit
+signing — no PR), establishing release backport as a deliberate convention rather than the prior "never back-merged"
+norm.
+
+The backport is idempotent: re-running on a `dev` already in sync exits 0 with no commit.
+
 ### First-time publish (one-time)
 
 The very first crate publish requires a regular crates.io API token (Trusted Publishing needs the crate to exist first).
@@ -197,10 +275,10 @@ gh api repos/brettdavies/agentnative-cli/commits/<sha>/check-runs --jq '.check_r
 
 ## Required secrets
 
-| Secret | Purpose | Lifecycle |
-| ------ | ------- | --------- |
-| `CI_RELEASE_TOKEN` | Fine-grained PAT, Contents R+W, Pull requests R+W. Used by `release.yml` to dispatch the Homebrew formula update. | Rotated annually. |
-| `CARGO_REGISTRY_TOKEN` | crates.io API token. Required only for the first publish. | Remove after Trusted Publishing is configured. |
+| Secret                 | Purpose                                                                                                           | Lifecycle                                      |
+| ---------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| `CI_RELEASE_TOKEN`     | Fine-grained PAT, Contents R+W, Pull requests R+W. Used by `release.yml` to dispatch the Homebrew formula update. | Rotated annually.                              |
+| `CARGO_REGISTRY_TOKEN` | crates.io API token. Required only for the first publish.                                                         | Remove after Trusted Publishing is configured. |
 
 `GITHUB_TOKEN` is automatic; CI (`ci.yml`) only needs `contents: read` and uses no extra secrets.
 
diff --git a/build.rs b/build.rs
index 2dcda24..941ef00 100644
--- a/build.rs
+++ b/build.rs
@@ -1,11 +1,16 @@
-//! Build script: parse vendored spec frontmatter into `$OUT_DIR/generated_requirements.rs`.
+//! Build script. Two codegen pipelines plus build_info:
 //!
-//! See `build_support/parser.rs` for the parser; this file is a thin driver
-//! that reads `src/principles/spec/principles/p*-*.md`, hands each file to the
-//! parser, aggregates, and writes the generated Rust source.
+//! 1. Vendored spec frontmatter (`src/principles/spec/principles/p*-*.md`) →
+//!    `$OUT_DIR/generated_requirements.rs`. Driven by `build_support/parser.rs`.
+//! 2. Vendored skill manifest (`src/skill_install/skill.json`) →
+//!    `$OUT_DIR/generated_hosts.rs`. The manifest's `install` map is the
+//!    single source of truth for the `SkillHost` enum, `KNOWN_HOSTS` const,
+//!    and `resolve_host` fn. Updates to the JSON regenerate the Rust map
+//!    on next build — no manual sync.
 //!
-//! Errors here are *intentionally loud* — every parse failure cites the file,
-//! requirement id, and field. The build is the right time to catch spec drift.
+//! Errors here are *intentionally loud* — every parse failure cites the
+//! file, requirement / host id, and field. The build is the right time to
+//! catch fixture drift.
 
 use std::env;
 use std::fs;
@@ -22,6 +27,9 @@ fn main() {
     println!("cargo:rerun-if-changed=src/principles/spec/");
     println!("cargo:rerun-if-changed=build_support/parser.rs");
 
+    emit_build_info(&manifest_dir);
+    emit_skill_hosts(&manifest_dir);
+
     let spec_version = match fs::read_to_string(spec_dir.join("VERSION")) {
         Ok(s) => s.trim().to_string(),
         Err(_) => {
@@ -58,7 +66,12 @@ fn main() {
 
     let mut parsed_per_file = Vec::with_capacity(files.len());
     for path in &files {
-        let name = path.file_name().unwrap().to_str().unwrap().to_string();
+        let name = path
+            .file_name()
+            .expect("glob match always has a file name component")
+            .to_str()
+            .expect("principle filenames are ASCII per spec contract")
+            .to_string();
         let content = fs::read_to_string(path)
             .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
         let reqs = parser::parse_principle_file(&name, &content)
@@ -76,3 +89,202 @@ fn main() {
     fs::write(&out_path, rust_src)
         .unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display()));
 }
+
+/// Emit `$OUT_DIR/build_info.rs` with the `ANC_VERSION` constant.
+///
+/// `ANC_VERSION` is the crate version (always present via `CARGO_PKG_VERSION`)
+/// and surfaces in the scorecard's `anc.version` so a consumer can identify the
+/// `anc` build that produced a scorecard. Per-build Git SHA capture was dropped
+/// in v0.3.0 — the version pin is sufficient identity for scorecard consumers
+/// and the `cargo:rerun-if-changed` watches on `.git/` made cached-build SHAs
+/// fragile across local commits.
+fn emit_build_info(_manifest_dir: &std::path::Path) {
+    let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".to_string());
+
+    let mut src = String::new();
+    src.push_str("// @generated by build.rs. Do not edit by hand.\n\n");
+    src.push_str("/// Crate version (`CARGO_PKG_VERSION`) at build time.\n");
+    src.push_str(&format!("pub const ANC_VERSION: &str = \"{version}\";\n"));
+
+    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
+    let out_path = out_dir.join("build_info.rs");
+    fs::write(&out_path, src)
+        .unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display()));
+}
+
+/// Emit `$OUT_DIR/generated_hosts.rs` from `src/skill_install/skill.json`.
+///
+/// Reads the manifest's `install` map and emits, for every `<host>` key:
+///
+/// - a `SkillHost` enum variant (PascalCase of the snake_case key) with
+///   `clap::ValueEnum` derive + `#[value(rename_all = "snake_case")]` so
+///   surface names round-trip back to the JSON key verbatim;
+/// - an entry in `KNOWN_HOSTS: &[&str]`;
+/// - a match arm in `resolve_host(SkillHost) -> (&'static str, &'static str)`
+///   returning the `(url, dest_template)` parsed from the host's install
+///   command.
+///
+/// Each install command MUST have the canonical shape
+/// `git clone --depth 1 <url> <dest>` — six whitespace-separated tokens.
+/// Anything else panics the build with the offending host and command.
+/// This mirrors `agentnative-site/src/build/skill.mjs` validation so the
+/// two binaries reject the same malformed inputs.
+fn emit_skill_hosts(manifest_dir: &std::path::Path) {
+    let skill_json_path = manifest_dir.join("src/skill_install/skill.json");
+    println!("cargo:rerun-if-changed=src/skill_install/skill.json");
+
+    let content = fs::read_to_string(&skill_json_path)
+        .unwrap_or_else(|e| panic!("read {}: {e}", skill_json_path.display()));
+    let manifest: serde_json::Value = serde_json::from_str(&content)
+        .unwrap_or_else(|e| panic!("parse {}: {e}", skill_json_path.display()));
+
+    let install = manifest
+        .get("install")
+        .and_then(|v| v.as_object())
+        .unwrap_or_else(|| {
+            panic!(
+                "{}: \"install\" must be an object (host -> command map)",
+                skill_json_path.display()
+            )
+        });
+
+    if install.is_empty() {
+        panic!(
+            "{}: install map is empty — at least one host required",
+            skill_json_path.display()
+        );
+    }
+
+    // Collect (json_key, variant, url, dest) — sorted by JSON key so the
+    // generated source has stable byte output across runs (mirrors the
+    // site emitter's sorted-keys contract).
+    let mut hosts: Vec<(String, String, String, String)> = Vec::with_capacity(install.len());
+    for (key, cmd_value) in install {
+        let cmd = cmd_value.as_str().unwrap_or_else(|| {
+            panic!(
+                "{}: install.{key:?} must be a string",
+                skill_json_path.display()
+            )
+        });
+        let tokens: Vec<&str> = cmd.split_whitespace().collect();
+        if tokens.len() != 6
+            || tokens[0] != "git"
+            || tokens[1] != "clone"
+            || tokens[2] != "--depth"
+            || tokens[3] != "1"
+        {
+            panic!(
+                "{}: install.{key:?} must match `git clone --depth 1 <url> <dest>` (got {} tokens: {cmd:?})",
+                skill_json_path.display(),
+                tokens.len(),
+            );
+        }
+        let url = tokens[4].to_string();
+        let dest = tokens[5].to_string();
+        if dest.ends_with(".git") {
+            panic!(
+                "{}: install.{key:?} dest {dest:?} ends in `.git` — host commands must terminate with an explicit destination, not the bare repo name",
+                skill_json_path.display()
+            );
+        }
+        let variant = pascal_case(key).unwrap_or_else(|e| {
+            panic!(
+                "{}: install.{key:?} is not a valid Rust identifier: {e}",
+                skill_json_path.display()
+            )
+        });
+        hosts.push((key.clone(), variant, url, dest));
+    }
+    hosts.sort_by(|a, b| a.0.cmp(&b.0));
+
+    // Render Rust source.
+    let mut src = String::new();
+    src.push_str(
+        "// @generated by build.rs from src/skill_install/skill.json. Do not edit by hand.\n",
+    );
+    src.push_str(
+        "// Add or remove hosts via the JSON file (or `bash scripts/sync-skill-fixture.sh`)\n",
+    );
+    src.push_str("// and `cargo build` regenerates this file.\n\n");
+
+    src.push_str("/// Hosts the binary knows how to install into. Surface names match\n");
+    src.push_str("/// `agentnative-site/src/data/skill.json` keys verbatim via\n");
+    src.push_str("/// `rename_all = \"snake_case\"`.\n");
+    src.push_str("#[derive(Clone, Copy, Debug, PartialEq, Eq, ::clap::ValueEnum)]\n");
+    src.push_str("#[value(rename_all = \"snake_case\")]\n");
+    src.push_str("pub enum SkillHost {\n");
+    for (_, variant, _, _) in &hosts {
+        src.push_str(&format!("    {variant},\n"));
+    }
+    src.push_str("}\n\n");
+
+    src.push_str(
+        "/// Host names accepted by `anc skill install <host>`, in JSON-key sort order.\n",
+    );
+    src.push_str("/// Surfaces externally for shell-completion enumeration and as the seed\n");
+    src.push_str("/// for a future `anc skill list` verb. Stays in lockstep with [`SkillHost`]\n");
+    src.push_str("/// variants because both are generated from the same source.\n");
+    src.push_str("#[allow(dead_code)]\n");
+    src.push_str("pub const KNOWN_HOSTS: &[&str] = &[\n");
+    for (key, _, _, _) in &hosts {
+        src.push_str(&format!("    {key:?},\n"));
+    }
+    src.push_str("];\n\n");
+
+    src.push_str("/// Resolve a host enum to its `(url, dest_template)` pair, parsed\n");
+    src.push_str("/// at build time from the install command in src/skill_install/skill.json.\n");
+    src.push_str("/// Pure function — no I/O, no side effects.\n");
+    src.push_str("pub fn resolve_host(host: SkillHost) -> (&'static str, &'static str) {\n");
+    src.push_str("    match host {\n");
+    for (_, variant, url, dest) in &hosts {
+        src.push_str(&format!(
+            "        SkillHost::{variant} => ({url:?}, {dest:?}),\n"
+        ));
+    }
+    src.push_str("    }\n");
+    src.push_str("}\n\n");
+
+    src.push_str("/// JSON-key string for the envelope's `host` field. Generated alongside\n");
+    src.push_str("/// the enum so the surface stays in lockstep with the JSON contract.\n");
+    src.push_str("pub fn host_envelope_str(host: SkillHost) -> &'static str {\n");
+    src.push_str("    match host {\n");
+    for (key, variant, _, _) in &hosts {
+        src.push_str(&format!("        SkillHost::{variant} => {key:?},\n"));
+    }
+    src.push_str("    }\n");
+    src.push_str("}\n");
+
+    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
+    let out_path = out_dir.join("generated_hosts.rs");
+    fs::write(&out_path, src)
+        .unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display()));
+}
+
+/// Convert a snake_case ASCII identifier to PascalCase. Rejects empty
+/// strings, leading digits, and any character outside `[a-z0-9_]` so the
+/// emitted variant is always a valid Rust identifier.
+fn pascal_case(snake: &str) -> Result<String, String> {
+    if snake.is_empty() {
+        return Err("empty identifier".into());
+    }
+    if snake.starts_with(|c: char| c.is_ascii_digit()) {
+        return Err(format!("{snake:?} starts with a digit"));
+    }
+    if !snake
+        .chars()
+        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
+    {
+        return Err(format!(
+            "{snake:?} contains non-snake_case ASCII characters"
+        ));
+    }
+    let mut out = String::with_capacity(snake.len());
+    for word in snake.split('_') {
+        let mut chars = word.chars();
+        if let Some(first) = chars.next() {
+            out.push(first.to_ascii_uppercase());
+            out.push_str(chars.as_str());
+        }
+    }
+    Ok(out)
+}
diff --git a/completions/anc.bash b/completions/anc.bash
index 32173bc..e02e30f 100644
--- a/completions/anc.bash
+++ b/completions/anc.bash
@@ -28,6 +28,9 @@ _anc() {
             anc,help)
                 cmd="anc__help"
                 ;;
+            anc,skill)
+                cmd="anc__skill"
+                ;;
             anc__generate,coverage-matrix)
                 cmd="anc__generate__coverage__matrix"
                 ;;
@@ -52,9 +55,27 @@ _anc() {
             anc__help,help)
                 cmd="anc__help__help"
                 ;;
+            anc__help,skill)
+                cmd="anc__help__skill"
+                ;;
             anc__help__generate,coverage-matrix)
                 cmd="anc__help__generate__coverage__matrix"
                 ;;
+            anc__help__skill,install)
+                cmd="anc__help__skill__install"
+                ;;
+            anc__skill,help)
+                cmd="anc__skill__help"
+                ;;
+            anc__skill,install)
+                cmd="anc__skill__install"
+                ;;
+            anc__skill__help,help)
+                cmd="anc__skill__help__help"
+                ;;
+            anc__skill__help,install)
+                cmd="anc__skill__help__install"
+                ;;
             *)
                 ;;
         esac
@@ -62,7 +83,7 @@ _anc() {
 
     case "${cmd}" in
         anc)
-            opts="-q -h -V --quiet --help --version check completions generate help"
+            opts="-q -h -V --quiet --help --version check completions generate skill help"
             if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
                 COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
                 return 0
@@ -198,7 +219,7 @@ _anc() {
             return 0
             ;;
         anc__help)
-            opts="check completions generate help"
+            opts="check completions generate skill help"
             if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
                 COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
                 return 0
@@ -281,6 +302,108 @@ _anc() {
             COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
             return 0
             ;;
+        anc__help__skill)
+            opts="install"
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        anc__help__skill__install)
+            opts=""
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        anc__skill)
+            opts="-q -h --quiet --help install help"
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        anc__skill__help)
+            opts="install help"
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        anc__skill__help__help)
+            opts=""
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        anc__skill__help__install)
+            opts=""
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        anc__skill__install)
+            opts="-q -h --dry-run --output --quiet --help claude_code codex cursor factory kiro opencode"
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                --output)
+                    COMPREPLY=($(compgen -W "text json" -- "${cur}"))
+                    return 0
+                    ;;
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
     esac
 }
 
diff --git a/completions/anc.elvish b/completions/anc.elvish
index e6402dc..a118dc1 100644
--- a/completions/anc.elvish
+++ b/completions/anc.elvish
@@ -27,6 +27,7 @@ set edit:completion:arg-completer[anc] = {|@words|
             cand check 'Check a CLI project or binary for agent-readiness'
             cand completions 'Generate shell completions'
             cand generate 'Generate build artifacts (coverage matrix, etc.)'
+            cand skill 'Install or manage the agentnative skill bundle'
             cand help 'Print this message or the help of the given subcommand(s)'
         }
         &'anc;check'= {
@@ -73,10 +74,35 @@ set edit:completion:arg-completer[anc] = {|@words|
         }
         &'anc;generate;help;help'= {
         }
+        &'anc;skill'= {
+            cand -q 'Suppress non-essential output'
+            cand --quiet 'Suppress non-essential output'
+            cand -h 'Print help'
+            cand --help 'Print help'
+            cand install 'Install the skill bundle into a host''s canonical skills directory'
+            cand help 'Print this message or the help of the given subcommand(s)'
+        }
+        &'anc;skill;install'= {
+            cand --output 'Output format for the result envelope'
+            cand --dry-run 'Print the resolved git command without spawning. Captures cleanly via `eval $(anc skill install --dry-run <host>)`'
+            cand -q 'Suppress non-essential output'
+            cand --quiet 'Suppress non-essential output'
+            cand -h 'Print help (see more with ''--help'')'
+            cand --help 'Print help (see more with ''--help'')'
+        }
+        &'anc;skill;help'= {
+            cand install 'Install the skill bundle into a host''s canonical skills directory'
+            cand help 'Print this message or the help of the given subcommand(s)'
+        }
+        &'anc;skill;help;install'= {
+        }
+        &'anc;skill;help;help'= {
+        }
         &'anc;help'= {
             cand check 'Check a CLI project or binary for agent-readiness'
             cand completions 'Generate shell completions'
             cand generate 'Generate build artifacts (coverage matrix, etc.)'
+            cand skill 'Install or manage the agentnative skill bundle'
             cand help 'Print this message or the help of the given subcommand(s)'
         }
         &'anc;help;check'= {
@@ -88,6 +114,11 @@ set edit:completion:arg-completer[anc] = {|@words|
         }
         &'anc;help;generate;coverage-matrix'= {
         }
+        &'anc;help;skill'= {
+            cand install 'Install the skill bundle into a host''s canonical skills directory'
+        }
+        &'anc;help;skill;install'= {
+        }
         &'anc;help;help'= {
         }
     ]
diff --git a/completions/anc.fish b/completions/anc.fish
index 6c11e44..4be08e7 100644
--- a/completions/anc.fish
+++ b/completions/anc.fish
@@ -30,6 +30,7 @@ complete -c anc -n "__fish_anc_needs_command" -s V -l version -d 'Print version'
 complete -c anc -n "__fish_anc_needs_command" -f -a "check" -d 'Check a CLI project or binary for agent-readiness'
 complete -c anc -n "__fish_anc_needs_command" -f -a "completions" -d 'Generate shell completions'
 complete -c anc -n "__fish_anc_needs_command" -f -a "generate" -d 'Generate build artifacts (coverage matrix, etc.)'
+complete -c anc -n "__fish_anc_needs_command" -f -a "skill" -d 'Install or manage the agentnative skill bundle'
 complete -c anc -n "__fish_anc_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
 complete -c anc -n "__fish_anc_using_subcommand check" -l command -d 'Resolve a command from PATH and run behavioral checks against it' -r -f -a "(__fish_complete_command)"
 complete -c anc -n "__fish_anc_using_subcommand check" -l principle -d 'Filter checks by principle number (1-7)' -r
@@ -57,8 +58,21 @@ complete -c anc -n "__fish_anc_using_subcommand generate; and __fish_seen_subcom
 complete -c anc -n "__fish_anc_using_subcommand generate; and __fish_seen_subcommand_from coverage-matrix" -s h -l help -d 'Print help'
 complete -c anc -n "__fish_anc_using_subcommand generate; and __fish_seen_subcommand_from help" -f -a "coverage-matrix" -d 'Render the spec coverage matrix (registry → checks → artifact)'
 complete -c anc -n "__fish_anc_using_subcommand generate; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
-complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate help" -f -a "check" -d 'Check a CLI project or binary for agent-readiness'
-complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate help" -f -a "completions" -d 'Generate shell completions'
-complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate help" -f -a "generate" -d 'Generate build artifacts (coverage matrix, etc.)'
-complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
+complete -c anc -n "__fish_anc_using_subcommand skill; and not __fish_seen_subcommand_from install help" -s q -l quiet -d 'Suppress non-essential output'
+complete -c anc -n "__fish_anc_using_subcommand skill; and not __fish_seen_subcommand_from install help" -s h -l help -d 'Print help'
+complete -c anc -n "__fish_anc_using_subcommand skill; and not __fish_seen_subcommand_from install help" -f -a "install" -d 'Install the skill bundle into a host\'s canonical skills directory'
+complete -c anc -n "__fish_anc_using_subcommand skill; and not __fish_seen_subcommand_from install help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
+complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -l output -d 'Output format for the result envelope' -r -f -a "text\t''
+json\t''"
+complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -l dry-run -d 'Print the resolved git command without spawning. Captures cleanly via `eval $(anc skill install --dry-run <host>)`'
+complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -s q -l quiet -d 'Suppress non-essential output'
+complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -s h -l help -d 'Print help (see more with \'--help\')'
+complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from help" -f -a "install" -d 'Install the skill bundle into a host\'s canonical skills directory'
+complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
+complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "check" -d 'Check a CLI project or binary for agent-readiness'
+complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "completions" -d 'Generate shell completions'
+complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "generate" -d 'Generate build artifacts (coverage matrix, etc.)'
+complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "skill" -d 'Install or manage the agentnative skill bundle'
+complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
 complete -c anc -n "__fish_anc_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "coverage-matrix" -d 'Render the spec coverage matrix (registry → checks → artifact)'
+complete -c anc -n "__fish_anc_using_subcommand help; and __fish_seen_subcommand_from skill" -f -a "install" -d 'Install the skill bundle into a host\'s canonical skills directory'
diff --git a/completions/anc.powershell b/completions/anc.powershell
index 6e94bab..a8048ba 100644
--- a/completions/anc.powershell
+++ b/completions/anc.powershell
@@ -30,6 +30,7 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock {
             [CompletionResult]::new('check', 'check', [CompletionResultType]::ParameterValue, 'Check a CLI project or binary for agent-readiness')
             [CompletionResult]::new('completions', 'completions', [CompletionResultType]::ParameterValue, 'Generate shell completions')
             [CompletionResult]::new('generate', 'generate', [CompletionResultType]::ParameterValue, 'Generate build artifacts (coverage matrix, etc.)')
+            [CompletionResult]::new('skill', 'skill', [CompletionResultType]::ParameterValue, 'Install or manage the agentnative skill bundle')
             [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
             break
         }
@@ -84,10 +85,40 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock {
         'anc;generate;help;help' {
             break
         }
+        'anc;skill' {
+            [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Suppress non-essential output')
+            [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Suppress non-essential output')
+            [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
+            [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
+            [CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install the skill bundle into a host''s canonical skills directory')
+            [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
+            break
+        }
+        'anc;skill;install' {
+            [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'Output format for the result envelope')
+            [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Print the resolved git command without spawning. Captures cleanly via `eval $(anc skill install --dry-run <host>)`')
+            [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Suppress non-essential output')
+            [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Suppress non-essential output')
+            [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
+            [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
+            break
+        }
+        'anc;skill;help' {
+            [CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install the skill bundle into a host''s canonical skills directory')
+            [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
+            break
+        }
+        'anc;skill;help;install' {
+            break
+        }
+        'anc;skill;help;help' {
+            break
+        }
         'anc;help' {
             [CompletionResult]::new('check', 'check', [CompletionResultType]::ParameterValue, 'Check a CLI project or binary for agent-readiness')
             [CompletionResult]::new('completions', 'completions', [CompletionResultType]::ParameterValue, 'Generate shell completions')
             [CompletionResult]::new('generate', 'generate', [CompletionResultType]::ParameterValue, 'Generate build artifacts (coverage matrix, etc.)')
+            [CompletionResult]::new('skill', 'skill', [CompletionResultType]::ParameterValue, 'Install or manage the agentnative skill bundle')
             [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
             break
         }
@@ -104,6 +135,13 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock {
         'anc;help;generate;coverage-matrix' {
             break
         }
+        'anc;help;skill' {
+            [CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install the skill bundle into a host''s canonical skills directory')
+            break
+        }
+        'anc;help;skill;install' {
+            break
+        }
         'anc;help;help' {
             break
         }
diff --git a/completions/anc.zsh b/completions/anc.zsh
index 2d903aa..29cc19f 100644
--- a/completions/anc.zsh
+++ b/completions/anc.zsh
@@ -113,6 +113,61 @@ esac
     ;;
 esac
 ;;
+(skill)
+_arguments "${_arguments_options[@]}" : \
+'-q[Suppress non-essential output]' \
+'--quiet[Suppress non-essential output]' \
+'-h[Print help]' \
+'--help[Print help]' \
+":: :_anc__skill_commands" \
+"*::: :->skill" \
+&& ret=0
+
+    case $state in
+    (skill)
+        words=($line[1] "${words[@]}")
+        (( CURRENT += 1 ))
+        curcontext="${curcontext%:*:*}:anc-skill-command-$line[1]:"
+        case $line[1] in
+            (install)
+_arguments "${_arguments_options[@]}" : \
+'--output=[Output format for the result envelope]:OUTPUT:(text json)' \
+'--dry-run[Print the resolved git command without spawning. Captures cleanly via \`eval \$(anc skill install --dry-run <host>)\`]' \
+'-q[Suppress non-essential output]' \
+'--quiet[Suppress non-essential output]' \
+'-h[Print help (see more with '\''--help'\'')]' \
+'--help[Print help (see more with '\''--help'\'')]' \
+':host -- Target host (claude_code, codex, cursor, opencode):(claude_code codex cursor factory kiro opencode)' \
+&& ret=0
+;;
+(help)
+_arguments "${_arguments_options[@]}" : \
+":: :_anc__skill__help_commands" \
+"*::: :->help" \
+&& ret=0
+
+    case $state in
+    (help)
+        words=($line[1] "${words[@]}")
+        (( CURRENT += 1 ))
+        curcontext="${curcontext%:*:*}:anc-skill-help-command-$line[1]:"
+        case $line[1] in
+            (install)
+_arguments "${_arguments_options[@]}" : \
+&& ret=0
+;;
+(help)
+_arguments "${_arguments_options[@]}" : \
+&& ret=0
+;;
+        esac
+    ;;
+esac
+;;
+        esac
+    ;;
+esac
+;;
 (help)
 _arguments "${_arguments_options[@]}" : \
 ":: :_anc__help_commands" \
@@ -153,6 +208,26 @@ _arguments "${_arguments_options[@]}" : \
     ;;
 esac
 ;;
+(skill)
+_arguments "${_arguments_options[@]}" : \
+":: :_anc__help__skill_commands" \
+"*::: :->skill" \
+&& ret=0
+
+    case $state in
+    (skill)
+        words=($line[1] "${words[@]}")
+        (( CURRENT += 1 ))
+        curcontext="${curcontext%:*:*}:anc-help-skill-command-$line[1]:"
+        case $line[1] in
+            (install)
+_arguments "${_arguments_options[@]}" : \
+&& ret=0
+;;
+        esac
+    ;;
+esac
+;;
 (help)
 _arguments "${_arguments_options[@]}" : \
 && ret=0
@@ -172,6 +247,7 @@ _anc_commands() {
 'check:Check a CLI project or binary for agent-readiness' \
 'completions:Generate shell completions' \
 'generate:Generate build artifacts (coverage matrix, etc.)' \
+'skill:Install or manage the agentnative skill bundle' \
 'help:Print this message or the help of the given subcommand(s)' \
     )
     _describe -t commands 'anc commands' commands "$@"
@@ -223,6 +299,7 @@ _anc__help_commands() {
 'check:Check a CLI project or binary for agent-readiness' \
 'completions:Generate shell completions' \
 'generate:Generate build artifacts (coverage matrix, etc.)' \
+'skill:Install or manage the agentnative skill bundle' \
 'help:Print this message or the help of the given subcommand(s)' \
     )
     _describe -t commands 'anc help commands' commands "$@"
@@ -254,6 +331,49 @@ _anc__help__help_commands() {
     local commands; commands=()
     _describe -t commands 'anc help help commands' commands "$@"
 }
+(( $+functions[_anc__help__skill_commands] )) ||
+_anc__help__skill_commands() {
+    local commands; commands=(
+'install:Install the skill bundle into a host'\''s canonical skills directory' \
+    )
+    _describe -t commands 'anc help skill commands' commands "$@"
+}
+(( $+functions[_anc__help__skill__install_commands] )) ||
+_anc__help__skill__install_commands() {
+    local commands; commands=()
+    _describe -t commands 'anc help skill install commands' commands "$@"
+}
+(( $+functions[_anc__skill_commands] )) ||
+_anc__skill_commands() {
+    local commands; commands=(
+'install:Install the skill bundle into a host'\''s canonical skills directory' \
+'help:Print this message or the help of the given subcommand(s)' \
+    )
+    _describe -t commands 'anc skill commands' commands "$@"
+}
+(( $+functions[_anc__skill__help_commands] )) ||
+_anc__skill__help_commands() {
+    local commands; commands=(
+'install:Install the skill bundle into a host'\''s canonical skills directory' \
+'help:Print this message or the help of the given subcommand(s)' \
+    )
+    _describe -t commands 'anc skill help commands' commands "$@"
+}
+(( $+functions[_anc__skill__help__help_commands] )) ||
+_anc__skill__help__help_commands() {
+    local commands; commands=()
+    _describe -t commands 'anc skill help help commands' commands "$@"
+}
+(( $+functions[_anc__skill__help__install_commands] )) ||
+_anc__skill__help__install_commands() {
+    local commands; commands=()
+    _describe -t commands 'anc skill help install commands' commands "$@"
+}
+(( $+functions[_anc__skill__install_commands] )) ||
+_anc__skill__install_commands() {
+    local commands; commands=()
+    _describe -t commands 'anc skill install commands' commands "$@"
+}
 
 if [ "$funcstack[1]" = "_anc" ]; then
     _anc "$@"
diff --git a/scripts/SYNCS.md b/scripts/SYNCS.md
new file mode 100644
index 0000000..619a992
--- /dev/null
+++ b/scripts/SYNCS.md
@@ -0,0 +1,151 @@
+# Cross-repo sync map
+
+How spec / skill / coverage data flows in and out of this repo. Source of truth for sync mechanisms. Update when scripts
+or workflows change.
+
+This file complements the per-script header comments (which document mechanism + env vars) and the prose in
+`RELEASES.md` / `CLAUDE.md` / `AGENTS.md` (which document a single sync point in context). The job here is to lay every
+sync edge out in one table so the system is legible at a glance.
+
+## Cross-repo data map
+
+```mermaid
+flowchart LR
+    subgraph Inbound["Inbound — data INTO this repo"]
+        SPEC["agentnative-spec<br/>(principles + VERSION + CHANGELOG)"]
+        SITE_IN["agentnative-site<br/>src/data/skill.json"]
+        MAIN["self: main branch<br/>(Cargo.toml, Cargo.lock, CHANGELOG)"]
+    end
+
+    CLI(["agentnative-cli<br/>(this repo)"])
+
+    subgraph Outbound["Outbound — data OUT of this repo"]
+        SITE_COV["agentnative-site<br/>coverage-matrix.json"]
+        SITE_SCORE["agentnative-site<br/>per-tool scorecards"]
+        TAP["brettdavies/homebrew-tap<br/>(formula + bottles)"]
+        CRATES["crates.io<br/>(agentnative crate)"]
+    end
+
+    SPEC -->|sync-spec.sh| CLI
+    SITE_IN -->|sync-skill-fixture.sh| CLI
+    MAIN -->|sync-dev-after-release.sh<br/>main → dev| CLI
+
+    CLI -->|site's sync-coverage-matrix.sh<br/>(cli is authoritative)| SITE_COV
+    CLI -->|site's regen-scorecards.sh<br/>(anc check ... --output json)| SITE_SCORE
+    CLI -->|release.yml<br/>repository_dispatch:update-formula| TAP
+    CLI -->|release.yml<br/>cargo publish via OIDC| CRATES
+
+    TAP -.->|repository_dispatch:finalize-release<br/>(inverse — flips make_latest)| CLI
+
+    classDef repo fill:#1f2937,stroke:#60a5fa,color:#f9fafb
+    classDef self fill:#0f766e,stroke:#5eead4,color:#f0fdfa
+    class SPEC,SITE_IN,MAIN,SITE_COV,SITE_SCORE,TAP,CRATES repo
+    class CLI self
+```
+
+## Upstream — data flowing INTO this repo
+
+| Source                                                       | Mechanism                                                                 | What's synced                                                                                                                                                            | Trigger / cadence                                                                                                                                               | Drift check                                                                                                                                                                                                                                                      |
+| ------------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `brettdavies/agentnative` (spec) @ latest `v*` tag           | `scripts/sync-spec.sh` (manual; remote-first, falls back to `$SPEC_ROOT`) | `principles/p*-*.md` + top-level `VERSION` + `CHANGELOG.md` → `src/principles/spec/`                                                                                     | Rerun after every new `agentnative-spec` `v*` tag. The intended trigger is a `repository_dispatch` from the spec's publish workflow; until that exists, manual. | `build.rs` is *intentionally loud* — fails on missing `VERSION`, missing `principles/` dir, parse errors, duplicate IDs, or missing fields. `cargo test` (`integration::*` + `dangling_cover_ids`) catches `covers()` IDs that drift from the vendored registry. |
+| `brettdavies/agentnative-site` `src/data/skill.json` @ `dev` | `scripts/sync-skill-fixture.sh` (manual; `--check` in CI)                 | Skill bundle manifest (install map / hosts) → `src/skill_install/skill.json`                                                                                             | Rerun whenever the site changes `src/data/skill.json`. Pre-release checklist in `RELEASES.md` step 7 captures this for every release.                           | `.github/workflows/skill-fixture-drift.yml` runs `sync-skill-fixture.sh --check` on every PR + push to main/dev. Companion cargo test `host_map_matches_site_skill_json` catches drift between the Rust-codegen map and this fixture.                            |
+| this repo's own `main` branch (release artifacts)            | `scripts/sync-dev-after-release.sh vX.Y.Z` (manual; idempotent)           | `Cargo.toml` `[package].version` (surgical, single-line awk) + regenerated `Cargo.lock` (`cargo build --release`) + `CHANGELOG.md` (verbatim from `origin/main`) → `dev` | Run AFTER (1) `release/v*` → `main` PR merges, (2) `git tag vX.Y.Z` pushed, (3) `finalize-release.yml` flips the GitHub Release to `published`.                 | n/a — single signed commit, surgical edits, idempotent re-run is a no-op. Pre-flight checks: working tree clean, tag exists locally, tag is reachable from `origin/main`.                                                                                        |
+
+## Downstream — data flowing OUT of this repo
+
+| Consumer                                                               | Mechanism                                                                                                                                                                                                                                  | What's synced                                                                                                                                                        | Trigger / cadence                                                                                                                                                                                                          | Drift check                                                                                                                                                                                                                                                                                                                                            |
+| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `brettdavies/agentnative-site` (`/coverage` page)                      | site's `scripts/sync-coverage-matrix.sh` `cp`s from `$ANC_ROOT/coverage/matrix.json` (default `$HOME/dev/agentnative-cli`) → `src/data/coverage-matrix.json`                                                                               | `coverage/matrix.json` (`schema_version: "1.0"`), generated here by `anc generate coverage-matrix`, committed as a tracked artifact (not gitignored)                 | Run on the site after this repo bumps the matrix (new check, registry change, or `Check::covers()` change).                                                                                                                | This repo's CI (via `cargo test`) runs `test_generate_coverage_matrix_drift_check_passes_on_committed_artifacts`, which invokes `anc generate coverage-matrix --check` and exits non-zero when `docs/coverage-matrix.md` or `coverage/matrix.json` disagree with the registry. Site has no automated drift check — the cli-side gate is authoritative. |
+| `brettdavies/agentnative-site` (per-tool scorecards)                   | site's `scripts/regen-scorecards.sh` runs `anc check --command <bin> [--audit-profile <profile>] --output json` against each registry entry; writes `scorecards/<name>-v<version>.json` in the site repo                                   | Per-tool scorecard JSONs (`schema_version: "0.5"`); the `anc` binary embeds `spec_version` at compile time (sourced from the vendored `src/principles/spec/VERSION`) | Run on the site after `anc` is upgraded on the box (`brew upgrade brettdavies/tap/agentnative`); also run on registry changes. Script enforces `MIN_ANC_VERSION` (currently `0.1.3`) unless `--allow-dev-build` is passed. | Site validates schema 0.5 invariants at build time (`bun test` + `bun run build`). Filename owns the canonical version anchor — the actually-installed `anc --version` determines the output filename, so a filename can never lie about which release was scored.                                                                                     |
+| `brettdavies/homebrew-tap` (formula bump)                              | `.github/workflows/release.yml` → reusable `brettdavies/.github/.github/workflows/rust-release.yml@main` → `homebrew` job fires `repository_dispatch` (`event_type=update-formula`, payload: formula=`agentnative`, version=`X.Y.Z`, repo) | Triggers homebrew-tap to bump the `agentnative` formula and build bottles                                                                                            | On every `git tag v*.*.*` push to this repo. Authenticated via `CI_RELEASE_TOKEN` (fine-grained PAT with Contents R+W).                                                                                                    | n/a at this boundary. Bottle-build success is observable via the homebrew-tap workflow run; bottle-upload back to this repo's Release assets is what triggers the inverse `finalize-release` dispatch (next row).                                                                                                                                      |
+| `brettdavies/agentnative-cli` (this repo's own `finalize-release.yml`) | Inverse `repository_dispatch` from homebrew-tap's publish workflow — `event_type=finalize-release`                                                                                                                                         | Bottle SHAs uploaded to this Release's assets; `make_latest` flips from `false` → `true` on the GitHub Release                                                       | Fired by homebrew-tap after bottles upload. Idempotent — re-dispatch is safe.                                                                                                                                              | n/a — the flip is observable on the Release page.                                                                                                                                                                                                                                                                                                      |
+| `crates.io` (`agentnative` crate)                                      | Same `release.yml` → `publish-crate` job, `cargo publish` via OIDC Trusted Publishing (no static token after first publish)                                                                                                                | The compiled crate at the tag's version                                                                                                                              | On every `git tag v*.*.*` push. First publish requires `CARGO_REGISTRY_TOKEN` one-time; subsequent publishes are token-less.                                                                                               | `check-version` job gates the pipeline: tag must match `Cargo.toml` `[package].version` exactly, else release aborts before any publish.                                                                                                                                                                                                               |
+
+## Release / sync orchestration
+
+The full sync graph clusters around two events: **"new spec tag upstream"** and **"new `anc` release downstream"**.
+
+### When `agentnative-spec` cuts a new `v*` tag
+
+1. **Manual** — rerun `scripts/sync-spec.sh` here. Diff `src/principles/spec/`. Commit on a feature branch.
+2. **Manual** — propagate any new/changed requirement IDs into `src/checks/*` `Check::covers()` declarations. The
+   build's `dangling_cover_ids` drift detector forces this — typos surface at `cargo test`, not at render time.
+3. **Manual** — if the registry shape or covers map changed, run `anc generate coverage-matrix` and commit
+   `docs/coverage-matrix.md` + `coverage/matrix.json`. The cargo-level `--check` test fails CI otherwise.
+4. **Manual on the site** — once a new `anc` version ships (see next section), rerun
+   `agentnative-site/scripts/sync-coverage-matrix.sh` to pick up the new `coverage/matrix.json`.
+
+### When `agentnative-cli` cuts a new `v*` tag
+
+1. **Manual pre-release** (`RELEASES.md` step 7) — `bash scripts/sync-skill-fixture.sh` and review the diff. Catches any
+   site-side `skill.json` changes since `dev` was branched. The Rust host map regenerates from the JSON on the next
+   `cargo build` — no manual src edits.
+2. **Automatic on tag push** — `release.yml` runs `check-version` → `audit` → `build` (5 targets) → `publish-crate`
+   (crates.io OIDC) → `release` (draft GH Release, `make_latest: false`) → `homebrew` (`repository_dispatch` to
+   homebrew-tap).
+3. **Automatic, inverse** — homebrew-tap builds bottles, uploads them as assets on this repo's Release, then dispatches
+   `finalize-release` back to this repo. `finalize-release.yml` flips `make_latest: true` idempotently.
+4. **Manual post-release** — `./scripts/sync-dev-after-release.sh vX.Y.Z` then `git push origin dev`. Backports the
+   release-bookkeeping single-commit (Cargo.toml version, regenerated Cargo.lock, CHANGELOG.md from main) to `dev` so
+   future builds from `dev` report the released version and the embedded badge URL points at the right slug.
+5. **Manual on the site** — `scripts/regen-scorecards.sh` against the upgraded `anc` (gated by `MIN_ANC_VERSION` =
+   `0.1.3`) refreshes per-tool scorecards. Then `scripts/sync-coverage-matrix.sh` if the matrix changed.
+
+#### Release pipeline sequence
+
+```mermaid
+sequenceDiagram
+    autonumber
+    actor Maintainer
+    participant CLI as agentnative-cli repo
+    participant REL as release.yml
+    participant CRATES as crates.io
+    participant TAP as brettdavies/homebrew-tap
+    participant FIN as finalize-release.yml
+
+    Maintainer->>CLI: git push origin vX.Y.Z (tag)
+    CLI->>REL: tag push triggers workflow
+    REL->>REL: check-version (tag == Cargo.toml)
+    REL->>REL: audit + build (5 targets)
+    REL->>CRATES: publish-crate (cargo publish via OIDC)
+    CRATES-->>REL: published
+    REL->>CLI: create draft GH Release (make_latest: false)
+    REL->>TAP: repository_dispatch (event_type=update-formula)
+    TAP->>TAP: bump formula + build bottles
+    TAP->>CLI: upload bottle assets to Release
+    TAP->>FIN: repository_dispatch (event_type=finalize-release)
+    FIN->>CLI: flip make_latest: true (idempotent)
+    FIN-->>Maintainer: Release is now "latest"
+    Maintainer->>CLI: ./scripts/sync-dev-after-release.sh vX.Y.Z (manual)
+```
+
+### Cadence summary — what's automatic vs manual
+
+| Step                                             | Automation                                                    |
+| ------------------------------------------------ | ------------------------------------------------------------- |
+| spec → cli (`sync-spec.sh`)                      | manual (intended: spec `repository_dispatch`, not yet wired)  |
+| site → cli (`sync-skill-fixture.sh`) update      | manual; CI enforces no-drift via `--check`                    |
+| cli → site (coverage matrix)                     | manual on the site side; CI enforces no-drift on the cli side |
+| cli → site (scorecards)                          | manual on the site side                                       |
+| cli → crates.io                                  | automatic on `v*` tag push                                    |
+| cli → homebrew-tap (formula)                     | automatic on `v*` tag push                                    |
+| homebrew-tap → cli (`finalize-release`)          | automatic on bottle upload                                    |
+| cli main → cli dev (`sync-dev-after-release.sh`) | manual after `finalize-release` publishes                     |
+
+## Reference
+
+- [`scripts/sync-spec.sh`](sync-spec.sh) — header comment has detailed usage, env vars, and resync cadence.
+- [`scripts/sync-skill-fixture.sh`](sync-skill-fixture.sh) — header comment covers `--check` mode and CI integration.
+- [`scripts/sync-dev-after-release.sh`](sync-dev-after-release.sh) — header comment lists pre-flight conditions.
+- [`../RELEASES.md`](../RELEASES.md) — full release pipeline (branch flow, tag/publish, post-release sync).
+- [`../docs/plans/2026-04-23-001-feat-spec-vendor-plan.md`](../docs/plans/2026-04-23-001-feat-spec-vendor-plan.md) —
+  status: completed. The plan that originated the vendored-spec mechanism.
+- agentnative-spec roadmap (parent of the spec-vendor plan):
+  `agentnative-spec/docs/plans/2026-04-22-002-post-frontmatter-roadmap.md`.
+-
+  [`../docs/solutions/architecture-patterns/cross-repo-artifact-sync-commit-over-fetch-20260420.md`](../docs/solutions/architecture-patterns/cross-repo-artifact-sync-commit-over-fetch-20260420.md)
+  — the "commit-over-fetch" decision that anchors why `coverage/matrix.json` is a tracked artifact rather than a
+  build-time fetch.
+-
+  [`../docs/solutions/best-practices/cross-repo-artifact-consumption-static-sites-2026-04-21.md`](../docs/solutions/best-practices/cross-repo-artifact-consumption-static-sites-2026-04-21.md)
+  — the consumer-side pattern for the site.
diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push
index a9d2349..f3522c0 100755
--- a/scripts/hooks/pre-push
+++ b/scripts/hooks/pre-push
@@ -41,26 +41,47 @@ else
     echo "  - deny (skipped, cargo-deny not installed)"
 fi
 
-# 5. Windows cross-check: verify no unconditional use of unix-only APIs
-# This catches the libc::SIGPIPE issue without needing a Windows toolchain.
+# 5. Windows cross-check (fast): verify no unconditional use of unix-only APIs.
+# Catches the libc::SIGPIPE class of issue without invoking a Windows toolchain
+# — runs in milliseconds. Step 6 is the comprehensive backstop.
 if rg -n 'libc::(SIGPIPE|SIG_DFL|signal)' --type rust src/ | rg -v '#\[cfg(unix)\]' | rg -v '// *#\[cfg' | rg -qv '^$'; then
-    # Found libc unix calls — check they're inside #[cfg(unix)] blocks
     violations=$(rg -n 'libc::(SIGPIPE|SIG_DFL|signal)' --type rust src/ 2>/dev/null || true)
     if [ -n "$violations" ]; then
-        # Check each file for proper cfg gating
         while IFS= read -r line; do
             file=$(echo "$line" | cut -d: -f1)
             lineno=$(echo "$line" | cut -d: -f2)
-            # Look for #[cfg(unix)] in the 3 lines before the libc call
             before=$(sed -n "$((lineno > 3 ? lineno - 3 : 1)),${lineno}p" "$file")
             if ! echo "$before" | rg -q 'cfg\(unix\)'; then
                 fail "libc unix-only API used without #[cfg(unix)] at $file:$lineno"
             fi
         done <<< "$violations"
     fi
-    pass "windows compat"
+    pass "windows compat (libc grep)"
 else
-    pass "windows compat"
+    pass "windows compat (libc grep)"
+fi
+
+# 6. Windows cross-clippy: full type-check of the codebase as if compiling for
+# x86_64-pc-windows-gnu. Catches cfg-gated unused imports, conditional dead
+# code, and any other warning class that only surfaces on Windows. Linux
+# clippy alone misses these because `#[cfg(unix)]` filters out their usage
+# sites locally — the warning only fires on the platform where the gate
+# excludes them.
+#
+# Requirements (one-time setup):
+#   sudo apt install mingw-w64                          # the C linker tree-sitter needs
+#   rustup target add x86_64-pc-windows-gnu             # the Windows std library
+#
+# If either is missing the step is skipped with a one-line note rather than
+# failing — but local Windows coverage is then absent, and CI's Windows job
+# becomes the only backstop. Install both to close that gap.
+if command -v x86_64-w64-mingw32-gcc &>/dev/null \
+    && rustup target list --installed 2>/dev/null | grep -q '^x86_64-pc-windows-gnu$'; then
+    RUSTFLAGS="-Dwarnings" cargo clippy --target x86_64-pc-windows-gnu --all-targets --quiet 2>&1 \
+        || fail "cargo clippy --target x86_64-pc-windows-gnu -Dwarnings"
+    pass "windows clippy (cross-target)"
+else
+    echo "  - windows clippy (skipped: install \`mingw-w64\` + \`rustup target add x86_64-pc-windows-gnu\` to enable)"
 fi
 
 echo -e "${BOLD}${GREEN}All checks passed.${RESET}"
diff --git a/scripts/sync-dev-after-release.sh b/scripts/sync-dev-after-release.sh
new file mode 100755
index 0000000..aa558c2
--- /dev/null
+++ b/scripts/sync-dev-after-release.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# Backport release artifacts from main to dev after a release tag publishes.
+#
+# Pulls three files from main and lands them as a single signed commit on dev:
+#   - Cargo.toml — surgically updates ONLY the [package].version line. Other
+#     Cargo.toml lines on dev (deps, rust-version, etc) may legitimately be
+#     ahead of main; they are preserved.
+#   - Cargo.lock — regenerated cleanly via `cargo build --release` after the
+#     version bump. Never hand-patched.
+#   - CHANGELOG.md — copied verbatim from origin/main. Main is fully
+#     authoritative for CHANGELOG; dev never edits it directly.
+#
+# Run AFTER:
+#   1. The release/v* → main PR has merged.
+#   2. `git tag -a vX.Y.Z` has been pushed to origin.
+#   3. `finalize-release.yml` has flipped the GitHub Release to `published`
+#      (i.e. homebrew bottles uploaded, make_latest=true, full release done).
+#
+# Usage:
+#   ./scripts/sync-dev-after-release.sh v0.2.0
+#
+# Idempotent: safe to re-run. If dev already matches main on these three
+# files, the script exits 0 with no commit.
+
+set -euo pipefail
+
+if [[ $# -ne 1 ]]; then
+    echo "usage: $0 vX.Y.Z" >&2
+    exit 64
+fi
+
+VERSION="$1"
+if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+    echo "error: version must match vMAJOR.MINOR.PATCH (got: $VERSION)" >&2
+    exit 64
+fi
+VERSION_NO_V="${VERSION#v}"
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+
+if [[ -n "$(git status --porcelain)" ]]; then
+    echo "error: working tree not clean — commit or stash first" >&2
+    git status --short >&2
+    exit 65
+fi
+
+git fetch origin --tags --quiet
+
+# Verify the release tag exists on origin/main.
+if ! git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then
+    echo "error: tag $VERSION not found locally — run 'git fetch origin --tags' or verify the release published" >&2
+    exit 66
+fi
+
+# Verify main is at or past the tag (i.e. release actually merged).
+TAG_SHA="$(git rev-parse "$VERSION")"
+if ! git merge-base --is-ancestor "$TAG_SHA" origin/main; then
+    echo "error: tag $VERSION is not reachable from origin/main — wait for release/v* to merge" >&2
+    exit 66
+fi
+
+git switch dev
+git pull --ff-only origin dev
+
+# Surgical Cargo.toml version bump (only the first `version = "..."` line,
+# which is the [package] section's by convention). Uses awk to avoid sed -i
+# portability issues across Linux/macOS.
+awk -v ver="$VERSION_NO_V" '
+    !done && /^version = "[^"]*"$/ { print "version = \"" ver "\""; done=1; next }
+    { print }
+' Cargo.toml > Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml
+
+# CHANGELOG.md from main (authoritative).
+git checkout origin/main -- CHANGELOG.md
+
+# Regenerate Cargo.lock cleanly.
+cargo build --release --quiet
+
+if git diff --quiet Cargo.toml Cargo.lock CHANGELOG.md; then
+    echo "no changes — dev already in sync with $VERSION"
+    exit 0
+fi
+
+git add Cargo.toml Cargo.lock CHANGELOG.md
+git commit -m "chore(release): backport $VERSION artifacts to dev
+
+Brings dev's release-bookkeeping current with the $VERSION release on main:
+Cargo.toml [package].version, regenerated Cargo.lock, and CHANGELOG.md
+copied from origin/main."
+
+echo "committed; push with: git push origin dev"
diff --git a/scripts/sync-skill-fixture.sh b/scripts/sync-skill-fixture.sh
new file mode 100755
index 0000000..efd9770
--- /dev/null
+++ b/scripts/sync-skill-fixture.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# Sync src/skill_install/skill.json from agentnative-site/src/data/skill.json.
+#
+# The fixture is also the build-time input for build.rs's host-map codegen
+# (which emits the SkillHost enum, KNOWN_HOSTS const, and resolve_host fn
+# into $OUT_DIR/generated_hosts.rs). Single source of truth: rerun this
+# script on site updates and `cargo build` regenerates the Rust map. CI's
+# skill-fixture-drift workflow runs `--check` on every PR to catch drift
+# between the committed fixture and the canonical site contract.
+#
+# Modes:
+#   scripts/sync-skill-fixture.sh           Update the fixture in place.
+#   scripts/sync-skill-fixture.sh --check   Verify the fixture is current;
+#                                           exit non-zero on drift.
+#
+# Env vars (mirroring scripts/sync-spec.sh shape):
+#   SKILL_SITE_REMOTE_URL  Remote URL to query first.
+#                          Default: https://github.com/brettdavies/agentnative-site.git
+#   SKILL_SITE_REF         Ref to extract from. Default: dev.
+#                          (agentnative-site uses a dev/main forever-branch
+#                          flow — dev is the working trunk; main is older.)
+#   SKILL_SITE_ROOT        Local checkout to fall back to when the remote is
+#                          unreachable. Default: $HOME/dev/agentnative-site
+#
+# Resync cadence: rerun whenever agentnative-site changes
+# src/data/skill.json. Pre-release checklist captures this in RELEASES.md.
+
+set -euo pipefail
+
+SKILL_SITE_REMOTE_URL="${SKILL_SITE_REMOTE_URL:-https://github.com/brettdavies/agentnative-site.git}"
+SKILL_SITE_REF="${SKILL_SITE_REF:-dev}"
+SKILL_SITE_ROOT="${SKILL_SITE_ROOT:-$HOME/dev/agentnative-site}"
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+DEST_FILE="$REPO_ROOT/src/skill_install/skill.json"
+SOURCE_PATH="src/data/skill.json"
+
+mode="update"
+if [[ ${1:-} == "--check" ]]; then
+    mode="check"
+elif [[ -n ${1:-} ]]; then
+    echo "error: unknown argument: $1" >&2
+    echo "usage: $0 [--check]" >&2
+    exit 2
+fi
+
+# Always-allocated workspace dir for the upstream copy. The remote path
+# also clones into a child of this dir; the local path uses it just for
+# the staged blob.
+tmp_root="$(mktemp -d -t agentnative-site-sync-XXXXXX)"
+cleanup() {
+    if [[ -n "$tmp_root" && -d "$tmp_root" ]]; then
+        rm -rf "$tmp_root"
+    fi
+}
+trap cleanup EXIT
+
+# === Remote-first resolution ===========================================
+site_source=""
+resolved_sha=""
+
+echo "querying $SKILL_SITE_REMOTE_URL for $SKILL_SITE_REF..."
+remote_clone="$tmp_root/clone"
+if git clone --depth 1 --branch "$SKILL_SITE_REF" --quiet \
+        "$SKILL_SITE_REMOTE_URL" "$remote_clone" 2>/dev/null; then
+    site_source="$remote_clone"
+    resolved_sha="$(git -C "$site_source" rev-parse --short=7 HEAD)"
+    echo "extracting $SKILL_SITE_REF ($resolved_sha) from remote $SKILL_SITE_REMOTE_URL"
+fi
+
+# === Local fallback ====================================================
+if [[ -z "$site_source" ]]; then
+    if [[ ! -d "$SKILL_SITE_ROOT/.git" ]]; then
+        echo "error: remote unreachable and SKILL_SITE_ROOT is not a git repository: $SKILL_SITE_ROOT" >&2
+        echo "       remote: $SKILL_SITE_REMOTE_URL" >&2
+        echo "       set SKILL_SITE_ROOT to your agentnative-site checkout, or check network access." >&2
+        exit 1
+    fi
+    echo "warning: remote query failed; falling back to local $SKILL_SITE_ROOT" >&2
+    site_source="$SKILL_SITE_ROOT"
+    resolved_sha="$(git -C "$site_source" rev-parse --short=7 "$SKILL_SITE_REF" 2>/dev/null || echo "unknown")"
+    echo "extracting $SKILL_SITE_REF ($resolved_sha) from local $site_source"
+fi
+
+# === Extract via git show (works identically for remote and local) =====
+if ! git -C "$site_source" cat-file -e "$SKILL_SITE_REF:$SOURCE_PATH" 2>/dev/null; then
+    echo "error: $SKILL_SITE_REF has no $SOURCE_PATH in $site_source" >&2
+    exit 1
+fi
+
+# Stream the upstream blob to a temp file. Variable capture (`$(...)`) would
+# strip trailing newlines, breaking byte-for-byte parity with the committed
+# fixture. File-based comparison preserves exact bytes including the EOF
+# newline boundary.
+upstream_tmp="$tmp_root/skill.json"
+git -C "$site_source" show "$SKILL_SITE_REF:$SOURCE_PATH" >"$upstream_tmp"
+
+# === Mode-specific behavior ============================================
+case "$mode" in
+    update)
+        mkdir -p "$(dirname "$DEST_FILE")"
+        cp "$upstream_tmp" "$DEST_FILE"
+        echo "wrote $DEST_FILE"
+        echo
+        echo "next: review \`git diff $DEST_FILE\` for unexpected changes, then commit."
+        ;;
+    check)
+        if cmp -s "$DEST_FILE" "$upstream_tmp"; then
+            echo "ok: $DEST_FILE matches $SKILL_SITE_REF:$SOURCE_PATH ($resolved_sha)"
+            exit 0
+        fi
+        echo "error: $DEST_FILE drifted from $SKILL_SITE_REF:$SOURCE_PATH ($resolved_sha)" >&2
+        echo "       run \`scripts/sync-skill-fixture.sh\` to refresh, then commit." >&2
+        echo >&2
+        diff -u "$DEST_FILE" "$upstream_tmp" || true
+        exit 1
+        ;;
+esac
diff --git a/scripts/sync-spec.sh b/scripts/sync-spec.sh
index 2deee8d..7109c0b 100755
--- a/scripts/sync-spec.sh
+++ b/scripts/sync-spec.sh
@@ -1,74 +1,118 @@
 #!/usr/bin/env bash
 # Vendor agentnative-spec into src/principles/spec/.
 #
-# Extracts files at a pinned git ref via `git show <ref>:<path>` so the user's
-# spec working tree is not perturbed. The vendored tree is the build-time input
-# for build.rs, which generates the REQUIREMENTS slice.
+# Resolves the latest v* tag of agentnative-spec, preferring the remote
+# repository, and falls back to a local checkout if the remote is
+# unreachable. Extracts files via `git show <tag>:<path>` so neither
+# checkout's working tree is perturbed. The vendored tree is the
+# build-time input for build.rs, which generates the REQUIREMENTS slice.
 #
 # Usage:
 #   scripts/sync-spec.sh
-#   SPEC_REF=v0.2.1 scripts/sync-spec.sh
 #   SPEC_ROOT=/path/to/agentnative-spec scripts/sync-spec.sh
+#   SPEC_REMOTE_URL=git@github.com:brettdavies/agentnative.git scripts/sync-spec.sh
 #
 # Env vars:
-#   SPEC_ROOT  Path to agentnative-spec checkout. Default: $HOME/dev/agentnative-spec
-#   SPEC_REF   Git ref (tag, branch, or SHA) to vendor. Default: v0.3.0
+#   SPEC_REMOTE_URL  Remote URL to query first.
+#                    Default: https://github.com/brettdavies/agentnative.git
+#   SPEC_ROOT        Local checkout to fall back to when the remote is
+#                    unreachable. Default: $HOME/dev/agentnative-spec
 #
-# Resync cadence: rerun after every new agentnative-spec tag. Stale orphan
-# files in src/principles/spec/principles/ (e.g., from a spec rename) are
-# accepted; `git status` surfaces them at commit time.
+# Resync cadence: rerun after every new agentnative-spec tag. The remote
+# query picks up new tags automatically; a local fallback only sees what
+# the local checkout already has fetched.
+#
+# Stale orphan files in src/principles/spec/principles/ (e.g., from a
+# spec rename) are accepted; `git status` surfaces them at commit time.
 
 set -euo pipefail
 
+SPEC_REMOTE_URL="${SPEC_REMOTE_URL:-https://github.com/brettdavies/agentnative.git}"
 SPEC_ROOT="${SPEC_ROOT:-$HOME/dev/agentnative-spec}"
-SPEC_REF="${SPEC_REF:-v0.3.0}"
 
 REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
 DEST_DIR="$REPO_ROOT/src/principles/spec"
 DEST_PRINCIPLES="$DEST_DIR/principles"
 
-if [[ ! -d "$SPEC_ROOT/.git" ]]; then
-    echo "error: SPEC_ROOT is not a git repository: $SPEC_ROOT" >&2
-    echo "       set SPEC_ROOT to your agentnative-spec checkout, or clone it to the default" >&2
-    echo "       location: \$HOME/dev/agentnative-spec" >&2
-    exit 1
-fi
+# Cleanup hook for the temp clone (set only after mktemp succeeds).
+tmp_root=""
+cleanup() {
+    if [[ -n "$tmp_root" && -d "$tmp_root" ]]; then
+        rm -rf "$tmp_root"
+    fi
+}
+trap cleanup EXIT
 
-if ! git -C "$SPEC_ROOT" rev-parse --verify --quiet "$SPEC_REF^{commit}" >/dev/null; then
-    echo "error: SPEC_REF does not resolve to a commit in $SPEC_ROOT: $SPEC_REF" >&2
-    echo "       try \`git -C $SPEC_ROOT fetch --tags\` or check the ref name" >&2
-    exit 1
+# === Remote-first resolution ===========================================
+spec_source=""
+spec_tag=""
+
+echo "querying $SPEC_REMOTE_URL for latest v* tag..."
+remote_tag="$(git ls-remote --tags --sort='-version:refname' \
+    "$SPEC_REMOTE_URL" 'refs/tags/v*' 2>/dev/null \
+    | awk '{print $2}' \
+    | sed 's|refs/tags/||' \
+    | grep -v '\^{}$' \
+    | head -n 1 || true)"
+
+if [[ -n "$remote_tag" ]]; then
+    tmp_root="$(mktemp -d -t agentnative-spec-XXXXXX)"
+    if git clone --depth 1 --branch "$remote_tag" --quiet \
+            "$SPEC_REMOTE_URL" "$tmp_root" 2>/dev/null; then
+        spec_source="$tmp_root"
+        spec_tag="$remote_tag"
+        resolved_sha="$(git -C "$spec_source" rev-parse --short=7 "$spec_tag^{commit}")"
+        echo "vendoring $spec_tag ($resolved_sha) from remote $SPEC_REMOTE_URL"
+    fi
 fi
 
-resolved_sha="$(git -C "$SPEC_ROOT" rev-parse --short=7 "$SPEC_REF^{commit}")"
-echo "vendoring $SPEC_REF ($resolved_sha) from $SPEC_ROOT"
+# === Local fallback ====================================================
+if [[ -z "$spec_source" ]]; then
+    if [[ ! -d "$SPEC_ROOT/.git" ]]; then
+        echo "error: remote unreachable and SPEC_ROOT is not a git repository: $SPEC_ROOT" >&2
+        echo "       remote: $SPEC_REMOTE_URL" >&2
+        echo "       set SPEC_ROOT to your agentnative-spec checkout, or check network access." >&2
+        exit 1
+    fi
+    echo "warning: remote query failed; falling back to local $SPEC_ROOT" >&2
+
+    spec_source="$SPEC_ROOT"
+    spec_tag="$(git -C "$spec_source" tag --list 'v*' --sort='-version:refname' | head -n 1)"
+    if [[ -z "$spec_tag" ]]; then
+        echo "error: no v* tags found in $SPEC_ROOT" >&2
+        echo "       try \`git -C $SPEC_ROOT fetch --tags\` to pick up upstream tags" >&2
+        exit 1
+    fi
+    resolved_sha="$(git -C "$spec_source" rev-parse --short=7 "$spec_tag^{commit}")"
+    echo "vendoring $spec_tag ($resolved_sha) from local $spec_source"
+fi
 
-# Verify the principles/ tree exists at the ref.
-if ! git -C "$SPEC_ROOT" cat-file -e "$SPEC_REF:principles" 2>/dev/null; then
-    echo "error: $SPEC_REF has no principles/ directory in $SPEC_ROOT" >&2
+# === Verify + extract (works identically for remote and local sources) =
+if ! git -C "$spec_source" cat-file -e "$spec_tag:principles" 2>/dev/null; then
+    echo "error: $spec_tag has no principles/ directory in $spec_source" >&2
     exit 1
 fi
 
 mkdir -p "$DEST_PRINCIPLES"
 
 # VERSION and CHANGELOG.md are top-level in the spec repo.
-git -C "$SPEC_ROOT" show "$SPEC_REF:VERSION" >"$DEST_DIR/VERSION"
-git -C "$SPEC_ROOT" show "$SPEC_REF:CHANGELOG.md" >"$DEST_DIR/CHANGELOG.md"
+git -C "$spec_source" show "$spec_tag:VERSION" >"$DEST_DIR/VERSION"
+git -C "$spec_source" show "$spec_tag:CHANGELOG.md" >"$DEST_DIR/CHANGELOG.md"
 
-# Enumerate principle files at the ref and extract each one.
+# Enumerate principle files at the tag and extract each one.
 copied=0
 while IFS= read -r path; do
     case "$path" in
         principles/p*-*.md)
             dest_name="${path#principles/}"
-            git -C "$SPEC_ROOT" show "$SPEC_REF:$path" >"$DEST_PRINCIPLES/$dest_name"
+            git -C "$spec_source" show "$spec_tag:$path" >"$DEST_PRINCIPLES/$dest_name"
             copied=$((copied + 1))
             ;;
     esac
-done < <(git -C "$SPEC_ROOT" ls-tree --name-only "$SPEC_REF" principles/)
+done < <(git -C "$spec_source" ls-tree --name-only "$spec_tag" principles/)
 
 if [[ "$copied" -eq 0 ]]; then
-    echo "error: no principles/p*-*.md files found at $SPEC_REF" >&2
+    echo "error: no principles/p*-*.md files found at $spec_tag" >&2
     exit 1
 fi
 
diff --git a/src/argv.rs b/src/argv.rs
index db3b3da..ee3450c 100644
--- a/src/argv.rs
+++ b/src/argv.rs
@@ -3,7 +3,7 @@
 //! and so the injection logic is unit-testable in isolation.
 
 use std::collections::HashSet;
-use std::ffi::OsString;
+use std::ffi::{OsStr, OsString};
 
 use crate::cli::Cli;
 
@@ -113,10 +113,10 @@ where
                 .iter()
                 .any(|(l, _)| l.as_deref() == Some(rest));
         }
-        if let Some(rest) = token.strip_prefix('-') {
-            if let Some(c) = rest.chars().next().filter(|_| rest.len() == 1) {
-                return all_value_flags.iter().any(|(_, s)| *s == Some(c));
-            }
+        if let Some(rest) = token.strip_prefix('-')
+            && let Some(c) = rest.chars().next().filter(|_| rest.len() == 1)
+        {
+            return all_value_flags.iter().any(|(_, s)| *s == Some(c));
         }
         false
     };
@@ -148,10 +148,10 @@ where
             // Track whether this flag belongs to a subcommand rather than the
             // top-level Cli. If so, the user clearly intends `check` even when
             // no positional argument follows (e.g. `anc --command rg`).
-            if let Some(base) = base_form(&token) {
-                if !top_level_flags.contains(&base) {
-                    saw_subcommand_flag = true;
-                }
+            if let Some(base) = base_form(&token)
+                && !top_level_flags.contains(&base)
+            {
+                saw_subcommand_flag = true;
             }
             i += if consumes_next(&token) { 2 } else { 1 };
             continue;
@@ -174,9 +174,79 @@ where
     args
 }
 
+/// Format a captured argv vector as a shell-quoted command string, suitable
+/// for the scorecard's `run.invocation` field. Uses single-quote quoting:
+/// args containing whitespace, single quotes, double quotes, or shell
+/// metacharacters are wrapped in `'…'`, with embedded `'` escaped as
+/// `'\''`. Lossy UTF-8 conversion is intentional — the field records what
+/// the user typed for human review, not for byte-perfect replay.
+///
+/// Captured *before* `inject_default_subcommand` rewrites the args, so the
+/// recorded command reflects user intent (`anc .` stays as `anc .`, not
+/// `anc check .`).
+pub fn format_invocation(args: &[OsString]) -> String {
+    args.iter()
+        .map(|a| quote_arg(a))
+        .collect::<Vec<_>>()
+        .join(" ")
+}
+
+fn quote_arg(arg: &OsStr) -> String {
+    let s = arg.to_string_lossy();
+    if s.is_empty() {
+        return "''".to_string();
+    }
+    if needs_quoting(&s) {
+        // Single-quote everything; escape embedded single quotes by closing
+        // the quoted run, emitting `\'`, and reopening — POSIX-shell idiom.
+        let mut out = String::with_capacity(s.len() + 2);
+        out.push('\'');
+        for c in s.chars() {
+            if c == '\'' {
+                out.push_str("'\\''");
+            } else {
+                out.push(c);
+            }
+        }
+        out.push('\'');
+        out
+    } else {
+        s.into_owned()
+    }
+}
+
+fn needs_quoting(s: &str) -> bool {
+    s.chars().any(|c| {
+        c.is_whitespace()
+            || matches!(
+                c,
+                '\'' | '"'
+                    | '\\'
+                    | '$'
+                    | '`'
+                    | '|'
+                    | '&'
+                    | ';'
+                    | '<'
+                    | '>'
+                    | '('
+                    | ')'
+                    | '{'
+                    | '}'
+                    | '*'
+                    | '?'
+                    | '['
+                    | ']'
+                    | '#'
+                    | '~'
+                    | '!'
+            )
+    })
+}
+
 #[cfg(test)]
 mod tests {
-    use super::inject_default_subcommand;
+    use super::{format_invocation, inject_default_subcommand};
     use std::ffi::OsString;
 
     fn args(a: &[&str]) -> Vec<OsString> {
@@ -339,4 +409,67 @@ mod tests {
         let out = inject_default_subcommand(args(&["anc", ".", "--output", "json"]));
         assert_eq!(names(out), vec!["anc", "check", ".", "--output", "json"]);
     }
+
+    // ---- format_invocation ----
+
+    #[test]
+    fn format_invocation_simple_args_unquoted() {
+        let out = format_invocation(&args(&["anc", "check", "."]));
+        assert_eq!(out, "anc check .");
+    }
+
+    #[test]
+    fn format_invocation_pre_injection_user_intent_preserved() {
+        // Plan R4 intent check: a user who typed `anc .` MUST see `anc .` in
+        // the scorecard, not `anc check .` (which would be a fact about anc's
+        // internals, not the user's command).
+        let out = format_invocation(&args(&["anc", "."]));
+        assert_eq!(out, "anc .");
+    }
+
+    #[test]
+    fn format_invocation_arg_with_space_is_single_quoted() {
+        let out = format_invocation(&args(&["anc", "check", "/tmp/with space/repo"]));
+        assert_eq!(out, "anc check '/tmp/with space/repo'");
+    }
+
+    #[test]
+    fn format_invocation_arg_with_single_quote_is_escaped() {
+        // POSIX-shell escape: close, emit `\'`, reopen.
+        let out = format_invocation(&args(&["anc", "check", "ab'cd"]));
+        assert_eq!(out, "anc check 'ab'\\''cd'");
+    }
+
+    #[test]
+    fn format_invocation_arg_with_metacharacters_is_quoted() {
+        let out = format_invocation(&args(&["anc", "check", "$(rm -rf)"]));
+        assert_eq!(out, "anc check '$(rm -rf)'");
+    }
+
+    #[test]
+    fn format_invocation_empty_arg_renders_as_empty_quotes() {
+        let out = format_invocation(&args(&["anc", ""]));
+        assert_eq!(out, "anc ''");
+    }
+
+    #[test]
+    fn format_invocation_double_quote_is_quoted() {
+        let out = format_invocation(&args(&["anc", "say\"hi"]));
+        assert_eq!(out, "anc 'say\"hi'");
+    }
+
+    #[test]
+    fn format_invocation_round_trip_no_panic_on_invalid_utf8() {
+        // Linux-only: build an OsString containing invalid UTF-8. Lossy
+        // conversion must not panic; the field carries the lossy form.
+        #[cfg(target_os = "linux")]
+        {
+            use std::ffi::OsString;
+            use std::os::unix::ffi::OsStringExt;
+
+            let invalid = OsString::from_vec(vec![0xff, 0xfe]);
+            let out = format_invocation(&[OsString::from("anc"), invalid]);
+            assert!(out.starts_with("anc "));
+        }
+    }
 }
diff --git a/src/build_info.rs b/src/build_info.rs
new file mode 100644
index 0000000..a8f0d6f
--- /dev/null
+++ b/src/build_info.rs
@@ -0,0 +1,25 @@
+//! Build-time identity constants for `anc` itself.
+//!
+//! `ANC_VERSION` is the crate version (`CARGO_PKG_VERSION`) and surfaces in the
+//! scorecard's `anc.version` so a consumer can identify the `anc` build that
+//! produced a scorecard. Generated by `build.rs` into `$OUT_DIR/build_info.rs`.
+
+include!(concat!(env!("OUT_DIR"), "/build_info.rs"));
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn anc_version_matches_cargo_pkg_version() {
+        // The build script must populate `ANC_VERSION` from `CARGO_PKG_VERSION`.
+        // If they ever diverge, the scorecard's `anc.version` would lie about
+        // which crate version produced it — silently.
+        assert_eq!(ANC_VERSION, env!("CARGO_PKG_VERSION"));
+    }
+
+    #[test]
+    fn anc_version_is_non_empty() {
+        assert!(!ANC_VERSION.is_empty());
+    }
+}
diff --git a/src/checks/project/dry_run.rs b/src/checks/project/dry_run.rs
index cb0e861..af691f8 100644
--- a/src/checks/project/dry_run.rs
+++ b/src/checks/project/dry_run.rs
@@ -85,12 +85,11 @@ impl Check for DryRunCheck {
         }
 
         // Also check the manifest for subcommand names that imply writes
-        if !has_write_commands {
-            if let Some(manifest) = &project.manifest_path {
-                if let Ok(content) = fs::read_to_string(manifest) {
-                    has_write_commands = WRITE_KEYWORDS.iter().any(|kw| content.contains(kw));
-                }
-            }
+        if !has_write_commands
+            && let Some(manifest) = &project.manifest_path
+            && let Ok(content) = fs::read_to_string(manifest)
+        {
+            has_write_commands = WRITE_KEYWORDS.iter().any(|kw| content.contains(kw));
         }
 
         let status = if !has_write_commands {
diff --git a/src/checks/project/error_module.rs b/src/checks/project/error_module.rs
index 5848b5d..7a6376b 100644
--- a/src/checks/project/error_module.rs
+++ b/src/checks/project/error_module.rs
@@ -55,22 +55,22 @@ impl Check for ErrorModuleCheck {
         }
 
         // Check subdirectories: src/*/error.rs, src/*/errors.rs
-        if src_dir.is_dir() {
-            if let Ok(entries) = fs::read_dir(&src_dir) {
-                for entry in entries.flatten() {
-                    let path = entry.path();
-                    if path.is_dir() {
-                        for name in &["error.rs", "errors.rs", "error.py", "errors.py"] {
-                            if path.join(name).exists() {
-                                return Ok(CheckResult {
-                                    id: self.id().to_string(),
-                                    label: self.label().into(),
-                                    group: self.group(),
-                                    layer: self.layer(),
-                                    status: CheckStatus::Pass,
-                                    confidence: Confidence::High,
-                                });
-                            }
+        if src_dir.is_dir()
+            && let Ok(entries) = fs::read_dir(&src_dir)
+        {
+            for entry in entries.flatten() {
+                let path = entry.path();
+                if path.is_dir() {
+                    for name in &["error.rs", "errors.rs", "error.py", "errors.py"] {
+                        if path.join(name).exists() {
+                            return Ok(CheckResult {
+                                id: self.id().to_string(),
+                                label: self.label().into(),
+                                group: self.group(),
+                                layer: self.layer(),
+                                status: CheckStatus::Pass,
+                                confidence: Confidence::High,
+                            });
                         }
                     }
                 }
diff --git a/src/checks/source/python/no_color.rs b/src/checks/source/python/no_color.rs
index 234e1db..dcdd4a8 100644
--- a/src/checks/source/python/no_color.rs
+++ b/src/checks/source/python/no_color.rs
@@ -96,10 +96,10 @@ pub(crate) fn source_handles_no_color(source: &str) -> bool {
         r#"getenv('NO_COLOR')"#,
     ];
     for p_str in access_patterns {
-        if let Ok(pattern) = Pattern::try_new(p_str, Python) {
-            if root.root().find(&pattern).is_some() {
-                return true;
-            }
+        if let Ok(pattern) = Pattern::try_new(p_str, Python)
+            && root.root().find(&pattern).is_some()
+        {
+            return true;
         }
     }
 
diff --git a/src/checks/source/rust/error_types.rs b/src/checks/source/rust/error_types.rs
index 93d934e..0b9f672 100644
--- a/src/checks/source/rust/error_types.rs
+++ b/src/checks/source/rust/error_types.rs
@@ -96,10 +96,10 @@ pub(crate) fn has_error_enum(source: &str) -> bool {
             // Extract the enum name from the matched text
             if let Some(enum_pos) = text.find("enum ") {
                 let after_enum = &text[enum_pos + 5..];
-                if let Some(name) = after_enum.split_whitespace().next() {
-                    if name.contains("Error") {
-                        return true;
-                    }
+                if let Some(name) = after_enum.split_whitespace().next()
+                    && name.contains("Error")
+                {
+                    return true;
                 }
             }
         }
diff --git a/src/checks/source/rust/exit_codes.rs b/src/checks/source/rust/exit_codes.rs
index 7f11bc3..86428ff 100644
--- a/src/checks/source/rust/exit_codes.rs
+++ b/src/checks/source/rust/exit_codes.rs
@@ -105,25 +105,25 @@ fn find_raw_exit_codes(source: &str, file: &str) -> Vec<SourceLocation> {
         for m in root_node.find_all(&pattern) {
             let text = m.text().to_string();
             // Extract the argument inside exit(...)
-            if let Some(start) = text.rfind('(') {
-                if let Some(end) = text.rfind(')') {
-                    let arg = text[start + 1..end].trim();
-                    // A raw integer literal is all digits (possibly with a leading minus)
-                    let is_raw_literal = !arg.is_empty()
-                        && arg
-                            .strip_prefix('-')
-                            .unwrap_or(arg)
-                            .chars()
-                            .all(|c| c.is_ascii_digit());
-                    if is_raw_literal {
-                        let pos = m.start_pos();
-                        violations.push(SourceLocation {
-                            file: file.to_string(),
-                            line: pos.line() + 1,
-                            column: pos.column(&m) + 1,
-                            text,
-                        });
-                    }
+            if let Some(start) = text.rfind('(')
+                && let Some(end) = text.rfind(')')
+            {
+                let arg = text[start + 1..end].trim();
+                // A raw integer literal is all digits (possibly with a leading minus)
+                let is_raw_literal = !arg.is_empty()
+                    && arg
+                        .strip_prefix('-')
+                        .unwrap_or(arg)
+                        .chars()
+                        .all(|c| c.is_ascii_digit());
+                if is_raw_literal {
+                    let pos = m.start_pos();
+                    violations.push(SourceLocation {
+                        file: file.to_string(),
+                        line: pos.line() + 1,
+                        column: pos.column(&m) + 1,
+                        text,
+                    });
                 }
             }
         }
diff --git a/src/checks/source/rust/naked_println.rs b/src/checks/source/rust/naked_println.rs
index 0321c3a..34483c4 100644
--- a/src/checks/source/rust/naked_println.rs
+++ b/src/checks/source/rust/naked_println.rs
@@ -4,6 +4,9 @@
 //! dedicated output module, not scatter `println!` calls across the codebase.
 //! `eprintln!` is exempt (diagnostics go to stderr).
 //! Files with "output" or "display" in their path are exempt (output modules).
+//! `build.rs` is also exempt — Cargo build scripts emit metadata via
+//! `println!("cargo:...")` directives by required-by-protocol convention; no
+//! alternative API exists, so flagging them produces noise without recourse.
 
 use crate::check::Check;
 use crate::project::{Language, Project};
@@ -44,11 +47,15 @@ impl Check for NakedPrintlnCheck {
         for (path, parsed_file) in parsed.iter() {
             let file_str = path.display().to_string();
 
-            // Exempt files with "output" or "display" in their path
+            // Exempt files with "output" or "display" in their path, and
+            // Cargo build scripts (build.rs at any crate root).
             let lower = file_str.to_lowercase();
             if lower.contains("output") || lower.contains("display") {
                 continue;
             }
+            if is_cargo_build_script(&file_str) {
+                continue;
+            }
 
             if let CheckStatus::Warn(evidence) = check_naked_println(&parsed_file.source, &file_str)
             {
@@ -73,6 +80,30 @@ impl Check for NakedPrintlnCheck {
     }
 }
 
+/// True when `path` names a Cargo build script (`build.rs` at any crate
+/// root). The convention is fixed by Cargo — build scripts are always at
+/// `<crate-root>/build.rs`, never nested under `src/`, `tests/`, `examples/`,
+/// or `benches/`. Paths under those directories that happen to be named
+/// `build.rs` are misnamed source files, not build scripts, and stay flagged.
+fn is_cargo_build_script(path: &str) -> bool {
+    let lower = path.to_lowercase();
+    // Normalize Windows separators so segment checks are uniform.
+    let normalized = lower.replace('\\', "/");
+    let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
+    let Some(last) = segments.last() else {
+        return false;
+    };
+    if *last != "build.rs" {
+        return false;
+    }
+    // Cargo build scripts live at crate root. Reject paths where any
+    // ancestor segment is a known non-root subdirectory.
+    let parents = &segments[..segments.len() - 1];
+    !parents
+        .iter()
+        .any(|s| matches!(*s, "src" | "tests" | "examples" | "benches"))
+}
+
 /// Check a single source string for `println!` and `print!` calls.
 ///
 /// Kept public(crate) for unit testing with inline source strings.
@@ -177,6 +208,39 @@ fn main() {
         }
     }
 
+    #[test]
+    fn build_script_path_recognized() {
+        assert!(is_cargo_build_script("build.rs"));
+        assert!(is_cargo_build_script("./build.rs"));
+        assert!(is_cargo_build_script("/abs/path/build.rs"));
+        assert!(is_cargo_build_script("BUILD.RS")); // case-insensitive
+        assert!(is_cargo_build_script("subcrate\\build.rs")); // Windows path
+
+        assert!(!is_cargo_build_script("src/build.rs"));
+        // build.rs nested under src/ is not the cargo build script — it's a
+        // misnamed source file. Cargo build scripts only live at crate root.
+        // Exception: workspace member build scripts at <member>/build.rs
+        // are correctly matched by the `/build.rs` suffix logic.
+        assert!(!is_cargo_build_script("src/skill_install.rs"));
+        assert!(!is_cargo_build_script("build.rs.bak"));
+    }
+
+    #[test]
+    fn check_skips_build_script_println() {
+        // Direct check of the helper — the macro pattern matches, but the
+        // path-level filter in run() skips build.rs callers.
+        let source = r#"
+fn main() {
+    println!("cargo:rerun-if-changed=src/principles/spec/");
+}
+"#;
+        // The unit-level helper still warns (it doesn't know about file
+        // path); the run() loop is what skips. Confirm both behaviors.
+        let status = check_naked_println(source, "build.rs");
+        assert!(matches!(status, CheckStatus::Warn(_)));
+        assert!(is_cargo_build_script("build.rs"));
+    }
+
     #[test]
     fn applicable_for_rust() {
         let check = NakedPrintlnCheck;
diff --git a/src/cli.rs b/src/cli.rs
index 71efb66..436c43c 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -2,6 +2,7 @@ use clap::{Parser, Subcommand, ValueEnum, ValueHint};
 use clap_complete::Shell;
 
 use crate::principles::registry::ExceptionCategory;
+use crate::skill_install::SkillHost;
 
 #[derive(Parser)]
 #[command(name = "anc", version, about = "The agent-native CLI linter")]
@@ -79,6 +80,35 @@ pub enum Commands {
         #[command(subcommand)]
         artifact: GenerateKind,
     },
+    /// Install or manage the agentnative skill bundle
+    Skill {
+        #[command(subcommand)]
+        cmd: SkillCmd,
+    },
+}
+
+#[derive(Subcommand)]
+pub enum SkillCmd {
+    /// Install the skill bundle into a host's canonical skills directory.
+    ///
+    /// If the site adds a host before this `anc` release knows about it, run
+    /// the manual fallback printed by `--dry-run` for any known host and
+    /// substitute the destination path:
+    ///
+    ///     git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git <dest>
+    Install {
+        /// Target host (claude_code, codex, cursor, opencode).
+        host: SkillHost,
+
+        /// Print the resolved git command without spawning. Captures cleanly
+        /// via `eval $(anc skill install --dry-run <host>)`.
+        #[arg(long)]
+        dry_run: bool,
+
+        /// Output format for the result envelope.
+        #[arg(long, default_value = "text")]
+        output: OutputFormat,
+    },
 }
 
 #[derive(Subcommand)]
diff --git a/src/error.rs b/src/error.rs
index 65d0c59..86d1d0a 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -7,4 +7,26 @@ pub enum AppError {
 
     #[error("I/O error: {0}")]
     Io(#[from] std::io::Error),
+
+    #[error("HOME environment variable is not set")]
+    MissingHome,
+
+    #[error("`git` not found on PATH")]
+    GitNotFound,
+
+    #[error("`git clone` failed with exit code {code}")]
+    GitCloneFailed { code: i32 },
+
+    #[error("destination exists as a regular file: {path}")]
+    DestIsFile { path: std::path::PathBuf },
+
+    #[error("destination directory is not empty: {path}")]
+    DestNotEmpty { path: std::path::PathBuf },
+
+    #[error("failed to read destination at {path}: {source}")]
+    DestReadFailed {
+        path: std::path::PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
 }
diff --git a/src/main.rs b/src/main.rs
index fdbd9a2..a15a624 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,30 +1,41 @@
 mod argv;
+mod build_info;
 mod check;
 mod checks;
 mod cli;
 mod error;
+mod output;
 mod principles;
 mod project;
 mod runner;
 mod scorecard;
+mod skill_install;
 mod source;
 mod types;
 
+use std::time::{Duration, Instant};
+
 use clap::Parser as _;
 use clap_complete::generate;
+use time::OffsetDateTime;
+use time::format_description::well_known::Rfc3339;
 
-use argv::inject_default_subcommand;
+use argv::{format_invocation, inject_default_subcommand};
+use build_info::ANC_VERSION;
 use check::Check;
 use checks::behavioral::all_behavioral_checks;
 use checks::project::all_project_checks;
 use checks::source::all_source_checks;
-use cli::{Cli, Commands, GenerateKind, OutputFormat};
+use cli::{Cli, Commands, GenerateKind, OutputFormat, SkillCmd};
 use error::AppError;
 use principles::matrix;
 use principles::registry::{ExceptionCategory, SUPPRESSION_EVIDENCE_PREFIX, suppresses};
 use project::Project;
-use scorecard::audience;
-use scorecard::{exit_code, format_json, format_text};
+use runner::{BinaryRunner, RunStatus};
+use scorecard::{
+    AncInfo, PlatformInfo, RunInfo, RunMetadata, TargetInfo, ToolInfo, audience, compute_badge,
+    exit_code, format_json, format_text,
+};
 use types::{CheckGroup, CheckResult, CheckStatus, Confidence};
 
 fn main() {
@@ -45,7 +56,13 @@ fn main() {
 }
 
 fn run() -> Result<i32, AppError> {
-    let cli = Cli::parse_from(inject_default_subcommand(std::env::args_os()));
+    // Capture argv *before* `inject_default_subcommand` rewrites bare paths
+    // into `check <path>`, so the scorecard's `run.invocation` reflects what
+    // the user actually typed (R4). The injection rewrite is an internal
+    // detail; recording it would lie about user intent.
+    let raw_argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
+
+    let cli = Cli::parse_from(inject_default_subcommand(raw_argv.iter().cloned()));
 
     // --quiet is global (visible in top-level --help for agent discoverability)
     let quiet = cli.quiet;
@@ -82,6 +99,9 @@ fn run() -> Result<i32, AppError> {
             Some(Commands::Generate { artifact }) => {
                 return run_generate(artifact);
             }
+            Some(Commands::Skill { cmd }) => {
+                return run_skill(cmd);
+            }
             None => {
                 let mut cmd = <Cli as clap::CommandFactory>::command();
                 eprintln!("{}", cmd.render_help());
@@ -89,8 +109,18 @@ fn run() -> Result<i32, AppError> {
             }
         };
 
+    // Run-level timing starts at the top of the Check arm (R4): wall-clock
+    // milliseconds and an RFC 3339 UTC timestamp. We use `OffsetDateTime` for
+    // formatting only — duration math goes through `Instant` which is
+    // monotonic and unaffected by wall-clock adjustments.
+    let start_instant = Instant::now();
+    let started_at = OffsetDateTime::now_utc()
+        .format(&Rfc3339)
+        .unwrap_or_else(|_| String::from("1970-01-01T00:00:00Z"));
+
     // --command resolves a binary from PATH and runs behavioral checks against
     // it. conflicts_with = "path" ensures only one of the two is provided.
+    let command_name = command.clone();
     let resolved_path = match command {
         Some(name) => resolve_command_on_path(&name)?,
         None => path,
@@ -141,21 +171,21 @@ fn run() -> Result<i32, AppError> {
         if !check.applicable(&project) {
             continue;
         }
-        if let Some(cat) = exception_category {
-            if suppresses(check.id(), cat) {
-                results.push(CheckResult {
-                    id: check.id().to_string(),
-                    label: check.label().to_string(),
-                    group: check.group(),
-                    layer: check.layer(),
-                    status: CheckStatus::Skip(format!(
-                        "{SUPPRESSION_EVIDENCE_PREFIX}{}",
-                        cat.as_kebab_case()
-                    )),
-                    confidence: Confidence::High,
-                });
-                continue;
-            }
+        if let Some(cat) = exception_category
+            && suppresses(check.id(), cat)
+        {
+            results.push(CheckResult {
+                id: check.id().to_string(),
+                label: check.label().to_string(),
+                group: check.group(),
+                layer: check.layer(),
+                status: CheckStatus::Skip(format!(
+                    "{SUPPRESSION_EVIDENCE_PREFIX}{}",
+                    cat.as_kebab_case()
+                )),
+                confidence: Confidence::High,
+            });
+            continue;
         }
         let result = match check.run(&project) {
             Ok(r) => r,
@@ -184,18 +214,219 @@ fn run() -> Result<i32, AppError> {
     let audit_profile_label = exception_category.map(|c| c.as_kebab_case().to_string());
 
     // Format output. `format_json` needs the check catalog so it can map
-    // result IDs back to the requirements each check covers.
+    // result IDs back to the requirements each check covers, plus the
+    // run-level metadata (`tool`, `anc`, `run`, `target`). For text mode
+    // we still need the tool slug so the badge hint can render the
+    // canonical embed URL — derive it cheaply (no version probe) and
+    // hand it to `compute_badge`.
     let output_str = match output {
-        OutputFormat::Text => format_text(&results, quiet),
+        OutputFormat::Text => {
+            let tool_name = derive_tool_name(command_name.as_deref(), &project);
+            let badge = compute_badge(&results, &tool_name);
+            format_text(&results, quiet, Some(&badge))
+        }
         OutputFormat::Json => {
-            format_json(&results, &all_checks, audience_label, audit_profile_label)
+            let target = build_target_info(command_name.as_deref(), &project);
+            let tool = build_tool_info(command_name.as_deref(), &project);
+            let invocation = format_invocation(&raw_argv);
+            let duration_ms =
+                u64::try_from(start_instant.elapsed().as_millis()).unwrap_or(u64::MAX);
+            let metadata = RunMetadata {
+                tool,
+                anc: AncInfo {
+                    version: ANC_VERSION,
+                },
+                run: RunInfo {
+                    invocation,
+                    started_at,
+                    duration_ms,
+                    platform: PlatformInfo {
+                        os: std::env::consts::OS,
+                        arch: std::env::consts::ARCH,
+                    },
+                },
+                target,
+            };
+            format_json(
+                &results,
+                &all_checks,
+                audience_label,
+                audit_profile_label,
+                metadata,
+            )
         }
     };
-    print!("{output_str}");
+    output::emit(&output_str);
 
     Ok(exit_code(&results))
 }
 
+/// Classify what `anc check` was pointed at into structured `target` metadata.
+/// Three modes: `command` (PATH-resolved), `binary` (file argument), `project`
+/// (directory argument).
+///
+/// `path` is the **basename** of the resolved target — the directory name in
+/// project mode, the file name in binary mode. Absolute paths from
+/// `Project::discover`'s canonicalization would leak operator PII (home-dir
+/// username, org/employer dir structure) into committed scorecards, README
+/// badge URLs, and any agent-posted artifact. The basename carries the only
+/// signal a consumer needs (matches the slug used by `tool.name` and the
+/// `/score/<slug>` URL); anything richer is information leakage with no
+/// compensating use. For pathological paths where `file_name()` returns
+/// `None` (e.g. `/`), `path` falls back to `null` per the always-present
+/// null contract.
+fn build_target_info(command_name: Option<&str>, project: &Project) -> TargetInfo {
+    match command_name {
+        Some(name) => TargetInfo {
+            kind: "command".into(),
+            path: None,
+            command: Some(name.to_string()),
+        },
+        None if project.path.is_dir() => TargetInfo {
+            kind: "project".into(),
+            path: basename_string(&project.path),
+            command: None,
+        },
+        None => TargetInfo {
+            kind: "binary".into(),
+            path: basename_string(&project.path),
+            command: None,
+        },
+    }
+}
+
+/// Return the basename of `path` as an owned `String`, or `None` if the path
+/// has no file-name component (e.g. `/`, `..`). Uses `to_string_lossy` so
+/// non-UTF-8 path components round-trip with replacement characters rather
+/// than vanishing.
+fn basename_string(path: &std::path::Path) -> Option<String> {
+    path.file_name().map(|n| n.to_string_lossy().into_owned())
+}
+
+/// Cheap slug derivation: the same `name` `build_tool_info` would emit, but
+/// without the manifest read or `--version` subprocess probe. Used by the
+/// text-mode badge hint, where we need the slug to render the embed URL but
+/// have no use for the version. Keeping this in lock-step with
+/// `build_tool_info`'s `name` calculation guarantees the text-mode hint
+/// references the same `<tool>` slug a `--output json` consumer would see.
+fn derive_tool_name(command_name: Option<&str>, project: &Project) -> String {
+    match command_name {
+        Some(cmd) => cmd.to_string(),
+        None => project
+            .path
+            .file_name()
+            .and_then(|n| n.to_str())
+            .map(String::from)
+            .unwrap_or_default(),
+    }
+}
+
+/// Build the scorecard's `tool` block. `name` is always present (deterministic
+/// from path / command name). `binary` is the executable basename when one
+/// exists. `version` is best-effort: project-mode prefers the manifest version,
+/// command/binary mode probes `<bin> --version` / `<bin> -V`. Any failure
+/// yields `null` rather than aborting the run.
+fn build_tool_info(command_name: Option<&str>, project: &Project) -> ToolInfo {
+    let name = derive_tool_name(command_name, project);
+    let (binary, version_seed) = match command_name {
+        Some(cmd) => {
+            // Command mode: binary echoes the user-supplied name (NOT the
+            // resolved path — we don't want to leak /usr/local/bin/foo as
+            // the binary identifier).
+            (Some(cmd.to_string()), None)
+        }
+        None => {
+            if project.path.is_dir() {
+                let manifest_version = project
+                    .manifest_path
+                    .as_deref()
+                    .and_then(read_manifest_version);
+                let binary_name = project
+                    .binary_paths
+                    .first()
+                    .and_then(|p| p.file_name())
+                    .and_then(|n| n.to_str())
+                    .map(String::from);
+                (binary_name, manifest_version)
+            } else {
+                // Binary file passed directly.
+                (Some(name.clone()), None)
+            }
+        }
+    };
+
+    // Manifest version takes precedence; fall back to the binary self-report.
+    let version = version_seed.or_else(|| probe_tool_version(project));
+
+    ToolInfo {
+        name,
+        binary,
+        version,
+    }
+}
+
+/// Best-effort `<binary> --version` / `<binary> -V` probe. Reuses the runner's
+/// timeout + 1MB cap primitives via a fresh `BinaryRunner` with a tighter
+/// 2-second timeout (the version probe is one-shot, not a check).
+///
+/// Self-spawn guard: comparing the resolved binary path to `current_exe()`
+/// declines the probe when `anc` is asked to score itself. Without this,
+/// `anc check --command anc` would recursively score `anc` — bounded only by
+/// `arg_required_else_help` in `Cli`. Belt-and-suspenders.
+fn probe_tool_version(project: &Project) -> Option<String> {
+    let binary = project.binary_paths.first()?;
+
+    if let Ok(self_exe) = std::env::current_exe()
+        && let (Ok(a), Ok(b)) = (binary.canonicalize(), self_exe.canonicalize())
+        && a == b
+    {
+        // Both paths canonicalized (Project::discover canonicalizes; the OS
+        // resolves current_exe). Direct comparison is the right primitive.
+        return None;
+    }
+
+    let runner = BinaryRunner::new(binary.clone(), Duration::from_secs(2)).ok()?;
+    for flag in ["--version", "-V"] {
+        let result = runner.run(&[flag], &[]);
+        if matches!(result.status, RunStatus::Ok)
+            && result.exit_code == Some(0)
+            && let Some(line) = result.stdout.lines().next()
+        {
+            let trimmed = line.trim();
+            if !trimmed.is_empty() {
+                return Some(trimmed.to_string());
+            }
+        }
+    }
+    None
+}
+
+/// Read `package.version` from a Cargo.toml or `project.version` from a
+/// pyproject.toml. Returns `None` for unreadable / unparseable / missing-field
+/// cases — the version probe falls through to the binary self-report.
+fn read_manifest_version(manifest: &std::path::Path) -> Option<String> {
+    let content = std::fs::read_to_string(manifest).ok()?;
+    let parsed: toml::Value = content.parse().ok()?;
+
+    // Cargo.toml: [package] version = "...".
+    if let Some(v) = parsed
+        .get("package")
+        .and_then(|p| p.get("version"))
+        .and_then(|v| v.as_str())
+    {
+        return Some(v.to_string());
+    }
+    // pyproject.toml: [project] version = "...".
+    if let Some(v) = parsed
+        .get("project")
+        .and_then(|p| p.get("version"))
+        .and_then(|v| v.as_str())
+    {
+        return Some(v.to_string());
+    }
+    None
+}
+
 /// Resolve a command name to an absolute path by shelling out to `which`
 /// (Unix) or `where` (Windows). Returns a clear, actionable error when the
 /// name cannot be found on PATH. Subsequent `Project::discover()` validates
@@ -228,6 +459,16 @@ fn resolve_command_on_path(name: &str) -> Result<std::path::PathBuf, AppError> {
     Ok(std::path::PathBuf::from(first))
 }
 
+fn run_skill(cmd: SkillCmd) -> Result<i32, AppError> {
+    match cmd {
+        SkillCmd::Install {
+            host,
+            dry_run,
+            output,
+        } => skill_install::run_install(host, dry_run, output),
+    }
+}
+
 fn run_generate(artifact: GenerateKind) -> Result<i32, AppError> {
     match artifact {
         GenerateKind::CoverageMatrix {
@@ -280,25 +521,25 @@ fn run_generate(artifact: GenerateKind) -> Result<i32, AppError> {
                 return Ok(if md_matches && json_matches { 0 } else { 2 });
             }
 
-            if let Some(parent) = out.parent() {
-                if !parent.as_os_str().is_empty() {
-                    std::fs::create_dir_all(parent).map_err(|e| {
-                        AppError::ProjectDetection(anyhow::anyhow!(
-                            "creating parent dir for {}: {e}",
-                            out.display()
-                        ))
-                    })?;
-                }
+            if let Some(parent) = out.parent()
+                && !parent.as_os_str().is_empty()
+            {
+                std::fs::create_dir_all(parent).map_err(|e| {
+                    AppError::ProjectDetection(anyhow::anyhow!(
+                        "creating parent dir for {}: {e}",
+                        out.display()
+                    ))
+                })?;
             }
-            if let Some(parent) = json_out.parent() {
-                if !parent.as_os_str().is_empty() {
-                    std::fs::create_dir_all(parent).map_err(|e| {
-                        AppError::ProjectDetection(anyhow::anyhow!(
-                            "creating parent dir for {}: {e}",
-                            json_out.display()
-                        ))
-                    })?;
-                }
+            if let Some(parent) = json_out.parent()
+                && !parent.as_os_str().is_empty()
+            {
+                std::fs::create_dir_all(parent).map_err(|e| {
+                    AppError::ProjectDetection(anyhow::anyhow!(
+                        "creating parent dir for {}: {e}",
+                        json_out.display()
+                    ))
+                })?;
             }
             std::fs::write(&out, &rendered_md).map_err(|e| {
                 AppError::ProjectDetection(anyhow::anyhow!("writing {}: {e}", out.display()))
diff --git a/src/output.rs b/src/output.rs
new file mode 100644
index 0000000..0417158
--- /dev/null
+++ b/src/output.rs
@@ -0,0 +1,21 @@
+//! Single stdout hand-off point for the binary.
+//!
+//! Centralizing this lets the `p7-naked-println` source check exempt one
+//! module by path convention (filename contains "output") instead of treating
+//! every `println!`/`print!` site across the orchestration layer as a
+//! naked-println violation. Callers route final user-facing output through
+//! `emit` (no trailing newline) or `emit_line` (with trailing newline) per
+//! whatever the caller's renderer already produces.
+
+/// Write `s` to stdout with no trailing newline. Use when the caller's
+/// renderer has already appended whatever line terminator it wants.
+pub fn emit(s: &str) {
+    print!("{s}");
+}
+
+/// Write `s` to stdout with a trailing newline. Use when the caller renders
+/// content that does not end in a newline (e.g. JSON one-liners) and needs
+/// the terminal cursor to advance.
+pub fn emit_line(s: &str) {
+    println!("{s}");
+}
diff --git a/src/principles/spec/README.md b/src/principles/spec/README.md
index d123578..e34ee0a 100644
--- a/src/principles/spec/README.md
+++ b/src/principles/spec/README.md
@@ -1,22 +1,22 @@
 # Vendored agentnative-spec
 
 This directory is a **vendored copy** of [`brettdavies/agentnative`](https://github.com/brettdavies/agentnative) — the
-canonical specification of agent-native CLI principles. Files here are not edited by hand; they are mirrored from a
-pinned upstream tag and consumed by `build.rs` to generate the `REQUIREMENTS` slice at build time.
-
-**Current pin:** `v0.2.0`
+canonical specification of agent-native CLI principles. Files here are not edited by hand; they are mirrored from the
+latest upstream `v*` tag and consumed by `build.rs` to generate the `REQUIREMENTS` slice at build time. The currently
+vendored version is recorded in [`VERSION`](./VERSION).
 
 ## Resync
 
 Run from the repo root:
 
 ```sh
-scripts/sync-spec.sh                    # default: SPEC_REF=v0.2.0
-SPEC_REF=v0.2.1 scripts/sync-spec.sh    # bump to a newer tag
+scripts/sync-spec.sh    # queries the remote for the latest v* tag; falls back to local on network failure
 ```
 
-The script extracts files at the named git ref via `git show`, so the spec checkout's working tree is not perturbed.
-Override `SPEC_ROOT` if your spec checkout is not at `$HOME/dev/agentnative-spec`.
+The script queries `https://github.com/brettdavies/agentnative.git` for the latest `v*` tag and shallow-clones that tag
+into a temp directory for extraction. If the remote is unreachable, it falls back to a local checkout
+(`$HOME/dev/agentnative-spec` by default; override with `SPEC_ROOT`). Override `SPEC_REMOTE_URL` to query a different
+remote. The script extracts files via `git show`, so neither source's working tree is perturbed.
 
 ## Layout
 
diff --git a/src/project.rs b/src/project.rs
index 397fa0f..61cc11e 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -195,30 +195,28 @@ fn discover_binaries(
 fn discover_rust_binaries(dir: &Path, manifest_path: Option<&Path>) -> Vec<PathBuf> {
     let mut bin_names = Vec::new();
 
-    if let Some(manifest) = manifest_path {
-        if let Ok(content) = fs::read_to_string(manifest) {
-            if let Ok(doc) = content.parse::<toml::Table>() {
-                // Check [[bin]] entries
-                if let Some(bins) = doc.get("bin").and_then(|b| b.as_array()) {
-                    for bin in bins {
-                        if let Some(name) = bin.get("name").and_then(|n| n.as_str()) {
-                            bin_names.push(name.to_string());
-                        }
-                    }
-                }
-
-                // Fallback to package name if no [[bin]]
-                if bin_names.is_empty() {
-                    if let Some(name) = doc
-                        .get("package")
-                        .and_then(|p| p.get("name"))
-                        .and_then(|n| n.as_str())
-                    {
-                        bin_names.push(name.to_string());
-                    }
+    if let Some(manifest) = manifest_path
+        && let Ok(content) = fs::read_to_string(manifest)
+        && let Ok(doc) = content.parse::<toml::Table>()
+    {
+        // Check [[bin]] entries
+        if let Some(bins) = doc.get("bin").and_then(|b| b.as_array()) {
+            for bin in bins {
+                if let Some(name) = bin.get("name").and_then(|n| n.as_str()) {
+                    bin_names.push(name.to_string());
                 }
             }
         }
+
+        // Fallback to package name if no [[bin]]
+        if bin_names.is_empty()
+            && let Some(name) = doc
+                .get("package")
+                .and_then(|p| p.get("name"))
+                .and_then(|n| n.as_str())
+        {
+            bin_names.push(name.to_string());
+        }
     }
 
     let mut paths = Vec::new();
@@ -239,13 +237,13 @@ fn discover_simple_binaries(dir: &Path, subdirs: &[&str]) -> Vec<PathBuf> {
     let mut paths = Vec::new();
     for subdir in subdirs {
         let bin_dir = dir.join(subdir);
-        if bin_dir.is_dir() {
-            if let Ok(entries) = fs::read_dir(&bin_dir) {
-                for entry in entries.flatten() {
-                    let p = entry.path();
-                    if p.is_file() {
-                        paths.push(p);
-                    }
+        if bin_dir.is_dir()
+            && let Ok(entries) = fs::read_dir(&bin_dir)
+        {
+            for entry in entries.flatten() {
+                let p = entry.path();
+                if p.is_file() {
+                    paths.push(p);
                 }
             }
         }
diff --git a/src/runner/help_probe/mod.rs b/src/runner/help_probe/mod.rs
index 77cf5f3..1c23ed3 100644
--- a/src/runner/help_probe/mod.rs
+++ b/src/runner/help_probe/mod.rs
@@ -334,10 +334,10 @@ fn parse_subcommands(raw: &str) -> Vec<String> {
             // A new top-level section header ended the commands block.
             break;
         }
-        if let Some(name) = trimmed.split_whitespace().next() {
-            if is_subcommand_name(name) {
-                out.push(name.to_string());
-            }
+        if let Some(name) = trimmed.split_whitespace().next()
+            && is_subcommand_name(name)
+        {
+            out.push(name.to_string());
         }
     }
     out
diff --git a/src/scorecard/mod.rs b/src/scorecard/mod.rs
index 75ce1a9..049cff0 100644
--- a/src/scorecard/mod.rs
+++ b/src/scorecard/mod.rs
@@ -16,8 +16,32 @@ use crate::types::{CheckGroup, CheckResult, CheckStatus};
 /// first public release of `anc`. During `0.x`, additive fields are the
 /// norm; consumers feature-detect new keys rather than pinning exact
 /// values. History: `0.1` (initial), `0.2` (audience, audit_profile,
-/// coverage_summary), `0.3` (spec_version).
-pub const SCHEMA_VERSION: &str = "0.3";
+/// coverage_summary), `0.3` (spec_version), `0.4` (tool / anc / run /
+/// target metadata blocks — self-describing scoring run), `0.5` (`badge`
+/// block — eligibility, embed snippet, and badge/scorecard URLs derived
+/// from the run, so authors learn about the badge from the CLI itself
+/// rather than a round-trip to the site).
+pub const SCHEMA_VERSION: &str = "0.5";
+
+/// Eligibility floor for the agent-native badge, expressed as an integer
+/// percent. A score that meets or exceeds this floor qualifies a tool to
+/// embed the badge.
+///
+/// Authority is the site's published badge convention at
+/// <https://anc.dev/badge> (mirrors the `badgeColor` brightline in
+/// `agentnative-site/src/build/badge.mjs`). The spec convention currently
+/// lives on the `agentnative-spec` `feat/badge-claim-convention` branch
+/// and will move into the vendored spec via `sync-spec` once the floor
+/// lands as a published constant. Until then, this constant carries the
+/// site's authoritative value.
+pub const BADGE_ELIGIBILITY_FLOOR_PCT: u32 = 80;
+
+/// Canonical base URL the badge convention publishes against. Per the
+/// site convention, the URL is "always-latest" — `<base>/badge/<tool>.svg`
+/// reflects the most recent score against the most recent published spec.
+/// The constant is centralized here so the URL pattern is the single
+/// source of truth across `text_hint`, JSON emission, and tests.
+pub const BADGE_BASE_URL: &str = "https://anc.dev";
 
 /// Pre-launch (`0.x`) scorecard shape emitted by `anc check --output json`.
 ///
@@ -65,6 +89,189 @@ pub struct Scorecard {
     /// (build still succeeds; warning emitted). Pre-launch additive
     /// (schema `0.3`); older consumers feature-detect.
     pub spec_version: &'static str,
+    /// Identity of the scored target. Pre-launch additive (schema `0.4`).
+    pub tool: ToolInfo,
+    /// Identity of the `anc` build that produced this scorecard. Pre-launch
+    /// additive (schema `0.4`).
+    pub anc: AncInfo,
+    /// Run-level facts: invocation, timestamp, duration, platform.
+    /// Pre-launch additive (schema `0.4`).
+    pub run: RunInfo,
+    /// What `anc` was pointed at: project path, binary file, or PATH-resolved
+    /// command. Pre-launch additive (schema `0.4`).
+    pub target: TargetInfo,
+    /// Agent-native badge eligibility + embed snippet for this run. Always
+    /// emitted; below-floor runs leave `embed_markdown` `null` per the
+    /// "do not nag" rule in the site's badge convention. Pre-launch
+    /// additive (schema `0.5`).
+    pub badge: BadgeInfo,
+}
+
+/// Agent-native badge metadata derived from the current run.
+///
+/// `score_pct` is the rounded integer percent of `pass / (pass + warn +
+/// fail)` — the same denominator the site leaderboard uses. Skips and
+/// errors do not count toward either side of the ratio. When the
+/// denominator is zero (no scoring data — e.g., `--principle 99` filters
+/// every check out) the score is reported as `0` and `eligible` is
+/// `false`.
+///
+/// `eligible` is `true` iff `score_pct >= BADGE_ELIGIBILITY_FLOOR_PCT`
+/// **and** a tool slug was derivable. Without a slug we cannot construct
+/// the embed URL truthfully, and silently emitting a placeholder would
+/// mislead authors.
+///
+/// `embed_markdown` is `Some` only when the tool is eligible — the field
+/// drives the gating contract: a consumer that emits `embed_markdown` to
+/// a README knows it's safe to show because the floor was checked here.
+///
+/// `scorecard_url` and `badge_url` are populated whenever a tool slug
+/// exists, even below the floor. The site renders the SVG for every
+/// scored tool regardless of score so a regression below the floor shows
+/// the visual color shift instead of a 404.
+#[derive(Serialize)]
+pub struct BadgeInfo {
+    pub eligible: bool,
+    pub score_pct: u32,
+    pub embed_markdown: Option<String>,
+    pub scorecard_url: Option<String>,
+    pub badge_url: Option<String>,
+    pub convention_url: &'static str,
+}
+
+impl BadgeInfo {
+    /// Render the post-summary text hint shown in `--output text` mode
+    /// when the tool qualifies for the badge. Returns `None` below the
+    /// eligibility floor so callers can append unconditionally without
+    /// nagging authors who are not yet eligible.
+    pub fn text_hint(&self) -> Option<String> {
+        let embed = self.embed_markdown.as_deref()?;
+        Some(format!(
+            "\n🏆 Score: {}% — your tool qualifies for the agent-native badge.\n\
+             \x20  Embed in your README:\n\
+             \x20    {embed}\n\
+             \x20  Convention: {}/badge\n",
+            self.score_pct, BADGE_BASE_URL,
+        ))
+    }
+}
+
+/// Pure derivation of `BadgeInfo` from a result set and a tool slug. Used
+/// by both `build_scorecard` (for JSON emission) and the runner's text
+/// path (for the post-summary hint), so a single source of truth backs
+/// both surfaces.
+pub fn compute_badge(results: &[CheckResult], tool_name: &str) -> BadgeInfo {
+    let pct = score_pct(results);
+    let trimmed = tool_name.trim();
+    let has_slug = !trimmed.is_empty();
+    let eligible = has_slug && pct >= BADGE_ELIGIBILITY_FLOOR_PCT;
+
+    let scorecard_url = has_slug.then(|| format!("{BADGE_BASE_URL}/score/{trimmed}"));
+    let badge_url = has_slug.then(|| format!("{BADGE_BASE_URL}/badge/{trimmed}.svg"));
+    let embed_markdown = if eligible {
+        Some(format!(
+            "[![agent-native]({BADGE_BASE_URL}/badge/{trimmed}.svg)]({BADGE_BASE_URL}/score/{trimmed})"
+        ))
+    } else {
+        None
+    };
+
+    BadgeInfo {
+        eligible,
+        score_pct: pct,
+        embed_markdown,
+        scorecard_url,
+        badge_url,
+        convention_url: "https://anc.dev/badge",
+    }
+}
+
+/// Compute the rounded integer percent score using the leaderboard's
+/// denominator (`pass + warn + fail`). Skips and errors are excluded from
+/// both sides of the ratio. Returns `0` when no checks contribute (every
+/// status was Skip or Error, or no checks ran at all) — pairs with
+/// `BadgeInfo::eligible == false` so a zero score never qualifies.
+fn score_pct(results: &[CheckResult]) -> u32 {
+    let mut pass = 0u32;
+    let mut denom = 0u32;
+    for r in results {
+        match &r.status {
+            CheckStatus::Pass => {
+                pass += 1;
+                denom += 1;
+            }
+            CheckStatus::Warn(_) | CheckStatus::Fail(_) => {
+                denom += 1;
+            }
+            CheckStatus::Skip(_) | CheckStatus::Error(_) => {}
+        }
+    }
+    if denom == 0 {
+        0
+    } else {
+        let ratio = f64::from(pass) / f64::from(denom);
+        (ratio * 100.0).round() as u32
+    }
+}
+
+/// Identity of the scored target. `version` is best-effort: when the binary
+/// self-reports a parseable `--version` / `-V` first line we capture it,
+/// otherwise the field is `null`. The site's `registry.yaml` continues to own
+/// `version_extract` shell snippets as a fallback. Always-present keys
+/// (`null` rather than missing) keep consumer code simple.
+#[derive(Serialize)]
+pub struct ToolInfo {
+    pub name: String,
+    /// Binary basename when an executable was located; `null` for
+    /// project-mode runs without a built artifact.
+    pub binary: Option<String>,
+    /// Version string the tool self-reported. `null` when probing failed,
+    /// produced no parseable output, or was declined (self-spawn guard).
+    pub version: Option<String>,
+}
+
+/// Identity of the `anc` build that produced this scorecard. `version` is a
+/// build-time constant generated by `build.rs`.
+#[derive(Serialize)]
+pub struct AncInfo {
+    pub version: &'static str,
+}
+
+/// Run-level metadata. Captured by the runner immediately around the
+/// `Commands::Check` arm so the scorecard reflects this specific scoring run.
+///
+/// `invocation` is the user's argv joined with spaces, captured *before*
+/// `inject_default_subcommand` rewrites bare paths into `check <path>`.
+/// `started_at` is RFC 3339 / ISO 8601 in UTC. `duration_ms` is wall-clock
+/// milliseconds.
+#[derive(Serialize)]
+pub struct RunInfo {
+    pub invocation: String,
+    pub started_at: String,
+    pub duration_ms: u64,
+    pub platform: PlatformInfo,
+}
+
+/// `os` / `arch` tuple sourced from `std::env::consts::{OS, ARCH}`.
+#[derive(Serialize)]
+pub struct PlatformInfo {
+    pub os: &'static str,
+    pub arch: &'static str,
+}
+
+/// What `anc check` was pointed at. `kind` is one of `"project"`, `"binary"`,
+/// or `"command"`. `path` carries the **basename** of the resolved target
+/// (directory name in project mode, file name in binary mode) — never the
+/// full filesystem path, which would leak operator PII (home-dir username,
+/// org/employer dir structure) into committed scorecards, README badge URLs,
+/// and any agent-posted artifact. `command` carries the user-supplied name
+/// for `--command` mode. Always-present keys (the unused field is `null`,
+/// not missing) keep consumer code simple.
+#[derive(Serialize)]
+pub struct TargetInfo {
+    pub kind: String,
+    pub path: Option<String>,
+    pub command: Option<String>,
 }
 
 /// Per-level verification counts: how many requirements at this level had
@@ -198,7 +405,11 @@ fn group_order(group: &CheckGroup) -> u8 {
     }
 }
 
-pub fn format_text(results: &[CheckResult], quiet: bool) -> String {
+/// Format the scorecard as plain text. Pass `Some(badge)` to append the
+/// post-summary embed hint when the tool qualifies for the agent-native
+/// badge; below-floor runs see `text_hint()` return `None`, so nothing is
+/// appended (the "do not nag" rule from the badge convention).
+pub fn format_text(results: &[CheckResult], quiet: bool, badge: Option<&BadgeInfo>) -> String {
     let mut out = String::new();
 
     // Group results by CheckGroup
@@ -257,9 +468,27 @@ pub fn format_text(results: &[CheckResult], quiet: bool) -> String {
         s.total, s.pass, s.warn, s.fail, s.skip, s.error
     );
 
+    // Badge embed hint — appended only when eligible. Below the floor the
+    // `text_hint()` returns None and nothing is added (the convention's
+    // "do not nag" rule).
+    if let Some(hint) = badge.and_then(BadgeInfo::text_hint) {
+        out.push_str(&hint);
+    }
+
     out
 }
 
+/// Bundle of run-level metadata captured by the runner around `Commands::Check`
+/// and threaded into the scorecard. Grouped to keep `build_scorecard`'s
+/// signature manageable as schema `0.x` continues to add fields. The runner
+/// owns capture; this module owns serialization shape.
+pub struct RunMetadata {
+    pub tool: ToolInfo,
+    pub anc: AncInfo,
+    pub run: RunInfo,
+    pub target: TargetInfo,
+}
+
 /// Build the scorecard. The `ran_checks` slice is the catalog of checks
 /// that produced `results` — needed to translate check IDs back to the
 /// requirement IDs they cover for `coverage_summary`.
@@ -268,6 +497,7 @@ pub fn build_scorecard(
     ran_checks: &[Box<dyn Check>],
     audience: Option<String>,
     audit_profile: Option<String>,
+    metadata: RunMetadata,
 ) -> Scorecard {
     // `audience_reason` is derived from `results` rather than threaded
     // through as a caller parameter — the reason is a property of the
@@ -279,6 +509,17 @@ pub fn build_scorecard(
     } else {
         audience::classify_reason(results).map(|s| s.to_string())
     };
+    let RunMetadata {
+        tool,
+        anc,
+        run,
+        target,
+    } = metadata;
+    // Compute the badge from the same `tool.name` the JSON emits, so the
+    // embed URL in `badge.embed_markdown` and the slug in `tool.name` can
+    // never disagree (a regression that diverges them would mislead any
+    // author copy-pasting from the JSON).
+    let badge = compute_badge(results, &tool.name);
     Scorecard {
         schema_version: SCHEMA_VERSION,
         results: results.iter().map(CheckResultView::from_result).collect(),
@@ -288,6 +529,11 @@ pub fn build_scorecard(
         audience_reason,
         audit_profile,
         spec_version: SPEC_VERSION,
+        tool,
+        anc,
+        run,
+        target,
+        badge,
     }
 }
 
@@ -296,8 +542,9 @@ pub fn format_json(
     ran_checks: &[Box<dyn Check>],
     audience: Option<String>,
     audit_profile: Option<String>,
+    metadata: RunMetadata,
 ) -> String {
-    let scorecard = build_scorecard(results, ran_checks, audience, audit_profile);
+    let scorecard = build_scorecard(results, ran_checks, audience, audit_profile, metadata);
     serde_json::to_string_pretty(&scorecard).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
 }
 
@@ -403,15 +650,44 @@ mod tests {
         }
     }
 
+    /// Synthesize the metadata bundle existing tests need but don't care
+    /// about. Tests that exercise metadata behavior build their own.
+    fn fixture_metadata() -> RunMetadata {
+        RunMetadata {
+            tool: ToolInfo {
+                name: "fixture-tool".into(),
+                binary: None,
+                version: None,
+            },
+            anc: AncInfo {
+                version: "0.0.0-test",
+            },
+            run: RunInfo {
+                invocation: "anc check .".into(),
+                started_at: "1970-01-01T00:00:00Z".into(),
+                duration_ms: 0,
+                platform: PlatformInfo {
+                    os: "test-os",
+                    arch: "test-arch",
+                },
+            },
+            target: TargetInfo {
+                kind: "project".into(),
+                path: Some(".".into()),
+                command: None,
+            },
+        }
+    }
+
     #[test]
     fn test_format_json_valid() {
         let results = vec![
             make_result("c1", CheckStatus::Pass, CheckGroup::P1),
             make_result("c2", CheckStatus::Fail("bad".into()), CheckGroup::P2),
         ];
-        let json = format_json(&results, &[], None, None);
+        let json = format_json(&results, &[], None, None, fixture_metadata());
         let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
-        assert_eq!(parsed["schema_version"], "0.3");
+        assert_eq!(parsed["schema_version"], "0.5");
         assert_eq!(parsed["summary"]["total"], 2);
         assert_eq!(parsed["summary"]["pass"], 1);
         assert_eq!(parsed["summary"]["fail"], 1);
@@ -626,11 +902,11 @@ mod tests {
             .map(|id| make_result(id, CheckStatus::Pass, CheckGroup::P1))
             .collect();
         let audience = classify(&results);
-        let json = format_json(&results, &[], audience, None);
+        let json = format_json(&results, &[], audience, None, fixture_metadata());
         let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
         assert_eq!(parsed["audience"], "agent-optimized");
         assert!(parsed["audit_profile"].is_null());
-        assert_eq!(parsed["schema_version"], "0.3");
+        assert_eq!(parsed["schema_version"], "0.5");
     }
 
     #[test]
@@ -650,7 +926,7 @@ mod tests {
             })
             .collect();
         let audience = classify(&results);
-        let json = format_json(&results, &[], audience, None);
+        let json = format_json(&results, &[], audience, None, fixture_metadata());
         let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
         assert_eq!(parsed["audience"], "human-primary");
     }
@@ -666,7 +942,7 @@ mod tests {
             CheckGroup::P1,
         )];
         let audience = classify(&results);
-        let json = format_json(&results, &[], audience, None);
+        let json = format_json(&results, &[], audience, None, fixture_metadata());
         let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
         assert!(parsed["audience"].is_null());
     }
@@ -674,7 +950,13 @@ mod tests {
     #[test]
     fn format_json_echoes_audit_profile() {
         let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)];
-        let json = format_json(&results, &[], None, Some("human-tui".into()));
+        let json = format_json(
+            &results,
+            &[],
+            None,
+            Some("human-tui".into()),
+            fixture_metadata(),
+        );
         let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
         assert_eq!(parsed["audit_profile"], "human-tui");
     }
@@ -688,7 +970,7 @@ mod tests {
             CheckStatus::Pass,
             CheckGroup::P1,
         )];
-        let json = format_json(&results, &[], None, None);
+        let json = format_json(&results, &[], None, None, fixture_metadata());
         let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
         assert!(parsed["audience"].is_null());
         assert_eq!(parsed["audience_reason"], "insufficient_signal");
@@ -703,7 +985,7 @@ mod tests {
             .map(|id| make_result(id, CheckStatus::Pass, CheckGroup::P1))
             .collect();
         let audience = classify(&results);
-        let json = format_json(&results, &[], audience, None);
+        let json = format_json(&results, &[], audience, None, fixture_metadata());
         let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
         // audience has a label, so audience_reason must be omitted — not
         // merely null. `#[serde(skip_serializing_if = "Option::is_none")]`
@@ -735,7 +1017,13 @@ mod tests {
             })
             .collect();
         let audience = classify(&results);
-        let json = format_json(&results, &[], audience, Some("human-tui".into()));
+        let json = format_json(
+            &results,
+            &[],
+            audience,
+            Some("human-tui".into()),
+            fixture_metadata(),
+        );
         let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
         assert!(parsed["audience"].is_null());
         assert_eq!(parsed["audience_reason"], "suppressed");
@@ -802,7 +1090,13 @@ mod tests {
             .map(|id| make_result(id, CheckStatus::Pass, CheckGroup::P1))
             .collect();
         let audience = classify(&results);
-        let json = format_json(&results, &[], audience, Some("human-tui".into()));
+        let json = format_json(
+            &results,
+            &[],
+            audience,
+            Some("human-tui".into()),
+            fixture_metadata(),
+        );
 
         // audience: kebab-case.
         assert!(
@@ -830,4 +1124,353 @@ mod tests {
              would desync from the --audit-profile flag value shape",
         );
     }
+
+    #[test]
+    fn schema_v05_emits_every_documented_key() {
+        // Drift guard for schema 0.5. Builds a synthetic Scorecard, parses
+        // the JSON, and asserts every documented key path resolves —
+        // including keys that hold `null` values (those are part of the
+        // contract: consumer code should treat null and missing differently
+        // only for `audience_reason`, which uses `skip_serializing_if`).
+        //
+        // A field rename, deletion, or accidental top-level relocation is
+        // caught here loudly with a named-field assertion. New fields land
+        // alongside this test, not at the expense of it.
+        let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)];
+        let metadata = RunMetadata {
+            tool: ToolInfo {
+                name: "demo".into(),
+                binary: None,
+                version: None,
+            },
+            anc: AncInfo {
+                version: "0.0.1-test",
+            },
+            run: RunInfo {
+                invocation: "anc check .".into(),
+                started_at: "2026-04-29T16:00:00Z".into(),
+                duration_ms: 42,
+                platform: PlatformInfo {
+                    os: "linux",
+                    arch: "x86_64",
+                },
+            },
+            target: TargetInfo {
+                kind: "project".into(),
+                path: Some("/tmp/x".into()),
+                command: None,
+            },
+        };
+        let json = format_json(&results, &[], None, None, metadata);
+        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
+
+        // 0.1 / 0.2 / 0.3 keys remain — defends against accidental removal
+        // during schema work.
+        for path in [
+            "schema_version",
+            "results",
+            "summary",
+            "coverage_summary",
+            "audience",
+            "audit_profile",
+            "spec_version",
+        ] {
+            assert!(
+                parsed.get(path).is_some(),
+                "0.1-0.3 key `{path}` must remain present in 0.5",
+            );
+        }
+
+        // 0.4 + 0.5 additions — every documented sub-key resolves.
+        assert_eq!(parsed["schema_version"], "0.5");
+        for path in [
+            // 0.4
+            "tool.name",
+            "tool.binary",
+            "tool.version",
+            "anc.version",
+            "run.invocation",
+            "run.started_at",
+            "run.duration_ms",
+            "run.platform.os",
+            "run.platform.arch",
+            "target.kind",
+            "target.path",
+            "target.command",
+            // 0.5 — badge block
+            "badge.eligible",
+            "badge.score_pct",
+            "badge.embed_markdown",
+            "badge.scorecard_url",
+            "badge.badge_url",
+            "badge.convention_url",
+        ] {
+            let mut node = &parsed;
+            for segment in path.split('.') {
+                node = node
+                    .get(segment)
+                    .unwrap_or_else(|| panic!("0.5 key `{path}` missing — segment `{segment}`"));
+            }
+        }
+
+        // Emitted values match the synthetic input.
+        assert_eq!(parsed["tool"]["name"], "demo");
+        assert_eq!(parsed["anc"]["version"], "0.0.1-test");
+        assert_eq!(parsed["run"]["invocation"], "anc check .");
+        assert_eq!(parsed["run"]["duration_ms"], 42);
+        assert_eq!(parsed["run"]["platform"]["os"], "linux");
+        assert_eq!(parsed["target"]["kind"], "project");
+        assert_eq!(parsed["target"]["path"], "/tmp/x");
+
+        // Always-present-null contract: `tool.version`, `target.command`
+        // serialize as JSON null, not as missing keys. Consumer code should
+        // be able to access these paths unconditionally.
+        assert!(parsed["tool"]["version"].is_null());
+        assert!(parsed["tool"]["binary"].is_null());
+        assert!(parsed["target"]["command"].is_null());
+    }
+
+    #[test]
+    fn compute_badge_eligible_when_all_pass_and_slug_present() {
+        // Three Pass and zero failures → 100% → above the 80% floor.
+        let results = vec![
+            make_result("c1", CheckStatus::Pass, CheckGroup::P1),
+            make_result("c2", CheckStatus::Pass, CheckGroup::P2),
+            make_result("c3", CheckStatus::Pass, CheckGroup::P3),
+        ];
+        let badge = compute_badge(&results, "navi");
+        assert!(badge.eligible);
+        assert_eq!(badge.score_pct, 100);
+        assert_eq!(
+            badge.embed_markdown.as_deref(),
+            Some("[![agent-native](https://anc.dev/badge/navi.svg)](https://anc.dev/score/navi)"),
+        );
+        assert_eq!(
+            badge.scorecard_url.as_deref(),
+            Some("https://anc.dev/score/navi"),
+        );
+        assert_eq!(
+            badge.badge_url.as_deref(),
+            Some("https://anc.dev/badge/navi.svg"),
+        );
+        assert_eq!(badge.convention_url, "https://anc.dev/badge");
+    }
+
+    #[test]
+    fn compute_badge_below_floor_emits_urls_but_no_embed() {
+        // 4 of 5 fail → 1 pass / 5 denom = 20% → below floor.
+        let results = vec![
+            make_result("c1", CheckStatus::Pass, CheckGroup::P1),
+            make_result("c2", CheckStatus::Fail("a".into()), CheckGroup::P2),
+            make_result("c3", CheckStatus::Fail("b".into()), CheckGroup::P3),
+            make_result("c4", CheckStatus::Fail("c".into()), CheckGroup::P4),
+            make_result("c5", CheckStatus::Fail("d".into()), CheckGroup::P5),
+        ];
+        let badge = compute_badge(&results, "needs-work");
+        assert!(!badge.eligible);
+        assert_eq!(badge.score_pct, 20);
+        assert!(
+            badge.embed_markdown.is_none(),
+            "below the floor: embed_markdown must be None per the do-not-nag rule",
+        );
+        // The site renders an SVG for every scored tool regardless of
+        // score, so the URL is still useful below the floor.
+        assert!(badge.scorecard_url.is_some());
+        assert!(badge.badge_url.is_some());
+    }
+
+    #[test]
+    fn compute_badge_at_floor_is_eligible() {
+        // 4 pass / 5 denom = 80% — exactly at the floor must qualify.
+        let results = vec![
+            make_result("c1", CheckStatus::Pass, CheckGroup::P1),
+            make_result("c2", CheckStatus::Pass, CheckGroup::P2),
+            make_result("c3", CheckStatus::Pass, CheckGroup::P3),
+            make_result("c4", CheckStatus::Pass, CheckGroup::P4),
+            make_result("c5", CheckStatus::Fail("one fail".into()), CheckGroup::P5),
+        ];
+        let badge = compute_badge(&results, "edge-case");
+        assert!(badge.eligible, "score == floor must qualify");
+        assert_eq!(badge.score_pct, 80);
+        assert!(badge.embed_markdown.is_some());
+    }
+
+    #[test]
+    fn compute_badge_skips_excluded_from_denominator() {
+        // 1 Pass + 1 Skip + 1 Error → denom is 1 (only Pass), score 100%.
+        // Skips and Errors must not pull the score down — they're not
+        // verdicts, so the leaderboard formula excludes them.
+        let results = vec![
+            make_result("c1", CheckStatus::Pass, CheckGroup::P1),
+            make_result(
+                "c2",
+                CheckStatus::Skip("not applicable".into()),
+                CheckGroup::P2,
+            ),
+            make_result("c3", CheckStatus::Error("boom".into()), CheckGroup::P3),
+        ];
+        let badge = compute_badge(&results, "skipper");
+        assert_eq!(badge.score_pct, 100);
+        assert!(badge.eligible);
+    }
+
+    #[test]
+    fn compute_badge_no_scoring_data_is_ineligible() {
+        // Every result is a Skip → denominator is zero. Score 0% and not
+        // eligible — guard against division-by-zero turning into NaN or a
+        // misleading 100%.
+        let results = vec![
+            make_result("c1", CheckStatus::Skip("filtered".into()), CheckGroup::P1),
+            make_result("c2", CheckStatus::Skip("filtered".into()), CheckGroup::P2),
+        ];
+        let badge = compute_badge(&results, "ghost");
+        assert_eq!(badge.score_pct, 0);
+        assert!(!badge.eligible);
+        assert!(badge.embed_markdown.is_none());
+    }
+
+    #[test]
+    fn compute_badge_empty_slug_is_ineligible_even_at_perfect_score() {
+        // Without a tool slug the embed URL would be malformed
+        // (`/badge/.svg`); ineligible is the safe default — better to omit
+        // the hint than to emit a broken URL.
+        let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)];
+        let badge = compute_badge(&results, "");
+        assert_eq!(badge.score_pct, 100);
+        assert!(!badge.eligible);
+        assert!(badge.embed_markdown.is_none());
+        assert!(badge.scorecard_url.is_none());
+        assert!(badge.badge_url.is_none());
+        // Convention URL is always emitted — it's the same for every tool.
+        assert_eq!(badge.convention_url, "https://anc.dev/badge");
+    }
+
+    #[test]
+    fn badge_text_hint_present_when_eligible() {
+        let badge = compute_badge(
+            &[make_result("c1", CheckStatus::Pass, CheckGroup::P1)],
+            "demo",
+        );
+        let hint = badge.text_hint().expect("eligible run must produce hint");
+        assert!(
+            hint.contains("Score: 100%"),
+            "hint should announce the score, got: {hint}",
+        );
+        assert!(
+            hint.contains("https://anc.dev/badge/demo.svg"),
+            "hint should embed the canonical badge URL, got: {hint}",
+        );
+        assert!(
+            hint.contains("https://anc.dev/score/demo"),
+            "hint should link to the per-tool scorecard page, got: {hint}",
+        );
+        assert!(
+            hint.contains("https://anc.dev/badge"),
+            "hint should reference the convention page, got: {hint}",
+        );
+    }
+
+    #[test]
+    fn badge_text_hint_absent_when_below_floor() {
+        // The "do not nag" rule from the badge convention: below the floor
+        // we print nothing badge-related.
+        let badge = compute_badge(
+            &[
+                make_result("c1", CheckStatus::Fail("a".into()), CheckGroup::P1),
+                make_result("c2", CheckStatus::Fail("b".into()), CheckGroup::P2),
+            ],
+            "needs-work",
+        );
+        assert!(badge.text_hint().is_none());
+    }
+
+    #[test]
+    fn format_text_appends_hint_when_badge_eligible() {
+        let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)];
+        let badge = compute_badge(&results, "demo");
+        let text = format_text(&results, false, Some(&badge));
+        assert!(
+            text.contains("qualifies for the agent-native badge"),
+            "format_text must append the badge hint when eligible:\n{text}",
+        );
+        assert!(
+            text.contains("https://anc.dev/badge/demo.svg"),
+            "embedded URL must use the tool slug:\n{text}",
+        );
+    }
+
+    #[test]
+    fn format_text_omits_hint_when_below_floor() {
+        let results = vec![
+            make_result("c1", CheckStatus::Fail("a".into()), CheckGroup::P1),
+            make_result("c2", CheckStatus::Fail("b".into()), CheckGroup::P2),
+        ];
+        let badge = compute_badge(&results, "needs-work");
+        let text = format_text(&results, false, Some(&badge));
+        assert!(
+            !text.contains("agent-native badge"),
+            "below-floor runs must not nag:\n{text}",
+        );
+    }
+
+    #[test]
+    fn format_text_without_badge_arg_is_unchanged() {
+        // Callers that pass `None` (e.g., legacy plumbing or tests
+        // exercising the formatter alone) get the historical output with
+        // no badge tail.
+        let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)];
+        let text = format_text(&results, false, None);
+        assert!(!text.contains("agent-native badge"));
+    }
+
+    #[test]
+    fn scorecard_emits_badge_block() {
+        // End-to-end: a synthetic perfect run produces a JSON scorecard
+        // whose `badge` block reflects eligibility and the slug echoed in
+        // `tool.name`. Pins the contract that JSON consumers (notably the
+        // site's `/score/<tool>` renderer) can rely on without re-running
+        // `compute_badge` themselves.
+        let results = vec![make_result("c1", CheckStatus::Pass, CheckGroup::P1)];
+        let metadata = RunMetadata {
+            tool: ToolInfo {
+                name: "navi".into(),
+                binary: Some("navi".into()),
+                version: Some("0.1.0".into()),
+            },
+            anc: AncInfo {
+                version: "0.0.0-test",
+            },
+            run: RunInfo {
+                invocation: "anc check .".into(),
+                started_at: "1970-01-01T00:00:00Z".into(),
+                duration_ms: 0,
+                platform: PlatformInfo {
+                    os: "test-os",
+                    arch: "test-arch",
+                },
+            },
+            target: TargetInfo {
+                kind: "project".into(),
+                path: Some(".".into()),
+                command: None,
+            },
+        };
+        let json = format_json(&results, &[], None, None, metadata);
+        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
+        assert_eq!(parsed["badge"]["eligible"], true);
+        assert_eq!(parsed["badge"]["score_pct"], 100);
+        assert_eq!(
+            parsed["badge"]["embed_markdown"],
+            "[![agent-native](https://anc.dev/badge/navi.svg)](https://anc.dev/score/navi)"
+        );
+        assert_eq!(
+            parsed["badge"]["scorecard_url"],
+            "https://anc.dev/score/navi"
+        );
+        assert_eq!(
+            parsed["badge"]["badge_url"],
+            "https://anc.dev/badge/navi.svg"
+        );
+        assert_eq!(parsed["badge"]["convention_url"], "https://anc.dev/badge");
+    }
 }
diff --git a/src/skill_install.rs b/src/skill_install.rs
new file mode 100644
index 0000000..ea36f60
--- /dev/null
+++ b/src/skill_install.rs
@@ -0,0 +1,831 @@
+//! `anc skill install <host>` — clone the `agentnative-skill` bundle into a
+//! host's canonical skills directory using a hardcoded Rust host map and a
+//! hardened `git clone` invocation.
+//!
+//! Pipeline (ASCII; the Mermaid version is plan-doc only — source comments
+//! stay ASCII per repo convention):
+//!
+//! ```text
+//!   clap parse (host, --dry-run, --output)
+//!         |
+//!         v
+//!   resolve_host(SkillHost) -> (url, dest_template)
+//!         |
+//!         v
+//!   expand_tilde(dest_template) via $HOME  -- HOME unset --> MissingHome
+//!         |                                                  (reason=home-not-set)
+//!         v
+//!      dry_run? --yes--> emit_result(mode=dry-run, would_succeed) -> exit 0
+//!         |
+//!         no
+//!         v
+//!   check_destination()       -- conflict --> emit_result(error, reason) -> exit 1
+//!    (canonicalize + R9)
+//!         |
+//!         v
+//!   build_clone_command(url, dest) with hardening
+//!    (GIT_HARDEN_FLAGS,
+//!     env_remove GIT_HARDEN_ENV_REMOVE,
+//!     set GIT_TERMINAL_PROMPT=0)
+//!         |
+//!         v
+//!      spawn git -- not on PATH --> GitNotFound      (reason=git-not-found, exit 1)
+//!         |     -- nonzero ------> GitCloneFailed    (reason=git-clone-failed, exit 1)
+//!         v
+//!      exit 0  ------------------> emit_result(mode=install, exit_code=0) -> exit 0
+//! ```
+//!
+//! Hardening surface (R6c):
+//! - `GIT_HARDEN_FLAGS` — five `-c key=value` pairs (`credential.helper`,
+//!   `core.askPass`, `protocol.allow=never`, `protocol.https.allow=always`,
+//!   `http.followRedirects`).
+//! - `GIT_HARDEN_ENV_REMOVE` — five env vars stripped before spawn (SSH /
+//!   proxy / askpass / exec-path overrides).
+//! - `GIT_HARDEN_ENV_SET` — three env vars set on the spawned process:
+//!   `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`
+//!   together disable every layer of user-controlled git config (the
+//!   actual defense against `insteadOf` URL-rewriting attacks);
+//!   `GIT_TERMINAL_PROMPT=0` blocks credential prompts.
+//!
+//! Never `env_clear()` (strips PATH, breaks git's helper resolution). Never
+//! `sh -c` (tokens go directly to `git` via `Command::args`).
+
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use crate::cli::OutputFormat;
+use crate::error::AppError;
+
+// `SkillHost`, `KNOWN_HOSTS`, and `resolve_host` are auto-generated at
+// build time from `src/skill_install/skill.json`. To add or remove a host,
+// edit the JSON file (or run `bash scripts/sync-skill-fixture.sh` to pull
+// the upstream site contract) and `cargo build` regenerates this file.
+// See `build.rs::emit_skill_hosts` for the codegen logic.
+include!(concat!(env!("OUT_DIR"), "/generated_hosts.rs"));
+
+/// `git clone` config flags applied via `-c key=value` pairs, in token order
+/// suitable for `Command::args`. Five logical pairs (10 string tokens):
+///
+/// | Pair | Purpose |
+/// |------|---------|
+/// | `credential.helper=` | Suppress credential helpers — public clone, no creds |
+/// | `core.askPass=` | Suppress askpass programs |
+/// | `protocol.allow=never` | Default-deny every transport |
+/// | `protocol.https.allow=always` | Permit HTTPS only — paired with the deny above |
+/// | `http.followRedirects=false` | Pin destination — no transparent redirects |
+///
+/// Two corrections over the plan's original wording, both surfaced by the
+/// pre-merge manual smoke (R6c — verified against the actual `git` binary):
+///
+/// 1. `protocol.allow=https-only` is **not** valid git syntax (`fatal:
+///    unknown value`). The HTTPS-only intent is expressed as a default-deny
+///    plus per-protocol allow, which is the documented git-config form.
+/// 2. `url.<repo>.insteadOf=` (empty value) does the **opposite** of
+///    blocking — it rewrites every empty-prefix URL (i.e. all URLs) to
+///    start with `<repo>`, doubling the clone URL. The defense against
+///    `insteadOf` attacks is to disable global/system config entirely via
+///    [`GIT_HARDEN_ENV_SET`] (`GIT_CONFIG_GLOBAL=/dev/null`,
+///    `GIT_CONFIG_SYSTEM=/dev/null`), not via `-c`. The flag was dropped.
+pub const GIT_HARDEN_FLAGS: &[&str] = &[
+    "-c",
+    "credential.helper=",
+    "-c",
+    "core.askPass=",
+    "-c",
+    "protocol.allow=never",
+    "-c",
+    "protocol.https.allow=always",
+    "-c",
+    "http.followRedirects=false",
+];
+
+/// Environment variables removed via `Command::env_remove` before spawn.
+/// Each one is a known git-side override that could redirect or hijack the
+/// clone. `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` are **not** in this
+/// list — removing them would let git fall back to default config paths
+/// (`~/.gitconfig`, `/etc/gitconfig`); we instead point them at `/dev/null`
+/// via [`GIT_HARDEN_ENV_SET`] to actively disable user-controlled config.
+/// Never `env_clear()` — that strips PATH and breaks git's helper
+/// resolution.
+pub const GIT_HARDEN_ENV_REMOVE: &[&str] = &[
+    "GIT_SSH",
+    "GIT_SSH_COMMAND",
+    "GIT_PROXY_COMMAND",
+    "GIT_ASKPASS",
+    "GIT_EXEC_PATH",
+];
+
+/// Environment variables *set* (not removed) on the spawned process. The
+/// `GIT_CONFIG_*` pair points global / system config at `/dev/null` so
+/// user-config `insteadOf` rewriting and other ambient overrides cannot
+/// fire — this is the actual defense against URL-rewriting attacks.
+/// `GIT_TERMINAL_PROMPT=0` blocks credential prompts; git's default-when-
+/// unset is to prompt, which is the wrong default for a non-interactive
+/// subcommand.
+pub const GIT_HARDEN_ENV_SET: &[(&str, &str)] = &[
+    ("GIT_CONFIG_GLOBAL", "/dev/null"),
+    ("GIT_CONFIG_SYSTEM", "/dev/null"),
+    ("GIT_TERMINAL_PROMPT", "0"),
+];
+
+/// Snapshot of what `check_destination` found at the resolved path. Drives
+/// the JSON envelope's `destination_status` field. The success path returns
+/// only `Absent`/`EmptyDir`; conflict cases (`NonEmptyDir`, `File`) are
+/// inferred from the corresponding `AppError` variant in the caller.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum DestinationStatus {
+    Absent,
+    EmptyDir,
+    NonEmptyDir,
+    File,
+}
+
+impl DestinationStatus {
+    /// Kebab-case identifier for the JSON envelope `destination_status` field.
+    pub fn as_envelope_str(self) -> &'static str {
+        match self {
+            DestinationStatus::Absent => "absent",
+            DestinationStatus::EmptyDir => "empty-dir",
+            DestinationStatus::NonEmptyDir => "non-empty-dir",
+            DestinationStatus::File => "file",
+        }
+    }
+}
+
+/// Expand a leading `~` or `~/` to `$HOME`. Pure passthrough on inputs that
+/// do not start with `~` (R6a). `MissingHome` only fires when the input
+/// actually begins with `~` and `$HOME` is unset or empty — non-`~` inputs
+/// never read the environment.
+pub fn expand_tilde(template: &str) -> Result<PathBuf, AppError> {
+    let home = std::env::var("HOME").ok();
+    expand_tilde_with(template, home.as_deref())
+}
+
+/// Pure-function core of [`expand_tilde`]. Tests pass `home` explicitly so
+/// they never mutate the process environment (which would race with parallel
+/// tests). The public wrapper performs the env lookup.
+pub fn expand_tilde_with(template: &str, home: Option<&str>) -> Result<PathBuf, AppError> {
+    let needs_home = template == "~" || template.starts_with("~/");
+    if !needs_home {
+        return Ok(PathBuf::from(template));
+    }
+    let home = home
+        .filter(|s| !s.is_empty())
+        .ok_or(AppError::MissingHome)?;
+    if template == "~" {
+        return Ok(PathBuf::from(home));
+    }
+    let rest = template
+        .strip_prefix("~/")
+        .expect("template starts with ~/ per the branch guard");
+    let mut p = PathBuf::from(home);
+    p.push(rest);
+    Ok(p)
+}
+
+/// R9 destination conflict check. Canonicalizes the path so a symlinked
+/// skills directory resolves to its real target before the check runs (F4).
+/// Returns `Absent`/`EmptyDir` on success; `DestIsFile` for a regular file,
+/// `DestNotEmpty` for a populated directory, `DestReadFailed` for any I/O
+/// error along the way.
+///
+/// TOCTOU between this check and the subsequent `git clone` exec is
+/// acknowledged residual single-user-machine risk — `git clone` itself
+/// errors on a non-empty target, so the worst case is a less-actionable
+/// error message, not a security failure.
+pub fn check_destination(path: &Path) -> Result<DestinationStatus, AppError> {
+    match path.try_exists() {
+        Ok(false) => return Ok(DestinationStatus::Absent),
+        Ok(true) => {}
+        Err(e) => {
+            return Err(AppError::DestReadFailed {
+                path: path.to_path_buf(),
+                source: e,
+            });
+        }
+    }
+
+    let canonical = fs::canonicalize(path).map_err(|e| AppError::DestReadFailed {
+        path: path.to_path_buf(),
+        source: e,
+    })?;
+
+    let metadata = fs::metadata(&canonical).map_err(|e| AppError::DestReadFailed {
+        path: canonical.clone(),
+        source: e,
+    })?;
+
+    if metadata.is_file() {
+        return Err(AppError::DestIsFile { path: canonical });
+    }
+
+    if metadata.is_dir() {
+        let mut entries = fs::read_dir(&canonical).map_err(|e| AppError::DestReadFailed {
+            path: canonical.clone(),
+            source: e,
+        })?;
+        if entries.next().is_some() {
+            return Err(AppError::DestNotEmpty { path: canonical });
+        }
+        return Ok(DestinationStatus::EmptyDir);
+    }
+
+    // Block / char devices, sockets, fifos — not a normal place to clone into.
+    // Treat as a file conflict; the typed reason maps to `destination-is-file`
+    // which best describes "this is not the directory we expected".
+    Err(AppError::DestIsFile { path: canonical })
+}
+
+/// Build the hardened `git clone` command. Pure constructor — no spawn, no
+/// I/O. The returned `Command` carries the full hardening surface:
+///
+/// 1. [`GIT_HARDEN_FLAGS`] applied as `-c key=value` pairs *before* the
+///    `clone` subcommand (git's required position for top-level `-c`).
+/// 2. [`GIT_HARDEN_ENV_REMOVE`] entries removed via `env_remove` —
+///    user/attacker-controlled overrides we want to ignore.
+/// 3. [`GIT_HARDEN_ENV_SET`] entries set via `env` — most importantly
+///    `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`,
+///    which together disable every layer of user-controlled git config
+///    (the actual defense against `insteadOf` URL-rewriting attacks).
+///
+/// `--depth 1` is included to match the canonical install command shipped
+/// in `skill.json`. Verified during planning that `agentnative-skill`'s
+/// `bin/check-update` curls the upstream `VERSION` file and does NOT
+/// require local tag history, so shallow cloning is safe.
+pub fn build_clone_command(url: &str, dest: &Path) -> Command {
+    let mut cmd = Command::new("git");
+    cmd.args(GIT_HARDEN_FLAGS);
+    cmd.args(["clone", "--depth", "1"]);
+    cmd.arg(url);
+    cmd.arg(dest);
+    for var in GIT_HARDEN_ENV_REMOVE {
+        cmd.env_remove(var);
+    }
+    for (key, value) in GIT_HARDEN_ENV_SET {
+        cmd.env(key, value);
+    }
+    cmd
+}
+
+/// User-visible representation of the clone command for the JSON envelope's
+/// `command` field and the `--dry-run --output text` single-line output.
+/// Intentionally omits the hardening flags — those are an implementation
+/// detail. The displayed form matches `skill.json`'s `install.<host>`
+/// verbatim, so users can copy-paste-modify as a manual fallback.
+pub fn format_clone_command(url: &str, dest: &Path) -> String {
+    format!("git clone --depth 1 {url} {}", dest.display())
+}
+
+/// Result envelope shared by both `--output text` and `--output json`.
+/// Schema is uniform across success and error paths (R-OUT, C1).
+///
+/// Field-presence rules:
+/// - `would_succeed` — present in dry-run mode only.
+/// - `exit_code` — present on the live install path only (and only when we
+///   actually spawned `git`; e.g., `git-not-found` leaves it absent).
+/// - `reason` — present on error only, with the typed values enumerated in
+///   the plan's R-OUT.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct InstallEnvelope {
+    pub action: &'static str,
+    pub host: &'static str,
+    pub mode: &'static str,
+    pub command: String,
+    pub destination: String,
+    pub destination_status: &'static str,
+    pub status: &'static str,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub would_succeed: Option<bool>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub exit_code: Option<i32>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub reason: Option<&'static str>,
+}
+
+const ACTION: &str = "skill-install";
+const MODE_DRY_RUN: &str = "dry-run";
+const MODE_INSTALL: &str = "install";
+const STATUS_SUCCESS: &str = "success";
+const STATUS_ERROR: &str = "error";
+
+const REASON_DEST_NOT_EMPTY: &str = "destination-not-empty";
+const REASON_DEST_IS_FILE: &str = "destination-is-file";
+const REASON_HOME_NOT_SET: &str = "home-not-set";
+const REASON_GIT_NOT_FOUND: &str = "git-not-found";
+const REASON_GIT_CLONE_FAILED: &str = "git-clone-failed";
+
+/// Compute the envelope without performing I/O (dry-run) or, in install
+/// mode, after spawning `git`. Internal I/O failures that don't fit the
+/// typed reason taxonomy (e.g., `DestReadFailed` from a permission-denied
+/// `read_dir`) propagate as `AppError` — the top-level handler renders
+/// them on stderr and exits 2 (internal-error reserved per P4).
+pub fn compute_install_envelope(
+    host: SkillHost,
+    dry_run: bool,
+) -> Result<InstallEnvelope, AppError> {
+    let (url, dest_template) = resolve_host(host);
+    let host_str = host_envelope_str(host);
+    let mode_str = if dry_run { MODE_DRY_RUN } else { MODE_INSTALL };
+
+    // Step 1: tilde expand. MissingHome is an envelope error, not propagated.
+    let dest = match expand_tilde(dest_template) {
+        Ok(p) => p,
+        Err(AppError::MissingHome) => {
+            // Without $HOME we cannot show the resolved destination. Surface
+            // the template (with its literal `~`) so the consumer sees what
+            // would have been expanded; destination_status is `absent`
+            // because we never reached the filesystem.
+            let command = format!("git clone --depth 1 {url} {dest_template}");
+            return Ok(InstallEnvelope {
+                action: ACTION,
+                host: host_str,
+                mode: mode_str,
+                command,
+                destination: dest_template.to_string(),
+                destination_status: DestinationStatus::Absent.as_envelope_str(),
+                status: STATUS_ERROR,
+                would_succeed: if dry_run { Some(false) } else { None },
+                exit_code: None,
+                reason: Some(REASON_HOME_NOT_SET),
+            });
+        }
+        Err(e) => return Err(e),
+    };
+
+    let command = format_clone_command(url, &dest);
+    let dest_str = dest.display().to_string();
+
+    // Step 2: destination check. Conflict variants surface as envelope
+    // errors; DestReadFailed propagates (internal I/O failure).
+    let dest_status = match check_destination(&dest) {
+        Ok(s) => s,
+        Err(AppError::DestIsFile { .. }) => {
+            return Ok(InstallEnvelope {
+                action: ACTION,
+                host: host_str,
+                mode: mode_str,
+                command,
+                destination: dest_str,
+                destination_status: DestinationStatus::File.as_envelope_str(),
+                status: STATUS_ERROR,
+                would_succeed: if dry_run { Some(false) } else { None },
+                exit_code: None,
+                reason: Some(REASON_DEST_IS_FILE),
+            });
+        }
+        Err(AppError::DestNotEmpty { .. }) => {
+            return Ok(InstallEnvelope {
+                action: ACTION,
+                host: host_str,
+                mode: mode_str,
+                command,
+                destination: dest_str,
+                destination_status: DestinationStatus::NonEmptyDir.as_envelope_str(),
+                status: STATUS_ERROR,
+                would_succeed: if dry_run { Some(false) } else { None },
+                exit_code: None,
+                reason: Some(REASON_DEST_NOT_EMPTY),
+            });
+        }
+        Err(e) => return Err(e),
+    };
+
+    let dest_status_str = dest_status.as_envelope_str();
+
+    if dry_run {
+        return Ok(InstallEnvelope {
+            action: ACTION,
+            host: host_str,
+            mode: MODE_DRY_RUN,
+            command,
+            destination: dest_str,
+            destination_status: dest_status_str,
+            status: STATUS_SUCCESS,
+            would_succeed: Some(true),
+            exit_code: None,
+            reason: None,
+        });
+    }
+
+    // Step 3: spawn `git`. The spawn helper produces typed `AppError`
+    // variants (`GitNotFound`, `GitCloneFailed`) so the envelope-mapping
+    // pattern stays uniform across error sources. Other I/O errors
+    // propagate.
+    let mut cmd = build_clone_command(url, &dest);
+    match spawn_git_clone(&mut cmd) {
+        Ok(()) => Ok(InstallEnvelope {
+            action: ACTION,
+            host: host_str,
+            mode: MODE_INSTALL,
+            command,
+            destination: dest_str,
+            destination_status: dest_status_str,
+            status: STATUS_SUCCESS,
+            would_succeed: None,
+            exit_code: Some(0),
+            reason: None,
+        }),
+        Err(AppError::GitCloneFailed { code }) => Ok(InstallEnvelope {
+            action: ACTION,
+            host: host_str,
+            mode: MODE_INSTALL,
+            command,
+            destination: dest_str,
+            destination_status: dest_status_str,
+            status: STATUS_ERROR,
+            would_succeed: None,
+            exit_code: Some(code),
+            reason: Some(REASON_GIT_CLONE_FAILED),
+        }),
+        Err(AppError::GitNotFound) => Ok(InstallEnvelope {
+            action: ACTION,
+            host: host_str,
+            mode: MODE_INSTALL,
+            command,
+            destination: dest_str,
+            destination_status: dest_status_str,
+            status: STATUS_ERROR,
+            would_succeed: None,
+            exit_code: None,
+            reason: Some(REASON_GIT_NOT_FOUND),
+        }),
+        Err(e) => Err(e),
+    }
+}
+
+/// Spawn the prepared `git clone` command and reduce the result to typed
+/// `AppError` variants matching the plan's reason taxonomy. Other I/O
+/// failures (e.g., permission denied invoking the resolved binary) wrap
+/// into `AppError::Io` and propagate.
+fn spawn_git_clone(cmd: &mut Command) -> Result<(), AppError> {
+    match cmd.status() {
+        Ok(status) if status.success() => Ok(()),
+        Ok(status) => Err(AppError::GitCloneFailed {
+            code: status.code().unwrap_or(-1),
+        }),
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(AppError::GitNotFound),
+        Err(e) => Err(AppError::Io(e)),
+    }
+}
+
+/// Render the envelope as a single-line text command (`git clone …`) on
+/// success, or as a human error line on failure. Single-line dry-run output
+/// is the contract that `eval $(anc skill install --dry-run <host>)`
+/// depends on.
+pub fn emit_result_text(env: &InstallEnvelope) -> String {
+    if env.status == STATUS_SUCCESS {
+        match env.mode {
+            // Dry-run success: just the command, captures via `eval $(...)`.
+            "dry-run" => env.command.clone(),
+            // Live install success: short confirmation line on stdout.
+            _ => format!("Installed agent-native-cli into {}", env.destination),
+        }
+    } else {
+        let reason = env.reason.unwrap_or("unknown");
+        format!("error: {reason}: {}", env.destination)
+    }
+}
+
+/// Render the envelope as pretty-printed JSON. Pretty-print matches the
+/// existing `anc check --output json` style and keeps grep / `jaq` queries
+/// readable. `serde_json::to_string_pretty` is infallible for this struct
+/// (no map keys, no non-string keys, no skipped serializer), so we
+/// `expect()` rather than propagate.
+pub fn emit_result_json(env: &InstallEnvelope) -> String {
+    serde_json::to_string_pretty(env)
+        .expect("InstallEnvelope serialization is infallible by construction")
+}
+
+/// Orchestrate the install pipeline. Always emits an envelope (text or
+/// json) on stdout. Exit code is `0` for success, `1` for envelope errors
+/// (typed reason set), and `AppError` for internal I/O failures handled by
+/// `main` (exit 2 — internal-error reserved per P4).
+pub fn run_install(host: SkillHost, dry_run: bool, output: OutputFormat) -> Result<i32, AppError> {
+    let envelope = compute_install_envelope(host, dry_run)?;
+    let rendered = match output {
+        OutputFormat::Text => emit_result_text(&envelope),
+        OutputFormat::Json => emit_result_json(&envelope),
+    };
+    crate::output::emit_line(&rendered);
+    Ok(if envelope.status == STATUS_SUCCESS {
+        0
+    } else {
+        1
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use clap::ValueEnum;
+
+    /// Helper used by hardening-surface tests: any host's URL works because
+    /// every install command in the v1 fixture shares one upstream. Reads
+    /// straight from `resolve_host` so the value tracks the build-time
+    /// codegen — no parallel hardcoded constant to keep in sync.
+    fn skill_repo_url() -> &'static str {
+        resolve_host(SkillHost::ClaudeCode).0
+    }
+
+    /// Test 1 — `resolve_host` returns the expected `(url, dest_template)`
+    /// for every variant. Drives off `KNOWN_HOSTS` so adding a host to
+    /// `skill.json` automatically extends coverage. Since both
+    /// `KNOWN_HOSTS` and `resolve_host` are generated from the same JSON,
+    /// this test catches build.rs codegen regressions (e.g. wrong URL,
+    /// off-by-one tokenisation) — without it, a buggy emitter could
+    /// produce arbitrary garbage and the rest of the suite would still
+    /// pass.
+    #[test]
+    fn resolve_host_returns_expected_pair_for_every_variant() {
+        let fixture_text = include_str!("skill_install/skill.json");
+        let fixture: serde_json::Value =
+            serde_json::from_str(fixture_text).expect("fixture is valid JSON");
+        let install = fixture
+            .get("install")
+            .and_then(|v| v.as_object())
+            .expect("fixture has install map");
+
+        for &host_name in KNOWN_HOSTS {
+            let cmd = install
+                .get(host_name)
+                .and_then(|v| v.as_str())
+                .unwrap_or_else(|| panic!("fixture missing install.{host_name}"));
+            let tokens: Vec<&str> = cmd.split_whitespace().collect();
+            let expected_url = tokens[4];
+            let expected_dest = tokens[5];
+
+            let host = SkillHost::from_str(host_name, false)
+                .unwrap_or_else(|_| panic!("KNOWN_HOSTS entry {host_name:?} unparseable"));
+            let (url, dest) = resolve_host(host);
+
+            assert_eq!(url, expected_url, "url mismatch for {host_name}");
+            assert_eq!(dest, expected_dest, "dest mismatch for {host_name}");
+        }
+    }
+
+    /// Test 11 — `KNOWN_HOSTS` matches `SkillHost` variant count and names
+    /// exactly. Catches the regression where someone adds a variant but
+    /// forgets the const, or vice versa, before the next release ships.
+    #[test]
+    fn known_hosts_matches_skill_host_variant_count_and_names() {
+        let variant_names: Vec<String> = SkillHost::value_variants()
+            .iter()
+            .map(|v| {
+                v.to_possible_value()
+                    .expect("clap ValueEnum variant always has a possible value")
+                    .get_name()
+                    .to_string()
+            })
+            .collect();
+        let known: Vec<String> = KNOWN_HOSTS.iter().map(|s| (*s).to_string()).collect();
+        assert_eq!(
+            variant_names, known,
+            "SkillHost variants and KNOWN_HOSTS must stay in lockstep",
+        );
+    }
+
+    /// Defense-in-depth — the GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM
+    /// pointers must be set to a path that disables config loading
+    /// entirely (`/dev/null`). This is the actual defense against
+    /// user-config `insteadOf` URL-rewriting attacks; an earlier draft
+    /// used a `url.<repo>.insteadOf=` `-c` flag that did the *opposite*
+    /// of blocking (it rewrote every URL to start with our repo, doubling
+    /// the clone URL). Pin the corrected shape so we don't regress.
+    #[test]
+    fn git_harden_env_set_disables_user_config() {
+        let pairs: std::collections::HashMap<&str, &str> =
+            GIT_HARDEN_ENV_SET.iter().copied().collect();
+        for var in ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"] {
+            let v = pairs.get(var).unwrap_or_else(|| {
+                panic!("GIT_HARDEN_ENV_SET missing {var}; got {GIT_HARDEN_ENV_SET:?}")
+            });
+            assert_eq!(
+                *v, "/dev/null",
+                "{var} must be set to /dev/null to disable user config; got {v:?}",
+            );
+        }
+    }
+
+    /// Sanity: no panics when constructing each variant via `ValueEnum`.
+    /// Catches the regression where `rename_all = "snake_case"` is dropped
+    /// and the surface names drift away from skill.json keys.
+    #[test]
+    fn skill_host_clap_value_names_match_known_hosts() {
+        for &expected in KNOWN_HOSTS {
+            let parsed = SkillHost::from_str(expected, false)
+                .unwrap_or_else(|_| panic!("KNOWN_HOSTS entry {expected:?} not parseable"));
+            let rendered = parsed
+                .to_possible_value()
+                .expect("clap ValueEnum variant always has a possible value")
+                .get_name()
+                .to_string();
+            assert_eq!(rendered, expected);
+        }
+    }
+
+    // Test 12 — `host_map_matches_site_skill_json` — was the cargo-level
+    // drift anchor between the hand-maintained Rust map and the vendored
+    // fixture. It is provably redundant after the build.rs codegen
+    // refactor: both `SkillHost` / `KNOWN_HOSTS` / `resolve_host` AND
+    // `tests/fixtures/skill.json` are now single-sourced from
+    // `src/skill_install/skill.json`. Cargo's `rerun-if-changed` directive
+    // ensures the codegen regenerates whenever the fixture changes, so
+    // they cannot drift relative to each other within a single build.
+    // Drift between the fixture and the upstream site contract is still
+    // caught by `scripts/sync-skill-fixture.sh --check` (CI workflow
+    // `skill-fixture-drift.yml`, test 26).
+
+    /// Test 2 — `expand_tilde("~/.claude/skills/agent-native-cli")` with
+    /// `HOME=/home/test` resolves to the canonical absolute path. Uses the
+    /// pure helper to avoid mutating process env (which would race with
+    /// parallel tests).
+    #[test]
+    fn expand_tilde_replaces_leading_tilde_slash_with_home() {
+        let got = expand_tilde_with("~/.claude/skills/agent-native-cli", Some("/home/test"))
+            .expect("HOME present + ~/ prefix should expand cleanly");
+        assert_eq!(
+            got,
+            PathBuf::from("/home/test/.claude/skills/agent-native-cli")
+        );
+    }
+
+    /// Test 3 — `expand_tilde` with `HOME` unset returns `MissingHome`,
+    /// but only when the input begins with `~`.
+    #[test]
+    fn expand_tilde_missing_home_only_when_input_starts_with_tilde() {
+        let err = expand_tilde_with("~/anything", None)
+            .expect_err("HOME unset + tilde input should be MissingHome");
+        assert!(matches!(err, AppError::MissingHome));
+
+        let err_empty =
+            expand_tilde_with("~", Some("")).expect_err("HOME empty string is treated as unset");
+        assert!(matches!(err_empty, AppError::MissingHome));
+    }
+
+    /// Test 4 — Passthrough contract: paths that don't start with `~` pass
+    /// through unchanged regardless of `$HOME`. The hardcoded map only ever
+    /// feeds `~`-prefixed templates, so this branch is unreachable in
+    /// practice but keeps the contract simple and total (D1 passthrough).
+    #[test]
+    fn expand_tilde_no_tilde_passthrough() {
+        let got_with_home = expand_tilde_with("/abs/path", Some("/home/test"))
+            .expect("non-tilde input never errors");
+        assert_eq!(got_with_home, PathBuf::from("/abs/path"));
+
+        let got_without_home =
+            expand_tilde_with("/abs/path", None).expect("non-tilde input ignores HOME");
+        assert_eq!(got_without_home, PathBuf::from("/abs/path"));
+    }
+
+    /// Test 5 — `check_destination` on a nonexistent path returns
+    /// `Absent`. A fresh tempdir's child is a deterministic nonexistent
+    /// path.
+    #[test]
+    fn check_destination_absent_for_nonexistent_path() {
+        let tmp = tempfile::tempdir().expect("tempdir creation");
+        let target = tmp.path().join("does-not-exist");
+        let status = check_destination(&target).expect("absent path should be Ok(Absent)");
+        assert_eq!(status, DestinationStatus::Absent);
+    }
+
+    /// Test 6 — `check_destination` on an empty directory returns
+    /// `EmptyDir`.
+    #[test]
+    fn check_destination_empty_dir() {
+        let tmp = tempfile::tempdir().expect("tempdir creation");
+        let status = check_destination(tmp.path()).expect("empty tempdir should be Ok(EmptyDir)");
+        assert_eq!(status, DestinationStatus::EmptyDir);
+    }
+
+    /// Test 7 — `check_destination` on a non-empty directory returns
+    /// `DestNotEmpty`.
+    #[test]
+    fn check_destination_non_empty_dir_errors() {
+        let tmp = tempfile::tempdir().expect("tempdir creation");
+        std::fs::write(tmp.path().join("placeholder"), b"x").expect("write placeholder");
+        let err = check_destination(tmp.path()).expect_err("populated dir should be DestNotEmpty");
+        assert!(matches!(err, AppError::DestNotEmpty { .. }));
+    }
+
+    /// Test 8 — `check_destination` on a regular file returns `DestIsFile`.
+    #[test]
+    fn check_destination_regular_file_errors() {
+        let tmp = tempfile::tempdir().expect("tempdir creation");
+        let target = tmp.path().join("a-file");
+        std::fs::write(&target, b"contents").expect("write file");
+        let err = check_destination(&target).expect_err("file should be DestIsFile");
+        assert!(matches!(err, AppError::DestIsFile { .. }));
+    }
+
+    /// Test 9 — Symlink follow via `fs::canonicalize`: a symlink pointing at
+    /// a non-empty directory returns `DestNotEmpty`, not the symlink's own
+    /// status. Defends F4 — a symlinked skills dir resolves to the target
+    /// before the conflict check runs.
+    #[cfg(unix)]
+    #[test]
+    fn check_destination_follows_symlink_to_non_empty_dir() {
+        let tmp = tempfile::tempdir().expect("tempdir creation");
+        let real_dir = tmp.path().join("real");
+        std::fs::create_dir(&real_dir).expect("mkdir real");
+        std::fs::write(real_dir.join("placeholder"), b"x").expect("populate real");
+
+        let link = tmp.path().join("link");
+        std::os::unix::fs::symlink(&real_dir, &link).expect("symlink real -> link");
+
+        let err = check_destination(&link).expect_err("symlinked non-empty dir is DestNotEmpty");
+        assert!(matches!(err, AppError::DestNotEmpty { .. }));
+    }
+
+    /// Test 10 — `build_clone_command` introspection. Spawns nothing; reads
+    /// the constructed `Command` via `get_args` / `get_envs`. Pins three
+    /// invariants:
+    ///
+    /// 1. Every flag in `GIT_HARDEN_FLAGS` appears in the args list.
+    /// 2. Every var in `GIT_HARDEN_ENV_REMOVE` is in the removal set
+    ///    (`Some(None)` — set with no value means `env_remove`).
+    /// 3. `GIT_TERMINAL_PROMPT=0` is in the env-set list.
+    ///
+    /// Also pins the conventional `clone --depth 1 <url> <dest>` shape.
+    #[test]
+    fn build_clone_command_applies_hardening_surface() {
+        let url = skill_repo_url();
+        let dest = Path::new("/tmp/anc-skill-introspect");
+        let cmd = build_clone_command(url, dest);
+
+        let args: Vec<String> = cmd
+            .get_args()
+            .map(|s| s.to_string_lossy().into_owned())
+            .collect();
+
+        for &flag in GIT_HARDEN_FLAGS {
+            assert!(
+                args.iter().any(|a| a == flag),
+                "GIT_HARDEN_FLAGS entry {flag:?} missing from command args; got {args:?}",
+            );
+        }
+        assert!(
+            args.iter().any(|a| a == "clone"),
+            "missing 'clone' subcommand: {args:?}"
+        );
+        assert!(
+            args.iter().any(|a| a == "--depth"),
+            "missing --depth flag: {args:?}"
+        );
+        assert!(
+            args.iter().any(|a| a == "1"),
+            "missing --depth value: {args:?}"
+        );
+        assert!(
+            args.iter().any(|a| a == url),
+            "missing url operand: {args:?}",
+        );
+        assert!(
+            args.iter().any(|a| a == "/tmp/anc-skill-introspect"),
+            "missing dest operand: {args:?}",
+        );
+
+        let envs: std::collections::HashMap<String, Option<String>> = cmd
+            .get_envs()
+            .map(|(k, v)| {
+                (
+                    k.to_string_lossy().into_owned(),
+                    v.map(|s| s.to_string_lossy().into_owned()),
+                )
+            })
+            .collect();
+
+        for &var in GIT_HARDEN_ENV_REMOVE {
+            let entry = envs.get(var);
+            assert!(
+                matches!(entry, Some(None)),
+                "GIT_HARDEN_ENV_REMOVE entry {var:?} should be removed; got {entry:?}",
+            );
+        }
+
+        for &(key, value) in GIT_HARDEN_ENV_SET {
+            let entry = envs.get(key);
+            assert_eq!(
+                entry,
+                Some(&Some(value.to_string())),
+                "GIT_HARDEN_ENV_SET entry {key}={value:?} not present in env-set list; got {entry:?}",
+            );
+        }
+    }
+
+    /// Sanity: `format_clone_command` produces the canonical user-visible
+    /// form, matching the `install.<host>` strings in `skill.json` once the
+    /// destination template is expanded. Hardening flags are intentionally
+    /// absent — implementation detail.
+    #[test]
+    fn format_clone_command_matches_canonical_shape() {
+        let s = format_clone_command(
+            skill_repo_url(),
+            Path::new("/home/u/.claude/skills/agent-native-cli"),
+        );
+        assert_eq!(
+            s,
+            "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git /home/u/.claude/skills/agent-native-cli",
+        );
+    }
+}
diff --git a/src/skill_install/skill.json b/src/skill_install/skill.json
new file mode 100644
index 0000000..1db85ea
--- /dev/null
+++ b/src/skill_install/skill.json
@@ -0,0 +1,24 @@
+{
+  "schema_version": 1,
+  "type": "agent-skill",
+  "name": "agent-native-cli",
+  "version": "0.1.0",
+  "description": "Build CLI tools that AI agents can operate reliably.",
+  "principles_url": "https://anc.dev/p1",
+  "license": "MIT",
+  "source": {
+    "type": "git",
+    "url": "https://github.com/brettdavies/agentnative-skill.git"
+  },
+  "install": {
+    "claude_code": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.claude/skills/agent-native-cli",
+    "codex": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.codex/skills/agent-native-cli",
+    "cursor": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.cursor/skills/agent-native-cli",
+    "factory": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.factory/skills/agent-native-cli",
+    "kiro": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.kiro/skills/agent-native-cli",
+    "opencode": "git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git ~/.config/opencode/skills/agent-native-cli"
+  },
+  "update": "cd <install-dir> && git pull --ff-only",
+  "uninstall": "rm -rf <install-dir>",
+  "skill_page_html": "https://anc.dev/skill"
+}
diff --git a/tests/dogfood.rs b/tests/dogfood.rs
new file mode 100644
index 0000000..92f1e08
--- /dev/null
+++ b/tests/dogfood.rs
@@ -0,0 +1,75 @@
+//! Dogfood guards for the new `anc skill install` verb. Tests 24 and 25
+//! from the plan, both CRITICAL: without them, the dogfood claim that
+//! drove the binary-verb-vs-bash-one-liner decision (Problem Frame § "Why
+//! a binary verb, not a bash one-liner?") breaks silently.
+//!
+//! Each test spawns the real binary in project mode against this repo
+//! (CARGO_MANIFEST_DIR), parses the JSON envelope, and asserts no FAIL
+//! status on any `p2-*` (test 25) or `p5-*` (test 24) check. Warnings
+//! are tolerated; only `fail` breaks the guard.
+
+use assert_cmd::Command;
+use serde_json::Value;
+
+fn cmd() -> Command {
+    Command::cargo_bin("anc").expect("anc binary should exist")
+}
+
+fn check_repo_json() -> Value {
+    let manifest = env!("CARGO_MANIFEST_DIR");
+    let out = cmd()
+        .args(["check", manifest, "--output", "json"])
+        .output()
+        .expect("anc check spawn");
+    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+    serde_json::from_str(&stdout)
+        .unwrap_or_else(|e| panic!("failed to parse `anc check` JSON: {e}\nstdout:\n{stdout}"))
+}
+
+fn collect_failed(parsed: &Value, prefix: &str) -> Vec<String> {
+    parsed["results"]
+        .as_array()
+        .expect("results array")
+        .iter()
+        .filter(|r| {
+            r["id"].as_str().is_some_and(|id| id.starts_with(prefix)) && r["status"] == "fail"
+        })
+        .map(|r| {
+            format!(
+                "{} ({})",
+                r["id"].as_str().unwrap_or("?"),
+                r["evidence"].as_str().unwrap_or("(no evidence)"),
+            )
+        })
+        .collect()
+}
+
+/// Test 24 — CRITICAL. P5 (introspection — `--dry-run`, `--print` etc.)
+/// must show no `fail` after adding `anc skill install`. The new verb
+/// supports `--dry-run`; landing it without dogfooding P5 would invalidate
+/// the principle the spec ships against.
+#[test]
+fn dogfood_no_p5_fail_after_skill_subcommand() {
+    let parsed = check_repo_json();
+    let failed = collect_failed(&parsed, "p5-");
+    assert!(
+        failed.is_empty(),
+        "p5-* checks must not fail on this repo. Failures:\n  {}",
+        failed.join("\n  "),
+    );
+}
+
+/// Test 25 — CRITICAL. P2 (structured output — `--output {text,json}` and
+/// the JSON envelope contract) must show no `fail` after adding the new
+/// verb. `anc skill install` was specifically designed to dogfood P2 by
+/// emitting an envelope on every outcome.
+#[test]
+fn dogfood_no_p2_fail_after_skill_subcommand() {
+    let parsed = check_repo_json();
+    let failed = collect_failed(&parsed, "p2-");
+    assert!(
+        failed.is_empty(),
+        "p2-* checks must not fail on this repo. Failures:\n  {}",
+        failed.join("\n  "),
+    );
+}
diff --git a/tests/fixtures/hostile-hang/probe.sh b/tests/fixtures/hostile-hang/probe.sh
new file mode 100755
index 0000000..450ddb5
--- /dev/null
+++ b/tests/fixtures/hostile-hang/probe.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+# Hostile fixture: hangs on `--version` to verify the scorecard probe's
+# 2-second timeout actually fires. probe_tool_version() builds a fresh
+# BinaryRunner with Duration::from_secs(2); without that bound, anc would
+# wait the full sleep duration on a wedged target. We sleep 30s so the
+# timeout's effect is unambiguous: a successful guard returns in ~2-3s.
+case "$1" in
+    --version|-V)
+        # `exec` replaces the shell with `sleep` so SIGKILL on the spawned
+        # PID kills sleep directly. Without exec, the shell forks sleep as
+        # a child; SIGKILL on the shell leaves an orphan sleep holding the
+        # inherited stdout/stderr pipes, which blocks the runner's reader
+        # threads until sleep finishes. That defeats the timeout's purpose
+        # and inflates total runtime to ~90s instead of ~4s.
+        exec sleep 30
+        ;;
+    --help|-h)
+        echo "hostile-hang: probe --version to trigger hang"
+        exit 0
+        ;;
+    *)
+        echo "hostile-hang"
+        exit 0
+        ;;
+esac
diff --git a/tests/fixtures/hostile-nonzero-exit/probe.sh b/tests/fixtures/hostile-nonzero-exit/probe.sh
new file mode 100755
index 0000000..bef06a8
--- /dev/null
+++ b/tests/fixtures/hostile-nonzero-exit/probe.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+# Hostile fixture: exits nonzero on every probe variant the scorecard
+# tries (`--version`, `-V`). Verifies the fall-through contract — when
+# every version-probe attempt fails, tool.version must be null and the
+# overall run must succeed (a target that refuses to self-report is not
+# a scoring error).
+case "$1" in
+    --help|-h)
+        echo "hostile-nonzero-exit: --help works, version probes fail"
+        exit 0
+        ;;
+    --version|-V)
+        echo "version probe rejected" >&2
+        exit 1
+        ;;
+    *)
+        echo "hostile-nonzero-exit"
+        exit 0
+        ;;
+esac
diff --git a/tests/fixtures/hostile-stdout-flood/probe.sh b/tests/fixtures/hostile-stdout-flood/probe.sh
new file mode 100755
index 0000000..d29a89e
--- /dev/null
+++ b/tests/fixtures/hostile-stdout-flood/probe.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+# Hostile fixture: floods stdout with ~2 MiB on `--version` to verify the
+# scorecard's tool.version probe survives a binary that ignores reasonable
+# output sizes. The runner's read_capped() primitive enforces a 1 MiB
+# ceiling — anc must complete the run without exhausting memory or
+# panicking, regardless of what the captured first line looks like.
+case "$1" in
+    --version|-V)
+        # Emit ~2 MiB of 'x' across many lines so the first line is
+        # bounded but total output exceeds the cap.
+        i=0
+        while [ "$i" -lt 32768 ]; do
+            printf 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n'
+            i=$((i + 1))
+        done
+        exit 0
+        ;;
+    --help|-h)
+        echo "hostile-stdout-flood: probe --version to trigger flood"
+        exit 0
+        ;;
+    *)
+        echo "hostile-stdout-flood"
+        exit 0
+        ;;
+esac
diff --git a/tests/integration.rs b/tests/integration.rs
index e515ff1..45d9d66 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -743,7 +743,7 @@ fn test_audit_profile_echoed_in_json_output() {
     let json_str = String::from_utf8(output).expect("utf8 stdout");
     let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
     assert_eq!(parsed["audit_profile"], "human-tui");
-    assert_eq!(parsed["schema_version"], "0.3");
+    assert_eq!(parsed["schema_version"], "0.5");
 }
 
 #[test]
@@ -902,6 +902,14 @@ fn test_scorecard_json_has_stable_top_level_keys() {
         "audience",
         "audit_profile",
         "spec_version",
+        // 0.4 additions — see docs/plans/2026-04-29-001-feat-scorecard-schema-metadata-plan.md.
+        "tool",
+        "anc",
+        "run",
+        "target",
+        // 0.5 addition — agent-native badge block (eligibility, embed
+        // snippet, scorecard/badge URLs).
+        "badge",
     ];
     // `audience_reason` is present only when audience is null — on the
     // self-dogfood it should NOT appear, consistent with the skip rule.
@@ -925,7 +933,7 @@ fn test_scorecard_json_has_stable_top_level_keys() {
     );
 
     // Fixed enumerations also pin against the renderer contract.
-    assert_eq!(obj["schema_version"], "0.3");
+    assert_eq!(obj["schema_version"], "0.5");
 }
 
 #[test]
diff --git a/tests/scorecard_metadata_security.rs b/tests/scorecard_metadata_security.rs
new file mode 100644
index 0000000..25aef52
--- /dev/null
+++ b/tests/scorecard_metadata_security.rs
@@ -0,0 +1,194 @@
+//! Red-team regression tests for `tool.version` probing.
+//!
+//! These lock in the security guarantees the v0.4 schema plan promised: a
+//! self-spawn declined, hostile-binary survival (memory cap, timeout, nonzero
+//! exit). Without these tests, a future refactor could quietly drop the
+//! self-spawn guard or replace `BinaryRunner::run` with a fresh
+//! `Command::output()` and pass every shape-and-value test in the suite.
+//!
+//! Plan reference: `docs/plans/2026-04-29-001-feat-scorecard-schema-metadata-plan.md`,
+//! Risks table + U5 Test scenarios.
+
+use assert_cmd::Command;
+use serde_json::Value;
+use std::time::Duration;
+// `Instant` is only used by the unix-gated hostile-binary tests below; the
+// `cfg(unix)` cuts it out on Windows where shell-script fixtures can't run.
+#[cfg(unix)]
+use std::time::Instant;
+
+fn cmd() -> Command {
+    Command::cargo_bin("anc").expect("anc binary should exist")
+}
+
+fn fixture_path(name: &str) -> String {
+    format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
+}
+
+/// Spawn anc, parse its stdout as JSON. The shared output-buffering used by
+/// every test below.
+fn run_and_parse(args: &[&str]) -> (Value, std::process::Output) {
+    let output = cmd()
+        .args(args)
+        .timeout(Duration::from_secs(20))
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout.clone()).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
+        panic!("anc must emit valid JSON, got error {e}; stdout was {stdout:?}")
+    });
+    (parsed, output)
+}
+
+#[test]
+#[cfg(unix)]
+fn self_spawn_against_anc_binary_yields_null_version() {
+    // Recursive-fork-bomb hazard: pointing anc at its own binary path must
+    // hit the self-spawn guard in `probe_tool_version` and emit
+    // `tool.version: null`. `arg_required_else_help` already prevents the
+    // bare-args recursion path; this guard is defense-in-depth against any
+    // future loosening of that contract or any version-probe refactor that
+    // forgets the comparison.
+    let anc_path = assert_cmd::cargo::cargo_bin("anc");
+    let anc_str = anc_path.to_str().expect("utf-8 binary path");
+
+    let (parsed, output) = run_and_parse(&["check", anc_str, "--output", "json"]);
+
+    assert_eq!(
+        parsed["target"]["kind"], "binary",
+        "self-spawn target is the running binary file, kind must be 'binary'",
+    );
+    assert!(
+        parsed["tool"]["version"].is_null(),
+        "self-spawn guard must decline the version probe — \
+         got {:?}; full stdout: {}",
+        parsed["tool"]["version"],
+        String::from_utf8_lossy(&output.stdout),
+    );
+}
+
+#[test]
+#[cfg(unix)]
+fn hostile_binary_flooding_stdout_does_not_exhaust_memory() {
+    // Fixture emits ~2 MiB on `--version`. The runner's `read_capped`
+    // primitive enforces a 1 MiB ceiling. anc must complete the run
+    // without crashing or exhausting memory; the captured `tool.version`
+    // remains a string-or-null.
+    let path = fixture_path("hostile-stdout-flood/probe.sh");
+    let start = Instant::now();
+    let (parsed, _) = run_and_parse(&["check", &path, "--output", "json"]);
+    let elapsed = start.elapsed();
+
+    assert!(
+        elapsed < Duration::from_secs(10),
+        "anc must complete promptly even when target floods stdout; took {elapsed:?}",
+    );
+    let v = &parsed["tool"]["version"];
+    assert!(
+        v.is_string() || v.is_null(),
+        "tool.version must serialize as string-or-null after stdout flood, got {v:?}",
+    );
+    if let Some(s) = v.as_str() {
+        // The cap prevents memory exhaustion. The captured first line is
+        // bounded by what fits before the first newline in the fixture's
+        // output. We don't pin an exact size but require it stays sane.
+        assert!(
+            s.len() < 4 * 1024 * 1024,
+            "captured first line must be bounded, got {} bytes",
+            s.len(),
+        );
+    }
+}
+
+#[test]
+#[cfg(unix)]
+fn hostile_binary_that_hangs_is_killed_at_timeout() {
+    // Fixture sleeps 30s on `--version`. probe_tool_version's BinaryRunner
+    // has a 2-second timeout, so a healthy anc returns in ~2-4s (one
+    // timeout per probe attempt: --version then -V). If a regression drops
+    // the timeout, the test will time out at assert_cmd's 20s ceiling and
+    // fail loudly.
+    let path = fixture_path("hostile-hang/probe.sh");
+    let start = Instant::now();
+    let (parsed, _) = run_and_parse(&["check", &path, "--output", "json"]);
+    let elapsed = start.elapsed();
+
+    assert!(
+        elapsed < Duration::from_secs(15),
+        "hung version probe must be killed at 2s timeout; total run took {elapsed:?}",
+    );
+    assert!(
+        parsed["tool"]["version"].is_null(),
+        "hung version probe must yield tool.version: null, got {:?}",
+        parsed["tool"]["version"],
+    );
+}
+
+#[test]
+#[cfg(unix)]
+fn hostile_binary_nonzero_version_exit_yields_null() {
+    // Fixture exits 1 on both `--version` and `-V`. Both probe attempts
+    // must fail through; tool.version becomes null. The overall run
+    // succeeds — a target that refuses to self-report its version is not
+    // a scoring error.
+    let path = fixture_path("hostile-nonzero-exit/probe.sh");
+    let (parsed, _) = run_and_parse(&["check", &path, "--output", "json"]);
+
+    assert!(
+        parsed["tool"]["version"].is_null(),
+        "every nonzero version probe must yield null, got {:?}",
+        parsed["tool"]["version"],
+    );
+    // The scorecard itself must still emit — version probe failure is not
+    // a scoring failure.
+    assert_eq!(parsed["schema_version"], "0.5");
+    assert_eq!(parsed["target"]["kind"], "binary");
+}
+
+#[test]
+fn unknown_command_errors_with_actionable_message() {
+    // The v0.4 metadata work did NOT change `resolve_command_on_path`'s
+    // contract: an unknown command name still produces a top-level error,
+    // not a scorecard with null fields. Locking this in defends against a
+    // well-meaning refactor that "tolerates" unknown commands by emitting
+    // an empty scorecard — which would silently bless an invalid target.
+    let assert = cmd()
+        .args([
+            "check",
+            "--command",
+            "definitely-not-a-real-cmd-xyzzy-0a8b",
+            "--output",
+            "json",
+        ])
+        .timeout(Duration::from_secs(10))
+        .assert()
+        .failure();
+    let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf-8");
+    assert!(
+        stderr.contains("not found"),
+        "unknown command must explain itself, got stderr: {stderr:?}",
+    );
+}
+
+#[test]
+fn project_mode_without_built_binary_emits_manifest_version_and_null_binary() {
+    // `tests/fixtures/perfect-rust` has Cargo.toml but no target/ — the
+    // canonical "freshly cloned, never built" state. Plan U5 contract:
+    // `tool.binary` is null (no executable to probe), `tool.version` falls
+    // through to the manifest's [package].version (0.1.0 in this fixture).
+    // A future change that probes a non-existent binary path would emit
+    // `tool.version: null` here and fail the assertion.
+    let path = fixture_path("perfect-rust");
+    let (parsed, _) = run_and_parse(&["check", &path, "--output", "json"]);
+
+    assert_eq!(parsed["target"]["kind"], "project");
+    assert!(
+        parsed["tool"]["binary"].is_null(),
+        "no built binary in fixture — tool.binary must be null, got {:?}",
+        parsed["tool"]["binary"],
+    );
+    assert_eq!(
+        parsed["tool"]["version"], "0.1.0",
+        "manifest version (Cargo.toml [package].version) must populate tool.version when no binary exists",
+    );
+}
diff --git a/tests/scorecard_schema_v05.rs b/tests/scorecard_schema_v05.rs
new file mode 100644
index 0000000..e066b05
--- /dev/null
+++ b/tests/scorecard_schema_v05.rs
@@ -0,0 +1,307 @@
+//! End-to-end schema 0.5 drift guard.
+//!
+//! Spawns the real binary in each of the three `anc check` modes (project,
+//! binary, command) and asserts the documented v0.5 keys are all present in
+//! the JSON output. Catches gaps that unit tests can't — argv capture must
+//! actually flow through `inject_default_subcommand`, version probing must
+//! actually spawn a child, the `badge` block must be derived from the live
+//! tool slug, etc.
+
+use assert_cmd::Command;
+use serde_json::Value;
+
+fn cmd() -> Command {
+    Command::cargo_bin("anc").expect("anc binary should exist")
+}
+
+fn fixture_path(name: &str) -> String {
+    format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
+}
+
+/// Assert every documented v0.5 key path resolves on the parsed JSON. The
+/// segmented walk gives a precise failure message when a field is missing.
+fn assert_v05_shape(parsed: &Value) {
+    assert_eq!(
+        parsed["schema_version"], "0.5",
+        "schema_version must be 0.5",
+    );
+
+    for path in [
+        // 0.1-0.3 carryover — drift guard against accidental removal.
+        "results",
+        "summary",
+        "coverage_summary",
+        "audience",
+        "audit_profile",
+        "spec_version",
+        // 0.4 additions.
+        "tool.name",
+        "tool.binary",
+        "tool.version",
+        "anc.version",
+        "run.invocation",
+        "run.started_at",
+        "run.duration_ms",
+        "run.platform.os",
+        "run.platform.arch",
+        "target.kind",
+        "target.path",
+        "target.command",
+        // 0.5 additions — agent-native badge block.
+        "badge.eligible",
+        "badge.score_pct",
+        "badge.embed_markdown",
+        "badge.scorecard_url",
+        "badge.badge_url",
+        "badge.convention_url",
+    ] {
+        let mut node = parsed;
+        for segment in path.split('.') {
+            node = node
+                .get(segment)
+                .unwrap_or_else(|| panic!("expected key `{path}` — segment `{segment}` missing"));
+        }
+    }
+
+    // The convention URL is fixed and shared across every scored tool. A
+    // regression that pointed it at a stale path would silently break the
+    // pre-launch surface — pin it loudly here.
+    assert_eq!(
+        parsed["badge"]["convention_url"], "https://anc.dev/badge",
+        "badge.convention_url must be the canonical /badge page",
+    );
+}
+
+#[test]
+fn schema_v05_project_mode_emits_full_shape() {
+    let path = fixture_path("perfect-rust");
+    let output = cmd()
+        .args(["check", &path, "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    assert_v05_shape(&parsed);
+    assert_eq!(parsed["target"]["kind"], "project");
+    assert_eq!(
+        parsed["target"]["path"], "perfect-rust",
+        "project mode emits the basename of the resolved target, not the absolute path \
+         (PII-leak guard — operator home dir / org dir structure must not appear)",
+    );
+    assert!(parsed["target"]["command"].is_null());
+}
+
+#[test]
+fn schema_v05_binary_mode_emits_full_shape() {
+    let path = fixture_path("binary-only/test.sh");
+    let output = cmd()
+        .args(["check", &path, "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    assert_v05_shape(&parsed);
+    assert_eq!(parsed["target"]["kind"], "binary");
+    assert_eq!(
+        parsed["target"]["path"], "test.sh",
+        "binary mode emits the basename of the resolved target, not the absolute path \
+         (PII-leak guard)",
+    );
+    assert!(parsed["target"]["command"].is_null());
+}
+
+/// Regression guard — `target.path` must never contain a path separator. If
+/// this trips, the absolute-path leak from pre-fix `build_target_info` has
+/// crept back in (or someone built a new code path that emits a richer
+/// path representation). See `src/main.rs::build_target_info` doc comment
+/// for the leak-vector rationale.
+#[test]
+fn schema_v05_target_path_carries_no_separators() {
+    let path = fixture_path("perfect-rust");
+    let output = cmd()
+        .args(["check", &path, "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    let target_path = parsed["target"]["path"]
+        .as_str()
+        .expect("project mode emits a string target.path");
+    assert!(
+        !target_path.contains('/') && !target_path.contains('\\'),
+        "target.path must be a basename only, got: {target_path:?}",
+    );
+}
+
+#[test]
+fn schema_v05_command_mode_emits_full_shape() {
+    // `echo` exists on every supported platform; the version probe is
+    // best-effort and tolerates whatever `echo --version` happens to print.
+    let output = cmd()
+        .args(["check", "--command", "echo", "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    assert_v05_shape(&parsed);
+    assert_eq!(parsed["target"]["kind"], "command");
+    assert!(parsed["target"]["path"].is_null());
+    assert_eq!(parsed["target"]["command"], "echo");
+    assert_eq!(parsed["tool"]["name"], "echo");
+    assert_eq!(parsed["tool"]["binary"], "echo");
+}
+
+#[test]
+fn schema_v05_run_invocation_captures_user_intent_pre_injection() {
+    // Plan R4: a user who typed `anc <path>` (default-subcommand injection)
+    // must see `anc <path>` in the scorecard, NOT `anc check <path>`.
+    let path = fixture_path("perfect-rust");
+    let output = cmd()
+        .args([&path, "--output", "json"]) // no explicit `check`
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    let invocation = parsed["run"]["invocation"]
+        .as_str()
+        .expect("run.invocation is a string");
+    assert!(
+        !invocation.contains(" check "),
+        "run.invocation must reflect user intent (pre-injection), got: {invocation}",
+    );
+}
+
+#[test]
+fn schema_v05_run_platform_matches_runtime_os_arch() {
+    let path = fixture_path("perfect-rust");
+    let output = cmd()
+        .args(["check", &path, "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    assert_eq!(parsed["run"]["platform"]["os"], std::env::consts::OS);
+    assert_eq!(parsed["run"]["platform"]["arch"], std::env::consts::ARCH);
+}
+
+#[test]
+fn schema_v05_run_started_at_parses_as_rfc3339() {
+    let path = fixture_path("perfect-rust");
+    let output = cmd()
+        .args(["check", &path, "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    let started_at = parsed["run"]["started_at"]
+        .as_str()
+        .expect("started_at is a string");
+    // RFC 3339 shape check without pulling the `time` parsing feature into
+    // dev-deps just for one test: `YYYY-MM-DDTHH:MM:SS` plus optional
+    // fractional seconds, ending in `Z` or a timezone offset.
+    assert!(
+        started_at.len() >= 20
+            && started_at.as_bytes()[4] == b'-'
+            && started_at.as_bytes()[7] == b'-'
+            && started_at.as_bytes()[10] == b'T'
+            && started_at.as_bytes()[13] == b':'
+            && started_at.as_bytes()[16] == b':',
+        "started_at must look like RFC 3339, got {started_at:?}",
+    );
+    let last = started_at.chars().last().expect("non-empty");
+    assert!(
+        last == 'Z' || started_at.contains('+') || started_at[10..].contains('-'),
+        "started_at must end in `Z` or a timezone offset, got {started_at:?}",
+    );
+}
+
+#[test]
+fn schema_v05_anc_version_matches_cargo_pkg_version() {
+    let path = fixture_path("perfect-rust");
+    let output = cmd()
+        .args(["check", &path, "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    assert_eq!(parsed["anc"]["version"], env!("CARGO_PKG_VERSION"));
+}
+
+#[test]
+fn schema_v05_badge_block_reflects_live_tool_slug() {
+    // End-to-end the `badge.*` URLs are derived from `tool.name` — a
+    // regression that hardcodes "demo" or pulls the slug from the wrong
+    // place would produce an embed URL that doesn't match the live
+    // scorecard page. This pins the slug↔URL relationship without
+    // depending on the actual score (a fixture's pass-rate may shift as
+    // checks evolve, so we only assert URL shape, not eligibility).
+    let path = fixture_path("perfect-rust");
+    let output = cmd()
+        .args(["check", &path, "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    let slug = parsed["tool"]["name"]
+        .as_str()
+        .expect("tool.name is a string");
+    assert!(!slug.is_empty(), "tool.name must be non-empty");
+
+    let badge_url = parsed["badge"]["badge_url"]
+        .as_str()
+        .expect("badge.badge_url present when slug derived");
+    let scorecard_url = parsed["badge"]["scorecard_url"]
+        .as_str()
+        .expect("badge.scorecard_url present when slug derived");
+    assert_eq!(badge_url, format!("https://anc.dev/badge/{slug}.svg"));
+    assert_eq!(scorecard_url, format!("https://anc.dev/score/{slug}"));
+
+    // The two URL families MUST share the slug — a regression that
+    // computed them from different sources would point readers at one
+    // tool's scorecard via another tool's badge.
+    assert!(badge_url.contains(slug));
+    assert!(scorecard_url.contains(slug));
+}
+
+#[test]
+fn schema_v05_badge_eligibility_flag_matches_score() {
+    // Whatever the live score, `badge.eligible` must agree with
+    // `score_pct >= 80`. A regression that flipped the comparison or
+    // hard-coded `eligible: true` would slip the floor — caught here.
+    let path = fixture_path("perfect-rust");
+    let output = cmd()
+        .args(["check", &path, "--output", "json"])
+        .output()
+        .expect("anc spawn");
+    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
+    let parsed: Value = serde_json::from_str(&stdout).expect("valid JSON");
+
+    let pct = parsed["badge"]["score_pct"]
+        .as_u64()
+        .expect("score_pct is numeric") as u32;
+    let eligible = parsed["badge"]["eligible"]
+        .as_bool()
+        .expect("eligible is boolean");
+    assert_eq!(
+        eligible,
+        pct >= 80,
+        "badge.eligible must equal (score_pct >= 80); got pct={pct}, eligible={eligible}",
+    );
+
+    // Embed snippet contract: present iff eligible. A non-eligible tool
+    // emitting an embed would defeat the do-not-nag rule.
+    if eligible {
+        assert!(parsed["badge"]["embed_markdown"].is_string());
+    } else {
+        assert!(parsed["badge"]["embed_markdown"].is_null());
+    }
+}
diff --git a/tests/skill_install.rs b/tests/skill_install.rs
new file mode 100644
index 0000000..82f5ed0
--- /dev/null
+++ b/tests/skill_install.rs
@@ -0,0 +1,420 @@
+//! Integration tests for `anc skill install`. Spawns the real binary;
+//! introspects stdout / stderr / exit code. Tests numbered 13-23 per the
+//! plan's `Test scenarios` section. (Tests 1-12 live in `src/skill_install.rs`
+//! as unit tests; 24-25 live in `tests/dogfood.rs`; test 26 is a CI step.)
+
+use assert_cmd::Command;
+use serde_json::Value;
+
+fn cmd() -> Command {
+    Command::cargo_bin("anc").expect("anc binary should exist")
+}
+
+/// Build a clean `anc` invocation for tests that should be insensitive to
+/// the host's `$HOME` and `$PATH`. Each caller can re-`.env(…)` as needed.
+fn cmd_with_home(home: &std::path::Path) -> Command {
+    let mut c = cmd();
+    c.env("HOME", home);
+    c
+}
+
+// -------------------------------------------------------------------------
+// Test 13 — dry-run claude_code text mode prints a single-line `git clone`
+// command on stdout and exits 0.
+// -------------------------------------------------------------------------
+#[test]
+fn dry_run_claude_code_text_prints_single_line_command() {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let out = cmd_with_home(tmp.path())
+        .args(["skill", "install", "--dry-run", "claude_code"])
+        .output()
+        .expect("anc spawn");
+
+    assert!(
+        out.status.success(),
+        "expected exit 0; got {:?}",
+        out.status
+    );
+    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+    let trimmed = stdout.trim_end_matches('\n');
+    assert!(
+        !trimmed.contains('\n'),
+        "dry-run text mode must be single-line; got {trimmed:?}",
+    );
+    assert!(
+        trimmed.starts_with("git clone --depth 1 "),
+        "expected leading `git clone --depth 1 `; got {trimmed:?}",
+    );
+    assert!(
+        trimmed.contains(".claude/skills/agent-native-cli"),
+        "expected the canonical claude_code dest path; got {trimmed:?}",
+    );
+}
+
+// -------------------------------------------------------------------------
+// Test 14 — dry-run claude_code json mode produces the envelope schema with
+// mode=dry-run, would_succeed=true, status=success.
+// -------------------------------------------------------------------------
+#[test]
+fn dry_run_claude_code_json_emits_success_envelope() {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let out = cmd_with_home(tmp.path())
+        .args([
+            "skill",
+            "install",
+            "--dry-run",
+            "claude_code",
+            "--output",
+            "json",
+        ])
+        .output()
+        .expect("anc spawn");
+
+    assert!(out.status.success(), "expected exit 0");
+    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+    let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope");
+
+    assert_eq!(v["action"], "skill-install");
+    assert_eq!(v["host"], "claude_code");
+    assert_eq!(v["mode"], "dry-run");
+    assert_eq!(v["status"], "success");
+    assert_eq!(v["would_succeed"], true);
+    assert!(v["exit_code"].is_null(), "dry-run mode omits exit_code");
+    assert!(v["reason"].is_null(), "success path omits reason");
+    assert_eq!(v["destination_status"], "absent");
+    assert!(
+        v["command"]
+            .as_str()
+            .unwrap()
+            .starts_with("git clone --depth 1 "),
+        "command must start with the canonical clone prefix",
+    );
+    assert!(
+        v["destination"]
+            .as_str()
+            .unwrap()
+            .contains(".claude/skills/agent-native-cli"),
+        "destination must resolve to the claude_code canonical path",
+    );
+}
+
+// -------------------------------------------------------------------------
+// Test 15 — dry-run + pre-placed regular file at the canonical dest yields
+// status=error, reason=destination-is-file, would_succeed=false, exit 1.
+// -------------------------------------------------------------------------
+#[test]
+fn dry_run_with_regular_file_at_dest_emits_destination_is_file() {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    // Pre-place a regular file at the resolved canonical dest:
+    //   $HOME/.claude/skills/agent-native-cli
+    let dest_dir = tmp.path().join(".claude/skills");
+    std::fs::create_dir_all(&dest_dir).expect("mkdir parent");
+    std::fs::write(dest_dir.join("agent-native-cli"), b"not-a-dir").expect("write file");
+
+    let out = cmd_with_home(tmp.path())
+        .args([
+            "skill",
+            "install",
+            "--dry-run",
+            "claude_code",
+            "--output",
+            "json",
+        ])
+        .output()
+        .expect("anc spawn");
+
+    assert_eq!(out.status.code(), Some(1), "expected exit 1");
+    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+    let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope");
+
+    assert_eq!(v["status"], "error");
+    assert_eq!(v["reason"], "destination-is-file");
+    assert_eq!(v["would_succeed"], false);
+    assert_eq!(v["destination_status"], "file");
+    assert!(
+        v["exit_code"].is_null(),
+        "no spawn happened — exit_code must be absent"
+    );
+}
+
+// -------------------------------------------------------------------------
+// Test 16b — `#[ignore]` end-to-end. Spawns the real binary with HOME set
+// to a tempdir; runs the actual git clone against the upstream skill repo.
+// Excluded from default `cargo test` since it depends on network and the
+// public GitHub repo. Run with `cargo test -- --ignored skill_install` when
+// vetting a release.
+// -------------------------------------------------------------------------
+#[test]
+#[ignore = "network: clones from github.com/brettdavies/agentnative-skill"]
+fn live_install_clones_into_canonical_dest() {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let out = cmd_with_home(tmp.path())
+        .args(["skill", "install", "claude_code", "--output", "json"])
+        .output()
+        .expect("anc spawn");
+
+    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+    assert!(
+        out.status.success(),
+        "live install failed; stdout={stdout}, stderr={}",
+        String::from_utf8_lossy(&out.stderr),
+    );
+    let dest = tmp.path().join(".claude/skills/agent-native-cli");
+    assert!(
+        dest.join(".git").is_dir(),
+        "expected .git/ at {}",
+        dest.display()
+    );
+}
+
+// -------------------------------------------------------------------------
+// Test 17 — clap rejects unknown host with exit 2 and lists possible values.
+// -------------------------------------------------------------------------
+#[test]
+fn unknown_host_rejected_with_clap_exit_2() {
+    let out = cmd()
+        .args(["skill", "install", "definitely-not-a-host"])
+        .output()
+        .expect("anc spawn");
+
+    assert_eq!(out.status.code(), Some(2));
+    let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
+    assert!(
+        stderr.contains("invalid value"),
+        "expected clap 'invalid value' message; got {stderr:?}",
+    );
+    for host in ["claude_code", "codex", "cursor", "opencode"] {
+        assert!(
+            stderr.contains(host),
+            "expected possible value {host:?} in error; got {stderr:?}",
+        );
+    }
+}
+
+// -------------------------------------------------------------------------
+// Test 18 — clap rejects missing positional host with exit 2.
+// -------------------------------------------------------------------------
+#[test]
+fn missing_host_rejected_with_clap_exit_2() {
+    let out = cmd()
+        .args(["skill", "install"])
+        .output()
+        .expect("anc spawn");
+
+    assert_eq!(out.status.code(), Some(2));
+    let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
+    assert!(
+        stderr.contains("required") && stderr.contains("HOST"),
+        "expected required-arg-<HOST> message; got {stderr:?}",
+    );
+}
+
+// -------------------------------------------------------------------------
+// Test 19 — live install on already-populated destination → envelope with
+// status=error, reason=destination-not-empty, exit_code absent (we never
+// spawned).
+// -------------------------------------------------------------------------
+#[test]
+fn live_install_on_populated_dest_does_not_spawn() {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let dest = tmp.path().join(".claude/skills/agent-native-cli");
+    std::fs::create_dir_all(&dest).expect("mkdir dest");
+    std::fs::write(dest.join("placeholder"), b"x").expect("populate dest");
+
+    let out = cmd_with_home(tmp.path())
+        .args(["skill", "install", "claude_code", "--output", "json"])
+        .output()
+        .expect("anc spawn");
+
+    assert_eq!(out.status.code(), Some(1));
+    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+    let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope");
+
+    assert_eq!(v["status"], "error");
+    assert_eq!(v["reason"], "destination-not-empty");
+    assert_eq!(v["mode"], "install");
+    assert_eq!(v["destination_status"], "non-empty-dir");
+    assert!(
+        v["exit_code"].is_null(),
+        "destination check failed — never spawned git, exit_code must be null",
+    );
+    assert!(
+        v["would_succeed"].is_null(),
+        "would_succeed is dry-run-only"
+    );
+}
+
+// -------------------------------------------------------------------------
+// Test 20 — HOME unset surfaces reason=home-not-set, exit 1.
+// -------------------------------------------------------------------------
+#[test]
+fn home_unset_emits_home_not_set_envelope() {
+    let out = cmd()
+        .env_remove("HOME")
+        .args(["skill", "install", "claude_code", "--output", "json"])
+        .output()
+        .expect("anc spawn");
+
+    assert_eq!(out.status.code(), Some(1));
+    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+    let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope");
+
+    assert_eq!(v["status"], "error");
+    assert_eq!(v["reason"], "home-not-set");
+    assert!(
+        v["destination"].as_str().unwrap().starts_with("~"),
+        "MissingHome surfaces the unexpanded template in destination",
+    );
+    assert_eq!(v["destination_status"], "absent");
+}
+
+// -------------------------------------------------------------------------
+// Test 21 — `git` not on PATH surfaces reason=git-not-found, exit 1.
+// Pinning PATH to an empty directory makes `git` unresolvable on Unix
+// (where Command::status reports NotFound). Skipped on Windows since the
+// test infra would need a different shape there.
+// -------------------------------------------------------------------------
+#[cfg(unix)]
+#[test]
+fn git_not_on_path_emits_git_not_found_envelope() {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let empty_path = tmp.path().to_str().expect("utf-8 tempdir");
+
+    let out = cmd_with_home(tmp.path())
+        .env("PATH", empty_path)
+        .args(["skill", "install", "claude_code", "--output", "json"])
+        .output()
+        .expect("anc spawn");
+
+    assert_eq!(out.status.code(), Some(1));
+    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+    let v: Value = serde_json::from_str(&stdout).expect("valid JSON envelope");
+
+    assert_eq!(v["status"], "error");
+    assert_eq!(v["reason"], "git-not-found");
+    assert!(
+        v["exit_code"].is_null(),
+        "git not on PATH means we never spawned — exit_code must be null",
+    );
+}
+
+// -------------------------------------------------------------------------
+// Test 22 — `arg_required_else_help_unaffected_by_skill_subcommand`: bare
+// `anc skill` prints help and exits with code 2. Pins the fork-bomb-safety
+// invariant from CLAUDE.md ("Bare invocation prints help"). Catches the
+// regression where adding the skill subcommand accidentally drops
+// arg_required_else_help on the parent.
+// -------------------------------------------------------------------------
+#[test]
+fn arg_required_else_help_unaffected_by_skill_subcommand() {
+    let out = cmd().arg("skill").output().expect("anc spawn");
+    assert_eq!(
+        out.status.code(),
+        Some(2),
+        "bare `anc skill` must exit with code 2 (clap missing-subcommand)",
+    );
+    let stderr = String::from_utf8(out.stderr).unwrap_or_default();
+    let stdout = String::from_utf8(out.stdout).unwrap_or_default();
+    let combined = format!("{stdout}{stderr}");
+    assert!(
+        combined.contains("Usage:"),
+        "expected help text mentioning Usage; got stdout={stdout:?} stderr={stderr:?}",
+    );
+}
+
+// -------------------------------------------------------------------------
+// Test 23 — `exit_codes_match_p4_convention`: pins the P4 exit-code shape
+// in one place rather than scattering assertions across tests 15/19/20/21.
+// Table-driven across the user-error envelope cases. happy=0 is asserted in
+// test 13/14; internal-error=2 is reserved (clap surfaces it; tests 17/18
+// pin that side).
+// -------------------------------------------------------------------------
+#[test]
+fn exit_codes_match_p4_convention() {
+    struct Case {
+        name: &'static str,
+        setup: Box<dyn FnOnce(&std::path::Path) -> Command>,
+        expected_exit: i32,
+        expected_reason: &'static str,
+    }
+
+    let cases: Vec<Case> = vec![
+        Case {
+            name: "DestNotEmpty",
+            setup: Box::new(|home: &std::path::Path| {
+                let dest = home.join(".claude/skills/agent-native-cli");
+                std::fs::create_dir_all(&dest).expect("mkdir");
+                std::fs::write(dest.join("x"), b"y").expect("populate");
+                let mut c = cmd_with_home(home);
+                c.args(["skill", "install", "claude_code", "--output", "json"]);
+                c
+            }),
+            expected_exit: 1,
+            expected_reason: "destination-not-empty",
+        },
+        Case {
+            name: "DestIsFile",
+            setup: Box::new(|home: &std::path::Path| {
+                let parent = home.join(".claude/skills");
+                std::fs::create_dir_all(&parent).expect("mkdir parent");
+                std::fs::write(parent.join("agent-native-cli"), b"not-a-dir").expect("write file");
+                let mut c = cmd_with_home(home);
+                c.args(["skill", "install", "claude_code", "--output", "json"]);
+                c
+            }),
+            expected_exit: 1,
+            expected_reason: "destination-is-file",
+        },
+        Case {
+            name: "MissingHome",
+            setup: Box::new(|_home: &std::path::Path| {
+                let mut c = cmd();
+                c.env_remove("HOME");
+                c.args(["skill", "install", "claude_code", "--output", "json"]);
+                c
+            }),
+            expected_exit: 1,
+            expected_reason: "home-not-set",
+        },
+        #[cfg(unix)]
+        Case {
+            name: "GitNotFound",
+            setup: Box::new(|home: &std::path::Path| {
+                let mut c = cmd_with_home(home);
+                c.env("PATH", home.to_str().expect("utf-8"));
+                c.args(["skill", "install", "claude_code", "--output", "json"]);
+                c
+            }),
+            expected_exit: 1,
+            expected_reason: "git-not-found",
+        },
+    ];
+
+    for case in cases {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let mut command = (case.setup)(tmp.path());
+        let out = command
+            .output()
+            .unwrap_or_else(|e| panic!("anc spawn for {}: {e}", case.name));
+        assert_eq!(
+            out.status.code(),
+            Some(case.expected_exit),
+            "{}: expected exit {}, got {:?}",
+            case.name,
+            case.expected_exit,
+            out.status.code(),
+        );
+        let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
+        let v: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
+            panic!(
+                "{}: expected JSON envelope; parse failed: {e}; stdout={stdout}",
+                case.name
+            )
+        });
+        assert_eq!(
+            v["reason"], case.expected_reason,
+            "{}: reason mismatch",
+            case.name,
+        );
+    }
+}
