Babashka CLI and Claude Code skill for structural Clojure refactoring — move forms, fix declares, rename namespaces, explore via AST.
  • Clojure 87.5%
  • Shell 8%
  • Python 3.3%
  • Makefile 1%
  • HTML 0.1%
Find a file
forge-anvil affdfe3fd5 E-CEILING80 recorded: all three withdrawals fire; square 2 bounded to N<=40; report + brainfleet §32 + log
Co-Authored-By: Gene Kim <genek@itrevolution.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LB7FLmNFHsGUSaD4w7zoe
2026-09-04 01:38:00 +00:00
.agents/skills feat: reach the local structural microscope optimum 2026-08-06 12:51:47 -07:00
.beads chore: track disjoint edit decision 2026-08-29 18:40:51 -07:00
.claude/skills docs: sync skill mirror for the trip-wire note (mirror check caught unsynced e136ddbf intake) 2026-08-31 21:44:55 -07:00
.codex/visualizations/2026/08/07/019fde10-171c-7aa2-a792-257ba2c92711 Finish typed structural transaction workflow 2026-08-08 08:56:19 -07:00
bench release: prove installed prepared confirmation route 2026-08-30 20:44:55 -07:00
dev ratchet-fix: split-catalog edit_clojure schema authorizes only what its bound handler accepts 2026-09-03 05:15:00 +00:00
docs E-CEILING80 recorded: all three withdrawals fire; square 2 bounded to N<=40; report + brainfleet §32 + log 2026-09-04 01:38:00 +00:00
resources routing plate: restore the MCP-OP-RELAY-004 terminal_response paragraph 2026-09-03 04:41:55 +00:00
skills gene-report skill: always also output the report to chat in full (Gene reads on iOS / Claude Code) 2026-09-03 22:01:15 +00:00
src/clj_surgeon merge bridge/receipt-ratchets (c5ef7ca) — GO, executed adversarial review 2026-09-03 14:44:47 -07:00
test merge bridge/receipt-ratchets (c5ef7ca) — GO, executed adversarial review 2026-09-03 14:44:47 -07:00
test-fixtures feat: advance verified MCP structural transactions 2026-08-23 15:57:15 -07:00
.gitignore gitignore: .cpcache/ (two fix rounds tonight swept build caches into commits) 2026-09-03 03:07:04 +00:00
AGENTS.md Specify operation algebra state and effect laws 2026-08-29 18:40:48 -07:00
bb.edn Rename ns-surgeon → clj-surgeon using :rename-ns! (self-surgery) 2026-03-28 18:27:25 -07:00
CHANGELOG.md ratchet-fix: wording that agrees with its own numbers, and a cause the caller can act on 2026-09-03 04:18:53 +00:00
CLAUDE.md doctrine: fence review scope widened (subprocess argv, reader eval, walk paths, pre-parse bounds, next_call); red-teams on census, rf2, q5z launched 2026-09-03 02:20:20 +00:00
deps.edn harden closed compact relation boundary 2026-08-29 18:40:50 -07:00
LICENSE Publish-ready README: origin story, benchmarks, bitter lesson, prior art 2026-04-24 09:36:15 -07:00
LQBM-REPORT.md docs(kc-lqbm): build report for the combinable-transaction steering note 2026-08-31 20:23:22 -07:00
Makefile merge: worktree lifecycle MVP + stale-registration prune (Gene-ratified HLD/LLD/EARS) 2026-09-02 04:27:14 -07:00
README.md docs: record closed relations acid-test win 2026-08-29 18:40:51 -07:00
REPAIR-REPORT.md docs: polish create_files re-review evidence 2026-08-31 21:00:20 -07:00
skill.md docs: sync skill mirror for the trip-wire note (mirror check caught unsynced e136ddbf intake) 2026-08-31 21:44:55 -07:00

clj-surgeon

clj-surgeon is a Babashka CLI for structural reads and refactors in Clojure, ClojureScript, and CLJC files. It parses source with rewrite-clj and returns EDN. Claude Code and Codex skills teach coding agents the shortest safe commands.

Headline: one transaction is now five-times-native across agent families

The current high-water mark is a real historical extraction: move 15 named forms, their comments, dependencies, visibility change, and 63 remaining caller occurrences out of a 4,594-line namespace. Surgeon compiles that supplied decision into one snapshot-guarded transaction, formats staged bytes, runs the repository's exact verifier, reads the committed bytes back, and returns one terminal receipt.

Matched serial Anvil measurements at exact product commit 573e240:

Caller Surgeon route Matched native Speedup Surgeon actions
Codex Sol/high 21.446s 207.898s 9.69x 1 apply
Codex Terra/high 23.621s 178.477s 7.56x 1 apply
Claude Fable/high 14.325s 128.512s 8.97x 1 discovery + 1 apply
Claude Opus/high 12.924s 82.377s 6.37x 1 discovery + 1 apply

Every listed arm passed the same semantic scorer. The Surgeon arms performed no source read, native edit, shell verification, or recovery call. Both Claude arms resolved their deferred MCP catalog with one visible ToolSearch, called apply_clojure_changes once, received exact-exit verification inside the transaction, and returned the server's terminal response exactly. The Anvil native routes required eight commands for Sol and nine tool actions for each Claude model.

These are one matched pair per caller, so they prove capability, route shape, and a large advantage; they do not yet estimate tail latency. Independent local Claude pairs reproduced the finding: Fable was 18.850s versus 102.051s (5.41x), and Opus was 12.754s versus 107.484s (8.43x). The earlier promoted Sol product cohort measured a 19.216s Surgeon median versus the retained 122.278s correct-native median (6.36x).

Codex remains the frozen performance control. Claude compatibility is additive: Claude gets a thin deferred-discovery adapter, while Sol keeps its direct first-action path. In the Anvil run, Sol's first apply started after 14.651s of model materialization, the server completed in 2.367s, and Sol relayed the receipt 3.030s later. Fable and Opus paid for discovery but still materialized the apply in about 6.8s and 5.5s respectively. We will not tax or weaken the Sol route to manufacture caller parity.

The important mechanism is not faster parsing. It is phase deletion:

native:   discover spans → assemble script/patch → repair → lint → narrate
Surgeon:  [Claude may discover tool] → one compiled verified transaction

The compact editor shows the same crossover without extraction planning. A counterbalanced Sol/high Anvil pilot replayed a real nine-file cleanup with 51 supplied guarded changes. edit_clojure completed in 55.763 and 61.354 seconds, one exact atomic call each. The matched native arms completed in 206.727 and 372.286 seconds and each paid one failed mutation path. The two-observation midpoints are 58.559 versus 289.507 seconds: 4.944x faster and 79.77 percent lower wall time for compact Surgeon. On a small two-file, three-change control, native remains faster. The tool earns its ceremony when exact mechanical state spans enough owners and files to collapse many fragile actions into one chord.

Closed relations made the 51-edit chord 34 percent faster

A later independent Anvil cohort kept that same complete 51-edit, nine-file decision and the same relation-capable apply_clojure_changes surface in both arms. The normalized-flat arm repeated every exact namespace and symbol edit. The closed-relation arm stated one exact symbol migration plus one exact require change; Surgeon lowered it through the same guarded compiler and atomic transaction.

The predeclared Sol/high schedule was N R R N then R N N R. All eight runs were correct, route-adherent, one-shot, and exact verified. The relation arm reduced median request-emission time by 40.21 percent in Block 1 and 37.75 percent in Block 2. Pooled, it emitted 37.90 percent sooner and completed exact verification 33.99 percent sooner: 38.647 seconds versus 58.544 seconds. Complete process wall was 39.369 seconds. Against the retained matched native midpoint of 289.507 seconds for this workload, that is 7.35x faster.

This was not promoted from an attractive first block. An early cohort was held because caller row order changed an exact transaction hash. The product first proved that fully resolved, non-overlapping effects have a canonical semantic identity while submitted order remains diagnostic provenance. A new whole cohort then passed both counterbalanced blocks and the unchanged 20-percent emission and verified-wall gates on candidate 90b47d1. See the append-only promotion receipt.

Why make structure a product primitive? In an additive Terra/high screen, the native caller generated a Perl program to perform this extraction and to balance the Clojure delimiters. That script replaced a complete (:import ...))) clause with )), leaving an unmatched closing parenthesis. The namespace no longer parsed after 219.303 seconds and seven commands. The same Terra caller gave Surgeon the architectural decision once, and rewrite-clj performed the guarded structural transaction correctly in 23.621 seconds.

Spark/high found an even funnier solution to the parenthesis problem: do not balance the difficult forms—delete almost all of them. Its native route spent 62.207 seconds, 26 commands, and 984,329 input tokens producing Clojure that parsed successfully because scarcely any of the 4,594-line source namespace remained. The semantic scorer was less impressed. Spark's Surgeon route moved the intended 15 forms, preserved the other 63 caller occurrences and unrelated code, and finished correctly in one 11.966-second transaction. We do not call that a measured speedup because the native result was wrong; we call it an excellent argument for giving enthusiastic models a structural scalpel.

This is not an isolated quoting mishap. Coding agents will reach for Perl, Python, regexes, line slicing, delimiter counters, and repeated patches when asked to manipulate structure using only text tools. Balancing Clojure forms amid comments, strings, metadata, quoting, and reader conditionals is a parser problem. Surgeon lets every model stop rebuilding a fragile parser during the task: the model decides what should move; the kernel owns exact form boundaries, balanced delimiters, snapshot guards, rollback, and verification.

Clojure can change Clojure

transform_clojure is the unusual instrument in the catalog: the agent writes one bounded Clojure function, and Surgeon runs it in SCI over exact selected Clojure syntax. The result becomes guarded source edits; it does not become arbitrary host code.

Clojure source
    ↓ exact structural selection
bounded Clojure function in SCI
    ↓ exact cardinality + changed-character budget
preview or atomic guarded commit

The function cannot perform I/O, launch processes, load namespaces, mutate host state, or use Java interop. transform_clojure previews by default. edit_clojure can also combine computed SCI programs with literal edits and owner deletions in one frozen transaction. In editor terms, transform_clojure is the solo instrument; edit_clojure plays the complete atomic chord.

The failed first native attempt is evidence of the removed failure class, not a speedup denominator. A fresh Terra retry eventually passed the same semantic scorer and ran the exact verifier successfully, but still required 178.477 seconds and an in-run repair cycle. The correct matched result is therefore 7.56x in Surgeon's favor. We report matched speed only when both routes pass.

The cross-caller benchmark plan defines the replication and promotion gates. The longer hill climb—including the 3.23x direct route, exact-verifier fusion, and terminal receipt that crossed 5x—is recorded in the captain's log.

Use clj-surgeon to:

  • inspect a namespace without reading the complete file
  • select one top-level form by name, line, or distinctive text
  • search and compute over nested Clojure forms
  • materialize one guarded multi-file edit plan as one reversible transaction
  • create a reviewed, hash-bound replacement plan and apply it separately
  • move forms with explicit dependency handling
  • reorder definitions to remove unnecessary (declare ...) forms
  • rename namespaces or extract forms to new files
  • inspect or transform reader-conditional code

