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/rules.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Project rules file (``AGENTS.md``) — bootstrap + safe rule appending.
|
|
2
|
+
|
|
3
|
+
loom-code already *reads* a context file (``CLAUDE.md`` / ``AGENTS.md`` /
|
|
4
|
+
``.loom/context.md`` — see :mod:`loom_code.project`) into the system
|
|
5
|
+
prompt every session. This module *writes* to it:
|
|
6
|
+
|
|
7
|
+
* :func:`init_agents_md` — create a starter ``AGENTS.md`` when the repo
|
|
8
|
+
has no context file yet (the ``/init-loom`` command).
|
|
9
|
+
* :func:`add_rule` — append a durable rule the user stated in chat into a
|
|
10
|
+
clearly-delimited **managed block**, with dedup + supersession so the
|
|
11
|
+
block stays small and contradiction-free (the ``remember_rule`` tool).
|
|
12
|
+
|
|
13
|
+
The guardrail (matches Claude Code's split): the agent only ever curates
|
|
14
|
+
the **managed block** between the markers; everything the human writes
|
|
15
|
+
*outside* it is never touched. And the block is kept tidy at write-time —
|
|
16
|
+
duplicates are skipped, a superseding rule removes the one it replaces —
|
|
17
|
+
so it doesn't bloat the way an append-only log would.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from loomflow import tool
|
|
25
|
+
from loomflow.tools.registry import Tool
|
|
26
|
+
|
|
27
|
+
# Same order loom_code.project reads, so the file we WRITE is the file
|
|
28
|
+
# that gets READ back into the prompt.
|
|
29
|
+
_CONTEXT_FILENAMES = ("CLAUDE.md", "AGENTS.md", ".loom/context.md")
|
|
30
|
+
# When no context file exists yet, this is the one we create — the
|
|
31
|
+
# cross-tool open standard (read by loom-code and every AGENTS.md-aware
|
|
32
|
+
# agent), not a loom-only file.
|
|
33
|
+
_DEFAULT_RULES_FILE = "AGENTS.md"
|
|
34
|
+
|
|
35
|
+
# Markers delimiting the agent-curated block. The human's content lives
|
|
36
|
+
# OUTSIDE these; the agent only edits BETWEEN them.
|
|
37
|
+
BLOCK_START = (
|
|
38
|
+
"<!-- loom:rules (auto-added from chat — edit or delete freely) -->"
|
|
39
|
+
)
|
|
40
|
+
BLOCK_END = "<!-- /loom:rules -->"
|
|
41
|
+
|
|
42
|
+
# Soft cap on managed rules: past this we nudge (never auto-drop).
|
|
43
|
+
SOFT_CAP = 50
|
|
44
|
+
|
|
45
|
+
_STARTER_TEMPLATE = """\
|
|
46
|
+
# AGENTS.md
|
|
47
|
+
|
|
48
|
+
Instructions for AI coding agents working on this project (loom-code and
|
|
49
|
+
any AGENTS.md-aware tool). This file is read into the agent's context
|
|
50
|
+
each session — keep it concise (aim under ~200 lines).
|
|
51
|
+
|
|
52
|
+
## Overview
|
|
53
|
+
<what this project is — a line or two>
|
|
54
|
+
|
|
55
|
+
## Conventions
|
|
56
|
+
- <coding standards, e.g. indentation / naming>
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
- install: <...>
|
|
60
|
+
- test: <...>
|
|
61
|
+
- lint: <...>
|
|
62
|
+
|
|
63
|
+
## Rules
|
|
64
|
+
{block_start}
|
|
65
|
+
{block_end}
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def detect_rules_file(root: Path | str) -> Path | None:
|
|
70
|
+
"""Return the existing context file (first by priority), or None."""
|
|
71
|
+
base = Path(root)
|
|
72
|
+
for name in _CONTEXT_FILENAMES:
|
|
73
|
+
fp = base / name
|
|
74
|
+
if fp.is_file():
|
|
75
|
+
return fp
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def target_rules_file(root: Path | str) -> Path:
|
|
80
|
+
"""The file rules are written to: an existing context file if one is
|
|
81
|
+
present, else ``AGENTS.md`` at the repo root."""
|
|
82
|
+
return detect_rules_file(root) or (Path(root) / _DEFAULT_RULES_FILE)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def current_rules_text(root: Path | str) -> str:
|
|
86
|
+
"""Full text of the active rules file, or '' if none exists."""
|
|
87
|
+
fp = detect_rules_file(root)
|
|
88
|
+
if fp is None:
|
|
89
|
+
return ""
|
|
90
|
+
try:
|
|
91
|
+
return fp.read_text(encoding="utf-8")
|
|
92
|
+
except OSError:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def project_rules_block(root: Path | str) -> str:
|
|
97
|
+
"""Framed body for the per-turn ``project_rules`` working block.
|
|
98
|
+
|
|
99
|
+
The active rules file, re-read FRESH each turn so a mid-session edit
|
|
100
|
+
applies on the next turn (no restart) — unlike the static
|
|
101
|
+
startup-baked path. Empty string when there's no rules file."""
|
|
102
|
+
fp = detect_rules_file(root)
|
|
103
|
+
if fp is None:
|
|
104
|
+
return ""
|
|
105
|
+
try:
|
|
106
|
+
text = fp.read_text(encoding="utf-8").strip()
|
|
107
|
+
except OSError:
|
|
108
|
+
return ""
|
|
109
|
+
if not text:
|
|
110
|
+
return ""
|
|
111
|
+
return (
|
|
112
|
+
f"# Project rules ({fp.name})\n"
|
|
113
|
+
"House rules for this project — conventions, and things to do or "
|
|
114
|
+
"AVOID. Follow them in everything you do and delegate.\n\n"
|
|
115
|
+
f"{text}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def init_agents_md(root: Path | str) -> tuple[Path, bool]:
|
|
120
|
+
"""Create a starter ``AGENTS.md`` if the repo has no context file.
|
|
121
|
+
|
|
122
|
+
Returns ``(path, created)`` — ``created=False`` means a context file
|
|
123
|
+
already existed and was left untouched."""
|
|
124
|
+
existing = detect_rules_file(root)
|
|
125
|
+
if existing is not None:
|
|
126
|
+
return existing, False
|
|
127
|
+
fp = Path(root) / _DEFAULT_RULES_FILE
|
|
128
|
+
fp.write_text(
|
|
129
|
+
_STARTER_TEMPLATE.format(block_start=BLOCK_START, block_end=BLOCK_END),
|
|
130
|
+
encoding="utf-8",
|
|
131
|
+
)
|
|
132
|
+
return fp, True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _normalize(rule: str) -> str:
|
|
136
|
+
"""Compare-key for dedup/supersede: lowercased, whitespace-collapsed,
|
|
137
|
+
trailing punctuation dropped. Two rules that say the same thing in
|
|
138
|
+
slightly different wording collapse to the same key."""
|
|
139
|
+
return " ".join(rule.lower().split()).rstrip(".!").strip()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _ensure_block(text: str) -> str:
|
|
143
|
+
"""Guarantee the managed block markers exist, appending an empty one
|
|
144
|
+
(under a ## Rules heading) when absent."""
|
|
145
|
+
if BLOCK_START in text and BLOCK_END in text:
|
|
146
|
+
return text
|
|
147
|
+
suffix = "" if text.endswith("\n") or text == "" else "\n"
|
|
148
|
+
return f"{text}{suffix}\n## Rules\n{BLOCK_START}\n{BLOCK_END}\n"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _read_managed_rules(text: str) -> list[str]:
|
|
152
|
+
"""The ``- `` bullet lines currently inside the managed block."""
|
|
153
|
+
start = text.find(BLOCK_START)
|
|
154
|
+
end = text.find(BLOCK_END)
|
|
155
|
+
if start == -1 or end == -1 or end < start:
|
|
156
|
+
return []
|
|
157
|
+
inner = text[start + len(BLOCK_START):end]
|
|
158
|
+
out: list[str] = []
|
|
159
|
+
for line in inner.splitlines():
|
|
160
|
+
s = line.strip()
|
|
161
|
+
if s.startswith("- "):
|
|
162
|
+
out.append(s[2:].strip())
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _write_managed_rules(text: str, rules: list[str]) -> str:
|
|
167
|
+
"""Replace the managed block's body with ``rules`` (preserving
|
|
168
|
+
everything outside the markers verbatim)."""
|
|
169
|
+
start = text.find(BLOCK_START)
|
|
170
|
+
end = text.find(BLOCK_END)
|
|
171
|
+
body = "".join(f"- {r}\n" for r in rules)
|
|
172
|
+
new_block = f"{BLOCK_START}\n{body}{BLOCK_END}"
|
|
173
|
+
return text[:start] + new_block + text[end + len(BLOCK_END):]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def add_rule(
|
|
177
|
+
root: Path | str, rule: str, *, supersedes: str | None = None
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Append a durable rule to the managed block. Dedup (skip if already
|
|
180
|
+
present) + supersede (drop the rule ``supersedes`` matches before
|
|
181
|
+
adding). Creates the file if absent. Returns a human-readable status
|
|
182
|
+
(the tool relays it). Best-effort write; the human's content outside
|
|
183
|
+
the managed block is never modified."""
|
|
184
|
+
rule = " ".join((rule or "").split()).strip()
|
|
185
|
+
if not rule:
|
|
186
|
+
return "remember_rule: empty rule — nothing recorded."
|
|
187
|
+
|
|
188
|
+
fp = target_rules_file(root)
|
|
189
|
+
try:
|
|
190
|
+
if fp.is_file():
|
|
191
|
+
text = fp.read_text(encoding="utf-8")
|
|
192
|
+
else:
|
|
193
|
+
text = _STARTER_TEMPLATE.format(
|
|
194
|
+
block_start=BLOCK_START, block_end=BLOCK_END
|
|
195
|
+
)
|
|
196
|
+
text = _ensure_block(text)
|
|
197
|
+
rules = _read_managed_rules(text)
|
|
198
|
+
|
|
199
|
+
norm = _normalize(rule)
|
|
200
|
+
|
|
201
|
+
# Supersede: drop any managed rule the new one replaces.
|
|
202
|
+
superseded: str | None = None
|
|
203
|
+
if supersedes:
|
|
204
|
+
sup_norm = _normalize(supersedes)
|
|
205
|
+
for existing in list(rules):
|
|
206
|
+
en = _normalize(existing)
|
|
207
|
+
if en == sup_norm or sup_norm in en or en in sup_norm:
|
|
208
|
+
rules.remove(existing)
|
|
209
|
+
superseded = existing
|
|
210
|
+
break
|
|
211
|
+
|
|
212
|
+
# Dedup: already present (and not a supersede) → no-op.
|
|
213
|
+
if any(_normalize(r) == norm for r in rules):
|
|
214
|
+
if superseded is not None:
|
|
215
|
+
text = _write_managed_rules(text, rules)
|
|
216
|
+
fp.write_text(text, encoding="utf-8")
|
|
217
|
+
return (
|
|
218
|
+
f"Removed superseded rule ({superseded!r}); "
|
|
219
|
+
f"the new rule was already recorded."
|
|
220
|
+
)
|
|
221
|
+
return f"Already recorded in {fp.name}: {rule!r}."
|
|
222
|
+
|
|
223
|
+
rules.append(rule)
|
|
224
|
+
text = _write_managed_rules(text, rules)
|
|
225
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
fp.write_text(text, encoding="utf-8")
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
return f"remember_rule: could not write {fp.name}: {exc}"
|
|
229
|
+
|
|
230
|
+
msg = (
|
|
231
|
+
f"Saved to {fp.name}: {rule!r}"
|
|
232
|
+
+ (f" (replaced {superseded!r})" if superseded else "")
|
|
233
|
+
+ ". It applies from the next session; I'll keep following it now too."
|
|
234
|
+
)
|
|
235
|
+
if len(rules) > SOFT_CAP:
|
|
236
|
+
msg += (
|
|
237
|
+
f" Note: {len(rules)} rules are tracked — consider pruning "
|
|
238
|
+
f"{fp.name} (the long file weakens adherence)."
|
|
239
|
+
)
|
|
240
|
+
return msg
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def remember_rule_tool(root: Path | str) -> Tool:
|
|
244
|
+
"""Build the ``remember_rule`` tool for the coordinator. The model
|
|
245
|
+
calls it when the user states a DURABLE project rule, so the rule is
|
|
246
|
+
persisted to ``AGENTS.md`` (always-in-prompt next session) instead of
|
|
247
|
+
relying on probabilistic memory recall."""
|
|
248
|
+
base = Path(root)
|
|
249
|
+
|
|
250
|
+
async def remember_rule(rule: str, supersedes: str = "") -> str:
|
|
251
|
+
"""Persist a durable, user-stated rule to AGENTS.md."""
|
|
252
|
+
return add_rule(base, rule, supersedes=supersedes or None)
|
|
253
|
+
|
|
254
|
+
return tool(
|
|
255
|
+
name="remember_rule",
|
|
256
|
+
description=(
|
|
257
|
+
"Persist a DURABLE project rule the user explicitly stated "
|
|
258
|
+
"(e.g. 'never edit X', 'always run Y before commit', "
|
|
259
|
+
"'don't use Z') to AGENTS.md, so it survives future sessions "
|
|
260
|
+
"instead of relying on memory recall. Args: rule (the rule, "
|
|
261
|
+
"as a short imperative); supersedes (optional — the text of "
|
|
262
|
+
"an earlier rule this one reverses/updates; pass it so the "
|
|
263
|
+
"old rule is removed instead of leaving a contradiction). "
|
|
264
|
+
"Only call this for standing rules the user states, NOT for "
|
|
265
|
+
"one-off task requests. Duplicates are skipped automatically."
|
|
266
|
+
),
|
|
267
|
+
)(remember_rule)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Kernel-sandboxed bash for the coder — Claude-Code-style.
|
|
2
|
+
|
|
3
|
+
The coder's ``bash`` runs arbitrary shell, the one genuinely dangerous
|
|
4
|
+
tool (``edit`` / ``write`` only write where the model says, gated by the
|
|
5
|
+
approval prompt). So — like Claude Code, and unlike the framework's
|
|
6
|
+
``OSSandbox`` which ships a *Python function* to a child — we sandbox at
|
|
7
|
+
the **command** boundary: wrap the shell command string in the
|
|
8
|
+
platform's isolation wrapper.
|
|
9
|
+
|
|
10
|
+
* macOS -> ``sandbox-exec -p '<profile>' /bin/bash -lc "<cmd>"``
|
|
11
|
+
* Linux -> ``bwrap <binds> /bin/bash -lc "<cmd>"``
|
|
12
|
+
* else -> plain bash + a one-time warning (no kernel backend).
|
|
13
|
+
|
|
14
|
+
The policy: **deny writes outside the project root, deny network by
|
|
15
|
+
default.** Reads stay broad (a build/test needs to read system libs).
|
|
16
|
+
This is the right risk model — arbitrary shell can't exfiltrate or
|
|
17
|
+
tamper outside the workspace, while the structured file tools keep their
|
|
18
|
+
existing approval gate.
|
|
19
|
+
|
|
20
|
+
Wrapping the command (not a pickled callable) sidesteps the
|
|
21
|
+
picklability constraint that makes ``OSSandbox`` unsuitable for
|
|
22
|
+
loom-code's closure-based builtin tools.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import shutil
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import anyio
|
|
33
|
+
from loomflow import tool
|
|
34
|
+
from loomflow.tools.registry import Tool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _detect_backend() -> str:
|
|
38
|
+
"""``"seatbelt"`` | ``"bubblewrap"`` | ``"none"`` for this host."""
|
|
39
|
+
if sys.platform == "darwin" and shutil.which("sandbox-exec"):
|
|
40
|
+
return "seatbelt"
|
|
41
|
+
if sys.platform.startswith("linux") and shutil.which("bwrap"):
|
|
42
|
+
return "bubblewrap"
|
|
43
|
+
return "none"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _seatbelt_profile(root: Path, allow_network: bool) -> str:
|
|
47
|
+
"""Deny-by-default Seatbelt profile: read anywhere, write only under
|
|
48
|
+
``root`` + the OS temp dir, network gated by the flag.
|
|
49
|
+
|
|
50
|
+
Mirrors loomflow's OSSandbox profile (read-broad is required so the
|
|
51
|
+
shell + build tools can load system libs; the enforced properties
|
|
52
|
+
are no-write-outside-root + network control)."""
|
|
53
|
+
tmp = Path(tempfile.gettempdir()).resolve()
|
|
54
|
+
lines = [
|
|
55
|
+
"(version 1)",
|
|
56
|
+
";; loom-code sandboxed bash — deny by default.",
|
|
57
|
+
"(deny default)",
|
|
58
|
+
";; Reads broad: shell + build/test tools must load system libs.",
|
|
59
|
+
"(allow file-read*)",
|
|
60
|
+
"(allow process-fork)",
|
|
61
|
+
"(allow process-exec)",
|
|
62
|
+
"(allow sysctl-read)",
|
|
63
|
+
"(allow mach-lookup)",
|
|
64
|
+
"(allow signal (target self))",
|
|
65
|
+
f'(allow file-write* (subpath "{root}"))',
|
|
66
|
+
f'(allow file-write* (subpath "{tmp}"))',
|
|
67
|
+
# /dev/null and friends — writes the shell expects.
|
|
68
|
+
'(allow file-write* (subpath "/dev"))',
|
|
69
|
+
]
|
|
70
|
+
lines.append(
|
|
71
|
+
"(allow network*)" if allow_network
|
|
72
|
+
else ";; network denied (default)."
|
|
73
|
+
)
|
|
74
|
+
return "\n".join(lines) + "\n"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _bubblewrap_argv(root: Path, allow_network: bool) -> list[str]:
|
|
78
|
+
"""``bwrap`` argv: fresh namespace, read-only system, read-write
|
|
79
|
+
bind for ``root`` + a private /tmp, network dropped unless allowed."""
|
|
80
|
+
argv = [
|
|
81
|
+
"bwrap",
|
|
82
|
+
"--die-with-parent",
|
|
83
|
+
"--unshare-pid",
|
|
84
|
+
"--ro-bind", "/usr", "/usr",
|
|
85
|
+
"--ro-bind", "/bin", "/bin",
|
|
86
|
+
"--ro-bind", "/lib", "/lib",
|
|
87
|
+
"--proc", "/proc",
|
|
88
|
+
"--dev", "/dev",
|
|
89
|
+
"--tmpfs", "/tmp",
|
|
90
|
+
]
|
|
91
|
+
if Path("/lib64").exists():
|
|
92
|
+
argv += ["--ro-bind", "/lib64", "/lib64"]
|
|
93
|
+
if Path("/etc").exists():
|
|
94
|
+
argv += ["--ro-bind", "/etc", "/etc"]
|
|
95
|
+
argv += ["--bind", str(root), str(root)]
|
|
96
|
+
if not allow_network:
|
|
97
|
+
argv += ["--unshare-net"]
|
|
98
|
+
return argv
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sandboxed_bash_tool(
|
|
102
|
+
root: str | Path,
|
|
103
|
+
*,
|
|
104
|
+
allow_network: bool = False,
|
|
105
|
+
timeout: float = 300.0,
|
|
106
|
+
) -> Tool:
|
|
107
|
+
"""A ``bash`` tool whose command runs under OS isolation.
|
|
108
|
+
|
|
109
|
+
Drop-in replacement for ``loomflow.tools.bash_tool`` on the coder:
|
|
110
|
+
same name/signature, but the command executes inside ``sandbox-exec``
|
|
111
|
+
(macOS) / ``bwrap`` (Linux) with writes confined to ``root`` and
|
|
112
|
+
network denied unless ``allow_network=True``. On a host with no
|
|
113
|
+
backend it degrades to plain bash and warns once.
|
|
114
|
+
"""
|
|
115
|
+
root_path = Path(root).resolve()
|
|
116
|
+
backend = _detect_backend()
|
|
117
|
+
|
|
118
|
+
@tool(name="bash")
|
|
119
|
+
async def sandboxed_bash(command: str) -> str:
|
|
120
|
+
"""Run a shell command. It executes inside an OS sandbox:
|
|
121
|
+
it may read anywhere but can only WRITE under the project
|
|
122
|
+
root, and has NO network access (unless the session enabled
|
|
123
|
+
it). Use it for builds, tests, git, and file inspection."""
|
|
124
|
+
if backend == "seatbelt":
|
|
125
|
+
profile = _seatbelt_profile(root_path, allow_network)
|
|
126
|
+
fd, prof_path = tempfile.mkstemp(suffix=".sb", prefix="loomsb-")
|
|
127
|
+
import os
|
|
128
|
+
|
|
129
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
130
|
+
fh.write(profile)
|
|
131
|
+
argv = [
|
|
132
|
+
"sandbox-exec", "-f", prof_path,
|
|
133
|
+
"/bin/bash", "-lc", command,
|
|
134
|
+
]
|
|
135
|
+
elif backend == "bubblewrap":
|
|
136
|
+
prof_path = None
|
|
137
|
+
argv = [
|
|
138
|
+
*_bubblewrap_argv(root_path, allow_network),
|
|
139
|
+
"/bin/bash", "-lc", command,
|
|
140
|
+
]
|
|
141
|
+
else:
|
|
142
|
+
# No kernel backend (Windows / Linux w/o bwrap). Run plain
|
|
143
|
+
# bash; the output is prefixed with a not-isolated warning
|
|
144
|
+
# in the result-assembly below so the model + user know.
|
|
145
|
+
prof_path = None
|
|
146
|
+
argv = ["/bin/bash", "-lc", command]
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
with anyio.fail_after(timeout):
|
|
150
|
+
proc = await anyio.run_process(
|
|
151
|
+
argv, check=False, cwd=str(root_path)
|
|
152
|
+
)
|
|
153
|
+
except TimeoutError:
|
|
154
|
+
return f"[sandboxed bash] timed out after {timeout}s"
|
|
155
|
+
finally:
|
|
156
|
+
if prof_path is not None:
|
|
157
|
+
import os
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
os.unlink(prof_path)
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
out = (proc.stdout or b"").decode("utf-8", "replace")
|
|
165
|
+
err = (proc.stderr or b"").decode("utf-8", "replace")
|
|
166
|
+
body = out + (("\n" + err) if err else "")
|
|
167
|
+
if backend == "none":
|
|
168
|
+
body = (
|
|
169
|
+
"[warning: no OS sandbox backend on this host — command "
|
|
170
|
+
"ran WITHOUT kernel isolation]\n" + body
|
|
171
|
+
)
|
|
172
|
+
if proc.returncode != 0:
|
|
173
|
+
body = f"[exit {proc.returncode}]\n{body}"
|
|
174
|
+
return body
|
|
175
|
+
|
|
176
|
+
return sandboxed_bash
|
loom_code/scribe.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Small utility helpers that wrap loomflow Agent for one-shot
|
|
2
|
+
text generation. Today: ``generate_commit_message``.
|
|
3
|
+
|
|
4
|
+
These live in loom-code (not in loomflow framework) because they
|
|
5
|
+
encode a specific opinionated voice — conventional commits, no
|
|
6
|
+
preamble, terse imperative. The framework stays neutral; loom-code
|
|
7
|
+
is where the opinions live.
|
|
8
|
+
|
|
9
|
+
Anyone calling these gets a Pythonic ``await fn(input) -> str``
|
|
10
|
+
shape — no streaming, no event subscription, no agent loop.
|
|
11
|
+
Internally we still go through ``loomflow.Agent`` because that
|
|
12
|
+
gives us model-string resolution + retry policy + adapter
|
|
13
|
+
handling for free, but the caller doesn't see any of it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from loomflow import Agent
|
|
19
|
+
|
|
20
|
+
# Default model: small + fast + cheap. Commit-message generation
|
|
21
|
+
# is a pure-function-of-diff task; no need for a top-tier model.
|
|
22
|
+
# Caller can override via ``model=`` if they want different
|
|
23
|
+
# voice (e.g. claude-sonnet-4-6 for longer-body messages).
|
|
24
|
+
_DEFAULT_MODEL = "claude-haiku-4-5"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_COMMIT_SYSTEM_PROMPT = """\
|
|
28
|
+
You write conventional-commit messages from git diffs.
|
|
29
|
+
|
|
30
|
+
Format:
|
|
31
|
+
- First line: under 50 chars, imperative mood, ``type(scope): subject``
|
|
32
|
+
- Types: feat, fix, refactor, docs, test, chore, perf, style, ci, build
|
|
33
|
+
- Subject in imperative mood: "add" not "added" or "adds"
|
|
34
|
+
- Lowercase subject, no trailing period
|
|
35
|
+
- Optional body separated by a blank line, wrapped at 72 chars,
|
|
36
|
+
explains WHY (not what)
|
|
37
|
+
- No marketing fluff, no AI-disclosure trailers, no markdown fences
|
|
38
|
+
in the output
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
feat(auth): add JWT validator with RS256 support
|
|
42
|
+
|
|
43
|
+
fix(memory): close fact-store connection on agent teardown
|
|
44
|
+
|
|
45
|
+
docs: explain the prompt-caching opt-in shape
|
|
46
|
+
|
|
47
|
+
refactor(sidecar): extract git ops into _git_run helper
|
|
48
|
+
|
|
49
|
+
Reply with ONLY the commit message — no preamble, no quoting, no
|
|
50
|
+
markdown fences, no "Here is your commit message" lines."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def generate_commit_message(
|
|
54
|
+
diff: str,
|
|
55
|
+
*,
|
|
56
|
+
model: str | None = None,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Generate a conventional-commit message from a git diff.
|
|
59
|
+
|
|
60
|
+
One-shot call to a small/fast model. No tools, no memory, no
|
|
61
|
+
workspace — deliberately stateless because commit-message
|
|
62
|
+
generation is a pure function of the diff.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
diff: The git diff text. Typically the output of
|
|
66
|
+
``git diff --cached`` (the staged changes).
|
|
67
|
+
model: Override the default model. Pass any string
|
|
68
|
+
loomflow's model resolver understands (e.g.
|
|
69
|
+
``"gpt-4.1-nano"``, ``"claude-sonnet-4-6"``).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The suggested commit message as plain text — already
|
|
73
|
+
stripped of leading/trailing whitespace. Empty diff in →
|
|
74
|
+
an empty string out (caller should guard the empty case).
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
Whatever the underlying loomflow model adapter raises on
|
|
78
|
+
provider errors (missing API key, network, etc.). The
|
|
79
|
+
caller is responsible for surfacing those.
|
|
80
|
+
"""
|
|
81
|
+
if not diff or not diff.strip():
|
|
82
|
+
return ""
|
|
83
|
+
scribe = Agent(
|
|
84
|
+
_COMMIT_SYSTEM_PROMPT,
|
|
85
|
+
model=model or _DEFAULT_MODEL,
|
|
86
|
+
)
|
|
87
|
+
result = await scribe.run(diff)
|
|
88
|
+
return (result.output or "").strip()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Built-in skills shipped with loom-code.
|
|
2
|
+
|
|
3
|
+
Each subdirectory under here is a loomflow skill — ``SKILL.md``
|
|
4
|
+
(frontmatter + body) plus optional ``tools.py`` for Mode B Python
|
|
5
|
+
tools. Loomflow's :class:`SkillRegistry` discovers them from a
|
|
6
|
+
directory path; ``loom_code.agent.build_agent`` points at this
|
|
7
|
+
package's resource path so all bundled skills are wired into the
|
|
8
|
+
coordinator's surface automatically.
|
|
9
|
+
|
|
10
|
+
Shipped today:
|
|
11
|
+
|
|
12
|
+
* ``graphify/`` — knowledge-graph extraction over the project,
|
|
13
|
+
using graphify's Python primitives directly (no subprocess,
|
|
14
|
+
no MCP). Build / query / path / explain tools, prefixed
|
|
15
|
+
``graphify__*`` once loaded.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: graphify
|
|
3
|
+
description: Build + query a knowledge graph of this project's code. Reach for it on STRUCTURAL questions (cross-file deps, paths between concepts, which file/module everything connects through) that grep can't traverse — never for single-file questions grep wins on.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# graphify
|
|
7
|
+
|
|
8
|
+
Turn the project's source files into a knowledge graph: nodes are
|
|
9
|
+
symbols and files, edges are imports / calls / references / shared
|
|
10
|
+
data. Persisted to ``.loom/graphify/graph.json`` so queries are
|
|
11
|
+
cheap across runs. AST-only extraction — deterministic, no LLM
|
|
12
|
+
cost, no provider key required.
|
|
13
|
+
|
|
14
|
+
## Tools (after `load_skill`)
|
|
15
|
+
|
|
16
|
+
**IMPORTANT — these are PYTHON @tool FUNCTIONS, not shell
|
|
17
|
+
commands.** Call them via the model's tool-call mechanism, the
|
|
18
|
+
same way you call ``read`` / ``edit`` / ``grep``. Do NOT pass
|
|
19
|
+
them to ``bash`` — there is no ``graphify__build`` executable on
|
|
20
|
+
disk, only a registered tool with that name. If you try
|
|
21
|
+
``bash graphify__build ...`` you'll get "command not found"
|
|
22
|
+
because graphify__build is an in-process Python function, not a
|
|
23
|
+
CLI. Use tool-call syntax exclusively.
|
|
24
|
+
|
|
25
|
+
* ``graphify__build(path=".")`` — walk the project, extract,
|
|
26
|
+
cluster, write ``.loom/graphify/graph.json``. Idempotent and
|
|
27
|
+
incremental: re-running on an unchanged repo is fast (file
|
|
28
|
+
hashes gate re-extraction). Run this ONCE per project (or after
|
|
29
|
+
major refactors) before the query tools below; the post-commit
|
|
30
|
+
hook keeps it current every 5 commits afterward.
|
|
31
|
+
|
|
32
|
+
* ``graphify__query(question, path=".")`` — three-tier search
|
|
33
|
+
that surfaces the WHOLE subsystem around a keyword, not just
|
|
34
|
+
literal name matches. Returns results grouped by tier:
|
|
35
|
+
|
|
36
|
+
- **DIRECT** — nodes whose label or source file literally
|
|
37
|
+
matches the query keywords (the narrow case grep would also
|
|
38
|
+
catch).
|
|
39
|
+
- **NEIGHBOR** — 1-hop graph neighbours of any direct match.
|
|
40
|
+
Surfaces callers / callees / dependencies whose names DON'T
|
|
41
|
+
contain the keyword but participate in the same call
|
|
42
|
+
structure. (Example: querying ``auth`` surfaces
|
|
43
|
+
``validate_token``, ``check_credentials``,
|
|
44
|
+
``require_login`` — all of which grep would miss.)
|
|
45
|
+
- **COMMUNITY** — other nodes in the same Leiden cluster as
|
|
46
|
+
a direct match. Surfaces the rest of the subsystem when
|
|
47
|
+
the query is a concept name (``auth``, ``runtime``,
|
|
48
|
+
``memory``) rather than a specific symbol.
|
|
49
|
+
|
|
50
|
+
Use for "what's involved in X" / "how does Y work in this
|
|
51
|
+
codebase" / "what's the auth subsystem look like" — questions
|
|
52
|
+
about a CONCEPT spanning files, not a single name lookup. For
|
|
53
|
+
literal "where is foo defined" use grep directly.
|
|
54
|
+
|
|
55
|
+
* ``graphify__path(a, b, path=".")`` — shortest path between two
|
|
56
|
+
named concepts. Returns the hop-by-hop trail with edge labels.
|
|
57
|
+
Use for "how does A get to B" / "what's the connection between
|
|
58
|
+
X and Y" — exactly what grep can't answer.
|
|
59
|
+
|
|
60
|
+
* ``graphify__explain(node, path=".")`` — plain-language
|
|
61
|
+
explanation of one node: what it is, its source file/line, its
|
|
62
|
+
immediate neighbors, the community/cluster it belongs to. Use
|
|
63
|
+
when the user asks about a specific symbol or file.
|
|
64
|
+
|
|
65
|
+
## When to reach for graphify
|
|
66
|
+
|
|
67
|
+
Use the graph tools when the question is **structural** — about
|
|
68
|
+
how things connect across files. Concrete trigger shapes:
|
|
69
|
+
|
|
70
|
+
* "What connects A to B?"
|
|
71
|
+
* "Where is the auth code used?"
|
|
72
|
+
* "What are the dependencies of foo?"
|
|
73
|
+
* "Show me the path between X and Y."
|
|
74
|
+
* "Which file is central to this codebase?" → ``graphify__query("god nodes")`` returns highest-degree symbols.
|
|
75
|
+
|
|
76
|
+
**Do NOT reach for graphify when:**
|
|
77
|
+
|
|
78
|
+
* The question is about ONE file's content — use ``read`` /
|
|
79
|
+
``grep`` directly. Graph queries waste tokens on one-file
|
|
80
|
+
answers.
|
|
81
|
+
* You haven't run ``graphify__build`` yet — call it first, then
|
|
82
|
+
query. If ``.loom/graphify/graph.json`` doesn't exist any
|
|
83
|
+
graph_* call returns an error pointing here.
|
|
84
|
+
* The user wants raw source text — that's a ``read`` / ``grep``
|
|
85
|
+
job, not a graph job. The graph holds *structure*, not bodies.
|
|
86
|
+
|
|
87
|
+
## Cost shape
|
|
88
|
+
|
|
89
|
+
* ``graphify__build``: ~5-30s on typical loom-code-sized
|
|
90
|
+
projects (100-500 files). Up to 2 min on monorepos. Pure
|
|
91
|
+
Python, no LLM call. AST-only — code files only; docs / PDFs /
|
|
92
|
+
images need the standalone ``/graphify`` skill in Claude Code,
|
|
93
|
+
not this one.
|
|
94
|
+
|
|
95
|
+
* ``graphify__query`` / ``graphify__path`` / ``graphify__explain``:
|
|
96
|
+
milliseconds. They load ``graph.json`` once and traverse in
|
|
97
|
+
memory.
|