mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +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>
158 lines
4.5 KiB
JavaScript
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,
|
|
};
|