From 459366e1f3411620756df347b9c6b67fbfddf2e0 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 4 Jun 2026 21:43:35 -0500 Subject: [PATCH 1/4] feat(plugins): add codex plugin package Signed-off-by: phernandez --- .agents/plugins/marketplace.json | 20 ++ justfile | 8 +- plugins/codex/.codex-plugin/plugin.json | 46 ++++ plugins/codex/DEVELOPMENT.md | 76 ++++++ plugins/codex/README.md | 74 ++++++ plugins/codex/assets/app-icon.png | Bin 0 -> 10360 bytes plugins/codex/assets/logo.png | Bin 0 -> 10360 bytes plugins/codex/hooks/hooks.json | 31 +++ plugins/codex/hooks/pre-compact.sh | 224 ++++++++++++++++++ plugins/codex/hooks/session-start.sh | 207 ++++++++++++++++ plugins/codex/justfile | 19 ++ plugins/codex/schemas/codex-session.md | 47 ++++ plugins/codex/schemas/decision.md | 30 +++ plugins/codex/schemas/task.md | 30 +++ plugins/codex/skills/bm-checkpoint/SKILL.md | 62 +++++ .../skills/bm-checkpoint/agents/openai.yaml | 7 + .../skills/bm-checkpoint/assets/icon.svg | 6 + plugins/codex/skills/bm-decide/SKILL.md | 35 +++ .../codex/skills/bm-decide/agents/openai.yaml | 7 + .../codex/skills/bm-decide/assets/icon.svg | 4 + plugins/codex/skills/bm-orient/SKILL.md | 36 +++ .../codex/skills/bm-orient/agents/openai.yaml | 7 + .../codex/skills/bm-orient/assets/icon.svg | 5 + plugins/codex/skills/bm-remember/SKILL.md | 31 +++ .../skills/bm-remember/agents/openai.yaml | 7 + .../codex/skills/bm-remember/assets/icon.svg | 5 + plugins/codex/skills/bm-setup/SKILL.md | 101 ++++++++ .../codex/skills/bm-setup/agents/openai.yaml | 7 + plugins/codex/skills/bm-setup/assets/icon.svg | 11 + plugins/codex/skills/bm-share/SKILL.md | 38 +++ .../codex/skills/bm-share/agents/openai.yaml | 7 + plugins/codex/skills/bm-share/assets/icon.svg | 7 + plugins/codex/skills/bm-status/SKILL.md | 50 ++++ .../codex/skills/bm-status/agents/openai.yaml | 7 + .../codex/skills/bm-status/assets/icon.svg | 4 + scripts/validate_codex_plugin.py | 139 +++++++++++ skills-lock.json | 10 - 37 files changed, 1393 insertions(+), 12 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 plugins/codex/.codex-plugin/plugin.json create mode 100644 plugins/codex/DEVELOPMENT.md create mode 100644 plugins/codex/README.md create mode 100644 plugins/codex/assets/app-icon.png create mode 100644 plugins/codex/assets/logo.png create mode 100644 plugins/codex/hooks/hooks.json create mode 100755 plugins/codex/hooks/pre-compact.sh create mode 100755 plugins/codex/hooks/session-start.sh create mode 100644 plugins/codex/justfile create mode 100644 plugins/codex/schemas/codex-session.md create mode 100644 plugins/codex/schemas/decision.md create mode 100644 plugins/codex/schemas/task.md create mode 100644 plugins/codex/skills/bm-checkpoint/SKILL.md create mode 100644 plugins/codex/skills/bm-checkpoint/agents/openai.yaml create mode 100644 plugins/codex/skills/bm-checkpoint/assets/icon.svg create mode 100644 plugins/codex/skills/bm-decide/SKILL.md create mode 100644 plugins/codex/skills/bm-decide/agents/openai.yaml create mode 100644 plugins/codex/skills/bm-decide/assets/icon.svg create mode 100644 plugins/codex/skills/bm-orient/SKILL.md create mode 100644 plugins/codex/skills/bm-orient/agents/openai.yaml create mode 100644 plugins/codex/skills/bm-orient/assets/icon.svg create mode 100644 plugins/codex/skills/bm-remember/SKILL.md create mode 100644 plugins/codex/skills/bm-remember/agents/openai.yaml create mode 100644 plugins/codex/skills/bm-remember/assets/icon.svg create mode 100644 plugins/codex/skills/bm-setup/SKILL.md create mode 100644 plugins/codex/skills/bm-setup/agents/openai.yaml create mode 100644 plugins/codex/skills/bm-setup/assets/icon.svg create mode 100644 plugins/codex/skills/bm-share/SKILL.md create mode 100644 plugins/codex/skills/bm-share/agents/openai.yaml create mode 100644 plugins/codex/skills/bm-share/assets/icon.svg create mode 100644 plugins/codex/skills/bm-status/SKILL.md create mode 100644 plugins/codex/skills/bm-status/agents/openai.yaml create mode 100644 plugins/codex/skills/bm-status/assets/icon.svg create mode 100755 scripts/validate_codex_plugin.py delete mode 100644 skills-lock.json diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 000000000..8533570d5 --- /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/justfile b/justfile index 12594c89e..3842c339d 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 000000000..0360f8e0b --- /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/DEVELOPMENT.md b/plugins/codex/DEVELOPMENT.md new file mode 100644 index 000000000..b8886c4e1 --- /dev/null +++ b/plugins/codex/DEVELOPMENT.md @@ -0,0 +1,76 @@ +# 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. + +## 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 000000000..ebbfe2946 --- /dev/null +++ b/plugins/codex/README.md @@ -0,0 +1,74 @@ +# 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` | Injects a compact memory brief at thread start | +| `hooks/pre-compact.sh` | Writes an automatic Codex checkpoint before compaction | +| `skills/` | Codex-native Basic Memory workflows | +| `schemas/` | Seed schemas for Codex sessions, decisions, and tasks | + +## 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 0000000000000000000000000000000000000000..ae0a7bb944738ac81c5cab89bc7ff8c6c5423628 GIT binary patch literal 10360 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/dev/null 2>&1; then + BM="basic-memory" +elif command -v bm >/dev/null 2>&1; then + BM="bm" +elif command -v uvx >/dev/null 2>&1; then + BM="uvx basic-memory" +elif command -v uv >/dev/null 2>&1; then + BM="uv tool run basic-memory" +else + exit 0 +fi + +BM_HOOK_INPUT="$input" BM_BIN="$BM" python3 <<'PY' 2>/dev/null || exit 0 +import json +import os +import re +import shlex +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +bm_cmd = shlex.split(os.environ.get("BM_BIN") or "basic-memory") +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, +) + +try: + payload = json.loads(os.environ.get("BM_HOOK_INPUT") or "{}") +except Exception: + 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 "" + + +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) + + +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: + sys.exit(0) + + +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() + "..." + + +conversation = transcript_turns(transcript_path) +if not conversation or not any(role == "user" for role, _ in conversation): + sys.exit(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() +iso = now.strftime("%Y-%m-%dT%H:%M:%S") +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: + sys.exit(0) +PY diff --git a/plugins/codex/hooks/session-start.sh b/plugins/codex/hooks/session-start.sh new file mode 100755 index 000000000..d0ef2ad27 --- /dev/null +++ b/plugins/codex/hooks/session-start.sh @@ -0,0 +1,207 @@ +#!/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 + +input="$(cat 2>/dev/null || true)" + +if command -v basic-memory >/dev/null 2>&1; then + BM="basic-memory" +elif command -v bm >/dev/null 2>&1; then + BM="bm" +elif command -v uvx >/dev/null 2>&1; then + BM="uvx basic-memory" +elif command -v uv >/dev/null 2>&1; then + BM="uv tool run basic-memory" +else + exit 0 +fi + +BM_HOOK_INPUT="$input" BM_BIN="$BM" python3 <<'PY' 2>/dev/null || exit 0 +import json +import os +import re +import shlex +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +bm_cmd = shlex.split(os.environ.get("BM_BIN") or "basic-memory") +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 + +try: + payload = json.loads(os.environ.get("BM_HOOK_INPUT") or "{}") +except Exception: + payload = {} + +cwd = Path(payload.get("cwd") or os.getcwd()) +source = payload.get("source") or "startup" + + +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 + + +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() + +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 +shared_refs = shared_refs[:MAX_SHARED] + + +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(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 + + +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, ACTIVE_TASKS, primary_project or None) + fut_decisions = pool.submit(search, OPEN_DECISIONS, primary_project or None) + fut_codex = pool.submit(search, RECENT_CODEX, primary_project or None) + fut_sessions = pool.submit(search, RECENT_GENERIC, primary_project or None) + fut_shared = {ref: pool.submit(search, 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: + if not configured: + print( + "# 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._" + ) + else: + proj = primary_project or "the default project" + print( + "# Basic Memory for Codex\n\n" + f"_Could not read from `{proj}`. Run `Use bm-status` to check the " + "Basic Memory project mapping._" + ) + sys.exit(0) + + +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 + + +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 = [r for r in rows(primary_sessions) if r not in codex_rows] + +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)) +PY diff --git a/plugins/codex/justfile b/plugins/codex/justfile new file mode 100644 index 000000000..796266eb1 --- /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 000000000..3ff825c3a --- /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 000000000..eb7feddbc --- /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 000000000..5b7576e04 --- /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 000000000..a520bef7c --- /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 000000000..31639327f --- /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 000000000..f214d1edc --- /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 000000000..e25e517d6 --- /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 000000000..a1b3ff6a7 --- /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 000000000..a9db5caf1 --- /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 000000000..e59ddc823 --- /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 000000000..29ed36e60 --- /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 000000000..16d7ec774 --- /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 000000000..0109bbe91 --- /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 000000000..81d041d7a --- /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 000000000..5c29438c0 --- /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 000000000..011a92059 --- /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 000000000..4ab83c4ca --- /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 000000000..0c72399f9 --- /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 000000000..8ebc3f782 --- /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 000000000..45628631c --- /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 000000000..2a4427fca --- /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 000000000..471025d51 --- /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 000000000..9756e958a --- /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 000000000..09e25c762 --- /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 000000000..22702a46d --- /dev/null +++ b/scripts/validate_codex_plugin.py @@ -0,0 +1,139 @@ +#!/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/pre-compact.sh") +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/skills-lock.json b/skills-lock.json deleted file mode 100644 index 49ce00303..000000000 --- a/skills-lock.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "skills": { - "instrumentation": { - "source": "pydantic/skills", - "sourceType": "github", - "computedHash": "0727bffc6a92fdeaf675ae5796ae25341e193327e8c95cd06b188dc4a0a4e62e" - } - } -} From 092d2d78a691e2b142a98e2baeb1399132b5c0d1 Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Thu, 4 Jun 2026 22:49:23 -0500 Subject: [PATCH 2/4] fix(plugins): track codex mcp config Signed-off-by: phernandez <paul@basicmachines.co> --- .gitignore | 1 + plugins/codex/.mcp.json | 11 ++++++++ plugins/codex/DEVELOPMENT.md | 4 +++ plugins/codex/README.md | 5 ++++ plugins/codex/hooks/pre-compact.sh | 6 ++-- plugins/codex/hooks/session-start.sh | 2 +- skills-lock.json | 10 +++++++ tests/test_codex_plugin_package.py | 42 ++++++++++++++++++++++++++++ 8 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 plugins/codex/.mcp.json create mode 100644 skills-lock.json create mode 100644 tests/test_codex_plugin_package.py diff --git a/.gitignore b/.gitignore index fe5a5ddb1..7a690759e 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/plugins/codex/.mcp.json b/plugins/codex/.mcp.json new file mode 100644 index 000000000..6e86668da --- /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 index b8886c4e1..a82d4a0c1 100644 --- a/plugins/codex/DEVELOPMENT.md +++ b/plugins/codex/DEVELOPMENT.md @@ -32,6 +32,10 @@ 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: diff --git a/plugins/codex/README.md b/plugins/codex/README.md index ebbfe2946..359342853 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -36,6 +36,11 @@ verification, decision capture, and resumable checkpoints. ## Configuration +Plugin installation is user-level in Codex. Install `codex@basic-memory-local` +once and Codex can load the plugin across projects on the same machine. +The `.codex/basic-memory.json` file is still per repository: it maps that +checkout to the Basic Memory project and folders the hooks should use. + Run the setup skill, or create `.codex/basic-memory.json` in a repo: ```json diff --git a/plugins/codex/hooks/pre-compact.sh b/plugins/codex/hooks/pre-compact.sh index ad6c50d7d..e9c6f594a 100755 --- a/plugins/codex/hooks/pre-compact.sh +++ b/plugins/codex/hooks/pre-compact.sh @@ -28,7 +28,7 @@ import re import shlex import subprocess import sys -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path bm_cmd = shlex.split(os.environ.get("BM_BIN") or "basic-memory") @@ -143,8 +143,8 @@ recent_user = user_messages[-3:] recent_assistant = assistant_messages[-2:] status_lines = git_status(cwd) -now = datetime.now() -iso = now.strftime("%Y-%m-%dT%H:%M:%S") +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 = [ diff --git a/plugins/codex/hooks/session-start.sh b/plugins/codex/hooks/session-start.sh index d0ef2ad27..239c3616c 100755 --- a/plugins/codex/hooks/session-start.sh +++ b/plugins/codex/hooks/session-start.sh @@ -162,7 +162,7 @@ lines.append(f"Session source: {source}") task_rows = rows(primary_tasks) decision_rows = rows(primary_decisions) codex_rows = rows(primary_codex) -session_rows = [r for r in rows(primary_sessions) if r not in codex_rows] +session_rows = rows(primary_sessions) if task_rows: lines += ["", f"## Active Tasks ({len(task_rows)})", *[label(r) for r in task_rows]] diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..49ce00303 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "instrumentation": { + "source": "pydantic/skills", + "sourceType": "github", + "computedHash": "0727bffc6a92fdeaf675ae5796ae25341e193327e8c95cd06b188dc4a0a4e62e" + } + } +} diff --git a/tests/test_codex_plugin_package.py b/tests/test_codex_plugin_package.py new file mode 100644 index 000000000..2da1df749 --- /dev/null +++ b/tests/test_codex_plugin_package.py @@ -0,0 +1,42 @@ +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 = (repo_root / "plugins/codex/hooks/pre-compact.sh").read_text(encoding="utf-8") + session_start = (repo_root / "plugins/codex/hooks/session-start.sh").read_text(encoding="utf-8") + + assert "from datetime import datetime, timezone" in pre_compact + assert "datetime.now(timezone.utc)" in pre_compact + assert 'now.isoformat(timespec="seconds")' in pre_compact + assert "if r not in codex_rows" not in session_start + + +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 "Plugin installation is user-level in Codex" in readme + assert "The `.codex/basic-memory.json` file is still per repository" in readme From aa51d709d0b72ed385fa08410464f787d710570b Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Thu, 4 Jun 2026 22:56:45 -0500 Subject: [PATCH 3/4] docs(plugins): document codex plugin install Signed-off-by: phernandez <paul@basicmachines.co> --- plugins/codex/README.md | 22 +++++++++++++++++----- tests/test_codex_plugin_package.py | 5 ++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 359342853..dfc97b86a 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -34,12 +34,24 @@ verification, decision capture, and resumable checkpoints. | `skills/` | Codex-native Basic Memory workflows | | `schemas/` | Seed schemas for Codex sessions, decisions, and tasks | -## Configuration +## 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. -Plugin installation is user-level in Codex. Install `codex@basic-memory-local` -once and Codex can load the plugin across projects on the same machine. -The `.codex/basic-memory.json` file is still per repository: it maps that -checkout to the Basic Memory project and folders the hooks should use. +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: diff --git a/tests/test_codex_plugin_package.py b/tests/test_codex_plugin_package.py index 2da1df749..aec108f46 100644 --- a/tests/test_codex_plugin_package.py +++ b/tests/test_codex_plugin_package.py @@ -38,5 +38,8 @@ 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 "The `.codex/basic-memory.json` file is still per repository" in readme + assert "Each repository still needs its own `.codex/basic-memory.json`" in readme From 9259812a05afca674f2ee3ae500e911e1b400309 Mon Sep 17 00:00:00 2001 From: phernandez <paul@basicmachines.co> Date: Thu, 4 Jun 2026 23:03:52 -0500 Subject: [PATCH 4/4] refactor(plugins): move codex hooks to uv scripts Signed-off-by: phernandez <paul@basicmachines.co> --- plugins/codex/README.md | 6 +- plugins/codex/hooks/pre-compact.py | 232 ++++++++++++++++++++++++++ plugins/codex/hooks/pre-compact.sh | 215 +----------------------- plugins/codex/hooks/session-start.py | 237 +++++++++++++++++++++++++++ plugins/codex/hooks/session-start.sh | 198 +--------------------- scripts/validate_codex_plugin.py | 7 +- tests/test_codex_plugin_package.py | 24 ++- 7 files changed, 503 insertions(+), 416 deletions(-) create mode 100755 plugins/codex/hooks/pre-compact.py create mode 100755 plugins/codex/hooks/session-start.py diff --git a/plugins/codex/README.md b/plugins/codex/README.md index dfc97b86a..ab3ca4004 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -29,8 +29,10 @@ verification, decision capture, and resumable checkpoints. | `.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` | Injects a compact memory brief at thread start | -| `hooks/pre-compact.sh` | Writes an automatic Codex checkpoint before compaction | +| `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 | diff --git a/plugins/codex/hooks/pre-compact.py b/plugins/codex/hooks/pre-compact.py new file mode 100755 index 000000000..ba996e1be --- /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 index e9c6f594a..cd791060d 100755 --- a/plugins/codex/hooks/pre-compact.sh +++ b/plugins/codex/hooks/pre-compact.sh @@ -7,218 +7,9 @@ set -u -input="$(cat 2>/dev/null || true)" - -if command -v basic-memory >/dev/null 2>&1; then - BM="basic-memory" -elif command -v bm >/dev/null 2>&1; then - BM="bm" -elif command -v uvx >/dev/null 2>&1; then - BM="uvx basic-memory" -elif command -v uv >/dev/null 2>&1; then - BM="uv tool run basic-memory" -else +if ! command -v uv >/dev/null 2>&1; then exit 0 fi -BM_HOOK_INPUT="$input" BM_BIN="$BM" python3 <<'PY' 2>/dev/null || exit 0 -import json -import os -import re -import shlex -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path - -bm_cmd = shlex.split(os.environ.get("BM_BIN") or "basic-memory") -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, -) - -try: - payload = json.loads(os.environ.get("BM_HOOK_INPUT") or "{}") -except Exception: - 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 "" - - -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) - - -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: - sys.exit(0) - - -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() + "..." - - -conversation = transcript_turns(transcript_path) -if not conversation or not any(role == "user" for role, _ in conversation): - sys.exit(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: - sys.exit(0) -PY +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 000000000..1a401d25e --- /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 index 239c3616c..71d067afb 100755 --- a/plugins/codex/hooks/session-start.sh +++ b/plugins/codex/hooks/session-start.sh @@ -7,201 +7,9 @@ set -u -input="$(cat 2>/dev/null || true)" - -if command -v basic-memory >/dev/null 2>&1; then - BM="basic-memory" -elif command -v bm >/dev/null 2>&1; then - BM="bm" -elif command -v uvx >/dev/null 2>&1; then - BM="uvx basic-memory" -elif command -v uv >/dev/null 2>&1; then - BM="uv tool run basic-memory" -else +if ! command -v uv >/dev/null 2>&1; then exit 0 fi -BM_HOOK_INPUT="$input" BM_BIN="$BM" python3 <<'PY' 2>/dev/null || exit 0 -import json -import os -import re -import shlex -import subprocess -import sys -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path - -bm_cmd = shlex.split(os.environ.get("BM_BIN") or "basic-memory") -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 - -try: - payload = json.loads(os.environ.get("BM_HOOK_INPUT") or "{}") -except Exception: - payload = {} - -cwd = Path(payload.get("cwd") or os.getcwd()) -source = payload.get("source") or "startup" - - -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 - - -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() - -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 -shared_refs = shared_refs[:MAX_SHARED] - - -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(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 - - -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, ACTIVE_TASKS, primary_project or None) - fut_decisions = pool.submit(search, OPEN_DECISIONS, primary_project or None) - fut_codex = pool.submit(search, RECENT_CODEX, primary_project or None) - fut_sessions = pool.submit(search, RECENT_GENERIC, primary_project or None) - fut_shared = {ref: pool.submit(search, 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: - if not configured: - print( - "# 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._" - ) - else: - proj = primary_project or "the default project" - print( - "# Basic Memory for Codex\n\n" - f"_Could not read from `{proj}`. Run `Use bm-status` to check the " - "Basic Memory project mapping._" - ) - sys.exit(0) - - -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 - - -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)) -PY +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +uv run --script "$script_dir/session-start.py" 2>/dev/null || exit 0 diff --git a/scripts/validate_codex_plugin.py b/scripts/validate_codex_plugin.py index 22702a46d..93e34935b 100755 --- a/scripts/validate_codex_plugin.py +++ b/scripts/validate_codex_plugin.py @@ -23,7 +23,12 @@ ) REQUIRED_SCHEMAS = ("codex-session.md", "decision.md", "task.md") REQUIRED_HOOK_EVENTS = ("SessionStart", "PreCompact") -REQUIRED_HOOK_SCRIPTS = ("hooks/session-start.sh", "hooks/pre-compact.sh") +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", diff --git a/tests/test_codex_plugin_package.py b/tests/test_codex_plugin_package.py index aec108f46..7f2f968c1 100644 --- a/tests/test_codex_plugin_package.py +++ b/tests/test_codex_plugin_package.py @@ -25,13 +25,25 @@ def test_codex_plugin_mcp_config_is_tracked_and_not_ignored() -> None: def test_codex_plugin_hooks_use_clear_portable_runtime_patterns() -> None: repo_root = Path(__file__).resolve().parents[1] - pre_compact = (repo_root / "plugins/codex/hooks/pre-compact.sh").read_text(encoding="utf-8") - session_start = (repo_root / "plugins/codex/hooks/session-start.sh").read_text(encoding="utf-8") + 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 "from datetime import datetime, timezone" in pre_compact - assert "datetime.now(timezone.utc)" in pre_compact - assert 'now.isoformat(timespec="seconds")' in pre_compact - assert "if r not in codex_rows" not in session_start + 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: