This guide covers installing and running the P2Patch pipeline, its experiment profiles and evaluation commands, model-provider configuration, and the P2Patch Lab dashboard. For an overview of the research, see the main README.
Everything you need is in this repository — no submodules, no extra fetch step:
git clone https://github.com/P2Patch/P2patch.git
cd P2patchThe pipeline does not require this checkout to be a git repository — each run's
worktree is exported from the project's own clone, which
security-pipeline fetch creates.
The CVE corpus, the per-project Dockerfiles and both curated POV families are
under benchmark/ — 101 CVEs pinned to their vulnerable commit, with the
per-project build image and both certified POV suites.
Set P2PATCH_BENCHMARK to point at a checkout somewhere else — useful on a run
host that already holds the (tens of GB of) project sources on another volume.
The runs behind the paper's numbers are published separately, because they are
too large for this repository (security_pipeline_runs/ is gitignored for the
same reason). You do not need them to run the pipeline yourself — only to
inspect the exact runs the paper reports.
Download: https://figshare.com/s/e512bce09e2f8a1c05c4
The download is a ~2.4 GB zip, autosec_runs_backup.zip, which unpacks to
security_pipeline_runs.tar.zst together with its own README (tracked here as
docs/figshare-README.md, so the two cannot drift —
edit that copy and re-upload it). It is a snapshot
of 505 completed runs over the 101-CVE corpus across five arms — haiku-4.5
baseline, and hardening on each of haiku-4.5, deepseek-v4-flash,
gpt-5.6-luna and glm-5.2:floor — 101 runs each, over the identical set of
findings.
# unpack the figshare download first — it is a zip around the tarball
unzip autosec_runs_backup.zip # -> security_pipeline_runs.tar.zst + README.md
# then, from the repository root
mkdir -p security_pipeline_runs
zstd -d --long=27 -c /path/to/security_pipeline_runs.tar.zst | tar -xf - -C security_pipeline_runs/
./dashboard/dev.sh # every run is then browsable at http://localhost:8000--long=27 is mandatory on extraction: the archive was written with a 128 MiB
long-range window and zstd refuses windows above 8 MiB without a matching flag.
Needs zstd (brew install zstd / apt install zstd) and ~13 GB free.
The pipe above is used instead of tar -I 'zstd -d --long=27' -xf ... because
-I is GNU tar only: on macOS, BSD tar reads it as an inclusion pattern and
fails with Error inclusion pattern. The pipe works with both tars.
Three things worth knowing before you unpack:
- Each run's
worktree/andbaseline_checkout/were dropped — that is what takes the snapshot from 139 GB to 2.4 GB. Everything read-only still works (run lists, diffs, agent transcripts including fullstream.jsonlreasoning, container logs, verdicts, and every previously computed fixPOV, residual and gate score). The dashboard's three re-evaluation buttons — fixPOV replay, residual replay, verifier retrofit — and the POV-quality judge do not, since they read the patched tree out of the worktree. - Unpacking makes the pipeline skip these findings. The skip check is keyed
by
(finding_id, profile), so a--profile baselineor--profile hardeningsweep would find all 101 alerts already done. Either run an arm the snapshot does not contain (full,baseline_eval,no_verifier), unpack into a second checkout used only for browsing, or pass--rerun. - These runs predate the "ground truth" → "fixPOV" rename, so their results
still sit under
ground_truth/with aground_truth_evalstep. No migration is needed — every reader accepts the legacy names.
Nothing outside security_pipeline_runs/ is required to view them: the
dashboard maps each run back to its CVE through finder_results_filtered/ and
benchmark/, both of which ship with this repository.
- Python 3.10+
- Docker
- Claude Code CLI (
claudebinary) - Git (for worktree creation)
- Node.js 18+ and npm — only for the dashboard, which builds its frontend with Vite
Viewing published runs in the dashboard needs just Python, Node and npm; Docker, Git and the Claude Code CLI are needed only to produce new runs.
# Create virtual environment
python3 -m venv .venv
# Activate it
source .venv/bin/activate
# Install the pipeline (puts `security-pipeline` on PATH).
# It has no third-party dependencies — this only registers the command.
pip install -e .Every security-pipeline ... command below is also runnable without installing,
as python -m security_pipeline ... from the repository root.
The dashboard has its own dependencies (FastAPI, uvicorn, httpx); ./dashboard/dev.sh
installs them for you, or pip install -r dashboard/backend/requirements.txt.
Those are also what the full test suite needs:
python -m unittest discover -s teststests/test_baselines.py is the exception: six of its cases compare our commit
pins against the third-party baselines' own benchmarks, so they need the vendored
clones that baselines/setup.sh creates (vendor/ is gitignored). Without that
checkout they report the base revision as unknown and fail. Run either:
./baselines/setup.sh && python -m unittest discover -s tests # full suite, 342 tests
python -m unittest discover -s tests -p 'test_[!b]*.py' # skip them, 322 tests# Fetch project sources for alerts
security-pipeline fetch
# Run against a single alert
security-pipeline run --alert path/to/alert.json
# Run against a specific CVE
security-pipeline run --cve CVE-2022-26884
# Run all alerts (already-run alerts are skipped automatically)
security-pipeline run --all --limit 10
# Bare `run` defaults to --all
security-pipeline run
# Force re-running alerts that already have a run directory
security-pipeline run --rerun
# Skip specific projects by CVE id, project slug, or alert filename
security-pipeline run --except CVE-2022-26884 CVE-2018-1002202The pipeline can run its agents directly against Z.ai's Anthropic-compatible endpoint instead of Anthropic's:
| Model ID | Launcher label |
|---|---|
glm-5.2 |
GLM 5.2 (Z.ai) |
glm-5.1 |
GLM 5.1 (Z.ai) |
The list is an explicit allowlist (ZAI_MODELS in security_pipeline/zai.py);
exposing another GLM model is two edits — that tuple, plus a MODELS row in
dashboard/backend/live.py.
Credentials live in ~/.claude/settings-zai.json — the same file Z.ai's own
Claude Code setup instructions produce. It is a normal Claude Code settings
file; only its env block is read:
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_AUTH_TOKEN": "your_zai_api_key",
"API_TIMEOUT_MS": "3000000",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.2",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.2",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5.2"
}
}| Key | Required | Meaning |
|---|---|---|
ANTHROPIC_BASE_URL |
yes | Z.ai's Anthropic-compatible endpoint. Without it the request goes to Anthropic under a GLM model name and fails late and confusingly, so a run is rejected at launch if it is missing. |
ANTHROPIC_AUTH_TOKEN |
yes | Your Z.ai API key (ANTHROPIC_API_KEY is accepted too). A placeholder such as your_zai_api_key counts as unset and is rejected at launch. |
API_TIMEOUT_MS |
no | Request timeout. GLM turns can be slow; Z.ai's stock file sets 3000000 (50 min). |
ANTHROPIC_DEFAULT_*_MODEL |
no | Ignored by the pipeline — all three slots are overwritten with the model the run selected, so a GLM run stays single-model. Z.ai's stock file points the haiku slot at a cheap glm-4.5-air, which would otherwise put Claude Code's background turns on a different model than the run is labelled with. |
Get an API key from https://z.ai/manage-apikey/apikey-list. Anything else in
the env block (proxy settings, extra timeouts) is passed through unchanged.
Set P2PATCH_ZAI_SETTINGS to read the config from somewhere else (the
pre-rename AUTOSEC_ZAI_SETTINGS is still honoured):
export P2PATCH_ZAI_SETTINGS=/path/to/settings-zai.jsonpython -m security_pipeline run \
--alert path/to/alert.json \
--model glm-5.2 \
--effort highor pick the model in the dashboard launcher. Routing is per run: the config is
merged into the pipeline-owned per-agent --settings file and the claude
subprocess environment, and ~/.claude/settings.json is never rewritten —
your own interactive Claude Code sessions are unaffected. Any ANTHROPIC_*
variable exported on the host is cleared first, so a machine that exports
ANTHROPIC_API_KEY for normal Claude use cannot half-authenticate a GLM run.
The key is passed only through the process environment and is omitted from
agent_io/<agent>/settings.json, which is persisted and served by the
dashboard.
A missing file, a missing base URL, or an unfilled placeholder key fails at launch, before any Docker build or agent turn.
Note the Z.ai route has no generation API to reconcile billing against, so its reported cost stays Claude Code's Anthropic-tariff estimate. For real billed cost on GLM, use the OpenRouter route below.
The pipeline can run its existing Claude Code agent harness against specific OpenRouter models:
| Model ID | Launcher label |
|---|---|
deepseek/deepseek-v4-flash |
DeepSeek V4 Flash (OpenRouter) |
openai/gpt-5.6-luna |
GPT-5.6 Luna (OpenRouter) |
z-ai/glm-5.2:floor |
GLM 5.2 (OpenRouter, StreamLake FP8) |
z-ai/glm-5.2 |
GLM 5.2 (OpenRouter, default routing) |
The list is an explicit allowlist (OPENROUTER_MODELS in
security_pipeline/openrouter.py), so a sibling slug such as
openai/gpt-5.6-luna-pro is not routed until it is added there and to the
dashboard's MODELS list. Note z-ai/glm-5.2 (OpenRouter) and glm-5.2
(Z.ai direct) are two separate launcher rows for the same model on two
different routes; only the OpenRouter one reports the amount actually billed.
:floor is normally OpenRouter's price-sorted routing variant. P2Patch keeps
the z-ai/glm-5.2:floor launcher id for compatibility, but sends it as
z-ai/glm-5.2@preset/autosec-glm52-streamlake. That preset must allow only the
streamlake/fp8 endpoint and disable fallbacks, so every request in this arm is
attributable to StreamLake's FP8 implementation. Create it once in the same
OpenRouter workspace as the API key:
curl https://openrouter.ai/api/v1/presets/autosec-glm52-streamlake/messages \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "z-ai/glm-5.2",
"provider": {
"only": ["streamlake/fp8"],
"allow_fallbacks": false
},
"messages": []
}'OpenRouter presets are workspace-scoped. A missing preset makes the request
fail instead of falling back to a different endpoint. The plain
z-ai/glm-5.2 row retains OpenRouter's default multi-provider routing, while
the other :floor rows retain ordinary price-sorted routing.
Set an OpenRouter key, then select the model from the dashboard or pass it to the CLI:
export OPENROUTER_API_KEY="sk-or-..."
python -m security_pipeline run \
--alert path/to/alert.json \
--model openai/gpt-5.6-luna \
--effort highhigh and xhigh are the reasoning efforts supported by DeepSeek V4 Flash; the
launcher default (high) is valid for every model on the list. Instead of
exporting the key, it can be stored in ~/.claude/settings-openrouter.json:
{
"env": {
"OPENROUTER_API_KEY": "sk-or-..."
}
}Set P2PATCH_OPENROUTER_SETTINGS to use a different file (the pre-rename
AUTOSEC_OPENROUTER_SETTINGS is still honoured). The integration is
applied only to the spawned claude process: it uses OpenRouter's Anthropic
endpoint (https://openrouter.ai/api), pins every Claude Code model slot to the
selected OpenRouter model, and never rewrites ~/.claude/settings.json. The key
is passed through the process environment and is omitted from persisted run
artifacts.
Claude Code's terminal total_cost_usd applies its own fallback tariff to an
OpenRouter model and is not the amount OpenRouter billed. OpenRouter runs retain
their event stream even without --stream, then resolve each saved gen-* ID
through OpenRouter's generation API. The exact per-agent charge is cached in
agent_io/<agent>/provider_cost.json; the dashboard uses it only when every
generation resolved successfully, otherwise it keeps Claude's estimate and the
incomplete cache explains why reconciliation failed.
Run the exploiter -> patcher -> verifier pipeline.
security-pipeline run [options]
Target (pick one; defaults to --all when none is given):
--alert PATH Path to a finder alert JSON file
--cve CVE_ID CVE ID to run from the alerts directory
--all Run every alert in the alerts directory
Options:
--workspace-root PATH Workspace root (default: cwd)
--alerts-dir PATH Filtered alerts directory (default: finder_results_filtered)
--runs-dir PATH Run output directory (default: security_pipeline_runs)
--profile NAME Experiment arm / stage recipe (default: full).
One of: full, baseline, baseline_eval, no_verifier,
hardening
--max-rounds N Max adversarial hardening rounds in `hardening`
(default 4)
--stages a,b,c Ad-hoc stage override (advanced ablations); takes
precedence over the profile's stage list
--patcher-evidence {full,alert_only}
Whether the patcher is shown the exploit evidence
(default: derived from --profile)
--max-correction-attempts N
Max patcher attempts at each objective patch gate —
POV-after, regressions, and each hardening round
(min 1, default 3). A failing check is fed back to the
patcher and re-checked; 1 = the old one-shot gates that
reject on the first failure. Keep equal across arms in
a baseline-vs-full A/B.
--max-exploit-attempts N Max exploiter attempts at a POV that reproduces on the
unpatched code (min 1, default 3). A POV that does not
reproduce is fed back to the exploiter; 1 = the old
one-shot gate.
--model TEXT Claude alias/name or configured provider model ID
--effort TEXT Claude effort level (default: high)
--stream Stream agent output (stream-json) to
agent_io/<name>/stream.jsonl for live monitoring
--claude-bin TEXT Claude Code executable (default: claude)
--permission-mode TEXT Claude permission mode (default: bypassPermissions)
--agent-timeout-seconds Agent timeout (default: 3600)
--command-timeout-seconds Command timeout (default: 1800)
--dry-run Write context/state only; do not invoke Docker or Claude
--skip-docker-build Assume the computed Docker image already exists
--limit INT Limit number of alerts to run (applied after skipping)
--jobs, -j INT Run this many alerts concurrently (default 1 =
sequential; 0 = auto from Docker memory)
--label TEXT Free-form experiment tag recorded on each run
--except ID [ID ...] Skip alerts matching a CVE id, project slug, or alert
filename (alias: --exclude)
--rerun Re-run alerts even if a prior run directory exists
(alias: --force)
By default, run skips any alert that already has a run directory under
--runs-dir (matched by the alert's stable finding_id), so re-running
security-pipeline run only processes alerts that have not been run yet. A
--dry-run-only directory does not count as a prior run. Pass --rerun to
force re-running, and --except to skip specific projects. Skipped alerts are
reported with "status": "skipped".
The pipeline is a sequence of discrete stages (worktree, docker_build,
exploiter, patcher, converge, harden, verifier). --profile
selects which stages run and whether the patcher is shown the exploit
evidence — the core knob for measuring whether exploitation helps the patcher
produce a better fix.
The converge stage runs the POV-after and regression checks as a
self-correction fix-point — see Self-correction.
| Profile | Stages | Patcher sees the POV? |
|---|---|---|
full (default) |
exploiter → patcher → converge → verifier | yes |
baseline |
worktree → docker_build → patcher → verifier | no — no exploiter or POV |
baseline_eval |
same as full |
no — the exploiter still builds the scorer, but its output is withheld from the patcher |
no_verifier |
full minus the verifier gate |
yes |
hardening |
exploiter → patcher → pov_after → harden → regression → verifier | yes |
hardening is the arm the paper reports. It replaces converge with an explicit
pov_after gate followed by the adversarial harden loop, in which the exploiter
attacks the patched code for a fresh same-vulnerability bypass and every surviving
bypass becomes a permanent repair requirement (--max-rounds, default 4), then
re-checks the regression suite. Four of the snapshot's five arms use it.
baseline produces a patch from the alert alone, but that patch is still
reviewed by the verifier, which needs no POV — it has an alert-only evidence
mode where it judges the diff against the alert instead of against an exploit,
and runs the project's tests itself to check the patch did not break anything.
Withholding review made the arms differ in gating as well as in evidence,
which is not the comparison the study is trying to make. There is deliberately
no separate regression stage here: that gate exists to feed a broken test back
to the patcher, and this arm has no self-correction loop.
baseline_eval runs the exact same objective gates as full and builds a POV
to score with, so it remains the tightest A/B. The skip-if-already-run check is
profile-aware, so you can run baseline over projects that already have a
full run without --rerun, and each run records its profile in
state.json/verdict.json.
security-pipeline run --all --profile full # full pipeline (default)
security-pipeline run --all --profile baseline # alert-only patches
security-pipeline run --all --profile baseline_eval # controlled comparisonRuns recorded before baseline gained the verifier can be brought up to the
same standard without re-running the pipeline:
security-pipeline retrofit --dry-run # list eligible runs and exit
security-pipeline retrofit # verifier over every accepted `baseline` run
security-pipeline retrofit --project <slug> # one project
security-pipeline retrofit --gates regression # a non-default gate (not a baseline stage)This is assess-only. It replays the gates against the patch already on disk
and records whether that patch would have cleared them; it never sends the
patcher back, so the diff, the run's status, and the fixPOV/residual
scores computed from that diff all stay exactly as they were. The outcome lands
in security_pipeline_runs/<run>/gates/results.json and a retrofit_gates
field, and the replayed stages join the run's stage list so the dashboard rail
shows them.
A gate the run already ran natively is skipped rather than overwritten (use
--force to override). A gate the retrofit could not complete — a crashed
agent, a container error — is recorded as errored, never as a failure, and is
retried automatically on the next invocation.
A failing objective check is not a verdict — it goes back to the agent that owns it, with the failure as feedback.
The patcher (--max-correction-attempts, default 3) is sent back whenever a
gate on its patch fails: the POV still reproduces after patching, a regression
test broke, or (in hardening) a round's bypass variant survived the
strengthened fix. This applies to the converge stage in full /
baseline_eval / no_verifier, to the standalone pov_after and regression
stages in hardening, and to each hardening round. Every attempt re-checks the
POV before the tests, so a change made to fix a regression can never silently
re-open the vulnerability, and the regression command set can only grow — a
correcting patcher cannot drop the tests it is judged by.
The exploiter (--max-exploit-attempts, default 3) is sent back when its POV
does not reproduce on the unpatched code (or its output fails a gate), which is
an objectively broken POV rather than a reason to abandon the run. A crashed or
policy-refused agent is not retried.
Setting either budget to 1 restores the old one-shot behavior, where the first
failing check rejects the run.
# Old one-shot gates: reject on the first failing check.
security-pipeline run --all --profile full --max-correction-attempts 1 --max-exploit-attempts 1Because self-correction changes what the agents are measured on ("fixed in N
attempts" vs "fixed in one shot"), keep both budgets equal across arms in a
baseline-vs-full A/B. Each run records them in state.json/verdict.json, and
every retry keeps its own agent-IO folder (patcher_correction_*,
exploiter_retry_*) so the attempt that fixed it is inspectable.
Every profile ends in a non-gating fix_pov_eval step. It replays a
project's curated fixPOVs — real exploits derived from the CVE's
advisory and fix commit, checked in under benchmark/fix_povs/<project_slug>/ and
certified once to reproduce on the unpatched code and be blocked by the official
fix — against the pipeline-patched code. The result is a coverage score
(fraction of real exploit paths the patch blocked), written to
security_pipeline_runs/<run>/fix_pov/results.json and shown in the
dashboard. It is a metric only — it never rejects a run.
# Which local project sources you can author POVs for
security-pipeline fixpov list-projects
# Coverage / certification status across all curated projects
security-pipeline fixpov status
# Certify a project's POVs (builds it, runs each POV before/after the official fix)
security-pipeline fixpov validate --project srikanth-lingala__zip4j_CVE-2018-1002202_1.3.2
# Replay newly authored POVs against this project's already-completed runs.
# Each run is reconstructed from the vulnerable revision + the patch it produced,
# so this still works after the (multi-GB) run worktrees have been pruned.
security-pipeline fixpov replay --project srikanth-lingala__zip4j_CVE-2018-1002202_1.3.2
# ...or score the runs' preserved worktrees, the way replay used to
security-pipeline fixpov replay --project srikanth-lingala__zip4j_CVE-2018-1002202_1.3.2 --from-worktree
# Skip the fixPOV eval stage for a run
security-pipeline run --alert path/to/alert.json --no-fix-pov-evalTo author POVs for a new project, follow benchmark/fix_povs/GENERATING_POVS.md
(hand it to a coding agent, one project at a time). See
benchmark/fix_povs/README.md for the contract and layout.
Fetch IRIS project sources from GitHub based on alerts.
security-pipeline fetch [options]
Options:
--workspace-root PATH Workspace root (default: cwd)
--alerts-dir PATH Filtered alerts directory (default: finder_results_filtered)
--projects-dir PATH Output directory for cloned projects
--limit INT Limit number of projects to fetch
--timeout-seconds INT Git clone timeout (default: 300)
P2Patch/
├── security_pipeline/
│ ├── __init__.py # Package metadata
│ ├── __main__.py # Entry point
│ ├── cli.py # CLI argument parsing
│ ├── pipeline.py # Core pipeline orchestration (stage runner)
│ ├── stages.py # Pipeline stages + experiment profiles
│ ├── claude_agents.py # Claude Code agent runner
│ ├── docker_runner.py # Docker build and command execution
│ ├── gates.py # Output validation gates
│ ├── logging_io.py # File I/O utilities
│ ├── metadata.py # Alert and project metadata loading
│ ├── models.py # Data classes
│ ├── workspace.py # Git worktree and diff utilities
│ ├── prompts/ # Agent system prompts
│ │ ├── exploiter.md
│ │ ├── patcher.md
│ │ └── verifier.md
│ └── schemas/ # Agent output JSON schemas
│ ├── exploiter.json
│ ├── patcher.json
│ └── verifier.json
├── tests/
│ └── test_security_pipeline.py
├── benchmark/ # CVE corpus, Dockerfiles, POV suites
│ ├── dataset/
│ │ ├── project_info.csv
│ │ ├── build_info.csv
│ │ ├── fix_info.csv
│ │ ├── project-sources/ (gitignored)
│ │ └── Dockerfiles/
│ ├── fix_povs/ # "did the patch close the CVE?" suites
│ └── residual_povs/ # "did the patch beat upstream?" suites
├── finder_results_filtered/ # Security alert JSON files
├── security_pipeline_runs/ # Pipeline run outputs
└── dashboard/ # P2Patch Lab web UI (FastAPI + React)
- Alert Loading — Parses a security alert JSON and resolves project metadata from CSV files
- Worktree Creation — Creates an isolated git worktree for the run
- Docker Build — Builds a project-specific Docker image
- Exploiter Agent — Claude creates a POV test that reproduces the vulnerability
- POV Validation — Runs the POV in Docker to confirm it works before patching. A POV that does not reproduce goes back to the exploiter (see Self-correction)
- Patcher Agent — Claude patches the vulnerability in the worktree
- Converge — Verifies the patch holds: the POV no longer reproduces and the regression suite passes. A failing check is fed back to the patcher and re-checked instead of rejecting the run (see Self-correction)
- Verifier Agent — Claude reviews the full context and accepts/rejects the patch
This is the full profile; other profiles run a subset of these stages — see Experiment profiles.
Each run produces a directory under security_pipeline_runs/ containing:
state.json— Pipeline state and step historyverdict.json— Final acceptance/rejection verdictcontext.json— Full context passed to agentsgit/— Diff files (full, patch-only, POV)docker/— Docker command logsagent_io/— Agent inputs, outputs, and raw stdout/stderr
The 505 runs behind the paper's numbers are published as a separate download — see The paper's pipeline runs.
Generate context and state without running Docker or Claude:
security-pipeline run --alert alert.json --dry-rundashboard/ is a web UI for browsing, evaluating, launching, and comparing
pipeline runs. It reads the on-disk run artifacts (never mutates them), maps
each redacted run back to its real CVE and official fix, and can launch runs
live.
- Browse & evaluate — per-run signal-rail, agent I/O, diffs, container logs, fixPOV panel, and LLM-judge scorecards for patch and POV quality (with optional ensemble judging).
- Live runs — arm a finding and watch each stage stream in real time (token-level agent activity), with profile and model selectors. The workflow rail reflects the selected profile's stages.
- Batch runs — select multiple findings and queue them; a scheduler runs them up to a chosen concurrency (default 1), starting the next as each finishes. Stop queued or running launches.
- Compare — group runs by project and put two side by side (profile, model, status, patch score, cost, and patch diffs) — built for baseline-vs-full A/B.
- Manage & share — stop an active run or delete a run directory from the
UI; download a single run or a selected set as a portable ZIP. Extract the
ZIP in
security_pipeline_runs/to restore its run folders.
Run it (build the UI + serve everything on http://localhost:8000):
./dashboard/dev.sh # or, equivalently: bash dashboard/dev.shIt builds the frontend with npm, creates .venv if absent, installs the backend
requirements, and serves on port 8000 (PORT=... ./dashboard/dev.sh to change it).
Viewing runs does not require pip install -e ..
For hot-reload development and the full API reference, see dashboard/README.md.
