refactor: capability-tier isHeavyModelId, search provider registry, frontmatter_version field, schema docs

- 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>
This commit is contained in:
Mikael Hugo 2026-05-10 23:42:29 +02:00
parent b228bc9f5c
commit 605cd712be
6 changed files with 142 additions and 104 deletions

View file

@ -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.

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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 ||

View file

@ -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,
};

View file

@ -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'",