diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..8533570d --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "basic-memory-local", + "interface": { + "displayName": "Basic Memory Local" + }, + "plugins": [ + { + "name": "codex", + "source": { + "source": "local", + "path": "./plugins/codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.gitignore b/.gitignore index fe5a5ddb..7a690759 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ ENV/ claude-output **/.claude/settings.local.json .mcp.json +!/plugins/codex/.mcp.json .mcpregistry_* /.testmondata .benchmarks/ diff --git a/justfile b/justfile index 12594c89..3842c339 100644 --- a/justfile +++ b/justfile @@ -265,8 +265,8 @@ check: lint format typecheck test # Run all code quality checks and all test suites, including semantic benchmarks check-all: lint format typecheck test test-semantic -# Validate every consolidated agent package (Claude Code, skills, Hermes, OpenClaw) -package-check: package-check-claude-code package-check-skills package-check-hermes package-check-openclaw +# Validate every consolidated agent package (Claude Code, Codex, skills, Hermes, OpenClaw) +package-check: package-check-claude-code package-check-codex package-check-skills package-check-hermes package-check-openclaw # Alias for plugin/package validation during consolidation work plugins-check: package-check @@ -278,6 +278,10 @@ agent-harness-check: package-check-claude-code package-check-hermes package-chec package-check-claude-code: just --justfile plugins/claude-code/justfile --working-directory plugins/claude-code check +# Codex plugin: manifest, bundled skills, hooks, MCP config, and schemas +package-check-codex: + just --justfile plugins/codex/justfile --working-directory plugins/codex check + # Shared top-level SKILL.md source package-check-skills: just --justfile skills/justfile --working-directory skills check diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json new file mode 100644 index 00000000..0360f8e0 --- /dev/null +++ b/plugins/codex/.codex-plugin/plugin.json @@ -0,0 +1,46 @@ +{ + "name": "codex", + "version": "0.1.0+codex.20260604201213", + "description": "A Codex-native bridge to Basic Memory for durable engineering context, decisions, and resumable checkpoints.", + "author": { + "name": "Basic Machines", + "email": "hello@basicmachines.co", + "url": "https://basicmemory.com" + }, + "homepage": "https://docs.basicmemory.com", + "repository": "https://github.com/basicmachines-co/basic-memory/tree/main/plugins/codex", + "license": "MIT", + "keywords": [ + "basic-memory", + "codex", + "memory", + "knowledge-graph", + "mcp", + "checkpoints" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Basic Memory for Codex", + "shortDescription": "Carry decisions, active work, and handoffs across Codex threads", + "longDescription": "Use Basic Memory for Codex to orient from your durable knowledge graph, capture engineering decisions, checkpoint long-running work, and resume with repo-backed context across Codex sessions.", + "developerName": "Basic Machines", + "category": "Developer Tools", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://basicmemory.com", + "privacyPolicyURL": "https://basicmemory.com/privacy", + "termsOfServiceURL": "https://basicmemory.com/terms", + "defaultPrompt": [ + "Use Basic Memory to orient before changing this repo.", + "Checkpoint this Codex thread into Basic Memory.", + "Capture the decision we just made." + ], + "brandColor": "#2563EB", + "composerIcon": "./assets/app-icon.png", + "logo": "./assets/logo.png" + } +} diff --git a/plugins/codex/.mcp.json b/plugins/codex/.mcp.json new file mode 100644 index 00000000..6e86668d --- /dev/null +++ b/plugins/codex/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "basic-memory": { + "command": "uvx", + "args": [ + "basic-memory", + "mcp" + ] + } + } +} diff --git a/plugins/codex/DEVELOPMENT.md b/plugins/codex/DEVELOPMENT.md new file mode 100644 index 00000000..a82d4a0c --- /dev/null +++ b/plugins/codex/DEVELOPMENT.md @@ -0,0 +1,80 @@ +# Basic Memory Codex Plugin Development + +This plugin is developed in-place from the Basic Memory repository. Codex installs local plugins +through marketplaces, so local testing uses a repo-local marketplace wrapper rather than publishing +anything external. + +## Local Marketplace + +The repo marketplace lives at: + +```text +.agents/plugins/marketplace.json +``` + +It exposes this plugin as: + +```text +codex@basic-memory-local +``` + +The marketplace entry points at `./plugins/codex`, resolved relative to the repository root. + +## First-Time Setup + +From the repository root: + +```bash +codex plugin marketplace add "$(git rev-parse --show-toplevel)" +codex plugin add codex@basic-memory-local +``` + +Start a new Codex thread after installing. New threads are the reliable boundary for picking up +plugin skills, hooks, and MCP configuration. + +Plugin installation is user-level in Codex, so one install makes the plugin available across +projects on the same machine. Repo-specific memory routing still comes from each checkout's +`.codex/basic-memory.json`. + +## Iteration Loop + +After changing files in `plugins/codex`, run the local checks: + +```bash +just package-check-codex +``` + +Then update the manifest cachebuster and reinstall from the local marketplace: + +```bash +python3 "$CODEX_PLUGIN_CREATOR_SCRIPTS/update_plugin_cachebuster.py" \ + "$(git rev-parse --show-toplevel)/plugins/codex" +codex plugin add codex@basic-memory-local +``` + +Start a fresh Codex thread to test the updated plugin. + +## Useful Checks + +List configured marketplaces: + +```bash +codex plugin marketplace list +``` + +List plugins Codex can see: + +```bash +codex plugin list +``` + +Run the full package validation gate when touching plugin packaging, shared skills, or integration +metadata: + +```bash +just package-check +``` + +To also run Codex's scaffold validator during `just package-check-codex`, set +`CODEX_PLUGIN_VALIDATOR` to the local `plugin-creator` validator script before +running the check. diff --git a/plugins/codex/README.md b/plugins/codex/README.md new file mode 100644 index 00000000..ab3ca400 --- /dev/null +++ b/plugins/codex/README.md @@ -0,0 +1,93 @@ +# Basic Memory for Codex + +Basic Memory for Codex is the Codex-native bridge between a working coding thread +and Basic Memory's durable knowledge graph. + +It is not a 1:1 copy of the Claude Code plugin. This version leans into Codex +workflows: repo orientation, long-running goals, changed-file evidence, explicit +verification, decision capture, and resumable checkpoints. + +## What It Does + +- **Orient from memory.** The `bm-orient` skill reads active tasks, open + decisions, and recent Codex checkpoints before substantial work. +- **Checkpoint work.** The `bm-checkpoint` skill and `PreCompact` hook write + `type: codex_session` notes with the current work cursor. +- **Capture decisions.** The `bm-decide` skill records durable engineering + decisions with rationale, alternatives, and consequences. +- **Remember lightly.** The `bm-remember` skill saves small facts without turning + them into a full decision or session note. +- **Share deliberately.** The `bm-share` skill copies personal notes to configured + team projects only after confirmation. +- **Report status.** The `bm-status` skill shows configuration, reachability, and + recent memory state. + +## Package Contents + +| Path | Role | +| --- | --- | +| `.codex-plugin/plugin.json` | Codex plugin manifest | +| `.mcp.json` | Basic Memory MCP server configuration | +| `hooks/hooks.json` | SessionStart and PreCompact hook registration | +| `hooks/session-start.sh` | Launches the SessionStart uv script | +| `hooks/session-start.py` | Injects a compact memory brief at thread start | +| `hooks/pre-compact.sh` | Launches the PreCompact uv script | +| `hooks/pre-compact.py` | Writes an automatic Codex checkpoint before compaction | +| `skills/` | Codex-native Basic Memory workflows | +| `schemas/` | Seed schemas for Codex sessions, decisions, and tasks | + +## Install + +Install the plugin once from the Basic Memory repository root: + +```bash +codex plugin marketplace add "$(git rev-parse --show-toplevel)" +codex plugin add codex@basic-memory-local +``` + +Plugin installation is user-level in Codex, so one install makes the plugin +available across projects on the same machine. Start a new Codex thread after +installing so Codex can load the plugin skills, MCP configuration, and hooks. + +Each repository still needs its own `.codex/basic-memory.json` so the plugin +knows which Basic Memory project and folders to use for that checkout. Run the +setup skill in each repo, or create the config file shown below. + +## Configuration + +Run the setup skill, or create `.codex/basic-memory.json` in a repo: + +```json +{ + "basicMemory": { + "primaryProject": "my-project", + "secondaryProjects": [], + "teamProjects": {}, + "focus": "code/dev", + "captureFolder": "codex-sessions", + "rememberFolder": "codex-remember", + "recallTimeframe": "7d", + "placementConventions": "Put decisions in decisions/ and work checkpoints in codex-sessions/." + } +} +``` + +Codex plugin hooks must be reviewed and trusted before they run. Open `/hooks` in +Codex after enabling the plugin and trust the Basic Memory hook definitions. + +## Development + +From this directory: + +```bash +just check +``` + +From the repo root: + +```bash +just package-check-codex +``` + +The package intentionally keeps Codex-specific configuration separate from +Claude's `.claude/settings.json`. diff --git a/plugins/codex/assets/app-icon.png b/plugins/codex/assets/app-icon.png new file mode 100644 index 00000000..ae0a7bb9 Binary files /dev/null and b/plugins/codex/assets/app-icon.png differ diff --git a/plugins/codex/assets/logo.png b/plugins/codex/assets/logo.png new file mode 100644 index 00000000..ae0a7bb9 Binary files /dev/null and b/plugins/codex/assets/logo.png differ diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json new file mode 100644 index 00000000..8f87c1f3 --- /dev/null +++ b/plugins/codex/hooks/hooks.json @@ -0,0 +1,31 @@ +{ + "description": "Basic Memory for Codex hooks - orient from the graph on session start and checkpoint before compaction.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|compact", + "hooks": [ + { + "type": "command", + "command": "${PLUGIN_ROOT}/hooks/session-start.sh", + "statusMessage": "Loading Basic Memory context", + "timeout": 30 + } + ] + } + ], + "PreCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "${PLUGIN_ROOT}/hooks/pre-compact.sh", + "statusMessage": "Checkpointing Codex work to Basic Memory", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/plugins/codex/hooks/pre-compact.py b/plugins/codex/hooks/pre-compact.py new file mode 100755 index 00000000..ba996e1b --- /dev/null +++ b/plugins/codex/hooks/pre-compact.py @@ -0,0 +1,232 @@ +#!/usr/bin/env -S uv run --script +"""Checkpoint Codex work into Basic Memory before compaction.""" + +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + +def basic_memory_command() -> list[str] | None: + configured = os.environ.get("BM_BIN") + if configured: + return shlex.split(configured) + if shutil.which("basic-memory"): + return ["basic-memory"] + if shutil.which("bm"): + return ["bm"] + if shutil.which("uvx"): + return ["uvx", "basic-memory"] + if shutil.which("uv"): + return ["uv", "tool", "run", "basic-memory"] + return None + + +def parse_payload() -> dict: + try: + payload = json.loads(sys.stdin.read() or "{}") + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def load_config(directory: Path) -> dict: + path = directory / ".codex" / "basic-memory.json" + try: + data = json.loads(path.read_text()) + except Exception: + return {} + if not isinstance(data, dict): + return {} + return data.get("basicMemory", data) + + +def text_of(content): + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text = block.get("text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + return "" + + +def transcript_turns(path: str): + collected = [] + if not path: + return collected + try: + with open(path) as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + if obj.get("isMeta") or obj.get("toolUseResult") is not None: + continue + msg = obj.get("message") if isinstance(obj.get("message"), dict) else obj + role = msg.get("role") or obj.get("type") + if role not in ("user", "assistant"): + continue + text = text_of(msg.get("content")).strip() + if text: + collected.append((role, text)) + except Exception: + return [] + return collected + + +def git_status(directory: Path) -> list[str]: + try: + out = subprocess.run( + ["git", "status", "--short"], + cwd=directory, + capture_output=True, + text=True, + timeout=5, + ) + except Exception: + return [] + if out.returncode != 0: + return [] + return [line for line in out.stdout.splitlines() if line.strip()][:20] + + +def clip(value: str, limit: int) -> str: + compact = " ".join(value.split()) + return compact if len(compact) <= limit else compact[: limit - 1].rstrip() + "..." + + +def main() -> int: + bm_cmd = basic_memory_command() + if not bm_cmd: + return 0 + + payload = parse_payload() + cwd = Path(payload.get("cwd") or os.getcwd()) + transcript_path = payload.get("transcript_path") or "" + session_id = payload.get("session_id") or "" + turn_id = payload.get("turn_id") or "" + trigger = payload.get("trigger") or "" + model = payload.get("model") or "" + + cfg = load_config(cwd) + primary_project = str(cfg.get("primaryProject") or "").strip() + capture_folder = str(cfg.get("captureFolder") or "codex-sessions").strip() + + if not primary_project: + return 0 + + conversation = transcript_turns(transcript_path) + if not conversation or not any(role == "user" for role, _ in conversation): + return 0 + + user_messages = [text for role, text in conversation if role == "user"] + assistant_messages = [text for role, text in conversation if role == "assistant"] + opening = user_messages[0] if user_messages else "" + recent_user = user_messages[-3:] + recent_assistant = assistant_messages[-2:] + status_lines = git_status(cwd) + + now = datetime.now(timezone.utc) + iso = now.isoformat(timespec="seconds") + title = f"Codex session {now.strftime('%Y-%m-%d %H:%M:%S')} - {clip(opening, 40)}" + + frontmatter = [ + "---", + "type: codex_session", + "status: open", + f"started: {iso}", + f"ended: {iso}", + f"project: {primary_project}", + f"cwd: {cwd}", + ] + if session_id: + frontmatter.append(f"codex_session_id: {session_id}") + if turn_id: + frontmatter.append(f"codex_turn_id: {turn_id}") + if trigger: + frontmatter.append(f"trigger: {trigger}") + if model: + frontmatter.append(f"model: {model}") + frontmatter += ["capture: extractive", "---"] + + body = [ + "", + f"# {title}", + "", + "_Automatic Codex pre-compaction checkpoint. It records the working cursor, " + "not a polished summary._", + "", + "## Summary", + f"Working in `{cwd}`.", + f"- Opening request: {clip(opening, 300)}" if opening else "", + "", + "## Recent User Cursor", + ] + body += [f"- {clip(message, 240)}" for message in recent_user] + if recent_assistant: + body += ["", "## Recent Assistant Notes"] + body += [f"- {clip(message, 240)}" for message in recent_assistant] + if status_lines: + body += ["", "## Working Tree"] + body += [f"- `{line}`" for line in status_lines] + body += [ + "", + "## Observations", + f"- [context] Codex worked in `{cwd}`", + f"- [context] Session opened with: {clip(opening, 200)}" if opening else "", + "- [next_step] Re-read this checkpoint, inspect the current worktree, and " + "continue from the latest user request", + ] + + content = "\n".join(frontmatter + body) + project_flag = "--project-id" if UUID_RE.match(primary_project) else "--project" + + try: + subprocess.run( + [ + *bm_cmd, + "tool", + "write-note", + "--title", + title, + "--folder", + capture_folder, + project_flag, + primary_project, + "--tags", + "codex", + "--tags", + "auto-capture", + ], + input=content, + capture_output=True, + text=True, + timeout=60, + ) + except Exception: + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/hooks/pre-compact.sh b/plugins/codex/hooks/pre-compact.sh new file mode 100755 index 00000000..cd791060 --- /dev/null +++ b/plugins/codex/hooks/pre-compact.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# PreCompact hook - checkpoint Codex work into Basic Memory before compaction. +# +# Contract: best effort. The hook only writes when .codex/basic-memory.json pins a +# primary project, and every failure exits 0 so compaction can continue. + +set -u + +if ! command -v uv >/dev/null 2>&1; then + exit 0 +fi + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +uv run --script "$script_dir/pre-compact.py" 2>/dev/null || exit 0 diff --git a/plugins/codex/hooks/session-start.py b/plugins/codex/hooks/session-start.py new file mode 100755 index 00000000..1a401d25 --- /dev/null +++ b/plugins/codex/hooks/session-start.py @@ -0,0 +1,237 @@ +#!/usr/bin/env -S uv run --script +"""Brief Codex from Basic Memory at thread start.""" + +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + + +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) +MAX_SHARED = 6 + + +def basic_memory_command() -> list[str] | None: + configured = os.environ.get("BM_BIN") + if configured: + return shlex.split(configured) + if shutil.which("basic-memory"): + return ["basic-memory"] + if shutil.which("bm"): + return ["bm"] + if shutil.which("uvx"): + return ["uvx", "basic-memory"] + if shutil.which("uv"): + return ["uv", "tool", "run", "basic-memory"] + return None + + +def parse_payload() -> dict: + try: + payload = json.loads(sys.stdin.read() or "{}") + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def load_config(directory: Path) -> tuple[dict, bool]: + path = directory / ".codex" / "basic-memory.json" + try: + data = json.loads(path.read_text()) + except FileNotFoundError: + return {}, False + except Exception: + return {}, True + if not isinstance(data, dict): + return {}, True + return data.get("basicMemory", data), True + + +def project_args(project_ref: str | None) -> list[str]: + if not project_ref: + return [] + flag = "--project-id" if UUID_RE.match(project_ref) else "--project" + return [flag, project_ref] + + +def search( + bm_cmd: list[str], + filters: list[str], + project_ref: str | None = None, + timeout: int = 10, +): + cmd = [*bm_cmd, "tool", "search-notes", *filters, "--page-size", "5"] + cmd.extend(project_args(project_ref)) + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if out.returncode != 0: + return None + return json.loads(out.stdout) + except Exception: + return None + + +def rows(result): + return (result or {}).get("results") or [] + + +def label(result): + name = result.get("title") or result.get("file_path") or "(untitled)" + ref = result.get("permalink") or result.get("file_path") or "" + return f"- {name}" + (f" - {ref}" if ref else "") + + +def readable(ref): + return f"{ref[:8]}..." if UUID_RE.match(ref) else ref + + +def shared_project_refs(cfg: dict, primary_project: str) -> tuple[list[str], bool]: + secondary = cfg.get("secondaryProjects") + secondary = secondary if isinstance(secondary, list) else [] + team = cfg.get("teamProjects") + team = team if isinstance(team, dict) else {} + + shared_refs: list[str] = [] + for ref in list(secondary) + list(team.keys()): + if isinstance(ref, str) and ref.strip() and ref.strip() != primary_project: + clean = ref.strip() + if clean not in shared_refs: + shared_refs.append(clean) + shared_capped = len(shared_refs) > MAX_SHARED + return shared_refs[:MAX_SHARED], shared_capped + + +def no_context_message(configured: bool, primary_project: str) -> str: + if not configured: + return ( + "# Basic Memory for Codex\n\n" + "_This repo is not configured for Basic Memory yet. Run `Use Basic Memory " + "for Codex to set up this repo` to map a project, seed schemas, and turn " + "on Codex checkpoints._" + ) + + project = primary_project or "the default project" + return ( + "# Basic Memory for Codex\n\n" + f"_Could not read from `{project}`. Run `Use bm-status` to check the " + "Basic Memory project mapping._" + ) + + +def main() -> int: + bm_cmd = basic_memory_command() + if not bm_cmd: + return 0 + + payload = parse_payload() + cwd = Path(payload.get("cwd") or os.getcwd()) + source = payload.get("source") or "startup" + + cfg, configured = load_config(cwd) + primary_project = str(cfg.get("primaryProject") or "").strip() + recall_timeframe = str(cfg.get("recallTimeframe") or "7d").strip() + capture_folder = str(cfg.get("captureFolder") or "codex-sessions").strip() + placement = str(cfg.get("placementConventions") or "").strip() + focus = str(cfg.get("focus") or "").strip() + shared_refs, shared_capped = shared_project_refs(cfg, primary_project) + + active_tasks = ["--type", "task", "--status", "active"] + open_decisions = ["--type", "decision", "--status", "open"] + recent_codex = ["--type", "codex_session", "--after_date", recall_timeframe] + recent_generic = ["--type", "session", "--after_date", recall_timeframe] + + with ThreadPoolExecutor(max_workers=4 + MAX_SHARED) as pool: + fut_tasks = pool.submit(search, bm_cmd, active_tasks, primary_project or None) + fut_decisions = pool.submit(search, bm_cmd, open_decisions, primary_project or None) + fut_codex = pool.submit(search, bm_cmd, recent_codex, primary_project or None) + fut_sessions = pool.submit(search, bm_cmd, recent_generic, primary_project or None) + fut_shared = {ref: pool.submit(search, bm_cmd, open_decisions, ref) for ref in shared_refs} + primary_tasks = fut_tasks.result() + primary_decisions = fut_decisions.result() + primary_codex = fut_codex.result() + primary_sessions = fut_sessions.result() + shared_results = {ref: fut.result() for ref, fut in fut_shared.items()} + + if primary_tasks is None and primary_decisions is None and primary_codex is None: + print(no_context_message(configured, primary_project)) + return 0 + + lines = ["# Basic Memory for Codex", ""] + header = f"Project: {primary_project or 'default project'}" + if focus: + header += f" | focus: {focus}" + if shared_refs: + header += f" | reading {len(shared_refs)} shared project(s)" + lines.append(header) + lines.append(f"Session source: {source}") + + task_rows = rows(primary_tasks) + decision_rows = rows(primary_decisions) + codex_rows = rows(primary_codex) + session_rows = rows(primary_sessions) + + if task_rows: + lines += ["", f"## Active Tasks ({len(task_rows)})", *[label(r) for r in task_rows]] + if decision_rows: + lines += [ + "", + f"## Open Decisions ({len(decision_rows)})", + *[label(r) for r in decision_rows], + ] + if codex_rows: + lines += [ + "", + f"## Recent Codex Checkpoints ({len(codex_rows)})", + *[label(r) for r in codex_rows], + ] + elif session_rows: + lines += [ + "", + f"## Recent Sessions ({len(session_rows)})", + *[label(r) for r in session_rows], + ] + + shared_sections = [(ref, rows(shared_results.get(ref))) for ref in shared_refs] + shared_sections = [(ref, items) for ref, items in shared_sections if items] + if shared_sections: + lines += ["", "## Shared Context (Read Only)"] + for ref, items in shared_sections: + lines += [f"### {readable(ref)} open decisions", *[label(r) for r in items]] + if shared_capped: + lines += ["", f"Only the first {MAX_SHARED} shared projects are read on session start."] + + if not (task_rows or decision_rows or codex_rows or session_rows or shared_sections): + lines += ["", "_No active tasks, open decisions, or recent checkpoints found._"] + + lines += [ + "", + "## Codex Memory Posture", + "- Search Basic Memory before answering questions about prior decisions or status.", + "- Capture durable engineering decisions as typed decision notes.", + f"- Put automatic Codex checkpoints in `{capture_folder}/`.", + ] + if placement: + lines.append(f"- Follow these placement conventions for other notes: {placement}") + else: + lines.append("- Place other notes by topic, not in the checkpoint folder.") + + lines += [ + "", + "Use Basic Memory as durable context, but keep required repo rules in AGENTS.md " + "or checked-in docs.", + ] + + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/hooks/session-start.sh b/plugins/codex/hooks/session-start.sh new file mode 100755 index 00000000..71d067af --- /dev/null +++ b/plugins/codex/hooks/session-start.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# SessionStart hook - brief Codex from Basic Memory at thread start. +# +# Contract: best effort only. A missing Basic Memory install, empty project, slow +# cloud read, or bad config must never disrupt a Codex thread. + +set -u + +if ! command -v uv >/dev/null 2>&1; then + exit 0 +fi + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +uv run --script "$script_dir/session-start.py" 2>/dev/null || exit 0 diff --git a/plugins/codex/justfile b/plugins/codex/justfile new file mode 100644 index 00000000..796266eb --- /dev/null +++ b/plugins/codex/justfile @@ -0,0 +1,19 @@ +# Basic Memory Codex plugin checks + +repo_root := "../.." + +# Validate the plugin manifest, hooks, skills, schemas, and MCP config. +manifest-check: + python3 {{repo_root}}/scripts/validate_codex_plugin.py . + +# Validate against the local Codex plugin scaffold contract. +scaffold-check: + @validator="${CODEX_PLUGIN_VALIDATOR:-}"; \ + if [ -n "$validator" ]; then \ + cd {{repo_root}} && uv run python "$validator" plugins/codex; \ + else \ + echo "Skipping optional Codex scaffold validator: set CODEX_PLUGIN_VALIDATOR to enable"; \ + fi + +# Run every local package check for this plugin. +check: manifest-check scaffold-check diff --git a/plugins/codex/schemas/codex-session.md b/plugins/codex/schemas/codex-session.md new file mode 100644 index 00000000..3ff825c3 --- /dev/null +++ b/plugins/codex/schemas/codex-session.md @@ -0,0 +1,47 @@ +--- +title: Codex Session +type: schema +entity: CodexSession +version: 1 +schema: + summary?: string, one-paragraph what happened in this Codex thread + changed_file?(array): string, files created, edited, deleted, or inspected + verification?(array): string, checks run and their result + decision?(array): string, decisions surfaced or created during the thread + blocker?(array): string, unresolved blockers or failed approaches + next_step?(array): string, explicit cursor for the next Codex thread + produced?(array): Entity, notes or artifacts created or updated +settings: + validation: warn + frontmatter: + project: string, the Basic Memory project this session belongs to + started: string, when the session began or checkpoint was created + ended?: string, when the session was checkpointed + status?(enum, lifecycle of the checkpoint): [open, resumed, closed] + cwd?: string, working directory for the Codex thread + codex_session_id?: string, Codex session identifier + codex_turn_id?: string, Codex turn identifier + trigger?: string, compaction trigger or deliberate checkpoint source + model?: string, active Codex model slug when known + capture?(enum, how this checkpoint was produced): [extractive, deliberate, summarized] +--- + +# Codex Session + +A **CodexSession** note is a resumable engineering checkpoint. It captures the +thread cursor: what changed, what was verified, what decisions matter, and what +the next Codex thread should do first. + +Codex sessions are found by structured recall: +`search_notes(metadata_filters={"type": "codex_session"}, after_date="7d")`. + +## What Goes In A CodexSession + +- **summary** - what happened. +- **changed_file** - changed or inspected paths that matter to resume. +- **verification** - commands actually run and their outcome. +- **decision** - choices made or surfaced. +- **blocker** - open failures, constraints, or rejected approaches. +- **next_step** - the next concrete action. + +Validation is `warn` so checkpointing never blocks the user's flow. diff --git a/plugins/codex/schemas/decision.md b/plugins/codex/schemas/decision.md new file mode 100644 index 00000000..eb7feddb --- /dev/null +++ b/plugins/codex/schemas/decision.md @@ -0,0 +1,30 @@ +--- +title: Decision +type: schema +entity: Decision +version: 1 +schema: + decision: string, the choice that was made + rationale?: string, why this choice over alternatives + alternative?(array): string, options considered and not taken + consequence?(array): string, what this decision commits the work to + context?: string, the situation that prompted the decision + affects?(array): Entity, work or notes this decision bears on + supersedes?: Entity, a prior decision this one replaces +settings: + validation: warn + frontmatter: + status?(enum, lifecycle of the decision): [open, accepted, superseded, rejected] + decided?: string, when the decision was made + project?: string, the Basic Memory project this decision belongs to +--- + +# Decision + +A **Decision** note records a real choice with rationale and consequences. Codex +uses decisions to avoid relitigating the same tradeoff in later threads. + +Decisions are found by structured recall: +`search_notes(metadata_filters={"type": "decision", "status": "open"})`. + +Capture decisions sparingly. Use one note per genuine durable choice. diff --git a/plugins/codex/schemas/task.md b/plugins/codex/schemas/task.md new file mode 100644 index 00000000..5b7576e0 --- /dev/null +++ b/plugins/codex/schemas/task.md @@ -0,0 +1,30 @@ +--- +title: Task +type: schema +entity: Task +version: 1 +schema: + description: string, what needs to be done + status?(enum, current state): [active, blocked, done, abandoned] + assigned_to?: string, who is working on this + steps?(array): string, ordered steps to complete + current_step?: integer, which step number is current + context?: string, key context needed to resume + started?: string, when work began + completed?: string, when work finished + blockers?(array): string, what prevents progress + parent_task?: Task, parent task if this is a subtask +settings: + validation: warn +--- + +# Task + +A **Task** note tracks work in progress so Codex can find it on the next thread. +It matches the framework-agnostic `memory-tasks` shape. + +Tasks are found by structured recall: +`search_notes(metadata_filters={"type": "task", "status": "active"})`. + +Put queryable fields such as `status` and `current_step` in frontmatter, and use +observations for human-readable progress notes. diff --git a/plugins/codex/skills/bm-checkpoint/SKILL.md b/plugins/codex/skills/bm-checkpoint/SKILL.md new file mode 100644 index 00000000..a520bef7 --- /dev/null +++ b/plugins/codex/skills/bm-checkpoint/SKILL.md @@ -0,0 +1,62 @@ +--- +name: bm-checkpoint +description: Save a deliberate Codex work checkpoint to Basic Memory with changed files, verification, decisions, blockers, and the next action. +--- + +# Checkpoint Codex Work + +Create a durable handoff note for current Codex work. Use this when the user asks +to checkpoint, wrap up, hand off, remember the state of the work, or before a long +context transition. + +## Gather + +Read `.codex/basic-memory.json` if present: + +- `primaryProject`, default omitted +- `captureFolder`, default `codex-sessions` +- `placementConventions`, optional + +Gather repo evidence: + +- `git status --short` +- current branch +- changed files you touched +- tests or checks actually run +- failures or skipped checks +- decisions made in this thread +- unresolved blockers +- next action + +Do not claim a test passed unless you ran it or the user supplied the result. + +## Write + +Write a note to Basic Memory: + +- `title`: `Codex checkpoint - ` +- `directory`: configured `captureFolder` +- `tags`: `["codex", "checkpoint"]` +- frontmatter: + - `type: codex_session` + - `status: open` + - `project: ` + - `cwd: ` + - `capture: deliberate` + +Use sections: + +- Summary +- Changed Files +- Verification +- Decisions +- Blockers +- Next Action +- Observations + +Observations should include at least one `[next_step]` line. Add relations to +existing tasks, decisions, specs, issues, or PRs when the thread has obvious ones. + +## Confirm + +Reply with the permalink and the one next action the checkpoint preserves. diff --git a/plugins/codex/skills/bm-checkpoint/agents/openai.yaml b/plugins/codex/skills/bm-checkpoint/agents/openai.yaml new file mode 100644 index 00000000..31639327 --- /dev/null +++ b/plugins/codex/skills/bm-checkpoint/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Checkpoint" + short_description: "Save a resumable Codex work handoff" + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Use $bm-checkpoint to save the current Codex work state into Basic Memory." diff --git a/plugins/codex/skills/bm-checkpoint/assets/icon.svg b/plugins/codex/skills/bm-checkpoint/assets/icon.svg new file mode 100644 index 00000000..f214d1ed --- /dev/null +++ b/plugins/codex/skills/bm-checkpoint/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/codex/skills/bm-decide/SKILL.md b/plugins/codex/skills/bm-decide/SKILL.md new file mode 100644 index 00000000..e25e517d --- /dev/null +++ b/plugins/codex/skills/bm-decide/SKILL.md @@ -0,0 +1,35 @@ +--- +name: bm-decide +description: Capture a durable engineering decision in Basic Memory with rationale, alternatives, consequences, and affected work. +--- + +# Capture A Decision + +Use this when the user makes or asks to record a durable choice. A decision is a +choice with rationale and consequences, not a casual preference. + +## Steps + +1. Resolve `.codex/basic-memory.json`: + - write to `primaryProject` when set + - follow `placementConventions` for the directory when they are specific + - otherwise use `decisions` + +2. Clarify only if the choice itself is ambiguous. Do not ask for every field if + the conversation already contains the rationale. + +3. Write a `type: decision` note: + - `status: open` unless the user says it is accepted, superseded, or rejected + - `decided: ` + - `project: ` + +4. Include: + - the decision + - context + - rationale + - alternatives considered + - consequences + - affected files, specs, issues, PRs, or notes + +5. Confirm with the permalink. If this supersedes an older decision, update the old + note or link it as `supersedes`. diff --git a/plugins/codex/skills/bm-decide/agents/openai.yaml b/plugins/codex/skills/bm-decide/agents/openai.yaml new file mode 100644 index 00000000..a1b3ff6a --- /dev/null +++ b/plugins/codex/skills/bm-decide/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Decide" + short_description: "Record durable engineering decisions" + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Use $bm-decide to capture this engineering decision in Basic Memory." diff --git a/plugins/codex/skills/bm-decide/assets/icon.svg b/plugins/codex/skills/bm-decide/assets/icon.svg new file mode 100644 index 00000000..a9db5caf --- /dev/null +++ b/plugins/codex/skills/bm-decide/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/codex/skills/bm-orient/SKILL.md b/plugins/codex/skills/bm-orient/SKILL.md new file mode 100644 index 00000000..e59ddc82 --- /dev/null +++ b/plugins/codex/skills/bm-orient/SKILL.md @@ -0,0 +1,36 @@ +--- +name: bm-orient +description: Orient Codex from Basic Memory before substantial repo work by reading active tasks, decisions, recent Codex checkpoints, and repo conventions. +--- + +# Orient From Basic Memory + +Use this before substantial work in a repo, before resuming an old thread, or when +the user asks where things stand. + +## Steps + +1. Read `.codex/basic-memory.json` if present. Use `primaryProject`, `secondaryProjects`, + `recallTimeframe`, and `placementConventions`. If the file is missing, continue + against the default Basic Memory project and mention that setup has not been run. + +2. Query the primary project: + - active tasks: `type=task`, `status=active` + - open decisions: `type=decision`, `status=open` + - recent Codex sessions: `type=codex_session`, after `recallTimeframe` + - recent generic sessions only if no Codex sessions are found + +3. Query configured `secondaryProjects` read-only for open decisions. Do not write + to shared projects during orientation. + +4. Read the highest-signal hits before summarizing. Prefer notes that match the + current repo, named route, issue, branch, or file path. + +5. Present a compact orientation: + - active work + - decisions that constrain the next move + - recent checkpoint cursor + - likely next action + - any missing setup or ambiguous project mapping + +Keep the summary evidence-backed. Include permalinks for notes you rely on. diff --git a/plugins/codex/skills/bm-orient/agents/openai.yaml b/plugins/codex/skills/bm-orient/agents/openai.yaml new file mode 100644 index 00000000..29ed36e6 --- /dev/null +++ b/plugins/codex/skills/bm-orient/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Orient" + short_description: "Load repo context from Basic Memory" + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Use $bm-orient to load Basic Memory context before changing this repo." diff --git a/plugins/codex/skills/bm-orient/assets/icon.svg b/plugins/codex/skills/bm-orient/assets/icon.svg new file mode 100644 index 00000000..16d7ec77 --- /dev/null +++ b/plugins/codex/skills/bm-orient/assets/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/codex/skills/bm-remember/SKILL.md b/plugins/codex/skills/bm-remember/SKILL.md new file mode 100644 index 00000000..0109bbe9 --- /dev/null +++ b/plugins/codex/skills/bm-remember/SKILL.md @@ -0,0 +1,31 @@ +--- +name: bm-remember +description: Quickly save a small fact, reminder, or user preference into Basic Memory from Codex without turning it into a full decision or checkpoint. +--- + +# Remember + +Use this for lightweight capture: "remember that", "save this", "note this", or +a small fact that should survive the current thread. + +## Steps + +1. Read `.codex/basic-memory.json` if present: + - `primaryProject`, default omitted + - `rememberFolder`, default `codex-remember` + +2. Identify the exact text to save. If the user supplied text, preserve their + wording. If the user said "remember that" and the referent is unclear, ask one + short question. + +3. Write with `write_note`: + - `title`: first line trimmed to 80 characters, or a short descriptive title + - `directory`: `rememberFolder` + - `content`: the text to remember + - `tags`: `["codex", "manual-capture"]` + - route to `primaryProject` if configured + +4. Confirm in one line with the permalink. + +Do not use this for decisions with alternatives or for work handoffs. Use +`bm-decide` or `bm-checkpoint` for those. diff --git a/plugins/codex/skills/bm-remember/agents/openai.yaml b/plugins/codex/skills/bm-remember/agents/openai.yaml new file mode 100644 index 00000000..81d041d7 --- /dev/null +++ b/plugins/codex/skills/bm-remember/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Remember" + short_description: "Save small facts and preferences" + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Use $bm-remember to save this fact or preference into Basic Memory." diff --git a/plugins/codex/skills/bm-remember/assets/icon.svg b/plugins/codex/skills/bm-remember/assets/icon.svg new file mode 100644 index 00000000..5c29438c --- /dev/null +++ b/plugins/codex/skills/bm-remember/assets/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/codex/skills/bm-setup/SKILL.md b/plugins/codex/skills/bm-setup/SKILL.md new file mode 100644 index 00000000..011a9205 --- /dev/null +++ b/plugins/codex/skills/bm-setup/SKILL.md @@ -0,0 +1,101 @@ +--- +name: bm-setup +description: Set up Basic Memory for Codex in the current repo by mapping a Basic Memory project, seeding schemas, and writing .codex/basic-memory.json. +--- + +# Basic Memory for Codex Setup + +Set up the current repo so Codex can orient from Basic Memory and checkpoint work +back into it. Keep the interview short, but always ask before choosing where data +will be written. + +## Preconditions + +Confirm Basic Memory is reachable before changing files: + +1. Prefer MCP: call `list_memory_projects`. +2. If MCP tools are not available, run `basic-memory --version` or `bm --version`. +3. If neither works, stop and tell the user to install Basic Memory and connect the + MCP server. The plugin bundles an `.mcp.json` that starts `uvx basic-memory mcp`. +4. List available projects before the interview. Include cloud/local source, + workspace, qualified name, and project id when available. + +## Interview + +Ask the user to choose the project mapping. Do not infer write targets from the +repo, default project, current directory, or previous local state. + +- storage mode: cloud, local, or mixed. Prefer the user's stated mode over any + CLI default. +- `focus`: code/dev, research, writing, planning, or mixed. +- `primaryProject`: an existing Basic Memory project or a new one to create. +- `secondaryProjects`: optional read-only projects for session-start context. +- `teamProjects`: optional share targets for `bm-share`. +- `captureFolder`: default `codex-sessions`. +- `rememberFolder`: default `codex-remember`. +- `placementConventions`: a short note about where decisions, tasks, and research + notes should land. + +If there are duplicate names, show qualified names and ask the user which one to +use. Prefer qualified project names or project ids for cloud projects. Never pick +between cloud and local variants without confirmation. + +For a new or empty project, suggest a light convention instead of creating empty +folders. For an existing project, inspect `list_directory` and a few notes before +summarizing the real convention. + +## Apply + +After confirming the plan, write `.codex/basic-memory.json` in the repo: + +```json +{ + "basicMemory": { + "primaryProject": "", + "secondaryProjects": [], + "projectMode": "cloud", + "teamProjects": {}, + "focus": "", + "captureFolder": "codex-sessions", + "rememberFolder": "codex-remember", + "recallTimeframe": "7d", + "placementConventions": "" + } +} +``` + +Preserve unrelated keys if the file already exists. Include `projectMode` when +the user chose cloud, local, or mixed routing. This file is intentionally +Codex-specific; do not write `.claude/settings.json`. + +## Seed Schemas + +Read the schema files from `/schemas/`. This skill lives at +`/skills/bm-setup/SKILL.md`, so the schemas are two directories up. + +Seed these schema notes into the chosen `primaryProject` if they do not already +exist: + +- `codex-session.md` +- `decision.md` +- `task.md` + +Use `write_note` with `directory="schemas"`, `note_type="schema"`, schema +frontmatter as metadata, and the markdown body as content. Do not paste the YAML +frontmatter into content. + +Before seeding schemas, restate the exact target project and ask for confirmation +if it differs from the user's selected primary project or if routing is +ambiguous. + +## Verify + +Before closing, prove the mapping works: + +- Search the primary project for `type=schema` with page size 5. +- Search one shared project for open decisions if shared projects were configured. +- If either query errors, fix the project ref before finishing. + +Finish with the project mapping, schemas seeded or skipped, and the verification +result. Tell the user that plugin hooks need to be reviewed and trusted in Codex +before they run. diff --git a/plugins/codex/skills/bm-setup/agents/openai.yaml b/plugins/codex/skills/bm-setup/agents/openai.yaml new file mode 100644 index 00000000..4ab83c4c --- /dev/null +++ b/plugins/codex/skills/bm-setup/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Setup" + short_description: "Map this repo to Basic Memory" + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Use $bm-setup to map this repo to the right Basic Memory project." diff --git a/plugins/codex/skills/bm-setup/assets/icon.svg b/plugins/codex/skills/bm-setup/assets/icon.svg new file mode 100644 index 00000000..0c72399f --- /dev/null +++ b/plugins/codex/skills/bm-setup/assets/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/plugins/codex/skills/bm-share/SKILL.md b/plugins/codex/skills/bm-share/SKILL.md new file mode 100644 index 00000000..8ebc3f78 --- /dev/null +++ b/plugins/codex/skills/bm-share/SKILL.md @@ -0,0 +1,38 @@ +--- +name: bm-share +description: Share a personal Basic Memory note to a configured team project from Codex with attribution and explicit confirmation. +--- + +# Share A Note + +Copy a note from the configured primary project to a configured team project. This +is the deliberate shared-write path. Automatic checkpoints and quick remembers +stay personal. + +## Steps + +1. Read `.codex/basic-memory.json` and resolve: + - `primaryProject` + - `teamProjects`, a map of project ref to settings + +2. If no team projects are configured, stop and ask the user to run setup or add a + target. Do not invent a team destination. + +3. Read the source note from the user's argument or the current conversation. If + ambiguous, ask which note to share. + +4. Pick the target. If there is more than one team project, ask which one. + +5. Confirm before writing. The prompt should be specific: + `Share "" to <target>/<promoteFolder>?` + +6. Write the copy: + - route to the target project + - `directory`: target `promoteFolder`, default `shared` + - preserve the original content and useful frontmatter + - add `shared_from: <source permalink>` frontmatter when possible + - add `- [context] Shared from <source permalink>` as an observation + +7. Confirm with the new team permalink. + +Never share secrets, credentials, or private notes without an explicit yes. diff --git a/plugins/codex/skills/bm-share/agents/openai.yaml b/plugins/codex/skills/bm-share/agents/openai.yaml new file mode 100644 index 00000000..45628631 --- /dev/null +++ b/plugins/codex/skills/bm-share/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Share" + short_description: "Copy notes to configured team projects" + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Use $bm-share to share this Basic Memory note with a configured team project." diff --git a/plugins/codex/skills/bm-share/assets/icon.svg b/plugins/codex/skills/bm-share/assets/icon.svg new file mode 100644 index 00000000..2a4427fc --- /dev/null +++ b/plugins/codex/skills/bm-share/assets/icon.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#111827" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="18" cy="5" r="3"/> + <circle cx="6" cy="12" r="3"/> + <circle cx="18" cy="19" r="3"/> + <path d="M8.6 10.6l5.8-3.2"/> + <path d="M8.6 13.4l5.8 3.2"/> +</svg> diff --git a/plugins/codex/skills/bm-status/SKILL.md b/plugins/codex/skills/bm-status/SKILL.md new file mode 100644 index 00000000..471025d5 --- /dev/null +++ b/plugins/codex/skills/bm-status/SKILL.md @@ -0,0 +1,50 @@ +--- +name: bm-status +description: Report the Basic Memory for Codex configuration, reachability, hook expectations, recent Codex checkpoints, and active tasks. +--- + +# Basic Memory For Codex Status + +Gather a concise diagnostic. Do not over-investigate. + +## Gather + +1. CLI reachability: + - `basic-memory --version` + - fallback `bm --version` + +2. Plugin config: + - read `.codex/basic-memory.json` + - report `primaryProject`, `secondaryProjects`, `teamProjects`, + `captureFolder`, `rememberFolder`, `recallTimeframe`, and `focus` + +3. Hook files: + - confirm `plugins/codex/hooks/hooks.json` exists if running from this repo + - remind the user that Codex plugin hooks must be reviewed and trusted before + they run + +4. Basic Memory queries: + - recent `type=codex_session`, page size 5 + - active `type=task`, `status=active` + - open `type=decision`, `status=open` + +## Present + +Use this shape: + +```text +Basic Memory for Codex +- CLI: <version or missing> +- Project: <primaryProject or default> +- Reads from: <secondaryProjects or none> +- Share targets: <teamProjects or none> +- Capture folder: <captureFolder> +- Remember folder: <rememberFolder> +- Recall timeframe: <recallTimeframe> +- Recent Codex checkpoints: <count> +- Active tasks: <count> +- Open decisions: <count> +- Hooks: installed; trust review required in Codex +``` + +List recent checkpoints by title and permalink when available. diff --git a/plugins/codex/skills/bm-status/agents/openai.yaml b/plugins/codex/skills/bm-status/agents/openai.yaml new file mode 100644 index 00000000..9756e958 --- /dev/null +++ b/plugins/codex/skills/bm-status/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Status" + short_description: "Check Basic Memory plugin health" + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Use $bm-status to report the Basic Memory for Codex configuration and health." diff --git a/plugins/codex/skills/bm-status/assets/icon.svg b/plugins/codex/skills/bm-status/assets/icon.svg new file mode 100644 index 00000000..09e25c76 --- /dev/null +++ b/plugins/codex/skills/bm-status/assets/icon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#111827" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> + <path d="M3 12h4l2-6 4 12 2-6h6"/> + <path d="M4 20h16"/> +</svg> diff --git a/scripts/validate_codex_plugin.py b/scripts/validate_codex_plugin.py new file mode 100755 index 00000000..93e34935 --- /dev/null +++ b/scripts/validate_codex_plugin.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Validate the Basic Memory Codex plugin layout.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +from typing import Any + +from validate_skills import parse_frontmatter + + +REQUIRED_SKILLS = ( + "bm-setup", + "bm-orient", + "bm-checkpoint", + "bm-decide", + "bm-remember", + "bm-share", + "bm-status", +) +REQUIRED_SCHEMAS = ("codex-session.md", "decision.md", "task.md") +REQUIRED_HOOK_EVENTS = ("SessionStart", "PreCompact") +REQUIRED_HOOK_SCRIPTS = ( + "hooks/session-start.sh", + "hooks/session-start.py", + "hooks/pre-compact.sh", + "hooks/pre-compact.py", +) +REQUIRED_SKILL_AGENT_FILES = ("agents/openai.yaml", "assets/icon.svg") +REQUIRED_INTERFACE_ASSETS = { + "composerIcon": "assets/app-icon.png", + "logo": "assets/logo.png", +} + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text()) + except FileNotFoundError: + raise SystemExit(f"Missing JSON file: {path}") from None + except json.JSONDecodeError as exc: + raise SystemExit(f"{path}: invalid JSON: {exc}") from None + if not isinstance(payload, dict): + raise SystemExit(f"{path}: expected a JSON object") + return payload + + +def require_path(path: Path, label: str) -> None: + if not path.exists(): + raise SystemExit(f"Missing {label}: {path}") + + +def validate_plugin(plugin_dir: Path) -> None: + plugin_dir = plugin_dir.resolve() + + # --- Manifest --- + manifest_path = plugin_dir / ".codex-plugin" / "plugin.json" + manifest = read_json(manifest_path) + if manifest.get("name") != "codex": + raise SystemExit(f"{manifest_path}: expected name=codex") + if manifest.get("skills") != "./skills/": + raise SystemExit(f"{manifest_path}: expected skills=./skills/") + if manifest.get("mcpServers") != "./.mcp.json": + raise SystemExit(f"{manifest_path}: expected mcpServers=./.mcp.json") + interface = manifest.get("interface") + if not isinstance(interface, dict): + raise SystemExit(f"{manifest_path}: missing interface object") + if interface.get("displayName") != "Basic Memory for Codex": + raise SystemExit(f"{manifest_path}: unexpected interface.displayName") + for field, expected_path in REQUIRED_INTERFACE_ASSETS.items(): + if interface.get(field) != f"./{expected_path}": + raise SystemExit(f"{manifest_path}: expected interface.{field}=./{expected_path}") + require_path(plugin_dir / expected_path, f"interface.{field} asset") + + # --- MCP --- + mcp = read_json(plugin_dir / ".mcp.json") + servers = mcp.get("mcpServers") + if not isinstance(servers, dict) or "basic-memory" not in servers: + raise SystemExit(".mcp.json: expected mcpServers.basic-memory") + basic_memory = servers["basic-memory"] + if not isinstance(basic_memory, dict): + raise SystemExit(".mcp.json: basic-memory server must be an object") + if basic_memory.get("command") not in {"uvx", "basic-memory", "bm"}: + raise SystemExit(".mcp.json: basic-memory server uses an unexpected command") + + # --- Hooks --- + hooks_json = read_json(plugin_dir / "hooks" / "hooks.json") + hooks = hooks_json.get("hooks") + if not isinstance(hooks, dict): + raise SystemExit("hooks/hooks.json: expected hooks object") + for event in REQUIRED_HOOK_EVENTS: + if event not in hooks: + raise SystemExit(f"hooks/hooks.json: missing {event}") + for rel in REQUIRED_HOOK_SCRIPTS: + script = plugin_dir / rel + require_path(script, "hook script") + if not os.access(script, os.X_OK): + raise SystemExit(f"Hook script is not executable: {script}") + + # --- Skills --- + skills_root = plugin_dir / "skills" + require_path(skills_root, "skills directory") + present = {path.name for path in skills_root.iterdir() if path.is_dir()} + for skill_name in REQUIRED_SKILLS: + if skill_name not in present: + raise SystemExit(f"Missing required skill: skills/{skill_name}/SKILL.md") + for skill_dir in sorted(path for path in skills_root.iterdir() if path.is_dir()): + skill_file = skill_dir / "SKILL.md" + require_path(skill_file, "skill file") + frontmatter = parse_frontmatter(skill_file) + if frontmatter.get("name") != skill_dir.name: + raise SystemExit(f"{skill_file}: name must match directory") + if not frontmatter.get("description"): + raise SystemExit(f"{skill_file}: missing description") + for rel in REQUIRED_SKILL_AGENT_FILES: + require_path(skill_dir / rel, f"skill {rel}") + + # --- Schemas --- + schemas_root = plugin_dir / "schemas" + require_path(schemas_root, "schemas directory") + for schema_name in REQUIRED_SCHEMAS: + schema_file = schemas_root / schema_name + require_path(schema_file, "schema") + frontmatter = parse_frontmatter(schema_file) + if frontmatter.get("type") != "schema": + raise SystemExit(f"{schema_file}: expected type: schema") + if not frontmatter.get("entity"): + raise SystemExit(f"{schema_file}: missing entity") + + print(f"validated Codex plugin in {plugin_dir}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("plugin_dir", nargs="?", default="plugins/codex") + args = parser.parse_args() + validate_plugin(Path.cwd() / args.plugin_dir) + + +if __name__ == "__main__": + main() diff --git a/tests/test_codex_plugin_package.py b/tests/test_codex_plugin_package.py new file mode 100644 index 00000000..7f2f968c --- /dev/null +++ b/tests/test_codex_plugin_package.py @@ -0,0 +1,57 @@ +import subprocess +from pathlib import Path + + +def test_codex_plugin_mcp_config_is_tracked_and_not_ignored() -> None: + repo_root = Path(__file__).resolve().parents[1] + rel_path = "plugins/codex/.mcp.json" + + ignored = subprocess.run( + ["git", "check-ignore", "--quiet", rel_path], + cwd=repo_root, + check=False, + ) + assert ignored.returncode == 1 + + tracked = subprocess.run( + ["git", "ls-files", "--error-unmatch", rel_path], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + assert tracked.returncode == 0, tracked.stderr + + +def test_codex_plugin_hooks_use_clear_portable_runtime_patterns() -> None: + repo_root = Path(__file__).resolve().parents[1] + pre_compact_sh = (repo_root / "plugins/codex/hooks/pre-compact.sh").read_text(encoding="utf-8") + pre_compact_py = (repo_root / "plugins/codex/hooks/pre-compact.py").read_text(encoding="utf-8") + session_start_sh = (repo_root / "plugins/codex/hooks/session-start.sh").read_text( + encoding="utf-8" + ) + session_start_py = (repo_root / "plugins/codex/hooks/session-start.py").read_text( + encoding="utf-8" + ) + + assert "python3 <<'PY'" not in pre_compact_sh + assert "python3 <<'PY'" not in session_start_sh + assert 'uv run --script "$script_dir/pre-compact.py"' in pre_compact_sh + assert 'uv run --script "$script_dir/session-start.py"' in session_start_sh + assert pre_compact_py.startswith("#!/usr/bin/env -S uv run --script\n") + assert session_start_py.startswith("#!/usr/bin/env -S uv run --script\n") + assert "from datetime import datetime, timezone" in pre_compact_py + assert "datetime.now(timezone.utc)" in pre_compact_py + assert 'now.isoformat(timespec="seconds")' in pre_compact_py + assert "if r not in codex_rows" not in session_start_py + + +def test_codex_plugin_docs_explain_global_install_and_repo_mapping() -> None: + repo_root = Path(__file__).resolve().parents[1] + readme = (repo_root / "plugins/codex/README.md").read_text(encoding="utf-8") + + assert "## Install" in readme + assert 'codex plugin marketplace add "$(git rev-parse --show-toplevel)"' in readme + assert "codex plugin add codex@basic-memory-local" in readme + assert "Plugin installation is user-level in Codex" in readme + assert "Each repository still needs its own `.codex/basic-memory.json`" in readme