Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>/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/<name>/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-<name>/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** (`<!-- Generated by \`engram install\`. Edits are overwritten. -->`). 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.
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>/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
Expand Down
23 changes: 18 additions & 5 deletions src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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",
Comment on lines +204 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the bundled install guide for the skill migration

This switch leaves plugins/engram/README.md lines 32–47 directing Codex users to ~/.codex/prompts/ and explicitly stating that engram never ships skills because skill directories are unwritable. Those instructions are now the opposite of the installer behavior and will mislead users following the source-tree installation and Nix guidance; update that guide alongside the path change.

Useful? React with 👍 / 👎.

},
mcp_config: Some(McpConfigSource::Toml(".codex/config.toml")),
hooks_config: None,
Expand Down Expand Up @@ -350,7 +361,9 @@ pub fn sessions_dir(spec: &HarnessSpec) -> Option<PathBuf> {
/// Absolute path to a harness's command directory, when it has one.
pub fn commands_dir(spec: &HarnessSpec) -> Option<PathBuf> {
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,
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -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 { .. } => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Quote descriptions before emitting Codex skill YAML

When installing engram-save-chat for Codex, this new arm routes the template through render_skill, which emits its description without YAML escaping. That template contains description: Save this conversation: capture ...; the colon followed by a space makes the generated SKILL.md invalid YAML, so Codex cannot load the primary save-chat skill. Serialize or quote the description rather than interpolating it as a plain scalar.

AGENTS.md reference: AGENTS.md:L274-L275

Useful? React with 👍 / 👎.

render_skill(command.name, command.body, spec, &db)
}
_ => render_command(command.body, spec, &db),
Expand Down
26 changes: 17 additions & 9 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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"));
}
Expand Down
Loading