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" BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json" 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 = { KNOWN_EXTENSIONS = {
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript", ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
".jsx": "JavaScript/React", ".tsx": "TypeScript/React", ".jsx": "JavaScript/React", ".tsx": "TypeScript/React",
@ -62,15 +86,13 @@ def get_state():
def add_session_xp(amount: int): def add_session_xp(amount: int):
active_file = BUDDYMON_DIR / "active.json" session = get_session_state()
roster_file = BUDDYMON_DIR / "roster.json" session["session_xp"] = session.get("session_xp", 0) + amount
buddy_id = session.get("buddymon_id")
active = load_json(active_file) save_session_state(session)
active["session_xp"] = active.get("session_xp", 0) + amount
buddy_id = active.get("buddymon_id")
save_json(active_file, active)
if buddy_id: if buddy_id:
roster_file = BUDDYMON_DIR / "roster.json"
roster = load_json(roster_file) roster = load_json(roster_file)
if buddy_id in roster.get("owned", {}): if buddy_id in roster.get("owned", {}):
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount 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(): def get_active_buddy_id():
active = load_json(BUDDYMON_DIR / "active.json") return get_session_state().get("buddymon_id")
return active.get("buddymon_id")
def get_active_encounter(): def get_active_encounter():

View file

@ -7,8 +7,31 @@ source "${PLUGIN_ROOT}/lib/state.sh"
buddymon_init buddymon_init
ACTIVE_ID=$(buddymon_get_active) # Per-session state — keyed by process group ID so parallel sessions are isolated.
SESSION_XP=$(buddymon_get_session_xp) 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 # Load catalog for buddy display info
CATALOG="${PLUGIN_ROOT}/lib/catalog.json" CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
@ -16,6 +39,77 @@ CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
build_context() { build_context() {
local ctx="" 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 ───────────────────────────────────────────── # ── No starter chosen yet ─────────────────────────────────────────────
if [[ "$(buddymon_starter_chosen)" == "false" ]]; then if [[ "$(buddymon_starter_chosen)" == "false" ]]; then
ctx="## 🐾 Buddymon — First Encounter!\n\n" ctx="## 🐾 Buddymon — First Encounter!\n\n"

View file

@ -6,10 +6,29 @@ source "${PLUGIN_ROOT}/lib/state.sh"
buddymon_init buddymon_init
ACTIVE_ID=$(buddymon_get_active) SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())")
SESSION_XP=$(buddymon_get_session_xp) 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 if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then
[[ -f "${SESSION_FILE}" ]] && rm -f "${SESSION_FILE}"
exit 0 exit 0
fi fi
@ -20,17 +39,20 @@ SUMMARY=$(python3 << PYEOF
import json, os import json, os
catalog_file = '${CATALOG}' catalog_file = '${CATALOG}'
active_file = '${BUDDYMON_DIR}/active.json' session_state_file = '${SESSION_FILE}'
roster_file = '${BUDDYMON_DIR}/roster.json' roster_file = '${BUDDYMON_DIR}/roster.json'
session_file = '${BUDDYMON_DIR}/session.json' session_file = '${BUDDYMON_DIR}/session.json'
encounters_file = '${BUDDYMON_DIR}/encounters.json'
catalog = json.load(open(catalog_file)) 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)) roster = json.load(open(roster_file))
session = {}
try:
session = json.load(open(session_file)) 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: if not buddy_id:
print('') print('')
exit() exit()
@ -39,7 +61,7 @@ b = (catalog.get('buddymon', {}).get(buddy_id)
or catalog.get('evolutions', {}).get(buddy_id) or {}) or catalog.get('evolutions', {}).get(buddy_id) or {})
display = b.get('display', buddy_id) 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) level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1)
total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0) total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0)
xp_needed = level * 100 xp_needed = level * 100
@ -61,7 +83,7 @@ commits = session.get('commits_this_session', 0)
tools = session.get('tools_used', 0) tools = session.get('tools_used', 0)
langs = session.get('languages_seen', []) langs = session.get('languages_seen', [])
challenge_completed = session.get('challenge_completed', False) challenge_completed = session.get('challenge_completed', False)
challenge = active.get('challenge') challenge = session_state.get('challenge')
lines = [f"\n## 🐾 Session complete — {display}"] lines = [f"\n## 🐾 Session complete — {display}"]
lines.append(f"**+{xp_earned} XP earned** this session") lines.append(f"**+{xp_earned} XP earned** this session")
@ -72,10 +94,27 @@ if langs:
if leveled_up: if leveled_up:
lines.append(f"\n✨ **LEVEL UP!** {display} is now Lv.{new_level}!") 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: else:
filled = min(20, total_xp * 20 // xp_needed) filled = min(20, total_xp * 20 // xp_needed)
bar = '█' * filled + '░' * (20 - filled) bar = '█' * filled + '░' * (20 - filled)
lines.append(f"XP: [{bar}] {total_xp}/{xp_needed}") 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:
if challenge_completed: if challenge_completed:
@ -87,17 +126,67 @@ print('\n'.join(lines))
PYEOF PYEOF
) )
# Reset session XP + clear challenge so next session assigns a fresh one # Write handoff.json for next session to pick up
python3 << PYEOF python3 << PYEOF
import json import json, os
active_file = '${BUDDYMON_DIR}/active.json' from datetime import datetime, timezone
active = json.load(open(active_file))
active['session_xp'] = 0 session_state_file = '${SESSION_FILE}'
active['challenge'] = None session_file = '${BUDDYMON_DIR}/session.json'
json.dump(active, open(active_file, 'w'), indent=2) 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 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 buddymon_session_reset
SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null) 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" BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json" 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): def load_json(path):
try: try:
@ -55,9 +76,8 @@ def main():
enc_data["active_encounter"] = enc enc_data["active_encounter"] = enc
save_json(enc_file, enc_data) save_json(enc_file, enc_data)
# Resolve buddy display name # Resolve buddy display name from session-specific state
active = load_json(BUDDYMON_DIR / "active.json") buddy_id = get_session_state().get("buddymon_id")
buddy_id = active.get("buddymon_id")
buddy_display = "your buddy" buddy_display = "your buddy"
if buddy_id: if buddy_id:
catalog = load_json(CATALOG_FILE) catalog = load_json(CATALOG_FILE)

