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 b9e1967 setup-steward: support single directory-symlink layout
(Pattern D) (#247)
b9e1967 is described below
commit b9e19677a18ebbe77b578ac02ffdca7cdd4da5af
Author: Jarek Potiuk <[email protected]>
AuthorDate: Fri May 22 16:43:55 2026 +0200
setup-steward: support single directory-symlink layout (Pattern D) (#247)
Adds Pattern D to the adopter skills-dir conventions: one of
`.claude/skills` / `.github/skills` is itself a directory symlink
to the other, so both paths always reflect the same set of skills
without per-skill plumbing. Two orientations:
- **D.1** — canonical content under `.github/skills/`,
`.claude/skills` is the symlink. Natural for projects that
use `.github/` as their canonical infra-glue root (e.g.
apache/airflow).
- **D.2** — canonical content under `.claude/skills/`,
`.github/skills` is the symlink. Natural for flat-Pattern-A
projects that want a `.github/skills/` view too.
Pattern D coexists with project-native skills and framework
symlinks in the same canonical directory — the directory
symlink fans them out automatically.
Pre-Pattern-D consolidation: when both directories exist as
regular directories with independent content (the would-be
clobber-on-symlink case), the adopt flow surfaces the conflict
and proposes a one-time consolidation — move every skill into
one side, replace the other with a directory symlink — before
wiring any framework symlink. Never auto-renames adopter
content; falls back to Pattern A if the user declines.
Updates to per-flow handling:
- `conventions.md` — Pattern D section + two orientations, new
detection-algorithm branches, table row, ambiguous-case rules,
consolidation flow.
- `adopt.md` — Step 0.4 calls into the consolidation flow when
detection is ambiguous; Step 7 splits the `.gitignore` template
per pattern (Pattern D only needs the canonical-side lines);
Step 8 wires symlinks at the canonical side only for Pattern D.
- `upgrade.md` — Step 4b adds a Pattern D branch (overwrite
`setup-steward` at the canonical side; the directory symlink
resolves the other side automatically); Step 6 refreshes only
the canonical-side per-skill symlinks under Pattern D.
- `verify.md` — Check 4 splits the required `.gitignore` entries
per pattern.
- `unadopt.md` — Inventory + execute-removal split per pattern;
the directory symlink itself is adopter-owned and preserved.
- `worktree-init.md` — Per-pattern layer count for the worktree's
framework-skill symlinks.
- `docs/setup/install-recipes.md`, `docs/setup/unadopt.md`,
`SKILL.md` — same per-pattern guidance reflected in the
contributor-facing docs.
---
.claude/skills/setup-steward/SKILL.md | 2 +-
.claude/skills/setup-steward/adopt.md | 152 +++++++++++++++++++++-----
.claude/skills/setup-steward/conventions.md | 152 +++++++++++++++++++++++++-
.claude/skills/setup-steward/unadopt.md | 28 ++++-
.claude/skills/setup-steward/upgrade.md | 56 ++++++++--
.claude/skills/setup-steward/verify.md | 21 +++-
.claude/skills/setup-steward/worktree-init.md | 23 +++-
docs/setup/install-recipes.md | 54 +++++++--
docs/setup/unadopt.md | 16 ++-
9 files changed, 430 insertions(+), 74 deletions(-)
diff --git a/.claude/skills/setup-steward/SKILL.md
b/.claude/skills/setup-steward/SKILL.md
index 84ac1fd..301803f 100644
--- a/.claude/skills/setup-steward/SKILL.md
+++ b/.claude/skills/setup-steward/SKILL.md
@@ -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. |
diff --git a/.claude/skills/setup-steward/adopt.md
b/.claude/skills/setup-steward/adopt.md
index b9d9e9b..99fe5a1 100644
--- a/.claude/skills/setup-steward/adopt.md
+++ b/.claude/skills/setup-steward/adopt.md
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/.claude/skills/setup-steward/conventions.md
b/.claude/skills/setup-steward/conventions.md
index 29775f4..f870316 100644
--- a/.claude/skills/setup-steward/conventions.md
+++ b/.claude/skills/setup-steward/conventions.md
@@ -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
@@ -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
@@ -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).
diff --git a/.claude/skills/setup-steward/unadopt.md
b/.claude/skills/setup-steward/unadopt.md
index e04005a..2743321 100644
--- a/.claude/skills/setup-steward/unadopt.md
+++ b/.claude/skills/setup-steward/unadopt.md
@@ -88,7 +88,7 @@ every artefact).
| Local lock | `<local-lock>` | exists |
| Committed lock | `<committed-lock>` | exists |
| `.gitignore` entries | `<repo-root>/.gitignore` | which of the entries from
[`adopt.md` Step 7](adopt.md) are present |
-| Framework-skill symlinks | `<adopter-skills-dir>/` (and `.github/skills/` if
double-symlinked) | each symlink whose target resolves into
`<snapshot-dir>/.claude/skills/` |
+| Framework-skill symlinks | `<adopter-skills-dir>/` — both layers under
Pattern B; canonical side only under Pattern D (D.1: `.github/skills/`; D.2:
`.claude/skills/`); single layer under Pattern A | each symlink whose target
resolves into `<snapshot-dir>/.claude/skills/` |
| Post-checkout hook | `<repo-root>/.git/hooks/post-checkout` | exists +
invokes `~/.claude/scripts/sandbox-add-project-root.sh` |
| Doc section: `README.md` | `<repo-root>/README.md` | contains the `##
Agent-assisted contribution (apache-steward)` heading |
| Doc section: `AGENTS.md` | `<repo-root>/AGENTS.md` | contains the `##
apache-steward framework` heading |
@@ -117,8 +117,12 @@ The following will be REMOVED:
.apache-steward.local.lock
<adopter-skills-dir>/<symlink-1> →
.apache-steward/.claude/skills/<skill-1>/
<adopter-skills-dir>/<symlink-2> → ...
- .github/skills/<symlink-1> (if double-symlinked layout)
+ .github/skills/<symlink-1> (Pattern B only — second physical
layer)
.git/hooks/post-checkout (if it contains the steward recipe)
+ # Pattern A: <adopter-skills-dir> = .claude/skills/
+ # Pattern B: <adopter-skills-dir> spans .claude/skills/ AND
.github/skills/
+ # Pattern D: <adopter-skills-dir> = canonical side only
+ # (D.1: .github/skills/; D.2: .claude/skills/)
Committed (will show in `git status`):
.apache-steward.lock (the project's pin)
@@ -188,9 +192,20 @@ half-completed unadopt never leaves a dangling symlink
pointing at a deleted snapshot.
1. **Framework-skill symlinks.** For each entry in the
- inventory, `rm` the symlink. If the adopter uses the
- double-symlinked convention, remove both layers. Never
- touch a non-symlink at the same path.
+ inventory, `rm` the symlink. Per-pattern:
+
+ - **Pattern A** — one layer; just remove
+ `.claude/skills/<n>`.
+ - **Pattern B** — two layers; remove both
+ `.claude/skills/<n>` and `.github/skills/<n>`.
+ - **Pattern D** — one layer at the canonical side
+ (D.1: `.github/skills/<n>`; D.2: `.claude/skills/<n>`).
+ The directory symlink itself (`.claude/skills` or
+ `.github/skills`) is **adopter-owned** and **not
+ removed by unadopt** — it predates framework adoption
+ and serves the adopter's own native skills too.
+
+ Never touch a non-symlink at the same path.
2. **Post-checkout hook.** Remove only if its content matches
the steward recipe verbatim (i.e. the hook the adopt flow
wrote — a single
@@ -262,7 +277,7 @@ A summary of what was removed + what remains:
```text
✓ Snapshot removed: .apache-steward/
✓ Locks removed: .apache-steward.lock, .apache-steward.local.lock
-✓ Symlinks removed: <count> (under <adopter-skills-dir>/[,
.github/skills/])
+✓ Symlinks removed: <count> (per-pattern — A: under .claude/skills/; B:
under both .claude/skills/ AND .github/skills/; D: under the canonical side
only)
✓ Post-checkout hook: removed (or: preserved — contained extra adopter
logic)
✓ Doc sections removed: README.md[, AGENTS.md][, CONTRIBUTING.md]
✓ .gitignore cleaned: <N> entries removed
@@ -271,6 +286,7 @@ A summary of what was removed + what remains:
Preserved:
.apache-steward-overrides/ (M files; pass `--purge-overrides` to remove)
~/.config/apache-steward/user.md (per-user; shared with other adopters on
this machine — remove manually if this was your last adoption)
+ .claude/skills (or .github/skills) (Pattern D directory symlink —
adopter-owned, predates framework adoption)
<list of any non-steward-owned content the plan flagged>
Staged for commit (you'll see in `git status`):
diff --git a/.claude/skills/setup-steward/upgrade.md
b/.claude/skills/setup-steward/upgrade.md
index 9112ea2..2544645 100644
--- a/.claude/skills/setup-steward/upgrade.md
+++ b/.claude/skills/setup-steward/upgrade.md
@@ -160,18 +160,29 @@ bootstrap logic. It implements
over the committed copy:
```bash
- # For the flat layout:
+ # For the flat layout (Pattern A):
rm -rf .claude/skills/setup-steward
cp -r .apache-steward/.claude/skills/setup-steward \
.claude/skills/setup-steward
- # For the double-symlinked layout (e.g. apache/airflow):
+ # For the double-symlinked layout (Pattern B):
rm -rf .github/skills/setup-steward
cp -r .apache-steward/.claude/skills/setup-steward \
.github/skills/setup-steward
- # The .claude/skills/setup-steward symlink does not need
- # touching — it points at .github/skills/setup-steward
+ # The .claude/skills/setup-steward per-skill symlink does
+ # not need touching — it points at .github/skills/setup-steward
# which is now the new content.
+
+ # For the single directory-symlink layout (Pattern D),
+ # write to the *canonical* side only. With D.1
+ # (canonical = .github/skills/):
+ rm -rf .github/skills/setup-steward
+ cp -r .apache-steward/.claude/skills/setup-steward \
+ .github/skills/setup-steward
+ # With D.2 (canonical = .claude/skills/), write to
+ # .claude/skills/setup-steward instead. Either way: the
+ # symlinked side resolves to the refreshed content
+ # automatically — nothing to touch there.
```
4. **Reload in-flight.** Immediately after the copy lands —
@@ -259,12 +270,23 @@ family, reconcile the adopter's `.gitignore` so the new
family's snapshot symlinks are gitignored. Append the
`.gitignore` lines from
[`adopt.md` Step 7](adopt.md#step-7--gitignore-entries-fresh-only)
-for the new family's prefix (e.g. `/.claude/skills/issue-*`
-and the `.github/skills/` mirror when the adopter uses the
-double-symlinked convention). The append is idempotent —
-skip lines that already exist. The same idempotence covers
-adopters whose `.gitignore` already had the entries (e.g.
-from a manually-edited block or a previous adopt run).
+for the new family's prefix, matching the adopter's
+[skills-dir convention](conventions.md):
+
+- Pattern A — `/.claude/skills/<prefix>-*` only.
+- Pattern B — both `/.claude/skills/<prefix>-*` and
+ `/.github/skills/<prefix>-*` (two physical symlinks per
+ skill).
+- Pattern D — only the *canonical-side* `<canonical>/<prefix>-*`
+ ignore line. D.1 → `/.github/skills/<prefix>-*`; D.2 →
+ `/.claude/skills/<prefix>-*`. The symlinked side's
+ directory symlink does not need its own ignore line — git
+ does not descend into it.
+
+The append is idempotent — skip lines that already exist.
+The same idempotence covers adopters whose `.gitignore`
+already had the entries (e.g. from a manually-edited block
+or a previous adopt run).
The post-upgrade state must be: *every framework skill in
the new snapshot that belongs to the effective family set
@@ -301,7 +323,19 @@ Run two passes:
release notes), offer to re-symlink to the new name.
- If removed, offer to remove the stale symlink.
-For the double-symlinked convention, refresh both layers.
+Per-pattern symlink layers to refresh:
+
+- **Pattern A (flat)** — refresh the single layer at
+ `.claude/skills/<n>`.
+- **Pattern B (double-symlinked)** — refresh both layers
+ (inner at `.github/skills/<n>`, outer at
+ `.claude/skills/<n>` → inner).
+- **Pattern D (single directory symlink)** — refresh only
+ the *canonical-side* layer at
+ `<canonical>/skills/<n>` (D.1 → `.github/skills/<n>`;
+ D.2 → `.claude/skills/<n>`). The symlinked-side path
+ resolves through the directory symlink and needs no
+ per-skill plumbing.
## Step 6b — Sync locally-installed hooks and configuration
diff --git a/.claude/skills/setup-steward/verify.md
b/.claude/skills/setup-steward/verify.md
index 6f02157..ddbbd8a 100644
--- a/.claude/skills/setup-steward/verify.md
+++ b/.claude/skills/setup-steward/verify.md
@@ -112,12 +112,21 @@ Check that the entries from
must never be committed since the content is machine-specific
absolute paths)
-Recommended:
-
-- The framework-skill symlink patterns (`security-*`,
- `pr-management-*`, `issue-*`, `setup-isolated-setup-*`,
- `setup-shared-config-sync`, `list-steward-*`) under both
- `.claude/skills/` and `.github/skills/` per convention.
+Recommended (the family patterns the adopter's
+[skills-dir convention](conventions.md) requires):
+
+- **Pattern A** — framework-skill symlink patterns
+ (`security-*`, `pr-management-*`, `issue-*`,
+ `setup-isolated-setup-*`, `setup-shared-config-sync`,
+ `list-steward-*`) under `.claude/skills/` only.
+- **Pattern B** — same patterns under **both**
+ `.claude/skills/` and `.github/skills/` (one ignore line
+ per physical symlink).
+- **Pattern D** — same patterns under the **canonical side
+ only** (`.github/skills/` for D.1; `.claude/skills/` for
+ D.2). The symlinked side does not need its own ignore
+ lines because git does not descend into a directory
+ symlink.
- ✗ if `/.apache-steward/` is not gitignored — the snapshot
is at risk of being accidentally committed.
diff --git a/.claude/skills/setup-steward/worktree-init.md
b/.claude/skills/setup-steward/worktree-init.md
index 2125abb..888ae2f 100644
--- a/.claude/skills/setup-steward/worktree-init.md
+++ b/.claude/skills/setup-steward/worktree-init.md
@@ -101,9 +101,26 @@ For each framework skill in the effective family set:
repair it.
Reuse the convention detection from
-[`conventions.md`](conventions.md): flat vs double-symlinked
-layout drives where the inner / outer links land. Both
-layers gitignored.
+[`conventions.md`](conventions.md). The pattern drives how
+many layers the worktree's `<adopter-skills-dir>` needs:
+
+- **Pattern A (flat)** — one layer at
+ `.claude/skills/<n>`.
+- **Pattern B (double-symlinked)** — two layers (inner at
+ `.github/skills/<n>`, outer at `.claude/skills/<n>` →
+ inner). Both gitignored.
+- **Pattern D (single directory symlink)** — one layer at
+ the canonical side (D.1: `.github/skills/<n>`;
+ D.2: `.claude/skills/<n>`). The symlinked side resolves
+ automatically through the directory symlink, so there is
+ no per-skill plumbing to add or repair on that side.
+
+The worktree's `.claude/skills` / `.github/skills` directory
+symlink itself (for Pattern D) is **not** a framework
+artefact — it is checked into the repo as part of the
+adopter's layout, so every worktree inherits it via the
+ordinary `git worktree add` flow. `worktree-init` does not
+touch it.
Pick any framework skill symlink that should now exist (e.g.
`<worktree>/.claude/skills/security-issue-sync/SKILL.md`) and
diff --git a/docs/setup/install-recipes.md b/docs/setup/install-recipes.md
index 6a8ca43..d43c201 100644
--- a/docs/setup/install-recipes.md
+++ b/docs/setup/install-recipes.md
@@ -32,14 +32,24 @@ Pick the recipe that matches your distribution preference:
| [**git-branch**](#method-3--git-branch-defaults-to-main) | WIP path — track
the framework's `main` branch directly. The default during the framework's
pre-release phase. | Tracks branch tip |
> **Adopter convention** — pick the right `cp` step per your
-> existing skills layout:
+> existing skills layout (see
+>
[`.claude/skills/setup-steward/conventions.md`](../../.claude/skills/setup-steward/conventions.md)
+> for the full taxonomy):
>
-> - **flat** (`.claude/skills/<n>/SKILL.md` directly): copy
+> - **A — flat** (`.claude/skills/<n>/SKILL.md` directly): copy
> `setup-steward` into `.claude/skills/setup-steward/`.
-> - **double-symlinked** (e.g. `apache/airflow` —
-> `.claude/skills/<n>` → `.github/skills/<n>/`): copy the
-> content into `.github/skills/setup-steward/` AND symlink
+> - **B — double-symlinked** (per-skill
+> `.claude/skills/<n>` → `.github/skills/<n>/` symlinks): copy
+> the content into `.github/skills/setup-steward/` AND symlink
> `.claude/skills/setup-steward` → `../../.github/skills/setup-steward`.
+> - **D — single directory symlink** (one of
+> `.claude/skills` / `.github/skills` is itself a directory
+> symlink to the other): copy the content into the
+> *canonical* side only — `.github/skills/setup-steward/`
+> for D.1 (canonical `.github/skills/`) or
+> `.claude/skills/setup-steward/` for D.2 (canonical
+> `.claude/skills/`). The opposite side is the same
+> directory via the symlink, so there is nothing to wire up.
>
> The `setup-steward` skill itself is the **only** framework
> artefact you commit. Every other framework skill is wired
@@ -98,10 +108,20 @@ rm -f ${ZIP} ${ZIP}.sha512 ${ZIP}.asc
# A — flat layout (default):
cp -r .apache-steward/.claude/skills/setup-steward .claude/skills/setup-steward
#
-# B — double-symlinked layout (e.g. apache/airflow):
+# B — double-symlinked layout (per-skill symlinks):
# mkdir -p .github/skills .claude/skills
# cp -r .apache-steward/.claude/skills/setup-steward
.github/skills/setup-steward
# ln -sf ../../.github/skills/setup-steward .claude/skills/setup-steward
+#
+# D.1 — single directory symlink, canonical .github/skills/:
+# (.claude/skills is itself a symlink → ../.github/skills/)
+# cp -r .apache-steward/.claude/skills/setup-steward
.github/skills/setup-steward
+# (No second copy needed — .claude/skills/setup-steward resolves
+# to .github/skills/setup-steward via the directory symlink.)
+#
+# D.2 — single directory symlink, canonical .claude/skills/:
+# (.github/skills is itself a symlink → ../.claude/skills/)
+# cp -r .apache-steward/.claude/skills/setup-steward
.claude/skills/setup-steward
# 3. Add gitignore entries (idempotent — re-run is safe)
cat >> .gitignore <<'GITIGNORE'
@@ -116,14 +136,20 @@ cat >> .gitignore <<'GITIGNORE'
/.apache-steward.local.lock
# Symlinks created by /setup-steward into the gitignored snapshot.
+# Pattern A (flat) → keep only the .claude/skills/ block below.
+# Pattern B (per-skill double-symlinked) → keep BOTH blocks (one
+# physical symlink per layer needs its own ignore line).
+# Pattern D.1 (.claude/skills → .github/skills/) → keep only the
+# .github/skills/ block — git does not descend into the directory
+# symlink, so .claude/skills/ ignore lines are unnecessary.
+# Pattern D.2 (.github/skills → .claude/skills/) → keep only the
+# .claude/skills/ block (same reason, opposite orientation).
/.claude/skills/security-*
/.claude/skills/pr-management-*
/.claude/skills/issue-*
/.claude/skills/setup-isolated-setup-*
/.claude/skills/setup-shared-config-sync
/.claude/skills/list-steward-*
-# Mirror the same patterns under .github/skills/ if your repo uses
-# the double-symlinked convention.
/.github/skills/security-*
/.github/skills/pr-management-*
/.github/skills/issue-*
@@ -156,11 +182,13 @@ git clone --depth=1 \
https://github.com/apache/airflow-steward.git \
.apache-steward
-# Copy the `setup-steward` skill — pick A or B (see Method 1 step 2)
+# Copy the `setup-steward` skill — pick A / B / D (see Method 1 step 2)
cp -r .apache-steward/.claude/skills/setup-steward .claude/skills/setup-steward
-# OR for double-symlinked:
+# OR for double-symlinked (B):
# cp -r .apache-steward/.claude/skills/setup-steward
.github/skills/setup-steward
# ln -sf ../../.github/skills/setup-steward .claude/skills/setup-steward
+# OR for single directory-symlink (D) — copy to canonical side only;
+# the .claude/skills ↔ .github/skills directory symlink does the rest.
# Add gitignore entries (same block as Method 1 step 3 — see there)
@@ -183,11 +211,13 @@ git clone --depth=1 \
https://github.com/apache/airflow-steward.git \
.apache-steward
-# Copy the `setup-steward` skill — pick A or B (see Method 1 step 2)
+# Copy the `setup-steward` skill — pick A / B / D (see Method 1 step 2)
cp -r .apache-steward/.claude/skills/setup-steward .claude/skills/setup-steward
-# OR for double-symlinked:
+# OR for double-symlinked (B):
# cp -r .apache-steward/.claude/skills/setup-steward
.github/skills/setup-steward
# ln -sf ../../.github/skills/setup-steward .claude/skills/setup-steward
+# OR for single directory-symlink (D) — copy to canonical side only;
+# the .claude/skills ↔ .github/skills directory symlink does the rest.
# Add gitignore entries (same block as Method 1 step 3 — see there)
diff --git a/docs/setup/unadopt.md b/docs/setup/unadopt.md
index 120bf54..249c0ef 100644
--- a/docs/setup/unadopt.md
+++ b/docs/setup/unadopt.md
@@ -77,7 +77,7 @@ The following will be REMOVED:
.apache-steward.local.lock
<skills-dir>/<symlink-1> →
.apache-steward/.claude/skills/<skill-1>/
<skills-dir>/<symlink-2> → ...
- .github/skills/<symlink-1> (if double-symlinked layout)
+ .github/skills/<symlink-1> (Pattern B only — second
physical layer)
.git/hooks/post-checkout (if it contains the steward
recipe)
Committed (will show in `git status`):
@@ -93,9 +93,17 @@ The following will be PRESERVED:
.apache-steward-overrides/ (pass `--purge-overrides` to
remove)
```
-`<skills-dir>` resolves to your skills directory —
-typically `.claude/skills/`, or `.github/skills/` for repos
-using the double-symlinked layout.
+`<skills-dir>` resolves to your skills directory per the
+[skills-dir convention](../../.claude/skills/setup-steward/conventions.md)
+your repo uses:
+
+- **Pattern A** — `.claude/skills/`.
+- **Pattern B** — both `.claude/skills/` and `.github/skills/`
+ (one physical symlink per layer).
+- **Pattern D** — the canonical side only (D.1:
+ `.github/skills/`; D.2: `.claude/skills/`). The directory
+ symlink itself is adopter-owned and is **not** removed by
+ unadopt.
If `--purge-overrides` is passed, `.apache-steward-overrides/`
moves into the *removed* section with its files listed