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:
parent
e2a4b66267
commit
9b13150d1b
3 changed files with 125 additions and 4 deletions
|
|
@ -281,7 +281,6 @@ def main():
|
||||||
if tool_name == "Bash":
|
if tool_name == "Bash":
|
||||||
output = ""
|
output = ""
|
||||||
if isinstance(tool_response, dict):
|
if isinstance(tool_response, dict):
|
||||||
# CC may use any of these keys; combine all text fields
|
|
||||||
parts = [
|
parts = [
|
||||||
tool_response.get("output", ""),
|
tool_response.get("output", ""),
|
||||||
tool_response.get("content", ""),
|
tool_response.get("content", ""),
|
||||||
|
|
@ -292,7 +291,6 @@ def main():
|
||||||
elif isinstance(tool_response, str):
|
elif isinstance(tool_response, str):
|
||||||
output = tool_response
|
output = tool_response
|
||||||
elif isinstance(tool_response, list):
|
elif isinstance(tool_response, list):
|
||||||
# Array of content blocks: [{"type": "text", "text": "..."}]
|
|
||||||
output = "\n".join(
|
output = "\n".join(
|
||||||
b.get("text", "") for b in tool_response
|
b.get("text", "") for b in tool_response
|
||||||
if isinstance(b, dict) and b.get("type") == "text"
|
if isinstance(b, dict) and b.get("type") == "text"
|
||||||
|
|
@ -325,10 +323,9 @@ def main():
|
||||||
"defeatable": monster.get("defeatable", True),
|
"defeatable": monster.get("defeatable", True),
|
||||||
"xp_reward": monster.get("xp_reward", 50),
|
"xp_reward": monster.get("xp_reward", 50),
|
||||||
"weakened_by": [],
|
"weakened_by": [],
|
||||||
|
"announced": False,
|
||||||
}
|
}
|
||||||
set_active_encounter(encounter)
|
set_active_encounter(encounter)
|
||||||
msg = format_encounter_message(monster, strength, buddy_display)
|
|
||||||
messages.append(msg)
|
|
||||||
|
|
||||||
# Commit detection
|
# Commit detection
|
||||||
command = tool_input.get("command", "")
|
command = tool_input.get("command", "")
|
||||||
|
|
|
||||||
113
hooks-handlers/user-prompt-submit.py
Normal file
113
hooks-handlers/user-prompt-submit.py
Normal 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()
|
||||||
|
|
@ -12,6 +12,17 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/user-prompt-submit.py",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": "Bash|Edit|Write|MultiEdit",
|
"matcher": "Bash|Edit|Write|MultiEdit",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue