Skip to content

Instantly share code, notes, and snippets.

@scriptease
Created August 1, 2026 09:11
Show Gist options
  • Select an option

  • Save scriptease/8d6bf1716ad5b3de6e4cc50adf4cf069 to your computer and use it in GitHub Desktop.

Select an option

Save scriptease/8d6bf1716ad5b3de6e4cc50adf4cf069 to your computer and use it in GitHub Desktop.
insights-clean.py — provenance-filtered, config-aware rebuild of Claude Code's /insights (only analyzes sessions a human actually drove; gap analysis against your CLAUDE.md/skills/automation config)
#!/usr/bin/env python3
"""insights-clean.py — config-aware rebuild of Claude Code /insights.
Fixes vs the built-in:
1. Provenance filtering: only sessions with real typed user input
(promptSource=="typed") are analyzed. Observer sessions, Cue/headless
runs, and SDK-driven sessions are counted but excluded from analysis.
2. Config-aware suggestions: the report prompt receives CLAUDE.md files,
the skill inventory, and cue.yaml, and must not suggest what already
exists.
3. Honest stats: interactive vs headless vs observer split is explicit.
Usage:
insights-clean.py --dry-run # classification stats only, no LLM
insights-clean.py # full run (facets + report)
insights-clean.py --days 30 --max-new-facets 50
insights-clean.py --report-only # reuse cached facets, just regenerate report
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
PROJECTS_DIR = Path.home() / ".claude" / "projects"
DATA_DIR = Path.home() / ".claude" / "usage-data" / "insights-clean"
META_DIR = DATA_DIR / "meta"
FACETS_DIR = DATA_DIR / "facets"
WORK_DIR = Path("/tmp/insights-clean-work") # cwd for claude -p calls, excluded from scans
# Project dirs never analyzed (observer/synthetic session homes)
EXCLUDED_DIR_SUBSTRINGS = [
"claude-mem-observer-sessions",
"tmp-insights-clean-work",
]
FACET_PROMPT = """Analyze this Claude Code session and extract structured facets.
RESPOND WITH ONLY A VALID JSON OBJECT — no prose, no markdown fences — with exactly these keys:
"underlying_goal": string — what the USER was trying to achieve
"goal_categories": object mapping category -> count, categories: coding, debugging, research, writing, automation, configuration, other, warmup_minimal
"outcome": "achieved" | "partially_achieved" | "not_achieved"
"user_satisfaction": "happy" | "satisfied" | "likely_satisfied" | "dissatisfied" | "frustrated" | "unknown"
"claude_helpfulness": "essential" | "very_helpful" | "moderately_helpful" | "slightly_helpful" | "unhelpful"
"session_type": "single_task" | "multi_task" | "iterative_refinement" | "exploration"
"friction_counts": object mapping friction type -> count, types: misunderstood_request, wrong_approach, buggy_code, user_rejected_action, excessive_changes, environment_issue
"friction_detail": string — one sentence on the worst friction, or ""
"brief_summary": string — 1-2 sentences on what happened
"user_instructions_to_claude": array of strings — explicit process corrections/preferences the user stated (max 3)
CRITICAL GUIDELINES:
1. goal_categories: count ONLY what the USER explicitly asked for, not Claude's autonomous work.
2. user_satisfaction: base ONLY on explicit user signals in their messages.
3. friction_counts: be specific; empty object if none.
4. If very short or just warmup, use goal_categories {"warmup_minimal": 1}.
SESSION:
"""
def log(msg):
print(msg, file=sys.stderr, flush=True)
# ---------- session scanning ----------
def list_session_files():
files = []
for d in PROJECTS_DIR.iterdir():
if not d.is_dir():
continue
if any(s in d.name for s in EXCLUDED_DIR_SUBSTRINGS):
continue
for f in d.glob("*.jsonl"):
files.append(f)
return files
def scan_session(path):
"""Light single pass: provenance + counts. Returns metadata dict."""
typed = 0
user_msgs = 0
interruptions = 0
ts_first = None
ts_last = None
cwd = None
entrypoints = Counter()
with open(path, "r", errors="replace") as fh:
for line in fh:
if '"timestamp"' in line:
m = re.search(r'"timestamp":"([^"]+)"', line)
if m:
if ts_first is None:
ts_first = m.group(1)
ts_last = m.group(1)
if '"type":"user"' not in line:
continue
if '"isMeta":true' in line:
continue
if '"tool_result"' in line or '"tool_use_id"' in line:
continue
user_msgs += 1
if '"promptSource":"typed"' in line:
typed += 1
m = re.search(r'"entrypoint":"([^"]+)"', line)
if m:
entrypoints[m.group(1)] += 1
if cwd is None:
m = re.search(r'"cwd":"([^"]+)"', line)
if m:
cwd = m.group(1)
if "[Request interrupted by user" in line:
interruptions += 1
dur = 0.0
if ts_first and ts_last:
try:
t0 = datetime.fromisoformat(ts_first.replace("Z", "+00:00"))
t1 = datetime.fromisoformat(ts_last.replace("Z", "+00:00"))
dur = (t1 - t0).total_seconds() / 60
except ValueError:
pass
return {
"session_id": path.stem,
"project_dir": path.parent.name,
"cwd": cwd or "",
"start_time": ts_first or "",
"duration_minutes": round(dur, 1),
"user_message_count": user_msgs,
"typed_message_count": typed,
"interruptions": interruptions,
"entrypoints": dict(entrypoints),
"transcript_mtime": path.stat().st_mtime,
}
def get_meta(path):
"""Cached scan_session."""
cache = META_DIR / f"{path.stem}.json"
mtime = path.stat().st_mtime
if cache.exists():
try:
d = json.load(open(cache))
if d.get("transcript_mtime") == mtime:
return d
except (json.JSONDecodeError, OSError):
pass
d = scan_session(path)
META_DIR.mkdir(parents=True, exist_ok=True)
json.dump(d, open(cache, "w"), indent=1)
return d
def classify(meta):
if "claude-mem" in meta["cwd"]:
return "observer"
if meta["typed_message_count"] > 0:
return "interactive"
if meta["user_message_count"] >= 2:
# SDK-driven but multi-prompt: resumed/steered (e.g. via Discord relay)
return "steered"
return "headless"
# ---------- transcript condensation for facet extraction ----------
def condense_transcript(path, max_chars=40000):
parts = []
with open(path, "r", errors="replace") as fh:
for line in fh:
try:
d = json.loads(line)
except json.JSONDecodeError:
continue
if d.get("isMeta"):
continue
t = d.get("type")
msg = d.get("message")
if not isinstance(msg, dict):
continue
content = msg.get("content")
if t == "user":
if isinstance(content, str):
parts.append(f"USER: {content.strip()[:2000]}")
elif isinstance(content, list):
for c in content:
if isinstance(c, dict) and c.get("type") == "text":
parts.append(f"USER: {c.get('text','').strip()[:2000]}")
elif t == "assistant" and isinstance(content, list):
for c in content:
if not isinstance(c, dict):
continue
if c.get("type") == "text" and c.get("text", "").strip():
parts.append(f"ASSISTANT: {c['text'].strip()[:1500]}")
elif c.get("type") == "tool_use":
parts.append(f"ASSISTANT uses tool: {c.get('name','?')}")
text = "\n".join(parts)
if len(text) > max_chars:
head, tail = int(max_chars * 0.6), int(max_chars * 0.35)
text = text[:head] + "\n[... middle truncated ...]\n" + text[-tail:]
return text
# ---------- LLM calls via headless claude ----------
def claude_query(prompt, model, timeout=300):
WORK_DIR.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
["claude", "-p", "--model", model],
input=prompt, capture_output=True, text=True,
timeout=timeout, cwd=WORK_DIR,
)
if r.returncode != 0:
raise RuntimeError(f"claude -p failed: {r.stderr[:300]}")
return r.stdout.strip()
def extract_facet(path, meta, model):
transcript = condense_transcript(path)
if not transcript.strip():
return None
out = claude_query(FACET_PROMPT + transcript, model)
m = re.search(r"\{[\s\S]*\}", out)
if not m:
return None
try:
facet = json.loads(m.group(0))
except json.JSONDecodeError:
return None
facet["session_id"] = meta["session_id"]
facet["project_dir"] = meta["project_dir"]
facet["start_time"] = meta["start_time"]
return facet
# ---------- config context ----------
def read_trunc(p, limit=8000):
try:
return Path(p).expanduser().read_text(errors="replace")[:limit]
except OSError:
return ""
def skill_inventory(project_cwds):
lines = []
dirs = [Path.home() / ".claude" / "skills"]
dirs += [Path(c) / ".claude" / "skills" for c in project_cwds]
seen = set()
for d in dirs:
if not d.is_dir():
continue
for sk in sorted(d.iterdir()):
f = sk / "SKILL.md"
key = sk.name
if key in seen or not f.exists():
continue
seen.add(key)
desc = ""
for line in f.read_text(errors="replace").splitlines()[:15]:
if line.startswith("description:"):
desc = line[len("description:"):].strip()[:200]
break
lines.append(f"- {sk.name}: {desc}")
return "\n".join(lines)
def gather_config(top_cwds):
sections = []
g = read_trunc("~/.claude/CLAUDE.md")
if g:
sections.append("## Global CLAUDE.md\n" + g)
for c in top_cwds[:3]:
p = Path(c) / "CLAUDE.md"
if p.exists():
sections.append(f"## Project CLAUDE.md ({c})\n" + read_trunc(p))
cue = Path(c) / ".maestro" / "cue.yaml"
if cue.exists():
sections.append(f"## Cue automation ({c}/.maestro/cue.yaml)\n" + read_trunc(cue, 4000))
inv = skill_inventory(top_cwds)
if inv:
sections.append("## Installed skills\n" + inv)
return "\n\n".join(sections)
# ---------- report ----------
REPORT_PROMPT = """You are generating a Claude Code usage insights report for a senior developer.
You are given:
A) STATS — deterministic numbers computed from session transcripts (trust these exactly).
B) FACETS — per-session LLM-extracted summaries of INTERACTIVE sessions only (sessions where the user actually typed). Headless automation and observer sessions were deliberately excluded from analysis.
C) USER CONFIG — the user's actual CLAUDE.md files, installed skills, and cue.yaml automation config.
Write a markdown report with these sections:
# Insights (clean)
## At a glance — 3-4 sentences, honest about sample size.
## Project areas — group the facets into real work threads; give per-thread session counts.
## What worked — concrete workflows that went well, cite specific sessions.
## Friction — recurring problems, only if they appear in 2+ distinct sessions; one-offs go in a single line at the end.
## Gap analysis — THE KEY SECTION. Compare observed behavior against USER CONFIG:
- Suggest ONLY things NOT already covered by existing CLAUDE.md rules, skills, hooks, or cue.yaml subscriptions.
- If a session shows the user manually doing something an existing skill/cue already automates, flag that as "you have this automated but did it by hand".
- If existing config was violated or ignored in sessions, flag it.
- Never recommend a tool, script, or workflow that does not exist without marking it clearly as "would need to be built".
- If there are no genuine gaps, say so. An empty gap analysis is a valid result.
Rules: be blunt and concise, no filler praise, no invented statistics, cite session ids (8 chars) where useful. Do not exceed 150 lines.
=== A) STATS ===
{stats}
=== B) FACETS ===
{facets}
=== C) USER CONFIG ===
{config}
"""
def build_report(stats, facets, config, model):
facet_lines = []
for f in facets:
facet_lines.append(json.dumps({k: f.get(k) for k in (
"session_id", "project_dir", "start_time", "underlying_goal", "outcome",
"user_satisfaction", "session_type", "friction_counts", "friction_detail",
"brief_summary", "user_instructions_to_claude")}, ensure_ascii=False))
prompt = REPORT_PROMPT.format(
stats=json.dumps(stats, indent=1),
facets="\n".join(facet_lines),
config=config,
)
return claude_query(prompt, model, timeout=600)
# ---------- main ----------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--days", type=int, default=30)
ap.add_argument("--max-new-facets", type=int, default=50)
ap.add_argument("--dry-run", action="store_true", help="classification stats only, no LLM calls")
ap.add_argument("--report-only", action="store_true", help="skip facet extraction, use cache")
ap.add_argument("--parallel", type=int, default=5)
ap.add_argument("--model-facets", default="claude-haiku-4-5")
ap.add_argument("--model-report", default="claude-sonnet-5")
args = ap.parse_args()
cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)
files = list_session_files()
log(f"Scanning {len(files)} session files ...")
metas = {}
for i, f in enumerate(files):
if f.stat().st_mtime < cutoff.timestamp():
continue
m = get_meta(f)
m["_path"] = str(f)
metas[m["session_id"]] = m
if (i + 1) % 200 == 0:
log(f" scanned {i+1}/{len(files)}")
classes = Counter()
interactive = []
for m in metas.values():
c = classify(m)
classes[c] += 1
if c in ("interactive", "steered") and m["user_message_count"] >= 2 and m["duration_minutes"] >= 1:
m["class"] = c
interactive.append(m)
elif c == "interactive":
classes["interactive_too_short"] += 1
interactive.sort(key=lambda m: m["start_time"], reverse=True)
proj_counts = Counter(m["cwd"] for m in interactive)
stats = {
"window_days": args.days,
"sessions_in_window": len(metas),
"classification": dict(classes),
"analyzed_interactive": len(interactive),
"top_project_cwds": proj_counts.most_common(10),
"total_interruptions": sum(m["interruptions"] for m in interactive),
"total_typed_messages": sum(m["typed_message_count"] for m in interactive),
"total_hours_interactive": round(sum(m["duration_minutes"] for m in interactive) / 60, 1),
"date_range": [
min((m["start_time"] for m in interactive), default=""),
max((m["start_time"] for m in interactive), default=""),
],
}
log(json.dumps(stats, indent=1))
if args.dry_run:
print(json.dumps(stats, indent=1))
return
# facet extraction
FACETS_DIR.mkdir(parents=True, exist_ok=True)
facets = []
todo = []
for m in interactive:
cache = FACETS_DIR / f"{m['session_id']}.json"
if cache.exists():
try:
facets.append(json.load(open(cache)))
continue
except (json.JSONDecodeError, OSError):
pass
todo.append(m)
if not args.report_only:
todo = todo[: args.max_new_facets]
log(f"Extracting facets for {len(todo)} sessions ({args.model_facets}, {args.parallel} parallel) ...")
from concurrent.futures import ThreadPoolExecutor, as_completed
done = 0
with ThreadPoolExecutor(max_workers=args.parallel) as ex:
futs = {ex.submit(extract_facet, Path(m["_path"]), m, args.model_facets): m for m in todo}
for fut in as_completed(futs):
m = futs[fut]
done += 1
try:
f = fut.result()
except (RuntimeError, subprocess.TimeoutExpired) as e:
log(f" facet failed {m['session_id'][:8]}: {e}")
continue
if f:
json.dump(f, open(FACETS_DIR / f"{m['session_id']}.json", "w"), indent=1)
facets.append(f)
log(f" [{done}/{len(todo)}] {m['session_id'][:8]} {'ok' if f else 'SKIP'}")
# drop warmups
facets = [f for f in facets if list(f.get("goal_categories", {}).keys()) != ["warmup_minimal"]]
stats["facets_used"] = len(facets)
top_cwds = [c for c, _ in proj_counts.most_common(5) if c]
config = gather_config(top_cwds)
log(f"Config context: {len(config)} chars. Generating report ({args.model_report}) ...")
report = build_report(stats, facets, config, args.model_report)
ts = datetime.now().strftime("%Y-%m-%d-%H%M%S")
out = DATA_DIR / f"report-{ts}.md"
out.write_text(report + "\n\n---\n## Appendix: deterministic stats\n```json\n"
+ json.dumps(stats, indent=1) + "\n```\n")
(DATA_DIR / "report.md").write_text(out.read_text())
print(out)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment