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 meant to remove, and the pending count -- the operator's only signal -- drowns in messages that were never going to be acted on: 9 of 22 pending messages across 12 mailboxes were pure notices when this was written. coord-sweep.sh closes exactly one mechanically decidable class: reply-expected: no, older than a grace window (default 14 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. Four properties are load-bearing, not incidental: - Dry-run is the default, inverted from the rest of the engine. The others print or deliver; this one destroys pending state, so the safe direction has to be what you get by forgetting a flag. - Closing goes through coord-done.sh --repo, never mv, so the archive layout and the _broadcast refusal stay in one place. - Age is read from the filename prefix, never the file. An unreadable age is never treated as old: fail-safe, not fail-open. - Every closure is logged with sender and subject. Directed messages have no seen-tracking, so the sweep cannot tell "seen and ignored" from "never delivered" -- a notice can be closed unread, and the log is the only thing standing between that and silent data loss. The reply-expected read is bounded to the frontmatter block, matching coord-count.sh: a body line claiming it at column 0 is untrusted cross-repo input and must not close its own message. No scheduler, no launchd unit, no skill front door -- the script does nothing until invoked. Selftest 159 -> 182. The log check caught a real defect during development: the first implementation read from/subject AFTER coord-done.sh had moved the file, logging empty values and quietly defeating the only safeguard the design has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uwcak9j4m9JijDKeFmptww
32 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[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, nevermv. The engine owns the filename grammar and the archive layout, and it already refuses_broadcaston 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.logby 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-expectedread is bounded to the frontmatter block, matchingcoord-count.sh: a body line claimingreply-expected: noat 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/subjectaftercoord-done.shhad already moved the file, logging empty values and quietly defeating the only safeguard the design has.
[0.13.0] - 2026-07-31
Added
-
route.shnow emits--advisor opusinto 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 globaladvisorModelsetting that/advisorwrites. 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 androute.shnever mentioned it. Two independent triggers now decide it:- rows 1-2, always. The main model is Sonnet, so
opusis a capability lift rather than a peer — Opus judgement at Sonnet cost. This half is load-bearing: everyfallback-commandis 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
opusas under-capable for one.
The two triggers are almost disjoint by construction, since
costlyforces row 3 andone-wayforces row 4 — so a Sonnet row always hasreversibility=cheap, and neither rule can reach the other's rows.verification=noneis 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, sofallback-commandcarries its own correct answer.route-selftest.shgrows 17 checks (56 -> 73). Section 14 pins the rule and gates the three CLI facts it rests on against the installedclaudewithout spending a token: advisor validation runs before the empty-prompt check, so-p ""reaches the validator and stops there.--helpcannot 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.mdgains the two failure modes worth naming: pastecommandverbatim rather than dropping the advisor as noise, never add one to a command that came back without it, and remember that--advisoris a launch flag —/clearreuses the process and keeps whatever advisor the session started with, so it is not enough when the advisor changes. - rows 1-2, always. The main model is Sonnet, so
[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 hasCLAUDE_PLUGIN_ROOTset 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.shhappened to be instead of the plugin's own bundled script. Same defect classboard.shwas already fixed for. Reported live by org-ops: a--fyisend hit an old deployed copy lacking the flag.coord-selftest.shgrows 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-effortreports what the session actually ran with.route.shdocumented 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 exportsCLAUDE_EFFORTinto 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
xhighunder a board line sayinghighalready showed. Reading the variable makes all four fields knowable from inside the ending session, so the record no longer waits on anyone.route.shstill 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 inskills/route/SKILL.mdwould 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_MODELto read:modelis 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, andcoord-count.shhad to treat every unarchived file as a reply owed.coord-send.sh --fyiwritesreply-expected: no; omitting it writesyes.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-toresolves 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 readingreply-expected: noat column 0 cannot silence a real debt, and a file without valid frontmatter counts as owing a reply. That is stricter than thegrep -m1the older fields use, where frontmatter-comes-first happens to save them.
Changed
-
coord-done.sh --allsays 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.shprints 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.shcounts 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+ therouteskill - the writer for the board's cost field.next-costhad 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.shis a pure calculator: four scored traits of the next task (path,verification,reversibility,scope) plus a required rationale, and it prints the rubric row, thenext-costvalue, 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-costfield had two specs, and one of them was ours.board.sh --helpprescribed a versionlesssonnet/xhighwhile the global model rubric it points at spells the modelSonnet 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:
KOSTis a display column, and the board sorts on a numeric bucket key. Two newboard-selftest.shchecks (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+ theboardskill — the cross-repo attention board. The mailbox answers "who wrote to me"; it never answered "which repo deserves the next session".board.shscans 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 byboard-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_ROOTlike 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.shprints 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.shcounts 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--excludedrops 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 readsCLAUDE_COORD_REPOand 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
--repoit 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
_broadcastis a reserved namespace, not a repo.coord-sendguarded 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_seenor_configcannot 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~/reposis not a repo, and the fallback silently handed it the identityrepos— under which real mail was delivered. git toplevel or an explicit--from/--repoare 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
--repobypassed 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-sendandcoord-doneexit 2 outside a git repository instead of naming themselves after the working directory. Pass--from/--repoto 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-toorcoord-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-doneis 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).--fromoverrides 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).
- Un-send, not recall. Repos that already received the broadcast keep
it;
-
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-marketplaceafter 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.--retractis now the supported path.
[0.3.0] - 2026-07-25
Changed
-
Renamed the plugin and repository from
coordtorepo-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 fromopen/coordtoopen/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-sendrather 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 namecoord-send, the mailbox root~/.claude/coord/, and theCLAUDE_COORD_DIRoverride. 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 checksfor this plugin; the actual count has been 70 since v0.2.2.
[0.2.2] - 2026-07-25
Changed
coord-inbox.shnow 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.shalready enforces in the injection framing.
[0.2.1] - 2026-07-25
Fixed
- Broadcast self-delivery:
coord-send --broadcastleft 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 thefrom: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-toandcoord-donename guards instead of relying on downstream failure.
Added
-h/--helponcoord-inbox.sh, making usage output uniform across the three CLIs.- Selftest: 48 -> 64 checks, closing gaps around the default mailbox path via
HOMEfallback, 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 asadditionalContext. Always exits 0 — a broken mailbox never blocks a session. coord-sendskill: 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/--fromare 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.