Compare commits

...

22 commits
v0.1.0 ... main

Author SHA1 Message Date
pyr0ball
caa655ab9a feat: session handoff — persist last-session summary across CC restarts
session-stop.sh writes ~/.claude/buddymon/handoff.json with: buddy id,
XP earned, commit count, languages touched, caught monsters, challenge
state, any active encounter, and manual notes (for future /buddymon note).

session-start.sh reads handoff.json on next session start, injects a
'📬 From your last session' block into additionalContext, then removes
the file so it only fires once.

Closes #1 on Circuit-Forge/buddymon.
2026-04-02 23:15:01 -07:00
pyr0ball
c85bade62f feat: per-session buddy isolation via PGRP-keyed state files
Each Claude Code session now gets its own state file at:
  ~/.claude/buddymon/sessions/<pgrp>.json

Contains: buddymon_id, session_xp, challenge — all session-local.
Global active.json keeps the default buddymon_id for new sessions.

/buddymon assign writes to the session file only, so assigning in one
terminal window doesn't affect other open sessions. Each window can
have its own buddy assigned independently.

SessionStart creates the session file (inheriting global default).
SessionStop reads XP from it, writes to roster, then removes it.
2026-04-02 23:11:19 -07:00
pyr0ball
8e0a5f82cb feat: evolution system — prestige to evolved form at Lv.100
Evolution triggers at Lv.100 for all three starters:
  Pyrobyte → 🌋 Infernus  (power 40→70, catch_rate 0.45→0.55)
  Debuglin → 🔬 Verifex   (power 35→60, catch_rate 0.60→0.75)
  Minimox  → 🌑 Nullex    (power 35→55, catch_rate 0.50→0.65)

/buddymon evolve: checks eligibility, shows stat preview, resets buddy
to Lv.1 in evolved form, archives old form with evolved_into marker,
carries challenges forward.

session-stop.sh now prints EVOLUTION READY banner when level hits 100
or when already eligible at session end.
2026-04-02 23:08:32 -07:00
pyr0ball
85af20b6f1 feat: 6 control-flow bug monsters
InfiniteWisp  — while/until loops that never exit (KeyboardInterrupt, hung process)
BoundsHound   — off-by-one, IndexError, ArrayIndexOutOfBounds
BranchGhost   — wrong branch taken, unreachable/dead code, fallthrough
SwitchTrap    — unhandled case/switch arms, non-exhaustive match, missing default
RecurseWraith — missing base case, RecursionError, StackOverflow
CatchAll      — broad exception handlers; rare, defeat=false (catch only)

Total bug_monsters: 18
2026-04-02 22:40:38 -07:00
pyr0ball
6632d67da4 nerf: probability gates on wound and flee transitions
Healthy → wounded: 50% per clean run (avg ~2 runs, was instant)
Wounded → fled: 35% per clean run (avg ~3 runs, was instant)

Combined expected clean runs before auto-resolve: ~5, giving the user
a realistic window to type /buddymon catch between tool calls.
2026-04-02 22:39:07 -07:00
pyr0ball
85f53b1e83 feat: catch-pending flag + wounded state for encounter resolution
catch_pending: set immediately when /buddymon catch is invoked, suppresses
auto-resolve while weakening Q&A is in progress. Cleared before catch roll
(success clears encounter, failure leaves it without the flag so auto-resolve
resumes naturally on the next clean Bash run).

wounded: first clean Bash run without catch_pending drops the encounter to 5%
strength and re-announces via UserPromptSubmit with a fleeing message. Second
clean run auto-resolves it (it fled). UserPromptSubmit now shows distinct
announcement text for wounded vs fresh encounters.
2026-04-02 22:37:35 -07:00
pyr0ball
55747068e1 feat: expand MemoryLeech patterns + add CudaCrash bug_monster
MemoryLeech now catches: malloc failures, std::bad_alloc, Java OOM,
GC overhead limit, JavaScript heap OOM, OOMKilled, oom-killer,
macOS malloc region failures.

CudaCrash is a new uncommon bug_monster (strength 65, 130 XP) for
GPU/VRAM OOM: torch.cuda.OutOfMemoryError, CUDA error: out of memory,
cuDNN/CUBLAS allocation failures, device-side assert triggered.
2026-04-02 22:33:52 -07:00
pyr0ball
0c311b099b feat: buddymon statusline widget
Adds lib/statusline.sh — a fast bash+jq status bar widget showing
active buddy, level, session XP, and any active encounter in red.

install.sh now copies the script to ~/.claude/buddymon/statusline.sh
and wires it into settings.json automatically during install.

/buddymon statusline subcommand documented in SKILL.md for manual install.
2026-04-02 22:31:37 -07:00
pyr0ball
a9c5610914 feat: language affinity system — persistent XP + tier progression
Adds LANGUAGE_TIERS with 6 tiers: discovering → familiar → comfortable
→ proficient → expert → master (thresholds: 0/50/150/350/700/1200 XP).

add_language_affinity() writes to roster.json['language_affinities'],
accumulating across sessions. Returns (leveled_up, old_tier, new_tier)
so the Edit/Write branch can fire a level-up message immediately (Edit
PostToolUse additionalContext surfaces fine).

