diff --git a/packages/claude-code-plugin/hooks/lib/achievement_tracker.py b/packages/claude-code-plugin/hooks/lib/achievement_tracker.py index 24ba6547..3d16bf4b 100644 --- a/packages/claude-code-plugin/hooks/lib/achievement_tracker.py +++ b/packages/claude-code-plugin/hooks/lib/achievement_tracker.py @@ -66,6 +66,70 @@ "icon": "\U0001f525", # fire "face": "\u2666\u203f\u2666", # diamond eyes }, + # Micro-achievements: first-5-minute wins (#1436) + { + "id": "first_plan", + "name": "Planner!", + "description": "Enter PLAN mode for the first time", + "metric": "plan_entries", + "threshold": 1, + "icon": "\U0001f4dd", # memo + "face": "\u25c7\u203f\u25c7", # thin diamond eyes + }, + { + "id": "first_act", + "name": "Builder!", + "description": "Enter ACT mode for the first time", + "metric": "act_entries", + "threshold": 1, + "icon": "\U0001f528", # hammer + "face": "\u25a0\u203f\u25a0", # square eyes + }, + { + "id": "first_eval", + "name": "Critic!", + "description": "Enter EVAL mode for the first time", + "metric": "eval_entries", + "threshold": 1, + "icon": "\U0001f50d", # magnifying glass + "face": "\u25cb\u203f\u25cb", # circle eyes + }, + { + "id": "first_auto", + "name": "Autopilot!", + "description": "Enter AUTO mode for the first time", + "metric": "auto_entries", + "threshold": 1, + "icon": "\U0001f680", # rocket + "face": "\u25b7\u203f\u25b7", # triangle eyes + }, + { + "id": "council_summon", + "name": "Council Summoner", + "description": "Summon a specialist council for the first time", + "metric": "council_summons", + "threshold": 1, + "icon": "\U0001f451", # crown + "face": "\u2726\u203f\u2726", # four-pointed star eyes + }, + { + "id": "tdd_first", + "name": "Test First!", + "description": "Complete your first TDD cycle", + "metric": "tdd_cycles", + "threshold": 1, + "icon": "\u2705", # check mark + "face": "\u2713\u203f\u2713", # checkmark eyes + }, + { + "id": "multi_agent", + "name": "Orchestrator!", + "description": "Dispatch parallel specialist agents for the first time", + "metric": "multi_agent_dispatches", + "threshold": 1, + "icon": "\U0001f3af", # bullseye + "face": "\u29bf\u203f\u29bf", # circled bullet eyes + }, ] # Celebration messages by language @@ -162,6 +226,13 @@ def _default_progress(self) -> Dict[str, Any]: "commit_timestamps": [], "session_dates": [], "max_streak_days": 0, + # Micro-achievement counters (#1436) + "plan_entries": 0, + "act_entries": 0, + "eval_entries": 0, + "auto_entries": 0, + "council_summons": 0, + "multi_agent_dispatches": 0, } def get_progress(self) -> Dict[str, Any]: @@ -180,6 +251,29 @@ def get_unlocked(self) -> List[Dict[str, Any]]: """ return self._locked_read(self.unlocked_file, []) + def record_mode_entry(self, mode: str) -> None: + """Record a mode entry for micro-achievement tracking (#1436). + + Args: + mode: The mode entered (PLAN, ACT, EVAL, AUTO). + """ + key = f"{mode.lower()}_entries" + progress = self.get_progress() + progress[key] = progress.get(key, 0) + 1 + self._locked_write(self.progress_file, progress) + + def record_council_summon(self) -> None: + """Record a council scene render for micro-achievement tracking (#1436).""" + progress = self.get_progress() + progress["council_summons"] = progress.get("council_summons", 0) + 1 + self._locked_write(self.progress_file, progress) + + def record_multi_agent_dispatch(self) -> None: + """Record a parallel agent dispatch for micro-achievement tracking (#1436).""" + progress = self.get_progress() + progress["multi_agent_dispatches"] = progress.get("multi_agent_dispatches", 0) + 1 + self._locked_write(self.progress_file, progress) + def record_tdd_cycle(self) -> None: """Record a completed TDD cycle.""" progress = self.get_progress() diff --git a/packages/claude-code-plugin/hooks/lib/agent_memory.py b/packages/claude-code-plugin/hooks/lib/agent_memory.py index 5cf94e29..b9498b41 100644 --- a/packages/claude-code-plugin/hooks/lib/agent_memory.py +++ b/packages/claude-code-plugin/hooks/lib/agent_memory.py @@ -80,6 +80,34 @@ def get_context_prompt(self, agent_name: str) -> str: parts.append(f"- {json.dumps(p, ensure_ascii=False)}") return "\n".join(parts) + def get_council_context(self, agent_names: list) -> str: + """Get combined context prompts for a list of specialists (#1435). + + Returns a formatted block suitable for hook output injection. + Only includes agents that have prior findings. + + Args: + agent_names: List of specialist agent names. + + Returns: + Formatted markdown with memory context, or empty string. + """ + sections = [] + returning = [] + for name in agent_names: + prompt = self.get_context_prompt(name) + if prompt: + returning.append(name) + sections.append(f"### {name} (returning)\n{prompt}") + if not sections: + return "" + header = ( + f"# Agent Council Memory\n" + f"The following {len(returning)} specialist(s) have prior findings " + f"from previous sessions. Include this context when dispatching them.\n" + ) + return header + "\n\n".join(sections) + def clear(self, agent_name: str) -> None: filepath = self._filepath(agent_name) if os.path.isfile(filepath): diff --git a/packages/claude-code-plugin/hooks/post-tool-use.py b/packages/claude-code-plugin/hooks/post-tool-use.py index 250a9af8..75347416 100644 --- a/packages/claude-code-plugin/hooks/post-tool-use.py +++ b/packages/claude-code-plugin/hooks/post-tool-use.py @@ -68,6 +68,27 @@ def handle_post_tool_use(data: dict): except Exception: pass # Never block tool execution + # Micro-achievement: detect parallel agent dispatch (#1436) + try: + tool_name = data.get("tool_name", "") + if tool_name == "Agent" and data.get("tool_input", {}).get("run_in_background"): + from achievement_tracker import ( + AchievementTracker, + render_batch_celebration, + ) + + tracker = AchievementTracker() + tracker.record_multi_agent_dispatch() + newly_unlocked = tracker.check_achievements() + if newly_unlocked: + import sys as _sys + + celebration = render_batch_celebration(newly_unlocked) + if celebration: + print(celebration, file=_sys.stderr) + except Exception: + pass # Never block tool execution + return None diff --git a/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py b/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py index bdb2fa90..031a451e 100644 --- a/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py +++ b/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py @@ -41,15 +41,15 @@ class TestAchievementDefinitions: def test_get_all_definitions(self): defs = get_achievement_definitions() - assert len(defs) == 5 + assert len(defs) == 12 # 5 original + 7 micro-achievements (#1436) ids = {d["id"] for d in defs} - assert ids == { + assert { "tdd_champion", "agent_master", "quality_guard", "speed_coder", "streak", - } + }.issubset(ids) def test_get_by_id_found(self): defn = get_achievement_by_id("tdd_champion") @@ -199,8 +199,9 @@ def test_tdd_champion_unlock(self, tracker): tracker._locked_write(tracker.progress_file, progress) newly = tracker.check_achievements() - assert len(newly) == 1 - assert newly[0]["id"] == "tdd_champion" + ids = [a["id"] for a in newly] + assert "tdd_champion" in ids + assert "tdd_first" in ids # threshold=1 also triggers def test_agent_master_unlock(self, tracker): progress = tracker.get_progress() @@ -240,7 +241,8 @@ def test_already_unlocked_not_repeated(self, tracker): tracker._locked_write(tracker.progress_file, progress) first = tracker.check_achievements() - assert len(first) == 1 + first_ids = {a["id"] for a in first} + assert "tdd_champion" in first_ids second = tracker.check_achievements() assert second == [] @@ -254,8 +256,9 @@ def test_unlocked_persisted(self, tracker, tmp_data_dir): # New tracker instance reads persisted data tracker2 = AchievementTracker(data_dir=tmp_data_dir) unlocked = tracker2.get_unlocked() - assert len(unlocked) == 1 - assert unlocked[0]["id"] == "tdd_champion" + unlocked_ids = {u["id"] for u in unlocked} + assert "tdd_champion" in unlocked_ids + assert "tdd_first" in unlocked_ids class TestStreakCalculation: @@ -402,3 +405,99 @@ def test_two_achievements(self): result = render_batch_celebration(achievements, "en") assert "Alpha" in result assert "+1 more achievements unlocked!" in result + + +class TestMicroAchievements: + """Tests for micro-achievement definitions and triggers (#1436).""" + + def test_micro_achievement_definitions_exist(self): + """All 7 micro-achievements should be defined.""" + micro_ids = { + "first_plan", "first_act", "first_eval", "first_auto", + "council_summon", "tdd_first", "multi_agent", + } + defined_ids = {d["id"] for d in ACHIEVEMENT_DEFINITIONS} + assert micro_ids.issubset(defined_ids) + + def test_micro_achievements_have_threshold_1(self): + """Micro-achievements should trigger on first occurrence.""" + micro_ids = { + "first_plan", "first_act", "first_eval", "first_auto", + "council_summon", "tdd_first", "multi_agent", + } + for defn in ACHIEVEMENT_DEFINITIONS: + if defn["id"] in micro_ids: + assert defn["threshold"] == 1, f"{defn['id']} should have threshold=1" + + def test_record_mode_entry_plan(self, tmp_data_dir): + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_mode_entry("PLAN") + progress = tracker.get_progress() + assert progress["plan_entries"] == 1 + + def test_record_mode_entry_act(self, tmp_data_dir): + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_mode_entry("ACT") + progress = tracker.get_progress() + assert progress["act_entries"] == 1 + + def test_record_mode_entry_eval(self, tmp_data_dir): + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_mode_entry("EVAL") + progress = tracker.get_progress() + assert progress["eval_entries"] == 1 + + def test_record_mode_entry_auto(self, tmp_data_dir): + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_mode_entry("AUTO") + progress = tracker.get_progress() + assert progress["auto_entries"] == 1 + + def test_record_council_summon(self, tmp_data_dir): + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_council_summon() + progress = tracker.get_progress() + assert progress["council_summons"] == 1 + + def test_record_multi_agent_dispatch(self, tmp_data_dir): + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_multi_agent_dispatch() + progress = tracker.get_progress() + assert progress["multi_agent_dispatches"] == 1 + + def test_first_plan_unlocks_achievement(self, tmp_data_dir): + """First PLAN entry should unlock 'Planner!' achievement.""" + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_mode_entry("PLAN") + newly = tracker.check_achievements() + ids = [a["id"] for a in newly] + assert "first_plan" in ids + + def test_first_tdd_unlocks_both(self, tmp_data_dir): + """First TDD cycle should unlock both 'tdd_first' (threshold=1).""" + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_tdd_cycle() + newly = tracker.check_achievements() + ids = [a["id"] for a in newly] + assert "tdd_first" in ids + + def test_micro_achievement_fires_only_once(self, tmp_data_dir): + """Achievement should not fire again on second mode entry.""" + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_mode_entry("PLAN") + first = tracker.check_achievements() + assert any(a["id"] == "first_plan" for a in first) + + tracker.record_mode_entry("PLAN") + second = tracker.check_achievements() + assert not any(a["id"] == "first_plan" for a in second) + + def test_multiple_modes_unlock_independently(self, tmp_data_dir): + """Each mode entry unlocks its own achievement.""" + tracker = AchievementTracker(data_dir=tmp_data_dir) + tracker.record_mode_entry("PLAN") + tracker.record_mode_entry("ACT") + newly = tracker.check_achievements() + ids = [a["id"] for a in newly] + assert "first_plan" in ids + assert "first_act" in ids diff --git a/packages/claude-code-plugin/hooks/user-prompt-submit.py b/packages/claude-code-plugin/hooks/user-prompt-submit.py index fe1cd2cc..e5a25e9d 100644 --- a/packages/claude-code-plugin/hooks/user-prompt-submit.py +++ b/packages/claude-code-plugin/hooks/user-prompt-submit.py @@ -165,6 +165,44 @@ def main(): except Exception: pass + # Agent Council Memory: inject prior findings for specialists (#1435) + try: + from agent_memory import AgentMemory + + _council_agents = [] + if council_preset and isinstance(council_preset, dict): + # Extract specialist names from council preset + _council_agents = council_preset.get("specialists", []) + _primary = council_preset.get("primary") + if _primary: + _council_agents = [_primary] + list(_council_agents) + if _council_agents: + mem = AgentMemory() + memory_context = mem.get_council_context(_council_agents) + if memory_context: + print(memory_context) + except Exception: + pass # Never block prompt submission + + # Micro-achievement: record mode entry (#1436) + try: + from achievement_tracker import ( + AchievementTracker, + render_batch_celebration, + ) + + tracker = AchievementTracker() + tracker.record_mode_entry(detected_mode) + if council_preset: + tracker.record_council_summon() + newly_unlocked = tracker.check_achievements() + if newly_unlocked: + celebration = render_batch_celebration(newly_unlocked) + if celebration: + print(celebration, file=sys.stderr) + except Exception: + pass # Never block prompt submission + # Exit successfully (exit code 0 = success, output added as context) sys.exit(0) diff --git a/packages/claude-code-plugin/tests/test_agent_memory.py b/packages/claude-code-plugin/tests/test_agent_memory.py index d1e9add5..8efcac4a 100644 --- a/packages/claude-code-plugin/tests/test_agent_memory.py +++ b/packages/claude-code-plugin/tests/test_agent_memory.py @@ -171,3 +171,46 @@ def test_list_agents_returns_saved_agents(self, mem): mem.save("agent-b", {"findings": [], "patterns": [], "preferences": []}) agents = mem.list_agents() assert sorted(agents) == ["agent-a", "agent-b"] + + +class TestGetCouncilContext: + """Tests for batch council context generation (#1435).""" + + def test_empty_when_no_agents(self, mem): + result = mem.get_council_context([]) + assert result == "" + + def test_empty_when_agents_have_no_memory(self, mem): + result = mem.get_council_context(["security-specialist", "architecture-specialist"]) + assert result == "" + + def test_returns_context_for_agents_with_findings(self, mem): + mem.add_finding("security-specialist", {"issue": "XSS in login form"}) + result = mem.get_council_context(["security-specialist", "architecture-specialist"]) + assert "Agent Council Memory" in result + assert "security-specialist (returning)" in result + assert "XSS in login form" in result + assert "architecture-specialist" not in result + + def test_includes_multiple_returning_agents(self, mem): + mem.add_finding("security-specialist", {"issue": "XSS"}) + mem.add_pattern("architecture-specialist", {"pattern": "layered arch"}) + result = mem.get_council_context(["security-specialist", "architecture-specialist"]) + assert "2 specialist(s)" in result + assert "security-specialist (returning)" in result + assert "architecture-specialist (returning)" in result + + def test_includes_all_categories(self, mem): + mem.add_finding("agent-a", {"finding": "f1"}) + mem.add_pattern("agent-a", {"pattern": "p1"}) + mem.add_preference("agent-a", {"pref": "pr1"}) + result = mem.get_council_context(["agent-a"]) + assert "Previous Findings" in result + assert "Recognized Patterns" in result + assert "Agent Preferences" in result + + def test_skips_agents_not_in_list(self, mem): + mem.add_finding("security-specialist", {"issue": "XSS"}) + mem.add_finding("other-agent", {"issue": "unrelated"}) + result = mem.get_council_context(["security-specialist"]) + assert "other-agent" not in result