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.

561 lines 21.5 kB
/** * Loads and validates `.goat-flow/config.yaml`. * Owns defaults, schema-level validation, and the normalized `LoadedConfig` returned to audit and prompt builders. */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { load } from "js-yaml"; import { AUDIT_VERSION } from "../constants.js"; /** Top-level config keys recognized by the validator (others trigger warnings). */ const KNOWN_TOP_LEVEL_KEYS = new Set([ "version", "agents", "skills", "line-limits", "plans", "toolchain", "userRole", "telemetry", "learning-loop", "known-gaps", "skill-overrides", "harness", "hooks", "terminal", "quality", ]); /** Built-in default values used when config.yaml is missing or omits fields. */ const CONFIG_DEFAULTS = { version: AUDIT_VERSION, footguns: { path: ".goat-flow/learning-loop/footguns/" }, lessons: { path: ".goat-flow/learning-loop/lessons/" }, decisions: { path: ".goat-flow/learning-loop/decisions/" }, plans: { path: ".goat-flow/plans/" }, logs: { path: ".goat-flow/logs/" }, agents: null, skills: { install: "all" }, lineLimits: { target: 125, limit: 150 }, toolchain: { test: [], lint: [], build: [], package: [], format: [], }, userRole: "developer", telemetry: false, learningLoop: { autoCapture: { enabled: false, targets: [], }, }, knownGaps: [], skillOverrides: {}, terminal: { idleTimeoutMinutes: 480 }, harness: { acknowledge: [] }, hooks: {}, }; /** Clone the default config object so callers can mutate it safely. */ function cloneDefaults() { return { version: CONFIG_DEFAULTS.version, footguns: { ...CONFIG_DEFAULTS.footguns }, lessons: { ...CONFIG_DEFAULTS.lessons }, decisions: { ...CONFIG_DEFAULTS.decisions }, plans: { ...CONFIG_DEFAULTS.plans }, logs: { ...CONFIG_DEFAULTS.logs }, agents: CONFIG_DEFAULTS.agents, skills: { install: CONFIG_DEFAULTS.skills.install }, lineLimits: { ...CONFIG_DEFAULTS.lineLimits }, toolchain: { test: [...CONFIG_DEFAULTS.toolchain.test], lint: [...CONFIG_DEFAULTS.toolchain.lint], build: [...CONFIG_DEFAULTS.toolchain.build], package: [...CONFIG_DEFAULTS.toolchain.package], format: [...CONFIG_DEFAULTS.toolchain.format], }, userRole: CONFIG_DEFAULTS.userRole, telemetry: CONFIG_DEFAULTS.telemetry, learningLoop: { autoCapture: { enabled: CONFIG_DEFAULTS.learningLoop.autoCapture.enabled, targets: [...CONFIG_DEFAULTS.learningLoop.autoCapture.targets], }, }, knownGaps: [...CONFIG_DEFAULTS.knownGaps], skillOverrides: { ...CONFIG_DEFAULTS.skillOverrides }, terminal: { ...CONFIG_DEFAULTS.terminal }, harness: { acknowledge: [...CONFIG_DEFAULTS.harness.acknowledge] }, hooks: { ...CONFIG_DEFAULTS.hooks }, }; } /** Narrow unknown config values to plain object records before field inspection. */ function isRecord(value) { return (value !== null && typeof value === "object" && Array.isArray(value) === false); } /** Read raw config YAML from the project root or injected test filesystem. */ function readConfigText(projectRoot, fs) { if (fs) return fs.readFile(".goat-flow/config.yaml"); const path = join(projectRoot, ".goat-flow", "config.yaml"); if (!existsSync(path)) return null; return readFileSync(path, "utf8"); } /** Apply a config version override when the raw value is valid. */ function mergeVersion(value, merged) { if (typeof value === "string") { merged.version = value; } } /** Apply the configured skill install policy. */ function mergeSkills(value, merged) { if (!isRecord(value)) return; const { install } = value; if (install === "all" || Array.isArray(install)) { merged.skills.install = install; } const goatReview = value["goat-review"]; if (!isRecord(goatReview)) return; const localPrBase = goatReview.local_pr_base; if (typeof localPrBase === "string" && localPrBase.trim().length > 0) { merged.skills["goat-review"] = { localPrBase: localPrBase.trim() }; } } /** Normalize one raw command list into a filtered string array. */ function normalizeCommandList(value) { if (!Array.isArray(value)) return []; return value.filter((item) => typeof item === "string" && item.trim().length > 0); } /** Apply toolchain command arrays from the raw config. */ function mergeToolchain(value, merged) { if (!isRecord(value)) return; merged.toolchain.test = normalizeCommandList(value.test); merged.toolchain.lint = normalizeCommandList(value.lint); merged.toolchain.build = normalizeCommandList(value.build); merged.toolchain.package = normalizeCommandList(value.package); merged.toolchain.format = normalizeCommandList(value.format); } /** Valid userRole values accepted in the config file. */ const KNOWN_USER_ROLES = new Set(["developer", "investigator", "tester"]); /** Valid durable learning-loop targets accepted for future auto-capture. */ const LEARNING_LOOP_AUTO_CAPTURE_TARGETS = new Set([ "lessons", "footguns", "patterns", "decisions", ]); /** Narrow one raw target string to the supported durable learning-loop kinds. */ function isLearningLoopAutoCaptureTarget(value) { return (typeof value === "string" && LEARNING_LOOP_AUTO_CAPTURE_TARGETS.has(value)); } /** Apply a valid userRole override from the raw config. */ function mergeUserRole(value, merged) { if (typeof value === "string" && KNOWN_USER_ROLES.has(value)) { merged.userRole = value; } } /** Apply future automatic learning-loop capture policy from the raw config. */ function mergeLearningLoop(value, merged) { if (!isRecord(value)) return; const autoCapture = value["auto-capture"]; if (!isRecord(autoCapture)) return; if (typeof autoCapture.enabled === "boolean") { merged.learningLoop.autoCapture.enabled = autoCapture.enabled; } if (Array.isArray(autoCapture.targets)) { merged.learningLoop.autoCapture.targets = autoCapture.targets.filter(isLearningLoopAutoCaptureTarget); } } /** Apply positive line-limit overrides from the raw config. */ function mergeLineLimits(value, merged) { if (!isRecord(value)) return; if (typeof value.target === "number" && value.target > 0) merged.lineLimits.target = value.target; if (typeof value.limit === "number" && value.limit > 0) merged.lineLimits.limit = value.limit; } /** Merge a validated raw config object on top of the built-in defaults. */ function mergeConfig(raw) { const merged = cloneDefaults(); if (!isRecord(raw)) return merged; mergeVersion(raw.version, merged); // Path overrides for footguns/lessons/decisions/plans/logs removed in v1.1.0. // Canonical paths (.goat-flow/*) are always used. // Legacy `agents:` is intentionally ignored. Use `--agent <id>` to scope commands. mergeSkills(raw.skills, merged); // YAML key is `line-limits` (kebab-case), TypeScript field is `lineLimits` (camelCase) mergeLineLimits(raw["line-limits"], merged); mergeToolchain(raw.toolchain, merged); mergeUserRole(raw.userRole, merged); if (typeof raw.telemetry === "boolean") merged.telemetry = raw.telemetry; mergeLearningLoop(raw["learning-loop"], merged); // YAML key is `known-gaps` (kebab-case), TypeScript field is `knownGaps` (camelCase) if (Array.isArray(raw["known-gaps"])) { merged.knownGaps = raw["known-gaps"].filter((item) => typeof item === "string" && item.trim().length > 0); } // YAML key is `skill-overrides` (kebab-case), TypeScript field is `skillOverrides` (camelCase) if (isRecord(raw["skill-overrides"])) { merged.skillOverrides = { ...raw["skill-overrides"], }; } if (isRecord(raw.terminal)) { const timeout = raw.terminal["idle-timeout"]; if (typeof timeout === "number" && Number.isInteger(timeout) && timeout >= 0) { merged.terminal.idleTimeoutMinutes = timeout; } } mergeHarness(raw.harness, merged); mergeHooks(raw.hooks, merged); mergeQuality(raw.quality, merged); return merged; } /** Apply hook toggle state from the raw config. Unknown hook ids are handled by the registry. */ function mergeHooks(value, merged) { if (!isRecord(value)) return; const hooks = {}; for (const [hookId, hookValue] of Object.entries(value)) { if (!isRecord(hookValue)) continue; if (typeof hookValue.enabled !== "boolean") continue; hooks[hookId] = { enabled: hookValue.enabled }; } merged.hooks = hooks; } /** Pass through the raw quality config block; full validation lives in quality-config.ts. */ function mergeQuality(value, merged) { if (!isRecord(value)) return; merged.quality = { ...value }; } /** Apply harness acknowledge list from the raw config. */ function mergeHarness(value, merged) { if (!isRecord(value)) return; if (Array.isArray(value.acknowledge)) { merged.harness.acknowledge = value.acknowledge.filter((item) => typeof item === "string" && item.trim().length > 0); } } /** Append a config validation error with its source path. */ function pushError(errors, path, message) { errors.push({ level: "error", path, message }); } /** Append a config validation warning with its source path. */ function pushWarning(warnings, path, message) { warnings.push({ level: "warning", path, message }); } /** Warn when the config contains top-level keys that are not understood. */ function validateUnknownTopLevelKeys(raw, warnings) { for (const key of Object.keys(raw)) { if (!KNOWN_TOP_LEVEL_KEYS.has(key)) { pushWarning(warnings, key, "unknown top-level key"); } } } /** Validate that an optional top-level field is an object before inspecting nested keys. */ function validateObjectField(raw, key, errors, onValid) { if (!(key in raw)) return; const value = raw[key]; if (!isRecord(value)) { pushError(errors, key, "must be an object"); return; } onValid(value); } /** Require a positive numeric value for a numeric config field. */ function validatePositiveNumber(value, path, errors) { if (typeof value !== "number" || value <= 0) { pushError(errors, path, "must be a positive number"); } } /** Require a string array for command-list config fields. */ function validateStringArray(value, path, errors) { if (!Array.isArray(value)) { pushError(errors, path, "must be an array"); return; } for (const [index, item] of value.entries()) { if (typeof item !== "string" || item.trim().length === 0) { pushError(errors, `${path}[${index}]`, "must be a non-empty string"); } } } /** Validate version field. */ function validateVersionField(raw, _warnings, errors) { if ("version" in raw && typeof raw.version !== "string") { pushError(errors, "version", "must be a string"); } } /** Warn when the removed legacy agent allowlist appears in config. */ function validateLegacyAgentsField(raw, warnings, _errors) { if (raw.agents != null) { pushWarning(warnings, "agents", "ignored; use --agent <id> to scope commands"); } } /** Validate line-limit overrides and ensure target stays below limit. */ function validateLineLimitsField(raw, _warnings, errors) { validateObjectField(raw, "line-limits", errors, (value) => { if ("target" in value) validatePositiveNumber(value.target, "line-limits.target", errors); if ("limit" in value) validatePositiveNumber(value.limit, "line-limits.limit", errors); if (typeof value.target === "number" && typeof value.limit === "number" && value.target >= value.limit) { pushError(errors, "line-limits", "target must be less than limit"); } }); } /** Validate the toolchain command arrays. */ function validateToolchainField(raw, _warnings, errors) { validateObjectField(raw, "toolchain", errors, (value) => { if ("test" in value) validateStringArray(value.test, "toolchain.test", errors); if ("lint" in value) validateStringArray(value.lint, "toolchain.lint", errors); if ("build" in value) validateStringArray(value.build, "toolchain.build", errors); if ("package" in value) validateStringArray(value.package, "toolchain.package", errors); if ("format" in value) validateStringArray(value.format, "toolchain.format", errors); }); } /** Validate an explicit `skills.install` allowlist. */ function validateSkillInstallList(install, errors) { if (install.length === 0) { pushError(errors, "skills.install", "cannot be empty"); } for (const [index, value] of install.entries()) { if (typeof value !== "string") { pushError(errors, `skills.install[${index}]`, "must be a string"); } } } /** Validate the userRole field when present. */ function validateUserRoleField(raw, _warnings, errors) { if (!("userRole" in raw)) return; const { userRole } = raw; if (typeof userRole !== "string" || !KNOWN_USER_ROLES.has(userRole)) { pushError(errors, "userRole", `must be one of: ${Array.from(KNOWN_USER_ROLES).join(", ")}`); } } /** Validate the skills installation policy block. */ function validateSkillsField(raw, _warnings, errors) { validateObjectField(raw, "skills", errors, (value) => { if ("install" in value) { const { install } = value; if (install !== "all" && !Array.isArray(install)) { pushError(errors, "skills.install", 'must be "all" or an array'); } else if (Array.isArray(install)) { validateSkillInstallList(install, errors); } } if ("goat-review" in value) { const goatReview = value["goat-review"]; if (!isRecord(goatReview)) { pushError(errors, "skills.goat-review", "must be an object"); return; } if ("local_pr_base" in goatReview) { const localPrBase = goatReview.local_pr_base; if (typeof localPrBase !== "string" || localPrBase.trim().length === 0) { pushError(errors, "skills.goat-review.local_pr_base", "must be a non-empty string"); } } } }); } /** Validate the harness acknowledge list when present. */ function validateHarnessField(raw, _warnings, errors) { validateObjectField(raw, "harness", errors, (value) => { if (!("acknowledge" in value)) return; validateStringArray(value.acknowledge, "harness.acknowledge", errors); }); } /** Validate the hook toggle block when present. */ function validateHooksField(raw, _warnings, errors) { validateObjectField(raw, "hooks", errors, (value) => { for (const [hookId, hookValue] of Object.entries(value)) { if (!/^[a-z0-9][a-z0-9-]*$/u.test(hookId)) { pushError(errors, `hooks.${hookId}`, "hook id must be kebab-case"); continue; } if (!isRecord(hookValue)) { pushError(errors, `hooks.${hookId}`, "must be an object"); continue; } if (typeof hookValue.enabled !== "boolean") { pushError(errors, `hooks.${hookId}.enabled`, "must be a boolean"); } } }); } /** Validate the telemetry field when present. */ function validateTelemetryField(raw, _warnings, errors) { if (!("telemetry" in raw)) return; if (typeof raw.telemetry !== "boolean") { pushError(errors, "telemetry", "must be a boolean"); } } /** Validate future automatic learning-loop capture policy when present. */ function validateLearningLoopField(raw, _warnings, errors) { validateObjectField(raw, "learning-loop", errors, (value) => { if (!("auto-capture" in value)) return; const autoCapture = value["auto-capture"]; if (!isRecord(autoCapture)) { pushError(errors, "learning-loop.auto-capture", "must be an object"); return; } if ("enabled" in autoCapture && typeof autoCapture.enabled !== "boolean") { pushError(errors, "learning-loop.auto-capture.enabled", "must be a boolean"); } if (!("targets" in autoCapture)) return; if (!Array.isArray(autoCapture.targets)) { pushError(errors, "learning-loop.auto-capture.targets", "must be an array"); return; } for (const [index, target] of autoCapture.targets.entries()) { if (!isLearningLoopAutoCaptureTarget(target)) { pushError(errors, `learning-loop.auto-capture.targets[${index}]`, `must be one of: ${Array.from(LEARNING_LOOP_AUTO_CAPTURE_TARGETS).join(", ")}`); } } }); } /** Validate the known-gaps field when present. */ function validateKnownGapsField(raw, _warnings, errors) { if (!("known-gaps" in raw)) return; validateStringArray(raw["known-gaps"], "known-gaps", errors); } /** Validate the skill-overrides field when present. */ function validateSkillOverridesField(raw, _warnings, errors) { if (!("skill-overrides" in raw)) return; if (!isRecord(raw["skill-overrides"])) { pushError(errors, "skill-overrides", "must be an object"); } } /** Validate the terminal config block when present. */ function validateTerminalField(raw, _warnings, errors) { validateObjectField(raw, "terminal", errors, (value) => { if (!("idle-timeout" in value)) return; const timeout = value["idle-timeout"]; if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 0) { pushError(errors, "terminal.idle-timeout", "must be a non-negative integer"); } }); } /** Ordered list of field-level validators applied during config validation. */ const CONFIG_VALIDATORS = [ validateVersionField, validateLegacyAgentsField, validateLineLimitsField, validateSkillsField, validateToolchainField, validateUserRoleField, validateTelemetryField, validateLearningLoopField, validateKnownGapsField, validateSkillOverridesField, validateHarnessField, validateHooksField, validateTerminalField, ]; /** Validate a parsed config object and return structured warnings and errors. */ function validateConfig(raw) { const warnings = []; const errors = []; if (!isRecord(raw)) { pushError(errors, "config", "must be a YAML object"); return { valid: false, warnings, errors }; } validateUnknownTopLevelKeys(raw, warnings); for (const validator of CONFIG_VALIDATORS) { validator(raw, warnings, errors); } return { valid: errors.length === 0, warnings, errors }; } /** * Load, parse, validate, and normalize `.goat-flow/config.yaml`; malformed YAML never throws and * instead returns a structured invalid config. * * @param projectRoot - repository root whose `.goat-flow/config.yaml` should be loaded * @param fs - optional filesystem adapter for tests and audit facts * @returns parsed config state, including defaults when the file is absent or invalid */ export function loadConfig(projectRoot, fs) { const content = readConfigText(projectRoot, fs); if (content === null) { return { exists: false, valid: true, config: cloneDefaults(), warnings: [], errors: [], parseError: null, }; } let parsed; try { parsed = load(content) ?? {}; } catch (error) { return { exists: true, valid: false, config: cloneDefaults(), warnings: [], errors: [ { level: "error", path: ".goat-flow/config.yaml", message: error instanceof Error ? error.message : String(error), }, ], parseError: error instanceof Error ? error.message : String(error), }; } const validation = validateConfig(parsed); // Fail closed: if validation failed, downstream consumers must NOT see the // partially-merged malformed config. Return defaults instead so consumers // see a known-safe shape. The errors array still carries the specific paths // that failed so callers can surface them to the user. return { exists: true, valid: validation.valid, config: validation.valid ? mergeConfig(parsed) : cloneDefaults(), warnings: validation.warnings, errors: validation.errors, parseError: null, }; } //# sourceMappingURL=reader.js.map