Clean break, no compatibility alias. Renames the product name, the CLI binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` -> `AGENTHUB_`), the Prometheus metric namespace (`ccam_*` -> `agenthub_*`), the tmux session prefix (`ccam-lane-<id>` -> `agenthub-lane-<id>`), the per-repo runtime profile directory (`.ccam/profile` -> `.agenthub/profile`), the lanes state root (`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` -> `/agenthub:*`). Because there is no alias, an existing install has to be updated by hand: rename its `.ccam/profile` directory and any `CCAM_*` variables, and reinstall the plugin under its new name. Two other name families are deliberately left alone here, since they are not just strings: the `Claude-Code-Monitor` repository/marketplace name, and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard` runtime directory, which holds the live database and needs a migration rather than a rename. Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
30 KiB
agenthub ci status 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: Add agenthub lanes ci status <branch-or-sha> — a one-shot GitHub Actions status snapshot backed by a new server route — and wire it into ship-feature-lane Stage 13 so the CI-wait half of post-merge verification stops being hardcoded-skipped.
Architecture: A server/lib/gh.js execFile wrapper (mirrors worktree.js's git()), a server/lib/ci-status.js pure-logic module that turns gh run list/gh run view output into {state, ref, checks}, a thin GET /:id/ci/status route (mirrors GET /:id/git's try/available shape), and a thin CLI command (mirrors cmdLanesIntegration) that maps the response onto exit codes 0/1/3. No polling, no retry, no flake classification anywhere in this stack — the skill keeps that judgment, per the project's no-orchestration rule.
Tech Stack: Node.js (node:child_process execFile), Express, node:test + node:assert/strict, GitHub CLI (gh) as the actual data source.
Global Constraints
- Spec:
docs/superpowers/specs/2026-08-25-agenthub-ci-status-design.md— read it before starting; every task below implements one section of it. - Every new/edited source file needs the repo's copyright header (file overview +
@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>) — seeskills/file-headers/. - No flake auto-classification or auto-rerun logic anywhere in this stack —
stateis a derived fact, nothing more. - Route never writes lane
stage/status/notes(same boundary every other read-only lane route keeps). npm run test:servermust pass before this is done.- Server tests use real fixtures (a fake
ghshell script placed onPATH), never module mocking — no mocking library exists in this codebase's server tests, followstubClaudeBinaryinserver/__tests__/lane-lifecycle.test.jsas the pattern.
Task 1: server/lib/gh.js — the gh execFile wrapper
Files:
- Create:
server/lib/gh.js - Test:
server/__tests__/gh.test.js
Interfaces:
-
Produces:
async function gh(cwd, args) => { stdout: string, stderr: string }. ThrowsErrorwith.codeset to"ENOGH"when theghbinary isn't found onPATH, or the process's own exit code otherwise;.gh = { args, code, stderr }always set on failure. -
Step 1: Write the failing test
// server/__tests__/gh.test.js
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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(), "agenthub-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;
}
});
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/gh.test.js
Expected: FAIL — Cannot find module '../lib/gh'
- Step 3: Write the implementation
// server/lib/gh.js
/**
* @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ĩ <vinnt@smartgift.vn>
*/
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
/**
* Run `gh <args>` 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 };
- Step 4: Run test to verify it passes
Run: node --test server/__tests__/gh.test.js
Expected: PASS (3 tests)
- Step 5: Verify the file header and commit
bash skills/file-headers/scripts/check-headers.sh
git add server/lib/gh.js server/__tests__/gh.test.js
git commit -m "feat(server): add gh execFile wrapper for CI status"
Task 2: server/lib/ci-status.js — the run-list → {state, checks} logic
Files:
- Create:
server/lib/ci-status.js - Test:
server/__tests__/ci-status.test.js
Interfaces:
-
Consumes:
gh(cwd, args)from Task 1 (require("./gh")). -
Produces:
async function ciStatus(cwd, ref) => { state: "success"|"failure"|"pending", ref: string, checks: Array<{name: string, conclusion: string, url: string|null}> }. Throws whatevergh()throws (propagated, not caught) — the route maps that to an HTTP error. -
Step 1: Write the failing test
// server/__tests__/ci-status.test.js
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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(), "agenthub-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();
}
});
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/ci-status.test.js
Expected: FAIL — Cannot find module '../lib/ci-status'
- Step 3: Write the implementation
// server/lib/ci-status.js
/**
* @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 AgentHub does NOT do"), this module only
* reports what gh already knows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 };
- Step 4: Run test to verify it passes
Run: node --test server/__tests__/ci-status.test.js
Expected: PASS (5 tests)
- Step 5: Verify the file header and commit
bash skills/file-headers/scripts/check-headers.sh
git add server/lib/ci-status.js server/__tests__/ci-status.test.js
git commit -m "feat(server): derive CI state from a single gh run snapshot"
Task 3: GET /:id/ci/status route
Files:
- Modify:
server/routes/lanes.js— add the route nearGET /:id/git(both are read-only, no-sameOriginGuardlane facts routes). - Modify:
server/__tests__/lanes-api.test.js— add an HTTP-level describe block.
Interfaces:
-
Consumes:
ciStatus(cwd, ref)from Task 2 (require("../lib/ci-status")),laneOr404/sendRuntimeErroralready defined inserver/routes/lanes.js. -
Produces:
GET /api/lanes/:id/ci/status?ref=<branch-or-sha>→200 {state, ref, checks}on success,400 {error:{code:"EBADREF",...}}whenrefis missing,404for an unknown lane,500 {error:{code, message}}(viasendRuntimeError) on aghfailure. -
Step 1: Write the failing test
Add this block to server/__tests__/lanes-api.test.js, after the existing describe("/api/lanes", ...) block (same file, so it shares the request/BASE/path/os helpers already defined there — only fs needs adding to that file's top-of-file requires):
// --- add near the top of the file, alongside the other requires ---
const fs = require("fs");
// --- add as its own describe block ---
describe("GET /:id/ci/status", () => {
let laneId;
let laneCwd;
let restorePath;
before(async () => {
laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-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(), "agenthub-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");
});
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/lanes-api.test.js
Expected: FAIL — 404 on /ci/status (route doesn't exist yet, Express falls through to the /:id/:action catch-all or 404s)
- Step 3: Add the route
In server/routes/lanes.js, add near the top requires:
const { ciStatus } = require("../lib/ci-status");
Add the route directly after the existing GET /:id/git route (after its closing });, so it sits with the other read-only lane-facts routes):
/**
* 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 AgentHub 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);
}
});
- Step 4: Run test to verify it passes
Run: node --test server/__tests__/lanes-api.test.js
Expected: PASS (all tests in the file, including the 3 new ones)
- Step 5: Verify the file header (unchanged file, header already present) and commit
git add server/routes/lanes.js server/__tests__/lanes-api.test.js
git commit -m "feat(api): add GET /:id/ci/status"
Task 4: agenthub lanes ci status CLI command
Files:
- Modify:
bin/agenthub.js— addcmdLanesCi, wire the dispatch, add the help table row. - Modify:
server/__tests__/lanes-cli.test.js— add a describe block.
Interfaces:
-
Consumes:
GET /api/lanes/:id/ci/status?ref=...from Task 3;resolveLaneArg,get,c(chalk-like color helper already used elsewhere inbin/agenthub.js). -
Produces:
agenthub lanes ci status <branch-or-sha> [<lane-id>]— prints the JSON response to stdout, setsprocess.exitCodeto0(success),1(failure or any error), or3(pending). -
Step 1: Write the failing test
Add to server/__tests__/lanes-cli.test.js, in a new describe block (reusing that file's existing cli(), git(), BASE helpers and SOURCE_REPO/LANE_DIR fixtures already set up in its before()):
describe("agenthub lanes ci status", () => {
let restorePath;
before(() => {
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-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 r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
CI_STATUS_FIXTURE: "failure",
});
assert.equal(r.status, 1);
assert.match(r.stdout, /"state": "failure"/);
});
it("exits 3 when no run exists yet", async () => {
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"/);
});
});
cli() in this file only takes (args, cwd) today — extend its signature to accept an optional third extraEnv argument (backward compatible, every existing call site keeps working unchanged):
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), ...extraEnv },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => (stdout += c));
child.stderr.on("data", (c) => (stderr += c));
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
});
}
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/lanes-cli.test.js
Expected: FAIL — agenthub lanes ci prints a "no such subcommand" usage error, exit code 1 (not the expected 0/1/3 from real data), and no "state" in stdout
- Step 3: Add the CLI command
In bin/agenthub.js, add near cmdLanesIntegration (after its closing }):
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — 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: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
process.exitCode = 1;
return;
}
const ref = args[1];
if (!ref) {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
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;
}
Wire the dispatch — in the case "lanes": block, next to the existing if (rest[0] === "integration") ...:
if (rest[0] === "ci") {
return cmdLanesCi(rest.slice(1));
}
Add the help table row, next to the "lanes integration" row:
[
"lanes ci status",
"<branch-or-sha> [<id>]",
"One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending",
],
- Step 4: Run test to verify it passes
Run: node --test server/__tests__/lanes-cli.test.js
Expected: PASS (all tests in the file, including the 3 new ones)
- Step 5: Commit
git add bin/agenthub.js server/__tests__/lanes-cli.test.js
git commit -m "feat(cli): add agenthub lanes ci status"
Task 5: Wire Stage 13's CI-wait half + update docs
Files:
- Modify:
skills/ship-feature-lane/SKILL.md(Stage 13 section, and the "Current status" summary near the top) - Modify:
docs/LANES.md("Stages currently hardcoded skipped", theagenthub lanes integrationsection's claim aboutci_wait's consumer, and the command reference)
No test for this task — it's documentation/skill-text only, verified by reading it back and by the update-project-docs checklist. This task has no code, so it isn't gated behind npm run test:server; run that suite once at the very end of the plan instead (see Task 5 Step 4).
- Step 1: Rewrite Stage 13 in
skills/ship-feature-lane/SKILL.md
Replace the existing Stage 13 section (currently: ### 13 — Post-merge verification *(currently SKIPPED — CI-wait and dev-QC integrations are off)* through its two-line body) with:
### 13 — Post-merge verification *(CI-wait works; dev-QC still unavailable)*
- `agenthub stage merged --status running --note "watching development CI post-merge"`
- Get the merge commit: `gh pr view <pr_url> --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:
- `agenthub lanes ci status <mergeSha>` — 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 <run-id>` — 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) — `agenthub stage merged --status blocked --note "<job> 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 — `agenthub stage merged --status passed --note "CI green on development; dev-QC unavailable (separate task)"` and proceed to Stage 14.
- If `agenthub 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.
- Step 2: Update the skill's front-matter description and "Current status" summary
In skills/ship-feature-lane/SKILL.md, the YAML front-matter description field currently ends with:
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.
Change to:
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 agenthub lanes ci status.
Find the "Current status" bullet list (**Stages currently hardcoded skipped**) and change the Stage 13 line from:
**13 (Post-merge verification)**: Dev-CI-wait and dev-QC integrations are off — the stage transitions immediately todonewith a note explaining the gap.
to a line under a renamed heading that separates the two:
**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 `agenthub 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.
- Step 3: Update
docs/LANES.md
-
In the "Stages currently hardcoded skipped" list (around line 1137-1139), change the Stage 13 bullet to match the SKILL.md wording from Step 2 above (CI-wait works, dev-QC doesn't).
-
In the
agenthub lanes integrationsection (around line 1224), change:The agents that would act on an enabled toggle (ticketer,dev-qc) aren't built yet;ci_wait's consumer (agenthub ci) isn't either.ship-feature-lane's Stage 9/13/10 stay skipped/fallback regardless of what the toggle reads, until those land.to:
The agent that would act on an enableddev_qctoggle isn't built yet, and neither is atrackerticketer —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 isagenthub lanes ci status(added — see the command reference below); Stage 13's CI-wait half now honors it for real. -
Add
agenthub lanes ci statusto whereverdocs/LANES.mdlistsagenthub lanessubcommands (same table/sectionagenthub lanes sync-base,agenthub lanes integrationetc. already appear in) — one row:agenthub lanes ci status <branch-or-sha> [<id>]— one-shot GitHub Actions status for a ref (exit 0/1/3), used by Stage 13's CI-wait. -
Update the
**ticketer,dev-qc, andpr-reviewer** are not ported yet" line (around line 1198) to dropagenthub ci/ci_wait` framing if it's still described there as unbuilt — cross-check by re-reading that paragraph once Step 1-3 are done, since it may already only be about the two agents.
- Step 4: Full verification pass
npm run test:server
bash skills/file-headers/scripts/check-headers.sh
Both must pass clean — this is the first point every file this plan touched (code and docs) has been checked together.
- Step 5: Commit
git add skills/ship-feature-lane/SKILL.md docs/LANES.md
git commit -m "docs(lanes): wire Stage 13 CI-wait to agenthub lanes ci status"