From 59632e94db9098771019bd76477835d7319fa35e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 7 Jun 2026 15:25:20 +0200 Subject: [PATCH] session-memory: distribute entrypoint + live verify (WP-0007 T05) python -m session_memory.distribute: reads approved catalog patterns, builds targets from repo->domain map x flavors, renders scoped per-flavor proposals (HITL) + active registry. Live verify against the real catalog: 12 renders across 5 repos, idempotent, provisional skipped. proposals/ gitignored (regenerated); active_patterns.json committed. README documents detect->curate-> distribute. Phase 3 finished; suite 126/126. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + session_memory/README.md | 35 ++++++- session_memory/config.toml | 6 ++ session_memory/distribute/__main__.py | 89 +++++++++++++++++ .../distribute/active_patterns.json | 98 +++++++++++++++++++ tests/test_distribute_entrypoint.py | 76 ++++++++++++++ .../AGENTIC-WP-0007-session-memory-phase3.md | 4 +- 7 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 session_memory/distribute/__main__.py create mode 100644 session_memory/distribute/active_patterns.json create mode 100644 tests/test_distribute_entrypoint.py diff --git a/.gitignore b/.gitignore index 8e3b8bc..5bbedaf 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,8 @@ cython_debug/ # session-memory local store session_memory/.store/ +# generated per-flavor distribution proposals (HITL, regenerated each run) +session_memory/proposals/ __pycache__/ *.pyc .pytest_cache/ diff --git a/session_memory/README.md b/session_memory/README.md index 9c173bf..24e15fb 100644 --- a/session_memory/README.md +++ b/session_memory/README.md @@ -33,6 +33,12 @@ session_memory/ curate/decisions.py # hub decision audit trail (graceful local-queue fallback) curate/__main__.py # python -m session_memory.curate (interactive / --auto-approve) catalog/ # the committed Pattern Catalog (source of truth) + distribute/base.py # Artifact + Distributor protocol + idempotent snippet markers + distribute/claude.py # CLAUDE.md (or skill) renderer } per-flavor edges + distribute/codex.py # AGENTS.md renderer } (agnostic body, + distribute/grok.py # native instruction renderer } different targets) + distribute/proposals.py # scoping + proposed-not-applied output + active registry + distribute/__main__.py # python -m session_memory.distribute config.toml # store paths, retention caps, sources, repo->domain map, curate gate ``` @@ -114,6 +120,27 @@ python -m session_memory.curate --json # machine-readable result | `dist_require_cross_flavor` | require cross-flavor evidence to be distribution-eligible | | `dist_min_frequency` / `dist_min_cost_impact` | stricter floor for `distribution_ready` | +## Distribute patterns as per-flavor proposals + +Render approved catalog patterns into per-flavor artifacts — **proposed, never +auto-applied** (HITL). Completes the loop: **detect → curate → distribute**. + +```bash +python -m session_memory.distribute # proposals for all repos/flavors +python -m session_memory.distribute --repo state-hub --flavor claude +python -m session_memory.distribute --json +``` + +- Only `approved` + `distribution_ready` patterns are rendered; each pattern's + `Scope` (repos/domains/flavors) decides where it lands (FR-X2). +- Each flavor renders the **same agnostic body** to its own target (Claude → + `CLAUDE.md`/skill, Codex → `AGENTS.md`, Grok → native) via `rendering_hints` + (FR-A3); blocks carry stable `BEGIN/END` markers so re-running updates in place. +- Output goes to `session_memory/proposals//` (gitignored, + regenerated) — a reviewable diff a human applies (FR-X3). The committed + `distribute/active_patterns.json` records which pattern+version is proposed in + which `(repo, flavor)` (FR-X4). + ## Retention knobs (`[retention]` in config.toml) | Key | Meaning | @@ -141,4 +168,10 @@ python -m pytest # schema, adapters, store, digest, retention, ingest, - **Phase 2** (AGENTIC-WP-0004): Curate — Solution Pattern schema, versioned files-first Pattern Catalog, discuss/approve/reject review with an evidence bar + bloat guard, and hub-decision audit trail. -- **Next — Phase 3 (Distribute) / Phase 4 (Measure)** follow per the PRD. +- **Detect hardening** (AGENTIC-WP-0005): session-quality filter + tool-mix / + infra-overhead signals. **Error mining** (AGENTIC-WP-0006): recurring error + fingerprints → root-cause patterns. +- **Phase 3** (AGENTIC-WP-0007): Distribute — per-flavor distributor adapters + render approved patterns into proposed (HITL) artifacts, scoped by repo/domain, + with an active-pattern registry. +- **Next — Phase 4 (Measure)** closes the loop per the PRD. diff --git a/session_memory/config.toml b/session_memory/config.toml index d5464be..7458157 100644 --- a/session_memory/config.toml +++ b/session_memory/config.toml @@ -39,6 +39,12 @@ min_substantive = 3 # require >= this many substantive (edit/read/shell) tool min_prompt_len = 25 # first prompt shorter than this is treated as trivial # Curate phase (AGENTIC-WP-0004): catalog location + promotion evidence bar. +# Distribute phase (AGENTIC-WP-0007): where per-flavor proposals + the active +# registry are written. Proposals are HITL — reviewed, never auto-applied. +[distribute] +proposals_dir = "session_memory/proposals" # reviewable proposals (gitignored, regenerated) +active_registry = "session_memory/distribute/active_patterns.json" # what's proposed/active where (committed) + [curate] catalog_dir = "session_memory/catalog" # files-first Pattern Catalog (committed) review_log = "session_memory/.store/reviews.jsonl" # remembered decisions (gitignored) diff --git a/session_memory/distribute/__main__.py b/session_memory/distribute/__main__.py new file mode 100644 index 0000000..9f02c60 --- /dev/null +++ b/session_memory/distribute/__main__.py @@ -0,0 +1,89 @@ +"""Distribute entrypoint (T05): catalog -> per-flavor proposals (HITL). + + python -m session_memory.distribute [--config PATH] [--repo R] [--flavor F] [--json] + +Reads approved / distribution-ready Solution Patterns from the Pattern Catalog and +renders them into per-flavor **proposals** (never auto-applied) scoped by +repo/domain, recording what is proposed where in the active-pattern registry. +Targets are the repo->domain map in ``config.toml`` crossed with the known +distributor flavors; each pattern's own ``Scope`` filters where it actually lands. +""" + +from __future__ import annotations + +import argparse +import json +import os + +from ..curate.catalog import Catalog +from ..ingest import _expand, load_config +from .proposals import ActiveRegistry, Target, propose +from .registry import all_flavors + + +def build_targets(config: dict, repo_filter=None, flavor_filter=None) -> list[Target]: + repo_map = config.get("repo_domain_map", {}) + flavors = [flavor_filter] if flavor_filter else all_flavors() + targets = [] + for repo, domain in repo_map.items(): + if repo_filter and repo != repo_filter: + continue + for flavor in flavors: + targets.append(Target(repo=repo, domain=domain, flavor=flavor)) + return targets + + +def run_distribute(config: dict, *, repo_filter=None, flavor_filter=None): + cur = config.get("curate", {}) + dist = config.get("distribute", {}) + catalog = Catalog(_expand(cur.get("catalog_dir", "session_memory/catalog"))) + patterns = catalog.list() + targets = build_targets(config, repo_filter, flavor_filter) + registry = ActiveRegistry(_expand(dist.get("active_registry", + "session_memory/distribute/active_patterns.json"))) + out_dir = _expand(dist.get("proposals_dir", "session_memory/proposals")) + return propose(patterns, targets, out_dir, registry) + + +def _summary(res) -> str: + by_repo = {} + for repo, flavor, pid, _ in res.proposals: + by_repo.setdefault(repo, []).append(f"{pid}[{flavor}]") + lines = [f"# Distribute proposals ({len(res.proposals)} renders, " + f"{len(res.files_written)} files)"] + for repo in sorted(by_repo): + lines.append(f" {repo}: {', '.join(sorted(by_repo[repo]))}") + if res.skipped_not_distributable: + lines.append(f" skipped (not distribution-ready): " + f"{len(set(res.skipped_not_distributable))} pattern(s)") + if not res.proposals: + lines.append(" (no approved/distribution-ready patterns matched any target)") + return "\n".join(lines) + + +def main(argv=None) -> int: + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ap = argparse.ArgumentParser(description="Distribute approved patterns as per-flavor proposals.") + ap.add_argument("--config", default=os.path.join(here, "config.toml")) + ap.add_argument("--repo", default=None, help="limit to one target repo") + ap.add_argument("--flavor", default=None, help="limit to one flavor") + ap.add_argument("--json", action="store_true") + args = ap.parse_args(argv) + + config = load_config(args.config) + res = run_distribute(config, repo_filter=args.repo, flavor_filter=args.flavor) + + if args.json: + print(json.dumps({ + "proposals": [{"repo": r, "flavor": f, "pattern_id": p, "path": path} + for r, f, p, path in res.proposals], + "files_written": res.files_written, + "skipped": sorted(set(res.skipped_not_distributable)), + }, indent=2)) + else: + print(_summary(res)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/session_memory/distribute/active_patterns.json b/session_memory/distribute/active_patterns.json new file mode 100644 index 0000000..3f127a1 --- /dev/null +++ b/session_memory/distribute/active_patterns.json @@ -0,0 +1,98 @@ +[ + { + "flavor": "claude", + "pattern_id": "sp-problem-schema_thrash-schema_load", + "repo": "ops-bridge", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "claude", + "pattern_id": "sp-problem-tool_thrash-tool-bash", + "repo": "state-hub", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "claude", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "agentic-resources", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "grok", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "agentic-resources", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "claude", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "can-you-assist", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "grok", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "can-you-assist", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "claude", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "ops-bridge", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "grok", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "ops-bridge", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "claude", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "state-hub", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "grok", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "state-hub", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "claude", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "the-custodian", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + }, + { + "flavor": "grok", + "pattern_id": "sp-success-clean_pass-outcome", + "repo": "the-custodian", + "status": "proposed", + "updated_at": "2026-06-07T13:20:58Z", + "version": "1.0.0" + } +] diff --git a/tests/test_distribute_entrypoint.py b/tests/test_distribute_entrypoint.py new file mode 100644 index 0000000..a3e5708 --- /dev/null +++ b/tests/test_distribute_entrypoint.py @@ -0,0 +1,76 @@ +"""Distribute entrypoint tests (WP-0007 T05).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from session_memory.curate.catalog import Catalog # noqa: E402 +from session_memory.curate.schema import Resolution, Scope, SolutionPattern # noqa: E402 +from session_memory.distribute.__main__ import build_targets, main, run_distribute # noqa: E402 + + +def _pattern(pid, repos, flavors, status="approved", ready=True): + return SolutionPattern( + id=pid, name=pid, version="1.0.0", polarity="problem", problem="p", + resolutions=[Resolution(summary="do x")], + scope=Scope(repos=repos, flavors=flavors), status=status, distribution_ready=ready, + ) + + +def _config(tmp_path): + return { + "repo_domain_map": {"agentic-resources": "helix_forge", "state-hub": "custodian"}, + "curate": {"catalog_dir": str(tmp_path / "catalog")}, + "distribute": {"proposals_dir": str(tmp_path / "proposals"), + "active_registry": str(tmp_path / "active.json")}, + } + + +def test_build_targets_crosses_repos_and_flavors(): + cfg = {"repo_domain_map": {"r1": "d1", "r2": "d2"}} + targets = build_targets(cfg) + assert len(targets) == 2 * 3 # 2 repos x 3 flavors + assert build_targets(cfg, repo_filter="r1") and all(t.repo == "r1" + for t in build_targets(cfg, repo_filter="r1")) + assert all(t.flavor == "claude" for t in build_targets(cfg, flavor_filter="claude")) + + +def test_run_distribute_scopes_to_catalog(tmp_path): + cfg = _config(tmp_path) + cat = Catalog(cfg["curate"]["catalog_dir"]) + # in-scope for agentic-resources/claude only + cat.upsert(_pattern("sp-a", ["agentic-resources"], ["claude"])) + # provisional -> must be skipped + cat.upsert(_pattern("sp-prov", [], [], status="provisional", ready=False)) + res = run_distribute(cfg) + rendered = {pid for _, _, pid, _ in res.proposals} + assert "sp-a" in rendered + assert "sp-prov" not in rendered + assert "sp-prov" in res.skipped_not_distributable + # landed only in the agentic-resources/CLAUDE.md proposal + p = os.path.join(cfg["distribute"]["proposals_dir"], "agentic-resources", "CLAUDE.md") + assert os.path.exists(p) + assert not os.path.exists( + os.path.join(cfg["distribute"]["proposals_dir"], "state-hub", "CLAUDE.md")) + + +def test_main_runs_json(tmp_path, capsys): + cfg = _config(tmp_path) + cat = Catalog(cfg["curate"]["catalog_dir"]) + cat.upsert(_pattern("sp-a", [], ["claude"])) # unrestricted repos + # write a config file + import json as _json + cfg_path = tmp_path / "c.json" + # main() loads TOML; emulate by calling run_distribute path via a tiny toml + toml = tmp_path / "config.toml" + toml.write_text( + f'[repo_domain_map]\nagentic-resources = "helix_forge"\n' + f'[curate]\ncatalog_dir = "{cfg["curate"]["catalog_dir"]}"\n' + f'[distribute]\nproposals_dir = "{cfg["distribute"]["proposals_dir"]}"\n' + f'active_registry = "{cfg["distribute"]["active_registry"]}"\n') + rc = main(["--config", str(toml), "--json"]) + assert rc == 0 + out = capsys.readouterr().out + assert "sp-a" in out + _json.loads(out) # valid JSON diff --git a/workplans/AGENTIC-WP-0007-session-memory-phase3.md b/workplans/AGENTIC-WP-0007-session-memory-phase3.md index f287d18..99051ba 100644 --- a/workplans/AGENTIC-WP-0007-session-memory-phase3.md +++ b/workplans/AGENTIC-WP-0007-session-memory-phase3.md @@ -4,7 +4,7 @@ type: workplan title: "Coding Session Memory — Phase 3 (Distribute: per-flavor artifacts, HITL)" domain: helix_forge repo: agentic-resources -status: ready +status: finished owner: codex topic_slug: helix-forge created: "2026-06-07" @@ -85,7 +85,7 @@ in an active-pattern registry (FR-X4). Unit-tested. ```task id: AGENTIC-WP-0007-T05 -status: todo +status: done priority: medium state_hub_task_id: "f9e24c13-7049-4c1c-a2d6-3a4dc4e752fd" ```