Compare commits

...

4 commits
v0.2.1 ... main

Author SHA1 Message Date
pyr0ball
a6c1d23c81 chore: bump version to 0.2.3 2026-04-12 19:29:06 -07:00
pyr0ball
1edbfb9007 fix: sync cache on install, copy all hook files to live 0.1.0 cache
install.sh now copies hooks-handlers/*.py and hooks.json into the live
cache dir after symlinking — ensures edits take effect in the running
session without a CC restart for hook scripts, and on next session start
for hooks.json changes.

Also manually synced 0.1.0 cache to unblock current session:
- post-tool-use.py: find_catalog + already-owned spawn guard
- user-prompt-submit.py: null buddymon_id fallback
- roster-stop.py: reads roster_output.txt (no recursive CLI call)
- hooks.json: roster-stop.py Stop hook entry
2026-04-12 19:28:59 -07:00
pyr0ball
67e47de7ca chore: bump version to 0.2.2 2026-04-12 19:02:11 -07:00
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
6 changed files with 134 additions and 72 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "buddymon", "name": "buddymon",
"description": "Collectible creatures discovered through coding commit streaks, bug fights, and session challenges", "description": "Collectible creatures discovered through coding \u2014 commit streaks, bug fights, and session challenges",
"version": "0.2.1", "version": "0.2.3",
"author": { "author": {
"name": "CircuitForge LLC", "name": "CircuitForge LLC",
"email": "hello@circuitforge.tech" "email": "hello@circuitforge.tech"

View file

@ -20,9 +20,31 @@ import random
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" 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. # 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. # 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) BUDDYMON_DIR.mkdir(parents=True, exist_ok=True)
sys.exit(0) sys.exit(0)
catalog = load_json(CATALOG_FILE) catalog = find_catalog()
buddy_id = get_active_buddy_id() buddy_id = get_active_buddy_id()
# Look up display name # Look up display name
@ -663,27 +685,37 @@ def main():
target = monster or event target = monster or event
if target and random.random() < 0.70: if target and random.random() < 0.70:
if monster: # Skip spawn if this monster is already in the collection —
strength = compute_strength(monster, elapsed_minutes=0) # 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: else:
strength = target.get("base_strength", 30) if monster:
encounter = { strength = compute_strength(monster, elapsed_minutes=0)
"id": target["id"], else:
"display": target["display"], strength = target.get("base_strength", 30)
"base_strength": target.get("base_strength", 50), encounter = {
"current_strength": strength, "id": target["id"],
"catchable": target.get("catchable", True), "display": target["display"],
"defeatable": target.get("defeatable", True), "base_strength": target.get("base_strength", 50),
"xp_reward": target.get("xp_reward", 50), "current_strength": strength,
"rarity": target.get("rarity", "common"), "catchable": target.get("catchable", True),
"weak_against": target.get("weak_against", []), "defeatable": target.get("defeatable", True),
"strong_against": target.get("strong_against", []), "xp_reward": target.get("xp_reward", 50),
"immune_to": target.get("immune_to", []), "rarity": target.get("rarity", "common"),
"rival": target.get("rival"), "weak_against": target.get("weak_against", []),
"weakened_by": [], "strong_against": target.get("strong_against", []),
"announced": False, "immune_to": target.get("immune_to", []),
} "rival": target.get("rival"),
set_active_encounter(encounter) "weakened_by": [],
"announced": False,
}
set_active_encounter(encounter)
# Commit detection # Commit detection
if "git commit" in command and "exit_code" not in str(tool_response): if "git commit" in command and "exit_code" not in str(tool_response):

View file

@ -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. guaranteeing the full roster is visible without Bash-tool truncation.
""" """
import json import json
import os
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
PENDING_FLAG = BUDDYMON_DIR / "roster_pending.txt" PENDING_FLAG = BUDDYMON_DIR / "roster_pending.txt"
CLI = BUDDYMON_DIR / "cli.py" OUTPUT_FILE = BUDDYMON_DIR / "roster_output.txt"
def main(): def main():
@ -26,16 +24,16 @@ def main():
if not PENDING_FLAG.exists(): if not PENDING_FLAG.exists():
sys.exit(0) 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) PENDING_FLAG.unlink(missing_ok=True)
if not CLI.exists(): if not OUTPUT_FILE.exists():
sys.exit(0) sys.exit(0)
result = subprocess.run( output = OUTPUT_FILE.read_text().strip()
["python3", str(CLI), "roster"], OUTPUT_FILE.unlink(missing_ok=True)
capture_output=True, text=True, timeout=10,
)
output = result.stdout.strip()
if not output: if not output:
sys.exit(0) sys.exit(0)

View file

@ -23,7 +23,11 @@ SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
def get_session_state() -> dict: def get_session_state() -> dict:
try: try:
with open(SESSION_FILE) as f: 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: except Exception:
global_active = {} global_active = {}
try: try:

View file

@ -290,6 +290,16 @@ PYEOF
cp "${REPO_DIR}/lib/catalog.json" "${BUDDYMON_DIR}/catalog.json" cp "${REPO_DIR}/lib/catalog.json" "${BUDDYMON_DIR}/catalog.json"
ok "Installed catalog.json → ${BUDDYMON_DIR}/catalog.json" ok "Installed catalog.json → ${BUDDYMON_DIR}/catalog.json"
# Sync hooks-handlers and hooks.json into the live cache so edits take effect
# without waiting for a CC plugin-cache refresh. CC re-reads hook scripts on
# every invocation but only reads hooks.json at session start.
if [[ -d "${CACHE_DIR}/hooks-handlers" ]]; then
cp "${REPO_DIR}/hooks-handlers/"*.py "${CACHE_DIR}/hooks-handlers/"
cp "${REPO_DIR}/hooks-handlers/"*.sh "${CACHE_DIR}/hooks-handlers/" 2>/dev/null || true
cp "${REPO_DIR}/hooks/hooks.json" "${CACHE_DIR}/hooks/hooks.json"
ok "Synced hooks-handlers + hooks.json → ${CACHE_DIR}/"
fi
# 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

View file

@ -441,6 +441,14 @@ def _evo_chain_label(bid: str, owned: dict, catalog: dict) -> str:
def cmd_roster(): 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") roster = load(BUDDYMON_DIR / "roster.json")
owned = roster.get("owned", {}) owned = roster.get("owned", {})
active_bid = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id") active_bid = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id")
@ -464,13 +472,13 @@ def cmd_roster():
W = 52 W = 52
owned_tag = f"{total_owned} owned" owned_tag = f"{total_owned} owned"
pad = W - 10 - len(owned_tag) pad = W - 10 - len(owned_tag)
print("" + "" * W + "") _p("" + "" * W + "")
print(f"║ 🐾 ROSTER{' ' * pad}{owned_tag}") _p(f"║ 🐾 ROSTER{' ' * pad}{owned_tag}")
print("" + "" * W + "") _p("" + "" * W + "")
# ── BUDDYMON ────────────────────────────────────────────── # ── BUDDYMON ──────────────────────────────────────────────
print() _p()
print("── BUDDYMON ✓ levels via XP · assignable " + "" * 8) _p("── BUDDYMON ✓ levels via XP · assignable " + "" * 8)
for bid, b in sorted(core_buddymon.items(), for bid, b in sorted(core_buddymon.items(),
key=lambda x: -x[1].get("xp", 0)): key=lambda x: -x[1].get("xp", 0)):
lvl = b.get("level", 1) lvl = b.get("level", 1)
@ -484,26 +492,25 @@ def cmd_roster():
active_tag = " ← ACTIVE" if bid == active_bid else "" active_tag = " ← ACTIVE" if bid == active_bid else ""
chain = _evo_chain_label(bid, owned, catalog) 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 {} cat_entry = catalog.get("buddymon", {}).get(bid) or catalog.get("evolutions", {}).get(bid) or {}
evos = cat_entry.get("evolutions", []) evos = cat_entry.get("evolutions", [])
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None) next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
print() _p()
print(f" {display}{aff_tag}{active_tag}") _p(f" {display}{aff_tag}{active_tag}")
print(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}") _p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
if chain: if chain:
print(f" chain: {chain}") _p(f" chain: {chain}")
if next_evo: if next_evo:
gap = next_evo["level"] - lvl 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: elif b.get("evolved_into") and b["evolved_into"] in owned:
print(f" ✓ fully evolved") _p(f" ✓ fully evolved")
# ── LANGUAGE MASCOTS ────────────────────────────────────── # ── LANGUAGE MASCOTS ──────────────────────────────────────
print() _p()
print("── LANGUAGE MASCOTS ✓ levels via XP · assignable " + "" * 1) _p("── LANGUAGE MASCOTS ✓ levels via XP · assignable " + "" * 1)
if mascots_owned: if mascots_owned:
for bid, b in sorted(mascots_owned.items(), for bid, b in sorted(mascots_owned.items(),
key=lambda x: -x[1].get("xp", 0)): 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) next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
active_tag = " ← ACTIVE" if bid == active_bid else "" active_tag = " ← ACTIVE" if bid == active_bid else ""
print() _p()
print(f" {display} [{lang}]{elem_tag}{active_tag}") _p(f" {display} [{lang}]{elem_tag}{active_tag}")
print(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}") _p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
if next_evo: 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: else:
print(f" (none yet — code in Python, JS, Rust… to encounter them)") _p(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" {mascot_total} species to discover across {len(set(m.get('rarity','') for m in mascot_catalog.values()))} rarity tiers")
# ── LANGUAGE AFFINITIES ─────────────────────────────────── # ── LANGUAGE AFFINITIES ───────────────────────────────────
affinities = roster.get("language_affinities", {}) affinities = roster.get("language_affinities", {})
if affinities: if affinities:
print() _p()
print("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "" * 0) _p("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "" * 0)
for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)): for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)):
tier = data.get("tier", "discovering") tier = data.get("tier", "discovering")
level = data.get("level", 0) level = data.get("level", 0)
@ -544,12 +551,12 @@ def cmd_roster():
e_tag = f"[{elem}]" if elem else "" e_tag = f"[{elem}]" if elem else ""
has_mascot = any(m.get("language") == lang for m in mascot_catalog.values()) has_mascot = any(m.get("language") == lang for m in mascot_catalog.values())
m_tag = " 🦎" if has_mascot else "" 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 ─────────────────────────────────────── # ── BUG TROPHY CASE ───────────────────────────────────────
print() _p()
caught_count = len(caught_bugs) 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: if caught_bugs:
# Sort: notable (level>1 or has XP) first, then alpha # Sort: notable (level>1 or has XP) first, then alpha
def bug_sort_key(item): def bug_sort_key(item):
@ -557,7 +564,8 @@ def cmd_roster():
notable = b.get("level", 1) > 1 or b.get("xp", 0) > 0 notable = b.get("level", 1) > 1 or b.get("xp", 0) > 0
return (not notable, item[0]) return (not notable, item[0])
items = sorted(caught_bugs.items(), key=bug_sort_key) 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 COL = 28
for i in range(0, len(items), 2): for i in range(0, len(items), 2):
left_id, left = items[i] left_id, left = items[i]
@ -572,14 +580,14 @@ def cmd_roster():
weak = bc.get("weak_against", []) weak = bc.get("weak_against", [])
elem = f"[{weak[0]}]" if weak else "" elem = f"[{weak[0]}]" if weak else ""
lvl_tag = f" Lv.{lvl}" if lvl > 1 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) return cell_str[:COL].ljust(COL)
l = cell(left_id, left) l = cell(left_id, left)
r = cell(right_id, right) if right else "" r = cell(right_id, right) if right else ""
print(f" {l} {r}".rstrip()) _p(f" {l} {r}".rstrip())
else: else:
print(" none yet") _p(" none yet")
# ── DISCOVERY FOOTER ────────────────────────────────────── # ── DISCOVERY FOOTER ──────────────────────────────────────
missing_bugs = bug_total - len(caught_bugs) missing_bugs = bug_total - len(caught_bugs)
@ -588,12 +596,22 @@ def cmd_roster():
parts = [] parts = []
if missing_bugs > 0: parts.append(f"{missing_bugs} bug monsters") if missing_bugs > 0: parts.append(f"{missing_bugs} bug monsters")
if missing_mascots > 0: parts.append(f"{missing_mascots} mascots") 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 # Write roster output to a file for the Stop hook to deliver as additionalContext.
# Bash tool result truncation). The Stop hook reads and clears this file. # The Stop hook reads roster_output.txt directly — it does NOT re-run this command,
pending = BUDDYMON_DIR / "roster_pending.txt" # avoiding any recursive flag-write loop.
pending.write_text("1") 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): def cmd_start(choice: str | None = None):