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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/commands/sync-orchestrator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
description: Re-stamp MANAGED files after a plugin update — reconciles scaffolded workflow/CI files in an already-onboarded repo using the version markers `/orchestrator:setup` writes, without ever touching user-owned files.
---

This reconcile flow lives in the `/orchestrator:sync` skill (`.claude/skills/sync/SKILL.md`), which compares
the version markers already scaffolded by `/orchestrator:setup` against the versions the current plugin
ships, and re-stamps anything that's behind — while flagging local edits instead of clobbering them — via
`.claude/skills/sync/sync.sh`.

Run `/orchestrator:sync` after updating the orchestrator plugin, whenever you want managed files (like
`.claude/workflows/feature-fanout.js`) brought up to date with the version the plugin now carries. This file
is kept as a pointer so `/sync-orchestrator` still resolves for anyone used to the old name.
92 changes: 92 additions & 0 deletions .claude/skills/sync/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
name: sync
description: Re-stamp MANAGED files after a plugin update in an already-onboarded repo. Reconciles scaffolded files (e.g. `.claude/workflows/feature-fanout.js`) against the version markers `/orchestrator:setup` wrote, without ever touching user-owned files. Use this whenever the orchestrator plugin has been updated and the user asks to "sync the orchestrator", "re-stamp managed files after a plugin update", update managed files to match the new plugin version, or run `/orchestrator:sync`.
---

You are running **sync** — the reconcile step that runs *after* a plugin update, in a repo that has already
been through `/orchestrator:setup`. Setup materializes files a plugin can't distribute directly (the adapter,
`CLAUDE.md`, the fan-out workflow, CI YAML) and stamps the ones it manages going forward with an
`@orchestrator-managed <name> vN` marker comment. When the plugin later ships a newer version of one of those
managed files, sync is what brings the consumer repo's copy up to date — it is the seam that lets a plugin
upgrade reach into repos that already onboarded, without a human re-running the whole interview.

## Ownership model (reuse the setup MANIFEST — do not invent a new scheme)
See `.claude/skills/setup/templates/MANIFEST.md` for the authoritative ownership classes. Sync only acts on
the **managed** row (today: `feature-fanout.js` -> `.claude/workflows/feature-fanout.js`). It is designed so
adding a new managed file later is a one-line addition to `sync.sh`'s managed-file table, not a rewrite.

Sync **never** touches user-owned files, under any circumstance:
- `.claude/gates.json`
- `CLAUDE.md`
- `.claude/settings.local.json`
- `.claude/state/`

Nor does it touch `ci`-owned files (`.github/workflows/gates.yml`, `.github/actions/setup/action.yml`) —
those are created once by setup if absent and otherwise left to the human; sync's job is strictly the
version-marked managed files, not the "create if absent" ci files.

## Flow

1. **Orient.** Confirm you're in a repo that has already run `/orchestrator:setup` (a `.claude/gates.json`
with real values, not the placeholder, is a good signal — but don't hard-block on it; sync is harmless to
run even if a managed file is simply missing, it will just report `missing`). Resolve the repo root as the
current working directory.

2. **Run the reconcile script.** All of the actual comparison/re-stamp logic is non-interactive, offline shell
— delegate to it rather than reasoning about file contents yourself:

```
bash ${CLAUDE_PLUGIN_ROOT:-.claude}/skills/sync/sync.sh
```

Run it from the repo root (no argument needed there — it defaults to the current directory). For each
managed file it prints one of:
- `missing` — the file setup would have created isn't there. Sync does not create it (that's setup's job,
since creating implies opting the repo in); report this to the user and suggest re-running
`/orchestrator:setup` if the omission is unintentional.
- `up to date` — the installed marker version already matches what the plugin ships, and content matches
the pristine template. Nothing to do.
- `restamped vX -> vY` — the installed file was behind and carried no local edits, so it was safely
overwritten with the new version.
- `conflict / needs-merge` — the installed file is behind **and** its content has diverged from the
pristine template it was originally stamped from (i.e. someone hand-edited it). Sync does **not**
overwrite this file. It leaves it exactly as-is on disk.
- `kept (newer)` — the installed marker version is *newer* than what this plugin ships (e.g. a
hand-authored bump). Never downgrade; left untouched.
- `user-owned — skipped by design` — printed for awareness only; these files are never written by sync.
- `error` — the plugin install itself looks broken (a managed file's shipped template is missing, or the
template carries no valid `@orchestrator-managed <name> vN` marker). This is not a per-repo verdict like
the others above — it means the plugin's own files are inconsistent. `sync.sh` exits nonzero (1) whenever
any `error` line is printed, distinct from every other outcome above (including `conflict`), which are
normal per-file verdicts that still exit 0. Surface `error` lines to the user prominently and suggest
reinstalling/updating the plugin rather than treating it as something to fix in the consumer repo.

