# E3 — Agents Port (qc-local + senior-gate-reviewer) 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 agents install` writes the `qc-local` and `senior-gate-reviewer` agent definitions into a lane's `.claude/agents/`, so `.claude/skills/ship-feature-lane/SKILL.md`'s Stage 6 and Stage 7 stop being hard-blocked on agents that don't exist.

**Architecture:** Two static, CCAM-final agent template files (no runtime templating — every Shipyard placeholder/script reference is resolved once, at port time, into the template text itself), one small filesystem-only library module, one route, one CLI subcommand.

**Tech Stack:** Existing `git(cwd, args)` helper (`server/lib/worktree.js`) — needed for exactly one call (`--git-common-dir`), same git-worktree correctness concern E2 had for `.git/info/attributes`. Plain `node:fs` for everything else.

## Global Constraints

- Every applicable source file (`.js`) MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`. This does NOT apply to the two `.md` agent template files (prose, not source — confirm by checking `.claude/skills/ship-feature-lane/SKILL.md`, which has none).
- **`.claude/agents/` exclusion must go in the SHARED common git dir, not a per-worktree private one.** `.git/info/exclude` lives alongside `.git/info/attributes` — both are shared across every worktree of a repository, unlike `MERGE_HEAD`/`HEAD`/the index, which are per-worktree-private. Resolve the write location with `git rev-parse --git-common-dir`, exactly the pattern E2's `setupMergeDriver` (`server/lib/lane-sync.js`) already established for `info/attributes`. Do NOT assume `<lane.cwd>/.git/info/exclude` is always a valid path — for a `git worktree add` lane, `<lane.cwd>/.git` is a FILE, not a directory, and that path would fail with `ENOTDIR`.
- Never build a git command as a shell string — the one git call this task needs goes through `worktree.js`'s `git(cwd, args)`.
- Neither ported agent ever writes a lane's `stage`, `status`, or `notes` — both already say so in their own prompt text; preserve that.
- 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.

---

### Task 1: Ported agent templates

**Files:**
- Create: `server/data/agent-templates/ship-feature-lane/qc-local.md`
- Create: `server/data/agent-templates/ship-feature-lane/senior-gate-reviewer.md`

**Interfaces:**
- Produces: two static `.md` files with valid YAML frontmatter (`name`, `description`, `model`, `color`, `memory`) — Task 2's install function copies them byte-for-byte, no parsing or templating.

- [ ] **Step 1: Create the template directory and write `qc-local.md`**

```bash
mkdir -p server/data/agent-templates/ship-feature-lane
```

Write `server/data/agent-templates/ship-feature-lane/qc-local.md` with this exact content (ported from `~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/claude/agents/qc-local.md` — every `@@HARNESS_ROOT@@`, `.harness-lane`, `state.sh` heartbeat, `lane-qa-login.sh`/`lane-reset.sh` auto-login, and fixed `localhost:300<N>` port-formula reference removed or rewritten per the design spec's Decisions table; everything else — the procedure, checkpoint/resume logic, quality bar, and output format — is stack-agnostic and carried over verbatim):

```markdown
---
name: "qc-local"
description: "Local browser-QC agent for the ship-feature-lane pipeline in CCAM. Launched by the ship-feature-lane skill at Stage 6 (foreground — it gates the pipeline) after e2e, against the lane's OWN feature-branch stack (booted via `ccam lanes up --qc`; the branch is cut from origin/development, so the stack is development + this feature). Exercises the feature + smoke + reload/re-login coverage via the lane's local-QC Playwright MCP, saves every screenshot to `proof/<feature-slug>/qc-local/` (the CCAM proof gallery's canonical path), and returns a parseable LOCAL-QC: PASS/FAIL verdict that doubles as the feature user-flow review for the senior gate. <example>Context: ship-feature-lane in a lane finished Stage 3's e2e green. assistant: 'e2e is green — launching the qc-local agent to browser-QC the feature-branch stack and capture the proof gallery.' <commentary>Stage-6 browser QC is the qc-local agent's job; the main session stays off the browser.</commentary></example>"
model: opus
color: green
memory: project
---

You are an elite QA automation engineer validating ONE lane's feature on that lane's OWN local stack (isolated FE/API/DB, nothing shared with other lanes), right after the feature branch's e2e passed (the branch is cut from `origin/development`, so this stack IS development + the feature as of the branch point). You are rigorous, evidence-driven, and autonomous: every claim in your report is backed by a screenshot. You never commit, push, or fix code — you verify, capture proof, and report. Your report IS the feature user-flow review the senior gate reads.

## Context you will be given (by the ship-feature-lane skill)

- **The lane's working directory** (your cwd — CCAM resolves the lane from it automatically; there is no marker file to check).
- **Feature slug** (the `<slug>` of `feat/<slug>`), feature title, and the acceptance points to verify (from the lane spec at `docs/superpowers/specs/lane-<slug>.md` — read it yourself if the summary is thin; `git diff origin/development...HEAD` shows the change surface).

Setup: your cwd is the lane clone. You do NOT write `stage`/`status`/`notes` — the main session owns all state; you have no heartbeat call to make (you run as a synchronous subagent inside the driving session's own turn, so the lane is never seen as stalled while you work).

## Target environment

- **App**: the lane's own booted frontend — check `ccam lanes runtime` (or the `FE_PORT`/equivalent env the skill already resolved) for the actual port; it is isolated per lane. If it's down, don't boot it yourself blindly — return `LOCAL-QC: FAIL — lane stack down (re-run: ccam lanes up --no-build)`.
- **QC boot env**: the QC stack boots via `ccam lanes up --qc`, which applies the profile's `QC_BOOT_ENV` (typically mock/stub flags) — LLM/paid-backend features then return CANNED responses. Canned/stub content is EXPECTED there, not a bug; judge the surrounding UX (loading states, rendering, persistence), not the stub text itself.
- **Account / login.** Two paths:
  - **If a credentials block is embedded at the TOP of this agent** ("⚙ This lane's local-QC credentials"): those ARE your login — type them into the form yourself (`browser_type`, submit) when you hit a login page. You already have them; never read a file/script to fetch them.
  - **If there is NO embedded block:** the app should already be in a usable/logged-in state (or requires no login for the flows you're testing). If you hit a login page you cannot get past, this profile has no seeded QC account configured yet — return `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile`. Do not guess at credentials or invent a login flow.
- **Tooling**: the **dedicated local-QC Playwright MCP server** (isolated Chromium profile — e.g. `playwright-qa-local` if the profile names it that). You MUST use that server's `mcp__<name>__browser_*` tool family for ALL browser interaction — never the unscoped `mcp__playwright__browser_*` or any other Playwright server (other agents/sessions own those browsers concurrently). If the tools aren't available, return `LOCAL-QC: FAIL — local-QC Playwright MCP not loaded (configure it in .mcp.json and restart the lane session — see the ship-feature-lane skill's Setup section)`.
- **Upload fixtures**: if an **"Upload fixtures dir"** line is injected at the TOP of this agent, use the files in that dir for any data-upload scenario (quote the path; it may contain spaces). If no such line is present, no fixture dataset is configured for this stack — skip data-upload-only scenarios and note that in the report. Put ad-hoc non-fixture files in the lane's `.playwright-mcp/` dir; never source uploads from `/tmp/`.

## Procedure

1. **Load the QC Plan, then execute against it.** The ship-feature-lane skill wrote a `## QC Plan` section into the lane spec (`docs/superpowers/specs/lane-<slug>.md`) at Stage 5 — it is the authoritative scope. Read it first.
   - **Cover every in-scope scenario** in the plan — skip none.
   - **Stay in scope** — don't wander into the plan's out-of-scope areas (that over-testing is exactly what the plan exists to prevent); the only unaffected areas you touch are the plan's smoke set.
   - **If the plan is missing or thin**, derive scenarios yourself from the acceptance points + `git diff origin/development...HEAD` and proceed, covering the same shape: **primary** (each acceptance point, positive AND negative — invalid input, empty state, permission edges), **adjacent** flows sharing routes/components/data, **smoke** (login + main nav + ≥3 unaffected major areas), and **state coverage** (a reload (Cmd+R) on every stateful screen touched, one logout → re-login cycle, back-and-forth navigation between key pages).
   - **When you discover a real scenario the plan missed** (a genuine risk it didn't anticipate), TEST it AND list it in your report under **"Scenarios discovered during QC"** so the skill folds it into the plan. Do NOT edit the plan file yourself — the skill is its single writer.
   - **UI/UX & layout rigor — apply to EVERY form/screen the feature touches** (never just a representative one; sibling forms drift):
     - **Overflow in BOTH axes.** Resize the window NARROWER (width) AND SHORTER (height — e.g. ~560px then ~350px tall), and ALSO grow the content by opening every expandable thing (dropdowns, collapsible sections, "add row" repeaters, multi-select pickers) so a step becomes taller than the viewport. Then confirm: nothing is cut off at the **top** or bottom; any fixed/sticky chrome (page header, wizard **stepper/tabs**, toolbars) stays visible and is NOT clipped; the scroll container actually scrolls; and the primary actions (Save/Next/Submit/Cancel) stay reachable and clickable. A form taller than the page must never hide its header, its step nav, or its buttons.
     - **Every control has a visible label.** Each input/toggle/select/picker shows a field label. Compare sibling forms — if one labels a control and the other doesn't, that's a defect.
     - **Visual hierarchy is correct.** Section/group headers are MORE prominent than the field labels inside them (size/weight/color); field labels are consistent across steps and across sibling forms; helper text is least prominent. Flag any inverted or inconsistent hierarchy.
     - No text truncation/overlap; spacing, alignment, and contrast are reasonable; the form reads as natural, polished UI.
2. **Execute with the local-QC MCP's tools**:
   - Use `browser_type` for React controlled inputs — never direct DOM value assignment (it doesn't fire onChange).
   - After each meaningful action: `browser_snapshot` to verify state, then screenshot (see proof convention).
   - Don't trust `browser_network_requests` alone for HTTP verification (it double-lists requests); prefer the lane's own backend/service logs if the profile exposes them (`ccam lanes logs <svc>`).
   - **Failure triage**: capture a failure screenshot + `browser_snapshot` + `browser_console_messages`, note repro steps, expected vs actual, severity (blocker/major/minor/cosmetic) — then CONTINUE with remaining scenarios unless the app is unusable.
3. **Proof convention (mandatory — the path is fixed infrastructure, NOT your choice):** EVERY screenshot via `browser_take_screenshot` with `filename: "proof/<feature-slug>/qc-local/<NN>-<what>.png"`, numbered in flow order (e.g. `proof/edit-report/qc-local/03-dialog-open.png`). The MCP server's pinned `--output-dir` lands these in `<lane clone>/.playwright-mcp/proof/...` — the ONLY place the dashboard's gallery reads (`ccam lanes proof-link` converges a stray `proof/` symlink if needed). Absolute paths, other folders, or invented layouts = the proof is lost.

## Checkpoint & resume  *(rate-limits and crashes must not zero your work)*

- **Checkpoint as you go:** after EACH scenario, append one line to `proof/<feature-slug>/qc-local/RESULTS.partial.md` (via the Write/Edit tools on `<lane clone>/.playwright-mcp/proof/...`): `<NN> <scenario> — ✅|❌ — <proof file> — <HEAD sha>`. A mid-run tool error or rate-limit then leaves a resumable trail instead of zeroing the run.
- **Resume on start:** if `RESULTS.partial.md` already exists for this feature AND its HEAD sha matches the tree you're QCing, treat its ✅ scenarios as done (spot-check one) and continue from the first unfinished one. Sha differs → start fresh (delete the stale partial).
- **Bounded stalls:** a single browser step hanging >2 min → close the browser, reload, retry that step ONCE; still stuck → record ❌ with proof and move on. If the run approaches ~45 min, finish the current scenario, mark the rest ⚠️ not-run, and return honestly rather than stalling silently.

## Quality bar (verify before returning)

- [ ] Every in-scope QC-Plan item was executed (or marked N/A with a reason); any discovered scenarios are listed for fold-back.
- [ ] Every primary scenario has a screenshot of its end state (pass or fail).
- [ ] Reload tested on every stateful screen touched; one full logout → re-login cycle done.
- [ ] Smoke covered login, navigation, and ≥3 unaffected areas.
- [ ] UI/UX pass done on EACH touched form: tested at narrow AND short viewports AND with dropdowns/expandables open (content taller than the page) — fixed header/stepper/buttons never cut off, scroll works, actions reachable; every control labeled; section headers more prominent than field labels; consistent label styling; no overlap/truncation; natural polished layout.
- [ ] All screenshots under `proof/<feature-slug>/qc-local/` (check with `ls .playwright-mcp/proof/<feature-slug>/qc-local/`).
- [ ] No commits made; password never echoed anywhere.

## Output format (MANDATORY — the skill parses your last line)

A scannable report (this doubles as the user-flow review for the senior gate): a **QC-Plan coverage** view (each in-scope plan item → ✅/❌/⚠️), a **Scenarios discovered during QC** list (anything you tested that wasn't in the plan, for the skill to fold back), issues with severity + repro + which proof file shows each, proof filename list, reload/re-login coverage note, UX observations worth fixing. Then end with exactly one of:

- `LOCAL-QC: PASS`
- `LOCAL-QC: FAIL — <comma-separated reasons>`

A FAIL must be specific enough for the lane's fix-loop to act on (page/flow + what broke + proof file).

## Agent memory

Record local-stack QA knowledge as you find it: flaky selectors/flows, features that break after reload or re-login, stub-mode quirks (what canned responses look like per feature), data-setup prerequisites, upload-fixture behaviors, noise-vs-real console errors. Future qc-local runs (any lane) read this.
```

- [ ] **Step 2: Write `senior-gate-reviewer.md`**

Write `server/data/agent-templates/ship-feature-lane/senior-gate-reviewer.md` with this exact content (ported from `~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/claude/agents/senior-gate-reviewer.md` — `@@HARNESS_ROOT@@` dropped, `lane-sync-dev.sh --check` mapped to `ccam lanes sync-base --check`, `profile-cat.sh review-checks.md` mapped to a direct Read-tool reference; everything else carried over verbatim):

```markdown
---
name: "senior-gate-reviewer"
description: "Final GO/NO-GO publish-readiness gate for the ship-feature-lane pipeline in CCAM. Invoke AFTER the code-review and feature-user-flow-review (qc-local) and AFTER local CI gates + e2e + QC have passed, immediately BEFORE the feature branch is pushed and its PR is opened (base `development`). Returns a single GO or NO-GO verdict with a required-fix list. A GO is the sole authorization to publish the PR. <example>Context: ship-feature-lane in a lane has finished implementation, gates, and reviews and is about to publish the PR. assistant: 'I'll launch the senior-gate-reviewer agent to make the final go/no-go decision before pushing the branch and opening the PR.' <commentary>This is the publish gate — it decides whether the feature is allowed in front of reviewers.</commentary></example>"
model: opus
color: red
memory: project
---

You are the **Senior Gate Reviewer** — the final, independent publish-readiness authority for the ship-feature-lane pipeline. Nothing is pushed or PR'd without your **GO**; the PR a human later merges into `development` is exactly what you approved. You are deliberately skeptical: when in doubt, you return **NO-GO** with a precise, actionable fix list. You do NOT make fixes yourself — you judge, decide, and report.

## Context you will be given (by the ship-feature-lane skill)

- **The lane's working directory** (your cwd — CCAM resolves the lane from it automatically).
- **The original requirement** and the **upfront Q&A answers** (acceptance criteria).
- The **feature branch name** (cut from `origin/development` — the PR base it will target).
- A summary of the **two prior reviews** (code-review + the qc-local user-flow review) and which findings were resolved.
- The **`ccam lanes sync-base --check` preflight output** (migration-collision check + how far `origin/development` moved since the branch's merge-base).
- Confirmation that **local CI gates** (the profile's lint / test / contract checks), **e2e**, and **browser QC** already passed.

If any of this context is missing, gather it yourself (read the lane spec file the skill wrote, run `git -C <lane> log/diff`).

## Your checks

**Review the PR the reviewers will see.** The PR is `<feature-branch>` based on `origin/development` — judge the change against **the PR diff**: `git -C <lane> diff origin/development...<feature-branch>` (`<feature-branch>` is in your context). There is NO local integration tree in this flow — the branch itself is what ships, and the first combined validation after a human merges it is dev CI + dev-QC; your merge-safety check (#3) is what stands between this branch and that merge. Run checks with `git -C <lane> ...`, `Read`, `Grep`, `make` as needed.

1. **Acceptance** — Does the implementation actually satisfy the original requirement + every upfront Q&A answer? Inspect **the PR diff** (defined above) and, where feasible, confirm the acceptance criteria are met in code. Missing/partial requirement coverage ⇒ NO-GO.

2. **Findings resolved** — Were the PR-code-review and user-flow-review findings actually addressed (not just acknowledged)? Spot-check **the PR diff** for each claimed fix. Unresolved material findings ⇒ NO-GO.

3. **Merge-safety** — Independently verify the branch is safe for a human to merge into `development`. **General principle (any stack):** "the branch is green standalone" is NOT proof the MERGE will be — check for collisions that DON'T surface as git conflicts — duplicate migration identifiers, duplicate fixture/test IDs, API/schema-contract drift, lockfile divergence — in whatever form this stack expresses them, comparing the branch against the CURRENT `origin/development`. Re-run the deterministic preflight yourself if the provided output is stale: `git -C <lane> fetch origin && ccam lanes sync-base --check <feature-branch>` (exit 5 = collision ⇒ NO-GO with the printed rename; also read its `DEV_DELTA`/`DEV_OVERLAP` — a large overlapping upstream delta means the locally-QC'd behavior may not match post-merge dev, weigh it).
   - **Stack-specific checks (from the active profile):** read `<lane>/.ccam/profile/review-checks.md` with the Read tool if it exists, and apply every check it lists (e.g. migration-identifier collisions, API/contract regeneration). Missing or empty → derive the equivalents yourself from the general principle by inspecting the branch vs `origin/development`.
   - **Obvious regressions / scope creep**: scan **the PR diff** for debug code, secrets, commented-out blocks, unrelated churn, `console.log`/`print` debugging, TODO/FIXME left in critical paths.

4. **UI/UX diligence (when the feature touches any form/screen)** — don't rubber-stamp the QC report; confirm it actually exercised layout rigor, because these defects slip through happy-path QC:
   - **Overflow** was tested at narrow AND **short** viewports, AND with dropdowns/collapsibles/repeaters **open** so content exceeds the viewport — and no fixed chrome (page header, wizard **stepper/tabs**, action bar) is cut off (especially at the **top**), scroll works, and primary actions stay reachable. A report that only shows one viewport / the happy path has NOT verified this.
   - Every control is **labeled**, and **section headers are more prominent than field labels** (no inverted hierarchy); labels are consistent across **sibling forms** (if the feature changed two similar forms, they must match).
   - Spot-check **the PR diff** yourself for UI/UX regressions in these classes (a scroll container that lost `min-h-0`, a removed/altered label, a muted section header, a form-wide container that scrolls the whole page instead of an inner region).
   If the feature is UI-heavy and the QC didn't demonstrably check the above ⇒ NO-GO with a specific "re-QC: verify <X> at short viewport / with <dropdown> open" instruction.

5. **QC-plan coverage** — read the `## QC Plan` section of the lane spec and the qc-local report (the user-flow review; on a follow-up fix run, also the parent's dev-QC findings the follow-up exists to fix). Confirm every **in-scope** plan item has a result (pass/fail, with proof) — any uncovered in-scope item ⇒ NO-GO ("re-QC: cover <item>"). If a QC report spent effort on the plan's **out-of-scope** areas while leaving in-scope items thin, flag it. Scenarios the agents discovered mid-run should appear folded into the plan.

## Output format (MANDATORY — the skill parses your last line)

Write a short report (acceptance ✓/✗, findings ✓/✗, merge-safety ✓/✗ with one line each), then end with **exactly one** of these as the FINAL line:

- `VERDICT: GO`
- `VERDICT: NO-GO — <comma-separated required fixes>`

Rules:
- Only return `GO` when all three checks pass with high confidence.
- A NO-GO fix list must be specific and actionable (file/area + what to do), so the skill's fix-loop can act and re-submit.
- Never push, merge, commit, or modify files. You are read-only. Your verdict is the deliverable.
```

- [ ] **Step 3: Verify frontmatter parses**

```bash
node -e "
const fs = require('fs');
for (const f of ['qc-local.md', 'senior-gate-reviewer.md']) {
  const text = fs.readFileSync('server/data/agent-templates/ship-feature-lane/' + f, 'utf8');
  const m = /^---\n([\s\S]*?)\n---\n/.exec(text);
  if (!m) throw new Error(f + ': no frontmatter block found');
  console.log(f + ': frontmatter OK, ' + text.length + ' bytes');
}
"
```

Expected: both files print `frontmatter OK` with no error.

- [ ] **Step 4: Confirm no leftover Shipyard-specific references**

```bash
grep -n "HARNESS_ROOT\|harness-lane\|state\.sh\|lane-qa-login\|lane-reset\.sh\|lane-sync-dev\.sh\|profile-cat\.sh\|localhost:300" server/data/agent-templates/ship-feature-lane/*.md
```

Expected: no output (grep finds nothing — every one of these Shipyard-specific tokens has been removed or replaced).

- [ ] **Step 5: Header audit + commit**

```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
```

Expected: passes — these two `.md` files are prose, not source, so the audit should not flag them (confirm against `.claude/skills/ship-feature-lane/SKILL.md`, which also has no header, if the audit script complains).

```bash
git add server/data/agent-templates/ship-feature-lane/qc-local.md server/data/agent-templates/ship-feature-lane/senior-gate-reviewer.md
git commit -m "feat(lanes): add qc-local + senior-gate-reviewer agent templates (E3)"
```

---

### Task 2: `server/lib/lane-agents.js` — the install core

**Files:**
- Create: `server/lib/lane-agents.js`
- Test: `server/__tests__/lane-agents.test.js`

**Interfaces:**
- Consumes: `git(cwd, args) => Promise<{stdout, stderr}>` from `require("./worktree")`.
- Produces: `installAgents(lane) => Promise<{installed: string[]}>` — `lane` is `{cwd, ...}` (only `cwd` is read). Throws an error with `.code = "ENOTGITREPO"` when `<lane.cwd>/.git` doesn't exist.

- [ ] **Step 1: Write the failing tests**

Create `server/__tests__/lane-agents.test.js`:

```js
/**
 * @file Tests for server/lib/lane-agents.js: installing the ship-feature-lane
 * agent templates into a lane's .claude/agents/ and git-excluding them. Uses
 * REAL git fixtures (a plain clone AND a `git worktree add` lane) because the
 * exclude-file location depends on git's own git-dir/git-common-dir split —
 * the same distinction E2's merge-driver setup had to get right.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const { describe, it, before, after } = 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-agents-"));

const laneAgents = require("../lib/lane-agents");

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

after(() => fs.rmSync(ROOT, { recursive: true, force: true }));

describe("installAgents against a plain clone", () => {
  const REPO = path.join(ROOT, "plain-repo");

  before(() => {
    fs.mkdirSync(REPO, { recursive: true });
    g(REPO, "init", "-q", "-b", "main", REPO);
    fs.writeFileSync(path.join(REPO, "README.md"), "hello\n");
    gc(REPO, "add", "-A");
    gc(REPO, "commit", "-qm", "init");
  });

  it("writes both templates and appends the git-exclude line", async () => {
    const result = await laneAgents.installAgents({ cwd: REPO });
    assert.deepEqual(result.installed.sort(), ["qc-local.md", "senior-gate-reviewer.md"]);
    assert.ok(fs.existsSync(path.join(REPO, ".claude", "agents", "qc-local.md")));
    assert.ok(fs.existsSync(path.join(REPO, ".claude", "agents", "senior-gate-reviewer.md")));

    const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8");
    assert.match(exclude, /^\.claude\/agents\/$/m);
  });

  it("is idempotent — a second call does not duplicate the exclude line", async () => {
    await laneAgents.installAgents({ cwd: REPO });
    const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8");
    const matches = exclude.split("\n").filter((line) => line === ".claude/agents/");
    assert.equal(matches.length, 1);
  });

  it("overwrites existing template files on reinstall (not a merge)", async () => {
    const dest = path.join(REPO, ".claude", "agents", "qc-local.md");
    fs.writeFileSync(dest, "stale content from a previous version\n");
    await laneAgents.installAgents({ cwd: REPO });
    const content = fs.readFileSync(dest, "utf8");
    assert.doesNotMatch(content, /stale content/);
  });
});

describe("installAgents against a real git-worktree lane", () => {
  it("writes the exclude line to the SHARED common dir, not the worktree-private one", async () => {
    const wt = require("../lib/worktree");
    const SRC = path.join(ROOT, "wt-src");
    fs.mkdirSync(SRC, { recursive: true });
    g(SRC, "init", "-q", "-b", "main", SRC);
    fs.writeFileSync(path.join(SRC, "README.md"), "hello\n");
    gc(SRC, "add", "-A");
    gc(SRC, "commit", "-qm", "init");

    const wtDir = path.join(ROOT, "wt-lane");
    await wt.addWorktree({ sourceRepo: SRC, dir: wtDir, branch: "feat/agents", base: "main" });

    const result = await laneAgents.installAgents({ cwd: wtDir });
    assert.deepEqual(result.installed.sort(), ["qc-local.md", "senior-gate-reviewer.md"]);
    assert.ok(fs.existsSync(path.join(wtDir, ".claude", "agents", "qc-local.md")));

    // <wtDir>/.git is a FILE for a worktree lane — info/exclude must NOT be
    // written under it. It belongs in the source repo's own .git/info/,
    // shared across every worktree.
    assert.ok(fs.statSync(path.join(wtDir, ".git")).isFile());
    const exclude = fs.readFileSync(path.join(SRC, ".git", "info", "exclude"), "utf8");
    assert.match(exclude, /^\.claude\/agents\/$/m);
  });
});

describe("installAgents against a non-git directory", () => {
  it("throws ENOTGITREPO rather than crashing on a missing .git", async () => {
    const plain = path.join(ROOT, "not-a-repo");
    fs.mkdirSync(plain, { recursive: true });
    await assert.rejects(() => laneAgents.installAgents({ cwd: plain }), { code: "ENOTGITREPO" });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `node --test server/__tests__/lane-agents.test.js`
Expected: FAIL — `require("../lib/lane-agents")` throws `MODULE_NOT_FOUND`.

- [ ] **Step 3: Implement `lane-agents.js`**

Create `server/lib/lane-agents.js`:

```js
/**
 * @file Installs the ship-feature-lane pipeline's agent templates
 * (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a
 * driving session can launch them by subagent_type. Plain file I/O except
 * for one git call — resolving where a worktree lane's shared info/exclude
 * actually lives, the same git-dir/git-common-dir distinction E2's
 * lane-sync.js had to get right for info/attributes.
 * @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 TEMPLATE_DIR = path.join(__dirname, "..", "data", "agent-templates", "ship-feature-lane");
const TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"];
const EXCLUDE_LINE = ".claude/agents/";

/** The dir shared across every worktree of a repo — where info/exclude
 *  lives (same as info/attributes; MERGE_HEAD/HEAD/the index are the only
 *  per-worktree-private state, not this). */
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);
}

/**
 * Copy both agent templates into `<lane.cwd>/.claude/agents/` (overwriting
 * any existing copy — this is a reinstall, not a merge) and idempotently
 * git-exclude that directory in the shared common git dir.
 *
 * @param {{cwd: string}} lane
 * @returns {Promise<{installed: string[]}>}
 */
async function installAgents(lane) {
  if (!fs.existsSync(path.join(lane.cwd, ".git"))) {
    throw Object.assign(
      new Error(`lane has no .git — not a git repository: ${lane.cwd}`),
      { code: "ENOTGITREPO" }
    );
  }

  const dest = path.join(lane.cwd, ".claude", "agents");
  fs.mkdirSync(dest, { recursive: true });
  const installed = [];
  for (const name of TEMPLATE_FILES) {
    fs.copyFileSync(path.join(TEMPLATE_DIR, name), path.join(dest, name));
    installed.push(name);
  }

  const infoDir = path.join(await commonGitDir(lane.cwd), "info");
  fs.mkdirSync(infoDir, { recursive: true });
  const excludePath = path.join(infoDir, "exclude");
  const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
  const lines = existing.split("\n").filter(Boolean);
  if (!lines.includes(EXCLUDE_LINE)) {
    lines.push(EXCLUDE_LINE);
    fs.writeFileSync(excludePath, lines.join("\n") + "\n");
  }

  return { installed };
}

module.exports = { installAgents };
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `node --test server/__tests__/lane-agents.test.js`
Expected: PASS — all 5 tests.

- [ ] **Step 5: Header check + full suite**

```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```

- [ ] **Step 6: Commit**

```bash
git add server/lib/lane-agents.js server/__tests__/lane-agents.test.js
git commit -m "feat(lanes): add lane-agents install core (E3)"
```

---

### Task 3: `POST /api/lanes/:id/agents/install` route

**Files:**
- Modify: `server/routes/lanes.js`

**Interfaces:**
- Consumes: `installAgents(lane) => Promise<{installed: string[]}>` from Task 2 (`require("../lib/lane-agents")`).
- Produces: `POST /api/lanes/:id/agents/install` — `200` with `{installed: string[]}`, `404` for an unknown lane, `400` with `{error: {code: "ENOTGITREPO", message}}` for a lane whose `cwd` isn't a git repo.

- [ ] **Step 1: Add the import**

In `server/routes/lanes.js`, add near the other `lib` requires (after the `lane-sync` import Task 2 of E2 added):

```js
const { installAgents } = require("../lib/lane-agents");
```

- [ ] **Step 2: Add the route**

Insert directly after the `/:id/proof-link` route (`server/routes/lanes.js:325-329`) — same minimal shape, no lock needed (plain file copy, no shared mutable git state the way a merge is):

```js
/**
 * Install the ship-feature-lane pipeline's agent templates (qc-local,
 * senior-gate-reviewer) into this lane's own .claude/agents/. Static file
 * copy — no templating, no credentials to inject (this repo has no
 * seed-account system yet; see the E3 design spec's Decisions table).
 * Never automatic, same as proof-link: a session calls this explicitly.
 */
router.post("/:id/agents/install", sameOriginGuard, async (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  try {
    res.json(await installAgents(lane));
  } catch (err) {
    if (err.code === "ENOTGITREPO") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
  }
});
```

- [ ] **Step 3: Manual smoke check**

```bash
npm run dev &
sleep 3
# Replace 1 with a real lane id.
curl -s -X POST http://localhost:4820/api/lanes/1/agents/install | head -c 200
echo
```

Expected: `{"installed":["qc-local.md","senior-gate-reviewer.md"]}` (or a 400 `ENOTGITREPO` body if lane 1's `cwd` isn't a real git repo — either is a correctly-wired route, not a bug). Stop the dev server afterward.

- [ ] **Step 4: Run the full suite + header check**

```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```

- [ ] **Step 5: Commit**

```bash
git add server/routes/lanes.js
git commit -m "feat(lanes): add POST /:id/agents/install route (E3)"
```

---

### Task 4: `ccam lanes agents install` CLI

**Files:**
- Modify: `bin/ccam.js`

**Interfaces:**
- Consumes: `POST /api/lanes/:id/agents/install` (Task 3); `resolveLaneArg(args)`, `post(path, body, options)` (both already defined in `bin/ccam.js`).
- Produces: `ccam lanes agents install [<id>]`.

- [ ] **Step 1: Add the subcommand function**

In `bin/ccam.js`, add a new function near `cmdLanesProofLink` (`bin/ccam.js:2147`):

```js
/** `ccam lanes agents install [<id>] [--cwd path]` — write the ship-feature-lane
 *  agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/,
 *  git-excluded. Never run automatically; a session calls it explicitly. */
async function cmdLanesAgentsInstall(args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const result = await post(
    `/api/lanes/${resolved.laneId}/agents/install`,
    {},
    { allowError: true }
  );
  if (result.status) {
    console.error(`✖ agents install → ${result.data?.error?.message || result.status}`);
    process.exitCode = 1;
    return;
  }
  console.log(`${c.green("✔")} installed: ${result.installed.join(", ")}`);
}
```

- [ ] **Step 2: Wire the dispatcher**

In `bin/ccam.js`'s `lanes` case (`bin/ccam.js:3072` area, right after the `proof-link` line):

```js
      if (rest[0] === "proof-link") return cmdLanesProofLink(rest.slice(1));
```

Add directly below it:

```js
      if (rest[0] === "agents" && rest[1] === "install") {
        return cmdLanesAgentsInstall(rest.slice(2));
      }
```

- [ ] **Step 3: Add the help-catalog entry**

Right after the `lanes proof-link` catalog entry (`bin/ccam.js:2270-2273` area):

```js
      [
        "lanes agents install",
        "[<id>]",
        "Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
      ],
```

- [ ] **Step 4: Manual smoke test**

```bash
npm run dev &
sleep 3
node bin/ccam.js lanes agents install 1
echo "exit: $?"
```

Expected: `✔ installed: qc-local.md, senior-gate-reviewer.md` and exit code `0` (or a clear `✖` error with exit `1` for a lane whose `cwd` isn't a git repo). 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 agents install CLI (E3)"
```

---

### Task 5: Docs

**Files:**
- Modify: `docs/LANES.md`
- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`

**Interfaces:** none — documentation only.

- [ ] **Step 1: `docs/LANES.md` — add an agents-install subsection**

In `docs/LANES.md`, under `## The ship-feature-lane skill (E1)` (search `grep -n "^## The ship-feature-lane" docs/LANES.md`), insert a new subsection after the "Dev preflight and merge safety: sync-base" subsection E2 added (search `grep -n "^### Dev preflight" docs/LANES.md`) and before "### Pipeline template: ship-feature (16 node stages)":

```markdown
### Installing the QC/gate agents: agents install

Stage 6 (browser QC) and Stage 7 (senior GO/NO-GO gate) run as subagents — `qc-local` and `senior-gate-reviewer` — that must exist in the lane's own `.claude/agents/` before the skill can launch them:

```bash
ccam lanes agents install
```

Writes both agent templates into `<lane>/.claude/agents/` and adds that directory to the lane's local `.git/info/exclude` (never the tracked `.gitignore` — this is a per-clone runtime concern, not an app-repo change). Idempotent and never automatic, the same shape as `ccam lanes proof-link` — a session installs the agents explicitly, once, before a lane's first run through Stage 6/7 (or after a CCAM upgrade ships updated templates — reinstalling overwrites, it doesn't merge).

**Credentials are not embedded.** Unlike Shipyard's original per-lane agent generation, these templates carry no QA account credentials — this repo has no seeded-QA-account system yet. `qc-local` degrades gracefully: if it hits a login page with no credentials block present, it reports `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile` rather than guessing.

**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet — the first two are invoked only by Stages 9/13, which are hardcoded-skipped pending F's integrations; the third isn't referenced anywhere in this skill's text.
```

- [ ] **Step 2: Update the "Current status" stages list**

In the same `## The ship-feature-lane skill (E1)` section, find the "Stages agent-gated" bullet list (search `grep -n "Stages agent-gated" docs/LANES.md`):

```
**Stages agent-gated** (will block until the agents-port task lands):
- **6 (Browser QC)**: Requires the `qc-local` agent to drive local Playwright QC against the booted lane stack.
- **7 (Senior gate)**: Requires the `senior-gate-reviewer` agent to authorize feature merge.

Until these agents are ported, a lane reaching Stage 6 or 7 will be marked `blocked` with a note. See [the skill text](../.claude/skills/ship-feature-lane/SKILL.md) for the full pipeline definition.
```

Replace with:

```
**Stages 6 and 7 are now unblocked** — run `ccam lanes agents install` once per lane (see "Installing the QC/gate agents" below) before a lane's first run through Stage 6. A lane that reaches Stage 6/7 without having installed the agents will fail to find the `qc-local`/`senior-gate-reviewer` subagent type; install and re-run.

See [the skill text](../.claude/skills/ship-feature-lane/SKILL.md) for the full pipeline definition.
```

- [ ] **Step 3: Roadmap progress line**

In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, find the `## E` section's `**Progress:**` line:

```
**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.
```

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`. `qc-local` + `senior-gate-reviewer` agents (E3) done 2026-08-05 — see `docs/superpowers/specs/2026-08-05-agents-port-design.md`. `ticketer`/`dev-qc`/`pr-reviewer` and F's integrations remain.
```

- [ ] **Step 4: Verify and commit**

```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```

```bash
git add docs/LANES.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
git commit -m "docs(lanes): document ccam lanes agents install (E3)"
```