The project began after a coding agent spent 45 minutes refactoring a 5,000-line views.clj file. The agent repeatedly read source to find, move, and order forms. The initial four-hour prototype had 13 operations and about 1,500 lines. The current implementation is larger and requires Babashka and clj-kondo. Those counts describe the prototype, not the current product.

A structural editor for agents

Clojure source already contains named forms, nested expressions, dependency edges, and reader conditionals. A text-only workflow discards that structure. It searches for text, recovers a line number, prints a range, patches syntax, and rereads the file.

clj-surgeon gives coding agents a composable structural interface. :ls inventories a namespace. :cat returns exact selected forms from one or more files. :xray reads or computes over a Clojure path.

:match-form finds syntax instead of textual lookalikes. Dependency operations expose the graph. :change! compiles a complete exact edit plan into one verified transaction. Computed single edits retain separate planning and application.

ls / cat / xray / match-form / deps  →  intent  →  change!  →  receipt
                                      computed edit  →  plan  →  apply

For resolved definitions, references, implementations, and call hierarchy, search the deferred MCP catalog for mcp__cclsp__* before falling back to source. Use clj-surgeon for concrete syntax, structural computation, guarded writes, and receipts. cclsp supplies semantic evidence. It does not have write authority in this workflow. The Clojure Agent Tool Stack documents the clj-kondo, clojure-lsp, cclsp, and clj-surgeon runtimes and authority boundaries.

Run clj-surgeon :op :help for the complete caller surface. Unknown operations list the preferred names, not compatibility spellings. When an invalid call still identifies one safe intent, its EDN includes executable :command-args with the preferred operation and canonical argument names. A value that looks like a text regular expression instead returns a bounded text-search remedy. Execute the returned remedy instead of guessing another spelling.

When all exact before-forms, after-forms, scopes, and counts are known, :change! applies the complete plan in one call. It commits all files as one failure-atomic unit and saves a hash-fenced inverse receipt. Use :change to review the combined plan without writing. Use :undo-change! only while every forward result hash still matches.

A computed single write first produces a hash-bound plan and exact diff. A later command applies only that reviewed artifact. A literal single write with :expect applies in one call because the caller declared the exact before-state. All write routes return read-back-hash and whole-file-parse evidence.

The design target is one command for each judgment boundary:

one complete declared intent  →  one verified transaction

Each command owns its mechanical work: parsing, selection, ordering, ambiguity detection, hashing, atomic writing, and verification. The agent decides intent and scope, reviews the plan, and consents to the change. If the tool cannot select safely, it refuses with bounded evidence and executable remedies. It does not silently widen scope, choose an ambiguous match, or move additional code without consent.

When one target relationship and replacement are explicit, an :edit operation may be the first source-bearing call. Use :expect to apply a known literal replacement, or emit a plan for review. When several exact edits are already known, put them in one :change! spec. When intent depends on source discovery, run :xray first.

This is the project's Bitter Lesson boundary. clj-surgeon adds general structural primitives and trustworthy feedback. It does not add special cases that try to infer the caller's intent.

The product standard is behavioral. Give a clean agent a real task and inspect every command. A transcript exposes a product defect when the agent:

  • uses rg only to obtain a line number
  • rereads evidence that an apply receipt already proves
  • guesses a documented invocation
  • calls help for a route that the skill should make clear

After a defect, update the CLI, help, skill, or contract. Then rerun the task in a fresh context.

One clean-context edit replication reduced the workflow from 10 shell calls and 157,481 cumulative input tokens to four calls and 77,421 tokens. The four calls read the skill, selected with :cat :contains, planned, and applied. The edit was exact, and the apply returned a verified receipt.

An adjacent single-pair replication compared the previous paved road with the structural query pipeline. Both routes made the exact edit. Both planned and applied separately, and both avoided textual fallback.

The previous route used four shell calls, 77,320 cumulative input tokens, and 1,602 source-output bytes. The structural query used three calls, 63,514 input tokens, and 1,380 source-output bytes. Uncached input fell from 17,160 to 15,386. Wall time increased from 33.3 to 42.3 seconds, so this result supports fewer calls and less context, not a speed claim.

The target state is simple: agents should rarely need line-number choreography or text surgery for structural Clojure work.

The experiments, wrong turns, and clean-agent transcripts behind this vision are preserved in the captain's log.

Measured performance

The original planning observation compared clj-surgeon outlines with Explore agents that read five files containing about 5,000 lines:

  • clj-surgeon used about 1,000 tokens. The Explore agents used about 150,000 tokens for similar information.
  • clj-surgeon returned in milliseconds. The Explore agents took about 100 seconds.

The files were command_center.clj (821 lines), routes.clj (2,894 lines), state.clj (614 lines), processes.clj (508 lines), and process_handlers.clj (198 lines). This dated observation was not a controlled comparison against current models.

A later clean-context benchmark compared commit cc0f306, commit 477dca9, and a native control that used ordinary source tools without clj-surgeon. Each version ran each task four times. Efficiency values are medians of runs that passed the task's correctness gate.

Task Version Correct Wall Calls Input tokens Source output
Named form cc0f306 4/4 28.792 s 4 72,000 6,720 B
477dca9 4/4 22.709 s 2 43,971 1,461 B
Native control 4/4 23.949 s 2 41,379 2,322 B
Semantic form cc0f306 4/4 39.444 s 5 82,806 7,883 B
477dca9 4/4 28.384 s 2 44,016 1,531 B
Native control 4/4 27.347 s 3 56,778 4,244 B
Structural find cc0f306 4/4 35.959 s 4 76,213 663 B
477dca9 4/4 24.222 s 2 43,672 596 B
Native control 4/4 40.627 s 3 56,285 844 B
Case edit cc0f306 4/4 exact bytes 53.502 s 8 176,294 37,443 B
477dca9 4/4 exact bytes 52.832 s 8 116,114 10,542 B
Native control 0/4 exact bytes

All native case-edit runs changed the requested token but deleted an unrelated trailing blank byte. Because no native run passed the exact-byte correctness gate, the table does not report native efficiency medians for that task.

The first public transaction dogfood was not a controlled agent benchmark. It measured the mechanism before the clean-context comparison. One EDN spec contained three heterogeneous intents and produced four exact edits across two copied Clojure files.

Forward apply, durable receipt publication, and exact undo completed in approximately 0.5 seconds in one Babashka process. The receipt was 3,466 bytes, and both final file hashes equaled their starting hashes. This proved bounded mechanism cost and reversibility.

A later controlled benchmark measured the complete agent task on the frozen six-edit, two-file decision. Each lane ran four correct replicates. Efficiency medians include only runs whose final bytes matched the accepted files.

Route Correct MCP adopted Median wall Median input Shell calls
Assisted apply_clojure_changes 4 / 4 4 / 4 24.530 s 44,020 0
MCP + one project rule 4 / 4 4 / 4 27.432 s 44,215 0
Current CLI + skill 4 / 4 36.396 s 64,842 3
MCP available, no routing rule 4 / 4 0 / 4 40.618 s 74,865 2
Native control 4 / 4 43.190 s 74,872 4

The assisted MCP route was 18.660 seconds faster than native, a 43.2% reduction. It used one verified mutation call, zero source reads, and zero failed mutations in every run. Tool metadata alone did not cause adoption. A one-sentence project AGENTS.md rule changed adoption from 0 / 4 to 4 / 4 without loading the skill. See the complete Captain's Log.

A later rerun removed one ambiguous owner recovery round from that same six-edit task. Every one-shot MCP run was correct; four of five native controls were valid and correct.

Route Correct efficiency runs Median wall Tool actions Source bytes surfaced
One-shot Surgeon MCP 5 / 5 27.976 s 1.0 0
Fresh native control 4 / 5 68.932 s 3.5 3,577

In this complete supplied-decision stratum, native took 2.46 times as long; one-shot MCP reduced median wall time by 59.4%. The later exploratory lane also crossed over: 62.876 seconds for Surgeon versus 81.730 seconds for native, a 23.1% reduction. One supplied nested edit showed a smaller 19.3% reduction. The claims apply to a shared hot service and these tested Clojure change strata. See the end-to-end timing log and the current last-night synthesis.

A later paired read benchmark asked fresh Codex callers 45 exact behavior questions about 45 named forms in three frozen files. Both answers passed all 45 checks. Exact-source EDN made Surgeon 43.46 seconds slower because the result exceeded the visible transcript boundary and caused recovery reads. The explicit :format :semantic view reduced the transaction from 68,339 to 47,814 characters. On the identical rerace, Surgeon finished in 102.48 seconds versus 108.39 seconds for native generated extractors: 5.91 seconds (5.5%) faster, with 27.8% less command output. This is one paired roll, not a stable median. The complete negative and positive stages are preserved in the transaction/read Captain's Log.

A later single-replication experiment tested a local cclsp fork as the cross-file semantic sensor. These are causal pilot results, not stable medians:

Relationship task Before usable contract Enriched cclsp Native Outcome
Resolve three reference owners 60.762 s 24.252 s 46.686 s Enriched cclsp was correct and 48% faster than the correct native control
List direct outgoing calls 84.799 s, incorrect 20.049 s, correct 60.777 s, incorrect Enriched cclsp was the only correct route and saved at least 40.728 s

The gains came from removing recovery rounds. Read-only MCP annotations made the calls usable without approval pauses. Agent-native argument aliases removed guessed-field failures. Owner-enriched references removed line-to-form lookup. Each result is one replication, not a stable median. See the semantic-sensor Captain's Log.

The first clean proof-carrying change-buffer task started with only a Var and a return-contract goal. After one negative design stage, inspect_clojure returned the complete definition and complete named owner of every semantic reference. The caller then used the returned basis in one apply_clojure_changes call.

Route Correct Wall Source reads after skill Mutation calls
Native source tools yes 54.13 s 4 1
Proof-carrying basis yes 31.00 s 0 1

The basis route was 23.13 seconds faster, a 42.7% wall reduction and 1.75x speedup. It used exactly two MCP calls: prepare, then apply and verify. This is one paired probe, not a replicated median or a 3x claim. See the transaction Captain's Log.

The five-times-native extraction hill

Several weeks of dogfooding changed the optimization target. Surgeon operations were often already subsecond, yet complete coding turns remained slow because the model repeatedly stopped to inspect, reconstruct a plan, apply, lint, interpret the receipt, and narrate evidence that the kernel had already proved. The project therefore optimizes complete verified task time rather than tool execution time or Surgeon adoption.

The frozen north-star task is a historical 15-form namespace extraction with caller handling. Every promoted route uses the same Sol/high caller stratum and meaning-preservation scorer. The retained native control is correct and takes 122.278 seconds.

Earned route Complete wall Relative to native
Native source/edit route 122.278 s 1.00x
One-shot Surgeon extraction 37.871 s 3.23x
Fused exact verification, ordinary response 27.471 s mean 4.45x
Fused verification plus terminal response 21.815 s mean 5.61x
Promoted product cohort 19.216 s median 6.36x

