Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/setup-steward/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ proposed `/setup-steward upgrade`.
| [`adopt.md`](adopt.md) | First-time adoption walk-through — recognise existing-snapshot vs needs-bootstrap, write the two lock files, ask the user which skill families to wire up, create the gitignored symlinks, scaffold `.apache-steward-overrides/`, install the post-checkout hook, update project docs. The default sub-action. |
| [`upgrade.md`](upgrade.md) | Refresh the gitignored snapshot per the committed lock, reconcile any agentic overrides + symlinks against the new framework structure, surface conflicts. Drives the on-drift remediation flow. |
| [`verify.md`](verify.md) | Read-only health check — snapshot present + intact, both lock files in sync, symlinks point at live targets, `.gitignore` correct, `.apache-steward-overrides/` exists, drift status (committed vs local), the `setup-steward` skill itself is current. |
| [`conventions.md`](conventions.md) | Adopter skills-dir convention auto-detection — flat `.claude/skills/<n>/`, the `.claude/skills/<n>` → `.github/skills/<n>/` double-symlink pattern (e.g. apache/airflow), or neither yet. |
| [`conventions.md`](conventions.md) | Adopter skills-dir convention auto-detection — four patterns: A (flat `.claude/skills/<n>/`), B (per-skill `.claude/skills/<n>` → `.github/skills/<n>/` double-symlink), C (none yet), D (single directory symlink where one of `.claude/skills` / `.github/skills` is itself a symlink to the other; two orientations). |
| [`overrides.md`](overrides.md) | Agentic-override file management — open / scaffold an override for a framework skill, list existing overrides, help reconcile when the framework changes the underlying skill's structure on upgrade. |
| [`unadopt.md`](unadopt.md) | Reverse the adoption — remove snapshot, locks, symlinks, post-checkout hook, `.gitignore` entries, the adoption sections in `README.md` / `AGENTS.md` / `CONTRIBUTING.md`, and the committed `setup-steward` skill itself. Preserves `.apache-steward-overrides/` by default; `--purge-overrides` removes it too. Surfaces the full removal plan before any write. |

Expand Down
152 changes: 123 additions & 29 deletions .claude/skills/setup-steward/adopt.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@ between automatically:
result as `<adopter-skills-dir>` for the rest of this
flow.

