# 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 add`s 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.
