repo-mailbox/skills/board/SKILL.md
Kjell Tore Guttormsen 85cd628c3e feat(board): --voyage reports the Voyage briefs in flight
board read STATE lines and knew nothing about a brief, so a programme
running Voyage across several repos had no shared surface: nobody could
answer which briefs were in flight, in what phase, and who was waiting on
whom. Order 20260831T135934Z-696228720 fired the programme-level B1 row
once /trekplan had been delivered in llm-ingestion-okf, so the fields
were chosen against a run that had gone the whole way rather than guessed.

Adds board.sh --voyage (a sixth rendering of the same scan, read-only)
and a VOY column beside ORDRE and FLY, never summed with them and never
in the sort.

Detection is by PROPERTY, never by directory name: a directory holding
brief.md or brief.md.draft under any of the three planning locations the
convention recognises. It walks the FILESYSTEM, never the git index -
llm-ingestion-okf gitignores .claude/projects/ (087be0b), so an
index-based detector would report ZERO briefs in the one repo actually
running one.

The phase ladder measures ARTIFACTS, not sessions, and the legend says
so: /trekexecute leaves a file behind only in its multi-session form, so
a plan executed in one session leaves nothing and `plan` is the last
thing the filesystem can prove. Nothing here claims a session is alive -
the same refusal FLY carries.

Three absences that must not borrow the shape of a measurement:
brief_quality is read from the frontmatter block only and an absent field
reads `-`, never `complete` (only 8 of ~40 briefs on the real tree carry
it); research=- (never started) is distinct from research=0 (a directory
holding nothing); and --voyage always prints its own denominator rather
than rendering as an empty page.

Blocking decisions are counted at DECLARATION SITES, not mentions: the
pattern occurs on four lines of the real brief, of which one declares it,
so a bare grep -c answers 4 where the honest answer is 1.

The age is the NEWEST artifact, inverting the oldest-wins rule ORDRE and
FLY carry - an order queue's problem is the oldest item still waiting, a
project's problem is that its most recent activity is old.

Mutation-verified three ways: newest->oldest turns exactly one check red,
anchored pattern->bare string turns three red (the known-negative control
among them), filesystem->git ls-files turns 23 red. Live-verified against
the completed run: fase=plan, kvalitet=complete, blokkerende=1, gate=S4,
research=5 - the only project of 51 across 14 repos with an open blocking
decision.

Cost measured rather than assumed: 0.31.0 takes 6.3s over the real
52-repo tree, with VOY 7.7-9.2s. The first cut called stat once per
artifact and took 13.4s; the batched form is what makes the column
affordable. CLAUDE.md's old "~3s" claim did not survive the measurement
and is corrected.

Bounded gap, stated rather than closed: whether a detected project is
still "in flight" is not decided here. That needs a threshold, and a
threshold would make the board decide that work is abandoned - the
identical thing the order queue is already forbidden from doing.

board-selftest.sh: 325 -> 360 checks.

Co-Authored-By: Claude <claude-opus-5>
2026-08-31 18:11:04 +02:00

346 lines
19 KiB
Markdown

