diff --git a/hooks-handlers/post-tool-use.py b/hooks-handlers/post-tool-use.py index baf33dc..c7dc7f3 100755 --- a/hooks-handlers/post-tool-use.py +++ b/hooks-handlers/post-tool-use.py @@ -20,9 +20,31 @@ 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" + + +def find_catalog() -> dict: + """Load catalog from the first candidate path that exists. + + Checks the user-local copy installed by install.sh first, so the + current catalog is always used regardless of which plugin cache version + CLAUDE_PLUGIN_ROOT points to. + """ + plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent)) + candidates = [ + # User-local copy: always matches the live dev/install version + BUDDYMON_DIR / "catalog.json", + Path(plugin_root) / "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(): + try: + return json.loads(p.read_text()) + except Exception: + continue + return {} # 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. @@ -531,7 +553,7 @@ def main(): BUDDYMON_DIR.mkdir(parents=True, exist_ok=True) sys.exit(0) - catalog = load_json(CATALOG_FILE) + catalog = find_catalog() buddy_id = get_active_buddy_id() # Look up display name @@ -663,27 +685,37 @@ def main(): target = monster or event if target and random.random() < 0.70: - if monster: - strength = compute_strength(monster, elapsed_minutes=0) + # Skip spawn if this monster is already in the collection — + # no point announcing something the player already caught. + target_id = target.get("id", "") + _owned = load_json(BUDDYMON_DIR / "roster.json").get("owned", {}) + already_owned = (target_id in _owned + and _owned[target_id].get("type") + in ("caught_bug_monster", "caught_language_mascot")) + if already_owned: + pass # silently skip else: - strength = target.get("base_strength", 30) - encounter = { - "id": target["id"], - "display": target["display"], - "base_strength": target.get("base_strength", 50), - "current_strength": strength, - "catchable": target.get("catchable", True), - "defeatable": target.get("defeatable", True), - "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": [], - "announced": False, - } - set_active_encounter(encounter) + if monster: + 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), + "current_strength": strength, + "catchable": target.get("catchable", True), + "defeatable": target.get("defeatable", True), + "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": [], + "announced": False, + } + set_active_encounter(encounter) # Commit detection if "git commit" in command and "exit_code" not in str(tool_response): diff --git a/hooks-handlers/roster-stop.py b/hooks-handlers/roster-stop.py index ed30bf0..92abcb4 100644 --- a/hooks-handlers/roster-stop.py +++ b/hooks-handlers/roster-stop.py @@ -7,14 +7,12 @@ If present, runs the roster CLI and emits full output as additionalContext, guaranteeing the full roster is visible without Bash-tool truncation. """ import json -import os -import subprocess import sys from pathlib import Path -BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" -PENDING_FLAG = BUDDYMON_DIR / "roster_pending.txt" -CLI = BUDDYMON_DIR / "cli.py" +BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" +PENDING_FLAG = BUDDYMON_DIR / "roster_pending.txt" +OUTPUT_FILE = BUDDYMON_DIR / "roster_output.txt" def main(): @@ -26,16 +24,16 @@ def main(): if not PENDING_FLAG.exists(): sys.exit(0) + # Clear both files before reading — prevents a second Stop event from + # re-delivering the same roster if OUTPUT_FILE lingers. PENDING_FLAG.unlink(missing_ok=True) - if not CLI.exists(): + if not OUTPUT_FILE.exists(): sys.exit(0) - result = subprocess.run( - ["python3", str(CLI), "roster"], - capture_output=True, text=True, timeout=10, - ) - output = result.stdout.strip() + output = OUTPUT_FILE.read_text().strip() + OUTPUT_FILE.unlink(missing_ok=True) + if not output: sys.exit(0) diff --git a/hooks-handlers/user-prompt-submit.py b/hooks-handlers/user-prompt-submit.py index 1c35f2f..ada602a 100644 --- a/hooks-handlers/user-prompt-submit.py +++ b/hooks-handlers/user-prompt-submit.py @@ -23,7 +23,11 @@ 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) + 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: diff --git a/lib/cli.py b/lib/cli.py index acdb34d..e5e42cb 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -441,6 +441,14 @@ def _evo_chain_label(bid: str, owned: dict, catalog: dict) -> str: def cmd_roster(): + import io as _io + _buf = _io.StringIO() + + def _p(*args, **kwargs): + """print() that writes to the buffer instead of stdout.""" + kwargs.setdefault("file", _buf) + print(*args, **kwargs) + roster = load(BUDDYMON_DIR / "roster.json") owned = roster.get("owned", {}) active_bid = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id") @@ -464,13 +472,13 @@ def cmd_roster(): W = 52 owned_tag = f"{total_owned} owned" pad = W - 10 - len(owned_tag) - print("╔" + "═" * W + "╗") - print(f"║ 🐾 ROSTER{' ' * pad}{owned_tag} ║") - print("╚" + "═" * W + "╝") + _p("╔" + "═" * W + "╗") + _p(f"║ 🐾 ROSTER{' ' * pad}{owned_tag} ║") + _p("╚" + "═" * W + "╝") # ── BUDDYMON ────────────────────────────────────────────── - print() - print("── BUDDYMON ✓ levels via XP · assignable " + "─" * 8) + _p() + _p("── BUDDYMON ✓ levels via XP · assignable " + "─" * 8) for bid, b in sorted(core_buddymon.items(), key=lambda x: -x[1].get("xp", 0)): lvl = b.get("level", 1) @@ -484,26 +492,25 @@ def cmd_roster(): active_tag = " ← ACTIVE" if bid == active_bid else "" chain = _evo_chain_label(bid, owned, catalog) - chain_tag = f" {chain}" if chain else "" cat_entry = catalog.get("buddymon", {}).get(bid) or catalog.get("evolutions", {}).get(bid) or {} evos = cat_entry.get("evolutions", []) next_evo = next((e for e in evos if lvl < e.get("level", 999)), None) - print() - print(f" {display}{aff_tag}{active_tag}") - print(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}") + _p() + _p(f" {display}{aff_tag}{active_tag}") + _p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}") if chain: - print(f" chain: {chain}") + _p(f" chain: {chain}") if next_evo: gap = next_evo["level"] - lvl - print(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']} ({gap} levels to go)") + _p(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']} ({gap} levels to go)") elif b.get("evolved_into") and b["evolved_into"] in owned: - print(f" ✓ fully evolved") + _p(f" ✓ fully evolved") # ── LANGUAGE MASCOTS ────────────────────────────────────── - print() - print("── LANGUAGE MASCOTS ✓ levels via XP · assignable " + "─" * 1) + _p() + _p("── LANGUAGE MASCOTS ✓ levels via XP · assignable " + "─" * 1) if mascots_owned: for bid, b in sorted(mascots_owned.items(), key=lambda x: -x[1].get("xp", 0)): @@ -521,20 +528,20 @@ def cmd_roster(): next_evo = next((e for e in evos if lvl < e.get("level", 999)), None) active_tag = " ← ACTIVE" if bid == active_bid else "" - print() - print(f" {display} [{lang}]{elem_tag}{active_tag}") - print(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}") + _p() + _p(f" {display} [{lang}]{elem_tag}{active_tag}") + _p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}") if next_evo: - print(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']}") + _p(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']}") else: - print(f" (none yet — code in Python, JS, Rust… to encounter them)") - print(f" {mascot_total} species to discover across {len(set(m.get('rarity','') for m in mascot_catalog.values()))} rarity tiers") + _p(f" (none yet — code in Python, JS, Rust… to encounter them)") + _p(f" {mascot_total} species to discover across {len(set(m.get('rarity','') for m in mascot_catalog.values()))} rarity tiers") # ── LANGUAGE AFFINITIES ─────────────────────────────────── affinities = roster.get("language_affinities", {}) if affinities: - print() - print("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "─" * 0) + _p() + _p("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "─" * 0) 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) @@ -544,12 +551,12 @@ def cmd_roster(): e_tag = f"[{elem}]" if elem else "" has_mascot = any(m.get("language") == lang for m in mascot_catalog.values()) m_tag = " 🦎" if has_mascot else "" - print(f" {emoji} {lang:<13} {tier:<12} Lv.{level:<3} · {xp:>5} XP {e_tag}{m_tag}") + _p(f" {emoji} {lang:<13} {tier:<12} Lv.{level:<3} · {xp:>5} XP {e_tag}{m_tag}") # ── BUG TROPHY CASE ─────────────────────────────────────── - print() + _p() caught_count = len(caught_bugs) - print(f"── BUG TROPHY CASE ✗ trophies only · no leveling ({caught_count}/{bug_total}) " + "─" * 0) + _p(f"── BUG TROPHY CASE ✗ trophies only · no leveling ({caught_count}/{bug_total}) " + "─" * 0) if caught_bugs: # Sort: notable (level>1 or has XP) first, then alpha def bug_sort_key(item): @@ -557,7 +564,8 @@ def cmd_roster(): notable = b.get("level", 1) > 1 or b.get("xp", 0) > 0 return (not notable, item[0]) items = sorted(caught_bugs.items(), key=bug_sort_key) - # Two-column grid — 28 chars gives room for "🦅 ReviewHawk Lv.64 [systems]" + # Two-column grid — 28 chars per column; cap at COL so long names don't push + # the right column out of alignment. COL = 28 for i in range(0, len(items), 2): left_id, left = items[i] @@ -572,14 +580,14 @@ def cmd_roster(): weak = bc.get("weak_against", []) elem = f"[{weak[0]}]" if weak else "" lvl_tag = f" Lv.{lvl}" if lvl > 1 else "" - cell_str = f"{disp}{lvl_tag} {elem}" + cell_str = f"{disp}{lvl_tag} {elem}".strip() return cell_str[:COL].ljust(COL) l = cell(left_id, left) r = cell(right_id, right) if right else "" - print(f" {l} {r}".rstrip()) + _p(f" {l} {r}".rstrip()) else: - print(" none yet") + _p(" none yet") # ── DISCOVERY FOOTER ────────────────────────────────────── missing_bugs = bug_total - len(caught_bugs) @@ -588,12 +596,22 @@ def cmd_roster(): parts = [] if missing_bugs > 0: parts.append(f"{missing_bugs} bug monsters") if missing_mascots > 0: parts.append(f"{missing_mascots} mascots") - print(f"\n ❓ ??? — {' and '.join(parts)} still to discover") + _p(f"\n ❓ ??? — {' and '.join(parts)} still to discover") - # Signal the Stop hook to re-emit full output as additionalContext (avoids - # Bash tool result truncation). The Stop hook reads and clears this file. - pending = BUDDYMON_DIR / "roster_pending.txt" - pending.write_text("1") + # Write roster output to a file for the Stop hook to deliver as additionalContext. + # The Stop hook reads roster_output.txt directly — it does NOT re-run this command, + # avoiding any recursive flag-write loop. + output_text = _buf.getvalue() + roster_output = BUDDYMON_DIR / "roster_output.txt" + roster_pending = BUDDYMON_DIR / "roster_pending.txt" + roster_output.write_text(output_text) + roster_pending.write_text("1") + + # Print to stdout only when running interactively (direct terminal use). + # When called from a skill via Bash (piped), output is silent — the Stop + # hook delivers it as additionalContext instead. + if sys.stdout.isatty(): + sys.stdout.write(output_text) def cmd_start(choice: str | None = None):