Skip to content

Commit 16a9963

Browse files
committed
feat(plugin): add council memory injection and micro-achievements (#1435, #1436)
- Wire AgentMemory.get_council_context() into user-prompt-submit hook so specialists arrive pre-loaded with prior findings across sessions - Add 7 micro-achievements (threshold=1) that trigger in first 5 minutes: first-plan, first-act, first-eval, first-auto, council-summon, tdd-first, multi-agent - Add record_mode_entry/record_council_summon/record_multi_agent_dispatch tracking methods to AchievementTracker - Wire achievement triggers in user-prompt-submit (mode entry, council) and post-tool-use (parallel agent dispatch) - 86 tests pass (63 achievement + 23 agent memory) Closes #1435 Closes #1436
1 parent e4c4fd1 commit 16a9963

6 files changed

Lines changed: 331 additions & 8 deletions

File tree

packages/claude-code-plugin/hooks/lib/achievement_tracker.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,70 @@
6666
"icon": "\U0001f525", # fire
6767
"face": "\u2666\u203f\u2666", # diamond eyes
6868
},
69+
# Micro-achievements: first-5-minute wins (#1436)
70+
{
71+
"id": "first_plan",
72+
"name": "Planner!",
73+
"description": "Enter PLAN mode for the first time",
74+
"metric": "plan_entries",
75+
"threshold": 1,
76+
"icon": "\U0001f4dd", # memo
77+
"face": "\u25c7\u203f\u25c7", # thin diamond eyes
78+
},
79+
{
80+
"id": "first_act",
81+
"name": "Builder!",
82+
"description": "Enter ACT mode for the first time",
83+
"metric": "act_entries",
84+
"threshold": 1,
85+
"icon": "\U0001f528", # hammer
86+
"face": "\u25a0\u203f\u25a0", # square eyes
87+
},
88+
{
89+
"id": "first_eval",
90+
"name": "Critic!",
91+
"description": "Enter EVAL mode for the first time",
92+
"metric": "eval_entries",
93+
"threshold": 1,
94+
"icon": "\U0001f50d", # magnifying glass
95+
"face": "\u25cb\u203f\u25cb", # circle eyes
96+
},
97+
{
98+
"id": "first_auto",
99+
"name": "Autopilot!",
100+
"description": "Enter AUTO mode for the first time",
101+
"metric": "auto_entries",
102+
"threshold": 1,
103+
"icon": "\U0001f680", # rocket
104+
"face": "\u25b7\u203f\u25b7", # triangle eyes
105+
},
106+
{
107+
"id": "council_summon",
108+
"name": "Council Summoner",
109+
"description": "Summon a specialist council for the first time",
110+
"metric": "council_summons",
111+
"threshold": 1,
112+
"icon": "\U0001f451", # crown
113+
"face": "\u2726\u203f\u2726", # four-pointed star eyes
114+
},
115+
{
116+
"id": "tdd_first",
117+
"name": "Test First!",
118+
"description": "Complete your first TDD cycle",
119+
"metric": "tdd_cycles",
120+
"threshold": 1,
121+
"icon": "\u2705", # check mark
122+
"face": "\u2713\u203f\u2713", # checkmark eyes
123+
},
124+
{
125+
"id": "multi_agent",
126+
"name": "Orchestrator!",
127+
"description": "Dispatch parallel specialist agents for the first time",
128+
"metric": "multi_agent_dispatches",
129+
"threshold": 1,
130+
"icon": "\U0001f3af", # bullseye
131+
"face": "\u29bf\u203f\u29bf", # circled bullet eyes
132+
},
69133
]
70134

71135
# Celebration messages by language
@@ -162,6 +226,13 @@ def _default_progress(self) -> Dict[str, Any]:
162226
"commit_timestamps": [],
163227
"session_dates": [],
164228
"max_streak_days": 0,
229+
# Micro-achievement counters (#1436)
230+
"plan_entries": 0,
231+
"act_entries": 0,
232+
"eval_entries": 0,
233+
"auto_entries": 0,
234+
"council_summons": 0,
235+
"multi_agent_dispatches": 0,
165236
}
166237

