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.

687 lines 27.3 kB
import { AUDIT_VERSION } from "../constants.js"; const VERIFIED_ON = "2026-05-03"; /** Return the setup spec provenance. */ function setupSpecProvenance(paths) { return { source_type: "spec", source_urls: [], verified_on: VERIFIED_ON, normative_level: "MUST", evidence_paths: paths, }; } // Paths covered by named checks - excluded from the catch-all. // config.yaml is also excluded (covered by config-parses). const NAMED_PATHS = new Set([ ".goat-flow/learning-loop/lessons/", ".goat-flow/learning-loop/lessons/README.md", ".goat-flow/learning-loop/footguns/", ".goat-flow/learning-loop/footguns/README.md", ".goat-flow/architecture.md", ".goat-flow/code-map.md", ".goat-flow/glossary.md", ".goat-flow/learning-loop/patterns/README.md", ".goat-flow/learning-loop/decisions/", ".goat-flow/learning-loop/decisions/README.md", ".goat-flow/logs/sessions/", ".goat-flow/plans/", ".goat-flow/plans/.gitignore", ".goat-flow/plans/README.md", ".goat-flow/scratchpad/", ".goat-flow/scratchpad/.gitignore", ".goat-flow/scratchpad/README.md", ".goat-flow/skill-docs/", ".goat-flow/skill-docs/README.md", ".goat-flow/skill-docs/skill-preamble.md", ".goat-flow/skill-docs/skill-conventions.md", ".goat-flow/skill-docs/playbooks/", ".goat-flow/skill-docs/playbooks/README.md", ".goat-flow/skill-docs/playbooks/browser-use.md", ".goat-flow/skill-docs/playbooks/changelog.md", ".goat-flow/skill-docs/playbooks/code-comments.md", ".goat-flow/skill-docs/playbooks/gruff-code-quality.md", ".goat-flow/skill-docs/playbooks/observability.md", ".goat-flow/skill-docs/playbooks/page-capture.md", ".goat-flow/skill-docs/playbooks/release-notes.md", ".goat-flow/skill-docs/skill-quality-testing/", ".goat-flow/skill-docs/skill-quality-testing/README.md", ".goat-flow/skill-docs/skill-quality-testing/tdd-iteration.md", ".goat-flow/skill-docs/skill-quality-testing/adversarial-framing.md", ".goat-flow/skill-docs/skill-quality-testing/deployment.md", ".goat-flow/hooks/", ".goat-flow/hooks/deny-dangerous.sh", ".goat-flow/hooks/gruff-code-quality.sh", ".goat-flow/hooks/post-turn-safety.sh", ".goat-flow/hooks/deny-dangerous/", ".goat-flow/hooks/deny-dangerous/patterns-shell.sh", ".goat-flow/hooks/deny-dangerous/patterns-paths.sh", ".goat-flow/hooks/deny-dangerous/patterns-writes.sh", ".goat-flow/hooks/deny-dangerous/deny-dangerous-self-test.sh", ".goat-flow/config.yaml", ]); // Optional exclusions from the manifest catch-all setup gate. const EXCLUDED_MANIFEST_PATHS = new Set(); const READ_RULE_PATTERNS = [ /Before declaring any tool(?: or capability)? unavailable/i, /\.goat-flow\/skill-docs\/playbooks\//, /Availability Check/i, ]; const ROUTER_POINTER_PATTERNS = [ /\.goat-flow\/skill-docs\/playbooks\//, /tool playbooks?|skill docs?|skill playbooks?/i, ]; const REQUIRED_SKILL_DOC_FILES = [ // Meta references ".goat-flow/skill-docs/README.md", ".goat-flow/skill-docs/skill-preamble.md", ".goat-flow/skill-docs/skill-conventions.md", // Standalone playbooks ".goat-flow/skill-docs/playbooks/README.md", ".goat-flow/skill-docs/playbooks/browser-use.md", ".goat-flow/skill-docs/playbooks/changelog.md", ".goat-flow/skill-docs/playbooks/code-comments.md", ".goat-flow/skill-docs/playbooks/gruff-code-quality.md", ".goat-flow/skill-docs/playbooks/observability.md", ".goat-flow/skill-docs/playbooks/page-capture.md", ".goat-flow/skill-docs/playbooks/release-notes.md", ".goat-flow/skill-docs/skill-quality-testing/README.md", ".goat-flow/skill-docs/skill-quality-testing/tdd-iteration.md", ".goat-flow/skill-docs/skill-quality-testing/adversarial-framing.md", ".goat-flow/skill-docs/skill-quality-testing/deployment.md", ]; // Un-ignore patterns the goat-flow-gitignore template installs into // `.goat-flow/.gitignore`. The template ignores everything (`*`) by default, // then re-includes these committed surfaces. Pre-1.6.1 installs are missing // the old skill-doc entries, which silently hides the committed docs and hook // policy files from git even though the files exist on disk. const REQUIRED_GOAT_FLOW_GITIGNORE_PATTERNS = [ "!learning-loop/", "!learning-loop/**", "!skill-docs/", "!skill-docs/**", "!hooks/", "!hooks/**", "!plans/", "!plans/**", ]; function presentInstructionFiles(ctx) { const paths = Object.values(ctx.structure.agents).map((agent) => agent.instruction_file); return [...new Set(paths)].filter((path) => ctx.fs.exists(path)); } /** * Parse ATX headings from instruction markdown without a full Markdown parser. * * The audit only needs section boundaries for AGENTS/CLAUDE/Copilot files, so a * small deterministic parser avoids adding a runtime dependency to setup checks. * The scan mutates only the local RegExp cursor used for this string. */ function markdownHeadings(content) { const headingPattern = /^(#{1,6})\s+(.+?)\s*$/gm; const headings = []; let match; while ((match = headingPattern.exec(content)) !== null) { headings.push({ index: match.index, end: match.index + match[0].length, level: match[1]?.length ?? 0, title: match[2] ?? "", }); } return headings; } /** * Return the first content offset after a heading line. * * CRLF and LF both appear in installed instruction files; normalizing the start * offset here keeps section extraction from carrying the heading newline. */ function sectionStartOffset(content, headingEnd) { if (content.slice(headingEnd, headingEnd + 2) === "\r\n") return headingEnd + 2; if (content[headingEnd] === "\n") return headingEnd + 1; return headingEnd; } /** * Extract one markdown section by heading title. * * The end boundary is the next heading at the same or higher level because READ * can be nested under Execution Loop without swallowing sibling steps. */ function markdownSection(content, heading) { const headings = markdownHeadings(content); const headingIndex = headings.findIndex((entry) => heading.test(entry.title)); if (headingIndex < 0) return null; const startHeading = headings[headingIndex]; if (!startHeading) return null; const nextHeading = headings .slice(headingIndex + 1) .find((entry) => entry.level <= startHeading.level); return content .slice(sectionStartOffset(content, startHeading.end), nextHeading?.index) .trim(); } /** * Extract AGENTS-style bold execution-loop steps. * * Some installed instruction files encode READ/SCOPE/ACT/VERIFY as bold list * labels instead of headings; this fallback preserves compatibility with that * shape while keeping the skill-docs rule scoped to the Execution Loop. * The helper reads the provided string only; it does not touch project files. */ function boldStepSection(content, step) { const pattern = new RegExp(String.raw `(?:^|\n)\s*(?:[-*]\s*)?\*\*${step}\*\*[\s:–-]*(?<body>[\s\S]*?)(?=\n\s*(?:[-*]\s*)?\*\*(?:READ|SCOPE|ACT|VERIFY)\*\*[\s:–-]*|\n##\s|\n###\s|$)`, "i"); return pattern.exec(content)?.groups?.body?.trim() ?? null; } /** * Check that READ tells agents to consult playbooks before declaring tools absent. * * The scan is scoped to the Execution Loop so incidental references elsewhere * do not satisfy the setup contract. */ function hasSkillReferenceReadRule(content) { const executionLoop = markdownSection(content, /^Execution Loop\b/i); if (!executionLoop) return false; const readSection = markdownSection(executionLoop, /^READ\b/i) ?? boldStepSection(executionLoop, "READ"); if (!readSection) return false; return READ_RULE_PATTERNS.every((pattern) => pattern.test(readSection)); } /** * Check that the Router Table exposes the skill-docs/playbook paths. * * Keeping this in Router Table makes the discovery path explicit for future * agents instead of relying on a one-off mention in surrounding prose. */ function hasSkillReferenceRouterPointer(content) { const routerTable = markdownSection(content, /^Router Table\b/i); if (!routerTable) return false; return ROUTER_POINTER_PATTERNS.every((pattern) => pattern.test(routerTable)); } function missingSkillReferenceInstructionRequirements(content) { const missing = []; if (!hasSkillReferenceReadRule(content)) missing.push("READ rule"); if (!hasSkillReferenceRouterPointer(content)) { missing.push("Router Table pointer"); } return missing; } // === Named structure checks (10) === const lessons = { id: "lessons", name: "Lessons", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/architecture.md", ]), /** Run the Lessons check. */ run: (ctx) => { const missing = []; if (!ctx.fs.exists(".goat-flow/learning-loop/lessons")) missing.push(".goat-flow/learning-loop/lessons/"); if (!ctx.fs.exists(".goat-flow/learning-loop/lessons/README.md")) missing.push(".goat-flow/learning-loop/lessons/README.md"); if (missing.length === 0) return null; return { check: "Lessons", message: `Missing: ${missing.join(", ")}`, evidence: missing[0], howToFix: "Create lessons directory by running `goat-flow setup` or `mkdir -p .goat-flow/learning-loop/lessons`.", }; }, }; const footguns = { id: "footguns", name: "Footguns", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/architecture.md", ]), /** Run the Footguns check. */ run: (ctx) => { const missing = []; if (!ctx.fs.exists(".goat-flow/learning-loop/footguns")) missing.push(".goat-flow/learning-loop/footguns/"); if (!ctx.fs.exists(".goat-flow/learning-loop/footguns/README.md")) missing.push(".goat-flow/learning-loop/footguns/README.md"); if (missing.length === 0) return null; return { check: "Footguns", message: `Missing: ${missing.join(", ")}`, evidence: missing[0], howToFix: "Create footguns directory by running `goat-flow setup` or `mkdir -p .goat-flow/learning-loop/footguns`.", }; }, }; const architecture = { id: "architecture", name: "Architecture", scope: "setup", evidenceKind: "structural", provenance: setupSpecProvenance([ "workflow/manifest.json", "workflow/setup/04-architecture-code-map.md", ]), /** Run the Architecture check. */ run: (ctx) => { if (ctx.fs.exists(".goat-flow/architecture.md")) return null; return { check: "Architecture", message: "Missing: .goat-flow/architecture.md", evidence: ".goat-flow/architecture.md", howToFix: "Create .goat-flow/architecture.md by running `goat-flow setup`.", }; }, }; const codeMap = { id: "code-map", name: "Code map", scope: "setup", evidenceKind: "structural", provenance: setupSpecProvenance([ "workflow/manifest.json", "workflow/setup/04-architecture-code-map.md", ]), /** Run the Code map check. */ run: (ctx) => { if (ctx.fs.exists(".goat-flow/code-map.md")) return null; return { check: "Code map", message: "Missing: .goat-flow/code-map.md", evidence: ".goat-flow/code-map.md", howToFix: "Create .goat-flow/code-map.md by running `goat-flow setup`.", }; }, }; const glossary = { id: "glossary", name: "Glossary", scope: "setup", evidenceKind: "structural", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/architecture.md", ]), /** Run the Glossary check. */ run: (ctx) => { if (ctx.fs.exists(".goat-flow/glossary.md")) return null; return { check: "Glossary", message: "Missing: .goat-flow/glossary.md", evidence: ".goat-flow/glossary.md", howToFix: "Create .goat-flow/glossary.md by running `goat-flow setup`.", }; }, }; const patterns = { id: "patterns", name: "Patterns", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/architecture.md", ]), /** Run the Patterns check. */ run: (ctx) => { if (ctx.fs.exists(".goat-flow/learning-loop/patterns/README.md")) return null; return { check: "Patterns", message: "Missing: .goat-flow/learning-loop/patterns/README.md", evidence: ".goat-flow/learning-loop/patterns/README.md", howToFix: "Create .goat-flow/learning-loop/patterns/ directory by running `goat-flow setup`.", }; }, }; const decisions = { id: "decisions", name: "Decisions", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/architecture.md", ]), /** Run the Decisions check. */ run: (ctx) => { const missing = []; if (!ctx.fs.exists(".goat-flow/learning-loop/decisions")) missing.push(".goat-flow/learning-loop/decisions/"); if (!ctx.fs.exists(".goat-flow/learning-loop/decisions/README.md")) missing.push(".goat-flow/learning-loop/decisions/README.md"); if (missing.length === 0) return null; return { check: "Decisions", message: `Missing: ${missing.join(", ")}`, evidence: missing[0], howToFix: "Create decisions directory by running `goat-flow setup` or `mkdir -p .goat-flow/learning-loop/decisions`.", }; }, }; const sessionLogs = { id: "session-logs", name: "Session logs", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/architecture.md", ]), /** Run the Session logs check. */ run: (ctx) => { if (ctx.fs.exists(".goat-flow/logs/sessions")) return null; return { check: "Session logs", message: "Missing: .goat-flow/logs/sessions/", evidence: ".goat-flow/logs/sessions/", howToFix: "Create session logs directory by running `goat-flow setup` or `mkdir -p .goat-flow/logs/sessions`.", }; }, }; const plans = { id: "plans", name: "Plans", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/architecture.md", ".goat-flow/plans/README.md", ]), /** Run the Plans check. */ run: (ctx) => { const missing = []; if (!ctx.fs.exists(".goat-flow/plans")) missing.push(".goat-flow/plans/"); if (!ctx.fs.exists(".goat-flow/plans/.gitignore")) missing.push(".goat-flow/plans/.gitignore"); if (!ctx.fs.exists(".goat-flow/plans/README.md")) missing.push(".goat-flow/plans/README.md"); if (missing.length === 0) return null; return { check: "Plans", message: `Missing: ${missing.join(", ")}`, evidence: missing[0], howToFix: "Create plans directory by running `goat-flow setup`. README.md signals the dir is local-session-state by design.", }; }, }; const scratchpad = { id: "scratchpad", name: "Scratchpad", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/architecture.md", ".goat-flow/scratchpad/README.md", ]), /** Run the Scratchpad check. */ run: (ctx) => { const missing = []; if (!ctx.fs.exists(".goat-flow/scratchpad")) missing.push(".goat-flow/scratchpad/"); if (!ctx.fs.exists(".goat-flow/scratchpad/.gitignore")) missing.push(".goat-flow/scratchpad/.gitignore"); if (!ctx.fs.exists(".goat-flow/scratchpad/README.md")) missing.push(".goat-flow/scratchpad/README.md"); if (missing.length === 0) return null; return { check: "Scratchpad", message: `Missing: ${missing.join(", ")}`, evidence: missing[0], howToFix: "Create scratchpad directory by running `goat-flow setup`. README.md signals the dir is local WIP by design.", }; }, }; const goatFlowGitignoreContent = { id: "goat-flow-gitignore", name: "goat-flow gitignore exceptions", scope: "setup", provenance: setupSpecProvenance([ "workflow/setup/reference/goat-flow-gitignore", "workflow/install-goat-flow.sh", ]), /** Run the goat-flow gitignore exceptions check. */ run: (ctx) => { if (!ctx.fs.exists(".goat-flow/.gitignore")) { return { check: "goat-flow gitignore exceptions", message: "Missing: .goat-flow/.gitignore", evidence: ".goat-flow/.gitignore", howToFix: "Run `goat-flow install . --agent <id>` to copy the current gitignore template. The installer always overwrites .goat-flow/.gitignore.", }; } const content = ctx.fs.readFile(".goat-flow/.gitignore") ?? ""; const missing = REQUIRED_GOAT_FLOW_GITIGNORE_PATTERNS.filter((pattern) => !content.includes(pattern)); if (missing.length === 0) return null; return { check: "goat-flow gitignore exceptions", message: `.goat-flow/.gitignore is missing required un-ignore entries: ${missing.join(", ")}. Stale gitignores silently hide committed skill docs, hook policy, or plan anchors from git.`, evidence: ".goat-flow/.gitignore", howToFix: "Run `goat-flow install . --agent <id>` to refresh .goat-flow/.gitignore from the current template. After it overwrites, `git add .goat-flow/skill-docs/playbooks/ .goat-flow/skill-docs/` to track files that were previously hidden.", }; }, }; const instructionFileSkillReferencePointer = { id: "instruction-file-skill-docs-pointer", name: "Instruction file skill-docs pointer", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", "workflow/setup/reference/execution-loop.md", "workflow/setup/02-instruction-file.md", "workflow/skills/reference/README.md", "workflow/skills/playbooks/README.md", ]), /** Run the Instruction file skill-docs pointer check. */ run: (ctx) => { const missingReferenceFiles = REQUIRED_SKILL_DOC_FILES.filter((path) => !ctx.fs.exists(path)); if (missingReferenceFiles.length > 0) { return { check: "Instruction file skill-docs pointer", message: `Shared reference/playbook pack is incomplete. Missing: ${missingReferenceFiles.join(", ")}`, evidence: missingReferenceFiles[0], howToFix: "Refresh with `goat-flow install . --agent <agent>`. The index files are load-bearing and must be installed with the shared skill-docs/playbook pack.", }; } const missingRequirements = presentInstructionFiles(ctx).flatMap((path) => { const content = ctx.fs.readFile(path) ?? ""; const missing = missingSkillReferenceInstructionRequirements(content); return missing.length > 0 ? [`${path} (${missing.join(", ")})`] : []; }); if (missingRequirements.length === 0) return null; return { check: "Instruction file skill-docs pointer", message: `Instruction file(s) missing skill-docs READ rule or Router Table pointer: ${missingRequirements.join(", ")}`, evidence: missingRequirements[0]?.replace(/\s+\(.+\)$/, ""), howToFix: 'Append to the existing READ step: "Before declaring any tool or capability unavailable, read the matching playbook in `.goat-flow/skill-docs/playbooks/` (e.g. `browser-use.md`, `page-capture.md`) and run that doc\'s "Availability Check" section verbatim - project-local CLI tools at `~/.local/bin/` are valid; do not conflate "no harness/MCP tool" with "no tool"." Add a Router Table row for tool playbooks: | Skill playbooks (tools) | `.goat-flow/skill-docs/playbooks/` (README.md index; read BEFORE declaring a tool unavailable) |.', }; }, }; // === Catch-all for remaining manifest entries === const otherFiles = { id: "other-files", name: "Other required files", scope: "setup", provenance: setupSpecProvenance(["workflow/manifest.json"]), /** Run the Other required files check. */ run: (ctx) => { const allRequired = [ ...ctx.structure.required_files, ...ctx.structure.required_dirs, ]; const uncovered = allRequired.filter((p) => !NAMED_PATHS.has(p) && !EXCLUDED_MANIFEST_PATHS.has(p)); const missing = uncovered.filter((p) => { const trimmed = p.endsWith("/") ? p.slice(0, -1) : p; return !ctx.fs.exists(trimmed); }); if (missing.length === 0) return null; return { check: "Other required files", message: `Missing: ${missing.join(", ")}`, evidence: missing[0], howToFix: `Create ${missing.join(", ")} by running \`goat-flow setup\` or creating them manually.`, }; }, }; const configExistsAndParses = { id: "config-parses", name: "Config file", scope: "setup", provenance: setupSpecProvenance([ "workflow/manifest.json", ".goat-flow/config.yaml", ]), /** Run the Config file check. */ run: (ctx) => { if (!ctx.config.exists) { return { check: "Config file", message: ".goat-flow/config.yaml does not exist", howToFix: "Create .goat-flow/config.yaml by running `goat-flow setup`.", }; } if (ctx.config.parseError) { return { check: "Config file", message: `Parse error: ${ctx.config.parseError}`, evidence: ".goat-flow/config.yaml", howToFix: "Fix the YAML syntax error in .goat-flow/config.yaml.", }; } if (!ctx.config.valid) { const [firstError] = ctx.config.errors; const detail = firstError ? `${firstError.path}: ${firstError.message}` : "validation failed"; return { check: "Config file", message: `Validation error: ${detail}`, evidence: ".goat-flow/config.yaml", howToFix: "Fix the validation error in .goat-flow/config.yaml so it matches the manifest-backed config contract.", }; } return null; }, }; const configVersionCurrent = { id: "config-version", name: "Config version", scope: "setup", provenance: setupSpecProvenance([ ".goat-flow/config.yaml", "src/cli/constants.ts", ]), skip: (ctx) => !ctx.config.exists || ctx.config.parseError !== null, /** Run the Config version check. */ run: (ctx) => { const version = ctx.config.config.version; if (!version) { return { check: "Config version", message: "version field missing from config.yaml", howToFix: `Add \`version: "${AUDIT_VERSION}"\` to .goat-flow/config.yaml.`, }; } if (version !== AUDIT_VERSION) { return { check: "Config version", message: `Config version ${version} does not match current ${AUDIT_VERSION}`, howToFix: `Run \`goat-flow install . --agent <id> --update-config-version\` or update the version field in .goat-flow/config.yaml to "${AUDIT_VERSION}".`, }; } return null; }, }; const hookVersionCurrent = { id: "hook-version", name: "Hook version", scope: "setup", provenance: setupSpecProvenance([ ".goat-flow/hooks/deny-dangerous.sh", ".goat-flow/hooks/gruff-code-quality.sh", ".goat-flow/hooks/post-turn-safety.sh", "src/cli/constants.ts", ]), /** Run the Hook version check. */ run: (ctx) => { // Central hook dispatchers carry a `# goat-flow-hook-version: X.Y.Z` stamp. // Missing required dispatchers are a partial install; optional gruff may be // absent until enabled. const hookFiles = [ { file: "deny-dangerous.sh", required: true }, { file: "gruff-code-quality.sh", required: false }, { file: "post-turn-safety.sh", required: true }, ]; for (const { file: hookFile, required } of hookFiles) { const relPath = `.goat-flow/hooks/${hookFile}`; const content = ctx.fs.readFile(relPath); if (content === null) { if (!required) continue; return { check: "Hook version", message: `${relPath} is missing from the installed hook dispatcher set`, evidence: relPath, howToFix: `Re-run \`npx @blundergoat/goat-flow@${AUDIT_VERSION} hooks sync\` to install the required hook files.`, }; } const stamped = content.match(/goat-flow-hook-version:\s*([0-9]+\.[0-9]+\.[0-9]+)/); if (!stamped) { return { check: "Hook version", message: `${relPath} has no goat-flow-hook-version stamp (installed before ${AUDIT_VERSION})`, evidence: relPath, howToFix: `Re-run \`npx @blundergoat/goat-flow@${AUDIT_VERSION} hooks sync\` to update the hook files.`, }; } const stampedVersion = stamped[1]; if (stampedVersion !== AUDIT_VERSION) { return { check: "Hook version", message: `${relPath} is goat-flow-hook-version ${stampedVersion} but the current release is ${AUDIT_VERSION}`, evidence: relPath, howToFix: `Re-run \`npx @blundergoat/goat-flow@${AUDIT_VERSION} hooks sync\` to update the hook files.`, }; } } return null; }, }; /** 16 setup-scope build checks */ export const SETUP_CHECKS = [ lessons, footguns, architecture, codeMap, glossary, patterns, decisions, sessionLogs, plans, scratchpad, goatFlowGitignoreContent, instructionFileSkillReferencePointer, otherFiles, configExistsAndParses, configVersionCurrent, hookVersionCurrent, ]; //# sourceMappingURL=check-goat-flow.js.map