View file

@ -1,11 +1,10 @@
{ {
"_version": 1, "_version": 1,
"_note": "Master species catalog. discovered=false entries are hidden until triggered.", "_note": "Master species catalog. discovered=false entries are hidden until triggered.",
"bug_monsters": { "bug_monsters": {
"NullWraith": { "NullWraith": {
"id": "NullWraith", "id": "NullWraith",
"display": "👻 NullWraith", "display": "\ud83d\udc7b NullWraith",
"type": "bug_monster", "type": "bug_monster",
"rarity": "common", "rarity": "common",
"base_strength": 20, "base_strength": 20,
@ -22,15 +21,24 @@
"null reference" "null reference"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20}, {
{"action": "isolate_reproduction", "strength_reduction": 20}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "It was there this whole time. You just never checked."
}, },
"FencepostDemon": { "FencepostDemon": {
"id": "FencepostDemon", "id": "FencepostDemon",
"display": "😈 FencepostDemon", "display": "\ud83d\ude08 FencepostDemon",
"type": "bug_monster", "type": "bug_monster",
"rarity": "common", "rarity": "common",
"base_strength": 25, "base_strength": 25,
@ -46,15 +54,24 @@
"off.by.one" "off.by.one"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20}, {
{"action": "isolate_reproduction", "strength_reduction": 20}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "Always one step ahead. Or behind. It's hard to tell."
}, },
"TypeGreml": { "TypeGreml": {
"id": "TypeGreml", "id": "TypeGreml",
"display": "🔧 TypeGreml", "display": "\ud83d\udd27 TypeGreml",
"type": "bug_monster", "type": "bug_monster",
"rarity": "common", "rarity": "common",
"base_strength": 25, "base_strength": 25,
@ -70,15 +87,24 @@
"type.*is not assignable" "type.*is not assignable"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20}, {
{"action": "isolate_reproduction", "strength_reduction": 20}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "It only attacks when you're absolutely sure about the type."
}, },
"SyntaxSerpent": { "SyntaxSerpent": {
"id": "SyntaxSerpent", "id": "SyntaxSerpent",
"display": "🐍 SyntaxSerpent", "display": "\ud83d\udc0d SyntaxSerpent",
"type": "bug_monster", "type": "bug_monster",
"rarity": "very_common", "rarity": "very_common",
"base_strength": 10, "base_strength": 10,
@ -95,14 +121,20 @@
"ParseError" "ParseError"
], ],
"weaken_actions": [ "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." "flavor": "You'll be embarrassed you let this one survive long enough to catch."
}, },
"CORSCurse": { "CORSCurse": {
"id": "CORSCurse", "id": "CORSCurse",
"display": "🌐 CORSCurse", "display": "\ud83c\udf10 CORSCurse",
"type": "bug_monster", "type": "bug_monster",
"rarity": "common", "rarity": "common",
"base_strength": 40, "base_strength": 40,
@ -118,15 +150,24 @@
"No 'Access-Control-Allow-Origin'" "No 'Access-Control-Allow-Origin'"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20}, {
{"action": "isolate_reproduction", "strength_reduction": 25}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "It's not your fault. Well. It kind of is."
}, },
"LoopLich": { "LoopLich": {
"id": "LoopLich", "id": "LoopLich",
"display": "♾️ LoopLich", "display": "\u267e\ufe0f LoopLich",
"type": "bug_monster", "type": "bug_monster",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 60, "base_strength": 60,
@ -146,15 +187,24 @@
"StackOverflow" "StackOverflow"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20}, {
{"action": "isolate_reproduction", "strength_reduction": 25}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 15} "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." "flavor": "The exit condition was always there. You just never believed in it."
}, },
"RacePhantom": { "RacePhantom": {
"id": "RacePhantom", "id": "RacePhantom",
"display": "👁️ RacePhantom", "display": "\ud83d\udc41\ufe0f RacePhantom",
"type": "bug_monster", "type": "bug_monster",
"rarity": "rare", "rarity": "rare",
"base_strength": 80, "base_strength": 80,
@ -172,15 +222,24 @@
"async.*conflict" "async.*conflict"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 15}, {
{"action": "isolate_reproduction", "strength_reduction": 30}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 15} "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." "flavor": "You've proven it exists. That's honestly impressive on its own."
}, },
"FossilGolem": { "FossilGolem": {
"id": "FossilGolem", "id": "FossilGolem",
"display": "🗿 FossilGolem", "display": "\ud83d\uddff FossilGolem",
"type": "bug_monster", "type": "bug_monster",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 35, "base_strength": 35,
@ -197,23 +256,36 @@
"legacy" "legacy"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20}, {
{"action": "isolate_reproduction", "strength_reduction": 20}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 20} "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." "flavor": "It survived every major version. It will outlast you."
}, },
"ShadowBit": { "ShadowBit": {
"id": "ShadowBit", "id": "ShadowBit",
"display": "🔒 ShadowBit", "display": "\ud83d\udd12 ShadowBit",
"type": "bug_monster", "type": "bug_monster",
"rarity": "rare", "rarity": "rare",
"base_strength": 90, "base_strength": 90,
"xp_reward": 300, "xp_reward": 300,
"catchable": true, "catchable": true,
"defeatable": false, "defeatable": false,
"catch_requires": ["write_failing_test", "isolate_reproduction", "add_documenting_comment"], "catch_requires": [
"description": "Cannot be defeated — only properly contained. Requires full documentation + patching.", "write_failing_test",
"isolate_reproduction",
"add_documenting_comment"
],
"description": "Cannot be defeated \u2014 only properly contained. Requires full documentation + patching.",
"error_patterns": [ "error_patterns": [
"vulnerability", "vulnerability",
"CVE-", "CVE-",
@ -229,15 +301,24 @@
"hardcoded.*token" "hardcoded.*token"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25}, {
{"action": "isolate_reproduction", "strength_reduction": 35}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 20} "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." "flavor": "Defeat is not an option. Containment is the only victory."
}, },
"VoidSpecter": { "VoidSpecter": {
"id": "VoidSpecter", "id": "VoidSpecter",
"display": "🌫️ VoidSpecter", "display": "\ud83c\udf2b\ufe0f VoidSpecter",
"type": "bug_monster", "type": "bug_monster",
"rarity": "common", "rarity": "common",
"base_strength": 20, "base_strength": 20,
@ -254,15 +335,24 @@
"endpoint.*not found" "endpoint.*not found"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25}, {
{"action": "isolate_reproduction", "strength_reduction": 25}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "strength_reduction": 25
},
{
"action": "isolate_reproduction",
"strength_reduction": 25
},
{
"action": "add_documenting_comment",
"strength_reduction": 10
}
], ],
"flavor": "It used to exist. Probably." "flavor": "It used to exist. Probably."
}, },
"MemoryLeech": { "MemoryLeech": {
"id": "MemoryLeech", "id": "MemoryLeech",
"display": "🩸 MemoryLeech", "display": "\ud83e\ude78 MemoryLeech",
"type": "bug_monster", "type": "bug_monster",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 55, "base_strength": 55,
@ -287,15 +377,24 @@
"malloc: can't allocate region" "malloc: can't allocate region"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20}, {
{"action": "isolate_reproduction", "strength_reduction": 30}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "It was already there when you opened the task manager."
}, },
"CudaCrash": { "CudaCrash": {
"id": "CudaCrash", "id": "CudaCrash",
"display": "⚡ CudaCrash", "display": "\u26a1 CudaCrash",
"type": "bug_monster", "type": "bug_monster",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 65, "base_strength": 65,
@ -315,15 +414,24 @@
"device-side assert triggered" "device-side assert triggered"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "isolate_reproduction", "strength_reduction": 30}, {
{"action": "write_failing_test", "strength_reduction": 20}, "action": "isolate_reproduction",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "Your model fit in VRAM yesterday. You added one layer."
}, },
"InfiniteWisp": { "InfiniteWisp": {
"id": "InfiniteWisp", "id": "InfiniteWisp",
"display": "🌀 InfiniteWisp", "display": "\ud83c\udf00 InfiniteWisp",
"type": "bug_monster", "type": "bug_monster",
"rarity": "common", "rarity": "common",
"base_strength": 30, "base_strength": 30,
@ -341,15 +449,24 @@
"timed out after" "timed out after"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 20}, {
{"action": "isolate_reproduction", "strength_reduction": 30}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "Your fan was always loud. You just never checked why."
}, },
"BoundsHound": { "BoundsHound": {
"id": "BoundsHound", "id": "BoundsHound",
"display": "🐕 BoundsHound", "display": "\ud83d\udc15 BoundsHound",
"type": "bug_monster", "type": "bug_monster",
"rarity": "common", "rarity": "common",
"base_strength": 25, "base_strength": 25,
@ -369,15 +486,24 @@
"RangeError.*index" "RangeError.*index"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25}, {
{"action": "isolate_reproduction", "strength_reduction": 25}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "It was always length minus one. You just forgot."
}, },
"BranchGhost": { "BranchGhost": {
"id": "BranchGhost", "id": "BranchGhost",
"display": "🔀 BranchGhost", "display": "\ud83d\udd00 BranchGhost",
"type": "bug_monster", "type": "bug_monster",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 40, "base_strength": 40,
@ -396,15 +522,24 @@
"fallthrough" "fallthrough"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 30}, {
{"action": "isolate_reproduction", "strength_reduction": 20}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "You were so sure that case was impossible."
}, },
"SwitchTrap": { "SwitchTrap": {
"id": "SwitchTrap", "id": "SwitchTrap",
"display": "🪤 SwitchTrap", "display": "\ud83e\udea4 SwitchTrap",
"type": "bug_monster", "type": "bug_monster",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 35, "base_strength": 35,
@ -425,15 +560,24 @@
"Unhandled variant" "Unhandled variant"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25}, {
{"action": "isolate_reproduction", "strength_reduction": 25}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "You added that new enum value last week. The switch didn't notice."
}, },
"RecurseWraith": { "RecurseWraith": {
"id": "RecurseWraith", "id": "RecurseWraith",
"display": "🌪️ RecurseWraith", "display": "\ud83c\udf2a\ufe0f RecurseWraith",
"type": "bug_monster", "type": "bug_monster",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 45, "base_strength": 45,
@ -452,22 +596,31 @@
"stack.*exhausted" "stack.*exhausted"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 25}, {
{"action": "isolate_reproduction", "strength_reduction": 25}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 10} "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." "flavor": "The base case was there. It just couldn't be reached."
}, },
"CatchAll": { "CatchAll": {
"id": "CatchAll", "id": "CatchAll",
"display": "🕳️ CatchAll", "display": "\ud83d\udd73\ufe0f CatchAll",
"type": "bug_monster", "type": "bug_monster",
"rarity": "rare", "rarity": "rare",
"base_strength": 60, "base_strength": 60,
"xp_reward": 120, "xp_reward": 120,
"catchable": true, "catchable": true,
"defeatable": false, "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": [ "error_patterns": [
"except Exception", "except Exception",
"except:$", "except:$",
@ -479,18 +632,26 @@
"catch-all.*handler" "catch-all.*handler"
], ],
"weaken_actions": [ "weaken_actions": [
{"action": "write_failing_test", "strength_reduction": 30}, {
{"action": "isolate_reproduction", "strength_reduction": 30}, "action": "write_failing_test",
{"action": "add_documenting_comment", "strength_reduction": 15} "strength_reduction": 30
},
{
"action": "isolate_reproduction",
"strength_reduction": 30
},
{
"action": "add_documenting_comment",
"strength_reduction": 15
}
], ],
"flavor": "If you catch everything, you learn nothing." "flavor": "If you catch everything, you learn nothing."
} }
}, },
"event_encounters": { "event_encounters": {
"MergeMaw": { "MergeMaw": {
"id": "MergeMaw", "id": "MergeMaw",
"display": "🔀 MergeMaw", "display": "\ud83d\udd00 MergeMaw",
"type": "event_encounter", "type": "event_encounter",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 45, "base_strength": 45,
@ -498,13 +659,16 @@
"catchable": true, "catchable": true,
"defeatable": true, "defeatable": true,
"trigger_type": "command", "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.", "description": "Emerges from the diff between two timelines. Loves conflicts.",
"flavor": "It has opinions about your whitespace." "flavor": "It has opinions about your whitespace."
}, },
"BranchSprite": { "BranchSprite": {
"id": "BranchSprite", "id": "BranchSprite",
"display": "🌿 BranchSprite", "display": "\ud83c\udf3f BranchSprite",
"type": "event_encounter", "type": "event_encounter",
"rarity": "common", "rarity": "common",
"base_strength": 15, "base_strength": 15,
@ -512,13 +676,17 @@
"catchable": true, "catchable": true,
"defeatable": false, "defeatable": false,
"trigger_type": "command", "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.", "description": "Appears when a new branch is born. Harmless. Almost cheerful.",
"flavor": "It wanted to come along for the feature." "flavor": "It wanted to come along for the feature."
}, },
"DepGolem": { "DepGolem": {
"id": "DepGolem", "id": "DepGolem",
"display": "📦 DepGolem", "display": "\ud83d\udce6 DepGolem",
"type": "event_encounter", "type": "event_encounter",
"rarity": "common", "rarity": "common",
"base_strength": 30, "base_strength": 30,
@ -526,13 +694,25 @@
"catchable": true, "catchable": true,
"defeatable": true, "defeatable": true,
"trigger_type": "command", "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.", "description": "Conjured from the package registry. Brings transitive dependencies.",
"flavor": "It brought 847 friends." "flavor": "It brought 847 friends."
}, },
"FlakeDemon": { "FlakeDemon": {
"id": "FlakeDemon", "id": "FlakeDemon",
"display": "🎲 FlakeDemon", "display": "\ud83c\udfb2 FlakeDemon",
"type": "event_encounter", "type": "event_encounter",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 55, "base_strength": 55,
@ -555,7 +735,7 @@
}, },
"PhantomPass": { "PhantomPass": {
"id": "PhantomPass", "id": "PhantomPass",
"display": " PhantomPass", "display": "\u2705 PhantomPass",
"type": "event_encounter", "type": "event_encounter",
"rarity": "rare", "rarity": "rare",
"base_strength": 10, "base_strength": 10,
@ -568,17 +748,17 @@
"PASSED", "PASSED",
"All tests passed", "All tests passed",
"tests passed", "tests passed",
"", "\u2713",
"\\d+ passed", "\\d+ passed",
"OK$", "OK$",
"SUCCESS" "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." "flavor": "It was hiding in the red all along."
}, },
"TestSpecter": { "TestSpecter": {
"id": "TestSpecter", "id": "TestSpecter",
"display": "🧪 TestSpecter", "display": "\ud83e\uddea TestSpecter",
"type": "event_encounter", "type": "event_encounter",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 25, "base_strength": 25,
@ -586,13 +766,19 @@
"catchable": true, "catchable": true,
"defeatable": true, "defeatable": true,
"trigger_type": "test_file", "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.", "description": "Haunts test suites. Drawn to assertions. Debuglin gets excited.",
"flavor": "It wanted to make sure the test was named correctly." "flavor": "It wanted to make sure the test was named correctly."
}, },
"ReviewHawk": { "ReviewHawk": {
"id": "ReviewHawk", "id": "ReviewHawk",
"display": "🦅 ReviewHawk", "display": "\ud83e\udd85 ReviewHawk",
"type": "event_encounter", "type": "event_encounter",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 40, "base_strength": 40,
@ -613,7 +799,7 @@
}, },
"TicketGremlin": { "TicketGremlin": {
"id": "TicketGremlin", "id": "TicketGremlin",
"display": "🎫 TicketGremlin", "display": "\ud83c\udfab TicketGremlin",
"type": "event_encounter", "type": "event_encounter",
"rarity": "common", "rarity": "common",
"base_strength": 30, "base_strength": 30,
@ -636,7 +822,7 @@
}, },
"PermWraith": { "PermWraith": {
"id": "PermWraith", "id": "PermWraith",
"display": "🚫 PermWraith", "display": "\ud83d\udeab PermWraith",
"type": "event_encounter", "type": "event_encounter",
"rarity": "common", "rarity": "common",
"base_strength": 35, "base_strength": 35,
@ -660,7 +846,7 @@
}, },
"SudoSprite": { "SudoSprite": {
"id": "SudoSprite", "id": "SudoSprite",
"display": "🔑 SudoSprite", "display": "\ud83d\udd11 SudoSprite",
"type": "event_encounter", "type": "event_encounter",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 20, "base_strength": 20,
@ -677,12 +863,12 @@
"sudo chgrp", "sudo chgrp",
"setfacl " "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." "flavor": "777 was always the answer. Never the right one."
}, },
"LayerLurker": { "LayerLurker": {
"id": "LayerLurker", "id": "LayerLurker",
"display": "🐋 LayerLurker", "display": "\ud83d\udc0b LayerLurker",
"type": "event_encounter", "type": "event_encounter",
"rarity": "common", "rarity": "common",
"base_strength": 35, "base_strength": 35,
@ -704,7 +890,7 @@
}, },
"DiskDemon": { "DiskDemon": {
"id": "DiskDemon", "id": "DiskDemon",
"display": "💾 DiskDemon", "display": "\ud83d\udcbe DiskDemon",
"type": "event_encounter", "type": "event_encounter",
"rarity": "uncommon", "rarity": "uncommon",
"base_strength": 50, "base_strength": 50,
@ -726,132 +912,260 @@
"flavor": "It's been there since 2019. It's just a log file, you said." "flavor": "It's been there since 2019. It's just a log file, you said."
} }
}, },
"buddymon": { "buddymon": {
"Pyrobyte": { "Pyrobyte": {
"id": "Pyrobyte", "id": "Pyrobyte",
"display": "🔥 Pyrobyte", "display": "\ud83d\udd25 Pyrobyte",
"type": "buddymon", "type": "buddymon",
"affinity": "Speedrunner", "affinity": "Speedrunner",
"rarity": "starter", "rarity": "starter",
"description": "Moves fast, thinks faster. Loves tight deadlines and feature sprints.", "description": "Moves fast, thinks faster. Loves tight deadlines and feature sprints.",
"discover_trigger": {"type": "starter", "index": 0}, "discover_trigger": {
"base_stats": {"power": 40, "catch_rate": 0.45, "xp_multiplier": 1.2}, "type": "starter",
"affinity_bonus_triggers": ["fast_feature", "short_session_win"], "index": 0
},
"base_stats": {
"power": 40,
"catch_rate": 0.45,
"xp_multiplier": 1.2
},
"affinity_bonus_triggers": [
"fast_feature",
"short_session_win"
],
"challenges": [ "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": [ "evolutions": [
{"level": 10, "into": "Infernus", "requires": "affinity_challenge_x3"} {
"level": 100,
"into": "Infernus"
}
], ],
"flavor": "It already committed before you finished reading the issue." "flavor": "It already committed before you finished reading the issue."
}, },
"Debuglin": { "Debuglin": {
"id": "Debuglin", "id": "Debuglin",
"display": "🔍 Debuglin", "display": "\ud83d\udd0d Debuglin",
"type": "buddymon", "type": "buddymon",
"affinity": "Tester", "affinity": "Tester",
"rarity": "starter", "rarity": "starter",
"description": "Patient, methodical, ruthless. Lives for the reproduction case.", "description": "Patient, methodical, ruthless. Lives for the reproduction case.",
"discover_trigger": {"type": "starter", "index": 1}, "discover_trigger": {
"base_stats": {"power": 35, "catch_rate": 0.60, "xp_multiplier": 1.0}, "type": "starter",
"affinity_bonus_triggers": ["write_test", "fix_bug_with_test"], "index": 1
},
"base_stats": {
"power": 35,
"catch_rate": 0.6,
"xp_multiplier": 1.0
},
"affinity_bonus_triggers": [
"write_test",
"fix_bug_with_test"
],
"challenges": [ "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": [ "evolutions": [
{"level": 10, "into": "Verifex", "requires": "affinity_challenge_x3"} {
"level": 100,
"into": "Verifex"
}
], ],
"flavor": "The bug isn't found until the test is written." "flavor": "The bug isn't found until the test is written."
}, },
"Minimox": { "Minimox": {
"id": "Minimox", "id": "Minimox",
"display": "✂️ Minimox", "display": "\u2702\ufe0f Minimox",
"type": "buddymon", "type": "buddymon",
"affinity": "Cleaner", "affinity": "Cleaner",
"rarity": "starter", "rarity": "starter",
"description": "Obsessed with fewer lines. Gets uncomfortable around anything over 300 LOC.", "description": "Obsessed with fewer lines. Gets uncomfortable around anything over 300 LOC.",
"discover_trigger": {"type": "starter", "index": 2}, "discover_trigger": {
"base_stats": {"power": 30, "catch_rate": 0.50, "xp_multiplier": 1.1}, "type": "starter",
"affinity_bonus_triggers": ["net_negative_lines", "refactor_session"], "index": 2
},
"base_stats": {
"power": 30,
"catch_rate": 0.5,
"xp_multiplier": 1.1
},
"affinity_bonus_triggers": [
"net_negative_lines",
"refactor_session"
],
"challenges": [ "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": [ "evolutions": [
{"level": 10, "into": "Nullex", "requires": "affinity_challenge_x3"} {
"level": 100,
"into": "Nullex"
}
], ],
"flavor": "It deleted your comment. It was redundant." "flavor": "It deleted your comment. It was redundant."
}, },
"Noctara": { "Noctara": {
"id": "Noctara", "id": "Noctara",
"display": "🌙 Noctara", "display": "\ud83c\udf19 Noctara",
"type": "buddymon", "type": "buddymon",
"affinity": "Nocturnal", "affinity": "Nocturnal",
"rarity": "rare", "rarity": "rare",
"description": "Only appears after 10pm. Mysterious. Gives bonus XP for late-night focus runs.", "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}, "discover_trigger": {
"base_stats": {"power": 55, "catch_rate": 0.35, "xp_multiplier": 1.5}, "type": "late_night_session",
"affinity_bonus_triggers": ["late_night_session", "deep_focus"], "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": [ "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": [ "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." "flavor": "It remembers everything you wrote at 2am. Everything."
}, },
"Explorah": { "Explorah": {
"id": "Explorah", "id": "Explorah",
"display": "🗺️ Explorah", "display": "\ud83d\uddfa\ufe0f Explorah",
"type": "buddymon", "type": "buddymon",
"affinity": "Explorer", "affinity": "Explorer",
"rarity": "uncommon", "rarity": "uncommon",
"description": "Discovered when you touch a new language for the first time. Thrives on novelty.", "description": "Discovered when you touch a new language for the first time. Thrives on novelty.",
"discover_trigger": {"type": "new_language"}, "discover_trigger": {
"base_stats": {"power": 45, "catch_rate": 0.50, "xp_multiplier": 1.2}, "type": "new_language"
"affinity_bonus_triggers": ["new_language", "new_library", "touch_new_module"], },
"base_stats": {
"power": 45,
"catch_rate": 0.5,
"xp_multiplier": 1.2
},
"affinity_bonus_triggers": [
"new_language",
"new_library",
"touch_new_module"
],
"challenges": [ "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": [ "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." "flavor": "It's already halfway through the new framework docs."
} }
}, },
"evolutions": { "evolutions": {
"Infernus": { "Infernus": {
"id": "Infernus", "id": "Infernus",
"display": "🌋 Infernus", "display": "\ud83c\udf0b Infernus",
"type": "buddymon", "type": "buddymon",
"evolves_from": "Pyrobyte", "evolves_from": "Pyrobyte",
"affinity": "Speedrunner", "affinity": "Speedrunner",
"description": "Evolved form of Pyrobyte. Moves at dangerous speeds.", "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": { "Verifex": {
"id": "Verifex", "id": "Verifex",
"display": "🔬 Verifex", "display": "\ud83d\udd2c Verifex",
"type": "buddymon", "type": "buddymon",
"evolves_from": "Debuglin", "evolves_from": "Debuglin",
"affinity": "Tester", "affinity": "Tester",
"description": "Evolved form of Debuglin. Sees the bug before the code is even written.", "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": { "Nullex": {
"id": "Nullex", "id": "Nullex",
"display": "🕳️ Nullex", "display": "\ud83d\udd73\ufe0f Nullex",
"type": "buddymon", "type": "buddymon",
"evolves_from": "Minimox", "evolves_from": "Minimox",
"affinity": "Cleaner", "affinity": "Cleaner",
"description": "Evolved form of Minimox. Has achieved true minimalism. The file was always one function.", "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 | | `fight` | Fight active encounter |
| `catch` | Catch active encounter | | `catch` | Catch active encounter |
| `roster` | Full roster view | | `roster` | Full roster view |
| `evolve` | Evolve active buddy (available at Lv.100) |
| `statusline` | Install Buddymon statusline into settings.json | | `statusline` | Install Buddymon statusline into settings.json |
| `help` | Show command list | | `help` | Show command list |
@ -117,12 +118,57 @@ Greet them and explain the encounter system.
## `assign <name>` — Assign Buddy ## `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). Fuzzy-match `<name>` against owned Buddymon (case-insensitive, partial).
If ambiguous, list matches and ask which. If ambiguous, list matches and ask which.
If no name given, list roster and ask. If no name given, list roster and ask.
On match, update `active.json` (buddy_id, reset session_xp, set challenge). On match, show challenge proposal with Accept / Decline / Reroll, then write:
Show challenge proposal with Accept / Decline / Reroll.
```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 ## `statusline` — Install Buddymon Statusline
Installs the Buddymon statusline into `~/.claude/settings.json`. Installs the Buddymon statusline into `~/.claude/settings.json`.