loom-code 0.1.1__tar.gz → 0.1.3__tar.gz
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-0.1.1 → loom_code-0.1.3}/PKG-INFO +6 -5
- {loom_code-0.1.1 → loom_code-0.1.3}/README.md +4 -3
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/__init__.py +1 -1
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/approval.py +66 -12
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/checkpoint.py +15 -8
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/repomap.py +62 -12
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/PKG-INFO +6 -5
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/requires.txt +1 -1
- {loom_code-0.1.1 → loom_code-0.1.3}/pyproject.toml +8 -3
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_approval.py +75 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_checkpoint.py +35 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/LICENSE +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/_post_commit.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/agent.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/__init__.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/act.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/observe.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/session.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/verify.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/cli.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/code_index.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/compact.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/consent.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/credentials.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/edit_tool.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/extensions.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/file_history.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/file_tools.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/git_hook.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/grep_tool.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/hooks.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/__init__.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_ast_walk.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_files.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_graph.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_resolve.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_tests_map.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/extractor.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/schema.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/lsp_tools.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/mcp_host.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/operator.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/paste.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/paths.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/permissions.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/project.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/prompts.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/render.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/repl.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/rules.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/sandboxed_bash.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/scribe.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/skills/__init__.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/skills/graphify/SKILL.md +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/skills/graphify/tools.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/trust.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/turn.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/web_fetch.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/workers.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/worktree.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/SOURCES.txt +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/dependency_links.txt +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/entry_points.txt +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/top_level.txt +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/setup.cfg +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_agent.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_antipoison_gate.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_approval_danger.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_approval_integration.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_code_index.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_compact.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_credentials.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_edit_tool.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_extensions.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_file_boundary.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_file_history.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_git_hook.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_graphify_file_discovery.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_graphify_query_tiers.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_graphify_wiring.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_grep_tool.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_learned_notes.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_loom_hooks.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_lsp_tools.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_mcp.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_paste.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_permissions.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_pricing.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_project.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_prompts.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_render.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_repl_guards.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_resume_migration.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_resume_preview.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_routing.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_rules.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_sandboxed_bash.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_stream_liveness.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_turn_economy.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_web_fetch.py +0 -0
- {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_workers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loom-code
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: loom-code — a loomflow-native terminal coding agent
|
|
5
5
|
Author: Anupam Nautiyal
|
|
6
6
|
License: MIT License
|
|
@@ -39,7 +39,7 @@ Classifier: Topic :: Software Development :: Code Generators
|
|
|
39
39
|
Requires-Python: >=3.11
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
|
-
Requires-Dist: loomflow[litellm,mcp,web]<0.11,>=0.10.
|
|
42
|
+
Requires-Dist: loomflow[litellm,mcp,web]<0.11,>=0.10.30
|
|
43
43
|
Requires-Dist: rich>=13
|
|
44
44
|
Requires-Dist: prompt-toolkit>=3.0
|
|
45
45
|
Requires-Dist: anthropic
|
|
@@ -106,11 +106,12 @@ load-bearing — the agent loop, tools, planning, memory — is loomflow.
|
|
|
106
106
|
## Install
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
pipx install
|
|
109
|
+
pipx install loom-code
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
(`pip install` works too; `pipx` keeps CLI tools in their
|
|
113
|
-
No pipx? `brew install pipx` or
|
|
112
|
+
(`pip install loom-code` works too; `pipx` keeps CLI tools in their
|
|
113
|
+
own venvs. No pipx? `brew install pipx` or
|
|
114
|
+
`python -m pip install --user pipx`.)
|
|
114
115
|
|
|
115
116
|
Requires Python 3.11+. To update: `pipx upgrade loom-code`.
|
|
116
117
|
|
|
@@ -52,11 +52,12 @@ load-bearing — the agent loop, tools, planning, memory — is loomflow.
|
|
|
52
52
|
## Install
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
pipx install
|
|
55
|
+
pipx install loom-code
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
(`pip install` works too; `pipx` keeps CLI tools in their
|
|
59
|
-
No pipx? `brew install pipx` or
|
|
58
|
+
(`pip install loom-code` works too; `pipx` keeps CLI tools in their
|
|
59
|
+
own venvs. No pipx? `brew install pipx` or
|
|
60
|
+
`python -m pip install --user pipx`.)
|
|
60
61
|
|
|
61
62
|
Requires Python 3.11+. To update: `pipx upgrade loom-code`.
|
|
62
63
|
|
|
@@ -112,6 +112,30 @@ def _read_key_raw(fd: int) -> str:
|
|
|
112
112
|
return ch.lower()
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def _read_key_msvcrt() -> str:
|
|
116
|
+
"""Windows equivalent of :func:`_read_key_raw` — one LOGICAL key
|
|
117
|
+
via ``msvcrt.getwch()`` (already raw + no echo, so no mode
|
|
118
|
+
setup/teardown is needed).
|
|
119
|
+
|
|
120
|
+
Arrow keys arrive as a TWO-event sequence: a ``'\\xe0'`` (or
|
|
121
|
+
``'\\x00'`` for some layouts) prefix, then ``'H'`` (up) /
|
|
122
|
+
``'P'`` (down). Ctrl-C surfaces as ``'\\x03'`` and maps to the
|
|
123
|
+
SAFE 'esc', mirroring the POSIX reader."""
|
|
124
|
+
import msvcrt
|
|
125
|
+
|
|
126
|
+
ch = msvcrt.getwch()
|
|
127
|
+
if ch in ("\r", "\n"):
|
|
128
|
+
return "enter"
|
|
129
|
+
if ch == "\x03": # Ctrl-C
|
|
130
|
+
return "esc"
|
|
131
|
+
if ch == "\x1b":
|
|
132
|
+
return "esc"
|
|
133
|
+
if ch in ("\xe0", "\x00"): # extended-key prefix
|
|
134
|
+
final = msvcrt.getwch()
|
|
135
|
+
return {"H": "up", "P": "down"}.get(final, "esc")
|
|
136
|
+
return ch.lower()
|
|
137
|
+
|
|
138
|
+
|
|
115
139
|
def _read_key() -> str:
|
|
116
140
|
"""Single-key read that manages its own raw-mode window. Prefer
|
|
117
141
|
:func:`_read_key_raw` inside a selector that enters raw mode ONCE
|
|
@@ -187,23 +211,54 @@ def _select_option(options: list[tuple[str, str]], default: int = 0) -> str:
|
|
|
187
211
|
out.write("\r\n") # explicit CR+LF for raw mode
|
|
188
212
|
out.flush()
|
|
189
213
|
|
|
190
|
-
#
|
|
214
|
+
# Pick the platform's key reader + raw-mode strategy.
|
|
215
|
+
#
|
|
216
|
+
# POSIX: enter raw mode ONCE for the whole selector session — no
|
|
191
217
|
# per-keypress termios churn, and no cooked-mode gap between keys
|
|
192
218
|
# where type-ahead would echo raw escape bytes onto the prompt.
|
|
193
|
-
|
|
194
|
-
import
|
|
219
|
+
#
|
|
220
|
+
# Windows: there is NO termios (the bare import crashed /set_model
|
|
221
|
+
# for pipx users with ModuleNotFoundError). msvcrt.getwch() is
|
|
222
|
+
# already raw + unbuffered, so no mode management is needed at
|
|
223
|
+
# all; ``os.system("")`` nudges legacy conhost into processing the
|
|
224
|
+
# ANSI redraw sequences (Windows Terminal has VT on by default).
|
|
225
|
+
def _restore() -> None:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def _reader() -> str:
|
|
229
|
+
return _read_key()
|
|
195
230
|
|
|
196
|
-
fd = sys.stdin.fileno()
|
|
197
231
|
try:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
232
|
+
import termios
|
|
233
|
+
import tty
|
|
234
|
+
|
|
235
|
+
fd = sys.stdin.fileno()
|
|
236
|
+
try:
|
|
237
|
+
old = termios.tcgetattr(fd)
|
|
238
|
+
except Exception:
|
|
239
|
+
old = None
|
|
240
|
+
if old is not None:
|
|
241
|
+
tty.setraw(fd)
|
|
242
|
+
|
|
243
|
+
def _reader() -> str:
|
|
244
|
+
return _read_key_raw(fd)
|
|
245
|
+
|
|
246
|
+
def _restore() -> None:
|
|
247
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
248
|
+
except ImportError:
|
|
249
|
+
try:
|
|
250
|
+
import msvcrt # noqa: F401 — probe: Windows console?
|
|
251
|
+
import os
|
|
252
|
+
|
|
253
|
+
os.system("") # enable VT processing on legacy conhost
|
|
254
|
+
_reader = _read_key_msvcrt
|
|
255
|
+
except ImportError:
|
|
256
|
+
pass # exotic platform → keep the _read_key fallback
|
|
257
|
+
|
|
203
258
|
try:
|
|
204
259
|
_draw(first=True)
|
|
205
260
|
while True:
|
|
206
|
-
key =
|
|
261
|
+
key = _reader()
|
|
207
262
|
if key == "up":
|
|
208
263
|
idx = (idx - 1) % n
|
|
209
264
|
elif key == "down":
|
|
@@ -221,8 +276,7 @@ def _select_option(options: list[tuple[str, str]], default: int = 0) -> str:
|
|
|
221
276
|
continue # unknown key: ignore, keep waiting
|
|
222
277
|
_draw(first=False)
|
|
223
278
|
finally:
|
|
224
|
-
|
|
225
|
-
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
279
|
+
_restore()
|
|
226
280
|
|
|
227
281
|
|
|
228
282
|
def _read_single_key() -> str:
|
|
@@ -134,18 +134,25 @@ def _snapshot_commit(root: Path) -> tuple[str | None, str]:
|
|
|
134
134
|
|
|
135
135
|
# Temp index OUTSIDE the worktree so it never appears in the
|
|
136
136
|
# snapshot (an in-tree temp index leaked itself into the tree —
|
|
137
|
-
# verified).
|
|
138
|
-
#
|
|
137
|
+
# verified).
|
|
138
|
+
#
|
|
139
|
+
# Seed it from HEAD's TREE via read-tree — NOT by copying the real
|
|
140
|
+
# index file. A copied index carries the real index's STAT CACHE,
|
|
141
|
+
# and a same-size edit whose mtime matches the cached stat makes
|
|
142
|
+
# ``git add`` trust the cache and skip re-hashing — the snapshot
|
|
143
|
+
# silently captures the OLD content and /undo restores stale data
|
|
144
|
+
# (agents write fast; hit for real on CI). read-tree writes
|
|
145
|
+
# entries with no stat information, so the add below re-stats and
|
|
146
|
+
# re-hashes every path — the trap can't fire.
|
|
139
147
|
tmp_fd, tmp_index = tempfile.mkstemp(prefix="loom-ckpt-index-")
|
|
140
148
|
os.close(tmp_fd)
|
|
141
149
|
try:
|
|
142
|
-
real_index = root / ".git" / "index"
|
|
143
|
-
if real_index.is_file():
|
|
144
|
-
try:
|
|
145
|
-
Path(tmp_index).write_bytes(real_index.read_bytes())
|
|
146
|
-
except OSError:
|
|
147
|
-
pass # start from empty temp index
|
|
148
150
|
env = {"GIT_INDEX_FILE": tmp_index}
|
|
151
|
+
if parent:
|
|
152
|
+
rc, _o, err = _git(root, ["read-tree", parent], env=env)
|
|
153
|
+
if rc != 0:
|
|
154
|
+
return None, f"read-tree failed: {err.strip()}"
|
|
155
|
+
# No HEAD yet → empty temp index; add stages everything fresh.
|
|
149
156
|
# Stage tracked changes + untracked files. .loom is excluded so
|
|
150
157
|
# the snapshot doesn't churn on our own state files (it's also
|
|
151
158
|
# usually gitignored, but be explicit).
|
|
@@ -134,23 +134,61 @@ _SKIP_DIRS = frozenset(
|
|
|
134
134
|
|
|
135
135
|
# Cache: root -> (signature, rendered map). The signature is cheap to
|
|
136
136
|
# recompute; the expensive AST walk only re-runs when it changes.
|
|
137
|
-
|
|
137
|
+
# ``_UNMAPPABLE`` in the signature slot marks a root we refuse to map
|
|
138
|
+
# (drive root / home dir / walk blew the budget) — cached so later
|
|
139
|
+
# turns skip instantly instead of re-attempting the walk.
|
|
140
|
+
_UNMAPPABLE = object()
|
|
141
|
+
_REPO_MAP_CACHE: dict[str, tuple[object, str | None]] = {}
|
|
142
|
+
|
|
143
|
+
# Walk guards. The signature walk runs at EVERY turn start, on the
|
|
144
|
+
# REPL's event loop — it must never be able to stall a turn. A user
|
|
145
|
+
# launched loom-code at ``D:\`` (drive root, no git → cwd becomes the
|
|
146
|
+
# project root) and the first turn hung walking the whole drive.
|
|
147
|
+
_SIG_MAX_FILES = 50_000 # .py files — far beyond any sane repo
|
|
148
|
+
_SIG_TIME_BUDGET_S = 3.0 # wall-clock cap for the whole walk
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _unmappable_root(root: Path) -> bool:
|
|
152
|
+
"""Roots where a repo map makes no sense and the walk is dangerous:
|
|
153
|
+
a filesystem/drive root (``/``, ``C:\\``, ``D:\\``) or the user's
|
|
154
|
+
home directory. These aren't projects — they're the world."""
|
|
155
|
+
try:
|
|
156
|
+
return root == Path(root.anchor) or root == Path.home()
|
|
157
|
+
except OSError:
|
|
158
|
+
return True # can't even resolve — don't walk it
|
|
138
159
|
|
|
139
160
|
|
|
140
|
-
def _tree_signature(root: Path) -> tuple[float, int]:
|
|
161
|
+
def _tree_signature(root: Path) -> tuple[float, int] | None:
|
|
141
162
|
"""A cheap freshness key: (newest .py mtime, file count). Either
|
|
142
163
|
moving means the tree changed → rebuild. Count catches add/delete
|
|
143
|
-
that don't bump the newest mtime.
|
|
164
|
+
that don't bump the newest mtime.
|
|
165
|
+
|
|
166
|
+
``os.walk`` with pruning (NOT ``rglob``, which descends into
|
|
167
|
+
node_modules/.venv and filters after the fact) plus a file-count
|
|
168
|
+
and wall-clock budget. Returns ``None`` when the budget is
|
|
169
|
+
breached — the tree is too big to map; the caller caches the
|
|
170
|
+
refusal."""
|
|
171
|
+
import os
|
|
172
|
+
import time as _time
|
|
173
|
+
|
|
174
|
+
deadline = _time.monotonic() + _SIG_TIME_BUDGET_S
|
|
144
175
|
newest = 0.0
|
|
145
176
|
count = 0
|
|
146
|
-
for
|
|
147
|
-
if
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
177
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
178
|
+
if _time.monotonic() > deadline:
|
|
179
|
+
return None
|
|
180
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
181
|
+
for fn in filenames:
|
|
182
|
+
if not fn.endswith(".py"):
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
st = os.stat(os.path.join(dirpath, fn))
|
|
186
|
+
except OSError:
|
|
187
|
+
continue
|
|
188
|
+
newest = max(newest, st.st_mtime)
|
|
151
189
|
count += 1
|
|
152
|
-
|
|
153
|
-
|
|
190
|
+
if count > _SIG_MAX_FILES:
|
|
191
|
+
return None
|
|
154
192
|
return (newest, count)
|
|
155
193
|
|
|
156
194
|
|
|
@@ -161,11 +199,23 @@ def _repo_map_cached(
|
|
|
161
199
|
True when the source tree changed and we re-walked, False on a
|
|
162
200
|
cache hit. Freshness-keyed (newest .py mtime + file count), so it
|
|
163
201
|
only re-parses when something actually moved. Mirrors the map to
|
|
164
|
-
``<root>/.loom/repomap.md`` on every rebuild for inspection.
|
|
202
|
+
``<root>/.loom/repomap.md`` on every rebuild for inspection.
|
|
203
|
+
|
|
204
|
+
Unmappable roots (drive root / home / budget-breaching trees)
|
|
205
|
+
return ``(None, False)`` and are cached as such — the agent simply
|
|
206
|
+
runs without a repo map there, and no later turn pays the walk."""
|
|
165
207
|
root_p = Path(root).resolve()
|
|
166
208
|
key = str(root_p)
|
|
167
|
-
sig = _tree_signature(root_p)
|
|
168
209
|
cached = _REPO_MAP_CACHE.get(key)
|
|
210
|
+
if cached is not None and cached[0] is _UNMAPPABLE:
|
|
211
|
+
return None, False
|
|
212
|
+
if _unmappable_root(root_p):
|
|
213
|
+
_REPO_MAP_CACHE[key] = (_UNMAPPABLE, None)
|
|
214
|
+
return None, False
|
|
215
|
+
sig = _tree_signature(root_p)
|
|
216
|
+
if sig is None: # walk breached the budget — tree too big
|
|
217
|
+
_REPO_MAP_CACHE[key] = (_UNMAPPABLE, None)
|
|
218
|
+
return None, False
|
|
169
219
|
if cached is not None and cached[0] == sig:
|
|
170
220
|
return cached[1], False
|
|
171
221
|
rendered = repo_map_for_root(root_p, max_tokens=max_tokens)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loom-code
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: loom-code — a loomflow-native terminal coding agent
|
|
5
5
|
Author: Anupam Nautiyal
|
|
6
6
|
License: MIT License
|
|
@@ -39,7 +39,7 @@ Classifier: Topic :: Software Development :: Code Generators
|
|
|
39
39
|
Requires-Python: >=3.11
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
|
-
Requires-Dist: loomflow[litellm,mcp,web]<0.11,>=0.10.
|
|
42
|
+
Requires-Dist: loomflow[litellm,mcp,web]<0.11,>=0.10.30
|
|
43
43
|
Requires-Dist: rich>=13
|
|
44
44
|
Requires-Dist: prompt-toolkit>=3.0
|
|
45
45
|
Requires-Dist: anthropic
|
|
@@ -106,11 +106,12 @@ load-bearing — the agent loop, tools, planning, memory — is loomflow.
|
|
|
106
106
|
## Install
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
pipx install
|
|
109
|
+
pipx install loom-code
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
(`pip install` works too; `pipx` keeps CLI tools in their
|
|
113
|
-
No pipx? `brew install pipx` or
|
|
112
|
+
(`pip install loom-code` works too; `pipx` keeps CLI tools in their
|
|
113
|
+
own venvs. No pipx? `brew install pipx` or
|
|
114
|
+
`python -m pip install --user pipx`.)
|
|
114
115
|
|
|
115
116
|
Requires Python 3.11+. To update: `pipx upgrade loom-code`.
|
|
116
117
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "loom-code"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "loom-code — a loomflow-native terminal coding agent"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -54,11 +54,16 @@ dependencies = [
|
|
|
54
54
|
# plugin to use MCP. Discovery degrades gracefully if it's ever absent.
|
|
55
55
|
# >=0.10.25 carries the tool-call arg coercion + PEP 563 schema-typing
|
|
56
56
|
# fix that loom-code's typed tools (edit replace_all=bool, etc.) rely on.
|
|
57
|
+
# >=0.10.30 floor: the tool-transcript capture fix — earlier
|
|
58
|
+
# versions leaked rehydrated prose (a prior answer + the run's own
|
|
59
|
+
# prompt) into persisted transcripts on resumed sessions,
|
|
60
|
+
# corrupting rehydration and the /resume preview. loom-code's
|
|
61
|
+
# /resume scrubs old data; the floor stops NEW corruption.
|
|
57
62
|
# <0.11 ceiling: loomflow is pre-1.0, so a minor bump may break the
|
|
58
63
|
# composed surfaces (Team kwargs, stop hooks, working blocks). Raise
|
|
59
64
|
# the ceiling deliberately after testing, not by accident on a
|
|
60
65
|
# user's machine mid-`pipx upgrade`.
|
|
61
|
-
"loomflow[litellm,web,mcp]>=0.10.
|
|
66
|
+
"loomflow[litellm,web,mcp]>=0.10.30,<0.11",
|
|
62
67
|
"rich>=13",
|
|
63
68
|
# prompt_toolkit drives the REPL line editor — gives us inline
|
|
64
69
|
# slash-command autocomplete (a menu pops the moment the user
|
|
@@ -129,7 +134,7 @@ select = ["E", "F", "I", "B", "UP", "ASYNC"]
|
|
|
129
134
|
# publishing — no tokens). Daily flow stays plain git; ``make release
|
|
130
135
|
# BUMP=patch|minor|major`` is the explicit "this is a release" flag.
|
|
131
136
|
[tool.bumpversion]
|
|
132
|
-
current_version = "0.1.
|
|
137
|
+
current_version = "0.1.3"
|
|
133
138
|
search = "{current_version}"
|
|
134
139
|
replace = "{new_version}"
|
|
135
140
|
commit = true
|
|
@@ -70,3 +70,78 @@ async def test_approval_gate_allow_all_works_for_every_tool() -> None:
|
|
|
70
70
|
for tool in ("bash", "edit", "write"):
|
|
71
71
|
call = ToolCall(tool=tool, args={})
|
|
72
72
|
assert await gate.handler(call) is True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---- Windows key reader (msvcrt) -------------------------------------
|
|
76
|
+
# termios doesn't exist on Windows; the selector dispatches to
|
|
77
|
+
# _read_key_msvcrt there. We can't run real Windows in CI, but the
|
|
78
|
+
# reader's decode logic is pure — drive it with a fake msvcrt module.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fake_msvcrt(keys: list[str]):
|
|
82
|
+
"""A stand-in msvcrt whose getwch() pops from ``keys``."""
|
|
83
|
+
import types
|
|
84
|
+
|
|
85
|
+
mod = types.ModuleType("msvcrt")
|
|
86
|
+
seq = iter(keys)
|
|
87
|
+
mod.getwch = lambda: next(seq)
|
|
88
|
+
return mod
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_msvcrt_reader_decodes_logical_keys(monkeypatch) -> None:
|
|
92
|
+
import sys as _sys
|
|
93
|
+
|
|
94
|
+
from loom_code.approval import _read_key_msvcrt
|
|
95
|
+
|
|
96
|
+
cases = [
|
|
97
|
+
(["\r"], "enter"),
|
|
98
|
+
(["\n"], "enter"),
|
|
99
|
+
(["\x03"], "esc"), # Ctrl-C → SAFE cancel
|
|
100
|
+
(["\x1b"], "esc"),
|
|
101
|
+
(["\xe0", "H"], "up"),
|
|
102
|
+
(["\xe0", "P"], "down"),
|
|
103
|
+
(["\x00", "H"], "up"), # alternate extended prefix
|
|
104
|
+
(["\xe0", "K"], "esc"), # unknown extended key → safe
|
|
105
|
+
(["3"], "3"),
|
|
106
|
+
(["Y"], "y"),
|
|
107
|
+
]
|
|
108
|
+
for keys, expected in cases:
|
|
109
|
+
monkeypatch.setitem(
|
|
110
|
+
_sys.modules, "msvcrt", _fake_msvcrt(keys)
|
|
111
|
+
)
|
|
112
|
+
assert _read_key_msvcrt() == expected, (keys, expected)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_select_option_survives_missing_termios(monkeypatch) -> None:
|
|
116
|
+
"""Regression: on Windows there is no termios — the selector's
|
|
117
|
+
bare ``import termios`` crashed /set_model with
|
|
118
|
+
ModuleNotFoundError for every pipx-on-Windows user. With the
|
|
119
|
+
dispatch fix, a missing termios falls through to the msvcrt
|
|
120
|
+
reader (faked here): ↓ then Enter must select option 2 and
|
|
121
|
+
never raise."""
|
|
122
|
+
import builtins
|
|
123
|
+
import sys as _sys
|
|
124
|
+
|
|
125
|
+
from loom_code import approval
|
|
126
|
+
|
|
127
|
+
real_import = builtins.__import__
|
|
128
|
+
|
|
129
|
+
def _no_termios(name, *args, **kwargs):
|
|
130
|
+
if name in ("termios", "tty"):
|
|
131
|
+
raise ImportError(f"No module named '{name}'")
|
|
132
|
+
return real_import(name, *args, **kwargs)
|
|
133
|
+
|
|
134
|
+
monkeypatch.setattr(builtins, "__import__", _no_termios)
|
|
135
|
+
monkeypatch.setitem(
|
|
136
|
+
_sys.modules, "msvcrt", _fake_msvcrt(["\xe0", "P", "\r"])
|
|
137
|
+
)
|
|
138
|
+
monkeypatch.delitem(_sys.modules, "termios", raising=False)
|
|
139
|
+
monkeypatch.delitem(_sys.modules, "tty", raising=False)
|
|
140
|
+
# stdin must look like a TTY to reach the interactive path
|
|
141
|
+
monkeypatch.setattr(
|
|
142
|
+
approval.sys.stdin, "isatty", lambda: True, raising=False
|
|
143
|
+
)
|
|
144
|
+
result = approval._select_option(
|
|
145
|
+
[("a", "first"), ("b", "second"), ("c", "third")]
|
|
146
|
+
)
|
|
147
|
+
assert result == "b" # ↓ moved 0→1, Enter picked it
|
|
@@ -155,3 +155,38 @@ def test_no_checkpoints_to_restore(tmp_path: Path) -> None:
|
|
|
155
155
|
restored, err = cp.restore(root)
|
|
156
156
|
assert restored is None
|
|
157
157
|
assert "no checkpoint" in err.lower()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_checkpoint_sees_same_second_same_size_edit(
|
|
161
|
+
tmp_path: Path,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Regression: the racy-git stat trap. The temp snapshot index was
|
|
164
|
+
seeded by COPYING the real index — inheriting its stat cache. An
|
|
165
|
+
edit that keeps the same SIZE and lands in the same mtime SECOND
|
|
166
|
+
as the last index write (agents write fast; CI is faster) made
|
|
167
|
+
``git add`` trust the cached hash and skip the file, so the
|
|
168
|
+
snapshot captured the OLD content and /undo restored stale data.
|
|
169
|
+
Forge the collision deterministically with os.utime."""
|
|
170
|
+
import os
|
|
171
|
+
import time
|
|
172
|
+
|
|
173
|
+
root = tmp_path
|
|
174
|
+
_git(root, "init", "-q")
|
|
175
|
+
_git(root, "config", "user.email", "t@t.co")
|
|
176
|
+
_git(root, "config", "user.name", "t")
|
|
177
|
+
(root / ".loom").mkdir()
|
|
178
|
+
a = root / "a.py"
|
|
179
|
+
past = time.time() - 10 # older than the temp index's mtime →
|
|
180
|
+
a.write_text("v1\n") # the entry is NOT "racy", so git's
|
|
181
|
+
os.utime(a, (past, past)) # racy-protection can't save us
|
|
182
|
+
_git(root, "add", "-A")
|
|
183
|
+
_git(root, "commit", "-qm", "init")
|
|
184
|
+
a.write_text("v2\n") # same 3-byte size as "v1\n"
|
|
185
|
+
os.utime(a, (past, past)) # identical stat → cache trusted
|
|
186
|
+
|
|
187
|
+
snap, err = cp.checkpoint(root, summary="racy edit")
|
|
188
|
+
assert snap is not None, err
|
|
189
|
+
in_snapshot = _git(root, "show", f"{snap.sha}:a.py")
|
|
190
|
+
assert in_snapshot == "v2\n", (
|
|
191
|
+
f"snapshot captured stale content: {in_snapshot!r}"
|
|
192
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|