Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

recast

recast is a CLI for safe, atomic, transparent multi-file text rewrites. Pure Rust. Tuned for LLM coding agents driving mechanical edits; equally usable by humans for mechanical refactors.

Why it exists

LLM agents and shell scripts that rewrite code typically fall back to sed, sd, or a Python heredoc. All three share two silent failure modes:

  1. Silent no-match. The pattern misses, the tool exits 0, the agent moves on assuming success.
  2. Non-idempotent re-runs. Re-running a rewrite on already-rewritten text either no-ops (looks like the first run failed) or, worse, compounds the rewrite.

recast makes both impossible by default. A missing match is a non-zero exit; a non-convergent rewrite is rejected outright.

What it does

Capabilitysed / sdPython heredocrecast
Multi-file rewritemanual / yesyesyes
Match-required guardnonoyes
Idempotency checknonoyes
Atomic two-phase applynonoyes
Diff preview by defaultnonoyes
Crash-recovery sweepnonoyes
Agent-friendly JSONnonoyes
Regex patternyesyesyes
Script pattern (Rhai)noyes (Python)yes
Structural (AST)nonoyes (tree-sitter)

Status

Alpha. Tracked in PLAN.md; release notes in CHANGELOG.md.

Install

Pre-built binary

Grab the matching artifact from the Releases page.

PlatformArtifact
Linux x86_64 (glibc)recast-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz
Linux x86_64 (musl)recast-vX.Y.Z-x86_64-unknown-linux-musl.tar.gz
Linux aarch64 (glibc)recast-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz
Linux aarch64 (musl)recast-vX.Y.Z-aarch64-unknown-linux-musl.tar.gz
macOS Intelrecast-vX.Y.Z-x86_64-apple-darwin.tar.gz
macOS Apple Siliconrecast-vX.Y.Z-aarch64-apple-darwin.tar.gz

The musl builds are statically linked — drop into Alpine, distroless, or scratch containers without a glibc dependency.

Each archive ships with a .sha256 sidecar; verify before extracting:

shasum -a 256 -c recast-v0.1.3-x86_64-unknown-linux-gnu.tar.gz.sha256
tar -xzf recast-v0.1.3-x86_64-unknown-linux-gnu.tar.gz
sudo install -m 0755 recast /usr/local/bin/

Cargo install (crates.io)

cargo install recast-cli

The crate is published as recast-cli on crates.io (the bare recast name was already claimed by an unrelated serialization library). The installed binary is still called recast — every recast … command in these docs works as written.

The stock install ships every grammar, the Rhai script engine, and JSON output. Slim it down with --no-default-features and pick only the features you actually use — see Cargo features.

cargo install recast-cli --no-default-features --features lang-rust

From source

git clone https://github.com/Stoica-Mihai/recast
cd recast
cargo install --path crates/recast

Shell completions

recast --completions bash  > /etc/bash_completion.d/recast
recast --completions zsh   > ~/.config/zsh/completions/_recast
recast --completions fish  > ~/.config/fish/completions/recast.fish

Also supported: elvish, powershell.

Verify

recast --version
# recast 0.1.3

First rewrite

The three-step flow agents and humans both use.

1. Preview

recast 'OldName' 'NewName' src/

Output:

--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1 @@
-fn OldName() {}
+fn NewName() {}
recast: 1 file(s) would change, 1 match(es) across 8 scanned.

No writes happen until you pass --apply.

2. Apply

recast --apply 'OldName' 'NewName' src/

Output:

recast: applying 1 file(s), 1 match(es).

Under the hood: every file is staged in a sibling .recast.tmp.N (written + fsync’d), then renamed into place via a per-file original → .recast.bak.N / temp → original swap. A failure at any step reverse-renames every committed file from its backup, leaving the tree bit-identical to the pre-image. See Safety guarantees.

3. Re-run is safe

recast --apply 'OldName' 'NewName' src/
# recast: already applied; no changes needed.

recast checks convergence (re-applying the pattern to its own output produces no further change) and reports “already applied” with exit 0, so retrying a rewrite in CI or from an LLM-agent retry loop is safe.

If the pattern is non-convergent (e.g. 'a' -> 'aa'), recast refuses with a non_convergent error before touching any file.

Regex mode

Default mode. Powered by the regex crate, so the syntax is Perl-compatible minus lookaround (catastrophic backtracking is gone too — regex is linear-time).

Multi-line by default