3. **Report a diff summary.** Relay the script's per-file summary verbatim to the user (it's already in the
created/up-to-date/restamped/conflict/kept/skipped/error vocabulary above). Call out clearly which line, if
any, changed on disk (`restamped`) versus which are informational only, and call out `error` lines as a
plugin-install problem rather than a repo problem.

4. **Handle conflicts explicitly — never silently overwrite.** For every `conflict / needs-merge` line, tell
the user which file it is, that it has local edits diverging from the last pristine version it was stamped
from, and offer two paths — pick with the user, don't assume:
- **merge**: show the user the diff between their local copy and the new shipped template (e.g.
`diff -u <installed> <shipped-template>`), and if they confirm, write the merged result yourself (or, if
they'd rather take the new template wholesale and re-apply their local change afterward, do that
instead) — always propose the exact content and get an explicit "yes" before writing.
- **leave**: do nothing to that file. Note in your final summary that it's still behind and will be
flagged again on the next sync.
Under no circumstances write to a `conflict` file without the user's explicit go-ahead in this step —
`sync.sh` itself never does, and neither should you.

5. **Hand off.** Summarize: which managed files were checked, which were restamped, which need a human
merge decision (and what you did about it, if anything), and which are already current. Remind the user
that user-owned files (`gates.json`, `CLAUDE.md`, `settings.local.json`, `.claude/state/`) are never
touched by sync — those stay exactly as the human left them.

## Reference
- `.claude/skills/setup/templates/MANIFEST.md` — the template -> destination map and ownership classes this
skill reuses.
- `.claude/skills/sync/sync.sh` — the idempotent, offline implementation (safe to re-run any time; never
writes a user-owned file, never overwrites a file with local edits without being told to).
- `.claude/skills/setup/scaffold.sh` — the sibling script this mirrors; scaffold.sh handles first-time
creation, sync.sh handles ongoing reconciliation of what scaffold.sh already created.
210 changes: 210 additions & 0 deletions .claude/skills/sync/sync.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# sync.sh — idempotent, offline RECONCILE for `/orchestrator:sync` (issue #38).
#
# This is the sibling of `.claude/skills/setup/scaffold.sh`. scaffold.sh creates files
# the first time (`/orchestrator:setup`); sync.sh brings already-created MANAGED files
# up to date after a plugin update, using the same `@orchestrator-managed <name> vN`
# marker convention scaffold.sh stamps. It never creates a missing managed file (that's
# setup's job — creating implies opting the repo in) and it NEVER touches user-owned
# files. This script does ONLY non-interactive, non-network comparison/copy work — the
# prose flow (explaining results, offering to merge a conflict) lives in SKILL.md.
#
# Usage:
# sync.sh [target-repo-root]
#
# target-repo-root defaults to the current working directory. Pass an explicit path
# (e.g. a $TMPDIR scratch dir) to dry-run against a throwaway target instead of a real
# checkout — this is how the idempotency/conflict demo in the sync skill is run.
#
# Exit code: 0 on success (including a `conflict` verdict — deciding what to do about
# a conflict is a human/SKILL.md decision, not sync.sh's). Nonzero (1) if the plugin
# install itself looks broken — a shipped template is missing or carries no valid
# version marker (see the `error:` action below). A malformed/oversized version marker
# on an INSTALLED file is a per-file `conflict`, not a broken-install error, so it does
# not by itself change the exit code.
# Prints a per-file action summary (missing / up to date / restamped / conflict / kept
# / user-owned-skipped / error).
set -euo pipefail

# --- Resolve paths ----------------------------------------------------------------
# Templates live in the sibling `setup` skill so sync.sh re-stamps with the exact same
# pristine bytes scaffold.sh would have written.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
templates_dir="$script_dir/../setup/templates"

target_root="${1:-$PWD}"
target_root="$(cd "$target_root" && pwd)"

echo "orchestrator sync: reconciling managed files in $target_root"

# --- single source of truth: the TEMPLATE's own marker line, not scaffold.sh --------
# The byte sequence that actually lands on disk on a restamp is the literal marker line
# inside the template file (e.g. `// @orchestrator-managed feature-fanout v1`), NOT
# scaffold.sh's `MANAGED_VERSION=N` shell variable. Those used to be two independently
# maintained numbers that could drift apart (e.g. someone bumps MANAGED_VERSION without
# updating the template's marker comment, or vice versa). If they drift, restamping
# from "shipped_version = scaffold.sh's MANAGED_VERSION" while the template itself still
# carries the OLD marker means the freshly-restamped file's on-disk marker no longer
# equals the version sync.sh believes it just wrote — so the very next run sees the file
# as "behind" again and restamps it forever, breaking the idempotency contract.
#
# Reading shipped_version directly out of the template file (via `managed_version_of`,
# the same helper used below for the installed file) makes this inherently idempotent:
# after a restamp, installed marker == template marker by construction (it's a
# byte-for-byte `cp`), so the next run always reports "up to date". There is
# deliberately NO parse of scaffold.sh anywhere in this script.

# --- managed-file table -------------------------------------------------------------
# One entry per managed file: "template-name|dest-relpath|marker-prefix"
#
# marker-prefix MUST match the `@orchestrator-managed <name> v` convention scaffold.sh
# stamps (scaffold.sh hardcodes its own copy of this string as MARKER_PREFIX). The two
# scripts are coupled on this format string by necessity — both write/read the same
# on-disk marker — so keep them in sync if the convention ever changes. Adding a future
# managed file is exactly one more line here; its shipped_version is derived below
# straight from its own template, so there is nothing else to wire up.
MANAGED_FILES=(
"feature-fanout.js|.claude/workflows/feature-fanout.js|@orchestrator-managed feature-fanout v"
)

# --- user-owned files: NEVER written by sync, only reported for visibility ---------
USER_OWNED_FILES=(
".claude/gates.json"
"CLAUDE.md"
".claude/settings.local.json"
".claude/state/"
)

# --- helpers ------------------------------------------------------------------------
managed_version_of() {
# Prints the version number found in $1's marker line (matched via the literal
# marker prefix $2), or empty if no marker line / the file doesn't exist.
local f="$1" prefix="$2"
[ -f "$f" ] || { echo ""; return 0; }
grep -F -- "$prefix" "$f" 2>/dev/null | head -1 | grep -o '[0-9]\+$' || true
}

is_sane_version() {
# Bounded sane-integer check: 1-9 digits (covers up to 999,999,999 — comfortably more
# than any real version counter will ever reach). This guards against a malformed or
# absurdly oversized marker (e.g. a 20+ digit number) reaching the `-gt`/`-eq`
# integer comparisons below: under `set -e`, a `[ "$x" -gt "$y" ]` with a non-integer
# or too-large operand fails with "integer expression expected", but because that
# failure happens inside an `if` condition, `set -e` does NOT abort the script — the
# comparison just evaluates false. Both the `-gt` (newer) and `-eq` (up to date)
# guards would then silently evaluate false, and control would fall through to the
# "installed_version < shipped_version" branch, restamping (i.e. potentially
# DOWNGRADING) a file whose marker only looked newer because it was malformed. Every
# parsed version — installed AND shipped/template — is validated with this before
# it's used in an integer comparison.
local v="$1"
[[ "$v" =~ ^[0-9]{1,9}$ ]]
}

strip_marker_line() {
# Prints $1's content with any line containing the literal marker prefix $2 removed.
# Used to normalize before diffing "installed" against "pristine template": the
# marker line legitimately differs by version number alone even when nothing else
# changed, so the version bump itself must never count as a "local edit".
local f="$1" prefix="$2"
grep -vF -- "$prefix" "$f" 2>/dev/null || true
}

