hexcli 2.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hexcli/tools.py ADDED
@@ -0,0 +1,775 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.tools — the agent's leaf tools and write-scope guards, lifted out
3
+ of agent.py.
4
+
5
+ Everything the model can invoke through execute_tool_call: shell commands,
6
+ file read/edit/write, search, syntax verification, run_code, plus the
7
+ workspace snapshot and AGENTS.md loader — and the safety guards these tools
8
+ enforce (_check_write_scope / _check_sensitive_path / guard_mutation), which
9
+ own their state here (_HOME): tests that relocate the sensitive-home root
10
+ patch hexcli.tools._HOME.
11
+
12
+ The dispatcher (execute_tool_call) deliberately stays in agent.py and calls
13
+ these through agent's re-bound names, so tests that patch sa.run_command_tool
14
+ or sa.edit_file_tool keep intercepting every dispatch. The active config is
15
+ agent state; the three mutation guards read it at call time via
16
+ _active_config() below.
17
+
18
+ Split stage 3b (docs/V2X_ROADMAP.md, "The Split"). Bodies moved verbatim
19
+ apart from that lookup.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import ast
24
+ import json
25
+ import os
26
+ import queue
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import threading
32
+ import time
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from hexcli import memory, ui
37
+ from hexcli.cancel import CancelMonitor, UserCancelled
38
+ from hexcli.parsing import _RUFF, trim_text
39
+
40
+ DEFAULT_TIMEOUT_SECONDS = 300
41
+
42
+
43
+ def _active_config() -> dict[str, Any] | None:
44
+ """The config in force for the current turn, owned by hexcli.agent (the
45
+ prompt builder reads it too). Late import: agent imports this module."""
46
+ from hexcli import agent
47
+ return agent._ACTIVE_CONFIG
48
+
49
+
50
+
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Paths + safety
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def resolve_path(raw: str) -> Path:
58
+ expanded = os.path.expandvars(os.path.expanduser(raw.strip().strip('"')))
59
+ return Path(expanded).resolve()
60
+
61
+
62
+ _SENSITIVE_HOME_DIRS = frozenset({".ssh", ".gnupg", ".gpg", ".aws"})
63
+ _HOME = Path.home().resolve()
64
+
65
+ # Workspace write-scoping (docs/V2_PLAN.md §7). Reads stay unrestricted —
66
+ # the agent must be able to consult docs and libraries outside the project —
67
+ # but MUTATIONS are confined to the working directory unless explicitly
68
+ # allowed. This is the containment half of the safety story: the sensitive-
69
+ # command gate stops exfiltration, this stops collateral damage.
70
+ _ALWAYS_WRITABLE_PREFIXES = ("temp", "tmp")
71
+
72
+
73
+ def _check_write_scope(path: Path, op: str, config: dict[str, Any] | None = None) -> None:
74
+ """Deny AGENT-INITIATED mutations outside the workspace.
75
+
76
+ Scoping is a policy on what the agent may do during a turn, not a
77
+ property of the file helpers themselves. When no config is active
78
+ (config is None) the tools are being driven programmatically — by
79
+ /undo restore, checkpoint load, or a test — and the policy does not
80
+ apply. run_autopilot and the REPL both set the active config before any
81
+ tool can run, so every agent mutation IS scoped; this exemption cannot
82
+ be reached from a model-issued action.
83
+ """
84
+ if config is None:
85
+ return
86
+ cfg = config
87
+ if not cfg.get("workspace_write_scope", True):
88
+ return
89
+ try:
90
+ resolved = path.resolve()
91
+ except Exception:
92
+ resolved = path
93
+ root = Path(cwd_resolved()).resolve()
94
+ if _is_within(resolved, root):
95
+ return
96
+ # NOTE: system temp is deliberately NOT blanket-allowed. It looks harmless
97
+ # and was allowed in the first draft, but %TEMP% is a large shared area
98
+ # (other apps' state, other agents' sandboxes) and exempting it puts a
99
+ # hole through the containment guarantee for no benefit: eval sandboxes
100
+ # and run_code already run with the workspace AS cwd, so their writes are
101
+ # covered by the rule above. Anything else goes through the allow list.
102
+ for extra in cfg.get("workspace_write_allow", []) or []:
103
+ try:
104
+ if _is_within(resolved, Path(str(extra)).expanduser().resolve()):
105
+ return
106
+ except Exception:
107
+ continue
108
+ raise RuntimeError(
109
+ f"{op} is blocked: {resolved} is outside the workspace "
110
+ f"({root}). Mutations are confined to the working directory. If this "
111
+ "is intended, the user can add the path to workspace_write_allow."
112
+ )
113
+
114
+
115
+ def _is_within(path: Path, root: Path) -> bool:
116
+ try:
117
+ path.relative_to(root)
118
+ return True
119
+ except ValueError:
120
+ return False
121
+
122
+
123
+ def cwd_resolved() -> str:
124
+ """Indirection so tests can pin the workspace root."""
125
+ return str(Path.cwd())
126
+
127
+
128
+ def _check_sensitive_path(path: Path, op: str) -> None:
129
+ """Block file operations on SSH/GPG key dirs and Windows credential stores."""
130
+ try:
131
+ rel = path.relative_to(_HOME)
132
+ top = rel.parts[0].lower() if rel.parts else ""
133
+ except ValueError:
134
+ top = ""
135
+ # NOTE: these messages must never name an alternative route. Until
136
+ # 2026-07-31 they ended "Use run_command for direct access." — live uc3
137
+ # traces show the model reading that and immediately trying
138
+ # `run_command Get-Content ~/.ssh/id_rsa`. A refusal that teaches the
139
+ # bypass is worse than no refusal, because it also looks safe in review.
140
+ if top in _SENSITIVE_HOME_DIRS:
141
+ raise RuntimeError(
142
+ f"{op} is blocked for paths under ~/{rel.parts[0]} "
143
+ "(SSH/GPG keys and config). This is a hard boundary — do not "
144
+ "attempt another route. Tell the user what you wanted and why."
145
+ )
146
+ path_str = str(path).lower()
147
+ if "appdata" in path_str and any(
148
+ s in path_str for s in ("\\microsoft\\credentials", "\\microsoft\\protect")
149
+ ):
150
+ raise RuntimeError(
151
+ f"{op} is blocked for Windows credential store paths. This is a "
152
+ "hard boundary — do not attempt another route. Tell the user what "
153
+ "you wanted and why."
154
+ )
155
+
156
+
157
+ def guard_mutation(path: Path, op: str, config: dict[str, Any] | None) -> None:
158
+ """The single gate every file-mutating path must pass through.
159
+
160
+ Both checks, always, in this order. It exists because the pair kept coming
161
+ apart: protocol v2's `edit` reimplemented v1's and carried only the
162
+ sensitive-path half, so it could write anywhere on disk. Two calls that
163
+ must always appear together are a latent bug; one call is not.
164
+
165
+ Mutating tools must either call this directly or delegate to a v1 tool that
166
+ does. `evals/test_write_scope.py` drives every mutating entry point in both
167
+ protocols at an out-of-scope path and requires a refusal, so a new tool that
168
+ skips the gate fails CI rather than shipping.
169
+ """
170
+ _check_sensitive_path(path, op)
171
+ _check_write_scope(path, op, config)
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Shell + file tools
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def detect_shell(shell_hint: str) -> str:
179
+ if shell_hint:
180
+ return shell_hint
181
+ for candidate in ("pwsh.exe", "powershell.exe"):
182
+ resolved = shutil.which(candidate)
183
+ if resolved:
184
+ return resolved
185
+ return "powershell.exe"
186
+
187
+
188
+ def run_command_tool(
189
+ command: str, shell_exe: str, output_limit: int, *,
190
+ show_command: bool = True,
191
+ timeout: int = DEFAULT_TIMEOUT_SECONDS,
192
+ ) -> str:
193
+ if show_command:
194
+ ui.command_echo(command)
195
+ process = subprocess.Popen(
196
+ [shell_exe, "-NoLogo", "-NoProfile", "-Command", command],
197
+ stdout=subprocess.PIPE,
198
+ stderr=subprocess.STDOUT,
199
+ text=True, encoding="utf-8", errors="replace",
200
+ )
201
+ out_q: queue.Queue[str] = queue.Queue()
202
+
203
+ def reader() -> None:
204
+ assert process.stdout is not None
205
+ for line in iter(process.stdout.readline, ""):
206
+ out_q.put(line)
207
+ process.stdout.close()
208
+
209
+ t = threading.Thread(target=reader, daemon=True)
210
+ t.start()
211
+ def _terminate() -> None:
212
+ """Kill the command AND everything it spawned.
213
+
214
+ process.terminate() only signals the direct child (powershell.exe).
215
+ A command like `npm test` or `python -m http.server` leaves the real
216
+ work running as grandchildren — orphaned, still holding ports/files,
217
+ invisible to the user who just pressed Esc. taskkill /T walks the
218
+ whole tree; the plain kill stays as the fallback.
219
+ """
220
+ if process.poll() is None:
221
+ killed_tree = False
222
+ try:
223
+ subprocess.run(
224
+ ["taskkill", "/T", "/F", "/PID", str(process.pid)],
225
+ capture_output=True, timeout=10,
226
+ )
227
+ killed_tree = True
228
+ except Exception:
229
+ pass
230
+ try:
231
+ process.wait(timeout=3 if killed_tree else 2)
232
+ except subprocess.TimeoutExpired:
233
+ try:
234
+ process.kill()
235
+ except Exception:
236
+ pass
237
+
238
+ parts: list[str] = []
239
+ parts_chars = 0
240
+ open_row = False # the last printed output line had no newline
241
+ # Stop buffering once we have 4× the output limit (UTF-8 max 4B/char).
242
+ # Further lines are still printed to the terminal but not buffered.
243
+ _BUF_CAP = output_limit * 4
244
+ deadline = time.monotonic() + timeout
245
+ try:
246
+ with CancelMonitor() as monitor:
247
+ while t.is_alive() or not out_q.empty() or process.poll() is None:
248
+ if monitor.cancelled.is_set():
249
+ _terminate()
250
+ raise UserCancelled()
251
+ if time.monotonic() > deadline:
252
+ _terminate()
253
+ output = trim_text("".join(parts), output_limit)
254
+ return f"Exit code: TIMEOUT ({timeout}s)\n{output}".strip()
255
+ try:
256
+ line = out_q.get(timeout=0.05)
257
+ except queue.Empty:
258
+ continue
259
+ # Dim on screen: command output is evidence, not the answer.
260
+ print(f"{ui.C.DIM}{line.rstrip(chr(10))}{ui.C.RESET}", end="\n" if line.endswith("\n") else "")
261
+ open_row = not line.endswith("\n")
262
+ if parts_chars < _BUF_CAP:
263
+ parts.append(line)
264
+ parts_chars += len(line)
265
+ except KeyboardInterrupt:
266
+ _terminate()
267
+ raise UserCancelled()
268
+ process.wait()
269
+ output = "".join(parts)
270
+ if open_row:
271
+ print() # the command left its last line unterminated; the exit line gets its own row
272
+ ui.tool_event("run", f"exit {process.returncode}")
273
+ return trim_text(f"Exit code: {process.returncode}\n{output}".strip(), output_limit)
274
+
275
+
276
+ def read_file_tool(path_text: str, output_limit: int,
277
+ offset: int = 0, limit: int = 0) -> str:
278
+ """Read a file. With offset/limit (1-based line numbers), read one page —
279
+ v1.7 could only ever see the head of a large file, with no way to page."""
280
+ path = resolve_path(path_text)
281
+ _check_sensitive_path(path, "read_file")
282
+ if path.is_dir():
283
+ raise RuntimeError(
284
+ f"{path} is a directory, not a file. Use list_directory to see its contents."
285
+ )
286
+ if offset or limit:
287
+ lines = path.read_text(encoding="utf-8", errors="replace").split("\n")
288
+ total = len(lines)
289
+ start = max(1, int(offset or 1))
290
+ count = max(1, int(limit or 400))
291
+ page = lines[start - 1:start - 1 + count]
292
+ end = min(start - 1 + len(page), total)
293
+ header = f"[lines {start}-{end} of {total}]\n" if (start > 1 or end < total) else ""
294
+ ui.tool_event("read", f"{path} (lines {start}-{end} of {total})")
295
+ return header + trim_text("\n".join(page), output_limit)
296
+ # Avoid loading huge files; read at most 4× output_limit bytes (UTF-8 max 4B/char).
297
+ max_bytes = output_limit * 4
298
+ try:
299
+ size = path.stat().st_size
300
+ except OSError:
301
+ size = 0
302
+ if size > max_bytes:
303
+ with path.open("rb") as fh:
304
+ raw_bytes = fh.read(max_bytes)
305
+ # Byte reads skip universal-newline translation; normalise so CRLF
306
+ # files page on "\n" like the read_text path does.
307
+ content = raw_bytes.decode("utf-8", errors="replace").replace("\r\n", "\n")
308
+ # Count the real line total by streaming — the header must not claim
309
+ # the file ends where our memory-safe pre-read happened to stop.
310
+ total = 1
311
+ with path.open("rb") as fh:
312
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
313
+ total += chunk.count(b"\n")
314
+ else:
315
+ content = path.read_text(encoding="utf-8", errors="replace")
316
+ total = content.count("\n") + 1
317
+ if len(content) <= output_limit and size <= max_bytes:
318
+ ui.tool_event("read", str(path))
319
+ return content
320
+ # Too big for this step's budget: return the FIRST PAGE, line-aligned,
321
+ # with a header that tells the model how to page — a mid-line head cut
322
+ # with "[truncated]" taught it nothing about how much it had not seen.
323
+ page: list[str] = []
324
+ used = 0
325
+ for ln in content.split("\n"):
326
+ if used + len(ln) + 1 > output_limit:
327
+ break
328
+ page.append(ln)
329
+ used += len(ln) + 1
330
+ if not page:
331
+ # A single line bigger than the whole budget (minified/binary-ish):
332
+ # fall back to a plain head cut so the model still sees something.
333
+ ui.tool_event("read", f"{path} (first {output_limit} chars of a {total}-line file)")
334
+ return (f"[first {output_limit} chars of {total} lines; use offset/limit]\n"
335
+ + content[:output_limit])
336
+ end = len(page)
337
+ header = (f"[lines 1-{end} of {total}. The file continues: call read_file again "
338
+ f"with offset={end + 1} to read the next part.]\n")
339
+ ui.tool_event("read", f"{path} (lines 1-{end} of {total})")
340
+ return header + "\n".join(page)
341
+
342
+
343
+ def edit_file_tool(path_text: str, old_string: str, new_string: str) -> str:
344
+ """Replace old_string with new_string.
345
+
346
+ Exact match first; when that fails, fall back to the 3-tier fuzzy applier
347
+ (trailing-whitespace-insensitive, then indent-shifted) rather than erroring
348
+ out — the v1.7 audit found the model frequently mis-copies whitespace or
349
+ indentation, and a hard failure there burned whole step budgets. Ambiguity
350
+ is still an error, never a guess, and a genuine no-match now reports the
351
+ closest region with line numbers so the retry has something to work with.
352
+ """
353
+ from .protocol_v2 import apply_search_replace
354
+
355
+ path = resolve_path(path_text)
356
+ guard_mutation(path, "edit_file", _active_config())
357
+ if not old_string:
358
+ raise RuntimeError("edit_file requires a non-empty 'old_string'. Use write_file to overwrite the whole file.")
359
+ if not path.exists():
360
+ raise RuntimeError(f"File not found: {path}")
361
+ content = path.read_text(encoding="utf-8")
362
+ if content.count(old_string) == 1:
363
+ new_content = content.replace(old_string, new_string, 1)
364
+ else:
365
+ new_content, err = apply_search_replace(content, [(old_string, new_string)])
366
+ if err:
367
+ raise RuntimeError(err.replace("SEARCH block 1", "old_string"))
368
+ tmp = path.parent / (path.name + ".tmp")
369
+ tmp.write_text(new_content, encoding="utf-8")
370
+ tmp.replace(path)
371
+ delta = new_string.count("\n") - old_string.count("\n")
372
+ ui.tool_event("edit", f"{path} ({delta:+d} lines)")
373
+ return f"Edited {path}"
374
+
375
+
376
+ def write_file_tool(path_text: str, content: str) -> str:
377
+ path = resolve_path(path_text)
378
+ guard_mutation(path, "write_file", _active_config())
379
+ path.parent.mkdir(parents=True, exist_ok=True)
380
+ tmp = path.parent / (path.name + ".tmp")
381
+ tmp.write_text(content, encoding="utf-8")
382
+ tmp.replace(path)
383
+ ui.tool_event("write", f"{path} ({len(content)} chars)")
384
+ return f"Wrote {path}"
385
+
386
+
387
+ def append_file_tool(path_text: str, content: str) -> str:
388
+ path = resolve_path(path_text)
389
+ guard_mutation(path, "append_file", _active_config())
390
+ path.parent.mkdir(parents=True, exist_ok=True)
391
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
392
+ tmp = path.parent / (path.name + ".tmp")
393
+ tmp.write_text(existing + content, encoding="utf-8")
394
+ tmp.replace(path)
395
+ ui.tool_event("append", f"{path} ({len(content)} chars)")
396
+ return f"Appended to {path}"
397
+
398
+
399
+ def list_directory_tool(path_text: str, output_limit: int) -> str:
400
+ path = resolve_path(path_text or ".")
401
+ _check_sensitive_path(path, "list_directory")
402
+ if not path.exists():
403
+ raise RuntimeError(f"Directory not found: {path}")
404
+ if not path.is_dir():
405
+ raise RuntimeError(f"Not a directory: {path}")
406
+ entries = []
407
+ for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
408
+ entries.append(child.name + ("/" if child.is_dir() else ""))
409
+ result = "\n".join(entries) or "(empty)"
410
+ ui.tool_event("list", f"{path} ({len(entries)} entries)")
411
+ return trim_text(result, output_limit)
412
+
413
+
414
+ _SEARCH_EXCLUDE_DIRS = frozenset({
415
+ ".shellai", ".git", ".hg", ".svn",
416
+ "node_modules", "__pycache__", ".venv", "venv",
417
+ ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
418
+ })
419
+ _SEARCH_MAX_FILE_BYTES = 500_000 # skip files likely to be binary blobs
420
+
421
+
422
+ def search_files_tool(pattern: str, path_text: str, glob_pattern: str, output_limit: int) -> str:
423
+ search_path = resolve_path(path_text or ".")
424
+ _check_sensitive_path(search_path, "search_files")
425
+ glob_pattern = glob_pattern or "*"
426
+ results: list[str] = []
427
+ try:
428
+ compiled = re.compile(pattern)
429
+ except re.error as exc:
430
+ raise RuntimeError(f"Invalid regex: {exc}") from exc
431
+ try:
432
+ candidates = sorted(search_path.rglob(glob_pattern))
433
+ except ValueError as exc:
434
+ raise RuntimeError(f"Invalid glob pattern {glob_pattern!r}: {exc}") from exc
435
+ except (OSError, PermissionError):
436
+ candidates = []
437
+ for fp in candidates:
438
+ if not fp.is_file():
439
+ continue
440
+ # Skip hidden and data directories (e.g. .shellai/models/, .git/, node_modules/)
441
+ try:
442
+ rel_parts = fp.relative_to(search_path).parts[:-1]
443
+ except ValueError:
444
+ continue
445
+ if any(
446
+ p.lower() in _SEARCH_EXCLUDE_DIRS or (p.startswith(".") and len(p) > 1)
447
+ for p in rel_parts
448
+ ):
449
+ continue
450
+ # Skip large files (binary blobs, model weights, lock files)
451
+ try:
452
+ if fp.stat().st_size > _SEARCH_MAX_FILE_BYTES:
453
+ continue
454
+ except OSError:
455
+ continue
456
+ try:
457
+ for i, line in enumerate(fp.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
458
+ if compiled.search(line):
459
+ results.append(f"{fp}:{i}: {line}")
460
+ except (OSError, PermissionError):
461
+ pass
462
+ result = "\n".join(results) if results else f"No matches for '{pattern}'"
463
+ ui.tool_event("search", f"'{pattern}' in {search_path}/**/{glob_pattern} ({len(results)} matches)")
464
+ return trim_text(result, output_limit)
465
+
466
+
467
+ def find_files_tool(glob_pattern: str, path_text: str, output_limit: int) -> str:
468
+ search_path = resolve_path(path_text or ".")
469
+ _check_sensitive_path(search_path, "find_files")
470
+ filtered: list[Path] = []
471
+ try:
472
+ candidates = sorted(search_path.rglob(glob_pattern or "*"))
473
+ except ValueError as exc:
474
+ raise RuntimeError(f"Invalid glob pattern {glob_pattern!r}: {exc}") from exc
475
+ except (OSError, PermissionError):
476
+ candidates = []
477
+ for p in candidates:
478
+ if not p.is_file():
479
+ continue
480
+ try:
481
+ rel_parts = p.relative_to(search_path).parts[:-1]
482
+ except ValueError:
483
+ continue
484
+ if any(
485
+ part.lower() in _SEARCH_EXCLUDE_DIRS or (part.startswith(".") and len(part) > 1)
486
+ for part in rel_parts
487
+ ):
488
+ continue
489
+ filtered.append(p)
490
+ result = "\n".join(str(p) for p in filtered) if filtered else f"No files matching '{glob_pattern}'"
491
+ ui.tool_event("find", f"{glob_pattern} in {search_path} ({len(filtered)} files)")
492
+ return trim_text(result, output_limit)
493
+
494
+
495
+ _LANGUAGE_BY_EXT = {
496
+ ".py": "python", ".pyw": "python",
497
+ ".json": "json",
498
+ ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell",
499
+ ".js": "node", ".mjs": "node", ".cjs": "node",
500
+ ".ts": "node", ".tsx": "node", ".jsx": "node",
501
+ }
502
+ _VERIFY_MAX_BYTES = 500_000 # skip files too large for in-process parse
503
+
504
+
505
+ def _verify_python_syntax(path: Path) -> tuple[bool, str]:
506
+ try:
507
+ size = path.stat().st_size
508
+ except OSError:
509
+ size = 0
510
+ if size > _VERIFY_MAX_BYTES:
511
+ return True, f"OK: skipped (file too large: {size} bytes)"
512
+ source = path.read_text(encoding="utf-8", errors="replace")
513
+ try:
514
+ ast.parse(source, filename=str(path))
515
+ return True, "OK: no syntax errors"
516
+ except SyntaxError as exc:
517
+ return False, f"FAIL: line {exc.lineno}, col {exc.offset}: {exc.msg}"
518
+
519
+
520
+ def _verify_json_syntax(path: Path) -> tuple[bool, str]:
521
+ try:
522
+ size = path.stat().st_size
523
+ except OSError:
524
+ size = 0
525
+ if size > _VERIFY_MAX_BYTES:
526
+ return True, f"OK: skipped (file too large: {size} bytes)"
527
+ source = path.read_text(encoding="utf-8", errors="replace")
528
+ try:
529
+ json.loads(source)
530
+ return True, "OK: valid JSON"
531
+ except json.JSONDecodeError as exc:
532
+ return False, f"FAIL: line {exc.lineno}, col {exc.colno}: {exc.msg}"
533
+
534
+
535
+ def _verify_powershell_syntax(path: Path, shell_exe: str) -> tuple[bool, str]:
536
+ # [Parser]::ParseFile only tokenizes/parses an AST — it never invokes the script,
537
+ # so this is as non-destructive as the Python ast.parse() check above.
538
+ escaped = str(path).replace("'", "''")
539
+ script = (
540
+ f"$perr = $null; "
541
+ f"[void][System.Management.Automation.Language.Parser]::ParseFile('{escaped}', [ref]$null, [ref]$perr); "
542
+ f"if ($perr) {{ $perr | ForEach-Object {{ Write-Output $_.Message }}; exit 1 }} "
543
+ f"else {{ Write-Output 'OK' }}"
544
+ )
545
+ try:
546
+ result = subprocess.run(
547
+ [shell_exe, "-NoLogo", "-NoProfile", "-Command", script],
548
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
549
+ )
550
+ except Exception as exc:
551
+ return True, f"OK: skipped (could not run PowerShell parser: {exc})"
552
+ if result.returncode == 0:
553
+ return True, "OK: no syntax errors"
554
+ return False, f"FAIL: {result.stdout.strip() or result.stderr.strip()}"
555
+
556
+
557
+ def _verify_node_syntax(path: Path) -> tuple[bool, str]:
558
+ node = shutil.which("node")
559
+ if not node:
560
+ return True, f"OK: skipped (no checker available for {path.suffix} — node not found on PATH)"
561
+ try:
562
+ result = subprocess.run(
563
+ [node, "--check", str(path)],
564
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
565
+ )
566
+ except Exception as exc:
567
+ return True, f"OK: skipped (could not run node --check: {exc})"
568
+ if result.returncode == 0:
569
+ return True, "OK: no syntax errors"
570
+ return False, f"FAIL: {result.stderr.strip() or result.stdout.strip()}"
571
+
572
+
573
+ def verify_syntax_tool(path_text: str, language: str, shell_exe: str) -> str:
574
+ path = resolve_path(path_text)
575
+ if not path.exists():
576
+ raise RuntimeError(f"File not found: {path}")
577
+ lang = (language or "").strip().lower() or _LANGUAGE_BY_EXT.get(path.suffix.lower(), "")
578
+ if lang == "python":
579
+ ok, detail = _verify_python_syntax(path)
580
+ elif lang == "json":
581
+ ok, detail = _verify_json_syntax(path)
582
+ elif lang == "powershell":
583
+ ok, detail = _verify_powershell_syntax(path, shell_exe)
584
+ elif lang == "node" or path.suffix.lower() in {".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"}:
585
+ ok, detail = _verify_node_syntax(path)
586
+ else:
587
+ ok, detail = True, f"OK: skipped (no syntax checker for '{path.suffix or language or 'unknown'}')"
588
+ ui.tool_event("verify", f"{path} ({'pass' if ok else 'fail'})")
589
+ return detail
590
+
591
+
592
+ def lint_code_tool(path_text: str) -> str:
593
+ if not _RUFF:
594
+ raise RuntimeError("ruff is not on PATH — lint_code is unavailable.")
595
+ path = resolve_path(path_text)
596
+ if not path.exists():
597
+ raise RuntimeError(f"File not found: {path}")
598
+ try:
599
+ result = subprocess.run(
600
+ [_RUFF, "check", "--output-format=concise", str(path)],
601
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
602
+ )
603
+ except Exception as exc:
604
+ raise RuntimeError(f"ruff failed: {exc}") from exc
605
+ output = (result.stdout + result.stderr).strip()
606
+ status = "clean" if result.returncode == 0 else f"{result.returncode} issue(s)"
607
+ ui.tool_event("lint", f"{path} ({status})")
608
+ if result.returncode == 0:
609
+ return f"OK: no issues in {path}"
610
+ return output if output else f"OK: no issues in {path}"
611
+
612
+
613
+ _RUN_CODE_INTERPRETERS: dict[str, list[str]] = {
614
+ ".py": [sys.executable],
615
+ ".ps1": [], # filled in at call time with shell_exe
616
+ ".js": ["node"],
617
+ ".mjs": ["node"],
618
+ ".cjs": ["node"],
619
+ }
620
+
621
+
622
+ def run_code_tool(
623
+ path_text: str,
624
+ run_args: list[str],
625
+ timeout: int,
626
+ shell_exe: str,
627
+ output_limit: int,
628
+ ) -> str:
629
+ cwd = Path.cwd().resolve()
630
+ path = resolve_path(path_text)
631
+ if not path.exists():
632
+ raise RuntimeError(f"File not found: {path}")
633
+ if not path.is_relative_to(cwd):
634
+ raise RuntimeError(
635
+ f"run_code is restricted to files under the working directory ({cwd}). "
636
+ f"Resolved path was: {path}"
637
+ )
638
+ ext = path.suffix.lower()
639
+ if ext in {".js", ".mjs", ".cjs"} and not shutil.which("node"):
640
+ raise RuntimeError("node not found on PATH — cannot run .js/.mjs/.cjs files")
641
+ if ext == ".ps1":
642
+ cmd_prefix = [shell_exe, "-NoLogo", "-NoProfile", "-File"]
643
+ else:
644
+ cmd_prefix = _RUN_CODE_INTERPRETERS.get(ext)
645
+ if cmd_prefix is None:
646
+ raise RuntimeError(
647
+ f"Unsupported extension {ext!r} for run_code. "
648
+ "Allowed: .py .ps1 .js .mjs .cjs"
649
+ )
650
+ cmd = [*cmd_prefix, str(path), *[str(a) for a in run_args]]
651
+ try:
652
+ proc = subprocess.Popen(
653
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
654
+ text=True, encoding="utf-8", errors="replace",
655
+ )
656
+ except (PermissionError, FileNotFoundError, OSError) as exc:
657
+ raise RuntimeError(f"Failed to launch interpreter for {path.name}: {exc}") from exc
658
+ try:
659
+ stdout, stderr = proc.communicate(timeout=timeout)
660
+ except subprocess.TimeoutExpired:
661
+ proc.kill()
662
+ stdout, stderr = proc.communicate()
663
+ out = trim_text(stdout, output_limit)
664
+ err = trim_text(stderr, output_limit)
665
+ ui.tool_event("run", f"{path} (TIMEOUT after {timeout}s)")
666
+ return (
667
+ f"Exit code: TIMEOUT ({timeout}s exceeded)\n\n"
668
+ f"[stdout]\n{out or '(empty)'}\n\n"
669
+ f"[stderr]\n{err or '(empty)'}"
670
+ )
671
+ out = trim_text(stdout, output_limit)
672
+ err = trim_text(stderr, output_limit)
673
+ ui.tool_event("run", f"{path} (exit {proc.returncode})")
674
+ return (
675
+ f"Exit code: {proc.returncode}\n\n"
676
+ f"[stdout]\n{out or '(empty)'}\n\n"
677
+ f"[stderr]\n{err or '(empty)'}"
678
+ )
679
+
680
+
681
+ def workspace_snapshot(cwd: str) -> str:
682
+ """Return a compact ≤150-token workspace context line prepended to each agent turn."""
683
+ p = Path(cwd)
684
+ parts: list[str] = []
685
+
686
+ # Project type detection via marker files
687
+ proj = "dir"
688
+ if (p / "pyproject.toml").exists() or (p / "setup.py").exists() or (p / "requirements.txt").exists():
689
+ proj = "python"
690
+ elif (p / "package.json").exists():
691
+ proj = "node"
692
+ elif (p / "Cargo.toml").exists():
693
+ proj = "rust"
694
+ elif (p / "go.mod").exists():
695
+ proj = "go"
696
+ elif list(p.glob("*.sln")) or list(p.glob("*.csproj")):
697
+ proj = "csharp"
698
+ parts.append(f"workspace:{proj}")
699
+
700
+ # Git branch + dirty flag (0.5 s timeout — fast enough, safe on slow NTFS)
701
+ try:
702
+ br = subprocess.run(
703
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
704
+ cwd=cwd, capture_output=True, text=True, timeout=0.5,
705
+ )
706
+ if br.returncode == 0:
707
+ branch = br.stdout.strip()
708
+ # --porcelain detects staged, unstaged, and untracked changes in one call;
709
+ # git diff --quiet only detects unstaged changes (misses staged commits).
710
+ status = subprocess.run(
711
+ ["git", "status", "--porcelain"],
712
+ cwd=cwd, capture_output=True, text=True, timeout=0.5,
713
+ )
714
+ dirty = bool(status.stdout.strip())
715
+ parts.append(f"git:{branch}{'*' if dirty else ''}")
716
+ except Exception:
717
+ pass
718
+
719
+ # Primary entry point
720
+ for name in ("shellai.py", "main.py", "app.py", "index.js", "main.rs", "main.go", "main.cs"):
721
+ if (p / name).exists():
722
+ parts.append(f"entry:{name}")
723
+ break
724
+
725
+ # Test directory
726
+ for tdir in ("tests", "test", "evals", "spec"):
727
+ if (p / tdir).is_dir():
728
+ parts.append(f"tests:{tdir}/")
729
+ break
730
+
731
+ tag_line = "[" + " | ".join(parts) + "]"
732
+ sections = [tag_line]
733
+
734
+ project = read_project_instructions(p)
735
+ if project:
736
+ sections.append("Project instructions:\n" + project)
737
+
738
+ rules = memory.read_memory_rules(5)
739
+ if rules:
740
+ sections.append("Prior knowledge:\n" + "\n".join(f" {r}" for r in rules))
741
+ return "\n".join(sections)
742
+
743
+
744
+ # Per-project instructions, in precedence order. AGENTS.md is the cross-tool
745
+ # convention; the .shellai/ variant lets you keep it out of the repo.
746
+ _PROJECT_INSTRUCTION_FILES = ("AGENTS.md", ".shellai/AGENTS.md", "HEXCLI.md")
747
+ _PROJECT_INSTRUCTIONS_MAX_CHARS = 1200 # ~300 tokens — see docs/V2_PLAN.md §6.2
748
+
749
+
750
+ def read_project_instructions(cwd: Path, max_chars: int = _PROJECT_INSTRUCTIONS_MAX_CHARS) -> str:
751
+ """Read the project's agent instructions, hard-capped.
752
+
753
+ Every character here is prompt tokens on EVERY turn, against a measured
754
+ ~2,600-token degradation cliff — so the cap is deliberate and the
755
+ truncation is loud rather than silent, otherwise a long AGENTS.md would
756
+ quietly push the model over the edge and look like a model regression.
757
+ """
758
+ for name in _PROJECT_INSTRUCTION_FILES:
759
+ path = cwd / name
760
+ try:
761
+ if not path.is_file():
762
+ continue
763
+ text = path.read_text(encoding="utf-8", errors="replace").strip()
764
+ except OSError:
765
+ continue
766
+ if not text:
767
+ continue
768
+ # Drop comment-only and heading-only noise to spend the budget on rules.
769
+ lines = [ln.rstrip() for ln in text.splitlines()]
770
+ body = "\n".join(ln for ln in lines if ln.strip())
771
+ if len(body) > max_chars:
772
+ body = body[:max_chars].rsplit("\n", 1)[0]
773
+ body += f"\n […{name} truncated to {max_chars} chars to protect the context budget]"
774
+ return body
775
+ return ""