refactor: decompose preferences.ts, populate skills and models modules (#1091)

Extract types/interfaces/constants to preferences-types.ts (~200 lines),
validation logic to preferences-validation.ts (~490 lines), move skill
resolution into preferences-skills.ts (~160 lines), and model resolution
into preferences-models.ts (~270 lines). The retained preferences.ts
(~330 lines) handles loading, merging, rendering, hooks, and re-exports
all symbols so existing imports remain unmodified.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-17 22:27:21 -06:00 committed by GitHub
parent 25d5f60836
commit bf3c17c8de
5 changed files with 1521 additions and 1402 deletions

View file

@ -1,22 +1,323 @@
/**
* Focused re-export: model-related preferences.
* Model-related preferences: resolution, fallbacks, profile defaults, and routing.
*
* Consumers that only need model resolution can import from this module
* instead of the monolithic preferences.ts, reducing coupling surface.
* Contains all logic for resolving model configurations from preferences,
* including per-phase model selection, fallback chains, token profiles,
* and dynamic routing configuration.
*/
export {
// Types
type GSDPhaseModelConfig,
type GSDModelConfig,
type GSDModelConfigV2,
type ResolvedModelConfig,
// Functions
resolveModelForUnit,
resolveModelWithFallbacksForUnit,
resolveDynamicRoutingConfig,
getNextFallbackModel,
isTransientNetworkError,
validateModelId,
updatePreferencesModels,
} from "./preferences.js";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import type { DynamicRoutingConfig } from "./model-router.js";
import { defaultRoutingConfig } from "./model-router.js";
import type { TokenProfile, InlineLevel } from "./types.js";
import type {
GSDPreferences,
GSDModelConfigV2,
GSDPhaseModelConfig,
ResolvedModelConfig,
AutoSupervisorConfig,
} from "./preferences-types.js";
import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
// Re-export types so existing consumers of ./preferences-models.js keep working
export type { GSDPhaseModelConfig, GSDModelConfig, GSDModelConfigV2, ResolvedModelConfig } from "./preferences-types.js";
/**
* Resolve which model ID to use for a given auto-mode unit type.
* Returns undefined if no model preference is set for this unit type.
*/
export function resolveModelForUnit(unitType: string): string | undefined {
const resolved = resolveModelWithFallbacksForUnit(unitType);
return resolved?.primary;
}
/**
* Resolve model and fallbacks for a given auto-mode unit type.
* Returns the primary model and ordered fallbacks, or undefined if not configured.
*
* Supports both legacy string format and extended object format:
* - Legacy: `planning: claude-opus-4-6`
* - Extended: `planning: { model: claude-opus-4-6, fallbacks: [glm-5, minimax-m2.5] }`
*/
export function resolveModelWithFallbacksForUnit(unitType: string): ResolvedModelConfig | undefined {
const prefs = loadEffectiveGSDPreferences();
if (!prefs?.preferences.models) return undefined;
const m = prefs.preferences.models as GSDModelConfigV2;
let phaseConfig: string | GSDPhaseModelConfig | undefined;
switch (unitType) {
case "research-milestone":
case "research-slice":
phaseConfig = m.research;
break;
case "plan-milestone":
case "plan-slice":
case "replan-slice":
phaseConfig = m.planning;
break;
case "execute-task":
phaseConfig = m.execution;
break;
case "execute-task-simple":
phaseConfig = m.execution_simple ?? m.execution;
break;
case "complete-slice":
case "run-uat":
phaseConfig = m.completion;
break;
default:
// Subagent unit types (e.g., "subagent", "subagent/scout")
if (unitType === "subagent" || unitType.startsWith("subagent/")) {
phaseConfig = m.subagent;
break;
}
return undefined;
}
if (!phaseConfig) return undefined;
// Normalize: string -> { model, fallbacks: [] }
if (typeof phaseConfig === "string") {
return { primary: phaseConfig, fallbacks: [] };
}
// When provider is explicitly set, prepend it to the model ID so the
// resolution code in auto.ts can do an explicit provider match.
const primary = phaseConfig.provider && !phaseConfig.model.includes("/")
? `${phaseConfig.provider}/${phaseConfig.model}`
: phaseConfig.model;
return {
primary,
fallbacks: phaseConfig.fallbacks ?? [],
};
}
/**
* Determines the next fallback model to try when the current model fails.
* If the current model is not in the configured list, returns the primary model.
* If the current model is the last in the list, returns undefined (exhausted).
*/
export function getNextFallbackModel(
currentModelId: string | undefined,
modelConfig: ResolvedModelConfig,
): string | undefined {
const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
if (!currentModelId) {
return modelsToTry[0];
}
let foundCurrent = false;
for (let i = 0; i < modelsToTry.length; i++) {
const mId = modelsToTry[i];
// Check for exact match or provider/model suffix match
if (mId === currentModelId || (mId.includes("/") && mId.endsWith(`/${currentModelId}`))) {
foundCurrent = true;
return modelsToTry[i + 1]; // Return the next one, or undefined if at the end
}
}
// If the current model wasn't in our preference list, default to starting the sequence
if (!foundCurrent) {
return modelsToTry[0];
}
}
/**
* Detect whether an error message indicates a transient network error
* (worth retrying the same model) vs a permanent provider error
* (auth failure, quota exceeded, etc. -- should fall back immediately).
*/
export function isTransientNetworkError(errorMsg: string): boolean {
if (!errorMsg) return false;
const hasNetworkSignal = /network|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|fetch failed|connection.*reset|dns/i.test(errorMsg);
const hasPermanentSignal = /auth|unauthorized|forbidden|invalid.*key|quota|billing/i.test(errorMsg);
return hasNetworkSignal && !hasPermanentSignal;
}
/**
* Validate a model ID string.
* Returns true if the ID looks like a valid model identifier.
*/
export function validateModelId(modelId: string): boolean {
if (!modelId || typeof modelId !== "string") return false;
const trimmed = modelId.trim();
if (trimmed.length === 0 || trimmed.length > 256) return false;
// Allow alphanumeric, hyphens, underscores, dots, slashes, colons
return /^[a-zA-Z0-9\-_./:]+$/.test(trimmed);
}
/**
* Update the models section of the global GSD preferences file.
* Performs a safe read-modify-write: reads current content, updates the models
* YAML block, and writes back. Creates the file if it doesn't exist.
*/
export function updatePreferencesModels(models: GSDModelConfigV2): void {
const prefsPath = getGlobalGSDPreferencesPath();
let content = "";
if (existsSync(prefsPath)) {
content = readFileSync(prefsPath, "utf-8");
}
// Build the new models block
const lines: string[] = ["models:"];
for (const [phase, value] of Object.entries(models)) {
if (typeof value === "string") {
lines.push(` ${phase}: ${value}`);
} else if (value && typeof value === "object") {
const config = value as GSDPhaseModelConfig;
lines.push(` ${phase}:`);
lines.push(` model: ${config.model}`);
if (config.provider) {
lines.push(` provider: ${config.provider}`);
}
if (config.fallbacks && config.fallbacks.length > 0) {
lines.push(` fallbacks:`);
for (const fb of config.fallbacks) {
lines.push(` - ${fb}`);
}
}
}
}
const modelsBlock = lines.join("\n");
// Replace existing models block or append
const modelsRegex = /^models:[\s\S]*?(?=\n[a-z_]|\n*$)/m;
if (modelsRegex.test(content)) {
content = content.replace(modelsRegex, modelsBlock);
} else {
content = content.trimEnd() + "\n\n" + modelsBlock + "\n";
}
writeFileSync(prefsPath, content, "utf-8");
}
/**
* Resolve the dynamic routing configuration from effective preferences.
* Returns the merged config with defaults applied.
*/
export function resolveDynamicRoutingConfig(): DynamicRoutingConfig {
const prefs = loadEffectiveGSDPreferences();
const configured = prefs?.preferences.dynamic_routing;
if (!configured) return defaultRoutingConfig();
return {
...defaultRoutingConfig(),
...configured,
};
}
export function resolveAutoSupervisorConfig(): AutoSupervisorConfig {
const prefs = loadEffectiveGSDPreferences();
const configured = prefs?.preferences.auto_supervisor ?? {};
return {
soft_timeout_minutes: configured.soft_timeout_minutes ?? 20,
idle_timeout_minutes: configured.idle_timeout_minutes ?? 10,
hard_timeout_minutes: configured.hard_timeout_minutes ?? 30,
...(configured.model ? { model: configured.model } : {}),
};
}
// ─── Token Profile Resolution ─────────────────────────────────────────────
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality"]);
/**
* Resolve profile defaults for a given token profile tier.
* Returns a partial GSDPreferences that is used as the base layer --
* explicit user preferences always override these defaults.
*/
export function resolveProfileDefaults(profile: TokenProfile): Partial<GSDPreferences> {
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_slice_research: true,
},
};
case "quality":
return {
models: {},
phases: {},
};
}
}
/**
* Resolve the effective token profile from preferences.
* Returns "balanced" when no profile is set (D046).
*/
export function resolveEffectiveProfile(): TokenProfile {
const prefs = loadEffectiveGSDPreferences();
const profile = prefs?.preferences.token_profile;
if (profile && VALID_TOKEN_PROFILES.has(profile)) return profile;
return "balanced";
}
/**
* Resolve the inline level from the active token profile.
* budget -> minimal, balanced -> standard, quality -> full.
*/
export function resolveInlineLevel(): InlineLevel {
const profile = resolveEffectiveProfile();
switch (profile) {
case "budget": return "minimal";
case "balanced": return "standard";
case "quality": return "full";
}
}
/**
* Resolve the compression strategy from the active token profile.
* budget/balanced -> "compress", quality -> "truncate".
* Explicit preference always wins.
*/
export function resolveCompressionStrategy(): import("./types.js").CompressionStrategy {
const prefs = loadEffectiveGSDPreferences();
if (prefs?.preferences.compression_strategy) return prefs.preferences.compression_strategy;
const profile = resolveEffectiveProfile();
return profile === "quality" ? "truncate" : "compress";
}
/**
* Resolve the context selection mode from the active token profile.
* budget -> "smart", balanced/quality -> "full".
* Explicit preference always wins.
*/
export function resolveContextSelection(): import("./types.js").ContextSelectionMode {
const prefs = loadEffectiveGSDPreferences();
if (prefs?.preferences.context_selection) return prefs.preferences.context_selection;
const profile = resolveEffectiveProfile();
return profile === "budget" ? "smart" : "full";
}
/**
* Resolve the search provider preference from preferences.md.
* Returns undefined if not configured (caller falls back to existing behavior).
*/
export function resolveSearchProviderFromPreferences(): GSDPreferences["search_provider"] | undefined {
const prefs = loadEffectiveGSDPreferences();
return prefs?.preferences.search_provider;
}

