@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.
206 lines • 7.93 kB
JavaScript
/**
* Minimal `.goat-flow/config.yaml` writer for hook toggle state.
*
* The writer only replaces targeted top-level blocks so comments and ordering
* in the rest of the config file survive normal dashboard toggles.
*/
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { dump, load } from "js-yaml";
import { writeFileAtomic } from "../server/safe-exec.js";
const HOOK_ID_ALIASES = new Map([
["gruff-on-change", "gruff-code-quality"],
["guard-destructive-shell", "deny-dangerous"],
["guard-secret-paths", "deny-dangerous"],
["guard-repository-writes", "deny-dangerous"],
]);
const HOOK_BLOCK_COMMENT_LINES = new Set([
"# Togglable goat-flow hook state. Missing entries use registry defaults.",
"# Manage with the dashboard Hooks page or `goat-flow hooks <enable|disable|sync>`.",
]);
const REMOVED_TOP_LEVEL_BLOCK_COMMENTS = new Map([
[
"plan-guard",
new Set(["# Workflow reminder settings for the plan checkbox guard."]),
],
]);
/** Narrow parsed YAML values before reading the hooks block. */
function isRecord(value) {
return (value !== null &&
typeof value === "object" &&
Array.isArray(value) === false);
}
/** Resolve the project-local goat-flow config path used by dashboard hook toggles. */
function configPath(projectPath) {
return join(projectPath, ".goat-flow", "config.yaml");
}
/** Read existing config text or synthesize the minimal config needed before the first toggle write. */
function readConfigText(projectPath) {
const path = configPath(projectPath);
if (!existsSync(path)) {
return [
"# .goat-flow/config.yaml - project configuration",
'version: "1.8.0"',
"",
].join("\n");
}
return readFileSync(path, "utf-8");
}
/** Map legacy hook ids to canonical ids so old config entries keep their state. */
function normalizeHookIdentifier(hookIdentifier) {
return HOOK_ID_ALIASES.get(hookIdentifier) ?? hookIdentifier;
}
/** Parse explicitly configured hook states; malformed YAML uses an empty-map fallback. */
function readRawHooks(text) {
let parsed;
try {
parsed = load(text) ?? {};
}
catch {
return {};
}
if (!isRecord(parsed) || !isRecord(parsed.hooks))
return {};
const hooks = {};
for (const [hookId, value] of Object.entries(parsed.hooks)) {
if (!isRecord(value) || typeof value.enabled !== "boolean")
continue;
const normalizedHookIdentifier = normalizeHookIdentifier(hookId);
if (normalizedHookIdentifier !== hookId &&
Object.prototype.hasOwnProperty.call(hooks, normalizedHookIdentifier)) {
continue;
}
hooks[normalizedHookIdentifier] = { enabled: value.enabled };
}
return hooks;
}
/** Render the managed hooks block with stable ordering and the operator-facing ownership comment. */
function renderHooksBlock(hooks) {
const ordered = Object.fromEntries(Object.entries(hooks).sort(([a], [b]) => a.localeCompare(b)));
const dumped = dump({ hooks: ordered }, { lineWidth: 100 }).trimEnd();
return [
"# Togglable goat-flow hook state. Missing entries use registry defaults.",
"# Manage with the dashboard Hooks page or `goat-flow hooks <enable|disable|sync>`.",
dumped,
].join("\n");
}
/** Detect top-level YAML keys so hook-block replacement preserves following config sections. */
function isTopLevelLine(line) {
return /^[A-Za-z0-9_-]+:/u.test(line);
}
/** Replace only the managed top-level hooks block, preserving all unrelated config text. */
function replaceTopLevelHooksBlock(text, block) {
const lines = text.replace(/\s*$/u, "\n").split("\n");
const start = lines.findIndex((line) => /^hooks:\s*(?:#.*)?$/u.test(line));
if (start === -1)
return `${lines.join("\n").trimEnd()}\n\n${block}\n`;
let prefixEnd = start;
while (prefixEnd > 0 &&
HOOK_BLOCK_COMMENT_LINES.has(lines[prefixEnd - 1] ?? "")) {
prefixEnd -= 1;
}
let end = start + 1;
while (end < lines.length) {
const line = lines[end] ?? "";
if (line.trim() !== "" && isTopLevelLine(line))
break;
end += 1;
}
return [...lines.slice(0, prefixEnd), block, ...lines.slice(end)]
.join("\n")
.replace(/\n{3,}/gu, "\n\n")
.trimEnd()
.concat("\n");
}
function topLevelBlockRange(lines, key) {
if (!/^[A-Za-z0-9_-]+$/u.test(key))
return null;
const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*(?:#.*)?$`, "u").test(line));
if (start === -1)
return null;
let end = start + 1;
while (end < lines.length) {
const line = lines[end] ?? "";
if (line.trim() !== "" && isTopLevelLine(line))
break;
end += 1;
}
return { start, end };
}
function removablePrefixStart(lines, start, key) {
const comments = REMOVED_TOP_LEVEL_BLOCK_COMMENTS.get(key);
if (!comments)
return start;
let prefixStart = start;
while (prefixStart > 0 && comments.has(lines[prefixStart - 1] ?? "")) {
prefixStart -= 1;
}
if (prefixStart > 0 && (lines[prefixStart - 1] ?? "").trim() === "") {
prefixStart -= 1;
}
return prefixStart;
}
function removeTopLevelBlockFromText(text, key) {
const lines = text.replace(/\s*$/u, "\n").split("\n");
const range = topLevelBlockRange(lines, key);
if (!range)
return text;
const prefixStart = removablePrefixStart(lines, range.start, key);
return [...lines.slice(0, prefixStart), ...lines.slice(range.end)]
.join("\n")
.replace(/\n{3,}/gu, "\n\n")
.trimEnd()
.concat("\n");
}
/** Return the explicitly configured hook state, excluding registry defaults. */
function readHookConfig(projectPath) {
return readRawHooks(readConfigText(projectPath));
}
/**
* Return one hook's desired enabled state using the registry default on absence.
*
* @param projectPath - project whose goat-flow config stores hook overrides
* @param hookId - canonical hook id to read
* @param defaultEnabled - registry default to use when config omits the hook
* @returns configured enabled state, or the registry default when absent
*/
export function readHookEnabled(projectPath, hookId, defaultEnabled) {
return readHookConfig(projectPath)[hookId]?.enabled ?? defaultEnabled;
}
/**
* Set one hook's desired enabled state in `.goat-flow/config.yaml`.
*
* @param projectPath - project whose goat-flow config should be written
* @param hookId - canonical hook id to update
* @param enabled - desired enabled state to persist
*/
export function setHookEnabled(projectPath, hookId, enabled) {
const path = configPath(projectPath);
const text = readConfigText(projectPath);
const hooks = readRawHooks(text);
hooks[hookId] = { enabled };
mkdirSync(dirname(path), { recursive: true });
writeFileAtomic(path, replaceTopLevelHooksBlock(text, renderHooksBlock(hooks)), projectPath);
}
export function removeHookConfig(projectPath, hookId) {
const path = configPath(projectPath);
if (!existsSync(path))
return;
const text = readConfigText(projectPath);
const hooks = readRawHooks(text);
if (!Object.prototype.hasOwnProperty.call(hooks, hookId))
return;
Reflect.deleteProperty(hooks, hookId);
writeFileAtomic(path, replaceTopLevelHooksBlock(text, renderHooksBlock(hooks)), projectPath);
}
export function removeTopLevelConfigBlock(projectPath, key) {
const path = configPath(projectPath);
if (!existsSync(path))
return;
const text = readConfigText(projectPath);
const next = removeTopLevelBlockFromText(text, key);
if (next === text)
return;
writeFileAtomic(path, next, projectPath);
}
//# sourceMappingURL=writer.js.map