Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

7 changed files with 104 additions and 356 deletions

View file

@ -1,11 +1,11 @@
{
"name": "buddymon",
"description": "Collectible creatures discovered through coding \u2014 commit streaks, bug fights, and session challenges",
"version": "0.2.3",
"description": "Collectible creatures discovered through coding commit streaks, bug fights, and session challenges",
"version": "0.1.0",
"author": {
"name": "CircuitForge LLC",
"email": "hello@circuitforge.tech"
},
"license": "MIT",
"repository": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon"
}
}

View file

@ -20,31 +20,9 @@ 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"
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 {}
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
# 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.
@ -55,13 +33,14 @@ SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
def get_session_state() -> dict:
"""Read the current session's state file, falling back to global active.json."""
session = load_json(SESSION_FILE)
# Fall back when file is missing OR when buddymon_id is null (e.g. pgrp mismatch
# between the CLI process that ran evolve/assign and this hook process).
if not session.get("buddymon_id"):
if not session:
# No session file yet — inherit from global default
global_active = load_json(BUDDYMON_DIR / "active.json")
session["buddymon_id"] = global_active.get("buddymon_id")
session.setdefault("challenge", global_active.get("challenge"))
session.setdefault("session_xp", 0)
session = {
"buddymon_id": global_active.get("buddymon_id"),
"challenge": global_active.get("challenge"),
"session_xp": 0,
}
return session
@ -271,18 +250,7 @@ def is_starter_chosen():
def get_active_buddy_id():
"""Return the active buddy ID, skipping any caught monster that slipped in."""
bid = get_session_state().get("buddymon_id")
if not bid:
return None
# Validate: caught bug/mascot types are trophies, not assignable buddies.
# If one ended up in state (e.g. from a state corruption), fall back to active.json.
roster = load_json(BUDDYMON_DIR / "roster.json")
mon_type = roster.get("owned", {}).get(bid, {}).get("type", "")
if mon_type in ("caught_bug_monster",):
fallback = load_json(BUDDYMON_DIR / "active.json")
bid = fallback.get("buddymon_id", bid)
return bid
return get_session_state().get("buddymon_id")
def get_active_encounter():
@ -553,7 +521,7 @@ def main():
BUDDYMON_DIR.mkdir(parents=True, exist_ok=True)
sys.exit(0)
catalog = find_catalog()
catalog = load_json(CATALOG_FILE)
buddy_id = get_active_buddy_id()
# Look up display name
@ -602,24 +570,6 @@ def main():
existing = get_active_encounter()
if existing:
# Already-owned shortcut: dismiss immediately, no XP, no wound cycle.
# No point fighting something already in the collection.
enc_id = existing.get("id", "")
roster_quick = load_json(BUDDYMON_DIR / "roster.json")
owned_quick = roster_quick.get("owned", {})
if (enc_id in owned_quick
and owned_quick[enc_id].get("type") in ("caught_bug_monster", "caught_language_mascot")
and not existing.get("catch_pending")):
enc_data = load_json(BUDDYMON_DIR / "encounters.json")
enc_data.setdefault("history", []).append({**existing, "outcome": "dismissed_owned"})
enc_data["active_encounter"] = None
save_json(BUDDYMON_DIR / "encounters.json", enc_data)
messages.append(
f"\n🔁 **{existing.get('display', enc_id)}** — already in your collection. Dismissed."
)
existing = None # skip further processing this run
if existing:
# On a clean Bash run (monster patterns gone), respect catch_pending,
# wound a healthy monster, or auto-resolve a wounded one.
@ -685,37 +635,27 @@ def main():
target = monster or event
if target and random.random() < 0.70:
# 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
if monster:
strength = compute_strength(monster, elapsed_minutes=0)
else:
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)
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):
@ -734,14 +674,16 @@ def main():
ext = os.path.splitext(file_path)[1].lower()
lang = KNOWN_EXTENSIONS.get(ext)
if lang:
# Fire "new language" bonus only when affinity XP is genuinely zero.
# Affinity XP in roster.json is the reliable persistent signal;
# session.json languages_seen was volatile and caused false positives.
affinity = get_language_affinity(lang)
if affinity.get("xp", 0) == 0:
add_session_xp(15)
msg = format_new_language_message(lang, buddy_display)
messages.append(msg)
# 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()
if lang not in seen:
add_language_seen(lang)
affinity = get_language_affinity(lang)
if affinity.get("xp", 0) == 0:
add_session_xp(15)
msg = format_new_language_message(lang, buddy_display)
messages.append(msg)
# Persistent affinity XP — always accumulates
leveled_up, old_tier, new_tier = add_language_affinity(lang, 3)

View file

