Compare commits

..

3 commits

Author SHA1 Message Date
pyr0ball
caa655ab9a feat: session handoff — persist last-session summary across CC restarts
session-stop.sh writes ~/.claude/buddymon/handoff.json with: buddy id,
XP earned, commit count, languages touched, caught monsters, challenge
state, any active encounter, and manual notes (for future /buddymon note).

session-start.sh reads handoff.json on next session start, injects a
'📬 From your last session' block into additionalContext, then removes
the file so it only fires once.

Closes #1 on Circuit-Forge/buddymon.
2026-04-02 23:15:01 -07:00
pyr0ball
c85bade62f feat: per-session buddy isolation via PGRP-keyed state files
Each Claude Code session now gets its own state file at:
  ~/.claude/buddymon/sessions/<pgrp>.json

Contains: buddymon_id, session_xp, challenge — all session-local.
Global active.json keeps the default buddymon_id for new sessions.

/buddymon assign writes to the session file only, so assigning in one
terminal window doesn't affect other open sessions. Each window can
have its own buddy assigned independently.

SessionStart creates the session file (inheriting global default).
SessionStop reads XP from it, writes to roster, then removes it.
2026-04-02 23:11:19 -07:00
pyr0ball
8e0a5f82cb feat: evolution system — prestige to evolved form at Lv.100
Evolution triggers at Lv.100 for all three starters:
  Pyrobyte → 🌋 Infernus  (power 40→70, catch_rate 0.45→0.55)
  Debuglin → 🔬 Verifex   (power 35→60, catch_rate 0.60→0.75)
  Minimox  → 🌑 Nullex    (power 35→55, catch_rate 0.50→0.65)

/buddymon evolve: checks eligibility, shows stat preview, resets buddy
to Lv.1 in evolved form, archives old form with evolved_into marker,
carries challenges forward.

session-stop.sh now prints EVOLUTION READY banner when level hits 100
or when already eligible at session end.
2026-04-02 23:08:32 -07:00
6 changed files with 850 additions and 172 deletions

View file

@ -24,6 +24,30 @@ PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.par
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
# Each CC session gets its own state file keyed by process group ID.
# All hooks within one session share the same PGRP, giving stable per-session state.
SESSION_KEY = str(os.getpgrp())
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
def get_session_state() -> dict:
"""Read the current session's state file, falling back to global active.json."""
session = load_json(SESSION_FILE)
if not session:
# No session file yet — inherit from global default
global_active = load_json(BUDDYMON_DIR / "active.json")
session = {
"buddymon_id": global_active.get("buddymon_id"),
"challenge": global_active.get("challenge"),
"session_xp": 0,
}
return session
def save_session_state(state: dict) -> None:
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
save_json(SESSION_FILE, state)
KNOWN_EXTENSIONS = {
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
".jsx": "JavaScript/React", ".tsx": "TypeScript/React",
@ -62,15 +86,13 @@ def get_state():
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)
session = get_session_state()
session["session_xp"] = session.get("session_xp", 0) + amount
buddy_id = session.get("buddymon_id")
save_session_state(session)
if buddy_id:
roster_file = BUDDYMON_DIR / "roster.json"
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
@ -153,8 +175,7 @@ def is_starter_chosen():
def get_active_buddy_id():
active = load_json(BUDDYMON_DIR / "active.json")
return active.get("buddymon_id")
return get_session_state().get("buddymon_id")
def get_active_encounter():

View file

