diff --git a/src/resources/extensions/sf/bootstrap/db-tools.ts b/src/resources/extensions/sf/bootstrap/db-tools.ts index 83a69d90c..d3e9132f1 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.ts +++ b/src/resources/extensions/sf/bootstrap/db-tools.ts @@ -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 { diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index 13ee5a6fa..57cdbc9a5 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -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); } diff --git a/src/resources/extensions/sf/bootstrap/system-context.ts b/src/resources/extensions/sf/bootstrap/system-context.ts index cd146c735..6bcc2eeff 100644 --- a/src/resources/extensions/sf/bootstrap/system-context.ts +++ b/src/resources/extensions/sf/bootstrap/system-context.ts @@ -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 = { critical: 0, high: 1, medium: 2, low: 3 }; + 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; @@ -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 ) - const stripped = raw - .replace(//g, "") - .trim(); + const stripped = raw.replace(//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)`; + }); } /** diff --git a/src/resources/extensions/sf/gap-audit.ts b/src/resources/extensions/sf/gap-audit.ts index f9d534304..20926013e 100644 --- a/src/resources/extensions/sf/gap-audit.ts +++ b/src/resources/extensions/sf/gap-audit.ts @@ -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 diff --git a/src/resources/extensions/sf/requirement-promoter.ts b/src/resources/extensions/sf/requirement-promoter.ts index 01491b900..4483f814c 100644 --- a/src/resources/extensions/sf/requirement-promoter.ts +++ b/src/resources/extensions/sf/requirement-promoter.ts @@ -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; diff --git a/src/resources/extensions/sf/self-feedback-drain.ts b/src/resources/extensions/sf/self-feedback-drain.ts new file mode 100644 index 000000000..d3bd1876a --- /dev/null +++ b/src/resources/extensions/sf/self-feedback-drain.ts @@ -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; +} diff --git a/src/resources/extensions/sf/self-feedback.ts b/src/resources/extensions/sf/self-feedback.ts index 76920bb39..fda194eb9 100644 --- a/src/resources/extensions/sf/self-feedback.ts +++ b/src/resources/extensions/sf/self-feedback.ts @@ -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 diff --git a/src/resources/extensions/sf/tests/self-feedback-drain.test.ts b/src/resources/extensions/sf/tests/self-feedback-drain.test.ts new file mode 100644 index 000000000..d31b3731a --- /dev/null +++ b/src/resources/extensions/sf/tests/self-feedback-drain.test.ts @@ -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"); + }); +}); diff --git a/src/resources/extensions/sf/tests/triage-self-feedback.test.ts b/src/resources/extensions/sf/tests/triage-self-feedback.test.ts index c8c442038..78f052cad 100644 --- a/src/resources/extensions/sf/tests/triage-self-feedback.test.ts +++ b/src/resources/extensions/sf/tests/triage-self-feedback.test.ts @@ -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 /.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", diff --git a/src/resources/extensions/sf/tests/upstream-bridge.test.ts b/src/resources/extensions/sf/tests/upstream-bridge.test.ts index 90ab831e5..0f591d973 100644 --- a/src/resources/extensions/sf/tests/upstream-bridge.test.ts +++ b/src/resources/extensions/sf/tests/upstream-bridge.test.ts @@ -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/); }); diff --git a/src/resources/extensions/sf/triage-self-feedback.ts b/src/resources/extensions/sf/triage-self-feedback.ts index 6dac53684..56962b782 100644 --- a/src/resources/extensions/sf/triage-self-feedback.ts +++ b/src/resources/extensions/sf/triage-self-feedback.ts @@ -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), diff --git a/src/resources/extensions/sf/upstream-bridge.ts b/src/resources/extensions/sf/upstream-bridge.ts index 716d05dc9..d460f60fa 100644 --- a/src/resources/extensions/sf/upstream-bridge.ts +++ b/src/resources/extensions/sf/upstream-bridge.ts @@ -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;