Created
April 9, 2026 06:40
-
-
Save ceaksan/514ee710e6a1556c27a9cf4d6401fcde to your computer and use it in GitHub Desktop.
Coffee Debt: Gamified AI Error Tracking for Claude Code (tracker + analyzer)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Coffee Analysis CLI — Analyze AI mistake patterns from coffee-log.jsonl. | |
| Usage: | |
| python3 coffee-analyze.py # 7-day summary | |
| python3 coffee-analyze.py --days 30 # 30-day lookback | |
| python3 coffee-analyze.py --format json # JSON output | |
| Data source: ~/.claude/coffee-log.jsonl (produced by coffee-tracker.sh hook) | |
| Debt file: ~/.claude/coffee-debt (cumulative bean count) | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| from collections import Counter | |
| from datetime import datetime, timedelta, timezone | |
| from pathlib import Path | |
| COFFEE_LOG = Path.home() / ".claude" / "coffee-log.jsonl" | |
| COFFEE_DEBT = Path.home() / ".claude" / "coffee-debt" | |
| def parse_args(): | |
| parser = argparse.ArgumentParser(description="Coffee Analysis CLI") | |
| parser.add_argument( | |
| "--days", type=int, default=7, help="Lookback window in days (default: 7)" | |
| ) | |
| parser.add_argument("--since", type=str, help="Start date (YYYY-MM-DD)") | |
| parser.add_argument( | |
| "--format", choices=["plain", "json"], default="plain", help="Output format" | |
| ) | |
| return parser.parse_args() | |
| def load_coffee_log(since: datetime) -> list[dict]: | |
| if not COFFEE_LOG.exists(): | |
| return [] | |
| entries = [] | |
| since_str = since.strftime("%Y-%m-%dT%H:%M:%SZ") | |
| with open(COFFEE_LOG) as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| entry = json.loads(line) | |
| if entry.get("ts", "") >= since_str: | |
| entries.append(entry) | |
| except json.JSONDecodeError: | |
| continue | |
| return entries | |
| def get_coffee_debt() -> int: | |
| try: | |
| return int(COFFEE_DEBT.read_text().strip()) | |
| except (FileNotFoundError, ValueError): | |
| return 0 | |
| def analyze(entries: list[dict]) -> dict: | |
| if not entries: | |
| return {"count": 0} | |
| by_tool = Counter(e.get("tool", "unknown") for e in entries if e.get("tool")) | |
| by_reason = Counter(e.get("reason", "unknown") for e in entries) | |
| by_type = Counter(e.get("type", "unknown") for e in entries) | |
| # Time buckets | |
| buckets = Counter() | |
| for e in entries: | |
| ts = e.get("ts", "") | |
| if len(ts) >= 13: | |
| try: | |
| hour = int(ts[11:13]) | |
| if 6 <= hour < 12: | |
| buckets["morning (06-12)"] += 1 | |
| elif 12 <= hour < 18: | |
| buckets["afternoon (12-18)"] += 1 | |
| elif 18 <= hour < 24: | |
| buckets["evening (18-00)"] += 1 | |
| else: | |
| buckets["night (00-06)"] += 1 | |
| except ValueError: | |
| pass | |
| # Files with most errors | |
| file_errors = Counter() | |
| for e in entries: | |
| f = e.get("file", "") | |
| if f and f != "unknown": | |
| file_errors[f] += 1 | |
| return { | |
| "count": len(entries), | |
| "by_tool": by_tool.most_common(5), | |
| "by_reason": by_reason.most_common(5), | |
| "by_type": by_type.most_common(5), | |
| "by_time": buckets.most_common(4), | |
| "blocked": sum(1 for e in entries if e.get("type") == "blocked"), | |
| "warned": sum(1 for e in entries if e.get("type") == "warned"), | |
| "corrections": sum(1 for e in entries if e.get("type") == "correction"), | |
| "top_files": file_errors.most_common(5), | |
| } | |
| def format_plain(analysis: dict, days: int, debt: int) -> str: | |
| coffees = debt // 5 | |
| beans = debt % 5 | |
| lines = [] | |
| lines.append(f"=== Coffee Analysis: {days}-Day Summary ===") | |
| lines.append("") | |
| lines.append(f" Total debt: {coffees} coffees + {beans}/5 beans ({debt} total)") | |
| lines.append(f" Entries: {analysis['count']}") | |
| lines.append("") | |
| if analysis["count"] == 0: | |
| lines.append(" No data in this period.") | |
| return "\n".join(lines) | |
| lines.append(" ERROR PATTERNS") | |
| for tool, count in analysis["by_tool"]: | |
| lines.append(f" {tool}: {count}") | |
| lines.append("") | |
| lines.append(" TOP REASONS") | |
| for reason, count in analysis["by_reason"]: | |
| lines.append(f" {reason}: {count}x") | |
| lines.append("") | |
| lines.append(" TIME DISTRIBUTION") | |
| for bucket, count in analysis["by_time"]: | |
| bar = "#" * min(count, 30) | |
| lines.append(f" {bucket}: {bar} ({count})") | |
| lines.append("") | |
| if analysis["blocked"] > 0: | |
| lines.append(f" BLOCKED: {analysis['blocked']} destructive attempts") | |
| if analysis["warned"] > 0: | |
| lines.append(f" WARNED: {analysis['warned']} risky commands") | |
| if analysis["corrections"] > 0: | |
| lines.append(f" CORRECTIONS: {analysis['corrections']} user corrections") | |
| lines.append("") | |
| if analysis["top_files"]: | |
| lines.append(" TOP ERROR FILES") | |
| for f, count in analysis["top_files"][:5]: | |
| lines.append(f" {f}: {count}x") | |
| return "\n".join(lines) | |
| def main(): | |
| args = parse_args() | |
| if args.since: | |
| since = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc) | |
| else: | |
| since = datetime.now(timezone.utc) - timedelta(days=args.days) | |
| debt = get_coffee_debt() | |
| entries = load_coffee_log(since) | |
| analysis = analyze(entries) | |
| if args.format == "json": | |
| result = { | |
| "period_days": args.days, | |
| "since": since.isoformat(), | |
| "debt": debt, | |
| "analysis": { | |
| "count": analysis["count"], | |
| "by_tool": dict(analysis.get("by_tool", [])), | |
| "by_reason": dict(analysis.get("by_reason", [])), | |
| "blocked": analysis.get("blocked", 0), | |
| }, | |
| } | |
| print(json.dumps(result, indent=2, default=str)) | |
| else: | |
| print(format_plain(analysis, args.days, debt)) | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Coffee Debt Tracker — PostToolUse hook for Claude Code | |
| # Tracks wasted tokens from AI mistakes as coffee debt | |
| # 1 mistake = 1 bean, 5 beans = 1 coffee | |
| # | |
| # Setup: Add as PostToolUse hook in ~/.claude/settings.json | |
| # { | |
| # "hooks": { | |
| # "PostToolUse": [ | |
| # { "matcher": "Edit|Write|Bash", "command": "bash ~/.claude/hooks/coffee-tracker.sh" } | |
| # ] | |
| # } | |
| # } | |
| DEBT_FILE="$HOME/.claude/coffee-debt" | |
| SESSION_DEBT_FILE="/tmp/claude-coffee-session-${PPID}" | |
| LOG_FILE="$HOME/.claude/coffee-log.jsonl" | |
| # Initialize debt file if missing | |
| if [ ! -f "$DEBT_FILE" ]; then | |
| echo "0" > "$DEBT_FILE" | |
| fi | |
| # Read tool result from stdin | |
| STDIN=$(cat) | |
| TOOL_NAME=$(echo "$STDIN" | jq -r '.tool_name // empty' 2>/dev/null) | |
| TOOL_RESULT=$(echo "$STDIN" | jq -r '.tool_result // empty' 2>/dev/null) | |
| EXIT_CODE=$(echo "$STDIN" | jq -r '.tool_result.exitCode // empty' 2>/dev/null) | |
| TOOL_INPUT=$(echo "$STDIN" | jq -c '.tool_input // {}' 2>/dev/null) | |
| BEANS=0 | |
| REASON="" | |
| case "$TOOL_NAME" in | |
| Edit) | |
| # Edit failure: string not found, not unique, etc. | |
| if echo "$TOOL_RESULT" | grep -qiE "not found|not unique|no match|failed to match|old_string.*not found"; then | |
| BEANS=1 | |
| REASON="edit_fail" | |
| fi | |
| ;; | |
| Bash) | |
| # Non-zero exit code (skip exit code 1 — common for grep no-match etc.) | |
| if [ -n "$EXIT_CODE" ] && [ "$EXIT_CODE" != "0" ] && [ "$EXIT_CODE" != "1" ]; then | |
| BEANS=1 | |
| REASON="bash_error_${EXIT_CODE}" | |
| fi | |
| ;; | |
| Write) | |
| # Write failure | |
| if echo "$TOOL_RESULT" | grep -qiE "error|failed|denied"; then | |
| BEANS=1 | |
| REASON="write_fail" | |
| fi | |
| ;; | |
| esac | |
| # Add beans if earned | |
| if [ "$BEANS" -gt 0 ]; then | |
| # Update cumulative | |
| TOTAL=$(cat "$DEBT_FILE" 2>/dev/null || echo "0") | |
| TOTAL=$((TOTAL + BEANS)) | |
| echo "$TOTAL" > "$DEBT_FILE" | |
| # Update session counter | |
| SESSION=$(cat "$SESSION_DEBT_FILE" 2>/dev/null || echo "0") | |
| SESSION=$((SESSION + BEANS)) | |
| echo "$SESSION" > "$SESSION_DEBT_FILE" | |
| # Context proxies for pattern analysis | |
| PROMPT_COUNT=$(cat "/tmp/claude-prompt-counter-${PPID}" 2>/dev/null || echo "0") | |
| # Log the mistake with context | |
| TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") | |
| FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .command // "unknown"' 2>/dev/null | head -c 200) | |
| ERROR_SNIPPET=$(echo "$TOOL_RESULT" | head -c 300 | jq -Rs '.' 2>/dev/null || echo '""') | |
| echo "{\"ts\":\"${TIMESTAMP}\",\"type\":\"tool\",\"reason\":\"${REASON}\",\"tool\":\"${TOOL_NAME}\",\"file\":\"${FILE_PATH}\",\"error\":${ERROR_SNIPPET},\"prompt_count\":${PROMPT_COUNT},\"total\":${TOTAL}}" >> "$LOG_FILE" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment