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,149 @@
1
+ """Self-contained proof that the harness drives the shared loop end to end.
2
+
3
+ Starts a local stub of SID's ``/v1`` (chat completions + verify/patch), runs one
4
+ real loop turn through ``native_agent`` against it, and writes a receipt. No
5
+ network, no key, no spend — the same path a live run takes, minus the provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import tempfile
12
+ import threading
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .client import SIDClient
18
+ from .runner import RunOutcome, execute_run
19
+
20
+ STUB_MODEL = "sid-harness-selftest"
21
+ SELFTEST_KEY = "selftest-key"
22
+
23
+
24
+ def _completion(messages: list[dict[str, Any]]) -> dict[str, Any]:
25
+ """Turn 1: one write_file tool call. Turn 2: a final answer."""
26
+ has_tool_result = any(message.get("role") == "tool" for message in messages)
27
+ if not has_tool_result:
28
+ return {
29
+ "id": "chatcmpl-selftest-1",
30
+ "object": "chat.completion",
31
+ "model": STUB_MODEL,
32
+ "choices": [
33
+ {
34
+ "index": 0,
35
+ "finish_reason": "tool_calls",
36
+ "message": {
37
+ "role": "assistant",
38
+ "content": None,
39
+ "tool_calls": [
40
+ {
41
+ "id": "call_selftest_1",
42
+ "type": "function",
43
+ "function": {
44
+ "name": "write_file",
45
+ "arguments": json.dumps(
46
+ {
47
+ "path": "greeting.txt",
48
+ "content": "hello from sid-harness\n",
49
+ }
50
+ ),
51
+ },
52
+ }
53
+ ],
54
+ },
55
+ }
56
+ ],
57
+ "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
58
+ }
59
+ return {
60
+ "id": "chatcmpl-selftest-2",
61
+ "object": "chat.completion",
62
+ "model": STUB_MODEL,
63
+ "choices": [
64
+ {
65
+ "index": 0,
66
+ "finish_reason": "stop",
67
+ "message": {"role": "assistant", "content": "Updated greeting.txt."},
68
+ }
69
+ ],
70
+ "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16},
71
+ }
72
+
73
+
74
+ class _StubHandler(BaseHTTPRequestHandler):
75
+ def log_message(self, *args: Any) -> None: # silence the stub
76
+ pass
77
+
78
+ def _send(self, code: int, payload: dict[str, Any]) -> None:
79
+ body = json.dumps(payload).encode("utf-8")
80
+ self.send_response(code)
81
+ self.send_header("Content-Type", "application/json")
82
+ self.send_header("Content-Length", str(len(body)))
83
+ self.end_headers()
84
+ self.wfile.write(body)
85
+
86
+ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
87
+ length = int(self.headers.get("Content-Length") or 0)
88
+ raw = self.rfile.read(length) if length else b"{}"
89
+ try:
90
+ payload = json.loads(raw or b"{}")
91
+ except json.JSONDecodeError:
92
+ payload = {}
93
+ if self.path.endswith("/chat/completions"):
94
+ self._send(200, _completion(payload.get("messages") or []))
95
+ elif self.path.endswith("/verify/patch"):
96
+ self._send(
97
+ 200,
98
+ {
99
+ "verdict": "pass",
100
+ "issues": [],
101
+ "confidence": 1.0,
102
+ "parse_ok": True,
103
+ "executed": False,
104
+ },
105
+ )
106
+ else:
107
+ self._send(404, {"error": "stub: unknown path", "path": self.path})
108
+
109
+
110
+ def start_stub_server() -> tuple[ThreadingHTTPServer, str]:
111
+ """Return a running in-process SID stub and its base URL."""
112
+ server = ThreadingHTTPServer(("127.0.0.1", 0), _StubHandler)
113
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
114
+ thread.start()
115
+ host, port = server.server_address
116
+ return server, f"http://{host}:{port}"
117
+
118
+
119
+ def run_selftest(
120
+ *, receipt_path: str | Path | None = None, directory: str | Path | None = None
121
+ ) -> RunOutcome:
122
+ """Drive one loop turn against a local stub and write a run receipt."""
123
+ server, base_url = start_stub_server()
124
+ temp_dir: tempfile.TemporaryDirectory[str] | None = None
125
+ try:
126
+ if directory is None:
127
+ temp_dir = tempfile.TemporaryDirectory(prefix="sid-harness-selftest-")
128
+ root = Path(temp_dir.name)
129
+ (root / "notes.txt").write_text("start\n", encoding="utf-8")
130
+ else:
131
+ root = Path(directory)
132
+ client = SIDClient(
133
+ base_url=base_url, routing_profile="agent", api_key=SELFTEST_KEY
134
+ )
135
+ return execute_run(
136
+ task="Create greeting.txt with a hello message.",
137
+ directory=root,
138
+ client=client,
139
+ model="auto",
140
+ criteria=["greeting.txt contains a hello message."],
141
+ out_of_scope=["Do not add any other files."],
142
+ verify=True,
143
+ receipt_path=receipt_path,
144
+ )
145
+ finally:
146
+ server.shutdown()
147
+ server.server_close()
148
+ if temp_dir is not None:
149
+ temp_dir.cleanup()
@@ -0,0 +1,33 @@
1
+ """SID local session UX (SUB-58).
2
+
3
+ Durable local sessions for the first-party harness: resume/replay, a clean
4
+ interrupt that writes a durable receipt, an approve-before-apply diff gate, and
5
+ loop bounds that are configurable (defaults are the shared native loop's
6
+ constants — this package never removes them).
7
+
8
+ The session record lives at ``<dir>/.sid/sessions/<session_id>.json`` and its
9
+ runs are the harness's EXISTING receipts at ``<dir>/.sid/runs/<run_id>.json`` —
10
+ one receipt format (the LQ-573 shape), not two. No multi-user, no remote.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .bounds import BoundsError, SessionBounds
16
+ from .diff_gate import Approver, DiffProposal, DiffReview, build_review
17
+ from .session import Session, SessionError, SessionOutcome
18
+ from .store import SessionStore, SessionStoreError, new_session_id
19
+
20
+ __all__ = [
21
+ "Approver",
22
+ "BoundsError",
23
+ "DiffProposal",
24
+ "DiffReview",
25
+ "Session",
26
+ "SessionBounds",
27
+ "SessionError",
28
+ "SessionOutcome",
29
+ "SessionStore",
30
+ "SessionStoreError",
31
+ "build_review",
32
+ "new_session_id",
33
+ ]
@@ -0,0 +1,140 @@
1
+ """Configurable loop bounds (SUB-58).
2
+
3
+ The native loop's ceilings (turns, tool calls, output size, workspace size) are
4
+ parameters of ``native_agent.run_native_agent`` that default to the module
5
+ constants in ``infra/sid-gateway/app/native_agent.py``. Those constants are kept
6
+ and never removed — this module only lets a session override them per run, from
7
+ an optional JSON config file, environment variables, or explicit overrides.
8
+
9
+ Precedence (lowest to highest): module defaults → environment → config file →
10
+ explicit overrides.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from collections.abc import Mapping
18
+ from dataclasses import dataclass, replace
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from sid_harness.gateway import native_agent
23
+
24
+ # Environment names are stable, documented config — not a vendor/model.
25
+ _ENV_KEYS = {
26
+ "SID_MAX_TURNS": "max_turns",
27
+ "SID_MAX_TOOL_CALLS": "max_tool_calls",
28
+ "SID_MAX_TOOL_OUTPUT_CHARS": "max_tool_output_chars",
29
+ "SID_MAX_FILES": "max_files",
30
+ "SID_MAX_WORKSPACE_BYTES": "max_workspace_bytes",
31
+ "SID_MAX_FILE_BYTES": "max_file_bytes",
32
+ }
33
+
34
+ _FIELDS = tuple(_ENV_KEYS.values())
35
+
36
+
37
+ class BoundsError(ValueError):
38
+ """A bounds value was unknown or not a positive integer."""
39
+
40
+
41
+ def _coerce_int(name: str, value: Any) -> int:
42
+ if isinstance(value, bool):
43
+ raise BoundsError(f"bounds.{name} must be a positive integer, got a boolean")
44
+ if isinstance(value, int):
45
+ number = value
46
+ elif isinstance(value, str) and value.strip().isdigit():
47
+ number = int(value.strip())
48
+ else:
49
+ raise BoundsError(f"bounds.{name} must be a positive integer, got {value!r}")
50
+ if number <= 0:
51
+ raise BoundsError(f"bounds.{name} must be positive, got {number}")
52
+ return number
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SessionBounds:
57
+ """Overridable ceilings for one session's runs (defaults = native constants)."""
58
+
59
+ max_turns: int = native_agent.MAX_TURNS
60
+ max_tool_calls: int = native_agent.MAX_TOOL_CALLS
61
+ max_tool_output_chars: int = native_agent.MAX_TOOL_OUTPUT_CHARS
62
+ max_files: int = native_agent.MAX_FILES
63
+ max_workspace_bytes: int = native_agent.MAX_WORKSPACE_BYTES
64
+ max_file_bytes: int = native_agent.MAX_FILE_BYTES
65
+
66
+ def as_dict(self) -> dict[str, int]:
67
+ return {name: getattr(self, name) for name in _FIELDS}
68
+
69
+ def to_native_kwargs(self) -> dict[str, int]:
70
+ """Keyword arguments accepted by ``native_agent.run_native_agent``."""
71
+ return self.as_dict()
72
+
73
+ @classmethod
74
+ def defaults(cls) -> "SessionBounds":
75
+ return cls()
76
+
77
+ @classmethod
78
+ def from_mapping(
79
+ cls, data: Mapping[str, Any], *, base: "SessionBounds | None" = None
80
+ ) -> "SessionBounds":
81
+ start = base or cls()
82
+ unknown = set(data) - set(_FIELDS)
83
+ if unknown:
84
+ raise BoundsError(
85
+ "unknown bound(s): " + ", ".join(sorted(unknown))
86
+ )
87
+ updates = {name: _coerce_int(name, data[name]) for name in data}
88
+ return replace(start, **updates)
89
+
90
+ @classmethod
91
+ def from_file(
92
+ cls, path: str | os.PathLike[str], *, base: "SessionBounds | None" = None
93
+ ) -> "SessionBounds":
94
+ target = Path(path).expanduser()
95
+ if not target.is_file():
96
+ raise BoundsError(f"bounds config not found: {target}")
97
+ try:
98
+ data = json.loads(target.read_text(encoding="utf-8"))
99
+ except (OSError, json.JSONDecodeError) as exc:
100
+ raise BoundsError(f"bounds config is not valid JSON: {target}: {exc}") from exc
101
+ if not isinstance(data, Mapping):
102
+ raise BoundsError("bounds config must be a JSON object")
103
+ return cls.from_mapping(data, base=base)
104
+
105
+ @classmethod
106
+ def from_env(
107
+ cls,
108
+ environ: Mapping[str, str] | None = None,
109
+ *,
110
+ base: "SessionBounds | None" = None,
111
+ ) -> "SessionBounds":
112
+ env = environ if environ is not None else os.environ
113
+ present = {
114
+ field: env[var]
115
+ for var, field in _ENV_KEYS.items()
116
+ if env.get(var) not in (None, "")
117
+ }
118
+ if not present:
119
+ return base or cls()
120
+ return cls.from_mapping(present, base=base)
121
+
122
+ @classmethod
123
+ def resolve(
124
+ cls,
125
+ *,
126
+ config_path: str | os.PathLike[str] | None = None,
127
+ overrides: Mapping[str, Any] | None = None,
128
+ environ: Mapping[str, str] | None = None,
129
+ base: "SessionBounds | None" = None,
130
+ ) -> "SessionBounds":
131
+ """Compose environment → config file → explicit overrides onto defaults."""
132
+ bounds = cls.from_env(environ, base=base or cls())
133
+ if config_path is not None:
134
+ bounds = cls.from_file(config_path, base=bounds)
135
+ if overrides:
136
+ bounds = cls.from_mapping(overrides, base=bounds)
137
+ return bounds
138
+
139
+
140
+ __all__ = ["BoundsError", "SessionBounds"]
@@ -0,0 +1,93 @@
1
+ """Approve-before-apply diff gate (SUB-58).
2
+
3
+ A run may produce a diff. Applying it to the user's real working tree is gated:
4
+ the caller receives the diff and the changed paths and must approve before any
5
+ write happens. The default is DENY — no approver, a falsy decision, an approver
6
+ that raises, or a read-only run all refuse the apply and say why.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DiffProposal:
18
+ """What the approver is asked to accept: the diff and the changed paths."""
19
+
20
+ diff: str | None
21
+ changed: list[str]
22
+
23
+ def render(self) -> str:
24
+ header = "changed: " + (", ".join(self.changed) or "(none)")
25
+ if not self.diff:
26
+ return header
27
+ return f"{header}\n\n{self.diff}"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DiffReview:
32
+ """The gate's decision, recorded on the run receipt."""
33
+
34
+ diff: str | None
35
+ changed: list[str]
36
+ approved: bool
37
+ reason: str
38
+ reviewed_by: str | None = None
39
+
40
+ def as_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "approved": self.approved,
43
+ "reason": self.reason,
44
+ "changed": list(self.changed),
45
+ "reviewed_by": self.reviewed_by,
46
+ "has_diff": bool(self.diff),
47
+ }
48
+
49
+
50
+ Approver = Callable[[DiffProposal], bool]
51
+
52
+
53
+ def build_review(
54
+ result: dict[str, Any],
55
+ *,
56
+ approve: Approver | None = None,
57
+ reviewer: str | None = None,
58
+ requested: bool = True,
59
+ ) -> DiffReview:
60
+ """Decide whether the run's changes may be applied to the real tree."""
61
+ diff = result.get("diff")
62
+ changed = list(result.get("changed") or [])
63
+ proposal = DiffProposal(diff=diff, changed=list(changed))
64
+
65
+ if not requested:
66
+ return DiffReview(
67
+ diff, list(changed), approved=False, reason="write not requested", reviewed_by=reviewer
68
+ )
69
+ if not changed:
70
+ return DiffReview(diff, list(changed), approved=True, reason="no changes to apply", reviewed_by=reviewer)
71
+ if approve is None:
72
+ return DiffReview(
73
+ diff,
74
+ list(changed),
75
+ approved=False,
76
+ reason="no approver provided; changes not applied (fail closed)",
77
+ reviewed_by=reviewer,
78
+ )
79
+ try:
80
+ approved = bool(approve(proposal))
81
+ except Exception as exc: # a broken approver must never silently apply
82
+ return DiffReview(
83
+ diff,
84
+ list(changed),
85
+ approved=False,
86
+ reason=f"approver raised {type(exc).__name__}: {exc}",
87
+ reviewed_by=reviewer,
88
+ )
89
+ reason = "approved by reviewer" if approved else "rejected by reviewer"
90
+ return DiffReview(diff, list(changed), approved=approved, reason=reason, reviewed_by=reviewer)
91
+
92
+
93
+ __all__ = ["Approver", "DiffProposal", "DiffReview", "build_review"]