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.
File without changes
@@ -0,0 +1,131 @@
1
+ """Patch generator — turns Findings into copy-pasteable CLAUDE.md diffs.
2
+
3
+ generate(diagnosis) -> Diagnosis (single-session path)
4
+ generate_from_evidence(evidence) -> list[Patch] (cross-session path)
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import dataclasses
9
+ from typing import TYPE_CHECKING
10
+
11
+ from cctx.models import FindingKind, Patch
12
+
13
+ if TYPE_CHECKING:
14
+ from cctx.models import Diagnosis, Finding, KindEvidence
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Patch templates (append-style unified diffs, v0)
18
+ # ---------------------------------------------------------------------------
19
+
20
+ _RETRY_LOOP_DIFF = """\
21
+ +## Retry discipline
22
+ +
23
+ +If the same command or file operation fails twice with the same error, stop and
24
+ +diagnose before retrying. Read the relevant file, check the full error message,
25
+ +confirm paths exist. Try a meaningfully different approach — never repeat the
26
+ +exact failing call a third time."""
27
+
28
+ _SCOPE_CREEP_DIFF = """\
29
+ +## Scope discipline
30
+ +
31
+ +Finish the stated task before picking up anything else. If you notice an adjacent
32
+ +issue while working, note it as a TODO comment but do not fix it unless explicitly
33
+ +asked. One task at a time."""
34
+
35
+ _STALE_CONTEXT_DIFF = """\
36
+ +## Context hygiene
37
+ +
38
+ +Large tool outputs (grep results, file reads over ~2K tokens) go stale quickly.
39
+ +After a result has served its purpose, do not carry it through 5+ additional turns
40
+ +without re-referencing it. Prefer re-running the tool over dragging stale context
41
+ +forward — the compaction system handles removal."""
42
+
43
+ _TEMPLATES: dict[FindingKind, tuple[str, str, str]] = {
44
+ # kind → (description, diff_body, target_file)
45
+ FindingKind.RETRY_LOOP: ("Add retry discipline rule", _RETRY_LOOP_DIFF, "CLAUDE.md"),
46
+ FindingKind.SCOPE_CREEP: ("Add scope discipline rule", _SCOPE_CREEP_DIFF, "CLAUDE.md"),
47
+ FindingKind.STALE_CONTEXT: ("Add context hygiene rule", _STALE_CONTEXT_DIFF, "CLAUDE.md"),
48
+ }
49
+
50
+
51
+ def summarize(finding: Finding) -> str:
52
+ ev = finding.evidence
53
+ match finding.kind:
54
+ case FindingKind.RETRY_LOOP:
55
+ occs = ev.get("occurrences", [])
56
+ if occs:
57
+ first = occs[0]
58
+ loop_len = ev.get("loop_length", "?")
59
+ return (
60
+ f"{first['call']}({first['key'][:40]}) failed {loop_len}×"
61
+ f" between turns {first['turn']}–{occs[-1]['turn']}"
62
+ )
63
+ return finding.summary
64
+ case FindingKind.SCOPE_CREEP:
65
+ phrases = ev.get("phrases", [])
66
+ if phrases:
67
+ return f"'{phrases[0]['phrase']}' at turn {phrases[0]['turn']}"
68
+ return finding.summary
69
+ case FindingKind.STALE_CONTEXT:
70
+ items = ev.get("stale_items", [])
71
+ if items:
72
+ worst = max(items, key=lambda i: i["token_turns"])
73
+ tokens_k = worst["content_tokens"] // 1000
74
+ cost_str = f", ~${finding.cost_usd:.2f}" if finding.cost_usd else ""
75
+ return (
76
+ f"{tokens_k}K-token {worst['tool_name']} result stale "
77
+ f"{worst['turns_stale']} turns "
78
+ f"(~{ev.get('total_token_turns', 0):,} token-turns{cost_str})"
79
+ )
80
+ return finding.summary
81
+ case _:
82
+ return finding.summary
83
+
84
+
85
+ def _make_patch(finding: Finding) -> Patch:
86
+ description, diff_body, target_file = _TEMPLATES[finding.kind]
87
+ return Patch(
88
+ target_file=target_file,
89
+ description=description,
90
+ unified_diff=diff_body,
91
+ finding_kind=finding.kind,
92
+ evidence_summary=summarize(finding),
93
+ )
94
+
95
+
96
+ def generate(diagnosis: Diagnosis) -> Diagnosis:
97
+ """Populate patches on a Diagnosis. Returns a new Diagnosis; original unchanged."""
98
+ patches = [_make_patch(f) for f in diagnosis.findings]
99
+ return dataclasses.replace(diagnosis, patches=patches)
100
+
101
+
102
+ def generate_from_evidence(
103
+ evidence: dict[FindingKind, KindEvidence],
104
+ ) -> list[Patch]:
105
+ """Cross-session patch generation.
106
+
107
+ Like generate(), but appends an Evidence line when session_count >= 2:
108
+ Evidence: appeared in N of M sessions in the past window (~$X.XX wasted).
109
+ """
110
+ patches = []
111
+ for kind, ev in evidence.items():
112
+ if kind not in _TEMPLATES:
113
+ continue
114
+ description, diff_body, target_file = _TEMPLATES[kind]
115
+
116
+ if ev.session_count >= 2:
117
+ evidence_line = (
118
+ f"\n+\n+Evidence: appeared in {ev.session_count} sessions "
119
+ f"(~${ev.total_waste_usd:.2f} wasted)."
120
+ )
121
+ diff_body = diff_body + evidence_line
122
+
123
+ example = ev.example_summaries[0] if ev.example_summaries else ""
124
+ patches.append(Patch(
125
+ target_file=target_file,
126
+ description=description,
127
+ unified_diff=diff_body,
128
+ finding_kind=kind,
129
+ evidence_summary=example,
130
+ ))
131
+ return patches
@@ -0,0 +1,46 @@
1
+ """Cross-session evidence accumulation.
2
+
3
+ accumulate(diagnoses) -> dict[FindingKind, KindEvidence]
4
+
5
+ Counts how many sessions triggered each finding kind and sums waste cost.
6
+ Per the spec, session_count increments once per session per kind, regardless
7
+ of how many findings of that kind appear in one session.
8
+ Stores up to 3 example_summaries for the renderer.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from cctx.models import Diagnosis, FindingKind, KindEvidence
15
+ from cctx.recommender.claude_md import summarize
16
+
17
+ if TYPE_CHECKING:
18
+ from cctx.models import Finding
19
+
20
+
21
+ def _summarize_finding(finding: Finding) -> str:
22
+ return summarize(finding)
23
+
24
+
25
+ def accumulate(diagnoses: list[Diagnosis]) -> dict[FindingKind, KindEvidence]:
26
+ result: dict[FindingKind, KindEvidence] = {}
27
+ for diagnosis in diagnoses:
28
+ # Track which kinds we've already counted for this session to ensure
29
+ # session_count increments once per session per kind, not per finding.
30
+ seen_kinds: set[FindingKind] = set()
31
+ for finding in diagnosis.findings:
32
+ if finding.kind not in result:
33
+ result[finding.kind] = KindEvidence(
34
+ kind=finding.kind,
35
+ session_count=0,
36
+ total_waste_usd=0.0,
37
+ example_summaries=[],
38
+ )
39
+ ev = result[finding.kind]
40
+ if finding.kind not in seen_kinds:
41
+ ev.session_count += 1
42
+ seen_kinds.add(finding.kind)
43
+ ev.total_waste_usd += finding.cost_usd or 0.0
44
+ if len(ev.example_summaries) < 3:
45
+ ev.example_summaries.append(_summarize_finding(finding))
46
+ return result
File without changes
@@ -0,0 +1,58 @@
1
+ """HTML report renderer for autopsy Diagnosis output.
2
+
3
+ render_html(diag, trace) -> str
4
+ Returns a fully self-contained HTML string with inlined CSS.
5
+ No external resources; no JavaScript.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
14
+
15
+ if TYPE_CHECKING:
16
+ from cctx.models import Diagnosis, Finding, SessionTrace
17
+
18
+ _TEMPLATES_DIR = Path(__file__).parent / "templates"
19
+
20
+
21
+ def _flagged_index(findings: list[Finding]) -> dict[int, list[Finding]]:
22
+ index: dict[int, list[Finding]] = {}
23
+ for f in findings:
24
+ last = f.last_turn if f.last_turn is not None else f.first_turn
25
+ for tn in range(f.first_turn, last + 1):
26
+ index.setdefault(tn, []).append(f)
27
+ return index
28
+
29
+
30
+ def render_html(diag: Diagnosis, trace: SessionTrace) -> str:
31
+ """Render a Diagnosis as a self-contained HTML report string."""
32
+ env = Environment(
33
+ loader=FileSystemLoader(_TEMPLATES_DIR),
34
+ autoescape=select_autoescape(["html"]),
35
+ )
36
+ env.filters["to_json"] = lambda v: json.dumps(v, indent=2, default=str)
37
+
38
+ def _diff_highlight(diff_text: str) -> str:
39
+ lines = []
40
+ for line in diff_text.splitlines():
41
+ from markupsafe import Markup, escape
42
+ escaped = escape(line)
43
+ if line.startswith("+") and not line.startswith("+++"):
44
+ lines.append(Markup(f'<span class="add">{escaped}</span>'))
45
+ elif line.startswith("-") and not line.startswith("---"):
46
+ lines.append(Markup(f'<span class="del">{escaped}</span>'))
47
+ else:
48
+ lines.append(escaped)
49
+ return Markup("\n".join(lines))
50
+
51
+ env.filters["diff_highlight"] = _diff_highlight
52
+
53
+ tmpl = env.get_template("autopsy.html.j2")
54
+ return tmpl.render(
55
+ diag=diag,
56
+ trace=trace,
57
+ flagged=_flagged_index(diag.findings),
58
+ )
@@ -0,0 +1,249 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>cctx autopsy — {{ diag.session_id }}</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
11
+ font-size: 14px;
12
+ background: #0d1117;
13
+ color: #c9d1d9;
14
+ padding: 2rem 1rem;
15
+ }
16
+ main { max-width: 900px; margin: 0 auto; }
17
+ h1 { font-size: 1.4rem; color: #58a6ff; margin-bottom: .25rem; }
18
+ h2 { font-size: 1rem; color: #8b949e; text-transform: uppercase; letter-spacing: .08em; margin: 2rem 0 .75rem; }
19
+ code { font-family: monospace; font-size: .85em; background: #161b22; padding: .1em .35em; border-radius: 3px; }
20
+ a { color: #58a6ff; }
21
+
22
+ /* --- Verdict card --- */
23
+ .verdict-card {
24
+ background: #161b22;
25
+ border: 1px solid #30363d;
26
+ border-radius: 8px;
27
+ padding: 1.5rem;
28
+ margin-bottom: 2rem;
29
+ }
30
+ .verdict-card .meta { color: #8b949e; font-size: .8rem; margin-top: 1rem; }
31
+ .verdict { font-size: 1.1rem; font-weight: 600; margin: .5rem 0 1rem; }
32
+ .verdict.clean { color: #3fb950; }
33
+ .verdict.bad { color: #f85149; }
34
+ dl.costs { display: grid; grid-template-columns: auto 1fr; gap: .25rem .75rem; }
35
+ dl.costs dt { color: #8b949e; }
36
+ dl.costs dd { font-variant-numeric: tabular-nums; }
37
+
38
+ /* --- Badges --- */
39
+ .badge {
40
+ display: inline-block;
41
+ font-size: .7rem;
42
+ font-weight: 700;
43
+ padding: .15em .5em;
44
+ border-radius: 3px;
45
+ margin-right: .3rem;
46
+ vertical-align: middle;
47
+ }
48
+ .badge.kind-retry_loop { background: #f85149; color: #fff; }
49
+ .badge.kind-scope_creep { background: #d29922; color: #fff; }
50
+ .badge.kind-stale_context { background: #388bfd; color: #fff; }
51
+ .badge.sev-high { background: #6e1507; color: #f85149; border: 1px solid #f85149; }
52
+ .badge.sev-medium { background: #4d3500; color: #d29922; border: 1px solid #d29922; }
53
+ .badge.sev-low { background: #033a16; color: #3fb950; border: 1px solid #3fb950; }
54
+
55
+ /* --- Findings / patches --- */
56
+ details {
57
+ border: 1px solid #30363d;
58
+ border-radius: 6px;
59
+ margin-bottom: .6rem;
60
+ }
61
+ details[open] { border-color: #58a6ff44; }
62
+ summary {
63
+ list-style: none;
64
+ cursor: pointer;
65
+ padding: .75rem 1rem;
66
+ display: flex;
67
+ align-items: center;
68
+ flex-wrap: wrap;
69
+ gap: .35rem;
70
+ user-select: none;
71
+ }
72
+ summary::-webkit-details-marker { display: none; }
73
+ summary::before {
74
+ content: "▶";
75
+ font-size: .6rem;
76
+ color: #8b949e;
77
+ margin-right: .4rem;
78
+ transition: transform .15s;
79
+ }
80
+ details[open] summary::before { transform: rotate(90deg); }
81
+ .summary-text { flex: 1; color: #c9d1d9; }
82
+ .turns, .cost, .target { font-size: .75rem; color: #8b949e; }
83
+
84
+ .finding-body, .patch-body {
85
+ padding: .75rem 1rem 1rem;
86
+ border-top: 1px solid #21262d;
87
+ }
88
+ .finding-body p { color: #8b949e; margin-bottom: .5rem; }
89
+
90
+ pre.evidence, pre.diff {
91
+ background: #0d1117;
92
+ border: 1px solid #21262d;
93
+ border-radius: 4px;
94
+ padding: .75rem;
95
+ overflow-x: auto;
96
+ font-size: .8rem;
97
+ line-height: 1.5;
98
+ white-space: pre;
99
+ }
100
+ pre.diff .add { color: #3fb950; }
101
+ pre.diff .del { color: #f85149; }
102
+
103
+ .evidence-summary { color: #8b949e; font-size: .8rem; margin-bottom: .5rem; }
104
+
105
+ /* diff line coloring via spans */
106
+
107
+ /* --- Timeline --- */
108
+ .timeline {
109
+ display: flex;
110
+ flex-wrap: wrap;
111
+ gap: 3px;
112
+ margin-bottom: .5rem;
113
+ }
114
+ .turn-bar {
115
+ width: 28px;
116
+ height: 28px;
117
+ border-radius: 3px;
118
+ display: flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ font-size: .65rem;
122
+ font-weight: 600;
123
+ cursor: default;
124
+ border: 1px solid transparent;
125
+ background: #161b22;
126
+ color: #8b949e;
127
+ }
128
+ .turn-bar.role-user { background: #0d2940; color: #58a6ff; border-color: #1f4068; }
129
+ .turn-bar.role-assistant { background: #0d2018; color: #3fb950; border-color: #1a3a28; }
130
+ .turn-bar.role-tool_result { background: #1c1c2e; color: #8b949e; border-color: #2d2d4a; }
131
+ .turn-bar.flagged { background: #4d0000; color: #f85149; border-color: #7a0000; font-weight: 900; }
132
+ .timeline-legend {
133
+ display: flex;
134
+ gap: 1rem;
135
+ font-size: .75rem;
136
+ }
137
+ .legend-item { display: flex; align-items: center; gap: .3rem; color: #8b949e; }
138
+ .legend-item::before {
139
+ content: "";
140
+ display: inline-block;
141
+ width: 12px;
142
+ height: 12px;
143
+ border-radius: 2px;
144
+ border: 1px solid;
145
+ }
146
+ .legend-item.user::before { background: #0d2940; border-color: #1f4068; }
147
+ .legend-item.assistant::before { background: #0d2018; border-color: #1a3a28; }
148
+ .legend-item.tool_result::before { background: #1c1c2e; border-color: #2d2d4a; }
149
+ .legend-item.flagged::before { background: #4d0000; border-color: #7a0000; }
150
+
151
+ /* --- Footer --- */
152
+ footer {
153
+ margin-top: 3rem;
154
+ padding-top: 1rem;
155
+ border-top: 1px solid #21262d;
156
+ color: #484f58;
157
+ font-size: .75rem;
158
+ text-align: center;
159
+ }
160
+ </style>
161
+ </head>
162
+ <body>
163
+ <main>
164
+
165
+ <!-- Verdict card -->
166
+ <div class="verdict-card">
167
+ <h1>cctx autopsy</h1>
168
+ <p><code>{{ diag.session_id }}</code></p>
169
+ {% if diag.findings %}
170
+ <p class="verdict bad">{{ diag.findings|length }} {{ "finding" if diag.findings|length == 1 else "findings" }} &middot; ${{ "%.2f"|format(diag.waste_cost_usd) }} waste</p>
171
+ {% else %}
172
+ <p class="verdict clean">Clean session</p>
173
+ {% endif %}
174
+ <dl class="costs">
175
+ <dt>Total cost</dt><dd>${{ "%.2f"|format(diag.total_cost_usd) }}</dd>
176
+ <dt>Waste attributed</dt><dd>${{ "%.2f"|format(diag.waste_cost_usd) }}</dd>
177
+ {% if diag.inflection_turn is not none %}<dt>Inflection turn</dt><dd>{{ diag.inflection_turn }}</dd>{% endif %}
178
+ </dl>
179
+ <p class="meta">Analysed {{ diag.analysed_at.strftime("%Y-%m-%d %H:%M UTC") }}</p>
180
+ </div>
181
+
182
+ <!-- Findings -->
183
+ {% if diag.findings %}
184
+ <section>
185
+ <h2>Findings</h2>
186
+ {% for f in diag.findings %}
187
+ <details class="finding">
188
+ <summary>
189
+ <span class="badge kind-{{ f.kind.value }}">{{ f.kind.value.replace("_", " ").upper() }}</span>
190
+ <span class="badge sev-{{ f.severity.value }}">{{ f.severity.value.upper() }}</span>
191
+ <span class="summary-text">{{ f.summary }}</span>
192
+ <span class="turns">turns {{ f.first_turn }}{% if f.last_turn is not none and f.last_turn != f.first_turn %}–{{ f.last_turn }}{% endif %}</span>
193
+ {% if f.cost_usd is not none %}<span class="cost">${{ "%.4f"|format(f.cost_usd) }}</span>{% endif %}
194
+ </summary>
195
+ <div class="finding-body">
196
+ <p><strong>Confidence:</strong> {{ f.confidence.value }}</p>
197
+ {% if f.evidence %}
198
+ <pre class="evidence">{{ f.evidence | to_json }}</pre>
199
+ {% endif %}
200
+ </div>
201
+ </details>
202
+ {% endfor %}
203
+ </section>
204
+ {% endif %}
205
+
206
+ <!-- Patches -->
207
+ {% if diag.patches %}
208
+ <section>
209
+ <h2>Recommended CLAUDE.md patches</h2>
210
+ {% for p in diag.patches %}
211
+ <details class="patch">
212
+ <summary>
213
+ <span class="badge kind-{{ p.finding_kind.value }}">{{ p.finding_kind.value.replace("_", " ").upper() }}</span>
214
+ <span class="summary-text">{{ p.description }}</span>
215
+ <span class="target">{{ p.target_file }}</span>
216
+ </summary>
217
+ <div class="patch-body">
218
+ <p class="evidence-summary">{{ p.evidence_summary }}</p>
219
+ <pre class="diff">{{ p.unified_diff | diff_highlight }}</pre>
220
+ </div>
221
+ </details>
222
+ {% endfor %}
223
+ </section>
224
+ {% endif %}
225
+
226
+ <!-- Turn timeline -->
227
+ <section>
228
+ <h2>Turn timeline</h2>
229
+ <div class="timeline">
230
+ {% for turn in trace.turns %}
231
+ {% set is_flagged = turn.turn_number in flagged %}
232
+ <div class="turn-bar role-{{ turn.role }}{% if is_flagged %} flagged{% endif %}"
233
+ title="Turn {{ turn.turn_number }}: {{ turn.role }}{% if is_flagged %} — {{ flagged[turn.turn_number]|map(attribute='kind')|map(attribute='value')|join(', ') }}{% endif %}">{{ turn.turn_number }}</div>
234
+ {% endfor %}
235
+ </div>
236
+ <div class="timeline-legend">
237
+ <span class="legend-item user">user</span>
238
+ <span class="legend-item assistant">assistant</span>
239
+ <span class="legend-item tool_result">tool result</span>
240
+ <span class="legend-item flagged">flagged</span>
241
+ </div>
242
+ </section>
243
+
244
+ </main>
245
+ <footer>
246
+ <p>Generated by <a href="https://github.com/jacquardlabs/cctx">cctx</a> &middot; {{ diag.analysed_at.strftime("%Y-%m-%d") }}</p>
247
+ </footer>
248
+ </body>
249
+ </html>