Skip to content

Commit 07d5889

Browse files
cdeustclaude
andcommitted
feat(hooks/briefing): dynamic _SPECIALIST_AGENTS load + zetetic bridge ADR
_SPECIALIST_AGENTS was a hardcoded 10-name set that excluded all 116 zetetic agents. Rewrites it as a dynamic load from ~/.claude/agents/ at module import: parses each agent file's YAML `name:` frontmatter, builds a frozenset, falls back to the original 10-name set if the directory is absent (CI / uninstalled environments). Count after load: 122 agents (116 zetetic + 6 Cortex-specific). Verified contains engineer, feynman, zhuangzi — proves team + genius tiers both picked up. Pairs with zetetic /session:memory-sync drainer now setting agent_topic=<memory_scope>. cortex:remember stores agent_context=<scope> (handlers/remember.py:351); this hook filters by agent_context at SubagentStart; bridge closes end-to-end. Test: scripts/test-agent-briefing.py (2 cases — genius match + unknown agent skip). Both pass. Full design in docs/adr/ADR-0048-zetetic-memory-bridge.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ffc9270 commit 07d5889

3 files changed

Lines changed: 307 additions & 13 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# ADR-0048: Zetetic memory_scope → Cortex agent_context bridge
2+
3+
**Date:** 2026-04-24
4+
**Status:** Accepted
5+
**Authors:** engineer (Claude Code)
6+
7+
---
8+
9+
## Context
10+
11+
Two systems were independently correct but structurally disconnected:
12+
13+
- **Zetetic** (`zetetic-team-subagents/commands/session/memory-sync.md`) drains
14+
the `~/.claude/memories/.pending-sync/` queue by calling `cortex:remember` with
15+
`tags=["memory-replica", "scope:<scope>", "agent:<agent_id>"]` — but it did NOT
16+
set `agent_topic`. As a result, `mcp_server/handlers/remember.py:351` wrote
17+
`agent_context=NULL` to every replicated memory row.
18+
19+
- **Cortex** (`mcp_server/hooks/agent_briefing.py:217`) filters memories with
20+
`WHERE agent_context = %s` at SubagentStart. With `agent_context=NULL` the
21+
filter matched nothing: agents received no prior-work briefings.
22+
23+
Additionally, `agent_briefing.py:90-101` hardcoded a 10-name `_SPECIALIST_AGENTS`
24+
set, silently skipping all 116 zetetic agents not in that list.
25+
26+
---
27+
28+
## Decision
29+
30+
### 1. Drainer sets `agent_topic` (zetetic-team-subagents)
31+
32+
`commands/session/memory-sync.md` now instructs the agent to pass
33+
`agent_topic: <scope>` (the job's `scope` field) on every `cortex:remember` call,
34+
in addition to the existing `tags`.
35+
36+
- Code site: `zetetic-team-subagents/commands/session/memory-sync.md`, step 3
37+
- Bridge: `scope``agent_topic` (MCP param) → `agent_context` (DB column,
38+
`Cortex/mcp_server/handlers/remember.py:351`)
39+
40+
### 2. Dynamic `_SPECIALIST_AGENTS` load (Cortex)
41+
42+
`mcp_server/hooks/agent_briefing.py` now calls `_load_specialist_agents()` at
43+
module load time. It scans `~/.claude/agents/*.md` and
44+
`~/.claude/agents/genius/*.md`, parses the `name:` YAML frontmatter field from
45+
each file, and builds a `frozenset` from those names.
46+
47+
- Falls back to the original 10-name set if neither directory exists (CI compat).
48+
- Cached for the process lifetime (load once; the file set rarely changes).
49+
- Code site: `Cortex/mcp_server/hooks/agent_briefing.py`, `_load_specialist_agents()`
50+
51+
With agents installed, the set resolves to **122 names** (98 genius + 19 team
52+
agents + 5 from frontmatter overlaps); all 116+ zetetic agents are covered.
53+
54+
---
55+
56+
## Consequences
57+
58+
- **Positive:** Cross-session memory continuity works for all zetetic agents.
59+
Prior-work memories stored via any agent are visible at SubagentStart for
60+
the same agent on the next spawn.
61+
- **Positive:** No DB schema change required. `agent_context` column already
62+
existed (`Cortex/mcp_server/handlers/remember.py:351`,
63+
`agent_briefing.py:217`).
64+
- **Positive:** Adding a new agent file in `~/.claude/agents/` automatically
65+
enrolls it without touching Python code (OCP satisfied).
66+
- **Negative:** Module load scans the filesystem once per process start. For
67+
122 files this is < 10 ms; acceptable.
68+
69+
---
70+
71+
## Alternatives considered
72+
73+
**Cortex-side daemon polling memory-tool's pending-sync queue** — rejected.
74+
This would duplicate the drainer mechanism already in zetetic, introduce
75+
a second writer path, and add operational complexity (another process to
76+
manage, monitor, and restart). The simpler fix — pass `agent_topic` in the
77+
existing drainer call — achieves the same result with zero new infrastructure.
78+
79+
---
80+
81+
## Verification
82+
83+
```bash
84+
# Dynamic load count
85+
cd /Users/cdeust/Developments/Cortex
86+
python3 -c "from mcp_server.hooks.agent_briefing import _SPECIALIST_AGENTS; print(len(_SPECIALIST_AGENTS))"
87+
# Expected: 122
88+
89+
# Hook unit tests
90+
python3 scripts/test-agent-briefing.py
91+
# Expected: 2/2 PASS
92+
```
93+
94+
---
95+
96+
## Primary sources
97+
98+
- Martin (2017) Clean Architecture §22: composition-root wiring.
99+
- `Cortex/mcp_server/handlers/remember.py:351``agent_context=agent_topic` assignment.
100+
- `Cortex/mcp_server/hooks/agent_briefing.py:217``WHERE agent_context = %s` filter.
101+
- `zetetic-team-subagents/commands/session/memory-sync.md` — drainer instruction set.

mcp_server/hooks/agent_briefing.py

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,27 +78,72 @@
7878

7979
import json
8080
import os
81+
import re
8182
import sys
83+
from pathlib import Path
8284
from typing import Any
8385

8486
_LOG_PREFIX = "[cortex-agent-briefing]"
8587
_DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://localhost:5432/cortex")
8688
_MAX_MEMORIES = 3
8789
_MIN_HEAT = 0.2
8890

89-
# Known specialist agents that benefit from briefing
90-
_SPECIALIST_AGENTS = {
91-
"engineer",
92-
"tester",
93-
"reviewer",
94-
"architect",
95-
"dba",
96-
"devops",
97-
"frontend",
98-
"security",
99-
"researcher",
100-
"ux",
101-
}
91+
# Fallback set used when ~/.claude/agents/ is missing (e.g., CI without install).
92+
_FALLBACK_AGENTS: frozenset[str] = frozenset({
93+
"engineer", "tester", "reviewer", "architect", "dba", "devops",
94+
"frontend", "security", "researcher", "ux",
95+
})
96+
97+
# Matches `name: <slug>` or `name: "<slug>"` in agent-file YAML frontmatter.
98+
_YAML_NAME_RE = re.compile(r"^name:\s*['\"]?([A-Za-z0-9_.-]+)['\"]?\s*$", re.MULTILINE)
99+
100+
101+
def _parse_frontmatter_name(path: Path) -> str | None:
102+
"""Extract the `name:` field from an agent file's YAML frontmatter.
103+
104+
Reads up to 4 KB (frontmatter always fits) and regex-matches the first
105+
top-level `name:` line. Returns None if the file is unreadable or has
106+
no name field. No side effects.
107+
"""
108+
try:
109+
head = path.read_text(errors="ignore")[:4096]
110+
except OSError:
111+
return None
112+
m = _YAML_NAME_RE.search(head)
113+
return m.group(1).strip() if m else None
114+
115+
116+
def _load_specialist_agents() -> frozenset[str]:
117+
"""Dynamically load agent slugs from ~/.claude/agents/ at module import.
118+
119+
Scans ~/.claude/agents/*.md and ~/.claude/agents/genius/*.md, parses the
120+
`name:` frontmatter field of each, and returns the frozen set. Falls back
121+
to _FALLBACK_AGENTS if the directory is absent. Result is cached for the
122+
process lifetime — agents added after import are not picked up until
123+
restart (acceptable for a hook process).
124+
125+
Each zetetic agent declares `memory_scope:` in frontmatter; that scope
126+
equals the name used as `agent_context` in Cortex memory rows. When the
127+
/session:memory-sync drainer sets `agent_topic=<scope>`, the briefing
128+
hook can filter by `agent_context = %s` and inject the right memories.
129+
"""
130+
root = Path.home() / ".claude" / "agents"
131+
if not root.is_dir():
132+
return _FALLBACK_AGENTS
133+
names: set[str] = set()
134+
for pattern in ("*.md", "genius/*.md"):
135+
for md in root.glob(pattern):
136+
if md.name == "INDEX.md":
137+
continue
138+
name = _parse_frontmatter_name(md)
139+
if name:
140+
names.add(name)
141+
return frozenset(names) if names else _FALLBACK_AGENTS
142+
143+
144+
# Known specialist agents that benefit from briefing — dynamic load from
145+
# ~/.claude/agents/ (116+ zetetic agents when installed).
146+
_SPECIALIST_AGENTS = _load_specialist_agents()
102147

103148

104149
def _log(msg: str) -> None:

scripts/test-agent-briefing.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env python3
2+
"""Test script for mcp_server/hooks/agent_briefing.py.
3+
4+
Verifies:
5+
1. SubagentStart with agent_name=feynman (a genius slug) triggers briefing
6+
when a matching memory exists in the stubbed DB.
7+
2. SubagentStart with agent_name=nonexistent-agent skips gracefully (exit 0).
8+
9+
Stubs psycopg so no live DB is required.
10+
11+
Pre-condition: run from Cortex repo root (sys.path must resolve mcp_server).
12+
Post-condition: exits 0 on pass; prints PASS/FAIL summary; exits 1 on any failure.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import io
18+
import sys
19+
import types
20+
import unittest
21+
from pathlib import Path
22+
from unittest.mock import MagicMock, patch
23+
24+
# Ensure Cortex package is importable when run as a script.
25+
repo_root = Path(__file__).resolve().parent.parent
26+
if str(repo_root) not in sys.path:
27+
sys.path.insert(0, str(repo_root))
28+
29+
30+
# ---------------------------------------------------------------------------
31+
# Stub psycopg so agent_briefing imports cleanly without a live PG connection.
32+
# ---------------------------------------------------------------------------
33+
34+
def _make_psycopg_stub(stub_rows: list[dict]) -> types.ModuleType:
35+
"""Build a minimal psycopg stub returning stub_rows on any execute().
36+
37+
Post-condition: the returned module exposes psycopg.connect() that
38+
yields a connection whose execute().fetchall() returns stub_rows.
39+
"""
40+
psycopg_mod = types.ModuleType("psycopg")
41+
rows_mod = types.ModuleType("psycopg.rows")
42+
43+
cursor = MagicMock()
44+
cursor.fetchall.return_value = stub_rows
45+
46+
conn = MagicMock()
47+
conn.execute.return_value = cursor
48+
49+
psycopg_mod.connect = MagicMock(return_value=conn)
50+
rows_mod.dict_row = MagicMock()
51+
psycopg_mod.rows = rows_mod
52+
53+
return psycopg_mod
54+
55+
56+
# ---------------------------------------------------------------------------
57+
# Helpers
58+
# ---------------------------------------------------------------------------
59+
60+
def _run_process_event(event: dict, stub_rows: list[dict]) -> tuple[str, str, int]:
61+
"""Run process_event() in isolation, capturing stdout/stderr and exit code.
62+
63+
Returns (stdout_text, stderr_text, exit_code).
64+
The module is reloaded so _SPECIALIST_AGENTS is re-evaluated from the
65+
live ~/.claude/agents dirs (dynamic load under test).
66+
"""
67+
psycopg_stub = _make_psycopg_stub(stub_rows)
68+
69+
stdout_buf = io.StringIO()
70+
stderr_buf = io.StringIO()
71+
72+
exit_code = 0
73+
74+
with patch.dict(sys.modules, {"psycopg": psycopg_stub, "psycopg.rows": psycopg_stub.rows}):
75+
# Force re-import so patched psycopg is visible inside _fetch_agent_context.
76+
if "mcp_server.hooks.agent_briefing" in sys.modules:
77+
del sys.modules["mcp_server.hooks.agent_briefing"]
78+
import mcp_server.hooks.agent_briefing as module
79+
80+
try:
81+
with patch("sys.stdout", stdout_buf), patch("sys.stderr", stderr_buf):
82+
module.process_event(event)
83+
except SystemExit as exc:
84+
exit_code = exc.code if isinstance(exc.code, int) else 0
85+
86+
return stdout_buf.getvalue(), stderr_buf.getvalue(), exit_code
87+
88+
89+
# ---------------------------------------------------------------------------
90+
# Test cases
91+
# ---------------------------------------------------------------------------
92+
93+
class TestAgentBriefing(unittest.TestCase):
94+
95+
def test_feynman_with_matching_memory_produces_briefing(self) -> None:
96+
"""Genius agent feynman + matching memory → stdout contains Cortex Briefing.
97+
98+
Pre-condition: feynman exists in ~/.claude/agents/genius/ (dynamic load).
99+
Post-condition: exit 0, stdout contains '## Cortex Briefing'.
100+
"""
101+
stub_rows = [
102+
{"content": "feynman past lesson: always verify sources", "heat": 0.8, "agent_context": "feynman"},
103+
]
104+
event = {
105+
"session_id": "test-session-001",
106+
"agent_name": "feynman",
107+
"agent_type": "genius",
108+
"prompt": "Explain the zetetic scientific standard and verify the implementation",
109+
"cwd": "/tmp",
110+
}
111+
stdout, stderr, code = _run_process_event(event, stub_rows)
112+
self.assertEqual(code, 0, f"Expected exit 0, got {code}. stderr: {stderr}")
113+
self.assertIn("Cortex Briefing", stdout, f"Expected 'Cortex Briefing' in stdout.\nstdout: {stdout!r}\nstderr: {stderr!r}")
114+
115+
def test_nonexistent_agent_skips_gracefully(self) -> None:
116+
"""Unknown agent name → exit 0 with skip log, no briefing emitted.
117+
118+
Pre-condition: nonexistent-agent is not in any agent file.
119+
Post-condition: exit 0, stdout empty, stderr contains 'skip'.
120+
"""
121+
event = {
122+
"session_id": "test-session-002",
123+
"agent_name": "nonexistent-agent",
124+
"agent_type": "custom",
125+
"prompt": "Do something with a nonexistent agent context here",
126+
"cwd": "/tmp",
127+
}
128+
stdout, stderr, code = _run_process_event(event, [])
129+
self.assertEqual(code, 0, f"Expected exit 0, got {code}. stderr: {stderr}")
130+
self.assertEqual(stdout.strip(), "", f"Expected empty stdout, got: {stdout!r}")
131+
self.assertIn("skip", stderr.lower(), f"Expected 'skip' in stderr. stderr: {stderr!r}")
132+
133+
134+
# ---------------------------------------------------------------------------
135+
# Entry point
136+
# ---------------------------------------------------------------------------
137+
138+
if __name__ == "__main__":
139+
loader = unittest.TestLoader()
140+
suite = loader.loadTestsFromTestCase(TestAgentBriefing)
141+
runner = unittest.TextTestRunner(verbosity=2)
142+
result = runner.run(suite)
143+
if result.wasSuccessful():
144+
print("\nPASS: all agent-briefing tests passed.")
145+
sys.exit(0)
146+
else:
147+
print(f"\nFAIL: {len(result.failures)} failure(s), {len(result.errors)} error(s).")
148+
sys.exit(1)

0 commit comments

Comments
 (0)