feat(prefs): migrate canonical preferences file from PREFERENCES.md to preferences.yaml

New installations create .sf/preferences.yaml (pure YAML, no frontmatter
markers) and ~/.sf/preferences.yaml. Existing .md files are read as fallbacks
with no migration required for current users.

Changes:
- preferences.js: add yaml path getters, load chain tries .yaml first, add
  parsePreferencesYaml() for direct YAML parse without frontmatter extraction
- templates/preferences.yaml: new canonical template (pure YAML with comment
  header pointing to preferences-reference.md)
- gitignore.js: ensurePreferences() creates preferences.yaml; simplified by
  removing scaffold-versioning dependency
- init-wizard.js: buildPreferencesFile() produces pure YAML, writes preferences.yaml
- commands-prefs-wizard.js: savePreferencesFile() helper handles .yaml vs .md;
  ensurePreferencesFile uses yaml template for yaml paths
- preferences-template-upgrade.js: yaml files get raw YAML on upgrade
- planning-depth.js: returns {path, isYaml}, handles both formats
- deep-project-setup-policy.js: isWorkflowPrefsCaptured() tries all 3 paths
- detection.js: preferences.yaml added to all detection checks
- auto-worktree.js: canonical=yaml, LEGACY_PREFERENCES_FILES=["PREFERENCES.md","preferences.md"]
- auto-bootstrap-context.js: preferences.yaml before PREFERENCES.md in list
- guided-flow.js / worktree-root.js: existence checks include preferences.yaml
- User-visible strings / comments updated throughout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-10 21:05:10 +02:00
parent ce13017519
commit 48dbb175c0
37 changed files with 566 additions and 387 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<MID>/`, a full git worktree on the `milestone/<MID>` 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/<MID>` branch. No worktree directory — files are checked out in-place.

View file

@ -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.<id> = "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.<id> = "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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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