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
@@ -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
+ )