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.
Files changed (101) hide show
  1. {loom_code-0.1.1 → loom_code-0.1.3}/PKG-INFO +6 -5
  2. {loom_code-0.1.1 → loom_code-0.1.3}/README.md +4 -3
  3. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/__init__.py +1 -1
  4. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/approval.py +66 -12
  5. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/checkpoint.py +15 -8
  6. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/repomap.py +62 -12
  7. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/PKG-INFO +6 -5
  8. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/requires.txt +1 -1
  9. {loom_code-0.1.1 → loom_code-0.1.3}/pyproject.toml +8 -3
  10. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_approval.py +75 -0
  11. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_checkpoint.py +35 -0
  12. {loom_code-0.1.1 → loom_code-0.1.3}/LICENSE +0 -0
  13. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/_post_commit.py +0 -0
  14. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/agent.py +0 -0
  15. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/__init__.py +0 -0
  16. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/act.py +0 -0
  17. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/observe.py +0 -0
  18. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/session.py +0 -0
  19. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/browse/verify.py +0 -0
  20. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/cli.py +0 -0
  21. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/code_index.py +0 -0
  22. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/compact.py +0 -0
  23. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/consent.py +0 -0
  24. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/credentials.py +0 -0
  25. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/edit_tool.py +0 -0
  26. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/extensions.py +0 -0
  27. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/file_history.py +0 -0
  28. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/file_tools.py +0 -0
  29. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/git_hook.py +0 -0
  30. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/grep_tool.py +0 -0
  31. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/hooks.py +0 -0
  32. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/__init__.py +0 -0
  33. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_ast_walk.py +0 -0
  34. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_files.py +0 -0
  35. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_graph.py +0 -0
  36. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_resolve.py +0 -0
  37. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/_tests_map.py +0 -0
  38. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/extractor.py +0 -0
  39. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/loominit/schema.py +0 -0
  40. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/lsp_tools.py +0 -0
  41. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/mcp_host.py +0 -0
  42. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/operator.py +0 -0
  43. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/paste.py +0 -0
  44. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/paths.py +0 -0
  45. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/permissions.py +0 -0
  46. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/project.py +0 -0
  47. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/prompts.py +0 -0
  48. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/render.py +0 -0
  49. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/repl.py +0 -0
  50. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/rules.py +0 -0
  51. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/sandboxed_bash.py +0 -0
  52. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/scribe.py +0 -0
  53. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/skills/__init__.py +0 -0
  54. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/skills/graphify/SKILL.md +0 -0
  55. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/skills/graphify/tools.py +0 -0
  56. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/trust.py +0 -0
  57. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/turn.py +0 -0
  58. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/web_fetch.py +0 -0
  59. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/workers.py +0 -0
  60. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code/worktree.py +0 -0
  61. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/SOURCES.txt +0 -0
  62. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/dependency_links.txt +0 -0
  63. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/entry_points.txt +0 -0
  64. {loom_code-0.1.1 → loom_code-0.1.3}/loom_code.egg-info/top_level.txt +0 -0
  65. {loom_code-0.1.1 → loom_code-0.1.3}/setup.cfg +0 -0
  66. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_agent.py +0 -0
  67. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_antipoison_gate.py +0 -0
  68. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_approval_danger.py +0 -0
  69. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_approval_integration.py +0 -0
  70. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_code_index.py +0 -0
  71. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_compact.py +0 -0
  72. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_credentials.py +0 -0
  73. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_edit_tool.py +0 -0
  74. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_extensions.py +0 -0
  75. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_file_boundary.py +0 -0
  76. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_file_history.py +0 -0
  77. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_git_hook.py +0 -0
  78. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_graphify_file_discovery.py +0 -0
  79. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_graphify_query_tiers.py +0 -0
  80. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_graphify_wiring.py +0 -0
  81. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_grep_tool.py +0 -0
  82. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_learned_notes.py +0 -0
  83. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_loom_hooks.py +0 -0
  84. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_lsp_tools.py +0 -0
  85. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_mcp.py +0 -0
  86. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_paste.py +0 -0
  87. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_permissions.py +0 -0
  88. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_pricing.py +0 -0
  89. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_project.py +0 -0
  90. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_prompts.py +0 -0
  91. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_render.py +0 -0
  92. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_repl_guards.py +0 -0
  93. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_resume_migration.py +0 -0
  94. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_resume_preview.py +0 -0
  95. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_routing.py +0 -0
  96. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_rules.py +0 -0
  97. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_sandboxed_bash.py +0 -0
  98. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_stream_liveness.py +0 -0
  99. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_turn_economy.py +0 -0
  100. {loom_code-0.1.1 → loom_code-0.1.3}/tests/test_web_fetch.py +0 -0
  101. {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.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.23
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 git+https://github.com/Anurich/loomflow-cli
109
+ pipx install loom-code
110
110
  ```
111
111
 
112
- (`pip install` works too; `pipx` keeps CLI tools in their own venvs.
113
- No pipx? `brew install pipx` or `python -m pip install --user pipx`.)
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 git+https://github.com/Anurich/loomflow-cli
55
+ pipx install loom-code
56
56
  ```
57
57
 
58
- (`pip install` works too; `pipx` keeps CLI tools in their own venvs.
59
- No pipx? `brew install pipx` or `python -m pip install --user pipx`.)
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
 
@@ -19,4 +19,4 @@ belongs in the framework, not here. loom-code is the dogfood test
19
19
  that keeps loomflow honest.
20
20
  """
21
21
 
22
- __version__ = "0.1.1"
22
+ __version__ = "0.1.3"
@@ -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
- # Enter raw mode ONCE for the whole selector session — no
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
- import termios
194
- import tty
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
- old = termios.tcgetattr(fd)
199
- except Exception:
200
- old = None
201
- if old is not None:
202
- tty.setraw(fd)
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 = _read_key_raw(fd) if old is not None else _read_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
- if old is not None:
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). Seed it from the real index so unchanged staged state
138
- # is preserved; if there's no index yet, git creates one.
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
- _REPO_MAP_CACHE: dict[str, tuple[tuple[float, int], str | None]] = {}
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 p in root.rglob("*.py"):
147
- if any(part in _SKIP_DIRS for part in p.parts):
148
- continue
149
- try:
150
- newest = max(newest, p.stat().st_mtime)
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
- except OSError:
153
- continue
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.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.23
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 git+https://github.com/Anurich/loomflow-cli
109
+ pipx install loom-code
110
110
  ```
111
111
 
112
- (`pip install` works too; `pipx` keeps CLI tools in their own venvs.
113
- No pipx? `brew install pipx` or `python -m pip install --user pipx`.)
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,4 +1,4 @@
1
- loomflow[litellm,mcp,web]<0.11,>=0.10.23
1
+ loomflow[litellm,mcp,web]<0.11,>=0.10.30
2
2
  rich>=13
3
3
  prompt-toolkit>=3.0
4
4
  anthropic
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "loom-code"
3
- version = "0.1.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.23,<0.11",
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.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