chore(safety): narrow autonomous-rollback to flag-flip only (R066 D1)

Remove git-revert authority per operator decision M048-D1. Crash-loop
classifier sees runtime evidence, not commit attribution; reverting on
runtime symptoms risks reverting the wrong commit. On quarantine trigger,
smoke_gate is flipped false to halt ledger writes and a self-feedback entry
(kind: crash-loop-detected, severity: high) is filed with a manual-review
suggestion. Operator retains sole authority to git-revert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-17 18:17:18 +02:00
parent c2f101734f
commit 781a7e7319
11 changed files with 744 additions and 860 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -47,7 +47,6 @@ vi.mock("../auto-prompts.js", () => ({
}));
vi.mock("../preferences-models.js", () => ({
_initPrefsLoader: vi.fn(),
resolveModelWithFallbacksForUnit: vi.fn(() => ({ primary: "mock-model" })),
}));

View file

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

View file

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

View file

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

View file

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