simnova

board-conduit

"Board-as-conduit protocol: GitHub project 7 cards are the owner-authored instruction SSOT consumed unaltered via briefFromIssue - card anatomy, lane definitions (A learner/XR, B API/admin), parent/child decomposition, concurrency N-gate criteria, worker staging limits (<300 turns), and the delegator/owner division of every dispatch step. Load BEFORE creating any project-7 issue/card, dispatching any worker, or changing concurrency."

simnova 0 Updated 1w ago
GitHub

Install

npx skillscat add simnova/openclinxr/board-conduit

Install via the SkillsCat registry.

SKILL.md

Board conduit (project 7 = instruction SSOT)

Cards are authored ONLY by the product owner (ox thread). The delegator executes
them verbatim through briefFromIssue -> dispatch; no rewrite path exists.
This removes paraphrase surface to zero for instructions.

Card anatomy (issue body; all sections required)

## factory_step: <enum>            # existing requirement; instrument needs unblocks:
## lane: A|B                       # A=learner/XR+assets  B=API/admin/review
## write-roots: <full consumer closure>   # grep transitive reads of entry files;
                                   # name resolver/constants/helpers main.ts reads
## objective: <one sentence>
## known-good: <in-tree reference, path:line>
## failed-treatments: <named rows with what each produced>
## done_when:
   - run:/exists:/changed: proofs on TRACKED paths only
## out-of-scope slot + expected NOT TESTED line

A card enters Factory=Planted only when its RED + contract are committed to main.
Dispatch order follows the parent card's child list, adjusted for lane disjointness.

Mid-run steering: the dispatcher reads the BODY, never the comments

briefFromIssue builds its prompt from gh issue view <n> --json number,title,body. Body only.
Comments are not read, so a pre-dispatch correction posted as a comment reaches nobody and nothing
warns you — the brief still validates, because the body's ## done_when is intact.

Measured: a careful delta naming a freeze ceiling, a gate-is-suspect line, a named renderer and a
known-good column was posted with gh issue comment and was invisible to the dispatcher.

Put pre-dispatch corrections in the BODY via gh issue edit --body-file, then re-read it and grep
for a distinctive phrase from the delta. Comments are for post-close records — retros, verdicts,
corrections to a landed claim — where no worker will read them.

Mid-run steer of a RUNNING worker is mailbox.post / mailbox.poll only. tasks.comment is an audit
log and does not reach a running child.

Incident: §11b in docs/_archive/agent-rules/2026-08/PROTO_VERIFY_DELEGATION-incident-archive.md.

Decomposition

One parent card holds objective + prioritized child-card list. Each child carries
one proof group. Children in the same lane stay serialized; different lanes may
run concurrently after the N-gate below.

Concurrency gate

  • N=2 baseline: one lane-A + one lane-B slice, disjoint write roots.
  • Measure across 3 ticks: inference_retry counts (unified.jsonl), wall-clock per
    phase, port/temp collisions (workers take distinct portless ports).
  • N=3 only on zero cross-worker interference AND no retry storms.
  • Integrate stays SERIAL at any N (index contention); slices >> integrate, so it
    does not bind until slice duration drops ~10x.
  • Workers staged to return under ~300 turns (ox-alpha empty_response danger zone).

Division at dispatch

OWNER: card authorship, slice selection, done-gates, grades, N-gate decisions.
DELEGATOR: board -- next, dispatch unchanged, mechanical tree verification,
serial integrate, captures, WAKE re-arm, TICK reports.

Failure posture

Free-tier termination risk: test grok-4.6 fallback routing once before running
hot. Two consecutive integrate refusals => land nothing further, page owner
thread. Weekly worktree prune (~1.3 GB each). Owner-thread decay: consults kept
small; proactive re-open before ox-alpha context ceiling.

Two ways a card is born broken, both silent, both cost a full recreate

Measured 2026-09-03, in one sitting, on two cards.

