Files
nntrivi2001 77d79a59d7 docs(lanes): plan E2 — ccam lanes sync-base (E)
5 tasks: MIGRATIONS_DIR/GENERATED_MERGE_PATHS profile declarations, the
lane-sync.js git core (check/merge/continue, ported against a real
bare-origin fixture mirroring lane-sync-dev.sh's own test suite, plus a
dedicated git-worktree fixture to catch the git-dir vs git-common-dir
distinction MERGE_HEAD/info-attributes depend on), the sync-base route,
the CLI subcommand, and the SKILL.md/docs edits that turn three "if it
exists yet" conditionals into real instructions.
2026-08-05 09:49:56 +07:00

1199 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# E2 — `ccam lanes sync-base` Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `ccam lanes sync-base` exists as a real, working command — the migration-collision preflight (`--check`), the one sanctioned merge (`origin/development` into a feature branch), and its conflict-resolution follow-up (`--continue`) — so `.claude/skills/ship-feature-lane/SKILL.md`'s Stage 2/8/12 references to it stop being "later task" placeholders.
**Architecture:** One new core module (`server/lib/lane-sync.js`, pure git operations via `worktree.js`'s existing `git()` helper), two new profile declarations (`MIGRATIONS_DIR`, `GENERATED_MERGE_PATHS`), one new synchronous route (`POST /:id/sync-base`), one new CLI subcommand, and a `SKILL.md` edit that turns three "if it exists yet" conditionals into real instructions.
**Tech Stack:** Existing git helper (`server/lib/worktree.js`'s `git(cwd, args)`, execFile-based), existing profile system (`server/lib/lane-profile.js`), existing hook runner (`runHook`, already used for `regen`), existing lane lock (`server/lib/lane-lock.js`).
## Global Constraints
- Every applicable source file MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`.
- **Never build a git command as a shell string.** Every git call in `lane-sync.js` goes through `worktree.js`'s `git(cwd, args)` (execFile with an argument array). This is a binding project rule, not a style preference.
- **`sync-base` never writes a lane's `stage`, `status`, or `notes`.** It returns a structured result; the caller (the skill, or a human via the CLI) decides what a collision or conflict means for the lane's declared stage.
- `MIGRATIONS_DIR` and `GENERATED_MERGE_PATHS` are off-by-default declarations — a profile that never declares them sees zero behavior change, same pattern every other optional `DEFAULTS` entry in `lane-profile.js` already follows.
- The integration branch is hardcoded to `"development"` (a module-level constant in `lane-sync.js`, not a new profile setting) — `SKILL.md`'s whole pipeline already hardcodes this branch name throughout; making it configurable here would be scope creep this task doesn't need.
- Run `npm run test:server` (full suite) plus `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit.
- Never use `git add -A`. Stage exactly the files each task names.
- This repo's existing route tests for `server/routes/lanes.js` are all exercised at the `server/lib/*` layer (no HTTP-level test harness for lane routes exists anywhere in this repo) — Task 2's unit tests are the verification for the route's logic; Task 3 adds only what the route layer itself does that the lib doesn't (request parsing, lock acquisition, error-code mapping), covered by targeted assertions against the route handler, not a spun-up HTTP server.
---
### Task 1: Profile declarations — `MIGRATIONS_DIR`, `GENERATED_MERGE_PATHS`
**Files:**
- Modify: `server/lib/lane-profile.js` (`DEFAULTS`, `resolveProfile`)
- Test: `server/__tests__/lane-profile.test.js`
**Interfaces:**
- Produces: `resolveProfile(lane).generatedMergePaths``string[]`, parsed from `GENERATED_MERGE_PATHS` the same way `ports`/`laneDirs` are already parsed (via the existing `splitList` helper). `resolveProfile(lane).env.MIGRATIONS_DIR` — plain string, used as-is (not a list).
- [ ] **Step 1: Write the failing test**
Add to `server/__tests__/lane-profile.test.js` (it already has `writeProfile`/`makeLane` helpers — reuse them, don't redefine):
```js
describe("resolveProfile — E2 declarations", () => {
it("defaults MIGRATIONS_DIR to empty and GENERATED_MERGE_PATHS to an empty array", () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=api\n");
const profile = profileLib.resolveProfile(lanesLib.getLane(lane.id));
assert.equal(profile.env.MIGRATIONS_DIR, "");
assert.deepEqual(profile.generatedMergePaths, []);
});
it("parses declared MIGRATIONS_DIR and GENERATED_MERGE_PATHS", () => {
const lane = makeLane();
writeProfile(
lane.cwd,
'PORTS=api\nMIGRATIONS_DIR="db/migrations"\nGENERATED_MERGE_PATHS="api/openapi.json api/client.ts"\n'
);
const profile = profileLib.resolveProfile(lanesLib.getLane(lane.id));
assert.equal(profile.env.MIGRATIONS_DIR, "db/migrations");
assert.deepEqual(profile.generatedMergePaths, ["api/openapi.json", "api/client.ts"]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/lane-profile.test.js`
Expected: FAIL — `profile.generatedMergePaths` is `undefined` (deepEqual against `[]` fails), and `MIGRATIONS_DIR`/`GENERATED_MERGE_PATHS` aren't recognized declarations yet (they parse fine as arbitrary keys via `parseEnvFile`, but the *default* for an undeclared `MIGRATIONS_DIR` won't exist in `DEFAULTS` — the first assertion in test 1 fails with `undefined !== ""`).
- [ ] **Step 3: Implement**
In `server/lib/lane-profile.js`, add to `DEFAULTS` (after the existing `QC_BOOT_ENV` entry, `lane-profile.js:78`):
```js
QC_BOOT_ENV: "",
// E2: repo-relative path to a numbered-migrations directory (e.g.
// "db/migrations"), consumed by sync-base's collision preflight. Empty =
// off, same DEFAULTS pattern as every declaration above.
MIGRATIONS_DIR: "",
// E2: space-separated repo-relative paths given a keep-ours merge driver
// by sync-base's merge mode and regenerated post-merge by the profile's
// `regen` hook (e.g. an OpenAPI contract + its generated client). Empty =
// off — no driver installed, no regen fold-in attempted.
GENERATED_MERGE_PATHS: "",
});
```
In `resolveProfile`'s return object (`lane-profile.js:170-176`), add `generatedMergePaths` alongside the existing `ports`/`laneDirs`:
```js
return {
dir,
env,
hooks,
ports: splitList(env.PORTS),
laneDirs: splitList(env.LANE_DIRS),
generatedMergePaths: splitList(env.GENERATED_MERGE_PATHS),
};
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test server/__tests__/lane-profile.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/lane-profile.js server/__tests__/lane-profile.test.js
git commit -m "feat(lanes): add MIGRATIONS_DIR + GENERATED_MERGE_PATHS profile declarations (E2)"
```
---
### Task 2: `server/lib/lane-sync.js` — the git core
**Files:**
- Create: `server/lib/lane-sync.js`
- Test: `server/__tests__/lane-sync.test.js`
- Modify (read-only reference, no changes needed): `server/lib/worktree.js` (`git` is already exported — confirmed at `worktree.js:642-660`, nothing to add there)
**Interfaces:**
- Consumes: `git(cwd, args) => Promise<{stdout, stderr}>` from `require("./worktree")`; `runHook(lane, profile, name, args, options) => Promise<{code, output}>` from `require("./lane-profile")` (never throws on a non-zero hook exit).
- Produces:
- `checkSync(lane, profile, branch?) => Promise<{code: 0|5, devDelta?: string[]|null, overlap?: string[]|null, collisions?: Array<{file, collidesWith, suggestion}>}>`
- `mergeSync(lane, profile, branch?) => Promise<{code: 0|4|5, conflictedFiles?: string[], collisions?: Array<{file, collidesWith, suggestion}>}>`
- `continueSync(lane, profile, branch?) => Promise<{code: 0}>` (throws `EBADBRANCH`/`EUNRESOLVED`/`EMERGEUNCOMMITTED` on a precondition violation)
- All three accept `lane` as `{cwd, ...}` (only `cwd` is read) and `profile` as `resolveProfile()`'s return shape (`env`, `hooks`, `generatedMergePaths`).
- Thrown errors carry `.code``EBADBRANCH` (branch is `development`/`main`, or doesn't exist).
- [ ] **Step 1: Write the failing tests — fixture + branch guard + collision check**
Create `server/__tests__/lane-sync.test.js`:
```js
/**
* @file Tests for server/lib/lane-sync.js against a REAL git fixture: a bare
* "origin", a lane clone, and a second clone acting as another lane that
* pushes to origin/development independently. Mirrors the fixture shape of
* Shipyard's own lane-sync-dev.sh test (test_sync_dev.sh) — collision
* detection, clean merges, and conflicts are git's own behavior, so a mocked
* git would only test our idea of git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-"));
const laneSync = require("../lib/lane-sync");
const ORIGIN = path.join(ROOT, "origin.git");
const LANE_DIR = path.join(ROOT, "lane");
const PUSHER_DIR = path.join(ROOT, "pusher");
const g = (cwd, ...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
const gc = (cwd, ...args) => g(cwd, "-c", "user.email=t@h", "-c", "user.name=t", ...args);
function freshFixture() {
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(ROOT, { recursive: true });
g(ROOT, "init", "-q", "--bare", ORIGIN);
const seed = path.join(ROOT, "seed");
g(ROOT, "init", "-q", "-b", "development", seed);
fs.mkdirSync(path.join(seed, "db", "migrations"), { recursive: true });
fs.writeFileSync(path.join(seed, "db", "migrations", "001_init.sql"), "create table a;\n");
fs.writeFileSync(path.join(seed, "README.md"), "hello\n");
gc(seed, "add", "-A");
gc(seed, "commit", "-qm", "init");
gc(seed, "remote", "add", "origin", ORIGIN);
gc(seed, "push", "-q", "origin", "development");
g(ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
g(ROOT, "clone", "-q", ORIGIN, LANE_DIR);
g(ROOT, "clone", "-q", ORIGIN, PUSHER_DIR);
gc(LANE_DIR, "checkout", "-qb", "feat/thing");
}
function lane() {
return { cwd: LANE_DIR };
}
function profile(over = {}) {
return {
env: { MIGRATIONS_DIR: "db/migrations", GENERATED_MERGE_PATHS: "" },
generatedMergePaths: [],
hooks: new Set(),
...over,
};
}
before(freshFixture);
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("lane-sync guards", () => {
it("refuses to run on development or main", async () => {
await assert.rejects(
() => laneSync.checkSync(lane(), profile(), "development"),
(e) => e.code === "EBADBRANCH" && /feature branch/.test(e.message)
);
await assert.rejects(() => laneSync.mergeSync(lane(), profile(), "main"), {
code: "EBADBRANCH",
});
});
it("refuses a branch that doesn't exist", async () => {
await assert.rejects(() => laneSync.checkSync(lane(), profile(), "feat/nope"), {
code: "EBADBRANCH",
});
});
});
describe("lane-sync --check: migration collision", () => {
it("detects a collision and suggests the next free number", () => {
fs.writeFileSync(
path.join(LANE_DIR, "db", "migrations", "002_a.sql"),
"create table x;\n"
);
gc(LANE_DIR, "add", "-A");
gc(LANE_DIR, "commit", "-qm", "feat: add x");
fs.writeFileSync(
path.join(PUSHER_DIR, "db", "migrations", "002_b.sql"),
"create table y;\n"
);
gc(PUSHER_DIR, "add", "-A");
gc(PUSHER_DIR, "commit", "-qm", "other lane");
gc(PUSHER_DIR, "push", "-q", "origin", "development");
return laneSync.checkSync(lane(), profile(), "feat/thing").then((result) => {
assert.equal(result.code, 5);
assert.equal(result.collisions.length, 1);
assert.match(result.collisions[0].file, /002_a\.sql$/);
assert.match(result.collisions[0].suggestion, /^003_a\.sql$/);
});
});
it("passes clean after the renumber and reports the upstream delta", async () => {
gc(LANE_DIR, "mv", "db/migrations/002_a.sql", "db/migrations/003_a.sql");
gc(LANE_DIR, "commit", "-qm", "renumber migration");
const result = await laneSync.checkSync(lane(), profile(), "feat/thing");
assert.equal(result.code, 0);
assert.equal(result.devDelta.length, 1);
assert.match(result.devDelta[0], /002_b\.sql$/);
assert.deepEqual(result.overlap, []);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/lane-sync.test.js`
Expected: FAIL — `require("../lib/lane-sync")` throws `MODULE_NOT_FOUND`.
- [ ] **Step 3: Implement `lane-sync.js` — guards, collision check, dev-delta report, `checkSync`**
Create `server/lib/lane-sync.js`:
```js
/**
* @file The dev-based-flow safety primitive: the ONE sanctioned merge in the
* ship-feature-lane pipeline, origin/development INTO a lane's feature
* branch, gated by a migration-number collision preflight. Port of
* Shipyard's lane-sync-dev.sh. Pure git — every operation goes through
* worktree.js's git() execFile wrapper, never a shell string.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { git } = require("./worktree");
const { runHook } = require("./lane-profile");
/** The PR base branch. Hardcoded — the whole ship-feature-lane pipeline
* already hardcodes this name throughout SKILL.md; a configurable version
* would be scope this task doesn't need. */
const INTEGRATION_BRANCH = "development";
function badBranch(message) {
return Object.assign(new Error(message), { code: "EBADBRANCH" });
}
/** The branch to operate on: the caller's explicit choice, or the lane's
* current HEAD when omitted (mirrors the source script's own fallback). */
async function resolveBranch(cwd, branch) {
if (branch) return branch;
const result = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
return result.stdout.trim();
}
function assertFeatureBranch(branch) {
if (branch === INTEGRATION_BRANCH || branch === "main") {
throw badBranch(
`branch is '${branch}' — sync-base works on a feature branch (pass it explicitly)`
);
}
}
async function assertBranchExists(cwd, branch) {
try {
await git(cwd, ["rev-parse", "--verify", "--quiet", branch]);
} catch {
throw badBranch(`feature branch '${branch}' not found`);
}
}
/**
* Migration-number collision guard: two lanes independently add NNN_* files
* with the same number under MIGRATIONS_DIR — git merges both without
* conflict, and the collision only surfaces as red CI on development AFTER a
* human merges the PR. Detected from refs alone, before anything is merged.
*/
async function collisionCheck(cwd, migrationsDir, branch) {
if (!migrationsDir) return [];
const addedResult = await git(cwd, [
"diff",
"--name-only",
"--diff-filter=A",
`origin/${INTEGRATION_BRANCH}...${branch}`,
"--",
migrationsDir,
]);
const added = addedResult.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (!added.length) return [];
const treeResult = await git(cwd, [
"ls-tree",
"-r",
"--name-only",
`origin/${INTEGRATION_BRANCH}`,
"--",
migrationsDir,
]);
const devMigrations = treeResult.stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => /\/\d+_[^/]+$/.test(line));
let maxNum = 0;
for (const file of devMigrations) {
const m = /\/(\d+)_[^/]+$/.exec(file);
if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
}
let nextNum = maxNum + 1;
const collisions = [];
for (const file of added) {
const base = path.basename(file);
const m = /^(\d+)_/.exec(base);
if (!m) continue;
const num = m[1];
const clash = devMigrations.find((f) => f.includes(`/${num}_`));
if (!clash) continue;
const suggestion = `${String(nextNum).padStart(3, "0")}_${base.replace(/^\d+_/, "")}`;
collisions.push({ file, collidesWith: clash, suggestion });
nextNum += 1;
}
return collisions;
}
/** What moved on origin/development since branch's merge-base, and whether
* that delta touches branch's own changed files. Informational. */
async function devDeltaReport(cwd, branch, generatedPaths) {
let mergeBase = "";
try {
const result = await git(cwd, ["merge-base", `origin/${INTEGRATION_BRANCH}`, branch]);
mergeBase = result.stdout.trim();
} catch {
mergeBase = "";
}
if (!mergeBase) return { devDelta: null, overlap: null };
const filterGenerated = (files) =>
generatedPaths.length ? files.filter((f) => !generatedPaths.includes(f)) : files;
const namesOnly = (stdout) =>
stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const devDiff = await git(cwd, ["diff", "--name-only", mergeBase, `origin/${INTEGRATION_BRANCH}`]);
const delta = filterGenerated(namesOnly(devDiff.stdout));
const featDiff = await git(cwd, ["diff", "--name-only", mergeBase, branch]);
const featFiles = new Set(filterGenerated(namesOnly(featDiff.stdout)));
const overlap = delta.filter((f) => featFiles.has(f));
return { devDelta: delta, overlap };
}
/** Read-only preflight: fetch + collision check + dev-delta report. Merges
* nothing. */
async function checkSync(lane, profile, branchArg) {
const branch = await resolveBranch(lane.cwd, branchArg);
assertFeatureBranch(branch);
await assertBranchExists(lane.cwd, branch);
await git(lane.cwd, ["fetch", "origin", "--prune"]);
const collisions = await collisionCheck(lane.cwd, profile.env.MIGRATIONS_DIR, branch);
if (collisions.length) return { code: 5, collisions };
const { devDelta, overlap } = await devDeltaReport(lane.cwd, branch, profile.generatedMergePaths);
return { code: 0, devDelta, overlap };
}
module.exports = {
INTEGRATION_BRANCH,
checkSync,
};
```
(`mergeSync`/`continueSync` are added in Steps 58 below — this step only needs `checkSync` to make the current tests pass.)
- [ ] **Step 4: Run tests to verify they pass**
Run: `node --test server/__tests__/lane-sync.test.js`
Expected: PASS (all 4 tests so far — the two guard tests and the two `--check` tests).
- [ ] **Step 5: Write the failing tests — clean merge, merge-driver setup, regen fold-in**
Append to `server/__tests__/lane-sync.test.js`:
```js
describe("lane-sync merge: clean merge lands upstream on the feature branch", () => {
it("merges origin/development into the feature branch as a merge commit", async () => {
const result = await laneSync.mergeSync(lane(), profile(), "feat/thing");
assert.equal(result.code, 0);
assert.equal(g(LANE_DIR, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/thing");
assert.doesNotThrow(() => g(LANE_DIR, "rev-parse", "-q", "--verify", "HEAD^2"));
assert.ok(fs.existsSync(path.join(LANE_DIR, "db", "migrations", "002_b.sql")));
});
});
describe("lane-sync merge: generated-file merge driver + regen fold-in", () => {
const GEN_DIR = path.join(ROOT, "gen-fixture");
const GEN_ORIGIN = path.join(ROOT, "gen-origin.git");
const GEN_LANE = path.join(ROOT, "gen-lane");
const GEN_PUSHER = path.join(ROOT, "gen-pusher");
before(() => {
fs.mkdirSync(GEN_DIR, { recursive: true });
g(GEN_DIR, "init", "-q", "--bare", GEN_ORIGIN);
const seed = path.join(GEN_DIR, "seed");
g(GEN_DIR, "init", "-q", "-b", "development", seed);
fs.writeFileSync(path.join(seed, "api", "openapi.json"), '{"v":1}\n', { flag: "w" });
fs.mkdirSync(path.join(seed, "api"), { recursive: true });
fs.writeFileSync(path.join(seed, "api", "openapi.json"), '{"v":1}\n');
gc(seed, "add", "-A");
gc(seed, "commit", "-qm", "init");
gc(seed, "remote", "add", "origin", GEN_ORIGIN);
gc(seed, "push", "-q", "origin", "development");
g(GEN_ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
g(GEN_DIR, "clone", "-q", GEN_ORIGIN, GEN_LANE);
g(GEN_DIR, "clone", "-q", GEN_ORIGIN, GEN_PUSHER);
gc(GEN_LANE, "checkout", "-qb", "feat/gen");
// The lane's own change to the generated file (would conflict without
// the keep-ours driver).
fs.writeFileSync(path.join(GEN_LANE, "api", "openapi.json"), '{"v":2,"branch":"feat"}\n');
gc(GEN_LANE, "add", "-A");
gc(GEN_LANE, "commit", "-qm", "feat: touches the contract");
// Upstream's own change to the same generated file.
fs.writeFileSync(path.join(GEN_PUSHER, "api", "openapi.json"), '{"v":2,"branch":"dev"}\n');
gc(GEN_PUSHER, "add", "-A");
gc(GEN_PUSHER, "commit", "-qm", "dev: also touches the contract");
gc(GEN_PUSHER, "push", "-q", "origin", "development");
// A regen hook the fold-in step will run.
const profileDir = path.join(GEN_LANE, ".ccam", "profile");
fs.mkdirSync(path.join(profileDir, "hooks"), { recursive: true });
fs.writeFileSync(path.join(profileDir, "profile.env"), "PORTS=api\n");
fs.writeFileSync(
path.join(profileDir, "hooks", "regen.sh"),
'#!/usr/bin/env bash\nset -euo pipefail\necho \'{"v":3,"regenerated":true}\' > "$LANE_DIR/api/openapi.json"\n'
);
fs.chmodSync(path.join(profileDir, "hooks", "regen.sh"), 0o755);
});
function genProfile() {
return {
env: { MIGRATIONS_DIR: "", GENERATED_MERGE_PATHS: "api/openapi.json" },
generatedMergePaths: ["api/openapi.json"],
hooks: new Set(["regen"]),
dir: path.join(GEN_LANE, ".ccam", "profile"),
};
}
it("installs a keep-ours driver so the generated file never conflicts, then folds regen output into the merge commit", async () => {
const result = await laneSync.mergeSync({ cwd: GEN_LANE, slot: 999, id: 999 }, genProfile(), "feat/gen");
assert.equal(result.code, 0);
const contents = fs.readFileSync(path.join(GEN_LANE, "api", "openapi.json"), "utf8");
assert.match(contents, /"regenerated":true/);
// The regen output landed IN the merge commit, not a separate one.
assert.doesNotThrow(() => g(GEN_LANE, "rev-parse", "-q", "--verify", "HEAD^2"));
const parents = g(GEN_LANE, "log", "-1", "--format=%P").trim().split(" ");
assert.equal(parents.length, 2);
});
});
```
- [ ] **Step 6: Run tests to verify they fail**
Run: `node --test server/__tests__/lane-sync.test.js`
Expected: FAIL — `laneSync.mergeSync is not a function`.
- [ ] **Step 7: Implement `mergeSync` + the merge-driver setup + regen fold-in**
Append to `server/lib/lane-sync.js`, before `module.exports`:
```js
/** The worktree-private git dir (HEAD, index, MERGE_HEAD live here — distinct
* from the shared common dir below). Resolved fresh each call: cheap, and a
* cached value would go stale the moment a lane's slot/worktree changes. */
async function gitDir(cwd) {
const result = await git(cwd, ["rev-parse", "--git-dir"]);
const dir = result.stdout.trim();
return path.isAbsolute(dir) ? dir : path.join(cwd, dir);
}
/** The dir shared across every worktree of a repo — where info/attributes
* and git config live. For a plain (non-worktree) clone this is the same
* as gitDir(); for a `git worktree add` lane it is the source repo's own
* .git, so the merge driver is configured once per repository, not once
* per lane. */
async function commonGitDir(cwd) {
const result = await git(cwd, ["rev-parse", "--git-common-dir"]);
const dir = result.stdout.trim();
return path.isAbsolute(dir) ? dir : path.join(cwd, dir);
}
async function unmergedFiles(cwd) {
const result = await git(cwd, ["ls-files", "-u"]);
const files = new Set();
for (const line of result.stdout.split("\n")) {
const tab = line.indexOf("\t");
if (tab > -1) files.add(line.slice(tab + 1));
}
return [...files];
}
/** Generated artifacts (an OpenAPI contract, its generated client, ...) must
* never be hand-merged: a keep-ours driver (`true` exits 0 -> keep our
* side, no conflict) via the clone-local attributes file, idempotent every
* call — same "idempotent, never automatic" shape this repo's proof-link
* already established. */
async function setupMergeDriver(cwd, generatedPaths) {
if (!generatedPaths.length) return;
await git(cwd, ["config", "merge.ccam-generated.driver", "true"]);
await git(cwd, [
"config",
"merge.ccam-generated.name",
"keep ours; regenerated post-merge by the profile regen hook",
]);
const infoDir = path.join(await commonGitDir(cwd), "info");
fs.mkdirSync(infoDir, { recursive: true });
const attrPath = path.join(infoDir, "attributes");
const existing = fs.existsSync(attrPath) ? fs.readFileSync(attrPath, "utf8") : "";
const lines = new Set(existing.split("\n").filter(Boolean));
let changed = false;
for (const gp of generatedPaths) {
const line = `${gp} merge=ccam-generated`;
if (!lines.has(line)) {
lines.add(line);
changed = true;
}
}
if (changed) fs.writeFileSync(attrPath, [...lines].join("\n") + "\n");
}
/** Regenerate generated artifacts from the just-synced tree and fold them
* into the merge commit (or, on the --continue path, a follow-up commit).
* A no-op when nothing changed. */
async function regenFold(lane, profile, generatedPaths) {
if (!generatedPaths.length || !profile.hooks.has("regen")) return;
await runHook(lane, profile, "regen", []);
try {
await git(lane.cwd, ["add", "--", ...generatedPaths]);
} catch {
// A generated path that doesn't exist yet on this branch is fine —
// nothing to stage for it.
}
const staged = await git(lane.cwd, ["diff", "--cached", "--name-only"]);
if (!staged.stdout.trim()) return;
let isMergeCommit = true;
try {
await git(lane.cwd, ["rev-parse", "-q", "--verify", "HEAD^2"]);
} catch {
isMergeCommit = false;
}
if (isMergeCommit) {
await git(lane.cwd, ["commit", "--amend", "--no-edit"]);
} else {
await git(lane.cwd, ["commit", "-m", "chore: regenerate artifacts after dev sync"]);
}
}
/** The one sanctioned merge: origin/development INTO the feature branch. */
async function mergeSync(lane, profile, branchArg) {
const branch = await resolveBranch(lane.cwd, branchArg);
assertFeatureBranch(branch);
await assertBranchExists(lane.cwd, branch);
await git(lane.cwd, ["fetch", "origin", "--prune"]);
const generatedPaths = profile.generatedMergePaths;
await setupMergeDriver(lane.cwd, generatedPaths);
const collisions = await collisionCheck(lane.cwd, profile.env.MIGRATIONS_DIR, branch);
if (collisions.length) return { code: 5, collisions };
await git(lane.cwd, ["checkout", "--quiet", branch]);
try {
await git(lane.cwd, ["merge", "--no-edit", `origin/${INTEGRATION_BRANCH}`]);
} catch (err) {
const conflicted = await unmergedFiles(lane.cwd);
const mergeHeadPath = path.join(await gitDir(lane.cwd), "MERGE_HEAD");
if (conflicted.length && fs.existsSync(mergeHeadPath)) {
return { code: 4, conflictedFiles: conflicted };
}
throw err;
}
// rerere may have auto-resolved every conflict but left the merge
// uncommitted — finish it.
const mergeHeadPath = path.join(await gitDir(lane.cwd), "MERGE_HEAD");
if (fs.existsSync(mergeHeadPath) && !(await unmergedFiles(lane.cwd)).length) {
await git(lane.cwd, ["commit", "--no-edit"]);
}
await regenFold(lane, profile, generatedPaths);
return { code: 0 };
}
```
Update `module.exports` at the bottom of `lane-sync.js`:
```js
module.exports = {
INTEGRATION_BRANCH,
checkSync,
mergeSync,
};
```
- [ ] **Step 8: Run tests to verify they pass**
Run: `node --test server/__tests__/lane-sync.test.js`
Expected: PASS.
- [ ] **Step 9: Write the failing tests — conflict left in place, `--continue`**
Append to `server/__tests__/lane-sync.test.js`:
```js
describe("lane-sync merge: conflict is left in place, --continue finishes it", () => {
it("exits with code 4 and leaves MERGE_HEAD in place on a real conflict", async () => {
fs.writeFileSync(path.join(LANE_DIR, "README.md"), "feature words\n");
gc(LANE_DIR, "add", "-A");
gc(LANE_DIR, "commit", "-qm", "feat: readme");
fs.writeFileSync(path.join(PUSHER_DIR, "README.md"), "upstream words\n");
gc(PUSHER_DIR, "add", "-A");
gc(PUSHER_DIR, "commit", "-qm", "other readme");
gc(PUSHER_DIR, "push", "-q", "origin", "development");
const result = await laneSync.mergeSync(lane(), profile(), "feat/thing");
assert.equal(result.code, 4);
assert.deepEqual(result.conflictedFiles, ["README.md"]);
assert.ok(fs.existsSync(path.join(LANE_DIR, ".git", "MERGE_HEAD")));
});
it("--continue refuses while conflicts are unresolved", async () => {
await assert.rejects(() => laneSync.continueSync(lane(), profile(), "feat/thing"), {
code: "EUNRESOLVED",
});
});
it("--continue refuses while the merge is resolved but not committed", async () => {
fs.writeFileSync(path.join(LANE_DIR, "README.md"), "merged words\n");
gc(LANE_DIR, "add", "README.md");
await assert.rejects(() => laneSync.continueSync(lane(), profile(), "feat/thing"), {
code: "EMERGEUNCOMMITTED",
});
});
it("--continue finishes after the conflict is resolved and committed", async () => {
gc(LANE_DIR, "commit", "-q", "--no-edit");
const result = await laneSync.continueSync(lane(), profile(), "feat/thing");
assert.equal(result.code, 0);
assert.equal(g(LANE_DIR, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/thing");
});
});
```
- [ ] **Step 10: Run tests to verify they fail**
Run: `node --test server/__tests__/lane-sync.test.js`
Expected: FAIL — `laneSync.continueSync is not a function`.
- [ ] **Step 11: Implement `continueSync`**
Append to `server/lib/lane-sync.js`, before `module.exports`:
```js
/** Finish a sync after the session resolved a conflicted merge and
* committed it. Stateless — reads the lane's own git state directly rather
* than trusting a separate flag, so it can never disagree with reality. */
async function continueSync(lane, profile, branchArg) {
const branch = await resolveBranch(lane.cwd, branchArg);
const current = (await git(lane.cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
if (current !== branch) {
throw badBranch(`--continue: lane is not on '${branch}' (currently on '${current}')`);
}
const unresolved = await unmergedFiles(lane.cwd);
if (unresolved.length) {
throw Object.assign(
new Error(`--continue: unresolved conflicts remain: ${unresolved.join(", ")}`),
{ code: "EUNRESOLVED" }
);
}
const mergeHeadPath = path.join(await gitDir(lane.cwd), "MERGE_HEAD");
if (fs.existsSync(mergeHeadPath)) {
throw Object.assign(
new Error("--continue: merge not committed yet — git commit --no-edit"),
{ code: "EMERGEUNCOMMITTED" }
);
}
await regenFold(lane, profile, profile.generatedMergePaths);
return { code: 0 };
}
```
Update `module.exports`:
```js
module.exports = {
INTEGRATION_BRANCH,
checkSync,
mergeSync,
continueSync,
};
```
- [ ] **Step 12: Run tests to verify they pass**
Run: `node --test server/__tests__/lane-sync.test.js`
Expected: PASS — every test in the file.
- [ ] **Step 13: Verify against a REAL worktree lane (not just plain clones)**
The merge-driver setup and `MERGE_HEAD` detection use `--git-common-dir` vs `--git-dir` specifically because a `git worktree add` lane's `.git` is a FILE pointing at a private per-worktree dir, while `info/attributes` lives in the shared common dir. The tests above use plain clones (where both resolve to the same `.git`), which would pass even if this distinction were implemented backwards. Add one more test using this repo's own `worktree.js` to catch that class of bug:
```js
describe("lane-sync against a real git-worktree lane", () => {
it("resolves MERGE_HEAD and info/attributes correctly under git worktree add", async () => {
const wt = require("../lib/worktree");
const WT_ROOT = path.join(ROOT, "wt-fixture");
fs.mkdirSync(WT_ROOT, { recursive: true });
const src = path.join(WT_ROOT, "src");
g(WT_ROOT, "init", "-q", "-b", "development", src);
fs.writeFileSync(path.join(src, "README.md"), "hello\n");
gc(src, "add", "-A");
gc(src, "commit", "-qm", "init");
gc(src, "remote", "add", "origin", src); // self-origin: fetch is a same-repo no-op, good enough here
gc(src, "branch", "-f", "refs/remotes/origin/development", "development");
const wtDir = path.join(WT_ROOT, "wt-lane");
await wt.addWorktree({ sourceRepo: src, dir: wtDir, branch: "feat/wt", base: "development" });
// Simulate upstream moving, so devDeltaReport / collisionCheck have
// something to resolve against without a real remote. `src` is still on
// "development" here — addWorktree only checks out feat/wt in the NEW
// worktree dir; checking out feat/wt on src too would collide with the
// worktree (git refuses the same branch checked out twice).
fs.writeFileSync(path.join(src, "README.md"), "upstream change\n");
gc(src, "add", "-A");
gc(src, "commit", "-qm", "upstream");
gc(src, "branch", "-f", "refs/remotes/origin/development", "development");
const wtProfile = { env: { MIGRATIONS_DIR: "", GENERATED_MERGE_PATHS: "" }, generatedMergePaths: [], hooks: new Set() };
const result = await laneSync.checkSync({ cwd: wtDir }, wtProfile, "feat/wt");
assert.equal(result.code, 0);
assert.equal(result.devDelta.length, 1);
});
});
```
- [ ] **Step 14: Run full test file, verify pass**
Run: `node --test server/__tests__/lane-sync.test.js`
Expected: PASS — every test, including the worktree one.
- [ ] **Step 15: Header check + full suite**
```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```
- [ ] **Step 16: Commit**
```bash
git add server/lib/lane-sync.js server/__tests__/lane-sync.test.js
git commit -m "feat(lanes): add lane-sync core — check/merge/continue (E2)"
```
---
### Task 3: `POST /api/lanes/:id/sync-base` route
**Files:**
- Modify: `server/routes/lanes.js`
**Interfaces:**
- Consumes: `checkSync`/`mergeSync`/`continueSync` from Task 2 (`require("../lib/lane-sync")`); `requireProfile` (already imported, `lane-runtime.js`); `withLaneLock` (already imported); `laneOr404`, `sendRuntimeError` (already defined in this file).
- Produces: `POST /api/lanes/:id/sync-base` — body `{mode?: "check"|"merge"|"continue", branch?: string}` (`mode` defaults `"merge"`). `200` with the raw `checkSync`/`mergeSync`/`continueSync` result (including `code: 4` and `code: 5` — those are documented outcomes, not HTTP errors). `400`/`404`/`409` with `{error: {code, message}}` on a genuine fault (no lane, no profile, bad branch, unresolved `--continue`).
- [ ] **Step 1: Add the `lane-sync` import**
In `server/routes/lanes.js`, add near the other `lib` requires (after `const { withLaneLock } = require("../lib/lane-lock");` at line 34):
```js
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
```
- [ ] **Step 2: Extend `sendRuntimeError`'s bad-request code list**
`lane-sync.js`'s only thrown error code is `EBADBRANCH`; `continueSync` also throws `EUNRESOLVED`/`EMERGEUNCOMMITTED`. All three are caller mistakes (bad branch name, calling `--continue` too early), not server faults — they belong with the existing 400 group. In `sendRuntimeError` (`server/routes/lanes.js:566`):
```js
function sendRuntimeError(res, err) {
const badRequest = [
"ENOPROFILE",
"ENOHOOK",
"EBADLANEDIR",
"EBADSVC",
"EBADBRANCH",
"EUNRESOLVED",
"EMERGEUNCOMMITTED",
];
```
- [ ] **Step 3: Add the route**
Insert directly after the `/:id/hook/:name` route's closing `});` (`server/routes/lanes.js:760`, right before the `/:id/:action` catch-all comment block) — route order matters here, since the catch-all would otherwise 400 an unmatched path:
```js
/**
* The ONE sanctioned merge in the ship-feature-lane pipeline: origin/development
* INTO a feature branch, gated by a migration-number collision preflight.
* Synchronous — a fetch + collision-check + merge is seconds of git work, not
* the minutes a build/test hook can take, so this follows GET /:id/git's
* pattern rather than the hook route's 202-and-broadcast.
*
* Returns 200 with {code: 0|4|5, ...} for every DOCUMENTED outcome — a
* migration collision or a left-in-place conflict is an expected result, not
* an HTTP error. A malformed request, a missing profile, or an out-of-order
* --continue is the only case that answers with an `error` body.
*
* Never writes stage/status/notes — same boundary the hook and runtime
* routes already keep; the caller decides what a collision or conflict means
* for the lane's declared stage.
*/
router.post("/:id/sync-base", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
let profile;
try {
profile = requireProfile(lane);
} catch (err) {
return sendRuntimeError(res, err);
}
const mode = ["check", "merge", "continue"].includes(req.body?.mode) ? req.body.mode : "merge";
const branch =
typeof req.body?.branch === "string" && req.body.branch ? req.body.branch : undefined;
try {
const result = await withLaneLock(lane.id, () => {
const current = lanesLib.getLane(lane.id);
if (mode === "check") return checkSync(current, profile, branch);
if (mode === "continue") return continueSync(current, profile, branch);
return mergeSync(current, profile, branch);
});
res.json(result);
} catch (err) {
sendRuntimeError(res, err);
}
});
```
- [ ] **Step 4: Verify with a quick manual smoke check**
There's no HTTP-level test harness for lane routes in this repo (per the Global Constraints note) — Task 2's unit tests already cover `checkSync`/`mergeSync`/`continueSync` behavior directly. Confirm the route itself is wired correctly by starting the dev server and hitting it against a real profile-having lane:
```bash
npm run dev &
sleep 3
# Replace 1 with a real lane id that has a .ccam/profile and a feature branch.
curl -s -X POST http://localhost:4820/api/lanes/1/sync-base \
-H 'Content-Type: application/json' \
-d '{"mode":"check","branch":"feat/some-branch"}' | node -e "process.stdin.pipe(require('node:fs').createWriteStream('/dev/stdout'))"
```
Expected: a JSON body with a `code` field (0 or 5), not an Express 404/500 HTML page. Stop the dev server afterward.
- [ ] **Step 5: Run the full suite + header check**
```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```
- [ ] **Step 6: Commit**
```bash
git add server/routes/lanes.js
git commit -m "feat(lanes): add POST /:id/sync-base route (E2)"
```
---
### Task 4: `ccam lanes sync-base` CLI
**Files:**
- Modify: `bin/ccam.js`
**Interfaces:**
- Consumes: `POST /api/lanes/:id/sync-base` (Task 3); `resolveLaneArg(args)`, `post(path, body, options)` (both already defined in `bin/ccam.js`).
- Produces: `ccam lanes sync-base [<id>] [--check|--continue] [branch]``process.exitCode` set to `0`/`4`/`5` matching the route's `code`, so `SKILL.md`'s documented exit-code contract holds when scripted.
- [ ] **Step 1: Add the subcommand to `cmdLanesRuntime`**
In `bin/ccam.js`, extend the `if (sub === "hook") { ... }` block (ends at `bin/ccam.js:1902`) with a new `sync-base` branch, inside the same `cmdLanesRuntime(sub, args)` function:
```js
if (sub === "sync-base") {
const mode = laneArgs.includes("--check")
? "check"
: laneArgs.includes("--continue")
? "continue"
: "merge";
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
const result = await post(
`/api/lanes/${laneId}/sync-base`,
{ mode, branch },
{ allowError: true }
);
if (result.status) {
console.error(`✖ sync-base → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
if (result.code === 5) {
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
for (const c of result.collisions) {
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
}
process.exitCode = 5;
return;
}
if (result.code === 4) {
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error(
` then: ccam lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
);
process.exitCode = 4;
return;
}
if (mode === "check") {
if (result.devDelta === null) {
console.log("DEV_DELTA: unknown (no merge-base with origin/development)");
} else {
console.log(
`DEV_DELTA: ${result.devDelta.length} file(s) changed on origin/development since merge-base`
);
for (const f of result.devDelta) console.log(` ${f}`);
if (result.overlap.length) {
console.log(
`DEV_OVERLAP: ${result.overlap.length} file(s) — the upstream delta touches the feature's files:`
);
for (const f of result.overlap) console.log(` ${f}`);
} else {
console.log("DEV_OVERLAP: none");
}
}
console.log(`lane #${laneId} preflight vs origin/development: OK`);
return;
}
console.log(`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`);
return;
}
```
- [ ] **Step 2: Wire the subcommand into the dispatcher**
`bin/ccam.js:3088` currently reads:
```js
if (["up", "down", "runtime", "logs", "hook"].includes(rest[0])) {
return cmdLanesRuntime(rest[0], rest.slice(1));
}
```
Change to:
```js
if (["up", "down", "runtime", "logs", "hook", "sync-base"].includes(rest[0])) {
return cmdLanesRuntime(rest[0], rest.slice(1));
}
```
- [ ] **Step 3: Add the help-catalog entry**
In the command catalog array (`bin/ccam.js:2268`, right after the `lanes hook` entry), add:
```js
[
"lanes sync-base",
"[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
],
```
- [ ] **Step 4: Manual smoke test against the running dashboard**
```bash
npm run dev &
sleep 3
node bin/ccam.js lanes sync-base --check feat/some-branch 1
echo "exit: $?"
```
Expected: exit code `0` (clean) or `5` (collision) with readable output, not a stack trace. Stop the dev server afterward.
- [ ] **Step 5: Run the full suite + header check**
```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```
- [ ] **Step 6: Commit**
```bash
git add bin/ccam.js
git commit -m "feat(lanes): add ccam lanes sync-base CLI (E2)"
```
---
### Task 5: `SKILL.md` edits + docs
**Files:**
- Modify: `.claude/skills/ship-feature-lane/SKILL.md`
- Modify: `docs/LANES.md`
- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`
**Interfaces:** none — documentation only, but every command referenced must now be real (this repo's docs-markdown rule: keep command examples executable and aligned with actual scripts).
- [ ] **Step 1: Stage 2 — replace the "later task" paragraph**
In `.claude/skills/ship-feature-lane/SKILL.md`, find (Stage 2, `### 2 — Pre-push CI gates + dev preflight`):
```
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. **This command is a LATER task, not yet built as of this skill's authoring** — until it exists, skip this preflight check and rely on Stage 12's conflict handling to catch a divergence at merge time; note this gap explicitly in your Stage 2 evidence (`ccam stage gates --evidence "sync-base preflight unavailable, skipped"`).
- Once built, its contract is: exit 5 on a migration-number collision (print the exact rename, do it on the feature branch, re-run Stage 2); informational `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise (you do NOT sync the branch for it — GitHub merges non-conflicting histories fine).
```
Replace with:
```
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
```
- [ ] **Step 2: Stage 8 — make the re-run unconditional**
Find (Stage 8, around `SKILL.md:127`):
```
- Re-run the preflight if `ccam lanes sync-base` exists by the time you read this — development may have moved while you were in QC. If it doesn't exist yet, skip straight to the push (same gap noted in Stage 2).
```
Replace with:
```
- Re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
```
- [ ] **Step 3: Stage 12 — unconditional merge + the exit-4/`--continue` mechanics**
Find (Stage 12, around `SKILL.md:155`):
```
- `CONFLICTING` → the feature branch conflicts with `development`. Resolve it as real work:
- If `ccam lanes sync-base` exists by now: `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). Resolve every conflict thoughtfully — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. `git add` ONLY the conflicted files, `git commit --no-edit`.
- If it doesn't exist yet: `git fetch origin && git merge origin/development` directly on the feature branch, resolve conflicts the same way, commit.
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
```
Replace with:
```
- `CONFLICTING` → the feature branch conflicts with `development`. Resolve it as real work:
- `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step.
- **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit).
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
```
- [ ] **Step 4: `docs/LANES.md` — add a sync-base subsection**
In `docs/LANES.md`, under `## The ship-feature-lane skill (E1)` (`docs/LANES.md:967`), insert a new subsection after "### QC boot flag and profile integration" (ends around `docs/LANES.md:1005`) and before "### Pipeline template: ship-feature (16 node stages)":
```markdown
### Dev preflight and merge safety: sync-base
`ccam lanes sync-base` is the ONE sanctioned merge in the pipeline — `origin/development` into a feature branch — used at Stages 2, 8, and 12. Three modes:
```bash
ccam lanes sync-base --check feat/<slug> # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP
ccam lanes sync-base feat/<slug> # merge origin/development into the feature branch
ccam lanes sync-base --continue feat/<slug> # finish after a manually resolved conflict
```
Exit codes: `0` clean, `4` merge conflict (left in place — resolve, commit, then `--continue`), `5` migration-number collision (nothing merged — rename the printed file, re-run).
Two profile declarations control it, both empty (off) by default:
```bash
# .ccam/profile/profile.env
MIGRATIONS_DIR="db/migrations"
GENERATED_MERGE_PATHS="api/openapi.json api/client.ts"
```
`MIGRATIONS_DIR` enables the collision preflight against a numbered-migrations directory. `GENERATED_MERGE_PATHS` gives the listed files a keep-ours merge driver (never hand-merged) and folds the profile's `regen` hook output into the sync commit — the single most common cross-lane conflict, for a repo that generates an API contract/client.
```
Also update the "Current status" bullets (`docs/LANES.md:983-999`) — the stages 0-8/10-12/14 list is unaffected (sync-base was already inside those stages' scope, just non-functional), so no change needed there; the new subsection above is the only addition.
- [ ] **Step 5: Roadmap progress line**
In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, find the `## E` section's `**Progress:**` line (`docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md:282`):
```
**Progress:** pipeline template + skill text (E1) done 2026-08-04 — see `docs/superpowers/specs/2026-08-04-ship-feature-skill-design.md`. Agents, `sync-base`, and F's integrations remain.
```
Replace with:
```
**Progress:** pipeline template + skill text (E1) done 2026-08-04 — see `docs/superpowers/specs/2026-08-04-ship-feature-skill-design.md`. `sync-base` (E2) done 2026-08-05 — see `docs/superpowers/specs/2026-08-05-sync-base-design.md`. Agents and F's integrations remain.
```
- [ ] **Step 6: Verify and commit**
```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```
```bash
git add .claude/skills/ship-feature-lane/SKILL.md docs/LANES.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
git commit -m "docs(lanes): document ccam lanes sync-base (E2)"
```