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