tasks.create without projectId lands the card in the WRONG PROJECT and returns success.
The parameter is documented as "required if the token covers more than one project" — it is not
enforced, and nothing in the returned {id} says which project it went to. Two cards written for
OpenClinXR were created under Harbor, planted there, and looked entirely healthy: right parent id,
right lane, right factory_step, factory: Planted, status: ready. They were invisible to every
tasks.next the actual project would run.

The parent id does not save you. A parentId from another project is accepted without complaint,
so the card reads as a child of a parent it cannot be dequeued alongside.

Pass projectId on every tasks.create, and read it back off the response before planting.
projects.list gives the ids. This costs one line and one glance.

tasks.update cannot patch doneWhen. It patches status, blockedReason, branch, worktree,
session ids, fields — not the contract. So a card created Idle with an empty done_when, intending
to add proofs once its RED exists, can never be planted; it has to be recreated with the contract in
the create call. That is not a defect in the board: a card whose contract is decided at dispatch time
is precisely what it refuses. But it means the decision of what would prove the card done has to be
made before tasks.create, not after.

Recovery for both, in order: recreate with the full contract in the create call, plant the new card,
then tasks.update the stray to status: cancelled with a blockedReason naming the replacement id.
Cancel is the right verb — there is no delete, and an uncancelled duplicate is dispatchable-looking.
Leave a comment on the superseded card saying which id replaced it and why, or the next reader finds
two cards with identical titles and no way to tell which is live.

Read the audit's findings as leads, not verdicts

pnpm exec tsx tools/openclinxr/openclaw/audit-board-graph.ts is the fastest way to find work that
is stuck rather than hard. Two of its finding kinds need a measurement before you act on them, and
both were measured on 2026-09-03.

committed_red_idle does not distinguish a LIVE red from a flipped one. It reports that a card
is Idle while a test file it names exists in the tree. Four cards carried that finding; two of their
REDs had zero unflipped clauses, so planting them would have produced cards that were green before
any work happened — the by-construction pass this loop exists to prevent. Count the call sites
first:

grep -c "it\.fails(\|planted(" <the test file>     # 0 means the card is already satisfied

Then run the file and read the split — N passed | M expected fail — because a package that wraps
its plants in planted() will not show up under a bare it.fails grep in every repo layout.

dangling_dep on a CANCELLED card usually means a duplicate, and the work is often already on
main.
Two cancelled cards were holding eight dependency edges across seven children; both had a
Landed twin with the same title, and every deliverable was on main — measured by running the twin's
test (0 unflipped, all passing) and confirming the write roots exist as files. Seven cards were
waiting on nothing.

Do not assume the reverse either: a cancelled dependency can also be genuinely abandoned work. The
discriminator is cheap — look for a Landed card with the same title, then verify its deliverable in
the tree rather than in the card text.

You cannot repair the edge. tasks.update does not accept depIds, so the audit keeps reporting
a satisfied dependency forever. Record the measurement in a comment on the parent so the next reader
can treat the finding as noise with evidence behind it, and name both ids.

maxInFlight counts review, so clearing a blocker can cost a lane

Measured 2026-09-03. OpenClinXR runs maxInFlight: 2, maxIntegrating: 1. A card sitting in
review — worker finished, awaiting attestation, nobody working — occupies one of those two slots
exactly as a claimed card does. Four Planted, dependency-free, ready cards were queued behind two
in-flight ones, and one of the two was a card I had moved blocked -> review myself after measuring
its blocker stale. The premise change was right; the throughput cost was invisible and unbudgeted.

Before any status change that lands a card in the in-flight set, read the occupancy. If the board
is at its cap, moving a card into review or claimed takes a lane away from work that could
actually run. Say so on the card when you do it, so the person who owns the close knows their
decision now has a cost attached.

Do not quietly flip it back to free the slot. review is honest when a worker has finished, and a
state that gets edited for throughput stops describing anything. Attach the cost, or close the card.

sync.readyIds is not Harbor-scoped — CORRECTED 2026-09-03