View file

@ -1,18 +1,169 @@
/**
* Focused re-export: skill-related preferences.
* Skill-related preferences: resolution, discovery, and formatting.
*
* Consumers that only need skill resolution can import from this module
* instead of the monolithic preferences.ts, reducing coupling surface.
* Contains all logic for resolving skill references from preferences
* to absolute filesystem paths, plus skill discovery and staleness config.
*/
export {
// Types
type GSDSkillRule,
type SkillDiscoveryMode,
type SkillResolution,
type SkillResolutionReport,
// Functions
resolveAllSkillReferences,
resolveSkillDiscoveryMode,
resolveSkillStalenessDays,
} from "./preferences.js";
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { getAgentDir } from "@gsd/pi-coding-agent";
import { statSync } from "node:fs";
import type {
GSDPreferences,
SkillDiscoveryMode,
SkillResolution,
SkillResolutionReport,
} from "./preferences-types.js";
import { validatePreferences } from "./preferences-validation.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
// Re-export types so existing consumers of ./preferences-skills.js keep working
export type { GSDSkillRule, SkillDiscoveryMode, SkillResolution, SkillResolutionReport } from "./preferences-types.js";
/**
* Known skill directories, in priority order.
* User skills (~/.gsd/agent/skills/) take precedence over project skills.
*/
export function getSkillSearchDirs(cwd: string): Array<{ dir: string; method: SkillResolution["method"] }> {
return [
{ dir: join(getAgentDir(), "skills"), method: "user-skill" },
{ dir: join(cwd, ".pi", "agent", "skills"), method: "project-skill" },
];
}
/**
* Resolve a single skill reference to an absolute path.
*
* Resolution order:
* 1. Absolute path to a file -> check existsSync
* 2. Absolute path to a directory -> check for SKILL.md inside
* 3. Bare name -> scan known skill directories for <name>/SKILL.md
*/
export function resolveSkillReference(ref: string, cwd: string): SkillResolution {
const trimmed = ref.trim();
// Expand tilde
const expanded = trimmed.startsWith("~/")
? join(homedir(), trimmed.slice(2))
: trimmed;
// Absolute path
if (isAbsolute(expanded)) {
// Direct file reference
if (existsSync(expanded)) {
// Check if it's a directory -- look for SKILL.md inside
try {
const stat = statSync(expanded);
if (stat.isDirectory()) {
const skillFile = join(expanded, "SKILL.md");
if (existsSync(skillFile)) {
return { original: ref, resolvedPath: skillFile, method: "absolute-dir" };
}
return { original: ref, resolvedPath: null, method: "unresolved" };
}
} catch { /* fall through */ }
return { original: ref, resolvedPath: expanded, method: "absolute-path" };
}
// Maybe it's a directory path without SKILL.md suffix
const withSkillMd = join(expanded, "SKILL.md");
if (existsSync(withSkillMd)) {
return { original: ref, resolvedPath: withSkillMd, method: "absolute-dir" };
}
return { original: ref, resolvedPath: null, method: "unresolved" };
}
// Bare name -- scan known skill directories
for (const { dir, method } of getSkillSearchDirs(cwd)) {
if (!existsSync(dir)) continue;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (entry.name === expanded) {
const skillFile = join(dir, entry.name, "SKILL.md");
if (existsSync(skillFile)) {
return { original: ref, resolvedPath: skillFile, method };
}
}
}
} catch { /* directory not readable -- skip */ }
}
return { original: ref, resolvedPath: null, method: "unresolved" };
}
/**
* Resolve all skill references in a preferences object.
* Caches resolution per reference string to avoid redundant filesystem scans.
*/
export function resolveAllSkillReferences(preferences: GSDPreferences, cwd: string): SkillResolutionReport {
const validated = validatePreferences(preferences).preferences;
preferences = validated;
const resolutions = new Map<string, SkillResolution>();
const warnings: string[] = [];
function resolve(ref: string): SkillResolution {
const existing = resolutions.get(ref);
if (existing) return existing;
const result = resolveSkillReference(ref, cwd);
resolutions.set(ref, result);
if (result.method === "unresolved") {
warnings.push(ref);
}
return result;
}
// Resolve all skill lists
for (const skill of preferences.always_use_skills ?? []) resolve(skill);
for (const skill of preferences.prefer_skills ?? []) resolve(skill);
for (const skill of preferences.avoid_skills ?? []) resolve(skill);
// Resolve skill rules
for (const rule of preferences.skill_rules ?? []) {
for (const skill of rule.use ?? []) resolve(skill);
for (const skill of rule.prefer ?? []) resolve(skill);
for (const skill of rule.avoid ?? []) resolve(skill);
}
return { resolutions, warnings };
}
/**
* Format a skill reference for the system prompt.
* If resolved, shows the path so the agent knows exactly where to read.
* If unresolved, marks it clearly.
*/
export function formatSkillRef(ref: string, resolutions: Map<string, SkillResolution>): string {
const resolution = resolutions.get(ref);
if (!resolution || resolution.method === "unresolved") {
return `${ref} (⚠ not found — check skill name or path)`;
}
// For absolute paths where SKILL.md is just appended, don't clutter the output
if (resolution.method === "absolute-path" || resolution.method === "absolute-dir") {
return ref;
}
// For bare names resolved from skill directories, show the resolved path
return `${ref}\`${resolution.resolvedPath}\``;
}
/**
* Resolve the skill discovery mode from effective preferences.
* Defaults to "suggest" -- skills are identified during research but not installed automatically.
*/
export function resolveSkillDiscoveryMode(): SkillDiscoveryMode {
const prefs = loadEffectiveGSDPreferences();
return prefs?.preferences.skill_discovery ?? "suggest";
}
/**
* Resolve the skill staleness threshold in days.
* Returns 0 if disabled, default 60 if not configured.
*/
export function resolveSkillStalenessDays(): number {
const prefs = loadEffectiveGSDPreferences();
return prefs?.preferences.skill_staleness_days ?? 60;
}

View file

@ -0,0 +1,223 @@
/**
* Type definitions, constants, and configuration shapes for GSD preferences.
*
* All interfaces, type aliases, and static lookup tables live here so that
* both the validation and runtime modules can import them without pulling
* in filesystem or loading logic.
*/
import type { GitPreferences } from "./git-service.js";
import type {
PostUnitHookConfig,
PreDispatchHookConfig,
BudgetEnforcementMode,
NotificationPreferences,
TokenProfile,
InlineLevel,
PhaseSkipPreferences,
ParallelConfig,
CompressionStrategy,
ContextSelectionMode,
} from "./types.js";
import type { DynamicRoutingConfig } from "./model-router.js";
// ─── Workflow Modes ──────────────────────────────────────────────────────────
export type WorkflowMode = "solo" | "team";
/** Default preference values for each workflow mode. */
export const MODE_DEFAULTS: Record<WorkflowMode, Partial<GSDPreferences>> = {
solo: {
git: {
auto_push: true,
push_branches: false,
pre_merge_check: false,
merge_strategy: "squash",
isolation: "worktree",
commit_docs: true,
},
unique_milestone_ids: false,
},
team: {
git: {
auto_push: false,
push_branches: true,
pre_merge_check: true,
merge_strategy: "squash",
isolation: "worktree",
commit_docs: true,
},
unique_milestone_ids: true,
},
};
/** All recognized top-level keys in GSDPreferences. Used to detect typos / stale config. */
export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"version",
"mode",
"always_use_skills",
"prefer_skills",
"avoid_skills",
"skill_rules",
"custom_instructions",
"models",
"skill_discovery",
"skill_staleness_days",
"auto_supervisor",
"uat_dispatch",
"unique_milestone_ids",
"budget_ceiling",
"budget_enforcement",
"context_pause_threshold",
"notifications",
"remote_questions",
"git",
"post_unit_hooks",
"pre_dispatch_hooks",
"dynamic_routing",
"token_profile",
"phases",
"auto_visualize",
"auto_report",
"parallel",
"verification_commands",
"verification_auto_fix",
"verification_max_retries",
"search_provider",
"compression_strategy",
"context_selection",
]);
export const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]);
export interface GSDSkillRule {
when: string;
use?: string[];
prefer?: string[];
avoid?: string[];
}
/**
* Model configuration for a single phase.
* Supports primary model with optional fallbacks for resilience.
*/
export interface GSDPhaseModelConfig {
/** Primary model ID (e.g., "claude-opus-4-6") */
model: string;
/** Provider name to disambiguate when the same model ID exists across providers (e.g., "bedrock", "anthropic") */
provider?: string;
/** Fallback models to try in order if primary fails (e.g., rate limits, credits exhausted) */
fallbacks?: string[];
}
/**
* Legacy model config -- simple string per phase.
* Kept for backward compatibility; will be migrated to GSDModelConfigV2 on load.
*/
export interface GSDModelConfig {
research?: string;
planning?: string;
execution?: string;
execution_simple?: string;
completion?: string;
subagent?: string;
}
/**
* Extended model config with per-phase fallback support.
* Each phase can specify a primary model and ordered fallbacks.
*/
export interface GSDModelConfigV2 {
research?: string | GSDPhaseModelConfig;
planning?: string | GSDPhaseModelConfig;
execution?: string | GSDPhaseModelConfig;
execution_simple?: string | GSDPhaseModelConfig;
completion?: string | GSDPhaseModelConfig;
subagent?: string | GSDPhaseModelConfig;
}
/** Normalized model selection with resolved fallbacks */
export interface ResolvedModelConfig {
primary: string;
fallbacks: string[];
}
export type SkillDiscoveryMode = "auto" | "suggest" | "off";
export interface AutoSupervisorConfig {
model?: string;
soft_timeout_minutes?: number;
idle_timeout_minutes?: number;
hard_timeout_minutes?: number;
}
export interface RemoteQuestionsConfig {
channel: "slack" | "discord" | "telegram";
channel_id: string | number;
timeout_minutes?: number; // clamped to 1-30
poll_interval_seconds?: number; // clamped to 2-30
}
export interface GSDPreferences {
version?: number;
mode?: WorkflowMode;
always_use_skills?: string[];
prefer_skills?: string[];
avoid_skills?: string[];
skill_rules?: GSDSkillRule[];
custom_instructions?: string[];
models?: GSDModelConfig | GSDModelConfigV2;
skill_discovery?: SkillDiscoveryMode;
skill_staleness_days?: number; // Skills unused for N days get deprioritized (#599). 0 = disabled. Default: 60.
auto_supervisor?: AutoSupervisorConfig;
uat_dispatch?: boolean;
unique_milestone_ids?: boolean;
budget_ceiling?: number;
budget_enforcement?: BudgetEnforcementMode;
context_pause_threshold?: number;
notifications?: NotificationPreferences;
remote_questions?: RemoteQuestionsConfig;
git?: GitPreferences;
post_unit_hooks?: PostUnitHookConfig[];
pre_dispatch_hooks?: PreDispatchHookConfig[];
dynamic_routing?: DynamicRoutingConfig;
token_profile?: TokenProfile;
phases?: PhaseSkipPreferences;
auto_visualize?: boolean;
/** Generate HTML report snapshot after each milestone completion. Default: true. Set false to disable. */
auto_report?: boolean;
parallel?: ParallelConfig;
verification_commands?: string[];
verification_auto_fix?: boolean;
verification_max_retries?: number;
/** Search provider preference. "brave"/"tavily"/"ollama" force that backend and disable native Anthropic search. "native" forces native only. "auto" = current default behavior. */
search_provider?: "brave" | "tavily" | "ollama" | "native" | "auto";
/** Compression strategy for context that exceeds budget. "truncate" (default) drops sections, "compress" applies heuristic compression first. */
compression_strategy?: CompressionStrategy;
/** Context selection mode for file inlining. "full" inlines entire files, "smart" uses semantic chunking. Default derived from token profile. */
context_selection?: ContextSelectionMode;
}
export interface LoadedGSDPreferences {
path: string;
scope: "global" | "project";
preferences: GSDPreferences;
/** Validation warnings (unknown keys, type mismatches, deprecations). Empty when preferences are clean. */
warnings?: string[];
}
export interface SkillResolution {
/** The original reference from preferences (bare name or path). */
original: string;
/** The resolved absolute path to the SKILL.md file, or null if unresolved. */
resolvedPath: string | null;
/** How it was resolved. */
method: "absolute-path" | "absolute-dir" | "user-skill" | "project-skill" | "unresolved";
}
export interface SkillResolutionReport {
/** All resolution results, keyed by original reference. */
resolutions: Map<string, SkillResolution>;
/** References that could not be resolved. */
warnings: string[];
}

View file

@ -0,0 +1,597 @@
/**
* Validation logic for GSD preferences.
*
* Pure validation -- no filesystem access, no loading, no merging.
* Accepts a raw GSDPreferences object and returns a sanitized copy
* together with any errors and warnings.
*/
import type { GitPreferences } from "./git-service.js";
import type { PostUnitHookConfig, PreDispatchHookConfig, TokenProfile, PhaseSkipPreferences } from "./types.js";
import type { DynamicRoutingConfig } from "./model-router.js";
import { VALID_BRANCH_NAME } from "./git-service.js";
import { normalizeStringArray } from "../shared/mod.js";
import {
KNOWN_PREFERENCE_KEYS,
SKILL_ACTIONS,
type WorkflowMode,
type GSDPreferences,
type GSDSkillRule,
} from "./preferences-types.js";
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality"]);
export function validatePreferences(preferences: GSDPreferences): {
preferences: GSDPreferences;
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
const validated: GSDPreferences = {};
// ─── Unknown Key Detection ──────────────────────────────────────────
for (const key of Object.keys(preferences)) {
if (!KNOWN_PREFERENCE_KEYS.has(key)) {
warnings.push(`unknown preference key "${key}" — ignored`);
}
}
if (preferences.version !== undefined) {
if (preferences.version === 1) {
validated.version = 1;
} else {
errors.push(`unsupported version ${preferences.version}`);
}
}
// ─── Workflow Mode ──────────────────────────────────────────────────
if (preferences.mode !== undefined) {
const validModes = new Set<string>(["solo", "team"]);
if (typeof preferences.mode === "string" && validModes.has(preferences.mode)) {
validated.mode = preferences.mode as WorkflowMode;
} 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: GSDSkillRule[] = [];
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: GSDSkillRule = { when };
for (const action of SKILL_ACTIONS) {
const values = normalizeStringArray((rule as unknown as Record<string, unknown>)[action]);
if (values.length > 0) {
validatedRule[action as keyof GSDSkillRule] = values as never;
}
}
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"] as const) {
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.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`);
}
}
// ─── Token Profile ─────────────────────────────────────────────────
if (preferences.token_profile !== undefined) {
if (typeof preferences.token_profile === "string" && VALID_TOKEN_PROFILES.has(preferences.token_profile as TokenProfile)) {
validated.token_profile = preferences.token_profile as TokenProfile;
} else {
errors.push(`token_profile must be one of: budget, balanced, quality`);
}
}
// ─── Search Provider ─────────────────────────────────────────────
if (preferences.search_provider !== undefined) {
const validSearchProviders = new Set(["brave", "tavily", "ollama", "native", "auto"]);
if (typeof preferences.search_provider === "string" && validSearchProviders.has(preferences.search_provider)) {
validated.search_provider = preferences.search_provider as GSDPreferences["search_provider"];
} else {
errors.push(`search_provider must be one of: brave, tavily, ollama, native, auto`);
}
}
// ─── Phase Skip Preferences ─────────────────────────────────────────
if (preferences.phases !== undefined) {
if (typeof preferences.phases === "object" && preferences.phases !== null) {
const validatedPhases: PhaseSkipPreferences = {};
const p = preferences.phases as Record<string, unknown>;
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 as any).require_slice_discussion !== undefined) (validatedPhases as any).require_slice_discussion = !!(p as any).require_slice_discussion;
// Warn on unknown phase keys
const knownPhaseKeys = new Set(["skip_research", "skip_reassess", "skip_slice_research", "skip_milestone_validation", "require_slice_discussion"]);
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") {
validated.auto_supervisor = preferences.auto_supervisor;
} 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");
}
}
// ─── Remote Questions ───────────────────────────────────────────────
if (preferences.remote_questions !== undefined) {
if (preferences.remote_questions && typeof preferences.remote_questions === "object") {
validated.remote_questions = preferences.remote_questions;
} 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: PostUnitHookConfig[] = [];
const seenNames = new Set<string>();
const knownUnitTypes = new Set([
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
"run-uat", "complete-milestone",
]);
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: PostUnitHookConfig = { 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: PreDispatchHookConfig[] = [];
const seenPreNames = new Set<string>();
const knownUnitTypes = new Set([
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
"run-uat", "complete-milestone",
]);
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: PreDispatchHookConfig = { name, before, action: action as PreDispatchHookConfig["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 as unknown as Record<string, unknown>;
const validDr: Partial<DynamicRoutingConfig> = {};
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.tier_models !== undefined) {
if (typeof dr.tier_models === "object" && dr.tier_models !== null) {
const tm = dr.tier_models as Record<string, unknown>;
const validTm: Record<string, string> = {};
for (const tier of ["light", "standard", "heavy"]) {
if (tm[tier] !== undefined) {
if (typeof tm[tier] === "string") validTm[tier] = tm[tier] as string;
else errors.push(`dynamic_routing.tier_models.${tier} must be a string`);
}
}
if (Object.keys(validTm).length > 0) validDr.tier_models = validTm as DynamicRoutingConfig["tier_models"];
} else {
errors.push("dynamic_routing.tier_models must be an object");
}
}
if (Object.keys(validDr).length > 0) {
validated.dynamic_routing = validDr as unknown as DynamicRoutingConfig;
}
} else {
errors.push("dynamic_routing must be an object");
}
}
// ─── Parallel Config ────────────────────────────────────────────────────
if (preferences.parallel && typeof preferences.parallel === "object") {
const p = preferences.parallel as unknown as Record<string, unknown>;
const parallel: Record<string, unknown> = {};
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 (Object.keys(parallel).length > 0) {
validated.parallel = parallel as unknown as import("./types.js").ParallelConfig;
}
}
// ─── Verification Preferences ───────────────────────────────────────────
if (preferences.verification_commands !== undefined) {
if (Array.isArray(preferences.verification_commands)) {
const allStrings = preferences.verification_commands.every(
(item: unknown) => 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: Record<string, unknown> = {};
const g = preferences.git as Record<string, unknown>;
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 as "squash" | "merge";
} 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 as "worktree" | "branch" | "none";
} else {
errors.push("git.isolation must be one of: worktree, branch, none");
}
}
if (g.commit_docs !== undefined) {
if (typeof g.commit_docs === "boolean") git.commit_docs = g.commit_docs;
else errors.push("git.commit_docs must be a boolean");
}
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.");
}
if (Object.keys(git).length > 0) {
validated.git = git as GitPreferences;
}
}
return { preferences: validated, errors, warnings };
}

File diff suppressed because it is too large Load diff