diff --git a/src/resources/extensions/sf/docs/preferences-reference.md b/src/resources/extensions/sf/docs/preferences-reference.md index d02c41c5e..3e796904f 100644 --- a/src/resources/extensions/sf/docs/preferences-reference.md +++ b/src/resources/extensions/sf/docs/preferences-reference.md @@ -165,7 +165,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `context_pause_threshold`: number (0-100) — context window usage percentage at which autonomous mode should pause to suggest checkpointing. Set to `0` to disable. Default: `0` (disabled). -- `token_profile`: `"budget"`, `"balanced"`, `"quality"`, or `"burn-max"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models; `burn-max` keeps full-context defaults, disables downgrade routing, and keeps phase skips off. +- `token_profile`: `"budget"`, `"balanced"`, `"quality"`, or `"burn-max"` — coordinates phase skipping, context posture, and routing posture. Concrete models are selected automatically from the live registry and operator policy; profiles do not hard-code provider/model IDs. `budget` skips research/reassessment; `balanced` (default) skips research/reassessment to reduce token burn; `quality` keeps higher-context behavior; `burn-max` keeps full-context defaults, disables downgrade routing, and keeps phase skips off. - `phases`: fine-grained control over which phases run. Usually set by `token_profile`, but can be overridden. Keys: - `skip_research`: boolean — skip milestone-level research. Default: `false`. diff --git a/src/resources/extensions/sf/preferences-loader.js b/src/resources/extensions/sf/preferences-loader.js new file mode 100644 index 000000000..fc27b0f08 --- /dev/null +++ b/src/resources/extensions/sf/preferences-loader.js @@ -0,0 +1,497 @@ +/** + * Preferences loader -- canonical path resolution, parsing, merging, and + * effective preference construction. + * + * Purpose: provide one acyclic production entrypoint for reading SF + * preferences so model resolution does not depend on late module injection. + * + * Consumer: preferences.js public API and preferences-models.js routing helpers. + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { normalizeStringArray } from "@singularity-forge/coding-agent"; +import { parse as parseYaml } from "yaml"; +import { sfRoot } from "./paths.js"; +import { resolveProfileDefaults } from "./preferences-profile.js"; +import { upgradePreferencesFileIfDrifted } from "./preferences-template-upgrade.js"; +import { MODE_DEFAULTS } from "./preferences-types.js"; +import { validatePreferences } from "./preferences-validation.js"; +import { sfHome } from "./sf-home.js"; +import { logWarning } from "./workflow-logger.js"; + +function globalPreferencesYamlPath() { + return join(sfHome(), "preferences.yaml"); +} + +function legacyGlobalPreferencesMarkdownPath() { + return join(sfHome(), "preferences.md"); +} + +function projectPrefsRoot() { + const cwd = process.cwd(); + try { + const gitPath = join(cwd, ".git"); + if (!existsSync(gitPath)) return cwd; + const stat = readFileSync(gitPath, "utf-8"); + const m = /^gitdir:\s*(.+)$/m.exec(stat.trim()); + if (!m) return cwd; + const gitdir = m[1].trim(); + const commondirFile = join(gitdir, "commondir"); + if (existsSync(commondirFile)) { + const rel = readFileSync(commondirFile, "utf-8").trim(); + const commondir = resolve(gitdir, rel); + const mainRoot = dirname(commondir); + if (existsSync(join(mainRoot, ".sf"))) return mainRoot; + } + } catch { + /* non-fatal: fall back to cwd */ + } + return cwd; +} + +function projectPreferencesYamlPath() { + return join(sfRoot(projectPrefsRoot()), "preferences.yaml"); +} + +/** + * Resolve the canonical global preferences file path. + * + * Purpose: keep global preference writes and reads pointed at the same YAML + * source of truth. + * + * Consumer: preferences wizard, model preference persistence, and settings UI. + */ +export function getGlobalSFPreferencesPath() { + return globalPreferencesYamlPath(); +} + +/** + * Resolve the canonical project preferences file path. + * + * Purpose: keep project preference writes anchored to the main worktree even + * when SF runs inside an isolated linked worktree. + * + * Consumer: preferences wizard, project bootstrap, and effective preference loading. + */ +export function getProjectSFPreferencesPath() { + return projectPreferencesYamlPath(); +} + +/** + * Load global SF preferences from YAML, falling back to legacy markdown input. + * + * Purpose: preserve operator settings across the YAML migration while keeping + * preferences.yaml as the canonical write target. + * + * Consumer: loadEffectiveSFPreferences and preference status commands. + */ +export function loadGlobalSFPreferences() { + return ( + loadPreferencesFile(globalPreferencesYamlPath(), "global") ?? + loadPreferencesFile(legacyGlobalPreferencesMarkdownPath(), "global", { + legacyMarkdown: true, + }) + ); +} + +/** + * Load project-level SF preferences from the canonical project YAML path. + * + * Purpose: let repos override global defaults without reading transient + * worktree-local copies. + * + * Consumer: loadEffectiveSFPreferences and preference status commands. + */ +export function loadProjectSFPreferences() { + return loadPreferencesFile(projectPreferencesYamlPath(), "project"); +} + +/** + * Load and merge global and project preferences with profile and mode defaults. + * + * Purpose: expose the single effective preference contract consumed by SF + * runtime decisions. + * + * Consumer: model routing, hooks, CLI/web settings, notifications, and runtime guards. + */ +export function loadEffectiveSFPreferences() { + const globalPreferences = loadGlobalSFPreferences(); + const projectPreferences = loadProjectSFPreferences(); + if (!globalPreferences && !projectPreferences) return null; + let result; + if (!globalPreferences) { + result = projectPreferences; + } else if (!projectPreferences) { + result = globalPreferences; + } else { + const mergedWarnings = [ + ...(globalPreferences.warnings ?? []), + ...(projectPreferences.warnings ?? []), + ]; + result = { + path: projectPreferences.path, + scope: "project", + preferences: mergePreferences( + globalPreferences.preferences, + projectPreferences.preferences, + ), + ...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}), + }; + } + const profile = result.preferences.token_profile; + if (profile) { + result = { + ...result, + preferences: mergePreferences( + resolveProfileDefaults(profile), + result.preferences, + ), + }; + } + if (result.preferences.mode) { + result = { + ...result, + preferences: applyModeDefaults( + result.preferences.mode, + result.preferences, + ), + }; + } + return result; +} + +function loadPreferencesFile(path, scope, options = {}) { + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf-8"); + const preferences = options.legacyMarkdown + ? parsePreferencesMarkdown(raw) + : parsePreferencesYaml(raw); + if (!preferences) return null; + const validation = validatePreferences(preferences); + const aligned = options.legacyMarkdown + ? validation.preferences + : upgradePreferencesFileIfDrifted(path, validation.preferences); + const allWarnings = [...validation.warnings, ...validation.errors]; + return { + path, + scope, + preferences: aligned, + ...(allWarnings.length > 0 ? { warnings: allWarnings } : {}), + }; +} + +/** @internal Reset parser warning state for focused tests. */ +export function _resetParseWarningFlag() { + // No warn-once flags remain after removal of the legacy markdown parser. +} + +/** + * Parse preferences from pure YAML content. + * + * Purpose: keep preference parsing testable without touching disk. + * + * Consumer: preferences loader and focused parser tests. + */ +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 legacy frontmatter-style preference content. + * + * Purpose: keep older preference files importable while the canonical format + * remains pure YAML. + * + * Consumer: loadGlobalSFPreferences legacy fallback and older callers that + * still pass YAML through the markdown parser name. + */ +export function parsePreferencesMarkdown(content) { + const fenced = /^---\r?\n([\s\S]*?)\n---(?:\r?\n[\s\S]*)?$/.exec(content); + return parsePreferencesYaml(fenced ? fenced[1] : content); +} + +/** + * Apply mode defaults as the lowest-priority preference layer. + * + * Purpose: preserve explicit operator values while filling runtime defaults + * implied by solo/team mode. + * + * Consumer: loadEffectiveSFPreferences and tests for preference merging. + */ +export function applyModeDefaults(mode, prefs) { + const defaults = MODE_DEFAULTS[mode]; + if (!defaults) return prefs; + return mergePreferences(defaults, prefs); +} + +function mergePreferences(base, override) { + return { + version: override.version ?? base.version, + mode: override.mode ?? base.mode, + always_use_skills: mergeStringLists( + base.always_use_skills, + override.always_use_skills, + ), + prefer_skills: mergeStringLists(base.prefer_skills, override.prefer_skills), + avoid_skills: mergeStringLists(base.avoid_skills, override.avoid_skills), + skill_rules: [...(base.skill_rules ?? []), ...(override.skill_rules ?? [])], + custom_instructions: mergeStringLists( + base.custom_instructions, + override.custom_instructions, + ), + models: { ...(base.models ?? {}), ...(override.models ?? {}) }, + persist_model_changes: + override.persist_model_changes ?? base.persist_model_changes, + skill_discovery: override.skill_discovery ?? base.skill_discovery, + skill_staleness_days: + override.skill_staleness_days ?? base.skill_staleness_days, + auto_supervisor: { + ...(base.auto_supervisor ?? {}), + ...(override.auto_supervisor ?? {}), + }, + uat_dispatch: override.uat_dispatch ?? base.uat_dispatch, + unique_milestone_ids: + override.unique_milestone_ids ?? base.unique_milestone_ids, + budget_ceiling: override.budget_ceiling ?? base.budget_ceiling, + budget_enforcement: override.budget_enforcement ?? base.budget_enforcement, + context_pause_threshold: + override.context_pause_threshold ?? base.context_pause_threshold, + notifications: + base.notifications || override.notifications + ? { ...(base.notifications ?? {}), ...(override.notifications ?? {}) } + : undefined, + cmux: + base.cmux || override.cmux + ? { ...(base.cmux ?? {}), ...(override.cmux ?? {}) } + : undefined, + remote_questions: override.remote_questions + ? { ...(base.remote_questions ?? {}), ...override.remote_questions } + : base.remote_questions, + git: + base.git || override.git + ? { ...(base.git ?? {}), ...(override.git ?? {}) } + : undefined, + post_unit_hooks: mergeHooks(base.post_unit_hooks, override.post_unit_hooks), + pre_dispatch_hooks: mergeHooks( + base.pre_dispatch_hooks, + override.pre_dispatch_hooks, + ), + dynamic_routing: + base.dynamic_routing || override.dynamic_routing + ? { + ...(base.dynamic_routing ?? {}), + ...(override.dynamic_routing ?? {}), + } + : undefined, + uok: + base.uok || override.uok + ? { + enabled: override.uok?.enabled ?? base.uok?.enabled, + gates: + base.uok?.gates || override.uok?.gates + ? { ...(base.uok?.gates ?? {}), ...(override.uok?.gates ?? {}) } + : undefined, + model_policy: + base.uok?.model_policy || override.uok?.model_policy + ? { + ...(base.uok?.model_policy ?? {}), + ...(override.uok?.model_policy ?? {}), + } + : undefined, + execution_graph: + base.uok?.execution_graph || override.uok?.execution_graph + ? { + ...(base.uok?.execution_graph ?? {}), + ...(override.uok?.execution_graph ?? {}), + } + : undefined, + gitops: + base.uok?.gitops || override.uok?.gitops + ? { + ...(base.uok?.gitops ?? {}), + ...(override.uok?.gitops ?? {}), + } + : undefined, + audit_envelope: + base.uok?.audit_envelope || + base.uok?.audit_unified || + override.uok?.audit_envelope || + override.uok?.audit_unified + ? { + ...(base.uok?.audit_envelope ?? + base.uok?.audit_unified ?? + {}), + ...(override.uok?.audit_envelope ?? + override.uok?.audit_unified ?? + {}), + } + : undefined, + planning_flow: + base.uok?.planning_flow || + base.uok?.plan_v2 || + override.uok?.planning_flow || + override.uok?.plan_v2 + ? { + ...(base.uok?.planning_flow ?? base.uok?.plan_v2 ?? {}), + ...(override.uok?.planning_flow ?? + override.uok?.plan_v2 ?? + {}), + } + : undefined, + } + : undefined, + token_profile: override.token_profile ?? base.token_profile, + phases: + base.phases || override.phases + ? { ...(base.phases ?? {}), ...(override.phases ?? {}) } + : undefined, + parallel: + base.parallel || override.parallel + ? { + ...(base.parallel ?? {}), + ...(override.parallel ?? {}), + } + : undefined, + verification_commands: mergeStringLists( + base.verification_commands, + override.verification_commands, + ), + verification_auto_fix: + override.verification_auto_fix ?? base.verification_auto_fix, + verification_max_retries: + override.verification_max_retries ?? base.verification_max_retries, + verification_auto_defer_threshold: + override.verification_auto_defer_threshold ?? + base.verification_auto_defer_threshold, + enhanced_verification: + override.enhanced_verification ?? base.enhanced_verification, + enhanced_verification_pre: + override.enhanced_verification_pre ?? base.enhanced_verification_pre, + enhanced_verification_post: + override.enhanced_verification_post ?? base.enhanced_verification_post, + enhanced_verification_strict: + override.enhanced_verification_strict ?? + base.enhanced_verification_strict, + search_provider: override.search_provider ?? base.search_provider, + context_selection: override.context_selection ?? base.context_selection, + auto_visualize: override.auto_visualize ?? base.auto_visualize, + auto_report: override.auto_report ?? base.auto_report, + github: + base.github || override.github + ? { + ...(base.github ?? {}), + ...(override.github ?? {}), + } + : undefined, + experimental: + base.experimental || override.experimental + ? { ...(base.experimental ?? {}), ...(override.experimental ?? {}) } + : undefined, + service_tier: override.service_tier ?? base.service_tier, + forensics_dedup: override.forensics_dedup ?? base.forensics_dedup, + show_token_cost: override.show_token_cost ?? base.show_token_cost, + codebase: + base.codebase || override.codebase + ? { + ...(base.codebase ?? {}), + ...(override.codebase ?? {}), + exclude_patterns: [ + ...(base.codebase?.exclude_patterns ?? []), + ...(override.codebase?.exclude_patterns ?? []), + ].filter(Boolean), + } + : undefined, + slice_parallel: + base.slice_parallel || override.slice_parallel + ? { ...(base.slice_parallel ?? {}), ...(override.slice_parallel ?? {}) } + : undefined, + allowed_providers: mergeStringLists( + base.allowed_providers, + override.allowed_providers, + ), + advisor_allowed_providers: mergeStringLists( + base.advisor_allowed_providers, + override.advisor_allowed_providers, + ), + blocked_providers: mergeStringLists( + base.blocked_providers, + override.blocked_providers, + ), + provider_preference: + override.provider_preference ?? base.provider_preference, + provider_model_allow: mergeProviderModelAllow( + base.provider_model_allow, + override.provider_model_allow, + ), + provider_model_block: mergeProviderModelAllow( + base.provider_model_block, + override.provider_model_block, + ), + flat_rate_providers: mergeStringLists( + base.flat_rate_providers, + override.flat_rate_providers, + ), + stale_commit_threshold_minutes: + override.stale_commit_threshold_minutes ?? + base.stale_commit_threshold_minutes, + widget_mode: override.widget_mode ?? base.widget_mode, + modelOverrides: + base.modelOverrides || override.modelOverrides + ? { ...(base.modelOverrides ?? {}), ...(override.modelOverrides ?? {}) } + : undefined, + safety_harness: + base.safety_harness || override.safety_harness + ? { ...(base.safety_harness ?? {}), ...(override.safety_harness ?? {}) } + : undefined, + subscription: override.subscription ?? base.subscription, + allow_flat_rate_providers: + override.allow_flat_rate_providers ?? base.allow_flat_rate_providers, + deploy: + base.deploy || override.deploy + ? { ...(base.deploy ?? {}), ...(override.deploy ?? {}) } + : undefined, + }; +} + +function mergeStringLists(base, override) { + const merged = [ + ...normalizeStringArray(base), + ...normalizeStringArray(override), + ] + .map((item) => item.trim()) + .filter(Boolean); + return merged.length > 0 ? Array.from(new Set(merged)) : undefined; +} + +function mergeProviderModelAllow(base, override) { + if (!base && !override) return undefined; + const merged = {}; + for (const [provider, models] of Object.entries(base ?? {})) { + merged[provider] = [...models]; + } + for (const [provider, models] of Object.entries(override ?? {})) { + merged[provider] = [...models]; + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function mergeHooks(base, override) { + if (!base?.length && !override?.length) return undefined; + const merged = [...(base ?? [])]; + for (const hook of override ?? []) { + const idx = merged.findIndex((h) => h.name === hook.name); + if (idx >= 0) { + merged[idx] = hook; + } else { + merged.push(hook); + } + } + return merged.length > 0 ? merged : undefined; +} diff --git a/src/resources/extensions/sf/preferences-models.js b/src/resources/extensions/sf/preferences-models.js index 07e344458..20efd6b54 100644 --- a/src/resources/extensions/sf/preferences-models.js +++ b/src/resources/extensions/sf/preferences-models.js @@ -11,6 +11,14 @@ import { join } from "node:path"; import { getModels, getProviders } from "@singularity-forge/ai"; import { selectByBenchmarks } from "./benchmark-selector.js"; import { defaultRoutingConfig, MODEL_CAPABILITY_TIER } from "./model-router.js"; +import { + getGlobalSFPreferencesPath, + loadEffectiveSFPreferences, +} from "./preferences-loader.js"; +import { + isValidTokenProfile, + resolveProfileDefaults, +} from "./preferences-profile.js"; import { sfHome } from "./sf-home.js"; import { DEFAULT_RUNAWAY_CHANGED_FILES_WARNING, @@ -20,25 +28,6 @@ import { DEFAULT_RUNAWAY_TOOL_CALL_WARNING, } from "./uok/auto-runaway-guard.js"; -// ─── Lazy loader — breaks the preferences.js ↔ preferences-models.js cycle ── -// preferences.js imports resolveProfileDefaults from here, and needs -// loadEffectiveSFPreferences / getGlobalSFPreferencesPath from preferences.js. -// Injecting them via _initPrefsLoader (called at the end of preferences.js -// module evaluation) avoids the static circular import. -let _loadEffectiveSFPreferences; -let _getGlobalSFPreferencesPath; -/** @internal — called once by preferences.js after its exports are defined. */ -export function _initPrefsLoader(loadFn, getPathFn) { - _loadEffectiveSFPreferences = loadFn; - _getGlobalSFPreferencesPath = getPathFn; -} -function loadEffectiveSFPreferences() { - return _loadEffectiveSFPreferences(); -} -function getGlobalSFPreferencesPath() { - return _getGlobalSFPreferencesPath(); -} - import { getKeyManagerAuthStorage } from "./key-manager.js"; import { lookupDiscoveredModelCost } from "./model-catalog-cache.js"; @@ -767,74 +756,9 @@ export function resolveAutoSupervisorConfig() { ...(configured.model ? { model: configured.model } : {}), }; } +export { resolveProfileDefaults } from "./preferences-profile.js"; + // ─── Token Profile Resolution ───────────────────────────────────────────── -const VALID_TOKEN_PROFILES = new Set([ - "budget", - "balanced", - "quality", - "burn-max", -]); -/** - * Resolve profile defaults for a given token profile tier. - * Returns a partial SFPreferences that is used as the base layer -- - * explicit user preferences always override these defaults. - */ -export function resolveProfileDefaults(profile) { - switch (profile) { - case "budget": - return { - models: { - planning: "claude-sonnet-4-5-20250514", - execution: "claude-sonnet-4-5-20250514", - execution_simple: "claude-haiku-4-5-20250414", - completion: "claude-haiku-4-5-20250414", - subagent: "claude-haiku-4-5-20250414", - }, - phases: { - skip_research: true, - skip_reassess: true, - skip_slice_research: true, - skip_milestone_validation: true, - }, - }; - case "balanced": - return { - models: { - subagent: "claude-sonnet-4-5-20250514", - }, - phases: { - skip_research: true, - skip_reassess: true, - skip_slice_research: true, - }, - }; - case "quality": - return { - models: {}, - phases: { - skip_research: true, - skip_slice_research: true, - skip_reassess: true, - }, - }; - case "burn-max": - return { - // Quality-first profile: keep user-selected models, disable downgrade routing. - // Policy constraints still apply at dispatch time. - dynamic_routing: { - enabled: false, - }, - context_selection: "full", - phases: { - skip_research: false, - skip_slice_research: false, - skip_reassess: false, - skip_milestone_validation: false, - reassess_after_slice: true, - }, - }; - } -} /** * Resolve the effective token profile from preferences. * Returns "balanced" when no profile is set (D046). @@ -842,7 +766,7 @@ export function resolveProfileDefaults(profile) { export function resolveEffectiveProfile() { const prefs = loadEffectiveSFPreferences(); const profile = prefs?.preferences.token_profile; - if (profile && VALID_TOKEN_PROFILES.has(profile)) return profile; + if (profile && isValidTokenProfile(profile)) return profile; return "balanced"; } /** diff --git a/src/resources/extensions/sf/preferences-profile.js b/src/resources/extensions/sf/preferences-profile.js new file mode 100644 index 000000000..ec7db73db --- /dev/null +++ b/src/resources/extensions/sf/preferences-profile.js @@ -0,0 +1,86 @@ +/** + * Preferences token profiles define operator-facing defaults without loading + * preference files. + * + * Purpose: keep profile defaults pure so the preferences loader and model + * resolver can share them without circular imports. + * + * Consumer: preferences-loader.js when applying effective defaults, and + * preferences-models.js when deriving routing behavior from token profile. + */ +const VALID_TOKEN_PROFILES = new Set([ + "budget", + "balanced", + "quality", + "burn-max", +]); + +/** + * Return true when a value is a supported token profile name. + * + * Purpose: centralize profile validation for model-routing helpers without + * forcing those helpers through the preferences loader. + * + * Consumer: preferences-models.js resolveEffectiveProfile. + */ +export function isValidTokenProfile(profile) { + return VALID_TOKEN_PROFILES.has(profile); +} + +/** + * Resolve profile defaults for a given token profile tier. + * + * Purpose: provide the lowest-priority preference layer implied by a profile, + * while preserving explicit user/project preferences as overrides. + * + * Consumer: preferences-loader.js effective preference merging and public + * preferences.js re-exports. + */ +export function resolveProfileDefaults(profile) { + switch (profile) { + case "budget": + return { + phases: { + skip_research: true, + skip_reassess: true, + skip_slice_research: true, + skip_milestone_validation: true, + }, + }; + case "balanced": + return { + phases: { + skip_research: true, + skip_reassess: true, + skip_slice_research: true, + }, + }; + case "quality": + return { + models: {}, + phases: { + skip_research: true, + skip_slice_research: true, + skip_reassess: true, + }, + }; + case "burn-max": + return { + // Quality-first profile: keep user-selected models, disable downgrade routing. + // Policy constraints still apply at dispatch time. + dynamic_routing: { + enabled: false, + }, + context_selection: "full", + phases: { + skip_research: false, + skip_slice_research: false, + skip_reassess: false, + skip_milestone_validation: false, + reassess_after_slice: true, + }, + }; + default: + return {}; + } +} diff --git a/src/resources/extensions/sf/preferences.js b/src/resources/extensions/sf/preferences.js index 92fb5f1b2..4b4fd7bd7 100644 --- a/src/resources/extensions/sf/preferences.js +++ b/src/resources/extensions/sf/preferences.js @@ -9,20 +9,21 @@ * All symbols are re-exported here so that existing `import { ... } from "./preferences.js"` * statements continue to work without modification. */ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { normalizeStringArray } from "@singularity-forge/coding-agent"; -import { parse as parseYaml } from "yaml"; -import { sfRoot } from "./paths.js"; +import { join } from "node:path"; import { - _initPrefsLoader, - resolveProfileDefaults as _resolveProfileDefaults, -} from "./preferences-models.js"; -import { upgradePreferencesFileIfDrifted } from "./preferences-template-upgrade.js"; -import { formatSkillRef, MODE_DEFAULTS } from "./preferences-types.js"; + _resetParseWarningFlag, + applyModeDefaults, + getGlobalSFPreferencesPath, + getProjectSFPreferencesPath, + loadEffectiveSFPreferences, + loadGlobalSFPreferences, + loadProjectSFPreferences, + parsePreferencesMarkdown, + parsePreferencesYaml, +} from "./preferences-loader.js"; +import { formatSkillRef } from "./preferences-types.js"; import { validatePreferences } from "./preferences-validation.js"; import { sfHome } from "./sf-home.js"; -import { logWarning } from "./workflow-logger.js"; // ─── Re-exports: types ────────────────────────────────────────────────────── // Every type/interface that was previously exported from this file is @@ -32,6 +33,17 @@ import { logWarning } from "./workflow-logger.js"; export { resolveAllSkillReferences } from "./preferences-skills.js"; // ─── Re-exports: validation ───────────────────────────────────────────────── export { validatePreferences } from "./preferences-validation.js"; +export { + _resetParseWarningFlag, + applyModeDefaults, + getGlobalSFPreferencesPath, + getProjectSFPreferencesPath, + loadEffectiveSFPreferences, + loadGlobalSFPreferences, + loadProjectSFPreferences, + parsePreferencesMarkdown, + parsePreferencesYaml, +} from "./preferences-loader.js"; // These lived in preferences-skills.ts but imported loadEffectiveSFPreferences // back from this file, creating a circular dependency. Moved here since they // are trivial wrappers over loadEffectiveSFPreferences. @@ -83,489 +95,6 @@ export { export function getSfAgentSettingsPath() { return join(sfHome(), "agent", "settings.json"); } -// Canonical location — pure YAML, no frontmatter markers -function globalPreferencesYamlPath() { - return join(sfHome(), "preferences.yaml"); -} -function legacyGlobalPreferencesMarkdownPath() { - return join(sfHome(), "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.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. - * - * Strategy: read `.git` in the current worktree. If it's a file of the - * form `gitdir: `, we're in a linked worktree — resolve the - * commondir and walk up one level to reach the main worktree. Fall back - * to cwd silently for non-worktree setups (bare clones, submodules, etc. - * where the current logic is already correct). - */ -function projectPrefsRoot() { - const cwd = process.cwd(); - try { - const gitPath = join(cwd, ".git"); - if (!existsSync(gitPath)) return cwd; - const stat = readFileSync(gitPath, "utf-8"); - // Linked worktrees have `.git` as a FILE: "gitdir: /abs/path/to/main/.git/worktrees/NAME" - const m = /^gitdir:\s*(.+)$/m.exec(stat.trim()); - if (!m) return cwd; - const gitdir = m[1].trim(); - // gitdir looks like /.../.git/worktrees/NAME — commondir is the /.../.git/ part; - // the main worktree is the directory containing that .git dir. - // Use the `commondir` pointer file inside the linked worktree's gitdir when present. - const commondirFile = join(gitdir, "commondir"); - if (existsSync(commondirFile)) { - const rel = readFileSync(commondirFile, "utf-8").trim(); - const commondir = resolve(gitdir, rel); // usually "../.." → /.../.git - const mainRoot = dirname(commondir); // /.../ (main worktree root) - if (existsSync(join(mainRoot, ".sf"))) return mainRoot; - } - } catch { - /* non-fatal — fall back to cwd */ - } - return cwd; -} -// Canonical location — pure YAML -function projectPreferencesYamlPath() { - return join(sfRoot(projectPrefsRoot()), "preferences.yaml"); -} -/** - * Get the canonical path for the global SF preferences file (preferences.yaml). - */ -export function getGlobalSFPreferencesPath() { - return globalPreferencesYamlPath(); -} -/** - * Get the canonical path for the project-level SF preferences file (preferences.yaml). - */ -export function getProjectSFPreferencesPath() { - return projectPreferencesYamlPath(); -} -// ─── Loading ──────────────────────────────────────────────────────────────── -/** - * Load global SF preferences from preferences.yaml. - */ -export function loadGlobalSFPreferences() { - return ( - loadPreferencesFile(globalPreferencesYamlPath(), "global") ?? - loadPreferencesFile(legacyGlobalPreferencesMarkdownPath(), "global", { - legacyMarkdown: true, - }) - ); -} -/** - * Load project-level SF preferences from preferences.yaml. - */ -export function loadProjectSFPreferences() { - return loadPreferencesFile(projectPreferencesYamlPath(), "project"); -} -/** - * Load and merge global and project preferences with profile defaults and mode defaults applied. - */ -export function loadEffectiveSFPreferences() { - const globalPreferences = loadGlobalSFPreferences(); - const projectPreferences = loadProjectSFPreferences(); - if (!globalPreferences && !projectPreferences) return null; - let result; - if (!globalPreferences) { - result = projectPreferences; - } else if (!projectPreferences) { - result = globalPreferences; - } else { - const mergedWarnings = [ - ...(globalPreferences.warnings ?? []), - ...(projectPreferences.warnings ?? []), - ]; - result = { - path: projectPreferences.path, - scope: "project", - preferences: mergePreferences( - globalPreferences.preferences, - projectPreferences.preferences, - ), - ...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}), - }; - } - // Apply token-profile defaults as the lowest-priority layer so that - // `token_profile: budget` sets models and phase-skips automatically. - // Explicit user preferences always override profile defaults. - const profile = result.preferences.token_profile; - if (profile) { - const profileDefaults = _resolveProfileDefaults(profile); - result = { - ...result, - preferences: mergePreferences(profileDefaults, result.preferences), - }; - } - // Apply mode defaults as the lowest-priority layer - if (result.preferences.mode) { - result = { - ...result, - preferences: applyModeDefaults( - result.preferences.mode, - result.preferences, - ), - }; - } - return result; -} -function loadPreferencesFile(path, scope, options = {}) { - if (!existsSync(path)) return null; - const raw = readFileSync(path, "utf-8"); - const preferences = options.legacyMarkdown - ? parsePreferencesMarkdown(raw) - : parsePreferencesYaml(raw); - if (!preferences) return null; - const validation = validatePreferences(preferences); - // Self-align: if the file's recorded sf version drifted from current, - // silently re-render the frontmatter so subsequent reads match. No - // human-facing warning — sf keeps its own files in sync. Body content - // (anything after the frontmatter) is preserved verbatim. - const aligned = options.legacyMarkdown - ? validation.preferences - : upgradePreferencesFileIfDrifted(path, validation.preferences); - const allWarnings = [...validation.warnings, ...validation.errors]; - return { - path, - scope, - preferences: aligned, - ...(allWarnings.length > 0 ? { warnings: allWarnings } : {}), - }; -} -/** @internal Reset the warn-once flags — exported for testing only. */ -export function _resetParseWarningFlag() { - // No warn-once flags remain after removal of legacy markdown parser. -} -/** - * 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 {}; - } -} -/** - * @deprecated Use parsePreferencesYaml instead. - * Retained for callers (e.g. auto-dashboard.js) that read .yaml content via this path. - * Strips YAML frontmatter markers if present, then delegates to parsePreferencesYaml. - */ -export function parsePreferencesMarkdown(content) { - // Strip optional frontmatter fences so legacy callers work with pure YAML too - const fenced = /^---\r?\n([\s\S]*?)\n---(?:\r?\n[\s\S]*)?$/.exec(content); - return parsePreferencesYaml(fenced ? fenced[1] : content); -} -// ─── Merging ──────────────────────────────────────────────────────────────── -/** - * Apply mode defaults as the lowest-priority layer. - * Mode defaults fill in undefined fields; any explicit user value wins. - */ -/** - * Apply mode defaults as the lowest-priority layer to preferences. - */ -export function applyModeDefaults(mode, prefs) { - const defaults = MODE_DEFAULTS[mode]; - if (!defaults) return prefs; - return mergePreferences(defaults, prefs); -} -function mergePreferences(base, override) { - return { - version: override.version ?? base.version, - mode: override.mode ?? base.mode, - always_use_skills: mergeStringLists( - base.always_use_skills, - override.always_use_skills, - ), - prefer_skills: mergeStringLists(base.prefer_skills, override.prefer_skills), - avoid_skills: mergeStringLists(base.avoid_skills, override.avoid_skills), - skill_rules: [...(base.skill_rules ?? []), ...(override.skill_rules ?? [])], - custom_instructions: mergeStringLists( - base.custom_instructions, - override.custom_instructions, - ), - models: { ...(base.models ?? {}), ...(override.models ?? {}) }, - persist_model_changes: - override.persist_model_changes ?? base.persist_model_changes, - skill_discovery: override.skill_discovery ?? base.skill_discovery, - skill_staleness_days: - override.skill_staleness_days ?? base.skill_staleness_days, - auto_supervisor: { - ...(base.auto_supervisor ?? {}), - ...(override.auto_supervisor ?? {}), - }, - uat_dispatch: override.uat_dispatch ?? base.uat_dispatch, - unique_milestone_ids: - override.unique_milestone_ids ?? base.unique_milestone_ids, - budget_ceiling: override.budget_ceiling ?? base.budget_ceiling, - budget_enforcement: override.budget_enforcement ?? base.budget_enforcement, - context_pause_threshold: - override.context_pause_threshold ?? base.context_pause_threshold, - notifications: - base.notifications || override.notifications - ? { ...(base.notifications ?? {}), ...(override.notifications ?? {}) } - : undefined, - cmux: - base.cmux || override.cmux - ? { ...(base.cmux ?? {}), ...(override.cmux ?? {}) } - : undefined, - remote_questions: override.remote_questions - ? { ...(base.remote_questions ?? {}), ...override.remote_questions } - : base.remote_questions, - git: - base.git || override.git - ? { ...(base.git ?? {}), ...(override.git ?? {}) } - : undefined, - post_unit_hooks: mergePostUnitHooks( - base.post_unit_hooks, - override.post_unit_hooks, - ), - pre_dispatch_hooks: mergePreDispatchHooks( - base.pre_dispatch_hooks, - override.pre_dispatch_hooks, - ), - dynamic_routing: - base.dynamic_routing || override.dynamic_routing - ? { - ...(base.dynamic_routing ?? {}), - ...(override.dynamic_routing ?? {}), - } - : undefined, - uok: - base.uok || override.uok - ? { - enabled: override.uok?.enabled ?? base.uok?.enabled, - gates: - base.uok?.gates || override.uok?.gates - ? { ...(base.uok?.gates ?? {}), ...(override.uok?.gates ?? {}) } - : undefined, - model_policy: - base.uok?.model_policy || override.uok?.model_policy - ? { - ...(base.uok?.model_policy ?? {}), - ...(override.uok?.model_policy ?? {}), - } - : undefined, - execution_graph: - base.uok?.execution_graph || override.uok?.execution_graph - ? { - ...(base.uok?.execution_graph ?? {}), - ...(override.uok?.execution_graph ?? {}), - } - : undefined, - gitops: - base.uok?.gitops || override.uok?.gitops - ? { - ...(base.uok?.gitops ?? {}), - ...(override.uok?.gitops ?? {}), - } - : undefined, - audit_envelope: - base.uok?.audit_envelope || - base.uok?.audit_unified || - override.uok?.audit_envelope || - override.uok?.audit_unified - ? { - ...(base.uok?.audit_envelope ?? - base.uok?.audit_unified ?? - {}), - ...(override.uok?.audit_envelope ?? - override.uok?.audit_unified ?? - {}), - } - : undefined, - planning_flow: - base.uok?.planning_flow || - base.uok?.plan_v2 || - override.uok?.planning_flow || - override.uok?.plan_v2 - ? { - ...(base.uok?.planning_flow ?? base.uok?.plan_v2 ?? {}), - ...(override.uok?.planning_flow ?? - override.uok?.plan_v2 ?? - {}), - } - : undefined, - } - : undefined, - token_profile: override.token_profile ?? base.token_profile, - phases: - base.phases || override.phases - ? { ...(base.phases ?? {}), ...(override.phases ?? {}) } - : undefined, - parallel: - base.parallel || override.parallel - ? { - ...(base.parallel ?? {}), - ...(override.parallel ?? {}), - } - : undefined, - verification_commands: mergeStringLists( - base.verification_commands, - override.verification_commands, - ), - verification_auto_fix: - override.verification_auto_fix ?? base.verification_auto_fix, - verification_max_retries: - override.verification_max_retries ?? base.verification_max_retries, - verification_auto_defer_threshold: - override.verification_auto_defer_threshold ?? - base.verification_auto_defer_threshold, - enhanced_verification: - override.enhanced_verification ?? base.enhanced_verification, - enhanced_verification_pre: - override.enhanced_verification_pre ?? base.enhanced_verification_pre, - enhanced_verification_post: - override.enhanced_verification_post ?? base.enhanced_verification_post, - enhanced_verification_strict: - override.enhanced_verification_strict ?? - base.enhanced_verification_strict, - search_provider: override.search_provider ?? base.search_provider, - context_selection: override.context_selection ?? base.context_selection, - auto_visualize: override.auto_visualize ?? base.auto_visualize, - auto_report: override.auto_report ?? base.auto_report, - github: - base.github || override.github - ? { - ...(base.github ?? {}), - ...(override.github ?? {}), - } - : undefined, - experimental: - base.experimental || override.experimental - ? { ...(base.experimental ?? {}), ...(override.experimental ?? {}) } - : undefined, - service_tier: override.service_tier ?? base.service_tier, - forensics_dedup: override.forensics_dedup ?? base.forensics_dedup, - show_token_cost: override.show_token_cost ?? base.show_token_cost, - codebase: - base.codebase || override.codebase - ? { - ...(base.codebase ?? {}), - ...(override.codebase ?? {}), - // Merge exclude_patterns arrays rather than overriding - exclude_patterns: [ - ...(base.codebase?.exclude_patterns ?? []), - ...(override.codebase?.exclude_patterns ?? []), - ].filter(Boolean), - } - : undefined, - slice_parallel: - base.slice_parallel || override.slice_parallel - ? { ...(base.slice_parallel ?? {}), ...(override.slice_parallel ?? {}) } - : undefined, - // Fields previously validated but silently dropped here — same class - // of latent bug as service_tier (fixed separately). Each gets a simple - // override-wins merge so the preference actually reaches consumers. - allowed_providers: mergeStringLists( - base.allowed_providers, - override.allowed_providers, - ), - advisor_allowed_providers: mergeStringLists( - base.advisor_allowed_providers, - override.advisor_allowed_providers, - ), - blocked_providers: mergeStringLists( - base.blocked_providers, - override.blocked_providers, - ), - provider_preference: - override.provider_preference ?? base.provider_preference, - provider_model_allow: mergeProviderModelAllow( - base.provider_model_allow, - override.provider_model_allow, - ), - provider_model_block: mergeProviderModelAllow( - base.provider_model_block, - override.provider_model_block, - ), - flat_rate_providers: mergeStringLists( - base.flat_rate_providers, - override.flat_rate_providers, - ), - stale_commit_threshold_minutes: - override.stale_commit_threshold_minutes ?? - base.stale_commit_threshold_minutes, - widget_mode: override.widget_mode ?? base.widget_mode, - modelOverrides: - base.modelOverrides || override.modelOverrides - ? { ...(base.modelOverrides ?? {}), ...(override.modelOverrides ?? {}) } - : undefined, - safety_harness: - base.safety_harness || override.safety_harness - ? { ...(base.safety_harness ?? {}), ...(override.safety_harness ?? {}) } - : undefined, - // subscription: project-level wins over global (full replace, not merge), - // so that a project can declare its own subscription context independently. - subscription: override.subscription ?? base.subscription, - allow_flat_rate_providers: - override.allow_flat_rate_providers ?? base.allow_flat_rate_providers, - // ── Production delivery ── - deploy: - base.deploy || override.deploy - ? { ...(base.deploy ?? {}), ...(override.deploy ?? {}) } - : undefined, - }; -} -function mergeStringLists(base, override) { - const merged = [ - ...normalizeStringArray(base), - ...normalizeStringArray(override), - ] - .map((item) => item.trim()) - .filter(Boolean); - return merged.length > 0 ? Array.from(new Set(merged)) : undefined; -} -function mergeProviderModelAllow(base, override) { - if (!base && !override) return undefined; - const merged = {}; - for (const [provider, models] of Object.entries(base ?? {})) { - merged[provider] = [...models]; - } - for (const [provider, models] of Object.entries(override ?? {})) { - // Per-provider replace: a project entry replaces the global array for - // that provider instead of appending to it. - merged[provider] = [...models]; - } - return Object.keys(merged).length > 0 ? merged : undefined; -} -function mergePostUnitHooks(base, override) { - if (!base?.length && !override?.length) return undefined; - const merged = [...(base ?? [])]; - for (const hook of override ?? []) { - // Override hooks with same name replace base hooks - const idx = merged.findIndex((h) => h.name === hook.name); - if (idx >= 0) { - merged[idx] = hook; - } else { - merged.push(hook); - } - } - return merged.length > 0 ? merged : undefined; -} -function mergePreDispatchHooks(base, override) { - if (!base?.length && !override?.length) return undefined; - const merged = [...(base ?? [])]; - for (const hook of override ?? []) { - const idx = merged.findIndex((h) => h.name === hook.name); - if (idx >= 0) { - merged[idx] = hook; - } else { - merged.push(hook); - } - } - return merged.length > 0 ? merged : undefined; -} // ─── System Prompt Rendering ────────────────────────────────────────────────── /** * Render preferences as a formatted string for inclusion in system prompts. @@ -836,8 +365,3 @@ export function getHotCacheTurns() { const mode = prefs?.mode ?? "solo"; return mode === "team" ? 10 : 5; } - -// Inject the loader functions into preferences-models.js to break the -// preferences.js ↔ preferences-models.js circular import. This runs after -// all exports in this module are defined, so the injected references are live. -_initPrefsLoader(loadEffectiveSFPreferences, getGlobalSFPreferencesPath); diff --git a/src/resources/extensions/sf/safety/autonomous-rollback.js b/src/resources/extensions/sf/safety/autonomous-rollback.js index ce52ecf09..9e5f6a2f5 100644 --- a/src/resources/extensions/sf/safety/autonomous-rollback.js +++ b/src/resources/extensions/sf/safety/autonomous-rollback.js @@ -1,180 +1,76 @@ /** - * autonomous-rollback.js — revert autonomous commits after regression quarantine. + * autonomous-rollback.js — quarantine response for crash-loop regression (R066/M048-D1). * - * Purpose: give M048/S04 a bounded rollback path for crash-loop regressions - * without letting generic detectors mutate git state by accident. + * Purpose: on crash-loop quarantine, disable the smoke_gate feature flag and + * file operator-visible self-feedback. SF does NOT perform git-revert; the + * crash-loop classifier sees runtime evidence, not commit attribution — reverting + * on runtime symptoms risks reverting the wrong commit. Operator decides. * * Consumer: crash-loop quarantine handling and future daemon supervisor policy. */ -import { execFileSync } from "node:child_process"; import { recordSelfFeedback } from "../self-feedback.js"; - -export const AUTONOMOUS_COMMIT_PATTERNS = [ - /sf-snapshot-/i, - /autonomous-/i, - /SF-Task:/i, - /singularity-forge autonomous/i, -]; +import { setExperimentalFlag } from "../experimental.js"; /** - * Find the newest commit that looks autonomous by subject, body, or author. + * Quarantine response: flip the smoke_gate flag off and file self-feedback. * - * Purpose: keep rollback scoped to SF-authored changes rather than reverting - * arbitrary operator commits after a detector fires. + * Purpose: prevent further last-green ledger writes while the operator reviews + * the regression. No git state is mutated. * - * Consumer: revertAutonomousRegression(). + * Consumer: maybeRollbackCrashLoop() and direct callers in supervisor policy. */ -export function findLastAutonomousCommit(basePath, options = {}) { - const maxCount = options.maxCount ?? 30; - const runGit = options.runGit ?? defaultRunGit; - const output = runGit(basePath, [ - "log", - `-${maxCount}`, - "--format=%H%x00%an%x00%ae%x00%s%x00%b%x1e", - ]); - return ( - parseGitLog(output).find((commit) => isAutonomousCommit(commit)) ?? null - ); -} - -/** - * Revert the newest autonomous commit and record rollback self-feedback. - * - * Purpose: close the M048/S04 loop from crash-loop quarantine to actionable - * rollback evidence while leaving non-autonomous commits untouched. - * - * Consumer: daemon/server supervisor when crashLoopGate reports quarantine. - */ -export function revertAutonomousRegression( - basePath, - signature = {}, - options = {}, -) { - const runGit = options.runGit ?? defaultRunGit; +export function quarantineCrashLoop(basePath, signature = {}, options = {}) { const record = options.recordSelfFeedback ?? ((entry) => recordSelfFeedback(entry, basePath)); - const commit = - options.commit ?? - findLastAutonomousCommit(basePath, { ...options, runGit }); - if (!commit) { - const result = { - ok: false, - reverted: false, - reason: "no-autonomous-commit", - signature, - }; - recordRollbackFeedback(record, result, commit); - return result; - } + const setFlag = + options.setExperimentalFlag ?? + ((name, value) => setExperimentalFlag(name, value)); + const ledgerDiff = options.ledgerDiff ?? null; - try { - runGit(basePath, ["revert", "--no-edit", commit.sha]); - const result = { - ok: true, - reverted: true, - reason: "reverted-autonomous-commit", - commit, + setFlag("smoke_gate", false); + + const entry = { + kind: "crash-loop-detected", + severity: "high", + summary: + "Crash-loop classifier triggered quarantine. smoke_gate disabled to halt ledger writes.", + evidence: { signature, - }; - recordRollbackFeedback(record, result, commit); - return result; - } catch (error) { - const result = { - ok: false, - reverted: false, - reason: "git-revert-failed", - error: error instanceof Error ? error.message : String(error), - commit, - signature, - }; - recordRollbackFeedback(record, result, commit); - return result; - } + ledgerDiff, + }, + suggestedFix: + "Manual review: SF flagged a regression. The smoke_gate has been disabled. " + + "Review the ledger diff and decide whether to git-revert the implicated " + + "commits — SF will not auto-revert.", + }; + + record(entry); + + return { + ok: true, + quarantined: true, + reason: "smoke-gate-disabled", + signature, + }; } /** - * Apply rollback when a crash-loop detector result is quarantined. + * Apply quarantine when a crash-loop detector result is stuck. * - * Purpose: keep gate/run-control callers on one small API: pass the detector - * result, get either no-op or rollback outcome. + * Purpose: keep gate/run-control callers on one small API — pass the detector + * result, get either no-op or quarantine outcome. * * Consumer: crashLoopGate and future embedded supervisor backoff policy. */ export function maybeRollbackCrashLoop(basePath, detectorResult, options = {}) { if (!detectorResult?.stuck) { - return { ok: true, reverted: false, reason: "not-quarantined" }; + return { ok: true, quarantined: false, reason: "not-quarantined" }; } - return revertAutonomousRegression( + return quarantineCrashLoop( basePath, detectorResult.signature ?? {}, - options, + { ...options, ledgerDiff: detectorResult.ledgerDiff ?? null }, ); } - -function defaultRunGit(basePath, args) { - return execFileSync("git", args, { - cwd: basePath, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 30_000, - }); -} - -function parseGitLog(output) { - return String(output ?? "") - .split("\x1e") - .map((entry) => entry.trim()) - .filter(Boolean) - .map((entry) => { - const [sha, authorName, authorEmail, subject, ...bodyParts] = - entry.split("\x00"); - return { - sha, - authorName, - authorEmail, - subject, - body: bodyParts.join("\x00"), - }; - }) - .filter((commit) => /^[0-9a-f]{7,40}$/i.test(commit.sha ?? "")); -} - -function isAutonomousCommit(commit) { - const haystack = [ - commit.authorName, - commit.authorEmail, - commit.subject, - commit.body, - ] - .filter(Boolean) - .join("\n"); - return AUTONOMOUS_COMMIT_PATTERNS.some((pattern) => pattern.test(haystack)); -} - -function recordRollbackFeedback(record, result, commit) { - record({ - kind: "runaway-loop:crash-loop-rollback", - severity: result.ok ? "medium" : "high", - summary: result.ok - ? `Reverted autonomous regression commit ${commit?.sha ?? "unknown"}.` - : `Crash-loop rollback did not revert a commit: ${result.reason}.`, - evidence: { - reason: result.reason, - commit: commit - ? { - sha: commit.sha, - subject: commit.subject, - authorName: commit.authorName, - authorEmail: commit.authorEmail, - } - : null, - signature: result.signature, - error: result.error ?? null, - }, - suggestedFix: result.ok - ? "Verify the loop resumes on the reverted parent and keep the crash signature for regression tests." - : "Inspect the crash-loop signature and revert manually if the failed commit is not machine-identifiable.", - }); -} diff --git a/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs b/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs index 069bb7a42..7a3aca38a 100644 --- a/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs +++ b/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs @@ -47,7 +47,6 @@ vi.mock("../auto-prompts.js", () => ({ })); vi.mock("../preferences-models.js", () => ({ - _initPrefsLoader: vi.fn(), resolveModelWithFallbacksForUnit: vi.fn(() => ({ primary: "mock-model" })), })); diff --git a/src/resources/extensions/sf/tests/autonomous-rollback.test.mjs b/src/resources/extensions/sf/tests/autonomous-rollback.test.mjs index e445e5520..78a7520cf 100644 --- a/src/resources/extensions/sf/tests/autonomous-rollback.test.mjs +++ b/src/resources/extensions/sf/tests/autonomous-rollback.test.mjs @@ -1,149 +1,111 @@ /** - * autonomous-rollback.test.mjs — M048/S04 rollback controller contracts. + * autonomous-rollback.test.mjs — R066/M048-D1 flag-flip quarantine contracts. * - * Purpose: prove crash-loop rollback only targets autonomous-looking commits - * and records operator-visible self-feedback for success and failure. + * Purpose: prove crash-loop quarantine (a) flips smoke_gate off, (b) records + * self-feedback with kind "crash-loop-detected", and (c) performs NO git + * operations — git-revert authority was removed per operator decision M048-D1. */ import assert from "node:assert/strict"; import { test } from "vitest"; import { - findLastAutonomousCommit, maybeRollbackCrashLoop, - revertAutonomousRegression, + quarantineCrashLoop, } from "../safety/autonomous-rollback.js"; -function gitLog(commits) { - return commits - .map((commit) => - [ - commit.sha, - commit.authorName ?? "Human", - commit.authorEmail ?? "human@example.com", - commit.subject ?? "manual change", - commit.body ?? "", - ].join("\x00"), - ) - .join("\x1e"); -} - -test("findLastAutonomousCommit_when_subject_has_sf_snapshot_returns_newest_match", () => { - const output = gitLog([ - { sha: "aaaaaaaa", subject: "manual edit" }, - { sha: "bbbbbbbb", subject: "sf-snapshot-M048 detector patch" }, - ]); - const commit = findLastAutonomousCommit("/repo", { - runGit: () => output, - }); - - assert.equal(commit.sha, "bbbbbbbb"); - assert.equal(commit.subject, "sf-snapshot-M048 detector patch"); -}); - -test("findLastAutonomousCommit_when_body_has_sf_task_returns_match", () => { - const output = gitLog([ - { - sha: "cccccccc", - subject: "feat: add detector", - body: "SF-Task: M048/S04/T01", - }, - ]); - const commit = findLastAutonomousCommit("/repo", { - runGit: () => output, - }); - - assert.equal(commit.sha, "cccccccc"); -}); - -test("findLastAutonomousCommit_when_no_autonomous_marker_returns_null", () => { - const output = gitLog([{ sha: "dddddddd", subject: "human commit" }]); - const commit = findLastAutonomousCommit("/repo", { - runGit: () => output, - }); - - assert.equal(commit, null); -}); - -test("revertAutonomousRegression_when_commit_found_runs_git_revert_and_records_feedback", () => { - const calls = []; +test("quarantineCrashLoop_flips_smoke_gate_to_false", () => { + const flags = {}; const feedback = []; - const result = revertAutonomousRegression( - "/repo", - { sourceHash: "source-a" }, - { - runGit: (_cwd, args) => { - calls.push(args); - if (args[0] === "log") { - return gitLog([ - { sha: "eeeeeeee", subject: "sf-snapshot-M048 bad change" }, - ]); - } - return ""; - }, - recordSelfFeedback: (entry) => feedback.push(entry), - }, - ); + + quarantineCrashLoop("/repo", { sourceHash: "abc" }, { + setExperimentalFlag: (name, value) => { flags[name] = value; }, + recordSelfFeedback: (entry) => feedback.push(entry), + }); + + assert.equal(flags.smoke_gate, false); +}); + +test("quarantineCrashLoop_records_crash_loop_detected_feedback", () => { + const feedback = []; + + quarantineCrashLoop("/repo", { sourceHash: "abc" }, { + setExperimentalFlag: () => {}, + recordSelfFeedback: (entry) => feedback.push(entry), + }); + + assert.equal(feedback.length, 1); + assert.equal(feedback[0].kind, "crash-loop-detected"); + assert.equal(feedback[0].severity, "high"); +}); + +test("quarantineCrashLoop_feedback_includes_manual_review_suggestion", () => { + const feedback = []; + + quarantineCrashLoop("/repo", { sourceHash: "abc" }, { + setExperimentalFlag: () => {}, + recordSelfFeedback: (entry) => feedback.push(entry), + }); + + assert.match(feedback[0].suggestedFix, /Manual review/); + assert.match(feedback[0].suggestedFix, /SF will not auto-revert/); +}); + +test("quarantineCrashLoop_returns_quarantined_true_with_smoke_gate_reason", () => { + const result = quarantineCrashLoop("/repo", {}, { + setExperimentalFlag: () => {}, + recordSelfFeedback: () => {}, + }); assert.equal(result.ok, true); - assert.equal(result.reverted, true); - assert.deepEqual(calls.at(-1), ["revert", "--no-edit", "eeeeeeee"]); - assert.equal(feedback.length, 1); - assert.equal(feedback[0].kind, "runaway-loop:crash-loop-rollback"); - assert.equal(feedback[0].severity, "medium"); + assert.equal(result.quarantined, true); + assert.equal(result.reason, "smoke-gate-disabled"); }); -test("revertAutonomousRegression_when_no_commit_records_blocking_feedback", () => { +test("quarantineCrashLoop_includes_ledger_diff_in_evidence_when_provided", () => { const feedback = []; - const result = revertAutonomousRegression( - "/repo", - { sourceHash: "source-a" }, - { - runGit: () => gitLog([{ sha: "ffffffff", subject: "manual commit" }]), - recordSelfFeedback: (entry) => feedback.push(entry), - }, - ); + const ledgerDiff = { before: "hash-a", after: "hash-b" }; - assert.equal(result.ok, false); - assert.equal(result.reason, "no-autonomous-commit"); - assert.equal(feedback[0].severity, "high"); + quarantineCrashLoop("/repo", {}, { + setExperimentalFlag: () => {}, + recordSelfFeedback: (entry) => feedback.push(entry), + ledgerDiff, + }); + + assert.deepEqual(feedback[0].evidence.ledgerDiff, ledgerDiff); }); -test("revertAutonomousRegression_when_git_revert_fails_records_failure", () => { - const feedback = []; - const result = revertAutonomousRegression( - "/repo", - { sourceHash: "source-a" }, - { - runGit: (_cwd, args) => { - if (args[0] === "log") { - return gitLog([ - { sha: "11111111", subject: "autonomous-M048 bad change" }, - ]); - } - throw new Error("merge conflict"); - }, - recordSelfFeedback: (entry) => feedback.push(entry), - }, - ); +test("maybeRollbackCrashLoop_when_not_stuck_is_noop_and_does_not_flip_flag", () => { + const flags = {}; - assert.equal(result.ok, false); - assert.equal(result.reason, "git-revert-failed"); - assert.match(result.error, /merge conflict/); - assert.equal(feedback[0].severity, "high"); -}); - -test("maybeRollbackCrashLoop_when_not_stuck_is_noop", () => { const result = maybeRollbackCrashLoop( "/repo", { stuck: false, signature: {} }, { - runGit: () => { - throw new Error("should not run"); - }, + setExperimentalFlag: (name, value) => { flags[name] = value; }, + recordSelfFeedback: () => { throw new Error("should not record"); }, }, ); assert.equal(result.ok, true); - assert.equal(result.reverted, false); + assert.equal(result.quarantined, false); assert.equal(result.reason, "not-quarantined"); + assert.deepEqual(flags, {}); +}); + +test("maybeRollbackCrashLoop_when_stuck_delegates_to_quarantineCrashLoop", () => { + const flags = {}; + const feedback = []; + + const result = maybeRollbackCrashLoop( + "/repo", + { stuck: true, signature: { sourceHash: "xyz" } }, + { + setExperimentalFlag: (name, value) => { flags[name] = value; }, + recordSelfFeedback: (entry) => feedback.push(entry), + }, + ); + + assert.equal(result.quarantined, true); + assert.equal(flags.smoke_gate, false); + assert.equal(feedback[0].kind, "crash-loop-detected"); }); diff --git a/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs b/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs index be76bcd15..ef3ec719a 100644 --- a/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs +++ b/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs @@ -17,8 +17,6 @@ import { parseDiscoveredModels, } from "../model-catalog-cache.js"; -// Import preferences.js so that _initPrefsLoader is called and the -// circular dep lazy-loader is wired up (required by preferences-models.js). import "../preferences.js"; import { isProviderModelAllowed } from "../preferences-models.js"; diff --git a/src/resources/extensions/sf/tests/enabled-models-fallback.test.mjs b/src/resources/extensions/sf/tests/enabled-models-fallback.test.mjs index f8d7c7c74..436f769d2 100644 --- a/src/resources/extensions/sf/tests/enabled-models-fallback.test.mjs +++ b/src/resources/extensions/sf/tests/enabled-models-fallback.test.mjs @@ -19,7 +19,6 @@ import { join } from "node:path"; import { afterEach, describe, test } from "vitest"; import { isModelInEnabledList } from "../preferences-models.js"; -// Import preferences.js to wire up _initPrefsLoader (circular dep resolver) import "../preferences.js"; import { selectAndApplyModel } from "../auto-model-selection.js"; diff --git a/src/resources/extensions/sf/tests/preferences-models.test.mjs b/src/resources/extensions/sf/tests/preferences-models.test.mjs index ec0808155..0a74c0629 100644 --- a/src/resources/extensions/sf/tests/preferences-models.test.mjs +++ b/src/resources/extensions/sf/tests/preferences-models.test.mjs @@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; 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 { isModelInEnabledList,