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
56
CLAUDE.md
56
CLAUDE.md
|
|
@ -1533,6 +1533,47 @@ marketplace plugin. Three components, one boundary:
|
||||||
repo is macOS-only (BSD `stat`), and a case-sensitive APFS volume is the
|
repo is macOS-only (BSD `stat`), and a case-sensitive APFS volume is the
|
||||||
remaining gap.
|
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
|
- **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
|
doors mapping user intent to engine invocations. No mailbox logic lives here
|
||||||
either. `board` additionally owns the *ranking* — which repo wins and why —
|
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/coord-selftest.sh` must exit 0 (266/266),
|
||||||
`bash scripts/board-selftest.sh` must exit 0 (497/497),
|
`bash scripts/board-selftest.sh` must exit 0 (497/497),
|
||||||
`bash scripts/route-selftest.sh` must exit 0 (73/73),
|
`bash scripts/route-selftest.sh` must exit 0 (73/73),
|
||||||
`bash scripts/orders-selftest.sh` must exit 0 (116/116) and
|
`bash scripts/orders-selftest.sh` must exit 0 (116/116),
|
||||||
`bash scripts/state-line-guard-selftest.sh` must exit 0 (54/54).
|
`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
|
- English for all code, docs, and commit messages (public repo). Norwegian
|
||||||
trigger aliases in the skill description are deliberate.
|
trigger aliases in the skill description are deliberate.
|
||||||
- **README's Install block is FENCED (```bash) while the rest of the README
|
- **README's Install block is FENCED (```bash) while the rest of the README
|
||||||
|
|
@ -1653,9 +1695,10 @@ obligations in another repo.
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- Test: `bash scripts/coord-selftest.sh`, `bash scripts/board-selftest.sh`,
|
- Test: `bash scripts/coord-selftest.sh`, `bash scripts/board-selftest.sh`,
|
||||||
`bash scripts/route-selftest.sh`, `bash scripts/orders-selftest.sh` and
|
`bash scripts/route-selftest.sh`, `bash scripts/orders-selftest.sh`,
|
||||||
`bash scripts/state-line-guard-selftest.sh` (or `npm test`, the Node wrapper
|
`bash scripts/state-line-guard-selftest.sh` and
|
||||||
around all five plus the hook tests and the README-number check).
|
`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
|
`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,
|
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.
|
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;
|
strong --reversibility cheap --scope local --rationale smoke` (read-only;
|
||||||
needs a non-empty `/tmp/x.prompt`. Use `--target-pane yes` in a smoke test:
|
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)
|
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
|
- Route smoke test: `bash scripts/route.sh --path known --verification strong
|
||||||
--reversibility cheap --scope local --rationale x` (writes nothing, instant)
|
--reversibility cheap --scope local --rationale x` (writes nothing, instant)
|
||||||
- Sweep smoke test: `bash scripts/coord-sweep.sh` (dry-run is the default, so
|
- Sweep smoke test: `bash scripts/coord-sweep.sh` (dry-run is the default, so
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -14,7 +14,7 @@ Session A in repo X leaves a message for repo Y; the next session in repo Y gets
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -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.
|
**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".
|
**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.
|
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>" [--message "<text>"] # or body on stdin
|
||||||
coord-send.sh --to <repo> --subject "<subject>" --fyi # a notice: no reply expected
|
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
|
brief-nightly.sh # render the briefing to a file, atomically
|
||||||
route.sh --path <v> --verification <v> --reversibility <v> \
|
route.sh --path <v> --verification <v> --reversibility <v> \
|
||||||
--scope <v> --rationale "<why>" # model + effort for the next session
|
--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.
|
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/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/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
|
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.
|
TDD is the house rule: every behavior change lands with a failing selftest check first.
|
||||||
|
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -74,6 +74,13 @@ test('orders bash selftest passes', () => {
|
||||||
runSuite('orders');
|
runSuite('orders');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// session-gate.sh is the check a session starter calls before it starts one.
|
||||||
|
// Its suite feeds live sessions from a fake `claude` on PATH, so it proves the
|
||||||
|
// gate without depending on what happens to be running on this machine.
|
||||||
|
test('session-gate bash selftest passes', () => {
|
||||||
|
runSuite('session-gate');
|
||||||
|
});
|
||||||
|
|
||||||
// The engine refuses to invent an identity from the cwd, but the hook is the
|
// The engine refuses to invent an identity from the cwd, but the hook is the
|
||||||
// FOURTH place repo identity is derived, and a rule enforced in three of four
|
// FOURTH place repo identity is derived, and a rule enforced in three of four
|
||||||
// places is not a rule: as long as the hook resolved the name itself and passed
|
// places is not a rule: as long as the hook resolved the name itself and passed
|
||||||
|
|
@ -255,7 +262,7 @@ function suiteTotal(name) {
|
||||||
|
|
||||||
test('README states the selftest counts the suites actually reported', () => {
|
test('README states the selftest counts the suites actually reported', () => {
|
||||||
const readme = readFileSync(join(root, 'README.md'), 'utf8');
|
const readme = readFileSync(join(root, 'README.md'), 'utf8');
|
||||||
const suites = ['coord', 'board', 'route', 'orders', 'state-line-guard'];
|
const suites = ['coord', 'board', 'route', 'orders', 'state-line-guard', 'session-gate'];
|
||||||
|
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (const name of suites) {
|
for (const name of suites) {
|
||||||
|
|
@ -275,5 +282,5 @@ test('README states the selftest counts the suites actually reported', () => {
|
||||||
const badge = readme.match(/badge\/selftest_checks-(\d+)-/);
|
const badge = readme.match(/badge\/selftest_checks-(\d+)-/);
|
||||||
assert.ok(badge, 'README has no selftest_checks badge to check');
|
assert.ok(badge, 'README has no selftest_checks badge to check');
|
||||||
assert.equal(Number(badge[1]), sum,
|
assert.equal(Number(badge[1]), sum,
|
||||||
`README's badge says ${badge[1]} selftest checks; the five suites reported ${sum}`);
|
`README's badge says ${badge[1]} selftest checks; the ${suites.length} suites reported ${sum}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue