Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

12 changed files with 197 additions and 2215 deletions

View file

@ -1,11 +1,16 @@
{
"name": "buddymon",
"description": "Collectible creatures discovered through coding — commit streaks, bug fights, and session challenges",
"version": "0.1.0",
"description": "Collectible creatures discovered through coding — commit streaks, bug fights, and session challenges",
"author": {
"name": "CircuitForge LLC",
"email": "hello@circuitforge.tech"
"email": "hello@circuitforge.tech",
"url": "https://circuitforge.tech"
},
"license": "MIT",
"repository": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon"
"repository": {
"primary": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon",
"github": "https://github.com/CircuitForgeLLC/buddymon",
"codeberg": "https://codeberg.org/CircuitForge/buddymon"
}
}

View file

@ -1,19 +1,9 @@
# 🐾 Buddymon
A Claude Code **extension** that turns your coding sessions into a creature-collecting game.
A Claude Code plugin that turns your coding sessions into a creature-collecting game.
Buddymon are discovered, caught, and leveled up through real development work — not separate from it.
> **How it works:** Buddymon uses Claude Code's hook and plugin system — it is not a UI mod. Notifications (encounters, XP, session summaries) appear as system-injected context in the chat thread, visible to both you and Claude. They do not appear in Thrumble's speech bubble or any other CC UI widget.
### Companion widgets and Buddymon
Claude Code supports personal companion widgets — small characters that sit beside the input box and comment in a speech bubble. These are user-configured, not built into CC by Anthropic. Buddymon is a separate community plugin and has no relationship to any specific companion.
The CC plugin API does not expose a hook to drive companion speech bubbles, so Buddymon cannot make your companion announce encounters. If Anthropic ships a companion speech API in a future release, Buddymon will adopt it.
Until then, Buddymon notifications arrive as chat context rather than companion barks. All game state (XP, encounters, roster) works regardless.
---
## What it does
@ -50,9 +40,8 @@ Then **restart Claude Code** and run:
```
The install script:
- Creates a local `circuitforge` marketplace under `~/.claude/plugins/marketplaces/circuitforge/` (required — CC validates plugin names against the marketplace index)
- Symlinks the repo into `~/.claude/plugins/cache/circuitforge/buddymon/<version>/`
- Registers the plugin in `~/.claude/plugins/installed_plugins.json` and `~/.claude/plugins/known_marketplaces.json`
- Symlinks the repo into `~/.claude/plugins/cache/local/buddymon/0.1.0/`
- Registers the plugin in `~/.claude/plugins/installed_plugins.json`
- Enables it in `~/.claude/settings.json`
- Creates `~/.claude/buddymon/` state directory with initial JSON files

View file

@ -18,36 +18,11 @@ import re
import sys
import random
from pathlib import Path
from datetime import datetime
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
# 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",
@ -86,67 +61,21 @@ def get_state():
def add_session_xp(amount: int):
session = get_session_state()
session["session_xp"] = session.get("session_xp", 0) + amount
buddy_id = session.get("buddymon_id")
save_session_state(session)
active_file = BUDDYMON_DIR / "active.json"
roster_file = BUDDYMON_DIR / "roster.json"
active = load_json(active_file)
active["session_xp"] = active.get("session_xp", 0) + amount
buddy_id = active.get("buddymon_id")
save_json(active_file, active)
if buddy_id:
roster_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
save_json(roster_file, roster)
LANGUAGE_TIERS = [
(0, "discovering"),
(50, "familiar"),
(150, "comfortable"),
(350, "proficient"),
(700, "expert"),
(1200, "master"),
]
def _tier_for_xp(xp: int) -> tuple[int, str]:
"""Return (level_index, tier_label) for a given XP total."""
level = 0
label = LANGUAGE_TIERS[0][1]
for i, (threshold, name) in enumerate(LANGUAGE_TIERS):
if xp >= threshold:
level = i
label = name
return level, label
def get_language_affinity(lang: str) -> dict:
"""Return the affinity entry for lang from roster.json, or a fresh one."""
roster = load_json(BUDDYMON_DIR / "roster.json")
return roster.get("language_affinities", {}).get(lang, {"xp": 0, "level": 0, "tier": "discovering"})
def add_language_affinity(lang: str, xp_amount: int) -> tuple[bool, str, str]:
"""Add XP to lang's affinity. Returns (leveled_up, old_tier, new_tier)."""
roster_file = BUDDYMON_DIR / "roster.json"
roster = load_json(roster_file)
affinities = roster.setdefault("language_affinities", {})
entry = affinities.get(lang, {"xp": 0, "level": 0, "tier": "discovering"})
old_level, old_tier = _tier_for_xp(entry["xp"])
entry["xp"] = entry.get("xp", 0) + xp_amount
new_level, new_tier = _tier_for_xp(entry["xp"])
entry["level"] = new_level
entry["tier"] = new_tier
affinities[lang] = entry
roster["language_affinities"] = affinities
save_json(roster_file, roster)
leveled_up = new_level > old_level
return leveled_up, old_tier, new_tier
def get_languages_seen():
session = load_json(BUDDYMON_DIR / "session.json")
return set(session.get("languages_seen", []))
@ -175,7 +104,8 @@ def is_starter_chosen():
def get_active_buddy_id():
return get_session_state().get("buddymon_id")
active = load_json(BUDDYMON_DIR / "active.json")
return active.get("buddymon_id")
def get_active_encounter():
@ -190,21 +120,6 @@ def set_active_encounter(encounter: dict):
save_json(enc_file, data)
def wound_encounter() -> None:
"""Drop active encounter to minimum strength and flag for re-announcement."""
enc_file = BUDDYMON_DIR / "encounters.json"
data = load_json(enc_file)
enc = data.get("active_encounter")
if not enc:
return
enc["current_strength"] = 5
enc["wounded"] = True
enc["announced"] = False # triggers UserPromptSubmit re-announcement
enc["last_wounded_at"] = datetime.now().astimezone().isoformat()
data["active_encounter"] = enc
save_json(enc_file, data)
def match_bug_monster(output_text: str, catalog: dict) -> dict | None:
"""Return the first matching bug monster from the catalog, or None."""
if not output_text:
@ -309,58 +224,6 @@ def format_encounter_message(monster: dict, strength: int, buddy_display: str) -
return "\n".join(lines)
def match_event_encounter(command: str, output: str, session: dict, catalog: dict):
"""Detect non-error-based encounters: git ops, installs, test results."""
events = catalog.get("event_encounters", {})
errors_seen = bool(session.get("errors_encountered") or session.get("tools_used", 0) > 5)
for enc_id, enc in events.items():
trigger = enc.get("trigger_type", "")
if trigger == "command":
if any(re.search(pat, command) for pat in enc.get("command_patterns", [])):
return enc
elif trigger == "output":
if any(re.search(pat, output, re.IGNORECASE) for pat in enc.get("error_patterns", [])):
return enc
elif trigger == "test_victory":
# Only spawn PhantomPass when tests go green after the session has been running a while
if errors_seen and any(re.search(pat, output, re.IGNORECASE) for pat in enc.get("success_patterns", [])):
return enc
return None
def match_test_file_encounter(file_path: str, catalog: dict):
"""Spawn TestSpecter when editing a test file."""
enc = catalog.get("event_encounters", {}).get("TestSpecter")
if not enc:
return None
name = os.path.basename(file_path).lower()
if any(re.search(pat, name) for pat in enc.get("test_file_patterns", [])):
return enc
return None
def spawn_encounter(enc: dict) -> None:
"""Write an event encounter to active state with announced=False."""
strength = enc.get("base_strength", 30)
encounter = {
"id": enc["id"],
"display": enc["display"],
"base_strength": enc.get("base_strength", 30),
"current_strength": strength,
"catchable": enc.get("catchable", True),
"defeatable": enc.get("defeatable", True),
"xp_reward": enc.get("xp_reward", 50),
"weakened_by": [],
"announced": False,
}
set_active_encounter(encounter)
def format_new_language_message(lang: str, buddy_display: str) -> str:
return (
f"\n🗺️ **New language spotted: {lang}!**\n"
@ -369,23 +232,6 @@ def format_new_language_message(lang: str, buddy_display: str) -> str:
)
def format_language_levelup_message(lang: str, old_tier: str, new_tier: str, total_xp: int, buddy_display: str) -> str:
tier_emojis = {
"discovering": "🔭",
"familiar": "📖",
"comfortable": "🛠️",
"proficient": "",
"expert": "🎯",
"master": "👑",
}
emoji = tier_emojis.get(new_tier, "⬆️")
return (
f"\n{emoji} **{lang} affinity: {old_tier}{new_tier}!**\n"
f" {buddy_display} has grown more comfortable in {lang}.\n"
f" *Total {lang} XP: {total_xp}*\n"
)
def format_commit_message(streak: int, buddy_display: str) -> str:
if streak < 5:
return ""
@ -432,107 +278,46 @@ def main():
# ── Bash tool: error detection + auto-resolution + commit tracking ───────
if tool_name == "Bash":
command = tool_input.get("command", "")
output = ""
# CC Bash tool_response keys: stdout, stderr, interrupted, isImage, noOutputExpected
if isinstance(tool_response, dict):
parts = [
tool_response.get("stdout", ""),
tool_response.get("stderr", ""),
]
output = "\n".join(p for p in parts if isinstance(p, str) and p)
output = tool_response.get("output", "") or tool_response.get("content", "")
elif isinstance(tool_response, str):
output = tool_response
elif isinstance(tool_response, list):
output = "\n".join(
b.get("text", "") for b in tool_response
if isinstance(b, dict) and b.get("type") == "text"
)
existing = get_active_encounter()
if existing:
# On a clean Bash run (monster patterns gone), respect catch_pending,
# wound a healthy monster, or auto-resolve a wounded one.
# Probability gates prevent back-to-back Bash runs from instantly
# resolving encounters before the user can react.
# Auto-resolve if the monster's patterns no longer appear in output
if output and not encounter_still_present(existing, output, catalog):
if existing.get("catch_pending"):
# User invoked /buddymon catch — hold the monster for this run.
# Clear the flag now so the NEXT clean run resumes normal behavior.
# The skill sets it again at the start of each /buddymon catch call.
existing["catch_pending"] = False
enc_data = load_json(BUDDYMON_DIR / "encounters.json")
enc_data["active_encounter"] = existing
save_json(BUDDYMON_DIR / "encounters.json", enc_data)
else:
# Auto-attack rates scaled by encounter rarity and buddy level.
# Multiple parallel sessions share encounters.json — a wound
# cooldown prevents them pile-driving the same encounter.
rarity = existing.get("rarity", "common")
WOUND_RATES = {
"very_common": 0.55, "common": 0.40,
"uncommon": 0.22, "rare": 0.10, "legendary": 0.02,
}
RESOLVE_RATES = {
"very_common": 0.45, "common": 0.28,
"uncommon": 0.14, "rare": 0.05, "legendary": 0.01,
}
roster = load_json(BUDDYMON_DIR / "roster.json")
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
level_scale = 1.0 + (buddy_level / 100) * 0.25
# Wound cooldown: skip if another session wounded within 30s
last_wound = existing.get("last_wounded_at", "")
wound_cooldown_ok = True
if last_wound:
try:
from datetime import timezone as _tz
last_dt = datetime.fromisoformat(last_wound)
age = (datetime.now(_tz.utc) - last_dt).total_seconds()
wound_cooldown_ok = age > 30
except Exception:
pass
if existing.get("wounded"):
resolve_rate = min(0.70, RESOLVE_RATES.get(rarity, 0.28) * level_scale)
if wound_cooldown_ok and random.random() < resolve_rate:
xp, display = auto_resolve_encounter(existing, buddy_id)
messages.append(
f"\n💨 **{display} fled!** (escaped while wounded)\n"
f" {buddy_display} gets partial XP: +{xp}\n"
f"\n⚔️ **{buddy_display} defeated {display}!** (auto-resolved)\n"
f" +{xp} XP\n"
)
else:
wound_rate = min(0.85, WOUND_RATES.get(rarity, 0.40) * level_scale)
if wound_cooldown_ok and random.random() < wound_rate:
wound_encounter()
# else: monster still present, no message — don't spam every tool call
elif output or command:
# No active encounter — check for bug monster first, then event encounters
session = load_json(BUDDYMON_DIR / "session.json")
monster = match_bug_monster(output, catalog) if output else None
event = None if monster else match_event_encounter(command, output, session, catalog)
target = monster or event
if target and random.random() < 0.70:
# else: monster persists, no message — don't spam every tool call
elif output:
# No active encounter — check for a new one
monster = match_bug_monster(output, catalog)
if monster:
# 70% chance to trigger (avoids every minor warning spawning)
if random.random() < 0.70:
strength = compute_strength(monster, elapsed_minutes=0)
else:
strength = target.get("base_strength", 30)
encounter = {
"id": target["id"],
"display": target["display"],
"base_strength": target.get("base_strength", 50),
"id": monster["id"],
"display": monster["display"],
"base_strength": monster.get("base_strength", 50),
"current_strength": strength,
"catchable": target.get("catchable", True),
"defeatable": target.get("defeatable", True),
"xp_reward": target.get("xp_reward", 50),
"catchable": monster.get("catchable", True),
"defeatable": monster.get("defeatable", True),
"xp_reward": monster.get("xp_reward", 50),
"weakened_by": [],
"announced": False,
}
set_active_encounter(encounter)
msg = format_encounter_message(monster, strength, buddy_display)
messages.append(msg)
# Commit detection
command = tool_input.get("command", "")
if "git commit" in command and "exit_code" not in str(tool_response):
session_file = BUDDYMON_DIR / "session.json"
session = load_json(session_file)
@ -542,14 +327,13 @@ def main():
commit_xp = 20
add_session_xp(commit_xp)
# ── Write / Edit: new language detection + affinity + test file encounters
# ── Write / Edit: new language detection ─────────────────────────────
elif tool_name in ("Write", "Edit", "MultiEdit"):
file_path = tool_input.get("file_path", "")
if file_path:
ext = os.path.splitext(file_path)[1].lower()
lang = KNOWN_EXTENSIONS.get(ext)
if lang:
# Session-level "first encounter" bonus
seen = get_languages_seen()
if lang not in seen:
add_language_seen(lang)
@ -557,19 +341,6 @@ def main():
msg = format_new_language_message(lang, buddy_display)
messages.append(msg)
# Persistent affinity XP — always accumulates
leveled_up, old_tier, new_tier = add_language_affinity(lang, 3)
if leveled_up:
affinity = get_language_affinity(lang)
msg = format_language_levelup_message(lang, old_tier, new_tier, affinity["xp"], buddy_display)
messages.append(msg)
# TestSpecter: editing a test file with no active encounter
if not get_active_encounter():
enc = match_test_file_encounter(file_path, catalog)
if enc and random.random() < 0.50:
spawn_encounter(enc)
# Small XP for every file edit
add_session_xp(2)

View file

@ -7,53 +7,8 @@ source "${PLUGIN_ROOT}/lib/state.sh"
buddymon_init
# 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
# Clean up session files for dead processes (runs async-style; errors ignored)
python3 << 'PYEOF' 2>/dev/null &
import os, glob, json
for f in glob.glob(os.path.expanduser("~/.claude/buddymon/sessions/*.json")):
pid_str = os.path.basename(f).replace('.json', '')
try:
pid = int(pid_str)
# Check if the process group still exists
os.killpg(pid, 0)
except (ValueError, ProcessLookupError, PermissionError):
# PermissionError means the process exists (just not ours) — keep it
# ProcessLookupError means it's dead — remove
try:
d = json.load(open(f))
if '_version' not in d: # only remove valid-format dead sessions
os.remove(f)
except Exception:
pass
except Exception:
pass
PYEOF
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)
ACTIVE_ID=$(buddymon_get_active)
SESSION_XP=$(buddymon_get_session_xp)
# Load catalog for buddy display info
CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
@ -61,77 +16,6 @@ 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"
@ -209,28 +93,14 @@ if ch:
enc_display=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('display','?'))")
enc_strength=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('current_strength',100))")
ctx+="⚠️ **Unresolved encounter from last session:** ${enc_display} (strength: ${enc_strength}%)\n"
ctx+="Run \`/buddymon fight\` or \`/buddymon catch\` to resolve it.\n\n"
ctx+="Run \`/buddymon-fight\` or \`/buddymon-catch\` to resolve it.\n\n"
fi
ctx+="*Bug monsters appear from error output. Use \`/buddymon fight\` or \`/buddymon catch\`.*"
ctx+="*Bug monsters appear from error output. Use \`/buddymon-fight\` or \`/buddymon-catch\`.*"
echo "${ctx}"
}
# Assign a fresh challenge if none is set
python3 << PYEOF
import json, random
catalog = json.load(open('${CATALOG}'))
active_file = '${ACTIVE_FILE}'
active = json.load(open(active_file))
buddy_id = active.get('buddymon_id')
if buddy_id and not active.get('challenge'):
pool = catalog.get('buddymon', {}).get(buddy_id, {}).get('challenges', [])
if pool:
active['challenge'] = random.choice(pool)
json.dump(active, open(active_file, 'w'), indent=2)
PYEOF
CONTEXT=$(build_context)
# Escape for JSON

View file

@ -6,29 +6,14 @@ source "${PLUGIN_ROOT}/lib/state.sh"
buddymon_init
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)
ACTIVE_ID=$(buddymon_get_active)
SESSION_XP=$(buddymon_get_session_xp)
if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then
[[ -f "${SESSION_FILE}" ]] && rm -f "${SESSION_FILE}"
# Nothing to report
cat << 'EOF'
{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": ""}}
EOF
exit 0
fi
@ -39,20 +24,17 @@ SUMMARY=$(python3 << PYEOF
import json, os
catalog_file = '${CATALOG}'
session_state_file = '${SESSION_FILE}'
active_file = '${BUDDYMON_DIR}/active.json'
roster_file = '${BUDDYMON_DIR}/roster.json'
session_file = '${BUDDYMON_DIR}/session.json' # SESSION_DATA_FILE from state.sh
session_file = '${BUDDYMON_DIR}/session.json'
encounters_file = '${BUDDYMON_DIR}/encounters.json'
catalog = json.load(open(catalog_file))
session_state = json.load(open(session_state_file))
active = json.load(open(active_file))
roster = json.load(open(roster_file))
session = {}
try:
session = json.load(open(session_file))
except Exception:
pass
session = json.load(open(session_file))
buddy_id = session_state.get('buddymon_id')
buddy_id = active.get('buddymon_id')
if not buddy_id:
print('')
exit()
@ -61,7 +43,7 @@ b = (catalog.get('buddymon', {}).get(buddy_id)
or catalog.get('evolutions', {}).get(buddy_id) or {})
display = b.get('display', buddy_id)
xp_earned = session_state.get('session_xp', 0)
xp_earned = active.get('session_xp', 0)
level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1)
total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0)
xp_needed = level * 100
@ -83,7 +65,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 = session_state.get('challenge')
challenge = active.get('challenge')
lines = [f"\n## 🐾 Session complete — {display}"]
lines.append(f"**+{xp_earned} XP earned** this session")
@ -94,27 +76,10 @@ 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:
@ -126,74 +91,28 @@ print('\n'.join(lines))
PYEOF
)
# Write handoff.json for next session to pick up
# Reset session XP counter for next session (keep total in roster)
python3 << PYEOF
import json, os
from datetime import datetime, timezone
session_state_file = '${SESSION_FILE}'
session_file = '${BUDDYMON_DIR}/session.json' # SESSION_DATA_FILE from state.sh
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)
import json
active_file = '${BUDDYMON_DIR}/active.json'
active = json.load(open(active_file))
active['session_xp'] = 0
json.dump(active, open(active_file, 'w'), indent=2)
PYEOF
# Clean up this session's state file — each session is ephemeral
rm -f "${SESSION_FILE}"
# Reset shared session.json for legacy compatibility
# Reset session file
buddymon_session_reset
SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null)
[[ -z "${SUMMARY_JSON}" ]] && SUMMARY_JSON='""'
cat << EOF
{"systemMessage": ${SUMMARY_JSON}}
{
"hookSpecificOutput": {
"hookEventName": "Stop",
"additionalContext": ${SUMMARY_JSON}
}
}
EOF
exit 0

View file

@ -1,145 +0,0 @@
#!/usr/bin/env python3
"""
Buddymon UserPromptSubmit hook.
Fires on every user message. Checks for an unannounced active encounter
and surfaces it exactly once via additionalContext, then marks it announced
so the dedup loop breaks. Exits silently if nothing is pending.
"""
import json
import os
import sys
from pathlib import Path
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
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:
with open(path) as f:
return json.load(f)
except Exception:
return {}
def save_json(path, data):
try:
with open(path, "w") as f:
json.dump(data, f, indent=2)
except Exception:
pass
def main():
try:
json.load(sys.stdin)
except Exception:
pass
roster = load_json(BUDDYMON_DIR / "roster.json")
if not roster.get("starter_chosen", False):
sys.exit(0)
enc_file = BUDDYMON_DIR / "encounters.json"
enc_data = load_json(enc_file)
enc = enc_data.get("active_encounter")
if not enc or enc.get("announced", False):
sys.exit(0)
# Mark announced FIRST — prevents re-announce even if output delivery fails
enc["announced"] = True
enc_data["active_encounter"] = enc
save_json(enc_file, enc_data)
# 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)
b = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id))
if b:
buddy_display = b.get("display", buddy_id)
else:
catalog = load_json(CATALOG_FILE)
monster = catalog.get("bug_monsters", {}).get(enc.get("id", ""), {})
rarity = monster.get("rarity", "common")
rarity_stars = {
"very_common": "★☆☆☆☆", "common": "★★☆☆☆",
"uncommon": "★★★☆☆", "rare": "★★★★☆", "legendary": "★★★★★",
}
stars = rarity_stars.get(rarity, "★★☆☆☆")
strength = enc.get("current_strength", 50)
defeatable = enc.get("defeatable", True)
catchable = enc.get("catchable", True)
flavor = monster.get("flavor", "")
if enc.get("wounded"):
# Wounded re-announcement — urgent, catch-or-lose framing
lines = [
f"\n🩹 **{enc['display']} is wounded and fleeing!**",
f" Strength: {strength}% · This is your last chance to catch it.",
"",
f" **{buddy_display}** is ready — move fast!",
"",
" `[CATCH]` → `/buddymon catch` (near-guaranteed at 5% strength)",
" `[IGNORE]` → it flees on the next clean run",
]
else:
# Normal first appearance
catchable_str = "[catchable · catch only]" if not defeatable else f"[{rarity} · {'catchable' if catchable else ''}]"
lines = [
f"\n💀 **{enc['display']} appeared!** {catchable_str}",
f" Strength: {strength}% · Rarity: {stars}",
]
if flavor:
lines.append(f" *{flavor}*")
if not defeatable:
lines.append(" ⚠️ CANNOT BE DEFEATED — catch only")
lines += [
"",
f" **{buddy_display}** is ready to battle!",
"",
" `[FIGHT]` Fix the bug → `/buddymon fight` to claim XP",
" `[CATCH]` Weaken first (test/repro/comment) → `/buddymon catch`",
" `[FLEE]` Ignore → monster grows stronger",
]
msg = "\n".join(lines)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": msg,
}
}))
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -3,35 +3,22 @@
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh\"",
"timeout": 10
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/user-prompt-submit.py",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash|Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/post-tool-use.py",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/post-tool-use.py\"",
"timeout": 10
}
]
@ -39,11 +26,10 @@
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-stop.sh",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-stop.sh\"",
"timeout": 10
}
]

View file

@ -9,8 +9,8 @@
set -euo pipefail
PLUGIN_NAME="buddymon"
MARKETPLACE="circuitforge"
VERSION="0.1.1"
MARKETPLACE="local"
VERSION="0.1.0"
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGINS_DIR="${HOME}/.claude/plugins"
@ -82,27 +82,6 @@ if key in d.get('enabledPlugins', {}):
PYEOF
fi
# Remove from known_marketplaces.json
KNOWN_MARKETPLACES="${PLUGINS_DIR}/known_marketplaces.json"
if [[ -f "${KNOWN_MARKETPLACES}" ]]; then
python3 << PYEOF
import json
f = '${KNOWN_MARKETPLACES}'
d = json.load(open(f))
if '${MARKETPLACE}' in d:
del d['${MARKETPLACE}']
json.dump(d, open(f, 'w'), indent=2)
print(" Removed '${MARKETPLACE}' from known_marketplaces.json")
PYEOF
fi
# Remove marketplace plugin symlink (leave marketplace dir in case other CF plugins exist)
MARKETPLACE_PLUGIN_LINK="${PLUGINS_DIR}/marketplaces/${MARKETPLACE}/plugins/${PLUGIN_NAME}"
if [[ -L "${MARKETPLACE_PLUGIN_LINK}" ]]; then
rm "${MARKETPLACE_PLUGIN_LINK}"
ok "Removed marketplace plugin symlink"
fi
echo ""
echo "${PLUGIN_KEY} uninstalled. Restart Claude Code to apply."
}
@ -121,61 +100,6 @@ install() {
[[ -f "${REPO_DIR}/hooks/hooks.json" ]] \
|| die "Missing hooks/hooks.json"
# Create circuitforge marketplace (CC validates plugin name against marketplace index)
MARKETPLACE_DIR="${PLUGINS_DIR}/marketplaces/${MARKETPLACE}"
mkdir -p "${MARKETPLACE_DIR}/.claude-plugin"
mkdir -p "${MARKETPLACE_DIR}/plugins"
if [[ ! -f "${MARKETPLACE_DIR}/.claude-plugin/marketplace.json" ]]; then
python3 << PYEOF
import json
f = '${MARKETPLACE_DIR}/.claude-plugin/marketplace.json'
d = {
"\$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "${MARKETPLACE}",
"description": "CircuitForge LLC Claude Code plugins",
"owner": {"name": "CircuitForge LLC", "email": "hello@circuitforge.tech"},
"plugins": [{
"name": "${PLUGIN_NAME}",
"description": "Collectible creatures discovered through coding — commit streaks, bug fights, and session challenges",
"author": {"name": "CircuitForge LLC", "email": "hello@circuitforge.tech"},
"source": "./plugins/${PLUGIN_NAME}",
"category": "productivity",
"homepage": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon"
}]
}
json.dump(d, open(f, 'w'), indent=2)
PYEOF
ok "Created ${MARKETPLACE} marketplace"
fi
# Symlink repo into marketplace plugins dir
if [[ ! -L "${MARKETPLACE_DIR}/plugins/${PLUGIN_NAME}" ]]; then
ln -sf "${REPO_DIR}" "${MARKETPLACE_DIR}/plugins/${PLUGIN_NAME}"
ok "Linked into marketplace plugins dir"
fi
# Register marketplace in known_marketplaces.json
python3 << PYEOF
import json, os
from datetime import datetime, timezone
f = '${PLUGINS_DIR}/known_marketplaces.json'
try:
d = json.load(open(f))
except FileNotFoundError:
d = {}
if '${MARKETPLACE}' not in d:
d['${MARKETPLACE}'] = {
"source": {"source": "local", "path": '${MARKETPLACE_DIR}'},
"installLocation": '${MARKETPLACE_DIR}',
"lastUpdated": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'),
}
json.dump(d, open(f, 'w'), indent=2)
print(" Registered '${MARKETPLACE}' in known_marketplaces.json")
else:
print(" '${MARKETPLACE}' marketplace already registered")
PYEOF
# Create cache parent dir
mkdir -p "$(dirname "${CACHE_DIR}")"
@ -270,30 +194,6 @@ for name, default in files.items():
print(f" Created {name}")
else:
print(f" {name} already exists — kept")
# Pre-create hook_debug.log so the hook sandbox can write to it
log_path = os.path.join(d, 'hook_debug.log')
if not os.path.exists(log_path):
open(log_path, 'w').close()
print(" Created hook_debug.log")
PYEOF
# Copy statusline script to stable user-local path
cp "${REPO_DIR}/lib/statusline.sh" "${BUDDYMON_DIR}/statusline.sh"
chmod +x "${BUDDYMON_DIR}/statusline.sh"
ok "Installed statusline.sh → ${BUDDYMON_DIR}/statusline.sh"
# Install statusline into settings.json if not already configured
python3 << PYEOF
import json
f = '${SETTINGS_FILE}'
d = json.load(open(f))
if 'statusLine' not in d:
d['statusLine'] = {"type": "command", "command": "bash ${BUDDYMON_DIR}/statusline.sh"}
json.dump(d, open(f, 'w'), indent=2)
print(" Installed Buddymon statusline in settings.json")
else:
print(" statusLine already configured — skipped (run /buddymon statusline to install manually)")
PYEOF
echo ""

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,7 @@ BUDDYMON_DIR="${HOME}/.claude/buddymon"
ROSTER_FILE="${BUDDYMON_DIR}/roster.json"
ENCOUNTERS_FILE="${BUDDYMON_DIR}/encounters.json"
ACTIVE_FILE="${BUDDYMON_DIR}/active.json"
# Named SESSION_DATA_FILE (not SESSION_FILE) to avoid shadowing the
# per-session state file hooks define as SESSION_FILE=sessions/<pgrp>.json
SESSION_DATA_FILE="${BUDDYMON_DIR}/session.json"
SESSION_FILE="${BUDDYMON_DIR}/session.json"
buddymon_init() {
mkdir -p "${BUDDYMON_DIR}"
@ -44,7 +42,7 @@ EOF
EOF
fi
if [[ ! -f "${SESSION_DATA_FILE}" ]]; then
if [[ ! -f "${SESSION_FILE}" ]]; then
buddymon_session_reset
fi
}
@ -52,7 +50,7 @@ EOF
buddymon_session_reset() {
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
cat > "${SESSION_DATA_FILE}" << EOF
cat > "${SESSION_FILE}" << EOF
{
"_version": 1,
"started_at": "${ts}",

View file

@ -1,38 +0,0 @@
#!/usr/bin/env bash
# Buddymon statusline — displays active buddy + encounter in the CC status bar.
# Install: add to ~/.claude/settings.json → "statusLine" → "command"
# Or run: /buddymon statusline (installs automatically)
B="$HOME/.claude/buddymon"
# Bail fast if no state directory or no starter chosen
[[ -d "$B" ]] || exit 0
STARTER=$(jq -r '.starter_chosen // false' "$B/roster.json" 2>/dev/null)
[[ "$STARTER" == "true" ]] || exit 0
# Read state
ID=$(jq -r '.buddymon_id // ""' "$B/active.json" 2>/dev/null)
[[ -n "$ID" ]] || exit 0
LVL=$(jq -r ".owned[\"$ID\"].level // 1" "$B/roster.json" 2>/dev/null)
XP=$(jq -r '.session_xp // 0' "$B/active.json" 2>/dev/null)
ENC_JSON=$(jq -c '.active_encounter // null' "$B/encounters.json" 2>/dev/null)
ENC_DISPLAY=$(echo "$ENC_JSON" | jq -r '.display // ""' 2>/dev/null)
ENC_STRENGTH=$(echo "$ENC_JSON" | jq -r '.current_strength // 0' 2>/dev/null)
# ANSI colors
CY='\033[38;2;23;146;153m' # cyan — buddy
GR='\033[38;2;64;160;43m' # green — xp
RD='\033[38;2;203;60;51m' # red — encounter
DM='\033[38;2;120;120;120m' # dim — separators
RS='\033[0m'
printf "${CY}🐾 ${ID} Lv.${LVL}${RS}"
printf " ${DM}·${RS} ${GR}+${XP}xp${RS}"
if [[ "$ENC_JSON" != "null" ]] && [[ -n "$ENC_DISPLAY" ]]; then
printf " ${RD}${ENC_DISPLAY} [${ENC_STRENGTH}%%]${RS}"
fi
echo

View file

@ -25,8 +25,6 @@ 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 |
---
@ -88,14 +86,10 @@ Ask for 1, 2, or 3. On choice, write to roster + active:
```python
import json, os
from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon")
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json"))
starters = ["Pyrobyte", "Debuglin", "Minimox"]
choice = starters[0] # replace with user's choice (index 0/1/2)
@ -122,60 +116,12 @@ 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, show challenge proposal with Accept / Decline / Reroll, then write:
```python
import json, os, random
from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
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).
On match, update `active.json` (buddy_id, reset session_xp, set challenge).
Show challenge proposal with Accept / Decline / Reroll.
---
@ -228,79 +174,32 @@ ShadowBit (🔒) cannot be defeated — redirect to catch.
Read active encounter. If none: "No active encounter."
**Immediately set `catch_pending = True`** on the encounter to suppress auto-resolve
while the weakening Q&A is in progress:
```python
import json, os
BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon")
enc_file = f"{BUDDYMON_DIR}/encounters.json"
encounters = json.load(open(enc_file))
enc = encounters.get("active_encounter")
if enc:
enc["catch_pending"] = True
encounters["active_encounter"] = enc
json.dump(encounters, open(enc_file, "w"), indent=2)
```
**If `enc.get("wounded")` is True (strength already at 5%), skip all weakening
Q&A and go straight to the catch roll — do not ask, just throw.**
Otherwise, show strength and weakening status. Explain weaken actions:
Show strength and weakening status. Explain weaken actions:
- Write a failing test → -20% strength
- Isolate reproduction case → -20% strength
- Add documenting comment → -10% strength
Ask which weakening actions have been done. Apply reductions to `current_strength`.
Catch roll (clear `catch_pending` before rolling — success clears encounter, failure
leaves it active without the flag so auto-resolve resumes naturally):
Catch roll:
```python
import json, os, random, glob
import json, os, random
from datetime import datetime, timezone
from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
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"))
# PLUGIN_ROOT is not always set when skills run via Bash heredoc — search known paths
def find_catalog():
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
candidates = [
Path(plugin_root) / "lib" / "catalog.json" if plugin_root else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
]
for p in candidates:
if p and p.exists():
return json.load(open(p))
raise FileNotFoundError("buddymon catalog not found — check plugin installation")
catalog = find_catalog()
enc_file = BUDDYMON_DIR / "encounters.json"
active_file = BUDDYMON_DIR / "active.json"
roster_file = BUDDYMON_DIR / "roster.json"
enc_file = f"{BUDDYMON_DIR}/encounters.json"
active_file = f"{BUDDYMON_DIR}/active.json"
roster_file = f"{BUDDYMON_DIR}/roster.json"
encounters = json.load(open(enc_file))
active = json.load(open(active_file))
roster = json.load(open(roster_file))
# Buddy lookup: prefer per-session file, fall back to active.json
SESSION_KEY = str(os.getpgrp())
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
try:
session_state = json.load(open(SESSION_FILE))
buddy_id = session_state.get("buddymon_id")
except Exception:
buddy_id = None
if not buddy_id:
active = json.load(open(active_file))
buddy_id = active.get("buddymon_id")
enc = encounters.get("active_encounter")
# catch_pending is cleared by the PostToolUse hook after it fires on this
# Bash run — do NOT clear it here or the hook will see it already gone and
# may auto-resolve the encounter on the same run as the catch attempt.
buddy_id = active.get("buddymon_id")
buddy_data = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id) or {})
@ -321,13 +220,6 @@ if success:
"caught_at": datetime.now(timezone.utc).isoformat(),
}
roster.setdefault("owned", {})[enc["id"]] = caught_entry
# Write XP to session file if it exists, otherwise active.json
try:
ss = json.load(open(SESSION_FILE))
ss["session_xp"] = ss.get("session_xp", 0) + xp
json.dump(ss, open(SESSION_FILE, "w"), indent=2)
except Exception:
active = json.load(open(active_file))
active["session_xp"] = active.get("session_xp", 0) + xp
json.dump(active, open(active_file, "w"), indent=2)
if buddy_id and buddy_id in roster.get("owned", {}):
@ -340,10 +232,6 @@ if success:
json.dump(encounters, open(enc_file, "w"), indent=2)
print(f"caught:{xp}")
else:
# Leave catch_pending as-is — the PostToolUse hook clears it after this
# Bash run completes, giving one full clean run before auto-resolve resumes.
encounters["active_encounter"] = enc
json.dump(encounters, open(enc_file, "w"), indent=2)
print(f"failed:{int(catch_rate * 100)}")
```
@ -371,168 +259,8 @@ Read roster and display:
🌐 CORSCurse — caught 2026-03-28
❓ ??? — [n] more creatures to discover...
🗺️ Language Affinities
──────────────────────────────────────────
🛠️ Python comfortable (Lv.2 · 183 XP)
📖 TypeScript familiar (Lv.1 · 72 XP)
🔭 Rust discovering (Lv.0 · 9 XP)
```
Tier emoji mapping:
- 🔭 discovering (0 XP)
- 📖 familiar (50 XP)
- 🛠️ comfortable (150 XP)
- ⚡ proficient (350 XP)
- 🎯 expert (700 XP)
- 👑 master (1200 XP)
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
from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
active = json.load(open(BUDDYMON_DIR / "active.json"))
roster = json.load(open(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`.
The statusline shows active buddy + level + session XP, and highlights any
active encounter in red:
```
🐾 Debuglin Lv.90 · +45xp ⚔ 💀 NullWraith [60%]
```
Run this Python to install:
```python
import json, os
from pathlib import Path
SETTINGS = Path.home() / ".claude" / "settings.json"
# Prefer the stable user-local copy installed by install.sh
_script_candidates = [
Path.home() / ".claude/buddymon/statusline.sh",
Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "")) / "lib/statusline.sh" if os.environ.get("CLAUDE_PLUGIN_ROOT") else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/statusline.sh",
]
SCRIPT = next(str(p) for p in _script_candidates if p and p.exists())
settings = json.load(open(SETTINGS))
if settings.get("statusLine"):
print("⚠️ A statusLine is already configured. Replace it? (y/n)")
# ask user — if no, abort
# if yes, proceed
pass
settings["statusLine"] = {
"type": "command",
"command": f"bash {SCRIPT}",
}
json.dump(settings, open(SETTINGS, "w"), indent=2)
print(f"✅ Buddymon statusline installed. Reload Claude Code to activate.")
```
If a `statusLine` is already set, show the existing command and ask before replacing.
---
## `help`
@ -544,5 +272,4 @@ If a `statusLine` is already set, show the existing command and ask before repla
/buddymon fight — fight active encounter
/buddymon catch — catch active encounter
/buddymon roster — view full roster
/buddymon statusline — install statusline widget
```