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())


Reply via email to