fix(sf): drain self-feedback into repair turns

This commit is contained in:
Mikael Hugo 2026-05-02 13:59:22 +02:00
parent 983a2e0a44
commit 3d0ebd981f
12 changed files with 491 additions and 107 deletions

View file

@ -1,6 +1,6 @@
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@singularity-forge/pi-ai";
import type { AgentToolResult } from "@singularity-forge/pi-agent-core";
import { StringEnum } from "@singularity-forge/pi-ai";
import type { ExtensionAPI } from "@singularity-forge/pi-coding-agent";
import { Text } from "@singularity-forge/pi-tui";
import {

View file

@ -1,4 +1,4 @@
import { join, resolve, relative } from "node:path";
import { join, relative, resolve } from "node:path";
import type {
ExtensionAPI,
@ -58,8 +58,8 @@ import {
saveEvidenceToDisk,
} from "../safety/evidence-collector.js";
import { deriveState } from "../state.js";
import { parseUnitId } from "../unit-id.js";
import { countGoogleGeminiCliTokens } from "../token-counter.js";
import { parseUnitId } from "../unit-id.js";
import { logWarning as safetyLogWarning } from "../workflow-logger.js";
import {
BLOCKED_WRITE_ERROR,
@ -247,6 +247,10 @@ export function registerHooks(
`${highBlocked.length} inline-fix candidate${highBlocked.length === 1 ? "" : "s"} pending in .sf/SELF-FEEDBACK.md: ${ids}`,
"warning",
);
const { dispatchSelfFeedbackInlineFixIfNeeded } = await import(
"../self-feedback-drain.js"
);
dispatchSelfFeedbackInlineFixIfNeeded(process.cwd(), ctx, pi);
}
} catch {
/* non-fatal — self-feedback drain must never block session start */
@ -267,7 +271,9 @@ export function registerHooks(
// Summarise the last UOK parity report so the operator can act on
// divergences/fallbacks before starting any new work.
try {
const { summarizeParityReport } = await import("../uok-parity-summary.js");
const { summarizeParityReport } = await import(
"../uok-parity-summary.js"
);
await summarizeParityReport(process.cwd(), ctx);
} catch {
/* non-fatal — parity summary must never block session start */
@ -538,8 +544,7 @@ export function registerHooks(
if (isAutoActive() && process.env.SF_WORKTREE) {
const worktreeRoot = process.cwd();
const mainRepoRoot =
process.env.SF_PROJECT_ROOT ??
(resolve(worktreeRoot, ".."));
process.env.SF_PROJECT_ROOT ?? resolve(worktreeRoot, "..");
const targetPath = resolve(event.input.path);
const worktreeRel = relative(worktreeRoot, targetPath);
const mainSfRel = relative(join(mainRepoRoot, ".sf"), targetPath);
@ -582,9 +587,11 @@ export function registerHooks(
// positive fires when the LLM clearly ran a verification command (Bug #4385).
const callDash = getAutoDashboardData();
if (callDash.basePath && callDash.currentUnit?.type === "execute-task") {
const { milestone: cMid, slice: cSid, task: cTid } = parseUnitId(
callDash.currentUnit.id,
);
const {
milestone: cMid,
slice: cSid,
task: cTid,
} = parseUnitId(callDash.currentUnit.id);
if (cMid && cSid && cTid) {
saveEvidenceToDisk(callDash.basePath, cMid, cSid, cTid);
}
@ -655,7 +662,10 @@ export function registerHooks(
const answer = details.response?.answers?.[question.id];
if (
isDepthConfirmationAnswer(getSelectedGateAnswer(answer), question.options)
isDepthConfirmationAnswer(
getSelectedGateAnswer(answer),
question.options,
)
) {
// Always mark depth-verified AND clear the gate
if (isDepthQ) {
@ -746,9 +756,11 @@ export function registerHooks(
// restart mid-unit (Bug #4385 — non-persisted evidence false positives).
const endDash = getAutoDashboardData();
if (endDash.basePath && endDash.currentUnit?.type === "execute-task") {
const { milestone: pMid, slice: pSid, task: pTid } = parseUnitId(
endDash.currentUnit.id,
);
const {
milestone: pMid,
slice: pSid,
task: pTid,
} = parseUnitId(endDash.currentUnit.id);
if (pMid && pSid && pTid) {
saveEvidenceToDisk(endDash.basePath, pMid, pSid, pTid);
}

View file

@ -8,8 +8,8 @@ import {
shouldPromptToEnableCmux,
} from "../../cmux/index.js";
import { toPosixPath } from "../../shared/mod.js";
import { isCanAskUser } from "../auto.js";
import { getActiveAutoWorktreeContext } from "../auto-worktree.js";
import { isAutoActive, isCanAskUser } from "../auto.js";
import { buildCodeIntelligenceContextBlock } from "../code-intelligence.js";
import {
ensureCodebaseMapFresh,
@ -453,12 +453,20 @@ function loadSelfFeedbackBlock(cwd: string): string {
// Parse the table rows — skip header lines
const lines = raw.split("\n");
const entries: Array<{ timestamp: string; kind: string; severity: string; summary: string }> = [];
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);
const cells = line
.split("|")
.map((c) => c.trim())
.filter(Boolean);
if (cells.length >= 7) {
entries.push({
timestamp: cells[0],
@ -472,7 +480,12 @@ function loadSelfFeedbackBlock(cwd: string): string {
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 };
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;
@ -481,7 +494,9 @@ function loadSelfFeedbackBlock(cwd: string): string {
});
// Render all entries; sort already put high/critical first.
const rows = entries.map((e) => `- **${e.severity}** \`${e.kind}\`${e.summary}`).join("\n");
const rows = entries
.map((e) => `- **${e.severity}** \`${e.kind}\`${e.summary}`)
.join("\n");
let block = `## Self-Feedback Entries (from .sf/SELF-FEEDBACK.md, ordered by severity)\n\n${rows}`;
// If over the char budget, drop entries from the tail (lowest priority,
// oldest) one at a time until it fits. High/critical never get truncated
@ -492,7 +507,9 @@ function loadSelfFeedbackBlock(cwd: string): string {
kept = kept.slice(0, -1);
block =
`## Self-Feedback Entries (from .sf/SELF-FEEDBACK.md, ordered by severity, truncated)\n\n` +
kept.map((e) => `- **${e.severity}** \`${e.kind}\`${e.summary}`).join("\n");
kept
.map((e) => `- **${e.severity}** \`${e.kind}\`${e.summary}`)
.join("\n");
}
}
return `\n\n[SELF-FEEDBACK — Recent sf-internal anomalies]\n\n${block}`;
@ -514,14 +531,17 @@ export function loadTacitKnowledgeBlock(cwd: string): string {
const raw = cachedReadFile(filePath)?.trim() ?? "";
if (!raw) return "";
// Strip scaffold markers (HTML comments like <!-- sf-scaffold: ... -->)
const stripped = raw
.replace(/<!--\s*sf-scaffold:[^>]*-->/g, "")
.trim();
const stripped = raw.replace(/<!--\s*sf-scaffold:[^>]*-->/g, "").trim();
if (!stripped) return "";
const bytes = Buffer.byteLength(stripped, "utf-8");
if (bytes > TACIT_SECTION_MAX_BYTES) {
const truncated = stripped.slice(0, TACIT_SECTION_MAX_BYTES);
return truncated + "\n\n*(truncated — see .sf/" + filename + " for full content)*";
return (
truncated +
"\n\n*(truncated — see .sf/" +
filename +
" for full content)*"
);
}
return stripped;
}
@ -814,16 +834,15 @@ async function buildCarryForwardLines(
}),
);
return results
.map((r, idx) => {
if (r.status === "fulfilled") return r.value;
const file = summaryFiles[idx]!;
logWarning(
"bootstrap",
`Failed to load task summary ${sliceRel}/tasks/${file}: ${(r.reason as Error).message}`,
);
return `- \`${sliceRel}/tasks/${file}\` (load failed)`;
});
return results.map((r, idx) => {
if (r.status === "fulfilled") return r.value;
const file = summaryFiles[idx]!;
logWarning(
"bootstrap",
`Failed to load task summary ${sliceRel}/tasks/${file}: ${(r.reason as Error).message}`,
);
return `- \`${sliceRel}/tasks/${file}\` (load failed)`;
});
}
/**

View file

@ -9,7 +9,13 @@
*/
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join, relative } from "node:path";
import { recordSelfFeedback } from "./self-feedback.js";
@ -64,8 +70,9 @@ function findOrphanPrompts(): GapFinding[] {
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}'`);
const loaded =
grepImports(EXTENSION_SRC, `loadPrompt("${name}"`) ||
grepImports(EXTENSION_SRC, `loadPrompt('${name}'`);
if (!loaded) {
findings.push({
kind: "orphan-prompt",
@ -91,7 +98,9 @@ function findOrphanHandlers(): GapFinding[] {
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+)/);
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

View file

@ -14,12 +14,12 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { sfRoot } from "./paths.js";
import {
type PersistedSelfFeedbackEntry,
markResolved,
type PersistedSelfFeedbackEntry,
readAllSelfFeedback,
} from "./self-feedback.js";
import { sfRoot } from "./paths.js";
// ─── Constants ───────────────────────────────────────────────────────────────
@ -99,13 +99,17 @@ function appendRequirementRow(
// 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)/);
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;
const appended = content.endsWith("\n")
? content + block
: content + "\n" + block;
writeFileSync(filePath, appended, "utf-8");
}
}
@ -141,9 +145,7 @@ export function promoteFeedbackToRequirements(
// 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,
!e.resolvedAt && e.repoIdentity === "forge" && new Date(e.ts) >= cutoff,
);
if (eligible.length === 0) return empty;

View file

@ -0,0 +1,176 @@
/**
* self-feedback-drain.ts - dispatch high-priority sf self-feedback as repair work.
*
* Purpose: high/critical self-feedback should not remain a passive startup
* warning; it should become an executable repair turn when sf is dogfooding
* itself.
*
* Consumer: session_start hook in bootstrap/register-hooks.ts.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type {
ExtensionAPI,
ExtensionContext,
} from "@singularity-forge/pi-coding-agent";
import { sfRuntimeRoot } from "./paths.js";
import type { PersistedSelfFeedbackEntry } from "./self-feedback.js";
import {
readAllSelfFeedback,
readUpstreamSelfFeedback,
} from "./self-feedback.js";
const CLAIM_TTL_MS = 30 * 60 * 1000;
interface InlineFixClaim {
ids: string[];
dispatchedAt: string;
}
function claimPath(basePath: string): string {
return join(
sfRuntimeRoot(basePath),
"runtime",
"self-feedback-inline-fix.json",
);
}
function readClaim(basePath: string): InlineFixClaim | null {
try {
const path = claimPath(basePath);
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, "utf-8")) as InlineFixClaim;
} catch {
return null;
}
}
function writeClaim(basePath: string, ids: string[]): void {
const path = claimPath(basePath);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
JSON.stringify({ ids, dispatchedAt: new Date().toISOString() }, null, 2),
"utf-8",
);
}
function sameIds(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((id, idx) => id === b[idx]);
}
function claimStillFresh(claim: InlineFixClaim, ids: string[]): boolean {
if (!sameIds(claim.ids, ids)) return false;
const age = Date.now() - new Date(claim.dispatchedAt).getTime();
return Number.isFinite(age) && age >= 0 && age < CLAIM_TTL_MS;
}
function isForgeRepo(basePath: string): boolean {
try {
const pkg = JSON.parse(
readFileSync(join(basePath, "package.json"), "utf-8"),
);
return pkg?.name === "singularity-forge";
} catch {
return false;
}
}
/**
* Return unresolved high/critical forge-local self-feedback entries.
*
* Purpose: isolate the direct-drain candidate policy from the startup hook so
* tests and future dispatch paths can verify the same selection rule.
*
* Consumer: dispatchSelfFeedbackInlineFixIfNeeded during session_start.
*/
export function selectInlineFixCandidates(
basePath: string,
): PersistedSelfFeedbackEntry[] {
if (!isForgeRepo(basePath)) return [];
return [...readAllSelfFeedback(basePath), ...readUpstreamSelfFeedback()]
.filter(
(entry) =>
!entry.resolvedAt &&
entry.blocking &&
(entry.severity === "high" || entry.severity === "critical"),
)
.sort((a, b) => a.ts.localeCompare(b.ts));
}
function buildInlineFixPrompt(entries: PersistedSelfFeedbackEntry[]): string {
const rendered = entries
.map((entry) =>
[
`## ${entry.id}${entry.kind}`,
`- Severity: ${entry.severity}`,
`- Summary: ${entry.summary}`,
entry.acceptanceCriteria
? `- Acceptance criteria: ${entry.acceptanceCriteria}`
: "- Acceptance criteria: verify the reported failure is gone and add/adjust a regression test where practical.",
entry.evidence
? `\nEvidence:\n\n\`\`\`\n${entry.evidence}\n\`\`\``
: "",
entry.suggestedFix ? `\nSuggested fix: ${entry.suggestedFix}` : "",
]
.filter(Boolean)
.join("\n"),
)
.join("\n\n");
return [
"You are executing SF self-feedback inline-fix mode.",
"",
"These high/critical self-feedback entries blocked prior sf versions. Do not only triage them; repair the current codebase directly.",
"",
rendered,
"",
"Instructions:",
"1. Verify each entry still applies before editing.",
"2. Fix the smallest coherent set of code/docs/tests needed to satisfy the acceptance criteria.",
"3. Run focused verification and typecheck for touched areas.",
"4. Commit the fix with a conventional commit message.",
"5. Mark the repaired entries resolved in `.sf/self-feedback.jsonl` with agent-fix evidence and the commit SHA.",
"6. If an entry is already fixed, mark it resolved with agent-fix evidence and explain the verification.",
"",
"When done, say: Self-feedback inline fix complete.",
].join("\n");
}
/**
* Dispatch a focused inline-fix turn for unresolved high/critical self-feedback.
*
* Purpose: convert startup self-feedback warnings into executable work while
* preventing repeated dispatch of the same candidate set on every session.
*
* Consumer: bootstrap/register-hooks.ts session_start drain.
*/
export function dispatchSelfFeedbackInlineFixIfNeeded(
basePath: string,
ctx: ExtensionContext,
pi: ExtensionAPI,
): number {
const candidates = selectInlineFixCandidates(basePath);
if (candidates.length === 0) return 0;
const ids = candidates.map((entry) => entry.id);
const claim = readClaim(basePath);
if (claim && claimStillFresh(claim, ids)) return 0;
writeClaim(basePath, ids);
const prompt = buildInlineFixPrompt(candidates);
ctx.ui.notify(
`Dispatching self-feedback inline fix for ${ids.length} high/critical entr${ids.length === 1 ? "y" : "ies"}.`,
"warning",
);
pi.sendMessage(
{
customType: "sf-self-feedback-inline-fix",
content: prompt,
display: false,
},
{ triggerTurn: true },
);
return candidates.length;
}

View file

@ -41,7 +41,6 @@ import { dirname, join } from "node:path";
import { sfRuntimeRoot } from "./paths.js";
const SF_HOME = process.env.SF_HOME || join(homedir(), ".sf");
const UPSTREAM_LOG = join(SF_HOME, "agent", "upstream-feedback.jsonl");
const SELF_FEEDBACK_HEADER =
"# SF Self-Feedback\n\n" +
"Anomalies caught during auto runs (by runtime detectors or via the\n" +
@ -180,6 +179,11 @@ function projectMarkdownPath(basePath: string): string {
return join(sfRuntimeRoot(basePath), "SELF-FEEDBACK.md");
}
function upstreamLogPath(): string {
const sfHome = process.env.SF_HOME || SF_HOME;
return join(sfHome, "agent", "upstream-feedback.jsonl");
}
/**
* Migrate the legacy filename. Older sf versions wrote `BACKLOG.md`; the
* canonical name is now `SELF-FEEDBACK.md` (matches `self-feedback.jsonl`).
@ -220,8 +224,7 @@ function appendSelfFeedbackRow(
const unit = formatUnitCell(entry.occurredIn);
const summary = escapeCell(entry.summary);
const blocking = entry.blocking ? "yes" : "no";
const row =
`| ${entry.ts} | ${entry.kind} | ${entry.severity} | ${blocking} | ${entry.sfVersion} | ${unit} | ${summary} |\n`;
const row = `| ${entry.ts} | ${entry.kind} | ${entry.severity} | ${blocking} | ${entry.sfVersion} | ${unit} | ${summary} |\n`;
appendFileSync(path, row, "utf-8");
if (entry.evidence || entry.suggestedFix) {
const detail =
@ -293,7 +296,7 @@ export function recordSelfFeedback(
appendJsonl(projectJsonlPath(basePath), persisted);
appendSelfFeedbackRow(basePath, persisted);
} else {
appendJsonl(UPSTREAM_LOG, persisted);
appendJsonl(upstreamLogPath(), persisted);
}
return { entry: persisted, blocking: persisted.blocking };
} catch {
@ -310,7 +313,7 @@ export function readAllSelfFeedback(
): PersistedSelfFeedbackEntry[] {
const path = isForgeRepo(basePath)
? projectJsonlPath(basePath)
: UPSTREAM_LOG;
: upstreamLogPath();
try {
if (!existsSync(path)) return [];
const out: PersistedSelfFeedbackEntry[] = [];
@ -372,47 +375,78 @@ export function markResolved(
resolution: ResolutionInput,
basePath: string = process.cwd(),
): boolean {
const path = isForgeRepo(basePath)
? projectJsonlPath(basePath)
: UPSTREAM_LOG;
const paths = isForgeRepo(basePath)
? [projectJsonlPath(basePath), upstreamLogPath()]
: [upstreamLogPath()];
try {
if (!existsSync(path)) return false;
const lines = readFileSync(path, "utf-8").split("\n");
const out: string[] = [];
let mutated = false;
for (const line of lines) {
if (!line.trim()) {
out.push(line);
continue;
}
try {
const e = JSON.parse(line) as PersistedSelfFeedbackEntry;
if (e.id === entryId && !e.resolvedAt) {
e.resolvedAt = new Date().toISOString();
e.resolvedReason = resolution.reason;
e.resolvedBySfVersion = getCurrentSfVersion();
e.resolvedEvidence = resolution.evidence;
if (resolution.criteriaMet) {
e.resolvedCriteriaMet = resolution.criteriaMet;
for (const path of paths) {
if (!existsSync(path)) continue;
const lines = readFileSync(path, "utf-8").split("\n");
const out: string[] = [];
let mutated = false;
for (const line of lines) {
if (!line.trim()) {
out.push(line);
continue;
}
try {
const e = JSON.parse(line) as PersistedSelfFeedbackEntry;
if (e.id === entryId && !e.resolvedAt) {
e.resolvedAt = new Date().toISOString();
e.resolvedReason = resolution.reason;
e.resolvedBySfVersion = getCurrentSfVersion();
e.resolvedEvidence = resolution.evidence;
if (resolution.criteriaMet) {
e.resolvedCriteriaMet = resolution.criteriaMet;
}
mutated = true;
out.push(JSON.stringify(e));
} else {
out.push(line);
}
mutated = true;
out.push(JSON.stringify(e));
} else {
} catch {
out.push(line);
}
} catch {
out.push(line);
}
if (mutated) {
writeFileSync(path, out.join("\n"), "utf-8");
return true;
}
}
if (mutated) {
writeFileSync(path, out.join("\n"), "utf-8");
}
return mutated;
return false;
} catch {
return false;
}
}
/**
* Read unresolved feedback filed while sf was running in other repositories.
*
* Purpose: let forge-local triage and inline-fix units consume external
* observations as sf repair work instead of leaving them stranded in the
* global upstream log.
*
* Consumer: triage-self-feedback and self-feedback-drain.
*/
export function readUpstreamSelfFeedback(): PersistedSelfFeedbackEntry[] {
const path = upstreamLogPath();
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 [];
}
}
/**
* Compare two semver strings. Returns positive if a > b, 0 if equal, negative
* if a < b. Tolerant of pre-release / non-numeric segments by falling back

View file

@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, it } from "vitest";
import { recordSelfFeedback } from "../self-feedback.ts";
import {
dispatchSelfFeedbackInlineFixIfNeeded,
selectInlineFixCandidates,
} from "../self-feedback-drain.ts";
let roots: string[] = [];
const originalSfHome = process.env.SF_HOME;
afterEach(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
roots = [];
if (originalSfHome === undefined) delete process.env.SF_HOME;
else process.env.SF_HOME = originalSfHome;
});
function makeForgeProject(): string {
const root = mkdtempSync(join(tmpdir(), "sf-self-feedback-drain-"));
roots.push(root);
mkdirSync(join(root, ".sf"), { recursive: true });
process.env.SF_HOME = join(root, "sf-home");
writeFileSync(
join(root, "package.json"),
JSON.stringify({ name: "singularity-forge", version: "0.0.1" }),
"utf-8",
);
return root;
}
function makeExternalProject(sfHome: string): string {
const root = mkdtempSync(join(tmpdir(), "sf-self-feedback-external-"));
roots.push(root);
process.env.SF_HOME = sfHome;
return root;
}
describe("self-feedback inline drain", () => {
it("selects only unresolved blocking high and critical forge entries", () => {
const root = makeForgeProject();
const high = recordSelfFeedback(
{
kind: "tool-wiring-gap",
severity: "high",
summary: "Tool wiring broke",
source: "detector",
},
root,
);
recordSelfFeedback(
{
kind: "medium-noise",
severity: "medium",
summary: "Noisy but non-blocking",
source: "detector",
},
root,
);
const selected = selectInlineFixCandidates(root);
assert.deepEqual(
selected.map((entry) => entry.id),
[high?.entry.id],
);
});
it("dispatches once for the same candidate set within the claim TTL", () => {
const root = makeForgeProject();
recordSelfFeedback(
{
kind: "race-condition-silent-event-drop",
severity: "critical",
summary: "Critical event drop",
source: "detector",
},
root,
);
const messages: unknown[] = [];
const notifications: string[] = [];
const ctx = {
ui: {
notify(message: string) {
notifications.push(message);
},
},
} as any;
const pi = {
sendMessage(message: unknown) {
messages.push(message);
},
} as any;
assert.equal(dispatchSelfFeedbackInlineFixIfNeeded(root, ctx, pi), 1);
assert.equal(dispatchSelfFeedbackInlineFixIfNeeded(root, ctx, pi), 0);
assert.equal(messages.length, 1);
assert.equal(notifications.length, 1);
assert.match(JSON.stringify(messages[0]), /sf-self-feedback-inline-fix/);
});
it("selects high priority upstream entries filed while sf ran in another repo", () => {
const root = makeForgeProject();
const sfHome = process.env.SF_HOME!;
const externalRoot = makeExternalProject(sfHome);
const upstream = recordSelfFeedback(
{
kind: "external-repo-sf-bug",
severity: "high",
summary: "SF failed while running outside forge",
source: "detector",
},
externalRoot,
);
const selected = selectInlineFixCandidates(root);
assert.deepEqual(
selected.map((entry) => entry.id),
[upstream?.entry.id],
);
assert.equal(selected[0]?.repoIdentity, "external");
});
});

View file

@ -11,7 +11,6 @@
import assert from "node:assert/strict";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
@ -20,12 +19,9 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterAll, test } from "vitest";
import { fileURLToPath } from "node:url";
import {
readAllSelfFeedback,
recordSelfFeedback,
} from "../self-feedback.ts";
import { afterAll, test } from "vitest";
import { readAllSelfFeedback, recordSelfFeedback } from "../self-feedback.ts";
import {
applyTriageReport,
buildTriageSelfFeedbackPrompt,
@ -36,12 +32,14 @@ import {
const __dirname = dirname(fileURLToPath(import.meta.url));
const promptsDir = join(__dirname, "..", "prompts");
const originalSfHome = process.env.SF_HOME;
// ─── Test helpers ─────────────────────────────────────────────────────────────
function makeForgeProject(): string {
const root = mkdtempSync(join(tmpdir(), "sf-triage-self-feedback-"));
mkdirSync(join(root, ".sf"), { recursive: true });
process.env.SF_HOME = join(root, "sf-home");
// Give it a forge identity so entries land in <root>/.sf/self-feedback.jsonl
writeFileSync(
join(root, "package.json"),
@ -96,6 +94,8 @@ afterAll(() => {
for (const root of roots) {
cleanup(root);
}
if (originalSfHome === undefined) delete process.env.SF_HOME;
else process.env.SF_HOME = originalSfHome;
});
// ─── Test 1: loadTriageSelfFeedbackVars shape ──────────────────────────────────────
@ -130,10 +130,7 @@ test("loadTriageSelfFeedbackVars: returns correct shape for a tmpdir with sample
"forgeSelfFeedbackJson" in vars,
"vars must have forgeSelfFeedbackJson",
);
assert.ok(
"upstreamRollups" in vars,
"vars must have upstreamRollups",
);
assert.ok("upstreamRollups" in vars, "vars must have upstreamRollups");
assert.ok(
"existingRequirementsTable" in vars,
"vars must have existingRequirementsTable",

View file

@ -11,8 +11,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, test } from "vitest";
import {
type PersistedSelfFeedbackEntry,
markResolved,
type PersistedSelfFeedbackEntry,
readAllSelfFeedback,
} from "../self-feedback.ts";
import { bridgeUpstreamFeedback } from "../upstream-bridge.ts";
@ -135,7 +135,10 @@ test("files a rollup when ≥3 entries of same kind from ≥2 distinct repos", (
assert.match(rollup.summary, /3 repos/);
// Rollup appears in SELF-FEEDBACK.md
const backlog = readFileSync(join(forgeDir, ".sf", "SELF-FEEDBACK.md"), "utf-8");
const backlog = readFileSync(
join(forgeDir, ".sf", "SELF-FEEDBACK.md"),
"utf-8",
);
assert.match(backlog, /upstream-rollup:runaway-guard-hard-pause/);
});

View file

@ -6,21 +6,17 @@
* resolves entries via markResolved. Idempotent.
*/
import {
existsSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { sfRoot } from "./paths.js";
import { loadPrompt } from "./prompt-loader.js";
import {
markResolved,
readAllSelfFeedback,
type PersistedSelfFeedbackEntry,
type ResolutionEvidence,
readAllSelfFeedback,
readUpstreamSelfFeedback,
} from "./self-feedback.js";
import { sfRoot } from "./paths.js";
// ─── JSON schema types ────────────────────────────────────────────────────────
@ -79,7 +75,10 @@ export interface TriageSelfFeedbackVars {
* Read all open (unresolved) feedback entries from the feedback channel.
*/
function readOpenEntries(basePath: string): PersistedSelfFeedbackEntry[] {
return readAllSelfFeedback(basePath).filter((e) => !e.resolvedAt);
return [
...readAllSelfFeedback(basePath),
...readUpstreamSelfFeedback(),
].filter((e) => !e.resolvedAt);
}
/**
@ -191,9 +190,7 @@ export function loadTriageSelfFeedbackVars(
): TriageSelfFeedbackVars {
const allOpen = readOpenEntries(basePath);
const forgeEntries = allOpen.filter((e) => e.repoIdentity === "forge");
const upstreamEntries = allOpen.filter(
(e) => e.repoIdentity === "external",
);
const upstreamEntries = allOpen.filter((e) => e.repoIdentity === "external");
return {
forgeSelfFeedbackJson: JSON.stringify(forgeEntries, null, 2),

View file

@ -14,14 +14,19 @@ import { homedir } from "node:os";
import { join } from "node:path";
import {
type PersistedSelfFeedbackEntry,
type SelfFeedbackSeverity,
readAllSelfFeedback,
recordSelfFeedback,
type SelfFeedbackSeverity,
} from "./self-feedback.js";
// ─── Constants ────────────────────────────────────────────────────────────────
const SEVERITY_ORDER: SelfFeedbackSeverity[] = ["low", "medium", "high", "critical"];
const SEVERITY_ORDER: SelfFeedbackSeverity[] = [
"low",
"medium",
"high",
"critical",
];
const ROLLUP_CAP: SelfFeedbackSeverity = "medium";
const THRESHOLD_COUNT = 3;
const THRESHOLD_REPOS = 2;
@ -70,7 +75,9 @@ function capSeverity(sev: SelfFeedbackSeverity): SelfFeedbackSeverity {
return SEVERITY_ORDER[Math.min(idx, capIdx)];
}
function maxSeverity(entries: PersistedSelfFeedbackEntry[]): SelfFeedbackSeverity {
function maxSeverity(
entries: PersistedSelfFeedbackEntry[],
): SelfFeedbackSeverity {
let max = 0;
for (const e of entries) {
const idx = SEVERITY_ORDER.indexOf(e.severity);
@ -87,7 +94,9 @@ function maxSeverity(entries: PersistedSelfFeedbackEntry[]): SelfFeedbackSeverit
*
* @returns count of new rollup entries filed (0 on bail/failure)
*/
export function bridgeUpstreamFeedback(basePath: string = process.cwd()): number {
export function bridgeUpstreamFeedback(
basePath: string = process.cwd(),
): number {
try {
if (!isForgeRepo(basePath)) return 0;