UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

327 lines 14.4 kB
/** * Single-source-of-truth manifest loader (M06a). * * Reads `workflow/manifest.json` and returns a resolved `Manifest` where every * `facts` field has been computed from canonical code sources (derived) or * validated against observed on-disk reality (static). Loading fails hard with * a `ManifestValidationError` when a static fact has drifted from what the * code actually exposes - that is the entire point of the module. * * Used by `composeQuality` and `composeSetup` to avoid hardcoded counts, and * by the `goat-flow manifest` CLI command. */ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { SKILL_NAMES } from "../constants.js"; import { SETUP_CHECKS } from "../audit/check-goat-flow.js"; import { AGENT_CHECKS } from "../audit/check-agent-setup.js"; import { HARNESS_CHECKS } from "../audit/harness/index.js"; import { getPackageVersion, getTemplatePath, isPackagedInstall, resolveFirstExistingPackagePath, } from "../paths.js"; import { ManifestValidationError } from "./types.js"; import { readManifestJson } from "./manifest-json.js"; // Re-exported here for API stability. These declarations moved into a sibling // module to break a circular dependency (its header explains the full story); // consumers and tests still reference them through this module. // (search: "design.circular-import") export { validateSkillReferenceSchema, getRequiredInstructionSections, } from "./manifest-json.js"; /** goat-flow architectural fact: `goat` is the dispatcher, the rest are functional. */ const DISPATCHER_NAME = "goat"; const PROMPT_INVOCATION_STYLES = new Set(["slash", "dollar"]); const SKILL_SOURCES = new Set(["installed", "agent-mirror", "github-mirror"]); function readAgentCapabilityCandidate(agent) { if (typeof agent !== "object" || agent === null || Array.isArray(agent)) { return null; } const capabilities = agent.capabilities; if (typeof capabilities !== "object" || capabilities === null || Array.isArray(capabilities)) { return null; } return capabilities; } function validateAgentCapabilityFields(capabilities, prefix) { const findings = []; if (typeof capabilities.terminal_binary !== "string" || capabilities.terminal_binary.trim().length === 0) { findings.push(`${prefix}.terminal_binary must be a non-empty string.`); } if (!Array.isArray(capabilities.setup_surfaces) || capabilities.setup_surfaces.length === 0 || capabilities.setup_surfaces.some((surface) => typeof surface !== "string" || surface.trim().length === 0)) { findings.push(`${prefix}.setup_surfaces must be a non-empty string array.`); } if (typeof capabilities.prompt_invocation_style !== "string" || !PROMPT_INVOCATION_STYLES.has(capabilities.prompt_invocation_style)) { findings.push(`${prefix}.prompt_invocation_style must be one of: slash, dollar.`); } if (typeof capabilities.skill_source !== "string" || !SKILL_SOURCES.has(capabilities.skill_source)) { findings.push(`${prefix}.skill_source must be one of: installed, agent-mirror, github-mirror.`); } return findings; } /** Reports capability metadata errors so `validateManifest` can aggregate them with drift findings. */ function validateAgentCapabilities(json) { const findings = []; for (const [agentId, agent] of Object.entries(json.agents)) { const prefix = `agents.${agentId}.capabilities`; const capabilities = readAgentCapabilityCandidate(agent); if (capabilities === null) { findings.push(`${prefix} must be an object.`); continue; } findings.push(...validateAgentCapabilityFields(capabilities, prefix)); } return findings; } /** Enumerate dashboard view names; invariant: manifest view facts mirror these HTML files. */ function readDashboardViewNames() { const dir = getTemplatePath(join("src", "dashboard", "views")); if (!existsSync(dir)) return []; return readdirSync(dir) .filter((f) => f.endsWith(".html")) .map((f) => f.replace(/\.html$/, "")) .sort(); } /** Relative locations where the dashboard preset catalog may exist. */ const PRESET_CATALOG_PATHS = [ join("src", "dashboard", "preset-prompts.json"), join("dist", "dashboard", "preset-prompts.json"), ]; /** Count preset objects in the dashboard preset catalog; throws when no usable catalog exists. */ function countPresetsFromSource() { let absolute; try { absolute = resolveFirstExistingPackagePath(PRESET_CATALOG_PATHS); } catch { throw new ManifestValidationError("Could not find a dashboard preset catalog in src/ or dist/.", PRESET_CATALOG_PATHS.map((relative) => `${relative} not found.`)); } const relative = PRESET_CATALOG_PATHS.find((candidate) => getTemplatePath(candidate) === absolute) ?? PRESET_CATALOG_PATHS[0]; const raw = JSON.parse(readFileSync(absolute, "utf-8")); if (!Array.isArray(raw)) { throw new ManifestValidationError(`${relative} must contain a JSON array.`, [`${relative} must contain a JSON array.`]); } return raw.length; } /** Compute derived skill facts from `SKILL_NAMES` and the manifest's stale list. */ function computeSkills(names, staleNames) { return { total: names.length, names, dispatcher: DISPATCHER_NAME, functional_count: names.filter((n) => n !== DISPATCHER_NAME).length, stale_names: staleNames, }; } /** Compute derived check counts from the three check arrays. */ function computeChecks(observed) { return { setup: observed.setupChecks, agent: observed.agentChecks, harness: observed.harnessChecks, total: observed.setupChecks + observed.agentChecks + observed.harnessChecks, }; } /** Compare string arrays as sets; invariant: manifest order is not semantically meaningful. */ function sameSortedSet(leftValues, rightValues) { if (leftValues.length !== rightValues.length) return false; const sortedLeft = [...leftValues].sort(); const sortedRight = [...rightValues].sort(); for (let i = 0; i < sortedLeft.length; i++) { if (sortedLeft[i] !== sortedRight[i]) return false; } return true; } /** * Validate manifest facts against the values observed from live code. * * In packaged installs the `src/` tree isn't shipped (package.json `files` * ships only `dist/` + `workflow/`), so source-derived drift checks for * static facts (`dashboard_views`) would always trip against empty observed * values. That fact was validated at publish time - here we trust the * manifest and skip it. Preset count is derived from the shipped preset * catalog, and skill-canonical drift is still checked because `SKILL_NAMES` * ships in `dist/`. * * @param json - Parsed manifest JSON from `workflow/manifest.json`. * @param observed - Facts observed from the current source or packaged install. */ export function validateManifest(json, observed) { const findings = []; findings.push(...validateAgentCapabilities(json)); if (!json.facts) { const msg = "workflow/manifest.json is missing the top-level `facts` key."; throw new ManifestValidationError(msg, [msg]); } const packaged = isPackagedInstall(); if (!packaged) { const declaredViews = json.facts.dashboard_views; if (!sameSortedSet(declaredViews, observed.views)) { findings.push(`facts.dashboard_views drift: manifest declares [${[...declaredViews].sort().join(", ")}]; src/dashboard/views/ has [${observed.views.join(", ")}].`); } } if (!sameSortedSet(json.skills.canonical, observed.skills)) { findings.push(`skills.canonical drift: manifest declares [${[...json.skills.canonical].sort().join(", ")}]; SKILL_NAMES exports [${[...observed.skills].sort().join(", ")}].`); } if (findings.length > 0) { throw new ManifestValidationError(`workflow/manifest.json has drifted from observed state (${findings.length} finding${findings.length === 1 ? "" : "s"}).`, findings); } } /** * Compose the resolved manifest from validated JSON and observed facts. * * @param json - Manifest JSON that has already passed `validateManifest`. * @param observed - Current facts used to fill derived fields. * @returns Resolved manifest used by CLI, dashboard, and prompt composers. */ export function composeManifest(json, observed) { const jsonFacts = json.facts; if (!jsonFacts) { const msg = "composeManifest called before validateManifest - json.facts missing."; throw new ManifestValidationError(msg, [msg]); } const facts = { version: observed.version, skills: computeSkills(observed.skills, json.skills.stale_names), checks: computeChecks(observed), dashboard_views: { count: jsonFacts.dashboard_views.length, names: [...jsonFacts.dashboard_views].sort(), }, presets: { count: observed.presetsCount }, }; return { version: json.version, required_files: json.required_files, required_dirs: json.required_dirs, skills: json.skills, agents: json.agents, instruction_file: json.instruction_file, facts, }; } /** * Return the canonical template-file list for one skill. * * @param name - Canonical skill name from `SKILL_NAMES`. * @returns `SKILL.md` plus manifest-declared reference files for that skill. */ export function getSkillFiles(name) { const references = loadManifest().skills.references ?? {}; const files = references[name]; return [ "SKILL.md", ...(Array.isArray(files) ? files.filter((file) => typeof file === "string") : []), ]; } /** * Return unique installed skill roots declared by the manifest-backed agents. * * @returns Deduplicated skill-root paths with trailing slashes removed. */ export function getInstalledSkillRoots() { return [ ...new Set(Object.values(loadManifest().agents) .map((agent) => agent.skills_dir.replace(/\/$/, "")) .filter((dir) => dir.length > 0)), ]; } let cached = null; /** * Load, validate, and cache the resolved workflow manifest. * * @returns Resolved manifest for the current package/workspace. */ export function loadManifest() { if (cached) return cached; const json = readManifestJson(); const observed = { views: readDashboardViewNames(), presetsCount: countPresetsFromSource(), skills: SKILL_NAMES, setupChecks: SETUP_CHECKS.length, agentChecks: AGENT_CHECKS.length, harnessChecks: HARNESS_CHECKS.length, version: getPackageVersion(), }; validateManifest(json, observed); cached = composeManifest(json, observed); return cached; } /** Clear the module-level cache (tests only). */ export function resetManifestCache() { cached = null; } /** * Run internal consistency validation for `goat-flow manifest --check`. * * Reports `ManifestValidationError` because the CLI needs a structured result; * unexpected errors still throw so operational failures are not hidden. * * @returns Pass/fail report with manifest-drift findings. */ export function checkManifest() { try { loadManifest(); return { status: "pass", findings: [] }; } catch (err) { if (err instanceof ManifestValidationError) { return { status: "fail", findings: err.findings.map((f) => ({ rule: "manifest-drift", message: f, })), }; } throw err; } } /** * Render the resolved manifest as a compact Markdown table. Used by `goat-flow manifest`. * * @param manifest - Resolved manifest to present. * @returns Markdown summary of derived facts and agent surfaces. */ export function renderManifestMarkdown(manifest) { const lines = []; lines.push("# goat-flow manifest"); lines.push(""); lines.push(`**Version:** ${manifest.facts.version}`); lines.push("**Agent registry authority:** `workflow/manifest.json` rendered through `src/cli/agents/registry.ts`"); lines.push(""); lines.push("| Fact | Value | Source |"); lines.push("|------|-------|--------|"); lines.push(`| Setup checks | ${manifest.facts.checks.setup} | derived: \`SETUP_CHECKS.length\` |`); lines.push(`| Agent checks | ${manifest.facts.checks.agent} | derived: \`AGENT_CHECKS.length\` |`); lines.push(`| Harness checks | ${manifest.facts.checks.harness} | derived: \`HARNESS_CHECKS.length\` |`); lines.push(`| Total checks | ${manifest.facts.checks.total} | derived: sum of above |`); lines.push(`| Skills (total) | ${manifest.facts.skills.total} | derived: \`SKILL_NAMES.length\` |`); lines.push(`| Skills (functional) | ${manifest.facts.skills.functional_count} | derived: \`SKILL_NAMES\` minus dispatcher |`); lines.push(`| Dispatcher | \`${manifest.facts.skills.dispatcher}\` | architectural constant |`); lines.push(`| Dashboard views | ${manifest.facts.dashboard_views.count} | static: \`workflow/manifest.json\` (validated against \`src/dashboard/views/\`) |`); lines.push(`| Presets | ${manifest.facts.presets.count} | derived: preset catalog JSON length |`); lines.push(""); lines.push(`**Skills:** ${manifest.facts.skills.names.map((n) => `\`${n}\``).join(", ")}`); lines.push(""); lines.push("## Agents"); lines.push(""); lines.push("| Agent | Instruction | Settings | Hook config | Hooks | Skills |"); lines.push("|------|-------------|----------|-------------|-------|--------|"); for (const [id, agent] of Object.entries(manifest.agents)) { lines.push(`| \`${id}\` (${agent.name}) | \`${agent.instruction_file}\` | \`${agent.settings ?? "n/a"}\` | \`${agent.hook_config_file ?? agent.settings ?? "n/a"}\` | \`${agent.hooks_dir ?? "n/a"}\` | \`${agent.skills_dir}\` |`); } lines.push(""); lines.push(`**Dashboard views:** ${manifest.facts.dashboard_views.names.map((n) => `\`${n}\``).join(", ")}`); return lines.join("\n"); } //# sourceMappingURL=manifest.js.map