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
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:
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:
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 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.
No sdlc-graph-viewer.env in the project root → create it from the example, filled in:
# <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.
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:
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):
# 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).
1---2name: view-run3description: 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.4---56# View Run78## Goal910Turn `docs/graph-runs/<run-id>/state.json` into something a human can *look at*: the run's position on11the graph, what can fire next and why, the full trace, and the ledger — without reading JSON.1213## Use When1415- The user wants to **see** a run rather than read its JSON — *"show me the run"*, *"where is the16 graph now"*, *"visualize the state"*, *"open the run viewer"*, *"what's the current state"*.17- They ask to **watch** a run as it happens, or for a page they can leave open.18- **`sdlc-graph` triggers this at run start** when the plugin is installed — that is the common case,19 and it expects the printed summary relayed verbatim.20- They want a **shareable snapshot** of a finished run to attach to a review or a report.2122## Do Not Use When2324- **Nothing has run yet.** No `docs/graph-runs/*/state.json` means there is nothing to render; say so25 rather than producing an empty page.26- **The question is "is this run sound?"** — that is the graph's own eval suite and the trail in27 `history[]`. This skill renders what the file says; it does not judge it.28- **Something needs changing.** This skill is read-only with respect to the run, always.2930## Inputs3132**None.** One invocation, always scoped to **the current working directory**. Everything else is33discovered — or asked.3435## Workflow3637### 1. Discover runs under the current directory3839```bash40find . -maxdepth 6 -path '*/docs/graph-runs/*/state.json' \41 -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.worktrees/*' 2>/dev/null42```4344Group the hits by **project directory** — the path prefix before `/docs/graph-runs/`.4546- **No hits** → say so and stop. **Do not invent a state file** — a run that never started has47 nothing to view.48- **One project** → proceed with it.49- **More than one project** (a monorepo, nested test repos) → **ask the user which directory**,50 listing each project dir with its newest run id and that run's `status` so the choice is51 informed. Never pick a subproject silently — the newest file across projects is exactly how the52 wrong project's run gets rendered.5354Within the chosen project, use the **newest** `docs/graph-runs/*/state.json` — and *say which one* so a stale pick55is visible. If the user's message named a specific run, prefer that match over newest.5657### 2. Sanity-check it, honestly5859Read the JSON. If it fails to parse, report the parse error and stop. If `schema_version` is absent60or not `5`, still generate — the viewer shows what it can, and an older file degrades to blank61fields rather than wrong ones — but **say which version the file was written against**, and say it62first. The graph itself is stricter: a mismatch there is an unconditional halt with no migration63path, so a viewer quietly rendering a stale file is showing a run the graph would refuse to resume.6465### 3. Generate the snapshot6667The template is `${CLAUDE_PLUGIN_ROOT}/skills/view-run/viewer/run-viewer.html`. It contains an injection slot:6869```js70const INLINE=/*__STATE__*/null/*__END__*/71```7273Replace the `null` between the markers with the state JSON, and write the result **next to the74state file** as `docs/graph-runs/<run-id>/view.html`:7576All paths below are **relative to the chosen project directory** from step 1 — in a monorepo the77state file lives under the subproject, and the snapshot belongs beside it:7879```bash80python3 - "${CLAUDE_PLUGIN_ROOT}/skills/view-run/viewer/run-viewer.html" \81 "<project>/docs/graph-runs/<run-id>/state.json" "<project>/docs/graph-runs/<run-id>/view.html" <<'PY'82import sys, json83tpl, state, out = sys.argv[1], sys.argv[2], sys.argv[3]84s = open(tpl).read()85data = json.dumps(json.load(open(state))) # parse first: invalid JSON must fail HERE86# The slot is inside a <script> block, and HTML ends that block at the first literal `</script>`87# ANYWHERE — inside a JS string included. json.dumps escapes neither `<` nor `/`, so an observation88# reading `fixed the parser </script><img src=x onerror=…>` used to truncate the script, kill boot(),89# and execute the rest as markup. These three escapes are legal inside a JS string literal and90# JSON.parse back to the identical characters, so the snapshot is unchanged and can no longer close91# its own tag. Run data is data: it reaches the DOM through esc(), never as markup.92data = data.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")93marker_a, marker_b = "/*__STATE__*/", "/*__END__*/"94i, j = s.index(marker_a) + len(marker_a), s.index(marker_b)95open(out, "w").write(s[:i] + data + s[j:])96print("wrote", out)97PY98```99100The snapshot is **fully self-contained** — no server, no polling, works from `file://`, safe to101attach or archive. Its stall banner is disabled (a snapshot is a moment, not a feed), and its102sidebar shows exactly one run, the one baked in. Both are expected, not defects.103104### 4. Deliver it105106- Send the generated file to the user (rendered, not as a download card, when the client supports it).107- On macOS, also offer: `open docs/graph-runs/<run-id>/view.html`.108109### 5. Live mode — the default when a server can run110111**Live is preferred; the snapshot is the fallback and the archive format.** Check whether this112project's viewer server is already up (sdlc-graph starts one per project at run start when this113plugin is installed). If not, start the **bundled server — ~120 lines, zero dependencies, shipped114in this plugin**:115116**First, ensure the env file — the configuration must be explicit on disk, never implicit.**1171181. **No `sdlc-graph-viewer.env` in the project root** → create it from the example, **filled in**:119120 ```bash121 # <project>/sdlc-graph-viewer.env — written by view-run, resolved values, no placeholders122 PROJECT_DIR=<absolute project path>123 RUNS_DIR=<absolute path to the run directories> # usually $PROJECT_DIR/docs/graph-runs124 POLL_MS=1000125 HOST=127.0.0.1126 # PORT unset on purpose -> stable per-project derived port127 ```128129 Defaults are fine — the point is that **every value the server will use is written down**, so130 any agent (or human) can read one file and know exactly what is configured. Nothing secret goes131 in it; committing it is safe.1321332. **File already exists** → read it and **verify `PROJECT_DIR` points at this project**. It does →134 use it untouched (the user's `PORT`/`POLL_MS` choices stand). It points elsewhere → say so, and135 start with an inline `PROJECT_DIR=<this project>` override (env beats file) rather than silently136 rewriting the user's file.137138Then start the server from the project root — it auto-loads `./sdlc-graph-viewer.env`:139140```bash141cd "<project>" && node "${CLAUDE_PLUGIN_ROOT}/skills/view-run/server/server.mjs" &142```143144**Configuration is a native `.env` file — nothing is ever edited in code.** The server loads145`$SDLC_GRAPH_VIEWER_ENV`, else `./sdlc-graph-viewer.env` if present, via Node's own `process.loadEnvFile` —146so real environment variables always beat the file (env > file > default):147148```bash149# sdlc-graph-viewer.env150PROJECT_DIR=/abs/path/to/project # where the project lives (default: cwd)151RUNS_DIR=/abs/path/to/docs/graph-runs # where the run DIRECTORIES live (default: $PROJECT_DIR/docs/graph-runs)152PORT=8477 # fixed port (default: stable derived per-project port)153POLL_MS=500 # page poll interval (default: 1000)154HOST=127.0.0.1 # bind address (default: localhost only)155VIEWER_HTML=/abs/path.html # viewer page (default: the bundled viewer/run-viewer.html)156PORT_BASE=8400 PORT_RANGE=400 PORT_RETRIES=20 # derived-port parameters157```158159**Every setting the server uses comes from the environment** — the code holds only the defaults160above; no path, suffix, or address is assumed anywhere else.161162The pages read `POLL_MS` from `GET /api/config` at load — with a 1000ms fallback, so snapshot and163drag-drop modes stay fully serverless. The printed summary names which env file (or `defaults +164env`) is in force.165166**The env file is the contract between agent and server.** The skill fills it at spawn (step167above) precisely so nothing about the running server is implicit: what an agent must provide to168have the HTML + server running is exactly that file's contents, and reading it back is how anyone169verifies the configuration. Later user requests ("set the port to 9000") edit the same file —170copy-and-fill from `server/sdlc-graph-viewer.env.example` for any variable not yet present, never171hand-written from memory.172173It prints the summary — project, **its own per-project port**, home, live-view and API URLs, and174which config is in force. **Relay it as a complete block, never partially** — every one of these175lines, every time a server is started or found already running:176177```178● sdlc-graph-viewer — <project>179 port <port>180 home http://localhost:<port>/ (the run list is the app's own sidebar)181 live view http://localhost:<port>/view?state=/api/runs/<run-id>/state182 runs api http://localhost:<port>/api/runs183 config <path to sdlc-graph-viewer.env in force> · or: defaults (no env file — example at184 ${CLAUDE_PLUGIN_ROOT}/skills/view-run/server/sdlc-graph-viewer.env.example)185 viewer <path to the run-viewer.html being served>186 stop kill <pid>187```188189A summary missing the port, a URL, or the config location is an incomplete delivery of this skill.190191> **If the server prints `cannot read the viewer page` and exits, that is the whole diagnosis** —192> `VIEWER_HTML` (or the bundled default) does not resolve to a readable file. It fails at spawn on193> purpose: a server whose only page is missing used to bind its port anyway and answer `404 not194> found` on `/` and `/view` while every `/api/*` route worked, which reads as a bad URL rather than195> a broken install and sends people hunting the wrong thing. The page polls the state JSON every **1s, forever — even after `DONE`**.196**One server per project, never shared**; a second project gets a second server on its own port.197**Tell the user what is running and how to stop it** whenever you started a server.198199## Output Contract200201- `docs/graph-runs/<run-id>/view.html` — a self-contained snapshot of the named run, delivered to the user.202- 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.203- Any anomaly seen while generating (parse error, missing `schema_version`, `RUNNING` with a quiet204 file) **stated, not smoothed over**.205206## Validation207208Before telling the user it is done:209210- **The page opens standalone, and that is now a command, not a squint:**211 `python3 ${CLAUDE_PLUGIN_ROOT}/skills/view-run/evals/sync/graph_sync.py` — its212 `page-loads-nothing-external` check looks for markup or code that FETCHES at load time213 (`<script src>`, `<link href>`, `@import`, an absolute-URL `fetch`). **Do not "simplify" it to a214 grep for `http`** — a run's own `qa_env.api_base_url` is legitimately `http://localhost:3000`, and215 that grep fails on real state data while catching nothing a snapshot actually loads.216- **The rendered node matches the file — with one deliberate exception.** `state.node` (or `status`217 at a terminal) is what the header and the **run facts** panel show, always. The **board** may218 differ: with several milestones in flight it highlights the **least advanced** one, because a run is219 only as far along as its slowest milestone agent, and the now-card says so in as many words ("least advanced220 of N milestones"). Board ≠ `state.node` **without** that line is a real defect; with it, it is the221 point. If header and file disagree, the injection went wrong and every other panel is suspect.222- **The counters show budget, not just count** — `attempts["TEST:2"] 1/3`, never a bare `1`. A number223 with no bound beside it is the decorative-bound failure one layer out.224- **Display names are everywhere or nowhere, and never in a key** —225 `python3 …/evals/viewer/node_labels.py`. A node's ID is schema (`state.node`, both endpoints of226 every `history[]` entry, the `attempts["GATE_B:<id>"]` keys); `NODE_LABEL` is the name a human227 reads, and `GATE_B` renders as **`ms-final-review`** — the milestone's final review, as opposed to228 `PR_FINAL_REVIEW` on the open PR. Two failure modes, opposite directions: a surface that misses the229 label reads as a *different node* to anyone comparing panels, and a label that reaches a lookup230 (`data-node`, `A.selNode`, any `GRAPH.*[id]`) matches nothing and **throws nothing** — the board231 just highlights empty. The checker counts label sites per renderer rather than testing presence,232 because a five-site panel that keeps one is exactly the half-fixed surface this repo keeps233 producing. **`run facts` stays raw on purpose**: it is the panel that says what the file says.234- **The dimmed next-step buttons still mean what the guard beside them says** —235 `python3 …/evals/viewer/guard_predicates.py`. `APPLIES` is the other thing the viewer owns236 outside the `__GRAPH__` markers, and it is the one the generator therefore never corrected: it237 kept the edge ids of the graph this viewer was forked from, so seven keys named edges that no238 longer existed and `'2'` and `'13'` dimmed the exact **inverse** of their own guard. A reading239 aid that lies is worse than none — it says "this run cannot go there" about a run that can — and240 it stays invisible, because a wrong predicate and a missing one both just draw a button a241 slightly different grey. The checker reads each guard cell out of `GRAPH.EDGES`, runs the real242 predicate under node, and fails when they disagree; a guard whose cell it cannot parse must be243 declared in `UNCHECKED` with a reason.244- **A liveness claim is only ever made in live mode, and only from a real clock.** The milestone agent line may245 say a milestone agent has gone silent **only** when the page is polling — not in a snapshot, not on a246 dropped file, not on a `paused` run, and not on one that is no longer `RUNNING`. `Date.now()` in a247 snapshot is the *viewing* time, so an unguarded marker reports every milestone agent in a shared snapshot dead248 minutes after capture; the stall banner has always been guarded this way and the milestone agent line must249 match it. Two more rules learned the same way: `seen_at` advances at **node boundaries only** — the250 orchestrator's monitor filters heartbeats out — so judge against the journal's own last timestamp251 where the server can supply it, and use a window measured in *tens of minutes* where it cannot, or252 a healthy seven-minute `GATE_A` reads as a corpse. And an absent or unparseable timestamp is253 **unknown, never fresh**: `NaN > threshold` is false, so the input that most suggests a milestone agent died254 mid-write is exactly the one that renders healthiest.255- **The ledger is visible without scrolling past the happy path.** A run with a non-empty256 `skipped_gates[]` is not a clean run, and the page must not read as though it were.257- **Live mode: fetch `/api/runs` and the state endpoint once** and confirm both answer. A summary258 naming a port nothing is listening on is worse than no summary.259- **The guard list still matches the graph** — `python3 ${CLAUDE_PLUGIN_ROOT}/skills/view-run/evals/sync/graph_sync.py`.260 **A non-zero exit is a stale viewer, not a warning.** The graph's own eval suite cannot reach this261 plugin (a plugin may not read above its own root), so this checker reads in the other direction —262 edge ids and endpoints, the node set, the bounds, the six stops, and whether each guard still263 tests the same fields. It prints `skip` and exits 0 when `sdlc-graph` is not installed.264- **A snapshot survives its own run data** —265 `python3 ${CLAUDE_PLUGIN_ROOT}/skills/view-run/evals/safety/snapshot_safety.py`. **Run this after any266 edit to the injector above or to the slot's surroundings.** Snapshot mode is the one place run data267 is inlined into the page's *source* rather than rendered through `esc()`, so a closing script tag268 in an observation ends the block, `boot()` never runs, and the rest of the run is handed to the269 HTML parser as markup — a blank page that also executes. The checker builds a snapshot using **the270 injector extracted from this file**, not a copy of it, so the documented command and the checked271 one cannot drift apart.272- **Both checkers can still fail** — `python3 …/evals/sync/graph_sync_selftest.py` breaks the viewer and273 the spec one way at a time and asserts the drift checker goes red for each. It exists because274 `graph_sync.py` shipped with a guard extractor that read the guard cell *after* its backticks had275 been stripped: it matched nothing at all, left 26 of the 42 guards compared against an empty set,276 and printed `ok` the whole time. A checker nobody checks is the recurring shape here.277278## Guardrails279280- **Read-only with respect to the run.** Never write to `docs/graph-runs/<run-id>/state.json` — the orchestrator is281 the single writer, and this skill racing it would corrupt the one durable record.282- Never regenerate the template's graph data by hand — copy `run-viewer.html` as-is and inject only283 the state. The embedded graph lives between `/*__GRAPH_BEGIN__*/` and284 `/*__GRAPH_END__*/` and is **data only**, which is what makes `graph_sync.py` able to parse it at285 all. Any hand-edit inside those markers — and any function put in there — must be followed by286 running the checker.287- The snapshot may contain the run's file paths and branch names — fine for the repo, but do not288 publish it outside the repo (artifact, gist) unless the user asks.289290## References291292- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/viewer/run-viewer.html` — the template (demo mode when opened293 directly). **The one viewer copy in this plugin** — the bundled site serves this same file.294- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/server/sdlc-graph-viewer.env.example` — the annotated config295 template; copy to `sdlc-graph-viewer.env` beside wherever the server is started.296- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/server/server.mjs` — the server: plain `node:http`, zero297 dependencies, no build. Serves `/` **and** `/view` (both the viewer — its sidebar is the run298 list), `/api/runs`, `/api/runs/<id>/state`, `/api/runs/<id>/progress` — everything `no-store`.299 Per-project port derived from the project path. **`/progress`** returns the tail of each milestone's300 journal: a torn last line is the normal case there (append-only, read while being written), so it301 is counted as `unreadable` rather than allowed to blank a live milestone agent.302- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/fixtures/` — ten state files for checking this303 page by hand: three parallel inflight, a `BLOCKED` halt, a 40-transition run paused at `MERGE`, a304 strategy-D park, a pre-plan empty run, one deliberately corrupt file, `parallel-live-*` (three milestones305 mid-flight, one of them deliberately silent for hours), and306 `hostile-run-data-*` — every field markup- or instruction-shaped, because run data is DATA and307 the tags must render as visible text. **Not shipped inside a snapshot** — they are a test308 corpus, and drag-and-drop is how you open one.309- `${CLAUDE_PLUGIN_ROOT}/skills/view-run/evals/` — `run_all.py` runs the seven deterministic310 suites: `graph_sync.py` (the drift checker), `graph_sync_selftest.py` (proves it can go red),311 `snapshot_safety.py` (a snapshot of hostile run data is inert), `server_boot.py`,312 `viewer/agent_panel.py`, `viewer/node_labels.py` (display names), and313 `viewer/guard_predicates.py`. Described under *Validation*.314 The one WRITER, `sync/sync_graph.py --write`, regenerates the `GRAPH` block from the spec —315 which is why anything the viewer owns rather than copies (`NODE_LABEL`, the `APPLIES` guard316 predicates) is deliberately outside `/*__GRAPH_BEGIN__*/…/*__GRAPH_END__*/`. **That is also the317 hole `guard_predicates.py` closes:** the generator does not reach `APPLIES`, so it kept the edge318 ids of the graph this viewer was forked from — seven keys naming edges that no longer existed,319 and two predicates that dimmed the exact inverse of their own guard.320- `graph/state.md` in the **sdlc-graph plugin** — the schema this viewer renders321 (`schema_version 5`: `in_flight` names the milestones with a live agent, `milestones[].progress`322 is what each one claims, and `stopped.kind` is why a run is not moving).