Session-level languages_seen remains for the one-time Explorer bonus.
Roster skill view updated to show language affinity section.
2026-04-02 22:23:31 -07:00
pyr0ball
d2006727a1 feat: add LayerLurker and DiskDemon event encounters
LayerLurker triggers on docker/podman build commands.
DiskDemon triggers on ENOSPC and disk-full output patterns.
2026-04-02 22:21:31 -07:00
pyr0ball
75f3d9e179 feat: add ReviewHawk, TicketGremlin, PermWraith, SudoSprite encounters
ReviewHawk  — gh pr create / push -u (catch-only, opens PR)
TicketGremlin — jira/linear CLI, curl to issue tracker APIs
PermWraith  — Permission denied / EACCES / EPERM output
SudoSprite  — chmod / chown / chgrp commands (catch-only)

Also switch command_patterns matching to re.search so patterns
with .* work correctly (e.g. git push.*--set-upstream).
2026-04-02 22:16:00 -07:00
pyr0ball
6a81392074 feat: expand encounter triggers — git ops, installs, test events, test files
New event_encounters catalog section:
  🔀 MergeMaw     — git merge / rebase
  🌿 BranchSprite — git checkout -b / switch -c (catch-only)
  📦 DepGolem     — pip/npm/cargo/yarn/brew install
  🎲 FlakeDemon   — test failure output (FAILED, AssertionError, etc.)
   PhantomPass  — tests pass after session errors (rare, catch-only)
  🧪 TestSpecter  — editing a test file (50% chance)

Detection logic:
  - Bash hook now checks tool_input.command for command-pattern triggers
  - Event encounters run when no bug monster matched (priority: bugs first)
  - Edit/Write hook adds TestSpecter check on test file paths
2026-04-02 22:11:31 -07:00
pyr0ball
1930bd29bd fix: rotate challenge each session
Stop hook clears active.challenge after reporting it.
Start hook assigns a random challenge from the buddy's pool if none is set.
Result: fresh challenge every session, no stale repeat.
2026-04-02 12:20:19 -07:00
pyr0ball
95ffc92e7d docs: companion widget note is user-configured, not Anthropic-shipped 2026-04-02 11:22:50 -07:00
pyr0ball
39b14f27be fix: correct Bash output extraction + README Thrumble relationship note
- CC Bash tool_response uses stdout/stderr keys (not output/content/text)
- Encounter detection now works — confirmed TypeGreml match via stderr
- README: clarify Thrumble is Anthropic's widget, not driveable by plugins
2026-04-02 11:22:16 -07:00
pyr0ball
b0deb21d3a fix: use stdout/stderr fields for Bash tool_response output extraction
CC sends Bash results as {stdout, stderr, interrupted, isImage, noOutputExpected}.
Previous code guessed output/content/text — all wrong, so encounter detection
never matched. Confirmed via active.json relay debug.
2026-04-02 11:20:00 -07:00
pyr0ball
7b9e78a501 fix: add matcher field to UserPromptSubmit hook (semgrep pattern) 2026-04-01 23:13:27 -07:00
pyr0ball
9b13150d1b feat: UserPromptSubmit hook for encounter announcements
Bash PostToolUse additionalContext is silently dropped by CC — encounters
are written to state but never surfaced. Fix with a two-phase approach:

- PostToolUse (Bash): detects error, writes encounter with announced:false
- UserPromptSubmit: fires on next user message, checks for unannounced
  encounter, surfaces it once, marks announced:true so dedup loop breaks

Removes debug scaffolding and the format_encounter_message call from the
Bash hook (announcement is now fully owned by user-prompt-submit.py).
2026-04-01 23:08:57 -07:00
pyr0ball
e2a4b66267 fix: stop hook schema, uninstall cleanup, and README architecture note
- Fix session-stop.sh in 0.1.0 cache to use systemMessage instead of
  hookSpecificOutput (Stop hook schema doesn't support hookSpecificOutput)
- Remove debug scaffolding from post-tool-use.py
- Installer: pre-create hook_debug.log so sandbox can write to it;
  uninstall now removes marketplace plugin symlink
- README: clarify extension vs mod architecture, fix cache path in
  install description
2026-04-01 22:49:12 -07:00
pyr0ball
507a2fc4a9 fix: correct hook output schemas and marketplace registration
Stop hook was emitting hookSpecificOutput with hookEventName=Stop,
which is not a valid hookSpecificOutput type (only PreToolUse,
PostToolUse, UserPromptSubmit are). Changed to systemMessage.

SessionStart still uses additionalContext (confirmed working).

Stale /buddymon-fight and /buddymon-catch references in session-start.sh
updated to /buddymon fight and /buddymon catch.

install.sh now creates a full circuitforge marketplace with marketplace.json
so CC can validate the plugin name against the index before loading.
Removed invalid extraKnownMarketplaces local source (only github/git valid).
2026-04-01 21:45:59 -07:00
pyr0ball
9440c8c0b2 fix: installPath must be cache symlink path, not realpath
CC requires installPath to be inside ~/.claude/plugins/cache/ — resolved
real path breaks plugin loading. Version bump to 0.1.1 in install.sh.
2026-04-01 17:05:31 -07:00
pyr0ball
b81f39bfcd fix: plugin registration and reload survival
- plugin.json: flatten repository to string, remove extra fields that
  failed CC schema validation (caused 'Unknown skill' on reload)
- hooks.json: remove escaped quotes from command paths (matched hookify
  reference implementation)
- install.sh: register 'local' marketplace in known_marketplaces.json
  so CC doesn't GC the cache symlink on /reload-plugins
2026-04-01 16:52:09 -07:00
11 changed files with 1838 additions and 179 deletions

View file

@ -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"
}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View 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()

View file

@ -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
}
]

View file

@ -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 ""

File diff suppressed because it is too large Load diff

38
lib/statusline.sh Normal file
View 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

View file

@ -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
```