refactor(prefs): remove all legacy PREFERENCES.md / preferences.md support

preferences.yaml is now the only preferences file. No fallback chains,
no .md parsing paths, no legacy path getters.

- preferences.js: remove globalPreferencesPath, globalPreferencesPathUppercase,
  legacyGlobalPreferencesPath, projectPreferencesPath, projectPreferencesPathUppercase,
  getLegacyGlobalSFPreferencesPath; simplify load functions to yaml-only;
  parsePreferencesMarkdown kept as thin deprecated shim over parsePreferencesYaml
- commands-prefs-wizard.js: remove parseFrontmatterMap/splitFrontmatter usage,
  .md branch in savePreferencesFile/ensurePreferencesFile, legacyGlobal display
- auto-dashboard.js: parsePreferencesMarkdown → parsePreferencesYaml
- guided-flow.js / worktree-root.js: remove PREFERENCES.md existence checks
- detection.js: remove .md fallbacks from all 3 detection functions
- auto-bootstrap-context.js: remove .sf/PREFERENCES.md from priority list
- auto-worktree.js: remove LEGACY_PREFERENCES_FILES array and all copy fallbacks
- deep-project-setup-policy.js: only check preferences.yaml
- gitignore.js: ensurePreferences checks yaml only
- planning-depth.js: returns plain string path (not {path,isYaml}); yaml-only
- preferences-template-upgrade.js: remove .md branch; always write raw YAML
- tests: update fixtures to preferences.yaml with plain YAML content
- docs/learning: update all remaining PREFERENCES.md references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-10 21:14:43 +02:00
parent 48dbb175c0
commit 48a01dd764
19 changed files with 426 additions and 382 deletions

View file

@ -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",
];

View file

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

View file

@ -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

View file

@ -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 {

View file

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

View file

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

View file

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

View file

@ -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`):

View file

@ -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.

View file

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

View file

@ -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

View file

@ -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/<providerId>.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])];
}

View file

@ -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) {

View file

@ -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: <semver>`; 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: <semver>`; 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;
}

View file

@ -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.
*/

View file

@ -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 ?? [];
}

View file

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

View file

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

View file

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