feat: language mascot system + script-first architecture (v0.2.0)

Language mascots:
- 11 mascots across common/uncommon/rare/legendary tiers (Pythia, Asynclet,
  Bashling, Goroutling, Typeling, Vueling, Querion, Ferrix, Perlius,
  Cobolithon, Lispling)
- Full evolution chains for all mascots (16 evolutions total in catalog)
- Spawn via PostToolUse after language affinity milestones; probability
  scales with affinity level; only fires with no active encounter
- Passive strength reduction: each Write/Edit in the mascot's language
  ticks current_strength down (floor 5%, triggers re-announcement)
- Mascot-aware catch formula: base_rate + affinity_bonus (6% per level) +
  weakness_bonus + soft element gating via existing player_elements
- Language-themed weakening menu and catch failure messages in CLI
- Caught mascots stored as type="caught_language_mascot"; assignable as buddy
- UserPromptSubmit uses distinct 🦎 announcement with language context

Roster display:
- New "Language Mascots" section between core buddymon and caught bug monsters
- Language affinity table marks languages with spawnable mascots (🦎)
- Discovery counter now tracks both bug monsters and mascots separately

Veritarch (third evolution):
- Debuglin → Verifex (Lv.100) → Veritarch (Lv.200)
- TYPE FORTRESS / INVARIANT PROOF / ZERO FLAKE challenges
- xp_multiplier 1.7, catch_rate 0.90

Script-first architecture:
- All game logic extracted to lib/cli.py (~850 lines); SKILL.md is now
  a ~55-line relay — 88% token reduction per invocation
- CLI emits [INPUT_NEEDED] and [HAIKU_NEEDED] markers for interactive flows
- PostToolUse hook re-emits CLI stdout as additionalContext for inline display

Session XP fix:
- statusline.sh and session state now read from sessions/<pgrp>.json
  (per-window) with fallback to active.json; fixes stale XP in statusline
This commit is contained in:
pyr0ball 2026-04-10 01:31:51 -07:00
parent b3b1813e9c
commit 457276e302
7 changed files with 2490 additions and 618 deletions

View file

