fix(dispatch): background report signs with --from; coord-send refuses a worktree-derived sender

In a linked worktree basename(git toplevel) is the worktree's name, so the
background child's report was signed `from: dispatch-bg-form` (measured by
the PM on 741ada8) - a mailbox no session holds.

- skills/dispatch: the starter reports by full path with
  `coord-send.sh --from <repo> --to <sender>`; board-selftest §19b gains the
  check (red on 741ada8) plus a known-negative control that runs the same
  predicate against the old "via coord-send" wording.
- coord-send.sh: a DERIVED sender inside a linked worktree exits 2, naming
  --from and the repo the worktree belongs to. Chose refusal over a warning
  because stderr is where a session least looks and the mail would already be
  delivered, and over a silent redirect for the reason the retired
  ktg-plugin-marketplace address is rejected rather than redirected.
  Linked-ness is git's own test (git-dir != git-common-dir), not ".git is a
  file", because a submodule has that too. coord-selftest section 39: 9
  checks, 3 red before the fix; controls for submodule, main checkout,
  explicit --from and a worktree named like its repo.
- Six other scripts derive identity the same way; named as a bounded gap in
  CLAUDE.md, not changed (not ordered).

Suites under /bin/bash 3.2: coord 266, board 497, route 73, orders 116,
guard 54 = 1006 (was 995). npm test 12/12.

Order 20260923T082933Z-466405184-from-.claude.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-23 11:20:39 +02:00
commit 7b09114cd6
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 136 additions and 8 deletions

View file

@ -26,6 +26,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`--target-pane` stays `yes|no`. board-selftest §19b pins all of it (17 `--target-pane` stays `yes|no`. board-selftest §19b pins all of it (17
checks, 14 red before the skill changed). checks, 14 red before the skill changed).
### Fixed
- **The background starter signs its report with `--from <repo>`, and
`coord-send` refuses a sender derived inside a linked git worktree.** In a
worktree, `basename(git toplevel)` is the worktree's name, so the child's
report was signed `from: dispatch-bg-form` (measured on 741ada8), a mailbox
no session holds. The starter now reports by full path with `--from`
(board-selftest §19b, red before the skill changed, with a known-negative
control). `coord-send` exits 2 on a derived sender inside a linked worktree,
naming `--from` and the repo the worktree belongs to. Linked-ness is git's
own test (git-dir differs from git-common-dir), so a submodule, the main
checkout, an explicit `--from` and a worktree named like its repo all still
send (coord-selftest section 39, 9 checks, 3 red before the fix). The six
other scripts that derive identity the same way are unchanged and named as a
gap in CLAUDE.md.
## [0.35.0] - 2026-09-18 ## [0.35.0] - 2026-09-18
### Added ### Added

View file

