Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/agents/implementer.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ Never call bare `gh`. EVERY `gh` invocation (PR create/update, comments, `gh api
```

If a reviewer rejects your work, address every reason, re-run the gates, and report again. Iterate until approved.

## Progress events (observability)
Best-effort, additive only — never changes gate enforcement or control flow. Log a progress event at each phase transition:
`bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/log-event.sh --role implementer --task <issue id> --phase <implementing|gate-running|done> --model <your model>`
Call it with `--phase implementing` when you start work (step 3), `--phase gate-running` before step 5's gate run, and `--phase done` when you file your report (step 7). If `log-event.sh` fails, ignore it and continue — it must never block or alter your work.
5 changes: 5 additions & 0 deletions .claude/agents/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ Every subagent you spawn starts a fresh context that loads CLAUDE.md and its age
- **One worker for small tasks** (rule 1 above) is also the #1 token rule: skipping a needless fan-out saves more than any model routing.
- Git hygiene: tell workers to **stage explicit paths, never `git add -A`/`git commit -a`**. A sandboxed session masks config paths (shell rc, `.gitconfig`, `.mcp.json`, `.claude/{hooks,skills,routines}`, editor dirs) as `/dev/null` device nodes that show up in `git status`; a blanket add can abort the commit. They're expected artifacts, not the worker's changes (see `docs/HARDENING.md` → Caveats).

## Progress events (observability)
Best-effort, additive only — never changes gate enforcement, review consensus, or control flow. After you present the plan (step 2), run:
`bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/log-event.sh --role orchestrator --task <issue/task id> --phase scoped --model <your model>`
When the run wraps (step 6), you may also log `--phase done`. If `log-event.sh` fails for any reason, ignore it and continue — never let it block or alter your loop.

## Status report format (your "standup")
```
## Run summary
Expand Down
5 changes: 5 additions & 0 deletions .claude/agents/reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ If you touch GitHub at all (e.g. `gh pr diff`, `gh pr view`, `gh api`), route it
- If approve: one line on what you checked and why you're satisfied.
```
Reject if you find anything that would block merge under your lens. Be specific and actionable so the implementer can fix without guessing.

## Progress events (observability)
Best-effort, additive only — never changes review consensus or control flow. Log a progress event at the start of your review and when you emit your verdict:
`bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/log-event.sh --role reviewer --task <issue/task id> --phase reviewing --model <your model> --lens <your lens>`
then again with `--phase done` once you've emitted your verdict. If `log-event.sh` fails, ignore it and continue — it must never block or alter your review.
88 changes: 84 additions & 4 deletions .claude/scripts/cockpit.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
#!/usr/bin/env bash
# cockpit.sh — Phase 1 read-only dashboard (issue #51): a single static HTML
# cockpit.sh — Phase 1 read-only dashboard (issue #51), extended in Phase 2
# (issue #52) with a live per-worker progress panel: 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
# + adapter config), active worker worktrees, and — from the local progress
# event log (see log-event.sh) — the CURRENT phase of every in-flight worker
# (scoped/implementing/gate-running/reviewing/done). 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:
Expand All @@ -18,6 +21,9 @@
# --fixtures <dir>: read <dir>/issues.json and <dir>/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.
# In this mode, the live-progress panel also reads <dir>/events.jsonl (if
# present; missing = "no active workers") instead of the real event log, so
# tests never touch .claude/state/.
#
# Degrades gracefully: if a bot-gh.sh call fails (no network / no gh auth),
# that section renders an "unavailable (gh/network)" placeholder instead of
Expand Down Expand Up @@ -180,6 +186,18 @@ node -e '
fs.writeFileSync(process.argv[3], JSON.stringify(out));
' "$gates" "$gates_ref" "$tmpdir/adapter.json"

# ---- live worker progress events (issue #52) -------------------------------
# Never reads the real event log in --fixtures mode (offline seam for tests).
# Otherwise honors CLAUDE_EVENTS_FILE for parity with log-event.sh, defaulting
# to the same gitignored .claude/state/events.jsonl. Missing/empty log is not
# an error — it just means no workers are currently in flight.
if [ -n "$fixtures" ]; then
events_file="$fixtures/events.jsonl"
else
events_file="${CLAUDE_EVENTS_FILE:-$root/.claude/state/events.jsonl}"
fi
if [ -f "$events_file" ]; then cp "$events_file" "$tmpdir/events.jsonl"; else : >"$tmpdir/events.jsonl"; fi

# ---- active worktrees -----------------------------------------------------------
node -e '
const fs = require("fs");
Expand Down Expand Up @@ -219,6 +237,26 @@ const worktrees = readJson("worktrees.json", []);
const issuesUnavailable = process.env.COCKPIT_ISSUES_UNAVAILABLE === "1";
const prsUnavailable = process.env.COCKPIT_PRS_UNAVAILABLE === "1";

// Live progress events (issue #52): JSONL, one object per line. Tolerate
// blank/malformed lines — skip them, never crash the whole render.
function readEvents() {
let text = "";
try { text = fs.readFileSync(path.join(tmpdir, "events.jsonl"), "utf8"); } catch (e) { return []; }
const events = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed);
// typeof [] === "object" too, so exclude arrays explicitly -- otherwise
// a stray JSON-array line would produce a phantom worker row below.
if (obj && typeof obj === "object" && !Array.isArray(obj)) events.push(obj);
} catch (e) { /* skip malformed line */ }
}
return events;
}
const events = readEvents();

