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:
parent
95ac7101ea
commit
d16a3f57e7
3 changed files with 400 additions and 2 deletions
53
CLAUDE.md
53
CLAUDE.md
|
|
@ -170,6 +170,57 @@ marketplace plugin. Three components, one boundary:
|
||||||
never affected - `join` does not interpret its argument as a pattern.
|
never affected - `join` does not interpret its argument as a pattern.
|
||||||
Pinned by state-line-guard-selftest.sh section 9 (`$\`` as the real repro,
|
Pinned by state-line-guard-selftest.sh section 9 (`$\`` as the real repro,
|
||||||
`$&` as a second sequence proving the fix is general).
|
`$&` 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
|
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
|
||||||
next-step blocks + board lines, `git status`, and mailbox pending counts, and
|
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
|
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/coord-selftest.sh` must exit 0 (220/220),
|
||||||
`bash scripts/board-selftest.sh` must exit 0 (217/217),
|
`bash scripts/board-selftest.sh` must exit 0 (217/217),
|
||||||
`bash scripts/route-selftest.sh` must exit 0 (69/69) and
|
`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
|
- English for all code, docs, and commit messages (public repo). Norwegian
|
||||||
trigger aliases in the skill description are deliberate.
|
trigger aliases in the skill description are deliberate.
|
||||||
- Conventional Commits: `type(scope): description`.
|
- Conventional Commits: `type(scope): description`.
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,65 @@
|
||||||
// being created oversized.
|
// being created oversized.
|
||||||
// - Block: stderr + exit 2
|
// - Block: stderr + exit 2
|
||||||
// - Allow: exit 0, no output
|
// - Allow: exit 0, no output
|
||||||
|
//
|
||||||
|
// SECOND INVARIANT (ORDRE 42, operator decision 2026-08-16): the same projected
|
||||||
|
// content must not claim `status=done` in its board line while the repo holds
|
||||||
|
// commits that are not on the branch's upstream. 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 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 because
|
||||||
|
// `done` removes a repo from the board plan, `morning --say <repo>` could not
|
||||||
|
// reach either of them: one defect hid the other.
|
||||||
|
//
|
||||||
|
// WHY THE WRITE PATH AND NOT SESSION END. The order offered three directions
|
||||||
|
// and named session-end (B) as the recommendation. B does not exist in the form
|
||||||
|
// it assumes, measured against the official hooks docs 2026-08-16:
|
||||||
|
// - Stop fires "once per turn", not once when the session ends, and there is
|
||||||
|
// no signal telling a Stop hook that this turn is the last. Its premise
|
||||||
|
// ("by then commit and push are done") holds only for the final turn; on
|
||||||
|
// every earlier turn it would block live work, and exit 2 there
|
||||||
|
// "prevents Claude from stopping, continues the conversation" - so a repo
|
||||||
|
// that genuinely cannot push (the rate limit that caused the incident)
|
||||||
|
// gets a session that will not end.
|
||||||
|
// - SessionEnd is the once-per-session event, and it cannot block at all:
|
||||||
|
// "Can block? No", exit 2 "shows stderr to user only". It can nag after the
|
||||||
|
// fact, which is what the order explicitly did not want.
|
||||||
|
// C (warn on write, deny at session end) inherits B's half without gaining
|
||||||
|
// anything a single deny does not already give. So: the write path, which is
|
||||||
|
// where the false claim is actually made.
|
||||||
|
//
|
||||||
|
// The false-positive trap the order warned about is real but bounded. STATE.md
|
||||||
|
// is written BEFORE the session's final commit, so a session that batches its
|
||||||
|
// pushes has unpushed commits at exactly this moment. Two things keep that from
|
||||||
|
// biting: the global git rule already requires a push immediately after every
|
||||||
|
// commit (so a compliant session sits at zero unpushed here - measured on the
|
||||||
|
// real tree 2026-08-16, 43 of 44 repos carrying a STATE.md had nothing
|
||||||
|
// unpushed, the one exception being status=blocked and honest), and the deny is
|
||||||
|
// escapable by telling the truth rather than only by pushing: status=blocked
|
||||||
|
// and status=in-progress are always writable, in the same single edit.
|
||||||
|
//
|
||||||
|
// NO RATCHET HERE, deliberately, and the difference from the line-count rule
|
||||||
|
// above is the reason. A file already over the line limit needs many writes to
|
||||||
|
// come back under it, so denying every intermediate step would make trimming
|
||||||
|
// impossible; a false `done` is corrected by changing one token in the write
|
||||||
|
// that is already being made. A "only deny the transition into done" rule was
|
||||||
|
// considered and rejected outright: the common shape is a repo that ended
|
||||||
|
// `done` last session and rewrites `done` this session, which such a rule would
|
||||||
|
// wave through - precisely the case the order exists to stop.
|
||||||
|
//
|
||||||
|
// FAILS OPEN on every git uncertainty (no upstream, detached HEAD, missing
|
||||||
|
// remote-tracking ref, not a repo, git absent or slow). A confident denial
|
||||||
|
// built on a measurement that did not happen is the worse error, and 8 of the
|
||||||
|
// 44 STATE.md repos on the real tree have no upstream at all - one of them
|
||||||
|
// already status=done. The hole this leaves is named in the selftest (10.6).
|
||||||
|
//
|
||||||
|
// The file keeps its name: both invariants are properties of a line in
|
||||||
|
// STATE.md, and one hook process per Write/Edit stays cheaper than two.
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { basename } from 'node:path';
|
import { basename, dirname } from 'node:path';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
const MAX_LINES = 120;
|
const MAX_LINES = 120;
|
||||||
|
|
||||||
|
|
@ -121,6 +177,52 @@ if (toolName === 'Write') {
|
||||||
currentLines = countLines(current);
|
currentLines = countLines(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The board line is selected with board.sh's own anchor (grep -m1 '^<!-- board:'),
|
||||||
|
// so the guard judges the exact line the board renders - or neither of them
|
||||||
|
// finds one. Prose is therefore never a trigger, which matters because a
|
||||||
|
// STATE.md documenting this very guard writes the literal string status=done.
|
||||||
|
function boardLineOf(text) {
|
||||||
|
const m = text.match(/^<!-- board:[^\n]*/m);
|
||||||
|
return m ? m[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// board.sh's `sed -n 's/.*status=\([a-z-]*\).*/\1/p'` is greedy, so it reads the
|
||||||
|
// LAST status= on the line; mirror that rather than the first. The value is
|
||||||
|
// then compared to the exact vocabulary token: board.sh's prefix defect (F3+F4,
|
||||||
|
// queued separately) reads done2 as done, and copying that here would pin the
|
||||||
|
// defect instead of the vocabulary.
|
||||||
|
function boardStatusOf(line) {
|
||||||
|
const all = line.match(/status=[^;>\s]*/g);
|
||||||
|
return all ? all[all.length - 1].slice('status='.length) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(dir, args) {
|
||||||
|
return execFileSync('git', ['-C', dir, ...args], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
timeout: 5000,
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { branch, upstream, count, subjects } when the repo demonstrably has
|
||||||
|
// commits the upstream does not, or null in every other case INCLUDING every
|
||||||
|
// case it could not measure.
|
||||||
|
function unpushedOf(dir) {
|
||||||
|
try {
|
||||||
|
const upstream = git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
||||||
|
const count = parseInt(git(dir, ['rev-list', '--count', '@{u}..HEAD']), 10);
|
||||||
|
if (!Number.isFinite(count) || count < 1) return null;
|
||||||
|
return {
|
||||||
|
branch: git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
||||||
|
upstream,
|
||||||
|
count,
|
||||||
|
subjects: git(dir, ['log', '--format=%h %s', '-n', '5', '@{u}..HEAD']),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lines = countLines(projected);
|
const lines = countLines(projected);
|
||||||
if (lines > MAX_LINES && lines > currentLines) {
|
if (lines > MAX_LINES && lines > currentLines) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
|
|
@ -133,4 +235,38 @@ if (lines > MAX_LINES && lines > currentLines) {
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boardLine = boardLineOf(projected);
|
||||||
|
if (boardLine && boardStatusOf(boardLine) === 'done') {
|
||||||
|
const unpushed = unpushedOf(dirname(filePath));
|
||||||
|
if (unpushed) {
|
||||||
|
const one = unpushed.count === 1;
|
||||||
|
const noun = one ? 'commit' : 'commits';
|
||||||
|
const verb = one ? 'is' : 'are';
|
||||||
|
const indented = unpushed.subjects.split('\n').map((l) => ` ${l}`).join('\n');
|
||||||
|
const more = unpushed.count > 5 ? ` ... and ${unpushed.count - 5} more\n` : '';
|
||||||
|
process.stderr.write(
|
||||||
|
`\n[repo-mailbox] STATE DONE GUARD: ${toolName} blocked\n` +
|
||||||
|
` File: ${filePath}\n` +
|
||||||
|
` Board line: ${boardLine}\n` +
|
||||||
|
` Branch: ${unpushed.branch} -> ${unpushed.upstream}\n` +
|
||||||
|
` Unpushed: ${unpushed.count} ${noun}, present only in this checkout\n` +
|
||||||
|
`${indented}\n${more}\n` +
|
||||||
|
`status=done claims the WORK LANDED, not that the session finished. It has\n` +
|
||||||
|
`not landed: the ${noun} above ${verb} not on ${unpushed.upstream}, so anything\n` +
|
||||||
|
`reading the board -- or the published remote -- sees green over nothing.\n\n` +
|
||||||
|
`Do one of these, then write STATE.md again:\n` +
|
||||||
|
` git push origin ${unpushed.branch}\n` +
|
||||||
|
` -- if it goes through, status=done is true\n` +
|
||||||
|
` status=blocked\n` +
|
||||||
|
` -- if the push is refused (SSH rate limit: UFW allows 6 connections\n` +
|
||||||
|
` per 30s on port 22, and the chain ends in REJECT)\n` +
|
||||||
|
` status=in-progress\n` +
|
||||||
|
` -- if the work simply is not finished\n\n` +
|
||||||
|
`Only the board line's status token is judged here; nothing else in this\n` +
|
||||||
|
`write is being questioned.\n`
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
|
||||||
|
|
@ -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)" $?
|
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: new_string containing \$& is also treated literally (fix is general, not backtick-specific)" $?
|
||||||
unset STATE_GUARD_FIXTURE6
|
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 ""
|
||||||
echo "state-line-guard-selftest: $PASS passed, $FAIL failed"
|
echo "state-line-guard-selftest: $PASS passed, $FAIL failed"
|
||||||
[ "$FAIL" -eq 0 ] || exit 1
|
[ "$FAIL" -eq 0 ] || exit 1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue