* feat: dynamic model routing for token consumption optimization (#575) Add complexity-based model routing that classifies units into light/standard/heavy tiers and routes to cheaper models when appropriate. Reduces token consumption by 20-50% for users on capped plans. - Complexity classifier with heuristic-based tier assignment (no LLM call) - Model router with downgrade-only semantics (user's config is ceiling) - Budget-pressure-aware routing (more aggressive as budget fills) - Cross-provider cost comparison via bundled cost table - Hook classification support - Escalation on failure (light → standard → heavy) - Full preference validation and merge support - Metrics tracking with tier and downgrade fields - 40 new tests (classifier, router, cost table) Closes #575 * feat: phases 2-4 — dashboard, adaptive learning, task introspection Phase 2 — Observability & Dashboard: - Tier badge [L]/[S]/[H] displayed in progress widget next to phase label - Dynamic routing savings summary shown in footer when units have been downgraded - Tier and modelDowngraded fields passed through snapshotUnitMetrics Phase 3 — Adaptive Learning: - New routing-history.ts: tracks success/failure per tier per unit-type pattern - Rolling window of 50 entries per pattern to prevent stale data - User feedback support (over/under/ok) with 2x weight vs automatic - Failure rate >20% auto-bumps tier for that pattern - Tag-specific patterns (e.g. execute-task:docs) for granular learning - History persists to .gsd/routing-history.json - Classifier consults adaptive history before finalizing tier Phase 4 — Task Plan Introspection: - Code block counting in task plans (5+ blocks → heavy) - Complexity keyword detection: migration, architecture, security, performance, concurrency, compatibility - Multiple complexity keywords (2+) → heavy, single → standard - New codeBlockCount and complexityKeywords fields in TaskMetadata Tests: 16 new tests (routing history + introspection), 419 total passing
1383 lines
52 KiB
TypeScript
1383 lines
52 KiB
TypeScript
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { isAbsolute, join } from "node:path";
|
|
import { getAgentDir } from "@gsd/pi-coding-agent";
|
|
import type { GitPreferences } from "./git-service.js";
|
|
import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences, TokenProfile, InlineLevel, PhaseSkipPreferences } from "./types.js";
|
|
import type { DynamicRoutingConfig } from "./model-router.js";
|
|
import { defaultRoutingConfig } from "./model-router.js";
|
|
import { VALID_BRANCH_NAME } from "./git-service.js";
|
|
|
|
const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
|
|
const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md");
|
|
const PROJECT_PREFERENCES_PATH = join(process.cwd(), ".gsd", "preferences.md");
|
|
// Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake.
|
|
// Check uppercase as a fallback so those files aren't silently ignored.
|
|
const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(homedir(), ".gsd", "PREFERENCES.md");
|
|
const PROJECT_PREFERENCES_PATH_UPPERCASE = join(process.cwd(), ".gsd", "PREFERENCES.md");
|
|
const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]);
|
|
|
|
/** All recognized top-level keys in GSDPreferences. Used to detect typos / stale config. */
|
|
const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|
"version",
|
|
"always_use_skills",
|
|
"prefer_skills",
|
|
"avoid_skills",
|
|
"skill_rules",
|
|
"custom_instructions",
|
|
"models",
|
|
"skill_discovery",
|
|
"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",
|
|
]);
|
|
|
|
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";
|
|
channel_id: string | number;
|
|
timeout_minutes?: number; // clamped to 1-30
|
|
poll_interval_seconds?: number; // clamped to 2-30
|
|
}
|
|
|
|
export interface GSDPreferences {
|
|
version?: number;
|
|
always_use_skills?: string[];
|
|
prefer_skills?: string[];
|
|
avoid_skills?: string[];
|
|
skill_rules?: GSDSkillRule[];
|
|
custom_instructions?: string[];
|
|
models?: GSDModelConfig | GSDModelConfigV2;
|
|
skill_discovery?: SkillDiscoveryMode;
|
|
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;
|
|
}
|
|
|
|
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 function getGlobalGSDPreferencesPath(): string {
|
|
return GLOBAL_PREFERENCES_PATH;
|
|
}
|
|
|
|
export function getLegacyGlobalGSDPreferencesPath(): string {
|
|
return LEGACY_GLOBAL_PREFERENCES_PATH;
|
|
}
|
|
|
|
export function getProjectGSDPreferencesPath(): string {
|
|
return PROJECT_PREFERENCES_PATH;
|
|
}
|
|
|
|
export function loadGlobalGSDPreferences(): LoadedGSDPreferences | null {
|
|
return loadPreferencesFile(GLOBAL_PREFERENCES_PATH, "global")
|
|
?? loadPreferencesFile(GLOBAL_PREFERENCES_PATH_UPPERCASE, "global")
|
|
?? loadPreferencesFile(LEGACY_GLOBAL_PREFERENCES_PATH, "global");
|
|
}
|
|
|
|
export function loadProjectGSDPreferences(): LoadedGSDPreferences | null {
|
|
return loadPreferencesFile(PROJECT_PREFERENCES_PATH, "project")
|
|
?? loadPreferencesFile(PROJECT_PREFERENCES_PATH_UPPERCASE, "project");
|
|
}
|
|
|
|
export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null {
|
|
const globalPreferences = loadGlobalGSDPreferences();
|
|
const projectPreferences = loadProjectGSDPreferences();
|
|
|
|
if (!globalPreferences && !projectPreferences) return null;
|
|
if (!globalPreferences) return projectPreferences;
|
|
if (!projectPreferences) return globalPreferences;
|
|
|
|
const mergedWarnings = [
|
|
...(globalPreferences.warnings ?? []),
|
|
...(projectPreferences.warnings ?? []),
|
|
];
|
|
|
|
return {
|
|
path: projectPreferences.path,
|
|
scope: "project",
|
|
preferences: mergePreferences(globalPreferences.preferences, projectPreferences.preferences),
|
|
...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}),
|
|
};
|
|
}
|
|
|
|
// ─── Skill Reference Resolution ───────────────────────────────────────────────
|
|
|
|
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[];
|
|
}
|
|
|
|
/**
|
|
* Known skill directories, in priority order.
|
|
* User skills (~/.gsd/agent/skills/) take precedence over project skills.
|
|
*/
|
|
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
|
|
*/
|
|
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()) 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.
|
|
*/
|
|
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}\``;
|
|
}
|
|
|
|
// ─── System Prompt Rendering ──────────────────────────────────────────────────
|
|
|
|
export function renderPreferencesForSystemPrompt(preferences: GSDPreferences, resolutions?: Map<string, SkillResolution>): string {
|
|
const validated = validatePreferences(preferences);
|
|
const lines: string[] = ["## GSD Skill Preferences"];
|
|
|
|
if (validated.errors.length > 0) {
|
|
lines.push("- Validation: some preference values were ignored because they were invalid.");
|
|
}
|
|
for (const warning of validated.warnings) {
|
|
lines.push(`- Deprecation: ${warning}`);
|
|
}
|
|
|
|
preferences = validated.preferences;
|
|
|
|
lines.push(
|
|
"- Treat these as explicit skill-selection policy for GSD work.",
|
|
"- If a listed skill exists and is relevant, load and follow it instead of treating it as a vague suggestion.",
|
|
"- Current user instructions still override these defaults.",
|
|
);
|
|
|
|
const fmt = (ref: string) => resolutions ? formatSkillRef(ref, resolutions) : ref;
|
|
|
|
if (preferences.always_use_skills && preferences.always_use_skills.length > 0) {
|
|
lines.push("- Always use these skills when relevant:");
|
|
for (const skill of preferences.always_use_skills) {
|
|
lines.push(` - ${fmt(skill)}`);
|
|
}
|
|
}
|
|
|
|
if (preferences.prefer_skills && preferences.prefer_skills.length > 0) {
|
|
lines.push("- Prefer these skills when relevant:");
|
|
for (const skill of preferences.prefer_skills) {
|
|
lines.push(` - ${fmt(skill)}`);
|
|
}
|
|
}
|
|
|
|
if (preferences.avoid_skills && preferences.avoid_skills.length > 0) {
|
|
lines.push("- Avoid these skills unless clearly needed:");
|
|
for (const skill of preferences.avoid_skills) {
|
|
lines.push(` - ${fmt(skill)}`);
|
|
}
|
|
}
|
|
|
|
if (preferences.skill_rules && preferences.skill_rules.length > 0) {
|
|
lines.push("- Situational rules:");
|
|
for (const rule of preferences.skill_rules) {
|
|
lines.push(` - When ${rule.when}:`);
|
|
if (rule.use && rule.use.length > 0) {
|
|
lines.push(` - use: ${rule.use.map(fmt).join(", ")}`);
|
|
}
|
|
if (rule.prefer && rule.prefer.length > 0) {
|
|
lines.push(` - prefer: ${rule.prefer.map(fmt).join(", ")}`);
|
|
}
|
|
if (rule.avoid && rule.avoid.length > 0) {
|
|
lines.push(` - avoid: ${rule.avoid.map(fmt).join(", ")}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (preferences.custom_instructions && preferences.custom_instructions.length > 0) {
|
|
lines.push("- Additional instructions:");
|
|
for (const instruction of preferences.custom_instructions) {
|
|
lines.push(` - ${instruction}`);
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function loadPreferencesFile(path: string, scope: "global" | "project"): LoadedGSDPreferences | null {
|
|
if (!existsSync(path)) return null;
|
|
|
|
const raw = readFileSync(path, "utf-8");
|
|
const preferences = parsePreferencesMarkdown(raw);
|
|
if (!preferences) return null;
|
|
|
|
const validation = validatePreferences(preferences);
|
|
const allWarnings = [...validation.warnings, ...validation.errors];
|
|
|
|
return {
|
|
path,
|
|
scope,
|
|
preferences: validation.preferences,
|
|
...(allWarnings.length > 0 ? { warnings: allWarnings } : {}),
|
|
};
|
|
}
|
|
|
|
/** @internal Exported for testing only */
|
|
export function parsePreferencesMarkdown(content: string): GSDPreferences | null {
|
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
if (!match) return null;
|
|
return parseFrontmatterBlock(match[1]);
|
|
}
|
|
|
|
function parseFrontmatterBlock(frontmatter: string): GSDPreferences {
|
|
const root: Record<string, unknown> = {};
|
|
const stack: Array<{ indent: number; value: Record<string, unknown> }> = [{ indent: -1, value: root }];
|
|
|
|
const lines = frontmatter.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (!line.trim()) continue;
|
|
|
|
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
const trimmed = line.trim();
|
|
|
|
// Skip comment lines (standalone YAML comments)
|
|
if (trimmed.startsWith("#")) continue;
|
|
|
|
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
stack.pop();
|
|
}
|
|
|
|
const current = stack[stack.length - 1].value;
|
|
const keyMatch = trimmed.match(/^([A-Za-z0-9_]+):(.*)$/);
|
|
if (!keyMatch) continue;
|
|
|
|
const [, key, remainder] = keyMatch;
|
|
// Strip inline comments from the value portion
|
|
const valuePart = remainder.replace(/\s+#.*$/, "").trim();
|
|
|
|
if (valuePart === "") {
|
|
const nextLine = lines[i + 1] ?? "";
|
|
const nextTrimmed = nextLine.trim();
|
|
if (nextTrimmed.startsWith("- ")) {
|
|
const items: unknown[] = [];
|
|
let j = i + 1;
|
|
while (j < lines.length) {
|
|
const candidate = lines[j];
|
|
const candidateIndent = candidate.match(/^\s*/)?.[0].length ?? 0;
|
|
const candidateTrimmed = candidate.trim();
|
|
if (!candidateTrimmed) {
|
|
j++;
|
|
continue;
|
|
}
|
|
if (candidateIndent <= indent || !candidateTrimmed.startsWith("- ")) break;
|
|
|
|
const itemText = candidateTrimmed.slice(2).trim();
|
|
const nextCandidate = lines[j + 1] ?? "";
|
|
const nextCandidateIndent = nextCandidate.match(/^\s*/)?.[0].length ?? 0;
|
|
const nextCandidateTrimmed = nextCandidate.trim();
|
|
|
|
// Treat an array item as a structured object only when:
|
|
// a) It looks like a YAML key-value pair (key starts with [A-Za-z0-9_]+:), OR
|
|
// b) The next line is indented deeper (nested block under this item).
|
|
// Bare colons (e.g. "qwen/qwen3-coder:free") are NOT key-value pairs.
|
|
const looksLikeKeyValue = /^[A-Za-z0-9_]+:/.test(itemText);
|
|
if (looksLikeKeyValue || (nextCandidateTrimmed && nextCandidateIndent > candidateIndent)) {
|
|
const obj: Record<string, unknown> = {};
|
|
const firstMatch = itemText.match(/^([A-Za-z0-9_]+):(.*)$/);
|
|
if (firstMatch) {
|
|
obj[firstMatch[1]] = parseScalar(firstMatch[2].trim());
|
|
}
|
|
j++;
|
|
while (j < lines.length) {
|
|
const nested = lines[j];
|
|
const nestedIndent = nested.match(/^\s*/)?.[0].length ?? 0;
|
|
const nestedTrimmed = nested.trim();
|
|
if (!nestedTrimmed) {
|
|
j++;
|
|
continue;
|
|
}
|
|
if (nestedIndent <= candidateIndent) break;
|
|
const nestedMatch = nestedTrimmed.match(/^([A-Za-z0-9_]+):(.*)$/);
|
|
if (nestedMatch) {
|
|
const nestedValue = nestedMatch[2].trim();
|
|
if (nestedValue === "") {
|
|
const nestedItems: string[] = [];
|
|
j++;
|
|
while (j < lines.length) {
|
|
const nestedArrayLine = lines[j];
|
|
const nestedArrayIndent = nestedArrayLine.match(/^\s*/)?.[0].length ?? 0;
|
|
const nestedArrayTrimmed = nestedArrayLine.trim();
|
|
if (!nestedArrayTrimmed) {
|
|
j++;
|
|
continue;
|
|
}
|
|
if (nestedArrayIndent <= nestedIndent || !nestedArrayTrimmed.startsWith("- ")) break;
|
|
nestedItems.push(String(parseScalar(nestedArrayTrimmed.slice(2).trim())));
|
|
j++;
|
|
}
|
|
obj[nestedMatch[1]] = nestedItems;
|
|
continue;
|
|
}
|
|
obj[nestedMatch[1]] = parseScalar(nestedValue);
|
|
}
|
|
j++;
|
|
}
|
|
items.push(obj);
|
|
continue;
|
|
}
|
|
|
|
items.push(parseScalar(itemText));
|
|
j++;
|
|
}
|
|
current[key] = items;
|
|
i = j - 1;
|
|
} else {
|
|
const obj: Record<string, unknown> = {};
|
|
current[key] = obj;
|
|
stack.push({ indent, value: obj });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
current[key] = parseScalar(valuePart);
|
|
}
|
|
|
|
return root as GSDPreferences;
|
|
}
|
|
|
|
function parseScalar(value: string): unknown {
|
|
// Strip inline YAML comments: " # comment" (# preceded by whitespace).
|
|
// Quoted strings are returned as-is (the comment is inside quotes).
|
|
const quoteMatch = value.match(/^(['"])(.*)(\1)$/);
|
|
if (quoteMatch) return quoteMatch[2];
|
|
|
|
const stripped = value.replace(/\s+#.*$/, "");
|
|
if (stripped === "true") return true;
|
|
if (stripped === "false") return false;
|
|
// Recognize empty array/object literals (with or without surrounding quotes)
|
|
const unquoted = stripped.replace(/^['\"]|['\"]$/g, "");
|
|
if (unquoted === "[]") return [];
|
|
if (unquoted === "{}") return {};
|
|
if (/^-?\d+$/.test(stripped)) {
|
|
const n = Number(stripped);
|
|
// Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss
|
|
if (Number.isSafeInteger(n)) return n;
|
|
return stripped;
|
|
}
|
|
return unquoted;
|
|
}
|
|
|
|
/**
|
|
* 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 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] }`
|
|
*/
|
|
/**
|
|
* 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];
|
|
}
|
|
}
|
|
|
|
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 ?? [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
},
|
|
};
|
|
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";
|
|
}
|
|
}
|
|
|
|
function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPreferences {
|
|
return {
|
|
version: override.version ?? base.version,
|
|
always_use_skills: mergeStringLists(base.always_use_skills, override.always_use_skills),
|
|
prefer_skills: mergeStringLists(base.prefer_skills, override.prefer_skills),
|
|
avoid_skills: mergeStringLists(base.avoid_skills, override.avoid_skills),
|
|
skill_rules: [...(base.skill_rules ?? []), ...(override.skill_rules ?? [])],
|
|
custom_instructions: mergeStringLists(base.custom_instructions, override.custom_instructions),
|
|
models: { ...(base.models ?? {}), ...(override.models ?? {}) },
|
|
skill_discovery: override.skill_discovery ?? base.skill_discovery,
|
|
auto_supervisor: { ...(base.auto_supervisor ?? {}), ...(override.auto_supervisor ?? {}) },
|
|
uat_dispatch: override.uat_dispatch ?? base.uat_dispatch,
|
|
unique_milestone_ids: override.unique_milestone_ids ?? base.unique_milestone_ids,
|
|
budget_ceiling: override.budget_ceiling ?? base.budget_ceiling,
|
|
budget_enforcement: override.budget_enforcement ?? base.budget_enforcement,
|
|
context_pause_threshold: override.context_pause_threshold ?? base.context_pause_threshold,
|
|
notifications: (base.notifications || override.notifications)
|
|
? { ...(base.notifications ?? {}), ...(override.notifications ?? {}) }
|
|
: undefined,
|
|
remote_questions: override.remote_questions
|
|
? { ...(base.remote_questions ?? {}), ...override.remote_questions }
|
|
: base.remote_questions,
|
|
git: (base.git || override.git)
|
|
? { ...(base.git ?? {}), ...(override.git ?? {}) }
|
|
: undefined,
|
|
post_unit_hooks: mergePostUnitHooks(base.post_unit_hooks, override.post_unit_hooks),
|
|
pre_dispatch_hooks: mergePreDispatchHooks(base.pre_dispatch_hooks, override.pre_dispatch_hooks),
|
|
dynamic_routing: (base.dynamic_routing || override.dynamic_routing)
|
|
? { ...(base.dynamic_routing ?? {}), ...(override.dynamic_routing ?? {}) } as DynamicRoutingConfig
|
|
: undefined,
|
|
token_profile: override.token_profile ?? base.token_profile,
|
|
phases: (base.phases || override.phases)
|
|
? { ...(base.phases ?? {}), ...(override.phases ?? {}) }
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
validated.always_use_skills = normalizeStringList(preferences.always_use_skills);
|
|
validated.prefer_skills = normalizeStringList(preferences.prefer_skills);
|
|
validated.avoid_skills = normalizeStringList(preferences.avoid_skills);
|
|
validated.custom_instructions = normalizeStringList(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 = normalizeStringList((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`);
|
|
}
|
|
}
|
|
|
|
// ─── 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;
|
|
// Warn on unknown phase keys
|
|
const knownPhaseKeys = new Set(["skip_research", "skip_reassess", "skip_slice_research"]);
|
|
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 = normalizeStringList(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 = normalizeStringList(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");
|
|
}
|
|
}
|
|
|
|
// ─── 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"]);
|
|
if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) {
|
|
git.isolation = g.isolation as "worktree" | "branch";
|
|
} else {
|
|
errors.push("git.isolation must be one of: worktree, branch");
|
|
}
|
|
}
|
|
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");
|
|
}
|
|
// 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 };
|
|
}
|
|
|
|
function mergeStringLists(base?: unknown, override?: unknown): string[] | undefined {
|
|
const merged = [
|
|
...normalizeStringList(base),
|
|
...normalizeStringList(override),
|
|
]
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
return merged.length > 0 ? Array.from(new Set(merged)) : undefined;
|
|
}
|
|
|
|
function normalizeStringList(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.filter((item): item is string => typeof item === "string")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function mergePostUnitHooks(
|
|
base?: PostUnitHookConfig[],
|
|
override?: PostUnitHookConfig[],
|
|
): PostUnitHookConfig[] | undefined {
|
|
if (!base?.length && !override?.length) return undefined;
|
|
const merged = [...(base ?? [])];
|
|
for (const hook of override ?? []) {
|
|
// Override hooks with same name replace base hooks
|
|
const idx = merged.findIndex(h => h.name === hook.name);
|
|
if (idx >= 0) {
|
|
merged[idx] = hook;
|
|
} else {
|
|
merged.push(hook);
|
|
}
|
|
}
|
|
return merged.length > 0 ? merged : undefined;
|
|
}
|
|
|
|
/**
|
|
* Resolve enabled post-unit hooks from effective preferences.
|
|
* Returns an empty array when no hooks are configured.
|
|
*/
|
|
export function resolvePostUnitHooks(): PostUnitHookConfig[] {
|
|
const prefs = loadEffectiveGSDPreferences();
|
|
return (prefs?.preferences.post_unit_hooks ?? [])
|
|
.filter(h => h.enabled !== false);
|
|
}
|
|
|
|
function mergePreDispatchHooks(
|
|
base?: PreDispatchHookConfig[],
|
|
override?: PreDispatchHookConfig[],
|
|
): PreDispatchHookConfig[] | undefined {
|
|
if (!base?.length && !override?.length) return undefined;
|
|
const merged = [...(base ?? [])];
|
|
for (const hook of override ?? []) {
|
|
const idx = merged.findIndex(h => h.name === hook.name);
|
|
if (idx >= 0) {
|
|
merged[idx] = hook;
|
|
} else {
|
|
merged.push(hook);
|
|
}
|
|
}
|
|
return merged.length > 0 ? merged : undefined;
|
|
}
|
|
|
|
/**
|
|
* Resolve enabled pre-dispatch hooks from effective preferences.
|
|
* Returns an empty array when no hooks are configured.
|
|
*/
|
|
export function resolvePreDispatchHooks(): PreDispatchHookConfig[] {
|
|
const prefs = loadEffectiveGSDPreferences();
|
|
return (prefs?.preferences.pre_dispatch_hooks ?? [])
|
|
.filter(h => h.enabled !== false);
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
}
|