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

@ -14,7 +14,7 @@ Session A in repo X leaves a message for repo Y; the next session in repo Y gets
![Hooks](https://img.shields.io/badge/hooks-1-green)
![Skills](https://img.shields.io/badge/skills-4-orange)
![CLI scripts](https://img.shields.io/badge/CLI_scripts-8-blue)
![Selftest checks](https://img.shields.io/badge/selftest_checks-1006-blue)
![Selftest checks](https://img.shields.io/badge/selftest_checks-1103-blue)
---
@ -100,11 +100,13 @@ Scoring is judgement and belongs to the skill; turning scores into a row is a lo
**A third form: the background session.** When the dispatching session starts the child itself, the skill describes `claude --model <m> --effort <e> --disallowedTools AskUserQuestion,SendMessage,ListAgents --bg --name <name> "<starter>"`. `--model` comes directly after `claude`, and the model and effort are taken from `route.sh`. The three disallowed tools are there because a background child cannot be answered: a question stops the child, and a message reaching back to its parent is a direction nobody asked for. The starter tells the child to pin its branch with `git checkout -B`, to commit there, and to push, tag or release nothing. Merging and publishing belong to the operator. This form lives in the skill alone. It has no pane, no plan block and no `morning` consumer, so `board.sh --dispatch` still emits exactly the two forms above.
**Gating a session start (`session-gate.sh`).** Whatever starts a session can ask first. `session-gate.sh --repo <dir>` reads that repo's `PLAN.md` from disk and answers with exit 0 (allow) or 2 (deny, one line naming the rule and what is missing), with zero model calls. It checks three things. First, a single gate line records when the operator said yes to the retelling and declares the coverage table complete (`<!-- gate: gjenfortelling=<time>; dekning=komplett; r1-proeve=<time|->; foerste-maaling=<time|->; retning=<name> -->`). Second, the `Logg` table's `Bevegelse` and `Kvote` columns show less than 10% of the weekly quota spent without movement since the last row that moved. Third, a build session stays under the concurrency cap, counted with `claude agents --json --cwd <dir>`: 1 until both the sample and the first measurement are recorded, then 2. Night starts (23:00-07:00) need the recorded sample. Every check that cannot run denies, including a row with a field left blank or unmeasured. The gate checks that the fields are filled in, not that they were thought through: the retelling is the only control with real content, because the operator reads it. `board.sh --dispatch` does not call it. The dispatch line is generated before a session starts, often much earlier, so a live-session count taken then would be stale by start time. Counting live sessions is also a process reading, which the board refuses by design. The gate belongs where the start actually happens.
**Making the order outlive the tab (the order queue).** A prompt file passed through argv dies with the pane it was typed into, and nothing in the receiving repo records that an order ever arrived. Measured 2026-08-17: one order was dispatched three times over 90 minutes before it was worked, because the first two tabs ran something else. So dispatch now delivers the order into the recipient's own queue — `~/.claude/coord/<repo>/orders/` — and the pasted line becomes a thin **starter** carrying only the order id. The order text has one home. If the tab is never run, nothing is lost: the order stays pending, is re-injected at every session start in that repo, and shows up in `board`'s ORDRE column, which sits beside INN and is never summed with it — INN is "others are waiting on you", ORDRE is "work is waiting on this repo".
Ownership is explicit rather than implied. An order is pending until a session **claims** it, and the claim is a rename with no check-then-act step, so of any number of racing sessions exactly one wins and the rest get a clean refusal. The claiming session owns it until it either closes it with a commit pointer or **returns** it with a reason recorded in the order itself. At claim time the session is told to compare the order against its own `STATE.md` next step and to state any divergence in its first reply — a dispatch that displaces a live next step is a decision, and this makes it an uttered one. A session that claims an order and dies is the one remaining way an order could vanish, so claimed orders stay visible in the injection with their in-flight age; that is a visible-again rule, not a lease timer, because nothing here can know that a session is dead.
**CLI.** The engine is twelve user-facing bash scripts in the plugin's `scripts/` directory (plus five selftests); resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
**CLI.** The engine is thirteen user-facing bash scripts in the plugin's `scripts/` directory (plus six selftests); resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
coord-send.sh --to <repo> --subject "<subject>" [--message "<text>"] # or body on stdin
coord-send.sh --to <repo> --subject "<subject>" --fyi # a notice: no reply expected
@ -128,6 +130,7 @@ Ownership is explicit rather than implied. An order is pending until a session *
brief-nightly.sh # render the briefing to a file, atomically
route.sh --path <v> --verification <v> --reversibility <v> \
--scope <v> --rationale "<why>" # model + effort for the next session
session-gate.sh --repo <dir> [--kind build|read] [--night] # may a new session start in <dir>? 0 allow, 2 deny
The reply/resolve hints the hook injects (`-> reply: coord-send --reply-to … | done without reply: coord-done …`) refer to these scripts.
@ -207,7 +210,8 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
bash scripts/route-selftest.sh # 73 checks, incl. the route->board round trip
bash scripts/orders-selftest.sh # 116 checks, incl. the 20-way barriered claim race
bash scripts/state-line-guard-selftest.sh # 54 checks, incl. the Edit replace_all projection and the ratchet
npm test # all five selftests, the hook tests, and the README-number check
bash scripts/session-gate-selftest.sh # 97 checks against fixture repos and a fake claude on PATH
npm test # all six selftests, the hook tests, and the README-number check
TDD is the house rule: every behavior change lands with a failing selftest check first.