diff --git a/src/resources/extensions/sf/auto-bootstrap-context.js b/src/resources/extensions/sf/auto-bootstrap-context.js index 334bdc8ad..cdfc989cb 100644 --- a/src/resources/extensions/sf/auto-bootstrap-context.js +++ b/src/resources/extensions/sf/auto-bootstrap-context.js @@ -23,7 +23,6 @@ const AUTO_BOOTSTRAP_SF_SPEC_FILES = [ ".sf/PRINCIPLES.md", ".sf/TASTE.md", ".sf/preferences.yaml", - ".sf/PREFERENCES.md", ".sf/ANTI-GOALS.md", ".sf/CODEBASE.md", ]; diff --git a/src/resources/extensions/sf/auto-dashboard.js b/src/resources/extensions/sf/auto-dashboard.js index b4c0a108a..b5a83f6f5 100644 --- a/src/resources/extensions/sf/auto-dashboard.js +++ b/src/resources/extensions/sf/auto-dashboard.js @@ -21,7 +21,7 @@ import { getActiveHook } from "./post-unit-hooks.js"; import { getGlobalSFPreferencesPath, getProjectSFPreferencesPath, - parsePreferencesMarkdown, + parsePreferencesYaml, } from "./preferences.js"; import { computeProgressScore } from "./progress-score.js"; import { @@ -450,7 +450,7 @@ function safeReadTextFile(path) { function readWidgetModeFromFile(path) { const raw = safeReadTextFile(path); if (!raw) return undefined; - const prefs = parsePreferencesMarkdown(raw); + const prefs = parsePreferencesYaml(raw); const saved = prefs?.widget_mode; if (saved && WIDGET_MODES.includes(saved)) { return saved; diff --git a/src/resources/extensions/sf/auto-worktree.js b/src/resources/extensions/sf/auto-worktree.js index 8047cbe92..be0ba214c 100644 --- a/src/resources/extensions/sf/auto-worktree.js +++ b/src/resources/extensions/sf/auto-worktree.js @@ -71,7 +71,6 @@ import { const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); const PROJECT_PREFERENCES_FILE = "preferences.yaml"; -const LEGACY_PREFERENCES_FILES = ["PREFERENCES.md", "preferences.md"]; // ─── Shared Constants & Helpers ───────────────────────────────────────────── /** * Root-level .sf/ state files synced between worktree and project root. @@ -552,29 +551,23 @@ export function syncSfStateToWorktree(mainBasePath, worktreePath_) { } } // Forward-sync project preferences from project root to worktree (additive only). - // Prefer the canonical yaml file, but keep legacy md fallbacks so older repos still work. { - const worktreeHasPreferences = - existsSync(join(wtSf, PROJECT_PREFERENCES_FILE)) || - LEGACY_PREFERENCES_FILES.some((f) => existsSync(join(wtSf, f))); + const worktreeHasPreferences = existsSync(join(wtSf, PROJECT_PREFERENCES_FILE)); if (!worktreeHasPreferences) { - for (const file of [PROJECT_PREFERENCES_FILE, ...LEGACY_PREFERENCES_FILES]) { - const src = join(mainSf, file); - const dst = join(wtSf, file); - if (existsSync(src)) { - try { - cpSync(src, dst); - synced.push(file); - } catch (err) { - /* non-fatal */ - logWarning( - "worktree", - `preferences copy failed (${file}): ${err instanceof Error ? err.message : String(err)}`, - ); - } - break; + const src = join(mainSf, PROJECT_PREFERENCES_FILE); + const dst = join(wtSf, PROJECT_PREFERENCES_FILE); + if (existsSync(src)) { + try { + cpSync(src, dst); + synced.push(PROJECT_PREFERENCES_FILE); + } catch (err) { + /* non-fatal */ + logWarning( + "worktree", + `preferences copy failed (${PROJECT_PREFERENCES_FILE}): ${err instanceof Error ? err.message : String(err)}`, + ); + } } - } } } // Sync milestones: copy entire milestone directories that are missing @@ -1190,24 +1183,13 @@ function copyPlanningArtifacts(srcBase, wtPath) { ]) { safeCopy(join(srcSf, file), join(dstSf, file), { force: true }); } - // Seed canonical preferences.yaml when available; fall back to legacy md files. + // Seed canonical preferences.yaml when available. if (existsSync(join(srcSf, PROJECT_PREFERENCES_FILE))) { safeCopy( join(srcSf, PROJECT_PREFERENCES_FILE), join(dstSf, PROJECT_PREFERENCES_FILE), { force: true }, ); - } else { - for (const legacyFile of LEGACY_PREFERENCES_FILES) { - if (existsSync(join(srcSf, legacyFile))) { - safeCopy( - join(srcSf, legacyFile), - join(dstSf, legacyFile), - { force: true }, - ); - break; - } - } } // Shared WAL (R012): worktrees use the project root's DB directly. // No longer copy sf.db into the worktree — the DB path resolver in diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.js b/src/resources/extensions/sf/bootstrap/register-hooks.js index ca921b9aa..aa8bc4579 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.js +++ b/src/resources/extensions/sf/bootstrap/register-hooks.js @@ -490,6 +490,16 @@ export function registerHooks(pi, ecosystemHandlers = []) { } catch { /* non-fatal — requirement promoter must never block session start */ } + // Refresh model catalogs for discoverable providers in the background. + try { + const { scheduleModelCatalogRefresh } = await import( + "../model-catalog-cache.js" + ); + const { getKeyManagerAuthStorage } = await import("../key-manager.js"); + scheduleModelCatalogRefresh(process.cwd(), getKeyManagerAuthStorage()); + } catch { + /* non-fatal — model catalog refresh must never block session start */ + } // Compaction should never behave like a stop boundary. If autonomous mode // was active when compaction happened, continue automatically on session start. try { diff --git a/src/resources/extensions/sf/commands-prefs-wizard.js b/src/resources/extensions/sf/commands-prefs-wizard.js index 277624909..7bc52b987 100644 --- a/src/resources/extensions/sf/commands-prefs-wizard.js +++ b/src/resources/extensions/sf/commands-prefs-wizard.js @@ -10,17 +10,15 @@ import { join } from "node:path"; import { runClaudeImportFlow } from "./claude-import.js"; import { loadFile, - parseFrontmatterMap, saveFile, - splitFrontmatter, } from "./files.js"; import { getGlobalSFPreferencesPath, - getLegacyGlobalSFPreferencesPath, getProjectSFPreferencesPath, loadEffectiveSFPreferences, loadGlobalSFPreferences, loadProjectSFPreferences, + parsePreferencesYaml, resolveAllSkillReferences, } from "./preferences.js"; @@ -32,32 +30,11 @@ export { import { serializePreferencesToFrontmatter } from "./preferences-serializer.js"; -/** Extract body content after frontmatter closing delimiter, or null if none. */ -function extractBodyAfterFrontmatter(content) { - const closingIdx = content.indexOf("\n---", content.indexOf("---")); - if (closingIdx === -1) return null; - const afterFrontmatter = content.slice(closingIdx + 4); - return afterFrontmatter.trim() ? afterFrontmatter : null; -} -/** - * Serialize prefs and save to path, using format appropriate for file type. - * .yaml → raw YAML (no --- markers, no Markdown body) - * .md → YAML frontmatter + preserved or default Markdown body - */ +/** Serialize prefs and save to path as raw YAML. */ async function savePreferencesFile(path, prefs) { prefs.version = prefs.version || 1; const yaml = serializePreferencesToFrontmatter(prefs); - if (path.endsWith(".yaml")) { - await saveFile(path, yaml); - return; - } - let body = - "\n# SF Skill Preferences\n\nSee `~/.sf/agent/extensions/sf/docs/preferences-reference.md` for full field documentation and examples.\n"; - if (existsSync(path)) { - const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); - if (preserved) body = preserved; - } - await saveFile(path, `---\n${yaml}---${body}`); + await saveFile(path, yaml); } // ─── Numeric validation helpers ────────────────────────────────────────────── /** Parse a string as a non-negative integer, or return null on failure. */ @@ -109,9 +86,8 @@ export async function handlePrefs(args, ctx) { const globalPrefs = loadGlobalSFPreferences(); const projectPrefs = loadProjectSFPreferences(); const canonicalGlobal = getGlobalSFPreferencesPath(); - const legacyGlobal = getLegacyGlobalSFPreferencesPath(); const globalStatus = globalPrefs - ? `present: ${globalPrefs.path}${globalPrefs.path === legacyGlobal ? " (legacy fallback)" : ""}` + ? `present: ${globalPrefs.path}` : `missing: ${canonicalGlobal}`; const projectStatus = projectPrefs ? `present: ${projectPrefs.path}` @@ -158,12 +134,7 @@ export async function handleImportClaude(ctx, scope) { const readPrefs = () => { if (!existsSync(path)) return { version: 1 }; const content = readFileSync(path, "utf-8"); - if (path.endsWith(".yaml")) { - const [frontmatterLines] = splitFrontmatter(`---\n${content}\n---`); - return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 }; - } - const [frontmatterLines] = splitFrontmatter(content); - return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 }; + return parsePreferencesYaml(content) ?? { version: 1 }; }; const writePrefs = async (prefs) => savePreferencesFile(path, prefs); await runClaudeImportFlow(ctx, scope, readPrefs, writePrefs); @@ -840,10 +811,8 @@ export async function handlePrefsWizard(ctx, scope) { } export async function ensurePreferencesFile(path, ctx, scope) { if (!existsSync(path)) { - // Use yaml template for .yaml paths, markdown template for legacy .md paths - const templateFile = path.endsWith(".yaml") ? "preferences.yaml" : "PREFERENCES.md"; const template = await loadFile( - join(import.meta.dirname, "templates", templateFile), + join(import.meta.dirname, "templates", "preferences.yaml"), ); if (!template) { ctx.ui.notify("Could not load SF preferences template.", "error"); diff --git a/src/resources/extensions/sf/deep-project-setup-policy.js b/src/resources/extensions/sf/deep-project-setup-policy.js index 6b45955c1..86409ce51 100644 --- a/src/resources/extensions/sf/deep-project-setup-policy.js +++ b/src/resources/extensions/sf/deep-project-setup-policy.js @@ -22,8 +22,6 @@ export function isWorkflowPrefsCaptured(basePath) { const sfRootPath = sfRoot(basePath); const candidates = [ join(sfRootPath, "preferences.yaml"), - join(sfRootPath, "PREFERENCES.md"), - join(sfRootPath, "preferences.md"), ]; for (const prefsPath of candidates) { if (!existsSync(prefsPath)) continue; diff --git a/src/resources/extensions/sf/detection.js b/src/resources/extensions/sf/detection.js index 3ba74ef40..677359bc0 100644 --- a/src/resources/extensions/sf/detection.js +++ b/src/resources/extensions/sf/detection.js @@ -311,9 +311,7 @@ function detectV2Sf(basePath) { const sfPath = sfRoot(basePath); if (!existsSync(sfPath)) return null; const hasPreferences = - existsSync(join(sfPath, "preferences.yaml")) || - existsSync(join(sfPath, "PREFERENCES.md")) || - existsSync(join(sfPath, "preferences.md")); + existsSync(join(sfPath, "preferences.yaml")); const hasContext = existsSync(join(sfPath, "CONTEXT.md")); let milestoneCount = 0; const milestonesPath = join(sfPath, "milestones"); @@ -862,11 +860,7 @@ function isMakeTestTargetSafe(basePath) { * Check if global SF setup exists (has ~/.sf/ with preferences). */ export function hasGlobalSetup() { - return ( - existsSync(join(sfHome, "preferences.yaml")) || - existsSync(join(sfHome, "PREFERENCES.md")) || - existsSync(join(sfHome, "preferences.md")) - ); + return existsSync(join(sfHome, "preferences.yaml")); } /** * Check if this is the very first time SF has been used on this machine. @@ -875,11 +869,7 @@ export function hasGlobalSetup() { export function isFirstEverLaunch() { if (!existsSync(sfHome)) return true; // If we have preferences, not first launch - if ( - existsSync(join(sfHome, "preferences.yaml")) || - existsSync(join(sfHome, "PREFERENCES.md")) || - existsSync(join(sfHome, "preferences.md")) - ) { + if (existsSync(join(sfHome, "preferences.yaml"))) { return false; } // If we have auth.json, not first launch (onboarding.ts already ran) diff --git a/src/resources/extensions/sf/docs/preferences-reference.md b/src/resources/extensions/sf/docs/preferences-reference.md index 40d7b871a..abd4933bb 100644 --- a/src/resources/extensions/sf/docs/preferences-reference.md +++ b/src/resources/extensions/sf/docs/preferences-reference.md @@ -51,8 +51,8 @@ skill_rules: [] Preferences are loaded from two locations and merged: -1. **Global:** `~/.sf/PREFERENCES.md` — applies to all projects -2. **Project:** `.sf/PREFERENCES.md` — applies to the current project only +1. **Global:** `~/.sf/preferences.yaml` — applies to all projects +2. **Project:** `.sf/preferences.yaml` — applies to the current project only **Merge behavior** (see `mergePreferences()` in `preferences.ts`): diff --git a/src/resources/extensions/sf/gitignore.js b/src/resources/extensions/sf/gitignore.js index c1697e1eb..183b92996 100644 --- a/src/resources/extensions/sf/gitignore.js +++ b/src/resources/extensions/sf/gitignore.js @@ -293,14 +293,10 @@ export function untrackRuntimeFiles(basePath) { * Ensure basePath/.sf/preferences.yaml exists as a template. * Creates the file with YAML only if it doesn't exist. * Returns true if created, false if already exists. - * - * Checks yaml (canonical) and legacy .md variants to avoid duplicates. */ export function ensurePreferences(basePath) { const yamlPath = join(sfRoot(basePath), "preferences.yaml"); - const uppercasePath = join(sfRoot(basePath), "PREFERENCES.md"); - const legacyPath = join(sfRoot(basePath), "preferences.md"); - if (existsSync(yamlPath) || existsSync(uppercasePath) || existsSync(legacyPath)) { + if (existsSync(yamlPath)) { return false; } // Auto-detect project type and seed verification_commands. diff --git a/src/resources/extensions/sf/guided-flow.js b/src/resources/extensions/sf/guided-flow.js index 3710b1371..9de98842a 100644 --- a/src/resources/extensions/sf/guided-flow.js +++ b/src/resources/extensions/sf/guided-flow.js @@ -1594,13 +1594,12 @@ export async function showWorkflowEntry(ctx, pi, basePath, options) { } // ── Detection preamble — run before any bootstrap ──────────────────── // Check bootstrap completeness, not just .sf/ directory existence. - // A zombie .sf/ state (symlink exists but missing preferences.yaml/PREFERENCES.md and + // A zombie .sf/ state (symlink exists but missing preferences.yaml and // milestones/) must trigger the init wizard, not skip it (#2942). const sfPath = sfRoot(basePath); const hasBootstrapArtifacts = existsSync(sfPath) && (existsSync(join(sfPath, "preferences.yaml")) || - existsSync(join(sfPath, "PREFERENCES.md")) || existsSync(join(sfPath, "milestones"))); if (!hasBootstrapArtifacts) { const detection = detectProjectState(basePath); diff --git a/src/resources/extensions/sf/learning/fallback-chain-writer.mjs b/src/resources/extensions/sf/learning/fallback-chain-writer.mjs index 4e9fe9028..83dcdfb0f 100644 --- a/src/resources/extensions/sf/learning/fallback-chain-writer.mjs +++ b/src/resources/extensions/sf/learning/fallback-chain-writer.mjs @@ -8,14 +8,14 @@ * immediately returns `null`, which surfaces as `"All providers exhausted"` * even when there are dozens of healthy providers available. * - * ## Why this lives in the plugin, not in preferences.md + * ## Why this lives in the plugin, not in preferences.yaml * - * `~/.sf/preferences.md` tells sf which model to START a unit with — it + * `~/.sf/preferences.yaml` tells sf which model to START a unit with — it * feeds `before_model_select`, which this plugin already intercepts. But * once dispatch begins and the LLM call 429s, ai's retry path reads * `~/.sf/agent/settings.json` → `fallback.chains` directly via * `SettingsManager.getFallbackSettings()`. Those two configs are separate - * pipelines. preferences.md never reaches the retry walker. + * pipelines. preferences.yaml never reaches the retry walker. * * The plugin owns this file because: * 1. Rankings are dynamic — Bayesian blended priors + observed outcomes diff --git a/src/resources/extensions/sf/model-catalog-cache.js b/src/resources/extensions/sf/model-catalog-cache.js new file mode 100644 index 000000000..ef4243d7e --- /dev/null +++ b/src/resources/extensions/sf/model-catalog-cache.js @@ -0,0 +1,157 @@ +/** + * model-catalog-cache.js — live model-list discovery with disk cache. + * + * Fetches model lists from providers that publish a /v1/models (or equivalent) + * endpoint. Results are cached to .sf/runtime/model-catalog/.json + * with a 6-hour TTL so the model picker and preference validation see fresh + * model IDs without blocking the event loop. + * + * Provider configuration (base URL, auth format, model filter patterns) comes + * from provider-catalog-config.js — not hardcoded here. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + DISCOVERABLE_PROVIDER_IDS, + getProviderCatalogConfig, + getProviderModelExcludePatterns, +} from "./provider-catalog-config.js"; +import { sfRuntimeRoot } from "./paths.js"; + +const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; + +function cacheDirPath(basePath) { + return join(sfRuntimeRoot(basePath), "model-catalog"); +} + +function cacheFilePath(basePath, providerId) { + return join(cacheDirPath(basePath), `${providerId}.json`); +} + +/** + * Read cached model IDs for a provider. Returns null if cache is missing or stale. + */ +export function readCachedModelIds(basePath, providerId) { + try { + const path = cacheFilePath(basePath, providerId); + if (!existsSync(path)) return null; + const entry = JSON.parse(readFileSync(path, "utf-8")); + if (!entry?.fetchedAt || !Array.isArray(entry.modelIds)) return null; + if (Date.now() - new Date(entry.fetchedAt).getTime() > CATALOG_TTL_MS) return null; + return entry.modelIds; + } catch { + return null; + } +} + +function writeCacheEntry(basePath, providerId, modelIds) { + try { + mkdirSync(cacheDirPath(basePath), { recursive: true }); + writeFileSync( + cacheFilePath(basePath, providerId), + JSON.stringify({ fetchedAt: new Date().toISOString(), modelIds }), + "utf-8", + ); + } catch { + // Best-effort — never fail the caller. + } +} + +function buildAuthHeaders(cfg, apiKey) { + switch (cfg.auth.type) { + case "bearer": + return { Authorization: `Bearer ${apiKey}` }; + case "x-api-key": + return { [cfg.auth.header ?? "x-api-key"]: apiKey }; + case "query-param": + return {}; + default: + return {}; + } +} + +function buildUrl(cfg, apiKey) { + const base = cfg.baseUrl.replace(/\/$/, ""); + const path = cfg.modelsPath; + if (cfg.auth.type === "query-param" && cfg.auth.param) { + return `${base}${path}?${cfg.auth.param}=${encodeURIComponent(apiKey)}`; + } + return `${base}${path}`; +} + +function parseModelIds(cfg, json) { + let items; + if (cfg.type === "google") { + // Google: { models: [{name: "models/gemini-..."}] } + items = Array.isArray(json?.models) ? json.models : null; + if (!items) return null; + return items.map((m) => (m.name ?? "").replace(/^models\//, "")).filter(Boolean); + } + // OpenAI-compatible: { data: [{id}] } or { models: [{id}] } + items = Array.isArray(json?.data) ? json.data : Array.isArray(json?.models) ? json.models : null; + if (!items) return null; + return items.map((m) => m.id ?? m.name).filter(Boolean); +} + +function applyModelFilter(providerId, modelIds) { + const patterns = getProviderModelExcludePatterns(providerId); + if (patterns.length === 0) return modelIds; + return modelIds.filter((id) => !patterns.some((re) => re.test(id))); +} + +/** + * Fetch the live model list for one provider and update the disk cache. + * Returns the filtered model ID array on success, null on any error. + */ +export async function refreshProviderCatalog(basePath, providerId, apiKey) { + const cfg = getProviderCatalogConfig(providerId); + if (!cfg?.modelsPath || !apiKey) return null; + try { + const url = buildUrl(cfg, apiKey); + const res = await fetch(url, { + headers: buildAuthHeaders(cfg, apiKey), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) return null; + const json = await res.json(); + const raw = parseModelIds(cfg, json); + if (!raw) return null; + const modelIds = applyModelFilter(providerId, raw); + writeCacheEntry(basePath, providerId, modelIds); + return modelIds; + } catch { + return null; + } +} + +/** + * Fire-and-forget background refresh for all discoverable providers that have + * a key in auth.json and a stale or absent cache. Safe to call at session + * start — never throws, never blocks. + */ +export function scheduleModelCatalogRefresh(basePath, auth) { + setImmediate(async () => { + for (const providerId of DISCOVERABLE_PROVIDER_IDS) { + try { + const creds = auth.getCredentialsForProvider(providerId); + const apiKey = creds.find((c) => c.type === "api_key" && c.key)?.key; + if (!apiKey) continue; + if (readCachedModelIds(basePath, providerId) !== null) continue; + await refreshProviderCatalog(basePath, providerId, apiKey); + } catch { + // Per-provider failures are silently swallowed. + } + } + }); +} + +/** + * Return the union of SDK-static model IDs and live-discovered model IDs for a + * provider. Hot path — reads only from disk cache, no network call. + * + * Consumer: model picker, preference validation for unknown model IDs. + */ +export function getKnownModelIds(basePath, providerId, sdkModelIds = []) { + const cached = readCachedModelIds(basePath, providerId) ?? []; + return [...new Set([...sdkModelIds, ...cached])]; +} diff --git a/src/resources/extensions/sf/planning-depth.js b/src/resources/extensions/sf/planning-depth.js index df0451160..0ae16603e 100644 --- a/src/resources/extensions/sf/planning-depth.js +++ b/src/resources/extensions/sf/planning-depth.js @@ -2,29 +2,18 @@ // // Persists the user's deep-mode opt-in across sessions. Reads the existing // preferences file (if any), parses its YAML, sets/updates planning_depth, -// and writes the file back preserving body content (for legacy .md) or the full -// YAML (for .yaml). +// and writes the full YAML back. import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { sfRoot } from "./paths.js"; import { logWarning } from "./workflow-logger.js"; -const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; /** - * Resolve the path to the project-level preferences file (yaml canonical, md fallback). - * Returns { path, isYaml } where isYaml indicates the format to use for writes. + * Resolve the path to the project-level preferences file. */ function getProjectSFPreferencesFilePath(basePath) { - const sfRootPath = sfRoot(basePath); - const yamlPath = join(sfRootPath, "preferences.yaml"); - if (existsSync(yamlPath)) return { path: yamlPath, isYaml: true }; - const upperMdPath = join(sfRootPath, "PREFERENCES.md"); - if (existsSync(upperMdPath)) return { path: upperMdPath, isYaml: false }; - const lowerMdPath = join(sfRootPath, "preferences.md"); - if (existsSync(lowerMdPath)) return { path: lowerMdPath, isYaml: false }; - // Default to yaml for new files - return { path: yamlPath, isYaml: true }; + return join(sfRoot(basePath), "preferences.yaml"); } /** * Resolve the path to the project-level .sf/runtime/research-decision.json file. @@ -49,84 +38,53 @@ export function writeDefaultResearchSkipDecision(basePath) { writeFileSync(decisionPath, payload, "utf-8"); } /** - * Set planning_depth in the project's preferences file (.sf/preferences.yaml or legacy .sf/PREFERENCES.md). - * Creates the file if it does not exist. Preserves existing frontmatter - * keys and body content. Intended to be called when the user opts into - * (or out of) deep mode via `/new-project --deep` or similar. + * Set planning_depth in the project's preferences file (.sf/preferences.yaml). + * Creates the file if it does not exist. Preserves existing YAML keys. + * Intended to be called when the user opts into (or out of) deep mode via + * `/new-project --deep` or similar. */ export function setPlanningDepth(basePath, depth) { - const { path, isYaml } = getProjectSFPreferencesFilePath(basePath); - const { frontmatter, body } = readProjectPreferencesParts(path, isYaml); + const path = getProjectSFPreferencesFilePath(basePath); + const { frontmatter } = readProjectPreferencesParts(path); frontmatter.planning_depth = depth; if (depth === "deep") { applyDeepWorkflowPreferenceDefaults(frontmatter); } - writeProjectPreferencesParts(path, frontmatter, body, isYaml); + writeProjectPreferencesParts(path, frontmatter); if (depth === "deep") { ensureResearchDecisionDefault(basePath); } } export function ensureWorkflowPreferencesCaptured(basePath) { - const { path, isYaml } = getProjectSFPreferencesFilePath(basePath); - const { frontmatter, body } = readProjectPreferencesParts(path, isYaml); + const path = getProjectSFPreferencesFilePath(basePath); + const { frontmatter } = readProjectPreferencesParts(path); frontmatter.planning_depth = "deep"; applyDeepWorkflowPreferenceDefaults(frontmatter); - writeProjectPreferencesParts(path, frontmatter, body, isYaml); + writeProjectPreferencesParts(path, frontmatter); ensureResearchDecisionDefault(basePath); } -function readProjectPreferencesParts(path, isYaml) { +function readProjectPreferencesParts(path) { let frontmatter = {}; - let body = ""; if (existsSync(path)) { const content = readFileSync(path, "utf-8"); - if (isYaml) { - // Pure YAML — no frontmatter markers - try { - const parsed = parseYaml(content); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - frontmatter = parsed; - } - } catch (err) { - logWarning( - "guided", - `preferences.yaml has invalid YAML — rewriting: ${err instanceof Error ? err.message : String(err)}`, - ); + try { + const parsed = parseYaml(content); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + frontmatter = parsed; } - return { frontmatter, body: "" }; - } - const match = content.match(FRONTMATTER_RE); - if (match) { - try { - const parsed = parseYaml(match[1]); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - frontmatter = parsed; - } - body = match[2]; - } catch (err) { - logWarning( - "guided", - `preferences file frontmatter has invalid YAML — preserving body and rewriting frontmatter: ${err instanceof Error ? err.message : String(err)}`, - ); - body = content; - } - } else { - body = content; + } catch (err) { + logWarning( + "guided", + `preferences.yaml has invalid YAML — rewriting: ${err instanceof Error ? err.message : String(err)}`, + ); } } - return { frontmatter, body }; + return { frontmatter }; } -function writeProjectPreferencesParts(path, frontmatter, body, isYaml) { +function writeProjectPreferencesParts(path, frontmatter) { const yamlBlock = stringifyYaml(frontmatter).replace(/\n$/, ""); - let newContent; - if (isYaml) { - newContent = `${yamlBlock}\n`; - } else { - newContent = body - ? `---\n${yamlBlock}\n---\n\n${body.replace(/^\n+/, "")}` - : `---\n${yamlBlock}\n---\n`; - } mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, newContent, "utf-8"); + writeFileSync(path, `${yamlBlock}\n`, "utf-8"); } function applyDeepWorkflowPreferenceDefaults(frontmatter) { if (frontmatter.commit_policy === undefined) { diff --git a/src/resources/extensions/sf/preferences-template-upgrade.js b/src/resources/extensions/sf/preferences-template-upgrade.js index 9faa5ff6b..b1d7b0fb3 100644 --- a/src/resources/extensions/sf/preferences-template-upgrade.js +++ b/src/resources/extensions/sf/preferences-template-upgrade.js @@ -1,17 +1,15 @@ /** * Self-aligning template upgrades for SF config files. * - * Goal: when sf evolves, projects' .sf/PREFERENCES.md (and friends) get - * brought into alignment WITHOUT human intervention. The file's frontmatter - * stamps `last_synced_with_sf: `; on every load, sf compares to its - * own version and silently re-writes the frontmatter to match. The body - * (Markdown after the frontmatter) is preserved verbatim — users can keep - * notes there and sf won't clobber them. + * Goal: when sf evolves, projects' .sf/preferences.yaml gets brought into + * alignment WITHOUT human intervention. The file's frontmatter stamps + * `last_synced_with_sf: `; on every load, sf compares to its own + * version and silently re-writes the file to match. * * What this is NOT: a human-facing warning system. The end-user shouldn't * have to read drift advisories; sf keeps its own house in order. */ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import { serializePreferencesToFrontmatter } from "./preferences-serializer.js"; export function detectTemplateDrift(prefs) { const toVersion = process.env.SF_VERSION || "0.0.0"; @@ -49,37 +47,10 @@ export function upgradePreferencesFileIfDrifted(path, prefs) { last_synced_with_sf: drift.toVersion, }; if (!existsSync(path)) return upgraded; - let content; try { - content = readFileSync(path, "utf-8"); - } catch { - return upgraded; - } - // Pure YAML file — rewrite entirely (no frontmatter markers, no body to preserve) - if (path.endsWith(".yaml")) { - try { - writeFileSync(path, serializePreferencesToFrontmatter(upgraded), "utf-8"); - } catch { - // Read-only filesystem, permission error, etc. — not fatal. - } - return upgraded; - } - const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n"; - if (!content.startsWith(startMarker)) { - // File doesn't have the canonical frontmatter shape — leave it alone. - return upgraded; - } - const endIdx = content.indexOf("\n---", startMarker.length); - if (endIdx === -1) return upgraded; - // Body is everything after the frontmatter's closing `---` line. - const bodyStart = content.indexOf("\n", endIdx + 1); - const body = bodyStart === -1 ? "" : content.slice(bodyStart); - const newFrontmatter = `---\n${serializePreferencesToFrontmatter(upgraded)}---`; - try { - writeFileSync(path, newFrontmatter + body, "utf-8"); + writeFileSync(path, serializePreferencesToFrontmatter(upgraded), "utf-8"); } catch { // Read-only filesystem, permission error, etc. — not fatal. - return upgraded; } return upgraded; } diff --git a/src/resources/extensions/sf/preferences.js b/src/resources/extensions/sf/preferences.js index e1703c810..345fa7748 100644 --- a/src/resources/extensions/sf/preferences.js +++ b/src/resources/extensions/sf/preferences.js @@ -78,20 +78,10 @@ export { function sfHome() { return process.env.SF_HOME || join(homedir(), ".sf"); } -// Canonical new location — pure YAML, no frontmatter markers +// Canonical location — pure YAML, no frontmatter markers function globalPreferencesYamlPath() { return join(sfHome(), "preferences.yaml"); } -// Legacy markdown paths kept for fallback reads -function globalPreferencesPath() { - return join(sfHome(), "preferences.md"); -} -function globalPreferencesPathUppercase() { - return join(sfHome(), "PREFERENCES.md"); -} -function legacyGlobalPreferencesPath() { - return join(homedir(), ".pi", "agent", "sf-preferences.md"); -} /** * Resolve the "project root" for preferences. When SF is running inside a * git worktree (e.g. `.sf/worktrees/M003/`), project-level prefs should @@ -131,58 +121,34 @@ function projectPrefsRoot() { } return cwd; } -// Canonical new location — pure YAML +// Canonical location — pure YAML function projectPreferencesYamlPath() { return join(sfRoot(projectPrefsRoot()), "preferences.yaml"); } -// Legacy markdown paths kept for fallback reads -function projectPreferencesPath() { - return join(sfRoot(projectPrefsRoot()), "preferences.md"); -} -function projectPreferencesPathUppercase() { - return join(sfRoot(projectPrefsRoot()), "PREFERENCES.md"); -} /** - * Get the canonical path for the global SF preferences file. - * New installs use preferences.yaml; legacy .md files are read as fallbacks. + * Get the canonical path for the global SF preferences file (preferences.yaml). */ export function getGlobalSFPreferencesPath() { return globalPreferencesYamlPath(); } /** - * Get the path to the legacy global SF preferences file (deprecated location). - */ -export function getLegacyGlobalSFPreferencesPath() { - return legacyGlobalPreferencesPath(); -} -/** - * Get the canonical path for the project-level SF preferences file. - * New installs use preferences.yaml; legacy .md files are read as fallbacks. + * Get the canonical path for the project-level SF preferences file (preferences.yaml). */ export function getProjectSFPreferencesPath() { return projectPreferencesYamlPath(); } // ─── Loading ──────────────────────────────────────────────────────────────── /** - * Load global SF preferences, trying yaml first then legacy markdown locations. + * Load global SF preferences from preferences.yaml. */ export function loadGlobalSFPreferences() { - return ( - loadPreferencesFile(globalPreferencesYamlPath(), "global") ?? - loadPreferencesFile(globalPreferencesPath(), "global") ?? - loadPreferencesFile(globalPreferencesPathUppercase(), "global") ?? - loadPreferencesFile(legacyGlobalPreferencesPath(), "global") - ); + return loadPreferencesFile(globalPreferencesYamlPath(), "global"); } /** - * Load project-level SF preferences, trying yaml first then legacy markdown. + * Load project-level SF preferences from preferences.yaml. */ export function loadProjectSFPreferences() { - return ( - loadPreferencesFile(projectPreferencesYamlPath(), "project") ?? - loadPreferencesFile(projectPreferencesPath(), "project") ?? - loadPreferencesFile(projectPreferencesPathUppercase(), "project") - ); + return loadPreferencesFile(projectPreferencesYamlPath(), "project"); } /** * Load and merge global and project preferences with profile defaults and mode defaults applied. @@ -237,10 +203,7 @@ export function loadEffectiveSFPreferences() { function loadPreferencesFile(path, scope) { if (!existsSync(path)) return null; const raw = readFileSync(path, "utf-8"); - // .yaml files are pure YAML — no frontmatter extraction needed - const preferences = path.endsWith(".yaml") - ? parsePreferencesYaml(raw) - : parsePreferencesMarkdown(raw); + const preferences = parsePreferencesYaml(raw); if (!preferences) return null; const validation = validatePreferences(preferences); // Self-align: if the file's recorded sf version drifted from current, @@ -256,13 +219,9 @@ function loadPreferencesFile(path, scope) { ...(allWarnings.length > 0 ? { warnings: allWarnings } : {}), }; } -let _warnedUnrecognizedFormat = false; -let _warnedSectionParse = false; /** @internal Reset the warn-once flags — exported for testing only. */ export function _resetParseWarningFlag() { - _warnedUnrecognizedFormat = false; - _warnedFrontmatterParse = false; - _warnedSectionParse = false; + // No warn-once flags remain after removal of legacy markdown parser. } /** * Parse preferences from a pure YAML file (no frontmatter markers needed). @@ -281,119 +240,14 @@ export function parsePreferencesYaml(content) { } } /** - * Parse preferences from markdown frontmatter or heading+list format. - * - * @internal Exported for testing only + * @deprecated Use parsePreferencesYaml instead. + * Retained for callers (e.g. auto-dashboard.js) that read .yaml content via this path. + * Strips YAML frontmatter markers if present, then delegates to parsePreferencesYaml. */ export function parsePreferencesMarkdown(content) { - // Use indexOf instead of [\s\S]*? regex to avoid backtracking (#468) - const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n"; - if (content.startsWith(startMarker)) { - const searchStart = startMarker.length; - const endIdx = content.indexOf("\n---", searchStart); - if (endIdx === -1) return null; - const block = content.slice(searchStart, endIdx); - return parseFrontmatterBlock(block.replace(/\r/g, "")); - } - // Fallback: heading+list format (e.g. "## Git\n- isolation: none") (#2036) - // SF agents may write preferences files without frontmatter delimiters. - if (/^##\s+\w/m.test(content)) { - return parseHeadingListFormat(content); - } - // Warn when a non-empty file exists but lacks frontmatter delimiters (#2036). - if (content.trim().length > 0 && !_warnedUnrecognizedFormat) { - _warnedUnrecognizedFormat = true; - console.warn( - "[SF] Warning: preferences file has unrecognized format — content does not use YAML frontmatter delimiters (---). " + - "Wrap your preferences in --- fences. See https://github.com/singularity-forge/sf-run/issues/2036", - ); - } - return null; -} -let _warnedFrontmatterParse = false; -function parseFrontmatterBlock(frontmatter) { - try { - const parsed = parseYaml(frontmatter); - if (typeof parsed !== "object" || parsed === null) { - return {}; - } - return parsed; - } catch (e) { - // Warn at most once per session to avoid flooding TUI (#3376) - if (!_warnedFrontmatterParse) { - _warnedFrontmatterParse = true; - logWarning( - "guided", - `YAML parse error in preferences frontmatter (suppressing further): ${e.message}`, - ); - } - return {}; - } -} -/** - * Parse heading+list format into a nested object, then cast to SFPreferences. - * Handles markdown like: - * ## Git - * - isolation: none - * - commit_docs: true - * ## Models - * - planner: sonnet - */ -function parseHeadingListFormat(content) { - const result = {}; - let currentSection = null; - for (const rawLine of content.split("\n")) { - const line = rawLine.replace(/\r$/, ""); - const headingMatch = line.match(/^##\s+(.+)$/); - if (headingMatch) { - currentSection = headingMatch[1] - .trim() - .toLowerCase() - .replace(/\s+/g, "_"); - if (!result[currentSection]) result[currentSection] = []; - continue; - } - if (currentSection && line.trim() && !line.trimStart().startsWith("#")) { - result[currentSection].push(line); - } - } - const typed = {}; - for (const [section, lines] of Object.entries(result)) { - if (lines.length === 0) continue; - const usesLegacyListItems = lines.every((line) => - /^\s*-\s+[^:]+:\s*.*$/.test(line), - ); - const yamlBlock = usesLegacyListItems - ? lines.map((line) => line.replace(/^\s*-\s+/, "")).join("\n") - : lines.join("\n"); - try { - const parsed = parseYaml(yamlBlock); - if (typeof parsed !== "object" || parsed === null) continue; - let targetSection = section; - let value = parsed; - if (!Array.isArray(parsed)) { - const keys = Object.keys(parsed); - if (keys.length === 1) { - const [onlyKey] = keys; - if ( - onlyKey === section || - (!KNOWN_PREFERENCE_KEYS.has(section) && - KNOWN_PREFERENCE_KEYS.has(onlyKey)) - ) { - targetSection = onlyKey; - value = parsed[onlyKey]; - } - } - } - typed[targetSection] = value; - } catch (e) { - if (!_warnedSectionParse) { - _warnedSectionParse = true; - logWarning("guided", `preferences section parse failed: ${e.message}`); - } - } - } - return typed; + // Strip optional frontmatter fences so legacy callers work with pure YAML too + const fenced = /^---\r?\n([\s\S]*?)\n---(?:\r?\n[\s\S]*)?$/.exec(content); + return parsePreferencesYaml(fenced ? fenced[1] : content); } // ─── Merging ──────────────────────────────────────────────────────────────── /** @@ -787,7 +641,7 @@ export function resolvePreDispatchHooks() { * Resolve the effective git isolation mode from preferences. * Returns "none" (default), "worktree", or "branch". * - * Default is "none" so SF works out of the box without preferences.md. + * Default is "none" so SF works out of the box without preferences.yaml. * Worktree isolation requires explicit opt-in because it depends on git * branch infrastructure that must be set up before use. */ diff --git a/src/resources/extensions/sf/provider-catalog-config.js b/src/resources/extensions/sf/provider-catalog-config.js new file mode 100644 index 000000000..db18e5de1 --- /dev/null +++ b/src/resources/extensions/sf/provider-catalog-config.js @@ -0,0 +1,173 @@ +/** + * provider-catalog-config.js — per-provider runtime configuration. + * + * Single source of truth for provider-level settings that go beyond key + * management: API format, base URL, model-discovery endpoint, and rate-limit + * semantics. Consumers: model-catalog-cache.js, doctor-providers.js (test + * endpoints), and the backoff/retry layer. + * + * Adding a provider: + * 1. Add an entry here. + * 2. If it needs a key, add it to PROVIDER_REGISTRY in key-manager.js. + * 3. Run `npm run copy-resources` to rebuild dist. + */ + +/** + * API format families. + * - "openai" — OpenAI REST: Bearer auth, POST /v1/chat/completions, GET /v1/models returns { data: [{id}] } + * - "anthropic" — Anthropic REST: x-api-key header, POST /v1/messages, no public GET /v1/models + * - "google" — Gemini REST: x-goog-api-key header, GET /v1beta/models returns { models: [{name}] } + * - "custom" — Non-standard; model discovery not supported + */ + +/** + * Rate-limit scope. + * - "provider" — a single 429 takes down the whole provider (back off all models) + * - "model" — 429 is per-model; other models on the same provider remain usable + */ + +export const PROVIDER_CATALOG_CONFIG = { + // ─── Anthropic ──────────────────────────────────────────────────────────── + anthropic: { + type: "anthropic", + baseUrl: "https://api.anthropic.com", + modelsPath: null, + auth: { type: "x-api-key", header: "x-api-key" }, + rateLimits: { scope: "model" }, + }, + + // ─── OpenAI family ─────────────────────────────────────────────────────── + openai: { + type: "openai", + baseUrl: "https://api.openai.com", + modelsPath: "/v1/models", + auth: { type: "bearer" }, + rateLimits: { scope: "model" }, + modelFilter: { + // exclude embedding, moderation, image, audio, realtime, and legacy models + excludePatterns: [ + /embed/i, + /moderation/i, + /dall-e/i, + /tts/i, + /whisper/i, + /realtime/i, + /instruct/i, + /babbage/i, + /davinci/i, + /ada/i, + /curie/i, + ], + }, + }, + + groq: { + type: "openai", + baseUrl: "https://api.groq.com", + modelsPath: "/openai/v1/models", + auth: { type: "bearer" }, + rateLimits: { scope: "model" }, + modelFilter: { + excludePatterns: [/whisper/i, /guard/i, /tool-use/i, /vision-preview/i], + }, + }, + + mistral: { + type: "openai", + baseUrl: "https://api.mistral.ai", + modelsPath: "/v1/models", + auth: { type: "bearer" }, + rateLimits: { scope: "provider" }, + modelFilter: { + excludePatterns: [ + /embed/i, + /moderation/i, + /ocr/i, + /voxtral/i, + /transcribe/i, + /tts/i, + /realtime/i, + /^ft:/, + ], + }, + }, + + openrouter: { + type: "openai", + baseUrl: "https://openrouter.ai", + modelsPath: "/api/v1/models", + auth: { type: "bearer" }, + // OpenRouter aggregates many providers — 429 varies by upstream but + // the router itself rate-limits per-account, not per-model. + rateLimits: { scope: "provider" }, + modelFilter: { + // Only free-tier models pass the built-in policy (isModelAllowedByBuiltInProviderPolicy) + // so no additional filter needed here; handled in preferences-models.js. + excludePatterns: [], + }, + }, + + cerebras: { + type: "openai", + baseUrl: "https://api.cerebras.ai", + modelsPath: "/v1/models", + auth: { type: "bearer" }, + rateLimits: { scope: "provider" }, + modelFilter: { + excludePatterns: [], + }, + }, + + // ─── Google ────────────────────────────────────────────────────────────── + google: { + type: "google", + baseUrl: "https://generativelanguage.googleapis.com", + // Returns { models: [{name: "models/gemini-..."}] } — non-standard format. + modelsPath: "/v1beta/models", + auth: { type: "query-param", param: "key" }, + rateLimits: { scope: "model" }, + modelFilter: { + // Gemini model list includes embedding and vision-only models. + excludePatterns: [/embed/i, /aqa/i], + }, + }, + + // ─── Azure OpenAI ───────────────────────────────────────────────────────── + // Base URL is deployment-specific; model discovery via /openai/deployments. + "azure-openai": { + type: "openai", + baseUrl: null, + modelsPath: null, + auth: { type: "bearer" }, + rateLimits: { scope: "model" }, + }, +}; + +/** + * Provider IDs that support live model-list discovery (have a modelsPath). + */ +export const DISCOVERABLE_PROVIDER_IDS = Object.entries(PROVIDER_CATALOG_CONFIG) + .filter(([, cfg]) => cfg.modelsPath !== null) + .map(([id]) => id); + +/** + * Returns the catalog config for a provider, or null if not configured. + */ +export function getProviderCatalogConfig(providerId) { + return PROVIDER_CATALOG_CONFIG[providerId] ?? null; +} + +/** + * Returns true if a 429 from this provider should back off all models on the + * provider rather than just the one that triggered the response. + */ +export function isProviderScopedRateLimit(providerId) { + return PROVIDER_CATALOG_CONFIG[providerId]?.rateLimits?.scope === "provider"; +} + +/** + * Returns the exclude patterns for a provider's model list, or []. + */ +export function getProviderModelExcludePatterns(providerId) { + return PROVIDER_CATALOG_CONFIG[providerId]?.modelFilter?.excludePatterns ?? []; +} diff --git a/src/resources/extensions/sf/tests/doctor-providers.test.mjs b/src/resources/extensions/sf/tests/doctor-providers.test.mjs index 1c45cb25b..5dcb8f15c 100644 --- a/src/resources/extensions/sf/tests/doctor-providers.test.mjs +++ b/src/resources/extensions/sf/tests/doctor-providers.test.mjs @@ -26,10 +26,10 @@ function makePreferencesProject(globalPreferences) { mkdirSync(home, { recursive: true }); mkdirSync(join(home, ".sf", "agent"), { recursive: true }); mkdirSync(join(project, ".sf"), { recursive: true }); - writeFileSync(join(home, "preferences.md"), globalPreferences, "utf-8"); + writeFileSync(join(home, "preferences.yaml"), globalPreferences, "utf-8"); writeFileSync( - join(project, ".sf", "PREFERENCES.md"), - "---\nversion: 1\nmodels: {}\n---\n", + join(project, ".sf", "preferences.yaml"), + "version: 1\nmodels: {}\n", "utf-8", ); process.env.SF_HOME = home; @@ -49,12 +49,10 @@ describe("doctor provider checks", () => { test("runProviderChecks_when_any_configured_llm_route_is_usable_does_not_require_every_preferred_provider", () => { makePreferencesProject( [ - "---", "version: 1", "models:", " execution: kimi-coding/kimi-k2.6", " reassess: mistral/devstral-latest", - "---", "", ].join("\n"), ); @@ -71,7 +69,6 @@ describe("doctor provider checks", () => { test("runProviderChecks_when_remote_questions_auto_resolve_does_not_require_channel_token", () => { makePreferencesProject( [ - "---", "version: 1", "models:", " execution: kimi-coding/kimi-k2.6", @@ -80,7 +77,6 @@ describe("doctor provider checks", () => { " channel_id: 562797207", " auto_resolve_on_timeout: true", " auto_resolve_strategy: recommended-option", - "---", "", ].join("\n"), ); @@ -97,11 +93,9 @@ describe("doctor provider checks", () => { test("runProviderChecks_when_google_env_auth_is_default_off_treats_google_as_missing_required_route", () => { makePreferencesProject( [ - "---", "version: 1", "models:", " planning: google/gemini-2.5-pro", - "---", "", ].join("\n"), ); @@ -116,11 +110,9 @@ describe("doctor provider checks", () => { test("runProviderChecks_when_google_env_auth_is_enabled_accepts_google_env_key", () => { const project = makePreferencesProject( [ - "---", "version: 1", "models:", " planning: google/gemini-2.5-pro", - "---", "", ].join("\n"), ); diff --git a/src/resources/extensions/sf/tests/preferences-models.test.mjs b/src/resources/extensions/sf/tests/preferences-models.test.mjs index 66738304d..7a3e922db 100644 --- a/src/resources/extensions/sf/tests/preferences-models.test.mjs +++ b/src/resources/extensions/sf/tests/preferences-models.test.mjs @@ -27,7 +27,7 @@ function makePreferencesProject(projectPreferences, projectSettings) { mkdirSync(join(home, ".sf", "agent"), { recursive: true }); mkdirSync(join(project, ".sf"), { recursive: true }); writeFileSync( - join(project, ".sf", "PREFERENCES.md"), + join(project, ".sf", "preferences.yaml"), projectPreferences, "utf-8", ); @@ -47,12 +47,10 @@ describe("preferences model resolution", () => { test("resolveModelWithFallbacksForUnit_when_google_env_auth_is_default_off_skips_google_auto_benchmark_candidates", () => { makePreferencesProject( [ - "---", "version: 1", "allowed_providers:", " - google", "models: {}", - "---", "", ].join("\n"), ); diff --git a/src/resources/extensions/sf/worktree-root.js b/src/resources/extensions/sf/worktree-root.js index abf11a731..72c75d9da 100644 --- a/src/resources/extensions/sf/worktree-root.js +++ b/src/resources/extensions/sf/worktree-root.js @@ -108,8 +108,6 @@ function hasSfBootstrapArtifacts(sfPath) { return ( existsSync(sfPath) && (existsSync(join(sfPath, "preferences.yaml")) || - existsSync(join(sfPath, "PREFERENCES.md")) || - existsSync(join(sfPath, "preferences.md")) || existsSync(join(sfPath, "milestones"))) ); }