. matches \n by default (implicit (?s)); --single-line (-s) turns that off. This matches what LLMs usually expect.

Capture interpolation

recast 'fn (\w+)_old\b' 'fn ${1}_new' src/

$1, ${name} interpolated. To treat the pattern and replacement as literal text, pass --literal (-L).

Case-insensitive

recast -i 'todo' 'TODO' .

Match-count guard

--at-least N (default 1) and --at-most N (default unbounded) bracket the total matches across all files. Violations exit 2.

recast --at-least 5 'foo' 'bar' src/       # require ≥5 matches
recast --at-most 0 'TODO' 'FIXME' src/     # CI gate: there must be zero TODOs
recast --at-least 0 'maybe' 'def' src/     # allow zero matches (no guard)

Idempotency check

The plan step reapplies the pattern to its own post-image. If any file would change again, recast aborts with non_convergent — the rewrite isn’t safe to run twice. Override with --allow-non-convergent if you know what you’re doing.

Examples of patterns recast rejects:

  • 'a' -> 'aa' (grows on every run)
  • 'foo' -> 'foofoo'

Examples it accepts:

  • 'old' -> 'new'
  • 'fn (\w+)_old' -> 'fn ${1}_new'

Filters

recast -t rust 'Old' 'New' .                # only Rust files
recast -T markdown 'Old' 'New' .            # everything except Markdown
recast -g '!vendor/**' 'Old' 'New' .        # exclude vendor dir
recast --no-ignore 'Old' 'New' .            # bypass .gitignore
recast --hidden 'Old' 'New' .               # include dot-files
recast --max-bytes 102400 'Old' 'New' .     # skip files > 100KiB
recast --max-files 50 'Old' 'New' .         # cap total file count

-t / -T accept the same shorthand vocabulary as ripgrep (rust, js, py, markdown, …). -g accepts ripgrep-style include/exclude globs.

Stdin mode

echo 'fn old_name() {}' | recast --stdin 'old_name' 'new_name'
# fn new_name() {}

Read one buffer, rewrite once, write to stdout. Skips the walker and commit phases. The match-count guard still applies.

Script mode (--script)

When regex’s mad-libs template ($1, ${name}) can’t compute the replacement, drop in a Rhai script callback.

Quick example: version bump

cat > bump.rhai <<'RHAI'
(parse_int(captures[1]) + 1).to_string()
RHAI

echo "version 3" | recast --stdin --script bump.rhai '(\d+)' ''
# version 4

API the script sees

BindingTypeMeaning
capturesArraycaptures[0] is the full match, captures[1..] are the named/numbered groups in order
wholeStringAlias for captures[0] (match is a Rhai keyword)

The return value is coerced to string and used as the replacement.

Conditional rewrites

if captures[1] == "old" {
    "new"
} else {
    captures[1]                 // keep as-is
}

Uppercase a capture

captures[1].to_upper()

Mode notes

  • The positional REPLACEMENT argument is still required when using --script (pass ""); its value is ignored.
  • Scripted scans run sequentially — the Rhai engine isn’t Sync. That’s usually fine since --script runs are dominated by per-script work, not file I/O. Use plain regex mode for big trees.
  • Sandbox limits: 1 M operations, 1 MiB strings, 1024 array entries, expression depth 64.
  • Match-count guard, idempotency check, atomic apply, and recovery all still apply.

Structural mode (--lang)

Tree-sitter-backed AST matching. Pick a language with --lang, then pass either a friendly --ast pattern or a raw tree-sitter --query.

Supported languages

LanguageCLI nameCargo feature
Rustrust, rslang-rust
TypeScripttypescript, tslang-ts
TSXtsxlang-ts
JavaScriptjavascript, js, jsxlang-js
Pythonpython, pylang-python
Bashbash, sh, shelllang-bash
Gogo, golanglang-go
JSONjsonlang-json
Markdownmarkdown, mdlang-md

YAML / TOML are pending — the upstream tree-sitter grammars don’t yet target the v0.25 ABI recast uses.

Friendly --ast patterns

Write the pattern in the target language with $NAME (single-node) and $$$NAME (variable-shape subtree) metavars:

recast --lang rust --apply \
  --ast 'fn $NAME($$$ARGS) { $$$BODY }' \
  '' 'fn ${NAME}_v2$ARGS $BODY' \
  src/

Matches every function regardless of signature or body shape; renames it and keeps the original args + body verbatim.

