# `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 ` — 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ĩ `) — see `skills/file-headers/`. - No flake auto-classification or auto-rerun logic anywhere in this stack — `state` is a derived fact, nothing more. - Route never writes lane `stage`/`status`/`notes` (same boundary every other read-only lane route keeps). - `npm run test:server` must pass before this is done. - Server tests use real fixtures (a fake `gh` shell script placed on `PATH`), never module mocking — no mocking library exists in this codebase's server tests, follow `stubClaudeBinary` in `server/__tests__/lane-lifecycle.test.js` as 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 }`. Throws `Error` with `.code` set to `"ENOGH"` when the `gh` binary isn't found on `PATH`, or the process's own exit code otherwise; `.gh = { args, code, stderr }` always set on failure. - [ ] **Step 1: Write the failing test** ```js // 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ĩ */ 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** ```js // 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ĩ */ 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 }; ``` - [ ] **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 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 whatever `gh()` throws (propagated, not caught) — the route maps that to an HTTP error. - [ ] **Step 1: Write the failing test** ```js // 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ĩ */ 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** ```js // 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ĩ */ 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 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 near `GET /:id/git` (both are read-only, no-`sameOriginGuard` lane 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`/`sendRuntimeError` already defined in `server/routes/lanes.js`. - Produces: `GET /api/lanes/:id/ci/status?ref=` → `200 {state, ref, checks}` on success, `400 {error:{code:"EBADREF",...}}` when `ref` is missing, `404` for an unknown lane, `500 {error:{code, message}}` (via `sendRuntimeError`) on a `gh` failure. - [ ] **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): ```js // --- 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: ```js 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): ```js /** * 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** ```bash 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` — add `cmdLanesCi`, 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 in `bin/agenthub.js`). - Produces: `agenthub lanes ci status []` — prints the JSON response to stdout, sets `process.exitCode` to `0` (success), `1` (failure or any error), or `3` (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()`): ```js 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): ```js 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 `}`): ```js /** `agenthub 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: agenthub lanes ci status []"); process.exitCode = 1; return; } const ref = args[1]; if (!ref) { console.error("usage: agenthub 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; } ``` Wire the dispatch — in the `case "lanes":` block, next to the existing `if (rest[0] === "integration") ...`: ```js if (rest[0] === "ci") { return cmdLanesCi(rest.slice(1)); } ``` Add the help table row, next to the `"lanes integration"` row: ```js [ "lanes ci status", " []", "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** ```bash 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", the `agenthub lanes integration` section's claim about `ci_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: ```markdown ### 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 --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 ` — 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) — `agenthub 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 — `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 to `done` with a note explaining the gap.` to a line under a renamed heading that separates the two: ```markdown **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`** 1. 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). 2. In the `agenthub lanes integration` section (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 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 `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real.` 3. Add `agenthub lanes ci status` to wherever `docs/LANES.md` lists `agenthub lanes` subcommands (same table/section `agenthub lanes sync-base`, `agenthub lanes integration` etc. already appear in) — one row: `agenthub lanes ci status []` — one-shot GitHub Actions status for a ref (exit 0/1/3), used by Stage 13's CI-wait. 4. Update the `**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet" line (around line 1198) to drop `agenthub 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** ```bash 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** ```bash 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" ```