@ -7,8 +7,31 @@ source "${PLUGIN_ROOT}/lib/state.sh"
buddymon_init
ACTIVE_ID=$(buddymon_get_active)
SESSION_XP=$(buddymon_get_session_xp)
# Per-session state — keyed by process group ID so parallel sessions are isolated.
SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())")
SESSION_FILE="${BUDDYMON_DIR}/sessions/${SESSION_KEY}.json"
mkdir -p "${BUDDYMON_DIR}/sessions"
# Create session file if missing, inheriting buddy from global active.json
if [[ ! -f "${SESSION_FILE}" ]]; then
python3 << PYEOF
import json, os
active = {}
try:
active = json.load(open('${BUDDYMON_DIR}/active.json'))
except Exception:
pass
session_state = {
"buddymon_id": active.get("buddymon_id"),
"challenge": active.get("challenge"),
"session_xp": 0,
}
json.dump(session_state, open('${SESSION_FILE}', 'w'), indent=2)
PYEOF
fi
ACTIVE_ID=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('buddymon_id',''))" 2>/dev/null)
SESSION_XP=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('session_xp',0))" 2>/dev/null)
# Load catalog for buddy display info
CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
@ -16,6 +39,77 @@ CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
build_context() {
local ctx=""
# ── Session handoff from previous session ──────────────────────────────
local handoff_file="${BUDDYMON_DIR}/handoff.json"
if [[ -f "${handoff_file}" ]]; then
local handoff_block
handoff_block=$(python3 << PYEOF
import json, os
from datetime import datetime, timezone
try:
h = json.load(open('${handoff_file}'))
except Exception:
print('')
exit()
buddy_id = h.get('buddy_id')
if not buddy_id:
print('')
exit()
import json as _json
catalog_file = '${CATALOG}'
try:
catalog = _json.load(open(catalog_file))
b = (catalog.get('buddymon', {}).get(buddy_id)
or catalog.get('evolutions', {}).get(buddy_id) or {})
display = b.get('display', buddy_id)
except Exception:
display = buddy_id
lines = [f"### 📬 From your last session ({h.get('date', '?')}) — {display}"]
xp = h.get('xp_earned', 0)
commits = h.get('commits', 0)
langs = h.get('languages', [])
caught = h.get('caught', [])
if xp:
lines.append(f"- Earned **{xp} XP**")
if commits:
lines.append(f"- Made **{commits} commit{'s' if commits != 1 else ''}**")
if langs:
lines.append(f"- Languages touched: {', '.join(langs)}")
if caught:
lines.append(f"- Caught: {', '.join(caught)}")
challenge = h.get('challenge')
challenge_completed = h.get('challenge_completed', False)
if challenge:
status = "✅ completed" if challenge_completed else "⏳ still in progress"
lines.append(f"- Challenge **{challenge.get('name','?')}** — {status}")
enc = h.get('active_encounter')
if enc:
lines.append(f"- ⚠️ **Unresolved encounter carried over:** {enc.get('display', '?')} (strength: {enc.get('current_strength', 100)}%)")
notes = h.get('notes', [])
if notes:
lines.append("**Notes:**")
for n in notes:
lines.append(f" · {n}")
print('\n'.join(lines))
PYEOF
)
if [[ -n "${handoff_block}" ]]; then
ctx+="${handoff_block}\n\n"
fi
# Archive handoff — consumed for this session
rm -f "${handoff_file}"
fi
# ── No starter chosen yet ─────────────────────────────────────────────
if [[ "$(buddymon_starter_chosen)" == "false" ]]; then
ctx="## 🐾 Buddymon — First Encounter!\n\n"

View file

@ -6,10 +6,29 @@ source "${PLUGIN_ROOT}/lib/state.sh"
buddymon_init
ACTIVE_ID=$(buddymon_get_active)
SESSION_XP=$(buddymon_get_session_xp)
SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())")
SESSION_FILE="${BUDDYMON_DIR}/sessions/${SESSION_KEY}.json"
ACTIVE_ID=$(python3 -c "
import json, sys
try:
d = json.load(open('${SESSION_FILE}'))
print(d.get('buddymon_id', ''))
except Exception:
print('')
" 2>/dev/null)
SESSION_XP=$(python3 -c "
import json, sys
try:
d = json.load(open('${SESSION_FILE}'))
print(d.get('session_xp', 0))
except Exception:
print(0)
" 2>/dev/null)
if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then
[[ -f "${SESSION_FILE}" ]] && rm -f "${SESSION_FILE}"
exit 0
fi
@ -20,17 +39,20 @@ SUMMARY=$(python3 << PYEOF
import json, os
catalog_file = '${CATALOG}'
active_file = '${BUDDYMON_DIR}/active.json'
session_state_file = '${SESSION_FILE}'
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))
session_state = json.load(open(session_state_file))
roster = json.load(open(roster_file))
session = json.load(open(session_file))
session = {}
try:
session = json.load(open(session_file))
except Exception:
pass
buddy_id = active.get('buddymon_id')
buddy_id = session_state.get('buddymon_id')
if not buddy_id:
print('')
exit()
@ -39,7 +61,7 @@ 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)
xp_earned = session_state.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
@ -61,7 +83,7 @@ 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')
challenge = session_state.get('challenge')
lines = [f"\n## 🐾 Session complete — {display}"]
lines.append(f"**+{xp_earned} XP earned** this session")
@ -72,10 +94,27 @@ if langs:
if leveled_up:
lines.append(f"\n✨ **LEVEL UP!** {display} is now Lv.{new_level}!")
# Check if evolution is now available
catalog_entry = (catalog.get('buddymon', {}).get(buddy_id)
or catalog.get('evolutions', {}).get(buddy_id) or {})
evolutions = catalog_entry.get('evolutions', [])
evo = next((e for e in evolutions if new_level >= e.get('level', 999)), None)
if evo:
into = catalog.get('evolutions', {}).get(evo['into'], {})
lines.append(f"\n⭐ **EVOLUTION READY!** {display} can evolve into {into.get('display', evo['into'])}!")
lines.append(f" Run `/buddymon evolve` to prestige — resets to Lv.1 with upgraded stats.")
else:
filled = min(20, total_xp * 20 // xp_needed)
bar = '█' * filled + '░' * (20 - filled)
lines.append(f"XP: [{bar}] {total_xp}/{xp_needed}")
# Remind if already at evolution threshold but hasn't evolved yet
catalog_entry = (catalog.get('buddymon', {}).get(buddy_id)
or catalog.get('evolutions', {}).get(buddy_id) or {})
evolutions = catalog_entry.get('evolutions', [])
evo = next((e for e in evolutions if level >= e.get('level', 999)), None)
if evo:
into = catalog.get('evolutions', {}).get(evo['into'], {})
lines.append(f"\n⭐ **Evolution available:** `/buddymon evolve` → {into.get('display', evo['into'])}")
if challenge:
if challenge_completed:
@ -87,17 +126,67 @@ print('\n'.join(lines))
PYEOF
)
# Reset session XP + clear challenge so next session assigns a fresh one
# Write handoff.json for next session to pick up
python3 << PYEOF
import json
active_file = '${BUDDYMON_DIR}/active.json'
active = json.load(open(active_file))
active['session_xp'] = 0
active['challenge'] = None
json.dump(active, open(active_file, 'w'), indent=2)
import json, os
from datetime import datetime, timezone
session_state_file = '${SESSION_FILE}'
session_file = '${BUDDYMON_DIR}/session.json'
encounters_file = '${BUDDYMON_DIR}/encounters.json'
handoff_file = '${BUDDYMON_DIR}/handoff.json'
session_state = {}
try:
session_state = json.load(open(session_state_file))
except Exception:
pass
session_data = {}
try:
session_data = json.load(open(session_file))
except Exception:
pass
encounters = {}
try:
encounters = json.load(open(encounters_file))
except Exception:
pass
# Collect caught monsters from this session
caught_this_session = [
e.get('display', e.get('id', '?'))
for e in encounters.get('history', [])
if e.get('outcome') == 'caught'
and e.get('timestamp', '') >= datetime.now(timezone.utc).strftime('%Y-%m-%d')
]
# Carry over any existing handoff notes (user-added via /buddymon note)
existing = {}
try:
existing = json.load(open(handoff_file))
except Exception:
pass
handoff = {
"date": datetime.now(timezone.utc).strftime('%Y-%m-%d'),
"buddy_id": session_state.get('buddymon_id'),
"xp_earned": session_state.get('session_xp', 0),
"commits": session_data.get('commits_this_session', 0),
"languages": session_data.get('languages_seen', []),
"caught": caught_this_session,
"challenge": session_state.get('challenge'),
"challenge_completed": session_data.get('challenge_completed', False),
"active_encounter": encounters.get('active_encounter'),
"notes": existing.get('notes', []), # preserve any manual notes
}
json.dump(handoff, open(handoff_file, 'w'), indent=2)
PYEOF
# Reset session file
# Clean up this session's state file — each session is ephemeral
rm -f "${SESSION_FILE}"
# Reset shared session.json for legacy compatibility
buddymon_session_reset
SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null)

