localcode 0.4.0__py3-none-macosx_13_0_arm64.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 (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. localcode-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,261 @@
1
+ """read_file — read a file with optional line offset/limit."""
2
+ from __future__ import annotations
3
+
4
+ from .base import ToolContext
5
+
6
+
7
+ DEFAULT_LIMIT = 240
8
+ MAX_DEFAULT_CHARS = 12_000
9
+
10
+
11
+ def _dynamic_read_defaults(ctx: ToolContext) -> tuple[int, int]:
12
+ """(default_line_limit, max_default_chars), scaled to the model's real
13
+ context window. read_file is the primary code-ingestion path: on a 256K
14
+ window the fixed 240-line / 12K-char default forces needless pagination and
15
+ re-reads where the machine has the MOST room. Scale up to ~6% of the window
16
+ in chars (and lines proportionally), floored at the static defaults so a
17
+ 16 GB Mac (small window) is byte-identical to before.
18
+ """
19
+ try:
20
+ ctx_tokens = int(ctx.app.engine._target_num_ctx())
21
+ except Exception:
22
+ ctx_tokens = 0
23
+ if not ctx_tokens:
24
+ return DEFAULT_LIMIT, MAX_DEFAULT_CHARS
25
+ max_chars = max(MAX_DEFAULT_CHARS, int(ctx_tokens * 3.5 * 0.06))
26
+ # Keep the historical ~50 chars/line ratio between the two defaults.
27
+ line_limit = max(DEFAULT_LIMIT, max_chars // 50)
28
+ return line_limit, max_chars
29
+
30
+ SCHEMA = {
31
+ "type": "function",
32
+ "function": {
33
+ "name": "read_file",
34
+ "description": "Read a file. You MUST read before editing. Returns content with line numbers.",
35
+ "parameters": {
36
+ "type": "object",
37
+ "properties": {
38
+ "path": {"type": "string", "description": "File path relative to repo root"},
39
+ "offset": {"type": "integer", "description": "Start line (0-based). Optional."},
40
+ "limit": {
41
+ "type": "integer",
42
+ "description": (
43
+ "Max lines. Default 240. Use a small targeted range "
44
+ "for large files; only request a larger limit when the "
45
+ "whole file is genuinely needed."
46
+ ),
47
+ },
48
+ },
49
+ "required": ["path"],
50
+ },
51
+ },
52
+ }
53
+
54
+
55
+ # Fuzzy-match ratio at/above which we AUTO-READ the real file instead of
56
+ # merely suggesting it. 0.85 is high enough that only genuine typos /
57
+ # case-variants of one real name match (e.g. a dropped letter or wrong
58
+ # case), not two distinct files that happen to be somewhat similar.
59
+ AUTO_READ_RATIO = 0.85
60
+
61
+
62
+ def _list_dir_candidates(missing):
63
+ """(candidates, base) — every file under the nearest existing ancestor of
64
+ `missing`. Walk up to 6 levels so a wrong path segment deep in the tree
65
+ still resolves to a searchable directory. Capped at 4000 files."""
66
+ from pathlib import Path
67
+ missing = Path(missing)
68
+ base = missing.parent
69
+ for _ in range(6):
70
+ if base.exists():
71
+ break
72
+ base = base.parent
73
+ if not base.exists():
74
+ return [], None
75
+ candidates = []
76
+ for p in base.rglob("*"):
77
+ if p.is_file():
78
+ candidates.append(p)
79
+ if len(candidates) > 4000:
80
+ break
81
+ return candidates, base
82
+
83
+
84
+ def _confident_match(missing):
85
+ """Return the ONE real file `missing` is almost certainly a typo of, else
86
+ None. Authoritative (used to auto-read), so it only fires when unambiguous:
87
+
88
+ • exactly one file whose basename equals `missing`'s case-insensitively
89
+ (a pure case/typo of a unique name — gitHub→Github, README.MD→readme.md), OR
90
+ • exactly one file whose basename fuzzy-matches at ratio ≥ AUTO_READ_RATIO.
91
+
92
+ Requiring the winner to be UNIQUE prevents auto-reading the wrong one of
93
+ several same-named files in different dirs — that stays a soft suggestion.
94
+ """
95
+ import difflib
96
+ from pathlib import Path
97
+ try:
98
+ candidates, _ = _list_dir_candidates(missing)
99
+ if not candidates:
100
+ return None
101
+ name = Path(missing).name
102
+ # Pure case/typo of a unique basename → definitely that file.
103
+ same = [p for p in candidates if p.name.lower() == name.lower()]
104
+ if len(same) == 1:
105
+ return same[0]
106
+ if len(same) > 1:
107
+ return None # ambiguous by name — don't auto-pick.
108
+ # High-confidence fuzzy basename match, and it must be unique.
109
+ names = [p.name for p in candidates]
110
+ close = difflib.get_close_matches(name, names, n=1, cutoff=AUTO_READ_RATIO)
111
+ if close:
112
+ hits = [p for p in candidates if p.name == close[0]]
113
+ if len(hits) == 1:
114
+ return hits[0]
115
+ except Exception:
116
+ pass
117
+ return None
118
+
119
+
120
+ def _suggest_path(missing) -> str:
121
+ """Suggest the real file when the model gave a typo'd/wrong-case path.
122
+
123
+ Walk up to the nearest existing ancestor dir and fuzzy-match the missing
124
+ filename (and its path segments) against what's actually there. Lower-
125
+ confidence sibling of `_confident_match`: used only when we're NOT sure
126
+ enough to auto-read, so it stays a hint the model can act on.
127
+ """
128
+ import difflib
129
+ from pathlib import Path
130
+ try:
131
+ missing = Path(missing)
132
+ name = missing.name
133
+ candidates, base = _list_dir_candidates(missing)
134
+ if not candidates:
135
+ return ""
136
+ names = [p.name for p in candidates]
137
+ close = difflib.get_close_matches(name, names, n=1, cutoff=0.6)
138
+ if close:
139
+ match = next(p for p in candidates if p.name == close[0])
140
+ return f"Did you mean: {match} — read THAT exact path (don't guess variants)."
141
+ except Exception:
142
+ pass
143
+ return ""
144
+
145
+
146
+ def execute(ctx: ToolContext, args: dict) -> str:
147
+ if "path" not in args:
148
+ return "Error: 'path' argument is required for read_file."
149
+ requested = args["path"]
150
+ path = ctx.resolve_path(requested)
151
+ note = ""
152
+ source_disp = str(requested)
153
+ if not path.exists():
154
+ # A small model invents misspelled/wrong-case paths (a dropped letter,
155
+ # gitHub/github/Github) and, getting a bare "not found", retries with
156
+ # ANOTHER wrong variant forever (dedup can't collapse differing typos).
157
+ real = _confident_match(path)
158
+ if real is None:
159
+ # Not confident enough to auto-read — hand back the best soft hint.
160
+ hint = _suggest_path(path)
161
+ return f"File not found: {requested}" + (f"\n{hint}" if hint else "")
162
+ # AUTHORITATIVE recovery: `requested` is unambiguously a typo/case-
163
+ # variant of exactly one real file. Read THAT file now instead of
164
+ # bouncing the model back with a suggestion it can ignore (which is
165
+ # what fuels the 20+-retry not-found death loop). The note pins the
166
+ # correct path so the next call uses it directly.
167
+ try:
168
+ source_disp = str(real.relative_to(ctx.repo))
169
+ except Exception:
170
+ source_disp = str(real)
171
+ note = (
172
+ f"[read {source_disp} instead of '{requested}' — that path does not "
173
+ f"exist and this is the only close match. Use '{source_disp}' "
174
+ "exactly from now on; do NOT guess more spellings.]\n\n"
175
+ )
176
+ path = real
177
+ if path.is_dir():
178
+ # The model routinely read_file's a directory (confusing it with
179
+ # list_files), gets IsADirectoryError, and burns a whole round before
180
+ # retrying with list_files. Just hand back the listing + a nudge so the
181
+ # round is productive instead of wasted.
182
+ try:
183
+ entries = sorted(
184
+ p.name + ("/" if p.is_dir() else "") for p in path.iterdir()
185
+ )
186
+ except Exception as e:
187
+ return f"Error: '{source_disp}' is a directory and could not be listed: {e}"
188
+ shown = entries[:200]
189
+ more = f"\n … ({len(entries) - len(shown)} more)" if len(entries) > len(shown) else ""
190
+ body = "\n".join(f" {e}" for e in shown) or " (empty)"
191
+ return (
192
+ f"'{source_disp}' is a directory, not a file ({len(entries)} entries). "
193
+ f"read_file needs a FILE path; use list_files for directories. Contents:\n"
194
+ f"{body}{more}"
195
+ )
196
+ # Binary guard: reading an image / .gguf / compiled artifact would dump raw
197
+ # bytes into the model's context (garbage that wastes tokens and can derail
198
+ # it). Detect via a NUL byte in the head — the standard text/binary
199
+ # heuristic — reading only the first 8 KB so a multi-GB file stays cheap.
200
+ try:
201
+ with open(path, "rb") as _fh:
202
+ _head = _fh.read(8192)
203
+ if b"\x00" in _head:
204
+ _sz = path.stat().st_size
205
+ return (
206
+ f"'{source_disp}' is a BINARY file ({_sz} bytes) — not shown, "
207
+ f"because reading it would dump raw bytes into context. It is not "
208
+ f"a text file; open it with the appropriate tool if you need it."
209
+ )
210
+ except Exception:
211
+ pass
212
+ content = path.read_text(errors="replace")
213
+ lines = content.splitlines()
214
+ default_limit, max_default_chars = _dynamic_read_defaults(ctx)
215
+ offset = args.get("offset", 0)
216
+ explicit_limit = "limit" in args
217
+ limit = args.get("limit", default_limit)
218
+ # A read is "full" (satisfies the read-before-edit guard) only when it
219
+ # starts at the top AND returns every line untruncated. offset>0 or any
220
+ # truncation below flips this to a partial view.
221
+ _full_read = (not offset) and (len(lines) <= offset + limit)
222
+ selected = lines[offset:offset + limit]
223
+ numbered = [f"{i + offset + 1}\t{line}" for i, line in enumerate(selected)]
224
+ result = "\n".join(numbered)
225
+ if len(lines) > offset + limit:
226
+ result += f"\n\n[{len(lines) - offset - limit} more lines — use offset={offset + limit} to continue]"
227
+ if not explicit_limit and len(result) > max_default_chars:
228
+ kept: list[str] = []
229
+ total = 0
230
+ for line in numbered:
231
+ total += len(line) + 1
232
+ if total > max_default_chars:
233
+ break
234
+ kept.append(line)
235
+ result = "\n".join(kept)
236
+ remaining_from_line = offset + len(kept)
237
+ result += (
238
+ f"\n\n[Large file summarized at {max_default_chars} chars. "
239
+ f"File has {len(lines)} lines; continue with "
240
+ f"offset={remaining_from_line}, limit={default_limit}, or request "
241
+ "a focused smaller range around the symbol you need.]"
242
+ )
243
+ _full_read = False # char-truncated: the model has not seen the whole file
244
+ # Record this read for the read-before-edit staleness guard (edit_file /
245
+ # write_file / multi_edit consult it). Best-effort; never fail a read.
246
+ try:
247
+ from . import read_state
248
+ read_state.record_read(ctx.app, path, full=_full_read)
249
+ except Exception:
250
+ pass
251
+ # Prompt-injection defence: wrap untrusted file content in explicit
252
+ # data/instruction separator markers so the model knows this text
253
+ # is DATA, not commands. Signature detector flags common injection
254
+ # phrases (IGNORE ALL PRIOR INSTRUCTIONS, etc.) with a visible
255
+ # warning before the content. See src/localcode/injection_defense.py.
256
+ from ..injection_defense import wrap_untrusted
257
+ return note + wrap_untrusted(result, source=source_disp)
258
+
259
+
260
+ def is_concurrency_safe(args: dict) -> bool:
261
+ return True
@@ -0,0 +1,223 @@
1
+ """Per-session read-state registry — the read-before-edit staleness guard.
2
+
3
+ Ports claude-code's `FileEditTool.readFileState` pattern (a map of
4
+ absolute-path -> {content-hash, mtime, full/partial}) and folds in the
5
+ compare-on-write idea from codex/opencode V2 (`writeIfUnchanged`).
6
+
7
+ Why a guard at all
8
+ ------------------
9
+ A surgical edit (`edit_file`/`multi_edit`) only makes sense against the
10
+ CURRENT bytes of a file. If the model never read the file, or read it and
11
+ the file has since changed on disk (its own earlier write, a formatter, an
12
+ AV/cloud-sync touch, another process), then `old_string` is being matched
13
+ against text the model has NOT seen — the classic silent-clobber failure.
14
+ claude-code refuses such edits until the file is (re)read.
15
+
16
+ Two subtleties copied from claude-code
17
+ --------------------------------------
18
+ * A PARTIAL read (offset/limit or a truncated large-file read) does NOT
19
+ satisfy the guard — the model hasn't seen the whole file, so its anchor
20
+ may sit in an unseen region.
21
+ * CONTENT-EQUALITY fallback: if the mtime advanced but the bytes are
22
+ byte-identical to what was read, we do NOT false-reject (a formatter or
23
+ AV touch that rewrites the file with the same content bumps mtime).
24
+
25
+ Session scoping
26
+ ---------------
27
+ State lives on the app object (`app._edit_read_state`), so it is naturally
28
+ scoped to one agent session and a fresh app (e.g. a unit test that calls
29
+ `edit_file` directly) starts empty. The guard only ENFORCES once the
30
+ session has recorded at least one read (`is_armed`): with no read on record
31
+ there is no freshness baseline, so enforcing would be pure friction. In a
32
+ real session `read_file` fires constantly, arming the guard immediately.
33
+
34
+ After a successful write/edit the tool calls `record_write`, so the model's
35
+ own writes refresh the baseline and never trip the guard on the next round.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ _ATTR = "_edit_read_state"
44
+
45
+ # Files larger than this are not hashed (staleness still works off mtime;
46
+ # only the content-equality fallback is skipped for very large files).
47
+ _MAX_HASH_BYTES = 4 * 1024 * 1024
48
+
49
+
50
+ def _registry(app: Any) -> dict[str, dict]:
51
+ reg = getattr(app, _ATTR, None)
52
+ if not isinstance(reg, dict):
53
+ reg = {}
54
+ try:
55
+ setattr(app, _ATTR, reg)
56
+ except Exception:
57
+ # Slotted/frozen app: fall back to a module-level per-id map so
58
+ # the guard still functions instead of silently disabling.
59
+ reg = _fallback_registry(app)
60
+ return reg
61
+
62
+
63
+ _FALLBACK: dict[int, dict] = {}
64
+
65
+
66
+ def _fallback_registry(app: Any) -> dict[str, dict]:
67
+ return _FALLBACK.setdefault(id(app), {})
68
+
69
+
70
+ def _key(path: Path) -> str:
71
+ try:
72
+ return str(path.resolve())
73
+ except Exception:
74
+ return str(path)
75
+
76
+
77
+ def _hash_bytes(data: bytes) -> str:
78
+ return hashlib.sha256(data).hexdigest()
79
+
80
+
81
+ def _file_meta(path: Path) -> tuple[float, int, str | None]:
82
+ st = path.stat()
83
+ sha: str | None = None
84
+ if st.st_size <= _MAX_HASH_BYTES:
85
+ try:
86
+ sha = _hash_bytes(path.read_bytes())
87
+ except OSError:
88
+ sha = None
89
+ return st.st_mtime, st.st_size, sha
90
+
91
+
92
+ def record_read(app: Any, path: Path, *, full: bool) -> None:
93
+ """Record that `path` was read this session.
94
+
95
+ `full` must be False for a partial read (offset/limit or a truncated
96
+ large-file view) — a partial read must NOT satisfy the guard.
97
+ """
98
+ try:
99
+ mtime, size, sha = _file_meta(path)
100
+ except OSError:
101
+ return
102
+ _registry(app)[_key(path)] = {
103
+ "mtime": mtime,
104
+ "size": size,
105
+ "sha": sha,
106
+ "full": bool(full),
107
+ }
108
+
109
+
110
+ def record_write(app: Any, path: Path, content: str) -> None:
111
+ """Refresh read-state after a successful write/edit (claude-code write-back)
112
+ so the model's own writes never trip the guard next round."""
113
+ try:
114
+ mtime, size, _ = _file_meta(path)
115
+ except OSError:
116
+ mtime, size = 0.0, len(content.encode("utf-8", "replace"))
117
+ _registry(app)[_key(path)] = {
118
+ "mtime": mtime,
119
+ "size": size,
120
+ "sha": _hash_bytes(content.encode("utf-8", "replace")),
121
+ "full": True,
122
+ }
123
+
124
+
125
+ def is_armed(app: Any) -> bool:
126
+ """True once the session has ≥1 read on record. The guard only enforces
127
+ when armed (see module docstring)."""
128
+ reg = getattr(app, _ATTR, None)
129
+ if isinstance(reg, dict) and reg:
130
+ return True
131
+ return bool(_FALLBACK.get(id(app)))
132
+
133
+
134
+ def check(app: Any, path: Path) -> str | None:
135
+ """Return None if editing `path` is allowed, else a reason code:
136
+
137
+ * 'unread' — never read this session
138
+ * 'partial' — only a partial (offset/limit/truncated) read on record
139
+ * 'stale' — file changed on disk since it was read
140
+
141
+ Only meaningful when `is_armed(app)` is True.
142
+ """
143
+ entry = _registry(app).get(_key(path))
144
+ if entry is None:
145
+ return "unread"
146
+ if not entry.get("full"):
147
+ return "partial"
148
+ try:
149
+ st = path.stat()
150
+ except OSError:
151
+ # File vanished — let the tool's own not-found handling deal with it.
152
+ return None
153
+ # Fresh: mtime has not advanced past the recorded read.
154
+ if st.st_mtime <= entry["mtime"] + 1e-6:
155
+ return None
156
+ # mtime advanced — content-equality fallback (formatter / AV / cloud-sync
157
+ # touch that changes mtime but not the bytes). Only applies to full reads,
158
+ # which are the only ones with a stored hash.
159
+ sha = entry.get("sha")
160
+ if sha is not None:
161
+ try:
162
+ if _hash_bytes(path.read_bytes()) == sha:
163
+ # Same bytes — refresh mtime so we don't re-hash every round.
164
+ entry["mtime"] = st.st_mtime
165
+ entry["size"] = st.st_size
166
+ return None
167
+ except OSError:
168
+ return None
169
+ return "stale"
170
+
171
+
172
+ def guard_edit(app: Any, path: Path, display_path: str) -> str | None:
173
+ """Read-before-edit guard for surgical edits (edit_file / multi_edit).
174
+
175
+ Returns an actionable error string to hand back to the model, or None to
176
+ allow the edit. No-op unless the session is armed.
177
+ """
178
+ if not is_armed(app):
179
+ return None
180
+ reason = check(app, path)
181
+ if reason == "unread":
182
+ return (
183
+ f"REJECTED: read {display_path} before editing it. You are editing a "
184
+ f"file you have not read this session, so `old_string` is being "
185
+ f"matched against text you have not seen. Call read_file "
186
+ f"path={display_path!r} first, then edit with an exact anchor."
187
+ )
188
+ if reason == "partial":
189
+ return (
190
+ f"REJECTED: you only read PART of {display_path} (offset/limit or a "
191
+ f"truncated view). Read the whole file — or at least the full "
192
+ f"region around your edit — before editing, so `old_string` "
193
+ f"matches the current text. Call read_file path={display_path!r}."
194
+ )
195
+ if reason == "stale":
196
+ return (
197
+ f"REJECTED: {display_path} changed on disk since you read it (an edit "
198
+ f"elsewhere, a formatter, or another process). Your `old_string` "
199
+ f"may no longer match. Re-read it (read_file path={display_path!r}) "
200
+ f"to get the current text, then edit again."
201
+ )
202
+ return None
203
+
204
+
205
+ def guard_overwrite(app: Any, path: Path, display_path: str, existed: bool) -> str | None:
206
+ """Staleness guard for write_file overwrites.
207
+
208
+ write_file is the create-or-full-rewrite tool, so — unlike edit_file — it
209
+ does NOT require a prior read (a fresh scaffold/rewrite is legitimate). But
210
+ if the file WAS read this session and has since changed on disk, a blind
211
+ overwrite would clobber those changes; refuse it. Returns an error string
212
+ or None.
213
+ """
214
+ if not existed or not is_armed(app):
215
+ return None
216
+ if check(app, path) == "stale":
217
+ return (
218
+ f"REJECTED: {display_path} changed on disk since you last read it. "
219
+ f"Overwriting now would silently clobber those changes. Re-read it "
220
+ f"(read_file path={display_path!r}) to see the current contents, "
221
+ f"then rewrite intentionally."
222
+ )
223
+ return None
@@ -0,0 +1,41 @@
1
+ """skill — model-invoked recipe loader (Voyager / agent / minimal-agent pattern)."""
2
+ from __future__ import annotations
3
+
4
+ from .base import ToolContext
5
+
6
+ SCHEMA = {
7
+ "type": "function",
8
+ "function": {
9
+ "name": "skill",
10
+ "description": (
11
+ "Load a named recipe's instructions into the conversation. "
12
+ "Use when the user's request matches a skill listed under "
13
+ "'Available skills' in your system prompt. The skill body "
14
+ "returns as the tool result; your next turn should follow it."
15
+ ),
16
+ "parameters": {
17
+ "type": "object",
18
+ "properties": {
19
+ "name": {"type": "string", "description": "Skill name, e.g. 'locate' or 'run-tests'."},
20
+ },
21
+ "required": ["name"],
22
+ },
23
+ },
24
+ }
25
+
26
+
27
+ def execute(ctx: ToolContext, args: dict) -> str:
28
+ """Load a named skill's body and return it as tool-result text. The
29
+ next agent turn will have the instructions in-context and should
30
+ act on them. Mirrors agent's SkillTool inline-context execution —
31
+ no sub-agent fork.
32
+ """
33
+ from ..skills import load_registry, invoke_skill
34
+ name = (args.get("name") or "").strip()
35
+ if not name:
36
+ return (
37
+ "Error: skill tool requires `name`. Check the 'Available skills' "
38
+ "block in your system prompt for valid names."
39
+ )
40
+ registry = load_registry(ctx.app.repo_root)
41
+ return invoke_skill(registry, name)