@ -108,6 +108,40 @@ LANGUAGE_TIERS = [
(1200, "master"), (1200, "master"),
] ]
# Maps each known language to its elemental type.
# Elements: systems, dynamic, typed, shell, web, data
LANGUAGE_ELEMENTS: dict[str, str] = {
"Python": "dynamic",
"JavaScript": "dynamic",
"TypeScript": "typed",
"JavaScript/React": "web",
"TypeScript/React": "web",
"Ruby": "dynamic",
"Go": "systems",
"Rust": "systems",
"C": "systems",
"C++": "systems",
"Java": "typed",
"C#": "typed",
"Swift": "typed",
"Kotlin": "typed",
"PHP": "web",
"Lua": "dynamic",
"Elixir": "dynamic",
"Haskell": "typed",
"OCaml": "typed",
"Clojure": "dynamic",
"R": "data",
"Julia": "data",
"Shell": "shell",
"SQL": "data",
"HTML": "web",
"CSS": "web",
"SCSS": "web",
"Vue": "web",
"Svelte": "web",
}
def _tier_for_xp(xp: int) -> tuple[int, str]: def _tier_for_xp(xp: int) -> tuple[int, str]:
"""Return (level_index, tier_label) for a given XP total.""" """Return (level_index, tier_label) for a given XP total."""
@ -147,6 +181,47 @@ def add_language_affinity(lang: str, xp_amount: int) -> tuple[bool, str, str]:
return leveled_up, old_tier, new_tier return leveled_up, old_tier, new_tier
def get_player_elements(min_level: int = 2) -> set[str]:
"""Return the set of elements the player has meaningful affinity in (level >= min_level = 'comfortable'+)."""
roster = load_json(BUDDYMON_DIR / "roster.json")
elements: set[str] = set()
for lang, entry in roster.get("language_affinities", {}).items():
if entry.get("level", 0) >= min_level:
elem = LANGUAGE_ELEMENTS.get(lang)
if elem:
elements.add(elem)
return elements
def element_multiplier(encounter: dict, player_elements: set[str]) -> float:
"""Return a wound/resolve rate multiplier based on elemental matchup.
super effective (+25%): player has an element the monster is weak_against
not very effective (-15%): player element is in monster's strong_against
immune (-30%): all player elements are in monster's immune_to
mixed (+5%): advantage and disadvantage cancel, slight net bonus
"""
if not player_elements:
return 1.0
weak = set(encounter.get("weak_against", []))
strong = set(encounter.get("strong_against", []))
immune = set(encounter.get("immune_to", []))
has_advantage = bool(player_elements & weak)
has_disadvantage = bool(player_elements & strong)
fully_immune = bool(immune) and player_elements.issubset(immune)
if fully_immune:
return 0.70
elif has_advantage and has_disadvantage:
return 1.05 # mixed — slight net bonus
elif has_advantage:
return 1.25
elif has_disadvantage:
return 0.85
return 1.0
def get_languages_seen(): def get_languages_seen():
session = load_json(BUDDYMON_DIR / "session.json") session = load_json(BUDDYMON_DIR / "session.json")
return set(session.get("languages_seen", [])) return set(session.get("languages_seen", []))
@ -278,36 +353,6 @@ def compute_strength(monster: dict, elapsed_minutes: float) -> int:
return min(100, int(base * 1.8)) return min(100, int(base * 1.8))
def format_encounter_message(monster: dict, strength: int, buddy_display: str) -> str:
rarity_stars = {"very_common": "★☆☆☆☆", "common": "★★☆☆☆",
"uncommon": "★★★☆☆", "rare": "★★★★☆", "legendary": "★★★★★"}
stars = rarity_stars.get(monster.get("rarity", "common"), "★★☆☆☆")
defeatable = monster.get("defeatable", True)
catch_note = "[catchable]" if monster.get("catchable") else ""
fight_note = "" if defeatable else "⚠️ CANNOT BE DEFEATED — catch only"
catchable_str = "[catchable · catch only]" if not defeatable else f"[{monster.get('rarity','?')} · {catch_note}]"
lines = [
f"\n💀 **{monster['display']} appeared!** {catchable_str}",
f" Strength: {strength}% · Rarity: {stars}",
f" *{monster.get('flavor', '')}*",
"",
]
if fight_note:
lines.append(f" {fight_note}")
lines.append("")
lines += [
f" **{buddy_display}** is ready to battle!",
"",
" `[FIGHT]` Beat the bug → your buddy defeats it → XP reward",
" `[CATCH]` Weaken it first (write a test, isolate repro, add comment) → attempt catch",
" `[FLEE]` Ignore → monster grows stronger",
"",
" Use `/buddymon fight` or `/buddymon catch` to weaken + catch it.",
]
return "\n".join(lines)
def match_event_encounter(command: str, output: str, session: dict, catalog: dict): def match_event_encounter(command: str, output: str, session: dict, catalog: dict):
"""Detect non-error-based encounters: git ops, installs, test results.""" """Detect non-error-based encounters: git ops, installs, test results."""
@ -355,12 +400,73 @@ def spawn_encounter(enc: dict) -> None:
"catchable": enc.get("catchable", True), "catchable": enc.get("catchable", True),
"defeatable": enc.get("defeatable", True), "defeatable": enc.get("defeatable", True),
"xp_reward": enc.get("xp_reward", 50), "xp_reward": enc.get("xp_reward", 50),
"rarity": enc.get("rarity", "common"),
"weak_against": enc.get("weak_against", []),
"strong_against": enc.get("strong_against", []),
"immune_to": enc.get("immune_to", []),
"rival": enc.get("rival"),
# Language mascot fields (no-op for regular encounters)
"encounter_type": enc.get("type", "event"),
"language": enc.get("language"),
"passive_reduction_per_use": enc.get("passive_reduction_per_use", 0),
"weakened_by": [], "weakened_by": [],
"announced": False, "announced": False,
} }
set_active_encounter(encounter) set_active_encounter(encounter)
def try_spawn_language_mascot(lang: str, affinity_level: int, catalog: dict) -> dict | None:
"""Try to spawn a language mascot for lang at the given affinity level.
Probability = base_rate * (1 + affinity_level * affinity_scale).
Only fires if no active encounter exists.
Returns the mascot dict if spawned, None otherwise.
"""
for _mid, mascot in catalog.get("language_mascots", {}).items():
if mascot.get("language") != lang:
continue
spawn_cfg = mascot.get("spawn", {})
if affinity_level < spawn_cfg.get("min_affinity_level", 1):
continue
base_rate = spawn_cfg.get("base_rate", 0.02)
affinity_scale = spawn_cfg.get("affinity_scale", 0.3)
prob = base_rate * (1 + affinity_level * affinity_scale)
if random.random() < prob:
spawn_encounter(mascot)
return mascot
return None
def apply_passive_mascot_reduction(lang: str) -> bool:
"""If the active encounter is a language mascot for lang, tick down its strength.
Returns True if a reduction was applied.
"""
enc_file = BUDDYMON_DIR / "encounters.json"
enc_data = load_json(enc_file)
enc = enc_data.get("active_encounter")
if not enc:
return False
if enc.get("encounter_type") != "language_mascot":
return False
if enc.get("language") != lang:
return False
reduction = enc.get("passive_reduction_per_use", 5)
old_strength = enc.get("current_strength", enc.get("base_strength", 50))
new_strength = max(5, old_strength - reduction)
enc["current_strength"] = new_strength
# Flag as wounded (and trigger re-announcement) when freshly floored
if new_strength <= 5 and not enc.get("wounded"):
enc["wounded"] = True
enc["announced"] = False
enc_data["active_encounter"] = enc
save_json(enc_file, enc_data)
return True
def format_new_language_message(lang: str, buddy_display: str) -> str: def format_new_language_message(lang: str, buddy_display: str) -> str:
return ( return (
f"\n🗺️ **New language spotted: {lang}!**\n" f"\n🗺️ **New language spotted: {lang}!**\n"
@ -449,6 +555,19 @@ def main():
if isinstance(b, dict) and b.get("type") == "text" if isinstance(b, dict) and b.get("type") == "text"
) )
# ── Buddymon CLI display: surface output as additionalContext ─────────
# Bash tool output is collapsed by default in CC's UI. When the skill
# runs the CLI, re-emit stdout here so it's always visible inline.
if "buddymon/cli.py" in command or "buddymon cli.py" in command:
if output.strip():
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": output.strip(),
}
}))
sys.exit(0) # Skip XP tracking / error scanning for game commands
existing = get_active_encounter() existing = get_active_encounter()
if existing: if existing:
@ -481,6 +600,7 @@ def main():
roster = load_json(BUDDYMON_DIR / "roster.json") roster = load_json(BUDDYMON_DIR / "roster.json")
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1) buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
level_scale = 1.0 + (buddy_level / 100) * 0.25 level_scale = 1.0 + (buddy_level / 100) * 0.25
elem_mult = element_multiplier(existing, get_player_elements())
# Wound cooldown: skip if another session wounded within 30s # Wound cooldown: skip if another session wounded within 30s
last_wound = existing.get("last_wounded_at", "") last_wound = existing.get("last_wounded_at", "")
@ -495,7 +615,7 @@ def main():
pass pass
if existing.get("wounded"): if existing.get("wounded"):
resolve_rate = min(0.70, RESOLVE_RATES.get(rarity, 0.28) * level_scale) resolve_rate = min(0.70, RESOLVE_RATES.get(rarity, 0.28) * level_scale * elem_mult)
if wound_cooldown_ok and random.random() < resolve_rate: if wound_cooldown_ok and random.random() < resolve_rate:
xp, display = auto_resolve_encounter(existing, buddy_id) xp, display = auto_resolve_encounter(existing, buddy_id)
messages.append( messages.append(
@ -503,7 +623,7 @@ def main():
f" {buddy_display} gets partial XP: +{xp}\n" f" {buddy_display} gets partial XP: +{xp}\n"
) )
else: else:
wound_rate = min(0.85, WOUND_RATES.get(rarity, 0.40) * level_scale) wound_rate = min(0.85, WOUND_RATES.get(rarity, 0.40) * level_scale * elem_mult)
if wound_cooldown_ok and random.random() < wound_rate: if wound_cooldown_ok and random.random() < wound_rate:
wound_encounter() wound_encounter()
# else: monster still present, no message — don't spam every tool call # else: monster still present, no message — don't spam every tool call
@ -527,6 +647,11 @@ def main():
"catchable": target.get("catchable", True), "catchable": target.get("catchable", True),
"defeatable": target.get("defeatable", True), "defeatable": target.get("defeatable", True),
"xp_reward": target.get("xp_reward", 50), "xp_reward": target.get("xp_reward", 50),
"rarity": target.get("rarity", "common"),
"weak_against": target.get("weak_against", []),
"strong_against": target.get("strong_against", []),
"immune_to": target.get("immune_to", []),
"rival": target.get("rival"),
"weakened_by": [], "weakened_by": [],
"announced": False, "announced": False,
} }
@ -549,13 +674,16 @@ def main():
ext = os.path.splitext(file_path)[1].lower() ext = os.path.splitext(file_path)[1].lower()
lang = KNOWN_EXTENSIONS.get(ext) lang = KNOWN_EXTENSIONS.get(ext)
if lang: if lang:
# Session-level "first encounter" bonus # Session-level "first encounter" bonus — only announce if genuinely new
# (zero affinity XP). Languages already in the roster just get quiet XP.
seen = get_languages_seen() seen = get_languages_seen()
if lang not in seen: if lang not in seen:
add_language_seen(lang) add_language_seen(lang)
add_session_xp(15) affinity = get_language_affinity(lang)
msg = format_new_language_message(lang, buddy_display) if affinity.get("xp", 0) == 0:
messages.append(msg) add_session_xp(15)
msg = format_new_language_message(lang, buddy_display)
messages.append(msg)
# Persistent affinity XP — always accumulates # Persistent affinity XP — always accumulates
leveled_up, old_tier, new_tier = add_language_affinity(lang, 3) leveled_up, old_tier, new_tier = add_language_affinity(lang, 3)
@ -564,6 +692,14 @@ def main():
msg = format_language_levelup_message(lang, old_tier, new_tier, affinity["xp"], buddy_display) msg = format_language_levelup_message(lang, old_tier, new_tier, affinity["xp"], buddy_display)
messages.append(msg) messages.append(msg)
# Passive mascot reduction: coding in this language weakens its encounter
apply_passive_mascot_reduction(lang)
# Language mascot spawn: try after affinity update, only if no active encounter
if not get_active_encounter():
affinity = get_language_affinity(lang)
try_spawn_language_mascot(lang, affinity.get("level", 0), catalog)
# TestSpecter: editing a test file with no active encounter # TestSpecter: editing a test file with no active encounter
if not get_active_encounter(): if not get_active_encounter():
enc = match_test_file_encounter(file_path, catalog) enc = match_test_file_encounter(file_path, catalog)

View file