The final route is one atomic apply_clojure_changes call. It moves the forms, updates declared callers, formats staged bytes, runs the repository-owned exact verifier, reads the writes back, publishes hashes and a durable receipt, and rolls back on non-pass outcomes. Mechanically derivable planning moved inside that transaction. A conditional terminal response lets the model relay concise success evidence when the mutation completes all remaining work. In the promoted cohort this removed another model interpretation boundary without causing compound tasks to stop early.

The result is intentionally scoped. It does not claim that Surgeon is 6.36x faster for ordinary one-line edits, exploratory work, or every model. Native patching remains the right competitor for a small visible literal change. Surgeon earns the interaction when one understood decision spans enough forms, callers, files, formatting, and verification that native editing would pay several model/tool boundaries.

Negative results were part of the hill climb. Fewer MCP schemas were slower. A compact plan handle missed its gate. Broad fuzzy ranking was rejected. Formatter CPU was not the missing 13 seconds. A selector continuation removed duplicate reads but initially made complete wall time worse; simpler guidance recovered the loss but still missed the fixed speed gate. These receipts keep the project from optimizing attractive proxies instead of the user-visible outcome. See the terminal-proof Captain's Log, three-day option portfolio, and agent-usage release window.

Production examples

clj-surgeon renamed this project from ns-surgeon to clj-surgeon in less than one second. It changed 10 files, moved 10 files, and updated namespace declarations and :require entries structurally. It preserved aliases and string literals.

One-command declare cleanup: A 2768-line production namespace with 7 forward declares. One command:

$ clj-surgeon :op :fix-declares! :file src/writer/state.clj

{:summary {:moves 7, :declares-deleted 5, :skipped 2}}

$ make runtests-once
337 tests, 1056 assertions, 0 failures.

The command removed five declarations. Three required direct moves, and two required their leaf dependencies. It skipped two moves with non-leaf dependency chains. The first-use observation records the failure pattern that led to :fix-declares.

Install

git clone https://github.com/realgenekim/clj-surgeon.git
cd clj-surgeon
make install    # → CLI plus Codex and Claude skills

make install creates content-addressed, read-only copies of the CLI runtime and the canonical skills/clj-surgeon package under ${INSTALL_ROOT:-$HOME/.local/share/clj-surgeon}. The CLI launcher and the Codex and Claude skill entrances point to those stable copies, never to the active checkout. Switching this repository's branch therefore cannot silently change an installed version.

The default entrances are ~/bin/clj-surgeon, ${CODEX_HOME:-$HOME/.codex}/skills/clj-surgeon, and ${CLAUDE_HOME:-$HOME/.claude}/skills/clj-surgeon. Each gets a neighboring .receipt.edn file that records the source commit, source hash, destination, and immutable package path. Run make install-cli, make install-codex-skill, or make install-claude-skill to install one surface. Installation refuses to replace unrelated files or skill directories.

To put the CLI somewhere else, pass its complete file path as CLI_DEST. The installer creates the parent directory and handles paths containing spaces:

make install-cli CLI_DEST="$HOME/.local/bin/clj-surgeon"

CLI_DEST affects only the CLI. Set CODEX_HOME, CLAUDE_HOME, and INSTALL_ROOT independently when the skill homes or stable package root need non-default locations:

make install \
  CLI_DEST="$HOME/.local/bin/clj-surgeon" \
  CODEX_HOME="$HOME/.config/codex" \
  CLAUDE_HOME="$HOME/.config/claude" \
  INSTALL_ROOT="$HOME/.local/share/clj-surgeon"

For tool development only, make install-dev deliberately creates a branch-coupled CLI launcher and direct skill links to the current checkout. Its output and receipts use :mode :development-link. Run stable make install again before benchmarks or release validation.

Requirements

Two binaries must be on PATH:

  • babashka (bb) — the runtime. rewrite-clj and cheshire are built in.
  • clj-kondo — used by :ls, :fix-declares!, and any op that needs forward-reference detection. Without it those ops fail.

No-sudo install (e.g. inside the Claude Code sandbox, where sudo is blocked):

