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);