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.

268 lines 12 kB
import { EVIDENCE_PATTERN, FILE_REF_REGEX, ISO_DATE_REGEX, computeFreshness, countMatches, findCompetingArtifactSurfaces, isFileRef, listMarkdownEntries, parseFrontmatterFields, parseMarkdownFrontmatter, summarizeFootgunRefs, summarizeLessonRefs, } from "./learning-loop-common.js"; import { collectFootgunStructureDiagnostics } from "./learning-loop-sections.js"; export { computeFreshness, parseFrontmatterFields, } from "./learning-loop-common.js"; export { extractLearningLoopEntries } from "./learning-loop-entries.js"; /** Known filesystem locations where footgun artifacts may appear. */ const FOOTGUN_SURFACE_CANDIDATES = [ ".goat-flow/learning-loop/footguns/", "docs/footguns.md", ]; /** Known filesystem locations where lesson artifacts may appear. */ const LESSON_SURFACE_CANDIDATES = [ ".goat-flow/learning-loop/lessons/", "docs/lessons/", "docs/lessons.md", ]; /** Count `## Lesson:` or `## Pattern:` bucket entries in one markdown file. */ function countLessonEntries(content) { const { body } = parseMarkdownFrontmatter(content); const bucketCount = countMatches(body, /^##\s+(?:Lesson|Pattern):\s+/gm); return bucketCount > 0 ? bucketCount : 1; } /** Count active footgun sections, preserving legacy single-entry files as one entry. */ function countFootgunEntries(content) { const { body } = parseMarkdownFrontmatter(content); const bucketCount = countMatches(body, /^##\s+Footgun:\s+/gm); return bucketCount > 0 ? bucketCount : 1; } /** Count footgun evidence labels so stats can compare labels to entry count. */ function countFootgunLabels(content) { const { body } = parseMarkdownFrontmatter(content); const bucketCount = countMatches(body, /^##\s+Footgun:\s+/gm); if (bucketCount > 0) { return countMatches(body, /\*\*Evidence(?:\s+type)?:\*\*\s*(?:ACTUAL_MEASURED|DESIGN_TARGET|HYPOTHETICAL_EXAMPLE)/gim); } return hasEvidenceLabel(content) ? 1 : 0; } /** Accumulate directory mention counts from file references in markdown content. */ function mergeDirMentions(target, content) { const pathRefs = content.matchAll(new RegExp(FILE_REF_REGEX.source, "g")); for (const match of pathRefs) { const group = match[1]; if (group === undefined || !isFileRef(group)) continue; const dir = group.split("/").slice(0, -1).join("/"); if (!dir) continue; target.set(dir, (target.get(dir) ?? 0) + 1); } } /** Detect whether a footgun entry declares an explicit evidence label. */ function hasEvidenceLabel(content) { return (/^evidence_type:\s*.+$/im.test(content) || /^\*\*Evidence type:\*\*/m.test(content) || /\*\*Evidence:\*\*\s*(?:ACTUAL_MEASURED|DESIGN_TARGET|HYPOTHETICAL_EXAMPLE)/m.test(content)); } /** Detect whether markdown content cites at least one file reference. */ function hasFileEvidence(content) { const refs = content.matchAll(/`([^`]+\.[a-zA-Z]{1,10}:[0-9]+(?:[-,][0-9]+)*)`/g); for (const match of refs) { if (match[1] !== undefined && isFileRef(match[1])) return true; } return false; } /** Detect whether a footgun entry includes usable file or line evidence. */ function hasFootgunEvidence(content) { if (!EVIDENCE_PATTERN.test(content)) return false; return hasFileEvidence(content); } /** Append a category-missing diagnostic when the bucket body requires one. */ function collectCategoryDiagnostic(path, body, fields, diagnostics) { const lessonBuckets = countMatches(body, /^##\s+(?:Lesson|Pattern):\s+/gm); const footgunBuckets = countMatches(body, /^##\s+Footgun:\s+/gm); const isBucket = lessonBuckets > 0 || footgunBuckets > 0; if (!fields.category && lessonBuckets > 0) { diagnostics.push(`${path} is a lessons category bucket but missing frontmatter category`); } if (!fields.category && footgunBuckets > 0) { diagnostics.push(`${path} is a footguns category bucket but missing frontmatter category`); } return isBucket; } /** Append a last_reviewed diagnostic when the field is missing or malformed. */ function collectLastReviewedDiagnostic(path, fields, diagnostics) { const raw = fields.last_reviewed; if (raw === undefined || raw === "") { diagnostics.push(`${path} missing frontmatter last_reviewed`); return; } if (!ISO_DATE_REGEX.test(raw)) { diagnostics.push(`${path} has invalid last_reviewed format "${raw}" (expected YYYY-MM-DD)`); } } /** Return a format diagnostic when a lesson or footgun bucket is missing required frontmatter. */ function getMissingFrontmatterDiagnostic(path, content) { const { frontmatter, body } = parseMarkdownFrontmatter(content); if (frontmatter === null) return `${path} missing YAML frontmatter`; const fields = parseFrontmatterFields(frontmatter); const diagnostics = []; const isBucket = collectCategoryDiagnostic(path, body, fields, diagnostics); if (isBucket) collectLastReviewedDiagnostic(path, fields, diagnostics); return diagnostics.length === 0 ? null : diagnostics.join("; "); } /** Extract the most recent `**Created:**` or `**Updated:**` date from a bucket body. * Returns YYYY-MM-DD or null if no parseable dates are found. Any non-YYYY-MM-DD * value is ignored; malformed dates would already be caught elsewhere. */ function extractMaxEntryDate(body) { const pattern = /\*\*(?:Created|Updated|Resolved):\*\*\s*(\d{4}-\d{2}-\d{2})/gi; let max = null; for (const match of body.matchAll(pattern)) { const date = match[1]; if (date === undefined || !ISO_DATE_REGEX.test(date)) continue; if (max === null || date > max) max = date; } return max; } /** Build a per-bucket freshness record from one markdown entry. */ function buildBucketFreshness(entry, entryCount, staleRefs, invalidLineRefs, now) { const { frontmatter, body } = parseMarkdownFrontmatter(entry.content); const fields = frontmatter === null ? {} : parseFrontmatterFields(frontmatter); const raw = fields.last_reviewed; const lastReviewed = raw !== undefined && raw !== "" && ISO_DATE_REGEX.test(raw) ? raw : null; const { days, band } = computeFreshness(lastReviewed, now); const maxEntryDate = extractMaxEntryDate(body); return { path: entry.path, lastReviewed, freshnessDays: days, freshnessBand: band, entryCount, staleRefs, invalidLineRefs, maxEntryDate, sizeBytes: Buffer.byteLength(entry.content, "utf8"), lineCount: entry.content.split("\n").length - (entry.content.endsWith("\n") ? 1 : 0), }; } /** Aggregate evidence, labels, directory mentions, stale refs, and per-bucket freshness across footgun entries. */ function summarizeFootgunEntries(fs, entries, now) { const dirMentions = new Map(); const staleRefs = []; const invalidLineRefs = []; const diagnostics = []; const buckets = []; let hasEvidence = false; let entryCount = 0; let labelCount = 0; let totalRefs = 0; let validRefs = 0; for (const entry of entries) { const { content, path } = entry; const bucketEntryCount = countFootgunEntries(content); entryCount += bucketEntryCount; labelCount += countFootgunLabels(content); hasEvidence ||= hasFootgunEvidence(content); mergeDirMentions(dirMentions, content); const refSummary = summarizeFootgunRefs(fs, content); totalRefs += refSummary.totalRefs; validRefs += refSummary.validRefs; staleRefs.push(...refSummary.staleRefs); invalidLineRefs.push(...refSummary.invalidLineRefs); const diagnostic = getMissingFrontmatterDiagnostic(path, content); if (diagnostic) diagnostics.push(diagnostic); diagnostics.push(...collectFootgunStructureDiagnostics(path, content)); buckets.push(buildBucketFreshness(entry, bucketEntryCount, refSummary.staleRefs, refSummary.invalidLineRefs, now)); } return { hasEvidence, entryCount, labelCount, dirMentions, staleRefs, invalidLineRefs, totalRefs, validRefs, formatDiagnostic: diagnostics.length > 0 ? diagnostics.join("; ") : null, buckets, }; } /** Aggregate entry counts, stale refs, diagnostics, and per-bucket freshness across lesson entries. */ function summarizeLessonEntries(fs, entries, now) { const staleRefs = []; const invalidLineRefs = []; const diagnostics = []; const buckets = []; let entryCount = 0; for (const entry of entries) { const { content, path } = entry; const bucketEntryCount = countLessonEntries(content); entryCount += bucketEntryCount; const refSummary = summarizeLessonRefs(fs, content); staleRefs.push(...refSummary.staleRefs); invalidLineRefs.push(...refSummary.invalidLineRefs); const diagnostic = getMissingFrontmatterDiagnostic(path, content); if (diagnostic) diagnostics.push(diagnostic); buckets.push(buildBucketFreshness(entry, bucketEntryCount, refSummary.staleRefs, refSummary.invalidLineRefs, now)); } return { entryCount, staleRefs, invalidLineRefs, formatDiagnostic: diagnostics.length > 0 ? diagnostics.join("; ") : null, buckets, }; } /** * Extract footgun facts: existence, evidence quality, directory mention counts, and per-bucket freshness. * * @param fs - filesystem adapter for the target project * @param configState - loaded config that chooses the footgun artifact path * @param now - comparison clock for deterministic bucket freshness */ export function extractFootgunFacts(fs, configState, now = new Date()) { const dir = listMarkdownEntries(fs, configState.config.footguns.path); const summary = summarizeFootgunEntries(fs, dir.files, now); const formatDiagnostic = summary.entryCount === 0 && dir.exists ? "Footgun directory exists but contains 0 entries" : summary.formatDiagnostic; return { exists: dir.exists, hasEvidence: summary.hasEvidence, entryCount: summary.entryCount, labelCount: summary.labelCount, hasEvidenceLabels: summary.entryCount > 0 && summary.labelCount >= summary.entryCount, dirMentions: summary.dirMentions, staleRefs: summary.staleRefs, invalidLineRefs: summary.invalidLineRefs, duplicateSurfacePaths: findCompetingArtifactSurfaces(fs, [configState.config.footguns.path], FOOTGUN_SURFACE_CANDIDATES), totalRefs: summary.totalRefs, validRefs: summary.validRefs, formatDiagnostic, path: configState.config.footguns.path, buckets: summary.buckets, }; } /** * Extract lessons facts: existence, entry presence, and per-bucket freshness. * * @param fs - filesystem adapter for the target project * @param configState - loaded config that chooses the lessons artifact path * @param now - comparison clock for deterministic bucket freshness */ export function extractLessonsFacts(fs, configState, now = new Date()) { const dir = listMarkdownEntries(fs, configState.config.lessons.path); const summary = summarizeLessonEntries(fs, dir.files, now); const formatDiagnostic = summary.entryCount === 0 && dir.exists ? "Lesson directory exists but contains 0 entries" : summary.formatDiagnostic; return { exists: dir.exists, hasEntries: summary.entryCount > 0, entryCount: summary.entryCount, staleRefs: summary.staleRefs, invalidLineRefs: summary.invalidLineRefs, duplicateSurfacePaths: findCompetingArtifactSurfaces(fs, [configState.config.lessons.path], LESSON_SURFACE_CANDIDATES), formatDiagnostic, path: configState.config.lessons.path, buckets: summary.buckets, }; } //# sourceMappingURL=learning-loop.js.map