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,598 @@
1
+ """Opt-in git write path (SUB-95): branch, commit the approved diff, open a PR.
2
+
3
+ The harness is read-only by default. This module is the explicit opt-in that
4
+ finishes the everyday coder loop after a run: create a branch, commit only the
5
+ approved diff, push that branch, and open a pull request through ``gh``.
6
+
7
+ Every mutating step needs its own human approval. If no approver is supplied
8
+ each step fails closed; a falsy decision or a raising approver refuses the step
9
+ and stops the sequence. A partly completed sequence is reported honestly (the
10
+ branch and any commit already made stay named in the result).
11
+
12
+ Hard guards, enforced here rather than by convention:
13
+
14
+ * the write target may never be ``main``/``master`` (or a sub-ref of either) —
15
+ validated before any approval prompt or git command is issued;
16
+ * no force is ever built into a command (no ``-f``/``--force``/``--force-with-lease``);
17
+ * no commit happens without an explicit approval for the commit step;
18
+ * only the explicit approved paths are staged and committed.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import subprocess
25
+ from collections.abc import Callable, Iterable
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from .local_workspace import git_info
31
+
32
+ PROTECTED_BRANCHES = frozenset({"main", "master"})
33
+ PROTECTED_REF_PREFIX = "refs/heads/"
34
+
35
+ _GIT_TIMEOUT = 60.0
36
+ _GH_TIMEOUT = 120.0
37
+
38
+ _BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
39
+ _REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
40
+ _WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:")
41
+ _URL_RE = re.compile(r"https?://[^\s]+")
42
+
43
+ _FORCE_FLAGS = frozenset(
44
+ {"-f", "--force", "--force-with-lease", "--force-if-includes"}
45
+ )
46
+
47
+ _MAX_DETAIL_CHARS = 2000
48
+
49
+
50
+ class GitWriteError(RuntimeError):
51
+ """A git write step was refused or could not complete."""
52
+
53
+
54
+ class _Refused(GitWriteError):
55
+ """An approval gate refused the step; no further mutation may happen."""
56
+
57
+
58
+ class _Failed(GitWriteError):
59
+ """A git/gh command failed; the sequence stops and reports what completed."""
60
+
61
+
62
+ Approver = Callable[["GitWriteStep"], bool]
63
+ GitRunner = Callable[[Path, list[str]], subprocess.CompletedProcess]
64
+ GhRunner = Callable[[list[str], Path], subprocess.CompletedProcess]
65
+
66
+
67
+ # --------------------------------------------------------------------------- #
68
+ # Validation helpers (pure — testable without a repo)
69
+ # --------------------------------------------------------------------------- #
70
+ def normalize_branch(value: object) -> str:
71
+ """Return a branch name without the ``refs/heads/`` prefix."""
72
+ text = str(value or "").strip()
73
+ if text.startswith(PROTECTED_REF_PREFIX):
74
+ text = text[len(PROTECTED_REF_PREFIX) :]
75
+ return text
76
+
77
+
78
+ def is_protected_branch(value: object) -> bool:
79
+ """True when ``value`` names main/master or a sub-ref of either."""
80
+ name = normalize_branch(value).lower()
81
+ if not name:
82
+ return False
83
+ if name in PROTECTED_BRANCHES:
84
+ return True
85
+ return name.split("/", 1)[0] in PROTECTED_BRANCHES
86
+
87
+
88
+ def assert_writable_branch(value: object) -> str:
89
+ """Validate a branch the harness may commit/push to; refuse protected refs."""
90
+ name = normalize_branch(value)
91
+ if not name:
92
+ raise GitWriteError("a branch name is required")
93
+ if is_protected_branch(name):
94
+ raise GitWriteError(
95
+ f"refusing to write to protected branch {name!r}: the git-write path "
96
+ "never commits or pushes main/master"
97
+ )
98
+ if (
99
+ not _BRANCH_RE.fullmatch(name)
100
+ or ".." in name
101
+ or name.endswith(("/", ".", ".lock"))
102
+ or "//" in name
103
+ ):
104
+ raise GitWriteError(f"invalid branch name: {value!r}")
105
+ return name
106
+
107
+
108
+ def assert_safe_remote(value: object) -> str:
109
+ remote = str(value or "").strip()
110
+ if not remote or not _REMOTE_RE.fullmatch(remote) or is_protected_branch(remote):
111
+ raise GitWriteError(f"invalid remote name: {value!r}")
112
+ return remote
113
+
114
+
115
+ def contains_force_flag(argv: Iterable[str]) -> bool:
116
+ """True when an argv carries a force-push flag (it never should)."""
117
+ return any(
118
+ token in _FORCE_FLAGS or token.startswith("--force")
119
+ for token in argv
120
+ )
121
+
122
+
123
+ def _assert_no_force(argv: list[str]) -> list[str]:
124
+ if contains_force_flag(argv):
125
+ raise GitWriteError(f"refusing a force argument: {argv!r}")
126
+ return argv
127
+
128
+
129
+ def _validate_paths(paths: Iterable[object]) -> tuple[str, ...]:
130
+ validated: list[str] = []
131
+ for raw in paths:
132
+ text = str(raw or "").strip()
133
+ if not text:
134
+ raise GitWriteError("an approved path may not be empty")
135
+ normalized = text.replace("\\", "/")
136
+ if (
137
+ normalized.startswith("/")
138
+ or _WINDOWS_ABSOLUTE.match(normalized)
139
+ or ".." in normalized.split("/")
140
+ ):
141
+ raise GitWriteError(f"approved path escapes the workspace: {text!r}")
142
+ if normalized not in validated:
143
+ validated.append(normalized)
144
+ return tuple(validated)
145
+
146
+
147
+ # --------------------------------------------------------------------------- #
148
+ # Command builders (pure; the guards live here, not at the call sites)
149
+ # --------------------------------------------------------------------------- #
150
+ def branch_command(branch: str) -> list[str]:
151
+ return ["checkout", "-b", assert_writable_branch(branch)]
152
+
153
+
154
+ def add_command(paths: Iterable[object]) -> list[str]:
155
+ validated = _validate_paths(paths)
156
+ if not validated:
157
+ raise GitWriteError("no approved paths to stage")
158
+ return ["add", "--", *validated]
159
+
160
+
161
+ def commit_command(message: str, paths: Iterable[object]) -> list[str]:
162
+ text = str(message or "").strip()
163
+ if not text:
164
+ raise GitWriteError("a commit message is required")
165
+ if "\x00" in text:
166
+ raise GitWriteError("commit message must not contain NUL bytes")
167
+ validated = _validate_paths(paths)
168
+ if not validated:
169
+ raise GitWriteError("no approved paths to commit")
170
+ return ["commit", "-m", text, "--", *validated]
171
+
172
+
173
+ def push_command(remote: str, branch: str) -> list[str]:
174
+ target = assert_writable_branch(branch)
175
+ safe_remote = assert_safe_remote(remote)
176
+ return _assert_no_force(
177
+ ["push", "--set-upstream", safe_remote, target]
178
+ )
179
+
180
+
181
+ def pr_command(
182
+ branch: str,
183
+ *,
184
+ base: str,
185
+ title: str,
186
+ body: str,
187
+ ) -> list[str]:
188
+ head = assert_writable_branch(branch)
189
+ base_name = normalize_branch(base)
190
+ if not base_name:
191
+ raise GitWriteError("a pull-request base branch is required")
192
+ return _assert_no_force(
193
+ [
194
+ "pr",
195
+ "create",
196
+ "--head",
197
+ head,
198
+ "--base",
199
+ base_name,
200
+ "--title",
201
+ str(title or "").strip() or head,
202
+ "--body",
203
+ str(body or "").strip(),
204
+ ]
205
+ )
206
+
207
+
208
+ # --------------------------------------------------------------------------- #
209
+ # Plan / result shapes
210
+ # --------------------------------------------------------------------------- #
211
+ @dataclass(frozen=True)
212
+ class GitWriteStep:
213
+ """One mutating step, shown to the approver before it runs."""
214
+
215
+ name: str
216
+ description: str
217
+ command: str | None = None
218
+
219
+ def render(self) -> str:
220
+ if self.command:
221
+ return f"{self.name}: {self.description}\n $ {self.command}"
222
+ return f"{self.name}: {self.description}"
223
+
224
+ def as_dict(self) -> dict[str, Any]:
225
+ return {
226
+ "name": self.name,
227
+ "description": self.description,
228
+ "command": self.command,
229
+ }
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class GitWritePlan:
234
+ """What the opt-in path will do, decided before anything mutates."""
235
+
236
+ directory: Path
237
+ branch: str
238
+ message: str
239
+ paths: tuple[str, ...]
240
+ remote: str = "origin"
241
+ base_branch: str = "main"
242
+ open_pr: bool = True
243
+ pr_title: str | None = None
244
+ pr_body: str | None = None
245
+
246
+
247
+ @dataclass
248
+ class GitWriteResult:
249
+ """Honest outcome: names the branch, commit, and PR (plus what was refused)."""
250
+
251
+ status: str = "refused"
252
+ branch: str | None = None
253
+ commit: str | None = None
254
+ pr_url: str | None = None
255
+ pushed: bool = False
256
+ reason: str | None = None
257
+ steps: list[dict[str, Any]] = field(default_factory=list)
258
+ commands: list[str] = field(default_factory=list)
259
+
260
+ def as_dict(self) -> dict[str, Any]:
261
+ return {
262
+ "status": self.status,
263
+ "branch": self.branch,
264
+ "commit": self.commit,
265
+ "pr_url": self.pr_url,
266
+ "pr": self.pr_url,
267
+ "pushed": self.pushed,
268
+ "reason": self.reason,
269
+ "steps": list(self.steps),
270
+ "commands": list(self.commands),
271
+ }
272
+
273
+
274
+ # --------------------------------------------------------------------------- #
275
+ # Defaults for the runner/CLI
276
+ # --------------------------------------------------------------------------- #
277
+ def default_branch_name(run_id: str) -> str:
278
+ safe = re.sub(r"[^A-Za-z0-9._/-]+", "-", str(run_id or "run").strip())
279
+ return f"sid/{safe or 'run'}"
280
+
281
+
282
+ def default_commit_message(task: str, run_id: str) -> str:
283
+ first_line = " ".join(str(task or "").split())
284
+ if not first_line:
285
+ return f"sid: apply approved changes ({run_id})"
286
+ if len(first_line) > 72:
287
+ first_line = first_line[:69].rstrip() + "..."
288
+ return first_line
289
+
290
+
291
+ # --------------------------------------------------------------------------- #
292
+ # Command execution
293
+ # --------------------------------------------------------------------------- #
294
+ def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess:
295
+ return subprocess.run(
296
+ ["git", "-C", str(root), *args],
297
+ capture_output=True,
298
+ text=True,
299
+ encoding="utf-8",
300
+ errors="replace",
301
+ timeout=_GIT_TIMEOUT,
302
+ )
303
+
304
+
305
+ def _run_gh(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
306
+ return subprocess.run(
307
+ ["gh", *args],
308
+ cwd=str(cwd),
309
+ capture_output=True,
310
+ text=True,
311
+ encoding="utf-8",
312
+ errors="replace",
313
+ timeout=_GH_TIMEOUT,
314
+ )
315
+
316
+
317
+ def _display(argv: list[str]) -> str:
318
+ return " ".join(argv)
319
+
320
+
321
+ def _detail(proc: subprocess.CompletedProcess | None) -> str:
322
+ if proc is None:
323
+ return "no process result"
324
+ text = (getattr(proc, "stderr", "") or "").strip()
325
+ if not text:
326
+ text = (getattr(proc, "stdout", "") or "").strip()
327
+ if len(text) > _MAX_DETAIL_CHARS:
328
+ text = text[:_MAX_DETAIL_CHARS] + "..."
329
+ return text or f"exit {getattr(proc, 'returncode', '?')}"
330
+
331
+
332
+ def _check(
333
+ proc: subprocess.CompletedProcess | None,
334
+ action: str,
335
+ result: GitWriteResult,
336
+ ) -> None:
337
+ if proc is None or proc.returncode != 0:
338
+ raise _Failed(f"{action} failed: {_detail(proc)}")
339
+
340
+
341
+ def _record(result: GitWriteResult, argv: list[str], proc: subprocess.CompletedProcess) -> None:
342
+ result.commands.append(_display(argv))
343
+
344
+
345
+ def _git(
346
+ root: Path,
347
+ args: list[str],
348
+ run: GitRunner,
349
+ result: GitWriteResult,
350
+ ) -> subprocess.CompletedProcess:
351
+ argv = ["git", "-C", str(root), *args]
352
+ try:
353
+ proc = run(root, args)
354
+ except FileNotFoundError as exc:
355
+ raise _Failed(f"git is not available: {exc}") from exc
356
+ _record(result, argv, proc)
357
+ return proc
358
+
359
+
360
+ def _gh(
361
+ root: Path,
362
+ argv: list[str],
363
+ run: GhRunner,
364
+ result: GitWriteResult,
365
+ ) -> subprocess.CompletedProcess:
366
+ try:
367
+ proc = run(argv, root)
368
+ except FileNotFoundError as exc:
369
+ raise _Failed(f"gh CLI is not available: {exc}") from exc
370
+ _record(result, ["gh", *argv], proc)
371
+ return proc
372
+
373
+
374
+ def _current_branch(root: Path, run: GitRunner, result: GitWriteResult) -> str:
375
+ proc = _git(root, ["rev-parse", "--abbrev-ref", "HEAD"], run, result)
376
+ _check(proc, "read current branch", result)
377
+ return normalize_branch(proc.stdout)
378
+
379
+
380
+ def _branch_exists(root: Path, branch: str, run: GitRunner, result: GitWriteResult) -> bool:
381
+ proc = _git(
382
+ root,
383
+ ["rev-parse", "--verify", "--quiet", f"{PROTECTED_REF_PREFIX}{branch}"],
384
+ run,
385
+ result,
386
+ )
387
+ return proc.returncode == 0
388
+
389
+
390
+ def _require_approval(step: GitWriteStep, approve: Approver | None) -> None:
391
+ if approve is None:
392
+ raise _Refused(
393
+ f"no approver supplied; {step.name} requires explicit human approval"
394
+ )
395
+ try:
396
+ decision = bool(approve(step))
397
+ except Exception as exc: # a broken approver must fail closed
398
+ raise _Refused(
399
+ f"approver raised {type(exc).__name__}: {exc}"
400
+ ) from exc
401
+ if not decision:
402
+ raise _Refused(f"{step.name} refused by reviewer")
403
+
404
+
405
+ def _gate(
406
+ result: GitWriteResult,
407
+ approve: Approver | None,
408
+ step: GitWriteStep,
409
+ ) -> None:
410
+ entry = step.as_dict()
411
+ entry["approved"] = False
412
+ result.steps.append(entry)
413
+ try:
414
+ _require_approval(step, approve)
415
+ except _Refused as exc:
416
+ entry["detail"] = str(exc)
417
+ raise
418
+ entry["approved"] = True
419
+
420
+
421
+ def _extract_url(text: str) -> str | None:
422
+ matches = _URL_RE.findall(text or "")
423
+ return matches[-1] if matches else None
424
+
425
+
426
+ # --------------------------------------------------------------------------- #
427
+ # Execute
428
+ # --------------------------------------------------------------------------- #
429
+ def execute_git_write(
430
+ plan: GitWritePlan,
431
+ *,
432
+ approve: Approver | None,
433
+ git_runner: GitRunner | None = None,
434
+ gh_runner: GhRunner | None = None,
435
+ ) -> GitWriteResult:
436
+ """Run the opt-in sequence: branch -> commit -> push -> PR.
437
+
438
+ Returns a result even on refusal/failure, so a receipt always names what
439
+ happened. Hard guards are checked before the first approval prompt.
440
+ """
441
+ run_git = git_runner or _run_git
442
+ run_gh = gh_runner or _run_gh
443
+ result = GitWriteResult()
444
+
445
+ try:
446
+ branch = assert_writable_branch(plan.branch)
447
+ base_branch = normalize_branch(plan.base_branch or "main")
448
+ if not base_branch:
449
+ raise GitWriteError("a pull-request base branch is required")
450
+ paths = _validate_paths(plan.paths)
451
+ if not paths:
452
+ raise GitWriteError("no approved changes to commit")
453
+ message = str(plan.message or "").strip()
454
+ if not message:
455
+ raise GitWriteError("a commit message is required")
456
+ safe_remote = assert_safe_remote(plan.remote)
457
+ except GitWriteError as exc:
458
+ result.reason = str(exc)
459
+ return result
460
+
461
+ result.branch = branch
462
+ info = git_info(plan.directory)
463
+ if not info.is_repo or info.root is None:
464
+ result.reason = f"not a git repository: {plan.directory}"
465
+ return result
466
+ root = info.root
467
+
468
+ try:
469
+ # 1 — branch ------------------------------------------------------- #
470
+ current = _current_branch(root, run_git, result)
471
+ if normalize_branch(current) == branch and _branch_exists(
472
+ root, branch, run_git, result
473
+ ):
474
+ result.steps.append(
475
+ {
476
+ "name": "branch",
477
+ "description": f"already on branch {branch!r}",
478
+ "command": None,
479
+ "approved": True,
480
+ "detail": "no branch creation needed",
481
+ }
482
+ )
483
+ else:
484
+ if _branch_exists(root, branch, run_git, result):
485
+ raise _Refused(
486
+ f"branch {branch!r} already exists; refusing to move an "
487
+ "existing branch"
488
+ )
489
+ args = branch_command(branch)
490
+ _gate(
491
+ result,
492
+ approve,
493
+ GitWriteStep(
494
+ "branch",
495
+ f"create branch {branch!r} from {current or 'HEAD'}",
496
+ _display(["git", "-C", str(root), *args]),
497
+ ),
498
+ )
499
+ _check(_git(root, args, run_git, result), "create branch", result)
500
+
501
+ # 2 — commit only the approved paths ------------------------------- #
502
+ add_args = add_command(paths)
503
+ commit_args = commit_command(message, paths)
504
+ _gate(
505
+ result,
506
+ approve,
507
+ GitWriteStep(
508
+ "commit",
509
+ f"commit {len(paths)} approved path(s): {', '.join(paths)}",
510
+ _display(["git", "-C", str(root), *commit_args]),
511
+ ),
512
+ )
513
+ _check(_git(root, add_args, run_git, result), "stage approved paths", result)
514
+ _check(
515
+ _git(root, commit_args, run_git, result),
516
+ "commit approved paths",
517
+ result,
518
+ )
519
+ head = _git(root, ["rev-parse", "HEAD"], run_git, result)
520
+ _check(head, "read commit sha", result)
521
+ result.commit = head.stdout.strip() or None
522
+
523
+ # 3 — push the branch (never main/master, never forced) ------------ #
524
+ push_args = push_command(safe_remote, branch)
525
+ _gate(
526
+ result,
527
+ approve,
528
+ GitWriteStep(
529
+ "push",
530
+ f"push branch {branch!r} to {safe_remote!r} (no force)",
531
+ _display(["git", "-C", str(root), *push_args]),
532
+ ),
533
+ )
534
+ _check(
535
+ _git(root, push_args, run_git, result),
536
+ "push branch",
537
+ result,
538
+ )
539
+ result.pushed = True
540
+
541
+ # 4 — open the pull request via gh --------------------------------- #
542
+ if plan.open_pr:
543
+ title = (plan.pr_title or message.splitlines()[0])[:120]
544
+ body = plan.pr_body if plan.pr_body is not None else message
545
+ args = pr_command(branch, base=base_branch, title=title, body=body)
546
+ _gate(
547
+ result,
548
+ approve,
549
+ GitWriteStep(
550
+ "pr",
551
+ f"open a pull request {branch!r} -> {base_branch!r}",
552
+ _display(["gh", *args]),
553
+ ),
554
+ )
555
+ proc = _gh(root, args, run_gh, result)
556
+ _check(proc, "open pull request", result)
557
+ url = _extract_url(proc.stdout)
558
+ if not url:
559
+ raise _Failed("gh pr create returned no pull-request URL")
560
+ result.pr_url = url
561
+
562
+ result.status = "completed"
563
+ return result
564
+ except _Refused as exc:
565
+ result.status = "refused"
566
+ result.reason = str(exc)
567
+ return result
568
+ except _Failed as exc:
569
+ result.status = "failed"
570
+ result.reason = str(exc)
571
+ return result
572
+ except GitWriteError as exc: # defensive: a builder guard tripped mid-way
573
+ result.status = "failed"
574
+ result.reason = str(exc)
575
+ return result
576
+
577
+
578
+ __all__ = [
579
+ "PROTECTED_BRANCHES",
580
+ "Approver",
581
+ "GitWriteError",
582
+ "GitWritePlan",
583
+ "GitWriteResult",
584
+ "GitWriteStep",
585
+ "add_command",
586
+ "assert_safe_remote",
587
+ "assert_writable_branch",
588
+ "branch_command",
589
+ "commit_command",
590
+ "contains_force_flag",
591
+ "default_branch_name",
592
+ "default_commit_message",
593
+ "execute_git_write",
594
+ "is_protected_branch",
595
+ "normalize_branch",
596
+ "pr_command",
597
+ "push_command",
598
+ ]