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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""File-touch history — what happened the last time we changed a file.
|
|
2
|
+
|
|
3
|
+
The data foundation for *anticipation* (the loom-code differentiator
|
|
4
|
+
over a stateless coder): before the agent touches ``foo.py`` again, it
|
|
5
|
+
can be reminded "last time you edited this, the change was marked bad"
|
|
6
|
+
or "you've edited this 6 times — it's a hotspot." Cursor's index knows
|
|
7
|
+
what the code IS; this knows what HAPPENED to it, across runs.
|
|
8
|
+
|
|
9
|
+
One JSON file per project — ``<root>/.loom/file_history.json`` — same
|
|
10
|
+
convention as the rest of loom-code's per-project state (notebook,
|
|
11
|
+
memory.db, code_index.db, last_session.txt). Schema:
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"version": 1,
|
|
15
|
+
"files": {
|
|
16
|
+
"src/auth.py": {
|
|
17
|
+
"touch_count": 6,
|
|
18
|
+
"success_count": 4,
|
|
19
|
+
"fail_count": 1,
|
|
20
|
+
"last_touched_at": "2026-05-29T18:40:00Z",
|
|
21
|
+
"last_outcome": "success" | "fail" | "unknown",
|
|
22
|
+
"last_summary": "<one-line gist of the turn that touched it>"
|
|
23
|
+
},
|
|
24
|
+
...
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Outcome is the SAME signal the self-improvement loop uses — the
|
|
29
|
+
moved-on heuristic / ``/good`` / ``/bad`` (success: bool). We don't
|
|
30
|
+
yet parse test output for a finer signal; that's a future refinement
|
|
31
|
+
(the schema's ``last_outcome`` already has room for "fail" from a
|
|
32
|
+
detected test break). ``unknown`` is recorded when a turn touched
|
|
33
|
+
files but no success/failure was ever attributed (e.g. the user
|
|
34
|
+
quit mid-judgement).
|
|
35
|
+
|
|
36
|
+
Everything here is best-effort: a malformed file, a disk error, a
|
|
37
|
+
concurrent writer — none may ever break a turn. Reads return an empty
|
|
38
|
+
history; writes silently no-op. Anticipation degrading to silence is
|
|
39
|
+
correct; a crash is not.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from datetime import UTC, datetime
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any
|
|
49
|
+
|
|
50
|
+
_SCHEMA_VERSION = 1
|
|
51
|
+
_HISTORY_FILENAME = "file_history.json"
|
|
52
|
+
|
|
53
|
+
# Cap the number of files we track — a runaway monorepo touch history
|
|
54
|
+
# would bloat the JSON + the recall prompt. We keep the most-recently-
|
|
55
|
+
# touched N; older untouched entries fall off. 500 covers any real
|
|
56
|
+
# working set without the file growing unbounded.
|
|
57
|
+
_MAX_FILES = 500
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class FileRecord:
|
|
62
|
+
"""One file's accumulated touch history."""
|
|
63
|
+
|
|
64
|
+
path: str
|
|
65
|
+
touch_count: int
|
|
66
|
+
success_count: int
|
|
67
|
+
fail_count: int
|
|
68
|
+
last_touched_at: str
|
|
69
|
+
last_outcome: str # "success" | "fail" | "unknown"
|
|
70
|
+
last_summary: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _history_path(root: Path | str) -> Path:
|
|
74
|
+
return Path(root) / ".loom" / _HISTORY_FILENAME
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _now_iso() -> str:
|
|
78
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load(root: Path | str) -> dict[str, Any]:
|
|
82
|
+
"""Load the raw history dict, or a fresh empty one. Never raises —
|
|
83
|
+
a corrupt/absent file yields an empty history so the caller always
|
|
84
|
+
gets a usable structure."""
|
|
85
|
+
path = _history_path(root)
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, json.JSONDecodeError):
|
|
89
|
+
return {"version": _SCHEMA_VERSION, "files": {}}
|
|
90
|
+
if not isinstance(data, dict) or not isinstance(
|
|
91
|
+
data.get("files"), dict
|
|
92
|
+
):
|
|
93
|
+
return {"version": _SCHEMA_VERSION, "files": {}}
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _save(root: Path | str, data: dict[str, Any]) -> None:
|
|
98
|
+
"""Persist the history dict. Best-effort: a disk error silently
|
|
99
|
+
no-ops (the history is a convenience, never load-bearing)."""
|
|
100
|
+
path = _history_path(root)
|
|
101
|
+
try:
|
|
102
|
+
path.parent.mkdir(exist_ok=True)
|
|
103
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
104
|
+
except OSError:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _prune(files: dict[str, Any]) -> dict[str, Any]:
|
|
109
|
+
"""Keep the most-recently-touched ``_MAX_FILES`` entries so the
|
|
110
|
+
store can't grow without bound on a huge repo."""
|
|
111
|
+
if len(files) <= _MAX_FILES:
|
|
112
|
+
return files
|
|
113
|
+
ordered = sorted(
|
|
114
|
+
files.items(),
|
|
115
|
+
key=lambda kv: kv[1].get("last_touched_at", ""),
|
|
116
|
+
reverse=True,
|
|
117
|
+
)
|
|
118
|
+
return dict(ordered[:_MAX_FILES])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def record_touches(
|
|
122
|
+
root: Path | str,
|
|
123
|
+
paths: list[str],
|
|
124
|
+
*,
|
|
125
|
+
outcome: str,
|
|
126
|
+
summary: str = "",
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Record that ``paths`` were edited this turn with ``outcome``
|
|
129
|
+
(``"success"`` / ``"fail"`` / ``"unknown"``).
|
|
130
|
+
|
|
131
|
+
Idempotent per call, additive across calls: each path's
|
|
132
|
+
``touch_count`` increments and the matching outcome counter bumps.
|
|
133
|
+
``summary`` is a one-line gist of the turn (the user's prompt,
|
|
134
|
+
typically) so recall can say *why* the file was touched. Dedupes
|
|
135
|
+
``paths`` so one turn editing the same file twice counts once.
|
|
136
|
+
|
|
137
|
+
Best-effort — never raises."""
|
|
138
|
+
if not paths:
|
|
139
|
+
return
|
|
140
|
+
if outcome not in ("success", "fail", "unknown"):
|
|
141
|
+
outcome = "unknown"
|
|
142
|
+
summary = summary.replace("\n", " ").strip()[:200]
|
|
143
|
+
now = _now_iso()
|
|
144
|
+
|
|
145
|
+
data = _load(root)
|
|
146
|
+
files: dict[str, Any] = data.get("files", {})
|
|
147
|
+
for path in dict.fromkeys(paths): # dedupe, preserve order
|
|
148
|
+
rec = files.get(path) or {
|
|
149
|
+
"touch_count": 0,
|
|
150
|
+
"success_count": 0,
|
|
151
|
+
"fail_count": 0,
|
|
152
|
+
"last_touched_at": "",
|
|
153
|
+
"last_outcome": "unknown",
|
|
154
|
+
"last_summary": "",
|
|
155
|
+
}
|
|
156
|
+
rec["touch_count"] = int(rec.get("touch_count", 0)) + 1
|
|
157
|
+
if outcome == "success":
|
|
158
|
+
rec["success_count"] = int(rec.get("success_count", 0)) + 1
|
|
159
|
+
elif outcome == "fail":
|
|
160
|
+
rec["fail_count"] = int(rec.get("fail_count", 0)) + 1
|
|
161
|
+
rec["last_touched_at"] = now
|
|
162
|
+
rec["last_outcome"] = outcome
|
|
163
|
+
if summary:
|
|
164
|
+
rec["last_summary"] = summary
|
|
165
|
+
files[path] = rec
|
|
166
|
+
|
|
167
|
+
data["files"] = _prune(files)
|
|
168
|
+
data["version"] = _SCHEMA_VERSION
|
|
169
|
+
_save(root, data)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def update_last_outcome(
|
|
173
|
+
root: Path | str, paths: list[str], outcome: str
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Revise the outcome of the MOST RECENT touch of ``paths`` without
|
|
176
|
+
incrementing the touch count.
|
|
177
|
+
|
|
178
|
+
The flow that needs this: a turn records its touches as
|
|
179
|
+
``"unknown"`` immediately (so a crash before judgement still leaves
|
|
180
|
+
a record), then the moved-on / ``/good`` / ``/bad`` signal arrives
|
|
181
|
+
a turn later and revises it to success/fail. We move the count from
|
|
182
|
+
unknown into the right bucket rather than double-counting the touch.
|
|
183
|
+
|
|
184
|
+
Best-effort — never raises."""
|
|
185
|
+
if not paths or outcome not in ("success", "fail"):
|
|
186
|
+
return
|
|
187
|
+
data = _load(root)
|
|
188
|
+
files: dict[str, Any] = data.get("files", {})
|
|
189
|
+
changed = False
|
|
190
|
+
for path in dict.fromkeys(paths):
|
|
191
|
+
rec = files.get(path)
|
|
192
|
+
if rec is None:
|
|
193
|
+
continue
|
|
194
|
+
# Only revise if the last recorded outcome was unknown — don't
|
|
195
|
+
# clobber an already-judged touch (idempotent re-attribution).
|
|
196
|
+
if rec.get("last_outcome") != "unknown":
|
|
197
|
+
continue
|
|
198
|
+
if outcome == "success":
|
|
199
|
+
rec["success_count"] = int(rec.get("success_count", 0)) + 1
|
|
200
|
+
else:
|
|
201
|
+
rec["fail_count"] = int(rec.get("fail_count", 0)) + 1
|
|
202
|
+
rec["last_outcome"] = outcome
|
|
203
|
+
files[path] = rec
|
|
204
|
+
changed = True
|
|
205
|
+
if changed:
|
|
206
|
+
data["files"] = files
|
|
207
|
+
_save(root, data)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def history_for(root: Path | str, paths: list[str]) -> list[FileRecord]:
|
|
211
|
+
"""Return the recorded history for each of ``paths`` that has any.
|
|
212
|
+
|
|
213
|
+
Used by proactive recall (Phase 2b) to answer "what happened last
|
|
214
|
+
time we touched the files this prompt is about." Order matches the
|
|
215
|
+
input; paths with no history are omitted. Never raises."""
|
|
216
|
+
data = _load(root)
|
|
217
|
+
files: dict[str, Any] = data.get("files", {})
|
|
218
|
+
out: list[FileRecord] = []
|
|
219
|
+
for path in paths:
|
|
220
|
+
rec = files.get(path)
|
|
221
|
+
if rec is None:
|
|
222
|
+
continue
|
|
223
|
+
out.append(
|
|
224
|
+
FileRecord(
|
|
225
|
+
path=path,
|
|
226
|
+
touch_count=int(rec.get("touch_count", 0)),
|
|
227
|
+
success_count=int(rec.get("success_count", 0)),
|
|
228
|
+
fail_count=int(rec.get("fail_count", 0)),
|
|
229
|
+
last_touched_at=str(rec.get("last_touched_at", "")),
|
|
230
|
+
last_outcome=str(rec.get("last_outcome", "unknown")),
|
|
231
|
+
last_summary=str(rec.get("last_summary", "")),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def candidate_paths_from_prompt(
|
|
238
|
+
root: Path | str, prompt: str
|
|
239
|
+
) -> list[str]:
|
|
240
|
+
"""Best-effort extract the tracked file paths a prompt is ABOUT.
|
|
241
|
+
|
|
242
|
+
Matches any tracked path that appears verbatim in the prompt (the
|
|
243
|
+
common case: "fix the bug in src/auth.py"), plus a basename match
|
|
244
|
+
("the auth.py change broke") so a user who names the file without
|
|
245
|
+
its dir still gets the warning. Only returns paths that actually
|
|
246
|
+
have history — this is the candidate set for proactive recall.
|
|
247
|
+
Cheap string containment; no embedding. Never raises."""
|
|
248
|
+
if not prompt:
|
|
249
|
+
return []
|
|
250
|
+
data = _load(root)
|
|
251
|
+
tracked = list(data.get("files", {}).keys())
|
|
252
|
+
if not tracked:
|
|
253
|
+
return []
|
|
254
|
+
low = prompt.lower()
|
|
255
|
+
hits: list[str] = []
|
|
256
|
+
for path in tracked:
|
|
257
|
+
p_low = path.lower()
|
|
258
|
+
base = path.rsplit("/", 1)[-1].lower()
|
|
259
|
+
# Full path mentioned, or the basename as a whole word-ish
|
|
260
|
+
# token (guard against "a.py" matching inside "data.py" by
|
|
261
|
+
# requiring the basename to be reasonably specific).
|
|
262
|
+
if p_low in low or (len(base) >= 5 and base in low):
|
|
263
|
+
hits.append(path)
|
|
264
|
+
return hits
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def anticipation_block(records: list[FileRecord]) -> str:
|
|
268
|
+
"""Render file-touch records into a proactive-recall working block.
|
|
269
|
+
|
|
270
|
+
Only surfaces records WORTH warning about — a prior failure, or a
|
|
271
|
+
churn hotspot (touched many times). A clean, once-touched file
|
|
272
|
+
produces nothing (silence is correct; noise trains the user to
|
|
273
|
+
ignore the section). Returns "" when nothing's notable."""
|
|
274
|
+
notable: list[FileRecord] = []
|
|
275
|
+
for r in records:
|
|
276
|
+
if r.last_outcome == "fail" or r.fail_count > 0:
|
|
277
|
+
notable.append(r)
|
|
278
|
+
elif r.touch_count >= 4: # churn hotspot
|
|
279
|
+
notable.append(r)
|
|
280
|
+
if not notable:
|
|
281
|
+
return ""
|
|
282
|
+
lines = ["# What happened last time"]
|
|
283
|
+
lines.append(
|
|
284
|
+
"You're about to work on files with relevant history. "
|
|
285
|
+
"Heed it before repeating a past mistake."
|
|
286
|
+
)
|
|
287
|
+
for r in notable:
|
|
288
|
+
if r.last_outcome == "fail" or r.fail_count > 0:
|
|
289
|
+
why = f" ({r.last_summary})" if r.last_summary else ""
|
|
290
|
+
note = (
|
|
291
|
+
f"- `{r.path}` — last change was marked BAD{why}. "
|
|
292
|
+
f"Touched {r.touch_count}×, {r.fail_count} failed. "
|
|
293
|
+
"Be extra careful + verify."
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
note = (
|
|
297
|
+
f"- `{r.path}` — churn hotspot ({r.touch_count} edits). "
|
|
298
|
+
"Fragile / frequently-revised; tread carefully."
|
|
299
|
+
)
|
|
300
|
+
lines.append(note)
|
|
301
|
+
return "\n".join(lines)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def all_records(root: Path | str) -> list[FileRecord]:
|
|
305
|
+
"""Every tracked file, most-recently-touched first. For a future
|
|
306
|
+
'hotspots' view / the desktop anticipation surface (Phase 2c)."""
|
|
307
|
+
data = _load(root)
|
|
308
|
+
files: dict[str, Any] = data.get("files", {})
|
|
309
|
+
recs = [
|
|
310
|
+
FileRecord(
|
|
311
|
+
path=p,
|
|
312
|
+
touch_count=int(r.get("touch_count", 0)),
|
|
313
|
+
success_count=int(r.get("success_count", 0)),
|
|
314
|
+
fail_count=int(r.get("fail_count", 0)),
|
|
315
|
+
last_touched_at=str(r.get("last_touched_at", "")),
|
|
316
|
+
last_outcome=str(r.get("last_outcome", "unknown")),
|
|
317
|
+
last_summary=str(r.get("last_summary", "")),
|
|
318
|
+
)
|
|
319
|
+
for p, r in files.items()
|
|
320
|
+
]
|
|
321
|
+
recs.sort(key=lambda r: r.last_touched_at, reverse=True)
|
|
322
|
+
return recs
|
loom_code/file_tools.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""loom-code's file tools — Claude-Code-style path boundary.
|
|
2
|
+
|
|
3
|
+
The built-in loomflow ``read_tool`` hard-refuses any path outside its
|
|
4
|
+
workdir (``_resolve_within`` raises). That makes "point at a file one
|
|
5
|
+
directory up" impossible — the tool returns "file not found" no matter
|
|
6
|
+
what path the model passes. Claude Code instead lets the tool reach
|
|
7
|
+
any path the OS allows and puts the boundary in the PERMISSION layer:
|
|
8
|
+
reads are lenient (in-project auto-allowed, outside approvable), writes
|
|
9
|
+
are strict (outside always confirmed).
|
|
10
|
+
|
|
11
|
+
This module builds loom-code's ``read`` tool to that model:
|
|
12
|
+
|
|
13
|
+
* Resolve the path with :func:`loom_code.paths.resolve_path` (``~``,
|
|
14
|
+
cwd-relative, absolute) — one canonical resolver.
|
|
15
|
+
* IN the project → read normally.
|
|
16
|
+
* OUTSIDE the project → allowed only when the user REFERENCED the file
|
|
17
|
+
this session (``@``-mention / pasted path → :mod:`loom_code.consent`)
|
|
18
|
+
or a config rule permits it. Reads never mutate, so — matching Claude
|
|
19
|
+
Code — an outside read the user asked for goes straight through
|
|
20
|
+
rather than nagging; a self-initiated outside read the user never
|
|
21
|
+
named is refused (prompt-injection guard).
|
|
22
|
+
|
|
23
|
+
The underlying read is delegated to a loomflow ``read_tool`` rooted at
|
|
24
|
+
the filesystem root, so an absolute path never "escapes" it — the
|
|
25
|
+
containment decision has already been made here.
|
|
26
|
+
|
|
27
|
+
Edit/Write keep their existing loom-code wrappers (``edit_tool.py``),
|
|
28
|
+
which already consent-gate outside writes and always show a diff.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from loomflow import tool
|
|
37
|
+
from loomflow.tools import read_tool as _loomflow_read_tool
|
|
38
|
+
|
|
39
|
+
from .paths import is_within, resolve_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def loom_read_tool(workdir: Path | str) -> Any:
|
|
43
|
+
"""A ``read`` tool whose boundary is policy, not the cwd.
|
|
44
|
+
|
|
45
|
+
In-project reads behave exactly as before. An outside-project read
|
|
46
|
+
is permitted when the user referenced that file this session (see
|
|
47
|
+
:mod:`loom_code.consent`); otherwise it's refused with a message
|
|
48
|
+
telling the model to ask the user to reference the file — so a
|
|
49
|
+
prompt-injected "read ~/.ssh/id_rsa" the user never named fails.
|
|
50
|
+
"""
|
|
51
|
+
root = Path(workdir).resolve()
|
|
52
|
+
anchor = root.anchor or "/"
|
|
53
|
+
# Delegate the actual read to a loomflow tool rooted at the
|
|
54
|
+
# filesystem anchor ("/"), so an already-resolved absolute path is
|
|
55
|
+
# always accepted by its own ``_resolve_within`` (nothing escapes
|
|
56
|
+
# the root). The containment decision is made HERE, not there.
|
|
57
|
+
inner = _loomflow_read_tool(Path(anchor))
|
|
58
|
+
|
|
59
|
+
async def read(
|
|
60
|
+
path: str,
|
|
61
|
+
offset: int = 0,
|
|
62
|
+
limit: int | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
target = resolve_path(path, root)
|
|
65
|
+
if not is_within(target, root):
|
|
66
|
+
from . import consent
|
|
67
|
+
|
|
68
|
+
if not consent.is_granted(target):
|
|
69
|
+
return (
|
|
70
|
+
f"ERROR: {path} is outside the project and was not "
|
|
71
|
+
"referenced by the user. Ask the user to share the "
|
|
72
|
+
"file (they can paste its path or @-mention it); "
|
|
73
|
+
"do not read outside the project on your own."
|
|
74
|
+
)
|
|
75
|
+
# Hand the inner ("/"-rooted) tool a path relative to ITS root:
|
|
76
|
+
# the absolute target with the leading anchor stripped.
|
|
77
|
+
rel = str(target)
|
|
78
|
+
if rel.startswith(anchor):
|
|
79
|
+
rel = rel[len(anchor):]
|
|
80
|
+
return await inner.fn(path=rel, offset=offset, limit=limit)
|
|
81
|
+
|
|
82
|
+
return tool(
|
|
83
|
+
name="read",
|
|
84
|
+
description=(
|
|
85
|
+
"Read a text file, returned with line numbers. The path may "
|
|
86
|
+
"be relative to the project, absolute, or start with ~. "
|
|
87
|
+
"Files the user referenced this session (pasted a path or "
|
|
88
|
+
"@-mentioned) are readable even outside the project; other "
|
|
89
|
+
"outside paths are refused — ask the user to share the file "
|
|
90
|
+
"rather than reading outside the project yourself. Args: "
|
|
91
|
+
"path, offset=0, limit=None."
|
|
92
|
+
),
|
|
93
|
+
)(read)
|
loom_code/git_hook.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Install / uninstall loom-code's debounced post-commit hook.
|
|
2
|
+
|
|
3
|
+
The hook (a small shell wrapper around ``python -m
|
|
4
|
+
loom_code._post_commit``) counts commits since the last refresh
|
|
5
|
+
per indexer and runs an incremental rebuild every N commits
|
|
6
|
+
(default 5). See :mod:`loom_code._post_commit` for the actual
|
|
7
|
+
refresh logic.
|
|
8
|
+
|
|
9
|
+
Why this lives in loom-code and not graphify / loominit
|
|
10
|
+
individually: there's only ONE ``.git/hooks/post-commit`` slot
|
|
11
|
+
per repo. A shared hook that checks which indexers are set up
|
|
12
|
+
and refreshes whichever apply lets ``/graphify on`` and
|
|
13
|
+
``/loominit`` coexist without clobbering each other.
|
|
14
|
+
|
|
15
|
+
The installer is idempotent. It marks its lines with a
|
|
16
|
+
``# loom-code-hook`` sentinel so subsequent installs detect the
|
|
17
|
+
prior hook and skip; uninstall removes those lines and leaves
|
|
18
|
+
any other hook content (other tools, manual scripts) intact.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import stat
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Sentinel comment we write to mark our section of the hook.
|
|
28
|
+
# Anything between this sentinel and the next blank line is
|
|
29
|
+
# considered loom-code's; the rest is left alone on uninstall.
|
|
30
|
+
_MARKER = "# loom-code-hook"
|
|
31
|
+
_HOOK_NAME = "post-commit"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _git_hooks_dir(project_root: Path) -> Path | None:
|
|
35
|
+
"""Resolve the git hooks directory for ``project_root`` — or
|
|
36
|
+
``None`` if this isn't a git repo. Respects ``core.hooksPath``
|
|
37
|
+
when set (some teams move hooks out of ``.git/``)."""
|
|
38
|
+
git_dir = project_root / ".git"
|
|
39
|
+
if not git_dir.exists():
|
|
40
|
+
return None
|
|
41
|
+
# Honour ``git config core.hooksPath`` if set. Sub-projects in
|
|
42
|
+
# a workspace + teams using shared-hook tooling rely on this.
|
|
43
|
+
import subprocess
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["git", "config", "--get", "core.hooksPath"],
|
|
47
|
+
cwd=project_root,
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
timeout=5,
|
|
51
|
+
)
|
|
52
|
+
except (subprocess.SubprocessError, OSError):
|
|
53
|
+
result = None
|
|
54
|
+
if result is not None and result.returncode == 0 and result.stdout.strip():
|
|
55
|
+
hooks_path = result.stdout.strip()
|
|
56
|
+
# Relative paths are relative to the worktree root, not .git.
|
|
57
|
+
return (project_root / hooks_path).resolve()
|
|
58
|
+
# Default: .git/hooks (or hooks/ inside a bare repo).
|
|
59
|
+
if git_dir.is_dir():
|
|
60
|
+
return git_dir / "hooks"
|
|
61
|
+
# ``.git`` could be a file (worktree / submodule). Resolve.
|
|
62
|
+
return _resolve_dotgit_file(git_dir) / "hooks"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_dotgit_file(git_file: Path) -> Path:
|
|
66
|
+
"""Worktrees + submodules: ``.git`` is a regular file with a
|
|
67
|
+
``gitdir: <abs path>`` pointer. Follow it to the real git dir."""
|
|
68
|
+
text = git_file.read_text(encoding="utf-8").strip()
|
|
69
|
+
for line in text.splitlines():
|
|
70
|
+
if line.startswith("gitdir:"):
|
|
71
|
+
return Path(line.split(":", 1)[1].strip())
|
|
72
|
+
return git_file.parent # fallback
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_installed(project_root: Path) -> bool:
|
|
76
|
+
"""True iff the loom-code section is present in the post-commit
|
|
77
|
+
hook for this project."""
|
|
78
|
+
hooks_dir = _git_hooks_dir(project_root)
|
|
79
|
+
if hooks_dir is None:
|
|
80
|
+
return False
|
|
81
|
+
hook_path = hooks_dir / _HOOK_NAME
|
|
82
|
+
if not hook_path.is_file():
|
|
83
|
+
return False
|
|
84
|
+
return _MARKER in hook_path.read_text(encoding="utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def install(project_root: Path) -> str:
|
|
88
|
+
"""Install (or refresh) the loom-code post-commit hook.
|
|
89
|
+
|
|
90
|
+
Returns a status string suitable for printing to the user:
|
|
91
|
+
``"installed"`` (newly added), ``"updated"`` (replaced an
|
|
92
|
+
existing loom-code section), ``"skipped: not a git repo"``,
|
|
93
|
+
or ``"skipped: <reason>"`` on failure.
|
|
94
|
+
|
|
95
|
+
Idempotent — calling on an already-installed repo
|
|
96
|
+
just refreshes the section in case the marker / runner
|
|
97
|
+
path changed between loom-code versions.
|
|
98
|
+
"""
|
|
99
|
+
hooks_dir = _git_hooks_dir(project_root)
|
|
100
|
+
if hooks_dir is None:
|
|
101
|
+
return "skipped: not a git repo"
|
|
102
|
+
try:
|
|
103
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
return f"skipped: cannot mkdir {hooks_dir}: {exc}"
|
|
106
|
+
hook_path = hooks_dir / _HOOK_NAME
|
|
107
|
+
|
|
108
|
+
# The line we want present. Background (``&``) + stdout/err
|
|
109
|
+
# redirect so the hook never blocks the commit or spams the
|
|
110
|
+
# terminal. ``sys.executable`` pins to whichever Python
|
|
111
|
+
# loom-code is running in — avoids the "wrong python on PATH"
|
|
112
|
+
# class of bug.
|
|
113
|
+
loomcode_block = (
|
|
114
|
+
f"{_MARKER}\n"
|
|
115
|
+
f"exec {sys.executable} -m loom_code._post_commit "
|
|
116
|
+
f'"$(git rev-parse --show-toplevel)" '
|
|
117
|
+
f">/dev/null 2>&1 &\n"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
updated = False
|
|
121
|
+
if hook_path.is_file():
|
|
122
|
+
existing = hook_path.read_text(encoding="utf-8")
|
|
123
|
+
if _MARKER in existing:
|
|
124
|
+
# Replace the existing section.
|
|
125
|
+
existing = _strip_loomcode_section(existing)
|
|
126
|
+
updated = True
|
|
127
|
+
# Append our block to whatever else is there.
|
|
128
|
+
new_content = existing.rstrip() + "\n\n" + loomcode_block
|
|
129
|
+
else:
|
|
130
|
+
new_content = "#!/bin/sh\n" + loomcode_block
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
hook_path.write_text(new_content, encoding="utf-8")
|
|
134
|
+
# Mark executable (mode 0o755) — git won't run a hook
|
|
135
|
+
# without +x on POSIX.
|
|
136
|
+
st = hook_path.stat()
|
|
137
|
+
exec_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
138
|
+
hook_path.chmod(st.st_mode | exec_bits)
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
return f"skipped: write failed: {exc}"
|
|
141
|
+
return "updated" if updated else "installed"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def uninstall(project_root: Path) -> str:
|
|
145
|
+
"""Remove loom-code's section from the post-commit hook.
|
|
146
|
+
Leaves other tools' hook lines intact. Returns ``"removed"``,
|
|
147
|
+
``"not present"``, or ``"skipped: <reason>"``."""
|
|
148
|
+
hooks_dir = _git_hooks_dir(project_root)
|
|
149
|
+
if hooks_dir is None:
|
|
150
|
+
return "skipped: not a git repo"
|
|
151
|
+
hook_path = hooks_dir / _HOOK_NAME
|
|
152
|
+
if not hook_path.is_file():
|
|
153
|
+
return "not present"
|
|
154
|
+
existing = hook_path.read_text(encoding="utf-8")
|
|
155
|
+
if _MARKER not in existing:
|
|
156
|
+
return "not present"
|
|
157
|
+
stripped = _strip_loomcode_section(existing)
|
|
158
|
+
# If only the shebang remains (or empty), drop the file entirely
|
|
159
|
+
# so we don't leave a no-op behind that other tools might find.
|
|
160
|
+
if stripped.strip() in ("", "#!/bin/sh"):
|
|
161
|
+
try:
|
|
162
|
+
hook_path.unlink()
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
return f"skipped: unlink failed: {exc}"
|
|
165
|
+
else:
|
|
166
|
+
try:
|
|
167
|
+
hook_path.write_text(stripped, encoding="utf-8")
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
return f"skipped: write failed: {exc}"
|
|
170
|
+
return "removed"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _strip_loomcode_section(content: str) -> str:
|
|
174
|
+
"""Remove the ``_MARKER`` line + the ``exec ...`` line that
|
|
175
|
+
follows it. Preserves everything else in the hook file."""
|
|
176
|
+
lines = content.splitlines()
|
|
177
|
+
out: list[str] = []
|
|
178
|
+
skip_count = 0
|
|
179
|
+
for line in lines:
|
|
180
|
+
if skip_count > 0:
|
|
181
|
+
skip_count -= 1
|
|
182
|
+
continue
|
|
183
|
+
if line.strip() == _MARKER:
|
|
184
|
+
# Skip this line + the exec line that comes right
|
|
185
|
+
# after. Two-line block per ``install()``.
|
|
186
|
+
skip_count = 1
|
|
187
|
+
continue
|
|
188
|
+
out.append(line)
|
|
189
|
+
# Collapse any resulting double blank lines.
|
|
190
|
+
cleaned: list[str] = []
|
|
191
|
+
prev_blank = False
|
|
192
|
+
for line in out:
|
|
193
|
+
if not line.strip():
|
|
194
|
+
if prev_blank:
|
|
195
|
+
continue
|
|
196
|
+
prev_blank = True
|
|
197
|
+
else:
|
|
198
|
+
prev_blank = False
|
|
199
|
+
cleaned.append(line)
|
|
200
|
+
return "\n".join(cleaned).rstrip() + "\n"
|