A Field Guide

Phronesis &
the rules
that do not fade.

Notes on a small RETE rules engine, the MCP server that hosts it, and the hook surfaces we use to keep project guidance alive across a long working session — including the durable journal that lets rules condition on what the agent has been doing, and the per-toolchain adapter that grounds a commit on build, tests, and known-bug outcomes rather than syntax alone.

On the name — phronesis, practical wisdom.

Phronesis (φρόνησις) is Aristotle's term for practical wisdom — the judgment an experienced practitioner brings to a concrete situation when the general principle does not quite reach. It is not theoretical knowledge, and it is not technique. It is knowing what to do here, now, in this particular case.

The work this engine does has that shape. A rule that says "don't use .unwrap() in src/" is not a theorem. It is a situated judgment: in this codebase, under these constraints, given what we learned the hard way, do this rather than that. What the engine persists across compression boundaries is exactly that kind of small, hard-won, project-specific maxim — a particular team's practical wisdom about a particular system, kept durable across sessions in which context windows fill, conversations get summarized, and the guidance you most need fades fastest.

§01
1

The premise — guidance fades, work continues.

Claude Code, OpenAI Codex, Gemini CLI, and the rest of the LLM-assisted development family share one startup pattern: load whatever project guidance the user has set down — CLAUDE.md, AGENTS.md, a system primer — and place it in the model's context window. Then the session begins. The window fills with code, command output, and conversation. Older content is summarized by auto-compaction. Even content that remains literally in context draws less attention from the model as new material accumulates ahead of it. The directive you most need at hour three was last read carefully somewhere around token eight hundred.

This is not a bug. Context windows are finite, and compaction is the price of long-running sessions. But it means that any project convention encoded only in prose — any instruction that lives solely in the loaded markdown — has a practical half-life. The deflective phrasing you warned the model against in turn one returns in turn ninety. The workspace-flags rule you established on Monday is no longer reliably surfaced on Thursday afternoon. The shape of the project — the small, hard-won particulars one is meant to honor — fades, whether by explicit summarization or by the gentler erosion of attention.

Phronesis moves enforcement out of the conversation entirely. Rules live on disk in .phronesis/rules.json. Lightweight hooks re-read them at every relevant tool call. They fire from outside the context window — and so cannot be compressed away, because they were never loaded into context to begin with. They fire the same in token nine hundred thousand as they do in token eight hundred.

Fig. 1 · Persistence of guidance, by state x: tokens of session · y: signal level
MAX MID · 0 50k 120k 200k A · CLAUDE.MD IN CONTEXT compression begins B · RULES ON DISK each mark · one hook fire · one rule check One mechanism degrades; the other does not.
§02
2

What it is — a small engine, hosted as a hook.

Phronesis is a RETE rules engine. RETE — developed by Charles Forgy at Carnegie Mellon in the late 1970s and published in its canonical 1982 paper Rete: A Fast Algorithm for the Many Pattern / Many Object Pattern Match Problem — has lived inside production-rule systems ever since: expert systems, business-rules platforms, event-correlation engines, the JBoss Drools framework one of the authors used in earlier participatory-modeling work, and any number of others.

The algorithm is straightforward. Facts enter a network. Conditions match against them in a compiled discrimination graph. Consequences fire when every condition of a rule is satisfied. Think of a card-game referee: each card laid on the table is a fact; the rules of the game are the conditions; the referee speaks up only when something is out of bounds — a card from the wrong suit, a play out of turn. That is the engine's whole job.

Our use of the algorithm is not especially novel. The new thing is the purpose. We use RETE to lint LLM tool calls.

Phr-MCP is the wrapper around the engine: a single Rust binary that exposes it three ways. It runs as an MCP server over stdio, for any MCP-capable client. It runs as a set of CLI subcommands that bind directly to the Claude Code, Codex, and Gemini CLI hook protocols. And it runs as a whole-tree audit tool. The hooks are where the durability property comes from. They are invoked by the host (Claude Code, Codex, Gemini CLI) at well-defined moments — before a tool fires, after it applies, when a session opens, when the user submits a prompt — and they re-read the rules file from disk on every invocation.

That last property is the structural point. The hook does not cache anything between invocations. It does not depend on the model's memory or on any state retained in the conversation. It opens a file, parses it, runs the network against the facts extracted from the current tool call, prints a result, and exits. Whatever the model has forgotten, the hook still has access to — because the hook never knew anything to begin with, and reads the rules fresh from disk every time it is asked to fire.

