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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion .plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "meridian",
"version": "0.11.1",
"version": "0.11.2",
"hooks": "./hooks/hooks-copilot.json"
}
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion agents/research.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion agents/triangulate.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 15 additions & 5 deletions hooks/lib/host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() {
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion hooks/lib/types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down
17 changes: 17 additions & 0 deletions test/meridian-hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@anthropic.com>"',
Expand Down
31 changes: 31 additions & 0 deletions test/meridian-lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading