Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
11 changed files with 208 additions and 2191 deletions
17
README.md
17
README.md
|
|
@ -1,19 +1,9 @@
|
||||||
# 🐾 Buddymon
|
# 🐾 Buddymon
|
||||||
|
|
||||||
A Claude Code **extension** that turns your coding sessions into a creature-collecting game.
|
A Claude Code plugin that turns your coding sessions into a creature-collecting game.
|
||||||
|
|
||||||
Buddymon are discovered, caught, and leveled up through real development work — not separate from it.
|
Buddymon are discovered, caught, and leveled up through real development work — not separate from it.
|
||||||
|
|
||||||
> **How it works:** Buddymon uses Claude Code's hook and plugin system — it is not a UI mod. Notifications (encounters, XP, session summaries) appear as system-injected context in the chat thread, visible to both you and Claude. They do not appear in Thrumble's speech bubble or any other CC UI widget.
|
|
||||||
|
|
||||||
### Companion widgets and Buddymon
|
|
||||||
|
|
||||||
Claude Code supports personal companion widgets — small characters that sit beside the input box and comment in a speech bubble. These are user-configured, not built into CC by Anthropic. Buddymon is a separate community plugin and has no relationship to any specific companion.
|
|
||||||
|
|
||||||
The CC plugin API does not expose a hook to drive companion speech bubbles, so Buddymon cannot make your companion announce encounters. If Anthropic ships a companion speech API in a future release, Buddymon will adopt it.
|
|
||||||
|
|
||||||
Until then, Buddymon notifications arrive as chat context rather than companion barks. All game state (XP, encounters, roster) works regardless.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
@ -50,9 +40,8 @@ Then **restart Claude Code** and run:
|
||||||
```
|
```
|
||||||
|
|
||||||
The install script:
|
The install script:
|
||||||
- Creates a local `circuitforge` marketplace under `~/.claude/plugins/marketplaces/circuitforge/` (required — CC validates plugin names against the marketplace index)
|
- Symlinks the repo into `~/.claude/plugins/cache/local/buddymon/0.1.0/`
|
||||||
- Symlinks the repo into `~/.claude/plugins/cache/circuitforge/buddymon/<version>/`
|
- Registers the plugin in `~/.claude/plugins/installed_plugins.json`
|
||||||
- Registers the plugin in `~/.claude/plugins/installed_plugins.json` and `~/.claude/plugins/known_marketplaces.json`
|
|
||||||
- Enables it in `~/.claude/settings.json`
|
- Enables it in `~/.claude/settings.json`
|
||||||
- Creates `~/.claude/buddymon/` state directory with initial JSON files
|
- Creates `~/.claude/buddymon/` state directory with initial JSON files
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,36 +18,11 @@ import re
|
||||||
import sys
|
import sys
|
||||||
import random
|
import random
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
|
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"
|
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.
|
|
||||||
SESSION_KEY = str(os.getpgrp())
|
|
||||||
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)
|
|
||||||
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"),
|
|
||||||
"challenge": global_active.get("challenge"),
|
|
||||||
"session_xp": 0,
|
|
||||||
}
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def save_session_state(state: dict) -> None:
|
|
||||||
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
save_json(SESSION_FILE, state)
|
|
||||||
|
|
||||||
KNOWN_EXTENSIONS = {
|
KNOWN_EXTENSIONS = {
|
||||||
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
|
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
|
||||||
".jsx": "JavaScript/React", ".tsx": "TypeScript/React",
|
".jsx": "JavaScript/React", ".tsx": "TypeScript/React",
|
||||||
|
|
@ -86,67 +61,21 @@ def get_state():
|
||||||
|
|
||||||
|
|
||||||
def add_session_xp(amount: int):
|
def add_session_xp(amount: int):
|
||||||
session = get_session_state()
|
active_file = BUDDYMON_DIR / "active.json"
|
||||||
session["session_xp"] = session.get("session_xp", 0) + amount
|
roster_file = BUDDYMON_DIR / "roster.json"
|
||||||
buddy_id = session.get("buddymon_id")
|
|
||||||
save_session_state(session)
|
active = load_json(active_file)
|
||||||
|
active["session_xp"] = active.get("session_xp", 0) + amount
|
||||||
|
buddy_id = active.get("buddymon_id")
|
||||||
|
save_json(active_file, active)
|
||||||
|
|
||||||
if buddy_id:
|
if buddy_id:
|
||||||
roster_file = BUDDYMON_DIR / "roster.json"
|
|
||||||
roster = load_json(roster_file)
|
roster = load_json(roster_file)
|
||||||
if buddy_id in roster.get("owned", {}):
|
if buddy_id in roster.get("owned", {}):
|
||||||
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount
|
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount
|
||||||
save_json(roster_file, roster)
|
save_json(roster_file, roster)
|
||||||
|
|
||||||
|
|
||||||
LANGUAGE_TIERS = [
|
|
||||||
(0, "discovering"),
|
|
||||||
(50, "familiar"),
|
|
||||||
(150, "comfortable"),
|
|
||||||
(350, "proficient"),
|
|
||||||
(700, "expert"),
|
|
||||||
(1200, "master"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _tier_for_xp(xp: int) -> tuple[int, str]:
|
|
||||||
"""Return (level_index, tier_label) for a given XP total."""
|
|
||||||
level = 0
|
|
||||||
label = LANGUAGE_TIERS[0][1]
|
|
||||||
for i, (threshold, name) in enumerate(LANGUAGE_TIERS):
|
|
||||||
if xp >= threshold:
|
|
||||||
level = i
|
|
||||||
label = name
|
|
||||||
return level, label
|
|
||||||
|
|
||||||
|
|
||||||
def get_language_affinity(lang: str) -> dict:
|
|
||||||
"""Return the affinity entry for lang from roster.json, or a fresh one."""
|
|
||||||
roster = load_json(BUDDYMON_DIR / "roster.json")
|
|
||||||
return roster.get("language_affinities", {}).get(lang, {"xp": 0, "level": 0, "tier": "discovering"})
|
|
||||||
|
|
||||||
|
|
||||||
def add_language_affinity(lang: str, xp_amount: int) -> tuple[bool, str, str]:
|
|
||||||
"""Add XP to lang's affinity. Returns (leveled_up, old_tier, new_tier)."""
|
|
||||||
roster_file = BUDDYMON_DIR / "roster.json"
|
|
||||||
roster = load_json(roster_file)
|
|
||||||
affinities = roster.setdefault("language_affinities", {})
|
|
||||||
entry = affinities.get(lang, {"xp": 0, "level": 0, "tier": "discovering"})
|
|
||||||
|
|
||||||
old_level, old_tier = _tier_for_xp(entry["xp"])
|
|
||||||
entry["xp"] = entry.get("xp", 0) + xp_amount
|
|
||||||
new_level, new_tier = _tier_for_xp(entry["xp"])
|
|
||||||
entry["level"] = new_level
|
|
||||||
entry["tier"] = new_tier
|
|
||||||
|
|
||||||
affinities[lang] = entry
|
|
||||||
roster["language_affinities"] = affinities
|
|
||||||
save_json(roster_file, roster)
|
|
||||||
|
|
||||||
leveled_up = new_level > old_level
|
|
||||||
return leveled_up, old_tier, new_tier
|
|
||||||
|
|
||||||
|
|
||||||
def get_languages_seen():
|
def get_languages_seen():
|
||||||
session = load_json(BUDDYMON_DIR / "session.json")
|
session = load_json(BUDDYMON_DIR / "session.json")
|
||||||
return set(session.get("languages_seen", []))
|
return set(session.get("languages_seen", []))
|
||||||
|
|
@ -175,7 +104,8 @@ def is_starter_chosen():
|
||||||
|
|
||||||
|
|
||||||
def get_active_buddy_id():
|
def get_active_buddy_id():
|
||||||
return get_session_state().get("buddymon_id")
|
active = load_json(BUDDYMON_DIR / "active.json")
|
||||||
|
return active.get("buddymon_id")
|
||||||
|
|
||||||
|
|
||||||
def get_active_encounter():
|
def get_active_encounter():
|
||||||
|
|
@ -190,21 +120,6 @@ def set_active_encounter(encounter: dict):
|
||||||
save_json(enc_file, data)
|
save_json(enc_file, data)
|
||||||
|
|
||||||
|
|
||||||
def wound_encounter() -> None:
|
|
||||||
"""Drop active encounter to minimum strength and flag for re-announcement."""
|
|
||||||
enc_file = BUDDYMON_DIR / "encounters.json"
|
|
||||||
data = load_json(enc_file)
|
|
||||||
enc = data.get("active_encounter")
|
|
||||||
if not enc:
|
|
||||||
return
|
|
||||||
enc["current_strength"] = 5
|
|
||||||
enc["wounded"] = True
|
|
||||||
enc["announced"] = False # triggers UserPromptSubmit re-announcement
|
|
||||||
enc["last_wounded_at"] = datetime.now().astimezone().isoformat()
|
|
||||||
data["active_encounter"] = enc
|
|
||||||
save_json(enc_file, data)
|
|
||||||
|
|
||||||
|
|
||||||
def match_bug_monster(output_text: str, catalog: dict) -> dict | None:
|
def match_bug_monster(output_text: str, catalog: dict) -> dict | None:
|
||||||
"""Return the first matching bug monster from the catalog, or None."""
|
"""Return the first matching bug monster from the catalog, or None."""
|
||||||
if not output_text:
|
if not output_text:
|
||||||
|
|
@ -309,58 +224,6 @@ def format_encounter_message(monster: dict, strength: int, buddy_display: str) -
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def match_event_encounter(command: str, output: str, session: dict, catalog: dict):
|
|
||||||
"""Detect non-error-based encounters: git ops, installs, test results."""
|
|
||||||
events = catalog.get("event_encounters", {})
|
|
||||||
errors_seen = bool(session.get("errors_encountered") or session.get("tools_used", 0) > 5)
|
|
||||||
|
|
||||||
for enc_id, enc in events.items():
|
|
||||||
trigger = enc.get("trigger_type", "")
|
|
||||||
|
|
||||||
if trigger == "command":
|
|
||||||
if any(re.search(pat, command) for pat in enc.get("command_patterns", [])):
|
|
||||||
return enc
|
|
||||||
|
|
||||||
elif trigger == "output":
|
|
||||||
if any(re.search(pat, output, re.IGNORECASE) for pat in enc.get("error_patterns", [])):
|
|
||||||
return enc
|
|
||||||
|
|
||||||
elif trigger == "test_victory":
|
|
||||||
# Only spawn PhantomPass when tests go green after the session has been running a while
|
|
||||||
if errors_seen and any(re.search(pat, output, re.IGNORECASE) for pat in enc.get("success_patterns", [])):
|
|
||||||
return enc
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def match_test_file_encounter(file_path: str, catalog: dict):
|
|
||||||
"""Spawn TestSpecter when editing a test file."""
|
|
||||||
enc = catalog.get("event_encounters", {}).get("TestSpecter")
|
|
||||||
if not enc:
|
|
||||||
return None
|
|
||||||
name = os.path.basename(file_path).lower()
|
|
||||||
if any(re.search(pat, name) for pat in enc.get("test_file_patterns", [])):
|
|
||||||
return enc
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def spawn_encounter(enc: dict) -> None:
|
|
||||||
"""Write an event encounter to active state with announced=False."""
|
|
||||||
strength = enc.get("base_strength", 30)
|
|
||||||
encounter = {
|
|
||||||
"id": enc["id"],
|
|
||||||
"display": enc["display"],
|
|
||||||
"base_strength": enc.get("base_strength", 30),
|
|
||||||
"current_strength": strength,
|
|
||||||
"catchable": enc.get("catchable", True),
|
|
||||||
"defeatable": enc.get("defeatable", True),
|
|
||||||
"xp_reward": enc.get("xp_reward", 50),
|
|
||||||
"weakened_by": [],
|
|
||||||
"announced": False,
|
|
||||||
}
|
|
||||||
set_active_encounter(encounter)
|
|
||||||
|
|
||||||
|
|
||||||
def format_new_language_message(lang: str, buddy_display: str) -> str:
|
def format_new_language_message(lang: str, buddy_display: str) -> str:
|
||||||
return (
|
return (
|
||||||
f"\n🗺️ **New language spotted: {lang}!**\n"
|
f"\n🗺️ **New language spotted: {lang}!**\n"
|
||||||
|
|
@ -369,23 +232,6 @@ def format_new_language_message(lang: str, buddy_display: str) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_language_levelup_message(lang: str, old_tier: str, new_tier: str, total_xp: int, buddy_display: str) -> str:
|
|
||||||
tier_emojis = {
|
|
||||||
"discovering": "🔭",
|
|
||||||
"familiar": "📖",
|
|
||||||
"comfortable": "🛠️",
|
|
||||||
"proficient": "⚡",
|
|
||||||
"expert": "🎯",
|
|
||||||
"master": "👑",
|
|
||||||
}
|
|
||||||
emoji = tier_emojis.get(new_tier, "⬆️")
|
|
||||||
return (
|
|
||||||
f"\n{emoji} **{lang} affinity: {old_tier} → {new_tier}!**\n"
|
|
||||||
f" {buddy_display} has grown more comfortable in {lang}.\n"
|
|
||||||
f" *Total {lang} XP: {total_xp}*\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_commit_message(streak: int, buddy_display: str) -> str:
|
def format_commit_message(streak: int, buddy_display: str) -> str:
|
||||||
if streak < 5:
|
if streak < 5:
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -432,107 +278,46 @@ def main():
|
||||||
|
|
||||||
# ── Bash tool: error detection + auto-resolution + commit tracking ───────
|
# ── Bash tool: error detection + auto-resolution + commit tracking ───────
|
||||||
if tool_name == "Bash":
|
if tool_name == "Bash":
|
||||||
command = tool_input.get("command", "")
|
|
||||||
output = ""
|
output = ""
|
||||||
# CC Bash tool_response keys: stdout, stderr, interrupted, isImage, noOutputExpected
|
|
||||||
if isinstance(tool_response, dict):
|
if isinstance(tool_response, dict):
|
||||||
parts = [
|
output = tool_response.get("output", "") or tool_response.get("content", "")
|
||||||
tool_response.get("stdout", ""),
|
|
||||||
tool_response.get("stderr", ""),
|
|
||||||
]
|
|
||||||
output = "\n".join(p for p in parts if isinstance(p, str) and p)
|
|
||||||
elif isinstance(tool_response, str):
|
elif isinstance(tool_response, str):
|
||||||
output = tool_response
|
output = tool_response
|
||||||
elif isinstance(tool_response, list):
|
|
||||||
output = "\n".join(
|
|
||||||
b.get("text", "") for b in tool_response
|
|
||||||
if isinstance(b, dict) and b.get("type") == "text"
|
|
||||||
)
|
|
||||||
|
|
||||||
existing = get_active_encounter()
|
existing = get_active_encounter()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# On a clean Bash run (monster patterns gone), respect catch_pending,
|
# Auto-resolve if the monster's patterns no longer appear in output
|
||||||
# wound a healthy monster, or auto-resolve a wounded one.
|
|
||||||
# Probability gates prevent back-to-back Bash runs from instantly
|
|
||||||
# resolving encounters before the user can react.
|
|
||||||
if output and not encounter_still_present(existing, output, catalog):
|
if output and not encounter_still_present(existing, output, catalog):
|
||||||
if existing.get("catch_pending"):
|
xp, display = auto_resolve_encounter(existing, buddy_id)
|
||||||
# User invoked /buddymon catch — hold the monster for this run.
|
messages.append(
|
||||||
# Clear the flag now so the NEXT clean run resumes normal behavior.
|
f"\n⚔️ **{buddy_display} defeated {display}!** (auto-resolved)\n"
|
||||||
# The skill sets it again at the start of each /buddymon catch call.
|
f" +{xp} XP\n"
|
||||||
existing["catch_pending"] = False
|
)
|
||||||
enc_data = load_json(BUDDYMON_DIR / "encounters.json")
|
# else: monster persists, no message — don't spam every tool call
|
||||||
enc_data["active_encounter"] = existing
|
elif output:
|
||||||
save_json(BUDDYMON_DIR / "encounters.json", enc_data)
|
# No active encounter — check for a new one
|
||||||
else:
|
monster = match_bug_monster(output, catalog)
|
||||||
# Auto-attack rates scaled by encounter rarity and buddy level.
|
if monster:
|
||||||
# Multiple parallel sessions share encounters.json — a wound
|
# 70% chance to trigger (avoids every minor warning spawning)
|
||||||
# cooldown prevents them pile-driving the same encounter.
|
if random.random() < 0.70:
|
||||||
rarity = existing.get("rarity", "common")
|
|
||||||
WOUND_RATES = {
|
|
||||||
"very_common": 0.55, "common": 0.40,
|
|
||||||
"uncommon": 0.22, "rare": 0.10, "legendary": 0.02,
|
|
||||||
}
|
|
||||||
RESOLVE_RATES = {
|
|
||||||
"very_common": 0.45, "common": 0.28,
|
|
||||||
"uncommon": 0.14, "rare": 0.05, "legendary": 0.01,
|
|
||||||
}
|
|
||||||
roster = load_json(BUDDYMON_DIR / "roster.json")
|
|
||||||
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
|
|
||||||
level_scale = 1.0 + (buddy_level / 100) * 0.25
|
|
||||||
|
|
||||||
# Wound cooldown: skip if another session wounded within 30s
|
|
||||||
last_wound = existing.get("last_wounded_at", "")
|
|
||||||
wound_cooldown_ok = True
|
|
||||||
if last_wound:
|
|
||||||
try:
|
|
||||||
from datetime import timezone as _tz
|
|
||||||
last_dt = datetime.fromisoformat(last_wound)
|
|
||||||
age = (datetime.now(_tz.utc) - last_dt).total_seconds()
|
|
||||||
wound_cooldown_ok = age > 30
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if existing.get("wounded"):
|
|
||||||
resolve_rate = min(0.70, RESOLVE_RATES.get(rarity, 0.28) * level_scale)
|
|
||||||
if wound_cooldown_ok and random.random() < resolve_rate:
|
|
||||||
xp, display = auto_resolve_encounter(existing, buddy_id)
|
|
||||||
messages.append(
|
|
||||||
f"\n💨 **{display} fled!** (escaped while wounded)\n"
|
|
||||||
f" {buddy_display} gets partial XP: +{xp}\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
wound_rate = min(0.85, WOUND_RATES.get(rarity, 0.40) * level_scale)
|
|
||||||
if wound_cooldown_ok and random.random() < wound_rate:
|
|
||||||
wound_encounter()
|
|
||||||
# else: monster still present, no message — don't spam every tool call
|
|
||||||
elif output or command:
|
|
||||||
# No active encounter — check for bug monster first, then event encounters
|
|
||||||
session = load_json(BUDDYMON_DIR / "session.json")
|
|
||||||
monster = match_bug_monster(output, catalog) if output else None
|
|
||||||
event = None if monster else match_event_encounter(command, output, session, catalog)
|
|
||||||
target = monster or event
|
|
||||||
|
|
||||||
if target and random.random() < 0.70:
|
|
||||||
if monster:
|
|
||||||
strength = compute_strength(monster, elapsed_minutes=0)
|
strength = compute_strength(monster, elapsed_minutes=0)
|
||||||
else:
|
encounter = {
|
||||||
strength = target.get("base_strength", 30)
|
"id": monster["id"],
|
||||||
encounter = {
|
"display": monster["display"],
|
||||||
"id": target["id"],
|
"base_strength": monster.get("base_strength", 50),
|
||||||
"display": target["display"],
|
"current_strength": strength,
|
||||||
"base_strength": target.get("base_strength", 50),
|
"catchable": monster.get("catchable", True),
|
||||||
"current_strength": strength,
|
"defeatable": monster.get("defeatable", True),
|
||||||
"catchable": target.get("catchable", True),
|
"xp_reward": monster.get("xp_reward", 50),
|
||||||
"defeatable": target.get("defeatable", True),
|
"weakened_by": [],
|
||||||
"xp_reward": target.get("xp_reward", 50),
|
}
|
||||||
"weakened_by": [],
|
set_active_encounter(encounter)
|
||||||
"announced": False,
|
msg = format_encounter_message(monster, strength, buddy_display)
|
||||||
}
|
messages.append(msg)
|
||||||
set_active_encounter(encounter)
|
|
||||||
|
|
||||||
# Commit detection
|
# Commit detection
|
||||||
|
command = tool_input.get("command", "")
|
||||||
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):
|
||||||
session_file = BUDDYMON_DIR / "session.json"
|
session_file = BUDDYMON_DIR / "session.json"
|
||||||
session = load_json(session_file)
|
session = load_json(session_file)
|
||||||
|
|
@ -542,14 +327,13 @@ def main():
|
||||||
commit_xp = 20
|
commit_xp = 20
|
||||||
add_session_xp(commit_xp)
|
add_session_xp(commit_xp)
|
||||||
|
|
||||||
# ── Write / Edit: new language detection + affinity + test file encounters ─
|
# ── Write / Edit: new language detection ──────────────────────────────
|
||||||
elif tool_name in ("Write", "Edit", "MultiEdit"):
|
elif tool_name in ("Write", "Edit", "MultiEdit"):
|
||||||
file_path = tool_input.get("file_path", "")
|
file_path = tool_input.get("file_path", "")
|
||||||
if file_path:
|
if file_path:
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
lang = KNOWN_EXTENSIONS.get(ext)
|
lang = KNOWN_EXTENSIONS.get(ext)
|
||||||
if lang:
|
if lang:
|
||||||
# Session-level "first encounter" bonus
|
|
||||||
seen = get_languages_seen()
|
seen = get_languages_seen()
|
||||||
if lang not in seen:
|
if lang not in seen:
|
||||||
add_language_seen(lang)
|
add_language_seen(lang)
|
||||||
|
|
@ -557,19 +341,6 @@ def main():
|
||||||
msg = format_new_language_message(lang, buddy_display)
|
msg = format_new_language_message(lang, buddy_display)
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
|
||||||
# Persistent affinity XP — always accumulates
|
|
||||||
leveled_up, old_tier, new_tier = add_language_affinity(lang, 3)
|
|
||||||
if leveled_up:
|
|
||||||
affinity = get_language_affinity(lang)
|
|
||||||
msg = format_language_levelup_message(lang, old_tier, new_tier, affinity["xp"], buddy_display)
|
|
||||||
messages.append(msg)
|
|
||||||
|
|
||||||
# TestSpecter: editing a test file with no active encounter
|
|
||||||
if not get_active_encounter():
|
|
||||||
enc = match_test_file_encounter(file_path, catalog)
|
|
||||||
if enc and random.random() < 0.50:
|
|
||||||
spawn_encounter(enc)
|
|
||||||
|
|
||||||
# Small XP for every file edit
|
# Small XP for every file edit
|
||||||
add_session_xp(2)
|
add_session_xp(2)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,53 +7,8 @@ source "${PLUGIN_ROOT}/lib/state.sh"
|
||||||
|
|
||||||
buddymon_init
|
buddymon_init
|
||||||
|
|
||||||
# Per-session state — keyed by process group ID so parallel sessions are isolated.
|
ACTIVE_ID=$(buddymon_get_active)
|
||||||
SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())")
|
SESSION_XP=$(buddymon_get_session_xp)
|
||||||
SESSION_FILE="${BUDDYMON_DIR}/sessions/${SESSION_KEY}.json"
|
|
||||||
mkdir -p "${BUDDYMON_DIR}/sessions"
|
|
||||||
|
|
||||||
# Create session file if missing, inheriting buddy from global active.json
|
|
||||||
if [[ ! -f "${SESSION_FILE}" ]]; then
|
|
||||||
python3 << PYEOF
|
|
||||||
import json, os
|
|
||||||
active = {}
|
|
||||||
try:
|
|
||||||
active = json.load(open('${BUDDYMON_DIR}/active.json'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
session_state = {
|
|
||||||
"buddymon_id": active.get("buddymon_id"),
|
|
||||||
"challenge": active.get("challenge"),
|
|
||||||
"session_xp": 0,
|
|
||||||
}
|
|
||||||
json.dump(session_state, open('${SESSION_FILE}', 'w'), indent=2)
|
|
||||||
PYEOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean up session files for dead processes (runs async-style; errors ignored)
|
|
||||||
python3 << 'PYEOF' 2>/dev/null &
|
|
||||||
import os, glob, json
|
|
||||||
for f in glob.glob(os.path.expanduser("~/.claude/buddymon/sessions/*.json")):
|
|
||||||
pid_str = os.path.basename(f).replace('.json', '')
|
|
||||||
try:
|
|
||||||
pid = int(pid_str)
|
|
||||||
# Check if the process group still exists
|
|
||||||
os.killpg(pid, 0)
|
|
||||||
except (ValueError, ProcessLookupError, PermissionError):
|
|
||||||
# PermissionError means the process exists (just not ours) — keep it
|
|
||||||
# ProcessLookupError means it's dead — remove
|
|
||||||
try:
|
|
||||||
d = json.load(open(f))
|
|
||||||
if '_version' not in d: # only remove valid-format dead sessions
|
|
||||||
os.remove(f)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
ACTIVE_ID=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('buddymon_id',''))" 2>/dev/null)
|
|
||||||
SESSION_XP=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('session_xp',0))" 2>/dev/null)
|
|
||||||
|
|
||||||
# Load catalog for buddy display info
|
# Load catalog for buddy display info
|
||||||
CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
|
CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
|
||||||
|
|
@ -61,77 +16,6 @@ CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
|
||||||
build_context() {
|
build_context() {
|
||||||
local ctx=""
|
local ctx=""
|
||||||
|
|
||||||
# ── Session handoff from previous session ──────────────────────────────
|
|
||||||
local handoff_file="${BUDDYMON_DIR}/handoff.json"
|
|
||||||
if [[ -f "${handoff_file}" ]]; then
|
|
||||||
local handoff_block
|
|
||||||
handoff_block=$(python3 << PYEOF
|
|
||||||
import json, os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
try:
|
|
||||||
h = json.load(open('${handoff_file}'))
|
|
||||||
except Exception:
|
|
||||||
print('')
|
|
||||||
exit()
|
|
||||||
|
|
||||||
buddy_id = h.get('buddy_id')
|
|
||||||
if not buddy_id:
|
|
||||||
print('')
|
|
||||||
exit()
|
|
||||||
|
|
||||||
import json as _json
|
|
||||||
catalog_file = '${CATALOG}'
|
|
||||||
try:
|
|
||||||
catalog = _json.load(open(catalog_file))
|
|
||||||
b = (catalog.get('buddymon', {}).get(buddy_id)
|
|
||||||
or catalog.get('evolutions', {}).get(buddy_id) or {})
|
|
||||||
display = b.get('display', buddy_id)
|
|
||||||
except Exception:
|
|
||||||
display = buddy_id
|
|
||||||
|
|
||||||
lines = [f"### 📬 From your last session ({h.get('date', '?')}) — {display}"]
|
|
||||||
|
|
||||||
xp = h.get('xp_earned', 0)
|
|
||||||
commits = h.get('commits', 0)
|
|
||||||
langs = h.get('languages', [])
|
|
||||||
caught = h.get('caught', [])
|
|
||||||
|
|
||||||
if xp:
|
|
||||||
lines.append(f"- Earned **{xp} XP**")
|
|
||||||
if commits:
|
|
||||||
lines.append(f"- Made **{commits} commit{'s' if commits != 1 else ''}**")
|
|
||||||
if langs:
|
|
||||||
lines.append(f"- Languages touched: {', '.join(langs)}")
|
|
||||||
if caught:
|
|
||||||
lines.append(f"- Caught: {', '.join(caught)}")
|
|
||||||
|
|
||||||
challenge = h.get('challenge')
|
|
||||||
challenge_completed = h.get('challenge_completed', False)
|
|
||||||
if challenge:
|
|
||||||
status = "✅ completed" if challenge_completed else "⏳ still in progress"
|
|
||||||
lines.append(f"- Challenge **{challenge.get('name','?')}** — {status}")
|
|
||||||
|
|
||||||
enc = h.get('active_encounter')
|
|
||||||
if enc:
|
|
||||||
lines.append(f"- ⚠️ **Unresolved encounter carried over:** {enc.get('display', '?')} (strength: {enc.get('current_strength', 100)}%)")
|
|
||||||
|
|
||||||
notes = h.get('notes', [])
|
|
||||||
if notes:
|
|
||||||
lines.append("**Notes:**")
|
|
||||||
for n in notes:
|
|
||||||
lines.append(f" · {n}")
|
|
||||||
|
|
||||||
print('\n'.join(lines))
|
|
||||||
PYEOF
|
|
||||||
)
|
|
||||||
if [[ -n "${handoff_block}" ]]; then
|
|
||||||
ctx+="${handoff_block}\n\n"
|
|
||||||
fi
|
|
||||||
# Archive handoff — consumed for this session
|
|
||||||
rm -f "${handoff_file}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── No starter chosen yet ─────────────────────────────────────────────
|
# ── No starter chosen yet ─────────────────────────────────────────────
|
||||||
if [[ "$(buddymon_starter_chosen)" == "false" ]]; then
|
if [[ "$(buddymon_starter_chosen)" == "false" ]]; then
|
||||||
ctx="## 🐾 Buddymon — First Encounter!\n\n"
|
ctx="## 🐾 Buddymon — First Encounter!\n\n"
|
||||||
|
|
@ -209,28 +93,14 @@ if ch:
|
||||||
enc_display=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('display','?'))")
|
enc_display=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('display','?'))")
|
||||||
enc_strength=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('current_strength',100))")
|
enc_strength=$(echo "${enc}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('current_strength',100))")
|
||||||
ctx+="⚠️ **Unresolved encounter from last session:** ${enc_display} (strength: ${enc_strength}%)\n"
|
ctx+="⚠️ **Unresolved encounter from last session:** ${enc_display} (strength: ${enc_strength}%)\n"
|
||||||
ctx+="Run \`/buddymon fight\` or \`/buddymon catch\` to resolve it.\n\n"
|
ctx+="Run \`/buddymon-fight\` or \`/buddymon-catch\` to resolve it.\n\n"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ctx+="*Bug monsters appear from error output. Use \`/buddymon fight\` or \`/buddymon catch\`.*"
|
ctx+="*Bug monsters appear from error output. Use \`/buddymon-fight\` or \`/buddymon-catch\`.*"
|
||||||
|
|
||||||
echo "${ctx}"
|
echo "${ctx}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Assign a fresh challenge if none is set
|
|
||||||
python3 << PYEOF
|
|
||||||
import json, random
|
|
||||||
catalog = json.load(open('${CATALOG}'))
|
|
||||||
active_file = '${ACTIVE_FILE}'
|
|
||||||
active = json.load(open(active_file))
|
|
||||||
buddy_id = active.get('buddymon_id')
|
|
||||||
if buddy_id and not active.get('challenge'):
|
|
||||||
pool = catalog.get('buddymon', {}).get(buddy_id, {}).get('challenges', [])
|
|
||||||
if pool:
|
|
||||||
active['challenge'] = random.choice(pool)
|
|
||||||
json.dump(active, open(active_file, 'w'), indent=2)
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
CONTEXT=$(build_context)
|
CONTEXT=$(build_context)
|
||||||
|
|
||||||
# Escape for JSON
|
# Escape for JSON
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,14 @@ source "${PLUGIN_ROOT}/lib/state.sh"
|
||||||
|
|
||||||
buddymon_init
|
buddymon_init
|
||||||
|
|
||||||
SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())")
|
ACTIVE_ID=$(buddymon_get_active)
|
||||||
SESSION_FILE="${BUDDYMON_DIR}/sessions/${SESSION_KEY}.json"
|
SESSION_XP=$(buddymon_get_session_xp)
|
||||||
|
|
||||||
ACTIVE_ID=$(python3 -c "
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
d = json.load(open('${SESSION_FILE}'))
|
|
||||||
print(d.get('buddymon_id', ''))
|
|
||||||
except Exception:
|
|
||||||
print('')
|
|
||||||
" 2>/dev/null)
|
|
||||||
|
|
||||||
SESSION_XP=$(python3 -c "
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
d = json.load(open('${SESSION_FILE}'))
|
|
||||||
print(d.get('session_xp', 0))
|
|
||||||
except Exception:
|
|
||||||
print(0)
|
|
||||||
" 2>/dev/null)
|
|
||||||
|
|
||||||
if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then
|
if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then
|
||||||
[[ -f "${SESSION_FILE}" ]] && rm -f "${SESSION_FILE}"
|
# Nothing to report
|
||||||
|
cat << 'EOF'
|
||||||
|
{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": ""}}
|
||||||
|
EOF
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -39,20 +24,17 @@ SUMMARY=$(python3 << PYEOF
|
||||||
import json, os
|
import json, os
|
||||||
|
|
||||||
catalog_file = '${CATALOG}'
|
catalog_file = '${CATALOG}'
|
||||||
session_state_file = '${SESSION_FILE}'
|
active_file = '${BUDDYMON_DIR}/active.json'
|
||||||
roster_file = '${BUDDYMON_DIR}/roster.json'
|
roster_file = '${BUDDYMON_DIR}/roster.json'
|
||||||
session_file = '${BUDDYMON_DIR}/session.json' # SESSION_DATA_FILE from state.sh
|
session_file = '${BUDDYMON_DIR}/session.json'
|
||||||
|
encounters_file = '${BUDDYMON_DIR}/encounters.json'
|
||||||
|
|
||||||
catalog = json.load(open(catalog_file))
|
catalog = json.load(open(catalog_file))
|
||||||
session_state = json.load(open(session_state_file))
|
active = json.load(open(active_file))
|
||||||
roster = json.load(open(roster_file))
|
roster = json.load(open(roster_file))
|
||||||
session = {}
|
session = json.load(open(session_file))
|
||||||
try:
|
|
||||||
session = json.load(open(session_file))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
buddy_id = session_state.get('buddymon_id')
|
buddy_id = active.get('buddymon_id')
|
||||||
if not buddy_id:
|
if not buddy_id:
|
||||||
print('')
|
print('')
|
||||||
exit()
|
exit()
|
||||||
|
|
@ -61,7 +43,7 @@ b = (catalog.get('buddymon', {}).get(buddy_id)
|
||||||
or catalog.get('evolutions', {}).get(buddy_id) or {})
|
or catalog.get('evolutions', {}).get(buddy_id) or {})
|
||||||
display = b.get('display', buddy_id)
|
display = b.get('display', buddy_id)
|
||||||
|
|
||||||
xp_earned = session_state.get('session_xp', 0)
|
xp_earned = active.get('session_xp', 0)
|
||||||
level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1)
|
level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1)
|
||||||
total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0)
|
total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0)
|
||||||
xp_needed = level * 100
|
xp_needed = level * 100
|
||||||
|
|
@ -83,7 +65,7 @@ commits = session.get('commits_this_session', 0)
|
||||||
tools = session.get('tools_used', 0)
|
tools = session.get('tools_used', 0)
|
||||||
langs = session.get('languages_seen', [])
|
langs = session.get('languages_seen', [])
|
||||||
challenge_completed = session.get('challenge_completed', False)
|
challenge_completed = session.get('challenge_completed', False)
|
||||||
challenge = session_state.get('challenge')
|
challenge = active.get('challenge')
|
||||||
|
|
||||||
lines = [f"\n## 🐾 Session complete — {display}"]
|
lines = [f"\n## 🐾 Session complete — {display}"]
|
||||||
lines.append(f"**+{xp_earned} XP earned** this session")
|
lines.append(f"**+{xp_earned} XP earned** this session")
|
||||||
|
|
@ -94,27 +76,10 @@ if langs:
|
||||||
|
|
||||||
if leveled_up:
|
if leveled_up:
|
||||||
lines.append(f"\n✨ **LEVEL UP!** {display} is now Lv.{new_level}!")
|
lines.append(f"\n✨ **LEVEL UP!** {display} is now Lv.{new_level}!")
|
||||||
# Check if evolution is now available
|
|
||||||
catalog_entry = (catalog.get('buddymon', {}).get(buddy_id)
|
|
||||||
or catalog.get('evolutions', {}).get(buddy_id) or {})
|
|
||||||
evolutions = catalog_entry.get('evolutions', [])
|
|
||||||
evo = next((e for e in evolutions if new_level >= e.get('level', 999)), None)
|
|
||||||
if evo:
|
|
||||||
into = catalog.get('evolutions', {}).get(evo['into'], {})
|
|
||||||
lines.append(f"\n⭐ **EVOLUTION READY!** {display} can evolve into {into.get('display', evo['into'])}!")
|
|
||||||
lines.append(f" Run `/buddymon evolve` to prestige — resets to Lv.1 with upgraded stats.")
|
|
||||||
else:
|
else:
|
||||||
filled = min(20, total_xp * 20 // xp_needed)
|
filled = min(20, total_xp * 20 // xp_needed)
|
||||||
bar = '█' * filled + '░' * (20 - filled)
|
bar = '█' * filled + '░' * (20 - filled)
|
||||||
lines.append(f"XP: [{bar}] {total_xp}/{xp_needed}")
|
lines.append(f"XP: [{bar}] {total_xp}/{xp_needed}")
|
||||||
# Remind if already at evolution threshold but hasn't evolved yet
|
|
||||||
catalog_entry = (catalog.get('buddymon', {}).get(buddy_id)
|
|
||||||
or catalog.get('evolutions', {}).get(buddy_id) or {})
|
|
||||||
evolutions = catalog_entry.get('evolutions', [])
|
|
||||||
evo = next((e for e in evolutions if level >= e.get('level', 999)), None)
|
|
||||||
if evo:
|
|
||||||
into = catalog.get('evolutions', {}).get(evo['into'], {})
|
|
||||||
lines.append(f"\n⭐ **Evolution available:** `/buddymon evolve` → {into.get('display', evo['into'])}")
|
|
||||||
|
|
||||||
if challenge:
|
if challenge:
|
||||||
if challenge_completed:
|
if challenge_completed:
|
||||||
|
|
@ -126,74 +91,28 @@ print('\n'.join(lines))
|
||||||
PYEOF
|
PYEOF
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write handoff.json for next session to pick up
|
# Reset session XP counter for next session (keep total in roster)
|
||||||
python3 << PYEOF
|
python3 << PYEOF
|
||||||
import json, os
|
import json
|
||||||
from datetime import datetime, timezone
|
active_file = '${BUDDYMON_DIR}/active.json'
|
||||||
|
active = json.load(open(active_file))
|
||||||
session_state_file = '${SESSION_FILE}'
|
active['session_xp'] = 0
|
||||||
session_file = '${BUDDYMON_DIR}/session.json' # SESSION_DATA_FILE from state.sh
|
json.dump(active, open(active_file, 'w'), indent=2)
|
||||||
encounters_file = '${BUDDYMON_DIR}/encounters.json'
|
|
||||||
handoff_file = '${BUDDYMON_DIR}/handoff.json'
|
|
||||||
|
|
||||||
session_state = {}
|
|
||||||
try:
|
|
||||||
session_state = json.load(open(session_state_file))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
session_data = {}
|
|
||||||
try:
|
|
||||||
session_data = json.load(open(session_file))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
encounters = {}
|
|
||||||
try:
|
|
||||||
encounters = json.load(open(encounters_file))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Collect caught monsters from this session
|
|
||||||
caught_this_session = [
|
|
||||||
e.get('display', e.get('id', '?'))
|
|
||||||
for e in encounters.get('history', [])
|
|
||||||
if e.get('outcome') == 'caught'
|
|
||||||
and e.get('timestamp', '') >= datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
|
||||||
]
|
|
||||||
|
|
||||||
# Carry over any existing handoff notes (user-added via /buddymon note)
|
|
||||||
existing = {}
|
|
||||||
try:
|
|
||||||
existing = json.load(open(handoff_file))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
handoff = {
|
|
||||||
"date": datetime.now(timezone.utc).strftime('%Y-%m-%d'),
|
|
||||||
"buddy_id": session_state.get('buddymon_id'),
|
|
||||||
"xp_earned": session_state.get('session_xp', 0),
|
|
||||||
"commits": session_data.get('commits_this_session', 0),
|
|
||||||
"languages": session_data.get('languages_seen', []),
|
|
||||||
"caught": caught_this_session,
|
|
||||||
"challenge": session_state.get('challenge'),
|
|
||||||
"challenge_completed": session_data.get('challenge_completed', False),
|
|
||||||
"active_encounter": encounters.get('active_encounter'),
|
|
||||||
"notes": existing.get('notes', []), # preserve any manual notes
|
|
||||||
}
|
|
||||||
|
|
||||||
json.dump(handoff, open(handoff_file, 'w'), indent=2)
|
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
# Clean up this session's state file — each session is ephemeral
|
# Reset session file
|
||||||
rm -f "${SESSION_FILE}"
|
|
||||||
|
|
||||||
# Reset shared session.json for legacy compatibility
|
|
||||||
buddymon_session_reset
|
buddymon_session_reset
|
||||||
|
|
||||||
SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null)
|
SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null)
|
||||||
[[ -z "${SUMMARY_JSON}" ]] && SUMMARY_JSON='""'
|
[[ -z "${SUMMARY_JSON}" ]] && SUMMARY_JSON='""'
|
||||||
|
|
||||||
cat << EOF
|
cat << EOF
|
||||||
{"systemMessage": ${SUMMARY_JSON}}
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "Stop",
|
||||||
|
"additionalContext": ${SUMMARY_JSON}
|
||||||
|
}
|
||||||
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"SessionStart": [
|
"SessionStart": [
|
||||||
{
|
{
|
||||||
"matcher": "*",
|
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
|
|
@ -13,18 +12,6 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"matcher": "",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/user-prompt-submit.py",
|
|
||||||
"timeout": 5
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": "Bash|Edit|Write|MultiEdit",
|
"matcher": "Bash|Edit|Write|MultiEdit",
|
||||||
|
|
@ -39,7 +26,6 @@
|
||||||
],
|
],
|
||||||
"Stop": [
|
"Stop": [
|
||||||
{
|
{
|
||||||
"matcher": "*",
|
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
|
|
|
||||||
108
install.sh
108
install.sh
|
|
@ -9,8 +9,8 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PLUGIN_NAME="buddymon"
|
PLUGIN_NAME="buddymon"
|
||||||
MARKETPLACE="circuitforge"
|
MARKETPLACE="local"
|
||||||
VERSION="0.1.1"
|
VERSION="0.1.0"
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
PLUGINS_DIR="${HOME}/.claude/plugins"
|
PLUGINS_DIR="${HOME}/.claude/plugins"
|
||||||
|
|
@ -82,25 +82,11 @@ if key in d.get('enabledPlugins', {}):
|
||||||
PYEOF
|
PYEOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove from known_marketplaces.json
|
# Remove marketplace plugin symlink
|
||||||
KNOWN_MARKETPLACES="${PLUGINS_DIR}/known_marketplaces.json"
|
MARKETPLACE_PLUGIN_DIR="${PLUGINS_DIR}/marketplaces/${MARKETPLACE}/plugins/${PLUGIN_NAME}"
|
||||||
if [[ -f "${KNOWN_MARKETPLACES}" ]]; then
|
if [[ -L "${MARKETPLACE_PLUGIN_DIR}/${PLUGIN_NAME}" ]]; then
|
||||||
python3 << PYEOF
|
rm "${MARKETPLACE_PLUGIN_DIR}/${PLUGIN_NAME}"
|
||||||
import json
|
ok "Removed marketplace symlink"
|
||||||
f = '${KNOWN_MARKETPLACES}'
|
|
||||||
d = json.load(open(f))
|
|
||||||
if '${MARKETPLACE}' in d:
|
|
||||||
del d['${MARKETPLACE}']
|
|
||||||
json.dump(d, open(f, 'w'), indent=2)
|
|
||||||
print(" Removed '${MARKETPLACE}' from known_marketplaces.json")
|
|
||||||
PYEOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove marketplace plugin symlink (leave marketplace dir in case other CF plugins exist)
|
|
||||||
MARKETPLACE_PLUGIN_LINK="${PLUGINS_DIR}/marketplaces/${MARKETPLACE}/plugins/${PLUGIN_NAME}"
|
|
||||||
if [[ -L "${MARKETPLACE_PLUGIN_LINK}" ]]; then
|
|
||||||
rm "${MARKETPLACE_PLUGIN_LINK}"
|
|
||||||
ok "Removed marketplace plugin symlink"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -121,61 +107,39 @@ install() {
|
||||||
[[ -f "${REPO_DIR}/hooks/hooks.json" ]] \
|
[[ -f "${REPO_DIR}/hooks/hooks.json" ]] \
|
||||||
|| die "Missing hooks/hooks.json"
|
|| die "Missing hooks/hooks.json"
|
||||||
|
|
||||||
# Create circuitforge marketplace (CC validates plugin name against marketplace index)
|
# Register 'local' marketplace so CC doesn't GC the cache entry on reload
|
||||||
|
KNOWN_MARKETPLACES="${PLUGINS_DIR}/known_marketplaces.json"
|
||||||
MARKETPLACE_DIR="${PLUGINS_DIR}/marketplaces/${MARKETPLACE}"
|
MARKETPLACE_DIR="${PLUGINS_DIR}/marketplaces/${MARKETPLACE}"
|
||||||
mkdir -p "${MARKETPLACE_DIR}/.claude-plugin"
|
|
||||||
mkdir -p "${MARKETPLACE_DIR}/plugins"
|
|
||||||
|
|
||||||
if [[ ! -f "${MARKETPLACE_DIR}/.claude-plugin/marketplace.json" ]]; then
|
|
||||||
python3 << PYEOF
|
|
||||||
import json
|
|
||||||
f = '${MARKETPLACE_DIR}/.claude-plugin/marketplace.json'
|
|
||||||
d = {
|
|
||||||
"\$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
||||||
"name": "${MARKETPLACE}",
|
|
||||||
"description": "CircuitForge LLC Claude Code plugins",
|
|
||||||
"owner": {"name": "CircuitForge LLC", "email": "hello@circuitforge.tech"},
|
|
||||||
"plugins": [{
|
|
||||||
"name": "${PLUGIN_NAME}",
|
|
||||||
"description": "Collectible creatures discovered through coding — commit streaks, bug fights, and session challenges",
|
|
||||||
"author": {"name": "CircuitForge LLC", "email": "hello@circuitforge.tech"},
|
|
||||||
"source": "./plugins/${PLUGIN_NAME}",
|
|
||||||
"category": "productivity",
|
|
||||||
"homepage": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
json.dump(d, open(f, 'w'), indent=2)
|
|
||||||
PYEOF
|
|
||||||
ok "Created ${MARKETPLACE} marketplace"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Symlink repo into marketplace plugins dir
|
|
||||||
if [[ ! -L "${MARKETPLACE_DIR}/plugins/${PLUGIN_NAME}" ]]; then
|
|
||||||
ln -sf "${REPO_DIR}" "${MARKETPLACE_DIR}/plugins/${PLUGIN_NAME}"
|
|
||||||
ok "Linked into marketplace plugins dir"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Register marketplace in known_marketplaces.json
|
|
||||||
python3 << PYEOF
|
python3 << PYEOF
|
||||||
import json, os
|
import json, os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
f = '${PLUGINS_DIR}/known_marketplaces.json'
|
|
||||||
|
f = '${KNOWN_MARKETPLACES}'
|
||||||
try:
|
try:
|
||||||
d = json.load(open(f))
|
d = json.load(open(f))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
d = {}
|
d = {}
|
||||||
if '${MARKETPLACE}' not in d:
|
|
||||||
d['${MARKETPLACE}'] = {
|
if 'local' not in d:
|
||||||
|
d['local'] = {
|
||||||
"source": {"source": "local", "path": '${MARKETPLACE_DIR}'},
|
"source": {"source": "local", "path": '${MARKETPLACE_DIR}'},
|
||||||
"installLocation": '${MARKETPLACE_DIR}',
|
"installLocation": '${MARKETPLACE_DIR}',
|
||||||
"lastUpdated": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'),
|
"lastUpdated": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'),
|
||||||
}
|
}
|
||||||
json.dump(d, open(f, 'w'), indent=2)
|
json.dump(d, open(f, 'w'), indent=2)
|
||||||
print(" Registered '${MARKETPLACE}' in known_marketplaces.json")
|
print(" Registered 'local' marketplace")
|
||||||
else:
|
else:
|
||||||
print(" '${MARKETPLACE}' marketplace already registered")
|
print(" 'local' marketplace already registered")
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
|
# Symlink repo into marketplace plugins dir (so CC can discover it)
|
||||||
|
MARKETPLACE_PLUGIN_DIR="${MARKETPLACE_DIR}/plugins/${PLUGIN_NAME}"
|
||||||
|
mkdir -p "${MARKETPLACE_PLUGIN_DIR}"
|
||||||
|
if [[ ! -L "${MARKETPLACE_PLUGIN_DIR}/${PLUGIN_NAME}" ]]; then
|
||||||
|
ln -sf "${REPO_DIR}" "${MARKETPLACE_PLUGIN_DIR}/${PLUGIN_NAME}"
|
||||||
|
ok "Linked into marketplace dir"
|
||||||
|
fi
|
||||||
|
|
||||||
# Create cache parent dir
|
# Create cache parent dir
|
||||||
mkdir -p "$(dirname "${CACHE_DIR}")"
|
mkdir -p "$(dirname "${CACHE_DIR}")"
|
||||||
|
|
||||||
|
|
@ -270,30 +234,6 @@ for name, default in files.items():
|
||||||
print(f" Created {name}")
|
print(f" Created {name}")
|
||||||
else:
|
else:
|
||||||
print(f" {name} already exists — kept")
|
print(f" {name} already exists — kept")
|
||||||
|
|
||||||
# Pre-create hook_debug.log so the hook sandbox can write to it
|
|
||||||
log_path = os.path.join(d, 'hook_debug.log')
|
|
||||||
if not os.path.exists(log_path):
|
|
||||||
open(log_path, 'w').close()
|
|
||||||
print(" Created hook_debug.log")
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
# Copy statusline script to stable user-local path
|
|
||||||
cp "${REPO_DIR}/lib/statusline.sh" "${BUDDYMON_DIR}/statusline.sh"
|
|
||||||
chmod +x "${BUDDYMON_DIR}/statusline.sh"
|
|
||||||
ok "Installed statusline.sh → ${BUDDYMON_DIR}/statusline.sh"
|
|
||||||
|
|
||||||
# Install statusline into settings.json if not already configured
|
|
||||||
python3 << PYEOF
|
|
||||||
import json
|
|
||||||
f = '${SETTINGS_FILE}'
|
|
||||||
d = json.load(open(f))
|
|
||||||
if 'statusLine' not in d:
|
|
||||||
d['statusLine'] = {"type": "command", "command": "bash ${BUDDYMON_DIR}/statusline.sh"}
|
|
||||||
json.dump(d, open(f, 'w'), indent=2)
|
|
||||||
print(" Installed Buddymon statusline in settings.json")
|
|
||||||
else:
|
|
||||||
print(" statusLine already configured — skipped (run /buddymon statusline to install manually)")
|
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
1182
lib/catalog.json
1182
lib/catalog.json
File diff suppressed because it is too large
Load diff
|
|
@ -6,9 +6,7 @@ BUDDYMON_DIR="${HOME}/.claude/buddymon"
|
||||||
ROSTER_FILE="${BUDDYMON_DIR}/roster.json"
|
ROSTER_FILE="${BUDDYMON_DIR}/roster.json"
|
||||||
ENCOUNTERS_FILE="${BUDDYMON_DIR}/encounters.json"
|
ENCOUNTERS_FILE="${BUDDYMON_DIR}/encounters.json"
|
||||||
ACTIVE_FILE="${BUDDYMON_DIR}/active.json"
|
ACTIVE_FILE="${BUDDYMON_DIR}/active.json"
|
||||||
# Named SESSION_DATA_FILE (not SESSION_FILE) to avoid shadowing the
|
SESSION_FILE="${BUDDYMON_DIR}/session.json"
|
||||||
# per-session state file hooks define as SESSION_FILE=sessions/<pgrp>.json
|
|
||||||
SESSION_DATA_FILE="${BUDDYMON_DIR}/session.json"
|
|
||||||
|
|
||||||
buddymon_init() {
|
buddymon_init() {
|
||||||
mkdir -p "${BUDDYMON_DIR}"
|
mkdir -p "${BUDDYMON_DIR}"
|
||||||
|
|
@ -44,7 +42,7 @@ EOF
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "${SESSION_DATA_FILE}" ]]; then
|
if [[ ! -f "${SESSION_FILE}" ]]; then
|
||||||
buddymon_session_reset
|
buddymon_session_reset
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +50,7 @@ EOF
|
||||||
buddymon_session_reset() {
|
buddymon_session_reset() {
|
||||||
local ts
|
local ts
|
||||||
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
cat > "${SESSION_DATA_FILE}" << EOF
|
cat > "${SESSION_FILE}" << EOF
|
||||||
{
|
{
|
||||||
"_version": 1,
|
"_version": 1,
|
||||||
"started_at": "${ts}",
|
"started_at": "${ts}",
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Buddymon statusline — displays active buddy + encounter in the CC status bar.
|
|
||||||
# Install: add to ~/.claude/settings.json → "statusLine" → "command"
|
|
||||||
# Or run: /buddymon statusline (installs automatically)
|
|
||||||
|
|
||||||
B="$HOME/.claude/buddymon"
|
|
||||||
|
|
||||||
# Bail fast if no state directory or no starter chosen
|
|
||||||
[[ -d "$B" ]] || exit 0
|
|
||||||
STARTER=$(jq -r '.starter_chosen // false' "$B/roster.json" 2>/dev/null)
|
|
||||||
[[ "$STARTER" == "true" ]] || exit 0
|
|
||||||
|
|
||||||
# Read state
|
|
||||||
ID=$(jq -r '.buddymon_id // ""' "$B/active.json" 2>/dev/null)
|
|
||||||
[[ -n "$ID" ]] || exit 0
|
|
||||||
|
|
||||||
LVL=$(jq -r ".owned[\"$ID\"].level // 1" "$B/roster.json" 2>/dev/null)
|
|
||||||
XP=$(jq -r '.session_xp // 0' "$B/active.json" 2>/dev/null)
|
|
||||||
|
|
||||||
ENC_JSON=$(jq -c '.active_encounter // null' "$B/encounters.json" 2>/dev/null)
|
|
||||||
ENC_DISPLAY=$(echo "$ENC_JSON" | jq -r '.display // ""' 2>/dev/null)
|
|
||||||
ENC_STRENGTH=$(echo "$ENC_JSON" | jq -r '.current_strength // 0' 2>/dev/null)
|
|
||||||
|
|
||||||
# ANSI colors
|
|
||||||
CY='\033[38;2;23;146;153m' # cyan — buddy
|
|
||||||
GR='\033[38;2;64;160;43m' # green — xp
|
|
||||||
RD='\033[38;2;203;60;51m' # red — encounter
|
|
||||||
DM='\033[38;2;120;120;120m' # dim — separators
|
|
||||||
RS='\033[0m'
|
|
||||||
|
|
||||||
printf "${CY}🐾 ${ID} Lv.${LVL}${RS}"
|
|
||||||
printf " ${DM}·${RS} ${GR}+${XP}xp${RS}"
|
|
||||||
|
|
||||||
if [[ "$ENC_JSON" != "null" ]] && [[ -n "$ENC_DISPLAY" ]]; then
|
|
||||||
printf " ${RD}⚔ ${ENC_DISPLAY} [${ENC_STRENGTH}%%]${RS}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
|
|
@ -25,8 +25,6 @@ Parse `$ARGUMENTS` (trim whitespace, lowercase the first word) and dispatch:
|
||||||
| `fight` | Fight active encounter |
|
| `fight` | Fight active encounter |
|
||||||
| `catch` | Catch active encounter |
|
| `catch` | Catch active encounter |
|
||||||
| `roster` | Full roster view |
|
| `roster` | Full roster view |
|
||||||
| `evolve` | Evolve active buddy (available at Lv.100) |
|
|
||||||
| `statusline` | Install Buddymon statusline into settings.json |
|
|
||||||
| `help` | Show command list |
|
| `help` | Show command list |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -88,14 +86,10 @@ Ask for 1, 2, or 3. On choice, write to roster + active:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import json, os
|
import json, os
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon")
|
||||||
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
||||||
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
|
catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json"))
|
||||||
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
|
|
||||||
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
|
|
||||||
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
|
|
||||||
|
|
||||||
starters = ["Pyrobyte", "Debuglin", "Minimox"]
|
starters = ["Pyrobyte", "Debuglin", "Minimox"]
|
||||||
choice = starters[0] # replace with user's choice (index 0/1/2)
|
choice = starters[0] # replace with user's choice (index 0/1/2)
|
||||||
|
|
@ -122,60 +116,12 @@ Greet them and explain the encounter system.
|
||||||
|
|
||||||
## `assign <name>` — Assign Buddy
|
## `assign <name>` — Assign Buddy
|
||||||
|
|
||||||
Assignment is **per-session** — each Claude Code window can have its own buddy.
|
|
||||||
It writes to the session state file only, not the global default.
|
|
||||||
|
|
||||||
Fuzzy-match `<name>` against owned Buddymon (case-insensitive, partial).
|
Fuzzy-match `<name>` against owned Buddymon (case-insensitive, partial).
|
||||||
If ambiguous, list matches and ask which.
|
If ambiguous, list matches and ask which.
|
||||||
If no name given, list roster and ask.
|
If no name given, list roster and ask.
|
||||||
|
|
||||||
On match, show challenge proposal with Accept / Decline / Reroll, then write:
|
On match, update `active.json` (buddy_id, reset session_xp, set challenge).
|
||||||
|
Show challenge proposal with Accept / Decline / Reroll.
|
||||||
```python
|
|
||||||
import json, os, random
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
|
||||||
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
|
||||||
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
|
|
||||||
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
|
|
||||||
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
|
|
||||||
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
|
|
||||||
|
|
||||||
SESSION_KEY = str(os.getpgrp())
|
|
||||||
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
|
|
||||||
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
roster = json.load(open(BUDDYMON_DIR / "roster.json"))
|
|
||||||
|
|
||||||
buddy_id = "Debuglin" # replace with matched buddy id
|
|
||||||
buddy_catalog = (catalog.get("buddymon", {}).get(buddy_id)
|
|
||||||
or catalog.get("evolutions", {}).get(buddy_id) or {})
|
|
||||||
challenges = buddy_catalog.get("challenges", [])
|
|
||||||
|
|
||||||
# Load or init session state
|
|
||||||
try:
|
|
||||||
session_state = json.load(open(SESSION_FILE))
|
|
||||||
except Exception:
|
|
||||||
session_state = {}
|
|
||||||
|
|
||||||
session_state["buddymon_id"] = buddy_id
|
|
||||||
session_state["session_xp"] = 0
|
|
||||||
session_state["challenge"] = random.choice(challenges) if challenges else None
|
|
||||||
|
|
||||||
json.dump(session_state, open(SESSION_FILE, "w"), indent=2)
|
|
||||||
|
|
||||||
# Also update global default so new sessions inherit this assignment
|
|
||||||
active = {}
|
|
||||||
try:
|
|
||||||
active = json.load(open(BUDDYMON_DIR / "active.json"))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
active["buddymon_id"] = buddy_id
|
|
||||||
json.dump(active, open(BUDDYMON_DIR / "active.json", "w"), indent=2)
|
|
||||||
```
|
|
||||||
|
|
||||||
Show challenge proposal with Accept / Decline / Reroll (updating `session_state["challenge"]` accordingly).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -228,79 +174,32 @@ ShadowBit (🔒) cannot be defeated — redirect to catch.
|
||||||
|
|
||||||
Read active encounter. If none: "No active encounter."
|
Read active encounter. If none: "No active encounter."
|
||||||
|
|
||||||
**Immediately set `catch_pending = True`** on the encounter to suppress auto-resolve
|
Show strength and weakening status. Explain weaken actions:
|
||||||
while the weakening Q&A is in progress:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json, os
|
|
||||||
BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon")
|
|
||||||
enc_file = f"{BUDDYMON_DIR}/encounters.json"
|
|
||||||
encounters = json.load(open(enc_file))
|
|
||||||
enc = encounters.get("active_encounter")
|
|
||||||
if enc:
|
|
||||||
enc["catch_pending"] = True
|
|
||||||
encounters["active_encounter"] = enc
|
|
||||||
json.dump(encounters, open(enc_file, "w"), indent=2)
|
|
||||||
```
|
|
||||||
|
|
||||||
**If `enc.get("wounded")` is True (strength already at 5%), skip all weakening
|
|
||||||
Q&A and go straight to the catch roll — do not ask, just throw.**
|
|
||||||
|
|
||||||
Otherwise, show strength and weakening status. Explain weaken actions:
|
|
||||||
- Write a failing test → -20% strength
|
- Write a failing test → -20% strength
|
||||||
- Isolate reproduction case → -20% strength
|
- Isolate reproduction case → -20% strength
|
||||||
- Add documenting comment → -10% strength
|
- Add documenting comment → -10% strength
|
||||||
|
|
||||||
Ask which weakening actions have been done. Apply reductions to `current_strength`.
|
Ask which weakening actions have been done. Apply reductions to `current_strength`.
|
||||||
|
|
||||||
Catch roll (clear `catch_pending` before rolling — success clears encounter, failure
|
Catch roll:
|
||||||
leaves it active without the flag so auto-resolve resumes naturally):
|
|
||||||
```python
|
```python
|
||||||
import json, os, random, glob
|
import json, os, random
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
BUDDYMON_DIR = os.path.expanduser("~/.claude/buddymon")
|
||||||
|
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
||||||
|
catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json"))
|
||||||
|
|
||||||
# PLUGIN_ROOT is not always set when skills run via Bash heredoc — search known paths
|
enc_file = f"{BUDDYMON_DIR}/encounters.json"
|
||||||
def find_catalog():
|
active_file = f"{BUDDYMON_DIR}/active.json"
|
||||||
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
roster_file = f"{BUDDYMON_DIR}/roster.json"
|
||||||
candidates = [
|
|
||||||
Path(plugin_root) / "lib" / "catalog.json" if plugin_root else None,
|
|
||||||
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
|
|
||||||
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
|
|
||||||
]
|
|
||||||
for p in candidates:
|
|
||||||
if p and p.exists():
|
|
||||||
return json.load(open(p))
|
|
||||||
raise FileNotFoundError("buddymon catalog not found — check plugin installation")
|
|
||||||
|
|
||||||
catalog = find_catalog()
|
|
||||||
|
|
||||||
enc_file = BUDDYMON_DIR / "encounters.json"
|
|
||||||
active_file = BUDDYMON_DIR / "active.json"
|
|
||||||
roster_file = BUDDYMON_DIR / "roster.json"
|
|
||||||
|
|
||||||
encounters = json.load(open(enc_file))
|
encounters = json.load(open(enc_file))
|
||||||
|
active = json.load(open(active_file))
|
||||||
roster = json.load(open(roster_file))
|
roster = json.load(open(roster_file))
|
||||||
|
|
||||||
# Buddy lookup: prefer per-session file, fall back to active.json
|
|
||||||
SESSION_KEY = str(os.getpgrp())
|
|
||||||
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
|
|
||||||
try:
|
|
||||||
session_state = json.load(open(SESSION_FILE))
|
|
||||||
buddy_id = session_state.get("buddymon_id")
|
|
||||||
except Exception:
|
|
||||||
buddy_id = None
|
|
||||||
if not buddy_id:
|
|
||||||
active = json.load(open(active_file))
|
|
||||||
buddy_id = active.get("buddymon_id")
|
|
||||||
|
|
||||||
enc = encounters.get("active_encounter")
|
enc = encounters.get("active_encounter")
|
||||||
|
buddy_id = active.get("buddymon_id")
|
||||||
# catch_pending is cleared by the PostToolUse hook after it fires on this
|
|
||||||
# Bash run — do NOT clear it here or the hook will see it already gone and
|
|
||||||
# may auto-resolve the encounter on the same run as the catch attempt.
|
|
||||||
|
|
||||||
buddy_data = (catalog.get("buddymon", {}).get(buddy_id)
|
buddy_data = (catalog.get("buddymon", {}).get(buddy_id)
|
||||||
or catalog.get("evolutions", {}).get(buddy_id) or {})
|
or catalog.get("evolutions", {}).get(buddy_id) or {})
|
||||||
|
|
@ -321,15 +220,8 @@ if success:
|
||||||
"caught_at": datetime.now(timezone.utc).isoformat(),
|
"caught_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
roster.setdefault("owned", {})[enc["id"]] = caught_entry
|
roster.setdefault("owned", {})[enc["id"]] = caught_entry
|
||||||
# Write XP to session file if it exists, otherwise active.json
|
active["session_xp"] = active.get("session_xp", 0) + xp
|
||||||
try:
|
json.dump(active, open(active_file, "w"), indent=2)
|
||||||
ss = json.load(open(SESSION_FILE))
|
|
||||||
ss["session_xp"] = ss.get("session_xp", 0) + xp
|
|
||||||
json.dump(ss, open(SESSION_FILE, "w"), indent=2)
|
|
||||||
except Exception:
|
|
||||||
active = json.load(open(active_file))
|
|
||||||
active["session_xp"] = active.get("session_xp", 0) + xp
|
|
||||||
json.dump(active, open(active_file, "w"), indent=2)
|
|
||||||
if buddy_id and buddy_id in roster.get("owned", {}):
|
if buddy_id and buddy_id in roster.get("owned", {}):
|
||||||
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp
|
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp
|
||||||
json.dump(roster, open(roster_file, "w"), indent=2)
|
json.dump(roster, open(roster_file, "w"), indent=2)
|
||||||
|
|
@ -340,10 +232,6 @@ if success:
|
||||||
json.dump(encounters, open(enc_file, "w"), indent=2)
|
json.dump(encounters, open(enc_file, "w"), indent=2)
|
||||||
print(f"caught:{xp}")
|
print(f"caught:{xp}")
|
||||||
else:
|
else:
|
||||||
# Leave catch_pending as-is — the PostToolUse hook clears it after this
|
|
||||||
# Bash run completes, giving one full clean run before auto-resolve resumes.
|
|
||||||
encounters["active_encounter"] = enc
|
|
||||||
json.dump(encounters, open(enc_file, "w"), indent=2)
|
|
||||||
print(f"failed:{int(catch_rate * 100)}")
|
print(f"failed:{int(catch_rate * 100)}")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -371,168 +259,8 @@ Read roster and display:
|
||||||
🌐 CORSCurse — caught 2026-03-28
|
🌐 CORSCurse — caught 2026-03-28
|
||||||
|
|
||||||
❓ ??? — [n] more creatures to discover...
|
❓ ??? — [n] more creatures to discover...
|
||||||
|
|
||||||
🗺️ Language Affinities
|
|
||||||
──────────────────────────────────────────
|
|
||||||
🛠️ Python comfortable (Lv.2 · 183 XP)
|
|
||||||
📖 TypeScript familiar (Lv.1 · 72 XP)
|
|
||||||
🔭 Rust discovering (Lv.0 · 9 XP)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Tier emoji mapping:
|
|
||||||
- 🔭 discovering (0 XP)
|
|
||||||
- 📖 familiar (50 XP)
|
|
||||||
- 🛠️ comfortable (150 XP)
|
|
||||||
- ⚡ proficient (350 XP)
|
|
||||||
- 🎯 expert (700 XP)
|
|
||||||
- 👑 master (1200 XP)
|
|
||||||
|
|
||||||
Read `roster.json` → `language_affinities`. Skip this section if empty.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `evolve` — Evolve Buddy (Prestige)
|
|
||||||
|
|
||||||
Evolution is available when the active buddy is **Lv.100** (total XP ≥ 9,900).
|
|
||||||
Evolving resets the buddy to Lv.1 in their new form — but the evolved form has
|
|
||||||
higher base stats and a better XP multiplier, so the second climb is faster.
|
|
||||||
|
|
||||||
Read state and check eligibility:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json, os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
|
||||||
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
|
||||||
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
|
|
||||||
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
|
|
||||||
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
|
|
||||||
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
|
|
||||||
|
|
||||||
active = json.load(open(BUDDYMON_DIR / "active.json"))
|
|
||||||
roster = json.load(open(BUDDYMON_DIR / "roster.json"))
|
|
||||||
|
|
||||||
buddy_id = active.get("buddymon_id")
|
|
||||||
owned = roster.get("owned", {})
|
|
||||||
buddy_data = owned.get(buddy_id, {})
|
|
||||||
level = buddy_data.get("level", 1)
|
|
||||||
total_xp = buddy_data.get("xp", 0)
|
|
||||||
|
|
||||||
# Check evolution entry in catalog
|
|
||||||
catalog_entry = catalog.get("buddymon", {}).get(buddy_id) or catalog.get("evolutions", {}).get(buddy_id)
|
|
||||||
evolutions = catalog_entry.get("evolutions", []) if catalog_entry else []
|
|
||||||
evolution = next((e for e in evolutions if level >= e.get("level", 999)), None)
|
|
||||||
```
|
|
||||||
|
|
||||||
If `evolution` is None or level < 100: show current level and XP toward 100, no evolution available yet.
|
|
||||||
|
|
||||||
If eligible, show evolution preview:
|
|
||||||
|
|
||||||
```
|
|
||||||
╔══════════════════════════════════════════════════════════╗
|
|
||||||
║ ✨ Evolution Ready! ║
|
|
||||||
╠══════════════════════════════════════════════════════════╣
|
|
||||||
║ ║
|
|
||||||
║ 🔍 Debuglin Lv.100 → 🔬 Verifex ║
|
|
||||||
║ ║
|
|
||||||
║ Verifex: Sees the bug before the code is even written. ║
|
|
||||||
║ catch_rate: 0.60 → 0.75 · xp_multiplier: 1.0 → 1.3 ║
|
|
||||||
║ ║
|
|
||||||
║ ⚠️ Resets to Lv.1. Your caught monsters stay. ║
|
|
||||||
║ ║
|
|
||||||
╚══════════════════════════════════════════════════════════╝
|
|
||||||
Evolve? (y/n)
|
|
||||||
```
|
|
||||||
|
|
||||||
On confirm, execute the evolution:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
into_id = evolution["into"]
|
|
||||||
into_data = catalog["evolutions"][into_id]
|
|
||||||
|
|
||||||
# Archive old form with evolution marker
|
|
||||||
owned[buddy_id]["evolved_into"] = into_id
|
|
||||||
owned[buddy_id]["evolved_at"] = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
# Create new form entry at Lv.1
|
|
||||||
owned[into_id] = {
|
|
||||||
"id": into_id,
|
|
||||||
"display": into_data["display"],
|
|
||||||
"affinity": into_data.get("affinity", catalog_entry.get("affinity", "")),
|
|
||||||
"level": 1,
|
|
||||||
"xp": 0,
|
|
||||||
"evolved_from": buddy_id,
|
|
||||||
"evolved_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Carry challenges forward from original form
|
|
||||||
challenges = catalog_entry.get("challenges") or into_data.get("challenges", [])
|
|
||||||
roster["owned"] = owned
|
|
||||||
json.dump(roster, open(f"{BUDDYMON_DIR}/roster.json", "w"), indent=2)
|
|
||||||
|
|
||||||
# Update active to point to evolved form
|
|
||||||
active["buddymon_id"] = into_id
|
|
||||||
active["session_xp"] = 0
|
|
||||||
active["challenge"] = challenges[0] if challenges else None
|
|
||||||
json.dump(active, open(f"{BUDDYMON_DIR}/active.json", "w"), indent=2)
|
|
||||||
```
|
|
||||||
|
|
||||||
Show result:
|
|
||||||
```
|
|
||||||
✨ Debuglin evolved into 🔬 Verifex!
|
|
||||||
Starting fresh at Lv.1 — the second climb is faster.
|
|
||||||
New challenge: IRON TEST
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `statusline` — Install Buddymon Statusline
|
|
||||||
|
|
||||||
Installs the Buddymon statusline into `~/.claude/settings.json`.
|
|
||||||
|
|
||||||
The statusline shows active buddy + level + session XP, and highlights any
|
|
||||||
active encounter in red:
|
|
||||||
|
|
||||||
```
|
|
||||||
🐾 Debuglin Lv.90 · +45xp ⚔ 💀 NullWraith [60%]
|
|
||||||
```
|
|
||||||
|
|
||||||
Run this Python to install:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json, os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
||||||
# Prefer the stable user-local copy installed by install.sh
|
|
||||||
_script_candidates = [
|
|
||||||
Path.home() / ".claude/buddymon/statusline.sh",
|
|
||||||
Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "")) / "lib/statusline.sh" if os.environ.get("CLAUDE_PLUGIN_ROOT") else None,
|
|
||||||
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/statusline.sh",
|
|
||||||
]
|
|
||||||
SCRIPT = next(str(p) for p in _script_candidates if p and p.exists())
|
|
||||||
|
|
||||||
settings = json.load(open(SETTINGS))
|
|
||||||
|
|
||||||
if settings.get("statusLine"):
|
|
||||||
print("⚠️ A statusLine is already configured. Replace it? (y/n)")
|
|
||||||
# ask user — if no, abort
|
|
||||||
# if yes, proceed
|
|
||||||
pass
|
|
||||||
|
|
||||||
settings["statusLine"] = {
|
|
||||||
"type": "command",
|
|
||||||
"command": f"bash {SCRIPT}",
|
|
||||||
}
|
|
||||||
json.dump(settings, open(SETTINGS, "w"), indent=2)
|
|
||||||
print(f"✅ Buddymon statusline installed. Reload Claude Code to activate.")
|
|
||||||
```
|
|
||||||
|
|
||||||
If a `statusLine` is already set, show the existing command and ask before replacing.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `help`
|
## `help`
|
||||||
|
|
@ -544,5 +272,4 @@ If a `statusLine` is already set, show the existing command and ask before repla
|
||||||
/buddymon fight — fight active encounter
|
/buddymon fight — fight active encounter
|
||||||
/buddymon catch — catch active encounter
|
/buddymon catch — catch active encounter
|
||||||
/buddymon roster — view full roster
|
/buddymon roster — view full roster
|
||||||
/buddymon statusline — install statusline widget
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue