tact: Public API#
The package-level module lists the stable, supported import surface. The command-line and MCP mappings are in the reference; module docstrings carry behavior-level detail.
Core package#
Reliable file-edit primitives for agents.
tact exposes one reconciliation core through a Python library, the tact CLI, and an optional
MCP server. find_block locates a requested block through seven ordered rungs of tolerance:
EXACT, CANON, RSTRIP, INDENT, SUBLINE, REFLOW, and FUZZY. It reports the finest
unique match and refuses ambiguity, no-match outcomes, unsafe indentation adjustment, and
overlapping batches rather than guessing.
apply_edits resolves every edit in one file before its atomic replacement. apply_many resolves
and stages every file before committing, while reporting the unavoidable limit that independent
POSIX renames cannot form one crash-atomic cross-file transaction. The package also provides
read-only healing, patch parsing, undo history, durable anchors, delta reads, optional LSP
navigation and diagnostics, and LSP-required semantic rename.
The library has no runtime dependencies. The optional MCP skin requires fastmcp; language-server
features degrade to structured unavailable results except rename, which has no safe textual
fallback. Undo, anchors, and telemetry are convenience state under the configured AI-home
directory and never determine whether an edit is correct.
Provenance: canonicalize() and the Levenshtein/similarity primitives adapt portions of dirac
(Apache-2.0, version 0.4.11). See the repository NOTICE and tact.reconcile for attribution
and the deliberate differences in this implementation.
- class tact.AdoptionReport(window_start: str | None, window_end: str | None, total_events: int, rows: list[~tact.adoption.HarnessRow], hook_eligible_attempts: int, hook_enforced: int, hook_warned: int, hook_ineligible: int, library_applied: int, library_healed: int, library_refused: int, library_attributed: int, library_events: int, library_by_harness: dict[str, int] = <factory>, client_census: dict[str, int] = <factory>, other_surface_events: int = 0, unmeasured: dict[str, str] = <factory>)#
A fully-reduced, deterministic view of both event populations.
The two surfaces are kept apart on purpose and are never divided by one another; see the module docstring for why, and
unmeasuredfor what that costs.- Parameters:
window_start – ISO-8601 timestamp of the earliest event in the window.
window_end – ISO-8601 timestamp of the latest event in the window.
total_events – Total event rows parsed.
rows – One
HarnessRowper harness, sorted by harness name.hook_eligible_attempts – Fleet sum of
HarnessRow.eligible_attempts. Unit: intercepted tool calls.hook_enforced – Fleet sum of
HarnessRow.enforced.hook_warned – Fleet sum of
HarnessRow.warned.hook_ineligible – Fleet sum of
HarnessRow.ineligible.library_applied – Edits reconciled with no healing. Unit: individual edits, so a batch of five contributes five.
library_healed – Edits that reconciled only after the ladder healed them.
library_refused – Edits in a batch tact declined to write. One event per edit in the refused batch, so this is not a count of refused operations.
library_attributed – How many library events carried a real harness name. Zero means the library emitter is still hardcoding
unknown; a nonzero value is the signal that per-harness attribution has become possible.library_events – Total library-surface events, the denominator for
library_attributed.library_by_harness – Library events attributed per harness (from the CLI’s explicit
--harnessassertion — the only trust channel that names a harness today). Unknown-attributed events are excluded here and visible inlibrary_events - sum(library_by_harness.values()).client_census – Census of the verbatim
clientInfo.namevalues callers sent on the MCP initialize handshake, counted over every event row carrying one — today that is the MCP surface’s write-path rows (surface='mcp'), which this report has no per-surface section for and would otherwise silently drop (MEMY-734’s empirical capture: this is the evidence any future client-name→ harness mapping must be decided from).other_surface_events – Events on a surface with no section in this report. Must normally be zero; a nonzero value means an emitter is writing rows that
total_eventscounts but nothing here reports.unmeasured – Quantity name to the reason it cannot be computed, from
UNMEASURED_REASONS. Rendered in full by both renderers.
- class tact.Anchor(path: Path, sha256: str, block_text: str, index: int)#
A durable handle onto one block of text at one file, resolved at one point in time.
- Parameters:
path – The file the block lives in (resolved, symlinks followed).
sha256 – The whole file’s content hash at anchor time — the fast-path check
resolve_anchor()uses: if the file’s current hash still matches this, the block is guaranteed still atindexwith no re-search needed.block_text – The anchored block, verbatim, same convention as
old_block(multi-line blocks split on\\n).index – The 0-based line index
block_textresolved to at anchor time.
- class tact.AnchorCreateResult(ok: bool, anchor: Anchor | None, rung: Rung | None, fuzz: float | None, ambiguous: bool, candidates: tuple[~tact.reconcile.Candidate, ...]=<factory>, error: str | None = None)#
The outcome of one
anchor()call.- Parameters:
ok – Whether
block_textresolved to a single, unique anchor right now.anchor – The resulting
Anchor, orNoneon failure.rung – Which rung resolved it, or the rung the ambiguity was found at.
fuzz – The rung’s match-quality score.
ambiguous – Whether more than one candidate was found (a fresh anchor never resolves ambiguity via a
nearhint — see module docstring — soambiguous=Truehere always meansok=False).candidates – Every candidate position, when
ambiguousis set.error – A human-legible reason anchoring failed, or
Noneon success.
- class tact.AnchorLoadResult(ok: bool, anchor: Anchor | None, error: str | None = None)#
The outcome of one
load_anchor()call.
- class tact.AnchorResult(ok: bool, index: int | None, rung: Rung | None, fuzz: float | None, same_sha: bool, ambiguous: bool, candidates: tuple[~tact.reconcile.Candidate, ...]=<factory>, error: str | None = None)#
The outcome of one
resolve_anchor()call.- Parameters:
ok – Whether the block was re-located (either the fast path or a fresh ladder run).
index – The 0-based line index the block resolves to now, or
Noneon failure.rung – Which rung resolved it (
Noneon the fast, sha-unchanged path — no rung ran).fuzz – The rung’s match-quality score, or
Noneon the fast path.same_sha – Whether the file’s content hash was unchanged since anchor time (the fast path: the block is guaranteed to still be at
index, no re-search needed).ambiguous – Whether re-resolution found more than one candidate.
candidates – Every candidate position, when
ambiguousis set.error – A human-legible reason re-resolution failed, or
Noneon success.
- class tact.AnchorSaveResult(ok: bool, name: str, error: str | None = None)#
The outcome of one
save_anchor()call.
- class tact.ApplyReport(applied: bool, drift_detected: bool, results: tuple[EditResult, ...], error: str | None = None, diagnostics_delta: tuple[Diagnostic, ...] | None = None, source_sha256: str | None = None, result_sha256: str | None = None)#
The outcome of one
apply_edits()call.- Parameters:
applied – Whether the file was actually rewritten.
Falsemeans every anchor was either resolvable or not, but something about the batch (an unresolved edit, an overlap) rejected the whole thing before any write.drift_detected – Whether
expected_sha256was given and didn’t match the file’s content at read time. The ladder re-anchored regardless (see module docstring); this only reports that it had to.results – One
EditResultper input edit, same order.error – A batch-level reason nothing was written (e.g. which edits overlapped), or
NonewhenappliedisTrueor the batch was a legitimate no-op.diagnostics_delta – New LSP diagnostics published for
pathafter this write, orNone. Advisory only — a write never fails because of it, andNonemeans “no LSP available for this write” (nolsp_managerpassed, or no live server for the file’s project root), never an error; an empty tuple means “LSP checked, nothing new”.source_sha256 – SHA-256 of the exact original file bytes read for this nonempty call, or
Nonefor an empty no-op.result_sha256 – SHA-256 of the UTF-8 postimage actually written, or
Nonewhen the batch was refused or was an empty no-op.
- class tact.Candidate(index: int, excerpt: str, fuzz: float | None = None)#
One candidate position surfaced when a rung finds more than one match.
- Parameters:
index – 0-based line index in
file_lineswhere the candidate block starts.excerpt – The candidate block’s first line, for a human/agent to disambiguate by eye.
fuzz – The candidate’s similarity score (rung 7 only);
Noneat rungs 1-6, where every candidate is an exact match under that rung’s relation.
- class tact.CheckReport(would_apply: bool, drift_detected: bool, results: tuple[EditResult, ...], error: str | None = None, preview: str = '', source_sha256: str | None = None, result_sha256: str | None = None)#
The outcome of one
check_edits()call —apply_edits’s read-only twin.- Parameters:
would_apply – Whether this batch would have been written by
apply_edits()— the dry-run analogue ofApplyReport.applied(never namedapplieditself: that would misleadingly imply a write happened).drift_detected – Same meaning as
ApplyReport.drift_detected.results – One
EditResultper input edit, same order.error – A batch-level reason the batch wouldn’t apply, or
None.preview – A unified diff (
difflib.unified_diff()) of the file’s current content against what applying this batch would produce — deterministic, no timestamps in the diff headers (the file’s own path is used for both---/+++lines). The empty string whenwould_applyisFalse(nothing to preview) or the batch was a legitimate no-op.source_sha256 – SHA-256 of the exact original file bytes read for this nonempty call, or
Nonefor an empty no-op.result_sha256 – SHA-256 of the UTF-8 postimage that this call would write, or
Nonewhen the batch was refused or was an empty no-op.
- class tact.DeltaResult(path: Path, status: DeltaStatus, new_sha256: str | None, diff: str = '', content: str | None = None, note: str | None = None)#
The outcome of one
read_since()call — always present, every status self-reporting.- Parameters:
path – The file that was checked (resolved, symlinks followed).
status – Which of the four
DeltaStatusoutcomes this is.new_sha256 – The file’s current content hash, or
NonewhenstatusisMISSING(there is no current content to hash).diff – A unified diff (
difflib.unified_diff(), no timestamps) from the recovered baseline to the current content — populated only forCHANGED,''for every other status.content – The file’s full current text — populated only for
REWRITTEN(the caller’s baseline wasn’t recoverable, so a diff can’t be built; the full content is the honest fallback),Nonefor every other status, includingREWRITTENitself when the current content isn’t valid UTF-8 (seenotein that case).note – A structured, human-legible explanation for a degraded outcome (why no baseline was recoverable, or why
content/diffis empty despite a real change), orNonewhen nothing needs explaining.
- class tact.DeltaStatus(*values)#
Which of the three outcomes
read_since()reports.- UNCHANGED = 'unchanged'#
known_sha256matches the file’s current content – nothing to send.
- CHANGED = 'changed'#
The file changed, and a real line diff against the recovered baseline is available.
- REWRITTEN = 'rewritten'#
The file changed, but the baseline for
known_sha256was not recoverable – the full current content is returned instead of a diff that can’t be computed honestly.
- MISSING = 'missing'#
The file no longer exists.
- class tact.Diagnostic(source: str, severity: int | None, message: str, start: Position, end: Position, code: str | None = None)#
One
publishDiagnosticsentry, tagged with which server reported it.Hashable/comparable by value (the default dataclass
__eq__/__hash__viafrozen=True) —LspManager.diagnostics_delta()’s “new means not in the before set” logic depends on that.
- class tact.Edit(old_block: str, new_block: str, near: int | None = None)#
One requested change: find
old_block, replace it withnew_block.- Parameters:
old_block – The text to locate, as it would appear read verbatim out of the file (multi-line blocks are split on
\n; no trailing newline expected).new_block – The replacement text, same convention.
near – An optional line-index hint passed through to
find_block()to break ties when the anchor is ambiguous.
- class tact.EditResult(ok: bool, rung: Rung | None, fuzz: float | None, line_range: tuple[int, int] | None, indent_delta: int | None, ambiguous: bool, overlap: bool = False, candidates: tuple[~tact.reconcile.Candidate, ...]=<factory>, error: str | None = None, col_range: tuple[int, int] | None=None)#
The per-edit outcome inside an
ApplyReport— always present, win or lose.- Parameters:
ok – Whether this edit resolved to a single, usable anchor.
Falsemeans the batch was rejected before any write (ambiguous, no-match, a rung-4 match with no uniform indent shift to re-indent by, or this edit’s range collided with another’s);oknever reflects “was actually written” beyond that — seeApplyReport.appliedfor the batch-level outcome.rung – Which rung resolved this edit, or
Noneon failure.fuzz – The rung’s match-quality score (see
MatchResult.fuzz).line_range – The
[start, end)line interval this edit’s old block occupied in the file at resolution time, orNonewhen no anchor was found at all. Populated even on a rung-4 indent refusal (ok=False,indent_delta=None): the anchor genuinely was found there, only the safe re-indent couldn’t be computed, so the span is true information the caller can act on.col_range – The
[col_start, col_end)character columns the match began and ended at, withinline_range’s first and last line respectively. For every line-aligned rung this is the full width of those lines; theSUBLINEandREFLOWrungs are the ones that can report a partial line, so this is where a partial-line edit self-reports what it actually touched.Noneon failure, and populated alongsideline_rangeon an indent refusal for the same reason.indent_delta – The rung-4 uniform indent shift, or
None(seeMatchResult).Nonehere is whyokisFalsewhenrungisINDENT— checkis not None, not truthiness, since0is a distinct, valid shift.ambiguous – Whether the anchor had multiple candidates (resolved via
nearor not).overlap – Whether this edit’s resolved range collided with another edit’s in the same batch — the reason a structurally-otherwise-valid edit still didn’t get applied.
candidates – Every candidate position, when
ambiguousis set.error – A human-legible reason this edit didn’t resolve/apply, or
None.
- class tact.HarnessRow(harness: str, status: str, eligible_attempts: int, enforced: int, warned: int, ineligible: int, tool_counts: dict[str, int]=<factory>, p50_ms: float | None = None, p95_ms: float | None = None, latency_samples: int = 0)#
One harness’s hook-surface activity over a reporting window.
Deliberately hook-only. Library-surface events carry no harness (see
AdoptionReport.library_attributed), so attributing an apply or a heal to a named harness is not currently possible — and a per-harness0there would read as “this harness never used tact” when the truth is “the emitter did not say.”- Parameters:
harness – Harness name.
status –
instrumented(hook events present),unavailable(recognized but no hook events in this window), orunknown(an unrecognized harness name).eligible_attempts – Intercepted tool calls that tact owns by policy — ordinary, already-existing files, not notebooks, not new-file creation. Unit: one per intercepted tool call, never per edit.
enforced – Subset of
eligible_attemptsindenymode, where the native edit was blocked outright. The one branch where reroute-or-abandon is guaranteed.warned – Subset of
eligible_attemptsinwarnmode, where the native edit was advised against but allowed to proceed. The upper bound on hook-visible bypass; seeUNMEASURED_REASONS.ineligible – Intercepted tool calls that tact does not own and correctly failed open.
tool_counts – Structured mapping of tool name to invocation count for this harness.
p50_ms – Median hook-routing duration in milliseconds, or
Nonewith no samples.p95_ms – 95th percentile hook-routing duration, or
Nonewith no samples.latency_samples – How many rows actually backed
p50_ms/p95_ms. Printed beside them so a percentile over three events is not mistaken for a fleet measurement.
- class tact.HealResult(ok: bool, rung: Rung | None, fuzz: float | None, line_range: tuple[int, int] | None, indent_delta: int | None, old_block: str | None, edit: Edit | None, preview: str, ambiguous: bool, candidates: tuple[~tact.reconcile.Candidate, ...]=<factory>, error: str | None = None, col_range: tuple[int, int] | None=None, applied: bool = False)#
The outcome of one
heal()call.- Parameters:
ok – Whether
old_stringresolved to a single, usable anchor in the file’s current content, and (at rungINDENT) a uniform shift could be computed for it.Falsealso covers a rung-4 match with no uniform indent shift — the anchor was found, but healing refuses rather than guess at a re-indent, matchingapply_edits()’s own refusal.rung – Which rung resolved it, or the rung the ambiguity/indent-refusal was found at, or
Nonewhen no rung ever produced a candidate.fuzz – The rung’s match-quality score (
1.0for rungs 1-6, mean per-line similarity for rung 7).line_range – The
[start, end)line interval the match occupies, orNonewhen no anchor was found at all. Populated on an indent refusal too — the anchor genuinely was found there.col_range – The
[col_start, col_end)character columns the match began and ended at, withinline_range’s first and last line. Full line widths at every line-aligned rung; a genuinely partial line only atSUBLINE/REFLOW. Without it a partial-line heal would report the lines it touched but not how much of them, overstating its own reach.Noneon failure.indent_delta – The rung-4 uniform indent shift, or
None(seeMatchResult).Noneat rungINDENTis whyokisFalse— checkis not None, not truthiness, since0is a distinct, valid shift.old_block – The file’s actual current lines at the matched span, joined with
\\n— feeding this straight back into a freshEdit.old_blockis guaranteed to resolve at rungEXACT.Nonewhen no anchor was found at all; populated on an indent refusal (ok=False) too, since it is the most actionable thing to hand back — the caller’s nextnew_blockshould be derived from it directly rather than guessed at.edit – A ready-to-apply
Editbuilt from the healed anchor (old_blockas above,new_block=new_string,nearset to the match’s start line so a caller re-running the ladder breaks any tie in the same direction this call did).Noneon failure.preview – A unified diff of applying
editat the healed span — built through the same splice pathapply_edits()itself uses, so it never disagrees with what an actual apply would write. The empty string on failure.ambiguous – Whether the anchor had multiple candidates.
candidates – Every candidate position, when
ambiguousis set.error – A human-legible reason healing failed, or
Noneon success.applied – Always
False—heal()never writes (see module docstring). The field exists so that a success can never read as an apply: a convincingok=Trueplus a diff preview is exactly what a real write reports, and agents in the field trusted that reading and lost edits waiting for a file that never changed.applied=Falsebeside them is the explicit nothing-happened signal a machine caller can test instead of inferring.
- class tact.HistoryEntry(seq: int, sha256: str, size: int, mode: int | None)#
One journaled pre-image, newest-first order in
history()’s return.- Parameters:
seq – This file’s ring-local monotonic ordinal (higher is newer) — not a timestamp, not comparable across different files.
sha256 – The stashed content’s hash — the key into
load_blob().size – The stashed content’s size in UTF-8-encoded bytes.
mode – The file’s permission bits at journal time, or
None.
- class tact.Location(path: Path, start: Position, end: Position)#
A position range inside one file — the shape
definition/referencesreturn.
- class tact.LspManager(*, pyrefly_cmd: str | None = None, ruff_cmd: str | None = None)#
Lazy, per-project-root persistent LSP client — see module docstring for the contract.
- restart(path: Path) None#
Clear the blacklist for
path’s project root and drop any live servers for it.The next verb call for a file under that root respawns fresh. The explicit recovery action for “the server misbehaved and I know why” (Serena’s
restart_language_serverprecedent) — an out-of-band edit, a config change, anything the running server can’t see.
- shutdown_all() None#
Kill every live server. Registered with
atexitso nothing outlives the process.
- document_symbols(path: Path) LspResult[tuple[SymbolInfo, ...]]#
documentSymbol: the file’s outline, hierarchical where the server supports it.
- definition(path: Path, target: str | tuple[int, int] | Position) LspResult[tuple[Location, ...]]#
definition: wheretarget(a name or position) is defined.
- references(path: Path, target: str | tuple[int, int] | Position, *, include_declaration: bool = True) LspResult[tuple[Location, ...]]#
references: every use oftarget(a name or position).
- hover(path: Path, target: str | tuple[int, int] | Position) LspResult[str | None]#
hover: the type/docstring info fortarget(a name or position).
- capabilities(path: Path) dict[str, Any] | None#
The live pyrefly server’s advertised
initializecapabilities forpath’s root.Nonewhen no live server is available for this root (not installed, blacklisted, handshake failed) — distinct from{}, which would mean “a server is live and advertised no capabilities at all” (never actually seen from pyrefly, but a real, different state).tact.rename.rename_symbol()checksrenameProviderhere before ever sending a rename request.
- rename(path: Path, target: str | tuple[int, int] | Position, new_name: str) LspResult[dict[str, Any] | None]#
textDocument/rename: the rawWorkspaceEditfor renamingtargettonew_name.Returns the response’s raw
result(aWorkspaceEditdict, orNonewhen the server legitimately has nothing to change) wrapped inLspResult— parsing that into per-fileEditbatches and applying them istact.rename.rename_symbol()’s job, not this client’s; this method stays symmetric with every other verb here (degrade took=False+ a legibleerror, never raise, never guess).
- notify_written(path: Path, new_text: str) dict[str, int]#
Push a just-written file’s new content to every live server for its root.
The write-side sync primitive:
diagnostics_around_write()(whichtact.apply.apply_edits()calls after every successful write) invokes this for its before/after snapshots, so a server’s in-memory view never drifts from disk. A no-op (returns{}) when no server is live for this root — never blocks the write that already happened.- Returns:
The document version sent to each live server, keyed by server name (e.g.
{'pyrefly': 3, 'ruff': 3}) —diagnostics_around_write()uses this to know which version’spublishDiagnosticsto wait for.
- diagnostics_for(path: Path) tuple[Diagnostic, ...] | None#
Every diagnostic currently known for
path, merged across live servers.Nonemeans no server is live for this root (LSP unavailable) — distinct from an empty tuple, which means “available, and currently clean”.
- static diagnostics_delta(before: Sequence[Diagnostic], after: Sequence[Diagnostic]) tuple[Diagnostic, ...]#
The diagnostics in
afterbut not inbefore— new problems only.Deviation from the literal spec signature (
diagnostics_delta(path, before, after)):pathis dropped since aDiagnosticset is already scoped to one file by the caller (bothdiagnostics_forsnapshots came from the samepath) — nothing about set difference needs it.
- diagnostics_around_write(path: Path, old_text: str, new_text: str, *, settle_timeout: float = 5.0, poll_interval: float = 0.05) tuple[Diagnostic, ...] | None#
The diagnostics this edit introduced — new problems only, never pre-existing ones.
Nonewhen no server is live forpath’s root — the advisory field this feeds (ApplyReport.diagnostics_delta) must stayNonein that case, never an empty tuple, so a caller can tell “not checked” from “checked, nothing new”.The naive approach — snapshot whatever the manager already knows, push the new content, snapshot again — over-reports on a file’s first touch this session: with nothing yet known, the “before” snapshot is empty and the file’s entire pre-existing diagnostic load (unrelated lint issues, say) surfaces as “new”. This method instead pushes
old_text(the file’s content just before this edit) first, settles, and takesbeforefrom that — a real look at what the file looked like right before this specific edit — then pushesnew_text, settles again, and diffs.didChangeis in-memory only, so ending onnew_textleaves the server’s buffer matching what is now actually on disk.
- class tact.LspResult(ok: bool, value: T | None, error: str | None = None)#
Generic outcome wrapper for every verb — degrade gracefully, never raise.
- Parameters:
ok – Whether the call produced a usable
value.Falsecovers every LSP failure mode uniformly (no server, timeout, protocol error, unresolved symbol name) — callers branch onok, not on exception types, because none of this raises.value – The result, or
Noneon failure.error – A human-legible reason, or
Noneon success.
- class tact.ManyFileResult(path: Path, ok: bool, results: tuple[EditResult, ...] = (), drift_detected: bool = False, error: str | None = None, source_sha256: str | None = None, result_sha256: str | None = None)#
Per-file outcome inside a
ManyReport.- Parameters:
path – The file this result describes — resolved (symlinks followed) once resolution got that far, else the key exactly as given in
edits_by_path.ok – Whether this file’s batch resolved cleanly (unique anchors, no overlap) and, when
required_sha256snamed it, without drift — i.e. whether this file would have been part of the write.results – One
EditResultper edit for this file, or()when the file itself failed before per-edit resolution (missing, not a regular file, or a duplicate-resolved-path rejection).drift_detected – Whether
required_sha256snamed this file and it didn’t match.error – A human-legible reason this file blocked the whole transaction, or
None.source_sha256 – SHA-256 of the exact preimage read for a resolved nonempty batch, or
Nonewhen the file could not be read or its edit list was empty.result_sha256 – SHA-256 of the exact UTF-8 postimage committed for this file, or
Nonewhen it was refused, skipped as a no-op, or failed before replacement.
- class tact.ManyReport(applied: bool, files: tuple[~tact.transact.ManyFileResult, ...]=<factory>, error: str | None = None)#
The outcome of one
apply_many()call.- Parameters:
applied – Whether every file was actually rewritten.
Falsemeans nothing was written — either the batch had nothing to do (empty input, or every file’s edit list was itself empty) or at least one file blocked the transaction.files – One
ManyFileResultper key inedits_by_path, same iteration order as given.error – A batch-level reason nothing was written, or
None.
- class tact.MatchResult(rung: Rung | None, index: int | None, fuzz: float, indent_delta: int | None, ambiguous: bool, candidates: tuple[~tact.reconcile.Candidate, ...]=<factory>, error: str | None = None, end: int | None = None, col_start: int = 0, col_end: int = 0)#
The outcome of one
find_block()call — always self-reporting, never silent.A match is a half-open character region: it starts at column
col_startof lineindexand ends at columncol_endof lineend - 1. Callers must splice using all four fields rather than assumingend == index + len(target_lines)— that assumption holds for the line-aligned rungs but is false forSUBLINE/REFLOW, which may match a partial line or a run of lines whose count differs from the target’s.- Parameters:
rung – Which rung matched, or — on any refusal a rung is responsible for (an ambiguity, or a boundary-unsafe approximate match) — which rung raised it.
Noneonly when no rung ever produced a candidate at all, i.e. the text is genuinely absent rather than present-but-unusable. Readindex is Nonefor “nothing resolved, nothing can be written”; readrungfor where that was decided.index – 0-based line index the match starts at, or
Noneon failure.end – 0-based line index one past the last line the match touches, or
Noneon failure. Equalsindex + len(target_lines)at every line-aligned rung.col_start – Character column within line
indexwhere the match begins;0at every line-aligned rung.col_end – Character column within line
end - 1where the match ends (exclusive); the length of that line at every line-aligned rung.fuzz – Match quality in
[0, 1];1.0for rungs 1-6 (perfect matches under that rung’s equivalence relation), the mean per-line similarity for rung 7.indent_delta – The uniform per-line indent-width shift (file minus target) recorded at rung 4, so a caller can re-indent replacement text to the file’s own style.
Noneat every other rung, andNoneat rung 4 itself when the block’s indentation isn’t uniformly shifted (no single delta describes it) or mixes tabs into its leading whitespace. ThisNoneis not merely informational: a write-time caller (apply_edits(),heal()) must refuse rather than splice the replacement unshifted – seeindent_refusal_reason(). A0is a genuine computed shift (no re-indent needed) and must never be treated the same asNone; a plain truthiness check on this field conflates the two.ambiguous –
Truewhen a rung found two or more candidates — whether or not anearhint went on to resolve one of them.candidates – Every candidate position found when
ambiguousisTrue(empty otherwise): all of them on a bare ambiguity failure, or the full tied set (withindexpointing at thenear-resolved winner) on a resolved ambiguity.error – A human-legible failure reason, or
Noneon success.
- class tact.PatchFileResult(path: Path | None, edits: tuple[Edit, ...] = (), error: str | None = None)#
One target file’s parse outcome inside a
PatchParseResult.- Parameters:
path – The file path named by the hunk/block header, or
Nonefor a fenced old/new pair that had no precedingFile:marker to attribute it to.edits – The parsed
Editbatch for this file, in the order its hunks/blocks appeared. Empty on error.error – A structured reason this file’s hunks/blocks failed to parse, or
Noneon success. Per the module’s own contract, this is never partial: either every hunk/block for this file parsed (error is None) or none of them did.
- class tact.PatchParseResult(files: tuple[~tact.patch.PatchFileResult, ...]=<factory>, ok: bool = True)#
The outcome of one
parse_unified_diff()/parse_fenced_blocks()call.- Parameters:
files – One
PatchFileResultper target file named in the input, in first-seen order.ok – Whether every named file parsed cleanly.
Falsemeans at least one file’s entry carries anerror— the other files are still fully usable (see module docstring’s per-file-scoped contract).
- class tact.Position(line: int, character: int)#
A 0-based LSP position — line and UTF-16-code-unit character offset.
pyrefly advertises
positionEncoding: utf-16(the LSP default). A caller that builds a raw(line, character)tuple via plain Python string indexing (which counts Unicode codepoints) will mis-resolve on a line containing characters outside the Basic Multilingual Plane (rare in source code, but real — some emoji, historic scripts). The name-first path (LspManager.definition()et al. given astr) sidesteps this entirely, since the position then comes from the server’s owndocumentSymbolresponse, already in its native encoding — prefer it over a hand-built position where possible.
- class tact.ReadResult(path: Path, sha256: str, content: str | None, unchanged: bool, truncated: bool, total_bytes: int, returned_bytes: int, uses_crlf: bool, decode_error: str | None = None, line_numbers: tuple[int, ...]=<factory>)#
The outcome of one
read_file()call — always present, every field self-reporting.- Parameters:
path – The file that was read (resolved, symlinks followed — matching
tact.apply.apply_edits()’s own resolution so a hash/path pair from one composes with the other).sha256 – Hex digest of the file’s full raw bytes, unaffected by
max_bytesor theline_numbersrendering — the anchor a laterapply_edits(..., expected_sha256=...)call can rely on.content – The (possibly truncated) text, decoded UTF-8, CRLF preserved as read (this module never normalizes line endings — that is
apply_edits’s job, once, at the point of write).NonewhenunchangedisTrue(nothing new to return) or when the file could not be decoded as UTF-8 (decode_errorset instead).unchanged –
Truewhenseen_hashesalready contained this file’ssha256— the caller is holding stale interest in a file whose bytes haven’t moved;contentis omitted to make that cheap.truncated – Whether
max_bytescut the returnedcontentshort of the file’s actual size. Never true whenunchangedis true (there is no content to have cut).total_bytes – The file’s actual size in bytes, always reported (even when truncated or unchanged) so a caller can judge how much was left out.
returned_bytes – How many bytes of
contentwere actually returned (UTF-8-encoded length, not char count) — equal tototal_bytesunlesstruncated.uses_crlf – Whether the file (or the truncated prefix read) uses CRLF line endings.
decode_error – A human-legible reason
contentisNonebecause the bytes weren’t valid UTF-8 (binary file, wrong encoding) — distinct fromunchangedfully by being reported here rather than via the dedup flag.line_numbers – The 1-based line number of each entry in
content, split the waytact._lines.split_lines()splits it (on\n/\r\nonly), so a line number reported here is the same line number an edit resolves against. Present whenwith_line_numberswas requested and decoding succeeded;()otherwise.
- class tact.RenameResult(ok: bool, unavailable: bool = False, files: tuple[~pathlib.Path, ...]=<factory>, many_report: ManyReport | None = None, notified: tuple[~pathlib.Path, ...]=<factory>, error: str | None = None)#
The outcome of one
rename_symbol()call.- Parameters:
ok – Whether the rename was computed by the server and applied to every file.
unavailable – Whether the failure is “no server can even attempt this” (no live server,
renameProvidernot advertised, root blacklisted/dead) as opposed to a server-side rejection of this specific rename (ambiguous target, unsupported symbol, a conversion/transaction failure) — the two are always mutually exclusive withok, and a caller retrying afterunavailable=Trueshould not expect a different outcome without changing the LSP setup itself.files – The files touched, resolved —
()on any failure.many_report – The underlying
ManyReportfrom the conversion’sapply_many()call, orNonewhen the rename never got that far (LSP unavailable/failed, or theWorkspaceEdititself didn’t parse).notified – Files for which
notify_written()was called after a successful write.error – A human-legible reason nothing was renamed, or
Noneon success.
- class tact.Rung(*values)#
Which rung of the reconciliation ladder a match was found at — coarsest tolerance last.
The integer values are the ladder’s attempt order, so
a < breads as “rungais tried first and is the stronger signal”. Nothing persists these integers — telemetry, the CLI’s--jsonand the MCP tools all serializename— so the order may be extended without a migration.
- class tact.SeenHashes(*args, **kwargs)#
The dedup seam: anything a caller already has (a
set[str], a dict’s keys, …).__contains__is declaredobject-typed and positional-only, mirroring the real signature ofset/dict/frozensetin typeshed — those containers never raise on a wrong-typed probe, they just returnFalse, so narrowing the parameter tostrhere would (correctly) rejectset[str]/dict[str, ...]as non-conforming.
- class tact.SkeletonEntry(name: str, kind: str, line: int, end_line: int)#
One outline entry: a name, a human-legible kind, and its 1-based line range.
- class tact.SkeletonResult(path: Path, source: str, entries: tuple[SkeletonEntry, ...], error: str | None = None)#
The outcome of one
skeleton()call — always reports how it was produced.- Parameters:
path – The file described.
source –
'lsp'(a live server answered),'ast'(dependency-free fallback for.py), or'unavailable'(neither applied — seeerror).entries – The outline, in file order. Empty (not missing) for a genuinely symbol-free file at either source.
error – Set only when
sourceis'unavailable', or when theastfallback itself failed (a syntax error, undecodable bytes).
- class tact.SymbolInfo(name: str, kind: int, start: Position, end: Position, selection_start: Position, selection_end: Position, children: tuple[~tact.lsp.SymbolInfo, ...]=<factory>)#
One
documentSymbolentry — a name, its full range, itsselectionRange, children.
- class tact.UndoResult(ok: bool, path: Path, restored_seq: int | None, restored_sha256: str | None, restored_bytes: int | None, entries_remaining: int | None, error: str | None = None)#
The outcome of one
undo()call — always present, win or lose.- Parameters:
ok – Whether a pre-image was found and restored.
path – The file
undo()was called on (resolved).restored_seq – The ring-local ordinal of the entry that was restored, or
Noneon failure.restored_sha256 – The hash of the content that is now on disk, or
Noneon failure.restored_bytes – How many bytes were restored, or
Noneon failure.entries_remaining – The ring’s size for this file after this call (the just-restored entry plus the fresh entry journaled for the pre-undo content this call itself made — see module docstring on why undo is redoable), or
Noneon failure.error – A human-legible reason nothing was restored, or
Noneon success.
- tact.anchor(path: Path, block_text: str) AnchorCreateResult#
Anchor
block_textat its current, unique position inpath.- Parameters:
path – The file to anchor into. Must already exist and be (or resolve to) a regular file.
block_text – The block to anchor — must resolve to exactly one position right now (no
nearhint is available yet to break a tie; see module docstring).
- Returns:
An
AnchorCreateResult. Never raises for “doesn’t resolve uniquely” — that is a reconciliation outcome, not a malformed call.- Raises:
FileNotFoundError –
pathdoesn’t exist.ValueError –
pathis not a regular file, orblock_textis the empty string.
- tact.apply_edits(path: Path, edits: Sequence[Edit], *, expected_sha256: str | None = None, lsp_manager: LspManager | None = None, surface: 'hook' | 'library' | 'cli' | 'mcp' = 'library', harness: str | None = None, client: str | None = None) ApplyReport#
Resolve and apply a batch of edits to
pathas one atomic write.- Parameters:
path – The file to edit. Must already exist and be (or resolve to) a regular file. A symlink is followed: the target is edited in place and the link itself is preserved — the atomic rename must never materialize a link into a plain file (this repo’s symlink farms depend on that).
path.resolve()is what saves that symlink: the rename lands on the resolved target, so the link name is never the destination. A hardlink has no indirection to resolve — every name is the file — so the same mechanism that preserves symlinks is what silently breaks hardlinks: any other name hardlinked to the original keeps the pre-edit content, because temp-file-then-rename always produces a new inode. This is deliberate, not a bug: atomicity (all-or-nothing) and hardlink preservation (writing in place) are mutually exclusive here, and atomicity wins.edits – The batch. An empty sequence is a legitimate no-op (
applied=False,results=(),error=None) — nothing to do isn’t a failure.expected_sha256 – The sha256 the caller believes the file currently has (from the read its edits were derived from). A mismatch sets
drift_detected=Trueon the report but does not block the write — see module docstring.lsp_manager – An optional live
LspManager. When given, a successful write pushes the new content viadidChange/didOpenand populatesApplyReport.diagnostics_deltawith any newly-published problems (never blocking the write itself — see that field’s docstring).None(the default) skips LSP integration entirely, matching every phase-1 call site unchanged.surface – The entry point this call came through —
'library'(the default, for a direct/embedded caller),'cli'(thetactconsole script), or'mcp'(thetact-mcpserver). Recorded on every emitted telemetry event so the three populations stop landing in one indistinguishable bucket; unlikeharness, this is trustworthy to thread because each caller asserts its own identity rather than the value being sniffed from something inheritable.harness – An explicit harness attribution for this call’s telemetry events (
Nonerecordsunknown). Same assertion-based trust model assurface: pass a value only when the caller vouches for it — the CLI’s--harnessflag is the pattern — never a value inferred from the environment. MEMY-734’s adoption join reads this field.client – The calling MCP client’s verbatim
clientInfo.name(MCP server only;Noneelsewhere). Recorded alongsideharnessso the harness→client-name mapping accumulates in the stream itself.
- Returns:
An
ApplyReportdescribing what happened, per edit and for the batch.- Raises:
FileNotFoundError –
pathdoesn’t exist.ValueError –
pathis not a regular file (a FIFO or device would hang or corrupt the read), or any edit’sold_blockis the empty string (nothing to search for — a malformed call, not a reconciliation outcome).
Note
Fuzzy-rung edits to a parseable Python file are syntax-gated before the write (LIBS-49): a postimage that fails
ast.parse()makes the whole batch a structured refusal (applied=False, nothing written), with the fuzzy edit’sEditResultnaming the rung and theSyntaxError.Note
A read-only file (e.g. mode
0o444) is edited without complaint. Its mode is captured before the write and restored after, so permissions survive, but the read-only intent is not honored — this is also deliberate (“butler, not gate”): the bit is routinely a checkout artifact rather than an instruction, and asking for it to block or warn would need a field onApplyReportthat doesn’t exist, which is simultaneously an MCP-surface schema change for an advisory note.
- tact.apply_many(edits_by_path: Mapping[Path, Sequence[Edit]], *, required_sha256s: dict[Path, str] | None = None, surface: 'hook' | 'library' | 'cli' | 'mcp' = 'library', harness: str | None = None, client: str | None = None) ManyReport#
Resolve every file’s edit batch, then write all of them or none of them.
- Parameters:
edits_by_path – One edit batch per file. An empty dict, or a dict whose every value is an empty sequence, is a legitimate no-op (
applied=False,error=None).required_sha256s – Optional per-file optimistic-concurrency hashes, keyed the same way as
edits_by_path. Unliketact.apply.apply_edits(), a mismatch here blocks the transaction — see module docstring.surface – The entry point this call came through —
'library'(the default),'cli', or'mcp'. Same contract astact.apply.apply_edits()’ssurfaceparameter.harness – Explicit harness attribution for this transaction’s telemetry events (
Nonerecordsunknown). Same assertion-based trust model astact.apply.apply_edits()’sharness: pass a value only when the caller vouches for it, never one inferred from the environment (MEMY-734).client – The calling MCP client’s verbatim
clientInfo.name(MCP server only); recorded on every event row. Seetact.telemetry.record_event().
- Returns:
A
ManyReportdescribing what happened, per file and for the batch.- Raises:
ValueError – any edit anywhere has an empty
old_block— a malformed call, checked up front across the whole batch before any file is touched.
- tact.build_adoption_report(events: list[dict[str, Any]] | None = None, *, days: int = 7, gemini_dir: Path | None = None) AdoptionReport#
Build an adoption report from the event stream.
- Parameters:
events – Pre-loaded event rows (for testing). Defaults to
load_events().days – Reporting window in days (default 7 for the shadow baseline).
gemini_dir – Override the Gemini ledger directory (for testing).
- Returns:
A deterministic
AdoptionReportholding the two event populations side by side, and naming what cannot be derived from them.
- tact.canonicalize(line: str) str#
Normalize Unicode punctuation so visually-identical text compares equal.
Ported from dirac’s
canonicalize()(shared/string.ts): NFC-normalizes the string, folds curly quotes/dashes/NBSP-family code points to their ASCII (or plain-space) equivalent via_PUNCT_EQUIV, then un-escapes backslash-escaped backticks and quotes (\`/\'/\") so an edit block that was copied with or without escaping still compares equal.
- tact.check_edits(path: Path, edits: Sequence[Edit], *, expected_sha256: str | None = None) CheckReport#
Dry-run
apply_edits(): the identical resolution pipeline, writing nothing.- Parameters:
path – Same contract as
apply_edits().edits – Same contract as
apply_edits().expected_sha256 – Same contract as
apply_edits().
- Returns:
A
CheckReportdescribing what would happen, plus a unified-diff preview.- Raises:
FileNotFoundError –
pathdoesn’t exist.ValueError –
pathis not a regular file, or any edit’sold_blockis empty — identical raising contract toapply_edits().
- tact.find_block(file_lines: Sequence[str], target_lines: Sequence[str], *, near: int | None = None) MatchResult#
Locate
target_linesinfile_lines, descending the reconciliation ladder.- Parameters:
file_lines – The file’s content, one entry per line, no trailing newlines.
target_lines – The block to find, same convention.
near – An optional line-index hint. When a rung finds multiple candidates, the one closest to
nearis picked and the resolution is recorded (ambiguous=True,candidateslists every tied position); withoutnear, multiple candidates are a structured failure instead.
- Returns:
A
MatchResult. Never raises for an ordinary “didn’t find it” or “found more than one” outcome — those are data (ambiguous/error), not exceptions.- Raises:
ValueError –
target_linesis empty — a programmer error (there is nothing to search for), not a reconciliation outcome.
- tact.find_project_root(path: Path) Path#
Walk up from
pathfor the nearestpyproject.toml/.git, else its own parent.One server pair is spawned per root this function returns — never one server for an entire monorepo (pyrefly’s own docs warn about import-path heuristics outside a config root).
- tact.heal(path: Path, old_string: str, new_string: str) HealResult#
Reconcile a failed builtin-Edit’s
old_stringagainstpath’s current content.- Parameters:
path – The file the builtin edit was attempted against. Must already exist and be (or resolve to) a regular file.
old_string – The exact
old_stringthe builtin editor was given and rejected.new_string – The exact
new_stringthe builtin editor was given — carried through unchanged intoHealResult.edit(healnever rewrites the replacement text, only re-locates where it should land).
- Returns:
A
HealResult— never raises for an ordinary “didn’t find it” or “found more than one” outcome (those areambiguous/error, not exceptions).- Raises:
FileNotFoundError –
pathdoesn’t exist.ValueError –
pathis not a regular file, orold_stringis the empty string (nothing to search for — a malformed call, not a reconciliation outcome; matchesapply_edits()’s own contract for an emptyold_block).
- tact.history(path: Path) tuple[HistoryEntry, ...]#
Every journaled pre-image for
path, newest first.()if none, or on corruption.Never raises — an unreadable/corrupt ring index is reported as empty history, the same “nothing to show” a caller sees for a file that was simply never journaled.
- tact.journal_preimage(path: Path, content: bytes | str, mode: int | None) None#
Stash
content— a file’s content right before an imminent write — content-addressed.- Parameters:
path – The file this pre-image belongs to (used only as the ring index’s key; the blob itself is stored independent of any path).
content – The pre-write bytes, or the equivalent text. Hashed and stored as UTF-8 text (every caller in this package already decoded its content as UTF-8 before this point —
original_bytes— so no bytes round-trip is lost); bytes that fail UTF-8 decoding are a swallowed failure, not a raised one, same as every other failure mode here.mode – The file’s permission bits at journal time, or
None.
Never raises. Every failure (unwritable
ai_home(), disk full, undecodable content) is swallowed and reported to stderr — a journal failure must never block or fail the write it was about to protect (see module docstring).
- tact.list_anchors() tuple[str, ...]#
Every saved anchor’s name, sorted.
()if none exist, or the store is unreadable.Never raises — an unreadable
ai_home()/tact/anchors/is reported as “no anchors”, same as a store that was simply never used.
- tact.load_anchor(name: str) AnchorLoadResult#
Load a previously
save_anchor()-d anchor byname.Never raises: an invalid
name, a missing file, or corrupt JSON is a structuredok=False, not an exception — see module docstring.
- tact.load_blob(sha256: str) bytes | None#
The stashed content for
sha256, orNoneif this journal never saw it.Content-addressed and global: any ring entry (for any file) that ever journaled this exact content makes it available here, regardless of which file it was originally stashed from — the hash is the only key. Never raises; a corrupt/unreadable blob is reported as
None, same as a blob that was never written.
- tact.parse_fenced_blocks(text: str) PatchParseResult#
Parse this package’s
File:-headedold/newfenced convention into edits.See the module docstring for the exact accepted grammar and what counts as malformed.
- Parameters:
text – The full text containing zero or more
File:-headed old/new fence pairs.- Returns:
A
PatchParseResult. Text with no fenced pairs at all parses toPatchParseResult(files=(), ok=True)— a legitimate “nothing here”.
- tact.parse_patch(text: str) PatchParseResult#
Parse
textas a unified diff, falling back to this package’s fenced-block grammar.The auto-detect a skin (the
tactCLI’sapply-patch, thetact_apply_patchMCP tool) needs when it doesn’t already know which of the two grammars a caller sent:parse_unified_diff()runs first and reportsfiles=()(neverok=False) when it finds no---/+++headers at all (see that function’s own “nothing here” contract) — only then doesparse_fenced_blocks()get a turn. Lives here, not in a skin, so both skins can never disagree about which grammar wins.
- tact.parse_unified_diff(text: str) PatchParseResult#
Parse standard unified-diff text into a per-file
Editbatch.See the module docstring for the exact accepted grammar and what counts as malformed.
- Parameters:
text – The full diff text (one or more
--- ``/``+++ `` file-header pairs, each followed by one or more ``@@ @@hunks).- Returns:
A
PatchParseResult. Text with no recognizable--- ``/``+++ `` headers at all parses to ``PatchParseResult(files=(), ok=True)— a legitimate “nothing here”, not an error (mirrorsapply_edits’s own empty-batch-is-a-no-op stance).
- tact.read_file(path: Path, *, offset: int | None = None, limit: int | None = None, max_bytes: int = 256000, seen_hashes: SeenHashes | None = None, with_line_numbers: bool = False) ReadResult#
Read
path, hashing the whole file and returning a (possibly capped) slice of it.- Parameters:
path – The file to read. Must exist and be (or resolve to) a regular file.
offset – 0-based line to start returning from, or
Nonefor the start of the file. Applied after decoding, beforelimit/max_bytes.limit – Maximum number of lines to return from
offset, orNonefor “to the end (subject tomax_bytes)”.max_bytes – Cap on the UTF-8-encoded size of the returned
content. The file is still read and hashed in full regardless of this cap — only what’s returned is bounded.seen_hashes – An optional dedup oracle (see
SeenHashes); when it already contains this file’s hash,contentis omitted andunchanged=True.with_line_numbers – When set (and decoding succeeds), populate
line_numberswith the 1-based number of each returned line.
- Returns:
A
ReadResult. Never raises for “file isn’t valid UTF-8” (reported viadecode_error) — only for outright missing/non-regular files, which are programmer/caller errors, not read outcomes.- Raises:
FileNotFoundError –
pathdoesn’t exist.ValueError –
pathis not a regular file, oroffset,limit, ormax_bytesis negative.
- tact.read_since(path: Path, known_sha256: str) DeltaResult#
Report what changed in
pathsince the caller last sawknown_sha256.- Parameters:
path – The file to check.
known_sha256 – The sha256 the caller believes
pathhas (from a priorread_file()call, typically).
- Returns:
A
DeltaResult. Deliberately never raises for a missing file — unlikeread_file()’s own contract, “the file disappeared since you last looked” is exactly the kind of delta this verb exists to report, not a malformed call:statusis set toDeltaStatus.MISSINGinstead.
- tact.rename_symbol(path: Path, target: str | tuple[int, int] | Position, new_name: str, manager: LspManager, *, surface: 'hook' | 'library' | 'cli' | 'mcp' = 'library', harness: str | None = None, client: str | None = None) RenameResult#
Rename
target(inpath) tonew_name, across every file the server names.- Parameters:
path – The file containing the symbol to rename. Must already exist and be (or resolve to) a regular file.
target – Either a bare symbol name (resolved name-first, matching every other
LspManagerverb) or a raw(line, character)position.new_name – The identifier’s new name, passed through to the server verbatim.
manager – A live
LspManager. Unlike every write-side verb elsewhere in this package, this parameter is required, not optional — a rename has no symbolic fallback (see module docstring).surface – The entry point this call came through —
'library'(the default),'cli', or'mcp'. Same contract astact.apply.apply_edits()’ssurfaceparameter, threaded into theapply_many()call this function makes internally so a rename’s telemetry attributes to the caller that actually issued it, not to an undifferentiated'library'default.harness – Explicit harness attribution (
Nonerecordsunknown), threaded through to the internalapply_many()call. Same assertion-based trust model astact.apply.apply_edits()’sharness(MEMY-734).client – The calling MCP client’s verbatim
clientInfo.name(MCP server only); threaded through to the event rows.
- Returns:
A
RenameResult. Never raises for an LSP failure, an unsupported rename, or a malformedWorkspaceEdit— those are structured outcomes; seeunavailablefor how to distinguish “no server available” from “the server rejected this rename”.- Raises:
FileNotFoundError –
pathdoesn’t exist.ValueError –
pathis not a regular file.
- tact.resolve_anchor(a: Anchor) AnchorResult#
Re-locate
a.block_textina.path, fast path first.- Parameters:
a – A previously-created
Anchor, fromanchor()orload_anchor().- Returns:
An
AnchorResult. Never raises for “the file moved” or “no longer resolves” — both are structured outcomes (a stale anchor pointing at a file that’s gone, or one the ladder can no longer place uniquely, is exactly what this verb exists to report).
- tact.save_anchor(a: Anchor, name: str) AnchorSaveResult#
Persist
aundernameinai_home()/tact/anchors/.Never raises: an invalid
nameor any IO failure is a structuredok=False, not an exception — see module docstring.
- tact.skeleton(path: Path, *, manager: LspManager | None = None) SkeletonResult#
The outline of
path: LSPdocumentSymbolfirst,astfallback for.py.- Parameters:
path – The file to outline.
manager – A live
LspManagerto try first, orNoneto go straight to theastfallback (or “unavailable” for non-Python files).
- Returns:
A
SkeletonResult, never raising for a missing server or a syntax error in the file — both degrade to a reported, non-lspsource.
- tact.undo(path: Path) UndoResult#
Restore
path’s newest journaled pre-image, through the atomic writer.This reverses the single most recent write this journal saw for
path— a toggle, not a multi-step history walk. Before restoring, the file’s current content is itself journaled (viajournal_preimage()), so callingundoagain reverses this undo and lands back where you started; walking further back than one step means readinghistory()and restoring a specific blob by hand (load_blob()pluswrite_atomic()) — an honest limit of the “toggle” design, not a missing feature this call itself performs.- Parameters:
path – The file to restore. Must already exist and be (or resolve to) a regular file —
undoreverts a prior write’s content, it does not resurrect a file some other tool deleted (matchingapply_edits()’s own existence contract for the same reason: that is a different, unattempted feature, not this one degrading).- Returns:
An
UndoResult. Never raises for “nothing to restore” or “the stashed blob is gone” — those are structured failures, not exceptions.- Raises:
FileNotFoundError –
pathdoesn’t exist.ValueError –
pathis not a regular file.
Native hook routing#
One routing policy, rendered into five native agent-hook contracts.
The routing boundary is intentionally narrow: tact owns reconciliation edits to existing ordinary files. Harness-native creation tools remain available for new files, notebooks stay with notebook-aware tools, and malformed or unrecognized payloads fail open. This keeps policy separate from each harness’s hook mechanism while making recovery guidance visible only when the hook actually declines a native edit.
No file content is read here. Existence and suffix checks are the entire filesystem-facing classification surface; the actual edit continues through tact’s ordinary CLI or MCP APIs.
- class tact.routing.Harness(*values)#
A supported agent harness.
- class tact.routing.RouteMode(*values)#
How an eligible builtin edit is handled.
- class tact.routing.HookAction(*values)#
The normalized decision before native rendering.
- class tact.routing.HookResult(harness: Harness, mode: RouteMode, action: HookAction, eligible: bool, reason: str, paths: tuple[~pathlib.Path, ...]=<factory>, output: dict[str, ~typing.Any]=<factory>)#
A normalized routing decision plus its harness-native JSON.
- Parameters:
harness – Harness whose native contract was rendered.
mode – Configured rollout mode.
action – Effective allow, warning, or denial.
eligible – Whether the operation belongs on tact’s reconciliation path.
reason – Explanation for a denial or an ineligible classification; empty for a permitted eligible edit.
paths – Normalized target paths. Content is deliberately never retained.
output – JSON object to emit on standard output.
- tact.routing.route_hook(harness: Harness | str, payload: dict[str, Any], *, mode: RouteMode | str = RouteMode.WARN) HookResult#
Classify and render one native pre-edit hook call.
- Parameters:
harness – Native harness contract to parse and render.
payload – JSON object read from the hook’s standard input.
mode –
shadowobserves,warnobserves eligible native edits without injecting standing advice (the default), anddenyreroutes eligible edits by refusing the builtin tool with a concrete tact next action.
- Returns:
A
HookResult. Malformed and unsupported operations always fail open.
Installation diagnostics#
Fast, read-mostly installation checks for tact’s fleet-facing surfaces.
A checkup, not a gate: every check that comes back not-ok and has something a human could
do about it carries that repair in Check.fix (why, in prose) and
Check.command (the runnable line, kept separate so a renderer can reflow the one and
never the other), so tact doctor prints the way out beside the finding instead of leaving
the reader to guess. A fix of None means there is genuinely nothing to do — most often
a harness that simply isn’t installed on this box, which is a fact about the machine rather
than a fault in the installation, and which inspect_installation() already excludes
from the health verdict.
- class tact.doctor.DoctorStatus(*values)#
Top-level health categories suitable for fleet automation.
- class tact.doctor.Check(ok: bool, detail: str, fix: str | None = None, command: str | None = None)#
One named readiness check.
- Parameters:
ok – Whether this component is ready.
detail – What was observed — a version, a path, an error string.
fix – Why this matters and what to do about it, in prose, when
okisFalseand there is something to do.Noneboth whenok(nothing to fix) and when the not-ok state is informational rather than broken (an absent harness).command – The exact command that performs
fix, when one command does. Kept separate from the prose because a renderer may reflow prose to the page but must never reflow a command — a path broken across lines cannot be pasted, and the runnable line is the part a reader most needs intact.
- class tact.doctor.DoctorReport(status: DoctorStatus, version: str, python: Check, cli: Check, mcp: Check, storage: Check, telemetry: Check, harnesses: dict[str, Check])#
Tact installation and runtime readiness at one instant.
- tact.doctor.inspect_installation() DoctorReport#
Inspect import, storage, optional MCP, and telemetry readiness.
- Returns:
A stable
DoctorReport. The optional MCP extra may be absent without making the core CLI unhealthy; that is represented as a degraded component under an otherwise healthy installation.
Privacy-minimal telemetry#
Per-outcome counters — the data that gates the neural rungs (instruments-04).
One counter per ladder outcome: a rung name (exact/canon/rstrip/indent/
subline/reflow/fuzzy) for every edit that resolved, plus ambiguous,
no_match, indent_refused (a rung-4 match with no uniform indent shift to re-indent
by safely), overlap_rejected, and drift_detected for the
structured-failure paths. Persisted as one small JSON file under
tact._home.ai_home() — tact/telemetry.json — so a later decision about whether the
symbolic ladder actually needs a neural rung is made from counts, not vibes (N-36). Unknown
keys are counted, not rejected — this module doesn’t
gatekeep future rung names.
Counting must never be able to break a write. Every write here is best-effort: any IO error
is swallowed and reported to stderr, never raised into the caller (apply_edits).
Tests point the counters somewhere disposable by setting $AI_HOME (the seam
tact._home.ai_home() already honors) — there is deliberately no path
parameter here, one way in, one location.
- tact.telemetry.Surface#
The entry point an event came through — the complete vocabulary, as a type.
Three review lenses independently observed that a bare
strhere lets a typo ('clii','CLI','cli ') silently fork a fourth bucket that no report reads, quietly re-creating the single indistinguishable population this field exists to split. ALiteralmakes that a type error at every call site instead, which is the cheapest possible enforcement: static, zero runtime cost, and no new branch to test.Deliberately not enforced at runtime.
record_eventmust never raise into a write path, and this module’s standing rule is that unknown outcome keys are counted rather than rejected — so a value that somehow reaches the stream still gets written and shows up inadoption.py’sother_surface_eventsbucket rather than being dropped.alias of
Literal[‘hook’, ‘library’, ‘cli’, ‘mcp’]
- tact.telemetry.telemetry_path() Path#
Where the counters live:
ai_home() / "tact" / "telemetry.json".
- tact.telemetry.load() dict[str, int]#
Read the current counters, or an empty dict if the file is missing/corrupt.
- tact.telemetry.record(outcomes: Iterable[str]) None#
Increment one or more outcome counters in a single atomic read-modify-write.
- Parameters:
outcomes – The outcome keys to increment by one each (an
apply_edits()call may report several in one batch — e.g. twoexactmatches plus onedrift_detected).
Never raises. Any failure (unwritable dir, disk full, permissions) is swallowed and reported to stderr — telemetry is advisory, an apply must never fail because counting did.
- tact.telemetry.record_event(*, outcome: str, harness: str, surface: 'hook' | 'library' | 'cli' | 'mcp', mode: str, eligible: bool, decision: str, duration_ms: float | None = None, batch_size: int | None = None, rung: str | None = None, client: str | None = None) None#
Append one content-free operational event.
- Parameters:
outcome – Stable event outcome such as
routedorhealed.harness – Calling harness, or
unknownoutside a hook. Only ever an explicit caller assertion — the hook’s installed--harness, a CLI--harnessflag — orunknown; never sniffed from the environment, which every descendant process inherits and which would mislabel, say, a test suite run from inside a harness’s shell as that harness’s activity.surface – Entry surface such as
hook,cli, ormcp.mode – Routing or apply mode active for the event.
eligible – Whether tact owned the attempted operation.
decision – Effective decision or write outcome.
batch_size – How many edits the accompanying
duration_mscovers, orNone. Only ever set beside a duration: an apply-side batch (any surface) emits one row per edit but is timed once, so the measurement rides a single row and this says how many edits it timed. Without it a reader cannot tell a slow single edit from a fast batch of ten.duration_ms – End-to-end operation duration in milliseconds, or
Nonewhen the caller has no measurement to report.Noneomits the key entirely rather than writing a placeholder0.0— a reader must be able to tell “took no measurable time” from “was never timed”, becausebuild_adoption_report()computes latency percentiles over these values and a placeholder zero silently poisons them.rung – Optional reconciliation rung. File paths, content, sessions, repositories, and user identifiers are intentionally not accepted by this API.
client – The calling MCP client’s self-reported software name (the
clientInfo.namefrom the protocol’sinitializehandshake), verbatim and unmapped, orNonewhen unknown — presence-only-when-known, likeduration_ms. This is the empirical capture the harness→client mapping needs: the stream itself accumulates which name strings real harnesses send, so a later mapping pass decides from evidence rather than invention. A program name is software identity, not a user identifier; it rides the same privacy contract asharnessitself.
Each event is one compact JSON line written with
O_APPEND. As with aggregate counters, telemetry failure is advisory and never escapes into the edit path.