Add --json to a Pullboard command to receive one JSON document on stdout. The document has version: 1; diagnostics stay out of stderr except for sign-in instructions and check consent notices that must appear before execution. The flag may appear with command options before --. A flag after -- is an argument to the command, not an output-mode switch.
The API version is independent of the package version. Its contract is stable within each major API version: existing command names, required fields, field types, and refusal fields do not change within that version. An incompatible change requires a new version. New fields may be added, so clients should ignore fields they do not use.
Decision shouts without a recipient go to the agent's coordinator, or to person when sent by the coordinator. An agent may answer a decision sent to its own lane; the reply names that agent and goes to the original asker. An agent in another lane gets NOT_YOUR_DECISION. pullboard pass <shout-id> <note> is for coordinators: it forwards an open coordinator decision to the person with the original question and note. From the main checkout, pullboard decisions and pullboard answer <shout-id> <text> default to the coordinator; use --as person to select the person's queue or answer a person-addressed decision. Person mode is accepted only in the main checkout. An answer in person mode cannot answer a coordinator-addressed decision, and a coordinator-default answer cannot impersonate the person; the refusal prints the command to retry. A person answer is delivered to the original asker. An agent worktree cannot select person mode.
A successful command returns the fields listed below. version is always the number 1. The catalog lists required top-level fields; nested objects and arrays are command data, and optional top-level fields may be added.
pullboard settings reads machine-wide settings from the OS account's ~/.pullboard/settings.json; the gate queue database is beside it. This location does not follow HOME or PULLBOARD_HOME, so agents with private board homes still share one machine gate limit. Set PULLBOARD_MACHINE_HOME to an explicit directory only when a separate private machine settings and gate pool is intended; tests use this override. pullboard settings gateSlots <n> changes the gate queue capacity, which defaults to 2. Raising gate or browser capacity takes effect immediately, even with holders and waiters: settings and the durable queue limit change under the same lock, and existing waiters observe the new limit. Lowering capacity is refused while that resource has holders or waiters; the refusal names its live holders and leaves their leases, settings and queue order intact. Retry the decrease after the resource becomes idle.
Browser-free check admission is opt-in per machine with "gate": { "browserFreeChecks": true } in ~/.pullboard/settings.json; it defaults to false. When enabled, the reviewed test classifier can let checks use a CPU-only gate slot. An absent or false setting keeps every check browser-gated, and uncertain or unsupported test commands remain browser-gated even when enabled. The same setting controls the wider frozen-check classification introduced in 0.8.26.
Classifying test files by launch is opt-in per machine with "gate": { "browserByLaunch": true } in ~/.pullboard/settings.json; it defaults to false. When enabled, the test runner gives a test file a browser slot only when its import graph reaches a real Chrome launch: an import of startChrome, launchChromeProcess, findChromeExecutable (or any binding other than the DevTools-only helpers) from test/chrome-fixture.js, directly or through a helper, or a process starter beside a Chrome executable reference. Cockpit test files keep the browser slot. Every other file runs without one. In such a file, and in Node children that inherit the guard (the runner adds it to NODE_OPTIONS), a node:child_process call that would start a browser (Chrome's install paths, PULLBOARD_CHROME's value, a browser executable name, or one of those inside a shell command string) fails at once with BROWSER_SLOT_REQUIRED naming the file, before the browser starts; test/chrome-fixture.js checks again at its launch point. A launch that bypasses the repository's launch helpers (a child that strips its inherited environment, a non-Node child, or a shell running a script file) is out of scope and is not caught, so new tests must launch Chrome through test/chrome-fixture.js or the view fixture. An absent or false setting classifies exactly as before, and the launch guard is inert.
The machine gate pool admits landings first and then everything else in arrival order. A machine opts in to reviews first with "gate": { "queue": { "reviewsFirst": true } } in ~/.pullboard/settings.json: the pool then admits landing runs, then reviews, then submits and item checks, then focused and other runs, first come first served within each class. gate.queue.agingMinutes (a positive whole number, default 30) is how long a waiting run waits before it moves up one class, so no class starves; it is used while reviewsFirst or the release priority below is enabled. pullboard settings prints both values, and pullboard resources names the order in effect.
Release priority is separately opt-in with "gate": { "queue": { "currentReleaseFirst": true } }. Before queueing, a gate run captures its board identity and its item's roadmap rank. The first unfinished milestone is current, counting only this board's own items as verified out of total; repo#id references are left out and never opened. Later milestones follow in roadmap order, then already completed earlier milestones, then unplanned work. Within otherwise-tied positions belonging to one board, current-release work goes first: A-later, B, A-current becomes A-current, B, A-later. Different boards retain their opportunity positions. Global admission classes and landings retain their existing precedence, and a waiter older than agingMinutes keeps its position against release reordering. Queued ranks do not change if the roadmap changes. With the setting off, no board is read for release priority. Unknown board identities keep their positions; while any older client is waiting, all clients fall back together to landings then arrival. pullboard settings prints gate.queue.currentReleaseFirst, default false.
pullboard init and pullboard resume keep pullboard view running in the background, so pullboard.dev finds this computer's boards with no command. When no view answers on the view port (4890, or PULLBOARD_VIEW_PORT), they start one detached, writing its output to a private view-<port>.log beside the view's session record in ~/.pullboard; a view already running is left alone, and a busy port is one line that fails neither command. init prints one line naming where the view runs, its log and the kill <pid> that stops it; resume prints that line only when it starts the view or cannot. Both JSON results carry view with state (started, running, busy, off or failed), port and, for a view, pid. It is on unless the machine sets "view": { "background": false } in ~/.pullboard/settings.json; PULLBOARD_VIEW_BACKGROUND=0 turns it off for one process tree, and the test runner sets it so tests leave no view behind.
The memory hold is opt-in per machine with "gate": { "memoryFloorMB": <n> } in ~/.pullboard/settings.json; absent, it is off and the pool admits by slots alone, as before. When set, before admitting a run the machine gate pool reads the machine's memory: on macOS the kernel's memory pressure level and free percentage, on Linux MemAvailable and the pressure stall averages in /proc/pressure/memory. The whole queue, landings included, waits in place while pressure is critical or free memory is under that many megabytes (0 leaves only the pressure check). A held run prints one line naming why and the three biggest processes by memory, with pid, size and working directory, and starts once memory recovers. While the floor is set, pullboard resources prints the same line and returns it as memory in JSON; with no floor it reads no memory and memory is null. Where memory cannot be read, the pool admits by slots alone and prints one warning that memory is not checked there.
Short gate slots are opt-in per machine with pullboard settings gate.shortSlots <n>, stored as "gate": { "shortSlots": n }; the default 0 keeps them off and the pool behaves as before. With n above 0, up to n runs that name their test files (a pullboard check or a review whose check is a literal node bin/run-tests.js call over test files, or a direct node bin/run-tests.js <files>) are admitted on short slots while every gate slot is held. Whole suites, directories, landings, diagnostics and anything the parser cannot read never use them, and a child that does not name its files cannot reuse a short lease (SHORT_SLOT_FULL_SUITE). Short slots do not add to gateSlots, and browser runs still count against browserSlots. pullboard settings prints gate.shortSlots; pullboard resources --json reports shortSlots, each gate holder's slot (gate or short), and requestedSlot: "short-eligible" on a waiting run that may take one.
When add or edit supplies a new nonempty check, it records the item before measuring that check in a background temporary checkout of the captured main commit. Use --wait to wait for the measurement before returning, or args.wait: true for an HTTP add/edit move. The item returned by add, edit, show, and next --verify may include item_check_baseline: {command, main, result, request?, seconds?, reason?, warning?}. main is the commit id or null, and result is pending, green, red, or unavailable. A background request carries an opaque request identity; an older completion cannot overwrite a newly edited or cleared check.
An item's HTTP detail, and each item in the full board state, include check and, when recorded, checkBaseline with the same observation fields. A green result carries warning: "CRITERION_PROVES_NOTHING". When accepted main's pullboard.json sets checks.provesNothing to "hold" (pullboard init writes it for new boards), claim and submit refuse that item with CRITERION_PROVES_NOTHING until the check changes to one whose baseline is not green, or the person accepts the check as it reads with pullboard check accept <id>, or the local view sends check-accept with the item. The acceptance carries the check's SHA-256 digest, so a changed check needs a new one; it refuses agent shells, remote shells, agent worktrees, the API channel and MCP. With no setting it stays a warning: text output names it when showing or reserving the item for review, and when filing it with --wait. An exact match with the project gate configured at that main commit records green with reason: "repo gate" without running it again. A missing main records unavailable with reason: "no main" and still files the item. Changing the check replaces this observation; clearing it removes the observation. Captured observations and completions travel through ordered board moves, so replicas store results without executing commands. A completed result awaiting relay delivery stays on the measuring device and is retried by the next normal CLI command.
An accepted item that has not landed can carry item_verdict_state in CLI show, list, and resume JSON. Its code is STALE_VERDICT; file and commit identify the first known main change to one of the submitted candidate's files, and reviewedTrunk and main identify the compared tips. HTTP board states, summaries, and item details expose the same observation as optional verdictState. Moving main invalidates both cached state and live change notifications even when the served worktree's HEAD stays fixed. Unrelated files leave acceptance current; reverting a relevant main change still requires re-review. Missing reviewed history also fails closed, with a reason and null file/commit instead of an invented change.
Freshness never rewrites the stored ACCEPT or its verdict row. New accept/reject event details carry review: {commit, trunk, paths}: the original submitted commit, the known reviewed trunk, and sorted unique submitted paths. Existing receipts replay with their recorded engine and remain unchanged. Older acceptances without captured review metadata use a conservative claim-base bound and may require a new review after upgrading. Recorded or physically landed work no longer needs this freshness check, except when an older in-flight landing records a different tree from its newer checked review. That append-only landing-review-moved receipt projects STALE_VERDICT with landingCode: "LAND_ITEM_MOVED" and the exact checked head beside the immutable landed commit. The coordinator creates a separate correction item; the already-landed original cannot be reopened or re-reviewed. When the checked composition reaches the known trunk, this derived warning clears. A replica missing its checked Git objects retains the warning and the correction-item guidance.
For a stale acceptance, merge current main into the submitted work and commit a clean review HEAD. Another agent runs pullboard verify <id> accept --note "what was re-reviewed"; its frozen check runs at that combined HEAD, and a new verdict appends to the history while retaining the original submitted commit. A main move during the check refuses VERDICT_TRUNK_MOVED and requires another review. resume keeps stale acceptances out of its merge list. land excludes stale items (and reports their file and commit); when none remain ready, it refuses STALE_VERDICT. Nothing in this observation changes the approved criterion or grants person authority.
The board setting submit.requireInfraNote in the coordinator's accepted pullboard.json is false by default. With it true, a submit that edits a test file, pullboard.json, a githook or a file a gate command names, and that already existed at the claim, is refused with INFRA_NOTE_REQUIRED until it carries --infra "<why>". The refusal names the files. With either setting, a supplied note and those files are recorded on the item's submit event as infra: {note, files}, and pullboard show prints them for the reviewer as infra note:. A new test file needs no note.
pullboard stats [--since <date>] --json returns {version: 1, stats: {...}} from the append-only event log. submissions and rejections count moves, including repeated attempts; rejectionShare is rejections divided by submissions (zero when the window has no submissions). merged counts distinct items with a merge move; mergedWithoutAccept counts those whose merge lacks an earlier acceptance of the latest submitted commit. That audit uses earlier history even when the acceptance falls before the selected window. Current item and verdict rows do not replace event evidence.
stats.caught lists what the board caught in the window, read from moves it already records and never pushed: counts per kind (sentBack, conflict, redHead, flake, stuck), total, and entries of {kind, item, at, text}. A sentBack entry carries the gap the reviewer named; conflict is a reopen naming a trunk conflict; redHead is a reopen after the full suite failed at landing; flake is a flake proven by a paired rerun; stuck is a sentinel health finding. Text stats prints a caught: count line and one line per entry, and pullboard digest carries the same list for the last 30 days under What the board caught. It appears only when someone runs one of those commands.
gateWaits, beside stats, lists, for each kind of gated run in the last day (check, review, doctrine, gate, landing), its runs, medianWaitMs, slowestWaitMs and medianRunMs, read from the board's gate receipts; text stats prints them under gate waits, last day:. A gate receipt is written for every run that takes a gate slot. Its fields are waitMs (queued for a slot), admissionMs (deciding and queueing, then handing over), setupMs, runMs and evidenceMs (reading the result and writing its record), and slot: {kind, reason}, where kind is ordinary, browser, short, runner or inherited. A timing profile carries it as receipt, check --json returns it as receipt, and the timing lines print it as phases: ...; slot: <kind> (<reason>). A doctrine hold at submit prints its own doctrine hold phases: line.
stats.bars counts bar corrections, keyed by agent: {corrections, barRejects, buildRejects}. A correction is an edit --criterion or --check on an item that was ever claimed. It must carry --reason "what the old bar got wrong" (HTTP args.reason); without one it is refused with BAR_REASON. A correction that follows the item's newest, unexplained reject marks that reject as caused by the bar, counted to the correcting agent; every other reject counts to the build, under the agent who added the item. Text stats prints one bars: line per agent, and text show prints each correction with its reason.
stats.flow adds the flow through the board:
asOfis the ISO UTC timestamp of this observation, shared by every queue age.stages.build,reviewWait,reviewandmergeWaiteach containcount,totalMinutes,averageMinutes,medianMinutesandunmeasuredCompleted. Build runs from first claim to the final submission, including rework. Review wait ends at the reservation whose verdict closes that submission; released or expired reservations remain waiting time. Review runs from that reservation to verdict, and merge wait from accept to the first merge receipt. Each stage enters its averages when its end is inside the inclusive--sincewindow. Unfinished or missing intervals are omitted; averages and medians with no samples arenull. Legacy verdicts without reservations leave review wait and review unmeasured, never zero.queues.open,claimed,submittedandacceptedcontainsizeandoldest({id, since, ageMinutes}, ornull). These are current queues, including work added before the window; accepted means accepted but not merged. Open age starts at the latest add, reopen, reject, build release or claim lapse. Claimed age starts at claim, unaffected by renewal; submitted age starts at final submit, and accepted age at accept. Equal ages choose the lower item id. Expired claims become open at their recorded lease expiry. Eachsinceis the ISO UTC timestamp of that queue origin, andageMinutesis exactly(Date.parse(asOf) - Date.parse(since)) / 60000. Separate observations check that equation independently; their invariant content, includingsince, stays comparable without a tolerance band.dailycontains UTC{date, added, merged}counts for dates with those moves in the window. An item's first merge completes its cycle; repeated receipts never create another completion, even in a later window.submitsPerMergeddivides window submission attempts by first completions in the window, or is zero without completions.stats.rejectionShareremains the share of window submissions sent back.activeAgentscontainstotal, anagentslist (id,lane,role), and counts inrolesandlanes. Activity means any move in the inclusive last 60 minutes, independent of--since. Builder roles come from claim, submit or build release; verifier roles from reserve, verdict or review release. A roleless move retains the latest recorded role, including earlier history; without one the role isunknown. The coordinator's role iscoordinator. Lanes come from recorded joins, not a guess from the id.bottleneckcontainsstage,share,completedItems,fullyMeasuredCycles,totalCycleMinutes,lowConfidence,messageandrecommendation. Only completed cycles with all four measured stages contribute stage shares; the line states how many fully measured cycles support it. Fewer than half the completed cycles fully measured sets low confidence. The largest summed share wins, with ties in build, review wait, review, merge wait order. No completed cycles, or none fully measured, means no named stage or action (null).
The single recommended action uses the first matching rule: review wait with fewer than one active verifier per four active builders says add a verifier; otherwise review the oldest submission first (#N). Build with more than 30% of window submissions sent back says tighten criteria or briefs: N of M submissions were sent back; otherwise add a builder in <lane with the most open items>, with lane-name ties sorted alphabetically. Review says reviews are slow (median X min): check how long the frozen checks take. Merge wait says land the accepted items: #A, #B, with ids ascending. If the relevant queue is empty, its action explicitly says none waiting, or add a builder when open work is available. JSON retains numeric precision; CLI minutes round to two decimal places. Statistics are read-only.
stats.flow.unreviewed is a current {size, oldest} queue using the same {id, since, ageMinutes} shape and observation as the other queues. It contains submitted items with no verdict since their final submit and no live reviewer reservation; released or expired reservations count as unreserved, and their age still starts at the final submit.
The coordinator's resume ends with one flow: line if the measured review-wait average is strictly greater than 60 minutes, or the oldest currently unreviewed submission is strictly older than 180 minutes. Exact boundaries do not trip. Both thresholds share one line if both trip. The line gives the tripped threshold's numbers, the measured bottleneck stage, its share and total cycle minutes, and the same recommended action as stats. It does not replace a measured build, review or merge bottleneck with review wait. Without a fully measured cycle, a trip still names its threshold and waiting item, followed by bottleneck: not measured yet, with no invented stage or action. A healthy or unmeasured board with no trip adds no line. Agent resumes do not add this line. Coordinator resume JSON includes the same flow observation as stats.
The coordinator's resume opens, after its header line, with its inbox: inbox: <n> unread shouts, <n> open decisions for you, then the newest three unread shouts by id, sender, age and first line, then <n> more; read them all with pullboard inbox when there are more. Only shouts to the coordinator, its lane or all count. While shouts are unread its next: reads read your <n> unread shouts first: pullboard inbox; then .... Resume marks nothing read; inbox does. Coordinator resume JSON carries the same as inbox: {unread, decisions, newest: [{id, from, at, text}], more, read}.
On a board with items and no spec rows, resume treats the spec setup step as complete and offers pullboard spec draft "<text>" as optional guidance. Its next action follows the actual work or another unanswered setup step, and does not require drafting or approving rows first. A board with existing rows retains its approval and apply guidance.
The coordinator's resume names who commits which uncommitted Pullboard file. A row file (the spec or the practice file) that is committed and changed, as spec apply leaves it, is the coordinator's, together with any sign-off receipt a signed apply appended to .pullboard/signoffs.jsonl: The coordinator must commit the row file spec apply changed: <git add and git commit of exactly those files> (the line says changed and its sign-off receipt: when a receipt is included), and its next: reads commit the applied row file first: <that command>; then .... Every other setup file that differs from the last commit, including a row file init wrote that is not committed yet, is the person's: The person must commit Pullboard's setup files before agents can use worktrees: <git add of exactly those files>. Each line appears only when it has files, and a dirty file that is not a setup file is never named. Coordinator resume JSON carries the same as commits: {coordinator: {files, command} | null, person: {files, command} | null}.
For example: flow: review wait average 61 min (1 measured; > 60 min); bottleneck: build (66.67% of 300 measured cycle min); next: add a builder when open work is available. An unmeasured trip reads flow: unreviewed #1 waiting 181 min (> 180 min); bottleneck: not measured yet.
firstEventAt and lastEventAt are the first and latest dates in the selected window, or null when it is empty. since is the inclusive boundary as an ISO UTC timestamp, or null for the full history. Dates accept YYYY-MM-DD or an ISO UTC timestamp ending in Z; invalid dates return BAD_SINCE with repair guidance.
agentCount counts distinct event actors other than board and person; the coordinator and join moves are included. agents lists {id, moves, families} for each actor. families lists {name, agents, moves} by recorded family label, and familyCount counts these buckets, including unknown. Labels are not inferred from a model name or current agent, item, or verdict rows. A family's first recorded event snapshot applies to that move and following moves, until another snapshot changes or clears it; earlier unattributed moves remain unknown. Date windows preserve earlier recorded declarations but count only selected moves. Arrays are sorted by identifier or label. Local HTTP board state carries the same full-history object as state.proofStats.
merged <id> <commit> records only a commit reachable from the primary checkout's branch that contains the item's submitted commit or has the same stable patch id as the item's change from its claim base. Other commits are refused with NOT_MERGED; --note "why" records an exceptional receipt and keeps the note in that item's merged event.
| Command | Required top-level fields |
|---|---|
help |
version:number, help:string |
version |
version:number, release:string |
init |
version:number, root:string, notes:array, proposedLanes:array |
hooks |
version:number, notes:array |
join |
version:number, agent:string, lane:string, route:string, path:string, model:string, displayName:string |
takeover |
version:number, agent:string, path:string |
worktree |
version:number, agent:string, lane:string, route:string, path:string, branch:string, prompt:string, model:string, displayName:string |
resume |
version:number, me:object, all:array, requests:array, holding:array, sentBack:array, awaiting:array, toVerify:array, toMerge:array, open:array, stale:array, doctrineNotes:array, holds:array, unread:number, newest:array, root:string, newWorktreeLocation:string, dirty:number, next:string |
whoami |
version:number, id:string, lane:string, path:string, model:string, displayName:string |
agents |
version:number, agents:array |
lanes |
version:number, lanes:object, shared:array, coordinator:string |
lane add |
version:number, lane:string, owns:array |
resources |
version:number, resources:array |
settings |
version:number, settings:object |
runner |
version:number, runner:object |
relay |
version:number, linked:boolean, board:string, url:string, link:string, sequence:number, behind:number, recovery:object |
list |
version:number, items:array |
queue |
version:number, releases:array, unreleased:array, first:object |
roadmap |
version:number, milestones:array |
milestone add |
version:number, milestone:object |
milestone items |
version:number, milestone:object |
milestone order |
version:number, milestone:object |
milestone move |
version:number, milestone:object |
milestone edit |
version:number, milestone:object |
milestone remove |
version:number, milestone:object |
milestone ship |
version:number, milestone:object, `into:string |
show |
version:number, item_id:number, item_title:string, item_lane:string, item_status:string, verdicts:array, thread:array |
stats |
version:number, stats:object, gateWaits:array |
status |
version:number, me:object, mine:array, stats:object, reviewQueue:object, unread:number, relay:object |
doctor |
version:number, problems:array |
health |
version:number, asOf:string, thresholds:object, stuck:array, waiting:array |
skills |
version:number, updated:array, current:array, customized:array |
inbox |
version:number, shouts:array |
decisions |
version:number, decisions:array |
ledger |
version:number, items:array, stats:object |
log |
version:number, events:array |
flakes |
version:number, flakes:array, unclassified:array, regressions:array, stable:array |
flakes rerun |
version:number, flakes:object, file:string |
rigor |
version:number, causes:array, uncaused:number |
rigor draft |
version:number, key:string, reads:string, evidence:object, proof:object, drafts:array |
add |
version:number, item:object |
edit |
version:number, item:object |
fact |
version:number, item:number, fact:object |
why |
version:number, id:number, decisions:array |
escalate |
version:number, id:number, from:string, to:string |
run |
version:number, messages:array |
sweep |
version:number, messages:array |
next |
version:number, item:object, review:boolean, held:boolean, shared:array |
check |
version:number, id:number, green:boolean, seconds:number, check:string, receipt:object, report:string |
check accept |
version:number, id:number, digest:string, check:string |
claim |
version:number, id:number, renewed:boolean, leaseUntil:string, digest:string |
hold |
version:number, lane:string, held:boolean |
release |
version:number, id:number |
submit |
version:number, id:number, commit:string, pin:string, gate:object |
done |
version:number, id:number, commit:string, pin:string, gate:object |
verify |
version:number, id:number, decision:string, reason:string |
merged |
version:number, id:number, commit:string |
land |
version:number, batch:object|null, landed:array, conflicts:array, blocked:array, culprits:array, flakes:array |
withdraw |
version:number, id:number, reason:string, milestones:array |
refreeze |
version:number, id:number, after:string |
reopen |
version:number, id:number, commit:string |
shout |
version:number, id:number, decision:boolean |
answer |
version:number, id:number, answers:number |
pass |
version:number, id:number, answers:number |
tour |
version:number, messages:array |
lifecycle |
version:number, markdown:string |
view |
version:number, url:string, port:number |
view export |
version:number, path:string |
serve |
version:number, url:string, port:number |
watch |
one document per line: version:number, sequence:number, kind:string, item:number|null, actor:string|null, detail:string |
forget |
version:number, root:string |
prompt |
version:number, role:string, text:string |
gate |
version:number, green:boolean, report:string |
gate set |
version:number, gate:string, file:string |
export |
version:number, tables:object |
digest |
version:number, markdown:string, bytes:number, sequence:number, sections:array |
import |
version:number, tables:array |
spec |
version:number, rows:array |
spec check |
version:number, rows:array |
spec view |
version:number, path:string |
spec show |
version:number, row:object, standing:object |
blueprint starters |
version:number, blueprint:object, starters:array |
spec draft |
version:number, id:string, row:object |
blueprint add |
version:number, blueprint:object, section:string, rows:array, items:array, gate:string|null, proposedLanes:array |
blueprint fit |
version:number, proposals:array |
spec fit |
version:number, proposals:array |
spec unmet |
version:number, rows:array |
spec signoff |
version:number, count:number, by:string, ids:array, evidence:array |
spec signers |
version:number, added:boolean, by:string, path:string, initial:boolean |
spec approve |
version:number, decisions:array |
spec decline |
version:number, decisions:array |
spec answer |
version:number, decisions:array |
spec apply |
version:number, applied:array, files:array |
doctrine check |
version:number, rules:array |
hook pre-commit |
version:number, messages:array |
hook pre-merge-commit |
version:number, messages:array |
hook commit-msg |
version:number, messages:array |
hook pre-push |
version:number, messages:array |
sentinel propose |
version:number, sentinel:object |
sentinel approve |
version:number, sentinel:object |
sentinel decline |
version:number, sentinel:object |
sentinels |
version:number, sentinels:array |
loop |
version:number, jobs:array |
loop declare |
version:number, job:object |
loop ran |
version:number, job:object |
spec draft "<rule>" --doctrine --source "chat, 2026-10-16" proposes a sourced doctrine row and returns a qualified id, such as doctrine:DR1, for the existing person approval commands. Resume adds doctrineNotes, an array of newly seen approved rules with id, text and gate; each checkout consumes its own notices. Drafts and pending applications are excluded. The shared approved doctrine also supplies frozen citations and stale-item comparisons in older checkouts.
hook pre-merge-commit runs the same staged checks as hook pre-commit before Git creates an automatic merge commit. init and hooks install it; doctor names it when absent at Git's effective hook path. Lane ownership comes from HEAD:pullboard.json, so an unstaged configuration edit cannot grant a lane new folders. lane add writes the new lane to the working pullboard.json and returns { lane, owns }; commit the file before the lane's ownership takes effect. init returns the lanes it proposes as proposedLanes, each { name, owns }, and writes none of them. Lane-sensitive checks refuse a repository with info/grafts in its Git common directory because grafts alter ancestry even when replacement objects are disabled.
join and worktree require a free-text --model label or PULLBOARD_MODEL environment value; labels are not inferred from a provider or family. Rejoining the same worktree preserves its agent id; the model label is required on every join, and omitting the optional --family preserves its prior declaration. pullboard.json may set agents.names to suffix (the default, web-1 (Claude)) or prefix (claude-web-1). CLI agent fields remain stable ids; displayName shows the chosen style. resume.me and API state agents[] carry model and displayName; older agents with no model display unknown. Each event records the actor model in event_detail.model for a registered agent, with unknown for legacy agents without a declaration. show includes item_builder_family and each verdict's verdict_verifier_family. These family fields are null when the agent did not declare a family, and later declarations do not rewrite prior submissions or verdicts.
Each checkout is bound to the first agent session that writes there. The local Git directory holds only its session digest, agent id and timestamp; bindings are absent from board exports and relay snapshots. Another agent session gets NOT_YOUR_CHECKOUT and the command to make its own worktree. The same agent starting a new session runs pullboard takeover; the existing ordered shout records the takeover, addressed to the person for the main checkout or the coordinator for a lane checkout. On a linked board one main checkout holds the coordinator at a time: another linked main checkout acting as coordinator gets COORDINATOR_HELD, naming the holder as computer and the end of its relay machine id, and pullboard takeover there, from an agent session or a person's terminal, takes the coordinator; its result adds coordinator (the new holder) and previous. Markerless terminals, reads, the view and person requests keep their existing behavior. A shout or decision addressed to its sender is refused with SELF_SHOUT.
status.reviewQueue contains pending, reviewing (distinct agents with live review leases), reserved, oldestSubmittedAt and ageMs. Age starts at each outstanding item's latest submit event; reserving or renewing its review does not reset it. An empty queue has oldestSubmittedAt: null and ageMs: 0. Among unreserved submissions, awaitingFirstReview counts items with no release since their latest submission; releasedWithoutVerdict counts those released without a verdict. releasedItems lists {item, releases, reason} with the cumulative release count and latest reason for that submission. Historical releases without a reason show reason not recorded.
With submitGate: "landing", status.submitInvariantProposals lists files that failed outside a recorded submit selection. Each proposal carries file, escapes (distinct tested merge commits), runtimeMs, budgetMs: 60000, withinBudget, commits and the participating submissions. Repeated escapes below budget come first. Unmeasured whole-file runtime is null, not a sum of test-case durations. A later whole-file diagnostic or full gate passing at the identical commit suppresses that escape; another commit or merely an equal Git tree does not. Submission events record versioned submissionProof: { version: 1, files, full, deferred, reason }, including the actual full-fallback reason. A deferred full fallback records only invariants actually selected to run, rather than its possible full selection. Historical submissions without this receipt remain unknown. Landing batches append versioned invariantEvidence with actual tested commits, participants and whole-file diagnostics; earlier evidence cannot be replaced. Status reads those canonical events and changes no configuration. Edit submitInvariants on main to accept a proposal. Outside the landing submit policy the proposal list is empty.
Releasing a reserved review requires a nonempty one-line --note (or --note-file); the authenticated release action accepts the same note field. A refusal keeps the reservation and records no release. The same reviewer is not offered that submission again until it is resubmitted or an hour passes, while other reviewers remain eligible. Releasing a build claim keeps its existing behavior and does not require a reason.
claim.reopenFirstRefusalMinutes in pullboard.json is a non-negative whole number of minutes, default 0 (off). With a positive value, rejection, a failed landing or a trunk conflict gives the original builder first refusal until the recorded UTC deadline. Another seat receives REOPEN_HELD_FOR_BUILDER with the builder and deadline; next skips that reserved work for other seats. The builder receives one persisted notice when the item returns. At the deadline anyone may claim. Before it, the builder may yield with pullboard release <id> directly from the open state, or release after reclaiming. Other seats and review releases cannot cancel that reservation. Ordinary lane, route, dependency and one-live-claim rules still apply. Without the setting, reopened claims and releases keep their existing behavior. The window is recorded in the return event so linked replicas use the same builder and deadline.
With verify.unrelatedTrunkMoves: true in pullboard.json (off by default), an accept whose review saw main move is still recorded when every moved commit changes none of the item's files and none of the files its check and affected tests read; the verdict note names those commits. Any other move, or a check that is not an explicit test selection, refuses VERDICT_TRUNK_MOVED as before, naming the file that reaches the review.
When outstanding reviews reach verify.reviewRatio times the active reviewers (default 3, with zero reviewers counting as one), next first offers a review the agent may take. This creates no claim or reservation. Its additive v1 offer has the review's item id, exact command to reserve it, queue and ratio; build previews the otherwise available build or is null. next --verify <id> reserves explicitly. next --build claims explicitly; a fresh claim's event detail records reviewSkipped with the offered id (or null), ratio and queue snapshot. Renewing an existing claim creates no new skip.
For HTTP next, a review offer returns success with event: null, offer and the ordinary CLI result. Send args.build: true to claim as before and receive the actual claim event. The unattended run command always supplies explicit build intent, records it on fresh claims, and prints the review backlog each iteration.
pullboard health scans the board read-only and lists every stuck item with its cause, since when, the one next step and who takes it. It opens the board through a read-only connection, records no event, skips relay sync and asks git only merge-tree, which never touches a HEAD, index or working tree. It exits 0 when nothing is stuck and 1 when something is; the text form then prints nothing is stuck, or one line per finding: #12 claim lapsed, since 2026-10-17 10:00 (2h): web-3's lease ran out and nobody is building it. Next, web-3 (builder): renew it with pullboard claim 12.
health --json returns {version: 1, asOf, thresholds: {stuck, holds, release}, stuck: [...], waiting: [...]}. asOf is the board clock's ISO time. Each finding in stuck and waiting is {key, cause, item, subject, title, since, ageMinutes, why, next, command, owner: {role, who}, detail}:
keynames the cause and what is stuck, likeCLAIM_LAPSED:item:12,HOLD_STALE:lane:docs,DECISION_UNANSWERED:shout:40orRELEASE_OVERDUE:release:v0.9.0; it stays the same while the finding persists, so a watcher can post it once and notice when it clears.itemis the item id, ornullfor a lane hold, a decision or a release;subjectis the same thing as text (#12,lane docs,decision #40,release v0.9.0).sinceis when the wait began, in ISO time;ageMinutescounts whole minutes from it toasOf.
A gate has its own two alarms, which need no board. Each real pullboard gate run appends a line to pullboard-gate-runs.jsonl in the git directory, with its result and, when pullboard.json declares positiveControl, that known-good command's result. GATE_CONTROL_FAILED:gate is stuck when the last run's positive control failed. GATE_REFUSED_EVERY_RUN:gate is stuck when the runs in the last day were all refused, and there are at least three of them. Each carries the gate, its control and its last five runs in detail; its command is pullboard gate for the refusal alarm.
whyandnextare plain sentences;commandis the exact command for the next step, ornullwhen the step has none.owner.roleisbuilder,reviewer,coordinatororperson;owner.whois the agent, lane,coordinatororperson, andnullwhen any reviewer may take it.detailholds the cause's facts:holder,reviewer,builder,commit,filesandtrunkfor a conflict,parentfor a stacked item,reasonandbyfor a hold,withdrawn,after,moveandmilestone,shout,fromandto, orreleaseandlanded.
The causes, each with its owner:
| cause | stuck when | owner |
|---|---|---|
CLAIM_LAPSED |
a claim's lease ran out; since is when it ran out |
the holder |
REVIEW_LAPSED |
a review reservation's lease ran out with no verdict | a reviewer |
NO_REVIEWER |
a submission has had no reviewer past health.stuck |
a reviewer |
UNLANDED_CONFLICT |
accepted past health.stuck and its commit conflicts with the trunk; detail.files names the files |
its builder |
UNLANDED_HELD |
accepted past health.stuck and held; detail.reason names the hold |
who holds it |
UNLANDED_STACKED |
accepted past health.stuck and carrying another item's commit that has no accept |
a reviewer, or that item's builder |
UNLANDED_WAITING |
accepted past health.stuck and waiting on an item it lands after |
the coordinator |
UNLANDED_READY |
accepted past health.stuck, merges cleanly and nobody landed it |
the coordinator |
HOLD_STALE |
an item or lane hold older than health.holds |
who holds it |
CONFLICT_HOLD_CLEARS |
a hold whose reason names a conflict, on an item whose commit now merges cleanly | who holds it |
REWORK_UNCLAIMED |
rejected or reopened in the current release and unclaimed past health.stuck |
its builder |
REWORK_LATER |
the same in a later milestone; listed in waiting |
its builder |
AFTER_WITHDRAWN |
an unfinished item waits on a withdrawn item | the coordinator |
DECISION_UNANSWERED |
a decision to the coordinator or the person is unanswered past health.stuck |
the coordinator or the person |
REQUEST_UNANSWERED |
a person's request is not done or declined past health.stuck |
the coordinator |
RELEASE_OVERDUE |
landed work waits and the newest version tag is older than health.release |
the coordinator |
ROUTE_UNSERVED |
open current work on a route that no active agent can take; detail.items names them |
the coordinator, who starts an agent on the route |
ITEM_UNTAKEABLE |
an open item that no active agent in its lane can take on its route | the coordinator |
IDLE_AGENT |
an agent with no claim, review or event for 30 minutes while open work in its lane is one it could take | the agent itself |
RANK_INVERSION |
a claim whose holder could take an earlier-ranked item that sits free; detail.ahead names it |
the holder |
An agent is active while it holds a claim or review, has submitted work still awaiting review, or had an event or shout in the last 30 minutes. Queue faults read the queue's own order, so pullboard queue and health agree.
The current release is the roadmap's first milestone with unverified local work; an unplanned item counts as current. A release is the newest tag named like v1.2.3 or 1.2.3, dated by its creation, and its landed work is every item first recorded merged after it. A repository with no such tag has no release deadline. The trunk is the primary checkout's branch, or the retained trunk when that checkout is detached.
The health sentinel runs this read-only scan on pullboard resume and every ten minutes while the background view runs. New findings and resolutions arrive as ordinary shouts from sentinel to the coordinator and the finding’s owner, with its cause and next step. A continuing episode is not posted again. Ordered engine-42 receipts retain the observed revision; a concurrent board change defers the observation to a later resume instead of publishing stale findings. Health never changes claim state. Set health.sentinel to false in this repo’s pullboard.json or in machine settings to turn automatic scans off. Repeated health-failure receipts are visible to pullboard rigor, including a recurring work cause with no changed code commit.
pullboard.json sets the thresholds under health, each a duration like 90m, 2h or 1d: stuck (default 1h), holds (default 1d) and release (default 1d). A value that is not a duration is refused as BAD_CONFIG.
Starting with engine 50, the second REJECT verdict since an item's latest route change records the existing escalation move automatically. Releases do not count as failed attempts. One escalate event carries both rejection records, including their reason, note, commit, digest, checked head and timestamp. A hand escalation or a route edit starts a new count; strong remains the ceiling. Sealed operations from engines below 50 retain their prior replay behavior. Starting with engine 54, a pair at the strongest route records a strong-rejection-notice and sends the coordinator one message naming the builder and both rejection receipts, without an escalate event or route change. The notice starts the next pair count; ordinary reject, owner and lease behavior is unchanged. Engine 50 through 53 replay retains its ceiling escalation.
pullboard fact <id> <kind> "<text>" appends a fact to an existing item. The observation kinds are capture, measurement, note and diff; any registered agent may append them. The judgement kinds are decision, rejection, supersession and root-cause; only the item's live lease holder or the coordinator may append them. An expired lease gives no judgement permission. Facts do not change the item's status or its claim.
The result is {version: 1, item: <item-id>, fact: {...}}. A fact has id (stable string identity), eventId (board-local event sequence), kind, exact text, by (registered author), at (UTC timestamp), ref and supersedes. The board supplies identity, author and time. ref and supersedes are null when omitted.
Use --ref src/file.js:12-30@<full-40-character-sha> to bind a fact to a committed code range. A single line is :12@.... The reference becomes {path, start, end, commit}; paths are relative to the repo, line numbers are positive and ascending, and the commit is lowercase hexadecimal. Short hashes are refused with BAD_FACT_REF. Replicas validate this binding without fetching source code.
A correction is a new fact with --supersedes <fact-id>. It requires the live holder or coordinator even when its kind is an observation. The earlier fact remains visible; supersession must name a fact on the same item. A missing or cross-item identity is refused with NO_FACT. Unsupported kinds receive BAD_FACT_KIND, empty text receives EMPTY_FACT, and unauthorized judgements or corrections receive FACT_JUDGEMENT.
show --json retains its existing item fields and verdicts and adds thread. The HTTP item detail's thread field, also on each item in the full board state, has the same array. Entries are oldest first, ordered by the append-only event sequence. A fact entry is {type: "fact", ...fact}. A move entry is {type: "move", eventId, kind, by, at, detail}, with detail holding the original move's structured event detail. Text show prints these moves and facts as one timeline, including full references and both sides of a correction. Ordinary verdict summaries and --history remain available.
A decision names the items it changes with --implies 12,13 on fact, verify or answer; the HTTP moves take the same value as args.implies, and answer also takes args.supersedes, the id of an earlier answer. Each named item must exist; an unknown one is refused with NO_ITEM naming it, before anything is recorded. Each named item's thread then gets a note fact with a decision citation {source, id, item, by, line}, and a second fact with supersededBy when that decision is superseded (docs/formats.md has the shapes). These optional fact fields appear only when set. by is read from the board's record of the decision, never from the arguments, and only the person supersedes the person's answer. pullboard why <id> returns {version: 1, id, decisions: [...]}, newest first; each entry is the citation plus at, fact (the implied fact's id) and supersededBy (a citation, or null). resume --json adds decisions: the implied facts on held items shown for the first time, each {item, fact, text}.
The HTTP move is {verb: "fact", item: 12, agent: "web-1", args: {kind: "measurement", text: "The check takes 8 seconds", ref: "src/file.js:12-30@<full-sha>"}}. Omit agent to act as the coordinator; args.supersedes supplies a correction's earlier fact identity. The response carries the committed fact event and the CLI result. The event-log format stays at version 1 because facts use its existing immutable event rows; exports and imports preserve those rows. The sealed move engine advances to version 3 for this release so older engines refuse the new operation before replay.
Rigor is how errors become doctrine or specs. It starts by noticing a failure cause that repeats. A cause key comes only from structured fields: test:<file>::<title>, refusal:<CODE>, row:<id> or doctrine:<id>. A reject (verify <id> reject ... --cause <key>) and a reopen <id> --note "..." --cause <key> record the key they name as cause in their event detail. A reopen after a red landing also gives one test: key per failing test in its landingFailure.tests, including reopens recorded before cause keys existed. Notes are never parsed, so an older reject with no cause counts as uncaused. A malformed key is refused with BAD_CAUSE before the move. Merge conflicts never count: a landing test whose name starts with conflicts with, and refusal:MERGE_CONFLICT.
pullboard flakes lists recorded test failures: confirmed flake sightings, unclassified failures, possible regressions and stable red targets. Each observation retains the pinned source and complete raw/profile digests. Raw artifacts stay in the local common Git directory under pullboard/flake-evidence/; another device reading the board does not automatically receive those files. A paired pass never changes the original red verdict.
The coordinator can run pullboard flakes rerun on|off, then commit pullboard.json to apply that policy. Paired diagnostics default off; recording remains on. On allows one exact-target pair for a supported structured Node target at unchanged source, with a pinned-main comparison. A green main is a possible regression. Unknown selectors, changed source and unsupported remote comparisons remain unclassified. A second confirmed sighting drafts ordinary strong repair work; it approves no spec or doctrine row. Off preserves the released landing diagnosis and its first legacy defect. Enabled landing consumes the saved pair, including after interruption, without another diagnostic measurement.
pullboard rigor is read-only. It lists each cause that repeated: on 2 items, or on 1 item at 2 different commits. Each line shows the key, how it reads (work for doctrine: and refusal:, product for row:, both for test:), its failure count and its items. Its JSON result is {version, causes: [{key, reads, occurrences, neverAgain, items: [{id, commits}]}], uncaused}; uncaused counts rejects and reopens with no cause key.
The person can mark a cause never-again from the main checkout's terminal: pullboard fact <id> root-cause "<why>" --cause <key> --never-again --as person. The fact is recorded as the person's, carries cause and neverAgain: true, and rigor lists its cause at once. An agent or a fact without --as person is refused with RIGOR_PERSON_ONLY. Over the relay, a fact move with person mode, a never-again or a cause key is accepted only from an authenticated person sender; an agent, a coordinator seat or the machine credential is refused with RELAY_PERSON_ONLY whatever channel the move names, and nothing is recorded. A move that carries a cause key needs move engine 30; moves without one replay as before.
pullboard rigor draft <key> "<rule>" --check "<command>" --red <commit> --green <commit> [--section <letter>] turns a currently noticed cause into a draft row. --red must resolve to one of that exact cause's failure-receipt commits; an unrelated red commit is refused with RIGOR_UNPROVEN, naming the noticed commits. A work check exits 0 or 1 and prints problems at red and none at green. A product check fails at red and passes at green. For a test:<file>::<title> cause, the first supported proof format is a literal node --test <file> command, optionally with --test-name-pattern: the exact file must be regular in both commits, and complete real Node TAP must fail that named test at red and pass it at green without skipping it. Shell wrappers, other runners and unrelated files or selectors are refused rather than inferred to execute the named test. Node preload options are not carried into named-test proof. Ordinary board checks keep their existing runner support.
The proof holds a real local machine gate while it runs on throwaway detached worktrees. Its owned child group is drained before each worktree is removed; an uncertain drain retains the worktree and refuses proof. Missing references, a timeout, incomplete test accounting or a check that is not red then green is RIGOR_UNPROVEN. Work causes draft doctrine with gate: check <command> | count: 0; product causes draft spec with the test gate; unclear test causes draft both, deriving a portable doctrine scanner that prints only a failed test run so a passing test has count zero. Each row's Why retains the evidence and both full commit hashes. Rows remain [draft, must]; only the person approves, and ordinary spec apply applies that decision with no extra proof-building item. The JSON result is {version, key, reads, evidence: {items, occurrences, neverAgain}, proof: {check, red, green}, drafts: [{id, file, line, row}]}; red and green retain {commit, exit, problems, timedOut}. --section selects the existing target section, defaulting to doctrine's first section or spec's G.
A refusal keeps the command's existing exit status and prints exactly one versioned document to stdout. In JSON mode stderr stays empty except for sign-in instructions and check consent notices. The command-specific code identifies the rule; message explains what happened; next gives the next step.
Only the coordinator sets or edits an item's --check. pullboard check [id] prints the command and its setter before execution, and asks for consent when the caller did not set it. Use --yes to confirm that command without a prompt. An EOF or declined answer refuses with CHECK_CONFIRM before running the command. With --json, the notice and prompt appear immediately on stderr and stdout remains one JSON document; a successful check result also includes the optional by setter field.
pullboard add and pullboard edit refuse a check with CHECK_MASKS_EXIT, naming the stage and the fix, when a pipeline's last stage is not the command that proves the work, so a red run would exit 0 (for example node bin/run-tests.js ... | tail). That includes a pipeline inside a ( ... ) subshell or a { ...; } group, or after earlier commands or assignments. A check is exempt only when its first command is set -o pipefail (alone or with other set flags, run unconditionally) and it never clears pipefail, or when it captures the output first and tests it (out=$(...)). The rule reads the check's own top-level text: a pipeline inside a nested shell (sh -c, bash -c), eval or a script file is out of its scope.
{
"version": 1,
"error": {
"code": "NODE_TOO_OLD",
"message": "this is Node 20.11.1, and pullboard needs Node 22.13 or newer",
"next": "install Node 22.13 or newer, then run the command again"
}
}| Part | Required fields |
|---|---|
envelope |
version:number, error:object |
error |
code:string, message:string, next:string |
relay on, relay, and relay off share the relay result shape. Successful pullboard relay recover --skip N returns that same shape with optional skipped: N naming the explicitly refused sequence and adopted: true when it adopts a different authenticated person checkpoint that proves the same blocked refusal. Every relay result includes recovery: { pending, skip, next }; when a saved recovery is pending, next is the exact retry command. The behind count remains the number of pending uploads and does not include recovery. status includes this relay object alongside the local board summary. Human status and relay output show the recovery sequence and retry command when one is saved. The local agent_last_shout_id inbox read cursor is metadata: recovery ignores only that field in its guarded digest and preserves the greater valid cursor for matching agents. Other native changes remain protected; if a semantic local write changes while recovery is pending, the CLI preserves it and refuses automatic restoration. Exporting alone does not unblock that conflict; this prototype has no merge or discard command, so the person must reconcile the export and remote checkpoint before continuing. Successful results may include diagnostics for a relay refusal or inactivity notice; a local move still succeeds when its upload must wait. Device sign-in instructions are written immediately to stderr, including with --json, so the person can sign in before the command returns.
With "relay": { "outbox": true } in the committed pullboard.json (off by default), a board move on a link whose baseline the relay accepted no longer fails while the relay is unavailable. The move is sealed into this machine's relay outbox in the order it was made, made on this machine at once, and the command exits 0. The relay result then carries outbox: [{id, operation, item, actor, queuedAt}], counted in behind, and pullboard show <id> --json carries relayPending for the item's queued moves. The next command that reaches the relay replays the outbox first, in order: a move the relay already holds from a lost reply is not sent twice, and a move refused at replay, because another machine's move came first, is kept in outboxRefusals: [{move, queuedAt, code, message, earlier: [{sequence, move}]}] with the moves the relay ordered before it. While queued moves wait, RELAY_OUTBOX_PENDING refuses to apply other machines' moves underneath them.
relay on --all [--url <address>] returns {version, linked: [{project, root, board}], failed: [{project, root, reason}], paired, autoLink: true}. Its status is nonzero when a project failed; successful links remain available. With no paired phone, the command prints one tappable pairing link and QR, then waits in the foreground up to ten minutes. Interruption or expiration leaves completed links intact and exits zero when no link failed. Every explicit invocation signs in afresh; no person session is retained on the Mac. Later project registrations publish one sealed phone-link proposal and keep working locally until its single approval tap. doctor and resume name the waiting project and its expiry; expiration requires an explicit fresh sign-in rather than silently asking again. relay off excludes that project from automatic linking; explicit relay on clears the exclusion.
relay devices returns {version, devices: [{deviceId, pairedHere, label, createdAt, seen, revoked, boards}], computers: [{machine, host, created, seen, boards}]} without a private key or credential. devices are the phones that read this computer's linked boards; computers are the other computers that joined one of them with relay join. Only the computer that paired a phone keeps its label and createdAt; a phone paired on another computer has pairedHere: false, null label and createdAt, and prints as paired on another computer with the end of its id. seen is the last relay contact in epoch milliseconds, or null when unknown, revoked is the relay's revocation status, and boards names the linked boards each one can read. The text output names each one with its paired date, last seen and boards. --verbose adds ids in the text and, for phones paired here, the key fingerprint in both text and JSON. relay revoke <deviceId> returns {version, deviceId, revoked: true, notice} after removing the local recipient and deleting its remote wraps. Native revocation never asks the target device to approve its own removal. Another paired phone can approve it with a short-lived, single-use, RAM-only grant for that exact target. With no other paired approver or no linked publishing board, the person completes a fresh GitHub device-code sign-in on the computer instead. The authenticated account and relay origin must match the saved enrollment; the fresh session stays in memory and is revoked after the action. Agent terminals are refused before any sign-in or network request. A failed or interrupted approval or sign-in needs an explicit retry. Revocation stops future wrapping and refuses the removed browser’s enrolled session with DEVICE_REVOKED (HTTP 403), including snapshot/event reads and auth-session, board-list and token-list reads. That same principal boundary refuses its machine or agent credential issuance, so the removed session cannot mint another reader. The public board-free shell still loads to clear local browser data; it grants no person actions. Its next connected page clears the locally held board keys, private device key and board request cache and shows that it was removed; reload retains the refusal. The relay stores only public session ids tied to the enrollment, never an extra bearer credential. A fresh GitHub sign-in remains eligible for recovery; it does not resurrect the revoked device. An offline copy cannot be remotely erased and board key rotation remains separate (#346).
Phone enrollment uses a one-use #device=<locator>.<secret> fragment, removed before sign-in. The secret remains on the phone and Mac. Person-authenticated /api/v1/devices/session returns the current account id. /api/v1/devices/enrollments/:locator supports POST {} to start a ten-minute locator, PUT of the exact public enrollment and MAC, GET of {version, enrollment} and DELETE to consume it. A native Mac checks the MAC locally before retaining any recipient. /api/v1/devices/:deviceId supports POST {} to record an authenticated recipient and DELETE to revoke it. PUT /api/v1/devices/:deviceId/boards/:boardId stores {engine, wrapped}; GET /api/v1/devices/:deviceId/boards returns {version, wraps: [{board, engine, wrapped}]} filtered by current board access. wrapped is opaque base64url client ciphertext. Enrollment, device listing and direct revocation require a person session. A board-scoped machine credential can PUT only its own board wrap to an already enrolled device. Cookie writes require the exact configured Origin, and all responses are no-store. Device ids and grants belong to the authenticated GitHub account. Revoked ids cannot be registered or uploaded again.
POST /auth/machines {board,machine,host?} requires a foreground person session and returns a pm_ credential bound to that board and machine; relay join sends its host name as host. GET /api/v1/devices/<deviceId> with a board's pm_ credential returns {version, seen, revoked} for a phone of the same account, with null fields for an unknown id. GET /api/v1/devices with that credential returns {version, devices: [{deviceId, seen, revoked}]} for the phones holding that board's key, by id only; names stay on the computer that paired them. This delegate can list only its board’s token ids and metadata, and through POST /auth/tokens {board,agent} it first-mints a pa_ token only for a seat the relay never issued a token to, including after that token expired or was revoked (TOKEN_EXISTS otherwise). Such a seat (a manual pullboard join, or any name minted ahead) is machine-origin for good: it builds, never verifies on a linked board, and its name cannot later be registered (SEAT_TAKEN). The native client also checks that the seat is registered, which the relay cannot see. It cannot link a board, issue a machine credential, post a phone request, revoke another credential directly, or use person-only cookie endpoints.
A pa_ token renews only its own seat: POST /auth/tokens {board,agent} naming itself returns a new token and revokes the one that asked (TOKEN_AGENT for any other seat). A seat's first token from another seat comes only with the move that registers it: POST /api/v1/boards/:board/moves {sequence, sealed, registration}, sent with the creating seat's token, where registration is {target, creator, sequence, digest} (the creator is the sender, the sequence is this upload's, and the digest is the SHA-256 of the sealed text). The relay appends the move and issues the target's first token in one board lock and returns it only in that reply as enrollment; the public event carries the receipt and never the token. An exact retry returns the same issuance id and expiry without a token. The receipt commits in the same journal transaction as the move and is kept through compaction, and it claims the target from that moment: the machine credential and a person are refused a first mint for any name a receipt claims, under the same board lock that appends receipts, so no failure between the append and the enrollment leaves a window. If enrollment fails after the append, the creator's exact retry, or its recover:true reissue, rolls the first mint forward from the journalled receipt. A lost token is reissued by its creator, POST /auth/tokens {board,agent,recover:true} with the creator's token, which revokes the old ones. /auth/tokens refuses a registration field (REGISTRATION_IN_MOVE), so a copied receipt never mints. Replicas apply an engine-48 registration by another seat only with the receipt that names its target, creator, position and digest, so the board's record of who created a seat matches the relay's.
POST /auth/coordinators {board} takes a person session only and issues the coordinator's token: the first one (bootstrap), a fresh one after loss, expiry or revocation (reissue), or the person's reset of a coordinator first minted by the machine (reset). It revokes every older coordinator token. pullboard relay on calls it inside its own sign-in, so a board needs no extra tap. GET /auth/tokens also returns coordinatorEvents, each with its reason, the person session id, the revoked token ids, the issued id and the time; pullboard relay tokens prints them.
On a linked board, an engine-48 verdict, review reservation or review offer needs verifier standing: the verifier was enrolled through the relay by its creator, each creator up the line was too, the line ends at the coordinator, and neither the builder nor a machine join is on it. The coordinator stands on the token the person set up. A seat without standing is refused with VERIFIER_STANDING in one line that names the fix, pullboard worktree <lane> --model "<model name>" run by the coordinator from the main checkout. Seats from before engine 48, or from before the board was linked, keep building; reviews need a fresh seat made that way. Each person-made coordinator token carries a machine lineage (a reissue that replaces a token keeps it; another machine's token starts its own), and a seat enrolled from it, or from a seat in its line, records that lineage. A coordinator claim records the lineage of the machine that builds it. When the coordinator built an item, a seat from another machine's registration lineage, or the person, verifies it; a seat from the building machine's own lineage is refused and told so. On an unlinked board who may verify is unchanged. The relay stamps each record it stores with policy (and root for the person-made coordinator token); a stamped record below engine 48 is refused, older unstamped records replay as they did, and a board with machine or agent credentials requires engine 48.
Residual: tokens on disk, in the repository's shared relay.json, are readable by every process of the same operating-system user, so a process that reads another seat's current pa_ can act as that seat and renew it; signed moves (#408) close that. An agent holding the machine credential can still make a seat of its own, by a manual join or by minting a never-issued name, which also makes a creator skip that name. That seat cannot verify that agent's work, nor any work on a linked board, and cannot act as any existing seat. A sealed snapshot uploaded with the machine credential remains a trusted checkpoint for replicas that restore from it.
GET /auth/machines?board=<id> returns {version, machines: [{machine, host, created, seen}]} for the live computer credentials on that board, with duplicate computer ids listed once. host is the host name the computer sent when it joined, or null; created is its first join and seen its last relay contact, which moves at most once a minute. It requires that board's machine credential or a person session with current repository write access; agent tokens and credentials from another board are refused. relay off excludes its own computer from the list and warns which joined computers lose the shared copy if the phone approves deletion. The warning names each computer by its host name, or by the end of its id when the relay has no host for it. JSON also returns those ids in affectedMachines and the names in joined. Local unlink still succeeds when the relay cannot supply the list.
After phone-approved deletion, a previously live scoped credential receives HTTP 410 / RELAY_BOARD_DELETED for that deleted copy. The relay retains only its hash, board id and original expiry as a deletion receipt, prunes expired receipts, and grants no access through them. Unknown, already revoked and already expired credentials receive AUTH_REQUIRED as before. Only a versioned deletion response naming this link's own board turns a joined computer back into a local board. The CLI removes the inert link and its device key, excludes the project from automatic relinking, and retains a private credential-free notice that the relay copy was deleted; native board contents remain local. A successful explicit relay on starts a new link and clears that notice. A network outage or ordinary credential refusal does not authorize this transition. Before forgetting a deleted copy, the CLI commits any pending native move and its durable receipt locally. The notice keeps only the receipt id and intent digests, so interrupted cleanup or an explicit repeat can return the original outcome without applying that move twice; different local work can continue beside it.
A machine publishes an opaque approval with POST /api/v1/devices/approvals {context,sealed}. The publishing machine may GET only its own requests. The paired phone reads /api/v1/devices/:deviceId/approvals, explicitly POSTs /:requestId/authorize {} and completes the encrypted reply through /:requestId/complete {response}. The publishing machine executes a native revocation through /:requestId/execute {grant}; the two-minute grant binds the entire context and is atomically consumed once. Native reply keys and grants never persist. A refused foreground approval fails immediately with the original refusal code and reason plus its request id. A refused remote approval makes local unlinking or registration exit nonzero while retaining the completed local work: one line names that completion, the refusal code and reason, the request id and the next action. The JSON result preserves the local outcome and includes remote: {code, reason, requestId}. New-board linking uses a durable non-person reply key and returns only a board-scoped machine credential. relay off immediately unlinks locally even offline or in an agent shell, reports that the relay copy remains, and queues a separate one-tap phone deletion request when possible.
The result and refusal tables are checked against JSON_SHAPES in src/json.js by docs/api.test.js.
When verify <id> accept runs a frozen check and refuses with CHECK_RED or CHECK_UNVERIFIED, error.message includes its output digest, up to 40 sanitized tail lines, and full output file: <path>. The file is written with owner-only permissions. If diagnostic storage fails, the message says full output file: (unavailable: CODE) while retaining the bounded sanitized lines; the JSON error object remains { code, message, next }. An item with no frozen check produces no check-output section and records check: "none" without requesting a native test or browser slot. Its submitted-commit, frozen-digest, trunk, dependency, review-reservation, and verdict guards still apply.
pullboard mcp serves MCP tools over stdio for the agent of the checkout it starts in. The agent's own MCP client launches it from that agent's worktree, for example as a stdio server whose command is pullboard mcp. It speaks newline-delimited JSON-RPC 2.0 on stdin and stdout; stdout carries only protocol messages and diagnostics go to stderr. The server opens no network connection and needs no dependency. It exits when the client closes stdin. It takes no arguments, and --json is refused with USAGE because stdout belongs to the protocol.
It answers initialize with protocolVersion (the client's, when it is 2025-06-18, 2025-03-26 or 2024-11-05, otherwise 2025-06-18), capabilities: {tools: {}}, serverInfo: {name: "pullboard", version} and instructions naming the seat's agent. It also answers ping, tools/list and tools/call; notifications such as notifications/initialized get no reply, and any other method gets JSON-RPC error -32601.
| Tool | Input schema | Mirrors | Result |
|---|---|---|---|
board_status |
{} |
pullboard status and pullboard list |
the status document plus items (open and active items) and needsYou: the decisions and holds fields of the view's board projection, unshaped, as the view's Needs-you reads them; spec rows that need the person come with #702 |
show_item |
{id: integer} |
pullboard show <id> |
the show document |
shout |
{to: string, text: string} |
pullboard shout <to> <text> |
the shout document |
capture_idea |
{text: string} |
pullboard shout coordinator "idea: <text>" |
the shout document; until ideas become their own kind of item, an idea is a shout to the coordinator whose text starts idea: |
needsYou is read-only and comes from the same projection as the local API's board state and the view: decisions are the open decisions addressed to the person and holds the held lanes. It carries no spec rows yet; spec rows that need the person come with #702. Building that projection reads every item and the spec file's history, so board_status is slower on large boards than the other tools.
Every input schema sets additionalProperties: false; an unknown argument or a value of the wrong type returns BAD_REQUEST. A value of the right type goes to the CLI as it is, so a blank shout returns the CLI's EMPTY_SHOUT and item 0 its USAGE. No argument names an author, a seat or a channel: the author of every move is the agent of the checkout the server runs in.
Each tool runs the CLI command it mirrors through the same engine as the terminal and the local API, with its arguments as distinct argv entries after --, so a move records the same events and a refusal carries the CLI's own code, message and next. A result's content is one text part holding the versioned JSON document, which structuredContent repeats. A refusal is a result with isError: true and the versioned refusal document. A tool name the server does not offer gets JSON-RPC error -32602 with an UNKNOWN_TOOL refusal in data.
A person's own moves never come through MCP (P6). A tool call that names one (approving or declining rows, answering a decision, signing off) returns HUMAN_REQUIRED before any argument is read, whatever the client claims in its arguments, _meta, clientInfo or environment, and from any seat, including the main checkout. Every engine call the server makes also carries the mcp channel, which the CLI's own person check refuses. The refusal's next says to ask the coordinator to bring the call to the person. Live notifications over MCP are not offered yet.
Run pullboard serve --port 0 to print a private API address on 127.0.0.1. Authenticate each call with the address's k query parameter, an X-Pullboard-Key header, or Authorization: Bearer <key>. The secret belongs to that server session. Board ids are random 128-bit values stored in each board; reopening or moving a repo preserves its id.
The server checks any Origin against its own address and grants no CORS permission. JSON request bodies are limited to 100000 bytes; oversized bodies are drained and refused without applying a partial move. A missing or invalid registered folder does not hide readable boards: it appears in warnings with its registry display fields and a versioned refusal whose next field explains how to restore or forget it.
| Method and path | Result |
|---|---|
GET /api/v1/settings?board=id |
Local-only read-only inventory: machine settings plus all authorized boards, or the selected board. Each row has key, scope, value, default, source, and origin; repo/board rows also identify board and root. Credential rows carry secret: true and only set or not set. Runtime events, queues and cached relay payloads are excluded. |
GET /api/v1/boards |
Registered boards with their project details and ids |
GET /api/v1/boards/:board/state?seen=N |
The board state, listing item summaries, including open coordinator requests and the unseen shout count since N |
GET /api/v1/boards/:board/state?detail=full |
The same state with every item in full, which static view exports read |
GET /api/v1/boards/:board/state?since=N |
What changed in the summary state since the snapshot this server sent at event N, and the new latest sequence |
GET /api/v1/boards/:board/items/:id |
One item's full detail: the fields its summary leaves out |
GET /api/v1/boards/:board/items?q=text |
The ids of the items whose id, title, lane, spec ids or criterion contain the text, ignoring case |
GET /api/v1/boards/:board/shouts/:id |
One shout from any point in the board's history, including its decision and answer fields |
GET /api/v1/boards/:board/tokens |
This linked board's agent-token metadata |
POST /api/v1/boards/:board/tokens |
Revoke one token named by its opaque id |
GET /api/v1/boards/:board/code?ref=path:lines@commit&before=... |
A bounded preview of committed file lines from that registered board |
GET /api/v1/boards/:board/events?after=N |
Events after sequence N, in order, and their event-log format version |
GET /api/v1/events?boards=<id>,<id> |
One live stream of every named board's events and changed signals, each tagged with its board |
POST /api/v1/boards/:board/moves |
One CLI move and its emitted event |
POST /api/v1/boards/:board/requests |
A person's request for the coordinator (view only) |
GET /api/v1/view-link |
Whether the full-machine view can start device sign-in |
POST /api/v1/view-link |
Start, inspect, cancel or continue device sign-in in that view |
Settings default names the shipped fallback before other settings constrain it. For example, testConcurrency.browser has a default cap of 2, while its current value is capped by the effective file concurrency. Credential presence describes configured storage, not whether a credential currently authenticates. Reads preserve settings, registry, durable board data and schema; SQLite may create its normal -wal/-shm read-lock sidecars.
The local API exposes token management for a board already linked to the relay. GET returns {version: 1, tokens: [...]}; each row contains only id, board, agent, created, expires and revoked, never a bearer credential. POST accepts only {id} from that board's token list and returns {version: 1, linked, board, url, link, sequence, behind, recovery, id, revoked} with revoked: true. Revocation is a person action: the local view has the required person channel, while an ordinary pullboard serve API caller is refused with B26_PERSON_CHANNEL. An unlinked board cannot list or revoke its relay tokens.
The full-machine local view calls /api/v1/view-link with its X-Pullboard-Key header only. This route rejects query strings and Authorization, and a project-scoped view reports available: false and refuses sign-in. GET returns {version: 1, available}. POST takes a JSON action: {action: "start"} returns {version, intentId, verificationURL, userCode, expiresAt}; {action: "status", intentId} returns the public state and board outcomes; {action: "cancel", intentId} returns the state after cancellation; and {action: "pairing", intentId} returns a short HTML page with a link to continue the pending phone-pairing step. The intent id must have been started in this view. Status and cancellation results include {version, state, boards} and may include a public error or pairingCleanup; private machine paths, relay tickets and credentials are not returned. Sign-in and pairing use the existing person flow, so completed board links are not described as rolled back if pairing is cancelled.
The boards response keeps boards and may include warnings for registered entries that could not be opened. State seen must be a nonnegative safe integer; omitting it retains the unseen: null result. State continues to include only the newest 40 shouts. Use GET /api/v1/boards/:board/shouts/:id to resolve an older address; its { shout } result contains the complete stored shout row, including sender, recipient, text, timestamp, decision flag and reply relationship. Decision shouts also include decision_state (open or answered) and decision_answer (the reply row or null); other shouts use null for both fields. A missing id returns NO_SHOUT with HTTP 404; malformed ids are refused with HTTP 400. Code previews read only a registered repo's committed tree, use a plain commit SHA and at most 60 lines, and never read the working tree. The shared router lets adapters omit the optional code capability; such adapters return the versioned CODE_NOT_AVAILABLE refusal.
The state from pullboard serve lists every item as a summary of what the lists and the roadmap read: id, title, lane, status, route, milestone, specs, claim, builtBy, blockedBy, updatedAt, stage, since, counts and verdict. milestone names the first roadmap milestone that lists the item, or is null. claim is the live lease on the item, {by, until}: its builder's while it is claimed, or its reviewer's while a submitted item is reserved for review; otherwise it is null. stage is the item's status, except accepted for a verified item and merged once a merge is recorded. since is when the item entered that stage: edits, review reservations and renewals do not restart it, and a lapsed claim's stage starts at its recorded lease deadline. counts has the item's number of verdicts and facts. verdict keeps only the latest verdict's decision and reason, or is null; for an item sent back and not verified, it also keeps note, the first nonblank line of that reject's note. The state also has moveCounts, the board's lifecycle moves keyed from>to:verb, as the view's lifecycle drawing counts them.
GET /api/v1/boards/:board/items/:id returns {item}: the item's id and every field its summary leaves out, which are owner, reviewer, reviewUntil, verifiedBy, criterion, brief, commit, merged, history, check, checkBaseline when recorded, verdicts, thread, and the latest verdict in full. Laid over its summary, the detail is the item exactly as the full state carries it. An unknown id returns NO_ITEM with HTTP 404; a malformed id is refused with HTTP 400. state?detail=full returns every item in full, summary fields included, and detail=summary returns summaries; any other value is refused with BAD_REQUEST. Without detail, a server answers with its own default, stateDetail: "summary" or "full": serveApi, which pullboard serve runs, defaults to summaries, and createLocalApiHandler, which a page's own server builds, defaults to full items until that page reads summaries. GET /api/v1/boards/:board/items?q=text returns {ids}, in state order, for the items whose #id, title, lane, spec ids or criterion contain the trimmed text, ignoring case, as the view's search matches; empty text matches every item. An adapter without these reads returns ITEM_NOT_AVAILABLE. The paired API2 phone transport implements these reads by decrypting addressed pages; its initial state is bounded and full detail follows separately.
Every state names the event it reflects as sequence, the latest event id when it was read. GET /api/v1/boards/:board/state?since=N answers {since, sequence, eventLogVersion, delta}, where sequence is the new latest and delta brings the summary state this server sent at event N up to date: delta.replace holds whole fields that changed, delta.merge holds keyed lists by field, as {key, set, ids?}, with only their changed entries and, when entries came, went or moved, the full key order, and delta.remove names fields that went away. Items merge by id, shouts and asked by shout_id, events by event_id, agents by agent_id, spec and practice rows by id, and milestones by name. unseen, proofStats and the Needs-you lists, decisions, holds, requests and personRequests, always come whole. Apply a delta without recomputing anything: copy replace over the state, delete each remove field, and for each merge field, overlay set on the entries by key and order them by ids, or keep their order. applyStateDelta(state, delta) in src/api-http.js does exactly this, and the result equals a fresh snapshot at sequence. A delta also carries changes that wrote no event, such as a spec or doctrine edit, so its sequence may equal N. N alone cannot say which of the snapshots sent at N a client holds, so after such a change a later delta from the same N may repeat it; it still applies exactly.
A server keeps, in memory only, the fingerprints of the last 24 summary states it sent for each board, as snapshots or as a delta's result; a read of one item or a search sends no snapshot, so it never makes a base. When it cannot extend the client's snapshot, delta is null and reset says why, with {code, message, next}: BASE_NOT_KEPT when it never sent a snapshot at N or has forgotten it, as after a restart; BASE_AHEAD when N is past the board's latest event; and FORMAT_CHANGED when the request's optional eventLogVersion differs from the server's. The client then loads a fresh snapshot. since must be a nonnegative integer (BAD_CURSOR otherwise), works with seen, and is refused with detail=full; an adapter without deltas, such as the relay, returns DELTA_NOT_AVAILABLE.
Core readers can call allShouts(board) for every stored shout, oldest first, without advancing any agent's unread cursor. Static exports can use that read to retain text for old shout addresses. Relay snapshots carry the same history only inside their authenticated, encrypted presentation; the relay service receives no plaintext shout history. Older presentations without complete history return SHOUT_NOT_AVAILABLE; refresh them with a current CLI.
The local board state and events responses include eventLogVersion, which identifies the persisted event-record format separately from the HTTP envelope's version. Static view exports keep this field in both state.json and events.json so a reader can refuse a format newer than it understands. Sealed relay events do not use this local board format marker.
The coordinator maintains the roadmap with milestone add <name> [--note ...] [--items 1,2,3], milestone items <name> --add|--remove ids, milestone move <name> --before <other>, milestone edit <name> [--name <new>] [--note <text>] [--target <date and time>|none], milestone ship <name> [--next <name>], and milestone remove <name>. A milestone stores its name, optional note, optional target, its shipment once shipped, and ordered item ids in board_meta; removing a milestone leaves its items untouched. A target is stored as ISO time; --target none clears it. A repo#id reference uses a registered repo's display name (or its folder name) before #; its status is read from that board when the repo is registered on this machine.
An item counts as done only once it has landed on main: it is verified and its merge commit is recorded. pullboard roadmap prints milestones in order as N/M landed, with the target, past target for an unshipped milestone whose target has passed, or the commit it shipped at, and each item as landed, accepted (verified, not landed) or its status. Its JSON results and API state use { name, note, target, pastTarget, shipped, items: [{ id, title, status, state }], landed, done, total, withdrawn } for each milestone: target is ISO text or null, shipped is { commit, at, by } or null, state is landed, accepted or the status, and done equals landed. Each milestone also carries summary: { total, left, landed, landing, review, building, held, waiting, needsYou, pace: { last6h, last2h, last24h }, estimate, window, reason }. Withdrawn members are omitted from milestone items and total and counted separately in withdrawn; remaining work precedes verified items, preserving order within each group. The summary also excludes withdrawn work from its total, left, six state counts and needsYou. The six summary state counts are exclusive and sum to summary total: landed, accepted but not landed, submitted, live claimed, held, then waiting (including expired claims and unresolved references), in that order of precedence. left is total minus landed. Holds include item and lane holds. needsYou contains local milestone item ids linked to unanswered person decisions by typed item evidence or explicit local #N tokens in the question; repo-qualified and URL references do not link local items. Pace counts this board's distinct currently landed items at the first merged event for their current merge commit, with windows excluding the exact lower boundary and excluding future events.
pullboard queue prints the work queue agents pull from, top to bottom, for agents and coordinators. Each unshipped release is a breakpoint line, name: N/M landed as the roadmap counts it, followed by its open, claimed and submitted items in the order pullboard next serves them (the board's one build order: release, rank, route, then age). Each item shows its route, lane, status and holder (the builder while claimed, the reviewer while a submitted item is reserved), and a waits on line names unverified items it comes after, an item hold or a lane hold. Work in no release follows under in no release. The closing line, next now: strong → #N, mid → #N, light → #N, names the first item each route would get: open, waiting on nothing, at or below that route. --json prints { releases: [{ name, landed, total, items }], unreleased, first }, where each item is { id, title, route, lane, status, holder, waits } and first maps each route to an item id or null. The person never needs it; the view's Roadmap shows only what is next and what ships in each release.
pace.last6h, pace.last2h and pace.last24h count landings in the last six, two and 24 hours.
For an unshipped milestone with at least three landings in six hours, window is "6h" and estimate is { low, high } hours: divide left by each of the six-hour and two-hour hourly rates, sort the bounds and round them to the nearest half hour; a zero two-hour rate uses the six-hour rate for both bounds.
With fewer than three landings in six hours but at least three in 24 hours, window is "24h" and both estimate bounds use the 24-hour hourly rate, rounded to the nearest half hour.
Either estimate has reason: null. With fewer than three landings in 24 hours, estimate and window are null and reason is "few-landings"; after shipping, estimate, window and reason are all null.
Unshipped milestones print one summary line: for example, about 2.5 h at today's pace, about 6 h at the last day's pace, or too few landings yet to estimate.
Equal bounds print once; different six-hour bounds print a range. Shipment, targets and item states retain their existing fields.
pullboard withdraw removes the item's numeric reference and unambiguous references through this project's registered name or basename from every milestone. Foreign and ambiguous project references remain. Its milestones JSON array lists the affected milestone names in stored order.
milestone ship is the coordinator's move. It records the milestone as shipped at the main checkout's commit and moves every local item that has not landed into the next unshipped milestone after it, or into --next, recording a milestone_moved event on each moved item's thread. A withdrawn local item stays in place. A repo#id reference also stays in place because its board belongs to another repository; every such reference is listed in notMoved in JSON and named as “not moved across boards” in text output. Its JSON result is { milestone, into, moved, notMoved }. A shipped milestone takes no new items: milestone items <name> --add refuses MILESTONE_SHIPPED. Shipping refuses NO_NEXT_MILESTONE when unlanded local items have nowhere to go, and MILESTONE_COMMIT when main has no commit. Targets and shipping are engine 45 moves.
| Response | Required top-level fields |
|---|---|
boards |
version:number, boards:array |
state |
version:number, state:object |
delta |
version:number, since:number, sequence:number, delta:object|null |
shout |
version:number, shout:object |
item |
version:number, item:object |
items |
version:number, ids:array |
events |
version:number, events:array |
move |
version:number, event:object, result:object |
request |
version:number, event:object, result:object |
stream |
version:number, event:object |
A move body is {verb, item, args, agent}. item is the positive integer id when the move needs one. args names its CLI positional arguments and flags; text values stay literal, including leading dashes. Omit agent to act as the coordinator, or name a registered agent to run in its worktree. Coordinator verification takes args.as: "coordinator", matching the CLI's explicit identity check. The local session secret may act as any agent on that board. result is the CLI's JSON result. next claims work atomically; when a claim is already held it renews it and returns the renewal event. With no work available it returns the CLI's NOTHING_FREE refusal. Waiting remains a CLI option. reopen is coordinator-only: args.note is required, and it returns a submitted item to open without adding a verdict or replacing its last submitted commit. A reject (verify with decision: "reject") and a reopen may name args.cause, the failure's cause key for pullboard rigor. For a reopen, args.conflict names the files of an accepted item that conflicts with the trunk, as one exact path or an array of exact paths, each passed as its own --conflict flag and compared byte for byte with Git's conflict list; it needs no note, records the trunk commit, and the item reads needs main merge until its next verdict.
Decision moves keep the CLI's routing: shout with args.decision: true may omit args.to; agents ask their coordinator, and the coordinator asks the person. pass takes the decision's id as item and args.note. Answering a decision addressed to the person requires answer with args.as: "person" from the coordinator's main checkout. Other callers receive the CLI's refusal.
spec approve <ids> and spec decline <ids> --reason "why" record the person's exact row decision without writing repo files. Bare ids name SPEC.md rows; doctrine:<id> names a repo doctrine row. These commands refuse agent shells and agent worktrees. The authenticated local view uses moves spec-approve with args: {ids: "G1 G2", by: "<optional SSH principal>"} or spec-decline with args: {ids: "G1", reason: "why"}, acting through the main checkout. Pending rows keep their source status and add decision and stage (approved, pending apply or declined, pending apply) in spec show and the shared view/API state.
Only the coordinator runs spec apply. It preflights every pending row against the exact source text, refusing stale decisions before writing any file. Approval changes the status to approved; decline changes it to wont with the person's reason as the row text, preserving trailing fields. SSH-enabled approvals carry one signed row-decision receipt binding the file, source, replacement, decision, reason and row text. Apply copies that same receipt into .pullboard/signoffs.jsonl; it remains a current exact-text sign-off. Commit the changed files and receipts together.
init and blueprint add take --blueprint more than once, and the blueprints land in the order given. A board takes at most one product blueprint and one architecture blueprint, and any number of practice and beyond-code ones; a second product or architecture blueprint is refused with BLUEPRINT_KIND_TAKEN, naming both, before anything is written, whether the first is named in the same command or already on the board. Every blueprint leaves <!-- pullboard-blueprint-kind <name> <kind or none> --> beside its rows, which is how a later command finds it. A blueprint an older Pullboard added has its marker alone: its kind is read from the bundled file of that name, else from <name>.json or <name>.blueprint.json at the repo root; failing both, its kind is unknown, and a product or architecture blueprint is refused with BLUEPRINT_KIND_TAKEN naming both until the person records the kind by adding that blueprint's file again. Adding the blueprint the board already has again changes nothing but that kind line and is no clash. A rule or question whose row text an earlier blueprint in the same command drafted is drafted once: the later blueprint cites that row and lists it in shared as { key, id }. Each starter item in items carries blueprint, the name of the blueprint that filed it, and its add appends a note fact blueprint starter: {"blueprint":"<name>","version":<version>,"key":"<key>"} to the item. With several blueprints, blueprint add returns the first blueprint's fields with every blueprint's rows, items, proposedLanes and checksNotRun, and each one's own result in blueprints; init returns the first in blueprint and all of them in blueprints.
To answer a pending question row (<ask>: <default>. Choices: a, b., as blueprint add writes it), the person runs spec answer <id> "<choice or text>", or the local view sends spec-answer with args: {row: "B1", answer: "Postgres"}. A listed choice matches in any case and keeps its listed spelling; other text joins the choices last. It refuses agent shells, agent worktrees and the API channel, records the same exact-row approval receipt, whose JSON decisions carry an answer read back from the signed text (never stored on its own), and the row leaves pending when the coordinator runs spec apply. spec check errors on a question that was pending at the trunk tip and now differs with no matching person decision.
blueprint fit <name or file> <row id or #item> keep|adapt|drop|answer [--text "..."] [--reason "why"] records a seat's fit proposal for one row, question or open starter item of a blueprint fully added to this board. Proposals are unverified drafts in blueprint-fit.json beside the board; nothing changes until the person reads spec fit, edits if needed, and approves the exact batch code with spec fit approve <batch>. Approval refuses the proposer, non-person channels, rewritten readings, and any proposal added, removed or changed since the person's reading. Row decisions preserve the proposal reason and blueprint source in their note; starter adaptations change the criterion and drops withdraw the item after recording a decision fact. The local view/API state carries the waiting batch and per-row/item fit status.
To approve new wording, the person uses spec approve <one-id> --text "exact proposed text", or spec-approve with args: {ids: "G1", text: "exact proposed text"}. This records the same exact-row receipt; files wait for coordinator apply. Text must be one nonempty line without a field separator. Pre-commit checks the staged row against that person decision or a verified staged SSH sign-off; unstaged receipts cannot authorize a commit.
blueprint starters <name or file> lists a blueprint's starter items and whether each is on this board. Each entry in starters is { key, title, filed, id, status }: a filed starter is the item this blueprint's add filed for it, with its id and status, and an unfiled one has id and status null. When blueprint add files a starter it appends a note fact to the item, blueprint starter: {"blueprint":"<name>","version":<version>,"key":"<key>"}, and blueprint starters counts only items whose link names the same blueprint, version and starter key. An item with no such fact, filed before adds recorded one, counts only when no linked item names the starter and it carries the starter's title and cites exactly the rows that blueprint's add numbered for the starter's keys. blueprint starters reads the board and changes nothing. A repeat blueprint add of a version already on the board returns marked: true with empty rows and items, changes no file and records no board event. init --blueprint adds blueprint.marked and blueprint.waitingRows, every row that waits for the person as { id, status, text, file }: the blueprint's new SPEC.md rows, then each draft or pending doctrine row, such as an inherited standard rule.
A coordinator criterion or check correction on a live claim remains pending until its holder or the coordinator runs pullboard refreeze <id>. Renewal and submission refuse with PENDING_REFREEZE until then. This refreeze keeps the holder, lease and claim base, and returns retainedClaim: true in JSON.
Doctor and resume report every stale frozen item after cited text changes. Open, claimed and submitted items suggest coordinator refreeze. Verified and merged items keep their receipts, report that they shipped against the old text, and suggest adding a follow-up citing the changed row. Resume JSON includes these findings in stale.
The SQLite schema marker is now PRAGMA user_version = 2. Opening an older board upgrades it in place: board_meta stores the id as meta_key = "board_id" and a 32-character hexadecimal meta_value; shout_request and shout_request_outcome mark requests and their answers. Existing items, agents and events remain intact. The HTTP envelope stays at version 1 independently of the SQLite schema marker.
A request body is {text}. It creates a request from the person to the coordinator. Open requests stay first in coordinator resume and inbox until answered done, or declined with a reason, and do not count as open decisions.
For live events, send Accept: text/event-stream to the events path. Ordinary messages have an id equal to their event sequence and data has the stream shape above. The local server also sends event: changed with data: {"version":1,"event":{"event_kind":"changed"}} when a view input changes without a board event; that notification has no id and does not move the reconnect cursor. Reconnect with Last-Event-ID to resume after the last ordinary event sequence; it takes precedence over after.
To follow several boards on one connection, send Accept: text/event-stream to GET /api/v1/events?boards=<id>,<id>, naming each board once by its id from GET /api/v1/boards. A browser holds at most six connections per origin, so a project group's view follows all its repos through this one stream. Each frame's data also names its board: an event is {"version":1,"board":"<id>","event":{...}}, and the changed signal is {"version":1,"board":"<id>","event":{"event_kind":"changed"}}, sent on the same terms as a single board's. An ordinary event's id carries one cursor per board, <id>:<sequence> pairs joined by commas, so reconnecting with Last-Event-ID, or passing the same cursor as after, resumes each board where it left off. A followed board the cursor leaves out starts from its first event, and a pair for a board not followed is ignored. Every named board must be one the caller may read: otherwise the request is refused with that board's refusal, naming the board, and nothing is streamed. The path is live only; a request without Accept: text/event-stream is refused with BAD_REQUEST. The single-board events path is unchanged.
pullboard watch follows this same stream from a terminal. It uses the running view's stream when the view serves the board, and otherwise serves API v1 for that one board on a private loopback port and follows that. It prints one line per event, or one JSON document per line with --json; it only reads the board. A dropped stream is followed again with Last-Event-ID set to the last sequence it handled, and --since <sequence> starts a new watch there. Work notices from --work <lane> (claimable, review-wait and main) carry notice: true and the sequence they follow.
The local server uses a shared HTTP router. Streams recheck access before each poll and close with a versioned refusal if access ends. Slow readers pause delivery and resume from the last delivered sequence.
HTTP refusals use the same versioned error envelope above: 400 for malformed calls, 401 for a missing or wrong session secret, 403 for an agent belonging to another board, 404 for an unknown board, path or shout, and 409 for the CLI engine's refusal. Shouts and answers append their events in the same transaction as their records. A move returns its own event, including when it also sends a coordinator shout.
pullboard spec check fails when an approved must-row names a gate file that was committed and is gone now (a lost test), and it names the row, the file and the removal. A gate file that was never committed only warns that its test is not written yet, so a new project's example spec still passes, and every other row only warns. The pre-commit hook refuses a commit that deletes or renames a file an approved must-row names, unless the same commit points that row at its new home.
pullboard spec signers add [--key <path>] [--by <principal>] opts a repo into OpenSSH-signed spec sign-offs. The key defaults to Git user.signingkey, then ~/.ssh/id_ed25519.pub. The principal defaults to the exact Git user.email; --by overrides it. spec signoff uses the same email default in a repo with SSH signers, and accepts --by when a listed principal differs. A row's signers: names those exact principals. Spec checking refuses a required principal without a corresponding signer entry.
The initial command output names .pullboard/signers, .pullboard/first-commit and .pullboard/signers.initial for staging and committing. Later sign-offs and signed signer-list changes are recorded in .pullboard/signoffs.jsonl; commit that file with the corresponding signer-list change.
Every signed row and signer-list change binds the initial signer-list hash in its canonical text. Rewriting .pullboard/signers.initial, or relabelling that hash in earlier receipts, invalidates those receipts.
Relay API2 preserves the version: 1 response envelope, executable moves and
seal envelope. GET /api/v2 advertises sealed-page support. The authenticated
/api/v2/boards/:board/state, /uploads, /uploads/:id/objects/:hash,
/uploads/:id/publish, /objects/:hash, and /storage routes exchange bounded
opaque objects and server-derived storage totals; their full transport contract
is in relay/README.md.
The phone installs a current-state presentation under 900,000 bytes before
streaming complete history. It uses the same lifecycle stage, since, claim,
counts and summary projection as the local API. Full item details, search and old
shout addresses can read selected encrypted pages before the complete stream.
The transport's historyProgress(boardId) returns {loaded, total, complete, sequence} and optional error. Completion means all declared native rows and
item details arrived for that checkpoint, not merely that current lists rendered.
A newer current checkpoint can appear while an older pinned stream completes;
historical completion never rewinds the live cursor.
Account quota responses use ACCOUNT_STORAGE_LIMIT with HTTP 507 and
error.storage: {account: {id, login}, usedBytes, limitBytes}. The same validated
fields apply to API1 and API2 refusals. GET /api/v2/boards/:board/storage returns
{version: 1, apiVersion: 2, storage, warning?} without downloading the snapshot.
All routes retain current authorization and credential-derived sender checks.
An API1 request for a paged head fails explicitly with RELAY_API_VERSION and
upgrade guidance; it never returns an older or partial image.
Every session on a paired machine reads the board: a terminal, SSH, an agent, a scheduled job or CI. relay on keeps the board key only in the owner-only file relay-keys/<board>.key under the pullboard home, even where a keychain exists. A session that cannot read that file still reads the local board with every read command, resume included: resume reports once that the key is not reachable and leaves its own bookkeeping moves to a session that holds it. A move in such a session is refused with RELAY_KEY_MISSING and nothing is sent.
A CI or cloud session that cannot pair sets PULLBOARD_RELAY_TOKEN to one agent token, PULLBOARD_RELAY_KEY to the board key and, for a relay other than app.pullboard.dev, PULLBOARD_RELAY_URL. In a clone with no relay link, its first command asks the relay for the token's one board, reads that board's sealed snapshot with the key and writes the local board, plus a relay.json marked environment that names the token's agent and holds neither the token nor the key. Every later command reads and moves as that agent, on that one board; a token for any other board is refused (TOKEN_BOARD). A clone already linked to another board keeps its relay.json: the environment never overwrites it, reads, relay status among them, go on with a RELAY_LINKED notice, and moves are refused. A session with both PULLBOARD_RELAY_TOKEN and PULLBOARD_RELAY_KEY uses that key as given when no key file exists: it never reads a keychain for it or writes a key file.
The authenticated relay page calls cockpitPage with readOnly: true and the separate requests: true capability. This enables only the person's literal intents through the sealing transport; generic API writes remain refused. Local read-only pages and exported snapshots omit this capability and refuse every action.
When pullboard.json names approvers, only those GitHub logins act as the person on the linked board: a phone request to answer, hold, spec-approve or spec-decline from another writer's session is received as refused with NOT_APPROVER, and so is a direct person move. add and shout requests stay open to every writer. Each event a relayed move writes names its person, the sender's GitHub login, in event_detail, beside event_by. A move a computer makes to carry out a phone request names the phone's person, and a refused move or request names the person who tried it.
A paired relay browser translates add, shout, answer, hold (including args.off: true), spec-approve and spec-decline into literal CLI intent. Its transport accepts {verb, item?, args} at the paired board's moves address and seals a separate request document before posting {sequence, sealed} to /api/v1/boards/:board/requests. It never sends plaintext arguments, an engine operation or the board key. The relay stores and orders that opaque envelope; it neither executes a command nor writes a repository. Agent credentials cannot submit person intent or person-attributed holds and releases.
The device-only request format is {version: 1, type: "person-request", id, move: {verb, item?, args}}. This request version is independent of the ordinary move engine version. The stable id survives interrupted sends and sequence collisions. Browser storage keeps each pending request's ciphertext separately, so another tab cannot replace or clear it. A later board read reconciles pending sends with the relay's acknowledged prefix before retrying.
The next ordinary local Pullboard command receives requests in relay order. One linked native device claims each request for ten minutes, measured exclusively from relay receipt timestamps, and runs its literal arguments through the actual CLI in the coordinator's primary checkout, outside the relay lock. Lane, brief, spec, person-channel and other CLI checks run there. Receiving a request never calls an engine operation directly. Its committed result travels as an ordinary move; durable request and move receipts prevent command retries from applying that result twice. A restarted device resumes its durable stages. After the executor lease expires, another linked device may claim the waiting request; each executor has distinct move ids, while the request’s atomic result and status prevent a late executor from creating another effect. Network or sign-in failures leave the request waiting.
Local GET /api/v1/boards/:board/state and the device-decrypted relay presentation expose additive state.personRequests. Each entry has:
| Field | Meaning |
|---|---|
id |
Stable request identity |
sequence, at |
Original relay position and timestamp |
by |
person for authenticated person intent; a refused sender names its authenticated actor |
move |
Literal {verb, item?, args}, or null for refused malformed input |
status |
waiting, done or refused |
error |
Present for refusal: the original {code, message, next} CLI guidance, or a typed format/sender refusal |
result, resultSequence |
Optional committed ordinary-move result and its relay position |
coordinatorRequest |
Optional local shout id for a repository-change request |
A browser action's decoded response is {version: 1, event, result: {request}}; request has the same status shape. event is the opaque acknowledgement, or null when reconciliation finds the earlier acknowledgement already in a sealed checkpoint. Direct actions become done when their actual CLI move commits. A refused add, for example, retains UNKNOWN_SPEC and the same explanation and next step as the terminal command, without creating an item. Answers always use the person channel; holds and releases keep the coordinator's execution guard while attributing the decision and event to the person.
Row approval and decline first record the person's exact decision through the same authenticated view boundary as the local view, without editing any file. They then create a person-to-coordinator request to run spec apply. That request is first in coordinator resume and inbox. Its status stays waiting until the coordinator applies it and answers done, or answers declined <reason>. Decline resolves the request as refused with REQUEST_DECLINED, the coordinator's reason and a next step. The browser and relay never run spec apply.
pullboard hold <id> "reason" holds one open or claimed item; pullboard hold <id> --off lifts that hold. Both retain the hold JSON shape (lane, held) and add id and reason (null when lifted). Held open items are skipped by next and refuse new claims with ITEM_HELD; an existing live holder can renew and submit. show and list include the hold actor and reason. Successful next --json results may include reasons naming skipped holds.
pullboard land [--max N] [--dry-run] [--adopt] is coordinator-only. It merges verified items in verdict order in a separate landing worktree, gates the batch once and records pushed merges. Conflicts and stacked unverified items are named separately. Reproducible failures are bisected; a failing file which passes alone files one flake and holds the batch without retries. Resume includes landingBatches, each with its state, owner, exact tip, items and log path.
The landing gate sets PULLBOARD_LANDING_RUN=1 only for its test runner. In that mode, the runner requires process isolation and POSIX process identities; it stops scheduling files after the first test-file failure, interrupts files already running, and reports the failure, interrupted files, and selected files not run. Landing early-stop fails before starting tests on Windows because the runner cannot safely identify and drain owned worker descendants there. It uses Node's execution-ordered test completion events; ordinary focused runs and all non-landing gates keep normal scheduling. The runner clears this marker for test workers and nested commands. If a process-identity census stays unavailable, tests still determine the gate result; the runner reports cleanup as UNVERIFIED and does not signal processes whose identity it could not establish. A UNVERIFIED cleanup report means descendant cleanup was not confirmed.
With landingShards: N in pullboard.json (a whole number from 1 to 64; absent or 1 keeps one run), the landing run of bin/run-tests.js splits the tracked test files into N shards. It balances them by each file's wall time from the last sharded landing, kept in the repository's pullboard/landing-file-times.json; with no times it balances by file count. The first shard uses the gate's own slot. Each other shard takes a landing-priority gate slot, locally or on a registered runner. The run is green only if every shard is green. The first red shard stops the others, which are reported as stopped. A shard whose runner fails mid-run prints landing shard <i>/<n>: runner <host> failed mid-shard; rerunning shard <i>/<n> locally and reruns on a local slot. Runner refusals are classed by code. RUNNER_SETUP_REFUSED and RESOURCE_LEASE_LOST rerun locally, with the code and host in the log. RUNNER_CLEANUP_TIMEOUT reruns locally and also quarantines that host for the rest of the landing, printing landing runner <host> quarantined for the rest of this landing: <message>. RUNNER_SOURCE_CHANGED and any unknown code fail the landing red as refused, with no retry. The landing pins its source (HEAD, tracked changes, and the names and contents of untracked files) before the first shard. A local shard, first run or rerun, checks that source before and after it runs and refuses as LANDING_SOURCE_CHANGED if the checkout moved, so a result never comes from other code. Stopping a local shard first records every process it owns from the process census: its parent-tree descendants, including detached ones in their own group or session, and their group members. It then sends TERM so the shard's run-tests drains through its own custody, and signals whatever the census still finds. Processes that cannot be shown gone are named as [LANDING_SHARD_CLEANUP_UNVERIFIED] in the log and in landingShards.unverified, with an instruction to inspect them before another run. The merged log prints one # landing shard <i>/<n> on <host>: <green|red|stopped> in <ms> ms line per shard and one # landing file <i>/<n> <host> <ms|-> <path> line per file. It also names how the plan was balanced (history, history and average or file count) and, once history exists, the slowest recorded file as # landing floor: <ms> ms <path>, since no shard count can finish sooner. The gate profile carries the same fields as landingShards.balance, landingShards.floor, landingShards.shards and landingShards.files. Every shard takes any free gate slot, so a runner with two slots can run two shards, and each local shard needing Chrome still waits for the machine's browserSlots.
Only the person can run pullboard land --waive <test> --until <date> --reason "why". A waiver retains every test in the full gate. The batch records its exact tree and each failing test with its covering live waiver; pre-push validates that distinct proof and creates no ordinary green stamp. Expired waivers cannot authorize a push. Existing gate-stamped pushes and pullboard merged remain available.
The same live person waivers also cover the submit gate and pullboard gate --landing. Every failure must be accounted for and covered by its exact test name or file; an unwaived failure, expired waiver, or separate failing setup stage still refuses the run. The item's frozen check must pass independently. Each waived failure prints its waiver, reason and expiry. A waived run succeeds as waived, not green, writes no ordinary green stamp, and a waived submission records that distinction on its item thread. gate --landing --json reports green: false with waived entries containing file, name, waiver, reason and until.
A pullboard serve key grants access to a board; it does not identify the person. API moves always use a non-person channel, including when the server was started in an ordinary terminal. spec-approve, spec-decline, answering or verifying with as: person, and any other person-only move are refused with B26_PERSON_CHANNEL without recording an event. Token revocation through the standalone API is refused for the same reason. Spec signing off is not an offered API move. Use the person's terminal or the actual pullboard view page for person actions; ordinary agent moves remain available through the API.
The view's own page retains its person channel. The standalone API refuses POST /api/v1/boards/:board/requests with B26_PERSON_CHANNEL; the view continues to record the person's coordinator request. Residual: an agent that starts pullboard view itself also holds that view's link; this change does not isolate that link from the agent. Signed person moves (#408) address that separate boundary.
runner add registers machine-local SSH capacity. Optional --node and --root select absolute remote paths when Node is outside the SSH login path or runner storage needs a specific location. runner remove <ssh-host> removes exactly that registered runner while preserving other machine settings; it refuses an unknown host and lists the registered hosts as the next step. If that runner has active slots, removal is refused until they finish. The default storage root is ~/.pullboard-runner; runner work shares the remote machine resource pool.
Runner storage keeps one repository object cache and creates an isolated checkout with complete history per run. A locked inventory handshake transfers only missing committed objects after scanning their blobs, paths and commit metadata, including ancestors. Historical protected paths are checked before connecting. A credential-free HTTPS origin may seed the cache at a locally known published ancestor; caller credentials are never forwarded. Cached object contents are not rescanned on every run.
Each checkout with a committed npm lockfile runs npm ci, including lifecycle scripts. Only npm's content cache is shared; installed dependencies and generated files stay in the fresh checkout. Other lock managers are refused. Combined command output and transport diagnostics are bounded at 64 MiB; exceeding that limit refuses the run.
Post-run cleanup reports RUNNER_CLEANUP_TIMEOUT after 30 seconds instead of leaving the controller waiting indefinitely. That failure does not retry the command. The physical runner slot is released after its known children have drained and no unresolved live process remains; an unresolved live process keeps the slot occupied until cleanup can be verified.
New acceptances seal the exact checked commit in review.head. A stale re-review lands that checked composition while retaining the original submitted commit as the item identity; landing snapshots record reviewedCommit when it differs. Adoption and publishing refuse if that proof changes. Historical released-engine receipts retain their original replay meaning. Coordinator resume derives item_checked_commit on its pending-merge cards. Merge groups, active integration claims and the printed merge command use that accepted composition; item_commit remains the immutable submitted identity, and stale cards still stay out of toMerge. The checked commit is pinned at refs/pullboard/items/<id>/<first-12-sha>; another replica must fetch that ref's Git objects before the sealed re-review can clear its stale state. Missing objects fail closed. The checked-head meaning starts at engine 33 on this composition, as assigned by the coordinator. Merge the actual trunk before landing. Earlier records retain their original meaning.
A re-review seals the union of the original item paths and paths changed by the checked repair against its reviewed trunk. Files added by a repair therefore participate in later stale-verdict detection.
Device-sealed presentations carry the same derived verdictState, including Git-only trunk movement and re-review, without changing native tables. Browser snapshot decoding preserves it. Repeated unchanged sealed snapshots reuse derived values and start no Git process after warm-up; staged supplied boards bypass that cache.
Cold freshness reads query first-parent history limited to the item’s literal paths, then inspect matching commits. Unrelated main revisions do not each start a file-diff process; merge and revert changes still count.
Unavailable Git-object verdict projections are retried on the next read, so fetching the sealed reviewed commit clears that uncertainty without a board event or trunk move. Known freshness projections remain cached.
When the runner's own resource pool refuses the run's setup, the run fails at once with RUNNER_SETUP_REFUSED, naming the runner host and the pool's refusal code. It is not retried, and it leaves no holder or waiter on either machine.
Agents and the coordinator can set explicit item ranks with milestone order <name> <ids> (comma-separated ids from that milestone). It replaces that milestone’s order list in board metadata; other items stay unranked. next compares milestone, explicit rank, then its existing route/recent-file/age tie-breaks. A never-ranked board keeps its existing order. Removing an item drops its rank; adding it again leaves it unranked. This does not change newcomer help or person flows.
resume on the coordinator seat includes rigor: {undrafted: [...]}, naming each repeated cause that has no live or approved proved draft. Each entry retains the ordinary Rigor cause key, item evidence and commits. The text resume names the proof-bearing pullboard rigor draft command. Declined repeats stay quiet until a new item repeats them. needsYou may contain an individual kind: "rigor-draft" call with cause, evidence, proof, a qualified spec or doctrine target, and what: "Approve or Decline". An ordinary draft remains in the existing aggregate count; a Rigor draft is not counted twice. These fields add no person permission: the existing person-only approval and decline channels still apply.
A project may declare a staticChecks command in pullboard.json (empty by default). Submit uses the command from accepted main, and an explicit item check uses its accepted policy, so candidate settings cannot remove the guard. It runs before the frozen check and affected proof; a refusal retains the static log and reports the offending source. The existing queue and conservative browser classification still apply. Pullboard's own command checks actual CLI/View import bindings, the source-only prose command declaration, wall-clock budget explanations and doctrine counts. Browser prose behavior remains in the ordinary test suite.
pullboard sentinel propose <name> --check "<command>" --every 10m|hourly|daily --owner <seat or lane> proposes a board row. Optional --directory <relative path>, --timeout <milliseconds> and --next "<step>" are part of the exact proposal. The directory defaults to this project, and must stay inside it; timeout defaults to 30000 ms and is a hang ceiling. The person approves or declines through the same person-channel guard as spec rows: pullboard sentinel approve <name> or pullboard sentinel decline <name> --reason "<why>". An agent cannot approve. Any new command, directory, schedule, owner, timeout or next step needs fresh approval. No row in SPEC.md is edited.
An approved watch runs when due on resume and through the background view’s ordinary resume worker. The existing board and machine health.sentinel: false switches suppress it. Ordered run leases prevent duplicate concurrent observers. External checks run outside the database write lock, in the approved directory, with bounded final output and an owned-child timeout; recorded replay never executes a command. On POSIX hosts shutdown drains the owned process group; Windows uses direct-child termination. A failed check notifies its owner once with its name, final output and next step; staying red posts no duplicate, and recovery posts one cleared notice. Every red run remains a sentinel:<name> cause receipt for Rigor, without inventing a code change. Cancellation records its outcome without creating a failure or recovery notice. pullboard sentinels --json lists current rows, their owners, approval, latest result and current proposal’s run history. This is engine 56; earlier engines refuse the new moves before replay.
A row approved after the board’s compatibility baseline is reported as agent-approved until its exact source is backed by a person decision or a verified signed row-decision receipt. The initial approved set is recorded during init, before the setup commit. An older board records its existing approved rows on its first coordinator or person command from the main checkout with this version; earlier reads report the baseline as pending and flag nothing. The snapshot is a compatibility boundary, not evidence of historical person approval. Git blame supplies the row's commit and author, labelled “Git author, seat unknown”; it never establishes person approval. pullboard spec approve <id> confirms the row and spec decline <id> --reason "why" declines it through the existing person channel. File application remains a coordinator step.
pullboard.json accepts the existing "spec": "SPEC.md" form, or "spec": {"file": "SPEC.md", "agentApproval": "flag"}. flag is the default and keeps work moving; allow suppresses the outstanding approval call; explicit refuse prevents a new frozen citation of an unconfirmed row with AGENT_APPROVAL_REQUIRED.
Board state and status expose specApprovalBaseline: {pending: boolean, invalid: boolean}. Read-only status/API calls do not establish a pending baseline. A present but unreadable baseline is invalid, never pending: current approved rows require their own confirmation evidence, flag-mode work remains available, and no command automatically resets the marker. Restore a known-good board export to recover it. Board state adds spec[].agentApproval (or null), item agentApproved and approvalQualification, and agent-approved Needs-you calls carrying the available author and commit. Invalid receipt evidence remains unconfirmed and appears in specApprovalEvidenceErrors; it is never treated as an approval. The state cache and live watcher include receipt and signer-trust file bytes. Status JSON exposes the same calls and diagnostics.
Verification output, current item detail and the ledger qualify affected accepted work as “verified against agent-approved ”. A later confirmation clears the current qualification; it does not rewrite earlier acceptance receipts.