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>
This commit is contained in:
Kjell Tore Guttormsen 2026-08-31 18:11:04 +02:00
commit 85cd628c3e
5 changed files with 701 additions and 21 deletions

102
CLAUDE.md
View file

@ -411,7 +411,7 @@ marketplace plugin. Three components, one boundary:
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
next-step blocks + board lines, `git status`, and mailbox pending counts, and
prints one line per repo. Read-only by construction: it writes to no repo, no
STATE.md and no mailbox. Pinned by `board-selftest.sh` (325 checks).
STATE.md and no mailbox. Pinned by `board-selftest.sh` (360 checks).
**It lives here because the mailbox is one of its three inputs, and it carries
the same axis distinction the mailbox does.** A pending count means *others
@ -1074,6 +1074,98 @@ marketplace plugin. Three components, one boundary:
way `--dispatch` requires `--target-pane` to be measured by the caller and
passed in. Not built: not ordered, and keeping it out keeps the dependency
running one way.
- **`--voyage` is the SIXTH rendering, and it exists because `board` read STATE
lines and knew nothing about a brief.** Order 20260831T135934Z-696228720
(`.claude`, 2026-08-31) fired the programme-level B1 row once `/trekplan` had
been delivered in `llm-ingestion-okf`, so the fields could be chosen against a
run that had gone the whole way (`brief.md.draft` 30.08 -> `brief.md` -> four
research topics + README 31.08 14:19-14:54 -> `plan.md` 15:30) rather than
guessed. A programme with Voyage in several repos had no shared surface at
all: nobody could answer which briefs were in flight, in what phase, and who
was waiting on whom. Read-only holds untouched - every field is a read.
**Detection is by PROPERTY, never by directory name, and never through the git
index.** The global CLAUDE.md says plans and briefs are defined by properties
and recognises three locations (`.claude/projects/`, `docs/`,
`features/<n>-<name>/`), so a directory counts iff it HOLDS `brief.md` or
`brief.md.draft` under one of them. The index-based alternative was refused on
a measurement the order supplied and this repo re-checked: `llm-ingestion-okf`
gitignores `.claude/projects/` (commit 087be0b), so `git ls-files` would report
ZERO briefs in the one repo actually running one - a broken query returning a
positive-looking null, which is ansikt 4 pointed at our own tooling.
Mutation-verified: swapping the filesystem test for `git ls-files` turns 23
checks red, and the fixture asserts its own ground truth (`git check-ignore`
really does ignore it) before anything depends on it. The known-negative
control the location rule already passes is a real one - `maskinrommet`'s
`serier/*/brief.md` are content briefs, not Voyage projects, and sit outside
all three locations.
**The phase ladder measures ARTIFACTS, not sessions, and the legend says so in
those words.** `brief-draft` -> `brief` -> `research` -> `plan` -> `execute` ->
`review`, furthest artifact present wins. The bound is stated rather than
hidden: `/trekexecute` leaves a file behind only in its MULTI-session form
(`.session-state.local.json`), 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, and pinned the same way, as
a check on the legend TEXT.
**Three fields where an ABSENCE must not borrow the shape of a measurement.**
`brief_quality` is read from the FRONTMATTER BLOCK ONLY (a body line is
untrusted text and must not speak for the brief, the same rule
`coord-count.sh` applies to `reply-expected`), and an absent field reads `-`,
never `complete`: measured across the real tree 2026-08-31, only 8 of ~40
briefs carry it at all. `research=-` (no directory, never started) and
`research=0` (a directory holding nothing) are different facts - the completed
run's `research/README.md` opened with "this directory held 0 files when the
session started", and no board could show that. And `--voyage` always prints
its own denominator; a tree with no projects says `ingen=0 ... funnet i N
skannede repo` rather than rendering as an empty page.
**Blocking decisions are counted at DECLARATION SITES, not mentions.** The
pattern `[BLOCKING DECISION, before S<n>]` occurs on FOUR lines of the real
brief, of which ONE declares it and three refer to it in prose, so a bare
`grep -c` answers 4 where the honest answer is 1. The bracket must open the
line's content after list markers, headings and bold. Mutation-verified:
replacing the anchored pattern with the bare string turns exactly three checks
red, the known-negative control (a brief whose only occurrences are prose must
count 0) among them. It reports what the brief DECLARES and infers nothing
about resolution - board reports and refuses the inference.
**The age is the NEWEST artifact, which INVERTS the oldest-wins rule ORDRE and
FLY carry, deliberately.** An order queue's problem is the oldest item still
waiting; a project's problem is that its most recent activity is old
("nothing has happened here for N days"). Both answer the same question about
a different object. Mutation-verified: flipping `-gt` to `-lt` turns exactly
the one oldest/newest check red with every control green. The repo-level `VOY`
cell then takes the STALEST project's age, which is the oldest-wins rule
applied one level up.
**`VOY` is display-only, never summed with `ORDRE`/`FLY`, and never in the
sort** - the standing rule that a new ranking axis would silently reorder a
parser living in another repo. It is not in `--plan`'s `key=value` blocks
either: the requirement is that a human reading the board can see a brief is
standing still, and the table is where that is read. The per-project detail
lives in `--voyage` alone, because one cell cannot carry a phase when a repo
holds seven briefs (`portfolio-optimiser` holds 20).
**The cost was measured rather than assumed, and this file's old "~3s" claim
did not survive it.** Shipped 0.31.0 takes **6.3s** over the real 52-repo tree;
with `VOY` it is **7.7-9.2s**. The first cut called `stat` once per artifact
and took 13.4s - the batched form (`find ... -exec stat -f %m {} +`, one
`find` and one `stat` per project) is what makes the column affordable beside
the table. Live verification against the completed run reproduced every field
the order named: `fase=plan`, `kvalitet=complete`, `blokkerende=1`,
`blokkerende_gate=S4`, `research=5`, and it is the only project of 51 across
14 repos with an open blocking decision.
**Bounded gap, stated rather than closed:** whether a detected project is
still "in flight" is NOT decided here. Distinguishing a live brief from a
finished one needs a threshold, and encoding one would make the board decide
that work is abandoned - the identical thing the order queue is already
forbidden from doing ("nothing here expires anything"). The board reports
phase and age and leaves the judgement to the reader, which is why 51
projects are listed rather than some filtered subset.
- **Skills (`skills/coord-send/`, `skills/board/`, `skills/route/`, `skills/dispatch/`):** natural-language front
doors mapping user intent to engine invocations. No mailbox logic lives here
either. `board` additionally owns the *ranking* — which repo wins and why —
@ -1122,7 +1214,7 @@ obligations in another repo.
builtins only in hook and tests.
- TDD: no behavior change without a failing selftest check first.
`bash scripts/coord-selftest.sh` must exit 0 (242/242),
`bash scripts/board-selftest.sh` must exit 0 (325/325),
`bash scripts/board-selftest.sh` must exit 0 (360/360),
`bash scripts/route-selftest.sh` must exit 0 (69/69),
`bash scripts/orders-selftest.sh` must exit 0 (110/110) and
`bash scripts/state-line-guard-selftest.sh` must exit 0 (54/54).
@ -1156,7 +1248,11 @@ obligations in another repo.
- Hook smoke test: `node hooks/scripts/session-start.mjs` (expects JSON on stdout)
- State-line-guard smoke test: `echo '{"tool_name":"Write","tool_input":{"file_path":"/tmp/STATE.md","content":"x\n"}}' | node hooks/scripts/pre-state-line-guard.mjs; echo $?`
(expects exit 0, no output — a one-line STATE.md is under the limit)
- Board smoke test: `bash scripts/board.sh` (read-only, ~3s over the real tree)
- Board smoke test: `bash scripts/board.sh` (read-only, ~8s over the real
52-repo tree - measured 2026-08-31; the older "~3s" figure predated both
the current tree size and the VOY column)
- Voyage smoke test: `bash scripts/board.sh --voyage` (read-only, writes
nothing; ~22s, since it greps every detected brief)
- Briefing smoke test: `bash scripts/board.sh --brief` (read-only, writes
nothing). `brief-nightly.sh` DOES write — it overwrites `$CLAUDE_BRIEF_FILE`
(default `~/.claude/briefing.md`), so point that at a scratch path when