---
name: board
description: >-
Answer "which repository deserves the next session" across every repo at once,
using the local cross-repo attention board (`board.sh`) as the evidence. Use
whenever the user asks what to work on next, who is waiting on them, what
unblocks the most other work, what is cheapest to move under quota pressure, or
for a cross-repo status read: "what should I work on", "who is waiting on me",
"what unblocks the most", "show the board", "what is cheapest to move",
"cross-repo status", "where is the leverage", "which repo is blocked". Also
covers planning a whole day rather than picking one repo: "plan my day", "which
repos should I open tabs for", "day plan" — including a day with a subject:
"focus day on X", "today I am only working on X", "which repos does X touch".
Also triggers on Norwegian phrasings:
"hva skal jeg jobbe med", "hvem venter på meg", "hva løsner mest", "vis tavlen",
"hva er billigst å flytte", "hvor bør jeg begynne", "status på tvers av repo",
"hva er blokkert", "lag en dagsplan", "planlegg dagen", "hvilke repo skal jeg
åpne i dag", "fokusdag på X", "i dag jobber jeg bare med X", "hvilke repo
gjelder X". Also covers Voyage briefs in flight across repos: "which briefs are
running", "what phase is that brief in", "which brief is blocked on a decision",
"show the voyage board", "hvilke briefer er i gang", "hvilken fase ligger den i",
"hvilke briefer venter paa en beslutning", "vis Voyage-oversikten". Trigger even when the
user names no repo and no tool — choosing *between* repos is this skill. Not for
"where were we" inside the current repo: that is this repo's own STATE.md,
already injected at session start.
version: "0.31.0"
---
# board — which repo deserves the next session
This skill turns "what should I work on" into a decision backed by measurement.
`board.sh` is the engine; this skill is the ranking and the answer. The script is
read-only by construction — it never writes to a repo, a STATE.md, or the mailbox
— so running it is always safe and needs no confirmation.
The failure mode this skill exists to prevent is dumping the table. The board
prints one line per repo across every repo on the machine; pasting that back is
not an answer, it is the question again in table form. Run it, read it, name one
repo.
## The engine
BOARD="${CLAUDE_PLUGIN_ROOT}/scripts/board.sh"
There is no deployed copy anywhere else and no fallback path. One entry point is
the whole point: two entries pointed at two copies and made it impossible to know
which one actually ran. If this fails to resolve, the fix is this expression —
never restoring a copy under `~/.claude/scripts/`.
"$BOARD" # every repo under the default roots
"$BOARD" --roots <dir>[,<dir>...] # scan somewhere else
`BOARD_ROOTS` overrides the default scan roots and `CLAUDE_COORD_DIR` the mailbox
root. Exit 0 is normal (0 discovered repos also exits 0, silently); exit 2 means a
malformed argument — read stderr and fix it rather than retrying.
**Re-run it every single invocation.** Never answer from a board you ran earlier
in the session, from a number quoted in a document, or from memory. Inbox counts,
uncommitted files and board lines all change between turns, and a recommendation
built on a stale count is the exact defect the operator's premise-verification
rule exists to stop. The run costs about eight seconds over ~50 repos (measured 2026-08-31; the
older "about three seconds" figure predates both the current tree size and
the `VOY` column).
## What the columns mean
| Column | Meaning |
|---|---|
| `STATUS` | `planned` / `in-progress` / `blocked` / `deferred` / `done`, or `blocked>X` naming the repo it waits on. `?` means the STATE.md has no board line. |
| `KOST` | Model/effort for the next step, from the rubric row table in `route.sh` (the `route` skill writes it; this one only reads it). |
| `INN` | Unhandled inbox: **other repos are waiting on THIS one**. An obligation it owes outward. |
| `ORDRE` | Pending orders: **authorized work is waiting on this repo**, unclaimed and pickable. |
| `FLY` | Orders in flight (claimed). Someone TOOK the order — never proof a session is still alive. |
| `VOY` | Voyage projects (briefs in flight). `N:Md` = how many, and how long since the **stalest** one's newest artifact. A bare `0` means none. Per-project detail lives in `--voyage`. |
| `DRT` | Uncommitted files. |
| `ALDER` | Days since STATE.md last changed — the age of the *plan*. `-` where the repo has none. |
| `SISTE` | Days since the last commit — the age of the *work*. `-` where the repo has no commits yet. |
| `NESTE` | First line of the STATE.md next-step block, truncated. |
## Briefs in flight — `--voyage`
"$BOARD" --voyage
Answers what no single `STATE.md` can: **which Voyage briefs are running, in what
phase, and who is waiting on whom.** One `key=value` block per project.
`fase` is the only field here that cannot be derived from a STATE.md at all, and
it is why the view exists: `brief-draft` (the `/trekbrief` review gate has not
cleared) → `brief``research``plan``execute``review`.
Read these three the way the engine means them, and never soften them:
- **`fase` measures ARTIFACTS, not sessions.** A plan executed in one session
leaves no file, so `plan` is the last thing the filesystem can prove. Nothing
here says a session is alive — the same refusal `FLY` carries.
- **`kvalitet=-` means the `brief_quality` field is ABSENT, never that the brief
is complete.** Only 8 of ~40 briefs on the real tree carry it. `partial` vs
`complete` is exactly what three presence-greps cannot tell apart.
- **`research=-` and `research=0` are different facts.** `-` is no research
directory (never started); `0` is a directory that exists and holds nothing —
a research step with null output.
`venter=operatoerbeslutning` means the brief declares an open
`[BLOCKING DECISION, before S<n>]`, and `blokkerende_gate` names the step it
gates. That is the one form of "who waits on whom" the files can prove; the
`ordre_id=` lines beside it carry the repo's pending orders, which is what lets a
reader go from "this brief is standing still" to "this order is pending".
Report what the blocks say. Do **not** infer that a brief is abandoned, that a
decision has since been resolved, or that a session is running — the view
reports and refuses the inference, and so should you.
**`ORDRE` and `FLY` are never summed, and `FLY` is never read as "busy."** They
are the same queue in two states. Before `FLY` existed, a repo with one order in
flight and a repo with no orders at all both printed `ORDRE 0` — the same digit
for two opposite facts, which is how two tabs sat idle for 45 hours holding
finished orders with nothing on the board reporting it. What `FLY` still cannot
tell you is whether a session is *running*: nothing un-claims an order when the
session that claimed it dies (one order on the live mailbox had been claimed for
117 hours). Say "an order is claimed here", never "a session is working here".
The board inspects no processes and will not start.
**`INN` never means "this repo is waiting on someone."** It means the opposite:
messages arrived and were not handled. The mailbox format carries no reply-to or
thread field, so outbound waiting is not derivable from it at all — `blocked>X` is
the only source for who a repo waits *on*. Conflating the two axes is a known
defect class here; keep them apart in every sentence you write.
## How to rank
Apply in order and stop at the first that discriminates. Always say which rule
fired — the rule is the justification, and it is what lets the operator disagree.
1. **Unblocks the most.** A repo that others are `blocked>` on. Moving it converts
several blocked repos to active: the highest leverage per token spent. Follow
the chain — if A is blocked on B and B is blocked on C, the answer is C.
2. **Cheap and blocking.** Among unblockers, prefer the lowest `KOST`. This is the
standing rule under quota pressure: take the cheap blocking one first.
3. **Owes the most outward.** High `INN` means other sessions are stalled waiting
for a reply. Answering is procedurally mandatory anyway, so this work is owed
regardless of what else is on the board.
4. **Uncommitted risk.** High `DRT` is not urgent work, but it is exposure worth
naming: the config backup mirrors commits, not working trees, so uncommitted
files exist in exactly one place.
**Neither `ALDER` nor `SISTE` is a ranking input.** An old STATE.md often means a
finished repo, not a neglected one, and an old last commit can mean the same.
Report either only if the user asks about staleness directly — the four rules
above stay four.
Read them as a **pair**, though: they answer different questions, and the gap
between them is the reading neither gives alone. A fresh `ALDER` beside a long
`SISTE` is a repo that keeps being re-planned without anything landing, which no
other column on the board reports.
`deferred` is a deliberate choice, not neglect — do not surface a deferred repo as
a candidate unless nothing else qualifies, and say that it was deferred if you do.
## Read the winner's STATE.md before answering
The `NESTE` column is truncated to fit the table, so it is a pointer, not the
action. Once ranking picks a repo, open that repo's `STATE.md` and read the actual
next-step block. Report the real first action from that block. A truncated
fragment quoted as if it were the next step is a wrong answer that looks right.
## The answer
Short prose, roughly five to ten lines. No table, no per-repo rundown, no top-five
list — "top five with their columns" is still a dump.
- **The repo**, named once.
- **Why it won**, as the rule that fired.
- **The concrete first action**, from that repo's STATE.md next-step block.
- **What it costs** — the `KOST` value, so the operator can set model and effort
before starting.
- **Runner-up in one clause**, only if it is genuinely close.
If the user asked a narrower question ("who is waiting on me", "what is cheapest"),
answer that question directly from the same run instead of forcing the full
ranking onto them.
## When the ask is a day, not a repo
"Hvilke repo skal jeg åpne tabber for i dag", "lag en dagsplan", "planlegg dagen",
"hva står på programmet" — that is a different question from "which repo wins",
and it has its own rendering:
"$BOARD" --plan
This is the one case where a list *is* the answer and the no-dumping rule does not
apply — the user asked for the day, and a day has more than one repo in it. Pass
the plan through with a short framing line; do not re-rank it, re-order it, or trim
it. The order is the engine's position and it is deterministic — five ordered
groups, each a lookup, no weights:
1. chain-root credit (repos released transitively, most first)
2. unhandled inbox (most owed first, whatever the status)
3. planned
4. in-progress
5. no declared status (last, and labelled)
Within a group: that group's own quantity, then a Sonnet `next-cost`, then oldest
plan first. `planned` sits above `in-progress` by operator decision at 0.20.0 —
turning a decision into motion is the slow step; live work is already moving.
**Chain-root credit is the term worth understanding before you explain an order
to the operator.** For every `blocked` repo the engine follows `blocked-on` to
the ROOT of the chain and credits only the root, transitively. Opening a blocked
repo releases nobody — its next step is by definition waiting; opening the root
releases everything behind it. A cycle or a `blocked-on` naming an unscanned repo
credits nobody. This is engine rule 1 above, now computed rather than eyeballed.
Debt is **uncapped and never excluded** by decision: owing a reply is the other
axis from a repo's own next step, and answering is often what unblocks a chain.
Sitting one group below chain-root credit is not a cap — a debtor keeps its tab,
its most-owed-first position among the other debtors, and its `why=inbox:N`.
`why=` names the **group** that put the repo in the plan, so a block can read
`why=unblocks:2` even though the repo also owes mail. Read it as "what opening
this releases", not as the only reason it qualified. Substituting your own judgement for the order makes
the plan unreproducible and costs the property that makes it trustworthy.
Two things to say out loud when you hand it over:
- **A `done` or `blocked` repo in the plan is not a bug.** It is there because it
owes mail, and owing an answer is a different axis from whether its own next step
can move. `why=inbox:N` says exactly that.
- **`command_missing=` means that repo has no usable route line**, so there is no
command to paste. Name those repos rather than letting the operator discover it
per tab. Fixing them is the `route` skill's job, in *that* repo — never a side
quest here.
- **`ledig_antall=N` and the `ledig=` lines are free capacity, not tabs.** They
name the repos that can take NEW work: nothing owed, nothing queued, nothing in
flight, clean tree, at `done` or `deferred`. They carry no `tab=` and no
command on purpose — there is no next step to start, so the operator decides
what to send there. Read them out when the ask is about capacity ("hvem kan ta
mer arbeid", "hvor har jeg ledig kapasitet") and whenever the plan is short.
`status=done` alone is **not** the same set: on the real tree 4 of 17
done/deferred repos were not free. Never derive this list yourself from the
table — the engine joins four fields you would have to join by hand.
- **`fly=N` on a tab block means that repo already holds a claimed order.** Say
so before the operator opens the pane. It is not proof a session is live, and
it is not a reason to drop the tab — it is a reason to look first.
### When the day has a subject ("fokusdag")
"I dag jobber jeg bare med X", "fokusdag på X", "hvilke repo gjelder X" — pass the
user's own words through, untouched:
"$BOARD" --plan --focus "<the user's phrasing, verbatim>"
**Do not tokenize, normalize or translate the phrase into a slug yourself.** The
engine resolves prose against the topic markers the scanned `STATE.md` files
actually declare, and it reports what it resolved to. Guessing a slug here would
put a judgement call in front of a lookup that is already deterministic — and
`fokus=` in the output tells the user what the phrase resolved to, which your
guess would silently replace.
This is the only rendering that *hides* repos, so three lines carry weight and
must be relayed, never trimmed as noise:
- **`fokus_utenfor=`** — repos that *mention* the subject with no marker line.
They are named, not counted, because that is where the misses live: a repo can
be a heavy participant and still never have written a marker. Say "nevner",
never "dekker" — this reports text found in a file, not relevance.
- **`fokus_droppet=`** — how many blocks the cutoff removed.
- **`fokus_rekkevidde=`** — how many `STATE.md` were searched. The scan opens no
other file, so a repo whose evidence lives in a `README` is invisible to it.
If `fokus_ikke_brukt=` appears, the phrase matched no declared subject and the
**full** plan came back. Say so plainly — the user asked for a narrow day and got
a wide one, and presenting it as focused would be the one failure this whole
rendering is built to avoid.
Blocks are `key=value` and comment lines start with `#`, because a separate driver
repo consumes this output. That format is a contract with a consumer outside this
repo: never reformat it, and never hand-edit a block before passing it on.
**Point the operator at `paste=`, not at `dir=` + `command=`.** Those two exist
for a driver, which cd's the pane itself and then types the command; `paste=` is
the same thing already joined, and it is the only line a human should ever copy.
Telling someone to "cd to `dir=` and then paste `command=`" asks them to assemble
two fields by hand, which is exactly how a session gets started in the wrong repo.
A block with `command_missing=` has no `paste=` at all — say so for those repos
rather than letting the operator hunt for a line that is not there.
## Coverage gaps you must name
A ranking is only as honest as its inputs, and two gaps are invisible in the answer
unless you state them:
- **Repos with no board line** render `?` in status and cost, so they drop out of
exactly the sort the board exists for. The script counts them in its closing
note. Pass that count through as one clause ("N repos have no board line and are
outside this ranking"), and never let it grow into a side quest — adding board
lines to other repos is that repo's work, not this session's.
- **`MALFORMED` status tokens** mean a board line outside the closed vocabulary.
The script warns; repeat the warning and name the repo, because a malformed line
silently misplaces a repo in the wrong bucket.
Repos with no STATE.md at all appear in their own group. They are not candidates —
there is no next step to read — but a large uncommitted count in one is worth a
sentence under rule 4.
## Boundaries
- **Read-only, both ways.** The script writes nothing, and neither should the
skill. Do not fix a missing board line, edit another repo's STATE.md, or send a
coord message as a side effect of ranking. Recommend; the operator decides.
- **Not a session-start hook.** Injecting the board into every session costs real
context about repos the session is not in. It runs when asked, never on a timer.
- **Coordination metadata is private.** Repo names, blocking chains and inbox
counts are local operational data and never belong on a public surface.
## Examples
Placeholder names throughout — resolve real ones from the run, never from a
document.
**Example 1 — the ordinary ask**
Input: "hva skal jeg jobbe med nå?"
Action: run the board. `repo-a` is `blocked>repo-b`, and `repo-b` is active at a
low cost. Answer: `repo-b`, rule 1 + 2 (cheap and unblocks `repo-a`), the first
action read from `repo-b/STATE.md`, and its `KOST`.
**Example 2 — quota pressure**
Input: "jeg har lite kvote igjen, hva er billigst å flytte?"
Action: run the board, restrict to candidates whose `KOST` is at the low end of the
rubric, and prefer one that also unblocks something. Name the cost explicitly.
**Example 3 — the obligation question**
Input: "hvem venter på meg?"
Action: read `INN` only. List the repos with unhandled mail and say that this is
mail owed outward, not repos this one is waiting on. Do not rank; the question was
narrower than the ranking.
**Example 4 — chain following**
Input: "what unblocks the most?"
Action: build the `blocked>` chain from the run, and name the repo at its root,
plus how many repos come free when it moves.
**Example 5 — nothing qualifies**
Input: "vis tavlen"
Action: if every repo is `done` or `deferred`, say so plainly and name the largest
uncommitted exposure instead of inventing a candidate. An empty board is a real
answer.