Fig. 2 · A tool call, end-to-end edit · bash · write  →  hook  →  rete  →  exit code
step 1 Tool call Edit · Bash Write · … step 2 · hook pre-check post-check (stdin: payload) .phronesis/ rules.json step 3 · engine RETE network extracted facts diff · ast · path · cmd step 4 · decision exit 0 allow exit 1 warn (post) exit 2 block (pre) the host (Claude Code · Codex · Gemini CLI) honors the decision · the hook never modifies source itself
Fig. 3 · Inside the RETE network working memory → alpha → beta → P-state → consequence
Facts flow through Alpha memories, shared Beta joins, and a production state before emitting a consequence.

We have kept the picture deliberately small. There is no daemon, no socket, no shared state between invocations of any kind. Every hook fire is a fresh process that reads the rules from disk, evaluates them against the current tool call's facts, and exits. The persistence we care about comes from the file system, not from anything the engine itself is asked to remember.

A note on pure-script rules. One engine refinement is worth naming, because it is what makes the journey-rule shapes in §5 expressible at all. In earlier versions, the alpha-network was built only from rules that had at least one ordinary leaf condition; rules whose when consisted entirely of __script__ clauses (the small expression DSL that handles facts_count(…) >= N and facts_count(…) == 0) were silently skipped at alpha-state creation and never reached the agenda. As of phr 0.13.0, update_agenda walks the production set and emits activations for pure-script rules with empty bindings; the script evaluator then runs against whatever facts have been asserted. Mixed rules — one or more leaf conditions plus a script clause — are unaffected; they ride the incremental alpha/beta path as before. The visible consequence is small but load-bearing: the headline auth-churn-without-tests rule (count and absence, both expressed as script) is now a rule the engine can actually fire.

A rule is a small refusal the project keeps making, even after it has forgotten the conversation in which it was first imagined.
§03
3

Anatomy of a rule — JSON, conditions, consequence.

Rules are a small DSL, and JSON is its surface syntax. We considered a dedicated grammar — Drools DRL did this years ago and the expressivity is genuinely attractive — but landed on JSON instead. The reasoning was practical: anyone using phronesis is already writing in Rust, Python, TypeScript, or Swift, and we did not want to add a parser to the learning curve. JSON brings syntax highlighting in every editor and a familiar shape; the rest is five fields per rule — an identifier, a phase, an integer priority, a when array, and a then action.

The DSL lives in the predicate vocabulary, not in the grammar. Each entry in the when array is a single-key object whose key names a predicate and whose value is its argument; predicates are evaluated against facts extracted from the tool call. When every entry matches, the then clause fires.

In practice, then is a single-key object mapping a verb to a message: "block" refuses the tool call at pre-phase, "warn" emits an advisory, "log" records silently. The message string is what the model sees — in the hook's stderr output and in the session-start summary — which is how a rule's authored intent gets communicated back to the model that triggered it.

Fig. 4 · A single rule, dissected enforce-no-unwrap-in-src
RULE · JSON "id": "enforce-no-unwrap-in-src", "phase": "pre", "priority": 10, "audit": true, "when": [ { "new_content_contains": ".unwrap()" }, { "file_path_matches": "src" } ], "then": { "block": "Avoid .unwrap()…" } stable identifier used in logs, audit reports, hook output phase pre · blocks · before edit applies post · warns · after edit applies audit · silent at hook · sweep only when predicate-as-key objects. ALL must match for the rule to fire. then verb → message. block · warn · log + the human-readable message. A rule is one JSON object · when/then · the rules file is an array of these.
predicate
A named fact-matcher provided by the host. Examples: new_content_contains (substring match against the diff), file_extension_is (gate), function_returns_result_string (AST), function_param_count_high (AST), cargo_command_lacks_workspace (Bash content), file_line_count_above (audit-only gate).
fact
A tuple produced from the tool call payload: the file path, the added text, the removed text, the AST-derived shapes, the Bash command string. Facts are inserted into the RETE network; conditions unify against them.
phase
When a rule runs. pre rules block (exit 2); post rules warn (exit 1); audit rules are silent at hook time and only fire during phr-mcp audit.
§04
4

Four surfaces — enforce, sweep, re-inject, detect drift.

The same rule format serves four distinct moments in the development cycle — each with a different cadence, a different audience, and a different mode of action. Together they cover the life of a piece of project guidance: moment-of-action enforcement at the hook, periodic debt review across the whole tree, session-wide prose steering through the durable-directive file, and heuristic drift detection that surfaces the gap between what a team has decided and what is actually enforced. No single surface is adequate on its own; the four in combination close the gap that motivated the project.

Hook-time

block · warn · allow

Pre-check rules fire before an Edit, Write, or Bash call applies its changes. A failing condition exits 2, which the host honors by refusing the call. Post-check rules fire after the edit has been applied; they exit 1 to warn but cannot reverse what has already been done to the tree.

  • phase: pre — blocks the call
  • phase: post — warns on the call
  • fires once per tool invocation

Audit

debt · sweep · trend

A whole-tree scan against every rule tagged audit: true, producing per-rule hit counts with file and line states. Each audit run also appends a snapshot to the action log, so that phr-mcp trend can report whether the pile is shrinking from one week to the next.

  • phase: audit — never at hook time
  • opt-in via audit: true
  • writes a trend snapshot per run

Durable directives

prose · re-inject

A small markdown file at .phronesis/durable.md. Its contents are re-injected into the model's context at every SessionStart and every UserPromptSubmit. CLAUDE.md fades; this does not. Reserved for the few directives that absolutely must survive context compression.

  • injected by session-context
  • injected by interaction-context
  • prose, not enforced — re-read every turn

Drift detection

gap · triage · suggest

One consolidated drift tool compares what the project says (in CLAUDE.md, in auto-memory, in ADR-style decision pages) and what the code still defines (via rule-to-code bindings) against what the rule pack actually enforces. Uncovered items surface as triage candidates — gaps that should either become rules or be explicitly marked as non-lintable by design. Guidance sources use Jaccard token overlap, no LLM call.

  • drift --source claude_md — CLAUDE.md bullets
  • drift --source memory — auto-memory entries
  • drift --source wiki — ADR decision pages
  • drift --source code — stale rule-to-code bindings
  • --suggest emits draft rule JSON
⁘ ⁘ ⁘
§05
5

Journey facts — what the trajectory remembers.

The hook surface described so far is point-in-time: a predicate looks at the current diff, the current file's AST, the wall clock. That is enough to catch don't write .unwrap() in src/. It is not enough to catch the patterns that actually hurt long agent runs, which are temporal and cross-call — you have edited the auth module three times this session and never touched its tests, or a destructive SQL command ran in the last five tool calls, or nothing has been built since the public API changed. The first kind of predicate watches a single tool call. The second kind watches a trajectory.

The temptation, when you want to watch a trajectory, is to let the network accumulate facts across invocations. Phronesis refuses that move. A working memory that grows across calls reintroduces the exact state-and-drift problem the project exists to kill — its firing would diverge from invocation to invocation, exactly the property the file-system-resident rules are designed to avoid. Journey facts are therefore never accumulated in the network. They are recomputed every invocation, from a durable append-only journal at .phronesis/journey/events.jsonl, over a bounded window of recent records. State lives on disk; decay is the sliding window; firing stays identical at token nine hundred thousand as at token eight. The hook is, and stays, stateless — and yet it can answer questions about what the agent did three calls ago.

That single decision answers four otherwise-difficult questions at once. Where does state live — on disk, in a versioned event log, not in RAM. When does a journey fact stop being true — never explicitly; the next invocation recomputes from scratch and the fact is either present or absent for that window. Is firing deterministic — yes, given the journal bytes and the invocation timestamp, the asserted facts are byte-identical across runs. How does it not blow up at repo scale — each call reads only a bounded suffix of the journal, sized to the largest window any loaded rule references.

The four pieces.

The journey layer is small. A journal (journey/journal.rs) appends one compact record per executed tool call — tool, path, extension, resolved module, tags, an optional subject — with the same flock-serialized write discipline the action log uses. A tagger (journey/tagger.rs) stamps each record with project-defined tags by reusing the ordinary predicate engine: journey.json declares taggers whose when clauses look structurally identical to a rule's, and whose effect is attach this tag to this record instead of block or warn. Derivation (journey/derive.rs) runs at the head of every pre- and post-check, scans the loaded rules for journey_* conditions, reads exactly enough of the journal tail to cover the windows those rules reference, and asserts the matching aggregator facts into the otherwise-fresh network. A fourth piece — a checkpoint for repo-lifetime counters — is deferred to phase 2; today's windows are bounded in calls, time, or session.

The aggregator family is five predicates, fixed and project-neutral. journey_occurrence emits one fact per matching record so the existing facts_count(…) >= N and facts_count(…) == 0 DSL handles both threshold and absence. journey_count exposes the count as a single bindable fact for reporting. journey_seen is a plain boolean — presence-of-at-least-one over a window. journey_since_ge emits one fact for each k up to the distance since the last matching record, so has it been at least eight calls since the last build is a count threshold. journey_distinct counts unique values of a field — distinct paths edited in a window, for instance. The selector and the window are arguments; the project supplies the tag names; the aggregators stay neutral.

