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,396 @@
|
|
|
1
|
+
"""Shared building blocks for markdown report generation.
|
|
2
|
+
|
|
3
|
+
A :class:`ReportContext` is computed once from a finalized session and contains
|
|
4
|
+
only deterministic, evidence-backed data. Reporters consume it to render their
|
|
5
|
+
mode-specific markdown. No reporter invents root causes, intent, or results.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from ..derive import Derivation, derive
|
|
14
|
+
from ..filters import ReportCommand, build_report_commands
|
|
15
|
+
from ..models import (
|
|
16
|
+
COMMAND_STATUS_ERROR,
|
|
17
|
+
COMMAND_STATUS_PASSED,
|
|
18
|
+
COMMAND_STATUS_TIMED_OUT,
|
|
19
|
+
CommandData,
|
|
20
|
+
EventType,
|
|
21
|
+
Session,
|
|
22
|
+
)
|
|
23
|
+
from ..utils import human_duration, parse_iso8601
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TimelineEntry:
|
|
28
|
+
timestamp: str
|
|
29
|
+
kind: str
|
|
30
|
+
text: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ReportContext:
|
|
35
|
+
session: Session
|
|
36
|
+
report_commands: List[ReportCommand] = field(default_factory=list)
|
|
37
|
+
failed_commands: List[ReportCommand] = field(default_factory=list)
|
|
38
|
+
verification_commands: List[ReportCommand] = field(default_factory=list)
|
|
39
|
+
test_commands: List[ReportCommand] = field(default_factory=list)
|
|
40
|
+
notes: List[Tuple[str, str]] = field(default_factory=list)
|
|
41
|
+
timeline: List[TimelineEntry] = field(default_factory=list)
|
|
42
|
+
derivation: Derivation = field(default_factory=Derivation)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _short_time(iso_timestamp: Optional[str]) -> str:
|
|
46
|
+
if not iso_timestamp:
|
|
47
|
+
return "unknown time"
|
|
48
|
+
try:
|
|
49
|
+
moment = parse_iso8601(iso_timestamp)
|
|
50
|
+
except (ValueError, TypeError):
|
|
51
|
+
return iso_timestamp
|
|
52
|
+
return moment.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _clock(iso_timestamp: Optional[str]) -> str:
|
|
56
|
+
if not iso_timestamp:
|
|
57
|
+
return "--:--:--"
|
|
58
|
+
try:
|
|
59
|
+
moment = parse_iso8601(iso_timestamp)
|
|
60
|
+
except (ValueError, TypeError):
|
|
61
|
+
return iso_timestamp
|
|
62
|
+
return moment.strftime("%H:%M:%S")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def status_label(status: str) -> str:
|
|
66
|
+
return {
|
|
67
|
+
COMMAND_STATUS_PASSED: "passed",
|
|
68
|
+
"failed": "failed",
|
|
69
|
+
COMMAND_STATUS_TIMED_OUT: "timed out",
|
|
70
|
+
COMMAND_STATUS_ERROR: "did not run",
|
|
71
|
+
}.get(status, status)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_context(session: Session) -> ReportContext:
|
|
75
|
+
command_events = session.command_events()
|
|
76
|
+
report_commands = build_report_commands(command_events)
|
|
77
|
+
|
|
78
|
+
failed = [rc for rc in report_commands if rc.failed]
|
|
79
|
+
verification = [rc for rc in report_commands if rc.is_verification]
|
|
80
|
+
tests = [rc for rc in report_commands if rc.is_test]
|
|
81
|
+
|
|
82
|
+
notes: List[Tuple[str, str]] = []
|
|
83
|
+
for event in session.note_events():
|
|
84
|
+
text = (event.data or {}).get("text", "").strip()
|
|
85
|
+
if text:
|
|
86
|
+
notes.append((event.timestamp, text))
|
|
87
|
+
|
|
88
|
+
timeline = _build_timeline(session)
|
|
89
|
+
|
|
90
|
+
return ReportContext(
|
|
91
|
+
session=session,
|
|
92
|
+
report_commands=report_commands,
|
|
93
|
+
failed_commands=failed,
|
|
94
|
+
verification_commands=verification,
|
|
95
|
+
test_commands=tests,
|
|
96
|
+
notes=notes,
|
|
97
|
+
timeline=timeline,
|
|
98
|
+
derivation=derive(session),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_timeline(session: Session) -> List[TimelineEntry]:
|
|
103
|
+
entries: List[TimelineEntry] = []
|
|
104
|
+
for event in session.events:
|
|
105
|
+
if event.type == EventType.NOTE.value:
|
|
106
|
+
text = (event.data or {}).get("text", "").strip()
|
|
107
|
+
if text:
|
|
108
|
+
entries.append(TimelineEntry(event.timestamp, "note", text))
|
|
109
|
+
elif event.type == EventType.COMMAND.value:
|
|
110
|
+
data = CommandData.from_dict(event.data)
|
|
111
|
+
label = status_label(data.classification.status)
|
|
112
|
+
exit_repr = "n/a" if data.exit_code is None else str(data.exit_code)
|
|
113
|
+
duration = f" [{_fmt_duration(data.duration_seconds)}]"
|
|
114
|
+
entries.append(
|
|
115
|
+
TimelineEntry(
|
|
116
|
+
event.timestamp,
|
|
117
|
+
"command",
|
|
118
|
+
f"`{data.command}` -> {label} (exit {exit_repr}){duration}",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
elif event.type == EventType.WARNING.value:
|
|
122
|
+
message = (event.data or {}).get("message", "").strip()
|
|
123
|
+
if message:
|
|
124
|
+
entries.append(TimelineEntry(event.timestamp, "warning", message))
|
|
125
|
+
elif event.type == EventType.SNAPSHOT.value:
|
|
126
|
+
phase = (event.data or {}).get("phase")
|
|
127
|
+
if phase == "start":
|
|
128
|
+
entries.append(
|
|
129
|
+
TimelineEntry(event.timestamp, "snapshot", "Session started.")
|
|
130
|
+
)
|
|
131
|
+
elif phase == "end":
|
|
132
|
+
entries.append(
|
|
133
|
+
TimelineEntry(event.timestamp, "snapshot", "Session ended.")
|
|
134
|
+
)
|
|
135
|
+
entries.sort(key=lambda e: _safe_seconds(e.timestamp))
|
|
136
|
+
return entries
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _safe_seconds(iso_timestamp: str) -> float:
|
|
140
|
+
try:
|
|
141
|
+
return parse_iso8601(iso_timestamp).timestamp()
|
|
142
|
+
except (ValueError, TypeError):
|
|
143
|
+
return 0.0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fmt_duration(seconds: float) -> str:
|
|
147
|
+
"""Compact per-command duration, e.g. ``0.25s`` or ``3s``."""
|
|
148
|
+
if seconds < 10:
|
|
149
|
+
return f"{seconds:g}s"
|
|
150
|
+
return human_duration(seconds)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BaseReporter:
|
|
154
|
+
"""Base class providing shared, reusable markdown section builders."""
|
|
155
|
+
|
|
156
|
+
mode = "base"
|
|
157
|
+
|
|
158
|
+
def __init__(self, context: ReportContext) -> None:
|
|
159
|
+
self.ctx = context
|
|
160
|
+
self.session = context.session
|
|
161
|
+
|
|
162
|
+
# Subclasses must implement render().
|
|
163
|
+
def render(self) -> str: # pragma: no cover - abstract
|
|
164
|
+
raise NotImplementedError
|
|
165
|
+
|
|
166
|
+
# Reusable sections -------------------------------------------------------
|
|
167
|
+
def title_line(self) -> str:
|
|
168
|
+
return f"# {self.session.title}"
|
|
169
|
+
|
|
170
|
+
def metadata_lines(self) -> List[str]:
|
|
171
|
+
s = self.session
|
|
172
|
+
lines = ["## Session metadata", ""]
|
|
173
|
+
lines.append(f"- **Session ID:** `{s.session_id}`")
|
|
174
|
+
lines.append(f"- **Status:** {s.status}")
|
|
175
|
+
lines.append(f"- **Project root:** `{s.project_root}`")
|
|
176
|
+
lines.append(f"- **Started:** {_short_time(s.timestamps.start)}")
|
|
177
|
+
lines.append(f"- **Ended:** {_short_time(s.timestamps.end)}")
|
|
178
|
+
if s.git.is_repo:
|
|
179
|
+
branch = s.git.branch or (
|
|
180
|
+
"(detached HEAD)" if s.git.detached_head else "(unknown)"
|
|
181
|
+
)
|
|
182
|
+
lines.append(f"- **Git branch:** {branch}")
|
|
183
|
+
lines.append(
|
|
184
|
+
f"- **Initial commit:** `{_sha(s.git.initial_sha)}` "
|
|
185
|
+
f"**Final commit:** `{_sha(s.git.final_sha)}`"
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
lines.append("- **Git:** not a Git repository")
|
|
189
|
+
lines.append(
|
|
190
|
+
f"- **Notes:** {s.summary.notes_count} "
|
|
191
|
+
f"**Commands:** {s.summary.commands_count} "
|
|
192
|
+
f"**Failed commands:** {s.summary.failed_commands_count}"
|
|
193
|
+
)
|
|
194
|
+
return lines
|
|
195
|
+
|
|
196
|
+
def warnings_section(self) -> List[str]:
|
|
197
|
+
warnings = self.session.warnings
|
|
198
|
+
capture = self.session.summary.command_capture_status
|
|
199
|
+
redacted = self.ctx.derivation.redaction_applied
|
|
200
|
+
if not warnings and capture == "full" and not redacted:
|
|
201
|
+
return []
|
|
202
|
+
lines = ["## Warnings and limitations", ""]
|
|
203
|
+
if capture != "full":
|
|
204
|
+
lines.append(
|
|
205
|
+
f"- Command capture status: **{capture}** "
|
|
206
|
+
"(some commands may not have been recorded)."
|
|
207
|
+
)
|
|
208
|
+
if redacted:
|
|
209
|
+
lines.append(
|
|
210
|
+
"- Secret-like values in captured output, commands, or notes "
|
|
211
|
+
"were replaced with `[redacted]`. Redaction is best effort and "
|
|
212
|
+
"conservative; it does not catch everything."
|
|
213
|
+
)
|
|
214
|
+
for warning in warnings:
|
|
215
|
+
lines.append(f"- {warning}")
|
|
216
|
+
return lines
|
|
217
|
+
|
|
218
|
+
_STATUS_WORDS = {
|
|
219
|
+
"M": "modified",
|
|
220
|
+
"A": "added",
|
|
221
|
+
"D": "deleted",
|
|
222
|
+
"R": "renamed",
|
|
223
|
+
"C": "copied",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
def changed_files_section(self) -> List[str]:
|
|
227
|
+
s = self.session
|
|
228
|
+
# Only render inside a repo and only when files actually changed. With
|
|
229
|
+
# no real content the section is omitted rather than padded.
|
|
230
|
+
if not s.git.is_repo:
|
|
231
|
+
return []
|
|
232
|
+
file_changes = s.summary.file_changes
|
|
233
|
+
files = s.summary.modified_files
|
|
234
|
+
if not file_changes and not files:
|
|
235
|
+
return []
|
|
236
|
+
lines = ["## Modified files", ""]
|
|
237
|
+
count = len(file_changes) if file_changes else len(files)
|
|
238
|
+
lines.append(
|
|
239
|
+
f"_{count} file(s) changed, "
|
|
240
|
+
f"+{s.summary.lines_added} / -{s.summary.lines_deleted} lines._"
|
|
241
|
+
)
|
|
242
|
+
lines.append("")
|
|
243
|
+
if file_changes:
|
|
244
|
+
for fc in file_changes:
|
|
245
|
+
word = self._STATUS_WORDS.get(fc.status, fc.status)
|
|
246
|
+
lines.append(f"- `{fc.status}` {word}: `{fc.path}`")
|
|
247
|
+
else:
|
|
248
|
+
for path in files:
|
|
249
|
+
lines.append(f"- `{path}`")
|
|
250
|
+
return lines
|
|
251
|
+
|
|
252
|
+
def verification_section(self) -> List[str]:
|
|
253
|
+
lines = ["## Verification and tests", ""]
|
|
254
|
+
verification = self.ctx.verification_commands
|
|
255
|
+
if not verification:
|
|
256
|
+
if self.ctx.test_commands:
|
|
257
|
+
lines.append(
|
|
258
|
+
"_Test/verification commands were run but none passed. "
|
|
259
|
+
"This work is **not** verified._"
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
lines.append(
|
|
263
|
+
"_No verification commands (test/build/lint/typecheck) "
|
|
264
|
+
"were run during this session._"
|
|
265
|
+
)
|
|
266
|
+
return lines
|
|
267
|
+
for rc in verification:
|
|
268
|
+
kind = "test" if rc.is_test else "check"
|
|
269
|
+
tool = f" ({rc.tool})" if rc.tool else ""
|
|
270
|
+
repeat = f" x{rc.count}" if rc.count > 1 else ""
|
|
271
|
+
lines.append(f"- [passed] {kind}{tool}: `{rc.command}`{repeat}")
|
|
272
|
+
return lines
|
|
273
|
+
|
|
274
|
+
def relevant_commands_section(
|
|
275
|
+
self, heading: str = "## Relevant commands"
|
|
276
|
+
) -> List[str]:
|
|
277
|
+
lines = [heading, ""]
|
|
278
|
+
commands = self.ctx.report_commands
|
|
279
|
+
if not commands:
|
|
280
|
+
lines.append("_No notable commands were recorded._")
|
|
281
|
+
return lines
|
|
282
|
+
for rc in commands:
|
|
283
|
+
repeat = f" x{rc.count}" if rc.count > 1 else ""
|
|
284
|
+
exit_repr = "n/a" if rc.exit_code is None else str(rc.exit_code)
|
|
285
|
+
lines.append(
|
|
286
|
+
f"- `{rc.command}`{repeat} -> {status_label(rc.status)} "
|
|
287
|
+
f"(exit {exit_repr})"
|
|
288
|
+
)
|
|
289
|
+
return lines
|
|
290
|
+
|
|
291
|
+
# Derived sections --------------------------------------------------------
|
|
292
|
+
def one_liner_section(self) -> List[str]:
|
|
293
|
+
one_liner = self.ctx.derivation.one_liner
|
|
294
|
+
if not one_liner:
|
|
295
|
+
return []
|
|
296
|
+
return ["## Summary", "", one_liner]
|
|
297
|
+
|
|
298
|
+
def reproduce_verify_section(self) -> List[str]:
|
|
299
|
+
d = self.ctx.derivation
|
|
300
|
+
if not d.reproduce_command and not d.verify_command:
|
|
301
|
+
return []
|
|
302
|
+
lines = ["## Reproduce and verify", ""]
|
|
303
|
+
if d.reproduce_command:
|
|
304
|
+
lines.append(f"- Reproduce (failed): `{d.reproduce_command}`")
|
|
305
|
+
if d.verify_command:
|
|
306
|
+
lines.append(f"- Verify (passed): `{d.verify_command}`")
|
|
307
|
+
return lines
|
|
308
|
+
|
|
309
|
+
def red_to_green_section(self) -> List[str]:
|
|
310
|
+
rtg = self.ctx.derivation.red_to_green
|
|
311
|
+
if rtg is None:
|
|
312
|
+
return []
|
|
313
|
+
lines = ["## Red to green", ""]
|
|
314
|
+
window = human_duration(rtg.window_seconds)
|
|
315
|
+
lines.append(
|
|
316
|
+
f"A check failed at `{_clock(rtg.failed_at)}` and `{rtg.command}` "
|
|
317
|
+
f"passed at `{_clock(rtg.passed_at)}` (window {window})."
|
|
318
|
+
)
|
|
319
|
+
if rtg.changed_files:
|
|
320
|
+
lines.append("")
|
|
321
|
+
lines.append(
|
|
322
|
+
"Between the failing and passing checks, these files changed "
|
|
323
|
+
"(correlation, not proven cause):"
|
|
324
|
+
)
|
|
325
|
+
for path in rtg.changed_files:
|
|
326
|
+
lines.append(f"- `{path}`")
|
|
327
|
+
else:
|
|
328
|
+
lines.append("")
|
|
329
|
+
lines.append(
|
|
330
|
+
"No tracked file changes were recorded across this window."
|
|
331
|
+
)
|
|
332
|
+
return lines
|
|
333
|
+
|
|
334
|
+
def timeline_section(
|
|
335
|
+
self, heading: str = "## Timeline", condensed: bool = False
|
|
336
|
+
) -> List[str]:
|
|
337
|
+
entries = self.ctx.timeline
|
|
338
|
+
if condensed:
|
|
339
|
+
entries = [e for e in entries if e.kind in ("note", "command", "warning")]
|
|
340
|
+
if not entries:
|
|
341
|
+
return []
|
|
342
|
+
lines = [heading, ""]
|
|
343
|
+
for entry in entries:
|
|
344
|
+
lines.append(f"- `{_clock(entry.timestamp)}` ({entry.kind}) {entry.text}")
|
|
345
|
+
return lines
|
|
346
|
+
|
|
347
|
+
def observed_error_section(self) -> List[str]:
|
|
348
|
+
error = self.ctx.derivation.observed_error
|
|
349
|
+
if not error:
|
|
350
|
+
return []
|
|
351
|
+
return [
|
|
352
|
+
"## Observed error",
|
|
353
|
+
"",
|
|
354
|
+
"Quoted verbatim from real command output:",
|
|
355
|
+
"",
|
|
356
|
+
"```",
|
|
357
|
+
error,
|
|
358
|
+
"```",
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
def ruled_out_section(self) -> List[str]:
|
|
362
|
+
ruled = self.ctx.derivation.ruled_out
|
|
363
|
+
if not ruled:
|
|
364
|
+
return []
|
|
365
|
+
lines = ["## What was ruled out", ""]
|
|
366
|
+
for rec in ruled:
|
|
367
|
+
exit_repr = "n/a" if rec.exit_code is None else str(rec.exit_code)
|
|
368
|
+
lines.append(
|
|
369
|
+
f"- `{rec.command}` -> {status_label(rec.status)} (exit {exit_repr})"
|
|
370
|
+
)
|
|
371
|
+
return lines
|
|
372
|
+
|
|
373
|
+
def footer(self) -> List[str]:
|
|
374
|
+
return [
|
|
375
|
+
"---",
|
|
376
|
+
"",
|
|
377
|
+
"_Generated by DebugBrief. This report is built from explicitly "
|
|
378
|
+
"recorded notes, executed commands, and Git state. DebugBrief does "
|
|
379
|
+
"not use AI and does not infer root causes, intent, or results._",
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _sha(value: Optional[str]) -> str:
|
|
384
|
+
if not value:
|
|
385
|
+
return "n/a"
|
|
386
|
+
return value[:12]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def join_sections(*blocks: List[str]) -> str:
|
|
390
|
+
"""Join section line-lists into a single markdown document."""
|
|
391
|
+
parts: List[str] = []
|
|
392
|
+
for block in blocks:
|
|
393
|
+
if not block:
|
|
394
|
+
continue
|
|
395
|
+
parts.append("\n".join(block))
|
|
396
|
+
return "\n\n".join(parts).rstrip() + "\n"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Handoff-mode report: hand a partially solved or tricky issue to someone else."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from ..derive import next_step_notes
|
|
8
|
+
from .base import BaseReporter, join_sections
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HandoffReporter(BaseReporter):
|
|
12
|
+
mode = "handoff"
|
|
13
|
+
|
|
14
|
+
def render(self) -> str:
|
|
15
|
+
return join_sections(
|
|
16
|
+
[self.title_line()],
|
|
17
|
+
self.one_liner_section(),
|
|
18
|
+
self.metadata_lines(),
|
|
19
|
+
self.warnings_section(),
|
|
20
|
+
self._current_status_section(),
|
|
21
|
+
self._hypotheses_section(),
|
|
22
|
+
self.timeline_section("## Timeline", condensed=False),
|
|
23
|
+
self.relevant_commands_section("## Commands attempted"),
|
|
24
|
+
self.ruled_out_section(),
|
|
25
|
+
self._repo_state_section(),
|
|
26
|
+
self._next_steps_section(),
|
|
27
|
+
self.footer(),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _current_status_section(self) -> List[str]:
|
|
31
|
+
lines = ["## Current status", ""]
|
|
32
|
+
verified = len(self.ctx.verification_commands) > 0
|
|
33
|
+
failed = len(self.ctx.failed_commands)
|
|
34
|
+
|
|
35
|
+
if verified and failed == 0:
|
|
36
|
+
summary = (
|
|
37
|
+
"At least one verification command passed and no recorded "
|
|
38
|
+
"commands are currently failing."
|
|
39
|
+
)
|
|
40
|
+
elif failed:
|
|
41
|
+
summary = (
|
|
42
|
+
f"{failed} command(s) were failing when the session ended; "
|
|
43
|
+
"work appears incomplete."
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
summary = (
|
|
47
|
+
"No verification command passed; the state of the fix is "
|
|
48
|
+
"unconfirmed."
|
|
49
|
+
)
|
|
50
|
+
lines.append(summary)
|
|
51
|
+
return lines
|
|
52
|
+
|
|
53
|
+
def _hypotheses_section(self) -> List[str]:
|
|
54
|
+
lines = ["## Working hypotheses / findings", ""]
|
|
55
|
+
if not self.ctx.notes:
|
|
56
|
+
lines.append(
|
|
57
|
+
"_No hypotheses or findings were recorded as notes during this "
|
|
58
|
+
"session._"
|
|
59
|
+
)
|
|
60
|
+
return lines
|
|
61
|
+
for _timestamp, text in self.ctx.notes:
|
|
62
|
+
lines.append(f"- {text}")
|
|
63
|
+
return lines
|
|
64
|
+
|
|
65
|
+
def _repo_state_section(self) -> List[str]:
|
|
66
|
+
s = self.session
|
|
67
|
+
if not s.git.is_repo:
|
|
68
|
+
return []
|
|
69
|
+
lines = ["## Current repo state", ""]
|
|
70
|
+
branch = s.git.branch or (
|
|
71
|
+
"(detached HEAD)" if s.git.detached_head else "(unknown)"
|
|
72
|
+
)
|
|
73
|
+
lines.append(f"- Branch: {branch}")
|
|
74
|
+
lines.append(f"- HEAD at session end: `{_sha(s.git.final_sha)}`")
|
|
75
|
+
lines.append(
|
|
76
|
+
f"- Uncommitted changes: {len(s.summary.modified_files)} file(s), "
|
|
77
|
+
f"+{s.summary.lines_added} / -{s.summary.lines_deleted} lines."
|
|
78
|
+
)
|
|
79
|
+
return lines
|
|
80
|
+
|
|
81
|
+
def _next_steps_section(self) -> List[str]:
|
|
82
|
+
# Next steps are drawn only from recorded notes, never inferred.
|
|
83
|
+
steps = next_step_notes(self.session)
|
|
84
|
+
if not steps:
|
|
85
|
+
return []
|
|
86
|
+
lines = ["## Suggested next steps", ""]
|
|
87
|
+
lines.append("_Based only on the notes you recorded:_")
|
|
88
|
+
for note in steps:
|
|
89
|
+
lines.append(f"- {note}")
|
|
90
|
+
return lines
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _sha(value):
|
|
94
|
+
if not value:
|
|
95
|
+
return "n/a"
|
|
96
|
+
return value[:12]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Incident-mode report: a chronological, timeline-oriented engineering note."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from ..utils import parse_iso8601
|
|
8
|
+
from .base import BaseReporter, join_sections
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IncidentReporter(BaseReporter):
|
|
12
|
+
mode = "incident"
|
|
13
|
+
|
|
14
|
+
def render(self) -> str:
|
|
15
|
+
return join_sections(
|
|
16
|
+
[self.title_line()],
|
|
17
|
+
self.one_liner_section(),
|
|
18
|
+
self.metadata_lines(),
|
|
19
|
+
self.warnings_section(),
|
|
20
|
+
self._time_window_section(),
|
|
21
|
+
self.timeline_section("## Chronological event timeline", condensed=False),
|
|
22
|
+
self.observed_error_section(),
|
|
23
|
+
self._resolution_section(),
|
|
24
|
+
self.verification_section(),
|
|
25
|
+
self._followup_section(),
|
|
26
|
+
self.footer(),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _time_window_section(self) -> List[str]:
|
|
30
|
+
s = self.session
|
|
31
|
+
lines = ["## Time window", ""]
|
|
32
|
+
start = s.timestamps.start
|
|
33
|
+
end = s.timestamps.end
|
|
34
|
+
lines.append(f"- **Start:** {start or 'unknown'}")
|
|
35
|
+
lines.append(f"- **End:** {end or 'unknown'}")
|
|
36
|
+
duration = _duration(start, end)
|
|
37
|
+
if duration is not None:
|
|
38
|
+
lines.append(f"- **Duration:** {duration}")
|
|
39
|
+
return lines
|
|
40
|
+
|
|
41
|
+
def _resolution_section(self) -> List[str]:
|
|
42
|
+
s = self.session
|
|
43
|
+
lines = ["## Resolution / current state", ""]
|
|
44
|
+
verified = len(self.ctx.verification_commands) > 0
|
|
45
|
+
failed = len(self.ctx.failed_commands)
|
|
46
|
+
if verified and failed == 0:
|
|
47
|
+
lines.append(
|
|
48
|
+
"A verification command passed and no commands are failing. "
|
|
49
|
+
"The recorded evidence is consistent with a resolved state."
|
|
50
|
+
)
|
|
51
|
+
elif failed:
|
|
52
|
+
lines.append(
|
|
53
|
+
f"{failed} command(s) were failing at the end of the session; "
|
|
54
|
+
"the incident does not appear fully resolved."
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
lines.append(
|
|
58
|
+
"No verification command passed; the resolution state is "
|
|
59
|
+
"unconfirmed by the recorded evidence."
|
|
60
|
+
)
|
|
61
|
+
if s.git.is_repo and s.summary.modified_files:
|
|
62
|
+
lines.append("")
|
|
63
|
+
lines.append(
|
|
64
|
+
f"Working tree: {len(s.summary.modified_files)} file(s) changed, "
|
|
65
|
+
f"+{s.summary.lines_added} / -{s.summary.lines_deleted} lines."
|
|
66
|
+
)
|
|
67
|
+
return lines
|
|
68
|
+
|
|
69
|
+
def _followup_section(self) -> List[str]:
|
|
70
|
+
lines = ["## Follow-up items", ""]
|
|
71
|
+
items: List[str] = []
|
|
72
|
+
for rc in self.ctx.failed_commands:
|
|
73
|
+
items.append(f"Resolve the failing command `{rc.command}`.")
|
|
74
|
+
if not self.ctx.verification_commands:
|
|
75
|
+
items.append("Add a passing verification command to confirm resolution.")
|
|
76
|
+
if not items:
|
|
77
|
+
return []
|
|
78
|
+
for item in items:
|
|
79
|
+
lines.append(f"- {item}")
|
|
80
|
+
return lines
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _duration(start, end):
|
|
84
|
+
if not start or not end:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
delta = parse_iso8601(end) - parse_iso8601(start)
|
|
88
|
+
except (ValueError, TypeError):
|
|
89
|
+
return None
|
|
90
|
+
total = int(delta.total_seconds())
|
|
91
|
+
if total < 0:
|
|
92
|
+
return None
|
|
93
|
+
hours, remainder = divmod(total, 3600)
|
|
94
|
+
minutes, seconds = divmod(remainder, 60)
|
|
95
|
+
if hours:
|
|
96
|
+
return f"{hours}h {minutes}m {seconds}s"
|
|
97
|
+
if minutes:
|
|
98
|
+
return f"{minutes}m {seconds}s"
|
|
99
|
+
return f"{seconds}s"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""PR-mode report: a pull-request-ready summary of a debugging session.
|
|
2
|
+
|
|
3
|
+
Every section is derived from recorded evidence and is omitted when it has no
|
|
4
|
+
real content, so the report never pads itself with templated filler.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .base import BaseReporter, join_sections
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PRReporter(BaseReporter):
|
|
13
|
+
mode = "pr"
|
|
14
|
+
|
|
15
|
+
def render(self) -> str:
|
|
16
|
+
return join_sections(
|
|
17
|
+
[self.title_line()],
|
|
18
|
+
self.one_liner_section(),
|
|
19
|
+
self.metadata_lines(),
|
|
20
|
+
self.warnings_section(),
|
|
21
|
+
self.reproduce_verify_section(),
|
|
22
|
+
self.red_to_green_section(),
|
|
23
|
+
self.changed_files_section(),
|
|
24
|
+
self.timeline_section("## Timeline", condensed=True),
|
|
25
|
+
self.verification_section(),
|
|
26
|
+
self.ruled_out_section(),
|
|
27
|
+
self.footer(),
|
|
28
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Helpers for locating and describing generated reports.
|
|
2
|
+
|
|
3
|
+
These are read-only utilities used by the ``last`` and ``open`` commands. They
|
|
4
|
+
never require an active session and never open files themselves.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from .reporters import VALID_MODES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def list_reports(reports_dir: Path) -> List[Path]:
|
|
16
|
+
"""Return report markdown files, most-recently-modified first."""
|
|
17
|
+
if not reports_dir.is_dir():
|
|
18
|
+
return []
|
|
19
|
+
reports = [p for p in reports_dir.glob("*.md") if p.is_file()]
|
|
20
|
+
reports.sort(key=lambda p: (p.stat().st_mtime, p.name), reverse=True)
|
|
21
|
+
return reports
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def latest_report(reports_dir: Path) -> Optional[Path]:
|
|
25
|
+
reports = list_reports(reports_dir)
|
|
26
|
+
return reports[0] if reports else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def infer_mode(report_path: Path) -> Optional[str]:
|
|
30
|
+
"""Infer the report mode from a filename like ``<id>-pr.md``."""
|
|
31
|
+
stem = report_path.stem # filename without .md
|
|
32
|
+
for mode in VALID_MODES:
|
|
33
|
+
if stem.endswith(f"-{mode}"):
|
|
34
|
+
return mode
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def first_title(report_path: Path) -> Optional[str]:
|
|
39
|
+
"""Return the first markdown H1 title line ('# ...') from the report."""
|
|
40
|
+
try:
|
|
41
|
+
with open(report_path, encoding="utf-8") as handle:
|
|
42
|
+
for line in handle:
|
|
43
|
+
stripped = line.strip()
|
|
44
|
+
if stripped.startswith("# "):
|
|
45
|
+
return stripped[2:].strip()
|
|
46
|
+
if stripped.startswith("#"):
|
|
47
|
+
return stripped.lstrip("#").strip()
|
|
48
|
+
except OSError:
|
|
49
|
+
return None
|
|
50
|
+
return None
|