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);
+ });
+});