diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9b6e8e0..d951eee 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "meridian", "description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.", - "version": "0.11.1", + "version": "0.11.2", "author": { "name": "KodingDev" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index ec85ff5..79525df 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "meridian", "description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.", - "version": "0.11.1", + "version": "0.11.2", "author": { "name": "KodingDev" }, diff --git a/.plugin/plugin.json b/.plugin/plugin.json index ed03d74..32ae663 100644 --- a/.plugin/plugin.json +++ b/.plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "meridian", - "version": "0.11.1", + "version": "0.11.2", "hooks": "./hooks/hooks-copilot.json" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ce50261..2607315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to Meridian are recorded here. The format follows to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). History before 0.11.0 lives in the git log. +## [0.11.2] - 2026-07-03 + +### Added + +- Codex host support, alongside the existing Claude Code, Cursor, and Copilot hosts. + `detectHost` recognizes Codex by the un-prefixed `PLUGIN_ROOT` it sets — Claude sets only + `CLAUDE_PLUGIN_ROOT`, which Codex also sets as a compat alias — and routes Codex session + state to `CODEX_HOME` (default `~/.codex`). Codex loads the plugin's default + `hooks/hooks.json` and shares Claude's `hookSpecificOutput` contract, so orientation and + the periodic routing audit inject on `SessionStart` and `UserPromptSubmit`, and the + commit-attribution `PreToolUse` guard applies. + +### Fixed + +- Copilot's `research` and `triangulate` subagents keep web access: their `tools` arrays add + Copilot's `web_fetch` tool, which — unlike `Read`/`Grep`/`Glob`/`Bash`, whose Claude names + Copilot resolves as aliases — has no Claude-name alias, so `WebFetch`/`WebSearch` alone + resolved to nothing and left those agents with no web tool. + ## [0.11.1] - 2026-06-29 ### Changed diff --git a/README.md b/README.md index 3b260ec..1bae748 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,14 @@ Hooks are Node scripts (`node ./hooks/*.mjs`) — no Git Bash or shell polyglot ### GitHub Copilot CLI -Copilot CLI installs the same plugin via its `/plugin` commands and loads the skills and subagents directly. Subagent `tools` are declared as YAML arrays of Claude tool names (`[Read, Grep, Glob, Bash, …]`); Copilot resolves these case-insensitively to its own primitives (`read`, `search`, `execute`, `web`), so the agents get full file/search/shell/web access rather than the bare baseline. +Copilot CLI installs the same plugin via its `/plugin` commands and loads the skills and subagents directly. Copilot resolves the Claude subagent tool names as compatible aliases — `Read`, `Grep`, `Glob`, `Bash` map to its `view`, `grep`, `glob`, `bash` tools — so file, search, and shell access carry over unchanged. Its web tool (`web_fetch`) has no Claude-name alias, so the agents that need the web (`research`, `triangulate`) also list `web_fetch` explicitly; otherwise `WebFetch`/`WebSearch` resolve to nothing and the agent silently loses web access. Hooks ship in Copilot's own format: Copilot's command schema treats `command` as a shell string and has no `args` array, so the Claude exec-form config would run a bare `node`. Copilot is pointed at `hooks/hooks-copilot.json` via `.plugin/plugin.json` (a manifest slot Copilot reads but Claude and Cursor don't). Session orientation injects via a flat `additionalContext` on `sessionStart`; prompt submission is state-only, because Copilot's `userPromptSubmitted` hook cannot inject context (same ceiling as Cursor). +### Codex + +Codex CLI installs the plugin from the same marketplace and loads the plugin's default `hooks/hooks.json` — the same Claude-shaped hook config (event → matcher groups → `{ "type": "command" }`) — sharing Claude's `hookSpecificOutput` context contract. So, unlike Cursor and Copilot, orientation injects on `SessionStart` **and** the routing audit injects on `UserPromptSubmit`, and the commit-attribution `PreToolUse` guard runs. Codex is detected by the un-prefixed `PLUGIN_ROOT` it sets (Claude sets only `CLAUDE_PLUGIN_ROOT`, which Codex also sets as a compat alias); state lives under `CODEX_HOME` (default `~/.codex`). Codex does not honor a plugin-manifest hook redirect ([openai/codex#16430](https://github.com/openai/codex/issues/16430)), so there is no Codex-specific hooks file — the default `hooks/hooks.json` drives it. Plugin hooks install untrusted; run one interactive Codex session to review and trust them, after which they fire automatically. + Hook tests: `node --test test/meridian-hooks.test.mjs` ## Credit diff --git a/agents/research.md b/agents/research.md index 9fb1997..2576411 100644 --- a/agents/research.md +++ b/agents/research.md @@ -1,7 +1,7 @@ --- name: research description: Verify external-API/library/protocol facts against live documentation. Returns facts and source URLs, never reasoning chains or hedging. Used by the research skill to isolate doc-fetching from orchestrator context. -tools: [WebFetch, WebSearch, Read, Grep, Glob] +tools: [WebFetch, WebSearch, Read, Grep, Glob, web_fetch] --- # Research Agent diff --git a/agents/triangulate.md b/agents/triangulate.md index d3f4ccd..f24f8c4 100644 --- a/agents/triangulate.md +++ b/agents/triangulate.md @@ -1,7 +1,7 @@ --- name: triangulate description: Multi-source verification subagent. Reads candidate sources, returns a Ground Truth Audit with confidence label and a concrete falsifier. Used by the triangulate skill to isolate heavy verification reading from orchestrator context. -tools: [Read, Grep, Glob, Bash, WebFetch, WebSearch] +tools: [Read, Grep, Glob, Bash, WebFetch, WebSearch, web_fetch] --- # Triangulate Agent diff --git a/hooks/lib/host.mjs b/hooks/lib/host.mjs index e172dff..4f56c82 100644 --- a/hooks/lib/host.mjs +++ b/hooks/lib/host.mjs @@ -22,11 +22,14 @@ function resolveStateBase(name) { const claude = join(homedir(), ".claude"); const cursor = join(homedir(), ".cursor"); const copilot = process.env.COPILOT_HOME || join(homedir(), ".copilot"); - // Copilot sets CLAUDE_PLUGIN_ROOT too, so its branch must precede that fallback. + const codex = process.env.CODEX_HOME || join(homedir(), ".codex"); + // Copilot and Codex both set CLAUDE_PLUGIN_ROOT (as a compat alias), so their + // branches must precede that fallback or they'd resolve to ~/.claude. const candidates = [ () => process.env.CLAUDE_CONFIG_DIR, () => name === "cursor" && cursor, () => name === "copilot" && copilot, + () => name === "codex" && codex, () => process.env.CLAUDE_PLUGIN_ROOT && claude, () => existsSync(claude) && claude, () => existsSync(cursor) && cursor, @@ -41,9 +44,14 @@ function resolveStateBase(name) { /** * Detect the host once. Cursor sets `CURSOR_PLUGIN_ROOT` and injects context only * on `SessionStart` (via `additional_context`); Copilot sets `COPILOT_PLUGIN_ROOT` - * and injects on `SessionStart` only (via flat `additionalContext`); Claude injects - * on every event (via `hookSpecificOutput`). Copilot also sets `CLAUDE_PLUGIN_ROOT`, - * so it must be checked before the Claude default. + * and injects on `SessionStart` only (via flat `additionalContext`); Codex sets the + * un-prefixed `PLUGIN_ROOT` and, like Claude, injects on every event via + * `hookSpecificOutput`; Claude injects on every event via `hookSpecificOutput`. + * Copilot and Codex also set `CLAUDE_PLUGIN_ROOT` (compat), so both must be checked + * before the Claude default. Codex ships no unique identity var, so the un-prefixed + * `PLUGIN_ROOT` — which Codex sets but Claude does not — is the documented + * discriminator; a misdetect only picks the wrong state dir, since Codex and Claude + * share the `hookSpecificOutput` emit shape. * @returns {Host} */ export function detectHost() { @@ -52,7 +60,9 @@ export function detectHost() { ? "cursor" : process.env.COPILOT_PLUGIN_ROOT ? "copilot" - : "claude"; + : process.env.PLUGIN_ROOT + ? "codex" + : "claude"; return { name, stateBase: resolveStateBase(name), diff --git a/hooks/lib/types.mjs b/hooks/lib/types.mjs index 92be2f4..d2085ca 100644 --- a/hooks/lib/types.mjs +++ b/hooks/lib/types.mjs @@ -14,7 +14,7 @@ * @property {string} [hook_event_name] */ -/** @typedef {"claude" | "cursor" | "copilot"} HostName */ +/** @typedef {"claude" | "cursor" | "copilot" | "codex"} HostName */ /** The hook events that can inject context. @typedef {"SessionStart" | "UserPromptSubmit"} HookEvent */ diff --git a/test/meridian-hooks.test.mjs b/test/meridian-hooks.test.mjs index 85aded7..98694ff 100644 --- a/test/meridian-hooks.test.mjs +++ b/test/meridian-hooks.test.mjs @@ -96,6 +96,23 @@ test("hooks.json SessionStart matcher stays empty so it catches the compact sour } }); +test("session-start emits orientation under Codex (un-prefixed PLUGIN_ROOT)", () => { + // Codex is detected via PLUGIN_ROOT and, like Claude, takes the hookSpecificOutput + // emit path — so the same orientation payload must come back. CODEX_HOME points state + // at the temp dir so the real ~/.codex is untouched. + const cfg = tmpConfig(); + const { code, stdout } = runHook( + "session-start.mjs", + { session_id: SID, hook_event_name: "SessionStart", source: "startup" }, + { PLUGIN_ROOT: "/fake/plugin", CLAUDE_PLUGIN_ROOT: "/fake/plugin", CODEX_HOME: cfg }, + ); + assert.equal(code, 0); + const out = JSON.parse(stdout); + assert.equal(out.hookSpecificOutput.hookEventName, "SessionStart"); + assert.match(out.hookSpecificOutput.additionalContext, /\[Meridian orientation\]/); + rmSync(cfg, { recursive: true, force: true }); +}); + test("pre-tool-use denies a git commit carrying AI attribution", () => { for (const command of [ 'git commit -m "feat: x\n\nCo-Authored-By: Claude "', diff --git a/test/meridian-lib.test.mjs b/test/meridian-lib.test.mjs index 5058bdc..ce40ed8 100644 --- a/test/meridian-lib.test.mjs +++ b/test/meridian-lib.test.mjs @@ -132,13 +132,44 @@ test("Copilot state base stays ~/.copilot even when CLAUDE_PLUGIN_ROOT is also s } }); +test("detectHost resolves the Codex state base and injects on every event", () => { + // Codex ships no unique identity var; it sets the un-prefixed PLUGIN_ROOT (plus + // CLAUDE_PLUGIN_ROOT for compat) — the documented discriminator from Claude, which + // sets only CLAUDE_PLUGIN_ROOT. Like Claude, Codex injects on every event. + const saved = { + CURSOR_PLUGIN_ROOT: process.env.CURSOR_PLUGIN_ROOT, + COPILOT_PLUGIN_ROOT: process.env.COPILOT_PLUGIN_ROOT, + PLUGIN_ROOT: process.env.PLUGIN_ROOT, + CODEX_HOME: process.env.CODEX_HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CLAUDE_PLUGIN_ROOT: process.env.CLAUDE_PLUGIN_ROOT, + }; + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CURSOR_PLUGIN_ROOT; + delete process.env.COPILOT_PLUGIN_ROOT; + delete process.env.CODEX_HOME; + process.env.PLUGIN_ROOT = "/fake/plugin"; + process.env.CLAUDE_PLUGIN_ROOT = "/fake/plugin"; + const host = detectHost(); + assert.equal(host.name, "codex"); + assert.equal(host.stateBase, join(homedir(), ".codex")); + assert.equal(host.supportsContext("SessionStart"), true); + assert.equal(host.supportsContext("UserPromptSubmit"), true); + for (const [key, val] of Object.entries(saved)) { + if (val === undefined) delete process.env[key]; + else process.env[key] = val; + } +}); + test("detectHost defaults to claude and injects on every event", () => { const saved = { CURSOR_PLUGIN_ROOT: process.env.CURSOR_PLUGIN_ROOT, COPILOT_PLUGIN_ROOT: process.env.COPILOT_PLUGIN_ROOT, + PLUGIN_ROOT: process.env.PLUGIN_ROOT, }; delete process.env.CURSOR_PLUGIN_ROOT; delete process.env.COPILOT_PLUGIN_ROOT; + delete process.env.PLUGIN_ROOT; const host = detectHost(); assert.equal(host.name, "claude"); assert.equal(host.supportsContext("UserPromptSubmit"), true);