@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.
254 lines • 10.6 kB
JavaScript
import { pushUniquePath } from "./routing.js";
/** Normalize hook command arguments into a repo-relative shell-script path. */
function normalizeHookPath(candidate) {
if (!candidate)
return null;
let path = candidate.trim();
if (!path)
return null;
path = path.replace(/^['"`]|['"`]$/g, "");
// Hook launchers prefix the script path with a resolved repo root, either as
// an inline substitution ($(git rev-parse --show-toplevel)/...sh) or a shell
// variable ($root/...sh, $REPO/...sh) populated earlier in the command. Strip
// either prefix so callers see a repo-relative script path.
const substitutionMatch = path.match(/\$(?:\([^)]*\)|\{?\w+\}?)\/(.*\.sh)$/);
if (substitutionMatch && substitutionMatch[1]) {
path = substitutionMatch[1];
}
if (!path.endsWith(".sh"))
return null;
return path;
}
/** Extract normalized shell-script paths from one hook command string. */
function extractHookPathsFromCommand(command) {
const pathCandidates = [];
const quotedMatches = command.matchAll(/["']([^"']+\.sh)["']/g);
for (const match of quotedMatches) {
const path = match[1];
if (path === undefined)
continue;
const normalized = normalizeHookPath(path);
if (normalized)
pushUniquePath(pathCandidates, normalized);
}
const unquotedMatches = command.matchAll(/([^\s"'`]+\.sh)/g);
for (const match of unquotedMatches) {
const path = match[1];
if (path === undefined)
continue;
const normalized = normalizeHookPath(path);
if (normalized)
pushUniquePath(pathCandidates, normalized);
}
return pathCandidates;
}
/** Return the preferred shell-script path referenced by a list of hook commands. */
function preferredHookPathFromCommands(commands) {
const paths = [];
for (const command of commands) {
const candidates = extractHookPathsFromCommand(command);
for (const candidate of candidates)
pushUniquePath(paths, candidate);
}
const preferred = paths.find((path) => path.endsWith("/post-turn-safety.sh")) ??
paths.find((path) => path.endsWith("/deny-dangerous.sh")) ??
paths.find((path) => path.endsWith("/guard-repository-writes.sh")) ??
paths.find((path) => !path.endsWith("/plan-checkbox-guard.sh")) ??
null;
return preferred;
}
/** Return the parsed `hooks` object from settings when it exists. */
function readHooksObject(settingsParsed) {
if (!settingsParsed || typeof settingsParsed !== "object")
return null;
const hooks = settingsParsed.hooks;
if (!hooks || typeof hooks !== "object")
return null;
return hooks;
}
/** Return one Antigravity top-level hook definition by goat-flow hook id. */
function readAntigravityHookDefinition(hookConfigParsed, hookId) {
if (!hookConfigParsed || typeof hookConfigParsed !== "object")
return null;
const definition = hookConfigParsed[hookId];
if (!definition || typeof definition !== "object")
return null;
return definition;
}
/** Return the first Antigravity top-level hook definition registered for the post-turn event. */
function readAntigravityPostTurnRegistration(agent, hookConfigParsed) {
if (agent.id !== "antigravity" || !agent.hookEvents?.postTurn)
return null;
if (!hookConfigParsed || typeof hookConfigParsed !== "object")
return null;
const commands = [];
for (const definition of Object.values(hookConfigParsed)) {
if (!definition || typeof definition !== "object")
continue;
const hookDefinition = definition;
if (hookDefinition.enabled === false)
continue;
commands.push(...extractCommandsFromEventConfig(hookDefinition, agent.hookEvents.postTurn));
}
const path = preferredHookPathFromCommands(commands);
return { isRegistered: path !== null, path };
}
/** Extract the shell command from one hook entry when it uses command mode. */
function hasSupportedHookType(hookObj) {
return (hookObj.type === undefined ||
hookObj.type === "command" ||
hookObj.type === "Command");
}
/** Read one shell command field from a normalized hook object. */
function readHookCommand(hookObj) {
if (typeof hookObj.bash === "string")
return hookObj.bash;
if (typeof hookObj.command === "string")
return hookObj.command;
const nestedCommand = hookObj.command;
if (!nestedCommand || typeof nestedCommand !== "object")
return null;
return typeof nestedCommand.bash === "string"
? nestedCommand.bash
: null;
}
/** Extract the shell command from supported hook payload shapes. */
function extractCommandFromHook(hook) {
if (!hook || typeof hook !== "object")
return null;
const hookObj = hook;
if (!hasSupportedHookType(hookObj))
return null;
return readHookCommand(hookObj);
}
/** Extract all shell commands declared inside one event registration entry. */
function extractCommandsFromEventEntry(entry) {
if (!entry || typeof entry !== "object")
return [];
const directCommand = extractCommandFromHook(entry);
if (directCommand !== null)
return [directCommand];
const eventHooks = entry.hooks;
if (!Array.isArray(eventHooks))
return [];
const commands = [];
for (const hook of eventHooks) {
const command = extractCommandFromHook(hook);
if (command !== null)
commands.push(command);
}
return commands;
}
/** Extract all shell commands from one normalized event config. */
function extractCommandsFromEventConfig(hooks, event) {
if (!event)
return [];
const rawEvent = hooks[event];
if (!Array.isArray(rawEvent))
return [];
const commands = [];
for (const entry of rawEvent) {
commands.push(...extractCommandsFromEventEntry(entry));
}
return commands;
}
/** Normalize one event's hook registration into a simple registered/path pair. */
function normalizeEventConfig(hooks, event) {
const commands = extractCommandsFromEventConfig(hooks, event);
const path = preferredHookPathFromCommands(commands);
return { isRegistered: path !== null, path };
}
/**
* Resolve the parsed hook config for one agent, reusing the already-parsed
* settings file when the agent stores hooks there and only reading a separate
* file otherwise - this avoids parsing the same file twice.
*
* @param fs - read-only filesystem adapter used only when hooks live in a separate file
* @param agent - agent profile naming its settings and hook-config files
* @param settingsParsed - already-parsed settings content, reused when it doubles as the hook config
* @param settingsValid - whether that pre-parsed settings content parsed successfully
* @returns the parsed hook config and its validity; both default to null/false when the agent declares no hook file
*/
export function readHookConfig(fs, agent, settingsParsed, settingsValid) {
if (!agent.hookConfigFile) {
return { parsed: null, valid: false };
}
if (agent.hookConfigFile === agent.settingsFile) {
return { parsed: settingsParsed, valid: settingsValid };
}
const parsed = fs.readJson(agent.hookConfigFile);
return { parsed, valid: parsed !== null };
}
/**
* Report whether the agent's post-turn (learning-loop) hook is registered and
* which script it points at. Returns the not-registered shape for agents that
* declare no hook events or whose config has no usable `hooks` object.
*
* @param agent - agent profile naming its post-turn hook event, if any
* @param hookConfigParsed - parsed hook config from readHookConfig, or null/invalid content
* @returns post-turn registration flag and resolved script path; path is null when not registered
*/
export function buildHookRegistration(agent, hookConfigParsed) {
const antigravityPostTurn = readAntigravityPostTurnRegistration(agent, hookConfigParsed);
if (antigravityPostTurn !== null) {
return {
postTurnRegistered: antigravityPostTurn.isRegistered,
postTurnRegisteredPath: antigravityPostTurn.path,
};
}
const hooks = readHooksObject(hookConfigParsed);
if (!hooks) {
return {
postTurnRegistered: false,
postTurnRegisteredPath: null,
};
}
if (!agent.hookEvents) {
return { postTurnRegistered: false, postTurnRegisteredPath: null };
}
const postTurn = normalizeEventConfig(hooks, agent.hookEvents.postTurn);
return {
postTurnRegistered: postTurn.isRegistered,
postTurnRegisteredPath: postTurn.path,
};
}
/**
* Report whether the dangerous-command deny guard is registered as a pre-tool
* hook, and its script path. Antigravity is handled separately because its deny
* hook is a top-level keyed definition with its own `enabled` flag, so an
* explicit `enabled: false` there counts as not registered.
*
* @param agent - agent profile naming its pre-tool hook event and identifying Antigravity
* @param hookConfigParsed - parsed hook config from readHookConfig, or null/invalid content
* @returns deny registration flag and resolved script path; path is null when not registered or disabled
*/
export function buildDenyRegistration(agent, hookConfigParsed) {
if (agent.id === "antigravity") {
if (!agent.hookEvents) {
return { denyIsRegistered: false, denyRegisteredPath: null };
}
const denyDefinition = readAntigravityHookDefinition(hookConfigParsed, "deny-dangerous") ??
readAntigravityHookDefinition(hookConfigParsed, "guard-repository-writes");
if (!denyDefinition || denyDefinition.enabled === false) {
return { denyIsRegistered: false, denyRegisteredPath: null };
}
const preTool = normalizeEventConfig(denyDefinition, agent.hookEvents.preTool);
return {
denyIsRegistered: preTool.isRegistered,
denyRegisteredPath: preTool.path,
};
}
const hooks = readHooksObject(hookConfigParsed);
if (!hooks) {
return { denyIsRegistered: false, denyRegisteredPath: null };
}
if (!agent.hookEvents) {
return { denyIsRegistered: false, denyRegisteredPath: null };
}
const preTool = normalizeEventConfig(hooks, agent.hookEvents.preTool);
return {
denyIsRegistered: preTool.isRegistered,
denyRegisteredPath: preTool.path,
};
}
//# sourceMappingURL=hook-registration.js.map