The rules these enable are the point of the exercise. You have edited the auth module three times this session is one count threshold. And you have not touched its tests is the same predicate against a different tag, in the same when, joined by ordinary conjunction — composite journey rules are simply rules with more than one journey condition. A SQL/migration edit happened in the last five tool calls is a single journey_seen. Eight calls since the last build is a journey_since_ge. The full JSON for each of these lives in the catalogue; the point here is that the trajectory has become a first-class subject for rules to talk about. (See the catalogue for rule bodies and the headline four.)

Selector validation — the silent-typo guard. One small but important property: a rule that references ['tests','s'] when the project's journey.json defines the tag as test (singular) would otherwise count zero matching records, satisfy any absence clause, and fire on every invocation — exactly the wrong default for an absence-as-zero-count regime. Derivation therefore walks the loaded rules at load time, collects every tag and module selector referenced under a journey_* condition, and rejects the rule if the selector does not appear in journey.json's taggers or modules. A typo is a load-time error with a named selector, not a rule that silently always-fires.

§06
6

Confidence scoring — on the slow clock.

A syntactic predicate can ask does this code look like it compiles? It cannot ask did the build succeed? The distinction matters when the rule is there to ground a claim of done. Trace the call chain end-to-end reads well in a warning string. The tests passed and the known-bug test went from red to green reads as evidence. Confidence scoring is the subsystem that watches for evidence and refuses to let a weak claim reach a commit.

It helps to think of two clocks. Edits, diffs, ASTs, regex-over-content — the ordinary point-in-time predicates — are the fast clock: they tick on every keystroke and every tool call. Compiles and test runs are the slow clock: they happen when the agent actually invokes the toolchain, and they produce real output that says something true. The confidence subsystem listens to the slow clock. It does not try to predict whether a build will pass; it watches the build run, parses its output, and ledgers what actually happened.

Three grounded signals are enough to ground a claim. compile_ok — the build adapter parsed a successful cargo build or cargo check. test_pass — the test adapter parsed a green cargo test with no failures. bug_caught — a test named in .phronesis/bugs.json as red on the buggy baseline went red-to-green under the current change, with no new regressions alongside. Three signals out of three is high confidence. Two of three is medium. One or zero is low. The bands are discrete on purpose: the rule language counts facts, it does not do arithmetic; the band is what a rule can match against.

The gate fires before a commit, not after it. Phronesis installs two default rules from the confidence pack: one pre-check that blocks the commit when fewer than two signals have passed, one that warns at exactly two. Three passes silently — the commit proceeds, the work is presented as done with grounded evidence behind it. The rules live in rules.json; the band is read from signal_pass facts derived from the open work unit's slow-clock history; the known-bug registry lives in .phronesis/bugs.json, tracked in git as project knowledge. (See the catalogue for the gate-rule JSON.)

The 0.13.0 fold-in.

The confidence subsystem first shipped in 0.12.0 with its own per-subject ledger at .phronesis/outcomes/<subject>.jsonl — a small append-only log with its own flock discipline, dedicated to outcome events. In 0.13.0 it folded into the journey journal. The cargo adapter now stamps the journal record with a subject field and a set of outcome:* tags (outcome:compile_ok, outcome:test_pass, outcome:bug_caught) instead of writing to a parallel ledger. Per-subject reads become a tail-read of the journal filtered by subject — cheap, because outcome traffic is sparse compared to edit traffic. The result is one storage layer, one flock-serialized append path, and one set of invariants to reason about. Two storage surfaces collapsed into one.

An honest limitation. A playtest of the gate against real merge-night traffic surfaced a hole worth naming. The two confidence rules currently trigger on bash_command_matches: "git commit" — the literal substring. That catches the headline case of an agent typing git commit -m "…", but five of six commit-producing porcelain commands bypass it: git merge, git rebase, git cherry-pick, git revert, and the default-merge form of git pull all produce commits without containing the literal git commit. The invariant the rules mean to enforce — no commit reaches main without grounded validation — is honored for one shape and quietly violated for the rest. The follow-up (SPEC-gate-merge-commits) widens the pattern to a regex over the full set of commit-producing verbs. A first-class produces_commit predicate would be cleaner still, and is the long-term shape; the regex closes the immediate hole.

⁘ ⁘ ⁘
§07
7

How it slots in — one binary, one rules file.

