diff --git a/src/headless-feedback.ts b/src/headless-feedback.ts index ea479706e..f89e71932 100644 --- a/src/headless-feedback.ts +++ b/src/headless-feedback.ts @@ -151,14 +151,35 @@ async function handleAdd( const id = newId(); const ts = new Date().toISOString(); + // Detect forge-vs-external repo via the canonical helper used by + // recordSelfFeedback; this affects which channel the entry is read from. + // Previously hardcoded "external", which incorrectly tagged forge-repo + // entries — combined with sfVersion="" below, this fed the + // auto-version-bump resolver false positives that swallowed legitimate + // operator-direction within seconds of filing (R124 incident 2026-05-17). + const sfHelpers = (await jiti.import(sfExtensionPath("self-feedback"), {})) as { + isForgeRepo?: (basePath: string) => boolean; + getCurrentSfVersion?: () => string; + }; + const repoIdentity: "forge" | "external" = sfHelpers.isForgeRepo?.(basePath) + ? "forge" + : "external"; + // sfVersion must reflect the SF version that filed the entry. An empty + // string sorts BELOW any real semver — so triageBlockedEntries would + // retry-close every empty-version entry as "version-bumped" (R124 + // incident 2026-05-17). Use the canonical helper + env fallback. + const sfVersion = + sfHelpers.getCurrentSfVersion?.() ?? + process.env.SF_VERSION ?? + "unknown"; const entry = { id, ts, kind, severity, blocking, - repoIdentity: "external" as const, - sfVersion: "", + repoIdentity, + sfVersion, basePath, occurredIn: { unitType: unitType ?? null, diff --git a/src/resources/extensions/sf/self-feedback.js b/src/resources/extensions/sf/self-feedback.js index 56f545b5c..e828d147b 100644 --- a/src/resources/extensions/sf/self-feedback.js +++ b/src/resources/extensions/sf/self-feedback.js @@ -970,12 +970,50 @@ function compareSemver(a, b) { } return 0; } +/** + * Self-feedback kinds that represent operator intent rather than mechanical + * symptoms. Auto-version-bump close MUST NOT fire on these — operator-filed + * design / capability / spec direction does NOT expire when SF's version + * bumps. Mechanical symptoms (runaway-loop:idle-halt, prompt-quality-issue, + * silent-worker-failure) ARE retry-eligible because the underlying bug may + * have been fixed in the bumped version. + * + * Observed 2026-05-17: operator filed `improvement-idea` (R124 proposal) via + * `sf headless feedback add`; entry written with empty sfVersion; resolver + * compareSemver'd "" against "2.75.4" → close as "version-bumped" within 54 + * seconds. Two defects, both fixed here: + * 1) empty/null/undefined sfVersion treated as comparable (now treated as + * unknown). + * 2) operator-direction kinds NOT exempt from the close path (now skipped). + */ +const OPERATOR_DIRECTION_KINDS = new Set([ + "improvement-idea", + "architecture-defect", + "missing-feature", + "gap", +]); + +function isMissingVersion(v) { + return ( + v === undefined || + v === null || + v === "" || + v === "unknown" || + v === "null" || + v === "undefined" + ); +} + export function triageBlockedEntries(basePath = process.cwd()) { const current = getCurrentSfVersion(); const retry = []; const stillBlocked = []; for (const e of getBlockedEntries(basePath)) { - if (current === "unknown" || e.sfVersion === "unknown") { + if (OPERATOR_DIRECTION_KINDS.has(e.kind)) { + stillBlocked.push(e); + continue; + } + if (isMissingVersion(current) || isMissingVersion(e.sfVersion)) { stillBlocked.push(e); continue; }