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 +3 -0
- cctx/cli.py +375 -0
- cctx/diagnostician/__init__.py +81 -0
- cctx/diagnostician/aggregate.py +40 -0
- cctx/diagnostician/inflection.py +19 -0
- cctx/diagnostician/patterns/__init__.py +1 -0
- cctx/diagnostician/patterns/retry_loop.py +145 -0
- cctx/diagnostician/patterns/scope_creep.py +87 -0
- cctx/diagnostician/patterns/stale_context.py +147 -0
- cctx/discovery.py +185 -0
- cctx/exporters/__init__.py +0 -0
- cctx/exporters/csv.py +64 -0
- cctx/exporters/jsonl.py +64 -0
- cctx/harvest.py +173 -0
- cctx/models.py +269 -0
- cctx/parsers/__init__.py +1 -0
- cctx/parsers/claude_code.py +690 -0
- cctx/pricing.py +18 -0
- cctx/recommender/__init__.py +0 -0
- cctx/recommender/claude_md.py +131 -0
- cctx/recommender/evidence.py +46 -0
- cctx/renderers/__init__.py +0 -0
- cctx/renderers/report.py +58 -0
- cctx/renderers/templates/autopsy.html.j2 +249 -0
- cctx/renderers/terminal.py +251 -0
- cctx/renderers/trace_tui.py +291 -0
- cctx/tokenizer.py +77 -0
- cctx_cli-0.1.0.dist-info/METADATA +159 -0
- cctx_cli-0.1.0.dist-info/RECORD +31 -0
- cctx_cli-0.1.0.dist-info/WHEEL +4 -0
- cctx_cli-0.1.0.dist-info/entry_points.txt +2 -0
cctx/__init__.py
ADDED
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 []
|