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)
This commit is contained in:
pyr0ball 2026-04-01 15:11:46 -07:00
commit f3d1f45253
11 changed files with 1635 additions and 0 deletions

View file

@ -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"
}
}

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
# Python
__pycache__/
*.pyc
*.pyo
# OS
.DS_Store
Thumbs.db
# Local state (lives in ~/.claude/buddymon/, not the repo)
*.local.json

21
LICENSE Normal file
View file

@ -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.

109
README.md Normal file
View file

@ -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 <name>` | 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.*

309
hooks-handlers/post-tool-use.py Executable file
View file

@ -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()

124
hooks-handlers/session-start.sh Executable file
View file

@ -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 <name>\` 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<xp_filled; i++)); do xp_bar+="█"; done
for ((i=xp_filled; i<20; i++)); do xp_bar+="░"; done
ctx="## 🐾 Buddymon Active: ${buddy_display}\n"
ctx+="**Lv.${buddy_level}** XP: [${xp_bar}] ${buddy_xp}/${xp_needed}\n\n"
# Active challenge
local challenge
challenge=$(python3 -c "
import json
f = '${ACTIVE_FILE}'
d = json.load(open(f))
ch = d.get('challenge')
if ch:
print(ch.get('name','?') + ' — ' + ch.get('description',''))
" 2>/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

118
hooks-handlers/session-stop.sh Executable file
View file

@ -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

39
hooks/hooks.json Normal file
View file

@ -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
}
]
}
]
}
}

417
lib/catalog.json Normal file
View file

@ -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}
}
}
}

199
lib/state.sh Normal file
View file

@ -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
}

272
skills/buddymon/SKILL.md Normal file
View file

@ -0,0 +1,272 @@
---
name: buddymon
description: Buddymon companion game — status, roster, encounters, and session management
argument-hint: [start|assign <name>|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 <name>` | 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 <name>` — Assign Buddy
Fuzzy-match `<name>` 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 <n> — assign buddy to session
/buddymon fight — fight active encounter
/buddymon catch — catch active encounter
/buddymon roster — view full roster
```