Metavar rules

  • $NAME matches a single AST node at the placeholder’s position.
  • $$$NAME matches whatever the surrounding node contains (any number of statements, params, fields, etc.). The capture text is the whole wrapper node: { $$$BODY } captures { ... }, not just the inside. Templates therefore should not re-add the wrapper: write $BODY, not { $BODY }.
  • Literal identifiers in the pattern (anything that isn’t a metavar) must match exactly — fn old_name() {} only matches fn old_name() {}, not every nullary empty-body function.

Template substitution

Templates use the same $NAME / ${NAME} substitution rules. ${NAME} is needed when the name is followed by _ / alphanumeric characters that would otherwise extend the identifier:

fn ${NAME}_v2     # explicit boundary
fn $NAME_v2       # error: no capture named `NAME_v2`

Raw --query patterns

Pass a tree-sitter S-expression query directly. Use this when you need predicates (#eq?, #match?, …) or want to scope a match to a specific node kind:

recast --lang rust --apply \
  --query '((identifier) @id (#eq? @id "old_name"))' \
  '' 'new_name' src/

The capture named @root (or, absent that, the outermost capture in each match) defines the byte range to replace. Templates can reference any captured node by name ($id, ${id}).

Deleting items with their attributes (--include-leading-attrs)

Deleting a function with a plain match replaces only the function_item node — its #[test] / #[cfg(...)] attributes and /// doc comments are siblings, so they survive as orphans:

# leaves an orphaned `#[test]` behind
recast --lang rust --apply --ast 'fn drop_me() {}' '' '' src/

--include-leading-attrs extends each match backward over the contiguous run of preceding attribute_item / doc-comment siblings, so the attributes and docs go with the item:

recast --lang rust --apply --include-leading-attrs \
  --ast 'fn drop_me() {}' '' '' src/

A blank line ends the run (an attribute separated from the item by an empty line is treated as detached and left in place), and plain // / /* */ comments are never swallowed — only doc comments (///, //!, /**, /*!). The node kinds are Rust’s; languages without attribute_item simply never extend. MCP: include_leading_attrs: true on recast_structural.

Error messages

When a query fails to compile, recast surfaces a line/column-pinned error:

recast: structural: query error: tree-sitter query unknown node
  type error at line 1, column 2: zzz
  | (zzz) @x
  |  ^

When a friendly --ast pattern fails to parse, the error mentions which grammar choked and what the legal positions for metavars are.

When to use what

  • Identifier-level renames across a tree--query '((identifier) @id (#eq? @id "X"))'.
  • Whole-construct rewrites that need shape--ast.
  • Cross-cutting transforms that need predicates or alternatives → raw --query with #eq?, #match?, etc.

For anything more complex than what tree-sitter Query expresses, ast-grep or comby are richer. recast’s structural mode is intentionally minimal — it trades expressiveness for the same atomicity / idempotency / JSON-output guarantees the other modes have.

Agent rules snippet

If you’ve installed recast-mcp for an AI agent (Claude Code, Cursor, Continue, Claude Desktop, Cline, …), the agent gets the tools but not always the judgment about when to reach for them. Default LLM behavior is to fall back to Edit / write_file / sed because those are top-of-mind and recast feels new.

Paste the block below into your project’s agent-instructions file — whichever your runtime picks up:

RuntimeFile
Claude CodeAGENTS.md (or CLAUDE.md)
Cursor.cursor/rules/recast.md or .cursorrules
Continue.continue/rules.md
Cline.clinerules
AiderCONVENTIONS.md
Generic / otherany markdown file your agent reads at session start

The snippet doubles up with the recast-mcp server’s own instructions field (which the client injects into the system prompt during MCP handshake). Two prompts hitting the same nail — the agent hears the decision rule from both sides.

Copy-paste block

## Multi-file rewrites — use `recast` MCP tools

For ANY edit that touches the same syntactic shape in 3+ files
(renames, struct-field additions, fn-signature changes, enum-variant
reshapes, version bumps, dependency migrations), call
`recast_preview` first, inspect the diff, then `recast_apply`.

Do NOT default to:
- `Edit` / `write_file` loops over N files
- `sed -i` across the tree
- per-file copy-paste of the same transform

Those silently fail on zero matches, can't roll back mid-failure,
and blast-radius across unintended sites. The recast engine catches
all three: typed `too_few_matches` error on zero hits, atomic
two-phase commit with rollback per file, and a convergence check
that refuses non-idempotent patterns (`a` → `aa`) before any write.

### Decision flow

1. About to make the same change in **3+ files** → `recast_preview`.
2. About to make a **shape-sensitive** change (struct literal, enum
   variant, fn signature, AST node) → `recast_structural` with
   `ast_pattern` instead of regex.
3. Preview returned 0 matches → **iterate the pattern**, do NOT fall
   back to per-file `Edit`.
4. Preview looks right → `recast_apply` with identical args.
5. A prior apply was killed mid-run → `recast_recover`.

### Concrete triggers

- "Rename `OldName` to `NewName` across the codebase"
- "Add field `direction: None` to every `ClientMessage::SplitPane { ... }` literal"
- "Update the N call sites of `pane_title(state)` to `pane_title(state, slot)`"
- "Bump every `1.2.3` to `1.2.4` in the workspace"
- "Migrate `.unwrap()` to `.expect("…")` everywhere"

### Tool pick

- `recast_apply` — regex / literal / Rhai script. Works on any
  language; text-level.
- `recast_structural` — tree-sitter `ast_pattern`. Use when the
  change is shape-sensitive. Supported langs: rust, ts, tsx, js,
  python, bash, go, json, markdown.
- `recast_recover` — only after a crash mid-apply.

Why this is needed (the survey)

A Claude Code session was given recast-mcp access and asked to do a multi-file refactor. The agent defaulted to Edit for every change even though recast_apply would have done the whole sweep in one call. Post-session debrief:

“Edit was top-of-mind every time. Each individual change felt small enough to justify staying in the familiar tool. The compound cost of ‘small Edit × 50’ sneaked past me.”

“I usually saw ‘this is a repeated transform across N sites’ only AFTER hitting the third or fourth site. By then I’d already done the manual edits and finishing felt cheaper than switching tools.”

The fix: tell the agent the decision rule directly. The system prompt beats latent tool-ranker preferences every time.

CLI flags

recast [OPTIONS] <PATTERN> <REPLACEMENT> [PATHS]...

Modes

FlagEffect
(default)Diff preview to stdout
--applyAtomic two-phase commit
--checkExit 1 if any file would change; no output, no writes
--stdinRead one buffer from stdin → stdout
--recoverReconcile leftover .recast.bak.* / .recast.tmp.* siblings

Match guards

FlagDefaultEffect
--at-least N1Require at least N matches across all files
--at-most NRequire at most N matches
--allow-non-convergentoffSkip the idempotency check
--allow-syntax-errorsoffSkip the syntax-regression guard (see Safety)

Filters

FlagEffect
-t LANG, --type LANGInclude only files of this type (rust, js, py, …)
-T LANG, --type-not LANGExclude this file type
-g PAT, --glob PATInclude / exclude glob (!pat to exclude)
--hiddenInclude dot-files
--no-ignoreBypass .gitignore filtering
--max-bytes NRefuse files larger than N bytes (default 10 MiB)
--max-files NRefuse runs touching more than N files (default 1000)

Regex options

FlagEffect
-L, --literalTreat pattern + replacement as literal text
-i, --ignore-caseCase-insensitive
-s, --single-lineDisable implicit (?s). no longer matches \n

Script mode

FlagEffect
--script PATHRhai script file run per match; return value = replacement

Structural mode

FlagEffect
--lang LANGTree-sitter grammar (rust, ts, python, …)
--query QUERYRaw tree-sitter S-expression query
--ast PATTERNFriendly source-shaped pattern with $NAME metavars
--include-leading-attrsExtend each match backward over leading #[attr] / doc-comment lines (see Structural mode)

Output

FlagEffect
--jsonEmit single-line JSON on stdout
--quietSuppress diff body; print only the summary
-v, --verbosePer-file timing and counters

Misc

FlagEffect
--threads NWorker threads (default = num CPUs)
--forceBypass the workspace lock check
--completions SHELLPrint shell completion script to stdout
--helpHelp summary
--versionVersion string

JSON output schema

--json emits exactly one line of compact JSON on stdout per invocation. Snapshot-locked in crates/recast-core/src/snapshots/ — changing field names or order is a breaking change.

Errors go to stdout too (not stderr) so agents have a single stream to parse.

Common shape

Every report carries a kind discriminator:

kind ∈ "plan" | "apply" | "check" | "error"

Non-error reports share outcome, files_scanned, and total_matches as a header that appears in that order; the mode-specific count (files_changed / files_written / files_would_change) follows.

plan (default mode)

{
  "kind": "plan",
  "outcome": "changes" | "already_applied",
  "files_scanned": 5,
  "total_matches": 3,
  "files_changed": 2,
  "changes": [
    { "path": "src/a.rs", "matches": 2 },
    { "path": "src/b.rs", "matches": 1 }
  ]
}

apply

{
  "kind": "apply",
  "outcome": "changes" | "already_applied",
  "files_scanned": 5,
  "total_matches": 3,
  "files_written": 2
}

check

{
  "kind": "check",
  "outcome": "changes" | "already_applied",
  "files_scanned": 5,
  "total_matches": 3,
  "files_would_change": 2
}

error

{
  "kind": "error",
  "error":
      "too_few_matches"
    | "too_many_matches"
    | "non_convergent"
    | "too_many_files"
    | "file_too_large"
    | "invalid_regex"
    | "invalid_glob"
    | "walk"
    | "io"
    | "script_parse"
    | "script_runtime"
    | "unknown_language"
    | "structural_query"
    | "structural_template"
    | "structural_parse"
    | "locked"
    | "invalid_threads"
    | "thread_pool",
  "message": "human-readable description",
  "exit_code": 2 | 3
}

The exit_code field mirrors the process exit code so agents can branch on kind: "error" without re-reading $?.

Stability

Every shape above has an insta snapshot test. Any PR that changes field names, drops a field, or reorders them shows up as a snapshot diff in review — there’s no quiet schema drift.

Exit codes

CodeMeaning
0Success, or “no changes needed”
1--check set and at least one file would change
2Match-count guard violated (--at-least / --at-most)
3Internal error (regex / glob parse, I/O, non-convergent pattern, syntax regression, script error, structural query error, workspace lock held, …)

Agents can branch on these without parsing stdout. Combined with --json, exit-code 2 always pairs with kind: "error" + error: "too_few_matches" or "too_many_matches", and exit-code 3 with one of the remaining error discriminants.

Examples

recast --check 'TODO' 'FIXME' .
echo "exit=$?"
# exit=0 → no files would change (clean)
# exit=1 → at least one file would change (CI gate fail)

recast --at-least 5 'foo' 'bar' src/
# exit=2 → fewer than 5 matches; nothing applied

Safety guarantees

The six things that make recast safer than sed / sd / a Python heredoc.

1. Match-required guard

Default --at-least 1 makes a silent zero-match exit impossible. An agent that types the wrong pattern gets an immediate non-zero exit instead of “looks like it worked”.

Override with --at-least 0 if you really do want to allow no-op runs.

2. Idempotency / convergence

Before any write, recast re-applies the rewrite to its own post-image. If any file would change again, the run is aborted with a non_convergent error.

Examples that pass:

  • 'old' -> 'new'
  • 'fn (\w+)_old' -> 'fn ${1}_new'

Examples that get rejected:

  • 'a' -> 'aa' (grows on every run)
  • 'foo' -> 'foofoo'

A successful first run followed by a re-run reports already_applied and exits 0, so retry loops are safe.

3. Syntax-regression guard

For every changed file whose extension maps to a compiled tree-sitter grammar (.rs, .ts, .tsx, .js/.mjs/.cjs/.jsx, .py, .sh/.bash, .go, .json, .md), recast re-parses the post-image and counts parse errors. If the rewrite introduces new errors relative to the pre-image, the run is aborted with a syntax_regression error before anything is written.

The comparison is a count delta, so a file that was already unparsable (mid-refactor, exotic macro) stays acceptable as long as the rewrite doesn’t make it worse. This catches a greedy regex that strands a brace or truncates an expression.

# regex deletes the `fn open(` line but leaves the body + closing brace
recast --apply 'fn open\(\) \{\n' '' src/   # → syntax_regression, nothing written

Limitation — syntactic, not semantic. The guard sees parse errors, not compiler errors. Deleting a function body while leaving its #[test] attribute behind produces valid syntax (the attribute just binds to the next item); tree-sitter does not flag it, so the guard does not fire. That class is a job for cargo check or, better, for structural mode, which removes the attribute along with the item.

Override per run with --allow-syntax-errors (CLI) / allow_syntax_errors: true (MCP). Files with no compiled grammar — and --no-default-features builds with every lang-* feature off — skip the guard and pass through unchecked.

4. Two-phase atomic apply

Phase A (stage)   per file: write sibling .recast.tmp, fsync, copy mode
Phase B (commit)  per file: rename original→.recast.bak, rename .tmp→original
Phase C (cleanup) per dir:  delete backups, fsync parent dir

Any failure in Phase B walks the rename log in reverse — every already-renamed file is restored from its backup, leaving the tree bit-identical to the pre-image. Failure in Phase A just deletes the staged temps; originals are never touched.

This applies to regex, script, and structural modes.

5. Crash-recovery sweep

If the process dies mid-commit (SIGKILL, panic, power loss), the tree may be left with leftover .foo.recast.bak.N / .foo.recast.tmp.N siblings. Reconcile with:

recast --recover src/
  • Target exists + stale backup/temp → delete leftovers
  • Target missing + backup present → rename newest backup back to target
  • Target missing + only temps → leave untouched (can’t safely decide)

6. Workspace lock

--apply and --recover take an exclusive non-blocking lock on <root>/.recast.lock so two concurrent rewrites against the same tree don’t interleave. Second invocation gets an immediate locked error with exit 3 instead of corrupting the tree.

--force bypasses the lock for cases you genuinely understand (e.g., the previous holder crashed and you’ve already run --recover). --check and --diff skip the lock since they don’t write.

Cargo features

recast (the binary) and recast-core (the library) expose a matching set of cargo features so users can opt out of grammars or modes they don’t need.

recast binary

FeatureDefaultPulls in
scriptrecast-core/script (Rhai callback)
lang-rusttree-sitter + tree-sitter-rust
lang-tstree-sitter + tree-sitter-typescript
lang-jstree-sitter + tree-sitter-javascript
lang-pythontree-sitter + tree-sitter-python
lang-bashtree-sitter + tree-sitter-bash
lang-gotree-sitter + tree-sitter-go
lang-jsontree-sitter + tree-sitter-json
lang-mdtree-sitter + tree-sitter-md
lang-allMeta — enables every lang-* above

Stock install:

cargo install --path crates/recast

Slim install — Rust grammar only, no script engine:

cargo install --path crates/recast \
  --no-default-features \
  --features lang-rust

Slim install — Python + TypeScript only:

cargo install --path crates/recast \
  --no-default-features \
  --features lang-python,lang-ts

recast-core library

The library exposes the same lang-* features plus serde (JSON output schema). At least one lang-* must be enabled for structural mode to compile in.

Binary size

Each grammar adds ~5–15 MB of compiled tables. The full --features lang-all binary is ~80 MB. A slim lang-rust-only build is ~25 MB. Static musl builds are a few MB larger.

Building from source

git clone https://github.com/Stoica-Mihai/recast
cd recast
cargo build --release --workspace --all-features

The full test + lint matrix CI runs:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo doc --workspace --no-deps --all-features      # RUSTDOCFLAGS=-D warnings in CI

Property tests run as part of cargo test; criterion benchmarks are opt-in:

cargo bench --features lang-rust,script -p recast-core

HTML bench reports land under target/criterion/.

Cutting a release

  1. Bump version in Cargo.toml (workspace) and the recast-core path-dep pin.

  2. Promote ## [Unreleased]## [X.Y.Z] — YYYY-MM-DD in CHANGELOG.md.

  3. Commit, tag, push:

    git commit -am "chore: bump to X.Y.Z"
    git tag -a vX.Y.Z -m "vX.Y.Z — short tagline"
    git push origin main
    git push origin vX.Y.Z
    
  4. .github/workflows/release.yml cross-compiles seven targets, packages each, extracts the [X.Y.Z] CHANGELOG section as release notes, and attaches everything to the matching GitHub Release. Workflow_dispatch re-runs gh release edit --notes-file against an existing tag.

Operating manual

The full operating manual for AI agents and human contributors lives in AGENTS.md (symlinked as CLAUDE.md for agent runtimes that look it up by that name).

Highlights:

  • TDD by default — every feature goes through /tdd:tdd
  • DRY enforced — every feature/fix runs through /engineering-principles:dry-principle
  • Tests live in their own files (foo_tests.rs next to foo.rs)
  • Property tests via proptest for every public entry point
  • Snapshot tests via insta for JSON + unified-diff output
  • Conventional commit prefixes; one concern per PR
  • No Co-Authored-By trailers
  • README + CHANGELOG + PLAN are documentation contracts — drift is a bug
  • Release process is fully automated from tag push

See the canonical file for the full set of rules and the reasoning behind each one.