repo-mailbox/CHANGELOG.md
Kjell Tore Guttormsen 123d40e4b4 fix(board): track an open HTML comment through to its closer
Skipping lines that START with the comment opener is not the same as
skipping a comment. Every continuation line of a wrapped comment under
the NESTE heading still looked like prose, so the excerpt became comment
internals instead of the next step. Both the board line and the route
line wrap easily; measured, 1 of 28 repos was affected, with the symptom
held down only by the convention of keeping those comments one-line.

An open comment is now tracked to its closer, and a NESTE block that is
nothing but a comment reports an empty block rather than promoting the
comment's own text.

Two limits are the format, not leftovers: an HTML comment body may not
contain the closer at all, so a rationale quoting it still ends its own
comment early.

The fixture is the real shape - one-line, wrapped, one-line, prose -
because a lone wrapped comment passes even with a flag that never
resets, and the continuation marker sits at the start of its line
because the table's 38-char cut would otherwise truncate it away and
the check would pass against the broken code too.

board-selftest 138 -> 142.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014K262DRWBJzEpWoMsBjB8J
2026-08-03 09:53:25 +02:00

932 lines
49 KiB
Markdown

# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.20.1] - 2026-08-03
### Fixed
- **A multi-line HTML comment under the NESTE heading no longer leaks into the
next-step excerpt.** `board.sh` skipped lines that *started* with the comment
opener, which is not the same thing as skipping a comment: every continuation
line of a wrapped comment still looked like prose, so the excerpt became
comment internals — `scope=local; rationale=...` where the next step should
have been. An open comment is now tracked through to its closer. Both the
board line and the route line wrap easily, and the affected repo was this one
(1 of 28 measured), where the symptom was held down only by the convention of
writing those comments on a single line.
Two limits are the format, not leftovers. An HTML comment body may not contain
`-->` at all, so a route rationale that quotes the closer still terminates its
own comment early — no fix here can change that. And a NESTE block consisting
of nothing but a comment now reports an empty block rather than promoting the
comment's own text, which is what it always should have said.
`board-selftest.sh` 138 → 142 checks. The fixture is the real shape on
purpose — one-line comment, wrapped comment, one-line comment, prose — because
a lone wrapped comment would pass even with an open-comment flag that never
resets. The continuation marker sits at the start of its line for the same
reason: further in, the table's 38-character cut would truncate it away and
the check would pass against the broken code too.
## [0.20.0] - 2026-08-03
### Changed
- **`--plan` ranks on FIVE ORDERED GROUPS, replacing the weighted score shipped
one version earlier.** The groups are chain-root credit, unhandled inbox,
`planned`, `in-progress`, and undeclared status — each a lookup over a field
the scan already read, so a selftest pins an outcome rather than a
coefficient. Within a group: the quantity that group is about, then a Sonnet
`next-cost`, then oldest plan first.
The objection the score answered is **accepted, not forgotten**, and is
recorded in `board.sh` and `CLAUDE.md` so it is read as decided rather than as
an unfixed defect: a group order genuinely cannot express "owes one message
**and** releases two others" as a single quantity. What the score could not do
was hold still for the format's second consumer — re-tuning 40 against 15
silently reorders a parser living in another repo, and no test here can catch
that. Operator decision, 2026-08-03.
- **`planned` now ranks ABOVE `in-progress`**, inverted from every earlier
version by the same decision. A decided but unstarted next step is the step
that converts a decision into motion; live work is already moving. Flipping it
back is a policy change, not a sort fix.
- **`why=` names the group that admitted the repo**, not the dominant scoring
term. The emitted values are unchanged (`unblocks:N`, `inbox:N`, `planned`,
`in-progress`, `uavklart`), so neither consumer sees a new token — but the
chain-root group is tested first, so `why=unblocks:N` now wins for **any**
root, not only where its weight happened to dominate.
- **The Sonnet `next-cost` tiebreak introduced with the score is carried
forward deliberately**, demoted to the last key below group and magnitude. It
is a lookup, and removing a shipped property nobody asked to remove would be
the larger surprise. It can now only separate repos that are otherwise equal.
### Notes
- **Debt is still neither capped nor excluded.** Being one group below
chain-root credit is not the cap that was proposed and declined at 0.19.0: a
debtor keeps its tab, its most-owed-first position among the other debtors,
and its `why=inbox:N`. Selftest section 12 pins both halves on a
discriminating fixture — a root releasing **one** repo outranks a repo owing
**four** (which the score would have ordered the other way), and that debtor
keeps everything it had.
- `board-selftest.sh` is 134 → **138 checks**. Suite total 183 + 138 + 73 =
**394**.
## [0.19.0] - 2026-08-02
### Added
- **Chain-root credit in `board.sh --plan`.** `blocked-on` was scanned and then
used for one display string (`blocked>X`); it never reached the ordering. For
every `blocked` repo the edge is now followed **transitively** to the root of
the chain — the first repo not itself blocked — and only that root is
credited with the repos it holds up. Opening a blocked repo releases nobody;
opening the root releases everything behind it. Measured on the real tree: the
root of the one live chain moved from tab 14 to tab 1, ahead of the two repos
waiting on it.
- **`why=unblocks:N`.** `why=` now names the *dominant* scoring term rather than
the first rule that matched, so a block says what opening it would release.
Previously every planned block read `inbox:N` — the same sentence repeated.
- **`blocked-on` is a record field**, inserted before `neste` so no existing
field index moves. `neste` is free prose lifted from a STATE.md and may carry
a literal `|` from a markdown table, so it stays last.
### Changed
- **`--plan` ranks on one score, not four hard buckets.** Four groups could not
express "this repo owes one message and releases two others", which is exactly
how a chain's root ended up below the repos waiting on it:
40 x repos released transitively + 15 x inbox
+10 in-progress / +5 planned / +2 undeclared, +3 for a Sonnet row
Every term is a lookup over a field the scan already read, so the order stays
reproducible and costs no model call.
- **`--plan` header lines** now state the score and that a blocked repo never
receives credit. The format is otherwise **unchanged**: `tab=`, `repo=`,
`dir=`, `why=`, `status=`, `neste=`, `command=`/`command_missing=`, `paste=`
and the `fokus_*` header all keep their names and positions.
### Not done, deliberately
- **No cap on inbox debt.** Proposed alongside the credit and declined by the
operator: owing a reply is the other axis from a repo's own next step, and
answering is often precisely what unblocks a chain. The debt weight is 15 —
above the sum of every other bonus (13) — so one message more still outranks
any combination of status and cost, and the bonuses only separate repos that
owe the same. At the proposed 12 they could have flipped it, which would have
weakened the debt order through a tiebreak.
## [0.18.0] - 2026-08-02
### Added
- **`--focus "<prose>"` — the plan's first cutoff, shipped with the disclosure
that was its precondition.** `--plan` narrows to the repos whose `STATE.md`
*declares* a topic marker (`<slug>: <status>`) matching the operator's prose.
Measured on the real tree: 26 blocks to 6.
The filter alone was never the feature. `--plan` documents that it takes one
position (the order), hides nothing, and labels what it cannot rank rather
than dropping it — so a silent filter would break a property the format had
already written down. The same run therefore prints `fokus=` (the slugs the
prose resolved to), `fokus_droppet=` (blocks removed), `fokus_utenfor=` (the
repos that *mention* a resolved slug with no marker line, **named**) and
`fokus_rekkevidde=` (how many `STATE.md` were searched). Every surviving
block carries `fokus_treff=`, the declaration it survived on — the per-block
evidence that closed `topics=`.
Four constraints are load-bearing rather than stylistic. The held-back class
is **enumerated, not counted**, because in the measurement behind this
feature the decisive find — a heavy consumer pinning the library in its build
file — was invisible to every string measurement and appeared only once the
population was listed. The wording says **"nevner", never "dekker"**: this
reports text found in a file, and `board.sh` has no grounds for a claim about
relevance. The reach line states **which files were searched**, because one
repo's strongest evidence lives in a `README` this scan never opens. And the
slug vocabulary comes from the scanned `STATE.md` themselves, so the
"reads `STATE.md` and no other file" invariant survives — `register.md` owns
the grammar, this only ever reads a declaration written in it.
The reader accepts the marker as operators actually write it (bulleted, bold,
backticked), not only the strict form the register's own grep looks for —
that grep losing a bold declaration is precisely the class that hid the
decisive find.
Prose matching no declared slug prints the **full** plan plus
`fokus_ikke_brukt=`. The driver forwards operator prose verbatim, so a typo
must not silently produce a morning with no tabs. Ambiguous prose widens to
the union and names every slug, since picking one silently would make the
cutoff lie about its own size.
Emitted as `key=value` rather than `#` comments deliberately: the format's
second consumer drops comment lines by rule, so a disclosure written as a
comment would reach the operator on the terminal path and vanish on the
driver path — the one case where the cutoff would be applied unseen.
`board-selftest.sh` 89 -> 114 checks. The narrowing check is comparative
(focused block count strictly below unfocused, and non-zero): every
presence-only assertion passes against a no-op filter, which is exactly the
state the flag was in while it was being swallowed by the unknown-argument
fallback.
## [0.17.0] - 2026-08-02
### Added
- **`paste=` in every `--plan` block — the operator's line.** `--plan` shipped
with `dir=` and `command=` as separate fields, which is correct for a driver
(it moves the pane itself, then types the command) and unusable for a person.
The first operator to read a block could not act on it: joining an absolute
path from one field with a command from another is not a saved line of
output, it is the step where a session gets started in the wrong repository.
`paste=` is the two already joined — `cd <dir> && <command>` — one thing to
select and paste. Both forms stay, because the two consumers want the same
fact shaped differently and neither shape serves both.
It is emitted **only alongside `command=`**, never once per block: a
`paste=cd X && ` with nothing after it would run the `cd` and then a bare
newline, which fails silently by leaving the operator in the right directory
with no session started and no error to notice.
- `board-selftest.sh`: 86 -> 89 checks.
## [0.16.0] - 2026-08-02
### Added
- **`board.sh --plan` — the day plan, a THIRD rendering of the same scan.** The
table says what the state of every repo is; the briefing says who is waiting;
the plan says which repos to open a tab for today, in what order, with which
command to start each. Built on exactly the argument `--brief` was: it is a
lookup over data the scan already holds, it costs zero model calls, and
`route.sh` already derives the per-repo command.
**The order is the position it takes, and the only one.** There is no cutoff,
so nothing is hidden, and four deterministic rules decide the ranking:
1. **Repos owing mail come first, most-owed first — whatever their status.**
This is the rule most likely to be "fixed" into a defect. Excluding
`blocked` or `done` is a claim about a repo's *own next step*, which by
definition cannot be moved; owing an answer is a different axis entirely,
and answering is frequently what unblocks it. Measured on the real tree at
this version, two of 26 planned repos were `done` with an unhandled inbox,
which is why the fixture tree now pins both `done`-with-debt and
`blocked`-with-debt rather than the general rule alone.
2. `in-progress` — live work, oldest plan first.
3. `planned`.
4. Repos with no board line, ranked last and **labelled** `why=uavklart`. The
table already prints a note about these; a plan that dropped them silently
would repeat exactly that defect.
Excluded, and only when the repo owes nothing: `done`, `deferred`, `blocked`,
and repos with no STATE.md. A tab that cannot be moved is not a plan entry.
- **`key=value` blocks rather than prose, because the plan has two consumers:**
the operator pasting a command, and a separate repo driving a terminal from
it. Prose would make the rendered format an API that no test in this repo
could hold stable for a consumer living in another one. Comment lines all
start with `#`, so a consumer drops them with one rule.
Two fields carry measured constraints rather than preferences. `dir=` is
absolute because a new terminal pane inherits its anchor's working directory,
so a plan that omitted it would look right and point at the wrong repo. And a
repo whose route line is missing or unparseable gets `command_missing=` with
the reason, never an empty `command=`: a driver reading `^command=` would type
a blank line into whatever that pane is running.
### Changed
- `route_cmd_for()` is now the single reader of the route-line grammar, shared
by `--brief` and `--plan`. It distinguishes "no route line" from "a route line
`route.sh` rejects" by **exit code** rather than by an empty string, because
both must degrade to a marker and neither may become a guess. `--brief` output
is unchanged.
- `board-selftest.sh`: 51 -> 86 checks.
### Notes
- **Driving a terminal from this plan deliberately does not live here.** The
measurement in `docs/ghostty-orchestration-measurement.md` is the argument:
it would be a version-pinned, undocumented composition over a preview API
whose documented path is already broken upstream and whose regression was
closed as *not planned*, with a blast radius reaching into other repos' live
sessions. The dependency runs one way — the driver consumes the plan, the plan
never knows a terminal exists — so if that API breaks, the plan still prints.
## [0.15.0] - 2026-08-01
### Added
- **`board.sh --brief` — the nightly cross-repo briefing, a second RENDERING of
the scan `board.sh` already does, never a second scan.** The operator has
more repos than fits in memory, and the question that actually costs them is
"who is waiting on me, and what does answering cost" — `board.sh` already
scans for it, but nothing rendered it into a form an unattended job could
leave behind. `--brief` prints NESTE **uncut**: the 38-character cut is the
table column's property, not the record's, and the value used to be
truncated at record-build time, leaving the cut string as the only copy.
Each startup command is derived by **calling** `route.sh` with that repo's
own four traits — `next-cost` alone cannot produce it, since the advisor
flag is a property of the ROW and two rows can share a model/effort pair
while differing on it. A repo with no route line is told so rather than
handed a guess.
- **It cross-checks itself against `coord-count.sh`**, and that is the
substance of the change, not a nicety. The repo scan and the mailbox are two
different populations: a mailbox can carry a name no scan will ever produce
— a declared non-git surface (`CLAUDE_COORD_REPO`, e.g. `~/repos`) or a
checkout outside the roots. Measured on the real mailbox: 11 repos / 21
messages in the briefing against `coord-count`'s 12 / 22, the missing one
being the declared surface `repos`. A briefing that only walked the scan
would answer "who is waiting on you" with a number it quietly knew was
short.
- **Zero model calls**, the deciding property. Measured against 2.1.220 under
subscription auth: `--max-budget-usd` does bite (`terminal_reason:
budget_exhausted`, exit 1), but only *after* turn one — floor ~0.25
USD-equivalent per turn on `claude-opus-5[1m]`. It is a runaway brake, not a
pre-flight gate, so a nightly `claude -p` job would draw on the same quota
pool as interactive work every night. Determinism removes the question
entirely.
- **`scripts/brief-nightly.sh`** — the one writer in this path. `board.sh`
stays read-only: `brief-nightly.sh` renders to a temp file and renames it
into place, and treats an empty render as a *failed* one, since `board.sh`
prints nothing when its scan roots don't exist — what a mistyped path or a
moved home directory looks like. A plain `> file` redirect would destroy
yesterday's briefing on a bad launchd environment.
- **`launchd/com.ktg.repo-mailbox-brief.plist`** — a template carrying
placeholders, not absolute paths, since this repo is mirrored publicly. It
points at a checkout rather than the plugin cache, which is version-pinned
and would break silently on the next bump.
- `board-selftest.sh`: 36 -> 49 checks. One check pins a defect caught only by
eye against the real tree: `fold` copies its input's missing trailing
newline, which ran the command onto the tail of the NESTE prose and produced
a briefing whose commands could not be copied. Two more checks landed six
minutes after this tag, in an untagged follow-up commit that shipped no
version bump of its own — `board-selftest.sh` had already reached 51 by the
time `0.16.0`'s own work began, which is why that entry's count starts there.
## [0.14.0] - 2026-08-01
### Added
- **`scripts/coord-sweep.sh` — the FYI backlog closes itself, deterministically
and without a model.** A notice needs no answer, but it is still re-injected at
every session start until someone closes it by hand. Across enough
repositories that hand-closing *is* the manual work the mailbox was supposed to
remove, and the pending count — the operator's only signal — drowns in messages
that were never going to be acted on. At the time of writing, 9 of 22 pending
messages across 12 mailboxes were pure notices.
The sweep closes exactly one mechanically decidable class: a directed message
whose sender declared `reply-expected: no`, older than a grace window (default
14 days, `--days`). **A message that owes a reply is never touched, at any age,
with any flag** — answering it would mean deciding something on the receiving
repo's behalf, which is the one thing this system exists to prevent a session
from doing.
Four properties are load-bearing rather than incidental:
- **Dry-run is the default**, inverted from every other script here. The others
print or deliver; this one destroys pending state, so the safe direction has
to be the one you get by forgetting a flag.
- **Closing goes through `coord-done.sh --repo`, never `mv`.** The engine owns
the filename grammar and the archive layout, and it already refuses
`_broadcast` on its own — archiving out of there would retire an announcement
for every repo that has not read it yet, an unauthenticated retract.
- **Age is read from the filename prefix, never from the file.** A name the
grammar does not produce has no readable age, and an unreadable age is never
treated as old: fail-safe, not fail-open.
- **Every closure is logged** (`$CLAUDE_COORD_DIR/_sweep.log` by default) with
sender and subject. This is not bookkeeping. A directed message has no
seen-tracking — only broadcasts do — so the sweep cannot distinguish "seen and
ignored" from "never delivered", and a notice to a repo left unopened for the
whole window is closed **unread**. That is an accepted tradeoff; the log is
the only thing standing between it and a silent disappearance.
The `reply-expected` read is bounded to the frontmatter block, matching
`coord-count.sh`: a body line claiming `reply-expected: no` at column 0 is
untrusted cross-repo input and must not be able to close its own message. That
is the one place where getting it wrong would let another repo delete its way
out of your inbox.
Scoped deliberately: no scheduler, no launchd unit, no skill front door. The
script does nothing until invoked.
- Selftest 159 → 182 checks (new section 29). The log check caught a real defect
during development — the first implementation read `from`/`subject` *after*
`coord-done.sh` had already moved the file, logging empty values and quietly
defeating the only safeguard the design has.
## [0.13.0] - 2026-07-31
### Added
- **`route.sh` now emits `--advisor opus` into the startup command, per row and
on a need.** The advisor is a second, stronger model consulted at key moments
during a session; Claude Code exposes it both as a launch flag and as the
global `advisorModel` setting that `/advisor` writes. The setting was tried
and abandoned here because it applies to every session in every repo and
burned quota accordingly — but nothing replaced it, so the flag went
effectively unused and `route.sh` never mentioned it. Two independent
triggers now decide it:
- **rows 1-2, always.** The main model is Sonnet, so `opus` is a capability
*lift* rather than a peer — Opus judgement at Sonnet cost. This half is
load-bearing: every `fallback-command` is one row cheaper and the cheapest
rows are the Sonnet ones, so this is what makes the quota fallback safe to
take.
- **rows 3-4, only at `reversibility=costly|one-way`.** The main model is
already Opus, so the advisor buys peer review — worth paying for when a
mistake is not cheap to undo.
- **rows 5-6, never.** Not a judgement call: the CLI rejects every advisor
for a Fable main model, and refuses `opus` as under-capable for one.
The two triggers are almost disjoint by construction, since `costly` forces
row 3 and `one-way` forces row 4 — so a Sonnet row always has
`reversibility=cheap`, and neither rule can reach the other's rows.
`verification=none` is deliberately **not** a third trigger: beyond the stakes
rule the only cases it would add are mistakes that are cheap to reverse, docs
sessions (`known/none/cheap/local`) among them. The decision is applied per
ROW rather than once, so `fallback-command` carries its own correct answer.
`route-selftest.sh` grows 17 checks (56 -> 73). Section 14 pins the rule and
gates the three CLI facts it rests on against the installed `claude` without
spending a token: advisor validation runs before the empty-prompt check, so
`-p ""` reaches the validator and stops there. `--help` cannot serve as that
gate — it short-circuits before option validation, so even an unknown flag
exits 0 and the gate would pass without testing anything.
`skills/route/SKILL.md` gains the two failure modes worth naming: paste
`command` verbatim rather than dropping the advisor as noise, never add one to
a command that came back without it, and remember that `--advisor` is a launch
flag — `/clear` reuses the process and keeps whatever advisor the session
started with, so it is not enough when the advisor changes.
## [0.12.1] - 2026-07-31
### Fixed
- **`coord-send`'s own script-path expression fell back to a stale deployed
copy.** A Bash tool call never has `CLAUDE_PLUGIN_ROOT` set as a real shell
variable — only the skill's own markdown rendering resolves that bare
token — so the `${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}` fallback silently won
every time the engine line was actually executed, routing through whatever
the operator's personal `~/.claude/scripts/coord-send.sh` happened to be
instead of the plugin's own bundled script. Same defect class `board.sh`
was already fixed for. Reported live by org-ops: a `--fyi` send hit an old
deployed copy lacking the flag. `coord-selftest.sh` grows three checks
(156 -> 159) pinning the fix.
## [0.12.0] - 2026-07-31
### Fixed
- **The last-session record asked the operator for the one field it could
measure.** `route-last` (0.10.0) exists to make the routing policy
falsifiable — a policy that always answers "Opus" is not a policy — and it
only is if `--last-effort` reports what the session actually ran with.
`route.sh` documented the opposite as settled fact: that a session cannot
observe its own effort from the inside. That was true when written and is not
now. Claude Code exports `CLAUDE_EFFORT` into every tool-use context as the
session's current effort, so a single Bash call reads it.
**The premise had a cost, and it was the record's weakest field.** With effort
unobservable, the record could only be completed by asking the operator at
session end, which made all four fields block on their presence. It was also
the weaker measurement, in exactly the way the previous board line is: the
operator reads the effort off the startup command they typed, so both sources
report what was *prescribed* rather than what was *run*. Those come apart
precisely when the record would be worth having — as a session that silently
ran `xhigh` under a board line saying `high` already showed. Reading the
variable makes all four fields knowable from inside the ending session, so the
record no longer waits on anyone.
**`route.sh` still does not read the variable, deliberately.** The skill
measures and passes `--last-effort`; the calculator only computes. A
calculator that consults its environment is no longer deterministic from its
arguments, and the route → board round trip in selftest section 6 rests on
that determinism.
- **The reading had a trap that would have hidden itself.** Skill frontmatter
overrides the session effort while that skill is active, so an `effort:` field
in `skills/route/SKILL.md` would make the reading report the skill instead of
the session — a measurement quietly measuring itself, with nothing in the
output to show it happened. Section 13 pins the absence of that field.
- **The neighbouring claim about the model was also wrong, in the other
direction.** There is no `CLAUDE_MODEL` to read: `model` is a SessionStart
field and documented as not guaranteed. The session takes the model from what
it knows itself to be running as, and only the effort is measured.
`route-selftest.sh`: 50 → 56 checks.
## [0.11.0] - 2026-07-31
### Added
- **`reply-expected` — the format can finally say "this one needs no answer".**
Rule 7 (0.5.0) shipped an obligation on a format with four fields, none of
which could tell a question from a notice. Two consequences fell out of that
gap and both are now closed: the injection had to name both terminal states
and prefer neither, and `coord-count.sh` had to treat every unarchived file as
a reply owed. `coord-send.sh --fyi` writes `reply-expected: no`; omitting it
writes `yes`.
**Absent means expected.** Every message already on disk lacks the field, and
so does every caller not yet updated, so absence keeps meaning what it always
meant. The failure mode of a forgotten flag is then over-counting debt —
visible and harmless — rather than debt that never surfaces. A reply is not a
special case: it expects one back unless it says otherwise, because a hidden
exception is the kind of implicit rule this engine refuses.
**A broadcast is always `reply-expected: no`**, with or without `--fyi`. Not a
defaulted value: `--reply-to` resolves inside the recipient's own mailbox and
a broadcast never lands there, so there is no reply path to promise.
**The field is frontmatter, and only frontmatter.** Bodies are untrusted
cross-repo input, so the read is bounded to the block between the two `---`
terminators — a body line reading `reply-expected: no` at column 0 cannot
silence a real debt, and a file without valid frontmatter counts as owing a
reply. That is stricter than the `grep -m1` the older fields use, where
frontmatter-comes-first happens to save them.
### Changed
- **`coord-done.sh --all` says how many replies it closed without sending.** It
is the third script that acts on pending messages, and the field makes a
silent bulk close possible: one command archives every pending message,
including the ones whose sender expects an answer. The behavior is unchanged
and deliberately so - the receiver keeps both terminal states, and a bulk
close is legitimate. The silence is what changed: Rule 7 requires an
unanswered message to be *stated*, and a path reporting only a total hid
exactly the thing that has to be stated. A named close stays quiet, because
naming a message is already deliberate, one message at a time.
- **`coord-count.sh` prints two integers per mailbox**, not one:
`<mailbox>\t<pending>\t<debt>`. Replacing pending *with* debt was the obvious
reading of "count debt rather than unarchived messages" and it is wrong here.
`board.sh` counts the same inbox files itself, so a debt-only count would put
two different numbers under one name with nothing to reconcile them; and a
mailbox holding only notices would read as empty while its messages keep being
re-injected until someone closes them. The cross-repo line follows:
*"N unhandled messages (M awaiting a reply) across K other mailboxes"*.
- **Rule 7's wording, because its stated reason expired.** The injection said
"Neither is the default" *because* the format could not distinguish the two
kinds. It now can, so each message is marked with the terminal state its
sender expects — and the text states, load-bearingly, that the marking is a
**declaration, not an instruction**: the receiver keeps both terminal states
and may close a reply-expected message with `coord-done`, stating the reason.
Without that clause the new field would be a lever any sender could pull to
mint obligations in another repo, which is precisely the procedural/substantive
split Rule 6 exists to hold. Selftest section 20 pins both halves together, as
it has since 0.5.0.
## [0.10.0] - 2026-07-31
### Added
- **`route.sh` + the `route` skill - the writer for the board's cost field.**
`next-cost` had a reader and no writer, so it was retyped by hand every
session; measured across the operator's tree, the existing board lines carried
eight distinct spellings of a field with one documented form. `route.sh` is a
pure calculator: four scored traits of the next task (`path`, `verification`,
`reversibility`, `scope`) plus a required rationale, and it prints the rubric
row, the `next-cost` value, a pasteable startup command and the one-row-cheaper
fallback. Escalation is asymmetric - any single trait escalates, the cheapest
row needs all four at the cheap end. The row table is a closed set of six, so a
seventh spelling cannot enter circulation, and selftest section 6 runs the
round trip (route emits, board parses) inside one repo instead of across two.
The table is the operator's global model rubric, moved here as the single
copy: two copies of one policy drift, and a table in a prose file cannot be
tested. Scoring is judgement and stays in the skill; turning scores into a row
is a lookup and costs no model calls.
### Fixed
- **The board line's `next-cost` field had two specs, and one of them was
ours.** `board.sh --help` prescribed a versionless `sonnet/xhigh` while the
global model rubric it points at spells the model `Sonnet 5 / xhigh`. Neither
a session following the help nor one following the rubric was doing anything
wrong, so the field drifted: measured across a real 44-repo tree, the 14
existing board lines carried **eight** distinct strings — and not one used the
form documented here. The help now prescribes the rubric's spelling
(`Sonnet 5/xhigh`), which is also the one most lines already use.
**The parser is unchanged and stays lenient.** It still reads to `;` or
`-->` rather than stopping at the first non-lowercase character, precisely so
the rubric's spacing survives; the selftest fixtures still vary the spelling
on purpose to pin that. Leniency on the read side was never the defect — it
is what makes a documentation-only fix sufficient.
**No normalization was added, deliberately.** Board lines are rewritten every
session by convention, so an unambiguous spec self-heals the existing lines
within about one session per active repo. Mapping variants at read time would
be permanent code migrating data that migrates itself, and nothing compares
the field mechanically today: `KOST` is a display column, and the board sorts
on a numeric bucket key. Two new `board-selftest.sh` checks (30 total) pin the
documented form, because a spec that contradicts the rubric regenerates the
divergence no matter how often the data is cleaned up.
## [0.9.0] - 2026-07-28
### Added
- **`board.sh` + the `board` skill — the cross-repo attention board.** The
mailbox answers "who wrote to me"; it never answered "which repo deserves the
next session". `board.sh` scans every discovered repo and reads three sources
per repo: the STATE.md next-step block with its optional board line, `git
status`, and that repo's pending mailbox count. Read-only by construction — it
writes to no repo, no STATE.md and no mailbox — and pinned by
`board-selftest.sh` (28 checks against a throwaway repo tree and a throwaway
mailbox).
**Why it ships here rather than as a personal script.** The mailbox is one of
its three inputs, and the two carry the same axis distinction: a repo's pending
count means *others are waiting on it* (an obligation owed outward), while who
a repo waits *on* is only derivable from its own board line, because the
message format has no reply-to or thread field. Splitting the board from the
mailbox would put that distinction in two places, and a rule enforced in one of
two places is not a rule. It also means the engine resolves through
`CLAUDE_PLUGIN_ROOT` like every other script here, instead of existing only on
the author's machine.
The skill is a ranking, not a report: it re-runs the board on every invocation
(counts drift between turns), ranks by what unblocks the most and what is
cheapest to move, then names one repo, the rule that fired, and the real next
action read from that repo's STATE.md — never the table. Deliberately not wired
into session start: injecting the whole board into every session spends context
on repos the session is not in.
## [0.8.0] - 2026-07-27
### Added
- **`coord-count.sh` — counting without delivering.** Reading *is* delivery in
this engine: `coord-inbox.sh` prints a broadcast and then records it as seen,
so there was no way to ask "what is pending" without answering it. Asking per
repo would have consumed every repo's broadcast backlog as a side effect —
once, silently, and unrecoverably, since the seen set is delivery history that
retraction deliberately leaves alone. `coord-count.sh` counts files instead
and writes nothing: no seen set, no `.origin`.
It keys on **mailboxes**, not repos. It enumerates `$COORD/*` and never scans
a filesystem for checkouts, so a repo without a mailbox is not missing from
the count — it is absent from the domain. Drained mailboxes are omitted rather
than reported as zero, the reserved `_` namespace is skipped, and `--exclude`
drops one mailbox (the caller's own).
- **A cross-repo line in the SessionStart injection.** A session whose own inbox
was empty saw silence and concluded "all clear" while mail sat unanswered in
every other mailbox. The read path now appends one aggregate line:
Elsewhere in the mailbox: 3 unhandled messages across 2 other mailboxes.
Counted, not delivered -- none of it is yours to handle here. Run
coord-count for the per-mailbox breakdown.
Deliberately an **aggregate of two integers, not a roster**. A list of names
would reproduce every other repo's situation inside this repo's injection —
the state boundary the mailbox exists to respect — and mailbox names are
cross-repo input. Two integers cannot carry anything that escapes framing.
The disclaimer is engine behavior, not politeness (Rule 7): the line lands
directly beneath *"handle this inbox FIRST"*, and without it the numbers read
as an extension of that obligation. Pinned in selftest section 26, exactly as
section 20 pins the priority text.
### Changed
- **BREAKING (injection contract): the read path is no longer silent whenever
*this* repo has nothing pending.** It is now a silent no-op only when the
whole mailbox is empty. The empty-inbox session is precisely the one that
needed the cross-repo line, so coupling the line to having your own mail would
have hidden it from its only real audience. Three selftest assertions that
used "no output at all" as a proxy for "nothing was delivered" now assert the
absence of the content itself, which is what they always meant.
## [0.7.0] - 2026-07-27
### Added
- **`CLAUDE_COORD_REPO` — declaring an identity for a non-git surface.** 0.6.0
removed the working-directory fallback, which was right, but it left every
non-git working surface (`~/repos`, `$HOME`) with nothing to derive from: the
read path declines silently, so such a surface simply stops seeing its inbox,
with no error and no exit code. That is the same loss-looks-like-normal shape
0.6.0 set out to remove. The hook now reads `CLAUDE_COORD_REPO` and forwards
it verbatim as `--repo`, so a directory can *declare* which mailbox it owns:
{ "env": { "CLAUDE_COORD_REPO": "repos" } }
This is not the fallback returning. The fallback guessed a name from wherever
the session happened to stand; a declaration is written down, readable back,
and deletable. Because it forwards as `--repo` it inherits every engine rule,
including that an explicit override never claims `<repo>/.origin` — otherwise
a surface borrowing a name could steal the claim from the checkout that owns
it. Both halves are pinned by tests.
## [0.6.0] - 2026-07-26
### Fixed
- **`_broadcast` is a reserved namespace, not a repo.** `coord-send` guarded
retraction with a sender check, but that guard only covered the door it was
nailed to: `coord-done --repo _broadcast <file>` archived a broadcast out of
the queue — a full unauthenticated retract of an announcement for every repo
that had not read it yet. The whole `_` prefix is now reserved, in every CLI,
so a later `_seen` or `_config` cannot reopen the hole.
- **Repo identity is derived, never invented.** The fallback to
`basename(pwd)` is gone. It existed so the CLI would work anywhere, but
"anywhere" includes every global surface: a session in `~/repos` is not a
repo, and the fallback silently handed it the identity `repos` — under which
real mail was delivered. git toplevel or an explicit `--from`/`--repo` are
now the only sources. Write paths exit 2 and name the fix; the read path
declines silently, because the hook runs at every session start and must
never fail a session.
- **Broadcast delivery is recorded only after the injection is written.**
Marking inside the read loop left a window where the seen set said
"delivered" while the operator saw nothing — and the hook runs under
`timeout: 10`, so the window was reachable. A lost broadcast is
unrecoverable by design (the seen set is delivery history, and retraction
deliberately leaves it alone), so the failure mode is now redelivery.
- **The hook no longer resolves identity at all.** It was a fourth independent
copy of the identity rule and the only one that runs in production, so
passing `--repo` bypassed the engine's guards exactly where they mattered —
and suppressed the new collision check along with them.
### Added
- **Mailbox-collision warning.** Two checkouts with the same directory name
share one mailbox. Re-keying identity would break every existing mailbox and
the readable `--to <repo>` addressing, so the first git-derived read records
the claiming path in `<repo>/.origin`, and a read from a different path is
reported in the injection. A warning, not a refusal: the same repo moved or
re-cloned is the ordinary case. It goes in the injection because the hook
discards stderr, and a warning nobody can see is not a warning.
### Changed
- Selftest 93 → 116 checks; three Node tests now cover the hook behaviorally
rather than by reading its source.
### Breaking
- `coord-send` and `coord-done` exit 2 outside a git repository instead of
naming themselves after the working directory. Pass `--from`/`--repo` to
choose an identity explicitly. Repo names beginning with `_` are refused
everywhere.
## [0.5.0] - 2026-07-25
### Changed
- **The inbox is a priority, not a suggestion (Rule 7).** Through 0.4.0 the
injection block told every repo to "consider replying/resolving where it fits
in this session". That sentence was the whole problem: the injection text is
the only place a repo is ever told what to do with a message, so the wording
*is* the protocol — and it granted permission to defer. Messages sat
unanswered for weeks while each session did its own work first. Nothing was
broken; the protocol was asking for exactly what it got.
The block now states an ordering and a completion obligation:
- **Handle the inbox first**, before the task the session came to do — "not
after it, not *if there is time*".
- **Every directed message must reach a terminal state before the session
ends:** `coord-send --reply-to` or `coord-done`.
- **Neither terminal state is the default.** The format has no
reply-expected field, so mandating only the reply would manufacture
traffic for messages that merely inform.
- **Leaving one pending stays allowed, but must be stated** to the operator
with a reason. The escape hatch is explicit rather than silent.
- **Raising priority deliberately does not widen the trust boundary.** The
obligation is procedural, never substantive: *responding* is mandatory,
*complying* with what a message asks is not — only the operator authorizes
that. Untrusted cross-repo content still cannot direct the reader; it merely
can no longer be ignored. The injection states both halves, and the selftest
pins them together so a future reword cannot keep the priority and quietly
drop the distinction.
- Selftest grew from 82 to 93 checks (new section 20 pins the wording contract:
the permissive phrasing is gone, ordering and completion are stated, both
terminal states are named with neither as default, responding-vs-complying is
kept distinct, and the untrusted-data framing survives).
## [0.4.0] - 2026-07-25
### Added
- **`coord-send --retract <filename>`: a broadcast can finally be retired.**
Until now nothing could remove a message from `_broadcast/inbox/`:
`coord-done` is directed-only and never touches the broadcast queue. The
backlog could therefore only grow, and every *new* repo received the entire
standing history at its first session — including announcements that had
since become false. Retract moves the message to `_broadcast/archive/`, so
no future repo is served it.
Deliberate limits, both documented and pinned by tests:
- **Un-send, not recall.** Repos that already received the broadcast keep
it; `_broadcast/seen/` is delivery history and is left untouched.
- **Only the sender may retract** (`from:` must match the repo identity).
`--from` overrides this, as it does everywhere else in the engine, which
makes the check an accident guard rather than a security boundary.
- **Nothing is deleted**, mirroring `coord-done`. Retracting twice is a
no-op (exit 0).
- Selftest grew from 70 to 82 checks (new section 19 covers retraction:
authorization, archiving, non-delivery to new repos, no collateral damage to
other broadcasts, invalid names, flag combinations, and idempotency).
### Fixed
- README's install command still said `claude plugin install
coord@ktg-plugin-marketplace` after the v0.3.0 rename.
- README told the reader to prune `_broadcast/inbox/` by hand when a notice
stopped being relevant — advice that contradicted the rule that the script
owns mailbox files. `--retract` is now the supported path.
## [0.3.0] - 2026-07-25
### Changed
- **Renamed the plugin and repository from `coord` to `repo-mailbox`.** The old
name said that something was coordinated but not what the thing was; the new
one names what it is, using the same vocabulary the code and docs already use
throughout (mailbox, inbox, archive, broadcast). The Forgejo repository moved
from `open/coord` to `open/repo-mailbox` (the old URL redirects), and the
marketplace entry, plugin manifest and package name follow.
**This is breaking for installed users:** the skill is now invoked as
`/repo-mailbox:coord-send` rather than `/coord:coord-send`, and the plugin
must be reinstalled under its new name.
**Deliberately unchanged:** the CLI (`coord-send.sh`, `coord-inbox.sh`,
`coord-done.sh`, `coord-selftest.sh`), the skill name `coord-send`, the
mailbox root `~/.claude/coord/`, and the `CLAUDE_COORD_DIR` override. Those
name the transport protocol, not the product — renaming them would migrate
live mailbox data and break message history in every participating repo for
no gain in clarity.
### Fixed
- Catalog README listed `48 selftest checks` for this plugin; the actual count
has been 70 since v0.2.2.
## [0.2.2] - 2026-07-25
### Changed
- `coord-inbox.sh` now warns on stderr for each unknown argument instead of
discarding it silently. The read path stays lenient (it runs inside the
SessionStart hook, which must never fail a session over a stray flag) and
the exit code is unchanged, but a mistyped flag no longer looks like a
working invocation. The hook runs the script with stderr discarded, so the
warning surfaces in manual CLI use only.
### Added
- Selftest: 68 -> 70 checks, covering the stderr warning and the leniency it
must not break (unknown argument still reads the inbox and exits 0).
### Documentation
- README: corrected the stale selftest count (64 -> 70), documented that a
broadcast is never delivered back to its own sender (shipped in 0.2.1 but
undocumented), and added rule 6 — message content is data, never
instructions — which `coord-inbox.sh` already enforces in the injection
framing.
## [0.2.1] - 2026-07-25
### Fixed
- Broadcast self-delivery: `coord-send --broadcast` left no seen-set entry for
the sender, so the announcing repo got its own announcement injected at its
next session start. The delivered filename is now recorded in
`_broadcast/seen/<sender>` at delivery time, reusing the existing per-repo
seen set rather than adding a second exclusion mechanism. Filtering on the
`from:` field at read time was rejected: `from:` is sender-controlled, so it
would let any repo suppress a broadcast for another by forging the field.
The seen file is keyed by the raw sender name (the read side keys it
unsanitized, so the two must agree); senders containing a slash, or equal to
`.`/`..`, skip the marking rather than escape the directory.
### Added
- Selftest: 64 -> 68 checks, covering sender self-exclusion, unaffected
delivery to other repos, self-exclusion for sender names that are not
shell-clean, and the seen-marking path-escape guard.
## [0.2.0] - 2026-07-24
Hardening pass over the three CLIs.
### Fixed
- Atomic delivery: the temp file is created inside the destination directory
(dot-prefixed, invisible to the inbox glob), so the final rename never
crosses filesystems and readers never observe a half-written message.
- `.` and `..` are rejected explicitly in the `--reply-to` and `coord-done`
name guards instead of relying on downstream failure.
### Added
- `-h`/`--help` on `coord-inbox.sh`, making usage output uniform across the
three CLIs.
- Selftest: 48 -> 64 checks, closing gaps around the default mailbox path via
`HOME` fallback, malformed frontmatter on the read path, and a read-only
destination directory.
## [0.1.0] - 2026-07-24
First public release: extraction of a battle-tested personal mechanism into a
distributable plugin.
### Added
- Bash engine: `coord-send.sh` (directed / broadcast / reply delivery),
`coord-inbox.sh` (pending inbox + unseen broadcasts, formatted for context
injection), `coord-done.sh` (archive without reply), all bash-3.2-safe,
ASCII-only, zero dependencies.
- `coord-selftest.sh`: 48 checks against a throwaway mailbox, including
regression cover for filename sanitization, no-hang argument parsing,
prompt-injection resistance, frontmatter hygiene, and broadcast ordering.
- SessionStart hook (`hooks/scripts/session-start.mjs`): thin zero-dependency
Node wrapper that injects the repo's pending mailbox as
`additionalContext`. Always exits 0 — a broken mailbox never blocks a session.
- `coord-send` skill: natural-language front door (English + Norwegian
triggers) mapping intent to engine invocations, including bounded
multi-target loops, deferred sends, and replies.
### Fixed
- Frontmatter injection: CR/LF and control characters in `--subject`/`--from`
are collapsed before writing, so fields can no longer inject frontmatter
lines or a premature `---` terminator.
- Broadcast loss: a per-repo seen set (`_broadcast/seen/<repo>`, one delivered
filename per line) replaces the single high-water mark, which silently
dropped a same-second broadcast whose filename sorted below one already
seen. Upgrade note: a legacy watermark file is read as a one-entry seen
set, so old broadcasts may be re-delivered once.
### Changed
- Read-side protocol strings are English (`--- message:`, `-> reply:`,
UNTRUSTED DATA framing); the forgery-resistance selftest checks pin the
English tokens.