Commit graph

58 commits

Author SHA1 Message Date
123d40e4b4 fix(board): track an open HTML comment through to its closer v0.20.1
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
d0a5ffe515 feat(board)!: rank --plan on five ordered groups, planned above in-progress v0.20.0
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
2026-08-03 06:55:21 +02:00
e5e9cbe847 feat(board): rank --plan on one score, with transitive chain-root credit v0.19.0
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).
2026-08-02 22:38:40 +02:00
917a902172 fix(board): anchor the NESTE marker to the heading form
The awk reader matched /NESTE/ unanchored, so any prose line that merely
mentioned the word (not just the real ## NESTE heading) set the flag and the
excerpt became whatever line followed the false match, not the real next
step.

Closed by operator decision 2026-08-02: one anchor form for all repos,
^#+[[:space:]].*NESTE. Measured 26/27 repos already use the heading form;
the one exception now renders "(tom NESTE-blokk)" instead of a misleading
fragment, which is the intended, honest failure mode until it switches
over - already flagged directly and on the same shape as the existing
"MERK: N repo mangler board-linje" line.

board-selftest 116 -> 118 (new fixture proves the false-match failure before
the fix, both checks green after).
2026-08-02 20:52:17 +02:00
402597b1e5 fix(board): read emphasis on the status token, not only on the slug
The marker reader accepted **slug**: done and missed slug: **done**. That was
never a rule - it was whichever example happened to be in front of us when the
regex was written. The second form is in live use, and it makes a repo that HAS
declared look silent to --focus, which is the exact failure the held-back
report exists to surface.

Found by acting on the report's own output instead of reading it: enumerating
the held-back population turned up a repo whose declaration we were dropping
ourselves. The report blamed the repo; the defect was here.

The evidence field reports the status unwrapped - the emphasis is markdown the
operator typed, not part of the token, and "**done**" in a key=value field
reads as a value. board-selftest 114 -> 116.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186vKCzuUEN5WcJB82kddzF
2026-08-02 20:34:53 +02:00
ecbb34ae48 chore(release): prepare v0.18.0 - version bump, and the entry point --focus was missing v0.18.0
release-plugin.mjs does not bump these; it REFUSES to release until they
already match the target (release-plugin.mjs:64). The bump belongs in this
repo, not in the catalog session, so the catalog step is now unblocked:
plugin.json, package.json, README badge and all three SKILL.md frontmatters
move to 0.18.0 together, and CHANGELOG's [Unreleased] becomes [0.18.0].

More important than the numbers: --focus was reachable by nobody. The board
SKILL.md is the operator's only entry point and it documented only --plan, so
"fokusdag pa X" would have routed to an unfocused plan on a released 0.18.0.
The skill now passes the user's phrasing through VERBATIM - resolving prose to
a slug in the skill would put a guess in front of a deterministic lookup, and
fokus= already reports what the phrase resolved to. It also relays the three
disclosure lines rather than trimming them as noise, since this is the one
rendering that hides repos.

check-versions: 0 ERROR, 1 WARN (catalog ref lags plugin.json - the documented
transient state before the ref bump).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186vKCzuUEN5WcJB82kddzF
2026-08-02 20:15:52 +02:00
dd04356920 docs(changelog): correct the check count to what the suite actually reports
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186vKCzuUEN5WcJB82kddzF
2026-08-02 20:09:15 +02:00
040480890d fix(board): an empty held-back set renders a value, not a dangling colon
With nobody held back, fokus_utenfor= ended at a bare colon. sed could not
supply the placeholder it looked like it was supplying: given an empty string
there is no input line for the substitution to run on, so it silently did
nothing. That is the shape command_missing= exists to keep out of this format
- a field whose value carries nothing.

Zero repos mentioning a slug without declaring it is a real answer and now
looks like one. board-selftest 113 -> 114.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186vKCzuUEN5WcJB82kddzF
2026-08-02 20:08:32 +02:00
3fe159e75d docs(board): scrub a real topic slug out of a public-mirror comment
The worked example in the focus comment was a live slug from another repo's
private topic register. This repo mirrors publicly, and the decision file
deliberately avoids naming slugs for that reason - the code has to hold the
same line. Replaced with a shaped placeholder; behaviour untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186vKCzuUEN5WcJB82kddzF
2026-08-02 20:04:35 +02:00
9bbfe9facd docs(focus): record what shipped against the condition it was permitted under
The decision file was written before any code existed and said so in its
opening line. That line is now false, and the fix is not to delete the tense
but to name it: everything above "What shipped" is the record of what the
feature was ALLOWED to be, and the closing section reports what was built
against it.

Also records the one thing the decision could not have known, because it was
found by reading the consumer's parser rather than assuming it: paragraph mode
discards any block without tab=, so neither a '#' comment nor a bare key=value
in the header ever reaches the driver's records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186vKCzuUEN5WcJB82kddzF
2026-08-02 20:02:44 +02:00
f9d2c927ab feat(board): give --plan a focus cutoff that reports what it held back
--focus "<prose>" narrows the day plan to repos whose STATE.md DECLARES a
matching topic marker. 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 cutoff would break a property the format had
already written down. The same run prints the slugs the prose resolved to,
how many blocks it removed, the repos that MENTION a resolved slug with no
marker line - named, not counted - and how many STATE.md it searched. Each
surviving block carries the declaration it survived on.

Enumerated rather than counted because the decisive find behind this feature
was invisible to every string measurement until the held-back population was
listed. "nevner", never "dekker": this reports text found, and board.sh has
no grounds for a claim about relevance. The slug vocabulary comes from the
scanned STATE.md themselves, so the "no other file" invariant survives.

Emitted as key=value, not '#' comments: the consumer's parser runs in
paragraph mode and drops any block without tab=, so a comment would reach the
operator on the terminal path and vanish on the driver path - the one case
where the cutoff is applied unseen.

board-selftest 89 -> 113. The narrowing check is comparative, since every
presence-only assertion passes against a no-op filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186vKCzuUEN5WcJB82kddzF
2026-08-02 19:58:25 +02:00
cb77f2889d docs(focus): record the condition --focus rests on, before it is built
A cross-repo exchange settled that --plan may grow a --focus filter only if
the same run reports the repos it held back. The decision constrains a future
feature, but it lived only in a private mailbox and a local-only STATE.md, so
nothing durable pointed at it.

The argument is an invariant, not a preference: --plan documents that it has
no cutoff and labels rather than drops, and --focus IS a cutoff. Measuring the
held-back population also turned up the find that carries the decision - a
heavy consumer of the subject, invisible to every strict-marker measurement in
the exchange, recovered only by enumerating what the filter would have hidden.

Other repos are unnamed by design: this mirror is public, and which repo
carries which malformed marker line is that repo's business.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USD9q3Fr2unp58fe8YvapW
2026-08-02 18:42:45 +02:00
50f6420da5 docs(changelog): reconstruct the missing 0.15.0 entry
The log jumped straight from 0.16.0 to 0.14.0 even though 0.15.0 shipped
and is tagged - a log gap, not a code gap, but this repo mirrors publicly
and nothing machine-checks changelog prose.

Reconstructed from v0.15.0 alone: the tag points exactly at 9dd24c3
(--brief + brief-nightly.sh). STATE.md's pointer to also use 6de2408
(the malformed-route-line test) was a stale premise - `git merge-base
--is-ancestor 6de2408 v0.15.0` says no, it landed 6 minutes after the tag
and only entered history via v0.16.0. Checked out both tags into scratch
trees and ran board-selftest.sh live to get 36 -> 49 from PASS=, not from
commit-message prose (this repo's README has printed a wrong count
before). Grepped the launchd plist for leaked absolute paths before
repeating the "placeholders only" claim: clean.

Left the published [0.16.0] entry's "51 -> 86" as-is per operator
call - the 49 -> 51 gap (6de2408's two checks, landed untagged between
the tags) is explained in the new entry instead of rewriting an
already-tagged entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017orCFDkmp88fLnqDR3chdJ
2026-08-02 16:05:53 +02:00
4080e22abd docs(ghostty): a ghost surface is detectable in one property read
The measurement identified realized surfaces by counting shell processes, which
is what was available while probing. morning-driver, building against this
document, found a cheaper and stronger test in the sdef: `working directory` is
readable on a terminal, and a surface with no shell process reports it EMPTY -
exactly the ghost signature the broken creation API produces.

That turns "has this surface been realized" from an inference into a
machine-checkable question, which is the guard anything driving Ghostty needs
before it types into a pane.

The same property answers one of the document's own open questions: it goes
empty when a shell exits and returns when the prompt is back, so "has this pane
returned to a prompt" can be polled rather than guessed at with a delay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eKRzhD1RqY2MxvDEkrJAM
2026-08-02 15:57:27 +02:00
5bea1f8651 feat(board): give the plan a paste= line the operator can actually use v0.17.0
--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
2026-08-02 07:34:30 +02:00
6d0fe14d0f feat(board): render the day plan as a third rendering of the same scan v0.16.0
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
2026-08-02 06:40:22 +02:00
5d9c76763d docs(board): measure what Ghostty 1.3.1 can actually be driven to do
The gate question before any day-plan design: can Ghostty be scripted to open
a tab with two vertical panes, each in its own directory, with a command
running in one? Measured against the installed 1.3.1 rather than reasoned
about, because this is undocumented platform behavior.

Yes, but not through the documented API. new window / new tab / split return
correctly shaped objects that never spawn a shell - nine probe windows, shell
count flat at 29 - and every call that needs the process fails with "Terminal
surface model is not available". That is issue #12730, a 1.3.1 regression
closed as not planned, and 1.3.1 is the newest release. surface configuration
is collateral: all four of its useful properties ride that path.

What does work is perform action on an ALREADY REALIZED surface. new_tab and
new_split produce live shells (29 -> 31), and input text + send key drives
them - two panes cd'd to different repos reported distinct directories and
distinct PIDs. The working directory becomes a typed cd, so the broken path is
not needed at all.

Three constraints fell out of the measurement and are recorded because each
was found by breaking something: pane targeting must diff tab ids, since
last tab of w is not the new tab and a probe that trusted position typed into
a tab it did not create; new tabs inherit the anchor's directory; and
count of terminals is not a pane count.

Recommends splitting the work - the plan here as a third rendering of the
board scan, the Ghostty driver in a separate repo. The argument is the
measurement: a preview API that breaks in 1.4, whose documented path is
already broken, with a blast radius reaching into other repos' live sessions,
does not belong behind the mailbox.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfDGWyyhnM26J4p93GSk2L
2026-08-02 06:15:47 +02:00
8e207c6c49 test(sweep): stop the --days 0 check from racing a one-second cutoff
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
2026-08-01 23:01:32 +02:00
6de2408b28 test(brief): pin the malformed route line, the case that will actually occur
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
2026-08-01 22:44:35 +02:00
9dd24c3446 feat(brief): render the nightly cross-repo briefing without a model v0.15.0
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
2026-08-01 22:38:46 +02:00
459c9feec0 feat(sweep): close the aged FYI backlog without a human in the loop v0.14.0
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
2026-08-01 22:00:42 +02:00
9cb405c2cd feat(route): give the advisor a writer, on a need and per row v0.13.0
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
2026-07-31 21:39:59 +02:00
6ffe089b68 docs(coord-send): correct a false claim the fallback fix shipped with
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
2026-07-31 18:41:34 +02:00
6e369936f9 chore(release): 0.12.1 -- coord-send stops silently falling back to a stale copy v0.12.1
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
2026-07-31 18:25:39 +02:00
34f263cf28 fix(coord-send): drop the ~/.claude fallback in the skill's own script path
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; coord-send still had it. Reported live by org-ops (2026-07-31): a
--fyi send hit an old deployed copy lacking the flag.

coord-selftest.sh grows three checks (156 -> 159) pinning the SKILL.md
engine line and ruling the fallback out entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJMJanRHkUhEf9EYxMpbM5
2026-07-31 18:22:44 +02:00
dffc1f5e47 chore(release): 0.12.0 -- a session measures the effort it ran with v0.12.0
Bumps the six version-bearing files and gives ac62a38 its changelog entry.

Until the tag exists the plugin cache keeps serving the 0.11.0 skill, which
instructs every new session that effort is not observable and to ask the
operator for it. The fix is committed and pushed but not in circulation, so
the release is the part that changes behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxRVZm1busUeuXd5iPkK36
2026-07-31 17:28:19 +02:00
ac62a38b40 fix(route): measure the effort a session ran with, instead of asking for it
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
2026-07-31 17:21:10 +02:00
6735298752 chore(release): 0.11.0 -- a message can say it needs no answer, and 0.10.0 gets its changelog v0.11.0
Bumps plugin.json, package.json, the README badge and the three skill
frontmatters, because release-plugin.mjs blocks on a plugin.json that does not
already stand at the target version.

Also closes a gap this release would otherwise have inherited: v0.10.0 shipped
without a CHANGELOG section, and the next-cost fix sat under [Unreleased] even
though it is inside v0.9.0..v0.10.0. Labelling that block 0.11.0 would have
made a second wrong claim, so 0.10.0 now has its own section, written from the
commits in the tag rather than from memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016iJoZVmU2guTEZcMghk88z
2026-07-31 15:50:41 +02:00
5754d67a6b feat(engine): make a bulk close state what Rule 7 says must be stated
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
2026-07-31 15:48:42 +02:00
c0ccb1d611 feat(engine): let a message say it needs no answer, and count debt without losing sight of the rest
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
2026-07-31 15:46:14 +02:00
261a75bd7b test(board): make the no-commit check fail when the row is missing
The check read a row that was not there and passed: an absent `repo-c` leaves
the captured line empty, and an empty string contains no day count, so the
assertion succeeded by finding nothing rather than by reading the column. Every
other new check had a real red state; this one's was "column absent", which is
a different failure than the one it claims to guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QS6gYYgYgJAz7VjcFHEzbb
2026-07-31 11:07:35 +02:00
6a09c9caa1 feat(board): split the age column, so a silent repo cannot hide behind a fresh plan
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
2026-07-31 11:04:27 +02:00
dbf7391d57 chore(release): 0.10.0 -- route ships, and the board skill stops advertising a path that is gone v0.10.0
Version bump across plugin.json, package.json, the README badge and all three
SKILL.md frontmatters.

Also removes the last false statement in the public docs: the board skill still
sold a `~/.claude/scripts/` fallback as portability, but that copy was deleted
when the skill was made the only entry point. Tagging it would have published a
path that does not resolve, next to a sentence claiming it does. One entry point
is the point -- two entries pointed at two copies and made it impossible to know
which one ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017peNgsxVt1BR4BTuMwiPoX
2026-07-31 10:22:53 +02:00
a435a031db fix(route): close the record's drift surface and the confounded smoke test
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
2026-07-31 10:19:30 +02:00
0d1183800e feat(route): give next-cost a writer, and make the rubric the only copy
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
2026-07-31 10:10:22 +02:00
305f1687b7 fix(board): one spec for next-cost, and it is the rubric's
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
2026-07-28 22:06:45 +02:00
f7ad4741a3 docs(board): keep the axis rationale, drop the private referents
The v0.9.0 comments explained the attention-vs-topic axis by pointing at the
operator's private cross-repo register by path, naming an internal incident
code, and quoting a measured count of the operator's own repo population.
This repo mirrors to a public remote, so operational metadata about a private
machine does not belong in it - the reasoning does, the referents do not.

Rewritten self-contained: the topic-axis contrast now stands on its own terms
without citing a file a public reader cannot see, the defect class is named
rather than coded, and the worktree rationale keeps WHY .git is tested with
-e (a worktree or submodule has .git as a FILE) while dropping the census
that motivated measuring it. Illustrative paths stay; they carry no fact.

Comments only, no behavior change: 136 + 28 selftest checks and 6/6 node
tests unchanged. No version bump, so the v0.9.0 tag and the catalog ref stay
where they are - and with them the pre-scrub text, which history and that tag
still carry. Un-publishing that is a history rewrite and the operator's call,
not a side effect of a comment edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
2026-07-28 21:35:42 +02:00
61e224ccc3 feat(board): cross-repo attention board as script + skill (0.9.0) v0.9.0
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
2026-07-28 21:15:03 +02:00
ec92c866c1 feat(engine)!: counting is not delivering, and an empty inbox is not all clear v0.8.0
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
2026-07-27 20:31:21 +02:00
28e7cb42cd feat(hook): let a non-git surface declare its identity via CLAUDE_COORD_REPO v0.7.0
0.6.0 removed the working-directory fallback, which was right, but it left
every non-git working surface with nothing to derive from. The read path
declines silently by design - the hook must never fail a session - so such a
surface simply stops seeing its inbox: no error, no exit code, nothing. That is
the same loss-looks-like-normal shape 0.6.0 set out to remove, reintroduced one
layer up.

The hook now forwards CLAUDE_COORD_REPO verbatim as --repo. This is not the
fallback returning, and the distinction is the whole point: the fallback GUESSED
a name from wherever the session happened to stand, while a declaration is
written down in that directory's settings, readable back, and deletable.
Identity stays a choice someone made.

Forwarding as --repo rather than reimplementing anything means it inherits every
engine rule, including that an explicit override never claims <repo>/.origin -
otherwise a surface borrowing a name would steal the claim from the checkout
that owns it. Both halves are pinned by tests, and the shared test helper now
deletes CLAUDE_COORD_REPO unless a test asks for it, so a value set globally
later cannot silently satisfy the tests that prove the hook resolves nothing on
its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
2026-07-27 08:56:31 +02:00
95c24cd034 chore(release): v0.6.0 v0.6.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
2026-07-26 21:03:20 +02:00
0b53d6c0d4 docs(claude): mark .origin and the seen set as delivery metadata, not state
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
2026-07-25 20:38:48 +02:00
1d62b073f8 fix(engine)!: identity is derived, never invented
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
2026-07-25 20:34:43 +02:00
737127a14c feat(engine)!: the inbox is a priority, not a suggestion v0.5.0
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
2026-07-25 18:44:20 +02:00
316b8acdd2 feat(engine): retire a broadcast with coord-send --retract
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.
2026-07-25 15:11:47 +02:00
c27b20fc62 feat(release)!: rename plugin and repo 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, 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.
2026-07-25 07:00:17 +02:00
4ae34658ff docs(claude): record release-plugin.mjs dry-run and gate behavior
--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
2026-07-25 06:42:22 +02:00
2c44c091ff chore(release): v0.2.2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvTviFeoMCKJcALATRempy
2026-07-25 06:40:37 +02:00
11178fa168 feat(engine): warn on unknown coord-inbox arguments
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
2026-07-25 06:39:21 +02:00
27b0443aea chore(release): v0.2.1
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
2026-07-25 06:20:11 +02:00