feat: gap audit + upstream bridge + backlog prompt injection

- gap-audit.ts: automatic detection of orphaned prompts, handlers, native modules, and advertised commands. Deduped by content hash, runs at session_start.
- upstream-bridge.ts: rolls up recurring upstream anomalies into forge-local backlog when threshold crossed (≥3 entries, ≥2 repos, 30d window). Severity capped at medium.
- system-context.ts: injects top-5 backlog entries into system prompt, sorted by severity then recency. Capped at 2K chars.
- register-hooks.ts: wires both gap audit and upstream bridge into session_start drain.
- Tests: 13 upstream-bridge tests covering thresholds, idempotency, resolution, severity capping, and multi-kind handling.
This commit is contained in:
Mikael Hugo 2026-05-02 09:03:08 +02:00
parent 1990d2a2ee
commit f9116f5514
6 changed files with 985 additions and 1 deletions

View file

@ -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) => {

View file

@ -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<string, number> = { 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)

View file

@ -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<string, GapFinding[]>();
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;
}

View file

@ -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<string | undefined>;
}
/**
* 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<string, Cluster>();
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;
}
}

View file

@ -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> = {},
): 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);
});

View file

@ -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<string, PersistedSelfFeedbackEntry[]>();
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;
}
}