From 48c9e5e61450af5eae5394595bdb2cb789319ad5 Mon Sep 17 00:00:00 2001 From: Ajay Surya Date: Sun, 28 Jun 2026 16:22:50 +0530 Subject: [PATCH] feat: add Dorian Loop Guard (loop preflight steering + skill) Deterministic, token-free CONTINUE/REPAIR/ESCALATE steering for AI coding loops, built as a thin classifier over revalidate (no model at check time). Adds 'dorian loop preflight|prompt|install', the /dorian-loop-guard Claude Code skill templates, alignment/positioning docs, a README section, and a dogfood warrant (10/10). Purely additive: warrant schema, checker grammar, exit codes, fold policy, and security posture unchanged; core stays zero-dependency; REVOKED stays a steering signal, ERRORED fail-closed evidence. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 32 + README.md | 24 + docs/CLAUDE_CODE_DORIAN_WORKFLOW.md | 7 + docs/DORIAN_LOOP_GUARD.md | 155 ++++ docs/LOOP_ENGINEERING_ALIGNMENT.md | 76 ++ docs/POSITIONING_LOOP_GUARD_2026_06_28.md | 54 ++ docs/changes/dorian-loop-guard.claims.json | 86 +++ docs/changes/dorian-loop-guard.md | 43 ++ docs/changes/dorian-loop-guard.md.warrant | 283 +++++++ src/dorian/claude_code.py | 11 +- src/dorian/cli.py | 141 ++++ src/dorian/commands.py | 201 +++++ src/dorian/loop.py | 707 ++++++++++++++++++ .../claude_code/dorian-loop-guard/README.md | 41 + .../claude_code/dorian-loop-guard/SKILL.md | 107 +++ .../reference/loop-decisions.md | 70 ++ .../reference/safety-boundary.md | 47 ++ .../dorian-loop-guard/templates/LOOP.md | 46 ++ .../dorian-loop-guard/templates/STATE.md | 38 + .../templates/dorian-loop.yml | 39 + .../templates/loop-run-log.md | 17 + tests/test_loop_guard_dogfood.py | 105 +++ tests/test_loop_install.py | 94 +++ tests/test_loop_packaging.py | 41 + tests/test_loop_preflight.py | 313 ++++++++ tests/test_loop_prompt.py | 54 ++ tests/test_packaging.py | 28 + 27 files changed, 2858 insertions(+), 2 deletions(-) create mode 100644 docs/DORIAN_LOOP_GUARD.md create mode 100644 docs/LOOP_ENGINEERING_ALIGNMENT.md create mode 100644 docs/POSITIONING_LOOP_GUARD_2026_06_28.md create mode 100644 docs/changes/dorian-loop-guard.claims.json create mode 100644 docs/changes/dorian-loop-guard.md create mode 100644 docs/changes/dorian-loop-guard.md.warrant create mode 100644 src/dorian/loop.py create mode 100644 src/dorian/templates/claude_code/dorian-loop-guard/README.md create mode 100644 src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md create mode 100644 src/dorian/templates/claude_code/dorian-loop-guard/reference/loop-decisions.md create mode 100644 src/dorian/templates/claude_code/dorian-loop-guard/reference/safety-boundary.md create mode 100644 src/dorian/templates/claude_code/dorian-loop-guard/templates/LOOP.md create mode 100644 src/dorian/templates/claude_code/dorian-loop-guard/templates/STATE.md create mode 100644 src/dorian/templates/claude_code/dorian-loop-guard/templates/dorian-loop.yml create mode 100644 src/dorian/templates/claude_code/dorian-loop-guard/templates/loop-run-log.md create mode 100644 tests/test_loop_guard_dogfood.py create mode 100644 tests/test_loop_install.py create mode 100644 tests/test_loop_packaging.py create mode 100644 tests/test_loop_preflight.py create mode 100644 tests/test_loop_prompt.py 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