Phronesis for Loop-Based Agent Programming
A guide to governing the iterative agentic loop so it doesn’t drift.
This guide is for people running Claude Code, OpenAI Codex, Gemini
CLI, or another hook-capable LLM agent in a long, iterative loop — the
propose → act → observe → propose again cycle that drives real
coding work — and who want that loop to stay on the rails from the first
turn to the thousandth. For the conceptual grounding, see the documentation site —
the explainer essay and the rule catalogue live there. It assumes you’ve
read the README and have phr-mcp
installed. For the full CLI surface, see the Command Reference.
The problem: the loop forgets
The agentic loop is simple to describe and hard to keep honest:
┌── propose ──► tool runs ──► observe ──┐
│ (Edit, Bash…) │
│ │
└────────── next turn ◄─────────────────┘
Every turn appends to the context window: the diff you wrote, the
compiler output, the test log, the conversation. Your project guidance —
CLAUDE.md, the architectural decisions, the “always run the
build before claiming done” rule — is read carefully at the
start of the loop and then steadily buried. By iteration two
hundred, the directive you most need was last seen clearly hundreds of
thousands of tokens ago, and auto-compaction may have dropped it
entirely.
This is contextual drift, and it is structural: the
longer and more productive the loop, the worse it gets. You cannot fix
it by writing a better CLAUDE.md, because the problem is
not what the guidance says — it’s that the guidance lives
inside the very context window the loop is filling up.
Phronesis fixes it by moving enforcement out of the
conversation entirely. Rules live on disk in
.phronesis/rules.json, are re-read by lightweight hooks at
every single tool call, and fire from outside the context
window. They cannot be compressed away, because they were never loaded
into context to begin with. A rule fires the same in token nine hundred
thousand as it does in token eight hundred.
Three layers of loop governance
Phronesis governs the loop at three increasingly powerful layers. Most projects start at layer 1 and add the others as the loop gets longer.
| Layer | What it sees | What it catches |
|---|---|---|
| 1. Per-iteration | This one tool call | “Don’t write .unwrap() in src/” —
syntactic violations on the current edit |
| 2. Trajectory | The accumulated journey across iterations | “You’ve edited auth 3× this session and never ran the
tests” — temporal patterns |
| 3. Honest closure | Grounded build/test/bug signals | “Don’t commit — the build is red and you said it was done” — gated completion |
Layer 1 — per-iteration enforcement (the inner loop)
Two hooks wrap each turn of the loop:
pre-checkruns before a tool call lands. If a rule withphase: "pre"matches, the adapter returns the host’s blocking decision — the agent sees the message and adjusts before any damage is done.post-checkruns after the action. Aphase: "post"rule exits 1 and warns — advisory, the action already happened.
Each hook is a fresh, stateless process that reads the rules from disk, asserts facts about this one call, fires, and exits. That statelessness is the whole point: there is no in-memory state to drift, so iteration 900 is checked exactly as rigorously as iteration 1.
This is the layer the starter packs target.
phr-mcp init --packs rust gives you a working set
immediately: the rust pack blocks .unwrap() /
panic!() / todo!() in src/,
Result<_, String> returns, and more; the
llm pack blocks blame-shifting and unverified
completion claims, and warns on bare git commit -m
to nudge end-to-end verification. (See the Command Reference for the
full pack contents.)
Layer 1 is extensible without recompiling Phronesis. Sandboxed Rhai
providers under .phronesis/predicates/*.rhai receive a
normalized event and call
emit_fact(predicate, args) to add project vocabulary before
RETE matching. For a multi-file Codex apply_patch,
providers first receive the complete event.files change set
and then one event.file_path view per file. Use the MCP’s
test_predicate_provider before installing a provider. This
repository’s change_set.rhai provider demonstrates the
pattern by classifying production Rust paths, test paths, and
production-only change sets.
Layer 2 — trajectory awareness (across iterations)
Layer 1 only sees the current edit. But the patterns that actually wreck long loops are temporal and cross-file:
- You’ve edited the auth module three times this session but never touched its tests.
- You ran a destructive migration command in the last five tool calls.
- You changed the public API fifteen edits ago and still haven’t rebuilt.
These are invisible to a point-in-time rule. Journey
facts make them first-class. Phronesis keeps an append-only
journal of every executed tool call under
.phronesis/journey/, and on every hook invocation it
recomputes aggregate journey_* facts from a
bounded suffix of that journal and asserts them into the same network
your syntactic rules use.
The crucial property: journey facts are never accumulated in
memory. They are recomputed from the durable journal every
call, over the window your rules actually ask for. So they survive
compaction, stay deterministic, and “decay” for free — a
journey_occurrence count of 3 holds while the window covers
three auth edits, and quietly drops to 2 once the window slides past one
of them. (Full design: SPEC-journey-facts.)
Because a pre-check runs before the current
call is journaled, a journey rule can block the current action
based on the trajectory that led to it — “have you done X
before” (journey) cleanly separated from “are you doing X now” (the
diff). That is the headline capability for loop programming.
Layer 3 — honest closure (ending the loop)
A loop’s most dangerous moment is when it decides it’s done.
The model is optimistic; the build may be red. The
confidence pack gates git commit on
grounded signals — actual build, test, and known-bug
outcomes read through declarative toolchain definitions (built-in Cargo
plus project definitions in .phronesis/toolchains.json) and
one generic parser — not on syntactic proxies:
- low confidence → blocks the commit
- medium → warns
- high → passes clean
Pair it with the llm pack’s “unverified completion
claim” rule and you have a loop that physically cannot declare victory
before the work compiles and passes. The default platform writes
.phronesis/confidence.json; inspect the current band with
phr-mcp confidence. (Full design: SPEC-confidence-scoring.)
Setup
# 1. Install the binary (once per machine)
cargo install --path crates/phronesis-mcp
# 2. Register the MCP server at user scope (once per machine)
phr-mcp install
# 3. Initialize the default platform plus the relevant language pack
cd /your/project
phr-mcp init --packs rustJourney support and the other language-agnostic subsystems are
enabled by default. init is idempotent and only adds its
own entries — existing permissions, hooks, and gitignore lines are
preserved. After running it, restart the agent host so it picks up the
hooks and MCP server. Codex users must also review changed project hooks
with /hooks; Phronesis does not bypass that trust
boundary.
You can sanity-check the install at any time:
$ phr-mcp --version
phr-mcp 0.26.0Everything in the rest of this guide is drawn from the actual state
of this repository at the time of writing — the configs, rules, stats,
and confidence band shown below are what phr-mcp reports on
this machine, not synthetic examples. The point is that you can run the
same commands against your own project and see equivalent output.
Defining your loop’s risk surface
Journey rules don’t match on hardcoded concepts like “sql” or “auth”
— the engine is domain-neutral. Instead, you define your loop’s
risk surface in .phronesis/journey.json, reusing the same
predicate vocabulary the syntactic rules already use. A
tagger is a mini-rule whose effect is “stamp this tag
on the journal record” instead of “block.”
The minimum useful surface is one tag. Here is the actual
journey.json from this repository — it is exactly what the
journey pack ships:
{
"version": 1,
"taggers": [
{ "tag": "build", "when": [ { "bash_command_matches": "cargo (build|check|test)" } ] }
],
"modules": []
}That single tag is enough to power the build-staleness rule in §5, which on this repository fired 125 times in the last 7 days — by far the most active warning. Most loop pathologies you’ll hit early are temporal (you forgot to rebuild) rather than cross-module (you touched auth without tests). Start there.
Once a tag earns its keep, grow the surface. Plausible next additions for a Rust project look like this:
{
"taggers": [
{ "tag": "build", "when": [ { "bash_command_matches": "cargo (build|check|test)" } ] },
{ "tag": "tests", "when": [ { "file_path_matches": "tests/" } ] },
{ "tag": "sql", "when": [ { "or": [
{ "new_content_contains": "INSERT INTO" },
{ "new_content_contains": "DELETE FROM" },
{ "file_path_matches": "migrations/" } ] } ] }
],
"modules": [
{ "name": "engine", "paths": [ "crates/phronesis-mcp/src/engine/**" ] }
]
}Pick the handful of surfaces where churn or absence actually hurts in your loop: the module you keep re-touching, the test directory you keep forgetting, the destructive operations. Adding a tag costs nothing until a rule references it — the derivation pass only computes aggregates the loaded rules consume.
Selector validation is your typo guard. A rule that references a tag your
journey.jsondoesn’t define is rejected — the next pre-check exits non-zero withphronesis: BLOCKED — rule \` references undefined selector ` ` — not in journey.json taggers or modules , naming both the offending rule and the missing tag. This is safe by default: the dangerous case (absence rules of the form== 0`) would otherwise fire constantly when the tag is missing, so rejecting at config time keeps that failure mode out of the loop entirely. Wire the tagger up first, then add the rule that depends on it.
Writing loop-aware rules
Journey rules live in .phronesis/rules.json alongside
your syntactic rules and use the same v2
when/then shape. The derivation pass scans
your loaded rules for journey_* conditions and computes
exactly those aggregates and nothing more — you never pay to
derive a fact no rule consumes.
The aggregator family:
| Predicate | Args | Use for |
|---|---|---|
journey_occurrence |
[selector, window] |
counting (one fact per match) — feeds
facts_count(...) >= N and == 0 |
journey_seen |
[selector, window] |
presence (≥1) — a plain boolean |
journey_since_ge |
[selector, k] |
distance since last occurrence ≥ k |
journey_count |
[selector, window, count] |
the count as a bindable value |
journey_distinct |
[field, window, count] |
distinct values of a field in a window |
Window tokens: 5c = last 5 calls ·
30m/2h/7d = wall time ·
s = current session.
The headline four
These are the patterns worth starting with — each maps to a
recognizable loop failure mode. Only the fourth (build)
works with the minimum journey.json from §4; the
auth, tests, and sql selectors
require the expanded taggers from the same section.
Auth churn (count over a session):
{
"id": "auth-churn-session",
"phase": "pre",
"priority": 20,
"when": [ { "__script__": "facts_count('journey_occurrence', ['auth','s']) >= 3" } ],
"then": { "warn": "You've edited the auth module 3+ times this session. Does this need test coverage or a review before the next change?" }
}Churn without tests (count + absence — composable through
ordinary when):
{
"id": "auth-churn-without-tests",
"phase": "pre",
"priority": 25,
"when": [
{ "__script__": "facts_count('journey_occurrence', ['auth','s']) >= 3" },
{ "__script__": "facts_count('journey_occurrence', ['tests','s']) == 0" }
],
"then": { "warn": "You've edited auth 3+ times this session without touching its tests. Add or update coverage before continuing." }
}Recent destructive SQL (presence in a call window):
{
"id": "sql-recent-call-window",
"phase": "pre",
"when": [ { "journey_seen": ["sql", "5c"] } ],
"then": { "warn": "A SQL/migration edit happened in the last 5 tool calls — double-check it ran against the right database." }
}Build staleness (distance-since-last — the classic loop pathology):
This is the rule that actually ships in this repository. It’s worth showing in full because the production version is more nuanced than the textbook form above:
{
"id": "build-staleness",
"phase": "pre",
"priority": 5,
"when": [
{ "__script__": "facts_count('journey_since_ge', ['build','8']) >= 1" },
{ "or": [
{ "change_type": "edit" },
{ "change_type": "write" },
{ "change_type": "multiedit" },
{ "change_type": "replace" },
{ "change_type": "write_file" }
] }
],
"then": { "warn": "8+ tool calls since the last build/test. Run the build before reporting done." }
}The trajectory clause (journey_since_ge) is only half
the rule. Without the change_type filter on the current
call, the warning would fire on every Read, Grep, and idle Bash call
after the eighth tool call since the last build — the agent would be
drowning in noise and would learn to ignore it. Restricting to
edit-shaped tool calls means the warning fires when it can act on
it: “you’re about to write more code without rebuilding.”
This is the general shape of a good journey rule:
<trajectory condition>and<the current action this trajectory matters for>.
The trajectory tells you the loop is in a precarious state; the current-call clause tells you this is the call where intervention helps.
Threshold rules fire once per session. Pure-script rules dedupe on rule id, which is the right semantics for “≥3 occurrences” — you get one warning per condition, not one per matching call.
Watching the loop
Phronesis gives you read-only surfaces to see what the loop is doing and tune your rules. The outputs below are real captures from this repository at the time of writing.
phr-mcp stats --since 7d — per-rule
activity over the last week. This is where you discover which rules are
pulling their weight and which are dead code:
Rule Blocked Warned Last fired
build-staleness 0 125 2d ago
warn-rust-function-param-count-high 0 115 1h ago
nudge-verify-before-commit 0 57 1m ago
build-staleness#or0 0 44 1h ago
confidence-medium-warns-commit 0 22 1m ago
warn-cargo-build-without-workspace 0 18 1h ago
confidence-low-blocks-commit 12 0 3m ago
build-staleness#or1 0 5 1h ago
enforce-no-result-string-error 2 0 3d ago
warn-clone-heavy 0 2 3d ago
llm-warn-git-add-all 0 1 3d ago
Total: 14 blocked, 389 warned across 11 rules (window: 1w)
Three things to read from that table:
build-stalenessis the workhorse (125 warns). The single tagger in §4 is doing real work — the loop genuinely does forget to rebuild.confidence-low-blocks-commitactually blocked 12 commits. Layer 3 is not theoretical; it intercepted closure 12 times this week alone — the most recent of those, three minutes before this capture, was the commit that landed this very guide (see §8).- The
#or0..#or4siblings underbuild-stalenessare the per-branch fire counts for theorclause from §5 — useful when debugging which arm of a multi-branch rule is doing the matching.
phr-mcp confidence — current band and
grounded signals. Three real captures from this session, in temporal
order:
$ phr-mcp confidence --json # build was stale; commit attempt would block
{ "band": "low", "signals": ["compile"], "subject": "unit-…" }
$ phr-mcp confidence --json # after cargo check + cargo test refreshed signals
{ "band": "medium", "signals": ["compile", "tests"], "subject": "unit-…" }
$ phr-mcp confidence --json # after the commit settled the unit
{ "subject": null }
The signal isn’t a heuristic vibe — it’s the actual exit status of
the last cargo check and the test summary line from the
last cargo test, captured by the post-check hook and
persisted to .phronesis/outcomes/. A
git commit both settles the open work unit (subject goes
null between units) and opens the next one on the next tool
call.
phr-mcp journey — what journey facts
the engine would assert against the current journal, before any
rule fires:
PREDICATE ARGS RULES
journey_since_ge build | 1 build-staleness#or0..or4
journey_since_ge build | 2 build-staleness#or0..or4
…
journey_since_ge build | 8 build-staleness#or0..or4
This is the “why did this fire” view. Add
--explain <rule-id> to filter to a specific rule’s
dependencies.
phr-mcp audit — whole-tree sweep (the
hook only sees per-call diffs).
phr-mcp audit --fail-on block turns it
into a CI gate.
phr-mcp trend — debt-over-time, diffing
successive audit snapshots:
Rule 2026-06-20 Δ
audit-rust-let-binding-count-high 45 0 ·
audit-rust-let-mut-count-high 29 0 ·
warn-rust-function-param-count-high 16 0 ·
…
get_journey, get_confidence, and the other
MCP tools expose the same views in-conversation, so the agent
can ask “what does my trajectory look like” mid-loop and
self-correct.
Use phr-mcp stats to spot noisy rules (silence them with
silent: true), dead rules (delete them), or to confirm a
tuning change had the effect you wanted. Use phr-mcp trend
after a cleanup sweep to confirm the pile actually shrank.
Participatory governance: the loop helps evolve its own rules
So far the loop has been a subject of governance — rules constrain it from outside. But the same loop is also the cheapest place to discover new rules: it has live evidence of where it slips, where existing rules over-fire, and where guidance lives as prose that nobody can enforce.
Phronesis exposes one consolidated drift surface with source filters:
phr-mcp drift --source claude_md— bullets inCLAUDE.mdthat no current rule covers. Candidates for porting to a rule, or for explicitly marking “non-lintable by design.”phr-mcp drift --source memory— entries in the agent’s auto-memory store that have no matching rule ordurable.mdparagraph. Actionable memories (named commands, named tool calls) should become rules; ambient ones (shared prose) belong indurable.md.phr-mcp drift --source wiki— ADR-style decisions under.phronesis/wiki/decisions/that no rule enforces. Decisions with explicitenforces: [rule-id]frontmatter resolve deterministically; others fall through to a token-overlap fallback.phr-mcp drift --source code— rules whose bound function referents the structural code graph no longer defines. Stale bindings surface here with the original and relocated definition paths, so a rule that outlived its code is reviewed rather than trusted.
A snippet of real output from drift --source wiki on
this repo:
Decision Bucket Match
journey-derivation-scaling uncovered (no match)
no-panic-in-production covered → rule enforce-no-todo-in-src
no-llm-deflection covered → rule enforce-no-pre-existing-issue
borrow-ergonomic-apis covered → rule warn-rust-public-fn-takes-string-ref
…
The “uncovered” rows are the actionable signal: each one is a design decision that lives only as prose, with no enforcement teeth. Some of those should become rules; others are inherently non-lintable and that’s fine — but the list is concrete instead of speculative.
When the loop hits a real moment of friction or insight, it can participate in closing the gap:
- Remember → decide → enforce. When the human says
“remember X” or “make a rule for X”, check the drift tools first, then
scaffold a decision with
phr-mcp decision new <slug>. Fill in Context / Decision / Enforcement / Consequences. If the decision is mechanically enforceable, propose a rule in.phronesis/rules.jsonand wireenforces: [rule-id]into the decision’s frontmatter so the next wiki-source drift run picks it up as covered. - Friction-driven proposals. When a rule blocks the
loop three or more times in the same session for what looks like a
legitimate pattern, pause: either the rule’s scope is too broad (propose
a narrower
file_path_matchesor an exclusion) or the loop is doing something it shouldn’t (adjust the approach, don’t weaken the rule). - Cross-session knowledge transfer. A discovery that warrants permanence — a bug pattern, a rollout lesson — becomes a decision page. ADRs travel with the repo and outlive any one session’s context window.
The bidirectionality is the point: the rules govern the loop and the loop proposes new rules, with the human ratifying. Drift surfaces are how that feedback channel stays in sync.
A worked example: this repository, last week
Pull the trail from §6 together with a concrete narrative. Everything
below is what phr-mcp actually reports about this
repository over the last 7 days — there is no hypothetical.
Layer 1 (per-iteration) kept syntactic discipline
cheap and quiet. Two enforce-no-result-string-error blocks
intercepted Result<_, String> returns before they
landed; one llm-warn-git-add-all nudge caught a
git add -A; a handful of warn-clone-heavy and
warn-dbg-in-src warnings flagged small slips on the way
past.
Layer 2 (trajectory) was, by volume, the dominant
voice in the loop. build-staleness fired 125
times — every time the trajectory accumulated eight tool calls
without a cargo build|check|test, and the current
call was an edit. Each fire was a quiet course-correct: “rebuild before
you write more.” warn-rust-function-param-count-high chimed
in 106 times on the syntactic side, but the temporal warning
was the one preventing late-loop collapse.
Layer 3 (closure) intercepted closure 12 separate
times. confidence-low-blocks-commit blocked
git commit because the build-or-test signal in
.phronesis/outcomes/ was red. The companion
confidence-medium-warns-commit warned 22 more times —
yellow band, “commit if you mean to, but the signal is unstable.” Across
the same window nudge-verify-before-commit fired 57 times
reminding the loop to actually run the verification before reaching for
the commit verb.
The cleanest illustration is the most recent one of those 12 blocks,
three minutes before the stats capture above: the commit that
landed this very guide. The loop tried git commit
with a stale red compile signal in .phronesis/outcomes/,
and confidence-low-blocks-commit refused:
[phr-mcp pre-check]: phronesis: BLOCKED — Low confidence — compile/tests/
known-bug not all green. Run the build and tests and resolve failing signals
before committing.
The agent (running this session) refreshed the signals with
cargo check --workspace and
cargo test -p phronesis-mcp, which stamped
outcome:compile_ok and outcome:test_pass
against the open subject. The band rose to medium, the
block fell back to a warn, and the commit landed:
$ git log --oneline -1
5f5e84c docs(loop-guide): rewrite with real captures from this repo
None of this fired from a CLAUDE.md paragraph that might
have been compacted away. It fired from disk, from outside the context
window, against the recorded exit status of the last
cargo check. The same mechanism would fire identically at
turn 12 or at turn 1,200.
That is the entire premise of the guide, captured live — the guide demonstrates itself.
Inner loop vs. outer loop
“Loop-based programming” has two meanings, and phronesis serves both:
The inner agentic loop — the per-turn propose/act/observe cycle this guide has been describing throughout. This is phronesis’s home turf: per-call hooks, trajectory-aware journey rules, confidence-gated closure.
An outer recurring loop — running a whole task repeatedly on an interval (for example, an unattended job that keeps grinding on a backlog). Phronesis composes cleanly here too: end each iteration with
phr-mcp audit --fail-on blockto gate it, so the recurring loop can’t drift past your rules even when nobody is watching. The deep integration, though, is the inner loop — that is the cycle phronesis exists to keep honest.
Command reference
The commands below cover the operational surface used most often in a
long agent loop. Run phr-mcp <command> --help for
every option.
| Command | Purpose |
|---|---|
phr-mcp init |
Install the default platform and host integrations |
phr-mcp init --packs rust |
Add the Rust language rules to the default platform |
phr-mcp stats |
Summarize rule activity from the append-only action log |
phr-mcp audit --fail-on block |
Scan the tree and fail when blocking debt is present |
phr-mcp trend |
Inspect debt over time from audit snapshots |
phr-mcp confidence |
Show the grounded confidence band for the open work unit |
phr-mcp journey --explain <rule-id> |
Show current trajectory facts and their derivation |
phr-mcp drift |
Compare rules with durable guidance, memory, ADRs, and bound code |
phr-mcp graph status --json |
Inspect structural graph freshness and generation |
phr-mcp graph rebuild --json |
Rebuild the graph and reconcile rule-to-code bindings |
The MCP server exposes the same governance state as structured JSON
objects. Collection tools such as list_rules,
list_facts, get_agenda,
get_consequences, and get_action_log always
wrap arrays in named object envelopes. Structural clients can use
query_code_graph, get_code_graph_status, and
rebuild_code_graph without dropping to a shell. Mutating
rule and fact tools persist or reconcile their related state at the same
boundary.
Further reading
- README — project overview and the premise
- Command Reference — full CLI + pack details
- SPEC-journey-facts — the trajectory layer in depth
- SPEC-confidence-scoring — grounded closure gating
- The Explainer — the RETE engine and design intent
- The Catalogue — visual reference of starter rules