Compare commits

...

29 commits
v0.1.0 ... main

Author SHA1 Message Date
pyr0ball
b3b1813e9c feat: rarity + level scaled auto-attacks, wound cooldown for parallel sessions
Auto-attack rates were flat 50%/35% regardless of encounter rarity or buddy
level, and parallel sessions could each roll independently against the same
encounter — compounding to ~87.5% effective wound rate with 3 sessions open.

Wound rates by rarity (base, before level scaling):
  very_common: 55%  common: 40%  uncommon: 22%  rare: 10%  legendary: 2%

Auto-resolve rates by rarity:
  very_common: 45%  common: 28%  uncommon: 14%  rare: 5%   legendary: 1%

Level multiplier: 1.0 + (level / 100) * 0.25 — Lv.44 adds ~11% to each.
So Lv.44 vs common: wound=44%, resolve=31%. Lv.44 vs rare: wound=11%, resolve=6%.

Wound cooldown: encounters stamped with last_wounded_at. Any session that
would wound/resolve within 30s of the last wound is skipped — prevents
parallel sessions from pile-driving the same encounter.
2026-04-06 00:01:10 -07:00
pyr0ball
c12a234652 fix: catch_pending race condition — hook clears flag, not the roll script
Previously the catch roll script wrote catch_pending=False to disk before
the Bash process exited. The PostToolUse hook fired after exit, saw False,
and could auto-resolve the encounter on the same run as the catch attempt.

Fix: the hook now owns the flag lifecycle.
- Hook clears catch_pending after consuming it (protects exactly one run)
- Roll script never touches catch_pending (no premature clear)
- On failure, flag stays True through the roll, cleared by hook afterward
- Next /buddymon catch call re-sets it at the top as before

Net effect: a failed catch attempt always gets one full clean Bash run of
grace before auto-resolve can fire again.
2026-04-05 23:58:08 -07:00
pyr0ball
910da843fe fix: robust catalog path resolution when CLAUDE_PLUGIN_ROOT unset
Skills run via Bash heredoc don't inherit CLAUDE_PLUGIN_ROOT, so
f"{PLUGIN_ROOT}/lib/catalog.json" collapsed to /lib/catalog.json.

All four PLUGIN_ROOT usages in SKILL.md now try:
  1. $CLAUDE_PLUGIN_ROOT/lib/catalog.json  (if env var is set)
  2. ~/.claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json
  3. ~/.claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json

statusline block also fixed: prefers ~/.claude/buddymon/statusline.sh
(stable user-local copy from install.sh) over plugin-relative paths.
2026-04-05 23:40:57 -07:00
pyr0ball
1ed888db72 fix: skill catch — auto-throw at 5%, session file fallback to active.json
- wounded encounters (5% strength) skip weakening Q&A and go straight
  to the throw — no more 'want to throw?' prompt at 5%
