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

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