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/permissions.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Permission rules + modes for the approval gate.
|
|
2
|
+
|
|
3
|
+
Claude Code's safety model, adapted: instead of asking the user y/n
|
|
4
|
+
for every destructive call, a layered policy decides allow / ask / deny
|
|
5
|
+
up front, and only genuine "ask" calls reach the interactive prompt.
|
|
6
|
+
|
|
7
|
+
Two knobs:
|
|
8
|
+
|
|
9
|
+
* **Rules** — ``allow`` / ``ask`` / ``deny`` glob patterns declared in
|
|
10
|
+
``settings.toml`` (user + project scopes), matched against a
|
|
11
|
+
``tool(target)`` string like ``bash(pytest -q)`` or
|
|
12
|
+
``edit(src/.env)``. ``deny`` wins over ``ask`` wins over ``allow``,
|
|
13
|
+
and deny is absolute (not even 'allow all' or accept-edits overrides
|
|
14
|
+
it). This lets a user say ``allow "bash(pytest*)"`` /
|
|
15
|
+
``deny "edit(*.env)"`` once instead of confirming forever.
|
|
16
|
+
* **Mode** — the session's default posture for calls no rule matches:
|
|
17
|
+
``default`` (ask), ``accept-edits`` (auto-allow write/edit, still ask
|
|
18
|
+
for bash), ``plan`` (deny all mutation — research only), or
|
|
19
|
+
``yolo`` (allow all; the irreversible-danger gate still fires).
|
|
20
|
+
|
|
21
|
+
The engine here is pure decision logic + parsing; :class:`ApprovalGate`
|
|
22
|
+
owns the interactive prompt and calls :func:`decide`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import fnmatch
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from enum import StrEnum
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
# The mutation tools the gate arbitrates. Read-only tools never reach
|
|
33
|
+
# it (loomflow's StandardPermissions only flags these).
|
|
34
|
+
_MUTATION_TOOLS = frozenset({"write", "edit", "multi_edit", "bash"})
|
|
35
|
+
_EDIT_TOOLS = frozenset({"write", "edit", "multi_edit"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Decision(StrEnum):
|
|
39
|
+
ALLOW = "allow"
|
|
40
|
+
ASK = "ask"
|
|
41
|
+
DENY = "deny"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Mode(StrEnum):
|
|
45
|
+
"""Session default posture for calls no rule matches."""
|
|
46
|
+
|
|
47
|
+
DEFAULT = "default" # ask for every mutation
|
|
48
|
+
ACCEPT_EDITS = "accept-edits" # auto-allow edits, ask for bash
|
|
49
|
+
PLAN = "plan" # deny all mutation (research only)
|
|
50
|
+
YOLO = "yolo" # allow all (danger gate still fires)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Rules:
|
|
55
|
+
"""Ordered allow/ask/deny glob patterns. Patterns match a
|
|
56
|
+
``tool(target)`` string, case-sensitively, via fnmatch."""
|
|
57
|
+
|
|
58
|
+
allow: list[str] = field(default_factory=list)
|
|
59
|
+
ask: list[str] = field(default_factory=list)
|
|
60
|
+
deny: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
def merged_with(self, other: Rules) -> Rules:
|
|
63
|
+
"""Combine two scopes (project onto user). Concatenation is
|
|
64
|
+
enough — :func:`decide` applies deny>ask>allow precedence, so
|
|
65
|
+
order within a bucket doesn't change the outcome."""
|
|
66
|
+
return Rules(
|
|
67
|
+
allow=[*self.allow, *other.allow],
|
|
68
|
+
ask=[*self.ask, *other.ask],
|
|
69
|
+
deny=[*self.deny, *other.deny],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def call_target(tool: str, args: dict[str, Any]) -> str:
|
|
74
|
+
"""The string a rule matches against: ``tool(target)``.
|
|
75
|
+
|
|
76
|
+
``target`` is the command for bash, the path for edits/writes, so a
|
|
77
|
+
user writes intuitive patterns: ``bash(pytest*)``, ``edit(*.env)``.
|
|
78
|
+
"""
|
|
79
|
+
if tool == "bash":
|
|
80
|
+
target = str(args.get("command", "")).strip()
|
|
81
|
+
else:
|
|
82
|
+
target = str(args.get("path", "")).strip()
|
|
83
|
+
return f"{tool}({target})"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _matches(patterns: list[str], tool: str, target: str) -> bool:
|
|
87
|
+
"""True if any pattern matches, tried against both the full
|
|
88
|
+
``tool(target)`` string and a bare ``tool`` (so ``deny "bash"``
|
|
89
|
+
blanket-denies bash without needing ``bash(*)``)."""
|
|
90
|
+
for pat in patterns:
|
|
91
|
+
if fnmatch.fnmatch(target, pat) or fnmatch.fnmatch(tool, pat):
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def decide(
|
|
97
|
+
tool: str, args: dict[str, Any], rules: Rules, mode: Mode
|
|
98
|
+
) -> Decision:
|
|
99
|
+
"""Resolve a destructive call to allow / ask / deny.
|
|
100
|
+
|
|
101
|
+
Precedence, highest first:
|
|
102
|
+
1. explicit ``deny`` rule — absolute, nothing overrides it
|
|
103
|
+
2. explicit ``ask`` rule — force the prompt even in yolo
|
|
104
|
+
3. explicit ``allow`` rule — skip the prompt
|
|
105
|
+
4. mode default — plan denies, accept-edits allows
|
|
106
|
+
edits, yolo allows, default asks
|
|
107
|
+
"""
|
|
108
|
+
target = call_target(tool, args)
|
|
109
|
+
if _matches(rules.deny, tool, target):
|
|
110
|
+
return Decision.DENY
|
|
111
|
+
if _matches(rules.ask, tool, target):
|
|
112
|
+
return Decision.ASK
|
|
113
|
+
if _matches(rules.allow, tool, target):
|
|
114
|
+
return Decision.ALLOW
|
|
115
|
+
|
|
116
|
+
if mode is Mode.PLAN:
|
|
117
|
+
return Decision.DENY
|
|
118
|
+
if mode is Mode.YOLO:
|
|
119
|
+
return Decision.ALLOW
|
|
120
|
+
if mode is Mode.ACCEPT_EDITS and tool in _EDIT_TOOLS:
|
|
121
|
+
return Decision.ALLOW
|
|
122
|
+
return Decision.ASK
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_mode(text: str) -> Mode | None:
|
|
126
|
+
"""Parse a ``/mode`` argument, tolerant of aliases, or None."""
|
|
127
|
+
t = text.strip().lower().replace("_", "-")
|
|
128
|
+
aliases = {
|
|
129
|
+
"default": Mode.DEFAULT,
|
|
130
|
+
"normal": Mode.DEFAULT,
|
|
131
|
+
"ask": Mode.DEFAULT,
|
|
132
|
+
"accept-edits": Mode.ACCEPT_EDITS,
|
|
133
|
+
"acceptedits": Mode.ACCEPT_EDITS,
|
|
134
|
+
"edits": Mode.ACCEPT_EDITS,
|
|
135
|
+
"auto-edit": Mode.ACCEPT_EDITS,
|
|
136
|
+
"plan": Mode.PLAN,
|
|
137
|
+
"readonly": Mode.PLAN,
|
|
138
|
+
"read-only": Mode.PLAN,
|
|
139
|
+
"yolo": Mode.YOLO,
|
|
140
|
+
"bypass": Mode.YOLO,
|
|
141
|
+
"allow-all": Mode.YOLO,
|
|
142
|
+
}
|
|
143
|
+
return aliases.get(t)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_rules(settings_dir_paths: list[Any]) -> Rules:
|
|
147
|
+
"""Merge ``[permissions]`` allow/ask/deny lists from each
|
|
148
|
+
``<dir>/settings.toml`` in order (user first, project last so the
|
|
149
|
+
project layers on top). Lenient — missing file / bad TOML / wrong
|
|
150
|
+
types are skipped, never raised (a broken config must not brick
|
|
151
|
+
startup, matching the extensions loader's posture)."""
|
|
152
|
+
import tomllib
|
|
153
|
+
from pathlib import Path
|
|
154
|
+
|
|
155
|
+
merged = Rules()
|
|
156
|
+
for base in settings_dir_paths:
|
|
157
|
+
settings = Path(base) / "settings.toml"
|
|
158
|
+
try:
|
|
159
|
+
data = tomllib.loads(settings.read_text(encoding="utf-8"))
|
|
160
|
+
except (OSError, ValueError):
|
|
161
|
+
continue
|
|
162
|
+
perms = data.get("permissions")
|
|
163
|
+
if not isinstance(perms, dict):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
def _strs(perms: dict[str, Any], key: str) -> list[str]:
|
|
167
|
+
v = perms.get(key)
|
|
168
|
+
return [str(x) for x in v] if isinstance(v, list) else []
|
|
169
|
+
|
|
170
|
+
merged = merged.merged_with(
|
|
171
|
+
Rules(
|
|
172
|
+
allow=_strs(perms, "allow"),
|
|
173
|
+
ask=_strs(perms, "ask"),
|
|
174
|
+
deny=_strs(perms, "deny"),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return merged
|
loom_code/project.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Project detection — find the repo root + load context files.
|
|
2
|
+
|
|
3
|
+
A coding agent that doesn't know where the project starts and what
|
|
4
|
+
its conventions are wastes turns rediscovering them. This module
|
|
5
|
+
answers two questions cheaply, once, at startup:
|
|
6
|
+
|
|
7
|
+
1. **Where's the project root?** Walk up from cwd looking for a
|
|
8
|
+
``.git`` dir (fall back to cwd if none — loom-code still works
|
|
9
|
+
on a loose folder of files).
|
|
10
|
+
2. **What are the project's conventions?** Read the first context
|
|
11
|
+
file we find — ``CLAUDE.md`` / ``AGENTS.md`` / ``.loom/context.md``
|
|
12
|
+
— and hand it to the system prompt so the agent starts already
|
|
13
|
+
knowing the house rules.
|
|
14
|
+
|
|
15
|
+
Note: the codebase structure is NOT baked here. A deterministic
|
|
16
|
+
repo map (top symbols by structural importance) is injected per turn
|
|
17
|
+
into the ``loom_index`` working block via
|
|
18
|
+
:func:`loom_code.loominit.repomap.repo_map_for_root_cached`.
|
|
19
|
+
``CLAUDE.md`` / ``AGENTS.md`` stay as static bake because they encode
|
|
20
|
+
"house rules" (small, every-turn relevant) — a different role from
|
|
21
|
+
the codebase map.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# Context-file names checked in priority order. CLAUDE.md first
|
|
30
|
+
# because it's the de-facto standard a lot of repos already have;
|
|
31
|
+
# AGENTS.md is the cross-tool convention; .loom/context.md is the
|
|
32
|
+
# loom-code-native opt-in for a dedicated house-rules file.
|
|
33
|
+
# LOOM.md is deliberately absent — see module docstring.
|
|
34
|
+
_CONTEXT_FILENAMES = (
|
|
35
|
+
"CLAUDE.md",
|
|
36
|
+
"AGENTS.md",
|
|
37
|
+
".loom/context.md",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Cap the context file we inline into the system prompt — a
|
|
41
|
+
# runaway 50KB CLAUDE.md would blow the budget. Past this we
|
|
42
|
+
# truncate with a note; the agent can ``read`` the full file.
|
|
43
|
+
_MAX_CONTEXT_CHARS = 8_000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Project:
|
|
48
|
+
"""Everything loom-code needs to know about where it's running."""
|
|
49
|
+
|
|
50
|
+
root: Path
|
|
51
|
+
"""The project root — git toplevel, or cwd if not a git repo."""
|
|
52
|
+
|
|
53
|
+
is_git: bool
|
|
54
|
+
"""True when ``root`` contains a ``.git`` directory."""
|
|
55
|
+
|
|
56
|
+
context_file: Path | None
|
|
57
|
+
"""Path to the context file we found (CLAUDE.md etc.), or None."""
|
|
58
|
+
|
|
59
|
+
context_text: str
|
|
60
|
+
"""The context file's body, truncated to a budget. Empty string
|
|
61
|
+
when no context file exists."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def detect_project(start: Path | str | None = None) -> Project:
|
|
65
|
+
"""Detect the project rooted at (or above) ``start`` (default cwd).
|
|
66
|
+
|
|
67
|
+
Walks up looking for ``.git``; if found, that's the root and
|
|
68
|
+
``is_git`` is True. Otherwise the root is ``start`` itself —
|
|
69
|
+
loom-code still runs, it just doesn't have a repo boundary.
|
|
70
|
+
"""
|
|
71
|
+
cwd = Path(start).resolve() if start else Path.cwd().resolve()
|
|
72
|
+
|
|
73
|
+
root = cwd
|
|
74
|
+
is_git = False
|
|
75
|
+
for candidate in (cwd, *cwd.parents):
|
|
76
|
+
if (candidate / ".git").exists():
|
|
77
|
+
root = candidate
|
|
78
|
+
is_git = True
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
context_file: Path | None = None
|
|
82
|
+
context_text = ""
|
|
83
|
+
for name in _CONTEXT_FILENAMES:
|
|
84
|
+
fp = root / name
|
|
85
|
+
if fp.is_file():
|
|
86
|
+
context_file = fp
|
|
87
|
+
raw = fp.read_text(errors="replace")
|
|
88
|
+
if len(raw) > _MAX_CONTEXT_CHARS:
|
|
89
|
+
context_text = (
|
|
90
|
+
raw[:_MAX_CONTEXT_CHARS]
|
|
91
|
+
+ f"\n\n... [context file truncated at "
|
|
92
|
+
f"{_MAX_CONTEXT_CHARS} chars — use the `read` "
|
|
93
|
+
f"tool on {name} for the full text]"
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
context_text = raw
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
return Project(
|
|
100
|
+
root=root,
|
|
101
|
+
is_git=is_git,
|
|
102
|
+
context_file=context_file,
|
|
103
|
+
context_text=context_text,
|
|
104
|
+
)
|