@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.
520 lines • 21.3 kB
JavaScript
/**
* Report assembly, enrichment, and audit-cache I/O behind the dashboard's `/api/audit` and quality routes.
*
* Projects raw audit results into the DashboardReport wire shape, layers in compact Home-only
* learning-loop context (cached in-memory by a content signature with a TTL), and reads/writes the
* persisted on-disk audit cache. Also builds the deterministic content signatures used to invalidate
* both caches: the signature must stay stable for identical inputs and change when any tracked file
* changes, or the dashboard serves stale audits. All filesystem reads here swallow missing/unreadable
* inputs into stable sentinels so a partial project still produces a report. Routes live in
* dashboard-audit-routes.ts and dashboard-quality-routes.ts; the wire types in types.ts.
*/
import { createHash } from "node:crypto";
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { AGENT_PROFILE_MAP } from "./dashboard-route-types.js";
import { loadConfig } from "../config/reader.js";
import { createFS } from "../facts/fs.js";
import { extractSharedFacts } from "../facts/shared/index.js";
import { collectIndexFreshness } from "../stats/index-freshness.js";
import { buildStatsReport, checkStats } from "../stats/stats.js";
import { parseBucket, resolveIndexBucketPaths, } from "../learning-loop-index/parse-bucket.js";
import { resolveLocalStatePath } from "./local-paths.js";
/**
* Decide whether to collect per-span audit timings for one request. Profiling is gated on both an
* explicit opt-in and a trust signal so an untrusted caller cannot force the extra timing work.
*
* @param url - the request URL; profiling requires the `profile=true` query parameter
* @param devMode - true when the server runs in dev mode; otherwise the `GOAT_FLOW_AUDIT_PROFILE=1`
* environment flag must be set to allow profiling on a packaged server
* @returns true only when the request opts in AND the server is trusted to expose timings
*/
export function shouldProfileAuditRequest(url, devMode) {
return (url.searchParams.get("profile") === "true" &&
(devMode || process.env["GOAT_FLOW_AUDIT_PROFILE"] === "1"));
}
export function createDashboardAuditProfiler(enabled) {
const spans = [];
return {
enabled,
spans,
span(name, fn) {
if (!enabled)
return fn();
const start = performance.now();
try {
return fn();
}
finally {
spans.push({
name,
durationMs: Number((performance.now() - start).toFixed(3)),
});
}
},
};
}
export function appendAuditProfile(body, profiler) {
if (!profiler.enabled)
return body;
const summedSpanMs = Number(profiler.spans
.reduce((total, span) => total + span.durationMs, 0)
.toFixed(3));
return {
...body,
_profile: {
summedSpanMs,
spans: profiler.spans,
},
};
}
/**
* Project one quality-history entry into the compact Home-card summary, deriving severity counts and
* the distinct evidence methods from its findings so the dashboard never loads the full report.
*
* @param entry - the latest matching history entry, or null when no history matches the filter
* @returns the display summary, or null when entry is null - null means "no quality run to show yet"
* (an expected empty state, not an error)
*/
export function buildLatestQualitySummary(entry) {
if (!entry)
return null;
const findings = entry.report.findings;
return {
id: entry.id,
date: entry.date,
time: entry.time,
agent: entry.agent,
setupTotal: entry.report.scores.setup.total,
systemTotal: entry.report.scores.system.total,
blockerCount: findings.filter((f) => f.severity === "BLOCKER").length,
majorCount: findings.filter((f) => f.severity === "MAJOR").length,
minorCount: findings.filter((f) => f.severity === "MINOR").length,
evidenceMethods: Array.from(new Set(findings.map((f) => f.evidence_method))),
scope: entry.report.scope ?? null,
};
}
/** Return compact learning-loop health for Home without exposing the full stats report. */
function buildDashboardLearningLoopSummary(projectPath) {
try {
const fs = createFS(projectPath);
const configState = loadConfig(projectPath, fs);
const shared = extractSharedFacts(fs, configState);
const indexes = collectIndexFreshness(fs, resolveIndexBucketPaths(configState.config));
const stats = buildStatsReport({
footguns: shared.footguns,
lessons: shared.lessons,
indexes,
});
const check = checkStats(stats);
const staleCount = check.findings.filter((finding) => finding.rule === "stale-last-reviewed" || finding.rule === "stale-ref").length;
const invalidLineRefCount = check.findings.filter((finding) => finding.rule === "invalid-line-ref").length;
const oversizedCount = check.findings.filter((finding) => finding.rule === "bucket-size").length;
const indexStaleCount = indexes.filter((entry) => entry.state === "stale").length;
const indexMissingCount = indexes.filter((entry) => entry.state === "missing").length;
const recordCount = stats.footguns.totalEntries + stats.lessons.totalEntries;
const allBuckets = [...stats.footguns.buckets, ...stats.lessons.buckets];
const reviewedDates = allBuckets
.map((bucket) => bucket.lastReviewed)
.filter((lastReviewed) => lastReviewed !== null)
.sort();
const oldestLastReviewed = reviewedDates[0] ?? null;
const topBucketsNeedingAction = allBuckets
.filter((b) => b.staleRefs.length > 0 ||
b.invalidLineRefs.length > 0 ||
b.sizeBytes > 40_000)
.sort((a, b) => b.staleRefs.length +
b.invalidLineRefs.length -
(a.staleRefs.length + a.invalidLineRefs.length))
.slice(0, 3)
.map((b) => ({
path: b.path,
reason: [
b.staleRefs.length > 0 ? `${b.staleRefs.length} stale refs` : "",
b.invalidLineRefs.length > 0
? `${b.invalidLineRefs.length} invalid line refs`
: "",
b.sizeBytes > 40_000 ? `${Math.round(b.sizeBytes / 1024)}KB` : "",
]
.filter(Boolean)
.join(", "),
}));
const status = !shared.footguns.exists && !shared.lessons.exists
? "unavailable"
: staleCount > 2 ||
invalidLineRefCount > 0 ||
oversizedCount > 0 ||
indexStaleCount > 0
? "needs-review"
: "fresh";
return {
recordCount,
footgunCount: stats.footguns.totalEntries,
lessonCount: stats.lessons.totalEntries,
staleCount,
invalidLineRefCount,
oversizedCount,
indexes: indexes.map((entry) => ({
...entry,
entryCount: parseBucket(fs, entry.dirPath, entry.bucket).length,
})),
indexStaleCount,
indexMissingCount,
oldestLastReviewed,
topBucketsNeedingAction,
status,
};
}
catch {
return null;
}
}
/** List stable markdown lesson buckets; swallows absent lessons directories. */
function listLessonBuckets(lessonsDir) {
try {
return readdirSync(lessonsDir)
.filter((filename) => filename.endsWith(".md") && filename !== "README.md")
.sort();
}
catch {
return [];
}
}
/** Return the created date inside one lesson section, if present. */
function parseLessonCreated(section) {
return section.match(/\*\*Created:\*\*\s*(\d{4}-\d{2}-\d{2})/)?.[1] ?? null;
}
/** Read lesson headings from one bucket file. */
function readLessonBucketEntries(lessonsDir, filename, startOrder) {
let content;
try {
content = readFileSync(join(lessonsDir, filename), "utf-8");
}
catch {
return [];
}
return Array.from(content.matchAll(/^## Lesson:\s+(.+)$/gm)).flatMap((heading, index, headings) => {
const title = heading[1]?.trim();
if (!title)
return [];
const start = heading.index;
const nextHeading = headings[index + 1];
const end = nextHeading === undefined ? content.length : nextHeading.index;
const section = content.slice(start, end);
return [
{
title,
created: parseLessonCreated(section),
path: `.goat-flow/learning-loop/lessons/${filename}`,
order: startOrder + index,
},
];
});
}
/** Sort latest lessons first, with file order as the fallback. */
function sortRecentLessons(lessons) {
return lessons.sort((a, b) => {
if (a.created !== b.created) {
if (a.created === null)
return 1;
if (b.created === null)
return -1;
return b.created.localeCompare(a.created);
}
return b.order - a.order;
});
}
/** Read recent lesson headings for the compact Home panel. */
function readRecentLessons(projectPath) {
const lessonsDir = join(projectPath, ".goat-flow", "learning-loop", "lessons");
const filenames = listLessonBuckets(lessonsDir);
const lessons = [];
for (const filename of filenames) {
lessons.push(...readLessonBucketEntries(lessonsDir, filename, lessons.length));
}
const total = lessons.length;
return sortRecentLessons(lessons)
.slice(0, 4)
.map((lesson, index) => ({
id: `L-${String(total - index).padStart(3, "0")}`,
title: lesson.title,
created: lesson.created,
path: lesson.path,
}));
}
const ENRICHMENT_TTL_MS = 60_000;
// Cap the signature walk at 500 entries because the signature only needs to detect learning-loop
// edits, not fingerprint the whole tree; an unbounded walk would let a large project stall every
// cache-miss audit on directory I/O. Past the limit the walk records a `:truncated` marker and stops.
const DIRECTORY_SIGNATURE_FILE_LIMIT = 500;
const DIRECTORY_SIGNATURE_IGNORES = new Set([".git", "node_modules", "dist"]);
const enrichmentCache = new Map();
/** Hash cache and identity inputs without storing raw remote URLs in keys. */
function hashString(value) {
return createHash("sha256").update(value).digest("hex");
}
/** Hash one cache input file; swallows disappearing files as a stable `missing` sentinel. */
function hashExistingFile(projectPath, relativePath) {
try {
return hashString(readFileSync(join(projectPath, relativePath), "utf-8"));
}
catch {
return "missing";
}
}
function readSignatureStat(projectPath, relativePath) {
try {
return statSync(join(projectPath, relativePath));
}
catch {
return null;
}
}
function appendDirectorySignatureEntry(projectPath, relativeDir, name, entries) {
if (DIRECTORY_SIGNATURE_IGNORES.has(name))
return;
const relativePath = join(relativeDir, name);
const stat = readSignatureStat(projectPath, relativePath);
if (!stat) {
entries.push(`${relativePath}:missing`);
return;
}
if (stat.isDirectory()) {
readDirectorySignatureEntries(projectPath, relativePath, entries);
return;
}
if (!stat.isFile())
return;
entries.push(`${relativePath}:${stat.size}:${stat.mtimeMs}:${hashExistingFile(projectPath, relativePath)}`);
}
function readDirectorySignatureEntries(projectPath, relativeDir, entries) {
if (entries.length >= DIRECTORY_SIGNATURE_FILE_LIMIT)
return;
let names;
try {
names = readdirSync(join(projectPath, relativeDir)).sort();
}
catch {
entries.push(`${relativeDir}:missing`);
return;
}
for (const name of names) {
if (entries.length >= DIRECTORY_SIGNATURE_FILE_LIMIT) {
entries.push(`${relativeDir}:truncated`);
return;
}
appendDirectorySignatureEntry(projectPath, relativeDir, name, entries);
}
}
/** Hash a bounded, deterministic directory snapshot for cache invalidation. */
function directorySignature(projectPath, relativeDir) {
const entries = [];
readDirectorySignatureEntries(projectPath, relativeDir, entries);
return hashString(entries.join("\n"));
}
/** Build the Home enrichment cache key from learning-loop content directories. */
function buildLearningLoopCacheSignature(projectPath) {
return hashString([
directorySignature(projectPath, ".goat-flow/learning-loop/footguns"),
directorySignature(projectPath, ".goat-flow/learning-loop/lessons"),
directorySignature(projectPath, ".goat-flow/learning-loop/patterns"),
directorySignature(projectPath, ".goat-flow/learning-loop/decisions"),
].join("\n"));
}
export function buildAuditCacheSignature(projectPath, packageVersion) {
const contentFiles = [
".goat-flow/config.yaml",
".goat-flow/architecture.md",
".goat-flow/code-map.md",
".goat-flow/glossary.md",
"CLAUDE.md",
"AGENTS.md",
".github/copilot-instructions.md",
".claude/settings.json",
".codex/config.toml",
".codex/hooks.json",
".agents/hooks.json",
".github/hooks/hooks.json",
".goat-flow/hooks/deny-dangerous.sh",
".goat-flow/hooks/gruff-code-quality.sh",
".goat-flow/hooks/post-turn-safety.sh",
".goat-flow/hooks/deny-dangerous/patterns-shell.sh",
".goat-flow/hooks/deny-dangerous/patterns-paths.sh",
".goat-flow/hooks/deny-dangerous/patterns-writes.sh",
".goat-flow/hooks/deny-dangerous/deny-dangerous-self-test.sh",
];
const directoryInputs = [
".claude/skills",
".agents/skills",
".github/skills",
".goat-flow/learning-loop/decisions",
".goat-flow/learning-loop/footguns",
".goat-flow/learning-loop/lessons",
".goat-flow/learning-loop/patterns",
".goat-flow/skill-docs",
".goat-flow/hooks/deny-dangerous",
];
return hashString([
`package:${packageVersion}`,
...contentFiles.map((relativePath) => `${relativePath}:${hashExistingFile(projectPath, relativePath)}`),
...directoryInputs.map((relativeDir) => `${relativeDir}:${directorySignature(projectPath, relativeDir)}`),
].join("\n"));
}
/**
* Attach compact Home-only learning-loop context (loop health plus recent lessons) to a dashboard
* report, served from a per-project in-memory cache keyed by a content signature with a 60s TTL so
* repeated Home loads avoid re-scanning `.goat-flow`. Returns a new report; the input is not mutated.
*
* @param report - the base dashboard report to extend; returned unchanged except for the two added fields
* @param projectPath - absolute project root whose `.goat-flow` learning-loop content is summarised
* @param fresh - when true, bypass the cache and recompute (used right after a fresh audit so the
* first response reflects current content)
* @returns a copy of the report with `learningLoop` and `recentLessons` populated; `learningLoop` is
* null when the loop directories are absent or unreadable
*/
export function enrichDashboardReport(report, projectPath, fresh = false) {
const now = Date.now();
const signature = buildLearningLoopCacheSignature(projectPath);
const cached = enrichmentCache.get(projectPath);
if (!fresh &&
cached &&
cached.signature === signature &&
now - cached.cachedAt < ENRICHMENT_TTL_MS) {
return {
...report,
learningLoop: cached.learningLoop,
recentLessons: cached.recentLessons,
};
}
const learningLoop = buildDashboardLearningLoopSummary(projectPath);
const recentLessons = readRecentLessons(projectPath);
enrichmentCache.set(projectPath, {
learningLoop,
recentLessons,
signature,
cachedAt: now,
});
return { ...report, learningLoop, recentLessons };
}
/**
* Assemble the `/api/audit` DashboardReport from one aggregate audit and the per-agent audits,
* deriving agent cards from per-agent results and overall scopes/status from the aggregate. Always
* runs learning-loop enrichment with fresh=true so a newly built report reflects current content.
*
* @param auditRpt - the aggregate (dashboard-wide) audit supplying scopes, overall status, and target
* @param perAgentAudits - one entry per managed agent, each id paired with that agent's audit report;
* becomes the per-agent `agentScores` cards
* @param projectPath - absolute project root, used for the learning-loop enrichment pass
* @param profiler - optional per-request profiler; when present the enrichment pass is timed as a span
* @returns the fully populated dashboard report ready to serialise to the client
*/
export function buildDashboardReport(auditRpt, perAgentAudits, projectPath, profiler) {
const report = {
agentScores: perAgentAudits.map((pa) => {
const agentId = pa.id;
return {
id: pa.id,
name: AGENT_PROFILE_MAP[agentId].name,
agent: pa.audit.scopes.agent,
harness: pa.audit.scopes.harness,
concerns: pa.audit.concerns,
enforcement: pa.audit.enforcement.find((entry) => entry.agent === pa.id) ?? null,
};
}),
status: auditRpt.status,
scopes: {
setup: auditRpt.scopes.setup,
agent: auditRpt.scopes.agent,
...(auditRpt.scopes.harness ? { harness: auditRpt.scopes.harness } : {}),
},
overall: auditRpt.overall,
learningLoop: null,
recentLessons: [],
target: auditRpt.target,
};
return profiler
? profiler.span("learning-loop enrichment", () => enrichDashboardReport(report, projectPath, true))
: enrichDashboardReport(report, projectPath, true);
}
const AUDIT_CACHE_FILE = "audit-cache.json";
/** Read the local config version; swallows absent configs as cache-miss input. */
function readConfigVersion(projectPath) {
try {
const raw = readFileSync(resolveLocalStatePath(projectPath, "config.yaml"), "utf-8");
const match = raw.match(/^version:\s*["']?([^\s"']+)["']?\s*$/m);
return match?.[1] ?? null;
}
catch {
return null;
}
}
/** Validate persisted cache JSON before trusting it as a dashboard report. */
function isAuditCacheEnvelope(value) {
if (typeof value !== "object" || value === null)
return false;
const envelope = value;
return (typeof envelope.packageVersion === "string" &&
typeof envelope.configVersion === "string" &&
typeof envelope.cachedAt === "string" &&
typeof envelope.signature === "string" &&
typeof envelope.report === "object" &&
envelope.report !== null);
}
/** Parse cached audit JSON; swallows malformed envelopes as a cache miss. */
function parseAuditCacheEnvelope(raw) {
try {
const parsed = JSON.parse(raw);
return isAuditCacheEnvelope(parsed) ? parsed : null;
}
catch {
return null;
}
}
function auditCacheMatches(envelope, projectPath, packageVersion, signature) {
const configVersion = readConfigVersion(projectPath);
return (envelope.packageVersion === packageVersion &&
envelope.signature === signature &&
configVersion !== null &&
envelope.configVersion === configVersion);
}
export function readAuditCache(projectPath, packageVersion, signature) {
try {
const raw = readFileSync(resolveLocalStatePath(projectPath, AUDIT_CACHE_FILE), "utf-8");
const envelope = parseAuditCacheEnvelope(raw);
if (!envelope)
return null;
if (!auditCacheMatches(envelope, projectPath, packageVersion, signature)) {
return null;
}
return {
report: envelope.report,
cachedAt: envelope.cachedAt,
};
}
catch {
return null;
}
}
export function writeAuditCache(projectPath, packageVersion, signature, report) {
try {
const configVersion = readConfigVersion(projectPath);
if (!configVersion)
return;
const envelope = {
packageVersion,
configVersion,
signature,
cachedAt: new Date().toISOString(),
report,
};
writeFileSync(resolveLocalStatePath(projectPath, AUDIT_CACHE_FILE), JSON.stringify(envelope));
}
catch {
// Cache write failure is non-fatal
}
}
export function buildQualityAuditCacheKey(projectPath, agent) {
return `${projectPath}\n${agent}`;
}
//# sourceMappingURL=dashboard-reporting.js.map