Compare commits
22 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa655ab9a | ||
|
|
c85bade62f | ||
|
|
8e0a5f82cb | ||
|
|
85af20b6f1 | ||
|
|
6632d67da4 | ||
|
|
85f53b1e83 | ||
|
|
55747068e1 | ||
|
|
0c311b099b | ||
|
|
a9c5610914 | ||
|
|
d2006727a1 | ||
|
|
75f3d9e179 | ||
|
|
6a81392074 | ||
|
|
1930bd29bd | ||
|
|
95ffc92e7d | ||
|
|
39b14f27be | ||
|
|
b0deb21d3a | ||
|
|
7b9e78a501 | ||
|
|
9b13150d1b | ||
|
|
e2a4b66267 | ||
|
|
507a2fc4a9 | ||
|
|
9440c8c0b2 | ||
|
|
b81f39bfcd |
11 changed files with 1838 additions and 179 deletions
|
|
@ -1,16 +1,11 @@
|
|||
{
|
||||
"name": "buddymon",
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
"url": "https://circuitforge.tech"
|
||||
"email": "hello@circuitforge.tech"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"primary": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon",
|
||||
"github": "https://github.com/CircuitForgeLLC/buddymon",
|
||||
"codeberg": "https://codeberg.org/CircuitForge/buddymon"
|
||||
}
|
||||
"repository": "https://git.opensourcesolarpunk.com/Circuit-Forge/buddymon"
|
||||
}
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -1,9 +1,19 @@
|
|||
# 🐾 Buddymon
|
||||
|
||||
A Claude Code plugin that turns your coding sessions into a creature-collecting game.
|
||||
A Claude Code **extension** 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.
|
||||
|
||||
> **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
|
||||
|
|
@ -40,8 +50,9 @@ Then **restart Claude Code** and run:
|
|||
```
|
||||
|
||||
The install script:
|
||||
- Symlinks the repo into `~/.claude/plugins/cache/local/buddymon/0.1.0/`
|
||||
- Registers the plugin in `~/.claude/plugins/installed_plugins.json`
|
||||
- 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/circuitforge/buddymon/<version>/`
|
||||
- Registers the plugin in `~/.claude/plugins/installed_plugins.json` and `~/.claude/plugins/known_marketplaces.json`
|
||||
- Enables it in `~/.claude/settings.json`
|
||||
- Creates `~/.claude/buddymon/` state directory with initial JSON files
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,36 @@ import re
|
|||
import sys
|
||||
import random
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
|
||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
||||
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
|
||||
|
||||
# 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 = {
|
||||
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
|
||||
".jsx": "JavaScript/React", ".tsx": "TypeScript/React",
|
||||
|
|
@ -61,21 +86,67 @@ def get_state():
|
|||
|
||||
|
||||
def add_session_xp(amount: int):
|
||||
active_file = BUDDYMON_DIR / "active.json"
|
||||
roster_file = BUDDYMON_DIR / "roster.json"
|
||||
|
||||
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)
|
||||
session = get_session_state()
|
||||
session["session_xp"] = session.get("session_xp", 0) + amount
|
||||
buddy_id = session.get("buddymon_id")
|
||||
save_session_state(session)
|
||||
|
||||
if buddy_id:
|
||||
roster_file = BUDDYMON_DIR / "roster.json"
|
||||
roster = load_json(roster_file)
|
||||
if buddy_id in roster.get("owned", {}):
|
||||
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount
|
||||
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():
|
||||
session = load_json(BUDDYMON_DIR / "session.json")
|
||||
return set(session.get("languages_seen", []))
|
||||
|
|
@ -104,8 +175,7 @@ def is_starter_chosen():
|
|||
|
||||
|
||||
def get_active_buddy_id():
|
||||
active = load_json(BUDDYMON_DIR / "active.json")
|
||||
return active.get("buddymon_id")
|
||||
return get_session_state().get("buddymon_id")
|
||||
|
||||
|
||||
def get_active_encounter():
|
||||
|
|
@ -120,6 +190,20 @@ def set_active_encounter(encounter: dict):
|
|||
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
|
||||
data["active_encounter"] = enc
|
||||
save_json(enc_file, data)
|
||||
|
||||
|
||||
def match_bug_monster(output_text: str, catalog: dict) -> dict | None:
|
||||
"""Return the first matching bug monster from the catalog, or None."""
|
||||
if not output_text:
|
||||
|
|
@ -224,6 +308,58 @@ def format_encounter_message(monster: dict, strength: int, buddy_display: str) -
|
|||
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:
|
||||
return (
|
||||
f"\n🗺️ **New language spotted: {lang}!**\n"
|
||||
|
|
@ -232,6 +368,23 @@ 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:
|
||||
if streak < 5:
|
||||
return ""
|
||||
|
|
@ -278,46 +431,73 @@ def main():
|
|||
|
||||
# ── Bash tool: error detection + auto-resolution + commit tracking ───────
|
||||
if tool_name == "Bash":
|
||||
command = tool_input.get("command", "")
|
||||
output = ""
|
||||
# CC Bash tool_response keys: stdout, stderr, interrupted, isImage, noOutputExpected
|
||||
if isinstance(tool_response, dict):
|
||||
output = tool_response.get("output", "") or tool_response.get("content", "")
|
||||
parts = [
|
||||
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):
|
||||
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()
|
||||
|
||||
if existing:
|
||||
# Auto-resolve if the monster's patterns no longer appear in output
|
||||
# On a clean Bash run (monster patterns gone), respect catch_pending,
|
||||
# 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):
|
||||
xp, display = auto_resolve_encounter(existing, buddy_id)
|
||||
messages.append(
|
||||
f"\n⚔️ **{buddy_display} defeated {display}!** (auto-resolved)\n"
|
||||
f" +{xp} XP\n"
|
||||
)
|
||||
# else: monster persists, no message — don't spam every tool call
|
||||
elif output:
|
||||
# No active encounter — check for a new one
|
||||
monster = match_bug_monster(output, catalog)
|
||||
if monster:
|
||||
# 70% chance to trigger (avoids every minor warning spawning)
|
||||
if random.random() < 0.70:
|
||||
if existing.get("catch_pending"):
|
||||
# User invoked /buddymon catch — hold the monster for them
|
||||
pass
|
||||
elif existing.get("wounded"):
|
||||
# Wounded: 35% chance to flee per clean run (avg ~3 runs to escape)
|
||||
if random.random() < 0.35:
|
||||
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:
|
||||
# Healthy: 50% chance to wound per clean run (avg ~2 runs to wound)
|
||||
if random.random() < 0.50:
|
||||
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)
|
||||
encounter = {
|
||||
"id": monster["id"],
|
||||
"display": monster["display"],
|
||||
"base_strength": monster.get("base_strength", 50),
|
||||
"current_strength": strength,
|
||||
"catchable": monster.get("catchable", True),
|
||||
"defeatable": monster.get("defeatable", True),
|
||||
"xp_reward": monster.get("xp_reward", 50),
|
||||
"weakened_by": [],
|
||||
}
|
||||
set_active_encounter(encounter)
|
||||
msg = format_encounter_message(monster, strength, buddy_display)
|
||||
messages.append(msg)
|
||||
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),
|
||||
"weakened_by": [],
|
||||
"announced": False,
|
||||
}
|
||||
set_active_encounter(encounter)
|
||||
|
||||
# Commit detection
|
||||
command = tool_input.get("command", "")
|
||||
if "git commit" in command and "exit_code" not in str(tool_response):
|
||||
session_file = BUDDYMON_DIR / "session.json"
|
||||
session = load_json(session_file)
|
||||
|
|
@ -327,13 +507,14 @@ def main():
|
|||
commit_xp = 20
|
||||
add_session_xp(commit_xp)
|
||||
|
||||
# ── Write / Edit: new language detection ──────────────────────────────
|
||||
# ── Write / Edit: new language detection + affinity + test file encounters ─
|
||||
elif tool_name in ("Write", "Edit", "MultiEdit"):
|
||||
file_path = tool_input.get("file_path", "")
|
||||
if file_path:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
lang = KNOWN_EXTENSIONS.get(ext)
|
||||
if lang:
|
||||
# Session-level "first encounter" bonus
|
||||
seen = get_languages_seen()
|
||||
if lang not in seen:
|
||||
add_language_seen(lang)
|
||||
|
|
@ -341,6 +522,19 @@ def main():
|
|||
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)
|
||||
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
|
||||
add_session_xp(2)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,31 @@ source "${PLUGIN_ROOT}/lib/state.sh"
|
|||
|
||||
buddymon_init
|
||||
|
||||
ACTIVE_ID=$(buddymon_get_active)
|
||||
SESSION_XP=$(buddymon_get_session_xp)
|
||||
# Per-session state — keyed by process group ID so parallel sessions are isolated.
|
||||
SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())")
|
||||
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
|
||||
|
||||
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
|
||||
CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
|
||||
|
|
@ -16,6 +39,77 @@ CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
|
|||
build_context() {
|
||||
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 ─────────────────────────────────────────────
|
||||
if [[ "$(buddymon_starter_chosen)" == "false" ]]; then
|
||||
ctx="## 🐾 Buddymon — First Encounter!\n\n"
|
||||
|
|
@ -93,14 +187,28 @@ if ch:
|
|||
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))")
|
||||
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
|
||||
|
||||
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}"
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
# Escape for JSON
|
||||
|
|
|
|||
|
|
@ -6,14 +6,29 @@ source "${PLUGIN_ROOT}/lib/state.sh"
|
|||
|
||||
buddymon_init
|
||||
|
||||
ACTIVE_ID=$(buddymon_get_active)
|
||||
SESSION_XP=$(buddymon_get_session_xp)
|
||||
SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())")
|
||||
SESSION_FILE="${BUDDYMON_DIR}/sessions/${SESSION_KEY}.json"
|
||||
|
||||
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
|
||||
# Nothing to report
|
||||
cat << 'EOF'
|
||||
{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": ""}}
|
||||
EOF
|
||||
[[ -f "${SESSION_FILE}" ]] && rm -f "${SESSION_FILE}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -24,17 +39,20 @@ SUMMARY=$(python3 << PYEOF
|
|||
import json, os
|
||||
|
||||
catalog_file = '${CATALOG}'
|
||||
active_file = '${BUDDYMON_DIR}/active.json'
|
||||
session_state_file = '${SESSION_FILE}'
|
||||
roster_file = '${BUDDYMON_DIR}/roster.json'
|
||||
session_file = '${BUDDYMON_DIR}/session.json'
|
||||
encounters_file = '${BUDDYMON_DIR}/encounters.json'
|
||||
|
||||
catalog = json.load(open(catalog_file))
|
||||
active = json.load(open(active_file))
|
||||
session_state = json.load(open(session_state_file))
|
||||
roster = json.load(open(roster_file))
|
||||
session = json.load(open(session_file))
|
||||
session = {}
|
||||
try:
|
||||
session = json.load(open(session_file))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
buddy_id = active.get('buddymon_id')
|
||||
buddy_id = session_state.get('buddymon_id')
|
||||
if not buddy_id:
|
||||
print('')
|
||||
exit()
|
||||
|
|
@ -43,7 +61,7 @@ b = (catalog.get('buddymon', {}).get(buddy_id)
|
|||
or catalog.get('evolutions', {}).get(buddy_id) or {})
|
||||
display = b.get('display', buddy_id)
|
||||
|
||||
xp_earned = active.get('session_xp', 0)
|
||||
xp_earned = session_state.get('session_xp', 0)
|
||||
level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1)
|
||||
total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0)
|
||||
xp_needed = level * 100
|
||||
|
|
@ -65,7 +83,7 @@ commits = session.get('commits_this_session', 0)
|
|||
tools = session.get('tools_used', 0)
|
||||
langs = session.get('languages_seen', [])
|
||||
challenge_completed = session.get('challenge_completed', False)
|
||||
challenge = active.get('challenge')
|
||||
challenge = session_state.get('challenge')
|
||||
|
||||
lines = [f"\n## 🐾 Session complete — {display}"]
|
||||
lines.append(f"**+{xp_earned} XP earned** this session")
|
||||
|
|
@ -76,10 +94,27 @@ if langs:
|
|||
|
||||
if leveled_up:
|
||||
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:
|
||||
filled = min(20, total_xp * 20 // xp_needed)
|
||||
bar = '█' * filled + '░' * (20 - filled)
|
||||
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_completed:
|
||||
|
|
@ -91,28 +126,74 @@ print('\n'.join(lines))
|
|||
PYEOF
|
||||
)
|
||||
|
||||
# Reset session XP counter for next session (keep total in roster)
|
||||
# Write handoff.json for next session to pick up
|
||||
python3 << PYEOF
|
||||
import json
|
||||
active_file = '${BUDDYMON_DIR}/active.json'
|
||||
active = json.load(open(active_file))
|
||||
active['session_xp'] = 0
|
||||
json.dump(active, open(active_file, 'w'), indent=2)
|
||||
import json, os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
session_state_file = '${SESSION_FILE}'
|
||||
session_file = '${BUDDYMON_DIR}/session.json'
|
||||
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
|
||||
|
||||
# Reset session file
|
||||
# Clean up this session's state file — each session is ephemeral
|
||||
rm -f "${SESSION_FILE}"
|
||||
|
||||
# Reset shared session.json for legacy compatibility
|
||||
buddymon_session_reset
|
||||
|
||||
SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null)
|
||||
[[ -z "${SUMMARY_JSON}" ]] && SUMMARY_JSON='""'
|
||||
|
||||
cat << EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "Stop",
|
||||
"additionalContext": ${SUMMARY_JSON}
|
||||
}
|
||||
}
|
||||
{"systemMessage": ${SUMMARY_JSON}}
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
|
|
|
|||
145
hooks-handlers/user-prompt-submit.py
Normal file
145
hooks-handlers/user-prompt-submit.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
#!/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()
|
||||
|
|
@ -6,19 +6,31 @@
|
|||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh\"",
|
||||
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/user-prompt-submit.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/post-tool-use.py\"",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/post-tool-use.py",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
|
|
@ -29,7 +41,7 @@
|
|||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-stop.sh\"",
|
||||
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-stop.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
|
|
|
|||
104
install.sh
104
install.sh
|
|
@ -9,8 +9,8 @@
|
|||
set -euo pipefail
|
||||
|
||||
PLUGIN_NAME="buddymon"
|
||||
MARKETPLACE="local"
|
||||
VERSION="0.1.0"
|
||||
MARKETPLACE="circuitforge"
|
||||
VERSION="0.1.1"
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
PLUGINS_DIR="${HOME}/.claude/plugins"
|
||||
|
|
@ -82,6 +82,27 @@ if key in d.get('enabledPlugins', {}):
|
|||
PYEOF
|
||||
fi
|
||||
|
||||
# Remove from known_marketplaces.json
|
||||
KNOWN_MARKETPLACES="${PLUGINS_DIR}/known_marketplaces.json"
|
||||
if [[ -f "${KNOWN_MARKETPLACES}" ]]; then
|
||||
python3 << PYEOF
|
||||
import json
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo "✓ ${PLUGIN_KEY} uninstalled. Restart Claude Code to apply."
|
||||
}
|
||||
|
|
@ -100,6 +121,61 @@ install() {
|
|||
[[ -f "${REPO_DIR}/hooks/hooks.json" ]] \
|
||||
|| die "Missing hooks/hooks.json"
|
||||
|
||||
# Create circuitforge marketplace (CC validates plugin name against marketplace index)
|
||||
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
|
||||
import json, os
|
||||
from datetime import datetime, timezone
|
||||
f = '${PLUGINS_DIR}/known_marketplaces.json'
|
||||
try:
|
||||
d = json.load(open(f))
|
||||
except FileNotFoundError:
|
||||
d = {}
|
||||
if '${MARKETPLACE}' not in d:
|
||||
d['${MARKETPLACE}'] = {
|
||||
"source": {"source": "local", "path": '${MARKETPLACE_DIR}'},
|
||||
"installLocation": '${MARKETPLACE_DIR}',
|
||||
"lastUpdated": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'),
|
||||
}
|
||||
json.dump(d, open(f, 'w'), indent=2)
|
||||
print(" Registered '${MARKETPLACE}' in known_marketplaces.json")
|
||||
else:
|
||||
print(" '${MARKETPLACE}' marketplace already registered")
|
||||
PYEOF
|
||||
|
||||
# Create cache parent dir
|
||||
mkdir -p "$(dirname "${CACHE_DIR}")"
|
||||
|
||||
|
|
@ -194,6 +270,30 @@ for name, default in files.items():
|
|||
print(f" Created {name}")
|
||||
else:
|
||||
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
|
||||
|
||||
echo ""
|
||||
|
|
|
|||
936
lib/catalog.json
936
lib/catalog.json
File diff suppressed because it is too large
Load diff
38
lib/statusline.sh
Normal file
38
lib/statusline.sh
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/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,6 +25,8 @@ Parse `$ARGUMENTS` (trim whitespace, lowercase the first word) and dispatch:
|
|||
| `fight` | Fight active encounter |
|
||||
| `catch` | Catch active encounter |
|
||||
| `roster` | Full roster view |
|
||||
| `evolve` | Evolve active buddy (available at Lv.100) |
|
||||
| `statusline` | Install Buddymon statusline into settings.json |
|
||||
| `help` | Show command list |
|
||||
|
||||
---
|
||||
|
|
@ -116,12 +118,57 @@ Greet them and explain the encounter system.
|
|||
|
||||
## `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).
|
||||
If ambiguous, list matches and ask which.
|
||||
If no name given, list roster and ask.
|
||||
|
||||
On match, update `active.json` (buddy_id, reset session_xp, set challenge).
|
||||
Show challenge proposal with Accept / Decline / Reroll.
|
||||
On match, show challenge proposal with Accept / Decline / Reroll, then write:
|
||||
|
||||
```python
|
||||
import json, os, random
|
||||
from pathlib import Path
|
||||
|
||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
||||
catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json"))
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -174,14 +221,31 @@ ShadowBit (🔒) cannot be defeated — redirect to catch.
|
|||
|
||||
Read active encounter. If none: "No active encounter."
|
||||
|
||||
Show strength and weakening status. Explain weaken actions:
|
||||
**Immediately set `catch_pending = True`** on the encounter to suppress auto-resolve
|
||||
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)
|
||||
```
|
||||
|
||||
Show strength and weakening status. If `enc.get("wounded")` is True, note that
|
||||
it's already at 5% and a catch is near-guaranteed. Explain weaken actions:
|
||||
- Write a failing test → -20% strength
|
||||
- Isolate reproduction case → -20% strength
|
||||
- Add documenting comment → -10% strength
|
||||
|
||||
Ask which weakening actions have been done. Apply reductions to `current_strength`.
|
||||
|
||||
Catch roll:
|
||||
Catch roll (clear `catch_pending` before rolling — success clears encounter, failure
|
||||
leaves it active without the flag so auto-resolve resumes naturally):
|
||||
```python
|
||||
import json, os, random
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -201,6 +265,9 @@ roster = json.load(open(roster_file))
|
|||
enc = encounters.get("active_encounter")
|
||||
buddy_id = active.get("buddymon_id")
|
||||
|
||||
# Clear catch_pending before rolling (win or lose)
|
||||
enc["catch_pending"] = False
|
||||
|
||||
buddy_data = (catalog.get("buddymon", {}).get(buddy_id)
|
||||
or catalog.get("evolutions", {}).get(buddy_id) or {})
|
||||
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
|
||||
|
|
@ -232,6 +299,9 @@ if success:
|
|||
json.dump(encounters, open(enc_file, "w"), indent=2)
|
||||
print(f"caught:{xp}")
|
||||
else:
|
||||
# Save cleared catch_pending back on failure
|
||||
encounters["active_encounter"] = enc
|
||||
json.dump(encounters, open(enc_file, "w"), indent=2)
|
||||
print(f"failed:{int(catch_rate * 100)}")
|
||||
```
|
||||
|
||||
|
|
@ -259,8 +329,158 @@ Read roster and display:
|
|||
🌐 CORSCurse — caught 2026-03-28
|
||||
|
||||
❓ ??? — [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
|
||||
|
||||
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"))
|
||||
|
||||
active = json.load(open(f"{BUDDYMON_DIR}/active.json"))
|
||||
roster = json.load(open(f"{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, shutil
|
||||
|
||||
SETTINGS = os.path.expanduser("~/.claude/settings.json")
|
||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
||||
SCRIPT = os.path.join(PLUGIN_ROOT, "lib", "statusline.sh")
|
||||
|
||||
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`
|
||||
|
|
@ -272,4 +492,5 @@ Read roster and display:
|
|||
/buddymon fight — fight active encounter
|
||||
/buddymon catch — catch active encounter
|
||||
/buddymon roster — view full roster
|
||||
/buddymon statusline — install statusline widget
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in a new issue