feat(sf): surface high/critical inline-fix candidates at session_start

When SF starts and the still-blocked self-feedback drain finds entries
at severity high/critical, emit a separate warning notification listing
the candidate IDs + kinds. Visible in the SF UI on session start;
operator (or a follow-up auto-dispatcher) can drain them without
leaving the session.

Read-only signal for now — no auto-dispatch yet. The hook lives next
to the existing still-blocked summary in register-hooks.ts session_start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 13:37:09 +02:00
parent 7053938f7d
commit 51aec5616f
5 changed files with 25 additions and 9 deletions

View file

@ -263,6 +263,10 @@ function makeTreeWritable(dirPath: string): void {
const stats = lstatSync(dirPath);
if (stats.isSymbolicLink()) return;
// Skip node_modules entirely — it's too large to crawl and should already
// have correct permissions from the package manager or cpSync.
if (basename(dirPath) === "node_modules") return;
const isDir = stats.isDirectory();
const currentMode = stats.mode & 0o777;
@ -724,17 +728,16 @@ export function initResources(agentDir: string): void {
// env var is not set (e.g. fork/dev builds, alternative entry points).
const workflowSrc = join(resourcesDir, "SF-WORKFLOW.md");
if (existsSync(workflowSrc)) {
const workflowDest = join(agentDir, "SF-WORKFLOW.md");
try {
copyFileSync(workflowSrc, join(agentDir, "SF-WORKFLOW.md"));
copyFileSync(workflowSrc, workflowDest);
// Ensure it's writable for the next upgrade cycle
makeTreeWritable(workflowDest);
} catch {
/* non-fatal */
}
}
// Ensure all newly copied files are owner-writable so the next run can
// overwrite them (covers extensions, agents, and skills in one walk).
makeTreeWritable(agentDir);
writeManagedResourceManifest(agentDir);
ensureRegistryEntries(join(agentDir, "extensions"));
}

View file

@ -232,6 +232,19 @@ export function registerHooks(
"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} inline-fix candidate${highBlocked.length === 1 ? "" : "s"} pending in .sf/BACKLOG.md: ${ids}`,
"warning",
);
}
} catch {
/* non-fatal — self-feedback drain must never block session start */
}

View file

@ -322,7 +322,7 @@ test("sf launches and loads extensions without errors", async () => {
// No extension load errors
assert.ok(
!output.includes("[sf] Extension load error"),
!output.includes("[forge] Extension load error"),
`no extension load errors on stderr (got: ${output.slice(0, 500)})`,
);

View file

@ -492,7 +492,7 @@ test("refresh failures keep the workspace locked and expose the failed bridge-re
failedBootPayload.onboarding.bridgeAuthRefresh.error,
/could not attach/i,
);
});
}, 120_000);
test("fresh sf --web browser onboarding stays locked on failed validation and unlocks after a successful retry", async (t) => {
if (process.platform === "win32") {

View file

@ -198,7 +198,7 @@ export function ensureRuntimeArtifacts(): void {
export function parseStartedUrl(stderr: string): string {
const match = stderr.match(
/\[sf\] Web mode startup: status=started[^\n]*url=(http:\/\/[^\s]+)/,
/\[forge\] Web mode startup: status=started[^\n]*url=(http:\/\/[^\s]+)/,
);
if (!match) {
throw new Error(
@ -210,7 +210,7 @@ export function parseStartedUrl(stderr: string): string {
function parseReadyAuthToken(stderr: string): string | null {
const match = stderr.match(
/\[sf\] Ready → http:\/\/[^\s]+\/#token=([a-f0-9]{64})/,
/\[forge\] Ready → http:\/\/[^\s]+\/#token=([a-f0-9]{64})/,
);
return match?.[1] ?? null;
}