Scopes the second of E's remaining pieces: migration-collision preflight, keep-ours merge driver for generated files, and the single sanctioned origin/development-into-feature-branch merge. Agents and F's integrations stay out of scope, per the roadmap's own subsystem split.
12 KiB
E2 — ccam lanes sync-base
Status: approved 2026-08-05. Roadmap it belongs to:
docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md (subsystem E).
Problem
.claude/skills/ship-feature-lane/SKILL.md (ported in E1) names ccam lanes sync-base at Stages 2, 8 and 12 by its intended contract, but the command doesn't exist — Stage 2 hardcodes "preflight unavailable, skipped" and Stage 12 falls back to a bare git merge. Shipyard's source, lane-sync-dev.sh (~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/bin/lane-sync-dev.sh, 173 lines, plus its test tests/test_sync_dev.sh), is the reference: the ONE sanctioned merge in the pipeline (origin/development INTO the feature branch), with a migration-number collision preflight and a keep-ours merge driver for generated files. E (per the roadmap) has three pieces left after E1: agents, sync-base, F's integrations. This spec covers sync-base only.
Scope
In scope:
- Two new profile declarations:
MIGRATIONS_DIR,GENERATED_MERGE_PATHS(empty = off, sameDEFAULTSpattern as every other optional A2 declaration). server/lib/lane-sync.js— the git-only core: collision check, dev-delta report, merge, continue-after-conflict.POST /api/lanes/:id/sync-baseroute.ccam lanes sync-base [--check|--continue] [branch] [<id>]CLI subcommand..claude/skills/ship-feature-lane/SKILL.md— Stage 2/8/12 edits: drop the "if it exists yet" conditionals, add the exit-4/--continuemechanics (currently absent from the CCAM port; Shipyard's originalship-feature/SKILL.mdStage 12 has the text to carry over).docs/LANES.md+ roadmap progress line.
Out of scope:
- The five agents (qc-local, senior-gate-reviewer, ticketer, dev-qc, pr-reviewer) — separate task.
- F's integrations — separate task, unrelated to git sync.
write_lane_markers's other concerns (push-guard config, proof-link,.harness-laneexclude) — none of those are sync-base's job; only theGENERATED_MERGE_PATHSmerge-driver piece of that function is relevant here, and it's ported standalone (see Decisions).
Decisions
| Choice | Rationale | |
|---|---|---|
| Route timing model | Synchronous — the route runs the git operation and returns JSON directly, no 202 + websocket broadcast |
Unlike ci-gate/e2e (test suites, minutes), a fetch + collision-check + merge is a few seconds of git work. Matches the existing GET /:id/git pattern (working-copy facts, synchronous), not the hook pattern. |
| Merge-driver setup timing | Lazy, inside mergeSync itself, idempotent every call |
Matches this repo's own established precedent: proof-link is "idempotent, never automatic" rather than a provision-time step. No other CCAM lane-creation path (addWorktree/provisionLane) currently touches git config or .git/info/*; adding one there for this alone would be a new concern in an unrelated file for a need only sync-base has. |
| Stage/status writes | None. sync-base returns a structured result (code, collisions, devDelta, overlap, conflictedFiles); it never calls anything that sets a lane's stage, status, or notes |
This repo's own rule: "declared stages come from ccam stage... The console never writes a lane's stage" and "the runtime never writes stage, status or notes." Shipyard's state.sh set heartbeat/notes calls inside lane-sync-dev.sh are dropped for the same reason --qc's design dropped state.sh init in E1 — CCAM has a narrower, already-established boundary for who writes stage, and it's not this command. The skill is responsible for calling ccam stage ... --status blocked itself on a collision or conflict, same as any other gate failure. |
| Exit codes | Preserved verbatim: 0 clean, 4 conflict (left in place), 5 migration collision |
SKILL.md already documents these two non-zero codes by name (written during E1, before the command existed) — the contract predates this implementation and this spec fulfills it rather than inventing a new one. |
git execution |
Reuse worktree.js's git(cwd, args) helper (export it — not currently exported) |
This repo's binding rule: never build a git command as a shell string; worktree.js already has the one approved execFile wrapper. A second implementation in lane-sync.js would duplicate it. |
State for --continue |
None — read straight from the lane's own git state (git ls-files -u, presence of .git/MERGE_HEAD), exactly like the source script |
Stateless by construction: the working copy IS the source of truth for "is this conflict resolved yet," so a separate DB flag would just be a second place to get out of sync with the first. |
Command mapping
| Shipyard | CCAM | Status |
|---|---|---|
lane-sync-dev.sh <N> --check [branch] |
ccam lanes sync-base --check [branch] [<id>] |
new in this task |
lane-sync-dev.sh <N> [branch] |
ccam lanes sync-base [branch] [<id>] |
new in this task |
lane-sync-dev.sh <N> --continue [branch] |
ccam lanes sync-base --continue [branch] [<id>] |
new in this task |
state.sh "$N" set stage=migration-collision ... (on exit 5) |
dropped — see Decisions | n/a |
state.sh "$N" set stage=sync-conflict ... (on exit 4) |
dropped — see Decisions | n/a |
write_lane_markers (push guards, .harness-lane exclude, proof-link) |
dropped — out of scope, unrelated to sync | n/a |
write_lane_markers's GENERATED_MERGE_PATHS driver setup |
inlined into mergeSync, lazy/idempotent |
new in this task, narrower scope |
run_hook "$N" regen (post-merge fold-in) |
runHook(lane, profile, "regen", ...) (already exists, regen already in the HOOKS allowlist) |
exists |
Design
Profile declarations
server/lib/lane-profile.js's DEFAULTS:
MIGRATIONS_DIR: ""— a repo-relative path (e.g.db/migrations). Empty = collision check is a no-op (mirrors the source script's[ -n "${MIGRATIONS_DIR:-}" ] || return 0guard).GENERATED_MERGE_PATHS: ""— space-separated repo-relative paths, parsed with the existingsplitListhelper (same shape asPORTS/LANE_DIRS). Empty = no merge driver installed, no regen fold-in attempted.
server/lib/lane-sync.js
Three exported async functions, each taking (lane, profile, branch):
checkSync(lane, profile, branch)— validatesbranchisn'tdevelopment/mainand exists;git fetch origin --prune; runs the collision check (below); if a collision is found, returns{code: 5, collisions: [{file, num, clash, suggestion}]}without going further. Otherwise computesdevDelta(files changed onorigin/developmentsince the merge-base withbranch, filtered againstGENERATED_MERGE_PATHS) andoverlap(the subset of that delta touchingbranch's own changed files), returns{code: 0, devDelta, overlap}.mergeSync(lane, profile, branch)— same validation + fetch; installs the keep-ours merge driver for everyGENERATED_MERGE_PATHSentry viagit config merge.ccam-generated.driver true+ a line per path in.git/info/attributes(both local-only, idempotent —grep -qxFbefore appending, exactly like the source's guard); runs the collision check (exit 5 short-circuits, nothing merged); checks outbranch; runsgit merge --no-edit origin/development. A clean merge or a fully rerere-auto-resolved one falls through to the regen fold-in (below) and returns{code: 0}. A real conflict is left in place — returns{code: 4, conflictedFiles: [...]}fromgit diff --name-only --diff-filter=U.continueSync(lane, profile, branch)— asserts current branch equalsbranch, no unmerged paths (git ls-files -uempty), and no.git/MERGE_HEAD(already committed) — any violation is a thrown error with a clear message, not a silent no-op. Then runs the regen fold-in and returns{code: 0}.
Collision check (shared helper, not exported): if MIGRATIONS_DIR is unset, no-op. Otherwise diff origin/development...branch for added files under MIGRATIONS_DIR, list origin/development's existing NNN_* files in that dir, and for each added file whose number already exists upstream, compute the next free number and report it — same arithmetic as the source script (10#${maxnum:-0} + 1, incrementing per additional clash in the same batch).
Regen fold-in (shared helper, not exported): only runs when GENERATED_MERGE_PATHS is set AND the profile has a regen hook. Runs the hook, git adds the generated paths, and if anything is staged, amends the merge commit (if HEAD^2 exists — i.e. we're on a merge commit) or makes a plain commit (the --continue path, where the merge commit already happened manually).
Route
POST /api/lanes/:id/sync-base — body {mode: "check"|"merge"|"continue", branch} (mode defaults to "merge" if omitted, branch defaults to the lane's current branch — read via git rev-parse --abbrev-ref HEAD server-side, same as the source script's ${2:-$(git ... HEAD)} fallback). Requires a resolved profile (requireProfile, same 404/ENOPROFILE handling every hook route already uses). Wrapped in withLaneLock — the lane's own git state is being mutated, same reasoning every other mutating lane route already applies. Returns 200 with {code, ...} for every outcome including 4/5 — those are documented, expected results, not HTTP errors; a genuine fault (bad branch name, no profile, git failure) is the only case that returns a 4xx/5xx with a structured error.
CLI
ccam lanes sync-base [--check|--continue] [branch] [<id>] in bin/ccam.js, same flag-parsing style as lanes up. Maps the response code to process.exitCode (0/4/5) so a driving skill's $?/exit-code check works exactly as SKILL.md already documents. On code: 5, prints each collision's suggested rename; on code: 4, prints the conflicted file list plus the exact --continue follow-up command; on code: 0, prints DEV_DELTA:/DEV_OVERLAP: (check mode) or a plain success line (merge/continue mode) — same output shape as the source script, since the skill's stage text already expects to read DEV_DELTA:/DEV_OVERLAP: lines.
SKILL.md edits
- Stage 2: replace the "later task, not yet built" paragraph with the real command + its exit-5 handling (rename, re-run gates) — text already half-written in E1's port, just needs the conditional removed.
- Stage 8: same — "re-run the preflight if it exists by now" becomes unconditional.
- Stage 12: replace the "if it exists / if it doesn't" branch with the unconditional
ccam lanes sync-base feat/<slug>call, and add the exit-4 handling this repo's port currently lacks: resolve conflicts,git addonly the resolved files,git commit --no-edit, thenccam lanes sync-base --continue feat/<slug>(folds regenerated artifacts in) — carried over from Shipyard's originalship-feature/SKILL.mdStage 12, which already has this exact text.
Invariants
- CCAM still does not orchestrate.
sync-baseperforms one git operation per call and returns; it never chains into gate re-runs or stage transitions itself — the skill decides what happens next. - A profile that never declares
MIGRATIONS_DIRorGENERATED_MERGE_PATHSsees no behavior change — collision checking and the merge driver both degrade to no-ops, same as every other optional declaration. - Never a shell-string git command. Every git invocation in
lane-sync.jsgoes through the sharedgit(cwd, args)execFile helper. sync-basenever writesstage/status/notes. Same boundary as the runtime and the hook routes.
Verify
Unit tests in server/__tests__/lane-sync.test.js, porting tests/test_sync_dev.sh's five scenarios against a real bare-origin git fixture (mirrors the existing test style — see server/__tests__/lane-profile.test.js's writeProfile/temp-dir pattern): (a) --check detects a migration-number collision and suggests the next free number, (b) --check passes clean after the renumber and reports DEV_DELTA/DEV_OVERLAP, (c) a clean merge lands upstream's migration on the feature branch as a merge commit, (d) a conflicting merge exits with code: 4 and leaves .git/MERGE_HEAD in place, then --continue after manual resolution finishes it, (e) running on development/main itself is refused. A light route-level test confirms the HTTP contract (body shape, lock usage, 200 on code 4/5). No dry run needed beyond the unit tests — this command has no UI surface and no long-running process to observe live.