@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.
179 lines • 7.51 kB
JavaScript
import { listMarkdownEntries, parseMarkdownFrontmatter, summarizeFootgunRefs, summarizeLessonRefs, } from "./learning-loop-common.js";
import { isDecisionRecordMarkdown } from "./decision-files.js";
import { splitFootgunSections } from "./learning-loop-sections.js";
/** Extract one metadata date from an entry body. */
function extractEntryDate(content, label) {
const match = content.match(new RegExp(`\\*\\*${label}:\\*\\*\\s*(\\d{4}-\\d{2}-\\d{2})`, "i"));
return match?.[1] ?? null;
}
/** Return the first markdown heading, or a stable filename fallback. */
function firstHeadingTitle(content, fallback) {
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
return heading?.length ? heading : fallback;
}
/** Return the last slash-delimited path segment. */
function basename(path) {
const idx = path.lastIndexOf("/");
return idx === -1 ? path : path.slice(idx + 1);
}
/** Strip light markdown syntax and metadata for prompt-safe excerpts. */
function compactEntryExcerpt(content, maxBytes = 900) {
const cleaned = content
.replace(/^##\s+(?:Footgun|Lesson|Pattern):\s+.+$/gm, "")
.replace(/^#\s+.+$/gm, "")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 &&
line !== "---" &&
!/^\*\*Status:\*\*/i.test(line) &&
!/^>/.test(line))
.join(" ")
.replace(/\*\*/g, "")
.replace(/\s+/g, " ")
.trim();
if (Buffer.byteLength(cleaned, "utf8") <= maxBytes)
return cleaned;
let out = "";
for (const char of cleaned) {
const next = out + char;
if (Buffer.byteLength(next + "...", "utf8") > maxBytes)
break;
out = next;
}
return `${out.trimEnd()}...`;
}
/** Date used for deterministic newest-first ranking. */
function entrySortDate(entry) {
return entry.updated ?? entry.created ?? null;
}
/** Split lessons/patterns buckets into individual markdown sections. */
function splitLearningSections(body, defaultKind) {
const headings = Array.from(body.matchAll(/^##\s+(Lesson|Pattern):\s+(.+)$/gm), (match) => ({
kind: (match[1] ?? defaultKind).toLowerCase() === "pattern"
? "pattern"
: defaultKind,
title: (match[2] ?? "").trim(),
start: match.index,
}));
return headings.map((heading, index) => {
const end = headings[index + 1]?.start ?? body.length;
return {
...heading,
content: body.slice(heading.start, end),
};
});
}
/** Build compact entry facts from footgun buckets. */
function extractFootgunEntries(fs, dir, startOrder) {
let order = startOrder;
const entries = [];
for (const file of dir.files) {
const { body } = parseMarkdownFrontmatter(file.content);
const bucketSizeBytes = Buffer.byteLength(file.content, "utf8");
for (const section of splitFootgunSections(body)) {
const refs = summarizeFootgunRefs(fs, section.content);
entries.push({
sourcePath: file.path,
kind: "footgun",
title: section.title,
status: section.status === "active" || section.status === "resolved"
? section.status
: null,
created: extractEntryDate(section.content, "Created"),
updated: extractEntryDate(section.content, "Updated"),
resolved: extractEntryDate(section.content, "Resolved"),
excerpt: compactEntryExcerpt(section.content),
staleRefs: refs.staleRefs,
invalidLineRefs: refs.invalidLineRefs,
hasValidAnchor: refs.validRefs > 0,
bucketSizeBytes,
order: order++,
});
}
}
return entries;
}
/** Build compact entry facts from lessons or patterns buckets. */
function extractLessonLikeEntries(fs, dir, defaultKind, startOrder) {
let order = startOrder;
const entries = [];
for (const file of dir.files) {
const { body } = parseMarkdownFrontmatter(file.content);
const bucketSizeBytes = Buffer.byteLength(file.content, "utf8");
for (const section of splitLearningSections(body, defaultKind)) {
const refs = summarizeLessonRefs(fs, section.content);
entries.push({
sourcePath: file.path,
kind: section.kind,
title: section.title,
status: null,
created: extractEntryDate(section.content, "Created"),
updated: extractEntryDate(section.content, "Updated"),
resolved: extractEntryDate(section.content, "Resolved"),
excerpt: compactEntryExcerpt(section.content),
staleRefs: refs.staleRefs,
invalidLineRefs: refs.invalidLineRefs,
hasValidAnchor: refs.validRefs > 0,
bucketSizeBytes,
order: order++,
});
}
}
return entries;
}
/** Build compact entry facts from ADR files. */
function extractDecisionEntries(dir, startOrder) {
let order = startOrder;
return dir.files
.filter((file) => isDecisionRecordMarkdown(basename(file.path)))
.map((file) => {
const filename = basename(file.path);
return {
sourcePath: file.path,
kind: "decision",
title: firstHeadingTitle(file.content, filename.replace(/\.md$/i, "").replace(/^ADR-\d+-/, "")),
status: null,
created: extractEntryDate(file.content, "Date"),
updated: extractEntryDate(file.content, "Updated"),
resolved: null,
excerpt: compactEntryExcerpt(file.content),
staleRefs: [],
invalidLineRefs: [],
hasValidAnchor: true,
bucketSizeBytes: Buffer.byteLength(file.content, "utf8"),
order: order++,
};
});
}
/**
* Extract compact learning-loop entries for bounded prompt retrieval.
*
* @param fs - filesystem adapter for the target project
* @param configState - loaded config with footgun, lesson, and decision paths
* @returns ordered compact entries suitable for prompt context selection
*/
export function extractLearningLoopEntries(fs, configState) {
const footgunDir = listMarkdownEntries(fs, configState.config.footguns.path);
const lessonDir = listMarkdownEntries(fs, configState.config.lessons.path);
const patternDir = listMarkdownEntries(fs, ".goat-flow/learning-loop/patterns/");
const decisionDir = listMarkdownEntries(fs, configState.config.decisions.path);
const entries = [
...extractFootgunEntries(fs, footgunDir, 0),
...extractLessonLikeEntries(fs, lessonDir, "lesson", 10_000),
...extractLessonLikeEntries(fs, patternDir, "pattern", 20_000),
...extractDecisionEntries(decisionDir, 30_000),
];
return entries.sort((left, right) => {
const kindDiff = left.kind.localeCompare(right.kind);
if (kindDiff !== 0)
return kindDiff;
const dateDiff = (entrySortDate(right) ?? "").localeCompare(entrySortDate(left) ?? "");
if (dateDiff !== 0)
return dateDiff;
const pathDiff = left.sourcePath.localeCompare(right.sourcePath);
if (pathDiff !== 0)
return pathDiff;
return left.order - right.order;
});
}
//# sourceMappingURL=learning-loop-entries.js.map