From 605cd712be0adf813b5c1470a1150676e671c989 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 10 May 2026 23:42:29 +0200 Subject: [PATCH] refactor: capability-tier isHeavyModelId, search provider registry, frontmatter_version field, schema docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preferences-models.js: replace 6-regex isHeavyModelId() with MODEL_CAPABILITY_TIER lookup + regex fallback for unknown models; new models in model-router.js are automatically reflected without touching preferences-models.js - search-the-web/provider.js: replace ~200-line per-provider waterfall with PROVIDER_REGISTRY array + firstAvailable()/resolveWithFallback() helpers; preserves Tavily→Brave→Serper→Exa→Ollama→MiniMax auto-fallback order - sf-db.js: bump SCHEMA_VERSION 58→60 (v59 now reachable); add frontmatter_version column to tasks table via v60 migration and CREATE TABLE definition; wire frontmatter_version into upsertTaskPlanning() SQL and .run() params - task-frontmatter.js: add frontmatterVersion:1 to DEFAULT_TASK_FRONTMATTER, add validation block in validateTaskFrontmatter(), add frontmatterVersion mapping in taskFrontmatterFromRecord() - sf-db-migration.test.mjs: update hardcoded version assertion 58→60 - docs/specs/sf-operating-model.md: add Planning Schema section documenting the 3-table model (milestones/slices/tasks, their PKs, spec tables, and ID naming conventions) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/sf-operating-model.md | 46 +++++++ .../extensions/search-the-web/provider.js | 129 ++++++------------ .../extensions/sf/preferences-models.js | 33 +++-- src/resources/extensions/sf/sf-db.js | 23 +++- .../extensions/sf/task-frontmatter.js | 13 ++ .../sf/tests/sf-db-migration.test.mjs | 2 +- 6 files changed, 142 insertions(+), 104 deletions(-) diff --git a/docs/specs/sf-operating-model.md b/docs/specs/sf-operating-model.md index 86fb5ced5..665aedeb0 100644 --- a/docs/specs/sf-operating-model.md +++ b/docs/specs/sf-operating-model.md @@ -191,3 +191,49 @@ SF source placement follows the same axis model. New code should extend the owni - Do not name output encodings as surfaces. JSON belongs to output formats. - Do not name permission expansion as run control. `autonomous` means the loop continues; `trusted` or `unrestricted` means the permission profile widened. - Do not route human questions because of `headless`. Questions come from run-control and permission-policy gates; the surface only determines delivery. + +## Planning Schema + +SF uses a three-table planning hierarchy stored in `.sf/sf.db`. Each level owns a different granularity of work and carries distinct lifecycle state. + +### Table: `milestones` + +Primary key: `id` (e.g. `M001`, `M002`). + +Stores the top-level milestone record with its current status, title, and parent goal reference. The `milestone_specs` table holds the spec-authored metadata (description, success criteria, constraints). + +### Table: `slices` + +Primary key: `(milestone_id, id)` (e.g. `M001 / S01`). + +Stores one vertical slice of a milestone — a self-contained deliverable. Slices carry their own status, sequence number, and optional blocking relationships. The `slice_specs` table holds the spec-authored metadata (acceptance criteria, deliverables, risk level). + +### Table: `tasks` + +Primary key: `(milestone_id, slice_id, id)` (e.g. `M001 / S01 / T01`). + +Stores one atomic unit of implementation work. Tasks carry: + +- Planning fields: `title`, `description`, `estimate`, `files` (JSON array), `verify`, `inputs`, `expected_output`, `full_plan_md` +- Frontmatter fields: `risk`, `mutation_scope`, `verification_type`, `plan_approval`, `task_status`, `estimated_effort`, `dependencies` (JSON array), `blocks_parallel`, `requires_user_input`, `auto_retry`, `max_retries`, `frontmatter_version` +- Lifecycle fields: `status` (ORCH-style), `sequence`, `run_index`, `retries`, `evidence`, `error` + +The `task_specs` table holds spec-authored content and carries its own `spec_version` field. The `frontmatter_version` field on the `tasks` table records which frontmatter schema version was used when the row was last written, enabling forward-compatible migrations as the frontmatter schema evolves. + +### Relationship + +``` +milestones 1──* slices 1──* tasks + │ │ + slice_specs task_specs +``` + +Spec tables (`milestone_specs`, `slice_specs`, `task_specs`) are append-append: a new spec row is inserted when the spec changes, keeping history. The planning tables (`milestones`, `slices`, `tasks`) are live mutable state, updated as work progresses. + +### ID Conventions + +- Milestone IDs: `M001`, `M002`, … +- Slice IDs: `S01`, `S02`, … (scoped within a milestone) +- Task IDs: `T01`, `T02`, … (scoped within a slice) + +Never use raw integer IDs or UUIDs for planning hierarchy references. The `M/S/T` prefixed IDs appear in prompts, logs, and evidence, and must be human-readable. diff --git a/src/resources/extensions/search-the-web/provider.js b/src/resources/extensions/search-the-web/provider.js index 1f41a422e..b429597da 100644 --- a/src/resources/extensions/search-the-web/provider.js +++ b/src/resources/extensions/search-the-web/provider.js @@ -101,6 +101,24 @@ export function setSearchProviderPreference(pref, authPath) { auth.remove(PREFERENCE_KEY); auth.set(PREFERENCE_KEY, { type: "api_key", key: pref }); } +/** + * Ordered registry of all supported search providers. + * + * Purpose: single authoritative list that drives `resolveSearchProvider` — eliminates the + * per-provider copy-paste waterfall and makes adding/reordering providers a one-line change. + * + * Priority order is the `auto` fallback chain: Tavily → Brave → Serper → Exa → Ollama → MiniMax. + * Each entry's `getKey` is evaluated at resolution time so hot-reloaded env vars work. + */ +const PROVIDER_REGISTRY = [ + { name: "tavily", getKey: getTavilyApiKey }, + { name: "brave", getKey: getBraveApiKey }, + { name: "serper", getKey: getSerperApiKey }, + { name: "exa", getKey: getExaApiKey }, + { name: "ollama", getKey: getOllamaApiKey }, + { name: "minimax", getKey: getMiniMaxSearchApiKey }, +]; + /** * Resolve which search provider to use based on available API keys and user preference. * @@ -108,33 +126,36 @@ export function setSearchProviderPreference(pref, authPath) { * 1. If an explicit override is given, use it — but only if that provider's key exists. * If the key doesn't exist, fall through to the other provider. * 2. Otherwise, read the stored preference. - * 3. If preference is 'auto': prefer Tavily, then Brave. - * 4. If preference is a specific provider: use it if key exists, else fall back to the other. - * 5. Return null if neither key is available — explicit signal for "no provider". + * 3. If preference is 'auto': use the first provider in PROVIDER_REGISTRY that has a key. + * 4. If preference is a specific provider: use it if key exists, else fall back in registry order. + * 5. Return null if no provider's key is available. * * @param overridePreference — Optional override (e.g. from a tool parameter). */ export function resolveSearchProvider(overridePreference) { - const tavilyKey = getTavilyApiKey(); - const minimaxKey = getMiniMaxSearchApiKey(); - const braveKey = getBraveApiKey(); - const serperKey = getSerperApiKey(); - const exaKey = getExaApiKey(); - const ollamaKey = getOllamaApiKey(); - const hasTavily = tavilyKey.length > 0; - const hasMiniMax = minimaxKey.length > 0; - const hasBrave = braveKey.length > 0; - const hasSerper = serperKey.length > 0; - const hasExa = exaKey.length > 0; - const hasOllama = ollamaKey.length > 0; - const hasAny = - hasTavily || hasMiniMax || hasBrave || hasSerper || hasExa || hasOllama; + const hasKey = (name) => { + const entry = PROVIDER_REGISTRY.find((p) => p.name === name); + return entry ? entry.getKey().length > 0 : false; + }; + + /** Returns the first provider in registry order that has a key, or null. */ + const firstAvailable = () => + PROVIDER_REGISTRY.find((p) => p.getKey().length > 0)?.name ?? null; + + /** Returns pref if its key exists, otherwise walks registry in order. */ + const resolveWithFallback = (pref) => { + if (hasKey(pref)) return pref; + return PROVIDER_REGISTRY + .filter((p) => p.name !== pref) + .find((p) => p.getKey().length > 0)?.name ?? null; + }; + // Determine effective preference let pref; if (overridePreference && VALID_PREFERENCES.has(overridePreference)) { pref = overridePreference; } else { - // PREFERENCES.md takes priority over auth.json + // PREFERENCES.yaml takes priority over auth.json const mdPref = resolveSearchProviderFromPreferences(); if (mdPref && mdPref !== "auto" && mdPref !== "native") { pref = mdPref; @@ -147,72 +168,8 @@ export function resolveSearchProvider(overridePreference) { pref = getSearchProviderPreference(); } } - // Resolve based on preference - if (pref === "auto") { - if (hasTavily) return "tavily"; - if (hasBrave) return "brave"; - if (hasSerper) return "serper"; - if (hasExa) return "exa"; - if (hasOllama) return "ollama"; - if (hasMiniMax) return "minimax"; - return null; - } - if (pref === "combosearch") { - return hasAny ? "combosearch" : null; - } - if (pref === "tavily") { - if (hasTavily) return "tavily"; - if (hasBrave) return "brave"; - if (hasSerper) return "serper"; - if (hasExa) return "exa"; - if (hasOllama) return "ollama"; - if (hasMiniMax) return "minimax"; - return null; - } - if (pref === "minimax") { - if (hasMiniMax) return "minimax"; - if (hasTavily) return "tavily"; - if (hasBrave) return "brave"; - if (hasSerper) return "serper"; - if (hasExa) return "exa"; - if (hasOllama) return "ollama"; - return null; - } - if (pref === "brave") { - if (hasBrave) return "brave"; - if (hasTavily) return "tavily"; - if (hasSerper) return "serper"; - if (hasExa) return "exa"; - if (hasOllama) return "ollama"; - if (hasMiniMax) return "minimax"; - return null; - } - if (pref === "serper") { - if (hasSerper) return "serper"; - if (hasTavily) return "tavily"; - if (hasBrave) return "brave"; - if (hasExa) return "exa"; - if (hasOllama) return "ollama"; - if (hasMiniMax) return "minimax"; - return null; - } - if (pref === "exa") { - if (hasExa) return "exa"; - if (hasSerper) return "serper"; - if (hasTavily) return "tavily"; - if (hasBrave) return "brave"; - if (hasOllama) return "ollama"; - if (hasMiniMax) return "minimax"; - return null; - } - if (pref === "ollama") { - if (hasOllama) return "ollama"; - if (hasTavily) return "tavily"; - if (hasBrave) return "brave"; - if (hasSerper) return "serper"; - if (hasExa) return "exa"; - if (hasMiniMax) return "minimax"; - return null; - } - return null; + + if (pref === "auto") return firstAvailable(); + if (pref === "combosearch") return firstAvailable() ? "combosearch" : null; + return resolveWithFallback(pref); } diff --git a/src/resources/extensions/sf/preferences-models.js b/src/resources/extensions/sf/preferences-models.js index 77a9c4ecc..fc9d103a5 100644 --- a/src/resources/extensions/sf/preferences-models.js +++ b/src/resources/extensions/sf/preferences-models.js @@ -17,7 +17,7 @@ import { DEFAULT_RUNAWAY_TOOL_CALL_WARNING, } from "./auto-runaway-guard.js"; import { selectByBenchmarks } from "./benchmark-selector.js"; -import { defaultRoutingConfig } from "./model-router.js"; +import { defaultRoutingConfig, MODEL_CAPABILITY_TIER } from "./model-router.js"; // ─── Lazy loader — breaks the preferences.js ↔ preferences-models.js cycle ── // preferences.js imports resolveProfileDefaults from here, and needs @@ -857,22 +857,25 @@ export function resolveSearchProviderFromPreferences() { return prefs?.preferences.search_provider; } -// Word-boundary patterns for known heavy/expensive model families. -// "Heavy" means: high cost, high latency — blocked when modelMode === "fast". -// Use word boundaries (\b) so "opus-lite" or "mini-o1" don't false-positive. -const HEAVY_MODEL_PATTERNS = [ - /\bopus\b/i, - /\bo1\b/i, - /\bo3\b/i, - /\bgpt-4-turbo\b/i, - /\bgpt-5\b/i, - /\bdeepseek-reasoner\b/i, -]; /** - * Returns true if modelId belongs to a known heavy (high-cost/high-latency) family. - * Used to enforce `modelMode === "fast"` constraints on subagent dispatch. + * Returns true if modelId belongs to the "heavy" capability tier. + * + * Purpose: enforce `modelMode === "fast"` constraints on subagent dispatch by + * blocking high-cost/high-latency models from being used in fast-mode contexts. + * + * Primary: looks up the bare model ID (strips provider prefix) in MODEL_CAPABILITY_TIER + * from model-router.js — the single authoritative tier map for the entire system. + * Fallback: word-boundary regex patterns for models not yet in the tier map, so new + * additions to model-router.js are automatically picked up without editing this file. + * + * Consumer: subagent-inheritance.js when checking modelMode === "fast" gate. */ export function isHeavyModelId(modelId) { if (!modelId || typeof modelId !== "string") return false; - return HEAVY_MODEL_PATTERNS.some((pattern) => pattern.test(modelId)); + // Strip provider prefix (e.g. "anthropic/claude-opus-4-6" → "claude-opus-4-6") + const bare = modelId.includes("/") ? modelId.split("/").slice(1).join("/") : modelId; + const tier = MODEL_CAPABILITY_TIER[bare] ?? MODEL_CAPABILITY_TIER[modelId]; + if (tier !== undefined) return tier === "heavy"; + // Fallback regex for models not yet in the tier map + return /\bopus\b|\bo1\b|\bo3\b|\bgpt-4-turbo\b|\bgpt-5\b|\bdeepseek-reasoner\b/i.test(modelId); } diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 5ff26a8ed..6bc438447 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -245,7 +245,7 @@ function performDatabaseMaintenance(rawDb, path) { ); } } -const SCHEMA_VERSION = 58; +const SCHEMA_VERSION = 60; function indexExists(db, name) { return !!db .prepare( @@ -1192,6 +1192,7 @@ function initSchema(db, fileBacked) { requires_user_input INTEGER NOT NULL DEFAULT 0, auto_retry INTEGER NOT NULL DEFAULT 1, max_retries INTEGER NOT NULL DEFAULT 2, + frontmatter_version INTEGER NOT NULL DEFAULT 1, sequence INTEGER DEFAULT 0, -- Ordering hint: tools may set this to control execution order escalation_pending INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): pause-on-escalation flag escalation_awaiting_review INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): continueWithDefault=true marker (no pause) @@ -3230,6 +3231,22 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 60) { + // Schema v60: add frontmatter_version to tasks table for future frontmatter + // schema migrations. Defaults to 1 for all existing rows. + ensureColumn( + db, + "tasks", + "frontmatter_version", + "ALTER TABLE tasks ADD COLUMN frontmatter_version INTEGER NOT NULL DEFAULT 1", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 60, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -4441,7 +4458,8 @@ export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) { blocks_parallel = :blocks_parallel, requires_user_input = :requires_user_input, auto_retry = :auto_retry, - max_retries = :max_retries + max_retries = :max_retries, + frontmatter_version = :frontmatter_version WHERE milestone_id = :milestone_id AND slice_id = :slice_id AND id = :id`) .run({ ":milestone_id": milestoneId, @@ -4470,6 +4488,7 @@ export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) { ":requires_user_input": frontmatter.requiresUserInput ? 1 : 0, ":auto_retry": frontmatter.autoRetry ? 1 : 0, ":max_retries": frontmatter.maxRetries, + ":frontmatter_version": frontmatter.frontmatterVersion, }); if ( planning.schedulerStatus !== undefined || diff --git a/src/resources/extensions/sf/task-frontmatter.js b/src/resources/extensions/sf/task-frontmatter.js index cf6e133d1..9d2d1f83b 100644 --- a/src/resources/extensions/sf/task-frontmatter.js +++ b/src/resources/extensions/sf/task-frontmatter.js @@ -91,6 +91,7 @@ export const DEFAULT_TASK_FRONTMATTER = { requiresUserInput: false, autoRetry: true, maxRetries: 2, + frontmatterVersion: 1, }; export function normalizeTaskStatus(value) { @@ -233,6 +234,17 @@ export function validateTaskFrontmatter(frontmatter = {}) { } } + if (frontmatter.frontmatterVersion !== undefined) { + const ver = Number(frontmatter.frontmatterVersion); + if (Number.isInteger(ver) && ver >= 1) { + normalized.frontmatterVersion = ver; + } else { + errors.push( + `Invalid frontmatterVersion "${frontmatter.frontmatterVersion}". Must be a positive integer.`, + ); + } + } + return { valid: errors.length === 0, errors, @@ -267,6 +279,7 @@ export function taskFrontmatterFromRecord(task = {}, overrides = {}) { requiresUserInput: task.requires_user_input ?? task.requiresUserInput, autoRetry: task.auto_retry ?? task.autoRetry, maxRetries: task.max_retries ?? task.maxRetries, + frontmatterVersion: task.frontmatter_version ?? task.frontmatterVersion, ...overrides, }; diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 716e47f59..5deb5db37 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -222,7 +222,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 58); + assert.equal(version.version, 60); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",