Skip to content

Plans and nodes ​

A plan is a DAG of work across repositories. Nodes are the pieces; edges are dependencies; and a node's status is derived from what Stoat observes, never from someone marking it done.

That last part is the whole design. Nobody maintains a status column, nobody can mark something finished that isn't, and a plan cannot quietly go stale — because there is nothing in it to go stale.

A plan, whole ​

yaml
schema: 1
plan: critter-parity
title: Fisher 1.10 across the stack
anchor: JasperFx/fisher#120
repos:
  fisher: JasperFx/fisher
  stoat: JasperFx/stoat
nodes:
  - id: fix-codegen
    kind: issue
    repo: fisher
    issue: 120
    brief: |
      Keep the public signature; the fix belongs in the parser.
  - id: ship-fisher
    kind: publish
    package: Fisher
    bump: minor
    depends_on: [fix-codegen]
  - id: stoat-takes-fisher
    kind: consume
    repo: stoat
    package: Fisher
    depends_on: [ship-fisher]
  - id: stoat-green
    kind: test-run-gate
    title: Stoat's suites green on the new Fisher
    repo: stoat
    steps:
      - id: tests
        run: dotnet test --solution stoat.slnx
    depends_on: [stoat-takes-fisher]

Read top to bottom, that is the work: fix an issue in Fisher, publish a minor release, have Stoat pin it, and prove Stoat's suites green on it. Each node is done when Stoat observes it done (the issue closed, the version on nuget.org, the pin committed, the steps passing), so ship-fisher is ready only once the issue is actually closed.

The header:

Field
schemaAlways 1.
planThe slug: lower-case kebab, the plan's identity everywhere.
titleWhat the board shows; defaults to the slug.
anchorThe GitHub issue the plan belongs to, org/repo#n. Optional.
colorThe plan's colour on the board; one is assigned when it is left out.
reposAliases for repositories, alias: org/name, or the mapping form with the branch a pin is read from; see Repositories.
branchThe plan's integration branch: stoat run-plan lands finished nodes there, never on main.
resourcesInfrastructure parallel nodes take turns on, postgres: { capacity: 1 }, or the whole machine's with scope: machine; see Shared resources.
modelThe model the plan's workers ask for, under --model and a node's own.
skillsThe skills the plan's work uses, for a node that names none. See which skills a node's work uses.

Node kinds ​

KindSatisfied by
issueA GitHub issue being closed. The number is optional — a node without one is unrealized, and bind_plan_node materializes it once the issue exists.
prA pull request. Merge policy is manual-review (default) or merge-on-green; see the merge.
publishA package version being genuinely downloadable. Declare a bump (fix/minor/major) or an exact version; the feed defaults to nuget.org, and feed: npmjs.org makes it an npm package. On a local folder feed the plan runner packs it, and rehearses: names the real publish it stands in for; see Local packs.
consumeA repository taking an upstream package version — its committed pin reaching what the publish shipped. manifest names the file the pin is read from; without one, Central Package Management's Directory.Packages.props. branch names the branch it is read from, over the one its repo declares, which is over the plan's integration branch:; see Repositories.
test-run-gateA suite passing: a Bobcat run under its BOBCAT_RUN_TAG, or a command run through stoat gate exec, or (where a person allowed it) an agent's asserted result. Optional steps declare what the gate consists of, and an optional repo says which checkout they run in; see below.
sliceAn Event Modeling slice, with a pattern of command/view/automation/translation/query. Its bound {Feature}/{Scenario} specs going green is the node's done; empty specs derive as unrealized. stoat run-plan builds these unattended.
reviewA review of the plan's integration branch as a whole, before its PR. Done when a person has decided every finding the reviewer submitted, or signed off a review that found none; see Deciding a review.
personA step a person does, with nothing on GitHub behind it: recording a demo, publishing by hand. No agent claims it, and it is done when a person presses mark done on its card. See Nodes a person does.

Every kind also takes:

  • brief: the author's Markdown instructions for whoever works the node. The runner fences it into the agent's prompt under the statement of the work. It never changes status. Stored with \n line endings and no trailing whitespace, so a YAML | block's last newline or a CRLF save is no change.
  • agent: cloud: keep the node for a cloud agent.
  • uses: the plan's shared resources it holds while it runs.
  • model (issue, slice, test-run-gate, review): the model this node's worker asks for, over the run's --model and the plan's model:. For a hard node that needs a bigger one; see What a worker starts with.
  • skills: the skills this node's work uses, over the plan's skills:.
  • assignee: person (issue, slice, test-run-gate, review): keep the node for a person. No agent claims it and it needs no go-ahead; see Nodes a person does.
  • exclusive: machine (the kinds the runner launches): the node keeps the machine to itself while it runs; see A node that runs alone. uses: [heavy] is the lighter form: heavy work runs one at a time across every plan, while light work carries on.

The plan's header takes model: (its workers' model, under --model and a node's own) and skills: (the skills the plan's work uses, for a node that names none), the same through create_plan / change_plan and the plan routes.

An issue, pr or consume node takes merge (manual-review or merge-on-green; a consume's applies to the pin-bump PR it rides), a consume takes bumped_by: <node> when another node (a release PR, say) moves its pin, so no runner opens a PR for it and no person is asked to bump it, and a test-run gate takes steps (below), an optional repo (the checkout its steps run in, below) and reverify: on-branch-change (a gate that re-arms).

Authoring ​

Either tool by tool —

create_plan(slug: "critter-parity", agent: "my-agent", title: "…")
add_plan_node(slug: "critter-parity", id: "fisher-1-10", kind: "publish", agent: "my-agent", package: "Fisher", bump: "minor", …)
link_plan_nodes(slug: "critter-parity", node: "stoat-bump", dependsOn: "fisher-1-10", agent: "my-agent")

— or in bulk, as a YAML document (schema 1):

import_plan(yaml: "…", agent: "my-agent")

import_plan diffs rather than replacing: it creates the plan, or folds the document into an existing one so only what actually changed is recorded. Re-importing the same document records nothing.

Authoring is guarded rather than trusting. link_plan_nodes is refused if it would make a cycle. add_plan_node is refused, with the reason, for an unknown dependency, a cycle, or a field that does not belong to the kind. remove_plan_node is refused while anything still depends on it — dropping an edge is a decision, not a side effect of deleting something else.

Note that change_plan_node replaces a node wholesale: pass the complete node as you want it, because anything omitted is cleared. For edges alone use link/unlink; for the GitHub binding alone use bind_plan_node.

Gate steps ​

A test-run gate can declare its steps, so Stoat knows what the gate consists of before anyone claims it:

yaml
- id: providers
  kind: test-run-gate
  title: Provider suites green
  repo: wolverine
  steps:
    - id: build
      run: dotnet build wolverine.slnx
    - id: postgres
      title: PostgresqlTests
      run: dotnet test --project src/Persistence/PostgresqlTests
      timeout: 20m
    - id: http
      run: dotnet test --project Wolverine.Http.Tests -f net9.0
      cwd: src/Http
      env:
        CI_JOB_NAME: http
        ConnectionStrings__postgres: Host=localhost;Port=5433
      timeout: 00:30:00
    - id: sqlserver

A gate may name its repo (a repos alias or org/name). In a plan spanning several repositories it is which checkout the steps run in: a plan runner launches a gate that names a repository only from a checkout of that repository, the way it does an issue, and skips it with in JasperFx/marten, not this checkout otherwise. A gate naming none runs from any checkout.

Each step may also say cwd (a directory relative to the repository root, forward slashes, never climbing out), env (a map of variables the command gets on top of the agent's own) and timeout (a duration: 20m, 90s, 1h30m, or 00:20:00). stoat gate exec applies them when it runs the step — the prompt's line carries them as --cwd, --env NAME=VALUE and --timeout, and stoat gate run reads them from the plan — and a dotnet test step's project is checked from its cwd. Stoat still stamps BOBCAT_RUN_TAG itself, as {plan}/{node}/{step}, so a step whose env sets BOBCAT_RUN_TAG is refused when the plan is written. A step that runs past its timeout is killed, tree and all, and its run is recorded as timed out: the gate reads step postgres timed out (limit 20m) after 20m 3s, never a bare exit code, and is failed. Every problem with a step (a cwd with .., an env name that is not one, a timeout that is not a duration) is reported with the rest of the document's, all at once.

How a step is decided ​

With steps declared, stoat gate exec evidence proves the gate only when every step has a passing run under its own --step <id>. Two green steps of three is waiting, naming the one not run; a failing step fails the gate. A run under some other step id stands in for none of them. run is the step's command, which the runner's prompt hands the agent already wrapped in stoat gate exec --step <id> -- …. That command runs under the step's own tag, BOBCAT_RUN_TAG={plan}/{node}/{step}, so a suite that reports to Bobcat records a per-step run. A step's newest evidence decides it, whichever channel saw it: the Bobcat run a gate exec --step wrapper launches starts after the wrapper's own record and knows which tests ran, so it decides; a later gate exec --step re-run decides over an older Bobcat run. A Bobcat run under the gate's own bare tag names no step, so on a gate that declares steps it is shown on the node's detail as unassigned and never counts. The card shows a chip per step, coloured by where it stands, and its tooltip says which evidence decided it.

A step that runs a Bobcat Gate publishes one run per unit. The Gate takes the step's tag as its prefix, so its runs are tagged {plan}/{node}/{step}/{project}.{framework}. The step is the worst of its units: running while any unit runs (or the gate exec wrapping them is still going), failed if any unit failed, passed when every unit passed. A wrapper that exits red fails the step even when every unit it published passed, since a unit whose test host never started publishes nothing. The zoom view shows a card per unit. Nothing extra is declared: the step's run is just the command that starts the Gate.

On the board ​

A gate with a step running shows an in-progress widget where its state pill would be: a turning ring and "3 of 12 steps, 1 failed", counting the steps with a verdict. Once nothing runs the pill is back with a compact tally beside it, 4✓ 1✗ 7○. While a step's Bobcat run is in flight the card's small bar follows that run live, without a reload. The zoom view lists the steps: each one's state (pending, running, passed, failed, timed out at its declared timeout, or that it ran another command), what decided it, its latest run with its duration and a link to it on bobcat ui, the exact stoat gate exec {plan}/{node} --step <id> [--cwd …] [--env …] [--timeout …] -- <command> that runs it again with the step's declared flags, and, while its run is going, a progress bar coloured by the verdict split Bobcat's supervisor sends (passed, failed, retried, workers busy) with the elapsed time. A finished step keeps its result. A run whose publisher reports dropping events under load says "per-test detail incomplete" on its card: its counts are complete, the scenarios under it may not be. A gate that declares no steps shows the same bar and card for its one run.

A running gate also shows how far its Bobcat run has got ("37 / 120 · 2 stalled"). That count is live only: Stoat stores when a run starts and finishes, not its progress.

Checked when it is written, and when it runs ​

The command is compared. A passing run whose recorded command is not the step's run:, word for word as a shell would split it, is a mismatch, and never passes the step. Either the step was declared wrong (edit it to what ran) or the agent ran something else (run the declared command).

A dotnet test step is checked when it is written: the project exists, is a test project, and targets the -f framework, read from the project and the Directory.Build.props above it. That needs a checkout to read: the one the author names (checkout on the MCP tools and the REST bodies, ?checkout= on an import), or else this plan's runner's on this machine. What is wrong comes back as warnings beside the result; a warning never refuses the save, and with no checkout nothing is checked.

npm packages ​

An npm package is the same release train, read from two different places:

yaml
nodes:
  - id: ship-renderer
    kind: publish
    package: "@jasperfx/event-model-vue"
    feed: npmjs.org
    version: 0.2.0
  - id: take-renderer
    kind: consume
    repo: JasperFx/CritterWatch
    package: "@jasperfx/event-model-vue"
    manifest: src/CritterWatch.FrontEnd/package.json
    depends_on: [ship-renderer]
  • The publish is done when the version is on the registry, not when the workflow that published it went green. Stoat never runs npm publish; that stays in the owning repository's GitHub Actions.
  • The consume is done when the committed package.json reaches it. A range pins its floor: ^0.2.0, ~0.2.0 and >=0.2.0 all guarantee 0.2.0, so that is what is compared. A spec with no single floor (*, latest, 1.x, a two-sided or || range, a git or workspace: source) is reported verbatim and waits, rather than guessing that an open range caught up.
  • A manifest the repository does not have is missing, the way an unconfigured feed is on a publish node. A typo'd path must surface, not wait forever.
  • One read never retracts a version. Registry metadata is CDN-fronted, so a read can be stale. A version Stoat has already seen stays seen, so a publish node cannot fall back from done and re-block what it released.

A multi-package publish is one publish node per package, and each consumer waits on exactly the package it pins, so a package on a separate version line is never caught in someone else's wait.

manifest is not npm-only: any other file is read as MSBuild XML, so a NuGet consumer can name the one .csproj that pins it.

Local packs, and the replay ​

A release train builds and tests everything on local packs before anything reaches nuget.org. Give the train a folder feed (Stoat__Feeds__local__Path=~/code/local-nuget), run stoat setup once so NuGet on this machine sees it, and write each package twice: the real publish, and a local pack that rehearses it.

yaml
repos:
  jasperfx: { repo: JasperFx/jasperfx, branch: '3.0' }
  marten: { repo: JasperFx/marten, branch: '10.0' }
nodes:
  - id: rel-jasperfx
    kind: publish
    package: JasperFx
    version: 3.0.0
  - id: jfx-pack
    kind: publish
    package: JasperFx
    feed: local
    repo: jasperfx
    rehearses: rel-jasperfx
  - id: marten-takes-jasperfx
    kind: consume
    repo: marten
    package: JasperFx
    depends_on: [jfx-pack]
  • The pack. The plan runner in a checkout of JasperFx/jasperfx packs 3.0.0-local.N (one past the highest on the feed) from the 3.0 branch into the feed folder. It runs the repository's recorded way of packing (declare_repository_pack, a command with {version} and {feed}), and with none recorded it launches an agent to work it out and record it. The node is done when the package is in the folder.
  • The promotion. The runner in a checkout of JasperFx/marten moves the pin to that version and commits it on 10.0, without touching any working copy, and never pushes it. Stoat reads the pin from that committed branch, so the consume is done and downstream work can start.
  • The replay. Once rel-jasperfx is published to nuget.org (a person's step, listed as a need), and once every rehearsed pin in that repository has its real release, the runner moves the pins to the real versions and pushes the branch. It never pushes a branch that still pins a local pack.

A local publish without rehearses: is packed and promoted the same way, and its pins stay local.

A real release due on the default branch is never committed there by the runner. Every pin read from a repository's default branch whose real release has shipped goes into one pin-bump pull request per repository, on stoat/bump-pins-{plan}, cut fresh from the default branch and looked at every two minutes. A later poll updates the open PR rather than opening a second. The consume nodes it carries show it as their PR line, under their own merge: (manual review by default): while it is open, the person's action is the merge, not a bump; closed without merging, the bump is back. Merge-on-green merges it once for every node riding it, and only when every one of them is merge-on-green.

A consume is measured against the newest release. Its target is the newest real release any publish of its package shipped, in its plan or a plan it borrows from, within the linked release's major. And when a repository takes an upstream that needs a newer version of a package than the repository itself pins (a version diamond), the pin's card says so, with an align a version need, though its status does not change.

Repositories, and the branch a pin is read from ​

repos: declares the repositories a plan spans, each under the alias node bodies use. The plain form is the alias and its org/name; the mapping form adds the branch a consume node's pin is read from:

yaml
repos:
  jasperfx: JasperFx/jasperfx
  wolverine:
    repo: JasperFx/wolverine
    branch: net11
nodes:
  - id: wolverine-takes-jasperfx
    kind: consume
    repo: wolverine
    package: JasperFx
    depends_on: [ship-jasperfx]
  - id: wolverine-takes-events
    kind: consume
    repo: wolverine
    package: JasperFx.Events
    branch: net11-events
    depends_on: [ship-events]

A release train's pins move on a per-repo integration branch (net11 in every repository of the .NET 11 train) for weeks before anything touches main, and a consume node reading main would sit at waiting the whole time the work is in fact done. So a consume node reads its pin from the first of these that applies:

  1. the node's own branch:;
  2. its repository's branch: in repos:;
  3. the plan's integration branch:, in every repository of the plan, not only the one run-plan is attached to, since one train uses the same branch name in each;
  4. the remote's default branch.

A plan with no branch: anywhere reads the default branch, which is right for a plan delivered as one PR. The repository's branch belongs to the repository, so a node writing repo: JasperFx/wolverine in full reads the same branch as one using the alias. main is allowed in repos: and on a node, since reading it by name is harmless, where the plan's own integration branch: refuses it.

An integration branch that is not cut yet falls back. run-plan creates the integration branch in a repository only when it first lands work there, so for a while most repositories of a train do not have it. A pin read through the plan's branch: (step 3) then comes from the default branch, and the detail says so: pinned 2.37.0 on main (integration branch net11 not created in JasperFx/marten yet), waiting for 2.38.0. Once the branch exists, the next sweep reads it. A branch somebody named, on the node or on the repo, gets no fallback: if it does not exist, the node reads it as having nothing, pinned nothing on net11, and a named manifest is missing there, because a typo'd branch has to surface rather than quietly read main.

Where the pin was read is part of the fact. A pin on main and a pin on net11 are recorded separately, so a stale read of one never hides behind the other, and the node's detail says which it is: 2.38.0 has shipped: bump this pin (pinned 2.37.0 on net11). A manifest the branch does not have is missing on that branch, named: src/Wolverine/Wolverine.csproj not found on net11 of JasperFx/wolverine.

A branch that only exists on this machine is read from disk. When stoat run-plan --watch is attached from a checkout of the repository, and its integration branch is the one the consume node reads (named, or implied by the plan's branch:), the pin comes from the integration worktree the runner owns rather than from GitHub, because that is where the branch is until somebody pushes it. The detail names the path: pinned 2.38.0 in /code/wolverine-net11-integration on net11 (published 2.38.0). With no such runner, or on another machine, the branch is read from GitHub.

Over REST and MCP the same declaration is repoBranches, alias or org/name → branch, on create_plan / change_plan and the plan header routes, and branch on a consume node.

Derived status ​

plan_status gives each node one of:

unrealized · unknown · missing · open · claimed · pr-open · abandoned · done · waiting · running · failed · mismatch · partial · stale · findings · fixing

stale is a gate whose pass no longer counts: the integration branch moved under a reverify gate, a person asked for it to be verified again, or the pass was on a dirty tree or is behind its release branch while something still waits on it (A pass that proves less). It is ready again. findings and fixing are a review's (findings waiting on a person, then accepted fix nodes not yet done), and findings is also a red gate's whose worker diagnosed it (A red gate becomes findings). partial is an issue whose worker reported what it left out, closed or not. None of these is ready.

An issue node can also read done while its PR is still open (its issue was closed by hand, or a person accepted it as partial): the card says done · landing #790, and its dependents wait until the PR merges. See Landing.

Two distinctions worth keeping straight:

  • unrealized vs missing. Unrealized means the node was never bound to a real thing — the issue has not been filed yet. Missing means it was bound and the thing is not there.
  • unknown is not missing. "No run yet" and "we cannot see whether there was a run" are different facts, and a plan that conflated them would report confidence it does not have.

An issue's pull requests ​

An issue or pr node also carries prs: one entry per pull request (an issue's closing PRs, open, merged and closed; a pr node's own), with its number and URL, its state (open, draft, merged, or closed without merging), its CI verdict and counts with the failing checks named, GitHub's review decision (approved, changes_requested, review_required), whether it can merge (mergeable: mergeable / conflicting; mergeState: clean, behind, dirty, …), its head commit and when it merged. The plan card shows each as one line, the number linking to the PR:

⇄ #587  open · CI ✓ 6/6 · review: approved
⇄ #585  draft · CI ✕ 1 failed (test)
⇄ #586  open · conflicts with main · CI ✓ 6/6
⇄ #584  merged 16:28

plan_status and next_ready_nodes return the same list, so an agent reads exactly what the person sees. State, review, mergeability and head commit are stored. The CI counts, the failing checks' names and the merge state change with every finished check, so Stoat keeps them in memory only, and they are empty for up to a minute after Stoat restarts. While GitHub recomputes mergeability after the base branch moves, Stoat keeps the last answer it had. A PR turning conflicting, or a review decision, is a line in the plan's Activity.

Reading the plan page ​

A plan's page is built to answer "what needs me, and what is everything else waiting on" before anything else.

The top of a plan page: header, the needs panel, the views row and the first bandThe top of a plan page: the lead chip and cost pill in the header, what the plan needs from you, then the views row with the lens, Expand / Collapse all, find and the agent cap, and the first repository band.

Every card is in one of five classes, from the server, so the card, the needs panel and an agent reading plan_status (stance and stanceWhy on each node) agree:

ClassMeans
✋youA person owes it something: a go-ahead, an answer, a merge, a review, a partial to accept, a bump, a node of yours. Exactly the cards the needs panel lists.
⨯brokenA failed gate, red CI, a conflict, a give-up, a stalled or orphaned claim, or a node GitHub cannot be read for.
⏸heldHeld by a control: a paused or missing runner, a limit, a reservation, a resource.
⟳movingBeing worked: a live claim, a running gate, CI running. An agent still on a red PR reads moving.
waitingWaiting on its dependencies.

A waiting card names its root blocker. Rather than "waiting", its detail leads with what it is ultimately waiting on, walked upstream across plans: waits on p0-sdk (✋ your go-ahead). The needs panel ranks its items by how many waiting cards each one holds ("unblocks 12").

The views. Graph (the default), Table, Activity and YAML share one switcher, and the choice rides the URL (?view=table). The table lists the same cards as rows, what needs you first, and gives and withdraws go-aheads in bulk; see Keyboard shortcuts.

Bands per repository. The graph keeps its dependency columns, and stacks each repository's cards in a band, upstream repositories first (a repository whose cards others depend on, or whose packages others consume, goes higher). Each band's header is that repository's: its runner (state, Start… or the command to start one, Pause), its CI toggle, Hold or its session limit, how many cards are ready there, and approve all N for the cards that can take a go-ahead. A band lays out only the columns its own cards use, so a wide plan stays narrow. ▼ / ▶ folds a band, and Expand all / Collapse all fold them together; a band whose runner is calling out never folds.

A repository band's header with its cardsWolverine's band: its CI turned off on purpose, Hold, how many cards are ready, and withdraw all for the go-aheads given in it.

The lens. All · ✋ You · ⨯ Stuck · ⟳ Moving mutes every card outside the class. Clicking a muted waiting card traces its path to what blocks it. A stalled plan opens in ✋ You, once per visit. A band with no lit card folds to its header, and the ‹ › counter steps through the lit cards.

The ✋ You lensThe ✋ You lens: bands with nothing for you fold to "N cards, none in this lens", and the counter reads ‹ 1 of 10 ›.

What the plan needs from you stands at the top: every action you owe the plan, grouped, with a "stalled" banner when work is waiting and nothing is running. It stays reachable as a one-line bar as you scroll. Merges are listed in the order they should land (see What is waiting on you).

The header carries the plan's own controls: the title's ✎ renames the plan (the title only; the slug is its identity), ⚙ runners create issues, the plan's cost and its lead. The views row beside the view switcher has Start all / Pause all for the plan's runners, Expand all / Collapse all and 👥 the agent cap. The machine's own controls are in the app's top bar on every page: ⟳ N (agents working now), the machine's hold, and its session limit.

On a card, 🔎 is manual review and 🤖 merge-on-green, ⛰ marks heavy work, 🔒 exclusive work, and 👤 a person's step. Every card button has a key: see Keyboard shortcuts.

Repository icons ​

A repository that has an icon of its own is shown with it, beside its name, wherever Stoat names it: band headers, cards, the needs panel's chips, the plans list's pills, the Table's repository column, the Repositories page, the Agents page and the ready board's rows. Nothing is configured per repository.

The plans list, with each plan's per-repository pillsThe plans list: each plan's done / total per repository, with the repository's own icon on its pill.

The icon is the repository's favicon: a favicon.ico, favicon.svg or favicon.png anywhere in its tree, its docs site's preferred when there are several. Icons under sample, scratch, test or old folders, and the stock ASP.NET and Vue template icons, are ignored, so a repository whose only favicon came with a template shows none. The server looks for each repository it knows (registered, or named by an active plan) through the GitHub sweep's token, over REST: one tree read per repository a day (Stoat:RepositoryIconRefreshHours), and the file itself only when it changed. It stands down while GitHub's rate limit is spent, and with no GitHub token there are no icons. Icons are kept on disk (Stoat:IconsPath), never in the store: they are a cue, and nothing derives from them.

Zooming into a node ​

A card answers "where does this node stand?" in one line. Clicking it (on the plan page or the ready board) zooms in: a modal with everything Stoat knows about the node, and the GitHub text it is about, in one place.

A node zoomed inmarten-tfm zoomed in: its brief, what is happening now (the claim), the tokens its attempts used, and the issue and PRs on GitHub.

  • Header: id, kind, status, why it is or is not ready (waiting on `marten-takes-rc1` , ready, awaiting a go-ahead), and the binding as a link.
  • The work: the author's brief, then GitHub's own text, rendered as Markdown: the issue's title, body, labels, milestone and most recent comments (collapsed past a few), and for each PR its description, state, review, mergeability, diff stat and every check. A failing check shows its summary, its first annotations and, for a GitHub Actions job, the tail of its log. The text is fetched when the modal opens, through the GitHub sweep's token, and kept a minute; it is never stored in the plan. Raw HTML in an issue body shows as the text it is.
  • What is happening now: the claim (who, since when, the session, whether the agent is alive), the plan runner's view (an agent of it running the node, its callouts about the node with what to do), the agent's last report and open question, answerable in place, and a copyable cd <worktree> && claude --resume <session> when the runner knows the session.
  • Test runs (a gate): its steps as a list, each with its state, its latest run, its progress bar while the run is going, and the command that runs it again; see Gate steps.
  • History: this node's lines of the Activity, people's notes, a review's findings, a gate's stoat gate exec runs.
  • Neighbourhood: what it depends on and what it unblocks, across plans, each a link that zooms to that node, plus the repository notes and memories an agent starting it would recall.
  • Actions: the card's own buttons, repeated: approve for agents, expedite, the merge policy pill, restart, allow asserted, re-verify, a note, and open in GitHub.

The modal is live: it follows the same pushed status the board does, and reloads its detail when the node changes. It is deep-linkable as /plans/{slug}/nodes/{id}, so a notification or an ask_person link can open straight onto it. An agent gets the same view from get_node, and anything else from GET /api/plans/{slug}/nodes/{id}.

Finding work ​

next_ready_nodes()

Ready means: every dependency is done — wherever it lives, including in another plan — and the node is not itself done, missing or running (or a review waiting on its findings or fixes). Results are sorted by leverage: how many nodes finishing this one unblocks.

Claims ​

A claim is how one agent tells the others it is working on something.

claim_node(plan: "critter-parity", node: "fisher-1-10", agent: "…")
report_node(…, note: "reproduced; the fix is in the parser")
release_node(…)
  • A claim is a lease, default 30 minutes, and its expiry is derived at read time rather than written down — so a session that dies without releasing does not hold a node forever, and nothing has to sweep.
  • Claiming a taken node is refused with the holder's name, and claiming a done node is refused outright.
  • Releasing changes no status. Finishing the work is proven by the issue closing, the version appearing, the suite passing — not by saying so.

agent_roster shows every Claude Code session on the box, what it holds, and whether anyone is actually working — running, idle, or stopped with the cause it stopped for. An agent the box cannot see (on another machine, or not a Claude Code session) joins it with declare_agent and keeps itself there with report_activity.

An agent holding a node can also say the steps it means to take with declare_node_steps, and report each one as it goes. The agent view then shows what is and is not complete: the agent's own word for each step, and beside it, for a step that names a runTag, what the tagged run actually observed. Steps never change a node's status. A repository's own gates (a ./build.sh CommitGate, say) are recorded with declare_repository_gate, so a step can say which gate it ran and what it returned.

A node that runs alone ​

Some work has to be the only thing on the machine: a memory stress test, a benchmark, anything another agent's build or suite would spoil. Mark it exclusive: machine:

yaml
  - id: marten-stress-run
    kind: test-run-gate
    repo: marten
    exclusive: machine

Once a runner would launch it, the machine is reserved for it:

  • No new claims anywhere. Every other node, in every plan, says machine reserved and waits, and a claim on one is refused with the reason. Work already running is never stopped.
  • It waits for the machine to drain. It starts when every other live claim and every running stoat gate exec on this machine has finished, and its card says what it is still waiting for.
  • It reserves nothing it would not run. Paused, with no runner, waiting for a go-ahead or given up on, it holds nobody back.

A gate run by hand is never refused, since gate exec never fails the command. It says on stderr that the machine is reserved, and the exclusive node waits for it to finish.

When a repository's CI is off on purpose ​

Turning CI off for a day, so the Actions queue gates only the release, is a decision Stoat needs to know about. Otherwise every worker waits on checks that will never run. Each repository's band on the plan page has a CI toggle in its header: turning it off asks why, and the reason shows on hover. A plan's live lead can relay it with set_repository_ci.

While a repository's CI is off:

  • Workers stop waiting for checks. They prove the change locally with the repository's commit gate, scoped to what they changed, say in a PR comment what they ran, and finish at the open PR. Give the gate a scope with declare_repository_gate so a worker knows which part of a long gate covers which code.
  • Your review stays yours. A manual-review card's PR is in front of you at once, marked CI off. Only a card you switched to merge-on-green is merged without checks, and only once it is mergeable.
  • A conflict resolution is tested first. When a PR's head changes while it conflicts (a rebase, or a merge made in GitHub's web editor), its PR line says resolved at 1a2b3c4, not tested, and merge-on-green waits until the commit gate passes on that head: stoat gate run <plan>/<node> --step <gate>, or a scoped run recorded under that step (stoat gate exec <plan>/<node> --step <gate> -- <command>). A web-editor merge once dropped a line that only the gate caught. A repository with no commit gate declared leaves that merge to you.

Turning it back on puts it back: workers watch CI again, and merge-on-green waits for green.

Waiting without polling ​

The cross-repo case that motivated all of this: a downstream agent needs an upstream publish.

await_package_version(feed: "nuget.org", package: "Fisher", minVersion: "1.10.0")
await_dependencies(plan: "critter-parity", node: "stoat-bump")

Each blocks until the fact changes, and costs one open request. await_package_version queries the feed directly, so it works for any package — not only ones a plan happens to declare, and on npm too (feed: "npmjs.org").

History and Activity ​

plan_history returns every change ever made, oldest first, with who made it and what it was: plan_created, node_added, node_changed, node_removed, dependency_added, dependency_removed, node_bound, the plan header changes, and every person's decision (a go-ahead, an expedite, a note). The plan is an event stream, so this is the stream, not a log somebody remembered to write.

The plan's Activity viewThe Activity view: newest first, one line per change, each naming its node and who, asserted or observed.

The plan page's Activity (GET /api/plans/{slug}/timeline) is wider: the plan's stream joined with each node's claim, gate-run and GitHub streams, newest first, one line per change naming its node, who (with the session for a claim), and whether it was asserted (an agent or a person said so) or observed (GitHub, a feed, a run). Only news: a lease renewal is a line when its note changed, a GitHub sweep only for what moved. A person's note (+ note, on the plan's strip or in the node editor) is a line of its own.

Editing and archiving ​

The plan page edits the plan through the same commands: + node and a card's ✎ add, change, remove and rewire nodes, with a preview of what the save does to the run. Archiving takes a plan out of the active set and keeps its stream; an archived plan can be purged for good. See A person's controls.

The store is the master copy. YAML is an import and export format only: export_plan (the plan page's YAML view) writes a document that, imported back, records nothing; the Stoat:PlansPath directory only ever seeds a plan the store has never had, so an edit made over the API is never undone by a stale file.

See also ​

Licensed under the Business Source License 1.1.