diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3656d01..f8e9c50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,38 @@ semantics have been stable since 1.0.0.
## [Unreleased]
+### Added — Dorian Loop Guard (vNext / 1.4.0)
+- **`dorian loop`** (`src/dorian/loop.py`, `cmd_loop` in `commands.py`) — a deterministic,
+ token-free **steering layer for AI coding loops**, built as a thin classifier on top of
+ `revalidate` (no new verification, **no model at check time**):
+ - **`dorian loop preflight`** re-checks the claim warrants a change touched and emits a
+ **CONTINUE / REPAIR / ESCALATE** decision packet (`--format json|md|text`). The decision is
+ a pure function of the revalidate result, the `--policy` (`cautious|assist|unattended`), the
+ repair-attempt cap (`--max-repairs`/`--repair-attempts`/`--state-file`), and the
+ `--scope`/`--deny-path` lane. Exits **0 on success by default** (Dorian does not stop the loop
+ by itself); `--fail-on repair|escalate` opts into a hard exit-4 gate. Honors
+ `--deny-exec`/`--deny-shell`/`--checker-source`.
+ - **`dorian loop prompt`** renders the packet as a compact next-iteration instruction
+ (or `--from-json` a saved packet).
+ - **`dorian loop install`** scaffolds a project-local Claude Code skill at
+ `.claude/skills/dorian-loop-guard/` (**`/dorian-loop-guard`**) plus example LOOP.md / STATE.md /
+ run-log (`--with-state`) and a GitHub Actions example (`--with-action`). Writes files only;
+ idempotent; never overwrites without `--force`.
+- **Packaged loop-guard templates** (`src/dorian/templates/claude_code/dorian-loop-guard/…`, shipped
+ as wheel package data): the skill, a bundle README, `reference/loop-decisions.md` +
+ `reference/safety-boundary.md`, and example `LOOP.md`/`STATE.md`/`loop-run-log.md`/`dorian-loop.yml`.
+- **Docs**: [`docs/DORIAN_LOOP_GUARD.md`](docs/DORIAN_LOOP_GUARD.md),
+ [`docs/LOOP_ENGINEERING_ALIGNMENT.md`](docs/LOOP_ENGINEERING_ALIGNMENT.md),
+ [`docs/POSITIONING_LOOP_GUARD_2026_06_28.md`](docs/POSITIONING_LOOP_GUARD_2026_06_28.md), a README
+ "Using dorian inside AI coding loops" section, and a loop-memory note in
+ [`docs/CLAUDE_CODE_DORIAN_WORKFLOW.md`](docs/CLAUDE_CODE_DORIAN_WORKFLOW.md).
+
+**No breaking changes** — purely additive: warrant schema, checker grammar, exit codes, fold policy,
+and security posture are unchanged; the core stays zero-dependency; `REVOKED` remains a steering signal
+(not a halt), `ERRORED` remains fail-closed evidence (not a false claim), and **no model touches the
+verification path**. `claude_code.build_plan` gained an optional `manifest=` parameter (backward
+compatible) so the loop installer reuses the same scaffolding machinery.
+
## [1.3.0] — 2026-06-28
The **Dorian Claim Warrants for Claude Code** integration: one command scaffolds a project-local Claude
diff --git a/README.md b/README.md
index 36afc03..78760ee 100644
--- a/README.md
+++ b/README.md
@@ -341,6 +341,30 @@ you) can emit the claims — but the canonical setup is **Claude Code**:
> is fail-closed, **not a sandbox**; see [SECURITY.md](SECURITY.md) and
> [docs/SECURITY_BOUNDARY.md](docs/SECURITY_BOUNDARY.md).
+## Using dorian inside AI coding loops
+
+When you hand an agent a long-running or unattended task, **Dorian Loop Guard** is the
+deterministic verify step the loop runs *before each iteration*. `dorian loop preflight`
+re-checks the claim warrants the change touched (token-free, the same `revalidate` engine)
+and returns a steering signal — **Dorian does not stop the loop by default**:
+
+- **`continue`** — the warranted claims still hold; do the next planned step.
+- **`repair`** — a load-bearing claim is `REVOKED`; fix the smallest cause (or update the
+ claim if the change was intentional), re-check, and log the attempt.
+- **`escalate`** — a checker `ERRORED`, a sensitive/denylisted path is involved, the repair
+ cap was hit, or the break is out of scope; stop autonomous edits and hand off to a human.
+
+```bash
+dorian loop preflight --since --policy assist --format json # the decision packet
+dorian loop install # scaffold /dorian-loop-guard
+```
+
+Sealed warrants become the loop's **deterministic memory**: the next iteration revalidates
+them instead of re-deriving the facts. Loop Guard steers; it does **not** judge whole-loop
+success, replace tests/review, or sandbox execution — and `REVOKED` is a steering signal,
+not a halt. Full guide: [`docs/DORIAN_LOOP_GUARD.md`](docs/DORIAN_LOOP_GUARD.md); how it fits
+loop engineering: [`docs/LOOP_ENGINEERING_ALIGNMENT.md`](docs/LOOP_ENGINEERING_ALIGNMENT.md).
+
## What gets committed
- the artifact (e.g. `docs/changes/login.md`),
diff --git a/docs/CLAUDE_CODE_DORIAN_WORKFLOW.md b/docs/CLAUDE_CODE_DORIAN_WORKFLOW.md
index 42fb2b5..007a438 100644
--- a/docs/CLAUDE_CODE_DORIAN_WORKFLOW.md
+++ b/docs/CLAUDE_CODE_DORIAN_WORKFLOW.md
@@ -21,6 +21,13 @@
5. On every later PR, the GitHub Action runs `dorian revalidate --since `; if a later edit broke a
sealed claim, the warrant folds to **REVOKED** (exit 4) and the Action blocks — naming the claim.
+> **Claim warrants are not only post-change receipts — they are loop memory.** In an autonomous
+> coding loop, the warrants you seal here become the deterministic facts the *next* iteration
+> revalidates: `dorian loop preflight` re-checks them before each step and returns
+> **CONTINUE / REPAIR / ESCALATE** so a stale assumption surfaces as a steering signal instead of
+> rotting silently. Same mechanism, pointed forward. See
+> [`DORIAN_LOOP_GUARD.md`](DORIAN_LOOP_GUARD.md) and the `/dorian-loop-guard` skill.
+
## How the agent should write claims
Pick the **checker whose strength matches the claim** (the truth axis). Each claim is
diff --git a/docs/DORIAN_LOOP_GUARD.md b/docs/DORIAN_LOOP_GUARD.md
new file mode 100644
index 0000000..5ec24d2
--- /dev/null
+++ b/docs/DORIAN_LOOP_GUARD.md
@@ -0,0 +1,155 @@
+# Dorian Loop Guard
+
+**The deterministic truth layer for AI coding loops.** Loop Guard turns Dorian's claim
+warrants from post-hoc receipts into the loop's *truth memory* and *steering signal*:
+before each iteration it re-checks the warrants the change touched and returns a
+**CONTINUE / REPAIR / ESCALATE** decision — token-free, no model at check time.
+
+## What it is
+
+A thin classifier on top of `dorian revalidate`. `dorian loop preflight` runs the same
+deterministic re-check `revalidate` does, reads each touched warrant's post-fold trust
+state, and maps the result to a loop decision packet (JSON / markdown / text). The
+verdict is exactly `revalidate`'s — Loop Guard adds no new verification, no model, no
+tokens.
+
+## What it is not
+
+- **Not a whole-loop judge.** It verifies only the specific warranted claims a change
+ touched — never that "the change is good" or "the loop succeeded". It cannot catch a
+ lie of omission.
+- **Not a replacement** for tests, SAST, code review, or human judgment — complementary.
+- **Not a loop runner.** It does not schedule the loop, manage worktrees, deliver
+ notifications, or estimate token cost. Those belong to a loop framework (e.g.
+ [loop-engineering](https://github.com/cobusgreyling/loop-engineering)'s loop-init /
+ loop-audit / loop-cost). See [LOOP_ENGINEERING_ALIGNMENT.md](LOOP_ENGINEERING_ALIGNMENT.md).
+- **Not a sandbox.** `preflight` calls `revalidate`, which *runs* each touched claim's
+ checker; `C4 pytest:` / `C5 shell:` execute code. Use it on **trusted, internal repos**.
+
+## Why loops need a deterministic truth layer
+
+The loop-engineering failure catalog's single biggest trap is **weak verification**: the
+agent that wrote the code grading its own work, or a verifier that says "looks good"
+without running anything (*verifier theater*). The fix is an *independent* verifier — and
+the most independent verifier possible is one that runs **no model at all**. A claim
+warrant either matches the current code or flips to `REVOKED`, leaving an inspectable
+sidecar. Dorian cannot rubber-stamp, cannot be talked past, and costs zero tokens — so it
+is the natural deterministic floor under a loop's verify step.
+
+## Preflight → continue / repair / escalate
+
+```bash
+dorian loop preflight --since --policy assist --format json
+```
+
+The decision is a **pure function** of the revalidate result, the policy, the
+repair-attempt count, and the scope/denylist (full table in
+[loop-decisions.md](../src/dorian/templates/claude_code/dorian-loop-guard/reference/loop-decisions.md)):
+
+| # | Condition | Decision |
+|---|-----------|----------|
+| 1 | a checker **ERRORED** (could not run) | **escalate** (`fix_environment`) |
+| 2 | a break exists and `repair_attempts >= max_repairs` | **escalate** (infinite-fix cap) |
+| 3 | a broken/errored claim is bound to a **sensitive / denylisted** path | **escalate** |
+| 4 | a **load-bearing** break is **outside `--scope`** | **escalate** (over-reach) |
+| 5 | `unattended` policy with **no `--scope`** and a load-bearing break | **escalate** |
+| 6 | a **load-bearing** break, in scope, under the cap | **repair** |
+| 7 | only **non-load-bearing** breaks | `cautious`: repair · else continue (warn) |
+| 8 | no breaks (TRUSTED / nothing warranted touched) | **continue** |
+
+Policies: **cautious** (repairs even non-load-bearing breaks; cap 1), **assist** (default;
+repairs load-bearing, continues past non-load-bearing; cap 3), **unattended** (like assist
+but requires `--scope` before repairing a load-bearing break — an L3 loop needs a bounded
+lane; cap 3).
+
+**Exit codes.** `preflight` exits **0 on success regardless of the decision** — Dorian
+does not stop the loop by default; the decision lives in the output. For a hard CI gate,
+opt in with `--fail-on repair` or `--fail-on escalate` (exit 4 at/above that severity) — the
+same opt-in-to-block spirit as the claim-warrants Action's `fail_on: revoked`, but here the
+default is non-blocking. The shipped Action example uses `--fail-on escalate`.
+
+## Claim warrants as loop memory
+
+After a change, the loop drafts and seals new warrants with **`/dorian-claim-warrants`**
+and `dorian verify`. Those `.warrant` sidecars are the loop's **deterministic memory**:
+the next iteration's preflight *revalidates* them instead of re-deriving the facts, and
+`REVOKE`s the ones a later edit broke — so a stale assumption surfaces as a steering
+signal rather than rotting silently in `STATE.md`.
+
+## Example `LOOP.md` / `STATE.md` / run log
+
+`dorian loop install --with-state` writes example
+[`LOOP.md`](../src/dorian/templates/claude_code/dorian-loop-guard/templates/LOOP.md) (the
+loop's durable spec: purpose, cadence, scope, verification commands, escalation rules),
+[`STATE.md`](../src/dorian/templates/claude_code/dorian-loop-guard/templates/STATE.md)
+(live working memory + a machine-read `repair_attempts` counter for `--state-file`), and
+[`loop-run-log.md`](../src/dorian/templates/claude_code/dorian-loop-guard/templates/loop-run-log.md)
+(append-only run history). These follow loop-engineering's conventions; Dorian only
+*reads* the attempt count (via `--state-file`) — it never writes loop state.
+
+## Example loop run
+
+```text
+$ dorian loop preflight --since main~1 --policy assist --format text
+decision: REPAIR (policy=assist)
+ 1 load-bearing claim(s) REVOKED, in scope and under the repair cap — repair the
+ smallest cause (or update the claim/doc if the change was intentional).
+ trust: trusted=0 warranted=0 degraded=0 revoked=1 errored=0
+ BROKEN login-timeout-30s [load-bearing] C3: regex_missing
+ paths=src/api/config.py -> repair_code
+ next: REPAIR: fix the smallest cause of these broken claim(s): login-timeout-30s …
+```
+
+The loop restores the timeout (or updates the claim), re-runs `dorian revalidate --since
+main~1` to confirm TRUSTED, logs the attempt, and continues. After the repair cap, the
+same break returns `escalate` instead of looping forever.
+
+## Failure modes Dorian helps with
+
+Weak verification, verifier theater, the infinite-fix-loop signal (paired with the cap),
+and the escalation *trigger*. It partially helps with over-reach (`--scope`), state rot
+(code-anchored warrants auto-revoke), token burn (a cheap deterministic pre-filter before
+spawning sub-agents), and comprehension debt (a machine-checked ledger of guarantees).
+
+## Failure modes Dorian does NOT solve
+
+Parallel collision (worktree isolation / locks — defer to the loop runner), notification
+*delivery* and digesting, budget/cost estimation, cognitive surrender and comprehension
+debt at the cultural level, and live PR/ticket liveness (state rot beyond code-anchored
+facts). Dorian provides the signal; the loop runner owns scheduling, delivery, and budget.
+
+## Trusted-repo / not-a-sandbox boundary
+
+`preflight` executes the touched claims' checkers through `revalidate`. `C4 pytest:` and
+`C5 shell:` run code with your privileges — **this is not a sandbox**. Use Loop Guard on
+trusted, internal repos. For semi-trusted contexts pass `--deny-exec` (refuse the
+executable families — fail-closed) and/or `--checker-source base` (never run a PR-added
+checker); neither is a sandbox. See [SECURITY_BOUNDARY.md](SECURITY_BOUNDARY.md).
+
+## How to use with Claude Code
+
+```bash
+dorian loop install # scaffolds .claude/skills/dorian-loop-guard/ (/dorian-loop-guard)
+dorian loop install --with-state --with-action # + LOOP.md/STATE.md/run-log + a GH Action example
+```
+
+Invoke **`/dorian-loop-guard`** when driving an autonomous loop. The skill tells Claude to
+run preflight before each iteration and act on the decision; after a change it hands off to
+`/dorian-claim-warrants` to seal new warrants.
+
+## How to use with GitHub Actions
+
+`dorian loop install --with-action` writes an example
+[`.github/workflows/dorian-loop.yml`](../src/dorian/templates/claude_code/dorian-loop-guard/templates/dorian-loop.yml)
+that runs `dorian loop preflight --fail-on escalate` on each pull request and writes the
+markdown packet to the job summary. It blocks only on ESCALATE by default (tighten with
+`--fail-on repair`). For public/fork PRs add `--checker-source base --deny-exec`.
+
+## How to combine with loop-engineering tools
+
+Dorian is a drop-in **L2/L3 verifier primitive**: run `dorian loop preflight` as the
+deterministic verify step, map its decision onto the loop's commit-vs-escalate gate, and
+let the `.warrant` trail complement `loop-run-log.md` as the deterministic-evidence ledger.
+Keep scheduling, worktree isolation, cost estimation, and readiness scoring in
+loop-engineering's own tools (loop-init / loop-audit / loop-cost) — Dorian does not compete
+with them. See [LOOP_ENGINEERING_ALIGNMENT.md](LOOP_ENGINEERING_ALIGNMENT.md).
diff --git a/docs/LOOP_ENGINEERING_ALIGNMENT.md b/docs/LOOP_ENGINEERING_ALIGNMENT.md
new file mode 100644
index 0000000..e3f8cfe
--- /dev/null
+++ b/docs/LOOP_ENGINEERING_ALIGNMENT.md
@@ -0,0 +1,76 @@
+# Loop-engineering alignment
+
+How Dorian Loop Guard plugs into [loop-engineering](https://github.com/cobusgreyling/loop-engineering)
+(MIT) — and, just as importantly, where it deliberately does **not** compete. Loop
+engineering frames building autonomous agent loops out of primitives (scheduling,
+run-until-done, worktrees, skills, connectors/MCP, sub-agents) plus durable memory, scored
+by readiness levels L0→L3. Dorian is one primitive in that world: the **deterministic,
+token-free verifier** under the maker/checker split.
+
+> Treat loop-engineering as adjacent ecosystem and design inspiration; this is alignment,
+> not a fork. Verify its facts live before relying on dated specifics.
+
+## Primitive map
+
+| loop-engineering primitive | Dorian's relationship |
+|---|---|
+| **Scheduling / automations** (`/loop`, cron, Actions) | Dorian runs *inside* a scheduled step; it never schedules. |
+| **Run-until-done** (`/goal`) | A warrant's TRUSTED/REVOKED is a deterministic stop/continue input to the loop's condition. |
+| **Worktrees** (isolation) | Dorian verifies inside a worktree; it does not manage them. |
+| **Skills** | Dorian ships a skill (`/dorian-loop-guard`) and a verify step; complementary to project skills. |
+| **Connectors / MCP** | Dorian is local + token-free; escalation *delivery* over connectors is the loop runner's. |
+| **Sub-agents (maker/checker)** | Dorian is the deterministic floor under the *checker* role — it cannot grade its own work or rubber-stamp. |
+| **Memory / state** (LOOP.md, STATE.md, run log) | `.warrant` sidecars are deterministic, revalidatable memory; Dorian *reads* a repair-attempt count, never writes loop state. |
+
+## Readiness levels
+
+- **L1 (report)** — `dorian loop preflight --format md|json` writes a deterministic finding
+ to state; a human acts. Maps to the `cautious`/`assist` policies with `--fail-on never`.
+- **L2 (assisted)** — `assist` policy: repair load-bearing breaks under a cap, continue past
+ non-load-bearing. The `--max-repairs` cap + `--repair-attempts` accounting is the bounded
+ auto-fix gate L2 requires.
+- **L3 (unattended)** — `unattended` policy **requires `--scope`** before autonomously
+ repairing a load-bearing break, mirroring L3's "denylist/bounded lane required" rule.
+ Pair with `--state-file`, `--deny-path`, and explicit human escalation.
+
+## Where Dorian plugs in
+
+The deterministic verify step. Its decision maps directly onto the loop's gate:
+
+- **CONTINUE** → commit / open PR / next planned step.
+- **REPAIR** → hand the broken claim(s) back to the implementer (smallest cause).
+- **ESCALATE** → write to STATE.md "waiting on human" and ping via a connector.
+
+The `.warrant` trail complements `loop-run-log.md` as the deterministic-evidence ledger.
+
+## Where Dorian should NOT compete
+
+| loop-engineering tool | Why Dorian stays out |
+|---|---|
+| **loop-init** | Scaffolds loop starters/budget/run-log. Dorian *consumes* these files; `dorian loop install` only adds the verify skill + examples. |
+| **loop-audit** | Scores L0–L3 readiness. Dorian can be *evidence* for a checklist item ("independent deterministic verifier present"), never a re-implementation of scoring. |
+| **loop-cost** | Estimates token spend. Dorian is token-free and models no cost. |
+| **schedulers / worktree managers** | Cadence, durability, isolation, parallel-collision locks — entirely the loop runner's. |
+
+## Failure-mode coverage
+
+| loop-engineering failure mode | Dorian | Note |
+|---|---|---|
+| Weak verification / same-session verifier | **strong** | model-free, separate-by-construction verifier |
+| Verifier theater | **strong** | can't say "looks good" — REVOKE leaves a sidecar |
+| Infinite fix loop | **signal + cap** | repeated REVOKED + `--max-repairs` → escalate; the *counter* lives in STATE.md |
+| Escalation failure | **trigger** | unambiguous escalate trigger; **delivery** is the loop runner's |
+| Over-reach / wrong scope | **partial** | `--scope` escalates out-of-lane breaks; path allow/deny stays in skills |
+| State rot | **partial** | code-anchored warrants auto-revoke; live PR/ticket liveness is the runner's prune |
+| Token burn | **aligned** | token-free pre-filter before spawning sub-agents; caps stay with loop-cost |
+| Comprehension debt | **partial** | warrant ledger documents guarantees; not a substitute for review |
+| Notification fatigue | **marginal** | high-precision triggers; routing/digest is the runner's |
+| Parallel collision | **no** | worktree/branch locks — defer entirely |
+| Cognitive surrender | **cultural** | explicit claims state intent; human gates are process |
+
+## One-line positioning
+
+Dorian is the **deterministic truth layer for AI coding loops** — it keeps the loop honest
+by warranting its load-bearing claims and steering the next iteration when one breaks. It
+does not replace loop-engineering; it is the model-free verifier loop-engineering says every
+serious loop needs.
diff --git a/docs/POSITIONING_LOOP_GUARD_2026_06_28.md b/docs/POSITIONING_LOOP_GUARD_2026_06_28.md
new file mode 100644
index 0000000..23641e7
--- /dev/null
+++ b/docs/POSITIONING_LOOP_GUARD_2026_06_28.md
@@ -0,0 +1,54 @@
+# Positioning — Dorian Loop Guard (2026-06-28)
+
+A focused reframe that reconnects Dorian to the original goal: *people give an AI coding
+agent a task, go to bed, and want useful results in the morning — Dorian should not stop
+the loop unnecessarily; it should keep the loop on track when something goes off track.*
+
+## The drift, named
+
+v1.3.0 shipped **claim warrants for Claude Code** — the right primitive, but framed as
+*post-hoc receipts* ("hold the agent to what it said it did"). That framing answers
+"who audits the summary?" but not the deeper goal: **steering a running loop**. Claim
+warrants were always capable of being the loop's truth memory; they were just pointed
+backward. Loop Guard points them forward.
+
+Verdict: **DRIFTED BUT RECOVERABLE** — recovered by a thin deterministic steering layer
+(`dorian loop preflight`) on top of the existing `revalidate` engine. No new verification,
+no model, no new trust semantics.
+
+## Tagline candidates (in use)
+
+- **"The deterministic truth layer for AI coding loops."**
+- "Keep your coding-agent loop on track when its assumptions drift."
+- "Claim warrants that steer the next agent iteration."
+
+## The one-paragraph pitch
+
+You hand an AI coding agent a task and walk away. Loop Guard is the deterministic,
+token-free verify step the loop runs before each iteration: it re-checks the claim warrants
+the change touched and returns **CONTINUE** (claims still hold), **REPAIR** (a load-bearing
+claim broke — fix the smallest cause or update the claim), or **ESCALATE** (a checker
+errored, a sensitive path is involved, the repair cap was hit, or the break is out of
+scope — hand off to a human). No model runs at check time, so the verifier can't be talked
+past, can't rubber-stamp, and costs nothing per check. Dorian doesn't judge whether the
+loop *succeeded* — it keeps the loop *honest about the specific facts it warranted*, and it
+doesn't stop the loop by default.
+
+## What changed vs the receipts framing
+
+| Receipts framing (1.3.0) | Loop Guard framing (1.4.0) |
+|---|---|
+| "Warrant what the agent said it did." | "Steer the next iteration from what still holds." |
+| Output: a sealed `.warrant` (a receipt). | Output: a CONTINUE/REPAIR/ESCALATE decision packet. |
+| Used after a change, for audit. | Used *before each iteration*, for steering; warrants are loop memory. |
+| Value: catch a silently-false claim later. | Value: keep an unattended loop on track without stopping it unnecessarily. |
+
+Same mechanism (deterministic warrants + revalidate), same boundaries (not a sandbox, not
+an LLM judge, token-free, trusted repos), reconnected to the autonomous-loop goal.
+
+## Honesty guards (unchanged)
+
+REVOKED is a steering signal, not a moral/whole-loop failure. ERRORED is fail-closed
+evidence, not a false claim. Weak binding/strength is low confidence, not falsity. Trigger
+and truth axes stay separate. Dorian verifies only specific written claims — never
+whole-loop success — and complements, never replaces, tests / review / human judgment.
diff --git a/docs/changes/dorian-loop-guard.claims.json b/docs/changes/dorian-loop-guard.claims.json
new file mode 100644
index 0000000..47c4cbc
--- /dev/null
+++ b/docs/changes/dorian-loop-guard.claims.json
@@ -0,0 +1,86 @@
+{
+ "claims": [
+ {
+ "id": "loop-engine",
+ "text": "The Loop Guard engine entry point preflight() lives in src/dorian/loop.py.",
+ "kind": "reference",
+ "load_bearing": true,
+ "checkers": [{ "type": "C3", "program": "symbol:src/dorian/loop.py::preflight" }]
+ },
+ {
+ "id": "loop-cli",
+ "text": "cli.py wires the nested `dorian loop` subcommand via _add_loop_parser.",
+ "kind": "reference",
+ "load_bearing": true,
+ "checkers": [{ "type": "C3", "program": "symbol:src/dorian/cli.py::_add_loop_parser" }]
+ },
+ {
+ "id": "loop-handler",
+ "text": "commands.py dispatches `dorian loop` via cmd_loop.",
+ "kind": "reference",
+ "load_bearing": true,
+ "checkers": [{ "type": "C3", "program": "symbol:src/dorian/commands.py::cmd_loop" }]
+ },
+ {
+ "id": "loop-guard-doc",
+ "text": "docs/DORIAN_LOOP_GUARD.md exists.",
+ "kind": "reference",
+ "load_bearing": true,
+ "checkers": [{ "type": "C3", "program": "path:docs/DORIAN_LOOP_GUARD.md" }]
+ },
+ {
+ "id": "loop-alignment-doc",
+ "text": "docs/LOOP_ENGINEERING_ALIGNMENT.md exists.",
+ "kind": "reference",
+ "load_bearing": true,
+ "checkers": [{ "type": "C3", "program": "path:docs/LOOP_ENGINEERING_ALIGNMENT.md" }]
+ },
+ {
+ "id": "loop-skill",
+ "text": "The /dorian-loop-guard skill template ships in package data.",
+ "kind": "reference",
+ "load_bearing": true,
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "path:src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md"
+ }
+ ]
+ },
+ {
+ "id": "zero-runtime-deps",
+ "text": "pyproject.toml's runtime dependencies list is empty (dependencies = []).",
+ "kind": "reference",
+ "load_bearing": true,
+ "checkers": [{ "type": "C3", "program": "regex:pyproject.toml::dependencies = \\[\\]" }]
+ },
+ {
+ "id": "readme-loops-section",
+ "text": "The README has a 'Using dorian inside AI coding loops' section.",
+ "kind": "reference",
+ "load_bearing": false,
+ "checkers": [
+ { "type": "C3", "program": "regex:README.md::Using dorian inside AI coding loops" }
+ ]
+ },
+ {
+ "id": "docs-not-a-sandbox",
+ "text": "The Loop Guard doc states Dorian is not a sandbox.",
+ "kind": "reference",
+ "load_bearing": false,
+ "checkers": [{ "type": "C3", "program": "regex:docs/DORIAN_LOOP_GUARD.md::not a sandbox" }]
+ },
+ {
+ "id": "docs-does-not-stop-loop",
+ "text": "The Loop Guard doc states Dorian does not stop the loop by default.",
+ "kind": "reference",
+ "load_bearing": false,
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "regex:docs/DORIAN_LOOP_GUARD.md::does not stop the loop by default"
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/changes/dorian-loop-guard.md b/docs/changes/dorian-loop-guard.md
new file mode 100644
index 0000000..5dcc71f
--- /dev/null
+++ b/docs/changes/dorian-loop-guard.md
@@ -0,0 +1,43 @@
+# Change: Dorian Loop Guard
+
+Dorian Loop Guard adds a deterministic, token-free **steering layer for AI coding loops**
+on top of `revalidate`: `dorian loop preflight` re-checks the warrants a change touched and
+returns a CONTINUE / REPAIR / ESCALATE decision; `dorian loop prompt` renders it for the
+next agent iteration; `dorian loop install` scaffolds the `/dorian-loop-guard` Claude Code
+skill. No model runs at check time; not a sandbox; trusted repos only.
+
+This is the dogfood change note. The checkable facts below are warranted by Dorian itself.
+
+## Checkable claims (warranted)
+
+- `loop-engine` — the engine entry point `preflight` exists in `src/dorian/loop.py`.
+- `loop-cli` — `cli.py` wires the nested `loop` subcommand (`_add_loop_parser`).
+- `loop-handler` — `commands.py` dispatches it (`cmd_loop`).
+- `loop-guard-doc` / `loop-alignment-doc` — the two Loop Guard docs exist.
+- `loop-skill` — the `/dorian-loop-guard` skill template ships.
+- `zero-runtime-deps` — `pyproject.toml` still declares zero runtime dependencies.
+- `readme-loops-section` — the README has a "Using dorian inside AI coding loops" section.
+- `docs-not-a-sandbox` / `docs-does-not-stop-loop` — the docs keep the boundary honest:
+ not a sandbox, and Dorian does not stop the loop by default (REVOKED → repair/escalate,
+ not an automatic halt).
+
+## Intentionally NOT warranted (prose, not checkable)
+
+"Loop Guard is the right reframe", "keeps loops on track", "useful product surface" — these
+are judgments, not deterministically falsifiable facts, so they are left as prose.
+
+## Verify
+
+```bash
+dorian verify docs/changes/dorian-loop-guard.md \
+ --claims docs/changes/dorian-loop-guard.claims.json \
+ --strength-gate=fail --binding-gate=warn
+```
+
+The sealed `docs/changes/dorian-loop-guard.md.warrant` is produced by running the command
+above (born-verifiable: it refuses to seal anything already false). `tests/test_loop_guard_dogfood.py`
+runs this exact verify→preflight flow in a temp copy and proves a mutated fact flips the
+loop decision from CONTINUE to REPAIR.
+
+Trust boundary: the model drafted this note; Dorian verifies the claims deterministically
+and token-free; not a sandbox; trusted repo only.
diff --git a/docs/changes/dorian-loop-guard.md.warrant b/docs/changes/dorian-loop-guard.md.warrant
new file mode 100644
index 0000000..7e6a8a7
--- /dev/null
+++ b/docs/changes/dorian-loop-guard.md.warrant
@@ -0,0 +1,283 @@
+{
+ "spec_version": "0.1",
+ "warrant": {
+ "id": "sha256:2e95af9de14d111a15ea4d7ef511555b84bc0a8ceec0906e3ad099bde80d8935",
+ "artifact": {
+ "uri": "docs/changes/dorian-loop-guard.md",
+ "content_hash": "sha256:50b71a55fda6632861bbc650bb6426e4f3e67bb674bcb928e77d98f6c7cddb6e",
+ "git_ref": "4e47a3d3fd5b43fcd786ae6fae22a15036552653"
+ },
+ "produced_by": {
+ "runner": "manual",
+ "captured_at": "2026-06-28T10:46:37.462746+00:00",
+ "run_ref": "",
+ "model": "",
+ "prompt_hash": ""
+ },
+ "read_set": [
+ {
+ "id": "rs-0",
+ "uri": "src/dorian/loop.py",
+ "selector": null,
+ "hash": "sha256:6c21c93b9ce1bb2d0e211a543d4a886bf38b9dce6899908e9b10c60ed860d28b",
+ "version": "4e47a3d3fd5b43fcd786ae6fae22a15036552653",
+ "scope": "project"
+ },
+ {
+ "id": "rs-1",
+ "uri": "src/dorian/cli.py",
+ "selector": null,
+ "hash": "sha256:7a19412b41bafbbdcebe31eb44027b6e6715cd652ce519dc7bf132132c2784aa",
+ "version": "4e47a3d3fd5b43fcd786ae6fae22a15036552653",
+ "scope": "project"
+ },
+ {
+ "id": "rs-2",
+ "uri": "src/dorian/commands.py",
+ "selector": null,
+ "hash": "sha256:e6b5c102314ef45344bb6a63b9aef3362876938136f10497d73f8df6713c2993",
+ "version": "4e47a3d3fd5b43fcd786ae6fae22a15036552653",
+ "scope": "project"
+ },
+ {
+ "id": "rs-3",
+ "uri": "docs/DORIAN_LOOP_GUARD.md",
+ "selector": null,
+ "hash": "sha256:c26557f9f90cf13b7dfc97c78bbb54ffc07e2bb1088a8e2b26540e46ee0d268b",
+ "version": "4e47a3d3fd5b43fcd786ae6fae22a15036552653",
+ "scope": "project"
+ },
+ {
+ "id": "rs-4",
+ "uri": "docs/LOOP_ENGINEERING_ALIGNMENT.md",
+ "selector": null,
+ "hash": "sha256:d751fb10a061897b3c9ecceeafbd72077255fb1b2eb1ebd3ea197606f427e634",
+ "version": "4e47a3d3fd5b43fcd786ae6fae22a15036552653",
+ "scope": "project"
+ },
+ {
+ "id": "rs-5",
+ "uri": "src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md",
+ "selector": null,
+ "hash": "sha256:b625aa971db3f1b29820853168c62822885afa271722d2933585423d1702020b",
+ "version": "4e47a3d3fd5b43fcd786ae6fae22a15036552653",
+ "scope": "project"
+ },
+ {
+ "id": "rs-6",
+ "uri": "pyproject.toml",
+ "selector": null,
+ "hash": "sha256:2c2ebbbf8a81a09caf84472633a4133c3b96d2cb4a77786f76a55830e2ee3a50",
+ "version": "4e47a3d3fd5b43fcd786ae6fae22a15036552653",
+ "scope": "project"
+ },
+ {
+ "id": "rs-7",
+ "uri": "README.md",
+ "selector": null,
+ "hash": "sha256:b5b1b4a18ce6ff941fbe4f1ff49e7b3e45528f4c04293ea2fa59a69a39a05bbd",
+ "version": "4e47a3d3fd5b43fcd786ae6fae22a15036552653",
+ "scope": "project"
+ }
+ ],
+ "claims": [
+ {
+ "id": "loop-engine",
+ "text": "The Loop Guard engine entry point preflight() lives in src/dorian/loop.py.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": true,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "symbol:src/dorian/loop.py::preflight",
+ "expect": "exit:0",
+ "watch": [
+ "src/dorian/loop.py"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "loop-cli",
+ "text": "cli.py wires the nested `dorian loop` subcommand via _add_loop_parser.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": true,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "symbol:src/dorian/cli.py::_add_loop_parser",
+ "expect": "exit:0",
+ "watch": [
+ "src/dorian/cli.py"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "loop-handler",
+ "text": "commands.py dispatches `dorian loop` via cmd_loop.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": true,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "symbol:src/dorian/commands.py::cmd_loop",
+ "expect": "exit:0",
+ "watch": [
+ "src/dorian/commands.py"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "loop-guard-doc",
+ "text": "docs/DORIAN_LOOP_GUARD.md exists.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": true,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "path:docs/DORIAN_LOOP_GUARD.md",
+ "expect": "exit:0",
+ "watch": [
+ "docs/DORIAN_LOOP_GUARD.md"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "loop-alignment-doc",
+ "text": "docs/LOOP_ENGINEERING_ALIGNMENT.md exists.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": true,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "path:docs/LOOP_ENGINEERING_ALIGNMENT.md",
+ "expect": "exit:0",
+ "watch": [
+ "docs/LOOP_ENGINEERING_ALIGNMENT.md"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "loop-skill",
+ "text": "The /dorian-loop-guard skill template ships in package data.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": true,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "path:src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md",
+ "expect": "exit:0",
+ "watch": [
+ "src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "zero-runtime-deps",
+ "text": "pyproject.toml's runtime dependencies list is empty (dependencies = []).",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": true,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "regex:pyproject.toml::dependencies = \\[\\]",
+ "expect": "exit:0",
+ "watch": [
+ "pyproject.toml"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "readme-loops-section",
+ "text": "The README has a 'Using dorian inside AI coding loops' section.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": false,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "regex:README.md::Using dorian inside AI coding loops",
+ "expect": "exit:0",
+ "watch": [
+ "README.md"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "docs-not-a-sandbox",
+ "text": "The Loop Guard doc states Dorian is not a sandbox.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": false,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "regex:docs/DORIAN_LOOP_GUARD.md::not a sandbox",
+ "expect": "exit:0",
+ "watch": [
+ "docs/DORIAN_LOOP_GUARD.md"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ },
+ {
+ "id": "docs-does-not-stop-loop",
+ "text": "The Loop Guard doc states Dorian does not stop the loop by default.",
+ "kind": "reference",
+ "anchor": null,
+ "load_bearing": false,
+ "supports": [],
+ "checkers": [
+ {
+ "type": "C3",
+ "program": "regex:docs/DORIAN_LOOP_GUARD.md::does not stop the loop by default",
+ "expect": "exit:0",
+ "watch": [
+ "docs/DORIAN_LOOP_GUARD.md"
+ ],
+ "timeout_s": 30
+ }
+ ]
+ }
+ ],
+ "derives_from": [],
+ "fold_policy": {
+ "revoke_on": "load_bearing_broken",
+ "degrade_on": "any_stale_or_broken"
+ },
+ "sealed_at": "2026-06-28T10:46:37Z",
+ "supersedes": null
+ }
+}
diff --git a/src/dorian/claude_code.py b/src/dorian/claude_code.py
index d6b38d2..cd62e1c 100644
--- a/src/dorian/claude_code.py
+++ b/src/dorian/claude_code.py
@@ -190,13 +190,20 @@ def _atomic_write(path: Path, content: str) -> None:
os.replace(tmp, path)
-def build_plan(repo: Path, *, groups: frozenset[str] = DEFAULT_GROUPS) -> ScaffoldPlan:
+def build_plan(
+ repo: Path,
+ *,
+ groups: frozenset[str] = DEFAULT_GROUPS,
+ manifest: tuple[_Template, ...] = MANIFEST,
+) -> ScaffoldPlan:
"""Compute (without writing) the files the install would scaffold for ``repo``.
``groups`` selects which manifest groups to install (subset of ``GROUPS``).
+ ``manifest`` defaults to the claim-warrants MANIFEST; a sibling installer (e.g.
+ ``dorian loop install``) passes its own manifest of the same ``_Template`` shape.
``repo`` should already be the repo root (see ``find_repo_root``)."""
root, is_git = find_repo_root(repo)
- selected = [t for t in MANIFEST if t.group in groups]
+ selected = [t for t in manifest if t.group in groups]
files_ = tuple(
ScaffoldFile(
path=t.dest,
diff --git a/src/dorian/cli.py b/src/dorian/cli.py
index 103072a..bed794d 100644
--- a/src/dorian/cli.py
+++ b/src/dorian/cli.py
@@ -323,9 +323,150 @@ def build_parser() -> argparse.ArgumentParser:
icw.add_argument(
"--target", help="target repo/project root (default: --repo, i.e. current directory)"
)
+
+ _add_loop_parser(sub)
return p
+def _add_loop_steer_flags(parser: argparse.ArgumentParser) -> None:
+ """Inputs shared by `loop preflight` and `loop prompt`: the change window, the
+ steering policy, repair-cap accounting, scope/denylist, and the execution policy."""
+ parser.add_argument("--since", help="git ref to diff from (e.g. the loop's base ref)")
+ parser.add_argument("--changed-paths", help="file listing changed paths (one per line)")
+ parser.add_argument(
+ "--policy",
+ choices=["cautious", "assist", "unattended"],
+ default="assist",
+ help="steering policy (default assist). cautious: repair even non-load-bearing breaks,"
+ " cap 1. assist: repair load-bearing, continue past non-load-bearing, cap 3. unattended:"
+ " like assist but REQUIRES --scope before repairing a load-bearing break (L3 needs a"
+ " bounded lane), cap 3.",
+ )
+ parser.add_argument(
+ "--max-repairs",
+ type=int,
+ default=None,
+ help="repair-attempt cap before escalating (default: per-policy — cautious 1, else 3)",
+ )
+ parser.add_argument(
+ "--repair-attempts",
+ type=int,
+ default=0,
+ help="prior repair attempts for the current broken set (the loop threads this in;"
+ " reaching --max-repairs escalates as an infinite-fix-loop guard)",
+ )
+ parser.add_argument(
+ "--state-file",
+ help="optional JSON loop-state file; reads repair_attempts from it (a --repair-attempts"
+ " flag wins). Dorian never writes it — the loop owns its state.",
+ )
+ parser.add_argument(
+ "--scope",
+ action="append",
+ default=[],
+ metavar="GLOB",
+ help="allowlist glob (repeatable): a load-bearing break whose files fall outside every"
+ " --scope glob ESCALATES (over-reach guard) instead of being repaired",
+ )
+ parser.add_argument(
+ "--deny-path",
+ action="append",
+ default=[],
+ metavar="GLOB",
+ help="extra sensitive-path glob (repeatable), added to the built-in security/infra/secrets"
+ " set; a break touching one ESCALATES regardless of policy",
+ )
+ parser.add_argument(
+ "--checker-source",
+ choices=["head", "base"],
+ default=None,
+ help="which sidecar a claim's checker SPEC is read from (passed through to revalidate);"
+ " 'base' never executes a PR-added checker. Env: DORIAN_CHECKER_SOURCE.",
+ )
+ parser.add_argument("--enable-c2lite", action="store_true")
+ _add_exec_policy_flags(parser)
+
+
+def _add_loop_parser(sub: argparse._SubParsersAction) -> None:
+ """`dorian loop`: deterministic CONTINUE/REPAIR/ESCALATE steering for AI coding loops."""
+ lp = sub.add_parser(
+ "loop",
+ help="loop steering: preflight (continue/repair/escalate), prompt, and install the skill",
+ description="Dorian Loop Guard — a deterministic, token-free steering layer for AI coding"
+ " loops. `preflight` re-checks the warrants a change touched and classifies"
+ " the result into CONTINUE / REPAIR / ESCALATE; `prompt` renders that as a next-iteration"
+ " instruction; `install` scaffolds the Claude Code loop-guard skill. No model runs at check"
+ " time, and it never judges whole-loop success — not a sandbox, trusted repos only.",
+ )
+ lp_sub = lp.add_subparsers(dest="loop_command", required=True)
+
+ pf = lp_sub.add_parser(
+ "preflight",
+ help="re-check touched warrants and emit a continue/repair/escalate decision packet",
+ description="Re-check the claim warrants the change touched and classify the deterministic"
+ " result into a CONTINUE/REPAIR/ESCALATE loop-decision packet. Exits 0 on success by"
+ " default (the decision is in the output — Dorian does not stop the loop by default); use"
+ " --fail-on to map a decision to exit 4 for a hard CI gate.",
+ )
+ _add_loop_steer_flags(pf)
+ pf.add_argument(
+ "--format",
+ choices=["json", "md", "text"],
+ default="json",
+ help="output format (default json)",
+ )
+ pf.add_argument(
+ "--fail-on",
+ choices=["never", "repair", "escalate"],
+ default="never",
+ help="exit non-zero (4) when the decision is at/above this severity (default never: always"
+ " exit 0 on success, so preflight never blocks the loop by itself)",
+ )
+
+ pr = lp_sub.add_parser(
+ "prompt",
+ help="render the decision as a compact next-iteration instruction for the coding agent",
+ description="Run preflight (or read a saved packet via --from-json) and render a compact,"
+ " agent-readable markdown instruction for the next loop iteration.",
+ )
+ _add_loop_steer_flags(pr)
+ pr.add_argument(
+ "--from-json",
+ help="render from a saved preflight packet (path, or '-' for stdin) instead of re-running",
+ )
+
+ ins = lp_sub.add_parser(
+ "install",
+ help="scaffold the Claude Code loop-guard skill (/dorian-loop-guard) into .claude/",
+ description="Scaffold a Claude Code skill at .claude/skills/dorian-loop-guard/"
+ " (invoked as /dorian-loop-guard) plus example LOOP.md / STATE.md / run-log and a GitHub"
+ " Actions snippet. Writes files only; never overwrites without --force; idempotent. Not a"
+ " sandbox — trusted repos.",
+ )
+ ins.add_argument(
+ "--force", action="store_true", help="overwrite existing scaffolded files (default: skip)"
+ )
+ ins.add_argument("--dry-run", action="store_true", help="print the plan; write nothing")
+ ins.add_argument(
+ "--with-state",
+ action="store_true",
+ help="also write LOOP.md, STATE.md, and loop-run-log.md to the repo root",
+ )
+ ins.add_argument(
+ "--with-action",
+ action="store_true",
+ help="also write a GitHub Actions example to .github/workflows/dorian-loop.yml",
+ )
+ ins.add_argument(
+ "--target", help="target repo/project root (default: --repo, i.e. current directory)"
+ )
+ ins.add_argument(
+ "--print-next-steps",
+ action="store_true",
+ help="print the post-install usage guide and trust boundary, then exit (no writes)",
+ )
+
+
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
diff --git a/src/dorian/commands.py b/src/dorian/commands.py
index 241d945..1c6c97a 100644
--- a/src/dorian/commands.py
+++ b/src/dorian/commands.py
@@ -31,6 +31,7 @@
gitio,
init,
intoto,
+ loop,
store,
strength,
suggestclaims,
@@ -1109,6 +1110,206 @@ def _print_claim_warrants_next_steps(*, with_hook: bool) -> None:
print(f"\nTrust boundary: {claude_code.TRUST_BOUNDARY}")
+def cmd_loop(args: argparse.Namespace) -> int:
+ """Dispatch `dorian loop ` (preflight | prompt | install)."""
+ if args.loop_command == "preflight":
+ return _cmd_loop_preflight(args)
+ if args.loop_command == "prompt":
+ return _cmd_loop_prompt(args)
+ if args.loop_command == "install":
+ return _cmd_loop_install(args)
+ print(f"dorian loop: '{args.loop_command}' not implemented", file=sys.stderr)
+ return EXIT_USAGE
+
+
+def _split_globs(values: list[str] | None) -> tuple[str, ...]:
+ """Flatten repeatable --scope/--deny-path values, also splitting comma lists."""
+ out: list[str] = []
+ for v in values or ():
+ out.extend(g.strip() for g in v.split(",") if g.strip())
+ return tuple(out)
+
+
+def _run_loop_preflight(args: argparse.Namespace) -> tuple[dict | None, int]:
+ """Validate inputs and run loop.preflight. Returns (packet_dict, EXIT_OK) on
+ success, or (None, exit_code) after printing the error. Shared by preflight and
+ prompt; mirrors cmd_revalidate's input validation and error→exit mapping."""
+ repo = _repo(args)
+ changed_file = Path(args.changed_paths) if args.changed_paths else None
+ if (args.since is None) == (changed_file is None):
+ print(
+ "dorian loop preflight: provide exactly one of --since [ or --changed-paths ",
+ file=sys.stderr,
+ )
+ return None, EXIT_USAGE
+ if _missing_repo(repo, "loop preflight"):
+ return None, EXIT_USAGE
+ checker_source = args.checker_source or os.environ.get("DORIAN_CHECKER_SOURCE", "head").strip()
+ if checker_source not in ("head", "base"):
+ print(
+ f"dorian loop preflight: --checker-source must be head|base (got {checker_source!r})",
+ file=sys.stderr,
+ )
+ return None, EXIT_USAGE
+ if checker_source == "base" and args.since is None:
+ print(
+ "dorian loop preflight: --checker-source base requires --since ",
+ file=sys.stderr,
+ )
+ return None, EXIT_USAGE
+ extra_notes: list[str] = []
+ repair_attempts = args.repair_attempts
+ if args.state_file and not args.repair_attempts: # explicit flag wins over the state file
+ repair_attempts, note = loop.read_repair_attempts(Path(args.state_file))
+ if note:
+ extra_notes.append(note)
+ try:
+ packet = loop.preflight(
+ repo,
+ since=args.since,
+ changed_paths_file=changed_file,
+ policy=args.policy,
+ max_repairs=args.max_repairs,
+ repair_attempts=repair_attempts,
+ scope=_split_globs(args.scope),
+ deny_paths=_split_globs(args.deny_path),
+ exec_policy=ExecutionPolicy.from_flags_and_env(
+ deny_exec=args.deny_exec, deny_shell=args.deny_shell
+ ),
+ checker_source=checker_source,
+ enable_c2lite=args.enable_c2lite,
+ extra_notes=tuple(extra_notes),
+ )
+ except (gitio.GitError, ChangedPathsError) as exc:
+ print(f"dorian loop preflight: {exc}", file=sys.stderr)
+ return None, EXIT_USAGE
+ except _SIDECAR_ERRORS as exc:
+ print(f"dorian loop preflight: corrupt warrant sidecar: {exc}", file=sys.stderr)
+ return None, EXIT_REVOKED
+ d = packet.to_dict()
+ # close the promise/default gap: the infinite-fix cap can only fire if the loop
+ # threads repair state. On a repair decision with neither --repair-attempts nor
+ # --state-file supplied, the cap is silently inactive — say so, don't pretend.
+ if d["decision"] == "repair" and not args.state_file and not args.repair_attempts:
+ d["notes"].append(
+ "infinite-fix cap inactive: thread --repair-attempts (or --state-file) each iteration"
+ " so repeated repairs escalate instead of looping forever."
+ )
+ return d, EXIT_OK
+
+
+def _loop_exit_code(decision: str, fail_on: str) -> int:
+ """Map a decision to an exit code under --fail-on. Default (never) is always 0:
+ preflight succeeding never blocks the loop by itself. --fail-on reuses exit 4."""
+ if fail_on == "escalate":
+ return EXIT_REVOKED if decision == "escalate" else EXIT_OK
+ if fail_on == "repair":
+ return EXIT_REVOKED if decision in ("repair", "escalate") else EXIT_OK
+ return EXIT_OK
+
+
+def _cmd_loop_preflight(args: argparse.Namespace) -> int:
+ packet, code = _run_loop_preflight(args)
+ if packet is None:
+ return code
+ renderers = {"json": loop.render_json, "md": loop.render_md, "text": loop.render_text}
+ print(renderers[args.format](packet), end="")
+ return _loop_exit_code(packet["decision"], args.fail_on)
+
+
+def _cmd_loop_prompt(args: argparse.Namespace) -> int:
+ if args.from_json: # render a saved packet instead of re-running preflight
+ try:
+ data = (
+ sys.stdin.read()
+ if args.from_json == "-"
+ else Path(args.from_json).read_text(encoding="utf-8")
+ )
+ packet = json.loads(data)
+ except (OSError, ValueError) as exc:
+ print(f"dorian loop prompt: cannot read --from-json: {exc}", file=sys.stderr)
+ return EXIT_USAGE
+ print(loop.render_prompt(packet), end="")
+ return EXIT_OK
+ packet, code = _run_loop_preflight(args)
+ if packet is None:
+ return code
+ print(loop.render_prompt(packet), end="")
+ return EXIT_OK
+
+
+def _cmd_loop_install(args: argparse.Namespace) -> int:
+ """Scaffold the Claude Code loop-guard skill (+ opt-in LOOP/STATE/run-log and Action
+ example). Writes files only; never overwrites without --force; idempotent."""
+ if args.print_next_steps:
+ _print_loop_next_steps()
+ return EXIT_OK
+ target = Path(args.target).resolve() if args.target else _repo(args)
+ if _missing_repo(target, "loop install"):
+ return EXIT_USAGE
+ groups = set(loop.DEFAULT_GROUPS)
+ if args.with_state:
+ groups.add("state")
+ if args.with_action:
+ groups.add("action")
+ try:
+ plan = claude_code.build_plan(target, groups=frozenset(groups), manifest=loop.LOOP_MANIFEST)
+ result = claude_code.apply(plan, force=args.force, dry_run=args.dry_run)
+ except (ValueError, OSError) as exc:
+ print(f"dorian loop install: {exc}", file=sys.stderr)
+ return EXIT_USAGE
+ if args.json:
+ print(
+ json.dumps(
+ {
+ "repo": str(plan.repo_root),
+ "is_git": plan.is_git,
+ "dry_run": args.dry_run,
+ "created": list(result.created),
+ "overwritten": list(result.overwritten),
+ "skipped": list(result.skipped),
+ "warnings": list(result.warnings),
+ "next_steps": list(loop.NEXT_STEPS_INSTALL),
+ "trust_boundary": loop.TRUST_BOUNDARY,
+ },
+ indent=2,
+ )
+ )
+ else:
+ _print_loop_install_summary(plan, result, dry_run=args.dry_run)
+ return EXIT_OK
+
+
+def _print_loop_install_summary(plan, result, *, dry_run: bool) -> None:
+ blurbs = {f.path: f.blurb for f in plan.files}
+ header = (
+ "dorian loop install --dry-run (no files written)"
+ if dry_run
+ else "Dorian loop-guard skill installed."
+ )
+ print(header)
+ written = list(result.created) + list(result.overwritten)
+ if written:
+ print("\nWould create:" if dry_run else "\nWrote:")
+ width = max(len(p) for p in written)
+ for p in written:
+ print(f" {p.ljust(width)} {blurbs.get(p, '')}")
+ if result.skipped:
+ print("\nSkipped (already present — use --force to overwrite):")
+ for p in result.skipped:
+ print(f" {p}")
+ for w in result.warnings:
+ print(f"\nwarning: {w}")
+ _print_loop_next_steps()
+
+
+def _print_loop_next_steps() -> None:
+ print("\nNext:")
+ for i, step in enumerate(loop.NEXT_STEPS_INSTALL, 1):
+ print(f" {i}. {step}")
+ print(f"\nTrust boundary: {loop.TRUST_BOUNDARY}")
+
+
def cmd_sync(args: argparse.Namespace) -> int:
repo = _repo(args)
if _missing_repo(repo, "sync"):
diff --git a/src/dorian/loop.py b/src/dorian/loop.py
new file mode 100644
index 0000000..0ea2f64
--- /dev/null
+++ b/src/dorian/loop.py
@@ -0,0 +1,707 @@
+"""dorian loop: deterministic CONTINUE / REPAIR / ESCALATE steering for AI coding loops.
+
+Loop Guard is a thin classifier ON TOP OF ``revalidate``. It re-checks the claim
+warrants a change touched, reads their post-fold trust state, and turns that
+deterministic verdict into a loop *decision packet*: continue the loop, repair a
+broken load-bearing claim, or escalate to a human. **No model runs here** — the
+verdict is exactly ``revalidate``'s, so Loop Guard is token-free and inherits the
+protocol's trigger-vs-truth discipline: a ``REVOKED`` claim is a steering signal,
+an ``ERRORED`` checker is fail-closed evidence — neither is a verdict on the whole
+loop, and neither is a "moral failure".
+
+What Loop Guard is NOT: it does not judge whole-loop success, run the loop,
+schedule it, manage worktrees, deliver notifications, or estimate token cost —
+those belong to the loop runner (e.g. loop-engineering's loop-init / loop-audit /
+loop-cost). Loop Guard is the deterministic *truth layer* the loop's verifier step
+stands on. It is **not a sandbox** (``C4 pytest:``/``C5 shell:`` checkers execute
+code through ``revalidate``); use it on trusted repos.
+
+The install scaffolder reuses ``claude_code``'s file machinery with a Loop-Guard
+manifest, so the ``.claude/skills/dorian-loop-guard/`` bundle ships as package data.
+"""
+
+from __future__ import annotations
+
+import fnmatch
+import json
+from dataclasses import dataclass, replace
+from pathlib import Path
+
+from dorian import store
+from dorian.claude_code import _Template
+from dorian.model import Warrant
+from dorian.policy import ExecutionPolicy
+from dorian.revalidate import RevalResult, revalidate
+
+SCHEMA_VERSION = 1
+
+POLICIES = ("cautious", "assist", "unattended")
+DEFAULT_POLICY = "assist"
+# Per-policy default repair-attempt cap (an explicit --max-repairs overrides). Cautious
+# escalates sooner; assist/unattended allow a few autonomous repair iterations.
+_DEFAULT_MAX_REPAIRS = {"cautious": 1, "assist": 3, "unattended": 3}
+
+DECISIONS = ("continue", "repair", "escalate")
+NEXT_STEPS = ("repair_code", "update_claim", "fix_environment", "escalate")
+
+# Built-in sensitive-path globs (fnmatch, '*' crosses '/'): a broken or errored claim
+# touching one of these ESCALATES under every policy — security/infra/secrets/CI changes
+# want a human, not an autonomous repair. The active set (these plus any --deny-path) is
+# echoed in the packet's `sensitive_globs`, so the heuristic is transparent, never hidden.
+SENSITIVE_GLOBS = (
+ "*secret*",
+ "*.pem",
+ "*.key",
+ "*.env",
+ "*.env.*",
+ ".github/workflows/*",
+ "*Dockerfile*",
+ "*/secrets/*",
+ "*/auth/*",
+ "*/security/*",
+ "*/migrations/*",
+ "infra/*",
+ "deploy/*",
+ "terraform/*",
+ "*.tf",
+)
+
+
+@dataclass(frozen=True)
+class BrokenClaim:
+ """One re-checked claim that did not hold this run (BROKEN) or could not run
+ (ERRORED), enriched with the steering metadata the next iteration needs."""
+
+ artifact: str
+ warrant: str # warrant id
+ claim_id: str
+ text: str
+ kind: str
+ load_bearing: bool
+ verdict: str # BROKEN | ERRORED
+ trust_state: str # the warrant's post-fold trust state (REVOKED|DEGRADED|UNKNOWN|...)
+ checker: str # checker type, e.g. C3 / C4 / C5
+ evidence: str # revalidate detail (why it broke / why it errored)
+ paths: tuple[str, ...] # files the claim is bound to (checker watches + supports)
+ sensitive: bool
+ in_scope: bool
+ suggested_next_step: str # one of NEXT_STEPS
+
+ def to_dict(self) -> dict:
+ return {
+ "artifact": self.artifact,
+ "warrant": self.warrant,
+ "claim_id": self.claim_id,
+ "text": self.text,
+ "kind": self.kind,
+ "load_bearing": self.load_bearing,
+ "verdict": self.verdict,
+ "trust_state": self.trust_state,
+ "checker": self.checker,
+ "evidence": self.evidence,
+ "paths": list(self.paths),
+ "sensitive": self.sensitive,
+ "in_scope": self.in_scope,
+ "suggested_next_step": self.suggested_next_step,
+ }
+
+
+@dataclass(frozen=True)
+class LoopDecision:
+ """The deterministic loop-steering packet: a pure function of the RevalResult,
+ the policy, the repair-attempt count, and the scope/denylist."""
+
+ decision: str # continue | repair | escalate
+ reason: str
+ policy: str
+ candidates: int
+ trust_summary: dict
+ broken_claims: tuple[BrokenClaim, ...]
+ recalled: tuple[dict, ...]
+ loop_instruction: str
+ human_escalation: dict
+ repair: dict # {"attempts": int, "max": int}
+ scope: tuple[str, ...]
+ sensitive_globs: tuple[str, ...]
+ notes: tuple[str, ...]
+
+ def to_dict(self) -> dict:
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "decision": self.decision,
+ "reason": self.reason,
+ "policy": self.policy,
+ "candidates": self.candidates,
+ "trust_summary": dict(sorted(self.trust_summary.items())),
+ "broken_claims": [b.to_dict() for b in self.broken_claims],
+ "recalled": [dict(r) for r in self.recalled],
+ "loop_instruction": self.loop_instruction,
+ "human_escalation": self.human_escalation,
+ "repair": self.repair,
+ "scope": list(self.scope),
+ "sensitive_globs": list(self.sensitive_globs),
+ "notes": list(self.notes),
+ }
+
+
+def _matches_any(path: str, globs: tuple[str, ...]) -> bool:
+ return any(fnmatch.fnmatch(path, g) for g in globs)
+
+
+def _claim_paths(claim, entries: dict) -> tuple[str, ...]:
+ """Files a claim is bound to: every checker watch plus every supported read-set uri."""
+ paths: set[str] = set()
+ for spec in claim.checkers:
+ paths.update(spec.watch)
+ for sid in claim.supports:
+ e = entries.get(sid)
+ if e is not None:
+ paths.add(e.uri)
+ return tuple(sorted(paths))
+
+
+def _split_detail(detail: str) -> tuple[str | None, str]:
+ """Split a revalidate detail into (checker_type, evidence). A BROKEN detail is
+ prefixed ``C3: ...`` — return ("C3", the rest); an ERRORED detail has no prefix —
+ return (None, detail). Splitting it out keeps the checker type and the evidence
+ separate so renderers don't double the prefix (``C3: C3: ...``)."""
+ head = detail.split(":", 1)[0].strip()
+ if len(head) == 2 and head[0] == "C" and head[1].isdigit():
+ return head, detail.split(":", 1)[1].strip()
+ return None, detail
+
+
+def _warrant_states(repo: Path) -> dict:
+ """{warrant_id: trust_state} for every indexed warrant. Read after ``revalidate``
+ has synced + folded + committed, so these are the post-fold trust states."""
+ conn = store.connect(repo)
+ try:
+ return {w["id"]: w["trust_state"] for w in store.get_warrants(conn)}
+ finally:
+ conn.close()
+
+
+_STATE_TO_BUCKET = {
+ "TRUSTED": "trusted",
+ "WARRANTED": "warranted",
+ "DEGRADED": "degraded",
+ "REVOKED": "revoked",
+ "UNKNOWN": "errored", # a checker ERRORED -> warrant folds to UNKNOWN -> count as errored
+}
+
+
+def _trust_summary(result: RevalResult, states: dict) -> dict:
+ """Bucketed counts of the TOUCHED warrants' post-fold trust states."""
+ buckets = {"trusted": 0, "warranted": 0, "degraded": 0, "revoked": 0, "errored": 0}
+ touched = (
+ set(result.artifacts)
+ | {wid for wid, _, _ in result.broken}
+ | {wid for wid, _, _ in result.errored}
+ )
+ for wid in touched:
+ bucket = _STATE_TO_BUCKET.get(states.get(wid, ""))
+ if bucket:
+ buckets[bucket] += 1
+ return buckets
+
+
+def _collect_broken(
+ repo: Path,
+ result: RevalResult,
+ states: dict,
+ scope: tuple[str, ...],
+ sensitive_globs: tuple[str, ...],
+) -> tuple[list[BrokenClaim], list[str]]:
+ """Enrich every BROKEN/ERRORED record with claim text/kind/load_bearing/paths and
+ the sensitive/in-scope flags. Returns (broken_claims, notes). The provisional
+ suggested_next_step is set here (fix_environment for ERRORED, repair_code for BROKEN);
+ `_decide` may upgrade specific ones to `escalate`."""
+ notes: list[str] = []
+ cache: dict[str, Warrant | None] = {}
+
+ def warrant_for(wid: str) -> Warrant | None:
+ if wid not in cache:
+ uri = result.artifacts.get(wid)
+ try:
+ cache[wid] = Warrant.load(repo / (uri + ".warrant")) if uri else None
+ except Exception: # sidecar already integrity-checked by revalidate; stay robust
+ cache[wid] = None
+ return cache[wid]
+
+ out: list[BrokenClaim] = []
+ for verdict, recs in (("BROKEN", result.broken), ("ERRORED", result.errored)):
+ for wid, cid, detail in recs:
+ w = warrant_for(wid)
+ artifact = result.artifacts.get(wid, "")
+ claim = next((c for c in w.claims if c.id == cid), None) if w else None
+ step = "fix_environment" if verdict == "ERRORED" else "repair_code"
+ ctype, evidence = _split_detail(detail)
+ if claim is None:
+ notes.append(f"{cid}: claim unreadable from {artifact}.warrant; using bare record")
+ out.append(
+ BrokenClaim(
+ artifact=artifact,
+ warrant=wid,
+ claim_id=cid,
+ text="",
+ kind="",
+ load_bearing=True, # fail-safe: treat unknown as load-bearing
+ verdict=verdict,
+ trust_state=states.get(wid, ""),
+ checker=ctype or "",
+ evidence=evidence,
+ paths=(),
+ sensitive=False,
+ in_scope=True,
+ suggested_next_step=step,
+ )
+ )
+ continue
+ entries = {e.id: e for e in w.read_set}
+ paths = _claim_paths(claim, entries)
+ sensitive = any(_matches_any(p, sensitive_globs) for p in paths)
+ in_scope = True if not scope else all(_matches_any(p, scope) for p in paths)
+ out.append(
+ BrokenClaim(
+ artifact=artifact,
+ warrant=wid,
+ claim_id=cid,
+ text=claim.text,
+ kind=claim.kind,
+ load_bearing=bool(claim.load_bearing),
+ verdict=verdict,
+ trust_state=states.get(wid, ""),
+ checker=ctype or (claim.checkers[0].type if claim.checkers else ""),
+ evidence=evidence,
+ paths=paths,
+ sensitive=sensitive,
+ in_scope=in_scope,
+ suggested_next_step=step,
+ )
+ )
+ return out, notes
+
+
+def _decide(
+ broken: list[BrokenClaim],
+ result: RevalResult,
+ *,
+ policy: str,
+ max_repairs: int,
+ repair_attempts: int,
+ scope_given: bool,
+) -> tuple[str, str, str]:
+ """Pure decision over the enriched breaks. Returns (decision, reason, cause).
+
+ Precedence (first match wins): errored -> cap -> sensitive -> over-reach ->
+ unattended-needs-scope -> repair (load-bearing) -> degraded (non-load-bearing) ->
+ continue. `cause` keys the per-claim next-step assignment in `_assign_steps`."""
+ errored = [b for b in broken if b.verdict == "ERRORED"]
+ breaks = [b for b in broken if b.verdict == "BROKEN"]
+ lb = [b for b in breaks if b.load_bearing]
+ nlb = [b for b in breaks if not b.load_bearing]
+
+ if errored:
+ return (
+ "escalate",
+ f"{len(errored)} checker(s) ERRORED (could not run) — fail-closed evidence, "
+ "not a false claim; the environment/infra needs a human before the loop continues.",
+ "errored",
+ )
+ # breaks the active policy would actually REPAIR: load-bearing always, plus
+ # non-load-bearing under cautious. assist/unattended `continue` past a
+ # non-load-bearing break, so the cap and over-reach guards must not stop the loop
+ # for one (don't stop the loop unnecessarily).
+ repairable = lb + (nlb if policy == "cautious" else [])
+ if repairable and repair_attempts >= max_repairs:
+ return (
+ "escalate",
+ f"repair attempt cap reached ({repair_attempts} >= {max_repairs}) — a claim the loop "
+ "would repair is still broken after repeated attempts (possible infinite-fix loop).",
+ "cap",
+ )
+ if any(b.sensitive for b in broken):
+ return (
+ "escalate",
+ "a broken/errored claim is bound to a sensitive or denylisted path "
+ "(security / infra / secrets / CI) — a human must review the repair.",
+ "sensitive",
+ )
+ if scope_given and any(not b.in_scope for b in repairable):
+ return (
+ "escalate",
+ "a claim the loop would repair broke outside the configured --scope (possible "
+ "over-reach) — escalate rather than repair out of the loop's lane.",
+ "scope",
+ )
+ if lb and policy == "unattended" and not scope_given:
+ return (
+ "escalate",
+ "unattended policy requires a bounded --scope before autonomously repairing a "
+ "load-bearing break (an L3 loop needs a denylist/scope) — escalate.",
+ "unattended_no_scope",
+ )
+ if lb:
+ return (
+ "repair",
+ f"{len(lb)} load-bearing claim(s) REVOKED, in scope and under the repair cap — "
+ "repair the smallest cause (or update the claim/doc if the change was intentional).",
+ "repair",
+ )
+ if nlb:
+ if policy == "cautious":
+ return (
+ "repair",
+ f"{len(nlb)} non-load-bearing claim(s) broke (DEGRADED); the cautious policy "
+ "repairs them rather than continuing.",
+ "degraded_repair",
+ )
+ return (
+ "continue",
+ f"{len(nlb)} non-load-bearing claim(s) broke (DEGRADED) — not blocking under the "
+ f"{policy} policy; continue and address them when convenient.",
+ "degraded_continue",
+ )
+ if result.candidates == 0:
+ return (
+ "continue",
+ "no warranted claims were affected by the changed paths — nothing to steer on.",
+ "clean",
+ )
+ return (
+ "continue",
+ f"all {result.candidates} re-checked claim(s) still hold — touched warrants are TRUSTED.",
+ "clean",
+ )
+
+
+def _assign_steps(broken: list[BrokenClaim], *, cause: str) -> list[BrokenClaim]:
+ """Finalize each claim's suggested_next_step given the overall escalation cause:
+ the specific claims that triggered an escalation are marked `escalate`; ERRORED
+ stays `fix_environment`; everything else stays `repair_code`."""
+ out: list[BrokenClaim] = []
+ for b in broken:
+ step = b.suggested_next_step
+ if b.verdict == "ERRORED":
+ step = "fix_environment"
+ elif (
+ (cause == "sensitive" and b.sensitive)
+ or (cause == "scope" and not b.in_scope)
+ or (cause in ("cap", "unattended_no_scope") and b.verdict == "BROKEN")
+ ):
+ step = "escalate"
+ out.append(replace(b, suggested_next_step=step))
+ return out
+
+
+def _loop_instruction(
+ decision: str, reason: str, broken: list[BrokenClaim], max_repairs: int
+) -> str:
+ """A compact, agent-readable instruction for the next loop iteration."""
+ if decision == "continue":
+ return (
+ "CONTINUE: no broken load-bearing claims for the changed paths. Proceed with the next "
+ "planned step, stay within scope, and re-run `dorian loop preflight` next iteration."
+ )
+ if decision == "repair":
+ # name only in-scope breaks: an out-of-scope load-bearing break would have
+ # ESCALATED, so a repair instruction must never tell the agent to touch a file
+ # outside the configured lane (a co-occurring out-of-scope non-load-bearing break
+ # can ride along under assist).
+ focus = [b for b in broken if b.in_scope] or broken
+ ids = ", ".join(f"{b.claim_id} ({b.artifact})" for b in focus) or "(none)"
+ paths = sorted({p for b in focus for p in b.paths})
+ return (
+ f"REPAIR: fix the smallest cause of these broken claim(s): {ids}. "
+ f"Touch only: {', '.join(paths) or 'the bound files'}. Re-run the exact checker or "
+ "`dorian revalidate --since ` to confirm. If the change was intentional, update "
+ f"the claim/doc instead of the code. Do not exceed {max_repairs} repair attempt(s) — "
+ "escalate after that."
+ )
+ # escalate: the human sees every broken/errored claim, in or out of scope
+ ids = ", ".join(f"{b.claim_id} ({b.artifact})" for b in broken) or "(none)"
+ paths = sorted({p for b in broken for p in b.paths})
+ return (
+ f"ESCALATE: stop autonomous edits. {reason} Hand off to a human with the broken claim(s): "
+ f"{ids}; evidence and bound files: {', '.join(paths) or '(none)'}. "
+ "Record this in STATE.md under 'waiting on human'."
+ )
+
+
+def preflight(
+ repo: Path,
+ *,
+ since: str | None = None,
+ changed_paths_file: Path | None = None,
+ policy: str = DEFAULT_POLICY,
+ max_repairs: int | None = None,
+ repair_attempts: int = 0,
+ scope: tuple[str, ...] = (),
+ deny_paths: tuple[str, ...] = (),
+ exec_policy: ExecutionPolicy | None = None,
+ checker_source: str = "head",
+ enable_c2lite: bool = False,
+ extra_notes: tuple[str, ...] = (),
+) -> LoopDecision:
+ """Re-check the warrants the change touched and classify the result into a
+ CONTINUE/REPAIR/ESCALATE decision packet. Pure verdict reuse — calls
+ ``revalidate`` (token-free, deterministic) and never runs a model.
+
+ One of ``since`` or ``changed_paths_file`` is required (as for ``revalidate``).
+ ``policy`` is one of POLICIES; ``max_repairs`` defaults per-policy when None."""
+ if policy not in POLICIES:
+ raise ValueError(f"policy must be one of {POLICIES}, got {policy!r}")
+ cap = _DEFAULT_MAX_REPAIRS[policy] if max_repairs is None else max_repairs
+ sensitive_globs = SENSITIVE_GLOBS + tuple(deny_paths)
+
+ result = revalidate(
+ repo,
+ since=since,
+ changed_paths_file=changed_paths_file,
+ enable_c2lite=enable_c2lite,
+ policy=exec_policy if exec_policy is not None else ExecutionPolicy(),
+ checker_source=checker_source,
+ )
+ states = _warrant_states(repo)
+ broken, notes = _collect_broken(repo, result, states, scope, sensitive_globs)
+ notes = list(extra_notes) + notes
+ if result.notes: # checker-source=base advisories from revalidate
+ notes.extend(result.notes)
+
+ decision, reason, cause = _decide(
+ broken,
+ result,
+ policy=policy,
+ max_repairs=cap,
+ repair_attempts=repair_attempts,
+ scope_given=bool(scope),
+ )
+ broken = _assign_steps(broken, cause=cause)
+ instruction = _loop_instruction(decision, reason, broken, cap)
+ human = {
+ "required": decision == "escalate",
+ "reason": reason if decision == "escalate" else "",
+ "message": instruction if decision == "escalate" else "",
+ }
+ if result.recalled:
+ n = len({e["warrant_id"] for e in result.recalled})
+ notes.append(f"{n} downstream warrant(s) recalled (flagged, not re-checked).")
+
+ return LoopDecision(
+ decision=decision,
+ reason=reason,
+ policy=policy,
+ candidates=result.candidates,
+ trust_summary=_trust_summary(result, states),
+ broken_claims=tuple(broken),
+ recalled=tuple(result.recalled),
+ loop_instruction=instruction,
+ human_escalation=human,
+ repair={"attempts": repair_attempts, "max": cap},
+ scope=tuple(scope),
+ sensitive_globs=sensitive_globs,
+ notes=tuple(notes),
+ )
+
+
+# --- repair-attempt state ----------------------------------------------------------
+
+
+def read_repair_attempts(path: Path) -> tuple[int, str | None]:
+ """Read ``repair_attempts`` (int) from a JSON state file. Returns (count, note):
+ a missing/unreadable/garbled file yields (0, note) — never raises, so a fresh loop
+ with no state file just starts at zero."""
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError) as exc:
+ return 0, f"--state-file unreadable ({exc}); assuming repair_attempts=0"
+ value = data.get("repair_attempts", 0) if isinstance(data, dict) else 0
+ try:
+ return max(0, int(value)), None
+ except (TypeError, ValueError):
+ return 0, "--state-file repair_attempts is not an integer; assuming 0"
+
+
+# --- renderers (all operate on packet.to_dict()) -----------------------------------
+
+
+def render_json(d: dict) -> str:
+ return json.dumps(d, indent=2, sort_keys=True) + "\n"
+
+
+_DECISION_GLYPH = {"continue": "▶ CONTINUE", "repair": "✎ REPAIR", "escalate": "⚠ ESCALATE"}
+_SUMMARY_KEYS = ("trusted", "warranted", "degraded", "revoked", "errored")
+
+
+def render_text(d: dict) -> str:
+ lines = [f"decision: {d['decision'].upper()} (policy={d['policy']})", f" {d['reason']}"]
+ ts = d["trust_summary"]
+ lines.append(" trust: " + " ".join(f"{k}={ts[k]}" for k in _SUMMARY_KEYS))
+ for b in d["broken_claims"]:
+ lb = "load-bearing" if b["load_bearing"] else "non-load-bearing"
+ lines.append(f" {b['verdict']:7} {b['claim_id']} [{lb}] {b['checker']}: {b['evidence']}")
+ paths = ", ".join(b["paths"]) or "-"
+ lines.append(f" paths={paths} -> {b['suggested_next_step']}")
+ if d["human_escalation"]["required"]:
+ lines.append(f" human escalation REQUIRED: {d['human_escalation']['reason']}")
+ for note in d["notes"]:
+ lines.append(f" note: {note}")
+ lines.append(f" next: {d['loop_instruction']}")
+ return "\n".join(lines) + "\n"
+
+
+def render_md(d: dict) -> str:
+ glyph = _DECISION_GLYPH.get(d["decision"], d["decision"].upper())
+ lines = [
+ f"### dorian loop: {glyph}",
+ "",
+ f"**Decision:** `{d['decision']}` · **policy:** `{d['policy']}` · "
+ f"**repair:** {d['repair']['attempts']}/{d['repair']['max']}",
+ "",
+ d["reason"],
+ ]
+ ts = d["trust_summary"]
+ lines += [
+ "",
+ "| " + " | ".join(_SUMMARY_KEYS) + " |",
+ "| " + " | ".join(["---:"] * len(_SUMMARY_KEYS)) + " |",
+ "| " + " | ".join(str(ts[k]) for k in _SUMMARY_KEYS) + " |",
+ ]
+ if d["broken_claims"]:
+ lines += [
+ "",
+ "| claim | artifact | kind | load-bearing | verdict | why | next |",
+ "| --- | --- | --- | :---: | --- | --- | --- |",
+ ]
+ for b in d["broken_claims"]:
+ why = f"{b['checker']}: {b['evidence']}".replace("|", "\\|")[:160]
+ lines.append(
+ f"| `{b['claim_id']}` | `{b['artifact']}` | {b['kind']} | "
+ f"{'yes' if b['load_bearing'] else 'no'} | {b['verdict']} | {why} | "
+ f"{b['suggested_next_step']} |"
+ )
+ if d["human_escalation"]["required"]:
+ lines += ["", f"> **Human escalation required.** {d['human_escalation']['reason']}"]
+ if d["notes"]:
+ lines += ["", "Notes:"] + [f"- {n}" for n in d["notes"]]
+ lines += ["", f"**Next iteration:** {d['loop_instruction']}"]
+ return "\n".join(lines) + "\n"
+
+
+def render_prompt(d: dict) -> str:
+ """A compact markdown prompt for the next coding-agent iteration."""
+ header = {
+ "continue": "Dorian Loop Guard says CONTINUE.",
+ "repair": "Dorian Loop Guard says REPAIR before continuing.",
+ "escalate": "Dorian Loop Guard says ESCALATE — stop autonomous edits.",
+ }[d["decision"]]
+ lines = [f"# {header}", "", d["loop_instruction"], ""]
+ if d["broken_claims"]:
+ lines.append("Broken / unrunnable claims:")
+ for b in d["broken_claims"]:
+ tag = "load-bearing" if b["load_bearing"] else "non-load-bearing"
+ lines.append(
+ f"- `{b['claim_id']}` in `{b['artifact']}` ({tag}, {b['verdict']}): "
+ f"{b['text'] or '(claim text unavailable)'}"
+ )
+ lines.append(f" - why: {b['checker']}: {b['evidence']}")
+ lines.append(
+ f" - bound files: {', '.join(b['paths']) or '(none)'} → "
+ f"suggested: {b['suggested_next_step']}"
+ )
+ lines += [
+ "",
+ "Boundary: Dorian verified only these specific warranted claims, not the whole loop. "
+ "REVOKED is a steering signal, not a moral failure; ERRORED is fail-closed evidence, not a "
+ "false claim. Dorian is not a sandbox — trusted repos only.",
+ ]
+ return "\n".join(lines) + "\n"
+
+
+# --- install scaffolder (reuses claude_code.build_plan/apply with a Loop-Guard manifest) ---
+
+SKILL_DIR = ".claude/skills/dorian-loop-guard"
+# logical groups: "skill" installs by default; "state" (LOOP.md/STATE.md/run-log at the
+# repo root) and "action" (a GitHub Actions example) are opt-in.
+GROUPS = ("skill", "state", "action")
+DEFAULT_GROUPS = frozenset({"skill"})
+
+LOOP_MANIFEST: tuple[_Template, ...] = (
+ _Template(
+ "dorian-loop-guard/SKILL.md",
+ f"{SKILL_DIR}/SKILL.md",
+ "skill",
+ "the skill, invoked as /dorian-loop-guard",
+ ),
+ _Template(
+ "dorian-loop-guard/README.md",
+ f"{SKILL_DIR}/README.md",
+ "skill",
+ "what the bundle is and how to use it",
+ ),
+ _Template(
+ "dorian-loop-guard/reference/loop-decisions.md",
+ f"{SKILL_DIR}/reference/loop-decisions.md",
+ "skill",
+ "continue / repair / escalate, and the policies",
+ ),
+ _Template(
+ "dorian-loop-guard/reference/safety-boundary.md",
+ f"{SKILL_DIR}/reference/safety-boundary.md",
+ "skill",
+ "Dorian steers, it does not judge the loop; not a sandbox",
+ ),
+ _Template(
+ "dorian-loop-guard/templates/LOOP.md",
+ f"{SKILL_DIR}/templates/LOOP.md",
+ "skill",
+ "example loop spec (copy to repo root)",
+ ),
+ _Template(
+ "dorian-loop-guard/templates/STATE.md",
+ f"{SKILL_DIR}/templates/STATE.md",
+ "skill",
+ "example live loop state (copy to repo root)",
+ ),
+ _Template(
+ "dorian-loop-guard/templates/loop-run-log.md",
+ f"{SKILL_DIR}/templates/loop-run-log.md",
+ "skill",
+ "example append-only run log",
+ ),
+ _Template(
+ "dorian-loop-guard/templates/dorian-loop.yml",
+ f"{SKILL_DIR}/templates/dorian-loop.yml",
+ "skill",
+ "example GitHub Actions preflight step",
+ ),
+ # opt-in: write the live state files to the repo root (--with-state)
+ _Template("dorian-loop-guard/templates/LOOP.md", "LOOP.md", "state", "loop spec at repo root"),
+ _Template("dorian-loop-guard/templates/STATE.md", "STATE.md", "state", "live loop state"),
+ _Template(
+ "dorian-loop-guard/templates/loop-run-log.md",
+ "loop-run-log.md",
+ "state",
+ "append-only run log",
+ ),
+ # opt-in: a GitHub Actions example workflow (--with-action)
+ _Template(
+ "dorian-loop-guard/templates/dorian-loop.yml",
+ ".github/workflows/dorian-loop.yml",
+ "action",
+ "GitHub Actions loop preflight workflow",
+ ),
+)
+
+NEXT_STEPS_INSTALL = (
+ "Restart Claude Code (a new skills directory registers only at startup)",
+ "Before each loop iteration, run: dorian loop preflight --since --format json",
+ "Act on the decision: CONTINUE / REPAIR / ESCALATE (see the printed loop_instruction)",
+ "After a change, seal new warrants with /dorian-claim-warrants, then `dorian verify`",
+ "Copy templates/LOOP.md + STATE.md to the repo root (or re-run with --with-state)",
+)
+
+TRUST_BOUNDARY = (
+ "Loop Guard steers; it does not judge whole-loop success and runs no model at check time. "
+ "REVOKED is a steering signal, ERRORED is fail-closed evidence. Not a sandbox — trusted repos."
+)
diff --git a/src/dorian/templates/claude_code/dorian-loop-guard/README.md b/src/dorian/templates/claude_code/dorian-loop-guard/README.md
new file mode 100644
index 0000000..0e14e34
--- /dev/null
+++ b/src/dorian/templates/claude_code/dorian-loop-guard/README.md
@@ -0,0 +1,41 @@
+# Dorian loop guard — bundle
+
+This directory is a project-local Claude Code **skill** that wires Dorian into an
+autonomous coding loop as the deterministic, token-free **truth + steering layer**. It
+was scaffolded by `dorian loop install`.
+
+## What it is
+
+- `SKILL.md` — the skill, invoked as **`/dorian-loop-guard`**. Tells Claude to run
+ `dorian loop preflight` before each iteration and act on CONTINUE / REPAIR / ESCALATE.
+- `reference/loop-decisions.md` — the full decision table, the three policies, and the
+ loop-engineering failure modes Dorian does / does not help with.
+- `reference/safety-boundary.md` — what Dorian steers vs. what it never decides; the
+ not-a-sandbox / not-a-judge / token-free boundary.
+- `templates/LOOP.md`, `templates/STATE.md`, `templates/loop-run-log.md` — example loop
+ state files. Copy them to the repo root (or re-run `dorian loop install --with-state`).
+- `templates/dorian-loop.yml` — a GitHub Actions example that runs preflight on each
+ pull request (fails only on ESCALATE by default).
+
+## The one move
+
+```bash
+# before each loop iteration
+dorian loop preflight --since --policy assist --format json
+# -> {"decision": "continue|repair|escalate", "loop_instruction": "...", ...}
+```
+
+- `continue` → do the next planned step (stay in scope).
+- `repair` → fix the smallest cause of the broken load-bearing claim(s), re-check, log it.
+- `escalate` → stop autonomous edits; hand off to a human with the evidence.
+
+After a change, draft + seal new warrants with **`/dorian-claim-warrants`** and
+`dorian verify`; commit the `.warrant`. Those warrants are the loop's memory — the next
+preflight revalidates them.
+
+## Boundary
+
+Dorian steers; it does not judge whole-loop success and runs **no model at check time**.
+`REVOKED` is a steering signal, `ERRORED` is fail-closed evidence — neither is a verdict
+on the loop. Not a sandbox; trusted repos only. Dorian does not schedule the loop, manage
+worktrees, deliver notifications, or estimate cost — pair it with your loop runner.
diff --git a/src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md b/src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md
new file mode 100644
index 0000000..54b45c1
--- /dev/null
+++ b/src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md
@@ -0,0 +1,107 @@
+---
+name: Dorian loop guard
+description: >-
+ Use Dorian as the deterministic, token-free truth layer that steers an autonomous
+ coding loop. Before each iteration, run `dorian loop preflight` to revalidate the
+ claim warrants the change touched and get a CONTINUE / REPAIR / ESCALATE decision;
+ act on it. Use when running an unattended or long-running coding loop, when the user
+ asks to keep a loop on track, steer the next iteration, or wire Dorian into a loop.
+ Dorian steers; it does not judge whole-loop success and runs no model at check time.
+ Trusted repos only.
+when_to_use: >-
+ When driving a multi-iteration or unattended coding loop and you want a deterministic
+ signal for whether to continue, repair, or hand off to a human. Triggers: "loop
+ guard", "keep the loop on track", "dorian loop preflight", "steer the next iteration",
+ "should the loop continue or escalate".
+---
+
+# Dorian loop guard
+
+Invoke with **`/dorian-loop-guard`** when you are driving an autonomous coding loop.
+Dorian is the **deterministic truth layer underneath the loop's verifier step**: it
+re-checks the specific claim warrants a change touched and turns the result into a
+**CONTINUE / REPAIR / ESCALATE** steering signal — token-free, no model at check time.
+
+**Dorian steers; it does not grade the whole loop.** A `REVOKED` claim is a steering
+signal, not a moral failure. An `ERRORED` checker is fail-closed evidence, not a false
+claim. Dorian verifies only the specific claims someone wrote — never whole-loop
+success. It is **not a sandbox** and runs in **trusted repos** only (`C4 pytest:` / `C5
+shell:` checkers execute code through `revalidate`). See
+[reference/safety-boundary.md](reference/safety-boundary.md).
+
+Dorian does **not** schedule the loop, manage worktrees, deliver notifications, or
+estimate token cost — those belong to your loop runner (e.g. loop-engineering's
+loop-init / loop-audit / loop-cost). Dorian plugs in at the verify step.
+
+## The loop, with Dorian in it
+
+### 1. Before each iteration — preflight
+Run preflight against the loop's base ref and read the decision:
+```bash
+dorian loop preflight --since --policy --format json
+```
+Useful flags: `--scope 'src/**'` (bound the loop's lane — required by `unattended`),
+`--max-repairs N` and `--repair-attempts N` (infinite-fix-loop guard), `--state-file
+STATE.md.json` (read the prior attempt count), `--deny-path 'infra/**'` (extra sensitive
+paths). The packet's `decision`, `reason`, `broken_claims[]`, and `loop_instruction`
+tell you what to do.
+
+### 2. If decision is `continue`
+- No broken **load-bearing** claim for the changed paths. Proceed with the next planned
+ step. Stay inside `--scope`; do not touch out-of-scope or denylisted paths.
+
+### 3. If decision is `repair`
+- Read `broken_claims[]`. For each, repair the **smallest cause** in the bound files
+ (`paths`), or — if the change was intentional — update the claim/doc instead of the
+ code (`suggested_next_step` is a hint, not a verdict).
+- Re-run the exact checker or `dorian revalidate --since ` to confirm the fix.
+- Record the attempt and outcome in `STATE.md` / the run log; increment the repair
+ count. **Do not exceed `--max-repairs`** — after the cap, preflight returns `escalate`.
+
+### 4. If decision is `escalate`
+- **Stop autonomous edits.** Produce a human handoff from `human_escalation` +
+ `broken_claims[]`: the broken claim ids, the evidence, the bound files, and the reason.
+- Write it to `STATE.md` under "waiting on human"; do not keep retrying. Escalation
+ happens for `ERRORED` checkers, the repair cap, sensitive/denylisted paths, over-reach
+ (a break outside `--scope`), or an `unattended` loop with no scope configured.
+
+### 5. After any successful code/docs change — seal new warrants
+- Use **`/dorian-claim-warrants`** to draft the change note + `claims.json` for the new
+ load-bearing facts, then prove them:
+ ```bash
+ dorian verify docs/changes/.md --claims docs/changes/.claims.json \
+ --strength-gate=fail --binding-gate=warn
+ ```
+- Commit the sealed `.warrant` alongside the change. Those warrants become the loop's
+ **deterministic memory**: the next iteration's preflight revalidates them instead of
+ re-deriving the facts, and REVOKEs the ones a later edit breaks.
+
+## Render a next-iteration prompt
+To hand the decision to the next agent step as a compact instruction:
+```bash
+dorian loop prompt --since --policy assist
+```
+
+## Policies (pick per autonomy level)
+- `cautious` — repair even non-load-bearing breaks; cap 1. For closely-watched loops.
+- `assist` (default) — repair load-bearing breaks; continue past non-load-bearing with a
+ warning; cap 3.
+- `unattended` — like assist, but **requires `--scope`** before repairing a load-bearing
+ break (an L3 unattended loop needs a bounded lane); cap 3.
+
+See [reference/loop-decisions.md](reference/loop-decisions.md) for the full decision
+table and the failure modes Dorian does / does not address.
+
+## Hard rules
+- Dorian **steers**, it does not judge the whole loop. Never report "the loop is
+ correct" or "the change is verified" — only that the **named warranted claims** hold
+ or broke.
+- `REVOKED` ≠ failure-to-stop. It is a signal to repair or escalate, not a reason to
+ halt the loop by default (preflight exits 0 unless you set `--fail-on`).
+- `ERRORED` is fail-closed evidence (a checker could not run), **not** a false claim —
+ escalate it, do not "fix" it by weakening the checker.
+- Keep the two axes apart: binding = *when* a claim re-checks; strength = *whether* the
+ checker can falsify it. A weak-binding/strength warning is low confidence, **not** a
+ false claim — never hide it.
+- No model runs at check time. Dorian is not an LLM judge and not a sandbox; trusted
+ repos only.
diff --git a/src/dorian/templates/claude_code/dorian-loop-guard/reference/loop-decisions.md b/src/dorian/templates/claude_code/dorian-loop-guard/reference/loop-decisions.md
new file mode 100644
index 0000000..fceb97d
--- /dev/null
+++ b/src/dorian/templates/claude_code/dorian-loop-guard/reference/loop-decisions.md
@@ -0,0 +1,70 @@
+# Loop decisions: continue / repair / escalate
+
+`dorian loop preflight` runs `dorian revalidate` (deterministic, token-free) on the
+warrants the change touched, then classifies the result. The decision is a **pure
+function** of the revalidate outcome, the policy, the repair-attempt count, and the
+scope/denylist — no model, same input → same decision.
+
+## Decision precedence (first match wins)
+
+| # | Condition | Decision |
+|---|-----------|----------|
+| 1 | A checker **ERRORED** (could not run) | **escalate** (`fix_environment`) |
+| 2 | A break exists **and** `repair_attempts >= max_repairs` | **escalate** (infinite-fix cap) |
+| 3 | A broken/errored claim is bound to a **sensitive / denylisted** path | **escalate** |
+| 4 | A **load-bearing** break is **outside `--scope`** | **escalate** (over-reach) |
+| 5 | `unattended` policy **and no `--scope`** configured, with a load-bearing break | **escalate** |
+| 6 | A **load-bearing** break, in scope, under the cap | **repair** |
+| 7 | Only **non-load-bearing** breaks (DEGRADED) | `cautious`: **repair** · else **continue** (warn) |
+| 8 | No breaks (TRUSTED, or no warranted claim touched) | **continue** |
+
+`max_repairs` defaults per policy (cautious 1, assist/unattended 3) unless `--max-repairs`
+overrides. The active sensitive-path set (built-in security/infra/secrets/CI globs plus
+any `--deny-path`) is echoed in the packet's `sensitive_globs`, so it is never hidden.
+
+## Policies
+
+- **cautious** — most conservative. Repairs even non-load-bearing breaks; cap 1. Use when
+ a human is watching closely.
+- **assist** (default) — repairs load-bearing breaks; continues past non-load-bearing
+ breaks with a warning; cap 3. Use for assisted loops.
+- **unattended** — same break handling as assist, but a load-bearing break **escalates
+ unless `--scope` is set** (an L3 unattended loop needs a bounded lane). Cap 3.
+
+## Per-claim next step (`suggested_next_step`)
+
+A hint, not a verdict — Dorian cannot know your intent:
+
+- `repair_code` — the code drifted from a still-true claim; fix the smallest cause.
+- `update_claim` — the change was intentional; update the claim/doc instead of the code.
+- `fix_environment` — the checker **ERRORED**; fix the environment/infra, do not weaken
+ the checker.
+- `escalate` — this specific claim triggered the escalation (sensitive path, over-reach,
+ or the cap).
+
+## Exit codes
+
+`dorian loop preflight` exits **0 on success regardless of the decision** — Dorian does
+not stop the loop by default; the decision is in the output. For a hard CI gate, opt in
+with `--fail-on repair` or `--fail-on escalate` (exit 4 at/above that severity). Corrupt
+sidecar / bad input follow the usual contract (2 usage, 4 integrity).
+
+## Loop-engineering failure modes
+
+Dorian is the deterministic verifier *underneath* an LLM verifier sub-agent; it maps onto
+the loop-engineering failure catalog as follows. Dorian provides the **signal**; delivery,
+scheduling, and budgeting stay with the loop runner.
+
+| Failure mode | Dorian helps? | How |
+|---|---|---|
+| Weak verification / same-session verifier | **strongly** | Dorian is, by construction, a separate model-free verifier that cannot rubber-stamp. |
+| Verifier theater ("looks good", no tests) | **strongly** | A warrant either matches or REVOKEs, leaving an inspectable sidecar — it cannot just approve. |
+| Infinite fix loop | **signal** | Repeated REVOKED across attempts + the `--max-repairs` cap → escalate. The attempt *counter* lives in your STATE.md. |
+| Escalation failure | **trigger** | A revoked/errored load-bearing claim is an unambiguous escalate trigger. **Delivery** (Slack/STATE.md "waiting on human") is the loop runner's job. |
+| Over-reach / wrong scope | **partial** | `--scope` escalates a break outside the lane; warrants catch a silently-mutated fact. Path allow/deny enforcement stays in your skills/safety config. |
+| State rot | **partial** | Code-anchored warrants auto-REVOKE on drift instead of rotting. Live PR/ticket liveness is the loop runner's prune step. |
+| Token burn | **aligned** | Token-free preflight is a cheap "is anything actually broken?" pre-filter before spawning expensive sub-agents. Budget caps stay with loop-cost. |
+| Comprehension debt | **partial** | The warrant ledger is machine-checked documentation of what is guaranteed. Does not replace human review. |
+| Notification fatigue | **marginal** | Deterministic verdicts are high-precision escalation triggers (fewer false pings). Routing/digest is the loop runner's. |
+| Parallel collision | **no** | Worktree isolation / branch locks are the loop runner's job. Dorian defers. |
+| Cognitive surrender | **cultural** | Writing explicit claims is stating intent, but human gates are process, not Dorian. |
diff --git a/src/dorian/templates/claude_code/dorian-loop-guard/reference/safety-boundary.md b/src/dorian/templates/claude_code/dorian-loop-guard/reference/safety-boundary.md
new file mode 100644
index 0000000..c782d56
--- /dev/null
+++ b/src/dorian/templates/claude_code/dorian-loop-guard/reference/safety-boundary.md
@@ -0,0 +1,47 @@
+# Loop guard — safety boundary
+
+Dorian Loop Guard is a **steering** layer, not a judge and not a sandbox. Keep these
+distinctions exactly:
+
+## What Dorian decides
+
+- Whether the **specific warranted claims** a change touched still hold, by re-running
+ their deterministic checkers (`dorian revalidate`). No model runs at check time.
+- A CONTINUE / REPAIR / ESCALATE signal computed deterministically from that result plus
+ your policy, repair cap, and scope.
+
+## What Dorian never decides
+
+- Whether the **whole loop** succeeded. Dorian verifies only claims someone wrote; it
+ cannot catch a lie of omission, and a clean preflight is **not** "the change is good".
+- Whether the code is *correct*, *secure*, or *well-designed*. Those need tests, SAST,
+ review, and human judgment — Dorian is complementary, never a replacement.
+- It does not run, schedule, or budget the loop, and it does not deliver notifications or
+ manage worktrees. Pair it with your loop runner.
+
+## Verdict vocabulary (do not collapse)
+
+- **REVOKED** — a load-bearing claim broke. A *steering signal* (repair or escalate), not
+ a moral failure and not a reason to halt by default.
+- **DEGRADED** — a non-load-bearing claim broke. Lower priority; policy decides repair vs.
+ continue.
+- **ERRORED** — a checker could not run. **Fail-closed evidence, not a false claim** —
+ escalate and fix the environment; never weaken the checker to make it pass.
+- **weak binding / weak strength** — low coverage / low confidence, **not** a false claim.
+ Two separate axes: binding = *when* a claim re-checks; strength = *whether* the checker
+ can falsify it. Never hide a binding/strength warning.
+
+## Trust boundary
+
+`dorian loop preflight` calls `revalidate`, which **runs** each touched claim's checker.
+`C4 pytest:` and `C5 shell:` execute code. **This is not a sandbox** — allowed checkers
+run with your privileges. Use Loop Guard on **trusted, internal repos**. For semi-trusted
+contexts, pass `--deny-exec` (refuse the executable families — fail-closed) and/or
+`--checker-source base` (never run a PR-added checker); neither is a sandbox. See the main
+[docs/SECURITY_BOUNDARY.md](https://github.com/ajaysurya1221/dorian/blob/main/docs/SECURITY_BOUNDARY.md).
+
+## The handshake
+
+The loop's model **plans, repairs, and summarizes**. Dorian **proves** the checkable
+claims deterministically. Keep that line: never report a claim "verified" unless
+`dorian verify`/`dorian revalidate` actually said so.
diff --git a/src/dorian/templates/claude_code/dorian-loop-guard/templates/LOOP.md b/src/dorian/templates/claude_code/dorian-loop-guard/templates/LOOP.md
new file mode 100644
index 0000000..7448511
--- /dev/null
+++ b/src/dorian/templates/claude_code/dorian-loop-guard/templates/LOOP.md
@@ -0,0 +1,46 @@
+# LOOP.md — loop spec (example)
+
+The durable *design* of the coding loop that maintains this repo. Edit it to fit; the
+loop runner and the `/dorian-loop-guard` skill read it. Dorian is the deterministic
+verify step — it does not run, schedule, or budget the loop.
+
+## Purpose
+
+
+## Cadence
+
+
+## Scope (the loop's lane)
+- allow: `src/**`, `docs/**`
+- denylist (never auto-edit): `infra/**`, `.github/workflows/**`, `**/secrets/**`, `**/*.pem`
+
+## Verification commands
+```bash
+# before each iteration: deterministic steering signal
+dorian loop preflight --since --policy unattended --scope 'src/**' \
+ --max-repairs 3 --state-file STATE.json --format json
+# after a change: seal new warrants (model drafts, dorian proves)
+dorian verify docs/changes/.md --claims docs/changes/.claims.json \
+ --strength-gate=fail --binding-gate=warn
+# your own gates (Dorian complements, never replaces these):
+make lint && make test
+```
+
+## Steering policy
+- `continue` → next planned step, stay in scope.
+- `repair` → fix the smallest cause of a broken load-bearing claim, re-check, log it.
+- `escalate` → stop autonomous edits, hand off to a human (see STATE.md "waiting on human").
+
+## Escalation rules
+Escalate (do not auto-repair) when Dorian preflight says so: a checker ERRORED, the repair
+cap was reached, a sensitive/denylisted path is involved, a break is outside scope, or an
+`unattended` run has no scope. Deliver escalations to the Human Inbox in STATE.md.
+
+## Safety gates
+- No auto-merge except trivial, allowlisted paths.
+- Worktree isolation for parallel work (loop runner's job, not Dorian's).
+- Kill switch: pause the schedule on repeated escalation or budget overrun.
+
+## Non-goals
+Dorian does not judge whole-loop success, replace tests/review, sandbox execution, or
+manage cost/worktrees. It verifies specific written claims, token-free, on a trusted repo.
diff --git a/src/dorian/templates/claude_code/dorian-loop-guard/templates/STATE.md b/src/dorian/templates/claude_code/dorian-loop-guard/templates/STATE.md
new file mode 100644
index 0000000..a82be9e
--- /dev/null
+++ b/src/dorian/templates/claude_code/dorian-loop-guard/templates/STATE.md
@@ -0,0 +1,38 @@
+# STATE.md — live loop state (example)
+
+The mutable working memory of the loop. The loop runner / agent updates it every run.
+Dorian never writes this file; it only *reads* an attempt count when you pass
+`--state-file`. (For the machine-read count, keep a small `STATE.json` alongside this
+file — see the bottom.)
+
+## Current objective
+
+
+## Last run
+- timestamp:
+- base/ref:
+- Dorian decision:
+
+## Open loop items (watch list)
+
+
+## Revoked claims to repair
+
+
+## Waiting on human (escalation queue)
+
+
+## Recent noise (ignore this cycle)
+
+
+---
+
+Machine-read attempt count for `dorian loop preflight --state-file STATE.json`:
+
+```json
+{ "repair_attempts": 0 }
+```
+
diff --git a/src/dorian/templates/claude_code/dorian-loop-guard/templates/dorian-loop.yml b/src/dorian/templates/claude_code/dorian-loop-guard/templates/dorian-loop.yml
new file mode 100644
index 0000000..b174f1f
--- /dev/null
+++ b/src/dorian/templates/claude_code/dorian-loop-guard/templates/dorian-loop.yml
@@ -0,0 +1,39 @@
+# Example: run Dorian loop preflight as a deterministic steering step.
+#
+# This is an EXAMPLE, not auto-installed into your workflows. It runs `dorian loop
+# preflight` on each pull request and fails the job only on ESCALATE (a checker errored,
+# a sensitive path, over-reach, or the repair cap) — REPAIR and CONTINUE do not block, so
+# the loop is not stopped unnecessarily. Tighten with `--fail-on repair` if you want a
+# load-bearing break to block too.
+#
+# SECURITY: `dorian revalidate` (which preflight calls) RUNS each touched claim's checker;
+# C4 pytest: / C5 shell: execute code. This is NOT a sandbox. Recommended for trusted /
+# internal repos. For public/fork PRs, add `--checker-source base --deny-exec` (fail-closed,
+# still not a sandbox) — see docs/SECURITY_BOUNDARY.md.
+
+name: dorian-loop
+on: [pull_request]
+
+permissions:
+ contents: read
+
+jobs:
+ preflight:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # preflight diffs against the PR base sha
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - run: pip install dorian-vwp
+ - name: dorian loop preflight
+ run: |
+ dorian loop preflight \
+ --since "origin/${{ github.base_ref }}" \
+ --policy assist \
+ --scope 'src/**' --scope 'docs/**' \
+ --fail-on escalate \
+ --format md | tee "$GITHUB_STEP_SUMMARY"
diff --git a/src/dorian/templates/claude_code/dorian-loop-guard/templates/loop-run-log.md b/src/dorian/templates/claude_code/dorian-loop-guard/templates/loop-run-log.md
new file mode 100644
index 0000000..efa14cf
--- /dev/null
+++ b/src/dorian/templates/claude_code/dorian-loop-guard/templates/loop-run-log.md
@@ -0,0 +1,17 @@
+# loop-run-log.md — append-only run history (example)
+
+One entry per loop run, newest last. Separate from STATE.md so you can debug the loop's
+decisions over time. Dorian contributes the deterministic `dorian_decision` and the
+broken/revoked claim ids; the loop runner records the rest.
+
+```json
+{"run_id": "2026-06-28T02:00:00Z", "base": "main~1", "dorian_decision": "continue", "revoked_claims": [], "actions_taken": "ran next planned step", "tests": "lint+test green", "next": "continue", "escalations": 0}
+{"run_id": "2026-06-28T03:00:00Z", "base": "main~1", "dorian_decision": "repair", "revoked_claims": ["login-timeout-30s"], "actions_taken": "restored LOGIN_TIMEOUT=30 in src/config.py; revalidate -> TRUSTED", "tests": "lint+test green", "next": "continue", "escalations": 0}
+{"run_id": "2026-06-28T04:00:00Z", "base": "main~1", "dorian_decision": "escalate", "revoked_claims": ["db-migration-applied"], "actions_taken": "stopped; migrations/ is a sensitive path", "tests": "not run", "next": "waiting on human", "escalations": 1}
+```
+
+Human-readable one-liners (optional, mirror the JSON):
+
+- `2026-06-28T02:00Z` — CONTINUE — no warranted claims affected — next: continue.
+- `2026-06-28T03:00Z` — REPAIR — `login-timeout-30s` REVOKED → restored timeout → TRUSTED.
+- `2026-06-28T04:00Z` — ESCALATE — `db-migration-applied` on a sensitive path → human.
diff --git a/tests/test_loop_guard_dogfood.py b/tests/test_loop_guard_dogfood.py
new file mode 100644
index 0000000..b1eb48b
--- /dev/null
+++ b/tests/test_loop_guard_dogfood.py
@@ -0,0 +1,105 @@
+"""Dogfood: Dorian warrants its own Loop Guard claims, and a mutated fact flips the loop.
+
+Copies the real files the committed dogfood claims reference into a throwaway git repo,
+seals them born-verifiable with `dorian verify --strength-gate=fail`, then mutates one
+load-bearing fact and shows `dorian loop preflight` flip from CONTINUE to REPAIR. This is
+the runnable, honest form of the dogfood (the committed `.warrant` is produced by running
+the same verify command outside the sandbox).
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+from pathlib import Path
+
+from conftest import commit_all, git
+from dorian import cli, commands, gitio
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+NOTE = "docs/changes/dorian-loop-guard.md"
+CLAIMS = "docs/changes/dorian-loop-guard.claims.json"
+# the real files the dogfood claims reference (copied verbatim into the temp repo)
+REFERENCED = (
+ "src/dorian/loop.py",
+ "src/dorian/cli.py",
+ "src/dorian/commands.py",
+ "docs/DORIAN_LOOP_GUARD.md",
+ "docs/LOOP_ENGINEERING_ALIGNMENT.md",
+ "src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md",
+ "pyproject.toml",
+ "README.md",
+)
+
+
+def _ns(*argv: str):
+ return cli.build_parser().parse_args(list(argv))
+
+
+def _dogfood_repo(tmp_path: Path) -> Path:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ git(repo, "init", "-q", "-b", "main")
+ for rel in (*REFERENCED, NOTE, CLAIMS):
+ dst = repo / rel
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(REPO_ROOT / rel, dst)
+ commit_all(repo, "dogfood: loop guard artifacts")
+ return repo
+
+
+def test_dogfood_claims_seal_born_verifiable(tmp_path: Path, capsys) -> None:
+ repo = _dogfood_repo(tmp_path)
+ rc = commands.cmd_verify(
+ _ns(
+ "--repo",
+ str(repo),
+ "verify",
+ NOTE,
+ "--claims",
+ str(repo / CLAIMS),
+ "--strength-gate=fail",
+ "--binding-gate=warn",
+ )
+ )
+ out = capsys.readouterr().out
+ assert rc == 0, out
+ assert (repo / (NOTE + ".warrant")).is_file()
+ assert "verified 10/10 claim(s)" in out
+
+
+def test_dogfood_mutation_flips_continue_to_repair(tmp_path: Path, capsys) -> None:
+ repo = _dogfood_repo(tmp_path)
+ commands.cmd_verify(
+ _ns(
+ "--repo",
+ str(repo),
+ "verify",
+ NOTE,
+ "--claims",
+ str(repo / CLAIMS),
+ "--strength-gate=fail",
+ )
+ )
+ capsys.readouterr()
+ base = gitio.head_ref(repo)
+
+ # clean preflight: nothing changed -> continue
+ commands.cmd_loop(
+ _ns("--repo", str(repo), "loop", "preflight", "--since", base, "--format", "json")
+ )
+ clean = json.loads(capsys.readouterr().out)
+ assert clean["decision"] == "continue"
+
+ # mutate a warranted load-bearing fact: remove the preflight() definition
+ loop_py = repo / "src/dorian/loop.py"
+ loop_py.write_text(
+ loop_py.read_text(encoding="utf-8").replace("def preflight(", "def preflight_RENAMED("),
+ encoding="utf-8",
+ )
+ commands.cmd_loop(
+ _ns("--repo", str(repo), "loop", "preflight", "--since", base, "--format", "json")
+ )
+ drifted = json.loads(capsys.readouterr().out)
+ assert drifted["decision"] == "repair"
+ assert any(b["claim_id"] == "loop-engine" for b in drifted["broken_claims"])
diff --git a/tests/test_loop_install.py b/tests/test_loop_install.py
new file mode 100644
index 0000000..177946b
--- /dev/null
+++ b/tests/test_loop_install.py
@@ -0,0 +1,94 @@
+"""Loop install: idempotent scaffolding of the .claude/skills/dorian-loop-guard bundle."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from conftest import git
+from dorian import cli, commands
+
+SKILL = ".claude/skills/dorian-loop-guard/SKILL.md"
+
+
+def _ns(*argv: str):
+ return cli.build_parser().parse_args(list(argv))
+
+
+def _repo(tmp_path: Path) -> Path:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ git(repo, "init", "-q", "-b", "main")
+ return repo
+
+
+def _install(capsys, repo: Path, *extra: str) -> tuple[int, dict]:
+ rc = commands.cmd_loop(_ns("--repo", str(repo), "--json", "loop", "install", *extra))
+ return rc, json.loads(capsys.readouterr().out)
+
+
+def test_install_scaffolds_skill_bundle(tmp_path: Path, capsys) -> None:
+ repo = _repo(tmp_path)
+ rc, data = _install(capsys, repo)
+ assert rc == 0
+ assert SKILL in data["created"]
+ skill = repo / SKILL
+ assert skill.is_file()
+ text = skill.read_text(encoding="utf-8")
+ assert "/dorian-loop-guard" in text
+ assert "not a sandbox" in text
+ # the example state/action templates ride inside the skill dir by default
+ assert (repo / ".claude/skills/dorian-loop-guard/templates/LOOP.md").is_file()
+ assert (repo / ".claude/skills/dorian-loop-guard/reference/loop-decisions.md").is_file()
+ # but NOT at the repo root unless --with-state / --with-action
+ assert not (repo / "LOOP.md").exists()
+ assert not (repo / ".github/workflows/dorian-loop.yml").exists()
+
+
+def test_install_idempotent_without_force(tmp_path: Path, capsys) -> None:
+ repo = _repo(tmp_path)
+ _install(capsys, repo)
+ edited = "edited by user\n"
+ (repo / SKILL).write_text(edited, encoding="utf-8")
+ rc, data = _install(capsys, repo)
+ assert rc == 0
+ assert SKILL in data["skipped"]
+ assert data["created"] == []
+ assert (repo / SKILL).read_text(encoding="utf-8") == edited # not clobbered
+
+
+def test_install_force_overwrites(tmp_path: Path, capsys) -> None:
+ repo = _repo(tmp_path)
+ _install(capsys, repo)
+ (repo / SKILL).write_text("stale\n", encoding="utf-8")
+ rc, data = _install(capsys, repo, "--force")
+ assert rc == 0
+ assert SKILL in data["overwritten"]
+ assert "/dorian-loop-guard" in (repo / SKILL).read_text(encoding="utf-8")
+
+
+def test_install_with_state_and_action(tmp_path: Path, capsys) -> None:
+ repo = _repo(tmp_path)
+ rc, data = _install(capsys, repo, "--with-state", "--with-action")
+ assert rc == 0
+ for p in ("LOOP.md", "STATE.md", "loop-run-log.md", ".github/workflows/dorian-loop.yml"):
+ assert p in data["created"]
+ assert (repo / p).is_file()
+
+
+def test_install_dry_run_writes_nothing(tmp_path: Path, capsys) -> None:
+ repo = _repo(tmp_path)
+ rc, data = _install(capsys, repo, "--dry-run")
+ assert rc == 0
+ assert data["dry_run"] is True
+ assert SKILL in data["created"] # planned
+ assert not (repo / SKILL).exists() # but not written
+
+
+def test_install_print_next_steps_no_writes(tmp_path: Path, capsys) -> None:
+ repo = _repo(tmp_path)
+ rc = commands.cmd_loop(_ns("--repo", str(repo), "loop", "install", "--print-next-steps"))
+ out = capsys.readouterr().out
+ assert rc == 0
+ assert "Trust boundary" in out
+ assert not (repo / SKILL).exists()
diff --git a/tests/test_loop_packaging.py b/tests/test_loop_packaging.py
new file mode 100644
index 0000000..e6963fc
--- /dev/null
+++ b/tests/test_loop_packaging.py
@@ -0,0 +1,41 @@
+"""Loop-guard template packaging: every manifest template resolves via package data.
+
+The non-slow checks here run in an editable checkout (importlib.resources over the
+source tree); the slow installed-wheel check lives in test_packaging.py and proves the
+same files ship in a built wheel and resolve from an installed package.
+"""
+
+from __future__ import annotations
+
+from dorian import claude_code, loop
+
+
+def test_every_loop_template_is_readable_package_data() -> None:
+ for t in loop.LOOP_MANIFEST:
+ assert claude_code._read_template(t.src), f"empty/missing loop template: {t.src}"
+
+
+def test_loop_manifest_groups_and_dests() -> None:
+ assert {t.group for t in loop.LOOP_MANIFEST} <= set(loop.GROUPS)
+ assert "skill" in loop.DEFAULT_GROUPS
+ assert len(loop.DEFAULT_GROUPS) == 1
+ for t in loop.LOOP_MANIFEST:
+ if t.group == "skill":
+ assert t.dest.startswith(".claude/skills/dorian-loop-guard/")
+
+
+def test_skill_frontmatter_and_boundary_phrases() -> None:
+ text = claude_code._read_template("dorian-loop-guard/SKILL.md")
+ assert text.startswith("---\n")
+ frontmatter = text.split("---\n", 2)[1]
+ for key in ("name:", "description:", "when_to_use:"):
+ assert key in frontmatter
+ assert "/dorian-loop-guard" in text
+ for phrase in ("not a sandbox", "token-free", "steering signal", "not an LLM judge"):
+ assert phrase in text
+
+
+def test_loop_decisions_reference_documents_the_table() -> None:
+ text = claude_code._read_template("dorian-loop-guard/reference/loop-decisions.md")
+ for phrase in ("continue", "repair", "escalate", "cautious", "assist", "unattended"):
+ assert phrase in text
diff --git a/tests/test_loop_preflight.py b/tests/test_loop_preflight.py
new file mode 100644
index 0000000..cb46ba3
--- /dev/null
+++ b/tests/test_loop_preflight.py
@@ -0,0 +1,313 @@
+"""Loop preflight: the deterministic CONTINUE / REPAIR / ESCALATE decision packet.
+
+Reuses the shared fixture repo and the 4 demo claims from test_revalidate (_sealed):
+ c1 load-bearing C1 span over src/auth.py (relocates on rename, never breaks here)
+ c2 load-bearing C5 shell TIMEOUT == 30 (breaks when src/config.py drifts)
+ c3 NON-load-bearing C3 string /v1/login (breaks when the route is removed)
+ c4 NON-load-bearing C5 rowcount (untouched here)
+
+So a config edit breaks exactly one LOAD-BEARING claim; a routes edit breaks exactly one
+NON-load-bearing claim; --deny-shell makes the C5 shell ERROR instead of run.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from conftest import CONFIG_PY, ROUTES_PY, write
+from dorian import cli, commands, gitio
+from test_revalidate import _sealed
+
+
+def _ns(*argv: str):
+ return cli.build_parser().parse_args(list(argv))
+
+
+def _preflight(capsys, repo: Path, base: str, *extra: str) -> tuple[int, dict]:
+ rc = commands.cmd_loop(
+ _ns("--repo", str(repo), "loop", "preflight", "--since", base, "--format", "json", *extra)
+ )
+ return rc, json.loads(capsys.readouterr().out)
+
+
+def _break_config(repo: Path) -> None:
+ write(repo, "src/config.py", CONFIG_PY.replace("TIMEOUT = 30", "TIMEOUT = 10"))
+
+
+def _break_route(repo: Path) -> None:
+ write(repo, "src/routes.py", ROUTES_PY.replace(' "/v1/login": "auth.login",\n', ""))
+
+
+# --- continue --------------------------------------------------------------------
+
+
+def test_clean_change_continues(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo) # nothing changed since HEAD
+ rc, data = _preflight(capsys, fixture_repo, base)
+ assert rc == 0
+ assert data["decision"] == "continue"
+ assert data["candidates"] == 0
+ assert data["broken_claims"] == []
+ assert data["human_escalation"]["required"] is False
+
+
+# --- repair (load-bearing break) -------------------------------------------------
+
+
+def test_load_bearing_break_repairs(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo)
+ rc, data = _preflight(capsys, fixture_repo, base, "--policy", "assist")
+ assert rc == 0 # default --fail-on never: preflight does not stop the loop by itself
+ assert data["decision"] == "repair"
+ (c2,) = [b for b in data["broken_claims"] if b["claim_id"] == "c2"]
+ assert c2["load_bearing"] is True
+ assert c2["verdict"] == "BROKEN"
+ assert c2["suggested_next_step"] == "repair_code"
+ assert c2["trust_state"] == "REVOKED"
+ assert c2["checker"] == "C5" # checker type captured separately
+ assert not c2["evidence"].startswith("C5:") # and not doubled into the evidence text
+ assert data["trust_summary"]["revoked"] == 1
+ assert data["human_escalation"]["required"] is False
+
+
+def test_fail_on_repair_and_escalate_gates(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo)
+ # decision is "repair": --fail-on repair trips the gate (exit 4); escalate does not
+ rc_repair, _ = _preflight(capsys, fixture_repo, base, "--fail-on", "repair")
+ assert rc_repair == 4
+ rc_esc, data = _preflight(capsys, fixture_repo, base, "--fail-on", "escalate")
+ assert rc_esc == 0
+ assert data["decision"] == "repair"
+
+
+# --- degraded (non-load-bearing break) depends on policy -------------------------
+
+
+def test_non_load_bearing_break_continues_under_assist(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_route(fixture_repo)
+ rc, data = _preflight(capsys, fixture_repo, base, "--policy", "assist")
+ assert rc == 0
+ assert data["decision"] == "continue"
+ (c3,) = [b for b in data["broken_claims"] if b["claim_id"] == "c3"]
+ assert c3["load_bearing"] is False
+ assert data["trust_summary"]["degraded"] == 1
+
+
+def test_non_load_bearing_break_repairs_under_cautious(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_route(fixture_repo)
+ rc, data = _preflight(capsys, fixture_repo, base, "--policy", "cautious")
+ assert data["decision"] == "repair"
+ assert rc == 0
+
+
+# --- escalate: ERRORED checker ---------------------------------------------------
+
+
+def test_errored_checker_escalates(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo) # makes c2 a candidate
+ _, data = _preflight(capsys, fixture_repo, base, "--deny-shell")
+ assert data["decision"] == "escalate"
+ (c2,) = [b for b in data["broken_claims"] if b["claim_id"] == "c2"]
+ assert c2["verdict"] == "ERRORED"
+ assert c2["suggested_next_step"] == "fix_environment"
+ assert data["human_escalation"]["required"] is True
+ rc_gate, _ = _preflight(capsys, fixture_repo, base, "--deny-shell", "--fail-on", "escalate")
+ assert rc_gate == 4
+
+
+# --- escalate: repair-attempt cap ------------------------------------------------
+
+
+def test_repair_cap_escalates(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo)
+ _, data = _preflight(capsys, fixture_repo, base, "--max-repairs", "2", "--repair-attempts", "2")
+ assert data["decision"] == "escalate"
+ assert data["repair"] == {"attempts": 2, "max": 2}
+ assert "infinite-fix" in data["reason"]
+
+
+def test_cap_does_not_stop_non_load_bearing_under_assist(fixture_repo: Path, capsys) -> None:
+ """A stale repair counter must NOT cap-escalate a non-load-bearing-only break that
+ assist would otherwise `continue` past — don't stop the loop unnecessarily."""
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_route(fixture_repo) # non-load-bearing c3 only
+ _, data = _preflight(
+ capsys,
+ fixture_repo,
+ base,
+ "--policy",
+ "assist",
+ "--max-repairs",
+ "1",
+ "--repair-attempts",
+ "5",
+ )
+ assert data["decision"] == "continue"
+
+
+def test_cap_stops_non_load_bearing_under_cautious(fixture_repo: Path, capsys) -> None:
+ """cautious DOES repair non-load-bearing breaks, so the cap applies to them there."""
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_route(fixture_repo)
+ _, data = _preflight(
+ capsys,
+ fixture_repo,
+ base,
+ "--policy",
+ "cautious",
+ "--max-repairs",
+ "1",
+ "--repair-attempts",
+ "5",
+ )
+ assert data["decision"] == "escalate"
+
+
+# --- escalate: scope over-reach / unattended needs scope / sensitive path --------
+
+
+def test_out_of_scope_break_escalates(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo) # bound to src/config.py
+ _, data_out = _preflight(capsys, fixture_repo, base, "--scope", "docs/**")
+ assert data_out["decision"] == "escalate"
+ _, data_in = _preflight(capsys, fixture_repo, base, "--scope", "src/**")
+ assert data_in["decision"] == "repair"
+
+
+def test_cautious_scope_escalates_out_of_lane_non_load_bearing(fixture_repo: Path, capsys) -> None:
+ """cautious repairs non-load-bearing breaks, so one outside --scope over-reaches -> escalate."""
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_route(fixture_repo) # non-load-bearing c3, bound to src/routes.py
+ _, data = _preflight(capsys, fixture_repo, base, "--policy", "cautious", "--scope", "docs/**")
+ assert data["decision"] == "escalate"
+
+
+def test_repair_instruction_excludes_out_of_scope_files(fixture_repo: Path, capsys) -> None:
+ """An in-scope load-bearing repair must never tell the agent to touch an out-of-scope file."""
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo) # load-bearing c2, src/config.py (in scope)
+ _break_route(fixture_repo) # non-load-bearing c3, src/routes.py (out of scope)
+ _, data = _preflight(
+ capsys, fixture_repo, base, "--policy", "assist", "--scope", "src/config.py"
+ )
+ assert data["decision"] == "repair"
+ assert "src/config.py" in data["loop_instruction"]
+ assert "src/routes.py" not in data["loop_instruction"]
+
+
+def test_repair_warns_when_cap_tracking_absent(fixture_repo: Path, capsys) -> None:
+ """On a repair with no --repair-attempts/--state-file, the packet says the cap is inactive."""
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo)
+ _, data = _preflight(capsys, fixture_repo, base, "--policy", "assist")
+ assert data["decision"] == "repair"
+ assert any("infinite-fix cap inactive" in n for n in data["notes"])
+
+
+def test_unattended_requires_scope(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo)
+ _, no_scope = _preflight(capsys, fixture_repo, base, "--policy", "unattended")
+ assert no_scope["decision"] == "escalate"
+ _, scoped = _preflight(
+ capsys, fixture_repo, base, "--policy", "unattended", "--scope", "src/**"
+ )
+ assert scoped["decision"] == "repair"
+
+
+def test_sensitive_denylisted_path_escalates(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo)
+ _, data = _preflight(capsys, fixture_repo, base, "--deny-path", "src/config.py")
+ assert data["decision"] == "escalate"
+ (c2,) = [b for b in data["broken_claims"] if b["claim_id"] == "c2"]
+ assert c2["sensitive"] is True
+ assert c2["suggested_next_step"] == "escalate"
+
+
+# --- packet shape ----------------------------------------------------------------
+
+
+def test_packet_shape(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo)
+ _, d = _preflight(capsys, fixture_repo, base)
+ assert d["schema_version"] == 1
+ assert d["decision"] in ("continue", "repair", "escalate")
+ assert set(d["trust_summary"]) == {"trusted", "warranted", "degraded", "revoked", "errored"}
+ assert set(d["repair"]) == {"attempts", "max"}
+ assert set(d["human_escalation"]) == {"required", "reason", "message"}
+ assert d["loop_instruction"]
+ assert d["sensitive_globs"] # built-in set is echoed, never hidden
+ b = d["broken_claims"][0]
+ for key in (
+ "artifact",
+ "warrant",
+ "claim_id",
+ "text",
+ "kind",
+ "load_bearing",
+ "verdict",
+ "trust_state",
+ "checker",
+ "evidence",
+ "paths",
+ "sensitive",
+ "in_scope",
+ "suggested_next_step",
+ ):
+ assert key in b
+
+
+def test_cli_main_dispatches_loop_preflight(fixture_repo: Path, capsys) -> None:
+ """Full top-level dispatch: cli.main(argv) -> commands.cmd_loop -> preflight."""
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ rc = cli.main(
+ ["--repo", str(fixture_repo), "loop", "preflight", "--since", base, "--format", "json"]
+ )
+ assert rc == 0
+ assert json.loads(capsys.readouterr().out)["decision"] == "continue"
+
+
+def test_requires_since_or_changed_paths(fixture_repo: Path, capsys) -> None:
+ rc = commands.cmd_loop(_ns("--repo", str(fixture_repo), "loop", "preflight"))
+ assert rc == 2
+ assert "exactly one of" in capsys.readouterr().err
+
+
+def test_state_file_repair_attempts(fixture_repo: Path, tmp_path: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ _break_config(fixture_repo)
+ state = tmp_path / "state.json"
+ state.write_text(json.dumps({"repair_attempts": 3}))
+ _, data = _preflight(
+ capsys, fixture_repo, base, "--max-repairs", "3", "--state-file", str(state)
+ )
+ assert data["decision"] == "escalate" # 3 >= 3 -> cap
+ assert data["repair"]["attempts"] == 3
diff --git a/tests/test_loop_prompt.py b/tests/test_loop_prompt.py
new file mode 100644
index 0000000..a985f30
--- /dev/null
+++ b/tests/test_loop_prompt.py
@@ -0,0 +1,54 @@
+"""Loop prompt: a compact, agent-readable next-iteration instruction."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from conftest import CONFIG_PY, write
+from dorian import cli, commands, gitio
+from test_revalidate import _sealed
+
+
+def _ns(*argv: str):
+ return cli.build_parser().parse_args(list(argv))
+
+
+def test_prompt_repair_mentions_broken_claim(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ write(fixture_repo, "src/config.py", CONFIG_PY.replace("TIMEOUT = 30", "TIMEOUT = 10"))
+ rc = commands.cmd_loop(_ns("--repo", str(fixture_repo), "loop", "prompt", "--since", base))
+ out = capsys.readouterr().out
+ assert rc == 0
+ assert "REPAIR" in out
+ assert "c2" in out
+ assert "Boundary" in out # the standing not-a-judge / not-a-sandbox reminder
+
+
+def test_prompt_continue(fixture_repo: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ rc = commands.cmd_loop(_ns("--repo", str(fixture_repo), "loop", "prompt", "--since", base))
+ assert rc == 0
+ assert "CONTINUE" in capsys.readouterr().out
+
+
+def test_prompt_from_saved_json(fixture_repo: Path, tmp_path: Path, capsys) -> None:
+ _sealed(fixture_repo)
+ base = gitio.head_ref(fixture_repo)
+ write(fixture_repo, "src/config.py", CONFIG_PY.replace("TIMEOUT = 30", "TIMEOUT = 10"))
+ commands.cmd_loop(
+ _ns("--repo", str(fixture_repo), "loop", "preflight", "--since", base, "--format", "json")
+ )
+ packet = capsys.readouterr().out
+ saved = tmp_path / "packet.json"
+ saved.write_text(packet)
+ # rendering from the saved packet must not need the repo at all
+ rc = commands.cmd_loop(_ns("loop", "prompt", "--from-json", str(saved)))
+ out = capsys.readouterr().out
+ assert rc == 0
+ assert "REPAIR" in out
+ assert "c2" in out
+ # the saved packet and a fresh render agree on the decision
+ assert json.loads(packet)["decision"] == "repair"
diff --git a/tests/test_packaging.py b/tests/test_packaging.py
index df7834b..064fdc0 100644
--- a/tests/test_packaging.py
+++ b/tests/test_packaging.py
@@ -148,3 +148,31 @@ def test_installed_scaffolds_claim_warrants_skill(installed_dorian: Path, tmp_pa
text = skill.read_text(encoding="utf-8")
for phrase in ("DRAFT", "not a sandbox", "strength-gate=fail"):
assert phrase in text
+
+
+def test_installed_scaffolds_loop_guard_skill(installed_dorian: Path, tmp_path: Path) -> None:
+ """The packaged binary scaffolds the loop-guard bundle from package data — proving the
+ skill, the state templates, AND the .yml Action example ship in the wheel."""
+ repo = tmp_path / "looprepo"
+ repo.mkdir()
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True, capture_output=True)
+ r = subprocess.run(
+ [
+ str(installed_dorian),
+ "--repo",
+ str(repo),
+ "loop",
+ "install",
+ "--with-state",
+ "--with-action",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ assert r.returncode == 0, r.stderr
+ assert (repo / ".claude/skills/dorian-loop-guard/SKILL.md").is_file()
+ assert (repo / "LOOP.md").is_file()
+ assert (repo / ".github/workflows/dorian-loop.yml").is_file()
+ text = (repo / ".claude/skills/dorian-loop-guard/SKILL.md").read_text(encoding="utf-8")
+ for phrase in ("/dorian-loop-guard", "not a sandbox", "token-free"):
+ assert phrase in text
]