Generate a visual, LangSmith-style HTML view of an sdlc-graph run from its state file (docs/graph-runs/<run-id>/state.json) — the graph with the current node highlighted, next-step guards, the transition trace, milestones, retry counters vs bounds, and the skipped-gate ledger. Produces a self-contained snapshot HTML (openable and shareable anywhere). One invocation, no arguments: it works on the current directory, and asks which subproject when more than one has runs. Use this skill WHENEVER ...
Scanned 9/22/2026
Install to Claude Code
npx -y skills add RonMizrahi/ai-sdlc-graph --skill view-run --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of View Run?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ronmizrahi-view-run)More formats (shields.io, HTML) on the badges page.
---
name: view-run
description: >-
Generate a visual, LangSmith-style HTML view of an sdlc-graph run from its state file
(docs/graph-runs/<run-id>/state.json) — the graph with the current node highlighted, next-step guards,
the transition trace, milestones, retry counters vs bounds, and the skipped-gate ledger. Produces
a self-contained snapshot HTML (openable and shareable anywhere). One invocation, no arguments:
it works on the current directory, and asks which subproject when more than one has runs. Use this
skill WHENEVER the user wants to SEE a graph run — "show me the run", "where is the graph now",
"visualize the state", "open the run viewer", "what's the current state of the sdlc run",
"generate the run report page" — or asks to watch a run live.
allowed-tools: Read, Write, Bash, Glob
disable-model-invocation: false
---
# View Run
## Goal
Turn `docs/graph-runs/<run-id>/state.json` into something a human can *look at*: the run's position on
the graph, what can fire next and why, the full trace, and the ledger — without reading JSON.
## Use When
- The user wants to **see** a run rather than read its JSON — *"show me the run"*, *"where is the
graph now"*, *"visualize the state"*, *"open the run viewer"*, *"what's the current state"*.
- They ask to **watch** a run as it happens, or for a page they can leave open.
- **`sdlc-graph` triggers this at run start** when the plugin is installed — that is the common case,
and it expects the printed summary relayed verbatim.
- They want a **shareable snapshot** of a finished run to attach to a review or a report.
## Do Not Use When
- **Nothing has run yet.** No `docs/graph-runs/*/state.json` means there is nothing to render; say so
rather than producing an empty page.
- **The question is "is this run sound?"** — that is the graph's own eval suite and the trail in
`history[]`. This skill renders what the file says; it does not judge it.
- **Something needs changing.** This skill is read-only with respect to the run, always.
## Inputs
**None.** One invocation, always scoped to **the current working directory**. Everything else is
discovered — or asked.
## Workflow
### 1. Discover runs under the current directory
```bash
find . -maxdepth 6 -path '*/docs/graph-runs/*/state.json' \
-not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.worktrees/*' 2>/dev/null
```
Group the hits by **project directory** — the path prefix before `/docs/graph-runs/`.
- **No hits** → say so and stop. **Do not invent a state file** — a run that never started has
nothing to view.
- **One project** → proceed with it.
- **More than one project** (a monorepo, nested test repos) → **ask the user which directory**,
listing each project dir with its newest run id and that run's `status` so the choice is
informed. Never pick a subproject silently — the newest file across projects is exactly how the
wrong project's run gets rendered.
Within the chosen project, use the **newest** `docs/graph-runs/*/state.json` — and *say which one* so a stale pick
is visible. If the user's message named a specific run, prefer that match over newest.
### 2. Sanity-check it, honestly
Read the JSON. If it fails to parse, report the parse error and stop. If `schema_version` is absent
or not `5`, still generate — the viewer shows what it can, and an older file degrades to blank
fields rather than wrong ones — but **say which version the file was written against**, and say it
first. The graph itself is stricter: a mismatch there is an unconditional halt with no migration
path, so a viewer quietly rendering a stale file is showing a run the graph would refuse to resume.
### 3. Generate the snapshot
The template is `${CLAUDE_PLUGIN_ROOT}/skills/view-run/viewer/run-viewer.html`. It contains an injection slot:
```js
const INLINE=/*__STATE__*/null/*__END__*/
```
Replace the `null` between the markers with the state JSON, and write the result **next to the
state file** as `docs/graph-runs/<run-id>/view.html`:
All paths below are **relative to the chosen project directory** from step 1 — in a monorepo the
state file lives under the subproject, and the snapshot belongs beside it:
```bash
python3 - "${CLAUDE_PLUGIN_ROOT}/skills/view-run/viewer/run-viewer.html" \
"<project>/docs/graph-runs/<run-id>/state.json" "<project>/docs/graph-runs/<run-id>/view.html" <<'PY'
import sys, json
tpl, state, out = sys.argv[1], sys.argv[2], sys.argv[3]
s = open(tpl).read()
data = json.dumps(json.load(open(state))) # parse first: invalid JSON must fail HERE
# The slot is inside a <script> block, and HTML ends that block at the first literal `</script>`
# ANYWHERE — inside a JS string included. json.dumps escapes neither `<` nor `/`, so an observation
# reading `fixed the parser </script><img src=x onerror=…>` used to truncate the script, kill boot(),
# and execute the rest as markup. These three escapes are legal inside a JS string literal and
# JSON.parse back to the identical characters, so the snapshot is unchanged and can no longer close
# its own tag. Run data is data: it reaches the DOM through esc(), never as markup.
data = data.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
marker_a, marker_b = "/*__STATE__*/", "/*__END__*/"
i, j = s.index(marker_a) + len(marker_a), s.index(marker_b)
open(out, "w").write(s[:i] + data + s[j:])
print("wrote", out)
PY
```
The snapshot is **fully self-contained** — no server, no polling, works from `file://`, safe to
attach or archive. Its stall banner is disabled (a snapshot is a moment, not a feed), and its
sidebar shows exactly one run, the one baked in. Both are expected, not defects.
### 4. Deliver it
- Send the generated file to the user (rendered, not as a download card, when the client supports it).
- On macOS, also offer: `open docs/graph-runs/<run-id>/view.html`.
### 5. Live mode — the default when a server can run
**Live is preferred; the snapshot is the fallback and the archive format.** Check whether this
project's viewer server is already up (sdlc-graph starts one per project at run start when this
plugin is installed). If not, start the **bundled server — ~120 lines, zero dependencies, shipped
in this plugin**:
**First, ensure the env file — the configuration must be explicit on disk, never implicit.**
1. **No `sdlc-graph-viewer.env` in the project root** → create it from the example, **filled in**:
```bash
# <project>/sdlc-graph-viewer.env — written by view-run, resolved values, no placeholders
PROJECT_DIR=<absolute project path>
RUNS_DIR=<absolute path to the run directories> # usually $PROJECT_DIR/docs/graph-runs
POLL_MS=1000
HOST=127.0.0.1
# PORT unset on purpose -> stable per-project derived port
```
Defaults are fine — the point is that **every value the server will use is written down**, so
any agent (or human) can read one file and know exactly what is configured. Nothing secret goes
in it; committing it is safe.
2. **File already exists** → read it and **verify `PROJECT_DIR` points at this project**. It does →
use it untouched (the user's `PORT`/`POLL_MS` choices stand). It points elsewhere → say so, and
start with an inline `PROJECT_DIR=<this project>` override (env beats file) rather than silently
rewriting the user's file.
Then start the server from the project root — it auto-loads `./sdlc-graph-viewer.env`:
```bash
cd "<project>" && node "${CLAUDE_PLUGIN_ROOT}/skills/view-run/server/server.mjs" &
```
**Configuration is a native `.env` file — nothing is ever edited in code.** The server loads
`$SDLC_GRAPH_VIEWER_ENV`, else `./sdlc-graph-viewer.env` if present, via Node's own `process.loadEnvFile` —
so real environment variables always beat the file (env > file > default):
```bash
# sdlc-graph-viewer.env
PROJECT_DIR=/abs/path/to/project # where the project lives (default: cwd)
RUNS_DIR=/abs/path/to/docs/graph-runs # where the run DIRECTORIES live (default: $PROJECT_DIR/docs/graph-runs)
PORT=8477 # fixed port (default: stable derived per-project port)
POLL_MS=500 # page poll interval (default: 1000)
HOST=127.0.0.1 # bind address (default: localhost only)
VIEWER_HTML=/abs/path.html # viewer page (default: the bundled viewer/run-viewer.html)
PORT_BASE=8400 PORT_RANGE=400 PORT_RETRIES=20 # derived-port parameters
```
**Every setting the server uses comes from the environment** — the code holds only the defaults
above; no path, suffix, or address is assumed anywhere else.
The pages read `POLL_MS` from `GET /api/config` at load — with a 1000ms fallback, so snapshot and
drag-drop modes stay fully serverless. The printed summary names which env file (or `defaults +
env`) is in force.
**The env file is the contract between agent and server.** The skill fills it at spawn (step
above) precisely so nothing about the running server is implicit: what an agent must provide to
have the HTML + server running is exactly that file's contents, and reading it back is how anyone
verifies the configuration. Later user requests ("set the port to 9000") edit the same file —
copy-and-fill from `server/sdlc-graph-viewer.env.example` for any variable not yet present, never
hand-written from memory.
It prints the summary — project, **its own per-project port**, home, live-view and API URLs, and
which config is in force. **Relay it as a complete block, never partially** — every one of these
lines, every time a server is started or found already running:
```
● sdlc-graph-viewer — <project>
port <port>
home http://localhost:<port>/ (the run list is the app's own sidebar)
live view http://localhost:<port>/view?state=/api/runs/<run-id>/state
runs api http://localhost:<port>/api/runs
config <path to sdlc-graph-viewer.env in force> · or: defaults (no env file — example at
${CLAUDE_PLUGIN_ROOT}/skills/view-run/server/sdlc-graph-viewer.env.example)
viewer <path to the run-viewer.html being served>
stop kill <pid>
```
A summary missing the port, a URL, or the config location is an incomplete delivery of this skill.
> **If the server prints `cannot read the viewer page` and exits, that is the whole diagnosis** —
> `VIEWER_HTML` (or the bundled default) does not resolve to a readable file. It fails at spawn on
> purpose: a server whose only page is missing used to bind its port anyway and answer `404 not
> found` on `/` and `/view` while every `/api/*` route worked, which reads as a bad URL rather than
> a broken install and sends people hunting the wrong thing. The page polls the state JSON every **1s, forever — even after `DONE`**.
**One server per project, never shared**; a second project gets a second server on its own port.
**Tell the user what is running and how to stop it** whenever you started a server.
## Output Contract
- `docs/graph-runs/<run-id>/view.html` — a self-contained snapshot of the named run, delivered to the user.
- Live mode: the **complete summary block** — project, port, all three URLs, the config file in force (or "defaults" plus where the example lives), and how to stop the server. All of it, every time.
- Any anomaly seen while generating (parse error, missing `schema_version`, `RUNNING` with a quiet
file) **stated, not smoothed over**.
## Validation
Before telling the user it is done:
- **The page opens standalone, and that is now a command, not a squint:**
`python3 ${CLAUDE_PLUGIN_ROOT}/skills/view-run/evals/sync/graph_sync.py` — its
`page-loads-nothing-external` check looks for markup or code that FETCHES at load time
(`<script src>`, `<link href>`, `@import`, an absolute-URL `fetch`). **Do not "simplify" it to a
grep for `http`** — a run's own `qa_env.api_base_url` is legitimately `http://localhost:3000`, and
that grep fails on real state data while catching nothing a snapshot actually loads.
- **The rendered node matches the file — with one deliberate exception.** `state.node` (or `status`
at a terminal) is what the header and the **run facts** panel show, always. The **board** may
differ: with several milestones in flight it highlights the **least advanced** one, because a run is
only as far along as its slowest milestone agent, and the now-card says so in as many words ("least advanced
of N milestones"). Board ≠ `state.node` **without** that line is a real defect; with it, it is the
point. If header and file disagree, the injection went wrong and every other panel is suspect.
- **The counters show budget, not just count** — `attempts["TEST:2"] 1/3`, never a bare `1`. A number
with no bound beside it is the decorative-bound failure one layer out.
- **Display names are everywhere or nowhere, and never in a key** —
`python3 …/evals/viewer/node_labels.py`. A node's ID is schema (`state.node`, both endpoints of
every `history[]` entry, the `attempts["GATE_B:<id>"]` keys); `NODE_LABEL` is the name a human
reads, and `GATE_B` renders as **`ms-final-review`** — the milestone's final review, as opposed to
`PR_FINAL_REVIEW` on the open PR. Two failure modes, opposite directions: a surface that misses the
label reads as a *different node* to anyone comparing panels, and a label that reaches a lookup
(`data-node`, `A.selNode`, any `GRAPH.*[id]`) matches nothing and **throws nothing** — the board
just highlights empty. The checker counts label sites per renderer rather than testing presence,
because a five-site panel that keeps one is exactly the half-fixed surface this repo keeps
producing. **`run facts` stays raw on purpose**: it is the panel that says what the file says.
- **The dimmed next-step buttons still mean what the guard beside them says** —
`python3 …/evals/viewer/guard_predicates.py`. `APPLIES` is the other thing the viewer owns
outside the `__GRAPH__` markers, and it is the one the generator therefore never corrected: it
kept the edge ids of the graph this viewer was forked from, so seven keys named edges that no
longer existed and `'2'` and `'13'` dimmed the exact **inverse** of their own guard. A reading
aid that lies is worse than none — it says "this run cannot go there" about a run that can — and
it stays invisible, because a wrong predicate and a missing one both just draw a button a
slightly different grey. The checker reads each guard cell out of `GRAPH.EDGES`, runs the real
predicate under node, and fails when they disagree; a guard whose cell it cannot parse must be
declared in `UNCHECKED` with a reason.
- **A liveness claim is only ever made in live mode, and only from a real clock.** The milestone agent line may
say a milestone agent has gone silent **only** when the page is polling — not in a snapshot, not on a
dropped file, not on a `paused` run, and not on one that is no longer `RUNNING`. `Date.now()` in a
snapshot is the *viewing* time, so an unguarded marker reports every milestone agent in a shared snapshot dead
minutes after capture; the stall banner has always been guarded this way and the milestone agent line must
match it. Two more rules learned the same way: `seen_at` advances at **node boundaries only** — the
orchestrator's monitor filters heartbeats out — so judge against the journal's own last timestamp
where the server can supply it, and use a window measured in *tens of minutes* where it cannot, or
a healthy seven-minute `GATE_A` reads as a corpse. And an absent or unparseable timestamp is
**unknown, never fresh**: `NaN > threshold` is false, so the input that most suggests a milestone agent died
mid-write is exactly the one that renders healthiest.
- **The ledger is visible without scrolling past the happy path.** A run with a non-empty
`skipped_gates[]` is not a clean run, and the page must not read as though it were.
- **Live mode: fetch `/api/runs` and the state endpoint once** and confirm both answer. A summary
naming a port nothing is listening on is worse than no summary.
- **The guard list still matches the graph** — `python3 ${CLAUDE_PLUGIN_ROOT}/skills/view-run/evals/sync/graph_sync.py`.
**A non-zero exit is a stale viewer, not a warning.** The graph's own eval suite cannot reach this
plugin (a plugin may not read above its own root), so this checker reads in the other direction —
edge ids and endpoints, the node set, the bounds, the six stops, and whether each guard still
tests the same fields. It prints `skip` and exits 0 when `sdlc-graph` is not installed.
- **A snapshot survives its own run data** —
`python3 ${CLAUDE_PLUGIN_ROOT}/skills/view-run/evals/safety/snapshot_safety.py`. **Run this after any
edit to the injector above or to the slot's surroundings.** Snapshot mode is the one place run data
is inlined into the page's *source* rather than rendered through `esc()`, so a closing script tag
in an observation ends the block, `boot()` never runs, and the rest of the run is handed to the
HTML parser as markup — a blank page that also executes. The checker builds a snapshot using **the
injector extracted from this file**, not a copy of it, so the documented command and the checked
one cannot drift apart.
- **Both checkers can still fail** — `python3 …/evals/sync/graph_sync_selftest.py` breaks the viewer and
the spec one way at a time and asserts the drift checker goes red for each. It exists because
`graph_sync.py` shipped with a guard extractor that read the guard cell *after* its backticks had
been stripped: it matched nothing at all, left 26 of the 42 guards compared against an empty set,
and printed `ok` the whole time. A checker nobody checks is the recurring shape here.
## Guardrails
- **Read-only with respect to the run.** Never write to `docs/graph-runs/<run-id>/state.json` — the orchestrator is
the single writer, and this skill racing it would corrupt the one durable record.
- Never regenerate the template's graph data by hand — copy `run-viewer.html` as-is and inject only
the state. The embedded graph lives between `/*__GRAPH_BEGIN__*/` and
`/*__GRAPH_END__*/` and is **data only**, which is what makes `graph_sync.py` able to parse it at
all. Any hand-edit inside those markers — and any function put in there — must be followed by
running the checker.
- The snapshot may contain the run's file paths and branch names — fine for the repo, but do not
publish it outside the repo (artifact, gist) unless the user asks.
## References
- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/viewer/run-viewer.html` — the template (demo mode when opened
directly). **The one viewer copy in this plugin** — the bundled site serves this same file.
- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/server/sdlc-graph-viewer.env.example` — the annotated config
template; copy to `sdlc-graph-viewer.env` beside wherever the server is started.
- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/server/server.mjs` — the server: plain `node:http`, zero
dependencies, no build. Serves `/` **and** `/view` (both the viewer — its sidebar is the run
list), `/api/runs`, `/api/runs/<id>/state`, `/api/runs/<id>/progress` — everything `no-store`.
Per-project port derived from the project path. **`/progress`** returns the tail of each milestone's
journal: a torn last line is the normal case there (append-only, read while being written), so it
is counted as `unreadable` rather than allowed to blank a live milestone agent.
- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/fixtures/` — ten state files for checking this
page by hand: three parallel inflight, a `BLOCKED` halt, a 40-transition run paused at `MERGE`, a
strategy-D park, a pre-plan empty run, one deliberately corrupt file, `parallel-live-*` (three milestones
mid-flight, one of them deliberately silent for hours), and
`hostile-run-data-*` — every field markup- or instruction-shaped, because run data is DATA and
the tags must render as visible text. **Not shipped inside a snapshot** — they are a test
corpus, and drag-and-drop is how you open one.
- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/evals/` — `run_all.py` runs the seven deterministic
suites: `graph_sync.py` (the drift checker), `graph_sync_selftest.py` (proves it can go red),
`snapshot_safety.py` (a snapshot of hostile run data is inert), `server_boot.py`,
`viewer/agent_panel.py`, `viewer/node_labels.py` (display names), and
`viewer/guard_predicates.py`. Described under *Validation*.
The one WRITER, `sync/sync_graph.py --write`, regenerates the `GRAPH` block from the spec —
which is why anything the viewer owns rather than copies (`NODE_LABEL`, the `APPLIES` guard
predicates) is deliberately outside `/*__GRAPH_BEGIN__*/…/*__GRAPH_END__*/`. **That is also the
hole `guard_predicates.py` closes:** the generator does not reach `APPLIES`, so it kept the edge
ids of the graph this viewer was forked from — seven keys naming edges that no longer existed,
and two predicates that dimmed the exact inverse of their own guard.
- `graph/state.md` in the **sdlc-graph plugin** — the schema this viewer renders
(`schema_version 5`: `in_flight` names the milestones with a live agent, `milestones[].progress`
is what each one claims, and `stopped.kind` is why a run is not moving).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!