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.
Files changed (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. 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}"]