function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
Expand Down Expand Up @@ -256,6 +294,47 @@ function moduleLabelsOf(issue) {
return (issue.labels || []).map((l) => l.name).filter((n) => typeof n === "string" && n.startsWith("module:"));
}

// ---- Live worker progress section (issue #52) -----------------------------
// Derive the CURRENT state per worker keyed by (role, task): keep the LATEST
// event (by file order, i.e. append order) per key. No event log, or an
// empty one, renders a muted "no active workers" placeholder — never a
// crash, matching Phase 1's degrade contract.
function phaseBadge(phase) {
switch (phase) {
case "done": return { cls: "good" };
case "gate-running":
case "reviewing":
case "implementing":
case "scoped": return { cls: "warn" };
default: return { cls: "muted" };
}
}
function renderLiveProgress() {
const latest = new Map(); // "roletask" -> event
for (const ev of events) {
const role = ev.role != null ? String(ev.role) : "";
const task = ev.task != null ? String(ev.task) : "";
const key = role + "" + task;
latest.set(key, ev); // later lines overwrite earlier ones for the same key
}
const workers = [...latest.values()];
let html = `<section id="live"><h2>Live worker progress</h2>`;
if (workers.length === 0) {
html += `<p class="muted">no active workers</p>`;
} else {
html += `<table class="routing"><thead><tr><th>Role</th><th>Task</th><th>Model</th><th>Phase</th><th>Lens</th><th>Updated</th></tr></thead><tbody>`;
for (const w of workers) {
const badge = phaseBadge(w.phase);
html += `<tr><td>${esc(w.role)}</td><td>${esc(w.task)}</td><td><code>${esc(w.model || "(none)")}</code></td>`;
html += `<td><span class="badge ${badge.cls}">${esc(w.phase || "(unknown)")}</span></td>`;
html += `<td>${esc(w.lens || "")}</td><td>${esc(w.ts)}</td></tr>`;
}
html += `</tbody></table>`;
}
html += `</section>`;
return html;
}

