@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
JavaScript
/**
* 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