feat: add Dorian Loop Guard (loop preflight steering + skill)#25
Conversation
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) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds the ChangesDorian Loop Guard
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (1)
tests/test_packaging.py (1)
173-175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the full
--with-statebundle here.This wheel-level test only checks
LOOP.md, but the state group also includesSTATE.mdandloop-run-log.mdintests/test_loop_install.py, Lines 74-76. If either package-data entry drops from the wheel, the source-tree install tests still pass and this packaging test will miss it.Suggested diff
assert (repo / ".claude/skills/dorian-loop-guard/SKILL.md").is_file() assert (repo / "LOOP.md").is_file() + assert (repo / "STATE.md").is_file() + assert (repo / "loop-run-log.md").is_file() assert (repo / ".github/workflows/dorian-loop.yml").is_file()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_packaging.py` around lines 173 - 175, The packaging test currently verifies only part of the `--with-state` bundle, so it can miss missing wheel assets. Update `test_packaging` to assert the full state bundle by checking for `STATE.md` and `loop-run-log.md` alongside `LOOP.md`, matching the bundle covered in `test_loop_install` and the related state install checks. Use the existing `repo` path assertions in this test so the wheel-level coverage fails if any package-data entry for the state group drops from the wheel.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/dorian/cli.py`:
- Around line 352-354: `--repair-attempts` currently uses a falsy default so `0`
is treated the same as “not provided,” which breaks precedence in the CLI
preflight path. Update the argument handling in `src/dorian/cli.py` and
`_run_loop_preflight()` in `src/dorian/commands.py` so omission is represented
by `None`, then only fall back to `state_file` when `args.repair_attempts` is
`None`. Keep `args.repair_attempts` as the explicit value when passed, including
`0`, so the loop uses the user-supplied override instead of stale persisted
state.
In `@src/dorian/commands.py`:
- Around line 1221-1232: Guard the saved-packet path in `commands.py` before
calling `loop.render_prompt(packet)`: after `json.loads(data)` succeeds,
validate that the parsed packet contains the fields `render_prompt` expects (for
example `decision` and `broken_claims`) and treat missing/invalid structure as a
usage error. If validation fails, print a clear `dorian loop prompt: invalid
--from-json packet` message to stderr and return `EXIT_USAGE` instead of letting
`loop.render_prompt()` raise a traceback.
In `@src/dorian/loop.py`:
- Around line 239-257: The unreadable-claim fallback in loop.py currently
creates a BrokenClaim with load_bearing set but still marks it as non-sensitive
and in scope, which can let assist return repair too easily. Update the
BrokenClaim construction in this claim None branch to fail closed by marking the
record escalation-worthy, or add an explicit unreadable-metadata decision cause
that prevents a normal repair path; use the BrokenClaim fields and the
surrounding claim None handling to locate the fix.
- Around line 591-597: The render_prompt function currently indexes the header
map directly with d["decision"], so arbitrary --from-json input can raise
KeyError instead of a usage-style error. Update render_prompt in the loop module
to validate the saved packet before lookup by checking that d is a dict and that
decision is one of the expected values used in the header mapping, and have
commands.py handle invalid packets by returning a clear error path rather than
letting the exception traceback.
- Around line 51-62: The sensitive path filter in SENSITIVE_GLOBS misses
root-level directories because the current patterns only match nested paths.
Update SENSITIVE_GLOBS in loop.py to include root equivalents for auth,
security, migrations, and secrets alongside the existing "*/auth/*",
"*/security/*", "*/migrations/*", and "*/secrets/*" entries so root files like
auth/login.py and secrets/prod.env are also excluded.
In `@src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md`:
- Around line 43-46: The `--with-state` docs are incomplete because they
reference `--state-file STATE.md.json` but the `state` bundle in the SKILL docs
only scaffolds `LOOP.md`, `STATE.md`, and `loop-run-log.md`. Update the relevant
guidance in `SKILL.md` so the `--with-state`/`--state-file` flow clearly
includes creating or emitting the JSON state file expected by
`loop.read_repair_attempts()`, and make sure the `decision`, `reason`,
`broken_claims[]`, and `loop_instruction` packet fields are documented as part
of that state path.
In `@src/dorian/templates/claude_code/dorian-loop-guard/templates/LOOP.md`:
- Around line 17-24: The verification example in LOOP.md uses a state file name
that does not match the scaffolded artifacts, so update the loop preflight
command in the LOOP.md template to reference the actual state document used by
the bundled workflow. Keep the guidance aligned with the symbols and files
already mentioned in the template, especially dorian loop install --with-state,
LOOP.md, and STATE.md, so the example is consistent with the generated state-cap
tracking setup.
In `@src/dorian/templates/claude_code/dorian-loop-guard/templates/STATE.md`:
- Around line 31-38: The state bundle is missing the machine-readable
`STATE.json` seed, so preflight can’t reliably persist `repair_attempts` through
`dorian loop preflight --state-file STATE.json`. Add a `STATE.json` template
alongside the existing `STATE.md` guidance and include it in the `state` install
group so fresh installs ship the counter file by default. Keep the existing loop
guard flow in `src/dorian/loop.py` aligned with this seeded state file.
---
Nitpick comments:
In `@tests/test_packaging.py`:
- Around line 173-175: The packaging test currently verifies only part of the
`--with-state` bundle, so it can miss missing wheel assets. Update
`test_packaging` to assert the full state bundle by checking for `STATE.md` and
`loop-run-log.md` alongside `LOOP.md`, matching the bundle covered in
`test_loop_install` and the related state install checks. Use the existing
`repo` path assertions in this test so the wheel-level coverage fails if any
package-data entry for the state group drops from the wheel.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 7534dbd0-feba-4e6b-934d-56792c27005a
📒 Files selected for processing (27)
CHANGELOG.mdREADME.mddocs/CLAUDE_CODE_DORIAN_WORKFLOW.mddocs/DORIAN_LOOP_GUARD.mddocs/LOOP_ENGINEERING_ALIGNMENT.mddocs/POSITIONING_LOOP_GUARD_2026_06_28.mddocs/changes/dorian-loop-guard.claims.jsondocs/changes/dorian-loop-guard.mddocs/changes/dorian-loop-guard.md.warrantsrc/dorian/claude_code.pysrc/dorian/cli.pysrc/dorian/commands.pysrc/dorian/loop.pysrc/dorian/templates/claude_code/dorian-loop-guard/README.mdsrc/dorian/templates/claude_code/dorian-loop-guard/SKILL.mdsrc/dorian/templates/claude_code/dorian-loop-guard/reference/loop-decisions.mdsrc/dorian/templates/claude_code/dorian-loop-guard/reference/safety-boundary.mdsrc/dorian/templates/claude_code/dorian-loop-guard/templates/LOOP.mdsrc/dorian/templates/claude_code/dorian-loop-guard/templates/STATE.mdsrc/dorian/templates/claude_code/dorian-loop-guard/templates/dorian-loop.ymlsrc/dorian/templates/claude_code/dorian-loop-guard/templates/loop-run-log.mdtests/test_loop_guard_dogfood.pytests/test_loop_install.pytests/test_loop_packaging.pytests/test_loop_preflight.pytests/test_loop_prompt.pytests/test_packaging.py
| "--repair-attempts", | ||
| type=int, | ||
| default=0, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make --repair-attempts 0 distinguishable from “flag omitted”.
With default=0, argparse cannot tell whether the user explicitly passed --repair-attempts 0 or omitted the flag. That breaks the precedence promised in src/dorian/commands.py (--repair-attempts should win over --state-file), because _run_loop_preflight() currently falls back to the state file on any falsy value. A loop that resets its counter to zero can therefore still inherit a stale persisted count and escalate incorrectly.
💡 Proposed fix
parser.add_argument(
"--repair-attempts",
type=int,
- default=0,
+ default=None,
help="prior repair attempts for the current broken set (the loop threads this in;"
" reaching --max-repairs escalates as an infinite-fix-loop guard)",
)# src/dorian/commands.py
repair_attempts = 0 if args.repair_attempts is None else args.repair_attempts
if args.state_file and args.repair_attempts is None:
repair_attempts, note = loop.read_repair_attempts(Path(args.state_file))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dorian/cli.py` around lines 352 - 354, `--repair-attempts` currently uses
a falsy default so `0` is treated the same as “not provided,” which breaks
precedence in the CLI preflight path. Update the argument handling in
`src/dorian/cli.py` and `_run_loop_preflight()` in `src/dorian/commands.py` so
omission is represented by `None`, then only fall back to `state_file` when
`args.repair_attempts` is `None`. Keep `args.repair_attempts` as the explicit
value when passed, including `0`, so the loop uses the user-supplied override
instead of stale persisted state.
| 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="") |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Guard malformed --from-json packets before rendering.
json.loads() failures are handled, but a saved packet missing keys like decision or broken_claims will still raise inside loop.render_prompt() and terminate the command with a traceback instead of EXIT_USAGE.
Suggested fix
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:
+ rendered = loop.render_prompt(packet)
+ except (OSError, ValueError, KeyError, TypeError) as exc:
print(f"dorian loop prompt: cannot read --from-json: {exc}", file=sys.stderr)
return EXIT_USAGE
- print(loop.render_prompt(packet), end="")
+ print(rendered, end="")
return EXIT_OK📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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="") | |
| 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) | |
| rendered = loop.render_prompt(packet) | |
| except (OSError, ValueError, KeyError, TypeError) as exc: | |
| print(f"dorian loop prompt: cannot read --from-json: {exc}", file=sys.stderr) | |
| return EXIT_USAGE | |
| print(rendered, end="") |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dorian/commands.py` around lines 1221 - 1232, Guard the saved-packet path
in `commands.py` before calling `loop.render_prompt(packet)`: after
`json.loads(data)` succeeds, validate that the parsed packet contains the fields
`render_prompt` expects (for example `decision` and `broken_claims`) and treat
missing/invalid structure as a usage error. If validation fails, print a clear
`dorian loop prompt: invalid --from-json packet` message to stderr and return
`EXIT_USAGE` instead of letting `loop.render_prompt()` raise a traceback.
| SENSITIVE_GLOBS = ( | ||
| "*secret*", | ||
| "*.pem", | ||
| "*.key", | ||
| "*.env", | ||
| "*.env.*", | ||
| ".github/workflows/*", | ||
| "*Dockerfile*", | ||
| "*/secrets/*", | ||
| "*/auth/*", | ||
| "*/security/*", | ||
| "*/migrations/*", |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
python - <<'PY'
import fnmatch
for path, pattern in [
("auth/login.py", "*/auth/*"),
("security/policy.py", "*/security/*"),
("migrations/001.sql", "*/migrations/*"),
("secrets/prod.env", "*/secrets/*"),
]:
print(path, pattern, fnmatch.fnmatch(path, pattern))
PYRepository: ajaysurya1221/dorian
Length of output: 300
🏁 Script executed:
#!/bin/bash
sed -n '1,140p' src/dorian/loop.pyRepository: ajaysurya1221/dorian
Length of output: 5460
Cover root-level sensitive directories. */auth/*, */security/*, */migrations/*, and */secrets/* miss root paths like auth/login.py, security/policy.py, migrations/001.sql, and secrets/prod.env, so those can still be repaired autonomously. Add root-level globs alongside the nested ones.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dorian/loop.py` around lines 51 - 62, The sensitive path filter in
SENSITIVE_GLOBS misses root-level directories because the current patterns only
match nested paths. Update SENSITIVE_GLOBS in loop.py to include root
equivalents for auth, security, migrations, and secrets alongside the existing
"*/auth/*", "*/security/*", "*/migrations/*", and "*/secrets/*" entries so root
files like auth/login.py and secrets/prod.env are also excluded.
| 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, | ||
| ) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Fail closed when claim metadata is unreadable.
This fallback treats an unreadable claim as load-bearing, but leaves it sensitive=False and in_scope=True, so assist can return repair without any bound paths. Mark it as escalation-worthy, or add an explicit unreadable-metadata decision cause.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dorian/loop.py` around lines 239 - 257, The unreadable-claim fallback in
loop.py currently creates a BrokenClaim with load_bearing set but still marks it
as non-sensitive and in scope, which can let assist return repair too easily.
Update the BrokenClaim construction in this claim None branch to fail closed by
marking the record escalation-worthy, or add an explicit unreadable-metadata
decision cause that prevents a normal repair path; use the BrokenClaim fields
and the surrounding claim None handling to locate the fix.
| 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"]] |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Validate saved prompt packets before indexing.
commands.py passes arbitrary --from-json data into render_prompt; a valid JSON object with a missing or unknown decision raises KeyError and can traceback instead of returning a usage error.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dorian/loop.py` around lines 591 - 597, The render_prompt function
currently indexes the header map directly with d["decision"], so arbitrary
--from-json input can raise KeyError instead of a usage-style error. Update
render_prompt in the loop module to validate the saved packet before lookup by
checking that d is a dict and that decision is one of the expected values used
in the header mapping, and have commands.py handle invalid packets by returning
a clear error path rather than letting the exception traceback.
| 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` |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
--with-state does not scaffold the file this flag expects.
The docs point users at --state-file STATE.md.json, but the installed "state" bundle only creates LOOP.md, STATE.md, and loop-run-log.md, while loop.read_repair_attempts() accepts JSON only. As written, the advertised repair-attempt threading path is incomplete unless users invent their own sidecar file, so the cap stays inactive by default.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dorian/templates/claude_code/dorian-loop-guard/SKILL.md` around lines 43
- 46, The `--with-state` docs are incomplete because they reference
`--state-file STATE.md.json` but the `state` bundle in the SKILL docs only
scaffolds `LOOP.md`, `STATE.md`, and `loop-run-log.md`. Update the relevant
guidance in `SKILL.md` so the `--with-state`/`--state-file` flow clearly
includes creating or emitting the JSON state file expected by
`loop.read_repair_attempts()`, and make sure the `decision`, `reason`,
`broken_claims[]`, and `loop_instruction` packet fields are documented as part
of that state path.
| ## Verification commands | ||
| ```bash | ||
| # before each iteration: deterministic steering signal | ||
| dorian loop preflight --since <base> --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/<slug>.md --claims docs/changes/<slug>.claims.json \ | ||
| --strength-gate=fail --binding-gate=warn |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The bundled state-file example does not match the scaffolded files.
This example tells users to pass --state-file STATE.json, but dorian loop install --with-state only scaffolds LOOP.md, STATE.md, and loop-run-log.md, and the reference guide also says the counter lives in STATE.md. As shipped, someone following the bundle verbatim has no compatible JSON state file for cap tracking, so repeated repairs can silently lose the infinite-fix guard.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dorian/templates/claude_code/dorian-loop-guard/templates/LOOP.md` around
lines 17 - 24, The verification example in LOOP.md uses a state file name that
does not match the scaffolded artifacts, so update the loop preflight command in
the LOOP.md template to reference the actual state document used by the bundled
workflow. Keep the guidance aligned with the symbols and files already mentioned
in the template, especially dorian loop install --with-state, LOOP.md, and
STATE.md, so the example is consistent with the generated state-cap tracking
setup.
| Machine-read attempt count for `dorian loop preflight --state-file STATE.json`: | ||
|
|
||
| ```json | ||
| { "repair_attempts": 0 } | ||
| ``` | ||
| <!-- Keep the JSON above in STATE.json. Increment repair_attempts after each repair | ||
| attempt on the current broken set; reset to 0 once the loop returns to continue. | ||
| Reaching --max-repairs makes preflight escalate (infinite-fix-loop guard). --> |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Ship the STATE.json seed with the state bundle.
This template tells users to run preflight with --state-file STATE.json, and src/dorian/loop.py:510-523 only persists the repair cap through that JSON file. But the shipped --with-state scaffold currently creates LOOP.md, STATE.md, and loop-run-log.md only, so a fresh install has no machine-readable counter unless the user notices this footnote and creates it manually. That silently falls back to repair_attempts=0 and weakens the infinite-fix-loop guard.
Proposed fix
+{
+ "repair_attempts": 0
+}Add this as a STATE.json template and include it in the state install group alongside STATE.md.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/dorian/templates/claude_code/dorian-loop-guard/templates/STATE.md` around
lines 31 - 38, The state bundle is missing the machine-readable `STATE.json`
seed, so preflight can’t reliably persist `repair_attempts` through `dorian loop
preflight --state-file STATE.json`. Add a `STATE.json` template alongside the
existing `STATE.md` guidance and include it in the `state` install group so
fresh installs ship the counter file by default. Keep the existing loop guard
flow in `src/dorian/loop.py` aligned with this seeded state file.
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) noreply@anthropic.com
Summary by CodeRabbit
New Features
dorian loopworkflow with preflight, prompt, and install commands.Documentation
Bug Fixes