// ---- Issues section: group by module label, parse blocking graph per issue ----
function renderIssues() {
if (issuesUnavailable) {
Expand Down Expand Up @@ -413,7 +492,8 @@ const html = `<!doctype html>
</head>
<body>
<h1>Cockpit</h1>
<p class="meta">Generated ${esc(generatedAt)} &middot; read-only Phase 1 snapshot (issue #51) &middot; re-run <code>cockpit.sh</code> to refresh</p>
<p class="meta">Generated ${esc(generatedAt)} &middot; read-only Phase 1 snapshot (issue #51) + Phase 2 live progress (issue #52) &middot; re-run <code>cockpit.sh</code> to refresh</p>
${renderLiveProgress()}
${renderIssues()}
${renderPRs()}
${renderRouting()}
Expand Down
95 changes: 85 additions & 10 deletions .claude/scripts/cockpit.test.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
#!/usr/bin/env bash
# cockpit.test.sh — offline smoke test for cockpit.sh (issue #51).
# cockpit.test.sh — offline smoke test for cockpit.sh (issue #51, extended for
# Phase 2 live progress in issue #52).
#
# 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).
# Runs the generator against controlled FIXTURE issue/PR/events JSON (never
# live gh/network, and never the real event log — 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,
# a live-progress panel deduped to each worker's latest phase) 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
Expand Down Expand Up @@ -72,6 +75,27 @@ cat > "$work/fixtures/prs.json" <<'EOF'
{"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
# Live progress fixture (issue #52): two events for the SAME (role,task) —
# only the LATER phase ("gate-running") must win the dedup — plus a second
# worker ("reviewer"/task 52b) in a different phase, to prove both distinct
# workers render. Also folds in, per the "tests" review lens:
# - a malformed JSON line (unparsable) and a blank line, which readEvents()
# must silently skip rather than crash the whole render;
# - a JSON *array* line ("[1,2,3]") -- typeof [] === "object" too, so this
# guards the Array.isArray() exclusion in readEvents() (a regression here
# would produce a phantom worker row);
# - a third legitimate worker ("52c") whose role contains a <script> tag and
# a quote, to prove the live section runs esc() on every field (a stored-
# XSS regression guard, mirroring the issue-title escaping check below).
cat > "$work/fixtures/events.jsonl" <<'EOF'
{"ts":"2026-01-01T00:00:00Z","role":"implementer","model":"sonnet","task":"52","phase":"implementing","lens":"","detail":""}
{ this is not json

[1,2,3]
{"ts":"2026-01-01T00:05:00Z","role":"implementer","model":"sonnet","task":"52","phase":"gate-running","lens":"","detail":""}
{"ts":"2026-01-01T00:02:00Z","role":"reviewer","model":"opus","task":"52b","phase":"reviewing","lens":"correctness","detail":""}
{"ts":"2026-01-01T00:03:00Z","role":"<script>xss()</script>\"","model":"sonnet","task":"52c","phase":"scoped","lens":"","detail":""}
EOF

html="$work/cockpit.html"
bash "$cockpit" --fixtures "$work/fixtures" "$html" >"$work/stdout.log" 2>"$work/stderr.log"
Expand Down Expand Up @@ -106,6 +130,53 @@ check "adapter path shown in routing section" grep -q 'Adapter: <code>.claude/ga
# Worktrees section (state may vary, so only assert the section exists).
check "worktrees section present" grep -q '<section id="worktrees"' "$html"

# Live worker progress (issue #52): dedup-to-latest-phase + multiple workers.
check "live section present" grep -q '<section id="live"' "$html"
check "implementer/task 52 shows the LATEST phase (gate-running), not the earlier one (implementing)" bash -c '
grep -qF "gate-running" "$1" || exit 1
# the earlier "implementing" phase for the SAME worker must not also appear
# as its own row — count rows for task "52": exactly one, and it must be gate-running.
rows=$(grep -o "<td>implementer</td><td>52</td>[^<]*<td><code>sonnet</code></td><td><span class=\"badge[^>]*>[a-z-]*</span></td>" "$1" | wc -l)
[ "$rows" -eq 1 ]
' _ "$html"
check "reviewer/task 52b renders with role/model/phase/lens" bash -c '
grep -qF "<td>reviewer</td><td>52b</td>" "$1" &&
grep -qF "<code>opus</code>" "$1" &&
grep -qF "badge warn\">reviewing</span>" "$1" &&
grep -qF "<td>correctness</td>" "$1"
' _ "$html"

# Malformed-line tolerance (guards the readEvents() try/catch skip path): the
# fixture above folds in an unparsable line and a blank line among otherwise
# valid ones. Regressing this would blow up the whole dashboard on one bad
# line, silently -- so assert BOTH the process still exits 0 (already checked
# above, re-asserted here for intent) AND a known-good worker row from a
# valid line still renders despite the bad lines sitting right next to it.
check "malformed/blank JSON lines are skipped without crashing the render" [ "$rc" -eq 0 ]
check "a known-good worker row still renders alongside malformed/blank lines" grep -qF '<td>implementer</td><td>52</td>' "$html"

# Array-line guard (correctness lens): typeof [] === "object" too, so a
# top-level JSON array line must NOT produce a phantom worker row. The
# fixture has exactly 3 legitimate workers (52 deduped to its latest phase,
# 52b, 52c) -- assert the live table has exactly 3 data rows, i.e. the
# malformed/blank/array lines contributed zero phantom rows.
check "JSON-array line produces no phantom worker row (exact row count == legitimate workers)" node -e '
const fs = require("fs");
const html = fs.readFileSync(process.argv[1], "utf8");
const m = html.match(/<section id="live">[\s\S]*?<\/section>/);
if (!m) throw new Error("live section not found");
const rows = (m[0].match(/<tr><td>/g) || []).length;
if (rows !== 3) throw new Error("expected 3 live-worker rows, got " + rows);
' "$html"

# Live-section HTML-escaping (stored-XSS regression guard): worker 52c's
# role contains a <script> tag and a quote -- mirror the issue-title escaping
# check above, but for the live-progress panel, which has its own esc() calls.
check "live-section field escaping: raw <script> absent, escaped form present" bash -c '
! grep -qF "<script>xss()</script>" "$1" &&
grep -qF "&lt;script&gt;xss()&lt;/script&gt;&quot;" "$1"
' _ "$html"

# ---------------------------------------------------------------------------
# 3. GATES_FILE override is honored (self-host adapter), still with fixtures
# (no gh/network either way).
Expand All @@ -125,12 +196,16 @@ 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"
# CLAUDE_EVENTS_FILE points at a guaranteed-missing path so this run is fully
# offline/deterministic (never touches the real, gitignored event log) and
# doubles as the "no events file at all" -> "no active workers" assertion.
COCKPIT_GH_BIN="$fake_gh" CLAUDE_EVENTS_FILE="$work/no-such-events.jsonl" 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 '<section id="issues"><h2>Open issues</h2><p class="unavailable">unavailable (gh/network)</p>' "$html_unavail"
check "PRs section shows unavailable placeholder" grep -q '<section id="prs"><h2>Open PRs</h2><p class="unavailable">unavailable (gh/network)</p>' "$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"
check "missing events file renders 'no active workers' placeholder" grep -q '<section id="live"><h2>Live worker progress</h2><p class="muted">no active workers</p>' "$html_unavail"

echo ""
if [ "$fail" -eq 0 ]; then
Expand Down
Loading
Loading