From 7c69ba7baf9b3410060edcca231862ab0526ef7d Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Mon, 24 Aug 2026 02:39:16 +0300 Subject: [PATCH] Write Codex a skill, not a prompt it stopped reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the engram slash command appears in OpenClaude and Opencode but not Codex. The cause is a defect in engram, not the user's configuration. Codex 0.149 removed `~/.codex/prompts/` entirely. The shipped binary contains no `prompts` string at all; the feature moved to skills, discovered as `//SKILL.md`. Engram had been writing three prompt files into that directory for a release and nothing ever loaded them — the harness table had drifted from the harness. Codex's skills root is `~/.codex/skills`, which needs no marketplace entry, no `config.toml` registration, and no `codex plugin add`. That is why it is the right target: the alternative route (a personal plugin plus an entry in `~/.agents/plugins/marketplace.json` plus an install entry in `config.toml`) would mean mutating two config files engram does not own, which only `--hooks` is allowed to do, opt-in and with a backup. `CommandSurface::Skill { dir }` is a new variant rather than a reuse of `Plugin { dir }`: a plugin wraps its skills in a directory with a manifest, a bare skills root does not, and collapsing the two would have written Codex a manifest it has no use for. Engram never deletes, so the stale `~/.codex/prompts/engram-*.md` files stay where they are; the CHANGELOG says they can be removed by hand. Gates: fmt, clippy -D warnings, 255 tests, REUSE 3.3, makeinfo clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016i16R4GhdSffsboRYq97Fs --- AGENTS.md | 6 +++--- CHANGELOG.md | 16 ++++++++++++++++ src/harness.rs | 23 ++++++++++++++++++----- src/install.rs | 4 +++- tests/cli.rs | 26 +++++++++++++++++--------- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 308c62f..41dfac9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,10 +269,10 @@ Two readers exist: `claude_code` and `codex`. Adding a third means adding a `Rea - **`plugins/engram/` is the single source of truth.** `install.rs` embeds the command bodies with `include_str!`, so the plugin directory and the installed files cannot drift and the compiler enforces the files exist. Exactly two substitutions, via `str::replace`, no template engine: `{{DB}}` and `{{HARNESS}}`. - **`{{DB}}` is load-bearing.** The path is discovered from the harness's *own* MCP registration (`harness::registered_db`) — on a typical host all writable harnesses point at one shared store (here `~/.local/share/engram/engram.db`) — but see the drift note below: what they registered *yesterday* is not necessarily what a previously-generated command still pins. A generated command that omitted `--db` would fall back to clap's relative `engram.db` default and quietly write to a different store than the agents read. Config formats are scanned narrowly rather than deserialized: JSON (`mcpServers`, or Opencode's `mcp`), **JSONC** (comment-stripped by a string-aware pass — a `//` inside `"https://…"` must survive), and TOML (line-scanned, so engram needs no TOML dependency). Engram **reads** JSONC and never rewrites it; a serde round-trip would delete the user's comments. -- **5 of 8 harnesses can host something; only 4 host a *command*.** Claude Code, **OpenClaude**, Codex, and Opencode have writable command dirs. **Antigravity has no slash-command directory at all** — its extension surface is skills, packaged in plugins, and `agy plugin validate` reports a plugin's `commands/` as "2 processed (converted to skills)", so a command there is a skill either way. Engram writes it a plugin (`~/.gemini/config/plugins/engram/`: `plugin.json` + one `skills/engram-/SKILL.md` per command). Goose, Copilot CLI, and Qwen have nothing engram can write and each says so **in its own words** — one shared sentence described none of them accurately. +- **5 of 8 harnesses can host something; only 3 host a *command*.** Claude Code, **OpenClaude**, and Opencode have writable command dirs. **Codex 0.149 removed `~/.codex/prompts/`** — the binary contains no such string — and moved to skills at `~/.codex/skills//SKILL.md`, discovered automatically with nothing to register; engram writes there now. It wrote prompt files nobody read for a release, which is exactly what a harness table drifting from reality looks like. **Antigravity has no slash-command directory at all** — its extension surface is skills, packaged in plugins, and `agy plugin validate` reports a plugin's `commands/` as "2 processed (converted to skills)", so a command there is a skill either way. Engram writes it a plugin (`~/.gemini/config/plugins/engram/`: `plugin.json` + one `skills/engram-/SKILL.md` per command). Goose, Copilot CLI, and Qwen have nothing engram can write and each says so **in its own words** — one shared sentence described none of them accurately. - **OpenClaude is a Claude Code fork** (`@gitlawb/openclaude`) with its own config root. Its MCP registration lives in `~/.openclaude.json` — the `~/.claude.json` analogue — **not** `~/.openclaude/settings.json`, which holds env/model/hooks and no servers block. Its transcripts are Claude Code's format down to the record keys, so `ReaderKind::ClaudeCode` serves both; the fork-only record types (`mode`, `file-history-snapshot`, `last-prompt`) are already in the non-message allowlist and must stay there, since a fork tripping `unknown_record` every run would train the reader to ignore its own drift alarm. -- **`CommandSurface` is an enum, not a bool.** `Markdown { dir, file, frontmatter }`, `Plugin { dir }`, `None { detail }`. Antigravity broke the old `command_frontmatter: bool` because the *shape* of the artifact differs, not just its header — and `None` carries a per-harness reason. -- **Frontmatter is per-surface.** `Markdown { frontmatter: false }` for Codex, whose prompts are plain markdown and would otherwise render the YAML block as literal text at the top of every prompt. A `Plugin` skill has a *different* contract again — `name` + `description`, no `argument-hint`, no `allowed-tools` — and lifts its description from the shared template so the two surfaces cannot describe the same command differently. +- **`CommandSurface` is an enum, not a bool.** `Markdown { dir, file, frontmatter }`, `Skill { dir }` (a bare skills root, scanned directly — Codex), `Plugin { dir }` (a plugin wrapping skills — Antigravity), `None { detail }`. Antigravity broke the old `command_frontmatter: bool` because the *shape* of the artifact differs, not just its header — and `None` carries a per-harness reason. +- **Frontmatter is per-surface.** `Markdown { frontmatter: false }` exists for a harness whose command files are plain markdown and would otherwise render the YAML block as literal text; no shipped harness uses it since Codex moved to skills, and it stays because the next one may. A `Skill` or `Plugin` skill has a *different* contract again — `name` + `description`, no `argument-hint`, no `allowed-tools` — and lifts its description from the shared template so the two surfaces cannot describe the same command differently. - **The banner carries no version** (``). Putting one there would make every release rewrite every installed file, turning `install` from idempotent into perpetually-updating. - **Nix, and the corrected skill rule.** Engram installs into whatever surface a harness makes *writable*, and never into the Nix store. The older rule — "engram never ships a skill" — was written for `~/.claude/skills`, a read-only symlink into the store; it does not generalise. Antigravity's `~/.gemini/config/skills` is store-managed too, but its sibling `~/.gemini/config/plugins` is writable, and a plugin may contain skills — so that is where engram writes. `is_nix_managed` warns when a target resolves into the store, since the next `home-manager switch` would clobber the write; those users reference `plugins/` declaratively instead. - **The pinned database is checked against the registered one.** `install` reads the `--db` already baked into a generated command and, when it differs from what the harness now registers, reports the drift on the file *and* the harness before correcting it. This is not hypothetical: on the author's machine every harness moved to `~/.local/share/engram/engram.db` after `install` had pinned `~/.gemini/engram.db`, so the slash commands and the MCP tools read different stores for weeks with nothing to say so. Every response also carries `db_origin` (`override` / `registered` / `env` / `default`), because `default` is a *relative* `engram.db` that resolves against whatever directory the command runs in. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e744af..031a714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ follows [Keep a Changelog](https://keepachangelog.com/); versions follow ## [Unreleased] +### Fixed + +- **Codex commands went to a directory Codex no longer reads.** Codex 0.149 + removed `~/.codex/prompts/` entirely and moved to skills; engram had been + writing three prompt files there that nothing loaded. Engram now writes + `~/.codex/skills/engram-/SKILL.md`, the location Codex discovers + automatically with no marketplace or config registration. Engram never + deletes, so the stale `~/.codex/prompts/engram-*.md` files are left in place + and can be removed by hand. + +### Added + +- `CommandSurface::Skill { dir }` — a bare skills root scanned directly by the + harness, distinct from `Plugin { dir }`, which wraps skills in a plugin + directory with a manifest. + ### Added - **OpenClaude** is a supported harness (the eighth). It is a Claude Code fork diff --git a/src/harness.rs b/src/harness.rs index b8bb606..812d59e 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -91,6 +91,16 @@ pub enum CommandSurface { /// Home-relative directory holding plugin subdirectories. dir: &'static str, }, + /// A bare skills directory the harness scans directly, one subdirectory + /// per skill holding `SKILL.md`. + /// + /// Distinct from [`CommandSurface::Plugin`]: there is no plugin wrapper and + /// no manifest, and discovery is automatic — nothing has to be registered + /// in a config file engram does not own. + Skill { + /// Home-relative directory the harness scans for skills. + dir: &'static str, + }, /// Nothing engram can write, and why not. The reason is per-harness /// because the reasons genuinely differ. None { detail: &'static str }, @@ -188,10 +198,11 @@ pub const ALL: &[HarnessSpec] = &[ probe: &[".codex/config.toml", ".codex"], sessions_dir: Some(".codex/sessions"), transcript: TranscriptSupport::Reader(ReaderKind::Codex), - command_surface: CommandSurface::Markdown { - dir: ".codex/prompts", - file: "engram-{name}.md", - frontmatter: false, + // Codex 0.149 removed `~/.codex/prompts/` entirely --- the binary + // contains no such string --- and moved to skills. Engram wrote three + // files into that directory for a release and nothing ever read them. + command_surface: CommandSurface::Skill { + dir: ".codex/skills", }, mcp_config: Some(McpConfigSource::Toml(".codex/config.toml")), hooks_config: None, @@ -350,7 +361,9 @@ pub fn sessions_dir(spec: &HarnessSpec) -> Option { /// Absolute path to a harness's command directory, when it has one. pub fn commands_dir(spec: &HarnessSpec) -> Option { match spec.command_surface { - CommandSurface::Markdown { dir, .. } | CommandSurface::Plugin { dir } => in_home(dir), + CommandSurface::Markdown { dir, .. } + | CommandSurface::Plugin { dir } + | CommandSurface::Skill { dir } => in_home(dir), CommandSurface::None { .. } => None, } } diff --git a/src/install.rs b/src/install.rs index ae3b633..54bf56e 100644 --- a/src/install.rs +++ b/src/install.rs @@ -203,6 +203,8 @@ fn command_path(spec: &HarnessSpec, dir: &std::path::Path, name: &str) -> PathBu .join("skills") .join(format!("engram-{name}")) .join("SKILL.md"), + // A bare skills root: no plugin wrapper, no manifest. + CommandSurface::Skill { .. } => dir.join(format!("engram-{name}")).join("SKILL.md"), // Unreachable: the caller returns early for a harness with no surface. CommandSurface::None { .. } => dir.join(name), } @@ -357,7 +359,7 @@ pub fn install( let stale = pinned_db(&path).filter(|old| *old != db); let body = match spec.command_surface { - CommandSurface::Plugin { .. } => { + CommandSurface::Plugin { .. } | CommandSurface::Skill { .. } => { render_skill(command.name, command.body, spec, &db) } _ => render_command(command.body, spec, &db), diff --git a/tests/cli.rs b/tests/cli.rs index 9b58149..778a7f0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2299,9 +2299,12 @@ fn install_skips_files_it_did_not_write_unless_forced() { } /// Codex reads plain markdown prompts; frontmatter would render as literal -/// text at the top of every prompt. +/// Codex 0.149 removed `~/.codex/prompts/` and moved to skills, so engram +/// writes it a skill directory rather than a prompt file. Claude Code still +/// takes a markdown command with frontmatter — the two surfaces differ in the +/// *shape* of the artifact, not merely in whether a header is read. #[test] -fn install_strips_frontmatter_for_harnesses_that_do_not_read_it() { +fn install_writes_codex_a_skill_and_claude_code_a_command() { let tmp = TempDir::new().expect("tempdir"); let db = tmp.path().join("test.db"); let home = tmp.path().join("home"); @@ -2313,20 +2316,25 @@ fn install_strips_frontmatter_for_harnesses_that_do_not_read_it() { .assert() .success(); - let codex = std::fs::read_to_string(home.join(".codex/prompts/engram-save-chat.md")) - .expect("codex prompt written"); - assert!( - !codex.contains("argument-hint:"), - "frontmatter leaked: {codex}" - ); + // Codex: one directory per skill, holding SKILL.md. + let codex = std::fs::read_to_string(home.join(".codex/skills/engram-save-chat/SKILL.md")) + .expect("codex skill written"); + assert!(codex.starts_with("---\n"), "{codex}"); + assert!(codex.contains("name: engram-save-chat")); + assert!(codex.contains("description: ")); + // Skill frontmatter, not command frontmatter. assert!(codex.contains("--db /shared/engram.db")); assert!(codex.contains("--harness codex")); + // The directory Codex abandoned must not be written to any more. + assert!(predicate::path::missing().eval(&home.join(".codex/prompts"))); + + // Claude Code is unchanged: a markdown command that keeps its frontmatter. let claude = std::fs::read_to_string(home.join(".claude/commands/engram-save-chat.md")) .expect("claude command written"); assert!( claude.contains("argument-hint:"), - "claude code reads frontmatter" + "claude code reads command frontmatter" ); assert!(claude.contains("--harness claude-code")); }