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>
319 lines
15 KiB
Bash
Executable file
319 lines
15 KiB
Bash
Executable file
#!/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
|