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,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
@@ -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"