Burin compass: steer toward AST edits
Harn ships a moat-quality set of AST-precise edit primitives — see
the structured refactorings cookbook.
They only pay off, though, if the agent loop reaches for them first.
Left to its own devices a model takes the path of least resistance — a
freeform str_replace / line patch — which is exactly the brittle,
string-collision failure mode the structural tools exist to eliminate.
The burin compass inverts that default. It is a built-in
system-reminder provider, compass_ast_edits,
that injects a standing reminder at session start (and on resume):
When editing source files, prefer the AST-precise edit tools over freeform text edits:
edit_apply_node/edit_insert_at_anchorfor node-level changes,edit_rename_symbolfor safe cross-file renames, and the structured refactors (edit_extract_function,edit_change_signature,edit_inline,edit_move_decl, …) for compound changes. Preview any plan withedit_dry_runbefore committing. Reach foredit_safe_text_patchonly when the language has no grammar support.
The compass ships opt-in. Unlike the other canonical providers —
which are conditional and stay silent until they have something to say
(project facts, a workspace anchor, token pressure) — a steer toward code
edits is only wanted in code-editing sessions, not in every sub-agent or
one-shot loop. So coding-agent personas and configs turn it on
explicitly; once enabled it reaches every agent surface that runs the
Harn agent loop — TUI, IDE, cloud-supervised. The reminder is marked
preserve_on_compact, so the guidance survives a context compaction and
keeps steering through a long session.
The active routing layer
The reminder is the steer; the tool-rewrite router is the active half of the compass (#2612). It is a per-tool-call hook in the agent loop that observes a freeform edit before it runs and acts on it. It sits at the single chokepoint every agent surface funnels through — after permission and pre-tool hooks, before the tool is dispatched — so it reaches the TUI, the IDE, and cloud-supervised loops with no per-surface wiring.
It recognises a freeform / whole-file edit on a parseable source file:
- a
str_replace-shape call ({path, old_text, new_text}or ahunksarray), - a whole-file
write_file/create_file, - a single-hunk edit on a rename-capable language that reads like a symbol rename.
For anything else — a structural call, a file with no tree-sitter grammar, an arg shape it doesn't understand — the router is inert and the call dispatches unchanged. It is conservative by construction: it never touches a call it cannot reason about.
It runs in one of two modes:
suggest(the default) — advisory. The router injects a one-turn system reminder naming the structural primitive the edit maps to (edit_apply_nodefor a node,edit_rename_symbolfor a rename, or the hash-guardededit_safe_text_patch), then dispatches the original call unchanged. The model stays in control; nothing is rewritten.rewrite— silent substitution, but only when the structural form is provably equivalent to the freeform call. The one substitution the router can prove without reading the file is a raw text replace →edit_safe_text_patch: identicalold_text → new_textmatcher, but with a stale-base hash guard and staged-fs atomicity. A rename or a whole-file write is not byte-equivalent (a project-wide rename touches other files; a whole-file write rewrites untouched bytes), so the router falls back to a suggestion rather than guess.
Observability
Every decision increments a harn.compass.* counter via the standard
counter(...) instrument, tagged with harn.compass.persona,
harn.compass.tool (the freeform tool), and harn.compass.target (the
structural tool):
harn.compass.suggested— an advisory routing decision fired.harn.compass.rewritten— a call was silently substituted.harn.compass.fell_back—rewritemode considered a substitution but could not prove equivalence, so the original freeform call ran.
The router also emits a live compass_routing_decision agent event before
dispatch. ACP surfaces it on _harn/agentEvent with toolCallId, mode,
action (suggested, rewritten, or fell_back), persona,
originalTool, routedTool, targetTool, and path when the edit call
named a file. The event carries routing metadata only; it does not include
old or new file contents.
These surface in eval dashboards as the agent-loop edit-reliability signal.
Why a reminder by default, not a hard rewrite
In suggest mode the compass steers rather than rewrites. A reminder
keeps the model in control: it can still choose a freeform patch when a
file has no tree-sitter grammar (the structural tools degrade to
Unsupported there anyway), and it never silently changes the bytes a
tool call would produce. That makes the behaviour predictable and
auditable — the reminder is visible in the transcript like any other
system reminder. rewrite mode is opt-in for exactly this reason: it only
ever substitutes a provably-equivalent call.
Turning it on
The compass is a normal reminder provider, so the standard controls apply:
- Enable it for a session or persona via the reminder config:
reminders.providers.compass_ast_edits = true. It is registered as a canonical provider but shipsdefault_enabled: false, so this opt-in is what activates the steer. - Inspect it alongside the other canonical providers —
compass_ast_editsappears in the provider metadata listing with the summary "Steer the agent toward AST edit primitives over freeform text edits."
Configuring the router (escape hatches)
The router is on by default in suggest mode for every agent loop. It is
controlled by the compass option passed alongside the agent-loop tools
and is fully gated:
compass: false— turn the router off entirely. Freeform edits dispatch with no observation, no reminder, no counter.compass: {enabled: false}orcompass: {mode: "off"}— equivalent off switch in dict form.compass: {mode: "suggest"}— the default; advisory reminders only.compass: {mode: "rewrite"}— silent substitution of provably-equivalent calls, with a fall-back-to-suggest safety net.compass: {prefer: ["edit_apply_node", ...]}— a persona's ordering hint. The router consumes theedit_strategy.preferlist a persona already declares (e.g.personas/fixer/manifest.harn), so a persona that prefers node-level editing nudges a plain hunk edit towardedit_apply_nodein its suggestion. When nocompass.preferoverride is set the router readsedit_strategy.preferdirectly.
The escape hatch the model always has: ignore the suggestion. A
suggest reminder never blocks or alters the call, and even a rewrite
only ever swaps in a behaviourally identical patch — so a deliberate
freeform edit on an unsupported file, or a one-off raw write, is always
available.
Composes with
- Structured refactorings cookbook — the tools the compass points at.
- Rename a symbol cookbook — the cross-file rename the compass calls out by name.
- System reminders — the delivery mechanism.