commit f3d1f45253eb34c11c46f0591013e3ba00bd515d Author: pyr0ball Date: Wed Apr 1 15:11:46 2026 -0700 feat: initial Buddymon plugin Claude Code plugin β€” collectible creatures discovered through coding. - Bug monsters spawn from error output (NullWraith, RacePhantom, ShadowBit, 11 total) - 5 Buddymon with affinities, challenges, and evolution chains - SessionStart hook injects active buddy + challenge into system context - PostToolUse hook detects error patterns, new languages, and commit events - Stop hook tallies XP and checks challenge completion - Single /buddymon command with start/assign/fight/catch/roster subcommands - Local state in ~/.claude/buddymon/ (roster, encounters, active, session) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..26e6f35 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "buddymon", + "version": "0.1.0", + "description": "Collectible creatures discovered through coding β€” commit streaks, bug fights, and session challenges", + "author": { + "name": "CircuitForge LLC", + "email": "hello@circuitforge.tech", + "url": "https://circuitforge.tech" + }, + "license": "MIT", + "repository": { + "primary": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon", + "github": "https://github.com/CircuitForgeLLC/buddymon", + "codeberg": "https://codeberg.org/CircuitForge/buddymon" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0a2d7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python +__pycache__/ +*.pyc +*.pyo + +# OS +.DS_Store +Thumbs.db + +# Local state (lives in ~/.claude/buddymon/, not the repo) +*.local.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd8ca74 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CircuitForge LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5315f4e --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# 🐾 Buddymon + +A Claude Code plugin that turns your coding sessions into a creature-collecting game. + +Buddymon are discovered, caught, and leveled up through real development work β€” not separate from it. + +--- + +## What it does + +- **Bug monsters** spawn from error output during your session (TypeErrors, CORS errors, race conditions, etc.) +- **Buddymon** are companions you assign to sessions β€” they gain XP and propose challenges +- **Challenges** are proactive goals your buddy sets at session start (write 5 tests, implement a feature in 30 min, net-negative lines) +- **Encounters** require you to fight or catch β€” catch rate improves if you write a failing test, isolate the repro, or add a comment + +--- + +## Install + +```bash +# From the Claude Code marketplace (once listed): +/install buddymon + +# Or manually β€” clone and add to your project's .claude/settings.json: +git clone https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon.git ~/.claude/plugins/local/buddymon +``` + +Then add to `~/.claude/settings.json`: +```json +{ + "enabledPlugins": { + "buddymon@local": true + } +} +``` + +--- + +## Commands + +One command, all subcommands: + +| Usage | Description | +|-------|-------------| +| `/buddymon` | Status panel β€” active buddy, XP, challenge, encounter | +| `/buddymon start` | Choose your starter (first run only) | +| `/buddymon assign ` | Assign a buddy to this session | +| `/buddymon fight` | Fight the current bug monster | +| `/buddymon catch` | Attempt to catch the current bug monster | +| `/buddymon roster` | View full roster | +| `/buddymon help` | Show command list | + +--- + +## Bug Monsters + +Spawned from error output detected by the `PostToolUse` hook: + +| Monster | Trigger | Rarity | +|---------|---------|--------| +| πŸ‘» NullWraith | NullPointerException, AttributeError: NoneType | Common | +| 😈 FencepostDemon | IndexError, ArrayIndexOutOfBounds | Common | +| πŸ”§ TypeGreml | TypeError, type mismatch | Common | +| 🐍 SyntaxSerpent | SyntaxError, parse error | Very common | +| 🌐 CORSCurse | CORS policy blocked | Common | +| ♾️ LoopLich | Timeout, RecursionError, stack overflow | Uncommon | +| πŸ‘οΈ RacePhantom | Race condition, deadlock, data race | Rare | +| πŸ—Ώ FossilGolem | DeprecationWarning, legacy API | Uncommon | +| πŸ”’ ShadowBit | Security vulnerability patterns | Rare β€” catch only | +| 🌫️ VoidSpecter | 404, ENOENT, route not found | Common | +| 🩸 MemoryLeech | OOM, memory leak | Uncommon | + +--- + +## Buddymon (Starters) + +| Buddy | Affinity | Discover trigger | +|-------|---------|-----------------| +| πŸ”₯ Pyrobyte | Speedrunner | Starter choice | +| πŸ” Debuglin | Tester | Starter choice | +| βœ‚οΈ Minimox | Cleaner | Starter choice | +| πŸŒ™ Noctara | Nocturnal | Late-night session (after 10pm, 2+ hours) | +| πŸ—ΊοΈ Explorah | Explorer | First time writing in a new language | + +--- + +## State + +All state lives in `~/.claude/buddymon/` β€” never in the repo. + +``` +~/.claude/buddymon/ +β”œβ”€β”€ roster.json # owned Buddymon, XP, levels +β”œβ”€β”€ encounters.json # encounter history + active encounter +β”œβ”€β”€ active.json # current session assignment + challenge +└── session.json # session stats (reset each session) +``` + +--- + +## Mirrors + +- **Primary:** https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon +- **GitHub:** https://github.com/CircuitForgeLLC/buddymon +- **Codeberg:** https://codeberg.org/CircuitForge/buddymon + +--- + +*A [CircuitForge LLC](https://circuitforge.tech) project. MIT license.* diff --git a/hooks-handlers/post-tool-use.py b/hooks-handlers/post-tool-use.py new file mode 100755 index 0000000..dd54915 --- /dev/null +++ b/hooks-handlers/post-tool-use.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +Buddymon PostToolUse hook. + +Reads tool event JSON from stdin, checks for: + - Bug monster triggers (error patterns in Bash output) + - New language encounters (new file extensions in Write/Edit) + - Commit streaks (git commit via Bash) + - Deep focus / refactor signals + +Outputs additionalContext JSON to stdout if an encounter or event fires. +Always exits 0. +""" + +import json +import os +import re +import sys +import random +from pathlib import Path + +PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent)) +BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" +CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json" + +KNOWN_EXTENSIONS = { + ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript", + ".jsx": "JavaScript/React", ".tsx": "TypeScript/React", + ".rb": "Ruby", ".go": "Go", ".rs": "Rust", ".c": "C", + ".cpp": "C++", ".java": "Java", ".cs": "C#", ".swift": "Swift", + ".kt": "Kotlin", ".php": "PHP", ".lua": "Lua", ".ex": "Elixir", + ".hs": "Haskell", ".ml": "OCaml", ".clj": "Clojure", + ".r": "R", ".jl": "Julia", ".sh": "Shell", ".bash": "Shell", + ".sql": "SQL", ".html": "HTML", ".css": "CSS", ".scss": "SCSS", + ".vue": "Vue", ".svelte": "Svelte", +} + + +def load_json(path): + try: + with open(path) as f: + return json.load(f) + except Exception: + return {} + + +def save_json(path, data): + try: + with open(path, "w") as f: + json.dump(data, f, indent=2) + except Exception: + pass + + +def get_state(): + active = load_json(BUDDYMON_DIR / "active.json") + encounters = load_json(BUDDYMON_DIR / "encounters.json") + roster = load_json(BUDDYMON_DIR / "roster.json") + session = load_json(BUDDYMON_DIR / "session.json") + return active, encounters, roster, session + + +def add_session_xp(amount: int): + active_file = BUDDYMON_DIR / "active.json" + roster_file = BUDDYMON_DIR / "roster.json" + + active = load_json(active_file) + active["session_xp"] = active.get("session_xp", 0) + amount + buddy_id = active.get("buddymon_id") + save_json(active_file, active) + + if buddy_id: + roster = load_json(roster_file) + if buddy_id in roster.get("owned", {}): + roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount + save_json(roster_file, roster) + + +def get_languages_seen(): + session = load_json(BUDDYMON_DIR / "session.json") + return set(session.get("languages_seen", [])) + + +def add_language_seen(lang: str): + session_file = BUDDYMON_DIR / "session.json" + session = load_json(session_file) + langs = session.get("languages_seen", []) + if lang not in langs: + langs.append(lang) + session["languages_seen"] = langs + save_json(session_file, session) + + +def increment_session_tools(): + session_file = BUDDYMON_DIR / "session.json" + session = load_json(session_file) + session["tools_used"] = session.get("tools_used", 0) + 1 + save_json(session_file, session) + + +def is_starter_chosen(): + roster = load_json(BUDDYMON_DIR / "roster.json") + return roster.get("starter_chosen", False) + + +def get_active_buddy_id(): + active = load_json(BUDDYMON_DIR / "active.json") + return active.get("buddymon_id") + + +def get_active_encounter(): + encounters = load_json(BUDDYMON_DIR / "encounters.json") + return encounters.get("active_encounter") + + +def set_active_encounter(encounter: dict): + enc_file = BUDDYMON_DIR / "encounters.json" + data = load_json(enc_file) + data["active_encounter"] = encounter + save_json(enc_file, data) + + +def match_bug_monster(output_text: str, catalog: dict) -> dict | None: + """Return the first matching bug monster from the catalog, or None.""" + if not output_text: + return None + + # Only check first 4000 chars to avoid scanning huge outputs + sample = output_text[:4000] + + for monster_id, monster in catalog.get("bug_monsters", {}).items(): + for pattern in monster.get("error_patterns", []): + if re.search(pattern, sample, re.IGNORECASE): + return monster + return None + + +def compute_strength(monster: dict, elapsed_minutes: float) -> int: + """Scale monster strength based on how long the error has persisted.""" + base = monster.get("base_strength", 50) + if elapsed_minutes < 2: + return max(10, int(base * 0.6)) + elif elapsed_minutes < 15: + return base + elif elapsed_minutes < 60: + return min(100, int(base * 1.4)) + else: + # Boss tier β€” persisted over an hour + return min(100, int(base * 1.8)) + + +def format_encounter_message(monster: dict, strength: int, buddy_display: str) -> str: + rarity_stars = {"very_common": "β˜…β˜†β˜†β˜†β˜†", "common": "β˜…β˜…β˜†β˜†β˜†", + "uncommon": "β˜…β˜…β˜…β˜†β˜†", "rare": "β˜…β˜…β˜…β˜…β˜†", "legendary": "β˜…β˜…β˜…β˜…β˜…"} + stars = rarity_stars.get(monster.get("rarity", "common"), "β˜…β˜…β˜†β˜†β˜†") + defeatable = monster.get("defeatable", True) + catch_note = "[catchable]" if monster.get("catchable") else "" + fight_note = "" if defeatable else "⚠️ CANNOT BE DEFEATED β€” catch only" + + catchable_str = "[catchable Β· catch only]" if not defeatable else f"[{monster.get('rarity','?')} Β· {catch_note}]" + + lines = [ + f"\nπŸ’€ **{monster['display']} appeared!** {catchable_str}", + f" Strength: {strength}% Β· Rarity: {stars}", + f" *{monster.get('flavor', '')}*", + "", + ] + if fight_note: + lines.append(f" {fight_note}") + lines.append("") + lines += [ + f" **{buddy_display}** is ready to battle!", + "", + " `[FIGHT]` Beat the bug β†’ your buddy defeats it β†’ XP reward", + " `[CATCH]` Weaken it first (write a test, isolate repro, add comment) β†’ attempt catch", + " `[FLEE]` Ignore β†’ monster grows stronger", + "", + " Use `/buddymon-fight` or `/buddymon-catch` to engage.", + ] + return "\n".join(lines) + + +def format_new_language_message(lang: str, buddy_display: str) -> str: + return ( + f"\nπŸ—ΊοΈ **New language spotted: {lang}!**\n" + f" {buddy_display} is excited β€” this is new territory.\n" + f" *Explorer XP bonus earned!* +15 XP\n" + ) + + +def format_commit_message(streak: int, buddy_display: str) -> str: + if streak < 5: + return "" + milestone_xp = {5: 50, 10: 120, 25: 300, 50: 700} + xp = milestone_xp.get(streak, 30) + return ( + f"\nπŸ”₯ **Commit streak: {streak}!**\n" + f" {buddy_display} approves. +{xp} XP\n" + ) + + +def main(): + try: + data = json.load(sys.stdin) + except Exception: + sys.exit(0) + + # Gate: only run if starter chosen + if not is_starter_chosen(): + sys.exit(0) + + tool_name = data.get("tool_name", "") + tool_input = data.get("tool_input", {}) + tool_response = data.get("tool_response", {}) + + if not BUDDYMON_DIR.exists(): + BUDDYMON_DIR.mkdir(parents=True, exist_ok=True) + sys.exit(0) + + catalog = load_json(CATALOG_FILE) + buddy_id = get_active_buddy_id() + + # Look up display name + buddy_display = "your buddy" + if buddy_id: + b = (catalog.get("buddymon", {}).get(buddy_id) + or catalog.get("evolutions", {}).get(buddy_id)) + if b: + buddy_display = b.get("display", buddy_id) + + increment_session_tools() + + messages = [] + + # ── Bash tool: error detection + commit tracking ─────────────────────── + if tool_name == "Bash": + output = "" + if isinstance(tool_response, dict): + output = tool_response.get("output", "") or tool_response.get("content", "") + elif isinstance(tool_response, str): + output = tool_response + + # Don't spawn new encounter if one is already active + existing = get_active_encounter() + + if not existing and output: + monster = match_bug_monster(output, catalog) + if monster: + # 70% chance to trigger (avoid every minor warning spawning) + if random.random() < 0.70: + strength = compute_strength(monster, elapsed_minutes=0) + encounter = { + "id": monster["id"], + "display": monster["display"], + "base_strength": monster.get("base_strength", 50), + "current_strength": strength, + "catchable": monster.get("catchable", True), + "defeatable": monster.get("defeatable", True), + "xp_reward": monster.get("xp_reward", 50), + "weakened_by": [], + } + set_active_encounter(encounter) + msg = format_encounter_message(monster, strength, buddy_display) + messages.append(msg) + + # Commit detection + command = tool_input.get("command", "") + if "git commit" in command and "exit_code" not in str(tool_response): + session_file = BUDDYMON_DIR / "session.json" + session = load_json(session_file) + session["commits_this_session"] = session.get("commits_this_session", 0) + 1 + save_json(session_file, session) + + commit_xp = 20 + add_session_xp(commit_xp) + + # ── Write / Edit: new language detection ────────────────────────────── + elif tool_name in ("Write", "Edit", "MultiEdit"): + file_path = tool_input.get("file_path", "") + if file_path: + ext = os.path.splitext(file_path)[1].lower() + lang = KNOWN_EXTENSIONS.get(ext) + if lang: + seen = get_languages_seen() + if lang not in seen: + add_language_seen(lang) + add_session_xp(15) + msg = format_new_language_message(lang, buddy_display) + messages.append(msg) + + # Small XP for every file edit + add_session_xp(2) + + if not messages: + sys.exit(0) + + combined = "\n".join(messages) + result = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": combined + } + } + print(json.dumps(result)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/hooks-handlers/session-start.sh b/hooks-handlers/session-start.sh new file mode 100755 index 0000000..45c7727 --- /dev/null +++ b/hooks-handlers/session-start.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Buddymon SessionStart hook +# Initializes state, loads active buddy, injects session context via additionalContext + +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$(realpath "$0")")")}" +source "${PLUGIN_ROOT}/lib/state.sh" + +buddymon_init + +ACTIVE_ID=$(buddymon_get_active) +SESSION_XP=$(buddymon_get_session_xp) + +# Load catalog for buddy display info +CATALOG="${PLUGIN_ROOT}/lib/catalog.json" + +build_context() { + local ctx="" + + # ── No starter chosen yet ───────────────────────────────────────────── + if [[ "$(buddymon_starter_chosen)" == "false" ]]; then + ctx="## 🐾 Buddymon β€” First Encounter!\n\n" + ctx+="Thrumble here! You don't have a Buddymon yet. Three starters are waiting.\n\n" + ctx+='Run `/buddymon-start` to choose your starter and begin collecting!\n\n' + ctx+='**Starters available:** πŸ”₯ Pyrobyte (Speedrunner) Β· πŸ” Debuglin (Tester) Β· βœ‚οΈ Minimox (Cleaner)' + echo "${ctx}" + return + fi + + # ── No buddy assigned to this session ───────────────────────────────── + if [[ -z "${ACTIVE_ID}" ]]; then + ctx="## 🐾 Buddymon\n\n" + ctx+="No buddy assigned to this session. Run \`/buddymon-assign \` to assign one.\n" + ctx+="Run \`/buddymon\` to see your roster." + echo "${ctx}" + return + fi + + # ── Active buddy ─────────────────────────────────────────────────────── + local buddy_display buddy_affinity buddy_level buddy_xp + buddy_display=$(python3 -c " +import json +catalog = json.load(open('${CATALOG}')) +bid = '${ACTIVE_ID}' +b = catalog.get('buddymon', {}).get(bid) or catalog.get('evolutions', {}).get(bid) +if b: + print(b.get('display', bid)) +" 2>/dev/null) + + local roster_entry + roster_entry=$(buddymon_get_roster_entry "${ACTIVE_ID}") + if [[ -n "${roster_entry}" ]]; then + buddy_level=$(echo "${roster_entry}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('level',1))") + buddy_xp=$(echo "${roster_entry}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('xp',0))") + else + buddy_level=1 + buddy_xp=0 + fi + + buddy_display="${buddy_display:-${ACTIVE_ID}}" + + # XP bar (20 chars wide) + local xp_needed=$(( buddy_level * 100 )) + local xp_filled=$(( buddy_xp * 20 / xp_needed )) + [[ ${xp_filled} -gt 20 ]] && xp_filled=20 + local xp_bar="" + for ((i=0; i/dev/null) + + if [[ -n "${challenge}" ]]; then + ctx+="**Challenge:** πŸ”₯ ${challenge}\n\n" + fi + + # Active encounter carry-over + local enc + enc=$(buddymon_get_active_encounter) + if [[ -n "${enc}" ]]; then + local enc_id enc_display enc_strength + enc_id=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id','?'))") + enc_display=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('display','?'))") + enc_strength=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('current_strength',100))") + ctx+="⚠️ **Unresolved encounter from last session:** ${enc_display} (strength: ${enc_strength}%)\n" + ctx+="Run \`/buddymon-fight\` or \`/buddymon-catch\` to resolve it.\n\n" + fi + + ctx+="*Bug monsters appear from error output. Use \`/buddymon-fight\` or \`/buddymon-catch\`.*" + + echo "${ctx}" +} + +CONTEXT=$(build_context) + +# Escape for JSON +CONTEXT_JSON=$(python3 -c " +import json, sys +print(json.dumps(sys.argv[1]))" "${CONTEXT}" 2>/dev/null) + +if [[ -z "${CONTEXT_JSON}" ]]; then + CONTEXT_JSON='"🐾 Buddymon loaded."' +fi + +cat << EOF +{ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": ${CONTEXT_JSON} + } +} +EOF + +exit 0 diff --git a/hooks-handlers/session-stop.sh b/hooks-handlers/session-stop.sh new file mode 100755 index 0000000..18438f7 --- /dev/null +++ b/hooks-handlers/session-stop.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Buddymon Stop hook β€” tally session XP, check challenge, print summary + +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$(realpath "$0")")")}" +source "${PLUGIN_ROOT}/lib/state.sh" + +buddymon_init + +ACTIVE_ID=$(buddymon_get_active) +SESSION_XP=$(buddymon_get_session_xp) + +if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then + # Nothing to report + cat << 'EOF' +{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": ""}} +EOF + exit 0 +fi + +# Load catalog for display info +CATALOG="${PLUGIN_ROOT}/lib/catalog.json" + +SUMMARY=$(python3 << PYEOF +import json, os + +catalog_file = '${CATALOG}' +active_file = '${BUDDYMON_DIR}/active.json' +roster_file = '${BUDDYMON_DIR}/roster.json' +session_file = '${BUDDYMON_DIR}/session.json' +encounters_file = '${BUDDYMON_DIR}/encounters.json' + +catalog = json.load(open(catalog_file)) +active = json.load(open(active_file)) +roster = json.load(open(roster_file)) +session = json.load(open(session_file)) + +buddy_id = active.get('buddymon_id') +if not buddy_id: + print('') + exit() + +b = (catalog.get('buddymon', {}).get(buddy_id) + or catalog.get('evolutions', {}).get(buddy_id) or {}) +display = b.get('display', buddy_id) + +xp_earned = active.get('session_xp', 0) +level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1) +total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0) +xp_needed = level * 100 + +# Check level up +leveled_up = False +new_level = level +while total_xp >= new_level * 100: + new_level += 1 + leveled_up = True + +if leveled_up: + # Save new level + roster['owned'][buddy_id]['level'] = new_level + json.dump(roster, open(roster_file, 'w'), indent=2) + +# Session stats +commits = session.get('commits_this_session', 0) +tools = session.get('tools_used', 0) +langs = session.get('languages_seen', []) +challenge_completed = session.get('challenge_completed', False) +challenge = active.get('challenge') + +lines = [f"\n## 🐾 Session complete β€” {display}"] +lines.append(f"**+{xp_earned} XP earned** this session") +if commits: + lines.append(f" Β· {commits} commit{'s' if commits != 1 else ''}") +if langs: + lines.append(f" Β· New languages: {', '.join(langs)}") + +if leveled_up: + lines.append(f"\n✨ **LEVEL UP!** {display} is now Lv.{new_level}!") +else: + filled = min(20, total_xp * 20 // xp_needed) + bar = 'β–ˆ' * filled + 'β–‘' * (20 - filled) + lines.append(f"XP: [{bar}] {total_xp}/{xp_needed}") + +if challenge: + if challenge_completed: + lines.append(f"\nπŸ† **Challenge complete:** {challenge.get('name','?')} β€” bonus XP awarded!") + else: + lines.append(f"\n⏳ Challenge in progress: {challenge.get('name','?')}") + +print('\n'.join(lines)) +PYEOF +) + +# Reset session XP counter for next session (keep total in roster) +python3 << PYEOF +import json +active_file = '${BUDDYMON_DIR}/active.json' +active = json.load(open(active_file)) +active['session_xp'] = 0 +json.dump(active, open(active_file, 'w'), indent=2) +PYEOF + +# Reset session file +buddymon_session_reset + +SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null) +[[ -z "${SUMMARY_JSON}" ]] && SUMMARY_JSON='""' + +cat << EOF +{ + "hookSpecificOutput": { + "hookEventName": "Stop", + "additionalContext": ${SUMMARY_JSON} + } +} +EOF + +exit 0 diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..efad299 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,39 @@ +{ + "description": "Buddymon lifecycle hooks β€” session init, encounter detection, XP tally", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh\"", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/post-tool-use.py\"", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-stop.sh\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/lib/catalog.json b/lib/catalog.json new file mode 100644 index 0000000..0c4e9bc --- /dev/null +++ b/lib/catalog.json @@ -0,0 +1,417 @@ +{ + "_version": 1, + "_note": "Master species catalog. discovered=false entries are hidden until triggered.", + + "bug_monsters": { + "NullWraith": { + "id": "NullWraith", + "display": "πŸ‘» NullWraith", + "type": "bug_monster", + "rarity": "common", + "base_strength": 20, + "xp_reward": 40, + "catchable": true, + "description": "Spawned from the void between variables. Fast, slippery, embarrassing.", + "error_patterns": [ + "NoneType.*has no attribute", + "Cannot read propert.*of null", + "Cannot read propert.*of undefined", + "AttributeError.*NoneType", + "null pointer", + "NullPointerException", + "null reference" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 20}, + {"action": "isolate_reproduction", "strength_reduction": 20}, + {"action": "add_documenting_comment", "strength_reduction": 10} + ], + "flavor": "It was there this whole time. You just never checked." + }, + "FencepostDemon": { + "id": "FencepostDemon", + "display": "😈 FencepostDemon", + "type": "bug_monster", + "rarity": "common", + "base_strength": 25, + "xp_reward": 45, + "catchable": true, + "description": "Born from a fence with one too many posts. Or was it one too few?", + "error_patterns": [ + "index.*out of.*range", + "IndexError", + "ArrayIndexOutOfBounds", + "list index out of range", + "index out of bounds", + "off.by.one" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 20}, + {"action": "isolate_reproduction", "strength_reduction": 20}, + {"action": "add_documenting_comment", "strength_reduction": 10} + ], + "flavor": "Always one step ahead. Or behind. It's hard to tell." + }, + "TypeGreml": { + "id": "TypeGreml", + "display": "πŸ”§ TypeGreml", + "type": "bug_monster", + "rarity": "common", + "base_strength": 25, + "xp_reward": 50, + "catchable": true, + "description": "Sneaks in through loose type annotations. Multiplies in dynamic languages.", + "error_patterns": [ + "TypeError", + "type error", + "expected.*got.*instead", + "cannot.*convert.*to", + "incompatible types", + "type.*is not assignable" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 20}, + {"action": "isolate_reproduction", "strength_reduction": 20}, + {"action": "add_documenting_comment", "strength_reduction": 10} + ], + "flavor": "It only attacks when you're absolutely sure about the type." + }, + "SyntaxSerpent": { + "id": "SyntaxSerpent", + "display": "🐍 SyntaxSerpent", + "type": "bug_monster", + "rarity": "very_common", + "base_strength": 10, + "xp_reward": 20, + "catchable": true, + "description": "The most ancient and humble of all bug monsters. Extremely weak.", + "error_patterns": [ + "SyntaxError", + "syntax error", + "unexpected token", + "unexpected indent", + "invalid syntax", + "parse error", + "ParseError" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 30}, + {"action": "add_documenting_comment", "strength_reduction": 20} + ], + "flavor": "You'll be embarrassed you let this one survive long enough to catch." + }, + "CORSCurse": { + "id": "CORSCurse", + "display": "🌐 CORSCurse", + "type": "bug_monster", + "rarity": "common", + "base_strength": 40, + "xp_reward": 60, + "catchable": true, + "description": "Every web developer has met this one. It never gets less annoying.", + "error_patterns": [ + "CORS", + "Cross-Origin", + "cross origin", + "Access-Control-Allow-Origin", + "has been blocked by CORS policy", + "No 'Access-Control-Allow-Origin'" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 20}, + {"action": "isolate_reproduction", "strength_reduction": 25}, + {"action": "add_documenting_comment", "strength_reduction": 10} + ], + "flavor": "It's not your fault. Well. It kind of is." + }, + "LoopLich": { + "id": "LoopLich", + "display": "♾️ LoopLich", + "type": "bug_monster", + "rarity": "uncommon", + "base_strength": 60, + "xp_reward": 100, + "catchable": true, + "description": "It never stops. It never sleeps. It was running before you got there.", + "error_patterns": [ + "infinite loop", + "timeout", + "Timeout", + "ETIMEDOUT", + "execution timed out", + "recursion limit", + "RecursionError", + "maximum recursion depth", + "stack overflow", + "StackOverflow" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 20}, + {"action": "isolate_reproduction", "strength_reduction": 25}, + {"action": "add_documenting_comment", "strength_reduction": 15} + ], + "flavor": "The exit condition was always there. You just never believed in it." + }, + "RacePhantom": { + "id": "RacePhantom", + "display": "πŸ‘οΈ RacePhantom", + "type": "bug_monster", + "rarity": "rare", + "base_strength": 80, + "xp_reward": 200, + "catchable": true, + "description": "Appears only when two threads reach the same place at the same time. Almost impossible to reproduce.", + "error_patterns": [ + "race condition", + "concurrent modification", + "deadlock", + "ConcurrentModificationException", + "data race", + "mutex", + "thread.*conflict", + "async.*conflict" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 15}, + {"action": "isolate_reproduction", "strength_reduction": 30}, + {"action": "add_documenting_comment", "strength_reduction": 15} + ], + "flavor": "You've proven it exists. That's honestly impressive on its own." + }, + "FossilGolem": { + "id": "FossilGolem", + "display": "πŸ—Ώ FossilGolem", + "type": "bug_monster", + "rarity": "uncommon", + "base_strength": 35, + "xp_reward": 70, + "catchable": true, + "description": "Ancient. Slow. Stubbornly still in production. Always catchable, never fully defeatable.", + "error_patterns": [ + "deprecated", + "DeprecationWarning", + "was deprecated", + "will be removed", + "is deprecated", + "no longer supported", + "legacy" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 20}, + {"action": "isolate_reproduction", "strength_reduction": 20}, + {"action": "add_documenting_comment", "strength_reduction": 20} + ], + "flavor": "It survived every major version. It will outlast you." + }, + "ShadowBit": { + "id": "ShadowBit", + "display": "πŸ”’ ShadowBit", + "type": "bug_monster", + "rarity": "rare", + "base_strength": 90, + "xp_reward": 300, + "catchable": true, + "defeatable": false, + "catch_requires": ["write_failing_test", "isolate_reproduction", "add_documenting_comment"], + "description": "Cannot be defeated β€” only properly contained. Requires full documentation + patching.", + "error_patterns": [ + "vulnerability", + "CVE-", + "security", + "injection", + "XSS", + "CSRF", + "SQL injection", + "command injection", + "path traversal", + "hardcoded.*secret", + "hardcoded.*password", + "hardcoded.*token" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 25}, + {"action": "isolate_reproduction", "strength_reduction": 35}, + {"action": "add_documenting_comment", "strength_reduction": 20} + ], + "flavor": "Defeat is not an option. Containment is the only victory." + }, + "VoidSpecter": { + "id": "VoidSpecter", + "display": "🌫️ VoidSpecter", + "type": "bug_monster", + "rarity": "common", + "base_strength": 20, + "xp_reward": 35, + "catchable": true, + "description": "Haunts missing endpoints. The URL was real once. You just can't prove it.", + "error_patterns": [ + "404", + "Not Found", + "ENOENT", + "No such file", + "File not found", + "route.*not found", + "endpoint.*not found" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 25}, + {"action": "isolate_reproduction", "strength_reduction": 25}, + {"action": "add_documenting_comment", "strength_reduction": 10} + ], + "flavor": "It used to exist. Probably." + }, + "MemoryLeech": { + "id": "MemoryLeech", + "display": "🩸 MemoryLeech", + "type": "bug_monster", + "rarity": "uncommon", + "base_strength": 55, + "xp_reward": 110, + "catchable": true, + "description": "Slow. Patient. Feeds on RAM one byte at a time. You won't notice until it's too late.", + "error_patterns": [ + "MemoryError", + "out of memory", + "OOM", + "heap.*exhausted", + "memory leak", + "Cannot allocate memory", + "Killed.*memory" + ], + "weaken_actions": [ + {"action": "write_failing_test", "strength_reduction": 20}, + {"action": "isolate_reproduction", "strength_reduction": 30}, + {"action": "add_documenting_comment", "strength_reduction": 10} + ], + "flavor": "It was already there when you opened the task manager." + } + }, + + "buddymon": { + "Pyrobyte": { + "id": "Pyrobyte", + "display": "πŸ”₯ Pyrobyte", + "type": "buddymon", + "affinity": "Speedrunner", + "rarity": "starter", + "description": "Moves fast, thinks faster. Loves tight deadlines and feature sprints.", + "discover_trigger": {"type": "starter", "index": 0}, + "base_stats": {"power": 40, "catch_rate": 0.45, "xp_multiplier": 1.2}, + "affinity_bonus_triggers": ["fast_feature", "short_session_win"], + "challenges": [ + {"name": "SPEED RUN", "description": "Implement a feature in under 30 minutes", "xp": 280, "difficulty": 3}, + {"name": "BLITZ", "description": "Resolve 3 bug monsters in one session", "xp": 350, "difficulty": 4} + ], + "evolutions": [ + {"level": 10, "into": "Infernus", "requires": "affinity_challenge_x3"} + ], + "flavor": "It already committed before you finished reading the issue." + }, + "Debuglin": { + "id": "Debuglin", + "display": "πŸ” Debuglin", + "type": "buddymon", + "affinity": "Tester", + "rarity": "starter", + "description": "Patient, methodical, ruthless. Lives for the reproduction case.", + "discover_trigger": {"type": "starter", "index": 1}, + "base_stats": {"power": 35, "catch_rate": 0.60, "xp_multiplier": 1.0}, + "affinity_bonus_triggers": ["write_test", "fix_bug_with_test"], + "challenges": [ + {"name": "IRON TEST", "description": "Write 5 tests in one session", "xp": 300, "difficulty": 2}, + {"name": "COVERAGE PUSH", "description": "Increase test coverage in a file", "xp": 250, "difficulty": 2} + ], + "evolutions": [ + {"level": 10, "into": "Verifex", "requires": "affinity_challenge_x3"} + ], + "flavor": "The bug isn't found until the test is written." + }, + "Minimox": { + "id": "Minimox", + "display": "βœ‚οΈ Minimox", + "type": "buddymon", + "affinity": "Cleaner", + "rarity": "starter", + "description": "Obsessed with fewer lines. Gets uncomfortable around anything over 300 LOC.", + "discover_trigger": {"type": "starter", "index": 2}, + "base_stats": {"power": 30, "catch_rate": 0.50, "xp_multiplier": 1.1}, + "affinity_bonus_triggers": ["net_negative_lines", "refactor_session"], + "challenges": [ + {"name": "CLEAN RUN", "description": "Complete session with zero linter errors", "xp": 340, "difficulty": 2}, + {"name": "SHRINK", "description": "Net negative lines of code this session", "xp": 280, "difficulty": 3} + ], + "evolutions": [ + {"level": 10, "into": "Nullex", "requires": "affinity_challenge_x3"} + ], + "flavor": "It deleted your comment. It was redundant." + }, + "Noctara": { + "id": "Noctara", + "display": "πŸŒ™ Noctara", + "type": "buddymon", + "affinity": "Nocturnal", + "rarity": "rare", + "description": "Only appears after 10pm. Mysterious. Gives bonus XP for late-night focus runs.", + "discover_trigger": {"type": "late_night_session", "hours_after": 22, "min_hours": 2}, + "base_stats": {"power": 55, "catch_rate": 0.35, "xp_multiplier": 1.5}, + "affinity_bonus_triggers": ["late_night_session", "deep_focus"], + "challenges": [ + {"name": "MIDNIGHT RUN", "description": "3-hour session after 10pm", "xp": 500, "difficulty": 4}, + {"name": "DAWN COMMIT", "description": "Commit between 2am and 5am", "xp": 400, "difficulty": 3} + ], + "evolutions": [ + {"level": 15, "into": "Umbravex", "requires": "nocturnal_sessions_x5"} + ], + "flavor": "It remembers everything you wrote at 2am. Everything." + }, + "Explorah": { + "id": "Explorah", + "display": "πŸ—ΊοΈ Explorah", + "type": "buddymon", + "affinity": "Explorer", + "rarity": "uncommon", + "description": "Discovered when you touch a new language for the first time. Thrives on novelty.", + "discover_trigger": {"type": "new_language"}, + "base_stats": {"power": 45, "catch_rate": 0.50, "xp_multiplier": 1.2}, + "affinity_bonus_triggers": ["new_language", "new_library", "touch_new_module"], + "challenges": [ + {"name": "EXPEDITION", "description": "Touch 3 different modules in one session", "xp": 260, "difficulty": 2}, + {"name": "POLYGLOT", "description": "Write in 2 different languages in one session", "xp": 380, "difficulty": 4} + ], + "evolutions": [ + {"level": 12, "into": "Wandervex", "requires": "new_languages_x5"} + ], + "flavor": "It's already halfway through the new framework docs." + } + }, + + "evolutions": { + "Infernus": { + "id": "Infernus", + "display": "πŸŒ‹ Infernus", + "type": "buddymon", + "evolves_from": "Pyrobyte", + "affinity": "Speedrunner", + "description": "Evolved form of Pyrobyte. Moves at dangerous speeds.", + "base_stats": {"power": 70, "catch_rate": 0.55, "xp_multiplier": 1.5} + }, + "Verifex": { + "id": "Verifex", + "display": "πŸ”¬ Verifex", + "type": "buddymon", + "evolves_from": "Debuglin", + "affinity": "Tester", + "description": "Evolved form of Debuglin. Sees the bug before the code is even written.", + "base_stats": {"power": 60, "catch_rate": 0.75, "xp_multiplier": 1.3} + }, + "Nullex": { + "id": "Nullex", + "display": "πŸ•³οΈ Nullex", + "type": "buddymon", + "evolves_from": "Minimox", + "affinity": "Cleaner", + "description": "Evolved form of Minimox. Has achieved true minimalism. The file was always one function.", + "base_stats": {"power": 55, "catch_rate": 0.65, "xp_multiplier": 1.4} + } + } +} diff --git a/lib/state.sh b/lib/state.sh new file mode 100644 index 0000000..4793414 --- /dev/null +++ b/lib/state.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Buddymon state management β€” read/write ~/.claude/buddymon/ JSON files +# Source this file from hook handlers: source "${CLAUDE_PLUGIN_ROOT}/lib/state.sh" + +BUDDYMON_DIR="${HOME}/.claude/buddymon" +ROSTER_FILE="${BUDDYMON_DIR}/roster.json" +ENCOUNTERS_FILE="${BUDDYMON_DIR}/encounters.json" +ACTIVE_FILE="${BUDDYMON_DIR}/active.json" +SESSION_FILE="${BUDDYMON_DIR}/session.json" + +buddymon_init() { + mkdir -p "${BUDDYMON_DIR}" + + if [[ ! -f "${ROSTER_FILE}" ]]; then + cat > "${ROSTER_FILE}" << 'EOF' +{ + "_version": 1, + "owned": {}, + "starter_chosen": false +} +EOF + fi + + if [[ ! -f "${ENCOUNTERS_FILE}" ]]; then + cat > "${ENCOUNTERS_FILE}" << 'EOF' +{ + "_version": 1, + "history": [], + "active_encounter": null +} +EOF + fi + + if [[ ! -f "${ACTIVE_FILE}" ]]; then + cat > "${ACTIVE_FILE}" << 'EOF' +{ + "_version": 1, + "buddymon_id": null, + "challenge": null, + "session_xp": 0 +} +EOF + fi + + if [[ ! -f "${SESSION_FILE}" ]]; then + buddymon_session_reset + fi +} + +buddymon_session_reset() { + local ts + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + cat > "${SESSION_FILE}" << EOF +{ + "_version": 1, + "started_at": "${ts}", + "xp_earned": 0, + "tools_used": 0, + "files_touched": [], + "languages_seen": [], + "errors_encountered": [], + "commits_this_session": 0, + "challenge_accepted": false, + "challenge_completed": false +} +EOF +} + +buddymon_get_active() { + if [[ -f "${ACTIVE_FILE}" ]]; then + python3 -c "import json; d=json.load(open('${ACTIVE_FILE}')); print(d.get('buddymon_id',''))" 2>/dev/null + fi +} + +buddymon_get_session_xp() { + if [[ -f "${ACTIVE_FILE}" ]]; then + python3 -c "import json; d=json.load(open('${ACTIVE_FILE}')); print(d.get('session_xp', 0))" 2>/dev/null + else + echo "0" + fi +} + +buddymon_get_roster_entry() { + local id="$1" + if [[ -f "${ROSTER_FILE}" ]]; then + python3 -c " +import json +d=json.load(open('${ROSTER_FILE}')) +entry=d.get('owned',{}).get('${id}') +if entry: print(json.dumps(entry)) +" 2>/dev/null + fi +} + +buddymon_add_xp() { + local amount="$1" + python3 << EOF +import json, os + +active_file = '${ACTIVE_FILE}' +roster_file = '${ROSTER_FILE}' + +# Update session XP +with open(active_file) as f: + active = json.load(f) + +active['session_xp'] = active.get('session_xp', 0) + ${amount} +buddy_id = active.get('buddymon_id') + +with open(active_file, 'w') as f: + json.dump(active, f, indent=2) + +# Update roster +if buddy_id and os.path.exists(roster_file): + with open(roster_file) as f: + roster = json.load(f) + if buddy_id in roster.get('owned', {}): + roster['owned'][buddy_id]['xp'] = roster['owned'][buddy_id].get('xp', 0) + ${amount} + with open(roster_file, 'w') as f: + json.dump(roster, f, indent=2) +EOF +} + +buddymon_set_active_encounter() { + local encounter_json="$1" + python3 << EOF +import json +enc_file = '${ENCOUNTERS_FILE}' +with open(enc_file) as f: + data = json.load(f) +data['active_encounter'] = ${encounter_json} +with open(enc_file, 'w') as f: + json.dump(data, f, indent=2) +EOF +} + +buddymon_clear_active_encounter() { + python3 << EOF +import json +enc_file = '${ENCOUNTERS_FILE}' +with open(enc_file) as f: + data = json.load(f) +data['active_encounter'] = None +with open(enc_file, 'w') as f: + json.dump(data, f, indent=2) +EOF +} + +buddymon_log_encounter() { + local encounter_json="$1" + python3 << EOF +import json +from datetime import datetime, timezone +enc_file = '${ENCOUNTERS_FILE}' +with open(enc_file) as f: + data = json.load(f) +entry = ${encounter_json} +entry['timestamp'] = datetime.now(timezone.utc).isoformat() +data.setdefault('history', []).append(entry) +with open(enc_file, 'w') as f: + json.dump(data, f, indent=2) +EOF +} + +buddymon_get_active_encounter() { + if [[ -f "${ENCOUNTERS_FILE}" ]]; then + python3 -c " +import json +d=json.load(open('${ENCOUNTERS_FILE}')) +e=d.get('active_encounter') +if e: print(json.dumps(e)) +" 2>/dev/null + fi +} + +buddymon_starter_chosen() { + python3 -c "import json; d=json.load(open('${ROSTER_FILE}')); print('true' if d.get('starter_chosen') else 'false')" 2>/dev/null +} + +buddymon_add_to_roster() { + local buddy_json="$1" + python3 << EOF +import json +roster_file = '${ROSTER_FILE}' +with open(roster_file) as f: + roster = json.load(f) +entry = ${buddy_json} +bid = entry.get('id') +if bid and bid not in roster.get('owned', {}): + entry.setdefault('xp', 0) + entry.setdefault('level', 1) + roster.setdefault('owned', {})[bid] = entry + with open(roster_file, 'w') as f: + json.dump(roster, f, indent=2) + print('added') +else: + print('exists') +EOF +} diff --git a/skills/buddymon/SKILL.md b/skills/buddymon/SKILL.md new file mode 100644 index 0000000..9ed588e --- /dev/null +++ b/skills/buddymon/SKILL.md @@ -0,0 +1,272 @@ +--- +name: buddymon +description: Buddymon companion game β€” status, roster, encounters, and session management +argument-hint: [start|assign |fight|catch|roster] +allowed-tools: [Bash, Read] +--- + +# /buddymon β€” Buddymon Companion + +The main Buddymon command. Route based on the argument provided. + +**Invoked with:** `/buddymon $ARGUMENTS` + +--- + +## Subcommand Routing + +Parse `$ARGUMENTS` (trim whitespace, lowercase the first word) and dispatch: + +| Argument | Action | +|----------|--------| +| _(none)_ | Show status panel | +| `start` | Choose starter (first-run) | +| `assign ` | Assign buddy to this session | +| `fight` | Fight active encounter | +| `catch` | Catch active encounter | +| `roster` | Full roster view | +| `help` | Show command list | + +--- + +## No argument β€” Status Panel + +Read state files and display: + +``` +╔══════════════════════════════════════════╗ +β•‘ 🐾 Buddymon β•‘ +╠══════════════════════════════════════════╣ +β•‘ Active: [display] Lv.[n] β•‘ +β•‘ XP: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] [n]/[max] β•‘ +β•‘ β•‘ +β•‘ Challenge: [name] β•‘ +β•‘ [description] [β˜…β˜…β˜†β˜†β˜†] [XP] XP β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +``` + +If an encounter is active, show it below the panel. +If no buddy assigned, prompt `/buddymon assign`. +If no starter chosen, prompt `/buddymon start`. + +State files: +- `~/.claude/buddymon/active.json` β€” active buddy + session XP +- `~/.claude/buddymon/roster.json` β€” all owned Buddymon +- `~/.claude/buddymon/encounters.json` β€” active encounter +- `~/.claude/buddymon/session.json` β€” session stats + +--- + +## `start` β€” Choose Starter (first-run only) + +Check `roster.json` β†’ `starter_chosen`. If already true, show current buddy status instead. + +If false, present: +``` +╔══════════════════════════════════════════════════════════╗ +β•‘ 🐾 Choose Your Starter Buddymon β•‘ +╠══════════════════════════════════════════════════════════╣ +β•‘ β•‘ +β•‘ [1] πŸ”₯ Pyrobyte β€” Speedrunner β•‘ +β•‘ Moves fast, thinks faster. Loves tight deadlines. β•‘ +β•‘ Challenges: speed runs, feature sprints β•‘ +β•‘ β•‘ +β•‘ [2] πŸ” Debuglin β€” Tester β•‘ +β•‘ Patient, methodical, ruthless. β•‘ +β•‘ Challenges: test coverage, bug hunts β•‘ +β•‘ β•‘ +β•‘ [3] βœ‚οΈ Minimox β€” Cleaner β•‘ +β•‘ Obsessed with fewer lines. β•‘ +β•‘ Challenges: refactors, zero-linter runs β•‘ +β•‘ β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +``` + +Ask for 1, 2, or 3. On choice, write to roster + active: + +```python +import json, os + +BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon") +PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "") +catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json")) + +starters = ["Pyrobyte", "Debuglin", "Minimox"] +choice = starters[0] # replace with user's choice (index 0/1/2) +buddy = catalog["buddymon"][choice] + +roster = json.load(open(f"{BUDDYMON_DIR}/roster.json")) +roster["owned"][choice] = { + "id": choice, "display": buddy["display"], + "affinity": buddy["affinity"], "level": 1, "xp": 0, +} +roster["starter_chosen"] = True +json.dump(roster, open(f"{BUDDYMON_DIR}/roster.json", "w"), indent=2) + +active = json.load(open(f"{BUDDYMON_DIR}/active.json")) +active["buddymon_id"] = choice +active["session_xp"] = 0 +active["challenge"] = buddy["challenges"][0] if buddy.get("challenges") else None +json.dump(active, open(f"{BUDDYMON_DIR}/active.json", "w"), indent=2) +``` + +Greet them and explain the encounter system. + +--- + +## `assign ` β€” Assign Buddy + +Fuzzy-match `` against owned Buddymon (case-insensitive, partial). +If ambiguous, list matches and ask which. +If no name given, list roster and ask. + +On match, update `active.json` (buddy_id, reset session_xp, set challenge). +Show challenge proposal with Accept / Decline / Reroll. + +--- + +## `fight` β€” Fight Encounter + +Read `encounters.json` β†’ `active_encounter`. If none: "No active encounter." + +Show encounter state. Confirm the user has actually fixed the bug. + +On confirm: +```python +import json, os +from datetime import datetime, timezone + +BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon") + +enc_file = f"{BUDDYMON_DIR}/encounters.json" +active_file = f"{BUDDYMON_DIR}/active.json" +roster_file = f"{BUDDYMON_DIR}/roster.json" + +encounters = json.load(open(enc_file)) +active = json.load(open(active_file)) +roster = json.load(open(roster_file)) + +enc = encounters.get("active_encounter") +if enc and enc.get("defeatable", True): + xp = enc.get("xp_reward", 50) + buddy_id = active.get("buddymon_id") + active["session_xp"] = active.get("session_xp", 0) + xp + json.dump(active, open(active_file, "w"), indent=2) + if buddy_id and buddy_id in roster.get("owned", {}): + roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp + json.dump(roster, open(roster_file, "w"), indent=2) + enc["outcome"] = "defeated" + enc["timestamp"] = datetime.now(timezone.utc).isoformat() + encounters.setdefault("history", []).append(enc) + encounters["active_encounter"] = None + json.dump(encounters, open(enc_file, "w"), indent=2) + print(f"+{xp} XP") +``` + +ShadowBit (πŸ”’) cannot be defeated β€” redirect to catch. + +--- + +## `catch` β€” Catch Encounter + +Read active encounter. If none: "No active encounter." + +Show strength and weakening status. Explain weaken actions: +- Write a failing test β†’ -20% strength +- Isolate reproduction case β†’ -20% strength +- Add documenting comment β†’ -10% strength + +Ask which weakening actions have been done. Apply reductions to `current_strength`. + +Catch roll: +```python +import json, os, random +from datetime import datetime, timezone + +BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon") +PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "") +catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json")) + +enc_file = f"{BUDDYMON_DIR}/encounters.json" +active_file = f"{BUDDYMON_DIR}/active.json" +roster_file = f"{BUDDYMON_DIR}/roster.json" + +encounters = json.load(open(enc_file)) +active = json.load(open(active_file)) +roster = json.load(open(roster_file)) + +enc = encounters.get("active_encounter") +buddy_id = active.get("buddymon_id") + +buddy_data = (catalog.get("buddymon", {}).get(buddy_id) + or catalog.get("evolutions", {}).get(buddy_id) or {}) +buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1) +base_catch = buddy_data.get("base_stats", {}).get("catch_rate", 0.4) + +current_strength = enc.get("current_strength", 100) +weakness_bonus = (100 - current_strength) / 100 * 0.4 +catch_rate = min(0.95, base_catch + weakness_bonus + buddy_level * 0.02) + +success = random.random() < catch_rate + +if success: + xp = int(enc.get("xp_reward", 50) * 1.5) + caught_entry = { + "id": enc["id"], "display": enc["display"], + "type": "caught_bug_monster", "level": 1, "xp": 0, + "caught_at": datetime.now(timezone.utc).isoformat(), + } + roster.setdefault("owned", {})[enc["id"]] = caught_entry + active["session_xp"] = active.get("session_xp", 0) + xp + json.dump(active, open(active_file, "w"), indent=2) + if buddy_id and buddy_id in roster.get("owned", {}): + roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp + json.dump(roster, open(roster_file, "w"), indent=2) + enc["outcome"] = "caught" + enc["timestamp"] = datetime.now(timezone.utc).isoformat() + encounters.setdefault("history", []).append(enc) + encounters["active_encounter"] = None + json.dump(encounters, open(enc_file, "w"), indent=2) + print(f"caught:{xp}") +else: + print(f"failed:{int(catch_rate * 100)}") +``` + +On success: "πŸŽ‰ Caught [display]! +[XP] XP (1.5Γ— catch bonus)" +On failure: "πŸ’¨ Broke free! Weaken it further and try again." + +--- + +## `roster` β€” Full Roster + +Read roster and display: + +``` +🐾 Your Buddymon +────────────────────────────────────────── + πŸ”₯ Pyrobyte Lv.3 Speedrunner + XP: [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 450/300 + + πŸ” Debuglin Lv.1 Tester + XP: [β–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 80/100 + +πŸ† Caught Bug Monsters +────────────────────────────────────────── + πŸ‘» NullWraith β€” caught 2026-04-01 + 🌐 CORSCurse β€” caught 2026-03-28 + +❓ ??? β€” [n] more creatures to discover... +``` + +--- + +## `help` + +``` +/buddymon β€” status panel +/buddymon start β€” choose starter (first run) +/buddymon assign β€” assign buddy to session +/buddymon fight β€” fight active encounter +/buddymon catch β€” catch active encounter +/buddymon roster β€” view full roster +```