View file

@ -16,6 +16,27 @@ PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.par
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
SESSION_KEY = str(os.getpgrp())
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
def get_session_state() -> dict:
try:
with open(SESSION_FILE) as f:
return json.load(f)
except Exception:
global_active = {}
try:
with open(BUDDYMON_DIR / "active.json") as f:
global_active = json.load(f)
except Exception:
pass
return {
"buddymon_id": global_active.get("buddymon_id"),
"challenge": global_active.get("challenge"),
"session_xp": 0,
}
def load_json(path):
try:
@ -55,9 +76,8 @@ def main():
enc_data["active_encounter"] = enc
save_json(enc_file, enc_data)
# Resolve buddy display name
active = load_json(BUDDYMON_DIR / "active.json")
buddy_id = active.get("buddymon_id")
# Resolve buddy display name from session-specific state
buddy_id = get_session_state().get("buddymon_id")
buddy_display = "your buddy"
if buddy_id:
catalog = load_json(CATALOG_FILE)

View file

@ -1,11 +1,10 @@
{
"_version": 1,
"_note": "Master species catalog. discovered=false entries are hidden until triggered.",
"bug_monsters": {
"NullWraith": {
"id": "NullWraith",
"display": "👻 NullWraith",
"display": "\ud83d\udc7b NullWraith",
"type": "bug_monster",
"rarity": "common",
"base_strength": 20,
@ -22,15 +21,24 @@
"null reference"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20},
{"action": "isolate_reproduction", "strength_reduction": 20},
{"action": "add_documenting_comment", "strength_reduction": 10}
{
"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",
"display": "\ud83d\ude08 FencepostDemon",
"type": "bug_monster",
"rarity": "common",
"base_strength": 25,
@ -46,15 +54,24 @@
"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}
{
"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",
"display": "\ud83d\udd27 TypeGreml",
"type": "bug_monster",
"rarity": "common",
"base_strength": 25,
@ -70,15 +87,24 @@
"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}
{
"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",
"display": "\ud83d\udc0d SyntaxSerpent",
"type": "bug_monster",
"rarity": "very_common",
"base_strength": 10,
@ -95,14 +121,20 @@
"ParseError"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 30},
{"action": "add_documenting_comment", "strength_reduction": 20}
{
"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",
"display": "\ud83c\udf10 CORSCurse",
"type": "bug_monster",
"rarity": "common",
"base_strength": 40,
@ -118,15 +150,24 @@
"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}
{
"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",
"display": "\u267e\ufe0f LoopLich",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 60,
@ -146,15 +187,24 @@
"StackOverflow"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20},
{"action": "isolate_reproduction", "strength_reduction": 25},
{"action": "add_documenting_comment", "strength_reduction": 15}
{
"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",
"display": "\ud83d\udc41\ufe0f RacePhantom",
"type": "bug_monster",
"rarity": "rare",
"base_strength": 80,
@ -172,15 +222,24 @@
"async.*conflict"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 15},
{"action": "isolate_reproduction", "strength_reduction": 30},
{"action": "add_documenting_comment", "strength_reduction": 15}
{
"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",
"display": "\ud83d\uddff FossilGolem",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 35,
@ -197,23 +256,36 @@
"legacy"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20},
{"action": "isolate_reproduction", "strength_reduction": 20},
{"action": "add_documenting_comment", "strength_reduction": 20}
{
"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",
"display": "\ud83d\udd12 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.",
"catch_requires": [
"write_failing_test",
"isolate_reproduction",
"add_documenting_comment"
],
"description": "Cannot be defeated \u2014 only properly contained. Requires full documentation + patching.",
"error_patterns": [
"vulnerability",
"CVE-",
@ -229,15 +301,24 @@
"hardcoded.*token"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25},
{"action": "isolate_reproduction", "strength_reduction": 35},
{"action": "add_documenting_comment", "strength_reduction": 20}
{
"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",
"display": "\ud83c\udf2b\ufe0f VoidSpecter",
"type": "bug_monster",
"rarity": "common",
"base_strength": 20,
@ -254,15 +335,24 @@
"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}
{
"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",
"display": "\ud83e\ude78 MemoryLeech",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 55,
@ -287,15 +377,24 @@
"malloc: can't allocate region"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20},
{"action": "isolate_reproduction", "strength_reduction": 30},
{"action": "add_documenting_comment", "strength_reduction": 10}
{
"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."
},
"CudaCrash": {
"id": "CudaCrash",
"display": "⚡ CudaCrash",
"display": "\u26a1 CudaCrash",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 65,
@ -315,15 +414,24 @@
"device-side assert triggered"
],
"weaken_actions": [
{"action": "isolate_reproduction", "strength_reduction": 30},
{"action": "write_failing_test", "strength_reduction": 20},
{"action": "add_documenting_comment", "strength_reduction": 10}
{
"action": "isolate_reproduction",
"strength_reduction": 30
},
{
"action": "write_failing_test",
"strength_reduction": 20
},
{
"action": "add_documenting_comment",
"strength_reduction": 10
}
],
"flavor": "Your model fit in VRAM yesterday. You added one layer."
},
"InfiniteWisp": {
"id": "InfiniteWisp",
"display": "🌀 InfiniteWisp",
"display": "\ud83c\udf00 InfiniteWisp",
"type": "bug_monster",
"rarity": "common",
"base_strength": 30,
@ -341,15 +449,24 @@
"timed out after"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20},
{"action": "isolate_reproduction", "strength_reduction": 30},
{"action": "add_documenting_comment", "strength_reduction": 10}
{
"action": "write_failing_test",
"strength_reduction": 20
},
{
"action": "isolate_reproduction",
"strength_reduction": 30
},
{
"action": "add_documenting_comment",
"strength_reduction": 10
}
],
"flavor": "Your fan was always loud. You just never checked why."
},
"BoundsHound": {
"id": "BoundsHound",
"display": "🐕 BoundsHound",
"display": "\ud83d\udc15 BoundsHound",
"type": "bug_monster",
"rarity": "common",
"base_strength": 25,
@ -369,15 +486,24 @@
"RangeError.*index"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25},
{"action": "isolate_reproduction", "strength_reduction": 25},
{"action": "add_documenting_comment", "strength_reduction": 10}
{
"action": "write_failing_test",
"strength_reduction": 25
},
{
"action": "isolate_reproduction",
"strength_reduction": 25
},
{
"action": "add_documenting_comment",
"strength_reduction": 10
}
],
"flavor": "It was always length minus one. You just forgot."
},
"BranchGhost": {
"id": "BranchGhost",
"display": "🔀 BranchGhost",
"display": "\ud83d\udd00 BranchGhost",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 40,
@ -396,15 +522,24 @@
"fallthrough"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 30},
{"action": "isolate_reproduction", "strength_reduction": 20},
{"action": "add_documenting_comment", "strength_reduction": 10}
{
"action": "write_failing_test",
"strength_reduction": 30
},
{
"action": "isolate_reproduction",
"strength_reduction": 20
},
{
"action": "add_documenting_comment",
"strength_reduction": 10
}
],
"flavor": "You were so sure that case was impossible."
},
"SwitchTrap": {
"id": "SwitchTrap",
"display": "🪤 SwitchTrap",
"display": "\ud83e\udea4 SwitchTrap",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 35,
@ -425,15 +560,24 @@
"Unhandled variant"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25},
{"action": "isolate_reproduction", "strength_reduction": 25},
{"action": "add_documenting_comment", "strength_reduction": 10}
{
"action": "write_failing_test",
"strength_reduction": 25
},
{
"action": "isolate_reproduction",
"strength_reduction": 25
},
{
"action": "add_documenting_comment",
"strength_reduction": 10
}
],
"flavor": "You added that new enum value last week. The switch didn't notice."
},
"RecurseWraith": {
"id": "RecurseWraith",
"display": "🌪️ RecurseWraith",
"display": "\ud83c\udf2a\ufe0f RecurseWraith",
"type": "bug_monster",
"rarity": "uncommon",
"base_strength": 45,
@ -452,22 +596,31 @@
"stack.*exhausted"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25},
{"action": "isolate_reproduction", "strength_reduction": 25},
{"action": "add_documenting_comment", "strength_reduction": 10}
{
"action": "write_failing_test",
"strength_reduction": 25
},
{
"action": "isolate_reproduction",
"strength_reduction": 25
},
{
"action": "add_documenting_comment",
"strength_reduction": 10
}
],
"flavor": "The base case was there. It just couldn't be reached."
},
"CatchAll": {
"id": "CatchAll",
"display": "🕳️ CatchAll",
"display": "\ud83d\udd73\ufe0f CatchAll",
"type": "bug_monster",
"rarity": "rare",
"base_strength": 60,
"xp_reward": 120,
"catchable": true,
"defeatable": false,
"description": "Born from broad exception handlers. Swallows errors whole. Cannot be defeated only caught, by narrowing the catch.",
"description": "Born from broad exception handlers. Swallows errors whole. Cannot be defeated \u2014 only caught, by narrowing the catch.",
"error_patterns": [
"except Exception",
"except:$",
@ -479,18 +632,26 @@
"catch-all.*handler"
],
"weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 30},
{"action": "isolate_reproduction", "strength_reduction": 30},
{"action": "add_documenting_comment", "strength_reduction": 15}
{
"action": "write_failing_test",
"strength_reduction": 30
},
{
"action": "isolate_reproduction",
"strength_reduction": 30
},
{
"action": "add_documenting_comment",
"strength_reduction": 15
}
],
"flavor": "If you catch everything, you learn nothing."
}
},
"event_encounters": {
"MergeMaw": {
"id": "MergeMaw",
"display": "🔀 MergeMaw",
"display": "\ud83d\udd00 MergeMaw",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 45,
@ -498,13 +659,16 @@
"catchable": true,
"defeatable": true,
"trigger_type": "command",
"command_patterns": ["git merge", "git rebase"],
"command_patterns": [
"git merge",
"git rebase"
],
"description": "Emerges from the diff between two timelines. Loves conflicts.",
"flavor": "It has opinions about your whitespace."
},
"BranchSprite": {
"id": "BranchSprite",
"display": "🌿 BranchSprite",
"display": "\ud83c\udf3f BranchSprite",
"type": "event_encounter",
"rarity": "common",
"base_strength": 15,
@ -512,13 +676,17 @@
"catchable": true,
"defeatable": false,
"trigger_type": "command",
"command_patterns": ["git checkout -b", "git switch -c", "git branch "],
"command_patterns": [
"git checkout -b",
"git switch -c",
"git branch "
],
"description": "Appears when a new branch is born. Harmless. Almost cheerful.",
"flavor": "It wanted to come along for the feature."
},
"DepGolem": {
"id": "DepGolem",
"display": "📦 DepGolem",
"display": "\ud83d\udce6 DepGolem",
"type": "event_encounter",
"rarity": "common",
"base_strength": 30,
@ -526,13 +694,25 @@
"catchable": true,
"defeatable": true,
"trigger_type": "command",
"command_patterns": ["pip install", "pip3 install", "npm install", "npm i ", "cargo add", "yarn add", "apt install", "brew install", "poetry add", "uv add", "uv pip install"],
"command_patterns": [
"pip install",
"pip3 install",
"npm install",
"npm i ",
"cargo add",
"yarn add",
"apt install",
"brew install",
"poetry add",
"uv add",
"uv pip install"
],
"description": "Conjured from the package registry. Brings transitive dependencies.",
"flavor": "It brought 847 friends."
},
"FlakeDemon": {
"id": "FlakeDemon",
"display": "🎲 FlakeDemon",
"display": "\ud83c\udfb2 FlakeDemon",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 55,
@ -555,7 +735,7 @@
},
"PhantomPass": {
"id": "PhantomPass",
"display": " PhantomPass",
"display": "\u2705 PhantomPass",
"type": "event_encounter",
"rarity": "rare",
"base_strength": 10,
@ -568,17 +748,17 @@
"PASSED",
"All tests passed",
"tests passed",
"",
"\u2713",
"\\d+ passed",
"OK$",
"SUCCESS"
],
"description": "Appears only when tests go green after going red. Rare. Fleeting. Cannot be fought only caught.",
"description": "Appears only when tests go green after going red. Rare. Fleeting. Cannot be fought \u2014 only caught.",
"flavor": "It was hiding in the red all along."
},
"TestSpecter": {
"id": "TestSpecter",
"display": "🧪 TestSpecter",
"display": "\ud83e\uddea TestSpecter",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 25,
@ -586,13 +766,19 @@
"catchable": true,
"defeatable": true,
"trigger_type": "test_file",
"test_file_patterns": ["\\.test\\.", "_test\\.", "test_", "_spec\\.", "\\.spec\\."],
"test_file_patterns": [
"\\.test\\.",
"_test\\.",
"test_",
"_spec\\.",
"\\.spec\\."
],
"description": "Haunts test suites. Drawn to assertions. Debuglin gets excited.",
"flavor": "It wanted to make sure the test was named correctly."
},
"ReviewHawk": {
"id": "ReviewHawk",
"display": "🦅 ReviewHawk",
"display": "\ud83e\udd85 ReviewHawk",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 40,
@ -613,7 +799,7 @@
},
"TicketGremlin": {
"id": "TicketGremlin",
"display": "🎫 TicketGremlin",
"display": "\ud83c\udfab TicketGremlin",
"type": "event_encounter",
"rarity": "common",
"base_strength": 30,
@ -636,7 +822,7 @@
},
"PermWraith": {
"id": "PermWraith",
"display": "🚫 PermWraith",
"display": "\ud83d\udeab PermWraith",
"type": "event_encounter",
"rarity": "common",
"base_strength": 35,
@ -660,7 +846,7 @@
},
"SudoSprite": {
"id": "SudoSprite",
"display": "🔑 SudoSprite",
"display": "\ud83d\udd11 SudoSprite",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 20,
@ -677,12 +863,12 @@
"sudo chgrp",
"setfacl "
],
"description": "Emerges when permissions are corrected. Doesn't fight it just watches to make sure you chose the right octal.",
"description": "Emerges when permissions are corrected. Doesn't fight \u2014 it just watches to make sure you chose the right octal.",
"flavor": "777 was always the answer. Never the right one."
},
"LayerLurker": {
"id": "LayerLurker",
"display": "🐋 LayerLurker",
"display": "\ud83d\udc0b LayerLurker",
"type": "event_encounter",
"rarity": "common",
"base_strength": 35,
@ -704,7 +890,7 @@
},
"DiskDemon": {
"id": "DiskDemon",
"display": "💾 DiskDemon",
"display": "\ud83d\udcbe DiskDemon",
"type": "event_encounter",
"rarity": "uncommon",
"base_strength": 50,
@ -726,132 +912,260 @@
"flavor": "It's been there since 2019. It's just a log file, you said."
}
},
"buddymon": {
"Pyrobyte": {
"id": "Pyrobyte",
"display": "🔥 Pyrobyte",
"display": "\ud83d\udd25 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"],
"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}
{
"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"}
{
"level": 100,
"into": "Infernus"
}
],
"flavor": "It already committed before you finished reading the issue."
},
"Debuglin": {
"id": "Debuglin",
"display": "🔍 Debuglin",
"display": "\ud83d\udd0d 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"],
"discover_trigger": {
"type": "starter",
"index": 1
},
"base_stats": {
"power": 35,
"catch_rate": 0.6,
"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}
{
"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"}
{
"level": 100,
"into": "Verifex"
}
],
"flavor": "The bug isn't found until the test is written."
},
"Minimox": {
"id": "Minimox",
"display": "✂️ Minimox",
"display": "\u2702\ufe0f 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"],
"discover_trigger": {
"type": "starter",
"index": 2
},
"base_stats": {
"power": 30,
"catch_rate": 0.5,
"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}
{
"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"}
{
"level": 100,
"into": "Nullex"
}
],
"flavor": "It deleted your comment. It was redundant."
},
"Noctara": {
"id": "Noctara",
"display": "🌙 Noctara",
"display": "\ud83c\udf19 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"],
"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}
{
"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"}
{
"level": 15,
"into": "Umbravex",
"requires": "nocturnal_sessions_x5"
}
],
"flavor": "It remembers everything you wrote at 2am. Everything."
},
"Explorah": {
"id": "Explorah",
"display": "🗺️ Explorah",
"display": "\ud83d\uddfa\ufe0f 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"],
"discover_trigger": {
"type": "new_language"
},
"base_stats": {
"power": 45,
"catch_rate": 0.5,
"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}
{
"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"}
{
"level": 12,
"into": "Wandervex",
"requires": "new_languages_x5"
}
],
"flavor": "It's already halfway through the new framework docs."
}
},
"evolutions": {
"Infernus": {
"id": "Infernus",
"display": "🌋 Infernus",
"display": "\ud83c\udf0b 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}
"base_stats": {
"power": 70,
"catch_rate": 0.55,
"xp_multiplier": 1.5
}
},
"Verifex": {
"id": "Verifex",
"display": "🔬 Verifex",
"display": "\ud83d\udd2c 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}
"base_stats": {
"power": 60,
"catch_rate": 0.75,
"xp_multiplier": 1.3
}
},
"Nullex": {
"id": "Nullex",
"display": "🕳️ Nullex",
"display": "\ud83d\udd73\ufe0f 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}
"base_stats": {
"power": 55,
"catch_rate": 0.65,
"xp_multiplier": 1.4
}
}
}
}

