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.

453 lines 17.3 kB
/** * Configuration layer for the deterministic skill-quality scoring engine. * * Goat-flow ships with `DEFAULT_QUALITY_CONFIG` calibrated against the in-tree * 12-artifact corpus. Consumer projects override pieces via * `.goat-flow/config.yaml` under the top-level `quality` section. * * Zero-config behaviour (no `quality` section in config) is identical to the * older hardcoded defaults; fixtures still pass without any project * override. */ import { loadConfig } from "../config/reader.js"; import { getAgentProfiles } from "../agents/registry.js"; const DEFAULT_PROFILES = { workflow: { "trigger-clarity": 15, "workflow-completeness": 15, "gate-quality": 10, "evidence-testability": 10, "cold-start": 10, "token-cost": 10, "tool-deps": 10, "write-risk": 10, "skill-reference-fit": 10, }, dispatcher: { "trigger-clarity": 15, "workflow-completeness": 5, "gate-quality": 0, "evidence-testability": 10, "cold-start": 10, "token-cost": 10, "tool-deps": 10, "write-risk": 0, "skill-reference-fit": 10, }, report: { "trigger-clarity": 15, "workflow-completeness": 10, "gate-quality": 10, "evidence-testability": 10, "cold-start": 10, "token-cost": 10, "tool-deps": 10, "write-risk": 0, "skill-reference-fit": 10, }, playbook: { "trigger-clarity": 15, "workflow-completeness": 15, "gate-quality": 0, "evidence-testability": 10, "cold-start": 10, "token-cost": 10, "tool-deps": 10, "write-risk": 0, "skill-reference-fit": 10, }, meta: { "trigger-clarity": 10, "workflow-completeness": 5, "gate-quality": 5, "evidence-testability": 10, "cold-start": 0, "token-cost": 10, "tool-deps": 0, "write-risk": 0, "skill-reference-fit": 10, }, index: { "trigger-clarity": 10, "workflow-completeness": 10, "gate-quality": 0, "evidence-testability": 10, "cold-start": 5, "token-cost": 10, "tool-deps": 5, "write-risk": 0, "skill-reference-fit": 10, }, }; /** Goat-flow's calibrated defaults. Mirrors the legacy hardcoded values exactly. */ export const DEFAULT_QUALITY_CONFIG = { walkRoots: { skills: [ { dir: ".claude/skills", source: "installed" }, { dir: ".agents/skills", source: "agent-mirror" }, { dir: ".github/skills", source: "github-mirror" }, { dir: "workflow/skills", source: "workflow" }, ], references: [ { dir: ".goat-flow/skill-docs", source: "shared-reference" }, { dir: ".goat-flow/skill-docs/playbooks", source: "shared-reference" }, { dir: ".goat-flow/skill-docs/skill-quality-testing", source: "shared-reference", }, ], }, composition: { skillPreamblePath: ".goat-flow/skill-docs/skill-preamble.md", skillConventionsPath: ".goat-flow/skill-docs/skill-conventions.md", skillReferencePattern: "references\\/([^\\s)`\"']+\\.md)", maxComposedBytes: 32 * 1024, }, maxArtifactBytes: 256 * 1024, gateVocabulary: { verificationGate: [ "verification gate", "exit criteria", "testing gate", "proof gate", "BLOCKING GATE", "CHECKPOINT", "\\- \\[ \\]", ], explicitPass: [ "pass[/ ]fail", "exit on", "must pass", "evidence.*required", ], humanStop: [ "BLOCKING GATE", "Human Verification", "approval", "CHECKPOINT", ], }, toolKeywordsRegex: "browser-use|page-capture|Playwright\\s+MCP|\\bbrowser_(?:navigate|snapshot|click|type|fill_form|evaluate|resize|wait_for|network_requests|console_messages)\\b|\\bmcp__[A-Za-z0-9_]+\\b|\\bgh\\b", subtypes: { meta: { detection: { kinds: ["shared-reference"], namePatterns: ["skill-preamble", "skill-conventions"], headingPatterns: [], mustNotHave: [], }, profile: DEFAULT_PROFILES.meta, notes: "Shared meta-reference loaded by every skill at runtime.", }, index: { detection: { kinds: ["shared-reference"], namePatterns: ["skill-quality-testing"], headingPatterns: [], mustNotHave: [], }, profile: DEFAULT_PROFILES.index, notes: "Index reference; routes to sibling files in a subdirectory.", }, playbook: { detection: { kinds: ["shared-reference"], namePatterns: [], headingPatterns: [ "##\\s+Availability Check", "##\\s+.*Workflow", "##\\s+(Environment|Prerequisites|Common Gotchas|Quick Reference)\\b", "\\bbrowser_(?:navigate|snapshot|click|type|fill_form|evaluate|resize|wait_for|network_requests|console_messages)\\b", "\\bPlaywright\\s+MCP\\b", "\\bmcp__[A-Za-z0-9_]+\\b", ], mustNotHave: [], }, profile: DEFAULT_PROFILES.playbook, notes: "Tool playbook; documents capability and availability check.", }, dispatcher: { detection: { kinds: ["skill"], namePatterns: ["goat"], headingPatterns: ["##\\s+Route Map"], mustNotHave: [], }, profile: DEFAULT_PROFILES.dispatcher, notes: "Dispatcher skill; routes to other skills with no own execution.", }, report: { detection: { kinds: ["skill"], namePatterns: ["goat-security"], headingPatterns: ["##\\s+Quick Scan Path", "##\\s+Audit Mode"], mustNotHave: ["##\\s+Step 0"], }, profile: DEFAULT_PROFILES.report, notes: "Reporting-only skill; assesses without writes.", }, workflow: { detection: { kinds: ["skill"], namePatterns: [], headingPatterns: [ "##\\s+Step 0\\b", "\\bCHECKPOINT\\b", "\\b(Read-Only|File-Write|Implement)\\b", ], mustNotHave: [], }, profile: DEFAULT_PROFILES.workflow, notes: "Workflow skill with Step 0 / phases / gates / mode system.", }, }, fixturePath: "test/fixtures/skill-quality/expected-scores.json", additionalFixtures: [], }; /** Narrow unknown values to plain object records. */ function isRecord(candidate) { return (candidate !== null && typeof candidate === "object" && Array.isArray(candidate) === false); } /** Return only string entries from a user-provided array; invalid containers fall back. */ function stringArray(candidate) { if (!Array.isArray(candidate)) return null; return candidate.filter((entry) => typeof entry === "string"); } /** Validate regex sources; RegExp syntax errors use a `false` fallback instead of failing scoring. */ function isValidRegexSource(source, flags = "i") { try { new RegExp(source, flags); return true; } catch { return false; } } /** Merge a regex-source array while dropping invalid expressions individually. */ function regexArray(candidatePatterns, fallback) { const strings = stringArray(candidatePatterns); if (strings === null) return fallback; return strings.filter((source) => isValidRegexSource(source)); } /** Normalize string and object walk-root entries into source-tagged crawl roots. */ function mergeWalkRoot(rawRoots, fallback) { if (!Array.isArray(rawRoots)) return fallback; const result = []; for (const entry of rawRoots) { if (typeof entry === "string") { result.push({ dir: entry, source: "installed" }); continue; } if (!isRecord(entry)) continue; const dir = entry.dir; const source = entry.source ?? "installed"; if (typeof dir !== "string" || typeof source !== "string") continue; result.push({ dir, source: source }); } return result.length > 0 ? result : fallback; } function mergeComposition(rawComposition, fallback) { if (!isRecord(rawComposition)) return fallback; return { skillPreamblePath: typeof rawComposition["skill-preamble-path"] === "string" ? rawComposition["skill-preamble-path"] : rawComposition["skill-preamble-path"] === null ? null : fallback.skillPreamblePath, skillConventionsPath: typeof rawComposition["skill-conventions-path"] === "string" ? rawComposition["skill-conventions-path"] : rawComposition["skill-conventions-path"] === null ? null : fallback.skillConventionsPath, skillReferencePattern: typeof rawComposition["skill-reference-pattern"] === "string" && isValidRegexSource(rawComposition["skill-reference-pattern"], "g") ? rawComposition["skill-reference-pattern"] : fallback.skillReferencePattern, maxComposedBytes: typeof rawComposition["max-composed-bytes"] === "number" && rawComposition["max-composed-bytes"] > 0 ? rawComposition["max-composed-bytes"] : fallback.maxComposedBytes, }; } function mergeGateVocabulary(rawVocabulary, fallback) { if (!isRecord(rawVocabulary)) return fallback; return { verificationGate: regexArray(rawVocabulary["verification-gate"], fallback.verificationGate), explicitPass: regexArray(rawVocabulary["explicit-pass"], fallback.explicitPass), humanStop: regexArray(rawVocabulary["human-stop"], fallback.humanStop), }; } function mergeSubtypeDetection(rawDetection, fallback) { if (!isRecord(rawDetection)) return fallback; const kinds = stringArray(rawDetection.kinds); return { kinds: kinds ? kinds : fallback.kinds, namePatterns: stringArray(rawDetection["name-patterns"]) ?? fallback.namePatterns, headingPatterns: regexArray(rawDetection["heading-patterns"], fallback.headingPatterns), mustNotHave: regexArray(rawDetection["must-not-have"], fallback.mustNotHave), }; } function mergeSubtypeProfile(rawProfile, fallback) { if (!isRecord(rawProfile)) return fallback; const profile = { ...fallback.profile }; if (isRecord(rawProfile.profile)) { for (const key of Object.keys(profile)) { const scoreValue = rawProfile.profile[key]; if (typeof scoreValue === "number" && scoreValue >= 0) { profile[key] = scoreValue; } } } return { detection: mergeSubtypeDetection(rawProfile.detection, fallback.detection), profile, notes: typeof rawProfile.notes === "string" ? rawProfile.notes : fallback.notes, }; } function mergeSubtypes(rawSubtypes, fallback) { if (!isRecord(rawSubtypes)) return fallback; const merged = { ...fallback }; for (const key of Object.keys(merged)) { if (key in rawSubtypes) { merged[key] = mergeSubtypeProfile(rawSubtypes[key], fallback[key]); } } return merged; } /** Read installed agent profiles so default skill roots follow the current manifest. */ function manifestSkillWalkRoots() { const roots = new Map(); for (const profile of getAgentProfiles()) { if (!roots.has(profile.skillsDir)) { roots.set(profile.skillsDir, profile.skillSource); } } roots.set("workflow/skills", "workflow"); return Array.from(roots, ([dir, source]) => ({ dir, source })); } /** Clone calibrated defaults and replace hardcoded skill roots with manifest-derived roots. */ function defaultQualityConfig() { const defaults = cloneQualityConfig(DEFAULT_QUALITY_CONFIG); defaults.walkRoots.skills = manifestSkillWalkRoots(); return defaults; } /** * Merge a raw quality config (read from YAML) on top of `DEFAULT_QUALITY_CONFIG`. * * Each section falls back independently because project YAML is user-authored: * one invalid override should not discard unrelated valid rubric settings. * * @param raw - Parsed `quality` block from `.goat-flow/config.yaml`. * @returns Normalized quality config with defaults filled in for missing or invalid fields. */ export function mergeQualityConfig(raw) { if (!isRecord(raw)) return defaultQualityConfig(); const defaults = defaultQualityConfig(); return { walkRoots: { skills: mergeWalkRoot(isRecord(raw["walk-roots"]) ? raw["walk-roots"].skills : undefined, defaults.walkRoots.skills), references: mergeWalkRoot(isRecord(raw["walk-roots"]) ? raw["walk-roots"].references : undefined, defaults.walkRoots.references), }, composition: mergeComposition(raw.composition, defaults.composition), maxArtifactBytes: typeof raw["max-artifact-bytes"] === "number" && raw["max-artifact-bytes"] > 0 ? raw["max-artifact-bytes"] : defaults.maxArtifactBytes, gateVocabulary: mergeGateVocabulary(raw["gate-vocabulary"], defaults.gateVocabulary), toolKeywordsRegex: typeof raw["tool-keywords-regex"] === "string" && isValidRegexSource(raw["tool-keywords-regex"]) ? raw["tool-keywords-regex"] : defaults.toolKeywordsRegex, subtypes: mergeSubtypes(raw.subtypes, defaults.subtypes), fixturePath: typeof raw["fixture-path"] === "string" ? raw["fixture-path"] : defaults.fixturePath, additionalFixtures: stringArray(raw["additional-fixtures"]) ?? defaults.additionalFixtures, }; } /** * Deep-clone a quality config so callers can mutate nested arrays safely. * * @param config - Normalized quality config to clone. * @returns Independent copy with no shared mutable arrays or nested objects. */ export function cloneQualityConfig(config) { return { walkRoots: { skills: config.walkRoots.skills.map((root) => ({ ...root })), references: config.walkRoots.references.map((root) => ({ ...root })), }, composition: { ...config.composition }, maxArtifactBytes: config.maxArtifactBytes, gateVocabulary: { verificationGate: [...config.gateVocabulary.verificationGate], explicitPass: [...config.gateVocabulary.explicitPass], humanStop: [...config.gateVocabulary.humanStop], }, toolKeywordsRegex: config.toolKeywordsRegex, subtypes: Object.fromEntries(Object.entries(config.subtypes).map(([key, value]) => [ key, { detection: { kinds: [...value.detection.kinds], namePatterns: [...value.detection.namePatterns], headingPatterns: [...value.detection.headingPatterns], mustNotHave: [...value.detection.mustNotHave], }, profile: { ...value.profile }, notes: value.notes, }, ])), fixturePath: config.fixturePath, additionalFixtures: [...config.additionalFixtures], }; } /** * Load `.goat-flow/config.yaml` and return its merged `quality` section, * falling back to `DEFAULT_QUALITY_CONFIG` if the file is missing or has * no `quality` block. * * @param projectRoot - Project root that may contain `.goat-flow/config.yaml`. * @returns Normalized quality config for the project. */ export function loadQualityConfig(projectRoot) { const loaded = loadConfig(projectRoot); const raw = loaded.config.quality; return mergeQualityConfig(raw); } /** * Compile an array of regex sources into a single OR'd RegExp. * * @param patterns - Valid regex sources to combine. * @returns Case-insensitive matcher, or a never-match regex when the list is empty. */ export function compilePatternList(patterns) { if (patterns.length === 0) { return /(?!)/; // never matches } return new RegExp(patterns.join("|"), "i"); } /** * Compute the maximum possible score for a subtype profile. * * @param config - Normalized quality config containing subtype profiles. * @param subtype - Subtype whose metric weights should be summed. * @returns Sum of all metric maxima for the subtype. */ export function profileMaxForSubtype(config, subtype) { return Object.values(config.subtypes[subtype].profile).reduce((sum, value) => sum + value, 0); } //# sourceMappingURL=quality-config.js.map