# Update

> Sync the ApexYard fork with upstream — preview, merge-or-rebase on a sync branch, walk per-version migrations.

- Skill: `me2resh/update` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add me2resh/update`
- Raw SKILL.md: https://api.skillmd.com/api/skills/me2resh/update/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: me2resh (https://skillmd.com/u/me2resh)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/me2resh/update

---


<!--
  Hidden flag: --from-dev (NOT in description above, on purpose — see #250).
  Pulls from upstream/dev instead of the latest tagged release. Adopter
  contract is tagged releases; --from-dev is an opt-in for framework
  maintainers and adopters who explicitly want to test pre-release work.
  Documented in `## Usage` and `## Options` below for operators who read
  the spec; deliberately omitted from `description` so /help does not
  surface it.
-->


# /update — Sync ApexYard Fork from Upstream

Read `.claude/rules/writing-standard.md`. Use the **controlled technical writing profile** for the sync
PR body and migration notes. Lead with the outcome and next action.

Single-command replacement for the manual "fetch → branch → merge → push → PR" dance that fork maintainers do to pull upstream apexyard changes into their ops fork.

## Path resolution

Read the registry path via `portfolio_registry`, the per-project docs dir via `portfolio_projects_dir`, and the ideas backlog via `portfolio_ideas_backlog` — all from `.claude/hooks/_lib-portfolio-paths.sh`. Source the helper at the top of any bash block that touches those paths:

```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-portfolio-paths.sh"
registry=$(portfolio_registry)
```

Defaults match today's single-fork layout (`./apexyard.projects.yaml`, `./projects`, `./projects/ideas-backlog.md`). Adopters in split-portfolio mode override the `portfolio.{registry, projects_dir, ideas_backlog}` keys in `.claude/project-config.json`. Don't hardcode literal `apexyard.projects.yaml` or `projects/` paths in bash blocks — the helper resolves whichever mode the adopter is in. See `docs/multi-project.md`.

## Usage

```
/update                              # merge-based sync (default, safer)
/update --rebase                     # rebase local customisations on top of upstream
/update --dry-run                    # preview only, don't touch anything
/update --from-version v1.2.0        # override the version anchor (use when fork anchor is missing/wrong)
/update --skip-migrations            # files-only sync; do NOT run the per-version migration chain
/update --skip-adapter-sync          # do not refresh an already-installed Codex adapter
/update --from-dev                   # (hidden) pull from upstream/dev — pre-release; expect breakage
```

## Options

| Flag | Effect |
|------|--------|
| `--rebase` | Rebase local commits onto upstream instead of merging. Cleaner linear history; rewrites local SHAs. |
| `--dry-run` | Run the preview step only. Print the commit delta and exit; no fetch-after-preview, no branch creation, no merge. **Does NOT execute migrations** — only previews the planned chain. |
| `--from-version vN.N.N` | Explicit version-anchor override. Use when `.claude/framework-version` is missing (legacy fork pre-v1.4.0) OR you've manually rolled back and the anchor is stale. The chain is built against this value instead of the file. Refuses if the value doesn't match `vMAJOR.MINOR.PATCH`. |
| `--skip-migrations` | Sync the framework files but DO NOT run the per-version migration chain. Prints an advisory warning naming each skipped pair and reminding the operator that the migrations can be replayed later with `bash .claude/migrations/<pair>.sh`. The anchor file IS still advanced to the new release tag, so subsequent runs won't re-offer the same migrations. Use sparingly — the chain is the point. |
| `--skip-adapter-sync` | Do not refresh an already-installed Codex adapter. Detection is installation-based, not harness-session-based: without this flag, `/update` reconciles only a manifest-backed or complete legacy ApexYard Codex adapter and leaves uninstalled forks untouched. Intended as a troubleshooting escape hatch. |
| `--from-dev` | **Hidden / opt-in.** Sync from `upstream/dev` (pre-release work) instead of the latest `upstream/main` tag. Prints a `⚠ PRE-RELEASE SYNC` banner BEFORE any fetch/state-mutation. Same sync-branch + conflict-resolution flow; branch is named `chore/sync-upstream-dev` (or `chore/#<TICKET>-sync-upstream-dev` if a tracking issue is supplied). Intended for the framework maintainer (testing pre-release work on another machine) and for adopters who explicitly want to validate an upcoming framework change. **Not** in the skill's `description` frontmatter on purpose — `/help` should not surface it, since the adopter contract is tagged releases (see AgDR-0007 release-cut model). Combinable with `--rebase` and `--dry-run`. **When `--from-dev` is set, the migration chain is automatically skipped** — pre-release work doesn't have a release tag to anchor against. |

## Output

On success: one sync branch ready to push (e.g. `chore/#N-sync-upstream-apexyard`), with an auto-generated PR body listing the commits pulled in, plus the exact next commands to run.

On conflict: paused at the conflict point with per-file options (keep mine / accept upstream / open editor).

On up-to-date: one line after reconciling any detected Codex adapter. Git refs
remain unchanged, but stale generated adapter files may be refreshed.

## When NOT to use

- The clone has no `upstream` remote. The skill prints the exact `git remote add upstream …` command and exits.
- The working tree is dirty (uncommitted changes or unstaged files). The skill refuses — stash or commit first.
- The current branch is not the default (`main` / `master`). The skill refuses — `git checkout main` first.
- You want to sync a specific feature branch from upstream. Out of scope — this skill is for default-branch fork sync only.

## Process

### Pre-step: Parse flags + print pre-release banner (when --from-dev)

Parse the invocation arguments first, BEFORE any fetch / branch / merge work:

```bash
FROM_DEV=0
DRY_RUN=0
REBASE=0
SKIP_MIGRATIONS=0
SKIP_ADAPTER_SYNC=0
FROM_VERSION_OVERRIDE=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    --from-dev)         FROM_DEV=1 ;;
    --dry-run)          DRY_RUN=1 ;;
    --rebase)           REBASE=1 ;;
    --skip-migrations)  SKIP_MIGRATIONS=1 ;;
    --skip-adapter-sync) SKIP_ADAPTER_SYNC=1 ;;
    --from-version)     shift; FROM_VERSION_OVERRIDE="$1" ;;
    --from-version=*)   FROM_VERSION_OVERRIDE="${1#--from-version=}" ;;
  esac
  shift
done

# Resolve the upstream ref + sync-branch suffix once, at the top, so every
# downstream step references the same target.
if [ "$FROM_DEV" = "1" ]; then
  UPSTREAM_REF=upstream/dev
  BRANCH_SUFFIX=sync-upstream-dev
  # Pre-release work has no release tag — chain walking is meaningless.
  SKIP_MIGRATIONS=1
else
  UPSTREAM_REF=upstream/main
  BRANCH_SUFFIX=sync-upstream-apexyard
fi

# Validate --from-version shape early (semver-core only; pre-release suffix
# is not supported, in line with the chain helper's contract).
if [ -n "$FROM_VERSION_OVERRIDE" ]; then
  if ! echo "$FROM_VERSION_OVERRIDE" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
    echo "--from-version: expected vMAJOR.MINOR.PATCH (got '$FROM_VERSION_OVERRIDE')." >&2
    exit 1
  fi
fi
```

If `FROM_DEV=1`, print this banner BEFORE doing anything else (in particular, before any `git fetch`, branch-create, or merge — operator must see the warning before any state mutation):

```
⚠ PRE-RELEASE SYNC — pulling from upstream/dev
   This is unreleased work; expect breakage.
   Revert with: git reset --hard origin/main
   For supported updates, use /update (no flag) to pull tagged releases.
```

The banner restates the deal every invocation — an operator who used `--from-dev` once should not be surprised the next time they run plain `/update` and find themselves on a different code path. The banner is load-bearing on purpose: dropping it would let pre-release breakage land silently.

### 0. Mark this session as bootstrap (REQUIRED)

