diff --git a/platform-integrations/claude/plugins/evolve-lite/README.md b/platform-integrations/claude/plugins/evolve-lite/README.md index df56e80f..82662b0c 100644 --- a/platform-integrations/claude/plugins/evolve-lite/README.md +++ b/platform-integrations/claude/plugins/evolve-lite/README.md @@ -82,7 +82,7 @@ Use `/evolve-lite:publish` to share one or more of your local guidelines with ot 1. The skill lists files in `.evolve/entities/guideline/` 2. You pick which ones to publish -3. Each selected file is copied to `.evolve/public/`, stamped with your username as the owner, committed, and pushed to your `public_repo.remote` +3. Each selected file is moved into `.evolve/public/guideline/`, stamped with `visibility: public`, `published_at`, and your username as the owner, committed, and pushed to your `public_repo.remote` Others can then subscribe using that remote URL. @@ -97,6 +97,7 @@ Use `/evolve-lite:subscribe` to pull in guidelines from another user's public re ``` The repo is cloned to `.evolve/subscribed/alice/` and mirrored into `.evolve/entities/subscribed/alice/` so recall picks them up immediately. +The repo is cloned directly into `.evolve/entities/subscribed/alice/` so recall can pick it up immediately. Subscription names must use only letters, numbers, `.`, `_`, and `-`. ### Syncing Subscriptions @@ -122,21 +123,22 @@ Use `/evolve-lite:unsubscribe` to remove a subscription and delete its locally c > 2. bob ``` -The skill confirms before deleting `.evolve/subscribed/{name}/` and its mirror under `.evolve/entities/subscribed/{name}/`. +The skill confirms before deleting `.evolve/entities/subscribed/{name}/`. ### Sharing Storage Layout ```text .evolve/ - public/ # git repo pushed to your public remote - guideline-name.md # owner-stamped guideline - subscribed/ - alice/ # git clone of alice's public repo - her-guideline.md + public/ + guideline/ + guideline-name.md # owner-stamped published guideline entities/ + guideline/ + private-guideline.md subscribed/ - alice/ # mirrored for recall - her-guideline.md + alice/ # git clone used directly by recall + guideline/ + her-guideline.md ``` ## Example Walkthrough 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 afb18c4b..156a429e 100644 --- a/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py +++ b/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py @@ -75,6 +75,18 @@ def find_entities_dir(): return c if c.is_dir() else None +def find_recall_entity_dirs(): + """Locate all directories that should be searched during recall. + + Returns the existing recall roots in priority order: + ``entities/`` first, then ``public/`` under the configured Evolve dir. + Missing directories are skipped. + """ + evolve_dir = get_evolve_dir() + candidates = [evolve_dir / "entities", evolve_dir / "public"] + return [path for path in candidates if path.is_dir()] + + def get_default_entities_dir(): """Return (and create) the default entities directory. diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py b/platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py index a3f98038..c8112790 100755 --- a/platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py @@ -57,8 +57,8 @@ def main(): print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) sys.exit(1) - if not src_path.exists(): - print(f"Error: entity file not found: {src_path}", file=sys.stderr) + if not src_path.is_file(): + print(f"Error: entity file not found or is a directory: {src_path}", file=sys.stderr) sys.exit(1) # Parse entity @@ -91,18 +91,26 @@ def main(): sys.exit(1) content = entity_to_markdown(entity) - tmp_fd, tmp_path = tempfile.mkstemp(dir=dest_path.parent, suffix=".tmp") + tmp_path = None try: - with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: - f.write(content) - Path(tmp_path).replace(dest_path) - except Exception: - try: - os.unlink(tmp_path) - except OSError: - pass - raise - src_path.unlink() + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=dest_path.parent, + prefix=f".{args.entity}.", + suffix=".tmp", + delete=False, + ) as temp_file: + temp_file.write(content) + temp_file.flush() + os.fsync(temp_file.fileno()) + tmp_path = Path(temp_file.name) + + tmp_path.replace(dest_path) + src_path.unlink() + finally: + if tmp_path is not None and tmp_path.exists(): + tmp_path.unlink() try: audit_append( diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py b/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py index 087f4d94..c3aba2f0 100755 --- a/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py @@ -8,6 +8,7 @@ import argparse import os +import re import subprocess import sys from pathlib import Path @@ -17,6 +18,8 @@ from config import load_config, save_config from audit import append as audit_append +_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+$") + def main(): parser = argparse.ArgumentParser() @@ -28,6 +31,13 @@ def main(): evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) project_root = str(evolve_dir.resolve().parent) + if not _SAFE_NAME.match(args.name): + print( + f"Error: invalid subscription name: {args.name!r} (only A-Z, a-z, 0-9, '.', '_', '-' allowed)", + file=sys.stderr, + ) + sys.exit(1) + # Validate name: resolve and confirm it stays within the subscribed directory subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() dest = (evolve_dir / "entities" / "subscribed" / args.name).resolve() diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py b/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py index 47de0628..ff59cf12 100755 --- a/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py @@ -133,9 +133,19 @@ def main(): for sub in subscriptions: if not isinstance(sub, dict): continue - name = sub.get("name", "unknown") + name = sub.get("name") branch = sub.get("branch", "main") + if not isinstance(name, str) or not name.strip(): + summaries.append(f"{sub!r} (skipped — missing or non-string name)") + continue + name = name.strip() + + if not isinstance(branch, str) or not branch.strip(): + summaries.append(f"{name!r} (skipped — missing or non-string branch)") + continue + branch = branch.strip() + if not _SAFE_NAME.match(name): summaries.append(f"{name!r} (skipped — invalid subscription name)") continue diff --git a/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json b/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json index bd809235..861b7272 100644 --- a/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json +++ b/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "evolve-lite", - "version": "1.0.0", - "description": "Recall and save Evolve entities in Codex without MCP.", + "version": "1.1.0", + "description": "Recall, save, and share Evolve entities in Codex without MCP.", "author": { "name": "Vinod Muthusamy", "url": "https://github.com/AgentToolkit/altk-evolve" @@ -13,8 +13,8 @@ "skills": "./skills/", "interface": { "displayName": "Evolve Lite", - "shortDescription": "Recall and save reusable Evolve entities.", - "longDescription": "A lightweight Codex plugin that helps you save reusable entities from successful sessions and recall them automatically on new prompts.", + "shortDescription": "Recall, save, and share reusable Evolve entities.", + "longDescription": "A lightweight Codex plugin that helps you save reusable entities, publish selected guidance, subscribe to shared repos, and recall relevant memory automatically on new prompts.", "developerName": "AgentToolkit", "category": "Productivity", "capabilities": ["Interactive", "Write"], @@ -22,7 +22,9 @@ "defaultPrompt": [ "Recall Evolve entities for this task.", "Save new Evolve learnings from this session.", - "Show me the entities stored for this repo." + "Show me the entities stored for this repo.", + "Publish one of my Evolve guidelines.", + "Subscribe to a teammate's Evolve guidelines repo." ], "brandColor": "#2563EB" } diff --git a/platform-integrations/codex/plugins/evolve-lite/README.md b/platform-integrations/codex/plugins/evolve-lite/README.md index ad772374..aba4b7d3 100644 --- a/platform-integrations/codex/plugins/evolve-lite/README.md +++ b/platform-integrations/codex/plugins/evolve-lite/README.md @@ -1,6 +1,6 @@ # Evolve Lite Plugin for Codex -A plugin that helps Codex learn from conversations by automatically extracting and applying entities. +A plugin that helps Codex save, recall, and share reusable entities across workspaces. ⭐ Star the repo: https://github.com/AgentToolkit/altk-evolve @@ -9,20 +9,54 @@ A plugin that helps Codex learn from conversations by automatically extracting a - Automatic recall through a repo-level Codex `UserPromptSubmit` hook when Codex hooks are enabled - Manual `evolve-lite:learn` skill to save reusable entities into `.evolve/entities/` - Manual `evolve-lite:recall` skill to inspect everything stored for the current repo +- Manual `evolve-lite:publish` skill to publish private guidelines to your public repo +- Manual `evolve-lite:subscribe` and `evolve-lite:unsubscribe` skills to manage shared guideline repos +- Automatic or manual `evolve-lite:sync` to mirror subscribed repos into local recall storage ## Storage -Entities are stored in the active workspace under: +Entities and sharing data are stored in the active workspace under: ```text -.evolve/entities/ - guideline/ - use-context-managers-for-file-operations.md - cache-api-responses-locally.md +.evolve/ + entities/ + guideline/ + use-context-managers-for-file-operations.md + subscribed/ + alice/ + guideline/ + prefer-small-functions.md + public/ + guideline/ + no-eval.md + subscribed/ + alice/ + guideline/ + prefer-small-functions.md + audit.log ``` Each entity is a markdown file with lightweight YAML frontmatter. +Sharing configuration lives in `evolve.config.yaml` at the repo root: + +```yaml +identity: + user: alice + +public_repo: + remote: git@github.com:alice/evolve-guidelines.git + branch: main + +subscriptions: + - name: team + remote: git@github.com:myorg/evolve-guidelines.git + branch: main + +sync: + on_session_start: true +``` + ## Source Layout This source tree intentionally omits `lib/`. @@ -33,7 +67,12 @@ The shared library lives in: platform-integrations/claude/plugins/evolve-lite/lib/ ``` -`platform-integrations/install.sh` copies that shared library into the installed Codex plugin so the installed layout is self-contained. +`platform-integrations/install.sh` installs Codex in this order: + +1. copy the Codex plugin source into `plugins/evolve-lite/` +2. copy the shared `lib/` from the Claude plugin into `plugins/evolve-lite/lib/` +3. wire the marketplace entry +4. wire the Codex hooks ## Installation @@ -60,6 +99,81 @@ If you do not want to enable Codex hooks, you can still invoke the installed `ev The installed Codex hook does not require `git`. It walks upward from the current working directory until it finds the repo-local `plugins/evolve-lite/.../retrieve_entities.py` script. +The installer always registers a `SessionStart` hook with matcher `startup|resume`; it runs on every Codex session start or resume and exits quickly unless `sync.on_session_start` is enabled and subscriptions are configured in `evolve.config.yaml`. + +## Sharing Guidelines + +Evolve Lite supports sharing guidelines between users via public Git repositories. You can publish your own guidelines so others can subscribe to them, and subscribe to guidelines published by others. + +### Setup + +Sharing uses `evolve.config.yaml` at the project root. Minimal structure: + +```yaml +identity: + user: yourname + +public_repo: + remote: git@github.com:yourname/evolve-guidelines.git + branch: main + +subscriptions: [] + +sync: + on_session_start: true +``` + +The `.evolve/` directory is kept out of version control. + +### Publishing Guidelines + +Use `evolve-lite:publish` to share one or more of your local guidelines with others: + +1. Pick a file from `.evolve/entities/guideline/` +2. Publish it into `.evolve/public/guideline/` +3. The published file is stamped with `visibility: public`, `published_at`, and a `source` label derived from config when available +4. The original private guideline is removed from `.evolve/entities/guideline/` + +Others can then subscribe using that public remote URL. + +### Subscribing to Guidelines + +Use `evolve-lite:subscribe` to pull in guidelines from another user's public repo. + +The repo is cloned directly into `.evolve/entities/subscribed/{name}/` so recall can pick it up immediately. Subscription names must use only letters, numbers, `.`, `_`, and `-`. + +### Syncing Subscriptions + +Use `evolve-lite:sync` to pull the latest changes from all subscribed repos already cloned under `.evolve/entities/subscribed/`. + +If `sync.on_session_start: true` is set in config, this runs automatically whenever a Codex session starts or resumes. + +### Unsubscribing + +Use `evolve-lite:unsubscribe` to remove a subscription and delete its locally cloned files. + +This removes `.evolve/entities/subscribed/{name}/`. If an older workspace still has `.evolve/subscribed/{name}/`, unsubscribe cleans that up too. + +### Sharing Storage Layout + +```text +.evolve/ + public/ + guideline/ + guideline-name.md # published guideline, included in recall + entities/ + guideline/ + private-guideline.md # private local guideline + subscribed/ + alice/ + guideline/ + her-guideline.md # git clone used directly by recall, annotated [from: alice] +``` + +## Example Walkthrough + +See the [Codex example walkthrough](../../../../docs/examples/hello_world/codex.md) for a step-by-step example showing the save-then-recall loop in a Codex workspace. + ## Included Skills ### `evolve-lite:learn` @@ -68,4 +182,73 @@ Analyze the current session and save proactive Evolve entities as markdown files ### `evolve-lite:recall` -Show the entities already stored for the current workspace. +Show the entities already stored for the current workspace, including published guidelines under `.evolve/public/`. + +### `evolve-lite:publish` + +Move selected private guidelines into `.evolve/public/`, stamp them as public, and push them to your configured sharing repo. + +### `evolve-lite:subscribe` + +Clone another user's public guideline repo into `.evolve/entities/subscribed/` and register it in `evolve.config.yaml`. + +### `evolve-lite:unsubscribe` + +Remove a configured subscription and delete its local cloned subscription data. + +### `evolve-lite:sync` + +Pull every configured subscription under `.evolve/entities/subscribed/` so recall sees the latest shared guidelines automatically. + +## Environment Variables + +- `EVOLVE_DIR`: Override the default `.evolve` directory location for entities, sharing data, audit logs, and the mirrored subscription store. + +## Verification + +After installation, verify that: + +- `plugins/evolve-lite/` exists in the repo +- `.agents/plugins/marketplace.json` contains the `evolve-lite` entry +- `.codex/hooks.json` contains the Evolve `UserPromptSubmit` and `SessionStart` hooks + +You can also run: + +```bash +platform-integrations/install.sh status +``` + +## Plugin Structure + +```text +evolve-lite/ +├── .codex-plugin/ +│ └── plugin.json +├── skills/ +│ ├── learn/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── save_entities.py +│ ├── recall/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── retrieve_entities.py +│ ├── publish/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── publish.py +│ ├── subscribe/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── subscribe.py +│ ├── unsubscribe/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── unsubscribe.py +│ └── sync/ +│ ├── SKILL.md +│ └── scripts/ +│ └── sync.py +├── README.md +└── lib/ # copied in at install time from the Claude plugin +``` diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md index b2a8d556..4efd1ff5 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md @@ -27,6 +27,7 @@ Examples of artifacts that must be immediately created once proven as the succes Unless that artifact happens to be: - code which is a trivial one-liner that future agents would not benefit from reusing - code which embeds secrets, tokens, or user-specific sensitive data +- the guideline would instruct the agent to invoke a skill, tool, or external command by name (e.g. "run evolve-lite:learn", "call save_trajectory") - such guidelines trigger prompt-injection detection when retrieved by the recall skill in a future session - the user explicitly asked for a one-off result and not to persist helper code - redundant because an equivalent local artifact on disk would be just as effective diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py b/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py index b437e57c..93be490b 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py @@ -5,6 +5,7 @@ in the entities directory, organized by type. """ +import argparse import json import sys from pathlib import Path @@ -13,9 +14,14 @@ _script = Path(__file__).resolve() _lib = None for _ancestor in _script.parents: - _candidate = _ancestor / "lib" - if (_candidate / "entity_io.py").is_file(): - _lib = _candidate + for _candidate in ( + _ancestor / "lib", + _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", + ): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: break if _lib is None: raise ImportError(f"Cannot find plugin lib directory above {_script}") @@ -42,6 +48,10 @@ def normalize(text): def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--user", default=None, help="Stamp owner on every entity written") + args = parser.parse_args() + try: input_data = json.load(sys.stdin) log(f"Received input with keys: {list(input_data.keys())}") @@ -82,6 +92,9 @@ def main(): log(f"Skipping duplicate: {content[:60]}") continue + entity["owner"] = args.user or "unknown" + entity["visibility"] = "private" + path = write_entity_file(entities_dir, entity) existing_contents.add(normalize(content)) added_count += 1 diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/publish/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/publish/SKILL.md new file mode 100644 index 00000000..db97abe6 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/publish/SKILL.md @@ -0,0 +1,77 @@ +--- +name: publish +description: Publish a private guideline to your public repo so others can subscribe to it. +--- + +# Publish Guideline + +## Overview + +This skill publishes one or more private guidelines from your local `.evolve/entities/guideline/` directory to your public git repository, moving them into the public store so others can subscribe to them. + +## Workflow + +### Step 1: Bootstrap config if missing or incomplete + +Check whether `evolve.config.yaml` exists in the project root. + +If it does not exist, ask the user for: + +- a username such as `vatche` +- the remote URL for the public guidelines repo + +Create `evolve.config.yaml` with: + +```yaml +identity: + user: {username} +public_repo: + remote: {remote} + branch: main +subscriptions: [] +sync: + on_session_start: true +``` + +If the file exists but `identity.user` or `public_repo.remote` is missing, ask only for the missing values and update the file. + +### Step 2: First-time setup + +Ensure `.evolve/` is gitignored at the project root: + +```bash +grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore +``` + +If `.evolve/public/` does not already contain a `.git` directory, initialize it and add the configured remote: + +```bash +git init .evolve/public +git -C .evolve/public remote add origin {public_repo.remote} +``` + +### Step 3: List and select entities + +List the files in `.evolve/entities/guideline/` and ask the user which ones to publish. + +### Step 4: Run publish script + +For each selected file, run: + +```bash +python3 plugins/evolve-lite/skills/publish/scripts/publish.py \ + --entity "{filename}" \ + --user "{identity.user}" +``` + +### Step 5: Commit and push + +```bash +git -C .evolve/public add . +git -C .evolve/public commit -m "[evolve] publish: {name}" +git -C .evolve/public push origin "{public_repo.branch}" +``` + +### Step 6: Confirm + +Tell the user what was published and where it was pushed. diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py b/platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py new file mode 100644 index 00000000..170295c7 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Publish a private guideline entity to the public directory.""" + +import argparse +import datetime +import os +import re +import sys +import tempfile +from pathlib import Path, PurePath + +# Walk up from the script location to find the installed plugin lib directory. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in ( + _ancestor / "lib", + _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", + ): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 +from entity_io import entity_to_markdown, markdown_to_entity # noqa: E402 +from config import load_config # noqa: E402 + + +def _resolve_source(cfg, user_arg): + """Derive a source label from config or fallback to the provided user.""" + remote = cfg.get("public_repo", {}) + if isinstance(remote, dict): + remote = remote.get("remote", "") + if remote: + match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", remote) + if match: + return match.group(1) + + identity = cfg.get("identity", {}) + if isinstance(identity, dict) and identity.get("user"): + return identity["user"] + + return user_arg + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--entity", required=True, help="Basename of the .md file to publish") + parser.add_argument("--user", default=None, help="Username to stamp as owner") + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir) if evolve_dir.name != ".evolve" else str(resolved_evolve_dir.parent) + if PurePath(args.entity).name != args.entity or args.entity in {".", ".."}: + print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) + sys.exit(1) + + src_base = (evolve_dir / "entities" / "guideline").resolve() + src_path = (evolve_dir / "entities" / "guideline" / args.entity).resolve() + + if not src_path.is_relative_to(src_base): + print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) + sys.exit(1) + + if not src_path.is_file(): + print(f"Error: entity file not found or is a directory: {src_path}", file=sys.stderr) + sys.exit(1) + + entity = markdown_to_entity(src_path) + config = load_config(project_root) + identity = config.get("identity", {}) + effective_user = args.user or (identity.get("user") if isinstance(identity, dict) else None) + + entity["visibility"] = "public" + if effective_user: + entity["owner"] = effective_user + entity["published_at"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + source = _resolve_source(config, effective_user) + if source: + entity["source"] = source + + dest_dir = evolve_dir / "public" / "guideline" + dest_dir.mkdir(parents=True, exist_ok=True) + dest_base = dest_dir.resolve() + dest_path = (dest_dir / args.entity).resolve() + if not dest_path.is_relative_to(dest_base): + print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) + sys.exit(1) + if dest_path.exists(): + print(f"Error: already published: {dest_path}\nUnpublish it first or delete it manually.", file=sys.stderr) + sys.exit(1) + + temp_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=dest_dir, + prefix=f".{args.entity}.", + suffix=".tmp", + delete=False, + ) as temp_file: + temp_file.write(entity_to_markdown(entity)) + temp_file.flush() + os.fsync(temp_file.fileno()) + temp_path = Path(temp_file.name) + + temp_path.replace(dest_path) + src_path.unlink() + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + + try: + audit_append( + project_root=project_root, + action="publish", + actor=effective_user or "unknown", + entity=args.entity, + ) + except Exception as exc: + print(f"Warning: failed to append audit entry for publish: {exc}", file=sys.stderr) + + print(f"Published: {args.entity} -> {dest_path}") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md index dd65e726..ecccc49a 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/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/entities/` for guidance relevant to the current task. +1. Inspect `.evolve/entities/` and `.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. @@ -36,12 +36,12 @@ Do not proceed to other analysis or tool use until all steps below are complete. Before moving on, produce an explicit completion note in your reasoning or user update using one of these forms: -- `Recall complete: searched .evolve/entities/, read , applicable guidance: ` -- `Recall complete: searched .evolve/entities/, no relevant entities found` +- `Recall complete: searched .evolve/entities/ and .evolve/public/, read , applicable guidance: ` +- `Recall complete: searched .evolve/entities/ and .evolve/public/, no relevant entities found` ### Minimum Acceptable Procedure -1. List or search files under `.evolve/entities/`. +1. List or search files under `.evolve/entities/` and `.evolve/public/`. 2. Identify candidate entities relevant to the task. 3. Open and read those entity files. 4. Summarize what applies, or state that nothing applies. @@ -51,7 +51,7 @@ Before moving on, produce an explicit completion note in your reasoning or user The skill is not complete if any of the following are true: - You only read this `SKILL.md` -- You did not inspect `.evolve/entities/` +- You did not inspect `.evolve/entities/` and `.evolve/public/` - You did not read the relevant entity files - You proceeded without stating whether guidance was found @@ -59,19 +59,26 @@ 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/entities/`. +3. It loads stored entities from `.evolve/entities/` and `.evolve/public/`. 4. It prints formatted guidance to stdout. 5. Codex adds that text as extra developer context for the turn. ## Entities Storage -Entities are stored as markdown files in `.evolve/entities/`, nested by type: +Entities are loaded from two locations: ```text .evolve/entities/ guideline/ - use-context-managers-for-file-operations.md - cache-api-responses-locally.md + use-context-managers-for-file-operations.md <- private + subscribed/ + alice/ + guideline/ + alice-tip.md <- annotated [from: alice] + +.evolve/public/ + guideline/ + published-tip.md <- your own public, no annotation ``` Each file uses markdown with YAML frontmatter: diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py b/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py index 43ed3c96..c6158b92 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py @@ -10,14 +10,19 @@ _script = Path(__file__).resolve() _lib = None for _ancestor in _script.parents: - _candidate = _ancestor / "lib" - if (_candidate / "entity_io.py").is_file(): - _lib = _candidate + for _candidate in ( + _ancestor / "lib", + _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", + ): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: break 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, load_all_entities, log as _log # noqa: E402 +from entity_io import find_entities_dir, get_evolve_dir, markdown_to_entity, log as _log # noqa: E402 def log(message): @@ -39,6 +44,9 @@ def format_entities(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']}" @@ -49,6 +57,30 @@ def format_entities(entities): return header + "\n".join(items) +def load_entities_with_source(entities_dir): + """Load markdown entities from one recall root and annotate subscribed content.""" + entities_dir = Path(entities_dir) + entities = [] + for md in sorted(entities_dir.glob("**/*.md")): + if md.is_symlink(): + continue + try: + entity = markdown_to_entity(md) + except (OSError, UnicodeError): + continue + if not entity.get("content"): + continue + + entity.pop("_source", None) + parts = md.relative_to(entities_dir).parts + if parts and parts[0] == "subscribed" and len(parts) > 1: + entity["_source"] = parts[1] + + entities.append(entity) + + return entities + + def main(): try: input_data = json.load(sys.stdin) @@ -71,11 +103,16 @@ def main(): entities_dir = find_entities_dir() log(f"Entities dir: {entities_dir}") - if not entities_dir: - log("No entities directory found") - return - entities = load_all_entities(entities_dir) + entities = [] + if entities_dir: + entities = load_entities_with_source(entities_dir) + + public_dir = get_evolve_dir() / "public" + if public_dir.is_dir(): + log(f"Loading public entities from: {public_dir}") + entities += load_entities_with_source(public_dir) + if not entities: log("No entities found") return diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/SKILL.md new file mode 100644 index 00000000..15af5bd7 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/SKILL.md @@ -0,0 +1,52 @@ +--- +name: subscribe +description: Subscribe to another user's public guidelines repo. +--- + +# Subscribe to Guidelines + +## Overview + +This skill subscribes to another user's public guidelines repository. Their guidelines are cloned locally and become available in recall after sync. + +## Workflow + +### Step 1: Bootstrap config if missing + +Check whether `evolve.config.yaml` exists in the project root. + +If it does not exist, ask the user for a username and create: + +```yaml +identity: + user: {username} +subscriptions: [] +sync: + on_session_start: true +``` + +Also ensure `.evolve/` is gitignored: + +```bash +grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore +``` + +### Step 2: Gather details + +Ask the user for: + +- the remote URL for the guidelines repo +- a short local name such as `alice` + +### Step 3: Run subscribe script + +```bash +python3 plugins/evolve-lite/skills/subscribe/scripts/subscribe.py \ + --name "{name}" \ + --remote "{remote}" \ + --branch main +``` + +### Step 4: Confirm + +Tell the user the subscription was added and they can run `evolve-lite:sync` immediately if they want to pull updates now. diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py b/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py new file mode 100644 index 00000000..aaa4ae8e --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Subscribe to another user's public guidelines repo.""" + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in ( + _ancestor / "lib", + _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", + ): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 +from config import load_config, save_config # noqa: E402 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--name", required=True, help="Short subscription name") + parser.add_argument("--remote", required=True, help="Git remote URL") + parser.add_argument("--branch", default="main", help="Branch to track") + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + safe_name = re.compile(r"^[A-Za-z0-9._-]+$") + project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent) + subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() + dest = (evolve_dir / "entities" / "subscribed" / args.name).resolve() + legacy_dest = (evolve_dir / "subscribed" / args.name).resolve() + if args.name in {"", "."} or not safe_name.match(args.name) or dest == subscribed_base or not dest.is_relative_to(subscribed_base): + print(f"Error: invalid subscription name: {args.name!r}", file=sys.stderr) + sys.exit(1) + + cfg = load_config(project_root) + subscriptions = cfg.get("subscriptions", []) + if not isinstance(subscriptions, list): + subscriptions = [] + + for sub in subscriptions: + if isinstance(sub, dict) and sub.get("name") == args.name: + print(f"Error: subscription '{args.name}' already exists in config.", file=sys.stderr) + sys.exit(1) + + if dest.exists() or legacy_dest.exists(): + print(f"Error: destination already exists: {dest}", file=sys.stderr) + sys.exit(1) + else: + dest.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "git", + "clone", + args.remote, + str(dest), + "--branch", + args.branch, + "--depth", + "1", + ], + check=True, + ) + + subscriptions.append({"name": args.name, "remote": args.remote, "branch": args.branch}) + cfg["subscriptions"] = subscriptions + save_config(cfg, project_root) + + identity = cfg.get("identity", {}) + actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" + audit_append( + project_root=project_root, + action="subscribe", + actor=actor, + name=args.name, + remote=args.remote, + ) + + print(f"Subscribed to '{args.name}' from {args.remote}") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/sync/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/sync/SKILL.md new file mode 100644 index 00000000..72f16b8d --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/sync/SKILL.md @@ -0,0 +1,22 @@ +--- +name: sync +description: Pull the latest guidelines from all subscribed repos. +--- + +# Sync Subscriptions + +## Overview + +This skill pulls the latest guidelines from all subscribed repos and mirrors them into local recall storage. + +## Workflow + +### Step 1: Run sync script + +```bash +python3 plugins/evolve-lite/skills/sync/scripts/sync.py +``` + +### Step 2: Display summary + +Show the script output to the user. If there are no subscriptions, tell them they can add one with `evolve-lite:subscribe`. If there are no changes, explain that everything is already up to date. diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py b/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py new file mode 100644 index 00000000..4e0c148d --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Pull the latest guidelines from all subscribed repos.""" + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in ( + _ancestor / "lib", + _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", + ): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 +from config import _parse_yaml, load_config # noqa: E402 + + +_GIT_TIMEOUT = 30 # seconds + + +def git_pull(repo_path, branch): + """Pull latest from origin. Returns CompletedProcess, or None on timeout.""" + try: + return subprocess.run( + ["git", "-C", str(repo_path), "pull", "origin", branch, "--ff-only"], + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + ) + except subprocess.TimeoutExpired: + print(f"Warning: git pull timed out for {repo_path} (branch: {branch})", file=sys.stderr) + return None + + +def count_delta(repo_path): + """Count added/modified/deleted .md files since last pull.""" + result = subprocess.run( + ["git", "-C", str(repo_path), "diff", "--name-status", "HEAD@{1}", "HEAD"], + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + ) + if result.returncode != 0: + # HEAD@{1} doesn't exist (initial sync) — count all .md files as added. + added = len(list(repo_path.glob("**/*.md"))) + return {"added": added, "updated": 0, "removed": 0} + added = updated = removed = 0 + for line in result.stdout.splitlines(): + if not line.strip(): + continue + parts = line.split("\t", 1) + if len(parts) < 2: + continue + status, filename = parts[0].strip(), parts[1].strip() + if not filename.endswith(".md"): + continue + if status.startswith("A"): + added += 1 + elif status.startswith("M"): + updated += 1 + elif status.startswith("D"): + removed += 1 + return {"added": added, "updated": updated, "removed": removed} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--quiet", action="store_true", help="Suppress output if no changes") + parser.add_argument("--config", default=None, help="Explicit config path") + parser.add_argument( + "--session-start", + action="store_true", + help="Apply session-start gating for automatic hook execution", + ) + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir.parent) + audit_root = resolved_evolve_dir if resolved_evolve_dir.name == ".evolve" else resolved_evolve_dir / ".evolve" + + if args.config: + cfg_path = Path(args.config) + cfg = _parse_yaml(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {} + else: + cfg = load_config(project_root) + + sync_cfg = cfg.get("sync", {}) + if args.session_start and isinstance(sync_cfg, dict) and sync_cfg.get("on_session_start") is False: + sys.exit(0) + + subscriptions = cfg.get("subscriptions", []) + if not isinstance(subscriptions, list): + subscriptions = [] + + if not subscriptions: + if not args.quiet: + print("No subscriptions configured. Add one with the evolve-lite:subscribe skill to start syncing shared guidelines.") + sys.exit(0) + + identity = cfg.get("identity", {}) + actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" + + summaries = [] + total_delta = {} + any_changes = False + safe_name = re.compile(r"^[A-Za-z0-9._-]+$") + + for sub in subscriptions: + if not isinstance(sub, dict): + continue + raw_name = sub.get("name", "unknown") + raw_branch = sub.get("branch", "main") + + if not isinstance(raw_name, str) or not isinstance(raw_branch, str): + summaries.append(f"{raw_name!r} (skipped - invalid subscription config)") + continue + + name = raw_name.strip() + branch = raw_branch.strip() + + if not name or not branch: + summaries.append(f"{raw_name!r} (skipped - invalid subscription config)") + continue + + if not safe_name.match(name): + summaries.append(f"{name!r} (skipped - invalid subscription name)") + continue + + subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() + repo_path = (evolve_dir / "entities" / "subscribed" / name).resolve() + legacy_base = (evolve_dir / "subscribed").resolve() + legacy_repo_path = (evolve_dir / "subscribed" / name).resolve() + + if repo_path == subscribed_base or not repo_path.is_relative_to(subscribed_base): + summaries.append(f"{name!r} (skipped - invalid subscription name)") + continue + + if legacy_repo_path != legacy_base and legacy_repo_path.is_relative_to(legacy_base): + if legacy_repo_path.exists() and not repo_path.exists(): + repo_path.parent.mkdir(parents=True, exist_ok=True) + legacy_repo_path.rename(repo_path) + elif legacy_repo_path.exists() and repo_path.exists(): + summaries.append(f"{name} (duplicate subscription folders — remove .evolve/subscribed/{name})") + continue + + if not repo_path.is_dir(): + summaries.append(f"{name} (not cloned)") + continue + + pull_result = git_pull(repo_path, branch) + if pull_result is None or pull_result.returncode != 0: + if pull_result is None: + short_error = "timeout" + else: + error_lines = (pull_result.stderr or pull_result.stdout or "").strip().splitlines() + short_error = error_lines[-1] if error_lines else f"git exited with {pull_result.returncode}" + summaries.append(f"{name} (git pull failed: {short_error})") + total_delta[name] = {"added": 0, "updated": 0, "removed": 0} + any_changes = True + continue + + if "Already up to date" in (pull_result.stdout or ""): + delta = {"added": 0, "updated": 0, "removed": 0} + else: + delta = count_delta(repo_path) + total_delta[name] = delta + if any(value > 0 for value in delta.values()): + any_changes = True + + summaries.append(f"{name} (+{delta['added']} added, {delta['updated']} updated, {delta['removed']} removed)") + + audit_append(project_root=str(audit_root.parent), action="sync", actor=actor, delta=total_delta) + + if args.quiet and not any_changes: + sys.exit(0) + + print(f"Synced {len(summaries)} repo(s): " + ", ".join(summaries)) + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/SKILL.md new file mode 100644 index 00000000..3070f89d --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/SKILL.md @@ -0,0 +1,39 @@ +--- +name: unsubscribe +description: Remove a subscription and delete the locally synced guidelines. +--- + +# Unsubscribe from Guidelines + +## Overview + +This skill removes a subscription and deletes both the local clone and mirrored recall entities for that subscription. + +## Workflow + +### Step 1: List subscriptions + +Run: + +```bash +python3 plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py --list +``` + +Show the subscriptions to the user and ask which one to remove. + +### Step 2: Confirm + +Confirm that removing the subscription will delete: + +- `.evolve/subscribed/{name}/` +- `.evolve/entities/subscribed/{name}/` + +### Step 3: Run unsubscribe script + +```bash +python3 plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py --name "{name}" +``` + +### Step 4: Confirm + +Tell the user the subscription was removed. diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py b/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py new file mode 100644 index 00000000..8dbeb7ee --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Remove a subscription and delete the locally cloned directory.""" + +import argparse +import json +import os +import shutil +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in ( + _ancestor / "lib", + _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", + ): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 +from config import load_config, save_config # noqa: E402 + + +def main(): + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--list", action="store_true", help="Print subscriptions as JSON array") + group.add_argument("--name", help="Name of subscription to remove") + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent) + + cfg = load_config(project_root) + subscriptions = cfg.get("subscriptions", []) + if not isinstance(subscriptions, list): + subscriptions = [] + + if args.list: + print(json.dumps(subscriptions, indent=2)) + return + + name = args.name + subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() + dest = (evolve_dir / "entities" / "subscribed" / name).resolve() + if name in {"", "."} or dest == subscribed_base or not dest.is_relative_to(subscribed_base): + print(f"Error: invalid subscription name: {name!r}", file=sys.stderr) + sys.exit(1) + + legacy_base = (evolve_dir / "subscribed").resolve() + legacy_dest = (evolve_dir / "subscribed" / name).resolve() + if legacy_dest == legacy_base or not legacy_dest.is_relative_to(legacy_base): + print(f"Error: invalid subscription name: {name!r}", file=sys.stderr) + sys.exit(1) + + new_subs = [s for s in subscriptions if not (isinstance(s, dict) and s.get("name") == name)] + if len(new_subs) == len(subscriptions): + print(f"Error: subscription '{name}' not found.", file=sys.stderr) + sys.exit(1) + + if dest.exists(): + shutil.rmtree(dest) + print(f"Deleted {dest}") + else: + print(f"Warning: {dest} did not exist.", file=sys.stderr) + + if legacy_dest.exists(): + shutil.rmtree(legacy_dest) + print(f"Deleted {legacy_dest}") + + cfg["subscriptions"] = new_subs + save_config(cfg, project_root) + + identity = cfg.get("identity", {}) + actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" + audit_append(project_root=project_root, action="unsubscribe", actor=actor, name=name) + + print(f"Removed subscription '{name}' from config.") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/install.sh b/platform-integrations/install.sh index f51479e9..83a3497c 100755 --- a/platform-integrations/install.sh +++ b/platform-integrations/install.sh @@ -685,6 +685,36 @@ class CodexInstaller: def _recall_hook_group(): return {"matcher": "", "hooks": [CodexInstaller._recall_hook()]} + @staticmethod + def _sync_hook_command(): + return ( + "sh -lc '" + 'd=\"$PWD\"; ' + "while :; do " + 'candidate=\"$d/plugins/evolve-lite/skills/sync/scripts/sync.py\"; ' + 'if [ -f \"$candidate\" ]; then EVOLVE_DIR=\"$d/.evolve\" exec python3 \"$candidate\" --quiet --session-start; fi; ' + '[ \"$d\" = \"/\" ] && break; ' + 'd=\"$(dirname \"$d\")\"; ' + "done; " + "exit 1'" + ) + + @staticmethod + def _is_sync_command(command): + return isinstance(command, str) and "plugins/evolve-lite/skills/sync/scripts/sync.py" in command + + @staticmethod + def _sync_hook(): + return { + "type": "command", + "command": CodexInstaller._sync_hook_command(), + "statusMessage": "Syncing Evolve subscriptions", + } + + @staticmethod + def _sync_hook_group(): + return {"matcher": "startup|resume", "hooks": [CodexInstaller._sync_hook()]} + @staticmethod def _iter_group_hooks(group): hooks = group.get("hooks", []) @@ -699,6 +729,13 @@ class CodexInstaller: for h in CodexInstaller._iter_group_hooks(group) ) + @staticmethod + def _group_has_sync(group): + return any( + isinstance(h, dict) and CodexInstaller._is_sync_command(h.get("command")) + for h in CodexInstaller._iter_group_hooks(group) + ) + @staticmethod def _upsert_recall_into_group(group): updated = copy.deepcopy(group) @@ -722,6 +759,29 @@ class CodexInstaller: updated["hooks"] = [copy.deepcopy(recall)] return updated + @staticmethod + def _upsert_sync_into_group(group): + updated = copy.deepcopy(group) + sync = CodexInstaller._sync_hook() + hooks = updated.get("hooks") + if isinstance(hooks, list): + for i, h in enumerate(hooks): + if isinstance(h, dict) and CodexInstaller._is_sync_command(h.get("command")): + hooks[i] = merge_json_value(h, sync) + break + else: + hooks.append(copy.deepcopy(sync)) + elif isinstance(hooks, dict): + for key, h in hooks.items(): + if isinstance(h, dict) and CodexInstaller._is_sync_command(h.get("command")): + hooks[key] = merge_json_value(h, sync) + break + else: + hooks["evolve-lite"] = copy.deepcopy(sync) + else: + updated["hooks"] = [copy.deepcopy(sync)] + return updated + @staticmethod def _remove_recall_from_group(group): updated = copy.deepcopy(group) @@ -738,6 +798,22 @@ class CodexInstaller: } return updated + @staticmethod + def _remove_sync_from_group(group): + updated = copy.deepcopy(group) + hooks = updated.get("hooks") + if isinstance(hooks, list): + updated["hooks"] = [ + h for h in hooks + if not (isinstance(h, dict) and CodexInstaller._is_sync_command(h.get("command"))) + ] + elif isinstance(hooks, dict): + updated["hooks"] = { + k: h for k, h in hooks.items() + if not (isinstance(h, dict) and CodexInstaller._is_sync_command(h.get("command"))) + } + return updated + def _upsert_marketplace_entry(self, path, item): data = read_json(path) if not data: @@ -797,6 +873,50 @@ class CodexInstaller: hooks.pop("UserPromptSubmit", None) self.ops.atomic_write_json(path, data) + def _upsert_session_start_hook(self, path, group): + data = read_json(path) + if not data: + data = {"hooks": {}} + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a JSON object.") + hooks = data.setdefault("hooks", {}) + if not isinstance(hooks, dict): + hooks = {} + data["hooks"] = hooks + groups = hooks.setdefault("SessionStart", []) + if not isinstance(groups, list): + groups = [] + hooks["SessionStart"] = groups + for i, existing in enumerate(groups): + if isinstance(existing, dict) and self._group_has_sync(existing): + groups[i] = self._upsert_sync_into_group(existing) + break + else: + groups.append(copy.deepcopy(group)) + self.ops.atomic_write_json(path, data) + + def _remove_session_start_hook(self, path): + if not os.path.isfile(str(path)): + return + data = read_json(path) + hooks = data.get("hooks") + if not isinstance(hooks, dict): + return + groups = hooks.get("SessionStart", []) + if not isinstance(groups, list): + return + hooks["SessionStart"] = [ + self._remove_sync_from_group(g) if isinstance(g, dict) and self._group_has_sync(g) else g + for g in groups + ] + hooks["SessionStart"] = [ + group for group in hooks["SessionStart"] + if not isinstance(group, dict) or self._iter_group_hooks(group) + ] + if not hooks["SessionStart"]: + hooks.pop("SessionStart", None) + self.ops.atomic_write_json(path, data) + # ── Public interface ────────────────────────────────────────────────────── def install(self, target_dir): @@ -829,7 +949,9 @@ class CodexInstaller: hooks_target = Path(target_dir) / ".codex" / "hooks.json" self._upsert_user_prompt_hook(hooks_target, self._recall_hook_group()) + self._upsert_session_start_hook(hooks_target, self._sync_hook_group()) success(f"Upserted Codex UserPromptSubmit hook in {hooks_target}") + success(f"Upserted Codex SessionStart hook in {hooks_target}") warn("Automatic Codex recall requires hooks to be enabled in ~/.codex/config.toml:") print(" [features]") print(" codex_hooks = true") @@ -846,6 +968,7 @@ class CodexInstaller: "plugins", "name", CODEX_PLUGIN, ) self._remove_user_prompt_hook(Path(target_dir) / ".codex" / "hooks.json") + self._remove_session_start_hook(Path(target_dir) / ".codex" / "hooks.json") success("Codex uninstall complete") @@ -870,7 +993,13 @@ class CodexInstaller: for g in read_json(hooks_path).get("hooks", {}).get("UserPromptSubmit", [])) if hooks_path.is_file() else False ) + session_hook_present = ( + any(isinstance(g, dict) and self._group_has_sync(g) + for g in read_json(hooks_path).get("hooks", {}).get("SessionStart", [])) + if hooks_path.is_file() else False + ) print(f" .codex/hooks.json entry : {'✓' if hook_present else '✗'}") + print(f" SessionStart sync hook : {'✓' if session_hook_present else '✗'}") # ── Dispatch ────────────────────────────────────────────────────────────────── diff --git a/tests/platform_integrations/test_audit.py b/tests/platform_integrations/test_audit.py index 7b739851..b300b95f 100644 --- a/tests/platform_integrations/test_audit.py +++ b/tests/platform_integrations/test_audit.py @@ -12,7 +12,7 @@ ) import audit -pytestmark = pytest.mark.platform_integrations +pytestmark = [pytest.mark.platform_integrations, pytest.mark.unit] class TestAuditAppend: diff --git a/tests/platform_integrations/test_codex.py b/tests/platform_integrations/test_codex.py index 8b041f0c..1284dddf 100644 --- a/tests/platform_integrations/test_codex.py +++ b/tests/platform_integrations/test_codex.py @@ -9,6 +9,7 @@ EVOLVE_PLUGIN = "evolve-lite" EVOLVE_HOOK_SNIPPET = "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" +EVOLVE_SYNC_SNIPPET = "plugins/evolve-lite/skills/sync/scripts/sync.py" def _marketplace_has_evolve_plugin(path): @@ -26,6 +27,16 @@ def _hooks_have_evolve_recall(path): return False +def _hooks_have_evolve_sync(path): + data = json.loads(path.read_text()) + groups = data.get("hooks", {}).get("SessionStart", []) + for group in groups: + for hook in _iter_group_hooks(group): + if EVOLVE_SYNC_SNIPPET in hook.get("command", ""): + return group.get("matcher") == "startup|resume" + return False + + def _iter_group_hooks(group): hooks = group.get("hooks", []) if isinstance(hooks, list): @@ -36,6 +47,7 @@ def _iter_group_hooks(group): @pytest.mark.platform_integrations +@pytest.mark.e2e class TestCodexInstall: """Test the Codex install flow.""" @@ -49,8 +61,16 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, file_assertions.assert_file_exists(plugin_dir / "README.md") file_assertions.assert_dir_exists(plugin_dir / "skills" / "learn") file_assertions.assert_dir_exists(plugin_dir / "skills" / "recall") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "publish") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "subscribe") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "unsubscribe") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "sync") file_assertions.assert_file_exists(plugin_dir / "skills" / "learn" / "scripts" / "save_entities.py") file_assertions.assert_file_exists(plugin_dir / "skills" / "recall" / "scripts" / "retrieve_entities.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "publish" / "scripts" / "publish.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "subscribe" / "scripts" / "subscribe.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "unsubscribe" / "scripts" / "unsubscribe.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "sync" / "scripts" / "sync.py") file_assertions.assert_file_exists(plugin_dir / "lib" / "entity_io.py") marketplace_path = temp_project_dir / ".agents" / "plugins" / "marketplace.json" @@ -60,6 +80,7 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, hooks_path = temp_project_dir / ".codex" / "hooks.json" file_assertions.assert_valid_json(hooks_path) assert _hooks_have_evolve_recall(hooks_path), "Evolve recall hook missing from .codex/hooks.json" + assert _hooks_have_evolve_sync(hooks_path), "Evolve sync hook missing from .codex/hooks.json" hooks_data = json.loads(hooks_path.read_text()) evolve_groups = [ @@ -81,6 +102,25 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, "exit 1'" ) assert evolve_hook["command"] == expected_command + sync_groups = [ + group + for group in hooks_data.get("hooks", {}).get("SessionStart", []) + if any(EVOLVE_SYNC_SNIPPET in hook.get("command", "") for hook in group.get("hooks", [])) + ] + assert sync_groups[0]["matcher"] == "startup|resume" + sync_hook = next(hook for hook in sync_groups[0]["hooks"] if EVOLVE_SYNC_SNIPPET in hook.get("command", "")) + expected_sync_command = ( + "sh -lc '" + 'd="$PWD"; ' + "while :; do " + 'candidate="$d/plugins/evolve-lite/skills/sync/scripts/sync.py"; ' + 'if [ -f "$candidate" ]; then EVOLVE_DIR="$d/.evolve" exec python3 "$candidate" --quiet --session-start; fi; ' + '[ "$d" = "/" ] && break; ' + 'd="$(dirname "$d")"; ' + "done; " + "exit 1'" + ) + assert sync_hook["command"] == expected_sync_command assert "~/.codex/config.toml" in result.stdout assert "codex_hooks = true" in result.stdout assert "evolve-lite:recall" in result.stdout @@ -129,6 +169,21 @@ def test_install_updates_dict_based_matching_group(self, temp_project_dir, insta assert evolve_hook["statusMessage"] == "Loading Evolve guidance" assert evolve_hook["delayMs"] == 250 + def test_install_adds_session_start_sync_hook(self, temp_project_dir, install_runner, codex_fixtures): + """Installing should preserve user SessionStart hooks and add the sync hook.""" + hooks_path = codex_fixtures.create_existing_hooks(temp_project_dir) + + install_runner.run("install", platform="codex") + + hooks_data = json.loads(hooks_path.read_text()) + session_groups = hooks_data["hooks"]["SessionStart"] + assert len(session_groups) == 2 + assert any( + any(hook.get("command") == "python3 ~/.codex/hooks/session_start.py" for hook in _iter_group_hooks(group)) + for group in session_groups + ) + assert any(any(EVOLVE_SYNC_SNIPPET in hook.get("command", "") for hook in _iter_group_hooks(group)) for group in session_groups) + def test_uninstall_removes_only_evolve_hook_from_matching_group(self, temp_project_dir, install_runner, codex_fixtures): """Uninstalling should remove only the evolve hook entry and preserve the shared group.""" hooks_path = codex_fixtures.create_existing_hooks_with_dict_evolve_group(temp_project_dir) @@ -146,6 +201,33 @@ def test_uninstall_removes_only_evolve_hook_from_matching_group(self, temp_proje assert "evolve-lite" not in remaining_group["hooks"] assert all(EVOLVE_HOOK_SNIPPET not in hook.get("command", "") for hook in _iter_group_hooks(remaining_group)) + def test_uninstall_removes_session_start_sync_hook_only(self, temp_project_dir, install_runner, codex_fixtures): + """Uninstalling should remove the Evolve SessionStart hook and preserve user hooks.""" + hooks_path = codex_fixtures.create_existing_hooks(temp_project_dir) + install_runner.run("install", platform="codex") + + install_runner.run("uninstall", platform="codex") + + hooks_data = json.loads(hooks_path.read_text()) + session_groups = hooks_data["hooks"]["SessionStart"] + assert len(session_groups) == 1 + assert any(hook.get("command") == "python3 ~/.codex/hooks/session_start.py" for hook in _iter_group_hooks(session_groups[0])) + assert all(EVOLVE_SYNC_SNIPPET not in hook.get("command", "") for group in session_groups for hook in _iter_group_hooks(group)) + + def test_uninstall_prunes_evolve_only_hook_groups(self, temp_project_dir, install_runner, file_assertions): + """Uninstalling after a clean install should remove empty Evolve-only hook groups.""" + install_runner.run("install", platform="codex") + + hooks_path = temp_project_dir / ".codex" / "hooks.json" + file_assertions.assert_valid_json(hooks_path) + + install_runner.run("uninstall", platform="codex") + + hooks_data = json.loads(hooks_path.read_text()) + hooks = hooks_data.get("hooks", {}) + assert "UserPromptSubmit" not in hooks + assert "SessionStart" not in hooks + def test_codex_dry_run_does_not_write_files(self, temp_project_dir, install_runner): """Dry-run should report actions without writing files.""" result = install_runner.run("install", platform="codex", dry_run=True) @@ -164,3 +246,4 @@ def test_status_reports_codex_installation(self, temp_project_dir, install_runne assert "plugins/evolve-lite" in result.stdout assert "marketplace.json entry" in result.stdout assert ".codex/hooks.json entry" in result.stdout + assert "SessionStart sync hook" in result.stdout diff --git a/tests/platform_integrations/test_codex_sharing.py b/tests/platform_integrations/test_codex_sharing.py new file mode 100644 index 00000000..c6b30471 --- /dev/null +++ b/tests/platform_integrations/test_codex_sharing.py @@ -0,0 +1,631 @@ +"""Tests for the Codex sharing scripts.""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.platform_integrations, pytest.mark.e2e] + +_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/codex/plugins/evolve-lite" +SAVE_SCRIPT = _PLUGIN_ROOT / "skills/learn/scripts/save_entities.py" +RETRIEVE_SCRIPT = _PLUGIN_ROOT / "skills/recall/scripts/retrieve_entities.py" +PUBLISH_SCRIPT = _PLUGIN_ROOT / "skills/publish/scripts/publish.py" +SUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py" +UNSUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/unsubscribe/scripts/unsubscribe.py" +SYNC_SCRIPT = _PLUGIN_ROOT / "skills/sync/scripts/sync.py" +HOOK_INPUT = json.dumps({"prompt": "How do I write clean code?"}) + + +def run_script(script, project_dir, args=None, evolve_dir=None, stdin=None, expect_success=True): + env = {**os.environ} + if evolve_dir: + env["EVOLVE_DIR"] = str(evolve_dir) + return subprocess.run( + [sys.executable, str(script)] + (args or []), + input=stdin, + capture_output=True, + text=True, + cwd=str(project_dir), + env=env, + check=expect_success, + ) + + +class TestCodexSaveAndRetrieve: + def test_save_stamps_owner_and_private_visibility(self, temp_project_dir): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SAVE_SCRIPT, + project_dir=temp_project_dir, + args=["--user", "alice"], + evolve_dir=evolve_dir, + stdin=json.dumps({"entities": [{"type": "guideline", "content": "Write clear commit messages."}]}), + ) + files = list((evolve_dir / "entities" / "guideline").glob("*.md")) + assert len(files) == 1 + content = files[0].read_text() + assert "owner: alice" in content + assert "visibility: private" in content + + def test_save_ignores_incoming_owner_and_visibility(self, temp_project_dir): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SAVE_SCRIPT, + project_dir=temp_project_dir, + args=["--user", "alice"], + evolve_dir=evolve_dir, + stdin=json.dumps( + { + "entities": [ + { + "type": "guideline", + "content": "Prefer small, reversible changes.", + "owner": "mallory", + "visibility": "public", + } + ] + } + ), + ) + files = list((evolve_dir / "entities" / "guideline").glob("*.md")) + assert len(files) == 1 + content = files[0].read_text() + assert "owner: alice" in content + assert "owner: mallory" not in content + assert "visibility: private" in content + assert "visibility: public" not in content + + 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 / "tip.md").write_text("---\ntype: guideline\n---\n\nKeep functions small.\n") + sub_dir = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" + sub_dir.mkdir(parents=True) + (sub_dir / "alice-tip.md").write_text("---\ntype: guideline\nowner: alice\nvisibility: public\n---\n\nAlways write tests.\n") + + result = run_script( + RETRIEVE_SCRIPT, + project_dir=temp_project_dir, + evolve_dir=evolve_dir, + stdin=HOOK_INPUT, + 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 + + 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 / "tip.md").write_text("---\ntype: guideline\n---\n\nKeep functions small.\n") + public_dir = evolve_dir / "public" / "guideline" + public_dir.mkdir(parents=True) + (public_dir / "published-tip.md").write_text( + "---\ntype: guideline\nvisibility: public\nsource: alice/evolve-guidelines\n---\n\nDocument edge cases.\n" + ) + + result = run_script( + RETRIEVE_SCRIPT, + project_dir=temp_project_dir, + evolve_dir=evolve_dir, + stdin=HOOK_INPUT, + expect_success=False, + ) + assert result.returncode == 0 + assert "Keep functions small." in result.stdout + assert "Document edge cases." in result.stdout + assert "[from: alice/evolve-guidelines]" not in result.stdout + + +class TestCodexSharingScripts: + def test_publish_moves_entity_to_public_dir_and_audits(self, temp_project_dir): + guideline_dir = temp_project_dir / ".evolve" / "entities" / "guideline" + guideline_dir.mkdir(parents=True) + source = guideline_dir / "my-tip.md" + source.write_text("---\ntype: guideline\n---\n\nPrefer composition over inheritance.\n") + + run_script( + PUBLISH_SCRIPT, + project_dir=temp_project_dir, + args=["--entity", "my-tip.md", "--user", "alice"], + evolve_dir=temp_project_dir / ".evolve", + ) + + published = temp_project_dir / ".evolve" / "public" / "guideline" / "my-tip.md" + assert published.exists() + assert not source.exists() + content = published.read_text() + assert "visibility: public" in content + assert "owner: alice" in content + assert "published_at:" in content + + entry = json.loads((temp_project_dir / ".evolve" / "audit.log").read_text().strip()) + assert entry["action"] == "publish" + assert entry["actor"] == "alice" + + def test_publish_stamps_source_from_public_repo_remote(self, temp_project_dir): + guideline_dir = temp_project_dir / ".evolve" / "entities" / "guideline" + guideline_dir.mkdir(parents=True) + (guideline_dir / "my-tip.md").write_text("---\ntype: guideline\n---\n\nPrefer composition over inheritance.\n") + (temp_project_dir / "evolve.config.yaml").write_text( + 'public_repo:\n remote: "git@github.com:alice/evolve-guidelines.git"\n branch: "main"\n' + ) + + run_script( + PUBLISH_SCRIPT, + project_dir=temp_project_dir, + args=["--entity", "my-tip.md"], + evolve_dir=temp_project_dir / ".evolve", + ) + + content = (temp_project_dir / ".evolve" / "public" / "guideline" / "my-tip.md").read_text() + assert "source: alice/evolve-guidelines" in content + + def test_publish_uses_identity_user_as_owner_source_and_audit_fallback(self, temp_project_dir): + guideline_dir = temp_project_dir / ".evolve" / "entities" / "guideline" + guideline_dir.mkdir(parents=True) + (guideline_dir / "my-tip.md").write_text("---\ntype: guideline\n---\n\nPrefer composition over inheritance.\n") + (temp_project_dir / "evolve.config.yaml").write_text('identity:\n user: "alice"\n') + + run_script( + PUBLISH_SCRIPT, + project_dir=temp_project_dir, + args=["--entity", "my-tip.md"], + evolve_dir=temp_project_dir / ".evolve", + ) + + content = (temp_project_dir / ".evolve" / "public" / "guideline" / "my-tip.md").read_text() + assert "owner: alice" in content + assert "source: alice" in content + entry = json.loads((temp_project_dir / ".evolve" / "audit.log").read_text().strip()) + assert entry["actor"] == "alice" + + def test_publish_fails_when_entity_not_found(self, temp_project_dir): + result = run_script( + PUBLISH_SCRIPT, + project_dir=temp_project_dir, + args=["--entity", "missing.md"], + evolve_dir=temp_project_dir / ".evolve", + expect_success=False, + ) + assert result.returncode != 0 + assert "not found" in result.stderr + + def test_publish_succeeds_without_user_flag(self, temp_project_dir): + guideline_dir = temp_project_dir / ".evolve" / "entities" / "guideline" + guideline_dir.mkdir(parents=True) + (guideline_dir / "my-tip.md").write_text("---\ntype: guideline\n---\n\nPrefer composition.\n") + + run_script( + PUBLISH_SCRIPT, + project_dir=temp_project_dir, + args=["--entity", "my-tip.md"], + evolve_dir=temp_project_dir / ".evolve", + ) + content = (temp_project_dir / ".evolve" / "public" / "guideline" / "my-tip.md").read_text() + assert "visibility: public" in content + + def test_publish_fails_when_public_entity_already_exists(self, temp_project_dir): + guideline_dir = temp_project_dir / ".evolve" / "entities" / "guideline" + guideline_dir.mkdir(parents=True) + source = guideline_dir / "my-tip.md" + source.write_text("---\ntype: guideline\n---\n\nPrefer composition.\n") + + public_path = temp_project_dir / ".evolve" / "public" / "guideline" / "my-tip.md" + public_path.parent.mkdir(parents=True) + existing_content = "---\ntype: guideline\nvisibility: public\n---\n\nExisting public content.\n" + public_path.write_text(existing_content) + + result = run_script( + PUBLISH_SCRIPT, + project_dir=temp_project_dir, + args=["--entity", "my-tip.md"], + evolve_dir=temp_project_dir / ".evolve", + expect_success=False, + ) + + assert result.returncode != 0 + assert "already published" in result.stderr + assert public_path.read_text() == existing_content + assert source.exists() + + @pytest.mark.parametrize("entity_name", ["../../etc/passwd", "subdir/tip.md", ".", ".."]) + def test_publish_rejects_invalid_entity_name(self, temp_project_dir, entity_name): + guideline_dir = temp_project_dir / ".evolve" / "entities" / "guideline" + guideline_dir.mkdir(parents=True) + (guideline_dir / "tip.md").write_text("---\ntype: guideline\n---\n\nA tip.\n") + result = run_script( + PUBLISH_SCRIPT, + project_dir=temp_project_dir, + args=["--entity", entity_name], + evolve_dir=temp_project_dir / ".evolve", + expect_success=False, + ) + assert result.returncode != 0 + assert "invalid entity name" in result.stderr + + def test_subscribe_sync_and_unsubscribe_round_trip(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + assert (evolve_dir / "entities" / "subscribed" / "alice").is_dir() + assert not (evolve_dir / "subscribed" / "alice").exists() + + run_script(SYNC_SCRIPT, project_dir=temp_project_dir, evolve_dir=evolve_dir) + synced = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" / "tip-one.md" + assert synced.exists() + assert "Always write tests." in synced.read_text() + + run_script( + UNSUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice"], + evolve_dir=evolve_dir, + ) + assert not (evolve_dir / "subscribed" / "alice").exists() + assert not (evolve_dir / "entities" / "subscribed" / "alice").exists() + + def test_subscribe_updates_config_and_rejects_duplicate(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + config_text = (temp_project_dir / "evolve.config.yaml").read_text() + assert 'name: "alice"' in config_text + + result = run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + expect_success=False, + ) + assert result.returncode != 0 + assert "already exists" in result.stderr + + def test_subscribe_rejects_path_traversal_in_name(self, temp_project_dir, local_repo): + result = run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "../../evil", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=temp_project_dir / ".evolve", + expect_success=False, + ) + assert result.returncode != 0 + assert "invalid subscription name" in result.stderr + + @pytest.mark.parametrize("name", ["", "."]) + def test_subscribe_rejects_empty_or_dot_name(self, temp_project_dir, local_repo, name): + result = run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", name, "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=temp_project_dir / ".evolve", + expect_success=False, + ) + + assert result.returncode != 0 + assert "invalid subscription name" in result.stderr + + def test_subscribe_fails_when_destination_already_exists(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + existing_dest = evolve_dir / "entities" / "subscribed" / "alice" + existing_dest.mkdir(parents=True) + + result = run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + expect_success=False, + ) + + assert result.returncode != 0 + assert "destination already exists" in result.stderr + config_path = temp_project_dir / "evolve.config.yaml" + assert not config_path.exists() + + def test_unsubscribe_list_and_not_found(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + result = run_script( + UNSUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--list"], + evolve_dir=evolve_dir, + ) + data = json.loads(result.stdout) + assert data[0]["name"] == "alice" + + missing = run_script( + UNSUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "missing"], + evolve_dir=evolve_dir, + expect_success=False, + ) + assert missing.returncode != 0 + assert "not found" in missing.stderr + + def test_unsubscribe_rejects_path_traversal_in_name(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + result = run_script( + UNSUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "../../evil"], + evolve_dir=evolve_dir, + expect_success=False, + ) + assert result.returncode != 0 + assert "invalid subscription name" in result.stderr + + def test_sync_quiet_exits_cleanly_without_changes(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + run_script(SYNC_SCRIPT, project_dir=temp_project_dir, evolve_dir=evolve_dir) + result = run_script( + SYNC_SCRIPT, + project_dir=temp_project_dir, + args=["--quiet"], + evolve_dir=evolve_dir, + expect_success=False, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + def test_sync_no_subscriptions_exits_cleanly(self, temp_project_dir): + result = run_script( + SYNC_SCRIPT, + project_dir=temp_project_dir, + evolve_dir=temp_project_dir / ".evolve", + expect_success=False, + ) + assert result.returncode == 0 + assert "No subscriptions" in result.stdout + assert "evolve-lite:subscribe" in result.stdout + + def test_sync_skips_invalid_subscription_name(self, temp_project_dir): + evolve_dir = temp_project_dir / ".evolve" + (temp_project_dir / "evolve.config.yaml").write_text( + 'subscriptions:\n - name: "."\n remote: "https://example.com/repo.git"\n branch: "main"\n' + ) + + result = run_script( + SYNC_SCRIPT, + project_dir=temp_project_dir, + evolve_dir=evolve_dir, + expect_success=False, + ) + + assert result.returncode == 0 + assert "'.' (skipped - invalid subscription name)" in result.stdout + assert not (evolve_dir / "entities" / "subscribed").exists() + + def test_sync_uses_workspace_config_with_custom_evolve_dir(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / "custom-evolve" + subscribed_dir = evolve_dir / "entities" / "subscribed" + subscribed_dir.mkdir(parents=True) + subprocess.run( + ["git", "clone", "--branch", "main", "--depth", "1", str(local_repo["bare"]), str(subscribed_dir / "alice")], + check=True, + env=local_repo["env"], + ) + (temp_project_dir / "evolve.config.yaml").write_text( + f'identity:\n user: "alice"\nsubscriptions:\n - name: "alice"\n remote: "{local_repo["bare"]}"\n branch: "main"\n' + ) + + result = run_script( + SYNC_SCRIPT, + project_dir=temp_project_dir, + evolve_dir=evolve_dir, + expect_success=False, + ) + + assert result.returncode == 0 + mirrored = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" / "tip-one.md" + assert mirrored.exists() + audit_log = evolve_dir / ".evolve" / "audit.log" + assert audit_log.exists() + entry = json.loads(audit_log.read_text().strip()) + assert entry["action"] == "sync" + assert entry["actor"] == "alice" + + def test_manual_sync_runs_even_when_on_session_start_is_false(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + (temp_project_dir / "evolve.config.yaml").write_text( + f'subscriptions:\n - name: "alice"\n remote: "{local_repo["bare"]}"\n branch: "main"\nsync:\n on_session_start: false\n' + ) + + result = run_script( + SYNC_SCRIPT, + project_dir=temp_project_dir, + evolve_dir=evolve_dir, + expect_success=False, + ) + + assert result.returncode == 0 + assert "Synced 1 repo(s):" in result.stdout + + def test_session_start_sync_respects_on_session_start_false(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + git_env = local_repo["env"] + new_entity = local_repo["work"] / "guideline" / "tip-two.md" + new_entity.write_text("---\ntype: guideline\n---\n\nDelete dead code promptly.\n") + subprocess.run(["git", "-C", str(local_repo["work"]), "add", "."], check=True, env=git_env) + subprocess.run(["git", "-C", str(local_repo["work"]), "commit", "-m", "add tip-two"], check=True, env=git_env) + subprocess.run(["git", "-C", str(local_repo["work"]), "push", "origin", "main"], check=True, env=git_env) + (temp_project_dir / "evolve.config.yaml").write_text( + f'subscriptions:\n - name: "alice"\n remote: "{local_repo["bare"]}"\n branch: "main"\nsync:\n on_session_start: false\n' + ) + + result = run_script( + SYNC_SCRIPT, + project_dir=temp_project_dir, + args=["--quiet", "--session-start"], + evolve_dir=evolve_dir, + expect_success=False, + ) + + assert result.returncode == 0 + synced = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" / "tip-two.md" + assert not synced.exists() + + def test_sync_writes_audit_log(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + run_script(SYNC_SCRIPT, project_dir=temp_project_dir, evolve_dir=evolve_dir) + actions = [ + json.loads(line)["action"] for line in (temp_project_dir / ".evolve" / "audit.log").read_text().splitlines() if line.strip() + ] + assert "sync" in actions + + def test_sync_picks_up_new_entity_after_push(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + run_script(SYNC_SCRIPT, project_dir=temp_project_dir, evolve_dir=evolve_dir) + + git_env = local_repo["env"] + new_entity = local_repo["work"] / "guideline" / "tip-two.md" + new_entity.write_text("---\ntype: guideline\n---\n\nDelete dead code promptly.\n") + subprocess.run(["git", "-C", str(local_repo["work"]), "add", "."], check=True, env=git_env) + subprocess.run(["git", "-C", str(local_repo["work"]), "commit", "-m", "add tip-two"], check=True, env=git_env) + subprocess.run(["git", "-C", str(local_repo["work"]), "push", "origin", "main"], check=True, env=git_env) + + run_script(SYNC_SCRIPT, project_dir=temp_project_dir, evolve_dir=evolve_dir) + mirrored = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" / "tip-two.md" + assert mirrored.exists() + assert "Delete dead code promptly." in mirrored.read_text() + + def test_sync_skips_symlinked_markdown_files(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + + git_env = local_repo["env"] + target = local_repo["work"] / "guideline" / "tip-one.md" + symlink = local_repo["work"] / "guideline" / "tip-link.md" + symlink.symlink_to(target.name) + subprocess.run(["git", "-C", str(local_repo["work"]), "add", "."], check=True, env=git_env) + subprocess.run(["git", "-C", str(local_repo["work"]), "commit", "-m", "add tip symlink"], check=True, env=git_env) + subprocess.run(["git", "-C", str(local_repo["work"]), "push", "origin", "main"], check=True, env=git_env) + + run_script(SYNC_SCRIPT, project_dir=temp_project_dir, evolve_dir=evolve_dir) + + subscribed_dir = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" + assert (subscribed_dir / "tip-one.md").exists() + assert (subscribed_dir / "tip-link.md").exists() + + result = run_script( + RETRIEVE_SCRIPT, + project_dir=temp_project_dir, + evolve_dir=evolve_dir, + stdin=HOOK_INPUT, + expect_success=False, + ) + assert result.returncode == 0 + assert "Always write tests." in result.stdout + assert "tip-link" not in result.stdout + + def test_sync_removed_entity_disappears_after_sync(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + run_script( + SUBSCRIBE_SCRIPT, + project_dir=temp_project_dir, + args=["--name", "alice", "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + ) + run_script(SYNC_SCRIPT, project_dir=temp_project_dir, evolve_dir=evolve_dir) + + tip_one = evolve_dir / "entities" / "subscribed" / "alice" / "guideline" / "tip-one.md" + assert tip_one.exists() + + git_env = local_repo["env"] + subprocess.run(["git", "-C", str(local_repo["work"]), "rm", "guideline/tip-one.md"], check=True, env=git_env) + subprocess.run(["git", "-C", str(local_repo["work"]), "commit", "-m", "remove tip-one"], check=True, env=git_env) + subprocess.run(["git", "-C", str(local_repo["work"]), "push", "origin", "main"], check=True, env=git_env) + + run_script(SYNC_SCRIPT, project_dir=temp_project_dir, evolve_dir=evolve_dir) + assert not tip_one.exists() + + def test_sync_migrates_legacy_subscribed_repo(self, temp_project_dir, local_repo): + evolve_dir = temp_project_dir / ".evolve" + legacy_dir = evolve_dir / "subscribed" + legacy_dir.mkdir(parents=True) + subprocess.run( + ["git", "clone", "--branch", "main", "--depth", "1", str(local_repo["bare"]), str(legacy_dir / "alice")], + check=True, + env=local_repo["env"], + ) + (temp_project_dir / "evolve.config.yaml").write_text( + f'subscriptions:\n - name: "alice"\n remote: "{local_repo["bare"]}"\n branch: "main"\n' + ) + + result = run_script( + SYNC_SCRIPT, + project_dir=temp_project_dir, + evolve_dir=evolve_dir, + expect_success=False, + ) + + assert result.returncode == 0 + migrated_repo = evolve_dir / "entities" / "subscribed" / "alice" + assert migrated_repo.is_dir() + assert not (evolve_dir / "subscribed" / "alice").exists() + assert (migrated_repo / "guideline" / "tip-one.md").exists() diff --git a/tests/platform_integrations/test_entity_io.py b/tests/platform_integrations/test_entity_io.py index 7e68c3d8..33b1dd8c 100644 --- a/tests/platform_integrations/test_entity_io.py +++ b/tests/platform_integrations/test_entity_io.py @@ -63,6 +63,26 @@ def test_find_entities_dir_env_missing_subdir_returns_none(monkeypatch, tmp_path assert entity_io.find_entities_dir() is None +@pytest.mark.unit +def test_find_recall_entity_dirs_includes_entities_and_public(monkeypatch, temp_project_dir): + custom = temp_project_dir / "custom-evolve" + entities = custom / "entities" + public = custom / "public" + entities.mkdir(parents=True) + public.mkdir(parents=True) + monkeypatch.setenv("EVOLVE_DIR", str(custom)) + assert entity_io.find_recall_entity_dirs() == [entities, public] + + +@pytest.mark.unit +def test_find_recall_entity_dirs_skips_missing_locations(monkeypatch, temp_project_dir): + custom = temp_project_dir / "custom-evolve" + public = custom / "public" + public.mkdir(parents=True) + monkeypatch.setenv("EVOLVE_DIR", str(custom)) + assert entity_io.find_recall_entity_dirs() == [public] + + # --------------------------------------------------------------------------- # get_default_entities_dir # --------------------------------------------------------------------------- diff --git a/tests/platform_integrations/test_entity_io_core.py b/tests/platform_integrations/test_entity_io_core.py index 9bf745f2..4294de2b 100644 --- a/tests/platform_integrations/test_entity_io_core.py +++ b/tests/platform_integrations/test_entity_io_core.py @@ -15,7 +15,7 @@ ) import entity_io -pytestmark = pytest.mark.platform_integrations +pytestmark = [pytest.mark.platform_integrations, pytest.mark.unit] class TestSlugify: diff --git a/tests/platform_integrations/test_preservation.py b/tests/platform_integrations/test_preservation.py index 1271cf4f..6296d6da 100644 --- a/tests/platform_integrations/test_preservation.py +++ b/tests/platform_integrations/test_preservation.py @@ -201,7 +201,15 @@ def test_preserves_existing_hooks_and_plugin_files(self, temp_project_dir, insta current_hooks = json.loads(hooks_file.read_text()) session_start_hooks = current_hooks["hooks"]["SessionStart"] - assert len(session_start_hooks) == 1, "User's SessionStart hook was removed!" + assert len(session_start_hooks) == 2, "Expected the user's SessionStart hook plus the Evolve sync hook." + assert any( + any(hook.get("command") == "python3 ~/.codex/hooks/session_start.py" for hook in group.get("hooks", [])) + for group in session_start_hooks + ), "User's SessionStart hook was removed!" + assert any( + any("plugins/evolve-lite/skills/sync/scripts/sync.py" in hook.get("command", "") for hook in group.get("hooks", [])) + for group in session_start_hooks + ), "Evolve SessionStart hook was not added!" prompt_hooks = current_hooks["hooks"]["UserPromptSubmit"] custom_prompt_hooks = [ diff --git a/tests/platform_integrations/test_publish.py b/tests/platform_integrations/test_publish.py index 2dfee8f9..20bab82e 100644 --- a/tests/platform_integrations/test_publish.py +++ b/tests/platform_integrations/test_publish.py @@ -10,8 +10,14 @@ pytestmark = pytest.mark.platform_integrations -_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/claude/plugins/evolve-lite" -PUBLISH_SCRIPT = _PLUGIN_ROOT / "skills/publish/scripts/publish.py" +_REPO_ROOT = Path(__file__).parent.parent.parent +CLAUDE_PUBLISH_SCRIPT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py" +CODEX_PUBLISH_SCRIPT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py" +PUBLISH_SCRIPT = CLAUDE_PUBLISH_SCRIPT +PUBLISH_SCRIPT_VARIANTS = [ + ("claude", CLAUDE_PUBLISH_SCRIPT), + ("codex", CODEX_PUBLISH_SCRIPT), +] @pytest.fixture @@ -35,10 +41,24 @@ def run_publish(project_dir, args, expect_success=True): ) +def run_publish_script(script_path, project_dir, args, expect_success=True): + env = {**os.environ, "EVOLVE_DIR": str(project_dir / ".evolve")} + return subprocess.run( + [sys.executable, str(script_path)] + args, + capture_output=True, + text=True, + cwd=str(project_dir), + env=env, + check=expect_success, + ) + + class TestPublish: - def test_copies_entity_to_public_dir(self, project_dir): + def test_moves_entity_to_public_dir(self, project_dir): + source = project_dir / ".evolve" / "entities" / "guideline" / "my-tip.md" run_publish(project_dir, ["--entity", "my-tip.md"]) assert (project_dir / ".evolve" / "public" / "guideline" / "my-tip.md").exists() + assert not source.exists() def test_sets_visibility_public(self, project_dir): run_publish(project_dir, ["--entity", "my-tip.md"]) @@ -55,6 +75,11 @@ def test_stamps_owner_when_user_flag_given(self, project_dir): content = (project_dir / ".evolve" / "public" / "guideline" / "my-tip.md").read_text() assert "owner: alice" in content + def test_stamps_source_from_user_when_user_flag_given(self, project_dir): + run_publish(project_dir, ["--entity", "my-tip.md", "--user", "alice"]) + content = (project_dir / ".evolve" / "public" / "guideline" / "my-tip.md").read_text() + assert "source: alice" in content + def test_preserves_original_content(self, project_dir): run_publish(project_dir, ["--entity", "my-tip.md"]) content = (project_dir / ".evolve" / "public" / "guideline" / "my-tip.md").read_text() @@ -95,3 +120,15 @@ def test_rejects_path_traversal_in_entity_name(self, project_dir): result = run_publish(project_dir, ["--entity", "../../etc/passwd"], expect_success=False) assert result.returncode != 0 assert "invalid entity name" in result.stderr + + +@pytest.mark.parametrize(("platform_name", "publish_script"), PUBLISH_SCRIPT_VARIANTS) +def test_publish_rejects_directory_entity_path(temp_project_dir, publish_script, platform_name): + guideline_dir = temp_project_dir / ".evolve" / "entities" / "guideline" + entity_dir = guideline_dir / "my-tip.md" + entity_dir.mkdir(parents=True) + + result = run_publish_script(publish_script, temp_project_dir, ["--entity", "my-tip.md"], expect_success=False) + assert result.returncode != 0 + assert "not found or is a directory" in result.stderr + assert entity_dir.is_dir() diff --git a/tests/platform_integrations/test_retrieve.py b/tests/platform_integrations/test_retrieve.py index 30e6cfa4..46916154 100644 --- a/tests/platform_integrations/test_retrieve.py +++ b/tests/platform_integrations/test_retrieve.py @@ -10,19 +10,24 @@ pytestmark = pytest.mark.platform_integrations -_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/claude/plugins/evolve-lite" -RETRIEVE_SCRIPT = _PLUGIN_ROOT / "skills/recall/scripts/retrieve_entities.py" +_REPO_ROOT = Path(__file__).parent.parent.parent +CLAUDE_RETRIEVE_SCRIPT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" +CODEX_RETRIEVE_SCRIPT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" +SCRIPT_VARIANTS = [ + ("claude", CLAUDE_RETRIEVE_SCRIPT, "Entities for this task"), + ("codex", CODEX_RETRIEVE_SCRIPT, "Evolve entities 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(evolve_dir=None, stdin_data=None): +def run_retrieve(script_path, evolve_dir=None, stdin_data=None): env = {**os.environ} if evolve_dir: env["EVOLVE_DIR"] = str(evolve_dir) return subprocess.run( - [sys.executable, str(RETRIEVE_SCRIPT)], + [sys.executable, str(script_path)], input=stdin_data or HOOK_INPUT, capture_output=True, text=True, @@ -50,65 +55,75 @@ def evolve_dir(temp_project_dir): class TestRetrieve: - def test_exits_cleanly_with_no_output_when_no_entities_dir(self, temp_project_dir): - result = run_retrieve(evolve_dir=temp_project_dir / ".evolve") + @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") assert result.returncode == 0 assert result.stdout.strip() == "" - def test_outputs_owned_entities(self, evolve_dir): - result = run_retrieve(evolve_dir=evolve_dir) + @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) assert result.returncode == 0 assert "Keep functions small." in result.stdout - def test_annotates_subscribed_entities_with_from_source(self, evolve_dir): - result = run_retrieve(evolve_dir=evolve_dir) + @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_owned_entities_not_annotated_with_from(self, evolve_dir): - result = run_retrieve(evolve_dir=evolve_dir) + @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_output_includes_type_annotation(self, evolve_dir): - result = run_retrieve(evolve_dir=evolve_dir) + @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_handles_invalid_json_stdin_gracefully(self, evolve_dir): - result = run_retrieve(evolve_dir=evolve_dir, stdin_data="not valid json") + @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") assert result.returncode == 0 assert result.stdout.strip() == "" - def test_output_has_header(self, evolve_dir): - result = run_retrieve(evolve_dir=evolve_dir) - assert "Entities for this task" in result.stdout + @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) + assert expected_header in result.stdout - def test_public_entities_included_in_recall(self, temp_project_dir): + @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) + def test_public_entities_included_in_recall(self, temp_project_dir, retrieve_script, expected_header, platform_name): d = temp_project_dir / ".evolve" (d / "public" / "guideline").mkdir(parents=True) (d / "public" / "guideline" / "pub.md").write_text( "---\ntype: guideline\nvisibility: public\n---\n\nPrefer immutable data structures.\n" ) - result = run_retrieve(evolve_dir=d) + result = run_retrieve(retrieve_script, evolve_dir=d) assert result.returncode == 0 assert "Prefer immutable data structures." in result.stdout - def test_public_entities_not_annotated_with_from(self, temp_project_dir): + @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) + def test_public_entities_not_annotated_with_from(self, temp_project_dir, retrieve_script, expected_header, platform_name): d = temp_project_dir / ".evolve" (d / "public" / "guideline").mkdir(parents=True) (d / "public" / "guideline" / "pub.md").write_text( "---\ntype: guideline\nvisibility: public\n---\n\nPrefer immutable data structures.\n" ) - result = run_retrieve(evolve_dir=d) + result = run_retrieve(retrieve_script, evolve_dir=d) pub_lines = [line for line in result.stdout.splitlines() if "Prefer immutable data structures." in line] assert pub_lines assert not any("[from:" in line for line in pub_lines) - def test_entities_with_trigger_include_when_line(self, temp_project_dir): + @pytest.mark.parametrize(("platform_name", "retrieve_script", "expected_header"), SCRIPT_VARIANTS) + def test_entities_with_trigger_include_when_line(self, temp_project_dir, retrieve_script, expected_header, platform_name): d = temp_project_dir / ".evolve" gdir = d / "entities" / "guideline" gdir.mkdir(parents=True) (gdir / "tip.md").write_text("---\ntype: guideline\ntrigger: when writing tests\n---\n\nAssert the important thing.\n") - result = run_retrieve(evolve_dir=d) + result = run_retrieve(retrieve_script, evolve_dir=d) assert "when writing tests" in result.stdout diff --git a/tests/platform_integrations/test_save_entities.py b/tests/platform_integrations/test_save_entities.py index 8a4e0a7b..33cd6500 100644 --- a/tests/platform_integrations/test_save_entities.py +++ b/tests/platform_integrations/test_save_entities.py @@ -8,7 +8,7 @@ import pytest -pytestmark = pytest.mark.platform_integrations +pytestmark = [pytest.mark.platform_integrations, pytest.mark.e2e] _PLUGIN_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/claude/plugins/evolve-lite" SAVE_SCRIPT = _PLUGIN_ROOT / "skills/learn/scripts/save_entities.py" diff --git a/tests/platform_integrations/test_subscribe.py b/tests/platform_integrations/test_subscribe.py index f234a30b..d672da22 100644 --- a/tests/platform_integrations/test_subscribe.py +++ b/tests/platform_integrations/test_subscribe.py @@ -14,11 +14,17 @@ ) import config as cfg_module -pytestmark = pytest.mark.platform_integrations +pytestmark = [pytest.mark.platform_integrations, pytest.mark.e2e] -_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/claude/plugins/evolve-lite" -SUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py" -UNSUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/unsubscribe/scripts/unsubscribe.py" +_REPO_ROOT = Path(__file__).parent.parent.parent +CLAUDE_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite" +CODEX_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite" +SUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py" +UNSUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/unsubscribe/scripts/unsubscribe.py" +SUBSCRIBE_SCRIPT_VARIANTS = [ + ("claude", CLAUDE_PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py"), + ("codex", CODEX_PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py"), +] def run_script(script, project_dir, args, evolve_dir=None, expect_success=True): @@ -35,6 +41,21 @@ def run_script(script, project_dir, args, evolve_dir=None, expect_success=True): ) +@pytest.mark.parametrize(("platform_name", "subscribe_script"), SUBSCRIBE_SCRIPT_VARIANTS) +@pytest.mark.parametrize("bad_name", ["foo/bar", "../etc", "alice:bob", "alice bob"]) +def test_subscribe_rejects_invalid_name_characters(temp_project_dir, local_repo, subscribe_script, platform_name, bad_name): + evolve_dir = temp_project_dir / ".evolve" + result = run_script( + subscribe_script, + temp_project_dir, + ["--name", bad_name, "--remote", str(local_repo["bare"]), "--branch", "main"], + evolve_dir=evolve_dir, + expect_success=False, + ) + assert result.returncode != 0 + assert "invalid subscription name" in result.stderr + + class TestSubscribe: def test_clones_remote_into_subscribed_dir(self, temp_project_dir, local_repo): evolve_dir = temp_project_dir / ".evolve" diff --git a/tests/platform_integrations/test_sync.py b/tests/platform_integrations/test_sync.py index e30fa0ef..0c7eef8b 100644 --- a/tests/platform_integrations/test_sync.py +++ b/tests/platform_integrations/test_sync.py @@ -8,11 +8,17 @@ import pytest -pytestmark = pytest.mark.platform_integrations +pytestmark = [pytest.mark.platform_integrations, pytest.mark.e2e] -_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/claude/plugins/evolve-lite" -SUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py" -SYNC_SCRIPT = _PLUGIN_ROOT / "skills/sync/scripts/sync.py" +_REPO_ROOT = Path(__file__).parent.parent.parent +CLAUDE_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite" +CODEX_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite" +SUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py" +SYNC_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/sync/scripts/sync.py" +SYNC_SCRIPT_VARIANTS = [ + ("claude", CLAUDE_PLUGIN_ROOT / "skills/sync/scripts/sync.py"), + ("codex", CODEX_PLUGIN_ROOT / "skills/sync/scripts/sync.py"), +] def run_script(script, project_dir, args=None, evolve_dir=None, expect_success=True): @@ -29,6 +35,28 @@ def run_script(script, project_dir, args=None, evolve_dir=None, expect_success=T ) +@pytest.mark.parametrize(("platform_name", "sync_script"), SYNC_SCRIPT_VARIANTS) +@pytest.mark.parametrize( + "config_text", + [ + "subscriptions:\n - name: 123\n remote: git@github.com:x/y.git\n branch: main\n", + "subscriptions:\n - name: alice\n remote: git@github.com:x/y.git\n branch: 123\n", + 'subscriptions:\n - name: " "\n remote: git@github.com:x/y.git\n branch: main\n', + 'subscriptions:\n - name: alice\n remote: git@github.com:x/y.git\n branch: " "\n', + ], +) +def test_sync_skips_malformed_subscription_entries(temp_project_dir, sync_script, platform_name, config_text): + evolve_dir = temp_project_dir / ".evolve" + cfg_path = temp_project_dir / "evolve.config.yaml" + cfg_path.write_text(config_text) + + result = run_script(sync_script, temp_project_dir, evolve_dir=evolve_dir) + assert result.returncode == 0 + assert "skipped" in result.stdout + assert "Traceback" not in result.stderr + assert not (evolve_dir / "entities" / "subscribed" / "alice").exists() + + @pytest.fixture def subscribed_project(temp_project_dir, local_repo): """A project already subscribed to local_repo.""" @@ -118,6 +146,18 @@ def test_skips_symlinked_entities(self, subscribed_project): real_file.write_text("---\ntype: guideline\n---\n\nReal content.\n") symlink_file = lr["work"] / "guideline" / "link.md" symlink_file.symlink_to(real_file) + git_env = lr["env"] + subprocess.run(["git", "-C", str(lr["work"]), "add", "."], check=True, env=git_env) + subprocess.run( + ["git", "-C", str(lr["work"]), "commit", "-m", "add symlinked entity"], + check=True, + env=git_env, + ) + subprocess.run( + ["git", "-C", str(lr["work"]), "push", "origin", "main"], + check=True, + env=git_env, + ) run_script(SYNC_SCRIPT, p["project_dir"], evolve_dir=p["evolve_dir"]) mirrored = p["evolve_dir"] / "entities" / "subscribed" / "alice" / "guideline" assert not (mirrored / "link.md").exists() @@ -130,7 +170,7 @@ def test_skips_invalid_subscription_name(self, temp_project_dir): result = run_script(SYNC_SCRIPT, temp_project_dir, evolve_dir=evolve_dir) assert result.returncode == 0 assert "invalid subscription name" in result.stdout - assert not (evolve_dir / "subscribed" / ".." / "evil").exists() + assert not (evolve_dir / "entities" / "evil").exists() def test_manual_run_ignores_on_session_start_false(self, subscribed_project): p = subscribed_project