loom-code 0.1.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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/checkpoint.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Auto-checkpoint — silent, automatic snapshots for fearless edits.
|
|
2
|
+
|
|
3
|
+
The retention feature that beats Cursor's: before the agent makes a
|
|
4
|
+
batch of changes, loom-code snapshots the ENTIRE working tree (tracked
|
|
5
|
+
edits AND untracked new files) as a real git commit object — then the
|
|
6
|
+
user can revert one step with ``/undo`` even mid-session, without
|
|
7
|
+
knowing git. Cursor's checkpoints are an opaque per-edit snapshot; ours
|
|
8
|
+
are inspectable git objects you can ``git show``, durable across a
|
|
9
|
+
crash, and they never touch your branch, index, or working tree.
|
|
10
|
+
|
|
11
|
+
Why not ``git stash``: ``git stash create`` silently DROPS untracked
|
|
12
|
+
files (verified) — so a checkpoint taken right before the agent writes
|
|
13
|
+
a brand-new file couldn't restore the pre-write state. Instead we build
|
|
14
|
+
a snapshot the robust way:
|
|
15
|
+
|
|
16
|
+
1. copy the repo's index to a TEMP index file (outside the tree),
|
|
17
|
+
2. ``git add -A`` against that temp index (stages tracked + new),
|
|
18
|
+
3. ``git write-tree`` → a tree object of the full working state,
|
|
19
|
+
4. ``git commit-tree`` → a commit object parented on HEAD.
|
|
20
|
+
|
|
21
|
+
The real index + working tree are never touched (the temp index
|
|
22
|
+
absorbs the staging), so taking a checkpoint is invisible to the user
|
|
23
|
+
and to any in-flight git state. Restore is ``git restore --source=
|
|
24
|
+
<snapshot> --worktree -- .`` which rewrites the working tree to the
|
|
25
|
+
snapshot without moving HEAD or the branch.
|
|
26
|
+
|
|
27
|
+
The snapshot stack lives in ``.loom/checkpoints.json`` (capped). Like
|
|
28
|
+
everything in loom-code's ``.loom`` state, this is best-effort: a git
|
|
29
|
+
failure, a non-repo, a disk error — every function degrades to a no-op
|
|
30
|
+
+ a returned error string, never an exception that could kill a turn.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import subprocess
|
|
38
|
+
import tempfile
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from datetime import UTC, datetime
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
_CHECKPOINTS_FILENAME = "checkpoints.json"
|
|
45
|
+
_SCHEMA_VERSION = 1
|
|
46
|
+
|
|
47
|
+
# Keep the last N checkpoints. A long session shouldn't accumulate
|
|
48
|
+
# thousands of dangling commit objects; old snapshots fall off the
|
|
49
|
+
# stack (the commit objects become unreachable + get GC'd by git).
|
|
50
|
+
_MAX_CHECKPOINTS = 50
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Checkpoint:
|
|
55
|
+
"""One snapshot: its sequence number, the snapshot commit SHA, a
|
|
56
|
+
one-line summary (the prompt that triggered it), and when."""
|
|
57
|
+
|
|
58
|
+
seq: int
|
|
59
|
+
sha: str
|
|
60
|
+
summary: str
|
|
61
|
+
created_at: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _git(
|
|
65
|
+
cwd: Path | str, args: list[str], *, env: dict[str, str] | None = None
|
|
66
|
+
) -> tuple[int, str, str]:
|
|
67
|
+
"""Run a git subcommand. Returns (rc, stdout, stderr); never raises
|
|
68
|
+
on non-zero exit. ``env`` overlays os.environ (used to point git at
|
|
69
|
+
a temp index)."""
|
|
70
|
+
full_env = {**os.environ, **env} if env else None
|
|
71
|
+
try:
|
|
72
|
+
proc = subprocess.run(
|
|
73
|
+
["git", *args],
|
|
74
|
+
cwd=str(cwd),
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=60,
|
|
78
|
+
env=full_env,
|
|
79
|
+
)
|
|
80
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
81
|
+
return 1, "", str(exc)
|
|
82
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_git_repo(root: Path | str) -> bool:
|
|
86
|
+
return (Path(root) / ".git").exists()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _checkpoints_path(root: Path | str) -> Path:
|
|
90
|
+
return Path(root) / ".loom" / _CHECKPOINTS_FILENAME
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load(root: Path | str) -> dict[str, Any]:
|
|
94
|
+
"""Load the checkpoint stack, or a fresh empty one. Never raises."""
|
|
95
|
+
path = _checkpoints_path(root)
|
|
96
|
+
try:
|
|
97
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, json.JSONDecodeError):
|
|
99
|
+
return {"version": _SCHEMA_VERSION, "next_seq": 1, "stack": []}
|
|
100
|
+
if (
|
|
101
|
+
not isinstance(data, dict)
|
|
102
|
+
or not isinstance(data.get("stack"), list)
|
|
103
|
+
):
|
|
104
|
+
return {"version": _SCHEMA_VERSION, "next_seq": 1, "stack": []}
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _save(root: Path | str, data: dict[str, Any]) -> None:
|
|
109
|
+
"""Persist the stack. Best-effort — a disk error silently no-ops."""
|
|
110
|
+
path = _checkpoints_path(root)
|
|
111
|
+
try:
|
|
112
|
+
path.parent.mkdir(exist_ok=True)
|
|
113
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _now_iso() -> str:
|
|
119
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _snapshot_commit(root: Path) -> tuple[str | None, str]:
|
|
123
|
+
"""Build a commit object capturing the FULL working tree (tracked +
|
|
124
|
+
untracked, excluding .loom) without touching the real index/tree.
|
|
125
|
+
|
|
126
|
+
Returns ``(sha, "")`` or ``(None, error)``. The technique: stage
|
|
127
|
+
everything into a throwaway temp index, write a tree from it, and
|
|
128
|
+
commit-tree that parented on HEAD.
|
|
129
|
+
"""
|
|
130
|
+
# HEAD is the parent. A repo with no commits yet has no HEAD — we
|
|
131
|
+
# snapshot with no parent in that case (root commit).
|
|
132
|
+
rc, head, _ = _git(root, ["rev-parse", "HEAD"])
|
|
133
|
+
parent = head.strip() if rc == 0 else ""
|
|
134
|
+
|
|
135
|
+
# Temp index OUTSIDE the worktree so it never appears in the
|
|
136
|
+
# snapshot (an in-tree temp index leaked itself into the tree —
|
|
137
|
+
# verified). Seed it from the real index so unchanged staged state
|
|
138
|
+
# is preserved; if there's no index yet, git creates one.
|
|
139
|
+
tmp_fd, tmp_index = tempfile.mkstemp(prefix="loom-ckpt-index-")
|
|
140
|
+
os.close(tmp_fd)
|
|
141
|
+
try:
|
|
142
|
+
real_index = root / ".git" / "index"
|
|
143
|
+
if real_index.is_file():
|
|
144
|
+
try:
|
|
145
|
+
Path(tmp_index).write_bytes(real_index.read_bytes())
|
|
146
|
+
except OSError:
|
|
147
|
+
pass # start from empty temp index
|
|
148
|
+
env = {"GIT_INDEX_FILE": tmp_index}
|
|
149
|
+
# Stage tracked changes + untracked files. .loom is excluded so
|
|
150
|
+
# the snapshot doesn't churn on our own state files (it's also
|
|
151
|
+
# usually gitignored, but be explicit).
|
|
152
|
+
rc, _o, err = _git(
|
|
153
|
+
root, ["add", "-A", "--", ".", ":!.loom"], env=env
|
|
154
|
+
)
|
|
155
|
+
if rc != 0:
|
|
156
|
+
return None, f"git add failed: {err.strip()}"
|
|
157
|
+
rc, tree, err = _git(root, ["write-tree"], env=env)
|
|
158
|
+
if rc != 0:
|
|
159
|
+
return None, f"write-tree failed: {err.strip()}"
|
|
160
|
+
tree = tree.strip()
|
|
161
|
+
commit_args = ["commit-tree", tree, "-m", "loom checkpoint"]
|
|
162
|
+
if parent:
|
|
163
|
+
commit_args[1:1] = ["-p", parent]
|
|
164
|
+
rc, sha, err = _git(root, commit_args, env=env)
|
|
165
|
+
if rc != 0:
|
|
166
|
+
return None, f"commit-tree failed: {err.strip()}"
|
|
167
|
+
return sha.strip(), ""
|
|
168
|
+
finally:
|
|
169
|
+
try:
|
|
170
|
+
os.unlink(tmp_index)
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def checkpoint(
|
|
176
|
+
root: Path | str, summary: str = ""
|
|
177
|
+
) -> tuple[Checkpoint | None, str]:
|
|
178
|
+
"""Take a checkpoint of the current working tree. Returns
|
|
179
|
+
``(Checkpoint, "")`` or ``(None, error)``.
|
|
180
|
+
|
|
181
|
+
Called automatically before a turn that will write. A no-op (returns
|
|
182
|
+
an error string, never raises) when ``root`` isn't a git repo — the
|
|
183
|
+
feature simply doesn't apply outside version control.
|
|
184
|
+
"""
|
|
185
|
+
root = Path(root)
|
|
186
|
+
if not _is_git_repo(root):
|
|
187
|
+
return None, "not a git repository"
|
|
188
|
+
sha, err = _snapshot_commit(root)
|
|
189
|
+
if sha is None:
|
|
190
|
+
return None, err
|
|
191
|
+
data = _load(root)
|
|
192
|
+
seq = int(data.get("next_seq", 1))
|
|
193
|
+
cp = Checkpoint(
|
|
194
|
+
seq=seq,
|
|
195
|
+
sha=sha,
|
|
196
|
+
summary=summary.replace("\n", " ").strip()[:200],
|
|
197
|
+
created_at=_now_iso(),
|
|
198
|
+
)
|
|
199
|
+
stack: list[dict[str, Any]] = data.get("stack", [])
|
|
200
|
+
stack.append(
|
|
201
|
+
{
|
|
202
|
+
"seq": cp.seq,
|
|
203
|
+
"sha": cp.sha,
|
|
204
|
+
"summary": cp.summary,
|
|
205
|
+
"created_at": cp.created_at,
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
# Cap the stack — oldest fall off (their commit objects become
|
|
209
|
+
# unreachable and git GCs them eventually).
|
|
210
|
+
if len(stack) > _MAX_CHECKPOINTS:
|
|
211
|
+
stack = stack[-_MAX_CHECKPOINTS:]
|
|
212
|
+
data["stack"] = stack
|
|
213
|
+
data["next_seq"] = seq + 1
|
|
214
|
+
data["version"] = _SCHEMA_VERSION
|
|
215
|
+
_save(root, data)
|
|
216
|
+
return cp, ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def list_checkpoints(root: Path | str) -> list[Checkpoint]:
|
|
220
|
+
"""The checkpoint stack, most-recent LAST (so [-1] is the latest).
|
|
221
|
+
Never raises."""
|
|
222
|
+
data = _load(root)
|
|
223
|
+
out: list[Checkpoint] = []
|
|
224
|
+
for item in data.get("stack", []):
|
|
225
|
+
try:
|
|
226
|
+
out.append(
|
|
227
|
+
Checkpoint(
|
|
228
|
+
seq=int(item["seq"]),
|
|
229
|
+
sha=str(item["sha"]),
|
|
230
|
+
summary=str(item.get("summary", "")),
|
|
231
|
+
created_at=str(item.get("created_at", "")),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
except (KeyError, ValueError, TypeError):
|
|
235
|
+
continue
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def restore(
|
|
240
|
+
root: Path | str, seq: int | None = None
|
|
241
|
+
) -> tuple[Checkpoint | None, str]:
|
|
242
|
+
"""Restore the working tree to checkpoint ``seq`` (or the latest
|
|
243
|
+
when ``seq`` is None). Returns ``(restored_checkpoint, "")`` or
|
|
244
|
+
``(None, error)``.
|
|
245
|
+
|
|
246
|
+
Rewrites the WORKING TREE to the snapshot via ``git restore
|
|
247
|
+
--source`` — HEAD and the branch are untouched, so this is a pure
|
|
248
|
+
working-tree revert, not a history rewrite. Files the snapshot
|
|
249
|
+
didn't have are NOT deleted (git restore only writes tracked-in-
|
|
250
|
+
snapshot paths); this is intentionally conservative — we never rm a
|
|
251
|
+
user file on undo. Before restoring we take a SAFETY checkpoint of
|
|
252
|
+
the current state, so ``/undo`` is itself undoable (redo).
|
|
253
|
+
"""
|
|
254
|
+
root = Path(root)
|
|
255
|
+
if not _is_git_repo(root):
|
|
256
|
+
return None, "not a git repository"
|
|
257
|
+
checkpoints = list_checkpoints(root)
|
|
258
|
+
if not checkpoints:
|
|
259
|
+
return None, "no checkpoints to restore"
|
|
260
|
+
if seq is None:
|
|
261
|
+
target = checkpoints[-1]
|
|
262
|
+
else:
|
|
263
|
+
match = [c for c in checkpoints if c.seq == seq]
|
|
264
|
+
if not match:
|
|
265
|
+
return None, f"no checkpoint #{seq}"
|
|
266
|
+
target = match[0]
|
|
267
|
+
|
|
268
|
+
# Safety net: snapshot the current (pre-restore) state so an
|
|
269
|
+
# accidental /undo can itself be undone. Best-effort; a failure here
|
|
270
|
+
# doesn't block the restore the user asked for.
|
|
271
|
+
checkpoint(root, summary=f"before restoring #{target.seq}")
|
|
272
|
+
|
|
273
|
+
rc, _o, err = _git(
|
|
274
|
+
root, ["restore", "--source", target.sha, "--worktree", "--", "."]
|
|
275
|
+
)
|
|
276
|
+
if rc != 0:
|
|
277
|
+
# Older git without ``restore``: fall back to checkout-tree.
|
|
278
|
+
rc2, _o2, err2 = _git(
|
|
279
|
+
root, ["checkout", target.sha, "--", "."]
|
|
280
|
+
)
|
|
281
|
+
if rc2 != 0:
|
|
282
|
+
return None, f"restore failed: {(err or err2).strip()}"
|
|
283
|
+
return target, ""
|