mkdir -p ~/bin   # ~/.profile already prepends ~/bin to PATH on Ubuntu/Debian
bash <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install) --dir ~/bin
bash <(curl -s https://raw.githubusercontent.com/clj-kondo/clj-kondo/master/script/install-clj-kondo) --dir ~/bin

make install will warn (but not fail) if either binary is missing.

What changed since you installed? See CHANGELOG.md.

Experimental Codex MCP entrance

Keep this branch-local while the MCP contract is under evaluation. The development installer points the CLI and both skills at the current checkout, starts the hot clj-surgeon and cclsp services on loopback, and registers exactly four clj-surgeon tools with Codex:

  • inspect_clojure for read-only structural batches and proof-carrying change preparation;
  • apply_clojure_changes for guarded direct or basis-backed mutation;
  • edit_clojure for byte-exact, compare-and-swap subtree replacement inside a named top-level form;
  • transform_clojure for a bounded SCI relation compiled into one or more exact, guarded structural edits.
make install-mcp-codex-dev

# Join any repository to the same hot clj-surgeon and cclsp processes.
clj-surgeon up /absolute/repository

clj-surgeon up [WORKSPACE] defaults to the current directory. It discovers the effective clj-kondo configuration, registers the canonical workspace with the shared cclsp provider, starts the shared loopback services when needed, and installs the two MCP entries in the workspace's .codex/config.toml. It preserves unrelated TOML, migrates older per-repository tables, validates the result, and is idempotent. make workspace-mcp-onboard WORKSPACE=/repo remains a compatibility alias.

The managed cclsp service inherits the invoking shell's complete PATH. This is part of semantic correctness: clojure-lsp is a native executable, but it still invokes the separate clojure command to discover a project's classpath.

If an already-onboarded agent reports invalid-mcp-session, missing tools, or a green health check followed by a failed structural call, use the bounded reset button once:

clj-surgeon recover /absolute/repository

Success means more than healthy processes. The command opens fresh MCP sessions, checks the actual tool catalog, resolves one hash-bound Clojure Var through cclsp, performs one temporary failure-atomic write with read-back verification, removes its fixture, and returns :terminal-state :recovered with :next-action :none. It does not restart healthy shared services.

Failure returns one terminal state, a redacted receipt path, and one executable report command. Receipts are scoped to the canonical workspace. Run the exact :report-command once on this development machine, then execute the receipt's :fallback-command from that workspace. For example:

clj-surgeon report-failure --receipt \
  ~/.local/state/clj-surgeon/workspaces/WORKSPACE_HASH/receipts/last-failure.edn

The reporter creates or updates one local Bead by stable failure fingerprint. It never uploads or retains source, prompts, URLs, or workspace paths. When a local .beads database is unavailable, it returns the same safe issue draft as data and performs no write. Do not loop on up or recover.

A typed semantic-provider-warming result is different: one clojure-lsp child is still initializing under a bounded lease. Keep its lsp_session, child_pid, retained source anchor, and next_call. Do not restart or recover. Wait, then retry that exact call once. Structural MCP reads and exact-source writes remain available while the semantic graph warms.

Start a new Codex session in the target repository only when onboarding changed its .codex/config.toml. Do not start another MCP server for the repository. For a workspace other than the server default, send its canonical absolute workspace_root on inspect_clojure and apply_clojure_changes. A prepared next_call already carries the same root; preserve it unchanged.

A qualifying edit can then be one native tool call. For an exact nested edit, name the file, top-level owner, old subtree, and replacement; Surgeon derives the transaction bookkeeping:

{
  "workspace_root": "/absolute/workspace",
  "edits": [{
    "file": "src/app.clj",
    "within": {"form": "render"},
    "from": ":old",
    "to": ":new"
  }],
  "verify": "fast"
}

The old subtree is the compare-and-swap guard. One match is the default. When the same exact subtree must change a known number of times inside the owner, add "matches": 2; any other count refuses the whole request before write.

When the replacement is derived from the selected value or the same relation applies to several sites, use transform_clojure instead of materializing each replacement. The expression uses the same capability-limited Clojure path DSL as CLI :edit :expr and must end in transform:

{
  "workspace_root": "/absolute/workspace",
  "file": "src/policy.clj",
  "expression": "(-> (form 'retry-policy) (match :delays) right (transform #(mapv (partial + 100) %)))",
  "expect": {"matches": 1, "max_changed_characters": 64}
}

Preview is the default and never writes. Add "commit": true only when the relation and bounds are already decided. The tool compiles every selected node to a distinct retained address, parses the complete future file, hash-fences the original source, commits atomically, reads the file back, and returns an inverse receipt. One-shot commit refuses a selected subtree that contains a comment; narrow the selection or review the preview. Use a semantic or tested refactor operation when caller completeness or namespace mechanics matter. Surgeon never offers an unbounded replace-all through this entrance.

For different edits or more explicit transaction control, send all known Clojure replacements, scopes, and cardinality guards to apply_clojure_changes. A successful verification_complete=true receipt is terminal evidence, so do not reread or diff the files afterward. One server remains hot across projects and Codex sessions. Each request is confined to its selected canonical workspace_root; a relative path cannot escape that root. On macOS, a local launchd job keeps the service alive across terminal and Codex restarts. It listens only on 127.0.0.1:7888 and records full local telemetry under ~/.local/state/clj-surgeon/mcp. The job is session-persistent, not a permanent login item; rerun the installer after logging out or rebooting.

The direct route uses one closed object per change:

{
  "workspace_root": "/absolute/workspace",
  "changes": [{
    "id": "status",
    "files": ["src/app.clj"],
    "forms": ["render"],
    "find": ":old",
    "replace": ":new",
    "expect": {"matches": 1, "each_form": 1}
  }],
  "expect": {"changes": 1, "edits": 1, "files": 1},
  "verify": "fast"
}

Address one multimethod implementation without line numbers by putting a typed owner in forms:

{"kind": "defmethod", "name": "render", "dispatch": ":card"}

The method name and complete dispatch form must select exactly one top-level defmethod. Missing or duplicate dispatches refuse before mutation.

Managed transactions format staged future bytes before the first live write. Projects can add a live application proof and a bounded cold suite with closed data in .clj-surgeon.edn; MCP callers can select a profile but cannot send a shell command or arbitrary nREPL expression:

{:formatter ["npx" "@chrisoakman/standard-clojure-style" "fix" "{files}"]
 :verification-profiles
 {"fast" {:commands [["clj-kondo" "--lint" "{files}"]]
          :hot {:port-file ".nrepl-port"
                :reload ["app.model" "app.routes"]
                :tests ["app.model-test/invariant"]
                :timeout-ms 5000}}
  "full" {:cold {:command ["make" "test"]
                  :timeout-ms 1200000}}}}

Formatting, configured commands, application reload, and focused laws finish in the guarded hot transaction. Failure rolls back before the call returns. A configured :cold command starts only after the hot proof passes. The edit then returns immediately with verification_complete=false and one next_call:

{
  "tool": "inspect_clojure",
  "workspace_root": "/absolute/workspace",
  "verification_job": "verify/…",
  "view": "verification"
}

Continue useful work, then copy that call once. A cold failure never performs a surprise late rollback; it reports the bounded output and leaves the inverse receipt available. Running and terminal status carry the same undo_receipt and receipt_hash, so recovery needs no archaeology. Do not replay the edit or reread its source while the job is running.

For a namespace extraction, send the architectural decision once. The typed route creates the absent destination, moves the named owners, applies every exact caller rewrite, verifies the coherent future file set, and emits one guarded undo receipt:

{
  "workspace_root": "/absolute/workspace",
  "extraction": {
    "file": "src/app/core.clj",
    "to": "src/app/render.clj",
    "forms": ["render-card", "render-page"],
    "require_policy": "copy-all",
    "caller_changes": [{
      "id": "redirect-render",
      "files": ["src/app/routes.clj"],
      "forms": ["routes"],
      "find": "app.core/render-page",
      "replace": "app.render/render-page",
      "expect": {"matches": 1, "each_form": 1, "each_file": 1}
    }],
    "ignored_caller_files": [],
    "expect": {"forms": 2, "caller_edits": 1, "files": 3}
  },
  "verify": "fast"
}

Every caller candidate must be changed or explicitly ignored; omission refuses before mutation. The destination parent must exist inside the workspace, and the destination itself must be absent. Undo through the returned receipt:

clj-surgeon :op :undo-extract! :receipt /path/from/undo_receipt

Execution proof and caller proof are separate. A receipt reports one explicit caller-proof level:

Level Meaning Can an empty result authorize deletion?
semantic-complete Resolved definitions and references completed in one hash-bound semantic session. Yes, within that declared semantic authority.
structural-candidates-only Exact structural candidate and quoted-Var scans completed; aliases, macros, or generated references may remain. This is the current direct-extraction level. No
caller-proof-unavailable No trustworthy semantic or structural inventory completed. Successful extraction refuses rather than silently using this level. No

verification_complete=true proves that the requested transaction committed, parsed, passed its configured verification, and was read back exactly. It does not upgrade structural-candidates-only into semantic caller completeness.

Use exactly one of forms or owner and exactly one mutation action in each change. The actions are replace, insert_before, insert_after, rename_binding, assoc_entry, and delete. find, a replacement, and every inserted member must be one complete parseable Clojure form. Insertion preserves the selected sibling's existing whitespace separator. It refuses when that gap contains comments or detached source; replace a larger exact span when comment placement is part of the decision. Optional verify accepts only fast or full on either the direct or prepared route. Commands and live-JVM laws in the named closed profile run after the atomic commit and roll back on failure. A configured cold command runs as a bounded job and keeps verification_complete=false until its status receipt is terminal.

Delete several known top-level owners directly—without marker forms, semantic preparation, or a native cleanup patch:

{
  "workspace_root": "/absolute/workspace",
  "changes": [{
    "id": "delete-obsolete",
    "files": ["src/app/server.clj"],
    "forms": ["portal-drafts", "handle-portal"],
    "delete": true,
    "expect": {"matches": 2, "each_form": 1}
  }],
  "expect": {"changes": 1, "edits": 2, "files": 1},
  "verify": "fast"
}

The delete action owns each complete named form, its contiguous attached leading comments, and the necessary separator. It refuses before mutation if an owner is missing, duplicated, stale, or ambiguous.

For example, add two members after one exact set or vector member without replacing the whole owner:

{
  "workspace_root": "/absolute/workspace",
  "changes": [{
    "id": "add-metrics",
    "files": ["src/metrics.clj"],
    "forms": ["numeric-fields"],
    "find": ":wall-ms",
    "insert_after": [":cached-input-tokens", ":uncached-input-tokens"],
    "expect": {"matches": 1, "each_form": 1, "each_file": 1}
  }],
  "expect": {"changes": 1, "edits": 1, "files": 1}
}

Use rename_binding when a local name must change but its external :keys keyword must not change. Name every owner. matches counts the binding and its resolved local usages. each_form: 1 requires one selected binding per owner.

{
  "workspace_root": "/absolute/workspace",
  "changes": [{
    "id": "rename-sort-binding",
    "files": ["src/app.clj"],
    "forms": ["feed-page", "table-page"],
    "rename_binding": {
      "from": "sort-by",
      "to": "sort-field",
      "preserve_external_key": true
    },
    "expect": {"matches": 12, "each_form": 1}
  }],
  "expect": {"changes": 1, "edits": 12, "files": 1}
}

Use assoc_entry to add one entry to maps that have the same Clojure value but different comments or layout. Existing source trivia remains in place. Add inside when one complete ancestor form must distinguish equivalent maps.

{
  "workspace_root": "/absolute/workspace",
  "changes": [{
    "id": "add-contract-field",
    "files": ["test/app_test.clj"],
    "forms": ["contract-test"],
    "find": "{:a 1 :b 2}",
    "inside": "(is (= {:a 1 :b 2} actual))",
    "assoc_entry": {"key": ":status", "value": ":ready"},
    "expect": {"matches": 1}
  }],
  "expect": {"changes": 1, "edits": 1, "files": 1}
}

When one Var or one related Var set names the subject but the exact edit sites are unknown, compile the decision in two calls:

inspect_clojure prepare-change -> fill keep/replace/delete holes -> apply_clojure_changes
{
  "mode": "prepare-change",
  "subjects": [
    "clj-surgeon.mcp-contract/normalize-success-receipt",
    "clj-surgeon.mcp-tool/success-summary"
  ],
  "intent": "Add context to the terminal receipt without changing callers"
}

If the owner is known but cclsp does not index the file, use the exact-source route instead:

{
  "mode": "prepare-change",
  "file": "bench/summarize_clean_codex.clj",
  "form": "numeric-fields",
  "intent": "Add one benchmark metric",
  "label": "metrics"
}

This route proves the exact file hash, unique named owner, and structural address. It intentionally reports zero references because it does not claim language-server coverage.

The response contains a compact, complete surface vector for every definition and reference. It unions cclsp's resolved references with a lossless proof of #'name and (var name) sites, including namespace aliases and fully qualified private Vars. Each site reports authority; structural supplements are never misrepresented as language-server evidence. Comments, strings, and quoted data do not become callers, so a separate literal sweep is unnecessary. With scope=definition, only the definition decision carries source; open selected retained site IDs when more owner source is required. With scope=surface, every site is a decision and carries its complete named owner. The response also contains a complete next_call. Preserve its workspace_root, basis, site IDs, and verification profile. Replace every null with {"keep":true}, {"replace":"ONE FORM"}, {"delete":true}, or one compact nested edit. A whole-site delete removes the prepared owner and its contiguous leading comment block; use it for obsolete definitions, call sites, tests, and declare forms. Submit that basis request to apply_clojure_changes once.

Do not reconstruct a direct changes request. Apply uses the retained source hashes and zipper paths. It does not repeat semantic resolution or selection. Omit verify from prepare unless the user explicitly requests the full repository suite; the default is fast.

The basis is process-local and expires after one hour. The server retains at most 32 bases. Prepare refuses before publishing a basis when the decision set exceeds 24 sites, 32 KiB of visible decision source, or 4 MiB of retained source. scope=definition can still report a larger compact proof surface because references do not become decisions or carry visible source. The fast profile runs clj-kondo and Standard Clojure Style on changed files. The full profile runs make test. Verification failure restores the original files before it returns.

cclsp binds every definition and reference to one LSP session, a canonical project-relative file, an exact range, and the SHA-256 of the synchronized source bytes. clj-surgeon independently reads and hashes those files before it stores a basis. A language-server owner must match the exact-source owner. For a version-3 ownerless reference inside a project macro, clj-surgeon must derive one exact-source owner. Missing evidence, mixed sessions, stale bytes, owner ambiguity, and owner disagreement refuse before basis storage.

An unanchored fully qualified Var may live in another workspace already joined to the shared stack. Pass the caller's canonical workspace_root; do not use ../ paths or guess the owning project. clj-surgeon up publishes each workspace's confined Clojure source roots. cclsp converts the namespace to its canonical file suffix, queries only workspaces that contain that file, and returns both requested_workspace_root and authoritative_workspace_root. workspace_search.selection records the shortlist and every queried outcome. If any workspace lacks source-root metadata, cclsp falls back to all configured Clojure workspaces and preserves typed zero, ambiguous, error, and timeout results instead of claiming uniqueness.

Every named form returned by inspect_clojure includes a source_anchor with the exact file, SHA-256, owner, and zero-based range. Copy that object into cclsp resolve_var_surface; do not restart discovery with an unanchored workspace-symbol query. For one architectural question involving up to four known Vars, call resolve_var_surfaces once with the ordered anchored subjects. It returns every complete surface or typed refusal in the same order.

An inspect call declares all knowable reads in one requests vector. The four operation variants are forms, outline, match, and xray; every variant has a closed JSON schema and paths are confined to project-relative .clj, .cljs, and .cljc files. Distinct canonical files are read once, results retain request order and SHA-256 snapshot hashes, and any validation, parse, cardinality, or output-limit failure refuses the complete batch. Full evidence is in structuredContent; the bounded text result is an ordinary transcript summary. The read tool is annotated read-only, non-destructive, idempotent, and closed-world. This experiment adds no resources, prompts, shell access, unrestricted evaluation, or custom MCP UI.

{
  "requests": [
    {
      "id": "summary-fields",
      "operation": "forms",
      "file": "bench/summarize_clean_codex.clj",
      "forms": ["numeric-fields", "boolean-fields"],
      "expect": {"forms": 2}
    }
  ],
  "expect": {"requests": 1, "files": 1}
}

The first counterbalanced read portfolio produced 4/4 correct one-call MCP runs with zero shell and failed calls. Its 27.97-second median was 13.8% lower than the CLI structural route's 32.44 seconds, not the hypothesized 2× and not the 30% keep threshold. Treat the tool as experimental and preserve that negative result when evaluating the next iteration.

The first self-hosted proof-carrying change resolved one definition and two call sites in 0.45 seconds. Its original fast profile accidentally ran the complete MCP suite and took 45.65 seconds. Changed-file verification reduced that gate to 2.69 seconds: 0.19 seconds for clj-kondo and 2.50 seconds for the style check. This is component evidence, not yet a 3x clean-agent result.

For an isolated evaluation, start the server with BENCH_MCP_PORT=7889 make benchmark-inspect-mcp; the harness creates a fresh temporary Codex home per run and writes only this registration there:

[mcp_servers.clj-surgeon]
url = "http://127.0.0.1:7889/mcp"
required = true
enabled_tools = ["inspect_clojure"]
default_tools_approval_mode = "writes"

The harness stops its own server on exit. Removing its external result directory removes the temporary Codex homes; it never replaces the user's global registration.

Check or remove the experimental entrance with:

make mcp-status
make uninstall-mcp-codex-dev

make mcp-status checks both loopback services. cclsp listens on port 7890 and clj-surgeon listens on port 7888. TypeScript changes reload under the stable cclsp URL. Run make mcp-reload for Clojure handler or tool-contract changes. The command preserves the MCP PID and URL, synchronizes the live registry, and reports before/after contract hashes.

http://127.0.0.1:7888/healthz is a functional readiness check. It returns success only when both the shared tool runtime and live tool registry are ready. A real bounded inspect_clojure request remains the authoritative post-reload probe.

A client that honors tools/list_changed re-lists without reconnecting. The current Codex turn can cache its model-visible schema text. Start a new session only when that cached schema prevents a required call. See CLAUDE.md for the exact hot/warm/cold boundary.

cclsp starts one lazy clojure-lsp child for each workspace that receives a semantic request. Shared onboarding configures a 10-second interactive request timeout and a separate 120-second cold-initialization timeout. Exact Surgeon source anchors include the owner-token position, so the primary semantic route calls textDocument/references without a redundant documentSymbol round.

Use cclsp's read-only inspect_runtime MCP tool before reading logs or inspecting processes. It returns a compact server summary and bounded structured state for one workspace or semantic request: runtime/config identity, LSP session, child PID, initialization state, outstanding calls, queue, recoveries, and the last initialization error. A timed-out semantic request is a typed refusal, not a reported success. The durable JSONL flight recorder is ~/.local/state/clj-surgeon/cclsp/server.log. Other workspace children and the shared cclsp parent remain running during a root-scoped recovery.

cclsp health also binds readiness to the stateful runtime generation. If core LSP source changed after that runtime was constructed, /healthz returns 503 with runtime_current=false and restart_required=true. Tool-only hot reloads keep the stable URL. restart_server can start an exact configured workspace whose previous LSP child already exited; it reports previous_exit=not-running and waits for replacement initialization before returning success. Hot-added workspaces publish their configuration immediately and begin one initialization lease on the first semantic request. Interactive calls have a 10-second deadline; the managed cold-start lease is 120 seconds. Caller deadlines and root-scoped recovery attach to that lease and never kill or multiply the initializing child. The shared launcher and runtime also publish an explicit UTF-8 locale. A missing locale is a readiness failure: under a launchd-style environment, Graal clojure-lsp can remain alive without answering initialize even for a tiny workspace.

make install remains the stable copied CLI-and-skills installation. It does not enable the experimental MCP server.

Teach coding agents

This repository ships a native project skill at .claude/skills/clj-surgeon/SKILL.md. make install copies the same canonical package to ~/.claude/skills/clj-surgeon for use outside this checkout. The root skill.md remains a compact legacy entrance for configurations that explicitly read a Markdown file. A test checks it against the canonical skill, with only its repository-relative reference links allowed to differ.

The canonical package keeps uncommon move, extraction, dependency, and CLJC guidance in references/advanced-operations.md. It keeps the complete process-starting CLI manual in references/cli-fallback.md. The default skill is a compact MCP-first contract. It tells agents to inspect the deferred tool catalog before they start a shell process.

The skill activates before an agent uses native Read, Edit, grep, sed, or cat on an existing Clojure file. It routes coherent reads to inspect_clojure, semantic graph questions to cclsp, and structural writes to apply_clojure_changes. It retains native Write for new files and native editing for unsupported transformations. The CLI reference is loaded only when MCP is unavailable, the operation is not exposed, or the CLI is under test.

make install also installs the same canonical package for Codex at ${CODEX_HOME:-$HOME/.codex}/skills/clj-surgeon. Do not maintain separate vendor copies. Tests require the installed Claude and Codex skill content to match the canonical package.

Operations

Read-only operations

:ls — See the skeleton of a namespace

clj-surgeon :op :ls :file src/writer/state.clj

Every top-level form with exact line boundaries, types, names, arglists, and forward reference detection. 236 forms in a 2768-line file, returned in ~200ms.

:cat — Read exact top-level forms from one or more files

Use a name:

clj-surgeon :op :cat :file src/writer/state.clj :form transition!

When several owner names in one file are known, read them from one parsed snapshot and preserve the requested order:

clj-surgeon :op :cat :file src/writer/routes.clj \
  :forms '[handle-sync-draft draft-conflict-response]'

:forms accepts a nonempty EDN vector of up to 50 unique unqualified names. The batch is all-or-nothing. If any name is invalid, missing, duplicated, or ambiguous, the command returns compact per-name evidence and no partial source. Combined source over 65,536 characters also refuses without partial source. Add one :platform to disambiguate the complete CLJC batch.

When known owners span files, send one manifest through stdin instead of one shell command per file:

printf '%s\n' \
  '{:reads [{:file "src/app/routes.clj" :forms [route-request conflict-response]} {:file "src/app/events.clj" :forms [publish-conflict! clear-conflict!]}] :expect {:file-count 2 :form-count 4} :limits {:source-chars 65536}}' |
  clj-surgeon :op :cat :spec-file - :format :semantic

The manifest preserves file and form order. It reads each distinct physical file once and returns one complete-file hash per file. :expect must declare the exact file and form counts. The command rejects unknown keys, duplicate physical paths, count mismatches, failed form selection, and output above the declared or hard limit. Every refusal returns no partial source. Use :spec-file PATH for a saved manifest. Inline :spec remains available for a small manifest, but stdin avoids shell escaping.

Always attach stdin in the same shell action. Do not invoke :spec-file - and wait to send the manifest later. :format :semantic is the compact behavior and architecture view. It prints canonical Clojure data with file hashes and a hard output cap. It omits comments and layout, and reader shorthand such as #() may expand. Omit :format when comments, layout, exact token spelling, or other lexical evidence matters; the default EDN result preserves exact source.

Or use a line contained by the form:

clj-surgeon :op :cat :file src/writer/state.clj :line 1134

Quote form names that contain shell metacharacters. The quotes belong to the shell command, not to the Clojure name:

clj-surgeon :op :cat :file src/convert.clj :form 'source->target'
clj-surgeon :op :cat :file src/convert.clj :form 'ready?'

Without the quotes, a shell can interpret > as redirection or ? as a glob before clj-surgeon receives the argument.

Or select the one form containing distinctive literal text:

clj-surgeon :op :cat :file src/writer/state.clj :contains :finish

Supply exactly one of :form, :forms, :line, or :contains. :contains searches for a case-sensitive literal substring, not a regular expression. It searches form source, strings, docstrings, and attached comments.

Multiple occurrences in one form succeed. Occurrences in multiple forms refuse with bounded candidate evidence. CLI values remain literal text, so a search such as :finish does not need an EDN-string workaround. For ambiguous reader-conditional definitions, add :platform :clj or :platform :cljs.

Single-form success returns the exact parsed form source, type, optional name, platforms, line range, attached-comment start, and complete-file source hash. Same-file batch success returns the records in an ordered :forms vector under one complete-file source hash. Cross-file success returns the ordered file results under :files. A missing or ambiguous selector returns structured EDN and exits nonzero. The command never chooses the first match.

:cat never dumps the complete file.

When a top-level name, containing line, or distinctive text is known, use :cat as the first source inspection instead of reconstructing a sed range. Do not run :ls solely as a preflight. Continue to use rg for broad textual discovery, :match-form for nested structural syntax, and bounded text reads for context that genuinely spans forms or files. Inside one Clojure file, use :cat :contains instead of rg -n followed by :cat :line. Do not print a large outline merely to discover the line.

:xray — Read and compute with one Clojure path

Use :xray :expr for literal structural reads and pure computation. A path without a terminal returns exact source evidence:

clj-surgeon :op :xray :file src/state.clj \
  :expr "(-> (form 'transition) (match :finish) right)"

Use (line N) when the enclosing top-level form has no configured name, but a physical source line identifies it. The line can be the form's opening line, an interior line, its closing line, or a contiguous comment immediately above it. Then select the exact nested syntax normally:

clj-surgeon :op :xray :file src/cache.clj \
  :expr "(-> (line 412) (match '(old-reader account-id)))"

(line N) selects the owner, not the nested leaf. A blank gap refuses with :line-not-in-form. A physical line shared by reader-conditional owners refuses with :ambiguous-form. Prefer (form 'NAME) when the semantic name is known. Use (line N) as a precise physical locator for otherwise unnamed top-level syntax.

End a literal path with expect-count to return exact source only when the cardinality matches:

clj-surgeon :op :xray :file src/policy.clj \
  :expr "(-> (form 'audit-report) initializer (expect-count 1))"

End the same path with analyze to derive an answer from the ordered selection vector. Put expect-count before it when cardinality must be exact:

clj-surgeon :op :xray :file src/policy.clj \
  :expr "(-> (form 'audit-report) initializer (expect-count 1) (analyze (fn [[report]] (frequencies (map :category (:events report))))))"

analyze always passes a vector of zero, one, or many selected values in query order. expect-count refuses before calling the function and does not change that input type. Therefore one selected vector is [[...]], which remains distinct from multiple selected scalar forms.

Use initializer after a named def to select its right-hand side directly. It returns zero matches for an unbound def or a non-def form and never evaluates constructor syntax.

The value contains parsed source syntax, not evaluated program state. For computed X-ray, a selected value that is itself a map literal or hash-map / array-map constructor receives one shallow canonical map view. Nested constructor syntax remains syntax.

The tool pairs already-parsed key/value forms. It never invokes a constructor or argument. Unsupported calls remain lists, malformed map constructors refuse, and literal X-ray plus evidence retain exact source. Return concrete EDN from computation. Realize lazy results with vec or another collection.

analyze always receives one vector of ordinary Clojure data. Write one terminating pure function over that contract instead of a separate shape-discovery query. When a predicate identifies the desired descendants, use (filter predicate (tree-seq coll? seq value)) inside that function.

Literal paths return full source evidence. Computed paths keep :value, addresses, ranges, trace, per-match hashes, a selection hash, and the complete-file hash without repeating selected source. Add :evidence :full when a computed read must also return exact selected source. Compact evidence is the default. New read workflows should use the Clojure X-ray surface above.

Named paths are file-aware. (form 'load-starred-post :cljs) and the EDN step [:form load-starred-post :cljs] select one reader-conditional branch in a .cljc file. The extension also constrains ordinary forms: a plain .clj form cannot match :cljs, and a plain .cljs form cannot match :clj. A platform selector without a .clj, .cljs, or .cljc context refuses. Without a platform, duplicate branch-local definitions remain honest multiple matches.

:xray uses the same sandboxed pure Clojure functions and structural builders as :edit :expr. It never writes source or creates a plan. It refuses truncated input, analyzer failure, lazy or non-EDN output, and output over 65,536 characters. SCI does not expose I/O, processes, namespaces, mutable references, classes, or host interop. X-ray is capability-limited, not termination-proof: analyzers remain responsible for bounded work.

The same right relationship moves from a case key, cond guard, map key, or binding name to its paired value. Navigation skips whitespace and comments while returned addresses still refer to the lossless concrete-syntax tree.

In the initial unprimed four-run checksum experiment, :xray was exact in four of four runs. The preceding workflow was exact in three of four. Median wall time fell 21%, shell calls 33%, input tokens 45%, and output tokens 43%. Routine literal reads remain cheaper than computation. The maximality audit then compared compact X-ray with an external Babashka pipeline and direct execution. See the experiment record.

Make the same path an updater by ending it with [:replace FORM]:

clj-surgeon :op :edit :file src/state.clj \
  :query '[[:form transition] [:find :finish] :right [:replace (assoc state :status :complete)]]' \
  :plan-out plan.edn

clj-surgeon :op :replace-subform! :plan plan.edn

The first command never writes source. It refuses unless the path selects exactly one node, reparses the candidate file, and emits the existing versioned, hash-bound single-edit plan with selector, trace, diff, source hash, and result hash. Review it, then run the separate apply command. Never chain plan and apply. Invalid queries return the compact supported-step grammar in the error, so an agent does not need a second --help call or a wall of generic help.

By default, the :edit front door authors the same plan with sandboxed pure Clojure:

clj-surgeon :op :edit :file src/state.clj \
  :expr "(-> (form 'transition) (match :finish) right (replace '(assoc state :status :complete)))" \
  :plan-out plan.edn

clj-surgeon :op :replace-subform! :plan plan.edn

This is Clojure manipulating Clojure data. :expr provides pure clojure.core collection functions—including let, destructuring, assoc, update, mapv, filter, reduce, comp, and juxt—plus structural query builders. SCI compiles the result to the existing query vector. The unchanged planner still enforces exact-one selection, one terminal edit, complete-file parse, diff, and hashes.

The important native operation is transform. It receives the selected form as Clojure data, so an agent can derive a replacement without first reading and copying the current value:

clj-surgeon :op :edit :file src/policy.clj \
  :expr "(-> (form 'retry-policy) (match :delays) right (transform #(mapv (partial + 100) %)))" \
  :plan-out plan.edn

The function runs only after the selector finds exactly one form. The review plan contains the concrete replacement as [:replace ...], never the function. The existing EDN executor can therefore read and replay the plan without SCI. Use replace when the new form is already known. Use transform when the new form depends on the selected code or data.

A literal replace or replace-span written inline in :expr preserves the exact replacement source. This includes anonymous-function shorthand, comments, commas, metadata, reader syntax, and multiline layout:

clj-surgeon :op :edit :file src/page.clj \
  :expr "(-> (form 'page) (match '{:dev-mode? dev-mode?}) (replace '{:dev-mode? dev-mode? :head {:asset-url #(views/static %)}}))" \
  :expect '{:dev-mode? dev-mode?}'

The planner retains that literal source beside the evaluated query and verifies that both describe the same replacement. A replacement computed through a local binding or transform has no literal source to preserve. The :query surface also contains data rather than source spelling. Those routes use canonical printing and still require plan review.

The returned :selector :query is semantic data, so it can display #() as an equivalent fn* form. This is not the planned source spelling. Read the edit's :after and :diff fields for the exact source that clj-surgeon will write.

Without :expect, :plan-out is required because the command is plan-only. Use an .edn suffix. Do not preflight whether that path exists. A successful plan atomically replaces that artifact. A refusal leaves an existing plan unchanged.

:expect is the optional one-call guarded form. It is the caller's declared before-state for a literal replacement. The comparison ignores whitespace. It does not ignore comments, metadata, reader macros, or token spelling. Those elements can affect behavior or preserve design intent.

On equality, :edit applies the plan in memory and returns its evidence merged with the verified apply receipt and :mode :expect-guarded. No plan filename is needed. Add :plan-out plan.edn only when an audit artifact must be retained. On any difference the command exits nonzero with :error-type :expect-mismatch, reports :expected, :actual, and :actual-source, and leaves both the source bytes and an existing plan artifact untouched:

clj-surgeon :op :edit :file src/state.clj \
  :expr "(-> (form 'transition) (match :finish) right (replace '(assoc state :status :complete)))" \
  :expect '(assoc state :status :done)'

The same guarded route works inside an unnamed top-level owner. This is one CLI call because the caller declares the exact old leaf:

clj-surgeon :op :edit :file src/cache.clj \
  :expr "(-> (line 412) (match '(old-reader account-id)) (replace '(new-reader account-id)))" \
  :expect '(old-reader account-id)'

The line narrows ownership. match selects the leaf. :expect verifies the old leaf before the tool writes atomically, reparses the complete file, and returns the read-back hash. Repeated copies in other top-level forms remain outside the selection.

Use shell single quotes around :expect source when possible. They prevent the shell from expanding $, backticks, and other source characters.

If the selected subtree contains a comment or metadata that is absent from :expect, the command refuses with :expect-mismatch. It returns the lossless :actual-source. Prefer a narrower replacement that leaves the surrounding syntax in place:

clj-surgeon :op :edit :file src/state.clj \
  :expr "(-> (form 'transition) (match :done) (replace :complete))" \
  :expect :done

This command replaces only :done. A comment elsewhere in the enclosing form stays in place. Do not rely on automatic comment migration. The tool refuses an undeclared comment because it cannot infer the correct destination after a larger rewrite.

:expect refuses a terminal transform. A transform computes its after-state, so the before-state alone cannot replace review of the generated diff. Remove :expect, review the saved plan, and apply it separately. Use a literal replace when both the before-state and after-state are known.

Without :expect nothing changes: :edit stays plan-only and the documented default remains plan first, review, then apply separately with :replace-subform!. :expect mechanizes that review gate rather than removing it—the saved plan is still the audit artifact—so use it when the before-state is already known exactly, and the two-command flow when it is not. The guarded receipt verifies the source write and the saved plan artifact. Atomic source writes preserve the existing file permissions, including the executable bit.

The SCI environment does not expose filesystem or process operations, namespace loading, mutable references, Java classes, or host interop. Supply exactly one of :query and :expr. Use the literal EDN query when it is shorter. Use pure Clojure when computation makes the selector or replacement clearer. Both surfaces save the same plan and use the same separate verified executor. A refusal includes the allowed capabilities and signatures, so a caller can repair the expression without a second help command.

When adjacent forms are themselves the meaningful object, promote the current node and its next semantic peer into a lossless slice with [:span 2]:

clj-surgeon :op :xray :file src/state.clj \
  :expr "(-> (form 'transition) (match :finish) (span 2))"

clj-surgeon :op :edit :file src/state.clj \
  :query '[[:form transition] [:find :finish] [:span 2] [:replace-span :finish (assoc state :status :complete)]]' \
  :plan-out plan.edn

clj-surgeon :op :replace-subform! :plan plan.edn

:span N contains the current semantic node and its next N-1 siblings and never crosses their parent. It counts through comments and whitespace without owning the trivia before or after the slice. :replace-span requires exactly N replacement forms and replaces corresponding nodes, preserving every intervening comment and whitespace byte. The same primitive makes a flattened anonymous-function body addressable—for example, [[:find select-keys] [:where {:parent-tag :fn}] [:span 3]]—without pretending that rewrite-clj contains a wrapper list that is not actually there.

When the task asks for every pair, use [:partition-all 2] instead of reading the owner, counting children, or issuing one query per key:

clj-surgeon :op :xray :file src/state.clj \
  :expr "(-> (form 'transition) (match 'case) up down right right (partition-all 2))"

[:partition-all N] starts at the current node and partitions it with all following semantic siblings. Each result is an existing lossless span with :forms, exact :source, addresses, gaps, and :partition {:size N :index I :complete? BOOLEAN}. The operation never crosses the parent and never drops a remainder. For a case, a final one-form span can be the default expression. The tool reports only that the span is incomplete; the caller decides what it means. A nested branch result remains one subtree.

If repeated nested heads make the first outer sibling unknown, promote every head to its owner and retain the maximal owners before partitioning:

clj-surgeon :op :xray :file src/policy.clj \
  :expr "(-> (form 'classify-request) (match 'cond) up outermost down right (partition-all 2))"

Placement matters. The symbol nodes selected by [:find cond] do not contain one another, but their owner lists do. Thus use :up :outermost, not :outermost :up. If the first outer guard is already known, anchor on it directly. That route is shorter.

Use :right for one known value, [:span 2] for one known pair, and [:partition-all 2] for all pairs in a sibling suffix. A terminal [:replace-span FORM ...] can update one unambiguous partition with equal arity. Multiple partitions refuse mutation, so enumeration never becomes a silent bulk edit.

This algebra removes the cat owner → reconstruct peer match → plan bridge. The existing commands remain useful standard-library spellings: :cat is the fastest exact top-level read, and :match-form / :replace-subform are concise when an independent subtree pattern already identifies the target.

:ls-tree / :tree / :map — Map an entire directory of repos

# Map a single project
clj-surgeon :op :ls-tree :dir .

# Search across ALL your repos in seconds
clj-surgeon :op :ls-tree :dir ~/src.local/ :grep "postgres|jdbc"

The operation discovers projects through deps.edn or project.clj, reads their :paths, and outlines each .clj file. With :grep, it uses ripgrep to select matching files before parsing. In one observed search across 20 repositories, this reduced a 90-second full scan to 3 seconds and about 3,000 tokens. An Explore agent took more than 80 seconds and 55,000 tokens for the same question.

See docs/ls-tree.md for full documentation.

:ls-deps — Transitive dependency tree

clj-surgeon :op :ls-deps :file state.clj :form transition!

The result shows the dependency chain as a nested tree. It identifies leaves, transitive dependencies, and cycles. Use it to assess a move or extraction.

transition! (line 2655)
├── log-event! (line 2600)
│   └── events-file ◆ leaf
├── log-wal! (line 2627)
│   └── wal-file ◆ leaf
├── transition-tx (line 2642)
│   └── settle-editing-state ◆ leaf
└── wal-snapshot ◆ leaf

:ls-extract — Minimal extractable unit

clj-surgeon :op :ls-extract :file state.clj :form rebuild-ai-paragraphs!

The result contains the target form and each private helper used only by that form. This is the minimum extraction set that preserves those dependencies.

:declares — Audit which declares are needed

clj-surgeon :op :declares :file src/writer/state.clj

:deps — Intra-namespace call graph

clj-surgeon :op :deps :file state.clj :form sync-draft!

:topo — Topological sort (optimal form ordering)

clj-surgeon :op :topo :file state.clj

CLJ, CLJS, and CLJC operations

Reader conditionals require structural handling. A form inside #?(:clj ...) is not an ordinary list at the top level. Free-form edits can create malformed splices, inconsistent aliases, or missing :refer lists.

These operations are deterministic and refuse to emit malformed reader conditionals. The read-only operations :ls, :deps, :topo, :ls-deps, :ls-extract, and :declares are reader-conditional-aware. For example, a (defn foo …) inside #?(:clj …) has :platforms [:clj] in the outline and participates in dependency analysis.

:cljc-merge — Combine parallel CLJ + CLJS files into one CLJC

clj-surgeon :op :cljc-merge :clj src/foo.clj :cljs src/foo.cljs :out src/foo.cljc

The operation handles shared and one-sided requires, including #?@(:clj […]) and #?@(:cljs […]). It handles divergent aliases, divergent :refer lists, npm string requires for :cljs, and per-form body collisions as #?(:clj … :cljs …). It refuses namespace docstrings, attribute maps, unsupported subforms, name mismatches, and body-count mismatches.

:cljc-split — Inverse of merge

clj-surgeon :op :cljc-split :file src/foo.cljc :clj-out src/foo.clj :cljs-out src/foo.cljs

Round-trip-tested: (merge → split → merge) is a fixed point.

:cljc-add-require — Platform-aware require addition

clj-surgeon :op :cljc-add-require :file src/foo.cljc \
  :platform :cljs :ns goog.string :as gstr :out src/foo.cljc

:platform is :clj, :cljs, or :cljc. The operation refuses to bind one alias to two namespaces on the same platform. It preserves npm string literals, so :ns "react" remains a string.

:cljc-analyze — Structured classification for LLM consumption

clj-surgeon :op :cljc-analyze :clj src/foo.clj :cljs src/foo.cljs    # pair
clj-surgeon :op :cljc-analyze :file src/foo.cljc                      # single CLJC

The result contains EDN buckets for shared, one-sided, and divergent requires. It also contains per-platform top-level form summaries.

Write operations

:change / :change! / :undo-change! — Compile one complete plan

Use one transaction when the complete plan is already known. A scoped change states the files, optional unique owner forms, exact structural target, literal replacement, and exact consent counts. The aggregate expectation guards the complete plan.

clj-surgeon :op :change! :spec-file - \
  :receipt-out /tmp/ui-change.edn <<'EDN'
{:changes
 [{:id :body-class
   :in ["src/ui.clj"]
   :forms [shell reader]
   :find ":body"
   :do [:replace ":body.page"]
   :expect {:matches 2 :each-form 1}}]
 :expect {:changes 1 :edits 2 :files 1}}
EDN

This follows the kubectl apply -f - convention: command-line arguments select the operation, stdin carries the large structured document, and stdout returns the compact receipt. Use :spec-file PATH for a saved document. Inline :spec remains compatible for small plans, but it is not the primary agent route.

All changes compile against the same original snapshots. :find matches exact structural source inside the explicit files and optional owner forms. Whitespace can differ. Comments, metadata, reader macros, token spelling, and collection type must match. :each-form proves the match distribution across owners. :each-file proves it across files. A named owner must resolve exactly once in each scoped file.

The first scoped-change slice supports [:replace SOURCE]. It does not yet support relational :path, captures, insertion, deletion, computed transformations, regular expressions, or fuzzy matching. Use :edit for one relational or computed change. Use the specialized move, dependency, require, rename, and CLJC operations for their stronger contracts.

Any count mismatch, overlap, invalid future file, or stale hash refuses the complete transaction. A handled write or receipt-publication failure restores transaction-owned bytes. Recovery never overwrites unknown concurrent bytes.

Use the non-mutating command when the combined diff needs review:

clj-surgeon :op :change :spec-file plan.edn

Successful :change! output is compact. The durable receipt contains concrete inverse edits and original/result hashes. Undo refuses before writing when any current file differs from its recorded forward result:

clj-surgeon :op :undo-change! :receipt /tmp/api-change.edn

Do not open the receipt file. The :change! stdout contains the counts, hashes, verification, receipt path, and inverse summary needed for the decision. Pass the saved path directly to :undo-change!.

Do not split one known multi-edit plan into repeated :edit or :replace-subform! calls. Use those operations when the replacement must be computed from selected source or reviewed as one independently meaningful edit.

Legacy exact :intents with :files, :from, :to, :expect-count, and the original aggregate expectation keys remain accepted. Do not mix :intents and :changes in one transaction.

:fix-declares / :fix-declares! — Eliminate unnecessary declares

clj-surgeon :op :fix-declares :file state.clj     # plan (dry run)
clj-surgeon :op :fix-declares! :file state.clj    # execute

The compound operation:

  1. Finds removable declares via topological sort
  2. Checks each form's dependencies at the destination
  3. Pulls leaf deps along with their dependents (no Whac-A-Mole)
  4. Moves safe defns above their first callers
  5. Deletes stale declare lines
  6. Skips truly unsafe moves (non-leaf dep chains) with warnings

:mv — Reorder a form within a file

mv writes its input file directly unless :dry-run true is present. An LLM should use this branching workflow, not jump straight to :mv-with-deps:

# 1. Orient and preview the exact requested move. Neither command writes.
clj-surgeon :op :ls :file state.clj
clj-surgeon :op :mv :file state.clj :form foo :before bar :dry-run true

# 2a. Only when the preview returns :ok true, review :plan/:diff and execute.
clj-surgeon :op :mv :file state.clj :form foo :before bar

Moves a named form (including its preceding comment header) before another form. Before writing, :mv validates the complete candidate. If the move would strand a dependency or caller, it refuses with structured EDN, changes no bytes, and exits nonzero.

A dependency refusal is a decision point for an agent:

{:error-type :would-strand-dependencies
 :form "foo"
 :before "bar"
 :stranded [{:name "config"
             :defined-at 80       ; original source line
             :would-be-at 94      ; line in the rejected candidate
             :required-before 40}]
 :recommended-action :preview-dependency-closure
 :recommended-command
 "clj-surgeon :op :mv-with-deps :file state.clj :form foo :before bar :dry-run true"
 :apply-command
 "clj-surgeon :op :mv-with-deps :file state.clj :form foo :before bar"}

Run the safe :recommended-command, then inspect all of :plan/:added-forms, :move-order, and :diff. Execute :apply-command only after consenting to every added form. The successful preview repeats :apply-command, so it is self-contained. :mv-with-deps always forces :with-deps true, even if a caller also supplies :with-deps false.

# 2b. Dependency refusal only: preview the widened move, then apply after review.
clj-surgeon :op :mv-with-deps :file state.clj :form foo :before bar :dry-run true
clj-surgeon :op :mv-with-deps :file state.clj :form foo :before bar

:mv-with-deps is the opt-in alias for :mv :with-deps true. It moves only the minimum transitive dependency closure required at the selected destination, lists every added form in the plan, and never silently adds declarations or moves callers. For :would-strand-users, cycles, ambiguity, unsupported source layouts, or any other refusal, stop and choose a different destination or refactor. Do not force the alias.

A dry run is an informational preview, not a saved hash-bound application artifact. Preview again after any source change. After writing, rerun :ls, audit any now-redundant declaration with :declares or :fix-declares, and run the target repository's formatter, linter, compiler, and tests. Shell-quote names containing special characters, for example :form '*state*'.

:rename-ns / :rename-ns! — Rename a namespace prefix

clj-surgeon :op :rename-ns :from old-prefix :to new-prefix :root .   # plan
clj-surgeon :op :rename-ns! :from old-prefix :to new-prefix :root .  # execute

Walks every .clj file's AST. Renames ns declarations and :require entries structurally (not text replace). Computes file moves. Flags non-Clojure files for manual review. This is how clj-surgeon renamed itself.

:extract / :extract! — Move forms to a new namespace

clj-surgeon :op :extract :file src/writer/state.clj \
  :forms '[rebuild-ai-paragraphs! enter-distillery!]' \
  :to src/writer/state/distillery.clj    # plan (dry run)

clj-surgeon :op :extract :file src/writer/state.clj \
  :forms '[rebuild-ai-paragraphs! enter-distillery!]' \
  :to src/writer/state/distillery.clj \
  :require-policy :copy-all              # conservative plan

clj-surgeon :op :extract! :file src/writer/state.clj \
  :forms '[rebuild-ai-paragraphs! enter-distillery!]' \
  :to src/writer/state/distillery.clj \
  :receipt-out /tmp/state-extraction.edn # execute

clj-surgeon :op :undo-extract! \
  :receipt /tmp/state-extraction.edn     # guarded inverse

The compound extraction operation:

  1. Moves the exact supplied forms; use :ls-extract first to choose a complete dependency closure
  2. Compiles the target namespace header from dependencies used by those forms
  3. Adds a collision-free source require only when remaining source forms call moved Vars; its sorted :refer list preserves unqualified calls
  4. Compiles and parses both complete future files from one source snapshot
  5. Commits both files atomically, verifies read-back hashes, and rolls back a handled partial failure
  6. Reports callers that may need updating, including exact authority-labeled #'name and (var name) references that semantic indexes can omit
  7. Optionally publishes a guarded inverse receipt

The preview reports :target-requires, :omitted-target-requires, :remaining-source-callers, and :source-referred-forms. Direct vector libspecs are minimized from free-symbol evidence. An unproved side-effect-only, reader-conditional, prefix-list, or comment-bearing require shape refuses with :unsupported-require-minimization; clj-surgeon never silently copies or drops it.

Extraction separates movement from dependency cleanup. The default :require-policy :minimal proves the target's exact requirements and refuses when that proof would lose source meaning. Use :require-policy :copy-all when the first goal is a behavior-preserving move: it copies the complete source namespace header, including comments, reader conditionals, imports, and side-effect-only requires, and changes only the namespace name. The resulting target can have excess dependencies by design. Compile and test the move, then minimize dependencies as a separate, reviewable change. Both policies keep the same parse, hash, atomic-write, read-back, and undo guards.

Planning is pure. Only :extract! writes files. It refuses an existing target, a stale source snapshot, an invalid candidate, or a receipt that aliases either source path. :undo-extract! restores the source and removes the new target only while both still match the receipt. Compile and test both namespaces after extraction; other namespaces in :callers-to-review still require an explicit migration decision.

:match-form / :replace-subform — Nested structural edits

Use :match-form for file-wide structural search. It is not a text regular expression. Matching ignores formatting, and _ matches exactly one subtree:

clj-surgeon :op :match-form :file src/views.clj \
  :match '(post! "/api/items" _)'

Add :inside only when the containing top-level form is already known or when you need to narrow multiple matches:

clj-surgeon :op :match-form :file src/views.clj :inside render \
  :match '(post! "/api/items" _)'

Plan exactly one replacement, review the EDN and diff, then apply that exact hash-guarded plan:

clj-surgeon :op :replace-subform :file src/views.clj :inside render \
  :match '(post! "/api/items" _)' \
  :with '(items/actions surface)' \
  :plan-out plan.edn

clj-surgeon :op :replace-subform! :plan plan.edn

Search reports every match. Replacement refuses zero or multiple matches. Each search match includes its enclosing top-level name in :inside when one is mechanically available. Reuse that value directly to narrow a replacement. Do not run :cat :line merely to recover the owner.

:match and :with must each contain exactly one complete Clojure form—trailing syntax is an error. The plan is a versioned EDN artifact with stable :operation, :selector, hash, edit, diff, and provenance fields. Its :diff is a standard unified diff, suitable for human or agent review.

Application validates the plan version, source snapshot, recorded address, exact before text, complete rewritten-file parse, and result hash. It then atomically replaces the target file and reads it back.

A successful EDN receipt includes :applied-edit, :verified whole-file parse, :read-back-hash, and atomic-write evidence. Do not repeat those checks with rg, :cat, git diff, or shasum. The reviewed plan is the edit-level diff. Continue with the relevant formatter, linter, compiler, and tests.

Review an aggregate Git diff only if the task already establishes a worktree or explicitly requests the diff. Do not probe .git only to repeat edit-level evidence. If atomic replacement is unavailable, the command fails. It does not fall back to a weaker write. Every error is concise EDN and exits nonzero.

Apply the reviewed plan directly with :replace-subform!. Do not edit the plan with apply_patch or another text tool. If the intended edit changes, generate a new plan.

A case clause, cond branch, map entry, or binding pair is adjacent sibling syntax, not a synthetic wrapper list. When one peer identifies another, use :xray navigation rather than reconstructing a match from the whole owner. Use :replace-subform when an independently readable value or expression already identifies the target exactly. See issue #21.

Run plan generation as a standalone shell command. Observe and review its result before a separate apply command. Never chain planning and application.

In its first production use, :ls reduced a 4,036-line, 322-form namespace to the relevant synchronization and admission forms. Three :find-subform queries then uniquely located the Save Draft Hiccup vector, an unsafe browser-sync mutation, and a session-save call by semantic path. The practical division was clear. rg remained faster for broad discovery, while structural search was substantially safer for repeated Hiccup actions and large handler forms. This first report validated discovery. The follow-up below validated the replacement workflow separately.

A follow-up production edit supplied that proof: :find-subform uniquely selected a 13-line inline-JavaScript “Edit in Draft” button, and the reviewed plan replaced it with a shared single-flight command call. :replace-subform! returned the exact result hash recorded by the plan.

When a replacement contains Clojure that generates JavaScript, quote the whole :with form with single shell quotes and use pr-str for generated JavaScript string literals:

clj-surgeon :op :replace-subform \
  :file src/writer/views/book_workshop.clj \
  :inside editor-panel \
  :match '[:button {:style _ :onclick _} "Edit in Draft →" [:span.kbd-hint _ "^Enter"]]' \
  :with '[:button {:onclick (str "editBookNodeInDraft(" (pr-str id) ",this.closest(" (pr-str ".bw-editor-panel") "))")} "Edit in Draft →"]' \
  :plan-out plan.edn

JavaScript \xNN escapes are not valid EDN/Clojure string escapes. Prefer pr-str, or valid Clojure escapes such as \", \\, and \u0027.

A broader session-history ethnography found 38 direct production structural-query invocations across UI, state, storage, and route work. Beyond replacement, agents used zero/one match results as executable hypotheses about code shape and used sequential hash-bound plans to refactor a fast-moving dirty worktree safely.

Custom defining forms

Clojure codebases can define forms with macros such as Guardrails >defn and >defn-, Malli mu/defn, and Schema s/defn. Projects can also define macros such as defendpoint, defenterprise, and defsetting.

The forms.clj classification module defines what counts as a definition, what has arglists, and what is private. The operations :ls, :deps, :topo, :ls-deps, :ls-extract, :extract, and :fix-declares use this module.

Classification has three rules:

  1. Match core forms such as defn, defn-, def, defonce, and defmacro exactly.
  2. For a qualified form such as mu/defn, s/defn, or malli.util/defn-, remove the qualifier and match the local name. Thus mu/defn, m/defn, and malli/defn all classify as :defn.
  3. List non-standard names such as >defn and >defn- in forms.clj as explicit aliases.

Before you add support for a macro, check whether qualification already handles it:

;; Namespace-qualified forms just work — no config needed
(mu/defn foo ...)     ; ✓ auto-detected: local name "defn" → :defn
(s/defn bar ...)      ; ✓ auto-detected: local name "defn" → :defn
(my.lib/defn- baz ..) ; ✓ auto-detected: local name "defn-" → :defn- (private)

If the macro has a non-standard local name, add one entry to explicit-aliases in src/clj_surgeon/forms.clj:

(def explicit-aliases
  {">defn"          :defn
   ">defn-"         :defn-
   "defendpoint"    :defn      ;; your custom macro here
   "defenterprise"  :defn})

Agent workflow

The Claude Code and Codex entrances load one canonical skill package. That skill teaches these common routes:

  • Before native source tools touch an existing Clojure file, load the skill; retain native Write for new files and unsupported prose- or comment-heavy changes.
  • For an unknown large file, use :ls, then read only the required forms.
  • For a known form, line, or distinctive text, start with :cat.
  • For nested syntax in an unnamed top-level form, start an :xray or :edit expression with (line N), then narrow to the exact subtree.
  • For a known exact multi-edit plan, use one :change! transaction. Use :change when the combined diff needs review first.
  • For a computed single edit, generate and review one plan before applying it.
  • For a declaration, run :fix-declares before deciding whether to apply :fix-declares!.
  • For an extraction, inspect :ls-deps and :ls-extract before planning the write.
  • For a namespace rename, review :rename-ns before running :rename-ns!.

The tool performs parsing, selection, movement, and require rewriting. The agent decides scope and intent.

Repeated agent-usage studies

The repo-local study-agent-usage skill turns the recurring Codex-versus-Claude ethnography into one bounded command:

make study-agent-usage

The canonical package lives under skills/study-agent-usage. The repository exposes it to Codex through .agents/skills/ and to Claude Code through .claude/skills/, both as links to that one package.

It scans both providers from the newest completed study marker, prints a compact aggregate, and writes the complete versioned JSON receipt to the reported temporary path. Pass --receipt-out PATH through AGENT_USAGE_ARGS to retain it elsewhere. Neither surface contains transcript prose or workspace paths. The receipt distinguishes skill visibility, skill loading, real clj-surgeon invocations, native Clojure actions, direct tool wall, and complete Codex turn wall. A completed study records the emitted next_marker, which becomes the next automatic "since last time" boundary. Run make study-agent-usage-self-test to verify both history parsers and the privacy contract.

Development method

Features begin with observed agent behavior on real tasks:

  1. Give a clean agent a bounded task.
  2. Record unnecessary reads, guesses, help calls, unsafe plans, and repeated work.
  3. Separate mechanical bookkeeping from decisions that require judgment.
  4. Add the smallest general structural primitive that removes the mechanical work.
  5. Add a named regression, exhaustive pure tests, CLI contract tests, and a real-program-derived fixture.
  6. Repeat the clean-context task and preserve the transcript.

The observation records contain the resulting experiments and failure reports.

Why structural operations help

rewrite-clj provides a position-aware concrete-syntax tree. clj-surgeon can therefore walk and replace syntax nodes while preserving comments and formatting.

Operation Structural leverage Typical alternative
Namespace rename Symbol-node replacement IDE or language-server refactor
Dependency graph Walk resolved form references Full language server
Topological sort Reuse the dependency graph Separate semantic analysis
Dep tree visualization Project the same graph Separate indexing layer
Forward ref detection 1 clj-kondo call Custom parser + resolver
Form boundaries Built into rewrite-clj Tree-sitter + custom queries
Require inference Namespace/form data Module resolution engine
Leaf dep-pulling Reuse dep graph + form mover Compound IDE refactor

Babashka bundles the parser and printer. Analysis composes collection operations, and transforms use zipper replacements. This provides reusable structural leverage without requiring a complete language-server refactor.

The long-open clojure-lsp move-form issue #566 shows the broader problem. clj-surgeon handles bounded bookkeeping and leaves scope decisions to the caller.

Failures that shaped the tool

Real use on a 2,768-line production file exposed these defects. Each fix added a regression test:

  1. :mv matches declare, not defnfind-form hit the first name occurrence. Fixed: skip declare forms.
  2. z/next infinite walk — traversed entire file, not just one form. Fixed: scope zipper to form subtree.
  3. Topo sort reversed — Kahn's algorithm on wrong edge direction. Fixed: process zero-dependency forms first.
  4. Metadata blindness(def ^:private events-file ...) returned "^:private" as the name. Fixed: detect :meta node tag, walk to rightmost child.
  5. Declares in dep graph(declare foo) returned empty deps, masking real (defn foo) deps. Fixed: exclude declares from analysis.

See docs/observations/ for the complete build-use-fix records.

Testing

make test

The complete gate keeps two runtimes explicit: Babashka runs the fast pure structural/core suite; the JVM MCP suite runs the real formatter, nREPL, HTTP, process, and hot-reload boundaries. make test runs both plus smoke and benchmark self-tests. JVM-only MCP namespaces are not duplicated inside the Babashka runner, so adding a real JVM boundary cannot silently break the fast suite or remove the boundary proof.

Pure analysis functions take strings, zippers, or data and return data. Tests exercise those functions with literals. Filesystem and subprocess tests cover only contracts that require those boundaries. Write tests verify that refusal does not change bytes and that a successful candidate parses and passes the appropriate lint or compile check. See docs/testing-guidelines.md.

The transaction, extraction, retained-basis, quoted-reference, and diagnostic delta decisions are pure compilers over captured values. Their tests enumerate ordering, ambiguity, budget, stale-evidence, trivia, inverse, and refusal states without starting a service or creating a project. A smaller retained integration layer proves snapshot capture, processes, atomic writes, MCP serialization, and read-back. Tests are never weakened when logic moves into a pure core; the real boundary proof remains.

Architecture

src/clj_surgeon/
  forms.clj          # single source of truth for defining-form classification
  core.clj           # CLI entry point, :op dispatch
  outline.clj        # rewrite-clj form boundary parser (CLJC-aware)
  forward_refs.clj   # clj-kondo forward-ref detection
  move.clj           # form reordering within a file
  analyze.clj        # dep graph, topo sort, dep tree, closure (CLJC-aware)
  rename.clj         # namespace prefix rename (AST surgery)
  fix_declares.clj   # compound op: eliminate removable declares + pull leaf deps
  extract.clj        # compound op: move forms to a new namespace file
  cljc/
    walk.clj         # reader-conditional-aware top-level form walker
    merge.clj        # CLJ + CLJS → CLJC (divergent aliases, npm, body collisions)
    split.clj        # CLJC → CLJ + CLJS (inverse of merge)
    require_ops.clj  # platform-aware add-require (with alias-collision detection)
    analyze.clj      # structured CLJC classification for LLM consumption

The installed CLI requires Babashka and clj-kondo. Babashka supplies rewrite-clj and Cheshire in its runtime. clj-kondo supplies the static forward-reference boundary.

Prior art and acknowledgments

  • rewrite-clj, created by Yannick Scherer (@xsc) and maintained by Lee Read (@lread), provides the zipper and concrete-syntax tree.
  • Babashka, created by Michiel Borkent (@borkdude), supplies the runtime, rewrite-clj, and Cheshire.
  • clj-kondo, also created by Michiel Borkent, supplies forward-reference analysis.
  • Eric Dallo's ECA work and the long-open clojure-lsp#566 helped define the boundary between bookkeeping and judgment.
  • Dan Peddle's cclsp showed how many structural operations an agent-facing Clojure tool can expose.

Design principle: bookkeeping, not judgment

clj-surgeon performs bounded mechanical work. The caller decides what to move, where to move it, and whether to apply a reviewed plan. The compiler and tests then provide external feedback.

This boundary keeps the tool general. It also prevents the tool from silently guessing intent or expanding the requested scope.

Compatibility aliases

Existing callers can continue to use :outline, :show-form, :find-subform, :grep-form, :lens, and :q. New callers should use :ls, :cat, :match-form, :xray, and :edit.