intact 0.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- intact-0.7.1.dist-info/METADATA +154 -0
- intact-0.7.1.dist-info/RECORD +25 -0
- intact-0.7.1.dist-info/WHEEL +4 -0
- intact-0.7.1.dist-info/entry_points.txt +4 -0
- tact/README.md +3 -0
- tact/__init__.py +133 -0
- tact/_home.py +35 -0
- tact/_io.py +89 -0
- tact/_serialize.py +54 -0
- tact/anchor.py +331 -0
- tact/apply.py +571 -0
- tact/cli.py +615 -0
- tact/delta.py +157 -0
- tact/eval.py +393 -0
- tact/heal.py +210 -0
- tact/lsp.py +932 -0
- tact/mcp_server.py +334 -0
- tact/patch.py +376 -0
- tact/read.py +216 -0
- tact/reconcile.py +424 -0
- tact/rename.py +345 -0
- tact/skeleton.py +122 -0
- tact/telemetry.py +70 -0
- tact/transact.py +322 -0
- tact/undo.py +304 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: intact
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: A reconciliation ladder that heals stale or drifted edits instead of failing them, with atomic multi-file transactions, patch ingestion, undo, durable anchors, and an LSP client — as a library, a CLI, and an MCP server.
|
|
5
|
+
Author: Robb Doering
|
|
6
|
+
Author-email: Robb Doering <robb@doering.ai>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
8
|
+
Classifier: Framework :: Pydantic :: 2
|
|
9
|
+
Classifier: Typing :: Typed
|
|
10
|
+
Requires-Dist: fastmcp>=3.4.0 ; extra == 'mcp'
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Project-URL: Homepage, https://gitlab.com/doering-ai/libs/tact
|
|
13
|
+
Project-URL: Source, https://gitlab.com/doering-ai/libs/tact
|
|
14
|
+
Project-URL: Issues, https://gitlab.com/doering-ai/libs/tact/-/issues
|
|
15
|
+
Project-URL: Documentation, https://intact.readthedocs.io
|
|
16
|
+
Provides-Extra: mcp
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# intact: _Self-Healing File Edits for Agents_
|
|
20
|
+
|
|
21
|
+
  [](/LICENSE) [](https://intact.readthedocs.io)  [](https://pypi.org/project/intact) [](https://pypi.org/project/intact)
|
|
22
|
+
|
|
23
|
+
[](https://github.com/pre-commit/pre-commit) [](https://gitlab.com/doering-ai/corpus/-/blob/main/policies/versioning.md) [](https://github.com/facebook/pyrefly) [](https://github.com/astral-sh/ruff)
|
|
24
|
+
|
|
25
|
+
> A reconciliation ladder that heals stale or drifted edits instead of failing them, with atomic multi-file transactions, patch ingestion, undo, durable anchors, and an LSP client — as a library, a CLI, and an MCP server.
|
|
26
|
+
|
|
27
|
+
> [!NOTE] **Genesis.** `intact` was designed, built, reviewed, and shipped end-to-end by **Claude (Fable 5)** as an autonomous long-run engineering experiment — from mining two weeks of real agent-session ledgers for the failure class worth killing, through a fleet of builder/critic subagents and adversarial review rounds, to the benchmark, the extraction, and this release.
|
|
28
|
+
> The human operator set the charter, held the release gates, and found the name.
|
|
29
|
+
> The eval numbers below are measured on that same fleet's real failures — this package was built by the population it serves.
|
|
30
|
+
|
|
31
|
+
______________________________________________________________________
|
|
32
|
+
|
|
33
|
+
## The problem, measured
|
|
34
|
+
|
|
35
|
+
An independent audit of two weeks of this fleet's own Claude Code session ledgers found that the single largest *recurring* cost line wasn't a wrong answer or a slow model — it was harness friction: an agent's builtin `Edit` tool failing an exact-string match against text that had drifted underneath it (formatter passes, a stale read, whitespace normalization), 99 instances totaling roughly 190K tokens over the window, each one paid as a full read-diff-retry round trip.
|
|
36
|
+
`tact` exists to kill that class of failure at the source: it heals what's mechanical (whitespace, Unicode punctuation, indentation shift, small textual drift) through a symbolic, self-reporting reconciliation ladder, and it refuses loudly — never silently — the moment a match is genuinely ambiguous or absent, because a wrong guess in a file edit is worse than an honest failure.
|
|
37
|
+
It ships as a library, a CLI, and an MCP server, so the same reconciliation core backs whichever front end a given agent harness actually calls.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install intact
|
|
43
|
+
# or
|
|
44
|
+
uv add intact
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The CLI and library work with no extras.
|
|
48
|
+
The MCP server needs the `mcp` extra:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv add "intact[mcp]"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 60-second quickstart
|
|
55
|
+
|
|
56
|
+
### As a library
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from tact import read_file, apply_edits, Edit
|
|
60
|
+
|
|
61
|
+
r = read_file('config.py')
|
|
62
|
+
apply_edits(
|
|
63
|
+
'config.py',
|
|
64
|
+
[Edit(old='DEBUG = True', new='DEBUG = False')],
|
|
65
|
+
expected_sha256=r.sha256, # advisory -- a mismatch heals, it never blocks
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### As a CLI
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# a builtin exact-match Edit just failed -- heal it in one round trip
|
|
73
|
+
uv run tact heal config.py --old "DEBUG = True" --new "DEBUG = False"
|
|
74
|
+
|
|
75
|
+
# or drive the whole read -> edit cycle through tact directly
|
|
76
|
+
uv run tact --json edit config.py --old "DEBUG = True" --new "DEBUG = False"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### As an MCP server
|
|
80
|
+
|
|
81
|
+
Register `tact` (adapt the project path for your own install):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv run --extra mcp --project /path/to/tact tact-mcp
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Then call `tact_edit`/`tact_heal`/`tact_definition`/… as MCP tools instead of the harness's builtin Read/Edit.
|
|
88
|
+
See `docs/howto.md` for per-harness registration snippets (Claude Code, Droid, Codex, Cursor).
|
|
89
|
+
|
|
90
|
+
## The reconciliation ladder
|
|
91
|
+
|
|
92
|
+
`find_block` descends five rungs of mounting tolerance, stopping at the *finest* rung that yields a **unique** match:
|
|
93
|
+
|
|
94
|
+
| Rung | What it forgives | Example |
|
|
95
|
+
| -------- | --------------------------------------------------- | ---------------------------------------------- |
|
|
96
|
+
| `EXACT` | Nothing — verbatim line equality | `old_string` matches byte-for-byte |
|
|
97
|
+
| `CANON` | Unicode punctuation substitution | a curly `"quote"` pasted in for a straight one |
|
|
98
|
+
| `RSTRIP` | Trailing whitespace drift | a formatter stripped trailing spaces on save |
|
|
99
|
+
| `INDENT` | A uniform leading-whitespace shift | the block moved one indent level deeper |
|
|
100
|
+
| `FUZZY` | Small textual drift, bounded by per-line similarity | a stale line number after a nearby edit landed |
|
|
101
|
+
|
|
102
|
+
Two or more candidates at any rung is a **structured ambiguity refusal** (every candidate shown, resolved only by a `--near` hint); zero candidates at every rung is a **structured no-match refusal**.
|
|
103
|
+
Rungs 1-4 are true equivalence relations; rung 5 is a similarity ball, not transitive — which is exactly why ambiguity-handling concentrates there.
|
|
104
|
+
See `docs/explanation.md` for the full framing.
|
|
105
|
+
|
|
106
|
+
## The numbers
|
|
107
|
+
|
|
108
|
+
Replayed against the fleet's own real edit-failure history (`tact.eval`, ledger-mined, no `near` hint supplied — any multi-candidate rung is a refusal, never a guess):
|
|
109
|
+
|
|
110
|
+
| Corpus | Cases | Healed | False heals | Correct refusals |
|
|
111
|
+
| ------------------------------------------ | ----- | ------ | ----------- | ---------------- |
|
|
112
|
+
| Public subset (this repo, `tests/`) | 59 | 43 | 0 | 1 |
|
|
113
|
+
| Full private fleet corpus (eval-of-record) | 111 | 68 | 0 | 1 |
|
|
114
|
+
|
|
115
|
+
The public subset is a privacy-reviewed 59-fixture slice of the full 121-fixture harvest (62 fixtures held back for operator-stack detail or private prose, not for reconciliation performance — see `tests/fixtures/ledger_eval/README.md` for the curation).
|
|
116
|
+
Cases that don't heal are mostly `no_match`, not misses: a large share are `modified_since_read` failures where the stored file snapshot legitimately predates the real edit, so a refusal there is reality reflected correctly, not a ladder weakness.
|
|
117
|
+
|
|
118
|
+
## Features
|
|
119
|
+
|
|
120
|
+
- **`check`/`edit`** — resolve-then-atomically-write a batch of edits, with a dry-run twin (`check_edits`) that previews without touching the file.
|
|
121
|
+
- **`apply_many` transactions** — resolve every file in a batch before writing any of them; any failure blocks the whole transaction, nothing partial is ever written.
|
|
122
|
+
- **`heal`** — fix a failed builtin `Edit(path, old_string, new_string)` in one read-only round trip.
|
|
123
|
+
- **`undo`** — a content-addressed pre-image journal behind every write, with a redoable `undo` verb.
|
|
124
|
+
- **Durable anchors** — capture a block's position so it survives a session/compaction boundary; `resolve_anchor` re-finds it, biased by its own remembered index.
|
|
125
|
+
- **`delta`/`read_since`** — a cheap "what changed since this hash" re-read, backed by the undo journal's blob store.
|
|
126
|
+
- **`rename_symbol`** — LSP-computed cross-file renames, converted into one `apply_many` transaction (the one verb that is not LSP-optional).
|
|
127
|
+
- **`apply_patch`** — ingest a unified diff or a fenced old/new block pair straight into an `Edit` batch.
|
|
128
|
+
- **LSP navigation** — `def`/`refs`/`hover`/`sym`/`diag`, name-first, backed by a persistent, resilient pyrefly/ruff client.
|
|
129
|
+
|
|
130
|
+
## Honest limits
|
|
131
|
+
|
|
132
|
+
`tact` matches line-by-line, so a paragraph reflow (a prose formatter that rewraps text across different line boundaries) is not a whitespace/indent variant it heals — expect a no-match refusal and a manual re-read there.
|
|
133
|
+
Ambiguity refusals need a `--near` hint or a more distinctive block; tact will not pick a near-tie for you.
|
|
134
|
+
`rename_symbol` is LSP-backed only — it never falls back to a textual find-replace, since that risks renaming the wrong binding, so it structurally refuses when no live LSP is available.
|
|
135
|
+
The undo journal keeps the newest 32 pre-images per file and is index-pruned, not globally garbage-collected — it is acceptable-loss state, not a full version history.
|
|
136
|
+
|
|
137
|
+
## Provenance
|
|
138
|
+
|
|
139
|
+
The reconciliation ladder's canonicalization table and fuzzy-matching primitives in `tact/reconcile.py` are a port, with attribution, of parts of [`dirac`](https://github.com/dirac-run/dirac) (Apache-2.0, version 0.4.11) — see `NOTICE` and `reconcile.py`'s own module docstring for exactly what was ported and how it was adapted.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
Apache-2.0.
|
|
144
|
+
See [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
|
|
145
|
+
|
|
146
|
+
______________________________________________________________________
|
|
147
|
+
|
|
148
|
+
## Versioning
|
|
149
|
+
|
|
150
|
+
This project follows [workflow-gated semver]: the minor version records the highest-completed `py-workflow` step (`0.1.0`-`0.8.0`), with `0.9.0` (beta) gated on >=9 external users and `1.0.0` on a maintainer social guarantee.
|
|
151
|
+
The current version `0.7.0` means gate `7` is the highest cleared.
|
|
152
|
+
See the [normative rubric](https://gitlab.com/doering-ai/corpus/-/blob/main/policies/versioning.md) and the [overview](https://gitlab.com/doering-ai/corpus/-/blob/main/docs/versioning.md).
|
|
153
|
+
|
|
154
|
+
[workflow-gated semver]: https://gitlab.com/doering-ai/corpus/-/blob/main/docs/versioning.md
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
tact/README.md,sha256=FvEdE1mzj9BQ1RM4OvDbnS1Jrv-AuN03aaMeu62y5ss,196
|
|
2
|
+
tact/__init__.py,sha256=SAbEwFKkM1z-ddicxJpg7ZJG8J-ujwxAl-h3CcW2QEQ,4964
|
|
3
|
+
tact/_home.py,sha256=K-Cr4H0YZHLyLjVyGQYxaxuEuf_yPdsH_kGoPYdB4WU,1110
|
|
4
|
+
tact/_io.py,sha256=oNtq0CQ0XedaADSpZEjKgYC6rwcwLZJV4Iyulb5zy78,3418
|
|
5
|
+
tact/_serialize.py,sha256=wwUiYd4dGvmxcl1p1t-yoTzWj9D14ewYJs8Tfd1t2RE,2386
|
|
6
|
+
tact/anchor.py,sha256=8104f9dfCmH2z8C8l7_AiCq0Jd0HjROmy_FrWQXKhNw,12781
|
|
7
|
+
tact/apply.py,sha256=Um2O03iqdACOhPGDvuWlDAL4M6qGnPDg1oPa-YQRcIc,25316
|
|
8
|
+
tact/cli.py,sha256=aYTPFRJ96nkRF8yZ6GPMPk8gj19KkqD-7PJns0dBMtY,24129
|
|
9
|
+
tact/delta.py,sha256=lEGTx_lG36Q8_Jiwy2_aHaaMkByV-W44YjuMpGxZnu4,6930
|
|
10
|
+
tact/eval.py,sha256=y9zMdS4ZjtpVY5BQ7tugyq4yoGRvjeJnBIlgo87WzcM,15967
|
|
11
|
+
tact/heal.py,sha256=-Za29GvQ-wv8_BOLjIiQ2lWcUom9vghwzHdhPq9K_Iw,9297
|
|
12
|
+
tact/lsp.py,sha256=H5Akm7PmDcpMfFTPwvMqEYaBSVKBt1Yuy6QczDnfKYU,38988
|
|
13
|
+
tact/mcp_server.py,sha256=TlHZur2lAbaBZpg5bpodXYBNykHo0QSYBFetbbI6Upw,13994
|
|
14
|
+
tact/patch.py,sha256=ZMXBySncz_357-xDlX1mjVYt1x3yLHwQHRiTt5gbNoE,16108
|
|
15
|
+
tact/read.py,sha256=QoloIxW0IqK5qO07H4eWCS641EUbOcn9fJjnzufT8B8,9494
|
|
16
|
+
tact/reconcile.py,sha256=C8iJcUVJqXTYtEYL8KXCdn2dEBHITmBmQXvahcAjgqA,17560
|
|
17
|
+
tact/rename.py,sha256=RMFQ-QLmQFC-qIC0dx3Xq8ThUp0dRgZUdpzRcV-fjmg,16001
|
|
18
|
+
tact/skeleton.py,sha256=6IUu5bPRe-mHZJaq490e7e0YQ-T-KY8-qf1Qvbmrmgc,4465
|
|
19
|
+
tact/telemetry.py,sha256=040K6DQftWlqUN0jVXJGY6hRA5o2aywIR4oBDtkfJRA,2721
|
|
20
|
+
tact/transact.py,sha256=ovsp_ORG14JKn9cy1oZnLNJhJ28JB5_7a7t7kTydRxg,14543
|
|
21
|
+
tact/undo.py,sha256=qvqZ4xOjIxzAM68dSBnWivLjXGfFLjy--fQZv4G4SVQ,13620
|
|
22
|
+
intact-0.7.1.dist-info/WHEEL,sha256=jK0lbM7sVtq70msNoYotEXYS3OJMDdns2CRgyjhimnE,81
|
|
23
|
+
intact-0.7.1.dist-info/entry_points.txt,sha256=fbx5CMh15qEbCT6yKgk9rdCFs09jZgpEjG6DJxJIC0k,72
|
|
24
|
+
intact-0.7.1.dist-info/METADATA,sha256=yLXQWnQPEA8HEGj3D4at8Xn71EJ6WsbLROoDFg8gC_Y,10582
|
|
25
|
+
intact-0.7.1.dist-info/RECORD,,
|
tact/README.md
ADDED
tact/__init__.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""`tact` (Latin *tactus*, touch) — a self-healing write core for agent file edits.
|
|
2
|
+
|
|
3
|
+
Agents that read a file, decide on an edit, and write it back are routinely defeated by
|
|
4
|
+
turbulence that has nothing to do with the edit's substance: trailing-whitespace drift,
|
|
5
|
+
curly-quote/dash/NBSP substitutions introduced by a copy-paste, an indentation shift from a
|
|
6
|
+
block moving a level deeper, or a stale line number after a concurrent edit landed first.
|
|
7
|
+
Builtin exact-match editors hard-fail on all of these and hand the turbulence back to the
|
|
8
|
+
model as a diff to re-derive — expensive, and it happens on every file, every day.
|
|
9
|
+
|
|
10
|
+
`tact` handles this in library, not in every agent's context: `reconcile` finds a target
|
|
11
|
+
block under a fixed, self-reporting ladder of increasing tolerance (exact -> canonicalized ->
|
|
12
|
+
whitespace-insensitive -> indentation-insensitive -> bounded fuzzy match), `apply` turns a
|
|
13
|
+
batch of such finds into one atomic, overlap-checked write, and `telemetry` counts which rung
|
|
14
|
+
actually did the work so a later decision about adding a neural rung is made from data.
|
|
15
|
+
|
|
16
|
+
Phase 1 shipped the reconciliation core: `reconcile.py`, `apply.py`, `telemetry.py`. Phase 2
|
|
17
|
+
added the read side (`read.py`, `skeleton.py`) and a persistent, stdlib-only pyrefly/ruff LSP
|
|
18
|
+
client (`lsp.py`), wired into `apply_edits` via an optional `lsp_manager` for post-write
|
|
19
|
+
diagnostics deltas. The `tact-02` feature wave rounds out the verb set in the same minimalist
|
|
20
|
+
mold, in two halves. Wave A: `check_edits` (dry-run preview, in `apply.py`), `patch.py`
|
|
21
|
+
(unified-diff / fenced-block ingestion into `Edit` batches), `transact.py` (`apply_many`,
|
|
22
|
+
resolve-everything-then-write-all-or-nothing across files), and `heal.py` (the
|
|
23
|
+
builtin-Edit-failure fixer). Wave B: `undo.py` (a content-addressed pre-image journal, wired
|
|
24
|
+
into every write path, with a redoable `undo` verb), `rename.py` (`textDocument/rename` via
|
|
25
|
+
`LspManager.rename`, converted to a multi-file `apply_many` transaction — the one verb in this
|
|
26
|
+
package that is *not* LSP-optional, since renaming has no honest symbolic fallback),
|
|
27
|
+
`anchor.py` (durable, re-findable block handles surviving a session/compaction boundary), and
|
|
28
|
+
`delta.py` (`read_since`, cheap re-reads that compose with `undo.py`'s journal for a real line
|
|
29
|
+
diff). Later phases (`instruments-03`/`-04` in the task backlog) add a console script and an
|
|
30
|
+
MCP skin — `tact` itself stays the one library both front ends call into (N-32/N-35: policy in
|
|
31
|
+
the skins, mechanism in the library).
|
|
32
|
+
|
|
33
|
+
**Provenance.** The reconciliation ladder in `reconcile.py` ports, with attribution, parts of
|
|
34
|
+
`dirac` (`github.com/dirac-run/dirac`, Apache-2.0, version 0.4.11) — see the `NOTICE` file at
|
|
35
|
+
the repo root and the provenance block in `reconcile.py`'s own module docstring for exactly
|
|
36
|
+
what was ported and how it was adapted.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from tact.anchor import (
|
|
40
|
+
Anchor,
|
|
41
|
+
AnchorCreateResult,
|
|
42
|
+
AnchorLoadResult,
|
|
43
|
+
AnchorResult,
|
|
44
|
+
AnchorSaveResult,
|
|
45
|
+
anchor,
|
|
46
|
+
list_anchors,
|
|
47
|
+
load_anchor,
|
|
48
|
+
resolve_anchor,
|
|
49
|
+
save_anchor,
|
|
50
|
+
)
|
|
51
|
+
from tact.apply import ApplyReport, CheckReport, Edit, EditResult, apply_edits, check_edits
|
|
52
|
+
from tact.delta import DeltaResult, DeltaStatus, read_since
|
|
53
|
+
from tact.heal import HealResult, heal
|
|
54
|
+
from tact.lsp import (
|
|
55
|
+
Diagnostic,
|
|
56
|
+
Location,
|
|
57
|
+
LspManager,
|
|
58
|
+
LspResult,
|
|
59
|
+
Position,
|
|
60
|
+
SymbolInfo,
|
|
61
|
+
find_project_root,
|
|
62
|
+
)
|
|
63
|
+
from tact.patch import (
|
|
64
|
+
PatchFileResult,
|
|
65
|
+
PatchParseResult,
|
|
66
|
+
parse_fenced_blocks,
|
|
67
|
+
parse_patch,
|
|
68
|
+
parse_unified_diff,
|
|
69
|
+
)
|
|
70
|
+
from tact.read import ReadResult, SeenHashes, read_file
|
|
71
|
+
from tact.reconcile import Candidate, MatchResult, Rung, canonicalize, find_block
|
|
72
|
+
from tact.rename import RenameResult, rename_symbol
|
|
73
|
+
from tact.skeleton import SkeletonEntry, SkeletonResult, skeleton
|
|
74
|
+
from tact.transact import ManyFileResult, ManyReport, apply_many
|
|
75
|
+
from tact.undo import HistoryEntry, UndoResult, history, journal_preimage, load_blob, undo
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
'Anchor',
|
|
79
|
+
'AnchorCreateResult',
|
|
80
|
+
'AnchorLoadResult',
|
|
81
|
+
'AnchorResult',
|
|
82
|
+
'AnchorSaveResult',
|
|
83
|
+
'ApplyReport',
|
|
84
|
+
'Candidate',
|
|
85
|
+
'CheckReport',
|
|
86
|
+
'DeltaResult',
|
|
87
|
+
'DeltaStatus',
|
|
88
|
+
'Diagnostic',
|
|
89
|
+
'Edit',
|
|
90
|
+
'EditResult',
|
|
91
|
+
'HealResult',
|
|
92
|
+
'HistoryEntry',
|
|
93
|
+
'Location',
|
|
94
|
+
'LspManager',
|
|
95
|
+
'LspResult',
|
|
96
|
+
'ManyFileResult',
|
|
97
|
+
'ManyReport',
|
|
98
|
+
'MatchResult',
|
|
99
|
+
'PatchFileResult',
|
|
100
|
+
'PatchParseResult',
|
|
101
|
+
'Position',
|
|
102
|
+
'ReadResult',
|
|
103
|
+
'RenameResult',
|
|
104
|
+
'Rung',
|
|
105
|
+
'SeenHashes',
|
|
106
|
+
'SkeletonEntry',
|
|
107
|
+
'SkeletonResult',
|
|
108
|
+
'SymbolInfo',
|
|
109
|
+
'UndoResult',
|
|
110
|
+
'anchor',
|
|
111
|
+
'apply_edits',
|
|
112
|
+
'apply_many',
|
|
113
|
+
'canonicalize',
|
|
114
|
+
'check_edits',
|
|
115
|
+
'find_block',
|
|
116
|
+
'find_project_root',
|
|
117
|
+
'heal',
|
|
118
|
+
'history',
|
|
119
|
+
'journal_preimage',
|
|
120
|
+
'list_anchors',
|
|
121
|
+
'load_anchor',
|
|
122
|
+
'load_blob',
|
|
123
|
+
'parse_fenced_blocks',
|
|
124
|
+
'parse_patch',
|
|
125
|
+
'parse_unified_diff',
|
|
126
|
+
'read_file',
|
|
127
|
+
'read_since',
|
|
128
|
+
'rename_symbol',
|
|
129
|
+
'resolve_anchor',
|
|
130
|
+
'save_anchor',
|
|
131
|
+
'skeleton',
|
|
132
|
+
'undo',
|
|
133
|
+
]
|
tact/_home.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Vendored agent-home resolver — `$AI_HOME` (else `$AI`, else `~/.ai`).
|
|
2
|
+
|
|
3
|
+
`tact` must not depend on `corpus`. This is a minimal, stdlib-only port of
|
|
4
|
+
`corpus.ai_home.ai_home()` (see `myCorpus`, `policies/ai_home.md`) — just enough to resolve
|
|
5
|
+
the agent-owned working-state home tact writes into (`undo.py`'s journal, `anchor.py`'s
|
|
6
|
+
saved anchors, `telemetry.py`'s counters). See that module for the full convention,
|
|
7
|
+
bootstrap/CLI, and rationale; this file intentionally carries none of that beyond the
|
|
8
|
+
resolver itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
############
|
|
12
|
+
### HEAD ###
|
|
13
|
+
############
|
|
14
|
+
### STANDARD
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
############
|
|
21
|
+
### DATA ###
|
|
22
|
+
############
|
|
23
|
+
_AI_HOME_ENV = 'AI_HOME'
|
|
24
|
+
_AI_ENV = 'AI'
|
|
25
|
+
_DEFAULT_AI_HOME = '~/.ai'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
############
|
|
29
|
+
### BODY ###
|
|
30
|
+
############
|
|
31
|
+
def ai_home() -> Path:
|
|
32
|
+
"""The agent home: `$AI_HOME`, else `$AI`, else `~/.ai` — an absolute path, never created."""
|
|
33
|
+
override = os.environ.get(_AI_HOME_ENV) or os.environ.get(_AI_ENV)
|
|
34
|
+
home = Path(override or _DEFAULT_AI_HOME).expanduser()
|
|
35
|
+
return home if home.is_absolute() else Path.cwd() / home
|
tact/_io.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Shared atomic-write plumbing for the tact package.
|
|
2
|
+
|
|
3
|
+
One implementation of the temp-file-then-rename idiom (same-directory temp so the
|
|
4
|
+
:func:`os.replace` stays on one filesystem, flush + fsync before rename, best-effort
|
|
5
|
+
cleanup of the temp file on any failure) — used by the apply path, multi-file transactions,
|
|
6
|
+
and telemetry.
|
|
7
|
+
|
|
8
|
+
:func:`write_atomic` is the single-file entry point (stage, then commit, in one call) that
|
|
9
|
+
every phase-1/2 caller already uses unchanged. It is composed from two smaller primitives,
|
|
10
|
+
:func:`_stage` and :func:`_commit`, split out for :mod:`tact.transact`'s multi-file
|
|
11
|
+
``apply_many``: a transaction must stage *every* file's content (write + fsync, no rename)
|
|
12
|
+
before committing *any* of them, so it cannot call ``write_atomic`` per file — that would
|
|
13
|
+
rename each file the moment its own content was staged, defeating the all-or-nothing point.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
############
|
|
17
|
+
### HEAD ###
|
|
18
|
+
############
|
|
19
|
+
### STANDARD
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import os
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
############
|
|
29
|
+
### BODY ###
|
|
30
|
+
############
|
|
31
|
+
def _stage(path: Path, content: str) -> Path:
|
|
32
|
+
"""Write ``content`` to a fresh temp file in ``path``'s directory; do not rename it.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: The eventual destination — only its parent directory and name are used here
|
|
36
|
+
(same-directory temp keeps the later rename on one filesystem).
|
|
37
|
+
content: The full new file content, written UTF-8, flushed and fsynced before return.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The temp file's path, unrenamed — the caller owns committing it (:func:`_commit`)
|
|
41
|
+
or cleaning it up on failure.
|
|
42
|
+
"""
|
|
43
|
+
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), prefix=f'.{path.name}-', suffix='.tmp')
|
|
44
|
+
tmp = Path(tmp_path)
|
|
45
|
+
try:
|
|
46
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
47
|
+
f.write(content)
|
|
48
|
+
f.flush()
|
|
49
|
+
os.fsync(f.fileno())
|
|
50
|
+
except Exception:
|
|
51
|
+
with contextlib.suppress(OSError):
|
|
52
|
+
tmp.unlink()
|
|
53
|
+
raise
|
|
54
|
+
return tmp
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _commit(tmp: Path, path: Path, *, mode: int | None = None) -> None:
|
|
58
|
+
"""Rename a staged temp file onto ``path`` — the point of no return for that file.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
tmp: A temp file previously returned by :func:`_stage`.
|
|
62
|
+
path: The destination. Must not be a symlink — callers resolve first, so the
|
|
63
|
+
rename lands on the real file and never materializes a link.
|
|
64
|
+
mode: Permission bits to apply to the new file, or ``None`` to accept the
|
|
65
|
+
temp file's default.
|
|
66
|
+
"""
|
|
67
|
+
tmp.replace(path)
|
|
68
|
+
if mode is not None:
|
|
69
|
+
# After the rename: a briefly-too-private final file beats a world-readable temp.
|
|
70
|
+
path.chmod(mode)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_atomic(path: Path, content: str, *, mode: int | None = None) -> None:
|
|
74
|
+
"""Write ``content`` to ``path`` atomically (temp file in the same dir, then rename).
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
path: The destination. Must not be a symlink — callers resolve first, so the
|
|
78
|
+
rename lands on the real file and never materializes a link.
|
|
79
|
+
content: The full new file content, written UTF-8.
|
|
80
|
+
mode: Permission bits to apply to the new file, or ``None`` to accept the
|
|
81
|
+
temp file's default.
|
|
82
|
+
"""
|
|
83
|
+
tmp = _stage(path, content)
|
|
84
|
+
try:
|
|
85
|
+
_commit(tmp, path, mode=mode)
|
|
86
|
+
except Exception:
|
|
87
|
+
with contextlib.suppress(OSError):
|
|
88
|
+
tmp.unlink()
|
|
89
|
+
raise
|
tact/_serialize.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
r"""One JSON-shaping helper, shared by the CLI's ``--json`` and the MCP server.
|
|
2
|
+
|
|
3
|
+
Every verb in this package returns a frozen dataclass (often nesting other frozen
|
|
4
|
+
dataclasses, enums, ``Path``\\s, and tuples) — exactly the shape a human-facing ``--json``
|
|
5
|
+
flag or an MCP tool's return value needs to become plain JSON-safe data. :func:`to_jsonable`
|
|
6
|
+
is the one place that conversion happens, so the CLI and the MCP server can never disagree
|
|
7
|
+
about what a given result looks like as JSON.
|
|
8
|
+
|
|
9
|
+
**Enum handling.** :class:`~tact.reconcile.Rung` is an ``IntEnum`` — an ``int``
|
|
10
|
+
subclass — so the ``Enum`` check must run *before* the ``int``/``float``/``bool`` check below,
|
|
11
|
+
or every rung would serialize as a bare integer instead of its name (the spec's own
|
|
12
|
+
"asdict + enum names" contract).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
############
|
|
16
|
+
### HEAD ###
|
|
17
|
+
############
|
|
18
|
+
### STANDARD
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import fields, is_dataclass
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
############
|
|
28
|
+
### BODY ###
|
|
29
|
+
############
|
|
30
|
+
def to_jsonable(obj: Any) -> Any:
|
|
31
|
+
"""Recursively convert ``obj`` into plain JSON-safe data (``dict``/``list``/scalars).
|
|
32
|
+
|
|
33
|
+
Handles the shapes every verb in this package actually returns: frozen dataclasses
|
|
34
|
+
(including nested ones, e.g. :class:`~tact.rename.RenameResult`'s
|
|
35
|
+
``many_report``), ``Enum``/``IntEnum`` members (rendered as ``.name``, never ``.value``
|
|
36
|
+
or the raw int), ``Path`` (rendered as ``str``), and ``tuple``/``list`` (rendered as
|
|
37
|
+
``list``). Anything else (``None``, ``bool``, ``int``, ``float``, ``str``, already-plain
|
|
38
|
+
``dict``) passes through unchanged — this function never raises on a shape it doesn't
|
|
39
|
+
recognize; ``json.dumps`` is left to complain if something genuinely unsupported slips
|
|
40
|
+
through, which nothing in this package's public result types currently does.
|
|
41
|
+
"""
|
|
42
|
+
if isinstance(obj, Enum):
|
|
43
|
+
return obj.name
|
|
44
|
+
if obj is None or isinstance(obj, bool | int | float | str):
|
|
45
|
+
return obj
|
|
46
|
+
if isinstance(obj, Path):
|
|
47
|
+
return str(obj)
|
|
48
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
49
|
+
return {f.name: to_jsonable(getattr(obj, f.name)) for f in fields(obj)}
|
|
50
|
+
if isinstance(obj, list | tuple):
|
|
51
|
+
return [to_jsonable(item) for item in obj]
|
|
52
|
+
if isinstance(obj, dict):
|
|
53
|
+
return {str(k): to_jsonable(v) for k, v in obj.items()}
|
|
54
|
+
return obj
|