Compare commits

..

5 commits

Author SHA1 Message Date
4fd195513f
chore(release): 0.36.0
Version 0.36.0 in all seven files (plugin.json, package.json, README badge,
four SKILL.md frontmatters) and CHANGELOG [0.36.0] - 2026-09-23. No tag:
release-plugin.mjs is the operator's push round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:21:12 +02:00
7b09114cd6
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>
2026-09-23 11:20:39 +02:00
741ada82b7
fix(dispatch): background starter passes --repo to both order verbs
A background child runs in a worktree, and without --repo the order
verbs derive identity from the git toplevel's basename - the worktree's
name, not the repo's. Measured in the session that built the form: the
claim ran before the worktree and won; coord-order-done ran inside it and
exited 1 "no claimed order ... for dispatch-bg-form", leaving the order in
flight until --repo repo-mailbox was passed. The order's own starter
template carried the same defect.

§19b gains two checks (red against 24adf59, same predicate), the
template passes --repo <repo> to claim and done, and the skill says why.

Suites under bash 3.2: coord 257, board 495, route 73, orders 116,
guard 54 = 995. npm test 12/12.

Order 20260923T070445Z-9974217048-from-.claude (follow-up to 24adf59).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:37:38 +02:00
24adf594bf
feat(dispatch): background form (claude --bg) with the T4 guards - GREEN
The dispatch skill gains a third form beside paste and plan-file: the
PM starts the child itself (operator decision 2026-09-22). Template:
cd <dir> && claude --model <m> --effort <e> --disallowedTools
AskUserQuestion,SendMessage,ListAgents --bg --name <name> "<starter>",
with --model first because pre-dispatch-budget.sh counts only that
position, and model/effort spliced from route.sh's command= so the order
holds by construction. The ASCII starter claims and closes by full path,
says claim KUN denne, pins the branch with git checkout -B, and forbids
push/tag/release. The skill says what follows: watch the order file,
re-measure on the commit, merge in the operator's push round. The
description triggers on the form (EN + NO).

valgt: no bg= line in board.sh, fordi the background form uses none of
the plan-block format (no tab=, no pane, no morning consumer) and its one
generated part already has a single generator in route.sh. §19b pins
that --target-pane still refuses 'bg'. Stated overlap: starter steps
(1)(2)(4) repeat board.sh's thin-starter protocol.

board-selftest 478 -> 493 (§19b: 12 red -> green). Suites under bash
3.2: coord 257, board 493, route 73, orders 116, guard 54 = 993.
npm test 12/12. No version bump: release is the operator's push round.

Order 20260923T070445Z-9974217048-from-.claude.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:27:23 +02:00
e579d825aa
test(dispatch): background form (claude --bg) with T4 guards - RED
Section 19b pins what the dispatch skill must say about the background
form the PM has used for 15+ starts since 2026-09-22 and that the skill
never mentioned: --model directly after claude (the budget hook counts
only that position), AskUserQuestion/SendMessage/ListAgents disallowed
(hardening T4), INGEN push, git checkout -B, both order verbs by full
path, and a description that triggers on it. Every check reads the
section only. 12 of 15 red against b278dbd; the two controls are green
(the model-first predicate catches a planted wrong order; --target-pane
still refuses a third value).

