Finding
Three things this pipeline runner refuses to do, and the test that proves each.
A pipeline is a graph: script nodes, agent nodes and human gates, joined by edges that only advance when a verify command exits 0. The runner starts processes, reads exit codes, enforces caps and appends to a log. That is all it does.
- seedscriptexit 0
- edge verify
- bumpscriptretried
- edge verify
- gate1gateexit 3, waits
- a human answered
- finishscriptexit 0
- edge verified
- retry on fail
- human gate
-
It will not run a model inside the runner.
There is no model call in
src/runner.mjsand nothing to make one with:package.jsondeclares 0 runtime dependencies. An agent node is a spawnedclaude -pchild process, checked the way a script is checked: by its exit code, by whether the files it promised now exist, and against a USD cap the runner passes down. The selftest runs a whole graph with zero model calls.- Tests
the runner has no model inside it: zero dependencies, no HTTP client, no model SDKandan agent node is a spawned claude process, checked like a scriptintests/no-model.test.mjs- Command
npm test
-
It will not proceed past a human gate.
A gate node writes its question to disk and the process exits with code 3. Nothing times out into an assumed answer; only
answerfollowed byresumemoves the run forward. A declared cap that trips (stall,budget-while-working,cap:attempts,cap:wall,cap:usd) halts with the same code and a named reason. Exit 3 is never an error. It is the runner refusing to answer for you.- Tests
exit 3: the run stops at the gate and waits for a humanintests/redaction.test.mjs; selftest checksrun pauses at gate (exit 3)andpaused at gate1insrc/runner.mjs- Command
npm test, or the five lines under Run it yourself
-
It will not leak a planted secret.
The event log records a hash of everything a node produces and never the contents, so a replay of a client engagement carries no client material. Gate answers are the one value logged in full, because the decision a human made is the fact an attended replay exists to show. So every string inside an answer is redacted before it is written, and that promise is tested against the real runner, not a mock:
- A GitHub-token-shaped string was planted inside a gate answer. The test asserts it never reaches
events.jsonl, and that the decision itself,{"ok": true}, still does. - Then the redaction was removed and the same scenario was run again. The token came through, which is the test failing for the right reason. That control is now a permanent test: a copy of the runner with the redaction line deleted must let the token survive, or the suite fails.
The limit, stated: the redaction knows 11 token shapes, listed under Receipts, and it only sees what
answer_schemaadmits. A schema that accepts free text can accept anything.- Tests
a secret planted in a gate answer never reaches events.jsonl,the human decision itself still survives, or the replay shows nothingandnegative control: with the redaction removed, the planted token survives, so the first test must fail without itintests/redaction.test.mjs- Command
npm test
- A GitHub-token-shaped string was planted inside a gate answer. The test asserts it never reaches
Replay
The run, as it was recorded
Below is part of the demo replay: the rendered output of a real run of examples/demo/pipeline.graph.json through run, answer, resume, replay and view. Nothing on it was typed by hand. bin/build-site.mjs re-renders it from the committed run data with the same windlass view command and copies it here, and tests/site.test.mjs fails if the copy and examples/demo-replay.html differ by one byte.
What to look for: bump needed 2 attempts, because its outgoing edge's verify command failed the first time and the declared on_fail retry sent the run back to seed. Then gate1 paused the run with exit 3, a human answered, and only then did it resume. 34 events were recorded in all.
caps declared in graph: attended=true, max_wall_minutes=5, max_attempts_per_node=2, max_usd_total=1
| id | kind | final status | attempts | last exit | duration | cost |
|---|---|---|---|---|---|---|
seed | script | done | 2 | 0 (pass) | 142 ms | $0 |
bump | script | done | 2 | 0 (pass) | 135 ms | $0 |
gate1 | gate | done | 1 | 0 (pass) | 0 ms | $0 |
finish | script | done | 1 | 0 (pass) | 90 ms | $0 |
gate1 gate attempt 1 paused — waiting on human2026-09-05T11:31:15.130Z
state.json has count>=2 — confirm to proceed to finish?
gate1-answer.jsonstate.json{
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"type": "boolean"
}
}
}gate1: {"ok":true}gate1gate1 gate attempt 2 0 (pass)2026-09-05T11:31:22.466Z
at 2026-09-05T11:31:22.467Z, written to gate1-answer.json
{
"ok": true
}| edge | exit | duration |
|---|---|---|
gate1->finish | 0 (pass) | 90 ms |
Open the full replay one file, 16 KB, no JavaScript, no network requests; it passes windlass view --check
Run it yourself
Reproduce exit code 3
Node 22 or newer, zero runtime dependencies. The last line exits with code 3.
git clone https://github.com/jamessuuu/windlass
cd windlass
npm install
mkdir try && cp examples/echo/pipeline.graph.json try/
node bin/windlass.mjs run try/pipeline.graph.json --run-id try --allow-nested
It prints GRAPH PAUSED, halt_reason: gate, current: gate1 and writes try/gate1-answer.json.request.json, the question a human is being asked. Read the exit code with echo $?, or $LASTEXITCODE in PowerShell. Nothing continues until you answer:
node bin/windlass.mjs answer try/pipeline.graph.json gate1 '{"ok":true}' --run-id try
node bin/windlass.mjs resume try/pipeline.graph.json --run-id try --allow-nested
node bin/windlass.mjs replay try/pipeline.graph.json --run-id try
node bin/windlass.mjs view try/.graph-runner/try --out try/replay.html
--allow-nested is there because the runner refuses to start inside a live Claude Code session by default: a nested claude -p has been seen to hang. A script-only graph has nothing to spawn, but the runner cannot know that in advance. npm test runs the whole suite, selftest included.
Receipts
Every number on this page, and where it came from
Each value is written by bin/build-site.mjs from the file named beside it, and tests/site.test.mjs derives every one again and fails if the page disagrees. None is typed.
| What | Value | Artifact | Regenerate with |
|---|---|---|---|
| Selftest checks passed | 25 / 25 | src/runner.mjs, the selftest() function | npm run selftest |
| Tests in the suite | 41 (no-model 2, redaction 7, site 14, viewer 18) | tests/*.test.mjs, one test() each | npm test |
| Runtime dependencies | 0 | package.json, "dependencies" | node -e "console.log(Object.keys(require('./package.json').dependencies||{}).length)" |
| Nodes and edges in the demo graph | 4 nodes, 3 edges | examples/demo/pipeline.graph.json | node bin/windlass.mjs validate examples/demo/pipeline.graph.json |
| Human gates in the demo graph | 1 | examples/demo/pipeline.graph.json, nodes with kind "gate" | same as above |
| Events recorded in the demo run | 34 | examples/demo/.graph-runner/demo/events.jsonl, one line each | node bin/windlass.mjs replay examples/demo/pipeline.graph.json --run-id demo |
| Attempts the bump node needed | 2 | examples/demo/.graph-runner/demo/state.json, nodes.bump.attempts | same as above |
| Active runner time | 1056 ms, excluding time paused at the gate | state.json, active_ms | same as above |
| Total cost | $0, measured (script nodes cost nothing) | state.json, usd_total | same as above |
| The answer the human gave | {"ok":true} | events.jsonl, the gate-answer event | same as above |
| Token shapes the redaction knows | 11 | src/runner.mjs, SECRET_PATTERNS | listed below |
| Replay page | 16 KB, one file, no script, no remote resource | examples/demo-replay.html | node bin/windlass.mjs view examples/demo/.graph-runner/demo --out examples/demo-replay.html, then node src/viewer.mjs --check examples/demo-replay.html |
Token shapes the redaction knows
From SECRET_PATTERNS in src/runner.mjs. Each pattern is built by string concatenation so the source never contains a token-shaped literal itself.
- github token
- github oauth
- github fine-grained PAT
- anthropic key
- openai-style key
- aws access key
- slack token
- google api key
- telegram bot token
- private key block
- stripe live key
Limits
What this page does not show
- One fixture graph of 4 nodes, script-only. Nothing here claims the design holds at fifty nodes or with many agent nodes; the runner has not been run at that scale.
- The demo's cost column reads $0 because script nodes cost nothing and the runner measured zero. A field the log never recorded renders as "not recorded", never as 0; that rule has its own tests in
tests/viewer.test.mjs. - The redaction is a list of token shapes, not an understanding of secrets. Keep
answer_schemanarrow. - The replay viewer has been read on desktop and in print. It has not been tested with a screen reader.
- Exercised on Windows with Git Bash and PowerShell. Not yet on Linux or macOS.