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.

623 lines 25.6 kB
/** * 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