Explanation: why tact is shaped this way#

Drift is routine, not a permission to guess#

An edit can become stale for reasons unrelated to its intent: a formatter removes whitespace, a copy-paste changes a quote, a block moves one indent level, or another edit lands first. Exact matching correctly notices that the bytes changed, but it cannot distinguish harmless drift from a meaningful change. Tact makes that distinction explicit rather than asking a caller to reconstruct the edit from scratch.

Its benchmark fixtures replay historical edit failures, so the tool can be evaluated against the kind of drift it was made to handle. Those fixtures are evidence about a fixed corpus, not a universal accuracy claim.

A ladder preserves the strongest evidence#

find_block tries seven rungs in order: EXACT, CANON, RSTRIP, INDENT, SUBLINE, REFLOW, and FUZZY. It stops at the finest rung with one candidate and reports that rung. A single fixed tolerance is either too brittle or too permissive; always using the loosest match would conceal that stronger evidence existed.

The first four rungs are equality under progressively coarser normalizations. SUBLINE and REFLOW are exact matches on projections of the text, and therefore can safely precede approximate matching. They are the only rungs that can change part of a line, so their results include character columns. FUZZY is last because similarity is not transitive: two locations can both be close to a target without being interchangeable.

Refusal is part of the write contract#

Two or more candidates at any rung are an ambiguity, not a tie to break by position. Tact returns a structured refusal and accepts a near hint when the caller can supply one. It also rejects overlapping edits and refuses an indentation-adjusted write when no uniform indentation shift can be established.

The same posture applies to batches. A single-file batch resolves all edits before writing. A multi-file batch resolves and stages every file before committing any; after the first per-file rename, however, a crash or commit failure can leave a partial batch. That POSIX limitation is reported rather than hidden.

LSP is optional, except when identity matters#

Definition, references, hover, symbols, and diagnostics use language servers when they are available. Most return a structured unavailable result when no server is live; Python symbols can fall back to an AST outline. Rename is different: identifying the same binding across files needs the language server’s semantic graph. There is no safe text-replacement substitute, so rename refuses when that graph is unavailable.

Convenience state is disposable#

Undo history and saved anchors live under the configured AI-home directory. Storage errors become structured results, and removing that state sacrifices convenience rather than package correctness. The journal makes undo and delta reads more useful; it is not a dependency for making a sound edit.

Provenance#

canonicalize() and the Levenshtein/similarity primitives adapt portions of dirac, Apache-2.0 version 0.4.11. See NOTICE and tact/reconcile.py for the precise attribution. Tact collects every candidate before deciding, uses a per-line similarity mean, and adds SUBLINE and REFLOW; those are deliberate departures from the upstream implementation.