fix(sf): drain self-feedback into repair turns
This commit is contained in:
parent
983a2e0a44
commit
3d0ebd981f
12 changed files with 491 additions and 107 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
176
src/resources/extensions/sf/self-feedback-drain.ts
Normal file
176
src/resources/extensions/sf/self-feedback-drain.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
126
src/resources/extensions/sf/tests/self-feedback-drain.test.ts
Normal file
126
src/resources/extensions/sf/tests/self-feedback-drain.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue