debugbrief 1.1.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.
- debugbrief/__init__.py +13 -0
- debugbrief/__main__.py +10 -0
- debugbrief/cli.py +843 -0
- debugbrief/command_runner.py +233 -0
- debugbrief/derive.py +353 -0
- debugbrief/doctor.py +324 -0
- debugbrief/filters.py +280 -0
- debugbrief/git_utils.py +268 -0
- debugbrief/models.py +320 -0
- debugbrief/paths.py +130 -0
- debugbrief/redaction.py +103 -0
- debugbrief/reporters/__init__.py +108 -0
- debugbrief/reporters/base.py +396 -0
- debugbrief/reporters/handoff.py +96 -0
- debugbrief/reporters/incident.py +99 -0
- debugbrief/reporters/pr.py +28 -0
- debugbrief/reports_index.py +50 -0
- debugbrief/session_manager.py +361 -0
- debugbrief/sessions_index.py +78 -0
- debugbrief/utils.py +151 -0
- debugbrief-1.1.0.dist-info/METADATA +143 -0
- debugbrief-1.1.0.dist-info/RECORD +26 -0
- debugbrief-1.1.0.dist-info/WHEEL +5 -0
- debugbrief-1.1.0.dist-info/entry_points.txt +2 -0
- debugbrief-1.1.0.dist-info/licenses/LICENSE +21 -0
- debugbrief-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Execute commands via subprocess and capture honest, bounded results.
|
|
2
|
+
|
|
3
|
+
The runner never fakes an exit code and never claims success it did not observe.
|
|
4
|
+
While the command runs, its stdout and stderr stream live to the user's own
|
|
5
|
+
terminal, line by line and unmodified, so a test run or build behaves exactly as
|
|
6
|
+
it would outside DebugBrief. The full output is accumulated in parallel and
|
|
7
|
+
stored as bounded previews (not full logs), explicitly flagged when truncated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import shlex
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import IO, List, Optional
|
|
21
|
+
|
|
22
|
+
from . import filters
|
|
23
|
+
from .models import CommandData
|
|
24
|
+
from .redaction import redact_text
|
|
25
|
+
from .utils import (
|
|
26
|
+
DEFAULT_STDERR_PREVIEW_LIMIT,
|
|
27
|
+
DEFAULT_STDOUT_PREVIEW_LIMIT,
|
|
28
|
+
now_iso8601,
|
|
29
|
+
truncate_text,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DEFAULT_TIMEOUT_SECONDS = 300
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RunResult:
|
|
37
|
+
"""The outcome of running one command, ready to persist and report."""
|
|
38
|
+
|
|
39
|
+
command_data: CommandData
|
|
40
|
+
timed_out: bool
|
|
41
|
+
errored: bool
|
|
42
|
+
error_message: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def propagated_exit_code(self) -> int:
|
|
46
|
+
"""Exit code DebugBrief should return to its own caller.
|
|
47
|
+
|
|
48
|
+
Real exit codes pass through; timeouts/errors map to a nonzero code so
|
|
49
|
+
callers and scripts see failure.
|
|
50
|
+
"""
|
|
51
|
+
code = self.command_data.exit_code
|
|
52
|
+
if code is None:
|
|
53
|
+
return 1
|
|
54
|
+
return code
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pump_stream(
|
|
58
|
+
stream: IO[str], echo_to: Optional[IO[str]], chunks: List[str]
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Drain ``stream`` line by line, echoing live and accumulating.
|
|
61
|
+
|
|
62
|
+
Runs on a daemon reader thread, one per pipe. Lines are passed through to
|
|
63
|
+
``echo_to`` unmodified (it is the user's own terminal) and appended to
|
|
64
|
+
``chunks`` for the stored preview. A broken or closed echo target stops the
|
|
65
|
+
echo but never the capture.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
for line in iter(stream.readline, ""):
|
|
69
|
+
chunks.append(line)
|
|
70
|
+
if echo_to is not None:
|
|
71
|
+
try:
|
|
72
|
+
echo_to.write(line)
|
|
73
|
+
echo_to.flush()
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
echo_to = None
|
|
76
|
+
finally:
|
|
77
|
+
with contextlib.suppress(OSError):
|
|
78
|
+
stream.close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_command(
|
|
82
|
+
command: str,
|
|
83
|
+
cwd: Path,
|
|
84
|
+
use_shell: bool = False,
|
|
85
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
86
|
+
stdout_limit: int = DEFAULT_STDOUT_PREVIEW_LIMIT,
|
|
87
|
+
stderr_limit: int = DEFAULT_STDERR_PREVIEW_LIMIT,
|
|
88
|
+
redact: bool = True,
|
|
89
|
+
echo: bool = True,
|
|
90
|
+
) -> RunResult:
|
|
91
|
+
"""Run ``command`` from ``cwd`` and capture a :class:`CommandData`.
|
|
92
|
+
|
|
93
|
+
When ``use_shell`` is False (default), the command is parsed with
|
|
94
|
+
``shlex.split`` and executed without a shell. When ``use_shell`` is True,
|
|
95
|
+
the command runs through the system shell (shell features allowed).
|
|
96
|
+
|
|
97
|
+
While the command runs its stdout and stderr are echoed live to the
|
|
98
|
+
corresponding ``sys`` streams (disable with ``echo=False``). The echo is the
|
|
99
|
+
raw output; only the stored previews are redacted.
|
|
100
|
+
|
|
101
|
+
By default captured output and the command string are passed through
|
|
102
|
+
best-effort secret redaction before they are returned, so raw secrets never
|
|
103
|
+
reach the session file. Pass ``redact=False`` to store the raw text.
|
|
104
|
+
"""
|
|
105
|
+
started_at = now_iso8601()
|
|
106
|
+
start_monotonic = time.monotonic()
|
|
107
|
+
|
|
108
|
+
timed_out = False
|
|
109
|
+
errored = False
|
|
110
|
+
error_message: Optional[str] = None
|
|
111
|
+
exit_code: Optional[int] = None
|
|
112
|
+
stdout_text = ""
|
|
113
|
+
stderr_text = ""
|
|
114
|
+
|
|
115
|
+
popen_args: object
|
|
116
|
+
if use_shell:
|
|
117
|
+
popen_args = command
|
|
118
|
+
else:
|
|
119
|
+
try:
|
|
120
|
+
parsed: List[str] = shlex.split(command)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
parsed = []
|
|
123
|
+
errored = True
|
|
124
|
+
error_message = f"Could not parse command: {exc}"
|
|
125
|
+
if not parsed and not errored:
|
|
126
|
+
errored = True
|
|
127
|
+
error_message = "Empty command."
|
|
128
|
+
popen_args = parsed
|
|
129
|
+
|
|
130
|
+
process: Optional["subprocess.Popen[str]"] = None
|
|
131
|
+
if not errored:
|
|
132
|
+
try:
|
|
133
|
+
process = subprocess.Popen(
|
|
134
|
+
popen_args,
|
|
135
|
+
cwd=str(cwd),
|
|
136
|
+
shell=use_shell,
|
|
137
|
+
stdout=subprocess.PIPE,
|
|
138
|
+
stderr=subprocess.PIPE,
|
|
139
|
+
text=True,
|
|
140
|
+
errors="replace",
|
|
141
|
+
bufsize=1,
|
|
142
|
+
)
|
|
143
|
+
except FileNotFoundError as exc:
|
|
144
|
+
errored = True
|
|
145
|
+
exit_code = None
|
|
146
|
+
error_message = f"Command not found: {exc.filename or command}"
|
|
147
|
+
except PermissionError as exc:
|
|
148
|
+
errored = True
|
|
149
|
+
exit_code = None
|
|
150
|
+
error_message = f"Permission denied: {exc.filename or command}"
|
|
151
|
+
except OSError as exc: # pragma: no cover - defensive
|
|
152
|
+
errored = True
|
|
153
|
+
exit_code = None
|
|
154
|
+
error_message = f"Failed to execute command: {exc}"
|
|
155
|
+
|
|
156
|
+
if process is not None:
|
|
157
|
+
stdout_chunks: List[str] = []
|
|
158
|
+
stderr_chunks: List[str] = []
|
|
159
|
+
readers = [
|
|
160
|
+
threading.Thread(
|
|
161
|
+
target=_pump_stream,
|
|
162
|
+
args=(process.stdout, sys.stdout if echo else None, stdout_chunks),
|
|
163
|
+
daemon=True,
|
|
164
|
+
),
|
|
165
|
+
threading.Thread(
|
|
166
|
+
target=_pump_stream,
|
|
167
|
+
args=(process.stderr, sys.stderr if echo else None, stderr_chunks),
|
|
168
|
+
daemon=True,
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
for reader in readers:
|
|
172
|
+
reader.start()
|
|
173
|
+
try:
|
|
174
|
+
exit_code = process.wait(timeout=timeout_seconds)
|
|
175
|
+
# Normal exit: drain whatever is left in the pipes before moving on.
|
|
176
|
+
for reader in readers:
|
|
177
|
+
reader.join()
|
|
178
|
+
except subprocess.TimeoutExpired:
|
|
179
|
+
timed_out = True
|
|
180
|
+
exit_code = None
|
|
181
|
+
process.kill()
|
|
182
|
+
process.wait()
|
|
183
|
+
# Join briefly and keep whatever partial output was accumulated.
|
|
184
|
+
for reader in readers:
|
|
185
|
+
reader.join(timeout=2.0)
|
|
186
|
+
error_message = f"Command timed out after {timeout_seconds}s."
|
|
187
|
+
stdout_text = "".join(stdout_chunks)
|
|
188
|
+
stderr_text = "".join(stderr_chunks)
|
|
189
|
+
|
|
190
|
+
ended_at = now_iso8601()
|
|
191
|
+
duration = round(time.monotonic() - start_monotonic, 3)
|
|
192
|
+
|
|
193
|
+
stdout_preview, stdout_truncated = truncate_text(stdout_text, stdout_limit)
|
|
194
|
+
stderr_preview, stderr_truncated = truncate_text(stderr_text, stderr_limit)
|
|
195
|
+
|
|
196
|
+
# Classification is derived from the command tokens and the real exit code,
|
|
197
|
+
# so it is computed before redaction may alter the stored command string.
|
|
198
|
+
classification = filters.classify_command(
|
|
199
|
+
command=command,
|
|
200
|
+
exit_code=exit_code,
|
|
201
|
+
timed_out=timed_out,
|
|
202
|
+
errored=errored,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
stored_command = command
|
|
206
|
+
redacted = False
|
|
207
|
+
if redact:
|
|
208
|
+
stored_command, n_cmd = redact_text(command)
|
|
209
|
+
stdout_preview, n_out = redact_text(stdout_preview)
|
|
210
|
+
stderr_preview, n_err = redact_text(stderr_preview)
|
|
211
|
+
redacted = (n_cmd + n_out + n_err) > 0
|
|
212
|
+
|
|
213
|
+
command_data = CommandData(
|
|
214
|
+
command=stored_command,
|
|
215
|
+
started_at=started_at,
|
|
216
|
+
ended_at=ended_at,
|
|
217
|
+
duration_seconds=duration,
|
|
218
|
+
exit_code=exit_code,
|
|
219
|
+
stdout_preview=stdout_preview,
|
|
220
|
+
stderr_preview=stderr_preview,
|
|
221
|
+
stdout_truncated=stdout_truncated,
|
|
222
|
+
stderr_truncated=stderr_truncated,
|
|
223
|
+
used_shell=use_shell,
|
|
224
|
+
classification=classification,
|
|
225
|
+
redacted=redacted,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return RunResult(
|
|
229
|
+
command_data=command_data,
|
|
230
|
+
timed_out=timed_out,
|
|
231
|
+
errored=errored,
|
|
232
|
+
error_message=error_message,
|
|
233
|
+
)
|
debugbrief/derive.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Deterministic derivations shared by every report mode.
|
|
2
|
+
|
|
3
|
+
Everything here is computed only from recorded events. Nothing asserts a cause,
|
|
4
|
+
and no value is invented: anything the data cannot support is left as ``None``
|
|
5
|
+
or an empty list so the reporters can omit the corresponding section.
|
|
6
|
+
|
|
7
|
+
The reporters used to restate counts and echo notes. These derivations instead
|
|
8
|
+
reconstruct the shape of the investigation (what failed, what then passed, what
|
|
9
|
+
changed in between, what was ruled out) strictly from evidence.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from .models import (
|
|
19
|
+
COMMAND_STATUS_ERROR,
|
|
20
|
+
COMMAND_STATUS_FAILED,
|
|
21
|
+
COMMAND_STATUS_PASSED,
|
|
22
|
+
COMMAND_STATUS_TIMED_OUT,
|
|
23
|
+
CommandData,
|
|
24
|
+
Session,
|
|
25
|
+
)
|
|
26
|
+
from .utils import human_duration, parse_iso8601
|
|
27
|
+
|
|
28
|
+
_FAIL_STATUSES = (
|
|
29
|
+
COMMAND_STATUS_FAILED,
|
|
30
|
+
COMMAND_STATUS_TIMED_OUT,
|
|
31
|
+
COMMAND_STATUS_ERROR,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Cap how much of an error line we quote verbatim.
|
|
35
|
+
_OBSERVED_ERROR_LIMIT = 300
|
|
36
|
+
|
|
37
|
+
# Phrases that mark a recorded note as forward-looking (used for handoff steps).
|
|
38
|
+
# Matched on word boundaries so "retry" does not look like "try".
|
|
39
|
+
_NEXT_STEP_HINTS = (
|
|
40
|
+
"next",
|
|
41
|
+
"todo",
|
|
42
|
+
"to do",
|
|
43
|
+
"try",
|
|
44
|
+
"should",
|
|
45
|
+
"need to",
|
|
46
|
+
"needs to",
|
|
47
|
+
"follow up",
|
|
48
|
+
"follow-up",
|
|
49
|
+
"investigate",
|
|
50
|
+
"check",
|
|
51
|
+
)
|
|
52
|
+
_NEXT_STEP_RE = re.compile(
|
|
53
|
+
r"\b(?:" + "|".join(re.escape(h) for h in _NEXT_STEP_HINTS) + r")\b",
|
|
54
|
+
re.IGNORECASE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CommandRecord:
|
|
60
|
+
"""A single command event, flattened for derivation and reporting."""
|
|
61
|
+
|
|
62
|
+
command: str
|
|
63
|
+
timestamp: str
|
|
64
|
+
status: str
|
|
65
|
+
exit_code: Optional[int]
|
|
66
|
+
duration_seconds: float
|
|
67
|
+
is_test: bool
|
|
68
|
+
is_verification: bool
|
|
69
|
+
tool: Optional[str]
|
|
70
|
+
stderr_preview: str
|
|
71
|
+
changed_files: List[str]
|
|
72
|
+
head_sha: Optional[str]
|
|
73
|
+
redacted: bool
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def failed(self) -> bool:
|
|
77
|
+
return self.status in _FAIL_STATUSES
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def passed(self) -> bool:
|
|
81
|
+
return self.status == COMMAND_STATUS_PASSED
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_verification_candidate(self) -> bool:
|
|
85
|
+
"""A recognized test/build/lint/typecheck command, pass or fail.
|
|
86
|
+
|
|
87
|
+
``is_verification`` is only true when such a command passed; this stays
|
|
88
|
+
true regardless of outcome so a failing check still counts.
|
|
89
|
+
"""
|
|
90
|
+
return self.is_test or self.tool is not None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class RedToGreen:
|
|
95
|
+
command: str
|
|
96
|
+
failed_at: str
|
|
97
|
+
passed_at: str
|
|
98
|
+
window_seconds: float
|
|
99
|
+
changed_files: List[str]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Derivation:
|
|
104
|
+
one_liner: Optional[str] = None
|
|
105
|
+
reproduce_command: Optional[str] = None
|
|
106
|
+
verify_command: Optional[str] = None
|
|
107
|
+
red_to_green: Optional[RedToGreen] = None
|
|
108
|
+
observed_error: Optional[str] = None
|
|
109
|
+
ruled_out: List[CommandRecord] = field(default_factory=list)
|
|
110
|
+
redaction_applied: bool = False
|
|
111
|
+
command_records: List[CommandRecord] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _seconds(timestamp: str) -> float:
|
|
115
|
+
try:
|
|
116
|
+
return parse_iso8601(timestamp).timestamp()
|
|
117
|
+
except (ValueError, TypeError):
|
|
118
|
+
return 0.0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _records(session: Session) -> List[CommandRecord]:
|
|
122
|
+
records: List[CommandRecord] = []
|
|
123
|
+
for event in session.command_events():
|
|
124
|
+
data = CommandData.from_dict(event.data)
|
|
125
|
+
records.append(
|
|
126
|
+
CommandRecord(
|
|
127
|
+
command=data.command,
|
|
128
|
+
timestamp=event.timestamp,
|
|
129
|
+
status=data.classification.status,
|
|
130
|
+
exit_code=data.exit_code,
|
|
131
|
+
duration_seconds=data.duration_seconds,
|
|
132
|
+
is_test=data.classification.is_test,
|
|
133
|
+
is_verification=data.classification.is_verification,
|
|
134
|
+
tool=data.classification.tool,
|
|
135
|
+
stderr_preview=data.stderr_preview,
|
|
136
|
+
changed_files=list(data.git_changed_files),
|
|
137
|
+
head_sha=data.git_head,
|
|
138
|
+
redacted=data.redacted,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
records.sort(key=lambda r: _seconds(r.timestamp))
|
|
142
|
+
return records
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _session_span_seconds(session: Session) -> Optional[float]:
|
|
146
|
+
"""Total span from the first to the last recorded event, in seconds."""
|
|
147
|
+
stamps = [_seconds(e.timestamp) for e in session.events if e.timestamp]
|
|
148
|
+
stamps = [s for s in stamps if s > 0]
|
|
149
|
+
if len(stamps) < 2:
|
|
150
|
+
return None
|
|
151
|
+
span = max(stamps) - min(stamps)
|
|
152
|
+
return span if span > 0 else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _detect_red_to_green(
|
|
156
|
+
session: Session, records: List[CommandRecord]
|
|
157
|
+
) -> Optional[RedToGreen]:
|
|
158
|
+
first_fail: Optional[CommandRecord] = None
|
|
159
|
+
for rec in records:
|
|
160
|
+
if rec.is_verification_candidate and rec.failed:
|
|
161
|
+
first_fail = rec
|
|
162
|
+
break
|
|
163
|
+
if first_fail is None:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
fail_seconds = _seconds(first_fail.timestamp)
|
|
167
|
+
passed: Optional[CommandRecord] = None
|
|
168
|
+
for rec in records:
|
|
169
|
+
if (
|
|
170
|
+
rec.is_verification_candidate
|
|
171
|
+
and rec.passed
|
|
172
|
+
and _seconds(rec.timestamp) > fail_seconds
|
|
173
|
+
):
|
|
174
|
+
passed = rec
|
|
175
|
+
break
|
|
176
|
+
if passed is None:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
# Correlate file changes across the window from per-event snapshots. Only
|
|
180
|
+
# meaningful inside a repo; reported as correlation, never as cause.
|
|
181
|
+
if not session.git.is_repo:
|
|
182
|
+
return None
|
|
183
|
+
pass_seconds = _seconds(passed.timestamp)
|
|
184
|
+
changed: List[str] = []
|
|
185
|
+
for rec in records:
|
|
186
|
+
ts = _seconds(rec.timestamp)
|
|
187
|
+
if fail_seconds <= ts <= pass_seconds:
|
|
188
|
+
for path in rec.changed_files:
|
|
189
|
+
if path not in changed:
|
|
190
|
+
changed.append(path)
|
|
191
|
+
|
|
192
|
+
return RedToGreen(
|
|
193
|
+
command=passed.command,
|
|
194
|
+
failed_at=first_fail.timestamp,
|
|
195
|
+
passed_at=passed.timestamp,
|
|
196
|
+
window_seconds=max(0.0, pass_seconds - fail_seconds),
|
|
197
|
+
changed_files=sorted(changed),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extract_observed_error(records: List[CommandRecord]) -> Optional[str]:
|
|
202
|
+
"""Quote a single, real error line from a failed command's stderr.
|
|
203
|
+
|
|
204
|
+
Prefers the first failing verification command, then any failing command.
|
|
205
|
+
The text was already redacted at capture time.
|
|
206
|
+
"""
|
|
207
|
+
failing = [r for r in records if r.failed and r.stderr_preview.strip()]
|
|
208
|
+
candidates = [r for r in failing if r.is_verification_candidate]
|
|
209
|
+
candidates += [r for r in failing if not r.is_verification_candidate]
|
|
210
|
+
for rec in candidates:
|
|
211
|
+
line = _pick_error_line(rec.stderr_preview)
|
|
212
|
+
if line:
|
|
213
|
+
if len(line) > _OBSERVED_ERROR_LIMIT:
|
|
214
|
+
return line[:_OBSERVED_ERROR_LIMIT].rstrip() + " ..."
|
|
215
|
+
return line
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _pick_error_line(stderr: str) -> Optional[str]:
|
|
220
|
+
lines = [ln.strip() for ln in stderr.splitlines() if ln.strip()]
|
|
221
|
+
if not lines:
|
|
222
|
+
return None
|
|
223
|
+
# Prefer a line that looks like an assertion or error message.
|
|
224
|
+
for ln in reversed(lines):
|
|
225
|
+
lowered = ln.lower()
|
|
226
|
+
if (
|
|
227
|
+
"error" in lowered
|
|
228
|
+
or "assert" in lowered
|
|
229
|
+
or "exception" in lowered
|
|
230
|
+
or "traceback" in lowered
|
|
231
|
+
):
|
|
232
|
+
return ln
|
|
233
|
+
# Otherwise the last non-empty line.
|
|
234
|
+
return lines[-1]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _files_clause(session: Session, limit: int = 3) -> Optional[str]:
|
|
238
|
+
if not session.git.is_repo:
|
|
239
|
+
return None
|
|
240
|
+
files = list(session.summary.modified_files)
|
|
241
|
+
if not files:
|
|
242
|
+
return None
|
|
243
|
+
shown = files[:limit]
|
|
244
|
+
clause = ", ".join(shown)
|
|
245
|
+
extra = len(files) - len(shown)
|
|
246
|
+
if extra > 0:
|
|
247
|
+
clause += f", and {extra} more"
|
|
248
|
+
return clause
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _attempts_word(n: int) -> str:
|
|
252
|
+
return "attempt" if n == 1 else "attempts"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_one_liner(
|
|
256
|
+
session: Session,
|
|
257
|
+
records: List[CommandRecord],
|
|
258
|
+
red_to_green: Optional[RedToGreen],
|
|
259
|
+
) -> Optional[str]:
|
|
260
|
+
n = len(records)
|
|
261
|
+
span = _session_span_seconds(session)
|
|
262
|
+
duration = human_duration(span) if span is not None else None
|
|
263
|
+
files = _files_clause(session)
|
|
264
|
+
|
|
265
|
+
if red_to_green is not None:
|
|
266
|
+
parts = [f"Failing check `{red_to_green.command}` passed"]
|
|
267
|
+
parts.append(f"after {n} {_attempts_word(n)}")
|
|
268
|
+
if duration:
|
|
269
|
+
parts.append(f"over {duration}")
|
|
270
|
+
sentence = " ".join(parts)
|
|
271
|
+
if files:
|
|
272
|
+
sentence += f", changes touched {files}"
|
|
273
|
+
return sentence + "."
|
|
274
|
+
|
|
275
|
+
passed_verifications = [r for r in records if r.is_verification and r.passed]
|
|
276
|
+
failed_candidates = [r for r in records if r.is_verification_candidate and r.failed]
|
|
277
|
+
|
|
278
|
+
if passed_verifications:
|
|
279
|
+
cmd = passed_verifications[0].command
|
|
280
|
+
parts = [f"Verification `{cmd}` passed"]
|
|
281
|
+
parts.append(f"after {n} {_attempts_word(n)}")
|
|
282
|
+
if duration:
|
|
283
|
+
parts.append(f"over {duration}")
|
|
284
|
+
sentence = " ".join(parts)
|
|
285
|
+
if files:
|
|
286
|
+
sentence += f", changes touched {files}"
|
|
287
|
+
return sentence + "."
|
|
288
|
+
|
|
289
|
+
if failed_candidates:
|
|
290
|
+
cmd = failed_candidates[0].command
|
|
291
|
+
lead = f"Recorded {n} command {_attempts_word(n)}"
|
|
292
|
+
if duration:
|
|
293
|
+
lead += f" over {duration}"
|
|
294
|
+
return f"{lead}; verification `{cmd}` failed and none passed."
|
|
295
|
+
|
|
296
|
+
if n > 0:
|
|
297
|
+
lead = f"Recorded {n} command {_attempts_word(n)}"
|
|
298
|
+
if duration:
|
|
299
|
+
lead += f" over {duration}"
|
|
300
|
+
return f"{lead}; no verification commands were run."
|
|
301
|
+
|
|
302
|
+
notes = len(session.note_events())
|
|
303
|
+
if notes:
|
|
304
|
+
word = "note" if notes == 1 else "notes"
|
|
305
|
+
lead = f"Recorded {notes} {word}"
|
|
306
|
+
if duration:
|
|
307
|
+
lead += f" over {duration}"
|
|
308
|
+
return f"{lead}; no commands were run."
|
|
309
|
+
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def next_step_notes(session: Session) -> List[str]:
|
|
314
|
+
"""Return recorded notes that read as forward-looking next steps.
|
|
315
|
+
|
|
316
|
+
Used by the handoff report so its next-steps section is drawn only from what
|
|
317
|
+
the human actually wrote, never inferred.
|
|
318
|
+
"""
|
|
319
|
+
out: List[str] = []
|
|
320
|
+
for event in session.note_events():
|
|
321
|
+
text = (event.data or {}).get("text", "").strip()
|
|
322
|
+
if not text:
|
|
323
|
+
continue
|
|
324
|
+
if _NEXT_STEP_RE.search(text):
|
|
325
|
+
out.append(text)
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def derive(session: Session) -> Derivation:
|
|
330
|
+
records = _records(session)
|
|
331
|
+
red_to_green = _detect_red_to_green(session, records)
|
|
332
|
+
|
|
333
|
+
reproduce = next(
|
|
334
|
+
(r.command for r in records if r.is_verification_candidate and r.failed), None
|
|
335
|
+
)
|
|
336
|
+
verify = next(
|
|
337
|
+
(r.command for r in records if r.is_verification_candidate and r.passed), None
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
notes_redacted = any(
|
|
341
|
+
bool((e.data or {}).get("redacted")) for e in session.note_events()
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return Derivation(
|
|
345
|
+
one_liner=_build_one_liner(session, records, red_to_green),
|
|
346
|
+
reproduce_command=reproduce,
|
|
347
|
+
verify_command=verify,
|
|
348
|
+
red_to_green=red_to_green,
|
|
349
|
+
observed_error=_extract_observed_error(records),
|
|
350
|
+
ruled_out=[r for r in records if r.failed],
|
|
351
|
+
redaction_applied=any(r.redacted for r in records) or notes_redacted,
|
|
352
|
+
command_records=records,
|
|
353
|
+
)
|