From 6fb548dc47da7e51cbd3985e2051b1fd7705953b Mon Sep 17 00:00:00 2001 From: Vatche Isahagian Date: Mon, 30 Mar 2026 13:31:06 -0400 Subject: [PATCH 1/4] feat(platform-integrations): add codex evolve-lite installer --- platform-integrations/INSTALL_SPEC.md | 39 +++- .../evolve-lite/.codex-plugin/plugin.json | 29 +++ .../codex/plugins/evolve-lite/README.md | 58 +++++ .../plugins/evolve-lite/skills/learn/SKILL.md | 117 ++++++++++ .../skills/learn/scripts/save_entities.py | 97 ++++++++ .../evolve-lite/skills/recall/SKILL.md | 52 +++++ .../recall/scripts/retrieve_entities.py | 89 +++++++ platform-integrations/install.sh | 220 +++++++++++++++++- tests/platform_integrations/conftest.py | 104 ++++++++- tests/platform_integrations/test_codex.py | 72 ++++++ .../platform_integrations/test_idempotency.py | 101 ++++++++ .../test_preservation.py | 77 +++++- 12 files changed, 1034 insertions(+), 21 deletions(-) create mode 100644 platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json create mode 100644 platform-integrations/codex/plugins/evolve-lite/README.md create mode 100644 platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md create mode 100644 platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py create mode 100644 platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md create mode 100644 platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py create mode 100644 tests/platform_integrations/test_codex.py diff --git a/platform-integrations/INSTALL_SPEC.md b/platform-integrations/INSTALL_SPEC.md index 3e238c4..cef71fb 100644 --- a/platform-integrations/INSTALL_SPEC.md +++ b/platform-integrations/INSTALL_SPEC.md @@ -3,7 +3,7 @@ ## Overview `install.sh` is a single-file bash/Python hybrid installer that sets up Evolve integrations -into a user's project directory for one or more supported platforms: **Bob**, **Roo**, and **Claude**. +into a user's project directory for one or more supported platforms: **Bob**, **Roo**, **Claude**, and **Codex**. It is designed to be run: - Locally from within the evolve repo: `./install.sh install` @@ -48,13 +48,13 @@ Commands: status Show what is currently installed install options: - --platform {bob,roo,claude,all} Platform to install (default: auto-detect + prompt) + --platform {bob,roo,claude,codex,all} Platform to install (default: auto-detect + prompt) --mode {lite,full} Installation mode for bob (default: lite) --dir DIR Target project directory (default: current working dir) --dry-run Preview changes without modifying files uninstall options: - --platform {bob,roo,claude,all} Platform to uninstall (default: prompt) + --platform {bob,roo,claude,codex,all} Platform to uninstall (default: prompt) --dir DIR Target project directory (default: current working dir) --dry-run Preview changes without modifying files ``` @@ -70,6 +70,7 @@ Detection checks in order (any match = platform considered available): | bob | `.bob/` dir exists in target dir, OR `bob` on PATH | | roo | `.roomodes` file exists in target dir, OR `roo` or `roo-code` on PATH | | claude | `.claude/` dir exists in target dir, OR `claude` on PATH | +| codex | `.codex/` dir exists in target dir, OR `.agents/plugins/marketplace.json` exists, OR `codex` on PATH | If no `--platform` flag is given, the script runs interactively: shows detected platforms, lets the user pick one, multiple, or all. @@ -105,7 +106,7 @@ Target: project directory 3. Merge mode entry from `skills/.roomodes` → `.roomodes` in project dir - Target `.roomodes` may be JSON or YAML; detected by trying `json.loads` first - Upsert by `slug: evolve-lite` (JSON: array upsert; YAML: sentinel block) - - If target does not exist, create as JSON + - If target does not exist, create as YAML ### Claude — Lite Mode @@ -118,6 +119,18 @@ Source: `platform-integrations/claude/plugins/evolve-lite/` ``` 3. No file-system fallback for Claude (plugin system manages its own state) +### Codex — Lite Mode + +Source: `platform-integrations/codex/plugins/evolve-lite/` +Target: project directory + +1. Copy `platform-integrations/codex/plugins/evolve-lite/` → `plugins/evolve-lite/` in the target project +2. Copy shared lib from `platform-integrations/claude/plugins/evolve-lite/lib/` → `plugins/evolve-lite/lib/` +3. Upsert plugin entry `evolve-lite` into `.agents/plugins/marketplace.json` +4. Upsert a `UserPromptSubmit` hook into `.codex/hooks.json` that runs the Evolve recall helper script + +Codex is currently implemented only in lite mode. Full mode is reserved for future MCP-backed work. + --- ## Uninstall Actions @@ -138,11 +151,16 @@ Source: `platform-integrations/claude/plugins/evolve-lite/` 1. Attempt `claude plugin uninstall evolve-lite` via subprocess 2. If that fails, print manual instructions +### Codex +1. Remove `plugins/evolve-lite/` +2. Remove the `evolve-lite` entry from `.agents/plugins/marketplace.json` +3. Remove the Evolve `UserPromptSubmit` hook from `.codex/hooks.json` + --- ## File Operation Strategies -### JSON Strategy (mcp.json, .roomodes) +### JSON Strategy (mcp.json, .roomodes, marketplace.json, hooks.json) All JSON writes use atomic read-modify-write: 1. Read existing file (or start with `{}` if not found) @@ -150,9 +168,9 @@ All JSON writes use atomic read-modify-write: 3. Write to `.evolve.tmp` 4. `os.replace(tmp, path)` — atomic on POSIX -**Key upsert** (`mcpServers.evolve`): navigate nested keys via `dict.setdefault`, set leaf value. +**Key upsert** (`mcpServers.evolve`, `hooks.UserPromptSubmit` scaffolding): navigate nested keys via `dict.setdefault`, set leaf value. -**Array upsert** (`.roomodes` `customModes`): iterate array, find item where `item["slug"] == target_slug`, +**Array upsert** (`.roomodes` `customModes`, `marketplace.json` `plugins`): iterate array, find item where the identity key matches, replace in-place; append if not found. **Array remove**: filter array by `item["slug"] != target_slug`, write back. @@ -165,14 +183,14 @@ YAML files use sentinel comment blocks: customModes: - slug: other-mode ... -# >>>evolve-lite<<< +# >>>evolve:evolve-lite<<< - slug: evolve-lite name: Evolve Lite ... -# <<>>evolve-lite<<<` exists in file. If yes, replace the block +**Install**: check if sentinel `# >>>evolve:evolve-lite<<<` exists in file. If yes, replace the block between sentinels. If no, append sentinel block to end of file. **Uninstall**: find sentinel start and end lines, remove all lines between them (inclusive). @@ -190,6 +208,7 @@ All operations are safe to run multiple times: - JSON writes upsert (replace-if-exists, insert-if-not) - YAML writes check for sentinel before appending - Claude plugin install is idempotent by the Claude CLI itself +- Codex marketplace and hook writes replace matching Evolve entries and preserve user-owned entries --- diff --git a/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json b/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json new file mode 100644 index 0000000..bd80923 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "evolve-lite", + "version": "1.0.0", + "description": "Recall and save Evolve entities in Codex without MCP.", + "author": { + "name": "Vinod Muthusamy", + "url": "https://github.com/AgentToolkit/altk-evolve" + }, + "homepage": "https://github.com/AgentToolkit/altk-evolve", + "repository": "https://github.com/AgentToolkit/altk-evolve", + "license": "MIT", + "keywords": ["evolve", "codex", "entities", "memory"], + "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.", + "developerName": "AgentToolkit", + "category": "Productivity", + "capabilities": ["Interactive", "Write"], + "websiteURL": "https://github.com/AgentToolkit/altk-evolve", + "defaultPrompt": [ + "Recall Evolve entities for this task.", + "Save new Evolve learnings from this session.", + "Show me the entities stored for this repo." + ], + "brandColor": "#2563EB" + } +} diff --git a/platform-integrations/codex/plugins/evolve-lite/README.md b/platform-integrations/codex/plugins/evolve-lite/README.md new file mode 100644 index 0000000..e2a6eab --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/README.md @@ -0,0 +1,58 @@ +# Evolve Lite Plugin for Codex + +Evolve Lite for Codex provides lightweight file-backed learning and recall without MCP. + +## Features + +- Automatic recall through a repo-level Codex `UserPromptSubmit` hook +- Manual `learn` skill to save reusable entities into `.evolve/entities/` +- Manual `recall` skill to inspect everything stored for the current repo + +## Storage + +Entities are stored in the active workspace under: + +```text +.evolve/entities/ + guideline/ + use-context-managers-for-file-operations.md + cache-api-responses-locally.md +``` + +Each entity is a markdown file with lightweight YAML frontmatter. + +## Source Layout + +This source tree intentionally omits `lib/`. + +The shared library lives in: + +```text +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. + +## Installation + +Use the platform installer from the repo root: + +```bash +platform-integrations/install.sh install --platform codex +``` + +That installs: + +- `plugins/evolve-lite/` +- `.agents/plugins/marketplace.json` +- `.codex/hooks.json` + +## Included Skills + +### `learn` + +Analyze the current session and save proactive Evolve entities as markdown files. + +### `recall` + +Show the entities already stored for the current workspace. diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md new file mode 100644 index 0000000..f9d6364 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md @@ -0,0 +1,117 @@ +--- +name: learn +description: Extract actionable entities from Codex conversation trajectories. Systematically identifies errors, failures, and inefficiencies to generate proactive entities that prevent them from recurring. +--- + +# Entity Generator + +## Overview + +This skill analyzes the current Codex conversation to extract actionable entities that would help on similar tasks in the future. It **prioritizes errors encountered during the conversation** such as tool failures, exceptions, wrong approaches, and retry loops, then turns them into proactive recommendations that prevent them from recurring. + +## Workflow + +### Step 1: Analyze the Conversation + +Identify from your current conversation: + +- **Task/Request**: What was the user asking for? +- **Steps Taken**: What reasoning, actions, and observations occurred? +- **What Worked**: Which approaches succeeded? +- **What Failed**: Which approaches did not work and why? +- **Errors Encountered**: Tool failures, exceptions, permission errors, retry loops, dead ends, and wrong initial approaches + +### Step 2: Identify Errors and Root Causes + +Scan the conversation for these error signals: + +1. **Tool or command failures**: Non-zero exit codes, error messages, exceptions, stack traces +2. **Permission or access errors**: "Permission denied", "not found", sandbox restrictions +3. **Wrong initial approach**: First attempt abandoned in favor of a different strategy +4. **Retry loops**: Same action attempted multiple times with variations before succeeding +5. **Missing prerequisites**: Missing dependencies, packages, or configs discovered mid-task +6. **Silent failures**: Actions that appeared to succeed but produced wrong results + +For each error found, document: + +| | Error Example | Root Cause | Resolution | Prevention Guideline | +|---|---|---|---|---| +| 1 | `exiftool: command not found` | System tool unavailable in sandbox | Switched to Python PIL | Use PIL for image metadata in sandboxed environments | +| 2 | `git push` rejected (no upstream) | Branch not tracked to remote | Added `-u origin branch` | Always set upstream when pushing a new branch | +| 3 | Tried regex parsing of HTML, got wrong results | Regex cannot handle nested tags | Switched to BeautifulSoup | Use a proper HTML parser, never regex | + +If no errors are found, continue to Step 3 and extract entities from successful patterns. + +### Step 3: Extract Entities + +Extract 3-5 proactive entities. **Prioritize entities derived from errors identified in Step 2.** + +Follow these principles: + +1. **Reframe failures as proactive recommendations** + If an approach failed due to permissions, recommend the alternative first. + +2. **Focus on what worked, stated as the primary approach** + Bad: "If exiftool fails, use PIL instead" + Good: "In sandboxed environments, use Python libraries like PIL or Pillow for image metadata extraction" + +3. **Triggers should be situational context, not failure conditions** + Bad trigger: "When apt-get fails" + Good trigger: "When working in containerized or sandboxed environments" + +4. **For retry loops, recommend the final working approach as the starting point** + If three variations were tried before one worked, the entity should recommend the working variation directly. + +### Step 4: Output Entities JSON + +Output entities in this JSON format: + +```json +{ + "entities": [ + { + "content": "Proactive entity stating what TO DO", + "rationale": "Why this approach works better", + "type": "guideline", + "trigger": "Situational context when this applies" + } + ] +} +``` + +### Step 5: Save Entities + +After generating the entities JSON, save them using the helper script: + +#### Method 1: Direct Pipe + +```bash +echo '' | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +``` + +#### Method 2: From File + +```bash +cat entities.json | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +``` + +#### Method 3: Interactive + +```bash +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +``` + +The script will: + +- Find or create the entities directory at `.evolve/entities/` +- Write each entity as a markdown file in `{type}/` subdirectories +- Deduplicate against existing entities +- Display confirmation with the total count + +## Best Practices + +1. Prioritize error-derived entities first. +2. Keep entities specific and actionable. +3. Include rationale so the future agent understands why the guidance matters. +4. Use situational triggers instead of failure-based triggers. +5. Limit output to the 3-5 most valuable entities. 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 new file mode 100644 index 0000000..b437e57 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Save Entities Script +Reads entities from stdin JSON and writes each as a markdown file +in the entities directory, organized by type. +""" + +import json +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: + _candidate = _ancestor / "lib" + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + 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 ( # noqa: E402 + find_entities_dir, + get_default_entities_dir, + load_all_entities, + write_entity_file, + log as _log, +) + + +def log(message): + _log("save", message) + + +log("Script started") + + +def normalize(text): + """Normalize content for dedup comparison.""" + return " ".join(text.lower().split()) + + +def main(): + try: + input_data = json.load(sys.stdin) + log(f"Received input with keys: {list(input_data.keys())}") + except json.JSONDecodeError as e: + log(f"Failed to parse JSON input: {e}") + print(f"Error: Invalid JSON input - {e}", file=sys.stderr) + sys.exit(1) + + new_entities = input_data.get("entities", []) + if not new_entities: + log("No entities in input") + print("No entities provided in input.", file=sys.stderr) + sys.exit(0) + + log(f"Received {len(new_entities)} new entities") + + entities_dir = find_entities_dir() + if entities_dir: + entities_dir = entities_dir.resolve() + log(f"Found existing dir: {entities_dir}") + print(f"Using existing entities dir: {entities_dir}") + else: + entities_dir = get_default_entities_dir() + log(f"Created new dir: {entities_dir}") + print(f"Created new entities dir: {entities_dir}") + + existing_entities = load_all_entities(entities_dir) + existing_contents = {normalize(e["content"]) for e in existing_entities if e.get("content")} + log(f"Existing entities: {len(existing_entities)}") + + added_count = 0 + for entity in new_entities: + content = entity.get("content") + if not content: + log(f"Skipping entity without content: {entity}") + continue + if normalize(content) in existing_contents: + log(f"Skipping duplicate: {content[:60]}") + continue + + path = write_entity_file(entities_dir, entity) + existing_contents.add(normalize(content)) + added_count += 1 + log(f"Wrote: {path}") + + total = len(existing_entities) + added_count + log(f"Added {added_count} new entities. Total: {total}") + print(f"Added {added_count} new entity(ies). Total: {total}") + print(f"Entities stored in: {entities_dir}") + + +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 new file mode 100644 index 0000000..bb68c7a --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md @@ -0,0 +1,52 @@ +--- +name: recall +description: Retrieves relevant entities from the local Evolve knowledge base. Designed to be invoked automatically through a Codex UserPromptSubmit hook and manually when you want to inspect saved guidance. +--- + +# Entity Retrieval + +## Overview + +This skill retrieves relevant entities from the local Evolve knowledge base based on the current task context. It loads all stored entities and presents them to Codex as additional developer context. + +## How It Works + +1. 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/`. +4. It prints formatted guidance to stdout. +5. Codex adds that text as extra developer context for the turn. + +## Manual Use + +Run this if you want to inspect the currently stored entities yourself: + +```bash +printf '{"prompt":"Show stored Evolve entities"}' | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" +``` + +## Entities Storage + +Entities are stored as markdown files in `.evolve/entities/`, nested by type: + +```text +.evolve/entities/ + guideline/ + use-context-managers-for-file-operations.md + cache-api-responses-locally.md +``` + +Each file uses markdown with YAML frontmatter: + +```markdown +--- +type: guideline +trigger: When processing files or managing resources +--- + +Use context managers for file operations + +## Rationale + +Ensures proper resource cleanup +``` 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 new file mode 100644 index 0000000..822da0d --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Retrieve and output entities for Codex to use as extra developer context.""" + +import json +import os +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: + _candidate = _ancestor / "lib" + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + 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 + + +def log(message): + _log("retrieve", message) + + +log("Script started") + + +def format_entities(entities): + """Format all entities for Codex to review.""" + header = """## Evolve entities for this task + +Review these stored entities and apply any that are relevant to the user's request: + +""" + items = [] + for entity in entities: + content = entity.get("content") + if not content: + continue + item = f"- **[{entity.get('type', 'general')}]** {content}" + if entity.get("rationale"): + item += f"\n Rationale: {entity['rationale']}" + if entity.get("trigger"): + item += f"\n When: {entity['trigger']}" + items.append(item) + + return header + "\n".join(items) + + +def main(): + try: + input_data = json.load(sys.stdin) + log(f"Input keys: {list(input_data.keys())}") + except json.JSONDecodeError as e: + log(f"Failed to parse JSON input: {e}") + return + + prompt = input_data.get("prompt", "") + if prompt: + log(f"Prompt preview: {prompt[:120]}") + + log("=== Environment Variables ===") + for key, value in sorted(os.environ.items()): + if any(sensitive in key.upper() for sensitive in ["PASSWORD", "SECRET", "TOKEN", "KEY", "API"]): + log(f" {key}=***MASKED***") + else: + log(f" {key}={value}") + log("=== End Environment Variables ===") + + 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) + if not entities: + log("No entities found") + return + + output = format_entities(entities) + print(output) + log(f"Output {len(output)} chars to stdout") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/install.sh b/platform-integrations/install.sh index 87daf38..802ca7b 100755 --- a/platform-integrations/install.sh +++ b/platform-integrations/install.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Evolve Platform Installer -# Installs Evolve Lite (and optionally Full) integrations for Bob, Roo, and Claude Code. +# Installs Evolve Lite (and optionally Full) integrations for Bob, Roo, Claude Code, and Codex. # # Usage: -# ./install.sh install [--platform bob|roo|claude|all] [--mode lite|full] [--dir DIR] [--dry-run] -# ./install.sh uninstall [--platform bob|roo|claude|all] [--dir DIR] [--dry-run] +# ./install.sh install [--platform bob|roo|claude|codex|all] [--mode lite|full] [--dir DIR] [--dry-run] +# ./install.sh uninstall [--platform bob|roo|claude|codex|all] [--dir DIR] [--dry-run] # ./install.sh status [--dir DIR] # # Remote: @@ -143,6 +143,7 @@ DRY_RUN = False # set to True by --dry-run flag; checked in all write primitiv BOB_SLUG = "evolve-lite" ROO_SLUG = "evolve-lite" CLAUDE_PLUGIN = "evolve-lite" +CODEX_PLUGIN = "evolve-lite" # ── Colour helpers ──────────────────────────────────────────────────────────── @@ -305,6 +306,119 @@ def remove_json_array_item(path, array_key: str, id_key: str, id_val: str): atomic_write_json(path, data) +def _default_codex_marketplace(): + return { + "name": "evolve-local", + "interface": { + "displayName": "Evolve Local Plugins", + }, + "plugins": [], + } + + +def upsert_codex_marketplace_entry(path, item): + """Upsert a Codex marketplace plugin entry by name.""" + data = read_json(path) + if not data: + data = _default_codex_marketplace() + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a JSON object.") + + interface = data.setdefault("interface", {}) + if not isinstance(interface, dict): + interface = {} + data["interface"] = interface + data.setdefault("name", "evolve-local") + interface.setdefault("displayName", "Evolve Local Plugins") + + plugins = data.setdefault("plugins", []) + if not isinstance(plugins, list): + raise ValueError(f"{path} field 'plugins' must be an array.") + + for index, existing in enumerate(plugins): + if isinstance(existing, dict) and existing.get("name") == item.get("name"): + plugins[index] = item + break + else: + plugins.append(item) + + atomic_write_json(path, data) + + +def _codex_recall_hook_command(): + return 'python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"' + + +def _codex_recall_hook_group(): + return { + "hooks": [ + { + "type": "command", + "command": _codex_recall_hook_command(), + "statusMessage": "Loading Evolve guidance", + } + ] + } + + +def _group_contains_command(group, command): + hooks = group.get("hooks", []) + return any(isinstance(hook, dict) and hook.get("command") == command for hook in hooks) + + +def upsert_codex_user_prompt_hook(path, group): + """Upsert the Evolve UserPromptSubmit hook into a Codex hooks.json file.""" + 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("UserPromptSubmit", []) + if not isinstance(groups, list): + groups = [] + hooks["UserPromptSubmit"] = groups + + command = _codex_recall_hook_command() + for index, existing in enumerate(groups): + if isinstance(existing, dict) and _group_contains_command(existing, command): + groups[index] = group + break + else: + groups.append(group) + + atomic_write_json(path, data) + + +def remove_codex_user_prompt_hook(path): + """Remove the Evolve UserPromptSubmit hook from a Codex hooks.json file.""" + 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("UserPromptSubmit", []) + if not isinstance(groups, list): + return + + command = _codex_recall_hook_command() + hooks["UserPromptSubmit"] = [ + group for group in groups if not (isinstance(group, dict) and _group_contains_command(group, command)) + ] + if not hooks["UserPromptSubmit"]: + hooks.pop("UserPromptSubmit", None) + + atomic_write_json(path, data) + + # ── YAML helpers ─────────────────────────────────────────────────────────────── def _sentinel_start(slug): return f"# >>>evolve:{slug}<<<" @@ -455,6 +569,11 @@ def detect_platforms(target_dir): shutil.which("claude") is not None or (target / ".claude").is_dir() ), + "codex": ( + shutil.which("codex") is not None or + (target / ".codex").is_dir() or + (target / ".agents" / "plugins" / "marketplace.json").is_file() + ), } @@ -721,6 +840,85 @@ def status_claude(target_dir): print(f" evolve-lite plugin : ? (could not query)") +# ── Codex installer ─────────────────────────────────────────────────────────── + +def install_codex(source_dir, target_dir): + plugin_source = Path(source_dir) / "platform-integrations" / "codex" / "plugins" / CODEX_PLUGIN + plugin_target = Path(target_dir) / "plugins" / CODEX_PLUGIN + info(f"Installing Codex → {plugin_target}") + + copy_tree(plugin_source, plugin_target) + success("Copied Codex plugin") + + shared_lib = Path(source_dir) / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib" + if not shared_lib.is_dir(): + error(f"Shared lib not found: {shared_lib} — is the Claude plugin present in the source tree?") + sys.exit(1) + copy_tree(shared_lib, plugin_target / "lib") + success("Copied Codex lib") + + marketplace_target = Path(target_dir) / ".agents" / "plugins" / "marketplace.json" + upsert_codex_marketplace_entry( + marketplace_target, + { + "name": CODEX_PLUGIN, + "source": { + "source": "local", + "path": f"./plugins/{CODEX_PLUGIN}", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": "Productivity", + }, + ) + success(f"Upserted Codex marketplace entry in {marketplace_target}") + + hooks_target = Path(target_dir) / ".codex" / "hooks.json" + upsert_codex_user_prompt_hook(hooks_target, _codex_recall_hook_group()) + success(f"Upserted Codex UserPromptSubmit hook in {hooks_target}") + + success("Codex installation complete") + + +def uninstall_codex(target_dir): + info(f"Uninstalling Codex from {target_dir}") + + remove_dir(Path(target_dir) / "plugins" / CODEX_PLUGIN) + remove_json_array_item(Path(target_dir) / ".agents" / "plugins" / "marketplace.json", "plugins", "name", CODEX_PLUGIN) + remove_codex_user_prompt_hook(Path(target_dir) / ".codex" / "hooks.json") + + success("Codex uninstall complete") + + +def status_codex(target_dir): + plugin_dir = Path(target_dir) / "plugins" / CODEX_PLUGIN + print(" Codex:") + print(f" plugins/evolve-lite : {'✓' if plugin_dir.is_dir() else '✗'}") + print(f" lib/entity_io.py : {'✓' if (plugin_dir / 'lib' / 'entity_io.py').is_file() else '✗'}") + print(f" skills/learn : {'✓' if (plugin_dir / 'skills' / 'learn').is_dir() else '✗'}") + print(f" skills/recall : {'✓' if (plugin_dir / 'skills' / 'recall').is_dir() else '✗'}") + + marketplace_path = Path(target_dir) / ".agents" / "plugins" / "marketplace.json" + marketplace_present = False + if marketplace_path.is_file(): + data = read_json(marketplace_path) + marketplace_present = any(entry.get("name") == CODEX_PLUGIN for entry in data.get("plugins", [])) + print(f" marketplace.json entry : {'✓' if marketplace_present else '✗'}") + + hooks_path = Path(target_dir) / ".codex" / "hooks.json" + hook_present = False + if hooks_path.is_file(): + data = read_json(hooks_path) + hook_groups = data.get("hooks", {}).get("UserPromptSubmit", []) + hook_present = any( + isinstance(group, dict) and _group_contains_command(group, _codex_recall_hook_command()) + for group in hook_groups + ) + print(f" .codex/hooks.json entry : {'✓' if hook_present else '✗'}") + + # ── Dispatch ────────────────────────────────────────────────────────────────── def cmd_install(args): @@ -730,7 +928,7 @@ def cmd_install(args): # Resolve platforms if args.platform == "all": - platforms = ["bob", "roo", "claude"] + platforms = ["bob", "roo", "claude", "codex"] elif args.platform: platforms = [args.platform] else: @@ -755,6 +953,8 @@ def cmd_install(args): install_roo(SOURCE_DIR, target_dir) elif platform == "claude": install_claude(SOURCE_DIR, target_dir) + elif platform == "codex": + install_codex(SOURCE_DIR, target_dir) except Exception as e: error(f"Failed to install {platform}: {e}") if EVOLVE_DEBUG: @@ -782,7 +982,7 @@ def cmd_uninstall(args): info(_c("35", "DRY RUN — no files will be written or deleted")) if args.platform == "all": - platforms = ["bob", "roo", "claude"] + platforms = ["bob", "roo", "claude", "codex"] elif args.platform: platforms = [args.platform] else: @@ -799,6 +999,8 @@ def cmd_uninstall(args): uninstall_roo(target_dir) elif platform == "claude": uninstall_claude(target_dir) + elif platform == "codex": + uninstall_codex(target_dir) except Exception as e: error(f"Failed to uninstall {platform}: {e}") errors.append(platform) @@ -825,6 +1027,8 @@ def cmd_status(args): print() status_claude(target_dir) print() + status_codex(target_dir) + print() # ── argparse ────────────────────────────────────────────────────────────────── @@ -832,14 +1036,14 @@ def cmd_status(args): def main(): parser = argparse.ArgumentParser( prog="install.sh", - description="Install Evolve integrations for Bob, Roo, and Claude Code.", + description="Install Evolve integrations for Bob, Roo, Claude Code, and Codex.", ) sub = parser.add_subparsers(dest="command", required=True) # install p_install = sub.add_parser("install", help="Install Evolve into the current project") p_install.add_argument( - "--platform", choices=["bob", "roo", "claude", "all"], default=None, + "--platform", choices=["bob", "roo", "claude", "codex", "all"], default=None, help="Platform to install (default: auto-detect and prompt)", ) p_install.add_argument( @@ -858,7 +1062,7 @@ def main(): # uninstall p_uninstall = sub.add_parser("uninstall", help="Remove Evolve from the current project") p_uninstall.add_argument( - "--platform", choices=["bob", "roo", "claude", "all"], default=None, + "--platform", choices=["bob", "roo", "claude", "codex", "all"], default=None, help="Platform to uninstall (default: prompt)", ) p_uninstall.add_argument( diff --git a/tests/platform_integrations/conftest.py b/tests/platform_integrations/conftest.py index 5239751..f1650f4 100644 --- a/tests/platform_integrations/conftest.py +++ b/tests/platform_integrations/conftest.py @@ -84,7 +84,7 @@ def run( Args: command: Command to run (install, uninstall, status) - platform: Platform to target (bob, roo, claude, all, or None for interactive) + platform: Platform to target (bob, roo, claude, codex, all, or None for interactive) mode: Mode for bob (lite, full) dry_run: Whether to use --dry-run flag expect_success: Whether to expect the command to succeed @@ -372,6 +372,102 @@ def create_existing_roomodes_yaml(project_dir: Path): return roomodes_file +class CodexFixtures: + """Helper class to create Codex platform test fixtures.""" + + @staticmethod + def create_existing_plugin(project_dir: Path, plugin_name: str = "my-codex-plugin"): + """Create a custom plugin in plugins/.""" + plugin_dir = project_dir / "plugins" / plugin_name / ".codex-plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.json").write_text( + json.dumps( + { + "name": plugin_name, + "version": "0.1.0", + "description": "Custom user plugin", + "skills": "./skills/", + }, + indent=2, + ) + + "\n" + ) + return plugin_dir.parent + + @staticmethod + def create_existing_marketplace(project_dir: Path): + """Create a marketplace.json with a user's existing Codex plugin entry.""" + marketplace_file = project_dir / ".agents" / "plugins" / "marketplace.json" + marketplace_file.parent.mkdir(parents=True, exist_ok=True) + marketplace_file.write_text( + json.dumps( + { + "name": "custom-local", + "interface": { + "displayName": "Custom Local Plugins", + }, + "plugins": [ + { + "name": "my-codex-plugin", + "source": { + "source": "local", + "path": "./plugins/my-codex-plugin", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": "Productivity", + } + ], + }, + indent=2, + ) + + "\n" + ) + return marketplace_file + + @staticmethod + def create_existing_hooks(project_dir: Path): + """Create a .codex/hooks.json with a user's existing hooks.""" + hooks_file = project_dir / ".codex" / "hooks.json" + hooks_file.parent.mkdir(parents=True, exist_ok=True) + hooks_file.write_text( + json.dumps( + { + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.codex/hooks/session_start.py", + "statusMessage": "Loading notes", + } + ], + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ~/.codex/hooks/custom_prompt_memory.py", + "statusMessage": "Loading custom memory", + } + ] + } + ], + } + }, + indent=2, + ) + + "\n" + ) + return hooks_file + + @pytest.fixture def bob_fixtures(): """Provide Bob platform test fixtures.""" @@ -382,3 +478,9 @@ def bob_fixtures(): def roo_fixtures(): """Provide Roo platform test fixtures.""" return RooFixtures() + + +@pytest.fixture +def codex_fixtures(): + """Provide Codex platform test fixtures.""" + return CodexFixtures() diff --git a/tests/platform_integrations/test_codex.py b/tests/platform_integrations/test_codex.py new file mode 100644 index 0000000..8a0d99d --- /dev/null +++ b/tests/platform_integrations/test_codex.py @@ -0,0 +1,72 @@ +""" +Tests for the Codex platform integration installer behavior. +""" + +import json + +import pytest + + +EVOLVE_PLUGIN = "evolve-lite" +EVOLVE_HOOK_SNIPPET = "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" + + +def _marketplace_has_evolve_plugin(path): + data = json.loads(path.read_text()) + return any(entry.get("name") == EVOLVE_PLUGIN for entry in data.get("plugins", [])) + + +def _hooks_have_evolve_recall(path): + data = json.loads(path.read_text()) + groups = data.get("hooks", {}).get("UserPromptSubmit", []) + for group in groups: + for hook in group.get("hooks", []): + if EVOLVE_HOOK_SNIPPET in hook.get("command", ""): + return True + return False + + +@pytest.mark.platform_integrations +class TestCodexInstall: + """Test the Codex install flow.""" + + def test_install_creates_expected_files(self, temp_project_dir, install_runner, file_assertions): + """Installing Codex should create the plugin tree, marketplace entry, and hook.""" + install_runner.run("install", platform="codex") + + plugin_dir = temp_project_dir / "plugins" / EVOLVE_PLUGIN + file_assertions.assert_dir_exists(plugin_dir) + file_assertions.assert_file_exists(plugin_dir / ".codex-plugin" / "plugin.json") + 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_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 / "lib" / "entity_io.py") + + marketplace_path = temp_project_dir / ".agents" / "plugins" / "marketplace.json" + file_assertions.assert_valid_json(marketplace_path) + assert _marketplace_has_evolve_plugin(marketplace_path), "Evolve plugin entry missing from marketplace.json" + + 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" + + 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) + + assert "DRY RUN" in result.stdout + assert not (temp_project_dir / "plugins" / EVOLVE_PLUGIN).exists() + assert not (temp_project_dir / ".agents" / "plugins" / "marketplace.json").exists() + assert not (temp_project_dir / ".codex" / "hooks.json").exists() + + def test_status_reports_codex_installation(self, temp_project_dir, install_runner): + """Status should show the Codex installation state.""" + install_runner.run("install", platform="codex") + result = install_runner.run("status") + + assert "Codex:" in result.stdout + assert "plugins/evolve-lite" in result.stdout + assert "marketplace.json entry" in result.stdout + assert ".codex/hooks.json entry" in result.stdout diff --git a/tests/platform_integrations/test_idempotency.py b/tests/platform_integrations/test_idempotency.py index e6899a7..a622476 100644 --- a/tests/platform_integrations/test_idempotency.py +++ b/tests/platform_integrations/test_idempotency.py @@ -153,6 +153,56 @@ def test_install_creates_yaml_when_no_roomodes(self, temp_project_dir, install_r assert first_content == second_content, ".roomodes changed after second install" +@pytest.mark.platform_integrations +class TestCodexIdempotency: + """Test that Codex installation is idempotent.""" + + def test_multiple_installs(self, temp_project_dir, install_runner, file_assertions): + """Running install twice for Codex should be safe.""" + install_runner.run("install", platform="codex") + + marketplace_file = temp_project_dir / ".agents" / "plugins" / "marketplace.json" + hooks_file = temp_project_dir / ".codex" / "hooks.json" + first_marketplace = json.loads(marketplace_file.read_text()) + first_hooks = json.loads(hooks_file.read_text()) + + install_runner.run("install", platform="codex") + + second_marketplace = json.loads(marketplace_file.read_text()) + second_hooks = json.loads(hooks_file.read_text()) + + assert first_marketplace == second_marketplace, "marketplace.json changed after second install" + assert first_hooks == second_hooks, ".codex/hooks.json changed after second install" + + evolve_plugins = [entry for entry in second_marketplace["plugins"] if entry["name"] == "evolve-lite"] + assert len(evolve_plugins) == 1, "Duplicate evolve-lite marketplace entries found" + + prompt_hooks = second_hooks["hooks"]["UserPromptSubmit"] + evolve_hooks = [ + hook + for group in prompt_hooks + for hook in group.get("hooks", []) + if "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + ] + assert len(evolve_hooks) == 1, "Duplicate Evolve UserPromptSubmit hooks found" + + def test_install_after_partial_uninstall(self, temp_project_dir, install_runner, file_assertions): + """Installing after deleting part of the Codex plugin should restore it.""" + install_runner.run("install", platform="codex") + + plugin_dir = temp_project_dir / "plugins" / "evolve-lite" + + import shutil + + shutil.rmtree(plugin_dir / "skills" / "learn") + + install_runner.run("install", platform="codex") + + file_assertions.assert_dir_exists(plugin_dir / "skills" / "learn") + file_assertions.assert_file_exists(plugin_dir / "skills" / "learn" / "SKILL.md") + file_assertions.assert_file_exists(plugin_dir / "lib" / "entity_io.py") + + @pytest.mark.platform_integrations class TestUninstallInstallCycle: """Test that uninstall followed by install works correctly.""" @@ -221,3 +271,54 @@ def test_roo_uninstall_install_cycle(self, temp_project_dir, install_runner, roo # Assert: User content still intact file_assertions.assert_dir_exists(roo_dir / "skills" / "my-roo-skill") assert any(m["slug"] == "my-roo-mode" for m in data["customModes"]) + + def test_codex_uninstall_install_cycle(self, temp_project_dir, install_runner, codex_fixtures, file_assertions): + """Uninstalling and reinstalling Codex should work correctly.""" + custom_plugin = codex_fixtures.create_existing_plugin(temp_project_dir) + marketplace_file = codex_fixtures.create_existing_marketplace(temp_project_dir) + hooks_file = codex_fixtures.create_existing_hooks(temp_project_dir) + + plugin_json = custom_plugin / ".codex-plugin" / "plugin.json" + original_plugin_content = plugin_json.read_text() + + install_runner.run("install", platform="codex") + + evolve_plugin_dir = temp_project_dir / "plugins" / "evolve-lite" + file_assertions.assert_dir_exists(evolve_plugin_dir) + + install_runner.run("uninstall", platform="codex") + + file_assertions.assert_dir_not_exists(evolve_plugin_dir) + current_marketplace = json.loads(marketplace_file.read_text()) + assert all(entry["name"] != "evolve-lite" for entry in current_marketplace["plugins"]) + + current_hooks = json.loads(hooks_file.read_text()) + prompt_hooks = current_hooks["hooks"].get("UserPromptSubmit", []) + evolve_hooks = [ + hook + for group in prompt_hooks + for hook in group.get("hooks", []) + if "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + ] + assert not evolve_hooks, "Evolve hook still present after uninstall" + + install_runner.run("install", platform="codex") + + file_assertions.assert_dir_exists(evolve_plugin_dir) + file_assertions.assert_file_unchanged(plugin_json, original_plugin_content) + + reinstalled_marketplace = json.loads(marketplace_file.read_text()) + assert any(entry["name"] == "my-codex-plugin" for entry in reinstalled_marketplace["plugins"]) + assert any(entry["name"] == "evolve-lite" for entry in reinstalled_marketplace["plugins"]) + + reinstalled_hooks = json.loads(hooks_file.read_text()) + assert any( + hook.get("command") == "python3 ~/.codex/hooks/custom_prompt_memory.py" + for group in reinstalled_hooks["hooks"]["UserPromptSubmit"] + for hook in group.get("hooks", []) + ) + assert any( + "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + for group in reinstalled_hooks["hooks"]["UserPromptSubmit"] + for hook in group.get("hooks", []) + ) diff --git a/tests/platform_integrations/test_preservation.py b/tests/platform_integrations/test_preservation.py index 8b2a898..92fe3ad 100644 --- a/tests/platform_integrations/test_preservation.py +++ b/tests/platform_integrations/test_preservation.py @@ -211,12 +211,67 @@ def test_preserves_all_roo_content_together(self, temp_project_dir, install_runn assert len(evolve_modes) == 1, f"Expected 1 evolve-lite mode, found {len(evolve_modes)}" +@pytest.mark.platform_integrations +class TestCodexPreservation: + """Test that Codex installation preserves existing user data.""" + + def test_preserves_existing_marketplace_entries(self, temp_project_dir, install_runner, codex_fixtures, file_assertions): + """Install evolve when user already has marketplace entries - they must be preserved.""" + codex_fixtures.create_existing_plugin(temp_project_dir) + marketplace_file = codex_fixtures.create_existing_marketplace(temp_project_dir) + original_data = json.loads(marketplace_file.read_text()) + + install_runner.run("install", platform="codex") + + file_assertions.assert_valid_json(marketplace_file) + current_data = json.loads(marketplace_file.read_text()) + + custom_plugins = [entry for entry in current_data["plugins"] if entry["name"] == "my-codex-plugin"] + assert len(custom_plugins) == 1, "User's existing plugin entry was removed or duplicated!" + assert custom_plugins[0] == original_data["plugins"][0] + + evolve_plugins = [entry for entry in current_data["plugins"] if entry["name"] == "evolve-lite"] + assert len(evolve_plugins) == 1, "Evolve plugin entry missing from marketplace.json" + + def test_preserves_existing_hooks_and_plugin_files(self, temp_project_dir, install_runner, codex_fixtures, file_assertions): + """Install evolve when user already has hooks and plugins - they must be preserved.""" + custom_plugin = codex_fixtures.create_existing_plugin(temp_project_dir) + plugin_json = custom_plugin / ".codex-plugin" / "plugin.json" + original_plugin_content = plugin_json.read_text() + hooks_file = codex_fixtures.create_existing_hooks(temp_project_dir) + + install_runner.run("install", platform="codex") + + file_assertions.assert_file_unchanged(plugin_json, original_plugin_content) + + 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!" + + prompt_hooks = current_hooks["hooks"]["UserPromptSubmit"] + custom_prompt_hooks = [ + hook + for group in prompt_hooks + for hook in group.get("hooks", []) + if hook.get("command") == "python3 ~/.codex/hooks/custom_prompt_memory.py" + ] + assert len(custom_prompt_hooks) == 1, "User's UserPromptSubmit hook was removed!" + + evolve_hooks = [ + hook + for group in prompt_hooks + for hook in group.get("hooks", []) + if "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + ] + assert len(evolve_hooks) == 1, "Evolve UserPromptSubmit hook was not added!" + + @pytest.mark.platform_integrations class TestMultiPlatformPreservation: """Test that installing multiple platforms preserves all user data.""" def test_install_all_platforms_preserves_everything( - self, temp_project_dir, install_runner, bob_fixtures, roo_fixtures, file_assertions + self, temp_project_dir, install_runner, bob_fixtures, roo_fixtures, codex_fixtures, file_assertions ): """Install all platforms when user has content everywhere - all must be preserved.""" # Setup: Create user content for both platforms @@ -226,11 +281,16 @@ def test_install_all_platforms_preserves_everything( roo_skill = roo_fixtures.create_existing_skill(temp_project_dir) roo_modes = roo_fixtures.create_existing_roomodes_json(temp_project_dir) + codex_plugin = codex_fixtures.create_existing_plugin(temp_project_dir) + codex_marketplace = codex_fixtures.create_existing_marketplace(temp_project_dir) + codex_hooks = codex_fixtures.create_existing_hooks(temp_project_dir) # Save original content bob_skill_content = (bob_skill / "SKILL.md").read_text() bob_command_content = bob_command.read_text() roo_skill_content = (roo_skill / "SKILL.md").read_text() + codex_plugin_content = (codex_plugin / ".codex-plugin" / "plugin.json").read_text() + codex_marketplace_data = json.loads(codex_marketplace.read_text()) # Action: Install all platforms install_runner.run("install", platform="all") @@ -245,6 +305,19 @@ def test_install_all_platforms_preserves_everything( roo_data = json.loads(roo_modes.read_text()) assert any(m["slug"] == "my-roo-mode" for m in roo_data["customModes"]) - # Assert: Evolve content is added to both + # Assert: ALL Codex content is preserved + file_assertions.assert_file_unchanged(codex_plugin / ".codex-plugin" / "plugin.json", codex_plugin_content) + current_marketplace = json.loads(codex_marketplace.read_text()) + assert any(entry["name"] == "my-codex-plugin" for entry in current_marketplace["plugins"]) + assert codex_marketplace_data["plugins"][0] in current_marketplace["plugins"] + current_hooks = json.loads(codex_hooks.read_text()) + assert any( + hook.get("command") == "python3 ~/.codex/hooks/custom_prompt_memory.py" + for group in current_hooks["hooks"]["UserPromptSubmit"] + for hook in group.get("hooks", []) + ) + + # Assert: Evolve content is added everywhere file_assertions.assert_dir_exists(temp_project_dir / ".bob" / "skills" / "evolve-learn") file_assertions.assert_dir_exists(temp_project_dir / ".roo" / "skills" / "evolve-learn") + file_assertions.assert_dir_exists(temp_project_dir / "plugins" / "evolve-lite") From 5d966eb7553f4b02ee1f68f556fc1c71468f1d30 Mon Sep 17 00:00:00 2001 From: Vatche Isahagian Date: Tue, 31 Mar 2026 19:52:39 -0400 Subject: [PATCH 2/4] fix(platform-integrations): clarify codex hooks setup --- platform-integrations/INSTALL_SPEC.md | 6 ++++++ .../codex/plugins/evolve-lite/README.md | 11 +++++++++- .../evolve-lite/skills/recall/SKILL.md | 4 +++- platform-integrations/install.sh | 21 ++++++++++++------- tests/platform_integrations/test_codex.py | 20 ++++++++++++++++-- .../platform_integrations/test_idempotency.py | 13 +++++++----- .../test_preservation.py | 9 +++++--- 7 files changed, 65 insertions(+), 19 deletions(-) diff --git a/platform-integrations/INSTALL_SPEC.md b/platform-integrations/INSTALL_SPEC.md index cef71fb..ead66ff 100644 --- a/platform-integrations/INSTALL_SPEC.md +++ b/platform-integrations/INSTALL_SPEC.md @@ -128,6 +128,12 @@ Target: project directory 2. Copy shared lib from `platform-integrations/claude/plugins/evolve-lite/lib/` → `plugins/evolve-lite/lib/` 3. Upsert plugin entry `evolve-lite` into `.agents/plugins/marketplace.json` 4. Upsert a `UserPromptSubmit` hook into `.codex/hooks.json` that runs the Evolve recall helper script +5. Print post-install guidance that automatic recall requires `~/.codex/config.toml` to include: + ```toml + [features] + codex_hooks = true + ``` +6. Print a manual fallback note that users can invoke `evolve-lite:recall` directly if they do not want to enable Codex hooks Codex is currently implemented only in lite mode. Full mode is reserved for future MCP-backed work. diff --git a/platform-integrations/codex/plugins/evolve-lite/README.md b/platform-integrations/codex/plugins/evolve-lite/README.md index e2a6eab..581000b 100644 --- a/platform-integrations/codex/plugins/evolve-lite/README.md +++ b/platform-integrations/codex/plugins/evolve-lite/README.md @@ -4,7 +4,7 @@ Evolve Lite for Codex provides lightweight file-backed learning and recall witho ## Features -- Automatic recall through a repo-level Codex `UserPromptSubmit` hook +- Automatic recall through a repo-level Codex `UserPromptSubmit` hook when Codex hooks are enabled - Manual `learn` skill to save reusable entities into `.evolve/entities/` - Manual `recall` skill to inspect everything stored for the current repo @@ -47,6 +47,15 @@ That installs: - `.agents/plugins/marketplace.json` - `.codex/hooks.json` +Automatic recall requires Codex hooks to be enabled in `~/.codex/config.toml`: + +```toml +[features] +codex_hooks = true +``` + +If you do not want to enable Codex hooks, you can still invoke the installed `evolve-lite:recall` skill manually to load or inspect the saved guidance for the current repo. + ## Included Skills ### `learn` 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 bb68c7a..6ad80c6 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md @@ -11,7 +11,7 @@ This skill retrieves relevant entities from the local Evolve knowledge base base ## How It Works -1. The Codex `UserPromptSubmit` hook runs before the prompt is sent. +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/`. 4. It prints formatted guidance to stdout. @@ -25,6 +25,8 @@ Run this if you want to inspect the currently stored entities yourself: printf '{"prompt":"Show stored Evolve entities"}' | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" ``` +If you prefer not to enable Codex hooks, invoke the installed `evolve-lite:recall` skill manually when you want the saved guidance surfaced in the current session. + ## Entities Storage Entities are stored as markdown files in `.evolve/entities/`, nested by type: diff --git a/platform-integrations/install.sh b/platform-integrations/install.sh index 802ca7b..01ca4e4 100755 --- a/platform-integrations/install.sh +++ b/platform-integrations/install.sh @@ -349,8 +349,13 @@ def _codex_recall_hook_command(): return 'python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"' +def _is_codex_recall_command(command): + return isinstance(command, str) and "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in command + + def _codex_recall_hook_group(): return { + "matcher": "", "hooks": [ { "type": "command", @@ -361,9 +366,9 @@ def _codex_recall_hook_group(): } -def _group_contains_command(group, command): +def _group_contains_codex_recall_command(group): hooks = group.get("hooks", []) - return any(isinstance(hook, dict) and hook.get("command") == command for hook in hooks) + return any(isinstance(hook, dict) and _is_codex_recall_command(hook.get("command")) for hook in hooks) def upsert_codex_user_prompt_hook(path, group): @@ -384,9 +389,8 @@ def upsert_codex_user_prompt_hook(path, group): groups = [] hooks["UserPromptSubmit"] = groups - command = _codex_recall_hook_command() for index, existing in enumerate(groups): - if isinstance(existing, dict) and _group_contains_command(existing, command): + if isinstance(existing, dict) and _group_contains_codex_recall_command(existing): groups[index] = group break else: @@ -409,9 +413,8 @@ def remove_codex_user_prompt_hook(path): if not isinstance(groups, list): return - command = _codex_recall_hook_command() hooks["UserPromptSubmit"] = [ - group for group in groups if not (isinstance(group, dict) and _group_contains_command(group, command)) + group for group in groups if not (isinstance(group, dict) and _group_contains_codex_recall_command(group)) ] if not hooks["UserPromptSubmit"]: hooks.pop("UserPromptSubmit", None) @@ -878,6 +881,10 @@ def install_codex(source_dir, target_dir): hooks_target = Path(target_dir) / ".codex" / "hooks.json" upsert_codex_user_prompt_hook(hooks_target, _codex_recall_hook_group()) success(f"Upserted Codex UserPromptSubmit hook in {hooks_target}") + warn("Automatic Codex recall requires hooks to be enabled in ~/.codex/config.toml:") + print(" [features]") + print(" codex_hooks = true") + info("If you do not want to enable Codex hooks, invoke the installed evolve-lite:recall skill manually.") success("Codex installation complete") @@ -913,7 +920,7 @@ def status_codex(target_dir): data = read_json(hooks_path) hook_groups = data.get("hooks", {}).get("UserPromptSubmit", []) hook_present = any( - isinstance(group, dict) and _group_contains_command(group, _codex_recall_hook_command()) + isinstance(group, dict) and _group_contains_codex_recall_command(group) for group in hook_groups ) print(f" .codex/hooks.json entry : {'✓' if hook_present else '✗'}") diff --git a/tests/platform_integrations/test_codex.py b/tests/platform_integrations/test_codex.py index 8a0d99d..421cfdb 100644 --- a/tests/platform_integrations/test_codex.py +++ b/tests/platform_integrations/test_codex.py @@ -22,7 +22,7 @@ def _hooks_have_evolve_recall(path): for group in groups: for hook in group.get("hooks", []): if EVOLVE_HOOK_SNIPPET in hook.get("command", ""): - return True + return group.get("matcher") == "" return False @@ -32,7 +32,7 @@ class TestCodexInstall: def test_install_creates_expected_files(self, temp_project_dir, install_runner, file_assertions): """Installing Codex should create the plugin tree, marketplace entry, and hook.""" - install_runner.run("install", platform="codex") + result = install_runner.run("install", platform="codex") plugin_dir = temp_project_dir / "plugins" / EVOLVE_PLUGIN file_assertions.assert_dir_exists(plugin_dir) @@ -52,6 +52,22 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, file_assertions.assert_valid_json(hooks_path) assert _hooks_have_evolve_recall(hooks_path), "Evolve recall hook missing from .codex/hooks.json" + hooks_data = json.loads(hooks_path.read_text()) + evolve_groups = [ + group + for group in hooks_data.get("hooks", {}).get("UserPromptSubmit", []) + if any(EVOLVE_HOOK_SNIPPET in hook.get("command", "") for hook in group.get("hooks", [])) + ] + assert evolve_groups[0]["matcher"] == "" + evolve_hook = next( + hook for hook in evolve_groups[0]["hooks"] if EVOLVE_HOOK_SNIPPET in hook.get("command", "") + ) + expected_command = 'python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"' + assert evolve_hook["command"] == expected_command + assert "~/.codex/config.toml" in result.stdout + assert "codex_hooks = true" in result.stdout + assert "evolve-lite:recall" in result.stdout + 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) diff --git a/tests/platform_integrations/test_idempotency.py b/tests/platform_integrations/test_idempotency.py index a622476..16cd569 100644 --- a/tests/platform_integrations/test_idempotency.py +++ b/tests/platform_integrations/test_idempotency.py @@ -178,13 +178,16 @@ def test_multiple_installs(self, temp_project_dir, install_runner, file_assertio assert len(evolve_plugins) == 1, "Duplicate evolve-lite marketplace entries found" prompt_hooks = second_hooks["hooks"]["UserPromptSubmit"] - evolve_hooks = [ - hook + evolve_hook_groups = [ + group for group in prompt_hooks - for hook in group.get("hooks", []) - if "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + if any( + "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + for hook in group.get("hooks", []) + ) ] - assert len(evolve_hooks) == 1, "Duplicate Evolve UserPromptSubmit hooks found" + assert len(evolve_hook_groups) == 1, "Duplicate Evolve UserPromptSubmit hooks found" + assert evolve_hook_groups[0].get("matcher") == "" def test_install_after_partial_uninstall(self, temp_project_dir, install_runner, file_assertions): """Installing after deleting part of the Codex plugin should restore it.""" diff --git a/tests/platform_integrations/test_preservation.py b/tests/platform_integrations/test_preservation.py index 92fe3ad..fce6e8c 100644 --- a/tests/platform_integrations/test_preservation.py +++ b/tests/platform_integrations/test_preservation.py @@ -258,12 +258,15 @@ def test_preserves_existing_hooks_and_plugin_files(self, temp_project_dir, insta assert len(custom_prompt_hooks) == 1, "User's UserPromptSubmit hook was removed!" evolve_hooks = [ - hook + group for group in prompt_hooks - for hook in group.get("hooks", []) - if "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + if any( + "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + for hook in group.get("hooks", []) + ) ] assert len(evolve_hooks) == 1, "Evolve UserPromptSubmit hook was not added!" + assert evolve_hooks[0].get("matcher") == "" @pytest.mark.platform_integrations From fa4e374fc97cbff8feed60cfcaa29db5123d396f Mon Sep 17 00:00:00 2001 From: Vatche Isahagian Date: Tue, 31 Mar 2026 21:18:54 -0400 Subject: [PATCH 3/4] chore(tests): format codex integration checks --- tests/platform_integrations/test_codex.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/platform_integrations/test_codex.py b/tests/platform_integrations/test_codex.py index 421cfdb..2dcd81f 100644 --- a/tests/platform_integrations/test_codex.py +++ b/tests/platform_integrations/test_codex.py @@ -59,10 +59,10 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, if any(EVOLVE_HOOK_SNIPPET in hook.get("command", "") for hook in group.get("hooks", [])) ] assert evolve_groups[0]["matcher"] == "" - evolve_hook = next( - hook for hook in evolve_groups[0]["hooks"] if EVOLVE_HOOK_SNIPPET in hook.get("command", "") + evolve_hook = next(hook for hook in evolve_groups[0]["hooks"] if EVOLVE_HOOK_SNIPPET in hook.get("command", "")) + expected_command = ( + 'python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"' ) - expected_command = 'python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"' assert evolve_hook["command"] == expected_command assert "~/.codex/config.toml" in result.stdout assert "codex_hooks = true" in result.stdout From 901ae9c2581a4e3f78fafa4453c71c49d47900b3 Mon Sep 17 00:00:00 2001 From: Vatche Isahagian Date: Wed, 1 Apr 2026 10:50:09 -0400 Subject: [PATCH 4/4] fix(platform-integrations): harden codex hook resolution --- platform-integrations/INSTALL_SPEC.md | 2 +- .../codex/plugins/evolve-lite/README.md | 2 ++ .../codex/plugins/evolve-lite/skills/recall/SKILL.md | 2 ++ platform-integrations/install.sh | 12 +++++++++++- tests/platform_integrations/test_codex.py | 10 +++++++++- 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/platform-integrations/INSTALL_SPEC.md b/platform-integrations/INSTALL_SPEC.md index ead66ff..09a9b8c 100644 --- a/platform-integrations/INSTALL_SPEC.md +++ b/platform-integrations/INSTALL_SPEC.md @@ -127,7 +127,7 @@ Target: project directory 1. Copy `platform-integrations/codex/plugins/evolve-lite/` → `plugins/evolve-lite/` in the target project 2. Copy shared lib from `platform-integrations/claude/plugins/evolve-lite/lib/` → `plugins/evolve-lite/lib/` 3. Upsert plugin entry `evolve-lite` into `.agents/plugins/marketplace.json` -4. Upsert a `UserPromptSubmit` hook into `.codex/hooks.json` that runs the Evolve recall helper script +4. Upsert a `UserPromptSubmit` hook into `.codex/hooks.json` that runs the Evolve recall helper script by walking upward from the current working directory until it finds `plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py` (does not require `git`) 5. Print post-install guidance that automatic recall requires `~/.codex/config.toml` to include: ```toml [features] diff --git a/platform-integrations/codex/plugins/evolve-lite/README.md b/platform-integrations/codex/plugins/evolve-lite/README.md index 581000b..bea5df7 100644 --- a/platform-integrations/codex/plugins/evolve-lite/README.md +++ b/platform-integrations/codex/plugins/evolve-lite/README.md @@ -56,6 +56,8 @@ codex_hooks = true If you do not want to enable Codex hooks, you can still invoke the installed `evolve-lite:recall` skill manually to load or inspect the saved guidance for the current repo. +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. + ## Included Skills ### `learn` 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 6ad80c6..7cf6bdc 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md @@ -25,6 +25,8 @@ Run this if you want to inspect the currently stored entities yourself: printf '{"prompt":"Show stored Evolve entities"}' | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" ``` +The installed Codex hook itself does not require `git`; it walks upward from the current working directory until it finds the repo-local plugin script. + If you prefer not to enable Codex hooks, invoke the installed `evolve-lite:recall` skill manually when you want the saved guidance surfaced in the current session. ## Entities Storage diff --git a/platform-integrations/install.sh b/platform-integrations/install.sh index 01ca4e4..74e47b4 100755 --- a/platform-integrations/install.sh +++ b/platform-integrations/install.sh @@ -346,7 +346,17 @@ def upsert_codex_marketplace_entry(path, item): def _codex_recall_hook_command(): - return 'python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"' + return ( + "sh -lc '" + 'd=\"$PWD\"; ' + "while :; do " + 'candidate=\"$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py\"; ' + 'if [ -f \"$candidate\" ]; then exec python3 \"$candidate\"; fi; ' + '[ \"$d\" = \"/\" ] && break; ' + 'd=\"$(dirname \"$d\")\"; ' + "done; " + "exit 1'" + ) def _is_codex_recall_command(command): diff --git a/tests/platform_integrations/test_codex.py b/tests/platform_integrations/test_codex.py index 2dcd81f..e7be84d 100644 --- a/tests/platform_integrations/test_codex.py +++ b/tests/platform_integrations/test_codex.py @@ -61,7 +61,15 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, assert evolve_groups[0]["matcher"] == "" evolve_hook = next(hook for hook in evolve_groups[0]["hooks"] if EVOLVE_HOOK_SNIPPET in hook.get("command", "")) expected_command = ( - 'python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"' + "sh -lc '" + 'd="$PWD"; ' + "while :; do " + 'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; ' + 'if [ -f "$candidate" ]; then exec python3 "$candidate"; fi; ' + '[ "$d" = "/" ] && break; ' + 'd="$(dirname "$d")"; ' + "done; " + "exit 1'" ) assert evolve_hook["command"] == expected_command assert "~/.codex/config.toml" in result.stdout