#!/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 `. # # 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 `. # 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 `/.claude/worktrees/`, and `--cwd ` # returned it while `--cwd ` 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 [--kind build|read] [--night] Decide whether a NEW session may start in . Reads /PLAN.md from disk and, for a build session, `claude agents --json --cwd `. 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 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: 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