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

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