- catch roll now tries sessions/<pgrp>.json first, falls back to
  active.json if missing (fixes FileNotFoundError in new windows that
  haven't run any tools yet)
- XP write follows the same fallback pattern
2026-04-05 23:32:03 -07:00
pyr0ball
c3026592e5 fix: rename Sedamentalist → Sedamentalisk (more creature-like) 2026-04-05 23:26:49 -07:00
pyr0ball
849485ca2d feat: add Sedamentalist — sed/awk stream-editor bug monster
Uncommon, str=65, 90xp. Triggered by sed errors, awk failures,
truncated/0-byte files after in-place edits, backreference failures.

Inspired by sed's layer-by-layer file processing (the 'striations')
and the real incident where sed -i with a backreference wiped catalog.json.

Weakened by: dry-run preview before -i, verify output after edit.
Flavor: 'Knows every line of your file. Edited them all. Saved none.'
2026-04-05 23:26:03 -07:00
pyr0ball
0c2d245632 fix: SESSION_FILE shadow bug + hooks matcher + 5 security/privacy mons
state.sh used SESSION_FILE for session.json (global tracking data).
Both hook handlers override SESSION_FILE to sessions/<pgrp>.json for
per-session isolation. buddymon_session_reset() at session-stop end
was writing _version:1 data INTO sessions/<pgrp>.json, leaving stale
wrong-format session files that broke buddy lookup.

Fix: rename state.sh variable to SESSION_DATA_FILE — no more collision.

Also:
- Add matcher:'*' to SessionStart and Stop hooks (CC 2.x compatibility)
- Add dead-session GC to session-start.sh (cleans up orphaned PGRP files)
- Add 5 new privacy/security bug_monsters:
  LeakWraith (uncommon) — exposed credentials
  CipherNull (uncommon) — weak/broken crypto (MD5, ECB, rand() for secrets)
  ConsentShadow (rare)  — tracking/analytics without consent (CF flagship villain)
  ThrottleDemon (common) — ignored 429s and missing backoff
  PrivacyLich (legendary) — GDPR/CCPA/breach debt; unkillable, only containable
2026-04-05 23:24:27 -07:00
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
12 changed files with 2215 additions and 197 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,21 @@ 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
enc["last_wounded_at"] = datetime.now().astimezone().isoformat()
data["active_encounter"] = enc
save_json(enc_file, data)
def match_bug_monster(output_text: str, catalog: dict) -> dict | None:
"""Return the first matching bug monster from the catalog, or None."""
if not output_text:
@ -224,6 +309,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 +369,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 +432,107 @@ 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):
if existing.get("catch_pending"):
# User invoked /buddymon catch — hold the monster for this run.
# Clear the flag now so the NEXT clean run resumes normal behavior.
# The skill sets it again at the start of each /buddymon catch call.
existing["catch_pending"] = False
enc_data = load_json(BUDDYMON_DIR / "encounters.json")
enc_data["active_encounter"] = existing
save_json(BUDDYMON_DIR / "encounters.json", enc_data)
else:
# Auto-attack rates scaled by encounter rarity and buddy level.
# Multiple parallel sessions share encounters.json — a wound
# cooldown prevents them pile-driving the same encounter.
rarity = existing.get("rarity", "common")
WOUND_RATES = {
"very_common": 0.55, "common": 0.40,
"uncommon": 0.22, "rare": 0.10, "legendary": 0.02,
}
RESOLVE_RATES = {
"very_common": 0.45, "common": 0.28,
"uncommon": 0.14, "rare": 0.05, "legendary": 0.01,
}
roster = load_json(BUDDYMON_DIR / "roster.json")
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
level_scale = 1.0 + (buddy_level / 100) * 0.25
# Wound cooldown: skip if another session wounded within 30s
last_wound = existing.get("last_wounded_at", "")
wound_cooldown_ok = True
if last_wound:
try:
from datetime import timezone as _tz
last_dt = datetime.fromisoformat(last_wound)
age = (datetime.now(_tz.utc) - last_dt).total_seconds()
wound_cooldown_ok = age > 30
except Exception:
pass
if existing.get("wounded"):
resolve_rate = min(0.70, RESOLVE_RATES.get(rarity, 0.28) * level_scale)
if wound_cooldown_ok and random.random() < resolve_rate:
xp, display = auto_resolve_encounter(existing, buddy_id)
messages.append(
f"\n⚔️ **{buddy_display} defeated {display}!** (auto-resolved)\n"
f" +{xp} XP\n"
f"\n💨 **{display} fled!** (escaped while wounded)\n"
f" {buddy_display} gets partial 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)
else:
wound_rate = min(0.85, WOUND_RATES.get(rarity, 0.40) * level_scale)
if wound_cooldown_ok and random.random() < wound_rate:
wound_encounter()
# else: monster still present, no message — don't spam every tool call
elif output or command:
# No active encounter — check for bug monster first, then event encounters
session = load_json(BUDDYMON_DIR / "session.json")
monster = match_bug_monster(output, catalog) if output else None
event = None if monster else match_event_encounter(command, output, session, catalog)
target = monster or event
if target and random.random() < 0.70:
if monster:
# 70% chance to trigger (avoids every minor warning spawning)
if random.random() < 0.70:
strength = compute_strength(monster, elapsed_minutes=0)
else:
strength = target.get("base_strength", 30)
encounter = {
"id": monster["id"],
"display": monster["display"],
"base_strength": monster.get("base_strength", 50),
"id": target["id"],
"display": target["display"],
"base_strength": target.get("base_strength", 50),
"current_strength": strength,
"catchable": monster.get("catchable", True),
"defeatable": monster.get("defeatable", True),
"xp_reward": monster.get("xp_reward", 50),
"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)
msg = format_encounter_message(monster, strength, buddy_display)
messages.append(msg)
# 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 +542,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 +557,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,53 @@ 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
# Clean up session files for dead processes (runs async-style; errors ignored)
python3 << 'PYEOF' 2>/dev/null &
import os, glob, json
for f in glob.glob(os.path.expanduser("~/.claude/buddymon/sessions/*.json")):
pid_str = os.path.basename(f).replace('.json', '')
try:
pid = int(pid_str)
# Check if the process group still exists
os.killpg(pid, 0)
except (ValueError, ProcessLookupError, PermissionError):
# PermissionError means the process exists (just not ours) — keep it
# ProcessLookupError means it's dead — remove
try:
d = json.load(open(f))
if '_version' not in d: # only remove valid-format dead sessions
os.remove(f)
except Exception:
pass
except Exception:
pass
PYEOF
ACTIVE_ID=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('buddymon_id',''))" 2>/dev/null)
SESSION_XP=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('session_xp',0))" 2>/dev/null)
# Load catalog for buddy display info
CATALOG="${PLUGIN_ROOT}/lib/catalog.json"
@ -16,6 +61,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 +209,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'
session_file = '${BUDDYMON_DIR}/session.json' # SESSION_DATA_FILE from state.sh
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 = {}
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' # SESSION_DATA_FILE from state.sh
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

