Skip to content

Instantly share code, notes, and snippets.

@ceaksan
Created April 9, 2026 06:40
Show Gist options
  • Select an option

  • Save ceaksan/514ee710e6a1556c27a9cf4d6401fcde to your computer and use it in GitHub Desktop.

Select an option

Save ceaksan/514ee710e6a1556c27a9cf4d6401fcde to your computer and use it in GitHub Desktop.
Coffee Debt: Gamified AI Error Tracking for Claude Code (tracker + analyzer)
#!/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()
#!/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