The section below overstates its case and the correction matters more than the rule. I wrote it
after a bare sync returned readyIds: [] while OpenClinXR had four ready cards, and concluded the
array was Harbor's — because sync.project (singular) is Harbor. Measured again later the same
session, the same bare sync returned

readyIds: ["tsk_0e3ddf820ab8c465", "tsk_bae34b0cae2cf745"]

and both are OpenClinXR cards. So readyIds is not scoped to sync.project. The earlier empty array
meant nothing was ready at that moment, which is a different claim entirely and one I could have
checked by asking why.

What survives: sync IS a mixed-project payload, sync.project is whatever the credential defaults
to, and it accepts no project argument — so joining readyIds against tasks[] filtered to your own
projectId is still right, and is what the Codex monitor does. What does not survive is reading an
empty readyIds as evidence of scoping.

What actually thins that list is the write-root overlap filter. Measured with one card in flight
holding tools/openclinxr/evidence: of five Planted+ready cards, the three whose roots sit under that
path were absent from readyIds and the two disjoint ones were present. status: "ready" on a card
means eligible-in-principle; readyIds means dispatchable right now.

Read readyIds from a project-scoped source, never bare sync

bothy-board.sync returns a MIXED-PROJECT payload whose project (singular) is whatever the
credential defaults to — Harbor here, not the repo you are standing in — and it accepts no project
scope
: passing projectId or project changes nothing in the response. So a bare sync can hand
you readyIds: [] while the project you care about has four ready cards.

That empty array reads exactly like a board defect, and it is not one. Join readyIds against
tasks[] filtered to your explicit projectId, which is what the Codex monitor already does
(codex-bothy-event-monitor.ts — "sync only as a mixed-project hint").

Related: tasks.next returning {task: null, unchanged: true} while tasks.get reports a card
ready is not an inconsistency either — it is the in-flight cap reached, reported without a
reason. Check occupancy before concluding the dequeue is broken. Two separate agents reconstructed
this the slow way before it was written down.

A reaped claim offers a LIVE branch to the next dequeuer

Measured 2026-09-03. A card claimed at 03:29:07 was reaped at 03:52:04 — about 23 minutes — while
its worker was demonstrably alive: pid running, 32 min elapsed, transcript written five seconds
earlier, token count climbing, tool calls firing.

The reap clears assigneeAgentId and nothing else. grokSessionId, branch and worktreePath
all stay on the card, so it goes back to ready still carrying the identity of the process that is
writing to that branch right now. The next tasks.next will hand it out. With a 15-second monitor
poll, the window between the reap and a second worker landing on the same worktree is about fifteen
seconds.

The TTL is roughly ten minutes and only the dispatch spawn path renews it. Any worker started
outside that path — a hand-run grok -p, a resumed session — never renews, so a run longer than the
TTL is guaranteed to be reaped mid-flight. Nothing warns anybody: the worker does not learn it lost
its claim and keeps going.

When you see a ready card whose worktreePath and grokSessionId are still populated, check for
a live process before letting it be dequeued.
Liveness is the process and the transcript, never a
find sweep over ~/.grok/sessions — the URL-encoded session directories make those sweeps return
false negatives, which is how a live worker reads as dead.

pgrep -f "<sessionId>"
ls -la ~/.grok/sessions/*<slice>*/<sessionId>*/updates.jsonl

If it is alive: restore status: "claimed", re-stamp grokSessionId / branch / worktreePath,
and put the reason in blockedReason so the intervention is auditable rather than quiet. Do not
set an assignee you are not
— forging that is worse than the reap. It costs a lane, and that is the
right trade: a queued card only waits, while two workers on one branch corrupt each other.

Read the worker's own transcript before deciding it is stuck; a long run with a clean worktree is
often a model reading before it writes, and killing it discards real context.

Dispatched workers have NO board tools — mailbox steering is inert on that path

Measured 2026-09-03 across a worker's full transcript: 67 tool_call events, and the complete distinct
set was todo_write, list_dir, read_file, run_terminal_command, grep, write, search_replace, web_fetch, get_command_or_subagent_output. Not one bothy-board call. No mailbox.poll, no
agents.heartbeat, no tasks.get, no tasks.update.