`/update` edits framework-root files (resolving merge conflicts, updating CLAUDE.md imports, etc.) which the `require-active-ticket.sh` PreToolUse hook would otherwise block when the only "ticket" is the upstream-sync work itself. Write a marker so the hook exempts this skill (it's on the default `bootstrap_skills` list in `.claude/project-config.defaults.json`):

```bash
mkdir -p .claude/session && echo "update" > .claude/session/active-bootstrap
```

Clear the marker on completion (last step of this skill). If the skill is interrupted, the SessionStart hook `clear-bootstrap-marker.sh` clears it at the start of the next session. See AgDR-0011 + me2resh/apexyard#150.

### 1. Pre-flight

Run these checks in order. On first failure, stop and explain.

```bash
# 1a. upstream remote exists
git remote | grep -qx upstream || {
  ORIGIN=$(git remote get-url origin)
  echo "No 'upstream' remote configured."
  echo "Add it with:"
  echo "  git remote add upstream https://github.com/me2resh/apexyard.git"
  echo "Then re-run /update."
  exit 1
}

# 1b. working tree is clean
if [ -n "$(git status --porcelain)" ]; then
  echo "Working tree is dirty. Commit or stash first, then re-run /update."
  exit 1
fi

# 1c. on default branch
DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null | sed 's|origin/||')
DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}
CURRENT_BRANCH=$(git branch --show-current)
if [ "$CURRENT_BRANCH" != "$DEFAULT_BRANCH" ]; then
  echo "Not on default branch ($DEFAULT_BRANCH). Currently on: $CURRENT_BRANCH"
  echo "Run: git checkout $DEFAULT_BRANCH"
  exit 1
fi
```

#### 1d. Define installed-adapter reconciliation

Detection belongs to the generator, not to the current harness session. The
helper below refreshes only a manifest-backed ApexYard Codex adapter or the
complete pre-manifest shape (`.agents/skills/`, `.codex/agents/`, and
`.codex/hooks.json`). An uninstalled or partial adapter is a silent no-op.

```bash
reconcile_installed_codex_adapter() {
  if [ "$SKIP_ADAPTER_SYNC" = "1" ]; then
    echo "Codex adapter reconciliation skipped (--skip-adapter-sync)."
    return 0
  fi
  if [ "$DRY_RUN" = "1" ]; then
    echo "DRY-RUN: would reconcile an installed Codex adapter."
    return 0
  fi

  local script="$(git rev-parse --show-toplevel)/bin/sync-codex-adapter.sh"
  if [ ! -f "$script" ]; then
    echo "Codex adapter reconciliation failed: missing $script" >&2
    return 1
  fi
  bash "$script" --reconcile-installed
}
```

Unlike the SessionStart advisory nudge described below, an explicit `/update`
is strict: a detected adapter that cannot be generated and verified makes the
update fail instead of being reported as current.

### 2. Fetch both remotes

`git fetch upstream --quiet` brings down all upstream branches by default (including `upstream/dev`), so a single fetch covers both the default and the `--from-dev` target. No conditional fetch needed.

```bash
git fetch upstream --quiet
git fetch origin --quiet
```

Network failure: print a warning and exit. Don't try to "work from cache" — users should know they're seeing stale state.

### 3. Preview

Two signals matter here: a new upstream **tag** (the actionable one, meaning a real release is available), and upstream **main commits** since the fork's last sync (informational — may just be a docs typo).

When `--from-dev` is set, the comparison target is `upstream/dev` instead of `upstream/main`, and tag-based signals are skipped (dev is by definition pre-release; there is no tag to compare against). The preview reports the commit delta against `upstream/dev` and the operator decides whether to proceed.

```bash
AHEAD=$(git rev-list --count "$UPSTREAM_REF"..main)
BEHIND=$(git rev-list --count main.."$UPSTREAM_REF")

# Tag-based signal applies only to the tagged-release path.
if [ "$FROM_DEV" = "0" ]; then
  UPSTREAM_TAG=$(git tag --list --sort=-v:refname --merged upstream/main | head -n 1)
  LOCAL_TAG=$(git tag --list --sort=-v:refname --merged main | head -n 1)
fi
```

Then report. Examples:

**Up-to-date (no tag drift, no commit drift):**

```bash
reconcile_installed_codex_adapter || exit 1
rm -f .claude/session/active-bootstrap

echo "Fork is up to date with upstream/main. Nothing to sync."
```

Exit 0.

Any other preview path that exits 0 without creating a sync branch (for
example, the operator declines unreleased `upstream/main` commits) MUST call
`reconcile_installed_codex_adapter` before cleanup and exit. This closes the
stale-adapter bug even when there is no release work to merge. `--dry-run`
reaches the same helper but prints intent without mutating files.

**No release drift, but main has moved (common, NOT actionable):**

```
Fork is on upstream's latest release (v1.1.0) but upstream/main has 3 unreleased commits.
These are typically docs tweaks, CI fixes, or work-in-progress.

Sync anyway? [y/N]
```

Default answer is "no" — small main commits aren't worth syncing. Surface this without nagging; the user can still choose to pull in bleeding-edge.

**Behind only — new release available (actionable, default):**

```
New release available: v1.1.0 (you are on v1.0.0, 12 commits behind upstream/main).

Upstream commits to pull in:
  c8c93bb fix: merge-gate hooks read PR HEAD via gh pr view (#57)
  1299b59 fix(#47): catch gh api .../merge bypass (#54)
  5f067b5 fix: reject closed issue refs (#53)
  ... (9 more)

Proceed with merge? [Y/n]
```

Default answer is "yes" in this mode — there's a real release the user asked about by running `/update`.

**`--from-dev` (pre-release):**

```
Pre-release sync: 7 unreleased commits on upstream/dev since fork's HEAD.

Upstream/dev commits to pull in:
  ab12cde feat(#250): /update --from-dev hidden flag
  cd34efg fix(#248): tighten validation
  ... (5 more)

Proceed with merge from upstream/dev? [Y/n]
```

Default answer is "yes" — the operator opted into pre-release explicitly with the flag, the banner already warned them about breakage, and asking again would be nagging. Skip the tag-based prompts entirely; dev has no tags to compare.

**Ahead and behind (typical fork):**

The prompt's default answer branches on whether a new release is available:

- If `UPSTREAM_TAG` is strictly newer than `LOCAL_TAG` → default `[Y/n]` (there's a real release to pull in).
- If they're equal (no new release, just main drift) → default `[y/N]` (likely noise).

```
Fork has 5 local commits not in upstream, and is 12 commits behind.

Local commits (will be preserved on top of the merge):
  f46d4e7 Merge pull request #2 from …/chore/#40-configure-ops-repo
  840bb2d fix: auto-fix markdown lint in handover assessments
  (… 3 more …)

Upstream commits to pull in:
  c8c93bb fix: merge-gate hooks read PR HEAD via gh pr view (#57)
  (… 11 more …)

New release available: v1.1.0 (you are on v1.0.0).

Proceed with merge? [Y/n]
```

Cap each list at 20 entries with an `(N more)` marker.

If `--dry-run` is set, show the preview and exit without touching anything else.

### 4. Ask merge vs rebase (unless `--rebase` was passed)

If not already specified by flag:

```
Sync strategy:
  (1) merge   — creates a merge commit. Local history is preserved as-is. Safer for shared branches. DEFAULT.
  (2) rebase  — replays local commits on top of upstream. Cleaner linear history but rewrites local SHAs.

Choose [1]:
```

Default is merge. Record the choice.

### 5. Create a sync branch

Rationale for diverging from the `#58` AC wording ("leaves updated local main"): apexyard's own `block-main-push.sh` hook blocks direct pushes to `main` and also blocks commits made while on `main`. A merge with conflicts requires a `git commit` to finalise, which would be blocked. A sync branch sidesteps both issues and is the same shape the project uses for all other changes.

```bash
# Find or create a tracking issue. If a recent "sync" issue is open, reuse its number.
# Otherwise prompt the user to create one (or offer to create it via `gh issue create`).

# $BRANCH_SUFFIX was set in the pre-step:
#   sync-upstream-apexyard  for upstream/main (default)
#   sync-upstream-dev       for upstream/dev (--from-dev)
if [ -n "$TICKET" ]; then
  BRANCH="chore/#${TICKET}-${BRANCH_SUFFIX}"
else
  BRANCH="chore/${BRANCH_SUFFIX}"
fi
git checkout -b "$BRANCH"
```

### 6. Do the sync

`$UPSTREAM_REF` was set in the pre-step (`upstream/main` by default, `upstream/dev` under `--from-dev`).

**Merge path:**

```bash
git merge "$UPSTREAM_REF" --no-edit
```

**Rebase path:**

```bash
git rebase "$UPSTREAM_REF"
```

Capture stdout/stderr for the conflict-detection step.

### 7. Handle conflicts (if any)

If merge/rebase reports conflicts, show the user one file at a time:

```
CONFLICT in .claude/rules/pr-workflow.md

Upstream changed:  adds "### Both merge shapes are gated (#47)" section
Local changed:     inserted custom header paragraph at the top

Options:
  (1) Keep mine        — git checkout --ours .claude/rules/pr-workflow.md
  (2) Accept upstream  — git checkout --theirs .claude/rules/pr-workflow.md
  (3) Open in editor   — pause skill, wait for user to resolve, then resume

Choose [3]:
```

For each conflict file, get the user's choice. Default to (3) since auto-resolution on a governance framework is risky.

After each file: `git add <file>` to mark resolved.

When all conflicts are resolved:

```bash
# merge path
git commit --no-edit

# rebase path
git rebase --continue
```

If at any point the user wants to bail:

```bash
git merge --abort    # or: git rebase --abort
git checkout main
git branch -D "$BRANCH"
```

### 8. Detect deprecated config keys (advisory)

After the merge / rebase has applied (so the new `.claude/project-config.defaults.json` is on disk), scan the adopter's `.claude/project-config.json` for **top-level keys that no longer exist in defaults** — typically a config block removed upstream (e.g. `voice_prompts` removed in me2resh/apexyard#157) that still lingers in the override as dead config.

This is **advisory only**. Custom-extension keys an adopter has added (their own hooks, in-house extensions) are also surfaced — the detector cannot tell them apart from upstream-removed keys, and only the operator can. The y/n/s offer below is the human-in-the-loop step that disambiguates.

#### Detection

Source the helper and read the deprecated key list:

```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-detect-deprecated-config.sh"
DEPRECATED=$(detect_deprecated_config_keys)
```

Return values:

- Empty → nothing to surface, skip to step 9.
- One or more newline-separated key names → continue.

The helper:

- Reads only **top-level** keys (whole-block removals; sub-key renames are out of scope per the ticket).
- Whitelists metadata keys with a leading underscore (`_comment`, `_schema_version`, `_team_comment`, etc.) — those aren't deprecated config blocks.
- Returns silently with exit 1 if `jq` is missing or defaults file is absent (skill should skip detection in that case, not fail).

#### Offer

If `DEPRECATED` is non-empty, format and print:

```
ApexYard /update detected N config block(s) in .claude/project-config.json
that no longer exist in upstream defaults:

  - voice_prompts
  - abandoned_block

These keys may be:
  (a) dead config from a block the framework removed upstream (e.g.
      voice_prompts after #157), or
  (b) custom extension keys you've added intentionally.

The detector can't tell them apart — choose:

  [y] yes, remove the listed keys from .claude/project-config.json
  [n] no, leave them alone (they're harmless; you can clean up later)
  [s] show me the keys + their current values before deciding
```

Read the operator's reply.

| Reply | Action |
|-------|--------|
| `y` | Back the file up first (`cp .claude/project-config.json .claude/project-config.json.bak`), then run `remove_deprecated_config_keys` (edits `.claude/project-config.json` in place, no commit). Print `Removed N keys. Backup at .claude/project-config.json.bak — compare with: diff .claude/project-config.json.bak .claude/project-config.json`. **Do NOT `git add` the file** (me2resh/apexyard#1031): it is gitignored and untracked, so staging exits 1 and `git diff --staged` would show nothing anyway. A plain-file backup is the reviewable artefact here, because git holds no copy to diff against — which is exactly why an unrecoverable edit needs one. |
| `n` | Print `Leaving override untouched. Re-run /update later if you change your mind.` and continue to step 9. |
| `s` | Run `show_deprecated_config_keys` (prints each key + current value), then re-prompt with the same y/n options (no `s` recursion). |

The skill **never auto-removes without explicit `y`**. The skill **never auto-commits** — staging is the contract, the operator owns the commit.

#### Why advisory, not destructive

A custom-extension key indistinguishable from an upstream-removed key is a real possibility (e.g. an adopter who's ahead of defaults with their own block). The cost of incorrectly removing a custom block is much higher than the cost of one extra prompt — the y/n/s pattern matches the rest of `/update`'s "operator owns each material change" stance.

### 8a. Migrate to split-portfolio v2 layout (advisory, default-yes)

Detection. After the merge / rebase has applied the new `_lib-portfolio-paths.sh` + `_lib-ops-root.sh`, source the helper and check for **two** conditions that together identify a pre-v2 split-portfolio adopter:

```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-portfolio-paths.sh"

# Already v2 (or single-fork) — no migration needed.
if portfolio_is_v2; then
  V2_NEEDED=0
elif ! jq -e '.portfolio.registry' .claude/project-config.json >/dev/null 2>&1; then
  # No portfolio block at all → single-fork mode → no migration.
  V2_NEEDED=0
else
  # Has a portfolio block (split-portfolio) but no .apexyard-fork marker
  # → pre-v2 split-portfolio adopter. Migration applies.
  V2_NEEDED=1
fi
```

If `V2_NEEDED=0` → skip this step entirely and continue to step 9.

If `V2_NEEDED=1`, present the offer:

```
ApexYard /update detected your fork is in split-portfolio mode (v1 layout):

  - apexyard.projects.yaml     → resolved to a sibling private repo (good)
  - projects/                  → resolved to a sibling private repo (good)
  - onboarding.yaml            → still in this public fork (v1 layout)
  - workspace/                 → still in this public fork (v1 layout)

Split-portfolio v2 (introduced in framework #242) moves onboarding.yaml
AND workspace/ to the private sibling repo too, so the public fork holds
ONLY framework files + your customisations to skills/hooks/rules.

Migrate now? This will:
  - COPY onboarding.yaml to the sibling private repo (sibling becomes
    canonical) + untrack it from the public fork (snapshot left on disk)
  - MOVE workspace/<name>/ contents to the sibling private repo
  - Add gitignore entries for both in the public fork
  - Write a .apexyard-fork marker (the v2 ops-fork anchor)
  - Add portfolio.{onboarding,workspace_dir} keys to .claude/project-config.json

onboarding.yaml is COPIED (not moved) — the file is small, the legacy
ops-root walk still reads it as a fallback anchor, and a public-fork
snapshot is a useful safety net while the sibling-repo copy becomes
the source of truth. workspace/ is MOVED — clones are gigabytes; we
don't double disk. See AgDR-0021 § "v1→v2 migration semantics".

Idempotent — if interrupted, re-run.

[Y / n / dry-run — show commands, don't execute]
```

If `--dry-run` was passed to `/update`, force the dry-run branch automatically (print the commands the migration would run, do not execute, then continue to step 9).

Per-file-class confirmation — ask separately for `onboarding.yaml` and `workspace/`, so the operator can migrate one and defer the other:

```
Copy onboarding.yaml to sibling private repo? [Y/n]
Move workspace/?                              [Y/n]   # surfaces disk size: du -sh workspace
```

#### Migration steps

For each file class the operator confirmed, run the moves below. Resolve the sibling repo dir from the existing `portfolio.registry` path (the parent dir of the registry file is the sibling repo root):

```bash
SIBLING_ROOT=$(dirname "$(jq -r '.portfolio.registry' .claude/project-config.json)")
# e.g. SIBLING_ROOT=../apexyard-portfolio
```

##### Copy onboarding.yaml (NOT move — see AgDR-0021 § "v1→v2 migration semantics")

```bash
if [ -f onboarding.yaml ] && [ ! -f "$SIBLING_ROOT/onboarding.yaml" ]; then
  # COPY (cp -p preserves mtimes/permissions). The sibling-repo copy
  # becomes the canonical source of truth; the public-fork copy is left
  # on disk as a snapshot for the legacy ops-root walk-up fallback.
  cp -p onboarding.yaml "$SIBLING_ROOT/onboarding.yaml"
  (cd "$SIBLING_ROOT" && git add onboarding.yaml)

  # Untrack from the public fork so future commits don't ship it.
  # The file stays on disk (gitignored in the next sub-step) as a
  # legacy-tool snapshot.
  git rm --cached onboarding.yaml 2>/dev/null || true
elif [ -f "$SIBLING_ROOT/onboarding.yaml" ] && [ -f onboarding.yaml ]; then
  # Both present — sibling is canonical; we still need to untrack the
  # public-fork copy if it's currently tracked. Idempotent.
  git rm --cached onboarding.yaml 2>/dev/null || true
fi
```

Idempotence: re-running this block on a v2 layout is a no-op (the second branch's `git rm --cached` returns non-zero when the file is already untracked, suppressed by `|| true`; the first branch is gated by the negated `[ ! -f "$SIBLING_ROOT/onboarding.yaml" ]`).

**Why copy, not move?** Three reasons codified in AgDR-0021 § H:

1. **Legacy ops-root walk-up fallback** — `_lib-ops-root.sh` checks `.apexyard-fork` first, but falls back to `onboarding.yaml + apexyard.projects.yaml` for un-migrated forks. Leaving a snapshot in the public fork keeps the legacy path working even if the marker is accidentally removed.
2. **Safety net** — `onboarding.yaml` is small (KB, not GB). A duplicate on disk costs nothing meaningful and gives the operator a recoverable reference if the sibling repo is unreachable.
3. **Canonical source of truth in the sibling** — the public-fork copy is untracked (`git rm --cached`) and gitignored, so it cannot drift into commits. The sibling repo's copy is the one /setup writes to and /handover reads.

##### Move workspace/

```bash
if [ -d workspace ] && [ "$(ls -A workspace 2>/dev/null)" ]; then
  mkdir -p "$SIBLING_ROOT/workspace"
  # Move each entry individually so we don't trip on `mv` of a populated dir
  # to an existing dir (some shells refuse).
  for entry in workspace/*; do
    [ -e "$entry" ] || continue
    name=$(basename "$entry")
    # workspace/README.md is a committed framework artefact explaining the
    # workspace/*/ convention — it stays in the public fork (matches the
    # manual recipe in docs/multi-project.md § "What if you want to migrate
    # by hand?"). See AgDR-0021 § G for the rationale.
    if [ "$name" = "README.md" ]; then
      continue
    fi
    if [ -e "$SIBLING_ROOT/workspace/$name" ]; then
      echo "WARNING: workspace/$name exists in BOTH locations — skipped." >&2
      continue
    fi
    mv "$entry" "$SIBLING_ROOT/workspace/$name"
  done
fi
```

Idempotence: empty `workspace/` (no entries to move) is a no-op.

##### Update .gitignore

```bash
NEEDS=()
grep -qxF onboarding.yaml .gitignore 2>/dev/null || NEEDS+=(onboarding.yaml)
grep -qxF 'workspace/*' .gitignore 2>/dev/null || NEEDS+=('workspace/*')
grep -qxF '!workspace/README.md' .gitignore 2>/dev/null || NEEDS+=('!workspace/README.md')

if [ "${#NEEDS[@]}" -gt 0 ]; then
  {
    echo ""
    echo "# Split-portfolio v2 (framework ≥ #242): onboarding + workspace entries live in the private sibling repo."
    for n in "${NEEDS[@]}"; do echo "$n"; done
  } >> .gitignore
  git add .gitignore
fi
```

##### Write the .apexyard-fork marker

The marker is **presence-only**: readers (every ops-root walk) MUST ignore content; only file presence matters. Writers MAY include a single explanatory line so `head .apexyard-fork` is informative — both `echo "# comment" > .apexyard-fork` and `touch .apexyard-fork` are valid. See [AgDR-0021](../../../docs/agdr/AgDR-0021-split-portfolio-v2-path-resolution.md) § B.

```bash
if [ ! -f .apexyard-fork ]; then
  echo "# This file marks the directory as an ApexYard ops fork (split-portfolio v2)." > .apexyard-fork
  git add .apexyard-fork
fi
```

##### Update .claude/project-config.json

Add the two new keys to the `portfolio` block, pointing at the sibling repo. Use `jq` to merge so existing keys are preserved:

```bash
PCONFIG=.claude/project-config.json
if [ -f "$PCONFIG" ]; then
  TMP=$(mktemp)
  jq --arg onb "$SIBLING_ROOT/onboarding.yaml" \
     --arg ws "$SIBLING_ROOT/workspace" \
     '.portfolio.onboarding = (.portfolio.onboarding // $onb)
      | .portfolio.workspace_dir = (.portfolio.workspace_dir // $ws)' \
     "$PCONFIG" > "$TMP" && mv "$TMP" "$PCONFIG"
  git add "$PCONFIG"
fi
```

Idempotence: `// $onb` short-circuits if the operator already added the key by hand.

##### Final verification

```bash
portfolio_clear_cache
if portfolio_validate >/dev/null 2>&1; then
  echo "✓ Migration to split-portfolio v2 layout complete."
  echo "  Files moved to: $SIBLING_ROOT"
  echo "  Public-fork changes staged for review (git diff --cached)."
  echo "  Don't forget to commit + push the sibling repo as well:"
  echo "    cd $SIBLING_ROOT && git status"
else
  echo "✗ Migration left portfolio_validate broken — fix manually:"
  portfolio_validate
fi
```

The skill **does not commit** — staging is the contract; the operator owns both the public-fork commit AND the sibling-repo commit.

#### Why advisory, not silent

The migration moves real files between repos. If the operator has a custom workflow built on top of the in-fork `workspace/` location, an automatic move would silently break it. The y/n/dry-run pattern matches the deprecated-config-key offer in step 8 — operator owns each material change.

### 8b. Walk the intermediate-release migration chain

When an adopter jumps multiple releases at once (e.g. v1.0.0 → v1.4.0) the framework needs to run **every** per-version migration in order, not just the latest. The chain walker reads `.claude/framework-version` (the version anchor), compares against the latest upstream tag, builds the ordered list of pairs, and offers each migration with a `[Y / n / show-diff / skip-all]` prompt.

See [`AgDR-0032`](../../../docs/agdr/AgDR-0032-update-chain-migrations.md) for the design rationale (why a file anchor over derived signals, why per-pair scripts, why per-step confirmation).

This step **always** runs after step 8a (which handles the legacy single-shot split-portfolio v1→v2 detection — that migration is now also encoded as the `v1.2.0-to-v1.3.0.sh` chain script for adopters whose anchor file says they're still on v1.2.0). Step 8a remains as a fallback for adopters who lack the version anchor entirely and would otherwise miss the split-portfolio migration.

#### Detection

```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-migration-chain.sh"

# Where are we now? Falls back to "unknown" if the anchor is absent.
CURRENT_VERSION=$(migration_current_version)

# Operator override always wins (covers the "anchor lost" case).
if [ -n "$FROM_VERSION_OVERRIDE" ]; then
  CURRENT_VERSION="$FROM_VERSION_OVERRIDE"
fi

# Target is the latest tag we just pulled in.
TARGET_VERSION=$(git tag --list --sort=-v:refname --merged upstream/main | head -n 1)
```

If `TARGET_VERSION` is empty (no tags reachable — rare but possible on a fresh fork), skip the chain entirely and print one warning line.

#### "Unknown" anchor branch — interactive

If `CURRENT_VERSION="unknown"` AND `--from-version` was NOT passed:

```
ApexYard /update: no .claude/framework-version anchor in this fork.
This is normal on a fork created before framework v1.4.0.

To run the per-version migration chain to <TARGET_VERSION>, I need to
know which release this fork was last aligned with. Options:

  [a] v1.0.0  — earliest tagged release
  [b] v1.1.0
  [c] v1.2.0
  [d] v1.3.0  — most recent before <TARGET_VERSION>
  [e] skip migrations (files-only — advance anchor to <TARGET_VERSION> with no migrations)
  [f] abort sync

Choose [d]:
```

Default is the second-newest tag (most likely the case for adopters who synced recently but predate the anchor file). The list is built dynamically from `migration_known_versions` so future releases auto-extend the menu.

`[e]` is the same code path as `--skip-migrations` but reached through the interactive flow.

`[f]` aborts the sync, restores the original branch state, and exits 1 — the operator can rerun `/update --from-version vN.N.N` once they've confirmed which version their fork is on.

#### Build the chain

```bash
CHAIN=$(migration_chain "$CURRENT_VERSION" "$TARGET_VERSION")
```

| Result | Meaning |
|--------|---------|
| Non-empty newline-separated list | The chain we'll walk |
| Empty AND `CURRENT_VERSION = TARGET_VERSION` | Already up to date, skip cleanly |
| Empty AND `CURRENT_VERSION > TARGET_VERSION` | Going backwards — refuse; print warning; skip the chain |
| Empty AND a known link is missing | Refuse (a release without a migration script is a framework bug — log and bail) |

When the chain is non-empty, print the planned walk before running anything:

```
Per-version migration chain (3 steps):
  1. v1.0.0 → v1.1.0   (.claude/migrations/v1.0.0-to-v1.1.0.sh)
  2. v1.1.0 → v1.2.0   (.claude/migrations/v1.1.0-to-v1.2.0.sh)
  3. v1.2.0 → v1.3.0   (.claude/migrations/v1.2.0-to-v1.3.0.sh)

Each step is operator-confirmable. You can skip any individual step,
skip the rest with `skip-all`, or see the script with `show-diff`.
```

If `--dry-run` is set, print the chain and exit before any `migration_run` invocation.

#### Per-step prompt

For each pair in the chain, prompt:

```
Step N/M — <PAIR>
  Script: .claude/migrations/<PAIR>.sh
  Lines:  <wc -l output>

[Y] apply (run the script)
[n] skip this step (advance anchor anyway)
[d] show-diff (print the script body, then re-prompt y/n)
[a] skip-all remaining steps
```

Default `[Y]`. On `d`, print the script and re-prompt (no `d` recursion). On `a`, set a one-shot flag and skip all remaining steps (anchor still advances).

#### Run the migration

```bash
# Exit code contract:
#   0 — applied (or no-op success branch)
#   1 — conflict needs operator
#   2 — hard error

migration_run "$PAIR"
case "$?" in
  0)
    echo "  ✓ $PAIR applied"
    ;;
  1)
    echo "  ⚠ $PAIR reported a conflict — pausing the chain."
    echo "  Resolve manually, then resume with:"
    echo "    APEXYARD_RESUME_FROM=$PAIR /update"
    exit 1
    ;;
  2)
    echo "  ✗ $PAIR exited with a hard error — aborting chain."
    exit 2
    ;;
esac
```

#### After the chain (always)

Whether the operator applied all, skipped some, or used `--skip-migrations`, write the anchor:

```bash
migration_write_anchor "$TARGET_VERSION"
git add .claude/framework-version 2>/dev/null || true
```

Surfaces in the final-state report (step 9) as one line:

```
Framework version anchor advanced: <CURRENT> → <TARGET_VERSION>
```

If migrations were skipped, list them with the replay hint:

```
Skipped migrations (replay later with bash .claude/migrations/<pair>.sh):
  - v1.1.0-to-v1.2.0
  - v1.2.0-to-v1.3.0
```

### 8c. Topology drift detection (advisory, default-skip)

When a project was instantiated with a topology bundle (via `/handover --topology <name>` or the step 1.5 pick), the project's `projects/<name>/.topology/VERSION` records the topology version at instantiation time. If the framework has since bumped the topology's `VERSION`, the adopter's instantiated bundle is drifting from the curated baseline.

Walk the registry; for each project that has a `.topology/` anchor, compare versions:

```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-portfolio-paths.sh"

PROJECTS_DIR=$(portfolio_projects_dir)
OPS_ROOT="$(git rev-parse --show-toplevel)"

DRIFTED=()
for proj in "$PROJECTS_DIR"/*/; do
  [ -d "$proj" ] || continue
  anchor="$proj/.topology"
  [ -f "$anchor/name" ] || continue
  [ -f "$anchor/VERSION" ] || continue

  topology=$(cat "$anchor/name")
  instantiated_ver=$(cat "$anchor/VERSION")
  framework_ver=$(cat "$OPS_ROOT/topologies/$topology/VERSION" 2>/dev/null)

  if [ -z "$framework_ver" ]; then
    echo "⚠ $proj uses topology '$topology' but topologies/$topology/ is missing in this framework version."
    continue
  fi

  if [ "$instantiated_ver" != "$framework_ver" ]; then
    DRIFTED+=("$(basename "$proj")|$topology|$instantiated_ver|$framework_ver")
  fi
done
```

If `DRIFTED` is empty → skip this step entirely and continue to step 9.

If non-empty, surface the drift with a y/n/d offer per project — same shape as the deprecated-config offer in step 8:

```
Topology drift detected — N projects are behind the framework's topology bundle:

  - billing-api: topology=python-fastapi instantiated=1.0.0 → framework=1.1.0
  - dashboard:   topology=typescript-nextjs instantiated=1.0.0 → framework=1.2.0

Per-file diff acceptance — for each file in the topology, you'll see the
diff and pick:

  [Y] copy the framework version (overwrite the project's copy)
  [n] keep the project's copy (skip this file)
  [d] show the diff (then re-prompt)
  [s] skip this project entirely (advance to next)

Default per file is `n` (skip — operator owns the change).

Re-instantiate now? [y/N/dry-run]
```

| Input | Effect |
|-------|--------|
| `y` | Walk each drifted project; for each file in `topologies/<name>/handbooks/**` + `topologies/<name>/golden-paths/**` + `topologies/<name>/templates/**`, prompt y/n/d; on `y` copy the framework version over the project copy; on `n` skip the file. After all files for a project are processed, update `projects/<proj>/.topology/VERSION` to the framework version. |
| `N` / unrecognised | Skip this step entirely. Drift persists. Print: `Drift left in place. Re-run /update later or run /handover --topology <name> on the affected project to fully re-instantiate.` |
| `dry-run` | Walk each drifted project; for each file, print the diff and what would be copied, but execute no writes. |

#### Per-file prompt shape

```
Project: dashboard (typescript-nextjs 1.0.0 → 1.2.0)
File:    handbooks/architecture/migration-safety.md

[Y]es — copy framework version over project copy
[n]o  — keep project copy as-is
[d]iff — show the diff and re-prompt
[s]kip — skip this project entirely (advance to next)

[Y/n/d/s]
```

#### Why per-file (not bulk)

The deciding factor is the same as step 8b: adopters routinely edit topology handbooks in their project (tightening the rule, adding domain-specific examples, opting in to blocking enforcement). A bulk replace would silently destroy those edits; per-file lets the operator preview and choose.

#### Surfaces in the final-state report (step 9) as one line

```
Topology drift: <N projects drifted | skipped> ({…}, …)
```

This step **always** runs after step 8b. It does NOT block the sync — drift handling is purely additive and reversible (the operator can re-run `/update` later).

### 8d. Reconcile an installed Codex adapter

After framework files, migrations, config offers, and topology handling have
settled, refresh generated Codex output from the final canonical `.claude/`
state:

```bash
reconcile_installed_codex_adapter || {
  echo "Framework files synced, but the installed Codex adapter could not be reconciled." >&2
  echo "Fix the generator error, then retry: bash bin/sync-codex-adapter.sh --reconcile-installed" >&2
  exit 1
}
```

This step is intentionally after the merge and migrations so newly added or
rewritten skills, agents, and hooks are captured once. `check-upstream-drift.sh`'s
SessionStart hook complements this with a **read-only** advisory: on every
session it runs the generator's `--check-installed` mode (never writes) and
prints a one-line nudge to stderr if the installed adapter has drifted from
what `.cl

…(truncated)
