fix(sf): compact feedback view and animate progress

This commit is contained in:
Mikael Hugo 2026-05-02 16:43:54 +02:00
parent fbee428196
commit 364a1e000e
5 changed files with 366 additions and 69 deletions

View file

@ -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);
},
};
});

View file

@ -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"}`,

View file

@ -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<details><summary>${entry.id}${entry.kind}</summary>\n\n` +
(entry.evidence
? `**Evidence:**\n\n\`\`\`\n${entry.evidence}\n\`\`\`\n\n`
: "") +
(entry.suggestedFix
? `**Suggested fix:** ${entry.suggestedFix}\n\n`
: "") +
`</details>\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<details><summary>${entry.id}${entry.kind}</summary>\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`
: "") +
`</details>\n`;
appendFileSync(path, detail, "utf-8");
`</details>\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,

View file

@ -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");

View file

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