From efcea1797cfa0ebb7188a52bc8d968b1c3adc520 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:17:37 +0200 Subject: [PATCH 1/3] feat(cockpit): Phase 2 live per-worker progress events (closes #52) Add log-event.sh, a tiny best-effort observability helper the orchestrator/ implementer/reviewer agents call at each phase transition. It appends one JSONL event (ts, role, model, task, phase, lens, detail) to .claude/state/events.jsonl (gitignored, CLAUDE_EVENTS_FILE-overridable), built with node for safe escaping, and rotates to the last EVENTS_MAX_LINES (default 2000) lines via temp-file + atomic move. Extend cockpit.sh with a "Live worker progress" panel that reads the event log (or /events.jsonl in --fixtures mode), tolerates malformed lines, and dedups to each worker's LATEST phase keyed by (role, task). Degrades to a muted "no active workers" placeholder and never breaks the script's exit-0 contract, consistent with Phase 1. Wire the three agent prompts (orchestrator/implementer/reviewer) to log their phase transitions, explicitly noting this is additive-only observability that must never affect gate enforcement or review consensus. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TdLbxgUQhEVr6XtFGzBpsY --- .claude/agents/implementer.md | 5 ++ .claude/agents/orchestrator.md | 5 ++ .claude/agents/reviewer.md | 5 ++ .claude/scripts/cockpit.sh | 86 +++++++++++++++++++- .claude/scripts/cockpit.test.sh | 52 +++++++++--- .claude/scripts/log-event.sh | 103 ++++++++++++++++++++++++ .claude/scripts/log-event.test.sh | 127 ++++++++++++++++++++++++++++++ 7 files changed, 369 insertions(+), 14 deletions(-) create mode 100755 .claude/scripts/log-event.sh create mode 100644 .claude/scripts/log-event.test.sh diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index 05846a8..98c2877 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -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 --phase --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. diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md index 8b25a2d..570757e 100644 --- a/.claude/agents/orchestrator.md +++ b/.claude/agents/orchestrator.md @@ -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 --phase scoped --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 diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index 35f2428..743dcd9 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -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 --phase reviewing --model --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. diff --git a/.claude/scripts/cockpit.sh b/.claude/scripts/cockpit.sh index e88621e..8e3e36c 100755 --- a/.claude/scripts/cockpit.sh +++ b/.claude/scripts/cockpit.sh @@ -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: @@ -18,6 +21,9 @@ # --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. +# In this mode, the live-progress panel also reads /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 @@ -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"); @@ -219,6 +237,24 @@ 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); + if (obj && typeof obj === "object") events.push(obj); + } catch (e) { /* skip malformed line */ } + } + return events; +} +const events = readEvents(); + function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") @@ -256,6 +292,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 = `

Live worker progress

`; + if (workers.length === 0) { + html += `

no active workers

`; + } else { + html += ``; + for (const w of workers) { + const badge = phaseBadge(w.phase); + html += ``; + html += ``; + html += ``; + } + html += `
RoleTaskModelPhaseLensUpdated
${esc(w.role)}${esc(w.task)}${esc(w.model || "(none)")}${esc(w.phase || "(unknown)")}${esc(w.lens || "")}${esc(w.ts)}
`; + } + html += `
`; + return html; +} + // ---- Issues section: group by module label, parse blocking graph per issue ---- function renderIssues() { if (issuesUnavailable) { @@ -413,7 +490,8 @@ const html = `

Cockpit

-

Generated ${esc(generatedAt)} · read-only Phase 1 snapshot (issue #51) · re-run cockpit.sh to refresh

+

Generated ${esc(generatedAt)} · read-only Phase 1 snapshot (issue #51) + Phase 2 live progress (issue #52) · re-run cockpit.sh to refresh

+${renderLiveProgress()} ${renderIssues()} ${renderPRs()} ${renderRouting()} diff --git a/.claude/scripts/cockpit.test.sh b/.claude/scripts/cockpit.test.sh index 1a7fea0..bae61a4 100755 --- a/.claude/scripts/cockpit.test.sh +++ b/.claude/scripts/cockpit.test.sh @@ -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 @@ -72,6 +75,15 @@ 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. +cat > "$work/fixtures/events.jsonl" <<'EOF' +{"ts":"2026-01-01T00:00:00Z","role":"implementer","model":"sonnet","task":"52","phase":"implementing","lens":"","detail":""} +{"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":""} +EOF html="$work/cockpit.html" bash "$cockpit" --fixtures "$work/fixtures" "$html" >"$work/stdout.log" 2>"$work/stderr.log" @@ -106,6 +118,22 @@ check "adapter path shown in routing section" grep -q 'Adapter: .claude/ga # Worktrees section (state may vary, so only assert the section exists). check "worktrees section present" grep -q '
implementer52[^<]*sonnet]*>[a-z-]*" "$1" | wc -l) + [ "$rows" -eq 1 ] +' _ "$html" +check "reviewer/task 52b renders with role/model/phase/lens" bash -c ' + grep -qF "reviewer52b" "$1" && + grep -qF "opus" "$1" && + grep -qF "badge warn\">reviewing" "$1" && + grep -qF "correctness" "$1" +' _ "$html" + # --------------------------------------------------------------------------- # 3. GATES_FILE override is honored (self-host adapter), still with fixtures # (no gh/network either way). @@ -125,12 +153,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 '

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" +check "missing events file renders 'no active workers' placeholder" grep -q '

Live worker progress

no active workers

' "$html_unavail" echo "" if [ "$fail" -eq 0 ]; then diff --git a/.claude/scripts/log-event.sh b/.claude/scripts/log-event.sh new file mode 100755 index 0000000..693602e --- /dev/null +++ b/.claude/scripts/log-event.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# log-event.sh — append one progress event to the local, unattended-run event +# log (issue #52). Called by the orchestrator/implementer/reviewer agents at +# each phase transition (e.g. "implementing", "gate-running", "reviewing", +# "done") so cockpit.sh can render live per-worker state. This is +# OBSERVABILITY ONLY: it never gates anything, never affects control flow, and +# must never break the caller — always exits 0, even on odd/missing input. +# +# Usage: +# log-event.sh --role R --task T --phase P [--model M] [--lens L] [--detail D] +# +# Appends exactly ONE JSON object per line (JSONL) to the log, schema: +# {"ts":"","role":"...","model":"...","task":"...", +# "phase":"...","lens":"...","detail":"..."} +# Missing optional args (model/lens/detail) serialize as empty strings. The +# JSON line is built with `node` (never hand-rolled string interpolation) so +# values are always safely escaped, including quotes/backslashes/HTML. +# +# Log file: defaults to /.claude/state/events.jsonl. Override with +# CLAUDE_EVENTS_FILE= (used by tests to point at a temp file +# instead of the real, gitignored state dir). The parent directory is created +# if missing. +# +# Rotation/retention: after appending, the file is capped to the last +# ${EVENTS_MAX_LINES:-2000} lines (oldest dropped first), via a temp file + +# atomic `mv`, so unattended multi-day runs never grow the log unbounded and a +# crash mid-rotation never leaves a truncated/corrupt log in place. +set -u + +role="" +model="" +task="" +phase="" +lens="" +detail="" + +while [ $# -gt 0 ]; do + case "${1:-}" in + --role) role="${2:-}" ;; + --model) model="${2:-}" ;; + --task) task="${2:-}" ;; + --phase) phase="${2:-}" ;; + --lens) lens="${2:-}" ;; + --detail) detail="${2:-}" ;; + *) ;; + esac + # Always shift exactly one — a dangling flag with no value (e.g. trailing + # `--role`) must never stall the loop; best-effort parsing, never hang. + shift || break +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || exit 0 +root="$(cd "$script_dir/../.." 2>/dev/null && pwd)" || exit 0 + +events_file="${CLAUDE_EVENTS_FILE:-$root/.claude/state/events.jsonl}" +max_lines="${EVENTS_MAX_LINES:-2000}" + +mkdir -p "$(dirname "$events_file")" 2>/dev/null || exit 0 + +ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || ts="" + +CLAUDE_EVENT_TS="$ts" \ +CLAUDE_EVENT_ROLE="$role" \ +CLAUDE_EVENT_MODEL="$model" \ +CLAUDE_EVENT_TASK="$task" \ +CLAUDE_EVENT_PHASE="$phase" \ +CLAUDE_EVENT_LENS="$lens" \ +CLAUDE_EVENT_DETAIL="$detail" \ +node -e ' + const line = JSON.stringify({ + ts: process.env.CLAUDE_EVENT_TS || "", + role: process.env.CLAUDE_EVENT_ROLE || "", + model: process.env.CLAUDE_EVENT_MODEL || "", + task: process.env.CLAUDE_EVENT_TASK || "", + phase: process.env.CLAUDE_EVENT_PHASE || "", + lens: process.env.CLAUDE_EVENT_LENS || "", + detail: process.env.CLAUDE_EVENT_DETAIL || "", + }); + process.stdout.write(line + "\n"); +' >>"$events_file" 2>/dev/null || exit 0 + +# ---- rotation: cap to the last $max_lines lines, atomically ----------------- +node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const max = parseInt(process.argv[2], 10); + const tmp = process.argv[3]; + try { + if (!Number.isFinite(max) || max <= 0) process.exit(0); + const text = fs.readFileSync(file, "utf8"); + const lines = text.split("\n"); + // drop a single trailing empty string from the final newline, if present + if (lines.length && lines[lines.length - 1] === "") lines.pop(); + if (lines.length <= max) process.exit(0); + const kept = lines.slice(lines.length - max); + fs.writeFileSync(tmp, kept.join("\n") + "\n"); + fs.renameSync(tmp, file); + } catch (e) { + process.exit(0); + } +' "$events_file" "$max_lines" "$events_file.tmp.$$" 2>/dev/null + +exit 0 diff --git a/.claude/scripts/log-event.test.sh b/.claude/scripts/log-event.test.sh new file mode 100644 index 0000000..1a1fc9b --- /dev/null +++ b/.claude/scripts/log-event.test.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# log-event.test.sh — offline smoke test for log-event.sh (issue #52). +# +# Asserts: N appended events yield N valid-JSON lines with the expected +# fields (round-tripped, including safe escaping of quotes/backslashes/HTML), +# that rotation caps the log to the last EVENTS_MAX_LINES lines (most recent +# kept, oldest dropped), and that a weird/missing-arg call is still best +# effort — it never breaks the caller (always exits 0). +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/log-event.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +log_event="$script_dir/log-event.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/log-event-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. Appending N events yields N lines, each a valid JSON object. +# --------------------------------------------------------------------------- +events_file="$work/events.jsonl" +for i in 1 2 3; do + CLAUDE_EVENTS_FILE="$events_file" bash "$log_event" \ + --role implementer --task "task-$i" --phase implementing --model sonnet >/dev/null 2>&1 +done + +check "log file has exactly 3 lines" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 3 ]' _ "$events_file" + +check "every line is valid JSON" node -e ' + const fs = require("fs"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); + for (const l of lines) JSON.parse(l); +' "$events_file" + +# --------------------------------------------------------------------------- +# 2. Required fields present with expected values (round-trip role/task/phase), +# and unsafe characters are safely escaped (proves node-built JSON, not +# hand-rolled interpolation). +# --------------------------------------------------------------------------- +escapes_file="$work/escapes.jsonl" +weird_detail='He said "hi" \ then tags' +CLAUDE_EVENTS_FILE="$escapes_file" bash "$log_event" \ + --role reviewer --task "issue-52" --phase reviewing --model opus --lens correctness \ + --detail "$weird_detail" >/dev/null 2>&1 + +check "required fields round-trip and unsafe chars are safely escaped" node -e ' + const fs = require("fs"); + const line = fs.readFileSync(process.argv[1], "utf8").trim(); + const obj = JSON.parse(line); // throws (fails the check) if not valid JSON + if (obj.role !== "reviewer") throw new Error("role mismatch: " + obj.role); + if (obj.task !== "issue-52") throw new Error("task mismatch: " + obj.task); + if (obj.phase !== "reviewing") throw new Error("phase mismatch: " + obj.phase); + if (obj.model !== "opus") throw new Error("model mismatch: " + obj.model); + if (obj.lens !== "correctness") throw new Error("lens mismatch: " + obj.lens); + if (obj.detail !== process.argv[2]) throw new Error("detail mismatch: " + obj.detail); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(obj.ts)) throw new Error("ts not ISO-8601 UTC: " + obj.ts); +' "$escapes_file" "$weird_detail" + +# Missing optional args (model/lens/detail) serialize as empty strings. +optional_file="$work/optional.jsonl" +CLAUDE_EVENTS_FILE="$optional_file" bash "$log_event" \ + --role orchestrator --task "issue-52" --phase scoped >/dev/null 2>&1 +check "missing optional args serialize as empty strings" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.model !== "" || obj.lens !== "" || obj.detail !== "") { + throw new Error("expected empty optional fields, got " + JSON.stringify(obj)); + } +' "$optional_file" + +# --------------------------------------------------------------------------- +# 3. Rotation: EVENTS_MAX_LINES=5, writing 12 events leaves exactly the LAST 5. +# --------------------------------------------------------------------------- +rotate_file="$work/rotate.jsonl" +for i in $(seq 1 12); do + EVENTS_MAX_LINES=5 CLAUDE_EVENTS_FILE="$rotate_file" bash "$log_event" \ + --role implementer --task "t-$i" --phase implementing >/dev/null 2>&1 +done + +check "rotation caps the file to exactly 5 lines" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 5 ]' _ "$rotate_file" + +check "rotation keeps the LAST 5 events (most recent), in order" node -e ' + const fs = require("fs"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); + const tasks = lines.map((l) => JSON.parse(l).task); + const want = ["t-8", "t-9", "t-10", "t-11", "t-12"]; + if (JSON.stringify(tasks) !== JSON.stringify(want)) { + throw new Error("got " + JSON.stringify(tasks) + " want " + JSON.stringify(want)); + } +' "$rotate_file" + +# --------------------------------------------------------------------------- +# 4. Best-effort: a call with a weird/missing arg still exits 0. +# --------------------------------------------------------------------------- +best_effort_file="$work/best-effort.jsonl" +CLAUDE_EVENTS_FILE="$best_effort_file" bash "$log_event" --this-flag-does-not-exist >/dev/null 2>&1 +check "unknown/weird flag still exits 0" [ "$?" -eq 0 ] + +CLAUDE_EVENTS_FILE="$best_effort_file" bash "$log_event" >/dev/null 2>&1 +check "no args at all still exits 0" [ "$?" -eq 0 ] + +CLAUDE_EVENTS_FILE="$best_effort_file" bash "$log_event" --role >/dev/null 2>&1 +check "dangling flag with no value still exits 0" [ "$?" -eq 0 ] + +echo "" +if [ "$fail" -eq 0 ]; then + echo "log-event.test.sh: PASS ($ok checks)" + exit 0 +else + echo "log-event.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi From b0c8e84b9fe759aa47efe917b139be9580a359a1 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:17:47 +0200 Subject: [PATCH 2/3] fix(cockpit): make log-event.test.sh executable, matching cockpit.test.sh Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TdLbxgUQhEVr6XtFGzBpsY --- .claude/scripts/log-event.test.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .claude/scripts/log-event.test.sh diff --git a/.claude/scripts/log-event.test.sh b/.claude/scripts/log-event.test.sh old mode 100644 new mode 100755 From 5e2e9e6dbeb7f77398dc37b6db961e7e4f002620 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:27:32 +0200 Subject: [PATCH 3/3] test(cockpit): cover malformed-line skip + live-section escaping; guard array lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cockpit.sh readEvents(): tighten the object guard to exclude JSON arrays (typeof [] === "object" too), which previously produced a phantom worker row on a stray array line. - cockpit.test.sh: fold a malformed JSON line, a blank line, a JSON-array line, and an XSS-payload worker (script tag + quote in role) into the events fixture. Assert the render still exits 0, a known-good worker row still renders, the live table has exactly the legitimate worker count (no phantom rows from the array line), and the live section escapes fields (raw \"","model":"sonnet","task":"52c","phase":"scoped","lens":"","detail":""} EOF html="$work/cockpit.html" @@ -134,6 +146,37 @@ check "reviewer/task 52b renders with role/model/phase/lens" bash -c ' grep -qF "correctness" "$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 'implementer52' "$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(/
[\s\S]*?<\/section>/); + if (!m) throw new Error("live section not found"); + const rows = (m[0].match(//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 " "$1" && + grep -qF "<script>xss()</script>"" "$1" +' _ "$html" + # --------------------------------------------------------------------------- # 3. GATES_FILE override is honored (self-host adapter), still with fixtures # (no gh/network either way). diff --git a/.claude/scripts/log-event.test.sh b/.claude/scripts/log-event.test.sh index 1a1fc9b..1611009 100755 --- a/.claude/scripts/log-event.test.sh +++ b/.claude/scripts/log-event.test.sh @@ -104,6 +104,25 @@ check "rotation keeps the LAST 5 events (most recent), in order" node -e ' } ' "$rotate_file" +# Rotation boundary: writing EXACTLY EVENTS_MAX_LINES events must leave +# exactly that many lines -- i.e. rotation must not trigger (or drop +# anything) right at the boundary, only once the count exceeds the cap. +boundary_file="$work/rotate-boundary.jsonl" +for i in $(seq 1 5); do + EVENTS_MAX_LINES=5 CLAUDE_EVENTS_FILE="$boundary_file" bash "$log_event" \ + --role implementer --task "b-$i" --phase implementing >/dev/null 2>&1 +done +check "writing exactly N events with EVENTS_MAX_LINES=N leaves exactly N lines" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 5 ]' _ "$boundary_file" +check "boundary case keeps all N events in order (no spurious drop)" node -e ' + const fs = require("fs"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); + const tasks = lines.map((l) => JSON.parse(l).task); + const want = ["b-1", "b-2", "b-3", "b-4", "b-5"]; + if (JSON.stringify(tasks) !== JSON.stringify(want)) { + throw new Error("got " + JSON.stringify(tasks) + " want " + JSON.stringify(want)); + } +' "$boundary_file" + # --------------------------------------------------------------------------- # 4. Best-effort: a call with a weird/missing arg still exits 0. # ---------------------------------------------------------------------------