sid-harness 0.3.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.
Files changed (45) hide show
  1. sid_harness/__init__.py +13 -0
  2. sid_harness/__main__.py +4 -0
  3. sid_harness/_native.py +1639 -0
  4. sid_harness/brave_proof.py +246 -0
  5. sid_harness/bridge.py +851 -0
  6. sid_harness/cli.py +937 -0
  7. sid_harness/client.py +161 -0
  8. sid_harness/companion.py +151 -0
  9. sid_harness/config.py +68 -0
  10. sid_harness/context/__init__.py +40 -0
  11. sid_harness/context/index.py +267 -0
  12. sid_harness/context/retrieval.py +74 -0
  13. sid_harness/editor.py +231 -0
  14. sid_harness/gateway.py +106 -0
  15. sid_harness/git_write.py +598 -0
  16. sid_harness/local_workspace.py +421 -0
  17. sid_harness/loop.py +394 -0
  18. sid_harness/lsp.py +598 -0
  19. sid_harness/policy/__init__.py +44 -0
  20. sid_harness/policy/repo_policy.py +626 -0
  21. sid_harness/receipt.py +142 -0
  22. sid_harness/runner.py +468 -0
  23. sid_harness/selftest.py +149 -0
  24. sid_harness/session/__init__.py +33 -0
  25. sid_harness/session/bounds.py +140 -0
  26. sid_harness/session/diff_gate.py +93 -0
  27. sid_harness/session/session.py +427 -0
  28. sid_harness/session/store.py +88 -0
  29. sid_harness/tools/__init__.py +52 -0
  30. sid_harness/tools/mcp_client.py +285 -0
  31. sid_harness/tools/plan.py +138 -0
  32. sid_harness/tools/registry.py +159 -0
  33. sid_harness/tools/skills.py +185 -0
  34. sid_harness/tools/subagents.py +112 -0
  35. sid_harness/tui.py +96 -0
  36. sid_harness/workspace.py +107 -0
  37. sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
  38. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
  39. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
  40. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
  41. sid_harness-0.3.0.dist-info/METADATA +229 -0
  42. sid_harness-0.3.0.dist-info/RECORD +45 -0
  43. sid_harness-0.3.0.dist-info/WHEEL +5 -0
  44. sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
  45. sid_harness-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,421 @@
