diff --git a/.claude/scripts/cockpit.sh b/.claude/scripts/cockpit.sh
new file mode 100755
index 0000000..e88621e
--- /dev/null
+++ b/.claude/scripts/cockpit.sh
@@ -0,0 +1,428 @@
+#!/usr/bin/env bash
+# cockpit.sh — Phase 1 read-only dashboard (issue #51): a single static HTML
+# snapshot of open issues (grouped by module label, with a parsed blocking
+# graph), open PRs (review + CI state), model/skill routing (agent frontmatter
+# + adapter config), and active worker worktrees. Regenerated on demand — no
+# persistent server, no watch daemon (re-run this script, or wrap it in
+# `watch -n 30 bash .claude/scripts/cockpit.sh`).
+#
+# Usage:
+# cockpit.sh [--fixtures
] [output-path]
+# cockpit.sh --parse-blocking
+# Reads one issue body on stdin, prints its parsed blocking edges as JSON:
+# {"blockedBy":[...],"blocks":[...],"taskRefs":[...]}. Used internally
+# (the render step below shells back into this same script per issue) AND
+# directly by cockpit.test.sh, so there is exactly ONE implementation of
+# the parser to keep in sync.
+#
+# --fixtures : read /issues.json and /prs.json (arrays shaped
+# like `gh issue|pr list --json ...` output) instead of calling gh at all.
+# This is the offline seam cockpit.test.sh uses — no live gh/network in tests.
+#
+# Degrades gracefully: if a bot-gh.sh call fails (no network / no gh auth),
+# that section renders an "unavailable (gh/network)" placeholder instead of
+# data, and the script still exits 0 — reviewers/CI may run with no network,
+# and this must never hard-crash on that.
+#
+# Output: self-contained HTML (inline CSS, no external CDN/JS/fonts), default
+# .claude/state/cockpit.html (that dir is gitignored — never commit the
+# generated artifact). Pass a second positional arg to write elsewhere.
+set -uo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+root="$(cd "$script_dir/../.." && pwd)"
+self="$script_dir/cockpit.sh"
+
+# ---------------------------------------------------------------------------
+# Hidden seam: the blocking-graph parser as its own subcommand, so it has
+# exactly one implementation. Recognizes, case-insensitively:
+# "Blocked by #N[, #M ...]" → blockedBy
+# "Blocks #N[, #M ...]" → blocks
+# "- [ ] #N ..." / "- [x] #N ..." task-list lines → taskRefs
+# ---------------------------------------------------------------------------
+if [ "${1:-}" = "--parse-blocking" ]; then
+ node -e '
+ const fs = require("fs");
+ const body = fs.readFileSync(0, "utf8");
+
+ // Collect every "#N" found in a bounded window right after each match of
+ // phraseRe — up to the next newline or sentence end — so e.g.
+ // "blocked by #12. This blocks #99." does not bleed numbers across the
+ // two different phrases.
+ function extractAfterPhrase(text, phraseRe) {
+ const nums = new Set();
+ const flags = phraseRe.flags.includes("g") ? phraseRe.flags : phraseRe.flags + "g";
+ const re = new RegExp(phraseRe.source, flags);
+ let m;
+ while ((m = re.exec(text))) {
+ const rest = text.slice(m.index + m[0].length);
+ const cut = rest.search(/[\n]|\.(?!\d)/);
+ const window = cut === -1 ? rest.slice(0, 200) : rest.slice(0, cut);
+ (window.match(/#(\d+)/g) || []).forEach((t) => nums.add(parseInt(t.slice(1), 10)));
+ }
+ return [...nums].sort((a, b) => a - b);
+ }
+
+ const blockedBy = extractAfterPhrase(body, /blocked\s+by\b/i);
+ const blocks = extractAfterPhrase(body, /\bblocks\b/i);
+
+ const taskRefs = new Set();
+ const taskRe = /^[ \t]*-[ \t]*\[[ xX]\][ \t]*#(\d+)/gm;
+ let tm;
+ while ((tm = taskRe.exec(body))) taskRefs.add(parseInt(tm[1], 10));
+
+ process.stdout.write(JSON.stringify({
+ blockedBy,
+ blocks,
+ taskRefs: [...taskRefs].sort((a, b) => a - b),
+ }));
+ '
+ exit $?
+fi
+
+# ---------------------------------------------------------------------------
+# Args
+# ---------------------------------------------------------------------------
+fixtures=""
+out=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --fixtures) fixtures="$2"; shift 2 ;;
+ --fixtures=*) fixtures="${1#--fixtures=}"; shift ;;
+ *) out="$1"; shift ;;
+ esac
+done
+out="${out:-$root/.claude/state/cockpit.html}"
+case "$out" in
+ /*) : ;;
+ *) out="$root/$out" ;;
+esac
+mkdir -p "$(dirname "$out")"
+
+# gh entry point — overridable so tests can stub a failing "gh" without
+# touching real auth/network (see cockpit.test.sh's degrade-path case).
+gh_bin="${COCKPIT_GH_BIN:-$script_dir/bot-gh.sh}"
+gh() { "$gh_bin" "$@"; }
+
+# Adapter to read for model/skill routing — same override contract as
+# gate.sh/worktree.sh (GATES_FILE env, relative paths resolve from repo root),
+# so self-hosting this repo can point it at .claude/self/gates.json.
+gates_ref="${GATES_FILE:-.claude/gates.json}"
+case "$gates_ref" in
+ /*) gates="$gates_ref" ;;
+ *) gates="$root/$gates_ref" ;;
+esac
+
+tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/cockpit.XXXXXX")"
+trap 'rm -rf "$tmpdir"' EXIT
+
+valid_json() { node -e 'JSON.parse(require("fs").readFileSync(0,"utf8"))' <"$1" >/dev/null 2>&1; }
+
+# ---- issues -----------------------------------------------------------------
+issues_unavailable=0
+if [ -n "$fixtures" ]; then
+ if [ -f "$fixtures/issues.json" ]; then cp "$fixtures/issues.json" "$tmpdir/issues.json"; else echo "[]" >"$tmpdir/issues.json"; fi
+else
+ if ! gh issue list --state open --limit 200 --json number,title,url,labels,body >"$tmpdir/issues.json" 2>"$tmpdir/issues.err"; then
+ issues_unavailable=1
+ fi
+ if [ "$issues_unavailable" -eq 0 ] && ! valid_json "$tmpdir/issues.json"; then issues_unavailable=1; fi
+ [ "$issues_unavailable" -eq 1 ] && echo "[]" >"$tmpdir/issues.json"
+fi
+
+# ---- PRs ----------------------------------------------------------------------
+prs_unavailable=0
+if [ -n "$fixtures" ]; then
+ if [ -f "$fixtures/prs.json" ]; then cp "$fixtures/prs.json" "$tmpdir/prs.json"; else echo "[]" >"$tmpdir/prs.json"; fi
+else
+ if ! gh pr list --state open --limit 200 --json number,title,url,headRefName,reviewDecision,statusCheckRollup >"$tmpdir/prs.json" 2>"$tmpdir/prs.err"; then
+ prs_unavailable=1
+ fi
+ if [ "$prs_unavailable" -eq 0 ] && ! valid_json "$tmpdir/prs.json"; then prs_unavailable=1; fi
+ [ "$prs_unavailable" -eq 1 ] && echo "[]" >"$tmpdir/prs.json"
+fi
+
+# ---- agent model/skill routing (static config, always read locally) -----------
+node -e '
+ const fs = require("fs"), path = require("path");
+ const dir = process.argv[1];
+ const out = [];
+ let files = [];
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort(); } catch (e) {}
+ for (const f of files) {
+ let text = "";
+ try { text = fs.readFileSync(path.join(dir, f), "utf8"); } catch (e) { continue; }
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
+ let role = f.replace(/\.md$/, ""), model = "", description = "";
+ if (fm) {
+ const body = fm[1];
+ const nm = body.match(/^name:\s*(.+)$/m); if (nm) role = nm[1].trim();
+ const mm = body.match(/^model:\s*(.+)$/m); if (mm) model = mm[1].trim();
+ const dm = body.match(/^description:\s*(.+)$/m); if (dm) description = dm[1].trim();
+ }
+ out.push({ role, model, description });
+ }
+ fs.writeFileSync(process.argv[2], JSON.stringify(out));
+' "$root/.claude/agents" "$tmpdir/agents.json"
+
+# ---- adapter (review lenses/skills, budget) ------------------------------------
+node -e '
+ const fs = require("fs");
+ let g = null;
+ try { g = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { g = null; }
+ const out = {
+ path: process.argv[2],
+ available: g !== null,
+ lenses: (g && g.review && g.review.lenses) || [],
+ skills: (g && g.review && g.review.skills) || [],
+ budget: (g && g.budget) || {},
+ };
+ fs.writeFileSync(process.argv[3], JSON.stringify(out));
+' "$gates" "$gates_ref" "$tmpdir/adapter.json"
+
+# ---- active worktrees -----------------------------------------------------------
+node -e '
+ const fs = require("fs");
+ const dir = process.argv[1];
+ let names = [];
+ try {
+ names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
+ } catch (e) {}
+ fs.writeFileSync(process.argv[2], JSON.stringify(names));
+' "$root/.claude/worktrees" "$tmpdir/worktrees.json"
+
+# ---------------------------------------------------------------------------
+# Render — one node process reads all the gathered JSON + flags and writes the
+# final self-contained HTML file.
+# ---------------------------------------------------------------------------
+COCKPIT_TMPDIR="$tmpdir" \
+COCKPIT_SELF="$self" \
+COCKPIT_OUT="$out" \
+COCKPIT_ISSUES_UNAVAILABLE="$issues_unavailable" \
+COCKPIT_PRS_UNAVAILABLE="$prs_unavailable" \
+COCKPIT_GATES_REF="$gates_ref" \
+node - <<'NODE_RENDER'
+const fs = require("fs");
+const path = require("path");
+const { execFileSync } = require("child_process");
+
+const tmpdir = process.env.COCKPIT_TMPDIR;
+const readJson = (name, fallback) => {
+ try { return JSON.parse(fs.readFileSync(path.join(tmpdir, name), "utf8")); } catch (e) { return fallback; }
+};
+
+const issues = readJson("issues.json", []);
+const prs = readJson("prs.json", []);
+const agents = readJson("agents.json", []);
+const adapter = readJson("adapter.json", { path: process.env.COCKPIT_GATES_REF, available: false, lenses: [], skills: [], budget: {} });
+const worktrees = readJson("worktrees.json", []);
+const issuesUnavailable = process.env.COCKPIT_ISSUES_UNAVAILABLE === "1";
+const prsUnavailable = process.env.COCKPIT_PRS_UNAVAILABLE === "1";
+
+function esc(s) {
+ return String(s == null ? "" : s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+// Shell back into cockpit.sh's own --parse-blocking so the blocking-graph
+// parser has exactly one implementation (also exercised directly by
+// cockpit.test.sh).
+function parseBlocking(body) {
+ try {
+ const stdout = execFileSync("bash", [process.env.COCKPIT_SELF, "--parse-blocking"], {
+ input: body || "",
+ encoding: "utf8",
+ maxBuffer: 10 * 1024 * 1024,
+ });
+ return JSON.parse(stdout);
+ } catch (e) {
+ return { blockedBy: [], blocks: [], taskRefs: [] };
+ }
+}
+
+const knownIssueNumbers = new Set(issues.map((i) => i.number));
+function refLink(n) {
+ return knownIssueNumbers.has(n) ? `#${n}` : `#${n}`;
+}
+function refList(nums) {
+ return nums.map(refLink).join(", ");
+}
+
+function moduleLabelsOf(issue) {
+ return (issue.labels || []).map((l) => l.name).filter((n) => typeof n === "string" && n.startsWith("module:"));
+}
+
+// ---- Issues section: group by module label, parse blocking graph per issue ----
+function renderIssues() {
+ if (issuesUnavailable) {
+ return `Open issues
unavailable (gh/network)
`;
+ }
+ const groups = new Map(); // moduleName -> issue[]
+ for (const issue of issues) {
+ const mods = moduleLabelsOf(issue);
+ const key = mods.length ? mods[0] : "unlabeled";
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(issue);
+ }
+ const keys = [...groups.keys()].sort((a, b) => {
+ if (a === "unlabeled") return 1;
+ if (b === "unlabeled") return -1;
+ return a.localeCompare(b);
+ });
+
+ let html = `Open issues (${issues.length})
`;
+ if (keys.length === 0) {
+ html += `No open issues.
`;
+ }
+ for (const key of keys) {
+ const list = groups.get(key).sort((a, b) => a.number - b.number);
+ html += `${esc(key)}
`;
+ for (const issue of list) {
+ const edges = parseBlocking(issue.body || "");
+ html += `- #${issue.number} ${esc(issue.title)}`;
+ const rel = [];
+ if (edges.blockedBy.length) rel.push(`Blocked by ${refList(edges.blockedBy)}`);
+ if (edges.blocks.length) rel.push(`Blocks ${refList(edges.blocks)}`);
+ if (edges.taskRefs.length) rel.push(`Subtasks ${refList(edges.taskRefs)}`);
+ if (rel.length) html += `
${rel.join(" · ")}
`;
+ html += ` `;
+ }
+ html += `
`;
+ }
+ html += ``;
+ return html;
+}
+
+// ---- PRs section: review decision + CI rollup ----------------------------------
+function reviewBadge(rd) {
+ switch (rd) {
+ case "APPROVED": return { label: "approved", cls: "good" };
+ case "CHANGES_REQUESTED": return { label: "changes requested", cls: "bad" };
+ case "REVIEW_REQUIRED": return { label: "review required", cls: "warn" };
+ default: return { label: "pending", cls: "warn" };
+ }
+}
+function ciBadge(rollup) {
+ if (!rollup || rollup.length === 0) return { label: "no checks", cls: "muted" };
+ let hasFailure = false, hasPending = false;
+ for (const c of rollup) {
+ if (c.conclusion !== undefined && c.conclusion !== null && c.conclusion !== "") {
+ if (["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(c.conclusion)) hasFailure = true;
+ if (c.status && c.status !== "COMPLETED") hasPending = true;
+ } else if (c.state) {
+ if (["FAILURE", "ERROR"].includes(c.state)) hasFailure = true;
+ if (c.state === "PENDING") hasPending = true;
+ }
+ }
+ if (hasFailure) return { label: "failing", cls: "bad" };
+ if (hasPending) return { label: "pending", cls: "warn" };
+ return { label: "passing", cls: "good" };
+}
+function renderPRs() {
+ if (prsUnavailable) {
+ return `Open PRs
unavailable (gh/network)
`;
+ }
+ let html = `Open PRs (${prs.length})
`;
+ if (prs.length === 0) {
+ html += `No open PRs.
`;
+ } else {
+ html += ``;
+ for (const pr of prs.sort((a, b) => a.number - b.number)) {
+ const review = reviewBadge(pr.reviewDecision);
+ const ci = ciBadge(pr.statusCheckRollup);
+ html += `- #${pr.number} ${esc(pr.title)}`;
+ html += ` review: ${review.label}`;
+ html += ` CI: ${ci.label}`;
+ if (pr.headRefName) html += ` (${esc(pr.headRefName)})`;
+ html += `
`;
+ }
+ html += `
`;
+ }
+ html += ``;
+ return html;
+}
+
+// ---- Routing section: per-role model + adapter review/budget config ------------
+function renderRouting() {
+ let html = `Model / skill routing
`;
+ html += `| Role | model: | Description |
`;
+ if (agents.length === 0) {
+ html += `| No agent frontmatter found under .claude/agents/. |
`;
+ }
+ for (const a of agents) {
+ html += `| ${esc(a.role)} | ${esc(a.model || "(none)")} | ${esc(a.description)} |
`;
+ }
+ html += `
`;
+ html += `Adapter: ${esc(adapter.path)}${adapter.available ? "" : " (not found — showing defaults)"}
`;
+ html += `review.lenses: ${adapter.lenses.length ? adapter.lenses.map(esc).join(", ") : "(none)"}
`;
+ html += `review.skills: ${adapter.skills.length ? adapter.skills.map(esc).join(", ") : "(none)"}
`;
+ const b = adapter.budget || {};
+ html += `| Budget key | Value |
`;
+ const budgetKeys = ["orchestrator_model", "worker_model", "explorer_model", "reviewer_model", "max_parallel_workers"];
+ for (const k of budgetKeys) {
+ html += `| ${esc(k)} | ${esc(b[k] != null ? b[k] : "(unset)")} |
`;
+ }
+ html += `
`;
+ return html;
+}
+
+// ---- Active worktrees section ---------------------------------------------------
+function renderWorktrees() {
+ let html = `Active worktrees
`;
+ if (worktrees.length === 0) {
+ html += `none active
`;
+ } else {
+ html += `` + worktrees.map((w) => `${esc(w)} `).join("") + `
`;
+ }
+ html += ``;
+ return html;
+}
+
+const generatedAt = new Date().toISOString();
+const html = `
+
+
+
+Cockpit — ai-project-orchestrator
+
+
+
+Cockpit
+Generated ${esc(generatedAt)} · read-only Phase 1 snapshot (issue #51) · re-run cockpit.sh to refresh
+${renderIssues()}
+${renderPRs()}
+${renderRouting()}
+${renderWorktrees()}
+
+
+`;
+
+fs.writeFileSync(process.env.COCKPIT_OUT, html);
+NODE_RENDER
+
+echo "$out"
diff --git a/.claude/scripts/cockpit.test.sh b/.claude/scripts/cockpit.test.sh
new file mode 100755
index 0000000..1a7fea0
--- /dev/null
+++ b/.claude/scripts/cockpit.test.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+# cockpit.test.sh — offline smoke test for cockpit.sh (issue #51).
+#
+# Runs the generator against controlled FIXTURE issue/PR JSON (never live
+# gh/network — see cockpit.sh's --fixtures mode), then asserts the produced
+# HTML contains every required section (issues-by-module with blocking
+# relationships, PRs with review/CI badges, a routing table with a real
+# `model:` value, a worktrees section) and that the blocking-relationship
+# parser (`cockpit.sh --parse-blocking`) produces the expected edges for a
+# known fixture body. Also exercises the "gh/network unavailable" degrade
+# path via COCKPIT_GH_BIN, entirely offline (no real gh call, no .env).
+#
+# Exit 0 on success, non-zero if any assertion fails. Runnable bare:
+# bash .claude/scripts/cockpit.test.sh
+set -uo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cockpit="$script_dir/cockpit.sh"
+
+work="$(mktemp -d "${TMPDIR:-/tmp}/cockpit-test.XXXXXX")"
+trap 'rm -rf "$work"' EXIT
+
+fail=0
+ok=0
+check() {
+ local desc="$1"; shift
+ if "$@"; then
+ ok=$((ok + 1))
+ echo "ok - $desc"
+ else
+ fail=1
+ echo "FAIL - $desc"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# 1. Blocking-graph parser: exact edges for a known fixture body.
+# ---------------------------------------------------------------------------
+parsed="$(printf 'Blocked by #10, #11\nBlocks #20\n- [ ] #30 subtask\n- [x] #31 done\n' | bash "$cockpit" --parse-blocking)"
+check "parse-blocking produces exactly the expected edges" node -e '
+ const got = JSON.parse(process.argv[1]);
+ const want = { blockedBy: [10, 11], blocks: [20], taskRefs: [30, 31] };
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
+ console.error("got", got, "want", want);
+ process.exit(1);
+ }
+' "$parsed"
+
+# A second fixture: no relationships at all should parse to empty arrays,
+# and "blocked by" text should not leak into "blocks".
+parsed2="$(printf 'Nothing to see here. Blocks #99.\n' | bash "$cockpit" --parse-blocking)"
+check "parser finds only 'blocks' edge, no false blockedBy" node -e '
+ const got = JSON.parse(process.argv[1]);
+ if (JSON.stringify(got.blockedBy) !== "[]") process.exit(1);
+ if (JSON.stringify(got.blocks) !== "[99]") process.exit(1);
+' "$parsed2"
+
+# ---------------------------------------------------------------------------
+# 2. Fixture-driven full generator run (no gh/network).
+# ---------------------------------------------------------------------------
+mkdir -p "$work/fixtures"
+cat > "$work/fixtures/issues.json" <<'EOF'
+[
+ {"number":100,"title":"Issue A ","url":"https://example.com/100","labels":[{"name":"module:harness"}],"body":"Blocked by #101, #102\nBlocks #103\n- [ ] #104 subtask\n- [x] #105 done subtask"},
+ {"number":101,"title":"Issue B","url":"https://example.com/101","labels":[{"name":"module:docs"}],"body":""},
+ {"number":103,"title":"Issue C","url":"https://example.com/103","labels":[],"body":""}
+]
+EOF
+cat > "$work/fixtures/prs.json" <<'EOF'
+[
+ {"number":200,"title":"PR A","url":"https://example.com/pr/200","headRefName":"feat/x","reviewDecision":"APPROVED","statusCheckRollup":[{"conclusion":"SUCCESS","status":"COMPLETED","name":"build"}]},
+ {"number":201,"title":"PR B","url":"https://example.com/pr/201","headRefName":"feat/y","reviewDecision":"CHANGES_REQUESTED","statusCheckRollup":[{"conclusion":"FAILURE","status":"COMPLETED","name":"test"}]}
+]
+EOF
+
+html="$work/cockpit.html"
+bash "$cockpit" --fixtures "$work/fixtures" "$html" >"$work/stdout.log" 2>"$work/stderr.log"
+rc=$?
+check "generator exits 0 on fixture run" [ "$rc" -eq 0 ]
+check "generator prints the output path" grep -qF "$html" "$work/stdout.log"
+check "output HTML file was created" [ -s "$html" ]
+
+# Issues-by-module + blocking relationships.
+check "issues section present" grep -q 'module:harness' "$html"
+check "module:docs group heading present" grep -q 'module:docs
' "$html"
+check "unlabeled group heading present (issue with no module label)" grep -q 'unlabeled
' "$html"
+check "issue title is HTML-escaped, not raw" bash -c '! grep -qF "" "$1" && grep -qF "<script>alert(1)</script>" "$1"' _ "$html"
+check "blocked-by relationship rendered, linked to known issue #101" grep -qF 'Blocked by #101, #102' "$html"
+check "blocks relationship rendered, linked to known issue #103" grep -qF 'Blocks #103' "$html"
+check "subtasks (task-list refs) rendered" grep -qF 'Subtasks #104, #105' "$html"
+
+# PRs with review + CI status.
+check "PRs section present" grep -q 'orchestrator' "$html"
+check "routing table contains a real model: value" grep -qE '(opus|sonnet|haiku)' "$html"
+check "adapter path shown in routing section" grep -q 'Adapter: .claude/gates.json' "$html"
+
+# Worktrees section (state may vary, so only assert the section exists).
+check "worktrees section present" grep -q '/dev/null 2>"$work/stderr-self.log"
+check "GATES_FILE override honored in generator output" grep -q 'Adapter: .claude/self/gates.json' "$html_self"
+
+# ---------------------------------------------------------------------------
+# 4. Graceful degrade when gh is unavailable — stub COCKPIT_GH_BIN so this is
+# fully offline (no real gh call, no dependency on .env/auth/network).
+# ---------------------------------------------------------------------------
+fake_gh="$work/fake-gh-fail.sh"
+cat > "$fake_gh" <<'EOF'
+#!/usr/bin/env bash
+exit 1
+EOF
+chmod +x "$fake_gh"
+html_unavail="$work/cockpit-unavail.html"
+COCKPIT_GH_BIN="$fake_gh" bash "$cockpit" "$html_unavail" >/dev/null 2>"$work/stderr-unavail.log"
+rc_unavail=$?
+check "generator still exits 0 when gh is unavailable" [ "$rc_unavail" -eq 0 ]
+check "issues section shows unavailable placeholder" grep -q 'Open issues
unavailable (gh/network)
' "$html_unavail"
+check "PRs section shows unavailable placeholder" grep -q 'Open PRs
unavailable (gh/network)
' "$html_unavail"
+check "routing/worktrees sections still render (no crash) despite gh failure" bash -c 'grep -q "routing" "$1" && grep -q "worktrees" "$1"' _ "$html_unavail"
+
+echo ""
+if [ "$fail" -eq 0 ]; then
+ echo "cockpit.test.sh: PASS ($ok checks)"
+ exit 0
+else
+ echo "cockpit.test.sh: FAIL (see FAIL lines above)"
+ exit 1
+fi
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 79a8a9f..ed8c4aa 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -56,6 +56,13 @@ only the adapter changes.
- Token cost scales with agent count — see [`TOKEN_BUDGET.md`](TOKEN_BUDGET.md).
- Remove worktrees on merge (`git worktree list` / `git worktree remove`).
+## Cockpit (read-only dashboard)
+`.claude/scripts/cockpit.sh` regenerates a static local HTML snapshot of this topology in practice: open
+issues by module with their blocking graph, open PR review/CI state, per-role model/skill routing, and active
+worker worktrees. Run it, then open `.claude/state/cockpit.html`. No server, no new dependency — see the
+script header for usage (`--fixtures`, `GATES_FILE` override) and `docs/COCKPIT_EVALUATION.md` for the
+design rationale.
+
### Concurrent config-write safety
This template fans out to **parallel worktree-isolated workers**, and upstream
[anthropics/claude-code#29217](https://github.com/anthropics/claude-code/issues/29217) reported that