diff --git a/.pi/extensions/index.ts b/.pi/extensions/index.ts
index 65810292d..241deabce 100644
--- a/.pi/extensions/index.ts
+++ b/.pi/extensions/index.ts
@@ -24,7 +24,7 @@
* degrades to a warning and never terminates the Pi session.
*/
-import { execFile } from "node:child_process"
+import { execFile, execFileSync } from "node:child_process"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
@@ -556,11 +556,74 @@ function describeRulesStatus(): string {
return `${cachedRuleFileCount}/${PORTABLE_RULE_FILES.length} rule file(s), ${rules.length} chars, from rules/common/${shortfall}`
}
+const AGENT_CHECK_TIMEOUT_MS = 5_000
+
+type AgentPortability =
+ | { status: "validated"; count: number }
+ | { status: "failed"; reason: string }
+ | { status: "unvalidated"; reason: string }
+
+/**
+ * Verify agent portability by actually running `agent-convert.js --check` rather
+ * than trusting file existence. A converter that exists but fails (schema drift,
+ * a frontmatter variant that fails ajv) must not report a green doctor line.
+ */
+function validateAgentPortability(converterScript: string): AgentPortability {
+ if (!fs.existsSync(converterScript)) {
+ return { status: "unvalidated", reason: "converter script not found" }
+ }
+
+ let nodeExec: string
+ try {
+ nodeExec = resolveHookRuntime()
+ } catch (err) {
+ return { status: "unvalidated", reason: `no node runtime: ${messageOf(err)}` }
+ }
+
+ try {
+ const stdout = execFileSync(nodeExec, [converterScript, "--check"], {
+ timeout: AGENT_CHECK_TIMEOUT_MS,
+ encoding: "utf8",
+ })
+ const match = stdout.match(/OK: (\d+) agents parsed and validated/)
+ if (match) {
+ return { status: "validated", count: parseInt(match[1], 10) }
+ }
+ return { status: "failed", reason: `unexpected output: ${stdout.slice(0, 200).trim()}` }
+ } catch (err) {
+ return { status: "failed", reason: messageOf(err) }
+ }
+}
+
+function messageOf(err: unknown): string {
+ if (err && typeof err === "object" && "message" in err) {
+ return String((err as { message: unknown }).message)
+ }
+ return "unknown error"
+}
+
+function describeAgentPortability(agentCount: number, portability: AgentPortability): string {
+ if (agentCount === 0) {
+ return "NOT FOUND"
+ }
+ if (portability.status === "validated") {
+ return `${portability.count} agent(s) source schema validated; Pi conversion available via scripts/agent-convert.js`
+ }
+ if (portability.status === "failed") {
+ return `${agentCount} agent(s) present but validation FAILED: ${portability.reason}`
+ }
+ return `${agentCount} agent(s) present (unvalidated — ${portability.reason})`
+}
+
function buildDoctorReport(ctx: ExtensionContext): string {
const skillsDir = path.join(ECC_ROOT, "skills")
const commandsDir = path.join(ECC_ROOT, "commands")
const skillCount = countDirectories(skillsDir)
const commandCount = countMarkdownFiles(commandsDir)
+ const agentsDir = path.join(ECC_ROOT, "agents")
+ const agentCount = countMarkdownFiles(agentsDir)
+ const converterScript = path.join(ECC_ROOT, "scripts", "agent-convert.js")
+ const agentLine = describeAgentPortability(agentCount, validateAgentPortability(converterScript))
const lines = [
"ECC adapter for Pi",
@@ -572,6 +635,7 @@ function buildDoctorReport(ctx: ExtensionContext): string {
"Canonical resources",
` skills/ ${skillCount > 0 ? `${skillCount} skill(s)` : "NOT FOUND"} (${skillsDir})`,
` commands/ ${commandCount > 0 ? `${commandCount} command(s)` : "NOT FOUND"} (${commandsDir})`,
+ ` agents/ ${agentLine} (${agentsDir})`,
"",
"Engineering rules (injected into the system prompt)",
` ${describeRulesStatus()}`,
diff --git a/docs/architecture/harness-adapter-compliance.md b/docs/architecture/harness-adapter-compliance.md
index 09061190d..97588546f 100644
--- a/docs/architecture/harness-adapter-compliance.md
+++ b/docs/architecture/harness-adapter-compliance.md
@@ -39,7 +39,7 @@ The matrix below is rendered from
| Claude Code | Native | Claude plugin assets; skills; commands; hooks; MCP config; local rules; statusline-oriented workflows | Claude-native hooks do not imply parity in other harnesses | `./install.sh --profile minimal --target claude`; Claude plugin install | `npm run harness:audit -- --format json`; `node scripts/session-inspect.js --list-adapters` | Avoid loading every skill by default; keep hooks opt-in and inspectable. |
| Codex | Instruction-backed | `AGENTS.md`; Codex plugin metadata; skills; MCP reference config; command patterns | Native hook enforcement and Claude slash-command semantics are not equivalent | `./install.sh --profile minimal --target codex`; repo-local `AGENTS.md` review | `npm run harness:audit -- --format json` | Treat hooks as policy text unless a native Codex hook surface exists. |
| OpenCode | Adapter-backed | OpenCode package/plugin metadata; shared skills; MCP config; event adapter patterns | Event names, plugin packaging, and command dispatch differ from Claude Code | OpenCode package or plugin surface from this repo | `node tests/scripts/build-opencode.test.js`; `npm run harness:audit -- --format json` | Keep hook logic in shared scripts and adapt only event shape at the edge. |
-| Pi | Adapter-backed | Pi package manifest; canonical ECC skills (skills/); canonical ECC commands as prompt templates (commands/); canonical ECC engineering rules (rules/common/) injected into the system prompt; session lifecycle hook adapter; /ecc-doctor diagnostics command | Subagents, chains, approval prompts, and persistent todos require companion Pi packages and are not part of this adapter; Pi core has no MCP surface, though ECC MCP configs load verbatim through the community pi-mcp-adapter package, which ECC neither installs nor depends on | `pi install git:github.com/affaan-m/ECC`; `pi install /path/to/ECC` from a local checkout | `node tests/pi/pi-package-manifest.test.js`; `node tests/pi/pi-extension-adapter.test.js`; `npm run harness:adapters -- --check` | Pi extensions execute with full user permissions, and hooks run without a shell and resolve from the installed package rather than the user project; Keep canonical skills and commands as the single source of truth, and never generate copies under .pi/ |
+| Pi | Adapter-backed | Pi package manifest; canonical ECC skills (skills/); canonical ECC commands as prompt templates (commands/); canonical ECC engineering rules (rules/common/) injected into the system prompt; session lifecycle hook adapter; /ecc-doctor diagnostics command; canonical ECC agents (agents/) portable via scripts/agent-convert.js (claude -> pi) | Agent chains, approval prompts, and persistent todos require companion Pi packages and are not part of this adapter (individual agent definitions are portable via scripts/agent-convert.js); Pi core has no MCP surface, though ECC MCP configs load verbatim through the community pi-mcp-adapter package, which ECC neither installs nor depends on | `pi install git:github.com/affaan-m/ECC`; `pi install /path/to/ECC` from a local checkout | `node tests/pi/pi-package-manifest.test.js`; `node tests/pi/pi-extension-adapter.test.js`; `node tests/pi/agent-convert.test.js`; `npm run harness:adapters -- --check` | Pi extensions execute with full user permissions, and hooks run without a shell and resolve from the installed package rather than the user project; Keep canonical skills and commands as the single source of truth, and never generate copies under .pi/ |
| Cursor | Adapter-backed | Cursor rules; project-local skills; hook adapter; shared scripts | Cursor hook events and rule loading differ from Claude Code | `./install.sh --profile minimal --target cursor` | `node tests/lib/install-targets.test.js`; `npm run harness:audit -- --format json` | Cursor adapters must preserve existing project rules and avoid silent overwrite. |
| Gemini | Instruction-backed | Gemini project-local instructions; shared skills; rules; compatibility docs | No full ECC hook parity; ecosystem ports must document drift from upstream ECC | `./install.sh --profile minimal --target gemini` | `node tests/lib/install-targets.test.js` | Treat Gemini ports as ecosystem adapters until validated end to end inside Gemini CLI. |
| Zed | Adapter-backed | Zed project settings; flattened project rules; shared skills; commands; agents | Zed external agents and native Agent Panel permissions are not Claude hooks | `./install.sh --profile minimal --target zed` | `node tests/lib/install-targets.test.js`; `npm run harness:audit -- --format json` | Keep project settings conservative and do not copy BYOK/OpenRouter secrets into `.zed/`. |
diff --git a/package.json b/package.json
index ec14ccc57..fb9eb4f8e 100644
--- a/package.json
+++ b/package.json
@@ -109,6 +109,7 @@
"plugins/ecc/",
"rules/",
"schemas/",
+ "scripts/agent-convert.js",
"scripts/catalog.js",
"scripts/ci/scan-supply-chain-iocs.js",
"scripts/ci/supply-chain-advisory-sources.js",
diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json
new file mode 100644
index 000000000..a04cd56ca
--- /dev/null
+++ b/schemas/agent.schema.json
@@ -0,0 +1,59 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "ecc.agent-ir.v1",
+ "title": "ECC Agent IR",
+ "description": "Canonical harness-neutral representation of an ECC agent, produced from a source harness definition (e.g. agents/*.md Claude frontmatter). Emitters consume this IR and produce native agent/subagent definitions per harness. The `tools` array holds source tool names verbatim (lossless); each emitter maps them through its own tool table.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema",
+ "id",
+ "name",
+ "description",
+ "tools",
+ "body"
+ ],
+ "properties": {
+ "schema": {
+ "const": "ecc.agent-ir.v1"
+ },
+ "id": {
+ "type": "string",
+ "description": "Stable canonical id; the source filename stem (kebab-case).",
+ "pattern": "^[a-z0-9-]+$"
+ },
+ "name": {
+ "type": "string",
+ "description": "Agent name from the source frontmatter."
+ },
+ "description": {
+ "type": "string",
+ "description": "Agent description from the source frontmatter."
+ },
+ "model": {
+ "type": "string",
+ "description": "Source model tier, when present. Claude aliases only in v1.",
+ "enum": ["haiku", "sonnet", "opus"]
+ },
+ "tools": {
+ "type": "array",
+ "description": "Source tool names verbatim (Claude tool identifiers such as Read, Grep, Glob, Bash, Edit, Write, WebSearch, WebFetch, mcp__*).",
+ "items": {
+ "type": "string"
+ }
+ },
+ "color": {
+ "type": "string",
+ "description": "Optional source-only metadata (Claude color tag); emitters may ignore it."
+ },
+ "body": {
+ "type": "string",
+ "description": "The agent system prompt / markdown body after the frontmatter."
+ },
+ "frontmatter": {
+ "type": "object",
+ "description": "Lossless raw source frontmatter for round-trip fidelity and forward compatibility.",
+ "additionalProperties": true
+ }
+ }
+}
diff --git a/scripts/agent-convert.js b/scripts/agent-convert.js
new file mode 100644
index 000000000..5274917f7
--- /dev/null
+++ b/scripts/agent-convert.js
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * ECC Agent IR — converter CLI.
+ *
+ * Converts ECC's canonical agents from one harness format to another. v1
+ * supports a single direction: `claude -> pi`. Future emitters (cursor,
+ * opencode, gemini, codex, zed) plug into the same IR.
+ *
+ * Usage:
+ * node scripts/agent-convert.js --check
+ * node scripts/agent-convert.js --from claude --to pi [--json]
+ * node scripts/agent-convert.js --from claude --to pi --out
[--dry-run]
+ *
+ * When `--json` is set, stdout carries only the machine-readable summary;
+ * progress lines (wrote / would-write) go to stderr so automation can parse
+ * stdout as JSON.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { parseAllAgents } = require('./lib/agent-ir');
+const { emitAllPiAgents } = require('./lib/agent-emit-pi');
+
+const SUPPORTED = { claude: ['pi'] };
+
+const EMITTERS = {
+ pi: emitAllPiAgents,
+};
+
+function requireValue(argv, index, flag) {
+ const value = argv[index + 1];
+ if (value === undefined || value === '' || value.startsWith('--')) {
+ throw new Error(`missing value for ${flag}`);
+ }
+ return value;
+}
+
+function parseArgs(argv) {
+ let args = { from: 'claude', to: 'pi', json: false, dryRun: false, check: false, out: null };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--from') {
+ args = { ...args, from: requireValue(argv, i, '--from') };
+ i++;
+ } else if (a === '--to') {
+ args = { ...args, to: requireValue(argv, i, '--to') };
+ i++;
+ } else if (a === '--json') {
+ args = { ...args, json: true };
+ } else if (a === '--dry-run') {
+ args = { ...args, dryRun: true };
+
+ } else if (a === '--check') {
+ args = { ...args, check: true };
+ } else if (a === '--out') {
+ args = { ...args, out: requireValue(argv, i, '--out') };
+ i++;
+ } else if (a === '--help' || a === '-h') {
+ args = { ...args, help: true };
+ } else {
+ throw new Error(`unknown argument: ${a}`);
+ }
+ }
+ return args;
+}
+
+function showHelp() {
+ console.log(`ECC Agent IR converter (v1)
+
+Usage:
+ node scripts/agent-convert.js --check
+ Parse and validate every agent under agents/ against the IR schema.
+
+ node scripts/agent-convert.js --from claude --to pi [--json]
+ Emit all agents as Pi subagent definitions and print a summary.
+
+ node scripts/agent-convert.js --from claude --to pi --out [--dry-run]
+ Write emitted Pi agent files (/.md). --dry-run prints only.
+
+Options:
+ --from Source harness (v1: claude only)
+ --to Target harness (v1: pi only)
+ --json Machine-readable summary on stdout
+ --out Write emitted files to a directory
+ --dry-run With --out, report writes without writing
+ --check Validate parsing only (no emit)
+`);
+}
+
+function main() {
+ let args;
+ try {
+ args = parseArgs(process.argv.slice(2));
+ } catch (err) {
+ console.error(`error: ${err.message}`);
+ process.exit(2);
+ }
+ if (args.help) {
+ showHelp();
+ process.exit(0);
+ }
+
+ if (!SUPPORTED[args.from] || !SUPPORTED[args.from].includes(args.to)) {
+ console.error(`error: unsupported conversion ${args.from} -> ${args.to}`);
+ console.error(`supported: ${Object.entries(SUPPORTED).map(([f, ts]) => `${f} -> ${ts.join(',')}`).join('; ')}`);
+ process.exit(2);
+ }
+
+ let irs;
+ try {
+ irs = parseAllAgents();
+ } catch (err) {
+ console.error(`error: ${err.message}`);
+ process.exit(1);
+ }
+
+ if (args.check) {
+ if (args.json) {
+ console.log(JSON.stringify({ schema: 'ecc.agent-ir.v1', checked: irs.length, ok: true }, null, 2));
+ } else {
+ console.log(`OK: ${irs.length} agents parsed and validated against ecc.agent-ir.v1`);
+ }
+ process.exit(0);
+ }
+
+ const { results, warnings, notes, skipped } = EMITTERS[args.to](irs);
+
+ // Progress lines must never corrupt machine-readable stdout.
+ const progress = msg => {
+ if (args.json) process.stderr.write(`${msg}\n`);
+ else console.log(msg);
+ };
+
+ if (args.out) {
+ const dir = path.resolve(args.out);
+ if (!args.dryRun) fs.mkdirSync(dir, { recursive: true });
+ for (const r of results) {
+ const target = path.join(dir, `${r.id}.md`);
+ if (args.dryRun) {
+ progress(`[dry-run] would write ${target}`);
+ } else {
+ fs.writeFileSync(target, r.markdown);
+ progress(`wrote ${target}`);
+ }
+ }
+ }
+
+ const summary = {
+ schema: 'ecc.agent-ir.v1',
+ from: args.from,
+ to: args.to,
+ converted: results.length,
+ warnings: warnings.length,
+ warningsDetail: warnings,
+ skipped: skipped ? skipped.length : 0,
+ notes,
+ };
+
+ if (args.json) {
+ console.log(JSON.stringify(summary, null, 2));
+ } else {
+ console.log(`converted ${results.length} agents (${args.from} -> ${args.to})`);
+ for (const note of notes) {
+ console.log(` ${note}`);
+ }
+ if (warnings.length) {
+ console.log(`\n${warnings.length} actionable warning(s):`);
+ for (const w of warnings) console.log(` - ${w}`);
+ }
+ }
+}
+
+main();
diff --git a/scripts/lib/agent-emit-pi.js b/scripts/lib/agent-emit-pi.js
new file mode 100644
index 000000000..1d8956332
--- /dev/null
+++ b/scripts/lib/agent-emit-pi.js
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * ECC Agent IR — Pi emitter.
+ *
+ * Turns one IR object into a native Pi subagent definition (markdown file with
+ * Pi frontmatter). The frontmatter contract is verified against
+ * `@tintinweb/pi-subagents@0.19.0`: fields are `name` (the dispatch id),
+ * `description`, `tools` (a comma-separated strict child allowlist), and
+ * `prompt_mode` (`replace` keeps the body as the complete system prompt). The
+ * prompt lives in the document body.
+ *
+ * v1 behavior:
+ * - The source model tier is preserved as a YAML comment
+ * (`# source model tier: opus`) rather than a `model:` field, because
+ * Claude tiers are not valid Pi model ids. Pi's default child model
+ * applies; the tier is lossless in the IR and visible in the emitted file.
+ * - Unmapped tools (WebSearch, WebFetch, mcp__*) are never silently dropped;
+ * they surface in per-agent `warnings`.
+ * - Specialist isolation disables inherited extensions and skills, so the
+ * builtin allowlist cannot silently inherit configured extension tools.
+ * - The allowlist is always written. An empty allowlist is emitted as
+ * `tools: none`, never blank, because the companion's `csvList` treats a
+ * blank/missing `tools:` as *all* built-ins — which would escalate a
+ * fully-unmapped agent to every tool.
+ */
+
+const { mapToolToPi } = require('./agent-tool-map');
+
+// Pi core built-in agent names (case-sensitive). ECC agent names are kebab-case
+// and do not collide today; warn if a future one ever does.
+const RESERVED_AGENT_NAMES = new Set(['general-purpose', 'Explore', 'Plan']);
+
+/**
+ * Emit a Pi agent definition for one IR object.
+ *
+ * @param {object} ir
+ * @returns {{ markdown: string, warnings: string[], tools: string[] }}
+ */
+function emitPiAgent(ir) {
+ const warnings = [];
+ const seen = new Set();
+ const tools = [];
+
+ for (const sourceTool of ir.tools) {
+ const mapped = mapToolToPi(sourceTool);
+ if (mapped.tool && !seen.has(mapped.tool)) {
+ seen.add(mapped.tool);
+ tools.push(mapped.tool);
+ }
+ if (mapped.note) {
+ warnings.push(`${ir.id}: ${mapped.note}`);
+ }
+ }
+
+ if (RESERVED_AGENT_NAMES.has(ir.name)) {
+ warnings.push(`${ir.id}: name '${ir.name}' collides with a Pi built-in agent; rename to avoid shadowing`);
+ }
+
+ // Never emit a blank allowlist: `none` is the explicit "no tools" sentinel.
+ const toolsValue = tools.length > 0 ? tools.join(', ') : 'none';
+
+ const frontmatter = [
+ '---',
+ ...(ir.model ? [`# source model tier: ${ir.model}`] : []),
+ `name: ${yamlScalar(ir.name)}`,
+ `description: ${yamlScalar(ir.description)}`,
+ `tools: ${toolsValue}`,
+ 'prompt_mode: replace',
+ 'isolated: true',
+ 'extensions: false',
+ 'skills: false',
+ '---',
+ ];
+
+ const body = (ir.body || '').replace(/^\n+/, '').trimEnd();
+ const markdown = frontmatter.join('\n') + '\n\n' + body + '\n';
+ return { markdown, warnings, tools };
+}
+
+/** Emit a single-line YAML scalar, quoted only when needed. */
+function yamlScalar(value) {
+ const s = String(value);
+ // Quote when it contains a leading/trailing space, a colon followed by a
+ // space, a comment-starting hash, a leading special char, or a newline.
+ if (/^\s|\s$|: |\n|\s#|^[-?*&|>#@`"'\][{}!,]/.test(s)) {
+ return JSON.stringify(s);
+ }
+ return s;
+}
+
+/**
+ * Emit the full set of Pi agents, sorted by id for determinism, plus notes on
+ * the deliberate (documented) lossy conversions.
+ *
+ * @param {object[]} irs
+ * @returns {{ results: object[], warnings: string[], notes: string[] }}
+ */
+function emitAllPiAgents(irs) {
+ const results = [];
+ const warnings = [];
+ let modelTiers = {};
+ let unsupported = 0;
+
+ for (const ir of [...irs].sort((a, b) => a.id.localeCompare(b.id))) {
+ const { markdown, warnings: w, tools } = emitPiAgent(ir);
+ results.push({ id: ir.id, name: ir.name, tools, markdown });
+ warnings.push(...w);
+
+ if (ir.model) {
+ modelTiers = { ...modelTiers, [ir.model]: (modelTiers[ir.model] || 0) + 1 };
+ }
+ unsupported += ir.tools.filter(t => mapToolToPi(t).unsupported).length;
+ }
+
+ const notes = [];
+ if (Object.keys(modelTiers).length) {
+ const tiers = Object.entries(modelTiers).map(([t, n]) => `${t} x${n}`).join(', ');
+ notes.push(`model tiers preserved as comments (${tiers}) — Pi uses its default child model`);
+ }
+ if (unsupported) {
+ notes.push(`unmapped tools: ${unsupported} (WebSearch/WebFetch/mcp__* are not Pi built-ins) — see warnings`);
+ }
+
+ return { results, warnings, notes };
+}
+
+module.exports = {
+ RESERVED_AGENT_NAMES,
+ emitPiAgent,
+ emitAllPiAgents,
+ yamlScalar,
+};
diff --git a/scripts/lib/agent-ir.js b/scripts/lib/agent-ir.js
new file mode 100644
index 000000000..5e35fdff9
--- /dev/null
+++ b/scripts/lib/agent-ir.js
@@ -0,0 +1,157 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * ECC Agent IR — parser.
+ *
+ * Reads ECC's canonical agent files (Claude frontmatter markdown) and emits
+ * harness-neutral IR objects validated against `schemas/agent.schema.json`.
+ *
+ * Design constraints:
+ * - Deterministic: stable key order, no ambient state.
+ * - Lossless: the raw frontmatter is retained so emitters can round-trip.
+ * - Strict: invalid model tiers and non-string tool entries are rejected,
+ * never silently coerced or dropped.
+ * - No shell, no network, no new dependencies (uses `js-yaml` + `ajv`,
+ * which are already in `package.json`).
+ */
+
+const fs = require('fs');
+const path = require('path');
+const yaml = require('js-yaml');
+const Ajv = require('ajv');
+
+const AGENTS_DIR = path.join(__dirname, '..', '..', 'agents');
+const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schemas', 'agent.schema.json');
+
+const IR_SCHEMA_CONST = 'ecc.agent-ir.v1';
+const VALID_MODELS = ['haiku', 'sonnet', 'opus'];
+
+let _ajv = null;
+function ajv() {
+ if (!_ajv) {
+ _ajv = new Ajv({ allErrors: true, strict: false });
+ const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
+ _ajv.addSchema(schema, IR_SCHEMA_CONST);
+ }
+ return _ajv;
+}
+
+/** Split frontmatter + body, tolerating UTF-8 BOM and LF/CRLF. */
+function splitFrontmatter(content) {
+ const clean = content.replace(/^\uFEFF/, '');
+ const match = clean.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
+ if (!match) {
+ return { frontmatter: null, body: null };
+ }
+ return { frontmatter: match[1], body: match[2] || '' };
+}
+
+/**
+ * Normalize a frontmatter `tools` value to a string[].
+ * Claude agents use comma-separated strings (e.g. `Read, Grep, Glob`).
+ * Non-string scalars and non-string array entries are rejected.
+ */
+function normalizeTools(value, filename) {
+ if (value === null || value === undefined) return [];
+
+ const entries = Array.isArray(value)
+ ? value
+ : typeof value === 'string'
+ ? value.split(',')
+ : null;
+
+ if (entries === null) {
+ throw new Error(`${filename}: tools must be a comma-separated string or a list of strings`);
+ }
+
+ const tools = [];
+ for (const entry of entries) {
+ if (typeof entry !== 'string') {
+ throw new Error(`${filename}: tools must contain only strings`);
+ }
+ const trimmed = entry.trim();
+ if (trimmed) tools.push(trimmed);
+ }
+ return tools;
+}
+
+/** Parse one agent file into an IR object (throws on malformed input). */
+function parseAgentFile(filePath) {
+ const filename = path.basename(filePath);
+ const id = filename.replace(/\.md$/, '');
+
+ let content;
+ try {
+ content = fs.readFileSync(filePath, 'utf8');
+ } catch (err) {
+ throw new Error(`${filename}: cannot read: ${err.message}`);
+ }
+
+ const { frontmatter, body } = splitFrontmatter(content);
+ if (frontmatter === null) {
+ throw new Error(`${filename}: missing frontmatter (expected leading --- block)`);
+ }
+
+ let raw;
+ try {
+ raw = yaml.load(frontmatter);
+ } catch (err) {
+ throw new Error(`${filename}: invalid YAML frontmatter: ${err.message}`);
+ }
+ if (!raw || typeof raw !== 'object') {
+ throw new Error(`${filename}: frontmatter must be a YAML mapping`);
+ }
+
+ const model = raw.model;
+ if (model !== undefined && !VALID_MODELS.includes(model)) {
+ throw new Error(`${filename}: invalid model tier '${model}' (expected haiku, sonnet, or opus)`);
+ }
+
+ const ir = {
+ schema: IR_SCHEMA_CONST,
+ id,
+ name: typeof raw.name === 'string' ? raw.name : id,
+ description: typeof raw.description === 'string' ? raw.description : '',
+ tools: normalizeTools(raw.tools, filename),
+ body,
+ frontmatter: raw,
+ ...(model !== undefined ? { model } : {}),
+ ...(raw.color !== undefined ? { color: String(raw.color) } : {}),
+ };
+
+ validateIr(ir, filename);
+ return ir;
+}
+
+function validateIr(ir, filename) {
+ const validate = ajv().getSchema(IR_SCHEMA_CONST);
+ const ok = validate(ir);
+ if (!ok) {
+ const detail = (validate.errors || []).map(e => `${e.instancePath} ${e.message}`).join('; ');
+ throw new Error(`${filename}: IR validation failed: ${detail}`);
+ }
+}
+
+/** Parse every agent under `agents/`, sorted for determinism. */
+function parseAllAgents(dir = AGENTS_DIR) {
+ if (!fs.existsSync(dir)) {
+ throw new Error(`agents directory not found: ${dir}`);
+ }
+ const files = fs
+ .readdirSync(dir)
+ .filter(f => f.endsWith('.md'))
+ .sort();
+ return files.map(f => parseAgentFile(path.join(dir, f)));
+}
+
+module.exports = {
+ AGENTS_DIR,
+ SCHEMA_PATH,
+ IR_SCHEMA_CONST,
+ VALID_MODELS,
+ parseAgentFile,
+ parseAllAgents,
+ splitFrontmatter,
+ normalizeTools,
+};
diff --git a/scripts/lib/agent-tool-map.js b/scripts/lib/agent-tool-map.js
new file mode 100644
index 000000000..538c61c4c
--- /dev/null
+++ b/scripts/lib/agent-tool-map.js
@@ -0,0 +1,69 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * ECC Agent IR — tool and model mapping tables.
+ *
+ * The IR stores source (Claude) tool names verbatim. Each emitter maps those
+ * names to its own harness primitives through a table. Unmapped tools are
+ * never silently dropped: they are surfaced as `unsupported` so the emitter
+ * can warn and the conformance test can assert the mapping is complete.
+ *
+ * Pi tool names are verified against the installed
+ * `@earendil-works/pi-coding-agent` `dist/core/tools/index.d.ts`:
+ * read | bash | powershell | edit | write | grep | find | ls
+ * (`powershell` is Windows-only and not emitted here.)
+ *
+ * Security invariant: a Claude tool must never map to a Pi tool with MORE
+ * authority than the source. Read-only Claude tools (Read, Grep, Glob) map to
+ * read-only Pi tools (read, grep, find), never to `bash`.
+ */
+
+/** Claude tool name -> Pi tool name. */
+const CLAUDE_TO_PI_TOOLS = Object.freeze({
+ Read: 'read',
+ Grep: 'grep',
+ Glob: 'find',
+ Bash: 'bash',
+ Edit: 'edit',
+ Write: 'write',
+ // WebSearch and WebFetch are intentionally NOT mapped: Pi exposes no
+ // `web_search` or `fetch_content` built-in. They fall through to the
+ // "unmapped tool" warning rather than being guessed.
+});
+
+/**
+ * Map one Claude tool name to a Pi tool allowlist entry.
+ *
+ * @param {string} claudeTool
+ * @returns {{ tool: string|null, unsupported: boolean, note?: string }}
+ * `tool` is the Pi tool name, or null when unsupported. `unsupported` is
+ * true only for source tools that have no Pi built-in equivalent in v1.
+ */
+function mapToolToPi(claudeTool) {
+ const name = String(claudeTool).trim();
+ if (Object.prototype.hasOwnProperty.call(CLAUDE_TO_PI_TOOLS, name)) {
+ return { tool: CLAUDE_TO_PI_TOOLS[name], unsupported: false };
+ }
+ if (name.startsWith('mcp__')) {
+ return {
+ tool: null,
+ unsupported: true,
+ note: `MCP tool ${name} not a Pi built-in (configure the MCP server explicitly)`,
+ };
+ }
+ return { tool: null, unsupported: true, note: `unmapped tool: ${name} (not a Pi built-in)` };
+}
+
+/**
+ * Claude model tiers. Pi resolves its own child model; v1 does not emit a
+ * `model` field, so Pi's default applies. The tier is retained in the IR for
+ * lossless round-trips and for emitters that do have a model table.
+ */
+const CLAUDE_MODEL_TIERS = Object.freeze(['haiku', 'sonnet', 'opus']);
+
+module.exports = {
+ CLAUDE_TO_PI_TOOLS,
+ CLAUDE_MODEL_TIERS,
+ mapToolToPi,
+};
diff --git a/scripts/lib/harness-adapter-compliance.js b/scripts/lib/harness-adapter-compliance.js
index e1aafd752..df3bbd86a 100644
--- a/scripts/lib/harness-adapter-compliance.js
+++ b/scripts/lib/harness-adapter-compliance.js
@@ -133,27 +133,31 @@ const ADAPTER_RECORDS = Object.freeze([
'canonical ECC engineering rules (rules/common/) injected into the system prompt',
'session lifecycle hook adapter',
'/ecc-doctor diagnostics command',
+ 'canonical ECC agents (agents/) portable via scripts/agent-convert.js (claude -> pi)',
],
unsupported_surfaces: [
- 'Subagents, chains, approval prompts, and persistent todos require companion Pi packages and are not part of this adapter',
+ 'Agent chains, approval prompts, and persistent todos require companion Pi packages and are not part of this adapter (individual agent definitions are portable via scripts/agent-convert.js)',
'Pi core has no MCP surface, though ECC MCP configs load verbatim through the community pi-mcp-adapter package, which ECC neither installs nor depends on',
],
install_or_onramp: ['`pi install git:github.com/affaan-m/ECC`', '`pi install /path/to/ECC` from a local checkout'],
verification_commands: [
'`node tests/pi/pi-package-manifest.test.js`',
'`node tests/pi/pi-extension-adapter.test.js`',
+ '`node tests/pi/agent-convert.test.js`',
'`npm run harness:adapters -- --check`',
],
risk_notes: [
'Pi extensions execute with full user permissions, and hooks run without a shell and resolve from the installed package rather than the user project',
'Keep canonical skills and commands as the single source of truth, and never generate copies under .pi/',
],
- last_verified_at: '2026-08-10',
+ last_verified_at: '2026-09-08',
owner: 'ECC maintainers',
source_docs: [
'.pi/extensions/index.ts',
'.pi/README.md',
'package.json',
+ 'schemas/agent.schema.json',
+ 'scripts/agent-convert.js',
],
},
{
diff --git a/tests/pi/agent-convert.test.js b/tests/pi/agent-convert.test.js
new file mode 100644
index 000000000..dadc2783f
--- /dev/null
+++ b/tests/pi/agent-convert.test.js
@@ -0,0 +1,122 @@
+const AGENT_COUNT = require('fs').readdirSync(require('path').join(__dirname,'../../agents')).filter(f => f.endsWith('.md')).length;
+/**
+ * Tests for the ECC Agent IR converter CLI (scripts/agent-convert.js).
+ *
+ * Verifies the CLI contract: `--check`, `--json`, `--out` (write and
+ * side-effect-free `--dry-run`), and argument/exit-code behavior.
+ */
+
+const assert = require("assert")
+const fs = require("fs")
+const os = require("os")
+const path = require("path")
+const { execFileSync } = require("child_process")
+
+const CLI = path.join(__dirname, "..", "..", "scripts", "agent-convert.js")
+const NODE = process.execPath
+
+function runTest(name, fn) {
+ try {
+ fn()
+ console.log(` ✓ ${name}`)
+ return true
+ } catch (error) {
+ console.log(` ✗ ${name}`)
+ console.error(` ${error.message}`)
+ return false
+ }
+}
+
+function runCli(args) {
+ return execFileSync(NODE, [CLI, ...args], { encoding: "utf8" })
+}
+
+function tmpdir() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "ecc-agent-convert-"))
+}
+
+function main() {
+ let passed = 0
+ let failed = 0
+
+ const tests = [
+ ["--check exits 0 and reports AGENT_COUNT agents", () => {
+ const out = runCli(["--check"])
+ assert.match(out, new RegExp(`OK: ${AGENT_COUNT} agents parsed and validated`))
+ }],
+
+ ["--json emits a machine-readable summary", () => {
+ const out = runCli(["--from", "claude", "--to", "pi", "--json"])
+ const summary = JSON.parse(out)
+ assert.strictEqual(summary.schema, "ecc.agent-ir.v1")
+ assert.strictEqual(summary.from, "claude")
+ assert.strictEqual(summary.to, "pi")
+ assert.strictEqual(summary.converted, AGENT_COUNT)
+ assert.ok(Array.isArray(summary.warningsDetail))
+ }],
+
+ ["--out writes AGENT_COUNT files", () => {
+ const dir = tmpdir()
+ runCli(["--from", "claude", "--to", "pi", "--out", dir])
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".md"))
+ assert.strictEqual(files.length, AGENT_COUNT, "expected AGENT_COUNT emitted files")
+ const planner = fs.readFileSync(path.join(dir, "planner.md"), "utf8")
+ assert.match(planner, /name: planner\n/);
+ assert.match(planner, /prompt_mode: replace/);
+ }],
+
+ ["--out --dry-run writes nothing", () => {
+ const dir = tmpdir()
+ const out = runCli(["--from", "claude", "--to", "pi", "--out", dir, "--dry-run"])
+ assert.match(out, /\[dry-run\] would write/)
+ assert.strictEqual(fs.readdirSync(dir).length, 0, "dry-run must not create files")
+ }],
+
+ ["unsupported conversion exits non-zero", () => {
+ assert.throws(() => runCli(["--from", "claude", "--to", "zed"]), /unsupported conversion/);
+ }],
+
+ ["unknown flag exits non-zero", () => {
+ assert.throws(() => runCli(["--bogus"]), /unknown argument/)
+ }],
+
+ ["--json --out keeps stdout parseable JSON", () => {
+ const dir = tmpdir()
+ const out = runCli(["--from", "claude", "--to", "pi", "--out", dir, "--json"])
+ const summary = JSON.parse(out)
+ assert.strictEqual(summary.converted, AGENT_COUNT)
+ assert.strictEqual(summary.schema, "ecc.agent-ir.v1")
+ }],
+
+ ["missing value for --out exits non-zero", () => {
+ assert.throws(() => runCli(["--from", "claude", "--to", "pi", "--out"]), /missing value for --out/)
+ }],
+
+ ["--check --json emits JSON", () => {
+ const out = runCli(["--check", "--json"])
+ const summary = JSON.parse(out)
+ assert.strictEqual(summary.ok, true)
+ assert.strictEqual(summary.checked, AGENT_COUNT)
+ }],
+
+ ["--out followed by a flag is rejected (no unintended write)", () => {
+ const dir = tmpdir()
+ assert.throws(() => runCli(["--from", "claude", "--to", "pi", "--out", "--dry-run"]), /missing value for --out/)
+ assert.strictEqual(fs.readdirSync(dir).length, 0, "must not write anything")
+ }],
+ ]
+
+ for (const [name, fn] of tests) {
+ if (runTest(name, fn)) {
+ passed += 1
+ } else {
+ failed += 1
+ }
+ }
+
+ console.log(`\nPassed: ${passed}`)
+ console.log(`Failed: ${failed}`)
+ process.exit(failed > 0 ? 1 : 0)
+}
+
+main()
diff --git a/tests/pi/agent-emit.test.js b/tests/pi/agent-emit.test.js
new file mode 100644
index 000000000..6c14ccf29
--- /dev/null
+++ b/tests/pi/agent-emit.test.js
@@ -0,0 +1,176 @@
+const AGENT_COUNT = require('fs').readdirSync(require('path').join(__dirname,'../../agents')).filter(f => f.endsWith('.md')).length;
+/**
+ * Tests for the ECC Agent IR → Pi emitter (scripts/lib/agent-emit-pi.js).
+ *
+ * Verifies that every IR object becomes a valid Pi subagent definition against
+ * the verified `@tintinweb/pi-subagents@0.19.0` surface: `name` / `description`
+ * / `tools` (comma-separated allowlist) / `prompt_mode`, with the prompt in the
+ * body. Also enforces the permission boundary and the empty-allowlist guard.
+ */
+
+const assert = require("assert")
+const yaml = require("js-yaml")
+
+const { parseAllAgents } = require("../../scripts/lib/agent-ir")
+const { emitPiAgent, emitAllPiAgents, RESERVED_AGENT_NAMES } = require("../../scripts/lib/agent-emit-pi")
+const { CLAUDE_TO_PI_TOOLS, mapToolToPi } = require("../../scripts/lib/agent-tool-map")
+
+const VALID_PI_TOOLS = new Set(Object.values(CLAUDE_TO_PI_TOOLS))
+
+function runTest(name, fn) {
+ try {
+ fn()
+ console.log(` ✓ ${name}`)
+ return true
+ } catch (error) {
+ console.log(` ✗ ${name}`)
+ console.error(` ${error.message}`)
+ return false
+ }
+}
+
+function parseEmittedFrontmatter(markdown) {
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+ assert.ok(match, "emitted markdown must start with a frontmatter block")
+ const out = {}
+ for (const line of match[1].split(/\r?\n/)) {
+ const idx = line.indexOf(":")
+ if (idx <= 0) continue
+ const key = line.slice(0, idx).trim()
+ out[key] = line.slice(idx + 1).trim()
+ }
+ return out
+}
+
+function main() {
+ let passed = 0
+ let failed = 0
+
+ let irs
+ let results
+ let warnings
+ try {
+ irs = parseAllAgents()
+ ;({ results, warnings } = emitAllPiAgents(irs))
+ } catch (error) {
+ console.log(` ✗ setup failed: ${error.message}`)
+ console.log("\nPassed: 0")
+ console.log("Failed: 1")
+ process.exit(1)
+ }
+
+ const byId = new Map(irs.map(ir => [ir.id, ir]))
+
+ const tests = [
+ ["emits all AGENT_COUNT agents", () => {
+ assert.strictEqual(results.length, AGENT_COUNT)
+ }],
+
+ ["frontmatter uses the verified prompt_mode contract (no package, no systemPromptMode)", () => {
+ for (const r of results) {
+ const fm = parseEmittedFrontmatter(r.markdown)
+ assert.strictEqual(fm.prompt_mode, "replace", `${r.id}: prompt_mode must be replace`)
+ assert.strictEqual(fm.systemPromptMode, undefined, `${r.id}: systemPromptMode must not be emitted`)
+ assert.strictEqual(fm.package, undefined, `${r.id}: package must not be emitted (name is the dispatch id)`)
+ assert.strictEqual(fm.model, undefined, `${r.id}: model must be omitted (Pi default applies)`)
+ assert.strictEqual(fm.name, r.name, `${r.id}: name mismatch`)
+ assert.ok(fm.description.length > 0, `${r.id}: empty description`)
+ }
+ }],
+
+ ["tool allowlist contains only verified Pi built-ins", () => {
+ for (const r of results) {
+ for (const tool of r.tools) {
+ assert.ok(VALID_PI_TOOLS.has(tool), `${r.id}: invalid Pi tool '${tool}'`)
+ }
+ }
+ }],
+
+ ["no Claude tool name leaks into the Pi allowlist", () => {
+ const claudeNames = new Set(["Read", "Grep", "Glob", "Bash", "Edit", "Write", "WebSearch", "WebFetch"])
+ for (const r of results) {
+ for (const tool of r.tools) {
+ assert.ok(!claudeNames.has(tool), `${r.id}: leaked Claude tool '${tool}'`)
+ }
+ }
+ }],
+
+ ["read-only agents never gain bash", () => {
+ for (const r of results) {
+ const source = byId.get(r.id)
+ if (!source.tools.includes("Bash")) {
+ assert.ok(!r.tools.includes("bash"), `${r.id}: read-only agent must not emit 'bash'`)
+ }
+ }
+ }],
+
+ ["mcp__* tools are never mapped to a tool", () => {
+ assert.ok(!results.some(r => r.tools.includes("mcp")), "no 'mcp' tool should be emitted")
+ assert.ok(warnings.some(w => w.includes("docs-lookup") && w.includes("MCP tool")), "docs-lookup MCP must warn")
+ }],
+
+ ["every unsupported source tool is warned, never silently dropped", () => {
+ const unsupportedCount = irs.flatMap(ir => ir.tools).filter(t => mapToolToPi(t).unsupported).length
+ const warnedCount = warnings.filter(w => /not a Pi built-in|unmapped tool/.test(w)).length
+ assert.strictEqual(warnedCount, unsupportedCount, "each unsupported source tool must warn exactly once")
+ assert.ok(unsupportedCount > 0, "expected WebSearch/WebFetch/mcp__* to be unsupported")
+ }],
+
+ ["empty allowlist is emitted as tools: none (never blank, never escalates)", () => {
+ const noTools = { id: "x", name: "x", description: "x", tools: ["WebSearch"], body: "hi" }
+ const { markdown, tools } = emitPiAgent(noTools)
+ assert.deepStrictEqual(tools, [], "WebSearch-only agent must have an empty allowlist")
+ assert.match(markdown, /^tools: none$/m, "empty allowlist must be emitted as 'tools: none'")
+ }],
+
+ ["reserved built-in agent names are warned", () => {
+ const colliding = { id: "general-purpose", name: "general-purpose", description: "x", tools: ["Read"], body: "hi" }
+ const { warnings: w } = emitPiAgent(colliding)
+ assert.ok(w.some(x => x.includes("collides with a Pi built-in agent")), "built-in name collision must warn")
+ assert.ok(RESERVED_AGENT_NAMES.has("general-purpose"), "reserved set is pinned")
+ }],
+
+ ["body is preserved losslessly", () => {
+ for (const ir of irs) {
+ const emitted = results.find(r => r.id === ir.id)
+ const body = ir.body.replace(/^\n+/, "").trimEnd()
+ assert.ok(emitted.markdown.includes(body), `${ir.id}: body not preserved`)
+ }
+ }],
+
+ ["planner maps to read, grep, find", () => {
+ const planner = results.find(r => r.id === "planner")
+ assert.deepStrictEqual(planner.tools, ["read", "grep", "find"])
+ }],
+
+ ["emission is deterministic", () => {
+ const a = emitAllPiAgents(irs).results.map(r => r.markdown).join("\n")
+ const b = emitAllPiAgents(irs).results.map(r => r.markdown).join("\n")
+ assert.strictEqual(a, b)
+ }],
+
+ ["emitted frontmatter is valid YAML and round-trips key fields", () => {
+ for (const r of results) {
+ const match = r.markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+ const fm = yaml.load(match[1])
+ assert.strictEqual(fm.name, r.name, `${r.id}: name round-trip`)
+ assert.strictEqual(fm.prompt_mode, "replace", `${r.id}: prompt_mode round-trip`)
+ assert.deepStrictEqual(String(fm.tools).split(", "), r.tools, `${r.id}: tools round-trip`)
+ }
+ }],
+ ]
+
+ for (const [name, fn] of tests) {
+ if (runTest(name, fn)) {
+ passed += 1
+ } else {
+ failed += 1
+ }
+ }
+
+ console.log(`\nPassed: ${passed}`)
+ console.log(`Failed: ${failed}`)
+ process.exit(failed > 0 ? 1 : 0)
+}
+
+main()
diff --git a/tests/pi/agent-ir.test.js b/tests/pi/agent-ir.test.js
new file mode 100644
index 000000000..c66ede9ea
--- /dev/null
+++ b/tests/pi/agent-ir.test.js
@@ -0,0 +1,130 @@
+const AGENT_COUNT = require('fs').readdirSync(require('path').join(__dirname,'../../agents')).filter(f => f.endsWith('.md')).length;
+/**
+ * Tests for the ECC Agent IR parser (scripts/lib/agent-ir.js).
+ *
+ * Verifies the canonical-agent parse path: every Claude-format agent under
+ * agents/ must parse into a valid ecc.agent-ir.v1 object, deterministically,
+ * with lossless frontmatter retention.
+ */
+
+const assert = require("assert")
+const fs = require("fs")
+const path = require("path")
+
+const { parseAgentFile, parseAllAgents, IR_SCHEMA_CONST } = require("../../scripts/lib/agent-ir")
+
+const REPO_ROOT = path.join(__dirname, "..", "..")
+const AGENTS_DIR = path.join(REPO_ROOT, "agents")
+
+function runTest(name, fn) {
+ try {
+ fn()
+ console.log(` ✓ ${name}`)
+ return true
+ } catch (error) {
+ console.log(` ✗ ${name}`)
+ console.error(` ${error.message}`)
+ return false
+ }
+}
+
+function main() {
+ let passed = 0
+ let failed = 0
+
+ const tests = [
+ ["parses all AGENT_COUNT agents", () => {
+ const agents = parseAllAgents()
+ assert.strictEqual(agents.length, AGENT_COUNT, "expected AGENT_COUNT agents")
+ for (const ir of agents) {
+ assert.strictEqual(ir.schema, IR_SCHEMA_CONST, `${ir.id}: wrong schema const`)
+ assert.ok(/^[a-z0-9-]+$/.test(ir.id), `${ir.id}: id must be kebab-case`)
+ assert.ok(ir.name.length > 0, `${ir.id}: empty name`)
+ assert.ok(ir.description.length > 0, `${ir.id}: empty description`)
+ assert.ok(Array.isArray(ir.tools) && ir.tools.length > 0, `${ir.id}: empty tools`)
+ assert.ok(ir.body.length > 0, `${ir.id}: empty body`)
+ }
+ }],
+
+ ["is deterministic (stable key order + sorted ids)", () => {
+ const a = JSON.stringify(parseAllAgents())
+ const b = JSON.stringify(parseAllAgents())
+ assert.strictEqual(a, b, "parseAllAgents must be deterministic")
+ }],
+
+ ["retains current model tier distribution (61 sonnet / 6 haiku / 4 opus)", () => {
+ const agents = parseAllAgents()
+ for (const ir of agents) {
+ assert.ok(ir.model, `${ir.id}: missing model tier`)
+ }
+ const tally = agents.reduce(
+ (acc, ir) => ({ ...acc, [ir.model]: (acc[ir.model] || 0) + 1 }),
+ {}
+ )
+ assert.strictEqual(tally.sonnet, 61, "sonnet count")
+ assert.strictEqual(tally.haiku, 6, "haiku count")
+ assert.strictEqual(tally.opus, 4, "opus count")
+ }],
+
+ ["retains source-only color metadata", () => {
+ const agents = parseAllAgents()
+ const withColor = agents.filter(ir => ir.color)
+ assert.strictEqual(withColor.length, 5, "exactly 5 agents carry color")
+ for (const ir of withColor) {
+ assert.ok(typeof ir.color === "string" && ir.color.length > 0, `${ir.id}: bad color`)
+ }
+ }],
+
+ ["parses a known agent losslessly", () => {
+ const ir = parseAgentFile(path.join(AGENTS_DIR, "planner.md"))
+ assert.strictEqual(ir.id, "planner")
+ assert.strictEqual(ir.name, "planner")
+ assert.strictEqual(ir.model, "opus")
+ assert.deepStrictEqual(ir.tools, ["Read", "Grep", "Glob"])
+ assert.ok(ir.frontmatter, "raw frontmatter must be retained")
+ assert.strictEqual(ir.frontmatter.name, "planner", "frontmatter.name lossless")
+ assert.ok(ir.body.includes("You are an expert planning specialist"), "body preserved")
+ }],
+
+ ["throws on a missing frontmatter block", () => {
+ const badPath = path.join(__dirname, "..", "fixtures", "agent-no-frontmatter.md")
+ fs.mkdirSync(path.dirname(badPath), { recursive: true })
+ fs.writeFileSync(badPath, "# no frontmatter here\n")
+ try {
+ assert.throws(() => parseAgentFile(badPath), /missing frontmatter/)
+ } finally {
+ fs.rmSync(badPath, { force: true })
+ }
+ }],
+
+ ["throws on an invalid model tier instead of silently dropping it", () => {
+ const badPath = path.join(__dirname, "..", "fixtures", "agent-invalid-model.md")
+ fs.mkdirSync(path.dirname(badPath), { recursive: true })
+ fs.writeFileSync(badPath, "---\nname: x\ndescription: x\ntools: Read\nmodel: sonnet4\n---\n\nbody\n")
+ try {
+ assert.throws(() => parseAgentFile(badPath), /invalid model tier 'sonnet4'/)
+ } finally {
+ fs.rmSync(badPath, { force: true })
+ }
+ }],
+
+ ["throws when the agents directory is absent", () => {
+ const missingDir = path.join(__dirname, "..", "fixtures", "agents-does-not-exist")
+ assert.throws(() => parseAllAgents(missingDir), /agents directory not found/)
+ }],
+ ]
+
+ for (const [name, fn] of tests) {
+ if (runTest(name, fn)) {
+ passed += 1
+ } else {
+ failed += 1
+ }
+ }
+
+ console.log(`\nPassed: ${passed}`)
+ console.log(`Failed: ${failed}`)
+ process.exit(failed > 0 ? 1 : 0)
+}
+
+main()
diff --git a/tests/pi/pi-extension-adapter.test.js b/tests/pi/pi-extension-adapter.test.js
index 08c957a8e..d03032baa 100644
--- a/tests/pi/pi-extension-adapter.test.js
+++ b/tests/pi/pi-extension-adapter.test.js
@@ -319,6 +319,22 @@ function isDisabledByEnvMirror(value) {
return typeof value === "string" && DISABLED_VALUES_MIRROR.has(value.trim().toLowerCase())
}
+/**
+ * Mirror of the adapter's `describeAgentPortability` (same three states, same
+ * exact strings). The source-text assertions in the matching test pin the real
+ * function's wording out of `.pi/extensions/index.ts` so this copy cannot drift.
+ */
+function describeAgentPortabilityMirror(agentCount, portability) {
+ if (agentCount === 0) return "NOT FOUND"
+ if (portability.status === "validated") {
+ return `${portability.count} agent(s) source schema validated; Pi conversion available via scripts/agent-convert.js`
+ }
+ if (portability.status === "failed") {
+ return `${agentCount} agent(s) present but validation FAILED: ${portability.reason}`
+ }
+ return `${agentCount} agent(s) present (unvalidated — ${portability.reason})`
+}
+
/** Run the Pi adapter regression suite. */
async function main() {
console.log("\n=== Testing .pi/extensions/index.ts (Pi thin adapter) ===\n")
@@ -1495,6 +1511,48 @@ async function main() {
)
}],
+ ["/ecc-doctor validates agent portability instead of trusting file existence (source contract)", () => {
+ const start = extensionSource.indexOf("function validateAgentPortability")
+ assert.ok(start !== -1, "expected .pi/extensions/index.ts to define validateAgentPortability")
+ const end = extensionSource.indexOf("\nfunction ", start + 1)
+ const src = end === -1 ? extensionSource.slice(start) : extensionSource.slice(start, end)
+
+ assert.ok(src.includes("execFileSync("), "must use execFileSync (no shell) to run the check")
+ assert.ok(src.includes('"--check"'), "must run agent-convert.js in --check mode")
+ assert.ok(/timeout:\s*AGENT_CHECK_TIMEOUT_MS/.test(src), "must bound the check with a finite timeout")
+ assert.ok(src.includes("resolveHookRuntime()"), "must reuse the node-runtime resolver, not hardcode 'node'")
+ assert.ok(src.includes('"validated"'), "must model the validated state")
+ assert.ok(src.includes('"failed"'), "must model the failed state")
+ assert.ok(src.includes('"unvalidated"'), "must model the unvalidated state")
+ assert.ok(/OK: \(\\d\+\) agents parsed and validated/.test(src), "must parse the real --check output")
+ }],
+
+ ["/ecc-doctor renders three honest agent states (behavioral mirror)", () => {
+ assert.strictEqual(
+ describeAgentPortabilityMirror(68, { status: "validated", count: 68 }),
+ "68 agent(s) source schema validated; Pi conversion available via scripts/agent-convert.js"
+ )
+ assert.strictEqual(
+ describeAgentPortabilityMirror(68, { status: "failed", reason: "boom" }),
+ "68 agent(s) present but validation FAILED: boom"
+ )
+ assert.strictEqual(
+ describeAgentPortabilityMirror(68, { status: "unvalidated", reason: "no node" }),
+ "68 agent(s) present (unvalidated — no node)"
+ )
+ assert.strictEqual(
+ describeAgentPortabilityMirror(0, { status: "unvalidated", reason: "none" }),
+ "NOT FOUND"
+ )
+
+ assert.ok(
+ extensionSource.includes("agent(s) source schema validated; Pi conversion available via scripts/agent-convert.js"),
+ "expected the validated line pinned in the source"
+ )
+ assert.ok(extensionSource.includes("validation FAILED:"), "expected the failed line pinned in the source")
+ assert.ok(extensionSource.includes("unvalidated —"), "expected the unvalidated line pinned in the source")
+ }],
+
["isDisabledByEnv() behavioral mirror: recognizes 0/false/off/none/disabled case- and whitespace-insensitively, and the real function reads ECC_PI_RULES", () => {
for (const disabledValue of ["0", "false", "off", "none", "disabled"]) {
assert.strictEqual(
diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js
index b176bd827..ce9afaae8 100644
--- a/tests/scripts/npm-publish-surface.test.js
+++ b/tests/scripts/npm-publish-surface.test.js
@@ -67,6 +67,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/eval-harness.js",
"examples/eval-harness",
"scripts/feedback.js",
+ "scripts/agent-convert.js",
"scripts/catalog.js",
"scripts/ci/scan-supply-chain-iocs.js",
"scripts/ci/supply-chain-advisory-sources.js",
@@ -303,6 +304,11 @@ function main() {
"skills/backend-patterns/SKILL.md",
"skills/skill-comply/SKILL.md",
"skills/unified-memory/SKILL.md",
+ "scripts/agent-convert.js",
+ "scripts/lib/agent-ir.js",
+ "scripts/lib/agent-tool-map.js",
+ "scripts/lib/agent-emit-pi.js",
+ "schemas/agent.schema.json",
]) {
assert.ok(
packagedPaths.has(requiredPath),