has_local_edits() {
# $1 = installed file, $2 = pristine template shipped by this plugin, $3 = marker
# prefix.
#
# Local-edit detection mechanism (documented explicitly, since the plugin only ships
# ONE pristine version — the current one — so a true three-way merge base isn't
# available): strip the marker line from both the installed file and the shipped
# template, then compare what's left byte-for-byte. Any residual difference is
# treated as a local edit. This is deliberately conservative — a whitespace-only
# tweak still counts as "diverged" — because the cost of a false "conflict" is a
# human glance at a diff, while the cost of a false "safe to restamp" is silently
# destroying someone's hand-edit. Never trade the latter for convenience.
local installed="$1" template="$2" prefix="$3"
local a b
a="$(strip_marker_line "$installed" "$prefix")"
b="$(strip_marker_line "$template" "$prefix")"
[ "$a" != "$b" ]
}

# --- 1. managed files: compare marker version + content, act per the ladder below --
had_broken_install=0

for entry in "${MANAGED_FILES[@]}"; do
IFS='|' read -r tmpl_name dest_rel marker_prefix <<<"$entry"
template="$templates_dir/$tmpl_name"
dest="$target_root/$dest_rel"

if [ ! -f "$template" ]; then
echo " error: $dest_rel — no shipped template at $template; plugin install looks broken" >&2
had_broken_install=1
continue
fi

shipped_version="$(managed_version_of "$template" "$marker_prefix")"
if ! is_sane_version "$shipped_version"; then
echo " error: $dest_rel — shipped template $template has no valid @orchestrator-managed marker (got \"$shipped_version\"); plugin install looks broken" >&2
had_broken_install=1
continue
fi

if [ ! -f "$dest" ]; then
# Sync does not create managed files — creating one is an opt-in decision that
# belongs to /orchestrator:setup, not to a silent background reconcile.
echo " missing: $dest_rel — not present; run /orchestrator:setup to create it"
continue
fi

installed_version="$(managed_version_of "$dest" "$marker_prefix")"
if [ -z "$installed_version" ]; then
# No recognizable marker at all is treated as "version 0" (older than anything the
# plugin ships), so it flows through the same ladder below rather than a special case.
installed_version=0
elif ! is_sane_version "$installed_version"; then
# Malformed/oversized marker on the INSTALLED file — never let this reach the
# integer comparisons below (see is_sane_version's comment for why that's unsafe).
# Treat it like any other divergent-content case: flag for a human, don't restamp.
echo " conflict: $dest_rel has a malformed or out-of-range version marker (\"$installed_version\") — needs-merge, left untouched"
continue
fi

if [ "$installed_version" -gt "$shipped_version" ]; then
# Never downgrade a file that's newer than what this installer ships.
echo " kept: $dest_rel is v$installed_version, newer than this plugin's v$shipped_version — left untouched"
continue
fi

if [ "$installed_version" -eq "$shipped_version" ]; then
if has_local_edits "$dest" "$template" "$marker_prefix"; then
# Same marker version but content still diverges from pristine — inconsistent
# state (e.g. someone hand-edited without bumping the marker). Flag rather than
# trust the marker blindly.
echo " conflict: $dest_rel is marked v$installed_version but content diverges from the pristine v$shipped_version template — needs-merge, left untouched"
else
echo " up to date: $dest_rel already v$shipped_version"
fi
continue
fi

# installed_version < shipped_version
if has_local_edits "$dest" "$template" "$marker_prefix"; then
echo " conflict: $dest_rel is v$installed_version (behind v$shipped_version) AND has local edits — needs-merge, left untouched"
else
cp "$template" "$dest"
echo " restamped: $dest_rel v$installed_version -> v$shipped_version"
fi
done

# --- 2. user-owned files: report only, never write ----------------------------------
for f in "${USER_OWNED_FILES[@]}"; do
echo " user-owned — skipped by design: $f"
done

if [ "$had_broken_install" -eq 1 ]; then
echo "orchestrator sync: reconcile finished with errors — see 'error:' lines above; plugin install looks broken." >&2
exit 1
fi

echo "orchestrator sync: reconcile complete."
Loading