feat(state-line-guard): deny status=done while commits are unpushed

ORDRE 42 (operator, 2026-08-16). Two sessions had their push refused by the
UFW rate limit on port 22, reported that honestly in the coord inbox, and
wrote status=done anyway: board line green, one commit unpushed, published
surface 404. `done` meant "the session finished" where every reader takes it
to mean "the work landed" -- and since `done` drops a repo from the board
plan, `morning --say <repo>` could not reach either of them.

The deny sits on the WRITE, not on session end. Measured against the official
hooks docs rather than assumed: Stop fires "once per turn" with no signal
marking the last one, and its exit 2 "prevents Claude from stopping", so a
repo that genuinely cannot push would get a session that will not end;
SessionEnd is the once-per-session event and cannot block at all.

Fails open on every git uncertainty (no upstream, detached HEAD, missing
remote-tracking ref, not a repo) -- 8 of 44 repos on the real tree have no
upstream, one already status=done. Compares against the branch's own
upstream, never a hardcoded origin/main (three repos sit on master). Selects
the board line with board.sh's own anchor, so prose saying status=done never
triggers it. status=blocked and status=in-progress stay writable in the same
single edit, so the deny can never wedge a session.

state-line-guard-selftest.sh section 10, 17 checks (23 -> 40), including the
mandatory known-positive: status=done with everything pushed still allows.
Both outcomes also verified against real repos -- app-creator (1 unpushed)
denied, repo-mailbox (clean) allowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4LMWBQGmufmBU6UdvJZ2E
This commit is contained in:
Kjell Tore Guttormsen 2026-08-16 22:06:44 +02:00
commit d16a3f57e7
3 changed files with 400 additions and 2 deletions

View file

@ -170,6 +170,57 @@ marketplace plugin. Three components, one boundary:
never affected - `join` does not interpret its argument as a pattern.
Pinned by state-line-guard-selftest.sh section 9 (`$\`` as the real repro,
`$&` as a second sequence proving the fix is general).
**Since ORDRE 42 (operator, 2026-08-16) it carries a SECOND invariant: the
projected content may not claim `status=done` in its board line while the
repo holds commits the branch's upstream does not have.** Measured that day:
two sessions had their push refused by the UFW rate limit on port 22, said so
honestly in the coord inbox, and wrote `status=done` regardless - board line
green, one commit unpushed, published surface 404. `done` meant "the session
finished" where every reader takes it to mean "the work landed", and since
`done` drops a repo from the board plan, `morning --say <repo>` could not
reach either of them: one defect hid the other.
**The order recommended a session-end hook and that direction does not exist
in the form it assumes - measured against the official hooks docs, not
reasoned.** `Stop` fires "once per turn", not once when the session ends, with
no signal marking the last turn; its exit 2 "prevents Claude from stopping,
continues the conversation", so a repo that genuinely cannot push (the very
rate limit that caused the incident) would get a session that will not end.
`SessionEnd` is the once-per-session event and cannot block at all
("Can block? No" - exit 2 "shows stderr to user only"), which is the
after-the-fact nagging the order explicitly refused. Warn-on-write plus
deny-at-session-end inherits the broken half and buys nothing. So the deny
sits on the write, where the false claim is actually made.
**The false-positive trap is real but bounded, and the deny is escapable by
telling the truth.** STATE.md is written BEFORE the session's final commit, so
a session that batches its pushes does hold unpushed commits at that moment -
but the global git rule already requires a push immediately after every
commit, and the real tree bears that out (2026-08-16: 43 of 44 repos carrying
a STATE.md had nothing unpushed; the one exception was `status=blocked` and
honest). `status=blocked` and `status=in-progress` stay writable in the same
single edit, so a session that cannot push is never wedged - only stopped from
claiming otherwise.
**No ratchet here, unlike the line limit above, and the asymmetry is the
reason.** An oversized file needs many writes to come back under the limit, so
denying the intermediate steps would make trimming impossible; a false `done`
is corrected by changing one token in the write already being made. A
"deny only the transition into done" variant was rejected outright: the
common shape is a repo that ended `done` last session and writes `done` again
this session, which such a rule waves straight through. It **fails OPEN** on
every git uncertainty - no upstream, detached HEAD, missing remote-tracking
ref, not a repo, git slow or absent - because 8 of those 44 repos have no
upstream at all (one already `status=done`), and a confident denial resting on
a measurement that never happened is the worse error. The board line is
selected with `board.sh`'s own anchor (`^<!-- board:`) and the status token
compared exactly, so prose saying `status=done` never triggers it (a STATE.md
documenting this guard writes that string routinely) and `done2` is not `done`
here even though board.sh's F3+F4 prefix defect still reads it as one.
Selftest section 10, 17 checks, including the mandatory known-positive: a
`status=done` with everything pushed must still go through, or the guard is a
gate that denies everything and proves nothing.
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
next-step blocks + board lines, `git status`, and mailbox pending counts, and
prints one line per repo. Read-only by construction: it writes to no repo, no
@ -535,7 +586,7 @@ obligations in another repo.
`bash scripts/coord-selftest.sh` must exit 0 (220/220),
`bash scripts/board-selftest.sh` must exit 0 (217/217),
`bash scripts/route-selftest.sh` must exit 0 (69/69) and
`bash scripts/state-line-guard-selftest.sh` must exit 0 (23/23).
`bash scripts/state-line-guard-selftest.sh` must exit 0 (40/40).
- English for all code, docs, and commit messages (public repo). Norwegian
trigger aliases in the skill description are deliberate.
- Conventional Commits: `type(scope): description`.