1
+ """Local workspace driver (SUB-54): the loop edits the user's REAL working tree.
2
+
3
+ SUB-53 (``workspace.py``) loads a read-only, bounded snapshot of ``--dir`` and
4
+ never writes back. This driver adds what SUB-54 requires:
5
+
6
+ - real file read/write against the user's tree (no sandbox, no copy);
7
+ - real ``git`` status/diff — read-only, never commit, never push;
8
+ - worktree awareness (linked worktree vs main checkout);
9
+ - a ``.gitignore`` filter plus the secret / VCS / dependency denylist.
10
+
11
+ The denylist IS the shared ``native_agent._safe_path`` — the harness does not
12
+ carry a second safety model, and the selection walk stays in ``workspace.py``.
13
+ By default the harness never commits and never pushes. The only write path is
14
+ the explicit opt-in ``git_write`` module (SUB-95), which refuses main/master,
15
+ never forces, and gates every step behind its own approval.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+ import subprocess
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from .gateway import native_agent
27
+ from .workspace import (
28
+ DEFAULT_MAX_FILE_BYTES,
29
+ DEFAULT_MAX_FILES,
30
+ DEFAULT_MAX_WORKSPACE_BYTES,
31
+ WorkspaceError,
32
+ WorkspaceSelection,
33
+ load_workspace,
34
+ )
35
+
36
+ _GIT_TIMEOUT = 30.0
37
+
38
+
39
+ class LocalWorkspaceError(RuntimeError):
40
+ """A local workspace action was refused or could not complete."""
41
+
42
+
43
+ def safe_rel_path(raw: object) -> str:
44
+ """Gate a caller path through the SHARED ``native_agent._safe_path``.
45
+
46
+ One safety model: secrets, private keys, VCS, and dependency directories are
47
+ refused here exactly as the server-side loop refuses them.
48
+ """
49
+ try:
50
+ return native_agent._safe_path(raw)
51
+ except native_agent.NativeAgentError as exc:
52
+ raise LocalWorkspaceError(str(exc)) from exc
53
+
54
+
55
+ # --------------------------------------------------------------------------- #
56
+ # .gitignore — a self-contained subset (no git subprocess, so it is testable
57
+ # and deterministic). Covers comments, blank lines, ``!`` negation, trailing
58
+ # ``/`` directory-only, leading ``/`` anchoring, and ``*`` / ``?`` / ``**``.
59
+ # Nested .gitignore files are a documented v1 boundary: only the root file is
60
+ # read.
61
+ # --------------------------------------------------------------------------- #
62
+ def _glob_to_regex(pattern: str) -> re.Pattern[str]:
63
+ out = ["^"]
64
+ index = 0
65
+ length = len(pattern)
66
+ while index < length:
67
+ if pattern[index : index + 2] == "**":
68
+ if pattern[index + 2 : index + 3] == "/":
69
+ out.append("(?:.*/)?")
70
+ index += 3
71
+ else:
72
+ out.append(".*")
73
+ index += 2
74
+ elif pattern[index] == "*":
75
+ out.append("[^/]*")
76
+ index += 1
77
+ elif pattern[index] == "?":
78
+ out.append("[^/]")
79
+ index += 1
80
+ else:
81
+ out.append(re.escape(pattern[index]))
82
+ index += 1
83
+ out.append("$")
84
+ return re.compile("".join(out))
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class _IgnoreRule:
89
+ anchored: bool
90
+ dir_only: bool
91
+ negate: bool
92
+ regex: re.Pattern[str]
93
+
94
+ def matches(self, prefixes: list[str]) -> bool:
95
+ # prefixes are every ancestor path plus the path itself; a dir-only rule
96
+ # may not match the file itself, only a directory prefix.
97
+ targets = prefixes[:-1] if self.dir_only else prefixes
98
+ for target in targets:
99
+ subject = target if self.anchored else target.rsplit("/", 1)[-1]
100
+ if self.regex.match(subject):
101
+ return True
102
+ return False
103
+
104
+
105
+ def _parse_rule(line: str) -> _IgnoreRule | None:
106
+ stripped = line.rstrip("\n").rstrip()
107
+ if not stripped or stripped.lstrip().startswith("#"):
108
+ return None
109
+ negate = stripped.startswith("!")
110
+ body = stripped[1:] if negate else stripped
111
+ if not body:
112
+ return None
113
+ dir_only = body.endswith("/")
114
+ core = body.rstrip("/")
115
+ anchored = core.startswith("/") or "/" in core
116
+ core = core.lstrip("/")
117
+ if not core:
118
+ return None
119
+ return _IgnoreRule(
120
+ anchored=anchored,
121
+ dir_only=dir_only,
122
+ negate=negate,
123
+ regex=_glob_to_regex(core),
124
+ )
125
+
126
+
127
+ class GitIgnore:
128
+ """Root ``.gitignore`` for a local workspace (last matching rule wins)."""
129
+
130
+ def __init__(self, rules: tuple[_IgnoreRule, ...] = ()) -> None:
131
+ self._rules = rules
132
+
133
+ @classmethod
134
+ def load(cls, root: Path) -> GitIgnore:
135
+ path = Path(root) / ".gitignore"
136
+ rules: list[_IgnoreRule] = []
137
+ if path.is_file():
138
+ try:
139
+ text = path.read_text(encoding="utf-8", errors="replace")
140
+ except OSError:
141
+ text = ""
142
+ for line in text.splitlines():
143
+ rule = _parse_rule(line)
144
+ if rule is not None:
145
+ rules.append(rule)
146
+ return cls(tuple(rules))
147
+
148
+ def ignored(self, rel: str) -> bool:
149
+ rel = rel.replace("\\", "/")
150
+ parts = rel.split("/")
151
+ prefixes = ["/".join(parts[: index + 1]) for index in range(len(parts))]
152
+ ignored = False
153
+ for rule in self._rules:
154
+ if rule.matches(prefixes):
155
+ ignored = not rule.negate
156
+ return ignored
157
+
158
+
159
+ # --------------------------------------------------------------------------- #
160
+ # git — read-only. status/diff only; there is no commit and no push here.
161
+ # --------------------------------------------------------------------------- #
162
+ @dataclass(frozen=True)
163
+ class GitInfo:
164
+ is_repo: bool
165
+ root: Path | None
166
+ git_dir: Path | None
167
+ common_dir: Path | None
168
+ is_worktree: bool
169
+ branch: str | None
170
+ head: str | None
171
+
172
+ def as_dict(self) -> dict[str, object]:
173
+ return {
174
+ "is_repo": self.is_repo,
175
+ "is_worktree": self.is_worktree,
176
+ "root": str(self.root) if self.root else None,
177
+ "git_dir": str(self.git_dir) if self.git_dir else None,
178
+ "common_dir": str(self.common_dir) if self.common_dir else None,
179
+ "branch": self.branch,
180
+ "head": self.head,
181
+ }
182
+
183
+
184
+ def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
185
+ return subprocess.run(
186
+ ["git", "-C", str(root), *args],
187
+ capture_output=True,
188
+ text=True,
189
+ encoding="utf-8",
190
+ errors="replace",
191
+ timeout=_GIT_TIMEOUT,
192
+ )
193
+
194
+
195
+ def _resolve_git_path(base: Path, value: str) -> Path | None:
196
+ value = value.strip()
197
+ if not value:
198
+ return None
199
+ candidate = Path(value)
200
+ if not candidate.is_absolute():
201
+ candidate = base / candidate
202
+ try:
203
+ return candidate.resolve()
204
+ except OSError: # pragma: no cover - defensive
205
+ return candidate
206
+
207
+
208
+ def git_info(root: str | os.PathLike[str]) -> GitInfo:
209
+ """Worktree-aware, read-only git metadata for ``root`` (never mutates)."""
210
+ base = Path(root).expanduser().resolve()
211
+ probe = _run_git(
212
+ base,
213
+ ["rev-parse", "--is-inside-work-tree", "--git-dir", "--git-common-dir", "--show-toplevel"],
214
+ )
215
+ if probe.returncode != 0:
216
+ return GitInfo(False, None, None, None, False, None, None)
217
+ lines = probe.stdout.splitlines()
218
+ if len(lines) < 4 or lines[0].strip() != "true":
219
+ return GitInfo(False, None, None, None, False, None, None)
220
+ git_dir = _resolve_git_path(base, lines[1])
221
+ common_dir = _resolve_git_path(base, lines[2])
222
+ toplevel = _resolve_git_path(base, lines[3])
223
+ is_worktree = bool(git_dir and common_dir and git_dir != common_dir)
224
+
225
+ branch_probe = _run_git(base, ["rev-parse", "--abbrev-ref", "HEAD"])
226
+ branch = branch_probe.stdout.strip() if branch_probe.returncode == 0 else None
227
+ head_probe = _run_git(base, ["rev-parse", "HEAD"])
228
+ head = head_probe.stdout.strip() if head_probe.returncode == 0 else None
229
+ return GitInfo(True, toplevel, git_dir, common_dir, is_worktree, branch or None, head or None)
230
+
231
+
232
+ def git_status(root: str | os.PathLike[str]) -> list[str]:
233
+ """``git status --porcelain`` (read-only). Empty when ``root`` is not a repo."""
234
+ base = Path(root).expanduser().resolve()
235
+ proc = _run_git(base, ["status", "--porcelain"])
236
+ if proc.returncode != 0:
237
+ return []
238
+ return [line for line in proc.stdout.splitlines() if line.strip()]
239
+
240
+
241
+ def git_diff(root: str | os.PathLike[str], *, staged: bool = False) -> str:
242
+ """``git diff`` (read-only). Empty when ``root`` is not a repo."""
243
+ base = Path(root).expanduser().resolve()
244
+ args = ["diff"]
245
+ if staged:
246
+ args.append("--cached")
247
+ proc = _run_git(base, args)
248
+ if proc.returncode != 0:
249
+ return ""
250
+ return proc.stdout
251
+
252
+
253
+ # --------------------------------------------------------------------------- #
254
+ # The driver
255
+ # --------------------------------------------------------------------------- #
256
+ @dataclass
257
+ class LocalWorkspace:
258
+ """Real-tree driver: read/write on disk, git status/diff, worktree aware."""
259
+
260
+ root: Path
261
+ git: GitInfo
262
+ gitignore: GitIgnore = field(repr=False, default_factory=GitIgnore)
263
+
264
+ @classmethod
265
+ def open(cls, directory: str | os.PathLike[str]) -> LocalWorkspace:
266
+ base = Path(directory).expanduser().resolve()
267
+ if not base.is_dir():
268
+ raise LocalWorkspaceError(f"--dir is not a directory: {base}")
269
+ return cls(root=base, git=git_info(base), gitignore=GitIgnore.load(base))
270
+
271
+ def load(
272
+ self,
273
+ *,
274
+ max_files: int = DEFAULT_MAX_FILES,
275
+ max_bytes: int = DEFAULT_MAX_WORKSPACE_BYTES,
276
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
277
+ ) -> WorkspaceSelection:
278
+ """Bounded read snapshot; ``.gitignore`` + the shared denylist applied."""
279
+ return load_workspace(
280
+ self.root,
281
+ max_files=max_files,
282
+ max_bytes=max_bytes,
283
+ max_file_bytes=max_file_bytes,
284
+ ignore=lambda rel: self.gitignore.ignored(rel)
285
+ or (
286
+ (rel == ".sid" or rel.startswith(".sid/"))
287
+ and rel != native_agent.PROJECT_CONFIG_PATH
288
+ ),
289
+ )
290
+
291
+ def _resolve_within_root(self, rel: object) -> tuple[str, Path]:
292
+ safe = safe_rel_path(rel)
293
+ root = self.root.resolve()
294
+ target = (root / safe).resolve()
295
+ if target != root and root not in target.parents:
296
+ raise LocalWorkspaceError("path escapes the workspace root")
297
+ return safe, target
298
+
299
+ def read(self, rel: object) -> str:
300
+ _safe, target = self._resolve_within_root(rel)
301
+ if not target.is_file():
302
+ raise LocalWorkspaceError(f"not a file: {rel}")
303
+ try:
304
+ return target.read_text(encoding="utf-8")
305
+ except (OSError, UnicodeDecodeError) as exc:
306
+ raise LocalWorkspaceError(f"cannot read {rel}: {exc}") from exc
307
+
308
+ def write(self, rel: object, content: object) -> dict[str, object]:
309
+ safe, target = self._resolve_within_root(rel)
310
+ if not isinstance(content, str):
311
+ raise LocalWorkspaceError("content must be a string")
312
+ size = len(content.encode("utf-8"))
313
+ if size > DEFAULT_MAX_FILE_BYTES:
314
+ raise LocalWorkspaceError(
315
+ f"file exceeds the {DEFAULT_MAX_FILE_BYTES}-byte limit"
316
+ )
317
+ target.parent.mkdir(parents=True, exist_ok=True)
318
+ tmp = target.with_name(target.name + ".sid-tmp")
319
+ tmp.write_text(content, encoding="utf-8")
320
+ os.replace(tmp, target)
321
+ return {"written": safe, "bytes": size, "path": str(target)}
322
+
323
+ def plan_apply(self, changed: list[str], files: dict[str, str]) -> dict[str, list[str]]:
324
+ """Classify a loop result without touching disk.
325
+
326
+ ``writes`` are changed paths that exist in the final file set;
327
+ ``deletions`` are changed paths that disappeared. Nothing is applied
328
+ here — a deletion needs explicit approval before it reaches the disk.
329
+ """
330
+ unique = set(changed)
331
+ return {
332
+ "writes": sorted(rel for rel in unique if rel in files),
333
+ "deletions": sorted(rel for rel in unique if rel not in files),
334
+ }
335
+
336
+ def apply(
337
+ self,
338
+ changed: list[str],
339
+ files: dict[str, str],
340
+ *,
341
+ approved_deletions: list[str] | tuple[str, ...] | set[str] | None = None,
342
+ ) -> list[str]:
343
+ """Persist the loop's changed files to the real tree.
344
+
345
+ Writes are applied as before. Deletions are NEVER silent: a path in
346
+ ``changed`` that is absent from ``files`` is only removed when it is
347
+ named in ``approved_deletions``. Use :meth:`plan_apply` to show the
348
+ caller exactly what would be deleted before approving.
349
+ """
350
+ plan = self.plan_apply(changed, files)
351
+ approved = set(approved_deletions or ())
352
+ written: list[str] = []
353
+ for rel in plan["writes"]:
354
+ self.write(rel, files[rel])
355
+ written.append(rel)
356
+ for rel in plan["deletions"]:
357
+ if rel in approved:
358
+ self.delete(rel, approved=True)
359
+ return written
360
+
361
+ def delete(self, rel: object, *, approved: bool = False) -> dict[str, object]:
362
+ """Delete one real file, only behind explicit approval."""
363
+ safe, target = self._resolve_within_root(rel)
364
+ if not approved:
365
+ raise LocalWorkspaceError(
366
+ f"refusing to delete {safe}: deletions require explicit approval"
367
+ )
368
+ if not target.is_file():
369
+ raise LocalWorkspaceError(f"not a file: {rel}")
370
+ try:
371
+ target.unlink()
372
+ except OSError as exc:
373
+ raise LocalWorkspaceError(f"cannot delete {rel}: {exc}") from exc
374
+ return {"deleted": safe, "path": str(target)}
375
+
376
+ def move(
377
+ self,
378
+ source: object,
379
+ destination: object,
380
+ *,
381
+ approved: bool = False,
382
+ ) -> dict[str, object]:
383
+ """Move/rename one real file, only behind explicit approval."""
384
+ safe_source, src = self._resolve_within_root(source)
385
+ safe_destination, dst = self._resolve_within_root(destination)
386
+ if not approved:
387
+ raise LocalWorkspaceError(
388
+ f"refusing to move {safe_source}: moves require explicit approval"
389
+ )
390
+ if not src.is_file():
391
+ raise LocalWorkspaceError(f"not a file: {source}")
392
+ if dst.exists():
393
+ raise LocalWorkspaceError(f"destination already exists: {destination}")
394
+ try:
395
+ dst.parent.mkdir(parents=True, exist_ok=True)
396
+ os.replace(src, dst)
397
+ except OSError as exc:
398
+ raise LocalWorkspaceError(f"cannot move {source}: {exc}") from exc
399
+ return {
400
+ "moved": {"from": safe_source, "to": safe_destination},
401
+ "path": str(dst),
402
+ }
403
+
404
+ def status(self) -> list[str]:
405
+ return git_status(self.root) if self.git.is_repo else []
406
+
407
+ def diff(self, *, staged: bool = False) -> str:
408
+ return git_diff(self.root, staged=staged) if self.git.is_repo else ""
409
+
410
+
411
+ __all__ = [
412
+ "GitIgnore",
413
+ "GitInfo",
414
+ "LocalWorkspace",
415
+ "LocalWorkspaceError",
416
+ "WorkspaceError",
417
+ "git_diff",
418
+ "git_info",
419
+ "git_status",
420
+ "safe_rel_path",
421
+ ]