Order 20260923T070445Z-9974217048-from-.claude.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:14:00 +02:00
12 changed files with 345 additions and 18 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "repo-mailbox", "name": "repo-mailbox",
"version": "0.35.0", "version": "0.36.0",
"description": "Local mailbox for coordination between Claude Code sessions in different repositories. Directed messages and broadcasts as plain Markdown files on your own disk, injected as context at session start. Local, private, no network.", "description": "Local mailbox for coordination between Claude Code sessions in different repositories. Directed messages and broadcasts as plain Markdown files on your own disk, injected as context at session start. Local, private, no network.",
"author": { "author": {
"name": "Kjell Tore Guttormsen" "name": "Kjell Tore Guttormsen"

View file

@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.36.0] - 2026-09-23
### Added
- **The `dispatch` skill describes a third form: the background session.**
When the dispatching session starts the child itself (operator decision
2026-09-22), the command is `cd <dir> && claude --model <m> --effort <e>
--disallowedTools AskUserQuestion,SendMessage,ListAgents --bg --name <name>
"<starter>"`. `--model` sits directly after `claude`, because the budget hook
counts a start only in that position. The three tools are disallowed per
hardening T4. The ASCII starter claims by full path with `--repo`, compares against NESTE,
pins the branch with `git checkout -B`, forbids push/tag/release, and closes
by full path, also with `--repo`. Inside a worktree the verbs would otherwise
derive the worktree's name as the identity. That was measured: a close
without `--repo` exited 1 and left the order in flight. The skill also says what the PM does afterwards: watch the
order file, re-measure on the commit, and leave the merge to the operator's
push round. The skill description now triggers on the form. `board.sh` is
unchanged: the form has no pane, no plan block and no `morning` consumer, so
`--target-pane` stays `yes|no`. board-selftest §19b pins all of it (17
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` (478 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
@ -1514,6 +1541,25 @@ marketplace plugin. Three components, one boundary:
sits in choosing the four trait values, and the skill must never reason its sits in choosing the four trait values, and the skill must never reason its
way to a model instead. way to a model instead.
**The background form (`claude --bg`) lives in the `dispatch` SKILL alone,
and `board.sh` deliberately gained no third form** (order
20260923T070445Z-9974217048, operator decision 2026-09-22, hardening T4).
`board.sh --dispatch` is the single generator of the plan-block format, and
the background form uses none of it: no `tab=`, no pane, no `morning`
consumer. Its one generated part, model and effort, already has a single
generator in `route.sh`'s `command=`. That line begins `claude --model`, so
splicing it keeps `--model` in the one position the budget hook counts.
board-selftest §19b reads the skill's background SECTION only. It pins
model-first (with a planted-wrong-order control), the three T4 tools,
`INGEN push`, `git checkout -B`, and both order verbs by full path AND with
`--repo`. A worktree's git toplevel basename is the worktree's name, so
derived identity looks in the wrong queue. That was measured in the building
session: its close exited 1 until `--repo` was passed. The section also pins
a description that triggers on the form, and that `--target-pane` still
refuses `bg`. **Stated overlap, not closed:** starter steps (1), (2) and
(4) repeat the protocol `board.sh`'s thin starter carries. A protocol change
must move both copies.
**Boundary rule:** the mailbox is transport, not state. Durable decisions **Boundary rule:** the mailbox is transport, not state. Durable decisions
live in the owning repo's docs/git history; messages are notices pointing at live in the owning repo's docs/git history; messages are notices pointing at
them. Message content is untrusted cross-repo input — the read side quotes them. Message content is untrusted cross-repo input — the read side quotes
@ -1553,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 (478/478), `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

@ -10,11 +10,11 @@ Session A in repo X leaves a message for repo Y; the next session in repo Y gets
*AI-generated: all code produced by Claude Code through dialog-driven development.* *AI-generated: all code produced by Claude Code through dialog-driven development.*
![Version](https://img.shields.io/badge/version-0.35.0-blue) ![Version](https://img.shields.io/badge/version-0.36.0-blue)
![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-978-blue) ![Selftest checks](https://img.shields.io/badge/selftest_checks-1006-blue)
--- ---
@ -98,6 +98,8 @@ Scoring is judgement and belongs to the skill; turning scores into a row is a lo
**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`. **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`.
**A third form: the background session.** When the dispatching session starts the child itself, the skill describes `claude --model <m> --effort <e> --disallowedTools AskUserQuestion,SendMessage,ListAgents --bg --name <name> "<starter>"`. `--model` comes directly after `claude`, and the model and effort are taken from `route.sh`. The three disallowed tools are there because a background child cannot be answered: a question stops the child, and a message reaching back to its parent is a direction nobody asked for. The starter tells the child to pin its branch with `git checkout -B`, to commit there, and to push, tag or release nothing. Merging and publishing belong to the operator. This form lives in the skill alone. It has no pane, no plan block and no `morning` consumer, so `board.sh --dispatch` still emits exactly the two forms above.
**Making the order outlive the tab (the order queue).** A prompt file passed through argv dies with the pane it was typed into, and nothing in the receiving repo records that an order ever arrived. Measured 2026-08-17: one order was dispatched three times over 90 minutes before it was worked, because the first two tabs ran something else. So dispatch now delivers the order into the recipient's own queue — `~/.claude/coord/<repo>/orders/` — and the pasted line becomes a thin **starter** carrying only the order id. The order text has one home. If the tab is never run, nothing is lost: the order stays pending, is re-injected at every session start in that repo, and shows up in `board`'s ORDRE column, which sits beside INN and is never summed with it — INN is "others are waiting on you", ORDRE is "work is waiting on this repo". **Making the order outlive the tab (the order queue).** A prompt file passed through argv dies with the pane it was typed into, and nothing in the receiving repo records that an order ever arrived. Measured 2026-08-17: one order was dispatched three times over 90 minutes before it was worked, because the first two tabs ran something else. So dispatch now delivers the order into the recipient's own queue — `~/.claude/coord/<repo>/orders/` — and the pasted line becomes a thin **starter** carrying only the order id. The order text has one home. If the tab is never run, nothing is lost: the order stays pending, is re-injected at every session start in that repo, and shows up in `board`'s ORDRE column, which sits beside INN and is never summed with it — INN is "others are waiting on you", ORDRE is "work is waiting on this repo".
Ownership is explicit rather than implied. An order is pending until a session **claims** it, and the claim is a rename with no check-then-act step, so of any number of racing sessions exactly one wins and the rest get a clean refusal. The claiming session owns it until it either closes it with a commit pointer or **returns** it with a reason recorded in the order itself. At claim time the session is told to compare the order against its own `STATE.md` next step and to state any divergence in its first reply — a dispatch that displaces a live next step is a decision, and this makes it an uttered one. A session that claims an order and dies is the one remaining way an order could vanish, so claimed orders stay visible in the injection with their in-flight age; that is a visible-again rule, not a lease timer, because nothing here can know that a session is dead. Ownership is explicit rather than implied. An order is pending until a session **claims** it, and the claim is a rename with no check-then-act step, so of any number of racing sessions exactly one wins and the rest get a clean refusal. The claiming session owns it until it either closes it with a commit pointer or **returns** it with a reason recorded in the order itself. At claim time the session is told to compare the order against its own `STATE.md` next step and to state any divergence in its first reply — a dispatch that displaces a live next step is a decision, and this makes it an uttered one. A session that claims an order and dies is the one remaining way an order could vanish, so claimed orders stay visible in the injection with their in-flight age; that is a visible-again rule, not a lease timer, because nothing here can know that a session is dead.
@ -200,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 # 478 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

@ -1,6 +1,6 @@
{ {
"name": "repo-mailbox", "name": "repo-mailbox",
"version": "0.35.0", "version": "0.36.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {

View file

@ -1919,6 +1919,81 @@ check "dispatch SKILL.md's ORDER assignment carries no fallback" "$rc"
grep -q -- '--order-id' "$DSKILL" grep -q -- '--order-id' "$DSKILL"
check "dispatch SKILL.md dispatches on --order-id" $? check "dispatch SKILL.md dispatches on --order-id" $?
# --- 19b. The dispatch skill's BACKGROUND form -----------------------------
# Order 20260923T070445Z-9974217048 (.claude; operator decision 2026-09-22):
# the PM starts sessions itself again with `claude --bg`, and hardening T4
# says every such child is started with AskUserQuestion, SendMessage and
# ListAgents disallowed and told that the operator pushes. The form had been
# used for 15+ starts and was written down nowhere in the skill. Every check
# reads the background SECTION only, never the whole file, so prose elsewhere
# (the far-end section's bare verb names, for one) cannot satisfy or trip it.
dsk_bg="$(awk '/^## The background form/{f=1; print; next} f && /^## /{exit} f' "$DSKILL")"
[ -n "$dsk_bg" ]; check "dispatch SKILL.md has a '## The background form' section" $?
dsk_bgl="$(printf '%s\n' "$dsk_bg" | grep -E 'claude .*--bg( |$)')"
[ -n "$dsk_bgl" ]; check "background section carries a 'claude ... --bg' command line" $?
# --model DIRECTLY after claude: pre-dispatch-budget.sh counts a start only in
# that position (measured 2026-09-22 - a valued flag before --model breaks it).
# The predicate is a function so its known-positive control runs the SAME code.
dsk_model_first() { printf '%s\n' "$1" | grep -E 'claude .*--bg( |$)' | grep -vqE 'claude --model [^ ]+ '; }
if [ -n "$dsk_bgl" ] && ! dsk_model_first "$dsk_bgl"; then rc=0; else rc=1; fi
check "every 'claude --bg' line has --model directly after 'claude'" "$rc"
dsk_model_first 'cd /x && claude --bg --name x --model sonnet --effort high "go"'
check "control: the --model-first predicate catches a planted '--bg --name x --model' line" $?
dsk_dis="$(printf '%s\n' "$dsk_bgl" | sed -n 's/.*--disallowedTools \([^ ]*\).*/\1/p' | head -1)"
for t in AskUserQuestion SendMessage ListAgents; do
case ",$dsk_dis," in *",$t,"*) rc=0 ;; *) rc=1 ;; esac
check "background command disallows $t (T4)" "$rc"
done
printf '%s\n' "$dsk_bg" | grep -q 'INGEN push'
check "background starter says INGEN push (the operator pushes)" $?
printf '%s\n' "$dsk_bg" | grep -q 'git checkout -B '
check "background starter pins the branch with 'git checkout -B'" $?
# The verbs are not on PATH (ORDRE 65): a bg child executes the starter
# literally, so both must be called by full path, and no line may start with
# the bare verb as a command.
printf '%s\n' "$dsk_bg" | grep -q 'bash [^ ]*/scripts/coord-order-claim\.sh '
check "background starter claims by FULL path to coord-order-claim.sh" $?
printf '%s\n' "$dsk_bg" | grep -q 'bash [^ ]*/scripts/coord-order-done\.sh '
check "background starter closes by FULL path to coord-order-done.sh" $?
# ...and both carry --repo. A background child runs in a WORKTREE, whose git
# toplevel basename is the worktree's name, not the repo's, so a verb that
# derives its identity looks in the wrong queue. Measured 2026-09-23 in the
# session that built this section: claim ran before the worktree and won;
# done ran inside it and exited 1 "no claimed order ... for dispatch-bg-form".
printf '%s\n' "$dsk_bg" | grep -q 'coord-order-claim\.sh --repo [^ ]* '
check "background starter claims with --repo (a worktree's name is not the repo's)" $?
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)" $?
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"
# ...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
# a form nobody reaches through the front door.
dsk_fm="$(awk 'NR==1 && /^---$/{f=1; next} f && /^---$/{exit} f' "$DSKILL")"
printf '%s\n' "$dsk_fm" | grep -q -- 'claude --bg'
check "dispatch SKILL.md description names 'claude --bg'" $?
printf '%s\n' "$dsk_fm" | grep -qi 'bakgrunn'
check "dispatch SKILL.md description carries a Norwegian background trigger" $?
# Known-negative: the background form is the SKILL's, not a third board.sh
# rendering - --target-pane stays exactly yes|no.
"$BOARD" --roots "$ROOT" --dispatch --repo repo-a --prompt-file "$DSP" --target-pane bg $DSPTRAITS >/dev/null 2>&1; rc=$?
[ "$rc" -eq 2 ]; check "control: --target-pane still refuses 'bg' (board.sh gained no third form)" $?
# --- 20. The ORDRE column --------------------------------------------------- # --- 20. The ORDRE column ---------------------------------------------------
# Two mailbox-derived columns, never summed and never merged: INN is "others # Two mailbox-derived columns, never summed and never merged: INN is "others
# are waiting on YOU", ORDRE is "authorized work is waiting on this REPO". # are waiting on YOU", ORDRE is "authorized work is waiting on this REPO".

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

@ -23,7 +23,7 @@ description: >-
user names no repo and no tool — choosing *between* repos is this skill. Not for user names no repo and no tool — choosing *between* repos is this skill. Not for
"where were we" inside the current repo: that is this repo's own STATE.md, "where were we" inside the current repo: that is this repo's own STATE.md,
already injected at session start. already injected at session start.
version: "0.35.0" version: "0.36.0"
--- ---
# board — which repo deserves the next session # board — which repo deserves the next session

View file

@ -15,7 +15,7 @@ description: >-
covers retiring a broadcast that has become wrong or obsolete: "retract that covers retiring a broadcast that has become wrong or obsolete: "retract that
broadcast", "that announcement is outdated, pull it", "trekk tilbake kringkastingen", broadcast", "that announcement is outdated, pull it", "trekk tilbake kringkastingen",
"den broadcasten er utdatert". "den broadcasten er utdatert".
version: "0.35.0" version: "0.36.0"
--- ---
# coord-send — natural-language front door for inter-repo messages # coord-send — natural-language front door for inter-repo messages

View file

@ -12,12 +12,16 @@ description: >-
Also triggers on Norwegian phrasings: "dispatch en økt i repo X", "start en økt 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 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 <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 oppgaven", "hvordan starter jeg neste økt på dette". Also covers the
background form, where the dispatching session starts the child itself with
`claude --bg`: "start it in the background", "run that as a background
session", "launch a bg session on this order", "start en bakgrunnsøkt i repo
X", "kjør det i bakgrunnen", "start barnet selv". Trigger even when no tool
is named — producing a runnable startup command for another session IS this 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`), 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 not for scoring model and effort alone (that is `route`), and not for sending a
message to another repo (that is `coord-send`). message to another repo (that is `coord-send`).
version: "0.35.0" version: "0.36.0"
--- ---
# dispatch — hand a session a task it can actually start on # dispatch — hand a session a task it can actually start on
@ -29,9 +33,12 @@ 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 separate misfires in one day, by two different repos — and this skill exists so
they are not re-derived a fifth time. they are not re-derived a fifth time.
**You produce the command. You never run it.** Starting a session in another **In the paste and plan-file forms you produce the command and never run it.**
repo spends the operator's quota and takes an action inside a repo this session Starting a session in another repo spends the operator's quota and takes an
does not own. Hand back the finished command and stop. action inside a repo this session does not own. Hand back the finished command
and stop. The one exception is **the background form** (its own section below):
since the operator's decision of 2026-09-22 the PM starts sessions itself with
`claude --bg`, and that form is run, not handed over.
## The engine ## The engine
@ -181,6 +188,104 @@ a pane. Hand back the `paste=` line, and say it goes in the existing tab **after
operator is already standing in that one. `morning --relaunch` bypasses 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. filter but opens a *second* tab beside the existing one, which is rarely wanted.
## The background form
**When:** the PM starts the child session itself (operator decision
2026-09-22, `~/.claude/docs/2026-09-22-tos-bakgrunnsoekter-og-natt.md`). Not
when the operator is to paste a line into a tab. That is still steps 35
above. The background form replaces steps 35 only. Steps 12 are unchanged:
the order goes into the recipient's queue first, and the id is the handle.
There is no pane question, because a background session has no pane.
The command, run by the dispatching session through its Bash tool:
```bash
cd <absolute repo dir> && claude --model <m> --effort <e> --disallowedTools AskUserQuestion,SendMessage,ListAgents --bg --name <short-name> "<starter>"
```
- **`--model` directly after `claude`, always.**
`~/.claude/hooks/pre-dispatch-budget.sh` counts a start only in that position.
Measured 2026-09-22: `claude --model … --bg --name x` is counted, but a flag
with a value in front of `--model` breaks the pattern and the start goes
uncounted. The form must not depend on the hook learning more forms. Take the
model and effort from `route.sh`'s `command=` line (score the traits as in
step 4). That line already begins `claude --model <m> --effort <e>`, so
appending the rest keeps the order by construction. A Fable override is
written by hand, in the same order.
- **`--disallowedTools AskUserQuestion,SendMessage,ListAgents`** (hardening T4,
`~/.claude/docs/2026-09-22-herding-s1-steg4.md` § 3). A background child
cannot be answered. `AskUserQuestion` was open and stopped two sessions
before this guard existed. `SendMessage`/`ListAgents` close the child→PM
direction. The PM's own messages to the child are still delivered.
- **The `cd` is right here, and only here.** The no-`cd` rule is for a line
pasted into a terminal tab, where the operator already stands in the repo.
This line is run by a session standing in another repo, and `claude --bg`
starts in the working directory, so the `cd` is what picks the repo.
- **`--name`**: a short name (`<repo>-<topic>`), so `claude agents` and
`claude logs <id>` can be read at a glance.
The starter is **one line of ASCII, with no `$`, backtick or double quote**.
It sits inside the double quotes above and is evaluated by the shell, exactly
like `board.sh`'s thin starter. `<PLUGIN_ROOT>` is the absolute install
directory the engine lines at the top of this skill resolve to (the parent of
`dirname "$ORDER"`, e.g. the version-pinned plugin cache). Write it out in
full, because a child executes the starter literally and the order verbs are
not on PATH (ORDRE 65).
```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. (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
worktree, and without `--repo` the verbs derive their identity from the git
toplevel's basename. That basename is the worktree's name, not the repo's.
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>`,
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
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`.**
If the repo tracks STATE.md/PLAN.md and the newest of those commits sits on a
branch that has not been merged, the hash is that branch's tip. Measured
wrong 2026-09-23: `d5a6bac` was given where `8bd27d8` was meant.
- **`INGEN push/tag/release`**: the child commits on its branch and stops. The
operator's push round merges, pushes, and runs `release-plugin.mjs`.
**Measured platform facts** (agent-view docs + 2026-09-22): a background
session is forced into its own worktree. It cannot write in the main checkout,
not even after `ExitWorktree`. It never merges. That is why the starter pins
the branch and forbids the merge rather than asking for either.
**What happens afterwards, and it is the PM's:**
1. **Watch the order, not the process.** The child's claim moves the order to
`~/.claude/coord/<repo>/orders/claimed/<id>.md`. Closing moves it to
`orders/archive/<id>.md` (executed), or back to `orders/<id>.md` (returned,
with the reason written into it). `claude agents` / `claude logs <id>` show
the session. The file shows whether the work reached a terminal state.
2. **Re-measure on the commit.** Read the `commit=` the child wrote into the
archived order. Then run that repo's suites at that hash yourself. The
child's report is a claim, not a measurement.
3. **The merge is the operator's.** The branch waits for the operator's push
round, which merges, pushes and releases. A background child never does
any of the three.
**Why this is not a `board.sh` rendering.** `board.sh --dispatch` is the one
generator of the plan-block format (`tab=`/`dir=`/`command=`/`paste=`), and
this form uses none of it. There is no block, no pane and no `morning`
consumer. The one generated part, model and effort, already has its single
generator in `route.sh`. So `--target-pane` stays exactly `yes|no`, and the
selftest pins that. Stated overlap: steps (1), (2) and (4) of the starter
repeat the protocol in `board.sh`'s thin starter. If the protocol changes,
both copies must move together.
## Three things that must reach the operator ## Three things that must reach the operator
Say these in the handover, not only in the plan file. Each was a real Say these in the handover, not only in the plan file. Each was a real

View file

@ -14,7 +14,7 @@ description: >-
the operator names no model and no tool — choosing the model for the next the operator names no model and no tool — choosing the model for the next
session IS this skill. Not for choosing which REPO gets the next session: session IS this skill. Not for choosing which REPO gets the next session:
that is the `board` skill. that is the `board` skill.
version: "0.35.0" version: "0.36.0"
--- ---
# route — what the next session should run with # route — what the next session should run with