diff --git a/src/resources/extensions/sf/auto-bootstrap-context.js b/src/resources/extensions/sf/auto-bootstrap-context.js index 129490dde..334bdc8ad 100644 --- a/src/resources/extensions/sf/auto-bootstrap-context.js +++ b/src/resources/extensions/sf/auto-bootstrap-context.js @@ -22,6 +22,7 @@ const AUTO_BOOTSTRAP_SF_SPEC_FILES = [ ".sf/STATE.md", ".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-model-selection.js b/src/resources/extensions/sf/auto-model-selection.js index 31a36a047..e2107e1f6 100644 --- a/src/resources/extensions/sf/auto-model-selection.js +++ b/src/resources/extensions/sf/auto-model-selection.js @@ -837,7 +837,7 @@ export function resolveModelId(modelId, availableModels, currentProvider) { // Extension providers (e.g. claude-code) expose the same model IDs as their // upstream API providers but route through a subprocess with different // context, tool visibility, and cost characteristics (#2905). Bare IDs in - // PREFERENCES.md must resolve to the canonical API provider, not to an + // preferences.yaml must resolve to the canonical API provider, not to an // extension wrapper that happens to be the current session provider. const candidates = availableModels.filter((m) => matchesBareModelId(m.id, modelId), diff --git a/src/resources/extensions/sf/auto-start.js b/src/resources/extensions/sf/auto-start.js index 6e1268446..0edc23b41 100644 --- a/src/resources/extensions/sf/auto-start.js +++ b/src/resources/extensions/sf/auto-start.js @@ -360,7 +360,7 @@ export async function bootstrapAutoSession( // // Precedence: // 1) Explicit session override via /model (this session) - // 2) SF model preferences from PREFERENCES.md (validated against live auth) + // 2) SF model preferences from preferences.yaml (validated against live auth) // 3) Current session model from settings/session restore (if provider ready) // // This preserves #3517 defaults while honoring explicit runtime model @@ -368,7 +368,7 @@ export async function bootstrapAutoSession( // // Exception (#4122): when the session provider is a custom provider declared // in ~/.sf/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.), - // PREFERENCES.md is skipped entirely. PREFERENCES.md cannot reference custom + // preferences.yaml is skipped entirely. preferences.yaml cannot reference custom // providers, so honoring it would silently reroute autonomous mode to a built-in // provider the user is not logged into and surface as "Not logged in · Please // run /login" before pausing and resetting to claude-code/claude-sonnet-4-6. @@ -380,7 +380,7 @@ export async function bootstrapAutoSession( ? null : resolveDefaultSessionModel(ctx.model?.provider); // Validate the preferred model against the live registry + provider auth so - // an unconfigured PREFERENCES.md entry (no API key / OAuth) can't become the + // an unconfigured preferences.yaml entry (no API key / OAuth) can't become the // start-model snapshot. Without this, every subsequent unit would try to // fall back to an unusable model. let validatedPreferredModel; @@ -396,7 +396,7 @@ export async function bootstrapAutoSession( validatedPreferredModel = { provider: match.provider, id: match.id }; } else { ctx.ui.notify( - `Preferred model ${preferredModel.provider}/${preferredModel.id} from PREFERENCES.md is not configured; falling back to session default.`, + `Preferred model ${preferredModel.provider}/${preferredModel.id} from preferences.yaml is not configured; falling back to session default.`, "warning", ); } diff --git a/src/resources/extensions/sf/auto-worktree.js b/src/resources/extensions/sf/auto-worktree.js index 841ad3383..8047cbe92 100644 --- a/src/resources/extensions/sf/auto-worktree.js +++ b/src/resources/extensions/sf/auto-worktree.js @@ -70,8 +70,8 @@ import { } from "./worktree-manager.js"; const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); -const PROJECT_PREFERENCES_FILE = "PREFERENCES.md"; -const LEGACY_PROJECT_PREFERENCES_FILE = "preferences.md"; +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,17 +552,13 @@ export function syncSfStateToWorktree(mainBasePath, worktreePath_) { } } // Forward-sync project preferences from project root to worktree (additive only). - // Prefer the canonical uppercase file name, but keep the legacy lowercase - // fallback so older repos still work on case-sensitive filesystems. + // Prefer the canonical yaml file, but keep legacy md fallbacks so older repos still work. { const worktreeHasPreferences = existsSync(join(wtSf, PROJECT_PREFERENCES_FILE)) || - existsSync(join(wtSf, LEGACY_PROJECT_PREFERENCES_FILE)); + LEGACY_PREFERENCES_FILES.some((f) => existsSync(join(wtSf, f))); if (!worktreeHasPreferences) { - for (const file of [ - PROJECT_PREFERENCES_FILE, - LEGACY_PROJECT_PREFERENCES_FILE, - ]) { + for (const file of [PROJECT_PREFERENCES_FILE, ...LEGACY_PREFERENCES_FILES]) { const src = join(mainSf, file); const dst = join(wtSf, file); if (existsSync(src)) { @@ -1194,19 +1190,24 @@ function copyPlanningArtifacts(srcBase, wtPath) { ]) { safeCopy(join(srcSf, file), join(dstSf, file), { force: true }); } - // Seed canonical PREFERENCES.md when available; fall back to legacy lowercase. + // Seed canonical preferences.yaml when available; fall back to legacy md files. if (existsSync(join(srcSf, PROJECT_PREFERENCES_FILE))) { safeCopy( join(srcSf, PROJECT_PREFERENCES_FILE), join(dstSf, PROJECT_PREFERENCES_FILE), { force: true }, ); - } else if (existsSync(join(srcSf, LEGACY_PROJECT_PREFERENCES_FILE))) { - safeCopy( - join(srcSf, LEGACY_PROJECT_PREFERENCES_FILE), - join(dstSf, LEGACY_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/auto.js b/src/resources/extensions/sf/auto.js index 0bb4cdb39..8f8874991 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -1501,6 +1501,7 @@ export async function startAuto(ctx, pi, base, verboseMode, options) { ctx.ui.notify( "Switched to Build mode — starting autonomous execution.", "info", + { noticeKind: NOTICE_KIND.USER_VISIBLE }, ); } // On a *fresh* start, drop any stale active-tool baseline left by a prior @@ -1526,7 +1527,9 @@ export async function startAuto(ctx, pi, base, verboseMode, options) { base = escapeStaleWorktree(base); const startupFixes = healAutoStartupRuntime(base); for (const fix of startupFixes) { - ctx.ui.notify(`Startup self-heal: ${fix}.`, "info"); + ctx.ui.notify(`Startup self-heal: ${fix}.`, "info", { + noticeKind: NOTICE_KIND.SYSTEM_NOTICE, + }); } const freshStartAssessment = interruptedAssessment ?? (await assessInterruptedSession(base)); @@ -1537,6 +1540,10 @@ export async function startAuto(ctx, pi, base, verboseMode, options) { ? `Another autonomous mode session (PID ${pid}) appears to be running.\nStop it with \`kill ${pid}\` before starting a new session.` : "Another autonomous mode session appears to be running.", "error", + { + noticeKind: NOTICE_KIND.BLOCKING_NOTICE, + dedupe_key: "auto-start-concurrent-session-blocked", + }, ); return; } diff --git a/src/resources/extensions/sf/codebase-generator.js b/src/resources/extensions/sf/codebase-generator.js index bbf0f8d06..b3c7e807f 100644 --- a/src/resources/extensions/sf/codebase-generator.js +++ b/src/resources/extensions/sf/codebase-generator.js @@ -315,7 +315,7 @@ function inferProjectKnowledge(files) { } pushUnique( verificationCommands, - "pytest or the project quality command (lint + type + test stack from .sf/PREFERENCES.md)", + "pytest or the project quality command (lint + type + test stack from .sf/preferences.yaml)", ); pushUnique(skillNeeds, "Python packaging, typing, and tests"); } diff --git a/src/resources/extensions/sf/commands-codebase.js b/src/resources/extensions/sf/commands-codebase.js index 21d6f449d..0b5d2fe27 100644 --- a/src/resources/extensions/sf/commands-codebase.js +++ b/src/resources/extensions/sf/commands-codebase.js @@ -23,7 +23,7 @@ const USAGE = " help — Show this help\n\n" + "With no subcommand, shows stats if a map exists or help if not.\n" + "SF also refreshes CODEBASE.md automatically before prompt injection and after completed units when tracked files change.\n\n" + - "Configure defaults via preferences.md:\n" + + "Configure defaults via preferences.yaml:\n" + " codebase:\n" + ' exclude_patterns: ["docs/", "fixtures/"]\n' + " max_files: 1000\n" + diff --git a/src/resources/extensions/sf/commands-prefs-wizard.js b/src/resources/extensions/sf/commands-prefs-wizard.js index cd21905a7..277624909 100644 --- a/src/resources/extensions/sf/commands-prefs-wizard.js +++ b/src/resources/extensions/sf/commands-prefs-wizard.js @@ -39,6 +39,26 @@ function extractBodyAfterFrontmatter(content) { 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 + */ +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}`); +} // ─── Numeric validation helpers ────────────────────────────────────────────── /** Parse a string as a non-negative integer, or return null on failure. */ function tryParseInteger(val) { @@ -138,24 +158,14 @@ export async function handleImportClaude(ctx, scope) { const readPrefs = () => { if (!existsSync(path)) return { version: 1 }; const content = readFileSync(path, "utf-8"); - const [frontmatterLines] = splitFrontmatter(content); - return frontmatterLines - ? parseFrontmatterMap(frontmatterLines) - : { version: 1 }; - }; - const writePrefs = async (prefs) => { - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - 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; + if (path.endsWith(".yaml")) { + const [frontmatterLines] = splitFrontmatter(`---\n${content}\n---`); + return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 }; } - await saveFile(path, `---\n${frontmatter}---${body}`); + const [frontmatterLines] = splitFrontmatter(content); + return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 }; }; + const writePrefs = async (prefs) => savePreferencesFile(path, prefs); await runClaudeImportFlow(ctx, scope, readPrefs, writePrefs); } export async function handlePrefsMode(ctx, scope) { @@ -169,17 +179,7 @@ export async function handlePrefsMode(ctx, scope) { : loadGlobalSFPreferences(); const prefs = existing?.preferences ? { ...existing.preferences } : {}; await configureMode(ctx, prefs); - // Serialize and save - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - 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; - } - const content = `---\n${frontmatter}---${body}`; - await saveFile(path, content); + await savePreferencesFile(path, prefs); await ctx.waitForIdle(); await ctx.reload(); ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info"); @@ -832,26 +832,18 @@ export async function handlePrefsWizard(ctx, scope) { await configureNotifications(ctx, prefs); else if (choice.startsWith("Advanced")) await configureAdvanced(ctx, prefs); } - // ─── Serialize to frontmatter ─────────────────────────────────────────── - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - // Preserve existing body content (everything after closing ---) - 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; - } - const content = `---\n${frontmatter}---${body}`; - await saveFile(path, content); + // ─── Serialize and save ───────────────────────────────────────────────── + await savePreferencesFile(path, prefs); await ctx.waitForIdle(); await ctx.reload(); ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info"); } 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", "PREFERENCES.md"), + join(import.meta.dirname, "templates", templateFile), ); 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 b7f38e70a..6b45955c1 100644 --- a/src/resources/extensions/sf/deep-project-setup-policy.js +++ b/src/resources/extensions/sf/deep-project-setup-policy.js @@ -18,17 +18,29 @@ export function researchDecisionPath(basePath) { return join(runtimeDir(basePath), "research-decision.json"); } export function isWorkflowPrefsCaptured(basePath) { - const prefsPath = join(sfRoot(basePath), "PREFERENCES.md"); - if (!existsSync(prefsPath)) return false; - let content; - try { - content = readFileSync(prefsPath, "utf-8"); - } catch { - return false; + // Check yaml (canonical) first, fallback to legacy md + 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; + let content; + try { + content = readFileSync(prefsPath, "utf-8"); + } catch { + return false; + } + if (prefsPath.endsWith(".yaml")) { + return /^workflow_prefs_captured:\s*true\s*$/m.test(content); + } + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return false; + return /^workflow_prefs_captured:\s*true\s*$/m.test(match[1]); } - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return false; - return /^workflow_prefs_captured:\s*true\s*$/m.test(match[1]); + return false; } export function writeDefaultResearchSkipDecision( basePath, @@ -88,7 +100,7 @@ export function resolveDeepProjectSetupState(prefs, basePath) { return { status: "pending", stage: "workflow-preferences", - reason: ".sf/PREFERENCES.md is missing workflow_prefs_captured: true.", + reason: ".sf/preferences.yaml is missing workflow_prefs_captured: true.", }; } const projectPath = join(root, "PROJECT.md"); diff --git a/src/resources/extensions/sf/detection.js b/src/resources/extensions/sf/detection.js index 4ae64e598..3ba74ef40 100644 --- a/src/resources/extensions/sf/detection.js +++ b/src/resources/extensions/sf/detection.js @@ -311,6 +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")); const hasContext = existsSync(join(sfPath, "CONTEXT.md")); @@ -862,6 +863,7 @@ function isMakeTestTargetSafe(basePath) { */ export function hasGlobalSetup() { return ( + existsSync(join(sfHome, "preferences.yaml")) || existsSync(join(sfHome, "PREFERENCES.md")) || existsSync(join(sfHome, "preferences.md")) ); @@ -874,6 +876,7 @@ 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")) ) { diff --git a/src/resources/extensions/sf/docs/preferences-reference.md b/src/resources/extensions/sf/docs/preferences-reference.md index 1beb3dc73..40d7b871a 100644 --- a/src/resources/extensions/sf/docs/preferences-reference.md +++ b/src/resources/extensions/sf/docs/preferences-reference.md @@ -1,6 +1,6 @@ # SF Preferences Reference -Full documentation for `~/.sf/PREFERENCES.md` (global) and `.sf/PREFERENCES.md` (project). +Full documentation for `~/.sf/preferences.yaml` (global) and `.sf/preferences.yaml` (project). --- diff --git a/src/resources/extensions/sf/doctor-config-checks.js b/src/resources/extensions/sf/doctor-config-checks.js index 12b69300a..6cbc6e1a4 100644 --- a/src/resources/extensions/sf/doctor-config-checks.js +++ b/src/resources/extensions/sf/doctor-config-checks.js @@ -45,7 +45,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `context_compact_at (${compactAt}) must be <= context_hard_limit (${hardLimit}). Otherwise context compaction happens too late.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: true, }); if (shouldFix("config_context_compact_exceeds_hard_limit")) { @@ -64,7 +64,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `context_hard_limit (${hardLimit}) is very low. Typical prompts + context are 10-15k tokens. Consider increasing to at least 25000.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -78,7 +78,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `unit_timeout (${unitTimeout}s) is very low. Most units take 2-5 minutes. Consider increasing to at least 180 seconds.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -106,7 +106,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `unit_timeout_by_phase contains unrecognized phase "${phase}". Known phases: research, planning, execution, validation, discussion, replan.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -117,7 +117,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `unit_timeout_by_phase.${phase} (${timeout}s) is very low. Consider at least 180 seconds.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -145,7 +145,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `max_agents_by_phase contains unrecognized phase "${phase}". Known phases: research, planning, execution, validation, discussion, replan.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -156,7 +156,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `max_agents_by_phase.${phase} (${count}) is outside practical range [1, 16]. Most deployments use 1-4.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -172,7 +172,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `worktree_mode "${worktreeMode}" is invalid. Must be one of: none, auto, manual.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -186,7 +186,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `tool_abort_grace (${toolAbortGrace}ms) cannot be negative.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: true, }); if (shouldFix("config_tool_abort_grace_negative")) { @@ -203,7 +203,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `max_turns_per_attempt (${maxTurns}) is very low. Most units need 10-50 turns. Increasing to at least 20 is recommended.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -214,7 +214,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `max_turns_per_attempt (${maxTurns}) is very high. Units reaching >100 turns are likely stuck. Consider investigating or lowering to 100.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -228,7 +228,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `hot_cache_turns (${hotCacheTurns}) cannot be negative.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: true, }); if (shouldFix("config_hot_cache_negative")) { @@ -242,7 +242,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `hot_cache_turns (${hotCacheTurns}) is very high. Keeping >20 turns in memory wastes RAM. Consider 5-10.`, - file: ".sf/preferences.md or ~/.sf/preferences.md", + file: ".sf/preferences.yaml or ~/.sf/preferences.yaml", fixable: false, }); } @@ -254,7 +254,7 @@ export async function checkConfigHealth(issues, fixesApplied, shouldFix) { scope: "project", unitId: "config", message: `Config health check failed: ${err instanceof Error ? err.message : String(err)}`, - file: ".sf/preferences.md", + file: ".sf/preferences.yaml", fixable: false, }); } @@ -307,7 +307,7 @@ export function checkVaultHealth(issues, _shouldFix) { scope: "project", unitId: "vault", message: `Vault URIs detected (${vaultUris.length}) but no credentials found. Set VAULT_TOKEN or create ~/.vault-token to use vault.`, - file: ".sf/preferences.md or environment", + file: ".sf/preferences.yaml or environment", fixable: false, }); return; @@ -373,7 +373,7 @@ export function checkVaultHealth(issues, _shouldFix) { scope: "project", unitId: "vault", message: `Vault health check failed: ${err instanceof Error ? err.message : String(err)}`, - file: ".sf/preferences.md", + file: ".sf/preferences.yaml", fixable: false, }); } diff --git a/src/resources/extensions/sf/doctor-providers.js b/src/resources/extensions/sf/doctor-providers.js index a8d1cf682..3cfabba12 100644 --- a/src/resources/extensions/sf/doctor-providers.js +++ b/src/resources/extensions/sf/doctor-providers.js @@ -14,11 +14,8 @@ import { existsSync } from "node:fs"; import { AuthStorage } from "@singularity-forge/coding-agent"; import { getAuthPath, PROVIDER_REGISTRY } from "./key-manager.js"; import { loadEffectiveSFPreferences } from "./preferences.js"; -import { getConfiguredEnvApiKey } from "./provider-env-auth.js"; -import { - couldBeVaultUri, - hasProviderCredentialEnvVar, -} from "./vault-credential-resolver.js"; +import { getConfiguredEnvApiKey, getGatedEnvVarKey } from "./provider-env-auth.js"; +import { couldBeVaultUri } from "./vault-credential-resolver.js"; // ── Model → Provider ID mapping ─────────────────────────────────────────────── /** @@ -149,9 +146,9 @@ function resolveKey(providerId) { if (info?.envVar && couldBeVaultUri(process.env[info.envVar])) { return { found: true, source: "vault", backedOff: false }; } - // Fall back to PROVIDER_REGISTRY env var for providers not covered by getEnvApiKey - // (e.g., search providers like Brave, Tavily; tool providers like Jina, Context7) - if (info?.envVar && hasProviderCredentialEnvVar(info.envVar)) { + // Fallback for providers not covered by getEnvApiKey (search/tool providers like + // Brave, Tavily, Jina, Context7) — still gated by providerEnvAuth setting. + if (info?.envVar && getGatedEnvVarKey(providerId, info.envVar)) { return { found: true, source: "env", backedOff: false }; } return { found: false, source: "none", backedOff: false }; @@ -339,7 +336,7 @@ function checkOptionalProviders() { // "not configured" noise for alternative search providers when at least // one is already active (e.g. don't warn about missing BRAVE_API_KEY // when Tavily is configured). - const searchProviderIds = ["brave", "tavily"]; + const searchProviderIds = PROVIDER_REGISTRY.filter((p) => p.category === "search").map((p) => p.id); const hasAnySearchProvider = searchProviderIds.some( (id) => resolveKey(id).found, ); diff --git a/src/resources/extensions/sf/gitignore.js b/src/resources/extensions/sf/gitignore.js index 8fcfb0b01..c1697e1eb 100644 --- a/src/resources/extensions/sf/gitignore.js +++ b/src/resources/extensions/sf/gitignore.js @@ -1,8 +1,8 @@ /** - * SF bootstrappers for .gitignore and PREFERENCES.md + * SF bootstrappers for .gitignore and preferences.yaml * * Ensures baseline .gitignore exists with universally-correct patterns. - * Creates an empty PREFERENCES.md template if it doesn't exist. + * Creates an empty preferences.yaml template if it doesn't exist. * Both idempotent — non-destructive if already present. */ import { execFileSync } from "node:child_process"; @@ -14,7 +14,6 @@ import { GIT_NO_PROMPT_ENV } from "./git-constants.js"; import { SF_RUNTIME_PATTERNS } from "./git-runtime-patterns.js"; import { nativeLsFiles, nativeRmCached } from "./native-git-bridge.js"; import { sfRoot } from "./paths.js"; -import { bodyHash as preferencesBodyHash } from "./scaffold-versioning.js"; export { SF_RUNTIME_PATTERNS } from "./git-runtime-patterns.js"; @@ -291,23 +290,20 @@ export function untrackRuntimeFiles(basePath) { } } /** - * Ensure basePath/.sf/PREFERENCES.md exists as an empty template. - * Creates the file with frontmatter only if it doesn't exist. + * 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 both uppercase (canonical) and lowercase (legacy) to avoid - * creating a duplicate when a lowercase file already exists. + * Checks yaml (canonical) and legacy .md variants to avoid duplicates. */ export function ensurePreferences(basePath) { - const preferencesPath = join(sfRoot(basePath), "PREFERENCES.md"); + const yamlPath = join(sfRoot(basePath), "preferences.yaml"); + const uppercasePath = join(sfRoot(basePath), "PREFERENCES.md"); const legacyPath = join(sfRoot(basePath), "preferences.md"); - if (existsSync(preferencesPath) || existsSync(legacyPath)) { + if (existsSync(yamlPath) || existsSync(uppercasePath) || existsSync(legacyPath)) { return false; } - // Auto-detect project type and seed verification_commands. Without this, - // projects fall back to the user-level defaults — which point at sf's own - // dev scripts (npm run typecheck:extensions, test:sf-light) and produce - // false negatives on every non-Node project. Detection failure is non-fatal. + // Auto-detect project type and seed verification_commands. let verifySection = ""; try { const signals = detectProjectSignals(basePath); @@ -320,23 +316,11 @@ export function ensurePreferences(basePath) { } catch { // fall through to bare template } - // Stamp the sf version that wrote this template. Drift detection in - // checkPreferencesDrift uses this to flag stale templates after major - // sf updates. SF_VERSION is set by loader.ts; fall back to "0.0.0" if - // the env var is missing (atypical entry point). const sfVersion = process.env.SF_VERSION || "0.0.0"; - // ADR-021 Phase A: record initial template state + body hash in the - // frontmatter alongside last_synced_with_sf. The hash covers the - // Markdown body (everything after the closing `---` of frontmatter) - // so the same drift-detection pattern works as for marker-stamped - // Markdown docs. - const prefBody = `\n# SF Skill Preferences\n\nProject-specific guidance for skill selection and execution preferences.\n\nSee \`~/.sf/agent/extensions/sf/docs/preferences-reference.md\` for full field documentation and examples.\n\n## Fields\n\n- \`always_use_skills\`: Skills that must be available during all SF operations\n- \`prefer_skills\`: Skills to prioritize when multiple options exist\n- \`avoid_skills\`: Skills to minimize or avoid (with lower priority than prefer)\n- \`skill_rules\`: Context-specific rules (e.g., "use tool X for Y type of work")\n- \`custom_instructions\`: Append-only project guidance (do not override system rules)\n- \`models\`: Model preferences for specific task types\n- \`skill_discovery\`: Automatic skill detection preferences\n- \`auto_supervisor\`: Supervision and gating rules for autonomous modes\n- \`git\`: Git preferences — \`main_branch\` (default branch name for new repos, e.g., "main", "master", "trunk"), \`auto_push\`, \`snapshots\`, etc.\n\n## Examples\n\n\`\`\`yaml\nprefer_skills:\n - playwright\n - resolve_library\navoid_skills:\n - subagent # prefer direct execution in this project\n\ncustom_instructions:\n - "Always verify with browser_assert before marking UI work done"\n - "Use Context7 for all library/framework decisions"\n\`\`\`\n`; - const prefBodyHash = preferencesBodyHash(prefBody); - const template = `--- + const template = `# SF preferences — see ~/.sf/agent/extensions/sf/docs/preferences-reference.md for docs version: 1 last_synced_with_sf: ${yamlSafeString(sfVersion)} sf_template_state: pending -sf_template_hash: ${yamlSafeString(prefBodyHash)} ${verifySection}always_use_skills: [] prefer_skills: [] avoid_skills: [] @@ -345,40 +329,7 @@ custom_instructions: [] models: {} skill_discovery: {} auto_supervisor: {} ---- - -# SF Skill Preferences - -Project-specific guidance for skill selection and execution preferences. - -See \`~/.sf/agent/extensions/sf/docs/preferences-reference.md\` for full field documentation and examples. - -## Fields - -- \`always_use_skills\`: Skills that must be available during all SF operations -- \`prefer_skills\`: Skills to prioritize when multiple options exist -- \`avoid_skills\`: Skills to minimize or avoid (with lower priority than prefer) -- \`skill_rules\`: Context-specific rules (e.g., "use tool X for Y type of work") -- \`custom_instructions\`: Append-only project guidance (do not override system rules) -- \`models\`: Model preferences for specific task types -- \`skill_discovery\`: Automatic skill detection preferences -- \`auto_supervisor\`: Supervision and gating rules for autonomous modes -- \`git\`: Git preferences — \`main_branch\` (default branch name for new repos, e.g., "main", "master", "trunk"), \`auto_push\`, \`snapshots\`, etc. - -## Examples - -\`\`\`yaml -prefer_skills: - - playwright - - resolve_library -avoid_skills: - - subagent # prefer direct execution in this project - -custom_instructions: - - "Always verify with browser_assert before marking UI work done" - - "Use Context7 for all library/framework decisions" -\`\`\` `; - writeFileSync(preferencesPath, template, "utf-8"); + writeFileSync(yamlPath, template, "utf-8"); return true; } diff --git a/src/resources/extensions/sf/guided-flow.js b/src/resources/extensions/sf/guided-flow.js index dbf5512a4..3710b1371 100644 --- a/src/resources/extensions/sf/guided-flow.js +++ b/src/resources/extensions/sf/guided-flow.js @@ -1594,12 +1594,13 @@ 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.md and + // A zombie .sf/ state (symlink exists but missing preferences.yaml/PREFERENCES.md and // milestones/) must trigger the init wizard, not skip it (#2942). const sfPath = sfRoot(basePath); const hasBootstrapArtifacts = existsSync(sfPath) && - (existsSync(join(sfPath, "PREFERENCES.md")) || + (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/init-wizard.js b/src/resources/extensions/sf/init-wizard.js index f68d74d14..301966e4e 100644 --- a/src/resources/extensions/sf/init-wizard.js +++ b/src/resources/extensions/sf/init-wizard.js @@ -507,9 +507,9 @@ function bootstrapSfDirectory(basePath, prefs, signals) { const sf = sfRoot(basePath); mkdirSync(join(sf, "milestones"), { recursive: true }); mkdirSync(join(sf, "runtime"), { recursive: true }); - // Write PREFERENCES.md from wizard answers + // Write preferences.yaml from wizard answers (pure YAML, no frontmatter markers) const preferencesContent = buildPreferencesFile(prefs); - writeFileSync(join(sf, "PREFERENCES.md"), preferencesContent, "utf-8"); + writeFileSync(join(sf, "preferences.yaml"), preferencesContent, "utf-8"); // Seed CONTEXT.md with detected project signals const contextContent = buildContextSeed(signals); if (contextContent) { @@ -519,7 +519,7 @@ function bootstrapSfDirectory(basePath, prefs, signals) { ensureSiftIndexWarmup(basePath); } function buildPreferencesFile(prefs) { - const lines = ["---"]; + const lines = ["# SF preferences — see ~/.sf/agent/extensions/sf/docs/preferences-reference.md for docs"]; lines.push("version: 1"); lines.push(`mode: ${prefs.mode}`); // Git preferences @@ -559,17 +559,6 @@ function buildPreferencesFile(prefs) { if (prefs.minRequestIntervalMs > 0) { lines.push(`min_request_interval_ms: ${prefs.minRequestIntervalMs}`); } - lines.push("---"); - lines.push(""); - lines.push("# SF Project Preferences"); - lines.push(""); - lines.push( - "Generated by `/init`. Edit directly or use `/prefs project` to modify.", - ); - lines.push(""); - lines.push( - "See `~/.sf/agent/extensions/sf/docs/preferences-reference.md` for full field documentation.", - ); lines.push(""); return lines.join("\n"); } diff --git a/src/resources/extensions/sf/key-manager.js b/src/resources/extensions/sf/key-manager.js index 2f8388b8d..e0884ee70 100644 --- a/src/resources/extensions/sf/key-manager.js +++ b/src/resources/extensions/sf/key-manager.js @@ -8,6 +8,7 @@ import { chmodSync, existsSync, mkdirSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { AuthStorage } from "@singularity-forge/coding-agent"; import { getErrorMessage } from "./error-utils.js"; +import { isEnvAuthAllowed } from "./provider-env-auth.js"; export const PROVIDER_REGISTRY = [ // LLM Providers { @@ -291,7 +292,7 @@ export function getAllKeyStatuses(auth) { const rawCreds = auth.getCredentialsForProvider(provider.id); // Filter out empty-key entries (left by legacy removeProviderToken or skipped onboarding) const creds = rawCreds.filter((c) => !(c.type === "api_key" && !c.key)); - const envKey = getProviderEnvKey(provider); + const envKey = isEnvAuthAllowed(provider.id) ? getProviderEnvKey(provider) : undefined; if (creds.length > 0) { const firstCred = creds[0]; const desc = @@ -868,8 +869,9 @@ export function runKeyDoctor(auth) { } } } - // 4. Check for env var conflicts + // 4. Check for env var conflicts (only when env auth is allowed for that provider) for (const provider of PROVIDER_REGISTRY) { + if (!isEnvAuthAllowed(provider.id)) continue; const envKey = getProviderEnvKey(provider); if (!envKey) continue; const creds = auth.getCredentialsForProvider(provider.id); @@ -900,7 +902,7 @@ export function runKeyDoctor(auth) { const hasValidKey = creds.some((c) => c.type === "api_key" ? !!c.key : true, ); - const hasEnv = !!getProviderEnvKey(p); + const hasEnv = isEnvAuthAllowed(p.id) && !!getProviderEnvKey(p); return hasValidKey || hasEnv; }); if (!hasAnyLlm) { diff --git a/src/resources/extensions/sf/planning-depth.js b/src/resources/extensions/sf/planning-depth.js index f423da377..df0451160 100644 --- a/src/resources/extensions/sf/planning-depth.js +++ b/src/resources/extensions/sf/planning-depth.js @@ -1,9 +1,9 @@ -// SF — Deep planning mode — Helper to set planning_depth in .sf/PREFERENCES.md. +// SF — Deep planning mode — Helper to set planning_depth in .sf/preferences.yaml. // // Persists the user's deep-mode opt-in across sessions. Reads the existing -// preferences file (if any), parses its YAML frontmatter, sets/updates -// planning_depth, and writes the file back preserving body content and other -// frontmatter keys. +// 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). import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; @@ -12,10 +12,19 @@ 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 .sf/PREFERENCES.md file. + * 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. */ function getProjectSFPreferencesFilePath(basePath) { - return join(sfRoot(basePath), "PREFERENCES.md"); + 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 }; } /** * Resolve the path to the project-level .sf/runtime/research-decision.json file. @@ -40,36 +49,51 @@ export function writeDefaultResearchSkipDecision(basePath) { writeFileSync(decisionPath, payload, "utf-8"); } /** - * Set planning_depth in the project's .sf/PREFERENCES.md. + * 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. */ export function setPlanningDepth(basePath, depth) { - const path = getProjectSFPreferencesFilePath(basePath); - const { frontmatter, body } = readProjectPreferencesParts(path); + const { path, isYaml } = getProjectSFPreferencesFilePath(basePath); + const { frontmatter, body } = readProjectPreferencesParts(path, isYaml); frontmatter.planning_depth = depth; if (depth === "deep") { applyDeepWorkflowPreferenceDefaults(frontmatter); } - writeProjectPreferencesParts(path, frontmatter, body); + writeProjectPreferencesParts(path, frontmatter, body, isYaml); if (depth === "deep") { ensureResearchDecisionDefault(basePath); } } export function ensureWorkflowPreferencesCaptured(basePath) { - const path = getProjectSFPreferencesFilePath(basePath); - const { frontmatter, body } = readProjectPreferencesParts(path); + const { path, isYaml } = getProjectSFPreferencesFilePath(basePath); + const { frontmatter, body } = readProjectPreferencesParts(path, isYaml); frontmatter.planning_depth = "deep"; applyDeepWorkflowPreferenceDefaults(frontmatter); - writeProjectPreferencesParts(path, frontmatter, body); + writeProjectPreferencesParts(path, frontmatter, body, isYaml); ensureResearchDecisionDefault(basePath); } -function readProjectPreferencesParts(path) { +function readProjectPreferencesParts(path, isYaml) { 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)}`, + ); + } + return { frontmatter, body: "" }; + } const match = content.match(FRONTMATTER_RE); if (match) { try { @@ -79,28 +103,28 @@ function readProjectPreferencesParts(path) { } body = match[2]; } catch (err) { - // Invalid YAML — don't lose user content. Treat the whole file as - // a legacy non-frontmatter document and preserve it via the body - // path. The depth setter then prepends a fresh frontmatter block. logWarning( "guided", - `PREFERENCES.md frontmatter has invalid YAML — preserving body and rewriting frontmatter: ${err instanceof Error ? err.message : String(err)}`, + `preferences file frontmatter has invalid YAML — preserving body and rewriting frontmatter: ${err instanceof Error ? err.message : String(err)}`, ); body = content; } } else { - // No frontmatter delimiters — preserve existing content as body. body = content; } } return { frontmatter, body }; } -function writeProjectPreferencesParts(path, frontmatter, body) { - // yaml.stringify emits a trailing newline. Strip if present so we control framing. +function writeProjectPreferencesParts(path, frontmatter, body, isYaml) { const yamlBlock = stringifyYaml(frontmatter).replace(/\n$/, ""); - const newContent = body - ? `---\n${yamlBlock}\n---\n\n${body.replace(/^\n+/, "")}` - : `---\n${yamlBlock}\n---\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"); } diff --git a/src/resources/extensions/sf/preferences-models.js b/src/resources/extensions/sf/preferences-models.js index 6d02cdb4e..77a9c4ecc 100644 --- a/src/resources/extensions/sf/preferences-models.js +++ b/src/resources/extensions/sf/preferences-models.js @@ -38,7 +38,7 @@ function getGlobalSFPreferencesPath() { return _getGlobalSFPreferencesPath(); } -import { getConfiguredEnvApiKey } from "./provider-env-auth.js"; +import { getKeyManagerAuthStorage } from "./key-manager.js"; const OPENCODE_FREE_MODEL_IDS = new Set([ "big-pickle", @@ -275,10 +275,18 @@ function resolveAutoBenchmarkPickForUnit(unitType, prefs) { try { const allowed = prefs?.allowed_providers?.map((s) => s.toLowerCase()); const blocked = prefs?.blocked_providers?.map((s) => s.toLowerCase()); + const auth = getKeyManagerAuthStorage(); + const configuredProviderIds = new Set( + auth.getConfiguredProviders?.() ?? + getProviders().filter((p) => { + const creds = auth.getCredentialsForProvider(p); + return creds.some((c) => c.type === "oauth" || (c.type === "api_key" && c.key)); + }), + ); const candidates = []; for (const provider of getProviders()) { if (!isProviderAllowedByLists(provider, allowed, blocked)) continue; - if (!getConfiguredEnvApiKey(provider)) continue; + if (!configuredProviderIds.has(provider)) continue; for (const model of getModels(provider)) { if ( !isProviderModelAllowed( @@ -440,7 +448,7 @@ export function resolveModelWithFallbacksForUnit(unitType, options = {}) { * * Used at autonomous mode bootstrap to override the session model that was * determined by settings.json (defaultProvider/defaultModel). When - * PREFERENCES.md (or project preferences) configures an `execution` model + * preferences.yaml (or project preferences) configures an `execution` model * we treat that as the session default. Falls back through execution → * planning → first configured model. * @@ -509,11 +517,11 @@ export function resolveDefaultSessionModel(sessionProvider) { * proxies, etc.). * * Used by autonomous mode bootstrap to decide whether the session model - * (set via `/model`) should override `PREFERENCES.md`. Custom providers - * are never reachable from `PREFERENCES.md` (which only knows built-in + * (set via `/model`) should override `preferences.yaml`. Custom providers + * are never reachable from `preferences.yaml` (which only knows built-in * providers), so when the user has explicitly selected one, it must take * priority — otherwise autonomous mode tries to start the built-in provider from - * PREFERENCES.md and fails with "Not logged in · Please run /login" (#4122). + * preferences.yaml and fails with "Not logged in · Please run /login" (#4122). * * Reads models.json directly with a lightweight JSON parse to avoid * pulling in the full model-registry at this call site. Falls back to @@ -848,3 +856,23 @@ export function resolveSearchProviderFromPreferences() { const prefs = loadEffectiveSFPreferences(); return prefs?.preferences.search_provider; } + +// Word-boundary patterns for known heavy/expensive model families. +// "Heavy" means: high cost, high latency — blocked when modelMode === "fast". +// Use word boundaries (\b) so "opus-lite" or "mini-o1" don't false-positive. +const HEAVY_MODEL_PATTERNS = [ + /\bopus\b/i, + /\bo1\b/i, + /\bo3\b/i, + /\bgpt-4-turbo\b/i, + /\bgpt-5\b/i, + /\bdeepseek-reasoner\b/i, +]; +/** + * Returns true if modelId belongs to a known heavy (high-cost/high-latency) family. + * Used to enforce `modelMode === "fast"` constraints on subagent dispatch. + */ +export function isHeavyModelId(modelId) { + if (!modelId || typeof modelId !== "string") return false; + return HEAVY_MODEL_PATTERNS.some((pattern) => pattern.test(modelId)); +} diff --git a/src/resources/extensions/sf/preferences-serializer.js b/src/resources/extensions/sf/preferences-serializer.js index 3f2e28273..b4a11c1a6 100644 --- a/src/resources/extensions/sf/preferences-serializer.js +++ b/src/resources/extensions/sf/preferences-serializer.js @@ -36,7 +36,7 @@ export function yamlSafeString(val) { * stable diffs; unknown keys are appended after the ordered set. * * Purpose: produce the YAML block written between `---` markers in - * PREFERENCES.md. Relies only on yamlSafeString — no filesystem or + * preferences.yaml (or legacy PREFERENCES.md). Relies only on yamlSafeString — no filesystem or * preferences-loading imports needed. * * Consumer: preferences-template-upgrade.js, commands-prefs-wizard.js. diff --git a/src/resources/extensions/sf/preferences-template-upgrade.js b/src/resources/extensions/sf/preferences-template-upgrade.js index 05e3551b6..9faa5ff6b 100644 --- a/src/resources/extensions/sf/preferences-template-upgrade.js +++ b/src/resources/extensions/sf/preferences-template-upgrade.js @@ -55,6 +55,15 @@ export function upgradePreferencesFileIfDrifted(path, prefs) { } 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. diff --git a/src/resources/extensions/sf/preferences-validation.js b/src/resources/extensions/sf/preferences-validation.js index 9082f571d..b1f05a256 100644 --- a/src/resources/extensions/sf/preferences-validation.js +++ b/src/resources/extensions/sf/preferences-validation.js @@ -17,6 +17,7 @@ import { KNOWN_UNIT_TYPES, SKILL_ACTIONS, } from "./preferences-types.js"; +import { VALID_SEARCH_PROVIDER_PREFS } from "./setup-catalog.js"; const VALID_TOKEN_PROFILES = new Set([ "budget", @@ -420,25 +421,14 @@ export function validatePreferences(preferences) { } // ─── Search Provider ───────────────────────────────────────────── if (preferences.search_provider !== undefined) { - const validSearchProviders = new Set([ - "brave", - "tavily", - "minimax", - "serper", - "exa", - "ollama", - "combosearch", - "native", - "auto", - ]); if ( typeof preferences.search_provider === "string" && - validSearchProviders.has(preferences.search_provider) + VALID_SEARCH_PROVIDER_PREFS.has(preferences.search_provider) ) { validated.search_provider = preferences.search_provider; } else { errors.push( - `search_provider must be one of: brave, tavily, minimax, serper, exa, ollama, combosearch, native, auto`, + `search_provider must be one of: ${[...VALID_SEARCH_PROVIDER_PREFS].join(", ")}`, ); } } diff --git a/src/resources/extensions/sf/preferences.js b/src/resources/extensions/sf/preferences.js index 155d719e8..e1703c810 100644 --- a/src/resources/extensions/sf/preferences.js +++ b/src/resources/extensions/sf/preferences.js @@ -78,16 +78,24 @@ export { function sfHome() { return process.env.SF_HOME || join(homedir(), ".sf"); } +// Canonical new 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 - * come from the MAIN worktree's `.sf/PREFERENCES.md`, not the milestone + * come from the MAIN worktree's `.sf/preferences.yaml`, not the milestone * branch's frozen copy. Otherwise a pref change on main never reaches an * in-flight milestone, and we saw this in practice: updating PREFERENCES * on main had no effect until the milestone branch merged main. @@ -123,22 +131,23 @@ function projectPrefsRoot() { } return cwd; } +// Canonical new 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"); } -// Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake. -// Check uppercase as a fallback so those files aren't silently ignored. -function globalPreferencesPathUppercase() { - return join(sfHome(), "PREFERENCES.md"); -} function projectPreferencesPathUppercase() { return join(sfRoot(projectPrefsRoot()), "PREFERENCES.md"); } /** - * Get the path to the global SF preferences file. + * Get the canonical path for the global SF preferences file. + * New installs use preferences.yaml; legacy .md files are read as fallbacks. */ export function getGlobalSFPreferencesPath() { - return globalPreferencesPath(); + return globalPreferencesYamlPath(); } /** * Get the path to the legacy global SF preferences file (deprecated location). @@ -147,27 +156,30 @@ export function getLegacyGlobalSFPreferencesPath() { return legacyGlobalPreferencesPath(); } /** - * Get the path to the project-level SF preferences file. + * Get the canonical path for the project-level SF preferences file. + * New installs use preferences.yaml; legacy .md files are read as fallbacks. */ export function getProjectSFPreferencesPath() { - return projectPreferencesPath(); + return projectPreferencesYamlPath(); } // ─── Loading ──────────────────────────────────────────────────────────────── /** - * Load global SF preferences, trying multiple paths and legacy locations. + * Load global SF preferences, trying yaml first then legacy markdown locations. */ export function loadGlobalSFPreferences() { return ( + loadPreferencesFile(globalPreferencesYamlPath(), "global") ?? loadPreferencesFile(globalPreferencesPath(), "global") ?? loadPreferencesFile(globalPreferencesPathUppercase(), "global") ?? loadPreferencesFile(legacyGlobalPreferencesPath(), "global") ); } /** - * Load project-level SF preferences. + * Load project-level SF preferences, trying yaml first then legacy markdown. */ export function loadProjectSFPreferences() { return ( + loadPreferencesFile(projectPreferencesYamlPath(), "project") ?? loadPreferencesFile(projectPreferencesPath(), "project") ?? loadPreferencesFile(projectPreferencesPathUppercase(), "project") ); @@ -225,7 +237,10 @@ export function loadEffectiveSFPreferences() { function loadPreferencesFile(path, scope) { if (!existsSync(path)) return null; const raw = readFileSync(path, "utf-8"); - const preferences = parsePreferencesMarkdown(raw); + // .yaml files are pure YAML — no frontmatter extraction needed + const preferences = path.endsWith(".yaml") + ? parsePreferencesYaml(raw) + : parsePreferencesMarkdown(raw); if (!preferences) return null; const validation = validatePreferences(preferences); // Self-align: if the file's recorded sf version drifted from current, @@ -249,6 +264,22 @@ export function _resetParseWarningFlag() { _warnedFrontmatterParse = false; _warnedSectionParse = false; } +/** + * Parse preferences from a pure YAML file (no frontmatter markers needed). + * Used for preferences.yaml; the entire file content is YAML. + * + * @internal Exported for testing only + */ +export function parsePreferencesYaml(content) { + try { + const parsed = parseYaml(content); + if (typeof parsed !== "object" || parsed === null) return {}; + return parsed; + } catch (e) { + logWarning("guided", `YAML parse error in preferences.yaml: ${e.message}`); + return {}; + } +} /** * Parse preferences from markdown frontmatter or heading+list format. * diff --git a/src/resources/extensions/sf/prompts/guided-workflow-preferences.md b/src/resources/extensions/sf/prompts/guided-workflow-preferences.md index 56074e4eb..adb170d7d 100644 --- a/src/resources/extensions/sf/prompts/guided-workflow-preferences.md +++ b/src/resources/extensions/sf/prompts/guided-workflow-preferences.md @@ -1,6 +1,6 @@ **Working directory:** `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. For `.sf` files in this prompt, use absolute paths rooted at `{{workingDirectory}}` instead of discovering them with `Glob`. -Configure project workflow preferences. This stage runs ONCE per project, early in deep-mode bootstrap, before `discuss-project`. It applies a small set of recommended workflow defaults and persists them to the YAML frontmatter of `.sf/PREFERENCES.md` (the same file the runtime reads its preferences from). +Configure project workflow preferences. This stage runs ONCE per project, early in deep-mode bootstrap, before `discuss-project`. It applies a small set of recommended workflow defaults and persists them to `.sf/preferences.yaml` (the same file the runtime reads its preferences from). This is a **default-writing** stage — do NOT ask the user questions. Write the recommended defaults, then end. No follow-ups, no research, no opinion. @@ -32,15 +32,15 @@ Use these recommended defaults without asking: Apply the defaults: -1. Read `{{workingDirectory}}/.sf/PREFERENCES.md` if it exists. The file is YAML frontmatter (between `---` lines) followed by an optional markdown body. Parse the existing frontmatter so you can preserve unrelated keys (e.g. `planning_depth`). -2. Merge the defaults into the frontmatter under these keys, preserving any existing explicit value: +1. Read `{{workingDirectory}}/.sf/preferences.yaml` if it exists (pure YAML, no `---` markers). Also check `.sf/PREFERENCES.md` as a legacy fallback. Parse the existing YAML so you can preserve unrelated keys (e.g. `planning_depth`). +2. Merge the defaults into the YAML under these keys, preserving any existing explicit value: - top-level `commit_policy: per-task` - top-level `branch_model: single` - top-level `uat_dispatch: true` - top-level `research: skip` - nested `models.executor_class: balanced` 3. Also set top-level `workflow_prefs_captured: true` — this is the single explicit marker the dispatch layer uses to know the wizard has run. -4. Write `{{workingDirectory}}/.sf/PREFERENCES.md` back with the merged frontmatter and the original body preserved unchanged. Frontmatter delimiters are exactly `---` on their own lines. +4. Write `{{workingDirectory}}/.sf/preferences.yaml` back as plain YAML (no `---` frontmatter delimiters, no Markdown body). If the source was a legacy `.md` file, still write the new output to `preferences.yaml`. 5. Pre-seed the research decision so the standalone `research-decision` stage is a no-op if the user already answered here: - Ensure `{{workingDirectory}}/.sf/runtime/` exists. - Write `{{workingDirectory}}/.sf/runtime/research-decision.json`: @@ -56,7 +56,7 @@ Apply the defaults: 6. Print a concise summary in chat: each key on its own line, format `key: value`. Include `commit_policy`, `branch_model`, `uat_dispatch`, `models.executor_class`, and `research` (matching the preserved or pre-seeded runtime research decision). 7. Say exactly: `"Workflow preferences saved."` — nothing else. -Do NOT write to `.sf/config.json`; runtime preferences load from `PREFERENCES.md`. +Do NOT write to `.sf/config.json`; runtime preferences load from `preferences.yaml`. --- @@ -64,5 +64,5 @@ Do NOT write to `.sf/config.json`; runtime preferences load from `PREFERENCES.md - Do NOT ask any questions. Defaults only, write file, done. - Do NOT call `ask_user_questions`, `AskUserQuestion`, or any other interactive user-input tool in this stage. -- Do NOT change any keys other than the frontmatter keys specified plus `workflow_prefs_captured`. Research is persisted to `.sf/runtime/research-decision.json`, NOT to `phases.skip_research`. +- Do NOT change any keys other than the YAML keys specified plus `workflow_prefs_captured`. Research is persisted to `.sf/runtime/research-decision.json`, NOT to `phases.skip_research`. - Preserve existing explicit values for `commit_policy`, `branch_model`, `uat_dispatch`, and `models.executor_class`; only fill missing values with defaults. diff --git a/src/resources/extensions/sf/prompts/system.md b/src/resources/extensions/sf/prompts/system.md index 13083f259..6c9de441f 100644 --- a/src/resources/extensions/sf/prompts/system.md +++ b/src/resources/extensions/sf/prompts/system.md @@ -103,7 +103,7 @@ Titles live inside file content (headings, frontmatter), not in file or director ### Isolation Model -Autonomous mode supports three isolation modes (configured in `.sf/PREFERENCES.md` under `git.isolation`): +Autonomous mode supports three isolation modes (configured in `.sf/preferences.yaml` under `git.isolation`): - **worktree** (default): Work happens in `.sf/worktrees//`, a full git worktree on the `milestone/` branch. Each worktree has its own working copy and `.sf/` directory. Squash-merged back to the integration branch on milestone completion. - **branch**: Work happens in the project root on a `milestone/` branch. No worktree directory — files are checked out in-place. diff --git a/src/resources/extensions/sf/provider-env-auth.js b/src/resources/extensions/sf/provider-env-auth.js index 2bfb89d29..125519e93 100644 --- a/src/resources/extensions/sf/provider-env-auth.js +++ b/src/resources/extensions/sf/provider-env-auth.js @@ -3,11 +3,6 @@ import { join } from "node:path"; import { getEnvApiKey } from "@singularity-forge/ai"; import { getAgentDir, SettingsManager } from "@singularity-forge/coding-agent"; -const GOOGLE_ENV_AUTH_DEFAULT_OFF_PROVIDERS = new Set([ - "google", - "google-gemini-cli", -]); - function readJson(path) { try { if (!existsSync(path)) return {}; @@ -37,11 +32,18 @@ function getProviderEnvAuthMode(providerId, cwd) { return settingsManager.getProviderEnvAuthMode(providerId); } const settings = readProviderEnvAuthSettings(cwd, agentDir); - return ( - settings.providers?.[providerId] ?? - settings.default ?? - (GOOGLE_ENV_AUTH_DEFAULT_OFF_PROVIDERS.has(providerId) ? "off" : "auto") - ); + // Default: "off" — env var keys are ignored unless explicitly enabled per provider. + // Set providerEnvAuth.providers. = "auto" in settings.json to opt in. + return settings.providers?.[providerId] ?? settings.default ?? "off"; +} + +/** + * Returns true if env-var auth is allowed for this provider. + * Env auth is off by default — opt in via providerEnvAuth.providers. = "auto" + * in ~/.sf/agent/settings.json or .sf/settings.json. + */ +export function isEnvAuthAllowed(providerId, cwd = process.cwd()) { + return getProviderEnvAuthMode(providerId, cwd) !== "off"; } function getProviderEnvKey(providerId) { @@ -58,13 +60,24 @@ function getProviderEnvKey(providerId) { /** * Return the provider env API key only when Forge settings allow env auth. * - * Purpose: keep SF extension-side provider heuristics aligned with the core - * providerEnvAuth policy so ambient env keys do not bypass settings.json. - * - * Consumer: doctor-providers.js and preferences-models.js when checking whether - * a provider is available from environment credentials. + * Covers SDK-known providers (via getEnvApiKey) and Google variants. + * For SF-specific providers (search, tool), use getGatedEnvVarKey instead. */ export function getConfiguredEnvApiKey(providerId, cwd = process.cwd()) { - if (getProviderEnvAuthMode(providerId, cwd) === "off") return undefined; + if (!isEnvAuthAllowed(providerId, cwd)) return undefined; return getProviderEnvKey(providerId); } + +/** + * Return the value of a specific env var only when env auth is allowed for that provider. + * + * Purpose: gate SF-specific providers (Brave, Tavily, Serper, Exa, etc.) that are + * not covered by the SDK's getEnvApiKey but whose envVar is known from PROVIDER_REGISTRY. + * + * Consumer: doctor-providers.js fallback path for search/tool providers. + */ +export function getGatedEnvVarKey(providerId, envVarName, cwd = process.cwd()) { + if (!isEnvAuthAllowed(providerId, cwd)) return undefined; + const value = process.env[envVarName]; + return value && value.trim() ? value : undefined; +} diff --git a/src/resources/extensions/sf/rule-registry.js b/src/resources/extensions/sf/rule-registry.js index b4adc565a..a98346501 100644 --- a/src/resources/extensions/sf/rule-registry.js +++ b/src/resources/extensions/sf/rule-registry.js @@ -515,7 +515,7 @@ export class RuleRegistry { formatHookStatus() { const entries = this.getHookStatus(); if (entries.length === 0) { - return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .sf/PREFERENCES.md"; + return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .sf/preferences.yaml"; } const lines = ["Configured Hooks:", ""]; const postHooks = entries.filter((e) => e.type === "post"); diff --git a/src/resources/extensions/sf/setup-catalog.js b/src/resources/extensions/sf/setup-catalog.js index 4df9675d5..ea863dc75 100644 --- a/src/resources/extensions/sf/setup-catalog.js +++ b/src/resources/extensions/sf/setup-catalog.js @@ -118,3 +118,15 @@ export function getLlmProviderIds() { new Set([...getLlmProviders().map((p) => p.id), "claude-code"]), ); } +// Key-free and meta search provider IDs that are valid as preferences but have +// no PROVIDER_REGISTRY entry (no env-var key required). +const KEYLESS_SEARCH_PROVIDER_IDS = ["minimax", "ollama", "combosearch", "native", "auto"]; +/** + * All valid values for the `search_provider` preference. + * Derived from PROVIDER_REGISTRY (key-backed) plus keyless/meta providers. + * Single source of truth — import this instead of hardcoding the list. + */ +export const VALID_SEARCH_PROVIDER_PREFS = new Set([ + ...getSearchProviders().map((p) => p.id), + ...KEYLESS_SEARCH_PROVIDER_IDS, +]); diff --git a/src/resources/extensions/sf/skills/context-doctor/SKILL.md b/src/resources/extensions/sf/skills/context-doctor/SKILL.md index 2caf34616..50171738f 100644 --- a/src/resources/extensions/sf/skills/context-doctor/SKILL.md +++ b/src/resources/extensions/sf/skills/context-doctor/SKILL.md @@ -28,7 +28,7 @@ Read each persistent context file and judge: | `.sf/PROJECT.md` | Does it still match what the project actually is? | | `.sf/CODEBASE.md` | Are file paths and module references still valid? | | `.sf/KNOWLEDGE.md` | Any duplicates, stale conclusions, or contradictions? | -| `.sf/PREFERENCES.md` | Still reflective of how the user wants to work? | +| `.sf/preferences.yaml` | Still reflective of how the user wants to work? | | `.sf/DECISIONS.md` | Any superseded decisions still present? | | `.sf/REQUIREMENTS.md` | Active requirements still active? Anything done not marked done? | | `.sf/PM-STRATEGY.md` | Still aligned with current direction? | diff --git a/src/resources/extensions/sf/spec-projections.js b/src/resources/extensions/sf/spec-projections.js index bff2d28b4..b71935280 100644 --- a/src/resources/extensions/sf/spec-projections.js +++ b/src/resources/extensions/sf/spec-projections.js @@ -78,7 +78,7 @@ export function buildWorkingModelInputs(basePath) { `- Guidance: \`.sf/TASTE.md\` (${present(join(sfDir, "TASTE.md"))})`, `- Guidance: \`.sf/ANTI-GOALS.md\` (${present(join(sfDir, "ANTI-GOALS.md"))})`, `- Optional knowledge: \`.sf/KNOWLEDGE.md\` (${present(join(sfDir, "KNOWLEDGE.md"))})`, - `- Optional preferences: \`.sf/PREFERENCES.md\` (${present(join(sfDir, "PREFERENCES.md"))})`, + `- Optional preferences: \`.sf/preferences.yaml\` (${present(join(sfDir, "preferences.yaml"))})`, readDbSummary(basePath), "- Source roots analyzed as implementation evidence: `src/resources/extensions/sf/`, `src/headless*.ts`, `src/cli.ts`, `src/help-text.ts`, `web/`, `vscode-extension/`, `packages/`", "", @@ -193,7 +193,7 @@ export function generateOperatingModelSpec(basePath) { "", "Markdown under `.sf/` has two roles:", "", - "- working guidance and knowledge that the runtime loads, such as `PRINCIPLES.md`, `TASTE.md`, `ANTI-GOALS.md`, `KNOWLEDGE.md`, and `PREFERENCES.md`;", + "- working guidance and knowledge that the runtime loads, such as `PRINCIPLES.md`, `TASTE.md`, `ANTI-GOALS.md`, `KNOWLEDGE.md`, and `preferences.yaml`;", "- human-readable projections from DB-owned records, such as rendered decisions, requirements, roadmap, plan, summary, and state files.", "", "Markdown under `docs/specs/` is a human export for review, navigation, and git history. Generated docs can change; Git records that human-facing history. If SF needs its own operational history, it should store that in `.sf`/DB-backed state. Plans should record any surface, protocol, output-format, run-control, or permission-profile impact explicitly when a milestone changes integration behavior.", @@ -234,7 +234,7 @@ export function generateOperatingModelSpec(basePath) { "", "- `.sf/sf.db` is the canonical structured runtime state store for initialized SF repos. Treat a missing or unreadable DB as bootstrap/recovery, not a normal alternate source of truth.", "- `.sf/DECISIONS.md`, `.sf/REQUIREMENTS.md`, milestone roadmaps, and similar files are rendered working projections when database-backed tools own the data. They are useful to humans and agents but must not compete with DB rows.", - "- `.sf/PRINCIPLES.md`, `.sf/TASTE.md`, `.sf/ANTI-GOALS.md`, `.sf/KNOWLEDGE.md`, and `.sf/PREFERENCES.md` are repo-local working guidance files when present.", + "- `.sf/PRINCIPLES.md`, `.sf/TASTE.md`, `.sf/ANTI-GOALS.md`, `.sf/KNOWLEDGE.md`, and `.sf/preferences.yaml` are repo-local working guidance files when present.", "- Generated `.sf/` runtime files are evidence, projections, or import/recovery artifacts.", "- Durable human-facing exports belong in `docs/specs/`, `docs/adr/`, or `docs/plans/`. They are reviewable projections and git-history artifacts, not a second planning database.", "", diff --git a/src/resources/extensions/sf/steerable-autonomous-panel.js b/src/resources/extensions/sf/steerable-autonomous-panel.js index fadd41ef0..a56fecc36 100644 --- a/src/resources/extensions/sf/steerable-autonomous-panel.js +++ b/src/resources/extensions/sf/steerable-autonomous-panel.js @@ -8,6 +8,8 @@ import { createInterface } from "node:readline"; +import { NOTICE_KIND } from "./notification-store.js"; + // ─── Constants ────────────────────────────────────────────────────────────── const PANEL_WIDTH = 60; const PANEL_HEIGHT = 12; @@ -117,74 +119,101 @@ function renderPanel(currentStatus = "") { return renderBox(panelContent, "🎛️ Steerable Autonomous Mode"); } +// ─── Notification helpers ───────────────────────────────────────────────────── + +/** + * Build durable-notification metadata for steerable panel shortcuts so the store can classify and dedupe. + * + * Purpose: single source of truth for `noticeKind` / `dedupe_key` on user-initiated steering feedback (no duplicated option blobs per shortcut). + * + * Consumer: `notifySteeringPanel` and tests asserting stable metadata contracts. + * + * @param {string} actionId Stable handler id (matches CONTROL_CATEGORIES `action` values and YOLO variants). + * @returns {{ noticeKind: string; dedupe_key: string; source: string }} + */ +export function buildSteeringPanelNotifyMeta(actionId) { + return { + noticeKind: NOTICE_KIND.USER_VISIBLE, + dedupe_key: `steer-panel:${actionId}`, + source: "steerable-autonomous-panel", + }; +} + +/** + * Emit one panel shortcut acknowledgement through `ctx.ui.notify` with consistent metadata. + * + * Purpose: keep steering toasts classifiable and merge-friendly when the user spams a hotkey. + * + * Consumer: ACTION_HANDLERS below. + * + * @param {{ ui: { notify: (msg: string, level?: string, meta?: object) => void } }} ctx + * @param {string} actionId + * @param {string} message + * @param {"info"|"success"|"warning"|"error"} [level] + */ +function notifySteeringPanel(ctx, actionId, message, level = "info") { + ctx.ui.notify(message, level, buildSteeringPanelNotifyMeta(actionId)); +} + +/** Stub copy for shortcuts — replace with live AI/runtime wiring when those actions are implemented. */ +const STEERING_ACK_TEXT = { + focus_research: "🎯 Focusing on research phase", + focus_plan: "🎯 Focusing on planning phase", + focus_build: "🎯 Focusing on implementation phase", + speed_up: "⚡ Execution speed increased", + slow_down: "🐌 Execution speed decreased", + ask_status: "🤖 I'm currently working on [current task]", + ask_reasoning: "🤖 I chose this approach because...", + ask_next: "🤖 Next I'll [next step]", + ask_stuck: "🤖 I'm not stuck, but here's my status...", + ask_plan: "🤖 My plan is: [detailed plan]", + pause: "⏸️ Autonomous mode paused", + ask_attempts: "🤖 I've tried multiple approaches: [list of attempts]", + ask_blockers: "🤖 Main blockers: [list of current blockers]", + reassess: "🔄 Reassessing - trying new approaches", +}; + +/** + * @param {keyof typeof STEERING_ACK_TEXT} actionId + */ +function simpleSteeringHandler(actionId) { + return async (ctx) => { + notifySteeringPanel(ctx, actionId, STEERING_ACK_TEXT[actionId], "info"); + }; +} + // ─── Action Handlers ──────────────────────────────────────────────────────────── const ACTION_HANDLERS = { - focus_research: async (ctx) => { - ctx.ui.notify("🎯 Focusing on research phase", "info"); - // Would set autonomous mode to prioritize research - }, - - focus_plan: async (ctx) => { - ctx.ui.notify("🎯 Focusing on planning phase", "info"); - // Would set autonomous mode to prioritize planning - }, - - focus_build: async (ctx) => { - ctx.ui.notify("🎯 Focusing on implementation phase", "info"); - // Would set autonomous mode to prioritize building - }, - - speed_up: async (ctx) => { - ctx.ui.notify("⚡ Execution speed increased", "info"); - // Would adjust autonomous execution speed - }, - - slow_down: async (ctx) => { - ctx.ui.notify("🐌 Execution speed decreased", "info"); - // Would adjust autonomous execution speed - }, - - ask_status: async (ctx) => { - ctx.ui.notify("🤖 I'm currently working on [current task]", "info"); - // Would provide current status via AI response - }, - - ask_reasoning: async (ctx) => { - ctx.ui.notify("🤖 I chose this approach because...", "info"); - // Would provide reasoning via AI response - }, - - ask_next: async (ctx) => { - ctx.ui.notify("🤖 Next I'll [next step]", "info"); - // Would provide next steps via AI response - }, - - ask_stuck: async (ctx) => { - ctx.ui.notify("🤖 I'm not stuck, but here's my status...", "info"); - // Would provide stuck status via AI response - }, - - ask_plan: async (ctx) => { - ctx.ui.notify("🤖 My plan is: [detailed plan]", "info"); - // Would provide plan explanation via AI response - }, - - pause: async (ctx) => { - ctx.ui.notify("⏸️ Autonomous mode paused", "info"); - // Would pause autonomous execution - }, + focus_research: simpleSteeringHandler("focus_research"), + focus_plan: simpleSteeringHandler("focus_plan"), + focus_build: simpleSteeringHandler("focus_build"), + speed_up: simpleSteeringHandler("speed_up"), + slow_down: simpleSteeringHandler("slow_down"), + ask_status: simpleSteeringHandler("ask_status"), + ask_reasoning: simpleSteeringHandler("ask_reasoning"), + ask_next: simpleSteeringHandler("ask_next"), + ask_stuck: simpleSteeringHandler("ask_stuck"), + ask_plan: simpleSteeringHandler("ask_plan"), + pause: simpleSteeringHandler("pause"), + ask_attempts: simpleSteeringHandler("ask_attempts"), + ask_blockers: simpleSteeringHandler("ask_blockers"), + reassess: simpleSteeringHandler("reassess"), yolo: async (ctx) => { - // Toggle YOLO mode - integrate with existing SafeGit system - if (ctx.settingsManager && ctx.settingsManager.toggleYOLO) { + if (ctx.settingsManager?.toggleYOLO) { const enabled = ctx.settingsManager.toggleYOLO(); - ctx.ui.notify( - `🚀 YOLO mode ${enabled ? "ON" : "OFF"} - safe-git prompts ${enabled ? "disabled" : "enabled"}`, + const msg = `🚀 YOLO mode ${enabled ? "ON" : "OFF"} - safe-git prompts ${enabled ? "disabled" : "enabled"}`; + notifySteeringPanel( + ctx, + enabled ? "yolo-on" : "yolo-off", + msg, enabled ? "success" : "info", ); } else { - ctx.ui.notify( + notifySteeringPanel( + ctx, + "yolo-stub", "🚀 YOLO mode - safe-git prompts disabled for this session", "success", ); @@ -192,27 +221,13 @@ const ACTION_HANDLERS = { }, help: async (ctx) => { - // Show help about the steerable mode const helpText = renderPanel("Available controls shown above"); - ctx.ui.notify("Steerable Autonomous Mode Help\n\n" + helpText, "info"); - }, - - ask_attempts: async (ctx) => { - ctx.ui.notify( - "🤖 I've tried multiple approaches: [list of attempts]", + notifySteeringPanel( + ctx, + "help", + `Steerable Autonomous Mode Help\n\n${helpText}`, "info", ); - // Would provide list of attempted approaches - }, - - ask_blockers: async (ctx) => { - ctx.ui.notify("🤖 Main blockers: [list of current blockers]", "info"); - // Would explain why it's giving up - }, - - reassess: async (ctx) => { - ctx.ui.notify("🔄 Reassessing - trying new approaches", "info"); - // Would trigger immediate reassessment }, close: async (_ctx) => { diff --git a/src/resources/extensions/sf/subagent-inheritance.js b/src/resources/extensions/sf/subagent-inheritance.js index 3f45565ff..775d34653 100644 --- a/src/resources/extensions/sf/subagent-inheritance.js +++ b/src/resources/extensions/sf/subagent-inheritance.js @@ -15,7 +15,7 @@ import { resolveRunControlMode, resolveWorkMode, } from "./operating-model.js"; -import { isProviderAllowedByLists } from "./preferences-models.js"; +import { isHeavyModelId, isProviderAllowedByLists } from "./preferences-models.js"; import { logWarning } from "./workflow-logger.js"; function providerFromModelId(modelId) { @@ -24,18 +24,6 @@ function providerFromModelId(modelId) { return provider && provider !== modelId ? provider : null; } -function isHeavyModelId(modelId) { - if (!modelId || typeof modelId !== "string") return false; - const normalized = modelId.toLowerCase(); - return [ - "opus", - "o1-", - "gpt-4-turbo", - "gpt-5", - "claude-3-opus", - "deepseek-reasoner", - ].some((indicator) => normalized.includes(indicator)); -} /** * Build an inheritance envelope from the current parent session. diff --git a/src/resources/extensions/sf/templates/preferences.yaml b/src/resources/extensions/sf/templates/preferences.yaml new file mode 100644 index 000000000..0b8b35b6d --- /dev/null +++ b/src/resources/extensions/sf/templates/preferences.yaml @@ -0,0 +1,122 @@ +# SF preferences — see ~/.sf/agent/extensions/sf/docs/preferences-reference.md for docs +version: 1 +mode: +always_use_skills: [] +prefer_skills: [] +avoid_skills: [] +skill_rules: + - when: writing or editing docs, plans, records, handoffs, PR text, or other human-readable prose + use: + - human-writing + - when: building repo orientation, architecture maps, generated wiki, subsystem inventory, or durable codebase context + use: + - sf-wiki + - when: optimizing a measurable metric through experiments, benchmarks, performance work, bundle size, test speed, or model quality + use: + - autoresearch +custom_instructions: [] +models: {} +skill_discovery: +skill_staleness_days: +auto_supervisor: {} +git: + auto_push: + push_branches: + remote: + snapshots: + pre_merge_check: + commit_type: + main_branch: + merge_strategy: + isolation: + manage_gitignore: + worktree_post_create: +unique_milestone_ids: +budget_ceiling: +budget_enforcement: +context_pause_threshold: +token_profile: +phases: + skip_research: + skip_reassess: + reassess_after_slice: + skip_slice_research: + # SF ADR-011 P1: progressive planning. When true, the milestone planner + # can mark slices as sketches (is_sketch=1, sketch_scope=<2-3 sentence + # hint>) and refine-slice expands each one just-in-time using prior slice + # summaries as context. When false (default), all slices get full plans up + # front. (Note: SF's local ADR-011 is "Swarm Chat" — unrelated.) + progressive_planning: + # SF ADR-011 P2: mid-execution escalation. When true, complete_task honors + # an optional escalation: { question, options, recommendation, ... } payload. + # The agent's choice carries forward as a hard constraint into the next + # executor. See escalation_auto_accept for whether autonomous mode pauses or + # auto-accepts. + mid_execution_escalation: + # When true (default), an escalation in autonomous mode auto-accepts the agent's + # recommendation and continues — autonomous mode is autonomous. The user can + # review/override later via `/escalate list --all`. Set false to keep + # SF's pause-and-ask behavior (loop halts until user runs + # `/escalate resolve`). + escalation_auto_accept: +# Deep-mode planning gate (top-level, not under phases). When set to "deep", +# autonomous mode runs project-level discussion → requirements → optional research +# BEFORE any milestone work. Default: light (current SF behavior). +planning_depth: +dynamic_routing: + enabled: + tier_models: {} + escalate_on_failure: + budget_pressure: + cross_provider: + hooks: +uok: + enabled: true + gates: + enabled: true + model_policy: + enabled: true + execution_graph: + enabled: true + gitops: + enabled: true + turn_action: commit + turn_push: false + audit_envelope: + enabled: true + planning_flow: + enabled: true +auto_visualize: +auto_report: +parallel: + enabled: + max_workers: + budget_ceiling: + merge_strategy: + auto_merge: +verification_commands: [] +verification_auto_fix: +verification_max_retries: +notifications: + enabled: + on_complete: + on_error: + on_budget: + on_milestone: + on_attention: +cmux: + enabled: + notifications: + sidebar: + splits: + browser: +remote_questions: + channel: + channel_id: + timeout_minutes: + poll_interval_seconds: +uat_dispatch: +post_unit_hooks: [] +pre_dispatch_hooks: [] +# experimental: +# rtk: false diff --git a/src/resources/extensions/sf/tests/preferences-models.test.mjs b/src/resources/extensions/sf/tests/preferences-models.test.mjs index c9f8214a5..66738304d 100644 --- a/src/resources/extensions/sf/tests/preferences-models.test.mjs +++ b/src/resources/extensions/sf/tests/preferences-models.test.mjs @@ -6,7 +6,6 @@ import { afterEach, describe, test } from "vitest"; // Import preferences.js so that _initPrefsLoader is called and the circular dep lazy-loader is wired up. import "../preferences.js"; import { resolveModelWithFallbacksForUnit } from "../preferences-models.js"; -import { getConfiguredEnvApiKey } from "../provider-env-auth.js"; const originalCwd = process.cwd(); const originalEnv = { ...process.env }; @@ -64,32 +63,4 @@ describe("preferences model resolution", () => { assert.equal(result, undefined); }); - test("resolveModelWithFallbacksForUnit_when_google_env_auth_is_enabled_uses_google_auto_benchmark_candidates", () => { - makePreferencesProject( - [ - "---", - "version: 1", - "allowed_providers:", - " - google", - "models: {}", - "---", - "", - ].join("\n"), - { - providerEnvAuth: { - providers: { - google: "on", - }, - }, - }, - ); - process.env.GEMINI_API_KEY = "test-google-key"; - - assert.equal(getConfiguredEnvApiKey("google"), "test-google-key"); - const result = resolveModelWithFallbacksForUnit("plan-milestone"); - - assert.ok(result); - assert.equal(typeof result.primary, "string"); - assert.ok(result.primary.length > 0); - }); }); diff --git a/src/resources/extensions/sf/tests/steerable-autonomous-panel.test.mjs b/src/resources/extensions/sf/tests/steerable-autonomous-panel.test.mjs new file mode 100644 index 000000000..03bdcccd1 --- /dev/null +++ b/src/resources/extensions/sf/tests/steerable-autonomous-panel.test.mjs @@ -0,0 +1,19 @@ +/** + * Contract tests for steerable autonomous panel notification metadata. + */ +import assert from "node:assert"; +import { test } from "vitest"; +import { NOTICE_KIND } from "../notification-store.js"; +import { buildSteeringPanelNotifyMeta } from "../steerable-autonomous-panel.js"; + +test("buildSteeringPanelNotifyMeta_when_focus_action_sets_user_visible_dedupe_and_source", () => { + const meta = buildSteeringPanelNotifyMeta("focus_research"); + assert.strictEqual(meta.noticeKind, NOTICE_KIND.USER_VISIBLE); + assert.strictEqual(meta.dedupe_key, "steer-panel:focus_research"); + assert.strictEqual(meta.source, "steerable-autonomous-panel"); +}); + +test("buildSteeringPanelNotifyMeta_when_help_action_uses_distinct_dedupe_key", () => { + const meta = buildSteeringPanelNotifyMeta("help"); + assert.strictEqual(meta.dedupe_key, "steer-panel:help"); +}); diff --git a/src/resources/extensions/sf/worktree-root.js b/src/resources/extensions/sf/worktree-root.js index ff4628347..abf11a731 100644 --- a/src/resources/extensions/sf/worktree-root.js +++ b/src/resources/extensions/sf/worktree-root.js @@ -107,7 +107,8 @@ function resolveNearestBootstrappedSfRoot(path) { function hasSfBootstrapArtifacts(sfPath) { return ( existsSync(sfPath) && - (existsSync(join(sfPath, "PREFERENCES.md")) || + (existsSync(join(sfPath, "preferences.yaml")) || + existsSync(join(sfPath, "PREFERENCES.md")) || existsSync(join(sfPath, "preferences.md")) || existsSync(join(sfPath, "milestones"))) ); diff --git a/todo.md b/todo.md index 0b087fba1..d136910f2 100644 --- a/todo.md +++ b/todo.md @@ -18,7 +18,7 @@ Unimplemented items consolidated from root *.md files. Source file noted for eac - [ ] Persistent agents v1 command surface — `/sf agent run|reset|delete|inspect` *(BUILD_PLAN.md Tier 2.1)* - [ ] Intent chapters (`chapter_open`/`chapter_close` — crash-resume context) *(BUILD_PLAN.md Tier 2.3)* - [ ] PhaseReview 3-pass review (establish-context → parallel chunked → synthesis) *(BUILD_PLAN.md Tier 2.4)* -- [ ] `last_error` cap to 4 KB head+tail; full payload to file *(BUILD_PLAN.md Tier 2.6)* +- [x] `last_error` cap to 4 KB head+tail; full payload to file *(BUILD_PLAN.md Tier 2.6)* - [ ] Port workflow state machine hardening (gsd-2 `f2377eedd`, `b9a1c6743`, `153fb328a`, `381ccdef5`, `371b2eb31`) *(BUILD_PLAN.md Tier 0.5 #13, UPSTREAM_CHERRY_PICK_CANDIDATES.md Cluster F)* - [ ] Port `fix(claude-code-cli): persist Always Allow for non-Bash tools` (gsd-2 `a88baeae9`) *(BUILD_PLAN.md Tier 0.5 #11)* @@ -31,7 +31,7 @@ Unimplemented items consolidated from root *.md files. Source file noted for eac - [ ] Integration tests for full remote steering pipeline *(PRODUCTION_AUDIT.md Long Term #10)* - [x] Log `frontmatterErrors` in sf-db.js instead of silently dropping validation errors *(PRODUCTION_AUDIT.md 3.1)* - [ ] Search provider registry refactor — consolidate provider list across files into `SearchProviderRegistry` *(BUILD_PLAN.md Tier 1+)* -- [ ] Update ARCHITECTURE.md self-evolution section (triage pipeline IS active; injection IS automatic now) *(ARCHITECTURE.md)* +- [x] Update ARCHITECTURE.md self-evolution section (triage pipeline IS active; injection IS automatic now) *(ARCHITECTURE.md)* - [ ] Add Mermaid state machine diagram to ARCHITECTURE.md *(ARCHITECTURE.md)* - [ ] Symlinked packages/resources/skills/sessions dedup (pi-mono PR #3818) *(BUILD_PLAN.md Tier 0 #6)*