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:
parent
b228bc9f5c
commit
605cd712be
6 changed files with 142 additions and 104 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue