Files
Claude-Code-Monitor/docs/superpowers/specs/2026-08-05-sync-base-design.md
T
nntrivi2001 ea0cd3b455 docs(lanes): design E2 — ccam lanes sync-base (E)
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.
2026-08-05 09:38:55 +07:00

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:

  1. Two new profile declarations: MIGRATIONS_DIR, GENERATED_MERGE_PATHS (empty = off, same DEFAULTS pattern as every other optional A2 declaration).
  2. server/lib/lane-sync.js — the git-only core: collision check, dev-delta report, merge, continue-after-conflict.
  3. POST /api/lanes/:id/sync-base route.
  4. ccam lanes sync-base [--check|--continue] [branch] [<id>] CLI subcommand.
  5. .claude/skills/ship-feature-lane/SKILL.md — Stage 2/8/12 edits: drop the "if it exists yet" conditionals, add the exit-4/--continue mechanics (currently absent from the CCAM port; Shipyard's original ship-feature/SKILL.md Stage 12 has the text to carry over).
  6. 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-lane exclude) — none of those are sync-base's job; only the GENERATED_MERGE_PATHS merge-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 0 guard).
  • GENERATED_MERGE_PATHS: "" — space-separated repo-relative paths, parsed with the existing splitList helper (same shape as PORTS/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) — validates branch isn't development/main and 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 computes devDelta (files changed on origin/development since the merge-base with branch, filtered against GENERATED_MERGE_PATHS) and overlap (the subset of that delta touching branch's own changed files), returns {code: 0, devDelta, overlap}.
  • mergeSync(lane, profile, branch) — same validation + fetch; installs the keep-ours merge driver for every GENERATED_MERGE_PATHS entry via git config merge.ccam-generated.driver true + a line per path in .git/info/attributes (both local-only, idempotent — grep -qxF before appending, exactly like the source's guard); runs the collision check (exit 5 short-circuits, nothing merged); checks out branch; runs git 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: [...]} from git diff --name-only --diff-filter=U.
  • continueSync(lane, profile, branch) — asserts current branch equals branch, no unmerged paths (git ls-files -u empty), 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 add only the resolved files, git commit --no-edit, then ccam lanes sync-base --continue feat/<slug> (folds regenerated artifacts in) — carried over from Shipyard's original ship-feature/SKILL.md Stage 12, which already has this exact text.

Invariants

  1. CCAM still does not orchestrate. sync-base performs one git operation per call and returns; it never chains into gate re-runs or stage transitions itself — the skill decides what happens next.
  2. A profile that never declares MIGRATIONS_DIR or GENERATED_MERGE_PATHS sees no behavior change — collision checking and the merge driver both degrade to no-ops, same as every other optional declaration.
  3. Never a shell-string git command. Every git invocation in lane-sync.js goes through the shared git(cwd, args) execFile helper.
  4. sync-base never writes stage/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.