@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
JavaScript
/**
* 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