@ -16,7 +16,7 @@ marketplace plugin. Three components, one boundary:
`coord-done.sh` archives, `coord-count.sh` counts without delivering, `coord-done.sh` archives, `coord-count.sh` counts without delivering,
`coord-sweep.sh` closes the aged FYI backlog machine-wide. `coord-sweep.sh` closes the aged FYI backlog machine-wide.
Everything is pinned by `coord-selftest.sh` Everything is pinned by `coord-selftest.sh`
(257 checks, throwaway mailbox via `CLAUDE_COORD_DIR`). (266 checks, throwaway mailbox via `CLAUDE_COORD_DIR`).
**`ktg-plugin-marketplace` is a RETIRED `--to` address (operator decision **`ktg-plugin-marketplace` is a RETIRED `--to` address (operator decision
2026-08-15), rejected rather than redirected.** It is a polyrepo directory, 2026-08-15), rejected rather than redirected.** It is a polyrepo directory,
@ -80,6 +80,33 @@ marketplace plugin. Three components, one boundary:
`coord-send.sh`'s own guard comment protects dot names as real repos `coord-send.sh`'s own guard comment protects dot names as real repos
(`basename` of a git toplevel under a hidden directory). (`basename` of a git toplevel under a hidden directory).
**A sender derived inside a LINKED WORKTREE is refused, not signed (order
20260923T082933Z-466405184, 2026-09-23).** A background session runs in
`<repo>/.claude/worktrees/<name>`, and `basename(git toplevel)` there is
`<name>`: measured by the PM on 741ada8, the child's report arrived signed
`from: dispatch-bg-form`, a mailbox no session holds, so a reply to it goes
nowhere. That is section 22's "invented identity" in a new place, and it gets
section 22's answer: exit 2, naming `--from` and the repo the worktree belongs
to. Refusal was chosen over a warning because stderr is where a session least
looks and the message would already be delivered, and over a silent redirect
to the main repo's name for the reason the retired `ktg-plugin-marketplace`
address is rejected rather than redirected. Linked-ness is git's own test
(`--git-dir` differs from `--git-common-dir`), never a path pattern and never
".git is a file", because a SUBMODULE also has `.git` as a file and its
basename IS its name. Selftest section 39 pins that control, the main
checkout, an explicit `--from` inside the worktree, and a worktree directory
that carries the repo's own name. It fails OPEN when git cannot answer. The
dispatch skill's background starter now signs its report with `--from`
(board-selftest §19b), so the refusal is the net, not the path.
**Bounded gap, stated rather than closed:** six more scripts derive identity
the same way (`coord-done`, `coord-inbox`, `coord-order-send`,
`coord-order-claim`, `coord-order-done`, `coord-order-inbox` - measured with
`grep -n 'rev-parse --show-toplevel' scripts/*.sh`). The order named
`coord-send` only. `coord-order-done` already fails loudly (exit 1, "no
claimed order"); `coord-done` does not - measured inside a worktree, it prints
`no inbox for <worktree-name>` on stderr and exits 0, having archived nothing.
Reported to `.claude` for its own order.
**`coord-sweep.sh` is the only path that closes a message with no human in **`coord-sweep.sh` is the only path that closes a message with no human in
the loop, and every constraint on it follows from that.** It may close exactly the loop, and every constraint on it follows from that.** It may close exactly
one mechanically decidable class - `reply-expected: no`, older than the grace one mechanically decidable class - `reply-expected: no`, older than the grace
@ -467,7 +494,7 @@ marketplace plugin. Three components, one boundary:
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md - **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
next-step blocks + board lines, `git status`, and mailbox pending counts, and 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 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` (495 checks). STATE.md and no mailbox. Pinned by `board-selftest.sh` (497 checks).
**It lives here because the mailbox is one of its three inputs, and it carries **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 the same axis distinction the mailbox does.** A pending count means *others
@ -1572,8 +1599,8 @@ obligations in another repo.
- Zero dependencies everywhere: bash + coreutils in the engine, `node:` - Zero dependencies everywhere: bash + coreutils in the engine, `node:`
builtins only in hook and tests. builtins only in hook and tests.
- TDD: no behavior change without a failing selftest check first. - TDD: no behavior change without a failing selftest check first.
`bash scripts/coord-selftest.sh` must exit 0 (257/257), `bash scripts/coord-selftest.sh` must exit 0 (266/266),
`bash scripts/board-selftest.sh` must exit 0 (495/495), `bash scripts/board-selftest.sh` must exit 0 (497/497),
`bash scripts/route-selftest.sh` must exit 0 (73/73), `bash scripts/route-selftest.sh` must exit 0 (73/73),
`bash scripts/orders-selftest.sh` must exit 0 (116/116) and `bash scripts/orders-selftest.sh` must exit 0 (116/116) and
`bash scripts/state-line-guard-selftest.sh` must exit 0 (54/54). `bash scripts/state-line-guard-selftest.sh` must exit 0 (54/54).

View file

@ -14,7 +14,7 @@ Session A in repo X leaves a message for repo Y; the next session in repo Y gets
![Hooks](https://img.shields.io/badge/hooks-1-green) ![Hooks](https://img.shields.io/badge/hooks-1-green)
![Skills](https://img.shields.io/badge/skills-4-orange) ![Skills](https://img.shields.io/badge/skills-4-orange)
![CLI scripts](https://img.shields.io/badge/CLI_scripts-8-blue) ![CLI scripts](https://img.shields.io/badge/CLI_scripts-8-blue)
![Selftest checks](https://img.shields.io/badge/selftest_checks-995-blue) ![Selftest checks](https://img.shields.io/badge/selftest_checks-1006-blue)
--- ---
@ -202,8 +202,8 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
## Development ## Development
bash scripts/coord-selftest.sh # 257 checks against a throwaway mailbox bash scripts/coord-selftest.sh # 266 checks against a throwaway mailbox
bash scripts/board-selftest.sh # 495 checks against a throwaway repo tree bash scripts/board-selftest.sh # 497 checks against a throwaway repo tree
bash scripts/route-selftest.sh # 73 checks, incl. the route->board round trip bash scripts/route-selftest.sh # 73 checks, incl. the route->board round trip
bash scripts/orders-selftest.sh # 116 checks, incl. the 20-way barriered claim race bash scripts/orders-selftest.sh # 116 checks, incl. the 20-way barriered claim race
bash scripts/state-line-guard-selftest.sh # 54 checks, incl. the Edit replace_all projection and the ratchet bash scripts/state-line-guard-selftest.sh # 54 checks, incl. the Edit replace_all projection and the ratchet

View file

@ -1966,6 +1966,22 @@ printf '%s\n' "$dsk_bg" | grep -q 'coord-order-done\.sh --repo [^ ]* '
check "background starter closes with --repo (a worktree's name is not the repo's)" $? check "background starter closes with --repo (a worktree's name is not the repo's)" $?
if printf '%s\n' "$dsk_bg" | grep -qE '^[[:space:]]*coord-order-(claim|done) '; then rc=1; else rc=0; fi if printf '%s\n' "$dsk_bg" | grep -qE '^[[:space:]]*coord-order-(claim|done) '; then rc=1; else rc=0; fi
check "background section has no bare coord-order-claim/-done command line" "$rc" check "background section has no bare coord-order-claim/-done command line" "$rc"
# ...and the REPORT is signed with --from, by full path. coord-send derives its
# sender from the same toplevel basename, so in the worktree the report was
# signed `from: dispatch-bg-form` (measured by the PM on 741ada8, order
# 20260923T082933Z-466405184). The predicate reads the starter BLOCK only and
# is a function, so the known-negative control runs the same code: every
# mention of coord-send in the starter must be the full-path call with --from.
dsk_starter="$(printf '%s\n' "$dsk_bg" | awk '/^```text/{f=1; next} f && /^```/{exit} f')"
dsk_unsigned_report() {
n_all="$(printf '%s\n' "$1" | grep -o 'coord-send' | wc -l | tr -d ' ')"
n_ok="$(printf '%s\n' "$1" | grep -oE 'bash [^ ]*/scripts/coord-send\.sh --from [^ ]+ ' | wc -l | tr -d ' ')"
[ "$n_all" -eq 0 ] || [ "$n_ok" -ne "$n_all" ]
}
[ -n "$dsk_starter" ] && ! dsk_unsigned_report "$dsk_starter"
check "background starter reports by FULL path to coord-send.sh with --from (a worktree's name is not the repo's)" $?
dsk_unsigned_report 'ORDRE x ... (4) Lukk ... Rapport som coord til .claude via coord-send.'
check "control: the report predicate catches a starter that says 'via coord-send' without --from" $?
# The description is what makes a skill trigger; a form it does not mention is # The description is what makes a skill trigger; a form it does not mention is
# a form nobody reaches through the front door. # a form nobody reaches through the front door.
dsk_fm="$(awk 'NR==1 && /^---$/{f=1; next} f && /^---$/{exit} f' "$DSKILL")" dsk_fm="$(awk 'NR==1 && /^---$/{f=1; next} f && /^---$/{exit} f' "$DSKILL")"

View file

@ -1388,6 +1388,46 @@ check "launchd control: a program path INSIDE the plugin cache is caught" $?
/bin/rm -f "$BADPL" "$CACHEPL" 2>/dev/null /bin/rm -f "$BADPL" "$CACHEPL" 2>/dev/null
# 39. A LINKED WORKTREE's directory name is not a repo name, so a sender derived
# from it is invented, and section 22's rule applies: refuse, never guess.
# Order 20260923T082933Z-466405184 (.claude): a background child runs in
# <repo>/.claude/worktrees/<name>, and basename(git toplevel) there is <name>.
# Measured by the PM on 741ada8: the child's report arrived signed
# `from: dispatch-bg-form`, a mailbox no session holds, so any reply to it
# would have gone nowhere. Linked-ness is git's own test (git-dir differs from
# git-common-dir), not a path pattern and not ".git is a file": a SUBMODULE also
# has .git as a file, and its basename IS its real name - the control below.
WT="$(cd "$(mktemp -d)" && pwd -P)"
git -C "$WT" init -q wtmain >/dev/null 2>&1
git -C "$WT/wtmain" -c user.name=t -c user.email=t@t -c commit.gpgSign=false commit -q --allow-empty -m init >/dev/null 2>&1
git -C "$WT/wtmain" worktree add -q "$WT/wt-child" >/dev/null 2>&1
[ -f "$WT/wt-child/.git" ]; check "worktree fixture: wt-child really is a linked worktree (ground truth)" $?
wto="$( (cd "$WT/wt-child" && "$SEND" --to wttarget --subject s --message "WT-BODY" </dev/null) 2>&1 )"; rc=$?
[ "$rc" -eq 2 ]; check "worktree: a sender derived in a linked worktree is refused (exit 2)" $?
[ -z "$(ls "$CLAUDE_COORD_DIR/wttarget/inbox" 2>/dev/null)" ]; check "worktree: nothing is delivered under the worktree's name" $?
printf '%s' "$wto" | grep -q -- '--from wtmain'; check "worktree: the refusal names --from and the repo the worktree belongs to" $?
wtok="$( (cd "$WT/wt-child" && "$SEND" --to wttarget --from wtmain --subject s --message "WT-OK" </dev/null) 2>&1 )"; rc=$?
[ "$rc" -eq 0 ] && grep -q '^from: wtmain$' "$CLAUDE_COORD_DIR"/wttarget/inbox/*.md 2>/dev/null
check "worktree control: an explicit --from still sends from inside the worktree" $?
wtm="$( (cd "$WT/wtmain" && "$SEND" --to wtmaintarget --subject s --message "WT-MAIN" </dev/null) 2>&1 )"; rc=$?
[ "$rc" -eq 0 ] && grep -q '^from: wtmain$' "$CLAUDE_COORD_DIR"/wtmaintarget/inbox/*.md 2>/dev/null
check "worktree control: the MAIN checkout of the same repo still derives its sender" $?
# A worktree whose directory happens to carry the repo's own name derives the
# right identity, so refusing it would be a false positive.
mkdir -p "$WT/elsewhere"
git -C "$WT/wtmain" worktree add -q "$WT/elsewhere/wtmain" >/dev/null 2>&1
( cd "$WT/elsewhere/wtmain" && "$SEND" --to wtsame --subject s --message "WT-SAME" </dev/null >/dev/null 2>&1 ); rc=$?
[ "$rc" -eq 0 ]; check "worktree control: a worktree named exactly like its repo is not refused" $?
# Submodule control: .git is a file there too, and git-dir == git-common-dir.
git -C "$WT" init -q wtsub >/dev/null 2>&1
git -C "$WT/wtsub" -c user.name=t -c user.email=t@t -c commit.gpgSign=false commit -q --allow-empty -m init >/dev/null 2>&1
git -C "$WT/wtmain" -c protocol.file.allow=always submodule add -q "$WT/wtsub" wtsub >/dev/null 2>&1
[ -f "$WT/wtmain/wtsub/.git" ]; check "submodule fixture: .git really is a file in the submodule (ground truth)" $?
( cd "$WT/wtmain/wtsub" && "$SEND" --to wtsubtarget --subject s --message "WT-SUB" </dev/null >/dev/null 2>&1 ); rc=$?
[ "$rc" -eq 0 ] && grep -q '^from: wtsub$' "$CLAUDE_COORD_DIR"/wtsubtarget/inbox/*.md 2>/dev/null
check "worktree control: a SUBMODULE (.git is a file) still derives its own name" $?
/bin/rm -rf "$WT" 2>/dev/null
echo "----" echo "----"
echo "PASS=$PASS FAIL=$FAIL" echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ] [ "$FAIL" -eq 0 ]

View file

@ -22,6 +22,8 @@
# the broadcast queue so no future repo receives it. This is un-send, not # the broadcast queue so no future repo receives it. This is un-send, not
# recall - repos that already received it are unaffected. # recall - repos that already received it are unaffected.
# --from overrides the sender/self identity (default: basename of git toplevel/cwd). # --from overrides the sender/self identity (default: basename of git toplevel/cwd).
# Inside a linked git worktree the default is refused (exit 2): the toplevel's
# basename there is the worktree's name, not the repo's - pass --from.
# #
# Exit: 0 delivered, 1 delivered but --reply-to's original could NOT be closed # Exit: 0 delivered, 1 delivered but --reply-to's original could NOT be closed
# (the reply is sent; do not re-send it, close the original by hand), # (the reply is sent; do not re-send it, close the original by hand),
@ -62,13 +64,33 @@ done
# a real message was delivered under exactly that name. An invented identity is # a real message was delivered under exactly that name. An invented identity is
# worse than none: it signs mail as a repo that does not exist and, on the read # worse than none: it signs mail as a repo that does not exist and, on the read
# side, opens a mailbox that may belong to someone else. Refuse and say how. # side, opens a mailbox that may belong to someone else. Refuse and say how.
DERIVED_FROM=0
if [ -z "$FROM" ]; then if [ -z "$FROM" ]; then
FROM="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)" FROM="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)"
DERIVED_FROM=1
fi fi
if [ -z "$FROM" ]; then if [ -z "$FROM" ]; then
echo "coord-send: cannot resolve sender identity (not inside a git repo); pass --from <repo> to choose one explicitly" >&2 echo "coord-send: cannot resolve sender identity (not inside a git repo); pass --from <repo> to choose one explicitly" >&2
exit 2 exit 2
fi fi
# The same rule for a LINKED WORKTREE: its toplevel basename is the worktree's
# directory name (<repo>/.claude/worktrees/<name> for a background session), so
# the derived sender is invented. git's own test decides linked-ness - git-dir
# differs from git-common-dir - because ".git is a file" is also true of a
# submodule, whose basename IS its name. A worktree directory that happens to
# carry the repo's own name derives correctly and passes. Fails OPEN when git
# cannot answer: the refusal needs a positive measurement.
if [ "$DERIVED_FROM" -eq 1 ]; then
WT_GD="$(git rev-parse --path-format=absolute --git-dir 2>/dev/null)"
WT_CD="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"
if [ -n "$WT_GD" ] && [ -n "$WT_CD" ] && [ "$WT_GD" != "$WT_CD" ]; then
WT_REPO="$(basename "$(dirname "$WT_CD")")"
if [ "$FROM" != "$WT_REPO" ]; then
echo "coord-send: refusing to sign as '$FROM': that is a linked git worktree's directory name, not a repo name; pass --from explicitly (this worktree belongs to: --from $WT_REPO)" >&2
exit 2
fi
fi
fi
# A leading _ is reserved for engine internals (_broadcast today; the rule # A leading _ is reserved for engine internals (_broadcast today; the rule
# reserves the namespace so a later _seen or _config cannot reopen the hole). # reserves the namespace so a later _seen or _config cannot reopen the hole).
case "$FROM" in case "$FROM" in

View file

@ -233,7 +233,7 @@ full, because a child executes the starter literally and the order verbs are
not on PATH (ORDRE 65). not on PATH (ORDRE 65).
```text ```text
ORDRE <id> ligger i dette repoets ordrekoe. Gjor dette i rekkefolge: (1) claim den - bash <PLUGIN_ROOT>/scripts/coord-order-claim.sh --repo <repo> <id> - claim skriver ut hele ordreteksten. FULL STI ER MED VILJE. Claim KUN denne ordren. (2) Sammenlign mot STATE.md NESTE og uttal avviket i ditt FORSTE svar. (3) Du er en bakgrunnsoekt isolert i en NY worktree: sett grenen din til <hash> (git checkout -B <gren> <hash>) og verifiser. Commit paa den grenen, ingen fletting, INGEN push/tag/release - operatoren pusher. Du kan ikke stille spoersmaal; ved et teknisk valg, ta det anbefalte og skriv valgt X fordi Y. Lange kjoringer i forgrunnen, aldri arm venter. (4) Lukk med bash <PLUGIN_ROOT>/scripts/coord-order-done.sh --repo <repo> <id> --commit HASH - eller --return --reason. Rapport som coord til <sender> via coord-send. ORDRE <id> ligger i dette repoets ordrekoe. Gjor dette i rekkefolge: (1) claim den - bash <PLUGIN_ROOT>/scripts/coord-order-claim.sh --repo <repo> <id> - claim skriver ut hele ordreteksten. FULL STI ER MED VILJE. Claim KUN denne ordren. (2) Sammenlign mot STATE.md NESTE og uttal avviket i ditt FORSTE svar. (3) Du er en bakgrunnsoekt isolert i en NY worktree: sett grenen din til <hash> (git checkout -B <gren> <hash>) og verifiser. Commit paa den grenen, ingen fletting, INGEN push/tag/release - operatoren pusher. Du kan ikke stille spoersmaal; ved et teknisk valg, ta det anbefalte og skriv valgt X fordi Y. Lange kjoringer i forgrunnen, aldri arm venter. (4) Lukk med bash <PLUGIN_ROOT>/scripts/coord-order-done.sh --repo <repo> <id> --commit HASH - eller --return --reason. (5) Rapporter til <sender> med bash <PLUGIN_ROOT>/scripts/coord-send.sh --from <repo> --to <sender> - gren, hasher, roed til groenn, selftest-tall.
``` ```
- **`--repo <repo>` on both verbs, always.** A background child runs in a - **`--repo <repo>` on both verbs, always.** A background child runs in a
@ -242,6 +242,13 @@ ORDRE <id> ligger i dette repoets ordrekoe. Gjor dette i rekkefolge: (1) claim d
Measured 2026-09-23: a claim made before the worktree won. The close made Measured 2026-09-23: a claim made before the worktree won. The close made
inside the worktree exited 1 with `no claimed order … for <worktree-name>`, inside the worktree exited 1 with `no claimed order … for <worktree-name>`,
and the order stayed in flight. and the order stayed in flight.
- **`--from <repo>` on the report, by full path.** `coord-send` derives its
sender from the same toplevel basename, so the report was signed with the
worktree's name (measured on 741ada8: `from: dispatch-bg-form`, a mailbox no
session holds). Since 0.36.0 `coord-send` refuses a sender derived inside a
linked worktree, so a starter without `--from` now fails loudly instead of
delivering, but the starter must still say it: the child executes it
literally.
- **`Claim KUN denne ordren`** stays in even when the queue holds only this - **`Claim KUN denne ordren`** stays in even when the queue holds only this
one. Another order can land between the dispatch and the child's first turn. one. Another order can land between the dispatch and the child's first turn.
- **`<hash>` is the commit the child must build on, not automatically `main`.** - **`<hash>` is the commit the child must build on, not automatically `main`.**