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
Replaces the weighted score shipped in 0.19.0 with five lookups: chain-root
credit, unhandled inbox, planned, in-progress, undeclared status. Within a
group: that group's own quantity, then a Sonnet next-cost, then oldest plan.
The score's objection is accepted, not forgotten, and is written into board.sh
and CLAUDE.md so a later session reads it as decided rather than as an unfixed
defect: a group order cannot express "owes one message AND releases two others"
as one quantity. What the score could not do was hold still for the format's
second consumer - re-tuning one weight against another silently reorders a
parser in another repo, and no test here can catch that.
planned now ranks above in-progress, inverted by the same decision: converting a
decision into motion is the slow step; live work is already moving.
Debt stays uncapped and never excluded. One group below chain-root credit is not
the cap declined at 0.19.0 - the debtor keeps its tab, its most-owed-first
position, and its why=inbox:N. Pinned by a discriminating fixture the score
would fail: a root releasing one repo outranks a repo owing four.
board-selftest 134 -> 138. Suite 183 + 138 + 73 = 394.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ULuFCPMNYAPNN3pAjsXQ
blocked-on was scanned and then used for a single display string; it never
reached the ordering. A blocked chain's root could therefore rank below the
repos waiting on it - measured on the real tree, the root opened at tab 14
while the two repos it held up opened at tabs 5 and 8.
--plan now ranks on one score instead of four hard buckets, because four
groups cannot express "this repo owes one message and releases two others":
40 x repos released transitively + 15 x inbox
+10 in-progress / +5 planned / +2 undeclared, +3 for a Sonnet row
Chain-root credit follows blocked-on transitively to the first repo that is
not itself blocked and credits only that root. Opening a blocked repo
releases nobody - its own next step is by definition waiting. A cycle, a
blocked-on naming an unscanned repo, and a blocked repo with no target all
credit NOBODY: inventing a root there yields a plan that looks correct and
sends the operator to the wrong repo.
Debt is deliberately uncapped. A cap was proposed alongside the credit and
declined: owing a reply is the other axis from a repo's own next step, and
answering is often what unblocks a chain. The weight carries that decision -
15 per message exceeds the sum of every other bonus (13), so one message
more still outranks any combination of status and cost. At the originally
proposed 12 a tiebreak could flip the debt order, which is the cap arriving
through the back door.
blocked-on becomes 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.
why= now names the dominant term rather than the first rule that matched, so
a block says what opening it would release instead of repeating inbox:N.
Format is otherwise unchanged for both declared consumers: tab=, repo=, dir=,
why=, status=, neste=, command=/command_missing=, paste= and the fokus_*
header all keep their names and positions.
board-selftest 118 -> 134 (chain, cycle, dangling-edge and format fixtures
written first, all failing before the change).
--plan shipped with dir= and command= as separate fields. That is right for a
driver, which moves the pane itself and then types the command, and unusable
for a person: the first operator to read a block could not act on it at all.
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 - the same failure the absolute dir= exists to prevent, moved from
the machine to the human.
paste= is the two already joined, cd <dir> && <command>, one thing to select.
Both forms stay: the two consumers want the same fact shaped differently, and
neither shape serves both.
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 - the operator ends up in the right directory with no session started
and no error to notice.
board-selftest: 86 -> 89 checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eKRzhD1RqY2MxvDEkrJAM
The table says what the state of every repo is; the briefing says who is
waiting. Neither says which repos to open a tab for today, in what order,
and with which command - so a day plan was assembled by hand every morning
from a table that deliberately takes no position.
--plan is built on exactly the argument --brief was: a lookup over data the
scan already holds, 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.
The rule most likely to be "fixed" into a defect: repos owing mail rank
first regardless of status. Excluding blocked or done is a claim about a
repo's OWN next step, which by definition cannot be moved, while owing an
answer is the other axis entirely - and answering is often what unblocks
it. Measured on the real tree, two of 26 planned repos were done with an
unhandled inbox, so the fixture tree pins done-with-debt and
blocked-with-debt rather than the general rule alone. Repos with no board
line rank last and are LABELLED, because the table already prints a note
about them and dropping them silently would repeat that defect.
key=value rather than prose, because the plan has two consumers: the
operator, and a separate repo driving a terminal from it. Prose would make
the rendered format an API no test here could hold stable for a consumer
living in another repo. dir= is absolute because a new pane inherits its
anchor's directory; command_missing= carries both no-command causes,
because a driver reading ^command= would type a blank line into a live pane.
route_cmd_for() becomes the single reader of the route-line grammar, shared
with --brief, distinguishing the two failure causes by exit code rather
than by an empty string. --brief output is unchanged.
Driving a terminal from the plan stays OUT of this repo, on the measurement
in docs/ghostty-orchestration-measurement.md rather than on taste.
board-selftest: 51 -> 86 checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eKRzhD1RqY2MxvDEkrJAM
Measured, not guessed: the check failed 7 runs in 20, not once. The cause is
a same-second collision, reproduced deterministically - a notice minted at
20260801205457 against a cutoff of 20260801205457 survives, the same notice
60s older is closed.
coord-sweep.sh is right and is left alone. Its cutoff is second-granular and
it closes strictly older messages, which spares rather than closes at the
boundary; at any real --days value one second is unobservable. Relaxing that
guard to <= would make a destructive script more aggressive to satisfy a test.
So the test was claiming what the code does not promise: that a notice minted
earlier in the same run is necessarily older at second granularity. Under a
second of work separates the two, so it was a coin flip. Aged by 5 seconds
through the existing age_it, which keeps it well inside the default 14-day
window and clear of the boundary. No sleep: that would have hidden the answer
rather than fixed it.
Selftest 182 -> 183, 20/20 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfDGWyyhnM26J4p93GSk2L
The briefing had checks for a VALID route line and for a MISSING one, but
not for one that exists and carries a value outside route.sh's closed set -
which is the case that arrives on its own as 23 hand-written route lines
drift. route.sh exits 2, the command comes back empty, and without a check
nothing stops a future edit from printing a bare "$ ": the shape of a
runnable command carrying nothing, which is worse than an absent command
because it reads as copied-and-pasteable.
Two checks, because the marker appearing and the command line NOT appearing
are different claims and only both together describe the behavior. Verified
the fixture fails for the right reason: `--path knwon` exits 2 with
"unknown value".
board-selftest 49 -> 51. No behavior change; no version bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017orCFDkmp88fLnqDR3chdJ
The operator has more repos than they can hold in their head, and the
question that actually costs them is "who is waiting on me, and what does
answering cost". board.sh already scans for it; nothing rendered it in a
form an unattended job could leave behind.
--brief is a second RENDERING of that scan, never a second scan. It prints
NESTE uncut, because the 38-character cut is the table column's property
and not the record's - the value used to be truncated at record-build time,
which left 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 rather than 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, which was the deciding property. Measured against 2.1.220
under subscription auth: --max-budget-usd DOES bite (terminal_reason
budget_exhausted, exit 1), but it aborts 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.
board.sh stays read-only: the file write lives in brief-nightly.sh, which
renders to a temp file and renames it into place, and treats an EMPTY
render as a FAILED one - board prints nothing when its scan roots do not
exist, which is what a mistyped path or a moved home looks like, and a
plain `> file` redirect would destroy yesterday's briefing on a bad launchd
environment.
The launchd template carries placeholders, not absolute paths: this repo is
mirrored publicly and a plist is the one file here that would need a home
directory in it. It points at a checkout rather than the plugin cache,
which is version-pinned and would break silently on the next bump.
board-selftest 36 -> 49. 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.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017orCFDkmp88fLnqDR3chdJ
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
route.sh now emits `--advisor opus` into the startup command it prints.
The flag existed and worked, but nothing generated it, so it went unused:
the only mechanism that ever set an advisor here was `/advisor`, which
writes the global advisorModel setting -- every session, every repo -- and
was abandoned for burning quota. Nothing replaced it.
Two independent triggers, almost disjoint by construction:
rows 1-2 always. Sonnet main model, so opus is a capability LIFT rather
than a peer. Load-bearing: every fallback is one row cheaper and
the cheap rows are Sonnet, so this makes the quota fallback safe.
rows 3-4 only at reversibility=costly|one-way. Opus main model, so the
advisor buys peer review where a mistake is not cheap to undo.
rows 5-6 never. The CLI rejects every advisor for a Fable main model.
costly forces row 3 and one-way forces row 4, so a Sonnet row always has
reversibility=cheap and neither rule reaches the other's rows.
verification=none is deliberately not a third trigger: beyond the stakes
rule it adds only cheap-to-reverse mistakes, docs sessions among them.
Applied per ROW, so fallback-command carries its own correct answer.
route-selftest.sh 56 -> 73. Section 14 gates the three CLI facts the rule
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. --help cannot gate this -- it short-circuits before
option validation, so an unknown flag would pass the gate untested.
Three pre-existing checks updated rather than worked around: two asserted
whole command strings that now carry the advisor, and section 11's effort
extraction swallowed the tail of the command line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8N8hQEJSWWtieWUx37txT
v0.12.1's fix removed the ~/.claude/scripts fallback but its own commentary
still asserted the deployed copy was "real and intentional (the operator's
own terminal aliases point at it)". Checked directly with the operator:
they invoke coord-send and board only through their Skill front doors, never
a personal terminal alias. board.sh and route.sh never had a deployed copy
at all -- only the five coord-*.sh scripts did, and their one measured
effect was as an accidental fallback target for Claude sessions' own Bash
tool calls, the exact bug 0.12.1 fixed. With no owner and no remaining
function, the five files under ~/.claude/scripts/ have been deleted.
No behavior change -- coord-selftest.sh still 159/159.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJMJanRHkUhEf9EYxMpbM5
Version sync across plugin.json, package.json, README badge, CHANGELOG and
all three skill frontmatters, plus the 156 -> 159 selftest-count references
that follow from the previous commit's three new checks.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJMJanRHkUhEf9EYxMpbM5
The last-session record exists to make the routing policy falsifiable, and
it only is if --last-effort is measured. route.sh documented the opposite as
settled fact: that the effort a session ran with is not observable from
inside that session. 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 level, so a Bash call reads it directly.
The premise had a cost. With effort unobservable, the record could only be
completed by asking the operator at session end, which made it block on their
presence -- all four fields or none. That is also the weaker measurement, and
in the same 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. They come apart exactly when the record would be
most interesting, which is what a session that silently ran xhigh under a
board line saying high already showed.
Reading it makes all four fields knowable from inside the ending session, so
the record no longer waits on anyone. The skill does the reading; route.sh
deliberately does NOT default from the variable, because 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.
Section 13 also pins the trap this opens: skill frontmatter overrides the
session effort while that skill is active, so an effort: field in route's own
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.
Also corrects the neighbouring claim that the model is readable from the
environment. There is no CLAUDE_MODEL; the session takes it from what it
knows itself to be running as.
route-selftest 50 -> 56.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gfa1nvwGXdST2MHvbs6htD
coord-done.sh was the third script that acts on pending messages and the only
one that did not learn the field. --all archives every pending message in one
call, including the ones whose sender declared it expects a reply - the exact
outcome Rule 7 exists to prevent, now reachable with no friction and no trace.
The behavior stays. The receiver keeps both terminal states by design (section
20), and a bulk close is legitimate; refusing would move a decision that belongs
to the operator into the script. What was wrong was the silence: Rule 7 requires
leaving a message unanswered to be STATED, and a command reporting only a total
made the thing that has to be stated invisible.
So --all now names the number it closed without a reply, and only then - a line
that always fires is one nobody reads. A named close stays quiet, because naming
a message is already deliberate, one message at a time.
coord-selftest 151 -> 156.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016iJoZVmU2guTEZcMghk88z
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: 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. The fifth
field closes both.
coord-send.sh --fyi writes reply-expected: no; omitting it writes yes. Absent
means expected, because every message already on disk lacks the field - so a
forgotten flag over-counts debt, which is visible, rather than creating debt
nobody sees. A reply is not a special case. A broadcast is always no: --reply-to
resolves inside the recipient's own mailbox and a broadcast never lands there,
so there is no reply path to promise.
coord-count.sh now prints TWO integers per mailbox, not one. 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.
The field is frontmatter and only frontmatter - a body line claiming
"reply-expected: no" at column 0 cannot silence a real debt, and a file without
valid frontmatter counts as owing a reply.
Section 20's wording changed because its stated reason expired, but its second
half matters more now, not less: the marking is a DECLARATION, not an
instruction. Without that clause one word in an untrusted message becomes a
lever that mints obligations in another repo.
coord-selftest 136 -> 151.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016iJoZVmU2guTEZcMghk88z
ALDER carried two readings depending on which branch a repo fell into: STATE.md
mtime for repos that had one, last-commit age for repos that did not. The
consequence was one-sided -- a repo WITH a STATE.md reported only how recently
its plan was touched, so one that had not committed in a year looked exactly as
alive as one worked on this morning, and no column contradicted it.
Now each column means one thing. ALDER is the plan's age and is blank where
there is no plan; SISTE is the last commit, read for every repo, and blank where
there are no commits -- a day count there would be a fabricated reading, which
is what the old -1d was.
The four ranking rules stay four: the new column is evidence, never sort input.
The sort key was already a separate field from the displayed age, so bucket 5
keeps ordering by commit age while showing nothing in ALDER.
The fixture tree was missing the only case that discriminates -- a repo with a
STATE.md AND an old last commit -- so the checks land with it, plus its mirror
image (old plan, fresh commit) to pin that the two ages read independently and
that neither became the sort key. Backdating sets GIT_COMMITTER_DATE, not
--date=: %ct is the committer date, and an author-only backdate would have left
the red test failing for the wrong reason.
board-selftest: 30 -> 36 checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QS6gYYgYgJAz7VjcFHEzbb
Six follow-ups on 0d11838, three of which mattered.
The model-alias gate passed for the wrong reason: after the quoted-alias grep
it fell back to an unanchored grep for the bare word, which matches "opus"
anywhere in `claude --help` and would have reported success even if --model
stopped accepting the alias. A gate that cannot fail is worse than no gate.
Only the quoted form is matched now, and the failing aliases are named.
--last-model and --last-effort were unvalidated free text while the other two
record fields were gated. The next session READS the record back to decide
--opus-xhigh-failed, so a drifted spelling there rebuilds the exact
reader-versus-writer drift this script exists to remove, one field over. Both
are closed sets now: the row table's three model names and the verified effort
levels. That also makes the record's sanitizing dead code, so it is gone.
The skill told future sessions to write the record "every session" while
STATE documented that the effort level is not observable from inside a running
session. A session following both would have fabricated the value, and a
fabricated effort reads back later as a measurement. The skill now says: ask
the operator, and omit the record rather than guess -- explicitly including
that reading it off the previous board line measures what was PRESCRIBED, not
what was RUN.
Also: README said "seven bash scripts" (nine files, six user-facing) and its
skills badge still said 2; selftest counts updated to 50.
Verified, not assumed: the installed plugin cache at 0.9.0 contains only
board and coord-send, so route is not discoverable until a release bumps it --
the smoke test STATE had queued before release would have failed with 127 for
a reason unrelated to the skill. STATE reordered to release-then-test. The
manifest is auto_discover, so no skills array needs an entry.
check-versions.mjs is green (11 OK, 0 ERROR) with the new skill at 0.9.0.
Selftests: coord 136, board 30, route 50, node 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017peNgsxVt1BR4BTuMwiPoX
The board line's next-cost field had a reader (board.sh) and no writer, so
its value was retyped by hand every session and drifted into several
competing spellings. Cleaning the data could not fix that: the cause was the
missing write path.
route.sh is that writer. Four scored traits of the next task -- path,
verification, reversibility, scope -- plus a required rationale, run through
the operator's model rubric moved here as the single copy. The row table is a
closed set of six values, so a seventh spelling cannot enter circulation, and
route-selftest.sh section 6 runs the round trip (route emits -> board parses)
inside one repo rather than across two.
Two spellings of one decision come out of one table: the rubric name for the
board line, the CLI alias for the command the operator pastes. Effort levels
are the set pinned in this marketplace; model aliases are gated against the
installed claude rather than hardcoded.
Three things worth naming:
- Escalation is asymmetric. Any single trait escalates; the cheapest row needs
all four at the cheap end. Underkill costs one session, overkill costs quota
every session -- but a wrong architecture call costs more than either.
- The Fable rows fire only from an explicit judgement flag, never inferred
from the last-session record. "The session did not finish" also covers
context exhaustion and operator interrupts, which say nothing about the
model, and Fable runs without an advisor.
- The trait block is a single-line HTML comment because board.sh's NESTE
extractor skips only lines that START with '<!--'. Measured first: a YAML
block or a multi-line comment silently replaces the repo's next step on the
board with "next_task:". Pinned by section 7.
board.sh is untouched as a program; its header now points at route.sh for the
value set so this does not reopen the two-specs defect 305f168 closed.
Selftests: coord 136, board 30, route 47 (new), node 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017peNgsxVt1BR4BTuMwiPoX
The board line's cost field had two specifications and one of them was this
script. `--help` prescribed a versionless `sonnet/xhigh`; the global model
rubric it points at spells the model `Sonnet 5 / xhigh`. A session following
either was correct by its own source, so the field drifted with nobody making
a mistake. Measured across a real 44-repo tree: 14 board lines carrying eight
distinct strings, and not one of them the form documented here - the only
spelling nobody used was ours.
The help now prescribes the rubric's, which is also what most lines already
carry. The parser is untouched and stays lenient: it reads to `;` or `-->` so
the rubric's spacing survives, and the fixtures still vary the spelling on
purpose to pin that. Read-side leniency was never the defect; it is what makes
a documentation-only fix sufficient.
No normalization at read, deliberately. Board lines are rewritten every session
by convention, so an unambiguous spec self-heals the existing lines in about one
session per active repo - mapping variants would be permanent code migrating
data that migrates itself. Nothing compares the field mechanically today either:
KOST is a display column and the board sorts on a numeric bucket key, so the
defect was real but latent. Two checks (28 -> 30) pin the documented form,
red first, because cleaning the data cannot fix a spec that regenerates the
divergence every session. Validation at write has no write path to attach to:
nothing in this repo emits a board line outside the fixtures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
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 each: the STATE.md next-step block with its optional board
line, git status, and that repo's pending mailbox count. Read-only by
construction, pinned by board-selftest.sh (28 checks).
It ships here rather than as a personal script because the mailbox is one
of its three inputs and the two carry the same axis distinction: a pending
count means others are waiting on that repo, while who a repo waits ON comes
only from its board line, since the message format has no reply-to field.
Splitting the board from the mailbox would put that distinction in two
places. It also lets the skill resolve the engine through CLAUDE_PLUGIN_ROOT
like every other script here, instead of depending on a file that exists
only in ~/.claude/scripts (a directory with no remote and no backup).
The skill is a ranking, not a report: re-runs the board every invocation
because counts drift, 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. Not wired into session
start, which would spend context on repos the session is not in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MubwdTi88yu4hVLAFG1LbM
Reading IS delivery in this engine: coord-inbox.sh prints a broadcast and then
records it as seen. That made "what is pending elsewhere" unanswerable -- asking
the read path 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 answers it by counting files and writing nothing: no seen set, no
.origin. It keys on MAILBOXES rather than 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, because the question is "who is owed a
reply" and a list of zeroes answers a different one at every reader's expense.
The read path now closes with one aggregate line built from it. The case that
motivated this is the session whose own inbox is empty: it saw silence and
concluded "all clear" while mail sat unanswered everywhere else.
BREAKING (injection contract): the read path is no longer silent whenever THIS
repo has nothing pending. It is a silent no-op only when the whole mailbox is
empty. 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.
The line is an AGGREGATE of two integers, never a roster. A list of names would
reproduce other repos' 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 the framing.
Its 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 and a session starts answering other repos'
mail. Pinned in selftest section 26 exactly as section 20 pins the priority
text. The hook's header drops "(unread messages)" for the same reason -- it
would now announce mail that does not exist.
Selftest 116 -> 136. Every new negative check is anchored to a positive
assertion in the same output, because a missing script makes "X is absent"
true by vacuity and would have gone green proving nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
The boundary rule says the mailbox is transport, not state, and 0.6.0 added a
file that durably records a repo's filesystem path. That reads as a
contradiction without a stated test, and a future session would be right to try
to remove it. The test: would the file still mean anything if delivery were
removed. `_broadcast/seen/<repo>` and `<repo>/.origin` would not - they answer
only who has received what, and which checkout claimed a name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
Four defects that all reduce to the same thing: the engine trusted a name it
had no business trusting.
_broadcast is now 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>` walked in the side entrance
and archived a broadcast out of the queue - a full unauthenticated retract of
an announcement for every repo that had not read it yet. The rule reserves the
whole `_` prefix rather than one literal, so a later `_seen` or `_config`
cannot reopen the hole, and it is enforced in every CLI: a rule held in three
of four places is not a rule.
The pwd fallback 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 basename(pwd) silently handed it the identity "repos". That is not
hypothetical - mail was delivered under exactly that name. git toplevel or an
explicit --from/--repo are now the only two sources. The write paths refuse
and say so; the read path declines silently, because the hook runs it at every
session start and must never fail a session.
Two checkouts with the same directory name still share one mailbox - re-keying
identity would break every existing mailbox and the readable `--to <repo>`
addressing. Instead the first git-derived read records the claiming path in
<repo>/.origin, and a read from elsewhere is warned about in the injection. A
warning, not a refusal: the same repo moved or re-cloned is the ordinary case.
The warning goes in the injection because the hook discards stderr, and a
warning nobody can see is not a warning. The collision is live in this tree:
claude-code-100x is nested inside a repo of the same name.
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 has to be redelivery, never loss.
The hook stops resolving identity altogether. It was the fourth copy of the
rule and the only one that runs in production, so passing --repo bypassed the
engine's guards exactly where they mattered and suppressed the collision check
along with them. It is now the pure wrapper the boundary rule always claimed
it was, pinned by two behavioral tests rather than by reading the source.
Selftest 93 -> 116; three node tests cover the hook.
BREAKING CHANGE: coord-send and coord-done exit 2 outside a git repo instead
of naming themselves after the working directory. Pass --from/--repo to choose
an identity explicitly. Repo names beginning with _ are refused everywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
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 before the task the session came to do, and drive every directed
message to a terminal state before the session ends (--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.
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. Untrusted cross-repo content
still cannot direct the reader; it merely can no longer be ignored. The
injection states both halves and selftest section 20 pins them together,
so a future reword cannot keep the priority and quietly drop the
distinction -- that combination would turn prioritization into an
injection surface.
Selftest 82 -> 93.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
Nothing could remove a message from _broadcast/inbox/. coord-done is
directed-only and never touches the broadcast queue, so the backlog could
only grow: every new repo received the entire standing history at its first
session, including announcements that had since become false.
--retract <filename> archives the message into _broadcast/archive/, so no
future repo is served it. Three deliberate limits, all pinned by tests:
- Un-send, not recall. Repos that already received it keep it;
_broadcast/seen/ is delivery history and is left untouched.
- Only the sender may retract (from: must match the repo identity). --from
overrides it, as 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.
The branch runs before every send-side validation and before the stdin body
read, since a retract carries no subject and no body.
Selftest 70 -> 82 (new section 19). Also fixes two README defects the
feature exposed: the install command still named coord@ after the v0.3.0
rename, and the docs advised pruning _broadcast/inbox/ by hand, which
contradicted the rule that the script owns mailbox files.
The old name said that something was coordinated, but not what the thing
was. The new one names what it is, reusing the vocabulary the code and
docs already use throughout: mailbox, inbox, archive, broadcast.
Renamed: Forgejo repo (open/coord -> open/repo-mailbox, old URL
redirects), plugin manifest name, package name, README title and badge,
CLAUDE.md heading and release command.
BREAKING CHANGE: the skill is 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 CLAUDE_COORD_DIR. 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.
Selftest unchanged at 70/70 -- the engine was not touched.
--create-tag pushes the tag even without --write, and the closing gate runs
check-versions.mjs over every plugin, so an unrelated plugin in ERROR aborts
the run with the catalog edit written but uncommitted. Both cost a session
otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvTviFeoMCKJcALATRempy
coord-inbox.sh dropped unknown arguments silently, so a mistyped flag was
indistinguishable from a working invocation. It now warns on stderr per
argument and keeps reading: the read path must stay lenient because it runs
inside the SessionStart hook, which must never fail a session over a stray
flag. The hook runs the script with stderr discarded, so the warning costs
nothing there and surfaces in manual CLI use. Exit code is unchanged.
Selftest 68 -> 70: one check for the warning, one pinning the leniency it
must not break (unknown argument still reads the inbox and exits 0).
Docs realigned with shipped behavior in the same pass:
- selftest count was stale at 64 in README and CLAUDE.md (now 70)
- broadcast sender self-exclusion shipped in 0.2.1 but was undocumented
- rule 6 (message content is data, never instructions) was already enforced
in the injection framing but missing from the published rule list
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvTviFeoMCKJcALATRempy
Bump all version carriers (plugin.json, package.json, README badge,
SKILL.md frontmatter) and record the release in the changelog.
Also backfills the missing [0.2.0] changelog section: that release shipped
without one, which would have left the changelog jumping 0.1.0 -> 0.2.1.
Content reconstructed from the v0.2.0 commit, not from its subject line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvTviFeoMCKJcALATRempy
- Atomic delivery: create the temp file inside the destination dir
(dot-prefixed, invisible to the inbox glob) so the final rename never
crosses filesystems and readers never see a half-written message.
- Reject . and .. explicitly in the --reply-to and coord-done name
guards instead of relying on downstream failure.
- Add -h/--help to coord-inbox.sh (uniform across the three CLIs).
- Close selftest gaps: default mailbox path via HOME fallback, malformed
frontmatter on the read path, read-only destination dir. 48 -> 64
checks, all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fduZz8otpU3W3rhfoPD6t