- Remove stale .sf/milestones/M001/ and M002/ (not in DB, were blocking dispatch) - dispatch-guard.js: import findMilestoneIds from milestone-ids.js directly (not via guided-flow.js, which is in the circular-dep cluster) - auto.js: normalize 'Cannot dispatch' → prior-slice-blocker, 'SF resources updated' → resources-stale, 'Stuck:' → stuck in telemetry (was silently bucketing as 'other') Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026 lines
72 KiB
JavaScript
2026 lines
72 KiB
JavaScript
/**
|
|
* Validation logic for SF preferences.
|
|
*
|
|
* Pure validation -- no filesystem access, no loading, no merging.
|
|
* Accepts a raw SFPreferences object and returns a sanitized copy
|
|
* together with any errors and warnings.
|
|
*/
|
|
import { normalizeStringArray } from "../shared/format-utils.js";
|
|
import { VALID_BRANCH_NAME } from "./git-constants.js";
|
|
import {
|
|
CURRENT_PREFERENCES_SCHEMA_VERSION,
|
|
checkPreferencesDrift,
|
|
migrateForward,
|
|
} from "./preferences-migrations.js";
|
|
import {
|
|
KNOWN_PREFERENCE_KEYS,
|
|
KNOWN_UNIT_TYPES,
|
|
SKILL_ACTIONS,
|
|
} from "./preferences-types.js";
|
|
|
|
const VALID_TOKEN_PROFILES = new Set([
|
|
"budget",
|
|
"balanced",
|
|
"quality",
|
|
"burn-max",
|
|
]);
|
|
const VALID_UOK_TURN_ACTIONS = new Set(["commit", "snapshot", "status-only"]);
|
|
export function validatePreferences(preferences) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
const validated = {};
|
|
// Schema version: report drift, then migrate forward. Errors from a
|
|
// malformed migration chain bubble up so the caller can surface "your
|
|
// prefs need attention" instead of silently dropping fields. Field
|
|
// checks below run against the migrated copy.
|
|
for (const w of checkPreferencesDrift(preferences).warnings) warnings.push(w);
|
|
let migrated = preferences;
|
|
try {
|
|
const outcome = migrateForward(preferences);
|
|
migrated = outcome.preferences;
|
|
if (outcome.applied.length > 0) {
|
|
warnings.push(
|
|
`migrated prefs forward: ${outcome.applied
|
|
.map((m) => `v${m.from}→v${m.to} (${m.description})`)
|
|
.join("; ")}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
errors.push(
|
|
err instanceof Error
|
|
? `prefs migration failed: ${err.message}`
|
|
: `prefs migration failed: ${String(err)}`,
|
|
);
|
|
}
|
|
preferences = migrated;
|
|
// ─── Unknown Key Detection ──────────────────────────────────────────
|
|
// Common key migration hints for pi-level settings that don't map to SF prefs
|
|
const KEY_MIGRATION_HINTS = {
|
|
taskIsolation:
|
|
'use "git.isolation" instead (values: worktree, branch, none)',
|
|
task_isolation:
|
|
'use "git.isolation" instead (values: worktree, branch, none)',
|
|
isolation: 'use "git.isolation" instead (values: worktree, branch, none)',
|
|
manage_gitignore: 'use "git.manage_gitignore" instead',
|
|
auto_push: 'use "git.auto_push" instead',
|
|
main_branch: 'use "git.main_branch" instead',
|
|
};
|
|
for (const key of Object.keys(preferences)) {
|
|
if (!KNOWN_PREFERENCE_KEYS.has(key)) {
|
|
const hint = KEY_MIGRATION_HINTS[key];
|
|
if (hint) {
|
|
warnings.push(`unknown preference key "${key}" — ${hint}`);
|
|
} else {
|
|
warnings.push(`unknown preference key "${key}" — ignored`);
|
|
}
|
|
}
|
|
}
|
|
if (preferences.version !== undefined) {
|
|
if (preferences.version === CURRENT_PREFERENCES_SCHEMA_VERSION) {
|
|
validated.version = CURRENT_PREFERENCES_SCHEMA_VERSION;
|
|
} else if (preferences.version > CURRENT_PREFERENCES_SCHEMA_VERSION) {
|
|
// Already warned via checkPreferencesDrift; preserve so a later
|
|
// sf upgrade reads correctly without rewriting the file.
|
|
validated.version = preferences.version;
|
|
} else {
|
|
// Should be unreachable: migrateForward stamps the current version
|
|
// or throws. Defend against a future bug instead of silently dropping.
|
|
errors.push(
|
|
`unsupported version ${preferences.version} (migration chain ` +
|
|
`should have produced v${CURRENT_PREFERENCES_SCHEMA_VERSION})`,
|
|
);
|
|
}
|
|
}
|
|
// ─── Workflow Mode ──────────────────────────────────────────────────
|
|
if (preferences.mode !== undefined) {
|
|
const validModes = new Set(["solo", "team"]);
|
|
if (
|
|
typeof preferences.mode === "string" &&
|
|
validModes.has(preferences.mode)
|
|
) {
|
|
validated.mode = preferences.mode;
|
|
} else {
|
|
errors.push(
|
|
`invalid mode "${preferences.mode}" — must be one of: solo, team`,
|
|
);
|
|
}
|
|
}
|
|
const validDiscoveryModes = new Set(["auto", "suggest", "off"]);
|
|
if (preferences.skill_discovery) {
|
|
if (validDiscoveryModes.has(preferences.skill_discovery)) {
|
|
validated.skill_discovery = preferences.skill_discovery;
|
|
} else {
|
|
errors.push(
|
|
`invalid skill_discovery value: ${preferences.skill_discovery}`,
|
|
);
|
|
}
|
|
}
|
|
if (preferences.skill_staleness_days !== undefined) {
|
|
const days = Number(preferences.skill_staleness_days);
|
|
if (Number.isFinite(days) && days >= 0) {
|
|
validated.skill_staleness_days = Math.floor(days);
|
|
} else {
|
|
errors.push(
|
|
`invalid skill_staleness_days: must be a non-negative number`,
|
|
);
|
|
}
|
|
}
|
|
validated.always_use_skills = normalizeStringArray(
|
|
preferences.always_use_skills,
|
|
);
|
|
validated.prefer_skills = normalizeStringArray(preferences.prefer_skills);
|
|
validated.avoid_skills = normalizeStringArray(preferences.avoid_skills);
|
|
validated.custom_instructions = normalizeStringArray(
|
|
preferences.custom_instructions,
|
|
);
|
|
if (preferences.skill_rules) {
|
|
const validRules = [];
|
|
for (const rule of preferences.skill_rules) {
|
|
if (!rule || typeof rule !== "object") {
|
|
errors.push("invalid skill_rules entry");
|
|
continue;
|
|
}
|
|
const when = typeof rule.when === "string" ? rule.when.trim() : "";
|
|
if (!when) {
|
|
errors.push("skill_rules entry missing when");
|
|
continue;
|
|
}
|
|
const validatedRule = { when };
|
|
for (const action of SKILL_ACTIONS) {
|
|
const values = normalizeStringArray(rule[action]);
|
|
if (values.length > 0) {
|
|
validatedRule[action] = values;
|
|
}
|
|
}
|
|
if (!validatedRule.use && !validatedRule.prefer && !validatedRule.avoid) {
|
|
errors.push(`skill rule has no actions: ${when}`);
|
|
continue;
|
|
}
|
|
validRules.push(validatedRule);
|
|
}
|
|
if (validRules.length > 0) {
|
|
validated.skill_rules = validRules;
|
|
}
|
|
}
|
|
for (const key of [
|
|
"always_use_skills",
|
|
"prefer_skills",
|
|
"avoid_skills",
|
|
"custom_instructions",
|
|
]) {
|
|
if (validated[key] && validated[key].length === 0) {
|
|
delete validated[key];
|
|
}
|
|
}
|
|
if (preferences.uat_dispatch !== undefined) {
|
|
validated.uat_dispatch = !!preferences.uat_dispatch;
|
|
}
|
|
if (preferences.unique_milestone_ids !== undefined) {
|
|
validated.unique_milestone_ids = !!preferences.unique_milestone_ids;
|
|
}
|
|
if (preferences.persist_model_changes !== undefined) {
|
|
if (typeof preferences.persist_model_changes === "boolean") {
|
|
validated.persist_model_changes = preferences.persist_model_changes;
|
|
} else {
|
|
errors.push("persist_model_changes must be a boolean");
|
|
}
|
|
}
|
|
if (preferences.budget_ceiling !== undefined) {
|
|
const raw = preferences.budget_ceiling;
|
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
validated.budget_ceiling = raw;
|
|
} else if (typeof raw === "string" && Number.isFinite(Number(raw))) {
|
|
validated.budget_ceiling = Number(raw);
|
|
} else {
|
|
errors.push("budget_ceiling must be a finite number");
|
|
}
|
|
}
|
|
// ─── Budget Enforcement ──────────────────────────────────────────────
|
|
if (preferences.budget_enforcement !== undefined) {
|
|
const validModes = new Set(["warn", "pause", "halt"]);
|
|
if (
|
|
typeof preferences.budget_enforcement === "string" &&
|
|
validModes.has(preferences.budget_enforcement)
|
|
) {
|
|
validated.budget_enforcement = preferences.budget_enforcement;
|
|
} else {
|
|
errors.push(`budget_enforcement must be one of: warn, pause, halt`);
|
|
}
|
|
}
|
|
// ─── UOK Flags ──────────────────────────────────────────────────────
|
|
if (preferences.uok !== undefined) {
|
|
if (typeof preferences.uok === "object" && preferences.uok !== null) {
|
|
const raw = preferences.uok;
|
|
const valid = {};
|
|
if (raw.enabled !== undefined) {
|
|
if (typeof raw.enabled === "boolean") valid.enabled = raw.enabled;
|
|
else errors.push("uok.enabled must be a boolean");
|
|
}
|
|
const parseEnabledBlock = (key, targetKey) => {
|
|
const normalizedTargetKey =
|
|
targetKey ?? (key === "plan_v2" ? "planning_flow" : key);
|
|
const value = raw[key];
|
|
if (value === undefined) return;
|
|
if (typeof value !== "object" || value === null) {
|
|
errors.push(`uok.${key} must be an object`);
|
|
return;
|
|
}
|
|
const block = value;
|
|
const parsed = {};
|
|
if (block.enabled !== undefined) {
|
|
if (typeof block.enabled === "boolean")
|
|
parsed.enabled = block.enabled;
|
|
else errors.push(`uok.${key}.enabled must be a boolean`);
|
|
}
|
|
const unknown = Object.keys(block).filter((k) => k !== "enabled");
|
|
for (const unk of unknown) {
|
|
warnings.push(`unknown uok.${key} key "${unk}" — ignored`);
|
|
}
|
|
if (Object.keys(parsed).length > 0) {
|
|
valid[normalizedTargetKey] = parsed;
|
|
}
|
|
};
|
|
parseEnabledBlock("gates");
|
|
parseEnabledBlock("model_policy");
|
|
parseEnabledBlock("execution_graph");
|
|
parseEnabledBlock("audit_envelope");
|
|
if (raw.audit_unified !== undefined && raw.audit_envelope === undefined) {
|
|
warnings.push(
|
|
"uok.audit_unified is deprecated; use uok.audit_envelope",
|
|
);
|
|
parseEnabledBlock("audit_unified", "audit_envelope");
|
|
}
|
|
parseEnabledBlock("planning_flow");
|
|
if (raw.plan_v2 !== undefined && raw.planning_flow === undefined) {
|
|
warnings.push("uok.plan_v2 is deprecated; use uok.planning_flow");
|
|
parseEnabledBlock("plan_v2", "planning_flow");
|
|
}
|
|
if (raw.gitops !== undefined) {
|
|
if (typeof raw.gitops !== "object" || raw.gitops === null) {
|
|
errors.push("uok.gitops must be an object");
|
|
} else {
|
|
const gitops = raw.gitops;
|
|
const parsed = {};
|
|
if (gitops.enabled !== undefined) {
|
|
if (typeof gitops.enabled === "boolean")
|
|
parsed.enabled = gitops.enabled;
|
|
else errors.push("uok.gitops.enabled must be a boolean");
|
|
}
|
|
if (gitops.turn_action !== undefined) {
|
|
if (
|
|
typeof gitops.turn_action === "string" &&
|
|
VALID_UOK_TURN_ACTIONS.has(gitops.turn_action)
|
|
) {
|
|
parsed.turn_action = gitops.turn_action;
|
|
} else {
|
|
errors.push(
|
|
"uok.gitops.turn_action must be one of: commit, snapshot, status-only",
|
|
);
|
|
}
|
|
}
|
|
if (gitops.turn_push !== undefined) {
|
|
if (typeof gitops.turn_push === "boolean")
|
|
parsed.turn_push = gitops.turn_push;
|
|
else errors.push("uok.gitops.turn_push must be a boolean");
|
|
}
|
|
const unknown = Object.keys(gitops).filter(
|
|
(k) => !["enabled", "turn_action", "turn_push"].includes(k),
|
|
);
|
|
for (const unk of unknown) {
|
|
warnings.push(`unknown uok.gitops key "${unk}" — ignored`);
|
|
}
|
|
if (Object.keys(parsed).length > 0) {
|
|
valid.gitops = parsed;
|
|
}
|
|
}
|
|
}
|
|
const knownUokKeys = new Set([
|
|
"enabled",
|
|
"gates",
|
|
"model_policy",
|
|
"execution_graph",
|
|
"gitops",
|
|
"audit_envelope",
|
|
"audit_unified",
|
|
"planning_flow",
|
|
"plan_v2",
|
|
]);
|
|
for (const key of Object.keys(raw)) {
|
|
if (!knownUokKeys.has(key)) {
|
|
warnings.push(`unknown uok key "${key}" — ignored`);
|
|
}
|
|
}
|
|
if (Object.keys(valid).length > 0) {
|
|
validated.uok = valid;
|
|
}
|
|
} else {
|
|
errors.push("uok must be an object");
|
|
}
|
|
}
|
|
// ─── Token Profile ─────────────────────────────────────────────────
|
|
if (preferences.token_profile !== undefined) {
|
|
if (
|
|
typeof preferences.token_profile === "string" &&
|
|
VALID_TOKEN_PROFILES.has(preferences.token_profile)
|
|
) {
|
|
validated.token_profile = preferences.token_profile;
|
|
} else {
|
|
errors.push(
|
|
`token_profile must be one of: budget, balanced, quality, burn-max`,
|
|
);
|
|
}
|
|
}
|
|
// ─── Service Tier ───────────────────────────────────────────────────
|
|
// OpenAI service tier for gpt-5.4 models. "off" explicitly disables the
|
|
// whole feature (hooks, footer, command refuse enable). Undefined = not
|
|
// configured. Historical gap: this field wasn't wired through validation
|
|
// so even "priority" / "flex" were being silently dropped.
|
|
if (preferences.service_tier !== undefined) {
|
|
const validTiers = new Set(["priority", "flex", "off"]);
|
|
if (
|
|
typeof preferences.service_tier === "string" &&
|
|
validTiers.has(preferences.service_tier)
|
|
) {
|
|
validated.service_tier = preferences.service_tier;
|
|
} else {
|
|
errors.push(`service_tier must be one of: priority, flex, off`);
|
|
}
|
|
}
|
|
// ─── forensics_dedup ────────────────────────────────────────────────
|
|
if (preferences.forensics_dedup !== undefined) {
|
|
validated.forensics_dedup = !!preferences.forensics_dedup;
|
|
}
|
|
// ─── stale_commit_threshold_minutes ─────────────────────────────────
|
|
if (preferences.stale_commit_threshold_minutes !== undefined) {
|
|
const raw = Number(preferences.stale_commit_threshold_minutes);
|
|
if (Number.isFinite(raw) && raw >= 0) {
|
|
validated.stale_commit_threshold_minutes = Math.floor(raw);
|
|
} else {
|
|
errors.push(
|
|
"stale_commit_threshold_minutes must be a non-negative number (minutes; 0 = disabled)",
|
|
);
|
|
}
|
|
}
|
|
// ─── widget_mode ────────────────────────────────────────────────────
|
|
if (preferences.widget_mode !== undefined) {
|
|
const valid = new Set(["full", "small", "min", "off"]);
|
|
if (
|
|
typeof preferences.widget_mode === "string" &&
|
|
valid.has(preferences.widget_mode)
|
|
) {
|
|
validated.widget_mode = preferences.widget_mode;
|
|
} else {
|
|
errors.push("widget_mode must be one of: full, small, min, off");
|
|
}
|
|
}
|
|
// ─── slice_parallel ─────────────────────────────────────────────────
|
|
// Shallow validation: object-shape check + primitive field coercion.
|
|
// Deeper structural checks can come later; the goal here is to stop
|
|
// silently dropping the preference.
|
|
if (preferences.slice_parallel !== undefined) {
|
|
const sp = preferences.slice_parallel;
|
|
if (typeof sp === "object" && sp !== null && !Array.isArray(sp)) {
|
|
const v = {};
|
|
const anySp = sp;
|
|
if (anySp.enabled !== undefined) v.enabled = !!anySp.enabled;
|
|
if (anySp.max_workers !== undefined) {
|
|
const n = Number(anySp.max_workers);
|
|
if (Number.isFinite(n) && n >= 1) {
|
|
v.max_workers = Math.floor(n);
|
|
} else {
|
|
errors.push("slice_parallel.max_workers must be a positive integer");
|
|
}
|
|
}
|
|
validated.slice_parallel = v;
|
|
} else {
|
|
errors.push("slice_parallel must be an object");
|
|
}
|
|
}
|
|
// ─── modelOverrides ─────────────────────────────────────────────────
|
|
// Per-model capability overrides. Deep-merged into built-in profiles at
|
|
// consumer sites — here we just confirm the shape and pass through.
|
|
if (preferences.modelOverrides !== undefined) {
|
|
const mo = preferences.modelOverrides;
|
|
if (typeof mo === "object" && mo !== null && !Array.isArray(mo)) {
|
|
validated.modelOverrides = mo;
|
|
} else {
|
|
errors.push("modelOverrides must be an object keyed by model ID");
|
|
}
|
|
}
|
|
// ─── safety_harness ─────────────────────────────────────────────────
|
|
// Rich nested config. Pass-through with an object-shape guard; field-level
|
|
// validation can land alongside the features that consume them.
|
|
if (preferences.safety_harness !== undefined) {
|
|
const sh = preferences.safety_harness;
|
|
if (typeof sh === "object" && sh !== null && !Array.isArray(sh)) {
|
|
validated.safety_harness = sh;
|
|
} else {
|
|
errors.push("safety_harness must be an object");
|
|
}
|
|
}
|
|
// ─── Search Provider ─────────────────────────────────────────────
|
|
if (preferences.search_provider !== undefined) {
|
|
const validSearchProviders = new Set([
|
|
"brave",
|
|
"tavily",
|
|
"minimax",
|
|
"serper",
|
|
"exa",
|
|
"ollama",
|
|
"combosearch",
|
|
"native",
|
|
"auto",
|
|
]);
|
|
if (
|
|
typeof preferences.search_provider === "string" &&
|
|
validSearchProviders.has(preferences.search_provider)
|
|
) {
|
|
validated.search_provider = preferences.search_provider;
|
|
} else {
|
|
errors.push(
|
|
`search_provider must be one of: brave, tavily, minimax, serper, exa, ollama, combosearch, native, auto`,
|
|
);
|
|
}
|
|
}
|
|
// ─── Provider Preference (benchmark tie-break order) ────────────────
|
|
if (preferences.provider_preference !== undefined) {
|
|
if (
|
|
Array.isArray(preferences.provider_preference) &&
|
|
preferences.provider_preference.every((s) => typeof s === "string")
|
|
) {
|
|
const cleaned = preferences.provider_preference
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter((s) => s.length > 0);
|
|
if (cleaned.length > 0) validated.provider_preference = cleaned;
|
|
} else {
|
|
errors.push(
|
|
"provider_preference must be an array of provider-ID strings",
|
|
);
|
|
}
|
|
}
|
|
// ─── Allowed Providers (hard allowlist) ─────────────────────────────
|
|
// When set, model selection is gated to these providers only — any
|
|
// model from any other provider is filtered out of the candidate set
|
|
// before models.* resolution and dynamic routing. Case-insensitive.
|
|
if (preferences.allowed_providers !== undefined) {
|
|
if (Array.isArray(preferences.allowed_providers)) {
|
|
const allStrings = preferences.allowed_providers.every(
|
|
(s) => typeof s === "string",
|
|
);
|
|
if (allStrings) {
|
|
const cleaned = preferences.allowed_providers
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter((s) => s.length > 0);
|
|
if (cleaned.length > 0) validated.allowed_providers = cleaned;
|
|
} else {
|
|
errors.push(
|
|
"allowed_providers must be an array of strings (provider IDs)",
|
|
);
|
|
}
|
|
} else {
|
|
errors.push("allowed_providers must be an array of strings");
|
|
}
|
|
}
|
|
// ─── Blocked Providers (hard denylist) ──────────────────────────────
|
|
// Applied after allowed_providers; deny wins when both are configured.
|
|
if (preferences.blocked_providers !== undefined) {
|
|
if (Array.isArray(preferences.blocked_providers)) {
|
|
const allStrings = preferences.blocked_providers.every(
|
|
(s) => typeof s === "string",
|
|
);
|
|
if (allStrings) {
|
|
const cleaned = preferences.blocked_providers
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter((s) => s.length > 0);
|
|
if (cleaned.length > 0)
|
|
validated.blocked_providers = Array.from(new Set(cleaned));
|
|
} else {
|
|
errors.push(
|
|
"blocked_providers must be an array of strings (provider IDs)",
|
|
);
|
|
}
|
|
} else {
|
|
errors.push("blocked_providers must be an array of strings");
|
|
}
|
|
}
|
|
// ─── Per-provider model allow-list ──────────────────────────────────
|
|
// When a provider has an entry here, only listed model IDs are usable
|
|
// from that provider. Providers absent from the block are unrestricted.
|
|
if (preferences.provider_model_allow !== undefined) {
|
|
if (
|
|
preferences.provider_model_allow !== null &&
|
|
typeof preferences.provider_model_allow === "object" &&
|
|
!Array.isArray(preferences.provider_model_allow)
|
|
) {
|
|
const cleaned = {};
|
|
for (const [provider, models] of Object.entries(
|
|
preferences.provider_model_allow,
|
|
)) {
|
|
const providerId = provider.trim().toLowerCase();
|
|
if (!providerId) {
|
|
errors.push(
|
|
"provider_model_allow provider IDs must be non-empty strings",
|
|
);
|
|
continue;
|
|
}
|
|
if (
|
|
!Array.isArray(models) ||
|
|
models.some((m) => typeof m !== "string")
|
|
) {
|
|
errors.push(
|
|
`provider_model_allow.${provider} must be an array of model ID strings`,
|
|
);
|
|
continue;
|
|
}
|
|
const list = models.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
cleaned[providerId] = Array.from(new Set(list));
|
|
}
|
|
if (Object.keys(cleaned).length > 0)
|
|
validated.provider_model_allow = cleaned;
|
|
} else {
|
|
errors.push(
|
|
"provider_model_allow must be a map of provider → array of model IDs",
|
|
);
|
|
}
|
|
}
|
|
// ─── Per-provider model block-list ──────────────────────────────────
|
|
// Deny wins after provider_model_allow; matching models are never used.
|
|
if (preferences.provider_model_block !== undefined) {
|
|
if (
|
|
preferences.provider_model_block !== null &&
|
|
typeof preferences.provider_model_block === "object" &&
|
|
!Array.isArray(preferences.provider_model_block)
|
|
) {
|
|
const cleaned = {};
|
|
for (const [provider, models] of Object.entries(
|
|
preferences.provider_model_block,
|
|
)) {
|
|
const providerId = provider.trim().toLowerCase();
|
|
if (!providerId) {
|
|
errors.push(
|
|
"provider_model_block provider IDs must be non-empty strings",
|
|
);
|
|
continue;
|
|
}
|
|
if (
|
|
!Array.isArray(models) ||
|
|
models.some((m) => typeof m !== "string")
|
|
) {
|
|
errors.push(
|
|
`provider_model_block.${provider} must be an array of model ID strings`,
|
|
);
|
|
continue;
|
|
}
|
|
const list = models.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
cleaned[providerId] = Array.from(new Set(list));
|
|
}
|
|
if (Object.keys(cleaned).length > 0)
|
|
validated.provider_model_block = cleaned;
|
|
} else {
|
|
errors.push(
|
|
"provider_model_block must be a map of provider → array of model IDs",
|
|
);
|
|
}
|
|
}
|
|
// ─── Flat-rate Providers ────────────────────────────────────────────
|
|
// User-declared flat-rate providers for dynamic routing suppression.
|
|
// Built-in providers (github-copilot, copilot, claude-code) and any
|
|
// externalCli provider are already auto-detected; this list layers on
|
|
// top for private subscription proxies and custom CLI wrappers.
|
|
if (preferences.flat_rate_providers !== undefined) {
|
|
if (Array.isArray(preferences.flat_rate_providers)) {
|
|
const allStrings = preferences.flat_rate_providers.every(
|
|
(item) => typeof item === "string",
|
|
);
|
|
if (allStrings) {
|
|
// Strip empty/whitespace-only entries to avoid false matches.
|
|
validated.flat_rate_providers = preferences.flat_rate_providers
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
} else {
|
|
errors.push("flat_rate_providers must be an array of strings");
|
|
}
|
|
} else {
|
|
errors.push("flat_rate_providers must be an array of strings");
|
|
}
|
|
}
|
|
// ─── Shell Wrapper ───────────────────────────────────────────────────
|
|
if (preferences.shell_wrapper !== undefined) {
|
|
if (
|
|
Array.isArray(preferences.shell_wrapper) &&
|
|
preferences.shell_wrapper.every(
|
|
(s) => typeof s === "string" && s.length > 0,
|
|
)
|
|
) {
|
|
validated.shell_wrapper = preferences.shell_wrapper;
|
|
} else {
|
|
errors.push("shell_wrapper must be an array of non-empty strings");
|
|
}
|
|
}
|
|
// ─── Minimum Request Interval ───────────────────────────────────────
|
|
if (preferences.min_request_interval_ms !== undefined) {
|
|
const raw = Number(preferences.min_request_interval_ms);
|
|
if (Number.isFinite(raw) && raw >= 0) {
|
|
validated.min_request_interval_ms = Math.floor(raw);
|
|
} else {
|
|
errors.push(
|
|
"min_request_interval_ms must be a non-negative number (milliseconds; 0 = disabled)",
|
|
);
|
|
}
|
|
}
|
|
// ─── Workspace Lifecycle Hooks ───────────────────────────────────────
|
|
if (preferences.workspace !== undefined) {
|
|
if (
|
|
typeof preferences.workspace === "object" &&
|
|
preferences.workspace !== null
|
|
) {
|
|
const ws = preferences.workspace;
|
|
const validatedWs = {};
|
|
for (const key of ["after_create", "before_run", "after_run"]) {
|
|
if (ws[key] !== undefined) {
|
|
if (typeof ws[key] === "string") {
|
|
validatedWs[key] = ws[key];
|
|
} else {
|
|
errors.push(`workspace.${key} must be a string`);
|
|
}
|
|
}
|
|
}
|
|
validated.workspace = validatedWs;
|
|
} else {
|
|
errors.push("workspace must be an object");
|
|
}
|
|
}
|
|
// ─── Phase Skip Preferences ─────────────────────────────────────────
|
|
if (preferences.phases !== undefined) {
|
|
if (typeof preferences.phases === "object" && preferences.phases !== null) {
|
|
const validatedPhases = {};
|
|
const p = preferences.phases;
|
|
if (p.skip_research !== undefined)
|
|
validatedPhases.skip_research = !!p.skip_research;
|
|
if (p.skip_reassess !== undefined)
|
|
validatedPhases.skip_reassess = !!p.skip_reassess;
|
|
if (p.skip_slice_research !== undefined)
|
|
validatedPhases.skip_slice_research = !!p.skip_slice_research;
|
|
if (p.skip_milestone_validation !== undefined)
|
|
validatedPhases.skip_milestone_validation =
|
|
!!p.skip_milestone_validation;
|
|
if (p.reassess_after_slice !== undefined)
|
|
validatedPhases.reassess_after_slice = !!p.reassess_after_slice;
|
|
if (p.require_slice_discussion !== undefined)
|
|
validatedPhases.require_slice_discussion = !!p.require_slice_discussion;
|
|
if (p.mid_execution_escalation !== undefined)
|
|
validatedPhases.mid_execution_escalation = !!p.mid_execution_escalation;
|
|
if (p.progressive_planning !== undefined)
|
|
validatedPhases.progressive_planning = !!p.progressive_planning;
|
|
if (p.escalation_auto_accept !== undefined)
|
|
validatedPhases.escalation_auto_accept = !!p.escalation_auto_accept;
|
|
// Warn on unknown phase keys
|
|
const knownPhaseKeys = new Set([
|
|
"skip_research",
|
|
"skip_reassess",
|
|
"skip_slice_research",
|
|
"skip_milestone_validation",
|
|
"reassess_after_slice",
|
|
"require_slice_discussion",
|
|
"mid_execution_escalation",
|
|
"progressive_planning",
|
|
"escalation_auto_accept",
|
|
]);
|
|
for (const key of Object.keys(p)) {
|
|
if (!knownPhaseKeys.has(key)) {
|
|
warnings.push(`unknown phases key "${key}" — ignored`);
|
|
}
|
|
}
|
|
validated.phases = validatedPhases;
|
|
} else {
|
|
errors.push(`phases must be an object`);
|
|
}
|
|
}
|
|
// ─── Context Pause Threshold ────────────────────────────────────────
|
|
if (preferences.context_pause_threshold !== undefined) {
|
|
const raw = preferences.context_pause_threshold;
|
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
validated.context_pause_threshold = raw;
|
|
} else if (typeof raw === "string" && Number.isFinite(Number(raw))) {
|
|
validated.context_pause_threshold = Number(raw);
|
|
} else {
|
|
errors.push("context_pause_threshold must be a finite number");
|
|
}
|
|
}
|
|
// ─── Models ─────────────────────────────────────────────────────────
|
|
if (preferences.models !== undefined) {
|
|
if (preferences.models && typeof preferences.models === "object") {
|
|
validated.models = preferences.models;
|
|
} else {
|
|
errors.push("models must be an object");
|
|
}
|
|
}
|
|
// ─── Auto Supervisor ────────────────────────────────────────────────
|
|
if (preferences.auto_supervisor !== undefined) {
|
|
if (
|
|
preferences.auto_supervisor &&
|
|
typeof preferences.auto_supervisor === "object"
|
|
) {
|
|
const as = preferences.auto_supervisor;
|
|
const validatedAs = {};
|
|
if (as.model !== undefined) {
|
|
if (typeof as.model === "string") validatedAs.model = as.model;
|
|
else errors.push("auto_supervisor.model must be a string");
|
|
}
|
|
if (as.supervised_mode !== undefined) {
|
|
if (typeof as.supervised_mode === "boolean")
|
|
validatedAs.supervised_mode = as.supervised_mode;
|
|
else
|
|
errors.push(
|
|
"auto_supervisor.supervised_mode must be a boolean (true/false)",
|
|
);
|
|
}
|
|
if (as.runaway_guard_enabled !== undefined) {
|
|
if (typeof as.runaway_guard_enabled === "boolean") {
|
|
validatedAs.runaway_guard_enabled = as.runaway_guard_enabled;
|
|
} else {
|
|
errors.push(
|
|
"auto_supervisor.runaway_guard_enabled must be a boolean (true/false)",
|
|
);
|
|
}
|
|
}
|
|
if (as.runaway_hard_pause !== undefined) {
|
|
if (typeof as.runaway_hard_pause === "boolean") {
|
|
validatedAs.runaway_hard_pause = as.runaway_hard_pause;
|
|
} else {
|
|
errors.push(
|
|
"auto_supervisor.runaway_hard_pause must be a boolean (true/false)",
|
|
);
|
|
}
|
|
}
|
|
if (as.soft_timeout_minutes !== undefined) {
|
|
const val = Number(as.soft_timeout_minutes);
|
|
if (!Number.isNaN(val) && val >= 0)
|
|
validatedAs.soft_timeout_minutes = val;
|
|
else
|
|
errors.push(
|
|
"auto_supervisor.soft_timeout_minutes must be a non-negative number",
|
|
);
|
|
}
|
|
if (as.idle_timeout_minutes !== undefined) {
|
|
const val = Number(as.idle_timeout_minutes);
|
|
if (!Number.isNaN(val) && val >= 0)
|
|
validatedAs.idle_timeout_minutes = val;
|
|
else
|
|
errors.push(
|
|
"auto_supervisor.idle_timeout_minutes must be a non-negative number",
|
|
);
|
|
}
|
|
if (as.hard_timeout_minutes !== undefined) {
|
|
const val = Number(as.hard_timeout_minutes);
|
|
if (!Number.isNaN(val) && val >= 0)
|
|
validatedAs.hard_timeout_minutes = val;
|
|
else
|
|
errors.push(
|
|
"auto_supervisor.hard_timeout_minutes must be a non-negative number",
|
|
);
|
|
}
|
|
if (as.solver_max_iterations !== undefined) {
|
|
const val = Number(as.solver_max_iterations);
|
|
if (!Number.isNaN(val) && val >= 1 && val <= 100000) {
|
|
validatedAs.solver_max_iterations = Math.floor(val);
|
|
} else {
|
|
errors.push(
|
|
"auto_supervisor.solver_max_iterations must be a number from 1 to 100000",
|
|
);
|
|
}
|
|
}
|
|
if (as.solver_eval_on_autonomous_exit !== undefined) {
|
|
validatedAs.solver_eval_on_autonomous_exit =
|
|
!!as.solver_eval_on_autonomous_exit;
|
|
}
|
|
if (as.phase_timeout_minutes !== undefined) {
|
|
const val = Number(as.phase_timeout_minutes);
|
|
if (!Number.isNaN(val) && val >= 0)
|
|
validatedAs.phase_timeout_minutes = val;
|
|
else
|
|
errors.push(
|
|
"auto_supervisor.phase_timeout_minutes must be a non-negative number",
|
|
);
|
|
}
|
|
if (as.completion_nudge_after !== undefined) {
|
|
const val = Number(as.completion_nudge_after);
|
|
if (!Number.isNaN(val) && val >= 0)
|
|
validatedAs.completion_nudge_after = val;
|
|
else
|
|
errors.push(
|
|
"auto_supervisor.completion_nudge_after must be a non-negative number",
|
|
);
|
|
}
|
|
for (const key of [
|
|
"runaway_tool_call_warning",
|
|
"runaway_token_warning",
|
|
"runaway_elapsed_minutes",
|
|
"runaway_changed_files_warning",
|
|
"runaway_diagnostic_turns",
|
|
]) {
|
|
if (as[key] === undefined) continue;
|
|
const val = Number(as[key]);
|
|
if (!Number.isNaN(val) && val >= 0) {
|
|
validatedAs[key] = val;
|
|
} else {
|
|
errors.push(`auto_supervisor.${key} must be a non-negative number`);
|
|
}
|
|
}
|
|
validated.auto_supervisor = validatedAs;
|
|
} else {
|
|
errors.push("auto_supervisor must be an object");
|
|
}
|
|
}
|
|
// ─── Notifications ──────────────────────────────────────────────────
|
|
if (preferences.notifications !== undefined) {
|
|
if (
|
|
preferences.notifications &&
|
|
typeof preferences.notifications === "object"
|
|
) {
|
|
validated.notifications = preferences.notifications;
|
|
} else {
|
|
errors.push("notifications must be an object");
|
|
}
|
|
}
|
|
// ─── Cmux ───────────────────────────────────────────────────────────────
|
|
if (preferences.cmux !== undefined) {
|
|
if (preferences.cmux && typeof preferences.cmux === "object") {
|
|
const cmux = preferences.cmux;
|
|
const validatedCmux = {};
|
|
if (cmux.enabled !== undefined) validatedCmux.enabled = !!cmux.enabled;
|
|
if (cmux.notifications !== undefined)
|
|
validatedCmux.notifications = !!cmux.notifications;
|
|
if (cmux.sidebar !== undefined) validatedCmux.sidebar = !!cmux.sidebar;
|
|
if (cmux.splits !== undefined) validatedCmux.splits = !!cmux.splits;
|
|
if (cmux.browser !== undefined) validatedCmux.browser = !!cmux.browser;
|
|
const knownCmuxKeys = new Set([
|
|
"enabled",
|
|
"notifications",
|
|
"sidebar",
|
|
"splits",
|
|
"browser",
|
|
]);
|
|
for (const key of Object.keys(cmux)) {
|
|
if (!knownCmuxKeys.has(key)) {
|
|
warnings.push(`unknown cmux key "${key}" — ignored`);
|
|
}
|
|
}
|
|
if (Object.keys(validatedCmux).length > 0) {
|
|
validated.cmux = validatedCmux;
|
|
}
|
|
} else {
|
|
errors.push("cmux must be an object");
|
|
}
|
|
}
|
|
// ─── Remote Questions ───────────────────────────────────────────────
|
|
if (preferences.remote_questions !== undefined) {
|
|
if (
|
|
preferences.remote_questions &&
|
|
typeof preferences.remote_questions === "object"
|
|
) {
|
|
const rq = preferences.remote_questions;
|
|
const validRq = {
|
|
channel: rq.channel,
|
|
channel_id: rq.channel_id,
|
|
};
|
|
if (rq.timeout_minutes !== undefined) {
|
|
const timeout = Number(rq.timeout_minutes);
|
|
if (Number.isFinite(timeout)) validRq.timeout_minutes = timeout;
|
|
else errors.push("remote_questions.timeout_minutes must be a number");
|
|
}
|
|
if (rq.poll_interval_seconds !== undefined) {
|
|
const poll = Number(rq.poll_interval_seconds);
|
|
if (Number.isFinite(poll)) validRq.poll_interval_seconds = poll;
|
|
else
|
|
errors.push(
|
|
"remote_questions.poll_interval_seconds must be a number",
|
|
);
|
|
}
|
|
if (rq.allowed_user_ids !== undefined) {
|
|
if (Array.isArray(rq.allowed_user_ids)) {
|
|
const allowed = rq.allowed_user_ids
|
|
.map((id) => String(id).trim())
|
|
.filter((id) => /^-?\d{1,20}$/.test(id));
|
|
if (allowed.length === rq.allowed_user_ids.length) {
|
|
validRq.allowed_user_ids = allowed;
|
|
} else {
|
|
errors.push(
|
|
"remote_questions.allowed_user_ids must contain only Telegram numeric user IDs",
|
|
);
|
|
}
|
|
} else {
|
|
errors.push("remote_questions.allowed_user_ids must be an array");
|
|
}
|
|
}
|
|
if (rq.auto_resolve_on_timeout !== undefined) {
|
|
if (typeof rq.auto_resolve_on_timeout === "boolean") {
|
|
validRq.auto_resolve_on_timeout = rq.auto_resolve_on_timeout;
|
|
} else {
|
|
errors.push(
|
|
"remote_questions.auto_resolve_on_timeout must be a boolean",
|
|
);
|
|
}
|
|
}
|
|
if (rq.auto_resolve_strategy !== undefined) {
|
|
if (rq.auto_resolve_strategy === "recommended-option") {
|
|
validRq.auto_resolve_strategy = "recommended-option";
|
|
} else {
|
|
errors.push(
|
|
'remote_questions.auto_resolve_strategy must be "recommended-option"',
|
|
);
|
|
}
|
|
}
|
|
const knownRemoteKeys = new Set([
|
|
"channel",
|
|
"channel_id",
|
|
"allowed_user_ids",
|
|
"timeout_minutes",
|
|
"poll_interval_seconds",
|
|
"auto_resolve_on_timeout",
|
|
"auto_resolve_strategy",
|
|
]);
|
|
for (const key of Object.keys(rq)) {
|
|
if (!knownRemoteKeys.has(key)) {
|
|
warnings.push(`unknown remote_questions key "${key}" — ignored`);
|
|
}
|
|
}
|
|
validated.remote_questions = validRq;
|
|
} else {
|
|
errors.push("remote_questions must be an object");
|
|
}
|
|
}
|
|
// ─── Post-Unit Hooks ─────────────────────────────────────────────────
|
|
if (
|
|
preferences.post_unit_hooks &&
|
|
Array.isArray(preferences.post_unit_hooks)
|
|
) {
|
|
const validHooks = [];
|
|
const seenNames = new Set();
|
|
const knownUnitTypes = new Set(KNOWN_UNIT_TYPES);
|
|
for (const hook of preferences.post_unit_hooks) {
|
|
if (!hook || typeof hook !== "object") {
|
|
errors.push("post_unit_hooks entry must be an object");
|
|
continue;
|
|
}
|
|
const name = typeof hook.name === "string" ? hook.name.trim() : "";
|
|
if (!name) {
|
|
errors.push("post_unit_hooks entry missing name");
|
|
continue;
|
|
}
|
|
if (seenNames.has(name)) {
|
|
errors.push(`duplicate post_unit_hooks name: ${name}`);
|
|
continue;
|
|
}
|
|
const after = normalizeStringArray(hook.after);
|
|
if (after.length === 0) {
|
|
errors.push(`post_unit_hooks "${name}" missing after`);
|
|
continue;
|
|
}
|
|
for (const ut of after) {
|
|
if (!knownUnitTypes.has(ut)) {
|
|
errors.push(
|
|
`post_unit_hooks "${name}" unknown unit type in after: ${ut}`,
|
|
);
|
|
}
|
|
}
|
|
const prompt = typeof hook.prompt === "string" ? hook.prompt.trim() : "";
|
|
if (!prompt) {
|
|
errors.push(`post_unit_hooks "${name}" missing prompt`);
|
|
continue;
|
|
}
|
|
const validHook = { name, after, prompt };
|
|
if (hook.max_cycles !== undefined) {
|
|
const mc =
|
|
typeof hook.max_cycles === "number"
|
|
? hook.max_cycles
|
|
: Number(hook.max_cycles);
|
|
validHook.max_cycles = Number.isFinite(mc)
|
|
? Math.max(1, Math.min(10, Math.round(mc)))
|
|
: 1;
|
|
}
|
|
if (typeof hook.model === "string" && hook.model.trim()) {
|
|
validHook.model = hook.model.trim();
|
|
}
|
|
if (typeof hook.artifact === "string" && hook.artifact.trim()) {
|
|
validHook.artifact = hook.artifact.trim();
|
|
}
|
|
if (typeof hook.retry_on === "string" && hook.retry_on.trim()) {
|
|
validHook.retry_on = hook.retry_on.trim();
|
|
}
|
|
if (typeof hook.agent === "string" && hook.agent.trim()) {
|
|
validHook.agent = hook.agent.trim();
|
|
}
|
|
if (hook.enabled !== undefined) {
|
|
validHook.enabled = !!hook.enabled;
|
|
}
|
|
seenNames.add(name);
|
|
validHooks.push(validHook);
|
|
}
|
|
if (validHooks.length > 0) {
|
|
validated.post_unit_hooks = validHooks;
|
|
}
|
|
}
|
|
// ─── Pre-Dispatch Hooks ─────────────────────────────────────────────────
|
|
if (
|
|
preferences.pre_dispatch_hooks &&
|
|
Array.isArray(preferences.pre_dispatch_hooks)
|
|
) {
|
|
const validPreHooks = [];
|
|
const seenPreNames = new Set();
|
|
const knownUnitTypes = new Set(KNOWN_UNIT_TYPES);
|
|
const validActions = new Set(["modify", "skip", "replace"]);
|
|
for (const hook of preferences.pre_dispatch_hooks) {
|
|
if (!hook || typeof hook !== "object") {
|
|
errors.push("pre_dispatch_hooks entry must be an object");
|
|
continue;
|
|
}
|
|
const name = typeof hook.name === "string" ? hook.name.trim() : "";
|
|
if (!name) {
|
|
errors.push("pre_dispatch_hooks entry missing name");
|
|
continue;
|
|
}
|
|
if (seenPreNames.has(name)) {
|
|
errors.push(`duplicate pre_dispatch_hooks name: ${name}`);
|
|
continue;
|
|
}
|
|
const before = normalizeStringArray(hook.before);
|
|
if (before.length === 0) {
|
|
errors.push(`pre_dispatch_hooks "${name}" missing before`);
|
|
continue;
|
|
}
|
|
for (const ut of before) {
|
|
if (!knownUnitTypes.has(ut)) {
|
|
errors.push(
|
|
`pre_dispatch_hooks "${name}" unknown unit type in before: ${ut}`,
|
|
);
|
|
}
|
|
}
|
|
const action = typeof hook.action === "string" ? hook.action.trim() : "";
|
|
if (!validActions.has(action)) {
|
|
errors.push(
|
|
`pre_dispatch_hooks "${name}" invalid action: ${action} (must be modify, skip, or replace)`,
|
|
);
|
|
continue;
|
|
}
|
|
const validHook = {
|
|
name,
|
|
before,
|
|
action: action,
|
|
};
|
|
if (typeof hook.prepend === "string" && hook.prepend.trim())
|
|
validHook.prepend = hook.prepend.trim();
|
|
if (typeof hook.append === "string" && hook.append.trim())
|
|
validHook.append = hook.append.trim();
|
|
if (typeof hook.prompt === "string" && hook.prompt.trim())
|
|
validHook.prompt = hook.prompt.trim();
|
|
if (typeof hook.unit_type === "string" && hook.unit_type.trim())
|
|
validHook.unit_type = hook.unit_type.trim();
|
|
if (typeof hook.skip_if === "string" && hook.skip_if.trim())
|
|
validHook.skip_if = hook.skip_if.trim();
|
|
if (typeof hook.model === "string" && hook.model.trim())
|
|
validHook.model = hook.model.trim();
|
|
if (hook.enabled !== undefined) validHook.enabled = !!hook.enabled;
|
|
// Validation: action-specific required fields
|
|
if (action === "replace" && !validHook.prompt) {
|
|
errors.push(
|
|
`pre_dispatch_hooks "${name}" action "replace" requires prompt`,
|
|
);
|
|
continue;
|
|
}
|
|
if (action === "modify" && !validHook.prepend && !validHook.append) {
|
|
errors.push(
|
|
`pre_dispatch_hooks "${name}" action "modify" requires prepend or append`,
|
|
);
|
|
continue;
|
|
}
|
|
seenPreNames.add(name);
|
|
validPreHooks.push(validHook);
|
|
}
|
|
if (validPreHooks.length > 0) {
|
|
validated.pre_dispatch_hooks = validPreHooks;
|
|
}
|
|
}
|
|
// ─── Dynamic Routing ─────────────────────────────────────────────────
|
|
if (preferences.dynamic_routing !== undefined) {
|
|
if (
|
|
typeof preferences.dynamic_routing === "object" &&
|
|
preferences.dynamic_routing !== null
|
|
) {
|
|
const dr = preferences.dynamic_routing;
|
|
const validDr = {};
|
|
if (dr.enabled !== undefined) {
|
|
if (typeof dr.enabled === "boolean") validDr.enabled = dr.enabled;
|
|
else errors.push("dynamic_routing.enabled must be a boolean");
|
|
}
|
|
if (dr.escalate_on_failure !== undefined) {
|
|
if (typeof dr.escalate_on_failure === "boolean")
|
|
validDr.escalate_on_failure = dr.escalate_on_failure;
|
|
else
|
|
errors.push("dynamic_routing.escalate_on_failure must be a boolean");
|
|
}
|
|
if (dr.budget_pressure !== undefined) {
|
|
if (typeof dr.budget_pressure === "boolean")
|
|
validDr.budget_pressure = dr.budget_pressure;
|
|
else errors.push("dynamic_routing.budget_pressure must be a boolean");
|
|
}
|
|
if (dr.cross_provider !== undefined) {
|
|
if (typeof dr.cross_provider === "boolean")
|
|
validDr.cross_provider = dr.cross_provider;
|
|
else errors.push("dynamic_routing.cross_provider must be a boolean");
|
|
}
|
|
if (dr.hooks !== undefined) {
|
|
if (typeof dr.hooks === "boolean") validDr.hooks = dr.hooks;
|
|
else errors.push("dynamic_routing.hooks must be a boolean");
|
|
}
|
|
if (dr.capability_routing !== undefined) {
|
|
if (typeof dr.capability_routing === "boolean")
|
|
validDr.capability_routing = dr.capability_routing;
|
|
else
|
|
errors.push("dynamic_routing.capability_routing must be a boolean");
|
|
}
|
|
if (dr.tier_models !== undefined) {
|
|
if (typeof dr.tier_models === "object" && dr.tier_models !== null) {
|
|
const tm = dr.tier_models;
|
|
const validTm = {};
|
|
for (const tier of ["light", "standard", "heavy"]) {
|
|
if (tm[tier] !== undefined) {
|
|
if (typeof tm[tier] === "string") validTm[tier] = tm[tier];
|
|
else
|
|
errors.push(
|
|
`dynamic_routing.tier_models.${tier} must be a string`,
|
|
);
|
|
}
|
|
}
|
|
if (Object.keys(validTm).length > 0) validDr.tier_models = validTm;
|
|
} else {
|
|
errors.push("dynamic_routing.tier_models must be an object");
|
|
}
|
|
}
|
|
if (Object.keys(validDr).length > 0) {
|
|
validated.dynamic_routing = validDr;
|
|
}
|
|
} else {
|
|
errors.push("dynamic_routing must be an object");
|
|
}
|
|
}
|
|
// ─── Context Management ──────────────────────────────────────────────
|
|
if (preferences.context_management !== undefined) {
|
|
if (
|
|
typeof preferences.context_management === "object" &&
|
|
preferences.context_management !== null
|
|
) {
|
|
const cm = preferences.context_management;
|
|
const validCm = {};
|
|
if (cm.observation_masking !== undefined) {
|
|
if (typeof cm.observation_masking === "boolean")
|
|
validCm.observation_masking = cm.observation_masking;
|
|
else
|
|
errors.push(
|
|
"context_management.observation_masking must be a boolean",
|
|
);
|
|
}
|
|
if (cm.observation_mask_turns !== undefined) {
|
|
const turns = cm.observation_mask_turns;
|
|
if (typeof turns === "number" && turns >= 1 && turns <= 50)
|
|
validCm.observation_mask_turns = turns;
|
|
else
|
|
errors.push(
|
|
"context_management.observation_mask_turns must be a number between 1 and 50",
|
|
);
|
|
}
|
|
if (cm.compaction_threshold_percent !== undefined) {
|
|
const pct = cm.compaction_threshold_percent;
|
|
if (typeof pct === "number" && pct >= 0.5 && pct <= 0.95)
|
|
validCm.compaction_threshold_percent = pct;
|
|
else
|
|
errors.push(
|
|
"context_management.compaction_threshold_percent must be a number between 0.5 and 0.95",
|
|
);
|
|
}
|
|
if (cm.tool_result_max_chars !== undefined) {
|
|
const chars = cm.tool_result_max_chars;
|
|
if (typeof chars === "number" && chars >= 200 && chars <= 10000)
|
|
validCm.tool_result_max_chars = chars;
|
|
else
|
|
errors.push(
|
|
"context_management.tool_result_max_chars must be a number between 200 and 10000",
|
|
);
|
|
}
|
|
if (Object.keys(validCm).length > 0) {
|
|
validated.context_management = validCm;
|
|
}
|
|
} else {
|
|
errors.push("context_management must be an object");
|
|
}
|
|
}
|
|
// ─── Parallel Config ────────────────────────────────────────────────────
|
|
if (preferences.parallel && typeof preferences.parallel === "object") {
|
|
const p = preferences.parallel;
|
|
const parallel = {};
|
|
if (p.enabled !== undefined) {
|
|
if (typeof p.enabled === "boolean") parallel.enabled = p.enabled;
|
|
else errors.push("parallel.enabled must be a boolean");
|
|
}
|
|
if (p.max_workers !== undefined) {
|
|
if (
|
|
typeof p.max_workers === "number" &&
|
|
p.max_workers >= 1 &&
|
|
p.max_workers <= 4
|
|
) {
|
|
parallel.max_workers = Math.floor(p.max_workers);
|
|
} else {
|
|
errors.push("parallel.max_workers must be a number between 1 and 4");
|
|
}
|
|
}
|
|
if (p.budget_ceiling !== undefined) {
|
|
if (typeof p.budget_ceiling === "number" && p.budget_ceiling > 0) {
|
|
parallel.budget_ceiling = p.budget_ceiling;
|
|
} else {
|
|
errors.push("parallel.budget_ceiling must be a positive number");
|
|
}
|
|
}
|
|
if (p.merge_strategy !== undefined) {
|
|
const validStrategies = new Set(["per-slice", "per-milestone"]);
|
|
if (
|
|
typeof p.merge_strategy === "string" &&
|
|
validStrategies.has(p.merge_strategy)
|
|
) {
|
|
parallel.merge_strategy = p.merge_strategy;
|
|
} else {
|
|
errors.push(
|
|
"parallel.merge_strategy must be one of: per-slice, per-milestone",
|
|
);
|
|
}
|
|
}
|
|
if (p.auto_merge !== undefined) {
|
|
const validModes = new Set(["auto", "confirm", "manual"]);
|
|
if (typeof p.auto_merge === "string" && validModes.has(p.auto_merge)) {
|
|
parallel.auto_merge = p.auto_merge;
|
|
} else {
|
|
errors.push(
|
|
"parallel.auto_merge must be one of: auto, confirm, manual",
|
|
);
|
|
}
|
|
}
|
|
if (p.worker_model !== undefined) {
|
|
if (typeof p.worker_model === "string" && p.worker_model.length > 0) {
|
|
parallel.worker_model = p.worker_model;
|
|
} else {
|
|
errors.push("parallel.worker_model must be a non-empty string");
|
|
}
|
|
}
|
|
if (Object.keys(parallel).length > 0) {
|
|
validated.parallel = parallel;
|
|
}
|
|
}
|
|
// ─── Reactive Execution ─────────────────────────────────────────────────
|
|
if (preferences.reactive_execution !== undefined) {
|
|
if (
|
|
typeof preferences.reactive_execution === "object" &&
|
|
preferences.reactive_execution !== null
|
|
) {
|
|
const re = preferences.reactive_execution;
|
|
const validRe = {};
|
|
if (re.enabled !== undefined) {
|
|
if (typeof re.enabled === "boolean") validRe.enabled = re.enabled;
|
|
else errors.push("reactive_execution.enabled must be a boolean");
|
|
}
|
|
if (re.max_parallel !== undefined) {
|
|
const mp =
|
|
typeof re.max_parallel === "number"
|
|
? re.max_parallel
|
|
: Number(re.max_parallel);
|
|
if (Number.isFinite(mp) && mp >= 1 && mp <= 8) {
|
|
validRe.max_parallel = Math.floor(mp);
|
|
} else {
|
|
errors.push(
|
|
"reactive_execution.max_parallel must be a number between 1 and 8",
|
|
);
|
|
}
|
|
}
|
|
if (re.isolation_mode !== undefined) {
|
|
if (re.isolation_mode === "same-tree") {
|
|
validRe.isolation_mode = "same-tree";
|
|
} else {
|
|
errors.push('reactive_execution.isolation_mode must be "same-tree"');
|
|
}
|
|
}
|
|
if (re.subagent_model !== undefined) {
|
|
if (
|
|
typeof re.subagent_model === "string" &&
|
|
re.subagent_model.length > 0
|
|
) {
|
|
validRe.subagent_model = re.subagent_model;
|
|
} else {
|
|
errors.push(
|
|
"reactive_execution.subagent_model must be a non-empty string",
|
|
);
|
|
}
|
|
}
|
|
const knownReKeys = new Set([
|
|
"enabled",
|
|
"max_parallel",
|
|
"isolation_mode",
|
|
"subagent_model",
|
|
]);
|
|
for (const key of Object.keys(re)) {
|
|
if (!knownReKeys.has(key)) {
|
|
warnings.push(`unknown reactive_execution key "${key}" — ignored`);
|
|
}
|
|
}
|
|
if (Object.keys(validRe).length > 0) {
|
|
validated.reactive_execution = validRe;
|
|
}
|
|
} else {
|
|
errors.push("reactive_execution must be an object");
|
|
}
|
|
}
|
|
// ─── Gate Evaluation ─────────────────────────────────────────────────────
|
|
if (preferences.gate_evaluation !== undefined) {
|
|
if (
|
|
typeof preferences.gate_evaluation === "object" &&
|
|
preferences.gate_evaluation !== null
|
|
) {
|
|
const ge = preferences.gate_evaluation;
|
|
const validGe = {};
|
|
if (ge.enabled !== undefined) {
|
|
if (typeof ge.enabled === "boolean") validGe.enabled = ge.enabled;
|
|
else errors.push("gate_evaluation.enabled must be a boolean");
|
|
}
|
|
if (ge.slice_gates !== undefined) {
|
|
if (
|
|
Array.isArray(ge.slice_gates) &&
|
|
ge.slice_gates.every((g) => typeof g === "string")
|
|
) {
|
|
validGe.slice_gates = ge.slice_gates;
|
|
} else {
|
|
errors.push(
|
|
"gate_evaluation.slice_gates must be an array of strings",
|
|
);
|
|
}
|
|
}
|
|
if (ge.task_gates !== undefined) {
|
|
if (typeof ge.task_gates === "boolean")
|
|
validGe.task_gates = ge.task_gates;
|
|
else errors.push("gate_evaluation.task_gates must be a boolean");
|
|
}
|
|
const knownGeKeys = new Set(["enabled", "slice_gates", "task_gates"]);
|
|
for (const key of Object.keys(ge)) {
|
|
if (!knownGeKeys.has(key)) {
|
|
warnings.push(`unknown gate_evaluation key "${key}" — ignored`);
|
|
}
|
|
}
|
|
if (Object.keys(validGe).length > 0) {
|
|
validated.gate_evaluation = validGe;
|
|
}
|
|
} else {
|
|
errors.push("gate_evaluation must be an object");
|
|
}
|
|
}
|
|
// ─── Verification Preferences ───────────────────────────────────────────
|
|
if (preferences.verification_commands !== undefined) {
|
|
if (Array.isArray(preferences.verification_commands)) {
|
|
const allStrings = preferences.verification_commands.every(
|
|
(item) => typeof item === "string",
|
|
);
|
|
if (allStrings) {
|
|
validated.verification_commands = preferences.verification_commands;
|
|
} else {
|
|
errors.push("verification_commands must be an array of strings");
|
|
}
|
|
} else {
|
|
errors.push("verification_commands must be an array of strings");
|
|
}
|
|
}
|
|
if (preferences.verification_auto_fix !== undefined) {
|
|
if (typeof preferences.verification_auto_fix === "boolean") {
|
|
validated.verification_auto_fix = preferences.verification_auto_fix;
|
|
} else {
|
|
errors.push("verification_auto_fix must be a boolean");
|
|
}
|
|
}
|
|
if (preferences.verification_max_retries !== undefined) {
|
|
const raw = preferences.verification_max_retries;
|
|
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
|
|
validated.verification_max_retries = Math.floor(raw);
|
|
} else {
|
|
errors.push("verification_max_retries must be a non-negative number");
|
|
}
|
|
}
|
|
// ─── Git Preferences ───────────────────────────────────────────────────
|
|
if (preferences.git && typeof preferences.git === "object") {
|
|
const git = {};
|
|
const g = preferences.git;
|
|
if (g.auto_push !== undefined) {
|
|
if (typeof g.auto_push === "boolean") git.auto_push = g.auto_push;
|
|
else errors.push("git.auto_push must be a boolean");
|
|
}
|
|
if (g.push_branches !== undefined) {
|
|
if (typeof g.push_branches === "boolean")
|
|
git.push_branches = g.push_branches;
|
|
else errors.push("git.push_branches must be a boolean");
|
|
}
|
|
if (g.remote !== undefined) {
|
|
if (typeof g.remote === "string" && g.remote.trim() !== "")
|
|
git.remote = g.remote.trim();
|
|
else errors.push("git.remote must be a non-empty string");
|
|
}
|
|
if (g.snapshots !== undefined) {
|
|
if (typeof g.snapshots === "boolean") git.snapshots = g.snapshots;
|
|
else errors.push("git.snapshots must be a boolean");
|
|
}
|
|
if (g.pre_merge_check !== undefined) {
|
|
if (typeof g.pre_merge_check === "boolean") {
|
|
git.pre_merge_check = g.pre_merge_check;
|
|
} else if (
|
|
typeof g.pre_merge_check === "string" &&
|
|
g.pre_merge_check.trim() !== ""
|
|
) {
|
|
git.pre_merge_check = g.pre_merge_check.trim();
|
|
} else {
|
|
errors.push(
|
|
"git.pre_merge_check must be a boolean or a non-empty string command",
|
|
);
|
|
}
|
|
}
|
|
if (g.commit_type !== undefined) {
|
|
const validCommitTypes = new Set([
|
|
"feat",
|
|
"fix",
|
|
"refactor",
|
|
"docs",
|
|
"test",
|
|
"chore",
|
|
"perf",
|
|
"ci",
|
|
"build",
|
|
"style",
|
|
]);
|
|
if (
|
|
typeof g.commit_type === "string" &&
|
|
validCommitTypes.has(g.commit_type)
|
|
) {
|
|
git.commit_type = g.commit_type;
|
|
} else {
|
|
errors.push(
|
|
`git.commit_type must be one of: feat, fix, refactor, docs, test, chore, perf, ci, build, style`,
|
|
);
|
|
}
|
|
}
|
|
if (g.merge_strategy !== undefined) {
|
|
const validStrategies = new Set(["squash", "merge"]);
|
|
if (
|
|
typeof g.merge_strategy === "string" &&
|
|
validStrategies.has(g.merge_strategy)
|
|
) {
|
|
git.merge_strategy = g.merge_strategy;
|
|
} else {
|
|
errors.push("git.merge_strategy must be one of: squash, merge");
|
|
}
|
|
}
|
|
if (g.main_branch !== undefined) {
|
|
if (
|
|
typeof g.main_branch === "string" &&
|
|
g.main_branch.trim() !== "" &&
|
|
VALID_BRANCH_NAME.test(g.main_branch)
|
|
) {
|
|
git.main_branch = g.main_branch;
|
|
} else {
|
|
errors.push(
|
|
"git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)",
|
|
);
|
|
}
|
|
}
|
|
if (g.isolation !== undefined) {
|
|
const validIsolation = new Set(["worktree", "branch", "none"]);
|
|
if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) {
|
|
git.isolation = g.isolation;
|
|
} else {
|
|
errors.push("git.isolation must be one of: worktree, branch, none");
|
|
}
|
|
}
|
|
if (g.commit_docs !== undefined) {
|
|
warnings.push(
|
|
"git.commit_docs is deprecated — .sf/ is managed externally and always gitignored. Remove this setting.",
|
|
);
|
|
}
|
|
if (g.manage_gitignore !== undefined) {
|
|
if (typeof g.manage_gitignore === "boolean")
|
|
git.manage_gitignore = g.manage_gitignore;
|
|
else errors.push("git.manage_gitignore must be a boolean");
|
|
}
|
|
if (g.worktree_post_create !== undefined) {
|
|
if (
|
|
typeof g.worktree_post_create === "string" &&
|
|
g.worktree_post_create.trim()
|
|
) {
|
|
git.worktree_post_create = g.worktree_post_create.trim();
|
|
} else {
|
|
errors.push(
|
|
"git.worktree_post_create must be a non-empty string (path to script)",
|
|
);
|
|
}
|
|
}
|
|
if (g.auto_pr !== undefined) {
|
|
if (typeof g.auto_pr === "boolean") git.auto_pr = g.auto_pr;
|
|
else errors.push("git.auto_pr must be a boolean");
|
|
}
|
|
if (g.pr_target_branch !== undefined) {
|
|
if (typeof g.pr_target_branch === "string" && g.pr_target_branch.trim()) {
|
|
git.pr_target_branch = g.pr_target_branch.trim();
|
|
} else {
|
|
errors.push(
|
|
"git.pr_target_branch must be a non-empty string (branch name)",
|
|
);
|
|
}
|
|
}
|
|
// Deprecated: merge_to_main is ignored (branchless architecture).
|
|
if (g.merge_to_main !== undefined) {
|
|
warnings.push(
|
|
"git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting.",
|
|
);
|
|
}
|
|
// #4765 — collapse cadence + milestone resquash
|
|
if (g.collapse_cadence !== undefined) {
|
|
const validCadence = new Set(["milestone", "slice"]);
|
|
if (
|
|
typeof g.collapse_cadence === "string" &&
|
|
validCadence.has(g.collapse_cadence)
|
|
) {
|
|
git.collapse_cadence = g.collapse_cadence;
|
|
} else {
|
|
errors.push("git.collapse_cadence must be one of: milestone, slice");
|
|
}
|
|
}
|
|
if (g.milestone_resquash !== undefined) {
|
|
if (typeof g.milestone_resquash === "boolean") {
|
|
git.milestone_resquash = g.milestone_resquash;
|
|
const cadence =
|
|
git.collapse_cadence ??
|
|
(typeof g.collapse_cadence === "string"
|
|
? g.collapse_cadence
|
|
: undefined);
|
|
if (cadence !== "slice") {
|
|
warnings.push(
|
|
'git.milestone_resquash is ignored unless git.collapse_cadence is "slice"',
|
|
);
|
|
}
|
|
} else {
|
|
errors.push("git.milestone_resquash must be a boolean");
|
|
}
|
|
}
|
|
if (Object.keys(git).length > 0) {
|
|
validated.git = git;
|
|
}
|
|
}
|
|
// ─── Auto Visualize ─────────────────────────────────────────────────
|
|
if (preferences.auto_visualize !== undefined) {
|
|
if (typeof preferences.auto_visualize === "boolean") {
|
|
validated.auto_visualize = preferences.auto_visualize;
|
|
} else {
|
|
errors.push("auto_visualize must be a boolean");
|
|
}
|
|
}
|
|
// ─── Auto Report ────────────────────────────────────────────────────
|
|
if (preferences.auto_report !== undefined) {
|
|
if (typeof preferences.auto_report === "boolean") {
|
|
validated.auto_report = preferences.auto_report;
|
|
} else {
|
|
errors.push("auto_report must be a boolean");
|
|
}
|
|
}
|
|
// ─── Context Selection ──────────────────────────────────────────────
|
|
if (preferences.context_selection !== undefined) {
|
|
const validModes = new Set(["full", "smart"]);
|
|
if (
|
|
typeof preferences.context_selection === "string" &&
|
|
validModes.has(preferences.context_selection)
|
|
) {
|
|
validated.context_selection = preferences.context_selection;
|
|
} else {
|
|
errors.push(`context_selection must be one of: full, smart`);
|
|
}
|
|
}
|
|
// ─── GitHub Sync ────────────────────────────────────────────────────────
|
|
if (preferences.github !== undefined) {
|
|
if (typeof preferences.github === "object" && preferences.github !== null) {
|
|
const gh = preferences.github;
|
|
const validGh = {};
|
|
if (gh.enabled !== undefined) {
|
|
if (typeof gh.enabled === "boolean") validGh.enabled = gh.enabled;
|
|
else errors.push("github.enabled must be a boolean");
|
|
}
|
|
if (gh.repo !== undefined) {
|
|
if (typeof gh.repo === "string" && gh.repo.includes("/"))
|
|
validGh.repo = gh.repo;
|
|
else errors.push('github.repo must be a string in "owner/repo" format');
|
|
}
|
|
if (gh.project !== undefined) {
|
|
const p =
|
|
typeof gh.project === "number" ? gh.project : Number(gh.project);
|
|
if (Number.isFinite(p) && p > 0) validGh.project = Math.floor(p);
|
|
else errors.push("github.project must be a positive number");
|
|
}
|
|
if (gh.labels !== undefined) {
|
|
if (
|
|
Array.isArray(gh.labels) &&
|
|
gh.labels.every((l) => typeof l === "string")
|
|
) {
|
|
validGh.labels = gh.labels;
|
|
} else {
|
|
errors.push("github.labels must be an array of strings");
|
|
}
|
|
}
|
|
if (gh.auto_link_commits !== undefined) {
|
|
if (typeof gh.auto_link_commits === "boolean")
|
|
validGh.auto_link_commits = gh.auto_link_commits;
|
|
else errors.push("github.auto_link_commits must be a boolean");
|
|
}
|
|
if (gh.slice_prs !== undefined) {
|
|
if (typeof gh.slice_prs === "boolean") validGh.slice_prs = gh.slice_prs;
|
|
else errors.push("github.slice_prs must be a boolean");
|
|
}
|
|
const knownGhKeys = new Set([
|
|
"enabled",
|
|
"repo",
|
|
"project",
|
|
"labels",
|
|
"auto_link_commits",
|
|
"slice_prs",
|
|
]);
|
|
for (const key of Object.keys(gh)) {
|
|
if (!knownGhKeys.has(key)) {
|
|
warnings.push(`unknown github key "${key}" — ignored`);
|
|
}
|
|
}
|
|
if (Object.keys(validGh).length > 0) {
|
|
validated.github = validGh;
|
|
}
|
|
} else {
|
|
errors.push("github must be an object");
|
|
}
|
|
}
|
|
// ─── Show Token Cost ──────────────────────────────────────────────
|
|
if (preferences.show_token_cost !== undefined) {
|
|
if (typeof preferences.show_token_cost === "boolean") {
|
|
validated.show_token_cost = preferences.show_token_cost;
|
|
} else {
|
|
errors.push("show_token_cost must be a boolean");
|
|
}
|
|
}
|
|
// ─── Experimental Features ────────────────────────────────────────
|
|
if (preferences.experimental !== undefined) {
|
|
if (
|
|
typeof preferences.experimental === "object" &&
|
|
preferences.experimental !== null
|
|
) {
|
|
const exp = preferences.experimental;
|
|
const validExp = {};
|
|
if (exp.rtk !== undefined) {
|
|
if (typeof exp.rtk === "boolean") validExp.rtk = exp.rtk;
|
|
else errors.push("experimental.rtk must be a boolean");
|
|
}
|
|
if (exp.dispatch_rules !== undefined) {
|
|
if (
|
|
typeof exp.dispatch_rules === "object" &&
|
|
exp.dispatch_rules !== null
|
|
) {
|
|
const rawDispatch = exp.dispatch_rules;
|
|
const validDispatch = {};
|
|
if (rawDispatch.order !== undefined) {
|
|
if (
|
|
Array.isArray(rawDispatch.order) &&
|
|
rawDispatch.order.every((item) => typeof item === "string")
|
|
) {
|
|
validDispatch.order = rawDispatch.order
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.length > 0);
|
|
} else {
|
|
errors.push(
|
|
"experimental.dispatch_rules.order must be an array of strings",
|
|
);
|
|
}
|
|
}
|
|
if (rawDispatch.variants !== undefined) {
|
|
if (
|
|
typeof rawDispatch.variants === "object" &&
|
|
rawDispatch.variants !== null &&
|
|
!Array.isArray(rawDispatch.variants)
|
|
) {
|
|
const validVariants = {};
|
|
for (const [variantName, variantOrder] of Object.entries(
|
|
rawDispatch.variants,
|
|
)) {
|
|
if (
|
|
!Array.isArray(variantOrder) ||
|
|
variantOrder.some((item) => typeof item !== "string")
|
|
) {
|
|
errors.push(
|
|
`experimental.dispatch_rules.variants.${variantName} must be an array of strings`,
|
|
);
|
|
continue;
|
|
}
|
|
validVariants[variantName] = variantOrder
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.length > 0);
|
|
}
|
|
validDispatch.variants = validVariants;
|
|
} else {
|
|
errors.push(
|
|
"experimental.dispatch_rules.variants must be an object mapping variant names to string arrays",
|
|
);
|
|
}
|
|
}
|
|
if (rawDispatch.active_variant !== undefined) {
|
|
if (
|
|
typeof rawDispatch.active_variant === "string" &&
|
|
rawDispatch.active_variant.trim().length > 0
|
|
) {
|
|
validDispatch.active_variant = rawDispatch.active_variant.trim();
|
|
} else {
|
|
errors.push(
|
|
"experimental.dispatch_rules.active_variant must be a non-empty string",
|
|
);
|
|
}
|
|
}
|
|
const knownDispatchKeys = new Set([
|
|
"order",
|
|
"variants",
|
|
"active_variant",
|
|
]);
|
|
for (const key of Object.keys(rawDispatch)) {
|
|
if (!knownDispatchKeys.has(key)) {
|
|
warnings.push(
|
|
`unknown experimental.dispatch_rules key "${key}" — ignored`,
|
|
);
|
|
}
|
|
}
|
|
if (Object.keys(validDispatch).length > 0) {
|
|
validExp.dispatch_rules = validDispatch;
|
|
}
|
|
} else {
|
|
errors.push("experimental.dispatch_rules must be an object");
|
|
}
|
|
}
|
|
const knownExpKeys = new Set(["rtk", "dispatch_rules"]);
|
|
for (const key of Object.keys(exp)) {
|
|
if (!knownExpKeys.has(key)) {
|
|
warnings.push(`unknown experimental key "${key}" — ignored`);
|
|
}
|
|
}
|
|
if (Object.keys(validExp).length > 0) {
|
|
validated.experimental = validExp;
|
|
}
|
|
} else {
|
|
errors.push("experimental must be an object");
|
|
}
|
|
}
|
|
// ─── Codebase Map ──────────────────────────────────────────────────
|
|
if (preferences.codebase !== undefined) {
|
|
if (
|
|
typeof preferences.codebase === "object" &&
|
|
preferences.codebase !== null
|
|
) {
|
|
const cb = preferences.codebase;
|
|
const validCb = {};
|
|
if (cb.exclude_patterns !== undefined) {
|
|
if (
|
|
Array.isArray(cb.exclude_patterns) &&
|
|
cb.exclude_patterns.every((p) => typeof p === "string")
|
|
) {
|
|
validCb.exclude_patterns = cb.exclude_patterns;
|
|
} else {
|
|
errors.push("codebase.exclude_patterns must be an array of strings");
|
|
}
|
|
}
|
|
if (cb.max_files !== undefined) {
|
|
const mf =
|
|
typeof cb.max_files === "number"
|
|
? cb.max_files
|
|
: Number(cb.max_files);
|
|
if (Number.isFinite(mf) && mf >= 1) {
|
|
validCb.max_files = Math.floor(mf);
|
|
} else {
|
|
errors.push("codebase.max_files must be a positive integer");
|
|
}
|
|
}
|
|
if (cb.collapse_threshold !== undefined) {
|
|
const ct =
|
|
typeof cb.collapse_threshold === "number"
|
|
? cb.collapse_threshold
|
|
: Number(cb.collapse_threshold);
|
|
if (Number.isFinite(ct) && ct >= 1) {
|
|
validCb.collapse_threshold = Math.floor(ct);
|
|
} else {
|
|
errors.push("codebase.collapse_threshold must be a positive integer");
|
|
}
|
|
}
|
|
if (cb.indexer_backend !== undefined) {
|
|
if (cb.indexer_backend === "sift" || cb.indexer_backend === "none") {
|
|
validCb.indexer_backend = cb.indexer_backend;
|
|
} else {
|
|
errors.push(
|
|
'codebase.indexer_backend must be one of "sift" or "none"',
|
|
);
|
|
}
|
|
}
|
|
const knownCbKeys = new Set([
|
|
"exclude_patterns",
|
|
"max_files",
|
|
"collapse_threshold",
|
|
"indexer_backend",
|
|
]);
|
|
for (const key of Object.keys(cb)) {
|
|
if (!knownCbKeys.has(key)) {
|
|
warnings.push(`unknown codebase key "${key}" — ignored`);
|
|
}
|
|
}
|
|
if (Object.keys(validCb).length > 0) {
|
|
validated.codebase = validCb;
|
|
}
|
|
} else {
|
|
errors.push("codebase must be an object");
|
|
}
|
|
}
|
|
// ─── Enhanced Verification ──────────────────────────────────────────────────
|
|
if (preferences.enhanced_verification !== undefined) {
|
|
if (typeof preferences.enhanced_verification === "boolean") {
|
|
validated.enhanced_verification = preferences.enhanced_verification;
|
|
} else {
|
|
errors.push("enhanced_verification must be a boolean");
|
|
}
|
|
}
|
|
if (preferences.enhanced_verification_pre !== undefined) {
|
|
if (typeof preferences.enhanced_verification_pre === "boolean") {
|
|
validated.enhanced_verification_pre =
|
|
preferences.enhanced_verification_pre;
|
|
} else {
|
|
errors.push("enhanced_verification_pre must be a boolean");
|
|
}
|
|
}
|
|
if (preferences.enhanced_verification_post !== undefined) {
|
|
if (typeof preferences.enhanced_verification_post === "boolean") {
|
|
validated.enhanced_verification_post =
|
|
preferences.enhanced_verification_post;
|
|
} else {
|
|
errors.push("enhanced_verification_post must be a boolean");
|
|
}
|
|
}
|
|
if (preferences.enhanced_verification_strict !== undefined) {
|
|
if (typeof preferences.enhanced_verification_strict === "boolean") {
|
|
validated.enhanced_verification_strict =
|
|
preferences.enhanced_verification_strict;
|
|
} else {
|
|
errors.push("enhanced_verification_strict must be a boolean");
|
|
}
|
|
}
|
|
// ─── Discuss Preparation ────────────────────────────────────────────
|
|
if (preferences.discuss_preparation !== undefined) {
|
|
if (typeof preferences.discuss_preparation === "boolean") {
|
|
validated.discuss_preparation = preferences.discuss_preparation;
|
|
} else {
|
|
errors.push("discuss_preparation must be a boolean");
|
|
}
|
|
}
|
|
// ─── Discuss Web Research ───────────────────────────────────────────
|
|
if (preferences.discuss_web_research !== undefined) {
|
|
if (typeof preferences.discuss_web_research === "boolean") {
|
|
validated.discuss_web_research = preferences.discuss_web_research;
|
|
} else {
|
|
errors.push("discuss_web_research must be a boolean");
|
|
}
|
|
}
|
|
// ─── Discuss Depth ──────────────────────────────────────────────────
|
|
if (preferences.discuss_depth !== undefined) {
|
|
const validDepths = new Set(["quick", "standard", "thorough"]);
|
|
if (
|
|
typeof preferences.discuss_depth === "string" &&
|
|
validDepths.has(preferences.discuss_depth)
|
|
) {
|
|
validated.discuss_depth = preferences.discuss_depth;
|
|
} else {
|
|
errors.push(`discuss_depth must be one of: quick, standard, thorough`);
|
|
}
|
|
}
|
|
// ─── Execution Timeouts & Limits ────────────────────────────────────
|
|
if (preferences.context_compact_at !== undefined) {
|
|
const tokens = Number(preferences.context_compact_at);
|
|
if (Number.isFinite(tokens) && tokens > 0) {
|
|
validated.context_compact_at = Math.floor(tokens);
|
|
} else {
|
|
errors.push("context_compact_at must be a positive number");
|
|
}
|
|
}
|
|
if (preferences.context_hard_limit !== undefined) {
|
|
const tokens = Number(preferences.context_hard_limit);
|
|
if (Number.isFinite(tokens) && tokens > 0) {
|
|
validated.context_hard_limit = Math.floor(tokens);
|
|
} else {
|
|
errors.push("context_hard_limit must be a positive number");
|
|
}
|
|
}
|
|
if (preferences.unit_timeout !== undefined) {
|
|
const seconds = Number(preferences.unit_timeout);
|
|
if (Number.isFinite(seconds) && seconds > 0) {
|
|
validated.unit_timeout = Math.floor(seconds);
|
|
} else {
|
|
errors.push("unit_timeout must be a positive number (seconds)");
|
|
}
|
|
}
|
|
if (preferences.unit_timeout_by_phase !== undefined) {
|
|
if (
|
|
typeof preferences.unit_timeout_by_phase === "object" &&
|
|
preferences.unit_timeout_by_phase !== null
|
|
) {
|
|
const validatedPhaseTimeouts = {};
|
|
for (const [phase, timeout] of Object.entries(
|
|
preferences.unit_timeout_by_phase,
|
|
)) {
|
|
const seconds = Number(timeout);
|
|
if (Number.isFinite(seconds) && seconds > 0) {
|
|
validatedPhaseTimeouts[phase] = Math.floor(seconds);
|
|
} else {
|
|
errors.push(
|
|
`unit_timeout_by_phase.${phase} must be a positive number (seconds)`,
|
|
);
|
|
}
|
|
}
|
|
if (Object.keys(validatedPhaseTimeouts).length > 0) {
|
|
validated.unit_timeout_by_phase = validatedPhaseTimeouts;
|
|
}
|
|
} else {
|
|
errors.push(
|
|
"unit_timeout_by_phase must be an object mapping phases to seconds",
|
|
);
|
|
}
|
|
}
|
|
if (preferences.max_agents_by_phase !== undefined) {
|
|
if (
|
|
typeof preferences.max_agents_by_phase === "object" &&
|
|
preferences.max_agents_by_phase !== null
|
|
) {
|
|
const validatedAgents = {};
|
|
for (const [phase, count] of Object.entries(
|
|
preferences.max_agents_by_phase,
|
|
)) {
|
|
const agents = Number(count);
|
|
if (Number.isFinite(agents) && agents >= 1) {
|
|
validatedAgents[phase] = Math.floor(agents);
|
|
} else {
|
|
errors.push(
|
|
`max_agents_by_phase.${phase} must be a positive integer`,
|
|
);
|
|
}
|
|
}
|
|
if (Object.keys(validatedAgents).length > 0) {
|
|
validated.max_agents_by_phase = validatedAgents;
|
|
}
|
|
} else {
|
|
errors.push(
|
|
"max_agents_by_phase must be an object mapping phases to agent counts",
|
|
);
|
|
}
|
|
}
|
|
if (preferences.turn_input_required !== undefined) {
|
|
validated.turn_input_required = !!preferences.turn_input_required;
|
|
}
|
|
if (preferences.worktree_mode !== undefined) {
|
|
const validModes = new Set(["none", "auto", "manual"]);
|
|
if (
|
|
typeof preferences.worktree_mode === "string" &&
|
|
validModes.has(preferences.worktree_mode)
|
|
) {
|
|
validated.worktree_mode = preferences.worktree_mode;
|
|
} else {
|
|
errors.push(`worktree_mode must be one of: none, auto, manual`);
|
|
}
|
|
}
|
|
if (preferences.tool_abort_grace !== undefined) {
|
|
const ms = Number(preferences.tool_abort_grace);
|
|
if (Number.isFinite(ms) && ms >= 0) {
|
|
validated.tool_abort_grace = Math.floor(ms);
|
|
} else {
|
|
errors.push(
|
|
"tool_abort_grace must be a non-negative number (milliseconds)",
|
|
);
|
|
}
|
|
}
|
|
if (preferences.max_turns_per_attempt !== undefined) {
|
|
const turns = Number(preferences.max_turns_per_attempt);
|
|
if (Number.isFinite(turns) && turns >= 1) {
|
|
validated.max_turns_per_attempt = Math.floor(turns);
|
|
} else {
|
|
errors.push("max_turns_per_attempt must be a positive integer");
|
|
}
|
|
}
|
|
if (preferences.hot_cache_turns !== undefined) {
|
|
const turns = Number(preferences.hot_cache_turns);
|
|
if (Number.isFinite(turns) && turns >= 0) {
|
|
validated.hot_cache_turns = Math.floor(turns);
|
|
} else {
|
|
errors.push("hot_cache_turns must be a non-negative integer");
|
|
}
|
|
}
|
|
return { preferences: validated, errors, warnings };
|
|
}
|