A per-task dynamic workflow engine with a ticket tracker on top — both layers of the Run of Show design proposal (OPS-151 → OPS-152, rev 2):
- the execution layer (dispatcher replacement): every task carries a small, validated DAG — the flow spec — interpreted by a generic, event-sourced stage manager. Stages, iteration budgets, parallel fan-out, join rules and human gates are data, not dispatcher source.
- the tracker layer (Plane replacement): a real ticket entity on the
#N / #N.xtree, cross-taskneedsdependencies with promote-on-done, the eight-value state model (backlog / todo / design / design_review / in_progress / in_review / done / cancelled) projected live from the run record, an HTTP API + CLI to drive it all, and a real agent-spawn adapter that runs actual worker processes in git worktrees.
The engine holds the design's two reliability invariants:
- Every abnormal event reaches a human surface within sixty seconds — the Herald classifies every run event trace/status/page and delivers synchronously after the fsync'd append.
- Every worker can prove where it is before it does anything — each seat
receives a hashed stage manifest (branch, base sha, position in the plan,
remaining budget) and must complete the
worker_readyhandshake or refuse.
Four node kinds, a closed set. No expressions, no conditionals beyond pass/fail edges:
| kind | what it is |
|---|---|
worker |
one cast seat in the task worktree; onPass/onFail edges, maxVisits re-entry cap, same-visit retries |
gate |
a decision point: by: "human" (parked, zero tokens) or a cheap model check with a rubric; maxFails bounces |
fanout |
N parallel arms of this task, each in an isolated worktree forked from one captured base |
join |
how arms become one line again: all-merge, first, quorum, or a judge cast seat |
Every loop is bounded by construction: all caps are finite positive integers
at the schema layer — Infinity (and "alarms: off") is simply not
representable — and the linter refuses unknown edges, unreachable nodes,
orphaned/shared joins, arm escape, insane budgets and blocked casts.
src/
spec/types.ts the FlowSpec data model (§3.2)
spec/schema.ts zod validation at filing time
spec/lint.ts the Flow Linter (§3.3) — pure, no I/O
run/events.ts the closed twenty-event run vocabulary (§4.4)
run/fold.ts FlowRun = fold(event log) — pure, deterministic (§5.1)
run/store.ts the Run Store (§4.5): append-only fsync'd JSONL + head cache
engine/ports.ts the three internal contracts (§4.2) + world-facing ports
engine/manifest.ts the stage manifest + readiness handshake (§6.4)
engine/scheduler.ts the Seat Scheduler: caps, FIFO, (run,node,visit,arm) dedup
engine/sentinel.ts leases, quiet→stall, overrun, ready deadline (§6.1)
engine/herald.ts trace/status/page routing, ≤60s invariant (§6.3)
engine/stageManager.ts the interpreter: §4.3's append→fold→decide→gate→act→announce
adapters/simulated.ts hand-cranked workers + fake worktrees (tests, simulations)
adapters/git.ts real git worktrees, branches and merges
adapters/process.ts REAL workers: child processes + the JSONL driver protocol
authoring/builder.ts the fluent flow() builder used by scripts
authoring/script.ts .js/.mjs/.cjs flow scripts (+ .b routing) and hooks
authoring/basm.ts Bored Assembly: .b files assembled to linted specs
tracker/ticket.ts the ticket entity: #N/#N.x refs, needs, states (§1.1/§1.5)
tracker/store.ts tasks.json — the local, locked, versioned registry
tracker/tracker.ts filing, staffing, promote-on-done, state projection
server.ts the HTTP API over the tracker (+ static SPA + read-only)
presets.ts Appendix A: today's lifecycles as specs, plus §8 shapes
cli.ts lint/status/journal/events + serve/board/file/nudge/gate/…
web/ the board UI — React + Tailwind + shadcn, built by esbuild
examples/ §7 — the three simulated deployments, runnable
test/ 150+ tests, unit through end-to-end
Storage layout under the engine root (default ~/.bored for the CLI):
runs/<ref>.jsonl (the source of truth), runs/<ref>.head.json (cache only),
journal/<ref>.log, spend.jsonl.
# boot the board over a repo; every seat spawns your worker command in its
# own git worktree, speaking the JSONL driver protocol (adapters/process.ts)
npx bored serve --root ~/.bored --repo /path/to/repo \
--worker "node my-worker.cjs" --port 7770
# file work over the wire (auto-staffs when its needs are met)
npx bored file --title "notification prefs API" --body "…" --criteria "stores prefs"
npx bored file --title "prefs UI" --needs "#1" # promotes when #1 is done
npx bored board
npx bored gate "#1" --node design_review --verdict pass
npx bored nudge "#1" "prefer the small fix" --node implement
# apply the steer to the *current* in-flight work now (abort+re-staff):
npx bored nudge "#1" "stop — requirements changed" --node implement --interruptA worker is any executable: it gets BORED_MANIFEST (the §6.4 stage
manifest, written into .bored/stage-manifest.json in its worktree) and
BORED_BRIEF, proves where it is (worker_ready with the manifest hash +
the branch/sha it observes via git), emits progress events on stdout, and
finishes with a done-signal. Each seat has an enforced execution deadline
(the worker envelope, or serve --execution-timeout-ms), and timeout/abort
kills its entire detached process group after first making an empty-or-real WIP
commit. A process that dies silently is alarmed, retried, and parked by the
engine — never lost.
The same server can serve a single-page board over the tracker — columns by the
real TicketState enum with #N.x children nested under their parent, a ticket
drawer showing the full ticket plus the journal (the human narrative) and the
raw event log, and every operator verb (staff, nudge, pause, resume, gate,
cancel) wired to the HTTP API. The board and the open ticket poll on a short
interval — a running ticket progresses on screen without a refresh — and polling
pauses when the tab is hidden.
npm run build:web # esbuild + the Tailwind CLI → web/dist
# (Vite/vitest/Tailwind choke on a "#" in the repo
# path; esbuild resolves by filesystem path and
# builds fine — hence the hand-rolled web/build.mjs)
npx bored serve --repo /path/to/repo --worker "…" # UI at http://127.0.0.1:7770React + Tailwind v4 + shadcn/ui primitives, lucide-react icons, self-hosted
Geist/Geist Mono variable fonts, one deep-iris accent on a neutral zinc base,
dark-first theming from tokens (no raw hex). Columns sit in soft lanes over a
faint dot-grid canvas; motion is CSS-only and transform/opacity-scoped —
staggered card entrances, shimmer skeletons, a timeline journal — with
prefers-reduced-motion honoured. The build is self-contained: npm run build, npm run typecheck and npm test are the unchanged tsc/vitest
pipeline (the SPA lives outside src/ and never touches them).
Shareable links & read-only mode. A browser navigating to /tickets/%2312
gets the rendered ticket; the identical path still returns JSON to programmatic
callers (content negotiation on Accept), so a link pasted into chat opens the
ticket. serve --read-only (or BORED_READONLY=1) serves the board and ticket
views but answers every mutating route 403 and hides the action affordances in
the UI — the surface for a public view behind a tunnel.
To eyeball it without real workers, tsx scripts/seed-demo.mts boots a demo
board across every column; tsx scripts/shoot.mts renders the screenshots in
docs/screenshots/.
JSON specs and presets still work, but flows can be scripted: a .js/
.mjs/.cjs file default-exports a function that receives the ticket and
returns the flow — so the shape is computed for the task at hand instead of
one rigid structure for everything:
// adaptive.flow.mjs (full version in examples/flows/)
export default ({ ticket, flow, presets }) => {
if (/hotfix/i.test(ticket.title)) return presets.onePass();
const areas = /areas:\s*(.+)/i.exec(ticket.body ?? "")?.[1]?.split(",");
if (areas) {
return flow()
.fanout("split", {
arms: areas.map((a) => ({ cast: { harness: "pi", effort: "high" }, brief: `own ${a.trim()}` })),
join: "land",
})
.join("land", { strategy: "all-merge", onPass: "review" })
.gate("review", { by: { cast: { harness: "claude", model: "claude-sonnet-5" }, rubric: "criteria-vs-diff" }, onPass: "done" })
.budget({ usd: 40 })
.build();
}
return presets.reviewedLifecycle();
};
// hooks: a scripted concierge with operator authority, per task
export const hooks = {
async onEvent({ event, actions }) {
if (event.type === "parked" && event.reason === "max_visits_exhausted") {
await actions.resume({ extraVisits: 1 }); // one free extension, then a human owns it
}
},
};File it with flowScript (API), --flow-script (CLI), or
tracker.file({..., flowScript}). Scripting controls shape and management —
never the execution guarantees: the returned flow goes through the full
linter (bounded loops, closed node algebra), hooks run off the event path
with the same verbs a human concierge has, and budgets/caps still fence
everything, so scripted flows should set a budget. Hooks are reloaded from
the stored script path on recovery; hook errors are journalled, never fatal.
The fluent flow() builder is also exported for TypeScript callers.
The third authoring surface is assembly: a .b file is a line-oriented
program — labels, mnemonics, ;; comments — that assembles to the same
linted FlowSpec as JSON and .mjs scripts. The full language reference is
docs/basm.md.
;; deploy.b — agents in the first round
init deploy
depends on #1 ;; cross-ticket needs, filed with the ticket
budget usd 40
cast builder = pi effort=high ;; named casts, defined before use
split:
fanout into=land
arm builder "own the backend half"
arm builder "own the frontend half"
land:
join all-merge
onpass done
onfail park
endnpx bored lint examples/flows/deploy.b # assemble + lint
npx bored asm examples/flows/deploy.b # print the compiled JSON spec
npx bored file --title "ship prefs" --flow-script examples/flows/deploy.b--flow-script routes .b files to the assembler: header lines cover
entry, budget, supervise, statemap and depends on (which become
real cross-ticket needs); node blocks declare worker / gate / fanout
/ join with onpass/onfail edges and the same caps as the builder.
Assembly carries no JavaScript and therefore no hooks — for a scripted
concierge use .mjs. Everything the linter refuses in JSON (unknown edges,
unbounded cycles, insane budgets) is refused at assembly time, with line
numbers.
import {
StageManager, GitWorktreeSpawnAdapter, GitMergeProvider, systemClock,
} from "bored";
const adapter = new GitWorktreeSpawnAdapter(repoRoot, systemClock);
const engine = new StageManager(root, adapter, new GitMergeProvider(adapter), mySink, {
clock: systemClock,
maxWorkers: 8,
ownerDM: "dm:ro",
});
adapter.connect((ref, seatKey, ev) => engine.deliverWorkerEvent(ref, seatKey, ev));
await engine.open("#42.1", spec, { originChannel: "discord:ops", body, criteria });
// on a ≤30s cadence:
await engine.tick();
// on boot:
await engine.recoverAll();
// operator verbs:
engine.nudge("#42.1", "prefer the small fix", "implement"); // enqueue
await engine.interrupt("#42.1", "requirements changed", "implement"); // apply now
await engine.decideHumanGate("#42.1", "design_review", "pass");
await engine.pause("#42.1"); await engine.resume("#42.1", { extraVisits: 1 });The SpawnAdapter is the port a real deployment fills with its harness
drivers (claude/codex/pi): provision a worktree, spawn a worker against a
SeatRequest (brief + manifest + envelope), pipe its driver events back into
deliverWorkerEvent. The shipped SimulatedSpawnAdapter speaks the same
event vocabulary with hand-cranked workers; GitWorktreeSpawnAdapter runs
the same seats over real worktrees/branches/merges.
Three deployments run stage-by-stage with real specs, event logs and worker manifests — as runnable scripts and as asserted tests (the same scenario code backs both, so the demo can't drift from the proof):
npm run deploy:one-pass # §7.1 a one-pass README fix: 1 seat, 7 events, $0.35
npm run deploy:fanout # §7.2 backend+frontend fan-out → all-merge → review
npm run deploy:recovery # §7.3 stall → abort+retry → engine reboot re-staff → rework → done
npm run deploy:all
npm test # 125 tests across 14 files
npm run typecheck
Coverage highlights:
- Linter: every refusal class in §3.3, plus proof the shipped presets validate.
- Run Store: crash-truncation tolerance, loud mid-file corruption, stale/corrupt head rebuild.
- Fold: determinism, prefix consistency, full bookkeeping.
- Worker/gate/fanout/join semantics: pass/fail edges, retry ladders, cap parks and resume grants, human + model gates, all four join strategies, the silent arm, the half-merged join, fail-fast sibling abort.
- Budgets: usd/wall-clock ceilings pre-spawn and mid-run, per-run
maxConcurrentFIFO queueing, globalmax_workersacross runs, dedup. - Sentinel: the three clocks + hard backstop; a slow worker never dies,
a dead one always does;
progress_notedrollups at most 1/min/seat. - Handshake: manifest hash determinism, branch/sha mismatch refusal + re-staff.
- Herald: full classification table; the ≤60s invariant audited across a failing run.
- Crash recovery (property test): a reviewed run and a fanout run are killed after every event of their logs; the rebooted engine drives each to the same outcome with no duplicated verdicts.
- Real git integration: worktree provisioning, base capture, both-files merge, and a genuine conflict parking the half-merged join.
- Appendix A equivalence: OPS reviewed lifecycle, one-pass, and INT design
flow walk the same stages with the same caps as today's constants
(
MAX_REWORK_CYCLES,MAX_IMPLEMENT_RETRIES,MAX_DESIGN_CYCLES). - Tracker layer: ref allocation and the one-level tree, needs validation, promote-on-done (single, multiple, and autoStaff:false needs), state projection across all three lifecycles incl. the INT state_map, tracker recovery with missed promotions, and the versioned/locked registry.
- Real workers: an actual node child process handshakes against real git state, commits real output and completes the run; silent exits alarm and re-staff; refusals walk the refusal path; nudges arrive over stdin.
- HTTP API: the full lifecycle driven over the wire (file, staff, nudge, gate verdicts, pause/resume, cancel, deps promotion) with 400/404/409 error mapping.
npx bored lint spec.json|flow.b [--max-workers 8]
npx bored asm flow.b # compile a .b program to its JSON spec
npx bored status "#42.1" --root ~/.bored
npx bored journal "#42.1" --tail 20
# One clean JSON object per line: ticketRef, runId, timestamp, type,
# reason, and the event payload. Safe to pipe to jq or an AI operator.
npx bored events "#42.1" --tail 20
# The same structured feed over HTTP:
curl -s 'http://127.0.0.1:7770/tickets/%2342.1/events?tail=20'
Built from the design PDF (rev 2, 12 July 2026), pages 1–22 (§1 through §6.1) — the uploaded file ends there; §6.2–§10 and Appendix A were reconstructed from the TOC, §1.6, §3.4, §4.4 and §6.1's forward references. Deliberate implementation choices, all documented in-code:
-
Arm branches use a dot separator (
bored/task-42.1.arm-2, not…/arm-2): git's ref hierarchy cannot hold botha/banda/b/c. -
retries: 0is representable — "one pass, no retries" was an explicit §2.2 ask; every other counter keeps its ≥1 floor. -
DoneSignal.datacarries verdict-shaped structured output ({pass}for gate seats,{winner}/{synthesis}for judges) — the "harness-native structured output" channel of §1.4, typed. -
run_openedcarriesbody/criteriaso briefs are derivable from the log alone. -
Escalation ladder (§6.2 reconstruction): quiet window 1 → status-check nudge;
quietStrikeswindows → stall alarm → abort with WIP checkpoint → same-visit retry → exhausted → park (paged). Overrun stays advisory; the hard backstop kill pages; ready-timeout and refusals ride the same ladder. -
Grants are per-node: resuming a cap-exhaustion park with
extraVisitsarms the parked node only; each cap needs its own authority. -
Mid-run context injection is deterministic, not fire-and-forget (§5.5). Injecting / interrupting / adding context to a running agent is a first-class operation with two explicit modes:
- enqueue (
nudge): the steer is durably buffered and handed to the live worker best-effort over the wire. A worker that picks it up echoes anudge_ack(which drains the buffer); a worker that ignores it, dies, or retries leaves the steer buffered, so it folds into the next seat's brief. The steer is therefore never silently skipped and never lost past the run — it lands either on the running worker (confirmed by ack) or, at latest, on the run's next seat. The in-flight seat keeps running. - interrupt (
interrupt): the steer is buffered, the in-flight seat is aborted with a WIP checkpoint, and the same visit is re-staffed (next attempt, no retry cap burned) with the steer folded into a fresh brief — immediate, deterministic application to the current node's work, and the run never leavesrunning.
Every steer carries a stable id so its delivery is auditable end-to-end (
nudge_delivered→nudge_acked); the buffer survives an engine reboot because it is a fold of the persisted log. The Sentinel's liveness pokes are not steers — they carry no id and never fold into a brief. - enqueue (