From 3c4d707087b401aebc5dd378a5bf04c574f768ef Mon Sep 17 00:00:00 2001 From: Vatche Isahagian Date: Tue, 5 May 2026 16:21:57 -0400 Subject: [PATCH 1/4] feat(evolve-lite): manifest-first entity recall in plugin-source tree Rewrite the recall hook output from full entity bodies to a minimal manifest of {path, type, trigger}, so the agent reads entity files on demand rather than loading every stored entity into every turn. - plugin-source/lib/entity_io.py: add load_manifest, dedupe_manifest_entries, frontmatter-only parser; include public/ in find_recall_entity_dirs so published entities participate in recall; _manifest_path now resolves relative to the evolve root's parent so paths stay stable regardless of caller cwd. - plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py renamed to .py.j2 with platform-aware format_entities (bob emits human-readable markdown; claude/claw-code/codex emit JSON Lines). - plugin-source/skills/evolve-lite/recall/SKILL.md.j2: describe manifest emission per platform; cover public/ in the manual fallback; note the injected manifest supersedes directory scanning when present. - tests/platform_integrations: add test_entity_io_core.py, test_claude_retrieve_manifest.py, test_codex_retrieve_manifest.py; update test_retrieve.py, test_bob_sharing.py and the sharing/sync suites for manifest-first assertions. - platform-integrations/*: regenerated from plugin-source via build_plugins.py render. Co-Authored-By: Claude Opus 4.7 --- .../bob/evolve-lite/lib/entity_io.py | 100 ++++++++++++- .../skills/evolve-lite-recall/SKILL.md | 17 ++- .../scripts/retrieve_entities.py | 85 ++++------- .../plugins/evolve-lite/lib/entity_io.py | 100 ++++++++++++- .../skills/evolve-lite/recall/SKILL.md | 20 +-- .../recall/scripts/retrieve_entities.py | 84 ++++------- .../plugins/evolve-lite/lib/entity_io.py | 100 ++++++++++++- .../skills/evolve-lite/recall/SKILL.md | 20 +-- .../recall/scripts/retrieve_entities.py | 84 ++++------- .../plugins/evolve-lite/lib/entity_io.py | 100 ++++++++++++- .../skills/evolve-lite/recall/SKILL.md | 22 +-- .../recall/scripts/retrieve_entities.py | 84 ++++------- plugin-source/lib/entity_io.py | 100 ++++++++++++- .../skills/evolve-lite/recall/SKILL.md.j2 | 53 ++++--- ...ve_entities.py => retrieve_entities.py.j2} | 89 ++++-------- tests/platform_integrations/conftest.py | 2 +- .../platform_integrations/test_bob_sharing.py | 39 ++--- .../test_claude_retrieve_manifest.py | 133 ++++++++++++++++++ .../test_codex_retrieve_manifest.py | 133 ++++++++++++++++++ .../test_codex_sharing.py | 47 +++++-- .../test_entity_io_core.py | 51 +++++++ tests/platform_integrations/test_retrieve.py | 103 +++++++++----- tests/platform_integrations/test_subscribe.py | 10 +- tests/platform_integrations/test_sync.py | 5 +- 24 files changed, 1142 insertions(+), 439 deletions(-) rename plugin-source/skills/evolve-lite/recall/scripts/{retrieve_entities.py => retrieve_entities.py.j2} (58%) create mode 100644 tests/platform_integrations/test_claude_retrieve_manifest.py create mode 100644 tests/platform_integrations/test_codex_retrieve_manifest.py diff --git a/platform-integrations/bob/evolve-lite/lib/entity_io.py b/platform-integrations/bob/evolve-lite/lib/entity_io.py index b8e0eefa..63f77e2c 100644 --- a/platform-integrations/bob/evolve-lite/lib/entity_io.py +++ b/platform-integrations/bob/evolve-lite/lib/entity_io.py @@ -78,12 +78,13 @@ def find_entities_dir(): def find_recall_entity_dirs(): """Locate all directories that should be searched during recall. - Returns the existing recall roots. Only ``entities/`` is canonical — - private entities live in ``entities/guideline/`` and shared entities - live in ``entities/subscribed/{repo}/guideline/``. + Returns the existing recall roots. Two trees contribute to recall: + ``entities/`` (private entities in ``entities/guideline/`` and + subscribed entities in ``entities/subscribed/{repo}/guideline/``) and + ``public/`` (entities published by the local project). """ evolve_dir = get_evolve_dir() - candidates = [evolve_dir / "entities"] + candidates = [evolve_dir / "entities", evolve_dir / "public"] return [path for path in candidates if path.is_dir()] @@ -224,6 +225,97 @@ def markdown_to_entity(path): return entity +def _parse_frontmatter_lines(lines): + """Parse simple YAML-style frontmatter lines into a dict.""" + entity = {} + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key and value: + entity[key] = value + return entity + + +def _parse_frontmatter_only(path): + """Parse only the frontmatter section from a markdown entity file.""" + path = Path(path) + try: + with path.open(encoding="utf-8") as handle: + if handle.readline().strip() != "---": + return {} + + frontmatter_lines = [] + found_closing = False + for line in handle: + if line.strip() == "---": + found_closing = True + break + frontmatter_lines.append(line) + except (OSError, UnicodeDecodeError): + return {} + + if not found_closing: + return {} + + return _parse_frontmatter_lines(frontmatter_lines) + + +def _manifest_path(path): + """Return a manifest path relative to the project root (parent of the evolve dir). + + This keeps manifest paths stable regardless of the caller's working directory, + so hooks invoked from a subdirectory still emit ``.evolve/entities/...`` paths. + """ + path = Path(path) + try: + project_root = get_evolve_dir().resolve().parent + return str(path.resolve().relative_to(project_root)) + except ValueError: + return str(path) + + +def dedupe_manifest_entries(entries): + """Return deterministically ordered manifest entries with exact dedupe.""" + normalized = [] + seen = set() + for entry in sorted(entries, key=lambda item: (item["path"], item["type"], item["trigger"])): + key = (entry["path"], entry["type"], entry["trigger"]) + if key in seen: + continue + seen.add(key) + normalized.append(entry) + return normalized + + +def load_manifest(root_dir): + """Load a frontmatter-only manifest from a recall root.""" + root_dir = Path(root_dir) + entries = [] + for md in sorted(root_dir.glob("**/*.md")): + if md.is_symlink() or ".git" in md.parts: + continue + + entity = _parse_frontmatter_only(md) + entity_type = entity.get("type") + trigger = entity.get("trigger") + if not entity_type or not trigger: + continue + + entries.append( + { + "path": _manifest_path(md), + "type": entity_type, + "trigger": trigger, + } + ) + + return dedupe_manifest_entries(entries) + + # --------------------------------------------------------------------------- # Bulk load / write # --------------------------------------------------------------------------- diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md index b59e5b43..28304230 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md @@ -27,7 +27,7 @@ Before any non-trivial local work, you must complete the recall workflow below. Do not proceed to other analysis or tool use until all steps below are complete. -1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +1. If a manifest has already been injected for this turn, use it to pick which entity files to open. Otherwise inspect `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` for guidance relevant to the current task. 2. Read each matching entity file that appears relevant. 3. Summarize the applicable guidance in your own words before proceeding. 4. If no relevant entities exist, state that explicitly before proceeding. @@ -41,7 +41,7 @@ Before moving on, produce an explicit completion note in your reasoning or user ### Minimum Acceptable Procedure -1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` (or read the injected manifest if one is present). 2. Identify candidate entities relevant to the task. 3. Open and read those entity files. 4. Summarize what applies, or state that nothing applies. @@ -80,7 +80,14 @@ Entities can come from multiple sources: alice-guideline.md <- annotated [from: alice] ``` -Each file uses markdown with YAML frontmatter: +The manifest output is human-readable: + +``` +- `.evolve/entities/guideline/use-context-managers-for-file-operations.md` [guideline] — When processing files or managing resources +- `.evolve/entities/subscribed/alice/guideline/error-handling.md` [guideline] — When writing error handlers +``` + +Each file still uses markdown with YAML frontmatter: ```markdown --- @@ -94,3 +101,7 @@ Use context managers for file operations Ensures proper resource cleanup ``` + +## On-Demand Expansion + +When a manifest entry's trigger matches the current task, use `read_file` to load the full entity. The file body contains the guideline content and an optional `## Rationale` section. diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/scripts/retrieve_entities.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/scripts/retrieve_entities.py index 9daa7d38..b612f1e1 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/scripts/retrieve_entities.py +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/scripts/retrieve_entities.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Retrieve and output entities for the agent to use as extra context.""" +"""Retrieve and output an entity manifest for bob to expand on demand.""" import json import os @@ -21,7 +21,7 @@ if _lib is None: raise ImportError(f"Cannot find plugin lib directory above {_script}") sys.path.insert(0, str(_lib)) -from entity_io import find_entities_dir, get_evolve_dir, markdown_to_entity, log as _log # noqa: E402 +from entity_io import dedupe_manifest_entries, find_recall_entity_dirs, get_evolve_dir, load_manifest, log as _log # noqa: E402 import audit # noqa: E402 @@ -33,63 +33,29 @@ def log(message): def format_entities(entities): - """Format all entities for the agent to review. + """Format a manifest of entities for bob to expand on demand.""" + header = """## Evolve entity manifest for this task - Entities that came from a subscribed source have their path recorded in - the private ``_source`` key (set by load_entities_with_source). These are - annotated with ``[from: {name}]`` so the agent knows their provenance. - """ - header = """## Evolve entities for this task - -Review these stored entities and apply any that are relevant to the user's request: +These stored entities are available for this repo. Read only the files whose trigger looks relevant to the user's request: """ - items = [] - for entity in entities: - content = entity.get("content") - if not content: - continue - source = entity.get("_source") - if source: - content = f"[from: {source}] {content}" - item = f"- **[{entity.get('type', 'general')}]** {content}" - if entity.get("rationale"): - item += f"\n Rationale: {entity['rationale']}" - if entity.get("trigger"): - item += f"\n When: {entity['trigger']}" - items.append(item) - - return header + "\n".join(items) - - -def load_entities_with_source(entities_dir): - """Load markdown entities from one recall root and annotate subscribed content. - - Symlinks and any files inside a ``.git`` directory are skipped so we don't - surface git's own bookkeeping or sneak past path validation when a write - -scope clone lives under entities/subscribed/{name}/. - """ - entities_dir = Path(entities_dir) - entities = [] - for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): - if md.is_symlink(): - continue - try: - entity = markdown_to_entity(md) - except (OSError, UnicodeError): - continue - if not entity.get("content"): - continue + lines = [f"- `{e['path']}` [{e['type']}] — {e['trigger']}" for e in entities] + return header + "\n".join(lines) - entity.pop("_source", None) - entity["_id"] = str(md.relative_to(entities_dir).with_suffix("")) - parts = md.relative_to(entities_dir).parts - if parts and parts[0] == "subscribed" and len(parts) > 1: - entity["_source"] = parts[1] - entities.append(entity) +def _audit_id(path_str): + """Derive the audit entity id from a manifest path. - return entities + Matches upstream's convention for entities/: id is the path relative to + ``entities/`` with ``.md`` stripped (e.g. ``guideline/foo``, + ``subscribed/alice/guideline/bar``). Public entities are prefixed with + ``public/`` to keep the id space distinct from private entities. + """ + if "/entities/" in path_str: + return path_str.split("/entities/", 1)[1].removesuffix(".md") + if "/public/" in path_str: + return "public/" + path_str.split("/public/", 1)[1].removesuffix(".md") + return path_str.removesuffix(".md") def main(): @@ -124,12 +90,13 @@ def main(): log(f" {key}={value}") log("=== End Environment Variables ===") - entities_dir = find_entities_dir() - log(f"Entities dir: {entities_dir}") - entities = [] - if entities_dir: - entities = load_entities_with_source(entities_dir) + recall_dirs = find_recall_entity_dirs() + log(f"Recall dirs: {recall_dirs}") + for root_dir in recall_dirs: + entities.extend(load_manifest(root_dir)) + + entities = dedupe_manifest_entries(entities) if not entities: log("No entities found") @@ -156,7 +123,7 @@ def main(): session_id = stem.removeprefix("claude-transcript_") if not session_id and isinstance(input_data, dict) and isinstance(input_data.get("session_id"), str): session_id = input_data["session_id"] - entity_ids = sorted({entity["_id"] for entity in entities if entity.get("_id")}) + entity_ids = sorted({_audit_id(entity["path"]) for entity in entities if entity.get("path")}) if session_id and entity_ids: audit.append( evolve_dir=str(get_evolve_dir().resolve()), diff --git a/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py b/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py index b8e0eefa..63f77e2c 100644 --- a/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py +++ b/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py @@ -78,12 +78,13 @@ def find_entities_dir(): def find_recall_entity_dirs(): """Locate all directories that should be searched during recall. - Returns the existing recall roots. Only ``entities/`` is canonical — - private entities live in ``entities/guideline/`` and shared entities - live in ``entities/subscribed/{repo}/guideline/``. + Returns the existing recall roots. Two trees contribute to recall: + ``entities/`` (private entities in ``entities/guideline/`` and + subscribed entities in ``entities/subscribed/{repo}/guideline/``) and + ``public/`` (entities published by the local project). """ evolve_dir = get_evolve_dir() - candidates = [evolve_dir / "entities"] + candidates = [evolve_dir / "entities", evolve_dir / "public"] return [path for path in candidates if path.is_dir()] @@ -224,6 +225,97 @@ def markdown_to_entity(path): return entity +def _parse_frontmatter_lines(lines): + """Parse simple YAML-style frontmatter lines into a dict.""" + entity = {} + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key and value: + entity[key] = value + return entity + + +def _parse_frontmatter_only(path): + """Parse only the frontmatter section from a markdown entity file.""" + path = Path(path) + try: + with path.open(encoding="utf-8") as handle: + if handle.readline().strip() != "---": + return {} + + frontmatter_lines = [] + found_closing = False + for line in handle: + if line.strip() == "---": + found_closing = True + break + frontmatter_lines.append(line) + except (OSError, UnicodeDecodeError): + return {} + + if not found_closing: + return {} + + return _parse_frontmatter_lines(frontmatter_lines) + + +def _manifest_path(path): + """Return a manifest path relative to the project root (parent of the evolve dir). + + This keeps manifest paths stable regardless of the caller's working directory, + so hooks invoked from a subdirectory still emit ``.evolve/entities/...`` paths. + """ + path = Path(path) + try: + project_root = get_evolve_dir().resolve().parent + return str(path.resolve().relative_to(project_root)) + except ValueError: + return str(path) + + +def dedupe_manifest_entries(entries): + """Return deterministically ordered manifest entries with exact dedupe.""" + normalized = [] + seen = set() + for entry in sorted(entries, key=lambda item: (item["path"], item["type"], item["trigger"])): + key = (entry["path"], entry["type"], entry["trigger"]) + if key in seen: + continue + seen.add(key) + normalized.append(entry) + return normalized + + +def load_manifest(root_dir): + """Load a frontmatter-only manifest from a recall root.""" + root_dir = Path(root_dir) + entries = [] + for md in sorted(root_dir.glob("**/*.md")): + if md.is_symlink() or ".git" in md.parts: + continue + + entity = _parse_frontmatter_only(md) + entity_type = entity.get("type") + trigger = entity.get("trigger") + if not entity_type or not trigger: + continue + + entries.append( + { + "path": _manifest_path(md), + "type": entity_type, + "trigger": trigger, + } + ) + + return dedupe_manifest_entries(entries) + + # --------------------------------------------------------------------------- # Bulk load / write # --------------------------------------------------------------------------- diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md index 9bb3da53..a84f39fd 100644 --- a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md @@ -28,7 +28,7 @@ Before any non-trivial local work, you must complete the recall workflow below. Do not proceed to other analysis or tool use until all steps below are complete. -1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +1. If a manifest has already been injected for this turn, use it to pick which entity files to open. Otherwise inspect `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` for guidance relevant to the current task. 2. Read each matching entity file that appears relevant. 3. **Quote each matching entity verbatim in your final response** — include the full file contents (frontmatter, body, rationale, trigger). The parent agent does not see your intermediate Read tool results, so anything you do not quote in your final response is lost. 4. If no relevant entities exist, state that explicitly in your final response. @@ -42,7 +42,7 @@ Before moving on, produce an explicit completion note in your reasoning or user ### Minimum Acceptable Procedure -1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` (or read the injected manifest if one is present). 2. Identify candidate entities relevant to the task. 3. Open and read those entity files. 4. Quote each applicable entity's full file contents in your final response, or state that nothing applies. @@ -60,11 +60,9 @@ The skill is not complete if any of the following are true: 1. The Claude `UserPromptSubmit` hook fires before each user prompt is sent. 2. The helper script reads the prompt JSON from stdin. -3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, - read-scope subscriptions, and write-scope publish targets which all - live under `entities/subscribed/{repo}/`). -4. It prints formatted guidance to stdout. -5. Claude adds that text as additional context for the turn. +3. It emits a minimal manifest from `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` containing only `path`, `type`, and `trigger`. +4. Claude uses that manifest to decide which full entity files to read on demand. +5. If the hook is not active, this skill remains the full manual fallback: inspect the entity files directly, read the relevant ones, and summarize what applies. ## Entities Storage @@ -81,7 +79,13 @@ The skill is not complete if any of the following are true: alice-guideline.md <- annotated [from: alice] ``` -Each file uses markdown with YAML frontmatter: +Automatic hook output is manifest-first. Each manifest entry contains only: + +```json +{"path": ".evolve/entities/guideline/use-context-managers-for-file-operations.md", "type": "guideline", "trigger": "When processing files or managing resources"} +``` + +Each file still uses markdown with YAML frontmatter: ```markdown --- diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py index 9daa7d38..32a77aae 100644 --- a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Retrieve and output entities for the agent to use as extra context.""" +"""Retrieve and output an entity manifest for claude to expand on demand.""" import json import os @@ -21,7 +21,7 @@ if _lib is None: raise ImportError(f"Cannot find plugin lib directory above {_script}") sys.path.insert(0, str(_lib)) -from entity_io import find_entities_dir, get_evolve_dir, markdown_to_entity, log as _log # noqa: E402 +from entity_io import dedupe_manifest_entries, find_recall_entity_dirs, get_evolve_dir, load_manifest, log as _log # noqa: E402 import audit # noqa: E402 @@ -33,63 +33,28 @@ def log(message): def format_entities(entities): - """Format all entities for the agent to review. + """Format a manifest of entities for claude to expand on demand.""" + header = """## Evolve entity manifest for this task - Entities that came from a subscribed source have their path recorded in - the private ``_source`` key (set by load_entities_with_source). These are - annotated with ``[from: {name}]`` so the agent knows their provenance. - """ - header = """## Evolve entities for this task - -Review these stored entities and apply any that are relevant to the user's request: +These stored entities are available for this repo. Read only the files whose trigger looks relevant to the user's request: """ - items = [] - for entity in entities: - content = entity.get("content") - if not content: - continue - source = entity.get("_source") - if source: - content = f"[from: {source}] {content}" - item = f"- **[{entity.get('type', 'general')}]** {content}" - if entity.get("rationale"): - item += f"\n Rationale: {entity['rationale']}" - if entity.get("trigger"): - item += f"\n When: {entity['trigger']}" - items.append(item) - - return header + "\n".join(items) - - -def load_entities_with_source(entities_dir): - """Load markdown entities from one recall root and annotate subscribed content. - - Symlinks and any files inside a ``.git`` directory are skipped so we don't - surface git's own bookkeeping or sneak past path validation when a write - -scope clone lives under entities/subscribed/{name}/. - """ - entities_dir = Path(entities_dir) - entities = [] - for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): - if md.is_symlink(): - continue - try: - entity = markdown_to_entity(md) - except (OSError, UnicodeError): - continue - if not entity.get("content"): - continue + return header + "\n".join(json.dumps(entity) for entity in entities) - entity.pop("_source", None) - entity["_id"] = str(md.relative_to(entities_dir).with_suffix("")) - parts = md.relative_to(entities_dir).parts - if parts and parts[0] == "subscribed" and len(parts) > 1: - entity["_source"] = parts[1] - entities.append(entity) +def _audit_id(path_str): + """Derive the audit entity id from a manifest path. - return entities + Matches upstream's convention for entities/: id is the path relative to + ``entities/`` with ``.md`` stripped (e.g. ``guideline/foo``, + ``subscribed/alice/guideline/bar``). Public entities are prefixed with + ``public/`` to keep the id space distinct from private entities. + """ + if "/entities/" in path_str: + return path_str.split("/entities/", 1)[1].removesuffix(".md") + if "/public/" in path_str: + return "public/" + path_str.split("/public/", 1)[1].removesuffix(".md") + return path_str.removesuffix(".md") def main(): @@ -124,12 +89,13 @@ def main(): log(f" {key}={value}") log("=== End Environment Variables ===") - entities_dir = find_entities_dir() - log(f"Entities dir: {entities_dir}") - entities = [] - if entities_dir: - entities = load_entities_with_source(entities_dir) + recall_dirs = find_recall_entity_dirs() + log(f"Recall dirs: {recall_dirs}") + for root_dir in recall_dirs: + entities.extend(load_manifest(root_dir)) + + entities = dedupe_manifest_entries(entities) if not entities: log("No entities found") @@ -156,7 +122,7 @@ def main(): session_id = stem.removeprefix("claude-transcript_") if not session_id and isinstance(input_data, dict) and isinstance(input_data.get("session_id"), str): session_id = input_data["session_id"] - entity_ids = sorted({entity["_id"] for entity in entities if entity.get("_id")}) + entity_ids = sorted({_audit_id(entity["path"]) for entity in entities if entity.get("path")}) if session_id and entity_ids: audit.append( evolve_dir=str(get_evolve_dir().resolve()), diff --git a/platform-integrations/claw-code/plugins/evolve-lite/lib/entity_io.py b/platform-integrations/claw-code/plugins/evolve-lite/lib/entity_io.py index b8e0eefa..63f77e2c 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/lib/entity_io.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/lib/entity_io.py @@ -78,12 +78,13 @@ def find_entities_dir(): def find_recall_entity_dirs(): """Locate all directories that should be searched during recall. - Returns the existing recall roots. Only ``entities/`` is canonical — - private entities live in ``entities/guideline/`` and shared entities - live in ``entities/subscribed/{repo}/guideline/``. + Returns the existing recall roots. Two trees contribute to recall: + ``entities/`` (private entities in ``entities/guideline/`` and + subscribed entities in ``entities/subscribed/{repo}/guideline/``) and + ``public/`` (entities published by the local project). """ evolve_dir = get_evolve_dir() - candidates = [evolve_dir / "entities"] + candidates = [evolve_dir / "entities", evolve_dir / "public"] return [path for path in candidates if path.is_dir()] @@ -224,6 +225,97 @@ def markdown_to_entity(path): return entity +def _parse_frontmatter_lines(lines): + """Parse simple YAML-style frontmatter lines into a dict.""" + entity = {} + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key and value: + entity[key] = value + return entity + + +def _parse_frontmatter_only(path): + """Parse only the frontmatter section from a markdown entity file.""" + path = Path(path) + try: + with path.open(encoding="utf-8") as handle: + if handle.readline().strip() != "---": + return {} + + frontmatter_lines = [] + found_closing = False + for line in handle: + if line.strip() == "---": + found_closing = True + break + frontmatter_lines.append(line) + except (OSError, UnicodeDecodeError): + return {} + + if not found_closing: + return {} + + return _parse_frontmatter_lines(frontmatter_lines) + + +def _manifest_path(path): + """Return a manifest path relative to the project root (parent of the evolve dir). + + This keeps manifest paths stable regardless of the caller's working directory, + so hooks invoked from a subdirectory still emit ``.evolve/entities/...`` paths. + """ + path = Path(path) + try: + project_root = get_evolve_dir().resolve().parent + return str(path.resolve().relative_to(project_root)) + except ValueError: + return str(path) + + +def dedupe_manifest_entries(entries): + """Return deterministically ordered manifest entries with exact dedupe.""" + normalized = [] + seen = set() + for entry in sorted(entries, key=lambda item: (item["path"], item["type"], item["trigger"])): + key = (entry["path"], entry["type"], entry["trigger"]) + if key in seen: + continue + seen.add(key) + normalized.append(entry) + return normalized + + +def load_manifest(root_dir): + """Load a frontmatter-only manifest from a recall root.""" + root_dir = Path(root_dir) + entries = [] + for md in sorted(root_dir.glob("**/*.md")): + if md.is_symlink() or ".git" in md.parts: + continue + + entity = _parse_frontmatter_only(md) + entity_type = entity.get("type") + trigger = entity.get("trigger") + if not entity_type or not trigger: + continue + + entries.append( + { + "path": _manifest_path(md), + "type": entity_type, + "trigger": trigger, + } + ) + + return dedupe_manifest_entries(entries) + + # --------------------------------------------------------------------------- # Bulk load / write # --------------------------------------------------------------------------- diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md index 77c0c05b..33b470ff 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md @@ -27,7 +27,7 @@ Before any non-trivial local work, you must complete the recall workflow below. Do not proceed to other analysis or tool use until all steps below are complete. -1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +1. If a manifest has already been injected for this turn, use it to pick which entity files to open. Otherwise inspect `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` for guidance relevant to the current task. 2. Read each matching entity file that appears relevant. 3. Summarize the applicable guidance in your own words before proceeding. 4. If no relevant entities exist, state that explicitly before proceeding. @@ -41,7 +41,7 @@ Before moving on, produce an explicit completion note in your reasoning or user ### Minimum Acceptable Procedure -1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` (or read the injected manifest if one is present). 2. Identify candidate entities relevant to the task. 3. Open and read those entity files. 4. Summarize what applies, or state that nothing applies. @@ -59,11 +59,9 @@ The skill is not complete if any of the following are true: 1. The Claw-code `PreToolUse` hook fires before each tool call. 2. The helper script reads tool input from stdin (best-effort, ignored beyond logging). -3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, - read-scope subscriptions, and write-scope publish targets which all - live under `entities/subscribed/{repo}/`). -4. It prints formatted guidance to stdout. -5. Claw-code adds that text as additional context for the turn. +3. It emits a minimal manifest from `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` containing only `path`, `type`, and `trigger`. +4. Claw-code uses that manifest to decide which full entity files to read on demand. +5. If the hook is not active, this skill remains the full manual fallback: inspect the entity files directly, read the relevant ones, and summarize what applies. ## Entities Storage @@ -80,7 +78,13 @@ The skill is not complete if any of the following are true: alice-guideline.md <- annotated [from: alice] ``` -Each file uses markdown with YAML frontmatter: +Automatic hook output is manifest-first. Each manifest entry contains only: + +```json +{"path": ".evolve/entities/guideline/use-context-managers-for-file-operations.md", "type": "guideline", "trigger": "When processing files or managing resources"} +``` + +Each file still uses markdown with YAML frontmatter: ```markdown --- diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py index 9daa7d38..69e069f7 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Retrieve and output entities for the agent to use as extra context.""" +"""Retrieve and output an entity manifest for claw-code to expand on demand.""" import json import os @@ -21,7 +21,7 @@ if _lib is None: raise ImportError(f"Cannot find plugin lib directory above {_script}") sys.path.insert(0, str(_lib)) -from entity_io import find_entities_dir, get_evolve_dir, markdown_to_entity, log as _log # noqa: E402 +from entity_io import dedupe_manifest_entries, find_recall_entity_dirs, get_evolve_dir, load_manifest, log as _log # noqa: E402 import audit # noqa: E402 @@ -33,63 +33,28 @@ def log(message): def format_entities(entities): - """Format all entities for the agent to review. + """Format a manifest of entities for claw-code to expand on demand.""" + header = """## Evolve entity manifest for this task - Entities that came from a subscribed source have their path recorded in - the private ``_source`` key (set by load_entities_with_source). These are - annotated with ``[from: {name}]`` so the agent knows their provenance. - """ - header = """## Evolve entities for this task - -Review these stored entities and apply any that are relevant to the user's request: +These stored entities are available for this repo. Read only the files whose trigger looks relevant to the user's request: """ - items = [] - for entity in entities: - content = entity.get("content") - if not content: - continue - source = entity.get("_source") - if source: - content = f"[from: {source}] {content}" - item = f"- **[{entity.get('type', 'general')}]** {content}" - if entity.get("rationale"): - item += f"\n Rationale: {entity['rationale']}" - if entity.get("trigger"): - item += f"\n When: {entity['trigger']}" - items.append(item) - - return header + "\n".join(items) - - -def load_entities_with_source(entities_dir): - """Load markdown entities from one recall root and annotate subscribed content. - - Symlinks and any files inside a ``.git`` directory are skipped so we don't - surface git's own bookkeeping or sneak past path validation when a write - -scope clone lives under entities/subscribed/{name}/. - """ - entities_dir = Path(entities_dir) - entities = [] - for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): - if md.is_symlink(): - continue - try: - entity = markdown_to_entity(md) - except (OSError, UnicodeError): - continue - if not entity.get("content"): - continue + return header + "\n".join(json.dumps(entity) for entity in entities) - entity.pop("_source", None) - entity["_id"] = str(md.relative_to(entities_dir).with_suffix("")) - parts = md.relative_to(entities_dir).parts - if parts and parts[0] == "subscribed" and len(parts) > 1: - entity["_source"] = parts[1] - entities.append(entity) +def _audit_id(path_str): + """Derive the audit entity id from a manifest path. - return entities + Matches upstream's convention for entities/: id is the path relative to + ``entities/`` with ``.md`` stripped (e.g. ``guideline/foo``, + ``subscribed/alice/guideline/bar``). Public entities are prefixed with + ``public/`` to keep the id space distinct from private entities. + """ + if "/entities/" in path_str: + return path_str.split("/entities/", 1)[1].removesuffix(".md") + if "/public/" in path_str: + return "public/" + path_str.split("/public/", 1)[1].removesuffix(".md") + return path_str.removesuffix(".md") def main(): @@ -124,12 +89,13 @@ def main(): log(f" {key}={value}") log("=== End Environment Variables ===") - entities_dir = find_entities_dir() - log(f"Entities dir: {entities_dir}") - entities = [] - if entities_dir: - entities = load_entities_with_source(entities_dir) + recall_dirs = find_recall_entity_dirs() + log(f"Recall dirs: {recall_dirs}") + for root_dir in recall_dirs: + entities.extend(load_manifest(root_dir)) + + entities = dedupe_manifest_entries(entities) if not entities: log("No entities found") @@ -156,7 +122,7 @@ def main(): session_id = stem.removeprefix("claude-transcript_") if not session_id and isinstance(input_data, dict) and isinstance(input_data.get("session_id"), str): session_id = input_data["session_id"] - entity_ids = sorted({entity["_id"] for entity in entities if entity.get("_id")}) + entity_ids = sorted({_audit_id(entity["path"]) for entity in entities if entity.get("path")}) if session_id and entity_ids: audit.append( evolve_dir=str(get_evolve_dir().resolve()), diff --git a/platform-integrations/codex/plugins/evolve-lite/lib/entity_io.py b/platform-integrations/codex/plugins/evolve-lite/lib/entity_io.py index b8e0eefa..63f77e2c 100644 --- a/platform-integrations/codex/plugins/evolve-lite/lib/entity_io.py +++ b/platform-integrations/codex/plugins/evolve-lite/lib/entity_io.py @@ -78,12 +78,13 @@ def find_entities_dir(): def find_recall_entity_dirs(): """Locate all directories that should be searched during recall. - Returns the existing recall roots. Only ``entities/`` is canonical — - private entities live in ``entities/guideline/`` and shared entities - live in ``entities/subscribed/{repo}/guideline/``. + Returns the existing recall roots. Two trees contribute to recall: + ``entities/`` (private entities in ``entities/guideline/`` and + subscribed entities in ``entities/subscribed/{repo}/guideline/``) and + ``public/`` (entities published by the local project). """ evolve_dir = get_evolve_dir() - candidates = [evolve_dir / "entities"] + candidates = [evolve_dir / "entities", evolve_dir / "public"] return [path for path in candidates if path.is_dir()] @@ -224,6 +225,97 @@ def markdown_to_entity(path): return entity +def _parse_frontmatter_lines(lines): + """Parse simple YAML-style frontmatter lines into a dict.""" + entity = {} + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key and value: + entity[key] = value + return entity + + +def _parse_frontmatter_only(path): + """Parse only the frontmatter section from a markdown entity file.""" + path = Path(path) + try: + with path.open(encoding="utf-8") as handle: + if handle.readline().strip() != "---": + return {} + + frontmatter_lines = [] + found_closing = False + for line in handle: + if line.strip() == "---": + found_closing = True + break + frontmatter_lines.append(line) + except (OSError, UnicodeDecodeError): + return {} + + if not found_closing: + return {} + + return _parse_frontmatter_lines(frontmatter_lines) + + +def _manifest_path(path): + """Return a manifest path relative to the project root (parent of the evolve dir). + + This keeps manifest paths stable regardless of the caller's working directory, + so hooks invoked from a subdirectory still emit ``.evolve/entities/...`` paths. + """ + path = Path(path) + try: + project_root = get_evolve_dir().resolve().parent + return str(path.resolve().relative_to(project_root)) + except ValueError: + return str(path) + + +def dedupe_manifest_entries(entries): + """Return deterministically ordered manifest entries with exact dedupe.""" + normalized = [] + seen = set() + for entry in sorted(entries, key=lambda item: (item["path"], item["type"], item["trigger"])): + key = (entry["path"], entry["type"], entry["trigger"]) + if key in seen: + continue + seen.add(key) + normalized.append(entry) + return normalized + + +def load_manifest(root_dir): + """Load a frontmatter-only manifest from a recall root.""" + root_dir = Path(root_dir) + entries = [] + for md in sorted(root_dir.glob("**/*.md")): + if md.is_symlink() or ".git" in md.parts: + continue + + entity = _parse_frontmatter_only(md) + entity_type = entity.get("type") + trigger = entity.get("trigger") + if not entity_type or not trigger: + continue + + entries.append( + { + "path": _manifest_path(md), + "type": entity_type, + "trigger": trigger, + } + ) + + return dedupe_manifest_entries(entries) + + # --------------------------------------------------------------------------- # Bulk load / write # --------------------------------------------------------------------------- diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md index d0587a93..4c39550d 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md @@ -27,7 +27,7 @@ Before any non-trivial local work, you must complete the recall workflow below. Do not proceed to other analysis or tool use until all steps below are complete. -1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +1. If a manifest has already been injected for this turn, use it to pick which entity files to open. Otherwise inspect `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` for guidance relevant to the current task. 2. Read each matching entity file that appears relevant. 3. Summarize the applicable guidance in your own words before proceeding. 4. If no relevant entities exist, state that explicitly before proceeding. @@ -41,7 +41,7 @@ Before moving on, produce an explicit completion note in your reasoning or user ### Minimum Acceptable Procedure -1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` (or read the injected manifest if one is present). 2. Identify candidate entities relevant to the task. 3. Open and read those entity files. 4. Summarize what applies, or state that nothing applies. @@ -59,13 +59,9 @@ The skill is not complete if any of the following are true: 1. If Codex hooks are enabled in `~/.codex/config.toml` with `[features] codex_hooks = true`, the Codex `UserPromptSubmit` hook runs before the prompt is sent. 2. The helper script reads the prompt JSON from stdin. -3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, - read-scope subscriptions, and write-scope publish targets which all - live under `entities/subscribed/{repo}/`). -4. It prints formatted guidance to stdout. -5. Codex adds that text as extra developer context for the turn. - -If hooks are not enabled, complete the **Required Action** workflow above manually. +3. It emits a minimal manifest from `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` containing only `path`, `type`, and `trigger`. +4. Codex uses that manifest to decide which full entity files to read on demand. +5. If hooks are disabled, this skill remains the full manual fallback: inspect the entity files directly, read the relevant ones, and summarize what applies. ## Entities Storage @@ -82,7 +78,13 @@ If hooks are not enabled, complete the **Required Action** workflow above manual alice-guideline.md <- annotated [from: alice] ``` -Each file uses markdown with YAML frontmatter: +Automatic hook output is manifest-first. Each manifest entry contains only: + +```json +{"path": ".evolve/entities/guideline/use-context-managers-for-file-operations.md", "type": "guideline", "trigger": "When processing files or managing resources"} +``` + +Each file still uses markdown with YAML frontmatter: ```markdown --- diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py index 9daa7d38..c20f5e63 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Retrieve and output entities for the agent to use as extra context.""" +"""Retrieve and output an entity manifest for codex to expand on demand.""" import json import os @@ -21,7 +21,7 @@ if _lib is None: raise ImportError(f"Cannot find plugin lib directory above {_script}") sys.path.insert(0, str(_lib)) -from entity_io import find_entities_dir, get_evolve_dir, markdown_to_entity, log as _log # noqa: E402 +from entity_io import dedupe_manifest_entries, find_recall_entity_dirs, get_evolve_dir, load_manifest, log as _log # noqa: E402 import audit # noqa: E402 @@ -33,63 +33,28 @@ def log(message): def format_entities(entities): - """Format all entities for the agent to review. + """Format a manifest of entities for codex to expand on demand.""" + header = """## Evolve entity manifest for this task - Entities that came from a subscribed source have their path recorded in - the private ``_source`` key (set by load_entities_with_source). These are - annotated with ``[from: {name}]`` so the agent knows their provenance. - """ - header = """## Evolve entities for this task - -Review these stored entities and apply any that are relevant to the user's request: +These stored entities are available for this repo. Read only the files whose trigger looks relevant to the user's request: """ - items = [] - for entity in entities: - content = entity.get("content") - if not content: - continue - source = entity.get("_source") - if source: - content = f"[from: {source}] {content}" - item = f"- **[{entity.get('type', 'general')}]** {content}" - if entity.get("rationale"): - item += f"\n Rationale: {entity['rationale']}" - if entity.get("trigger"): - item += f"\n When: {entity['trigger']}" - items.append(item) - - return header + "\n".join(items) - - -def load_entities_with_source(entities_dir): - """Load markdown entities from one recall root and annotate subscribed content. - - Symlinks and any files inside a ``.git`` directory are skipped so we don't - surface git's own bookkeeping or sneak past path validation when a write - -scope clone lives under entities/subscribed/{name}/. - """ - entities_dir = Path(entities_dir) - entities = [] - for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): - if md.is_symlink(): - continue - try: - entity = markdown_to_entity(md) - except (OSError, UnicodeError): - continue - if not entity.get("content"): - continue + return header + "\n".join(json.dumps(entity) for entity in entities) - entity.pop("_source", None) - entity["_id"] = str(md.relative_to(entities_dir).with_suffix("")) - parts = md.relative_to(entities_dir).parts - if parts and parts[0] == "subscribed" and len(parts) > 1: - entity["_source"] = parts[1] - entities.append(entity) +def _audit_id(path_str): + """Derive the audit entity id from a manifest path. - return entities + Matches upstream's convention for entities/: id is the path relative to + ``entities/`` with ``.md`` stripped (e.g. ``guideline/foo``, + ``subscribed/alice/guideline/bar``). Public entities are prefixed with + ``public/`` to keep the id space distinct from private entities. + """ + if "/entities/" in path_str: + return path_str.split("/entities/", 1)[1].removesuffix(".md") + if "/public/" in path_str: + return "public/" + path_str.split("/public/", 1)[1].removesuffix(".md") + return path_str.removesuffix(".md") def main(): @@ -124,12 +89,13 @@ def main(): log(f" {key}={value}") log("=== End Environment Variables ===") - entities_dir = find_entities_dir() - log(f"Entities dir: {entities_dir}") - entities = [] - if entities_dir: - entities = load_entities_with_source(entities_dir) + recall_dirs = find_recall_entity_dirs() + log(f"Recall dirs: {recall_dirs}") + for root_dir in recall_dirs: + entities.extend(load_manifest(root_dir)) + + entities = dedupe_manifest_entries(entities) if not entities: log("No entities found") @@ -156,7 +122,7 @@ def main(): session_id = stem.removeprefix("claude-transcript_") if not session_id and isinstance(input_data, dict) and isinstance(input_data.get("session_id"), str): session_id = input_data["session_id"] - entity_ids = sorted({entity["_id"] for entity in entities if entity.get("_id")}) + entity_ids = sorted({_audit_id(entity["path"]) for entity in entities if entity.get("path")}) if session_id and entity_ids: audit.append( evolve_dir=str(get_evolve_dir().resolve()), diff --git a/plugin-source/lib/entity_io.py b/plugin-source/lib/entity_io.py index b8e0eefa..63f77e2c 100644 --- a/plugin-source/lib/entity_io.py +++ b/plugin-source/lib/entity_io.py @@ -78,12 +78,13 @@ def find_entities_dir(): def find_recall_entity_dirs(): """Locate all directories that should be searched during recall. - Returns the existing recall roots. Only ``entities/`` is canonical — - private entities live in ``entities/guideline/`` and shared entities - live in ``entities/subscribed/{repo}/guideline/``. + Returns the existing recall roots. Two trees contribute to recall: + ``entities/`` (private entities in ``entities/guideline/`` and + subscribed entities in ``entities/subscribed/{repo}/guideline/``) and + ``public/`` (entities published by the local project). """ evolve_dir = get_evolve_dir() - candidates = [evolve_dir / "entities"] + candidates = [evolve_dir / "entities", evolve_dir / "public"] return [path for path in candidates if path.is_dir()] @@ -224,6 +225,97 @@ def markdown_to_entity(path): return entity +def _parse_frontmatter_lines(lines): + """Parse simple YAML-style frontmatter lines into a dict.""" + entity = {} + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key and value: + entity[key] = value + return entity + + +def _parse_frontmatter_only(path): + """Parse only the frontmatter section from a markdown entity file.""" + path = Path(path) + try: + with path.open(encoding="utf-8") as handle: + if handle.readline().strip() != "---": + return {} + + frontmatter_lines = [] + found_closing = False + for line in handle: + if line.strip() == "---": + found_closing = True + break + frontmatter_lines.append(line) + except (OSError, UnicodeDecodeError): + return {} + + if not found_closing: + return {} + + return _parse_frontmatter_lines(frontmatter_lines) + + +def _manifest_path(path): + """Return a manifest path relative to the project root (parent of the evolve dir). + + This keeps manifest paths stable regardless of the caller's working directory, + so hooks invoked from a subdirectory still emit ``.evolve/entities/...`` paths. + """ + path = Path(path) + try: + project_root = get_evolve_dir().resolve().parent + return str(path.resolve().relative_to(project_root)) + except ValueError: + return str(path) + + +def dedupe_manifest_entries(entries): + """Return deterministically ordered manifest entries with exact dedupe.""" + normalized = [] + seen = set() + for entry in sorted(entries, key=lambda item: (item["path"], item["type"], item["trigger"])): + key = (entry["path"], entry["type"], entry["trigger"]) + if key in seen: + continue + seen.add(key) + normalized.append(entry) + return normalized + + +def load_manifest(root_dir): + """Load a frontmatter-only manifest from a recall root.""" + root_dir = Path(root_dir) + entries = [] + for md in sorted(root_dir.glob("**/*.md")): + if md.is_symlink() or ".git" in md.parts: + continue + + entity = _parse_frontmatter_only(md) + entity_type = entity.get("type") + trigger = entity.get("trigger") + if not entity_type or not trigger: + continue + + entries.append( + { + "path": _manifest_path(md), + "type": entity_type, + "trigger": trigger, + } + ) + + return dedupe_manifest_entries(entries) + + # --------------------------------------------------------------------------- # Bulk load / write # --------------------------------------------------------------------------- diff --git a/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 b/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 index c2f84271..8e183fa4 100644 --- a/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 +++ b/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 @@ -30,7 +30,7 @@ Before any non-trivial local work, you must complete the recall workflow below. Do not proceed to other analysis or tool use until all steps below are complete. -1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +1. If a manifest has already been injected for this turn, use it to pick which entity files to open. Otherwise inspect `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` for guidance relevant to the current task. 2. Read each matching entity file that appears relevant. {% if forked_context | default(false) -%} 3. **Quote each matching entity verbatim in your final response** — include the full file contents (frontmatter, body, rationale, trigger). The parent agent does not see your intermediate Read tool results, so anything you do not quote in your final response is lost. @@ -54,7 +54,7 @@ Before moving on, produce an explicit completion note in your reasoning or user ### Minimum Acceptable Procedure -1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` (or read the injected manifest if one is present). 2. Identify candidate entities relevant to the task. 3. Open and read those entity files. {% if forked_context | default(false) -%} @@ -81,29 +81,21 @@ The skill is not complete if any of the following are true: {% if platform == "claude" -%} 1. The Claude `UserPromptSubmit` hook fires before each user prompt is sent. 2. The helper script reads the prompt JSON from stdin. -3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, - read-scope subscriptions, and write-scope publish targets which all - live under `entities/subscribed/{repo}/`). -4. It prints formatted guidance to stdout. -5. Claude adds that text as additional context for the turn. +3. It emits a minimal manifest from `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` containing only `path`, `type`, and `trigger`. +4. Claude uses that manifest to decide which full entity files to read on demand. +5. If the hook is not active, this skill remains the full manual fallback: inspect the entity files directly, read the relevant ones, and summarize what applies. {%- elif platform == "claw-code" -%} 1. The Claw-code `PreToolUse` hook fires before each tool call. 2. The helper script reads tool input from stdin (best-effort, ignored beyond logging). -3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, - read-scope subscriptions, and write-scope publish targets which all - live under `entities/subscribed/{repo}/`). -4. It prints formatted guidance to stdout. -5. Claw-code adds that text as additional context for the turn. +3. It emits a minimal manifest from `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` containing only `path`, `type`, and `trigger`. +4. Claw-code uses that manifest to decide which full entity files to read on demand. +5. If the hook is not active, this skill remains the full manual fallback: inspect the entity files directly, read the relevant ones, and summarize what applies. {%- elif platform == "codex" -%} 1. If Codex hooks are enabled in `~/.codex/config.toml` with `[features] codex_hooks = true`, the Codex `UserPromptSubmit` hook runs before the prompt is sent. 2. The helper script reads the prompt JSON from stdin. -3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, - read-scope subscriptions, and write-scope publish targets which all - live under `entities/subscribed/{repo}/`). -4. It prints formatted guidance to stdout. -5. Codex adds that text as extra developer context for the turn. - -If hooks are not enabled, complete the **Required Action** workflow above manually. +3. It emits a minimal manifest from `${EVOLVE_DIR:-.evolve}/entities/` and `${EVOLVE_DIR:-.evolve}/public/` containing only `path`, `type`, and `trigger`. +4. Codex uses that manifest to decide which full entity files to read on demand. +5. If hooks are disabled, this skill remains the full manual fallback: inspect the entity files directly, read the relevant ones, and summarize what applies. {%- elif platform == "bob" -%} Bob has no auto-injection hook for entity retrieval. Complete the **Required Action** workflow above on every applicable task. @@ -129,7 +121,22 @@ Entities can come from multiple sources: alice-guideline.md <- annotated [from: alice] ``` -Each file uses markdown with YAML frontmatter: +{% if platform == "bob" -%} +The manifest output is human-readable: + +``` +- `.evolve/entities/guideline/use-context-managers-for-file-operations.md` [guideline] — When processing files or managing resources +- `.evolve/entities/subscribed/alice/guideline/error-handling.md` [guideline] — When writing error handlers +``` +{%- else -%} +Automatic hook output is manifest-first. Each manifest entry contains only: + +```json +{"path": ".evolve/entities/guideline/use-context-managers-for-file-operations.md", "type": "guideline", "trigger": "When processing files or managing resources"} +``` +{%- endif %} + +Each file still uses markdown with YAML frontmatter: ```markdown --- @@ -143,3 +150,9 @@ Use context managers for file operations Ensures proper resource cleanup ``` +{%- if platform == "bob" %} + +## On-Demand Expansion + +When a manifest entry's trigger matches the current task, use `read_file` to load the full entity. The file body contains the guideline content and an optional `## Rationale` section. +{%- endif %} diff --git a/plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py b/plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py.j2 similarity index 58% rename from plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py rename to plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py.j2 index 9daa7d38..17efea39 100644 --- a/plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py +++ b/plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py.j2 @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Retrieve and output entities for the agent to use as extra context.""" +"""Retrieve and output an entity manifest for {{ platform }} to expand on demand.""" import json import os @@ -21,7 +21,7 @@ if _lib is None: raise ImportError(f"Cannot find plugin lib directory above {_script}") sys.path.insert(0, str(_lib)) -from entity_io import find_entities_dir, get_evolve_dir, markdown_to_entity, log as _log # noqa: E402 +from entity_io import dedupe_manifest_entries, find_recall_entity_dirs, get_evolve_dir, load_manifest, log as _log # noqa: E402 import audit # noqa: E402 @@ -33,63 +33,33 @@ def log(message): def format_entities(entities): - """Format all entities for the agent to review. + """Format a manifest of entities for {{ platform }} to expand on demand.""" + header = """## Evolve entity manifest for this task - Entities that came from a subscribed source have their path recorded in - the private ``_source`` key (set by load_entities_with_source). These are - annotated with ``[from: {name}]`` so the agent knows their provenance. - """ - header = """## Evolve entities for this task - -Review these stored entities and apply any that are relevant to the user's request: +These stored entities are available for this repo. Read only the files whose trigger looks relevant to the user's request: """ - items = [] - for entity in entities: - content = entity.get("content") - if not content: - continue - source = entity.get("_source") - if source: - content = f"[from: {source}] {content}" - item = f"- **[{entity.get('type', 'general')}]** {content}" - if entity.get("rationale"): - item += f"\n Rationale: {entity['rationale']}" - if entity.get("trigger"): - item += f"\n When: {entity['trigger']}" - items.append(item) - - return header + "\n".join(items) - - -def load_entities_with_source(entities_dir): - """Load markdown entities from one recall root and annotate subscribed content. - - Symlinks and any files inside a ``.git`` directory are skipped so we don't - surface git's own bookkeeping or sneak past path validation when a write - -scope clone lives under entities/subscribed/{name}/. - """ - entities_dir = Path(entities_dir) - entities = [] - for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): - if md.is_symlink(): - continue - try: - entity = markdown_to_entity(md) - except (OSError, UnicodeError): - continue - if not entity.get("content"): - continue +{%- if platform == "bob" %} + lines = [f"- `{e['path']}` [{e['type']}] — {e['trigger']}" for e in entities] + return header + "\n".join(lines) +{%- else %} + return header + "\n".join(json.dumps(entity) for entity in entities) +{%- endif %} - entity.pop("_source", None) - entity["_id"] = str(md.relative_to(entities_dir).with_suffix("")) - parts = md.relative_to(entities_dir).parts - if parts and parts[0] == "subscribed" and len(parts) > 1: - entity["_source"] = parts[1] - entities.append(entity) +def _audit_id(path_str): + """Derive the audit entity id from a manifest path. - return entities + Matches upstream's convention for entities/: id is the path relative to + ``entities/`` with ``.md`` stripped (e.g. ``guideline/foo``, + ``subscribed/alice/guideline/bar``). Public entities are prefixed with + ``public/`` to keep the id space distinct from private entities. + """ + if "/entities/" in path_str: + return path_str.split("/entities/", 1)[1].removesuffix(".md") + if "/public/" in path_str: + return "public/" + path_str.split("/public/", 1)[1].removesuffix(".md") + return path_str.removesuffix(".md") def main(): @@ -124,12 +94,13 @@ def main(): log(f" {key}={value}") log("=== End Environment Variables ===") - entities_dir = find_entities_dir() - log(f"Entities dir: {entities_dir}") - entities = [] - if entities_dir: - entities = load_entities_with_source(entities_dir) + recall_dirs = find_recall_entity_dirs() + log(f"Recall dirs: {recall_dirs}") + for root_dir in recall_dirs: + entities.extend(load_manifest(root_dir)) + + entities = dedupe_manifest_entries(entities) if not entities: log("No entities found") @@ -156,7 +127,7 @@ def main(): session_id = stem.removeprefix("claude-transcript_") if not session_id and isinstance(input_data, dict) and isinstance(input_data.get("session_id"), str): session_id = input_data["session_id"] - entity_ids = sorted({entity["_id"] for entity in entities if entity.get("_id")}) + entity_ids = sorted({_audit_id(entity["path"]) for entity in entities if entity.get("path")}) if session_id and entity_ids: audit.append( evolve_dir=str(get_evolve_dir().resolve()), diff --git a/tests/platform_integrations/conftest.py b/tests/platform_integrations/conftest.py index 24551be5..18ba7660 100644 --- a/tests/platform_integrations/conftest.py +++ b/tests/platform_integrations/conftest.py @@ -644,7 +644,7 @@ def local_repo(tmp_path, git_env): # Seed one entity guideline = init / "guideline" guideline.mkdir() - (guideline / "guideline-one.md").write_text("---\ntype: guideline\n---\n\nAlways write tests.\n") + (guideline / "guideline-one.md").write_text("---\ntype: guideline\ntrigger: when adding coverage\n---\n\nAlways write tests.\n") subprocess.run(["git", "-C", str(init), "add", "."], check=True, capture_output=True, env=git_env) subprocess.run( ["git", "-C", str(init), "commit", "-m", "init"], diff --git a/tests/platform_integrations/test_bob_sharing.py b/tests/platform_integrations/test_bob_sharing.py index e98bdaff..53281b11 100644 --- a/tests/platform_integrations/test_bob_sharing.py +++ b/tests/platform_integrations/test_bob_sharing.py @@ -669,52 +669,55 @@ def test_output_reports_added_count(self, temp_project_dir): class TestBobRetrieveEntities: """Tests for Bob's retrieve_entities.py script. - Note: Bob's retrieve script outputs markdown for Bob's UI, not JSON. + Bob outputs human-readable manifest markdown (not JSON like Claude/Codex). """ def test_returns_entities_from_private_dir(self, temp_project_dir): evolve_dir = temp_project_dir / ".evolve" entities_dir = evolve_dir / "entities" / "guideline" entities_dir.mkdir(parents=True) - (entities_dir / "tip.md").write_text("---\ntype: guideline\n---\n\nPrivate tip.\n") + (entities_dir / "tip.md").write_text("---\ntype: guideline\ntrigger: when writing private code\n---\n\nPrivate tip.\n") result = run_script(RETRIEVE_SCRIPT, temp_project_dir, evolve_dir=evolve_dir) - assert "Private tip" in result.stdout - assert "## Evolve entities for this task" in result.stdout + assert "Evolve entity manifest for this task" in result.stdout + assert "[guideline]" in result.stdout + assert "when writing private code" in result.stdout + assert "Private tip." not in result.stdout def test_returns_published_entities_from_write_clone(self, temp_project_dir): """Published guidelines live in entities/subscribed/{repo}/guideline/.""" evolve_dir = temp_project_dir / ".evolve" - published_dir = evolve_dir / "entities" / "subscribed" / "my-memory" / "guideline" - published_dir.mkdir(parents=True) - (published_dir / "tip.md").write_text("---\ntype: guideline\nvisibility: public\n---\n\nPublished tip.\n") + public_dir = evolve_dir / "public" / "guideline" + public_dir.mkdir(parents=True) + (public_dir / "tip.md").write_text( + "---\ntype: guideline\ntrigger: when sharing guidelines\nvisibility: public\n---\n\nPublic tip.\n" + ) result = run_script(RETRIEVE_SCRIPT, temp_project_dir, evolve_dir=evolve_dir) - assert "Published tip" in result.stdout - assert "[from: my-memory]" in result.stdout + assert "when sharing guidelines" in result.stdout + assert "Public tip." not in result.stdout def test_returns_entities_from_subscribed_dir(self, temp_project_dir): evolve_dir = temp_project_dir / ".evolve" subscribed_dir = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" subscribed_dir.mkdir(parents=True) - (subscribed_dir / "tip.md").write_text("---\ntype: guideline\n---\n\nSubscribed tip.\n") + (subscribed_dir / "tip.md").write_text("---\ntype: guideline\ntrigger: when adding coverage\n---\n\nSubscribed tip.\n") result = run_script(RETRIEVE_SCRIPT, temp_project_dir, evolve_dir=evolve_dir) - assert "Subscribed tip" in result.stdout - assert "[from: alice]" in result.stdout + assert "when adding coverage" in result.stdout + assert ".evolve/entities/subscribed/alice/guideline/tip.md" in result.stdout + assert "Subscribed tip." not in result.stdout def test_retrieve_filters_symlinked_entities(self, temp_project_dir): evolve_dir = temp_project_dir / ".evolve" subscribed_dir = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" subscribed_dir.mkdir(parents=True) real_file = subscribed_dir / "real.md" - real_file.write_text("---\ntype: guideline\n---\n\nReal content.\n") + real_file.write_text("---\ntype: guideline\ntrigger: when testing\n---\n\nReal content.\n") link_file = subscribed_dir / "link.md" link_file.symlink_to(real_file) result = run_script(RETRIEVE_SCRIPT, temp_project_dir, evolve_dir=evolve_dir) - assert "Real content" in result.stdout - assert result.stdout.count("Real content") == 1, "Symlinked duplicate should be filtered out" - - -# Made with Bob + assert "when testing" in result.stdout + assert result.stdout.count("when testing") == 1, "Symlinked duplicate should be filtered out" + assert "Real content." not in result.stdout diff --git a/tests/platform_integrations/test_claude_retrieve_manifest.py b/tests/platform_integrations/test_claude_retrieve_manifest.py new file mode 100644 index 00000000..05136e53 --- /dev/null +++ b/tests/platform_integrations/test_claude_retrieve_manifest.py @@ -0,0 +1,133 @@ +"""Tests for Claude manifest-first recall output.""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.platform_integrations + +_REPO_ROOT = Path(__file__).parent.parent.parent +CLAUDE_RETRIEVE_SCRIPT = ( + _REPO_ROOT + / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" +) +HOOK_INPUT = json.dumps({"prompt": "How do I write clean code?"}) + + +def run_retrieve(project_dir, evolve_dir, stdin_data=None): + env = {**os.environ, "EVOLVE_DIR": str(evolve_dir)} + return subprocess.run( + [sys.executable, str(CLAUDE_RETRIEVE_SCRIPT)], + input=stdin_data or HOOK_INPUT, + capture_output=True, + text=True, + cwd=str(project_dir), + env=env, + check=False, + ) + + +def parse_manifest_lines(stdout): + return [json.loads(line) for line in stdout.splitlines() if line.startswith("{")] + + +@pytest.fixture +def evolve_dir(temp_project_dir): + d = temp_project_dir / ".evolve" + + own_dir = d / "entities" / "guideline" + own_dir.mkdir(parents=True) + (own_dir / "guideline.md").write_text("---\ntype: guideline\ntrigger: when refactoring functions\n---\n\nKeep functions small.\n") + + sub_dir = d / "entities" / "subscribed" / "alice" / "guideline" + sub_dir.mkdir(parents=True) + (sub_dir / "alice-guideline.md").write_text( + "---\ntype: guideline\ntrigger: when adding coverage\nowner: alice\nvisibility: public\n---\n\nAlways write tests.\n" + ) + + public_dir = d / "public" / "guideline" + public_dir.mkdir(parents=True) + (public_dir / "published-guideline.md").write_text( + "---\ntype: guideline\ntrigger: when documenting edge cases\nvisibility: public\nsource: alice/evolve-guidelines\n---\n\nDocument edge cases.\n" + ) + + return d + + +class TestClaudeRetrieveManifest: + def test_outputs_manifest_header_and_json_entries(self, temp_project_dir, evolve_dir): + result = run_retrieve(temp_project_dir, evolve_dir) + + assert result.returncode == 0 + assert "Evolve entity manifest for this task" in result.stdout + assert "Read only the files whose trigger looks relevant" in result.stdout + assert parse_manifest_lines(result.stdout) == [ + { + "path": ".evolve/entities/guideline/guideline.md", + "type": "guideline", + "trigger": "when refactoring functions", + }, + { + "path": ".evolve/entities/subscribed/alice/guideline/alice-guideline.md", + "type": "guideline", + "trigger": "when adding coverage", + }, + { + "path": ".evolve/public/guideline/published-guideline.md", + "type": "guideline", + "trigger": "when documenting edge cases", + }, + ] + + def test_does_not_emit_full_entity_bodies_or_extra_fields(self, temp_project_dir, evolve_dir): + result = run_retrieve(temp_project_dir, evolve_dir) + + assert "Keep functions small." not in result.stdout + assert "Always write tests." not in result.stdout + assert "Document edge cases." not in result.stdout + assert "[from:" not in result.stdout + assert "visibility" not in result.stdout + assert "source" not in result.stdout + + def test_output_is_deterministic_and_deduplicated(self, temp_project_dir): + evolve_dir = temp_project_dir / ".evolve" + guideline_dir = evolve_dir / "entities" / "guideline" + guideline_dir.mkdir(parents=True) + (guideline_dir / "b.md").write_text("---\ntype: guideline\ntrigger: beta\n---\n\nB body.\n") + (guideline_dir / "a.md").write_text("---\ntype: guideline\ntrigger: alpha\n---\n\nA body.\n") + + result = run_retrieve(temp_project_dir, evolve_dir) + + assert parse_manifest_lines(result.stdout) == [ + {"path": ".evolve/entities/guideline/a.md", "type": "guideline", "trigger": "alpha"}, + {"path": ".evolve/entities/guideline/b.md", "type": "guideline", "trigger": "beta"}, + ] + + def test_skips_symlinked_markdown_entities(self, temp_project_dir): + evolve_dir = temp_project_dir / ".evolve" + gdir = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" + gdir.mkdir(parents=True) + real_file = gdir / "real.md" + real_file.write_text("---\ntype: guideline\ntrigger: when testing\n---\n\nReal content.\n") + (gdir / "link.md").symlink_to(real_file) + + result = run_retrieve(temp_project_dir, evolve_dir) + + assert result.returncode == 0 + assert parse_manifest_lines(result.stdout) == [ + { + "path": ".evolve/entities/subscribed/alice/guideline/real.md", + "type": "guideline", + "trigger": "when testing", + } + ] + + def test_handles_invalid_json_stdin_gracefully(self, temp_project_dir, evolve_dir): + result = run_retrieve(temp_project_dir, evolve_dir, stdin_data="not valid json") + + assert result.returncode == 0 + assert result.stdout.strip() == "" diff --git a/tests/platform_integrations/test_codex_retrieve_manifest.py b/tests/platform_integrations/test_codex_retrieve_manifest.py new file mode 100644 index 00000000..c1eeb05d --- /dev/null +++ b/tests/platform_integrations/test_codex_retrieve_manifest.py @@ -0,0 +1,133 @@ +"""Tests for Codex manifest-first recall output.""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.platform_integrations + +_REPO_ROOT = Path(__file__).parent.parent.parent +CODEX_RETRIEVE_SCRIPT = ( + _REPO_ROOT + / "platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" +) +HOOK_INPUT = json.dumps({"prompt": "How do I write clean code?"}) + + +def run_retrieve(project_dir, evolve_dir, stdin_data=None): + env = {**os.environ, "EVOLVE_DIR": str(evolve_dir)} + return subprocess.run( + [sys.executable, str(CODEX_RETRIEVE_SCRIPT)], + input=stdin_data or HOOK_INPUT, + capture_output=True, + text=True, + cwd=str(project_dir), + env=env, + check=False, + ) + + +def parse_manifest_lines(stdout): + return [json.loads(line) for line in stdout.splitlines() if line.startswith("{")] + + +@pytest.fixture +def evolve_dir(temp_project_dir): + d = temp_project_dir / ".evolve" + + own_dir = d / "entities" / "guideline" + own_dir.mkdir(parents=True) + (own_dir / "guideline.md").write_text("---\ntype: guideline\ntrigger: when refactoring functions\n---\n\nKeep functions small.\n") + + sub_dir = d / "entities" / "subscribed" / "alice" / "guideline" + sub_dir.mkdir(parents=True) + (sub_dir / "alice-guideline.md").write_text( + "---\ntype: guideline\ntrigger: when adding coverage\nowner: alice\nvisibility: public\n---\n\nAlways write tests.\n" + ) + + public_dir = d / "public" / "guideline" + public_dir.mkdir(parents=True) + (public_dir / "published-guideline.md").write_text( + "---\ntype: guideline\ntrigger: when documenting edge cases\nvisibility: public\nsource: alice/evolve-guidelines\n---\n\nDocument edge cases.\n" + ) + + return d + + +class TestCodexRetrieveManifest: + def test_outputs_manifest_header_and_json_entries(self, temp_project_dir, evolve_dir): + result = run_retrieve(temp_project_dir, evolve_dir) + + assert result.returncode == 0 + assert "Evolve entity manifest for this task" in result.stdout + assert "Read only the files whose trigger looks relevant" in result.stdout + assert parse_manifest_lines(result.stdout) == [ + { + "path": ".evolve/entities/guideline/guideline.md", + "type": "guideline", + "trigger": "when refactoring functions", + }, + { + "path": ".evolve/entities/subscribed/alice/guideline/alice-guideline.md", + "type": "guideline", + "trigger": "when adding coverage", + }, + { + "path": ".evolve/public/guideline/published-guideline.md", + "type": "guideline", + "trigger": "when documenting edge cases", + }, + ] + + def test_does_not_emit_full_entity_bodies_or_extra_fields(self, temp_project_dir, evolve_dir): + result = run_retrieve(temp_project_dir, evolve_dir) + + assert "Keep functions small." not in result.stdout + assert "Always write tests." not in result.stdout + assert "Document edge cases." not in result.stdout + assert "[from:" not in result.stdout + assert "visibility" not in result.stdout + assert "source" not in result.stdout + + def test_output_is_deterministic_and_deduplicated(self, temp_project_dir): + evolve_dir = temp_project_dir / ".evolve" + guideline_dir = evolve_dir / "entities" / "guideline" + guideline_dir.mkdir(parents=True) + (guideline_dir / "b.md").write_text("---\ntype: guideline\ntrigger: beta\n---\n\nB body.\n") + (guideline_dir / "a.md").write_text("---\ntype: guideline\ntrigger: alpha\n---\n\nA body.\n") + + result = run_retrieve(temp_project_dir, evolve_dir) + + assert parse_manifest_lines(result.stdout) == [ + {"path": ".evolve/entities/guideline/a.md", "type": "guideline", "trigger": "alpha"}, + {"path": ".evolve/entities/guideline/b.md", "type": "guideline", "trigger": "beta"}, + ] + + def test_skips_symlinked_markdown_entities(self, temp_project_dir): + evolve_dir = temp_project_dir / ".evolve" + gdir = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" + gdir.mkdir(parents=True) + real_file = gdir / "real.md" + real_file.write_text("---\ntype: guideline\ntrigger: when testing\n---\n\nReal content.\n") + (gdir / "link.md").symlink_to(real_file) + + result = run_retrieve(temp_project_dir, evolve_dir) + + assert result.returncode == 0 + assert parse_manifest_lines(result.stdout) == [ + { + "path": ".evolve/entities/subscribed/alice/guideline/real.md", + "type": "guideline", + "trigger": "when testing", + } + ] + + def test_handles_invalid_json_stdin_gracefully(self, temp_project_dir, evolve_dir): + result = run_retrieve(temp_project_dir, evolve_dir, stdin_data="not valid json") + + assert result.returncode == 0 + assert result.stdout.strip() == "" diff --git a/tests/platform_integrations/test_codex_sharing.py b/tests/platform_integrations/test_codex_sharing.py index dcb35a20..f9857493 100644 --- a/tests/platform_integrations/test_codex_sharing.py +++ b/tests/platform_integrations/test_codex_sharing.py @@ -83,10 +83,12 @@ def test_retrieve_annotates_subscribed_entities(self, temp_project_dir): evolve_dir = temp_project_dir / ".evolve" own_dir = evolve_dir / "entities" / "guideline" own_dir.mkdir(parents=True) - (own_dir / "guideline.md").write_text("---\ntype: guideline\n---\n\nKeep functions small.\n") + (own_dir / "guideline.md").write_text("---\ntype: guideline\ntrigger: when refactoring\n---\n\nKeep functions small.\n") sub_dir = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" sub_dir.mkdir(parents=True) - (sub_dir / "alice-guideline.md").write_text("---\ntype: guideline\nowner: alice\nvisibility: public\n---\n\nAlways write tests.\n") + (sub_dir / "alice-guideline.md").write_text( + "---\ntype: guideline\ntrigger: when adding coverage\nowner: alice\nvisibility: public\n---\n\nAlways write tests.\n" + ) result = run_script( RETRIEVE_SCRIPT, @@ -96,20 +98,26 @@ def test_retrieve_annotates_subscribed_entities(self, temp_project_dir): expect_success=False, ) assert result.returncode == 0 - assert "Keep functions small." in result.stdout - assert "[from: alice]" in result.stdout - assert "Always write tests." in result.stdout + own_line = '{"path": ".evolve/entities/guideline/guideline.md", "type": "guideline", "trigger": "when refactoring"}' + assert own_line in result.stdout + subscribed_line = ( + '{"path": ".evolve/entities/subscribed/alice/guideline/alice-guideline.md", ' + '"type": "guideline", "trigger": "when adding coverage"}' + ) + assert subscribed_line in result.stdout + assert "Keep functions small." not in result.stdout + assert "Always write tests." not in result.stdout + assert "[from: alice]" not in result.stdout def test_retrieve_includes_published_guidelines(self, temp_project_dir): evolve_dir = temp_project_dir / ".evolve" own_dir = evolve_dir / "entities" / "guideline" own_dir.mkdir(parents=True) - (own_dir / "guideline.md").write_text("---\ntype: guideline\n---\n\nKeep functions small.\n") - # Published entities live inside the write-scope repo's local clone. - published_dir = evolve_dir / "entities" / "subscribed" / "my-memory" / "guideline" - published_dir.mkdir(parents=True) - (published_dir / "published-guideline.md").write_text( - "---\ntype: guideline\nvisibility: public\nsource: alice/evolve-guidelines\n---\n\nDocument edge cases.\n" + (own_dir / "guideline.md").write_text("---\ntype: guideline\ntrigger: when refactoring\n---\n\nKeep functions small.\n") + public_dir = evolve_dir / "public" / "guideline" + public_dir.mkdir(parents=True) + (public_dir / "published-guideline.md").write_text( + "---\ntype: guideline\ntrigger: when documenting edge cases\nvisibility: public\nsource: alice/evolve-guidelines\n---\n\nDocument edge cases.\n" ) result = run_script( @@ -120,8 +128,14 @@ def test_retrieve_includes_published_guidelines(self, temp_project_dir): expect_success=False, ) assert result.returncode == 0 - assert "Keep functions small." in result.stdout - assert "Document edge cases." in result.stdout + own_line = '{"path": ".evolve/entities/guideline/guideline.md", "type": "guideline", "trigger": "when refactoring"}' + public_line = ( + '{"path": ".evolve/public/guideline/published-guideline.md", "type": "guideline", "trigger": "when documenting edge cases"}' + ) + assert own_line in result.stdout + assert public_line in result.stdout + assert "Keep functions small." not in result.stdout + assert "Document edge cases." not in result.stdout _WRITE_REPO_CONFIG = ( @@ -674,7 +688,12 @@ def test_sync_skips_symlinked_markdown_files(self, temp_project_dir, local_repo) expect_success=False, ) assert result.returncode == 0 - assert "Always write tests." in result.stdout + manifest_line = ( + '{"path": ".evolve/entities/subscribed/alice/guideline/guideline-one.md", ' + '"type": "guideline", "trigger": "when adding coverage"}' + ) + assert manifest_line in result.stdout + assert "Always write tests." not in result.stdout assert "guideline-link" not in result.stdout def test_sync_removed_entity_disappears_after_sync(self, temp_project_dir, local_repo): diff --git a/tests/platform_integrations/test_entity_io_core.py b/tests/platform_integrations/test_entity_io_core.py index 360e3003..0206d903 100644 --- a/tests/platform_integrations/test_entity_io_core.py +++ b/tests/platform_integrations/test_entity_io_core.py @@ -158,3 +158,54 @@ def test_skips_files_without_content(self, tmp_path): def test_empty_directory_returns_empty_list(self, tmp_path): assert entity_io.load_all_entities(tmp_path) == [] + + +class TestManifestLoading: + def test_load_manifest_reads_frontmatter_only(self, temp_project_dir, monkeypatch): + monkeypatch.chdir(temp_project_dir) + path = temp_project_dir / ".evolve" / "entities" / "guideline" / "guideline.md" + path.parent.mkdir(parents=True) + path.write_text( + "---\ntype: guideline\ntrigger: when writing tests\n---\n\nBody content that should not matter.\n\n## Rationale\n\nStill ignored.\n" + ) + + manifest = entity_io.load_manifest(temp_project_dir / ".evolve" / "entities") + + assert manifest == [ + { + "path": ".evolve/entities/guideline/guideline.md", + "type": "guideline", + "trigger": "when writing tests", + } + ] + + def test_load_manifest_skips_symlinks_and_missing_trigger(self, temp_project_dir, monkeypatch): + monkeypatch.chdir(temp_project_dir) + root = temp_project_dir / ".evolve" / "entities" / "guideline" + root.mkdir(parents=True) + real_file = root / "real.md" + real_file.write_text("---\ntype: guideline\ntrigger: when testing\n---\n\nReal content.\n") + (root / "link.md").symlink_to(real_file) + (root / "missing-trigger.md").write_text("---\ntype: guideline\n---\n\nIgnored.\n") + + manifest = entity_io.load_manifest(temp_project_dir / ".evolve" / "entities") + + assert manifest == [ + { + "path": ".evolve/entities/guideline/real.md", + "type": "guideline", + "trigger": "when testing", + } + ] + + def test_dedupe_manifest_entries_is_deterministic(self): + entries = [ + {"path": ".evolve/public/guideline/b.md", "type": "guideline", "trigger": "beta"}, + {"path": ".evolve/entities/guideline/a.md", "type": "guideline", "trigger": "alpha"}, + {"path": ".evolve/public/guideline/b.md", "type": "guideline", "trigger": "beta"}, + ] + + assert entity_io.dedupe_manifest_entries(entries) == [ + {"path": ".evolve/entities/guideline/a.md", "type": "guideline", "trigger": "alpha"}, + {"path": ".evolve/public/guideline/b.md", "type": "guideline", "trigger": "beta"}, + ] diff --git a/tests/platform_integrations/test_retrieve.py b/tests/platform_integrations/test_retrieve.py index 71b6a328..415c3e94 100644 --- a/tests/platform_integrations/test_retrieve.py +++ b/tests/platform_integrations/test_retrieve.py @@ -18,15 +18,15 @@ _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" ) SCRIPT_VARIANTS = [ - ("claude", CLAUDE_RETRIEVE_SCRIPT, "Evolve entities for this task"), - ("codex", CODEX_RETRIEVE_SCRIPT, "Evolve entities for this task"), + ("claude", CLAUDE_RETRIEVE_SCRIPT, "Evolve entity manifest for this task"), + ("codex", CODEX_RETRIEVE_SCRIPT, "Evolve entity manifest for this task"), ] # The hook pipes this JSON to the script on stdin HOOK_INPUT = json.dumps({"prompt": "How do I write clean code?"}) -def run_retrieve(script_path, evolve_dir=None, stdin_data=None): +def run_retrieve(script_path, project_dir, evolve_dir=None, stdin_data=None): env = {**os.environ} if evolve_dir: env["EVOLVE_DIR"] = str(evolve_dir) @@ -35,11 +35,16 @@ def run_retrieve(script_path, evolve_dir=None, stdin_data=None): input=stdin_data or HOOK_INPUT, capture_output=True, text=True, + cwd=str(project_dir), env=env, check=False, ) +def parse_manifest_lines(stdout): + return [json.loads(line) for line in stdout.splitlines() if line.startswith("{")] + + @pytest.fixture def evolve_dir(temp_project_dir, file_assertions): """An .evolve dir with one owned entity and one subscribed entity.""" @@ -48,13 +53,13 @@ def evolve_dir(temp_project_dir, file_assertions): # Owned entity file_assertions.write_text( d / "entities" / "guideline" / "guideline.md", - "---\ntype: guideline\n---\n\nKeep functions small.\n", + "---\ntype: guideline\ntrigger: when refactoring\n---\n\nKeep functions small.\n", ) # Subscribed entity (lives under entities/subscribed/{name}/) file_assertions.write_text( d / "entities" / "subscribed" / "alice" / "guideline" / "alice-guideline.md", - "---\ntype: guideline\nowner: alice\nvisibility: public\n---\n\nAlways write tests.\n", + "---\ntype: guideline\ntrigger: when adding coverage\nowner: alice\nvisibility: public\n---\n\nAlways write tests.\n", ) return d @@ -63,47 +68,70 @@ def evolve_dir(temp_project_dir, file_assertions): class TestRetrieve: @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) def test_exits_cleanly_with_no_output_when_no_entities_dir(self, temp_project_dir, retrieve_script, expected_header, platform_name): - result = run_retrieve(retrieve_script, evolve_dir=temp_project_dir / ".evolve") + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=temp_project_dir / ".evolve") assert result.returncode == 0 assert result.stdout.strip() == "" @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_outputs_owned_entities(self, evolve_dir, retrieve_script, expected_header, platform_name): - result = run_retrieve(retrieve_script, evolve_dir=evolve_dir) + def test_outputs_owned_entities(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=evolve_dir) assert result.returncode == 0 - assert "Keep functions small." in result.stdout + entries = parse_manifest_lines(result.stdout) + own_entry = {"path": ".evolve/entities/guideline/guideline.md", "type": "guideline", "trigger": "when refactoring"} + assert own_entry in entries @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_annotates_subscribed_entities_with_from_source(self, evolve_dir, retrieve_script, expected_header, platform_name): - result = run_retrieve(retrieve_script, evolve_dir=evolve_dir) - assert "[from: alice]" in result.stdout - assert "Always write tests." in result.stdout + def test_includes_subscribed_entities(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=evolve_dir) + entries = parse_manifest_lines(result.stdout) + sub_entry = { + "path": ".evolve/entities/subscribed/alice/guideline/alice-guideline.md", + "type": "guideline", + "trigger": "when adding coverage", + } + assert sub_entry in entries @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_owned_entities_not_annotated_with_from(self, evolve_dir, retrieve_script, expected_header, platform_name): - result = run_retrieve(retrieve_script, evolve_dir=evolve_dir) - own_lines = [line for line in result.stdout.splitlines() if "Keep functions small." in line] - assert own_lines - assert not any("[from:" in line for line in own_lines) + def test_manifest_entries_contain_only_path_type_trigger( + self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name + ): + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=evolve_dir) + for entry in parse_manifest_lines(result.stdout): + assert set(entry.keys()) == {"path", "type", "trigger"} @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_output_includes_type_annotation(self, evolve_dir, retrieve_script, expected_header, platform_name): - result = run_retrieve(retrieve_script, evolve_dir=evolve_dir) - assert "[guideline]" in result.stdout + def test_does_not_emit_full_bodies(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=evolve_dir) + assert "Keep functions small." not in result.stdout + assert "Always write tests." not in result.stdout @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_handles_invalid_json_stdin_gracefully(self, evolve_dir, retrieve_script, expected_header, platform_name): - result = run_retrieve(retrieve_script, evolve_dir=evolve_dir, stdin_data="not valid json") + def test_handles_invalid_json_stdin_gracefully(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=evolve_dir, stdin_data="not valid json") assert result.returncode == 0 assert result.stdout.strip() == "" @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_output_has_header(self, evolve_dir, retrieve_script, expected_header, platform_name): - result = run_retrieve(retrieve_script, evolve_dir=evolve_dir) + def test_output_has_header(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=evolve_dir) assert expected_header in result.stdout @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_entities_with_trigger_include_when_line( + def test_public_entities_included_in_recall(self, temp_project_dir, retrieve_script, expected_header, platform_name, file_assertions): + d = temp_project_dir / ".evolve" + file_assertions.write_text( + d / "public" / "guideline" / "pub.md", + "---\ntype: guideline\ntrigger: when choosing data structures\nvisibility: public\n---\n\nPrefer immutable data structures.\n", + ) + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=d) + assert result.returncode == 0 + entries = parse_manifest_lines(result.stdout) + pub_entry = {"path": ".evolve/public/guideline/pub.md", "type": "guideline", "trigger": "when choosing data structures"} + assert pub_entry in entries + assert "Prefer immutable data structures." not in result.stdout + + @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) + def test_entities_with_trigger_include_trigger_in_manifest( self, temp_project_dir, retrieve_script, expected_header, platform_name, file_assertions ): d = temp_project_dir / ".evolve" @@ -111,7 +139,7 @@ def test_entities_with_trigger_include_when_line( d / "entities" / "guideline" / "guideline.md", "---\ntype: guideline\ntrigger: when writing tests\n---\n\nAssert the important thing.\n", ) - result = run_retrieve(retrieve_script, evolve_dir=d) + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=d) assert "when writing tests" in result.stdout @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) @@ -120,18 +148,21 @@ def test_skips_symlinked_markdown_entities(self, temp_project_dir, retrieve_scri gdir = d / "entities" / "subscribed" / "alice" / "guideline" gdir.mkdir(parents=True) real_file = gdir / "real.md" - real_file.write_text("---\ntype: guideline\n---\n\nReal content.\n") + real_file.write_text("---\ntype: guideline\ntrigger: when testing\n---\n\nReal content.\n") (gdir / "link.md").symlink_to(real_file) - result = run_retrieve(retrieve_script, evolve_dir=d) + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=d) assert result.returncode == 0 - assert result.stdout.count("Real content.") == 1 + entries = parse_manifest_lines(result.stdout) + assert len(entries) == 1 + assert entries[0]["trigger"] == "when testing" @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_writes_recall_audit_event_with_qualified_entity_ids(self, evolve_dir, retrieve_script, expected_header, platform_name): + def test_writes_recall_audit_event_with_qualified_entity_ids(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): result = run_retrieve( retrieve_script, + temp_project_dir, evolve_dir=evolve_dir, stdin_data=json.dumps( { @@ -152,9 +183,10 @@ def test_writes_recall_audit_event_with_qualified_entity_ids(self, evolve_dir, r } @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_writes_recall_audit_event_with_session_id_fallback(self, evolve_dir, retrieve_script, expected_header, platform_name): + def test_writes_recall_audit_event_with_session_id_fallback(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): result = run_retrieve( retrieve_script, + temp_project_dir, evolve_dir=evolve_dir, stdin_data=json.dumps( { @@ -177,11 +209,12 @@ def test_writes_recall_audit_under_custom_evolve_dir( custom_evolve_dir = temp_project_dir / "custom-evolve-data" file_assertions.write_text( custom_evolve_dir / "entities" / "guideline" / "guideline.md", - "---\ntype: guideline\n---\n\nKeep functions small.\n", + "---\ntype: guideline\ntrigger: when writing code\n---\n\nKeep functions small.\n", ) result = run_retrieve( retrieve_script, + temp_project_dir, evolve_dir=custom_evolve_dir, stdin_data=json.dumps( { @@ -199,8 +232,8 @@ def test_writes_recall_audit_under_custom_evolve_dir( assert not (temp_project_dir / ".evolve" / "audit.log").exists() @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_does_not_write_recall_audit_without_transcript_path(self, evolve_dir, retrieve_script, expected_header, platform_name): - result = run_retrieve(retrieve_script, evolve_dir=evolve_dir) + def test_does_not_write_recall_audit_without_transcript_path(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=evolve_dir) assert result.returncode == 0 assert not (evolve_dir / "audit.log").exists() diff --git a/tests/platform_integrations/test_subscribe.py b/tests/platform_integrations/test_subscribe.py index b7fcc557..f254f2e2 100644 --- a/tests/platform_integrations/test_subscribe.py +++ b/tests/platform_integrations/test_subscribe.py @@ -217,8 +217,8 @@ def test_rolls_back_clone_if_config_write_fails(self, temp_project_dir, local_re assert not dest.exists(), "Clone should be rolled back when config write fails" @pytest.mark.skipif(_IS_WINDOWS, reason="chmod not supported on Windows") - def test_rolls_back_clone_if_audit_write_fails(self, temp_project_dir, local_repo): - """If audit_append raises after a successful clone + config write, the clone is removed.""" + def test_warns_when_audit_write_fails(self, temp_project_dir, local_repo): + """If audit_append raises after a successful clone, subscribe still succeeds with a warning.""" evolve_dir = temp_project_dir / ".evolve" evolve_dir.mkdir(parents=True) # Pre-create a read-only audit.log so audit_append raises PermissionError @@ -235,10 +235,10 @@ def test_rolls_back_clone_if_audit_write_fails(self, temp_project_dir, local_rep ) finally: audit_log.chmod(0o644) - assert result.returncode != 0 - assert "failed to record subscription" in result.stderr + assert result.returncode == 0 + assert "Warning: audit log could not be updated" in result.stderr dest = evolve_dir / "entities" / "subscribed" / "alice" - assert not dest.exists(), "Clone should be rolled back when audit write fails" + assert dest.exists(), "Clone should be kept even when audit write fails" class TestUnsubscribe: diff --git a/tests/platform_integrations/test_sync.py b/tests/platform_integrations/test_sync.py index f2a1cb15..e3b4ef6e 100644 --- a/tests/platform_integrations/test_sync.py +++ b/tests/platform_integrations/test_sync.py @@ -148,7 +148,7 @@ def test_sync_preserves_symlinks_in_clone(self, subscribed_project): p = subscribed_project lr = p["local_repo"] real_file = lr["work"] / "guideline" / "real.md" - real_file.write_text("---\ntype: guideline\n---\n\nReal content.\n") + real_file.write_text("---\ntype: guideline\ntrigger: when reviewing PRs\n---\n\nReal content.\n") symlink_file = lr["work"] / "guideline" / "link.md" symlink_file.symlink_to(real_file) git_env = lr["env"] @@ -180,7 +180,8 @@ def test_sync_preserves_symlinks_in_clone(self, subscribed_project): ) assert result.returncode == 0 - assert "Real content." in result.stdout + assert "when reviewing PRs" in result.stdout + assert "Real content." not in result.stdout assert "link.md" not in result.stdout def test_skips_invalid_subscription_name(self, temp_project_dir): From c6bbbd351cd0604553ae673b3a3f83cbe75a64db Mon Sep 17 00:00:00 2001 From: Vatche Isahagian Date: Tue, 5 May 2026 16:28:55 -0400 Subject: [PATCH 2/4] style: ruff format manifest recall tests Co-Authored-By: Claude Opus 4.7 --- tests/platform_integrations/test_claude_retrieve_manifest.py | 3 +-- tests/platform_integrations/test_codex_retrieve_manifest.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/platform_integrations/test_claude_retrieve_manifest.py b/tests/platform_integrations/test_claude_retrieve_manifest.py index 05136e53..4202d62f 100644 --- a/tests/platform_integrations/test_claude_retrieve_manifest.py +++ b/tests/platform_integrations/test_claude_retrieve_manifest.py @@ -12,8 +12,7 @@ _REPO_ROOT = Path(__file__).parent.parent.parent CLAUDE_RETRIEVE_SCRIPT = ( - _REPO_ROOT - / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" + _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" ) HOOK_INPUT = json.dumps({"prompt": "How do I write clean code?"}) diff --git a/tests/platform_integrations/test_codex_retrieve_manifest.py b/tests/platform_integrations/test_codex_retrieve_manifest.py index c1eeb05d..0382272e 100644 --- a/tests/platform_integrations/test_codex_retrieve_manifest.py +++ b/tests/platform_integrations/test_codex_retrieve_manifest.py @@ -12,8 +12,7 @@ _REPO_ROOT = Path(__file__).parent.parent.parent CODEX_RETRIEVE_SCRIPT = ( - _REPO_ROOT - / "platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" + _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" ) HOOK_INPUT = json.dumps({"prompt": "How do I write clean code?"}) From a3b61cfd4a9869499832d7cd4f681959ad4c22d3 Mon Sep 17 00:00:00 2001 From: Vatche Isahagian Date: Tue, 5 May 2026 21:39:13 -0400 Subject: [PATCH 3/4] docs: add text language to bob recall manifest fence (MD040) Co-Authored-By: Claude Opus 4.7 --- .../bob/evolve-lite/skills/evolve-lite-recall/SKILL.md | 2 +- plugin-source/skills/evolve-lite/recall/SKILL.md.j2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md index 28304230..01ccd361 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md @@ -82,7 +82,7 @@ Entities can come from multiple sources: The manifest output is human-readable: -``` +```text - `.evolve/entities/guideline/use-context-managers-for-file-operations.md` [guideline] — When processing files or managing resources - `.evolve/entities/subscribed/alice/guideline/error-handling.md` [guideline] — When writing error handlers ``` diff --git a/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 b/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 index 8e183fa4..26774dc3 100644 --- a/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 +++ b/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 @@ -124,7 +124,7 @@ Entities can come from multiple sources: {% if platform == "bob" -%} The manifest output is human-readable: -``` +```text - `.evolve/entities/guideline/use-context-managers-for-file-operations.md` [guideline] — When processing files or managing resources - `.evolve/entities/subscribed/alice/guideline/error-handling.md` [guideline] — When writing error handlers ``` From 3dba68e6d4f7f0b75e07bafccce81cb905b287fd Mon Sep 17 00:00:00 2001 From: Vatche Isahagian Date: Thu, 7 May 2026 11:06:22 -0400 Subject: [PATCH 4/4] style: ruff format rebased test_retrieve.py Co-Authored-By: Claude Opus 4.7 --- tests/platform_integrations/test_retrieve.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/platform_integrations/test_retrieve.py b/tests/platform_integrations/test_retrieve.py index 415c3e94..9117a33c 100644 --- a/tests/platform_integrations/test_retrieve.py +++ b/tests/platform_integrations/test_retrieve.py @@ -159,7 +159,9 @@ def test_skips_symlinked_markdown_entities(self, temp_project_dir, retrieve_scri assert entries[0]["trigger"] == "when testing" @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_writes_recall_audit_event_with_qualified_entity_ids(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + def test_writes_recall_audit_event_with_qualified_entity_ids( + self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name + ): result = run_retrieve( retrieve_script, temp_project_dir, @@ -183,7 +185,9 @@ def test_writes_recall_audit_event_with_qualified_entity_ids(self, evolve_dir, t } @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_writes_recall_audit_event_with_session_id_fallback(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + def test_writes_recall_audit_event_with_session_id_fallback( + self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name + ): result = run_retrieve( retrieve_script, temp_project_dir, @@ -232,7 +236,9 @@ def test_writes_recall_audit_under_custom_evolve_dir( assert not (temp_project_dir / ".evolve" / "audit.log").exists() @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) - def test_does_not_write_recall_audit_without_transcript_path(self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name): + def test_does_not_write_recall_audit_without_transcript_path( + self, evolve_dir, temp_project_dir, retrieve_script, expected_header, platform_name + ): result = run_retrieve(retrieve_script, temp_project_dir, evolve_dir=evolve_dir) assert result.returncode == 0