feat(orders): order queue channel with atomic claim, board ORDRE column

ORDRE 59. A dispatched order used to live only in a scratch prompt file
passed through argv, so it died with the pane it was typed into. Measured
2026-08-17: one order was dispatched three times over 90 minutes before it
was worked, because the first two tabs ran something else and the order
left no trace in the receiving repo at all.

New channel `~/.claude/coord/<repo>/orders/`, beside `inbox/` and never
merged with it. The axis is authorization: inbox content is untrusted
cross-repo data that may never instruct a session (Rule 6), a dispatch
order is operator-authorized work by construction. One channel carrying
both classes would mean either mail that can instruct or orders that
cannot, so the infrastructure is reused and the channel is not.

Four one-verb engines: coord-order-send.sh (write), coord-order-inbox.sh
(read, writes nothing at all), coord-order-claim.sh (atomic claim),
coord-order-done.sh (executed with a commit pointer / --no-commit with a
reason / --return with a reason).

The claim is a rename with no check-then-act step, so of N racing sessions
exactly one finds the source and the rest get ENOENT. The test that proves
it spawns 20 claimers BARRIERED on a start flag - unbarriered children do
not race at all - and runs the identical harness against a deliberately
racy `[ -e src ] && cp && rm` as a known-negative control, which must
produce many winners. Without that control, "exactly one winner" is
indistinguishable from "the race never happened".

Channel separation is pinned structurally, not only behaviourally: no mail
script may contain the string `orders`, with a known-positive control
proving the grep can find. coord-done cannot archive an order and
coord-order-claim cannot claim a message.

board gains an ORDRE column beside INN, counted with the identical idiom
and never summed with it: INN is "others are waiting on YOU", ORDRE is
"work is waiting on this REPO". Claimed orders are excluded - the column
answers what a session can pick up. board.sh --dispatch --order-id emits a
thin starter carrying only the id and the four steps, so the order text has
exactly one home; the id is validated shell-clean and must be pending in
the target's queue.

SessionStart injects the queue as its own block below the mailbox block.
Two channels, two blocks, mail first: it carries Rule 7, and the queue
order is mail -> orders -> STATE's NESTE.

Also folds in dde392d (board prefix-match fix), which landed after the
0.26.0 bump and before any tag. v0.26.0 was never tagged, so 0.27.0 is the
release that carries all of it.

Suites: coord 220, board 237, route 69, orders 97, guard 40; npm test 11/11.
Antakelse 4 (atomic claim) and antakelse 6 (morning --plan-file --dry-run
reports 1 of 1 for the thin starter) both measured, not assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134iB7ipXGgEpv9imYoVmr2
This commit is contained in:
Kjell Tore Guttormsen 2026-08-17 21:17:17 +02:00
commit c519ab4994
18 changed files with 1410 additions and 80 deletions

View file

