feat(gate): session-gate.sh - R3/R4/R6 checks before a session starts

Order 20260926T070037Z-680859139 (.claude). A read-only, zero-model-call
check any session starter can call first: exit 0 allows, exit 2 denies with
one stderr line naming the rule and what is missing.

- R4: PLAN.md holds exactly one gate line,
  <!-- gate: gjenfortelling=<time>; dekning=komplett; r1-proeve=<time|->;
  foerste-maaling=<time|->; retning=<name> -->
- R3: the Logg table's Bevegelse (ja|nei) and Kvote (N%) columns. Deny at
  >= 10% of the weekly quota without movement since the last row that moved.
- R6: cap 1 live session per repo (claude agents --json --cwd <repo>) until
  r1-proeve and foerste-maaling are both times, then 2. Night 23:00-07:00 or
  --night needs r1-proeve.

Fail-closed: unmeasured rows, a missing claude binary, a non-JSON listing,
duplicate or unknown keys and usage errors all deny.

Measured: claude agents --cwd matches the directory a session was STARTED
in, not its printed cwd, so --repo must be the main checkout root.

Chose NOT to call it from board.sh --dispatch, because a dispatch line is
generated before the start (a live count would be stale), and live-session
reading is the process axis board.sh refuses by design.

session-gate-selftest.sh: 0 -> 97 checks. It was red 91 of 95 before the
script existed, then 97/97 green. Ten mutants all die. It is wired into npm
test as the sixth suite, and the README badge goes from 1006 to 1103.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-26 09:24:12 +02:00
commit 02d4df2dfb
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 794 additions and 10 deletions

View file

