@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.
623 lines • 25.6 kB
JavaScript
/**
* Template-vs-installed drift detection for goat-flow skills.
*
* Compares the canonical templates shipped in goat-flow against the
* installed copies inside a consumer project (or the goat-flow repo
* itself when run on its own root):
*
* - Per-skill SKILL.md for every name in SKILL_NAMES:
* workflow/skills/<name>/SKILL.md vs
* .claude/skills/<name>/SKILL.md
* .agents/skills/<name>/SKILL.md
* - Shared meta references (template → installed in .goat-flow/skill-docs/):
* workflow/skills/reference/README.md vs .goat-flow/skill-docs/README.md
* workflow/skills/reference/skill-preamble.md vs .goat-flow/skill-docs/skill-preamble.md
* workflow/skills/reference/skill-conventions.md vs .goat-flow/skill-docs/skill-conventions.md
* - Standalone playbooks (template → installed in .goat-flow/skill-docs/playbooks/):
* workflow/skills/playbooks/README.md vs .goat-flow/skill-docs/playbooks/README.md
* workflow/skills/playbooks/browser-use.md vs .goat-flow/skill-docs/playbooks/browser-use.md
* workflow/skills/playbooks/code-comments.md vs .goat-flow/skill-docs/playbooks/code-comments.md
* workflow/skills/playbooks/gruff-code-quality.md vs .goat-flow/skill-docs/playbooks/gruff-code-quality.md
* workflow/skills/playbooks/observability.md vs .goat-flow/skill-docs/playbooks/observability.md
* workflow/skills/playbooks/changelog.md vs .goat-flow/skill-docs/playbooks/changelog.md
* workflow/skills/playbooks/page-capture.md vs .goat-flow/skill-docs/playbooks/page-capture.md
* workflow/skills/playbooks/release-notes.md vs .goat-flow/skill-docs/playbooks/release-notes.md
* workflow/skills/playbooks/skill-quality-testing.md vs .goat-flow/skill-docs/skill-quality-testing/README.md
* - Orphan directories under .claude/skills or .agents/skills whose
* name is not in SKILL_NAMES. Names that appear in manifest.stale_names
* are reported as deprecated instead of a plain orphan.
*
* Comparison is semantic: YAML frontmatter is parsed and compared
* structurally (after stripping null/undefined leaves to avoid false
* negatives on bare keys like `description:`), body content is
* compared after trimEnd() + single trailing newline normalization.
* This avoids false positives on key reorder or trailing whitespace.
*/
import { readFileSync, existsSync } from "node:fs";
import { posix as pathPosix, resolve as resolvePath } from "node:path";
import { load } from "js-yaml";
import { isDeepStrictEqual } from "node:util";
import { SKILL_NAMES } from "../constants.js";
import { getTemplatePath } from "../paths.js";
import { getInstalledSkillRoots, getSkillFiles, loadManifest, } from "../manifest/manifest.js";
import { listHookSpecs } from "../server/hooks-registry.js";
const KNOWN_AGENT_IDS = new Set(["claude", "codex", "antigravity", "copilot"]);
/** Remove nullish values from nested data before comparing manifests. */
function stripNullish(frontmatterValue) {
if (frontmatterValue === null || frontmatterValue === undefined) {
return undefined;
}
if (Array.isArray(frontmatterValue)) {
return frontmatterValue.map(stripNullish).filter((v) => v !== undefined);
}
if (typeof frontmatterValue === "object") {
const out = {};
for (const [k, v] of Object.entries(frontmatterValue)) {
const cleaned = stripNullish(v);
if (cleaned !== undefined)
out[k] = cleaned;
}
return out;
}
return frontmatterValue;
}
/**
* Parse YAML frontmatter and body text from a markdown file.
*
* The parser swallows malformed YAML into a sentinel object and never throws so
* drift checks can report content mismatch without aborting the whole audit.
*
* @param raw - Full markdown file contents, including optional YAML frontmatter.
* @returns Parsed frontmatter plus body text after the closing marker.
*/
export function parseMarkdownFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match)
return { frontmatter: {}, body: raw };
const rawFrontmatter = match[1] ?? "";
const body = match[2] ?? "";
let parsedRaw;
try {
parsedRaw = load(rawFrontmatter) ?? {};
}
catch {
return { frontmatter: { __parseError: rawFrontmatter }, body };
}
const cleaned = stripNullish(parsedRaw);
return { frontmatter: cleaned ?? {}, body };
}
/** Normalize markdown body text before drift comparisons. */
function normalizeBody(body) {
return body.replace(/^\n+/, "").trimEnd() + "\n";
}
/**
* Compare skill markdown using goat-flow's drift semantics.
*
* Installed skill copies can reorder YAML keys or trim trailing whitespace
* during setup; those edits are not functional drift, but body or frontmatter
* value changes still are.
*
* @param expected - Template markdown content from `workflow/skills`.
* @param existing - Installed markdown content from an agent or skill-docs tree.
* @returns True when normalized frontmatter and body content match.
*/
export function skillContentsEquivalent(expected, existing) {
const expectedMarkdown = parseMarkdownFrontmatter(expected);
const existingMarkdown = parseMarkdownFrontmatter(existing);
if (!isDeepStrictEqual(expectedMarkdown.frontmatter, existingMarkdown.frontmatter)) {
return false;
}
return (normalizeBody(expectedMarkdown.body) ===
normalizeBody(existingMarkdown.body));
}
const SHARED_FILES = [
// Meta references (composed into every skill)
{
template: "workflow/skills/reference/README.md",
installed: ".goat-flow/skill-docs/README.md",
},
{
template: "workflow/skills/reference/skill-preamble.md",
installed: ".goat-flow/skill-docs/skill-preamble.md",
},
{
template: "workflow/skills/reference/skill-conventions.md",
installed: ".goat-flow/skill-docs/skill-conventions.md",
},
// Standalone playbooks (loaded on-demand)
{
template: "workflow/skills/playbooks/README.md",
installed: ".goat-flow/skill-docs/playbooks/README.md",
},
{
template: "workflow/skills/playbooks/browser-use.md",
installed: ".goat-flow/skill-docs/playbooks/browser-use.md",
},
{
template: "workflow/skills/playbooks/code-comments.md",
installed: ".goat-flow/skill-docs/playbooks/code-comments.md",
},
{
template: "workflow/skills/playbooks/gruff-code-quality.md",
installed: ".goat-flow/skill-docs/playbooks/gruff-code-quality.md",
},
{
template: "workflow/skills/playbooks/observability.md",
installed: ".goat-flow/skill-docs/playbooks/observability.md",
},
{
template: "workflow/skills/playbooks/changelog.md",
installed: ".goat-flow/skill-docs/playbooks/changelog.md",
},
{
template: "workflow/skills/playbooks/page-capture.md",
installed: ".goat-flow/skill-docs/playbooks/page-capture.md",
},
{
template: "workflow/skills/playbooks/release-notes.md",
installed: ".goat-flow/skill-docs/playbooks/release-notes.md",
},
{
template: "workflow/skills/playbooks/skill-quality-testing.md",
installed: ".goat-flow/skill-docs/skill-quality-testing/README.md",
},
{
template: "workflow/skills/playbooks/skill-quality-testing/tdd-iteration.md",
installed: ".goat-flow/skill-docs/skill-quality-testing/tdd-iteration.md",
},
{
template: "workflow/skills/playbooks/skill-quality-testing/adversarial-framing.md",
installed: ".goat-flow/skill-docs/skill-quality-testing/adversarial-framing.md",
},
{
template: "workflow/skills/playbooks/skill-quality-testing/deployment.md",
installed: ".goat-flow/skill-docs/skill-quality-testing/deployment.md",
},
];
/**
* Read a workflow template file relative to the package root.
*
* Missing or unreadable templates return null; this swallows file-read failures
* so callers can report the exact drift finding path instead of turning one
* filesystem failure into an exception that hides the rest of the audit.
*/
function readTemplate(templateRoot, relative) {
const abs = resolvePath(templateRoot, relative);
if (!existsSync(abs))
return null;
try {
return readFileSync(abs, "utf-8");
}
catch {
return null;
}
}
/** Narrow parsed YAML/JSON values before reading hook and manifest properties. */
function isRecord(value) {
return (value !== null &&
typeof value === "object" &&
Array.isArray(value) === false);
}
/** Keep dynamic manifest keys inside the known agent-id union for hook-specific logic. */
function isAgentId(value) {
return KNOWN_AGENT_IDS.has(value);
}
/** Read the configured list of deprecated skill names from the validated manifest. */
function getStaleSkillNames() {
return new Set(loadManifest().facts.skills.stale_names);
}
/** Compare installed skills against their workflow templates for drift.
*
* The manifest declares every supported agent's `skills_dir`, but a given
* consumer project may only have installed one agent (e.g. only `.claude/`).
* Iterating over absent agent roots reports phantom drift ("file missing")
* for every uninstalled tree. Filter to roots present on disk so single-
* agent installs report honest results. */
function compareSkills(fs, templateRoot, findings) {
let checked = 0;
const skillRoots = getInstalledSkillRoots().filter((dir) => fs.exists(dir));
for (const name of SKILL_NAMES) {
for (const relativeFile of getSkillFiles(name)) {
const templateRel = `workflow/skills/${name}/${relativeFile}`;
const template = readTemplate(templateRoot, templateRel);
if (template === null) {
findings.push({
kind: "missing",
path: templateRel,
message: `${name}: manifest declares ${templateRel} but the workflow template is missing`,
});
continue;
}
for (const agentDir of skillRoots) {
const installedRel = `${agentDir}/${name}/${relativeFile}`;
checked++;
if (!fs.exists(installedRel)) {
findings.push({
kind: "missing",
path: installedRel,
message: `${name}: template at ${templateRel} has no installed copy at ${installedRel}`,
});
continue;
}
const installed = fs.readFile(installedRel);
if (installed === null)
continue;
if (!skillContentsEquivalent(template, installed)) {
findings.push({
kind: "content",
path: installedRel,
message: `${name}: template (${templateRel}) and installed copy (${installedRel}) differ`,
});
}
}
}
}
return checked;
}
/** Compare shared setup files against their workflow templates for drift. */
function compareSharedFiles(fs, templateRoot, findings) {
let checked = 0;
for (const spec of SHARED_FILES) {
const template = readTemplate(templateRoot, spec.template);
if (template === null) {
findings.push({
kind: "missing",
path: spec.template,
message: `shared template missing: ${spec.template}`,
});
continue;
}
checked++;
if (!fs.exists(spec.installed)) {
findings.push({
kind: "missing",
path: spec.installed,
message: `${spec.template} has no installed copy at ${spec.installed}`,
});
continue;
}
const installed = fs.readFile(spec.installed);
if (installed === null)
continue;
if (!skillContentsEquivalent(template, installed)) {
findings.push({
kind: "content",
path: spec.installed,
message: `${spec.template} and ${spec.installed} differ`,
});
}
}
return checked;
}
/**
* Find installed skill directories that are no longer canonical.
*
* This branch-heavy scan exists because agent skill roots can contain editor
* files, docs, or partially-created directories. The SKILL.md guard avoids
* false positives. The function reports deprecated manifest names separately
* from unexpected orphans so cleanup messaging stays actionable.
*/
function findOrphans(fs, findings) {
const canonical = new Set(SKILL_NAMES);
const stale = getStaleSkillNames();
for (const agentDir of getInstalledSkillRoots()) {
if (!fs.exists(agentDir))
continue;
for (const entry of fs.listDir(agentDir)) {
if (canonical.has(entry))
continue;
const fullPath = `${agentDir}/${entry}`;
// Only flag real skill directories. listDir returns files too
// (.DS_Store, README.md, etc.); a skill is identified by SKILL.md.
if (!fs.exists(`${fullPath}/SKILL.md`))
continue;
if (stale.has(entry)) {
findings.push({
kind: "deprecated",
path: fullPath,
message: `deprecated skill still installed: ${entry} at ${fullPath}`,
});
}
else {
findings.push({
kind: "orphan",
path: fullPath,
message: `orphan directory in ${agentDir}: ${entry} (not a canonical goat-flow skill)`,
});
}
}
}
}
/** Compare installed hook scripts against their workflow templates. */
function hookTemplateRel(agentId, agent, hookFile) {
const hookConfigName = agent.hook_config_file
? pathPosix.basename(agent.hook_config_file)
: null;
if (hookConfigName && hookFile === hookConfigName) {
return pathPosix.join("workflow/hooks/agent-config", `${agentId}-hooks.json`);
}
return pathPosix.join("workflow/hooks", hookFile);
}
/** Compare installed hook scripts against their workflow templates. */
function hookEventKey(agentId, spec) {
if (agentId === "copilot") {
return spec.event === "PreToolUse" ? "preToolUse" : "postToolUse";
}
return spec.event;
}
/** Resolve a hook command path the same way installed agent configs store it. */
function hookCommandPath(agent, script) {
if (!agent.hooks_dir)
return script;
return pathPosix.join(agent.hooks_dir, script);
}
/** Build the optional Copilot hook entry that drift comparison expects when a toggle is enabled. */
function copilotHookEntry(agent, spec) {
const path = hookCommandPath(agent, spec.primaryScript);
return {
type: "command",
bash: path,
powershell: `if (Get-Command bash -ErrorAction SilentlyContinue) { bash ${path} } else { Write-Output '{"permissionDecision":"deny","permissionDecisionReason":"Bash, Git Bash, or WSL is required to run ${path} on Windows."}' }`,
timeoutSec: spec.timeoutSec ?? 30,
};
}
/** Detect managed hook entries by script reference so drift repair preserves unrelated hooks. */
function entryReferencesSpec(entry, spec) {
if (!isRecord(entry))
return false;
const commands = [
typeof entry.command === "string" ? entry.command : "",
typeof entry.bash === "string" ? entry.bash : "",
typeof entry.powershell === "string" ? entry.powershell : "",
].join("\n");
if (spec.scriptFiles.some((script) => commands.includes(script))) {
return true;
}
if (Array.isArray(entry.hooks)) {
return entry.hooks.some((hook) => entryReferencesSpec(hook, spec));
}
return false;
}
function ensureHooksObject(config) {
const hooks = config.hooks;
if (isRecord(hooks))
return hooks;
const next = {};
config.hooks = next;
return next;
}
function ensureHookEntries(config, event) {
const hooks = ensureHooksObject(config);
const entries = hooks[event];
if (Array.isArray(entries))
return entries;
const next = [];
hooks[event] = next;
return next;
}
/** Read explicit hook toggles from project config, returning null as the fallback when config is absent or invalid. */
function readExplicitHooks(fs) {
const config = fs.readFile(".goat-flow/config.yaml");
if (config === null)
return null;
let parsed;
try {
parsed = load(config) ?? {};
}
catch {
return null;
}
if (!isRecord(parsed) || !isRecord(parsed.hooks))
return null;
return parsed.hooks;
}
function expectedHookScript(_fs, _hookFile, template) {
return template;
}
/** Extract an explicit enabled boolean without treating missing config as disabled. */
function enabledFromHookConfig(value) {
if (!isRecord(value) || typeof value.enabled !== "boolean")
return null;
return value.enabled;
}
/** Resolve a hook toggle, including the legacy gruff-on-change alias used by existing configs. */
function explicitHookEnabled(fs, hookId) {
const hooks = readExplicitHooks(fs);
if (hooks === null)
return null;
const explicit = enabledFromHookConfig(hooks[hookId]);
if (explicit !== null)
return explicit;
if (hookId !== "gruff-code-quality")
return null;
return enabledFromHookConfig(hooks["gruff-on-change"]);
}
/** Keep hook-object access centralized because callers mutate the returned config object. */
function hooksObject(config) {
return ensureHooksObject(config);
}
function deleteHookEventIfEmpty(config, event) {
const hooks = hooksObject(config);
if (Array.isArray(hooks[event]) && hooks[event].length === 0) {
Reflect.deleteProperty(hooks, event);
}
}
function removeHookEntries(config, event, spec) {
const entries = ensureHookEntries(config, event);
const next = entries.filter((entry) => !entryReferencesSpec(entry, spec));
const hooks = hooksObject(config);
if (next.length === 0) {
Reflect.deleteProperty(hooks, event);
return;
}
hooks[event] = next;
}
function parsedHookTemplate(template) {
let config;
try {
config = JSON.parse(template);
}
catch {
return null;
}
return isRecord(config) ? config : null;
}
function applyExplicitHookToggle(fs, config, agent, spec) {
if (spec.unsupportedAgents?.copilot)
return false;
const enabled = explicitHookEnabled(fs, spec.id);
if (enabled === null)
return false;
const event = hookEventKey("copilot", spec);
removeHookEntries(config, event, spec);
if (!enabled) {
deleteHookEventIfEmpty(config, event);
return true;
}
ensureHookEntries(config, event).push(copilotHookEntry(agent, spec));
return true;
}
function applyExplicitHookToggles(fs, config, agent) {
let changed = false;
for (const spec of listHookSpecs()) {
changed = applyExplicitHookToggle(fs, config, agent, spec) || changed;
}
return changed;
}
/**
* Copilot keeps hook registrations in `.github/hooks/hooks.json`, which is
* also the manifest-declared installed hook artifact. The static template only
* represents default guardrails; dashboard/CLI toggles can add optional hooks.
* Drift therefore compares against template plus desired toggle state.
*/
function expectedHookConfig(fs, agentId, agent, template) {
if (agentId !== "copilot" || !isAgentId(agentId))
return template;
const config = parsedHookTemplate(template);
if (config === null)
return template;
const hasHookConfigChanged = applyExplicitHookToggles(fs, config, agent);
if (!hasHookConfigChanged)
return template;
return `${JSON.stringify(config, null, 2)}\n`;
}
function compareHookArtifact(fs, templateRoot, findings, templateRel, installedRel, expectedFromTemplate) {
const template = readTemplate(templateRoot, templateRel);
if (template === null) {
findings.push({
kind: "missing",
path: templateRel,
message: `declared hook artifact ${installedRel} has no template at ${templateRel}`,
});
return;
}
const expected = expectedFromTemplate(template);
if (!fs.exists(installedRel)) {
findings.push({
kind: "missing",
path: installedRel,
message: `hook template ${templateRel} has no installed copy at ${installedRel}`,
});
return;
}
const installed = fs.readFile(installedRel);
if (installed === null)
return;
if (installed.trimEnd() !== expected.trimEnd()) {
findings.push({
kind: "content",
path: installedRel,
message: `hook template (${templateRel}) and installed copy (${installedRel}) differ`,
});
}
}
/** Compare installed hook scripts against their workflow templates. */
function compareHooks(fs, templateRoot, findings, checkedHookArtifacts) {
let checked = 0;
const manifest = loadManifest();
for (const [agentId, agent] of Object.entries(manifest.agents)) {
if (!agent.hooks_dir || !agent.hooks)
continue;
if (!fs.exists(agent.hooks_dir))
continue;
for (const hookFile of agent.hooks) {
const templateRel = hookTemplateRel(agentId, agent, hookFile);
const installedRel = pathPosix.join(agent.hooks_dir, hookFile);
checked++;
checkedHookArtifacts.add(installedRel);
compareHookArtifact(fs, templateRoot, findings, templateRel, installedRel, (template) => hookFile === agent.hook_config_file
? expectedHookConfig(fs, agentId, agent, template)
: expectedHookScript(fs, hookFile, template));
}
if (agentId === "copilot" && agent.hook_config_file) {
const templateRel = "workflow/hooks/agent-config/copilot-hooks.json";
const installedRel = agent.hook_config_file;
checked++;
compareHookArtifact(fs, templateRoot, findings, templateRel, installedRel, (template) => expectedHookConfig(fs, agentId, agent, template));
}
}
return checked;
}
/**
* Decide whether the registry safety-net should compare one optional hook script.
*
* Drift only compares copies that actually exist on disk or that config explicitly
* enables - it never demands that a default-on hook be present. Whether a default
* guardrail like deny-dangerous is installed at all is the audit's agent-guardrail
* check's concern, not drift's; flagging it here would double-report and would mark
* hook-free installs (e.g. skills-only projects) as drifted. Gating on
* `spec.defaultEnabled` is therefore intentionally omitted.
*
* @param fs - ReadonlyFS rooted at the audited project.
* @param spec - Registry hook spec whose script is a comparison candidate.
* @param installedRel - Project-relative path of the installed hook script.
* @returns True when the installed copy is present or the hook is explicitly enabled.
*/
function shouldCompareRegistryHookScript(fs, spec, installedRel) {
if (fs.exists(installedRel))
return true;
return explicitHookEnabled(fs, spec.id) === true;
}
/** Compare optional registry hook scripts when present or explicitly enabled. */
function compareRegistryHookScripts(fs, templateRoot, findings, checkedHookArtifacts) {
let checked = 0;
for (const spec of listHookSpecs()) {
for (const script of spec.scriptFiles) {
if (script.includes("/"))
continue;
const installedRel = pathPosix.join(".goat-flow/hooks", script);
if (checkedHookArtifacts.has(installedRel))
continue;
if (!shouldCompareRegistryHookScript(fs, spec, installedRel))
continue;
checked++;
checkedHookArtifacts.add(installedRel);
compareHookArtifact(fs, templateRoot, findings, `workflow/hooks/${script}`, installedRel, (template) => expectedHookScript(fs, script, template));
}
}
return checked;
}
/**
* Run all drift comparisons and return a consolidated report.
*
* @param options - Project filesystem plus optional goat-flow template root.
* @returns Drift status, findings, and count of compared template/install pairs.
*/
export function checkDrift(options) {
const { fs } = options;
const templateRoot = options.templateRoot ?? getTemplatePath("");
const findings = [];
let checked = 0;
const checkedHookArtifacts = new Set();
checked += compareSkills(fs, templateRoot, findings);
checked += compareSharedFiles(fs, templateRoot, findings);
checked += compareHooks(fs, templateRoot, findings, checkedHookArtifacts);
checked += compareRegistryHookScripts(fs, templateRoot, findings, checkedHookArtifacts);
findOrphans(fs, findings);
return {
status: findings.length === 0 ? "pass" : "fail",
findings,
checked,
};
}
//# sourceMappingURL=check-drift.js.map