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:
parent
6c9b17277c
commit
02d4df2dfb
5 changed files with 794 additions and 10 deletions
408
scripts/session-gate-selftest.sh
Normal file
408
scripts/session-gate-selftest.sh
Normal 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
319
scripts/session-gate.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue