@jeganwrites/claudash 1.0.0
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.
- package/CONTRIBUTING.md +35 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/analyzer.py +890 -0
- package/bin/claudash.js +121 -0
- package/claude_ai_tracker.py +358 -0
- package/cli.py +1034 -0
- package/config.py +100 -0
- package/db.py +1156 -0
- package/fix_tracker.py +539 -0
- package/insights.py +359 -0
- package/mcp_server.py +414 -0
- package/package.json +39 -0
- package/scanner.py +385 -0
- package/server.py +762 -0
- package/templates/accounts.html +936 -0
- package/templates/dashboard.html +1742 -0
- package/tools/get-derived-keys.py +112 -0
- package/tools/mac-sync.py +386 -0
- package/tools/oauth_sync.py +308 -0
- package/tools/setup-pm2.sh +53 -0
- package/waste_patterns.py +334 -0
package/fix_tracker.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Fix Tracker — record a fix, measure whether it worked.
|
|
2
|
+
|
|
3
|
+
Workflow:
|
|
4
|
+
1. User records a fix for a project + waste pattern via `POST /api/fixes`
|
|
5
|
+
or `cli.py fix add`. Claudash snapshots the project's current metrics
|
|
6
|
+
into `fixes.baseline_json`.
|
|
7
|
+
2. User applies the fix to their CLAUDE.md / settings.json / prompts /
|
|
8
|
+
architecture and uses Claude Code normally for a week.
|
|
9
|
+
3. User runs `POST /api/fixes/{id}/measure` (or `cli.py measure {id}`).
|
|
10
|
+
Claudash captures a fresh snapshot, diffs it against the baseline,
|
|
11
|
+
assigns a plan-aware verdict, and stores the measurement.
|
|
12
|
+
4. The dashboard shows the before/after delta; a share-card endpoint
|
|
13
|
+
produces a plain-text receipt the user can paste anywhere.
|
|
14
|
+
|
|
15
|
+
Plan-aware framing — the module's most important contract:
|
|
16
|
+
|
|
17
|
+
* Max / Pro (flat subscription):
|
|
18
|
+
Primary metric = **window efficiency** (useful tokens / total tokens).
|
|
19
|
+
Savings are reported as "API-equivalent waste eliminated", never as
|
|
20
|
+
"you saved $X". The story is "same $100/mo plan, 2.5× more output".
|
|
21
|
+
* API (pay-per-token):
|
|
22
|
+
Primary metric = **cost_usd**. Savings are real dollars. The story
|
|
23
|
+
is "spent $X less this month".
|
|
24
|
+
|
|
25
|
+
This module never conflates the two. The verdict logic, the delta JSON,
|
|
26
|
+
the share card, and the CLI output all branch on `plan_type`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sqlite3
|
|
32
|
+
import time
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
|
|
35
|
+
from db import (
|
|
36
|
+
get_conn, get_accounts_config, insert_fix, get_fix, get_all_fixes,
|
|
37
|
+
update_fix_status, insert_fix_measurement, get_fix_measurements,
|
|
38
|
+
get_latest_fix_measurement,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ─── Constants ───────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
WASTE_PATTERNS = [
|
|
45
|
+
"floundering",
|
|
46
|
+
"repeated_reads",
|
|
47
|
+
"deep_no_compact",
|
|
48
|
+
"cost_outlier",
|
|
49
|
+
"cache_spike",
|
|
50
|
+
"model_waste",
|
|
51
|
+
"compaction_late",
|
|
52
|
+
"custom",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
FIX_TYPES = [
|
|
56
|
+
"claude_md",
|
|
57
|
+
"settings_json",
|
|
58
|
+
"prompt",
|
|
59
|
+
"architecture",
|
|
60
|
+
"other",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
WASTE_PATTERN_LABELS = {
|
|
64
|
+
"floundering": "Floundering (tool retry loops)",
|
|
65
|
+
"repeated_reads": "Repeated file reads",
|
|
66
|
+
"deep_no_compact": "Deep session without compaction",
|
|
67
|
+
"cost_outlier": "Cost outlier session",
|
|
68
|
+
"cache_spike": "Cache creation spike",
|
|
69
|
+
"model_waste": "Model waste (Opus for small outputs)",
|
|
70
|
+
"compaction_late": "Compaction fired too late",
|
|
71
|
+
"custom": "Custom pattern",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Plan-aware thresholds. Applied to the delta computation in
|
|
75
|
+
# `determine_verdict` — see the body of that function for how they're used.
|
|
76
|
+
MIN_SESSIONS_FOR_VERDICT = 3
|
|
77
|
+
WASTE_IMPROVING_PCT = 20
|
|
78
|
+
WASTE_WORSENED_PCT = 10
|
|
79
|
+
WINDOW_IMPROVING_PCT = 15
|
|
80
|
+
WINDOW_WORSENED_PCT = 10
|
|
81
|
+
COST_IMPROVING_PCT = 10
|
|
82
|
+
COST_WORSENED_PCT = 10
|
|
83
|
+
CONFIRM_MIN_DAYS = 7
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ─── Plan lookup ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def get_project_plan_info(conn, project):
|
|
89
|
+
"""Return (account_id, plan_type, monthly_cost_usd) for a project.
|
|
90
|
+
|
|
91
|
+
We look up the first session row for the project to find its account,
|
|
92
|
+
then read plan + monthly cost from the accounts table. Returns
|
|
93
|
+
('all', 'max', 0.0) if the project has no data yet (new install path).
|
|
94
|
+
"""
|
|
95
|
+
row = conn.execute(
|
|
96
|
+
"SELECT account FROM sessions WHERE project = ? LIMIT 1",
|
|
97
|
+
(project,),
|
|
98
|
+
).fetchone()
|
|
99
|
+
acct_id = (row["account"] if row else None) or "all"
|
|
100
|
+
accounts = get_accounts_config(conn)
|
|
101
|
+
info = accounts.get(acct_id) or {}
|
|
102
|
+
plan = info.get("plan") or "max"
|
|
103
|
+
cost = float(info.get("monthly_cost_usd") or 0)
|
|
104
|
+
return acct_id, plan, cost
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ─── Baseline capture ────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def capture_baseline(conn, project, days_window=7):
|
|
110
|
+
"""Snapshot the project's key metrics right now. Safe to call repeatedly —
|
|
111
|
+
the snapshot is self-contained and travels inside `fixes.baseline_json`."""
|
|
112
|
+
acct_id, plan, plan_cost = get_project_plan_info(conn, project)
|
|
113
|
+
since = int(time.time()) - (days_window * 86400)
|
|
114
|
+
|
|
115
|
+
# Aggregate session rows for this project in the window
|
|
116
|
+
agg = conn.execute(
|
|
117
|
+
"""SELECT
|
|
118
|
+
COUNT(DISTINCT session_id) AS sessions,
|
|
119
|
+
COALESCE(SUM(cost_usd), 0) AS total_cost,
|
|
120
|
+
COALESCE(SUM(cache_read_tokens), 0) AS cache_read,
|
|
121
|
+
COALESCE(SUM(cache_creation_tokens), 0) AS cache_create,
|
|
122
|
+
COALESCE(SUM(input_tokens), 0) AS input_tok,
|
|
123
|
+
COALESCE(SUM(output_tokens), 0) AS output_tok,
|
|
124
|
+
COUNT(*) AS total_rows,
|
|
125
|
+
COALESCE(SUM(CASE WHEN compaction_detected=1 THEN 1 ELSE 0 END), 0) AS compactions,
|
|
126
|
+
COALESCE(SUM(CASE WHEN is_subagent=1 THEN cost_usd ELSE 0 END), 0) AS subagent_cost
|
|
127
|
+
FROM sessions
|
|
128
|
+
WHERE project = ? AND timestamp >= ?""",
|
|
129
|
+
(project, since),
|
|
130
|
+
).fetchone()
|
|
131
|
+
|
|
132
|
+
sessions_count = agg["sessions"] or 0
|
|
133
|
+
total_cost = float(agg["total_cost"] or 0)
|
|
134
|
+
cache_read = int(agg["cache_read"] or 0)
|
|
135
|
+
cache_create = int(agg["cache_create"] or 0)
|
|
136
|
+
input_tok = int(agg["input_tok"] or 0)
|
|
137
|
+
output_tok = int(agg["output_tok"] or 0)
|
|
138
|
+
total_rows = int(agg["total_rows"] or 0)
|
|
139
|
+
compactions = int(agg["compactions"] or 0)
|
|
140
|
+
subagent_cost = float(agg["subagent_cost"] or 0)
|
|
141
|
+
|
|
142
|
+
avg_cost_per_session = (total_cost / sessions_count) if sessions_count > 0 else 0.0
|
|
143
|
+
|
|
144
|
+
total_cache_activity = cache_read + cache_create
|
|
145
|
+
cache_hit_rate = (cache_read / total_cache_activity * 100) if total_cache_activity > 0 else 0.0
|
|
146
|
+
|
|
147
|
+
total_tokens = input_tok + cache_read # inbound context size
|
|
148
|
+
avg_tokens_per_turn = (total_tokens / total_rows) if total_rows > 0 else 0.0
|
|
149
|
+
avg_cache_read_per_turn = (cache_read / total_rows) if total_rows > 0 else 0.0
|
|
150
|
+
|
|
151
|
+
avg_turns_per_session = (total_rows / sessions_count) if sessions_count > 0 else 0.0
|
|
152
|
+
compaction_rate = (compactions / sessions_count) if sessions_count > 0 else 0.0
|
|
153
|
+
|
|
154
|
+
# Waste events within the same window
|
|
155
|
+
waste_rows = conn.execute(
|
|
156
|
+
"""SELECT pattern_type, COUNT(*) AS n
|
|
157
|
+
FROM waste_events
|
|
158
|
+
WHERE project = ? AND detected_at >= ?
|
|
159
|
+
GROUP BY pattern_type""",
|
|
160
|
+
(project, since),
|
|
161
|
+
).fetchall()
|
|
162
|
+
waste = {
|
|
163
|
+
"floundering": 0,
|
|
164
|
+
"repeated_reads": 0,
|
|
165
|
+
"deep_no_compact": 0,
|
|
166
|
+
"cost_outliers": 0,
|
|
167
|
+
"total": 0,
|
|
168
|
+
}
|
|
169
|
+
for r in waste_rows:
|
|
170
|
+
pt = r["pattern_type"]
|
|
171
|
+
n = r["n"] or 0
|
|
172
|
+
if pt == "floundering":
|
|
173
|
+
waste["floundering"] = n
|
|
174
|
+
elif pt == "repeated_reads":
|
|
175
|
+
waste["repeated_reads"] = n
|
|
176
|
+
elif pt == "deep_no_compact":
|
|
177
|
+
waste["deep_no_compact"] = n
|
|
178
|
+
elif pt == "cost_outlier":
|
|
179
|
+
waste["cost_outliers"] = n
|
|
180
|
+
waste["total"] += n
|
|
181
|
+
|
|
182
|
+
# Token waste attribution — conservative per-turn scaling.
|
|
183
|
+
#
|
|
184
|
+
# Each floundering event represents ~1 wasted turn (Claude retried the
|
|
185
|
+
# same tool without progress). Each repeated_read event represents
|
|
186
|
+
# ~2 extra `Read` calls beyond the first necessary one. Both are scaled
|
|
187
|
+
# by per-turn token averages, NOT per-session. Scaling by per-session
|
|
188
|
+
# values wildly over-counts under prompt caching (cache_read dwarfs
|
|
189
|
+
# everything else) and makes effective_window_pct collapse to 0.
|
|
190
|
+
REPEATED_READ_EXTRA_TURNS = 2
|
|
191
|
+
tokens_wasted_on_floundering = int(waste["floundering"] * avg_tokens_per_turn)
|
|
192
|
+
tokens_wasted_on_repeated_reads = int(
|
|
193
|
+
waste["repeated_reads"] * REPEATED_READ_EXTRA_TURNS * avg_cache_read_per_turn
|
|
194
|
+
)
|
|
195
|
+
wasted_total = tokens_wasted_on_floundering + tokens_wasted_on_repeated_reads
|
|
196
|
+
|
|
197
|
+
if total_tokens > 0:
|
|
198
|
+
effective_window_pct = max(0.0, (total_tokens - wasted_total) / total_tokens * 100)
|
|
199
|
+
else:
|
|
200
|
+
effective_window_pct = 0.0
|
|
201
|
+
|
|
202
|
+
# Sub-agent cost share
|
|
203
|
+
subagent_cost_pct = (subagent_cost / total_cost * 100) if total_cost > 0 else 0.0
|
|
204
|
+
|
|
205
|
+
# Window burn → files_per_window
|
|
206
|
+
# We use window_burns if populated for this account, otherwise derive
|
|
207
|
+
# sessions-per-5-hour-block from session timestamps.
|
|
208
|
+
fpw = _estimate_files_per_window(conn, project, acct_id, since)
|
|
209
|
+
|
|
210
|
+
# Window hit rate (0..1) — fraction of rolled-up windows that hit 100%
|
|
211
|
+
hit = conn.execute(
|
|
212
|
+
"SELECT COALESCE(AVG(CASE WHEN hit_limit=1 THEN 1.0 ELSE 0.0 END), 0) "
|
|
213
|
+
"FROM window_burns WHERE account = ?",
|
|
214
|
+
(acct_id,),
|
|
215
|
+
).fetchone()
|
|
216
|
+
window_hit_rate = float((hit[0] or 0)) if hit else 0.0
|
|
217
|
+
|
|
218
|
+
baseline = {
|
|
219
|
+
"project": project,
|
|
220
|
+
"captured_at": int(time.time()),
|
|
221
|
+
"days_window": days_window,
|
|
222
|
+
"plan_type": plan,
|
|
223
|
+
"plan_cost_usd": plan_cost,
|
|
224
|
+
"sessions_count": sessions_count,
|
|
225
|
+
"cost_usd": round(total_cost, 4),
|
|
226
|
+
"avg_cost_per_session": round(avg_cost_per_session, 4),
|
|
227
|
+
"cache_hit_rate": round(cache_hit_rate, 2),
|
|
228
|
+
"avg_turns_per_session": round(avg_turns_per_session, 1),
|
|
229
|
+
"compaction_events": compactions,
|
|
230
|
+
"compaction_rate": round(compaction_rate, 2),
|
|
231
|
+
"waste_events": waste,
|
|
232
|
+
"subagent_cost_pct": round(subagent_cost_pct, 1),
|
|
233
|
+
"window_hit_rate": round(window_hit_rate, 3),
|
|
234
|
+
"tokens_wasted_on_floundering": tokens_wasted_on_floundering,
|
|
235
|
+
"tokens_wasted_on_repeated_reads": tokens_wasted_on_repeated_reads,
|
|
236
|
+
"effective_window_pct": round(effective_window_pct, 2),
|
|
237
|
+
"files_per_window": fpw,
|
|
238
|
+
# Scratch fields used by compute_delta
|
|
239
|
+
"_total_tokens": total_tokens,
|
|
240
|
+
"_avg_tokens_per_turn": round(avg_tokens_per_turn, 2),
|
|
241
|
+
"_avg_cache_read_per_turn": round(avg_cache_read_per_turn, 2),
|
|
242
|
+
}
|
|
243
|
+
return baseline
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _estimate_files_per_window(conn, project, acct_id, since):
|
|
247
|
+
"""Approximate 'sessions completed per 5-hour window' for this project.
|
|
248
|
+
|
|
249
|
+
Implementation: count distinct session_ids, bucket their first-seen
|
|
250
|
+
timestamps into 5-hour buckets, then average. Returns an integer.
|
|
251
|
+
"""
|
|
252
|
+
rows = conn.execute(
|
|
253
|
+
"""SELECT session_id, MIN(timestamp) AS first_seen
|
|
254
|
+
FROM sessions
|
|
255
|
+
WHERE project = ? AND timestamp >= ?
|
|
256
|
+
GROUP BY session_id""",
|
|
257
|
+
(project, since),
|
|
258
|
+
).fetchall()
|
|
259
|
+
if not rows:
|
|
260
|
+
return 0
|
|
261
|
+
buckets = defaultdict(int)
|
|
262
|
+
for r in rows:
|
|
263
|
+
ts = r["first_seen"] or 0
|
|
264
|
+
buckets[ts // 18000] += 1
|
|
265
|
+
if not buckets:
|
|
266
|
+
return 0
|
|
267
|
+
return int(round(sum(buckets.values()) / len(buckets)))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ─── Delta computation + verdict ─────────────────────────────────
|
|
271
|
+
|
|
272
|
+
def _pct_change(before, after):
|
|
273
|
+
"""Signed percent change. Returns 0 when `before` is 0 to avoid inf."""
|
|
274
|
+
if before == 0:
|
|
275
|
+
return 0.0
|
|
276
|
+
return round(((after - before) / before) * 100.0, 1)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def compute_delta(conn, fix_id):
|
|
280
|
+
"""Capture current metrics for the fixed project, diff against baseline,
|
|
281
|
+
assign a verdict, return (delta_json, verdict, current_metrics)."""
|
|
282
|
+
fix = get_fix(conn, fix_id)
|
|
283
|
+
if not fix:
|
|
284
|
+
return None, "not_found", None
|
|
285
|
+
baseline = json.loads(fix["baseline_json"] or "{}")
|
|
286
|
+
project = fix["project"]
|
|
287
|
+
plan_type = baseline.get("plan_type", "max")
|
|
288
|
+
plan_cost = baseline.get("plan_cost_usd", 0)
|
|
289
|
+
|
|
290
|
+
current = capture_baseline(conn, project, days_window=baseline.get("days_window", 7))
|
|
291
|
+
|
|
292
|
+
# Sessions since the fix was recorded (ANY row belonging to a session_id
|
|
293
|
+
# with a timestamp after fix.created_at)
|
|
294
|
+
sessions_since = conn.execute(
|
|
295
|
+
"""SELECT COUNT(DISTINCT session_id) FROM sessions
|
|
296
|
+
WHERE project = ? AND timestamp > ?""",
|
|
297
|
+
(project, fix["created_at"] or 0),
|
|
298
|
+
).fetchone()[0] or 0
|
|
299
|
+
|
|
300
|
+
now = int(time.time())
|
|
301
|
+
days_elapsed = max(int((now - (fix["created_at"] or now)) / 86400), 0)
|
|
302
|
+
|
|
303
|
+
before_waste = baseline.get("waste_events", {}) or {}
|
|
304
|
+
after_waste = current.get("waste_events", {}) or {}
|
|
305
|
+
|
|
306
|
+
def _waste_delta(key):
|
|
307
|
+
b = before_waste.get(key, 0) or 0
|
|
308
|
+
a = after_waste.get(key, 0) or 0
|
|
309
|
+
return {"before": b, "after": a, "pct_change": _pct_change(b, a)}
|
|
310
|
+
|
|
311
|
+
total_before = before_waste.get("total", 0) or 0
|
|
312
|
+
total_after = after_waste.get("total", 0) or 0
|
|
313
|
+
|
|
314
|
+
before_eff = baseline.get("effective_window_pct", 0) or 0
|
|
315
|
+
after_eff = current.get("effective_window_pct", 0) or 0
|
|
316
|
+
|
|
317
|
+
before_fpw = baseline.get("files_per_window", 0) or 0
|
|
318
|
+
after_fpw = current.get("files_per_window", 0) or 0
|
|
319
|
+
|
|
320
|
+
before_cps = baseline.get("avg_cost_per_session", 0) or 0
|
|
321
|
+
after_cps = current.get("avg_cost_per_session", 0) or 0
|
|
322
|
+
|
|
323
|
+
before_total_cost = baseline.get("cost_usd", 0) or 0
|
|
324
|
+
after_total_cost = current.get("cost_usd", 0) or 0
|
|
325
|
+
|
|
326
|
+
# Token savings are the reduction in attributed waste tokens
|
|
327
|
+
tokens_saved = max(
|
|
328
|
+
(baseline.get("tokens_wasted_on_floundering", 0)
|
|
329
|
+
+ baseline.get("tokens_wasted_on_repeated_reads", 0))
|
|
330
|
+
- (current.get("tokens_wasted_on_floundering", 0)
|
|
331
|
+
+ current.get("tokens_wasted_on_repeated_reads", 0)),
|
|
332
|
+
0,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# API-equivalent monthly savings — always computed, reported as a
|
|
336
|
+
# distinct field, never framed as real dollars for flat-plan users.
|
|
337
|
+
before_month = before_total_cost / max(baseline.get("days_window", 7), 1) * 30.0
|
|
338
|
+
after_month = after_total_cost / max(current.get("days_window", 7), 1) * 30.0
|
|
339
|
+
api_equivalent_savings_monthly = round(max(before_month - after_month, 0), 2)
|
|
340
|
+
|
|
341
|
+
# Output multiplier
|
|
342
|
+
if before_fpw > 0 and after_fpw > 0:
|
|
343
|
+
improvement_multiplier = round(after_fpw / before_fpw, 2)
|
|
344
|
+
else:
|
|
345
|
+
improvement_multiplier = 1.0
|
|
346
|
+
|
|
347
|
+
primary_metric = "window_efficiency" if plan_type in ("max", "pro") else "cost_usd"
|
|
348
|
+
|
|
349
|
+
delta = {
|
|
350
|
+
"plan_type": plan_type,
|
|
351
|
+
"plan_cost_usd": plan_cost,
|
|
352
|
+
"primary_metric": primary_metric,
|
|
353
|
+
"days_elapsed": days_elapsed,
|
|
354
|
+
"sessions_since_fix": sessions_since,
|
|
355
|
+
"waste_events": {"before": total_before, "after": total_after,
|
|
356
|
+
"pct_change": _pct_change(total_before, total_after)},
|
|
357
|
+
"floundering": _waste_delta("floundering"),
|
|
358
|
+
"repeated_reads": _waste_delta("repeated_reads"),
|
|
359
|
+
"deep_no_compact": _waste_delta("deep_no_compact"),
|
|
360
|
+
"cost_outliers": _waste_delta("cost_outliers"),
|
|
361
|
+
"effective_window_pct": {"before": round(before_eff, 1),
|
|
362
|
+
"after": round(after_eff, 1),
|
|
363
|
+
"pct_change": _pct_change(before_eff, after_eff)},
|
|
364
|
+
"tokens_saved": tokens_saved,
|
|
365
|
+
"files_per_window": {"before": before_fpw, "after": after_fpw,
|
|
366
|
+
"pct_change": _pct_change(before_fpw, after_fpw)},
|
|
367
|
+
"avg_cost_per_session": {"before": round(before_cps, 4),
|
|
368
|
+
"after": round(after_cps, 4),
|
|
369
|
+
"pct_change": _pct_change(before_cps, after_cps)},
|
|
370
|
+
"cost_usd": {"before": round(before_total_cost, 2),
|
|
371
|
+
"after": round(after_total_cost, 2),
|
|
372
|
+
"pct_change": _pct_change(before_total_cost, after_total_cost)},
|
|
373
|
+
"avg_turns_per_session": {"before": baseline.get("avg_turns_per_session", 0),
|
|
374
|
+
"after": current.get("avg_turns_per_session", 0),
|
|
375
|
+
"pct_change": _pct_change(
|
|
376
|
+
baseline.get("avg_turns_per_session", 0),
|
|
377
|
+
current.get("avg_turns_per_session", 0))},
|
|
378
|
+
"api_equivalent_savings_monthly": api_equivalent_savings_monthly,
|
|
379
|
+
"improvement_multiplier": improvement_multiplier,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
verdict = determine_verdict(delta, plan_type, sessions_since)
|
|
383
|
+
return delta, verdict, current
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def determine_verdict(delta, plan_type, sessions_since):
|
|
387
|
+
"""Return 'improving' | 'worsened' | 'neutral' | 'insufficient_data'.
|
|
388
|
+
|
|
389
|
+
Plan-aware: max/pro use window efficiency as the primary signal; api
|
|
390
|
+
uses raw cost. Waste events are always a valid trigger in both modes
|
|
391
|
+
because reducing waste should help either metric.
|
|
392
|
+
"""
|
|
393
|
+
if sessions_since < MIN_SESSIONS_FOR_VERDICT:
|
|
394
|
+
return "insufficient_data"
|
|
395
|
+
|
|
396
|
+
waste_pct = delta["waste_events"]["pct_change"]
|
|
397
|
+
if waste_pct <= -WASTE_IMPROVING_PCT:
|
|
398
|
+
return "improving"
|
|
399
|
+
if waste_pct >= WASTE_WORSENED_PCT:
|
|
400
|
+
return "worsened"
|
|
401
|
+
|
|
402
|
+
if plan_type in ("max", "pro"):
|
|
403
|
+
eff_pct = delta["effective_window_pct"]["pct_change"]
|
|
404
|
+
if eff_pct >= WINDOW_IMPROVING_PCT:
|
|
405
|
+
return "improving"
|
|
406
|
+
if eff_pct <= -WINDOW_WORSENED_PCT:
|
|
407
|
+
return "worsened"
|
|
408
|
+
else: # api
|
|
409
|
+
cost_pct = delta["cost_usd"]["pct_change"]
|
|
410
|
+
if cost_pct <= -COST_IMPROVING_PCT:
|
|
411
|
+
return "improving"
|
|
412
|
+
if cost_pct >= COST_WORSENED_PCT:
|
|
413
|
+
return "worsened"
|
|
414
|
+
|
|
415
|
+
return "neutral"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def record_fix(conn, project, waste_pattern, title, fix_type, fix_detail):
|
|
419
|
+
"""Capture a baseline and persist a new fix row. Return (fix_id, baseline)."""
|
|
420
|
+
if waste_pattern not in WASTE_PATTERNS:
|
|
421
|
+
waste_pattern = "custom"
|
|
422
|
+
if fix_type not in FIX_TYPES:
|
|
423
|
+
fix_type = "other"
|
|
424
|
+
baseline = capture_baseline(conn, project)
|
|
425
|
+
fix_id = insert_fix(conn, project, waste_pattern, title, fix_type, fix_detail, baseline)
|
|
426
|
+
return fix_id, baseline
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def measure_fix(conn, fix_id):
|
|
430
|
+
"""Run a measurement for a fix and persist it. Returns
|
|
431
|
+
(delta_json, verdict, metrics) or (None, 'not_found', None)."""
|
|
432
|
+
delta, verdict, metrics = compute_delta(conn, fix_id)
|
|
433
|
+
if delta is None:
|
|
434
|
+
return None, verdict, None
|
|
435
|
+
insert_fix_measurement(conn, fix_id, metrics, delta, verdict)
|
|
436
|
+
# Promote to 'confirmed' once we have a durable improvement
|
|
437
|
+
if verdict == "improving" and delta.get("days_elapsed", 0) >= CONFIRM_MIN_DAYS:
|
|
438
|
+
update_fix_status(conn, fix_id, "confirmed")
|
|
439
|
+
elif verdict == "worsened":
|
|
440
|
+
update_fix_status(conn, fix_id, "applied") # keep 'applied' on regression
|
|
441
|
+
else:
|
|
442
|
+
update_fix_status(conn, fix_id, "measuring")
|
|
443
|
+
return delta, verdict, metrics
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ─── Share card ──────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
def build_share_card(fix, latest_measurement):
|
|
449
|
+
"""Return a plain-text share card, plan-aware. See module docstring for
|
|
450
|
+
the framing rules."""
|
|
451
|
+
baseline = json.loads(fix["baseline_json"] or "{}")
|
|
452
|
+
plan_type = baseline.get("plan_type", "max")
|
|
453
|
+
plan_cost = baseline.get("plan_cost_usd", 0)
|
|
454
|
+
project = fix["project"]
|
|
455
|
+
pattern = fix["waste_pattern"] or ""
|
|
456
|
+
pattern_label = WASTE_PATTERN_LABELS.get(pattern, pattern.replace("_", " "))
|
|
457
|
+
title = fix["title"] or "(no title)"
|
|
458
|
+
border = "─" * 46
|
|
459
|
+
|
|
460
|
+
lines = [border, "Fixed a Claude Code waste pattern with Claudash", ""]
|
|
461
|
+
lines.append(f"Project: {project}")
|
|
462
|
+
lines.append(f"Issue: {pattern_label}")
|
|
463
|
+
lines.append(f"Fix: {title}")
|
|
464
|
+
lines.append("")
|
|
465
|
+
|
|
466
|
+
if not latest_measurement:
|
|
467
|
+
lines.append("(No measurements yet — run `cli.py measure` after ≥7 days.)")
|
|
468
|
+
lines.append("")
|
|
469
|
+
lines.append("Detected by Claudash — github.com/yourusername/claudash")
|
|
470
|
+
lines.append(border)
|
|
471
|
+
return "\n".join(lines)
|
|
472
|
+
|
|
473
|
+
delta = json.loads(latest_measurement.get("delta_json") or "{}")
|
|
474
|
+
days = delta.get("days_elapsed", 0)
|
|
475
|
+
waste = delta.get("waste_events", {})
|
|
476
|
+
waste_before = waste.get("before", 0)
|
|
477
|
+
waste_after = waste.get("after", 0)
|
|
478
|
+
waste_pct = waste.get("pct_change", 0)
|
|
479
|
+
|
|
480
|
+
lines.append(f"Before → After ({days} days):")
|
|
481
|
+
lines.append(f"• {pattern_label} events: {waste_before} → {waste_after} ({_signed(waste_pct)}%)")
|
|
482
|
+
|
|
483
|
+
if plan_type in ("max", "pro"):
|
|
484
|
+
eff = delta.get("effective_window_pct", {})
|
|
485
|
+
fpw = delta.get("files_per_window", {})
|
|
486
|
+
lines.append(f"• Window efficiency: {eff.get('before', 0)}% → {eff.get('after', 0)}% useful tokens")
|
|
487
|
+
lines.append(f"• Output per window: {fpw.get('before', 0)} → {fpw.get('after', 0)} files ({_signed(fpw.get('pct_change', 0))}%)")
|
|
488
|
+
lines.append("")
|
|
489
|
+
mult = delta.get("improvement_multiplier", 1.0)
|
|
490
|
+
api_eq = delta.get("api_equivalent_savings_monthly", 0)
|
|
491
|
+
lines.append(f"Same ${plan_cost:.0f}/mo plan. {mult}× more output.")
|
|
492
|
+
lines.append(f"API-equivalent waste eliminated: ~${api_eq:.0f}/mo")
|
|
493
|
+
else: # api
|
|
494
|
+
cps = delta.get("avg_cost_per_session", {})
|
|
495
|
+
monthly = delta.get("api_equivalent_savings_monthly", 0)
|
|
496
|
+
lines.append(f"• Cost per session: ${cps.get('before', 0):.2f} → ${cps.get('after', 0):.2f} ({_signed(cps.get('pct_change', 0))}%)")
|
|
497
|
+
lines.append(f"• Monthly savings: ~${monthly:.0f}/mo")
|
|
498
|
+
|
|
499
|
+
lines.append("")
|
|
500
|
+
lines.append("Detected by Claudash — github.com/yourusername/claudash")
|
|
501
|
+
lines.append(border)
|
|
502
|
+
return "\n".join(lines)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _signed(pct):
|
|
506
|
+
"""Format a percent change with an explicit sign."""
|
|
507
|
+
return f"+{pct:.0f}" if pct > 0 else f"{pct:.0f}"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ─── Aggregation helpers used by API/CLI/UI ──────────────────────
|
|
511
|
+
|
|
512
|
+
def fix_with_latest(conn, fix_id):
|
|
513
|
+
"""Return a fix dict with baseline parsed, measurements list, and
|
|
514
|
+
a top-level `latest` measurement shortcut."""
|
|
515
|
+
fix = get_fix(conn, fix_id)
|
|
516
|
+
if not fix:
|
|
517
|
+
return None
|
|
518
|
+
fix["baseline"] = json.loads(fix.pop("baseline_json") or "{}")
|
|
519
|
+
measurements = get_fix_measurements(conn, fix_id)
|
|
520
|
+
for m in measurements:
|
|
521
|
+
m["metrics"] = json.loads(m.pop("metrics_json") or "{}")
|
|
522
|
+
m["delta"] = json.loads(m.pop("delta_json") or "{}")
|
|
523
|
+
fix["measurements"] = measurements
|
|
524
|
+
fix["latest"] = measurements[-1] if measurements else None
|
|
525
|
+
return fix
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def all_fixes_with_latest(conn):
|
|
529
|
+
rows = get_all_fixes(conn)
|
|
530
|
+
out = []
|
|
531
|
+
for r in rows:
|
|
532
|
+
r["baseline"] = json.loads(r.pop("baseline_json") or "{}")
|
|
533
|
+
latest = get_latest_fix_measurement(conn, r["id"])
|
|
534
|
+
if latest:
|
|
535
|
+
latest["metrics"] = json.loads(latest.pop("metrics_json") or "{}")
|
|
536
|
+
latest["delta"] = json.loads(latest.pop("delta_json") or "{}")
|
|
537
|
+
r["latest"] = latest
|
|
538
|
+
out.append(r)
|
|
539
|
+
return out
|