167238
def get_progress(self) -> Dict[str, Any]:
@@ -180,6 +251,29 @@ def get_unlocked(self) -> List[Dict[str, Any]]:
180251
"""
181252
return self._locked_read(self.unlocked_file, [])
182253

254+
def record_mode_entry(self, mode: str) -> None:
255+
"""Record a mode entry for micro-achievement tracking (#1436).
256+
257+
Args:
258+
mode: The mode entered (PLAN, ACT, EVAL, AUTO).
259+
"""
260+
key = f"{mode.lower()}_entries"
261+
progress = self.get_progress()
262+
progress[key] = progress.get(key, 0) + 1
263+
self._locked_write(self.progress_file, progress)
264+
265+
def record_council_summon(self) -> None:
266+
"""Record a council scene render for micro-achievement tracking (#1436)."""
267+
progress = self.get_progress()
268+
progress["council_summons"] = progress.get("council_summons", 0) + 1
269+
self._locked_write(self.progress_file, progress)
270+
271+
def record_multi_agent_dispatch(self) -> None:
272+
"""Record a parallel agent dispatch for micro-achievement tracking (#1436)."""
273+
progress = self.get_progress()
274+
progress["multi_agent_dispatches"] = progress.get("multi_agent_dispatches", 0) + 1
275+
self._locked_write(self.progress_file, progress)
276+
183277
def record_tdd_cycle(self) -> None:
184278
"""Record a completed TDD cycle."""
185279
progress = self.get_progress()

packages/claude-code-plugin/hooks/lib/agent_memory.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,34 @@ def get_context_prompt(self, agent_name: str) -> str:
8080
parts.append(f"- {json.dumps(p, ensure_ascii=False)}")
8181
return "\n".join(parts)
8282

83+
def get_council_context(self, agent_names: list) -> str:
84+
"""Get combined context prompts for a list of specialists (#1435).
85+
86+
Returns a formatted block suitable for hook output injection.
87+
Only includes agents that have prior findings.
88+
89+
Args:
90+
agent_names: List of specialist agent names.
91+
92+
Returns:
93+
Formatted markdown with memory context, or empty string.
94+
"""
95+
sections = []
96+
returning = []
97+
for name in agent_names:
98+
prompt = self.get_context_prompt(name)
99+
if prompt:
100+
returning.append(name)
101+
sections.append(f"### {name} (returning)\n{prompt}")
102+
if not sections:
103+
return ""
104+
header = (
105+
f"# Agent Council Memory\n"
106+
f"The following {len(returning)} specialist(s) have prior findings "
107+
f"from previous sessions. Include this context when dispatching them.\n"
108+
)
109+
return header + "\n\n".join(sections)
110+
83111
def clear(self, agent_name: str) -> None:
84112
filepath = self._filepath(agent_name)
85113
if os.path.isfile(filepath):

packages/claude-code-plugin/hooks/post-tool-use.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ def handle_post_tool_use(data: dict):
6868
except Exception:
6969
pass # Never block tool execution
7070

71+
# Micro-achievement: detect parallel agent dispatch (#1436)
72+
try:
73+
tool_name = data.get("tool_name", "")
74+
if tool_name == "Agent" and data.get("tool_input", {}).get("run_in_background"):
75+
from achievement_tracker import (
76+
AchievementTracker,
77+
render_batch_celebration,
78+
)
79+
80+
tracker = AchievementTracker()
81+
tracker.record_multi_agent_dispatch()
82+
newly_unlocked = tracker.check_achievements()
83+
if newly_unlocked:
84+
import sys as _sys
85+
86+
celebration = render_batch_celebration(newly_unlocked)
87+
if celebration:
88+
print(celebration, file=_sys.stderr)
89+
except Exception:
90+
pass # Never block tool execution
91+
7192
return None
7293

7394

packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@ class TestAchievementDefinitions:
4141

4242
def test_get_all_definitions(self):
4343
defs = get_achievement_definitions()
44-
assert len(defs) == 5
44+
assert len(defs) == 12 # 5 original + 7 micro-achievements (#1436)
4545
ids = {d["id"] for d in defs}
46-
assert ids == {
46+
assert {
4747
"tdd_champion",
4848
"agent_master",
4949
"quality_guard",
5050
"speed_coder",
5151
"streak",
52-
}
52+
}.issubset(ids)
5353

5454
def test_get_by_id_found(self):
5555
defn = get_achievement_by_id("tdd_champion")
@@ -199,8 +199,9 @@ def test_tdd_champion_unlock(self, tracker):
199199
tracker._locked_write(tracker.progress_file, progress)
200200

201201
newly = tracker.check_achievements()
202-
assert len(newly) == 1
203-
assert newly[0]["id"] == "tdd_champion"
202+
ids = [a["id"] for a in newly]
203+
assert "tdd_champion" in ids
204+
assert "tdd_first" in ids # threshold=1 also triggers
204205

205206
def test_agent_master_unlock(self, tracker):
206207
progress = tracker.get_progress()
@@ -240,7 +241,8 @@ def test_already_unlocked_not_repeated(self, tracker):
240241
tracker._locked_write(tracker.progress_file, progress)
241242

242243
first = tracker.check_achievements()
243-
assert len(first) == 1
244+
first_ids = {a["id"] for a in first}
245+
assert "tdd_champion" in first_ids
244246

245247
second = tracker.check_achievements()
246248
assert second == []
@@ -254,8 +256,9 @@ def test_unlocked_persisted(self, tracker, tmp_data_dir):
254256
# New tracker instance reads persisted data
255257
tracker2 = AchievementTracker(data_dir=tmp_data_dir)
256258
unlocked = tracker2.get_unlocked()
257-
assert len(unlocked) == 1
258-
assert unlocked[0]["id"] == "tdd_champion"
259+
unlocked_ids = {u["id"] for u in unlocked}
260+
assert "tdd_champion" in unlocked_ids
261+
assert "tdd_first" in unlocked_ids
259262

260263

261264
class TestStreakCalculation:
@@ -402,3 +405,99 @@ def test_two_achievements(self):
402405
result = render_batch_celebration(achievements, "en")
403406
assert "Alpha" in result
404407
assert "+1 more achievements unlocked!" in result
408+
409+
410+
class TestMicroAchievements:
411+
"""Tests for micro-achievement definitions and triggers (#1436)."""
412+
413+
def test_micro_achievement_definitions_exist(self):
414+
"""All 7 micro-achievements should be defined."""
415+
micro_ids = {
416+
"first_plan", "first_act", "first_eval", "first_auto",
417+
"council_summon", "tdd_first", "multi_agent",
418+
}
419+
defined_ids = {d["id"] for d in ACHIEVEMENT_DEFINITIONS}
420+
assert micro_ids.issubset(defined_ids)
421+
422+
def test_micro_achievements_have_threshold_1(self):
423+
"""Micro-achievements should trigger on first occurrence."""
424+
micro_ids = {
425+
"first_plan", "first_act", "first_eval", "first_auto",
426+
"council_summon", "tdd_first", "multi_agent",
427+
}
428+
for defn in ACHIEVEMENT_DEFINITIONS:
429+
if defn["id"] in micro_ids:
430+
assert defn["threshold"] == 1, f"{defn['id']} should have threshold=1"
431+
432+
def test_record_mode_entry_plan(self, tmp_data_dir):
433+
tracker = AchievementTracker(data_dir=tmp_data_dir)
434+
tracker.record_mode_entry("PLAN")
435+
progress = tracker.get_progress()
436+
assert progress["plan_entries"] == 1
437+
438+
def test_record_mode_entry_act(self, tmp_data_dir):
439+
tracker = AchievementTracker(data_dir=tmp_data_dir)
440+
tracker.record_mode_entry("ACT")
441+
progress = tracker.get_progress()
442+
assert progress["act_entries"] == 1
443+
444+
def test_record_mode_entry_eval(self, tmp_data_dir):
445+
tracker = AchievementTracker(data_dir=tmp_data_dir)
446+
tracker.record_mode_entry("EVAL")
447+
progress = tracker.get_progress()
448+
assert progress["eval_entries"] == 1
449+
450+
def test_record_mode_entry_auto(self, tmp_data_dir):
451+
tracker = AchievementTracker(data_dir=tmp_data_dir)
452+
tracker.record_mode_entry("AUTO")
453+
progress = tracker.get_progress()
454+
assert progress["auto_entries"] == 1
455+
456+
def test_record_council_summon(self, tmp_data_dir):
457+
tracker = AchievementTracker(data_dir=tmp_data_dir)
458+
tracker.record_council_summon()
459+
progress = tracker.get_progress()
460+
assert progress["council_summons"] == 1
461+
462+
def test_record_multi_agent_dispatch(self, tmp_data_dir):
463+
tracker = AchievementTracker(data_dir=tmp_data_dir)
464+
tracker.record_multi_agent_dispatch()
465+
progress = tracker.get_progress()
466+
assert progress["multi_agent_dispatches"] == 1
467+
468+
def test_first_plan_unlocks_achievement(self, tmp_data_dir):
469+
"""First PLAN entry should unlock 'Planner!' achievement."""
470+
tracker = AchievementTracker(data_dir=tmp_data_dir)
471+
tracker.record_mode_entry("PLAN")
472+
newly = tracker.check_achievements()
473+
ids = [a["id"] for a in newly]
474+
assert "first_plan" in ids
475+
476+
def test_first_tdd_unlocks_both(self, tmp_data_dir):
477+
"""First TDD cycle should unlock both 'tdd_first' (threshold=1)."""
478+
tracker = AchievementTracker(data_dir=tmp_data_dir)
479+
tracker.record_tdd_cycle()
480+
newly = tracker.check_achievements()
481+
ids = [a["id"] for a in newly]
482+
assert "tdd_first" in ids
483+
484+
def test_micro_achievement_fires_only_once(self, tmp_data_dir):
485+
"""Achievement should not fire again on second mode entry."""
486+
tracker = AchievementTracker(data_dir=tmp_data_dir)
487+
tracker.record_mode_entry("PLAN")
488+
first = tracker.check_achievements()
489+
assert any(a["id"] == "first_plan" for a in first)
490+
491+
tracker.record_mode_entry("PLAN")
492+
second = tracker.check_achievements()
493+
assert not any(a["id"] == "first_plan" for a in second)
494+
495+
def test_multiple_modes_unlock_independently(self, tmp_data_dir):
496+
"""Each mode entry unlocks its own achievement."""
497+
tracker = AchievementTracker(data_dir=tmp_data_dir)
498+
tracker.record_mode_entry("PLAN")
499+
tracker.record_mode_entry("ACT")
500+
newly = tracker.check_achievements()
501+
ids = [a["id"] for a in newly]
502+
assert "first_plan" in ids
503+
assert "first_act" in ids

packages/claude-code-plugin/hooks/user-prompt-submit.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,44 @@ def main():
165165
except Exception:
166166
pass
167167

168+
# Agent Council Memory: inject prior findings for specialists (#1435)
169+
try:
170+
from agent_memory import AgentMemory
171+
172+
_council_agents = []
173+
if council_preset and isinstance(council_preset, dict):
174+
# Extract specialist names from council preset
175+
_council_agents = council_preset.get("specialists", [])
176+
_primary = council_preset.get("primary")
177+
if _primary:
178+
_council_agents = [_primary] + list(_council_agents)
179+
if _council_agents:
180+
mem = AgentMemory()
181+
memory_context = mem.get_council_context(_council_agents)
182+
if memory_context:
183+
print(memory_context)
184+
except Exception:
185+
pass # Never block prompt submission
186+
187+
# Micro-achievement: record mode entry (#1436)
188+
try:
189+
from achievement_tracker import (
190+
AchievementTracker,
191+
render_batch_celebration,
192+
)
193+
194+
tracker = AchievementTracker()
195+
tracker.record_mode_entry(detected_mode)
196+
if council_preset:
197+
tracker.record_council_summon()
198+
newly_unlocked = tracker.check_achievements()
199+
if newly_unlocked:
200+
celebration = render_batch_celebration(newly_unlocked)
201+
if celebration:
202+
print(celebration, file=sys.stderr)
203+
except Exception:
204+
pass # Never block prompt submission
205+
168206
# Exit successfully (exit code 0 = success, output added as context)
169207
sys.exit(0)
170208

0 commit comments

Comments
 (0)