mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
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>
135 lines
4.5 KiB
JavaScript
135 lines
4.5 KiB
JavaScript
#!/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,
|
|
};
|