Files
Claude-Code-Monitor/docs/superpowers/plans/2026-08-05-sync-base.md
T
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

50 KiB
Raw Blame History

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).generatedMergePathsstring[], 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):

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):

  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:

    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
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 .codeEBADBRANCH (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:

/**
 * @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:

/**
 * @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:

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:

/** 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:

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:

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:

/** 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:

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:

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 .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
  • Step 16: Commit
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):

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):

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:

/**
 * 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:

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 .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
  • Step 6: Commit
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:

  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:

      if (["up", "down", "runtime", "logs", "hook"].includes(rest[0])) {
        return cmdLanesRuntime(rest[0], rest.slice(1));
      }

Change to:

      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:

      [
        "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
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 .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
  • Step 6: Commit
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)":

### 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:

# .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
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)"