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

@ -173,7 +173,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` (183 checks).
STATE.md and no mailbox. Pinned by `board-selftest.sh` (217 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
@ -331,6 +331,59 @@ marketplace plugin. Three components, one boundary:
would run the cd and then a bare newline, which fails SILENTLY by leaving the
operator in the right directory with no session started.
**`board.sh --dispatch` is the FOURTH rendering, and the generator-ownership
question it settles was open for two sessions.** It carries a task INTO
another repo — "start a session in repo X, on order Y, at cost Z" — and emits
either a plan block or a single paste line. It lives in `board.sh` rather
than in a script of its own for one reason, and it is the reason the operator
and `.claude` both named first: the block format has exactly ONE generator,
and this file already is it. A second emitter of
`tab=`/`repo=`/`dir=`/`command=`/`paste=` would be two copies of one file
format, drifting apart, with a second place to get `paste=` wrong. Read-only
survives untouched: every check is a read, and the two writes a dispatch needs
(prompt file, plan file) stay with the caller — the same split
`brief-nightly.sh` already carries for the briefing.
**The cost comes from `route.sh`'s row table, and `--dispatch` deliberately
refuses a `--model`/`--effort` pair.** `--advisor opus` is a property of the
ROW; two rows share a model/effort pair while differing on it, and the CLI
accepts a wrong advisor silently. A dispatch taking the model directly would
have no honest source for that flag, and both available guesses produce the
same failure — a session that looks peer-reviewed without being. A Fable
dispatch is therefore not a `--dispatch` outcome at all, exactly as it is not
a `route.sh` outcome; it is a hand-written override.
**`--target-pane yes|no` is REQUIRED, with no default, and that is the same
rule `--last-effort` carries.** It is a measurement of the world — does the
target repo already have a Ghostty pane — and this repo must never learn to
look for a terminal itself; the caller measures with `morning --probe-panes`
and passes the fact in. Defaulting would be worst at `no`: that is the
plan-file form, and `morning`'s `plan_drop_open` (morning:1788) silently drops
a plan block for a repo that already has a pane, reporting "0 of 1" — which
reads as a broken plan file. Measured four times on 2026-08-16 by two
different repos. The `yes` form therefore emits **no `tab=` key at all**:
`plan_parse` discards a block without one, so the wrong use is impossible
rather than merely discouraged.
**The dry-run is a parse check, not the pane gate, and the difference was
measured (2026-08-16).** `morning --plan-file <f> --dry-run` proves the block
parses and yields a command. Run from a Claude session there is no tty, so
`morning` prints "window: unknown ... assuming an empty window" and
`plan_drop_open` never fires — a gate built on it would pass the self-dispatch
case every single time, which is the one case it would exist to catch.
`--probe-panes`, by contrast, DOES work without a tty: it cannot identify the
anchor pane, but the `DIR` column is there.
**The prompt goes in argv, and only the PATH has to be shell-clean.** Verified
directly: `"$(cat f)"` hands the file's bytes to the session as one argv
element with no re-evaluation, so `$(...)`, backticks, quotes and UTF-8 in the
BODY are inert — which is precisely why the prompt is passed this way instead
of inlined. The path sits inside those quotes and IS evaluated, so it must be
absolute (a relative one resolves against the pane's directory, not the
emitter's) and drawn from a safe character class. An empty prompt file is
refused with `test -s`: it would start a session and tell it nothing, which
from the far end is indistinguishable from one waiting for a Go.
**Driving a terminal from the plan does NOT belong here, and the measurement
in `docs/ghostty-orchestration-measurement.md` is the argument, not taste.**
It is a version-pinned undocumented composition over a preview API whose
@ -432,7 +485,7 @@ marketplace plugin. Three components, one boundary:
startup command. Corollary pinned by section 13: `skills/route/SKILL.md` must
never declare an `effort:` frontmatter field, because frontmatter overrides the
session effort and the reading would then measure the skill, not the session.
- **Skills (`skills/coord-send/`, `skills/board/`, `skills/route/`):** natural-language front
- **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 —
since `board.sh` deliberately prints evidence and takes no position. `route`
@ -480,7 +533,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 (220/220),
`bash scripts/board-selftest.sh` must exit 0 (183/183),
`bash scripts/board-selftest.sh` must exit 0 (217/217),
`bash scripts/route-selftest.sh` must exit 0 (69/69) and
`bash scripts/state-line-guard-selftest.sh` must exit 0 (23/23).
- English for all code, docs, and commit messages (public repo). Norwegian
@ -501,6 +554,11 @@ obligations in another repo.
(default `~/.claude/briefing.md`), so point that at a scratch path when
testing. Installed as a launchd agent from `launchd/`, which points at the
SOURCE repo, never the version-pinned plugin cache.
- Dispatch smoke test: `bash scripts/board.sh --dispatch --repo repo-mailbox
--prompt-file /tmp/x.prompt --target-pane yes --path known --verification
strong --reversibility cheap --scope local --rationale smoke` (read-only;
needs a non-empty `/tmp/x.prompt`. Use `--target-pane yes` in a smoke test:
it produces no plan file, so nothing can be handed to `morning` by accident)
- Route smoke test: `bash scripts/route.sh --path known --verification strong
--reversibility cheap --scope local --rationale x` (writes nothing, instant)
- Sweep smoke test: `bash scripts/coord-sweep.sh` (dry-run is the default, so
@ -510,8 +568,8 @@ obligations in another repo.
## Release
Version must agree across: `.claude-plugin/plugin.json`, `package.json`,
README version badge, `skills/coord-send/SKILL.md`, `skills/board/SKILL.md` and
`skills/route/SKILL.md` frontmatter, git tag `vX.Y.Z`, and the catalog `ref` in
README version badge, `skills/coord-send/SKILL.md`, `skills/board/SKILL.md`,
`skills/route/SKILL.md` and `skills/dispatch/SKILL.md` frontmatter, git tag `vX.Y.Z`, and the catalog `ref` in
`ktg-plugin-marketplace/catalog/.claude-plugin/marketplace.json`. Release via
the catalog's `scripts/release-plugin.mjs repo-mailbox` (tag + ref bump
together);

View file

@ -12,7 +12,7 @@ Session A in repo X leaves a message for repo Y; the next session in repo Y gets
![Version](https://img.shields.io/badge/version-0.24.0-blue)
![Hooks](https://img.shields.io/badge/hooks-1-green)
![Skills](https://img.shields.io/badge/skills-3-orange)
![Skills](https://img.shields.io/badge/skills-4-orange)
![CLI scripts](https://img.shields.io/badge/CLI_scripts-8-blue)
![Selftest checks](https://img.shields.io/badge/selftest_checks-433-blue)
@ -94,6 +94,8 @@ It lives here because it is the **writer** for the cost field the board already
Scoring is judgement and belongs to the skill; turning scores into a row is a lookup and costs no model calls. One deliberate side effect is worth more than the tokens saved: a next step that cannot be scored `known` or `partial`, with no design phase planned, is an **underspecified task description** — the answer is to rewrite the step, not to upgrade the model.
**Handing a task to another session (the `dispatch` skill).** "Start a session in repo X on this order." `board.sh --dispatch` turns that into one line the operator can paste: the order written to a prompt file, the model and effort looked up from the same row table `route.sh` uses, and the prompt passed **in argv**`claude --model … --effort … "$(cat <file>)"` — so the session is handed its task instead of having to guess it out of STATE.md. It emits one of two forms, and which one is a measurement rather than a preference: a repo with no terminal pane gets a plan block (`morning --plan-file <f> --no-go`), while a repo that already has one gets a bare paste line for that tab, because a plan block for an already-open repo is silently dropped by the driver and reads as a broken plan file. `--target-pane yes|no` is therefore required with no default, exactly as `route.sh` refuses to default `--last-effort`: it is a fact about the world, and this plugin never looks for a terminal itself. Read-only holds — the prompt file and the plan file are written by the caller, never by `board.sh`.
**CLI.** The engine is eight user-facing bash scripts in the plugin's `scripts/` directory (plus four selftests); resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
coord-send.sh --to <repo> --subject "<subject>" [--message "<text>"] # or body on stdin
@ -106,6 +108,8 @@ Scoring is judgement and belongs to the skill; turning scores into a row is a lo
coord-count.sh [--exclude <mailbox>] # per mailbox: pending + replies owed, delivering nothing
coord-sweep.sh [--write] [--days <n>] [--log <path>] # close aged notices machine-wide (dry-run by default)
board.sh [--roots <dir>[,<dir>...]] [--brief|--plan] [--focus "<prose>"] # cross-repo attention board (read-only)
board.sh --dispatch --repo <name> --prompt-file <abs path> \
--target-pane <yes|no> --path <v> ... --rationale "<why>" # startup command for a session in <name>
brief-nightly.sh # render the briefing to a file, atomically
route.sh --path <v> --verification <v> --reversibility <v> \
--scope <v> --rationale "<why>" # model + effort for the next session
@ -163,10 +167,10 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
## Development
bash scripts/coord-selftest.sh # 197 checks against a throwaway mailbox
bash scripts/board-selftest.sh # 178 checks against a throwaway repo tree
bash scripts/coord-selftest.sh # 220 checks against a throwaway mailbox
bash scripts/board-selftest.sh # 217 checks against a throwaway repo tree
bash scripts/route-selftest.sh # 69 checks, incl. the route->board round trip
bash scripts/state-line-guard-selftest.sh # 21 checks, incl. the Edit replace_all projection and the ratchet
bash scripts/state-line-guard-selftest.sh # 23 checks, incl. the Edit replace_all projection and the ratchet
npm test # all four selftests via node --test
TDD is the house rule: every behavior change lands with a failing selftest check first.

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

View file

@ -151,7 +151,28 @@
# debt field, not re-derived) so a caller can tell a request from a notice
# without recomputing it - the second explicit ask.
#
# Usage: board.sh [--roots <dir>[,<dir>...]] [--plain] [--brief|--plan|--inbox-plan]
# --dispatch is a FIFTH rendering, and the only one that carries a task INTO a
# repo rather than reporting on it: "start a session in repo X, on order Y, at
# cost Z". It is here, and not in a script of its own, because the block format
# has exactly ONE generator and this file already is it - a second emitter of
# tab=/repo=/dir=/command=/paste= would be two copies of one format drifting
# apart. Read-only is untouched: every check is a read, and the two writes a
# dispatch needs (the prompt file, the plan file) stay with the caller, exactly
# as brief-nightly.sh owns the briefing's write.
#
# board.sh --dispatch --repo <name> --prompt-file <abs path>
# --target-pane <yes|no>
# --path P --verification V --reversibility R --scope S
# --rationale "why"
#
# Model, effort and --advisor come from route.sh's row table, never from a
# --model/--effort pair: the advisor flag is a property of the ROW, and a
# dispatch that took the model directly would have no honest source for it.
# --target-pane is a MEASUREMENT (morning --probe-panes) and has no default -
# see the dispatch() comment for what happens when it is guessed.
#
# Usage: board.sh [--roots <dir>[,<dir>...]] [--plain]
# [--brief|--plan|--inbox-plan|--dispatch ...]
# [--focus "<prose>"]
# Env: CLAUDE_COORD_DIR overrides the mailbox root.
# BOARD_ROOTS overrides the default scan roots.
@ -166,6 +187,9 @@ BRIEF=0
PLAN=0
INBOX_PLAN=0
FOCUS=""
DISPATCH=0
D_REPO=""; D_PROMPT=""; D_PANE=""
D_PATH=""; D_VER=""; D_REV=""; D_SCOPE=""; D_RAT=""
# Sibling calculator, invoked rather than reimplemented: the rubric that turns
# four traits into a model has exactly one copy, and it is route.sh's row
@ -187,6 +211,25 @@ while [ $# -gt 0 ]; do
# `shift 2` guard as --roots, for the same bash 3.2 reason.
--focus) [ $# -ge 2 ] || { echo "board: --focus requires a value" >&2; exit 2; }
FOCUS="$2"; shift 2 ;;
# Dispatch rendering: one repo, one prompt file, one startup command. Same
# `shift 2` guard as every other value flag, for the same bash 3.2 reason.
--dispatch) DISPATCH=1; BRIEF=0; PLAN=0; INBOX_PLAN=0; shift ;;
--repo) [ $# -ge 2 ] || { echo "board: --repo requires a value" >&2; exit 2; }
D_REPO="$2"; shift 2 ;;
--prompt-file) [ $# -ge 2 ] || { echo "board: --prompt-file requires a value" >&2; exit 2; }
D_PROMPT="$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; }
D_PATH="$2"; shift 2 ;;
--verification) [ $# -ge 2 ] || { echo "board: --verification requires a value" >&2; exit 2; }
D_VER="$2"; shift 2 ;;
--reversibility) [ $# -ge 2 ] || { echo "board: --reversibility requires a value" >&2; exit 2; }
D_REV="$2"; shift 2 ;;
--scope) [ $# -ge 2 ] || { echo "board: --scope requires a value" >&2; exit 2; }
D_SCOPE="$2"; shift 2 ;;
--rationale) [ $# -ge 2 ] || { echo "board: --rationale requires a value" >&2; exit 2; }
D_RAT="$2"; shift 2 ;;
--plain) shift ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "board: unknown argument: $1 (ignored)" >&2; shift ;;
@ -654,6 +697,152 @@ if [ "$BRIEF" -eq 1 ]; then
exit 0
fi
# --- Dispatch rendering (--dispatch) ---------------------------------------
# The FOURTH rendering, and the only one that carries a task INTO another repo:
# "start a session in repo X, on order Y, at cost Z". It lives here because the
# block format has exactly ONE generator and this file already is it - a second
# emitter of tab=/repo=/dir=/command=/paste= would be two copies of one format,
# which is the drift defect this repo's CLAUDE.md warns about, and a second
# place to get `paste=` wrong.
#
# Read-only holds. Everything below is a read; the two FILE WRITES a dispatch
# needs - the prompt file and the plan file - stay with the caller, exactly as
# brief-nightly.sh already owns the briefing's write.
#
# THE COST COMES FROM route.sh's ROW TABLE, never from a --model/--effort pair
# handed in. That is not tidiness: `--advisor opus` is a property of the ROW,
# two rows share a model/effort pair while differing on it, and the CLI accepts
# a wrong advisor silently (it drops it for a Fable main model without failing).
# A dispatch that took the model directly would have no source for that flag,
# and both available guesses are wrong in the same direction - a session that
# looks peer-reviewed without being. Scoring the four traits is the caller's
# judgement; turning them into a command is this lookup.
dispatch() {
# --target-pane is REQUIRED and has no default, the same rule route.sh's
# --last-effort carries: it is a MEASUREMENT of the world (does the target
# repo already have a Ghostty pane), and this script must never learn to look
# for a terminal. Defaulting would be worst at "no": that is the plan-file
# form, and morning's plan_drop_open (morning:1788) silently drops a plan
# block for a repo that already has a pane - "0 of 1", which reads as a
# broken plan file. Measured four times on one day, 2026-08-16, by two
# different repos.
case "$D_PANE" in
yes|no) ;;
"") echo "board: --dispatch requires --target-pane yes|no - measure it (morning --probe-panes), never guess it" >&2; exit 2 ;;
*) 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; }
[ -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.
# Verified directly 2026-08-16: "$(cat f)" hands the file's bytes to the
# session as ONE argv element with no re-evaluation, so $(...), backticks,
# quotes and UTF-8 in the BODY are inert - which is exactly why the prompt
# goes in argv instead of being inlined. The path sits inside those quotes
# and IS evaluated, so a space or a quote there splits the command.
# 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; }
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 "$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
# available outcome: a command missing --advisor is indistinguishable from a
# row that legitimately has none.
d_route="$(bash "$ROUTE" --path "$D_PATH" --verification "$D_VER" \
--reversibility "$D_REV" --scope "$D_SCOPE" --rationale "$D_RAT" 2>&1)" || {
echo "board: route.sh rejected the traits, so there is no command to give:" >&2
printf '%s\n' "$d_route" >&2
exit 2
}
d_cmd="$(printf '%s\n' "$d_route" | sed -n 's/^command=//p')"
d_cost="$(printf '%s\n' "$d_route" | sed -n 's/^next-cost=//p')"
[ -n "$d_cmd" ] || { echo "board: route.sh produced no command for those traits" >&2; exit 2; }
# The prompt in argv is the whole point. A bare model+effort command forces
# the operator to type Go, and the session then has to GUESS its task out of
# 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 [ "$D_PANE" = "no" ]; then
printf '# DISPATCH %s - plan-file form (target has no pane)\n' "$D_REPO"
printf '# Write this whole output to a file and run: morning --plan-file <fil> --no-go\n'
printf 'form=plan-file\n'
printf '\n'
printf 'tab=1\n'
printf 'repo=%s\n' "$D_REPO"
printf 'dir=%s\n' "$d_dir"
printf 'why=dispatch\n'
printf 'status=%s\n' "$d_status"
printf 'neste=%s\n' "$d_neste"
printf 'next-cost=%s\n' "$d_cost"
printf 'command=%s\n' "$d_full"
printf 'paste=cd %s && %s\n' "$d_dir" "$d_full"
printf '\n'
# --no-go semantics, in the OUTPUT and not only in a comment: it stops the
# Go MESSAGE, never the work. morning:806 is exact - "--no-go says nothing
# is typed once the startup command is in" - so the startup command, prompt
# in argv and all, is typed regardless and the session starts working by
# itself. An operator decided on the opposite premise once already
# (2026-08-16) and it had to be corrected before the run.
printf 'note=--no-go stops only the follow-up Go message. The startup command - prompt in argv included - is typed regardless (morning:806), so the dispatched session starts working on its own.\n'
# What the dry-run does and does not prove, measured rather than assumed
# (2026-08-16, this dispatch run through the real morning): it validates
# that the block parses and that a command comes out - "opening: 1 of 1".
# It does NOT validate the pane question. Run from a Claude session there
# is no tty, so morning prints "window: unknown (no terminal to measure),
# assuming an empty window" and plan_drop_open never fires at all. A gate
# built on the dry-run would therefore pass the self-dispatch case every
# single time, which is the case it was supposed to catch.
printf 'note=Verify before running: morning --plan-file <fil> --dry-run must report 1 of 1. That proves the block parses and yields a command - it does NOT prove the target has no pane: run from a Claude session there is no tty, morning assumes an empty window and plan_drop_open never fires (measured 2026-08-16).\n'
printf 'note=The pane question is answered by morning --probe-panes, whose DIR column works without a tty. That measurement is what --target-pane carries.\n'
else
printf '# DISPATCH %s - paste-only form (target ALREADY has a pane)\n' "$D_REPO"
printf '# No plan file, deliberately: run from a real terminal, morning plan_drop_open\n'
printf '# (morning:1788) drops a block whose repo already has a pane and reports\n'
printf '# "0 of 1", which reads as a broken plan file. Hand the operator the line\n'
printf '# instead. (Without a tty morning cannot see the panes at all, so a dry-run\n'
printf '# from a Claude session would report 1 of 1 and prove nothing.)\n'
printf 'form=paste-only\n'
printf 'repo=%s\n' "$D_REPO"
printf 'dir=%s\n' "$d_dir"
printf 'next-cost=%s\n' "$d_cost"
# No cd: one repo per terminal tab, and the operator is already standing in
# that one. A cd here is noise at best and a session started in the wrong
# directory at worst.
printf 'paste=%s\n' "$d_full"
printf '\n'
printf 'note=Run this in the existing tab AFTER /exit - it starts a new session in the pane that is already there.\n'
printf 'note=Deliberately no tab= key: a block carrying one is a plan block, and a plan block for this repo is exactly what plan_drop_open discards.\n'
printf 'note=morning --relaunch bypasses the filter but opens a SECOND tab beside the existing one (morning:1784), which is rarely what is wanted when the operator is already sitting there.\n'
fi
}
if [ "$DISPATCH" -eq 1 ]; then
dispatch
exit 0
fi
# --- Focus resolution (--focus) --------------------------------------------
# --focus is the first CUTOFF this format has ever had, and the plan documents
# at length that it has none: it takes one position (the order), it hides

174
skills/dispatch/SKILL.md Normal file
View file

@ -0,0 +1,174 @@
---
name: dispatch
description: >-
Turn "start a session in repo X, on order Y, at cost Z" into a complete,
verified startup command — the prompt written to a file, the model and effort
derived from the rubric, and the output form chosen by whether the target repo
already has a terminal pane. Use whenever work is being handed to a session in
another repo, or to this repo's own next session: "dispatch a session in repo
X", "start a session there with this order", "give me the startup command for
repo Y", "hand this work to <repo>", "write the plan file for that session",
"open a tab for X with this task", "how do I launch the next session on this".
Also triggers on Norwegian phrasings: "dispatch en økt i repo X", "start en økt
der med denne ordren", "gi meg oppstartskommandoen for Y", "send arbeidet til
<repo>", "skriv planfila for den økten", "åpne en tab for X med denne
oppgaven", "hvordan starter jeg neste økt på dette". Trigger even when no tool
is named — producing a runnable startup command for another session IS this
skill. Not for choosing WHICH repo deserves the next session (that is `board`),
not for scoring model and effort alone (that is `route`), and not for sending a
message to another repo (that is `coord-send`).
version: "0.24.0"
---
# dispatch — hand a session a task it can actually start on
A dispatch is one line the operator pastes and one file that line reads. Both
halves are easy to get wrong in ways that look right: a command with no prompt
in it, a plan file the driver silently discards, a `--no-go` that does not stop
what everyone assumed it stopped. All three were measured on 2026-08-16 — four
separate misfires in one day, by two different repos — and this skill exists so
they are not re-derived a fifth time.
**You produce the command. You never run it.** Starting a session in another
repo spends the operator's quota and takes an action inside a repo this session
does not own. Hand back the finished command and stop.
## The engine
BOARD="${CLAUDE_PLUGIN_ROOT}/scripts/board.sh"
"$BOARD" --dispatch --repo <name> \
--prompt-file <absolute path> \
--target-pane <yes|no> \
--path <known|partial|undetermined> \
--verification <strong|weak|none> \
--reversibility <cheap|costly|one-way> \
--scope <local|multi-file|cross-cutting> \
--rationale "why these four scores"
It writes nothing and prints one block. Exit 2 means it refused — read stderr
and fix the call; every refusal is a case where a command would have been wrong
rather than merely imperfect.
It is `board.sh` and not a script of its own because the block format
(`tab=`/`repo=`/`dir=`/`command=`/`paste=`) has exactly one generator. Two
emitters of one file format is the drift defect this repo's CLAUDE.md warns
about.
## The four steps, in order
### 1. Write the prompt file
The order goes in a file, in plain prose, and the file is what the session
actually receives:
```bash
mkdir -p "$HOME/.claude/dispatch"
PF="$HOME/.claude/dispatch/<repo>-$(date -u +%Y%m%dT%H%M%SZ).prompt"
```
Runtime scratch, not repo content — but it must survive until the operator
pastes the line, so never write it to a directory that gets cleaned between
sessions. The path must be absolute and shell-clean (letters, digits, `/`, `.`,
`_`, `-`); `--dispatch` refuses anything else, because the path is expanded
inside the command while the *contents* are not.
Write the whole order: the concrete task, which discipline applies (Iron Law,
TDD, which file the test goes in), and what must **not** be triggered. A prompt
that says only "continue" forces the receiving session to guess its task out of
STATE.md, which is the thing handing over a prompt is supposed to prevent.
Norwegian prose, `æøå`, quotes, `$` and backticks are all fine in the file. That
is measured, not assumed: `"$(cat file)"` hands the bytes to the session as one
argv element with no re-evaluation. The ASCII rule applies to shell and hook
*code*, not to a prompt travelling through argv.
### 2. Measure whether the target already has a pane
```bash
morning --probe-panes | grep "<absolute dir of the target repo>"
```
A hit means `--target-pane yes`. **Measure it; never assume it.** This is the
one input `--dispatch` refuses to default, for the same reason `route.sh`
refuses to default `--last-effort`: it is a fact about the world, and guessing
it produces a dispatch that verifies green and opens nothing.
Two facts about this measurement, both verified 2026-08-16 against the installed
`morning`:
- `--probe-panes` **works from a Claude session**, without a tty. It cannot
identify the anchor pane, but the `DIR` column — the part you need — is there.
- A session dispatching **its own next session** is always `--target-pane yes`.
That is not a special case for one repo; it is what self-dispatch is, and it
is where all four of the day's misfires landed.
### 3. Score the four traits and call `--dispatch`
Scoring is judgement and it is yours; the model, effort and advisor flag are a
lookup and are `route.sh`'s. Score the task **the dispatched session** will do,
using the `route` skill's trait table.
`--dispatch` deliberately takes no `--model`/`--effort`. `--advisor opus` is a
property of the rubric *row* — two rows share a model/effort pair while
differing on it, and the CLI accepts a wrong advisor silently — so a dispatch
that took the model directly would have no honest source for that flag. If the
right call is a Fable row, the rubric cannot produce it: write that command by
hand, and say in the handover that it is a recorded override, running without an
advisor.
### 4. Verify, then hand it over
**`--target-pane no` (plan-file form).** Write the whole output to a file and
dry-run it:
```bash
"$BOARD" --dispatch ... > "$HOME/.claude/dispatch/<repo>-<ts>.plan"
morning --plan-file "$HOME/.claude/dispatch/<repo>-<ts>.plan" --dry-run
```
Expect `opening: 1 of 1`. Then hand back:
morning --plan-file <path to the plan file> --no-go
**Know what that dry-run does not prove.** It proves the block parses and yields
a command. It does *not* answer the pane question: run from a Claude session
there is no tty, so `morning` reports `window: unknown ... assuming an empty
window` and `plan_drop_open` never fires. A gate built on the dry-run would pass
the self-dispatch case every single time — the one case it would exist to catch.
Step 2 is the measurement; this is a parse check.
**`--target-pane yes` (paste-only form).** There is no plan file, deliberately:
`morning`'s `plan_drop_open` (morning:1788) drops a block whose repo already has
a pane. Hand back the `paste=` line, and say it goes in the existing tab **after
`/exit`**. Never prefix it with `cd` — one repo per terminal tab, and the
operator is already standing in that one. `morning --relaunch` bypasses the
filter but opens a *second* tab beside the existing one, which is rarely wanted.
## Three things that must reach the operator
Say these in the handover, not only in the plan file. Each was a real
correction, not a hypothetical:
1. **The command carries the prompt in argv.** `claude --model X --effort Y` on
its own is not a dispatch; it is a session waiting for someone to tell it
what to do. Delivered bare twice on 2026-08-16, corrected by the operator
with "gi meg alltid komplette oppstartskommandoer for nye sesjoner".
2. **`--no-go` does not make the session wait.** It suppresses only the
follow-up Go message — `morning:806` is exact: "--no-go says nothing is typed
once the startup command is in". The startup command, prompt and all, is
typed regardless, so the dispatched session starts working on its own. An
operator decision was once taken on the opposite premise and had to be
corrected before the run.
3. **Which form you produced, and why.** "Plan file, because `<repo>` has no
pane" or "paste line, because `<repo>` already has one and a plan block for
it would be dropped". The form is a consequence of a measurement, and the
operator should be able to see the measurement.
## Where a dispatch is not enough
The prompt file is scratch: it carries the order to one session and nothing
records it afterwards. When the receiving repo needs a durable record of the
order — something its next session will still see after this one is gone — send
it a `coord-send` message as well. The dispatch starts the session; the mailbox
is what makes the order survive it.