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

@ -354,6 +354,217 @@ run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: new_string containing \$& is also treated literally (fix is general, not backtick-specific)" $?
unset STATE_GUARD_FIXTURE6
# --- 10. status=done must not be writable while commits are unpushed --------
# ORDRE 42 (operator, 2026-08-16). Measured that day: round 3 of AAA+ dispatched
# 15 sessions; two of them (human-friendly-style, graceful-handoff) had their
# push REFUSED by the UFW rate limit on port 22, reported that honestly in the
# coord inbox - and still wrote status=done. Result: board line said done, one
# commit unpushed, the published surface 404. `done` today means "the session
# finished", not "the work landed", and the difference is invisible to everyone
# reading the board. Worse, `done` removes a repo from the board plan, so
# `morning --say <repo>` could not reach them either: one defect hid the other.
#
# The guard is on the WRITE, not on session end, and that is a measured choice,
# not the cheap one (see the hook header for the full argument): Stop fires
# "once per turn", not once at session end, and SessionEnd cannot block at all
# ("Shows stderr to user only") - both quoted from the official hooks docs,
# 2026-08-16.
#
# The KNOWN-POSITIVE control is mandatory here: a guard that denies everything
# passes every negative test and is worthless. Both outcomes are pinned below.
NOHOOKS="$TMPDIR/nohooks"
mkdir -p "$NOHOOKS"
# g <git args> -- git with the operator's global config neutralised. The real
# machine sets core.hooksPath globally (measured 2026-08-16), so a fixture repo
# would otherwise run the operator's own git hooks.
g() {
git -c user.name=selftest -c user.email=selftest@example.invalid \
-c commit.gpgsign=false -c core.hooksPath="$NOHOOKS" "$@"
}
# mkrepo <name> <branch> -- work repo at $TMPDIR/<name> with a bare remote at
# $TMPDIR/<name>.git, one commit pushed, upstream tracking configured.
mkrepo() {
g init -q --bare "$TMPDIR/$1.git"
g init -q "$TMPDIR/$1"
g -C "$TMPDIR/$1" symbolic-ref HEAD "refs/heads/$2"
printf 'seed\n' >"$TMPDIR/$1/f.txt"
g -C "$TMPDIR/$1" add -A
g -C "$TMPDIR/$1" commit -qm seed
g -C "$TMPDIR/$1" remote add origin "$TMPDIR/$1.git"
g -C "$TMPDIR/$1" push -q -u origin "$2"
}
# addcommit <name> <subject> -- one more local commit, deliberately not pushed.
addcommit() {
printf '%s\n' "$2" >>"$TMPDIR/$1/f.txt"
g -C "$TMPDIR/$1" add -A
g -C "$TMPDIR/$1" commit -qm "$2"
}
# state_text <status> -- a minimal, convention-shaped STATE.md.
state_text() {
printf '# STATE\n\n## NESTE - START HER\n<!-- board: status=%s; blocked-on=-; next-cost=Sonnet 5/high -->\nnext step goes here\n' "$1"
}
# write_payload -- Write payload from $SG_PATH / $SG_CONTENT
write_payload() {
payload '
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: process.env.SG_PATH, content: process.env.SG_CONTENT + "\n" }
}));
'
}
# 10.1 the measured defect: done + unpushed commit -> deny
mkrepo repo-unpushed main
addcommit repo-unpushed "feat: work that never left this checkout"
export SG_PATH="$TMPDIR/repo-unpushed/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Write: status=done with an unpushed commit denies (exit 2)" $?
printf '%s' "$HOOK_STDERR" | grep -q "1 commit"; check "denial message names how many commits are unpushed" $?
printf '%s' "$HOOK_STDERR" | grep -q "never left this checkout"; check "denial message shows the unpushed commit, not just a count" $?
printf '%s' "$HOOK_STDERR" | grep -q "git push"; check "denial message says what to do (push)" $?
printf '%s' "$HOOK_STDERR" | grep -q "status=blocked"; check "denial message names the honest alternative (status=blocked)" $?
# 10.2 KNOWN-POSITIVE CONTROL: done + everything pushed -> allow.
# Without this check, a guard that denies unconditionally passes 10.1 and every
# other negative case in this section while being worthless.
mkrepo repo-clean main
export SG_PATH="$TMPDIR/repo-clean/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "KNOWN-POSITIVE: status=done with everything pushed allows" $?
# 10.3 the guard judges the claim, not the repo: an honest status is always
# writable, which is the escape hatch that keeps the deny non-wedging.
export SG_PATH="$TMPDIR/repo-unpushed/STATE.md"
SG_CONTENT="$(state_text in-progress)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "status=in-progress with unpushed commits allows" $?
SG_CONTENT="$(state_text blocked)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "status=blocked with unpushed commits allows" $?
# 10.4 a repo with no remote at all: "pushed" has no meaning there. Measured on
# the real tree 2026-08-16: 8 of 44 repos carrying a STATE.md have no upstream,
# and one of them (ghcp) is status=done. Denying there would make STATE.md
# unwritable in repos that can never satisfy the check.
g init -q "$TMPDIR/repo-noremote"
g -C "$TMPDIR/repo-noremote" symbolic-ref HEAD refs/heads/main
printf 'seed\n' >"$TMPDIR/repo-noremote/f.txt"
g -C "$TMPDIR/repo-noremote" add -A
g -C "$TMPDIR/repo-noremote" commit -qm seed
export SG_PATH="$TMPDIR/repo-noremote/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "repo with no upstream allows status=done" $?
# 10.5 detached HEAD: no branch, so no upstream to compare against.
mkrepo repo-detached main
addcommit repo-detached "unpushed on a detached head"
g -C "$TMPDIR/repo-detached" checkout -q --detach HEAD
export SG_PATH="$TMPDIR/repo-detached/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "detached HEAD allows status=done (nothing to compare against)" $?
# 10.6 upstream configured but the remote-tracking ref is gone (never pushed a
# first time, or the ref was pruned). The two are indistinguishable from here,
# and a confident "nothing has ever landed" would be the wrong-and-loud kind of
# error, so this fails OPEN - a documented hole, not an oversight.
mkrepo repo-noref main
addcommit repo-noref "unpushed with no remote-tracking ref"
g -C "$TMPDIR/repo-noref" update-ref -d refs/remotes/origin/main
export SG_PATH="$TMPDIR/repo-noref/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "missing remote-tracking ref fails open" $?
# 10.7 the comparison is against the branch's OWN upstream, never a hardcoded
# origin/main..main. Measured 2026-08-16: three repos on the real tree sit on
# a branch named master.
mkrepo repo-master master
addcommit repo-master "unpushed on master"
export SG_PATH="$TMPDIR/repo-master/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "non-main branch: unpushed commits still deny (upstream, not origin/main)" $?
# 10.8 a STATE.md outside any git repo
mkdir -p "$TMPDIR/plain-dir"
export SG_PATH="$TMPDIR/plain-dir/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "STATE.md outside any git repo allows" $?
# 10.9 only the board line counts, never prose. A STATE.md describing THIS very
# defect contains the literal string status=done in its prose - this file's own
# repo wrote exactly that the evening the guard was built. The selector is
# board.sh's own anchor (^<!-- board:), so both read the same line or neither
# does.
export SG_PATH="$TMPDIR/repo-unpushed/STATE.md"
SG_CONTENT="$(printf '# STATE\n\n## NESTE - START HER\n<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/high -->\nThe guard denies status=done while commits are unpushed.\n')"
export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "prose containing status=done is not the board line" $?
# 10.10 exact vocabulary token. board.sh's own prefix/case defect (F3+F4, queued
# separately) reads done2 as done today; this guard does not, and pinning that
# keeps the guard aligned with the vocabulary rather than with the defect. Once
# F3+F4 lands, done2 is MALFORMED there too and green nowhere.
SG_CONTENT="$(state_text done2)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "status=done2 is not the token done (exact match, not prefix)" $?
# 10.11 the Edit path shares the same projection as Write
printf '# STATE\n\n## NESTE - START HER\n<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/high -->\nnext step goes here\n' >"$TMPDIR/repo-unpushed/STATE.md"
export SG_PATH="$TMPDIR/repo-unpushed/STATE.md"
P="$(payload '
process.stdout.write(JSON.stringify({
tool_name: "Edit",
tool_input: {
file_path: process.env.SG_PATH,
old_string: "status=in-progress",
new_string: "status=done"
}
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Edit: introducing status=done with unpushed commits denies" $?
# 10.12 and the correction is always writable in one edit - the deny can never
# wedge a session that cannot push.
printf '# STATE\n\n## NESTE - START HER\n<!-- board: status=done; blocked-on=-; next-cost=Sonnet 5/high -->\nnext step goes here\n' >"$TMPDIR/repo-unpushed/STATE.md"
P="$(payload '
process.stdout.write(JSON.stringify({
tool_name: "Edit",
tool_input: {
file_path: process.env.SG_PATH,
old_string: "status=done",
new_string: "status=blocked"
}
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: correcting done -> blocked is allowed with unpushed commits" $?
unset SG_PATH SG_CONTENT
echo ""
echo "state-line-guard-selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1