From eb7ffd8d643e041c275bedceffc49fe8957e4a8e Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:43:18 -0400 Subject: [PATCH] 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 --- .pi/extensions/index.ts | 66 ++++++- .../harness-adapter-compliance.md | 2 +- package.json | 1 + schemas/agent.schema.json | 59 ++++++ scripts/agent-convert.js | 175 +++++++++++++++++ scripts/lib/agent-emit-pi.js | 134 +++++++++++++ scripts/lib/agent-ir.js | 157 ++++++++++++++++ scripts/lib/agent-tool-map.js | 69 +++++++ scripts/lib/harness-adapter-compliance.js | 8 +- tests/pi/agent-convert.test.js | 122 ++++++++++++ tests/pi/agent-emit.test.js | 176 ++++++++++++++++++ tests/pi/agent-ir.test.js | 130 +++++++++++++ tests/pi/pi-extension-adapter.test.js | 58 ++++++ tests/scripts/npm-publish-surface.test.js | 6 + 14 files changed, 1159 insertions(+), 4 deletions(-) create mode 100644 schemas/agent.schema.json create mode 100644 scripts/agent-convert.js create mode 100644 scripts/lib/agent-emit-pi.js create mode 100644 scripts/lib/agent-ir.js create mode 100644 scripts/lib/agent-tool-map.js create mode 100644 tests/pi/agent-convert.test.js create mode 100644 tests/pi/agent-emit.test.js create mode 100644 tests/pi/agent-ir.test.js diff --git a/.pi/extensions/index.ts b/.pi/extensions/index.ts index 65810292d..241deabce 100644 --- a/.pi/extensions/index.ts +++ b/.pi/extensions/index.ts @@ -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()}`, diff --git a/docs/architecture/harness-adapter-compliance.md b/docs/architecture/harness-adapter-compliance.md index 09061190d..97588546f 100644 --- a/docs/architecture/harness-adapter-compliance.md +++ b/docs/architecture/harness-adapter-compliance.md @@ -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/`. | diff --git a/package.json b/package.json index ec14ccc57..fb9eb4f8e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json new file mode 100644 index 000000000..a04cd56ca --- /dev/null +++ b/schemas/agent.schema.json @@ -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 + } + } +} diff --git a/scripts/agent-convert.js b/scripts/agent-convert.js new file mode 100644 index 000000000..5274917f7 --- /dev/null +++ b/scripts/agent-convert.js @@ -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 [--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 [--dry-run] + Write emitted Pi agent files (/.md). --dry-run prints only. + +Options: + --from Source harness (v1: claude only) + --to Target harness (v1: pi only) + --json Machine-readable summary on stdout + --out 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(); diff --git a/scripts/lib/agent-emit-pi.js b/scripts/lib/agent-emit-pi.js new file mode 100644 index 000000000..1d8956332 --- /dev/null +++ b/scripts/lib/agent-emit-pi.js @@ -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, +}; diff --git a/scripts/lib/agent-ir.js b/scripts/lib/agent-ir.js new file mode 100644 index 000000000..5e35fdff9 --- /dev/null +++ b/scripts/lib/agent-ir.js @@ -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, +}; diff --git a/scripts/lib/agent-tool-map.js b/scripts/lib/agent-tool-map.js new file mode 100644 index 000000000..538c61c4c --- /dev/null +++ b/scripts/lib/agent-tool-map.js @@ -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, +}; diff --git a/scripts/lib/harness-adapter-compliance.js b/scripts/lib/harness-adapter-compliance.js index e1aafd752..df3bbd86a 100644 --- a/scripts/lib/harness-adapter-compliance.js +++ b/scripts/lib/harness-adapter-compliance.js @@ -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', ], }, { diff --git a/tests/pi/agent-convert.test.js b/tests/pi/agent-convert.test.js new file mode 100644 index 000000000..dadc2783f --- /dev/null +++ b/tests/pi/agent-convert.test.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() diff --git a/tests/pi/agent-emit.test.js b/tests/pi/agent-emit.test.js new file mode 100644 index 000000000..6c14ccf29 --- /dev/null +++ b/tests/pi/agent-emit.test.js @@ -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() diff --git a/tests/pi/agent-ir.test.js b/tests/pi/agent-ir.test.js new file mode 100644 index 000000000..c66ede9ea --- /dev/null +++ b/tests/pi/agent-ir.test.js @@ -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() diff --git a/tests/pi/pi-extension-adapter.test.js b/tests/pi/pi-extension-adapter.test.js index 08c957a8e..d03032baa 100644 --- a/tests/pi/pi-extension-adapter.test.js +++ b/tests/pi/pi-extension-adapter.test.js @@ -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( diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index b176bd827..ce9afaae8 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -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),