cfn-workbench
Renders a self-contained HTML progress page for a CFN Loop run. Reads scattered data (manifests, VERIFY doc, results JSON, bless ledger, lane reports, test outputs, screenshots, git log) and emits one fresh HTML file per call. The render is idempotent: same inputs produce the same output, runnable at any checkpoint.
This is a REPORTING ARTIFACT. It is never a gate. If wired as a Phase 5 hook, emit WARN only.
Purpose
A standardized, visually readable HTML replacement for the markdown progress
scraps that accumulate during a Loop run. One file, opens from file:// with
no server, no CORS, no external requests. Email it, drop it in a PR description,
or open it locally.
Inputs
Required
--slug <slug>: Run slug. MatchesVERIFY_<slug>.md,<slug>-iteration-*.png,lane-report-<slug>-*.json,test-output-<slug>-*.txt,.VERIFY_<slug>.bless.json,run-plan-<slug>.json, andcfn-events-<slug>.jsonl.
Optional
--out <path>: Output HTML path. Default:<root>/planning/<slug>/workbench_<slug>.html(the plan's own directory).--max-screenshots <N>: Global cap on embedded screenshots. Default:50. When more screenshots exist than the cap, the first N are embedded (in iteration order) and the rest are listed in a single overflow card with file names.--no-screenshots: Text-only mode. Skips all screenshot embedding.--root <dir>: Project root to resolve inputs from. Default: three levels up from this skill (.claude/skills/cfn-workbench/-> repo root). Tests point this at a fixtures dir.
Data sources (all optional except manifests; missing sources become "Data gaps" in the footer)
| Source | Path | Purpose |
|---|---|---|
| Manifests | <root>/.cfn-cache/manifests/cfn-*.json |
Gate timeline + suggestion vote ledger |
| VERIFY doc | <root>/planning/<slug>/VERIFY_<slug>.md |
Markdown AC table (parsed by header name) + embedded manifest with acs[] |
| Results JSON | <root>/planning/<slug>/VERIFY_RESULTS_<slug>.json |
Per-AC status/evidence overrides |
| Bless ledger | <root>/planning/<slug>/.VERIFY_<slug>.bless.json |
structure_changed / predicate_changed / verdict |
| Lane reports | <root>/tmp/lane-report-<slug>-*.json AND /tmp/lane-report-<slug>-*.json |
Per-lane pass rate |
| Test outputs | <root>/tmp/test-output-<slug>-*.txt AND /tmp/test-output-<slug>-*.txt |
Test runner summary line |
| Screenshots | <root>/tests/screenshots/<slug>-iteration-*.png |
Base64-inlined as data:image/png;base64,... |
| Run plan | <root>/planning/<slug>/run-plan-<slug>.json |
Lane roster (id/name/phase) for the Roster section |
| Lanes file | <root>/planning/<slug>/lanes-<slug>.json |
Wave layout for the Transit Map section (nested-first via plan_path, legacy flat <root>/planning/lanes-<slug>.json second) |
| Events feed | /tmp/cfn-events-<slug>.jsonl AND <root>/tmp/cfn-events-<slug>.jsonl |
Live lifecycle events (written by emit-event.sh) for the Events section, and lane status (lane_spawned/lane_landed) in the Roster section |
| Git log | git repo at <root> |
Branch + commit count |
Every planning/<slug>/ row above is resolved through .claude/skills/cfn-megaplan/lib/plan-paths.sh (exposed to the section libs as plan_path <root> <slug> <basename>): the plan's own directory first, the legacy flat <root>/planning/<basename> second. Plans written before per-plan directories therefore still render, and a missing-source gap names the canonical per-plan location so the message doubles as the fix.
Run plan file (planning/<slug>/run-plan-<slug>.json)
Written once per run (Phase 2, before lanes are spawned). Drives the Roster section's headline count and table rows.
{
"slug": "<slug>",
"generated_at": "ISO8601",
"phases": ["Phase 2", "Phase 3"],
"lanes": [
{"id": "frontend", "name": "Frontend UI", "phase": "Phase 2"}
]
}
lanes[].id is required (used to match lane-report files and events).
name/phase are optional and fall back to id / - in the table.
Transit map (planning/<slug>/lanes-<slug>.json)
Optional wave layout for the Transit Map section:
{"slug":"<slug>","waves":[["lane-id","lane-id"],["lane-id"]]}. Each inner
array is one wave, rendered left to right as a column of station nodes.
Fallbacks: lanes missing from the file are appended as a final column; no
lanes file at all falls back to grouping the run plan's phases (one column
per phase), then to a single sorted column. Station status is derived per
lane: blocked when the lane's latest report (by generated_at) has a
non-null blocked_on, else landed / in-flight / pending as in the
Roster. Geometry is trunk-and-connector: each multi-station column gets one
vertical trunk behind its stations plus a wave-colored header (wave N)
above it, adjacent columns are joined by exactly one horizontal connector
on a shared line above the first station row (so connectors never cross),
the column step is capped so few-wave maps center, and every connector
carries an arrowhead in its own stroke color. viewBox is sized to content:
width = min(960, gateX + 70), height = tallest column plus a fixed
loop-back corridor, never total lane count. In-flight lanes draw a train
baked into the SVG at render time
(riding the connector out of its column, or parked just off its station in
the last column); the inline script only animates between baked points and
is skipped under prefers-reduced-motion. A legend row under the SVG names
the four status fills, the train pill, the iterate loop and the gate. The
gate junction sits on the connector line and shows the latest
gate_verdict (pass / fail / unknown); a failed gate followed by a later
loop_started draws a dashed loop-back edge from the gate to the first
station of column 1 and the badge counts iterations from loop_started
events. Missing run plan (and no lanes file) records a data gap and renders
the empty state; the section never blocks the render.
Events feed (cfn-events-<slug>.jsonl)
Appended to by emit-event.sh (see below), one compact JSON line per event:
{"ts":"<ISO8601 UTC>","event":"<type>", ...optional lane/phase/detail}.
Both the project-root copy (<root>/tmp/...) and the runtime copy (/tmp/...)
are read and merged; malformed lines are skipped silently, never rendered raw.
Outputs
- stdout: three-line summary (rendered path, byte size, data-gap count).
- HTML file at
--out(default<root>/planning/<slug>/workbench_<slug>.html).
Usage
# Manual render at any checkpoint
$HOME/.claude/skills/cfn-workbench/render.sh --slug 1.18.x
# Custom output path
$HOME/.claude/skills/cfn-workbench/render.sh --slug 1.18.x --out /tmp/wb.html
# Text-only (no screenshots)
$HOME/.claude/skills/cfn-workbench/render.sh --slug 1.18.x --no-screenshots
# Cap screenshots
$HOME/.claude/skills/cfn-workbench/render.sh --slug 1.18.x --max-screenshots 10
# Point at a fixture root (used by tests)
$HOME/.claude/skills/cfn-workbench/render.sh --slug workbench_9col \
--root .claude/skills/cfn-workbench/tests/fixtures \
--out /tmp/wb.html
Live watch mode (watch.sh)
Re-renders automatically as run data changes, so an already-open page (opened
with --live <secs>) picks up new content via its meta-refresh without a
manual re-run.
# Start a background watcher for a run, polling every 10s (default)
$HOME/.claude/skills/cfn-workbench/watch.sh --slug 1.18.x --interval 10
# Check status / stop it
$HOME/.claude/skills/cfn-workbench/watch.sh --slug 1.18.x --status
$HOME/.claude/skills/cfn-workbench/watch.sh --slug 1.18.x --stop
# Run the poll loop in the foreground (used by tests)
$HOME/.claude/skills/cfn-workbench/watch.sh --slug 1.18.x --foreground
Each tick fingerprints the run's data sources (manifests, VERIFY doc/results,
bless ledger, run plan, lane reports, test outputs, events feed, screenshots);
on a change (or the first tick) it re-renders via render.sh --live <interval>.
A render failure is logged but never kills the watch loop. Idempotent: a
second --slug start with a live pidfile is a no-op. Pidfile:
/tmp/cfn-workbench-watch-<slug>.pid; log: /tmp/cfn-workbench-watch-<slug>.log.
Emitting lifecycle events (emit-event.sh)
Appends one event to the run's live events feed, read by the Events section and the Roster section's lane-status derivation.
$HOME/.claude/skills/cfn-workbench/emit-event.sh --slug 1.18.x --event loop_started
$HOME/.claude/skills/cfn-workbench/emit-event.sh --slug 1.18.x --event lane_spawned --lane frontend --phase "Phase 2"
$HOME/.claude/skills/cfn-workbench/emit-event.sh --slug 1.18.x --event lane_landed --lane frontend --detail "pass_rate=100%"
$HOME/.claude/skills/cfn-workbench/emit-event.sh --slug 1.18.x --event gate_verdict --detail "9/10 pass"
Closed event-type set: loop_started phase_started lane_spawned lane_landed gate_started gate_verdict patch_applied verify_started loop_finished. Unknown
event type or missing --slug/--event exits 2. --lane/--phase/--detail
are optional and only appear in the emitted JSON when passed. --file <path>
overrides the default output path (/tmp/cfn-events-<slug>.jsonl); orchestrator
call sites wrap every call with || true so a bad emit never blocks the loop.
HTML sections
- Header: slug, branch, iteration count, verdict pill, generated_at, and a
live staleness pill (
#wb-staleness) that ticksupdated Ns/Nm agoevery second client-side (JS off falls back to the staticgenerated <time> UTCtext). Pill class flipsstale-ok(< 120s) ->stale-warn(120-599s) ->stale-bad(>= 600s) purely in inline<script>/<style>, no re-render needed for the color to change. - Transit Map: inline SVG of the run as stations (one per lane, grouped
in wave columns), tracks between waves, a gate junction, a loop-back edge
when a gate failed and a new iteration started, and trains for in-flight
lanes (positions baked at render time; the one inline script only animates
between them and honors
prefers-reduced-motion). Missing data is a data gap; the section still renders with an empty-state card. - Garden: the run as a growing plant (
lib/section-garden.sh, Espalier v1). Inline SVG grown by jq from the same_wb_map_run_statederivation the map uses: stem height = iterations survived, one dark scar ring per completed gate cycle, one branch per wave, one leaf per lane (closed bud = pending, warm shimmer = in-flight, open leaf = landed, thorn + curled leaf = blocked), a flower at the apex on gate pass or a wilted head on fail. Static-complete without JS. An optional WebGL overlay (inline fragment shader, LYGIA hash/noise/bayer/palette chunks pasted under MIT attribution) adds tip glow, pollen motes, and a dithered vignette at 30fps; it hides itself when WebGL is unavailable OR the context is lost (a lost context presents a white buffer that would white-wash the plant undermix-blend-mode:screen), and renders one static frame underprefers-reduced-motion. Missing run plan is a data gap; the section still renders with an empty-state card. Contract:docs/WORKBENCH_DESIGN_HANDOFF.mdsection 10. - Iteration Timeline: pass rate, gate verdict, commit count per iteration.
- Lane Roster: headline
N of M lanes landed; table of every lane fromrun-plan-<slug>.jsonwith derived status (landed/in-flight/pending) and, for in-flight lanes, aSince HH:MMfrom theirlane_spawnedevent. Missing run plan is a data gap; the section still renders with an empty-state card. - Events: live feed table (Time, Event, Lane, Phase, Detail) from
cfn-events-<slug>.jsonl, newest first, capped at 30 rows with anN earlier events not shownnote past the cap. Missing events file is a data gap; the section still renders with an empty-state card. - Per-Iteration Detail: lanes (pass rate, passed/failed), test summary,
screenshot grid (click to enlarge via
<details>), gate events. - Acceptance Criteria: id, check, kind, status pill, evidence, reference.
- Vote Ledger: every suggestion id with the latest status (accepted, rejected, open).
- Tech-Debt Ledger: suggestions tagged
tech-debtor matchingcfn:. - Bless Ledger: structure_changed and predicate_changed lists + verdict.
- Footer: command, input count, byte size, data-gap warnings.
Section nav (section_nav in lib/html.sh) jumps to every section above by
anchor id (#sec-map, #sec-garden, #sec-timeline, #sec-roster,
#sec-events, #sec-detail, ...).
Project dashboard (all runs, one tab)
dashboard.sh answers a different question from render.sh: not "how is run
<slug> going" but "what is every session in this project doing right now".
One self-contained HTML page (<root>/planning/workbench-dashboard.html),
one transit line per loop run, sorted by last activity (newest first).
.claude/skills/cfn-workbench/dashboard.sh [--root <dir>] [--out <path>]
[--max-lines <N>] [--live <secs>] [--open]
- Discovery:
planning/*/run-plan-*.json(per-plan dirs) +planning/run-plan-*.json(legacy flat) + plan-less runs whose<root>/tmp/cfn-events-*.jsonlexists. Machine-global/tmp/cfn-events-*streams NEVER discover a run (no project marker); they only enrich a run already discovered, same merge rule as the roster. - Bands: each run renders as one horizontal line (stations left to right in
wave order, gate ring at the end, dashed loop-back below the line on
gate-fail + iterate, train per in-flight lane). Line color cycles a 10-color
palette by run index. Station labels sit ABOVE the line (rel y=-22) with a
per-step character budget (mono 10px, ~6px/char, clamped 4-14 chars; full
name in the station
<title>); wave ticks (W1,W2, ...) get their own row above the labels. No text label on the gate ring (run-header chip +<title>carry it). - Cap: default 12 bands (
--max-linesoverrides); hidden runs are counted in an overflow note. --live <secs>: injects<meta http-equiv="refresh" content="<secs>">so one tab stays current. Same self-containment contract (zero<link>, zero non-data:src/href,html_escapeon all interpolation); exit 0 even on empty state, exit 2 on usage errors. Never a gate.
Self-containment guarantees
The renderer asserts, on every run:
- zero
<link>tags - zero
src="..."that is notsrc="data:..." - zero
href="..."that is nothref="data:..."
Every interpolated value passes through html_escape (&, <, >, ", ').
The test suite additionally injects <script>alert(1)</script> and
"><img src=x> payloads into fixtures and asserts the escaped form appears.
AC table parser (F3)
The markdown AC table is parsed by HEADER NAME, not column index. Headers vary
across the repo (3, 5, 8, 9 columns observed). The parser builds a
name -> index map from the header row, then reads each data row by name. A
missing reference column renders as - in the display. Tests cover both a
5-column fixture (no reference) and a 9-column fixture (with reference).
Dependencies
- bash 4+ (associative arrays)
- jq 1.6+
- base64 (for screenshot inlining)
- git (for branch/commit count; absent git is recorded as a data gap, not an error)
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success, including empty-state (missing sources recorded as data gaps). |
| 2 | Usage error (bad args, missing --slug, non-existent --root). |
Testing
bash $HOME/.claude/skills/cfn-workbench/tests/test-render.sh
bash $HOME/.claude/skills/cfn-workbench/tests/test-live-sections.sh
bash $HOME/.claude/skills/cfn-workbench/tests/test-watch.sh
test-render.sh: 75 assertions cover arg parsing, empty-state, 5/9-column AC
tables, HTML escaping, self-containment, screenshot caps, no-screenshots mode,
data-gap recording, all ledgers, no em dashes, and HTML structure.
test-live-sections.sh: staleness banner (element/thresholds/CSS classes),
Roster (headline count, landed/in-flight/pending derivation, Since, empty
state on missing run plan), Events (row rendering, malformed-line skip, 30-row
cap + overflow note, empty state on missing file), emit-event.sh (exit
codes, optional-key handling, --file override, append-not-overwrite,
default output path), and nav/self-containment/no-em-dash regressions against
the new sections.
test-watch.sh: arg/exit contract, --status/--stop lifecycle, foreground
loop with WORKBENCH_WATCH_MAX_TICKS, fingerprint-triggered re-render vs.
no-op when nothing changed, pidfile lifecycle, idempotent double start.
cfn markers
lib/html.sh: none.lib/section-iteration-detail.sh:cfn: env-string passingonWORKBENCH_SHX_SHOW. Replace with a proper include/exclude dataclass if the iteration block grows more parameters.render.sh:cfn: defensive belt-and-suspenderson the in-render self-containment check. Run on every render so a regression in a section lib cannot silently leak an external src/href.lib/section-roster.sh: emits its own small inline<style>for thelane-landed/lane-inflight/lane-pendingpill classes rather than adding rules todefault_style()inlib/html.sh(out of scope for this lane;lib/html.shedits here are nav-only per the live-transparency contract). Upgrade trigger: fold these 4 rules intodefault_style()next timelib/html.shis touched for an unrelated reason.