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.

494 lines 19.2 kB
/** * Load, classify, and render persisted quality-report history. * * Agents write reports directly to `.goat-flow/logs/quality/<YYYY-MM-DD>-<HHMM>-<agent>-<rand5>.json` * in the agent-shape schema (no `id` field on findings). Positional finding ids * are attached deterministically at load time via `attachFindingIds`, so cross-run * diff/persistence tracking stays stable without trusting the agent's slugging. */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { parseQualityReport } from "./schema.js"; import { attachFindingIds } from "./ids.js"; import { KNOWN_AGENT_IDS } from "../agents/registry.js"; const QUALITY_HISTORY_FILENAME = new RegExp(`^(\\d{4}-\\d{2}-\\d{2})-(\\d{4})-(${KNOWN_AGENT_IDS.join("|")})-([a-z0-9]{5})\\.json$`); /** Return the numeric rank for one finding severity. */ function severityRank(severity) { if (severity === "BLOCKER") return 0; if (severity === "MAJOR") return 1; return 2; } /** Compare diff rows by severity and finding ID. */ function diffRowSort(left, right) { const severityDiff = severityRank(left.severity) - severityRank(right.severity); if (severityDiff !== 0) return severityDiff; return left.id.localeCompare(right.id); } /** Compare history entries in descending recency order. */ function compareEntriesDesc(left, right) { if (left.date !== right.date) return right.date.localeCompare(left.date); if (left.time !== right.time) return right.time.localeCompare(left.time); if (left.agent !== right.agent) return left.agent.localeCompare(right.agent); return right.id.localeCompare(left.id); } /** Return the whole-day gap between two run dates. */ function daysBetween(newerDate, olderDate) { const newer = new Date(`${newerDate}T00:00:00Z`); const older = new Date(`${olderDate}T00:00:00Z`); return Math.round((newer.getTime() - older.getTime()) / 86_400_000); } /** Count findings at one severity level. */ function countSeverity(report, severity) { return report.findings.filter((finding) => finding.severity === severity) .length; } /** Return true when one report belongs to the requested quality mode. * Legacy reports predate quality_mode and are classified as agent-setup, * because that was the only quality workflow at the time. */ function matchesQualityMode(entry, qualityMode) { if (qualityMode === null) return true; return entryQualityMode(entry) === qualityMode; } /** Return the mode used for comparisons, treating legacy reports as agent-setup. */ function entryQualityMode(entry) { return entry.report.quality_mode ?? "agent-setup"; } /** Parse the history filename. */ function parseHistoryFilename(filename) { const match = QUALITY_HISTORY_FILENAME.exec(filename); if (!match) return null; const [, date, time, agent, randomId] = match; if (date === undefined || time === undefined || agent === undefined || randomId === undefined) { return null; } return { date, time, agent: agent, randomId }; } /** Return the quality logs directory path. */ function getQualityLogsDir(projectPath) { return join(projectPath, ".goat-flow", "logs", "quality"); } /** Return quality JSON filenames newest-first; invariant: filename timestamps define recency. */ function listHistoryFilenamesDesc(dir) { return readdirSync(dir) .filter((f) => f.endsWith(".json")) .sort() .reverse(); } /** Parse a filename only if it belongs to the requested agent. */ function parseAgentHistoryFilename(filename, agent) { const parsedName = parseHistoryFilename(filename); if (!parsedName) return null; return parsedName.agent === agent ? parsedName : null; } /** Try to append one parsed history entry to a limited dashboard window. */ function appendMatchingHistoryEntry(entries, warnings, options) { const parsedName = parseAgentHistoryFilename(options.filename, options.agent); if (!parsedName) return false; const { entry, warning } = tryParseHistoryFile(options.dir, options.filename, parsedName); if (warning) warnings.push(warning); if (!entry) return false; if (!matchesQualityMode(entry, options.qualityMode)) return false; entries.push(entry); return true; } /** * Load every saved quality-history report from disk. * * Reports malformed files as warnings and skips them because agent-written * history must be non-blocking. Invariant: returned entries stay newest-first * and use filename-derived ids for stable diff selection. * * @param projectPath - Project root containing `.goat-flow/logs/quality`. * @returns Parsed entries sorted newest-first plus non-fatal parse warnings. */ export function loadQualityHistory(projectPath) { const dir = getQualityLogsDir(projectPath); if (!existsSync(dir)) return { entries: [], warnings: [] }; const entries = []; const warnings = []; for (const filename of readdirSync(dir)) { if (!filename.endsWith(".json")) continue; const parsedName = parseHistoryFilename(filename); if (!parsedName) continue; const fullPath = join(dir, filename); let raw; try { raw = JSON.parse(readFileSync(fullPath, "utf-8")); } catch (error) { warnings.push(`Skipping malformed quality history file ${filename}: ${error instanceof Error ? error.message : String(error)}`); continue; } const parsedReport = parseQualityReport(raw, { requireCurrentFields: false, }); if (!parsedReport.ok) { warnings.push(`Skipping malformed quality history file ${filename}: ${parsedReport.error}`); continue; } const withIds = attachFindingIds(parsedReport.report); if (!withIds.ok) { warnings.push(`Skipping malformed quality history file ${filename}: ${withIds.error}`); continue; } entries.push({ id: filename.replace(/\.json$/, ""), path: fullPath, date: parsedName.date, time: parsedName.time, agent: parsedName.agent, randomId: parsedName.randomId, report: withIds.report, }); } entries.sort(compareEntriesDesc); return { entries, warnings }; } /** * Load only the newest dashboard-sized quality-history window. For selected * agent tables, one extra matching entry is parsed so the oldest displayed row * can still calculate its delta without parsing the whole history directory. * * @param projectPath - Project root containing `.goat-flow/logs/quality`. * @param options - Agent/mode filters and optional dashboard row limit. * @returns Bounded entries sorted newest-first plus non-fatal parse warnings. */ export function loadQualityHistoryWindow(projectPath, options) { if (options.limit === null || options.agent === null) { return loadQualityHistory(projectPath); } const dir = getQualityLogsDir(projectPath); if (!existsSync(dir)) return { entries: [], warnings: [] }; const qualityMode = options.qualityMode ?? null; const entries = []; const warnings = []; const targetEntryCount = options.limit + 1; const filenames = listHistoryFilenamesDesc(dir); for (const filename of filenames) { const appended = appendMatchingHistoryEntry(entries, warnings, { dir, filename, agent: options.agent, qualityMode, }); if (appended && entries.length >= targetEntryCount) break; } return { entries, warnings }; } /** Try to load and validate one history file. Returns the entry or null + a warning. */ function tryParseHistoryFile(dir, filename, parsedName) { const fullPath = join(dir, filename); let raw; try { raw = JSON.parse(readFileSync(fullPath, "utf-8")); } catch (error) { return { entry: null, warning: `Skipping malformed quality history file ${filename}: ${error instanceof Error ? error.message : String(error)}`, }; } const parsedReport = parseQualityReport(raw, { requireCurrentFields: false, }); if (!parsedReport.ok) { return { entry: null, warning: `Skipping malformed quality history file ${filename}: ${parsedReport.error}`, }; } const withIds = attachFindingIds(parsedReport.report); if (!withIds.ok) { return { entry: null, warning: `Skipping malformed quality history file ${filename}: ${withIds.error}`, }; } return { entry: { id: filename.replace(/\.json$/, ""), path: fullPath, date: parsedName.date, time: parsedName.time, agent: parsedName.agent, randomId: parsedName.randomId, report: withIds.report, }, warning: null, }; } /** * Find the latest quality report for one agent/mode without parsing all files. * Scans filenames newest-first, filters by agent from the filename, and parses * only matching JSON until a valid entry is found. * * @param projectPath - Project root containing `.goat-flow/logs/quality`. * @param agent - Agent whose newest report should be found. * @param qualityMode - Optional mode filter; `null` accepts any mode. * @returns Latest valid entry plus warnings for malformed matching files. */ export function findLatestQualityReport(projectPath, agent, qualityMode = null) { const dir = getQualityLogsDir(projectPath); if (!existsSync(dir)) return { entry: null, warnings: [] }; const warnings = []; const filenames = listHistoryFilenamesDesc(dir); for (const filename of filenames) { const parsedName = parseAgentHistoryFilename(filename, agent); if (!parsedName) continue; const { entry, warning } = tryParseHistoryFile(dir, filename, parsedName); if (warning) warnings.push(warning); if (entry && matchesQualityMode(entry, qualityMode)) { return { entry, warnings }; } } return { entry: null, warnings }; } /** * Select visible quality-history entries after agent, mode, and limit filters. * * @param entries - Pre-sorted quality-history entries. * @param options - Filter and limit options from CLI or dashboard callers. * @returns Filtered entries, preserving input order. */ export function selectQualityHistoryEntries(entries, options) { const qualityMode = options.qualityMode ?? null; const filtered = entries.filter((entry) => { if (options.agent && entry.agent !== options.agent) return false; return matchesQualityMode(entry, qualityMode); }); if (options.limit === null) return filtered; return filtered.slice(0, options.limit); } /** * Build display rows with same-agent, same-mode setup deltas. * * @param entries - Pre-sorted quality-history entries. * @param options - Filter and limit options from CLI or dashboard callers. * @returns History table rows, preserving newest-first order. */ export function buildQualityHistoryRows(entries, options) { const filtered = selectQualityHistoryEntries(entries, { agent: options.agent, limit: null, qualityMode: options.qualityMode ?? null, }); const rows = filtered.map((entry, index) => { const entryMode = entryQualityMode(entry); const previousSameAgent = filtered .slice(index + 1) .find((candidate) => candidate.agent === entry.agent && entryQualityMode(candidate) === entryMode); const previousSetup = previousSameAgent?.report.scores.setup.total ?? null; return { id: entry.id, date: entry.report.run_date, agent: entry.agent, qualityMode: entryQualityMode(entry), setupTotal: entry.report.scores.setup.total, systemTotal: entry.report.scores.system.total, setupDelta: previousSetup === null ? null : entry.report.scores.setup.total - previousSetup, blockerCount: countSeverity(entry.report, "BLOCKER"), majorCount: countSeverity(entry.report, "MAJOR"), minorCount: countSeverity(entry.report, "MINOR"), evidenceMethods: Array.from(new Set(entry.report.findings.map((finding) => finding.evidence_method))), }; }); if (options.limit === null) return rows; return rows.slice(0, options.limit); } /** Build a finding map keyed by finding ID. */ function getFindingMap(report) { return new Map(report.findings.map((finding) => [finding.id, finding])); } /** Count consecutive runs that contain one finding. */ function countConsecutivePresence(entries, currentEntry, findingId) { const currentMode = entryQualityMode(currentEntry); const sameAgent = entries.filter((entry) => entry.agent === currentEntry.agent && entryQualityMode(entry) === currentMode); const currentIndex = sameAgent.findIndex((entry) => entry.id === currentEntry.id); if (currentIndex === -1) return 0; let count = 0; let previousEntry; for (let index = currentIndex; index < sameAgent.length; index += 1) { const entry = sameAgent[index]; if (entry === undefined) break; if (previousEntry !== undefined) { if (daysBetween(previousEntry.report.run_date, entry.report.run_date) > 30) { break; } } const hasFinding = entry.report.findings.some((finding) => finding.id === findingId); if (!hasFinding) break; count += 1; previousEntry = entry; } return count; } /** Build the diff between two quality-history runs. */ // eslint-disable-next-line complexity -- intentional because diff selection branches on implicit latest-vs-explicit pair resolution and validation before the shared comparison path. export function buildQualityDiff(entries, options) { const qualityMode = options.qualityMode ?? null; let sourceEntry; let targetEntry; if (options.pair) { const [fromId, toId, ...rest] = options.pair.split(":"); if (!fromId || !toId || rest.length > 0) { return { ok: false, error: "quality diff pair must be in the form <from-id>:<to-id>", }; } sourceEntry = entries.find((entry) => entry.id === fromId); targetEntry = entries.find((entry) => entry.id === toId); if (!sourceEntry || !targetEntry) { return { ok: false, error: "quality diff pair must reference existing saved report ids", }; } if (sourceEntry.agent !== targetEntry.agent) { return { ok: false, error: "quality diff rejects cross-agent comparisons", }; } if (options.agent && sourceEntry.agent !== options.agent) { return { ok: false, error: `quality diff pair does not match --agent ${options.agent}`, }; } if (entryQualityMode(sourceEntry) !== entryQualityMode(targetEntry)) { return { ok: false, error: "quality diff rejects cross-mode comparisons", }; } if (qualityMode !== null && (entryQualityMode(sourceEntry) !== qualityMode || entryQualityMode(targetEntry) !== qualityMode)) { return { ok: false, error: `quality diff pair does not match --mode ${qualityMode}`, }; } } else { if (!options.agent) { return { ok: false, error: "quality diff without explicit ids requires --agent", }; } const sameAgent = entries.filter((entry) => entry.agent === options.agent && matchesQualityMode(entry, qualityMode)); if (sameAgent.length < 2) { const modeScope = qualityMode === null ? "" : ` in ${qualityMode} mode`; return { ok: false, error: `Not enough saved quality reports for ${options.agent}${modeScope}. Need at least 2 runs.`, }; } const latest = sameAgent[0]; const previous = sameAgent[1]; if (!latest || !previous) { return { ok: false, error: "quality diff could not resolve the requested report pair", }; } targetEntry = latest; sourceEntry = previous; if (qualityMode === null && entryQualityMode(sourceEntry) !== entryQualityMode(targetEntry)) { return { ok: false, error: `quality diff would compare ${entryQualityMode(sourceEntry)} to ${entryQualityMode(targetEntry)}. Pass --mode to diff one quality mode, or pass explicit same-mode report ids.`, }; } } const fromMap = getFindingMap(sourceEntry.report); const toMap = getFindingMap(targetEntry.report); const resolved = [...fromMap.values()] .filter((finding) => !toMap.has(finding.id)) .map((finding) => ({ id: finding.id, severity: finding.severity, type: finding.type, summary: finding.summary, })) .sort(diffRowSort); const persisted = [...toMap.values()] .filter((finding) => fromMap.has(finding.id)) .map((finding) => ({ id: finding.id, severity: finding.severity, type: finding.type, summary: finding.summary, })) .sort(diffRowSort); const newFindings = [...toMap.values()] .filter((finding) => !fromMap.has(finding.id)) .map((finding) => ({ id: finding.id, severity: finding.severity, type: finding.type, summary: finding.summary, })) .sort(diffRowSort); const stuck = persisted .filter((finding) => { if (!["BLOCKER", "MAJOR"].includes(finding.severity)) return false; return countConsecutivePresence(entries, targetEntry, finding.id) >= 3; }) .sort(diffRowSort); return { ok: true, diff: { from: sourceEntry, to: targetEntry, setupDelta: targetEntry.report.scores.setup.total - sourceEntry.report.scores.setup.total, systemDelta: targetEntry.report.scores.system.total - sourceEntry.report.scores.system.total, resolved, newFindings, persisted, stuck, }, }; } export { renderQualityDiffText, renderQualityHistoryText, } from "./history-render.js"; //# sourceMappingURL=history.js.map