From 7646cbc358181216593cd2ecc17198eac035e04b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 7 Jun 2026 15:02:47 +0200 Subject: [PATCH] session-memory: distributor base + Artifact (WP-0007 T01) distribute/base.py: Artifact dataclass + Distributor protocol + idempotent BEGIN/END snippet markers (upsert_block replaces a pattern's block in place so re-distribution doesn't duplicate) + agnostic markdown body rendering from SolutionPattern fields. BaseDistributor honours per-flavor body/target hints. 8 new tests; suite 110/110. Co-Authored-By: Claude Opus 4.8 --- session_memory/distribute/__init__.py | 9 ++ session_memory/distribute/base.py | 115 ++++++++++++++++++ tests/test_distribute_base.py | 88 ++++++++++++++ .../AGENTIC-WP-0007-session-memory-phase3.md | 2 +- 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 session_memory/distribute/__init__.py create mode 100644 session_memory/distribute/base.py create mode 100644 tests/test_distribute_base.py diff --git a/session_memory/distribute/__init__.py b/session_memory/distribute/__init__.py new file mode 100644 index 0000000..cad6e0b --- /dev/null +++ b/session_memory/distribute/__init__.py @@ -0,0 +1,9 @@ +"""Distribute phase (PRD §6.4) — render approved Solution Patterns into per-flavor +artifacts. Mirror of the collector design: agnostic core, thin distributor edges. + + base.py Artifact + Distributor protocol + idempotent snippet markers (T01) + claude.py CLAUDE.md snippet distributor (T02) + codex.py AGENTS.md snippet distributor (T03) + grok.py native instruction distributor (T03) + __main__.py `python -m session_memory.distribute` (T05) +""" diff --git a/session_memory/distribute/base.py b/session_memory/distribute/base.py new file mode 100644 index 0000000..74197c7 --- /dev/null +++ b/session_memory/distribute/base.py @@ -0,0 +1,115 @@ +"""Distributor base — Artifact, the Distributor protocol, and idempotent markers +(PRD §6.4 FR-X1; T01). + +A **distributor** turns one agnostic :class:`SolutionPattern` into a per-flavor +:class:`Artifact` (a target path + a snippet of content). Everything flavor-neutral +lives here; each flavor adapter (T02/T03) only supplies its target filename and may +override the rendered body using the pattern's ``rendering_hints``. + +Snippets carry stable ``BEGIN/END`` markers keyed on the pattern id, so +re-distributing a pattern **updates its block in place** instead of duplicating it +— the property that lets Distribute run repeatedly (HITL) without drift. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Optional, Protocol, runtime_checkable + +from ..curate.schema import SolutionPattern + + +@dataclass +class Artifact: + """A proposed per-flavor rendering of a pattern (FR-X1/FR-X3 — proposed, not applied).""" + + flavor: str + target_path: str # repo-relative file the snippet belongs in (e.g. "CLAUDE.md") + pattern_id: str + content: str # the marker-wrapped snippet block + + +@runtime_checkable +class Distributor(Protocol): + flavor: str + target_path: str + + def render(self, pattern: SolutionPattern) -> Artifact: ... + + +# --- idempotent snippet markers --------------------------------------------- + +_MARK = "helix-forge pattern" + + +def begin_marker(pattern_id: str) -> str: + return f"" + + +def end_marker(pattern_id: str) -> str: + return f"" + + +def wrap_block(pattern_id: str, body: str, version: str = "") -> str: + """Wrap a rendered body in stable BEGIN/END markers.""" + ver = f" v{version}" if version else "" + return f"{begin_marker(pattern_id)}{ver}\n{body.strip()}\n{end_marker(pattern_id)}" + + +def upsert_block(doc_text: str, pattern_id: str, block: str) -> str: + """Insert or replace a pattern's marked block within a document (idempotent).""" + pat = re.compile( + re.escape(begin_marker(pattern_id)) + r".*?" + re.escape(end_marker(pattern_id)), + re.DOTALL, + ) + if pat.search(doc_text): + return pat.sub(block, doc_text) + sep = "" if doc_text.endswith("\n\n") or not doc_text else "\n\n" + return f"{doc_text}{sep}{block}\n" + + +# --- agnostic body rendering ------------------------------------------------ + +def render_markdown_body(pattern: SolutionPattern) -> str: + """Default flavor-neutral snippet body from the agnostic pattern fields.""" + label = "Avoid" if pattern.polarity == "problem" else "Prefer" + lines = [f"### {pattern.name}", "", pattern.problem.strip(), ""] + if pattern.resolutions: + lines.append(f"**{label}:**") + for r in pattern.resolutions: + detail = f" — {r.detail}" if r.detail else "" + lines.append(f"- {r.summary}{detail}") + for step in r.steps: + lines.append(f" - {step}") + return "\n".join(lines).strip() + + +def hint(pattern: SolutionPattern, flavor: str, key: str, default: Any = None) -> Any: + """Read a per-flavor rendering hint, falling back to ``default``.""" + return (pattern.rendering_hints.get(flavor) or {}).get(key, default) + + +class BaseDistributor: + """Shared distributor: renders the agnostic body, honouring a ``body`` hint + override and a ``target`` hint, then wraps it in idempotent markers.""" + + flavor: str = "" + target_path: str = "" + + def __init__(self, flavor: Optional[str] = None, target_path: Optional[str] = None) -> None: + if flavor is not None: + self.flavor = flavor + if target_path is not None: + self.target_path = target_path + + def body(self, pattern: SolutionPattern) -> str: + return hint(pattern, self.flavor, "body") or render_markdown_body(pattern) + + def target(self, pattern: SolutionPattern) -> str: + return hint(pattern, self.flavor, "target") or self.target_path + + def render(self, pattern: SolutionPattern) -> Artifact: + block = wrap_block(pattern.id, self.body(pattern), pattern.version) + return Artifact(flavor=self.flavor, target_path=self.target(pattern), + pattern_id=pattern.id, content=block) diff --git a/tests/test_distribute_base.py b/tests/test_distribute_base.py new file mode 100644 index 0000000..60385b8 --- /dev/null +++ b/tests/test_distribute_base.py @@ -0,0 +1,88 @@ +"""Distributor base tests (WP-0007 T01): markers, idempotent upsert, rendering.""" + +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.base import ( # noqa: E402 + Artifact, + BaseDistributor, + Distributor, + render_markdown_body, + upsert_block, + wrap_block, +) + + +def _pattern(pid="sp-x", polarity="problem"): + return SolutionPattern( + id=pid, name="Read before edit", version="1.2.0", polarity=polarity, + problem="Agents edit files they have not read.", + resolutions=[Resolution(summary="Read the file first", detail="then Edit", + steps=["Read", "Edit"])], + rendering_hints={"claude": {"target": "CLAUDE.md"}}, + ) + + +def test_render_markdown_body_has_problem_and_resolution(): + body = render_markdown_body(_pattern()) + assert "### Read before edit" in body + assert "Agents edit files" in body + assert "**Avoid:**" in body # problem polarity + assert "- Read the file first — then Edit" in body + assert " - Read" in body + + +def test_success_polarity_label(): + assert "**Prefer:**" in render_markdown_body(_pattern(polarity="success")) + + +def test_wrap_block_has_markers_and_version(): + block = wrap_block("sp-x", "hello", "1.2.0") + assert block.startswith(" v1.2.0") + assert block.rstrip().endswith("") + + +def test_upsert_inserts_then_replaces_in_place(): + doc = "# Title\n\nsome text\n" + b1 = wrap_block("sp-x", "first", "1") + once = upsert_block(doc, "sp-x", b1) + assert "first" in once and once.count("BEGIN helix-forge pattern:sp-x") == 1 + # re-distributing the same id replaces, does not duplicate + b2 = wrap_block("sp-x", "second", "2") + twice = upsert_block(once, "sp-x", b2) + assert "second" in twice and "first" not in twice + assert twice.count("BEGIN helix-forge pattern:sp-x") == 1 + + +def test_upsert_keeps_other_patterns(): + doc = upsert_block("", "sp-a", wrap_block("sp-a", "A")) + doc = upsert_block(doc, "sp-b", wrap_block("sp-b", "B")) + assert "sp-a" in doc and "sp-b" in doc + + +def test_base_distributor_renders_artifact(): + d = BaseDistributor(flavor="claude", target_path="CLAUDE.md") + art = d.render(_pattern()) + assert isinstance(art, Artifact) + assert isinstance(d, Distributor) # satisfies the protocol + assert art.flavor == "claude" + assert art.target_path == "CLAUDE.md" + assert "BEGIN helix-forge pattern:sp-x" in art.content + assert "Read before edit" in art.content + + +def test_body_hint_overrides_default(): + p = _pattern() + p.rendering_hints["claude"]["body"] = "custom claude body" + d = BaseDistributor(flavor="claude", target_path="CLAUDE.md") + assert "custom claude body" in d.render(p).content + + +def test_target_hint_overrides_default(): + p = _pattern() + p.rendering_hints["claude"]["target"] = "docs/CLAUDE.md" + d = BaseDistributor(flavor="claude", target_path="CLAUDE.md") + assert d.render(p).target_path == "docs/CLAUDE.md" diff --git a/workplans/AGENTIC-WP-0007-session-memory-phase3.md b/workplans/AGENTIC-WP-0007-session-memory-phase3.md index fe59b30..f9e02fb 100644 --- a/workplans/AGENTIC-WP-0007-session-memory-phase3.md +++ b/workplans/AGENTIC-WP-0007-session-memory-phase3.md @@ -29,7 +29,7 @@ active-pattern registry tracking which patterns are live where (FR-X4). ```task id: AGENTIC-WP-0007-T01 -status: todo +status: done priority: high state_hub_task_id: "ff618fa6-a78b-4b80-846b-8cde7ad65451" ```