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
loom_code/hooks.py ADDED
@@ -0,0 +1,297 @@
1
+ """Shell-command hooks for loom-code (the ``.loom`` ``settings.toml``).
2
+
3
+ Turns the ``[[hooks]]`` entries discovered by
4
+ :mod:`loom_code.extensions` into runnable behaviour. There are two
5
+ worlds, because loomflow only exposes hook points for some events:
6
+
7
+ * **Tool-lifecycle** (``PreToolUse`` / ``PostToolUse``) become loomflow
8
+ ``HookRegistry`` callbacks attached to the tool-executing agents
9
+ (the workers + the simple coder — NOT the coordinator, which only
10
+ delegates). ``PreToolUse`` can BLOCK a tool call or REWRITE its
11
+ input; ``PostToolUse`` observes the result.
12
+ * **REPL-lifecycle** (``SessionStart`` / ``UserPromptSubmit`` /
13
+ ``SessionEnd``) the REPL fires itself — loomflow has no hook point
14
+ for them. ``UserPromptSubmit`` can inject extra context into the
15
+ prompt or block the turn.
16
+
17
+ Each hook is a shell command. We run it with ``anyio.run_process``
18
+ (a *string* command runs through the real shell, so pipes / ``&&``
19
+ work), pass a JSON event payload on stdin, and interpret the result:
20
+
21
+ exit 2 -> BLOCK; reason from stderr
22
+ exit 0 + JSON out -> read ``decision`` / ``reason`` /
23
+ ``additionalContext`` / ``updatedInput``
24
+ exit 0 + text out -> treated as ``additionalContext``
25
+ any other exit -> non-blocking error; ignored
26
+
27
+ A hook must never crash a run: a command that fails to launch, times
28
+ out, or emits garbage degrades to "no opinion."
29
+
30
+ ``Stop`` hooks are intentionally NOT wired here yet — they re-enable
31
+ the framework's bounded continuation loop (``max_stop_hook_iterations``,
32
+ deliberately 0 in loom-code) and need their interaction with that cap
33
+ designed carefully. Tracked as a follow-up.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import re
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ import anyio
45
+ from loomflow.core.types import PermissionDecision, ToolCall, ToolResult
46
+
47
+ from .extensions import HookSpec
48
+
49
+
50
+ def matches(matcher: str, tool_name: str) -> bool:
51
+ """Does ``matcher`` select ``tool_name``?
52
+
53
+ ``"*"`` / ``""`` match everything. A token of only word chars and
54
+ ``|`` is a pipe-separated exact list (``"bash|edit"``). Anything
55
+ else is treated as a regex searched against the tool name; an
56
+ invalid regex matches nothing (fail closed for the matcher, not the
57
+ run)."""
58
+ matcher = matcher.strip()
59
+ if matcher in ("", "*"):
60
+ return True
61
+ if re.fullmatch(r"[\w|]+", matcher):
62
+ return tool_name in matcher.split("|")
63
+ try:
64
+ return re.search(matcher, tool_name) is not None
65
+ except re.error:
66
+ return False
67
+
68
+
69
+ @dataclass
70
+ class HookOutcome:
71
+ """Normalised result of running one hook command."""
72
+
73
+ block: bool = False
74
+ reason: str | None = None
75
+ additional_context: str | None = None
76
+ updated_input: dict[str, Any] | None = None
77
+
78
+
79
+ async def _run(
80
+ spec: HookSpec, payload: dict[str, Any], *, cwd: Path
81
+ ) -> HookOutcome:
82
+ """Run one hook command with ``payload`` on stdin; never raises.
83
+
84
+ Honours ``spec.timeout`` via ``anyio.move_on_after`` — a hung hook
85
+ is cancelled (which terminates the subprocess) and degrades to "no
86
+ opinion."""
87
+ stdin = json.dumps(payload).encode("utf-8")
88
+ result: Any = None
89
+ # Pipe-race errors to swallow: a hook that exits without reading
90
+ # stdin (``touch marker``, ``true``, …) closes the pipe while
91
+ # run_process is still writing the payload — the write raises
92
+ # BrokenResourceError (anyio's wrap of BrokenPipeError /
93
+ # ConnectionResetError). The hook RAN; only payload delivery
94
+ # failed, so it degrades to "no opinion". anyio's internal task
95
+ # group may deliver it bare OR wrapped in an ExceptionGroup —
96
+ # handle both, and re-raise groups holding anything else.
97
+ _pipe_errors = (OSError, ValueError, anyio.BrokenResourceError)
98
+ with anyio.move_on_after(spec.timeout):
99
+ try:
100
+ result = await anyio.run_process(
101
+ spec.command, input=stdin, cwd=str(cwd), check=False
102
+ )
103
+ except _pipe_errors:
104
+ return HookOutcome()
105
+ except BaseExceptionGroup as eg:
106
+ # subgroup() tests GROUP nodes too — exclude them so only
107
+ # leaf exceptions decide (a group is never itself a pipe
108
+ # error, and matching it would re-raise everything).
109
+ real = eg.subgroup(
110
+ lambda e: not isinstance(
111
+ e, (BaseExceptionGroup, *_pipe_errors)
112
+ )
113
+ )
114
+ if real is not None:
115
+ raise # a real error is in there — don't mask it
116
+ return HookOutcome()
117
+ if result is None:
118
+ # Timed out (cancelled) or failed to launch.
119
+ return HookOutcome()
120
+
121
+ code = result.returncode
122
+ out = result.stdout.decode("utf-8", "replace").strip()
123
+ err = result.stderr.decode("utf-8", "replace").strip()
124
+
125
+ if code == 2:
126
+ return HookOutcome(
127
+ block=True, reason=err or f"blocked by {spec.source} hook"
128
+ )
129
+ if code != 0:
130
+ return HookOutcome() # non-blocking error
131
+ if not out:
132
+ return HookOutcome()
133
+ try:
134
+ data = json.loads(out)
135
+ except (json.JSONDecodeError, ValueError):
136
+ # Plain text on stdout — treat as context the hook wants added.
137
+ return HookOutcome(additional_context=out)
138
+ if not isinstance(data, dict):
139
+ return HookOutcome()
140
+
141
+ decision = str(data.get("decision", "")).strip().lower()
142
+ upd = data.get("updatedInput")
143
+ ctx = data.get("additionalContext")
144
+ reason = data.get("reason")
145
+ return HookOutcome(
146
+ block=decision in ("block", "deny"),
147
+ reason=(str(reason) if reason is not None else None),
148
+ additional_context=(str(ctx) if ctx else None),
149
+ updated_input=upd if isinstance(upd, dict) else None,
150
+ )
151
+
152
+
153
+ # ---- tool-lifecycle hooks (framework HookRegistry) ------------------
154
+
155
+
156
+ def _make_pre_tool_hook(spec: HookSpec, *, cwd: Path) -> Any:
157
+ """Build a loomflow ``PreToolHook`` from a PreToolUse spec.
158
+
159
+ Returns a ``PermissionDecision.deny_`` to block, or ``None`` to
160
+ pass (loomflow's registry only acts on a deny). ``updatedInput``
161
+ mutates the live ``ToolCall.args`` in place — the loop executes the
162
+ same object, so the rewrite takes effect."""
163
+
164
+ async def hook(call: ToolCall) -> PermissionDecision | None:
165
+ if not matches(spec.matcher, call.tool):
166
+ return None
167
+ payload = {
168
+ "hook_event_name": "PreToolUse",
169
+ "tool_name": call.tool,
170
+ "tool_input": dict(call.args),
171
+ "cwd": str(cwd),
172
+ }
173
+ outcome = await _run(spec, payload, cwd=cwd)
174
+ if outcome.updated_input is not None:
175
+ call.args.clear()
176
+ call.args.update(outcome.updated_input)
177
+ if outcome.block:
178
+ return PermissionDecision.deny_(
179
+ outcome.reason or f"blocked by {spec.source} hook"
180
+ )
181
+ return None
182
+
183
+ return hook
184
+
185
+
186
+ def _make_post_tool_hook(spec: HookSpec, *, cwd: Path) -> Any:
187
+ """Build a loomflow ``PostToolHook`` from a PostToolUse spec.
188
+
189
+ PostToolUse is observe-only in loomflow (the callback returns
190
+ ``None`` and cannot alter the result), matching its common use —
191
+ formatting, logging, notifications."""
192
+
193
+ async def hook(call: ToolCall, result: ToolResult) -> None:
194
+ if not matches(spec.matcher, call.tool):
195
+ return
196
+ payload = {
197
+ "hook_event_name": "PostToolUse",
198
+ "tool_name": call.tool,
199
+ "tool_input": dict(call.args),
200
+ "tool_output": _truncate(_safe_str(result.output)),
201
+ "tool_error": result.error,
202
+ "ok": result.ok,
203
+ "cwd": str(cwd),
204
+ }
205
+ await _run(spec, payload, cwd=cwd)
206
+
207
+ return hook
208
+
209
+
210
+ def attach_tool_hooks(agent: Any, specs: list[HookSpec], *, cwd: Path) -> None:
211
+ """Register the PreToolUse/PostToolUse hooks in ``specs`` onto
212
+ ``agent``'s loomflow ``HookRegistry`` (``agent._hooks``).
213
+
214
+ No-op when ``specs`` has no tool-lifecycle entries, so the agent's
215
+ fast-hooks path stays intact when the user defined none. Bumps the
216
+ registry's per-hook timeout to cover the slowest spec — loomflow's
217
+ default cap (5s) would otherwise cancel a longer hook early."""
218
+ pre = [s for s in specs if s.event == "PreToolUse"]
219
+ post = [s for s in specs if s.event == "PostToolUse"]
220
+ if not pre and not post:
221
+ return
222
+ registry = agent._hooks # noqa: SLF001 — the agent's HookRegistry
223
+ longest = max(s.timeout for s in (*pre, *post))
224
+ registry.hook_timeout_s = max(registry.hook_timeout_s, longest + 1.0)
225
+ for s in pre:
226
+ registry.register_pre_tool(_make_pre_tool_hook(s, cwd=cwd))
227
+ for s in post:
228
+ registry.register_post_tool(_make_post_tool_hook(s, cwd=cwd))
229
+
230
+
231
+ # ---- REPL-lifecycle hooks (fired by the REPL) -----------------------
232
+
233
+
234
+ @dataclass
235
+ class ReplHookResult:
236
+ """Aggregate of all REPL hooks fired for one event."""
237
+
238
+ blocked: bool = False
239
+ reason: str | None = None
240
+ contexts: list[str] = field(default_factory=list)
241
+ messages: list[str] = field(default_factory=list)
242
+
243
+ @property
244
+ def added_context(self) -> str:
245
+ """The combined ``additionalContext`` to fold into the prompt
246
+ (empty string when none)."""
247
+ return "\n".join(c for c in self.contexts if c).strip()
248
+
249
+
250
+ async def run_repl_hooks(
251
+ specs: list[HookSpec],
252
+ event: str,
253
+ *,
254
+ cwd: Path,
255
+ prompt: str | None = None,
256
+ ) -> ReplHookResult:
257
+ """Run every hook registered for a REPL-lifecycle ``event``.
258
+
259
+ ``event`` is one of ``SessionStart`` / ``UserPromptSubmit`` /
260
+ ``SessionEnd``. For ``UserPromptSubmit`` the user's ``prompt`` is
261
+ included in the payload and a hook may block the turn (exit 2) or
262
+ return ``additionalContext`` to append. Hooks run in declaration
263
+ order (user scope before project scope, per discovery)."""
264
+ out = ReplHookResult()
265
+ for spec in specs:
266
+ if spec.event != event:
267
+ continue
268
+ payload: dict[str, Any] = {"hook_event_name": event, "cwd": str(cwd)}
269
+ if prompt is not None:
270
+ payload["prompt"] = prompt
271
+ outcome = await _run(spec, payload, cwd=cwd)
272
+ if outcome.block:
273
+ out.blocked = True
274
+ out.reason = outcome.reason
275
+ if outcome.additional_context:
276
+ out.contexts.append(outcome.additional_context)
277
+ return out
278
+
279
+
280
+ # ---- helpers --------------------------------------------------------
281
+
282
+
283
+ def _safe_str(value: Any) -> str:
284
+ if isinstance(value, str):
285
+ return value
286
+ try:
287
+ return json.dumps(value)
288
+ except (TypeError, ValueError):
289
+ return str(value)
290
+
291
+
292
+ def _truncate(text: str, limit: int = 4000) -> str:
293
+ """Cap tool output piped to a hook — a hook doesn't need a 1MB
294
+ file dump, and a giant stdin can stall the subprocess."""
295
+ if len(text) <= limit:
296
+ return text
297
+ return text[:limit] + "\n...[truncated]"
@@ -0,0 +1,23 @@
1
+ """loominit — structural codebase indexing for loom-code.
2
+
3
+ The **structural extractor** (:mod:`extractor`, via ``build_index``) is
4
+ deterministic and LLM-free: it walks the repo with Python's stdlib
5
+ ``ast``, builds the import + call graphs, scores symbols by PageRank,
6
+ detects API surface from ``__init__.py`` / ``__all__``, maps tests to
7
+ symbols, and samples git heat — producing a :class:`schema.LoomIndex`.
8
+
9
+ This index feeds :mod:`repomap`, which ranks the most structurally-
10
+ important symbols and renders a compact, token-budgeted **repo map**.
11
+ That map is rebuilt fresh-by-construction (re-walked only when the
12
+ source tree changes) and injected into the agent's ``loom_index``
13
+ working block every turn — no LLM cost, no persisted artifact, never
14
+ stale. See ``repomap.repo_map_for_root_cached``.
15
+
16
+ (Historical note: this package once also ran an LLM annotator that
17
+ produced a human-readable ``LOOM.md`` + a persisted ``index.json``,
18
+ retrieved per turn via BM25. That subsystem was removed once the
19
+ deterministic repo map replaced it — the narrative drifted as the
20
+ agent edited code, and nothing read it at runtime.)
21
+ """
22
+
23
+ from __future__ import annotations