cctx-cli 0.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.
cctx/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """cctx: profile, debug, and optimize Claude Code and Agent SDK sessions."""
2
+
3
+ __version__ = "0.1.0"
cctx/cli.py ADDED
@@ -0,0 +1,375 @@
1
+ """cctx CLI — click + rich-click entry point.
2
+
3
+ Commands:
4
+ cctx ls [project] List projects or sessions
5
+ cctx autopsy <session> Single-session diagnosis
6
+ cctx autopsy <project> --since Cross-session aggregation
7
+ cctx export <session> Export session data as JSONL or CSV
8
+ cctx trace <session> Interactive TUI trace viewer
9
+ cctx harvest <session> Apply autopsy patches to CLAUDE.md
10
+ cctx harvest <project> --since Cross-session harvest
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from datetime import timedelta
15
+ from pathlib import Path
16
+
17
+ import rich_click as click
18
+
19
+ from cctx import diagnostician
20
+ from cctx.diagnostician import aggregate
21
+ from cctx.discovery import complete_project as _complete_project
22
+ from cctx.models import AggregateReport
23
+ from cctx.parsers.claude_code import parse_session
24
+ from cctx.recommender import claude_md
25
+ from cctx.recommender import evidence as evidence_mod
26
+ from cctx.renderers.terminal import (
27
+ render_aggregate,
28
+ render_diagnosis,
29
+ render_harvest_results,
30
+ render_projects,
31
+ render_sessions,
32
+ )
33
+ from cctx.tokenizer import tokenize_session
34
+
35
+ click.rich_click.USE_RICH_MARKUP = True
36
+ click.rich_click.SHOW_ARGUMENTS = True
37
+
38
+
39
+ @click.group()
40
+ def cli() -> None:
41
+ """cctx — find out why your Claude Code session went sideways."""
42
+
43
+
44
+ @cli.command("ls")
45
+ @click.argument(
46
+ "project",
47
+ required=False,
48
+ type=click.Path(exists=True, path_type=Path),
49
+ shell_complete=lambda c, p, i: _complete_project(c, p, i),
50
+ )
51
+ def ls(project: Path | None) -> None:
52
+ """List Claude Code projects and sessions.
53
+
54
+ With no arguments, lists all projects in ~/.claude/projects/.
55
+
56
+ With PROJECT (a local project directory), lists sessions for that project.
57
+ """
58
+ from cctx.discovery import ProjectInfo, find_project_dir, list_projects, list_sessions
59
+
60
+ if project is None:
61
+ projects = list_projects()
62
+ render_projects(projects)
63
+ else:
64
+ cwd = project if project.is_dir() else project.parent
65
+ project_dir = find_project_dir(cwd)
66
+ if project_dir is None:
67
+ raise click.UsageError(
68
+ f"No Claude Code sessions found for {cwd}.\n"
69
+ "Check that ~/.claude/projects/ contains a matching directory."
70
+ )
71
+ sessions = list_sessions(project_dir)
72
+ info = ProjectInfo(
73
+ project_dir=project_dir,
74
+ display_name=str(cwd).replace(str(Path.home()), "~"),
75
+ sessions=sessions,
76
+ )
77
+ render_sessions(info)
78
+
79
+
80
+ @cli.command()
81
+ @click.argument(
82
+ "target",
83
+ required=False,
84
+ type=click.Path(path_type=Path),
85
+ shell_complete=lambda c, p, i: _complete_project(c, p, i),
86
+ )
87
+ @click.option(
88
+ "--since",
89
+ default=None,
90
+ metavar="DAYS",
91
+ type=int,
92
+ help="Cross-session mode: analyse all sessions modified within the last N days.",
93
+ )
94
+ @click.option(
95
+ "--latest",
96
+ is_flag=True,
97
+ default=False,
98
+ help="Diagnose the most recent session in TARGET project (default: cwd).",
99
+ )
100
+ @click.option(
101
+ "--html",
102
+ "html_out",
103
+ default=None,
104
+ metavar="FILE",
105
+ type=click.Path(path_type=Path),
106
+ help="Write a self-contained HTML report to FILE (single-session only).",
107
+ )
108
+ def autopsy(
109
+ target: Path | None,
110
+ since: int | None,
111
+ latest: bool,
112
+ html_out: Path | None,
113
+ ) -> None:
114
+ """Diagnose a session or project directory.
115
+
116
+ TARGET is a session JSONL file for single-session diagnosis,
117
+ or a project directory for --since cross-session aggregation.
118
+
119
+ Use --latest to automatically pick the most recent session in a project.
120
+ """
121
+ from cctx.discovery import find_project_dir
122
+ from cctx.discovery import latest_session as _latest_session
123
+
124
+ if latest and since is not None:
125
+ raise click.UsageError("--latest and --since are mutually exclusive.")
126
+
127
+ if target is None:
128
+ if not latest:
129
+ raise click.UsageError(
130
+ "TARGET is required. Pass a session .jsonl file, a project directory, "
131
+ "or use --latest to pick the most recent session."
132
+ )
133
+ target = Path.cwd()
134
+
135
+ if not target.exists():
136
+ raise click.UsageError(f"Path does not exist: {target}")
137
+
138
+ # Resolve a directory to its latest session (explicit --latest or implicit when
139
+ # a directory is passed without --since).
140
+ if target.is_dir() and since is None:
141
+ # Accept both local project dirs (~/Projects/foo) and encoded claude dirs
142
+ session = _latest_session(target) # works if target IS the .claude/projects dir
143
+ if session is None:
144
+ project_dir = find_project_dir(target)
145
+ if project_dir is not None:
146
+ session = _latest_session(project_dir)
147
+ if session is None:
148
+ raise click.UsageError(
149
+ f"No Claude Code sessions found for {target}.\n"
150
+ "Check that ~/.claude/projects/ contains a matching directory."
151
+ )
152
+ target = session
153
+
154
+ if since is not None:
155
+ if html_out is not None:
156
+ raise click.UsageError("--html is not supported with --since.")
157
+ # Cross-session path
158
+ project_dir = target if target.is_dir() else target.parent
159
+ window = timedelta(days=since)
160
+ diagnoses = aggregate.run(project_dir, window=window)
161
+ ev = evidence_mod.accumulate(diagnoses)
162
+ patches = claude_md.generate_from_evidence(ev)
163
+ report = AggregateReport(
164
+ window=window,
165
+ sessions_analysed=len(diagnoses),
166
+ sessions_with_findings=sum(1 for d in diagnoses if d.findings),
167
+ total_cost_usd=sum(d.total_cost_usd for d in diagnoses),
168
+ waste_cost_usd=sum(d.waste_cost_usd for d in diagnoses),
169
+ by_kind=ev,
170
+ patches=patches,
171
+ )
172
+ render_aggregate(report)
173
+ else:
174
+ # Single-session path
175
+ if target.is_dir():
176
+ raise click.UsageError(
177
+ "TARGET is a directory. Use --since N for cross-session mode, "
178
+ "or pass a .jsonl file directly."
179
+ )
180
+ trace = tokenize_session(parse_session(target))
181
+ diagnosis = diagnostician.run(trace)
182
+ diagnosis = claude_md.generate(diagnosis)
183
+ if html_out is not None:
184
+ from cctx.renderers.report import render_html
185
+ html_out.write_text(render_html(diagnosis, trace), encoding="utf-8")
186
+ click.echo(f"HTML report written to {html_out}")
187
+ else:
188
+ render_diagnosis(diagnosis, session_path=target)
189
+
190
+
191
+ @cli.command()
192
+ @click.argument("target", type=click.Path(exists=True, path_type=Path))
193
+ @click.option(
194
+ "--format",
195
+ "fmt",
196
+ type=click.Choice(["jsonl", "csv"]),
197
+ default="jsonl",
198
+ show_default=True,
199
+ help="Output format: jsonl (one object per session) or csv (one row per turn).",
200
+ )
201
+ @click.option(
202
+ "--out",
203
+ type=click.Path(path_type=Path),
204
+ default=None,
205
+ help="Write output to FILE instead of stdout.",
206
+ )
207
+ @click.option(
208
+ "--no-content",
209
+ is_flag=True,
210
+ default=False,
211
+ help="Omit text content (finding summaries, patch diffs).",
212
+ )
213
+ def export(target: Path, fmt: str, out: Path | None, no_content: bool) -> None:
214
+ """Export session data in machine-readable format.
215
+
216
+ TARGET is a session JSONL file.
217
+ """
218
+ import sys
219
+
220
+ from cctx.exporters import csv as csv_mod
221
+ from cctx.exporters import jsonl as jsonl_mod
222
+
223
+ trace = tokenize_session(parse_session(target))
224
+ diagnosis = diagnostician.run(trace)
225
+ diagnosis = claude_md.generate(diagnosis)
226
+ pairs = [(diagnosis, trace)]
227
+
228
+ if out is not None:
229
+ with open(out, "w", encoding="utf-8") as fh:
230
+ if fmt == "jsonl":
231
+ jsonl_mod.write(pairs, fh, include_content=not no_content)
232
+ else:
233
+ csv_mod.write(pairs, fh)
234
+ elif fmt == "jsonl":
235
+ jsonl_mod.write(pairs, sys.stdout, include_content=not no_content)
236
+ else:
237
+ csv_mod.write(pairs, sys.stdout)
238
+
239
+
240
+ @cli.command()
241
+ @click.argument(
242
+ "target",
243
+ required=False,
244
+ type=click.Path(path_type=Path),
245
+ )
246
+ @click.option(
247
+ "--latest",
248
+ is_flag=True,
249
+ default=False,
250
+ help="Open the most recent session in TARGET project (default: cwd).",
251
+ )
252
+ def trace(target: Path | None, latest: bool) -> None:
253
+ """Open an interactive TUI trace viewer for a session.
254
+
255
+ TARGET is a session JSONL file or project directory.
256
+ Use --latest to automatically pick the most recent session.
257
+ """
258
+ from cctx.discovery import find_project_dir
259
+ from cctx.discovery import latest_session as _latest_session
260
+ from cctx.renderers.trace_tui import launch
261
+
262
+ if target is None:
263
+ if not latest:
264
+ raise click.UsageError(
265
+ "TARGET is required. Pass a session .jsonl file, a project directory, "
266
+ "or use --latest to pick the most recent session."
267
+ )
268
+ target = Path.cwd()
269
+
270
+ if not target.exists():
271
+ raise click.UsageError(f"Path does not exist: {target}")
272
+
273
+ if target.is_dir():
274
+ session_path = _latest_session(target)
275
+ if session_path is None:
276
+ project_dir = find_project_dir(target)
277
+ if project_dir is not None:
278
+ session_path = _latest_session(project_dir)
279
+ if session_path is None:
280
+ raise click.UsageError(
281
+ f"No Claude Code sessions found for {target}.\n"
282
+ "Check that ~/.claude/projects/ contains a matching directory."
283
+ )
284
+ target = session_path
285
+
286
+ session = tokenize_session(parse_session(target))
287
+ diagnosis = diagnostician.run(session)
288
+ diagnosis = claude_md.generate(diagnosis)
289
+ launch(session, diagnosis)
290
+
291
+
292
+ @cli.command()
293
+ @click.argument("target", type=click.Path(exists=True, path_type=Path))
294
+ @click.option(
295
+ "--since",
296
+ default=None,
297
+ metavar="DAYS",
298
+ type=int,
299
+ help="Cross-session mode: apply patches from sessions in the last N days.",
300
+ )
301
+ @click.option(
302
+ "--apply",
303
+ "apply_mode",
304
+ is_flag=True,
305
+ default=False,
306
+ help="Apply patches without interactive confirmation.",
307
+ )
308
+ @click.option(
309
+ "--dry-run",
310
+ is_flag=True,
311
+ default=False,
312
+ help="Print what would change and exit 0; do not write.",
313
+ )
314
+ @click.option(
315
+ "--target-dir",
316
+ default=None,
317
+ type=click.Path(file_okay=False, path_type=Path),
318
+ help="Directory containing CLAUDE.md (default: cwd).",
319
+ )
320
+ def harvest(
321
+ target: Path,
322
+ since: int | None,
323
+ apply_mode: bool,
324
+ dry_run: bool,
325
+ target_dir: Path | None,
326
+ ) -> None:
327
+ """Apply autopsy patches to CLAUDE.md."""
328
+ from cctx.harvest import apply_patches, preview_patches
329
+
330
+ if apply_mode and dry_run:
331
+ raise click.UsageError("--apply and --dry-run are mutually exclusive.")
332
+
333
+ resolved_dir = target_dir or Path.cwd()
334
+ claude_md_path = resolved_dir / "CLAUDE.md"
335
+ click.echo(f"Target: {claude_md_path}")
336
+
337
+ if since is not None:
338
+ project_dir = target if target.is_dir() else target.parent
339
+ window = timedelta(days=since)
340
+ diagnoses = aggregate.run(project_dir, window=window)
341
+ ev = evidence_mod.accumulate(diagnoses)
342
+ patches = claude_md.generate_from_evidence(ev)
343
+ else:
344
+ if target.is_dir():
345
+ raise click.UsageError(
346
+ "TARGET is a directory. Use --since N for cross-session mode, "
347
+ "or pass a .jsonl file directly."
348
+ )
349
+ trace = tokenize_session(parse_session(target))
350
+ diagnosis = diagnostician.run(trace)
351
+ diagnosis = claude_md.generate(diagnosis)
352
+ patches = diagnosis.patches
353
+
354
+ if not patches:
355
+ render_harvest_results([], dry_run=dry_run)
356
+ return
357
+
358
+ if dry_run:
359
+ results = preview_patches(patches, resolved_dir)
360
+ render_harvest_results(results, dry_run=True)
361
+ return
362
+
363
+ if apply_mode:
364
+ results = apply_patches(patches, resolved_dir)
365
+ render_harvest_results(results)
366
+ return
367
+
368
+ preview = preview_patches(patches, resolved_dir)
369
+ render_harvest_results(preview, dry_run=True)
370
+ applicable = sum(1 for r in preview if r.status.value == "applied")
371
+ if applicable == 0:
372
+ return
373
+ if click.confirm(f"Apply {applicable} patch(es)?"):
374
+ results = apply_patches(patches, resolved_dir)
375
+ render_harvest_results(results)
@@ -0,0 +1,81 @@
1
+ """Autopsy diagnostician — public entry point.
2
+
3
+ run(trace) -> Diagnosis
4
+ Runs all three pattern classifiers, detects inflection turn,
5
+ patches cost attribution for stale_context findings, and returns
6
+ a Diagnosis with patches=[].
7
+
8
+ The Recommender (cctx.recommender.claude_md) populates patches.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ from datetime import datetime, timezone
14
+ from typing import TYPE_CHECKING
15
+
16
+ from cctx.diagnostician import inflection
17
+ from cctx.diagnostician.patterns import retry_loop, scope_creep, stale_context
18
+ from cctx.models import Diagnosis, Finding, FindingKind
19
+ from cctx.pricing import price_per_tok as _price_per_tok
20
+
21
+ if TYPE_CHECKING:
22
+ from cctx.models import SessionTrace
23
+
24
+ UTC = timezone.utc
25
+
26
+
27
+ def _patch_costs(findings: list[Finding], model: str | None) -> list[Finding]:
28
+ price = _price_per_tok(model)
29
+ result = []
30
+ for f in findings:
31
+ if f.kind is FindingKind.STALE_CONTEXT:
32
+ tt = f.evidence.get("total_token_turns", 0)
33
+ f = dataclasses.replace(f, cost_usd=round(tt * price, 4))
34
+ result.append(f)
35
+ return result
36
+
37
+
38
+ def _compute_total_cost(trace: SessionTrace, model: str | None) -> float:
39
+ """Approximate total session cost including cache reads and writes.
40
+
41
+ Billing rates relative to base input price:
42
+ cache_read: ×0.10 (read from prompt cache)
43
+ cache_write: ×1.25 (write to prompt cache, both 5-min and 1-hr TTLs)
44
+ """
45
+ price = _price_per_tok(model)
46
+ total = 0.0
47
+ for turn in trace.turns:
48
+ if turn.usage is not None:
49
+ total += turn.usage.input_tokens * price
50
+ total += turn.usage.cache_read * price * 0.1
51
+ cache_writes = turn.usage.cache_creation_5m + turn.usage.cache_creation_1h
52
+ total += cache_writes * price * 1.25
53
+ return round(total, 4)
54
+
55
+
56
+ def run(trace: SessionTrace) -> Diagnosis:
57
+ """Diagnose a single SessionTrace. Returns Diagnosis with patches=[]."""
58
+ findings: list[Finding] = [
59
+ *retry_loop.classify(trace),
60
+ *scope_creep.classify(trace),
61
+ *stale_context.classify(trace),
62
+ ]
63
+ findings.sort(key=lambda f: f.first_turn)
64
+
65
+ inflection_turn = inflection.detect(findings)
66
+ findings = _patch_costs(findings, trace.primary_model)
67
+
68
+ total_cost = _compute_total_cost(trace, trace.primary_model)
69
+ waste_cost = sum(f.cost_usd for f in findings if f.cost_usd is not None)
70
+ # Waste cannot exceed total session cost — cap as a logical invariant.
71
+ waste_cost = min(waste_cost, total_cost)
72
+
73
+ return Diagnosis(
74
+ session_id=trace.session_id,
75
+ findings=findings,
76
+ inflection_turn=inflection_turn,
77
+ patches=[],
78
+ total_cost_usd=total_cost,
79
+ waste_cost_usd=round(waste_cost, 4),
80
+ analysed_at=datetime.now(UTC),
81
+ )
@@ -0,0 +1,40 @@
1
+ """Cross-session aggregator.
2
+
3
+ run(project_dir, window) -> list[Diagnosis]
4
+
5
+ Discovers session JSONL files in project_dir modified within the time window,
6
+ parses each one, runs the per-session diagnostician, and returns the list of
7
+ Diagnoses. The CLI orchestrates the recommender call separately.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timedelta, timezone
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from cctx import diagnostician
16
+ from cctx.parsers.claude_code import parse_session
17
+ from cctx.tokenizer import tokenize_session
18
+
19
+ if TYPE_CHECKING:
20
+ from cctx.models import Diagnosis
21
+
22
+ UTC = timezone.utc
23
+
24
+
25
+ def run(project_dir: Path, window: timedelta) -> list[Diagnosis]:
26
+ cutoff = datetime.now(UTC) - window
27
+ paths = sorted(project_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)
28
+
29
+ result = []
30
+ for path in paths:
31
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, UTC)
32
+ if mtime < cutoff:
33
+ continue
34
+ try:
35
+ trace = tokenize_session(parse_session(path))
36
+ diagnosis = diagnostician.run(trace)
37
+ result.append(diagnosis)
38
+ except Exception:
39
+ continue # skip corrupt sessions; don't fail the whole run
40
+ return result
@@ -0,0 +1,19 @@
1
+ """Inflection-turn detection.
2
+
3
+ The inflection turn is the earliest turn in the session where a classifier
4
+ found a problem. Future versions may detect inflection from turn-level signals
5
+ that precede any classifier finding (rising error rate, apology language, etc.).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from cctx.models import Finding
13
+
14
+
15
+ def detect(findings: list[Finding]) -> int | None:
16
+ """Return the earliest first_turn across all findings, or None."""
17
+ if not findings:
18
+ return None
19
+ return min(f.first_turn for f in findings)
@@ -0,0 +1 @@
1
+ """Pattern classifiers."""
@@ -0,0 +1,145 @@
1
+ """Retry-loop classifier.
2
+
3
+ Detects repeated identical-failing tool calls with no intervening successful
4
+ fix. One Finding per session — all loops bundled into a single Finding with
5
+ all occurrences in evidence.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections import defaultdict
11
+ from typing import TYPE_CHECKING
12
+
13
+ from cctx.models import Confidence, Finding, FindingKind, Severity
14
+
15
+ if TYPE_CHECKING:
16
+ from cctx.models import SessionTrace, ToolResult
17
+
18
+
19
+ def _similarity_key(tool_name: str, tool_input: dict) -> str:
20
+ match tool_name:
21
+ case "Bash":
22
+ return tool_input.get("command", "").strip()
23
+ case "Edit" | "Read" | "Write":
24
+ return tool_input.get("file_path", "")
25
+ case "Grep" | "Glob":
26
+ return tool_input.get("pattern", "")
27
+ case _:
28
+ return json.dumps(tool_input, sort_keys=True)
29
+
30
+
31
+ def _is_error(result: ToolResult) -> bool:
32
+ if result.is_error:
33
+ return True
34
+ c = result.content
35
+ return (
36
+ c.startswith("Error:")
37
+ or c.startswith("error:")
38
+ or c.startswith("FAILED")
39
+ )
40
+
41
+
42
+ def _classify_impl(trace: SessionTrace) -> list[Finding]:
43
+ # Build tool_use_id → (ToolResult, turn_number) map
44
+ result_map: dict[str, tuple[ToolResult, int]] = {}
45
+ for turn in trace.turns:
46
+ for tr in turn.tool_results:
47
+ result_map[tr.tool_use_id] = (tr, turn.turn_number)
48
+
49
+ # Collect all tool calls with their error status
50
+ # Each entry: (tool_name, key, turn_number, is_error, tool_use_id)
51
+ Record = tuple[str, str, int, bool, str]
52
+ records: list[Record] = []
53
+ for turn in trace.turns:
54
+ if turn.role != "assistant":
55
+ continue
56
+ for tu in turn.tool_uses:
57
+ pair = result_map.get(tu.tool_use_id)
58
+ if pair is None:
59
+ continue
60
+ result, _ = pair
61
+ key = _similarity_key(tu.tool_name, tu.tool_input)
62
+ records.append((tu.tool_name, key, turn.turn_number, _is_error(result), tu.tool_use_id))
63
+
64
+ # Group by (tool_name, key)
65
+ groups: dict[tuple[str, str], list[Record]] = defaultdict(list)
66
+ for rec in records:
67
+ groups[(rec[0], rec[1])].append(rec)
68
+
69
+ loop_occurrences: list[dict] = []
70
+
71
+ for (tool_name, key), group in groups.items():
72
+ error_recs = [r for r in group if r[3]]
73
+ if len(error_recs) < 2:
74
+ continue
75
+
76
+ first_err_turn = error_recs[0][2]
77
+ last_err_turn = error_recs[-1][2]
78
+
79
+ # Check for any successful call between the first and last error
80
+ intervening_success = any(
81
+ r for r in group
82
+ if not r[3] and first_err_turn < r[2] < last_err_turn
83
+ )
84
+ if intervening_success:
85
+ continue
86
+
87
+ loop_occurrences.append({
88
+ "tool_name": tool_name,
89
+ "key": key,
90
+ "error_recs": error_recs,
91
+ })
92
+
93
+ if not loop_occurrences:
94
+ return []
95
+
96
+ # Flatten all error records and build evidence
97
+ all_errors: list[Record] = sorted(
98
+ (r for occ in loop_occurrences for r in occ["error_recs"]),
99
+ key=lambda r: r[2],
100
+ )
101
+
102
+ loop_length = len(all_errors)
103
+ # first_turn = turn of the second failing call (loop established here)
104
+ second_errors = []
105
+ for occ in loop_occurrences:
106
+ if len(occ["error_recs"]) >= 2:
107
+ second_errors.append(occ["error_recs"][1][2])
108
+ first_turn = min(second_errors)
109
+ last_turn = max(r[2] for r in all_errors)
110
+
111
+ severity = Severity.HIGH if loop_length >= 4 else Severity.MEDIUM
112
+
113
+ evidence_occurrences = []
114
+ for r in all_errors:
115
+ result, _ = result_map[r[4]]
116
+ evidence_occurrences.append({
117
+ "turn": r[2],
118
+ "key": r[1],
119
+ "call": r[0],
120
+ "error": result.content[:120],
121
+ })
122
+
123
+ # Summary: describe the first loop
124
+ first_occ = loop_occurrences[0]
125
+ first_err = first_occ["error_recs"]
126
+ tool_label = f"{first_occ['tool_name']}({first_occ['key'][:40]})"
127
+ summary = f"{tool_label} failed {loop_length}× between turns {first_err[0][2]}–{last_turn}"
128
+
129
+ return [Finding(
130
+ kind=FindingKind.RETRY_LOOP,
131
+ severity=severity,
132
+ confidence=Confidence.HIGH,
133
+ first_turn=first_turn,
134
+ last_turn=last_turn,
135
+ evidence={"occurrences": evidence_occurrences, "loop_length": loop_length},
136
+ cost_usd=None,
137
+ summary=summary,
138
+ )]
139
+
140
+
141
+ def classify(trace: SessionTrace) -> list[Finding]:
142
+ try:
143
+ return _classify_impl(trace)
144
+ except Exception:
145
+ return []