From 364a1e000e627ebd22ae469272d29bb1986f1fdd Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 16:43:54 +0200 Subject: [PATCH] fix(sf): compact feedback view and animate progress --- src/resources/extensions/sf/auto-dashboard.ts | 29 ++- .../extensions/sf/bootstrap/register-hooks.ts | 30 +-- src/resources/extensions/sf/self-feedback.ts | 122 +++++++----- .../sf/tests/auto-dashboard.test.ts | 80 +++++++- .../sf/tests/self-feedback-markdown.test.ts | 174 ++++++++++++++++++ 5 files changed, 366 insertions(+), 69 deletions(-) create mode 100644 src/resources/extensions/sf/tests/self-feedback-markdown.test.ts diff --git a/src/resources/extensions/sf/auto-dashboard.ts b/src/resources/extensions/sf/auto-dashboard.ts index 0681c0519..e5531f1af 100644 --- a/src/resources/extensions/sf/auto-dashboard.ts +++ b/src/resources/extensions/sf/auto-dashboard.ts @@ -44,6 +44,8 @@ import { logWarning } from "./workflow-logger.js"; import { getCurrentBranch } from "./worktree.js"; import { getActiveWorktreeName } from "./worktree-command.js"; +const ACTIVITY_FRAMES = ["|", "/", "-", "\\"]; + // ─── UAT Slice Extraction ───────────────────────────────────────────────────── /** @@ -724,10 +726,11 @@ export function updateProgressWidget( // Cache the effective service tier at widget creation time (reads preferences) const effectiveServiceTier = getEffectiveServiceTier(); - ctx.ui.setWidget("sf-progress", (_tui, theme) => { + ctx.ui.setWidget("sf-progress", (tui, theme) => { let cachedLines: string[] | undefined; let cachedWidth: number | undefined; let cachedRtkLabel: string | null | undefined; + let activityFrame = 0; const refreshRtkLabel = (): void => { try { @@ -766,6 +769,12 @@ export function updateProgressWidget( ); } }, 15_000); + const activityRefreshTimer = setInterval(() => { + activityFrame = (activityFrame + 1) % ACTIVITY_FRAMES.length; + cachedLines = undefined; + cachedWidth = undefined; + tui.requestRender(); + }, 1_000); return { render(width: number): string[] { @@ -786,10 +795,7 @@ export function updateProgressWidget( // ── Line 1: Top bar ─────────────────────────────────────────────── lines.push(...ui.bar()); - const dot = - Math.floor(Date.now() / 2000) % 2 === 0 - ? theme.fg("accent", GLYPH.statusActive) - : theme.fg("dim", GLYPH.statusPending); + const spinner = theme.fg("accent", ACTIVITY_FRAMES[activityFrame]); const elapsed = formatAutoElapsed(accessors.getAutoStartTime()); const modeTag = accessors.isStepMode() ? "NEXT" : "AUTO"; @@ -809,7 +815,7 @@ export function updateProgressWidget( : "x"; const healthStr = ` ${theme.fg(healthColor, healthIcon)} ${theme.fg(healthColor, score.summary)}`; - const headerLeft = `${pad}${theme.fg("accent", "╭─")} ${dot} ${theme.fg("accent", theme.bold("SF"))} ${theme.fg("dim", "▸")} ${theme.fg("success", modeTag)}${healthStr}`; + const headerLeft = `${pad}${theme.fg("accent", "╭─")} ${spinner} ${theme.fg("accent", theme.bold("SF"))} ${theme.fg("dim", "▸")} ${theme.fg("success", modeTag)}${healthStr}`; // ETA in header right, after elapsed const eta = estimateTimeRemaining(); @@ -915,7 +921,10 @@ export function updateProgressWidget( Math.min(18, Math.floor(width * 0.25)), ); const pct = total > 0 ? done / total : 0; - const filled = Math.max(0, Math.min(barWidth, Math.round(pct * barWidth))); + const filled = Math.max( + 0, + Math.min(barWidth, Math.round(pct * barWidth)), + ); const bar = theme.fg("success", "█".repeat(filled)) + theme.fg("dim", "░".repeat(barWidth - filled)); @@ -1019,7 +1028,10 @@ export function updateProgressWidget( Math.min(18, Math.floor(leftColWidth * 0.4)), ); const pct = total > 0 ? done / total : 0; - const filled = Math.max(0, Math.min(barWidth, Math.round(pct * barWidth))); + const filled = Math.max( + 0, + Math.min(barWidth, Math.round(pct * barWidth)), + ); const bar = theme.fg("success", "█".repeat(filled)) + theme.fg("dim", "░".repeat(barWidth - filled)); @@ -1179,6 +1191,7 @@ export function updateProgressWidget( }, dispose() { if (progressRefreshTimer) clearInterval(progressRefreshTimer); + if (activityRefreshTimer) clearInterval(activityRefreshTimer); }, }; }); diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index ab198b075..9119e34ce 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -198,11 +198,13 @@ export function registerHooks( // other init so notifications appear in the same session-start sweep. try { const { + compactSelfFeedbackMarkdown, markResolved, migrateLegacyBacklogFilename, triageBlockedEntries, } = await import("../self-feedback.js"); migrateLegacyBacklogFilename(process.cwd()); + compactSelfFeedbackMarkdown(process.cwd()); const triage = triageBlockedEntries(process.cwd()); const currentSfVersion = process.env.SF_VERSION || "unknown"; for (const e of triage.retry) { @@ -229,24 +231,24 @@ export function registerHooks( "info", ); } - if (triage.stillBlocked.length > 0) { - ctx.ui?.notify?.( - `${triage.stillBlocked.length} unresolved self-feedback entr${triage.stillBlocked.length === 1 ? "y" : "ies"} require sf fixes. See .sf/SELF-FEEDBACK.md or ~/.sf/agent/upstream-feedback.jsonl.`, - "warning", - ); - } + if (triage.stillBlocked.length > 0) { + ctx.ui?.notify?.( + `${triage.stillBlocked.length} unresolved self-feedback entr${triage.stillBlocked.length === 1 ? "y" : "ies"} require sf fixes. See .sf/SELF-FEEDBACK.md or ~/.sf/agent/upstream-feedback.jsonl.`, + "warning", + ); + } // Forge-only: surface high/critical entries as inline-fix candidates so // the operator (or a follow-up dispatcher) can drain self-reported bugs // without leaving the session. Read-only signal for now — no auto-dispatch. const highBlocked = triage.stillBlocked.filter( (e) => e.severity === "high" || e.severity === "critical", ); - if (highBlocked.length > 0) { - const ids = highBlocked.map((e) => `${e.id} (${e.kind})`).join(", "); - ctx.ui?.notify?.( - `${highBlocked.length} high/critical inline-fix candidate${highBlocked.length === 1 ? "" : "s"} pending in .sf/SELF-FEEDBACK.md: ${ids}`, - "warning", - ); + if (highBlocked.length > 0) { + const ids = highBlocked.map((e) => `${e.id} (${e.kind})`).join(", "); + ctx.ui?.notify?.( + `${highBlocked.length} high/critical inline-fix candidate${highBlocked.length === 1 ? "" : "s"} pending in .sf/SELF-FEEDBACK.md: ${ids}`, + "warning", + ); const { dispatchSelfFeedbackInlineFixIfNeeded } = await import( "../self-feedback-drain.js" ); @@ -380,7 +382,9 @@ export function registerHooks( const resolvedIds = consumeCompletedInlineFixClaim(process.cwd()); if (resolvedIds.length > 0) { const requestReload = ( - ctx as ExtensionContext & { requestReload?: (reason?: string) => void } + ctx as ExtensionContext & { + requestReload?: (reason?: string) => void; + } ).requestReload; requestReload?.( `self-feedback inline fix resolved ${resolvedIds.length} entr${resolvedIds.length === 1 ? "y" : "ies"}`, diff --git a/src/resources/extensions/sf/self-feedback.ts b/src/resources/extensions/sf/self-feedback.ts index 2d9f15ba4..ee79834a7 100644 --- a/src/resources/extensions/sf/self-feedback.ts +++ b/src/resources/extensions/sf/self-feedback.ts @@ -45,12 +45,12 @@ const SELF_FEEDBACK_HEADER = "# SF Self-Feedback\n\n" + "Anomalies caught during auto runs (by runtime detectors or via the\n" + "`sf_self_report` tool). Each row is a candidate work item for sf to\n" + - "address in itself. Source-of-truth records live in `self-feedback.jsonl`.\n\n" + - "Blocking entries (severity high+) hold their originating unit until\n" + - "`sf` is bumped past the version recorded with the entry, or the entry\n" + - "is explicitly resolved.\n\n" + - "| Timestamp | Kind | Severity | Blocking | sfVersion | Unit | Summary |\n" + - "|---|---|---|---|---|---|---|\n"; + "address in itself. This markdown file is a compact working view; the\n" + + "durable source of truth is `self-feedback.jsonl`.\n\n" + + "Blocking entries (severity high+) remain active until an sf fix explicitly\n" + + "marks them resolved with evidence.\n\n"; +const RECENT_RESOLVED_MARKDOWN_LIMIT = 20; +const MARKDOWN_DETAIL_CHAR_LIMIT = 2_000; export type SelfFeedbackSeverity = "critical" | "high" | "medium" | "low"; @@ -224,13 +224,17 @@ function ensureDir(path: string): void { /** * Regenerate SELF-FEEDBACK.md from the current jsonl state. - * This ensures resolved entries are properly marked in the markdown view. - * Called after markResolved to keep markdown in sync with jsonl (#sf-moobj36p-rlo95i). + * This keeps the markdown as a bounded work queue instead of a permanent audit log. + * + * Purpose: prevent old resolved/applied feedback from making the operator-facing + * file too long to scan while preserving full history in self-feedback.jsonl. + * + * Consumer: recordSelfFeedback and markResolved after mutating the jsonl source + * of truth. */ function regenerateSelfFeedbackMarkdown(basePath: string): void { try { const entries = readAllSelfFeedback(basePath); - if (entries.length === 0) return; const path = projectMarkdownPath(basePath); ensureDir(path); @@ -238,35 +242,40 @@ function regenerateSelfFeedbackMarkdown(basePath: string): void { // Separate unresolved and resolved entries const unresolved = entries.filter((e) => !e.resolvedAt); const resolved = entries.filter((e) => e.resolvedAt); + const recentResolved = resolved + .slice(-RECENT_RESOLVED_MARKDOWN_LIMIT) + .reverse(); + const compactedResolved = Math.max( + 0, + resolved.length - recentResolved.length, + ); let md = SELF_FEEDBACK_HEADER; - // Write unresolved entries first - for (const entry of unresolved) { - const unit = formatUnitCell(entry.occurredIn); - const summary = escapeCell(entry.summary); - const blocking = entry.blocking ? "yes" : "no"; - md += `| ${entry.ts} | ${entry.kind} | ${entry.severity} | ${blocking} | ${entry.sfVersion} | ${unit} | ${summary} |\n`; - if (entry.evidence || entry.suggestedFix) { - md += - `\n
${entry.id} — ${entry.kind}\n\n` + - (entry.evidence - ? `**Evidence:**\n\n\`\`\`\n${entry.evidence}\n\`\`\`\n\n` - : "") + - (entry.suggestedFix - ? `**Suggested fix:** ${entry.suggestedFix}\n\n` - : "") + - `
\n`; + md += + "## Open Entries\n\n" + + "| Timestamp | Kind | Severity | Blocking | sfVersion | Unit | Summary |\n" + + "|---|---|---|---|---|---|---|\n"; + + if (unresolved.length === 0) { + md += + "| — | — | — | — | — | — | No unresolved self-feedback entries. |\n"; + } else { + for (const entry of unresolved) { + md += formatOpenMarkdownRow(entry); + if (entry.blocking) { + md += formatEntryDetails(entry); + } } } // Write resolved section if there are resolved entries - if (resolved.length > 0) { + if (recentResolved.length > 0) { md += - "\n## Resolved Entries\n\n" + + "\n## Recently Resolved\n\n" + "| Resolved At | Kind | Severity | sfVersion | Unit | Summary | Resolution |\n" + "|---|---|---|---|---|---|---|\n"; - for (const entry of resolved) { + for (const entry of recentResolved) { const unit = formatUnitCell(entry.occurredIn); const summary = escapeCell(entry.summary); const resolution = entry.resolvedEvidence @@ -275,6 +284,9 @@ function regenerateSelfFeedbackMarkdown(basePath: string): void { md += `| ${entry.resolvedAt} | ${entry.kind} | ${entry.severity} | ${entry.sfVersion} | ${unit} | ${summary} | ${resolution} |\n`; } } + if (compactedResolved > 0) { + md += `\n_Compacted ${compactedResolved} older resolved entr${compactedResolved === 1 ? "y" : "ies"}; full history remains in \`self-feedback.jsonl\`._\n`; + } writeFileSync(path, md, "utf-8"); } catch { @@ -282,6 +294,25 @@ function regenerateSelfFeedbackMarkdown(basePath: string): void { } } +/** + * Rewrite SELF-FEEDBACK.md as the compact working view from jsonl. + * + * Purpose: let session-start maintenance drain legacy long markdown files even + * when no new feedback entry or resolution is recorded in that run. + * + * Consumer: startup self-feedback maintenance and operator repair commands. + */ +export function compactSelfFeedbackMarkdown( + basePath: string = process.cwd(), +): boolean { + try { + regenerateSelfFeedbackMarkdown(basePath); + return true; + } catch { + return false; + } +} + // ─── Writers ─────────────────────────────────────────────────────────────── function appendJsonl(path: string, entry: PersistedSelfFeedbackEntry): void { @@ -289,30 +320,32 @@ function appendJsonl(path: string, entry: PersistedSelfFeedbackEntry): void { appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf-8"); } -function appendSelfFeedbackRow( - basePath: string, - entry: PersistedSelfFeedbackEntry, -): void { - const path = projectMarkdownPath(basePath); - ensureDir(path); - if (!existsSync(path)) writeFileSync(path, SELF_FEEDBACK_HEADER, "utf-8"); +function formatOpenMarkdownRow(entry: PersistedSelfFeedbackEntry): string { 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`; - appendFileSync(path, row, "utf-8"); + return `| ${entry.ts} | ${entry.kind} | ${entry.severity} | ${blocking} | ${entry.sfVersion} | ${unit} | ${summary} |\n`; +} + +function formatEntryDetails(entry: PersistedSelfFeedbackEntry): string { if (entry.evidence || entry.suggestedFix) { - const detail = + return ( `\n
${entry.id} — ${entry.kind}\n\n` + (entry.evidence - ? `**Evidence:**\n\n\`\`\`\n${entry.evidence}\n\`\`\`\n\n` + ? `**Evidence:**\n\n\`\`\`\n${truncateMarkdownDetail(entry.evidence)}\n\`\`\`\n\n` : "") + (entry.suggestedFix - ? `**Suggested fix:** ${entry.suggestedFix}\n\n` + ? `**Suggested fix:** ${truncateMarkdownDetail(entry.suggestedFix)}\n\n` : "") + - `
\n`; - appendFileSync(path, detail, "utf-8"); + `\n` + ); } + return ""; +} + +function truncateMarkdownDetail(text: string): string { + if (text.length <= MARKDOWN_DETAIL_CHAR_LIMIT) return text; + return `${text.slice(0, MARKDOWN_DETAIL_CHAR_LIMIT).trimEnd()}\n\n[truncated; full detail remains in self-feedback.jsonl]`; } function formatUnitCell(occurred?: SelfFeedbackOccurredIn): string { @@ -369,7 +402,7 @@ export function recordSelfFeedback( }; if (persisted.repoIdentity === "forge") { appendJsonl(projectJsonlPath(basePath), persisted); - appendSelfFeedbackRow(basePath, persisted); + regenerateSelfFeedbackMarkdown(basePath); } else { appendJsonl(upstreamLogPath(), persisted); } @@ -442,8 +475,7 @@ export interface ResolutionInput { * naming which criteria were satisfied. (Not enforced — entries without * acceptanceCriteria are common during the bootstrap of this channel.) * - * After resolution, SELF-FEEDBACK.md is regenerated from jsonl to reflect - * the resolved state, including a "Resolved Entries" section. + * After resolution, SELF-FEEDBACK.md is regenerated as a compact working view. */ export function markResolved( entryId: string, diff --git a/src/resources/extensions/sf/tests/auto-dashboard.test.ts b/src/resources/extensions/sf/tests/auto-dashboard.test.ts index 6f336ce4e..747f639bd 100644 --- a/src/resources/extensions/sf/tests/auto-dashboard.test.ts +++ b/src/resources/extensions/sf/tests/auto-dashboard.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { test, afterEach } from 'vitest'; +import { afterEach, test, vi } from "vitest"; import { _resetWidgetModeForTests, @@ -15,6 +15,7 @@ import { getWidgetMode, unitPhaseLabel, unitVerb, + updateProgressWidget, } from "../auto-dashboard.ts"; const autoSource = readFileSync( @@ -219,7 +220,10 @@ test("formatAutoElapsed returns empty string for negative autoStartTime", () => }); test("getAutoDashboardData returns RTK savings in the dashboard payload", () => { - assert.match(autoSource, /const rtkSavings = sessionId && s\.basePath/); + assert.match( + autoSource, + /const rtkSavings =\s*\n\s*sessionId && s\.basePath/, + ); assert.match(autoSource, /rtkSavings,/); }); @@ -235,6 +239,76 @@ test("auto progress widget renders RTK savings under the footer stats line", () ); }); +test("auto progress widget invalidates cached frames while quiet", () => { + vi.useFakeTimers(); + _resetWidgetModeForTests(); + const root = makeTempDir("heartbeat"); + mkdirSync(root, { recursive: true }); + + let widget: { render(width: number): string[]; dispose(): void } | undefined; + let renderRequests = 0; + const tui = { + requestRender() { + renderRequests++; + }, + }; + const theme = { + fg(_name: string, text: string) { + return text; + }, + bold(text: string) { + return text; + }, + }; + const ctx = { + hasUI: true, + sessionManager: { getSessionId: () => null }, + ui: { + setWidget( + _key: string, + factory: (tuiArg: typeof tui, themeArg: typeof theme) => typeof widget, + ) { + widget = factory(tui, theme); + }, + }, + } as any; + + try { + updateProgressWidget( + ctx, + "research-slice", + "M001/S01", + { + phase: "executing", + activeMilestone: { id: "M001", title: "Milestone" }, + activeSlice: { id: "S01", title: "Slice" }, + activeTask: { id: "T01", title: "Task" }, + } as any, + { + getAutoStartTime: () => Date.now() - 5_000, + isStepMode: () => false, + getCmdCtx: () => null, + getBasePath: () => root, + isVerbose: () => false, + isSessionSwitching: () => false, + getCurrentDispatchedModelId: () => null, + }, + ); + assert.ok(widget); + const firstFrame = widget.render(100).join("\n"); + vi.advanceTimersByTime(1_000); + const secondFrame = widget.render(100).join("\n"); + + assert.notEqual(firstFrame, secondFrame); + assert.ok(renderRequests > 0, "heartbeat should request a TUI render"); + } finally { + widget?.dispose(); + vi.useRealTimers(); + cleanup(root); + _resetWidgetModeForTests(); + } +}); + // ─── extractUatSliceId ─────────────────────────────────────────────────── test("extractUatSliceId extracts slice ID from M001/S01 format", () => { @@ -249,7 +323,7 @@ test("extractUatSliceId returns null for invalid formats", () => { assert.equal(extractUatSliceId("M001/T01"), null); }); -test("widget mode respects project preference precedence and persists there", (t) => { +test("widget mode respects project preference precedence and persists there", () => { const homeDir = makeTempDir("home"); const projectDir = makeTempDir("project"); const globalPrefsPath = join(homeDir, ".sf", "preferences.md"); diff --git a/src/resources/extensions/sf/tests/self-feedback-markdown.test.ts b/src/resources/extensions/sf/tests/self-feedback-markdown.test.ts new file mode 100644 index 000000000..f779c500c --- /dev/null +++ b/src/resources/extensions/sf/tests/self-feedback-markdown.test.ts @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, it } from "vitest"; +import { + compactSelfFeedbackMarkdown, + markResolved, + recordSelfFeedback, +} from "../self-feedback.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-markdown-")); + 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; +} + +describe("self-feedback markdown view", () => { + it("keeps markdown compact while preserving resolved history in jsonl", () => { + const root = makeForgeProject(); + + for (let i = 0; i < 25; i++) { + const recorded = recordSelfFeedback( + { + kind: "resolved-history", + severity: "high", + summary: `resolved entry ${i}`, + evidence: `large evidence ${i}\n`.repeat(8), + suggestedFix: `fix resolved entry ${i}`, + source: "detector", + }, + root, + ); + assert.ok(recorded); + assert.equal( + markResolved( + recorded.entry.id, + { + reason: "covered by compact markdown test", + evidence: { kind: "agent-fix", commitSha: `abc12${i}` }, + }, + root, + ), + true, + ); + } + + const open = recordSelfFeedback( + { + kind: "open-work", + severity: "critical", + summary: "still needs a fix", + evidence: "operator should still see open evidence", + suggestedFix: "land the actual sf fix", + source: "agent", + }, + root, + ); + assert.ok(open); + + const markdown = readFileSync( + join(root, ".sf", "SELF-FEEDBACK.md"), + "utf-8", + ); + const jsonl = readFileSync( + join(root, ".sf", "self-feedback.jsonl"), + "utf-8", + ); + + assert.match(markdown, /## Open Entries/); + assert.match(markdown, /open-work/); + assert.match(markdown, /operator should still see open evidence/); + assert.match(markdown, /## Recently Resolved/); + assert.match(markdown, /Compacted 5 older resolved entries/); + assert.doesNotMatch(markdown, /resolved entry 0/); + assert.match(jsonl, /resolved entry 0/); + assert.equal(jsonl.trim().split("\n").length, 26); + assert.ok( + markdown.split("\n").length < 140, + "markdown should stay bounded as resolved history grows", + ); + }); + + it("keeps non-blocking evidence out of the markdown scan view", () => { + const root = makeForgeProject(); + recordSelfFeedback( + { + kind: "medium-noise", + severity: "medium", + summary: "scan row only", + evidence: "medium evidence should stay in jsonl", + source: "detector", + }, + root, + ); + + const markdown = readFileSync( + join(root, ".sf", "SELF-FEEDBACK.md"), + "utf-8", + ); + const jsonl = readFileSync( + join(root, ".sf", "self-feedback.jsonl"), + "utf-8", + ); + + assert.match(markdown, /medium-noise/); + assert.doesNotMatch(markdown, /medium evidence should stay in jsonl/); + assert.match(jsonl, /medium evidence should stay in jsonl/); + }); + + it("compacts an existing long markdown view from jsonl without a new entry", () => { + const root = makeForgeProject(); + const recorded = recordSelfFeedback( + { + kind: "legacy-long-view", + severity: "high", + summary: "already resolved", + evidence: "old evidence", + source: "detector", + }, + root, + ); + assert.ok(recorded); + assert.equal( + markResolved( + recorded.entry.id, + { + reason: "resolved before startup compact", + evidence: { kind: "agent-fix", commitSha: "abc1234" }, + }, + root, + ), + true, + ); + + writeFileSync( + join(root, ".sf", "SELF-FEEDBACK.md"), + "# SF Self-Feedback\n\n" + "stale resolved detail\n".repeat(400), + "utf-8", + ); + + assert.equal(compactSelfFeedbackMarkdown(root), true); + const markdown = readFileSync( + join(root, ".sf", "SELF-FEEDBACK.md"), + "utf-8", + ); + assert.doesNotMatch(markdown, /stale resolved detail/); + assert.match(markdown, /## Recently Resolved/); + assert.ok(markdown.split("\n").length < 40); + }); +});