diffbutler 0.5.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.
- diffbutler/__init__.py +1 -0
- diffbutler/__main__.py +6 -0
- diffbutler/config/__init__.py +1 -0
- diffbutler/config/default.toml +91 -0
- diffbutler/config/diagnostics.py +90 -0
- diffbutler/config/file.py +30 -0
- diffbutler/config/instance.py +55 -0
- diffbutler/config/settings.py +297 -0
- diffbutler/config/setup.py +92 -0
- diffbutler/config/transport.py +65 -0
- diffbutler/core/__init__.py +1 -0
- diffbutler/core/application.py +103 -0
- diffbutler/core/context.py +54 -0
- diffbutler/core/dashboard.py +393 -0
- diffbutler/core/doctor.py +200 -0
- diffbutler/core/lifecycle.py +70 -0
- diffbutler/core/managed.py +441 -0
- diffbutler/core/native.py +301 -0
- diffbutler/core/polling.py +118 -0
- diffbutler/core/process.py +64 -0
- diffbutler/core/repositories.py +260 -0
- diffbutler/core/reset.py +34 -0
- diffbutler/core/resources.py +50 -0
- diffbutler/core/review.py +331 -0
- diffbutler/core/setup.py +232 -0
- diffbutler/core/snapshot.py +70 -0
- diffbutler/core/sync.py +259 -0
- diffbutler/core/worker.py +205 -0
- diffbutler/core/workspaces.py +139 -0
- diffbutler/domain/__init__.py +1 -0
- diffbutler/domain/dashboard.py +63 -0
- diffbutler/domain/diff.py +285 -0
- diffbutler/domain/forge.py +123 -0
- diffbutler/domain/lifecycle.py +51 -0
- diffbutler/domain/polling.py +92 -0
- diffbutler/domain/process.py +29 -0
- diffbutler/domain/redaction.py +61 -0
- diffbutler/domain/resources.py +50 -0
- diffbutler/domain/review.py +167 -0
- diffbutler/domain/sync.py +196 -0
- diffbutler/forges/__init__.py +1 -0
- diffbutler/forges/base.py +39 -0
- diffbutler/forges/diagnostics.py +104 -0
- diffbutler/forges/github/__init__.py +1 -0
- diffbutler/forges/github/adapter.py +352 -0
- diffbutler/forges/github/auth.py +49 -0
- diffbutler/forges/github/http.py +326 -0
- diffbutler/forges/github/mapping.py +157 -0
- diffbutler/forges/github/setup.py +86 -0
- diffbutler/forges/registry.py +23 -0
- diffbutler/py.typed +0 -0
- diffbutler/reviewers/__init__.py +1 -0
- diffbutler/reviewers/base.py +101 -0
- diffbutler/reviewers/extraction.py +198 -0
- diffbutler/reviewers/native.py +238 -0
- diffbutler/reviewers/parsing.py +247 -0
- diffbutler/reviewers/presets.toml +12 -0
- diffbutler/reviewers/process.py +78 -0
- diffbutler/reviewers/prompt.py +148 -0
- diffbutler/reviewers/redaction.py +30 -0
- diffbutler/reviewers/resources.py +162 -0
- diffbutler/sources/__init__.py +1 -0
- diffbutler/sources/diagnostics.py +100 -0
- diffbutler/sources/fetch.py +285 -0
- diffbutler/sources/git.py +461 -0
- diffbutler/sources/remotes.py +82 -0
- diffbutler/store/__init__.py +1 -0
- diffbutler/store/base.py +88 -0
- diffbutler/store/dashboard.py +201 -0
- diffbutler/store/database.py +172 -0
- diffbutler/store/lifecycle.py +247 -0
- diffbutler/store/migrations/__init__.py +1 -0
- diffbutler/store/migrations/env.py +14 -0
- diffbutler/store/migrations/versions/0001_initial.py +147 -0
- diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
- diffbutler/store/migrations/versions/0003_reviews.py +72 -0
- diffbutler/store/migrations/versions/0004_claims.py +22 -0
- diffbutler/store/migrations/versions/0005_results.py +27 -0
- diffbutler/store/migrations/versions/0006_anchors.py +17 -0
- diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
- diffbutler/store/migrations/versions/0008_processes.py +30 -0
- diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
- diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
- diffbutler/store/migrations/versions/__init__.py +1 -0
- diffbutler/store/models.py +290 -0
- diffbutler/store/process.py +75 -0
- diffbutler/store/queue.py +289 -0
- diffbutler/store/review.py +583 -0
- diffbutler/ui/__init__.py +1 -0
- diffbutler/ui/cli/__init__.py +532 -0
- diffbutler/ui/cli/editor.py +33 -0
- diffbutler/ui/tui/__init__.py +1 -0
- diffbutler/ui/tui/_fake_agent.py +47 -0
- diffbutler/ui/tui/_foreground_exec.py +23 -0
- diffbutler/ui/tui/app.py +34 -0
- diffbutler/ui/tui/browse.py +119 -0
- diffbutler/ui/tui/browser.py +42 -0
- diffbutler/ui/tui/dashboard.py +926 -0
- diffbutler/ui/tui/foreground.py +103 -0
- diffbutler/ui/tui/handoff_probe.py +184 -0
- diffbutler-0.5.0.dist-info/METADATA +170 -0
- diffbutler-0.5.0.dist-info/RECORD +105 -0
- diffbutler-0.5.0.dist-info/WHEEL +4 -0
- diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
- diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Declarative offline extraction of backend envelopes; no backend flag assumptions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from diffbutler.reviewers.base import ReviewOutput
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
11
|
+
result: dict[str, object] = {}
|
|
12
|
+
for key, value in pairs:
|
|
13
|
+
if key in result:
|
|
14
|
+
raise ValueError("duplicate key")
|
|
15
|
+
result[key] = value
|
|
16
|
+
return result
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _constant(value: str) -> object:
|
|
20
|
+
raise ValueError("nonfinite JSON number")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load(text: str) -> object:
|
|
24
|
+
return json.loads(text, object_pairs_hook=_pairs, parse_constant=_constant)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _at(value: object, path: tuple[str, ...]) -> object:
|
|
28
|
+
for part in path:
|
|
29
|
+
if not isinstance(value, dict):
|
|
30
|
+
return None
|
|
31
|
+
value = cast(dict[str, object], value).get(part)
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class EnvelopeFormat:
|
|
37
|
+
text_path: tuple[str, ...] = ("result",)
|
|
38
|
+
session_path: tuple[str, ...] | None = ("session_id",)
|
|
39
|
+
error_path: tuple[str, ...] | None = ("is_error",)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class StreamFormat:
|
|
44
|
+
"""Only complete assistant messages are selected, never deltas or tool output.
|
|
45
|
+
|
|
46
|
+
Required successful terminal events distinguish a complete turn from a
|
|
47
|
+
truncated stream. Selectors describe a verified backend protocol; these
|
|
48
|
+
defaults are a synthetic testing protocol, not a Codex/Claude preset.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
type_path: tuple[str, ...] = ("type",)
|
|
52
|
+
assistant_type: str = "assistant"
|
|
53
|
+
text_path: tuple[str, ...] = ("text",)
|
|
54
|
+
session_type: str = "session"
|
|
55
|
+
session_path: tuple[str, ...] = ("id",)
|
|
56
|
+
complete_type: str = "complete"
|
|
57
|
+
failure_types: tuple[str, ...] = ("error", "failed")
|
|
58
|
+
assistant_filter_path: tuple[str, ...] | None = None
|
|
59
|
+
assistant_filter_value: str | None = None
|
|
60
|
+
require_session: bool = False
|
|
61
|
+
session_filter_path: tuple[str, ...] | None = None
|
|
62
|
+
session_filter_value: str | None = None
|
|
63
|
+
terminal_text_path: tuple[str, ...] | None = None
|
|
64
|
+
terminal_success_path: tuple[str, ...] | None = None
|
|
65
|
+
terminal_success_value: object = None
|
|
66
|
+
terminal_error_path: tuple[str, ...] | None = None
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
types = (self.assistant_type, self.session_type, self.complete_type, *self.failure_types)
|
|
70
|
+
if not all(types) or len(types) != len(set(types)):
|
|
71
|
+
raise ValueError("stream event types must be nonempty and distinct")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
type OutputFormat = EnvelopeFormat | StreamFormat
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def backend_failure_reason(output: ReviewOutput, format: OutputFormat | None) -> str | None:
|
|
78
|
+
"""Recognize known failures without copying private backend text into diagnostics."""
|
|
79
|
+
messages = [output.stderr.decode("utf-8", errors="replace")]
|
|
80
|
+
if isinstance(format, StreamFormat):
|
|
81
|
+
for line in output.stdout.decode("utf-8", errors="replace").splitlines():
|
|
82
|
+
try:
|
|
83
|
+
event = _load(line)
|
|
84
|
+
except ValueError, RecursionError:
|
|
85
|
+
continue
|
|
86
|
+
if _at(event, format.type_path) in format.failure_types:
|
|
87
|
+
for path in (("message",), ("error", "message")):
|
|
88
|
+
message = _at(event, path)
|
|
89
|
+
if isinstance(message, str):
|
|
90
|
+
messages.append(message)
|
|
91
|
+
if any("thread already has an active writer" in " ".join(m.split()) for m in messages):
|
|
92
|
+
return (
|
|
93
|
+
"Codex conversation is busy (thread already has an active writer). "
|
|
94
|
+
"Exit any other session using this conversation, wait briefly, then retry explicitly."
|
|
95
|
+
)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ExtractedOutput:
|
|
101
|
+
"""Raw output is never replaced by extracted assistant text."""
|
|
102
|
+
|
|
103
|
+
output: ReviewOutput
|
|
104
|
+
text: bytes | None
|
|
105
|
+
backend_session_id: str | None
|
|
106
|
+
error: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _identity(value: object, previous: str | None) -> str:
|
|
110
|
+
if (
|
|
111
|
+
not isinstance(value, str)
|
|
112
|
+
or not value.strip()
|
|
113
|
+
or len(value) > 1024
|
|
114
|
+
or any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in value)
|
|
115
|
+
or previous not in (None, value)
|
|
116
|
+
):
|
|
117
|
+
raise ValueError("invalid or conflicting session identity")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_output(output: ReviewOutput, format: OutputFormat) -> ExtractedOutput:
|
|
122
|
+
"""Fail the entire capture on malformed/truncated/error events, retaining raw bytes.
|
|
123
|
+
|
|
124
|
+
No progress text is returned: tool events and earlier messages can contain
|
|
125
|
+
secrets. Final assistant text is validated and retained by the review parser.
|
|
126
|
+
"""
|
|
127
|
+
session_id = output.backend_session_id
|
|
128
|
+
try:
|
|
129
|
+
if session_id is not None:
|
|
130
|
+
_identity(session_id, None)
|
|
131
|
+
text = output.stdout.decode("utf-8")
|
|
132
|
+
if isinstance(format, EnvelopeFormat):
|
|
133
|
+
root = _load(text)
|
|
134
|
+
if not isinstance(root, dict):
|
|
135
|
+
raise ValueError
|
|
136
|
+
root = cast(dict[str, object], root)
|
|
137
|
+
answer = _at(root, format.text_path)
|
|
138
|
+
if format.error_path is not None and _at(root, format.error_path) not in (None, False):
|
|
139
|
+
raise ValueError
|
|
140
|
+
if format.session_path is not None:
|
|
141
|
+
value = _at(root, format.session_path)
|
|
142
|
+
if value is not None:
|
|
143
|
+
session_id = _identity(value, session_id)
|
|
144
|
+
else:
|
|
145
|
+
answer: object = None
|
|
146
|
+
complete = False
|
|
147
|
+
for line in text.splitlines():
|
|
148
|
+
if not line.strip():
|
|
149
|
+
continue
|
|
150
|
+
if complete:
|
|
151
|
+
raise ValueError
|
|
152
|
+
event = _load(line)
|
|
153
|
+
kind = _at(event, format.type_path)
|
|
154
|
+
if not isinstance(event, dict) or not isinstance(kind, str):
|
|
155
|
+
raise ValueError
|
|
156
|
+
event = cast(dict[str, object], event)
|
|
157
|
+
if kind in format.failure_types:
|
|
158
|
+
raise ValueError
|
|
159
|
+
if kind == format.session_type and (
|
|
160
|
+
format.session_filter_path is None
|
|
161
|
+
or _at(event, format.session_filter_path) == format.session_filter_value
|
|
162
|
+
):
|
|
163
|
+
session_id = _identity(_at(event, format.session_path), session_id)
|
|
164
|
+
elif (
|
|
165
|
+
format.terminal_text_path is None
|
|
166
|
+
and kind == format.assistant_type
|
|
167
|
+
and (
|
|
168
|
+
format.assistant_filter_path is None
|
|
169
|
+
or _at(event, format.assistant_filter_path) == format.assistant_filter_value
|
|
170
|
+
)
|
|
171
|
+
):
|
|
172
|
+
answer = _at(event, format.text_path)
|
|
173
|
+
if not isinstance(answer, str):
|
|
174
|
+
raise ValueError
|
|
175
|
+
elif kind == format.complete_type:
|
|
176
|
+
if format.terminal_success_path is not None and (
|
|
177
|
+
_at(event, format.terminal_success_path) != format.terminal_success_value
|
|
178
|
+
):
|
|
179
|
+
raise ValueError
|
|
180
|
+
if format.terminal_error_path is not None and (
|
|
181
|
+
_at(event, format.terminal_error_path) is not False
|
|
182
|
+
):
|
|
183
|
+
raise ValueError
|
|
184
|
+
if format.terminal_text_path is not None:
|
|
185
|
+
answer = _at(event, format.terminal_text_path)
|
|
186
|
+
session_id = _identity(_at(event, format.session_path), session_id)
|
|
187
|
+
complete = True
|
|
188
|
+
if not complete or (format.require_session and session_id is None):
|
|
189
|
+
raise ValueError
|
|
190
|
+
if not isinstance(answer, str) or not answer.strip():
|
|
191
|
+
raise ValueError
|
|
192
|
+
if output.exit_code != 0:
|
|
193
|
+
return ExtractedOutput(output, None, session_id, "reviewer exited unsuccessfully")
|
|
194
|
+
return ExtractedOutput(output, answer.encode("utf-8"), session_id)
|
|
195
|
+
except ValueError, RecursionError:
|
|
196
|
+
return ExtractedOutput(
|
|
197
|
+
output, None, None, backend_failure_reason(output, format) or "invalid backend output"
|
|
198
|
+
)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Native agent launch contract. Sandboxing belongs to the installed agent CLI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import tomllib
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import cast
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from diffbutler.reviewers.base import ReviewOutput
|
|
18
|
+
from diffbutler.reviewers.extraction import StreamFormat
|
|
19
|
+
|
|
20
|
+
CODEX_OUTPUT = StreamFormat(
|
|
21
|
+
assistant_type="item.completed",
|
|
22
|
+
text_path=("item", "text"),
|
|
23
|
+
session_type="thread.started",
|
|
24
|
+
session_path=("thread_id",),
|
|
25
|
+
complete_type="turn.completed",
|
|
26
|
+
failure_types=("error", "turn.failed"),
|
|
27
|
+
assistant_filter_path=("item", "type"),
|
|
28
|
+
assistant_filter_value="agent_message",
|
|
29
|
+
require_session=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CLAUDE_OUTPUT = StreamFormat(
|
|
34
|
+
assistant_type="assistant", # Only the final result is an answer.
|
|
35
|
+
session_type="system",
|
|
36
|
+
session_path=("session_id",),
|
|
37
|
+
session_filter_path=("subtype",),
|
|
38
|
+
session_filter_value="init",
|
|
39
|
+
complete_type="result",
|
|
40
|
+
terminal_text_path=("result",),
|
|
41
|
+
terminal_success_path=("subtype",),
|
|
42
|
+
terminal_success_value="success",
|
|
43
|
+
terminal_error_path=("is_error",),
|
|
44
|
+
require_session=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NativeError(RuntimeError):
|
|
49
|
+
"""Fixed local diagnostic without captured agent text or credentials."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class NativePreset:
|
|
54
|
+
background: tuple[str, ...]
|
|
55
|
+
background_resume: tuple[str, ...]
|
|
56
|
+
foreground: tuple[str, ...]
|
|
57
|
+
name: str = "codex"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def output_format(self) -> StreamFormat:
|
|
61
|
+
return CLAUDE_OUTPUT if self.name == "claude" else CODEX_OUTPUT
|
|
62
|
+
|
|
63
|
+
def command_at(self, command: tuple[str, ...], cwd: Path) -> tuple[str, ...]:
|
|
64
|
+
return (*command, "-C", str(cwd)) if self.name == "codex" else command
|
|
65
|
+
|
|
66
|
+
def argv(
|
|
67
|
+
self,
|
|
68
|
+
command: tuple[str, ...],
|
|
69
|
+
session_id: str | None,
|
|
70
|
+
*,
|
|
71
|
+
foreground: bool = False,
|
|
72
|
+
session_name: str | None = None,
|
|
73
|
+
) -> tuple[str, ...]:
|
|
74
|
+
if session_id is not None:
|
|
75
|
+
try:
|
|
76
|
+
UUID(session_id)
|
|
77
|
+
except ValueError:
|
|
78
|
+
raise NativeError("Invalid native conversation identity") from None
|
|
79
|
+
if foreground and session_id is None:
|
|
80
|
+
raise NativeError("No saved native conversation to resume")
|
|
81
|
+
arguments = (
|
|
82
|
+
self.foreground
|
|
83
|
+
if foreground
|
|
84
|
+
else (self.background_resume if session_id else self.background)
|
|
85
|
+
)
|
|
86
|
+
# Opaque identities occupy a single argv element, never a shell/template.
|
|
87
|
+
return (
|
|
88
|
+
*command,
|
|
89
|
+
*(("--name", session_name) if self.name == "claude" and session_name else ()),
|
|
90
|
+
*((session_id or "") if arg == "{session_id}" else arg for arg in arguments),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def codex_preset() -> NativePreset:
|
|
95
|
+
return native_preset("codex")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def native_preset(name: str) -> NativePreset:
|
|
99
|
+
if name not in {"codex", "claude"}:
|
|
100
|
+
raise NativeError("Unsupported native reviewer preset")
|
|
101
|
+
data = tomllib.loads(files("diffbutler.reviewers").joinpath("presets.toml").read_text())[name]
|
|
102
|
+
return NativePreset(
|
|
103
|
+
tuple(data["background"]),
|
|
104
|
+
tuple(data["background_resume"]),
|
|
105
|
+
tuple(data["foreground"]),
|
|
106
|
+
name,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def stop_process_group(pid: int, *, grace_s: float = 2.0) -> None:
|
|
111
|
+
"""Let owned agents release native resources before forcing remaining members out."""
|
|
112
|
+
try:
|
|
113
|
+
os.killpg(pid, signal.SIGTERM)
|
|
114
|
+
except ProcessLookupError:
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
async with asyncio.timeout(grace_s):
|
|
118
|
+
while True:
|
|
119
|
+
await asyncio.sleep(0.05)
|
|
120
|
+
try:
|
|
121
|
+
os.killpg(pid, 0)
|
|
122
|
+
except ProcessLookupError:
|
|
123
|
+
return
|
|
124
|
+
except TimeoutError:
|
|
125
|
+
pass
|
|
126
|
+
finally:
|
|
127
|
+
with suppress(ProcessLookupError):
|
|
128
|
+
os.killpg(pid, signal.SIGKILL)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CapturedProcess:
|
|
132
|
+
"""Own a trusted CLI's process group and captured pipes, including spawn races.
|
|
133
|
+
|
|
134
|
+
Deliberately detached processes are outside this native CLI supervision model.
|
|
135
|
+
EOF never releases ownership: stop_and_wait must first stop and reap the group.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self) -> None:
|
|
139
|
+
self.spawn: asyncio.Task[asyncio.subprocess.Process] | None = None
|
|
140
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
141
|
+
self.readers: list[asyncio.Task[bytes]] = []
|
|
142
|
+
self.stdout = bytearray()
|
|
143
|
+
self.stderr = bytearray()
|
|
144
|
+
|
|
145
|
+
async def capture(
|
|
146
|
+
self,
|
|
147
|
+
argv: tuple[str, ...],
|
|
148
|
+
*,
|
|
149
|
+
cwd: Path,
|
|
150
|
+
env: Mapping[str, str],
|
|
151
|
+
input: bytes,
|
|
152
|
+
checkpoint: Callable[[str], Awaitable[None]] | None = None,
|
|
153
|
+
output_format: StreamFormat = CODEX_OUTPUT,
|
|
154
|
+
) -> ReviewOutput:
|
|
155
|
+
if self.spawn is not None:
|
|
156
|
+
raise NativeError("A process execution cannot be reused")
|
|
157
|
+
self.spawn = asyncio.create_task(
|
|
158
|
+
asyncio.create_subprocess_exec(
|
|
159
|
+
*argv,
|
|
160
|
+
cwd=cwd,
|
|
161
|
+
env=env,
|
|
162
|
+
stdin=asyncio.subprocess.PIPE,
|
|
163
|
+
stdout=asyncio.subprocess.PIPE,
|
|
164
|
+
stderr=asyncio.subprocess.PIPE,
|
|
165
|
+
start_new_session=True,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
self.process = await asyncio.shield(self.spawn)
|
|
169
|
+
process = self.process
|
|
170
|
+
assert process is not None
|
|
171
|
+
assert (
|
|
172
|
+
process.stdin is not None and process.stdout is not None and process.stderr is not None
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def collect(stream: asyncio.StreamReader, *, events: bool) -> bytes:
|
|
176
|
+
result = self.stdout if events else self.stderr
|
|
177
|
+
pending = bytearray()
|
|
178
|
+
while chunk := await stream.read(65536):
|
|
179
|
+
result.extend(chunk)
|
|
180
|
+
if events and checkpoint is not None:
|
|
181
|
+
pending.extend(chunk)
|
|
182
|
+
while b"\n" in pending:
|
|
183
|
+
line, _, rest = pending.partition(b"\n")
|
|
184
|
+
pending = bytearray(rest)
|
|
185
|
+
try:
|
|
186
|
+
event = cast(object, json.loads(line))
|
|
187
|
+
except ValueError:
|
|
188
|
+
continue # Strict final extraction rejects malformed output.
|
|
189
|
+
if isinstance(event, dict):
|
|
190
|
+
fields = cast(dict[str, object], event)
|
|
191
|
+
if fields.get("type") == output_format.session_type and (
|
|
192
|
+
output_format.session_filter_path is None
|
|
193
|
+
or fields.get(output_format.session_filter_path[0])
|
|
194
|
+
== output_format.session_filter_value
|
|
195
|
+
):
|
|
196
|
+
identity = fields.get(output_format.session_path[0])
|
|
197
|
+
if not isinstance(identity, str):
|
|
198
|
+
raise NativeError("Invalid native conversation identity")
|
|
199
|
+
await checkpoint(identity)
|
|
200
|
+
return bytes(result)
|
|
201
|
+
|
|
202
|
+
self.readers = [
|
|
203
|
+
asyncio.create_task(collect(process.stdout, events=True)),
|
|
204
|
+
asyncio.create_task(collect(process.stderr, events=False)),
|
|
205
|
+
]
|
|
206
|
+
try:
|
|
207
|
+
process.stdin.write(input)
|
|
208
|
+
await process.stdin.drain()
|
|
209
|
+
except BrokenPipeError, ConnectionResetError:
|
|
210
|
+
pass
|
|
211
|
+
finally:
|
|
212
|
+
process.stdin.close()
|
|
213
|
+
stdout, stderr = await asyncio.gather(*self.readers)
|
|
214
|
+
code = await process.wait()
|
|
215
|
+
logging.getLogger(__name__).info("Background agent exited code=%d", code)
|
|
216
|
+
return ReviewOutput(stdout, stderr, code)
|
|
217
|
+
|
|
218
|
+
async def stop_and_wait(self) -> None:
|
|
219
|
+
if self.spawn is None:
|
|
220
|
+
return
|
|
221
|
+
try:
|
|
222
|
+
self.process = await asyncio.shield(self.spawn)
|
|
223
|
+
except OSError:
|
|
224
|
+
return # Spawn failed; no process exists.
|
|
225
|
+
process = self.process
|
|
226
|
+
assert process is not None
|
|
227
|
+
await stop_process_group(process.pid)
|
|
228
|
+
for reader in self.readers:
|
|
229
|
+
reader.cancel()
|
|
230
|
+
await asyncio.gather(*self.readers, return_exceptions=True)
|
|
231
|
+
|
|
232
|
+
async def drain(stream: asyncio.StreamReader | None, target: bytearray) -> None:
|
|
233
|
+
if stream is not None:
|
|
234
|
+
while chunk := await stream.read(65536):
|
|
235
|
+
target.extend(chunk)
|
|
236
|
+
|
|
237
|
+
await asyncio.gather(drain(process.stdout, self.stdout), drain(process.stderr, self.stderr))
|
|
238
|
+
await process.wait()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Parse captured review text without executing a backend or changing queue state."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from typing import Annotated, Literal, cast
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from diffbutler.domain.review import Side, SourceFile
|
|
10
|
+
from diffbutler.reviewers.base import ReviewOutput
|
|
11
|
+
from diffbutler.reviewers.extraction import OutputFormat, backend_failure_reason, extract_output
|
|
12
|
+
|
|
13
|
+
Text = Annotated[str, Field(min_length=1, pattern=r"\S")]
|
|
14
|
+
Line = Annotated[int, Field(gt=0)]
|
|
15
|
+
Severity = Literal["blocking", "important", "minor", "nit", "question"]
|
|
16
|
+
Category = Literal["correctness", "security", "performance", "api", "tests", "style", "docs"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Record(BaseModel):
|
|
20
|
+
model_config = ConfigDict(frozen=True, strict=True, extra="forbid")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Location(_Record):
|
|
24
|
+
path: Text
|
|
25
|
+
line: Line
|
|
26
|
+
|
|
27
|
+
@field_validator("path")
|
|
28
|
+
@classmethod
|
|
29
|
+
def relative_path(cls, value: str) -> str:
|
|
30
|
+
SourceFile(value, Side.HEAD)
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LookHere(Location):
|
|
35
|
+
why: Text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Orientation(_Record):
|
|
39
|
+
intent: Text
|
|
40
|
+
shape: Text
|
|
41
|
+
look_here: tuple[LookHere, ...]
|
|
42
|
+
low_risk: tuple[Text, ...]
|
|
43
|
+
unverified: tuple[Text, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Finding(Location):
|
|
47
|
+
start_line: Line | None = None
|
|
48
|
+
side: Literal["LEFT", "RIGHT"]
|
|
49
|
+
severity: Severity
|
|
50
|
+
category: Category
|
|
51
|
+
title: Text
|
|
52
|
+
body: Text
|
|
53
|
+
suggestion: str | None = None
|
|
54
|
+
confidence: float = Field(ge=0, le=1, allow_inf_nan=False)
|
|
55
|
+
|
|
56
|
+
@model_validator(mode="after")
|
|
57
|
+
def ordered_range(self) -> Finding:
|
|
58
|
+
if self.start_line is not None and self.start_line > self.line:
|
|
59
|
+
raise ValueError("finding range is reversed")
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ReviewDocument(_Record):
|
|
64
|
+
orientation: Orientation
|
|
65
|
+
findings: tuple[Finding, ...]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DraftReview(_Record):
|
|
69
|
+
"""Human-readable analysis retained independently of raw backend transcripts."""
|
|
70
|
+
|
|
71
|
+
markdown: Text
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def no_findings(self) -> bool:
|
|
75
|
+
"""Only an explicit, unambiguous leading outcome establishes a clean review."""
|
|
76
|
+
lines = self.markdown.strip().splitlines()
|
|
77
|
+
outcomes = [line for line in lines if line.strip().startswith("Review outcome:")]
|
|
78
|
+
return lines[0] == "Review outcome: no_findings" and len(outcomes) == 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
type ResponseFormat = Literal["json", "markdown"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class ParsedReview:
|
|
86
|
+
"""Raw bytes remain available on failure; findings are never silently dropped.
|
|
87
|
+
|
|
88
|
+
Locations are only structurally valid. DiffIndex validation is still required
|
|
89
|
+
before any anchor can be considered commentable.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
output: ReviewOutput
|
|
93
|
+
document: ReviewDocument | None
|
|
94
|
+
tier: Literal["json", "fence", "object", "markdown"] | None
|
|
95
|
+
notes: tuple[str, ...] = ()
|
|
96
|
+
error: str | None = None
|
|
97
|
+
draft: DraftReview | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _InvalidJSON(ValueError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
105
|
+
result: dict[str, object] = {}
|
|
106
|
+
for key, value in pairs:
|
|
107
|
+
if key in result:
|
|
108
|
+
raise _InvalidJSON("duplicate key")
|
|
109
|
+
result[key] = value
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _constant(value: str) -> object:
|
|
114
|
+
raise _InvalidJSON("nonfinite number")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load(text: str) -> object:
|
|
118
|
+
return json.loads(text, object_pairs_hook=_object, parse_constant=_constant)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _outer_object(text: str) -> str | None:
|
|
122
|
+
"""Extract the first outer object, respecting quoted braces and escapes."""
|
|
123
|
+
start = text.find("{")
|
|
124
|
+
if start < 0:
|
|
125
|
+
return None
|
|
126
|
+
depth = 0
|
|
127
|
+
quoted = escaped = False
|
|
128
|
+
for offset in range(start, len(text)):
|
|
129
|
+
char = text[offset]
|
|
130
|
+
if quoted:
|
|
131
|
+
if escaped:
|
|
132
|
+
escaped = False
|
|
133
|
+
elif char == "\\":
|
|
134
|
+
escaped = True
|
|
135
|
+
elif char == '"':
|
|
136
|
+
quoted = False
|
|
137
|
+
elif char == '"':
|
|
138
|
+
quoted = True
|
|
139
|
+
elif char == "{":
|
|
140
|
+
depth += 1
|
|
141
|
+
elif char == "}":
|
|
142
|
+
depth -= 1
|
|
143
|
+
if depth == 0:
|
|
144
|
+
return text[start : offset + 1]
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _last_fence(text: str) -> str | None:
|
|
149
|
+
# Scan once, including an unfinished final fence; never select an older answer.
|
|
150
|
+
lines: list[str] | None = None
|
|
151
|
+
candidate: str | None = None
|
|
152
|
+
for line in text.splitlines(keepends=True):
|
|
153
|
+
marker = line.rstrip(" \t\r\n")
|
|
154
|
+
if marker == "```json":
|
|
155
|
+
lines = []
|
|
156
|
+
elif marker == "```" and lines is not None:
|
|
157
|
+
candidate = "".join(lines)
|
|
158
|
+
lines = None
|
|
159
|
+
elif lines is not None:
|
|
160
|
+
lines.append(line)
|
|
161
|
+
if lines is not None:
|
|
162
|
+
raise _InvalidJSON("unfinished final fence")
|
|
163
|
+
return candidate
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _normalize(value: object) -> tuple[object, tuple[str, ...]]:
|
|
167
|
+
notes: list[str] = []
|
|
168
|
+
if isinstance(value, dict):
|
|
169
|
+
root = cast(dict[str, object], value)
|
|
170
|
+
findings = root.get("findings")
|
|
171
|
+
if isinstance(findings, list):
|
|
172
|
+
for index, item in enumerate(cast(list[object], findings)):
|
|
173
|
+
if not isinstance(item, dict):
|
|
174
|
+
continue
|
|
175
|
+
finding = cast(dict[str, object], item)
|
|
176
|
+
for key, choices, fallback in (
|
|
177
|
+
("severity", {"blocking", "important", "minor", "nit", "question"}, "minor"),
|
|
178
|
+
(
|
|
179
|
+
"category",
|
|
180
|
+
{"correctness", "security", "performance", "api", "tests", "style", "docs"},
|
|
181
|
+
"style",
|
|
182
|
+
),
|
|
183
|
+
):
|
|
184
|
+
label = finding.get(key)
|
|
185
|
+
if isinstance(label, str) and label not in choices:
|
|
186
|
+
finding[key] = fallback
|
|
187
|
+
notes.append(f"finding {index + 1}: unknown {key} normalized to {fallback}")
|
|
188
|
+
return cast(object, value), tuple(notes)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_review(
|
|
192
|
+
output: ReviewOutput,
|
|
193
|
+
*,
|
|
194
|
+
output_format: OutputFormat | None = None,
|
|
195
|
+
response_format: ResponseFormat = "json",
|
|
196
|
+
) -> ParsedReview:
|
|
197
|
+
"""Parse assistant text or a declaratively selected backend envelope/stream.
|
|
198
|
+
|
|
199
|
+
Markdown is retained as written, without inferring issue counts or severity.
|
|
200
|
+
Legacy JSON schema errors fail the entire result.
|
|
201
|
+
Diagnostics contain no backend text; raw stdout/stderr stay in the result.
|
|
202
|
+
"""
|
|
203
|
+
if output.exit_code != 0:
|
|
204
|
+
return ParsedReview(
|
|
205
|
+
output,
|
|
206
|
+
None,
|
|
207
|
+
None,
|
|
208
|
+
error=backend_failure_reason(output, output_format)
|
|
209
|
+
or (
|
|
210
|
+
f"reviewer exited unsuccessfully (exit code {output.exit_code}); "
|
|
211
|
+
"inspect captured output"
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
payload = output.stdout
|
|
215
|
+
if output_format is not None:
|
|
216
|
+
extracted = extract_output(output, output_format)
|
|
217
|
+
output = replace(output, backend_session_id=extracted.backend_session_id)
|
|
218
|
+
if extracted.error is not None or extracted.text is None:
|
|
219
|
+
return ParsedReview(output, None, None, error=extracted.error)
|
|
220
|
+
payload = extracted.text
|
|
221
|
+
try:
|
|
222
|
+
text = payload.decode("utf-8")
|
|
223
|
+
except UnicodeDecodeError:
|
|
224
|
+
return ParsedReview(output, None, None, error="output is not UTF-8")
|
|
225
|
+
if response_format == "markdown":
|
|
226
|
+
if not text.strip():
|
|
227
|
+
return ParsedReview(output, None, None, error="empty review response")
|
|
228
|
+
return ParsedReview(output, None, "markdown", draft=DraftReview(markdown=text))
|
|
229
|
+
tier: Literal["json", "fence", "object"] = "json"
|
|
230
|
+
try:
|
|
231
|
+
try:
|
|
232
|
+
value = _load(text)
|
|
233
|
+
except json.JSONDecodeError:
|
|
234
|
+
candidate = _last_fence(text)
|
|
235
|
+
tier = "fence"
|
|
236
|
+
try:
|
|
237
|
+
value = _load(candidate) if candidate is not None else _load("")
|
|
238
|
+
except json.JSONDecodeError:
|
|
239
|
+
# Do not recover an earlier clean answer from a malformed final fence.
|
|
240
|
+
candidate = _outer_object(candidate if candidate is not None else text)
|
|
241
|
+
tier = "object"
|
|
242
|
+
value = _load(candidate or "")
|
|
243
|
+
value, notes = _normalize(value)
|
|
244
|
+
document = ReviewDocument.model_validate_json(json.dumps(value))
|
|
245
|
+
except ValueError, RecursionError:
|
|
246
|
+
return ParsedReview(output, None, None, error="invalid review document")
|
|
247
|
+
return ParsedReview(output, document, tier, notes)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Native CLI arguments; command assembly is covered by offline acceptance tests.
|
|
2
|
+
[codex]
|
|
3
|
+
background = ["-a", "never", "-s", "read-only", "exec", "--json", "--skip-git-repo-check", "-"]
|
|
4
|
+
background_resume = ["-a", "never", "-s", "read-only", "exec", "resume", "--json", "--skip-git-repo-check", "{session_id}", "-"]
|
|
5
|
+
foreground = ["-a", "on-request", "resume", "{session_id}"]
|
|
6
|
+
|
|
7
|
+
# Bash uses native auto permission checks; do not blanket-allow it.
|
|
8
|
+
# Keep normal user configuration; override tools and permissions per launch mode.
|
|
9
|
+
[claude]
|
|
10
|
+
background = ["--print", "--output-format", "stream-json", "--verbose", "--tools", "Read,Glob,Grep,Bash", "--allowedTools", "Read,Glob,Grep", "--permission-mode", "auto", "--permission-prompts", "none"]
|
|
11
|
+
background_resume = ["--print", "--output-format", "stream-json", "--verbose", "--tools", "Read,Glob,Grep,Bash", "--allowedTools", "Read,Glob,Grep", "--permission-mode", "auto", "--permission-prompts", "none", "--resume", "{session_id}"]
|
|
12
|
+
foreground = ["--tools", "default", "--permission-mode", "auto", "--resume", "{session_id}"]
|