From 9e28b1b806fe6f4385c3f72d583b6859c2e759c0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 7 Jun 2026 15:06:15 +0200 Subject: [PATCH] session-memory: Claude + Codex + Grok distributors + registry (WP-0007 T02/T03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin per-flavor distributors over the shared base: Claude (CLAUDE.md, optional skill-stub mode), Codex (AGENTS.md), Grok (.grok/instructions.md). registry maps flavor->distributor — adding a flavor is one entry + one module. Same agnostic body renders to distinct per-flavor targets (FR-A3). 7 new tests; suite 117/117. Co-Authored-By: Claude Opus 4.8 --- session_memory/distribute/claude.py | 42 ++++++++++++++++ session_memory/distribute/codex.py | 15 ++++++ session_memory/distribute/grok.py | 15 ++++++ session_memory/distribute/registry.py | 26 ++++++++++ tests/test_distribute_claude.py | 40 +++++++++++++++ tests/test_distribute_codex_grok.py | 49 +++++++++++++++++++ .../AGENTIC-WP-0007-session-memory-phase3.md | 4 +- 7 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 session_memory/distribute/claude.py create mode 100644 session_memory/distribute/codex.py create mode 100644 session_memory/distribute/grok.py create mode 100644 session_memory/distribute/registry.py create mode 100644 tests/test_distribute_claude.py create mode 100644 tests/test_distribute_codex_grok.py diff --git a/session_memory/distribute/claude.py b/session_memory/distribute/claude.py new file mode 100644 index 0000000..0252086 --- /dev/null +++ b/session_memory/distribute/claude.py @@ -0,0 +1,42 @@ +"""Claude distributor (PRD §6.4 FR-X1; T02). + +Renders an approved Solution Pattern into a ``CLAUDE.md`` snippet block. Most logic +is inherited from :class:`BaseDistributor`; the Claude-specific touch is an +optional **skill** rendering mode (``rendering_hints["claude"]["as"] == "skill"``) +that emits a skill-style stub instead of a plain instruction snippet — Claude's +native distribution targets are CLAUDE.md snippets, skills, or hooks. +""" + +from __future__ import annotations + +from ..curate.schema import SolutionPattern +from .base import BaseDistributor, hint, render_markdown_body + + +class ClaudeDistributor(BaseDistributor): + flavor = "claude" + target_path = "CLAUDE.md" + + def body(self, pattern: SolutionPattern) -> str: + override = hint(pattern, self.flavor, "body") + if override: + return override + if hint(pattern, self.flavor, "as") == "skill": + return self._skill_stub(pattern) + return render_markdown_body(pattern) + + @staticmethod + def _skill_stub(pattern: SolutionPattern) -> str: + trigger = "avoid" if pattern.polarity == "problem" else "apply" + lines = [ + f"## Skill: {pattern.name}", + "", + f"**When:** situations where you would {trigger} — {pattern.problem.strip()}", + "", + "**Steps:**", + ] + for r in pattern.resolutions: + lines.append(f"- {r.summary}" + (f" — {r.detail}" if r.detail else "")) + for step in r.steps: + lines.append(f" - {step}") + return "\n".join(lines).strip() diff --git a/session_memory/distribute/codex.py b/session_memory/distribute/codex.py new file mode 100644 index 0000000..c37d97d --- /dev/null +++ b/session_memory/distribute/codex.py @@ -0,0 +1,15 @@ +"""Codex distributor (PRD §6.4 FR-X1; T03). + +Renders an approved Solution Pattern into an ``AGENTS.md`` snippet — Codex's native +repo-convention surface. Identical agnostic body to the other flavors (FR-A3: one +pattern, expressible everywhere); only the target file differs. +""" + +from __future__ import annotations + +from .base import BaseDistributor + + +class CodexDistributor(BaseDistributor): + flavor = "codex" + target_path = "AGENTS.md" diff --git a/session_memory/distribute/grok.py b/session_memory/distribute/grok.py new file mode 100644 index 0000000..c28e485 --- /dev/null +++ b/session_memory/distribute/grok.py @@ -0,0 +1,15 @@ +"""Grok distributor (PRD §6.4 FR-X1; T03). + +Renders an approved Solution Pattern into Grok's native instruction format. Defaults +to a ``.grok/instructions.md`` snippet; the same agnostic body as the other flavors +(FR-A3), overridable via ``rendering_hints["grok"]``. +""" + +from __future__ import annotations + +from .base import BaseDistributor + + +class GrokDistributor(BaseDistributor): + flavor = "grok" + target_path = ".grok/instructions.md" diff --git a/session_memory/distribute/registry.py b/session_memory/distribute/registry.py new file mode 100644 index 0000000..e7b2377 --- /dev/null +++ b/session_memory/distribute/registry.py @@ -0,0 +1,26 @@ +"""Distributor registry (T03) — flavor -> distributor, the one place that knows +about all flavor edges. Adding a flavor = one entry here + one adapter module. +""" + +from __future__ import annotations + +from typing import Optional + +from .base import BaseDistributor +from .claude import ClaudeDistributor +from .codex import CodexDistributor +from .grok import GrokDistributor + +_REGISTRY: dict[str, BaseDistributor] = { + "claude": ClaudeDistributor(), + "codex": CodexDistributor(), + "grok": GrokDistributor(), +} + + +def get_distributor(flavor: str) -> Optional[BaseDistributor]: + return _REGISTRY.get(flavor) + + +def all_flavors() -> list[str]: + return list(_REGISTRY) diff --git a/tests/test_distribute_claude.py b/tests/test_distribute_claude.py new file mode 100644 index 0000000..6d1ce4f --- /dev/null +++ b/tests/test_distribute_claude.py @@ -0,0 +1,40 @@ +"""Claude distributor tests (WP-0007 T02).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from session_memory.curate.schema import Resolution, SolutionPattern # noqa: E402 +from session_memory.distribute.claude import ClaudeDistributor # noqa: E402 + + +def _pattern(hints=None): + return SolutionPattern( + id="sp-read-before-edit", name="Read before edit", version="1.0.0", + polarity="problem", problem="Agents edit files they have not read.", + resolutions=[Resolution(summary="Read the file first", steps=["Read", "Edit"])], + rendering_hints=hints or {"claude": {}}, + ) + + +def test_default_targets_claude_md(): + art = ClaudeDistributor().render(_pattern()) + assert art.flavor == "claude" + assert art.target_path == "CLAUDE.md" + assert "BEGIN helix-forge pattern:sp-read-before-edit" in art.content + assert "### Read before edit" in art.content + + +def test_skill_mode_emits_skill_stub(): + art = ClaudeDistributor().render(_pattern({"claude": {"as": "skill"}})) + assert "## Skill: Read before edit" in art.content + assert "**When:**" in art.content + assert " - Read" in art.content + + +def test_idempotent_marker_present_for_reupsert(): + art = ClaudeDistributor().render(_pattern()) + # same id in both renders -> caller can upsert in place + art2 = ClaudeDistributor().render(_pattern()) + assert art.pattern_id == art2.pattern_id == "sp-read-before-edit" diff --git a/tests/test_distribute_codex_grok.py b/tests/test_distribute_codex_grok.py new file mode 100644 index 0000000..eb791dd --- /dev/null +++ b/tests/test_distribute_codex_grok.py @@ -0,0 +1,49 @@ +"""Codex + Grok distributor + registry tests (WP-0007 T03).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from session_memory.curate.schema import Resolution, SolutionPattern # noqa: E402 +from session_memory.distribute.codex import CodexDistributor # noqa: E402 +from session_memory.distribute.grok import GrokDistributor # noqa: E402 +from session_memory.distribute.registry import all_flavors, get_distributor # noqa: E402 + + +def _pattern(): + return SolutionPattern( + id="sp-x", name="Read before edit", version="1.0.0", polarity="problem", + problem="Agents edit files they have not read.", + resolutions=[Resolution(summary="Read the file first")], + ) + + +def test_codex_targets_agents_md(): + art = CodexDistributor().render(_pattern()) + assert art.flavor == "codex" and art.target_path == "AGENTS.md" + assert "Read before edit" in art.content + + +def test_grok_targets_native_instructions(): + art = GrokDistributor().render(_pattern()) + assert art.flavor == "grok" and art.target_path == ".grok/instructions.md" + + +def test_same_pattern_expressible_for_all_flavors(): + # FR-A3: one pattern, rendered for every flavor (same body, different targets) + p = _pattern() + bodies = {} + for f in all_flavors(): + art = get_distributor(f).render(p) + # strip markers -> compare agnostic body + inner = art.content.split("\n", 1)[1].rsplit("\n", 1)[0] + bodies[f] = inner + targets = {get_distributor(f).render(p).target_path for f in all_flavors()} + assert len(targets) == 3 # distinct per-flavor targets + assert len(set(bodies.values())) == 1 # identical agnostic body + + +def test_registry_unknown_flavor(): + assert get_distributor("gpt") is None + assert set(all_flavors()) == {"claude", "codex", "grok"} diff --git a/workplans/AGENTIC-WP-0007-session-memory-phase3.md b/workplans/AGENTIC-WP-0007-session-memory-phase3.md index f9e02fb..c094321 100644 --- a/workplans/AGENTIC-WP-0007-session-memory-phase3.md +++ b/workplans/AGENTIC-WP-0007-session-memory-phase3.md @@ -44,7 +44,7 @@ adapters. Unit-tested. ```task id: AGENTIC-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: "64f50bd4-1fdf-452e-ae14-890253ab9f33" ``` @@ -57,7 +57,7 @@ place rather than duplicating. Uses `rendering_hints["claude"]`. Unit-tested. ```task id: AGENTIC-WP-0007-T03 -status: todo +status: done priority: high state_hub_task_id: "382790f5-1fb4-4394-b039-1649cbf3b20a" ```