Phase 3 gave the team a shared knowledge base. Phase 4 makes squad activity visually legible. A standalone Node.js HTTP server at 127.0.0.1:4003 reads ~/.claude/projects/*/memory/squad-metrics.jsonl across every project on the machine and renders a three-column dashboard: project list + filter, verdict trend chart with stats strip and drift placeholder, and a live agent-chat panel via WebSocket. Built on Node.js standard library only — no npm runtime dependencies, no build step. Complements /squad-metrics (CLI, point-in-time queries) with a surface tuned for at-a-glance trend reads. "Has my REVISE rate dropped in the last 30 days?" should be answerable in five seconds without running any command.
-
/dashboard — daemon command with PID file
Starts services/dashboard/server.js as a nohup daemon, writes /tmp/dashboard.pid, polls http://127.0.0.1:4003/ up to five seconds for readiness (50 × 100ms via curl -sf), prints the URL. Already-running check via kill -0 on the PID file. Stop with kill $(cat /tmp/dashboard.pid). Only one instance can bind port 4003; second start exits 1 with a lsof -i :4003 hint.
-
Three-column UI — projects / metrics / agent chat
Left: project list with per-project APPROVE / REVISE / BLOCK badges, keyboard-navigable with
aria-current="true" on selection; separate <select> filter for keyboard-first users. Center: hand-authored SVG stacked bar chart of the last 8 ISO weeks (no chart library), pattern fills (diagonal hatch / vertical lines / dots) differentiate APPROVE / REVISE / BLOCK without relying on color; window toggle 7d / 30d / All filters client-side on already-loaded data; summary stats strip; drift-status panel as a labeled empty state reserved for Phase 4C. Right: WebSocket feed from port 4001 (agent-chat) with exponential backoff reconnect (1s → 30s cap, ±500ms jitter), protocol derived from location.protocol so HTTPS deployments upgrade to wss: automatically.
-
Worktree deduplication
Multiple worktrees of the same project produce separate JSONL files (one per sanitized
cwd). The dashboard strips (--worktrees-.+|-.worktrees-.+)$ from the directory name and groups under the root — both patterns necessary because path sanitization of /.worktrees/ produces -.worktrees-, and projects whose name already contains a hyphen produce --worktrees-. Badges and verdict counts reflect the merged root.
-
docs/squad-metrics-telemetry-schema.md — v1 JSONL contract
Formalizes the JSONL shape for all seven event types (verdict, auto-fix-round, auto-fix-applied, command-invoked, command-completed, fleet-shard-complete, finding-overturned) with per-event detail field shapes. Documents additive-vs-breaking change rules. Implicit-v1 reader rule specifies that records without schema_version are treated as v1 for backfill compatibility with pre-V5.0 records. Consumer list (dashboard /api/metrics, /squad-metrics CLI, future CI tooling) makes the contract's reach explicit.
-
hooks/squad-telemetry.js — schema tagging + APPROVED normalisation
Adds schema_version: "1" to every new record written. Normalizes the legacy APPROVED verdict form to APPROVE at the emission boundary — prevents silent data loss in the aggregator, which only knows APPROVE. Unrecognized schema versions are skipped by consumers and counted in parse_warnings exposed to the UI as a yellow pill in the header when non-zero.
-
SVG chart accessible across every render
role="img" + aria-labelledby="trend-title trend-desc" + focusable="false" on the <svg> wrapper. <title id="trend-title"> and <desc id="trend-desc"> are the first SVG children and are preserved by renderTrend()’s clear-and-rebuild loop — captured before the while (svg.firstChild) wipe and re-appended alongside <defs>. Round 4 caught the case where only <defs> was restored, leaving aria-labelledby referencing orphaned IDs after the first render. An E2E assertion (await expect(page.locator('#trend-title')).toBeAttached()) guards against regression.
-
Pattern fills differentiate verdicts without color
APPROVE is diagonal hatch, REVISE is vertical lines, BLOCK is dots. Color-blind users see the same distinctions as color-sighted users; high-contrast mode users do too. A paired
visually-hidden data table mirrors the SVG chart contents for screen readers — populated via tbody.insertRow() + insertCell().textContent on every render (Round 3 fix replacing the initial tr.innerHTML pattern).
-
Heading landmarks and focus rings
<section aria-labelledby="trend-heading"> + visually-hidden <h2 id="trend-heading">Metrics</h2> gives the metrics column a screen-reader-navigable heading. Selected project row uses aria-current="true" (not orphaned aria-selected — Round 4 caught that aria-selected is only valid within listbox/grid/tab roles). :focus-visible outlines are explicit 2px accent-blue rings on every focusable element — no outline: none anywhere in the stylesheet.
-
Contrast token lifted to WCAG AA
--text-muted moved from #484f58 (2.43:1 on #0d1117, failing AA) to #768390 (5.1:1, passing AA for normal text). Affects every informational label in the UI: panel titles, stat labels, scope notes, empty-state copy, scrollbar thumbs. Stevey caught a hardcoded #484f58 on the empty-state SVG text that missed the token migration mid-Round 5; fixed in-round before the final APPROVE.
-
Media query pairs replicated from agent-chat
prefers-reduced-motion: reduce disables bar entry animation and the WebSocket-connecting pulse. prefers-contrast: more hardens borders to system ButtonText. Both inherit verbatim from the existing services/agent-chat/public/index.html patterns to prevent divergence between the two surfaces.
-
Loopback bind only, DNS rebinding guard
Server binds
127.0.0.1:4003 exclusively — no 0.0.0.0. The Host header must be 127.0.0.1:4003 or localhost:4003; anything else returns 400. Prevents a malicious local webpage from binding a DNS name to 127.0.0.1 and issuing fetches that reach the dashboard from the user's browser.
-
Symlink rejection + path-traversal guard
JSONL paths are resolved via
fs.promises.realpath and checked against metricsRoot + path.sep. A symlink pointing outside the expected base is dropped silently; the oversized-file check (> 5 MB) runs on the resolved path (Round 5 tightening — the original code statted the unresolved path). Object.prototype.hasOwnProperty.call guards every key lookup from parsed JSONL, defending against prototype pollution from malicious telemetry records.
-
Response hardening
All responses set
X-Content-Type-Options: nosniff and Cache-Control: no-store. HTML route additionally sets X-Frame-Options: DENY. Method gate returns 405 with Allow: GET on non-GET methods. No cwd leak: the cwd field from telemetry records is used internally to resolve file paths but never appears in any API response — including nested objects.
-
Round 1 — scope
Phase 4B-1 through 4B-4 deliverables mapped. Server routes defined, UI layout approved, schema contract drafted, command ergonomics agreed.
-
Round 2 — five nits cleared
TOCTOU fix (
createReadStream(resolved) instead of filePath), dead opts.port removed, empty tbody population wired to the SVG render, applied_failed snake_case normalization, four new E2E assertions landed.
-
Round 3 — REVISE, six must-fix items
SVG wrapper was missing
role="img" / aria-labelledby / focusable="false". tr.innerHTML was a latent XSS surface even on machine-generated content. role="listbox" declared without arrow-key navigation (broken ARIA is worse than no ARIA). E2E test 5 asserted the wrong element. :focus-visible { outline: none } was a WCAG 2.4.11 violation. CONTEXT.md had appliedFailed where the code used applied_failed.
-
Round 4 — REVISE, nine items (three blockers)
SVG title/desc destroyed on every render (fix only held in static HTML).
aria-selected on plain <li> without widget role. --text-muted contrast failing WCAG AA at 2.43:1 across every informational label. #parse-warning aria-label silencing the dynamic count. Telemetry APPROVED emission silently dropping in the aggregator. Missing <h2> landmark per plan 4B-2. scanMetrics at 100 lines exceeding the project's 40-line hard limit. Port-conflict test absent (plan 4B-3 success criterion). E2E survival check missing.
-
Round 5 — APPROVE + CONFIRM
Four reviewers independently verified the nine Round 4 fixes against source. Stevey caught one residual
#484f58 on the empty-state SVG text that missed the token migration; fixed in-round. FC flagged an unguarded svg.appendChild(defs) for symmetry; fixed in-round. Nando APPROVE with three Recommended (WebSocket protocol derivation, exponential backoff, plan-doc reconciliation of the 3-button window toggle). Emily CONFIRM after running both test suites green: 9/9 unit, 7/7 E2E. All three Recommended items applied in a follow-up pass before commit.
-
scanMetrics reduced from 100 lines to ~35
The monolith extracted into five named helpers, each under the project's 40-line hard limit: bumpVerdict(target, reviewer, verdict) (reviewer-keyed bucket increment with hasOwnProperty guard), createZeroWeekBucket(week) (fresh bucket factory), applyRecord(record, summary, weekMap) (event-type dispatch), aggregateProjectFile(resolved, summary, weekMap) (readline loop + JSONL parse + parse-warnings accumulation), resolveProjectFile(metricsRoot, dirName, base) (path resolution + size cap with a discriminated return shape: null | {oversize:true} | {resolved}). Main function is now pure orchestration.
-
Functional drift panel
Requires a
/squad-drift --cache output writer. The dashboard ships a labeled empty state ("Drift analysis coming in Phase 4C") with the aria-live structure intact so the panel can be wired later without markup changes.
-
Team-level aggregation view
Reuses Phase 3
/squad-sync shared state. Deferred until the shared-state format stabilizes across more than one team using it.
-
False-positive leaderboard
Reactivates when
finding-overturned event count crosses three per class. Today the live dataset has zero such events — premature to render.
-
90-day window toggle
Reactivates when any project crosses 100 verdict events. Current dataset total is 43 verdict events across all projects; a 90d toggle would display the same bars as All.
-
Out of scope permanently
Auth, mobile / responsive below 1024px, write endpoints, CSV/export, backfilling
schema_version onto legacy records. The implicit-v1 reader rule handles legacy records without requiring a migration pass.