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

267
scripts/board-selftest.sh Executable file → Normal file
View file

@ -2673,6 +2673,273 @@ check "orderage: order age is display-only - it does not reorder the board" $?
/bin/rm -rf "$AGROOT" "$AGCOORD" 2>/dev/null
# --- 29. VOYAGE: briefs in flight (ORDRE 20260831T135934Z-696228720) --------
# board read STATE lines and knew nothing about Voyage briefs, so a program
# running Voyage in several repos had no shared surface: nobody could answer
# "which briefs are in flight, in what phase, and who is waiting on whom".
#
# The fields below are the ones that carried information in the ONE completed
# run (llm-ingestion-okf, .claude/projects/2026-08-30-door-b-concept-granularity,
# brief.md.draft 30.08 -> brief.md -> research/ 31.08 14:19-14:54 -> plan.md
# 31.08 15:30), measured against the files themselves rather than inherited
# from the order.
#
# DETECTION IS BY PROPERTY, NEVER BY DIRECTORY NAME: a directory holding
# brief.md or brief.md.draft, under any of the three locations the global
# CLAUDE.md recognises (.claude/projects/, docs/, features/<n>-<name>/).
# It walks the FILESYSTEM, never `git ls-files`: llm-ingestion-okf gitignores
# .claude/projects/ (commit 087be0b), so an index-based detector would see
# ZERO briefs in the one repo actually running one. Pinned below with a
# known-positive control that asserts git really does ignore the fixture.
VROOT="$(mktemp -d)"
VCOORD="$(mktemp -d)"
V_OLD10="$(date -v-10d +%Y%m%d%H%M)"
V_OLD15="$(date -v-15d +%Y%m%d%H%M)"
V_OLD20="$(date -v-20d +%Y%m%d%H%M)"
mk_v_repo() {
mkrepo "$VROOT/$1"
{
echo "# STATE - $1"
echo ""
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=sonnet/xhigh -->"
echo "Et steg."
} > "$VROOT/$1/STATE.md"
}
# A brief with real frontmatter. $1=path $2=brief_quality ("-" for no field)
mk_v_brief() {
{
echo "---"
echo "type: trekbrief"
echo "created: 2026-08-30"
[ "$2" = "-" ] || echo "brief_quality: $2"
echo "---"
echo ""
echo "# Task: a thing"
} > "$1"
}
# vy-full: the shape of the completed run - brief + research + plan, one
# BLOCKING DECISION declared and referenced three more times in prose.
mk_v_repo vy-full
mkdir -p "$VROOT/vy-full/.claude/projects/2026-08-30-full/research"
mk_v_brief "$VROOT/vy-full/.claude/projects/2026-08-30-full/brief.md" complete
{
echo "## Open Questions"
echo ""
echo "- **[BLOCKING DECISION, before S4] Which form carries identity?**"
echo "- **S4b, written per branch, because the BLOCKING DECISION is open.**"
echo 'open\":** the \`[BLOCKING DECISION, before S4]\` form is an open question.'
echo "# ([BLOCKING DECISION, before S4] above). Research does not depend on it."
} >> "$VROOT/vy-full/.claude/projects/2026-08-30-full/brief.md"
echo "# Research" > "$VROOT/vy-full/.claude/projects/2026-08-30-full/research/README.md"
echo "# T1" > "$VROOT/vy-full/.claude/projects/2026-08-30-full/research/topic-1.md"
echo "# T2" > "$VROOT/vy-full/.claude/projects/2026-08-30-full/research/topic-2.md"
echo "# Plan" > "$VROOT/vy-full/.claude/projects/2026-08-30-full/plan.md"
find "$VROOT/vy-full/.claude/projects/2026-08-30-full" -type f -exec touch -t "$V_OLD10" {} \;
# vy-draft: /trekbrief ran, the review gate never cleared. brief.md.draft and
# no brief.md is the state the completed run sat in for a full day, and it is
# the one phase no STATE.md line can express.
mk_v_repo vy-draft
mkdir -p "$VROOT/vy-draft/.claude/projects/2026-08-01-draft"
echo "# draft" > "$VROOT/vy-draft/.claude/projects/2026-08-01-draft/brief.md.draft"
# vy-partial: docs/ location, brief_quality partial. Three presence-greps
# cannot tell this from vy-full's `complete` - that is the whole reason the
# field is read out of the frontmatter and not inferred from the file list.
mk_v_repo vy-partial
mkdir -p "$VROOT/vy-partial/docs/some-plan"
mk_v_brief "$VROOT/vy-partial/docs/some-plan/brief.md" partial
# vy-feature: features/<n>-<name>/ location, and a research directory that
# exists holding NOTHING. A research step with null output is ansikt 4 at
# programme level, so the count is reported rather than the directory's
# existence being taken as delivery.
mk_v_repo vy-feature
mkdir -p "$VROOT/vy-feature/features/01-thing/research"
mk_v_brief "$VROOT/vy-feature/features/01-thing/brief.md" -
# vy-review: the far end of the ladder.
mk_v_repo vy-review
mkdir -p "$VROOT/vy-review/.claude/projects/2026-07-01-rev"
mk_v_brief "$VROOT/vy-review/.claude/projects/2026-07-01-rev/brief.md" complete
echo "# Plan" > "$VROOT/vy-review/.claude/projects/2026-07-01-rev/plan.md"
echo "# Review" > "$VROOT/vy-review/.claude/projects/2026-07-01-rev/review.md"
# vy-prose: the known-NEGATIVE control for the blocking-decision count. The
# string appears, but only mid-line in prose - never as a declaration. A
# detector that greps for the bare string reads 2 here and is wrong.
mk_v_repo vy-prose
mkdir -p "$VROOT/vy-prose/.claude/projects/2026-07-02-prose"
mk_v_brief "$VROOT/vy-prose/.claude/projects/2026-07-02-prose/brief.md" complete
{
echo 'The plan stays gated on the \`[BLOCKING DECISION, before S4]\` in the brief.'
echo "# ([BLOCKING DECISION, before S9] above). Nothing is declared here."
} >> "$VROOT/vy-prose/.claude/projects/2026-07-02-prose/brief.md"
# vy-ignored: .claude/projects/ is gitignored, exactly as llm-ingestion-okf
# has it. Detection must still find the project.
mk_v_repo vy-ignored
echo ".claude/projects/" > "$VROOT/vy-ignored/.gitignore"
mkdir -p "$VROOT/vy-ignored/.claude/projects/2026-08-29-hidden"
mk_v_brief "$VROOT/vy-ignored/.claude/projects/2026-08-29-hidden/brief.md" complete
# vy-multi: two projects, one fresh and one stale. The repo's VOY age is the
# STALEST project's.
mk_v_repo vy-multi
mkdir -p "$VROOT/vy-multi/.claude/projects/2026-08-31-fresh"
mk_v_brief "$VROOT/vy-multi/.claude/projects/2026-08-31-fresh/brief.md" complete
mkdir -p "$VROOT/vy-multi/.claude/projects/2026-08-16-stale"
mk_v_brief "$VROOT/vy-multi/.claude/projects/2026-08-16-stale/brief.md" complete
touch -t "$V_OLD15" "$VROOT/vy-multi/.claude/projects/2026-08-16-stale/brief.md"
# vy-newest: one 20-day-old artifact and one fresh one in the SAME project.
# The age is the NEWEST artifact - "nothing has happened here for N days" -
# 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. Flipping this to oldest reads 20d.
mk_v_repo vy-newest
mkdir -p "$VROOT/vy-newest/.claude/projects/2026-08-11-newest"
mk_v_brief "$VROOT/vy-newest/.claude/projects/2026-08-11-newest/brief.md" complete
touch -t "$V_OLD20" "$VROOT/vy-newest/.claude/projects/2026-08-11-newest/brief.md"
echo "# Plan" > "$VROOT/vy-newest/.claude/projects/2026-08-11-newest/plan.md"
# vy-none: no Voyage project anywhere. VOY must read a bare 0.
mk_v_repo vy-none
mkdir -p "$VROOT/vy-none/docs"
echo "# just docs" > "$VROOT/vy-none/docs/notes.md"
# A pending order in vy-full, for the brief-phase <-> order-id coupling.
mkdir -p "$VCOORD/vy-full/orders"
printf -- '---\nfrom: x\nto: vy-full\norder-id: 20260831T120000Z-1111\n---\nDo a thing.\n' \
> "$VCOORD/vy-full/orders/20260831T120000Z-1111.md"
# --- Ground truth FIRST. Section 11 of state-line-guard-selftest.sh went
# vacuously green because its fixture was never created; assert the fixture
# really is what the checks below depend on before depending on it.
[ -f "$VROOT/vy-full/.claude/projects/2026-08-30-full/plan.md" ] \
&& [ -f "$VROOT/vy-draft/.claude/projects/2026-08-01-draft/brief.md.draft" ] \
&& [ ! -f "$VROOT/vy-draft/.claude/projects/2026-08-01-draft/brief.md" ]
check "voyage: GROUND TRUTH - the fixture tree really holds plan.md and a bare brief.md.draft" $?
git -C "$VROOT/vy-ignored" check-ignore -q ".claude/projects/2026-08-29-hidden/brief.md"
check "voyage: GROUND TRUTH - git really does ignore vy-ignored's brief (the index would see zero)" $?
vage="$(( ( $(date +%s) - $(stat -f %m "$VROOT/vy-newest/.claude/projects/2026-08-11-newest/brief.md") ) / 86400 ))"
[ "$vage" -ge 19 ] && [ "$vage" -le 21 ]
check "voyage: GROUND TRUTH - the stale artifact really is ~20 days old (touch -t worked)" $?
VOUT="$(CLAUDE_COORD_DIR="$VCOORD" "$BOARD" --roots "$VROOT" --voyage 2>/dev/null)"
VTAB="$(CLAUDE_COORD_DIR="$VCOORD" "$BOARD" --roots "$VROOT" 2>/dev/null)"
v_block() { printf '%s' "$VOUT" | awk -v p="prosjekt=$1" '$0==p{f=1;next} /^prosjekt=/{f=0} f'; }
# --- Detection reaches all three recognised locations -----------------------
printf '%s' "$VOUT" | grep -q '^prosjekt=vy-full/\.claude/projects/2026-08-30-full$'
check "voyage: detects a project under .claude/projects/" $?
printf '%s' "$VOUT" | grep -q '^prosjekt=vy-partial/docs/some-plan$'
check "voyage: detects a project under docs/" $?
printf '%s' "$VOUT" | grep -q '^prosjekt=vy-feature/features/01-thing$'
check "voyage: detects a project under features/<n>-<name>/" $?
printf '%s' "$VOUT" | grep -q '^prosjekt=vy-ignored/\.claude/projects/2026-08-29-hidden$'
check "voyage: a GITIGNORED project is still found (filesystem, never git ls-files)" $?
# Known-positive control for the negative: a repo with no brief must not be
# invented into the listing.
printf '%s' "$VOUT" | grep -q '^prosjekt=vy-none/' && rc=1 || rc=0
check "voyage: a repo with no brief produces no project block (control)" $rc
# --- Phase ladder -----------------------------------------------------------
v_block "vy-draft/.claude/projects/2026-08-01-draft" | grep -q '^fase=brief-draft$'
check "voyage: brief.md.draft with no brief.md reads fase=brief-draft" $?
v_block "vy-partial/docs/some-plan" | grep -q '^fase=brief$'
check "voyage: brief.md with no research and no plan reads fase=brief" $?
v_block "vy-full/.claude/projects/2026-08-30-full" | grep -q '^fase=plan$'
check "voyage: plan.md present reads fase=plan" $?
v_block "vy-review/.claude/projects/2026-07-01-rev" | grep -q '^fase=review$'
check "voyage: review.md present reads fase=review" $?
# --- brief_quality: read from the FRONTMATTER, absent is never `complete` ---
v_block "vy-full/.claude/projects/2026-08-30-full" | grep -q '^kvalitet=complete$'
check "voyage: brief_quality=complete is read from the frontmatter" $?
v_block "vy-partial/docs/some-plan" | grep -q '^kvalitet=partial$'
check "voyage: brief_quality=partial is distinguished from complete" $?
v_block "vy-feature/features/01-thing" | grep -q '^kvalitet=-$'
check "voyage: a brief with NO brief_quality field reads '-', never 'complete'" $?
# --- Blocking decisions: declarations, not mentions --------------------------
v_block "vy-full/.claude/projects/2026-08-30-full" | grep -q '^blokkerende=1$'
check "voyage: one declared BLOCKING DECISION plus three prose references counts 1" $?
v_block "vy-full/.claude/projects/2026-08-30-full" | grep -q '^blokkerende_gate=S4$'
check "voyage: the blocking decision's gate (before S4) is reported" $?
v_block "vy-prose/.claude/projects/2026-07-02-prose" | grep -q '^blokkerende=0$'
check "voyage: KNOWN-NEGATIVE - prose mentions alone count 0, not 2" $?
v_block "vy-full/.claude/projects/2026-08-30-full" | grep -q '^venter=operatoerbeslutning$'
check "voyage: a project with an open blocking decision waits on the OPERATOR" $?
v_block "vy-prose/.claude/projects/2026-07-02-prose" | grep -q '^venter=-$'
check "voyage: a project with no blocking decision waits on nobody (control)" $?
# --- Research denominator ---------------------------------------------------
v_block "vy-full/.claude/projects/2026-08-30-full" | grep -q '^research=3$'
check "voyage: a delivered research step reports its file count" $?
v_block "vy-feature/features/01-thing" | grep -q '^research=0$'
check "voyage: a research directory holding NOTHING reports 0, not absence" $?
v_block "vy-partial/docs/some-plan" | grep -q '^research=-$'
check "voyage: no research directory at all reports '-', distinct from 0" $?
# --- Order coupling (field 4) ------------------------------------------------
v_block "vy-full/.claude/projects/2026-08-30-full" | grep -q '^ordre_id=20260831T120000Z-1111$'
check "voyage: the repo's pending order id is carried beside the brief phase" $?
v_block "vy-partial/docs/some-plan" | grep -q '^ordre=0$'
check "voyage: a repo with no pending order reports ordre=0 (control)" $?
# --- Denominator: never a silent null ---------------------------------------
printf '%s' "$VOUT" | grep -qE '^# nevner: [0-9]+ repo skannet, [0-9]+ med minst ett Voyage-prosjekt, [0-9]+ prosjekter$'
check "voyage: the listing reports its own denominator" $?
VEMPTY="$(mktemp -d)"; mkrepo "$VEMPTY/plain"
VNONE="$("$BOARD" --roots "$VEMPTY" --voyage 2>/dev/null)"; rc=$?
[ "$rc" -eq 0 ]
check "voyage: a tree with no Voyage project at all still exits 0" $?
printf '%s' "$VNONE" | grep -q '^ingen=0 Voyage-prosjekter funnet'
check "voyage: no projects found SAYS so - it never renders as an empty page" $?
/bin/rm -rf "$VEMPTY" 2>/dev/null
# --- The legend is engine behaviour, exactly as FLY's liveness denial is -----
printf '%s' "$VOUT" | grep -qi 'artefakter, ikke sesjoner'
check "voyage: the legend states the phase measures ARTIFACTS, not sessions" $?
# --- The VOY table column ----------------------------------------------------
printf '%s' "$VTAB" | grep -q 'VOY'
check "voyage: the table carries a VOY column" $?
[ "$(printf '%s' "$VTAB" | awk '$1=="vy-none"{print $7}')" = "0" ]
check "voyage: a repo with no project prints a bare 0, never '0:0d'" $?
[ "$(printf '%s' "$VTAB" | awk '$1=="vy-multi"{print $7}')" = "2:15d" ]
check "voyage: VOY carries count and the STALEST project's age" $?
[ "$(printf '%s' "$VTAB" | awk '$1=="vy-newest"{print $7}')" = "1:0d" ]
check "voyage: the age is the NEWEST artifact in a project, not the oldest" $?
# --- Display only: VOY must not reorder the board ---------------------------
# vy-multi and vy-newest are both in-progress with identical STATE mtimes, so
# ordering stays alphabetical. Putting the age in the ranking would silently
# reorder a parser living in another repo (board.sh:87-116).
v_first="$(printf '%s' "$VTAB" | grep -E '^vy-(multi|newest) ' | head -1 | awk '{print $1}')"
[ "$v_first" = "vy-multi" ]
check "voyage: VOY is display-only - it does not reorder the board" $?
# --- board stays READ-ONLY ---------------------------------------------------
vsum_before="$(find "$VROOT" -type f | sort | wc -l)"
CLAUDE_COORD_DIR="$VCOORD" "$BOARD" --roots "$VROOT" --voyage >/dev/null 2>&1
CLAUDE_COORD_DIR="$VCOORD" "$BOARD" --roots "$VROOT" >/dev/null 2>&1
vsum_after="$(find "$VROOT" -type f | sort | wc -l)"
[ "$vsum_before" = "$vsum_after" ]
check "voyage: board wrote nothing to the scanned tree (read-only preserved)" $?
/bin/rm -rf "$VROOT" "$VCOORD" 2>/dev/null
echo ""
echo "board-selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1