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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- sid_harness-0.3.0.dist-info/top_level.txt +1 -0
sid_harness/selftest.py
ADDED
|
@@ -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"]
|