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:
parent
c2f101734f
commit
781a7e7319
11 changed files with 744 additions and 860 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
497
src/resources/extensions/sf/preferences-loader.js
Normal file
497
src/resources/extensions/sf/preferences-loader.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
86
src/resources/extensions/sf/preferences-profile.js
Normal file
86
src/resources/extensions/sf/preferences-profile.js
Normal 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 {};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ vi.mock("../auto-prompts.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../preferences-models.js", () => ({
|
||||
_initPrefsLoader: vi.fn(),
|
||||
resolveModelWithFallbacksForUnit: vi.fn(() => ({ primary: "mock-model" })),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue