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.

177 lines 8.28 kB
import { listMarkdownEntries, parseMarkdownFrontmatter, } from "../facts/shared/learning-loop-common.js"; /** Generation order for the four indexed buckets; stable so command output is deterministic. */ export const INDEX_BUCKETS = [ "footguns", "lessons", "patterns", "decisions", ]; /** Heading keyword per entry-style bucket (`## Footgun:` / `## Lesson:` / `## Pattern:`). */ const HEADING_KIND = { footguns: "Footgun", lessons: "Lesson", patterns: "Pattern", }; /** Lead-paragraph marker per entry-style bucket; the hook is its first sentence. */ const HOOK_MARKER = { footguns: "**Symptoms:**", lessons: "**What happened:**", patterns: "**Context:**", }; /** Entries below this marker are resolved history and stay out of the generated index. */ const RESOLVED_MARKER = "## Resolved Entries"; /** Metadata-only paragraphs skipped when falling back to the first body paragraph. */ const METADATA_LABEL = /^\*\*(?:Status|Created|Updated|Resolved|Evidence|Date|Superseded|Related):\*\*/; /** ADR record filenames; non-ADR files in the decisions dir are a stats finding, not index rows. */ const ADR_FILE = /^ADR-\d{3}-.+\.md$/; /** Limit rationale: 200 chars keeps index rows scannable in prompt context while preserving detail. */ const HOOK_MAX_CHARS = 200; /** * The patterns bucket has no config.yaml key (unlike footguns/lessons/decisions), so the path is * fixed by convention - the same convention `extractLearningLoopEntries` already relies on. */ const PATTERNS_BUCKET_PATH = ".goat-flow/learning-loop/patterns/"; /** * Resolve the four indexed bucket directories from loaded project config. * * @param config - validated goat-flow config carrying the footguns/lessons/decisions paths * @returns bucket-keyed relative directory paths; patterns falls back to the fixed convention path */ export function resolveIndexBucketPaths(config) { return { footguns: config.footguns.path, lessons: config.lessons.path, patterns: PATTERNS_BUCKET_PATH, decisions: config.decisions.path, }; } /** Return the file name from a POSIX-joined entry path. */ function baseName(path) { return path.split("/").pop() ?? path; } /** Cut a heading line before any embedded double quote so the search needle stays greppable. */ function searchNeedle(headingLine) { const quote = headingLine.indexOf('"'); return quote === -1 ? headingLine : headingLine.slice(0, quote).trimEnd(); } /** * Extract the first sentence of a paragraph, collapsing whitespace and truncating run-ons at a * word boundary. The sentence break requires a capital/backtick/quote follow-up so file names * like `cli.ts` inside a sentence do not split it early. Bold markers are stripped because a * sentence cut can otherwise leave an unbalanced `**` pair in the rendered row. */ function firstSentence(text) { const collapsed = text.replace(/\*\*/g, "").replace(/\s+/g, " ").trim(); const sentence = collapsed.split(/(?<=[.!?])\s+(?=[A-Z`"([])/)[0] ?? collapsed; if (sentence.length <= HOOK_MAX_CHARS) return sentence; const cut = sentence.slice(0, HOOK_MAX_CHARS); const lastSpace = cut.lastIndexOf(" "); return `${cut.slice(0, lastSpace > 0 ? lastSpace : HOOK_MAX_CHARS)}…`; } /** Return the first paragraph following a literal marker, or null when the marker is absent. */ function paragraphAfter(content, marker) { const idx = content.indexOf(marker); if (idx === -1) return null; const after = content.slice(idx + marker.length).trimStart(); const paragraph = (after.split(/\n\s*\n/)[0] ?? "").trim(); return paragraph.length > 0 ? paragraph : null; } /** First non-metadata body paragraph, with any leading `**Label:**` stripped - the hook fallback. */ function firstBodyParagraph(content) { const withoutHeading = content.replace(/^#{1,2}[^\n]*\n/, ""); for (const raw of withoutHeading.split(/\n\s*\n/)) { const paragraph = raw.trim(); if (paragraph.length === 0 || METADATA_LABEL.test(paragraph)) continue; return paragraph.replace(/^\*\*[^*\n]+:\*\*\s*/, ""); } return ""; } /** Slice a bucket body at each `## <Kind>:` heading into document-ordered sections. */ function splitEntrySections(body, kind) { const headingPattern = new RegExp(`^##\\s+${kind}:\\s+(.+)$`, "gm"); const headings = Array.from(body.matchAll(headingPattern), (match) => ({ title: (match[1] ?? "").trim(), headingLine: match[0], start: match.index, })); return headings.map((heading, index) => ({ ...heading, content: body.slice(heading.start, headings[index + 1]?.start ?? body.length), })); } /** Parse one footgun/lesson/pattern bucket file into active-entry index rows. */ function parseEntryFile(file, bucket) { const { body } = parseMarkdownFrontmatter(file.content); const resolvedAt = body.indexOf(RESOLVED_MARKER); const sourceFile = baseName(file.path); return splitEntrySections(body, HEADING_KIND[bucket]) .filter((section) => resolvedAt === -1 || section.start < resolvedAt) .filter((section) => !/\*\*Status:\*\*\s*resolved\b/i.test(section.content)) .map((section) => ({ title: section.title, sourceFile, anchor: searchNeedle(section.headingLine), hook: firstSentence(paragraphAfter(section.content, HOOK_MARKER[bucket]) ?? firstBodyParagraph(section.content)), })); } /** Read one `**Label:** YYYY-MM-DD` metadata date from an ADR body. */ function metadataDate(body, label) { return (body.match(new RegExp(`^\\*\\*${label}:\\*\\*\\s*(\\d{4}-\\d{2}-\\d{2})`, "m"))?.[1] ?? null); } /** Pick the date displayed beside an ADR status in generated indexes. */ function decisionIndexDate(body, status) { if (/^Superseded\b/u.test(status)) { return metadataDate(body, "Superseded") ?? metadataDate(body, "Date"); } return metadataDate(body, "Date"); } /** Read the status/date prefix for one ADR index hook. */ function decisionStatusPart(body) { const status = firstSentence(body.match(/^\*\*Status:\*\*\s*(.+)$/m)?.[1]?.trim() ?? "Unknown status"); const date = decisionIndexDate(body, status); return date === null ? status : `${status}, ${date}`; } /** Read the first ADR decision sentence, falling back to body prose for older ADR shapes. */ function decisionSummary(body) { return firstSentence(paragraphAfter(body, "\n## Decision") ?? firstBodyParagraph(body)); } /** Parse one ADR file into its index row; null when the file lacks an H1 title. */ function parseDecisionFile(file) { const { body } = parseMarkdownFrontmatter(file.content); const titleMatch = body.match(/^#\s+(.+)$/m); if (!titleMatch) return null; // ADR shapes vary: status/date lines are mandatory in current records, but older records may put // status prose in paragraphs, so the parser composes a compact hook from whichever stable parts exist. return { title: (titleMatch[1] ?? "").trim(), sourceFile: baseName(file.path), anchor: searchNeedle(titleMatch[0]), hook: `${decisionStatusPart(body)} - ${decisionSummary(body)}`, }; } /** * Parse one learning-loop bucket directory into the deterministic entry list a generated * INDEX.md is rendered from. Files come back lexicographically sorted (ADR number order for * decisions) with entries in document order, so repeated runs over unchanged content always * produce the same list. * * @param fs - read-only filesystem adapter rooted at the target project * @param dirPath - bucket directory path relative to the project root * @param bucket - which bucket grammar to apply (entry headings vs one-ADR-per-file) * @returns active-entry rows; empty when the directory is missing or holds no active entries */ export function parseBucket(fs, dirPath, bucket) { const dir = listMarkdownEntries(fs, dirPath); if (bucket === "decisions") { return dir.files .filter((file) => ADR_FILE.test(baseName(file.path))) .flatMap((file) => parseDecisionFile(file) ?? []); } return dir.files.flatMap((file) => parseEntryFile(file, bucket)); } //# sourceMappingURL=parse-bucket.js.map