Each Claude Code session now gets its own state file at: ~/.claude/buddymon/sessions/<pgrp>.json Contains: buddymon_id, session_xp, challenge — all session-local. Global active.json keeps the default buddymon_id for new sessions. /buddymon assign writes to the session file only, so assigning in one terminal window doesn't affect other open sessions. Each window can have its own buddy assigned independently. SessionStart creates the session file (inheriting global default). SessionStop reads XP from it, writes to roster, then removes it.
145 lines
4.5 KiB
Python
145 lines
4.5 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:
|
|
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()
|