@ -0,0 +1,408 @@
#!/bin/bash
# session-gate-selftest.sh - prove session-gate.sh against fixture repos.
# ASCII only, bash 3.2 safe. Writes only under a mktemp directory.
#
# Every rule gets at least one repo that must be LET THROUGH beside the repos
# that must be REFUSED, because a gate proven only by refusals is a gate that
# refuses everything and proves nothing. The live-session count is fed from a
# fake `claude` on PATH, so the suite never depends on what happens to be
# running on the machine - and the fake records its argv, so the real call
# shape (`agents --json --cwd <physical path>`) is pinned, not assumed.
set -u
export LC_ALL=C
DIR="$(cd "$(dirname "$0")" && pwd)"
GATE="$DIR/session-gate.sh"
PASS=0; FAIL=0
check() { if [ "$2" -eq 0 ]; then PASS=$((PASS+1)); echo " ok - $1"; else FAIL=$((FAIL+1)); echo " FAIL - $1"; fi; }
T="$(mktemp -d)"
trap 'chmod -R u+rwx "$T" 2>/dev/null; rm -rf "$T"' EXIT
T="$(cd -P "$T" && pwd -P)"
FAKEBIN="$T/fakebin"
mkdir -p "$FAKEBIN"
cat > "$FAKEBIN/claude" <<'FAKE'
#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
if [ -n "${FAKE_AGENTS_FILE:-}" ]; then cat "$FAKE_AGENTS_FILE"; fi
exit "${FAKE_AGENTS_EXIT:-0}"
FAKE
chmod +x "$FAKEBIN/claude"
FAKE_LOG="$T/fake.log"
printf '[]\n' > "$T/ag0"
cat > "$T/ag1" <<'J'
[
{
"pid": 4242,
"id": "aaaa1111",
"cwd": "/x/demo/.claude/worktrees/w",
"kind": "background",
"startedAt": 1790406122536,
"sessionId": "aaaa1111-0000-0000-0000-000000000000",
"name": "demo-w",
"status": "busy",
"state": "working"
}
]
J
printf '[{"id":"a","cwd":"/x/demo","kind":"background","sessionId":"a-1"},{"cwd":"/x/demo","kind":"interactive","sessionId":"b-2"}]\n' > "$T/ag2"
printf 'hello\n' > "$T/agjunk"
: > "$T/agempty"
# run_gate <args...>: env knobs NOW (clock, default 12:00; NOW_UNSET=1 leaves
# the variable out), AG (agents fixture, default ag0), AGX (fake exit),
# GPATH (PATH for the gate, default fakebin first).
run_gate() {
: > "$FAKE_LOG"
if [ "${NOW_UNSET:-0}" = 1 ]; then
OUT="$(env -u SESSION_GATE_NOW PATH="${GPATH:-$FAKEBIN:/usr/bin:/bin}" FAKE_LOG="$FAKE_LOG" \
FAKE_AGENTS_FILE="${AG:-$T/ag0}" FAKE_AGENTS_EXIT="${AGX:-0}" \
/bin/bash "$GATE" "$@" 2>"$T/err")"; RC=$?
else
OUT="$(env PATH="${GPATH:-$FAKEBIN:/usr/bin:/bin}" SESSION_GATE_NOW="${NOW-12:00}" FAKE_LOG="$FAKE_LOG" \
FAKE_AGENTS_FILE="${AG:-$T/ag0}" FAKE_AGENTS_EXIT="${AGX:-0}" \
/bin/bash "$GATE" "$@" 2>"$T/err")"; RC=$?
fi
ERR="$(cat "$T/err")"
}
# denied <rule>: exit 2, nothing on stdout, exactly ONE stderr line naming the rule.
denied() {
[ "$RC" -eq 2 ] && [ -z "$OUT" ] && [ "$(printf '%s\n' "$ERR" | grep -c .)" -eq 1 ] \
&& printf '%s' "$ERR" | grep -q "$1"
}
# allowed: exit 0, one ALLOW line on stdout, stderr empty.
allowed() {
[ "$RC" -eq 0 ] && [ -z "$ERR" ] && printf '%s' "$OUT" | grep -q '^session-gate: ALLOW'
}
GATE_OK='<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
GATE_R1='<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=2026-09-26T08:00; foerste-maaling=-; retning=demo -->'
GATE_R1M='<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=2026-09-26T08:00; foerste-maaling=2026-09-27T09:00+02:00; retning=demo -->'
LOG_OK='## Logg
| Dato | Hva | Bevegelse | Kvote |
|---|---|---|---|
| 26.09 | x | ja: first | 3% |
| 26.09 | y | nei: nothing moved | 4% |'
# mkplan <name> <gate-line> <logg-block>: a repo dir holding PLAN.md.
mkplan() {
mkdir -p "$T/$1"
printf '# PLAN - %s\n%s\n\n## Maal\nSomething.\n\n%s\n' "$1" "$2" "$3" > "$T/$1/PLAN.md"
}
echo "session-gate-selftest"
# --- 0. CLI, help and the known-positive baseline --------------------------
run_gate -h
[ "$RC" -eq 0 ]; check "-h exits 0" $?
printf '%s' "$OUT" | grep -q 'checks that the fields are filled in, not that they were thought through'
check "help states the caveat: filled in, not thought through" $?
printf '%s' "$OUT" | grep -q 'retelling is the only control with real content'
check "help states the caveat: the retelling is the only control with real content" $?
printf '%s' "$OUT" | grep -q '0 = allow' && printf '%s' "$OUT" | grep -q '2 = deny'
check "help documents exit 0 = allow and 2 = deny" $?
run_gate
denied usage; check "no arguments: deny (exit 2, one line)" $?
run_gate --repo
denied usage; check "--repo without a value: deny" $?
run_gate --repo "$T" --bogus
denied usage; check "unknown argument: deny" $?
mkplan base "$GATE_OK" "$LOG_OK"
run_gate --repo "$T/base" --kind other
denied usage; check "--kind outside build|read: deny" $?
run_gate --repo "$T/does-not-exist"
denied repo; check "--repo that does not exist: deny" $?
run_gate --repo "$T/base"
allowed; check "KNOWN-POSITIVE: complete PLAN.md, 0 live sessions, daytime -> allow" $?
run_gate --repo "$T/base" --kind build
allowed; check "explicit --kind build on the same repo -> allow" $?
LC_ALL=C grep -q '[^ -~ ]' "$GATE"; [ $? -eq 1 ]
check "session-gate.sh is ASCII-only" $?
[ -s "$GATE" ] && { grep -v '^[[:space:]]*#' "$GATE" | grep -Eq '(^|[^A-Za-z_-])(jq|python3?|node)([^A-Za-z_-]|$)'; [ $? -eq 1 ]; }
check "no jq/python/node in executable lines (zero dependencies)" $?
printf 'x=1\njq .\n' | grep -v '^[[:space:]]*#' | grep -Eq '(^|[^A-Za-z_-])(jq|python3?|node)([^A-Za-z_-]|$)'
check "KNOWN-POSITIVE: the dependency grep finds a planted jq call" $?
# --- 1. PLAN.md itself: fail-closed on absence and unreadability -----------
mkdir -p "$T/noplan"
run_gate --repo "$T/noplan"
denied PLAN.md; check "no PLAN.md: deny, naming PLAN.md" $?
mkdir -p "$T/lower"
printf '%s\n\n%s\n' "$GATE_OK" "$LOG_OK" > "$T/lower/plan.md"
[ -f "$T/lower/PLAN.md" ] && echo " note - case-insensitive volume: [ -f PLAN.md ] is TRUE for plan.md here"
run_gate --repo "$T/lower"
denied PLAN.md; check "only plan.md (wrong case): deny - the name is matched byte for byte" $?
mkplan unread "$GATE_OK" "$LOG_OK"
chmod 000 "$T/unread/PLAN.md"
run_gate --repo "$T/unread"
denied PLAN.md; check "unreadable PLAN.md: deny" $?
chmod 644 "$T/unread/PLAN.md"
mkdir -p "$T/plandir/PLAN.md"
run_gate --repo "$T/plandir"
denied PLAN.md; check "PLAN.md is a directory: deny" $?
mkdir -p "$T/emptyplan"; : > "$T/emptyplan/PLAN.md"
run_gate --repo "$T/emptyplan"
denied R4; check "empty PLAN.md: deny on R4 (no gate line)" $?
mkdir -p "$T/linked-target"
printf '%s\n\n%s\n' "$GATE_OK" "$LOG_OK" > "$T/linked-target/real-plan.md"
ln -s real-plan.md "$T/linked-target/PLAN.md"
run_gate --repo "$T/linked-target"
allowed; check "symlinked PLAN.md is read through the link -> allow" $?
# --- 2. R4: the retelling and the coverage table --------------------------
mkplan r4-none "" "$LOG_OK"
run_gate --repo "$T/r4-none"
denied R4; check "R4: no gate line: deny" $?
mkplan r4-two "$GATE_OK
$GATE_OK" "$LOG_OK"
run_gate --repo "$T/r4-two"
denied R4; check "R4: two gate lines (ambiguous): deny" $?
mkplan r4-prose "See the form \`$GATE_OK\` in the docs." "$LOG_OK"
run_gate --repo "$T/r4-prose"
denied R4; check "R4: a gate line quoted mid-sentence is prose, not a gate line: deny" $?
r4() { # r4 <name> <gate-line>: build and run
mkplan "$1" "$2" "$LOG_OK"; run_gate --repo "$T/$1"
}
r4 r4-nogj '<!-- gate: dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling missing: deny" $?
r4 r4-gjdash '<!-- gate: gjenfortelling=-; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling=- (no yes yet): deny" $?
r4 r4-gjdate '<!-- gate: gjenfortelling=2026-09-26; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling with a date but no time: deny" $?
r4 r4-gjjunk '<!-- gate: gjenfortelling=yes; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling=yes (not a time): deny" $?
r4 r4-gjmonth '<!-- gate: gjenfortelling=2026-13-01T10:00; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling in month 13: deny" $?
r4 r4-gjhour '<!-- gate: gjenfortelling=2026-09-26T24:10; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling at hour 24: deny" $?
r4 r4-dekdel '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=delvis; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: dekning=delvis: deny" $?
r4 r4-dekcase '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=Komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: dekning=Komplett (exact token only): deny" $?
r4 r4-dekpre '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett2; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: dekning=komplett2 (prefix is not the token): deny" $?
r4 r4-nodek '<!-- gate: gjenfortelling=2026-09-26T07:10; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: dekning missing: deny" $?
r4 r4-noret '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning= -->'
denied R4; check "R4: retning empty: deny" $?
r4 r4-retdash '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=- -->'
denied R4; check "R4: retning=- : deny" $?
r4 r4-nor1 '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: r1-proeve key missing (must be written, - if not yet): deny" $?
r4 r4-r1junk '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=ja; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: r1-proeve=ja (not a time or -): deny" $?
r4 r4-nomaal '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; retning=demo -->'
denied R4; check "R4: foerste-maaling key missing: deny" $?
r4 r4-typo '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; r1-prove=2026-09-26T08:00; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: an unknown key (typo r1-prove beside r1-proeve) is refused, not ignored" $?
r4 r4-repeat '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; r1-proeve=2026-09-26T08:00; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: a repeated key (which one counts?) is refused" $?
r4 r4-okz '<!-- gate:gjenfortelling=2026-09-26T07:10:00Z; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
allowed; check "R4 control: seconds and Z suffix -> allow" $?
r4 r4-okoff '<!-- gate: gjenfortelling=2026-09-26T07:10+02:00; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=ny retning 2 -->'
allowed; check "R4 control: +02:00 offset and a retning with spaces -> allow" $?
# --- 3. R3: movement per quota share, over PLAN.md's Logg -----------------
r3() { # r3 <name> <logg-block>
mkplan "$1" "$GATE_OK" "$2"; run_gate --repo "$T/$1"
}
HDR='## Logg
| Dato | Hva | Bevegelse | Kvote |
|---|---|---|---|'
r3 r3-ten "$HDR
| 1 | a | ja | 5% |
| 2 | b | nei | 6% |
| 3 | c | nei | 4% |"
denied R3; check "R3: 10% without movement since the last movement: deny (threshold is >= 10)" $?
printf '%s' "$ERR" | grep -q '10'
check "R3: the denial states the sum" $?
r3 r3-nine5 "$HDR
| 1 | a | nei | 4,5% |
| 2 | b | nei | 5 % |"
allowed; check "R3 control: 9.5% (comma decimal, spaced percent) -> allow" $?
r3 r3-before "$HDR
| 1 | a | nei | 50% |
| 2 | b | ja: moved | 1% |
| 3 | c | nei | 3% |"
allowed; check "R3 control: rows BEFORE the last movement do not count -> allow" $?
r3 r3-lastja "$HDR
| 1 | a | nei | 40% |
| 2 | b | Ja - moved K1 | 2% |"
allowed; check "R3 control: last row moved (Ja, any case) -> sum 0 -> allow" $?
r3 r3-nomove-ever "$HDR
| 1 | a | nei | 6% |
| 2 | b | nei | 6% |"
denied R3; check "R3: no movement ever, 12%: deny (counted from the first row)" $?
r3 r3-unmeasured "$HDR
| 1 | a | ja | 1% |
| 2 | b | nei | ikke maalt |"
denied R3; check "R3: kvote 'ikke maalt' is unmeasured, not zero: deny" $?
r3 r3-kdash "$HDR
| 1 | a | nei | - |"
denied R3; check "R3: kvote '-': deny" $?
r3 r3-kneg "$HDR
| 1 | a | nei | -3% |"
denied R3; check "R3: negative kvote: deny" $?
r3 r3-kbare "$HDR
| 1 | a | nei | 3 |"
denied R3; check "R3: kvote without % (unit unknown): deny" $?
r3 r3-bingen "$HDR
| 1 | a | ingen mot kriteriene | 1% |"
denied R3; check "R3: bevegelse outside ja|nei ('ingen ...'): deny" $?
r3 r3-bempty "$HDR
| 1 | a | | 1% |"
denied R3; check "R3: bevegelse empty: deny" $?
r3 r3-bjanei "$HDR
| 1 | a | janei | 1% |"
denied R3; check "R3: bevegelse 'janei' (prefix is not the token): deny" $?
r3 r3-cells "$HDR
| 1 | a | nei |"
denied R3; check "R3: a row with fewer cells than the header: deny" $?
r3 r3-pipe "$HDR
| 1 | a \`x|y\` | nei | 1% |"
denied R3; check "R3: a row with more cells than the header: deny" $?
r3 r3-nolog ""
denied R3; check "R3: no Logg section at all: deny" $?
r3 r3-notable "## Logg
- 26.09 did things, no movement, 3%"
denied R3; check "R3: Logg section with no table: deny" $?
r3 r3-nokvote "## Logg
| Dato | Hva | Bevegelse |
|---|---|---|
| 1 | a | ja |"
denied R3; check "R3: Logg table without a Kvote column: deny" $?
r3 r3-nobev "## Logg
| Dato | Hva | Kvote |
|---|---|---|
| 1 | a | 1% |"
denied R3; check "R3: Logg table without a Bevegelse column: deny" $?
r3 r3-hdronly "$HDR"
allowed; check "R3 control: header only, no rows yet (first session) -> allow" $?
r3 r3-twolog "$HDR
| 1 | a | ja | 1% |
## Logg
| Dato | Hva | Bevegelse | Kvote |
|---|---|---|---|"
denied R3; check "R3: two Logg sections (ambiguous): deny" $?
r3 r3-bullet "$HDR
| 1 | a | ja | 1% |
- 2 | b | nei | 30% (a row written as a bullet)"
denied R3; check "R3: a list item inside Logg (a row the gate cannot read): deny" $?
r3 r3-after "$HDR
| 1 | a | ja | 1% |
## Next section
- a bullet that belongs to another section
| x | y |"
allowed; check "R3 control: the Logg section ends at the next heading -> allow" $?
SECT="$(printf '\302\247')"
r3 r3-para "## $SECT Logg
| dato | hva | bevegelse | kvoteandel |
|:---|---|---|---:|
| 1 | a | nei | 2% |"
allowed; check "R3 control: '## <section sign> Logg', lower-case headers, Kvoteandel, aligned separator -> allow" $?
r3 r3-endrlogg "## Endringslogg
| Dato | Hva | Bevegelse | Kvote |
|---|---|---|---|
| 1 | a | ja | 1% |"
denied R3; check "R3: '## Endringslogg' is not the Logg section: deny" $?
# --- 4. R6: concurrency cap and night starts -------------------------------
mkplan r6 "$GATE_OK" "$LOG_OK"
AG="$T/ag0" run_gate --repo "$T/r6"
allowed; check "R6 control: 0 live sessions, cap 1 -> allow" $?
grep -qx "agents --json --cwd $T/r6" "$FAKE_LOG"
check "R6: live sessions are read with 'claude agents --json --cwd <repo>'" $?
ln -s "$T/r6" "$T/r6-link"
AG="$T/ag0" run_gate --repo "$T/r6-link"
grep -qx "agents --json --cwd $T/r6" "$FAKE_LOG"
check "R6: a symlinked --repo is resolved to its physical path before the query" $?
AG="$T/ag1" run_gate --repo "$T/r6"
denied R6; check "R6: 1 live session, no R1 proof -> cap 1 -> deny" $?
printf '%s' "$ERR" | grep -q '1'
check "R6: the denial states the live count" $?
mkplan r6-r1 "$GATE_R1" "$LOG_OK"
AG="$T/ag1" run_gate --repo "$T/r6-r1"
denied R6; check "R6: R1 proof but no first measurement -> cap still 1 -> deny" $?
mkplan r6-r1m "$GATE_R1M" "$LOG_OK"
AG="$T/ag1" run_gate --repo "$T/r6-r1m"
allowed; check "R6 control: R1 proof AND first measurement, 1 live -> cap 2 -> allow" $?
AG="$T/ag2" run_gate --repo "$T/r6-r1m"
denied R6; check "R6: 2 live (one-line JSON, interactive counts too), cap 2 -> deny" $?
AG="$T/ag1" run_gate --repo "$T/r6" --kind read
allowed; check "R6 control: --kind read is not capped -> allow with 1 live" $?
[ ! -s "$FAKE_LOG" ]
check "R6: --kind read does not query live sessions at all" $?
AG="$T/agjunk" run_gate --repo "$T/r6"
denied R6; check "R6: claude agents prints non-JSON: deny (fail-closed)" $?
AG="$T/agempty" run_gate --repo "$T/r6"
denied R6; check "R6: claude agents prints nothing: deny (fail-closed)" $?
AG="$T/ag0" AGX=1 run_gate --repo "$T/r6"
denied R6; check "R6: claude agents exits 1: deny (fail-closed)" $?
NOCL="$T/noclaude"; mkdir -p "$NOCL"
env PATH="$NOCL:/usr/bin:/bin" /bin/bash -c 'command -v claude' >/dev/null 2>&1; [ $? -ne 0 ]
check "ground truth: claude is not on the reduced PATH" $?
GPATH="$NOCL:/usr/bin:/bin" run_gate --repo "$T/r6"
denied R6; check "R6: claude not on PATH: deny (fail-closed)" $?
GPATH="$NOCL:/usr/bin:/bin" run_gate --repo "$T/r6" --kind read
allowed; check "R6 control: --kind read needs no claude binary -> allow" $?
NOW=23:00 run_gate --repo "$T/r6"
denied R6; check "R6 night: 23:00 without R1 proof: deny" $?
printf '%s' "$ERR" | grep -q 'r1-proeve'
check "R6 night: the denial names r1-proeve" $?
NOW=06:59 run_gate --repo "$T/r6"
denied R6; check "R6 night: 06:59 without R1 proof: deny" $?
NOW=07:00 run_gate --repo "$T/r6"
allowed; check "R6 night control: 07:00 is day -> allow" $?
NOW=22:59 run_gate --repo "$T/r6"
allowed; check "R6 night control: 22:59 is day -> allow" $?
NOW=12:00 run_gate --repo "$T/r6" --night
denied R6; check "R6 night: --night at noon without R1 proof: deny" $?
NOW=02:00 run_gate --repo "$T/r6" --kind read
denied R6; check "R6 night: a read session at 02:00 without R1 proof: deny" $?
NOW=02:00 run_gate --repo "$T/r6-r1"
allowed; check "R6 night control: R1 proof present -> night start allowed" $?
NOW=12:00 run_gate --repo "$T/r6-r1" --night
allowed; check "R6 night control: --night with R1 proof -> allow" $?
NOW=noon run_gate --repo "$T/r6"
denied SESSION_GATE_NOW; check "clock override 'noon' is refused by name, never ignored" $?
NOW=25:00 run_gate --repo "$T/r6"
denied SESSION_GATE_NOW; check "clock override 25:00 is refused by name" $?
NOW= run_gate --repo "$T/r6"
denied SESSION_GATE_NOW; check "clock override set but empty is refused by name" $?
NOW_UNSET=1 run_gate --repo "$T/r6-r1m"
allowed; check "KNOWN-POSITIVE: real clock (override unset), R1 proof -> allow at any hour" $?
# --- 5. Rule order: the first failing rule is the one reported ------------
mkplan order "" "$HDR
| 1 | a | nei | 50% |"
AG="$T/ag2" NOW=03:00 run_gate --repo "$T/order"
denied R4; check "all three rules fail: R4 is reported first (one line)" $?
echo "session-gate-selftest: $PASS passed, $FAIL failed (of $((PASS+FAIL)) checks)"
[ "$FAIL" -eq 0 ] || exit 1
exit 0

319
scripts/session-gate.sh Executable file
View file

@ -0,0 +1,319 @@
#!/bin/bash
# session-gate.sh - may a new session start in this repo? Three mechanical
# checks (R4, R3, R6 from the 2026-09-25 retro), zero model calls, exit 0 or 2.
#
# WHY this exists: prose rules about when a session may start were followed
# for weeks in the wrong direction, because nothing checked them at the moment
# a session was started. This script is the check. It is meant to be called by
# whatever starts sessions (a PreToolUse hook counting `claude --bg` starts is
# the first caller) and it only READS: the target repo's PLAN.md on disk, and,
# for a build session, `claude agents --json --cwd <repo>`.
#
# FAIL-CLOSED EVERYWHERE. A check that cannot run denies: a missing or
# unreadable PLAN.md, an unparseable gate line or Logg row, a missing `claude`
# binary, an agents listing that is not a JSON array. "Not measured" is never
# read as zero - the same rule the rest of this engine applies to exit 3 and
# `?`. The single exception is `--kind read`, which is not capped and therefore
# never asks how many sessions are live.
#
# PLAN.md is read from DISK, never from git: the file is local-only in any repo
# with a public remote, so `git show` would find nothing in exactly the repos
# that keep it private. The name is matched byte for byte through a glob, the
# same technique board.sh uses for its PLAN column, because APFS makes
# `[ -f PLAN.md ]` true for `plan.md`.
#
# Live sessions are counted by `claude agents --json --cwd <physical path>`.
# Measured 2026-09-26 against a session known to be running (this script's own
# building session): the listing includes interactive AND background sessions,
# and respects path boundaries (`--cwd .../repo-mail` did not return
# `.../repo-mailbox`). The filter matches the directory a session was STARTED
# in, not the `cwd` field it prints: that session started in the repo root and
# then entered `<repo>/.claude/worktrees/<name>`, and `--cwd <repo root>`
# returned it while `--cwd <that worktree>` returned `[]`. So --repo must be
# the main checkout root (where a local-only PLAN.md lives anyway); a session
# started elsewhere that works on this repo by absolute path is not counted.
# A path that does not exist returns `[]` with exit 0, which is why the repo is
# resolved and checked BEFORE the query - otherwise a typo would read as
# "nobody is running". Every listed session counts, whatever its state and
# whether it builds or reads: the listing cannot tell a build session from a
# read session, so the cap counts the conservative way.
#
# Exit codes: 0 = allow, 2 = deny (one line on stderr naming the rule and what
# is missing). Usage errors deny too: a caller that cannot form the call has not
# been checked.
set -u
export LC_ALL=C
# Policy constants the operator chose on 2026-09-25. Written out, not derived.
QUOTA_LIMIT=10 # R3: percent of the weekly quota spent without movement
NIGHT_FROM=23 # R6: night is [23:00, 07:00) local time
NIGHT_UNTIL=7
usage() {
cat <<'EOF'
Usage: session-gate.sh --repo <dir> [--kind build|read] [--night]
Decide whether a NEW session may start in <dir>. Reads <dir>/PLAN.md from disk
and, for a build session, `claude agents --json --cwd <dir>`. Writes nothing.
Exit: 0 = allow (one ALLOW line on stdout)
2 = deny (one line on stderr: the rule and what is missing)
Every check that cannot run denies (fail-closed), including usage errors.
--repo <dir> the repo's main checkout root, where PLAN.md lives (required).
Not a worktree path: live sessions are matched by the
directory they were started in, and worktrees sit below it.
--kind K build (default) or read. Only build sessions are capped (R6).
--night treat the start as a night start regardless of the clock.
R4 PLAN.md carries exactly ONE gate line starting in column 1, recommended
directly under the title:
<!-- gate: gjenfortelling=<time>; dekning=komplett; r1-proeve=<time|->; foerste-maaling=<time|->; retning=<name> -->
gjenfortelling = when the operator said yes to the three-sentence retelling
dekning = exactly `komplett` once every decision and recommendation
is covered or explicitly declined in the coverage table
r1-proeve = when the operator approved the R1 "show me" sample, or -
foerste-maaling= when the first measurement showed the right track, or -
retning = the name of the direction these times belong to
<time> is ISO 8601 with a time: 2026-09-26T07:10, optional :SS and Z/+HH:MM.
All five keys must be present; unknown or repeated keys deny.
R3 PLAN.md has exactly one `## ... Logg` section holding one markdown table
with the columns Bevegelse and Kvote (or Kvoteandel). Every row must have
Bevegelse: starts with `ja` or `nei` (any case), free text may follow
Kvote: a percent of the WEEKLY quota, e.g. `3%`, `2,5 %`
Rows are oldest first. Deny when the Kvote of the rows WITHOUT movement,
counted back from the last row WITH movement (or from the first row if none
moved), sums to 10% or more. A row missing a field is unmeasured and denies.
R6 Night (23:00-07:00 local, or --night): deny unless r1-proeve is a time.
Build sessions: deny when the live sessions under <dir> reach the cap:
cap 1 until BOTH r1-proeve and foerste-maaling are times, then 2.
Caveat, and it is the important part:
The gate checks that the fields are filled in, not that they were thought through correctly.
A yes, a coverage table and a quota share are all things a session can type.
The retelling is the only control with real content, because the operator reads it.
Testing knob: SESSION_GATE_NOW=HH:MM replaces the clock. An unusable value
(empty, not HH:MM, hour above 23) denies by name rather than falling back.
EOF
}
deny() {
printf 'session-gate: DENY %s\n' "$(printf '%s' "$*" | tr '\n\r\t' ' ')" >&2
exit 2
}
REPO=""; HAVE_REPO=0; KIND=build; NIGHT=0
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--repo) [ $# -ge 2 ] || deny "usage: --repo needs a directory (see --help)"
REPO="$2"; HAVE_REPO=1; shift 2 ;;
--kind) [ $# -ge 2 ] || deny "usage: --kind needs build or read (see --help)"
KIND="$2"; shift 2 ;;
--night) NIGHT=1; shift ;;
*) deny "usage: unknown argument '$1' (see --help)" ;;
esac
done
{ [ "$HAVE_REPO" -eq 1 ] && [ -n "$REPO" ]; } || deny "usage: --repo <dir> is required (see --help)"
case "$KIND" in
build|read) ;;
*) deny "usage: --kind must be build or read, not '$KIND'" ;;
esac
# --- the clock, validated before anything depends on it --------------------
if [ "${SESSION_GATE_NOW+set}" = set ]; then
HHMM="$SESSION_GATE_NOW"
printf '%s' "$HHMM" | grep -Eq '^([01][0-9]|2[0-3]):[0-5][0-9]$' \
|| deny "SESSION_GATE_NOW='$HHMM' is not HH:MM (00:00-23:59); unset it to use the real clock"
else
HHMM="$(date +%H:%M 2>/dev/null)"
printf '%s' "$HHMM" | grep -Eq '^([01][0-9]|2[0-3]):[0-5][0-9]$' \
|| deny "R6: could not read the local clock (date gave '$HHMM')"
fi
HOUR=$((10#${HHMM%%:*}))
# --- the repo and its PLAN.md -------------------------------------------------
[ -d "$REPO" ] || deny "repo: '$REPO' is not a directory"
PHYS="$(cd -P "$REPO" 2>/dev/null && pwd -P)" || deny "repo: cannot resolve '$REPO'"
[ -n "$PHYS" ] || deny "repo: cannot resolve '$REPO'"
PLAN=""
for f in "$PHYS"/PLAN.m[d]; do
[ "$(basename "$f")" = "PLAN.md" ] && PLAN="$f"
done
[ -n "$PLAN" ] || deny "PLAN.md: no file named exactly PLAN.md in $PHYS"
[ -f "$PLAN" ] || deny "PLAN.md: $PLAN is not a regular file"
[ -r "$PLAN" ] || deny "PLAN.md: $PLAN is not readable"
CONTENT="$(cat "$PLAN" 2>/dev/null)" || deny "PLAN.md: $PLAN could not be read"
# --- R4: the retelling and the coverage table ---------------------------------
NGATE="$(printf '%s\n' "$CONTENT" | grep -c '^<!-- gate:')"
[ "$NGATE" -ge 1 ] || deny "R4: PLAN.md has no gate line ('<!-- gate: gjenfortelling=...; dekning=komplett; ... -->', see --help)"
[ "$NGATE" -eq 1 ] || deny "R4: PLAN.md has $NGATE gate lines; exactly one is allowed"
GLINE="$(printf '%s\n' "$CONTENT" | grep '^<!-- gate:' | sed 's/[[:space:]]*$//')"
case "$GLINE" in
*'-->') ;;
*) deny "R4: the gate line does not end with -->" ;;
esac
BODY="${GLINE#<!-- gate:}"
BODY="${BODY%-->}"
is_time() {
printf '%s' "$1" | grep -Eq '^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?(Z|[+-]([01][0-9]|2[0-3]):?[0-5][0-9])?$'
}
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
GJ=""; DEK=""; R1=""; MAAL=""; RET=""
SEEN=" "
OLDIFS="$IFS"; IFS=';'; set -f
for kv in $BODY; do
IFS="$OLDIFS"
kv="$(trim "$kv")"
[ -n "$kv" ] || { IFS=';'; continue; }
case "$kv" in
*=*) ;;
*) set +f; deny "R4: gate line field '$kv' is not key=value" ;;
esac
key="$(trim "${kv%%=*}")"; val="$(trim "${kv#*=}")"
case "$SEEN" in
*" $key "*) set +f; deny "R4: gate line repeats '$key'" ;;
esac
SEEN="$SEEN$key "
case "$key" in
gjenfortelling) GJ="$val" ;;
dekning) DEK="$val" ;;
r1-proeve) R1="$val" ;;
foerste-maaling) MAAL="$val" ;;
retning) RET="$val" ;;
*) set +f; deny "R4: gate line has unknown key '$key'" ;;
esac
IFS=';'
done
IFS="$OLDIFS"; set +f
for k in gjenfortelling dekning r1-proeve foerste-maaling retning; do
case "$SEEN" in
*" $k "*) ;;
*) deny "R4: gate line is missing $k= (write - where a time is not there yet)" ;;
esac
done
is_time "$GJ" || deny "R4: gjenfortelling='$GJ' is not the operator's yes with a time (e.g. 2026-09-26T07:10)"
[ "$DEK" = "komplett" ] || deny "R4: dekning='$DEK' - the coverage table is not declared komplett"
{ [ -n "$RET" ] && [ "$RET" != "-" ]; } || deny "R4: retning is empty - name the direction the approval belongs to"
{ [ "$R1" = "-" ] || is_time "$R1"; } || deny "R4: r1-proeve='$R1' is neither a time nor -"
{ [ "$MAAL" = "-" ] || is_time "$MAAL"; } || deny "R4: foerste-maaling='$MAAL' is neither a time nor -"
# --- R3: movement per quota share, over the Logg table ------------------------
R3OUT="$(printf '%s\n' "$CONTENT" | awk -v LIMIT="$QUOTA_LIMIT" '
function trim(s) { sub(/^[ \t\r]+/, "", s); sub(/[ \t\r]+$/, "", s); return s }
function fail(m) { if (!bad) { bad = 1; msg = m }; exit }
function cells(line, arr, s) {
s = trim(line); sub(/^\|/, "", s); sub(/\|$/, "", s)
return split(s, arr, "|")
}
BEGIN { nsec = 0; insec = 0; lvl = 0; ts = 0; ncol = 0; bcol = 0; kcol = 0; sum = 0; rows = 0; bad = 0 }
/^#+[ \t]/ {
match($0, /^#+/); h = RLENGTH
if ($0 ~ /^#+[ \t]+(.*[^A-Za-z])?Logg[ \t\r]*$/) {
nsec++
if (nsec > 1) fail("R3: PLAN.md has more than one Logg section")
insec = 1; lvl = h; next
}
if (insec && h <= lvl) insec = 0
}
!insec { next }
/^[ \t]*\|/ {
if (ts == 3) fail("R3: the Logg section holds a second table; rows must be in one table")
if (ts == 0) {
ncol = cells($0, c)
for (i = 1; i <= ncol; i++) {
v = tolower(trim(c[i]))
if (v == "bevegelse") { if (bcol) fail("R3: the Logg table has two Bevegelse columns"); bcol = i }
if (v == "kvote" || v == "kvoteandel") { if (kcol) fail("R3: the Logg table has two Kvote columns"); kcol = i }
}
if (!bcol) fail("R3: the Logg table has no Bevegelse column")
if (!kcol) fail("R3: the Logg table has no Kvote column")
ts = 1; next
}
if (ts == 1) {
if ($0 !~ /^[ \t]*\|[ \t:|-]*-[ \t:|-]*[ \t\r]*$/) fail("R3: the Logg table header is not followed by a |---| separator row")
ts = 2; next
}
rows++
n = cells($0, c)
if (n != ncol) fail("R3: Logg row " rows " has " n " cells, the header has " ncol)
b = tolower(trim(c[bcol]))
if (b ~ /^ja($|[ \t:;,.()-])/) moved = 1
else if (b ~ /^nei($|[ \t:;,.()-])/) moved = 0
else fail("R3: Logg row " rows " Bevegelse is \"" trim(c[bcol]) "\" - it must start with ja or nei (unmeasured denies)")
k = trim(c[kcol])
if (k !~ /^[0-9]+([.,][0-9]+)?[ \t]*%/) fail("R3: Logg row " rows " Kvote is \"" k "\" - it must be a percent of the weekly quota, e.g. 3% (unmeasured denies)")
sub(/[ \t]*%.*/, "", k); gsub(/,/, ".", k)
if (moved) sum = 0; else sum += k + 0
next
}
{
if (ts == 2) ts = 3
if (ts == 1) fail("R3: the Logg table header is not followed by a |---| separator row")
if ($0 ~ /^[ \t]*([-*+]|[0-9]+\.)[ \t]/) fail("R3: the Logg section holds a list item; every session row must be a table row the gate can read")
}
END {
if (bad) { print msg; exit 0 }
if (nsec == 0) { print "R3: PLAN.md has no Logg section (a heading ending in Logg)"; exit 0 }
if (ts == 0) { print "R3: the Logg section has no table with Bevegelse and Kvote columns"; exit 0 }
if (ts == 1) { print "R3: the Logg table header is not followed by a |---| separator row"; exit 0 }
if (sum >= LIMIT - 0.000001) {
printf "R3: %g%% of the weekly quota spent without movement since the last row that moved (limit %d%%); a new session needs movement or the operator first\n", sum, LIMIT
exit 0
}
printf "OK %g %d\n", sum, rows
}')" || deny "R3: the Logg section could not be read (awk failed)"
case "$R3OUT" in
"OK "*) R3SUM="$(printf '%s' "$R3OUT" | awk '{print $2}')" ;;
R3:*) deny "$R3OUT" ;;
*) deny "R3: the Logg section could not be read" ;;
esac
# --- R6: night starts and the concurrency cap ----------------------------------
IS_NIGHT="$NIGHT"
{ [ "$HOUR" -ge "$NIGHT_FROM" ] || [ "$HOUR" -lt "$NIGHT_UNTIL" ]; } && IS_NIGHT=1
if [ "$IS_NIGHT" -eq 1 ] && [ "$R1" = "-" ]; then
deny "R6: night start ($HHMM local or --night) needs r1-proeve=<time> in the gate line; it is -"
fi
if [ "$KIND" = "read" ]; then
printf 'session-gate: ALLOW %s kind=read R4=ok R3=%s%%/%s%% R6=uncapped night=%s\n' \
"$PHYS" "$R3SUM" "$QUOTA_LIMIT" "$IS_NIGHT"
exit 0
fi
if [ "$R1" != "-" ] && [ "$MAAL" != "-" ]; then CAP=2; else CAP=1; fi
command -v claude >/dev/null 2>&1 || deny "R6: cannot count live sessions - 'claude' is not on PATH"
AGJSON="$(claude agents --json --cwd "$PHYS" 2>/dev/null)"; AGRC=$?
[ "$AGRC" -eq 0 ] || deny "R6: 'claude agents --json' exited $AGRC - live sessions not counted"
COMPACT="$(printf '%s' "$AGJSON" | tr -d ' \t\r\n')"
case "$COMPACT" in
'[]') LIVE=0 ;;
'['*']')
LIVE="$(printf '%s\n' "$AGJSON" | grep -o '"cwd"[[:space:]]*:' | wc -l | tr -d ' ')"
[ "$LIVE" -ge 1 ] 2>/dev/null || deny "R6: 'claude agents --json' returned a list with no readable sessions" ;;
*) deny "R6: 'claude agents --json' did not return a JSON array - live sessions not counted" ;;
esac
if [ "$LIVE" -ge "$CAP" ]; then
if [ "$CAP" -eq 1 ]; then
deny "R6: $LIVE live session(s) under $PHYS, cap is 1 until both r1-proeve and foerste-maaling are set"
fi
deny "R6: $LIVE live session(s) under $PHYS, cap is $CAP"
fi
printf 'session-gate: ALLOW %s kind=build R4=ok R3=%s%%/%s%% R6=live %s/cap %s night=%s\n' \
"$PHYS" "$R3SUM" "$QUOTA_LIMIT" "$LIVE" "$CAP" "$IS_NIGHT"
exit 0