The worker noticed before I did. From its own reasoning: "the skill 'bothy-board' mentions BothyBoard
MCP — but MCP tools available are dra…"
It went looking and they were not in its harness.

This is structural, not one odd worker:

  • The generated spawn prompt under .openclinxr/slices/<id>/prompt-<id>.md contains zero mentions
    of mailbox, bothy, heartbeat or tasks.release. The worker is never told to poll.
  • dispatch-worker.ts calls bothy-board.worktrees.register and bothy-board.agents.heartbeat
    itself, at spawn. The PARENT registers and heartbeats, once.

Three consequences, each of which cost time before this was written down:

  1. mailbox.post does not reach a worker on this path. The skill's "mid-run steer is only
    mailbox.post" is true of workers that HAVE the tools; a dispatched one does not. Steering it
    requires the harness — kill and re-dispatch with the finding baked into the prompt, or resume the
    session directly.
  2. The claim reap is guaranteed, not incidental. The dispatcher heartbeats once at spawn and never
    again, and the child cannot heartbeat at all, so any run longer than the TTL is reaped mid-flight
    every time. Restoring status: claimed is the only protection and must be re-applied for as long
    as the run lasts.
  3. The worker cannot end itself cleanly. No tasks.release, no status=review. Its only exits
    are max-turns or someone else acting — so a brief that says "release the card if you get stuck" is
    asking for something the agent cannot do.

The trap to avoid, which I walked into twice: a deliverable appearing shortly after you post a
steer is NOT evidence the steer landed. Check for a mailbox.poll in the transcript before claiming
any causal effect. I reported "the steer took" on two separate ticks about a worker that had never
read a word of it.

Plant refuses a done_when target outside writeRoots — check that before create

Measured 2026-09-03. A card declaring writeRoots: ["tools/openclinxr/evidence/motion-backend-bakeoff"]
with a changed:tools/openclinxr/evidence/<test>.ts rule was refused at plant:

changed:tools/openclinxr/evidence/the-body-region-goal-follows-the-body.test.ts
is outside write_roots (tools/openclinxr/evidence/motion-backend-bakeoff)

The refusal is correct and it fires at PLANT, after create. Since tasks.update patches neither
writeRoots nor doneWhen, the only repair is cancel-and-recreate, and the whole card body has to be
retyped. That is the third distinct create-then-refuse recreate this session, after factory_step
taking a value outside the project's field vocabulary and a card created in the wrong project.

Before tasks.create, walk the done_when list and confirm every path target sits under a declared
write root.
The trap is narrowing the roots for tidiness: a RED usually lives one directory ABOVE the
product it guards, and the worker must edit that RED to flip it, so the test's directory is a write
root whether or not the fix touches anything else there.

briefFromIssue does not catch this. It validates rule SYNTAX and refuses narrative-only contracts;
the write-root containment check lives in plant. Preflighting with briefFromIssue and then being
refused at plant is the expected shape, not a surprise.

What the worktree reset actually destroys — gitignored files SURVIVE

Measured 2026-09-03 against the source, after I claimed the opposite three times in one session.

worktree-base-freshness.ts:149 runs git clean -fd. Its own header at :15 says the reset means
git reset --hard <mainHead> + git clean -fd "(NOT -fdx)", and :136 records why: "gitignored
node_modules/dist are preserved"
.

So the combination that dies is untracked and not ignored. Untracked-and-ignored survives.

state survives a re-dispatch?
tracked, committed on the branch yes
tracked, modified, uncommitted no — reset --hard
untracked, not ignored no — clean -fd
untracked, ignored yes-fd does not touch ignored paths

I told two workers and wrote on a card that the reset "would delete every untracked file". That was
right for the peds worker's new .ts sources, which were untracked and not ignored, and wrong as a
general claim.

