Per-pass discipline for an active seeks loop. Drive state ONLY through bin/seeks.mjs (which self-resolves .seeks via git). Never hand-write status.json.
---
name: loop
description: Per-pass discipline for an active seeks loop. Drive state ONLY through bin/seeks.mjs (which self-resolves .seeks via git). Never hand-write status.json.
---
# seeks — per-pass protocol
> **CARDINAL RULE: do exactly ONE pass, then STOP and end your turn.** Never loop internally across passes. Ending your turn is what lets the Stop hook drive the next pass, advance `stop_fires` (so the `max_iters` backstop + stuck guard can fire), and survive context compaction. A loop where you never yield is unkillable by the hook.
>
> **Wind-down:** if the Stop-hook continue-message says the **time budget is nearly up**, treat it as a wind-down — do NOT start new work: write/refresh `summary.md` (what you found, what's still open), commit it, then end your turn. The clock will halt the loop shortly after.
State for loop `<name>` lives in the PRIMARY checkout's `.seeks/run/<name>/`. You are usually inside the loop's worktree; **always mutate state through the CLI, which resolves the control plane via git** — never write `.seeks/...` by relative path yourself:
`node "${CLAUDE_PLUGIN_ROOT}/bin/seeks.mjs" <subcommand> <name> ...` (run via the Bash tool).
## Every pass
1. **Orient** — read `.seeks/run/<name>/{state.md,backlog.md,context.md}` and `.seeks/loops/<name>/spec.md` (`node "${CLAUDE_PLUGIN_ROOT}/bin/seeks.mjs" seeks-dir` prints the absolute `.seeks` path if you need it).
2. **Backlog has `- [ ]` items** → do the SINGLE next one (respect `level`: L1 = no edits, findings to state.md; L2 = edit + commit on `seeks/<name>`). Mark it `- [x]`; record:
`… status-set <name> '{"last_change":"<what you did>"}'`; `… log-add <name> "pass N — <summary>"` (appends to `log.md`; never hand-append by relative path); commit `seeks(<name>): pass N — <summary>` (L2+).
3. **Backlog EMPTY** → ask the engine whether the sweep bar is met — **never re-derive it yourself**: `STATUS=$(node "${CLAUDE_PLUGIN_ROOT}/bin/seeks.mjs" sweep-status <name>)`. This returns the **exact predicate the gate releases on** (`{mode, satisfied, label, …}`). Reading `dry_sweeps`/`min_dry_sweeps` by hand is the trap that deadlocks an exhaustive loop: in `mode:"exhaustive"` the gate keys off `dry_depth_rounds`, **not** `dry_sweeps`, so a "3 dry sweeps → certify" shortcut sets `done` while the gate holds out forever.
- **`satisfied:true`** (this includes `mode:"none"` — no sweep configured, the legacy default) → go to the **verifier** (below).
- **`satisfied:false`** → run **exactly one creative discovery sweep through a fresh lens**, then end the pass. Get the angle: `LENS=$(… sweep-next-lens <name>)` (breadth-first through the catalog; a repeated lens won't advance the streak, so always take the one you're handed). **Depth (exhaustive):** the `sweep-status` label shows `depth` — depth 1 = each module's logic; depth 2 = cross-module dataflow; depth 3 = adversarial inputs / probing tests. Review through `$LENS` **at the current depth**; when a full catalog pass comes up dry the engine deepens automatically (via `sweep-tick`). **You MUST dispatch a bug-hunter subagent** for the sweep (triage role's model — `node "${CLAUDE_PLUGIN_ROOT}/bin/seeks.mjs" role triage`), handing it `$LENS` + the goal's blast radius. **Bound its reply** so a high-effort model doesn't stall generating a huge final answer (the failure mode where an opus hunter has to be killed and re-dispatched): tell it to *reason internally* but **return ONLY a terse findings list** — per real, demonstrable defect one line of `file:line · one-line defect · severity · one-line repro/why`; **cap ~5**; if it finds nothing, reply exactly `FINDINGS: 0`. No prose essay, no re-listing the source. This is not optional: a sweep you run **inline is anchored on your own edits**, so an inline "found nothing" is the self-grading seeks exists to prevent — and in until-dry/exhaustive mode those dry sweeps are exactly what advances the gate toward release. The subagent READS source and reasons about correctness through `$LENS`; **re-running tests/linters is only a regression floor, NOT the bug finder.** Then `… sweep-tick <name> <count-of-NEW-real-bugs> "$LENS"`:
- **Found > 0** → `… backlog-add <name> "<each new item>"` (re-seed); end the pass. (`sweep-tick` reset the dry streak to 0.)
- **Found == 0** → end the pass. The loop keeps sweeping — and in exhaustive mode **deepening** — until `sweep-status` reports `satisfied:true` **or the time budget winds it down.** For a rich/overnight target the time-budget halt is the *expected* end; do NOT short-circuit to the verifier on a few dry sweeps, and do NOT disarm — if the gate keeps blocking a loop you think is finished, **read its block reason**, which names the unmet bar (e.g. dry depth-round k/N).
**Verifier subagent** (separate context, maker ≠ checker): dispatch it **with the verifier role's model** (`node "${CLAUDE_PLUGIN_ROOT}/bin/seeks.mjs" role verifier` → `{model,effort}`); hand it the absolute worktree path; it must `cd` there and RUN each executable done-condition from spec.md itself (subagents do NOT inherit cwd), write `verify/<n>.md`, and report per condition.
- **All non-human conditions pass →** first **account for the oracle** (tests *and* build manifests/runner configs/CI — by default, if any **pre-existing** oracle file was modified or deleted, or a new runner config (`pytest.ini`, `conftest.py`, `.npmrc`, `tsconfig.json`, …) was added, the gate ends the loop in needs-human at release no matter what you ack, so a fix that needs to change an existing test, a `package.json` script or a runner config is a human's call; *adding* test files is free): run `… oracle-diff <name>`; for each changed/added oracle (test) file, open it and justify the change in `verify/oracle.md` (a relaxed assertion that hides a real failure is a REJECT, not an account), then `… oracle-ack <name>`. **Check `oracle_globs_present` in that output:** if it's `0` while a done-condition is test-based, the hash-accounting is vacuous (no test-glob files to pin) — note it in `verify/oracle.md` and rely on actually running the oracle + your judgment, not the hash. **Before you certify, re-confirm `… sweep-status <name>` is `satisfied:true`** — setting `done` while the sweep bar is unmet only deadlocks against the gate. Then certify: `… certify <name>`. That is the verifier's sign-off, **not** the verdict: at the next stop the **Stop hook runs every executable done-condition itself** in the worktree, and releases `done` only if each exits with its expected code (and `sweep-status` is `satisfied:true`, and the oracle ack matches the current diff). If one fails, the gate clears the certification, counts a condition-reject and shows you the output tail — fix it and re-verify. `status-set` can no longer write `done`, `verifier_certified` or `armed`, and cannot touch the budget, sweep, oracle or condition keys while the loop is live — it will refuse. **If `level` is L3, the loop must also deliver before it can finish:** when the gate's block reason says the conditions passed but the loop is undelivered, run `… deliver <name>` (pushes `seeks/<name>` and opens a PR; degrades to push-only/local if `gh`/remote are absent). The gate will not release `done` until `delivered` is true.
- **Any fails →** for each unmet condition: `… backlog-add <name> "<remediation>"` and `… condition-reject <name> <condition-id>` (atomic; auto-sets needs_human at the threshold). Set `last_verdict` (e.g. `"REJECT (typecheck)"`). `human-required` condition → `… status-set <name> '{"needs_human":true}'`.
4. **Second-to-last action:** `… progress-tick <name>` (recomputes open_items/no_progress/items_closed). Do ≥1 real tool action each pass (you always do).
5. **LAST action — STOP. One pass per turn.** After `progress-tick`, end your turn: print a one-line recap and nothing more. **Do NOT begin the next pass yourself.** The Stop hook re-invokes you for the next pass, or releases you when the loop is done / needs-human / stuck / at `max_iters`. This is mandatory — ending your turn is what advances `stop_fires` so the hook-owned `max_iters` backstop and the stuck guard can actually fire, and what lets state survive compaction between passes. Always stop after exactly one pass (one backlog item, OR one verifier round). **Do NOT disarm a loop yourself** — when a loop is genuinely terminal the gate *allows* the stop and prints the `✅ done` / `⏸ needs-human` / `⛔`/`⏰ halt` banner; a disarmed loop is invisible to the hook (no terminal banner) **and** self-disarm lets a maker bypass the sweep + delivery gates. If the gate keeps **blocking** a loop you believe is finished, that means a terminal condition is still unmet: **read the block reason — it names the exact bar** (dry depth-round k/N, undelivered L3, stale oracle ack). Act on it (keep sweeping, run `seeks deliver`, re-verify); never "unstick" the loop by disarming or re-certifying. Teardown is the user's call via `/seeks:stop` or `/seeks:harvest`.
## Verifier subagent (maker ≠ checker)
Fresh context; runs checks in the worktree; cites evidence; rejects on ambiguity; the only caller of `seeks certify`. Its job is the judgment the hook can't make (was a changed test weakened? do the fixes actually address the goal?); the exit codes are re-checked by the Stop hook regardless. A loop with **no executable condition** (subjective / `human_required`) can never be certified `done` by the gate — converge the mechanics, write findings/summary, then set `needs_human`. Do NOT set `done`.
**If the verifier subagent errors, stalls, or returns nothing, RE-DISPATCH it — never run the done-conditions yourself and certify on your own run.** That collapses maker≠checker (you become both the maker and the checker), and it's the whole point of the gate. Waiting one more pass for a fresh verifier is always correct; self-certifying to "unstick" the loop never is. You may run the conditions yourself for your *own* situational awareness, but the value written into `verifier_certified` must come from the independent verifier's verdict, not your inline run.
## Never
Never hand-write status.json/hook-state.json. Never let the maker self-certify. Never edit denylist paths. Never touch seeks' own plugin files. `seeks start` / `stop` / `reset-fires` / `budget-set` and the frozen status keys belong to the **user** while a loop is live — the CLI refuses them without the grant a user-typed `/seeks:start|stop|delete` creates; don't try to get around that.
**Hard-enforced — the PreToolUse hook denies these deterministically (you'll get a tool error; adapt, don't retry):** editing denylist paths (`**/.env`, `**/.env.*`, `**/secrets/**`, `**/.git`, `**/.git/**`, `**/*.pem`, `**/id_rsa*`, `**/id_ed25519*`, `**/id_ecdsa*`, `**/id_dsa*`, `**/.npmrc`, `**/.aws/**`, `**/.ssh/**` — a floor the loop can extend but not narrow); editing outside the worktree; touching `status.json`/`hook-state.json`/`decisions.jsonl`/`control-grant.json` **by any route — edit tool or Bash, read or write** (use `seeks status-get` / `status-set` / `why`). Via Bash the block reads the command: it follows `cd`, collapses `..`, expands `{a,b}` braces, resolves `*`/`?`/`[…]` globs, recurses into `eval`/`sh -c`, and scans `python -c`/`node -e`/`awk`/`sed` payloads, so a redirect, `tee`, `sed -i`, a relative path after a `cd`, a name spelled as a pattern, or `rm -rf` on the run dir are all denied. **It is best-effort, not a cage — which is exactly why the rule is a rule.** A shell can defeat any such check; reaching loop state by *any* means, including one that happens to slip past the hook, is a violation of this skill, not a clever workaround. At L1, any source edit or `git commit`; `git push` / `git merge` / `git rebase` at **every** level, including via `env`/`sudo`/`timeout`, a subshell `(git push)`, or `eval`/`sh -c` (delivery is automated via `seeks deliver` at L3 — the agent never pushes/merges directly). Oracle edits are NOT blocked, but by default modifying or deleting a *pre-existing* test or build manifest (`package.json`, `Makefile`, `pyproject.toml`, runner configs, CI) ends the loop in needs-human instead of done — prefer fixing the code; add new tests freely.
**Every verdict is logged** to `.seeks/run/<name>/decisions.jsonl` — allow, deny, and any hook crash. If a guardrail seems to have misfired, don't guess: run `seeks why <name> --denied` (or `--crashes`) and read the `rule`. If `SEEKS_STRICT_BASH` is on, Bash is deny-by-default and the error names the offending command — use an allowlisted tool instead of retrying.