resync
Resync SDD pipeline artifacts with the current branch. Renames .specwork files and updates state.json after a branch rename. Two modes — sync-only when called without args (assumes git rename already
SKILL.mdskills/resync/SKILL.md
GitHubResync
Load: view ${CLAUDE_PLUGIN_ROOT}/skills/resync/SKILL.md
Description
Synchronize SDD pipeline artifacts under .specwork/ with the current branch when the branch was renamed and the pipeline state still points at the old name. Renames the .specwork/*-state.json, spec, source, plan, cache, path, and context files, and updates state.json fields (id, branch, ticket, input_type, internal paths).
Two modes based on argument:
| Mode | Invocation | Behavior |
|---|---|---|
| Sync-only | /sdd:resync |
Compares current branch to state.json::branch. If they differ, renames artifacts to match the current branch. Does not touch git. Use after running git branch -m manually. |
| Atomic | /sdd:resync --rename-branch <new-branch-name> |
Runs git branch -m <new> first, then syncs. One-shot for "rename branch + update pipeline" in a single step. |
Use cases
- A free-text feature gets assigned a ticket: rename to
feature/IR-70-<original-slug>and resync —ticketandinput_typeget filled in automatically. - Branch name typo fixed mid-pipeline.
- Recovery: someone ran
git branch -mand forgot to update the pipeline.
Flow
| Step | Action |
|---|---|
| 1 | Prerequisite check — pipeline must be initialized in .specwork/ |
| 2 | Parse argument (--rename-branch <name> or none) |
| 3 | (Atomic mode only) Run git branch -m <new> |
| 4 | Derive new slug, ticket, input_type from current branch |
| 5 | Identify the active state.json, derive old slug, detect collisions |
| 6 | Rename every .specwork/*/<old-slug>-* file to use the new slug |
| 7 | Update state.json content (id, branch, ticket, input_type, internal paths) |
| 8 | Print a summary of what changed |
Step 1 — Prerequisites
if [ ! -d .specwork/_state ]; then
echo "No SDD pipeline found in .specwork/. Nothing to resync."
exit 0
fi
shopt -s nullglob 2>/dev/null
STATE_FILES=(.specwork/_state/*-state.json)
if [ ${#STATE_FILES[@]} -eq 0 ]; then
echo "No state.json found under .specwork/_state/. Nothing to resync."
exit 0
fi
Exit cleanly (not error) when there's no pipeline to resync.
Step 2 — Parse Argument
/sdd:resync accepts:
- No args → sync-only mode. Skip Step 3.
--rename-branch <name>→ atomic mode. Capture<name>as the new branch, then proceed to Step 3.- Anything else (bare value without the flag, unknown flag, or
--rename-branchwith no value) → stop with a usage message.
Usage:
/sdd:resync Sync pipeline with current branch (no git ops).
/sdd:resync --rename-branch feature/IR-70-foo Rename branch then sync (atomic).
NEW_BRANCH=""
case "$1" in
"")
: # sync-only mode
;;
--rename-branch)
if [ -z "$2" ]; then
echo "Usage: /sdd:resync --rename-branch <new-branch-name>" >&2
exit 1
fi
NEW_BRANCH="$2"
;;
*)
echo "Usage: /sdd:resync --rename-branch <new-branch-name>" >&2
exit 1
;;
esac
Step 3 — Rename Branch (atomic mode only)
NEW_BRANCH was captured from --rename-branch in Step 2.
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$CURRENT_BRANCH" != "$NEW_BRANCH" ]; then
git branch -m "$NEW_BRANCH"
fi
The if guard handles the idempotent case (already on the target branch). Surface git's exit code and stop on any failure — typically a name collision against another local branch.
After this step, git rev-parse --abbrev-ref HEAD returns the new branch name.
Step 4 — Derive Slug, Ticket, input_type
Derivation is done in code (lib/slug.py), not by re-deriving the regex here. It prints JSON for
the current branch (or a branch passed as an argument):
python3 ${CLAUDE_PLUGIN_ROOT}/lib/slug.py
# → {"branch": "...", "unprefixed": "...", "slug": "...", "ticket": "...", "input_type": "..."}
Capture the values into bash variables (CURRENT_BRANCH from branch, NEW_SLUG from slug,
TICKET from ticket, INPUT_TYPE from input_type), e.g. with python3 -c/jq on the output.
If slug is empty after derivation (e.g., branch was feature/ with nothing after), stop and tell the user the new branch name is not slug-friendly.
Step 5 — Identify Old Slug & Collision Check
Pick the active state.json. The normal case is exactly one file under .specwork/_state/. If multiple exist (leftover from a previous unclosed pipeline), prefer the one whose branch field matches the current branch (which, after Step 3 in atomic mode, is the new branch — so this only matches if we're already in sync; otherwise none match and we fall back to picking the only candidate or aborting).
OLD_STATE="$(python3 - <<'PY'
import glob, json, subprocess, sys
states = sorted(glob.glob(".specwork/_state/*-state.json"))
if not states:
sys.exit("No state.json files found.")
current = subprocess.check_output(['git','rev-parse','--abbrev-ref','HEAD']).decode().strip()
matches = []
for s in states:
try:
if json.load(open(s)).get('branch') == current:
matches.append(s)
except Exception:
pass
if matches:
print(matches[0])
elif len(states) == 1:
print(states[0])
else:
sys.exit(f"Multiple state.json files and none matches current branch '{current}'. Manual cleanup needed — keep one of: {', '.join(states)}")
PY
)"
[ -n "$OLD_STATE" ] || exit 1
OLD_SLUG="$(basename "$OLD_STATE" | sed -E 's/-state\.json$//')"
Collision check: if OLD_SLUG != NEW_SLUG and a file already exists at .specwork/_state/${NEW_SLUG}-state.json, abort with a clear message — refuse to overwrite another pipeline's state.
if [ "$OLD_SLUG" != "$NEW_SLUG" ] && [ -f ".specwork/_state/${NEW_SLUG}-state.json" ]; then
echo "Refusing to resync — a different pipeline already exists at slug '${NEW_SLUG}'."
echo "Run /sdd:close on one of them first, or pick a different branch name."
exit 1
fi
Step 6 — Rename Files
Skip if OLD_SLUG == NEW_SLUG (branch may have been renamed to a name that produces the same slug, or atomic mode was a no-op).
For every file in .specwork/ whose basename starts with ${OLD_SLUG}-, rename it to swap the prefix:
if [ "$OLD_SLUG" != "$NEW_SLUG" ]; then
find .specwork -type f -name "${OLD_SLUG}-*" -print0 | while IFS= read -r -d '' f; do
dir="$(dirname "$f")"
base="$(basename "$f")"
new_base="${NEW_SLUG}${base#"$OLD_SLUG"}"
mv "$f" "$dir/$new_base"
done
fi
This catches *-state.json, *-rules.json, *-implementation-cache.json, *-spec.md, *-source.md, *-plan.md, *-context.md consistently.
Step 7 — Update state.json
The rename is done in code (lib/state.py rename-slug): it rewrites every string field that
mentions the old slug (internal paths) before setting the authoritative
id/branch/ticket/input_type, so a new slug that contains the old one doesn't double up.
NEW_STATE=".specwork/_state/${NEW_SLUG}-state.json"
python3 ${CLAUDE_PLUGIN_ROOT}/lib/state.py rename-slug \
"$NEW_STATE" "$NEW_SLUG" "$OLD_SLUG" "$CURRENT_BRANCH" "$TICKET" "$INPUT_TYPE"
Do not modify source_title — that field reflects the user's original input at /sdd:start time and changing it requires explicit user intent. If the rename surfaced a ticket that wasn't there before, point the user at editing source_title manually or rerunning /sdd:start.
Step 8 — Print Summary
Show what changed in a compact block:
Resync complete.
Branch: feature/consent-personuuid → feature/IR-70-consent-personuuid
Slug: consent-personuuid → ir-70-consent-personuuid
Ticket: null → IR-70
Type: freetext → jira
Files renamed: 5
.specwork/_state/<slug>-state.json
.specwork/_state/<slug>-rules.json
.specwork/_state/<slug>-implementation-cache.json
.specwork/_spec/<slug>-spec.md
.specwork/_spec/<slug>-source.md
Next:
- source_title in state.json still reflects the original input.
Edit it manually or rerun /sdd:start if you want it to match the new ticket.
- If the old branch was pushed to origin, push the new branch and delete the old:
git push -u origin HEAD
git push origin --delete <old-branch>
Omit lines that did not change. If OLD_SLUG == NEW_SLUG, print "Slug unchanged — only state.json was refreshed.". If branch, slug, ticket, and input_type are all already current, print "Already in sync." and exit without writes.
Hard Rules
- Exit cleanly (zero) when there is no
.specwork/to resync. This skill is a recovery tool — never an error path for fresh repos. - Never touch the remote — no
git push, nogit push --delete. Print instructions for the user to run those manually. - Never overwrite an existing state.json belonging to a different slug. Collision means abort with manual cleanup needed.
- Do not modify
source_title. That is the user's original spec source and must not be implicitly rewritten. - The file rename + state.json update are not atomic. If a
mvfails mid-flight, surface the error so the user can recover manually — do not attempt automatic rollback. - Idempotent: running
/sdd:resyncwhen nothing needs syncing prints "Already in sync" and exits.
Related Skills
/sdd:start— initializes the pipeline; can produce the state this skill resyncs/sdd:whatnext— shows pipeline status; will block when branch ≠ state.json::branch (this skill resolves that)/sdd:close— clears.specwork/after merge; use when both old and new slugs need to be wiped