@ -88,48 +88,89 @@ def main():
else: else:
catalog = load_json(CATALOG_FILE) catalog = load_json(CATALOG_FILE)
monster = catalog.get("bug_monsters", {}).get(enc.get("id", ""), {})
rarity = monster.get("rarity", "common")
rarity_stars = { rarity_stars = {
"very_common": "★☆☆☆☆", "common": "★★☆☆☆", "very_common": "★☆☆☆☆", "common": "★★☆☆☆",
"uncommon": "★★★☆☆", "rare": "★★★★☆", "legendary": "★★★★★", "uncommon": "★★★☆☆", "rare": "★★★★☆", "legendary": "★★★★★",
} }
stars = rarity_stars.get(rarity, "★★☆☆☆")
strength = enc.get("current_strength", 50) strength = enc.get("current_strength", 50)
defeatable = enc.get("defeatable", True) is_mascot = enc.get("encounter_type") == "language_mascot"
catchable = enc.get("catchable", True)
flavor = monster.get("flavor", "")
if enc.get("wounded"): if is_mascot:
# Wounded re-announcement — urgent, catch-or-lose framing mascot_data = catalog.get("language_mascots", {}).get(enc.get("id", ""), {})
lines = [ rarity = mascot_data.get("rarity", "common")
f"\n🩹 **{enc['display']} is wounded and fleeing!**", stars = rarity_stars.get(rarity, "★★☆☆☆")
f" Strength: {strength}% · This is your last chance to catch it.", flavor = mascot_data.get("flavor", "")
"", lang = enc.get("language") or mascot_data.get("language", "")
f" **{buddy_display}** is ready — move fast!", assignable = mascot_data.get("assignable", False)
"",
" `[CATCH]` → `/buddymon catch` (near-guaranteed at 5% strength)", if enc.get("wounded"):
" `[IGNORE]` → it flees on the next clean run", lines = [
] f"\n🩹 **{enc['display']} is weakened and retreating!**",
f" Strength: {strength}% · Your {lang} work has worn it down.",
"",
f" **{buddy_display}** senses the opportunity — act now!",
"",
" `[CATCH]` → `/buddymon catch` (near-guaranteed at 5% strength)",
" `[IGNORE]` → it fades on your next edit",
]
else:
lines = [
f"\n🦎 **{enc['display']} appeared!** [language mascot · {rarity}]",
f" Language: {lang} · Strength: {strength}% · Rarity: {stars}",
]
if flavor:
lines.append(f" *{flavor}*")
if assignable:
lines.append(f" ✓ Catchable and assignable as buddy — has its own challenges.")
lines += [
"",
f" **{buddy_display}** is intrigued!",
"",
f" `[CATCH]` Code more in {lang} to weaken it → `/buddymon catch`",
" `[FLEE]` Ignore → it retreats as your affinity fades",
]
else: else:
# Normal first appearance monster = catalog.get("bug_monsters", {}).get(enc.get("id", ""), {})
catchable_str = "[catchable · catch only]" if not defeatable else f"[{rarity} · {'catchable' if catchable else ''}]" rarity = monster.get("rarity", "common")
lines = [ stars = rarity_stars.get(rarity, "★★☆☆☆")
f"\n💀 **{enc['display']} appeared!** {catchable_str}", defeatable = enc.get("defeatable", True)
f" Strength: {strength}% · Rarity: {stars}", catchable = enc.get("catchable", True)
] flavor = monster.get("flavor", "")
if flavor:
lines.append(f" *{flavor}*") if enc.get("wounded"):
if not defeatable: lines = [
lines.append(" ⚠️ CANNOT BE DEFEATED — catch only") f"\n🩹 **{enc['display']} is wounded and fleeing!**",
lines += [ f" Strength: {strength}% · This is your last chance to catch it.",
"", "",
f" **{buddy_display}** is ready to battle!", f" **{buddy_display}** is ready — move fast!",
"", "",
" `[FIGHT]` Fix the bug → `/buddymon fight` to claim XP", " `[CATCH]` → `/buddymon catch` (near-guaranteed at 5% strength)",
" `[CATCH]` Weaken first (test/repro/comment) → `/buddymon catch`", " `[IGNORE]` → it flees on the next clean run",
" `[FLEE]` Ignore → monster grows stronger", ]
] else:
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}*")
rival_id = enc.get("rival") or monster.get("rival")
if rival_id:
rival_entry = (catalog.get("bug_monsters", {}).get(rival_id)
or catalog.get("event_encounters", {}).get(rival_id, {}))
rival_display = rival_entry.get("display", rival_id)
lines.append(f" ⚔️ Eternal rival of **{rival_display}** — catch both to settle the debate.")
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) msg = "\n".join(lines)
print(json.dumps({ print(json.dumps({

View file

@ -278,11 +278,15 @@ if not os.path.exists(log_path):
print(" Created hook_debug.log") print(" Created hook_debug.log")
PYEOF PYEOF
# Copy statusline script to stable user-local path # Copy lib scripts to stable user-local path (accessible even without CLAUDE_PLUGIN_ROOT)
cp "${REPO_DIR}/lib/statusline.sh" "${BUDDYMON_DIR}/statusline.sh" cp "${REPO_DIR}/lib/statusline.sh" "${BUDDYMON_DIR}/statusline.sh"
chmod +x "${BUDDYMON_DIR}/statusline.sh" chmod +x "${BUDDYMON_DIR}/statusline.sh"
ok "Installed statusline.sh → ${BUDDYMON_DIR}/statusline.sh" ok "Installed statusline.sh → ${BUDDYMON_DIR}/statusline.sh"
cp "${REPO_DIR}/lib/cli.py" "${BUDDYMON_DIR}/cli.py"
chmod +x "${BUDDYMON_DIR}/cli.py"
ok "Installed cli.py → ${BUDDYMON_DIR}/cli.py"
# Install statusline into settings.json if not already configured # Install statusline into settings.json if not already configured
python3 << PYEOF python3 << PYEOF
import json import json

File diff suppressed because it is too large Load diff

881
lib/cli.py Normal file
View file

@ -0,0 +1,881 @@
#!/usr/bin/env python3
"""Buddymon CLI — all game logic lives here.
SKILL.md is a thin orchestrator that runs this script and handles
one round of user I/O when the script emits a marker:
[INPUT_NEEDED: <prompt>] ask user, re-run with answer appended
[HAIKU_NEEDED: <json>] spawn Haiku agent to parse NL input, re-run with result
Everything else is deterministic: no LLM reasoning, no context cost.
"""
import sys
import json
import os
import random
from pathlib import Path
from datetime import datetime, timezone
# ── Paths ─────────────────────────────────────────────────────────────────────
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
def find_catalog() -> dict:
pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
candidates = [
Path(pr) / "lib" / "catalog.json" if pr else None,
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.0/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
]
for p in candidates:
if p and p.exists():
return json.loads(p.read_text())
raise FileNotFoundError("buddymon catalog not found — check plugin installation")
# ── State helpers ─────────────────────────────────────────────────────────────
def load(path) -> dict:
try:
return json.loads(Path(path).read_text())
except Exception:
return {}
def save(path, data):
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(json.dumps(data, indent=2))
def session_file() -> Path:
key = str(os.getpgrp())
return BUDDYMON_DIR / "sessions" / f"{key}.json"
def get_buddy_id() -> str | None:
try:
ss = load(session_file())
bid = ss.get("buddymon_id")
if bid:
return bid
except Exception:
pass
return load(BUDDYMON_DIR / "active.json").get("buddymon_id")
def get_session_xp() -> int:
try:
return load(session_file()).get("session_xp", 0)
except Exception:
return load(BUDDYMON_DIR / "active.json").get("session_xp", 0)
def add_xp(amount: int):
buddy_id = get_buddy_id()
sf = session_file()
try:
ss = load(sf)
ss["session_xp"] = ss.get("session_xp", 0) + amount
save(sf, ss)
except Exception:
active = load(BUDDYMON_DIR / "active.json")
active["session_xp"] = active.get("session_xp", 0) + amount
save(BUDDYMON_DIR / "active.json", active)
if buddy_id:
roster = load(BUDDYMON_DIR / "roster.json")
if buddy_id in roster.get("owned", {}):
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount
save(BUDDYMON_DIR / "roster.json", roster)
# ── Display helpers ───────────────────────────────────────────────────────────
def xp_bar(current: int, maximum: int, width: int = 20) -> str:
if maximum <= 0:
return "" * width
filled = int(width * min(current, maximum) / maximum)
return "" * filled + "" * (width - filled)
def compute_level(total_xp: int) -> int:
level = 1
while total_xp >= level * 100:
level += 1
return level
# ── Element system (mirrors post-tool-use.py) ─────────────────────────────────
LANGUAGE_ELEMENTS = {
"Python": "dynamic", "Ruby": "dynamic", "JavaScript": "dynamic",
"TypeScript": "typed", "Java": "typed", "C#": "typed",
"Go": "typed", "Rust": "systems", "C": "systems",
"C++": "systems", "Bash": "shell", "Shell": "shell",
"PowerShell": "shell", "HTML": "web", "CSS": "web",
"Vue": "web", "React": "web", "SQL": "data",
"GraphQL": "data", "JSON": "data", "YAML": "data",
"Kotlin": "typed", "Swift": "typed", "PHP": "dynamic",
"Perl": "dynamic", "R": "data", "Julia": "data",
"TOML": "data", "Dockerfile": "systems",
}
ELEMENT_MATCHUPS = {
# (attacker_element, defender_element): multiplier
("dynamic", "typed"): 1.25, ("dynamic", "systems"): 0.85,
("typed", "shell"): 1.25, ("typed", "dynamic"): 0.85,
("systems", "web"): 1.25, ("systems", "typed"): 0.85,
("shell", "dynamic"): 1.25, ("shell", "data"): 0.85,
("web", "data"): 1.25, ("web", "systems"): 0.70,
("data", "dynamic"): 1.25, ("data", "web"): 0.85,
}
LANGUAGE_AFFINITY_TIERS = [
(0, "discovering"),
(50, "familiar"),
(150, "comfortable"),
(350, "proficient"),
(700, "expert"),
(1200, "master"),
]
TIER_EMOJI = {
"discovering": "🔭", "familiar": "📖", "comfortable": "🛠️",
"proficient": "", "expert": "🎯", "master": "👑",
}
def get_player_elements(roster: dict, min_level: int = 2) -> set:
"""Return the set of elements the player has meaningful affinity in."""
elements = set()
for lang, data in roster.get("language_affinities", {}).items():
if data.get("level", 0) >= min_level:
elem = LANGUAGE_ELEMENTS.get(lang)
if elem:
elements.add(elem)
return elements
def element_multiplier(enc: dict, player_elements: set) -> float:
"""Calculate catch rate bonus from element matchup."""
enc_elems = set(enc.get("weak_against", []))
immune_elems = set(enc.get("immune_to", []))
strong_elems = set(enc.get("strong_against", []))
if player_elements & immune_elems:
return 0.70
if player_elements & enc_elems:
return 1.25
if player_elements & strong_elems:
return 0.85
return 1.0
# ── Subcommands ───────────────────────────────────────────────────────────────
def cmd_status():
active = load(BUDDYMON_DIR / "active.json")
roster = load(BUDDYMON_DIR / "roster.json")
enc_data = load(BUDDYMON_DIR / "encounters.json")
if not roster.get("starter_chosen"):
print("No starter chosen yet. Run: /buddymon start")
return
buddy_id = get_buddy_id() or active.get("buddymon_id")
if not buddy_id:
print("No buddy assigned to this session. Run: /buddymon assign <name>")
return
sf = session_file()
try:
ss = load(sf)
session_xp = ss.get("session_xp", 0)
challenge = ss.get("challenge") or active.get("challenge")
except Exception:
session_xp = active.get("session_xp", 0)
challenge = active.get("challenge")
owned = roster.get("owned", {})
buddy = owned.get(buddy_id, {})
display = buddy.get("display", buddy_id)
total_xp = buddy.get("xp", 0)
level = buddy.get("level", compute_level(total_xp))
max_xp = level * 100
xp_in_level = total_xp % max_xp if max_xp else 0
bar = xp_bar(xp_in_level, max_xp)
ch_name = ""
if challenge:
ch_name = challenge.get("name", str(challenge)) if isinstance(challenge, dict) else str(challenge)
evolved_from = buddy.get("evolved_from", "")
prestige = f" (prestige from {evolved_from})" if evolved_from else ""
print("╔══════════════════════════════════════════╗")
print("║ 🐾 Buddymon ║")
print("╠══════════════════════════════════════════╣")
lv_str = f"Lv.{level}{prestige}"
print(f"║ Active: {display}")
print(f"{lv_str}")
print(f"║ XP: [{bar}] {xp_in_level}/{max_xp}")
print(f"║ Session: +{session_xp} XP")
if ch_name:
print(f"║ Challenge: {ch_name}")
print("╚══════════════════════════════════════════╝")
enc = enc_data.get("active_encounter")
if enc:
enc_display = enc.get("display", "???")
strength = enc.get("current_strength", 100)
wounded = enc.get("wounded", False)
print(f"\n⚔️ Active encounter: {enc_display} [{strength}% strength]")
if wounded:
print(" ⚠️ Wounded — `/buddymon catch` for near-guaranteed capture.")
else:
print(" Run `/buddymon fight` or `/buddymon catch`")
def cmd_fight(confirmed: bool = False):
enc_data = load(BUDDYMON_DIR / "encounters.json")
enc = enc_data.get("active_encounter")
if not enc:
print("No active encounter — it may have already been auto-resolved.")
return
display = enc.get("display", "???")
strength = enc.get("current_strength", 100)
if enc.get("id") == "ShadowBit":
print(f"⚠️ {display} cannot be defeated — use `/buddymon catch` instead.")
return
if not confirmed:
print(f"⚔️ Fighting {display} [{strength}% strength]")
print(f"\n[INPUT_NEEDED: Have you fixed the bug that triggered {display}? (yes/no)]")
return
# Confirmed — award XP and clear
xp = enc.get("xp_reward", 50)
add_xp(xp)
enc["outcome"] = "defeated"
enc["timestamp"] = datetime.now(timezone.utc).isoformat()
enc_data.setdefault("history", []).append(enc)
enc_data["active_encounter"] = None
save(BUDDYMON_DIR / "encounters.json", enc_data)
print(f"✅ Defeated {display}! +{xp} XP")
def cmd_catch(args: list[str]):
enc_data = load(BUDDYMON_DIR / "encounters.json")
enc = enc_data.get("active_encounter")
if not enc:
print("No active encounter.")
return
display = enc.get("display", "???")
current_strength = enc.get("current_strength", 100)
wounded = enc.get("wounded", False)
is_mascot = enc.get("encounter_type") == "language_mascot"
# --strength N means we already have a resolved strength value; go straight to roll
strength_override = None
if "--strength" in args:
idx = args.index("--strength")
try:
strength_override = int(args[idx + 1])
except (IndexError, ValueError):
pass
if strength_override is None and not wounded:
enc["catch_pending"] = True
enc_data["active_encounter"] = enc
save(BUDDYMON_DIR / "encounters.json", enc_data)
print(f"🎯 Catching {display} [{current_strength}% strength]")
print()
if is_mascot:
lang = enc.get("language", "this language")
print("Weakening actions (language mascots weaken through coding):")
print(f" 1) Write 10+ lines in {lang} → -20% strength")
print(f" 2) Refactor existing {lang} code → -15% strength")
print(f" 3) Add type annotations / docs → -10% strength")
else:
print("Weakening actions:")
print(" 1) Write a failing test → -20% strength")
print(" 2) Isolate a minimal repro case → -20% strength")
print(" 3) Add a documenting comment → -10% strength")
print()
print("[INPUT_NEEDED: Which have you done? Enter numbers (e.g. \"1 2\"), describe in words, or \"none\" to throw now]")
return
# Resolve to a final strength and roll
final_strength = strength_override if strength_override is not None else current_strength
enc["catch_pending"] = False
roster = load(BUDDYMON_DIR / "roster.json")
buddy_id = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id")
try:
catalog = find_catalog()
except Exception:
catalog = {}
buddy_data = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id) or {})
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
weakness_bonus = (100 - final_strength) / 100 * 0.4
player_elems = get_player_elements(roster)
elem_mult = element_multiplier(enc, player_elems)
if is_mascot:
# Mascot formula: base catch from mascot catalog + affinity bonus (6% per level)
mascot_data = catalog.get("language_mascots", {}).get(enc.get("id", ""), {})
lang = enc.get("language") or mascot_data.get("language", "")
lang_affinity = roster.get("language_affinities", {}).get(lang, {})
affinity_level = lang_affinity.get("level", 0)
base_catch = mascot_data.get("base_stats", {}).get("catch_rate", 0.35)
affinity_bonus = affinity_level * 0.06
catch_rate = min(0.95, (base_catch + affinity_bonus + weakness_bonus + buddy_level * 0.01) * elem_mult)
else:
base_catch = buddy_data.get("base_stats", {}).get("catch_rate", 0.4)
catch_rate = min(0.95, (base_catch + weakness_bonus + buddy_level * 0.02) * elem_mult)
success = random.random() < catch_rate
if success:
xp = int(enc.get("xp_reward", 50) * 1.5)
if is_mascot:
lang = enc.get("language", "")
caught_entry = {
"id": enc["id"],
"display": enc["display"],
"type": "caught_language_mascot",
"language": lang,
"level": 1,
"xp": 0,
"caught_at": datetime.now(timezone.utc).isoformat(),
}
else:
caught_entry = {
"id": enc["id"],
"display": enc["display"],
"type": "caught_bug_monster",
"level": 1,
"xp": 0,
"caught_at": datetime.now(timezone.utc).isoformat(),
}
roster.setdefault("owned", {})[enc["id"]] = caught_entry
if buddy_id and buddy_id in roster.get("owned", {}):
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp
save(BUDDYMON_DIR / "roster.json", roster)
add_xp(xp)
enc["outcome"] = "caught"
enc["timestamp"] = datetime.now(timezone.utc).isoformat()
enc_data.setdefault("history", []).append(enc)
enc_data["active_encounter"] = None
save(BUDDYMON_DIR / "encounters.json", enc_data)
elem_hint = ""
if elem_mult > 1.0:
if is_mascot:
elem_hint = " ⚡ Affinity resonance!"
else:
elem_hint = " ⚡ Super effective!"
elif elem_mult < 0.85:
elem_hint = " 🛡️ Resistant."
if is_mascot:
lang_disp = enc.get("language", "")
print(f"🎉 Caught {display}!{elem_hint} +{xp} XP")
if lang_disp:
print(f" Now assign it as your buddy to unlock {lang_disp} challenges.")
else:
print(f"🎉 Caught {display}!{elem_hint} +{xp} XP (1.5× catch bonus)")
else:
enc_data["active_encounter"] = enc
save(BUDDYMON_DIR / "encounters.json", enc_data)
if is_mascot:
lang = enc.get("language", "this language")
print(f"💨 {display} slipped away! Keep coding in {lang} to weaken it further. ({int(catch_rate * 100)}% catch rate)")
else:
print(f"💨 {display} broke free! Weaken it further and try again. ({int(catch_rate * 100)}% catch rate)")
def cmd_roster():
roster = load(BUDDYMON_DIR / "roster.json")
owned = roster.get("owned", {})
try:
catalog = find_catalog()
except Exception:
catalog = {}
mascot_catalog = catalog.get("language_mascots", {})
core_buddymon = {k: v for k, v in owned.items()
if v.get("type") not in ("caught_bug_monster", "caught_language_mascot")}
mascots_owned = {k: v for k, v in owned.items() if v.get("type") == "caught_language_mascot"}
caught = {k: v for k, v in owned.items() if v.get("type") == "caught_bug_monster"}
print("🐾 Your Buddymon")
print("" * 44)
for bid, b in core_buddymon.items():
lvl = b.get("level", 1)
total_xp = b.get("xp", 0)
max_xp = lvl * 100
xp_in_level = total_xp % max_xp if max_xp else 0
bar = xp_bar(xp_in_level, max_xp)
display = b.get("display", bid)
affinity = b.get("affinity", "")
evo_note = f"{b['evolved_into']}" if b.get("evolved_into") else ""
print(f" {display} Lv.{lvl} {affinity}{evo_note}")
print(f" XP: [{bar}] {xp_in_level}/{max_xp}")
if mascots_owned:
print()
print("🦎 Language Mascots")
print("" * 44)
for bid, b in sorted(mascots_owned.items(), key=lambda x: x[1].get("caught_at", ""), reverse=True):
display = b.get("display", bid)
lang = b.get("language", "")
caught_at = b.get("caught_at", "")[:10]
lvl = b.get("level", 1)
mc = mascot_catalog.get(bid, {})
assignable = mc.get("assignable", False)
assign_note = " ✓ assignable as buddy" if assignable else ""
evo_chains = mc.get("evolutions", [])
evo_note = f" → evolves at Lv.{evo_chains[0]['level']}" if evo_chains else ""
print(f" {display} [{lang}] Lv.{lvl}{assign_note}{evo_note}")
print(f" caught {caught_at}")
if caught:
print()
print("🏆 Caught Bug Monsters")
print("" * 44)
for bid, b in sorted(caught.items(), key=lambda x: x[1].get("caught_at", ""), reverse=True):
display = b.get("display", bid)
caught_at = b.get("caught_at", "")[:10]
print(f" {display} — caught {caught_at}")
affinities = roster.get("language_affinities", {})
if affinities:
print()
print("🗺️ Language Affinities")
print("" * 44)
for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)):
tier = data.get("tier", "discovering")
level = data.get("level", 0)
xp = data.get("xp", 0)
emoji = TIER_EMOJI.get(tier, "🔭")
elem = LANGUAGE_ELEMENTS.get(lang, "")
elem_tag = f" [{elem}]" if elem else ""
# Flag languages that have a spawnable mascot
has_mascot = any(m.get("language") == lang for m in mascot_catalog.values())
mascot_tag = " 🦎" if has_mascot and level >= 1 else ""
print(f" {emoji} {lang:<12} {tier:<12} (Lv.{level} · {xp} XP){elem_tag}{mascot_tag}")
bug_total = len(catalog.get("bug_monsters", {}))
mascot_total = len(mascot_catalog)
missing_bugs = bug_total - len(caught)
missing_mascots = mascot_total - len(mascots_owned)
if missing_bugs + missing_mascots > 0:
parts = []
if missing_bugs > 0:
parts.append(f"{missing_bugs} bug monsters")
if missing_mascots > 0:
parts.append(f"{missing_mascots} language mascots")
print(f"\n❓ ??? — {' and '.join(parts)} still to discover...")
def cmd_start(choice: str | None = None):
roster = load(BUDDYMON_DIR / "roster.json")
if roster.get("starter_chosen"):
print("Starter already chosen! Run `/buddymon status` or `/buddymon roster`.")
return
starters = {
"1": ("Pyrobyte", "🔥", "Speedrunner — loves tight deadlines and feature sprints"),
"2": ("Debuglin", "🔍", "Tester — patient, methodical, ruthless bug hunter"),
"3": ("Minimox", "✂️", "Cleaner — obsessed with fewer lines and zero linter runs"),
}
if choice is None:
print("╔══════════════════════════════════════════════════════════╗")
print("║ 🐾 Choose Your Starter Buddymon ║")
print("╠══════════════════════════════════════════════════════════╣")
for num, (name, emoji, desc) in starters.items():
print(f"║ [{num}] {emoji} {name:<10}{desc:<36}")
print("╚══════════════════════════════════════════════════════════╝")
print("\n[INPUT_NEEDED: Choose 1, 2, or 3]")
return
# Normalize choice
key = choice.strip().lower()
name_map = {v[0].lower(): k for k, v in starters.items()}
if key in name_map:
key = name_map[key]
if key not in starters:
print(f"Invalid choice '{choice}'. Please choose 1, 2, or 3.")
return
bid, emoji, _ = starters[key]
try:
catalog = find_catalog()
except Exception:
catalog = {}
buddy_cat = catalog.get("buddymon", {}).get(bid, {})
challenges = buddy_cat.get("challenges", [])
roster.setdefault("owned", {})[bid] = {
"id": bid,
"display": f"{emoji} {bid}",
"affinity": buddy_cat.get("affinity", ""),
"level": 1,
"xp": 0,
}
roster["starter_chosen"] = True
save(BUDDYMON_DIR / "roster.json", roster)
active = load(BUDDYMON_DIR / "active.json")
active["buddymon_id"] = bid
active["session_xp"] = 0
active["challenge"] = challenges[0] if challenges else None
save(BUDDYMON_DIR / "active.json", active)
sf = session_file()
try:
ss = load(sf)
except Exception:
ss = {}
ss["buddymon_id"] = bid
ss["session_xp"] = 0
ss["challenge"] = active["challenge"]
save(sf, ss)
print(f"✨ You chose {emoji} {bid}!")
if challenges:
ch = challenges[0]
ch_name = ch.get("name", str(ch)) if isinstance(ch, dict) else str(ch)
print(f" First challenge: {ch_name}")
print("\nBug monsters will appear when errors occur in your terminal.")
print("Run `/buddymon` any time to check your status.")
def cmd_assign(args: list[str]):
"""
assign list roster + [INPUT_NEEDED]
assign <name> fuzzy match show challenge [INPUT_NEEDED: accept/decline/reroll]
assign <name> --accept
assign <name> --reroll
assign <name> --resolved <buddy_id> (Haiku resolved ambiguity)
"""
roster = load(BUDDYMON_DIR / "roster.json")
owned = roster.get("owned", {})
buddymon_ids = [k for k, v in owned.items() if v.get("type") != "caught_bug_monster"]
if not buddymon_ids:
print("No Buddymon owned yet. Run `/buddymon start` first.")
return
# No name given
if not args or args[0].startswith("--"):
print("Your Buddymon:")
for bid in buddymon_ids:
b = owned[bid]
print(f" {b.get('display', bid)} Lv.{b.get('level', 1)}")
print("\n[INPUT_NEEDED: Which buddy do you want for this session?]")
return
name_input = args[0]
rest_flags = args[1:]
# --resolved bypasses fuzzy match (Haiku already picked)
if "--resolved" in rest_flags:
idx = rest_flags.index("--resolved")
try:
resolved_id = rest_flags[idx + 1]
except IndexError:
resolved_id = name_input
return cmd_assign([resolved_id] + [f for f in rest_flags if f != "--resolved" and f != resolved_id])
# Fuzzy match
query = name_input.lower()
matches = [bid for bid in buddymon_ids if query in bid.lower()]
if not matches:
print(f"No Buddymon matching '{name_input}'. Your roster:")
for bid in buddymon_ids:
print(f" {owned[bid].get('display', bid)}")
return
if len(matches) > 1:
# Emit Haiku marker to resolve ambiguity
candidates = [owned[m].get("display", m) for m in matches]
haiku_task = json.dumps({
"task": "fuzzy_match",
"input": name_input,
"candidates": candidates,
"instruction": "The user typed a partial buddy name. Which candidate do they most likely mean? Reply with ONLY the exact candidate string.",
})
print(f"[HAIKU_NEEDED: {haiku_task}]")
return
bid = matches[0]
b = owned[bid]
display = b.get("display", bid)
try:
catalog = find_catalog()
except Exception:
catalog = {}
buddy_cat = (catalog.get("buddymon", {}).get(bid)
or catalog.get("evolutions", {}).get(bid) or {})
challenges = buddy_cat.get("challenges", [])
if "--accept" in rest_flags:
# Write session state
sf = session_file()
try:
ss = load(sf)
except Exception:
ss = {}
chosen_challenge = None
if challenges:
# Pick randomly unless a specific index was passed
if "--challenge-idx" in rest_flags:
idx = rest_flags.index("--challenge-idx")
try:
ci = int(rest_flags[idx + 1])
chosen_challenge = challenges[ci % len(challenges)]
except (IndexError, ValueError):
chosen_challenge = random.choice(challenges)
else:
chosen_challenge = random.choice(challenges)
ss["buddymon_id"] = bid
ss["session_xp"] = ss.get("session_xp", 0)
ss["challenge"] = chosen_challenge
save(sf, ss)
# Also update global default
active = load(BUDDYMON_DIR / "active.json")
active["buddymon_id"] = bid
active["challenge"] = chosen_challenge
save(BUDDYMON_DIR / "active.json", active)
ch_name = ""
if chosen_challenge:
ch_name = chosen_challenge.get("name", str(chosen_challenge)) if isinstance(chosen_challenge, dict) else str(chosen_challenge)
print(f"{display} assigned to this session!")
if ch_name:
print(f" Challenge: {ch_name}")
return
if "--reroll" in rest_flags:
# Pick a different challenge and re-show the proposal
if not challenges:
print(f"No challenges available for {display}.")
return
challenge = random.choice(challenges)
elif challenges:
challenge = challenges[0]
else:
challenge = None
ch_name = ""
ch_desc = ""
ch_stars = ""
if challenge:
if isinstance(challenge, dict):
ch_name = challenge.get("name", "")
ch_desc = challenge.get("description", "")
difficulty = challenge.get("difficulty", 1)
ch_stars = "" * difficulty + "" * (5 - difficulty)
else:
ch_name = str(challenge)
print(f"🐾 Assign {display} to this session?")
if ch_name:
print(f"\n Challenge: {ch_name}")
if ch_desc:
print(f" {ch_desc}")
if ch_stars:
print(f" Difficulty: {ch_stars}")
print("\n[INPUT_NEEDED: accept / decline / reroll]")
def cmd_evolve(confirmed: bool = False):
roster = load(BUDDYMON_DIR / "roster.json")
active = load(BUDDYMON_DIR / "active.json")
buddy_id = get_buddy_id() or active.get("buddymon_id")
if not buddy_id:
print("No buddy assigned.")
return
try:
catalog = find_catalog()
except Exception:
catalog = {}
owned = roster.get("owned", {})
buddy = owned.get(buddy_id, {})
display = buddy.get("display", buddy_id)
level = buddy.get("level", 1)
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 not evo:
target_level = min((e.get("level", 100) for e in evolutions), default=100)
levels_to_go = max(0, target_level - level)
print(f"{display} is Lv.{level}. Evolution requires Lv.{target_level} ({levels_to_go} more levels).")
return
into_id = evo["into"]
into_data = catalog.get("evolutions", {}).get(into_id, {})
into_display = into_data.get("display", into_id)
old_catch = catalog_entry.get("base_stats", {}).get("catch_rate", 0.4)
new_catch = into_data.get("base_stats", {}).get("catch_rate", old_catch)
old_mult = catalog_entry.get("base_stats", {}).get("xp_multiplier", 1.0)
new_mult = into_data.get("base_stats", {}).get("xp_multiplier", old_mult)
if not confirmed:
print("╔══════════════════════════════════════════════════════════╗")
print("║ ✨ Evolution Ready! ║")
print("╠══════════════════════════════════════════════════════════╣")
print(f"{display} Lv.{level}{into_display}")
print(f"║ catch_rate: {old_catch:.2f}{new_catch:.2f} · xp_multiplier: {old_mult}{new_mult}")
print("║ ⚠️ Resets to Lv.1. Caught monsters stay.")
print("╚══════════════════════════════════════════════════════════╝")
print("\n[INPUT_NEEDED: Evolve? (y/n)]")
return
# Execute evolution
now = datetime.now(timezone.utc).isoformat()
owned[buddy_id]["evolved_into"] = into_id
owned[buddy_id]["evolved_at"] = now
challenges = catalog_entry.get("challenges") or into_data.get("challenges", [])
owned[into_id] = {
"id": into_id,
"display": into_display,
"affinity": into_data.get("affinity", catalog_entry.get("affinity", "")),
"level": 1,
"xp": 0,
"evolved_from": buddy_id,
"evolved_at": now,
}
roster["owned"] = owned
save(BUDDYMON_DIR / "roster.json", roster)
sf = session_file()
try:
ss = load(sf)
except Exception:
ss = {}
ss["buddymon_id"] = into_id
ss["session_xp"] = 0
ss["challenge"] = challenges[0] if challenges else None
save(sf, ss)
active["buddymon_id"] = into_id
active["session_xp"] = 0
active["challenge"] = ss["challenge"]
save(BUDDYMON_DIR / "active.json", active)
ch_name = ""
if ss["challenge"]:
ch = ss["challenge"]
ch_name = ch.get("name", str(ch)) if isinstance(ch, dict) else str(ch)
print(f"{display} evolved into {into_display}!")
print(" Starting fresh at Lv.1 — the second climb is faster.")
if ch_name:
print(f" New challenge: {ch_name}")
def cmd_statusline():
settings_path = Path.home() / ".claude" / "settings.json"
script_candidates = [
Path.home() / ".claude/buddymon/statusline.sh",
Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "")) / "lib/statusline.sh",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.0/lib/statusline.sh",
]
script_path = next((str(p) for p in script_candidates if p.exists()), None)
if not script_path:
print("❌ statusline.sh not found. Re-run install.sh.")
return
settings = load(settings_path)
if settings.get("statusLine"):
existing = settings["statusLine"].get("command", "")
print(f"⚠️ A statusLine is already configured:\n {existing}")
print("\n[INPUT_NEEDED: Replace it? (y/n)]")
return
settings["statusLine"] = {"type": "command", "command": f"bash {script_path}"}
save(settings_path, settings)
print(f"✅ Buddymon statusline installed. Reload Claude Code to activate.")
def cmd_help():
print("""/buddymon — status panel
/buddymon start choose starter (first run)
/buddymon assign <n> assign buddy to session
/buddymon fight fight active encounter
/buddymon catch catch active encounter
/buddymon roster view full roster
/buddymon evolve evolve buddy (Lv.100)
/buddymon statusline install statusline widget
/buddymon help this list""")
# ── Entry point ───────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
cmd = args[0].lower() if args else ""
rest = args[1:]
dispatch = {
"": lambda: cmd_status(),
"status": lambda: cmd_status(),
"fight": lambda: cmd_fight(confirmed="--confirmed" in rest),
"catch": lambda: cmd_catch(rest),
"roster": lambda: cmd_roster(),
"start": lambda: cmd_start(rest[0] if rest else None),
"assign": lambda: cmd_assign(rest),
"evolve": lambda: cmd_evolve(confirmed="--confirm" in rest),
"statusline": lambda: cmd_statusline(),
"help": lambda: cmd_help(),
}
handler = dispatch.get(cmd)
if handler:
handler()
else:
print(f"Unknown subcommand '{cmd}'. Run `/buddymon help` for the full list.")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -15,7 +15,15 @@ ID=$(jq -r '.buddymon_id // ""' "$B/active.json" 2>/dev/null)
[[ -n "$ID" ]] || exit 0 [[ -n "$ID" ]] || exit 0
LVL=$(jq -r ".owned[\"$ID\"].level // 1" "$B/roster.json" 2>/dev/null) 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)
# Per-session XP (accurate for multi-window setups); fall back to active.json
PGRP=$(python3 -c "import os; print(os.getpgrp())" 2>/dev/null)
SESSION_FILE="$B/sessions/${PGRP}.json"
if [[ -f "$SESSION_FILE" ]]; then
XP=$(jq -r '.session_xp // 0' "$SESSION_FILE" 2>/dev/null)
else
XP=$(jq -r '.session_xp // 0' "$B/active.json" 2>/dev/null)
fi
ENC_JSON=$(jq -c '.active_encounter // null' "$B/encounters.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_DISPLAY=$(echo "$ENC_JSON" | jq -r '.display // ""' 2>/dev/null)

View file

@ -1,548 +1,66 @@
--- ---
name: buddymon name: buddymon
description: Buddymon companion game — status, roster, encounters, and session management description: Buddymon companion game — status, roster, encounters, and session management
argument-hint: [start|assign <name>|fight|catch|roster] argument-hint: [start|assign <name>|fight|catch|roster|evolve|statusline|help]
allowed-tools: [Bash, Read] allowed-tools: [Bash, Agent]
--- ---
# /buddymon — Buddymon Companion # /buddymon
The main Buddymon command. Route based on the argument provided. Run the CLI:
**Invoked with:** `/buddymon $ARGUMENTS` ```bash
python3 ~/.claude/buddymon/cli.py $ARGUMENTS
```
The PostToolUse hook automatically surfaces the output as an inline system-reminder — no need to echo it yourself. After running, respond with one short line of acknowledgement at most (e.g. "Done." or nothing). Do not repeat the output.
--- ---
## Subcommand Routing ## Interactive Markers
Parse `$ARGUMENTS` (trim whitespace, lowercase the first word) and dispatch: ### `[INPUT_NEEDED: <prompt>]`
| Argument | Action | Ask the user the exact prompt. Then re-run with their answer appended:
|----------|--------|
| _(none)_ | Show status panel |
| `start` | Choose starter (first-run) |
| `assign <name>` | Assign buddy to this session |
| `fight` | Fight active encounter |
| `catch` | Catch active encounter |
| `roster` | Full roster view |
| `evolve` | Evolve active buddy (available at Lv.100) |
| `statusline` | Install Buddymon statusline into settings.json |
| `help` | Show command list |
--- - `fight` + user says "yes" → `python3 ~/.claude/buddymon/cli.py fight --confirmed`
- `evolve` + user says "y" → `python3 ~/.claude/buddymon/cli.py evolve --confirm`
- `start` + user says "2" → `python3 ~/.claude/buddymon/cli.py start 2`
- `assign <name>` + user says "accept" → `python3 ~/.claude/buddymon/cli.py assign <name> --accept`
- `assign <name>` + user says "reroll" → `python3 ~/.claude/buddymon/cli.py assign <name> --reroll`
- `statusline` + user says "y" → delete existing statusLine key from settings.json, re-run `statusline`
- `catch` + user gives weakening input → see catch flow below
## No argument — Status Panel ### `[HAIKU_NEEDED: <json>]`
Read state files and display: Spawn a Haiku subagent to parse ambiguous natural language. Pass the JSON task as the prompt, get a single-line response, then re-run the CLI with the result.
Example — fuzzy match resolution:
``` ```
╔══════════════════════════════════════════╗ Agent(model="haiku", prompt=<json>.instruction + "\n\nRespond with ONLY the matching name.")
║ 🐾 Buddymon ║ → re-run: python3 ~/.claude/buddymon/cli.py assign <haiku_result> --resolved <haiku_result>
╠══════════════════════════════════════════╣
║ Active: [display] Lv.[n] ║
║ XP: [████████████░░░░░░░░] [n]/[max] ║
║ ║
║ Challenge: [name] ║
║ [description] [★★☆☆☆] [XP] XP ║
╚══════════════════════════════════════════╝
```
If an encounter is active, show it below the panel.
If no buddy assigned, prompt `/buddymon assign`.
If no starter chosen, prompt `/buddymon start`.
State files:
- `~/.claude/buddymon/active.json` — active buddy + session XP
- `~/.claude/buddymon/roster.json` — all owned Buddymon
- `~/.claude/buddymon/encounters.json` — active encounter
- `~/.claude/buddymon/session.json` — session stats
---
## `start` — Choose Starter (first-run only)
Check `roster.json``starter_chosen`. If already true, show current buddy status instead.
If false, present:
```
╔══════════════════════════════════════════════════════════╗
║ 🐾 Choose Your Starter Buddymon ║
╠══════════════════════════════════════════════════════════╣
║ ║
║ [1] 🔥 Pyrobyte — Speedrunner ║
║ Moves fast, thinks faster. Loves tight deadlines. ║
║ Challenges: speed runs, feature sprints ║
║ ║
║ [2] 🔍 Debuglin — Tester ║
║ Patient, methodical, ruthless. ║
║ Challenges: test coverage, bug hunts ║
║ ║
║ [3] ✂️ Minimox — Cleaner ║
║ Obsessed with fewer lines. ║
║ Challenges: refactors, zero-linter runs ║
║ ║
╚══════════════════════════════════════════════════════════╝
```
Ask for 1, 2, or 3. On choice, write to roster + active:
```python
import json, os
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())))
starters = ["Pyrobyte", "Debuglin", "Minimox"]
choice = starters[0] # replace with user's choice (index 0/1/2)
buddy = catalog["buddymon"][choice]
roster = json.load(open(f"{BUDDYMON_DIR}/roster.json"))
roster["owned"][choice] = {
"id": choice, "display": buddy["display"],
"affinity": buddy["affinity"], "level": 1, "xp": 0,
}
roster["starter_chosen"] = True
json.dump(roster, open(f"{BUDDYMON_DIR}/roster.json", "w"), indent=2)
active = json.load(open(f"{BUDDYMON_DIR}/active.json"))
active["buddymon_id"] = choice
active["session_xp"] = 0
active["challenge"] = buddy["challenges"][0] if buddy.get("challenges") else None
json.dump(active, open(f"{BUDDYMON_DIR}/active.json", "w"), indent=2)
```
Greet them and explain the encounter system.
---
## `assign <name>` — Assign Buddy
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).
---
## `fight` — Fight Encounter
**Note:** Encounters auto-resolve when a clean Bash run (no matching error patterns) is detected.
Use `/buddymon fight` when the error was fixed outside Bash (e.g., in a config file) or to manually confirm a fix.
Read `encounters.json``active_encounter`. If none: "No active encounter — it may have already been auto-resolved."
Show encounter state. Confirm the user has fixed the bug.
On confirm:
```python
import json, os
from datetime import datetime, timezone
BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon")
enc_file = f"{BUDDYMON_DIR}/encounters.json"
active_file = f"{BUDDYMON_DIR}/active.json"
roster_file = f"{BUDDYMON_DIR}/roster.json"
encounters = json.load(open(enc_file))
active = json.load(open(active_file))
roster = json.load(open(roster_file))
enc = encounters.get("active_encounter")
if enc and enc.get("defeatable", True):
xp = enc.get("xp_reward", 50)
buddy_id = active.get("buddymon_id")
active["session_xp"] = active.get("session_xp", 0) + xp
json.dump(active, open(active_file, "w"), indent=2)
if buddy_id and buddy_id in roster.get("owned", {}):
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp
json.dump(roster, open(roster_file, "w"), indent=2)
enc["outcome"] = "defeated"
enc["timestamp"] = datetime.now(timezone.utc).isoformat()
encounters.setdefault("history", []).append(enc)
encounters["active_encounter"] = None
json.dump(encounters, open(enc_file, "w"), indent=2)
print(f"+{xp} XP")
```
ShadowBit (🔒) cannot be defeated — redirect to catch.
---
## `catch` — Catch Encounter
Read active encounter. If none: "No active encounter."
**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:
- 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):
```python
import json, os, random, glob
from datetime import datetime, timezone
from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
# 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"
encounters = json.load(open(enc_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_data = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id) or {})
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
base_catch = buddy_data.get("base_stats", {}).get("catch_rate", 0.4)
current_strength = enc.get("current_strength", 100)
weakness_bonus = (100 - current_strength) / 100 * 0.4
catch_rate = min(0.95, base_catch + weakness_bonus + buddy_level * 0.02)
success = random.random() < catch_rate
if success:
xp = int(enc.get("xp_reward", 50) * 1.5)
caught_entry = {
"id": enc["id"], "display": enc["display"],
"type": "caught_bug_monster", "level": 1, "xp": 0,
"caught_at": datetime.now(timezone.utc).isoformat(),
}
roster.setdefault("owned", {})[enc["id"]] = caught_entry
# 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", {}):
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp
json.dump(roster, open(roster_file, "w"), indent=2)
enc["outcome"] = "caught"
enc["timestamp"] = datetime.now(timezone.utc).isoformat()
encounters.setdefault("history", []).append(enc)
encounters["active_encounter"] = None
json.dump(encounters, open(enc_file, "w"), indent=2)
print(f"caught:{xp}")
else:
# 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)}")
```
On success: "🎉 Caught [display]! +[XP] XP (1.5× catch bonus)"
On failure: "💨 Broke free! Weaken it further and try again."
---
## `roster` — Full Roster
Read roster and display:
```
🐾 Your Buddymon
──────────────────────────────────────────
🔥 Pyrobyte Lv.3 Speedrunner
XP: [████████████░░░░░░░░] 450/300
🔍 Debuglin Lv.1 Tester
XP: [████░░░░░░░░░░░░░░░░] 80/100
🏆 Caught Bug Monsters
──────────────────────────────────────────
👻 NullWraith — caught 2026-04-01
🌐 CORSCurse — caught 2026-03-28
❓ ??? — [n] more creatures to discover...
🗺️ 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 ## Catch Flow
Installs the Buddymon statusline into `~/.claude/settings.json`. When `[INPUT_NEEDED]` fires during `catch`, the user describes which weakening actions they've done.
The statusline shows active buddy + level + session XP, and highlights any **If input is numeric** (e.g. `"1 3"`): calculate inline — 1→-20, 2→-20, 3→-10. Sum reductions. Re-run:
active encounter in red: ```bash
python3 ~/.claude/buddymon/cli.py catch --strength <100 - total_reduction>
```
🐾 Debuglin Lv.90 · +45xp ⚔ 💀 NullWraith [60%]
``` ```
Run this Python to install: **If input is natural language** (e.g. `"wrote a failing test and documented it"`): spawn Haiku:
```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.")
``` ```
Agent(model="haiku", prompt="""
If a `statusLine` is already set, show the existing command and ask before replacing. The user described their catch weakening actions: "<user input>"
Actions available: 1=failing_test (-20%), 2=isolation (-20%), 3=comment (-10%)
--- Reply with ONLY valid JSON: {"actions": [<numbers>], "reduction": <total_percent>}
""")
## `help`
``` ```
/buddymon — status panel Parse Haiku's JSON → compute remaining strength → re-run:
/buddymon start — choose starter (first run) ```bash
/buddymon assign <n> — assign buddy to session python3 ~/.claude/buddymon/cli.py catch --strength <100 - reduction>
/buddymon fight — fight active encounter
/buddymon catch — catch active encounter
/buddymon roster — view full roster
/buddymon statusline — install statusline widget
``` ```