session-scan
Compute metrics for Claude Code sessions. Discovers via ccrider, filters trivial, computes friction/opportunity/fingerprint scores. Use for broad session triage.
Install
npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/.claude/skills/session-scan
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
git clone https://github.com/oliver-kriska/claude-elixir-phoenix.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole oliver-kriska/claude-elixir-phoenix collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Session Scan (Tier 1)
Compute deterministic metrics for sessions discovered via ccrider MCP.
STOP — Read Before Executing
NEVER call mcp__ccrider__get_session_messages in main context.
Each response is 5-50KB of JSON. Fetching N sessions directly = context crash.
You MUST delegate all scoring to subagents. The main context only does: discovery, deduplication, subagent spawning, and result collection.
Requirements
Requires ccrider MCP. If not available:
ccrider MCP is required. See: https://github.com/neilberkman/ccrider
Usage
/session-scan # Last 7 days, up to 50 sessions
/session-scan --since 2026-02-01 # Since specific date
/session-scan --project enaia # Filter by project name
/session-scan --limit 20 # Cap session count
/session-scan --list # Discovery only, no scoring
/session-scan --rescan # Recompute already-scanned sessions
What Main Context Does
1. Parse Arguments
Extract from $ARGUMENTS:
--since DATE: ISO date filter (default: 7 days ago)--project NAME: Filter by project path substring--limit N: Max sessions to process (default: 50)--list: Discovery only — show table, skip scoring--rescan: Recompute metrics for already-scanned sessions
2. Discover Sessions (safe — response is ~1KB)
mcp__ccrider__list_recent_sessions(limit: N, project: PROJECT, after_date: SINCE)
Filter out sessions with < 10 messages. If --list: show table and stop.
3. Deduplicate
Read .claude/session-metrics/metrics.jsonl (if exists).
Skip sessions already scanned unless --rescan flag is set.
Report: "N new sessions to scan (M already in ledger)"
4. Resolve Scorer Path
Glob: **/session-scan/references/compute-metrics.py
Store as SCORER_PATH (absolute). PROJECT_ROOT = current working directory.
5. Spawn ONE Subagent Per Session
DO NOT fetch messages yourself. DO NOT run Python yourself. You MUST use the Task tool to spawn subagents.
For EACH unscanned session, spawn a haiku subagent using this template:
Task(subagent_type="general-purpose", model="haiku", mode="bypassPermissions", prompt="""
Score one Claude Code session. Steps:
1. Fetch messages:
mcp__ccrider__get_session_messages(session_id: "{SESSION_ID}", last_n: 200)
2. Write the full tool result (messages JSON) to:
{PROJECT_ROOT}/.claude/session-metrics/_tmp_{SHORT_ID}.json
3. Run scorer and capture result in ONE Bash command:
python3 {SCORER_PATH} {PROJECT_ROOT}/.claude/session-metrics/_tmp_{SHORT_ID}.json --session-id {SESSION_ID} --project {PROJECT_NAME} > {PROJECT_ROOT}/.claude/session-metrics/_result_{SHORT_ID}.json && rm {PROJECT_ROOT}/.claude/session-metrics/_tmp_{SHORT_ID}.json
4. Report back: "Scored {SHORT_ID}: friction=X.XX fingerprint=Y"
If ccrider fails, report the error and exit.
""")
Spawn ALL subagents in parallel. Wait for all to complete.
6. Collect Results
for f in .claude/session-metrics/_result_*.json; do
cat "$f" >> .claude/session-metrics/metrics.jsonl
rm "$f"
done
7. Display Results
Show summary table sorted by friction (descending):
| ID | Project | Date | Type | Friction | Opportunity | Tier2? |
|----------|---------|------------|-------------|----------|-------------|--------|
| ffa155ee | enaia | 2026-02-18 | bug-fix | 0.42 | 0.65 | Yes |
| 90a74843 | wedding | 2026-02-17 | feature | 0.15 | 0.20 | No |
If Tier 2 eligible: suggest /session-deep-dive --from-scan.
8. Update Scan Metadata
Write .claude/session-metrics/latest-scan.json:
{
"scanned_at": "2026-02-20T14:30:00Z",
"sessions_discovered": 25,
"sessions_scanned": 18,
"sessions_skipped": 7,
"tier2_eligible": 5
}
Scoring Reference
See references/scoring-guide.md for full algorithm documentation.
- Friction Score (0-1): Weighted signals, sigmoid-normalized
- Fingerprint: Rule-based classifier (6 types)
- Plugin Opportunity (0-1): Missed
/phx:commands - Tier 2 Eligible: friction > 0.35 OR opportunity > 0.5 OR plugin used OR msgs > 50
Iron Laws
- NEVER call
get_session_messagesin main context — WILL crash context - ONE subagent per session — each fetches exactly ONE session
mode="bypassPermissions"on every Task — subagents need Write+Bash- Absolute paths in subagent prompts — subagents don't inherit context
- Result files, not direct append — avoid jsonl race conditions
- NEVER modify existing metrics.jsonl entries — append-only ledger
- ALWAYS delete temp and result files after processing
Files (claude-elixir-phoenix)
-
references
-
compute-metrics.py 66.5 KB
#!/usr/bin/env python3 """ Session Analytics v2 — Compute deterministic metrics from Claude Code sessions. Reads ccrider message JSON and computes friction scores, fingerprints, plugin opportunity scores, tool bigrams, file hotspots, and session chaining. Usage: # Single session (outputs JSON to stdout) python3 compute-metrics.py <messages.json> --session-id ID --project NAME # Batch mode (appends to metrics.jsonl) python3 compute-metrics.py --batch <manifest.json> # Trends mode (computes windowed aggregates) python3 compute-metrics.py --trends <metrics.jsonl> [--memory MEMORY.md] # Backfill from v1 extracts python3 compute-metrics.py --backfill <extracts-dir/> """ import json import math import os import re import sys from collections import Counter, defaultdict from datetime import datetime, timedelta, timezone # ─── Friction Score Weights ─────────────────────────────────────────────────── FRICTION_WEIGHTS = { "error_tool_ratio": 2.0, "retry_loops": 3.0, "user_corrections": 2.5, "approach_changes": 2.0, "context_compactions": 1.5, "interrupted_requests": 1.0, } # Sigmoid normalization: score = 1 / (1 + e^(-k*(raw - midpoint))) FRICTION_SIGMOID_K = 3.0 FRICTION_SIGMOID_MIDPOINT = 1.5 # ─── Fingerprint Rules ─────────────────────────────────────────────────────── CORRECTION_PATTERNS = re.compile( r"\b(no[,.]?\s|wrong|instead|actually|that'?s not|not what I|" r"I meant|I said|please don'?t|stop|undo|revert)\b", re.IGNORECASE, ) FINGERPRINT_KEYWORDS = { "bug-fix": re.compile( r"\b(fix|bug|broken|error|issue|crash|fail|debug|wrong)\b", re.IGNORECASE ), "feature": re.compile( r"\b(add|implement|build|create|new feature|scaffold)\b", re.IGNORECASE ), "exploration": re.compile( r"\b(explore|understand|how does|what is|explain|look at)\b", re.IGNORECASE ), "maintenance": re.compile( r"\b(deps?|update|upgrade|bump|version|migrate)\b", re.IGNORECASE ), "review": re.compile( r"\b(review|PR|pull request|code review|feedback)\b", re.IGNORECASE ), "refactoring": re.compile( r"\b(refactor|extract|rename|move|reorganize|clean ?up)\b", re.IGNORECASE ), } # ─── Plugin Opportunity Signals ─────────────────────────────────────────────── PHX_COMMAND_RE = re.compile(r"/phx:\w+") SKILL_COMMAND_RE = re.compile(r"/(?:phx|ecto|lv):[a-z][a-z0-9_-]*") # ─── Model Context Windows ──────────────────────────────────────────────────── # Inspired by badlogic / earendil-works/pi session-context-stats.mjs # (https://github.com/earendil-works/pi/blob/main/scripts/session-context-stats.mjs) MODEL_CONTEXT_WINDOWS = { # Native 1M window, no [1m] variant (Sonnet 5+, Opus 4.8+, Fable). "claude-sonnet-5-5": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-opus-5-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-fable-5-1": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-4-7": 200_000, "claude-opus-4-7[1m]": 1_000_000, "claude-opus-4-6": 200_000, "claude-opus-4-6[1m]": 1_000_000, "claude-sonnet-4-6": 200_000, "claude-sonnet-4-6[1m]": 1_000_000, "claude-sonnet-4-5": 200_000, "claude-haiku-4-5": 200_000, "claude-haiku-4-5-20251001": 200_000, "claude-3-5-sonnet-20241022": 200_000, "claude-3-5-haiku-20241022": 200_000, } def get_context_window(model): """Look up context window for a model, with fuzzy matching.""" if not model: return None if model in MODEL_CONTEXT_WINDOWS: return MODEL_CONTEXT_WINDOWS[model] base = re.sub(r"\[1m\]$", "", model) if base in MODEL_CONTEXT_WINDOWS: return MODEL_CONTEXT_WINDOWS[base] for known, ctx in MODEL_CONTEXT_WINDOWS.items(): if known in model: return ctx return None def sigmoid(raw): """Apply sigmoid normalization to raw friction score.""" return 1.0 / (1.0 + math.exp(-FRICTION_SIGMOID_K * (raw - FRICTION_SIGMOID_MIDPOINT))) # ─── Message Parsing ───────────────────────────────────────────────────────── def parse_messages(data): """Parse ccrider message JSON into structured lists. Accepts either: - A list of message objects (ccrider format) - A dict with a 'messages' key containing the list """ if isinstance(data, dict): messages = data.get("messages", []) elif isinstance(data, list): messages = data else: return [] return messages def _get_role(msg): """Get message role, supporting both API format (role) and ccrider format (type).""" return msg.get("role", msg.get("type", msg.get("message", {}).get("role", ""))) def _get_content(msg): """Get message content, supporting both API and ccrider formats.""" return msg.get("content", msg.get("message", {}).get("content", "")) # Tool name detection from assistant text (ccrider doesn't preserve tool_use blocks) TOOL_MENTION_RE = re.compile( r"\b(Read|Edit|Write|Bash|Grep|Glob|Task|NotebookEdit|WebFetch|WebSearch" r"|mcp__tidewave\w*)\b" ) BASH_CMD_RE = re.compile(r"(?:^|\n)\s*(?:\$|>)\s*(mix\s|git\s|npm\s|python3?\s|cd\s|rm\s)") def extract_tool_calls(messages): """Extract ordered list of tool calls from messages. For API format: extracts structured tool_use blocks. For ccrider format: infers tool names from assistant message text patterns. """ tools = [] for msg in messages: if not isinstance(msg, dict): continue content = _get_content(msg) role = _get_role(msg) # API format: structured content blocks if isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "tool_use": tools.append(block) # ccrider format: infer tools from assistant text elif isinstance(content, str) and role == "assistant": mentioned = TOOL_MENTION_RE.findall(content) for name in mentioned: tools.append({"name": name, "input": {}}) # Detect bash commands in text if BASH_CMD_RE.search(content): tools.append({"name": "Bash", "input": {}}) return tools def extract_user_messages(messages): """Extract user message texts.""" user_msgs = [] for msg in messages: if not isinstance(msg, dict): continue role = _get_role(msg) if role != "user": continue content = _get_content(msg) if isinstance(content, str): if not content.startswith("<system-reminder>") and not content.startswith( "<local-command-caveat>" ) and not content.startswith("<local-command-stdout>") and len(content) > 5: user_msgs.append(content) elif isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "text": text = block.get("text", "") if not text.startswith("<system-reminder>") and not text.startswith( "<command-name>" ): if len(text) > 5: user_msgs.append(text) return user_msgs def extract_errors(messages): """Extract tool errors from messages.""" errors = [] for msg in messages: if not isinstance(msg, dict): continue content = _get_content(msg) role = _get_role(msg) # API format: structured error blocks if isinstance(content, list): for block in content: if isinstance(block, dict): if block.get("type") == "tool_result" and block.get("is_error"): err = block.get("content", "") if isinstance(err, str) and len(err) > 5: errors.append(err[:200]) # ccrider format: detect error patterns in assistant text elif isinstance(content, str) and role == "assistant": if re.search(r"\b(error|Error|ERROR|failed|Failed|FAILED)\b", content): if re.search(r"\b(compilation|compile|test|credo|format)\s+(error|fail)", content, re.I): errors.append(content[:200]) return errors def extract_timestamps(messages): """Extract timestamps from messages.""" timestamps = [] for msg in messages: if not isinstance(msg, dict): continue ts = msg.get("timestamp") if ts: timestamps.append(ts) return timestamps def extract_token_usage(messages): """Extract per-turn token usage and model info. Works on raw Claude Code JSONL entries (which have `message.usage` blocks with cache_creation/cache_read breakdown). Returns None for ccrider-extracted data without usage info. Total prompt tokens per turn = input_tokens + cache_creation + cache_read. Compaction is inferred when prompt tokens drop >40% between consecutive turns. Extended fields (2026-05-23): cache TTL split (ephemeral_5m vs ephemeral_1h), per-turn hit rate array, cache decay events (TTL expiry vs compaction), per-model token breakdown, first-turn baseline. """ turns = [] models = [] for msg in messages: if not isinstance(msg, dict): continue inner = msg.get("message") if not isinstance(inner, dict): continue usage = inner.get("usage") if not isinstance(usage, dict): continue input_tokens = usage.get("input_tokens", 0) or 0 cache_creation = usage.get("cache_creation_input_tokens", 0) or 0 cache_read = usage.get("cache_read_input_tokens", 0) or 0 output_tokens = usage.get("output_tokens", 0) or 0 prompt_tokens = input_tokens + cache_creation + cache_read # Cache TTL split (1h extended vs default 5m). Absent in older sessions. cc_detail = usage.get("cache_creation") or {} cache_create_5m = cc_detail.get("ephemeral_5m_input_tokens", 0) or 0 cache_create_1h = cc_detail.get("ephemeral_1h_input_tokens", 0) or 0 # Per-turn hit rate: cached portion of incoming context this turn. denom = input_tokens + cache_creation + cache_read hit_rate = round(cache_read / denom, 4) if denom else 0.0 model = inner.get("model") if model: models.append(model) turns.append({ "model": model, "prompt_tokens": prompt_tokens, "input_tokens": input_tokens, "cache_creation_tokens": cache_creation, "cache_creation_5m": cache_create_5m, "cache_creation_1h": cache_create_1h, "cache_read_tokens": cache_read, "output_tokens": output_tokens, "hit_rate": hit_rate, }) if not turns: return None prompt_seq = [t["prompt_tokens"] for t in turns] cache_read_seq = [t["cache_read_tokens"] for t in turns] cache_create_seq = [t["cache_creation_tokens"] for t in turns] max_prompt = max(prompt_seq) # Compaction events: prompt drops >40% (existing logic). compaction_events = 0 compaction_turns = set() pre_compaction_max = max_prompt for i in range(1, len(prompt_seq)): if prompt_seq[i - 1] > 10_000 and prompt_seq[i] < prompt_seq[i - 1] * 0.6: if compaction_events == 0: pre_compaction_max = max(prompt_seq[:i]) compaction_events += 1 compaction_turns.add(i) # Cache decay events: cache_read drops >50% from prior turn AND not a # compaction AND prior turn had meaningful cache. Indicates TTL expiry. cache_decay_events = [] for i in range(1, len(cache_read_seq)): prev_cr = cache_read_seq[i - 1] curr_cr = cache_read_seq[i] if prev_cr < 10_000 or i in compaction_turns: continue if curr_cr < prev_cr * 0.5: cache_decay_events.append({ "turn_index": i, "prev_cache_read": prev_cr, "cache_read": curr_cr, "drop_pct": round((prev_cr - curr_cr) / prev_cr * 100, 1), "cache_creation_this_turn": cache_create_seq[i], }) # Per-model breakdown: input/output/cache split by model (investigates # Sonnet/Haiku underuse vs Opus hypothesis from LinkedIn thread). model_breakdown = {} for t in turns: m = t["model"] or "unknown" b = model_breakdown.setdefault(m, { "turns": 0, "input_tokens": 0, "output_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, }) b["turns"] += 1 b["input_tokens"] += t["input_tokens"] b["output_tokens"] += t["output_tokens"] b["cache_creation_tokens"] += t["cache_creation_tokens"] b["cache_read_tokens"] += t["cache_read_tokens"] primary_model = Counter(models).most_common(1)[0][0] if models else None ctx_window = get_context_window(primary_model) max_ctx_pct = round(max_prompt / ctx_window * 100, 1) if ctx_window else None pre_compaction_ctx_pct = ( round(pre_compaction_max / ctx_window * 100, 1) if ctx_window else None ) total_input = sum(t["input_tokens"] for t in turns) total_output = sum(t["output_tokens"] for t in turns) total_cache_create = sum(t["cache_creation_tokens"] for t in turns) total_cache_create_5m = sum(t["cache_creation_5m"] for t in turns) total_cache_create_1h = sum(t["cache_creation_1h"] for t in turns) total_cache_read = sum(t["cache_read_tokens"] for t in turns) total_billable = total_input + total_cache_create + total_cache_read + total_output overall_hit_rate = ( round(total_cache_read / total_billable, 4) if total_billable else 0.0 ) pct_1h_writes = ( round(total_cache_create_1h / total_cache_create * 100, 1) if total_cache_create else None ) # First-turn baseline = system prompt + tool defs + CLAUDE.md + memory. first_turn_baseline = turns[0]["cache_creation_tokens"] if turns else 0 return { "primary_model": primary_model, "models_used": sorted(set(models)), "context_window": ctx_window, "turn_count_with_tokens": len(turns), "total_input_tokens": total_input, "total_output_tokens": total_output, "total_cache_creation_tokens": total_cache_create, "total_cache_creation_5m_tokens": total_cache_create_5m, "total_cache_creation_1h_tokens": total_cache_create_1h, "pct_1h_cache_writes": pct_1h_writes, "total_cache_read_tokens": total_cache_read, "overall_cache_hit_rate": overall_hit_rate, "per_turn_cache_hit_rates": [t["hit_rate"] for t in turns], "max_prompt_tokens": max_prompt, "max_ctx_pct": max_ctx_pct, "pre_compaction_max_tokens": pre_compaction_max, "pre_compaction_ctx_pct": pre_compaction_ctx_pct, "compaction_events_inferred": compaction_events, "cache_decay_events": cache_decay_events, "first_turn_baseline_tokens": first_turn_baseline, "model_breakdown": model_breakdown, } # ─── Metric Computation ────────────────────────────────────────────────────── def compute_friction(tool_calls, user_msgs, errors, messages): """Compute friction score (0.0-1.0) with signal breakdown.""" tool_count = len(tool_calls) # Error-tool ratio error_count = len(errors) error_tool_ratio = error_count / max(tool_count, 1) # Retry loops: same command 3+ times with failures between retry_loops = 0 bash_cmds = [] for tc in tool_calls: name = tc.get("name", "") inp = tc.get("input", {}) if name == "Bash": bash_cmds.append(inp.get("command", "")) # Detect consecutive similar commands window = [] for cmd in bash_cmds: normalized = cmd.strip().split()[0] if cmd.strip() else "" if window and window[-1] == normalized: window.append(normalized) else: if len(window) >= 3: retry_loops += 1 window = [normalized] if len(window) >= 3: retry_loops += 1 # User corrections user_corrections = 0 for text in user_msgs: if CORRECTION_PATTERNS.search(text[:500]): user_corrections += 1 # Approach changes: detect tool pattern shifts (edit-heavy -> read-heavy) approach_changes = 0 if len(tool_calls) >= 10: chunk_size = max(len(tool_calls) // 4, 5) chunks = [ tool_calls[i : i + chunk_size] for i in range(0, len(tool_calls), chunk_size) ] prev_dominant = None for chunk in chunks: counts = Counter(tc.get("name", "") for tc in chunk) dominant = counts.most_common(1)[0][0] if counts else None if prev_dominant and dominant and prev_dominant != dominant: approach_changes += 1 prev_dominant = dominant # Context compactions context_compactions = 0 for msg in messages: content = _get_content(msg) if isinstance(content, str) and "context compaction" in content.lower(): context_compactions += 1 elif isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "text": if "context compaction" in block.get("text", "").lower(): context_compactions += 1 # Interrupted requests interrupted_requests = 0 for msg in messages: content = _get_content(msg) if isinstance(content, str): if "[Request interrupted by user]" in content: interrupted_requests += 1 elif isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "text": if "[Request interrupted by user]" in block.get("text", ""): interrupted_requests += 1 signals = { "error_tool_ratio": round(error_tool_ratio, 3), "retry_loops": retry_loops, "user_corrections": user_corrections, "approach_changes": approach_changes, "context_compactions": context_compactions, "interrupted_requests": interrupted_requests, } # Weighted sum raw = sum( signals[k] * FRICTION_WEIGHTS[k] for k in FRICTION_WEIGHTS ) score = round(sigmoid(raw), 3) return score, signals def compute_fingerprint(user_msgs, tool_calls, files_edited): """Classify session type with confidence.""" scores = defaultdict(float) user_text = " ".join(user_msgs[:10]) # First 10 messages for intent for fp_type, pattern in FINGERPRINT_KEYWORDS.items(): matches = pattern.findall(user_text) scores[fp_type] += len(matches) * 2.0 # Tool profile signals tool_names = [tc.get("name", "") for tc in tool_calls] tool_counts = Counter(tool_names) total = max(len(tool_names), 1) read_pct = (tool_counts.get("Read", 0) + tool_counts.get("Grep", 0) + tool_counts.get("Glob", 0)) / total edit_pct = (tool_counts.get("Edit", 0) + tool_counts.get("Write", 0)) / total bash_pct = tool_counts.get("Bash", 0) / total if read_pct > 0.5 and edit_pct < 0.1: scores["exploration"] += 3.0 if edit_pct > 0.3: scores["feature"] += 2.0 if bash_pct > 0.3: scores["bug-fix"] += 2.0 if len(files_edited) > 10: scores["refactoring"] += 2.0 if len(files_edited) > 5: scores["feature"] += 1.0 # Tidewave signals tidewave_count = sum(1 for n in tool_names if n.startswith("mcp__tidewave")) if tidewave_count > 0: scores["bug-fix"] += 1.5 # Mix deps signals bash_cmds = [tc.get("input", {}).get("command", "") for tc in tool_calls if tc.get("name") == "Bash"] deps_cmds = [c for c in bash_cmds if "mix deps" in c or "mix hex" in c] if deps_cmds: scores["maintenance"] += 3.0 # gh pr signals pr_cmds = [c for c in bash_cmds if "gh pr" in c or "gh issue" in c] if pr_cmds: scores["review"] += 3.0 if not scores: return "unknown", 0.0 best = max(scores, key=scores.get) total_score = sum(scores.values()) confidence = round(scores[best] / max(total_score, 1), 2) return best, confidence def compute_plugin_opportunity(user_msgs, tool_calls, phx_commands): """Compute plugin opportunity score (0.0-1.0).""" could_use = [] tool_names = [tc.get("name", "") for tc in tool_calls] tool_count = len(tool_names) bash_cmds = [tc.get("input", {}).get("command", "") for tc in tool_calls if tc.get("name") == "Bash"] # Retry loops suggest /phx:investigate consecutive = 0 for i in range(1, len(bash_cmds)): if bash_cmds[i].split()[0:2] == bash_cmds[i - 1].split()[0:2] if bash_cmds[i].strip() else False: consecutive += 1 if consecutive >= 2: could_use.append("investigate") break else: consecutive = 0 # Many tools without plan suggest /phx:plan if tool_count > 50 and "plan" not in phx_commands: could_use.append("plan") # Multiple mix test runs suggest /phx:verify test_runs = sum(1 for c in bash_cmds if "mix test" in c or "mix compile" in c) if test_runs >= 3 and "verify" not in phx_commands: could_use.append("verify") # PR commands suggest /phx:pr-review pr_cmds = sum(1 for c in bash_cmds if "gh pr" in c) if pr_cmds >= 2 and "pr-review" not in phx_commands: could_use.append("pr-review") # Many edits without review suggest /phx:review edit_count = sum(1 for n in tool_names if n in ("Edit", "Write")) if edit_count > 10 and "review" not in phx_commands: could_use.append("review") score = min(len(could_use) * 0.2, 1.0) return round(score, 2), could_use def compute_tool_profile(tool_calls): """Compute tool usage percentages.""" names = [tc.get("name", "") for tc in tool_calls] total = max(len(names), 1) counts = Counter(names) read_count = counts.get("Read", 0) + counts.get("Glob", 0) edit_count = counts.get("Edit", 0) + counts.get("Write", 0) bash_count = counts.get("Bash", 0) grep_count = counts.get("Grep", 0) tidewave_count = sum(v for k, v in counts.items() if k.startswith("mcp__tidewave")) other_count = total - read_count - edit_count - bash_count - grep_count - tidewave_count return { "read_pct": round(read_count / total * 100, 1), "edit_pct": round(edit_count / total * 100, 1), "bash_pct": round(bash_count / total * 100, 1), "grep_pct": round(grep_count / total * 100, 1), "tidewave_pct": round(tidewave_count / total * 100, 1), "other_pct": round(max(other_count, 0) / total * 100, 1), } def compute_tool_bigrams(tool_calls, top_n=15): """Extract top tool sequence pairs.""" names = [tc.get("name", "") for tc in tool_calls] bigrams = Counter() for i in range(len(names) - 1): pair = f"{names[i]}->{names[i+1]}" bigrams[pair] += 1 return dict(bigrams.most_common(top_n)) def compute_file_hotspots(tool_calls, top_n=10): """Count reads/edits per file path.""" hotspots = defaultdict(lambda: {"reads": 0, "edits": 0}) for tc in tool_calls: name = tc.get("name", "") inp = tc.get("input", {}) fp = inp.get("file_path", "") if not fp: continue if name in ("Read", "Glob"): hotspots[fp]["reads"] += 1 elif name in ("Edit", "Write"): hotspots[fp]["edits"] += 1 ranked = sorted( hotspots.items(), key=lambda x: x[1]["reads"] + x[1]["edits"], reverse=True, )[:top_n] return [{"path": p, **counts} for p, counts in ranked] def compute_duration(timestamps): """Compute session duration in minutes from timestamps.""" if len(timestamps) < 2: return None try: first, last = timestamps[0], timestamps[-1] if isinstance(first, str) and isinstance(last, str): t1 = datetime.fromisoformat(first.replace("Z", "+00:00")) t2 = datetime.fromisoformat(last.replace("Z", "+00:00")) return round((t2 - t1).total_seconds() / 60, 1) elif isinstance(first, (int, float)) and isinstance(last, (int, float)): return round((last - first) / 60000, 1) except (ValueError, TypeError): pass return None def categorize_files(files): """Categorize files by type (preserved from v1 extract-session.py).""" categories = Counter() for fp in files: if "_live.ex" in fp or "_live/" in fp: categories["liveview"] += 1 elif "_test.exs" in fp: categories["test"] += 1 elif "/migrations/" in fp: categories["migration"] += 1 elif "_worker.ex" in fp or "/workers/" in fp: categories["oban_worker"] += 1 elif "/contexts/" in fp or (fp.endswith(".ex") and "/lib/" in fp): categories["context_or_module"] += 1 elif fp.endswith(".heex"): categories["template"] += 1 elif "router.ex" in fp: categories["router"] += 1 elif fp.endswith(".js") or fp.endswith(".ts"): categories["javascript"] += 1 elif fp.endswith(".css"): categories["css"] += 1 else: categories["other"] += 1 return dict(categories) # ─── Skill Effectiveness ───────────────────────────────────────────────────── def _locate_skill_invocations(user_msgs, all_messages): """Find skill invocations and their position in the message stream. Returns list of {skill, msg_index, user_msg_index} for each invocation. """ invocations = [] user_idx = 0 for i, msg in enumerate(all_messages): if not isinstance(msg, dict): continue role = _get_role(msg) content = _get_content(msg) if role != "user": continue if isinstance(content, str): text = content elif isinstance(content, list): text = " ".join( b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ) else: user_idx += 1 continue if text.startswith("Base directory for this skill:"): user_idx += 1 continue cmds = SKILL_COMMAND_RE.findall(text) for cmd in cmds: if "{" in cmd or "<" in cmd: continue invocations.append({ "skill": cmd, "msg_index": i, "user_msg_index": user_idx, }) user_idx += 1 return invocations def compute_skill_effectiveness(user_msgs, tool_calls, errors, messages): """Compute per-skill effectiveness signals. For each skill invocation, measures what happened before and after: - Pre/post error rates - Post-skill edit count (did the skill lead to action?) - Post-skill test runs (did the user verify?) - Whether user corrections followed (skill didn't help) - Time-to-action (how quickly edits followed) Returns dict keyed by skill command name. """ invocations = _locate_skill_invocations(user_msgs, messages) if not invocations: return {} # Build tool call index: map message index -> tool calls in that range total_msgs = len(messages) # Extract tool_calls with message positions tool_positions = [] for i, msg in enumerate(messages): if not isinstance(msg, dict): continue content = _get_content(msg) role = _get_role(msg) if isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "tool_use": tool_positions.append({"msg_index": i, "tc": block}) elif isinstance(content, str) and role == "assistant": mentioned = TOOL_MENTION_RE.findall(content) for name in mentioned: tool_positions.append({ "msg_index": i, "tc": {"name": name, "input": {}}, }) results = {} for inv in invocations: skill = inv["skill"] msg_idx = inv["msg_index"] # Collect tools after this skill invocation post_tools = [tp for tp in tool_positions if tp["msg_index"] > msg_idx] # Window: next 50 tool calls after invocation (or until next skill) next_skill_idx = total_msgs for other in invocations: if other["msg_index"] > msg_idx: next_skill_idx = min(next_skill_idx, other["msg_index"]) break window_tools = [tp for tp in post_tools if tp["msg_index"] < next_skill_idx][:50] # Count post-skill signals post_edits = sum( 1 for tp in window_tools if tp["tc"].get("name") in ("Edit", "Write") ) post_reads = sum( 1 for tp in window_tools if tp["tc"].get("name") in ("Read", "Grep", "Glob") ) # For API-format: check input.command; for ccrider-format: check surrounding text post_test_runs = 0 for tp in window_tools: if tp["tc"].get("name") != "Bash": continue cmd = tp["tc"].get("input", {}).get("command", "") if cmd and "mix test" in cmd: post_test_runs += 1 elif not cmd: # ccrider-format: tool input is empty, check assistant text at this position mi = tp["msg_index"] if mi < len(messages) and isinstance(messages[mi], dict): text = _get_content(messages[mi]) if isinstance(text, str) and "mix test" in text: post_test_runs += 1 # Post-skill errors (from messages in the window) window_msgs = [ m for j, m in enumerate(messages) if isinstance(m, dict) and j > msg_idx and j < next_skill_idx ] post_errors = len(extract_errors(window_msgs)) # Post-skill user corrections post_corrections = 0 for m in window_msgs: content = _get_content(m) role = _get_role(m) if role == "user" and isinstance(content, str): if CORRECTION_PATTERNS.search(content[:500]): post_corrections += 1 # Led to action: skill resulted in edits or test runs led_to_action = post_edits > 0 or post_test_runs > 0 # Outcome heuristic if post_errors == 0 and post_corrections == 0 and led_to_action: outcome = "effective" elif post_corrections > 0 or post_errors > 3: outcome = "friction" elif not led_to_action: outcome = "no_action" else: outcome = "mixed" # Store per-skill (aggregate if same skill invoked multiple times) if skill not in results: results[skill] = { "invocation_count": 0, "total_post_edits": 0, "total_post_reads": 0, "total_post_test_runs": 0, "total_post_errors": 0, "total_post_corrections": 0, "led_to_action_count": 0, "outcomes": [], } r = results[skill] r["invocation_count"] += 1 r["total_post_edits"] += post_edits r["total_post_reads"] += post_reads r["total_post_test_runs"] += post_test_runs r["total_post_errors"] += post_errors r["total_post_corrections"] += post_corrections if led_to_action: r["led_to_action_count"] += 1 r["outcomes"].append(outcome) # Compute summary metrics per skill for skill, r in results.items(): n = r["invocation_count"] r["action_rate"] = round(r["led_to_action_count"] / max(n, 1), 2) r["avg_post_errors"] = round(r["total_post_errors"] / max(n, 1), 2) r["avg_post_corrections"] = round(r["total_post_corrections"] / max(n, 1), 2) # Dominant outcome outcome_counts = Counter(r["outcomes"]) r["dominant_outcome"] = outcome_counts.most_common(1)[0][0] if outcome_counts else "unknown" return results # ─── Main Metric Pipeline ──────────────────────────────────────────────────── def compute_session_metrics(data, session_id, project, date=None): """Compute all metrics for a single session.""" messages = parse_messages(data) tool_calls = extract_tool_calls(messages) user_msgs = extract_user_messages(messages) errors = extract_errors(messages) timestamps = extract_timestamps(messages) # Extract files edited/read files_edited = set() files_read = set() for tc in tool_calls: name = tc.get("name", "") fp = tc.get("input", {}).get("file_path", "") if not fp: continue if name in ("Edit", "Write"): files_edited.add(fp) elif name == "Read": files_read.add(fp) # Extract phx commands from user messages phx_commands = [] for text in user_msgs: if not text.startswith("Base directory for this skill:"): cmds = PHX_COMMAND_RE.findall(text) cmds = [c for c in cmds if "{" not in c and "<" not in c] phx_commands.extend(cmds) # Tidewave detection tool_names = [tc.get("name", "") for tc in tool_calls] tidewave_available = any(n.startswith("mcp__tidewave") for n in tool_names) tidewave_used = tidewave_available # If calls exist, it was used friction_score, friction_signals = compute_friction( tool_calls, user_msgs, errors, messages ) fingerprint, fp_confidence = compute_fingerprint( user_msgs, tool_calls, list(files_edited) ) opportunity_score, could_use = compute_plugin_opportunity( user_msgs, tool_calls, [c.replace("/phx:", "") for c in phx_commands] ) tool_profile = compute_tool_profile(tool_calls) bigrams = compute_tool_bigrams(tool_calls) hotspots = compute_file_hotspots(tool_calls) duration = compute_duration(timestamps) skill_effectiveness = compute_skill_effectiveness( user_msgs, tool_calls, errors, messages ) token_usage = extract_token_usage(messages) # Tier 2 eligibility tier2_reasons = [] if friction_score > 0.35: tier2_reasons.append("friction > 0.35") if opportunity_score > 0.5: tier2_reasons.append("opportunity > 0.5") if phx_commands: tier2_reasons.append("plugin commands used") if len(user_msgs) > 50: tier2_reasons.append("message_count > 50") if token_usage and (token_usage.get("max_ctx_pct") or 0) >= 90: tier2_reasons.append("max_ctx_pct >= 90") tier2_eligible = len(tier2_reasons) > 0 return { "session_id": session_id, "scanned_at": datetime.now(timezone.utc).isoformat(), "project": project, "date": date or (timestamps[0][:10] if timestamps and isinstance(timestamps[0], str) else None), "duration_minutes": duration, "message_count": len(user_msgs), "tool_count": len(tool_calls), "fingerprint": fingerprint, "fingerprint_confidence": fp_confidence, "friction_score": friction_score, "friction_signals": friction_signals, "plugin_opportunity_score": opportunity_score, "plugin_signals": { "phx_commands_used": list(set(phx_commands)), "could_use": could_use, "tidewave_available": tidewave_available, "tidewave_used": tidewave_used, }, "tool_profile": tool_profile, "tool_bigrams": bigrams, "file_hotspots": hotspots, "file_categories": categorize_files(list(files_edited)), "skill_effectiveness": skill_effectiveness, "token_usage": token_usage, "session_chain": {"previous_session_id": None, "chain_length": 1}, "tier2_eligible": tier2_eligible, "tier2_reason": " AND ".join(tier2_reasons) if tier2_reasons else None, "tier2_completed": False, } # ─── Backfill from v1 Extracts ─────────────────────────────────────────────── def backfill_from_v1(extract_path): """Compute v2 metrics from a v1 extract JSON file. v1 extracts have tool_usage, user_messages, phx_commands, errors, etc. Some v2 signals (user corrections, approach changes) are approximated. """ with open(extract_path) as f: v1 = json.load(f) session_id = v1.get("session_id", os.path.basename(extract_path).replace(".json", "")) project = v1.get("project", "unknown") tool_usage = v1.get("tool_usage", {}) total_tools = sum(tool_usage.values()) # Approximate friction from available v1 data error_count = len(v1.get("errors", [])) error_tool_ratio = round(error_count / max(total_tools, 1), 3) # User corrections approximation from user messages user_msgs = v1.get("user_messages", []) user_corrections = sum( 1 for text in user_msgs if CORRECTION_PATTERNS.search(text[:500]) ) friction_signals = { "error_tool_ratio": error_tool_ratio, "retry_loops": 0, # Can't reliably detect from v1 extracts "user_corrections": user_corrections, "approach_changes": 0, "context_compactions": 0, "interrupted_requests": 0, } raw = sum(friction_signals[k] * FRICTION_WEIGHTS[k] for k in FRICTION_WEIGHTS) friction_score = round(sigmoid(raw), 3) # Fingerprint from v1 data user_text = " ".join(user_msgs[:10]) scores = defaultdict(float) for fp_type, pattern in FINGERPRINT_KEYWORDS.items(): matches = pattern.findall(user_text) scores[fp_type] += len(matches) * 2.0 # Tool profile from v1 tool_usage read_count = tool_usage.get("Read", 0) + tool_usage.get("Glob", 0) edit_count = tool_usage.get("Edit", 0) + tool_usage.get("Write", 0) bash_count = tool_usage.get("Bash", 0) grep_count = tool_usage.get("Grep", 0) tidewave_count = sum(v for k, v in tool_usage.items() if k.startswith("mcp__tidewave")) if read_count / max(total_tools, 1) > 0.5 and edit_count / max(total_tools, 1) < 0.1: scores["exploration"] += 3.0 if edit_count / max(total_tools, 1) > 0.3: scores["feature"] += 2.0 if bash_count / max(total_tools, 1) > 0.3: scores["bug-fix"] += 2.0 best = max(scores, key=scores.get) if scores else "unknown" total_score = sum(scores.values()) fp_confidence = round(scores.get(best, 0) / max(total_score, 1), 2) if scores else 0.0 # Plugin opportunity from v1 phx_commands phx_commands = v1.get("phx_commands", []) could_use = [] if total_tools > 50 and not phx_commands: could_use.append("plan") mix_cmds = v1.get("mix_commands", []) test_runs = sum(1 for c in mix_cmds if "mix test" in c or "mix compile" in c) if test_runs >= 3: could_use.append("verify") opportunity_score = min(len(could_use) * 0.2, 1.0) other_count = max(total_tools - read_count - edit_count - bash_count - grep_count - tidewave_count, 0) return { "session_id": session_id, "scanned_at": datetime.now(timezone.utc).isoformat(), "backfilled": True, "project": project, "date": None, "duration_minutes": v1.get("duration_minutes"), "message_count": v1.get("user_message_count", len(user_msgs)), "tool_count": total_tools, "fingerprint": best, "fingerprint_confidence": fp_confidence, "friction_score": friction_score, "friction_signals": friction_signals, "plugin_opportunity_score": round(opportunity_score, 2), "plugin_signals": { "phx_commands_used": phx_commands, "could_use": could_use, "tidewave_available": bool(v1.get("tidewave_usage")), "tidewave_used": bool(v1.get("tidewave_usage")), }, "tool_profile": { "read_pct": round(read_count / max(total_tools, 1) * 100, 1), "edit_pct": round(edit_count / max(total_tools, 1) * 100, 1), "bash_pct": round(bash_count / max(total_tools, 1) * 100, 1), "grep_pct": round(grep_count / max(total_tools, 1) * 100, 1), "tidewave_pct": round(tidewave_count / max(total_tools, 1) * 100, 1), "other_pct": round(other_count / max(total_tools, 1) * 100, 1), }, "tool_bigrams": {}, "file_hotspots": [], "file_categories": v1.get("file_categories", {}), "skill_effectiveness": {}, "token_usage": None, "session_chain": {"previous_session_id": None, "chain_length": 1}, "tier2_eligible": friction_score > 0.35 or opportunity_score > 0.5, "tier2_reason": None, "tier2_completed": False, } # ─── Raw JSONL Token/Context Scanner ───────────────────────────────────────── # Inspired by badlogic / earendil-works/pi session-context-stats.mjs. # PI's script focuses purely on context-window economics; this mode brings # the same per-day / per-model / threshold-bucket view to our pipeline. def scan_raw_jsonl(jsonl_dir, since_dt=None): """Scan raw Claude Code JSONL session files for token/context metrics. Returns list of per-session summaries. Skips files without `message.usage` blocks (e.g. legacy or non-assistant traffic only). """ results = [] if not os.path.isdir(jsonl_dir): return results for fname in sorted(os.listdir(jsonl_dir)): if not fname.endswith(".jsonl"): continue fpath = os.path.join(jsonl_dir, fname) if since_dt: try: mtime = datetime.fromtimestamp( os.path.getmtime(fpath), tz=timezone.utc ) if mtime < since_dt: continue except OSError: continue messages = [] first_ts = None last_ts = None try: with open(fpath, errors="ignore") as f: for line in f: line = line.strip() if not line: continue try: e = json.loads(line) except json.JSONDecodeError: continue messages.append(e) ts = e.get("timestamp") if isinstance(ts, str): if first_ts is None: first_ts = ts last_ts = ts except OSError: continue if not messages: continue usage = extract_token_usage(messages) if not usage: continue duration = None if first_ts and last_ts: try: t1 = datetime.fromisoformat(first_ts.replace("Z", "+00:00")) t2 = datetime.fromisoformat(last_ts.replace("Z", "+00:00")) duration = round((t2 - t1).total_seconds() / 60, 1) except (ValueError, TypeError): pass sid = fname.replace(".jsonl", "") results.append({ "session_id": sid, "date": first_ts[:10] if isinstance(first_ts, str) else None, "duration_minutes": duration, "turns": usage["turn_count_with_tokens"], "primary_model": usage["primary_model"], "models_used": usage["models_used"], "context_window": usage["context_window"], "max_prompt_tokens": usage["max_prompt_tokens"], "max_ctx_pct": usage["max_ctx_pct"], "pre_compaction_max_tokens": usage["pre_compaction_max_tokens"], "pre_compaction_ctx_pct": usage["pre_compaction_ctx_pct"], "compaction_events": usage["compaction_events_inferred"], "total_input_tokens": usage["total_input_tokens"], "total_output_tokens": usage["total_output_tokens"], "total_cache_creation_tokens": usage["total_cache_creation_tokens"], "total_cache_read_tokens": usage["total_cache_read_tokens"], }) return results def _median(values): if not values: return None s = sorted(values) n = len(s) if n % 2 == 1: return s[n // 2] return (s[n // 2 - 1] + s[n // 2]) / 2 def aggregate_ctx_scan(sessions): """Compute totals + per-day + per-model aggregates from scan_raw_jsonl output.""" if not sessions: return {"count": 0, "totals": {}, "by_day": {}, "by_model": {}} def _agg(group): ctx_pcts = [s["max_ctx_pct"] for s in group if s["max_ctx_pct"] is not None] pre_pcts = [ s["pre_compaction_ctx_pct"] for s in group if s["pre_compaction_ctx_pct"] is not None ] durations = [s["duration_minutes"] for s in group if s["duration_minutes"]] return { "count": len(group), "avg_turns": round(sum(s["turns"] for s in group) / len(group), 1), "avg_duration_min": round(sum(durations) / len(durations), 1) if durations else None, "avg_max_tokens": round(sum(s["max_prompt_tokens"] for s in group) / len(group)), "med_max_ctx_pct": round(_median(ctx_pcts), 1) if ctx_pcts else None, "avg_max_ctx_pct": round(sum(ctx_pcts) / len(ctx_pcts), 1) if ctx_pcts else None, "med_pre_compact_ctx_pct": round(_median(pre_pcts), 1) if pre_pcts else None, "over_80_pct_count": sum(1 for p in ctx_pcts if p >= 80), "over_90_pct_count": sum(1 for p in ctx_pcts if p >= 90), "over_100_pct_count": sum(1 for p in ctx_pcts if p >= 100), "compaction_rate_pct": round( sum(1 for s in group if s["compaction_events"] > 0) / len(group) * 100, 1 ), } by_day = defaultdict(list) by_model = defaultdict(list) for s in sessions: if s["date"]: by_day[s["date"]].append(s) if s["primary_model"]: by_model[s["primary_model"]].append(s) return { "count": len(sessions), "totals": _agg(sessions), "by_day": {d: _agg(g) for d, g in sorted(by_day.items())}, "by_model": {m: _agg(g) for m, g in sorted(by_model.items())}, } # ─── ASCII / HTML Rendering ────────────────────────────────────────────────── # Bar-chart styling and HTML preformatted-text layout follow PI's # session-context-stats.mjs report (badlogic / earendil-works/pi). def render_ascii_bar(value, max_value, width=40): """Render a horizontal bar using full/empty block characters.""" if value is None or max_value is None or max_value <= 0: return "░" * width ratio = max(0.0, min(1.0, value / max_value)) filled = round(ratio * width) return "█" * filled + "░" * (width - filled) def render_ctx_scan_text(scan_result): """Render scan_raw_jsonl + aggregate result as plain-text report.""" lines = [] t = scan_result["totals"] if not t: return "No sessions with token usage found." lines.append("─── Totals " + "─" * 60) lines.append( f" sessions: {t['count']} avg_turns: {t['avg_turns']} " f"avg_duration: {t['avg_duration_min'] or '-'} min" ) lines.append( f" avg_max_tokens: {t['avg_max_tokens']:,} " f"med_max_ctx: {t['med_max_ctx_pct']}% " f"med_pre_compact: {t['med_pre_compact_ctx_pct']}%" ) lines.append( f" >=80%: {t['over_80_pct_count']} >=90%: {t['over_90_pct_count']} " f">=100%: {t['over_100_pct_count']} compaction_rate: {t['compaction_rate_pct']}%" ) lines.append( " ctx usage: " + render_ascii_bar(t["med_max_ctx_pct"] or 0, 100) + f" {t['med_max_ctx_pct'] or 0}%" ) lines.append("") lines.append("─── By Day " + "─" * 60) lines.append( f" {'date':<12} {'n':>4} {'turns':>6} {'maxTok':>9} " f"{'medCtx%':>8} {'>=90':>5} bar" ) for d, g in scan_result["by_day"].items(): bar = render_ascii_bar(g["med_max_ctx_pct"] or 0, 100) lines.append( f" {d:<12} {g['count']:>4} {g['avg_turns']:>6} " f"{g['avg_max_tokens']:>9,} {(g['med_max_ctx_pct'] or 0):>7}% " f"{g['over_90_pct_count']:>5} {bar}" ) lines.append("") lines.append("─── By Model " + "─" * 58) lines.append( f" {'model':<35} {'n':>4} {'turns':>6} {'maxTok':>9} " f"{'medCtx%':>8} {'>=90':>5} bar" ) for m, g in scan_result["by_model"].items(): bar = render_ascii_bar(g["med_max_ctx_pct"] or 0, 100) lines.append( f" {m:<35} {g['count']:>4} {g['avg_turns']:>6} " f"{g['avg_max_tokens']:>9,} {(g['med_max_ctx_pct'] or 0):>7}% " f"{g['over_90_pct_count']:>5} {bar}" ) return "\n".join(lines) def render_trends_text(trends_result): """Render compute_trends() output as plain-text report with bars.""" lines = [] lines.append(f"Total sessions: {trends_result.get('total_sessions', 0)}") lines.append(f"Computed at: {trends_result.get('computed_at', '-')}") lines.append("") windows = trends_result.get("windows", {}) lines.append("─── Window Comparison " + "─" * 50) lines.append( f" {'metric':<28} {'7d':>8} {'30d':>8} {'all':>8}" ) rows = [ ("sessions", "count"), ("avg friction", "avg_friction"), ("max friction", "max_friction"), ("avg opportunity", "avg_opportunity"), ("tier2 eligible %", "tier2_eligible_pct"), ("plugin adoption %", "plugin_adoption_rate"), ] for label, key in rows: v7 = windows.get("7d", {}).get(key, "-") v30 = windows.get("30d", {}).get(key, "-") vall = windows.get("all", {}).get(key, "-") lines.append(f" {label:<28} {str(v7):>8} {str(v30):>8} {str(vall):>8}") # Friction bar (all-time vs 30d vs 7d) lines.append("") lines.append("─── Friction trend (avg, 0–1 scale) " + "─" * 35) for w in ("all", "30d", "7d"): v = windows.get(w, {}).get("avg_friction", 0) or 0 lines.append(f" {w:<5} {render_ascii_bar(v, 1.0)} {v}") # Fingerprints lines.append("") lines.append("─── Fingerprint Distribution (all-time) " + "─" * 32) fps = windows.get("all", {}).get("fingerprint_distribution", {}) fp_max = max(fps.values()) if fps else 1 for fp, n in fps.items(): lines.append(f" {fp:<14} {render_ascii_bar(n, fp_max)} {n}") # Per-model token aggregates (if any entries had token_usage) by_model = trends_result.get("by_model", {}) if by_model: lines.append("") lines.append("─── By Model (token usage) " + "─" * 45) lines.append( f" {'model':<35} {'n':>4} {'medCtx%':>8} {'>=90':>5} bar" ) for m, g in by_model.items(): bar = render_ascii_bar(g.get("med_max_ctx_pct") or 0, 100) lines.append( f" {m:<35} {g['count']:>4} " f"{(g.get('med_max_ctx_pct') or 0):>7}% " f"{g.get('over_90_pct_count', 0):>5} {bar}" ) mc = trends_result.get("memory_comparison") if mc: lines.append("") lines.append("─── MEMORY.md Comparison " + "─" * 47) for k, v in mc.items(): lines.append(f" {k:<32} {v}") return "\n".join(lines) def render_html_report(title, body_text, generated_at): """Wrap pre-formatted body text in a minimal HTML page.""" safe_body = ( body_text.replace("&", "&").replace("<", "<").replace(">", ">") ) return ( "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\n" f"<title>{title}</title>\n" "<style>\n" " body { font: 13px/1.5 ui-monospace, \"SF Mono\", Menlo, monospace;\n" " background: #f7f7f7; color: #222; margin: 0; padding: 24px; }\n" " h1 { font-size: 18px; margin: 0 0 4px; }\n" " .meta { color: #666; font-size: 12px; margin-bottom: 16px; }\n" " pre { background: white; padding: 16px; border: 1px solid #ddd;\n" " border-radius: 4px; overflow-x: auto; }\n" " .footer { margin-top: 12px; font-size: 11px; color: #888; }\n" " a { color: #555; }\n" "</style></head><body>\n" f"<h1>{title}</h1>\n" f"<div class=\"meta\">Generated {generated_at}</div>\n" f"<pre>{safe_body}</pre>\n" "<div class=\"footer\">Bar-chart layout inspired by " "<a href=\"https://github.com/earendil-works/pi/blob/main/scripts/session-context-stats.mjs\">" "badlogic / earendil-works/pi session-context-stats.mjs</a>.</div>\n" "</body></html>\n" ) # ─── Trends Computation ────────────────────────────────────────────────────── def compute_trends(metrics_path, memory_path=None, project_filter=None): """Compute windowed aggregates from metrics.jsonl.""" entries = [] with open(metrics_path) as f: for line in f: line = line.strip() if line: try: entry = json.loads(line) if project_filter: proj = entry.get("project", "") if project_filter.lower() not in proj.lower(): continue entries.append(entry) except json.JSONDecodeError: continue if not entries: return {"error": "No metrics found", "total_sessions": 0} now = datetime.now(timezone.utc) windows = { "7d": now - timedelta(days=7), "30d": now - timedelta(days=30), "all": datetime(2000, 1, 1, tzinfo=timezone.utc), } def parse_date(entry): d = entry.get("date") or entry.get("scanned_at", "") if not d: return None try: dt = datetime.fromisoformat(d.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt except (ValueError, TypeError): try: return datetime.strptime(d[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc) except (ValueError, TypeError): return None trends = {} for window_name, cutoff in windows.items(): window_entries = [ e for e in entries if (parse_date(e) or datetime(2000, 1, 1, tzinfo=timezone.utc)) >= cutoff ] if not window_entries: trends[window_name] = {"count": 0} continue frictions = [e.get("friction_score") or 0 for e in window_entries] opportunities = [e.get("plugin_opportunity_score") or 0 for e in window_entries] fingerprints = Counter(e.get("fingerprint", "unknown") for e in window_entries) tier2_count = sum(1 for e in window_entries if e.get("tier2_eligible")) phx_users = sum(1 for e in window_entries if e.get("plugin_signals", {}).get("phx_commands_used")) backfilled = sum(1 for e in window_entries if e.get("backfilled")) trends[window_name] = { "count": len(window_entries), "backfilled_count": backfilled, "avg_friction": round(sum(frictions) / len(frictions), 3), "max_friction": round(max(frictions), 3), "avg_opportunity": round(sum(opportunities) / len(opportunities), 3), "fingerprint_distribution": dict(fingerprints.most_common()), "tier2_eligible_count": tier2_count, "tier2_eligible_pct": round(tier2_count / len(window_entries) * 100, 1), "plugin_adoption_rate": round(phx_users / len(window_entries) * 100, 1), } # Memory comparison (if provided) memory_comparison = None if memory_path and os.path.exists(memory_path): with open(memory_path) as f: memory_text = f.read() memory_comparison = { "plugin_adoption_memory": "8-12%" if "8-12%" in memory_text else "unknown", "plugin_adoption_measured": f"{trends.get('all', {}).get('plugin_adoption_rate', 0)}%", } # Per-model aggregates (only entries with token_usage have model info) by_model = defaultdict(list) for e in entries: tu = e.get("token_usage") or {} model = tu.get("primary_model") if not model: continue by_model[model].append({ "max_ctx_pct": tu.get("max_ctx_pct"), "max_prompt_tokens": tu.get("max_prompt_tokens", 0), "compaction_events": tu.get("compaction_events_inferred", 0), "friction_score": e.get("friction_score") or 0, "plugin_opportunity_score": e.get("plugin_opportunity_score") or 0, }) by_model_summary = {} for model, group in by_model.items(): ctx_pcts = [g["max_ctx_pct"] for g in group if g["max_ctx_pct"] is not None] by_model_summary[model] = { "count": len(group), "avg_friction": round(sum(g["friction_score"] for g in group) / len(group), 3), "avg_opportunity": round( sum(g["plugin_opportunity_score"] for g in group) / len(group), 3 ), "med_max_ctx_pct": round(_median(ctx_pcts), 1) if ctx_pcts else None, "avg_max_tokens": round(sum(g["max_prompt_tokens"] for g in group) / len(group)), "over_90_pct_count": sum(1 for p in ctx_pcts if p >= 90), "compaction_rate_pct": round( sum(1 for g in group if g["compaction_events"] > 0) / len(group) * 100, 1 ), } return { "computed_at": now.isoformat(), "total_sessions": len(entries), "windows": trends, "by_model": by_model_summary, "memory_comparison": memory_comparison, } # -
scoring-guide.md 4.8 KB
# Scoring Guide Reference documentation for all session metrics algorithms, weights, thresholds, and fingerprint rules used by `compute-metrics.py`. ## Friction Score (0.0 - 1.0) Measures how much resistance/struggle occurred in a session. ### Formula ``` raw = Σ (signal_value × weight) score = sigmoid(raw) = 1 / (1 + e^(-k × (raw - midpoint))) ``` Parameters: `k = 3.0`, `midpoint = 1.5` ### Signal Weights | Signal | Weight | Detection Method | |--------|--------|------------------| | `error_tool_ratio` | 2.0 | `error_count / tool_count` | | `retry_loops` | 3.0 | Same Bash command prefix 3+ consecutive times | | `user_corrections` | 2.5 | Messages matching correction patterns | | `approach_changes` | 2.0 | Dominant tool shifts between session quarters | | `context_compactions` | 1.5 | System messages about context compaction | | `interrupted_requests` | 1.0 | `[Request interrupted by user]` occurrences | ### Correction Patterns Regex for detecting user corrections: ``` \b(no[,.]?\s|wrong|instead|actually|that's not|not what I| I meant|I said|please don't|stop|undo|revert)\b ``` Applied to first 500 chars of each user message. ### Approach Change Detection 1. Split tool call sequence into 4 equal chunks 2. Find dominant tool in each chunk 3. Count transitions between different dominant tools ### Interpretation | Score Range | Meaning | |-------------|---------| | 0.00 - 0.15 | Smooth session, minimal friction | | 0.15 - 0.35 | Some friction, 1-2 stuck points | | 0.35 - 0.60 | High friction, multiple issues | | 0.60 - 1.00 | Severe friction, likely abandoned approaches | ## Session Fingerprint Rule-based classifier that identifies session type. ### Keyword Scores (applied to first 10 user messages) | Type | Keywords (×2.0 each) | |------|---------------------| | `bug-fix` | fix, bug, broken, error, issue, crash, fail, debug, wrong | | `feature` | add, implement, build, create, new feature, scaffold | | `exploration` | explore, understand, how does, what is, explain, look at | | `maintenance` | deps, update, upgrade, bump, version, migrate | | `review` | review, PR, pull request, code review, feedback | | `refactoring` | refactor, extract, rename, move, reorganize, clean up | ### Tool Profile Bonuses | Condition | Type | Bonus | |-----------|------|-------| | Read+Grep > 50% AND Edit < 10% | `exploration` | +3.0 | | Edit > 30% | `feature` | +2.0 | | Bash > 30% | `bug-fix` | +2.0 | | Files edited > 10 | `refactoring` | +2.0 | | Files edited > 5 | `feature` | +1.0 | | Tidewave calls > 0 | `bug-fix` | +1.5 | | `mix deps` commands | `maintenance` | +3.0 | | `gh pr` commands | `review` | +3.0 | ### Confidence ``` confidence = best_type_score / total_all_scores ``` Range: 0.0 - 1.0. Higher = more certain classification. ## Plugin Opportunity Score (0.0 - 1.0) Detects missed plugin command opportunities. ### Signal Detection | Signal | Missed Command | Condition | |--------|---------------|-----------| | Retry loops (3+ same cmd) | `/phx:investigate` | Consecutive similar Bash commands | | 50+ tools, no plan | `/phx:plan` | High tool count without planning | | 3+ mix test/compile | `/phx:verify` | Repeated manual verification | | 2+ `gh pr` commands | `/phx:pr-review` | Manual PR workflow | | 10+ edits, no review | `/phx:review` | Many changes without quality check | ### Formula ``` score = min(missed_opportunities × 0.2, 1.0) ``` Commands already used in the session are excluded from missed opportunities. ## Tier 2 Eligibility A session is eligible for deep qualitative analysis if ANY of: | Condition | Rationale | |-----------|-----------| | `friction_score > 0.35` | High friction worth investigating | | `plugin_opportunity_score > 0.5` | Multiple missed opportunities | | Plugin commands used | Learn from actual plugin usage | | `message_count > 50` | Long sessions often have patterns | ## Tool Profile Percentage breakdown of tool usage: - `read_pct`: Read + Glob - `edit_pct`: Edit + Write - `bash_pct`: Bash - `grep_pct`: Grep - `tidewave_pct`: All `mcp__tidewave*` tools - `other_pct`: Everything else (Task, Skill, other MCP) ## Tuning Guide ### Adjusting Friction Sensitivity To make friction scores **more sensitive** (flag more sessions): - Decrease `FRICTION_SIGMOID_MIDPOINT` (e.g., 1.0 instead of 1.5) - Increase individual signal weights To make friction scores **less sensitive** (fewer false positives): - Increase `FRICTION_SIGMOID_MIDPOINT` (e.g., 2.0) - Decrease weights on noisy signals (e.g., `user_corrections`) ### Adjusting Tier 2 Thresholds - Lower `friction > 0.35` to catch more sessions - Lower `opportunity > 0.5` to flag sessions with fewer missed commands - Remove `message_count > 50` if long sessions aren't interesting ### Adding New Fingerprint Types 1. Add keyword regex to `FINGERPRINT_KEYWORDS` 2. Add tool profile bonuses in `compute_fingerprint()` 3. Update this guide
-
-
SKILL.md 5 KB
--- name: session-scan description: Compute metrics for Claude Code sessions. Discovers via ccrider, filters trivial, computes friction/opportunity/fingerprint scores. Use for broad session triage. argument-hint: "[--since DATE] [--project NAME] [--limit N] [--list] [--rescan]" disable-model-invocation: true --- # Session Scan (Tier 1) Compute deterministic metrics for sessions discovered via ccrider MCP. ## STOP — Read Before Executing **NEVER call `mcp__ccrider__get_session_messages` in main context.** Each response is 5-50KB of JSON. Fetching N sessions directly = context crash. **You MUST delegate all scoring to subagents.** The main context only does: discovery, deduplication, subagent spawning, and result collection. ## Requirements Requires **ccrider MCP**. If not available: > ccrider MCP is required. See: <https://github.com/neilberkman/ccrider> ## Usage ``` /session-scan # Last 7 days, up to 50 sessions /session-scan --since 2026-02-01 # Since specific date /session-scan --project enaia # Filter by project name /session-scan --limit 20 # Cap session count /session-scan --list # Discovery only, no scoring /session-scan --rescan # Recompute already-scanned sessions ``` ## What Main Context Does ### 1. Parse Arguments Extract from `$ARGUMENTS`: - **`--since DATE`**: ISO date filter (default: 7 days ago) - **`--project NAME`**: Filter by project path substring - **`--limit N`**: Max sessions to process (default: 50) - **`--list`**: Discovery only — show table, skip scoring - **`--rescan`**: Recompute metrics for already-scanned sessions ### 2. Discover Sessions (safe — response is ~1KB) ``` mcp__ccrider__list_recent_sessions(limit: N, project: PROJECT, after_date: SINCE) ``` Filter out sessions with < 10 messages. If `--list`: show table and stop. ### 3. Deduplicate Read `.claude/session-metrics/metrics.jsonl` (if exists). Skip sessions already scanned unless `--rescan` flag is set. Report: "N new sessions to scan (M already in ledger)" ### 4. Resolve Scorer Path ``` Glob: **/session-scan/references/compute-metrics.py ``` Store as `SCORER_PATH` (absolute). `PROJECT_ROOT` = current working directory. ### 5. Spawn ONE Subagent Per Session **DO NOT fetch messages yourself. DO NOT run Python yourself.** **You MUST use the Task tool to spawn subagents.** For EACH unscanned session, spawn a haiku subagent using this template: ``` Task(subagent_type="general-purpose", model="haiku", mode="bypassPermissions", prompt=""" Score one Claude Code session. Steps: 1. Fetch messages: mcp__ccrider__get_session_messages(session_id: "{SESSION_ID}", last_n: 200) 2. Write the full tool result (messages JSON) to: {PROJECT_ROOT}/.claude/session-metrics/_tmp_{SHORT_ID}.json 3. Run scorer and capture result in ONE Bash command: python3 {SCORER_PATH} {PROJECT_ROOT}/.claude/session-metrics/_tmp_{SHORT_ID}.json --session-id {SESSION_ID} --project {PROJECT_NAME} > {PROJECT_ROOT}/.claude/session-metrics/_result_{SHORT_ID}.json && rm {PROJECT_ROOT}/.claude/session-metrics/_tmp_{SHORT_ID}.json 4. Report back: "Scored {SHORT_ID}: friction=X.XX fingerprint=Y" If ccrider fails, report the error and exit. """) ``` **Spawn ALL subagents in parallel.** Wait for all to complete. ### 6. Collect Results ```bash for f in .claude/session-metrics/_result_*.json; do cat "$f" >> .claude/session-metrics/metrics.jsonl rm "$f" done ``` ### 7. Display Results Show summary table sorted by friction (descending): ``` | ID | Project | Date | Type | Friction | Opportunity | Tier2? | |----------|---------|------------|-------------|----------|-------------|--------| | ffa155ee | enaia | 2026-02-18 | bug-fix | 0.42 | 0.65 | Yes | | 90a74843 | wedding | 2026-02-17 | feature | 0.15 | 0.20 | No | ``` If Tier 2 eligible: suggest `/session-deep-dive --from-scan`. ### 8. Update Scan Metadata Write `.claude/session-metrics/latest-scan.json`: ```json { "scanned_at": "2026-02-20T14:30:00Z", "sessions_discovered": 25, "sessions_scanned": 18, "sessions_skipped": 7, "tier2_eligible": 5 } ``` ## Scoring Reference See `references/scoring-guide.md` for full algorithm documentation. - **Friction Score** (0-1): Weighted signals, sigmoid-normalized - **Fingerprint**: Rule-based classifier (6 types) - **Plugin Opportunity** (0-1): Missed `/phx:` commands - **Tier 2 Eligible**: friction > 0.35 OR opportunity > 0.5 OR plugin used OR msgs > 50 ## Iron Laws 1. **NEVER call `get_session_messages` in main context** — WILL crash context 2. **ONE subagent per session** — each fetches exactly ONE session 3. **`mode="bypassPermissions"` on every Task** — subagents need Write+Bash 4. **Absolute paths in subagent prompts** — subagents don't inherit context 5. **Result files, not direct append** — avoid jsonl race conditions 6. **NEVER modify existing metrics.jsonl entries** — append-only ledger 7. **ALWAYS delete temp and result files** after processing
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.