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


Reply via email to