mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 04:35:29 +02:00
feat(agents): integrate isolated Pi conversion foundation from PR 3022
Carry the canonical IR, Pi emitter and source-schema doctor from https://github.com/affaan-m/ECC/pull/3022. Cursor and OpenCode emitters remain with the open source PR pending capability fixes. Co-authored-by: alizahidraja <alizahidrajaa@gmail.com>
This commit is contained in:
+65
-1
@@ -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()}`,
|
||||
|
||||
@@ -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/`. |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <dir> [--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 <dir> [--dry-run]
|
||||
Write emitted Pi agent files (<dir>/<id>.md). --dry-run prints only.
|
||||
|
||||
Options:
|
||||
--from <claude> Source harness (v1: claude only)
|
||||
--to <pi> Target harness (v1: pi only)
|
||||
--json Machine-readable summary on stdout
|
||||
--out <dir> 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();
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user