From 62789ffae7554f8318ced2a10e42d9c43692aedb Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:04:28 +0700 Subject: [PATCH 01/10] feat(server): add gh execFile wrapper for CI status --- server/__tests__/gh.test.js | 65 +++++++++++++++++++++++++++++++++++++ server/lib/gh.js | 36 ++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 server/__tests__/gh.test.js create mode 100644 server/lib/gh.js diff --git a/server/__tests__/gh.test.js b/server/__tests__/gh.test.js new file mode 100644 index 00000000..8e5691dd --- /dev/null +++ b/server/__tests__/gh.test.js @@ -0,0 +1,65 @@ +/** + * @file Tests for server/lib/gh.js's execFile wrapper against real fake `gh` + * scripts on PATH — the same fixture style server/__tests__/lane-lifecycle.test.js + * uses for a fake `claude` binary, since this codebase's server tests use real + * subprocesses instead of mocking child_process. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gh-")); +const { gh } = require("../lib/gh"); + +/** Puts a fake `gh` script on PATH for the duration of one test. */ +function stubGh(scriptBody) { + const bin = path.join(ROOT, `bin-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(bin, { recursive: true }); + const bin_gh = path.join(bin, "gh"); + fs.writeFileSync(bin_gh, `#!/usr/bin/env bash\n${scriptBody}\n`); + fs.chmodSync(bin_gh, 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + return () => { + process.env.PATH = originalPath; + }; +} + +after(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +describe("gh()", () => { + it("returns stdout on success", async () => { + const restore = stubGh('echo "$@" >&2\necho \'{"ok":true}\''); + try { + const result = await gh(ROOT, ["run", "list"]); + assert.match(result.stdout, /"ok":true/); + } finally { + restore(); + } + }); + + it("throws with .gh details on a non-zero exit", async () => { + const restore = stubGh('echo "boom" >&2\nexit 7'); + try { + await assert.rejects( + () => gh(ROOT, ["run", "list"]), + (err) => err.gh.code === 7 && /boom/.test(err.gh.stderr) + ); + } finally { + restore(); + } + }); + + it("throws ENOGH when gh is not on PATH", async () => { + const originalPath = process.env.PATH; + process.env.PATH = ROOT; // a directory with no gh binary in it + try { + await assert.rejects(() => gh(ROOT, ["run", "list"]), { code: "ENOGH" }); + } finally { + process.env.PATH = originalPath; + } + }); +}); diff --git a/server/lib/gh.js b/server/lib/gh.js new file mode 100644 index 00000000..e662a969 --- /dev/null +++ b/server/lib/gh.js @@ -0,0 +1,36 @@ +/** + * @file Thin execFile wrapper around the GitHub CLI (`gh`), used by + * server/lib/ci-status.js to read Actions run state. Mirrors worktree.js's + * git() helper's shape (cwd-scoped, throws with structured .gh details on + * failure) but has no env-scrubbing to do — gh doesn't read GIT_* variables. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); + +/** + * Run `gh ` in `cwd`. Resolves `{ stdout, stderr }` on a zero exit. + * On failure, throws an Error with `.gh = { args, code, stderr }` — `.code` + * is `"ENOGH"` when the binary itself is missing, otherwise the process's + * exit code. + * + * @param {string} cwd + * @param {string[]} args + * @returns {Promise<{stdout: string, stderr: string}>} + */ +async function gh(cwd, args) { + try { + const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; + } catch (err) { + const error = new Error(`gh ${args[0]} failed`); + error.code = err.code === "ENOENT" ? "ENOGH" : err.code; + error.gh = { args, code: err.code, stderr: err.stderr || "" }; + throw error; + } +} + +module.exports = { gh }; From 15c69334b532f3a5c60aea5f904782e324746252 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:17:07 +0700 Subject: [PATCH 02/10] test(lanes): scrub git hook env vars from lane-agents fixture, matching worktree.js's own git() helper --- server/__tests__/lane-agents.test.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/server/__tests__/lane-agents.test.js b/server/__tests__/lane-agents.test.js index 67f90caa..06cb7dbc 100644 --- a/server/__tests__/lane-agents.test.js +++ b/server/__tests__/lane-agents.test.js @@ -22,13 +22,29 @@ const { installAgents } = require("../lib/lane-agents"); after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); +// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this +// very suite) sets in its own environment — those leak to every child +// process and override an explicit `cwd`, so without this `git init` here +// operates on the outer repo's .git instead of this fixture's throwaway one. +// Same scrub `lanes-cli.test.js` and `worktree.js`'s own git() helper use. +const GIT_ENV = { ...process.env }; +delete GIT_ENV.GIT_DIR; +delete GIT_ENV.GIT_WORK_TREE; +delete GIT_ENV.GIT_INDEX_FILE; +delete GIT_ENV.GIT_COMMON_DIR; +delete GIT_ENV.GIT_OBJECT_DIRECTORY; +delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES; +delete GIT_ENV.GIT_PREFIX; +delete GIT_ENV.GIT_NAMESPACE; +delete GIT_ENV.GIT_CONFIG_PARAMETERS; + let laneSeq = 0; /** Create a real git repo dir (used as a lane's cwd). */ function makeRepoDir() { const cwd = path.join(SUITE_ROOT, `repo-${++laneSeq}`); fs.mkdirSync(cwd, { recursive: true }); - execFileSync("git", ["init", "--initial-branch=main"], { cwd, stdio: "pipe" }); + execFileSync("git", ["init", "--initial-branch=main"], { cwd, stdio: "pipe", env: GIT_ENV }); return cwd; } From bd43f4ea73ca766ee82e9e00e6b482c99a0c0cec Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:18:44 +0700 Subject: [PATCH 03/10] docs(claude): prefer ccam:ship-feature over raw superpowers skills for lane stage tracking --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index b362f5c4..9b0d35b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,3 +75,4 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - Use project skills from `skills/` for repeatable workflows (they ship with the `ccam` plugin: `/ccam:`, or the bare `/` alias). - Use `agents/` subagents for focused review or investigation passes (they ship with the plugin: `ccam:backend-reviewer`, etc.). - **Declare lane stage even in plain chat, not just inside skills.** `ccam stage` (see `docs/LANES.md` § Reporting a stage) is a reporting command, not a skill-only ritual — any Claude session working inside an adopted lane's cwd should call it on real stage transitions (starting to plan, starting to implement, running tests, opening the PR, etc.), whether or not a skill is driving. Tool-event detection (`server/lib/stage-detect.js`) only ever paints the amber "detected" badge, never the blue `current` ring — a lane worked entirely through plain chat with no `ccam stage` calls will show a stale `current` stage no matter how much real work happens. Skip it only when `ccam stage` reports no lane owns the cwd (not adopted). +- **Prefer `ccam:ship-feature`(`-lane`) over invoking `superpowers:brainstorming`/`writing-plans`/`executing-plans`/`subagent-driven-development`/`test-driven-development` directly for implementation work inside a lane's cwd.** Those Superpowers skills know nothing about CCAM lanes and never call `ccam stage` themselves — going straight to them (skipping the CCAM wrapper) is exactly how a lane's `current` stage goes stale mid-implementation while `ccam stage` is never called for real transitions. `ccam:ship-feature`/`ship-feature-lane` drive the same underlying Superpowers skills phase-by-phase and declare `ccam stage` at every boundary — pick one of them first for any multi-phase feature/bugfix inside a lane; reach for a bare Superpowers skill directly only for work with no lane cwd to report against (e.g. a scratch repo, or explicitly told to skip the CCAM wrapper). From 8c76ce708e48a9eacc3f17be4e6cc99496390c44 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:21:25 +0700 Subject: [PATCH 04/10] feat(server): derive CI state from a single gh run snapshot --- server/__tests__/ci-status.test.js | 125 +++++++++++++++++++++++++++++ server/lib/ci-status.js | 61 ++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 server/__tests__/ci-status.test.js create mode 100644 server/lib/ci-status.js diff --git a/server/__tests__/ci-status.test.js b/server/__tests__/ci-status.test.js new file mode 100644 index 00000000..9f447cc2 --- /dev/null +++ b/server/__tests__/ci-status.test.js @@ -0,0 +1,125 @@ +/** + * @file Tests for server/lib/ci-status.js against a fake `gh` binary that + * returns canned `run list`/`run view` JSON for each scenario this module + * has to classify: no run yet, an in-progress run, a green run, and a red + * run — plus branch-vs-sha ref detection. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-")); +const { ciStatus } = require("../lib/ci-status"); + +/** + * Fake `gh` that branches on the subcommand: `run list` returns `listJson`, + * `run view` returns `viewJson`. Also records every invocation's args (one + * line per call, space-joined) to `$CALLS_LOG` so a test can assert which + * flag (--branch vs --commit) was used. + */ +function stubGh(listJson, viewJson) { + const bin = path.join(ROOT, `bin-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(bin, { recursive: true }); + const callsLog = path.join(bin, "calls.log"); + fs.writeFileSync(callsLog, ""); + const script = `#!/usr/bin/env bash +echo "$@" >> "${callsLog}" +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + cat <<'EOF' +${JSON.stringify(listJson)} +EOF + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + cat <<'EOF' +${JSON.stringify(viewJson)} +EOF + exit 0 +fi +echo "unexpected gh invocation: $@" >&2 +exit 1 +`; + fs.writeFileSync(path.join(bin, "gh"), script); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + return { + calls: () => fs.readFileSync(callsLog, "utf8").trim().split("\n").filter(Boolean), + restore: () => { + process.env.PATH = originalPath; + }, + }; +} + +after(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +describe("ciStatus()", () => { + it("reports pending when no run exists yet for the ref", async () => { + const stub = stubGh([], {}); + try { + const result = await ciStatus(ROOT, "development"); + assert.deepEqual(result, { state: "pending", ref: "development", checks: [] }); + } finally { + stub.restore(); + } + }); + + it("reports pending when the latest run is still in progress", async () => { + const stub = stubGh([{ databaseId: 1, status: "in_progress", conclusion: null }], { + jobs: [{ name: "server-tests", status: "in_progress", conclusion: null, url: null }], + }); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "pending"); + } finally { + stub.restore(); + } + }); + + it("reports success when the latest run completed clean", async () => { + const stub = stubGh([{ databaseId: 2, status: "completed", conclusion: "success" }], { + jobs: [ + { name: "server-tests", status: "completed", conclusion: "success", url: "https://x/1" }, + { name: "client-tests", status: "completed", conclusion: "skipped", url: "https://x/2" }, + ], + }); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "success"); + assert.equal(result.checks.length, 2); + } finally { + stub.restore(); + } + }); + + it("reports failure when any job failed", async () => { + const stub = stubGh([{ databaseId: 3, status: "completed", conclusion: "failure" }], { + jobs: [ + { name: "server-tests", status: "completed", conclusion: "failure", url: "https://x/3" }, + ], + }); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "failure"); + assert.equal(result.checks[0].conclusion, "failure"); + } finally { + stub.restore(); + } + }); + + it("uses --commit for a sha-shaped ref and --branch otherwise", async () => { + const stub = stubGh([], {}); + try { + await ciStatus(ROOT, "a1b2c3d"); + await ciStatus(ROOT, "feat/some-slug"); + const calls = stub.calls().filter((line) => line.startsWith("run list")); + assert.match(calls[0], /--commit a1b2c3d/); + assert.match(calls[1], /--branch feat\/some-slug/); + } finally { + stub.restore(); + } + }); +}); diff --git a/server/lib/ci-status.js b/server/lib/ci-status.js new file mode 100644 index 00000000..f2c8250d --- /dev/null +++ b/server/lib/ci-status.js @@ -0,0 +1,61 @@ +/** + * @file Turns a GitHub Actions run for one ref into a single derived fact — + * {state, checks} — for ship-feature-lane's Stage 13 CI-wait. Deliberately a + * snapshot, not a poll loop: the driving skill owns the wait/retry decision + * (docs/LANES.md "Orchestration: what CCAM does NOT do"), this module only + * reports what gh already knows. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { gh } = require("./gh"); + +/** A 7-40 char hex string reads as a commit sha; anything else is a branch name. */ +const SHA_RE = /^[0-9a-f]{7,40}$/i; + +/** + * @param {string} cwd - The lane's repo directory (gh resolves the GitHub + * repo from this directory's git remote). + * @param {string} ref - A branch name or commit sha. + * @returns {Promise<{state: "success"|"failure"|"pending", ref: string, checks: Array}>} + */ +async function ciStatus(cwd, ref) { + const refFlag = SHA_RE.test(ref) ? "--commit" : "--branch"; + const { stdout: listOut } = await gh(cwd, [ + "run", + "list", + refFlag, + ref, + "--json", + "databaseId,status,conclusion", + "--limit", + "1", + ]); + const runs = JSON.parse(listOut); + if (runs.length === 0) { + return { state: "pending", ref, checks: [] }; + } + const run = runs[0]; + if (run.status !== "completed") { + return { state: "pending", ref, checks: [] }; + } + + const { stdout: viewOut } = await gh(cwd, [ + "run", + "view", + String(run.databaseId), + "--json", + "jobs", + ]); + const { jobs } = JSON.parse(viewOut); + const checks = jobs.map((job) => ({ + name: job.name, + conclusion: job.conclusion || job.status, + url: job.url || null, + })); + + const FAILING = new Set(["failure", "cancelled", "timed_out", "action_required"]); + const state = checks.some((c) => FAILING.has(c.conclusion)) ? "failure" : "success"; + return { state, ref, checks }; +} + +module.exports = { ciStatus }; From db9b1fcafa30defbe0ee35b2eeb095f4506db44e Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:26:33 +0700 Subject: [PATCH 05/10] feat(api): add GET /:id/ci/status --- server/__tests__/lanes-api.test.js | 58 ++++++++++++++++++++++++++++++ server/routes/lanes.js | 22 ++++++++++++ 2 files changed, 80 insertions(+) diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index db346405..b3cc58d8 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -9,6 +9,7 @@ const assert = require("node:assert/strict"); const path = require("path"); const os = require("os"); const http = require("http"); +const fs = require("fs"); const { WebSocket } = require("ws"); const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`); @@ -928,3 +929,60 @@ describe("detection attributes work to the lane the work touched", () => { await request("DELETE", `/api/lanes/${session.body.lane.id}`); }); }); + +describe("GET /:id/ci/status", () => { + let laneId; + let laneCwd; + let restorePath; + + before(async () => { + laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-route-")); + const r = await request("POST", "/api/lanes", { title: "CI status lane", cwd: laneCwd }); + laneId = r.body.lane.id; + + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-bin-")); + fs.writeFileSync( + path.join(bin, "gh"), + `#!/usr/bin/env bash +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]' + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}' + exit 0 +fi +exit 1 +` + ); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + restorePath = () => { + process.env.PATH = originalPath; + }; + }); + + after(() => { + if (restorePath) restorePath(); + fs.rmSync(laneCwd, { recursive: true, force: true }); + }); + + it("404s for an unknown lane", async () => { + const r = await request("GET", "/api/lanes/999999/ci/status?ref=development"); + assert.equal(r.status, 404); + }); + + it("400s when ref is missing", async () => { + const r = await request("GET", `/api/lanes/${laneId}/ci/status`); + assert.equal(r.status, 400); + assert.equal(r.body.error.code, "EBADREF"); + }); + + it("returns the derived state for a real ref", async () => { + const r = await request("GET", `/api/lanes/${laneId}/ci/status?ref=development`); + assert.equal(r.status, 200); + assert.equal(r.body.state, "success"); + assert.equal(r.body.checks[0].name, "tests"); + }); +}); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 0ef1a888..7f32ba17 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -50,6 +50,7 @@ const { } = require("../lib/lane-runtime"); const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc"); const { detectNode, scaffoldProfile } = require("../lib/lane-detect"); +const { ciStatus } = require("../lib/ci-status"); const router = Router(); const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50; @@ -520,6 +521,27 @@ router.get("/:id/git", async (req, res) => { } }); +/** + * A single GitHub Actions status snapshot for one ref (branch or commit sha) + * in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns + * the wait/retry decision (docs/LANES.md "Orchestration: what CCAM does NOT + * do"); this route only reports what `gh` currently knows. Read-only, no + * same-origin guard, same reasoning as GET /:id/git. + */ +router.get("/:id/ci/status", async (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + const ref = typeof req.query.ref === "string" ? req.query.ref.trim() : ""; + if (!ref) { + return res.status(400).json({ error: { code: "EBADREF", message: "ref is required" } }); + } + try { + res.json(await ciStatus(lane.cwd, ref)); + } catch (err) { + sendRuntimeError(res, err); + } +}); + /** * Create a dashboard-managed worktree lane. Git work happens after the 202 * response because provisioning a large repository can take seconds. From a2fcd9bafc4e7a7283855c743a0d966e75c92126 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:33:26 +0700 Subject: [PATCH 06/10] feat(cli): add ccam lanes ci status --- bin/ccam.js | 40 +++++++++++++++ server/__tests__/lanes-cli.test.js | 78 +++++++++++++++++++++++++++++- server/lib/gh.js | 6 ++- 3 files changed, 121 insertions(+), 3 deletions(-) diff --git a/bin/ccam.js b/bin/ccam.js index 189647c1..247f9098 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -2263,6 +2263,38 @@ async function cmdLanesIntegration(args) { process.exitCode = enabled ? 0 : 1; } +/** `ccam lanes ci status []` — one-shot GitHub + * Actions status snapshot for a ref, via GET /:id/ci/status. Never polls; + * the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code + * mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */ +async function cmdLanesCi(args) { + if (args[0] !== "status") { + console.error("usage: ccam lanes ci status []"); + process.exitCode = 1; + return; + } + const ref = args[1]; + if (!ref) { + console.error("usage: ccam lanes ci status []"); + process.exitCode = 1; + return; + } + const resolved = await resolveLaneArg(args.slice(2)); + if (!resolved) return; + const result = await get( + `/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`, + undefined, + { allowError: true } + ); + if (result.status) { + console.error(`✖ ci status → ${result.data?.error?.message || result.status}`); + process.exitCode = 1; + return; + } + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.state === "success" ? 0 : result.state === "pending" ? 3 : 1; +} + /** * `ccam lanes pipeline [] []` — show or switch which pipeline * template a lane renders against. `ccam lanes add --pipeline` could only set @@ -2552,6 +2584,11 @@ const COMMAND_GROUPS = [ " []", "Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1", ], + [ + "lanes ci status", + " []", + "One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending", + ], [ "lanes gc", "[--dry-run]", @@ -3389,6 +3426,9 @@ async function runCommand(argv) { if (rest[0] === "integration") { return cmdLanesIntegration(rest.slice(1)); } + if (rest[0] === "ci") { + return cmdLanesCi(rest.slice(1)); + } if (rest[0] === "pipeline") { return cmdLanesPipeline(rest.slice(1)); } diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index f3fa85b1..bb592a81 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -126,11 +126,11 @@ function get(urlPath) { // spawnSync would stall the event loop and the CLI child's request to // 127.0.0.1 would never be served — a deadlock that looks like a network // sandbox blocking loopback. -function cli(args, cwd) { +function cli(args, cwd, extraEnv = {}) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [CLI, ...args], { cwd, - env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port) }, + env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port), ...extraEnv }, }); let stdout = ""; let stderr = ""; @@ -542,3 +542,77 @@ describe("ccam lanes — inferred stage detection", () => { } }); }); + +describe("ccam lanes ci status", () => { + let restorePath; + + before(() => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-cli-bin-")); + fs.writeFileSync( + path.join(bin, "gh"), + `#!/usr/bin/env bash +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + if [ "$CI_STATUS_FIXTURE" = "failure" ]; then + echo '[{"databaseId":1,"status":"completed","conclusion":"failure"}]' + elif [ "$CI_STATUS_FIXTURE" = "pending" ]; then + echo '[]' + else + echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]' + fi + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + if [ "$CI_STATUS_FIXTURE" = "failure" ]; then + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"failure","url":"https://x/1"}]}' + else + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}' + fi + exit 0 +fi +exit 1 +` + ); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + restorePath = () => { + process.env.PATH = originalPath; + }; + }); + + after(() => restorePath && restorePath()); + + it("exits 0 and prints success state", async () => { + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR); + assert.equal(r.status, 0); + assert.match(r.stdout, /"state": "success"/); + }); + + it("exits 1 on a failed run", async () => { + const orig = process.env.CI_STATUS_FIXTURE; + try { + process.env.CI_STATUS_FIXTURE = "failure"; + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, { + CI_STATUS_FIXTURE: "failure", + }); + assert.equal(r.status, 1); + assert.match(r.stdout, /"state": "failure"/); + } finally { + process.env.CI_STATUS_FIXTURE = orig; + } + }); + + it("exits 3 when no run exists yet", async () => { + const orig = process.env.CI_STATUS_FIXTURE; + try { + process.env.CI_STATUS_FIXTURE = "pending"; + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, { + CI_STATUS_FIXTURE: "pending", + }); + assert.equal(r.status, 3); + assert.match(r.stdout, /"state": "pending"/); + } finally { + process.env.CI_STATUS_FIXTURE = orig; + } + }); +}); diff --git a/server/lib/gh.js b/server/lib/gh.js index e662a969..a50cf890 100644 --- a/server/lib/gh.js +++ b/server/lib/gh.js @@ -23,7 +23,11 @@ const execFileAsync = promisify(execFile); */ async function gh(cwd, args) { try { - const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 }); + const result = await execFileAsync("gh", args, { + cwd, + env: process.env, + maxBuffer: 8 * 1024 * 1024, + }); return { stdout: result.stdout, stderr: result.stderr }; } catch (err) { const error = new Error(`gh ${args[0]} failed`); From 0343e706a7aefaf8e40ac83c4f9325d212339c2f Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:38:09 +0700 Subject: [PATCH 07/10] fix(server): revert unnecessary gh.js env change from Task 4 --- server/lib/gh.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/lib/gh.js b/server/lib/gh.js index a50cf890..e662a969 100644 --- a/server/lib/gh.js +++ b/server/lib/gh.js @@ -23,11 +23,7 @@ const execFileAsync = promisify(execFile); */ async function gh(cwd, args) { try { - const result = await execFileAsync("gh", args, { - cwd, - env: process.env, - maxBuffer: 8 * 1024 * 1024, - }); + const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 }); return { stdout: result.stdout, stderr: result.stderr }; } catch (err) { const error = new Error(`gh ${args[0]} failed`); From a9590f3cfe2d7964b06d288f988423e0d5fc8fc8 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:44:37 +0700 Subject: [PATCH 08/10] docs(lanes): wire Stage 13 CI-wait to ccam lanes ci status --- docs/LANES.md | 18 +++++++++++++++--- skills/ship-feature-lane/SKILL.md | 21 +++++++++++++++++---- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/LANES.md b/docs/LANES.md index 717e6d45..a9b1bff5 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -1106,7 +1106,9 @@ The skill frontloads all clarifying questions once, then runs unattended. The on **Stages currently hardcoded skipped** (pending F's integrations): - **9 (Ticket)**: Ticket-filing integration not yet built — this stage is skipped entirely. -- **13 (Post-merge verification)**: Dev-CI-wait and dev-QC integrations are off — the stage transitions immediately to `done` with a note explaining the gap. + +**Partially wired:** +- **13 (Post-merge verification)**: CI-wait half now runs for real via `ccam lanes ci status`. The dev-QC half (QCing the deployed site) is still unavailable — Stage 13 always ends noting that gap even when CI-wait passes clean. **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. @@ -1165,7 +1167,7 @@ Writes both agent definitions (from the repo's own `agents/`, the same files the **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. +**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet — `ticketer` is invoked by Stage 9 (skipped), and `dev-qc` by Stage 13's dev-QC half (unavailable); `pr-reviewer` isn't referenced anywhere in this skill's text. ### Syncing MCP servers: mcp sync @@ -1191,7 +1193,17 @@ A profile can declare `.ccam/profile/integrations.env` with `TRACKER_ENABLED`, ` ccam lanes integration tracker # exit 0 = on, 1 = off ``` -This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agents that would act on an enabled toggle (`ticketer`, `dev-qc`) aren't built yet; `ci_wait`'s consumer (`ccam ci`) isn't either. `ship-feature-lane`'s Stage 9/13/10 stay skipped/fallback regardless of what the toggle reads, until those land. +This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `ccam lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. + +### Post-merge CI status: ccam lanes ci status + +Check GitHub Actions status for a commit SHA or branch ref — used by Stage 13's CI-wait loop: + +```bash +ccam lanes ci status [] +``` + +Exit codes: `0` = all checks passed, `1` = at least one check failed, `3` = checks pending or in progress. The `` parameter is optional and filters results to a specific lane if provided; if omitted, it's not needed. ### Pipeline template: ship-feature (16 node stages) diff --git a/skills/ship-feature-lane/SKILL.md b/skills/ship-feature-lane/SKILL.md index ad9c06d4..b6ad6876 100644 --- a/skills/ship-feature-lane/SKILL.md +++ b/skills/ship-feature-lane/SKILL.md @@ -1,12 +1,20 @@ --- name: ship-feature-lane -description: "Autonomous end-to-end feature pipeline for ONE CCAM lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane `. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → independent unit-test agent → pre-push CI gates + dev preflight → e2e on the feature branch (agent-run) → code review (fresh-context agent) → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `ccam stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a CCAM lane. NOTE: ticket-filing and post-merge dev-site QC are currently OFF (F's integrations aren't built yet) — this pipeline stops at Stage 14 once dev CI/dev-QC support lands." +description: "Autonomous end-to-end feature pipeline for ONE CCAM lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane `. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → independent unit-test agent → pre-push CI gates + dev preflight → e2e on the feature branch (agent-run) → code review (fresh-context agent) → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `ccam stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a CCAM lane. NOTE: ticket-filing (Stage 9) and post-merge dev-site QC (Stage 13's second half) are currently OFF (F's integrations aren't built yet) — Stage 13's CI-wait half now runs via ccam lanes ci status." --- # Ship Feature Lane (CCAM lane pipeline) You are running the autonomous feature pipeline for **one CCAM lane**. The human's only interactive touchpoints are **Stage 0 (frontloaded Q&A)** and **merging the PR on GitHub**; everything else runs to completion or to a `blocked` escalation, reporting progress through `ccam stage` (which the dashboard renders). +## Current status + +**Stages currently hardcoded skipped** (pending F's integrations): +- **9 (Ticket)**: Ticket-filing integration not yet built — this stage is skipped entirely. + +**Partially wired:** +- **13 (Post-merge verification)**: CI-wait half now runs for real via `ccam lanes ci status`. The dev-QC half (QCing the deployed site) is still unavailable — Stage 13 always ends noting that gap even when CI-wait passes clean. + ## Setup — do this first, every run ```bash @@ -192,9 +200,14 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac - **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`. - Nothing new → background another `sleep 300` (`run_in_background: true`) and end the turn; you'll be re-invoked for the next poll. A PR can sit for days — that's fine. -### 13 — Post-merge verification *(currently SKIPPED — CI-wait and dev-QC integrations are off)* -- `ccam stage merged --status running --note "PR merged — post-merge verification unavailable (F not built)"` -- Both dev-CI-wait and dev-QC are hardcoded off (see Setup). Go straight to Stage 14. When F ships these integrations, this stage gets its real implementation (mirroring Shipyard's: dev CI watch, then a `dev-qc` agent QCing the deployed site, looping to a Stage 15 follow-up-fix pattern on any issue found). +### 13 — Post-merge verification *(CI-wait works; dev-QC still unavailable)* +- `ccam stage merged --status running --note "watching development CI post-merge"` +- Get the merge commit: `gh pr view --json mergeCommit -q .mergeCommit.oid`. +- Loop every ~5 minutes, paced by a **backgrounded** wait (same shape as Stage 12): `sleep 300` with `run_in_background: true`. Each iteration, bump the heartbeat and check: + - `ccam lanes ci status ` — exit `3` (pending) → keep waiting, background another `sleep 300`, end the turn. + - Exit `1` (failure) → triage exactly like Stage 10 (read the failing job via `gh run view ` — the run id is embedded in each failing check's `url` in the command's JSON output; distinguish real failure from shared-runner flake the same way). A real failure on `development` post-merge is a regression the fix-loop doesn't reach (the PR is already merged) — `ccam stage merged --status blocked --note " red on development after merge — human decision"` and STOP; this is not something the pipeline auto-fixes on a shared branch. + - Exit `0` (success) → CI-wait passed. Dev-QC (QCing the deployed site) is a separate, not-yet-built integration — `ccam stage merged --status passed --note "CI green on development; dev-QC unavailable (separate task)"` and proceed to Stage 14. +- If `ccam lanes integration ci_wait` reads off for this profile, skip the loop entirely and go straight to Stage 14 with `--note "ci_wait integration off for this profile"` — the toggle means the profile doesn't want this check, not that the command is unavailable. ### 14 — Done - Post the final report: PR merged, that post-merge verification is unavailable pending F. From 90a07debb5770a599b6a686c961f0d6c5fc6e883 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:49:18 +0700 Subject: [PATCH 09/10] docs(lanes): fix Stage 13/14 consistency gaps found in review --- docs/LANES.md | 2 +- skills/ship-feature-lane/SKILL.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/LANES.md b/docs/LANES.md index a9b1bff5..7220e039 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -1097,7 +1097,7 @@ Copies `skills/ship-feature-lane/` into `~/.claude/skills/ship-feature-lane/` (o The skill frontloads all clarifying questions once, then runs unattended. The only interactive touchpoints are **Stage 0 (Q&A intake)** and **merging the PR on GitHub**; everything else runs to completion or escalates with a `blocked` status visible on the dashboard. Progress is declared via `ccam stage` commands at every step. -### Current status: stages 0–8, 10–12, 14 work; stages 9, 13 deferred; stages 6–7 agent-gated +### Current status: stages 0–8, 10–12, 14 work; stage 9 deferred, stage 13 partially wired (CI-wait works, dev-QC doesn't); stages 6–7 agent-gated **Stages implemented now:** - **0-8**: Intake & frontloaded Q&A → plan → implementation → independent unit tests (1b) → gates+preflight → e2e on feature branch → code review → QC plan → QC → senior gate → publish PR diff --git a/skills/ship-feature-lane/SKILL.md b/skills/ship-feature-lane/SKILL.md index b6ad6876..2c027673 100644 --- a/skills/ship-feature-lane/SKILL.md +++ b/skills/ship-feature-lane/SKILL.md @@ -210,8 +210,8 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac - If `ccam lanes integration ci_wait` reads off for this profile, skip the loop entirely and go straight to Stage 14 with `--note "ci_wait integration off for this profile"` — the toggle means the profile doesn't want this check, not that the command is unavailable. ### 14 — Done -- Post the final report: PR merged, that post-merge verification is unavailable pending F. -- `ccam stage done --status passed --note "merged; post-merge verification unavailable (F not built)"` → STOP (leave the lane for the human to clear from the dashboard whenever). +- Post the final report: PR merged, that dev-QC (deployed-site QC) is unavailable pending F; CI-wait already ran in Stage 13. +- `ccam stage done --status passed --note "merged; dev-QC unavailable (F not built)"` → STOP (leave the lane for the human to clear from the dashboard whenever). ## Escalation Whenever you STOP early (an ambiguous merge conflict, an unexpected/unrecoverable failure, a missing agent this skill depends on), set `ccam stage --status blocked --note ""` — the dashboard surfaces it. Then summarize for the human and wait. From 928512b74977e778603dabccedaa9deba1cb6254 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:59:29 +0700 Subject: [PATCH 10/10] test(lanes): clean up fake-gh bin tmpdirs after ci/status tests --- server/__tests__/lanes-api.test.js | 4 +++- server/__tests__/lanes-cli.test.js | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index b3cc58d8..740c1301 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -934,13 +934,14 @@ describe("GET /:id/ci/status", () => { let laneId; let laneCwd; let restorePath; + let bin; before(async () => { laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-route-")); const r = await request("POST", "/api/lanes", { title: "CI status lane", cwd: laneCwd }); laneId = r.body.lane.id; - const bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-bin-")); + bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-bin-")); fs.writeFileSync( path.join(bin, "gh"), `#!/usr/bin/env bash @@ -966,6 +967,7 @@ exit 1 after(() => { if (restorePath) restorePath(); fs.rmSync(laneCwd, { recursive: true, force: true }); + fs.rmSync(bin, { recursive: true, force: true }); }); it("404s for an unknown lane", async () => { diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index bb592a81..fe9f8c46 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -545,9 +545,10 @@ describe("ccam lanes — inferred stage detection", () => { describe("ccam lanes ci status", () => { let restorePath; + let bin; before(() => { - const bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-cli-bin-")); + bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-cli-bin-")); fs.writeFileSync( path.join(bin, "gh"), `#!/usr/bin/env bash @@ -580,7 +581,10 @@ exit 1 }; }); - after(() => restorePath && restorePath()); + after(() => { + if (restorePath) restorePath(); + fs.rmSync(bin, { recursive: true, force: true }); + }); it("exits 0 and prints success state", async () => { const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR);