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

@ -1533,6 +1533,47 @@ marketplace plugin. Three components, one boundary:
repo is macOS-only (BSD `stat`), and a case-sensitive APFS volume is the
remaining gap.
- **Session gate (`scripts/session-gate.sh`):** may a new session start in a
repo? Exit 0 = allow, 2 = deny with ONE stderr line naming the rule. Zero
model calls, writes nothing. Pinned by `session-gate-selftest.sh` (97 checks).
Order 20260926T070037Z-680859139 (`.claude`), turning three decisions from
the 2026-09-25 retro into a mechanism: R4 (one `<!-- gate: ... -->` line in
PLAN.md with the operator's yes to the retelling as a time and
`dekning=komplett`), R3 (the Logg table's Bevegelse/Kvote columns; deny at
>= 10% of the weekly quota without movement since the last row that moved)
and R6 (cap 1 live session per repo until `r1-proeve` AND `foerste-maaling`
are times, then 2; night 23:00-07:00 or `--night` needs `r1-proeve`).
**Fail-closed everywhere, and "unmeasured" is never zero.** A Logg row whose
Kvote is not a percent or whose Bevegelse does not start with `ja`/`nei`
denies. So do a missing `claude` binary, an agents listing that is not a
JSON array, two gate lines, two Logg sections, a list item inside Logg,
unknown or repeated gate keys, and an unusable `SESSION_GATE_NOW`. A usage
error denies too. Only `--kind read` skips the live count, because only
build sessions are capped. PLAN.md is read from DISK (it is local-only
wherever the remote is public) and its name matched byte for byte, the
same glob board.sh's PLAN column uses.
**`claude agents --json --cwd` filters on the directory a session was
STARTED in, not on the `cwd` field it prints.** Measured 2026-09-26 on the
building session itself. It started in this repo's root and then entered a
worktree. `--cwd <root>` returned it; `--cwd <that worktree>` returned `[]`.
So `--repo` must be the main checkout root, and a caller passing a worktree
path would under-count. A path that does not exist also returns `[]` with
exit 0, which is why the repo is resolved before the query. Every listed
session counts, interactive or background, whatever its state, because the
listing cannot tell build from read.
**`board.sh --dispatch` deliberately does NOT call it.** A dispatch line is
generated before the start, often long before, so a live count taken then is
stale when the session starts. And a live-session reading is the process
axis board.sh refuses structurally (no `pgrep`/`lsof`, FLY is never
liveness). The first caller is `.claude`'s budget hook, wired by the PM.
**The caveat is part of the contract and stands in `--help`:** the gate
checks that fields are filled in, not that they were thought through. The
retelling is the only control with real content, because the operator reads
it.
- **Skills (`skills/coord-send/`, `skills/board/`, `skills/route/`, `skills/dispatch/`):** natural-language front
doors mapping user intent to engine invocations. No mailbox logic lives here
either. `board` additionally owns the *ranking* — which repo wins and why —
@ -1602,8 +1643,9 @@ obligations in another repo.
`bash scripts/coord-selftest.sh` must exit 0 (266/266),
`bash scripts/board-selftest.sh` must exit 0 (497/497),
`bash scripts/route-selftest.sh` must exit 0 (73/73),
`bash scripts/orders-selftest.sh` must exit 0 (116/116) and
`bash scripts/state-line-guard-selftest.sh` must exit 0 (54/54).
`bash scripts/orders-selftest.sh` must exit 0 (116/116),
`bash scripts/state-line-guard-selftest.sh` must exit 0 (54/54) and
`bash scripts/session-gate-selftest.sh` must exit 0 (97/97).
- English for all code, docs, and commit messages (public repo). Norwegian
trigger aliases in the skill description are deliberate.
- **README's Install block is FENCED (```bash) while the rest of the README
@ -1653,9 +1695,10 @@ obligations in another repo.
## Commands
- Test: `bash scripts/coord-selftest.sh`, `bash scripts/board-selftest.sh`,
`bash scripts/route-selftest.sh`, `bash scripts/orders-selftest.sh` and
`bash scripts/state-line-guard-selftest.sh` (or `npm test`, the Node wrapper
around all five plus the hook tests and the README-number check).
`bash scripts/route-selftest.sh`, `bash scripts/orders-selftest.sh`,
`bash scripts/state-line-guard-selftest.sh` and
`bash scripts/session-gate-selftest.sh` (or `npm test`, the Node wrapper
around all six plus the hook tests and the README-number check).
`tests/tracked-terms.test.mjs` fails when any tracked path or line matches
a term in the untracked `tests/excluded-terms.local.md`, and SKIPS, loudly,
when that file is absent - the term list itself is never committed.
@ -1682,6 +1725,9 @@ obligations in another repo.
strong --reversibility cheap --scope local --rationale smoke` (read-only;
needs a non-empty `/tmp/x.prompt`. Use `--target-pane yes` in a smoke test:
it produces no plan file, so nothing can be handed to `morning` by accident)
- Session gate smoke test: `bash scripts/session-gate.sh --repo <dir>`
(read-only; exit 0 allow, 2 deny with one line; `--kind read` skips the
live-session count)
- Route smoke test: `bash scripts/route.sh --path known --verification strong
--reversibility cheap --scope local --rationale x` (writes nothing, instant)
- Sweep smoke test: `bash scripts/coord-sweep.sh` (dry-run is the default, so