The upstream leg of the aggregation repo (CONTEXT.md: vendor sync): bring every source's consolidated copies back to current — matching upstream except the documented patches, with the patch manifest pins recording the true base — and get the pending-update issue closed. The freshness check detects; this skill maintains.
Two invariants (ADR-0002) shape every step: patched files are never overwritten — upstream changes enter a patched file only through a merge that preserves the local patch — and files upstream removed are never deleted without an explicit call.
1. Read the pending-update issue and dry-run
Confirm you are at the aggregation repo root (the git root owning scripts/vendor-sync.mjs); ask the user if it has moved.
- If a pending-update issue is open, read it (
gh issue list --state open --label pending-updateto find it,gh issue view <n>for the body — repo inferred fromgit remote -v). Its pending list is the freshness check's dry-run output; treat it as the plan, not the ground truth. - Run
node scripts/vendor-sync.mjs --dry-run; read the human block and theVENDOR_SYNC_SUMMARY=line.
Classify every pending item:
- added / updated — unpatched files the sync will copy;
- removed upstream (kept local) — unpatched files upstream deleted; the sync never deletes, the keep-or-drop call is the user's (step 3);
- patched, upstream modified — a patched file whose upstream moved since the pinned base: merge it (step 3);
- patched, upstream removed — upstream dropped a patched file: keep-or-drop call (step 3).
Done when: the dry run reported no source errors and every pending item has a class. If it reports nothing pending, there is no maintenance: say so, and if a pending-update issue is open anyway it is stale — close it per docs/agents/issue-tracker.md and stop.
2. Run the sync
node scripts/vendor-sync.mjs (real mode) copies added/updated files; it never touches patched files and never deletes. Confirm exit 0 and review git status --porcelain / git diff --stat: only the classified adds and updates — no patched file modified, no deletion.
Done when: the sync exited 0 and the diff is exactly the classified added/updated set.
3. Resolve the pending items
Patched, upstream modified — three-way merge
Rebuild each file's merge in a temp dir. Run this block from Git Bash or WSL (PowerShell cannot mktemp/redirect reliably; the repo's Windows-runtime pattern). Per source, gather the clone URL (the SOURCES array in scripts/vendor-sync.mjs), the pinned commit (the Upstream references table in PATCHES.md), and each row's Upstream counterpart (the file's path inside the upstream repo):
tmp=$(mktemp -d)
git clone --quiet --depth 1 <url> "$tmp/src"
git -C "$tmp/src" fetch --quiet --depth 1 origin <pinned-commit> # GitHub serves the SHA
For the file — base = pinned upstream, ours = the local consolidated copy, theirs = upstream HEAD:
git -C "$tmp/src" show <pinned-commit>:<upstream-path> > "$tmp/base"
git -C "$tmp/src" show HEAD:<upstream-path> > "$tmp/theirs"
cp <local patched file> "$tmp/ours"
git merge-file -p "$tmp/ours" "$tmp/base" "$tmp/theirs" > "$tmp/merged" # exit 0 clean, 1 conflict
- Clean (exit 0). First the adopted-patch case:
cmp "$tmp/ours" "$tmp/theirs"— equal means upstream adopted the local patch, the copy is already the final content; write nothing, note it, and restate the row in step 4 (the patch is no longer a deviation, so the Verification method "git diff shows exactly N lines" no longer holds). Otherwise prove the patch survived mechanically, not by eye: the re-applied patch's changed lines must equal the old patch's, only line numbers and context positions may move — extract and compare them:git diff --no-index "$tmp/base" "$tmp/ours" | awk '/^[-+]/ && !/^[-+]{3}/ { print substr($0,2) }' > "$tmp/old.patch" git diff --no-index "$tmp/theirs" "$tmp/merged" | awk '/^[-+]/ && !/^[-+]{3}/ { print substr($0,2) }' > "$tmp/new.patch" cmp "$tmp/old.patch" "$tmp/new.patch"cmpexit 0 → write the merged content over the consolidated copy. Mismatch → treat as a reshaped patch (a line-ending difference between blobs and the local file also lands here — a spurious mismatch, conservative; worth a glance before the manual review): do not write; show the user both diffs and the row, since the Patch summary must be rewritten together with the merge (step 4). - Conflict (exit 1). Do not write. Show the user the marked hunks (ours vs theirs; add
--diff3for the base too) and the row; resolve with them — the genuine manual-merge case ADR-0002 anticipated. The temp files keep both sides. - Pin unfetchable (the
fetchfailed — force-push or GC upstream). Fall back to a by-hand merge: showgit diff --no-index <local copy> <theirs>and reconcile with the user; the pin may need re-deriving.
Removed upstream — the keep-or-drop call
For every file the dry run lists as removed upstream (kept local) — patched or not — present the two options:
- Drop:
git rmit. Deletion is destructive — get the user's explicit go; patched rows then leave the manifest (step 4). - Keep: the content still earns its place with no upstream anymore — move it under
skills/self/<name>/so the sync stops tracking it (it only walks the source roots) and restate it as self-authored inPATCHES.md.
Done when: every patched file is merged (or handed off as a conflict), every removed file has the user's keep-or-drop call, and nothing is left pending.
4. Update the patch manifest
PATCHES.md is the single home of every deviation (ADR-0002) — keep it truthful to the new state:
- Bump the pins — the Upstream references table records each source's pinned commit (the diff baseline). Bump each source that moved to the commit the maintenance actually merged against: step 3's clone HEAD (
git -C "$tmp/src" rev-parse HEAD) when files were merged, elsegit ls-remote <url> HEAD. Current pins are what makes the freshness check read patched files as current again. - Patch summaries — if a merge reshaped a patch, rewrite the row's Patch summary and Verification method to describe what
git diffnow shows. - Removed rows — drop rows for files the user dropped; restate as self-authored for files kept under
skills/self/.
Done when: every pin matches the merged base and every row describes the current diff.
5. Verify the copies are current
node --test scripts/*.test.mjs— the repo's only script guardrail (script-tests.yml); it must pass.- Re-run
node scripts/vendor-sync.mjs --dry-run: the summary must readchanged=falsewith nopatchedDiffers,patchedRemoved, orremoveditems — the exact state that lets the freshness check close the issue.
Done when: the tests pass and the dry run reports nothing pending.
6. Propose commit, push, close the issue
The git writes and the remote writes need the user's go-ahead — propose, and on confirmation:
- commit — conventional message naming the sources:
vendor: sync <sources> and merge patched files, orvendor: refresh pins, copies currentwhen only the manifest changed; - push;
- close the pending-update issue per
docs/agents/issue-tracker.md— one write,git status --porcelainclean first, its EOF-recovery order if the close hiccups — with a report: what synced, which patches merged (and that they survived), the pin bumps, and the verification results.
If no issue was open, say so — the next freshness check run sees the copies current and stays silent.
Done when: the proposal has been made and the user has either approved and the commit, push, and issue close all landed (git status --porcelain empty, the pending-update issue closed), or explicitly declined.