Files
ECC/scripts/lib/agent-ir.js
T
haelyraandalizahidraja eb7ffd8d64 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>
2026-10-10 17:43:18 -04:00

158 lines
4.5 KiB
JavaScript

#!/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,
};