This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 9798f16 feat(skills): add setup-status adoption dashboard skill (#470)
9798f16 is described below
commit 9798f16be276f1442068a0266cbf5dee88185b17
Author: Yeonguk Choo <[email protected]>
AuthorDate: Mon Jun 8 15:28:57 2026 +0900
feat(skills): add setup-status adoption dashboard skill (#470)
---
.agents/skills/magpie-setup-status | 1 +
.claude/skills/magpie-setup-status | 1 +
.github/skills/magpie-setup-status | 1 +
docs/labels-and-capabilities.md | 1 +
skills/setup-status/SKILL.md | 239 +++++++++++++
skills/setup-status/adjust.md | 98 ++++++
skills/setup-status/collect.md | 113 ++++++
skills/setup-status/render.md | 113 ++++++
skills/setup-status/scripts/collect_status.py | 477 ++++++++++++++++++++++++++
9 files changed, 1044 insertions(+)
diff --git a/.agents/skills/magpie-setup-status
b/.agents/skills/magpie-setup-status
new file mode 120000
index 0000000..bd08fa1
--- /dev/null
+++ b/.agents/skills/magpie-setup-status
@@ -0,0 +1 @@
+../../skills/setup-status
\ No newline at end of file
diff --git a/.claude/skills/magpie-setup-status
b/.claude/skills/magpie-setup-status
new file mode 120000
index 0000000..4eaf51a
--- /dev/null
+++ b/.claude/skills/magpie-setup-status
@@ -0,0 +1 @@
+../../.agents/skills/magpie-setup-status
\ No newline at end of file
diff --git a/.github/skills/magpie-setup-status
b/.github/skills/magpie-setup-status
new file mode 120000
index 0000000..4eaf51a
--- /dev/null
+++ b/.github/skills/magpie-setup-status
@@ -0,0 +1 @@
+../../.agents/skills/magpie-setup-status
\ No newline at end of file
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 9e98c81..30d367c 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -162,6 +162,7 @@ Capabilities for every skill currently in
| `contributor-activity-sweep` | `capability:stats` |
| `committer-onboarding` | `capability:stats` |
| `list-skills` | `capability:stats` |
+| `setup-status` | `capability:stats` + `capability:setup` *(reports the
adoption configuration — stats — and delegates reconfiguration to the setup
skill)* |
| `setup` | `capability:setup` |
| `setup-isolated-setup-install` | `capability:setup` |
| `setup-isolated-setup-verify` | `capability:setup` |
diff --git a/skills/setup-status/SKILL.md b/skills/setup-status/SKILL.md
new file mode 100644
index 0000000..d55a676
--- /dev/null
+++ b/skills/setup-status/SKILL.md
@@ -0,0 +1,239 @@
+---
+name: magpie-setup-status
+description: |
+ Show how the apache-magpie framework is adopted in the current
+ repo, then adjust that setup in place. Renders a Markdown
+ adoption dashboard: install method and pin, drift, the wired
+ agent targets, the installed skill families, and symlink health.
+ From the same view the user can add or drop agent targets and
+ skill families; the actual change runs through the setup skill.
+when_to_use: |
+ Invoke when the user asks how magpie is set up here, which
+ agent targets are wired, which skill families are installed, or
+ whether the snapshot is in sync. Also when the user wants to
+ change the wiring — add an agent target, enable the security or
+ pr-management family — and wants to see the current state first.
+ Phrases the user might say: "magpie status", "how is magpie
+ adopted", "which agent targets are wired", "show adoption
+ state", "which families are installed", "add the github target".
+capability:
+ - capability:stats
+ - capability:setup
+license: Apache-2.0
+---
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+<!-- Placeholder convention (see
../../AGENTS.md#placeholder-convention-used-in-skill-files):
+ <project-config> → adopter's `.apache-magpie-overrides/` directory
+ <snapshot-dir> → `.apache-magpie/` (gitignored snapshot of the
framework)
+ <committed-lock> → `.apache-magpie.lock` (committed — the project's pin)
+ <local-lock> → `.apache-magpie.local.lock` (gitignored — per-machine
record)
+ <upstream> → adopter's public source repo (the repo this skill
runs in) -->
+
+# setup-status
+
+Render a one-glance picture of **how apache-magpie is adopted in
+this repo**, then let the user reconfigure it without leaving the
+view. The dashboard answers the questions an operator actually
+asks: *which install method and version are pinned, has the
+snapshot drifted, which agent targets are wired, which skill
+families are installed, and are the symlinks healthy.*
+
+This skill is the **configuration** view of adoption. It is read-
+only on its own; every change it offers is carried out by
+delegating to [`/magpie-setup`](../setup/SKILL.md) — the one skill
+that owns adoption mutation. For a **deep integrity / health
+check** (lock parsing, per-check ✓/✗ matrix, permission-hygiene
+audit, ASF comdev MCP prerequisites, stale-worktree sweep), use
+[`/magpie-setup verify`](../setup/verify.md); this skill links to
+it rather than duplicating its checks.
+
+---
+
+## Adopter overrides
+
+Before running the default behaviour documented below, this skill
+consults
+[`.apache-magpie-overrides/setup-status.md`](../../docs/setup/agentic-overrides.md)
+in the adopter repo if it exists, and applies any agent-readable
+overrides it finds. See
+[`docs/setup/agentic-overrides.md`](../../docs/setup/agentic-overrides.md)
+for the contract.
+
+**Hard rule**: agents NEVER modify the snapshot under
+`<adopter-repo>/.apache-magpie/`. Local modifications go in the
+override file. Framework changes go via PR to
+`apache/airflow-steward`.
+
+---
+
+## Snapshot drift
+
+Also at the top of every run, this skill compares the gitignored
+`.apache-magpie.local.lock` (per-machine fetch) against the
+committed `.apache-magpie.lock` (the project pin). On mismatch the
+skill surfaces the gap and proposes
+[`/magpie-setup upgrade`](../setup/upgrade.md). The proposal is
+non-blocking; surfacing the drift is itself part of this skill's
+dashboard, so the comparison feeds [Step 1](#step-1--render-the-dashboard)
+rather than gating the run.
+
+---
+
+## Inputs
+
+**Skill directives** (how the user invokes the skill):
+
+| Input | Effect |
+|---|---|
+| (none) | Render the dashboard, then offer adjustments interactively. |
+| `--no-adjust` | Render only; skip the reconfigure offer. |
+| `adjust` | Skip straight to the reconfigure flow after a brief state recap. |
+
+**Collector flags** (passed through to
+[`scripts/collect_status.py`](scripts/collect_status.py)):
+
+| Flag | Effect |
+|---|---|
+| `--repo <path>` | Inspect a repo other than the current git top-level. |
+| `--format md` | The Markdown dashboard (default). |
+| `--format json` | The raw machine-readable fields
([`collect.md`](collect.md)); add `--pretty` to indent. |
+
+The default output is the Markdown dashboard, rendered
+deterministically by the collector.
+
+---
+
+## Prerequisites
+
+- A git checkout (`git rev-parse --show-toplevel` succeeds), or an
+ explicit `--repo <path>`.
+- `python3` on `PATH` for the deterministic collector
+ ([`scripts/collect_status.py`](scripts/collect_status.py)). No
+ third-party packages, no network access.
+
+This skill reads only framework-internal, on-disk state (lock
+files, symlinks, `.gitignore`, the post-checkout hook). It reads
+**no external or private content**, so the prompt-injection and
+Privacy-LLM gate-checks do not apply.
+
+---
+
+## Step 0 — Pre-flight check
+
+1. Resolve the repo root (`--repo` if given, else
+ `git rev-parse --show-toplevel`).
+2. Apply adopter overrides and the drift preamble above.
+3. **Adopted?** If `<committed-lock>` (`.apache-magpie.lock`) is
+ absent, the repo is **not adopted**. Say so, point at
+ [`/magpie-setup`](../setup/SKILL.md) to adopt, and stop — there
+ is no state to render.
+
+---
+
+## Step 1 — Render the dashboard
+
+The collector **renders the dashboard itself**. Run it (it never
+writes and never fetches):
+
+```bash
+python3 <framework>/skills/setup-status/scripts/collect_status.py
+```
+
+(From a normal adopter the script lives under the snapshot at
+`.apache-magpie/skills/setup-status/scripts/`; invoke it via the
+`magpie-setup-status` symlink's resolved path. The default output
+is the Markdown dashboard; pass `--format json` only when tooling
+needs the raw fields — see [`collect.md`](collect.md).)
+
+> **OUTPUT CONTRACT — non-negotiable.** Present the script's
+> Markdown output **verbatim** (it is already GitHub-flavoured
+> Markdown — let the harness render the pipe table). Do **not**:
+> re-draw it as a box-drawing/ASCII table; drop the `serves` bullet
+> legend (it carries the agents each directory serves, including
+> the whole `universal` cluster); add a Reads column back into the
+> table (that is what made it wrap and break); recompute the
+> verdict; or "prettify" the layout. The script, not the agent,
+> owns the rendering, precisely because an LLM formatting pass
+> reliably mangles it (drops the agents-served legend, renames
+> columns, re-introduces the wide column). If you find yourself
+> rebuilding the table, stop and paste the script output instead.
+
+The renderer owns the headline, the agent-target table plus its
+`serves` legend (so the `universal` cluster and every registry
+vendor always appear), the family roster, and the drift /
+integrity summary.
+
+Full layout reference, the health-verdict rules, and mode-aware
+interpretation: [`render.md`](render.md).
+
+---
+
+## Step 2 — Interpret (lightly)
+
+After printing the verbatim dashboard, optionally add a one-line
+**mode-aware** note where it helps — without re-tabulating or
+contradicting the script output. Example: a `method:local`
+framework checkout commits its symlinks and has no snapshot or
+local lock, so the absent snapshot is healthy there but a fault
+for a normal adopter ([`render.md`](render.md#mode-aware-interpretation)).
+
+---
+
+## Step 3 — Offer adjustments
+
+Unless `--no-adjust` was passed, end the dashboard with the
+reconfigure offer. Detect the obvious deltas (a registry target
+present on disk but not wired; an opt-in family not installed;
+dangling symlinks; drift) and present each as a concrete,
+confirmable change. On confirmation, **delegate to
+[`/magpie-setup`](../setup/SKILL.md)** with the right flags
+(`agents:<list>`, `skill-families:<list>`, or `upgrade`) — this
+skill never edits symlinks, locks, or `.gitignore` itself.
+
+Full gap-detection and delegation rules: [`adjust.md`](adjust.md).
+
+---
+
+## Hard rules
+
+- **Read-only on its own; mutation only via setup.** This skill
+ never writes a symlink, a lock file, or `.gitignore`. Every
+ change is delegated to [`/magpie-setup`](../setup/SKILL.md),
+ preserving the framework's single source of truth for adoption
+ mutation.
+- **Propose before applying.** Each adjustment is a proposal the
+ user explicitly confirms before the delegated `/magpie-setup`
+ run starts. No silent reconfiguration.
+- **Do not duplicate `verify`.** For deep integrity, permission
+ hygiene, comdev-MCP prerequisites, and the stale-worktree sweep,
+ point the user at [`/magpie-setup verify`](../setup/verify.md)
+ rather than re-implementing those checks here.
+- **Never invent state.** Report only what the collector observed.
+ If a field is unknown (e.g. upstream-tip drift needs network),
+ say it was not checked and name the skill that does check it.
+
+---
+
+## References
+
+- [`collect.md`](collect.md) — the collector's JSON field
+ reference.
+- [`render.md`](render.md) — dashboard layout, health-verdict
+ rules, mode-aware interpretation.
+- [`adjust.md`](adjust.md) — gap detection and the delegation
+ contract to `/magpie-setup`.
+- [`scripts/collect_status.py`](scripts/collect_status.py) — the
+ deterministic, read-only state collector.
+- [`/magpie-setup`](../setup/SKILL.md) — adoption mutation:
+ [`adopt.md`](../setup/adopt.md), [`upgrade.md`](../setup/upgrade.md),
+ the [`agents.md`](../setup/agents.md) target registry, and the
+ [Golden rules](../setup/SKILL.md#golden-rules).
+- [`/magpie-setup verify`](../setup/verify.md) — the deep
+ integrity / health check this dashboard complements.
+- [`AGENTS.md`](../../AGENTS.md) — framework conventions and the
+ placeholder convention.
+- [`docs/setup/agentic-overrides.md`](../../docs/setup/agentic-overrides.md)
+ — the override contract every skill consults.
diff --git a/skills/setup-status/adjust.md b/skills/setup-status/adjust.md
new file mode 100644
index 0000000..5300c08
--- /dev/null
+++ b/skills/setup-status/adjust.md
@@ -0,0 +1,98 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# adjust — reconfigure adoption from the dashboard
+
+After [rendering](render.md), and unless `--no-adjust` was passed,
+offer the user concrete, confirmable changes to the adoption
+wiring. This skill **never edits symlinks, lock files, or
+`.gitignore` itself**: every change is carried out by delegating
+to [`/magpie-setup`](../setup/SKILL.md), the one skill that owns
+adoption mutation. The status skill detects the delta, proposes
+the exact command, and — on the user's explicit confirmation —
+runs it.
+
+## Step A — Detect the deltas
+
+From the collected JSON, surface each of these that applies:
+
+| Delta | Signal in the JSON |
+|---|---|
+| Registry target unwired | a registry dir is `present` but not in
`active_target_ids`, or present with `magpie_count == 0` |
+| Opt-in family not installed | `families.opt_in_absent` is non-empty |
+| Opt-in family recorded but missing | the lock's family list includes a
family absent from `families.opt_in_present` |
+| Dangling symlinks | any target's `dangling[]` is non-empty |
+| Drift | `drift.checked && !drift.in_sync`, or a `local lock absent` reason |
+| Not adopted | `adopted == false` (already handled in [Step
0](SKILL.md#step-0--pre-flight-check)) |
+
+Order the offers most → least impactful (drift and dangling links
+before optional family additions). If no delta applies, say the
+adoption is fully wired and stop — do not invent work.
+
+## Step B — Map each delta to a `/magpie-setup` command
+
+| Adjustment | Delegated command |
+|---|---|
+| Add an agent target | `/magpie-setup adopt agents:<full desired set>` |
+| Enable an opt-in family | `/magpie-setup adopt skill-families:<full desired
set>` |
+| Repair dangling / missing symlinks | `/magpie-setup verify
--auto-fix-symlinks` |
+| Sync drift / fetch snapshot | `/magpie-setup upgrade` |
+| Adopt from scratch | `/magpie-setup` |
+
+**The `agents:` and `skill-families:` flags replace the set for
+that run** (see [`../setup/SKILL.md` Inputs](../setup/SKILL.md#inputs)).
+So to *add* a target or family, pass the **union of the existing
+set and the new one**, computed from `active_target_ids` /
+`families.opt_in_present`. `universal` is always retained by setup
+even if omitted, because it is the canonical home every relay
+points at.
+
+Example — current targets are `universal, claude-code` and the
+user wants GitHub too:
+
+```bash
+/magpie-setup adopt agents:universal,claude-code,github
+```
+
+Example — `security` and `pr-management` are installed and the
+user wants the `issue` family as well:
+
+```bash
+/magpie-setup adopt skill-families:security,pr-management,issue
+```
+
+## Step C — Confirm, then delegate
+
+1. Present the proposed change as a single line: *what* changes
+ and *which* `/magpie-setup` command runs.
+2. Wait for explicit confirmation (`go`, `yes`, the command
+ itself). No confirmation → do nothing; the dashboard already
+ delivered the value.
+3. On confirmation, invoke the delegated `/magpie-setup`
+ sub-action. It owns the plan-before-write, the symlink/lock
+ edits, the worktree propagation, and the sandbox-allowlist
+ pass. Do not pre-empt or duplicate any of that here.
+4. After it returns, re-run [`collect_status.py`](scripts/collect_status.py)
+ and show the one-line headline so the user sees the change took
+ effect.
+
+## Removals are heavier — hand them to setup deliberately
+
+Adding targets/families is additive and safe. **Dropping** a
+target or family removes committed/gitignored symlinks and may
+strand overrides, so do not auto-propose it. If the user asks to
+drop one, restate it as a `/magpie-setup adopt` run with the
+reduced set (or [`/magpie-setup unadopt`](../setup/unadopt.md) to
+remove adoption entirely), describe what disappears, and let the
+setup skill carry it out under its own confirmation.
+
+## Self-adoption (`method:local`) caveat
+
+In the framework checkout itself, adoption links *every* skill
+under `skills/` across every active target by design — there is no
+opt-in family selection and no snapshot to sync. The only
+meaningful adjustments are **repairing dangling relays** (re-run
+`/magpie-setup`, idempotent) and **adding a new registry target
+dir** that the operator created. Skip the family-enable and
+drift-sync offers there; `families.opt_in_absent` being empty and
+`drift.checked == false` already reflect this.
diff --git a/skills/setup-status/collect.md b/skills/setup-status/collect.md
new file mode 100644
index 0000000..81de2a8
--- /dev/null
+++ b/skills/setup-status/collect.md
@@ -0,0 +1,113 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# collect — the adoption-state JSON the dashboard reads
+
+[`scripts/collect_status.py`](scripts/collect_status.py) walks the
+repo's on-disk adoption artefacts and emits one JSON document.
+It is **read-only** and **offline**: it parses files and reads
+symlinks; it never fetches over the network and never writes. The
+upstream-tip drift check and any remediation belong to
+[`/magpie-setup verify`](../setup/verify.md) and
+[`/magpie-setup upgrade`](../setup/upgrade.md).
+
+The skill renders the dashboard via `--format md`
+([Step 1](SKILL.md#step-1--render-the-dashboard)); this `--format
+json` form is the same data for tooling that wants the raw fields:
+
+```bash
+python3 <framework>/skills/setup-status/scripts/collect_status.py --format
json --pretty
+```
+
+## Top-level fields
+
+| Field | Meaning |
+|---|---|
+| `repo` | Absolute path of the inspected repo root. |
+| `adopted` | `true` when `<committed-lock>` (`.apache-magpie.lock`) exists.
`false` → not adopted; [Step 0](SKILL.md#step-0--pre-flight-check) already
stopped. |
+| `mode` | Install method from the committed lock: `local`, `git-branch`,
`git-tag`, `svn-zip`, or `null`. |
+| `self_adopted` | `true` when `mode == local` — the framework checkout
linking its own `skills/` source. |
+| `committed_lock` | Parsed `<committed-lock>` (the project pin), or `null`. |
+| `local_lock` | Parsed `<local-lock>` (per-machine fetch), or `null`. Always
`null` under `method:local`. |
+| `snapshot` | `{present, is_symlink}` for `.apache-magpie/`. |
+| `drift` | The committed-vs-local comparison (see below). |
+| `registry_source` | `agents.md` when the agent-target list was parsed live
from [`../setup/agents.md`](../setup/agents.md) (the normal case), or
`fallback` when that file could not be read and the script's built-in mirror
was used. |
+| `agent_targets` | One record per registry target (see below). |
+| `active_target_ids` | The subset of registry ids whose directory is present
on disk. |
+| `families` | The installed-skill roster grouped by family (see below). |
+| `overrides` | `{present, has_readme}` for `.apache-magpie-overrides/`. |
+| `post_checkout_hook` | `{present, executable, has_verify_recipe}`. |
+| `gitignore` | Coverage flags (see below). |
+
+## `agent_targets[]`
+
+The registry is **parsed live** from
+[`../setup/agents.md`](../setup/agents.md) on every run — its
+`## The registry` table is the single source of truth. Adding a
+vendor row there flows into this dashboard automatically; no edit
+to the collector is needed. (The script keeps a built-in mirror
+only as a `fallback` when agents.md cannot be read — see
+`registry_source` above.) Today the table yields `universal`
+(`.agents/skills/`, the canonical home) plus the `claude-code`,
+`github`, `windsurf`, and `goose` relay targets. Each record
+carries:
+
+| Field | Meaning |
+|---|---|
+| `id`, `dir` | Registry id and project skills directory. |
+| `reads` | The agents that read this directory, verbatim from the
[`../setup/agents.md`](../setup/agents.md) registry. For `universal` this is
the whole shared-path cluster (Codex, Cursor, Gemini CLI, Copilot, OpenCode,
Cline, Zed, Warp, Amp, …), so one wired directory serves many agents. |
+| `expected_kind` | `canonical` for `universal`, `relay` for the rest. |
+| `present` | Directory exists on disk. |
+| `entries[]` | One per `magpie-*` entry: `name`, `skill`, `family`,
`is_symlink`, `raw_target`, `resolves`, `kind`. |
+| `magpie_count`, `live_count`, `dangling[]` | Roll-ups over `entries`. |
+
+`entries[].kind` is one of: `canonical-source` (links into the
+in-repo `skills/<n>/` — self-adoption), `canonical-snapshot`
+(links into `.apache-magpie/skills/<n>/` — a normal adopter),
+`relay` (links back at `../../.agents/skills/magpie-<n>`), `copy`
+(a real directory holding `SKILL.md` — the committed
+`magpie-setup` bootstrap), or `broken` (a non-symlink with no
+`SKILL.md`).
+
+## `families`
+
+Each installed canonical skill is bucketed by source-name prefix:
+
+- `opt_in` — `security`, `pr-management`, `issue`: the three
+ user-selectable families. `opt_in_present` / `opt_in_absent`
+ list which are wired.
+- `always_on` — `setup` (every `setup-*` plus the committed
+ `setup` bootstrap) and `list` (`list-*`): wired unconditionally
+ per [Golden rule 8](../setup/SKILL.md#golden-rules).
+- `other` — installed skills outside those prefixes (the
+ contributor, pairing, audit, and authoring skills). Reported by
+ name so the dashboard never silently drops them.
+
+The bucketing is a prefix heuristic over what is actually on disk.
+The authoritative opt-in pick for a normal adopter is recorded in
+the lock files; when present, prefer the lock's family list over
+the heuristic for the *intended* set, and use the heuristic for
+the *installed* set so the dashboard can flag a gap between them.
+
+## `drift`
+
+| `checked` | Then |
+|---|---|
+| `false`, reason `method:local …` | Self-adoption has no remote snapshot to
drift against. |
+| `false`, reason `local lock absent …` | The snapshot was never fetched on
this machine. Propose [`/magpie-setup upgrade`](../setup/upgrade.md). |
+| `true` | `in_sync` plus any `mismatches[]` over `method` / `url` / `ref`.
The `git-branch` upstream-tip comparison needs network and is **not** done here
— `note` names `/magpie-setup verify` as the skill that does it. |
+
+## `gitignore`
+
+Top-level flags (`snapshot_ignored`, `local_lock_ignored`,
+`settings_local_ignored`) plus a per-target map. Each target
+carries `glob_ignored` + `setup_unignored` (the **normal-adopter**
+pattern: ignore the symlinks, keep the bootstrap tracked) and
+`all_unignored` (the **self-adoption** pattern: every `magpie-*`
+symlink is committed, so the whole glob is un-ignored).
+
+Interpretation is mode-aware — see
+[`render.md`](render.md#mode-aware-interpretation). Deep
+`.gitignore` validation is owned by
+[`/magpie-setup verify`](../setup/verify.md); this skill only
+surfaces the headline.
diff --git a/skills/setup-status/render.md b/skills/setup-status/render.md
new file mode 100644
index 0000000..b894184
--- /dev/null
+++ b/skills/setup-status/render.md
@@ -0,0 +1,113 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# render — the adoption dashboard
+
+**The dashboard is rendered deterministically by the collector
+itself — do not hand-rebuild it.** Run:
+
+```bash
+python3 <framework>/skills/setup-status/scripts/collect_status.py --format md
+```
+
+and present that output **verbatim** as the dashboard. The
+`--format md` renderer owns the headline, the full agent-target
+matrix (including the **Reads / agents-served** column and the
+`universal` cluster note), the family roster, and the drift /
+integrity summary. Rendering it in-script is deliberate: an
+LLM-formatted table reliably drops columns (the Reads column in
+particular), so the matrix must not depend on a formatting pass.
+
+After printing the verbatim dashboard, the agent may **add**:
+
+1. A one-line mode-aware interpretation where it helps (see
+ [Mode-aware interpretation](#mode-aware-interpretation)) —
+ without contradicting or re-tabulating the script output.
+2. The reconfigure offer from [`adjust.md`](adjust.md).
+
+The JSON form (`--format json`, see [`collect.md`](collect.md))
+remains available for tooling that wants the raw fields.
+
+## Layout reference (what `--format md` emits)
+
+The sections below document the layout the renderer produces, so a
+reviewer can reason about it. They are **not** a separate
+hand-rendering recipe.
+
+It is **GitHub-flavoured Markdown**: a pipe table for the target
+matrix (narrow columns only) plus a `serves` bullet legend for the
+wide agents-served text (kept out of the table so no column wraps
+and breaks). A self-adopted framework checkout renders like:
+
+```markdown
+## apache-magpie adoption — airflow-steward
+
+**mode:** local (self-adopted) · **pinned:** skills/ · **verdict:** ✅ healthy
+
+### Agent targets
+
+| Target | Dir | Kind | Skills | Status |
+|---|---|---|---|---|
+| universal | `.agents/skills` | canonical-source | 40 | ✅ wired |
+| claude-code | `.claude/skills` | relay | 40 | ✅ wired |
+| github | `.github/skills` | relay | 40 | ✅ wired |
+| windsurf | `.windsurf/skills` | relay | — | ⚪ absent |
+| goose | `.goose/skills` | relay | — | ⚪ absent |
+
+**serves** (which agents read each target dir):
+
+- `universal` — Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode, Cline,
Zed, Warp, …
+- `claude-code` — Claude Code
+- `github` — GitHub's skill loader
+- `windsurf` — Windsurf
+- `goose` — Goose
+
+### Skill families
+
+security ✅ 11 · pr-management ✅ 5 · issue ✅ 5 · always-on setup-*(8) list-*(1)
· other 10
+
+### Drift & integrity
+
+- **drift:** n/a (method:local …) · **snapshot:** in-repo source (local)
+- **overrides:** — · **hook:** —
+- → deep check (integrity, permissions, worktrees): `/magpie-setup verify`
+```
+
+Notes on the format:
+
+- **Status column**: `✅ wired` (all live), `❌ N broken` (dangling
+ symlinks), `⚠️ unwired` (dir present, zero `magpie-*`), `⚪
+ absent` (dir not present). Kept narrow so the table never wraps.
+- **`serves` legend** carries the agents that read each directory —
+ the one wide field, deliberately a bullet list outside the table.
+ `universal` is one directory but a whole cluster, so its bullet
+ names them and the operator sees the framework supports far more
+ than the five target ids.
+- **Verdict** (worst wins, computed by `verdict()` in the
+ collector): `❌` not adopted / `method`|`url` drift / dangling
+ links; `⚠️` `ref` drift / a present-but-unwired target; `✅`
+ otherwise.
+- The target list is **parsed live** from
+ [`../setup/agents.md`](../setup/agents.md), so it stays current
+ as the framework adds vendors. If `registry_source` is
+ `fallback`, agents.md was unreadable and the built-in mirror was
+ used (the renderer prints a stale-list warning). Per-user global
+ paths (`~/.codex/skills/`, …) are out of scope — project-scope
+ adoption only.
+
+## Mode-aware interpretation
+
+The same field means opposite things across adoption modes. Apply
+this before assigning health:
+
+| Signal | `method:local` (self-adoption) | normal adopter (git/svn) |
+|---|---|---|
+| `snapshot.present == false` | ✅ expected — links go to in-repo `skills/` | ❌
snapshot missing → `/magpie-setup upgrade` |
+| `local_lock == null` | ✅ expected — no per-machine fetch | ⚠️ snapshot not
fetched here → `/magpie-setup upgrade` |
+| `gitignore.targets[].all_unignored` | ✅ expected — symlinks are committed |
not the pattern used; ignore |
+| `gitignore.targets[].glob_ignored` + `setup_unignored` | not used | ✅
expected — symlinks gitignored, bootstrap tracked |
+| `drift.checked == false` | ✅ nothing to drift against | depends on `reason`
(see [`collect.md`](collect.md#drift)) |
+
+Never report a self-adopted framework checkout as unhealthy merely
+for lacking a snapshot, a local lock, or ignored symlinks — those
+absences are correct there.
diff --git a/skills/setup-status/scripts/collect_status.py
b/skills/setup-status/scripts/collect_status.py
new file mode 100644
index 0000000..80cf7a1
--- /dev/null
+++ b/skills/setup-status/scripts/collect_status.py
@@ -0,0 +1,477 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Collect the apache-magpie adoption state of a repo as JSON.
+
+Read-only. Enumerates the on-disk adoption artefacts —
+the two lock files, the framework-skill symlinks across every
+agent target, the snapshot, the overrides directory, the
+post-checkout hook, and the .gitignore coverage — and emits a
+single JSON document the ``setup-status`` skill renders into a
+dashboard.
+
+The script never fetches over the network and never writes: the
+upstream-tip drift check and any remediation belong to
+``/magpie-setup verify`` / ``/magpie-setup upgrade``.
+
+Usage::
+
+ python3 collect_status.py [--repo <path>] [--pretty]
+
+``--repo`` defaults to the git top-level of the current directory,
+falling back to the current directory when git is unavailable.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+# The agent-target registry is owned by skills/setup/agents.md
+# ("## The registry") — the single source of truth. At runtime the
+# collector PARSES that table (see load_agent_targets), so adding a
+# vendor row there automatically flows into this dashboard with no
+# edit here. The list below is only a FALLBACK used when agents.md
+# cannot be located or parsed (e.g. a partial snapshot). Keep it a
+# faithful mirror so the fallback degrades gracefully. The first
+# entry is the canonical home every other target relays into;
+# `reads` names the agents that read each directory (per-user
+# global paths like ~/.codex/skills/ are out of scope — adoption is
+# project-scope only).
+_FALLBACK_TARGETS = [
+ (
+ "universal",
+ ".agents/skills",
+ "canonical",
+ "Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode, "
+ "Cline, Zed, Warp, Amp, and the rest of the shared-path cluster",
+ ),
+ ("claude-code", ".claude/skills", "relay", "Claude Code"),
+ ("github", ".github/skills", "relay", "GitHub's skill loader"),
+ ("windsurf", ".windsurf/skills", "relay", "Windsurf"),
+ ("goose", ".goose/skills", "relay", "Goose"),
+]
+
+
+def _strip_md(cell: str) -> str:
+ """Drop backticks and bold markers from a table cell."""
+ return cell.replace("`", "").replace("**", "").strip()
+
+
+def load_agent_targets() -> tuple[list[tuple[str, str, str, str]], str]:
+ """Parse the registry table from the sibling setup skill's
+ agents.md, the single source of truth. Returns (targets,
+ source) where source is "agents.md" on success or "fallback".
+
+ Located relative to this script (`../../setup/agents.md`),
+ which holds in both the framework-source and snapshot layouts
+ since the skill dir structure is identical in each.
+ """
+ agents_md = (Path(__file__).resolve().parent / ".." / ".." / "setup" /
"agents.md")
+ try:
+ text = agents_md.read_text(encoding="utf-8")
+ except OSError:
+ return _FALLBACK_TARGETS, "fallback"
+
+ targets: list[tuple[str, str, str, str]] = []
+ in_table = False
+ for raw in text.splitlines():
+ line = raw.strip()
+ if not line.startswith("|"):
+ in_table = False
+ continue
+ cells = [c.strip() for c in line.strip("|").split("|")]
+ if len(cells) < 4:
+ continue
+ header = _strip_md(cells[0]).lower()
+ if header in ("target id", ""):
+ in_table = header == "target id" or in_table
+ continue
+ if set(_strip_md(cells[0])) <= {"-", ":"}: # the |---| divider
+ continue
+ if not in_table:
+ continue
+ target_id = _strip_md(cells[0])
+ skills_dir = _strip_md(cells[1]).rstrip("/")
+ kind = "canonical" if "canonical" in cells[2].lower() else "relay"
+ reads = _strip_md(cells[3])
+ if target_id and skills_dir:
+ targets.append((target_id, skills_dir, kind, reads))
+
+ if not targets:
+ return _FALLBACK_TARGETS, "fallback"
+ return targets, "agents.md"
+
+# Opt-in families the lock can record; every other prefix is
+# either always-on (setup-*, list-*) or "other".
+OPT_IN_FAMILIES = ["security", "pr-management", "issue"]
+ALWAYS_ON_FAMILIES = ["setup", "list"]
+
+
+def repo_root(explicit: str | None) -> Path:
+ if explicit:
+ return Path(explicit).resolve()
+ try:
+ out = subprocess.run(
+ ["git", "rev-parse", "--show-toplevel"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return Path(out.stdout.strip()).resolve()
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return Path.cwd().resolve()
+
+
+def parse_lock(path: Path) -> dict | None:
+ """Parse a ``key: value`` lock file, dropping comments/blanks."""
+ if not path.is_file():
+ return None
+ data: dict[str, str] = {}
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if ":" not in line:
+ continue
+ key, _, value = line.partition(":")
+ data[key.strip()] = value.strip()
+ return data
+
+
+def family_of(skill: str) -> str:
+ """Map a framework skill source name to its family bucket."""
+ if skill == "setup" or skill.startswith("setup-"):
+ return "setup"
+ if skill.startswith("list-"):
+ return "list"
+ for fam in OPT_IN_FAMILIES:
+ if skill == fam or skill.startswith(fam + "-"):
+ return fam
+ return "other"
+
+
+def link_info(entry: Path, root: Path) -> dict:
+ """Describe one ``magpie-*`` directory entry."""
+ name = entry.name
+ skill = name[len("magpie-") :]
+ info: dict = {"name": name, "skill": skill, "family": family_of(skill)}
+ if not entry.is_symlink():
+ info.update(is_symlink=False, resolves=False, raw_target=None)
+ # A non-symlink magpie-* (e.g. the committed magpie-setup
+ # copy in an adopter repo) still resolves if it holds a
+ # SKILL.md.
+ info["resolves"] = (entry / "SKILL.md").is_file()
+ info["kind"] = "copy" if info["resolves"] else "broken"
+ return info
+ raw = os.readlink(entry)
+ info["raw_target"] = raw
+ info["is_symlink"] = True
+ resolved = (entry.parent / raw).resolve() if not os.path.isabs(raw) else
Path(raw)
+ info["resolves"] = (resolved / "SKILL.md").is_file()
+ # Relay links point back at the canonical .agents/skills home;
+ # canonical links point at the snapshot or the in-repo source.
+ if ".agents/skills/magpie-" in raw:
+ info["kind"] = "relay"
+ elif ".apache-magpie/" in raw:
+ info["kind"] = "canonical-snapshot"
+ else:
+ info["kind"] = "canonical-source"
+ return info
+
+
+def collect_targets(root: Path, registry: list[tuple]) -> list[dict]:
+ targets = []
+ for target_id, rel, expected_kind, reads in registry:
+ d = root / rel
+ rec: dict = {
+ "id": target_id,
+ "dir": rel,
+ "expected_kind": expected_kind,
+ "reads": reads,
+ "present": d.is_dir(),
+ "entries": [],
+ }
+ if d.is_dir():
+ entries = sorted(
+ p for p in d.iterdir() if p.name.startswith("magpie-")
+ )
+ rec["entries"] = [link_info(p, root) for p in entries]
+ rec["magpie_count"] = len(rec["entries"])
+ rec["live_count"] = sum(1 for e in rec["entries"] if e["resolves"])
+ rec["dangling"] = [e["name"] for e in rec["entries"] if not
e["resolves"]]
+ targets.append(rec)
+ return targets
+
+
+def families_installed(canonical_entries: list[dict]) -> dict:
+ """Group the canonical (universal) entries by family."""
+ fam: dict[str, list[str]] = {}
+ for e in canonical_entries:
+ if not e["resolves"]:
+ continue
+ fam.setdefault(e["family"], []).append(e["skill"])
+ for v in fam.values():
+ v.sort()
+ summary = {
+ "opt_in": {f: sorted(fam.get(f, [])) for f in OPT_IN_FAMILIES},
+ "always_on": {f: sorted(fam.get(f, [])) for f in ALWAYS_ON_FAMILIES},
+ "other": sorted(fam.get("other", [])),
+ }
+ summary["opt_in_present"] = [f for f in OPT_IN_FAMILIES if fam.get(f)]
+ summary["opt_in_absent"] = [f for f in OPT_IN_FAMILIES if not fam.get(f)]
+ return summary
+
+
+def compute_drift(committed: dict | None, local: dict | None) -> dict:
+ if committed is None:
+ return {"checked": False, "reason": "not adopted (no committed lock)"}
+ if committed.get("method") == "local":
+ return {"checked": False, "reason": "method:local — no remote snapshot
to drift against"}
+ if local is None:
+ return {"checked": False, "reason": "local lock absent — snapshot not
fetched on this machine"}
+ pairs = [
+ ("method", committed.get("method"), local.get("source_method")),
+ ("url", committed.get("url"), local.get("source_url")),
+ ("ref", committed.get("ref"), local.get("source_ref")),
+ ]
+ mismatches = [
+ {"field": f, "committed": c, "local": l}
+ for f, c, l in pairs
+ if c is not None and l is not None and c != l
+ ]
+ return {
+ "checked": True,
+ "in_sync": not mismatches,
+ "mismatches": mismatches,
+ "note": "upstream-tip check for git-branch needs network — run
/magpie-setup verify",
+ }
+
+
+def gitignore_coverage(root: Path, targets: list[dict]) -> dict:
+ gi = root / ".gitignore"
+ text = gi.read_text(encoding="utf-8") if gi.is_file() else ""
+ lines = {ln.strip() for ln in text.splitlines()}
+ cov = {
+ "present": gi.is_file(),
+ "snapshot_ignored": "/.apache-magpie/" in lines,
+ "local_lock_ignored": "/.apache-magpie.local.lock" in lines,
+ "settings_local_ignored": "/.claude/settings.local.json" in lines,
+ "targets": {},
+ }
+ for t in targets:
+ if not t["present"]:
+ continue
+ glob = f"/{t['dir']}/magpie-*"
+ keep_setup = f"!/{t['dir']}/magpie-setup"
+ keep_all = f"!/{t['dir']}/magpie-*"
+ cov["targets"][t["id"]] = {
+ # normal-adopter pattern: ignore the relayed/snapshot
+ # symlinks, un-ignore only the committed bootstrap.
+ "glob_ignored": glob in lines,
+ "setup_unignored": keep_setup in lines,
+ # self-adoption pattern: every magpie-* symlink is
+ # committed, so the whole glob is un-ignored.
+ "all_unignored": keep_all in lines,
+ }
+ return cov
+
+
+def hook_status(root: Path) -> dict:
+ hook = root / ".git" / "hooks" / "post-checkout"
+ if not hook.is_file():
+ return {"present": False}
+ content = hook.read_text(encoding="utf-8", errors="replace")
+ return {
+ "present": True,
+ "executable": os.access(hook, os.X_OK),
+ "has_verify_recipe": "magpie-setup verify" in content,
+ }
+
+
+def verdict(d: dict) -> str:
+ """A one-line health verdict, computed deterministically."""
+ if not d["adopted"]:
+ return "❌ not adopted"
+ bad = warn = False
+ for t in d["agent_targets"]:
+ if t["present"] and t["dangling"]:
+ bad = True
+ if t["present"] and t["magpie_count"] == 0:
+ warn = True
+ dr = d["drift"]
+ if dr.get("checked") and not dr.get("in_sync"):
+ fields = {m["field"] for m in dr.get("mismatches", [])}
+ if fields & {"method", "url"}:
+ bad = True
+ elif fields:
+ warn = True
+ if bad:
+ return "❌ action needed"
+ if warn:
+ return "⚠️ needs attention"
+ return "✅ healthy"
+
+
+def render_markdown(d: dict) -> str:
+ """Render the deterministic dashboard as GitHub-flavoured
+ Markdown: a pipe table for the agent-target matrix (narrow
+ columns only) plus a `serves` bullet legend for the wide
+ agents-served text. Keeping that one wide field out of the table
+ is what stops the table from wrapping and breaking."""
+ repo = os.path.basename(d["repo"].rstrip("/")) or d["repo"]
+ cl = d["committed_lock"] or {}
+ pin = cl.get("ref") or cl.get("source") or cl.get("url") or "—"
+ mode = d["mode"] or "—"
+ if d["self_adopted"]:
+ mode += " (self-adopted)"
+
+ out: list[str] = []
+ out.append(f"## apache-magpie adoption — {repo}")
+ out.append("")
+ out.append(f"**mode:** {mode} · **pinned:** {pin} · **verdict:**
{verdict(d)}")
+ out.append("")
+
+ # Agent targets — a Markdown pipe table (narrow columns); the
+ # wide agents-served text goes in the bullet legend below.
+ out.append("### Agent targets")
+ out.append("")
+ out.append("| Target | Dir | Kind | Skills | Status |")
+ out.append("|---|---|---|---|---|")
+ for t in d["agent_targets"]:
+ if not t["present"]:
+ status, skills = "⚪ absent", "—"
+ elif t["magpie_count"] == 0:
+ status, skills = "⚠️ unwired", "0"
+ elif t["dangling"]:
+ status, skills = f"❌ {len(t['dangling'])} broken",
str(t["magpie_count"])
+ else:
+ status, skills = "✅ wired", str(t["magpie_count"])
+ kind = (sorted({e["kind"] for e in t["entries"]}) or
[t["expected_kind"]])[0]
+ out.append(f"| {t['id']} | `{t['dir']}` | {kind} | {skills} | {status}
|")
+ out.append("")
+ out.append("**serves** (which agents read each target dir):")
+ out.append("")
+ for t in d["agent_targets"]:
+ out.append(f"- `{t['id']}` — {t['reads']}")
+ if d["registry_source"] != "agents.md":
+ out.append("- ⚠️ registry from built-in fallback (agents.md
unreadable) — may be stale")
+ out.append("")
+
+ # Skill families — a Markdown table.
+ fam = d["families"]
+ out.append("### Skill families")
+ out.append("")
+ out.append("| Family | Type | Installed |")
+ out.append("|---|---|---|")
+ for f in ("security", "pr-management", "issue"):
+ n = len(fam["opt_in"][f])
+ out.append(f"| {f} | opt-in | {'✅ ' + str(n) if n else '— none'} |")
+ out.append(f"| setup-* | always-on | {len(fam['always_on']['setup'])} |")
+ out.append(f"| list-* | always-on | {len(fam['always_on']['list'])} |")
+ out.append(f"| other | — | {len(fam['other'])} |")
+ out.append("")
+
+ # Drift & integrity.
+ dr = d["drift"]
+ if dr.get("in_sync"):
+ drift_line = "✅ in sync"
+ elif not dr.get("checked"):
+ drift_line = f"n/a ({dr.get('reason', '')})"
+ else:
+ drift_line = "⚠️ drift → `/magpie-setup upgrade`"
+ if d["self_adopted"]:
+ snap = "in-repo source (local)"
+ elif d["snapshot"]["present"]:
+ snap = "present"
+ else:
+ snap = "❌ missing"
+ out.append("### Drift & integrity")
+ out.append("")
+ out.append(f"- **drift:** {drift_line} · **snapshot:** {snap}")
+ out.append(
+ f"- **overrides:** {'present' if d['overrides']['present'] else '—'} ·
"
+ f"**hook:** {'installed' if d['post_checkout_hook']['present'] else
'—'}"
+ )
+ out.append("- → deep check (integrity, permissions, worktrees):
`/magpie-setup verify`")
+ return "\n".join(out) + "\n"
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--repo", default=None, help="repo root (default: git
top-level)")
+ parser.add_argument(
+ "--format",
+ choices=["json", "md"],
+ default="md",
+ help="md (default, the deterministic dashboard) or json
(machine-readable)",
+ )
+ parser.add_argument("--pretty", action="store_true", help="indent the JSON
output")
+ args = parser.parse_args(argv)
+
+ root = repo_root(args.repo)
+ committed = parse_lock(root / ".apache-magpie.lock")
+ local = parse_lock(root / ".apache-magpie.local.lock")
+
+ registry, registry_source = load_agent_targets()
+ targets = collect_targets(root, registry)
+ canonical = next(
+ (t for t in targets if t["expected_kind"] == "canonical"),
+ targets[0] if targets else {"entries": []},
+ )
+
+ snapshot = root / ".apache-magpie"
+ method = committed.get("method") if committed else None
+
+ result = {
+ "repo": str(root),
+ "adopted": committed is not None,
+ "mode": method,
+ "self_adopted": method == "local",
+ "committed_lock": committed,
+ "local_lock": local,
+ "snapshot": {
+ "present": snapshot.exists(),
+ "is_symlink": snapshot.is_symlink(),
+ },
+ "drift": compute_drift(committed, local),
+ "registry_source": registry_source,
+ "agent_targets": targets,
+ "active_target_ids": [t["id"] for t in targets if t["present"]],
+ "families": families_installed(canonical["entries"]),
+ "overrides": {
+ "present": (root / ".apache-magpie-overrides").is_dir(),
+ "has_readme": (root / ".apache-magpie-overrides" /
"README.md").is_file(),
+ },
+ "post_checkout_hook": hook_status(root),
+ "gitignore": gitignore_coverage(root, targets),
+ }
+
+ if args.format == "md":
+ sys.stdout.write(render_markdown(result))
+ else:
+ json.dump(result, sys.stdout, indent=2 if args.pretty else None,
sort_keys=False)
+ sys.stdout.write("\n")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())