View file

@ -25,6 +25,7 @@ Parse `$ARGUMENTS` (trim whitespace, lowercase the first word) and dispatch:
| `fight` | Fight active encounter |
| `catch` | Catch active encounter |
| `roster` | Full roster view |
| `evolve` | Evolve active buddy (available at Lv.100) |
| `statusline` | Install Buddymon statusline into settings.json |
| `help` | Show command list |
@ -117,12 +118,57 @@ Greet them and explain the encounter system.
## `assign <name>` — Assign Buddy
Assignment is **per-session** — each Claude Code window can have its own buddy.
It writes to the session state file only, not the global default.
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.
On match, show challenge proposal with Accept / Decline / Reroll, then write:
```python
import json, os, random
from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json"))
SESSION_KEY = str(os.getpgrp())
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
roster = json.load(open(BUDDYMON_DIR / "roster.json"))
buddy_id = "Debuglin" # replace with matched buddy id
buddy_catalog = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id) or {})
challenges = buddy_catalog.get("challenges", [])
# Load or init session state
try:
session_state = json.load(open(SESSION_FILE))
except Exception:
session_state = {}
session_state["buddymon_id"] = buddy_id
session_state["session_xp"] = 0
session_state["challenge"] = random.choice(challenges) if challenges else None
json.dump(session_state, open(SESSION_FILE, "w"), indent=2)
# Also update global default so new sessions inherit this assignment
active = {}
try:
active = json.load(open(BUDDYMON_DIR / "active.json"))
except Exception:
pass
active["buddymon_id"] = buddy_id
json.dump(active, open(BUDDYMON_DIR / "active.json", "w"), indent=2)
```
Show challenge proposal with Accept / Decline / Reroll (updating `session_state["challenge"]` accordingly).
---
@ -303,6 +349,100 @@ Read `roster.json` → `language_affinities`. Skip this section if empty.
---
## `evolve` — Evolve Buddy (Prestige)
Evolution is available when the active buddy is **Lv.100** (total XP ≥ 9,900).
Evolving resets the buddy to Lv.1 in their new form — but the evolved form has
higher base stats and a better XP multiplier, so the second climb is faster.
Read state and check eligibility:
```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"))
active = json.load(open(f"{BUDDYMON_DIR}/active.json"))
roster = json.load(open(f"{BUDDYMON_DIR}/roster.json"))
buddy_id = active.get("buddymon_id")
owned = roster.get("owned", {})
buddy_data = owned.get(buddy_id, {})
level = buddy_data.get("level", 1)
total_xp = buddy_data.get("xp", 0)
# Check evolution entry in catalog
catalog_entry = catalog.get("buddymon", {}).get(buddy_id) or catalog.get("evolutions", {}).get(buddy_id)
evolutions = catalog_entry.get("evolutions", []) if catalog_entry else []
evolution = next((e for e in evolutions if level >= e.get("level", 999)), None)
```
If `evolution` is None or level < 100: show current level and XP toward 100, no evolution available yet.
If eligible, show evolution preview:
```
╔══════════════════════════════════════════════════════════╗
║ ✨ Evolution Ready! ║
╠══════════════════════════════════════════════════════════╣
║ ║
║ 🔍 Debuglin Lv.100 → 🔬 Verifex ║
║ ║
║ Verifex: Sees the bug before the code is even written. ║
║ catch_rate: 0.60 → 0.75 · xp_multiplier: 1.0 → 1.3 ║
║ ║
║ ⚠️ Resets to Lv.1. Your caught monsters stay. ║
║ ║
╚══════════════════════════════════════════════════════════╝
Evolve? (y/n)
```
On confirm, execute the evolution:
```python
from datetime import datetime, timezone
into_id = evolution["into"]
into_data = catalog["evolutions"][into_id]
# Archive old form with evolution marker
owned[buddy_id]["evolved_into"] = into_id
owned[buddy_id]["evolved_at"] = datetime.now(timezone.utc).isoformat()
# Create new form entry at Lv.1
owned[into_id] = {
"id": into_id,
"display": into_data["display"],
"affinity": into_data.get("affinity", catalog_entry.get("affinity", "")),
"level": 1,
"xp": 0,
"evolved_from": buddy_id,
"evolved_at": datetime.now(timezone.utc).isoformat(),
}
# Carry challenges forward from original form
challenges = catalog_entry.get("challenges") or into_data.get("challenges", [])
roster["owned"] = owned
json.dump(roster, open(f"{BUDDYMON_DIR}/roster.json", "w"), indent=2)
# Update active to point to evolved form
active["buddymon_id"] = into_id
active["session_xp"] = 0
active["challenge"] = challenges[0] if challenges else None
json.dump(active, open(f"{BUDDYMON_DIR}/active.json", "w"), indent=2)
```
Show result:
```
✨ Debuglin evolved into 🔬 Verifex!
Starting fresh at Lv.1 — the second climb is faster.
New challenge: IRON TEST
```
---
## `statusline` — Install Buddymon Statusline
Installs the Buddymon statusline into `~/.claude/settings.json`.