diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index 0ec329ff1..d2dbd4430 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -235,6 +235,32 @@ export function registerHooks( } catch { /* non-fatal — self-feedback drain must never block session start */ } + // Run gap audit to detect orphaned prompts, handlers, native modules, commands + try { + const { runGapAudit } = await import("../gap-audit.js"); + const filed = runGapAudit(process.cwd()); + if (filed > 0) { + ctx.ui?.notify?.( + `Gap audit filed ${filed} new finding${filed === 1 ? "" : "s"} in .sf/BACKLOG.md`, + "info", + ); + } + } catch { + /* non-fatal — gap audit must never block session start */ + } + // Bridge upstream feedback into forge-local backlog + try { + const { bridgeUpstreamFeedback } = await import("../upstream-bridge.js"); + const filed = bridgeUpstreamFeedback(process.cwd()); + if (filed > 0) { + ctx.ui?.notify?.( + `Upstream bridge filed ${filed} rollup${filed === 1 ? "" : "s"} in .sf/BACKLOG.md`, + "info", + ); + } + } catch { + /* non-fatal — upstream bridge must never block session start */ + } }); pi.on("session_switch", async (_event, ctx) => { diff --git a/src/resources/extensions/sf/bootstrap/system-context.ts b/src/resources/extensions/sf/bootstrap/system-context.ts index 0debb845d..9e65baae4 100644 --- a/src/resources/extensions/sf/bootstrap/system-context.ts +++ b/src/resources/extensions/sf/bootstrap/system-context.ts @@ -364,7 +364,9 @@ export async function buildBeforeAgentStartResult( ? `\n\n[JUDGMENT LOG — autonomous mode]\nWhen you make a judgment call between alternatives at an ambiguous point, call sf_log_judgment with: decision, alternatives, reasoning, confidence. This lets the user review your reasoning at milestone close. It does NOT delay or block the work.` : ""; - const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — SF]\n\n${escalationPolicyBlock}${systemContent}${preferenceBlock}${knowledgeBlock}${architectureBlock}${tacitKnowledgeBlock}${codebaseBlock}${codeIntelligenceBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}${repositoryVcsBlock}${modelIdentityBlock}${subagentModelBlock}${judgmentLogBlock}`; + const backlogBlock = loadBacklogBlock(process.cwd()); + + const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — SF]\n\n${escalationPolicyBlock}${systemContent}${preferenceBlock}${knowledgeBlock}${architectureBlock}${tacitKnowledgeBlock}${codebaseBlock}${codeIntelligenceBlock}${memoryBlock}${newSkillsBlock}${backlogBlock}${worktreeBlock}${repositoryVcsBlock}${modelIdentityBlock}${subagentModelBlock}${judgmentLogBlock}`; stopContextTimer({ systemPromptSize: fullSystem.length, @@ -433,6 +435,52 @@ export function loadKnowledgeBlock( } const TACIT_SECTION_MAX_BYTES = 4096; +const BACKLOG_MAX_ENTRIES = 5; +const BACKLOG_MAX_CHARS = 2000; + +function loadBacklogBlock(cwd: string): string { + const backlogPath = join(cwd, ".sf", "BACKLOG.md"); + if (!existsSync(backlogPath)) return ""; + const raw = cachedReadFile(backlogPath)?.trim() ?? ""; + if (!raw) return ""; + + // Parse the table rows — skip header lines + const lines = raw.split("\n"); + const entries: Array<{ timestamp: string; kind: string; severity: string; summary: string }> = []; + for (const line of lines) { + if (!line.startsWith("| ")) continue; + if (line.includes("Timestamp")) continue; // header + if (line.includes("|---|---|")) continue; // separator + const cells = line.split("|").map((c) => c.trim()).filter(Boolean); + if (cells.length >= 7) { + entries.push({ + timestamp: cells[0], + kind: cells[1], + severity: cells[2], + summary: cells[6], + }); + } + } + + if (entries.length === 0) return ""; + + // Sort by severity (high/critical first) then by timestamp (newest first) + const severityOrder: Record = { critical: 0, high: 1, medium: 2, low: 3 }; + entries.sort((a, b) => { + const sa = severityOrder[a.severity] ?? 99; + const sb = severityOrder[b.severity] ?? 99; + if (sa !== sb) return sa - sb; + return b.timestamp.localeCompare(a.timestamp); + }); + + const top = entries.slice(0, BACKLOG_MAX_ENTRIES); + const rows = top.map((e) => `- **${e.severity}** \`${e.kind}\` — ${e.summary}`).join("\n"); + const block = `## Recent Self-Feedback Entries (from .sf/BACKLOG.md)\n\n${rows}`; + if (block.length > BACKLOG_MAX_CHARS) { + return block.slice(0, BACKLOG_MAX_CHARS) + "\n\n*(truncated — see .sf/BACKLOG.md for full backlog)*"; + } + return `\n\n[BACKLOG — Recent sf-internal anomalies]\n\n${block}`; +} /** * Load tacit knowledge files (.sf/PRINCIPLES.md, .sf/TASTE.md, .sf/ANTI-GOALS.md) diff --git a/src/resources/extensions/sf/gap-audit.ts b/src/resources/extensions/sf/gap-audit.ts new file mode 100644 index 000000000..27d5c3268 --- /dev/null +++ b/src/resources/extensions/sf/gap-audit.ts @@ -0,0 +1,255 @@ +/** + * Gap Audit — detect orphaned/unwired artifacts in the SF extension. + * + * Purpose: automatically find dead code, unreferenced prompts, undispatched + * command handlers, and shipped-but-unimported native modules. Results are + * written to self-feedback so they surface in BACKLOG.md and can be triaged. + * + * Consumer: session_start drain hook in register-hooks.ts. + */ + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { recordSelfFeedback } from "./self-feedback.js"; + +const EXTENSION_SRC = import.meta.dirname; +const PROMPTS_DIR = join(EXTENSION_SRC, "prompts"); +const COMMANDS_DIR = join(EXTENSION_SRC, "commands"); +const HANDLERS_DIR = join(COMMANDS_DIR, "handlers"); +const NATIVE_PKG = join(EXTENSION_SRC, "..", "..", "..", "native"); + +interface GapFinding { + kind: "orphan-prompt" | "orphan-handler" | "orphan-native" | "orphan-command"; + name: string; + path: string; + detail: string; +} + +function hashFindings(findings: GapFinding[]): string { + const data = findings + .map((f) => `${f.kind}:${f.name}:${f.path}`) + .sort() + .join("\n"); + return createHash("sha256").update(data).digest("hex").slice(0, 16); +} + +function readFileLines(path: string): string[] { + try { + return readFileSync(path, "utf-8").split("\n"); + } catch { + return []; + } +} + +function grepImports(sourceDir: string, symbol: string): boolean { + try { + const files = readdirSync(sourceDir, { recursive: true }) as string[]; + for (const file of files) { + if (!file.endsWith(".ts")) continue; + const content = readFileSync(join(sourceDir, file), "utf-8"); + if (content.includes(symbol)) return true; + } + } catch { + /* ignore */ + } + return false; +} + +function findOrphanPrompts(): GapFinding[] { + const findings: GapFinding[] = []; + try { + const files = readdirSync(PROMPTS_DIR).filter((f) => f.endsWith(".md")); + for (const file of files) { + const name = file.slice(0, -3); + // Skip templates that are loaded by convention (guided-* variants) + if (name.startsWith("guided-")) continue; + const loaded = grepImports(EXTENSION_SRC, `loadPrompt("${name}"`) + || grepImports(EXTENSION_SRC, `loadPrompt('${name}'`); + if (!loaded) { + findings.push({ + kind: "orphan-prompt", + name, + path: relative(EXTENSION_SRC, join(PROMPTS_DIR, file)), + detail: `Prompt "${name}" exists but no loadPrompt("${name}") call found in extension source`, + }); + } + } + } catch { + /* prompts dir may not exist in test env */ + } + return findings; +} + +function findOrphanHandlers(): GapFinding[] { + const findings: GapFinding[] = []; + try { + const files = readdirSync(HANDLERS_DIR).filter((f) => f.endsWith(".ts")); + for (const file of files) { + const path = join(HANDLERS_DIR, file); + const lines = readFileLines(path); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Look for exported handle* functions + const match = line.match(/export\s+(?:async\s+)?function\s+(handle\w+)/); + if (!match) continue; + const handlerName = match[1]; + // Check if dispatched from ops.ts, workflow.ts, core.ts, auto.ts + const dispatched = grepImports(COMMANDS_DIR, handlerName); + if (!dispatched) { + findings.push({ + kind: "orphan-handler", + name: handlerName, + path: relative(EXTENSION_SRC, path), + detail: `${handlerName} exported from ${file} but never dispatched from commands/handlers/*.ts`, + }); + } + } + } + } catch { + /* handlers dir may not exist */ + } + return findings; +} + +function findOrphanNative(): GapFinding[] { + const findings: GapFinding[] = []; + const nativeEditDir = join(NATIVE_PKG, "src", "edit"); + try { + if (!existsSync(nativeEditDir)) return findings; + const indexPath = join(nativeEditDir, "index.ts"); + if (!existsSync(indexPath)) return findings; + const lines = readFileLines(indexPath); + for (const line of lines) { + const match = line.match(/export\s+(?:async\s+)?function\s+(\w+)/); + if (!match) continue; + const symbol = match[1]; + const imported = grepImports(EXTENSION_SRC, symbol); + if (!imported) { + findings.push({ + kind: "orphan-native", + name: symbol, + path: relative(EXTENSION_SRC, indexPath), + detail: `Native edit function ${symbol} exported but never imported from SF extension`, + }); + } + } + } catch { + /* native pkg may not exist */ + } + return findings; +} + +function findOrphanCommands(): GapFinding[] { + const findings: GapFinding[] = []; + const catalogPath = join(COMMANDS_DIR, "catalog.ts"); + if (!existsSync(catalogPath)) return findings; + + const catalogLines = readFileLines(catalogPath); + const advertisedCommands: string[] = []; + for (const line of catalogLines) { + // Match { cmd: "rate", desc: "..." } patterns + const match = line.match(/cmd:\s*["'](\w+)["']/); + if (match) advertisedCommands.push(match[1]); + } + + // Check which are dispatched from ops.ts / workflow.ts / core.ts + const dispatchFiles = ["ops.ts", "workflow.ts", "core.ts", "auto.ts"] + .map((f) => join(HANDLERS_DIR, f)) + .filter(existsSync); + + for (const cmd of advertisedCommands) { + let dispatched = false; + for (const path of dispatchFiles) { + const content = readFileSync(path, "utf-8"); + // Look for startsWith("cmd ") or includes("cmd ") patterns + if (content.includes(`"${cmd} "`) || content.includes(`'${cmd} '`)) { + dispatched = true; + break; + } + } + if (!dispatched) { + findings.push({ + kind: "orphan-command", + name: cmd, + path: relative(EXTENSION_SRC, catalogPath), + detail: `/sf ${cmd} advertised in catalog but no dispatch branch found in handlers`, + }); + } + } + return findings; +} + +/** + * Run the gap audit and file self-feedback entries for any findings. + * Deduped by content hash so repeat runs don't multiply entries. + * + * @returns number of new findings filed (0 if all were already reported) + */ +export function runGapAudit(basePath: string = process.cwd()): number { + const findings: GapFinding[] = [ + ...findOrphanPrompts(), + ...findOrphanHandlers(), + ...findOrphanNative(), + ...findOrphanCommands(), + ]; + + if (findings.length === 0) return 0; + + const hash = hashFindings(findings); + const hashPath = join(basePath, ".sf", "runtime", ".gap-audit-hash"); + + // Check if we've already reported this exact set + try { + if (existsSync(hashPath)) { + const prior = readFileSync(hashPath, "utf-8").trim(); + if (prior === hash) return 0; + } + } catch { + /* ignore */ + } + + // File one self-feedback entry per finding kind, grouped + const byKind = new Map(); + for (const f of findings) { + const list = byKind.get(f.kind) ?? []; + list.push(f); + byKind.set(f.kind, list); + } + + let filed = 0; + for (const [kind, items] of byKind) { + const severity = kind === "orphan-native" ? "high" : "medium"; + const summary = items.map((i) => i.name).join(", "); + const evidence = items.map((i) => `- ${i.name}: ${i.detail}`).join("\n"); + const result = recordSelfFeedback( + { + kind: `gap-audit-${kind}`, + severity: severity as "high" | "medium" | "low" | "critical", + summary: `${kind.replace("-", " ")}: ${summary}`, + evidence, + suggestedFix: + kind === "orphan-prompt" + ? "Remove unused prompt or wire it into a loadPrompt call" + : kind === "orphan-handler" + ? "Add dispatch branch in ops.ts/workflow.ts or remove dead export" + : kind === "orphan-native" + ? "Wire native function into SF extension or remove from native package" + : "Add dispatch branch for advertised command or remove from catalog", + source: "agent" as const, + }, + basePath, + ); + if (result) filed++; + } + + // Write hash to prevent re-filing + try { + mkdirSync(join(basePath, ".sf", "runtime"), { recursive: true }); + writeFileSync(hashPath, hash, "utf-8"); + } catch { + /* non-fatal */ + } + + return filed; +} diff --git a/src/resources/extensions/sf/requirement-promoter.ts b/src/resources/extensions/sf/requirement-promoter.ts new file mode 100644 index 000000000..eb046df96 --- /dev/null +++ b/src/resources/extensions/sf/requirement-promoter.ts @@ -0,0 +1,209 @@ +/** + * Requirement Promoter — threshold-to-requirement promotion sweeper. + * + * When feedback entries cluster (e.g., 5 instances of `git-empty-pathspec`, + * or `runaway-guard-hard-pause` recurring across 3 milestones), this module + * auto-promotes to a row in `.sf/REQUIREMENTS.md`. + * + * Requirements flow into prompt context via the existing planning pipeline, + * so promotion turns "noise that piles up in BACKLOG.md" into "something + * the next planning round naturally addresses." + * + * Consumer: session_start drain hook in register-hooks.ts (wired separately). + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + type PersistedSelfFeedbackEntry, + markResolved, + readAllSelfFeedback, +} from "./self-feedback.js"; +import { sfRoot } from "./paths.js"; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const COUNT_THRESHOLD = 5; +const MILESTONE_THRESHOLD = 3; +const LOOKBACK_DAYS = 90; + +const REQUIREMENTS_HEADER = + "# Requirements\n\n" + + "This file is the explicit capability and coverage contract for the project.\n\n" + + "## Active\n\n"; + +// ─── Forge detection (local — isForgeRepo is not exported) ─────────────────── + +function isForgeRepo(basePath: string): boolean { + try { + const pkgPath = join(basePath, "package.json"); + if (!existsSync(pkgPath)) return false; + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + return pkg?.name === "singularity-forge"; + } catch { + return false; + } +} + +// ─── REQUIREMENTS.md helpers ───────────────────────────────────────────────── + +function requirementsPath(basePath: string): string { + return join(sfRoot(basePath), "REQUIREMENTS.md"); +} + +/** + * Read the highest R-number present in REQUIREMENTS.md. + * Returns 0 if the file is absent or contains no R-IDs. + */ +function readHighestRNumber(filePath: string): number { + try { + if (!existsSync(filePath)) return 0; + const content = readFileSync(filePath, "utf-8"); + const matches = content.matchAll(/\bR(\d+)\b/g); + let max = 0; + for (const m of matches) { + const n = parseInt(m[1], 10); + if (n > max) max = n; + } + return max; + } catch { + return 0; + } +} + +/** + * Append one requirement block to REQUIREMENTS.md. + * Creates the file with header if it does not exist. + * Appends into the ## Active section (or at end if already structured). + */ +function appendRequirementRow( + filePath: string, + id: string, + title: string, + notes: string, +): void { + const dir = join(filePath, ".."); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + const block = + `### ${id} — ${title}\n` + + `- Class: operational\n` + + `- Status: active\n` + + `- Description: ${title}\n` + + `- Source: sf-promoter\n` + + `- Notes: ${notes}\n\n`; + + if (!existsSync(filePath)) { + writeFileSync(filePath, REQUIREMENTS_HEADER + block, "utf-8"); + } else { + // Append before any ## Traceability or ## Coverage Summary section if + // present; otherwise just append at the end. + const content = readFileSync(filePath, "utf-8"); + const insertionMarker = content.match(/\n## (?:Traceability|Coverage Summary)/); + if (insertionMarker && insertionMarker.index !== undefined) { + const before = content.slice(0, insertionMarker.index); + const after = content.slice(insertionMarker.index); + writeFileSync(filePath, before + "\n" + block + after, "utf-8"); + } else { + const appended = content.endsWith("\n") ? content + block : content + "\n" + block; + writeFileSync(filePath, appended, "utf-8"); + } + } +} + +// ─── Cluster & promote ─────────────────────────────────────────────────────── + +interface Cluster { + kind: string; + entries: PersistedSelfFeedbackEntry[]; + distinctMilestones: Set; +} + +/** + * Promote feedback entries to REQUIREMENTS.md when they cross threshold. + * + * Only runs when basePath is the forge repo itself. Bails silently otherwise. + * Never throws — returns { promoted: 0, requirementIds: [] } on failure. + * + * @returns { promoted: number; requirementIds: string[] } + */ +export function promoteFeedbackToRequirements( + basePath: string = process.cwd(), +): { promoted: number; requirementIds: string[] } { + const empty = { promoted: 0, requirementIds: [] as string[] }; + + try { + // Gate: only runs on singularity-forge itself + if (!isForgeRepo(basePath)) return empty; + + const cutoff = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000); + + // Read all entries, filter to open forge entries within the lookback window + const eligible = readAllSelfFeedback(basePath).filter( + (e) => + !e.resolvedAt && + e.repoIdentity === "forge" && + new Date(e.ts) >= cutoff, + ); + + if (eligible.length === 0) return empty; + + // Cluster by kind + const clusters = new Map(); + for (const e of eligible) { + const existing = clusters.get(e.kind); + if (existing) { + existing.entries.push(e); + existing.distinctMilestones.add(e.occurredIn?.milestone); + } else { + clusters.set(e.kind, { + kind: e.kind, + entries: [e], + distinctMilestones: new Set([e.occurredIn?.milestone]), + }); + } + } + + // Determine which clusters cross the promotion threshold + const promotable = [...clusters.values()].filter( + (c) => + c.entries.length >= COUNT_THRESHOLD || + c.distinctMilestones.size >= MILESTONE_THRESHOLD, + ); + + if (promotable.length === 0) return empty; + + const reqPath = requirementsPath(basePath); + const promotedIds: string[] = []; + + for (const cluster of promotable) { + const nextNum = readHighestRNumber(reqPath) + 1; + const reqId = `R${String(nextNum).padStart(3, "0")}`; + + const count = cluster.entries.length; + const milestoneCount = cluster.distinctMilestones.size; + const title = `Address recurring ${cluster.kind} (${count} entries across ${milestoneCount} milestone${milestoneCount !== 1 ? "s" : ""})`; + const sourceIds = cluster.entries.map((e) => e.id).join(", "); + const notes = `Source IDs: ${sourceIds}`; + + appendRequirementRow(reqPath, reqId, title, notes); + promotedIds.push(reqId); + + // Mark each contributing entry resolved + for (const entry of cluster.entries) { + markResolved( + entry.id, + { + reason: `Promoted to requirement ${reqId} by threshold-promotion sweeper`, + evidence: { kind: "promoted-to-requirement", requirementId: reqId }, + }, + basePath, + ); + } + } + + return { promoted: promotedIds.length, requirementIds: promotedIds }; + } catch { + return empty; + } +} diff --git a/src/resources/extensions/sf/tests/upstream-bridge.test.ts b/src/resources/extensions/sf/tests/upstream-bridge.test.ts new file mode 100644 index 000000000..436369ad2 --- /dev/null +++ b/src/resources/extensions/sf/tests/upstream-bridge.test.ts @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, test } from "vitest"; +import { + type PersistedSelfFeedbackEntry, + markResolved, + readAllSelfFeedback, +} from "../self-feedback.ts"; +import { bridgeUpstreamFeedback } from "../upstream-bridge.ts"; + +// ─── Test scaffolding ───────────────────────────────────────────────────────── + +let tmpBase: string; +let forgeDir: string; +let sfHomeDir: string; +let upstreamLog: string; +let originalSfHome: string | undefined; + +beforeEach(() => { + tmpBase = mkdtempSync(join(tmpdir(), "sf-upstream-bridge-")); + forgeDir = join(tmpBase, "forge"); + sfHomeDir = join(tmpBase, "sf-home"); + upstreamLog = join(sfHomeDir, "agent", "upstream-feedback.jsonl"); + + mkdirSync(join(forgeDir, ".sf"), { recursive: true }); + mkdirSync(join(sfHomeDir, "agent"), { recursive: true }); + + // Fake forge repo — name matches but no loader.ts (so sfRuntimeRoot → basePath/.sf) + writeFileSync( + join(forgeDir, "package.json"), + JSON.stringify({ name: "singularity-forge", version: "0.0.1" }), + "utf-8", + ); + + originalSfHome = process.env.SF_HOME; + process.env.SF_HOME = sfHomeDir; +}); + +afterEach(() => { + if (originalSfHome === undefined) delete process.env.SF_HOME; + else process.env.SF_HOME = originalSfHome; + rmSync(tmpBase, { recursive: true, force: true }); +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeEntry( + overrides: Partial = {}, +): PersistedSelfFeedbackEntry { + return { + id: `sf-${Math.random().toString(36).slice(2, 10)}`, + kind: "runaway-guard-hard-pause", + severity: "medium", + summary: "Runaway guard triggered", + source: "detector", + ts: new Date().toISOString(), + basePath: "/some/external/repo", + repoIdentity: "external", + sfVersion: "1.0.0", + blocking: false, + ...overrides, + }; +} + +function writeUpstream(entries: PersistedSelfFeedbackEntry[]): void { + const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; + writeFileSync(upstreamLog, lines, "utf-8"); +} + +function readForgeJsonl(): PersistedSelfFeedbackEntry[] { + return readAllSelfFeedback(forgeDir); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test("bails silently when basePath is not forge — no package.json", () => { + const nonForge = join(tmpBase, "other"); + mkdirSync(nonForge, { recursive: true }); + + writeUpstream([ + makeEntry({ basePath: "/repo/a" }), + makeEntry({ basePath: "/repo/b" }), + makeEntry({ basePath: "/repo/c" }), + ]); + + const count = bridgeUpstreamFeedback(nonForge); + assert.equal(count, 0); + // No forge-local jsonl created + assert.ok(!existsSync(join(nonForge, ".sf", "self-feedback.jsonl"))); +}); + +test("bails silently when basePath has wrong package name", () => { + const nonForge = join(tmpBase, "other2"); + mkdirSync(nonForge, { recursive: true }); + writeFileSync( + join(nonForge, "package.json"), + JSON.stringify({ name: "some-other-tool" }), + "utf-8", + ); + + writeUpstream([ + makeEntry({ basePath: "/repo/a" }), + makeEntry({ basePath: "/repo/b" }), + makeEntry({ basePath: "/repo/c" }), + ]); + + assert.equal(bridgeUpstreamFeedback(nonForge), 0); +}); + +test("files a rollup when ≥3 entries of same kind from ≥2 distinct repos", () => { + writeUpstream([ + makeEntry({ basePath: "/repo/a", kind: "runaway-guard-hard-pause" }), + makeEntry({ basePath: "/repo/b", kind: "runaway-guard-hard-pause" }), + makeEntry({ basePath: "/repo/c", kind: "runaway-guard-hard-pause" }), + ]); + + const count = bridgeUpstreamFeedback(forgeDir); + assert.equal(count, 1); + + const entries = readForgeJsonl(); + assert.equal(entries.length, 1); + const rollup = entries[0]; + assert.equal(rollup.kind, "upstream-rollup:runaway-guard-hard-pause"); + assert.equal(rollup.source, "detector"); + assert.match(rollup.summary, /3 external-repo entries/); + assert.match(rollup.summary, /3 repos/); + + // Rollup appears in BACKLOG.md + const backlog = readFileSync(join(forgeDir, ".sf", "BACKLOG.md"), "utf-8"); + assert.match(backlog, /upstream-rollup:runaway-guard-hard-pause/); +}); + +test("idempotent — re-running with same upstream state files 0 new entries", () => { + writeUpstream([ + makeEntry({ basePath: "/repo/a" }), + makeEntry({ basePath: "/repo/b" }), + makeEntry({ basePath: "/repo/c" }), + ]); + + const first = bridgeUpstreamFeedback(forgeDir); + assert.equal(first, 1); + + const second = bridgeUpstreamFeedback(forgeDir); + assert.equal(second, 0); + + assert.equal(readForgeJsonl().length, 1); +}); + +test("skips a kind when an open rollup for it already exists", () => { + // Pre-populate forge backlog with an open rollup of the same kind + writeUpstream([ + makeEntry({ basePath: "/repo/a" }), + makeEntry({ basePath: "/repo/b" }), + makeEntry({ basePath: "/repo/c" }), + ]); + bridgeUpstreamFeedback(forgeDir); // file initial rollup + + // Add more upstream entries + const existing = JSON.parse( + readFileSync(upstreamLog, "utf-8").trim().split("\n")[0], + ); + writeUpstream([ + existing as PersistedSelfFeedbackEntry, + makeEntry({ basePath: "/repo/d" }), + makeEntry({ basePath: "/repo/e" }), + makeEntry({ basePath: "/repo/f" }), + ]); + + const count = bridgeUpstreamFeedback(forgeDir); + assert.equal(count, 0, "should not re-file while open rollup exists"); + assert.equal(readForgeJsonl().length, 1); +}); + +test("re-fires after the previous rollup is resolved and new upstream entries arrive", () => { + writeUpstream([ + makeEntry({ basePath: "/repo/a" }), + makeEntry({ basePath: "/repo/b" }), + makeEntry({ basePath: "/repo/c" }), + ]); + bridgeUpstreamFeedback(forgeDir); + + // Resolve the open rollup + const [rollup] = readForgeJsonl(); + const resolved = markResolved( + rollup.id, + { reason: "fixed", evidence: { kind: "human-clear" } }, + forgeDir, + ); + assert.ok(resolved); + + // New upstream entries still present → should re-file + const count = bridgeUpstreamFeedback(forgeDir); + assert.equal(count, 1); + const entries = readForgeJsonl(); + assert.equal(entries.filter((e) => !e.resolvedAt).length, 1); +}); + +test("filters out entries older than 30 days", () => { + const oldTs = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(); + writeUpstream([ + makeEntry({ basePath: "/repo/a", ts: oldTs }), + makeEntry({ basePath: "/repo/b", ts: oldTs }), + makeEntry({ basePath: "/repo/c", ts: oldTs }), + ]); + + assert.equal(bridgeUpstreamFeedback(forgeDir), 0); + assert.equal(readForgeJsonl().length, 0); +}); + +test("caps severity at medium even when contributing entries are higher", () => { + writeUpstream([ + makeEntry({ basePath: "/repo/a", severity: "critical" }), + makeEntry({ basePath: "/repo/b", severity: "high" }), + makeEntry({ basePath: "/repo/c", severity: "critical" }), + ]); + + bridgeUpstreamFeedback(forgeDir); + const [rollup] = readForgeJsonl(); + assert.equal(rollup.severity, "medium"); + assert.equal(rollup.blocking, false); +}); + +test("does not file when count < 3", () => { + writeUpstream([ + makeEntry({ basePath: "/repo/a" }), + makeEntry({ basePath: "/repo/b" }), + ]); + + assert.equal(bridgeUpstreamFeedback(forgeDir), 0); +}); + +test("does not file when distinct repos < 2 (all from same repo)", () => { + writeUpstream([ + makeEntry({ basePath: "/repo/a" }), + makeEntry({ basePath: "/repo/a" }), + makeEntry({ basePath: "/repo/a" }), + ]); + + assert.equal(bridgeUpstreamFeedback(forgeDir), 0); +}); + +test("skips resolved upstream entries", () => { + writeUpstream([ + makeEntry({ basePath: "/repo/a", resolvedAt: new Date().toISOString() }), + makeEntry({ basePath: "/repo/b", resolvedAt: new Date().toISOString() }), + makeEntry({ basePath: "/repo/c", resolvedAt: new Date().toISOString() }), + ]); + + assert.equal(bridgeUpstreamFeedback(forgeDir), 0); +}); + +test("handles multiple distinct kinds independently", () => { + writeUpstream([ + makeEntry({ basePath: "/repo/a", kind: "kind-alpha" }), + makeEntry({ basePath: "/repo/b", kind: "kind-alpha" }), + makeEntry({ basePath: "/repo/c", kind: "kind-alpha" }), + makeEntry({ basePath: "/repo/a", kind: "kind-beta" }), + makeEntry({ basePath: "/repo/b", kind: "kind-beta" }), + makeEntry({ basePath: "/repo/c", kind: "kind-beta" }), + ]); + + const count = bridgeUpstreamFeedback(forgeDir); + assert.equal(count, 2); + const entries = readForgeJsonl(); + const kinds = new Set(entries.map((e) => e.kind)); + assert.ok(kinds.has("upstream-rollup:kind-alpha")); + assert.ok(kinds.has("upstream-rollup:kind-beta")); +}); + +test("returns 0 when upstream log does not exist", () => { + // Don't write any upstream log + assert.equal(bridgeUpstreamFeedback(forgeDir), 0); +}); diff --git a/src/resources/extensions/sf/upstream-bridge.ts b/src/resources/extensions/sf/upstream-bridge.ts new file mode 100644 index 000000000..b5972d7e1 --- /dev/null +++ b/src/resources/extensions/sf/upstream-bridge.ts @@ -0,0 +1,164 @@ +/** + * Upstream-feedback → forge-backlog bridge. + * + * Rolls up recurring upstream anomalies (observed while sf runs on external + * repos) into the forge-local self-feedback backlog so they can be triaged and + * addressed as forge-side fixes. + * + * Called from register-hooks.ts session_start drain (wired externally). + * Never throws — any I/O failure returns 0. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + type PersistedSelfFeedbackEntry, + type SelfFeedbackSeverity, + readAllSelfFeedback, + recordSelfFeedback, +} from "./self-feedback.js"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const SEVERITY_ORDER: SelfFeedbackSeverity[] = ["low", "medium", "high", "critical"]; +const ROLLUP_CAP: SelfFeedbackSeverity = "medium"; +const THRESHOLD_COUNT = 3; +const THRESHOLD_REPOS = 2; +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getUpstreamLogPath(): string { + const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); + return join(sfHome, "agent", "upstream-feedback.jsonl"); +} + +function isForgeRepo(basePath: string): boolean { + try { + const pkgPath = join(basePath, "package.json"); + if (!existsSync(pkgPath)) return false; + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + return pkg?.name === "singularity-forge"; + } catch { + return false; + } +} + +function readUpstreamEntries(): PersistedSelfFeedbackEntry[] { + const path = getUpstreamLogPath(); + try { + if (!existsSync(path)) return []; + const out: PersistedSelfFeedbackEntry[] = []; + for (const line of readFileSync(path, "utf-8").split("\n")) { + if (!line.trim()) continue; + try { + out.push(JSON.parse(line) as PersistedSelfFeedbackEntry); + } catch { + /* skip malformed lines */ + } + } + return out; + } catch { + return []; + } +} + +function capSeverity(sev: SelfFeedbackSeverity): SelfFeedbackSeverity { + const idx = SEVERITY_ORDER.indexOf(sev); + const capIdx = SEVERITY_ORDER.indexOf(ROLLUP_CAP); + return SEVERITY_ORDER[Math.min(idx, capIdx)]; +} + +function maxSeverity(entries: PersistedSelfFeedbackEntry[]): SelfFeedbackSeverity { + let max = 0; + for (const e of entries) { + const idx = SEVERITY_ORDER.indexOf(e.severity); + if (idx > max) max = idx; + } + return SEVERITY_ORDER[max]; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Roll up upstream feedback entries into the forge-local backlog. + * Only runs when basePath is the singularity-forge repo itself. + * + * @returns count of new rollup entries filed (0 on bail/failure) + */ +export function bridgeUpstreamFeedback(basePath: string = process.cwd()): number { + try { + if (!isForgeRepo(basePath)) return 0; + + const cutoff = Date.now() - THIRTY_DAYS_MS; + const upstream = readUpstreamEntries().filter( + (e) => + !e.resolvedAt && + e.repoIdentity === "external" && + new Date(e.ts).getTime() >= cutoff, + ); + if (upstream.length === 0) return 0; + + // Group by kind, then compute distinct basePaths per group + const byKind = new Map(); + for (const e of upstream) { + const list = byKind.get(e.kind) ?? []; + list.push(e); + byKind.set(e.kind, list); + } + + // Read existing forge-local entries once for idempotency checks + const existing = readAllSelfFeedback(basePath); + const openRollupKinds = new Set( + existing + .filter((e) => !e.resolvedAt && e.kind.startsWith("upstream-rollup:")) + .map((e) => e.kind), + ); + + let filed = 0; + for (const [kind, entries] of byKind) { + if (entries.length < THRESHOLD_COUNT) continue; + const distinctRepos = new Set(entries.map((e) => e.basePath)).size; + if (distinctRepos < THRESHOLD_REPOS) continue; + + const rollupKind = `upstream-rollup:${kind}`; + if (openRollupKinds.has(rollupKind)) continue; + + // Derive severity, capped at medium + const severity = capSeverity(maxSeverity(entries)); + + // Build evidence block: up to 5 samples + full id list + const samples = entries.slice(0, 5); + const sampleLines = samples + .map((e) => ` [${e.id}] ${e.basePath} — ${e.summary}`) + .join("\n"); + const allIds = entries.map((e) => e.id).join(", "); + const evidence = + `Samples (${samples.length} of ${entries.length}):\n${sampleLines}\n\n` + + `All upstream ids: ${allIds}`; + + const result = recordSelfFeedback( + { + kind: rollupKind, + severity, + summary: `${entries.length} external-repo entries of kind '${kind}' across ${distinctRepos} repos`, + evidence, + suggestedFix: + "Triage the cluster — common kinds suggest a forge-side fix (threshold tweak, gate fix, prompt clarification). Mark resolved with kind: 'agent-fix' citing the commit, or 'human-clear' with reason.", + source: "detector", + occurredIn: undefined, + }, + basePath, + ); + if (result) { + filed++; + openRollupKinds.add(rollupKind); // prevent double-filing in same run + } + } + + return filed; + } catch { + return 0; + } +}