@ -3,22 +3,35 @@
"hooks": {
"SessionStart": [
{
"matcher": "*",
"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
}
]
@ -26,10 +39,11 @@
],
"Stop": [
{
"matcher": "*",
"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

View file

@ -6,7 +6,9 @@ BUDDYMON_DIR="${HOME}/.claude/buddymon"
ROSTER_FILE="${BUDDYMON_DIR}/roster.json"
ENCOUNTERS_FILE="${BUDDYMON_DIR}/encounters.json"
ACTIVE_FILE="${BUDDYMON_DIR}/active.json"
SESSION_FILE="${BUDDYMON_DIR}/session.json"
# Named SESSION_DATA_FILE (not SESSION_FILE) to avoid shadowing the
# per-session state file hooks define as SESSION_FILE=sessions/<pgrp>.json
SESSION_DATA_FILE="${BUDDYMON_DIR}/session.json"
buddymon_init() {
mkdir -p "${BUDDYMON_DIR}"
@ -42,7 +44,7 @@ EOF
EOF
fi
if [[ ! -f "${SESSION_FILE}" ]]; then
if [[ ! -f "${SESSION_DATA_FILE}" ]]; then
buddymon_session_reset
fi
}
@ -50,7 +52,7 @@ EOF
buddymon_session_reset() {
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
cat > "${SESSION_FILE}" << EOF
cat > "${SESSION_DATA_FILE}" << EOF
{
"_version": 1,
"started_at": "${ts}",

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 |
---
@ -86,10 +88,14 @@ Ask for 1, 2, or 3. On choice, write to roster + active:
```python
import json, os
from pathlib import Path
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"))
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
starters = ["Pyrobyte", "Debuglin", "Minimox"]
choice = starters[0] # replace with user's choice (index 0/1/2)
@ -116,12 +122,60 @@ 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"
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
SESSION_KEY = str(os.getpgrp())
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
roster = json.load(open(BUDDYMON_DIR / "roster.json"))
buddy_id = "Debuglin" # replace with matched buddy id
buddy_catalog = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id) or {})
challenges = buddy_catalog.get("challenges", [])
# Load or init session state
try:
session_state = json.load(open(SESSION_FILE))
except Exception:
session_state = {}
session_state["buddymon_id"] = buddy_id
session_state["session_xp"] = 0
session_state["challenge"] = random.choice(challenges) if challenges else None
json.dump(session_state, open(SESSION_FILE, "w"), indent=2)
# Also update global default so new sessions inherit this assignment
active = {}
try:
active = json.load(open(BUDDYMON_DIR / "active.json"))
except Exception:
pass
active["buddymon_id"] = buddy_id
json.dump(active, open(BUDDYMON_DIR / "active.json", "w"), indent=2)
```
Show challenge proposal with Accept / Decline / Reroll (updating `session_state["challenge"]` accordingly).
---
@ -174,33 +228,80 @@ 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)
```
**If `enc.get("wounded")` is True (strength already at 5%), skip all weakening
Q&A and go straight to the catch roll — do not ask, just throw.**
Otherwise, show strength and weakening status. Explain weaken actions:
- Write a failing test → -20% strength
- 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
import json, os, random, glob
from datetime import datetime, timezone
from pathlib import Path
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"))
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
enc_file = f"{BUDDYMON_DIR}/encounters.json"
active_file = f"{BUDDYMON_DIR}/active.json"
roster_file = f"{BUDDYMON_DIR}/roster.json"
# PLUGIN_ROOT is not always set when skills run via Bash heredoc — search known paths
def find_catalog():
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
candidates = [
Path(plugin_root) / "lib" / "catalog.json" if plugin_root else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
]
for p in candidates:
if p and p.exists():
return json.load(open(p))
raise FileNotFoundError("buddymon catalog not found — check plugin installation")
catalog = find_catalog()
enc_file = BUDDYMON_DIR / "encounters.json"
active_file = BUDDYMON_DIR / "active.json"
roster_file = BUDDYMON_DIR / "roster.json"
encounters = json.load(open(enc_file))
active = json.load(open(active_file))
roster = json.load(open(roster_file))
enc = encounters.get("active_encounter")
# Buddy lookup: prefer per-session file, fall back to active.json
SESSION_KEY = str(os.getpgrp())
SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
try:
session_state = json.load(open(SESSION_FILE))
buddy_id = session_state.get("buddymon_id")
except Exception:
buddy_id = None
if not buddy_id:
active = json.load(open(active_file))
buddy_id = active.get("buddymon_id")
enc = encounters.get("active_encounter")
# catch_pending is cleared by the PostToolUse hook after it fires on this
# Bash run — do NOT clear it here or the hook will see it already gone and
# may auto-resolve the encounter on the same run as the catch attempt.
buddy_data = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id) or {})
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
@ -220,6 +321,13 @@ if success:
"caught_at": datetime.now(timezone.utc).isoformat(),
}
roster.setdefault("owned", {})[enc["id"]] = caught_entry
# Write XP to session file if it exists, otherwise active.json
try:
ss = json.load(open(SESSION_FILE))
ss["session_xp"] = ss.get("session_xp", 0) + xp
json.dump(ss, open(SESSION_FILE, "w"), indent=2)
except Exception:
active = json.load(open(active_file))
active["session_xp"] = active.get("session_xp", 0) + xp
json.dump(active, open(active_file, "w"), indent=2)
if buddy_id and buddy_id in roster.get("owned", {}):
@ -232,6 +340,10 @@ if success:
json.dump(encounters, open(enc_file, "w"), indent=2)
print(f"caught:{xp}")
else:
# Leave catch_pending as-is — the PostToolUse hook clears it after this
# Bash run completes, giving one full clean run before auto-resolve resumes.
encounters["active_encounter"] = enc
json.dump(encounters, open(enc_file, "w"), indent=2)
print(f"failed:{int(catch_rate * 100)}")
```
@ -259,8 +371,168 @@ 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
from pathlib import Path
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
_pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
_catalog_paths = [Path(_pr) / "lib/catalog.json" if _pr else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json"]
catalog = json.load(open(next(p for p in _catalog_paths if p and p.exists())))
active = json.load(open(BUDDYMON_DIR / "active.json"))
roster = json.load(open(BUDDYMON_DIR / "roster.json"))
buddy_id = active.get("buddymon_id")
owned = roster.get("owned", {})
buddy_data = owned.get(buddy_id, {})
level = buddy_data.get("level", 1)
total_xp = buddy_data.get("xp", 0)
# Check evolution entry in catalog
catalog_entry = catalog.get("buddymon", {}).get(buddy_id) or catalog.get("evolutions", {}).get(buddy_id)
evolutions = catalog_entry.get("evolutions", []) if catalog_entry else []
evolution = next((e for e in evolutions if level >= e.get("level", 999)), None)
```
If `evolution` is None or level < 100: show current level and XP toward 100, no evolution available yet.
If eligible, show evolution preview:
```
╔══════════════════════════════════════════════════════════╗
║ ✨ Evolution Ready! ║
╠══════════════════════════════════════════════════════════╣
║ ║
║ 🔍 Debuglin Lv.100 → 🔬 Verifex ║
║ ║
║ Verifex: Sees the bug before the code is even written. ║
║ catch_rate: 0.60 → 0.75 · xp_multiplier: 1.0 → 1.3 ║
║ ║
║ ⚠️ Resets to Lv.1. Your caught monsters stay. ║
║ ║
╚══════════════════════════════════════════════════════════╝
Evolve? (y/n)
```
On confirm, execute the evolution:
```python
from datetime import datetime, timezone
into_id = evolution["into"]
into_data = catalog["evolutions"][into_id]
# Archive old form with evolution marker
owned[buddy_id]["evolved_into"] = into_id
owned[buddy_id]["evolved_at"] = datetime.now(timezone.utc).isoformat()
# Create new form entry at Lv.1
owned[into_id] = {
"id": into_id,
"display": into_data["display"],
"affinity": into_data.get("affinity", catalog_entry.get("affinity", "")),
"level": 1,
"xp": 0,
"evolved_from": buddy_id,
"evolved_at": datetime.now(timezone.utc).isoformat(),
}
# Carry challenges forward from original form
challenges = catalog_entry.get("challenges") or into_data.get("challenges", [])
roster["owned"] = owned
json.dump(roster, open(f"{BUDDYMON_DIR}/roster.json", "w"), indent=2)
# Update active to point to evolved form
active["buddymon_id"] = into_id
active["session_xp"] = 0
active["challenge"] = challenges[0] if challenges else None
json.dump(active, open(f"{BUDDYMON_DIR}/active.json", "w"), indent=2)
```
Show result:
```
✨ Debuglin evolved into 🔬 Verifex!
Starting fresh at Lv.1 — the second climb is faster.
New challenge: IRON TEST
```
---
## `statusline` — Install Buddymon Statusline
Installs the Buddymon statusline into `~/.claude/settings.json`.
The statusline shows active buddy + level + session XP, and highlights any
active encounter in red:
```
🐾 Debuglin Lv.90 · +45xp ⚔ 💀 NullWraith [60%]
```
Run this Python to install:
```python
import json, os
from pathlib import Path
SETTINGS = Path.home() / ".claude" / "settings.json"
# Prefer the stable user-local copy installed by install.sh
_script_candidates = [
Path.home() / ".claude/buddymon/statusline.sh",
Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "")) / "lib/statusline.sh" if os.environ.get("CLAUDE_PLUGIN_ROOT") else None,
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/statusline.sh",
]
SCRIPT = next(str(p) for p in _script_candidates if p and p.exists())
settings = json.load(open(SETTINGS))
if settings.get("statusLine"):
print("⚠️ A statusLine is already configured. Replace it? (y/n)")
# ask user — if no, abort
# if yes, proceed
pass
settings["statusLine"] = {
"type": "command",
"command": f"bash {SCRIPT}",
}
json.dump(settings, open(SETTINGS, "w"), indent=2)
print(f"✅ Buddymon statusline installed. Reload Claude Code to activate.")
```
If a `statusLine` is already set, show the existing command and ask before replacing.
---
## `help`
@ -272,4 +544,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
```