The distinction decides salvage, and it decided one: a live bake had 375 files and 279 MB of staged
case output under .openclinxr/evidence/issue-288/cases/, all covered by .gitignore:9, zero tracked,
and git clean -nd on that path reported 0 paths to remove. An hour of Blender output was never at
risk. No preservation commit was warranted, and committing it would have been wrong anyway — the path
is ignored deliberately.

Check with git clean -nd <path> before deciding anything is doomed. It is a dry run and it
answers the question directly.

Before preserving a dead worker's tree, check whether its SESSION can be resumed

Measured 2026-09-03, on an intervention of mine that turned out to be unnecessary.

A card was reaped with its worker process gone and 22 uncommitted entries on its branch — 15 modified,
7 new — and zero commits ahead of main. Because the next dispatch resets the worktree before it
reattaches, that tree looked doomed, so I committed it to the branch as a snapshot.

The snapshot was discarded and the work survived anyway. The re-dispatch reset the branch back to
main, dropping my commit, and the resumed worker then produced a commit whose files are BYTE-IDENTICAL
to what I had preserved (three sampled files matched exactly; 22 files / 870 insertions in my snapshot
against 23 files / 880 insertions in theirs). The session transcript had carried the work, exactly as
this repo's own recovery note says: "the session's own transcript survives a worktree reset, so
resuming it replays decisions already made rather than re-deriving them."

So the question to ask first is not "is there uncommitted work" but "is the session resumable":

ls -la ~/.grok/sessions/*<slice>*/<sessionId>*/updates.jsonl   # transcript intact?
  • Transcript intact — the work is already backed up. A preservation commit is redundant, will be
    reset away by the next dispatch, and costs the reviewer a phantom commit to reason about.
  • Transcript gone or the slice will be re-dispatched to a FRESH session — nothing carries the
    work, and a snapshot on the branch is the only thing that will.

The snapshot did no damage here: identical content, cleanly discarded. But it was ceremony, and the
honest version of the earlier rule is narrower than what it said.

Claim renewal is unobservable, and the RED for it cannot be written yet

Measured 2026-09-03. Three live workers lost their claims in one session and the only detector was a
human polling the board every five minutes. The mechanism to prevent that already exists and appears
correct, which is why nobody has noticed it is silent.

dispatch-worker.ts:1480 starts a 2-minute renewal interval, and its own comment cites the incident
it was built for: "tsk_bca4085904e3b071 was claimed at 15:12:47Z and returned to ready at 15:22:52Z
with PID 79565 still alive and writing."
It calls announceBothyDispatchPresence, which wraps every
board call in catch {} under "board visibility is not a dispatch contract".

So a renewal that fails is indistinguishable from one that succeeded. Failure modes that would look
identical: auth, network, a board 500, or an agentId the dispatcher never received — :1461 sends
that field only if (input.agentId), while :228 records that heartbeat "renews that exact
claimant". The interval keeps firing either way.

Measured against the live ledger, .openclinxr/openclaw/worker-sessions.jsonl (1.2 MB):

what value
ledger row keys sessionId, slice, role, model, worktree, contractSource, at, phase
rows mentioning renewal 0
session 826687c4, spawned 04:58:53Z, ~20 renewals due 0 recorded
claims reaped under a live worker, this session 3

Why no RED is planted for this. I wrote one and deleted it. Its natural target is the ledger, and
.gitignore:9 covers .openclinxr/, so the test cannot pass — or meaningfully fail — outside the one
machine that has the file. That is the "a guard that inspects gitignored assets cannot fail on a clean
clone" trap, in its red-about-nothing direction. Neither startBothyClaimRenewal nor
announceBothyDispatchPresence is exported, so a unit test cannot reach them either. The mechanism is
unobservable from every direction at once, and making it observable means choosing an interface.

The decision, in one line: should renewal outcomes surface on DispatchLedgerEntry (testable, no
gitignored artifact), or as tracked rows outside .openclinxr/?
Until that is answered, the
detector is a human, and the repair is the manual claim restore described above.