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/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
|