@ -188,7 +188,7 @@ PLAN=0
INBOX_PLAN=0
FOCUS=""
DISPATCH=0
D_REPO=""; D_PROMPT=""; D_PANE=""
D_REPO=""; D_PROMPT=""; D_PANE=""; D_ORDER=""
D_PATH=""; D_VER=""; D_REV=""; D_SCOPE=""; D_RAT=""
# Sibling calculator, invoked rather than reimplemented: the rubric that turns
@ -218,6 +218,8 @@ while [ $# -gt 0 ]; do
D_REPO="$2"; shift 2 ;;
--prompt-file) [ $# -ge 2 ] || { echo "board: --prompt-file requires a value" >&2; exit 2; }
D_PROMPT="$2"; shift 2 ;;
--order-id) [ $# -ge 2 ] || { echo "board: --order-id requires a value" >&2; exit 2; }
D_ORDER="$2"; shift 2 ;;
--target-pane) [ $# -ge 2 ] || { echo "board: --target-pane requires a value" >&2; exit 2; }
D_PANE="$2"; shift 2 ;;
--path) [ $# -ge 2 ] || { echo "board: --path requires a value" >&2; exit 2; }
@ -278,7 +280,7 @@ done
[ -n "$(printf '%s' "$REPOS" | tr -d '[:space:]')" ] || exit 0
# --- Collect one record per repo -------------------------------------------
# Record: bucket|sortkey|name|status|cost|inbox|dirty|age|neste
# Record: bucket|sortkey|name|status|cost|inbox|dirty|age|last|dir|blockedon|orders|neste
RECORDS=""
MALFORMED=""
@ -296,6 +298,23 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
[ -n "$inbox" ] || inbox=0
fi
# PENDING orders only, counted with the identical idiom as INN so the two
# columns are computed the same way and can be compared at a glance. They are
# never summed and never merged: INN is "others are waiting on YOU" (an
# outgoing obligation), ORDRE is "work is waiting on this REPO" (incoming
# authorized work). Claimed orders are deliberately out - the column answers
# "what can a session pick up here", and one already in flight cannot be.
#
# Bounded gap, stated rather than closed: an order addressed to a mailbox
# with no matching directory in the scanned roots is invisible here, exactly
# as mail to such a name is invisible in INN. coord-count.sh is the cross-
# check for the mail half; there is deliberately no join built for orders.
orders=0
if [ -d "$COORD/$name/orders" ]; then
orders="$(ls "$COORD/$name/orders"/*.md 2>/dev/null | wc -l | tr -d ' ')"
[ -n "$orders" ] || orders=0
fi
# Read for EVERY repo, not just the STATE-less ones: a repo whose plan file
# is fresh can still have been silent for a year, and that is precisely the
# repo no other column reports. A repo with no commits at all has no reading
@ -311,8 +330,8 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
# No plan file, so no plan age: ALDER is blank rather than quietly showing
# the commit age under a heading that means something else everywhere else
# in the table. The sort key keeps using it - order is unchanged.
printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|-|(ingen STATE.md)\n' \
"$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d"
printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|-|%s|(ingen STATE.md)\n' \
"$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d" "$orders"
continue
fi
@ -398,9 +417,12 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
# name a target the scan never produced). Inserted BEFORE `neste`, never after:
# `neste` is free prose lifted out of a STATE.md and may contain a literal '|'
# from a markdown table, so it has to stay the last field on the line.
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s|%s\n' \
# `orders` sits between blockedon and neste, never after it: `neste` is free
# prose lifted out of a STATE.md and may contain a literal '|', so it has to
# stay the last field on the line.
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s|%s|%s\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" \
"${blockedon:--}" "$neste"
"${blockedon:--}" "$orders" "$neste"
done > "${TMPDIR:-/tmp}/board.$$"
RECORDS="${TMPDIR:-/tmp}/board.$$"
@ -507,15 +529,15 @@ awk -F'|' '$4 ~ /^blocked/ && $11 != "-" && $11 != "" {print $11}' "$RECORDS" \
hdr() {
printf '\n%s\n' "$1"
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
"REPO" "STATUS" "KOST" "INN" "DRT" "ALDER" "SISTE" "NESTE"
printf '%-32s %-34s %-14s %4s %6s %4s %6s %6s %s\n' \
"REPO" "STATUS" "KOST" "INN" "ORDRE" "DRT" "ALDER" "SISTE" "NESTE"
}
rows() {
awk -F'|' -v b="$1" '$1==b' "$RECORDS" | sort -t'|' -k2,2n | \
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir blockedon neste; do
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$last" "$(trunc "$neste" "$NESTE_WIDTH")"
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir blockedon orders neste; do
printf '%-32s %-34s %-14s %4s %6s %4s %6s %6s %s\n' \
"$name" "$status" "$cost" "$inbox" "$orders" "$dirty" "$age" "$last" "$(trunc "$neste" "$NESTE_WIDTH")"
done
}
@ -687,7 +709,7 @@ brief() {
FILENAME==OWF { ow[$1] = $2 + 0; next }
{ name = $3; owed = (name in ow) ? ow[name] : 0; if (owed > 0) print owed, $0 }
' "$OWED" "$RECORDS" | sort -t'|' -k1,1nr | \
while IFS='|' read -r owed bucket sortkey name status cost inbox dirty age last dir blockedon neste; do
while IFS='|' read -r owed bucket sortkey name status cost inbox dirty age last dir blockedon orders neste; do
printf ' %-32s INN %-4s %s\n' "$name" "$owed" "$status"
# Wrapped, not cut - the whole line is the point, but a 500-character one
# is unreadable in a file nobody watched being written. Locale is set for
@ -749,7 +771,18 @@ dispatch() {
*) echo "board: --target-pane must be yes or no (got: $D_PANE)" >&2; exit 2 ;;
esac
[ -n "$D_REPO" ] || { echo "board: --dispatch requires --repo <name>" >&2; exit 2; }
[ -n "$D_PROMPT" ] || { echo "board: --dispatch requires --prompt-file <path> - the order the session is dispatched ON" >&2; exit 2; }
# Two forms, exactly one of them. --order-id is the THIN STARTER: the order
# text lives in the recipient's queue (its canonical home, written by
# coord-order-send.sh), and the pasted line only points at it. --prompt-file
# is the older scratch form, kept because it still works when there is no
# queue to write to. Both at once has no meaning - the session would be told
# two things - so it is refused rather than silently preferring one.
if [ -n "$D_ORDER" ] && [ -n "$D_PROMPT" ]; then
echo "board: use either --order-id (the order lives in the queue) or --prompt-file (scratch), not both" >&2; exit 2
fi
if [ -z "$D_ORDER" ] && [ -z "$D_PROMPT" ]; then
echo "board: --dispatch requires --order-id <id> or --prompt-file <path> - the order the session is dispatched ON" >&2; exit 2
fi
[ -n "$D_RAT" ] || { echo "board: --dispatch requires --rationale (route.sh requires it, and for the same reason: a score with no stated why cannot be audited)" >&2; exit 2; }
# The prompt PATH is what has to be shell-clean, not the prompt CONTENT.
@ -761,24 +794,42 @@ dispatch() {
# Absolute, because a relative path resolves against whatever directory the
# pane happens to be in - which for the paste-only form is a repo this
# script never sees.
case "$D_PROMPT" in
/*) ;;
*) echo "board: --prompt-file must be an absolute path (a relative one resolves against the pane's directory, not this one): $D_PROMPT" >&2; exit 2 ;;
esac
case "$D_PROMPT" in
*[!A-Za-z0-9/._-]*) echo "board: --prompt-file path is not shell-clean; it is expanded inside the command: $D_PROMPT" >&2; exit 2 ;;
esac
[ -f "$D_PROMPT" ] || { echo "board: no prompt file at $D_PROMPT" >&2; exit 2; }
# test -s, not test -e: an empty prompt file starts a session and then tells
# it nothing, which from the far end is indistinguishable from a session
# waiting for a Go that will never come.
[ -s "$D_PROMPT" ] || { echo "board: the prompt file is empty: $D_PROMPT (a dispatched session would be started and told nothing)" >&2; exit 2; }
if [ -n "$D_PROMPT" ]; then
case "$D_PROMPT" in
/*) ;;
*) echo "board: --prompt-file must be an absolute path (a relative one resolves against the pane's directory, not this one): $D_PROMPT" >&2; exit 2 ;;
esac
case "$D_PROMPT" in
*[!A-Za-z0-9/._-]*) echo "board: --prompt-file path is not shell-clean; it is expanded inside the command: $D_PROMPT" >&2; exit 2 ;;
esac
[ -f "$D_PROMPT" ] || { echo "board: no prompt file at $D_PROMPT" >&2; exit 2; }
# test -s, not test -e: an empty prompt file starts a session and then tells
# it nothing, which from the far end is indistinguishable from a session
# waiting for a Go that will never come.
[ -s "$D_PROMPT" ] || { echo "board: the prompt file is empty: $D_PROMPT (a dispatched session would be started and told nothing)" >&2; exit 2; }
else
# The order id is the new injection surface. The prompt-file form's path
# rule exists because the path is expanded inside the emitted command while
# the file's CONTENTS are not; the starter form drops the path and
# interpolates the ID into command=/paste= instead, so the same rule applies
# to it for the same reason.
case "$D_ORDER" in
*[!A-Za-z0-9._-]*) echo "board: --order-id is not shell-clean; it is expanded inside the command: $D_ORDER" >&2; exit 2 ;;
esac
# And it must actually BE in the queue. Emitting a command that tells a
# session to claim an order that is not there is the same defect class as
# the empty prompt file: a dispatch that verifies green and hands the far
# end nothing to work on. Pending only - an order already claimed is
# someone else's, and a starter for it would send two sessions at one task.
[ -f "${CLAUDE_COORD_DIR:-$HOME/.claude/coord}/$D_REPO/orders/$D_ORDER.md" ] || {
echo "board: no PENDING order $D_ORDER in $D_REPO's queue - write it first (coord-order-send.sh --to $D_REPO ...), and check it has not already been claimed" >&2; exit 2; }
fi
d_rec="$(awk -F'|' -v n="$D_REPO" '$3==n {print; exit}' "$RECORDS")"
[ -n "$d_rec" ] || { echo "board: no repo named '$D_REPO' in the scanned roots ($ROOTS)" >&2; exit 2; }
d_status="$(printf '%s' "$d_rec" | cut -d'|' -f4)"
d_dir="$(printf '%s' "$d_rec" | cut -d'|' -f10)"
d_neste="$(printf '%s' "$d_rec" | cut -d'|' -f12-)"
d_neste="$(printf '%s' "$d_rec" | cut -d'|' -f13-)"
[ -d "$d_dir" ] || { echo "board: the directory for $D_REPO does not exist: $d_dir" >&2; exit 2; }
# Rejected traits REFUSE. Degrading to a command without them is the worst
@ -799,7 +850,21 @@ dispatch() {
# STATE.md instead of being handed it - delivered bare twice on 2026-08-16
# before the operator corrected it ("gi meg alltid komplette
# oppstartskommandoer for nye sesjoner").
d_full="$d_cmd \"\$(cat $D_PROMPT)\""
if [ -n "$D_PROMPT" ]; then
d_full="$d_cmd \"\$(cat $D_PROMPT)\""
else
# The THIN STARTER. It carries no order text at all, deliberately: the order
# has exactly one home (the queue), so a starter that repeated it would be a
# second copy free to drift from the first - and the copy in argv is the one
# that dies with the pane. What it does carry is the four steps, because a
# session handed only an id would have to guess the protocol.
#
# ASCII and no shell metacharacters: this string is emitted INSIDE the
# double quotes of the pasted command, so a quote, a $ or a backtick here
# would be evaluated by the operator's shell. The order BODY has no such
# restriction - it never passes through a shell at all.
d_full="$d_cmd \"ORDRE $D_ORDER ligger i dette repoets ordrekoe. Gjor dette i rekkefolge: (1) claim den - coord-order-claim $D_ORDER - claim skriver ut hele ordreteksten, som bare bor der. (2) Sammenlign ordren mot STATE.md sin NESTE-blokk og uttal avviket i ditt FORSTE svar. (3) Utfor ordren. (4) Lukk den - coord-order-done $D_ORDER --commit HASH - eller gi den tilbake med --return --reason hvis den ikke kan utfores.\""
fi
if [ "$D_PANE" = "no" ]; then
printf '# DISPATCH %s - plan-file form (target has no pane)\n' "$D_REPO"
@ -1186,7 +1251,7 @@ plan() {
echo ""
pn=0
while IFS='|' read -r why bucket sortkey name status cost inbox dirty age last dir blockedon neste; do
while IFS='|' read -r why bucket sortkey name status cost inbox dirty age last dir blockedon orders neste; do
[ -n "$name" ] || continue
pn=$((pn + 1))
printf 'tab=%s\n' "$pn"
@ -1240,7 +1305,7 @@ inbox_plan() {
FILENAME==RCF {
name = $3
rc_bucket[name] = $1; rc_status[name] = $4; rc_cost[name] = $5
rc_dir[name] = $10; rc_neste[name] = $12
rc_dir[name] = $10; rc_neste[name] = $13
next
}
{
@ -1311,6 +1376,10 @@ count() { awk -F'|' -v b="$1" '$1==b' "$RECORDS" | wc -l | tr -d ' '; }
echo "BOARD - tverr-repo oppmerksomhetstavle ($(awk 'END{print NR}' "$RECORDS") repo)"
echo "INN = uhaandtert innboks (andre venter paa DEG). DRT = ukommiterte filer."
# The two mailbox columns are adjacent and easy to read as one quantity, so the
# legend states the distinction where they are read - they measure opposite
# directions and are never summed.
echo "ORDRE = ventende ordrer (arbeid venter paa REPOET). INN og ORDRE summeres ALDRI."
echo "ALDER = dager siden STATE.md endret. SISTE = dager siden siste commit."
[ "$(count 1)" -gt 0 ] && { hdr "BLOKKERT (venter paa ekstern avhengighet)"; rows 1; }