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 unmeasured for 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 HarnessRow per 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 --harness assertion — the only trust channel that names a harness today). Unknown-attributed events are excluded here and visible in library_events - sum(library_by_harness.values()).

  • client_census – Census of the verbatim clientInfo.name values 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_events counts 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 at index with 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_text resolved 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_text resolved to a single, unique anchor right now.

  • anchor – The resulting Anchor, or None on 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 near hint — see module docstring — so ambiguous=True here always means ok=False).

  • candidates – Every candidate position, when ambiguous is set.

  • error – A human-legible reason anchoring failed, or None on 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 None on failure.

  • rung – Which rung resolved it (None on the fast, sha-unchanged path — no rung ran).

  • fuzz – The rung’s match-quality score, or None on 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 ambiguous is set.

  • error – A human-legible reason re-resolution failed, or None on 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. False means 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_sha256 was 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 EditResult per input edit, same order.

  • error – A batch-level reason nothing was written (e.g. which edits overlapped), or None when applied is True or the batch was a legitimate no-op.

  • diagnostics_delta – New LSP diagnostics published for path after this write, or None. Advisory only — a write never fails because of it, and None means “no LSP available for this write” (no lsp_manager passed, 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 None for an empty no-op.

  • result_sha256 – SHA-256 of the UTF-8 postimage actually written, or None when 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_lines where 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); None at 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 of ApplyReport.applied (never named applied itself: that would misleadingly imply a write happened).

  • drift_detected – Same meaning as ApplyReport.drift_detected.

  • results – One EditResult per 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 when would_apply is False (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 None for an empty no-op.

  • result_sha256 – SHA-256 of the UTF-8 postimage that this call would write, or None when 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 DeltaStatus outcomes this is.

  • new_sha256 – The file’s current content hash, or None when status is MISSING (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 for CHANGED, '' 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), None for every other status, including REWRITTEN itself when the current content isn’t valid UTF-8 (see note in that case).

  • note – A structured, human-legible explanation for a degraded outcome (why no baseline was recoverable, or why content/diff is empty despite a real change), or None when nothing needs explaining.

class tact.DeltaStatus(*values)#

Which of the three outcomes read_since() reports.

UNCHANGED = 'unchanged'#

known_sha256 matches 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_sha256 was 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 publishDiagnostics entry, tagged with which server reported it.

Hashable/comparable by value (the default dataclass __eq__/__hash__ via frozen=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 with new_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. False means 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); ok never reflects “was actually written” beyond that — see ApplyReport.applied for the batch-level outcome.

  • rung – Which rung resolved this edit, or None on 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, or None when 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, within line_range’s first and last line respectively. For every line-aligned rung this is the full width of those lines; the SUBLINE and REFLOW rungs are the ones that can report a partial line, so this is where a partial-line edit self-reports what it actually touched. None on failure, and populated alongside line_range on an indent refusal for the same reason.

  • indent_delta – The rung-4 uniform indent shift, or None (see MatchResult). None here is why ok is False when rung is INDENT — check is not None, not truthiness, since 0 is a distinct, valid shift.

  • ambiguous – Whether the anchor had multiple candidates (resolved via near or 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 ambiguous is 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-harness 0 there 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), or unknown (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_attempts in deny mode, where the native edit was blocked outright. The one branch where reroute-or-abandon is guaranteed.

  • warned – Subset of eligible_attempts in warn mode, where the native edit was advised against but allowed to proceed. The upper bound on hook-visible bypass; see UNMEASURED_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 None with no samples.

  • p95_ms – 95th percentile hook-routing duration, or None with 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_string resolved to a single, usable anchor in the file’s current content, and (at rung INDENT) a uniform shift could be computed for it. False also covers a rung-4 match with no uniform indent shift — the anchor was found, but healing refuses rather than guess at a re-indent, matching apply_edits()’s own refusal.

  • rung – Which rung resolved it, or the rung the ambiguity/indent-refusal was found at, or None when no rung ever produced a candidate.

  • fuzz – The rung’s match-quality score (1.0 for rungs 1-6, mean per-line similarity for rung 7).

  • line_range – The [start, end) line interval the match occupies, or None when 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, within line_range’s first and last line. Full line widths at every line-aligned rung; a genuinely partial line only at SUBLINE/REFLOW. Without it a partial-line heal would report the lines it touched but not how much of them, overstating its own reach. None on failure.

  • indent_delta – The rung-4 uniform indent shift, or None (see MatchResult). None at rung INDENT is why ok is False — check is not None, not truthiness, since 0 is 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 fresh Edit.old_block is guaranteed to resolve at rung EXACT. None when 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 next new_block should be derived from it directly rather than guessed at.

  • edit – A ready-to-apply Edit built from the healed anchor (old_block as above, new_block=new_string, near set to the match’s start line so a caller re-running the ladder breaks any tie in the same direction this call did). None on failure.

  • preview – A unified diff of applying edit at the healed span — built through the same splice path apply_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 ambiguous is set.

  • error – A human-legible reason healing failed, or None on success.

  • applied – Always False — heal() never writes (see module docstring). The field exists so that a success can never read as an apply: a convincing ok=True plus 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=False beside 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/references return.

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_server precedent) — an out-of-band edit, a config change, anything the running server can’t see.

shutdown_all() → None#

Kill every live server. Registered with atexit so 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: where target (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 of target (a name or position).

hover(path: Path, target: str | tuple[int, int] | Position) → LspResult[str | None]#

hover: the type/docstring info for target (a name or position).

capabilities(path: Path) → dict[str, Any] | None#

The live pyrefly server’s advertised initialize capabilities for path’s root.

None when 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() checks renameProvider here 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 raw WorkspaceEdit for renaming target to new_name.

Returns the response’s raw result (a WorkspaceEdit dict, or None when the server legitimately has nothing to change) wrapped in LspResult — parsing that into per-file Edit batches and applying them is tact.rename.rename_symbol()’s job, not this client’s; this method stays symmetric with every other verb here (degrade to ok=False + a legible error, 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() (which tact.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’s publishDiagnostics to wait for.

diagnostics_for(path: Path) → tuple[Diagnostic, ...] | None#

Every diagnostic currently known for path, merged across live servers.

None means 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 after but not in before — new problems only.

Deviation from the literal spec signature (diagnostics_delta(path, before, after)): path is dropped since a Diagnostic set is already scoped to one file by the caller (both diagnostics_for snapshots came from the same path) — 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.

None when no server is live for path’s root — the advisory field this feeds (ApplyReport.diagnostics_delta) must stay None in 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 takes before from that — a real look at what the file looked like right before this specific edit — then pushes new_text, settles again, and diffs. didChange is in-memory only, so ending on new_text leaves 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. False covers every LSP failure mode uniformly (no server, timeout, protocol error, unresolved symbol name) — callers branch on ok, not on exception types, because none of this raises.

  • value – The result, or None on failure.

  • error – A human-legible reason, or None on 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_sha256s named it, without drift — i.e. whether this file would have been part of the write.

  • results – One EditResult per 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_sha256s named 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 None when 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 None when 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. False means 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 ManyFileResult per key in edits_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_start of line index and ends at column col_end of line end - 1. Callers must splice using all four fields rather than assuming end == index + len(target_lines) — that assumption holds for the line-aligned rungs but is false for SUBLINE/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. None only when no rung ever produced a candidate at all, i.e. the text is genuinely absent rather than present-but-unusable. Read index is None for “nothing resolved, nothing can be written”; read rung for where that was decided.

  • index – 0-based line index the match starts at, or None on failure.

  • end – 0-based line index one past the last line the match touches, or None on failure. Equals index + len(target_lines) at every line-aligned rung.

  • col_start – Character column within line index where the match begins; 0 at every line-aligned rung.

  • col_end – Character column within line end - 1 where the match ends (exclusive); the length of that line at every line-aligned rung.

  • fuzz – Match quality in [0, 1]; 1.0 for 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. None at every other rung, and None at rung 4 itself when the block’s indentation isn’t uniformly shifted (no single delta describes it) or mixes tabs into its leading whitespace. This None is not merely informational: a write-time caller (apply_edits(), heal()) must refuse rather than splice the replacement unshifted – see indent_refusal_reason(). A 0 is a genuine computed shift (no re-indent needed) and must never be treated the same as None; a plain truthiness check on this field conflates the two.

  • ambiguous – True when a rung found two or more candidates — whether or not a near hint went on to resolve one of them.

  • candidates – Every candidate position found when ambiguous is True (empty otherwise): all of them on a bare ambiguity failure, or the full tied set (with index pointing at the near-resolved winner) on a resolved ambiguity.

  • error – A human-legible failure reason, or None on 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 None for a fenced old/new pair that had no preceding File: marker to attribute it to.

  • edits – The parsed Edit batch 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 None on 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 PatchFileResult per target file named in the input, in first-seen order.

  • ok – Whether every named file parsed cleanly. False means at least one file’s entry carries an error — 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 a str) sidesteps this entirely, since the position then comes from the server’s own documentSymbol response, already in its native encoding — prefer it over a hand-built position where possible.

to_lsp() → dict[str, int]#

This position as an LSP-wire {line, character} dict.

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_bytes or the line_numbers rendering — the anchor a later apply_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). None when unchanged is True (nothing new to return) or when the file could not be decoded as UTF-8 (decode_error set instead).

  • unchanged – True when seen_hashes already contained this file’s sha256 — the caller is holding stale interest in a file whose bytes haven’t moved; content is omitted to make that cheap.

  • truncated – Whether max_bytes cut the returned content short of the file’s actual size. Never true when unchanged is 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 content were actually returned (UTF-8-encoded length, not char count) — equal to total_bytes unless truncated.

  • uses_crlf – Whether the file (or the truncated prefix read) uses CRLF line endings.

  • decode_error – A human-legible reason content is None because the bytes weren’t valid UTF-8 (binary file, wrong encoding) — distinct from unchanged fully by being reported here rather than via the dedup flag.

  • line_numbers – The 1-based line number of each entry in content, split the way tact._lines.split_lines() splits it (on \n/\r\n only), so a line number reported here is the same line number an edit resolves against. Present when with_line_numbers was 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, renameProvider not 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 with ok, and a caller retrying after unavailable=True should not expect a different outcome without changing the LSP setup itself.

  • files – The files touched, resolved — () on any failure.

  • many_report – The underlying ManyReport from the conversion’s apply_many() call, or None when the rename never got that far (LSP unavailable/failed, or the WorkspaceEdit itself didn’t parse).

  • notified – Files for which notify_written() was called after a successful write.

  • error – A human-legible reason nothing was renamed, or None on 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 < b reads as “rung a is tried first and is the stronger signal”. Nothing persists these integers — telemetry, the CLI’s --json and the MCP tools all serialize name — so the order may be extended without a migration.

property spans_partial_lines: bool#

True for the rungs whose match need not start and end on line boundaries.

class tact.SeenHashes(*args, **kwargs)#

The dedup seam: anything a caller already has (a set[str], a dict’s keys, …).

__contains__ is declared object-typed and positional-only, mirroring the real signature of set/dict/frozenset in typeshed — those containers never raise on a wrong-typed probe, they just return False, so narrowing the parameter to str here would (correctly) reject set[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 — see error).

  • entries – The outline, in file order. Empty (not missing) for a genuinely symbol-free file at either source.

  • error – Set only when source is 'unavailable', or when the ast fallback 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 documentSymbol entry — a name, its full range, its selectionRange, 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 None on failure.

  • restored_sha256 – The hash of the content that is now on disk, or None on failure.

  • restored_bytes – How many bytes were restored, or None on 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 None on failure.

  • error – A human-legible reason nothing was restored, or None on success.

tact.anchor(path: Path, block_text: str) → AnchorCreateResult#

Anchor block_text at its current, unique position in path.

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 near hint 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:
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 path as 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=True on 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 via didChange/didOpen and populates ApplyReport.diagnostics_delta with 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' (the tact console script), or 'mcp' (the tact-mcp server). Recorded on every emitted telemetry event so the three populations stop landing in one indistinguishable bucket; unlike harness, 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 (None records unknown). Same assertion-based trust model as surface: pass a value only when the caller vouches for it — the CLI’s --harness flag 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; None elsewhere). Recorded alongside harness so the harness→client-name mapping accumulates in the stream itself.

Returns:

An ApplyReport describing what happened, per edit and for the batch.

Raises:
  • FileNotFoundError – path doesn’t exist.

  • ValueError – path is not a regular file (a FIFO or device would hang or corrupt the read), or any edit’s old_block is 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’s EditResult naming the rung and the SyntaxError.

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 on ApplyReport that 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. Unlike tact.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 as tact.apply.apply_edits()’s surface parameter.

  • harness – Explicit harness attribution for this transaction’s telemetry events (None records unknown). Same assertion-based trust model as tact.apply.apply_edits()’s harness: 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. See tact.telemetry.record_event().

Returns:

A ManyReport describing 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 AdoptionReport holding 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:
Returns:

A CheckReport describing what would happen, plus a unified-diff preview.

Raises:
tact.find_block(file_lines: Sequence[str], target_lines: Sequence[str], *, near: int | None = None) → MatchResult#

Locate target_lines in file_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 near is picked and the resolution is recorded (ambiguous=True, candidates lists every tied position); without near, 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_lines is empty — a programmer error (there is nothing to search for), not a reconciliation outcome.

tact.find_project_root(path: Path) → Path#

Walk up from path for the nearest pyproject.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_string against path’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_string the builtin editor was given and rejected.

  • new_string – The exact new_string the builtin editor was given — carried through unchanged into HealResult.edit (heal never 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 are ambiguous/error, not exceptions).

Raises:
  • FileNotFoundError – path doesn’t exist.

  • ValueError – path is not a regular file, or old_string is the empty string (nothing to search for — a malformed call, not a reconciliation outcome; matches apply_edits()’s own contract for an empty old_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 by name.

Never raises: an invalid name, a missing file, or corrupt JSON is a structured ok=False, not an exception — see module docstring.

tact.load_blob(sha256: str) → bytes | None#

The stashed content for sha256, or None if 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:-headed old / new fenced 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 to PatchParseResult(files=(), ok=True) — a legitimate “nothing here”.

tact.parse_patch(text: str) → PatchParseResult#

Parse text as a unified diff, falling back to this package’s fenced-block grammar.

The auto-detect a skin (the tact CLI’s apply-patch, the tact_apply_patch MCP tool) needs when it doesn’t already know which of the two grammars a caller sent: parse_unified_diff() runs first and reports files=() (never ok=False) when it finds no ---/+++ headers at all (see that function’s own “nothing here” contract) — only then does parse_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 Edit batch.

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 (mirrors apply_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 None for the start of the file. Applied after decoding, before limit/max_bytes.

  • limit – Maximum number of lines to return from offset, or None for “to the end (subject to max_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, content is omitted and unchanged=True.

  • with_line_numbers – When set (and decoding succeeds), populate line_numbers with the 1-based number of each returned line.

Returns:

A ReadResult. Never raises for “file isn’t valid UTF-8” (reported via decode_error) — only for outright missing/non-regular files, which are programmer/caller errors, not read outcomes.

Raises:
tact.read_since(path: Path, known_sha256: str) → DeltaResult#

Report what changed in path since the caller last saw known_sha256.

Parameters:
  • path – The file to check.

  • known_sha256 – The sha256 the caller believes path has (from a prior read_file() call, typically).

Returns:

A DeltaResult. Deliberately never raises for a missing file — unlike read_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: status is set to DeltaStatus.MISSING instead.

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 (in path) to new_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 LspManager verb) 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 as tact.apply.apply_edits()’s surface parameter, threaded into the apply_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 (None records unknown), threaded through to the internal apply_many() call. Same assertion-based trust model as tact.apply.apply_edits()’s harness (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 malformed WorkspaceEdit — those are structured outcomes; see unavailable for how to distinguish “no server available” from “the server rejected this rename”.

Raises:
tact.resolve_anchor(a: Anchor) → AnchorResult#

Re-locate a.block_text in a.path, fast path first.

Parameters:

a – A previously-created Anchor, from anchor() or load_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 a under name in ai_home()/tact/anchors/.

Never raises: an invalid name or any IO failure is a structured ok=False, not an exception — see module docstring.

tact.skeleton(path: Path, *, manager: LspManager | None = None) → SkeletonResult#

The outline of path: LSP documentSymbol first, ast fallback for .py.

Parameters:
  • path – The file to outline.

  • manager – A live LspManager to try first, or None to go straight to the ast fallback (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-lsp source.

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 (via journal_preimage()), so calling undo again reverses this undo and lands back where you started; walking further back than one step means reading history() and restoring a specific blob by hand (load_blob() plus write_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 — undo reverts a prior write’s content, it does not resurrect a file some other tool deleted (matching apply_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:

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 – shadow observes, warn observes eligible native edits without injecting standing advice (the default), and deny reroutes 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 ok is False and there is something to do. None both when ok (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 str here 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. A Literal makes 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_event must 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 in adoption.py’s other_surface_events bucket 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. two exact matches plus one drift_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.event_path() → Path#

Return the append-only, privacy-minimal event stream path.

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 routed or healed.

  • harness – Calling harness, or unknown outside a hook. Only ever an explicit caller assertion — the hook’s installed --harness, a CLI --harness flag — or unknown; 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, or mcp.

  • 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_ms covers, or None. 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 None when the caller has no measurement to report. None omits the key entirely rather than writing a placeholder 0.0 — a reader must be able to tell “took no measurable time” from “was never timed”, because build_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.name from the protocol’s initialize handshake), verbatim and unmapped, or None when unknown — presence-only-when-known, like duration_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 as harness itself.

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.