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.
Files changed (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. loom_code-0.1.1.dist-info/top_level.txt +1 -0
@@ -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, ""