loom-code 0.1.2__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.2 → loom_code-0.1.3}/PKG-INFO +2 -2
  2. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/__init__.py +1 -1
  3. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/checkpoint.py +15 -8
  4. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/repomap.py +62 -12
  5. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code.egg-info/PKG-INFO +2 -2
  6. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code.egg-info/requires.txt +1 -1
  7. {loom_code-0.1.2 → loom_code-0.1.3}/pyproject.toml +8 -3
  8. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_checkpoint.py +35 -0
  9. {loom_code-0.1.2 → loom_code-0.1.3}/LICENSE +0 -0
  10. {loom_code-0.1.2 → loom_code-0.1.3}/README.md +0 -0
  11. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/_post_commit.py +0 -0
  12. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/agent.py +0 -0
  13. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/approval.py +0 -0
  14. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/browse/__init__.py +0 -0
  15. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/browse/act.py +0 -0
  16. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/browse/observe.py +0 -0
  17. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/browse/session.py +0 -0
  18. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/browse/verify.py +0 -0
  19. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/cli.py +0 -0
  20. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/code_index.py +0 -0
  21. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/compact.py +0 -0
  22. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/consent.py +0 -0
  23. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/credentials.py +0 -0
  24. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/edit_tool.py +0 -0
  25. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/extensions.py +0 -0
  26. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/file_history.py +0 -0
  27. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/file_tools.py +0 -0
  28. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/git_hook.py +0 -0
  29. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/grep_tool.py +0 -0
  30. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/hooks.py +0 -0
  31. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/__init__.py +0 -0
  32. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/_ast_walk.py +0 -0
  33. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/_files.py +0 -0
  34. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/_graph.py +0 -0
  35. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/_resolve.py +0 -0
  36. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/_tests_map.py +0 -0
  37. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/extractor.py +0 -0
  38. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/loominit/schema.py +0 -0
  39. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/lsp_tools.py +0 -0
  40. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/mcp_host.py +0 -0
  41. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/operator.py +0 -0
  42. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/paste.py +0 -0
  43. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/paths.py +0 -0
  44. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/permissions.py +0 -0
  45. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/project.py +0 -0
  46. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/prompts.py +0 -0
  47. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/render.py +0 -0
  48. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/repl.py +0 -0
  49. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/rules.py +0 -0
  50. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/sandboxed_bash.py +0 -0
  51. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/scribe.py +0 -0
  52. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/skills/__init__.py +0 -0
  53. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/skills/graphify/SKILL.md +0 -0
  54. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/skills/graphify/tools.py +0 -0
  55. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/trust.py +0 -0
  56. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/turn.py +0 -0
  57. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/web_fetch.py +0 -0
  58. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/workers.py +0 -0
  59. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code/worktree.py +0 -0
  60. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code.egg-info/SOURCES.txt +0 -0
  61. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code.egg-info/dependency_links.txt +0 -0
  62. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code.egg-info/entry_points.txt +0 -0
  63. {loom_code-0.1.2 → loom_code-0.1.3}/loom_code.egg-info/top_level.txt +0 -0
  64. {loom_code-0.1.2 → loom_code-0.1.3}/setup.cfg +0 -0
  65. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_agent.py +0 -0
  66. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_antipoison_gate.py +0 -0
  67. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_approval.py +0 -0
  68. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_approval_danger.py +0 -0
  69. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_approval_integration.py +0 -0
  70. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_code_index.py +0 -0
  71. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_compact.py +0 -0
  72. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_credentials.py +0 -0
  73. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_edit_tool.py +0 -0
  74. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_extensions.py +0 -0
  75. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_file_boundary.py +0 -0
  76. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_file_history.py +0 -0
  77. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_git_hook.py +0 -0
  78. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_graphify_file_discovery.py +0 -0
  79. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_graphify_query_tiers.py +0 -0
  80. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_graphify_wiring.py +0 -0
  81. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_grep_tool.py +0 -0
  82. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_learned_notes.py +0 -0
  83. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_loom_hooks.py +0 -0
  84. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_lsp_tools.py +0 -0
  85. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_mcp.py +0 -0
  86. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_paste.py +0 -0
  87. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_permissions.py +0 -0
  88. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_pricing.py +0 -0
  89. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_project.py +0 -0
  90. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_prompts.py +0 -0
  91. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_render.py +0 -0
  92. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_repl_guards.py +0 -0
  93. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_resume_migration.py +0 -0
  94. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_resume_preview.py +0 -0
  95. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_routing.py +0 -0
  96. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_rules.py +0 -0
  97. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_sandboxed_bash.py +0 -0
  98. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_stream_liveness.py +0 -0
  99. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_turn_economy.py +0 -0
  100. {loom_code-0.1.2 → loom_code-0.1.3}/tests/test_web_fetch.py +0 -0
  101. {loom_code-0.1.2 → 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.2
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
@@ -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.2"
22
+ __version__ = "0.1.3"
@@ -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.2
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
@@ -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.2"
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.2"
137
+ current_version = "0.1.3"
133
138
  search = "{current_version}"
134
139
  replace = "{new_version}"
135
140
  commit = true
@@ -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