phr-mcp init writes a small set of files into a project to wire phronesis into its host environment. Hook configuration lands in each host's settings file — .claude/settings.local.json for Claude Code, .gemini/settings.json for Gemini CLI (which also carries Gemini's MCP server registration). A separate .mcp.json registers the server for Claude Code and any other MCP-capable client. Codex uses .codex/hooks.json plus .codex/config.toml; changed hooks remain subject to Codex's explicit /hooks trust review. The starter rules pack lives at .phronesis/rules.json, and entries are appended to .gitignore so phronesis's log and backup files are not committed by accident. A wiki scaffold at .phronesis/wiki/decisions/ is created for ADR-style decision pages — these are carved back out of the gitignore so they travel with the repo.

Re-running is idempotent. Existing configuration is preserved; only the entries owned by phronesis are added or refreshed. You can run init against an established project without fear of clobbering other tooling already present.

.claude/settings.local.json

Claude Code hooks

PreToolUse and PostToolUse entries point at phr-mcp pre-check and post-check. SessionStart and UserPromptSubmit entries handle context injection.

.codex/hooks.json · config.toml

OpenAI Codex hooks

codex-hook governs Bash and apply_patch, including multi-file change sets, and reinjects context across prompts, compaction, completion, and subagent lifecycle events.

.gemini/settings.json

Gemini CLI hooks

BeforeTool and AfterTool entries provide the equivalent pre/post enforcement. BeforeAgent is Gemini's name for the per-turn context injection point.

.mcp.json

MCP server registration

Exposes mcp__phronesis__* tools — mutate rules and facts, audit the codebase, query graph state, inspect freshness, and rebuild derived graph data — to any MCP-capable client over stdio. List and log responses use named JSON object envelopes for SDK compatibility.

.phronesis/predicates/*.rhai

Project predicate providers

Sandboxed scripts derive project-specific LHS facts from normalized events. Batch files context precedes per-file file_path evaluation.

.phronesis/rules.json

The rules pack

A JSON array. Composable starter packs ship with the binary: llm for behavior, rust for code shape, plus python, typescript, swift. Edit in place; rules are re-read from disk on every hook.

# a typical bootstrap
$ cargo install --path crates/phronesis-mcp
$ phr-mcp install              # user-scope MCP registration
$ cd ~/Git/my-project
$ phr-mcp init --packs llm,rust # project-scope wiring + starter rules
$ phr-mcp audit                # sweep existing debt
$ phr-mcp trend                # debt over time
$ phr-mcp drift                # guidance/rule gaps across all sources
$ phr-mcp drift --source wiki  # narrow to ADR decisions
$ phr-mcp graph status --json  # inspect structural freshness
$ phr-mcp graph rebuild --json # rebuild graph + reconcile bindings
$ phr-mcp decision new my-slug # scaffold a new ADR page
§08
8

A closing note — on what rules cannot do.

A word on what phronesis does not claim. It persists the prompt-to-think; it does not persist the thinking. A rule that warns trace the call chain end-to-end before claiming done will fire reliably on every git commit -m, and the model — or the human at the keyboard — will see the warning. Whether the operator then traces the chain remains the operator's choice. The rule cannot make anyone do the work; it can only ensure the question is asked again, long after the conversation in which it was first imagined has been compressed away.

What moving enforcement to disk buys you is durability — a fixed set of refusals and reminders that fires the same in hour one and hour ten, in token eight hundred and token nine hundred thousand. That is a smaller claim than is now fashionable to make about software of this kind. In our experience, it is also a useful one. The small project-specific maxims you would otherwise have to repeat across sessions are simply there, on disk, doing their work whether or not the conversation remembers them.

The rule fires the same in token nine hundred thousand as in token eight hundred.

References

Forgy, C. L. (1982). Rete: A Fast Algorithm for the Many Pattern / Many Object Pattern Match Problem. Artificial Intelligence, 19(1), 17–37.

Waterman, A. & García Barrios, L. (2009). Playing with the Rules: Participatory Modeling and Network Gaming through a Rules Engine. Proceedings of RulesFest 2009. El Colegio de la Frontera Sur, San Cristóbal de Las Casas, México.

García-Barrios, L., García-Barrios, R., Waterman, A. & Cruz-Morales, J. (2011). Social dilemmas and individual/group posination strategies in a complex rural land-use game. International Journal of the Commons, 5(2). DOI: 10.18352/ijc.289.

Aristotle. Nicomachean Ethics, Book VI. On φρόνησις (phronesis) as practical wisdom.