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.

265 lines 10.5 kB
/** * Learning-loop health report (`goat-flow stats`). * * Consumes the live `SharedFacts` pipeline - no second on-disk read path and no * persisted derived counts. `--check` mode reuses the same report data to decide * pass/fail, so CI and the human-readable report never disagree. */ import { DECISION_META_FILES } from "../facts/shared/decision-files.js"; /** Build one learning-loop section summary. */ function buildSection(side, totalInvalidLineRefs) { const bands = { fresh: 0, aging: 0, stale: 0, unknown: 0 }; for (const bucket of side.buckets) bands[bucket.freshnessBand] += 1; return { path: side.path, exists: side.exists, totalEntries: side.entryCount, totalStaleRefs: side.staleRefs.length, totalInvalidLineRefs, bands, buckets: side.buckets, formatDiagnostic: side.formatDiagnostic, }; } /** * Build the full stats report from the learning-loop slice of shared facts. * * @param shared Footgun, lesson, optional decision, and optional index-freshness facts from the shared extraction pipeline. * @returns Report shape consumed by all text, JSON, Markdown, and check renderers. */ export function buildStatsReport(shared) { return { footguns: buildSection(shared.footguns, shared.footguns.invalidLineRefs.length), lessons: buildSection(shared.lessons, shared.lessons.invalidLineRefs.length), ...(shared.decisions ? { decisions: shared.decisions } : {}), ...(shared.indexes ? { indexes: shared.indexes } : {}), }; } export function buildDecisionsSection(fs, rawPath) { const path = rawPath.replace(/\/$/, ""); const exists = fs.exists(path); const filenames = exists ? fs.listDir(path).sort() : []; const files = filenames.map((filename) => ({ filename, path: `${path}/${filename}`, content: fs.readFile(`${path}/${filename}`), })); return { path, exists, files, warnings: [], }; } /** Check one bucket for stale or missing last_reviewed metadata. */ function checkBucketLastReviewed(bucket) { if (bucket.lastReviewed === null) { return { file: bucket.path, rule: "missing-last-reviewed", message: `${bucket.path}: missing or invalid frontmatter last_reviewed (expected YYYY-MM-DD)`, }; } if (bucket.maxEntryDate !== null && bucket.maxEntryDate > bucket.lastReviewed) { return { file: bucket.path, rule: "stale-last-reviewed", message: `${bucket.path}: last_reviewed (${bucket.lastReviewed}) is older than the newest entry date (${bucket.maxEntryDate}); bump frontmatter last_reviewed.`, }; } return null; } const BUCKET_SIZE_WARN_BYTES = 40_000; /** Collect bucket findings. */ function collectBucketFindings(bucket) { const findings = []; const reviewFinding = checkBucketLastReviewed(bucket); if (reviewFinding !== null) findings.push(reviewFinding); if (bucket.sizeBytes > BUCKET_SIZE_WARN_BYTES) { const kb = Math.round(bucket.sizeBytes / 1024); findings.push({ file: bucket.path, rule: "bucket-size", message: `${bucket.path}: ${kb}KB exceeds ${Math.round(BUCKET_SIZE_WARN_BYTES / 1024)}KB threshold; consider splitting into narrower category buckets`, }); } for (const ref of bucket.staleRefs) { findings.push({ file: bucket.path, rule: "stale-ref", message: `${bucket.path}: stale file ref ${ref}`, }); } for (const ref of bucket.invalidLineRefs) { findings.push({ file: bucket.path, rule: "invalid-line-ref", message: `${bucket.path}: invalid line ref ${ref}`, }); } return findings; } /** * Collect blocking findings for one learning-loop directory. * * Why this re-parses `formatDiagnostic`: shared fact extraction emits one combined diagnostic * string, so this function must recover stable rule ids for CI while leaving empty buckets to warnings. */ function collectFindings(section) { const findings = []; if (!section.exists) { findings.push({ file: section.path, rule: "format", message: `${section.path}: directory missing`, }); return findings; } for (const bucket of section.buckets) { findings.push(...collectBucketFindings(bucket)); } if (section.formatDiagnostic !== null) { const alreadyReported = findings.some((f) => f.rule === "missing-last-reviewed"); for (const piece of section.formatDiagnostic.split("; ")) { if (isEmptyLearningLoopDiagnostic(piece)) continue; if (alreadyReported && /missing frontmatter last_reviewed/.test(piece)) { continue; } if (/invalid last_reviewed format/.test(piece)) { findings.push({ file: section.path, rule: "invalid-last-reviewed", message: piece, }); continue; } findings.push({ file: section.path, rule: "format", message: piece }); } } return findings; } /** Return true for empty-directory diagnostics that should remain advisory warnings. */ function isEmptyLearningLoopDiagnostic(message) { return (message === "Footgun directory exists but contains 0 entries" || message === "Lesson directory exists but contains 0 entries"); } /** Collect advisory learning-loop warnings without converting them into failing findings. */ function collectWarnings(section) { if (section.formatDiagnostic === null) return []; return section.formatDiagnostic .split("; ") .filter(isEmptyLearningLoopDiagnostic) .map((message) => ({ file: section.path, rule: "empty-learning-loop", message, })); } const ADR_FILENAME = /^ADR-\d{3}-[a-z0-9-]+\.md$/; const ROUTING_HINT = "Wrong home -> right home: implementation TODOs and scoped work plans belong in .goat-flow/plans/; recurring hazards with evidence belong in .goat-flow/learning-loop/footguns/; reusable takeaways belong in .goat-flow/learning-loop/lessons/; temporary notes belong in .goat-flow/scratchpad/; backlog requests belong in Linear/GitHub issues."; /** Match a second-level ADR heading exactly enough to avoid prose false positives. */ function hasHeading(content, heading) { return new RegExp(`^##\\s+${heading}\\b`, "m").test(content); } /** Build the routing finding for files that do not follow the ADR filename invariant. */ function decisionFilenameFinding(file) { return { file: file.path, rule: "decision-filename", message: `${file.path}: decision records must be named ADR-NNN-kebab-case-title.md. ${ROUTING_HINT}`, }; } /** Accept one tradeoff section variant so older ADR shapes do not need churn-only rewrites. */ function hasDecisionTradeoffSection(content) { return (hasHeading(content, "Consequences") || hasHeading(content, "Failure Mode Comparison") || hasHeading(content, "Reversibility")); } /** Return the required ADR structure pieces missing from one decision record. */ function missingDecisionStructure(content) { const missing = []; if (!/^\*\*Status:\*\*/m.test(content)) missing.push("**Status:**"); if (!/^\*\*Date:\*\*/m.test(content)) missing.push("**Date:**"); if (!hasHeading(content, "Context")) missing.push("## Context"); if (!hasHeading(content, "Decision")) missing.push("## Decision"); if (!hasDecisionTradeoffSection(content)) { missing.push("## Consequences or ## Failure Mode Comparison or ## Reversibility"); } return missing; } function decisionStructureFinding(file, missing) { return { file: file.path, rule: "decision-structure", message: `${file.path}: malformed ADR is missing ${missing.join(", ")}. ${ROUTING_HINT}`, }; } function collectDecisionFileFinding(file) { if (DECISION_META_FILES.has(file.filename)) return null; if (!ADR_FILENAME.test(file.filename)) return decisionFilenameFinding(file); const missing = missingDecisionStructure(file.content ?? ""); return missing.length > 0 ? decisionStructureFinding(file, missing) : null; } /** Collect structural ADR findings while ignoring the directory README and INDEX. */ function collectDecisionFindings(section) { if (!section.exists) return []; return section.files.flatMap((file) => collectDecisionFileFinding(file) ?? []); } /** Map stale generated indexes to blocking findings (the `index-fresh` check's failure arm). */ function collectIndexFindings(indexes) { return indexes .filter((entry) => entry.state === "stale") .map((entry) => ({ file: entry.indexPath, rule: "index-stale", message: `${entry.indexPath}: generated index is stale; re-run \`goat-flow index\``, })); } /** Map absent generated indexes to advisory warnings so a fresh install never false-fails. */ function collectIndexWarnings(indexes) { return indexes .filter((entry) => entry.state === "missing") .map((entry) => ({ file: entry.indexPath, rule: "index-missing", message: `${entry.indexPath}: INDEX.md not generated yet; run \`goat-flow index\``, })); } /** * Run the `--check` verdict against an already-built stats report. * * @param report Stats report built from the same facts used by normal rendering. * @returns Pass/fail verdict with blocking findings separated from advisory warnings. */ export function checkStats(report) { const findings = [ ...collectFindings(report.footguns), ...collectFindings(report.lessons), ...(report.decisions ? collectDecisionFindings(report.decisions) : []), ...(report.indexes ? collectIndexFindings(report.indexes) : []), ]; const warnings = [ ...collectWarnings(report.footguns), ...collectWarnings(report.lessons), ...(report.decisions?.warnings ?? []), ...(report.indexes ? collectIndexWarnings(report.indexes) : []), ]; return { status: findings.length === 0 ? "pass" : "fail", findings, warnings, }; } //# sourceMappingURL=stats.js.map