buddymon/hooks-handlers/user-prompt-submit.py
pyr0ball 81712d06e3 fix: roster via output file, catalog path, already-owned spawn guard
- post-tool-use: find_catalog() checks ~/.claude/buddymon/catalog.json first
  so Veritarch is always found regardless of which cache CLAUDE_PLUGIN_ROOT
  points to; fixes 'your buddy' display bug
- post-tool-use: skip spawn when monster already in collection (was only
  checked on existing encounters, not at spawn time; caused already-caught
  monsters to re-announce)
- post-tool-use: fix spawn block indentation — set_active_encounter was
  outside the else, running even on already-owned skip
- user-prompt-submit: same null buddymon_id fallback fix as post-tool-use
- cli.py cmd_roster: buffer all output to StringIO; write roster_output.txt
  + roster_pending.txt; suppress stdout when piped (isatty check)
- roster-stop.py: read roster_output.txt directly instead of re-running cli.py
  (eliminated the recursive flag-write loop); remove unused subprocess import
2026-04-12 19:02:04 -07:00

190 lines
6.8 KiB
Python

#!/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:
data = json.load(f)
# Fall through when file exists but buddymon_id is null (pgrp mismatch).
if not data.get("buddymon_id"):
raise ValueError("null buddymon_id")
return data
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)
rarity_stars = {
"very_common": "★☆☆☆☆", "common": "★★☆☆☆",
"uncommon": "★★★☆☆", "rare": "★★★★☆", "legendary": "★★★★★",
}
strength = enc.get("current_strength", 50)
is_mascot = enc.get("encounter_type") == "language_mascot"
if is_mascot:
mascot_data = catalog.get("language_mascots", {}).get(enc.get("id", ""), {})
rarity = mascot_data.get("rarity", "common")
stars = rarity_stars.get(rarity, "★★☆☆☆")
flavor = mascot_data.get("flavor", "")
lang = enc.get("language") or mascot_data.get("language", "")
assignable = mascot_data.get("assignable", False)
if enc.get("wounded"):
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:
monster = catalog.get("bug_monsters", {}).get(enc.get("id", ""), {})
rarity = monster.get("rarity", "common")
stars = rarity_stars.get(rarity, "★★☆☆☆")
defeatable = enc.get("defeatable", True)
catchable = enc.get("catchable", True)
flavor = monster.get("flavor", "")
if enc.get("wounded"):
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:
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)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": msg,
}
}))
sys.exit(0)
if __name__ == "__main__":
main()