From 6a81392074444553ee9f26acdfcaf17d4e5e1248 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 2 Apr 2026 22:11:31 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20expand=20encounter=20triggers=20?= =?UTF-8?q?=E2=80=94=20git=20ops,=20installs,=20test=20events,=20test=20fi?= =?UTF-8?q?les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hooks-handlers/post-tool-use.py | 103 +++++++++++++++++++++++++------ lib/catalog.json | 105 ++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 20 deletions(-) diff --git a/hooks-handlers/post-tool-use.py b/hooks-handlers/post-tool-use.py index 9b71626..6097bdb 100755 --- a/hooks-handlers/post-tool-use.py +++ b/hooks-handlers/post-tool-use.py @@ -225,6 +225,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(pat in 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" @@ -279,6 +331,7 @@ 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): @@ -306,28 +359,32 @@ def main(): 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: + 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": [], - "announced": False, - } - set_active_encounter(encounter) + 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) @@ -337,7 +394,7 @@ def main(): commit_xp = 20 add_session_xp(commit_xp) - # ── Write / Edit: new language detection ────────────────────────────── + # ── Write / Edit: new language detection + test file encounters ─────── elif tool_name in ("Write", "Edit", "MultiEdit"): file_path = tool_input.get("file_path", "") if file_path: @@ -351,6 +408,12 @@ def main(): msg = format_new_language_message(lang, 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) diff --git a/lib/catalog.json b/lib/catalog.json index 0c4e9bc..048e6b5 100644 --- a/lib/catalog.json +++ b/lib/catalog.json @@ -287,6 +287,111 @@ } }, + "event_encounters": { + "MergeMaw": { + "id": "MergeMaw", + "display": "πŸ”€ MergeMaw", + "type": "event_encounter", + "rarity": "uncommon", + "base_strength": 45, + "xp_reward": 80, + "catchable": true, + "defeatable": true, + "trigger_type": "command", + "command_patterns": ["git merge", "git rebase"], + "description": "Emerges from the diff between two timelines. Loves conflicts.", + "flavor": "It has opinions about your whitespace." + }, + "BranchSprite": { + "id": "BranchSprite", + "display": "🌿 BranchSprite", + "type": "event_encounter", + "rarity": "common", + "base_strength": 15, + "xp_reward": 50, + "catchable": true, + "defeatable": false, + "trigger_type": "command", + "command_patterns": ["git checkout -b", "git switch -c", "git branch "], + "description": "Appears when a new branch is born. Harmless. Almost cheerful.", + "flavor": "It wanted to come along for the feature." + }, + "DepGolem": { + "id": "DepGolem", + "display": "πŸ“¦ DepGolem", + "type": "event_encounter", + "rarity": "common", + "base_strength": 30, + "xp_reward": 45, + "catchable": true, + "defeatable": true, + "trigger_type": "command", + "command_patterns": ["pip install", "pip3 install", "npm install", "npm i ", "cargo add", "yarn add", "apt install", "brew install", "poetry add", "uv add", "uv pip install"], + "description": "Conjured from the package registry. Brings transitive dependencies.", + "flavor": "It brought 847 friends." + }, + "FlakeDemon": { + "id": "FlakeDemon", + "display": "🎲 FlakeDemon", + "type": "event_encounter", + "rarity": "uncommon", + "base_strength": 55, + "xp_reward": 90, + "catchable": true, + "defeatable": true, + "trigger_type": "output", + "error_patterns": [ + "FAILED", + "AssertionError", + "Expected.*[Rr]eceived", + "assert.*failed", + "\\d+ failed", + "FAIL\\b", + "test.*FAILED", + "FAILURES" + ], + "description": "Born from test infrastructure, not application code. The hardest kind to pin down.", + "flavor": "It passed on CI. It always passes on CI." + }, + "PhantomPass": { + "id": "PhantomPass", + "display": "βœ… PhantomPass", + "type": "event_encounter", + "rarity": "rare", + "base_strength": 10, + "xp_reward": 150, + "catchable": true, + "defeatable": false, + "trigger_type": "test_victory", + "success_patterns": [ + "passed", + "PASSED", + "All tests passed", + "tests passed", + "βœ“", + "\\d+ passed", + "OK$", + "SUCCESS" + ], + "description": "Appears only when tests go green after going red. Rare. Fleeting. Cannot be fought β€” only caught.", + "flavor": "It was hiding in the red all along." + }, + "TestSpecter": { + "id": "TestSpecter", + "display": "πŸ§ͺ TestSpecter", + "type": "event_encounter", + "rarity": "uncommon", + "base_strength": 25, + "xp_reward": 65, + "catchable": true, + "defeatable": true, + "trigger_type": "test_file", + "test_file_patterns": ["\\.test\\.", "_test\\.", "test_", "_spec\\.", "\\.spec\\."], + "description": "Haunts test suites. Drawn to assertions. Debuglin gets excited.", + "flavor": "It wanted to make sure the test was named correctly." + } + }, + "buddymon": { "Pyrobyte": { "id": "Pyrobyte",