Skip to content

Repository files navigation

bored

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.x tree, cross-task needs dependencies 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:

  1. 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.
  2. 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_ready handshake or refuse.

The node algebra (§3)

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.

Layout — one module per design component (§4)

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.

Running it as a tracker (server + real workers)

# 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 --interrupt

A 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 board UI

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:7770

React + 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/.

Authoring workflows in JavaScript

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.

Authoring workflows in Bored Assembly (.b)

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
end
npx 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.

Using it as a primitive

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.

The simulated deployments (§7)

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

Testing

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 maxConcurrent FIFO queueing, global max_workers across runs, dedup.
  • Sentinel: the three clocks + hard backstop; a slow worker never dies, a dead one always does; progress_noted rollups 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.

CLI

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'

Fidelity notes

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 both a/b and a/b/c.

  • retries: 0 is representable — "one pass, no retries" was an explicit §2.2 ask; every other counter keeps its ≥1 floor.

  • DoneSignal.data carries verdict-shaped structured output ({pass} for gate seats, {winner}/{synthesis} for judges) — the "harness-native structured output" channel of §1.4, typed.

  • run_opened carries body/criteria so briefs are derivable from the log alone.

  • Escalation ladder (§6.2 reconstruction): quiet window 1 → status-check nudge; quietStrikes windows → 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 extraVisits arms 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 a nudge_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 leaves running.

    Every steer carries a stable id so its delivery is auditable end-to-end (nudge_deliverednudge_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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages