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).
This commit is contained in:
pyr0ball 2026-04-01 23:08:57 -07:00
parent e2a4b66267
commit 9b13150d1b
3 changed files with 125 additions and 4 deletions

View file

@ -281,7 +281,6 @@ def main():
if tool_name == "Bash":
output = ""
if isinstance(tool_response, dict):
# CC may use any of these keys; combine all text fields
parts = [
tool_response.get("output", ""),
tool_response.get("content", ""),
@ -292,7 +291,6 @@ def main():
elif isinstance(tool_response, str):
output = tool_response
elif isinstance(tool_response, list):
# Array of content blocks: [{"type": "text", "text": "..."}]
output = "\n".join(
b.get("text", "") for b in tool_response
if isinstance(b, dict) and b.get("type") == "text"
@ -325,10 +323,9 @@ def main():
"defeatable": monster.get("defeatable", True),
"xp_reward": monster.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", "")

View file

@ -0,0 +1,113 @@
#!/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"
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
active = load_json(BUDDYMON_DIR / "active.json")
buddy_id = active.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", "")
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

@ -12,6 +12,17 @@
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/user-prompt-submit.py",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash|Edit|Write|MultiEdit",