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 += ``; + if (agents.length === 0) { + html += ``; + } + for (const a of agents) { + html += ``; + } + html += `
Rolemodel:Description
No agent frontmatter found under .claude/agents/.
${esc(a.role)}${esc(a.model || "(none)")}${esc(a.description)}
`; + 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 += ``; + const budgetKeys = ["orchestrator_model", "worker_model", "explorer_model", "reviewer_model", "max_parallel_workers"]; + for (const k of budgetKeys) { + html += ``; + } + html += `
Budget keyValue
${esc(k)}${esc(b[k] != null ? b[k] : "(unset)")}
`; + 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