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:
haelyra
2026-10-10 17:43:18 -04:00
co-authored by alizahidraja
parent 58fc8f25a5
commit eb7ffd8d64
14 changed files with 1159 additions and 4 deletions
+175
View File
@@ -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();
+134
View File
@@ -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,
};
+157
View File
@@ -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,
};
+69
View File
@@ -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,
};
+6 -2
View File
@@ -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',
],
},
{