@ -1,50 +0,0 @@
#!/usr/bin/env python3
"""
Buddymon Stop hook roster re-emitter.
Checks for roster_pending.txt written by `cli.py roster`.
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 sys
from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
PENDING_FLAG = BUDDYMON_DIR / "roster_pending.txt"
OUTPUT_FILE = BUDDYMON_DIR / "roster_output.txt"
def main():
try:
json.load(sys.stdin)
except Exception:
pass
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 OUTPUT_FILE.exists():
sys.exit(0)
output = OUTPUT_FILE.read_text().strip()
OUTPUT_FILE.unlink(missing_ok=True)
if not output:
sys.exit(0)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "Stop",
"additionalContext": output,
}
}))
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -23,11 +23,7 @@ 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
return json.load(f)
except Exception:
global_active = {}
try:

View file

@ -47,16 +47,6 @@
"timeout": 10
}
]
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/roster-stop.py",
"timeout": 10
}
]
}
]
}

View file

@ -287,19 +287,6 @@ PYEOF
chmod +x "${BUDDYMON_DIR}/cli.py"
ok "Installed cli.py → ${BUDDYMON_DIR}/cli.py"
cp "${REPO_DIR}/lib/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
python3 << PYEOF
import json

View file

@ -26,10 +26,8 @@ def find_catalog() -> dict:
pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
candidates = [
Path(pr) / "lib" / "catalog.json" if pr else None,
# User-local copy updated by install.sh — checked before stale plugin cache
BUDDYMON_DIR / "catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
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:
@ -414,204 +412,89 @@ def cmd_catch(args: list[str]):
print(f"💨 {display} broke free! Weaken it further and try again. ({int(catch_rate * 100)}% catch rate)")
def _evo_chain_label(bid: str, owned: dict, catalog: dict) -> str:
"""Build a short evolution chain string: A → B → C with current marked."""
# Walk backwards to find root
root = bid
while True:
prev = owned.get(root, {}).get("evolved_from") or catalog.get("evolutions", {}).get(root, {}).get("evolves_from")
if not prev or prev not in owned:
break
root = prev
# Walk forward
chain = [root]
cur = root
while True:
nxt = owned.get(cur, {}).get("evolved_into")
if not nxt or nxt not in owned:
break
chain.append(nxt)
cur = nxt
if len(chain) < 2:
return ""
return "".join(
f"[{n}]" if n == bid else n
for n in chain
)
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")
try:
catalog = find_catalog()
except Exception:
catalog = {}
mascot_catalog = catalog.get("language_mascots", {})
bug_catalog = catalog.get("bug_monsters", {})
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_bugs = {k: v for k, v in owned.items() if v.get("type") == "caught_bug_monster"}
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"}
total_owned = len(core_buddymon) + len(mascots_owned) + len(caught_bugs)
bug_total = len(bug_catalog)
mascot_total = len(mascot_catalog)
W = 52
owned_tag = f"{total_owned} owned"
pad = W - 10 - len(owned_tag)
_p("" + "" * W + "")
_p(f"║ 🐾 ROSTER{' ' * pad}{owned_tag}")
_p("" + "" * W + "")
# ── BUDDYMON ──────────────────────────────────────────────
_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)
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 = total_xp % max_xp if max_xp else 0
bar = xp_bar(xp_in, max_xp, width=16)
display = b.get("display", bid)
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", "")
aff_tag = f" [{affinity}]" if affinity else ""
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}")
active_tag = " ← ACTIVE" if bid == active_bid else ""
chain = _evo_chain_label(bid, owned, catalog)
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)
_p()
_p(f" {display}{aff_tag}{active_tag}")
_p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
if chain:
_p(f" chain: {chain}")
if next_evo:
gap = next_evo["level"] - lvl
_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:
_p(f" ✓ fully evolved")
# ── LANGUAGE MASCOTS ──────────────────────────────────────
_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)):
display = b.get("display", bid)
lang = b.get("language", "")
lvl = b.get("level", 1)
total_xp = b.get("xp", 0)
max_xp = lvl * 100
xp_in = total_xp % max_xp if max_xp else 0
bar = xp_bar(xp_in, max_xp, width=16)
mc = mascot_catalog.get(bid, {})
elem = mc.get("element", "")
elem_tag = f" [{elem}]" if elem else ""
evos = mc.get("evolutions", [])
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("🦎 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}")
_p()
_p(f" {display} [{lang}]{elem_tag}{active_tag}")
_p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
if next_evo:
_p(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']}")
else:
_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")
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}")
# ── LANGUAGE AFFINITIES ───────────────────────────────────
affinities = roster.get("language_affinities", {})
if affinities:
_p()
_p("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "" * 0)
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, "")
e_tag = f"[{elem}]" if elem else ""
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())
m_tag = " 🦎" if has_mascot else ""
_p(f" {emoji} {lang:<13} {tier:<12} Lv.{level:<3} · {xp:>5} XP {e_tag}{m_tag}")
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 TROPHY CASE ───────────────────────────────────────
_p()
caught_count = len(caught_bugs)
_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):
b = item[1]
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 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]
right_id, right = items[i + 1] if i + 1 < len(items) else (None, None)
def cell(bid, b):
if b is None:
return ""
disp = b.get("display", bid)
lvl = b.get("level", 1)
bc = bug_catalog.get(bid, {})
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}".strip()
return cell_str[:COL].ljust(COL)
l = cell(left_id, left)
r = cell(right_id, right) if right else ""
_p(f" {l} {r}".rstrip())
else:
_p(" none yet")
# ── DISCOVERY FOOTER ──────────────────────────────────────
missing_bugs = bug_total - len(caught_bugs)
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} mascots")
_p(f"\n ❓ ??? — {' and '.join(parts)} still to discover")
# 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)
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):