# Sync

> Make this machine match your personal agent repo — the private repo holding your skills, global CLAUDE.md, and shared Claude Code settings. On first run it clones an existing private repo or safely creates one from loose configuration; after that it ingests the current remote before reconciliation, then commits and pushes this machine's changes. Re-links anything that drifted back into ~/.claude, lints for paths that would be wrong on another machine, and optionally triggers a pull on your other machines. Trigger: "sync my config", "sync my machines", "update my skills from my repo", "is this machine up to date", or /harness:sync. Do NOT use as the first-time user-facing setup workflow (that's /harness:setup), for creating the model rubric (that's /harness:model-rubric), or for anything in a project repo — sync only touches this developer's user-global agent config. Harness setup invokes this skill internally for its existing repository, link, and portability mechanics.

- Skill: `studio-moser/sync` (Agent Skill)
- Install (CLI): `npx skillmds@latest add studio-moser/sync`
- Raw SKILL.md: https://api.skillmd.com/api/skills/studio-moser/sync/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: Studio-Moser (https://skillmd.com/u/studio-moser)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/studio-moser/sync

---


# Harness — Sync

Makes this machine match your personal agent repo.

## Composition boundary

`harness:setup` invokes this skill for repository discovery or cloning, link
reconciliation, and portability checks. Run the same phases and preserve every
existing prompt, conflict stop, authentication boundary, and report field. Return
the Phase 4 report to Setup; do not make rubric decisions or capability claims on
Setup's behalf.

**Default repo:** `$HOME/.agents`. If `$AGENTS_REPO` is set, use that instead.

---

## Dry run

If the user asks what would change, or passes `--dry-run`, run **only the
read-only pieces** — `link-plan.sh` (Phase 1's command),
`reconcile_shared_settings.py --check` for the shared settings file,
`mcp-manifest.sh --check` for the portable MCP manifest, Phase 2.5's MCP
reconcile block (table and plan only, no question), Phase 2.6's step 0
*detection* block (the `git
ls-files --error-unmatch` tracked-check and the `.gitignore` presence
check — not the fix block right after it) and step 1 (the
`skills-reconcile.sh` call, nothing that follows it),
`portability-lint.sh` (Phase 3's command), and `rubric-audit.sh` (Phase 3.5's
command — it only reads transcripts) — then print the report from
Phase 4 and stop. Phase 2.5's MCP block only reads the portable manifest and Claude Code's
machine-local user registry, then prints the per-host table and plan lines;
it belongs in a dry run because that's exactly the kind of thing someone
previewing a sync wants to see. The match / replace / merge question is not
asked and nothing is installed, removed, or imported.
Phase 2.6's step 0 detection and step 1 are the same shape: `git ls-files
--error-unmatch` and `grep` only read, and `skills-reconcile.sh` only
reads the manifest, `.fleet-local.json`, and `npx skills list -g --json`
output — all of them print findings without touching disk.

Skip everything else, explicitly:

- **Phase 2.2** (render `codex/AGENTS.md`) — a write; report
  `Derived: [skipped in dry run]`.
- **Phase 2's reconciliation and Phase 3.75's final transaction** — they write
  shared settings, manifests, the repo, or the remote.
- **Phase 0.5's remote preflight** — it may fetch and fast-forward the repo.
- **Phase 2.5's plugin half** (marketplace add / plugin install) — installs
  software. Run only its MCP reconcile block's table and plan, not the question or anything after it.
- **Phase 2.6's step 0 fix block** (the `.gitignore` write and `git rm
  --cached`) — a write, and a one-time repo migration, not
  something a preview should perform. Only step 0's detection block above
  it belongs in a dry run.
- **Phase 2.6's step 2 (installs, removals, override writes) and step 3
  (the `skills-manifest.sh` regeneration)** — running either would write
  real files or install real software; skip both. Only step 1's read/
  compare/report belongs in a dry run.
- Phase 1's action column (create/re-link/remove) and Phase 3's fix
  suggestions — both are writes, not part of the dry run.

Nothing is created, installed, moved, or removed. The read-only pieces that do
run are read-only, so this is safe to offer unprompted when the user seems
unsure.

---

## Phase 0: Locate the repo

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
[ -d "$repo/.git" ] && echo "found" || echo "absent"
```

`link-plan.sh` resolves `$claude` the same way internally — use this `$claude`
variable everywhere below instead of hardcoding `~/.claude` or `$HOME/.claude`,
so the skill and the script always agree on which directory is being managed.

**Each phase below may run as a separate command, in a separate shell.**
Nothing set in one command block — `repo=`, `claude=`, and later
`diff_status=`, `$diff_err`'s path — persists into the next. Re-resolve
`repo=`/`claude=` at the top of any later block that references them, rather
than assuming Phase 0's values are still set. Same rule for the diff
sequence in Phase 1: run the `diff`, the `$diff_err` check, the resulting
removal, and the `rm -f "$diff_err"` cleanup as one command block, not as
separately-issued commands — splitting them loses the temp file's path and
the `diff_status` value in between.

**absent** — first run on this machine. Do not assume a remote already exists.
Continue through First-run safety and adoption below.

**found** — continue to Phase 0.5.

---

## First-run safety and adoption

Run this section only when Phase 0 reported `absent`. It is the one path allowed
to create the personal repository; ordinary Sync runs never re-bootstrap it.

### 0.1 Back up the live configuration before any destructive step

Create a new timestamped archive under `$HOME` containing every present live entry
managed by `link-plan.sh`: `skills`, `output-styles`, `CLAUDE.md`, `settings.json`,
`statusline-command.sh`, the cross-tool `studio-moser` config directory, and Codex
`AGENTS.md`. Back up a present legacy `$claude/mcp.json` separately in the same
archive before cleaning it up, but never copy Claude Code's global `.claude.json`
state or adopt either file into Git. Resolve configured roots exactly as Phase 1 does.

Append each present entry to the archive separately. Missing optional entries are
normal and must not make the archive fail. Never overwrite an earlier backup, and
keep the new archive until Phase 1 reports every expected link resolved and
`settings.json` parses. No live path may be removed or replaced before its content
is either in this archive or explicitly declined after a shown diff.

### 0.2 Choose the source of truth

Ask one question:

- **Existing private repository** — ask for its exact URL; never guess it. Clone it
  into `$repo`, run Phase 0.5, then use Phase 1's recursive diff and keep/discard
  prompts for every live file or directory that conflicts with the clone.
- **Loose configuration with no repository yet** — adopt the current machine using
  the procedure below.

For an existing repository:

```bash
git clone <url> "${AGENTS_REPO:-$HOME/.agents}"
```

If the clone fails on authentication, say so plainly and stop — do not fall back
to another protocol without asking. A common cause is an SSH remote with no key
loaded (`ssh-add -l` reports no identities); `gh auth status` will show whether
HTTPS is the configured protocol instead.

For loose configuration:

1. If skills exist in more than one live location, compare each duplicated pair
   recursively and read the differences. A newer modification time is not proof
   that a copy is correct. Resolve the winning content before consolidation.
2. Localize machine-only skill routing before copying shared `settings.json`:

```bash
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
[ ! -e "$claude/settings.json" ] || "$harness/scripts/localize-skill-overrides.py" "$claude/settings.json" "$claude/settings.local.json"
```

   This removes only `skillOverrides` from the shared file and merges it into
   live `settings.local.json`, preserving every other local key.
3. Initialize an otherwise empty `$repo` on `main`. Do not copy any managed entry
   into it yet.
4. After the archive and duplicate inspection establish the intended loose source,
   ask for the exact private remote URL and configure it as `origin`; never invent
   one. Confirm the remote is private and its target branch is absent. If the branch
   already exists, stop and use the existing-repository clone path instead. On
   authentication failure, stop rather than silently switching protocols. A full
   Sync cannot continue without a remote because preflight must bind the final push
   before any repository content is adopted.
5. **Continue to Phase 0.5**, not Phase 1. Do not copy, link, render, reconcile, or
   otherwise write repository content until preflight reports ready.

---

## Phase 0.5: Ingest the remote before any reconciliation

Run this exactly once on every full Sync after locating a normal repository,
cloning an existing repository, or initializing an empty loose-adoption
repository. It is the single join point before Phase 1 or any newly adopted
content can write shared state. Do not run it in dry-run mode because a clean
repository may be fetched and fast-forwarded.

```bash
set -euo pipefail
repo="${AGENTS_REPO:-$HOME/.agents}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/sync-preflight.sh" "$repo"
```

The helper queries the actual remote branch. A clean repository is fetched and
fast-forwarded before any derived output is computed. Local work may continue
only while that remote still matches the already-known tracking SHA. If both
local work and a newer remote exist, it stops with an explicit instruction to
commit or stash the local work, ingest the remote, and rerun Sync. Divergence
also stops; never merge, rebase, or force automatically. A remote branch that
does not exist yet is a valid first-push state.

If the remote moves after this preflight, Phase 3.75 queries the actual remote
again and stops before commit or push. It never pulls after the derived files
and scans have run.

---

## Complete loose-config adoption after preflight

Run this section only for the loose-configuration path after Phase 0.5 reports
ready. Normal and cloned repositories skip directly to Phase 1.

1. Create `skills/`, `claude/`, `config/studio-moser/`, and `codex/` under `$repo`
   as needed. Copy every present managed entry into its Phase 1 repo path except
   machine-local MCP state; copy, do not move, so the originals remain recoverable
   until verification. Generate the secret-free `$repo/mcp.manifest.json`
   by reading each top-level `mcpServers` entry's secret-free shape from Claude Code's global
   `.claude.json` state. Never copy that state file. Do not adopt Codex `AGENTS.md` as a source:
   preserve any unique instruction in `House Style.md` or `CLAUDE.md`, then let
   Phase 2.2 render the derived file.
2. Keep local-only state out of Git: `claude/mcp.json`, `settings.local.json`,
   runtime project/session stores, credentials, secret-bearing profiles, resolved
   machine paths, approvals, temporary evidence, Shelby state,
   `.fleet-local.json`, and `.skill-lock.json`.
3. Continue to Phase 1 and replace only verified originals with links; the backup
   remains the recovery copy. Complete every later reconciliation and Phase 3's
   portability rules before Phase 3.75 stages and synchronizes the full result.

---

## Phase 1: Link check

**Remote ingestion must already be complete.** `CLAUDE.md` and `settings.json` are both rewritten by
tooling — a memory tool's bootstrap block edits one, Claude Code writes the other
on plugin toggle. A writer that does atomic-replace (temp file + rename) rather
than write-in-place silently converts a symlink back into a real file, and sync
stops working with no signal. This phase is how that gets noticed.

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/link-plan.sh" "$repo"
```

Each line ends in a state:

| state | meaning | action |
|---|---|---|
| `ok` | correct symlink | nothing |
| `ABSENT` | no such path in `$claude` | create the link |
| `REAL-FILE` | a real file (or directory — `skills`, `output-styles`, and `studio-moser` are directories among the seven portable entries) sits where the link should be | **diff first** (below) |
| `RELINK(->X)` | symlink points somewhere else | show `X`, confirm, re-link |
| `MISSING-IN-REPO` | the repo has no such file | report; do not create anything |

Claude Code's global `.claude.json` state is deliberately absent from this table.
Commands, arguments, environment variables, credentials, OAuth data, and application
state are machine-local. Phase 2.5 compares its top-level user-scope MCP servers
to the portable `mcp.manifest.json`; Sync never copies, links, prints, or tracks the
global state file.

**`MISSING-IN-REPO` is checked first and masks the other states.** If the
repo lacks the file, `link-plan.sh` reports `MISSING-IN-REPO` for that entry
no matter what `$claude` currently has there (a real file, a drifted
symlink, or nothing) — the five states are not independent signals about
both sides at once. Don't infer "no real file exists on this machine" from a
`MISSING-IN-REPO` line.

`link-plan.sh` resolves both paths before comparing them, so equivalent relative
and absolute targets both report `ok`. A `RELINK(->X)` result therefore means the
resolved target is genuinely different.

**On `REAL-FILE`, never overwrite silently.** That file may hold edits made on
this machine since the link broke. `skills` is a *directory*, so the diff must
be recursive:

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
diff_err="$(mktemp)"
diff -ru "$repo/<rel>" "$claude/<name>" 2>"$diff_err"
diff_status=$?
```

`diff_status`, not `status` — `status` is a read-only special variable in
zsh (an alias for `$?`), and assigning to it aborts the command with
`read-only variable: status` instead of setting anything. Since this skill
runs its Bash blocks through whatever shell the agent's tool uses, which may
be zsh, name it something the shell won't reject.

**First, before looking at `diff_status` at all: if `$diff_err` is
non-empty, stop and report.** Do not remove or re-link anything, and do not
fall through to the status bullets below — a stderr line means part of the
comparison didn't happen, so no status value is trustworthy:

```bash
if [ -s "$diff_err" ]; then cat "$diff_err"; rm -f "$diff_err"; exit 1; fi
```

This matters because BSD `diff -r` (macOS's default `diff`) can leave a
non-empty `diff_err` — a `diff: …: Permission denied` line from a
subdirectory it can't read — under two distinct exit codes. If that
unreadable subtree is the *only* difference, `diff -r` exits **0**
("identical") even though part of the tree was never compared, which would
otherwise walk straight into the keep/discard prompt below and `rm -r` a
tree that was never actually fully compared. If a *different*, readable part
of the same tree also genuinely differs, `diff -r` instead exits **1**
(`diff_status=1`) for that real difference, while the unreadable subtree is
still unchecked. Either exit code is untrustworthy once `diff_err` is
non-empty, which is why it's checked first and unconditionally, before
`diff_status` is read at all — removing the need to repeat the check on
every status branch.

Once `$diff_err` is confirmed empty, branch on `diff_status`:

- **`diff_status` = 0 (identical)** → remove the stray file (or `rm -r` the
  stray directory) and re-link.
- **`diff_status` = 1 (differs)** → show the diff and ask: keep the machine's
  version, or discard it. Never pick for the user. **Either way, remove the
  stray path before re-linking** — re-linking does not itself replace a real
  file or directory (see the `ln -sfn` note below), so skipping the removal
  step lands the new link inside the surviving path instead of replacing it,
  and the run reports success while the drift persists:
  - *keep, file*: `cp "$claude/<name>" "$repo/<rel>"` (commit it in the repo
    if the user wants it tracked), **then `rm "$claude/<name>"`**, then
    `ln -sfn`.
  - *keep, directory (e.g. `skills`)*: copy **contents**, not the directory
    itself — `cp -R "$claude/<name>/." "$repo/<rel>/"`. A plain
    `cp -R "$claude/<name>" "$repo/<rel>"` nests one level too deep
    (`$repo/<rel>/<name>/…`) because both sides already exist as
    directories, so the file ends up somewhere the repo's own `<rel>` path
    doesn't cover — "keep the machine's version" would silently not happen
    even though the command succeeds. **Then `rm -r "$claude/<name>"`**, then
    `ln -sfn`.
  - *discard*: **`rm -r "$claude/<name>"`**, then `ln -sfn` to the repo's
    version.
- `diff_status` >= 2 without a `diff_err` message is not expected from
  `diff`, but treat it the same as the stderr case above: stop and report,
  take no action.

**Whichever branch fires, clean up the temp file once done:**
`rm -f "$diff_err"`. All three branches above create and leave `$diff_err`
behind — this isn't specific to the `>= 2` case.

A plain `diff -u` here is a data-loss trap: non-recursive `diff` against
two directories prints only `Common subdirectories: …` and exits 0 even
when their contents differ, so the "no differences" branch would fire and
delete a `skills` tree that might hold this machine's only copy of local
edits. `-r` is required, not optional.

To create or re-link, use:

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
ln -sfn "$repo/<rel>" "$claude/<name>"
```

An absolute or relative target is accepted; `link-plan.sh` compares canonical
resolved paths. Prefer a relative target when the link itself will be tracked,
because Phase 3 rejects absolute targets in Git.

**The `studio-moser` entry lives under `${XDG_CONFIG_HOME:-$HOME/.config}`, not `$claude`.**
For it, the link is `"${XDG_CONFIG_HOME:-$HOME/.config}/studio-moser"` and the target is
`"$repo/config/studio-moser"`. Create the parent first — `mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}"` —
a fresh machine may not have it. The REAL-FILE diff/keep/merge procedure applies to it exactly
as it does to `skills` (both are directories). When keeping the machine's copy, also ensure
`$repo/.gitignore` covers `config/studio-moser/*.bak*` so local backup files never sync.

**The `AGENTS.md` entry lives under `${CODEX_HOME:-$HOME/.codex}`, not `$claude`.**
For it, the link is `"${CODEX_HOME:-$HOME/.codex}/AGENTS.md"` and the target is
`"$repo/codex/AGENTS.md"`. Create the parent first — `mkdir -p "${CODEX_HOME:-$HOME/.codex}"` —
a fresh machine may not have Codex installed yet, and the link is still correct to create.
It is a single file, so on REAL-FILE the diff is a plain `diff -u` (not
`-r`). The target is generated by Phase 2.25, so **never `cp` a real
`~/.codex/AGENTS.md` into the repo** — the render would overwrite it before
commit and the machine's hand-written instructions would silently vanish.
Instead: show the diff, move the file aside (`mv "$link"
"$link.bak-$(date +%F)"` — the backup lives under `~/.codex`, not the repo),
tell the operator that anything worth keeping belongs in `House Style.md` or
`CLAUDE.md` (the sources), then link.

**For a real `settings.json` whose machine version is kept, localize before the
keep-file `cp`.** Run this in the same command block as the copy so the sanitized
file, never its machine-only routing, becomes the repo copy:

```bash
set -euo pipefail
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/localize-skill-overrides.py" "$claude/settings.json" "$claude/settings.local.json"
cp "$claude/settings.json" "$repo/claude/settings.json"
```

`-sfn` only repoints an **existing symlink** (that's what its `-n` guards —
it treats the destination as the link itself, not as a directory to drop the
link into). It does **not** replace a real file or a real directory: run
against a real path — including a `REAL-FILE` directory like `skills`, or a
`skills` directory left over after only *part* of a `diff_status` = 1 cleanup ran —
`ln -sfn` creates `"$claude/<name>/<basename of $repo/<rel>>"` inside the
surviving path and exits 0, reporting success while nothing was actually
replaced. The real file or directory **must be removed (or moved aside)
first**, on every branch that re-links over an existing path, not only the
symlink-to-directory case.

---

## Phase 2: Reconcile shared and derived state

The Git transaction occurs only in Phase 3.75. Phase 2 prepares every shared write
so validation can see the complete staged result before anything reaches the remote.

### 2.1 Reconcile shared settings

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
[ ! -e "$repo/claude/settings.json" ] || "$harness/scripts/localize-skill-overrides.py" "$repo/claude/settings.json" "$claude/settings.local.json"
[ ! -e "$repo/claude/settings.json" ] || "$harness/scripts/reconcile_shared_settings.py" "$repo/claude/settings.json"
git -C "$repo" status --short
```

The localization command removes machine-only skill routing from the shared file.
The settings reconciliation then removes `machine@studio-moser` and requires
`harness@studio-moser: true`. Both run before staging. In dry-run mode, use
`--check` for the shared settings helper and do not run either writer.

### 2.2 Render derived files

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/render-codex-agents.sh" "$repo"
```

`codex/AGENTS.md` is Codex's global instructions and is **generated** from the
Claude-side sources (`claude/output-styles/House Style.md` and `claude/CLAUDE.md`)
so House Style stays the one file you edit. It runs here — after settings
reconciliation and before staging — so a machine whose sources moved
gets one fresh render in the same transaction. If it prints
`RENDER_STATE=regenerated`, leave it uncommitted for Phase 3.75.
`RENDER_STATE=failed: <reason>` (exit 3) means a source or a required section is
missing — nothing was written; carry the reason into the report and continue.
Never hand-edit `codex/AGENTS.md`; the next sync overwrites it.

**If Phase 1 reported `AGENTS.md -> codex/AGENTS.md MISSING-IN-REPO`**, the file
exists now (pulled or rendered): create the link here — `mkdir -p
"${CODEX_HOME:-$HOME/.codex}"` then `ln -s "$repo/codex/AGENTS.md"
"${CODEX_HOME:-$HOME/.codex}/AGENTS.md"` — unless a real file sits there, in which
case follow the Phase 1 `AGENTS.md` REAL-FILE rule (move it aside, then link).

### 2.3 Generate the portable MCP inventory and clean up the legacy tracked file

Claude Code stores user-scope MCP servers in the top-level `mcpServers` object of
`${CLAUDE_CONFIG_DIR}/.claude.json` when `CLAUDE_CONFIG_DIR` is set, or
`$HOME/.claude.json` otherwise. Read that file only to generate
`mcp.manifest.json`: each server's portable shape (`type`, `command`, `args`,
`url`, `env`, `headers`) with every env and header value replaced by a `${NAME}`
reference, plus a `machines` list naming the hosts that have it. The generator
adds this host to every server present here and removes it from every server
that is not. Never copy, link, print, stage, or commit the state file.

A names-only `mcp.manifest` from an older sync is migrated once: its names
become shapeless entries (`{"machines": []}`), the old file is untracked and
deleted, and the generator prints `MCP_MANIFEST_STATE=migrated`. A shapeless
entry shows as `NO-CONFIG` in Phase 2.5 until a machine that has the server
syncs.

The generator fails, naming the server only, when an arg looks like a token or
a `--flag=value` with a long value, or when `command` or an arg is an absolute
home path. Fix the server locally (move the secret to `env`, or the binary onto
`PATH`) and rerun.

`$claude/mcp.json` is a legacy path. If an older repo tracks it or the live legacy
path is a symlink into the repo, preserve its resolved bytes as a regular live file
before untracking it. Never print its command, args, URL, headers, or environment:

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
runtime_mcp="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
legacy_runtime="$claude/mcp.json"
if [ -L "$legacy_runtime" ]; then
  temporary="$(mktemp "$claude/.mcp.json.migrate.XXXXXX")"
  trap 'rm -f "$temporary"' EXIT HUP INT TERM
  cp -pL "$legacy_runtime" "$temporary"
  unlink "$legacy_runtime"
  mv "$temporary" "$legacy_runtime"
  temporary=""
  trap - EXIT HUP INT TERM
fi
if [ -f "$runtime_mcp" ]; then
  "$harness/scripts/mcp-manifest.sh" "$runtime_mcp" "$repo/mcp.manifest.json" || exit $?
else
  echo "MCP_STATE=not configured"
fi
grep -qxF 'claude/mcp.json' "$repo/.gitignore" 2>/dev/null || printf '%s\n' 'claude/mcp.json' >> "$repo/.gitignore"
git -C "$repo" rm --cached claude/mcp.json --ignore-unmatch -q
```

If the user registry is missing, report `MCP_STATE=not configured` and do not
invent a manifest. In dry-run mode, do not run this block; validate an existing
manifest with `mcp-manifest.sh --check "$repo/mcp.manifest.json"` and run Phase
2.5's reconcile block read-only.

---

## Phase 2.5: Reconcile installed plugins and MCP servers

This phase reconciles machine state before the final Git transaction. Plugin
operations may rewrite shared settings, so they must complete before Phase 3.75
stages and validates the final result. MCP comparison is read-only until the user
chooses how to reconcile the plan.

### Plugins — install what's missing, automatically

The plugin inventory is already in the tracked `settings.json` — read the
**repo's** copy (`$repo/claude/settings.json`), not the possibly-drifted live file
at `$claude/settings.json`
(Phase 1 runs first and would have already flagged or fixed any such drift,
but reading the repo copy here keeps both halves of this phase consistent on
principle rather than by coincidence). `extraKnownMarketplaces` names the
marketplaces this developer's config expects; `enabledPlugins` names the
plugins. **Merge `settings.local.json` over it** — `settings.local.json` is
never tracked (it's machine-local by design, per the "keep machine-local
things local" table), so it's always read from `$claude`, live. A local
`false` for a plugin the shared file marks `true` is a deliberate per-machine
override (how a local fork wins over a marketplace plugin of the same name),
and installing it anyway would overwrite that choice. Local wins.

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"

plugin_reconcile_script='import json, re, shutil, subprocess, sys

def load(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError:
        return {}

if shutil.which("claude") is None:
    print("PLUGINS_STATE=skipped: claude CLI not on PATH")
    raise SystemExit(0)

settings = load(sys.argv[1])
local = load(sys.argv[2])

marketplaces = settings.get("extraKnownMarketplaces", {})
enabled = dict(settings.get("enabledPlugins", {}))
enabled.update(local.get("enabledPlugins", {}))  # local wins over shared

def names(cmd):
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        cmd_str = " ".join(cmd)
        print(f"PLUGINS_STATE=failed: running {cmd_str} exited {r.returncode}: {(r.stderr or r.stdout).strip()}")
        raise SystemExit(0)
    return {m.group(1) for m in re.finditer(r"❯\s+(\S+)", r.stdout)}

have_marketplaces = names(["claude", "plugin", "marketplace", "list"])
have_plugins = names(["claude", "plugin", "list"])

added_marketplaces = []
for name, cfg in marketplaces.items():
    if name in have_marketplaces:
        continue
    repo = cfg.get("source", {}).get("repo")
    if not repo:
        continue
    r = subprocess.run(["claude", "plugin", "marketplace", "add", repo], capture_output=True, text=True)
    if r.returncode == 0:
        added_marketplaces.append(name)
        have_marketplaces.add(name)
    else:
        print(f"marketplace add failed: {name} ({repo}): {(r.stderr or r.stdout).strip()}", file=sys.stderr)

installed_plugins = []
for name, on in enabled.items():
    if not on or name in have_plugins:
        continue
    r = subprocess.run(["claude", "plugin", "install", name], capture_output=True, text=True)
    if r.returncode == 0:
        installed_plugins.append(name)
        have_plugins.add(name)
    else:
        print(f"plugin install failed: {name}: {(r.stderr or r.stdout).strip()}", file=sys.stderr)

# Update pass: refresh every marketplace, then bring each enabled plugin to the
# latest version the marketplace resolves. Third-party marketplaces have
# auto-update OFF by default, so without this a machine only ever gets the
# version it first installed.
r = subprocess.run(["claude", "plugin", "marketplace", "update"], capture_output=True, text=True)
if r.returncode != 0:
    print(f"marketplace update failed: {(r.stderr or r.stdout).strip()}", file=sys.stderr)

updated_plugins = []
for name, on in enabled.items():
    if not on or name not in have_plugins:
        continue
    r = subprocess.run(["claude", "plugin", "update", name], capture_output=True, text=True)
    out = (r.stdout or "") + (r.stderr or "")
    if r.returncode != 0:
        print(f"plugin update failed: {name}: {out.strip()}", file=sys.stderr)
    elif "updated from" in out:
        updated_plugins.append(name)

# Orphans: registered on this machine but not declared in shared settings.
# Report only — removal uninstalls the marketplace plugins, so the user runs it.
for name in sorted(have_marketplaces - set(marketplaces) - {"claude-plugins-official"}):
    print(f"orphan marketplace: {name} registered here but not in shared settings — remove with: claude plugin marketplace remove {name}  (also uninstalls its plugins)", file=sys.stderr)

if not added_marketplaces and not installed_plugins and not updated_plugins:
    print("PLUGINS_STATE=up to date")
else:
    print(f"PLUGINS_STATE=added {len(added_marketplaces)} marketplace(s), installed {len(installed_plugins)}, updated {len(updated_plugins)} — restart or /reload-plugins to apply")
'

printf '%s\n' "$plugin_reconcile_script" | python3 - "$repo/claude/settings.json" "$claude/settings.local.json"
```

`names()` fails closed: a non-zero exit from `claude plugin list` or
`claude plugin marketplace list` reports `PLUGINS_STATE=failed: ...` and
stops rather than treating empty/error output as "nothing installed," which
would otherwise try to install everything. The `claude` CLI itself missing
from `$PATH` is checked up front the same way, before any subprocess call.

Anything printed to stderr above (a failed marketplace add or plugin install)
is a finding — carry it into the Phase 4 report the same way an unfixed lint
finding is carried, never silently. **Plugin installs need a restart to take
effect** — the `PLUGINS_STATE` line already says so whenever it installed
anything; repeat it in the report.

The update pass runs every time: `claude plugin marketplace update` refreshes all
registered marketplaces, then `claude plugin update <name>` runs for each enabled
plugin. This exists because third-party marketplaces have auto-update **off** by
default — a machine that only installs would stay on its first-installed version
forever. `updated K` in `PLUGINS_STATE` counts plugins whose version actually
changed; a `plugin update failed:` line is a finding. An `orphan marketplace:`
line means this machine has a marketplace registered that shared settings no
longer declare (a retired plugin's source, typically). Sync never removes it —
removal also uninstalls that marketplace's plugins — so carry the printed
`claude plugin marketplace remove <name>` command into the report for the user
to run.

### MCP servers — compare, choose, apply

Read the tracked `mcp.manifest.json` and the machine-local user registry
(`${CLAUDE_CONFIG_DIR}/.claude.json` when configured, otherwise
`$HOME/.claude.json`). Validate the manifest first. The reconcile script is
read-only: it prints a table with one column per host that has ever synced plus
`here`, a blank line, then plan lines. Honour `disabledMcpjsonServers` in
`settings.local.json`; a server disabled here is not a finding. Never print a
command, URL, header, env value, or credential; findings name servers and
variable names only.

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
runtime_mcp="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
[ ! -e "$repo/mcp.manifest.json" ] || "$harness/scripts/mcp-manifest.sh" --check "$repo/mcp.manifest.json" || exit $?
[ -f "$runtime_mcp" ] || { echo "MCP_STATE=not configured"; runtime_mcp=/dev/null; }
"$harness/scripts/mcp-reconcile.sh" "$repo" "$runtime_mcp" "$claude/settings.local.json"
```

`/dev/null` as the registry makes the planner treat this machine as empty, so
every declared server shows as `INSTALL` or `NO-CONFIG`; that is the correct
picture for a machine that has never added a server.

**Read the plan here; do not carry it in a shell variable** (nothing persists
between blocks, see Phase 0). The plan line kinds:

| line | meaning |
|---|---|
| `INSTALL <name>` | declared with a shape, not here |
| `NO-CONFIG <name>` | declared without a shape; nothing to install from until a machine that has it syncs |
| `SKIP <name>` | declared, not here, recorded in `.fleet-local.json` `skipMcp` |
| `EXTRA <name>` | here, not declared |
| `KEEP-LOCAL <name>` | here, not declared, recorded in `keepLocalMcp` |
| `NEEDS-SECRET <name> <VAR>` | declared or installed here, but `VAR` has no value on this machine |
| `UNRESOLVED <name>` | here, command not on `PATH` |

**In a dry run, stop after the table and plan.** Otherwise, if there is no
`INSTALL`, `NO-CONFIG`, `EXTRA`, `UNRESOLVED`, or `NEEDS-SECRET` line, print
`MCP_STATE=up to date` and continue to Phase 2.6. If the only lines are `NEEDS-SECRET`
or `UNRESOLVED` (or both), skip the question: run the Secrets step for the
`NEEDS-SECRET` lines, then the unresolved-command follow-up for the `UNRESOLVED`
lines, both below. Otherwise ask **one** question with exactly these
three options, listing the affected names under each:

1. **Match this machine to the repo** — install every `INSTALL` server here,
   then list the `EXTRA` servers and ask a second confirm before removing them
   from this machine. A declined removal is recorded as `keepLocalMcp`.
2. **Replace the repo with this machine** — the manifest's server set becomes
   this machine's set. List, by name, every server only other machines have,
   and confirm: the next sync on those machines will offer to remove them. On
   yes, record the choice on disk, then continue. Nothing is installed here:

   ```bash
   claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"; : > "$claude/.mcp-prune-to-local"
   ```
3. **Merge** — install every `INSTALL` server here and let Phase 3.75 add every
   `EXTRA` to the manifest. No removals anywhere.

Never pick for the user. `NO-CONFIG` and `SKIP` lines are reported, never acted
on.

**Installing** a server uses the manifest entry with its references intact.
Claude Code expands `${VAR}` from the environment at launch, and the secrets
flow below replaces the reference with the value in the live registry when the
user supplies one:

```bash
repo="${AGENTS_REPO:-$HOME/.agents}"
name="<name>"
claude mcp add-json -s user "$name" "$(python3 -c '
import json, sys
entry = json.load(open(sys.argv[1]))["servers"][sys.argv[2]]
print(json.dumps({k: v for k, v in entry.items() if k != "machines"}))
' "$repo/mcp.manifest.json" "$name")"
```

If the command exits non-zero, report `install failed: <name>` as an unresolved
finding and never write it to overrides.

**Removing** a server from this machine (match, after the confirm):

```bash
claude mcp remove -s user "<name>"
```

A non-zero exit is `remove failed: <name>`, an unresolved finding.

**Secrets.** For every `NEEDS-SECRET <name> <VAR>` line, whether from an install
or from a server already here, say once that pasted values pass through this
session's transcript, then print the command to run on a machine that has the
values:

```bash
echo "On the other machine, run its harness copy of mcp-secrets.sh:  \"\$(ls -d \$HOME/.claude/plugins/cache/*/harness/*/ | sort -V | tail -1)scripts/mcp-secrets.sh\" export \"\${AGENTS_REPO:-\$HOME/.agents}/mcp.manifest.json\" \"\${CLAUDE_CONFIG_DIR:-\$HOME}/.claude.json\""
```

Then ask for the values. The user may paste the whole export block at the first
prompt; feed everything received to `import`, which skips empty values and
names it does not find, and then skip the remaining prompts for names it
covered:

```bash
runtime_mcp="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/mcp-secrets.sh" import "$runtime_mcp" <<'EOF'
<NAME=value lines>
EOF
```

`import` prints counts only. Never echo a value back. A variable the user
leaves empty stays a `NEEDS-SECRET` finding in the report.

**Unresolved commands.** Whenever `UNRESOLVED <name>` lines exist, rerun the
reconcile block after installs if there were any, otherwise go directly to this
follow-up: list them once and ask a single question:
skip these on this machine? On yes, remove each with `claude mcp remove -s user
"<name>"` and record it as `skipMcp` (see Overrides in Phase 2.6), so a machine
without Blender does not keep a blender server. On no, they stay as
`<name> command unavailable on this machine` findings.

Servers present here but absent from the manifest after a choice, except those
recorded in `keepLocalMcp`, are picked up by Phase 3.75's regeneration, which
stamps this host into `machines`.

---

## Phase 2.6: Reconcile third-party skills

**The store.** `npx skills` (vercel-labs) hardcodes its install directory to
`$HOME/.agents/skills` — it never looks at `$AGENTS_REPO`. When this
developer's repo *is* `$HOME/.agents` (the default), `$repo/skills/<name>`
holds the real code for a skill and `$claude/skills` is a symlink to it, so
other agent config already reads it directly (Phase 1 covers that link like
any other tracked entry). Some skills are authored right here in the repo;
others are installed from elsewhere with `npx skills` and should be
*declared*, not vendored. `npx skills list -g --json` reports every skill on
this machine across every agent, each with a `source` field — `null` for
locally-authored skills, `"owner/repo"` for an installed third-party one.
That field is the only thing that tells the two apart; a skill's own files
don't say where it came from.

`$repo/skills.manifest` is the developer's **declared** set of third-party
skills — one `name<TAB>source` line each, sorted, generated, never
hand-edited. It's regenerated from reality at the end of this phase, so a
skill removed by any means (this machine, another machine, or by hand)
simply disappears from the next regeneration. Other agents (Cursor, Pi, …)
symlink into the same store and manage themselves — never touch `~/.cursor`
or `~/.pi`. Codex reads the store natively, so it needs no registration and
this plugin writes nothing under `~/.codex/skills`; the only thing it manages
under `~/.codex` is the `AGENTS.md` link (Phase 1). The manifest only ever
covers entries whose `path` falls under `$HOME/.agents/skills/`.

### 0. One-time migration: untrack `.skill-lock.json`

`npx skills` writes its own lockfile, `.skill-lock.json`, and reads a
skill's `source` back *from that lockfile* — not by inspecting the skill
itself. If this repo tracks it, the failure mode is silent and exactly the
kind this phase exists to prevent: machine A removes a skill, its lock
entry goes with it and that removal gets committed; machine B pulls, but
`skills/<name>` is gitignored so the pull can't

…(truncated)
