feat(board): --dispatch, the startup command a dispatched session can act on

"Start a session in repo X, on order Y, at cost Z" was produced by hand, and
it misfired four times on 2026-08-16 across two repos. Three distinct holes,
all measured, all closed here:

1. A bare `claude --model X --effort Y` forces the operator to type Go, and
   the session then guesses its task out of STATE.md. The emitted command
   carries the prompt in argv: `... "$(cat <file>)"`. Verified directly that
   this passes the file's bytes as ONE argv element with no re-evaluation, so
   $(...), backticks, quotes and UTF-8 in the prompt BODY are inert - only the
   PATH is expanded, so it must be absolute and shell-clean.
2. --no-go stops only the follow-up Go message, never the work (morning:806).
   The plan-file form says so in its own output, not just in a comment.
3. A session dispatching its own next session gets an empty plan: morning's
   plan_drop_open (morning:1788) drops a block whose repo already has a pane,
   and --dry-run says "0 of 1", which reads as a broken plan file. --dispatch
   therefore emits two forms, chosen by --target-pane: a plan block, or a
   bare paste line for the tab that already exists (and no tab= key at all,
   so it can never be fed to morning as a plan).

Generator ownership, the question left open for two sessions: it goes in
board.sh, which already owns the block format including paste=. A second
emitter of tab=/repo=/dir=/command=/paste= would be two copies of one file
format. Read-only survives - the prompt file and the plan file are written by
the caller, the brief-nightly.sh split unchanged.

--target-pane yes|no is REQUIRED with no default, the same rule --last-effort
carries: it is a measurement (morning --probe-panes, which works without a
tty), and the dry-run cannot substitute for it - run from a Claude session
morning reports "window: unknown ... assuming an empty window" and
plan_drop_open never fires, so a dry-run gate would pass the self-dispatch
case every time.

Cost comes from route.sh's row table; --dispatch deliberately takes no
--model/--effort, because --advisor opus is a property of the ROW and a
dispatch taking the model directly has no honest source for that flag.

New skills/dispatch/SKILL.md is the front door. board-selftest 183 -> 217.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ett8uHMDLir6trFaMzrYRu
This commit is contained in:
Kjell Tore Guttormsen 2026-08-16 16:05:53 +02:00
commit 1ee003328c
5 changed files with 580 additions and 10 deletions

View file