If detection returns *"ambiguous → propose Pattern D
consolidation"* (both `.claude/skills/` and
`.github/skills/` exist as regular directories with
independent, non-aliased content), run the
**Pre-Pattern-D consolidation** flow described under
[section D of `conventions.md`](conventions.md#d-single-directory-symlink--one-of-claudeskills--githubskills-is-a-symlink-to-the-other)
before continuing:

- List the skills in each directory with their content
fingerprint (real dir vs symlink, target if symlink,
SKILL.md presence).
- Flag any name collisions where the two sides have
different content for the same name.
- Use a structured prompt (`AskUserQuestion` when the
harness offers one) with three options: **D.1**
(consolidate under `.github/skills/`), **D.2**
(consolidate under `.claude/skills/`), or **decline**
(fall back to Pattern A treating `.claude/skills/` as
canonical and leaving `.github/skills/` alone).
- On D.1 / D.2 confirmation: move every skill from the
side that will become the symlink into the side that
will become the real directory (resolving any flagged
name collisions first — never auto-rename adopter
content), then replace the now-empty side with a
relative symlink to the other side, then re-run
detection to confirm the pattern is now D.
- If the user declines or unresolved name collisions
block consolidation, fall back to Pattern A and pin
`<adopter-skills-dir>` = `.claude/skills/` as usual.

The consolidation is a one-time, deliberate layout
change; the adopt flow surfaces every step before
writing.

## Step 1 — Detect adoption shape

```text
Expand Down Expand Up @@ -284,26 +318,74 @@ fetched_at: <ISO-8601 timestamp>
The bootstrap recipe wrote these already; this step is
idempotent — re-add them if they're missing.

**Base entries — always needed**:

```text
/.apache-steward/
/.apache-steward.local.lock
/.claude/settings.local.json
/.claude/skills/security-*
/.claude/skills/pr-management-*
/.claude/skills/issue-*
/.claude/skills/setup-isolated-setup-*
/.claude/skills/setup-override-upstream
/.claude/skills/setup-shared-config-sync
/.claude/skills/list-steward-*
/.github/skills/security-*
/.github/skills/pr-management-*
/.github/skills/issue-*
/.github/skills/setup-isolated-setup-*
/.github/skills/setup-override-upstream
/.github/skills/setup-shared-config-sync
/.github/skills/list-steward-*
```

**Symlink-pattern entries — vary by adopter
[skills-dir convention](conventions.md)**:

- **Pattern A (flat)** — only the `.claude/skills/...` lines:

```text
/.claude/skills/security-*
/.claude/skills/pr-management-*
/.claude/skills/issue-*
/.claude/skills/setup-isolated-setup-*
/.claude/skills/setup-override-upstream
/.claude/skills/setup-shared-config-sync
/.claude/skills/list-steward-*
```

- **Pattern B (double-symlinked)** — both `.claude/skills/...`
AND `.github/skills/...` lines, because each framework skill
has two physical symlinks (outer at `.claude/skills/<n>`,
inner at `.github/skills/<n>`):

```text
/.claude/skills/security-*
/.claude/skills/pr-management-*
/.claude/skills/issue-*
/.claude/skills/setup-isolated-setup-*
/.claude/skills/setup-override-upstream
/.claude/skills/setup-shared-config-sync
/.claude/skills/list-steward-*
/.github/skills/security-*
/.github/skills/pr-management-*
/.github/skills/issue-*
/.github/skills/setup-isolated-setup-*
/.github/skills/setup-override-upstream
/.github/skills/setup-shared-config-sync
/.github/skills/list-steward-*
```

- **Pattern D (single directory symlink)** — only the
*canonical-side* `.../skills/...` lines. With D.1
(canonical = `.github/skills/`):

```text
/.github/skills/security-*
/.github/skills/pr-management-*
/.github/skills/issue-*
/.github/skills/setup-isolated-setup-*
/.github/skills/setup-override-upstream
/.github/skills/setup-shared-config-sync
/.github/skills/list-steward-*
```

With D.2 (canonical = `.claude/skills/`), mirror the same
list under `.claude/skills/` instead. Pattern D does not
need ignore lines on the *symlinked* side because that side
is itself a single tracked symlink — git does not descend
into it, so the symlinked-side paths match no tracked file.

- **Pattern C (none yet)** — same as the pattern the user
picks during adopt (defaults to A).

The `setup-override-upstream`, `setup-shared-config-sync`,
`setup-isolated-setup-*`, and `list-steward-*` entries are
the always-on families per
Expand All @@ -320,9 +402,6 @@ that each worktree carries independently). Most adopters
already gitignore this file by Claude Code convention; the
adopt flow checks for the line and adds it if missing.

Mirror under `.github/skills/` only if the adopter uses the
double-symlinked convention.

## Step 8 — Wire up the framework-skill symlinks

The skill walks `<snapshot-dir>/.claude/skills/` and creates
Expand All @@ -349,11 +428,24 @@ adoption path where the committed lock only records the
opt-in pick. Compute the family glob fresh from the snapshot
contents on disk — do not hard-code skill names.

If the adopter uses the double-symlinked convention
(see [`conventions.md`](conventions.md)), create both
layers — the inner one in `.github/skills/` points at the
snapshot, the outer `.claude/skills/` points at the
inner. Both gitignored.
Per-pattern symlink wiring (see
[`conventions.md`](conventions.md)):

- **Pattern A (flat)** — one symlink per skill at
`.claude/skills/<n>` → snapshot. Gitignored.
- **Pattern B (double-symlinked)** — two symlinks per skill:
the inner one in `.github/skills/<n>` → snapshot, the outer
`.claude/skills/<n>` → `../../.github/skills/<n>/`. Both
gitignored.
- **Pattern D (single directory symlink)** — one symlink per
skill at the *canonical-side* `<canonical>/skills/<n>` →
snapshot. **Skip the symlinked side entirely** — one of
`.claude/skills` / `.github/skills` is itself a directory
symlink into the other, so the symlinked-side path is
automatically resolved. With D.1 the canonical side is
`.github/skills/`; with D.2 it is `.claude/skills/`.
Gitignored.
- **Pattern C (none yet)** — same as A.

**Never overwrite an existing committed skill** of the same
name. Surface conflicts and stop. `setup-steward` itself is
Expand Down Expand Up @@ -662,8 +754,8 @@ framework before they hit a "skill not found" error:
Trim the skill-family list to what was actually picked in
Step 5 (only mention `security-*` if the adopter installed
that family, etc.). Adjust the skill paths to the adopter's
convention (flat vs double-symlinked — see
[`conventions.md`](conventions.md)). Skip this sub-step
convention (flat / double-symlinked / single-directory-symlink
— see [`conventions.md`](conventions.md)). Skip this sub-step
entirely if `README.md` does not exist.

2. **`AGENTS.md` (agent-facing detail, ONLY if the file
Expand Down Expand Up @@ -871,11 +963,13 @@ Committed (you'll see in `git status`):
Gitignored (do NOT commit):
.apache-steward/
.apache-steward.local.lock
.claude/skills/{security,pr-management}-* # opt-in families
.claude/skills/setup-isolated-setup-* # always-on
.claude/skills/{setup-override-upstream,setup-shared-config-sync} # always-on
.claude/skills/list-steward-* # always-on
(and same patterns under .github/skills/ for double-symlinked layouts)
<adopter-skills-dir>/{security,pr-management}-* # opt-in families
<adopter-skills-dir>/setup-isolated-setup-* # always-on
<adopter-skills-dir>/{setup-override-upstream,setup-shared-config-sync} # always-on
<adopter-skills-dir>/list-steward-* # always-on
# Pattern A: <adopter-skills-dir> = .claude/skills/
# Pattern B: <adopter-skills-dir> = both .claude/skills/ AND .github/skills/
# Pattern D: <adopter-skills-dir> = .github/skills/ only
```

Then suggest the user `git add` the committed files and open
Expand Down
152 changes: 150 additions & 2 deletions .claude/skills/setup-steward/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,148 @@ The skill creates the directory layout the adopter prefers
(default: pattern A, flat — simpler). If the user has a
preference, they say so during the adopt flow.

### D. Single directory symlink — one of `.claude/skills` / `.github/skills` is a symlink to the other

```text
# D.1 — content under .github/skills/, .claude/skills is the symlink:
<repo-root>/
├── .claude/
│ └── skills → ../.github/skills/
└── .github/
└── skills/
├── <native-skill>/
│ └── SKILL.md
├── <framework-symlink> → ../../.apache-steward/.claude/skills/<framework-skill>/
└── ...
```

```text
# D.2 — content under .claude/skills/, .github/skills is the symlink:
<repo-root>/
├── .claude/
│ └── skills/
│ ├── <native-skill>/
│ │ └── SKILL.md
│ ├── <framework-symlink> → ../../.apache-steward/.claude/skills/<framework-skill>/
│ └── ...
└── .github/
└── skills → ../.claude/skills/
```

A simplification of Pattern B: instead of one per-skill
symlink mirroring every entry from one directory to the
other, **one of the two directories is itself a symlink to
the other**. Both `.claude/skills/<n>` and
`.github/skills/<n>` always resolve to the same content for
every skill — the project's native skills and the framework's
gitignored symlinks alike — without any per-skill plumbing.
Adding a new skill (project-native or framework) just means
adding it once in the canonical directory; the mirror is
automatic.

**Two orientations** — same shape, opposite direction:

- **D.1** — content lives under `.github/skills/`,
`.claude/skills` is the symlink. The natural choice for
projects whose canonical skills directory is `.github/`
(e.g. apache/airflow, which uses `.github/` as its
infra-glue root and `.claude/` as a Claude-Code-facing
view).
- **D.2** — content lives under `.claude/skills/`,
`.github/skills` is the symlink. The natural choice for
projects whose canonical skills directory is `.claude/`
(e.g. a Pattern A project that wants `.github/skills/`
available too without duplicating content).

**Detection signal**: exactly one of `.claude/skills` /
`.github/skills` is a symlink (test with `[ -L <path> ]` /
`readlink <path>`) and resolves to the other path in the same
repo. Either orientation counts as Pattern D.

For framework symlinks: create them at **only one layer** —
the *real* directory side, never the symlinked side. With
D.1 that means `.github/skills/<n>` → relative path into
`.apache-steward/.claude/skills/<n>/`; with D.2 it means
`.claude/skills/<n>` → the same. The opposite path is
automatically the same content via the directory symlink.

Gitignore consequences: only entries on the real-directory
side are needed (e.g. `/.github/skills/security-*` for D.1,
or `/.claude/skills/security-*` for D.2). Git treats the
symlinked side as a single tracked symlink and does not
descend into it, so ignore entries on that side would match
no actual tracked path and are unnecessary.

The directory symlink itself is **adopter-owned** — created
deliberately by the adopter as part of the project's layout
choice, and not touched by `/setup-steward unadopt`. The
framework treats it the same way it treats the real-directory
side: as part of the surrounding repo layout.

**Pre-Pattern-D consolidation** — if both `.claude/skills/`
and `.github/skills/` exist as **regular directories** (not
yet symlinked to each other) and contain skill content that
is not already aliased through symlinks, the adopt flow
**does not silently apply Pattern D**. Each directory's
contents are an independent set; turning one into a symlink
to the other would clobber the symlinked side's content. The
flow surfaces the conflict and offers a consolidation prompt:

1. List the skills present in each directory (real
directories, regular files, and any non-Pattern-B
symlinks).
2. Flag name collisions where the same skill name exists in
both directories with different content.
3. Ask the user to pick D.1 or D.2 and confirm the
consolidation steps:
- Move every skill from the side that will become the
symlink into the side that will become the real
directory, resolving any flagged name collisions first.
- Replace the now-empty side with a relative symlink to
the other side.
4. Only after the consolidation is complete does the adopt
flow proceed to wire framework symlinks at the chosen
real-directory side.

If the consolidation cannot proceed (unresolved name
collisions the user has not addressed), the adopt flow stops
and lets the user resolve in their own commit before
re-invoking — the framework never auto-renames adopter-owned
content.

## Detection algorithm

```text
if .claude/skills/ exists:
# Pattern D first — either orientation:
if .claude/skills is a symlink:
if it resolves to .github/skills/ in the same repo:
pattern = D.1 (single directory symlink; canonical = .github/skills/)
else:
# operator pointed `.claude/skills` somewhere else
# deliberately; surface, do not guess.
pattern = ambiguous → prompt the user
elif .github/skills is a symlink:
if it resolves to .claude/skills/ in the same repo:
pattern = D.2 (single directory symlink; canonical = .claude/skills/)
else:
# same — surface the unexpected target, do not guess.
pattern = ambiguous → prompt the user

# Otherwise fall through to A / B / C:
elif .claude/skills/ exists (regular directory):
if any entry in .claude/skills/ is a symlink resolving
into .github/skills/:
pattern = B (double-symlinked)
else:
pattern = A (flat)
if .github/skills/ also exists as a regular directory
with independent content:
pattern = ambiguous → propose Pattern D
consolidation (see *Pre-Pattern-D
consolidation* under section D
above), with A as the fallback
if the user declines
else:
pattern = A (flat)
elif .github/skills/ exists:
pattern = B (the user has a `.github/skills/` half but
hasn't wired up `.claude/` yet — the adopt
Expand All @@ -114,6 +247,8 @@ else:
| A — flat | `.claude/skills/` | None |
| B — double-symlinked | `.github/skills/` (the inner layer); `.claude/skills/` symlinks to it | If `.github/skills/<n>` for a framework skill already exists as a real directory (an old in-repo copy), refuse and let the user resolve |
| C — none yet | `.claude/skills/` | Create the directory |
| D.1 — single directory symlink, canonical `.github/skills/` | `.github/skills/` (the only layer; `.claude/skills` resolves into it via the directory symlink) | None — no outer-layer plumbing to create |
| D.2 — single directory symlink, canonical `.claude/skills/` | `.claude/skills/` (the only layer; `.github/skills` resolves into it via the directory symlink) | None — no outer-layer plumbing to create |

## Ambiguous cases

Expand All @@ -126,3 +261,16 @@ else:
consistency. If the user wants absolute, they say so;
otherwise relative is the default — it survives a repo
move.
- **`.claude/skills` (or `.github/skills`) is a symlink but
resolves outside the repo or to a path other than the
expected counterpart directory**. The operator pointed it
somewhere deliberately (e.g. a sibling worktree). The
adopt flow surfaces the resolved target and asks the user;
it does not match Pattern D automatically.
- **Both `.claude/skills/` and `.github/skills/` exist as
regular directories with independent (non-aliased)
content**. Surfaced as a Pattern D consolidation
opportunity per the **Pre-Pattern-D consolidation** flow
under section D above. The user picks D.1 or D.2 (or
declines, in which case the flow falls back to Pattern A
treating `.claude/skills/` as canonical).
Loading
Loading