From bab8e2b2a4a1060852657bd2c1f3ee5e4878fd0e Mon Sep 17 00:00:00 2001 From: Sasan Sotoodehfar Date: Sat, 15 Aug 2026 16:50:55 -0400 Subject: [PATCH] catalog: add harness (claude-only); admit host-subset plugins --- .claude-plugin/marketplace.json | 5 +++++ README.md | 4 +++- catalog.json | 10 ++++++++++ catalog.schema.json | 8 +++----- scripts/validate-catalog.mjs | 17 ++++++++++------- scripts/validate-catalog.test.mjs | 30 ++++++++++++++++++++++++++---- 6 files changed, 57 insertions(+), 17 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3105a40..4ab4c4f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,6 +12,11 @@ "name": "obsidian-agent", "source": { "source": "github", "repo": "cavi-ai/obsidian-agent" }, "description": "Portable Obsidian vault workflows powered by the official Obsidian CLI." + }, + { + "name": "harness", + "source": { "source": "github", "repo": "cavi-ai/harness" }, + "description": "Policy engine for Claude Code sessions: hard stops, intent-scoped gates, advisories, outcome contracts." } ] } diff --git a/README.md b/README.md index 961422c..13e4bf3 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,19 @@ | --- | :---: | :---: | :---: | :---: | :---: | | [`mlx-agent`](https://github.com/cavi-ai/mlx-agent) — discover, verify, and wire local MLX models | ✓ | ✓ | ✓ | ✓ | ✓ | | [`obsidian-agent`](https://github.com/cavi-ai/obsidian-agent) — portable vault workflows over the official Obsidian CLI | ✓ | ✓ | ✓ | ✓ | ✓ | +| [`harness`](https://github.com/cavi-ai/harness) — policy engine for Claude Code sessions (hard stops, intent gates, advisories, outcome contracts) | ✓ | — | — | — | — | The machine-readable source of truth is [`catalog.json`](catalog.json). Claude and Codex consume native marketplace projections. Gemini and OpenCode use the discovery records under `providers/`, which link to each source repository's tested installer; those files do not claim a native marketplace protocol. Portable AgentSkills packages remain available from each plugin repository. ## Claude Code -Add the marketplace, then install either plugin: +Add the marketplace, then install any plugin: ```text /plugin marketplace add cavi-ai/plugins /plugin install mlx-agent@cavi-ai /plugin install obsidian-agent@cavi-ai +/plugin install harness@cavi-ai ``` ## Codex diff --git a/catalog.json b/catalog.json index 55833a0..1b16543 100644 --- a/catalog.json +++ b/catalog.json @@ -29,6 +29,16 @@ "opencode": { "path": "providers/opencode" }, "agentskills": { "path": "providers/agentskills" } } + }, + { + "name": "harness", + "repository": "cavi-ai/harness", + "license": "MIT", + "summary": "Policy engine for Claude Code sessions: hard stops, intent-scoped gates, advisories, outcome contracts.", + "hosts": ["claude"], + "packages": { + "claude": { "path": "." } + } } ] } diff --git a/catalog.schema.json b/catalog.schema.json index 361e15b..e9b3f4b 100644 --- a/catalog.schema.json +++ b/catalog.schema.json @@ -10,8 +10,7 @@ "name": { "const": "cavi-ai" }, "plugins": { "type": "array", - "minItems": 2, - "maxItems": 2, + "minItems": 1, "items": { "$ref": "#/$defs/plugin" } } }, @@ -28,15 +27,14 @@ "additionalProperties": false, "required": ["name", "repository", "license", "summary", "hosts", "packages"], "properties": { - "name": { "enum": ["mlx-agent", "obsidian-agent"] }, + "name": { "enum": ["mlx-agent", "obsidian-agent", "harness"] }, "repository": { "type": "string", "pattern": "^cavi-ai/[a-z0-9-]+$" }, "license": { "type": "string", "minLength": 1 }, "summary": { "type": "string", "minLength": 1 }, - "hosts": { "type": "array", "minItems": 5, "maxItems": 5, "uniqueItems": true, "items": { "$ref": "#/$defs/host" } }, + "hosts": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/host" } }, "packages": { "type": "object", "additionalProperties": false, - "required": ["claude", "codex", "gemini", "opencode", "agentskills"], "properties": { "claude": { "$ref": "#/$defs/package" }, "codex": { "$ref": "#/$defs/package" }, diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs index d5747d4..4b54fdb 100644 --- a/scripts/validate-catalog.mjs +++ b/scripts/validate-catalog.mjs @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url"; export const HOSTS = ["claude", "codex", "gemini", "opencode", "agentskills"]; // Marketplace identity: what users type after @ in `plugin install @`. export const MARKETPLACE = "cavi-ai"; +// Every plugin the catalog may list. REQUIRED is the subset that must be present and ships a pinned Codex package. +const CANONICAL = ["mlx-agent", "obsidian-agent", "harness"]; const REQUIRED = ["mlx-agent", "obsidian-agent"]; const INCLUDED_ROOTS = [".codex-plugin", "skills"]; const SKILL_INVENTORIES = { @@ -84,7 +86,7 @@ function unknownKeys(value, allowed) { } function validateCanonicalSchema(schema, catalog, errors) { - if (schema?.$schema !== "https://json-schema.org/draft/2020-12/schema" || schema?.$id !== "https://github.com/cavi-ai/plugins/catalog.schema.json" || schema?.type !== "object" || schema?.additionalProperties !== false || schema?.properties?.name?.const !== MARKETPLACE || schema?.properties?.plugins?.minItems !== 2 || schema?.properties?.plugins?.maxItems !== 2 || schema?.$defs?.plugin?.additionalProperties !== false || JSON.stringify(schema?.$defs?.host?.enum) !== JSON.stringify(HOSTS)) errors.push("catalog schema contract invalid"); + if (schema?.$schema !== "https://json-schema.org/draft/2020-12/schema" || schema?.$id !== "https://github.com/cavi-ai/plugins/catalog.schema.json" || schema?.type !== "object" || schema?.additionalProperties !== false || schema?.properties?.name?.const !== MARKETPLACE || schema?.properties?.plugins?.minItems !== 1 || schema?.properties?.plugins?.maxItems !== undefined || schema?.$defs?.plugin?.additionalProperties !== false || JSON.stringify(schema?.$defs?.host?.enum) !== JSON.stringify(HOSTS)) errors.push("catalog schema contract invalid"); const violation = (location, message) => errors.push(`catalog schema violation: ${location} ${message}`); if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { violation("root", "must be an object"); return; } if (catalog.$schema !== "./catalog.schema.json") errors.push("catalog schema reference invalid"); @@ -94,19 +96,19 @@ function validateCanonicalSchema(schema, catalog, errors) { else if (catalog.name !== MARKETPLACE) violation("name", `must equal ${MARKETPLACE}`); if (!Object.hasOwn(catalog, "plugins")) violation("plugins", "is required"); else if (!Array.isArray(catalog.plugins)) violation("plugins", "must be an array"); - else if (catalog.plugins.length !== 2) violation("plugins", "must contain exactly 2 items"); + else if (catalog.plugins.length < 1) violation("plugins", "must contain at least 1 item"); for (const [index, plugin] of (Array.isArray(catalog.plugins) ? catalog.plugins : []).entries()) { const location = `plugins[${index}]`; if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) { violation(location, "must be an object"); continue; } for (const key of unknownKeys(plugin, ["name", "repository", "license", "summary", "hosts", "packages"])) errors.push(`catalog plugin ${plugin?.name} unknown property: ${key}`); for (const key of ["name", "repository", "license", "summary", "hosts", "packages"]) if (!Object.hasOwn(plugin, key)) violation(`${location}.${key}`, "is required"); - if (Object.hasOwn(plugin, "name") && !REQUIRED.includes(plugin.name)) violation(`${location}.name`, "must be a canonical plugin name"); + if (Object.hasOwn(plugin, "name") && !CANONICAL.includes(plugin.name)) violation(`${location}.name`, "must be a canonical plugin name"); if (Object.hasOwn(plugin, "repository") && (typeof plugin.repository !== "string" || !/^cavi-ai\/[a-z0-9-]+$/.test(plugin.repository))) violation(`${location}.repository`, "must match ^cavi-ai/[a-z0-9-]+$"); for (const key of ["license", "summary"]) if (Object.hasOwn(plugin, key) && (typeof plugin[key] !== "string" || plugin[key].length < 1)) violation(`${location}.${key}`, "must be a non-empty string"); if (Object.hasOwn(plugin, "hosts")) { if (!Array.isArray(plugin.hosts)) violation(`${location}.hosts`, "must be an array"); else { - if (plugin.hosts.length !== 5) violation(`${location}.hosts`, "must contain exactly 5 items"); + if (plugin.hosts.length < 1) violation(`${location}.hosts`, "must contain at least 1 item"); if (new Set(plugin.hosts.map((host) => JSON.stringify(host))).size !== plugin.hosts.length) violation(`${location}.hosts`, "must contain unique items"); plugin.hosts.forEach((host, hostIndex) => { if (!HOSTS.includes(host)) violation(`${location}.hosts[${hostIndex}]`, "must be a supported host"); }); } @@ -114,7 +116,9 @@ function validateCanonicalSchema(schema, catalog, errors) { if (!Object.hasOwn(plugin, "packages")) continue; if (!plugin.packages || typeof plugin.packages !== "object" || Array.isArray(plugin.packages)) { violation(`${location}.packages`, "must be an object"); continue; } for (const key of unknownKeys(plugin.packages, HOSTS)) errors.push(`catalog plugin ${plugin?.name} unknown package host: ${key}`); - for (const host of HOSTS) { + const declared = Array.isArray(plugin.hosts) ? [...new Set(plugin.hosts.filter((host) => HOSTS.includes(host)))] : []; + for (const key of Object.keys(plugin.packages)) if (HOSTS.includes(key) && !declared.includes(key)) errors.push(`catalog plugin ${plugin?.name} package for undeclared host: ${key}`); + for (const host of declared) { if (!Object.hasOwn(plugin.packages, host)) { violation(`${location}.packages.${host}`, "is required"); continue; } const pkg = plugin.packages[host]; if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) { violation(`${location}.packages.${host}`, "must be an object"); continue; } @@ -262,14 +266,13 @@ export async function validateCatalog(root = process.cwd()) { seenHosts.add(host); } for (const host of plugin.hosts) if (!HOSTS.includes(host)) errors.push(`${prefix} unknown host: ${host}`); - for (const host of HOSTS) if (!seenHosts.has(host)) errors.push(`${prefix} required host missing: ${host}`); for (const host of new Set(plugin.hosts.filter((item) => HOSTS.includes(item)))) { if (!plugin.packages?.[host]?.path) errors.push(`${prefix} package path missing for host: ${host}`); } } } for (const name of REQUIRED) if (!byName.has(name)) errors.push(`required plugin missing: ${name}`); - for (const name of byName.keys()) if (!REQUIRED.includes(name)) errors.push(`unexpected plugin: ${name}`); + for (const name of byName.keys()) if (!CANONICAL.includes(name)) errors.push(`unexpected plugin: ${name}`); for (const [host, relative] of Object.entries(PROJECTIONS)) { const projection = await json(root, relative, errors); diff --git a/scripts/validate-catalog.test.mjs b/scripts/validate-catalog.test.mjs index 2822db9..caf9c2c 100644 --- a/scripts/validate-catalog.test.mjs +++ b/scripts/validate-catalog.test.mjs @@ -21,7 +21,7 @@ function plugin(name, repository = `cavi-ai/${name}`, supportedHosts = hosts) { license: "MIT", summary: `${name} summary`, hosts: supportedHosts, - packages: Object.fromEntries(hosts.map((host) => [host, { path: host === "claude" ? "." : host === "codex" ? `packages/codex/${name}` : host === "gemini" ? (name === "mlx-agent" ? "providers/gemini" : ".") : host === "opencode" ? "providers/opencode" : "providers/agentskills" }])) + packages: Object.fromEntries([...new Set(supportedHosts)].filter((host) => hosts.includes(host)).map((host) => [host, { path: host === "claude" ? "." : host === "codex" ? `packages/codex/${name}` : host === "gemini" ? (name === "mlx-agent" ? "providers/gemini" : ".") : host === "opencode" ? "providers/opencode" : "providers/agentskills" }])) }; } @@ -83,7 +83,7 @@ const entry = (name, host) => ({ ...(host === "opencode" ? { install: { command: name === "mlx-agent" ? "git clone https://github.com/cavi-ai/mlx-agent.git && python3 mlx-agent/scripts/mlx-agent install opencode --scope user --dry-run --json" : "git clone https://github.com/cavi-ai/obsidian-agent.git && node obsidian-agent/scripts/install.mjs --host opencode --scope user --dry-run" } } : {}) }); -test("accepts exactly the two canonical plugins and truthful host projections", async () => { +test("accepts the canonical plugins and truthful host projections", async () => { const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], @@ -94,6 +94,27 @@ test("accepts exactly the two canonical plugins and truthful host projections", assert.deepEqual(await validateCatalog(root), []); }); +test("accepts a claude-only plugin projected only to claude", async () => { + const withHarness = { ...canonical, plugins: [...canonical.plugins, plugin("harness", "cavi-ai/harness", ["claude"])] }; + const root = await fixture({ + catalog: withHarness, + claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude"), { name: "harness", repository: "cavi-ai/harness", package: "." }], + codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], + gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], + opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] + }); + assert.deepEqual(await validateCatalog(root), []); + const projectedWide = await fixture({ + catalog: withHarness, + claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude"), { name: "harness", repository: "cavi-ai/harness", package: "." }], + codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex"), { name: "harness", repository: "cavi-ai/harness", package: "packages/codex/harness" }], + gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], + opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] + }); + const errors = await validateCatalog(projectedWide); + assert(errors.some((error) => error.includes("codex projects unsupported plugin host: harness"))); +}); + test("rejects duplicates, unknown hosts, missing repositories, and legacy identities", async () => { const bad = { name: "claude-plugins", @@ -145,11 +166,12 @@ test("rejects repository identity and package path mismatches", async () => { assert(errors.some((error) => error.includes("opencode package mismatch: obsidian-agent"))); }); -test("requires the complete host matrix without duplicate host or projection entries", async () => { +test("rejects duplicate hosts, undeclared host packages, and duplicate projection entries", async () => { const incomplete = { name: MARKETPLACE, plugins: [plugin("mlx-agent", "cavi-ai/mlx-agent", ["claude", "claude", "codex", "gemini", "opencode"]), plugin("obsidian-agent")] }; + incomplete.plugins[0].packages.agentskills = { path: "providers/agentskills" }; const root = await fixture({ catalog: incomplete, claude: [entry("mlx-agent", "claude"), entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], @@ -159,7 +181,7 @@ test("requires the complete host matrix without duplicate host or projection ent }); const errors = await validateCatalog(root); assert(errors.some((error) => error.includes("duplicate host: claude"))); - assert(errors.some((error) => error.includes("required host missing: agentskills"))); + assert(errors.some((error) => error.includes("catalog plugin mlx-agent package for undeclared host: agentskills"))); assert(errors.some((error) => error.includes("claude duplicate projection: mlx-agent"))); });