@ -1587,6 +1587,151 @@ check "missing sibling: --inbox-plan carries a machine-readable warning key, not
/bin/rm -rf "$SIB_ROOT" "$SIB_COORD" "$SIB_SCRATCH" 2>/dev/null
# --- 18. Dispatch rendering (--dispatch) ------------------------------------
# The FOURTH rendering of the same scan, and the one that carries a task INTO
# another repo. It exists here rather than in a new script for one reason: the
# block format has exactly one generator, and board.sh already owns it
# (tab=/repo=/dir=/command=/paste=, consumed by morning's plan_parse). A second
# emitter of the same file format is the drift defect this repo's CLAUDE.md
# warns about, and it would be a second place to get `paste=` wrong.
#
# Read-only holds: every check here is a read (test -s on the prompt file, the
# path grammar, the scan), and the FILE WRITES - the prompt file and the plan
# file - stay with the caller. That is the brief-nightly.sh split, unchanged.
DSP="$ROOT/dispatch.prompt"
printf 'ORDRE X - do the thing.\nWith $(whoami) and `id` and "quotes" in the body.\n' > "$DSP"
DSPEMPTY="$ROOT/empty.prompt"
: > "$DSPEMPTY"
DSPTRAITS='--path known --verification strong --reversibility cheap --scope local --rationale t'
# --target-pane has NO DEFAULT, and that is the same rule route.sh's
# --last-effort carries: the answer is a MEASUREMENT of the world (does the
# target repo already have a Ghostty pane), and a calculator that guesses it
# produces a dispatch that verifies green and opens nothing. Defaulting to
# "no" would be worst: that is the plan-file form, which morning's
# plan_drop_open (morning:1788) silently drops for a repo that already has a
# pane - measured four times in one day, 2026-08-16, and read every time as a
# broken plan file rather than as the filter doing its job.
d1="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSP" $DSPTRAITS 2>&1)"; rc=$?
[ "$rc" -eq 2 ]; check "dispatch: refuses without --target-pane (no default, it is a measurement)" $?
printf '%s' "$d1" | grep -q 'command='; [ $? -ne 0 ]; check "dispatch: emits no command when it refuses for a missing --target-pane" $?
d2="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSP" --target-pane maybe $DSPTRAITS 2>&1)"; rc=$?
[ "$rc" -eq 2 ]; check "dispatch: refuses a --target-pane value outside yes|no" $?
# The prompt file is the whole payload: an empty one produces a session that
# is started and then told nothing, which is indistinguishable from a session
# waiting for Go - and the operator has to diagnose it from the far end.
d3="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$ROOT/nope.prompt" --target-pane no $DSPTRAITS 2>&1)"; rc=$?
[ "$rc" -eq 2 ]; check "dispatch: refuses a prompt file that does not exist" $?
d4="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSPEMPTY" --target-pane no $DSPTRAITS 2>&1)"; rc=$?
[ "$rc" -eq 2 ]; check "dispatch: refuses an EMPTY prompt file (test -s, not test -e)" $?
# The PATH is what has to be shell-clean, not the prompt CONTENT. Verified
# directly (2026-08-16): `"$(cat f)"` passes the file's bytes as ONE argv
# element with no re-evaluation, so $(...), backticks, quotes and UTF-8 in the
# BODY are inert - that is precisely why the dispatch form puts the prompt in
# argv instead of inlining it. The path, though, sits inside those quotes and
# IS evaluated, so a space or a quote in it splits the command.
mkdir -p "$ROOT/dir with space"
cp "$DSP" "$ROOT/dir with space/p.prompt"
d5="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$ROOT/dir with space/p.prompt" --target-pane no $DSPTRAITS 2>&1)"; rc=$?
[ "$rc" -eq 2 ]; check "dispatch: refuses a prompt PATH that is not shell-clean" $?
# A relative path resolves against whatever directory the pane is in, which
# for the paste-only form is a repo this script never sees - so the command
# would read a different file, or none, with no error anywhere near the
# dispatch.
d5b="$(cd "$ROOT" && "$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file dispatch.prompt --target-pane no $DSPTRAITS 2>&1)"; rc=$?
[ "$rc" -eq 2 ]; check "dispatch: refuses a RELATIVE prompt path (it resolves in the pane, not here)" $?
d6="$("$BOARD" --roots "$ROOT" --dispatch --repo no-such-repo --prompt-file "$DSP" --target-pane no $DSPTRAITS 2>&1)"; rc=$?
[ "$rc" -eq 2 ]; check "dispatch: refuses a repo the scan does not know" $?
# Invalid traits must refuse, never degrade to a command without them: a
# command missing --advisor reads exactly like a row that legitimately has no
# advisor.
d7="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSP" --target-pane no \
--path bogus --verification strong --reversibility cheap --scope local --rationale t 2>&1)"; rc=$?
[ "$rc" -eq 2 ]; check "dispatch: refuses trait values route.sh rejects, rather than emitting a partial command" $?
# --- The plan-file form (target has NO pane) ---
d8="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSP" --target-pane no $DSPTRAITS 2>/dev/null)"; rc=$?
[ "$rc" -eq 0 ]; check "dispatch: the plan-file form exits 0" $?
printf '%s\n' "$d8" | grep -q '^form=plan-file'; check "dispatch: names its output form machine-readably" $?
# The four keys morning's plan_parse requires (lib/plan.sh:30-46). Named here
# rather than by sourcing that library: it lives in another repo, and this
# selftest pins OUR side of the contract.
printf '%s\n' "$d8" | grep -q '^tab=1$'; check "dispatch: plan block carries tab= (plan_parse discards a block without it)" $?
printf '%s\n' "$d8" | grep -q '^repo=repo-a$'; check "dispatch: plan block carries repo=" $?
printf '%s\n' "$d8" | grep -q "^dir=$ROOT/repo-a\$"; check "dispatch: plan block carries dir=" $?
printf '%s\n' "$d8" | grep -q '^paste=cd '; check "dispatch: plan block carries a paste= that cds first" $?
# The prompt reaches the session through argv. A bare model+effort command
# forces the operator to type Go, and the session must then GUESS the task out
# of STATE instead of being handed it - measured twice on 2026-08-16.
printf '%s\n' "$d8" | grep -q "command=.*\"\$(cat $DSP)\""; check "dispatch: the command carries the prompt in argv, never bare model+effort" $?
printf '%s\n' "$d8" | grep -q "^paste=cd $ROOT/repo-a && claude .*\"\$(cat $DSP)\""; check "dispatch: paste= is the complete one-string form, prompt included" $?
# The cost comes from route.sh's row table and nowhere else, so --advisor is a
# property of the ROW. These two differ ONLY in reversibility: both land on
# row 3 (Opus 5/high), and only the costly one carries the advisor. A dispatch
# that took --model/--effort directly would have no source for that flag at
# all, and forwarding it blindly is how a session ends up looking peer-reviewed
# without being.
d9="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSP" --target-pane no \
--path known --verification strong --reversibility cheap --scope multi-file --rationale t 2>/dev/null)"
printf '%s\n' "$d9" | grep -q '^command=claude --model opus --effort high "'; check "dispatch: model+effort come from the route row (row 3, no advisor at cheap)" $?
d10="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSP" --target-pane no \
--path known --verification strong --reversibility costly --scope local --rationale t 2>/dev/null)"
printf '%s\n' "$d10" | grep -q '^command=claude --model opus --effort high --advisor opus "'; check "dispatch: the advisor flag follows the ROW (costly -> row 3 with advisor)" $?
printf '%s\n' "$d8" | grep -q '^next-cost=Sonnet 5/high$'; check "dispatch: the row's next-cost is reported alongside the command" $?
# --no-go stops the Go MESSAGE, not the work: the startup command - prompt in
# argv and all - is typed regardless (morning:806). An operator decided on the
# opposite premise once already, and it had to be corrected before the run.
printf '%s\n' "$d8" | grep -qi 'no-go'; check "dispatch: the plan-file form states what --no-go does and does not stop" $?
# The dry-run is NOT the pane gate, and the output must not imply it is.
# Measured 2026-08-16 against the real morning: run without a tty (which is
# every Claude session) it prints "window: unknown ... assuming an empty
# window" and plan_drop_open never fires, so a dry-run gate would pass the
# self-dispatch case every time - the one case it would exist to catch.
printf '%s\n' "$d8" | grep -q 'probe-panes'; check "dispatch: points at the measurement that CAN answer the pane question" $?
# --- The paste-only form (target ALREADY has a pane) ---
d11="$("$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSP" --target-pane yes $DSPTRAITS 2>/dev/null)"; rc=$?
[ "$rc" -eq 0 ]; check "dispatch: the paste-only form exits 0" $?
printf '%s\n' "$d11" | grep -q '^form=paste-only'; check "dispatch: names the paste-only form machine-readably" $?
# No tab= is the load-bearing half: a block with tab= is a plan block, and a
# plan block for a repo that already has a pane is the one morning drops in
# silence. Withholding the key makes the wrong use impossible rather than
# merely discouraged.
printf '%s\n' "$d11" | grep -q '^tab='; [ $? -ne 0 ]; check "dispatch: the paste-only form emits NO tab= (it must never be fed to morning as a plan)" $?
printf '%s\n' "$d11" | grep -q '^paste=claude '; check "dispatch: paste-only starts the session directly" $?
# One repo per terminal tab: the operator is already standing in that repo, so
# a cd is at best noise and at worst a session started in the wrong directory.
printf '%s\n' "$d11" | grep -q '^paste=cd '; [ $? -ne 0 ]; check "dispatch: paste-only carries NO cd (the operator is already in that tab)" $?
printf '%s\n' "$d11" | grep -q "^paste=claude .*\"\$(cat $DSP)\""; check "dispatch: paste-only also carries the prompt in argv" $?
printf '%s\n' "$d11" | grep -qi 'exit'; check "dispatch: paste-only says the existing session must be exited first" $?
printf '%s\n' "$d11" | grep -q 'plan_drop_open'; check "dispatch: paste-only names the filter that made this form necessary" $?
/bin/rm -rf "$ROOT/dir with space" 2>/dev/null
# --- 19. The dispatch skill's own engine line ------------------------------
# Same defect class coord-selftest section 28 pins for coord-send/SKILL.md: a
# Bash tool call never has CLAUDE_PLUGIN_ROOT set, so a `:-` fallback wins
# every time the line is actually executed and routes through some other copy.
# Fail loud (empty path) rather than silently running the wrong script.
DSKILL="$DIR/../skills/dispatch/SKILL.md"
[ -f "$DSKILL" ]; check "dispatch SKILL.md is where the selftest expects it" $?
grep -q 'BOARD="\${CLAUDE_PLUGIN_ROOT}/scripts/board.sh"' "$DSKILL"
check "dispatch SKILL.md's engine line has no fallback path" $?
if grep -q 'BOARD=.*CLAUDE_PLUGIN_ROOT:-' "$DSKILL"; then rc=1; else rc=0; fi
check "dispatch SKILL.md's BOARD assignment carries no fallback" "$rc"
# The pane question has exactly one measurement, and the skill must send the
# caller to it rather than to the dry-run, which cannot answer it from a
# session (no tty -> morning assumes an empty window).
grep -q 'probe-panes' "$DSKILL"
check "dispatch SKILL.md names --probe-panes as the pane measurement" $?
echo ""
echo "board-selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1