diff --git a/biome.json b/biome.json index 768b8e168..acfa3f177 100644 --- a/biome.json +++ b/biome.json @@ -7,11 +7,14 @@ }, "files": { "includes": [ - "**", + "**/*.{js,cjs,mjs,ts,tsx,json,jsonc,css,html}", + "!!.sf", + "!!.omg", "!!**/dist", "!!**/dist-test", "!!**/rust-engine/npm", - "!!src/resources/skills/create-sf-extension/templates/**" + "!!**/*.min.js", + "!!src/resources/skills/create-sf-extension/templates" ] }, "formatter": { @@ -48,6 +51,11 @@ "quoteStyle": "double" } }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, "assist": { "enabled": true, "actions": { diff --git a/packages/pi-coding-agent/src/utils/shell-env.test.ts b/packages/pi-coding-agent/src/utils/shell-env.test.ts new file mode 100644 index 000000000..34b7a4703 --- /dev/null +++ b/packages/pi-coding-agent/src/utils/shell-env.test.ts @@ -0,0 +1,23 @@ +/** + * shell-env.test.ts — Regression coverage for automated shell environment. + * + * Purpose: keep agent-run git commands non-interactive so operations such as + * `git rebase --continue` cannot hang by opening an editor. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { getShellEnv } from "./shell.js"; + +describe("getShellEnv", () => { + it("getShellEnv_when_git_rebase_continues_disables_editor_prompts", () => { + const env = getShellEnv(); + + assert.equal(env.GIT_TERMINAL_PROMPT, "0"); + assert.equal(env.GIT_EDITOR, "true"); + assert.equal(env.GIT_SEQUENCE_EDITOR, "true"); + assert.equal(env.GIT_ASKPASS, ""); + assert.equal(env.VISUAL, "true"); + assert.equal(env.EDITOR, "true"); + }); +}); diff --git a/packages/pi-coding-agent/src/utils/shell.ts b/packages/pi-coding-agent/src/utils/shell.ts index 78c8b6a9d..536345727 100644 --- a/packages/pi-coding-agent/src/utils/shell.ts +++ b/packages/pi-coding-agent/src/utils/shell.ts @@ -1,6 +1,6 @@ +import { spawn, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { delimiter } from "node:path"; -import { spawn, spawnSync } from "child_process"; import { getBinDir, getSettingsPath } from "../config.js"; import { SettingsManager } from "../core/settings-manager.js"; @@ -13,7 +13,10 @@ function findBashOnPath(): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) try { - const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("where", ["bash.exe"], { + encoding: "utf-8", + timeout: 5000, + }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch && existsSync(firstMatch)) { @@ -28,7 +31,10 @@ function findBashOnPath(): string | null { // Unix: Use 'which' and trust its output (handles Termux and special filesystems) try { - const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("which", ["bash"], { + encoding: "utf-8", + timeout: 5000, + }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch) { @@ -126,20 +132,35 @@ export function getShellConfig(): { shell: string; args: string[] } { */ export function sanitizeCommand(command: string): string { if (process.platform !== "win32") return command; - return command.replace(/(\d*>>?) *\bNUL\b(?=\s|;|\||&|\)|$)/gi, "$1 /dev/null"); + return command.replace( + /(\d*>>?) *\bNUL\b(?=\s|;|\||&|\)|$)/gi, + "$1 /dev/null", + ); } export function getShellEnv(): NodeJS.ProcessEnv { const binDir = getBinDir(); - const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; + const pathKey = + Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? + "PATH"; const currentPath = process.env[pathKey] ?? ""; const pathEntries = currentPath.split(delimiter).filter(Boolean); const hasBinDir = pathEntries.includes(binDir); - const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter); + const updatedPath = hasBinDir + ? currentPath + : [binDir, currentPath].filter(Boolean).join(delimiter); return { ...process.env, [pathKey]: updatedPath, + // Agent-run shells must not open an editor or credential prompt. Commands + // such as `git rebase --continue` should either complete or fail visibly. + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_ASKPASS: "", + VISUAL: "true", + EDITOR: "true", }; } diff --git a/src/resources/extensions/sf/code-intelligence.d.ts b/src/resources/extensions/sf/code-intelligence.d.ts index f3e54d4f2..a57f199af 100644 --- a/src/resources/extensions/sf/code-intelligence.d.ts +++ b/src/resources/extensions/sf/code-intelligence.d.ts @@ -1,28 +1,61 @@ -export const PROJECT_RAG_MCP_SERVER_NAME: string; -export function detectProjectRag(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): unknown; -export function resolveProjectRagBinary(env?: NodeJS.ProcessEnv): string | null; export function resolveSiftBinary(env?: NodeJS.ProcessEnv): string | null; -export function resolveSiftWarmupRuntimeDirs(projectRoot: string): { searchCache: string; tmpDir: string }; -export function ensureSiftRuntimeDirs(projectRoot: string): { searchCache: string; tmpDir: string }; -export function buildSiftEnv(projectRoot: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv; -export function resolveSiftSearchScope(projectRoot: string, scope?: string): string; -export function detectSift(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): unknown; -export function ensureSiftIndexWarmup(projectRoot: string, prefs: Record, options?: Record): Promise; -export function resolveProjectRagBuildJobs(env?: NodeJS.ProcessEnv): number; -export function findProjectRagSourceDir(projectRoot: string, env?: NodeJS.ProcessEnv): string | null; -export function resolveProjectRagBinaryForProject(projectRoot: string, env?: NodeJS.ProcessEnv): string | null; -export function buildProjectRagMcpServerConfig(projectRoot?: string, env?: NodeJS.ProcessEnv): Record; -export function buildProjectRagBinary(projectRoot: string, env?: NodeJS.ProcessEnv): boolean; -export function ensureProjectRagMcpConfig(projectRoot: string, env?: NodeJS.ProcessEnv): void; -export function resolveCodebaseIndexerBackendName(prefs: Record): string; -export function resolveEffectiveCodebaseIndexerBackendName(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export function getCodebaseIndexerBackend(prefsOrName: Record | string): unknown; -export function detectCodebaseIndexer(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): unknown; -export function formatCodebaseIndexerStatus(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export function buildCodeIntelligenceContextBlock(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export function formatProjectRagStatus(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export function formatSiftStatus(projectRoot: string, prefs: Record, env?: NodeJS.ProcessEnv): string; -export const PROJECT_RAG_CODEBASE_INDEXER_BACKEND: Record; +export function resolveSiftWarmupRuntimeDirs(projectRoot: string): { + searchCache: string; + tmpDir: string; +}; +export function ensureSiftRuntimeDirs(projectRoot: string): { + searchCache: string; + tmpDir: string; +}; +export function buildSiftEnv( + projectRoot: string, + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv; +export function resolveSiftSearchScope( + projectRoot: string, + scope?: string, +): string; +export function detectSift( + projectRoot: string, + prefs: Record, + env?: NodeJS.ProcessEnv, +): unknown; +export function ensureSiftIndexWarmup( + projectRoot: string, + prefs: Record, + options?: Record, +): Promise; +export function resolveCodebaseIndexerBackendName( + prefs: Record, +): string; +export function resolveEffectiveCodebaseIndexerBackendName( + projectRoot: string, + prefs: Record, + env?: NodeJS.ProcessEnv, +): string; +export function getCodebaseIndexerBackend( + prefsOrName: Record | string, +): unknown; +export function detectCodebaseIndexer( + projectRoot: string, + prefs: Record, + env?: NodeJS.ProcessEnv, +): unknown; +export function formatCodebaseIndexerStatus( + projectRoot: string, + prefs: Record, + env?: NodeJS.ProcessEnv, +): string; +export function buildCodeIntelligenceContextBlock( + projectRoot: string, + prefs: Record, + env?: NodeJS.ProcessEnv, +): string; +export function formatSiftStatus( + projectRoot: string, + prefs: Record, + env?: NodeJS.ProcessEnv, +): string; export const SIFT_CODEBASE_INDEXER_BACKEND: Record; export const NO_CODEBASE_INDEXER_BACKEND: Record; export const CODEBASE_INDEXER_BACKENDS: Record; diff --git a/src/resources/extensions/sf/code-intelligence.js b/src/resources/extensions/sf/code-intelligence.js index fca6c252b..d8772922b 100644 --- a/src/resources/extensions/sf/code-intelligence.js +++ b/src/resources/extensions/sf/code-intelligence.js @@ -1,25 +1,24 @@ /** * Optional code-intelligence backends for SF. * - * CODEBASE.md stays the durable baseline. Codebase indexers are optional - * accelerators for local code retrieval. + * Sift is the live code retrieval path. CODEBASE.md stays the durable fallback + * when the live index is unavailable, cold, or degraded. */ import { spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; import { delimiter, isAbsolute, join, relative, resolve } from "node:path"; -export const PROJECT_RAG_MCP_SERVER_NAME = "project-rag"; -const PROJECT_RAG_BINARY_NAME = process.platform === "win32" ? "project-rag.exe" : "project-rag"; + const SIFT_BINARY_NAME = process.platform === "win32" ? "sift.exe" : "sift"; -const PROJECT_RAG_SOURCE_CANDIDATES = [ - "vendor/project-rag", - "vendor/brainwires/project-rag", - "third_party/project-rag", - "third_party/brainwires/project-rag", - "tools/project-rag", - "project-rag", -]; const DEFAULT_SIFT_WARMUP_TTL_MS = 6 * 60 * 60 * 1000; -const DEFAULT_SIFT_WARMUP_QUERY = "repo architecture source tests entrypoints configuration"; +const DEFAULT_SIFT_WARMUP_QUERY = + "repo architecture source tests entrypoints configuration"; const DEFAULT_SIFT_WARMUP_LIMIT = 1; const DEFAULT_SIFT_WARMUP_RETRIEVER_TIMEOUT_MS = 30_000; const DEFAULT_SIFT_WARMUP_HARD_TIMEOUT_SEC = 600; @@ -27,18 +26,21 @@ const SIFT_WARMUP_KILL_GRACE_SEC = 10; const DEFAULT_SIFT_HEALTH_TIMEOUT_MS = 60_000; const SIFT_HEALTH_CACHE = new Map(); const SIFT_CACHE_POLLUTION_PATTERNS = [ - { label: ".claude worktrees", pattern: /(?:^|[/\\])\.claude[/\\]/ }, - { label: ".git internals", pattern: /(?:^|[/\\])\.git[/\\]/ }, - { label: "dist-test output", pattern: /(?:^|[/\\])dist-test[/\\]/ }, - { label: "node_modules", pattern: /(?:^|[/\\])node_modules[/\\]/ }, - { label: "package dist output", pattern: /(?:^|[/\\])packages[/\\][^/\\]+[/\\]dist[/\\]/ }, + { label: ".claude worktrees", pattern: /(?:^|[/\\])\.claude[/\\]/ }, + { label: ".git internals", pattern: /(?:^|[/\\])\.git[/\\]/ }, + { label: "dist-test output", pattern: /(?:^|[/\\])dist-test[/\\]/ }, + { label: "node_modules", pattern: /(?:^|[/\\])node_modules[/\\]/ }, + { + label: "package dist output", + pattern: /(?:^|[/\\])packages[/\\][^/\\]+[/\\]dist[/\\]/, + }, ]; export function resolveSiftWarmupRuntimeDirs(projectRoot) { - const runtimeRoot = join(projectRoot, ".sf", "runtime", "sift"); - return { - searchCache: join(runtimeRoot, "search-cache"), - tmpDir: join(runtimeRoot, "tmp"), - }; + const runtimeRoot = join(projectRoot, ".sf", "runtime", "sift"); + return { + searchCache: join(runtimeRoot, "search-cache"), + tmpDir: join(runtimeRoot, "tmp"), + }; } /** * Ensure the repo-local Sift runtime directories exist. @@ -49,18 +51,18 @@ export function resolveSiftWarmupRuntimeDirs(projectRoot) { * Consumer: Sift warmup, status probes, `sift_search`, and `codebase_search`. */ export function ensureSiftRuntimeDirs(projectRoot) { - const dirs = resolveSiftWarmupRuntimeDirs(projectRoot); - mkdirSync(dirs.searchCache, { recursive: true }); - mkdirSync(dirs.tmpDir, { recursive: true }); - return dirs; + const dirs = resolveSiftWarmupRuntimeDirs(projectRoot); + mkdirSync(dirs.searchCache, { recursive: true }); + mkdirSync(dirs.tmpDir, { recursive: true }); + return dirs; } export function buildSiftEnv(projectRoot, env) { - const dirs = resolveSiftWarmupRuntimeDirs(projectRoot); - return { - ...env, - SIFT_SEARCH_CACHE: dirs.searchCache, - TMPDIR: dirs.tmpDir, - }; + const dirs = resolveSiftWarmupRuntimeDirs(projectRoot); + return { + ...env, + SIFT_SEARCH_CACHE: dirs.searchCache, + TMPDIR: dirs.tmpDir, + }; } /** * Resolve a Sift search scope to the form Sift's local ignore matcher expects. @@ -71,909 +73,698 @@ export function buildSiftEnv(projectRoot, env) { * Consumer: Sift warmup, `sift_search`, and `codebase_search`. */ export function resolveSiftSearchScope(projectRoot, scope) { - const normalizedRoot = normalizeProjectRoot(projectRoot); - const requested = typeof scope === "string" && scope.trim() ? scope.trim() : "."; - const absolute = isAbsolute(requested) - ? resolve(requested) - : resolve(normalizedRoot, requested); - const rel = relative(normalizedRoot, absolute); - if (!rel) - return "."; - if (!rel.startsWith("..") && !isAbsolute(rel)) - return rel; - return requested; -} -function readJsonConfig(configPath) { - if (!existsSync(configPath)) - return {}; - const raw = readFileSync(configPath, "utf-8"); - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : {}; -} -function readMcpConfigEntries(projectRoot) { - const entries = []; - const seen = new Set(); - for (const configPath of [ - join(projectRoot, ".mcp.json"), - join(projectRoot, ".sf", "mcp.json"), - ]) { - try { - const data = readJsonConfig(configPath); - const servers = data.mcpServers ?? data.servers; - if (!servers || typeof servers !== "object") - continue; - for (const [name, config] of Object.entries(servers)) { - if (seen.has(name)) - continue; - seen.add(name); - entries.push({ name, config, configPath }); - } - } - catch { - // Malformed optional MCP config should not block SF startup. - } - } - return entries; -} -function configLooksLikeProjectRag(name, config) { - const haystack = [ - name, - config.command ?? "", - ...(config.args ?? []), - config.cwd ?? "", - ] - .join(" ") - .toLowerCase(); - return /project[-_]?rag|brainwires/.test(haystack); + const normalizedRoot = normalizeProjectRoot(projectRoot); + const requested = + typeof scope === "string" && scope.trim() ? scope.trim() : "."; + const absolute = isAbsolute(requested) + ? resolve(requested) + : resolve(normalizedRoot, requested); + const rel = relative(normalizedRoot, absolute); + if (!rel) return "."; + if (!rel.startsWith("..") && !isAbsolute(rel)) return rel; + return requested; } function normalizeProjectRoot(projectRoot) { - return resolve(projectRoot); + return resolve(projectRoot); } function commandExists(command, env = process.env) { - if (!command) - return false; - return lookupExecutable(command, env) !== null; -} -export function detectProjectRag(projectRoot, prefs, env = process.env) { - const mode = prefs?.project_rag ?? "auto"; - if (mode === "off") { - return { - backend: "projectRag", - status: "disabled", - reason: "codebase.project_rag is off", - }; - } - const configuredServer = prefs?.project_rag_server?.trim(); - const normalizedRoot = normalizeProjectRoot(projectRoot); - const binaryPath = resolveProjectRagBinaryForProject(normalizedRoot, env) ?? undefined; - const sourceDir = findProjectRagSourceDir(normalizedRoot, env) ?? undefined; - const entries = readMcpConfigEntries(normalizedRoot); - const match = entries.find(({ name, config }) => configuredServer - ? name === configuredServer - : configLooksLikeProjectRag(name, config)); - if (match) { - const configuredCommandExists = commandExists(match.config.command, env); - return { - backend: "projectRag", - status: "configured", - serverName: match.name, - configPath: match.configPath, - command: match.config.command, - binaryPath, - sourceDir, - reason: configuredCommandExists - ? "project-rag MCP server configured" - : "project-rag MCP server configured but command is not currently executable", - }; - } - return { - backend: "projectRag", - status: "missing", - binaryPath, - sourceDir, - reason: mode === "required" - ? "codebase.project_rag is required but no project-rag MCP server is configured" - : "no project-rag MCP server configured", - }; + if (!command) return false; + return lookupExecutable(command, env) !== null; } function lookupExecutable(command, env = process.env) { - if (/[\\/]/.test(command) && existsSync(command)) - return command; - const pathValue = env.PATH ?? ""; - for (const dir of pathValue.split(delimiter).filter(Boolean)) { - const candidate = join(dir, command); - if (existsSync(candidate)) - return candidate; - } - return null; + if (/[\\/]/.test(command) && existsSync(command)) return command; + const pathValue = env.PATH ?? ""; + for (const dir of pathValue.split(delimiter).filter(Boolean)) { + const candidate = join(dir, command); + if (existsSync(candidate)) return candidate; + } + return null; } function resolveSiftWarmupHardTimeoutSec(env, override) { - if (env.SF_SIFT_HARD_TIMEOUT_DISABLE === "1") - return null; - if (override !== undefined) { - return Number.isFinite(override) && override > 0 - ? Math.floor(override) - : null; - } - const raw = env.SF_SIFT_HARD_TIMEOUT_SEC?.trim(); - if (raw) { - const parsed = Number.parseInt(raw, 10); - if (parsed === 0) - return null; - if (Number.isFinite(parsed) && parsed > 0) - return parsed; - } - return DEFAULT_SIFT_WARMUP_HARD_TIMEOUT_SEC; + if (env.SF_SIFT_HARD_TIMEOUT_DISABLE === "1") return null; + if (override !== undefined) { + return Number.isFinite(override) && override > 0 + ? Math.floor(override) + : null; + } + const raw = env.SF_SIFT_HARD_TIMEOUT_SEC?.trim(); + if (raw) { + const parsed = Number.parseInt(raw, 10); + if (parsed === 0) return null; + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return DEFAULT_SIFT_WARMUP_HARD_TIMEOUT_SEC; } function resolveSiftWarmupTimeoutWrapper(env, timeoutSec) { - if (process.platform === "win32") - return null; - const candidates = process.platform === "darwin" - ? ["gtimeout", "timeout"] - : ["timeout", "gtimeout"]; - for (const candidate of candidates) { - const binary = lookupExecutable(candidate, env); - if (binary) { - return { - binary, - wrapperArgs: [ - `--kill-after=${SIFT_WARMUP_KILL_GRACE_SEC}`, - String(timeoutSec), - ], - timeoutSec, - }; - } - } - return null; -} -export function resolveProjectRagBinary(env = process.env) { - const explicit = env.SF_PROJECT_RAG_BIN?.trim() || env.PROJECT_RAG_BIN?.trim(); - if (explicit) - return explicit; - return lookupExecutable("project-rag", env); + if (process.platform === "win32") return null; + const candidates = + process.platform === "darwin" + ? ["gtimeout", "timeout"] + : ["timeout", "gtimeout"]; + for (const candidate of candidates) { + const binary = lookupExecutable(candidate, env); + if (binary) { + return { + binary, + wrapperArgs: [ + `--kill-after=${SIFT_WARMUP_KILL_GRACE_SEC}`, + String(timeoutSec), + ], + timeoutSec, + }; + } + } + return null; } export function resolveSiftBinary(env = process.env) { - const explicit = env.SIFT_PATH?.trim(); - if (explicit) - return explicit; - return (lookupExecutable(SIFT_BINARY_NAME, env) ?? - (SIFT_BINARY_NAME === "sift" ? null : lookupExecutable("sift", env))); + const explicit = env.SIFT_PATH?.trim(); + if (explicit) return explicit; + return ( + lookupExecutable(SIFT_BINARY_NAME, env) ?? + (SIFT_BINARY_NAME === "sift" ? null : lookupExecutable("sift", env)) + ); } function resolveSiftHealthTimeoutMs(env) { - const raw = env.SF_SIFT_HEALTH_TIMEOUT_MS?.trim(); - if (!raw) - return DEFAULT_SIFT_HEALTH_TIMEOUT_MS; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SIFT_HEALTH_TIMEOUT_MS; + const raw = env.SF_SIFT_HEALTH_TIMEOUT_MS?.trim(); + if (!raw) return DEFAULT_SIFT_HEALTH_TIMEOUT_MS; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_SIFT_HEALTH_TIMEOUT_MS; } function resolveSiftHealthProbePath(projectRoot) { - for (const candidate of ["src", "packages", "tests"]) { - const absolute = join(projectRoot, candidate); - if (existsSync(absolute)) - return candidate; - } - return "."; + for (const candidate of ["src", "packages", "tests"]) { + const absolute = join(projectRoot, candidate); + if (existsSync(absolute)) return candidate; + } + return "."; } function runSiftHealthProbe(projectRoot, binaryPath, env) { - const normalizedRoot = normalizeProjectRoot(projectRoot); - const timeoutMs = resolveSiftHealthTimeoutMs(env); - const probePath = resolveSiftHealthProbePath(normalizedRoot); - const cacheKey = [ - normalizedRoot, - binaryPath, - env.SIFT_PATH ?? "", - env.SF_SIFT_HEALTH_TIMEOUT_MS ?? "", - env.SF_SIFT_HEALTHCHECK_DISABLE ?? "", - ].join("\0"); - if (SIFT_HEALTH_CACHE.has(cacheKey)) - return SIFT_HEALTH_CACHE.get(cacheKey); - const dirs = ensureSiftRuntimeDirs(normalizedRoot); - if (env.SF_SIFT_HEALTHCHECK_DISABLE === "1") { - const skipped = { - ok: true, - probePath, - timeoutMs, - searchCache: dirs.searchCache, - tmpDir: dirs.tmpDir, - reason: "sift health probe disabled", - }; - SIFT_HEALTH_CACHE.set(cacheKey, skipped); - return skipped; - } - const result = spawnSync(binaryPath, [ - "search", - "--json", - "--strategy", - "bm25", - "--limit", - "1", - "--retriever-timeout-ms", - String(Math.min(timeoutMs, 1_000)), - probePath, - "function", - ], { - cwd: normalizedRoot, - env: buildSiftEnv(normalizedRoot, env), - encoding: "utf-8", - maxBuffer: 1024 * 1024, - timeout: timeoutMs, - }); - const probe = { - ok: result.status === 0, - probePath, - timeoutMs, - searchCache: dirs.searchCache, - tmpDir: dirs.tmpDir, - status: result.status, - signal: result.signal, - stderr: result.stderr, - reason: "", - }; - if (probe.ok) { - probe.reason = `sift scoped health probe passed for ${probePath}`; - } - else if (result.error?.code === "ETIMEDOUT" || result.signal) { - probe.reason = `sift scoped health probe timed out after ${timeoutMs}ms for ${probePath}`; - } - else if (result.error) { - probe.reason = `sift scoped health probe failed: ${result.error.message}`; - } - else { - const detail = String(result.stderr || "").trim(); - probe.reason = detail - ? `sift scoped health probe failed: ${detail.slice(0, 300)}` - : `sift scoped health probe exited ${result.status ?? "unknown"}`; - } - SIFT_HEALTH_CACHE.set(cacheKey, probe); - return probe; + const normalizedRoot = normalizeProjectRoot(projectRoot); + const timeoutMs = resolveSiftHealthTimeoutMs(env); + const probePath = resolveSiftHealthProbePath(normalizedRoot); + const cacheKey = [ + normalizedRoot, + binaryPath, + env.SIFT_PATH ?? "", + env.SF_SIFT_HEALTH_TIMEOUT_MS ?? "", + env.SF_SIFT_HEALTHCHECK_DISABLE ?? "", + ].join("\0"); + if (SIFT_HEALTH_CACHE.has(cacheKey)) return SIFT_HEALTH_CACHE.get(cacheKey); + const dirs = ensureSiftRuntimeDirs(normalizedRoot); + if (env.SF_SIFT_HEALTHCHECK_DISABLE === "1") { + const skipped = { + ok: true, + probePath, + timeoutMs, + searchCache: dirs.searchCache, + tmpDir: dirs.tmpDir, + reason: "sift health probe disabled", + }; + SIFT_HEALTH_CACHE.set(cacheKey, skipped); + return skipped; + } + const result = spawnSync( + binaryPath, + [ + "search", + "--json", + "--strategy", + "bm25", + "--limit", + "1", + "--retriever-timeout-ms", + String(Math.min(timeoutMs, 1_000)), + probePath, + "function", + ], + { + cwd: normalizedRoot, + env: buildSiftEnv(normalizedRoot, env), + encoding: "utf-8", + maxBuffer: 1024 * 1024, + timeout: timeoutMs, + }, + ); + const probe = { + ok: result.status === 0, + probePath, + timeoutMs, + searchCache: dirs.searchCache, + tmpDir: dirs.tmpDir, + status: result.status, + signal: result.signal, + stderr: result.stderr, + reason: "", + }; + if (probe.ok) { + probe.reason = `sift scoped health probe passed for ${probePath}`; + } else if (result.error?.code === "ETIMEDOUT" || result.signal) { + probe.reason = `sift scoped health probe timed out after ${timeoutMs}ms for ${probePath}`; + } else if (result.error) { + probe.reason = `sift scoped health probe failed: ${result.error.message}`; + } else { + const detail = String(result.stderr || "").trim(); + probe.reason = detail + ? `sift scoped health probe failed: ${detail.slice(0, 300)}` + : `sift scoped health probe exited ${result.status ?? "unknown"}`; + } + SIFT_HEALTH_CACHE.set(cacheKey, probe); + return probe; } function listFilesCapped(root, maxFiles = 32) { - const files = []; - const visit = (dir) => { - if (files.length >= maxFiles) - return; - let entries = []; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } - catch { - return; - } - for (const entry of entries) { - if (files.length >= maxFiles) - return; - const path = join(dir, entry.name); - if (entry.isDirectory()) { - visit(path); - } - else if (entry.isFile()) { - files.push(path); - } - } - }; - visit(root); - return files; + const files = []; + const visit = (dir) => { + if (files.length >= maxFiles) return; + let entries = []; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (files.length >= maxFiles) return; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + visit(path); + } else if (entry.isFile()) { + files.push(path); + } + } + }; + visit(root); + return files; } function inspectSiftCache(projectRoot) { - const dirs = resolveSiftWarmupRuntimeDirs(projectRoot); - const manifestRoot = join(dirs.searchCache, "artifacts", "manifests"); - const samples = []; - for (const manifest of listFilesCapped(manifestRoot, 16)) { - let text = ""; - try { - text = readFileSync(manifest).toString("utf-8"); - } - catch { - continue; - } - for (const { label, pattern } of SIFT_CACHE_POLLUTION_PATTERNS) { - const match = text.match(pattern); - if (match) { - const start = Math.max(0, (match.index ?? 0) - 80); - const end = Math.min(text.length, (match.index ?? 0) + 160); - const sample = text - .slice(start, end) - .replace(/[^\x20-\x7E]+/g, " ") - .trim(); - samples.push({ label, sample }); - break; - } - } - if (samples.length >= 5) - break; - } - return { - inspected: existsSync(manifestRoot), - polluted: samples.length > 0, - samples, - }; + const dirs = resolveSiftWarmupRuntimeDirs(projectRoot); + const manifestRoot = join(dirs.searchCache, "artifacts", "manifests"); + const samples = []; + for (const manifest of listFilesCapped(manifestRoot, 16)) { + let text = ""; + try { + text = readFileSync(manifest).toString("utf-8"); + } catch { + continue; + } + for (const { label, pattern } of SIFT_CACHE_POLLUTION_PATTERNS) { + const match = text.match(pattern); + if (match) { + const start = Math.max(0, (match.index ?? 0) - 80); + const end = Math.min(text.length, (match.index ?? 0) + 160); + const sample = text + .slice(start, end) + .replace(/[^\x20-\x7E]+/g, " ") + .trim(); + samples.push({ label, sample }); + break; + } + } + if (samples.length >= 5) break; + } + return { + inspected: existsSync(manifestRoot), + polluted: samples.length > 0, + samples, + }; } export function detectSift(projectRoot, prefs, env = process.env) { - if (prefs?.indexer_backend === "none") { - return { - backend: "sift", - status: "disabled", - reason: "codebase.indexer_backend is none", - }; - } - const explicit = env.SIFT_PATH?.trim(); - const binaryPath = resolveSiftBinary(env) ?? undefined; - if (!binaryPath) { - return { - backend: "sift", - status: "missing", - reason: "sift binary not found on PATH; set SIFT_PATH or install rupurt/sift.", - }; - } - if (explicit && !commandExists(explicit, env)) { - return { - backend: "sift", - status: "missing", - command: explicit, - binaryPath: explicit, - reason: "SIFT_PATH is set but does not resolve to an executable file.", - }; - } - const warmup = readSiftWarmupMarker(projectRoot); - if (warmup?.status === "warming") { - const dirs = ensureSiftRuntimeDirs(projectRoot); - return { - backend: "sift", - status: "warming", - command: binaryPath, - binaryPath, - searchCache: dirs.searchCache, - tmpDir: dirs.tmpDir, - probePath: warmup.scope ?? ".", - reason: `${explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH"}; repo-local Sift index warmup is still running`, - markerPath: warmup.markerPath, - }; - } - const health = runSiftHealthProbe(projectRoot, binaryPath, env); - if (!health.ok) { - return { - backend: "sift", - status: "degraded", - command: binaryPath, - binaryPath, - searchCache: health.searchCache, - tmpDir: health.tmpDir, - probePath: health.probePath, - reason: `${explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH"} but ${health.reason}`, - }; - } - const cacheInspection = inspectSiftCache(projectRoot); - if (cacheInspection.polluted) { - return { - backend: "sift", - status: "degraded", - command: binaryPath, - binaryPath, - searchCache: health.searchCache, - tmpDir: health.tmpDir, - probePath: health.probePath, - cacheInspection, - reason: `${explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH"} but repo-local Sift cache contains ignored/generated paths`, - }; - } - return { - backend: "sift", - status: "configured", - command: binaryPath, - binaryPath, - searchCache: health.searchCache, - tmpDir: health.tmpDir, - probePath: health.probePath, - cacheInspection, - reason: `${explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH"}; ${health.reason}`, - }; + if (prefs?.indexer_backend === "none") { + return { + backend: "sift", + status: "disabled", + reason: "codebase.indexer_backend is none", + }; + } + const explicit = env.SIFT_PATH?.trim(); + const binaryPath = resolveSiftBinary(env) ?? undefined; + if (!binaryPath) { + return { + backend: "sift", + status: "missing", + reason: + "sift binary not found on PATH; set SIFT_PATH or install rupurt/sift.", + }; + } + if (explicit && !commandExists(explicit, env)) { + return { + backend: "sift", + status: "missing", + command: explicit, + binaryPath: explicit, + reason: "SIFT_PATH is set but does not resolve to an executable file.", + }; + } + const warmup = readSiftWarmupMarker(projectRoot); + if (warmup?.status === "warming") { + const dirs = ensureSiftRuntimeDirs(projectRoot); + return { + backend: "sift", + status: "warming", + command: binaryPath, + binaryPath, + searchCache: dirs.searchCache, + tmpDir: dirs.tmpDir, + probePath: warmup.scope ?? ".", + reason: `${explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH"}; repo-local Sift index warmup is still running`, + markerPath: warmup.markerPath, + }; + } + const health = runSiftHealthProbe(projectRoot, binaryPath, env); + if (!health.ok) { + return { + backend: "sift", + status: "degraded", + command: binaryPath, + binaryPath, + searchCache: health.searchCache, + tmpDir: health.tmpDir, + probePath: health.probePath, + reason: `${explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH"} but ${health.reason}`, + }; + } + const cacheInspection = inspectSiftCache(projectRoot); + if (cacheInspection.polluted) { + return { + backend: "sift", + status: "degraded", + command: binaryPath, + binaryPath, + searchCache: health.searchCache, + tmpDir: health.tmpDir, + probePath: health.probePath, + cacheInspection, + reason: `${explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH"} but repo-local Sift cache contains ignored/generated paths`, + }; + } + return { + backend: "sift", + status: "configured", + command: binaryPath, + binaryPath, + searchCache: health.searchCache, + tmpDir: health.tmpDir, + probePath: health.probePath, + cacheInspection, + reason: `${explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH"}; ${health.reason}`, + }; } function isFreshMarker(markerPath, now, ttlMs) { - try { - const stat = statSync(markerPath); - if (now - stat.mtimeMs >= ttlMs) - return false; - const parsed = JSON.parse(readFileSync(markerPath, "utf-8")); - if (parsed.schemaVersion === 3) { - if (parsed.status === "warming" && parsed.pid && !isProcessAlive(parsed.pid)) - return false; - return typeof parsed.scope === "string" && parsed.scope.length > 0; - } - return (parsed.schemaVersion === 2 && - Array.isArray(parsed.args) && - parsed.args.at(-2) === "."); - } - catch { - return false; - } + try { + const stat = statSync(markerPath); + if (now - stat.mtimeMs >= ttlMs) return false; + const parsed = JSON.parse(readFileSync(markerPath, "utf-8")); + if (parsed.schemaVersion === 3) { + if ( + parsed.status === "warming" && + parsed.pid && + !isProcessAlive(parsed.pid) + ) + return false; + return typeof parsed.scope === "string" && parsed.scope.length > 0; + } + return ( + parsed.schemaVersion === 2 && + Array.isArray(parsed.args) && + parsed.args.at(-2) === "." + ); + } catch { + return false; + } } function readSiftWarmupMarker(projectRoot) { - const markerPath = join(projectRoot, ".sf", "runtime", "sift-index-warmup.json"); - try { - if (!existsSync(markerPath)) - return null; - const parsed = JSON.parse(readFileSync(markerPath, "utf-8")); - if (parsed.schemaVersion !== 3) - return null; - if (parsed.status !== "warming") - return null; - if (parsed.pid && !isProcessAlive(parsed.pid)) - return null; - const started = Date.parse(parsed.startedAt); - const hardTimeoutSec = Number(parsed.hardTimeoutSec ?? DEFAULT_SIFT_WARMUP_HARD_TIMEOUT_SEC); - const expiresAt = started + Math.max(60, hardTimeoutSec + SIFT_WARMUP_KILL_GRACE_SEC) * 1000; - if (!Number.isFinite(started) || Date.now() > expiresAt) - return null; - return { ...parsed, markerPath }; - } - catch { - return null; - } + const markerPath = join( + projectRoot, + ".sf", + "runtime", + "sift-index-warmup.json", + ); + try { + if (!existsSync(markerPath)) return null; + const parsed = JSON.parse(readFileSync(markerPath, "utf-8")); + if (parsed.schemaVersion !== 3) return null; + if (parsed.status !== "warming") return null; + if (parsed.pid && !isProcessAlive(parsed.pid)) return null; + const started = Date.parse(parsed.startedAt); + const hardTimeoutSec = Number( + parsed.hardTimeoutSec ?? DEFAULT_SIFT_WARMUP_HARD_TIMEOUT_SEC, + ); + const expiresAt = + started + + Math.max(60, hardTimeoutSec + SIFT_WARMUP_KILL_GRACE_SEC) * 1000; + if (!Number.isFinite(started) || Date.now() > expiresAt) return null; + return { ...parsed, markerPath }; + } catch { + return null; + } } function isProcessAlive(pid) { - try { - process.kill(Number(pid), 0); - return true; - } - catch { - return false; - } + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } } export function ensureSiftIndexWarmup(projectRoot, prefs, options = {}) { - const env = options.env ?? process.env; - const backendName = resolveEffectiveCodebaseIndexerBackendName(projectRoot, prefs, env); - if (backendName !== "sift") { - return { - status: "skipped", - reason: `effective codebase indexer is ${backendName}`, - }; - } - const detection = detectSift(projectRoot, prefs, { - ...env, - SF_SIFT_HEALTHCHECK_DISABLE: "1", - }); - if (detection.status === "warming") { - return { - status: "skipped", - reason: "sift index warmup is already running", - markerPath: detection.markerPath, - }; - } - if (!["configured", "degraded"].includes(detection.status) || !detection.binaryPath) { - return { - status: "unavailable", - reason: detection.reason, - }; - } - const markerPath = join(projectRoot, ".sf", "runtime", "sift-index-warmup.json"); - const now = options.now ?? Date.now(); - const ttlMs = options.ttlMs ?? DEFAULT_SIFT_WARMUP_TTL_MS; - if (!options.force && isFreshMarker(markerPath, now, ttlMs)) { - return { - status: "skipped", - reason: "recent sift warmup marker exists", - markerPath, - }; - } - const scope = resolveSiftSearchScope(projectRoot, options.scope ?? "."); - const siftArgs = [ - "search", - "--json", - "--strategy", - "page-index-hybrid", - "--limit", - String(options.limit ?? DEFAULT_SIFT_WARMUP_LIMIT), - "--retriever-timeout-ms", - String(options.retrieverTimeoutMs ?? DEFAULT_SIFT_WARMUP_RETRIEVER_TIMEOUT_MS), - scope, - options.query ?? DEFAULT_SIFT_WARMUP_QUERY, - ]; - const hardTimeoutSec = resolveSiftWarmupHardTimeoutSec(env, options.hardTimeoutSec); - const wrapper = hardTimeoutSec !== null - ? resolveSiftWarmupTimeoutWrapper(env, hardTimeoutSec) - : null; - const command = wrapper ? wrapper.binary : detection.binaryPath; - const args = wrapper - ? [...wrapper.wrapperArgs, detection.binaryPath, ...siftArgs] - : siftArgs; - const startedReason = wrapper - ? `sift page-index-hybrid warmup started (hard cap ${wrapper.timeoutSec}s via ${wrapper.binary})` - : hardTimeoutSec === null - ? "sift page-index-hybrid warmup started (hard cap disabled)" - : "sift page-index-hybrid warmup started (no timeout(1)/gtimeout on PATH; running unbounded)"; - try { - const runtimeDirs = resolveSiftWarmupRuntimeDirs(projectRoot); - ensureSiftRuntimeDirs(projectRoot); - const childEnv = buildSiftEnv(projectRoot, env); - const marker = { - schemaVersion: 3, - status: "warming", - startedAt: new Date(now).toISOString(), - command, - cwd: projectRoot, - args, - scope, - siftBinary: detection.binaryPath, - hardTimeoutSec: wrapper?.timeoutSec ?? null, - searchCache: runtimeDirs.searchCache, - tmpDir: runtimeDirs.tmpDir, - }; - writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf-8"); - const child = (options.spawnFn ?? spawn)(command, args, { - cwd: projectRoot, - env: childEnv, - stdio: "ignore", - detached: true, - }); - marker.pid = child.pid ?? null; - writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf-8"); - child.unref(); - return { - status: "started", - reason: startedReason, - command, - args, - markerPath, - }; - } - catch (err) { - return { - status: "error", - reason: err instanceof Error ? err.message : String(err), - command, - args, - markerPath, - }; - } -} -function projectRagBinaryFromSource(sourceDir) { - const candidate = join(sourceDir, "target", "release", PROJECT_RAG_BINARY_NAME); - return existsSync(candidate) ? candidate : null; -} -export function resolveProjectRagBuildJobs(env = process.env) { - const configured = env.SF_PROJECT_RAG_BUILD_JOBS?.trim() || env.CARGO_BUILD_JOBS?.trim(); - if (!configured) - return "2"; - const parsed = Number.parseInt(configured, 10); - return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : "2"; -} -export function findProjectRagSourceDir(projectRoot, env = process.env) { - const explicit = env.SF_PROJECT_RAG_SOURCE?.trim() || env.PROJECT_RAG_SOURCE?.trim(); - const candidates = [ - ...(explicit ? [explicit] : []), - ...PROJECT_RAG_SOURCE_CANDIDATES.map((relativePath) => join(normalizeProjectRoot(projectRoot), relativePath)), - ]; - for (const candidate of candidates) { - const manifestPath = join(candidate, "Cargo.toml"); - if (!existsSync(manifestPath)) - continue; - try { - const manifest = readFileSync(manifestPath, "utf-8"); - if (/name\s*=\s*"project-rag"/.test(manifest) || - /project-rag/i.test(candidate)) { - return resolve(candidate); - } - } - catch { - // Optional vendored source discovery should never block SF startup. - } - } - return null; -} -export function resolveProjectRagBinaryForProject(projectRoot, env = process.env) { - const explicitOrPath = resolveProjectRagBinary(env); - if (explicitOrPath) - return explicitOrPath; - const sourceDir = findProjectRagSourceDir(projectRoot, env); - if (sourceDir) { - const builtBinary = projectRagBinaryFromSource(sourceDir); - if (builtBinary) - return builtBinary; - } - for (const relativePath of [ - join("target", "release", PROJECT_RAG_BINARY_NAME), - join(".bin", PROJECT_RAG_BINARY_NAME), - join("bin", PROJECT_RAG_BINARY_NAME), - ]) { - const candidate = join(normalizeProjectRoot(projectRoot), relativePath); - if (existsSync(candidate)) - return candidate; - } - return null; -} -export function buildProjectRagMcpServerConfig(projectRoot = process.cwd(), env = process.env) { - const command = resolveProjectRagBinaryForProject(projectRoot, env); - if (!command) { - const sourceDir = findProjectRagSourceDir(projectRoot, env); - throw new Error(sourceDir - ? `project-rag source found at ${sourceDir}, but no release binary exists. Run /sf codebase rag build first.` - : "project-rag binary not found. Set SF_PROJECT_RAG_BIN, install project-rag on PATH, or vendor Brainwires/project-rag under vendor/project-rag."); - } - return { - command, - env: { - RUST_LOG: env.RUST_LOG ?? "info", - }, - }; -} -export function buildProjectRagBinary(projectRoot, env = process.env) { - const sourceDir = findProjectRagSourceDir(projectRoot, env); - if (!sourceDir) { - throw new Error("project-rag source not found. Vendor Brainwires/project-rag under vendor/project-rag or set SF_PROJECT_RAG_SOURCE."); - } - const cargo = lookupExecutable("cargo", env); - if (!cargo) { - throw new Error("cargo not found in PATH; cannot build vendored project-rag."); - } - const buildJobs = resolveProjectRagBuildJobs(env); - const result = spawnSync(cargo, ["build", "--release"], { - cwd: sourceDir, - env: { ...process.env, ...env, CARGO_BUILD_JOBS: buildJobs }, - encoding: "utf-8", - maxBuffer: 20 * 1024 * 1024, - }); - const stdout = result.stdout ?? ""; - const stderr = result.stderr ?? ""; - if (result.error) { - throw new Error(`cargo build failed to start: ${result.error.message}`); - } - if (result.status !== 0) { - throw new Error(`cargo build --release failed with exit ${result.status ?? "unknown"}:\n${stderr || stdout}`.trim()); - } - const binaryPath = projectRagBinaryFromSource(sourceDir); - if (!binaryPath) { - throw new Error(`cargo build completed, but ${join(sourceDir, "target", "release", PROJECT_RAG_BINARY_NAME)} was not found.`); - } - return { sourceDir, binaryPath, buildJobs, stdout, stderr }; -} -export function ensureProjectRagMcpConfig(projectRoot, env = process.env) { - const resolvedProjectRoot = normalizeProjectRoot(projectRoot); - const configPath = join(resolvedProjectRoot, ".mcp.json"); - const alreadyPresent = existsSync(configPath); - const existing = readJsonConfig(configPath); - const desiredServer = buildProjectRagMcpServerConfig(resolvedProjectRoot, env); - const previousServers = existing.mcpServers ?? {}; - const current = previousServers[PROJECT_RAG_MCP_SERVER_NAME]; - const unchanged = JSON.stringify(current ?? null) === JSON.stringify(desiredServer) && - existing.mcpServers !== undefined; - if (unchanged) { - return { - configPath, - serverName: PROJECT_RAG_MCP_SERVER_NAME, - status: "unchanged", - }; - } - const nextConfig = { - ...existing, - mcpServers: { - ...previousServers, - [PROJECT_RAG_MCP_SERVER_NAME]: desiredServer, - }, - }; - writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf-8"); - return { - configPath, - serverName: PROJECT_RAG_MCP_SERVER_NAME, - status: alreadyPresent ? "updated" : "created", - }; -} -function formatToolPrefix(serverName) { - return `mcp__${serverName.replace(/[^A-Za-z0-9_]/g, "_")}__`; -} -function buildProjectRagContextLines(projectRoot, prefs, env = process.env) { - const detection = detectProjectRag(projectRoot, prefs, env); - const lines = []; - if (detection.status === "disabled") { - lines.push("- Project RAG: disabled by `codebase.project_rag: off`."); - } - else if (detection.status === "configured" && detection.serverName) { - const prefix = formatToolPrefix(detection.serverName); - lines.push(`- Project RAG: configured as MCP server \`${detection.serverName}\`.`); - lines.push("- Use Project RAG for broad code retrieval before manual file-by-file reading, " + - "especially conceptual queries, exact identifiers, schema fields, and git-history questions."); - lines.push(`- Expected MCP tool prefix: \`${prefix}\` ` + - `(for example \`${prefix}index_codebase\`, \`${prefix}query_codebase\`, ` + - `\`${prefix}search_by_filters\`, \`${prefix}find_definition\`, ` + - `\`${prefix}find_references\`, \`${prefix}get_call_graph\`).`); - lines.push(prefs?.project_rag_auto_index === false - ? "- Do not auto-index unless explicitly needed; query existing indexes first. " + - "If any Project RAG tool is missing or fails, continue with `.sf/CODEBASE.md`, native `grep`/`find`/`ls`, `lsp`, `codebase_search`, and scout." - : "- Index first if the backend is stale or empty; use incremental indexing when available. " + - "If any Project RAG tool is missing or fails, continue with `.sf/CODEBASE.md`, native `grep`/`find`/`ls`, `lsp`, `codebase_search`, and scout."); - } - else { - lines.push("- Project RAG: not configured. This is optional; continue with `.sf/CODEBASE.md`, native `grep`/`find`/`ls`, `lsp`, `codebase_search`, and scout."); - lines.push("- To enable later: build/install Brainwires/project-rag, then run `/sf codebase rag init` or set `SF_PROJECT_RAG_BIN` before initializing MCP config."); - } - return lines; + const env = options.env ?? process.env; + const backendName = resolveEffectiveCodebaseIndexerBackendName( + projectRoot, + prefs, + env, + ); + if (backendName !== "sift") { + return { + status: "skipped", + reason: `effective codebase indexer is ${backendName}`, + }; + } + const detection = detectSift(projectRoot, prefs, { + ...env, + SF_SIFT_HEALTHCHECK_DISABLE: "1", + }); + if (detection.status === "warming") { + return { + status: "skipped", + reason: "sift index warmup is already running", + markerPath: detection.markerPath, + }; + } + if ( + !["configured", "degraded"].includes(detection.status) || + !detection.binaryPath + ) { + return { + status: "unavailable", + reason: detection.reason, + }; + } + const markerPath = join( + projectRoot, + ".sf", + "runtime", + "sift-index-warmup.json", + ); + const now = options.now ?? Date.now(); + const ttlMs = options.ttlMs ?? DEFAULT_SIFT_WARMUP_TTL_MS; + if (!options.force && isFreshMarker(markerPath, now, ttlMs)) { + return { + status: "skipped", + reason: "recent sift warmup marker exists", + markerPath, + }; + } + const scope = resolveSiftSearchScope(projectRoot, options.scope ?? "."); + const siftArgs = [ + "search", + "--json", + "--strategy", + "page-index-hybrid", + "--limit", + String(options.limit ?? DEFAULT_SIFT_WARMUP_LIMIT), + "--retriever-timeout-ms", + String( + options.retrieverTimeoutMs ?? DEFAULT_SIFT_WARMUP_RETRIEVER_TIMEOUT_MS, + ), + scope, + options.query ?? DEFAULT_SIFT_WARMUP_QUERY, + ]; + const hardTimeoutSec = resolveSiftWarmupHardTimeoutSec( + env, + options.hardTimeoutSec, + ); + const wrapper = + hardTimeoutSec !== null + ? resolveSiftWarmupTimeoutWrapper(env, hardTimeoutSec) + : null; + const command = wrapper ? wrapper.binary : detection.binaryPath; + const args = wrapper + ? [...wrapper.wrapperArgs, detection.binaryPath, ...siftArgs] + : siftArgs; + const startedReason = wrapper + ? `sift page-index-hybrid warmup started (hard cap ${wrapper.timeoutSec}s via ${wrapper.binary})` + : hardTimeoutSec === null + ? "sift page-index-hybrid warmup started (hard cap disabled)" + : "sift page-index-hybrid warmup started (no timeout(1)/gtimeout on PATH; running unbounded)"; + try { + const runtimeDirs = resolveSiftWarmupRuntimeDirs(projectRoot); + ensureSiftRuntimeDirs(projectRoot); + const childEnv = buildSiftEnv(projectRoot, env); + const marker = { + schemaVersion: 3, + status: "warming", + startedAt: new Date(now).toISOString(), + command, + cwd: projectRoot, + args, + scope, + siftBinary: detection.binaryPath, + hardTimeoutSec: wrapper?.timeoutSec ?? null, + searchCache: runtimeDirs.searchCache, + tmpDir: runtimeDirs.tmpDir, + }; + writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf-8"); + const child = (options.spawnFn ?? spawn)(command, args, { + cwd: projectRoot, + env: childEnv, + stdio: "ignore", + detached: true, + }); + marker.pid = child.pid ?? null; + writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf-8"); + child.unref(); + return { + status: "started", + reason: startedReason, + command, + args, + markerPath, + }; + } catch (err) { + return { + status: "error", + reason: err instanceof Error ? err.message : String(err), + command, + args, + markerPath, + }; + } } function buildSiftContextLines(projectRoot, prefs, env = process.env) { - const detection = detectSift(projectRoot, prefs, env); - const lines = []; - if (detection.status === "disabled") { - lines.push("- Codebase indexer: disabled by `codebase.indexer_backend: none`."); - } - else if (detection.status === "configured" && detection.binaryPath) { - lines.push(`- Sift: configured as local CLI \`${detection.binaryPath}\`.`); - lines.push(`- Sift cache: project-scoped at \`${detection.searchCache}\`; do not use a shared/global Sift search database for this repo.`); - lines.push("- Use Sift with explicit, narrow paths after quick `grep`/`find`/`ls` orientation; avoid root-scope searches unless status proves they are responsive."); - lines.push("- Tool: `sift_search` exposes the full Sift CLI surface — prefer direct `bm25`, `path-hybrid`, or `page-index-hybrid` with a scoped `path`."); - lines.push("- Tool: `codebase_search` is the platform-level wrapper — use it only with a scoped `scope` when possible."); - lines.push("- Strategy guide: `page-index-hybrid` (strongest recall + structural reranking), " + - "`path-hybrid` (filename/path-heavy), `bm25` (fast lexical-only), `vector` (semantic-only)."); - lines.push("- If Sift is slow, empty, or times out, continue with `.sf/CODEBASE.md`, native `grep`/`find`/`ls`, `lsp`, and scout."); - } - else if (detection.status === "warming" && detection.binaryPath) { - lines.push(`- Sift: installed at \`${detection.binaryPath}\`; repo-local index warmup is running.`); - lines.push(`- Sift cache: project-scoped at \`${detection.searchCache}\`; do not use a shared/global Sift search database for this repo.`); - lines.push("- Use grep/find/ls and `.sf/CODEBASE.md` for broad orientation while warmup runs. Use narrow `sift_search` paths if needed; broad root-scope Sift may still be cold."); - } - else if (detection.status === "degraded" && detection.binaryPath) { - lines.push(`- Sift: installed at \`${detection.binaryPath}\` but degraded for this repo: ${detection.reason}.`); - lines.push(`- Sift cache: project-scoped at \`${detection.searchCache}\`; do not use a shared/global Sift search database for this repo.`); - lines.push("- Do not use broad Sift/codebase_search as the first exploration step. Prefer native `grep`/`find`/`ls`, `.sf/CODEBASE.md`, and narrow `sift_search` only after reducing scope."); - } - else { - lines.push("- Sift: not available. This is optional; continue with `.sf/CODEBASE.md`, native `grep`/`find`/`ls`, `lsp`, and scout."); - lines.push("- To enable later: install `rupurt/sift` on PATH or set `SIFT_PATH` to the sift binary."); - } - return lines; + const detection = detectSift(projectRoot, prefs, env); + const lines = []; + if (detection.status === "disabled") { + lines.push( + "- Codebase indexer: disabled by `codebase.indexer_backend: none`.", + ); + } else if (detection.status === "configured" && detection.binaryPath) { + lines.push(`- Sift: configured as local CLI \`${detection.binaryPath}\`.`); + lines.push( + `- Sift cache: project-scoped at \`${detection.searchCache}\`; do not use a shared/global Sift search database for this repo.`, + ); + lines.push( + "- Use Sift with explicit, narrow paths after quick `grep`/`find`/`ls` orientation; avoid root-scope searches unless status proves they are responsive.", + ); + lines.push( + "- Tool: `sift_search` exposes the full Sift CLI surface — prefer direct `bm25`, `path-hybrid`, or `page-index-hybrid` with a scoped `path`.", + ); + lines.push( + "- Tool: `codebase_search` is the platform-level wrapper — use it only with a scoped `scope` when possible.", + ); + lines.push( + "- Strategy guide: `page-index-hybrid` (strongest recall + structural reranking), " + + "`path-hybrid` (filename/path-heavy), `bm25` (fast lexical-only), `vector` (semantic-only).", + ); + lines.push( + "- If Sift is slow, empty, or times out, continue with native `grep`/`find`/`ls`, `lsp`, scout, and `.sf/CODEBASE.md` only as fallback context.", + ); + } else if (detection.status === "warming" && detection.binaryPath) { + lines.push( + `- Sift: installed at \`${detection.binaryPath}\`; repo-local index warmup is running.`, + ); + lines.push( + `- Sift cache: project-scoped at \`${detection.searchCache}\`; do not use a shared/global Sift search database for this repo.`, + ); + lines.push( + "- Use grep/find/ls and lsp for broad orientation while warmup runs. Use `.sf/CODEBASE.md` only as fallback context. Use narrow `sift_search` paths if needed; broad root-scope Sift may still be cold.", + ); + } else if (detection.status === "degraded" && detection.binaryPath) { + lines.push( + `- Sift: installed at \`${detection.binaryPath}\` but degraded for this repo: ${detection.reason}.`, + ); + lines.push( + `- Sift cache: project-scoped at \`${detection.searchCache}\`; do not use a shared/global Sift search database for this repo.`, + ); + lines.push( + "- Do not use broad Sift/codebase_search as the first exploration step. Prefer native `grep`/`find`/`ls`, lsp, and narrow `sift_search` only after reducing scope. Use `.sf/CODEBASE.md` only as fallback context.", + ); + } else { + lines.push( + "- Sift: not available. This is optional; continue with native `grep`/`find`/`ls`, `lsp`, scout, and `.sf/CODEBASE.md` only as fallback context.", + ); + lines.push( + "- To enable later: install `rupurt/sift` on PATH or set `SIFT_PATH` to the sift binary.", + ); + } + return lines; } function buildNoCodebaseIndexerContextLines() { - return [ - "- Codebase indexer: disabled by `codebase.indexer_backend: none`; continue with `.sf/CODEBASE.md`, native `grep`/`find`/`ls`, `lsp`, and scout.", - ]; + return [ + "- Codebase indexer: disabled by `codebase.indexer_backend: none`; continue with native `grep`/`find`/`ls`, `lsp`, scout, and `.sf/CODEBASE.md` only as fallback context.", + ]; } export function resolveCodebaseIndexerBackendName(prefs) { - return prefs?.indexer_backend ?? "projectRag"; + if (prefs?.indexer_backend === "none") return "none"; + return "sift"; } -export function resolveEffectiveCodebaseIndexerBackendName(projectRoot, prefs, env = process.env) { - if (prefs?.indexer_backend) - return prefs.indexer_backend; - const sift = detectSift(projectRoot, prefs, env); - if (["configured", "warming", "degraded"].includes(sift.status)) - return "sift"; - return "projectRag"; +export function resolveEffectiveCodebaseIndexerBackendName( + _projectRoot, + prefs, + _env = process.env, +) { + if (prefs?.indexer_backend === "none") return "none"; + return "sift"; } export function getCodebaseIndexerBackend(prefsOrName) { - const name = typeof prefsOrName === "string" - ? prefsOrName - : resolveCodebaseIndexerBackendName(prefsOrName); - return CODEBASE_INDEXER_BACKENDS[name]; + const name = + typeof prefsOrName === "string" + ? prefsOrName + : resolveCodebaseIndexerBackendName(prefsOrName); + return CODEBASE_INDEXER_BACKENDS[name] ?? SIFT_CODEBASE_INDEXER_BACKEND; } export function detectCodebaseIndexer(projectRoot, prefs, env = process.env) { - const backendName = resolveEffectiveCodebaseIndexerBackendName(projectRoot, prefs, env); - return getCodebaseIndexerBackend(backendName).detect(projectRoot, prefs, env); + const backendName = resolveEffectiveCodebaseIndexerBackendName( + projectRoot, + prefs, + env, + ); + return getCodebaseIndexerBackend(backendName).detect(projectRoot, prefs, env); } -export function formatCodebaseIndexerStatus(projectRoot, prefs, env = process.env) { - const backendName = resolveEffectiveCodebaseIndexerBackendName(projectRoot, prefs, env); - return getCodebaseIndexerBackend(backendName).formatStatus(projectRoot, prefs, env); +export function formatCodebaseIndexerStatus( + projectRoot, + prefs, + env = process.env, +) { + const backendName = resolveEffectiveCodebaseIndexerBackendName( + projectRoot, + prefs, + env, + ); + return getCodebaseIndexerBackend(backendName).formatStatus( + projectRoot, + prefs, + env, + ); } -export function buildCodeIntelligenceContextBlock(projectRoot, prefs, env = process.env) { - const backendName = resolveEffectiveCodebaseIndexerBackendName(projectRoot, prefs, env); - const lines = [ - "[PROJECT CODE INTELLIGENCE]", - "", - "- Durable baseline: use `.sf/CODEBASE.md` for structural orientation and persistent project knowledge.", - ...getCodebaseIndexerBackend(backendName).buildContextLines(projectRoot, prefs, env), - ]; - return `\n\n${lines.join("\n")}`; -} -export function formatProjectRagStatus(projectRoot, prefs, env = process.env) { - const detection = detectProjectRag(projectRoot, prefs, env); - const lines = ["Project RAG Status", ""]; - lines.push(`Status: ${detection.status}`); - lines.push(`Reason: ${detection.reason}`); - if (detection.serverName) - lines.push(`Server: ${detection.serverName}`); - if (detection.configPath) - lines.push(`Config: ${detection.configPath}`); - if (detection.command) - lines.push(`Command: ${detection.command}`); - if (detection.binaryPath) - lines.push(`Binary: ${detection.binaryPath}`); - if (detection.sourceDir) - lines.push(`Source: ${detection.sourceDir}`); - if (detection.status === "configured" && detection.command) { - lines.push(`Operational: ${commandExists(detection.command, env) ? "yes" : "no - configured command is missing"}`); - } - else if (detection.binaryPath) { - lines.push("Operational: no - binary exists but MCP config is missing; run /sf codebase rag init."); - } - else if (detection.sourceDir) { - lines.push("Operational: no - source exists but release binary is missing; run /sf codebase rag build."); - } - else { - lines.push("Operational: no - binary/source not found."); - } - lines.push(""); - lines.push("Project RAG is optional. SF falls back to CODEBASE.md, native grep/find/ls, lsp, codebase_search, and scout when it is unavailable."); - lines.push("When configured, agents should use index_codebase, query_codebase, search_by_filters, find_definition, find_references, and get_call_graph before manual file-by-file reading."); - return lines.join("\n"); +export function buildCodeIntelligenceContextBlock( + projectRoot, + prefs, + env = process.env, +) { + const backendName = resolveEffectiveCodebaseIndexerBackendName( + projectRoot, + prefs, + env, + ); + const lines = [ + "[PROJECT CODE INTELLIGENCE]", + "", + "- Live code retrieval should use Sift when healthy. Use `.sf/CODEBASE.md` only as durable fallback context when Sift is unavailable, cold, degraded, or explicitly needed as a generated overview.", + ...getCodebaseIndexerBackend(backendName).buildContextLines( + projectRoot, + prefs, + env, + ), + ]; + return `\n\n${lines.join("\n")}`; } export function formatSiftStatus(projectRoot, prefs, env = process.env) { - const detection = detectSift(projectRoot, prefs, env); - const lines = ["Sift Status", ""]; - lines.push(`Status: ${detection.status}`); - lines.push(`Reason: ${detection.reason}`); - if (detection.command) - lines.push(`Command: ${detection.command}`); - if (detection.binaryPath) - lines.push(`Binary: ${detection.binaryPath}`); - if (detection.searchCache) - lines.push(`Search cache: ${detection.searchCache}`); - if (detection.tmpDir) - lines.push(`Temp dir: ${detection.tmpDir}`); - if (detection.probePath) - lines.push(`Health probe scope: ${detection.probePath}`); - if (detection.markerPath) - lines.push(`Warmup marker: ${detection.markerPath}`); - if (detection.cacheInspection?.polluted) { - lines.push("Cache integrity: polluted - ignored/generated paths were found in repo-local Sift manifests."); - for (const sample of detection.cacheInspection.samples ?? []) { - lines.push(`Cache sample (${sample.label}): ${sample.sample}`); - } - lines.push("Action: remove .sf/runtime/sift/search-cache and warm Sift again from the repo root."); - } - else if (detection.cacheInspection?.inspected) { - lines.push("Cache integrity: ok - no ignored/generated path samples found in inspected manifests."); - } - if (detection.status === "configured" && detection.command) { - lines.push(`Operational: ${commandExists(detection.command, env) ? "yes - scoped health probe passed" : "no - configured command is missing"}`); - } - else if (detection.status === "warming" && detection.command) { - lines.push("Operational: warming - binary exists and repo-local index warmup is running. Give Sift time on CPU before broad searches."); - } - else if (detection.status === "degraded" && detection.command) { - lines.push("Operational: degraded - binary exists, but the bounded scoped health probe failed. Use narrow paths or fallback search."); - } - else { - lines.push("Operational: no - install rupurt/sift on PATH or set SIFT_PATH."); - } - lines.push(""); - lines.push("Sift is optional. SF falls back to CODEBASE.md, native grep/find/ls, lsp, and scout when it is unavailable."); - lines.push('When configured, agents should use `sift search --json ""`; `page-index-hybrid` is the strongest direct-search preset and `path-hybrid` is best for path-heavy queries.'); - lines.push("SF runs Sift warmup with a project-scoped SIFT_SEARCH_CACHE under .sf/runtime/sift/ while leaving model cache shared."); - return lines.join("\n"); + const detection = detectSift(projectRoot, prefs, env); + const lines = ["Sift Status", ""]; + lines.push(`Status: ${detection.status}`); + lines.push(`Reason: ${detection.reason}`); + if (detection.command) lines.push(`Command: ${detection.command}`); + if (detection.binaryPath) lines.push(`Binary: ${detection.binaryPath}`); + if (detection.searchCache) + lines.push(`Search cache: ${detection.searchCache}`); + if (detection.tmpDir) lines.push(`Temp dir: ${detection.tmpDir}`); + if (detection.probePath) + lines.push(`Health probe scope: ${detection.probePath}`); + if (detection.markerPath) + lines.push(`Warmup marker: ${detection.markerPath}`); + if (detection.cacheInspection?.polluted) { + lines.push( + "Cache integrity: polluted - ignored/generated paths were found in repo-local Sift manifests.", + ); + for (const sample of detection.cacheInspection.samples ?? []) { + lines.push(`Cache sample (${sample.label}): ${sample.sample}`); + } + lines.push( + "Action: remove .sf/runtime/sift/search-cache and warm Sift again from the repo root.", + ); + } else if (detection.cacheInspection?.inspected) { + lines.push( + "Cache integrity: ok - no ignored/generated path samples found in inspected manifests.", + ); + } + if (detection.status === "configured" && detection.command) { + lines.push( + `Operational: ${commandExists(detection.command, env) ? "yes - scoped health probe passed" : "no - configured command is missing"}`, + ); + } else if (detection.status === "warming" && detection.command) { + lines.push( + "Operational: warming - binary exists and repo-local index warmup is running. Give Sift time on CPU before broad searches.", + ); + } else if (detection.status === "degraded" && detection.command) { + lines.push( + "Operational: degraded - binary exists, but the bounded scoped health probe failed. Use narrow paths or fallback search.", + ); + } else { + lines.push( + "Operational: no - install rupurt/sift on PATH or set SIFT_PATH.", + ); + } + lines.push(""); + lines.push( + "Sift is optional. SF falls back to native grep/find/ls, lsp, scout, and CODEBASE.md only as fallback context when it is unavailable.", + ); + lines.push( + 'When configured, agents should use `sift search --json ""`; `page-index-hybrid` is the strongest direct-search preset and `path-hybrid` is best for path-heavy queries.', + ); + lines.push( + "SF runs Sift warmup with a project-scoped SIFT_SEARCH_CACHE under .sf/runtime/sift/ while leaving model cache shared.", + ); + return lines.join("\n"); } function formatNoCodebaseIndexerStatus() { - return [ - "Codebase Indexer Status", - "", - "Status: disabled", - "Reason: codebase.indexer_backend is none", - "Operational: no - optional codebase indexer disabled.", - "", - "SF will use CODEBASE.md, native grep/find/ls, lsp, and scout for codebase orientation.", - ].join("\n"); + return [ + "Codebase Indexer Status", + "", + "Status: disabled", + "Reason: codebase.indexer_backend is none", + "Operational: no - optional codebase indexer disabled.", + "", + "SF will use native grep/find/ls, lsp, scout, and CODEBASE.md only as fallback context for codebase orientation.", + ].join("\n"); } -export const PROJECT_RAG_CODEBASE_INDEXER_BACKEND = { - name: "projectRag", - label: "Project RAG", - detect: detectProjectRag, - formatStatus: formatProjectRagStatus, - buildContextLines: buildProjectRagContextLines, -}; export const SIFT_CODEBASE_INDEXER_BACKEND = { - name: "sift", - label: "Sift", - detect: detectSift, - formatStatus: formatSiftStatus, - buildContextLines: buildSiftContextLines, + name: "sift", + label: "Sift", + detect: detectSift, + formatStatus: formatSiftStatus, + buildContextLines: buildSiftContextLines, }; export const NO_CODEBASE_INDEXER_BACKEND = { - name: "none", - label: "None", - detect: () => ({ - backend: "none", - status: "disabled", - reason: "codebase.indexer_backend is none", - }), - formatStatus: formatNoCodebaseIndexerStatus, - buildContextLines: buildNoCodebaseIndexerContextLines, + name: "none", + label: "None", + detect: () => ({ + backend: "none", + status: "disabled", + reason: "codebase.indexer_backend is none", + }), + formatStatus: formatNoCodebaseIndexerStatus, + buildContextLines: buildNoCodebaseIndexerContextLines, }; export const CODEBASE_INDEXER_BACKENDS = { - projectRag: PROJECT_RAG_CODEBASE_INDEXER_BACKEND, - sift: SIFT_CODEBASE_INDEXER_BACKEND, - none: NO_CODEBASE_INDEXER_BACKEND, + sift: SIFT_CODEBASE_INDEXER_BACKEND, + none: NO_CODEBASE_INDEXER_BACKEND, }; diff --git a/src/resources/extensions/sf/commands-bootstrap.js b/src/resources/extensions/sf/commands-bootstrap.js index d20fb317f..25786521e 100644 --- a/src/resources/extensions/sf/commands-bootstrap.js +++ b/src/resources/extensions/sf/commands-bootstrap.js @@ -1,271 +1,357 @@ -import { importExtensionModule, } from "@singularity-forge/pi-coding-agent"; +import { importExtensionModule } from "@singularity-forge/pi-coding-agent"; import { workflowTemplateCommandDefinitions } from "./workflow-templates.js"; + const TOP_LEVEL_SUBCOMMANDS = [ - { cmd: "help", desc: "Categorized command reference with descriptions" }, - { cmd: "next", desc: "Explicit step mode (same as /sf)" }, - { - cmd: "autonomous", - desc: "Autonomous mode — research, plan, execute, commit, repeat", - }, - { cmd: "stop", desc: "Stop autonomous mode gracefully" }, - { - cmd: "pause", - desc: "Pause autonomous mode (preserves state, /sf autonomous to resume)", - }, - { cmd: "status", desc: "Progress dashboard" }, - { cmd: "visualize", desc: "Open workflow visualizer" }, - { cmd: "queue", desc: "Queue and reorder future milestones" }, - { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, - { cmd: "discuss", desc: "Discuss architecture and decisions" }, - { cmd: "capture", desc: "Fire-and-forget thought capture" }, - { cmd: "changelog", desc: "Show categorized release notes" }, - { cmd: "triage", desc: "Manually trigger triage of pending captures" }, - { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, - { cmd: "history", desc: "View execution history" }, - { cmd: "undo", desc: "Revert last completed unit" }, - { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, - { cmd: "export", desc: "Export milestone or slice results" }, - { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, - { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, - { cmd: "prefs", desc: "Manage preferences" }, - { cmd: "config", desc: "Set API keys for external tools" }, - { cmd: "keys", desc: "API key manager" }, - { cmd: "hooks", desc: "Show configured hooks" }, - { cmd: "run-hook", desc: "Manually trigger a specific hook" }, - { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, - { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, - { cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" }, - { cmd: "forensics", desc: "Examine execution logs" }, - { cmd: "init", desc: "Project init wizard" }, - { cmd: "setup", desc: "Global setup status and configuration" }, - { cmd: "migrate", desc: "Migrate a v1 .planning directory to .sf format" }, - { cmd: "remote", desc: "Control remote auto-mode" }, - { cmd: "steer", desc: "Hard-steer plan documents during execution" }, - { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, - { cmd: "knowledge", desc: "Add persistent project knowledge" }, - { - cmd: "new-milestone", - desc: "Create a milestone from a specification document", - }, - { cmd: "parallel", desc: "Parallel milestone orchestration" }, - { cmd: "park", desc: "Park a milestone" }, - { cmd: "unpark", desc: "Reactivate a parked milestone" }, - { cmd: "update", desc: "Update SF to the latest version" }, - { cmd: "start", desc: "Start a workflow template" }, - { cmd: "templates", desc: "List available workflow templates" }, - { cmd: "extensions", desc: "Manage extensions" }, - { - cmd: "codebase", - desc: "Generate, refresh, and inspect the codebase map cache", - }, - { - cmd: "scaffold", - desc: "Inspect or refresh ADR-021 versioned scaffold docs", - }, + { cmd: "help", desc: "Categorized command reference with descriptions" }, + { cmd: "next", desc: "Explicit step mode (same as /sf)" }, + { + cmd: "autonomous", + desc: "Autonomous mode — research, plan, execute, commit, repeat", + }, + { cmd: "stop", desc: "Stop autonomous mode gracefully" }, + { + cmd: "pause", + desc: "Pause autonomous mode (preserves state, /sf autonomous to resume)", + }, + { cmd: "status", desc: "Progress dashboard" }, + { cmd: "visualize", desc: "Open workflow visualizer" }, + { cmd: "queue", desc: "Queue and reorder future milestones" }, + { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, + { cmd: "discuss", desc: "Discuss architecture and decisions" }, + { cmd: "capture", desc: "Fire-and-forget thought capture" }, + { cmd: "changelog", desc: "Show categorized release notes" }, + { cmd: "triage", desc: "Manually trigger triage of pending captures" }, + { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, + { cmd: "history", desc: "View execution history" }, + { cmd: "undo", desc: "Revert last completed unit" }, + { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, + { cmd: "export", desc: "Export milestone or slice results" }, + { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, + { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, + { cmd: "prefs", desc: "Manage preferences" }, + { cmd: "config", desc: "Set API keys for external tools" }, + { cmd: "keys", desc: "API key manager" }, + { cmd: "hooks", desc: "Show configured hooks" }, + { cmd: "run-hook", desc: "Manually trigger a specific hook" }, + { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, + { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, + { cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" }, + { cmd: "forensics", desc: "Examine execution logs" }, + { cmd: "init", desc: "Project init wizard" }, + { cmd: "setup", desc: "Global setup status and configuration" }, + { cmd: "migrate", desc: "Migrate a v1 .planning directory to .sf format" }, + { cmd: "remote", desc: "Control remote auto-mode" }, + { cmd: "steer", desc: "Hard-steer plan documents during execution" }, + { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, + { cmd: "knowledge", desc: "Add persistent project knowledge" }, + { + cmd: "new-milestone", + desc: "Create a milestone from a specification document", + }, + { cmd: "parallel", desc: "Parallel milestone orchestration" }, + { cmd: "park", desc: "Park a milestone" }, + { cmd: "unpark", desc: "Reactivate a parked milestone" }, + { cmd: "update", desc: "Update SF to the latest version" }, + { cmd: "start", desc: "Start a workflow template" }, + { cmd: "templates", desc: "List available workflow templates" }, + { cmd: "extensions", desc: "Manage extensions" }, + { + cmd: "codebase", + desc: "Generate, refresh, and inspect the codebase map cache", + }, + { + cmd: "scaffold", + desc: "Inspect or refresh ADR-021 versioned scaffold docs", + }, ]; function filterStartsWith(partial, options, prefix = "") { - const normalizedPrefix = prefix.length > 0 ? `${prefix} ` : ""; - return options - .filter((option) => option.cmd.startsWith(partial)) - .map((option) => ({ - value: `${normalizedPrefix}${option.cmd}`, - label: option.cmd, - description: option.desc, - })); + const normalizedPrefix = prefix.length > 0 ? `${prefix} ` : ""; + return options + .filter((option) => option.cmd.startsWith(partial)) + .map((option) => ({ + value: `${normalizedPrefix}${option.cmd}`, + label: option.cmd, + description: option.desc, + })); } function getSfArgumentCompletions(prefix) { - const parts = prefix.trim().split(/\s+/); - if (parts.length <= 1) { - return filterStartsWith(parts[0] ?? "", TOP_LEVEL_SUBCOMMANDS); - } - const partial = parts[1] ?? ""; - if ((parts[0] === "auto" || parts[0] === "autonomous") && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--verbose", desc: "Show detailed execution output" }, - { cmd: "--debug", desc: "Enable debug logging" }, - ], parts[0]); - } - if (parts[0] === "next" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--verbose", desc: "Show detailed step output" }, - { cmd: "--dry-run", desc: "Preview next step without executing" }, - ], "next"); - } - if (parts[0] === "mode" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "global", desc: "Edit global workflow mode" }, - { cmd: "project", desc: "Edit project-specific workflow mode" }, - ], "mode"); - } - if (parts[0] === "parallel" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "start", desc: "Start parallel milestone orchestration" }, - { cmd: "status", desc: "Show parallel worker statuses" }, - { cmd: "stop", desc: "Stop all parallel workers" }, - { cmd: "pause", desc: "Pause a specific worker" }, - { cmd: "resume", desc: "Resume a paused worker" }, - { cmd: "merge", desc: "Merge completed milestone branches" }, - ], "parallel"); - } - if (parts[0] === "setup" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "llm", desc: "Configure LLM provider settings" }, - { cmd: "search", desc: "Configure web search provider" }, - { cmd: "remote", desc: "Configure remote integrations" }, - { cmd: "keys", desc: "Manage API keys" }, - { cmd: "prefs", desc: "Configure global preferences" }, - ], "setup"); - } - if (parts[0] === "logs" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "debug", desc: "List or view debug log files" }, - { cmd: "tail", desc: "Show last N activity log summaries" }, - { cmd: "clear", desc: "Remove old activity and debug logs" }, - ], "logs"); - } - if (parts[0] === "keys" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "list", desc: "Show key status dashboard" }, - { cmd: "add", desc: "Add a key for a provider" }, - { cmd: "remove", desc: "Remove a key" }, - { cmd: "test", desc: "Validate key(s) with API call" }, - { cmd: "rotate", desc: "Replace an existing key" }, - { cmd: "doctor", desc: "Health check all keys" }, - ], "keys"); - } - if (parts[0] === "prefs" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "global", desc: "Edit global preferences file" }, - { cmd: "project", desc: "Edit project preferences file" }, - { cmd: "status", desc: "Show effective preferences" }, - { cmd: "wizard", desc: "Interactive preferences wizard" }, - { cmd: "setup", desc: "First-time preferences setup" }, - { cmd: "import-claude", desc: "Import settings from Claude Code" }, - ], "prefs"); - } - if (parts[0] === "remote" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "slack", desc: "Configure Slack integration" }, - { cmd: "discord", desc: "Configure Discord integration" }, - { cmd: "status", desc: "Show remote connection status" }, - { cmd: "disconnect", desc: "Disconnect remote integrations" }, - ], "remote"); - } - if (parts[0] === "history" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--cost", desc: "Show cost breakdown per entry" }, - { cmd: "--phase", desc: "Filter by phase type" }, - { cmd: "--model", desc: "Filter by model used" }, - { cmd: "10", desc: "Show last 10 entries" }, - { cmd: "20", desc: "Show last 20 entries" }, - { cmd: "50", desc: "Show last 50 entries" }, - ], "history"); - } - if (parts[0] === "export" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--json", desc: "Export as JSON" }, - { cmd: "--markdown", desc: "Export as Markdown" }, - { cmd: "--html", desc: "Export as HTML" }, - { cmd: "--html --all", desc: "Export all milestones as HTML" }, - ], "export"); - } - if (parts[0] === "cleanup" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "branches", desc: "Remove merged milestone branches" }, - { cmd: "snapshots", desc: "Remove old execution snapshots" }, - ], "cleanup"); - } - if (parts[0] === "knowledge" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "rule", desc: "Add a project rule" }, - { cmd: "pattern", desc: "Add a code pattern" }, - { cmd: "lesson", desc: "Record a lesson learned" }, - ], "knowledge"); - } - if (parts[0] === "start" && parts.length <= 2) { - return filterStartsWith(partial, [ - ...workflowTemplateCommandDefinitions(), - { cmd: "resume", desc: "Resume an in-progress workflow" }, - { cmd: "--list", desc: "List all available templates" }, - { cmd: "--dry-run", desc: "Preview workflow without executing" }, - ], "start"); - } - if (parts[0] === "templates" && parts.length <= 2) { - return filterStartsWith(partial, [{ cmd: "info", desc: "Show detailed template info" }], "templates"); - } - if (parts[0] === "extensions" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "list", desc: "List all extensions and their status" }, - { cmd: "enable", desc: "Enable a disabled extension" }, - { cmd: "disable", desc: "Disable an extension" }, - { cmd: "info", desc: "Show extension details" }, - ], "extensions"); - } - if (parts[0] === "codebase" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, - { cmd: "update", desc: "Refresh the CODEBASE.md cache immediately" }, - { - cmd: "stats", - desc: "Show codebase-map coverage and generation time", - }, - { - cmd: "rag", - desc: "Inspect optional project-rag code search backend", - }, - { - cmd: "rag build", - desc: "Build vendored Rust project-rag and configure MCP", - }, - { cmd: "help", desc: "Show usage and subcommands" }, - ], "codebase"); - } - if (parts[0] === "triage" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--source", desc: "Triage source (captures|todo)" }, - ], "triage"); - } - if (parts[0] === "triage" && parts[1] === "--source" && parts.length <= 3) { - return filterStartsWith(partial, [ - { cmd: "captures", desc: "Triage pending captures (default)" }, - { cmd: "todo", desc: "Triage repo-root TODO.md" }, - ], "triage --source"); - } - if (parts[0] === "doctor" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "fix", desc: "Auto-fix detected issues" }, - { cmd: "heal", desc: "AI-driven deep healing" }, - { cmd: "audit", desc: "Run health audit without fixing" }, - ], "doctor"); - } - if (parts[0] === "scaffold" && parts.length <= 2) { - return filterStartsWith(partial, [ - { - cmd: "sync", - desc: "Refresh ADR-021 scaffold docs (drift report + apply pending upgrades)", - }, - ], "scaffold"); - } - if (parts[0] === "dispatch" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "research", desc: "Run research phase" }, - { cmd: "plan", desc: "Run planning phase" }, - { cmd: "execute", desc: "Run execution phase" }, - { cmd: "complete", desc: "Run completion phase" }, - { cmd: "reassess", desc: "Reassess current progress" }, - { cmd: "uat", desc: "Run user acceptance testing" }, - { cmd: "replan", desc: "Replan the current slice" }, - ], "dispatch"); - } - return null; + const parts = prefix.trim().split(/\s+/); + if (parts.length <= 1) { + return filterStartsWith(parts[0] ?? "", TOP_LEVEL_SUBCOMMANDS); + } + const partial = parts[1] ?? ""; + if ((parts[0] === "auto" || parts[0] === "autonomous") && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "--verbose", desc: "Show detailed execution output" }, + { cmd: "--debug", desc: "Enable debug logging" }, + ], + parts[0], + ); + } + if (parts[0] === "next" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "--verbose", desc: "Show detailed step output" }, + { cmd: "--dry-run", desc: "Preview next step without executing" }, + ], + "next", + ); + } + if (parts[0] === "mode" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "global", desc: "Edit global workflow mode" }, + { cmd: "project", desc: "Edit project-specific workflow mode" }, + ], + "mode", + ); + } + if (parts[0] === "parallel" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "start", desc: "Start parallel milestone orchestration" }, + { cmd: "status", desc: "Show parallel worker statuses" }, + { cmd: "stop", desc: "Stop all parallel workers" }, + { cmd: "pause", desc: "Pause a specific worker" }, + { cmd: "resume", desc: "Resume a paused worker" }, + { cmd: "merge", desc: "Merge completed milestone branches" }, + ], + "parallel", + ); + } + if (parts[0] === "setup" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "llm", desc: "Configure LLM provider settings" }, + { cmd: "search", desc: "Configure web search provider" }, + { cmd: "remote", desc: "Configure remote integrations" }, + { cmd: "keys", desc: "Manage API keys" }, + { cmd: "prefs", desc: "Configure global preferences" }, + ], + "setup", + ); + } + if (parts[0] === "logs" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "debug", desc: "List or view debug log files" }, + { cmd: "tail", desc: "Show last N activity log summaries" }, + { cmd: "clear", desc: "Remove old activity and debug logs" }, + ], + "logs", + ); + } + if (parts[0] === "keys" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "list", desc: "Show key status dashboard" }, + { cmd: "add", desc: "Add a key for a provider" }, + { cmd: "remove", desc: "Remove a key" }, + { cmd: "test", desc: "Validate key(s) with API call" }, + { cmd: "rotate", desc: "Replace an existing key" }, + { cmd: "doctor", desc: "Health check all keys" }, + ], + "keys", + ); + } + if (parts[0] === "prefs" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "global", desc: "Edit global preferences file" }, + { cmd: "project", desc: "Edit project preferences file" }, + { cmd: "status", desc: "Show effective preferences" }, + { cmd: "wizard", desc: "Interactive preferences wizard" }, + { cmd: "setup", desc: "First-time preferences setup" }, + { cmd: "import-claude", desc: "Import settings from Claude Code" }, + ], + "prefs", + ); + } + if (parts[0] === "remote" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "slack", desc: "Configure Slack integration" }, + { cmd: "discord", desc: "Configure Discord integration" }, + { cmd: "status", desc: "Show remote connection status" }, + { cmd: "disconnect", desc: "Disconnect remote integrations" }, + ], + "remote", + ); + } + if (parts[0] === "history" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "--cost", desc: "Show cost breakdown per entry" }, + { cmd: "--phase", desc: "Filter by phase type" }, + { cmd: "--model", desc: "Filter by model used" }, + { cmd: "10", desc: "Show last 10 entries" }, + { cmd: "20", desc: "Show last 20 entries" }, + { cmd: "50", desc: "Show last 50 entries" }, + ], + "history", + ); + } + if (parts[0] === "export" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "--json", desc: "Export as JSON" }, + { cmd: "--markdown", desc: "Export as Markdown" }, + { cmd: "--html", desc: "Export as HTML" }, + { cmd: "--html --all", desc: "Export all milestones as HTML" }, + ], + "export", + ); + } + if (parts[0] === "cleanup" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "branches", desc: "Remove merged milestone branches" }, + { cmd: "snapshots", desc: "Remove old execution snapshots" }, + ], + "cleanup", + ); + } + if (parts[0] === "knowledge" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "rule", desc: "Add a project rule" }, + { cmd: "pattern", desc: "Add a code pattern" }, + { cmd: "lesson", desc: "Record a lesson learned" }, + ], + "knowledge", + ); + } + if (parts[0] === "start" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + ...workflowTemplateCommandDefinitions(), + { cmd: "resume", desc: "Resume an in-progress workflow" }, + { cmd: "--list", desc: "List all available templates" }, + { cmd: "--dry-run", desc: "Preview workflow without executing" }, + ], + "start", + ); + } + if (parts[0] === "templates" && parts.length <= 2) { + return filterStartsWith( + partial, + [{ cmd: "info", desc: "Show detailed template info" }], + "templates", + ); + } + if (parts[0] === "extensions" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "list", desc: "List all extensions and their status" }, + { cmd: "enable", desc: "Enable a disabled extension" }, + { cmd: "disable", desc: "Disable an extension" }, + { cmd: "info", desc: "Show extension details" }, + ], + "extensions", + ); + } + if (parts[0] === "codebase" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, + { cmd: "update", desc: "Refresh the CODEBASE.md cache immediately" }, + { + cmd: "stats", + desc: "Show codebase-map coverage and generation time", + }, + { + cmd: "indexer", + desc: "Inspect Sift code search backend", + }, + { cmd: "help", desc: "Show usage and subcommands" }, + ], + "codebase", + ); + } + if (parts[0] === "triage" && parts.length <= 2) { + return filterStartsWith( + partial, + [{ cmd: "--source", desc: "Triage source (captures|todo)" }], + "triage", + ); + } + if (parts[0] === "triage" && parts[1] === "--source" && parts.length <= 3) { + return filterStartsWith( + partial, + [ + { cmd: "captures", desc: "Triage pending captures (default)" }, + { cmd: "todo", desc: "Triage repo-root TODO.md" }, + ], + "triage --source", + ); + } + if (parts[0] === "doctor" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "fix", desc: "Auto-fix detected issues" }, + { cmd: "heal", desc: "AI-driven deep healing" }, + { cmd: "audit", desc: "Run health audit without fixing" }, + ], + "doctor", + ); + } + if (parts[0] === "scaffold" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { + cmd: "sync", + desc: "Refresh ADR-021 scaffold docs (drift report + apply pending upgrades)", + }, + ], + "scaffold", + ); + } + if (parts[0] === "dispatch" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { cmd: "research", desc: "Run research phase" }, + { cmd: "plan", desc: "Run planning phase" }, + { cmd: "execute", desc: "Run execution phase" }, + { cmd: "complete", desc: "Run completion phase" }, + { cmd: "reassess", desc: "Reassess current progress" }, + { cmd: "uat", desc: "Run user acceptance testing" }, + { cmd: "replan", desc: "Replan the current slice" }, + ], + "dispatch", + ); + } + return null; } export function registerLazySFCommand(pi) { - pi.registerCommand("sf", { - description: "SF — Singularity Forge", - getArgumentCompletions: getSfArgumentCompletions, - handler: async (args, ctx) => { - const { handleSFCommand } = await importExtensionModule(import.meta.url, "./commands.js"); - await handleSFCommand(args, ctx, pi); - }, - }); + pi.registerCommand("sf", { + description: "SF — Singularity Forge", + getArgumentCompletions: getSfArgumentCompletions, + handler: async (args, ctx) => { + const { handleSFCommand } = await importExtensionModule( + import.meta.url, + "./commands.js", + ); + await handleSFCommand(args, ctx, pi); + }, + }); } diff --git a/src/resources/extensions/sf/commands-codebase.js b/src/resources/extensions/sf/commands-codebase.js index 8e7f783e5..ca58f9108 100644 --- a/src/resources/extensions/sf/commands-codebase.js +++ b/src/resources/extensions/sf/commands-codebase.js @@ -2,176 +2,150 @@ * SF Command — /sf codebase * * Generate and manage the codebase map (.sf/CODEBASE.md). - * Subcommands: generate, update, stats, indexer, rag, help + * Subcommands: generate, update, stats, indexer, help */ -import { buildProjectRagBinary, ensureProjectRagMcpConfig, formatCodebaseIndexerStatus, } from "./code-intelligence.js"; -import { generateCodebaseMap, getCodebaseMapStats, readCodebaseMap, updateCodebaseMap, writeCodebaseMap, } from "./codebase-generator.js"; +import { formatCodebaseIndexerStatus } from "./code-intelligence.js"; +import { + generateCodebaseMap, + getCodebaseMapStats, + readCodebaseMap, + updateCodebaseMap, + writeCodebaseMap, +} from "./codebase-generator.js"; import { loadEffectiveSFPreferences } from "./preferences.js"; -const USAGE = "Usage: /sf codebase [generate|update|stats|indexer|rag]\n\n" + - " generate [--max-files N] [--collapse-threshold N] — Generate or regenerate CODEBASE.md\n" + - " update [--max-files N] [--collapse-threshold N] — Refresh the CODEBASE.md cache immediately\n" + - " stats — Show file count, coverage, and generation time\n" + - " indexer [status] — Inspect selected optional codebase-indexer backend\n" + - " rag [status|init|build] — Inspect selected backend, or build/configure project-rag MCP\n" + - " help — Show this help\n\n" + - "With no subcommand, shows stats if a map exists or help if not.\n" + - "SF also refreshes CODEBASE.md automatically before prompt injection and after completed units when tracked files change.\n\n" + - "Configure defaults via preferences.md:\n" + - " codebase:\n" + - ' exclude_patterns: ["docs/", "fixtures/"]\n' + - " max_files: 1000\n" + - " collapse_threshold: 15\n" + - " indexer_backend: sift # projectRag | sift | none; omit for auto-detect\n" + - " project_rag: auto # auto | off | required\n" + - " project_rag_auto_index: true"; + +const USAGE = + "Usage: /sf codebase [generate|update|stats|indexer]\n\n" + + " generate [--max-files N] [--collapse-threshold N] — Generate or regenerate CODEBASE.md\n" + + " update [--max-files N] [--collapse-threshold N] — Refresh the CODEBASE.md cache immediately\n" + + " stats — Show file count, coverage, and generation time\n" + + " indexer [status] — Inspect Sift codebase-indexer status\n" + + " help — Show this help\n\n" + + "With no subcommand, shows stats if a map exists or help if not.\n" + + "SF also refreshes CODEBASE.md automatically before prompt injection and after completed units when tracked files change.\n\n" + + "Configure defaults via preferences.md:\n" + + " codebase:\n" + + ' exclude_patterns: ["docs/", "fixtures/"]\n' + + " max_files: 1000\n" + + " collapse_threshold: 15\n" + + " indexer_backend: sift # sift | none; omit for auto-detect"; export async function handleCodebase(args, ctx, _pi) { - const basePath = process.cwd(); - const parts = args.trim().split(/\s+/); - const sub = parts[0] ?? ""; - switch (sub) { - case "generate": { - const options = resolveCodebaseOptions(args, ctx); - if (options === false) - return; // validation failed, message already shown - const existing = readCodebaseMap(basePath); - const existingDescriptions = existing - ? (await import("./codebase-generator.js")).parseCodebaseMap(existing) - : undefined; - const result = generateCodebaseMap(basePath, options, existingDescriptions); - if (result.fileCount === 0) { - ctx.ui.notify("Codebase map generated with 0 files.\n" + - "Is this a git repository? Run 'git ls-files' to verify.", "warning"); - return; - } - const outPath = writeCodebaseMap(basePath, result.content); - ctx.ui.notify(`Codebase map generated: ${result.fileCount} files\n` + - `Written to: ${outPath}` + - (result.truncated - ? `\n⚠ Truncated — increase --max-files to include all files` - : ""), "success"); - return; - } - case "update": { - const existing = readCodebaseMap(basePath); - if (!existing) { - ctx.ui.notify("No codebase map found. Run /sf codebase generate to create one.", "warning"); - return; - } - const options = resolveCodebaseOptions(args, ctx); - if (options === false) - return; - const result = updateCodebaseMap(basePath, options); - writeCodebaseMap(basePath, result.content); - ctx.ui.notify(`Codebase map updated: ${result.fileCount} files\n` + - ` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}` + - (result.truncated - ? `\n⚠ Truncated — increase --max-files to include all files` - : ""), "success"); - return; - } - case "stats": { - showStats(basePath, ctx); - return; - } - case "indexer": { - const action = (parts[1] ?? "status").toLowerCase(); - const prefs = loadEffectiveSFPreferences()?.preferences?.codebase; - if (action === "status") { - ctx.ui.notify(formatCodebaseIndexerStatus(basePath, prefs), "info"); - return; - } - ctx.ui.notify(`Unknown /sf codebase indexer action "${action}". Use status.`, "warning"); - return; - } - case "rag": { - const action = (parts[1] ?? "status").toLowerCase(); - const prefs = loadEffectiveSFPreferences()?.preferences?.codebase; - if (action === "status") { - ctx.ui.notify(formatCodebaseIndexerStatus(basePath, prefs), "info"); - return; - } - if (action === "init") { - try { - const result = ensureProjectRagMcpConfig(basePath); - ctx.ui.notify([ - result.status === "created" - ? "Created project-rag MCP config." - : result.status === "updated" - ? "Updated project-rag MCP config." - : "Project-rag MCP config is already up to date.", - "", - `Server: ${result.serverName}`, - `Config: ${result.configPath}`, - "", - "Restart the MCP client session so the new server and tools are loaded.", - ].join("\n"), "success"); - } - catch (err) { - ctx.ui.notify(`Could not initialize project-rag MCP config: ${err instanceof Error ? err.message : String(err)}`, "warning"); - } - return; - } - if (action === "build") { - try { - const build = buildProjectRagBinary(basePath); - const result = ensureProjectRagMcpConfig(basePath, { - ...process.env, - SF_PROJECT_RAG_BIN: build.binaryPath, - }); - ctx.ui.notify([ - "Built project-rag release binary.", - "", - `Source: ${build.sourceDir}`, - `Binary: ${build.binaryPath}`, - `Cargo jobs: ${build.buildJobs} (override with SF_PROJECT_RAG_BUILD_JOBS)`, - `MCP config: ${result.configPath} (${result.status})`, - "", - "Restart the MCP client session so the new server and tools are loaded.", - ].join("\n"), "success"); - } - catch (err) { - ctx.ui.notify(`Could not build project-rag: ${err instanceof Error ? err.message : String(err)}`, "warning"); - } - return; - } - ctx.ui.notify(`Unknown /sf codebase rag action "${action}". Use status, init, or build.`, "warning"); - return; - } - case "help": - ctx.ui.notify(USAGE, "info"); - return; - case "": { - // Safe default: show stats if map exists, help if not - const existing = readCodebaseMap(basePath); - if (existing) { - showStats(basePath, ctx); - } - else { - ctx.ui.notify(USAGE, "info"); - } - return; - } - default: - ctx.ui.notify(`Unknown subcommand "${sub}".\n\n${USAGE}`, "warning"); - } + const basePath = process.cwd(); + const parts = args.trim().split(/\s+/); + const sub = parts[0] ?? ""; + switch (sub) { + case "generate": { + const options = resolveCodebaseOptions(args, ctx); + if (options === false) return; // validation failed, message already shown + const existing = readCodebaseMap(basePath); + const existingDescriptions = existing + ? (await import("./codebase-generator.js")).parseCodebaseMap(existing) + : undefined; + const result = generateCodebaseMap( + basePath, + options, + existingDescriptions, + ); + if (result.fileCount === 0) { + ctx.ui.notify( + "Codebase map generated with 0 files.\n" + + "Is this a git repository? Run 'git ls-files' to verify.", + "warning", + ); + return; + } + const outPath = writeCodebaseMap(basePath, result.content); + ctx.ui.notify( + `Codebase map generated: ${result.fileCount} files\n` + + `Written to: ${outPath}` + + (result.truncated + ? `\n⚠ Truncated — increase --max-files to include all files` + : ""), + "success", + ); + return; + } + case "update": { + const existing = readCodebaseMap(basePath); + if (!existing) { + ctx.ui.notify( + "No codebase map found. Run /sf codebase generate to create one.", + "warning", + ); + return; + } + const options = resolveCodebaseOptions(args, ctx); + if (options === false) return; + const result = updateCodebaseMap(basePath, options); + writeCodebaseMap(basePath, result.content); + ctx.ui.notify( + `Codebase map updated: ${result.fileCount} files\n` + + ` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}` + + (result.truncated + ? `\n⚠ Truncated — increase --max-files to include all files` + : ""), + "success", + ); + return; + } + case "stats": { + showStats(basePath, ctx); + return; + } + case "indexer": { + const action = (parts[1] ?? "status").toLowerCase(); + const prefs = loadEffectiveSFPreferences()?.preferences?.codebase; + if (action === "status") { + ctx.ui.notify(formatCodebaseIndexerStatus(basePath, prefs), "info"); + return; + } + ctx.ui.notify( + `Unknown /sf codebase indexer action "${action}". Use status.`, + "warning", + ); + return; + } + case "help": + ctx.ui.notify(USAGE, "info"); + return; + case "": { + // Safe default: show stats if map exists, help if not + const existing = readCodebaseMap(basePath); + if (existing) { + showStats(basePath, ctx); + } else { + ctx.ui.notify(USAGE, "info"); + } + return; + } + default: + ctx.ui.notify(`Unknown subcommand "${sub}".\n\n${USAGE}`, "warning"); + } } function showStats(basePath, ctx) { - const stats = getCodebaseMapStats(basePath); - if (!stats.exists) { - ctx.ui.notify("No codebase map found. Run /sf codebase generate to create one.", "info"); - return; - } - const coverage = stats.fileCount > 0 - ? Math.round((stats.describedCount / stats.fileCount) * 100) - : 0; - ctx.ui.notify(`Codebase Map Stats:\n` + - ` Files: ${stats.fileCount}\n` + - ` Described: ${stats.describedCount} (${coverage}%)\n` + - ` Undescribed: ${stats.undescribedCount}\n` + - ` Generated: ${stats.generatedAt ?? "unknown"}\n\n` + - (stats.undescribedCount > 0 - ? `Tip: Auto-refresh keeps the cache current, but /sf codebase update forces an immediate refresh.` - : `Coverage is complete.`), "info"); + const stats = getCodebaseMapStats(basePath); + if (!stats.exists) { + ctx.ui.notify( + "No codebase map found. Run /sf codebase generate to create one.", + "info", + ); + return; + } + const coverage = + stats.fileCount > 0 + ? Math.round((stats.describedCount / stats.fileCount) * 100) + : 0; + ctx.ui.notify( + `Codebase Map Stats:\n` + + ` Files: ${stats.fileCount}\n` + + ` Described: ${stats.describedCount} (${coverage}%)\n` + + ` Undescribed: ${stats.undescribedCount}\n` + + ` Generated: ${stats.generatedAt ?? "unknown"}\n\n` + + (stats.undescribedCount > 0 + ? `Tip: Auto-refresh keeps the cache current, but /sf codebase update forces an immediate refresh.` + : `Coverage is complete.`), + "info", + ); } /** * Resolve codebase map options by merging preferences with CLI flags. @@ -179,39 +153,45 @@ function showStats(basePath, ctx) { * Returns false if validation failed (error already shown to user). */ function resolveCodebaseOptions(args, ctx) { - // Load preferences defaults - const prefs = loadEffectiveSFPreferences()?.preferences?.codebase; - // Parse CLI flags - const maxFilesStr = extractFlag(args, "--max-files"); - const collapseStr = extractFlag(args, "--collapse-threshold"); - // Validate --max-files - let maxFiles; - if (maxFilesStr) { - maxFiles = parseInt(maxFilesStr, 10); - if (Number.isNaN(maxFiles) || maxFiles < 1) { - ctx.ui.notify("--max-files must be a positive integer (e.g. --max-files 200).", "warning"); - return false; - } - } - // Validate --collapse-threshold - let collapseThreshold; - if (collapseStr) { - collapseThreshold = parseInt(collapseStr, 10); - if (Number.isNaN(collapseThreshold) || collapseThreshold < 1) { - ctx.ui.notify("--collapse-threshold must be a positive integer (e.g. --collapse-threshold 15).", "warning"); - return false; - } - } - return { - // CLI flags override preferences - maxFiles: maxFiles ?? prefs?.max_files, - collapseThreshold: collapseThreshold ?? prefs?.collapse_threshold, - excludePatterns: prefs?.exclude_patterns, - }; + // Load preferences defaults + const prefs = loadEffectiveSFPreferences()?.preferences?.codebase; + // Parse CLI flags + const maxFilesStr = extractFlag(args, "--max-files"); + const collapseStr = extractFlag(args, "--collapse-threshold"); + // Validate --max-files + let maxFiles; + if (maxFilesStr) { + maxFiles = parseInt(maxFilesStr, 10); + if (Number.isNaN(maxFiles) || maxFiles < 1) { + ctx.ui.notify( + "--max-files must be a positive integer (e.g. --max-files 200).", + "warning", + ); + return false; + } + } + // Validate --collapse-threshold + let collapseThreshold; + if (collapseStr) { + collapseThreshold = parseInt(collapseStr, 10); + if (Number.isNaN(collapseThreshold) || collapseThreshold < 1) { + ctx.ui.notify( + "--collapse-threshold must be a positive integer (e.g. --collapse-threshold 15).", + "warning", + ); + return false; + } + } + return { + // CLI flags override preferences + maxFiles: maxFiles ?? prefs?.max_files, + collapseThreshold: collapseThreshold ?? prefs?.collapse_threshold, + excludePatterns: prefs?.exclude_patterns, + }; } function extractFlag(args, flag) { - const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp(`${escaped}[=\\s]+(\\S+)`); - const match = args.match(regex); - return match?.[1]; + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escaped}[=\\s]+(\\S+)`); + const match = args.match(regex); + return match?.[1]; } diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index 898fb5a96..ce78b9344 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -1,578 +1,623 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { loadRegistry, workflowTemplateCommandDefinitions, } from "../workflow-templates.js"; +import { + loadRegistry, + workflowTemplateCommandDefinitions, +} from "../workflow-templates.js"; import { resolveProjectRoot } from "../worktree.js"; + const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); /** * Comprehensive description of all available SF commands for help text. */ -export const SF_COMMAND_DESCRIPTION = "SF — Singularity Forge: /sf help|start|templates|next|autonomous|stop|pause|reload|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan"; +export const SF_COMMAND_DESCRIPTION = + "SF — Singularity Forge: /sf help|start|templates|next|autonomous|stop|pause|reload|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan"; /** * Top-level SF subcommands with descriptions. */ export const TOP_LEVEL_SUBCOMMANDS = [ - { cmd: "help", desc: "Categorized command reference with descriptions" }, - { cmd: "next", desc: "Explicit step mode (same as /sf)" }, - { - cmd: "autonomous", - desc: "Autonomous mode — continuous loop, never asks user (self-resolves or stops with blocker)", - }, - { cmd: "stop", desc: "Stop autonomous mode gracefully" }, - { - cmd: "pause", - desc: "Pause autonomous mode (preserves state, /sf autonomous to resume)", - }, - { - cmd: "reload", - desc: "Reload extensions, skills, prompts, and themes in the TUI", - }, - { cmd: "status", desc: "Progress dashboard" }, - { cmd: "widget", desc: "Cycle widget: full → small → min → off" }, - { - cmd: "visualize", - desc: "Open 10-tab workflow visualizer (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", - }, - { cmd: "queue", desc: "Queue and reorder future milestones" }, - { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, - { cmd: "discuss", desc: "Discuss architecture and decisions" }, - { cmd: "capture", desc: "Fire-and-forget thought capture" }, - { cmd: "debug", desc: "Create and inspect persistent /sf debug sessions" }, - { cmd: "scan", desc: "Run source and project scans" }, - { cmd: "escalate", desc: "List, show, or resolve task escalations (gsd-2 ADR-011 P2)" }, - { cmd: "changelog", desc: "Show categorized release notes" }, - { cmd: "triage", desc: "Manually trigger triage of pending captures" }, - { cmd: "todo", desc: "Triage root TODO.md dump into eval/backlog artifacts" }, - { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, - { cmd: "history", desc: "View execution history" }, - { cmd: "undo", desc: "Revert last completed unit" }, - { - cmd: "undo-task", - desc: "Reset a specific task's completion state (DB + markdown)", - }, - { - cmd: "reset-slice", - desc: "Reset a slice and all its tasks (DB + markdown)", - }, - { - cmd: "rate", - desc: "Rate last unit's model tier (over/ok/under) — improves adaptive routing", - }, - { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, - { cmd: "export", desc: "Export milestone/slice results" }, - { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, - { cmd: "worktree", desc: "Manage worktrees from the TUI (list, merge, clean, remove)" }, - { cmd: "model", desc: "Switch the active session model or open a picker" }, - { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, - { cmd: "show-config", desc: "Show effective configuration (models, routing, toggles)" }, - { - cmd: "prefs", - desc: "Manage preferences (model selection, timeouts, etc.)", - }, - { cmd: "config", desc: "Set API keys for external tools" }, - { - cmd: "keys", - desc: "API key manager — list, add, remove, test, rotate, doctor", - }, - { cmd: "hooks", desc: "Show configured post-unit and pre-dispatch hooks" }, - { cmd: "run-hook", desc: "Manually trigger a specific hook" }, - { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, - { - cmd: "notifications", - desc: "View, filter, and clear persistent notification history", - }, - { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, - { cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" }, - { cmd: "forensics", desc: "Examine execution logs" }, - { - cmd: "init", - desc: "Project init wizard — detect, configure, bootstrap .sf/", - }, - { cmd: "setup", desc: "Global setup status and configuration" }, - { cmd: "migrate", desc: "Migrate a v1 .planning directory to .sf format" }, - { cmd: "remote", desc: "Control remote auto-mode" }, - { cmd: "steer", desc: "Hard-steer plan documents during execution" }, - { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, - { - cmd: "knowledge", - desc: "Add persistent project knowledge (rule, pattern, or lesson)", - }, - { - cmd: "harness", - desc: "Repo-native harness evolution (profile, status)", - }, - { - cmd: "new-milestone", - desc: "Create a milestone from a specification document (headless)", - }, - { - cmd: "parallel", - desc: "Parallel milestone orchestration (start, status, stop, merge, watch)", - }, - { - cmd: "cmux", - desc: "Manage cmux integration (status, sidebar, notifications, splits)", - }, - { cmd: "park", desc: "Park a milestone — skip without deleting" }, - { cmd: "unpark", desc: "Reactivate a parked milestone" }, - { cmd: "update", desc: "Update SF to the latest version" }, - { - cmd: "start", - desc: "Start a workflow template (bugfix, spike, feature, etc.)", - }, - { cmd: "templates", desc: "List available workflow templates" }, - { - cmd: "extensions", - desc: "Manage extensions (list, enable, disable, info)", - }, - { cmd: "fast", desc: "Toggle OpenAI service tier (on/off/flex/status)" }, - { - cmd: "mcp", - desc: "MCP server status, connectivity, and local config bootstrap (status, check, init)", - }, - { - cmd: "rethink", - desc: "Conversational project reorganization — reorder, park, discard, add milestones", - }, - { - cmd: "workflow", - desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)", - }, - { - cmd: "codebase", - desc: "Generate, refresh, and inspect the codebase map cache (.sf/CODEBASE.md)", - }, - { - cmd: "ship", - desc: "Create PR from milestone artifacts and open for review", - }, - { cmd: "do", desc: "Route freeform text to the right SF command" }, - { cmd: "session-report", desc: "Session cost, tokens, and work summary" }, - { cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" }, - { cmd: "schedule", desc: "Manage scheduled items (add, list, done, cancel, snooze, run)" }, - { cmd: "pr-branch", desc: "Create clean PR branch filtering .sf/ commits" }, - { cmd: "add-tests", desc: "Generate tests for completed slices" }, - { - cmd: "scaffold", - desc: "Inspect or refresh ADR-021 versioned scaffold docs (sync, --dry-run, --include-editing, --only=)", - }, - { - cmd: "extract-learnings", - desc: "Extract durable project learnings from session artifacts", - }, - { - cmd: "eval-review", - desc: "Milestone-end evaluation review — audit slice coverage and infrastructure with scored EVAL-REVIEW.md", - }, - { - cmd: "plan", - desc: "Promote planning artifacts from ~/.sf/ to docs/ (promote, list, diff)", - }, + { cmd: "help", desc: "Categorized command reference with descriptions" }, + { cmd: "next", desc: "Explicit step mode (same as /sf)" }, + { + cmd: "autonomous", + desc: "Autonomous mode — continuous loop, never asks user (self-resolves or stops with blocker)", + }, + { cmd: "stop", desc: "Stop autonomous mode gracefully" }, + { + cmd: "pause", + desc: "Pause autonomous mode (preserves state, /sf autonomous to resume)", + }, + { + cmd: "reload", + desc: "Reload extensions, skills, prompts, and themes in the TUI", + }, + { cmd: "status", desc: "Progress dashboard" }, + { cmd: "widget", desc: "Cycle widget: full → small → min → off" }, + { + cmd: "visualize", + desc: "Open 10-tab workflow visualizer (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", + }, + { cmd: "queue", desc: "Queue and reorder future milestones" }, + { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, + { cmd: "discuss", desc: "Discuss architecture and decisions" }, + { cmd: "capture", desc: "Fire-and-forget thought capture" }, + { cmd: "debug", desc: "Create and inspect persistent /sf debug sessions" }, + { cmd: "scan", desc: "Run source and project scans" }, + { + cmd: "escalate", + desc: "List, show, or resolve task escalations (gsd-2 ADR-011 P2)", + }, + { cmd: "changelog", desc: "Show categorized release notes" }, + { cmd: "triage", desc: "Manually trigger triage of pending captures" }, + { cmd: "todo", desc: "Triage root TODO.md dump into eval/backlog artifacts" }, + { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, + { cmd: "history", desc: "View execution history" }, + { cmd: "undo", desc: "Revert last completed unit" }, + { + cmd: "undo-task", + desc: "Reset a specific task's completion state (DB + markdown)", + }, + { + cmd: "reset-slice", + desc: "Reset a slice and all its tasks (DB + markdown)", + }, + { + cmd: "rate", + desc: "Rate last unit's model tier (over/ok/under) — improves adaptive routing", + }, + { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, + { cmd: "export", desc: "Export milestone/slice results" }, + { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, + { + cmd: "worktree", + desc: "Manage worktrees from the TUI (list, merge, clean, remove)", + }, + { cmd: "model", desc: "Switch the active session model or open a picker" }, + { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, + { + cmd: "show-config", + desc: "Show effective configuration (models, routing, toggles)", + }, + { + cmd: "prefs", + desc: "Manage preferences (model selection, timeouts, etc.)", + }, + { cmd: "config", desc: "Set API keys for external tools" }, + { + cmd: "keys", + desc: "API key manager — list, add, remove, test, rotate, doctor", + }, + { cmd: "hooks", desc: "Show configured post-unit and pre-dispatch hooks" }, + { cmd: "run-hook", desc: "Manually trigger a specific hook" }, + { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, + { + cmd: "notifications", + desc: "View, filter, and clear persistent notification history", + }, + { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, + { cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" }, + { cmd: "forensics", desc: "Examine execution logs" }, + { + cmd: "init", + desc: "Project init wizard — detect, configure, bootstrap .sf/", + }, + { cmd: "setup", desc: "Global setup status and configuration" }, + { cmd: "migrate", desc: "Migrate a v1 .planning directory to .sf format" }, + { cmd: "remote", desc: "Control remote auto-mode" }, + { cmd: "steer", desc: "Hard-steer plan documents during execution" }, + { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, + { + cmd: "knowledge", + desc: "Add persistent project knowledge (rule, pattern, or lesson)", + }, + { + cmd: "harness", + desc: "Repo-native harness evolution (profile, status)", + }, + { + cmd: "new-milestone", + desc: "Create a milestone from a specification document (headless)", + }, + { + cmd: "parallel", + desc: "Parallel milestone orchestration (start, status, stop, merge, watch)", + }, + { + cmd: "cmux", + desc: "Manage cmux integration (status, sidebar, notifications, splits)", + }, + { cmd: "park", desc: "Park a milestone — skip without deleting" }, + { cmd: "unpark", desc: "Reactivate a parked milestone" }, + { cmd: "update", desc: "Update SF to the latest version" }, + { + cmd: "start", + desc: "Start a workflow template (bugfix, spike, feature, etc.)", + }, + { cmd: "templates", desc: "List available workflow templates" }, + { + cmd: "extensions", + desc: "Manage extensions (list, enable, disable, info)", + }, + { cmd: "fast", desc: "Toggle OpenAI service tier (on/off/flex/status)" }, + { + cmd: "mcp", + desc: "MCP server status, connectivity, and local config bootstrap (status, check, init)", + }, + { + cmd: "rethink", + desc: "Conversational project reorganization — reorder, park, discard, add milestones", + }, + { + cmd: "workflow", + desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)", + }, + { + cmd: "codebase", + desc: "Generate, refresh, and inspect the codebase map cache (.sf/CODEBASE.md)", + }, + { + cmd: "ship", + desc: "Create PR from milestone artifacts and open for review", + }, + { cmd: "do", desc: "Route freeform text to the right SF command" }, + { cmd: "session-report", desc: "Session cost, tokens, and work summary" }, + { cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" }, + { + cmd: "schedule", + desc: "Manage scheduled items (add, list, done, cancel, snooze, run)", + }, + { cmd: "pr-branch", desc: "Create clean PR branch filtering .sf/ commits" }, + { cmd: "add-tests", desc: "Generate tests for completed slices" }, + { + cmd: "scaffold", + desc: "Inspect or refresh ADR-021 versioned scaffold docs (sync, --dry-run, --include-editing, --only=)", + }, + { + cmd: "extract-learnings", + desc: "Extract durable project learnings from session artifacts", + }, + { + cmd: "eval-review", + desc: "Milestone-end evaluation review — audit slice coverage and infrastructure with scored EVAL-REVIEW.md", + }, + { + cmd: "plan", + desc: "Promote planning artifacts from ~/.sf/ to docs/ (promote, list, diff)", + }, ]; /** * Nested subcommand definitions for multi-level completion. */ const NESTED_COMPLETIONS = { - autonomous: [ - { cmd: "full", desc: "Auto-merge milestones; chain end-to-end without review" }, - { cmd: "--full", desc: "Auto-merge milestones; chain end-to-end without review" }, - { cmd: "--verbose", desc: "Show detailed execution output" }, - { cmd: "--debug", desc: "Enable debug logging" }, - ], - auto: [ - { cmd: "full", desc: "Auto-merge milestones; chain end-to-end without review" }, - { cmd: "--full", desc: "Auto-merge milestones; chain end-to-end without review" }, - { cmd: "--verbose", desc: "Show detailed execution output" }, - { cmd: "--debug", desc: "Enable debug logging" }, - ], - next: [ - { cmd: "--verbose", desc: "Show detailed step output" }, - { cmd: "--dry-run", desc: "Preview next step without executing" }, - { cmd: "--debug", desc: "Enable debug logging" }, - ], - widget: [ - { cmd: "full", desc: "Full widget display" }, - { cmd: "small", desc: "Compact widget display" }, - { cmd: "min", desc: "Minimal widget display" }, - { cmd: "off", desc: "Hide widget" }, - ], - mode: [ - { cmd: "global", desc: "Edit global workflow mode" }, - { cmd: "project", desc: "Edit project-specific workflow mode" }, - ], - parallel: [ - { cmd: "start", desc: "Start parallel milestone orchestration" }, - { cmd: "status", desc: "Show parallel worker statuses" }, - { cmd: "stop", desc: "Stop all parallel workers" }, - { cmd: "pause", desc: "Pause a specific worker" }, - { cmd: "resume", desc: "Resume a paused worker" }, - { cmd: "merge", desc: "Merge completed milestone branches" }, - { cmd: "watch", desc: "Live TUI dashboard monitoring all workers" }, - ], - setup: [ - { cmd: "llm", desc: "Configure LLM provider settings" }, - { cmd: "search", desc: "Configure web search provider" }, - { cmd: "remote", desc: "Configure remote integrations" }, - { cmd: "keys", desc: "Manage API keys" }, - { cmd: "prefs", desc: "Configure global preferences" }, - ], - notifications: [ - { cmd: "clear", desc: "Clear all notifications" }, - { cmd: "tail", desc: "Show last N notifications (default: 20)" }, - { cmd: "filter", desc: "Filter by severity (error|warning|info|success)" }, - ], - logs: [ - { cmd: "debug", desc: "List or view debug log files" }, - { cmd: "tail", desc: "Show last N activity log summaries" }, - { cmd: "clear", desc: "Remove old activity and debug logs" }, - ], - keys: [ - { cmd: "list", desc: "Show key status dashboard" }, - { cmd: "add", desc: "Add a key for a provider" }, - { cmd: "remove", desc: "Remove a key" }, - { cmd: "test", desc: "Validate key(s) with API call" }, - { cmd: "rotate", desc: "Replace an existing key" }, - { cmd: "doctor", desc: "Health check all keys" }, - ], - prefs: [ - { cmd: "global", desc: "Edit global preferences file" }, - { cmd: "project", desc: "Edit project preferences file" }, - { cmd: "status", desc: "Show effective preferences" }, - { cmd: "wizard", desc: "Interactive preferences wizard" }, - { cmd: "setup", desc: "First-time preferences setup" }, - { cmd: "import-claude", desc: "Import settings from Claude Code" }, - ], - remote: [ - { cmd: "slack", desc: "Configure Slack integration" }, - { cmd: "discord", desc: "Configure Discord integration" }, - { cmd: "status", desc: "Show remote connection status" }, - { cmd: "disconnect", desc: "Disconnect remote integrations" }, - ], - history: [ - { cmd: "--cost", desc: "Show cost breakdown per entry" }, - { cmd: "--phase", desc: "Filter by phase type" }, - { cmd: "--model", desc: "Filter by model used" }, - { cmd: "10", desc: "Show last 10 entries" }, - { cmd: "20", desc: "Show last 20 entries" }, - { cmd: "50", desc: "Show last 50 entries" }, - ], - export: [ - { cmd: "--json", desc: "Export as JSON" }, - { cmd: "--markdown", desc: "Export as Markdown" }, - { cmd: "--html", desc: "Export as HTML" }, - { cmd: "--html --all", desc: "Export all milestones as HTML" }, - ], - cleanup: [ - { cmd: "branches", desc: "Remove merged milestone and legacy branches" }, - { cmd: "snapshots", desc: "Remove old execution snapshots" }, - { cmd: "worktrees", desc: "Remove merged/safe-to-delete worktrees" }, - { - cmd: "projects", - desc: "Audit orphaned ~/.sf/projects/ state directories", - }, - { - cmd: "projects --fix", - desc: "Delete orphaned project state directories (cannot be undone)", - }, - ], - worktree: [ - { cmd: "list", desc: "Show all worktrees with status" }, - { cmd: "merge", desc: "Merge a worktree into main, then remove it" }, - { cmd: "clean", desc: "Remove all merged/empty worktrees" }, - { cmd: "remove", desc: "Remove a worktree (use --force to skip safety checks)" }, - ], - knowledge: [ - { cmd: "rule", desc: "Add a project rule (always/never do X)" }, - { cmd: "pattern", desc: "Add a code pattern to follow" }, - { cmd: "lesson", desc: "Record a lesson learned" }, - ], - harness: [ - { - cmd: "profile", - desc: "Record a read-only repo profile for harness evolution", - }, - { cmd: "status", desc: "Alias for profile in the first implementation" }, - ], - start: [ - ...workflowTemplateCommandDefinitions(), - { cmd: "resume", desc: "Resume an in-progress workflow" }, - { cmd: "--list", desc: "List all available templates" }, - { cmd: "--dry-run", desc: "Preview workflow without executing" }, - ], - templates: [{ cmd: "info", desc: "Show detailed template info" }], - extensions: [ - { cmd: "list", desc: "List all extensions and their status" }, - { cmd: "enable", desc: "Enable a disabled extension" }, - { cmd: "disable", desc: "Disable an extension" }, - { cmd: "info", desc: "Show extension details" }, - ], - fast: [ - { cmd: "on", desc: "Priority tier (2x cost, faster)" }, - { cmd: "off", desc: "Disable service tier" }, - { cmd: "flex", desc: "Flex tier (0.5x cost, slower)" }, - { cmd: "status", desc: "Show current service tier setting" }, - ], - mcp: [ - { cmd: "status", desc: "Show all MCP server statuses (default)" }, - { cmd: "check", desc: "Detailed status for a specific server" }, - { - cmd: "init", - desc: "Write .mcp.json for the local SF workflow MCP server", - }, - ], - doctor: [ - { cmd: "fix", desc: "Auto-fix detected issues" }, - { cmd: "heal", desc: "AI-driven deep healing" }, - { cmd: "audit", desc: "Run health audit without fixing" }, - { cmd: "--dry-run", desc: "Show what --fix would change without applying" }, - { cmd: "--json", desc: "Output report as JSON (CI/tooling friendly)" }, - { cmd: "--build", desc: "Include slow build health check (npm run build)" }, - { cmd: "--test", desc: "Include slow test health check (npm test)" }, - ], - dispatch: [ - { cmd: "research", desc: "Run research phase" }, - { cmd: "plan", desc: "Run planning phase" }, - { cmd: "execute", desc: "Run execution phase" }, - { cmd: "complete", desc: "Run completion phase" }, - { cmd: "reassess", desc: "Reassess current progress" }, - { cmd: "uat", desc: "Run user acceptance testing" }, - { cmd: "replan", desc: "Replan the current slice" }, - ], - rate: [ - { cmd: "over", desc: "Model was overqualified for this task" }, - { cmd: "ok", desc: "Model was appropriate for this task" }, - { cmd: "under", desc: "Model was underqualified for this task" }, - ], - workflow: [ - { cmd: "new", desc: "Create a new workflow definition (via skill)" }, - { cmd: "run", desc: "Create a run and start auto-mode" }, - { cmd: "list", desc: "List workflow runs" }, - { cmd: "validate", desc: "Validate a workflow definition YAML" }, - { cmd: "pause", desc: "Pause custom workflow auto-mode" }, - { cmd: "resume", desc: "Resume paused custom workflow auto-mode" }, - ], - codebase: [ - { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, - { - cmd: "generate --max-files", - desc: "Generate with custom file limit (default: 500)", - }, - { - cmd: "generate --collapse-threshold", - desc: "Generate with custom collapse threshold (default: 20)", - }, - { - cmd: "update", - desc: "Refresh the CODEBASE.md cache immediately (preserves descriptions)", - }, - { cmd: "update --max-files", desc: "Update with custom file limit" }, - { - cmd: "update --collapse-threshold", - desc: "Update with custom collapse threshold", - }, - { - cmd: "stats", - desc: "Show file count, description coverage, and generation time", - }, - { cmd: "rag status", desc: "Show optional project-rag MCP backend status" }, - { - cmd: "rag init", - desc: "Write .mcp.json entry for project-rag when a binary is available", - }, - { - cmd: "rag build", - desc: "Build vendored Rust project-rag and write MCP config", - }, - { cmd: "help", desc: "Show usage and available subcommands" }, - ], - ship: [ - { cmd: "--dry-run", desc: "Preview PR without creating" }, - { cmd: "--draft", desc: "Open as draft PR" }, - { cmd: "--base", desc: "Override target branch (default: main)" }, - { cmd: "--force", desc: "Ship even with pending tasks" }, - ], - "session-report": [ - { cmd: "--json", desc: "Machine-readable JSON output" }, - { cmd: "--save", desc: "Save report to .sf/reports/" }, - ], - backlog: [ - { cmd: "add", desc: "Add item to backlog" }, - { cmd: "promote", desc: "Promote backlog item to active slice" }, - { cmd: "remove", desc: "Remove backlog item" }, - ], - schedule: [ - { cmd: "add", desc: "Add a scheduled item" }, - { cmd: "list", desc: "List scheduled items" }, - { cmd: "done", desc: "Mark item as done" }, - { cmd: "cancel", desc: "Cancel a scheduled item" }, - { cmd: "snooze", desc: "Snooze an item by duration" }, - { cmd: "run", desc: "Run a scheduled item now" }, - ], - todo: [ - { cmd: "triage", desc: "Triage root TODO.md into .sf/triage artifacts" }, - { cmd: "triage --no-clear", desc: "Triage TODO.md without resetting it" }, - { cmd: "triage --backlog", desc: "Also add implementation tasks to .sf/WORK-QUEUE.md" }, - ], - "pr-branch": [ - { cmd: "--dry-run", desc: "Preview what would be filtered" }, - { cmd: "--name", desc: "Custom branch name" }, - ], - scaffold: [ - { - cmd: "sync", - desc: "Refresh ADR-021 scaffold docs (drift report + apply pending upgrades)", - }, - { cmd: "sync --dry-run", desc: "Print drift report without modifying files" }, - { - cmd: "sync --include-editing", - desc: "Run scaffold-keeper synchronously for editing-drift items", - }, - { - cmd: "sync --only=", - desc: "Restrict the operation to a path glob (e.g. --only=harness/**)", - }, - ], - plan: [ - { cmd: "promote", desc: "Copy a planning artifact from ~/.sf/ into docs/" }, - { cmd: "list", desc: "List ~/.sf/ planning artifacts with promoted status" }, - { cmd: "diff", desc: "Show diff between ~/.sf/ and promoted version" }, - ], + autonomous: [ + { + cmd: "full", + desc: "Auto-merge milestones; chain end-to-end without review", + }, + { + cmd: "--full", + desc: "Auto-merge milestones; chain end-to-end without review", + }, + { cmd: "--verbose", desc: "Show detailed execution output" }, + { cmd: "--debug", desc: "Enable debug logging" }, + ], + auto: [ + { + cmd: "full", + desc: "Auto-merge milestones; chain end-to-end without review", + }, + { + cmd: "--full", + desc: "Auto-merge milestones; chain end-to-end without review", + }, + { cmd: "--verbose", desc: "Show detailed execution output" }, + { cmd: "--debug", desc: "Enable debug logging" }, + ], + next: [ + { cmd: "--verbose", desc: "Show detailed step output" }, + { cmd: "--dry-run", desc: "Preview next step without executing" }, + { cmd: "--debug", desc: "Enable debug logging" }, + ], + widget: [ + { cmd: "full", desc: "Full widget display" }, + { cmd: "small", desc: "Compact widget display" }, + { cmd: "min", desc: "Minimal widget display" }, + { cmd: "off", desc: "Hide widget" }, + ], + mode: [ + { cmd: "global", desc: "Edit global workflow mode" }, + { cmd: "project", desc: "Edit project-specific workflow mode" }, + ], + parallel: [ + { cmd: "start", desc: "Start parallel milestone orchestration" }, + { cmd: "status", desc: "Show parallel worker statuses" }, + { cmd: "stop", desc: "Stop all parallel workers" }, + { cmd: "pause", desc: "Pause a specific worker" }, + { cmd: "resume", desc: "Resume a paused worker" }, + { cmd: "merge", desc: "Merge completed milestone branches" }, + { cmd: "watch", desc: "Live TUI dashboard monitoring all workers" }, + ], + setup: [ + { cmd: "llm", desc: "Configure LLM provider settings" }, + { cmd: "search", desc: "Configure web search provider" }, + { cmd: "remote", desc: "Configure remote integrations" }, + { cmd: "keys", desc: "Manage API keys" }, + { cmd: "prefs", desc: "Configure global preferences" }, + ], + notifications: [ + { cmd: "clear", desc: "Clear all notifications" }, + { cmd: "tail", desc: "Show last N notifications (default: 20)" }, + { cmd: "filter", desc: "Filter by severity (error|warning|info|success)" }, + ], + logs: [ + { cmd: "debug", desc: "List or view debug log files" }, + { cmd: "tail", desc: "Show last N activity log summaries" }, + { cmd: "clear", desc: "Remove old activity and debug logs" }, + ], + keys: [ + { cmd: "list", desc: "Show key status dashboard" }, + { cmd: "add", desc: "Add a key for a provider" }, + { cmd: "remove", desc: "Remove a key" }, + { cmd: "test", desc: "Validate key(s) with API call" }, + { cmd: "rotate", desc: "Replace an existing key" }, + { cmd: "doctor", desc: "Health check all keys" }, + ], + prefs: [ + { cmd: "global", desc: "Edit global preferences file" }, + { cmd: "project", desc: "Edit project preferences file" }, + { cmd: "status", desc: "Show effective preferences" }, + { cmd: "wizard", desc: "Interactive preferences wizard" }, + { cmd: "setup", desc: "First-time preferences setup" }, + { cmd: "import-claude", desc: "Import settings from Claude Code" }, + ], + remote: [ + { cmd: "slack", desc: "Configure Slack integration" }, + { cmd: "discord", desc: "Configure Discord integration" }, + { cmd: "status", desc: "Show remote connection status" }, + { cmd: "disconnect", desc: "Disconnect remote integrations" }, + ], + history: [ + { cmd: "--cost", desc: "Show cost breakdown per entry" }, + { cmd: "--phase", desc: "Filter by phase type" }, + { cmd: "--model", desc: "Filter by model used" }, + { cmd: "10", desc: "Show last 10 entries" }, + { cmd: "20", desc: "Show last 20 entries" }, + { cmd: "50", desc: "Show last 50 entries" }, + ], + export: [ + { cmd: "--json", desc: "Export as JSON" }, + { cmd: "--markdown", desc: "Export as Markdown" }, + { cmd: "--html", desc: "Export as HTML" }, + { cmd: "--html --all", desc: "Export all milestones as HTML" }, + ], + cleanup: [ + { cmd: "branches", desc: "Remove merged milestone and legacy branches" }, + { cmd: "snapshots", desc: "Remove old execution snapshots" }, + { cmd: "worktrees", desc: "Remove merged/safe-to-delete worktrees" }, + { + cmd: "projects", + desc: "Audit orphaned ~/.sf/projects/ state directories", + }, + { + cmd: "projects --fix", + desc: "Delete orphaned project state directories (cannot be undone)", + }, + ], + worktree: [ + { cmd: "list", desc: "Show all worktrees with status" }, + { cmd: "merge", desc: "Merge a worktree into main, then remove it" }, + { cmd: "clean", desc: "Remove all merged/empty worktrees" }, + { + cmd: "remove", + desc: "Remove a worktree (use --force to skip safety checks)", + }, + ], + knowledge: [ + { cmd: "rule", desc: "Add a project rule (always/never do X)" }, + { cmd: "pattern", desc: "Add a code pattern to follow" }, + { cmd: "lesson", desc: "Record a lesson learned" }, + ], + harness: [ + { + cmd: "profile", + desc: "Record a read-only repo profile for harness evolution", + }, + { cmd: "status", desc: "Alias for profile in the first implementation" }, + ], + start: [ + ...workflowTemplateCommandDefinitions(), + { cmd: "resume", desc: "Resume an in-progress workflow" }, + { cmd: "--list", desc: "List all available templates" }, + { cmd: "--dry-run", desc: "Preview workflow without executing" }, + ], + templates: [{ cmd: "info", desc: "Show detailed template info" }], + extensions: [ + { cmd: "list", desc: "List all extensions and their status" }, + { cmd: "enable", desc: "Enable a disabled extension" }, + { cmd: "disable", desc: "Disable an extension" }, + { cmd: "info", desc: "Show extension details" }, + ], + fast: [ + { cmd: "on", desc: "Priority tier (2x cost, faster)" }, + { cmd: "off", desc: "Disable service tier" }, + { cmd: "flex", desc: "Flex tier (0.5x cost, slower)" }, + { cmd: "status", desc: "Show current service tier setting" }, + ], + mcp: [ + { cmd: "status", desc: "Show all MCP server statuses (default)" }, + { cmd: "check", desc: "Detailed status for a specific server" }, + { + cmd: "init", + desc: "Write .mcp.json for the local SF workflow MCP server", + }, + ], + doctor: [ + { cmd: "fix", desc: "Auto-fix detected issues" }, + { cmd: "heal", desc: "AI-driven deep healing" }, + { cmd: "audit", desc: "Run health audit without fixing" }, + { cmd: "--dry-run", desc: "Show what --fix would change without applying" }, + { cmd: "--json", desc: "Output report as JSON (CI/tooling friendly)" }, + { cmd: "--build", desc: "Include slow build health check (npm run build)" }, + { cmd: "--test", desc: "Include slow test health check (npm test)" }, + ], + dispatch: [ + { cmd: "research", desc: "Run research phase" }, + { cmd: "plan", desc: "Run planning phase" }, + { cmd: "execute", desc: "Run execution phase" }, + { cmd: "complete", desc: "Run completion phase" }, + { cmd: "reassess", desc: "Reassess current progress" }, + { cmd: "uat", desc: "Run user acceptance testing" }, + { cmd: "replan", desc: "Replan the current slice" }, + ], + rate: [ + { cmd: "over", desc: "Model was overqualified for this task" }, + { cmd: "ok", desc: "Model was appropriate for this task" }, + { cmd: "under", desc: "Model was underqualified for this task" }, + ], + workflow: [ + { cmd: "new", desc: "Create a new workflow definition (via skill)" }, + { cmd: "run", desc: "Create a run and start auto-mode" }, + { cmd: "list", desc: "List workflow runs" }, + { cmd: "validate", desc: "Validate a workflow definition YAML" }, + { cmd: "pause", desc: "Pause custom workflow auto-mode" }, + { cmd: "resume", desc: "Resume paused custom workflow auto-mode" }, + ], + codebase: [ + { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, + { + cmd: "generate --max-files", + desc: "Generate with custom file limit (default: 500)", + }, + { + cmd: "generate --collapse-threshold", + desc: "Generate with custom collapse threshold (default: 20)", + }, + { + cmd: "update", + desc: "Refresh the CODEBASE.md cache immediately (preserves descriptions)", + }, + { cmd: "update --max-files", desc: "Update with custom file limit" }, + { + cmd: "update --collapse-threshold", + desc: "Update with custom collapse threshold", + }, + { + cmd: "stats", + desc: "Show file count, description coverage, and generation time", + }, + { cmd: "indexer status", desc: "Show Sift codebase-indexer status" }, + { cmd: "help", desc: "Show usage and available subcommands" }, + ], + ship: [ + { cmd: "--dry-run", desc: "Preview PR without creating" }, + { cmd: "--draft", desc: "Open as draft PR" }, + { cmd: "--base", desc: "Override target branch (default: main)" }, + { cmd: "--force", desc: "Ship even with pending tasks" }, + ], + "session-report": [ + { cmd: "--json", desc: "Machine-readable JSON output" }, + { cmd: "--save", desc: "Save report to .sf/reports/" }, + ], + backlog: [ + { cmd: "add", desc: "Add item to backlog" }, + { cmd: "promote", desc: "Promote backlog item to active slice" }, + { cmd: "remove", desc: "Remove backlog item" }, + ], + schedule: [ + { cmd: "add", desc: "Add a scheduled item" }, + { cmd: "list", desc: "List scheduled items" }, + { cmd: "done", desc: "Mark item as done" }, + { cmd: "cancel", desc: "Cancel a scheduled item" }, + { cmd: "snooze", desc: "Snooze an item by duration" }, + { cmd: "run", desc: "Run a scheduled item now" }, + ], + todo: [ + { cmd: "triage", desc: "Triage root TODO.md into .sf/triage artifacts" }, + { cmd: "triage --no-clear", desc: "Triage TODO.md without resetting it" }, + { + cmd: "triage --backlog", + desc: "Also add implementation tasks to .sf/WORK-QUEUE.md", + }, + ], + "pr-branch": [ + { cmd: "--dry-run", desc: "Preview what would be filtered" }, + { cmd: "--name", desc: "Custom branch name" }, + ], + scaffold: [ + { + cmd: "sync", + desc: "Refresh ADR-021 scaffold docs (drift report + apply pending upgrades)", + }, + { + cmd: "sync --dry-run", + desc: "Print drift report without modifying files", + }, + { + cmd: "sync --include-editing", + desc: "Run scaffold-keeper synchronously for editing-drift items", + }, + { + cmd: "sync --only=", + desc: "Restrict the operation to a path glob (e.g. --only=harness/**)", + }, + ], + plan: [ + { cmd: "promote", desc: "Copy a planning artifact from ~/.sf/ into docs/" }, + { + cmd: "list", + desc: "List ~/.sf/ planning artifacts with promoted status", + }, + { cmd: "diff", desc: "Show diff between ~/.sf/ and promoted version" }, + ], }; /** * Filter and format completion options by prefix. */ function filterOptions(partial, options, prefix = "") { - const normalizedPrefix = prefix ? `${prefix} ` : ""; - return options - .filter((option) => option.cmd.startsWith(partial)) - .map((option) => ({ - value: `${normalizedPrefix}${option.cmd}`, - label: option.cmd, - description: option.desc, - })); + const normalizedPrefix = prefix ? `${prefix} ` : ""; + return options + .filter((option) => option.cmd.startsWith(partial)) + .map((option) => ({ + value: `${normalizedPrefix}${option.cmd}`, + label: option.cmd, + description: option.desc, + })); } function getExtensionCompletions(prefix, action) { - try { - const extDir = join(sfHome, "agent", "extensions"); - const ids = []; - for (const entry of readdirSync(extDir, { withFileTypes: true })) { - if (!entry.isDirectory()) - continue; - const manifestPath = join(extDir, entry.name, "extension-manifest.json"); - if (!existsSync(manifestPath)) - continue; - try { - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - if (typeof manifest?.id === "string") { - ids.push({ id: manifest.id, name: manifest.name ?? manifest.id }); - } - } - catch { - // ignore malformed manifests - } - } - return ids - .filter((entry) => entry.id.startsWith(prefix)) - .map((entry) => ({ - value: `extensions ${action} ${entry.id}`, - label: entry.id, - description: entry.name, - })); - } - catch { - return []; - } + try { + const extDir = join(sfHome, "agent", "extensions"); + const ids = []; + for (const entry of readdirSync(extDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifestPath = join(extDir, entry.name, "extension-manifest.json"); + if (!existsSync(manifestPath)) continue; + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + if (typeof manifest?.id === "string") { + ids.push({ id: manifest.id, name: manifest.name ?? manifest.id }); + } + } catch { + // ignore malformed manifests + } + } + return ids + .filter((entry) => entry.id.startsWith(prefix)) + .map((entry) => ({ + value: `extensions ${action} ${entry.id}`, + label: entry.id, + description: entry.name, + })); + } catch { + return []; + } } export function getSfArgumentCompletions(prefix) { - const hasTrailingSpace = prefix.endsWith(" "); - const parts = prefix.trim().split(/\s+/); - if (hasTrailingSpace && parts.length >= 1) { - parts.push(""); - } - if (parts.length <= 1) { - return filterOptions(parts[0] ?? "", TOP_LEVEL_SUBCOMMANDS); - } - const [command, subcommand = "", third = ""] = parts; - if (command === "cmux") { - if (parts.length <= 2) { - return filterOptions(subcommand, [ - { - cmd: "status", - desc: "Show cmux detection, prefs, and capabilities", - }, - { cmd: "on", desc: "Enable cmux integration" }, - { cmd: "off", desc: "Disable cmux integration" }, - { cmd: "notifications", desc: "Toggle cmux desktop notifications" }, - { cmd: "sidebar", desc: "Toggle cmux sidebar metadata" }, - { cmd: "splits", desc: "Toggle cmux visual subagent splits" }, - { cmd: "browser", desc: "Toggle future browser integration flag" }, - ], "cmux"); - } - if (parts.length <= 3 && - ["notifications", "sidebar", "splits", "browser"].includes(subcommand)) { - return filterOptions(third, [ - { cmd: "on", desc: "Enable this cmux area" }, - { cmd: "off", desc: "Disable this cmux area" }, - ], `cmux ${subcommand}`); - } - return []; - } - if (command === "templates" && subcommand === "info" && parts.length <= 3) { - try { - const registry = loadRegistry(); - return Object.entries(registry.templates) - .filter(([id]) => id.startsWith(third)) - .map(([id, entry]) => ({ - value: `templates info ${id}`, - label: id, - description: entry.description, - })); - } - catch { - return []; - } - } - if (command === "extensions" && - parts.length === 3 && - ["enable", "disable", "info"].includes(subcommand)) { - return getExtensionCompletions(third, subcommand); - } - if (command === "undo" && parts.length <= 2) { - return [ - { - value: "undo --force", - label: "--force", - description: "Skip confirmation prompt", - }, - ]; - } - // Workflow definition-name completion for `workflow run ` and `workflow validate ` - if (command === "workflow" && - (subcommand === "run" || subcommand === "validate") && - parts.length <= 3) { - try { - const defsDir = join(resolveProjectRoot(process.cwd()), ".sf", "workflow-defs"); - if (existsSync(defsDir)) { - return readdirSync(defsDir) - .filter((f) => f.endsWith(".yaml") && f.startsWith(third)) - .map((f) => { - const name = f.replace(/\.yaml$/, ""); - return { - value: `workflow ${subcommand} ${name}`, - label: name, - description: `Workflow definition: ${name}`, - }; - }); - } - } - catch { - // ignore filesystem errors during completion - } - return []; - } - const nested = NESTED_COMPLETIONS[command]; - if (nested && parts.length <= 2) { - return filterOptions(subcommand, nested, command); - } - return []; + const hasTrailingSpace = prefix.endsWith(" "); + const parts = prefix.trim().split(/\s+/); + if (hasTrailingSpace && parts.length >= 1) { + parts.push(""); + } + if (parts.length <= 1) { + return filterOptions(parts[0] ?? "", TOP_LEVEL_SUBCOMMANDS); + } + const [command, subcommand = "", third = ""] = parts; + if (command === "cmux") { + if (parts.length <= 2) { + return filterOptions( + subcommand, + [ + { + cmd: "status", + desc: "Show cmux detection, prefs, and capabilities", + }, + { cmd: "on", desc: "Enable cmux integration" }, + { cmd: "off", desc: "Disable cmux integration" }, + { cmd: "notifications", desc: "Toggle cmux desktop notifications" }, + { cmd: "sidebar", desc: "Toggle cmux sidebar metadata" }, + { cmd: "splits", desc: "Toggle cmux visual subagent splits" }, + { cmd: "browser", desc: "Toggle future browser integration flag" }, + ], + "cmux", + ); + } + if ( + parts.length <= 3 && + ["notifications", "sidebar", "splits", "browser"].includes(subcommand) + ) { + return filterOptions( + third, + [ + { cmd: "on", desc: "Enable this cmux area" }, + { cmd: "off", desc: "Disable this cmux area" }, + ], + `cmux ${subcommand}`, + ); + } + return []; + } + if (command === "templates" && subcommand === "info" && parts.length <= 3) { + try { + const registry = loadRegistry(); + return Object.entries(registry.templates) + .filter(([id]) => id.startsWith(third)) + .map(([id, entry]) => ({ + value: `templates info ${id}`, + label: id, + description: entry.description, + })); + } catch { + return []; + } + } + if ( + command === "extensions" && + parts.length === 3 && + ["enable", "disable", "info"].includes(subcommand) + ) { + return getExtensionCompletions(third, subcommand); + } + if (command === "undo" && parts.length <= 2) { + return [ + { + value: "undo --force", + label: "--force", + description: "Skip confirmation prompt", + }, + ]; + } + // Workflow definition-name completion for `workflow run ` and `workflow validate ` + if ( + command === "workflow" && + (subcommand === "run" || subcommand === "validate") && + parts.length <= 3 + ) { + try { + const defsDir = join( + resolveProjectRoot(process.cwd()), + ".sf", + "workflow-defs", + ); + if (existsSync(defsDir)) { + return readdirSync(defsDir) + .filter((f) => f.endsWith(".yaml") && f.startsWith(third)) + .map((f) => { + const name = f.replace(/\.yaml$/, ""); + return { + value: `workflow ${subcommand} ${name}`, + label: name, + description: `Workflow definition: ${name}`, + }; + }); + } + } catch { + // ignore filesystem errors during completion + } + return []; + } + const nested = NESTED_COMPLETIONS[command]; + if (nested && parts.length <= 2) { + return filterOptions(subcommand, nested, command); + } + return []; } diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index 98bf185e6..3a8e3d172 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -1,483 +1,568 @@ import { join } from "node:path"; import { handleCmux } from "../../commands-cmux.js"; -import { ensurePreferencesFile, handlePrefs, handlePrefsMode, handlePrefsWizard, } from "../../commands-prefs-wizard.js"; +import { + ensurePreferencesFile, + handlePrefs, + handlePrefsMode, + handlePrefsWizard, +} from "../../commands-prefs-wizard.js"; import { runEnvironmentChecks } from "../../doctor-environment.js"; -import { getGlobalSFPreferencesPath, getProjectSFPreferencesPath, } from "../../preferences.js"; -import { computeProgressScore, formatProgressLine, } from "../../progress-score.js"; +import { + getGlobalSFPreferencesPath, + getProjectSFPreferencesPath, +} from "../../preferences.js"; +import { + computeProgressScore, + formatProgressLine, +} from "../../progress-score.js"; import { setSessionModelOverride } from "../../session-model-override.js"; import { formattedShortcutPair } from "../../shortcut-defs.js"; import { deriveState } from "../../state.js"; import { projectRoot } from "../context.js"; export function showHelp(ctx, args = "") { - const summaryLines = [ - "SF — Singularity Forge\n", - "QUICK START", - " /sf start Start a workflow template", - " /sf Run next unit (same as /sf next)", - " /sf autonomous Run all queued product units continuously", - " /sf pause Pause autonomous mode", - " /sf stop Stop autonomous mode gracefully", - "", - "VISIBILITY", - ` /sf status Dashboard (${formattedShortcutPair("dashboard")})`, - ` /sf parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`, - ` /sf notifications Notification history (${formattedShortcutPair("notifications")})`, - " /sf visualize Interactive 10-tab TUI", - " /sf queue Show queued/dispatched units", - "", - "COURSE CORRECTION", - " /sf steer Apply user override to active work", - " /sf capture Quick-capture a thought to CAPTURES.md", - " /sf triage Classify and route pending captures", - " /sf undo Revert last completed unit [--force]", - " /sf rethink Conversational project reorganization", - "", - "SETUP", - " /sf init Project init wizard", - " /sf setup Global setup status [llm|search|remote|keys|prefs]", - " /sf reload Snapshot and reload agent with fresh extension code", - " /sf model Switch active session model", - " /sf prefs Manage preferences", - " /sf doctor Diagnose and repair .sf/ state", - "", - "Use /sf help full for the complete command reference.", - ]; - const fullLines = [ - "SF — Singularity Forge\n", - "WORKFLOW", - " /sf start Start a workflow template (bugfix, spike, feature, hotfix, etc.)", - " /sf templates List available workflow templates [info ]", - " /sf Run next unit in step mode (same as /sf next)", - " /sf next Execute next task, then pause [--dry-run] [--verbose]", - " /sf autonomous Run all queued product units continuously [--verbose]", - " /sf stop Stop autonomous mode gracefully", - " /sf pause Pause autonomous mode (preserves state, /sf autonomous to resume)", - " /sf discuss Start guided milestone/slice discussion", - " /sf new-milestone Create milestone from headless context (used by sf headless)", - "", - "VISIBILITY", - ` /sf status Show progress dashboard (${formattedShortcutPair("dashboard")})`, - ` /sf parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`, - " /sf visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", - " /sf queue Show queued/dispatched units and execution order", - " /sf history View execution history [--cost] [--phase] [--model] [N]", - " /sf changelog Show categorized release notes [version]", - ` /sf notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`, - "", - "COURSE CORRECTION", - " /sf steer Apply user override to active work", - " /sf capture Quick-capture a thought to CAPTURES.md", - " /sf triage Classify and route pending captures", - " /sf skip Prevent a unit from auto-mode dispatch", - " /sf undo Revert last completed unit [--force]", - " /sf rethink Conversational project reorganization — reorder, park, discard, add milestones", - " /sf park [id] Park a milestone — skip without deleting [reason]", - " /sf unpark [id] Reactivate a parked milestone", - "", - "PROJECT KNOWLEDGE", - " /sf knowledge Add rule, pattern, or lesson to KNOWLEDGE.md", - " /sf codebase [generate|update|stats|rag] Manage CODEBASE.md and optional code search", - "", - "SCHEDULE", - " /sf schedule add --in Schedule a follow-up item", - " /sf schedule list Show pending scheduled items", - " /sf schedule done <id> Mark an item complete", - "", - "SETUP & CONFIGURATION", - " /sf init Project init wizard — detect, configure, bootstrap .sf/", - " /sf setup Global setup status [llm|search|remote|keys|prefs]", - " /sf model Switch active session model [provider/model|model-id]", - " /sf mode Set workflow mode (solo/team) [global|project]", - " /sf prefs Manage preferences [global|project|status|wizard|setup|import-claude]", - " /sf cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", - " /sf config Set API keys for external tools", - " /sf keys API key manager [list|add|remove|test|rotate|doctor]", - " /sf show-config Show effective configuration (models, routing, toggles)", - " /sf hooks Show post-unit hook configuration", - " /sf extensions Manage extensions [list|enable|disable|info]", - " /sf fast Toggle OpenAI service tier [on|off|flex|status]", - " /sf mcp MCP server status and connectivity [status|check <server>|init [dir]]", - "", - "MAINTENANCE", - " /sf doctor Diagnose and repair .sf/ state [audit|fix|heal] [scope]", - " /sf reload Snapshot & reload agent, resume same session", - " /sf export Export milestone/slice results [--json|--markdown|--html] [--all]", - " /sf cleanup Remove merged branches or snapshots [branches|snapshots]", - " /sf worktree Manage worktrees from the TUI [list|merge|clean|remove]", - " /sf migrate Migrate .planning/ (v1) to .sf/ (v2) format", - " /sf remote Control remote auto-mode [slack|discord|status|disconnect]", - " /sf inspect Show SQLite DB diagnostics (schema, row counts, recent entries)", - " /sf update Update SF to the latest version via npm", - ]; - const full = ["full", "--full", "all"].includes(args.trim().toLowerCase()); - ctx.ui.notify((full ? fullLines : summaryLines).join("\n"), "info"); + const summaryLines = [ + "SF — Singularity Forge\n", + "QUICK START", + " /sf start <tpl> Start a workflow template", + " /sf Run next unit (same as /sf next)", + " /sf autonomous Run all queued product units continuously", + " /sf pause Pause autonomous mode", + " /sf stop Stop autonomous mode gracefully", + "", + "VISIBILITY", + ` /sf status Dashboard (${formattedShortcutPair("dashboard")})`, + ` /sf parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`, + ` /sf notifications Notification history (${formattedShortcutPair("notifications")})`, + " /sf visualize Interactive 10-tab TUI", + " /sf queue Show queued/dispatched units", + "", + "COURSE CORRECTION", + " /sf steer <desc> Apply user override to active work", + " /sf capture <text> Quick-capture a thought to CAPTURES.md", + " /sf triage Classify and route pending captures", + " /sf undo Revert last completed unit [--force]", + " /sf rethink Conversational project reorganization", + "", + "SETUP", + " /sf init Project init wizard", + " /sf setup Global setup status [llm|search|remote|keys|prefs]", + " /sf reload Snapshot and reload agent with fresh extension code", + " /sf model Switch active session model", + " /sf prefs Manage preferences", + " /sf doctor Diagnose and repair .sf/ state", + "", + "Use /sf help full for the complete command reference.", + ]; + const fullLines = [ + "SF — Singularity Forge\n", + "WORKFLOW", + " /sf start <tpl> Start a workflow template (bugfix, spike, feature, hotfix, etc.)", + " /sf templates List available workflow templates [info <name>]", + " /sf Run next unit in step mode (same as /sf next)", + " /sf next Execute next task, then pause [--dry-run] [--verbose]", + " /sf autonomous Run all queued product units continuously [--verbose]", + " /sf stop Stop autonomous mode gracefully", + " /sf pause Pause autonomous mode (preserves state, /sf autonomous to resume)", + " /sf discuss Start guided milestone/slice discussion", + " /sf new-milestone Create milestone from headless context (used by sf headless)", + "", + "VISIBILITY", + ` /sf status Show progress dashboard (${formattedShortcutPair("dashboard")})`, + ` /sf parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`, + " /sf visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", + " /sf queue Show queued/dispatched units and execution order", + " /sf history View execution history [--cost] [--phase] [--model] [N]", + " /sf changelog Show categorized release notes [version]", + ` /sf notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`, + "", + "COURSE CORRECTION", + " /sf steer <desc> Apply user override to active work", + " /sf capture <text> Quick-capture a thought to CAPTURES.md", + " /sf triage Classify and route pending captures", + " /sf skip <unit> Prevent a unit from auto-mode dispatch", + " /sf undo Revert last completed unit [--force]", + " /sf rethink Conversational project reorganization — reorder, park, discard, add milestones", + " /sf park [id] Park a milestone — skip without deleting [reason]", + " /sf unpark [id] Reactivate a parked milestone", + "", + "PROJECT KNOWLEDGE", + " /sf knowledge <type> <text> Add rule, pattern, or lesson to KNOWLEDGE.md", + " /sf codebase [generate|update|stats|indexer] Manage CODEBASE.md and Sift code search", + "", + "SCHEDULE", + " /sf schedule add --in <dur> <title> Schedule a follow-up item", + " /sf schedule list Show pending scheduled items", + " /sf schedule done <id> Mark an item complete", + "", + "SETUP & CONFIGURATION", + " /sf init Project init wizard — detect, configure, bootstrap .sf/", + " /sf setup Global setup status [llm|search|remote|keys|prefs]", + " /sf model Switch active session model [provider/model|model-id]", + " /sf mode Set workflow mode (solo/team) [global|project]", + " /sf prefs Manage preferences [global|project|status|wizard|setup|import-claude]", + " /sf cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", + " /sf config Set API keys for external tools", + " /sf keys API key manager [list|add|remove|test|rotate|doctor]", + " /sf show-config Show effective configuration (models, routing, toggles)", + " /sf hooks Show post-unit hook configuration", + " /sf extensions Manage extensions [list|enable|disable|info]", + " /sf fast Toggle OpenAI service tier [on|off|flex|status]", + " /sf mcp MCP server status and connectivity [status|check <server>|init [dir]]", + "", + "MAINTENANCE", + " /sf doctor Diagnose and repair .sf/ state [audit|fix|heal] [scope]", + " /sf reload Snapshot & reload agent, resume same session", + " /sf export Export milestone/slice results [--json|--markdown|--html] [--all]", + " /sf cleanup Remove merged branches or snapshots [branches|snapshots]", + " /sf worktree Manage worktrees from the TUI [list|merge|clean|remove]", + " /sf migrate Migrate .planning/ (v1) to .sf/ (v2) format", + " /sf remote Control remote auto-mode [slack|discord|status|disconnect]", + " /sf inspect Show SQLite DB diagnostics (schema, row counts, recent entries)", + " /sf update Update SF to the latest version via npm", + ]; + const full = ["full", "--full", "all"].includes(args.trim().toLowerCase()); + ctx.ui.notify((full ? fullLines : summaryLines).join("\n"), "info"); } export async function handleStatus(ctx) { - const basePath = projectRoot(); - // Open DB in cold sessions so status uses DB-backed state, not filesystem fallback (#3385) - const { ensureDbOpen } = await import("../../bootstrap/dynamic-tools.js"); - await ensureDbOpen(); - const state = await deriveState(basePath); - if (state.registry.length === 0) { - ctx.ui.notify("No SF milestones found. Run /sf to start.", "info"); - return; - } - const { SFDashboardOverlay } = await import("../../dashboard-overlay.js"); - const result = await ctx.ui.custom((tui, theme, _kb, done) => new SFDashboardOverlay(tui, theme, () => done(true)), { - overlay: true, - overlayOptions: { - width: "90%", - minWidth: 80, - maxHeight: "92%", - anchor: "center", - }, - }); - if (result === undefined) { - ctx.ui.notify(formatTextStatus(state), "info"); - } + const basePath = projectRoot(); + // Open DB in cold sessions so status uses DB-backed state, not filesystem fallback (#3385) + const { ensureDbOpen } = await import("../../bootstrap/dynamic-tools.js"); + await ensureDbOpen(); + const state = await deriveState(basePath); + if (state.registry.length === 0) { + ctx.ui.notify("No SF milestones found. Run /sf to start.", "info"); + return; + } + const { SFDashboardOverlay } = await import("../../dashboard-overlay.js"); + const result = await ctx.ui.custom( + (tui, theme, _kb, done) => + new SFDashboardOverlay(tui, theme, () => done(true)), + { + overlay: true, + overlayOptions: { + width: "90%", + minWidth: 80, + maxHeight: "92%", + anchor: "center", + }, + }, + ); + if (result === undefined) { + ctx.ui.notify(formatTextStatus(state), "info"); + } } export async function fireStatusViaCommand(ctx) { - await handleStatus(ctx); + await handleStatus(ctx); } export async function handleVisualize(ctx) { - if (!ctx.hasUI) { - ctx.ui.notify("Visualizer requires an interactive terminal.", "warning"); - return; - } - const { SFVisualizerOverlay } = await import("../../visualizer-overlay.js"); - const result = await ctx.ui.custom((tui, theme, _kb, done) => new SFVisualizerOverlay(tui, theme, () => done(true)), { - overlay: true, - overlayOptions: { - width: "80%", - minWidth: 80, - maxHeight: "90%", - anchor: "center", - }, - }); - if (result === undefined) { - ctx.ui.notify("Visualizer requires an interactive terminal. Use /sf status for a text-based overview.", "warning"); - } + if (!ctx.hasUI) { + ctx.ui.notify("Visualizer requires an interactive terminal.", "warning"); + return; + } + const { SFVisualizerOverlay } = await import("../../visualizer-overlay.js"); + const result = await ctx.ui.custom( + (tui, theme, _kb, done) => + new SFVisualizerOverlay(tui, theme, () => done(true)), + { + overlay: true, + overlayOptions: { + width: "80%", + minWidth: 80, + maxHeight: "90%", + anchor: "center", + }, + }, + ); + if (result === undefined) { + ctx.ui.notify( + "Visualizer requires an interactive terminal. Use /sf status for a text-based overview.", + "warning", + ); + } } export async function handleSetup(args, ctx) { - const { detectProjectState, hasGlobalSetup } = await import("../../detection.js"); - const globalConfigured = hasGlobalSetup(); - const detection = detectProjectState(projectRoot()); - const statusLines = ["SF Setup Status\n"]; - statusLines.push(` Global preferences: ${globalConfigured ? "configured" : "not set"}`); - statusLines.push(` Project state: ${detection.state}`); - if (detection.projectSignals.primaryLanguage) { - statusLines.push(` Detected: ${detection.projectSignals.primaryLanguage}`); - } - if (args === "llm" || args === "auth") { - ctx.ui.notify("Use /login to configure LLM authentication.", "info"); - return; - } - if (args === "search") { - ctx.ui.notify("Use /search-provider to configure web search.", "info"); - return; - } - if (args === "remote") { - ctx.ui.notify("Use /sf remote to configure remote questions.", "info"); - return; - } - if (args === "keys") { - const { handleKeys } = await import("../../key-manager.js"); - await handleKeys("", ctx); - return; - } - if (args === "prefs") { - await ensurePreferencesFile(getGlobalSFPreferencesPath(), ctx, "global"); - await handlePrefsWizard(ctx, "global"); - return; - } - ctx.ui.notify(statusLines.join("\n"), "info"); - ctx.ui.notify("Available setup commands:\n" + - " /sf setup llm — LLM authentication\n" + - " /sf setup search — Web search provider\n" + - " /sf setup remote — Remote questions (Discord/Slack/Telegram)\n" + - " /sf setup keys — Tool API keys\n" + - " /sf setup prefs — Global preferences wizard", "info"); + const { detectProjectState, hasGlobalSetup } = await import( + "../../detection.js" + ); + const globalConfigured = hasGlobalSetup(); + const detection = detectProjectState(projectRoot()); + const statusLines = ["SF Setup Status\n"]; + statusLines.push( + ` Global preferences: ${globalConfigured ? "configured" : "not set"}`, + ); + statusLines.push(` Project state: ${detection.state}`); + if (detection.projectSignals.primaryLanguage) { + statusLines.push(` Detected: ${detection.projectSignals.primaryLanguage}`); + } + if (args === "llm" || args === "auth") { + ctx.ui.notify("Use /login to configure LLM authentication.", "info"); + return; + } + if (args === "search") { + ctx.ui.notify("Use /search-provider to configure web search.", "info"); + return; + } + if (args === "remote") { + ctx.ui.notify("Use /sf remote to configure remote questions.", "info"); + return; + } + if (args === "keys") { + const { handleKeys } = await import("../../key-manager.js"); + await handleKeys("", ctx); + return; + } + if (args === "prefs") { + await ensurePreferencesFile(getGlobalSFPreferencesPath(), ctx, "global"); + await handlePrefsWizard(ctx, "global"); + return; + } + ctx.ui.notify(statusLines.join("\n"), "info"); + ctx.ui.notify( + "Available setup commands:\n" + + " /sf setup llm — LLM authentication\n" + + " /sf setup search — Web search provider\n" + + " /sf setup remote — Remote questions (Discord/Slack/Telegram)\n" + + " /sf setup keys — Tool API keys\n" + + " /sf setup prefs — Global preferences wizard", + "info", + ); } function sortModelsForSelection(models, currentModel) { - return [...models].sort((a, b) => { - const aCurrent = currentModel && - a.provider === currentModel.provider && - a.id === currentModel.id; - const bCurrent = currentModel && - b.provider === currentModel.provider && - b.id === currentModel.id; - if (aCurrent && !bCurrent) - return -1; - if (!aCurrent && bCurrent) - return 1; - const providerCmp = a.provider.localeCompare(b.provider); - if (providerCmp !== 0) - return providerCmp; - return a.id.localeCompare(b.id); - }); + return [...models].sort((a, b) => { + const aCurrent = + currentModel && + a.provider === currentModel.provider && + a.id === currentModel.id; + const bCurrent = + currentModel && + b.provider === currentModel.provider && + b.id === currentModel.id; + if (aCurrent && !bCurrent) return -1; + if (!aCurrent && bCurrent) return 1; + const providerCmp = a.provider.localeCompare(b.provider); + if (providerCmp !== 0) return providerCmp; + return a.id.localeCompare(b.id); + }); } function buildProviderModelGroups(models, currentModel) { - const byProvider = new Map(); - for (const model of sortModelsForSelection(models, currentModel)) { - let group = byProvider.get(model.provider); - if (!group) { - group = []; - byProvider.set(model.provider, group); - } - group.push(model); - } - return byProvider; + const byProvider = new Map(); + for (const model of sortModelsForSelection(models, currentModel)) { + let group = byProvider.get(model.provider); + if (!group) { + group = []; + byProvider.set(model.provider, group); + } + group.push(model); + } + return byProvider; } async function selectModelByProvider(title, models, ctx, currentModel) { - const byProvider = buildProviderModelGroups(models, currentModel); - const providerOptions = Array.from(byProvider.entries()).map(([provider, group]) => `${provider} (${group.length} model${group.length === 1 ? "" : "s"})`); - providerOptions.push("(cancel)"); - const providerChoice = await ctx.ui.select(`${title} — choose provider:`, providerOptions); - if (!providerChoice || - typeof providerChoice !== "string" || - providerChoice === "(cancel)") - return undefined; - const providerName = providerChoice.replace(/ \(\d+ models?\)$/, ""); - const providerModels = byProvider.get(providerName); - if (!providerModels || providerModels.length === 0) - return undefined; - const optionToModel = new Map(); - const modelOptions = providerModels.map((model) => { - const isCurrent = currentModel && - model.provider === currentModel.provider && - model.id === currentModel.id; - const label = `${isCurrent ? "* " : ""}${model.id}`; - optionToModel.set(label, model); - return label; - }); - modelOptions.push("(cancel)"); - const modelChoice = await ctx.ui.select(`${title} — ${providerName}:`, modelOptions); - if (!modelChoice || - typeof modelChoice !== "string" || - modelChoice === "(cancel)") - return undefined; - return optionToModel.get(modelChoice); + const byProvider = buildProviderModelGroups(models, currentModel); + const providerOptions = Array.from(byProvider.entries()).map( + ([provider, group]) => + `${provider} (${group.length} model${group.length === 1 ? "" : "s"})`, + ); + providerOptions.push("(cancel)"); + const providerChoice = await ctx.ui.select( + `${title} — choose provider:`, + providerOptions, + ); + if ( + !providerChoice || + typeof providerChoice !== "string" || + providerChoice === "(cancel)" + ) + return undefined; + const providerName = providerChoice.replace(/ \(\d+ models?\)$/, ""); + const providerModels = byProvider.get(providerName); + if (!providerModels || providerModels.length === 0) return undefined; + const optionToModel = new Map(); + const modelOptions = providerModels.map((model) => { + const isCurrent = + currentModel && + model.provider === currentModel.provider && + model.id === currentModel.id; + const label = `${isCurrent ? "* " : ""}${model.id}`; + optionToModel.set(label, model); + return label; + }); + modelOptions.push("(cancel)"); + const modelChoice = await ctx.ui.select( + `${title} — ${providerName}:`, + modelOptions, + ); + if ( + !modelChoice || + typeof modelChoice !== "string" || + modelChoice === "(cancel)" + ) + return undefined; + return optionToModel.get(modelChoice); } async function resolveRequestedModel(query, ctx) { - const { resolveModelId } = await import("../../auto-model-selection.js"); - const models = ctx.modelRegistry.getAvailable(); - const exact = resolveModelId(query, models, ctx.model?.provider); - if (exact) - return exact; - const lowerQuery = query.toLowerCase(); - const partialMatches = models.filter((model) => model.id.toLowerCase().includes(lowerQuery) || - `${model.provider}/${model.id}`.toLowerCase().includes(lowerQuery)); - if (partialMatches.length === 1) - return partialMatches[0]; - if (partialMatches.length === 0 || !ctx.hasUI) - return undefined; - return selectModelByProvider(`Multiple models match "${query}"`, partialMatches, ctx, ctx.model); + const { resolveModelId } = await import("../../auto-model-selection.js"); + const models = ctx.modelRegistry.getAvailable(); + const exact = resolveModelId(query, models, ctx.model?.provider); + if (exact) return exact; + const lowerQuery = query.toLowerCase(); + const partialMatches = models.filter( + (model) => + model.id.toLowerCase().includes(lowerQuery) || + `${model.provider}/${model.id}`.toLowerCase().includes(lowerQuery), + ); + if (partialMatches.length === 1) return partialMatches[0]; + if (partialMatches.length === 0 || !ctx.hasUI) return undefined; + return selectModelByProvider( + `Multiple models match "${query}"`, + partialMatches, + ctx, + ctx.model, + ); } async function handleModel(trimmedArgs, ctx, pi) { - const availableModels = ctx.modelRegistry.getAvailable(); - if (availableModels.length === 0) { - ctx.ui.notify("No available models found. Check provider auth and model discovery.", "warning"); - return; - } - if (!pi) { - ctx.ui.notify("Model switching is unavailable in this context.", "warning"); - return; - } - const trimmed = trimmedArgs.trim(); - let targetModel; - if (!trimmed) { - if (!ctx.hasUI) { - const current = ctx.model - ? `${ctx.model.provider}/${ctx.model.id}` - : "(none)"; - ctx.ui.notify(`Current model: ${current}\nUsage: /sf model <provider/model|model-id>`, "info"); - return; - } - targetModel = await selectModelByProvider("Select session model:", availableModels, ctx, ctx.model); - } - else { - targetModel = await resolveRequestedModel(trimmed, ctx); - } - if (!targetModel) { - ctx.ui.notify(`Model "${trimmed}" not found. Use /sf model with an exact provider/model or a unique model ID.`, "warning"); - return; - } - const ok = await pi.setModel(targetModel); - if (!ok) { - ctx.ui.notify(`No API key for ${targetModel.provider}/${targetModel.id}`, "warning"); - return; - } - // /sf model is an explicit per-session pin for SF dispatches. - // This is captured at auto bootstrap so it survives internal session - // switches during /sf auto and /sf next runs. - const sessionId = ctx.sessionManager?.getSessionId?.(); - if (sessionId) { - setSessionModelOverride(sessionId, { - provider: targetModel.provider, - id: targetModel.id, - }); - } - ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info"); + const availableModels = ctx.modelRegistry.getAvailable(); + if (availableModels.length === 0) { + ctx.ui.notify( + "No available models found. Check provider auth and model discovery.", + "warning", + ); + return; + } + if (!pi) { + ctx.ui.notify("Model switching is unavailable in this context.", "warning"); + return; + } + const trimmed = trimmedArgs.trim(); + let targetModel; + if (!trimmed) { + if (!ctx.hasUI) { + const current = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}` + : "(none)"; + ctx.ui.notify( + `Current model: ${current}\nUsage: /sf model <provider/model|model-id>`, + "info", + ); + return; + } + targetModel = await selectModelByProvider( + "Select session model:", + availableModels, + ctx, + ctx.model, + ); + } else { + targetModel = await resolveRequestedModel(trimmed, ctx); + } + if (!targetModel) { + ctx.ui.notify( + `Model "${trimmed}" not found. Use /sf model with an exact provider/model or a unique model ID.`, + "warning", + ); + return; + } + const ok = await pi.setModel(targetModel); + if (!ok) { + ctx.ui.notify( + `No API key for ${targetModel.provider}/${targetModel.id}`, + "warning", + ); + return; + } + // /sf model is an explicit per-session pin for SF dispatches. + // This is captured at auto bootstrap so it survives internal session + // switches during /sf auto and /sf next runs. + const sessionId = ctx.sessionManager?.getSessionId?.(); + if (sessionId) { + setSessionModelOverride(sessionId, { + provider: targetModel.provider, + id: targetModel.id, + }); + } + ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info"); } export async function handleCoreCommand(trimmed, ctx, pi) { - if (trimmed === "help" || - trimmed === "h" || - trimmed === "?" || - trimmed.startsWith("help ")) { - showHelp(ctx, trimmed.startsWith("help ") ? trimmed.slice(5).trim() : ""); - return true; - } - if (trimmed === "status") { - await handleStatus(ctx); - return true; - } - if (trimmed === "visualize") { - await handleVisualize(ctx); - return true; - } - if (trimmed === "widget" || trimmed.startsWith("widget ")) { - const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await import("../../auto-dashboard.js"); - const arg = trimmed.replace(/^widget\s*/, "").trim(); - if (arg === "full" || arg === "small" || arg === "min" || arg === "off") { - setWidgetMode(arg); - } - else { - cycleWidgetMode(); - } - ctx.ui.notify(`Widget: ${getWidgetMode()}`, "info"); - return true; - } - if (trimmed === "model" || trimmed.startsWith("model ")) { - await handleModel(trimmed.replace(/^model\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "mode" || trimmed.startsWith("mode ")) { - const modeArgs = trimmed.replace(/^mode\s*/, "").trim(); - const scope = modeArgs === "project" ? "project" : "global"; - const path = scope === "project" - ? getProjectSFPreferencesPath() - : getGlobalSFPreferencesPath(); - await ensurePreferencesFile(path, ctx, scope); - await handlePrefsMode(ctx, scope); - return true; - } - if (trimmed === "prefs" || trimmed.startsWith("prefs ")) { - await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "cmux" || trimmed.startsWith("cmux ")) { - await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "show-config") { - const { SFConfigOverlay, formatConfigText } = await import("../../config-overlay.js"); - const result = await ctx.ui.custom((tui, theme, _kb, done) => new SFConfigOverlay(tui, theme, () => done(true)), { - overlay: true, - overlayOptions: { - width: "65%", - minWidth: 55, - maxHeight: "85%", - anchor: "center", - }, - }); - if (result === undefined) { - ctx.ui.notify(formatConfigText(), "info"); - } - return true; - } - if (trimmed === "setup" || trimmed.startsWith("setup ")) { - await handleSetup(trimmed.replace(/^setup\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "reload") { - if (process.env.SF_HEADLESS !== "1") { - ctx.ui.notify("Reloading extensions, skills, prompts, and themes...", "info"); - await ctx.reload(); - ctx.ui.notify("Reloaded extensions, skills, prompts, and themes.", "info"); - return true; - } - ctx.ui.notify("Reloading agent with fresh extension code — session will be resumed...", "info"); - const tmpDir = process.env.TEMP ?? "/tmp"; - const sessionIdFile = join(tmpDir, "sf-current-session"); - const sentinelFile = join(tmpDir, "sf-reload-sentinel"); - const { existsSync, readFileSync, unlinkSync, writeFileSync } = await import("node:fs"); - if (existsSync(sessionIdFile)) { - try { - const sessionId = readFileSync(sessionIdFile, "utf-8").trim(); - if (sessionId) { - writeFileSync(sentinelFile, sessionId, "utf-8"); - } - } - catch { - /* non-fatal */ - } - try { - unlinkSync(sessionIdFile); - } - catch { - /* non-fatal */ - } - } - // EXIT_RELOAD = 12 — same as kill_agent - const EXIT_RELOAD = 12; // must match EXIT_RELOAD in src/headless-events.ts - process.exit(EXIT_RELOAD); - return true; - } - return false; + if ( + trimmed === "help" || + trimmed === "h" || + trimmed === "?" || + trimmed.startsWith("help ") + ) { + showHelp(ctx, trimmed.startsWith("help ") ? trimmed.slice(5).trim() : ""); + return true; + } + if (trimmed === "status") { + await handleStatus(ctx); + return true; + } + if (trimmed === "visualize") { + await handleVisualize(ctx); + return true; + } + if (trimmed === "widget" || trimmed.startsWith("widget ")) { + const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await import( + "../../auto-dashboard.js" + ); + const arg = trimmed.replace(/^widget\s*/, "").trim(); + if (arg === "full" || arg === "small" || arg === "min" || arg === "off") { + setWidgetMode(arg); + } else { + cycleWidgetMode(); + } + ctx.ui.notify(`Widget: ${getWidgetMode()}`, "info"); + return true; + } + if (trimmed === "model" || trimmed.startsWith("model ")) { + await handleModel(trimmed.replace(/^model\s*/, "").trim(), ctx, pi); + return true; + } + if (trimmed === "mode" || trimmed.startsWith("mode ")) { + const modeArgs = trimmed.replace(/^mode\s*/, "").trim(); + const scope = modeArgs === "project" ? "project" : "global"; + const path = + scope === "project" + ? getProjectSFPreferencesPath() + : getGlobalSFPreferencesPath(); + await ensurePreferencesFile(path, ctx, scope); + await handlePrefsMode(ctx, scope); + return true; + } + if (trimmed === "prefs" || trimmed.startsWith("prefs ")) { + await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); + return true; + } + if (trimmed === "cmux" || trimmed.startsWith("cmux ")) { + await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx); + return true; + } + if (trimmed === "show-config") { + const { SFConfigOverlay, formatConfigText } = await import( + "../../config-overlay.js" + ); + const result = await ctx.ui.custom( + (tui, theme, _kb, done) => + new SFConfigOverlay(tui, theme, () => done(true)), + { + overlay: true, + overlayOptions: { + width: "65%", + minWidth: 55, + maxHeight: "85%", + anchor: "center", + }, + }, + ); + if (result === undefined) { + ctx.ui.notify(formatConfigText(), "info"); + } + return true; + } + if (trimmed === "setup" || trimmed.startsWith("setup ")) { + await handleSetup(trimmed.replace(/^setup\s*/, "").trim(), ctx); + return true; + } + if (trimmed === "reload") { + if (process.env.SF_HEADLESS !== "1") { + ctx.ui.notify( + "Reloading extensions, skills, prompts, and themes...", + "info", + ); + await ctx.reload(); + ctx.ui.notify( + "Reloaded extensions, skills, prompts, and themes.", + "info", + ); + return true; + } + ctx.ui.notify( + "Reloading agent with fresh extension code — session will be resumed...", + "info", + ); + const tmpDir = process.env.TEMP ?? "/tmp"; + const sessionIdFile = join(tmpDir, "sf-current-session"); + const sentinelFile = join(tmpDir, "sf-reload-sentinel"); + const { existsSync, readFileSync, unlinkSync, writeFileSync } = + await import("node:fs"); + if (existsSync(sessionIdFile)) { + try { + const sessionId = readFileSync(sessionIdFile, "utf-8").trim(); + if (sessionId) { + writeFileSync(sentinelFile, sessionId, "utf-8"); + } + } catch { + /* non-fatal */ + } + try { + unlinkSync(sessionIdFile); + } catch { + /* non-fatal */ + } + } + // EXIT_RELOAD = 12 — same as kill_agent + const EXIT_RELOAD = 12; // must match EXIT_RELOAD in src/headless-events.ts + process.exit(EXIT_RELOAD); + return true; + } + return false; } export function formatTextStatus(state) { - const lines = ["SF Status\n"]; - lines.push(formatProgressLine(computeProgressScore())); - lines.push(""); - lines.push(`Phase: ${state.phase}`); - if (state.activeMilestone) { - lines.push(`Active milestone: ${state.activeMilestone.id} — ${state.activeMilestone.title}`); - } - if (state.activeSlice) { - lines.push(`Active slice: ${state.activeSlice.id} — ${state.activeSlice.title}`); - } - if (state.activeTask) { - lines.push(`Active task: ${state.activeTask.id} — ${state.activeTask.title}`); - } - if (state.progress) { - const { milestones, slices, tasks } = state.progress; - const parts = [ - `milestones ${milestones.done}/${milestones.total}`, - ]; - if (slices) - parts.push(`slices ${slices.done}/${slices.total}`); - if (tasks) - parts.push(`tasks ${tasks.done}/${tasks.total}`); - lines.push(`Progress: ${parts.join(", ")}`); - } - if (state.nextAction) { - lines.push(`Next: ${state.nextAction}`); - } - if (state.blockers.length > 0) { - lines.push(`Blockers: ${state.blockers.join("; ")}`); - } - if (state.registry.length > 0) { - lines.push(""); - lines.push("Milestones:"); - for (const milestone of state.registry) { - const icon = milestone.status === "complete" - ? "✓" - : milestone.status === "active" - ? "▶" - : milestone.status === "parked" - ? "⏸" - : "○"; - lines.push(` ${icon} ${milestone.id}: ${milestone.title} (${milestone.status})`); - } - } - const envResults = runEnvironmentChecks(projectRoot()); - const envIssues = envResults.filter((result) => result.status !== "ok"); - if (envIssues.length > 0) { - lines.push(""); - lines.push("Environment:"); - for (const issue of envIssues) { - lines.push(` ${issue.status === "error" ? "✗" : "⚠"} ${issue.message}`); - } - } - return lines.join("\n"); + const lines = ["SF Status\n"]; + lines.push(formatProgressLine(computeProgressScore())); + lines.push(""); + lines.push(`Phase: ${state.phase}`); + if (state.activeMilestone) { + lines.push( + `Active milestone: ${state.activeMilestone.id} — ${state.activeMilestone.title}`, + ); + } + if (state.activeSlice) { + lines.push( + `Active slice: ${state.activeSlice.id} — ${state.activeSlice.title}`, + ); + } + if (state.activeTask) { + lines.push( + `Active task: ${state.activeTask.id} — ${state.activeTask.title}`, + ); + } + if (state.progress) { + const { milestones, slices, tasks } = state.progress; + const parts = [`milestones ${milestones.done}/${milestones.total}`]; + if (slices) parts.push(`slices ${slices.done}/${slices.total}`); + if (tasks) parts.push(`tasks ${tasks.done}/${tasks.total}`); + lines.push(`Progress: ${parts.join(", ")}`); + } + if (state.nextAction) { + lines.push(`Next: ${state.nextAction}`); + } + if (state.blockers.length > 0) { + lines.push(`Blockers: ${state.blockers.join("; ")}`); + } + if (state.registry.length > 0) { + lines.push(""); + lines.push("Milestones:"); + for (const milestone of state.registry) { + const icon = + milestone.status === "complete" + ? "✓" + : milestone.status === "active" + ? "▶" + : milestone.status === "parked" + ? "⏸" + : "○"; + lines.push( + ` ${icon} ${milestone.id}: ${milestone.title} (${milestone.status})`, + ); + } + } + const envResults = runEnvironmentChecks(projectRoot()); + const envIssues = envResults.filter((result) => result.status !== "ok"); + if (envIssues.length > 0) { + lines.push(""); + lines.push("Environment:"); + for (const issue of envIssues) { + lines.push(` ${issue.status === "error" ? "✗" : "⚠"} ${issue.message}`); + } + } + return lines.join("\n"); } diff --git a/src/resources/extensions/sf/docs/preferences-reference.md b/src/resources/extensions/sf/docs/preferences-reference.md index 3f6264a04..f95e457ac 100644 --- a/src/resources/extensions/sf/docs/preferences-reference.md +++ b/src/resources/extensions/sf/docs/preferences-reference.md @@ -171,18 +171,12 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `skip_reassess`: boolean — force-disable roadmap reassessment even if `reassess_after_slice` is enabled. Default: `false`. - `skip_slice_research`: boolean — skip per-slice research. Default: `false`. -- `codebase`: configures `.sf/CODEBASE.md` and the optional code-intelligence backend. Keys: +- `codebase`: configures fallback `.sf/CODEBASE.md` and the optional Sift code-intelligence backend. Keys: - `exclude_patterns`: string[] — extra file or directory patterns to omit from CODEBASE.md. - `max_files`: number — maximum files to include in CODEBASE.md. Default: `500`. - `collapse_threshold`: number — files-per-directory threshold before collapsing a directory summary. Default: `20`. - - `indexer_backend`: `"projectRag"`, `"sift"`, or `"none"` — codebase-indexer backend used for prompt guidance and `/sf codebase indexer status`. Default: use Sift when it is on `PATH`; set `projectRag` explicitly to use the MCP RAG backend. - - `project_rag`: `"auto"`, `"off"`, or `"required"` — use Brainwires/project-rag MCP search when configured. Default: `"auto"`. - - `project_rag_server`: string — explicit MCP server name when the server cannot be detected from command or args. - - `project_rag_auto_index`: boolean — whether agents should prefer indexing before querying a configured Project RAG backend. Default: `true`. - - `/sf codebase indexer status` reports the selected backend status. For `sift`, install `rupurt/sift` on `PATH` or set `SIFT_PATH`. - - `/sf codebase rag status` reports whether the Rust backend is actually operational. - - `/sf codebase rag init` writes a `.mcp.json` entry when a `project-rag` binary is available. - - `/sf codebase rag build` builds vendored Brainwires/project-rag from `vendor/project-rag` (or `SF_PROJECT_RAG_SOURCE`) with `cargo build --release`, then writes the MCP config. The build defaults to `CARGO_BUILD_JOBS=2` so it does not saturate the workstation; override with `SF_PROJECT_RAG_BUILD_JOBS`. + - `indexer_backend`: `"sift"` or `"none"` — codebase-indexer backend used for prompt guidance and `/sf codebase indexer status`. Default: `"sift"`. + - `/sf codebase indexer status` reports Sift status. Install `rupurt/sift` on `PATH` or set `SIFT_PATH`. - `remote_questions`: route interactive questions to Slack/Discord for headless auto-mode. Keys: - `channel`: `"slack"` or `"discord"` — channel type. diff --git a/src/resources/extensions/sf/git-constants.js b/src/resources/extensions/sf/git-constants.js index 0f194b98a..70dbd0863 100644 --- a/src/resources/extensions/sf/git-constants.js +++ b/src/resources/extensions/sf/git-constants.js @@ -2,13 +2,17 @@ * Shared git constants used across git-service and native-git-bridge. */ /** - * Environment overlay suppressing interactive git prompts and git-svn noise. - * Set GIT_TERMINAL_PROMPT=0 to disable credential prompt, LC_ALL=C for English output. + * Environment overlay suppressing interactive git prompts, editors, and git-svn noise. + * Set LC_ALL=C for English output so stderr string checks work across locales. */ export const GIT_NO_PROMPT_ENV = { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - GIT_ASKPASS: "", - GIT_SVN_ID: "", - LC_ALL: "C", // force English git output so stderr string checks work on all locales (#1997) + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_ASKPASS: "", + VISUAL: "true", + EDITOR: "true", + GIT_SVN_ID: "", + LC_ALL: "C", // force English git output so stderr string checks work on all locales (#1997) }; diff --git a/src/resources/extensions/sf/preferences-validation.js b/src/resources/extensions/sf/preferences-validation.js index b7d467e63..c6c655ad7 100644 --- a/src/resources/extensions/sf/preferences-validation.js +++ b/src/resources/extensions/sf/preferences-validation.js @@ -7,1848 +7,1889 @@ */ import { normalizeStringArray } from "../shared/format-utils.js"; import { VALID_BRANCH_NAME } from "./git-service.js"; -import { checkPreferencesDrift, CURRENT_PREFERENCES_SCHEMA_VERSION, migrateForward, } from "./preferences-migrations.js"; -import { KNOWN_PREFERENCE_KEYS, KNOWN_UNIT_TYPES, SKILL_ACTIONS, } from "./preferences-types.js"; +import { + CURRENT_PREFERENCES_SCHEMA_VERSION, + checkPreferencesDrift, + migrateForward, +} from "./preferences-migrations.js"; +import { + KNOWN_PREFERENCE_KEYS, + KNOWN_UNIT_TYPES, + SKILL_ACTIONS, +} from "./preferences-types.js"; + const VALID_TOKEN_PROFILES = new Set([ - "budget", - "balanced", - "quality", - "burn-max", -]); -const VALID_UOK_TURN_ACTIONS = new Set([ - "commit", - "snapshot", - "status-only", + "budget", + "balanced", + "quality", + "burn-max", ]); +const VALID_UOK_TURN_ACTIONS = new Set(["commit", "snapshot", "status-only"]); export function validatePreferences(preferences) { - const errors = []; - const warnings = []; - const validated = {}; - // Schema version: report drift, then migrate forward. Errors from a - // malformed migration chain bubble up so the caller can surface "your - // prefs need attention" instead of silently dropping fields. Field - // checks below run against the migrated copy. - for (const w of checkPreferencesDrift(preferences).warnings) - warnings.push(w); - let migrated = preferences; - try { - const outcome = migrateForward(preferences); - migrated = outcome.preferences; - if (outcome.applied.length > 0) { - warnings.push(`migrated prefs forward: ${outcome.applied - .map((m) => `v${m.from}→v${m.to} (${m.description})`) - .join("; ")}`); - } - } - catch (err) { - errors.push(err instanceof Error - ? `prefs migration failed: ${err.message}` - : `prefs migration failed: ${String(err)}`); - } - preferences = migrated; - // ─── Unknown Key Detection ────────────────────────────────────────── - // Common key migration hints for pi-level settings that don't map to SF prefs - const KEY_MIGRATION_HINTS = { - taskIsolation: 'use "git.isolation" instead (values: worktree, branch, none)', - task_isolation: 'use "git.isolation" instead (values: worktree, branch, none)', - isolation: 'use "git.isolation" instead (values: worktree, branch, none)', - manage_gitignore: 'use "git.manage_gitignore" instead', - auto_push: 'use "git.auto_push" instead', - main_branch: 'use "git.main_branch" instead', - }; - for (const key of Object.keys(preferences)) { - if (!KNOWN_PREFERENCE_KEYS.has(key)) { - const hint = KEY_MIGRATION_HINTS[key]; - if (hint) { - warnings.push(`unknown preference key "${key}" — ${hint}`); - } - else { - warnings.push(`unknown preference key "${key}" — ignored`); - } - } - } - if (preferences.version !== undefined) { - if (preferences.version === CURRENT_PREFERENCES_SCHEMA_VERSION) { - validated.version = CURRENT_PREFERENCES_SCHEMA_VERSION; - } - else if (preferences.version > CURRENT_PREFERENCES_SCHEMA_VERSION) { - // Already warned via checkPreferencesDrift; preserve so a later - // sf upgrade reads correctly without rewriting the file. - validated.version = preferences.version; - } - else { - // Should be unreachable: migrateForward stamps the current version - // or throws. Defend against a future bug instead of silently dropping. - errors.push(`unsupported version ${preferences.version} (migration chain ` + - `should have produced v${CURRENT_PREFERENCES_SCHEMA_VERSION})`); - } - } - // ─── Workflow Mode ────────────────────────────────────────────────── - if (preferences.mode !== undefined) { - const validModes = new Set(["solo", "team"]); - if (typeof preferences.mode === "string" && - validModes.has(preferences.mode)) { - validated.mode = preferences.mode; - } - else { - errors.push(`invalid mode "${preferences.mode}" — must be one of: solo, team`); - } - } - const validDiscoveryModes = new Set(["auto", "suggest", "off"]); - if (preferences.skill_discovery) { - if (validDiscoveryModes.has(preferences.skill_discovery)) { - validated.skill_discovery = preferences.skill_discovery; - } - else { - errors.push(`invalid skill_discovery value: ${preferences.skill_discovery}`); - } - } - if (preferences.skill_staleness_days !== undefined) { - const days = Number(preferences.skill_staleness_days); - if (Number.isFinite(days) && days >= 0) { - validated.skill_staleness_days = Math.floor(days); - } - else { - errors.push(`invalid skill_staleness_days: must be a non-negative number`); - } - } - validated.always_use_skills = normalizeStringArray(preferences.always_use_skills); - validated.prefer_skills = normalizeStringArray(preferences.prefer_skills); - validated.avoid_skills = normalizeStringArray(preferences.avoid_skills); - validated.custom_instructions = normalizeStringArray(preferences.custom_instructions); - if (preferences.skill_rules) { - const validRules = []; - 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 = { when }; - for (const action of SKILL_ACTIONS) { - const values = normalizeStringArray(rule[action]); - if (values.length > 0) { - validatedRule[action] = values; - } - } - 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", - ]) { - 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.persist_model_changes !== undefined) { - if (typeof preferences.persist_model_changes === "boolean") { - validated.persist_model_changes = preferences.persist_model_changes; - } - else { - errors.push("persist_model_changes must be a boolean"); - } - } - 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`); - } - } - // ─── UOK Flags ────────────────────────────────────────────────────── - if (preferences.uok !== undefined) { - if (typeof preferences.uok === "object" && preferences.uok !== null) { - const raw = preferences.uok; - const valid = {}; - if (raw.enabled !== undefined) { - if (typeof raw.enabled === "boolean") - valid.enabled = raw.enabled; - else - errors.push("uok.enabled must be a boolean"); - } - const parseEnabledBlock = (key, targetKey) => { - const normalizedTargetKey = targetKey ?? (key === "plan_v2" ? "planning_flow" : key); - const value = raw[key]; - if (value === undefined) - return; - if (typeof value !== "object" || value === null) { - errors.push(`uok.${key} must be an object`); - return; - } - const block = value; - const parsed = {}; - if (block.enabled !== undefined) { - if (typeof block.enabled === "boolean") - parsed.enabled = block.enabled; - else - errors.push(`uok.${key}.enabled must be a boolean`); - } - const unknown = Object.keys(block).filter((k) => k !== "enabled"); - for (const unk of unknown) { - warnings.push(`unknown uok.${key} key "${unk}" — ignored`); - } - if (Object.keys(parsed).length > 0) { - valid[normalizedTargetKey] = parsed; - } - }; - parseEnabledBlock("legacy_fallback"); - parseEnabledBlock("gates"); - parseEnabledBlock("model_policy"); - parseEnabledBlock("execution_graph"); - parseEnabledBlock("audit_envelope"); - if (raw.audit_unified !== undefined && raw.audit_envelope === undefined) { - warnings.push("uok.audit_unified is deprecated; use uok.audit_envelope"); - parseEnabledBlock("audit_unified", "audit_envelope"); - } - parseEnabledBlock("planning_flow"); - if (raw.plan_v2 !== undefined && raw.planning_flow === undefined) { - warnings.push("uok.plan_v2 is deprecated; use uok.planning_flow"); - parseEnabledBlock("plan_v2", "planning_flow"); - } - if (raw.gitops !== undefined) { - if (typeof raw.gitops !== "object" || raw.gitops === null) { - errors.push("uok.gitops must be an object"); - } - else { - const gitops = raw.gitops; - const parsed = {}; - if (gitops.enabled !== undefined) { - if (typeof gitops.enabled === "boolean") - parsed.enabled = gitops.enabled; - else - errors.push("uok.gitops.enabled must be a boolean"); - } - if (gitops.turn_action !== undefined) { - if (typeof gitops.turn_action === "string" && - VALID_UOK_TURN_ACTIONS.has(gitops.turn_action)) { - parsed.turn_action = gitops.turn_action; - } - else { - errors.push("uok.gitops.turn_action must be one of: commit, snapshot, status-only"); - } - } - if (gitops.turn_push !== undefined) { - if (typeof gitops.turn_push === "boolean") - parsed.turn_push = gitops.turn_push; - else - errors.push("uok.gitops.turn_push must be a boolean"); - } - const unknown = Object.keys(gitops).filter((k) => !["enabled", "turn_action", "turn_push"].includes(k)); - for (const unk of unknown) { - warnings.push(`unknown uok.gitops key "${unk}" — ignored`); - } - if (Object.keys(parsed).length > 0) { - valid.gitops = parsed; - } - } - } - const knownUokKeys = new Set([ - "enabled", - "legacy_fallback", - "gates", - "model_policy", - "execution_graph", - "gitops", - "audit_envelope", - "audit_unified", - "planning_flow", - "plan_v2", - ]); - for (const key of Object.keys(raw)) { - if (!knownUokKeys.has(key)) { - warnings.push(`unknown uok key "${key}" — ignored`); - } - } - if (Object.keys(valid).length > 0) { - validated.uok = valid; - } - } - else { - errors.push("uok must be an object"); - } - } - // ─── Token Profile ───────────────────────────────────────────────── - if (preferences.token_profile !== undefined) { - if (typeof preferences.token_profile === "string" && - VALID_TOKEN_PROFILES.has(preferences.token_profile)) { - validated.token_profile = preferences.token_profile; - } - else { - errors.push(`token_profile must be one of: budget, balanced, quality, burn-max`); - } - } - // ─── Service Tier ─────────────────────────────────────────────────── - // OpenAI service tier for gpt-5.4 models. "off" explicitly disables the - // whole feature (hooks, footer, command refuse enable). Undefined = not - // configured. Historical gap: this field wasn't wired through validation - // so even "priority" / "flex" were being silently dropped. - if (preferences.service_tier !== undefined) { - const validTiers = new Set(["priority", "flex", "off"]); - if (typeof preferences.service_tier === "string" && - validTiers.has(preferences.service_tier)) { - validated.service_tier = - preferences.service_tier; - } - else { - errors.push(`service_tier must be one of: priority, flex, off`); - } - } - // ─── forensics_dedup ──────────────────────────────────────────────── - if (preferences.forensics_dedup !== undefined) { - validated.forensics_dedup = !!preferences.forensics_dedup; - } - // ─── stale_commit_threshold_minutes ───────────────────────────────── - if (preferences.stale_commit_threshold_minutes !== undefined) { - const raw = Number(preferences.stale_commit_threshold_minutes); - if (Number.isFinite(raw) && raw >= 0) { - validated.stale_commit_threshold_minutes = Math.floor(raw); - } - else { - errors.push("stale_commit_threshold_minutes must be a non-negative number (minutes; 0 = disabled)"); - } - } - // ─── widget_mode ──────────────────────────────────────────────────── - if (preferences.widget_mode !== undefined) { - const valid = new Set(["full", "small", "min", "off"]); - if (typeof preferences.widget_mode === "string" && - valid.has(preferences.widget_mode)) { - validated.widget_mode = - preferences.widget_mode; - } - else { - errors.push("widget_mode must be one of: full, small, min, off"); - } - } - // ─── slice_parallel ───────────────────────────────────────────────── - // Shallow validation: object-shape check + primitive field coercion. - // Deeper structural checks can come later; the goal here is to stop - // silently dropping the preference. - if (preferences.slice_parallel !== undefined) { - const sp = preferences.slice_parallel; - if (typeof sp === "object" && sp !== null && !Array.isArray(sp)) { - const v = {}; - const anySp = sp; - if (anySp.enabled !== undefined) - v.enabled = !!anySp.enabled; - if (anySp.max_workers !== undefined) { - const n = Number(anySp.max_workers); - if (Number.isFinite(n) && n >= 1) { - v.max_workers = Math.floor(n); - } - else { - errors.push("slice_parallel.max_workers must be a positive integer"); - } - } - validated.slice_parallel = v; - } - else { - errors.push("slice_parallel must be an object"); - } - } - // ─── modelOverrides ───────────────────────────────────────────────── - // Per-model capability overrides. Deep-merged into built-in profiles at - // consumer sites — here we just confirm the shape and pass through. - if (preferences.modelOverrides !== undefined) { - const mo = preferences.modelOverrides; - if (typeof mo === "object" && mo !== null && !Array.isArray(mo)) { - validated.modelOverrides = mo; - } - else { - errors.push("modelOverrides must be an object keyed by model ID"); - } - } - // ─── safety_harness ───────────────────────────────────────────────── - // Rich nested config. Pass-through with an object-shape guard; field-level - // validation can land alongside the features that consume them. - if (preferences.safety_harness !== undefined) { - const sh = preferences.safety_harness; - if (typeof sh === "object" && sh !== null && !Array.isArray(sh)) { - validated.safety_harness = sh; - } - else { - errors.push("safety_harness must be an object"); - } - } - // ─── Search Provider ───────────────────────────────────────────── - if (preferences.search_provider !== undefined) { - const validSearchProviders = new Set([ - "brave", - "tavily", - "minimax", - "serper", - "exa", - "ollama", - "combosearch", - "native", - "auto", - ]); - if (typeof preferences.search_provider === "string" && - validSearchProviders.has(preferences.search_provider)) { - validated.search_provider = - preferences.search_provider; - } - else { - errors.push(`search_provider must be one of: brave, tavily, minimax, serper, exa, ollama, combosearch, native, auto`); - } - } - // ─── Provider Preference (benchmark tie-break order) ──────────────── - if (preferences.provider_preference !== undefined) { - if (Array.isArray(preferences.provider_preference) && - preferences.provider_preference.every((s) => typeof s === "string")) { - const cleaned = preferences.provider_preference - .map((s) => s.trim().toLowerCase()) - .filter((s) => s.length > 0); - if (cleaned.length > 0) - validated.provider_preference = cleaned; - } - else { - errors.push("provider_preference must be an array of provider-ID strings"); - } - } - // ─── Allowed Providers (hard allowlist) ───────────────────────────── - // When set, model selection is gated to these providers only — any - // model from any other provider is filtered out of the candidate set - // before models.* resolution and dynamic routing. Case-insensitive. - if (preferences.allowed_providers !== undefined) { - if (Array.isArray(preferences.allowed_providers)) { - const allStrings = preferences.allowed_providers.every((s) => typeof s === "string"); - if (allStrings) { - const cleaned = preferences.allowed_providers - .map((s) => s.trim().toLowerCase()) - .filter((s) => s.length > 0); - if (cleaned.length > 0) - validated.allowed_providers = cleaned; - } - else { - errors.push("allowed_providers must be an array of strings (provider IDs)"); - } - } - else { - errors.push("allowed_providers must be an array of strings"); - } - } - // ─── Blocked Providers (hard denylist) ────────────────────────────── - // Applied after allowed_providers; deny wins when both are configured. - if (preferences.blocked_providers !== undefined) { - if (Array.isArray(preferences.blocked_providers)) { - const allStrings = preferences.blocked_providers.every((s) => typeof s === "string"); - if (allStrings) { - const cleaned = preferences.blocked_providers - .map((s) => s.trim().toLowerCase()) - .filter((s) => s.length > 0); - if (cleaned.length > 0) - validated.blocked_providers = Array.from(new Set(cleaned)); - } - else { - errors.push("blocked_providers must be an array of strings (provider IDs)"); - } - } - else { - errors.push("blocked_providers must be an array of strings"); - } - } - // ─── Per-provider model allow-list ────────────────────────────────── - // When a provider has an entry here, only listed model IDs are usable - // from that provider. Providers absent from the block are unrestricted. - if (preferences.provider_model_allow !== undefined) { - if (preferences.provider_model_allow !== null && - typeof preferences.provider_model_allow === "object" && - !Array.isArray(preferences.provider_model_allow)) { - const cleaned = {}; - for (const [provider, models] of Object.entries(preferences.provider_model_allow)) { - const providerId = provider.trim().toLowerCase(); - if (!providerId) { - errors.push("provider_model_allow provider IDs must be non-empty strings"); - continue; - } - if (!Array.isArray(models) || - models.some((m) => typeof m !== "string")) { - errors.push(`provider_model_allow.${provider} must be an array of model ID strings`); - continue; - } - const list = models - .map((s) => s.trim()) - .filter((s) => s.length > 0); - cleaned[providerId] = Array.from(new Set(list)); - } - if (Object.keys(cleaned).length > 0) - validated.provider_model_allow = cleaned; - } - else { - errors.push("provider_model_allow must be a map of provider → array of model IDs"); - } - } - // ─── Per-provider model block-list ────────────────────────────────── - // Deny wins after provider_model_allow; matching models are never used. - if (preferences.provider_model_block !== undefined) { - if (preferences.provider_model_block !== null && - typeof preferences.provider_model_block === "object" && - !Array.isArray(preferences.provider_model_block)) { - const cleaned = {}; - for (const [provider, models] of Object.entries(preferences.provider_model_block)) { - const providerId = provider.trim().toLowerCase(); - if (!providerId) { - errors.push("provider_model_block provider IDs must be non-empty strings"); - continue; - } - if (!Array.isArray(models) || - models.some((m) => typeof m !== "string")) { - errors.push(`provider_model_block.${provider} must be an array of model ID strings`); - continue; - } - const list = models - .map((s) => s.trim()) - .filter((s) => s.length > 0); - cleaned[providerId] = Array.from(new Set(list)); - } - if (Object.keys(cleaned).length > 0) - validated.provider_model_block = cleaned; - } - else { - errors.push("provider_model_block must be a map of provider → array of model IDs"); - } - } - // ─── Flat-rate Providers ──────────────────────────────────────────── - // User-declared flat-rate providers for dynamic routing suppression. - // Built-in providers (github-copilot, copilot, claude-code) and any - // externalCli provider are already auto-detected; this list layers on - // top for private subscription proxies and custom CLI wrappers. - if (preferences.flat_rate_providers !== undefined) { - if (Array.isArray(preferences.flat_rate_providers)) { - const allStrings = preferences.flat_rate_providers.every((item) => typeof item === "string"); - if (allStrings) { - // Strip empty/whitespace-only entries to avoid false matches. - validated.flat_rate_providers = preferences.flat_rate_providers - .map((s) => s.trim()) - .filter((s) => s.length > 0); - } - else { - errors.push("flat_rate_providers must be an array of strings"); - } - } - else { - errors.push("flat_rate_providers must be an array of strings"); - } - } - // ─── Shell Wrapper ─────────────────────────────────────────────────── - if (preferences.shell_wrapper !== undefined) { - if (Array.isArray(preferences.shell_wrapper) && - preferences.shell_wrapper.every((s) => typeof s === "string" && s.length > 0)) { - validated.shell_wrapper = preferences.shell_wrapper; - } - else { - errors.push("shell_wrapper must be an array of non-empty strings"); - } - } - // ─── Minimum Request Interval ─────────────────────────────────────── - if (preferences.min_request_interval_ms !== undefined) { - const raw = Number(preferences.min_request_interval_ms); - if (Number.isFinite(raw) && raw >= 0) { - validated.min_request_interval_ms = Math.floor(raw); - } - else { - errors.push("min_request_interval_ms must be a non-negative number (milliseconds; 0 = disabled)"); - } - } - // ─── Workspace Lifecycle Hooks ─────────────────────────────────────── - if (preferences.workspace !== undefined) { - if (typeof preferences.workspace === "object" && - preferences.workspace !== null) { - const ws = preferences.workspace; - const validatedWs = {}; - for (const key of ["after_create", "before_run", "after_run"]) { - if (ws[key] !== undefined) { - if (typeof ws[key] === "string") { - validatedWs[key] = ws[key]; - } - else { - errors.push(`workspace.${key} must be a string`); - } - } - } - validated.workspace = validatedWs; - } - else { - errors.push("workspace must be an object"); - } - } - // ─── Phase Skip Preferences ───────────────────────────────────────── - if (preferences.phases !== undefined) { - if (typeof preferences.phases === "object" && preferences.phases !== null) { - const validatedPhases = {}; - const p = preferences.phases; - if (p.skip_research !== undefined) - validatedPhases.skip_research = !!p.skip_research; - if (p.skip_reassess !== undefined) - validatedPhases.skip_reassess = !!p.skip_reassess; - if (p.skip_slice_research !== undefined) - validatedPhases.skip_slice_research = !!p.skip_slice_research; - if (p.skip_milestone_validation !== undefined) - validatedPhases.skip_milestone_validation = - !!p.skip_milestone_validation; - if (p.reassess_after_slice !== undefined) - validatedPhases.reassess_after_slice = !!p.reassess_after_slice; - if (p.require_slice_discussion !== undefined) - validatedPhases.require_slice_discussion = !!p - .require_slice_discussion; - if (p.mid_execution_escalation !== undefined) - validatedPhases.mid_execution_escalation = !!p - .mid_execution_escalation; - if (p.progressive_planning !== undefined) - validatedPhases.progressive_planning = !!p - .progressive_planning; - if (p.escalation_auto_accept !== undefined) - validatedPhases.escalation_auto_accept = !!p - .escalation_auto_accept; - // Warn on unknown phase keys - const knownPhaseKeys = new Set([ - "skip_research", - "skip_reassess", - "skip_slice_research", - "skip_milestone_validation", - "reassess_after_slice", - "require_slice_discussion", - "mid_execution_escalation", - "progressive_planning", - "escalation_auto_accept", - ]); - 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") { - const as = preferences.auto_supervisor; - const validatedAs = {}; - if (as.model !== undefined) { - if (typeof as.model === "string") - validatedAs.model = as.model; - else - errors.push("auto_supervisor.model must be a string"); - } - if (as.supervised_mode !== undefined) { - if (typeof as.supervised_mode === "boolean") - validatedAs.supervised_mode = as.supervised_mode; - else - errors.push("auto_supervisor.supervised_mode must be a boolean (true/false)"); - } - if (as.runaway_guard_enabled !== undefined) { - if (typeof as.runaway_guard_enabled === "boolean") { - validatedAs.runaway_guard_enabled = as.runaway_guard_enabled; - } - else { - errors.push("auto_supervisor.runaway_guard_enabled must be a boolean (true/false)"); - } - } - if (as.runaway_hard_pause !== undefined) { - if (typeof as.runaway_hard_pause === "boolean") { - validatedAs.runaway_hard_pause = as.runaway_hard_pause; - } - else { - errors.push("auto_supervisor.runaway_hard_pause must be a boolean (true/false)"); - } - } - if (as.soft_timeout_minutes !== undefined) { - const val = Number(as.soft_timeout_minutes); - if (!Number.isNaN(val) && val >= 0) - validatedAs.soft_timeout_minutes = val; - else - errors.push("auto_supervisor.soft_timeout_minutes must be a non-negative number"); - } - if (as.idle_timeout_minutes !== undefined) { - const val = Number(as.idle_timeout_minutes); - if (!Number.isNaN(val) && val >= 0) - validatedAs.idle_timeout_minutes = val; - else - errors.push("auto_supervisor.idle_timeout_minutes must be a non-negative number"); - } - if (as.hard_timeout_minutes !== undefined) { - const val = Number(as.hard_timeout_minutes); - if (!Number.isNaN(val) && val >= 0) - validatedAs.hard_timeout_minutes = val; - else - errors.push("auto_supervisor.hard_timeout_minutes must be a non-negative number"); - } - if (as.phase_timeout_minutes !== undefined) { - const val = Number(as.phase_timeout_minutes); - if (!Number.isNaN(val) && val >= 0) - validatedAs.phase_timeout_minutes = val; - else - errors.push("auto_supervisor.phase_timeout_minutes must be a non-negative number"); - } - if (as.completion_nudge_after !== undefined) { - const val = Number(as.completion_nudge_after); - if (!Number.isNaN(val) && val >= 0) - validatedAs.completion_nudge_after = val; - else - errors.push("auto_supervisor.completion_nudge_after must be a non-negative number"); - } - for (const key of [ - "runaway_tool_call_warning", - "runaway_token_warning", - "runaway_elapsed_minutes", - "runaway_changed_files_warning", - "runaway_diagnostic_turns", - ]) { - if (as[key] === undefined) - continue; - const val = Number(as[key]); - if (!Number.isNaN(val) && val >= 0) { - validatedAs[key] = val; - } - else { - errors.push(`auto_supervisor.${key} must be a non-negative number`); - } - } - validated.auto_supervisor = validatedAs; - } - 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"); - } - } - // ─── Cmux ─────────────────────────────────────────────────────────────── - if (preferences.cmux !== undefined) { - if (preferences.cmux && typeof preferences.cmux === "object") { - const cmux = preferences.cmux; - const validatedCmux = {}; - if (cmux.enabled !== undefined) - validatedCmux.enabled = !!cmux.enabled; - if (cmux.notifications !== undefined) - validatedCmux.notifications = !!cmux.notifications; - if (cmux.sidebar !== undefined) - validatedCmux.sidebar = !!cmux.sidebar; - if (cmux.splits !== undefined) - validatedCmux.splits = !!cmux.splits; - if (cmux.browser !== undefined) - validatedCmux.browser = !!cmux.browser; - const knownCmuxKeys = new Set([ - "enabled", - "notifications", - "sidebar", - "splits", - "browser", - ]); - for (const key of Object.keys(cmux)) { - if (!knownCmuxKeys.has(key)) { - warnings.push(`unknown cmux key "${key}" — ignored`); - } - } - if (Object.keys(validatedCmux).length > 0) { - validated.cmux = validatedCmux; - } - } - else { - errors.push("cmux must be an object"); - } - } - // ─── Remote Questions ─────────────────────────────────────────────── - if (preferences.remote_questions !== undefined) { - if (preferences.remote_questions && - typeof preferences.remote_questions === "object") { - const rq = preferences.remote_questions; - const validRq = { - channel: rq.channel, - channel_id: rq.channel_id, - }; - if (rq.timeout_minutes !== undefined) { - const timeout = Number(rq.timeout_minutes); - if (Number.isFinite(timeout)) - validRq.timeout_minutes = timeout; - else - errors.push("remote_questions.timeout_minutes must be a number"); - } - if (rq.poll_interval_seconds !== undefined) { - const poll = Number(rq.poll_interval_seconds); - if (Number.isFinite(poll)) - validRq.poll_interval_seconds = poll; - else - errors.push("remote_questions.poll_interval_seconds must be a number"); - } - if (rq.allowed_user_ids !== undefined) { - if (Array.isArray(rq.allowed_user_ids)) { - const allowed = rq.allowed_user_ids - .map((id) => String(id).trim()) - .filter((id) => /^-?\d{1,20}$/.test(id)); - if (allowed.length === rq.allowed_user_ids.length) { - validRq.allowed_user_ids = allowed; - } - else { - errors.push("remote_questions.allowed_user_ids must contain only Telegram numeric user IDs"); - } - } - else { - errors.push("remote_questions.allowed_user_ids must be an array"); - } - } - if (rq.auto_resolve_on_timeout !== undefined) { - if (typeof rq.auto_resolve_on_timeout === "boolean") { - validRq.auto_resolve_on_timeout = rq.auto_resolve_on_timeout; - } - else { - errors.push("remote_questions.auto_resolve_on_timeout must be a boolean"); - } - } - if (rq.auto_resolve_strategy !== undefined) { - if (rq.auto_resolve_strategy === "recommended-option") { - validRq.auto_resolve_strategy = "recommended-option"; - } - else { - errors.push('remote_questions.auto_resolve_strategy must be "recommended-option"'); - } - } - const knownRemoteKeys = new Set([ - "channel", - "channel_id", - "allowed_user_ids", - "timeout_minutes", - "poll_interval_seconds", - "auto_resolve_on_timeout", - "auto_resolve_strategy", - ]); - for (const key of Object.keys(rq)) { - if (!knownRemoteKeys.has(key)) { - warnings.push(`unknown remote_questions key "${key}" — ignored`); - } - } - validated.remote_questions = validRq; - } - 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 = []; - const seenNames = new Set(); - const knownUnitTypes = new Set(KNOWN_UNIT_TYPES); - for (const hook of preferences.post_unit_hooks) { - if (!hook || typeof hook !== "object") { - errors.push("post_unit_hooks entry must be an object"); - continue; - } - const name = typeof hook.name === "string" ? hook.name.trim() : ""; - if (!name) { - errors.push("post_unit_hooks entry missing name"); - continue; - } - if (seenNames.has(name)) { - errors.push(`duplicate post_unit_hooks name: ${name}`); - continue; - } - const after = normalizeStringArray(hook.after); - if (after.length === 0) { - errors.push(`post_unit_hooks "${name}" missing after`); - continue; - } - for (const ut of after) { - if (!knownUnitTypes.has(ut)) { - errors.push(`post_unit_hooks "${name}" unknown unit type in after: ${ut}`); - } - } - const prompt = typeof hook.prompt === "string" ? hook.prompt.trim() : ""; - if (!prompt) { - errors.push(`post_unit_hooks "${name}" missing prompt`); - continue; - } - const validHook = { 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 = []; - const seenPreNames = new Set(); - const knownUnitTypes = new Set(KNOWN_UNIT_TYPES); - const validActions = new Set(["modify", "skip", "replace"]); - for (const hook of preferences.pre_dispatch_hooks) { - if (!hook || typeof hook !== "object") { - errors.push("pre_dispatch_hooks entry must be an object"); - continue; - } - const name = typeof hook.name === "string" ? hook.name.trim() : ""; - if (!name) { - errors.push("pre_dispatch_hooks entry missing name"); - continue; - } - if (seenPreNames.has(name)) { - errors.push(`duplicate pre_dispatch_hooks name: ${name}`); - continue; - } - const before = normalizeStringArray(hook.before); - if (before.length === 0) { - errors.push(`pre_dispatch_hooks "${name}" missing before`); - continue; - } - for (const ut of before) { - if (!knownUnitTypes.has(ut)) { - errors.push(`pre_dispatch_hooks "${name}" unknown unit type in before: ${ut}`); - } - } - const action = typeof hook.action === "string" ? hook.action.trim() : ""; - if (!validActions.has(action)) { - errors.push(`pre_dispatch_hooks "${name}" invalid action: ${action} (must be modify, skip, or replace)`); - continue; - } - const validHook = { - name, - before, - action: 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; - const validDr = {}; - 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.capability_routing !== undefined) { - if (typeof dr.capability_routing === "boolean") - validDr.capability_routing = dr.capability_routing; - else - errors.push("dynamic_routing.capability_routing must be a boolean"); - } - if (dr.tier_models !== undefined) { - if (typeof dr.tier_models === "object" && dr.tier_models !== null) { - const tm = dr.tier_models; - const validTm = {}; - for (const tier of ["light", "standard", "heavy"]) { - if (tm[tier] !== undefined) { - if (typeof tm[tier] === "string") - validTm[tier] = tm[tier]; - else - errors.push(`dynamic_routing.tier_models.${tier} must be a string`); - } - } - if (Object.keys(validTm).length > 0) - validDr.tier_models = - validTm; - } - else { - errors.push("dynamic_routing.tier_models must be an object"); - } - } - if (Object.keys(validDr).length > 0) { - validated.dynamic_routing = validDr; - } - } - else { - errors.push("dynamic_routing must be an object"); - } - } - // ─── Context Management ────────────────────────────────────────────── - if (preferences.context_management !== undefined) { - if (typeof preferences.context_management === "object" && - preferences.context_management !== null) { - const cm = preferences.context_management; - const validCm = {}; - if (cm.observation_masking !== undefined) { - if (typeof cm.observation_masking === "boolean") - validCm.observation_masking = cm.observation_masking; - else - errors.push("context_management.observation_masking must be a boolean"); - } - if (cm.observation_mask_turns !== undefined) { - const turns = cm.observation_mask_turns; - if (typeof turns === "number" && turns >= 1 && turns <= 50) - validCm.observation_mask_turns = turns; - else - errors.push("context_management.observation_mask_turns must be a number between 1 and 50"); - } - if (cm.compaction_threshold_percent !== undefined) { - const pct = cm.compaction_threshold_percent; - if (typeof pct === "number" && pct >= 0.5 && pct <= 0.95) - validCm.compaction_threshold_percent = pct; - else - errors.push("context_management.compaction_threshold_percent must be a number between 0.5 and 0.95"); - } - if (cm.tool_result_max_chars !== undefined) { - const chars = cm.tool_result_max_chars; - if (typeof chars === "number" && chars >= 200 && chars <= 10000) - validCm.tool_result_max_chars = chars; - else - errors.push("context_management.tool_result_max_chars must be a number between 200 and 10000"); - } - if (Object.keys(validCm).length > 0) { - validated.context_management = validCm; - } - } - else { - errors.push("context_management must be an object"); - } - } - // ─── Parallel Config ──────────────────────────────────────────────────── - if (preferences.parallel && typeof preferences.parallel === "object") { - const p = preferences.parallel; - const parallel = {}; - if (p.enabled !== undefined) { - if (typeof p.enabled === "boolean") - parallel.enabled = p.enabled; - else - errors.push("parallel.enabled must be a boolean"); - } - if (p.max_workers !== undefined) { - if (typeof p.max_workers === "number" && - p.max_workers >= 1 && - p.max_workers <= 4) { - parallel.max_workers = Math.floor(p.max_workers); - } - else { - errors.push("parallel.max_workers must be a number between 1 and 4"); - } - } - if (p.budget_ceiling !== undefined) { - if (typeof p.budget_ceiling === "number" && p.budget_ceiling > 0) { - parallel.budget_ceiling = p.budget_ceiling; - } - else { - errors.push("parallel.budget_ceiling must be a positive number"); - } - } - if (p.merge_strategy !== undefined) { - const validStrategies = new Set(["per-slice", "per-milestone"]); - if (typeof p.merge_strategy === "string" && - validStrategies.has(p.merge_strategy)) { - parallel.merge_strategy = p.merge_strategy; - } - else { - errors.push("parallel.merge_strategy must be one of: per-slice, per-milestone"); - } - } - if (p.auto_merge !== undefined) { - const validModes = new Set(["auto", "confirm", "manual"]); - if (typeof p.auto_merge === "string" && validModes.has(p.auto_merge)) { - parallel.auto_merge = p.auto_merge; - } - else { - errors.push("parallel.auto_merge must be one of: auto, confirm, manual"); - } - } - if (p.worker_model !== undefined) { - if (typeof p.worker_model === "string" && p.worker_model.length > 0) { - parallel.worker_model = p.worker_model; - } - else { - errors.push("parallel.worker_model must be a non-empty string"); - } - } - if (Object.keys(parallel).length > 0) { - validated.parallel = - parallel; - } - } - // ─── Reactive Execution ───────────────────────────────────────────────── - if (preferences.reactive_execution !== undefined) { - if (typeof preferences.reactive_execution === "object" && - preferences.reactive_execution !== null) { - const re = preferences.reactive_execution; - const validRe = {}; - if (re.enabled !== undefined) { - if (typeof re.enabled === "boolean") - validRe.enabled = re.enabled; - else - errors.push("reactive_execution.enabled must be a boolean"); - } - if (re.max_parallel !== undefined) { - const mp = typeof re.max_parallel === "number" - ? re.max_parallel - : Number(re.max_parallel); - if (Number.isFinite(mp) && mp >= 1 && mp <= 8) { - validRe.max_parallel = Math.floor(mp); - } - else { - errors.push("reactive_execution.max_parallel must be a number between 1 and 8"); - } - } - if (re.isolation_mode !== undefined) { - if (re.isolation_mode === "same-tree") { - validRe.isolation_mode = "same-tree"; - } - else { - errors.push('reactive_execution.isolation_mode must be "same-tree"'); - } - } - if (re.subagent_model !== undefined) { - if (typeof re.subagent_model === "string" && - re.subagent_model.length > 0) { - validRe.subagent_model = re.subagent_model; - } - else { - errors.push("reactive_execution.subagent_model must be a non-empty string"); - } - } - const knownReKeys = new Set([ - "enabled", - "max_parallel", - "isolation_mode", - "subagent_model", - ]); - for (const key of Object.keys(re)) { - if (!knownReKeys.has(key)) { - warnings.push(`unknown reactive_execution key "${key}" — ignored`); - } - } - if (Object.keys(validRe).length > 0) { - validated.reactive_execution = - validRe; - } - } - else { - errors.push("reactive_execution must be an object"); - } - } - // ─── Gate Evaluation ───────────────────────────────────────────────────── - if (preferences.gate_evaluation !== undefined) { - if (typeof preferences.gate_evaluation === "object" && - preferences.gate_evaluation !== null) { - const ge = preferences.gate_evaluation; - const validGe = {}; - if (ge.enabled !== undefined) { - if (typeof ge.enabled === "boolean") - validGe.enabled = ge.enabled; - else - errors.push("gate_evaluation.enabled must be a boolean"); - } - if (ge.slice_gates !== undefined) { - if (Array.isArray(ge.slice_gates) && - ge.slice_gates.every((g) => typeof g === "string")) { - validGe.slice_gates = ge.slice_gates; - } - else { - errors.push("gate_evaluation.slice_gates must be an array of strings"); - } - } - if (ge.task_gates !== undefined) { - if (typeof ge.task_gates === "boolean") - validGe.task_gates = ge.task_gates; - else - errors.push("gate_evaluation.task_gates must be a boolean"); - } - const knownGeKeys = new Set(["enabled", "slice_gates", "task_gates"]); - for (const key of Object.keys(ge)) { - if (!knownGeKeys.has(key)) { - warnings.push(`unknown gate_evaluation key "${key}" — ignored`); - } - } - if (Object.keys(validGe).length > 0) { - validated.gate_evaluation = - validGe; - } - } - else { - errors.push("gate_evaluation must be an object"); - } - } - // ─── Verification Preferences ─────────────────────────────────────────── - if (preferences.verification_commands !== undefined) { - if (Array.isArray(preferences.verification_commands)) { - const allStrings = preferences.verification_commands.every((item) => typeof item === "string"); - if (allStrings) { - validated.verification_commands = preferences.verification_commands; - } - else { - errors.push("verification_commands must be an array of strings"); - } - } - else { - errors.push("verification_commands must be an array of strings"); - } - } - if (preferences.verification_auto_fix !== undefined) { - if (typeof preferences.verification_auto_fix === "boolean") { - validated.verification_auto_fix = preferences.verification_auto_fix; - } - else { - errors.push("verification_auto_fix must be a boolean"); - } - } - if (preferences.verification_max_retries !== undefined) { - const raw = preferences.verification_max_retries; - if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { - validated.verification_max_retries = Math.floor(raw); - } - else { - errors.push("verification_max_retries must be a non-negative number"); - } - } - // ─── Git Preferences ─────────────────────────────────────────────────── - if (preferences.git && typeof preferences.git === "object") { - const git = {}; - const g = preferences.git; - 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; - } - else { - errors.push("git.merge_strategy must be one of: squash, merge"); - } - } - if (g.main_branch !== undefined) { - if (typeof g.main_branch === "string" && - g.main_branch.trim() !== "" && - VALID_BRANCH_NAME.test(g.main_branch)) { - git.main_branch = g.main_branch; - } - else { - errors.push("git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)"); - } - } - if (g.isolation !== undefined) { - const validIsolation = new Set(["worktree", "branch", "none"]); - if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) { - git.isolation = g.isolation; - } - else { - errors.push("git.isolation must be one of: worktree, branch, none"); - } - } - if (g.commit_docs !== undefined) { - warnings.push("git.commit_docs is deprecated — .sf/ is managed externally and always gitignored. Remove this setting."); - } - if (g.manage_gitignore !== undefined) { - if (typeof g.manage_gitignore === "boolean") - git.manage_gitignore = g.manage_gitignore; - else - errors.push("git.manage_gitignore must be a boolean"); - } - if (g.worktree_post_create !== undefined) { - if (typeof g.worktree_post_create === "string" && - g.worktree_post_create.trim()) { - git.worktree_post_create = g.worktree_post_create.trim(); - } - else { - errors.push("git.worktree_post_create must be a non-empty string (path to script)"); - } - } - if (g.auto_pr !== undefined) { - if (typeof g.auto_pr === "boolean") - git.auto_pr = g.auto_pr; - else - errors.push("git.auto_pr must be a boolean"); - } - if (g.pr_target_branch !== undefined) { - if (typeof g.pr_target_branch === "string" && g.pr_target_branch.trim()) { - git.pr_target_branch = g.pr_target_branch.trim(); - } - else { - errors.push("git.pr_target_branch must be a non-empty string (branch name)"); - } - } - // Deprecated: merge_to_main is ignored (branchless architecture). - if (g.merge_to_main !== undefined) { - warnings.push("git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting."); - } - // #4765 — collapse cadence + milestone resquash - if (g.collapse_cadence !== undefined) { - const validCadence = new Set(["milestone", "slice"]); - if (typeof g.collapse_cadence === "string" && - validCadence.has(g.collapse_cadence)) { - git.collapse_cadence = g.collapse_cadence; - } - else { - errors.push("git.collapse_cadence must be one of: milestone, slice"); - } - } - if (g.milestone_resquash !== undefined) { - if (typeof g.milestone_resquash === "boolean") { - git.milestone_resquash = g.milestone_resquash; - const cadence = git.collapse_cadence ?? - (typeof g.collapse_cadence === "string" - ? g.collapse_cadence - : undefined); - if (cadence !== "slice") { - warnings.push('git.milestone_resquash is ignored unless git.collapse_cadence is "slice"'); - } - } - else { - errors.push("git.milestone_resquash must be a boolean"); - } - } - if (Object.keys(git).length > 0) { - validated.git = git; - } - } - // ─── Auto Visualize ───────────────────────────────────────────────── - if (preferences.auto_visualize !== undefined) { - if (typeof preferences.auto_visualize === "boolean") { - validated.auto_visualize = preferences.auto_visualize; - } - else { - errors.push("auto_visualize must be a boolean"); - } - } - // ─── Auto Report ──────────────────────────────────────────────────── - if (preferences.auto_report !== undefined) { - if (typeof preferences.auto_report === "boolean") { - validated.auto_report = preferences.auto_report; - } - else { - errors.push("auto_report must be a boolean"); - } - } - // ─── Context Selection ────────────────────────────────────────────── - if (preferences.context_selection !== undefined) { - const validModes = new Set(["full", "smart"]); - if (typeof preferences.context_selection === "string" && - validModes.has(preferences.context_selection)) { - validated.context_selection = - preferences.context_selection; - } - else { - errors.push(`context_selection must be one of: full, smart`); - } - } - // ─── GitHub Sync ──────────────────────────────────────────────────────── - if (preferences.github !== undefined) { - if (typeof preferences.github === "object" && preferences.github !== null) { - const gh = preferences.github; - const validGh = {}; - if (gh.enabled !== undefined) { - if (typeof gh.enabled === "boolean") - validGh.enabled = gh.enabled; - else - errors.push("github.enabled must be a boolean"); - } - if (gh.repo !== undefined) { - if (typeof gh.repo === "string" && gh.repo.includes("/")) - validGh.repo = gh.repo; - else - errors.push('github.repo must be a string in "owner/repo" format'); - } - if (gh.project !== undefined) { - const p = typeof gh.project === "number" ? gh.project : Number(gh.project); - if (Number.isFinite(p) && p > 0) - validGh.project = Math.floor(p); - else - errors.push("github.project must be a positive number"); - } - if (gh.labels !== undefined) { - if (Array.isArray(gh.labels) && - gh.labels.every((l) => typeof l === "string")) { - validGh.labels = gh.labels; - } - else { - errors.push("github.labels must be an array of strings"); - } - } - if (gh.auto_link_commits !== undefined) { - if (typeof gh.auto_link_commits === "boolean") - validGh.auto_link_commits = gh.auto_link_commits; - else - errors.push("github.auto_link_commits must be a boolean"); - } - if (gh.slice_prs !== undefined) { - if (typeof gh.slice_prs === "boolean") - validGh.slice_prs = gh.slice_prs; - else - errors.push("github.slice_prs must be a boolean"); - } - const knownGhKeys = new Set([ - "enabled", - "repo", - "project", - "labels", - "auto_link_commits", - "slice_prs", - ]); - for (const key of Object.keys(gh)) { - if (!knownGhKeys.has(key)) { - warnings.push(`unknown github key "${key}" — ignored`); - } - } - if (Object.keys(validGh).length > 0) { - validated.github = - validGh; - } - } - else { - errors.push("github must be an object"); - } - } - // ─── Show Token Cost ────────────────────────────────────────────── - if (preferences.show_token_cost !== undefined) { - if (typeof preferences.show_token_cost === "boolean") { - validated.show_token_cost = preferences.show_token_cost; - } - else { - errors.push("show_token_cost must be a boolean"); - } - } - // ─── Experimental Features ──────────────────────────────────────── - if (preferences.experimental !== undefined) { - if (typeof preferences.experimental === "object" && - preferences.experimental !== null) { - const exp = preferences.experimental; - const validExp = {}; - if (exp.rtk !== undefined) { - if (typeof exp.rtk === "boolean") - validExp.rtk = exp.rtk; - else - errors.push("experimental.rtk must be a boolean"); - } - if (exp.dispatch_rules !== undefined) { - if (typeof exp.dispatch_rules === "object" && - exp.dispatch_rules !== null) { - const rawDispatch = exp.dispatch_rules; - const validDispatch = {}; - if (rawDispatch.order !== undefined) { - if (Array.isArray(rawDispatch.order) && - rawDispatch.order.every((item) => typeof item === "string")) { - validDispatch.order = rawDispatch.order - .map((item) => item.trim()) - .filter((item) => item.length > 0); - } - else { - errors.push("experimental.dispatch_rules.order must be an array of strings"); - } - } - if (rawDispatch.variants !== undefined) { - if (typeof rawDispatch.variants === "object" && - rawDispatch.variants !== null && - !Array.isArray(rawDispatch.variants)) { - const validVariants = {}; - for (const [variantName, variantOrder] of Object.entries(rawDispatch.variants)) { - if (!Array.isArray(variantOrder) || - variantOrder.some((item) => typeof item !== "string")) { - errors.push(`experimental.dispatch_rules.variants.${variantName} must be an array of strings`); - continue; - } - validVariants[variantName] = variantOrder - .map((item) => item.trim()) - .filter((item) => item.length > 0); - } - validDispatch.variants = validVariants; - } - else { - errors.push("experimental.dispatch_rules.variants must be an object mapping variant names to string arrays"); - } - } - if (rawDispatch.active_variant !== undefined) { - if (typeof rawDispatch.active_variant === "string" && - rawDispatch.active_variant.trim().length > 0) { - validDispatch.active_variant = rawDispatch.active_variant.trim(); - } - else { - errors.push("experimental.dispatch_rules.active_variant must be a non-empty string"); - } - } - const knownDispatchKeys = new Set([ - "order", - "variants", - "active_variant", - ]); - for (const key of Object.keys(rawDispatch)) { - if (!knownDispatchKeys.has(key)) { - warnings.push(`unknown experimental.dispatch_rules key "${key}" — ignored`); - } - } - if (Object.keys(validDispatch).length > 0) { - validExp.dispatch_rules = validDispatch; - } - } - else { - errors.push("experimental.dispatch_rules must be an object"); - } - } - const knownExpKeys = new Set(["rtk", "dispatch_rules"]); - for (const key of Object.keys(exp)) { - if (!knownExpKeys.has(key)) { - warnings.push(`unknown experimental key "${key}" — ignored`); - } - } - if (Object.keys(validExp).length > 0) { - validated.experimental = validExp; - } - } - else { - errors.push("experimental must be an object"); - } - } - // ─── Codebase Map ────────────────────────────────────────────────── - if (preferences.codebase !== undefined) { - if (typeof preferences.codebase === "object" && - preferences.codebase !== null) { - const cb = preferences.codebase; - const validCb = {}; - if (cb.exclude_patterns !== undefined) { - if (Array.isArray(cb.exclude_patterns) && - cb.exclude_patterns.every((p) => typeof p === "string")) { - validCb.exclude_patterns = cb.exclude_patterns; - } - else { - errors.push("codebase.exclude_patterns must be an array of strings"); - } - } - if (cb.max_files !== undefined) { - const mf = typeof cb.max_files === "number" - ? cb.max_files - : Number(cb.max_files); - if (Number.isFinite(mf) && mf >= 1) { - validCb.max_files = Math.floor(mf); - } - else { - errors.push("codebase.max_files must be a positive integer"); - } - } - if (cb.collapse_threshold !== undefined) { - const ct = typeof cb.collapse_threshold === "number" - ? cb.collapse_threshold - : Number(cb.collapse_threshold); - if (Number.isFinite(ct) && ct >= 1) { - validCb.collapse_threshold = Math.floor(ct); - } - else { - errors.push("codebase.collapse_threshold must be a positive integer"); - } - } - if (cb.indexer_backend !== undefined) { - if (cb.indexer_backend === "projectRag" || - cb.indexer_backend === "sift" || - cb.indexer_backend === "none") { - validCb.indexer_backend = cb.indexer_backend; - } - else { - errors.push('codebase.indexer_backend must be one of "projectRag", "sift", or "none"'); - } - } - if (cb.project_rag !== undefined) { - if (cb.project_rag === "auto" || - cb.project_rag === "off" || - cb.project_rag === "required") { - validCb.project_rag = cb.project_rag; - } - else { - errors.push('codebase.project_rag must be one of "auto", "off", or "required"'); - } - } - if (cb.project_rag_server !== undefined) { - if (typeof cb.project_rag_server === "string" && - cb.project_rag_server.trim().length > 0) { - validCb.project_rag_server = cb.project_rag_server.trim(); - } - else { - errors.push("codebase.project_rag_server must be a non-empty string"); - } - } - if (cb.project_rag_auto_index !== undefined) { - if (typeof cb.project_rag_auto_index === "boolean") { - validCb.project_rag_auto_index = cb.project_rag_auto_index; - } - else { - errors.push("codebase.project_rag_auto_index must be a boolean"); - } - } - const knownCbKeys = new Set([ - "exclude_patterns", - "max_files", - "collapse_threshold", - "indexer_backend", - "project_rag", - "project_rag_server", - "project_rag_auto_index", - ]); - for (const key of Object.keys(cb)) { - if (!knownCbKeys.has(key)) { - warnings.push(`unknown codebase key "${key}" — ignored`); - } - } - if (Object.keys(validCb).length > 0) { - validated.codebase = validCb; - } - } - else { - errors.push("codebase must be an object"); - } - } - // ─── Enhanced Verification ────────────────────────────────────────────────── - if (preferences.enhanced_verification !== undefined) { - if (typeof preferences.enhanced_verification === "boolean") { - validated.enhanced_verification = preferences.enhanced_verification; - } - else { - errors.push("enhanced_verification must be a boolean"); - } - } - if (preferences.enhanced_verification_pre !== undefined) { - if (typeof preferences.enhanced_verification_pre === "boolean") { - validated.enhanced_verification_pre = - preferences.enhanced_verification_pre; - } - else { - errors.push("enhanced_verification_pre must be a boolean"); - } - } - if (preferences.enhanced_verification_post !== undefined) { - if (typeof preferences.enhanced_verification_post === "boolean") { - validated.enhanced_verification_post = - preferences.enhanced_verification_post; - } - else { - errors.push("enhanced_verification_post must be a boolean"); - } - } - if (preferences.enhanced_verification_strict !== undefined) { - if (typeof preferences.enhanced_verification_strict === "boolean") { - validated.enhanced_verification_strict = - preferences.enhanced_verification_strict; - } - else { - errors.push("enhanced_verification_strict must be a boolean"); - } - } - // ─── Discuss Preparation ──────────────────────────────────────────── - if (preferences.discuss_preparation !== undefined) { - if (typeof preferences.discuss_preparation === "boolean") { - validated.discuss_preparation = preferences.discuss_preparation; - } - else { - errors.push("discuss_preparation must be a boolean"); - } - } - // ─── Discuss Web Research ─────────────────────────────────────────── - if (preferences.discuss_web_research !== undefined) { - if (typeof preferences.discuss_web_research === "boolean") { - validated.discuss_web_research = preferences.discuss_web_research; - } - else { - errors.push("discuss_web_research must be a boolean"); - } - } - // ─── Discuss Depth ────────────────────────────────────────────────── - if (preferences.discuss_depth !== undefined) { - const validDepths = new Set(["quick", "standard", "thorough"]); - if (typeof preferences.discuss_depth === "string" && - validDepths.has(preferences.discuss_depth)) { - validated.discuss_depth = - preferences.discuss_depth; - } - else { - errors.push(`discuss_depth must be one of: quick, standard, thorough`); - } - } - return { preferences: validated, errors, warnings }; + const errors = []; + const warnings = []; + const validated = {}; + // Schema version: report drift, then migrate forward. Errors from a + // malformed migration chain bubble up so the caller can surface "your + // prefs need attention" instead of silently dropping fields. Field + // checks below run against the migrated copy. + for (const w of checkPreferencesDrift(preferences).warnings) warnings.push(w); + let migrated = preferences; + try { + const outcome = migrateForward(preferences); + migrated = outcome.preferences; + if (outcome.applied.length > 0) { + warnings.push( + `migrated prefs forward: ${outcome.applied + .map((m) => `v${m.from}→v${m.to} (${m.description})`) + .join("; ")}`, + ); + } + } catch (err) { + errors.push( + err instanceof Error + ? `prefs migration failed: ${err.message}` + : `prefs migration failed: ${String(err)}`, + ); + } + preferences = migrated; + // ─── Unknown Key Detection ────────────────────────────────────────── + // Common key migration hints for pi-level settings that don't map to SF prefs + const KEY_MIGRATION_HINTS = { + taskIsolation: + 'use "git.isolation" instead (values: worktree, branch, none)', + task_isolation: + 'use "git.isolation" instead (values: worktree, branch, none)', + isolation: 'use "git.isolation" instead (values: worktree, branch, none)', + manage_gitignore: 'use "git.manage_gitignore" instead', + auto_push: 'use "git.auto_push" instead', + main_branch: 'use "git.main_branch" instead', + }; + for (const key of Object.keys(preferences)) { + if (!KNOWN_PREFERENCE_KEYS.has(key)) { + const hint = KEY_MIGRATION_HINTS[key]; + if (hint) { + warnings.push(`unknown preference key "${key}" — ${hint}`); + } else { + warnings.push(`unknown preference key "${key}" — ignored`); + } + } + } + if (preferences.version !== undefined) { + if (preferences.version === CURRENT_PREFERENCES_SCHEMA_VERSION) { + validated.version = CURRENT_PREFERENCES_SCHEMA_VERSION; + } else if (preferences.version > CURRENT_PREFERENCES_SCHEMA_VERSION) { + // Already warned via checkPreferencesDrift; preserve so a later + // sf upgrade reads correctly without rewriting the file. + validated.version = preferences.version; + } else { + // Should be unreachable: migrateForward stamps the current version + // or throws. Defend against a future bug instead of silently dropping. + errors.push( + `unsupported version ${preferences.version} (migration chain ` + + `should have produced v${CURRENT_PREFERENCES_SCHEMA_VERSION})`, + ); + } + } + // ─── Workflow Mode ────────────────────────────────────────────────── + if (preferences.mode !== undefined) { + const validModes = new Set(["solo", "team"]); + if ( + typeof preferences.mode === "string" && + validModes.has(preferences.mode) + ) { + validated.mode = preferences.mode; + } else { + errors.push( + `invalid mode "${preferences.mode}" — must be one of: solo, team`, + ); + } + } + const validDiscoveryModes = new Set(["auto", "suggest", "off"]); + if (preferences.skill_discovery) { + if (validDiscoveryModes.has(preferences.skill_discovery)) { + validated.skill_discovery = preferences.skill_discovery; + } else { + errors.push( + `invalid skill_discovery value: ${preferences.skill_discovery}`, + ); + } + } + if (preferences.skill_staleness_days !== undefined) { + const days = Number(preferences.skill_staleness_days); + if (Number.isFinite(days) && days >= 0) { + validated.skill_staleness_days = Math.floor(days); + } else { + errors.push( + `invalid skill_staleness_days: must be a non-negative number`, + ); + } + } + validated.always_use_skills = normalizeStringArray( + preferences.always_use_skills, + ); + validated.prefer_skills = normalizeStringArray(preferences.prefer_skills); + validated.avoid_skills = normalizeStringArray(preferences.avoid_skills); + validated.custom_instructions = normalizeStringArray( + preferences.custom_instructions, + ); + if (preferences.skill_rules) { + const validRules = []; + 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 = { when }; + for (const action of SKILL_ACTIONS) { + const values = normalizeStringArray(rule[action]); + if (values.length > 0) { + validatedRule[action] = values; + } + } + 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", + ]) { + 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.persist_model_changes !== undefined) { + if (typeof preferences.persist_model_changes === "boolean") { + validated.persist_model_changes = preferences.persist_model_changes; + } else { + errors.push("persist_model_changes must be a boolean"); + } + } + 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`); + } + } + // ─── UOK Flags ────────────────────────────────────────────────────── + if (preferences.uok !== undefined) { + if (typeof preferences.uok === "object" && preferences.uok !== null) { + const raw = preferences.uok; + const valid = {}; + if (raw.enabled !== undefined) { + if (typeof raw.enabled === "boolean") valid.enabled = raw.enabled; + else errors.push("uok.enabled must be a boolean"); + } + const parseEnabledBlock = (key, targetKey) => { + const normalizedTargetKey = + targetKey ?? (key === "plan_v2" ? "planning_flow" : key); + const value = raw[key]; + if (value === undefined) return; + if (typeof value !== "object" || value === null) { + errors.push(`uok.${key} must be an object`); + return; + } + const block = value; + const parsed = {}; + if (block.enabled !== undefined) { + if (typeof block.enabled === "boolean") + parsed.enabled = block.enabled; + else errors.push(`uok.${key}.enabled must be a boolean`); + } + const unknown = Object.keys(block).filter((k) => k !== "enabled"); + for (const unk of unknown) { + warnings.push(`unknown uok.${key} key "${unk}" — ignored`); + } + if (Object.keys(parsed).length > 0) { + valid[normalizedTargetKey] = parsed; + } + }; + parseEnabledBlock("legacy_fallback"); + parseEnabledBlock("gates"); + parseEnabledBlock("model_policy"); + parseEnabledBlock("execution_graph"); + parseEnabledBlock("audit_envelope"); + if (raw.audit_unified !== undefined && raw.audit_envelope === undefined) { + warnings.push( + "uok.audit_unified is deprecated; use uok.audit_envelope", + ); + parseEnabledBlock("audit_unified", "audit_envelope"); + } + parseEnabledBlock("planning_flow"); + if (raw.plan_v2 !== undefined && raw.planning_flow === undefined) { + warnings.push("uok.plan_v2 is deprecated; use uok.planning_flow"); + parseEnabledBlock("plan_v2", "planning_flow"); + } + if (raw.gitops !== undefined) { + if (typeof raw.gitops !== "object" || raw.gitops === null) { + errors.push("uok.gitops must be an object"); + } else { + const gitops = raw.gitops; + const parsed = {}; + if (gitops.enabled !== undefined) { + if (typeof gitops.enabled === "boolean") + parsed.enabled = gitops.enabled; + else errors.push("uok.gitops.enabled must be a boolean"); + } + if (gitops.turn_action !== undefined) { + if ( + typeof gitops.turn_action === "string" && + VALID_UOK_TURN_ACTIONS.has(gitops.turn_action) + ) { + parsed.turn_action = gitops.turn_action; + } else { + errors.push( + "uok.gitops.turn_action must be one of: commit, snapshot, status-only", + ); + } + } + if (gitops.turn_push !== undefined) { + if (typeof gitops.turn_push === "boolean") + parsed.turn_push = gitops.turn_push; + else errors.push("uok.gitops.turn_push must be a boolean"); + } + const unknown = Object.keys(gitops).filter( + (k) => !["enabled", "turn_action", "turn_push"].includes(k), + ); + for (const unk of unknown) { + warnings.push(`unknown uok.gitops key "${unk}" — ignored`); + } + if (Object.keys(parsed).length > 0) { + valid.gitops = parsed; + } + } + } + const knownUokKeys = new Set([ + "enabled", + "legacy_fallback", + "gates", + "model_policy", + "execution_graph", + "gitops", + "audit_envelope", + "audit_unified", + "planning_flow", + "plan_v2", + ]); + for (const key of Object.keys(raw)) { + if (!knownUokKeys.has(key)) { + warnings.push(`unknown uok key "${key}" — ignored`); + } + } + if (Object.keys(valid).length > 0) { + validated.uok = valid; + } + } else { + errors.push("uok must be an object"); + } + } + // ─── Token Profile ───────────────────────────────────────────────── + if (preferences.token_profile !== undefined) { + if ( + typeof preferences.token_profile === "string" && + VALID_TOKEN_PROFILES.has(preferences.token_profile) + ) { + validated.token_profile = preferences.token_profile; + } else { + errors.push( + `token_profile must be one of: budget, balanced, quality, burn-max`, + ); + } + } + // ─── Service Tier ─────────────────────────────────────────────────── + // OpenAI service tier for gpt-5.4 models. "off" explicitly disables the + // whole feature (hooks, footer, command refuse enable). Undefined = not + // configured. Historical gap: this field wasn't wired through validation + // so even "priority" / "flex" were being silently dropped. + if (preferences.service_tier !== undefined) { + const validTiers = new Set(["priority", "flex", "off"]); + if ( + typeof preferences.service_tier === "string" && + validTiers.has(preferences.service_tier) + ) { + validated.service_tier = preferences.service_tier; + } else { + errors.push(`service_tier must be one of: priority, flex, off`); + } + } + // ─── forensics_dedup ──────────────────────────────────────────────── + if (preferences.forensics_dedup !== undefined) { + validated.forensics_dedup = !!preferences.forensics_dedup; + } + // ─── stale_commit_threshold_minutes ───────────────────────────────── + if (preferences.stale_commit_threshold_minutes !== undefined) { + const raw = Number(preferences.stale_commit_threshold_minutes); + if (Number.isFinite(raw) && raw >= 0) { + validated.stale_commit_threshold_minutes = Math.floor(raw); + } else { + errors.push( + "stale_commit_threshold_minutes must be a non-negative number (minutes; 0 = disabled)", + ); + } + } + // ─── widget_mode ──────────────────────────────────────────────────── + if (preferences.widget_mode !== undefined) { + const valid = new Set(["full", "small", "min", "off"]); + if ( + typeof preferences.widget_mode === "string" && + valid.has(preferences.widget_mode) + ) { + validated.widget_mode = preferences.widget_mode; + } else { + errors.push("widget_mode must be one of: full, small, min, off"); + } + } + // ─── slice_parallel ───────────────────────────────────────────────── + // Shallow validation: object-shape check + primitive field coercion. + // Deeper structural checks can come later; the goal here is to stop + // silently dropping the preference. + if (preferences.slice_parallel !== undefined) { + const sp = preferences.slice_parallel; + if (typeof sp === "object" && sp !== null && !Array.isArray(sp)) { + const v = {}; + const anySp = sp; + if (anySp.enabled !== undefined) v.enabled = !!anySp.enabled; + if (anySp.max_workers !== undefined) { + const n = Number(anySp.max_workers); + if (Number.isFinite(n) && n >= 1) { + v.max_workers = Math.floor(n); + } else { + errors.push("slice_parallel.max_workers must be a positive integer"); + } + } + validated.slice_parallel = v; + } else { + errors.push("slice_parallel must be an object"); + } + } + // ─── modelOverrides ───────────────────────────────────────────────── + // Per-model capability overrides. Deep-merged into built-in profiles at + // consumer sites — here we just confirm the shape and pass through. + if (preferences.modelOverrides !== undefined) { + const mo = preferences.modelOverrides; + if (typeof mo === "object" && mo !== null && !Array.isArray(mo)) { + validated.modelOverrides = mo; + } else { + errors.push("modelOverrides must be an object keyed by model ID"); + } + } + // ─── safety_harness ───────────────────────────────────────────────── + // Rich nested config. Pass-through with an object-shape guard; field-level + // validation can land alongside the features that consume them. + if (preferences.safety_harness !== undefined) { + const sh = preferences.safety_harness; + if (typeof sh === "object" && sh !== null && !Array.isArray(sh)) { + validated.safety_harness = sh; + } else { + errors.push("safety_harness must be an object"); + } + } + // ─── Search Provider ───────────────────────────────────────────── + if (preferences.search_provider !== undefined) { + const validSearchProviders = new Set([ + "brave", + "tavily", + "minimax", + "serper", + "exa", + "ollama", + "combosearch", + "native", + "auto", + ]); + if ( + typeof preferences.search_provider === "string" && + validSearchProviders.has(preferences.search_provider) + ) { + validated.search_provider = preferences.search_provider; + } else { + errors.push( + `search_provider must be one of: brave, tavily, minimax, serper, exa, ollama, combosearch, native, auto`, + ); + } + } + // ─── Provider Preference (benchmark tie-break order) ──────────────── + if (preferences.provider_preference !== undefined) { + if ( + Array.isArray(preferences.provider_preference) && + preferences.provider_preference.every((s) => typeof s === "string") + ) { + const cleaned = preferences.provider_preference + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0); + if (cleaned.length > 0) validated.provider_preference = cleaned; + } else { + errors.push( + "provider_preference must be an array of provider-ID strings", + ); + } + } + // ─── Allowed Providers (hard allowlist) ───────────────────────────── + // When set, model selection is gated to these providers only — any + // model from any other provider is filtered out of the candidate set + // before models.* resolution and dynamic routing. Case-insensitive. + if (preferences.allowed_providers !== undefined) { + if (Array.isArray(preferences.allowed_providers)) { + const allStrings = preferences.allowed_providers.every( + (s) => typeof s === "string", + ); + if (allStrings) { + const cleaned = preferences.allowed_providers + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0); + if (cleaned.length > 0) validated.allowed_providers = cleaned; + } else { + errors.push( + "allowed_providers must be an array of strings (provider IDs)", + ); + } + } else { + errors.push("allowed_providers must be an array of strings"); + } + } + // ─── Blocked Providers (hard denylist) ────────────────────────────── + // Applied after allowed_providers; deny wins when both are configured. + if (preferences.blocked_providers !== undefined) { + if (Array.isArray(preferences.blocked_providers)) { + const allStrings = preferences.blocked_providers.every( + (s) => typeof s === "string", + ); + if (allStrings) { + const cleaned = preferences.blocked_providers + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0); + if (cleaned.length > 0) + validated.blocked_providers = Array.from(new Set(cleaned)); + } else { + errors.push( + "blocked_providers must be an array of strings (provider IDs)", + ); + } + } else { + errors.push("blocked_providers must be an array of strings"); + } + } + // ─── Per-provider model allow-list ────────────────────────────────── + // When a provider has an entry here, only listed model IDs are usable + // from that provider. Providers absent from the block are unrestricted. + if (preferences.provider_model_allow !== undefined) { + if ( + preferences.provider_model_allow !== null && + typeof preferences.provider_model_allow === "object" && + !Array.isArray(preferences.provider_model_allow) + ) { + const cleaned = {}; + for (const [provider, models] of Object.entries( + preferences.provider_model_allow, + )) { + const providerId = provider.trim().toLowerCase(); + if (!providerId) { + errors.push( + "provider_model_allow provider IDs must be non-empty strings", + ); + continue; + } + if ( + !Array.isArray(models) || + models.some((m) => typeof m !== "string") + ) { + errors.push( + `provider_model_allow.${provider} must be an array of model ID strings`, + ); + continue; + } + const list = models.map((s) => s.trim()).filter((s) => s.length > 0); + cleaned[providerId] = Array.from(new Set(list)); + } + if (Object.keys(cleaned).length > 0) + validated.provider_model_allow = cleaned; + } else { + errors.push( + "provider_model_allow must be a map of provider → array of model IDs", + ); + } + } + // ─── Per-provider model block-list ────────────────────────────────── + // Deny wins after provider_model_allow; matching models are never used. + if (preferences.provider_model_block !== undefined) { + if ( + preferences.provider_model_block !== null && + typeof preferences.provider_model_block === "object" && + !Array.isArray(preferences.provider_model_block) + ) { + const cleaned = {}; + for (const [provider, models] of Object.entries( + preferences.provider_model_block, + )) { + const providerId = provider.trim().toLowerCase(); + if (!providerId) { + errors.push( + "provider_model_block provider IDs must be non-empty strings", + ); + continue; + } + if ( + !Array.isArray(models) || + models.some((m) => typeof m !== "string") + ) { + errors.push( + `provider_model_block.${provider} must be an array of model ID strings`, + ); + continue; + } + const list = models.map((s) => s.trim()).filter((s) => s.length > 0); + cleaned[providerId] = Array.from(new Set(list)); + } + if (Object.keys(cleaned).length > 0) + validated.provider_model_block = cleaned; + } else { + errors.push( + "provider_model_block must be a map of provider → array of model IDs", + ); + } + } + // ─── Flat-rate Providers ──────────────────────────────────────────── + // User-declared flat-rate providers for dynamic routing suppression. + // Built-in providers (github-copilot, copilot, claude-code) and any + // externalCli provider are already auto-detected; this list layers on + // top for private subscription proxies and custom CLI wrappers. + if (preferences.flat_rate_providers !== undefined) { + if (Array.isArray(preferences.flat_rate_providers)) { + const allStrings = preferences.flat_rate_providers.every( + (item) => typeof item === "string", + ); + if (allStrings) { + // Strip empty/whitespace-only entries to avoid false matches. + validated.flat_rate_providers = preferences.flat_rate_providers + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } else { + errors.push("flat_rate_providers must be an array of strings"); + } + } else { + errors.push("flat_rate_providers must be an array of strings"); + } + } + // ─── Shell Wrapper ─────────────────────────────────────────────────── + if (preferences.shell_wrapper !== undefined) { + if ( + Array.isArray(preferences.shell_wrapper) && + preferences.shell_wrapper.every( + (s) => typeof s === "string" && s.length > 0, + ) + ) { + validated.shell_wrapper = preferences.shell_wrapper; + } else { + errors.push("shell_wrapper must be an array of non-empty strings"); + } + } + // ─── Minimum Request Interval ─────────────────────────────────────── + if (preferences.min_request_interval_ms !== undefined) { + const raw = Number(preferences.min_request_interval_ms); + if (Number.isFinite(raw) && raw >= 0) { + validated.min_request_interval_ms = Math.floor(raw); + } else { + errors.push( + "min_request_interval_ms must be a non-negative number (milliseconds; 0 = disabled)", + ); + } + } + // ─── Workspace Lifecycle Hooks ─────────────────────────────────────── + if (preferences.workspace !== undefined) { + if ( + typeof preferences.workspace === "object" && + preferences.workspace !== null + ) { + const ws = preferences.workspace; + const validatedWs = {}; + for (const key of ["after_create", "before_run", "after_run"]) { + if (ws[key] !== undefined) { + if (typeof ws[key] === "string") { + validatedWs[key] = ws[key]; + } else { + errors.push(`workspace.${key} must be a string`); + } + } + } + validated.workspace = validatedWs; + } else { + errors.push("workspace must be an object"); + } + } + // ─── Phase Skip Preferences ───────────────────────────────────────── + if (preferences.phases !== undefined) { + if (typeof preferences.phases === "object" && preferences.phases !== null) { + const validatedPhases = {}; + const p = preferences.phases; + if (p.skip_research !== undefined) + validatedPhases.skip_research = !!p.skip_research; + if (p.skip_reassess !== undefined) + validatedPhases.skip_reassess = !!p.skip_reassess; + if (p.skip_slice_research !== undefined) + validatedPhases.skip_slice_research = !!p.skip_slice_research; + if (p.skip_milestone_validation !== undefined) + validatedPhases.skip_milestone_validation = + !!p.skip_milestone_validation; + if (p.reassess_after_slice !== undefined) + validatedPhases.reassess_after_slice = !!p.reassess_after_slice; + if (p.require_slice_discussion !== undefined) + validatedPhases.require_slice_discussion = !!p.require_slice_discussion; + if (p.mid_execution_escalation !== undefined) + validatedPhases.mid_execution_escalation = !!p.mid_execution_escalation; + if (p.progressive_planning !== undefined) + validatedPhases.progressive_planning = !!p.progressive_planning; + if (p.escalation_auto_accept !== undefined) + validatedPhases.escalation_auto_accept = !!p.escalation_auto_accept; + // Warn on unknown phase keys + const knownPhaseKeys = new Set([ + "skip_research", + "skip_reassess", + "skip_slice_research", + "skip_milestone_validation", + "reassess_after_slice", + "require_slice_discussion", + "mid_execution_escalation", + "progressive_planning", + "escalation_auto_accept", + ]); + 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" + ) { + const as = preferences.auto_supervisor; + const validatedAs = {}; + if (as.model !== undefined) { + if (typeof as.model === "string") validatedAs.model = as.model; + else errors.push("auto_supervisor.model must be a string"); + } + if (as.supervised_mode !== undefined) { + if (typeof as.supervised_mode === "boolean") + validatedAs.supervised_mode = as.supervised_mode; + else + errors.push( + "auto_supervisor.supervised_mode must be a boolean (true/false)", + ); + } + if (as.runaway_guard_enabled !== undefined) { + if (typeof as.runaway_guard_enabled === "boolean") { + validatedAs.runaway_guard_enabled = as.runaway_guard_enabled; + } else { + errors.push( + "auto_supervisor.runaway_guard_enabled must be a boolean (true/false)", + ); + } + } + if (as.runaway_hard_pause !== undefined) { + if (typeof as.runaway_hard_pause === "boolean") { + validatedAs.runaway_hard_pause = as.runaway_hard_pause; + } else { + errors.push( + "auto_supervisor.runaway_hard_pause must be a boolean (true/false)", + ); + } + } + if (as.soft_timeout_minutes !== undefined) { + const val = Number(as.soft_timeout_minutes); + if (!Number.isNaN(val) && val >= 0) + validatedAs.soft_timeout_minutes = val; + else + errors.push( + "auto_supervisor.soft_timeout_minutes must be a non-negative number", + ); + } + if (as.idle_timeout_minutes !== undefined) { + const val = Number(as.idle_timeout_minutes); + if (!Number.isNaN(val) && val >= 0) + validatedAs.idle_timeout_minutes = val; + else + errors.push( + "auto_supervisor.idle_timeout_minutes must be a non-negative number", + ); + } + if (as.hard_timeout_minutes !== undefined) { + const val = Number(as.hard_timeout_minutes); + if (!Number.isNaN(val) && val >= 0) + validatedAs.hard_timeout_minutes = val; + else + errors.push( + "auto_supervisor.hard_timeout_minutes must be a non-negative number", + ); + } + if (as.phase_timeout_minutes !== undefined) { + const val = Number(as.phase_timeout_minutes); + if (!Number.isNaN(val) && val >= 0) + validatedAs.phase_timeout_minutes = val; + else + errors.push( + "auto_supervisor.phase_timeout_minutes must be a non-negative number", + ); + } + if (as.completion_nudge_after !== undefined) { + const val = Number(as.completion_nudge_after); + if (!Number.isNaN(val) && val >= 0) + validatedAs.completion_nudge_after = val; + else + errors.push( + "auto_supervisor.completion_nudge_after must be a non-negative number", + ); + } + for (const key of [ + "runaway_tool_call_warning", + "runaway_token_warning", + "runaway_elapsed_minutes", + "runaway_changed_files_warning", + "runaway_diagnostic_turns", + ]) { + if (as[key] === undefined) continue; + const val = Number(as[key]); + if (!Number.isNaN(val) && val >= 0) { + validatedAs[key] = val; + } else { + errors.push(`auto_supervisor.${key} must be a non-negative number`); + } + } + validated.auto_supervisor = validatedAs; + } 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"); + } + } + // ─── Cmux ─────────────────────────────────────────────────────────────── + if (preferences.cmux !== undefined) { + if (preferences.cmux && typeof preferences.cmux === "object") { + const cmux = preferences.cmux; + const validatedCmux = {}; + if (cmux.enabled !== undefined) validatedCmux.enabled = !!cmux.enabled; + if (cmux.notifications !== undefined) + validatedCmux.notifications = !!cmux.notifications; + if (cmux.sidebar !== undefined) validatedCmux.sidebar = !!cmux.sidebar; + if (cmux.splits !== undefined) validatedCmux.splits = !!cmux.splits; + if (cmux.browser !== undefined) validatedCmux.browser = !!cmux.browser; + const knownCmuxKeys = new Set([ + "enabled", + "notifications", + "sidebar", + "splits", + "browser", + ]); + for (const key of Object.keys(cmux)) { + if (!knownCmuxKeys.has(key)) { + warnings.push(`unknown cmux key "${key}" — ignored`); + } + } + if (Object.keys(validatedCmux).length > 0) { + validated.cmux = validatedCmux; + } + } else { + errors.push("cmux must be an object"); + } + } + // ─── Remote Questions ─────────────────────────────────────────────── + if (preferences.remote_questions !== undefined) { + if ( + preferences.remote_questions && + typeof preferences.remote_questions === "object" + ) { + const rq = preferences.remote_questions; + const validRq = { + channel: rq.channel, + channel_id: rq.channel_id, + }; + if (rq.timeout_minutes !== undefined) { + const timeout = Number(rq.timeout_minutes); + if (Number.isFinite(timeout)) validRq.timeout_minutes = timeout; + else errors.push("remote_questions.timeout_minutes must be a number"); + } + if (rq.poll_interval_seconds !== undefined) { + const poll = Number(rq.poll_interval_seconds); + if (Number.isFinite(poll)) validRq.poll_interval_seconds = poll; + else + errors.push( + "remote_questions.poll_interval_seconds must be a number", + ); + } + if (rq.allowed_user_ids !== undefined) { + if (Array.isArray(rq.allowed_user_ids)) { + const allowed = rq.allowed_user_ids + .map((id) => String(id).trim()) + .filter((id) => /^-?\d{1,20}$/.test(id)); + if (allowed.length === rq.allowed_user_ids.length) { + validRq.allowed_user_ids = allowed; + } else { + errors.push( + "remote_questions.allowed_user_ids must contain only Telegram numeric user IDs", + ); + } + } else { + errors.push("remote_questions.allowed_user_ids must be an array"); + } + } + if (rq.auto_resolve_on_timeout !== undefined) { + if (typeof rq.auto_resolve_on_timeout === "boolean") { + validRq.auto_resolve_on_timeout = rq.auto_resolve_on_timeout; + } else { + errors.push( + "remote_questions.auto_resolve_on_timeout must be a boolean", + ); + } + } + if (rq.auto_resolve_strategy !== undefined) { + if (rq.auto_resolve_strategy === "recommended-option") { + validRq.auto_resolve_strategy = "recommended-option"; + } else { + errors.push( + 'remote_questions.auto_resolve_strategy must be "recommended-option"', + ); + } + } + const knownRemoteKeys = new Set([ + "channel", + "channel_id", + "allowed_user_ids", + "timeout_minutes", + "poll_interval_seconds", + "auto_resolve_on_timeout", + "auto_resolve_strategy", + ]); + for (const key of Object.keys(rq)) { + if (!knownRemoteKeys.has(key)) { + warnings.push(`unknown remote_questions key "${key}" — ignored`); + } + } + validated.remote_questions = validRq; + } 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 = []; + const seenNames = new Set(); + const knownUnitTypes = new Set(KNOWN_UNIT_TYPES); + for (const hook of preferences.post_unit_hooks) { + if (!hook || typeof hook !== "object") { + errors.push("post_unit_hooks entry must be an object"); + continue; + } + const name = typeof hook.name === "string" ? hook.name.trim() : ""; + if (!name) { + errors.push("post_unit_hooks entry missing name"); + continue; + } + if (seenNames.has(name)) { + errors.push(`duplicate post_unit_hooks name: ${name}`); + continue; + } + const after = normalizeStringArray(hook.after); + if (after.length === 0) { + errors.push(`post_unit_hooks "${name}" missing after`); + continue; + } + for (const ut of after) { + if (!knownUnitTypes.has(ut)) { + errors.push( + `post_unit_hooks "${name}" unknown unit type in after: ${ut}`, + ); + } + } + const prompt = typeof hook.prompt === "string" ? hook.prompt.trim() : ""; + if (!prompt) { + errors.push(`post_unit_hooks "${name}" missing prompt`); + continue; + } + const validHook = { 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 = []; + const seenPreNames = new Set(); + const knownUnitTypes = new Set(KNOWN_UNIT_TYPES); + const validActions = new Set(["modify", "skip", "replace"]); + for (const hook of preferences.pre_dispatch_hooks) { + if (!hook || typeof hook !== "object") { + errors.push("pre_dispatch_hooks entry must be an object"); + continue; + } + const name = typeof hook.name === "string" ? hook.name.trim() : ""; + if (!name) { + errors.push("pre_dispatch_hooks entry missing name"); + continue; + } + if (seenPreNames.has(name)) { + errors.push(`duplicate pre_dispatch_hooks name: ${name}`); + continue; + } + const before = normalizeStringArray(hook.before); + if (before.length === 0) { + errors.push(`pre_dispatch_hooks "${name}" missing before`); + continue; + } + for (const ut of before) { + if (!knownUnitTypes.has(ut)) { + errors.push( + `pre_dispatch_hooks "${name}" unknown unit type in before: ${ut}`, + ); + } + } + const action = typeof hook.action === "string" ? hook.action.trim() : ""; + if (!validActions.has(action)) { + errors.push( + `pre_dispatch_hooks "${name}" invalid action: ${action} (must be modify, skip, or replace)`, + ); + continue; + } + const validHook = { + name, + before, + action: 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; + const validDr = {}; + 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.capability_routing !== undefined) { + if (typeof dr.capability_routing === "boolean") + validDr.capability_routing = dr.capability_routing; + else + errors.push("dynamic_routing.capability_routing must be a boolean"); + } + if (dr.tier_models !== undefined) { + if (typeof dr.tier_models === "object" && dr.tier_models !== null) { + const tm = dr.tier_models; + const validTm = {}; + for (const tier of ["light", "standard", "heavy"]) { + if (tm[tier] !== undefined) { + if (typeof tm[tier] === "string") validTm[tier] = tm[tier]; + else + errors.push( + `dynamic_routing.tier_models.${tier} must be a string`, + ); + } + } + if (Object.keys(validTm).length > 0) validDr.tier_models = validTm; + } else { + errors.push("dynamic_routing.tier_models must be an object"); + } + } + if (Object.keys(validDr).length > 0) { + validated.dynamic_routing = validDr; + } + } else { + errors.push("dynamic_routing must be an object"); + } + } + // ─── Context Management ────────────────────────────────────────────── + if (preferences.context_management !== undefined) { + if ( + typeof preferences.context_management === "object" && + preferences.context_management !== null + ) { + const cm = preferences.context_management; + const validCm = {}; + if (cm.observation_masking !== undefined) { + if (typeof cm.observation_masking === "boolean") + validCm.observation_masking = cm.observation_masking; + else + errors.push( + "context_management.observation_masking must be a boolean", + ); + } + if (cm.observation_mask_turns !== undefined) { + const turns = cm.observation_mask_turns; + if (typeof turns === "number" && turns >= 1 && turns <= 50) + validCm.observation_mask_turns = turns; + else + errors.push( + "context_management.observation_mask_turns must be a number between 1 and 50", + ); + } + if (cm.compaction_threshold_percent !== undefined) { + const pct = cm.compaction_threshold_percent; + if (typeof pct === "number" && pct >= 0.5 && pct <= 0.95) + validCm.compaction_threshold_percent = pct; + else + errors.push( + "context_management.compaction_threshold_percent must be a number between 0.5 and 0.95", + ); + } + if (cm.tool_result_max_chars !== undefined) { + const chars = cm.tool_result_max_chars; + if (typeof chars === "number" && chars >= 200 && chars <= 10000) + validCm.tool_result_max_chars = chars; + else + errors.push( + "context_management.tool_result_max_chars must be a number between 200 and 10000", + ); + } + if (Object.keys(validCm).length > 0) { + validated.context_management = validCm; + } + } else { + errors.push("context_management must be an object"); + } + } + // ─── Parallel Config ──────────────────────────────────────────────────── + if (preferences.parallel && typeof preferences.parallel === "object") { + const p = preferences.parallel; + const parallel = {}; + if (p.enabled !== undefined) { + if (typeof p.enabled === "boolean") parallel.enabled = p.enabled; + else errors.push("parallel.enabled must be a boolean"); + } + if (p.max_workers !== undefined) { + if ( + typeof p.max_workers === "number" && + p.max_workers >= 1 && + p.max_workers <= 4 + ) { + parallel.max_workers = Math.floor(p.max_workers); + } else { + errors.push("parallel.max_workers must be a number between 1 and 4"); + } + } + if (p.budget_ceiling !== undefined) { + if (typeof p.budget_ceiling === "number" && p.budget_ceiling > 0) { + parallel.budget_ceiling = p.budget_ceiling; + } else { + errors.push("parallel.budget_ceiling must be a positive number"); + } + } + if (p.merge_strategy !== undefined) { + const validStrategies = new Set(["per-slice", "per-milestone"]); + if ( + typeof p.merge_strategy === "string" && + validStrategies.has(p.merge_strategy) + ) { + parallel.merge_strategy = p.merge_strategy; + } else { + errors.push( + "parallel.merge_strategy must be one of: per-slice, per-milestone", + ); + } + } + if (p.auto_merge !== undefined) { + const validModes = new Set(["auto", "confirm", "manual"]); + if (typeof p.auto_merge === "string" && validModes.has(p.auto_merge)) { + parallel.auto_merge = p.auto_merge; + } else { + errors.push( + "parallel.auto_merge must be one of: auto, confirm, manual", + ); + } + } + if (p.worker_model !== undefined) { + if (typeof p.worker_model === "string" && p.worker_model.length > 0) { + parallel.worker_model = p.worker_model; + } else { + errors.push("parallel.worker_model must be a non-empty string"); + } + } + if (Object.keys(parallel).length > 0) { + validated.parallel = parallel; + } + } + // ─── Reactive Execution ───────────────────────────────────────────────── + if (preferences.reactive_execution !== undefined) { + if ( + typeof preferences.reactive_execution === "object" && + preferences.reactive_execution !== null + ) { + const re = preferences.reactive_execution; + const validRe = {}; + if (re.enabled !== undefined) { + if (typeof re.enabled === "boolean") validRe.enabled = re.enabled; + else errors.push("reactive_execution.enabled must be a boolean"); + } + if (re.max_parallel !== undefined) { + const mp = + typeof re.max_parallel === "number" + ? re.max_parallel + : Number(re.max_parallel); + if (Number.isFinite(mp) && mp >= 1 && mp <= 8) { + validRe.max_parallel = Math.floor(mp); + } else { + errors.push( + "reactive_execution.max_parallel must be a number between 1 and 8", + ); + } + } + if (re.isolation_mode !== undefined) { + if (re.isolation_mode === "same-tree") { + validRe.isolation_mode = "same-tree"; + } else { + errors.push('reactive_execution.isolation_mode must be "same-tree"'); + } + } + if (re.subagent_model !== undefined) { + if ( + typeof re.subagent_model === "string" && + re.subagent_model.length > 0 + ) { + validRe.subagent_model = re.subagent_model; + } else { + errors.push( + "reactive_execution.subagent_model must be a non-empty string", + ); + } + } + const knownReKeys = new Set([ + "enabled", + "max_parallel", + "isolation_mode", + "subagent_model", + ]); + for (const key of Object.keys(re)) { + if (!knownReKeys.has(key)) { + warnings.push(`unknown reactive_execution key "${key}" — ignored`); + } + } + if (Object.keys(validRe).length > 0) { + validated.reactive_execution = validRe; + } + } else { + errors.push("reactive_execution must be an object"); + } + } + // ─── Gate Evaluation ───────────────────────────────────────────────────── + if (preferences.gate_evaluation !== undefined) { + if ( + typeof preferences.gate_evaluation === "object" && + preferences.gate_evaluation !== null + ) { + const ge = preferences.gate_evaluation; + const validGe = {}; + if (ge.enabled !== undefined) { + if (typeof ge.enabled === "boolean") validGe.enabled = ge.enabled; + else errors.push("gate_evaluation.enabled must be a boolean"); + } + if (ge.slice_gates !== undefined) { + if ( + Array.isArray(ge.slice_gates) && + ge.slice_gates.every((g) => typeof g === "string") + ) { + validGe.slice_gates = ge.slice_gates; + } else { + errors.push( + "gate_evaluation.slice_gates must be an array of strings", + ); + } + } + if (ge.task_gates !== undefined) { + if (typeof ge.task_gates === "boolean") + validGe.task_gates = ge.task_gates; + else errors.push("gate_evaluation.task_gates must be a boolean"); + } + const knownGeKeys = new Set(["enabled", "slice_gates", "task_gates"]); + for (const key of Object.keys(ge)) { + if (!knownGeKeys.has(key)) { + warnings.push(`unknown gate_evaluation key "${key}" — ignored`); + } + } + if (Object.keys(validGe).length > 0) { + validated.gate_evaluation = validGe; + } + } else { + errors.push("gate_evaluation must be an object"); + } + } + // ─── Verification Preferences ─────────────────────────────────────────── + if (preferences.verification_commands !== undefined) { + if (Array.isArray(preferences.verification_commands)) { + const allStrings = preferences.verification_commands.every( + (item) => typeof item === "string", + ); + if (allStrings) { + validated.verification_commands = preferences.verification_commands; + } else { + errors.push("verification_commands must be an array of strings"); + } + } else { + errors.push("verification_commands must be an array of strings"); + } + } + if (preferences.verification_auto_fix !== undefined) { + if (typeof preferences.verification_auto_fix === "boolean") { + validated.verification_auto_fix = preferences.verification_auto_fix; + } else { + errors.push("verification_auto_fix must be a boolean"); + } + } + if (preferences.verification_max_retries !== undefined) { + const raw = preferences.verification_max_retries; + if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { + validated.verification_max_retries = Math.floor(raw); + } else { + errors.push("verification_max_retries must be a non-negative number"); + } + } + // ─── Git Preferences ─────────────────────────────────────────────────── + if (preferences.git && typeof preferences.git === "object") { + const git = {}; + const g = preferences.git; + 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; + } else { + errors.push("git.merge_strategy must be one of: squash, merge"); + } + } + if (g.main_branch !== undefined) { + if ( + typeof g.main_branch === "string" && + g.main_branch.trim() !== "" && + VALID_BRANCH_NAME.test(g.main_branch) + ) { + git.main_branch = g.main_branch; + } else { + errors.push( + "git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)", + ); + } + } + if (g.isolation !== undefined) { + const validIsolation = new Set(["worktree", "branch", "none"]); + if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) { + git.isolation = g.isolation; + } else { + errors.push("git.isolation must be one of: worktree, branch, none"); + } + } + if (g.commit_docs !== undefined) { + warnings.push( + "git.commit_docs is deprecated — .sf/ is managed externally and always gitignored. Remove this setting.", + ); + } + if (g.manage_gitignore !== undefined) { + if (typeof g.manage_gitignore === "boolean") + git.manage_gitignore = g.manage_gitignore; + else errors.push("git.manage_gitignore must be a boolean"); + } + if (g.worktree_post_create !== undefined) { + if ( + typeof g.worktree_post_create === "string" && + g.worktree_post_create.trim() + ) { + git.worktree_post_create = g.worktree_post_create.trim(); + } else { + errors.push( + "git.worktree_post_create must be a non-empty string (path to script)", + ); + } + } + if (g.auto_pr !== undefined) { + if (typeof g.auto_pr === "boolean") git.auto_pr = g.auto_pr; + else errors.push("git.auto_pr must be a boolean"); + } + if (g.pr_target_branch !== undefined) { + if (typeof g.pr_target_branch === "string" && g.pr_target_branch.trim()) { + git.pr_target_branch = g.pr_target_branch.trim(); + } else { + errors.push( + "git.pr_target_branch must be a non-empty string (branch name)", + ); + } + } + // Deprecated: merge_to_main is ignored (branchless architecture). + if (g.merge_to_main !== undefined) { + warnings.push( + "git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting.", + ); + } + // #4765 — collapse cadence + milestone resquash + if (g.collapse_cadence !== undefined) { + const validCadence = new Set(["milestone", "slice"]); + if ( + typeof g.collapse_cadence === "string" && + validCadence.has(g.collapse_cadence) + ) { + git.collapse_cadence = g.collapse_cadence; + } else { + errors.push("git.collapse_cadence must be one of: milestone, slice"); + } + } + if (g.milestone_resquash !== undefined) { + if (typeof g.milestone_resquash === "boolean") { + git.milestone_resquash = g.milestone_resquash; + const cadence = + git.collapse_cadence ?? + (typeof g.collapse_cadence === "string" + ? g.collapse_cadence + : undefined); + if (cadence !== "slice") { + warnings.push( + 'git.milestone_resquash is ignored unless git.collapse_cadence is "slice"', + ); + } + } else { + errors.push("git.milestone_resquash must be a boolean"); + } + } + if (Object.keys(git).length > 0) { + validated.git = git; + } + } + // ─── Auto Visualize ───────────────────────────────────────────────── + if (preferences.auto_visualize !== undefined) { + if (typeof preferences.auto_visualize === "boolean") { + validated.auto_visualize = preferences.auto_visualize; + } else { + errors.push("auto_visualize must be a boolean"); + } + } + // ─── Auto Report ──────────────────────────────────────────────────── + if (preferences.auto_report !== undefined) { + if (typeof preferences.auto_report === "boolean") { + validated.auto_report = preferences.auto_report; + } else { + errors.push("auto_report must be a boolean"); + } + } + // ─── Context Selection ────────────────────────────────────────────── + if (preferences.context_selection !== undefined) { + const validModes = new Set(["full", "smart"]); + if ( + typeof preferences.context_selection === "string" && + validModes.has(preferences.context_selection) + ) { + validated.context_selection = preferences.context_selection; + } else { + errors.push(`context_selection must be one of: full, smart`); + } + } + // ─── GitHub Sync ──────────────────────────────────────────────────────── + if (preferences.github !== undefined) { + if (typeof preferences.github === "object" && preferences.github !== null) { + const gh = preferences.github; + const validGh = {}; + if (gh.enabled !== undefined) { + if (typeof gh.enabled === "boolean") validGh.enabled = gh.enabled; + else errors.push("github.enabled must be a boolean"); + } + if (gh.repo !== undefined) { + if (typeof gh.repo === "string" && gh.repo.includes("/")) + validGh.repo = gh.repo; + else errors.push('github.repo must be a string in "owner/repo" format'); + } + if (gh.project !== undefined) { + const p = + typeof gh.project === "number" ? gh.project : Number(gh.project); + if (Number.isFinite(p) && p > 0) validGh.project = Math.floor(p); + else errors.push("github.project must be a positive number"); + } + if (gh.labels !== undefined) { + if ( + Array.isArray(gh.labels) && + gh.labels.every((l) => typeof l === "string") + ) { + validGh.labels = gh.labels; + } else { + errors.push("github.labels must be an array of strings"); + } + } + if (gh.auto_link_commits !== undefined) { + if (typeof gh.auto_link_commits === "boolean") + validGh.auto_link_commits = gh.auto_link_commits; + else errors.push("github.auto_link_commits must be a boolean"); + } + if (gh.slice_prs !== undefined) { + if (typeof gh.slice_prs === "boolean") validGh.slice_prs = gh.slice_prs; + else errors.push("github.slice_prs must be a boolean"); + } + const knownGhKeys = new Set([ + "enabled", + "repo", + "project", + "labels", + "auto_link_commits", + "slice_prs", + ]); + for (const key of Object.keys(gh)) { + if (!knownGhKeys.has(key)) { + warnings.push(`unknown github key "${key}" — ignored`); + } + } + if (Object.keys(validGh).length > 0) { + validated.github = validGh; + } + } else { + errors.push("github must be an object"); + } + } + // ─── Show Token Cost ────────────────────────────────────────────── + if (preferences.show_token_cost !== undefined) { + if (typeof preferences.show_token_cost === "boolean") { + validated.show_token_cost = preferences.show_token_cost; + } else { + errors.push("show_token_cost must be a boolean"); + } + } + // ─── Experimental Features ──────────────────────────────────────── + if (preferences.experimental !== undefined) { + if ( + typeof preferences.experimental === "object" && + preferences.experimental !== null + ) { + const exp = preferences.experimental; + const validExp = {}; + if (exp.rtk !== undefined) { + if (typeof exp.rtk === "boolean") validExp.rtk = exp.rtk; + else errors.push("experimental.rtk must be a boolean"); + } + if (exp.dispatch_rules !== undefined) { + if ( + typeof exp.dispatch_rules === "object" && + exp.dispatch_rules !== null + ) { + const rawDispatch = exp.dispatch_rules; + const validDispatch = {}; + if (rawDispatch.order !== undefined) { + if ( + Array.isArray(rawDispatch.order) && + rawDispatch.order.every((item) => typeof item === "string") + ) { + validDispatch.order = rawDispatch.order + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } else { + errors.push( + "experimental.dispatch_rules.order must be an array of strings", + ); + } + } + if (rawDispatch.variants !== undefined) { + if ( + typeof rawDispatch.variants === "object" && + rawDispatch.variants !== null && + !Array.isArray(rawDispatch.variants) + ) { + const validVariants = {}; + for (const [variantName, variantOrder] of Object.entries( + rawDispatch.variants, + )) { + if ( + !Array.isArray(variantOrder) || + variantOrder.some((item) => typeof item !== "string") + ) { + errors.push( + `experimental.dispatch_rules.variants.${variantName} must be an array of strings`, + ); + continue; + } + validVariants[variantName] = variantOrder + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + validDispatch.variants = validVariants; + } else { + errors.push( + "experimental.dispatch_rules.variants must be an object mapping variant names to string arrays", + ); + } + } + if (rawDispatch.active_variant !== undefined) { + if ( + typeof rawDispatch.active_variant === "string" && + rawDispatch.active_variant.trim().length > 0 + ) { + validDispatch.active_variant = rawDispatch.active_variant.trim(); + } else { + errors.push( + "experimental.dispatch_rules.active_variant must be a non-empty string", + ); + } + } + const knownDispatchKeys = new Set([ + "order", + "variants", + "active_variant", + ]); + for (const key of Object.keys(rawDispatch)) { + if (!knownDispatchKeys.has(key)) { + warnings.push( + `unknown experimental.dispatch_rules key "${key}" — ignored`, + ); + } + } + if (Object.keys(validDispatch).length > 0) { + validExp.dispatch_rules = validDispatch; + } + } else { + errors.push("experimental.dispatch_rules must be an object"); + } + } + const knownExpKeys = new Set(["rtk", "dispatch_rules"]); + for (const key of Object.keys(exp)) { + if (!knownExpKeys.has(key)) { + warnings.push(`unknown experimental key "${key}" — ignored`); + } + } + if (Object.keys(validExp).length > 0) { + validated.experimental = validExp; + } + } else { + errors.push("experimental must be an object"); + } + } + // ─── Codebase Map ────────────────────────────────────────────────── + if (preferences.codebase !== undefined) { + if ( + typeof preferences.codebase === "object" && + preferences.codebase !== null + ) { + const cb = preferences.codebase; + const validCb = {}; + if (cb.exclude_patterns !== undefined) { + if ( + Array.isArray(cb.exclude_patterns) && + cb.exclude_patterns.every((p) => typeof p === "string") + ) { + validCb.exclude_patterns = cb.exclude_patterns; + } else { + errors.push("codebase.exclude_patterns must be an array of strings"); + } + } + if (cb.max_files !== undefined) { + const mf = + typeof cb.max_files === "number" + ? cb.max_files + : Number(cb.max_files); + if (Number.isFinite(mf) && mf >= 1) { + validCb.max_files = Math.floor(mf); + } else { + errors.push("codebase.max_files must be a positive integer"); + } + } + if (cb.collapse_threshold !== undefined) { + const ct = + typeof cb.collapse_threshold === "number" + ? cb.collapse_threshold + : Number(cb.collapse_threshold); + if (Number.isFinite(ct) && ct >= 1) { + validCb.collapse_threshold = Math.floor(ct); + } else { + errors.push("codebase.collapse_threshold must be a positive integer"); + } + } + if (cb.indexer_backend !== undefined) { + if (cb.indexer_backend === "sift" || cb.indexer_backend === "none") { + validCb.indexer_backend = cb.indexer_backend; + } else { + errors.push( + 'codebase.indexer_backend must be one of "sift" or "none"', + ); + } + } + const knownCbKeys = new Set([ + "exclude_patterns", + "max_files", + "collapse_threshold", + "indexer_backend", + ]); + for (const key of Object.keys(cb)) { + if (!knownCbKeys.has(key)) { + warnings.push(`unknown codebase key "${key}" — ignored`); + } + } + if (Object.keys(validCb).length > 0) { + validated.codebase = validCb; + } + } else { + errors.push("codebase must be an object"); + } + } + // ─── Enhanced Verification ────────────────────────────────────────────────── + if (preferences.enhanced_verification !== undefined) { + if (typeof preferences.enhanced_verification === "boolean") { + validated.enhanced_verification = preferences.enhanced_verification; + } else { + errors.push("enhanced_verification must be a boolean"); + } + } + if (preferences.enhanced_verification_pre !== undefined) { + if (typeof preferences.enhanced_verification_pre === "boolean") { + validated.enhanced_verification_pre = + preferences.enhanced_verification_pre; + } else { + errors.push("enhanced_verification_pre must be a boolean"); + } + } + if (preferences.enhanced_verification_post !== undefined) { + if (typeof preferences.enhanced_verification_post === "boolean") { + validated.enhanced_verification_post = + preferences.enhanced_verification_post; + } else { + errors.push("enhanced_verification_post must be a boolean"); + } + } + if (preferences.enhanced_verification_strict !== undefined) { + if (typeof preferences.enhanced_verification_strict === "boolean") { + validated.enhanced_verification_strict = + preferences.enhanced_verification_strict; + } else { + errors.push("enhanced_verification_strict must be a boolean"); + } + } + // ─── Discuss Preparation ──────────────────────────────────────────── + if (preferences.discuss_preparation !== undefined) { + if (typeof preferences.discuss_preparation === "boolean") { + validated.discuss_preparation = preferences.discuss_preparation; + } else { + errors.push("discuss_preparation must be a boolean"); + } + } + // ─── Discuss Web Research ─────────────────────────────────────────── + if (preferences.discuss_web_research !== undefined) { + if (typeof preferences.discuss_web_research === "boolean") { + validated.discuss_web_research = preferences.discuss_web_research; + } else { + errors.push("discuss_web_research must be a boolean"); + } + } + // ─── Discuss Depth ────────────────────────────────────────────────── + if (preferences.discuss_depth !== undefined) { + const validDepths = new Set(["quick", "standard", "thorough"]); + if ( + typeof preferences.discuss_depth === "string" && + validDepths.has(preferences.discuss_depth) + ) { + validated.discuss_depth = preferences.discuss_depth; + } else { + errors.push(`discuss_depth must be one of: quick, standard, thorough`); + } + } + return { preferences: validated, errors, warnings }; } diff --git a/src/resources/extensions/sf/prompts/discuss-headless.md b/src/resources/extensions/sf/prompts/discuss-headless.md index 37e50f9ef..84b10b85b 100644 --- a/src/resources/extensions/sf/prompts/discuss-headless.md +++ b/src/resources/extensions/sf/prompts/discuss-headless.md @@ -76,7 +76,7 @@ Before anything else, form a diagnosis: What is the core challenge? What is brok - **Measure coverage**: find untested critical paths - **Scan for dead code, stubs, and commented-out features** — abandoned attempts are signals - **Discover needed skills**: identify repo languages, frameworks, data stores, external services, build tools, and domain-specific competencies. Check installed skills first; record installed, missing, and potentially useful skills in `.sf/CODEBASE.md` and `.sf/PM-STRATEGY.md`. -- **Use code intelligence**: start with `.sf/CODEBASE.md`, in-process `grep`/`find`/`ls`, and `lsp` for broad orientation. Use `codebase_search` or `sift_search` only with a scoped path and only when the `PROJECT CODE INTELLIGENCE` block says Sift is healthy enough for this repo; if Sift is degraded, slow, empty, or timing out, keep using grep/find/ls and direct reads. Use Project RAG tools first for broad retrieval if Project RAG is configured. +- **Use code intelligence**: start with in-process `grep`/`find`/`ls` and `lsp` for broad orientation. Use scoped `codebase_search` or `sift_search` as the live code index when the `PROJECT CODE INTELLIGENCE` block says Sift is healthy enough for this repo. Use `.sf/CODEBASE.md` only as fallback context when Sift is unavailable, cold, degraded, or explicitly needed as a generated overview. If Sift is degraded, slow, empty, or timing out, keep using grep/find/ls, lsp, direct reads, and fallback CODEBASE context. - Use in-process `grep`, `find`, `ls`, and `lsp` before shelling out. Fall back to shell `rg`, `find`, `ast-grep`, or `ls -la` only when the native/in-process tool surface is insufficient. ### Step 2: Check library and ecosystem facts @@ -311,7 +311,7 @@ After writing final context and roadmap, say exactly: "Milestone {{milestoneId}} - **Preserve the specification's terminology** — don't paraphrase domain-specific language - **Document assumptions** — every judgment call gets noted in CONTEXT.md under "Assumptions" with reasoning - **Investigate thoroughly** — scout codebase, check library docs, web search. Same rigor as interactive mode. -- **Build project knowledge first** — update `.sf/CODEBASE.md` with stack signals, critical paths, verification commands, skill needs, file descriptions, and unresolved gaps before writing context. +- **Build project knowledge first** — use Sift/grep/lsp evidence to identify stack signals, critical paths, verification commands, skill needs, file descriptions, and unresolved gaps before writing context. Update `.sf/CODEBASE.md` only when you need a refreshed durable fallback snapshot. - **Do focused research** — identify table stakes, domain standards, omissions, scope traps. Same rigor as interactive mode. - **Use proper tools** — `sf_plan_milestone` for roadmaps, `sf_decision_save` for decisions, `sf_milestone_generate_id` for IDs - **Print artifacts in chat** — requirements table, roadmap preview, depth summary. The TUI scrollback is the user's audit trail. diff --git a/src/resources/extensions/sf/prompts/discuss.md b/src/resources/extensions/sf/prompts/discuss.md index ed42cb894..7ca493d26 100644 --- a/src/resources/extensions/sf/prompts/discuss.md +++ b/src/resources/extensions/sf/prompts/discuss.md @@ -34,7 +34,7 @@ After reflection is confirmed, decide the approach based on the actual scope — Before asking your first question, do a mandatory investigation pass. This is not optional. -1. **Scout the codebase** — start with in-process `grep`, `find`, `ls`, `.sf/CODEBASE.md`, and `lsp` for broad orientation. Use `codebase_search` or `sift_search` only with a scoped path and only when the `PROJECT CODE INTELLIGENCE` block says Sift is healthy enough for this repo; if Sift is degraded, slow, empty, or timing out, keep using grep/find/ls and direct reads. Use `scout` for broad unfamiliar areas that need a separate explorer. Understand what already exists, what patterns are established, what constraints current code imposes. +1. **Scout the codebase** — start with in-process `grep`, `find`, `ls`, and `lsp` for broad orientation. Use scoped `codebase_search` or `sift_search` as the live code index when the `PROJECT CODE INTELLIGENCE` block says Sift is healthy enough for this repo. Use `.sf/CODEBASE.md` only as durable fallback context when Sift is unavailable, cold, degraded, or explicitly needed as a generated overview. If Sift is degraded, slow, empty, or timing out, keep using grep/find/ls, lsp, direct reads, and fallback CODEBASE context. Use `scout` for broad unfamiliar areas that need a separate explorer. Understand what already exists, what patterns are established, what constraints current code imposes. 2. **Check library docs — DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) as the default for any GitHub-hosted library or framework the user mentioned. Fall back to `resolve_library` / `get_library_docs` (Context7) for npm/pypi/crates packages DeepWiki doesn't have. **Context7 free tier is capped at 1000 req/month — spend those on cases DeepWiki can't cover.** Get current facts about capabilities, constraints, API shapes, version-specific behavior. 3. **Web search** — `search-the-web` if the domain is unfamiliar, if you need current best practices, or if the user referenced external services/APIs you need facts about. Use `fetch_page` for full content when snippets aren't enough. diff --git a/src/resources/extensions/sf/prompts/guided-discuss-milestone.md b/src/resources/extensions/sf/prompts/guided-discuss-milestone.md index cf29a89db..88a8c58a2 100644 --- a/src/resources/extensions/sf/prompts/guided-discuss-milestone.md +++ b/src/resources/extensions/sf/prompts/guided-discuss-milestone.md @@ -15,8 +15,7 @@ Apply `pm-planning` skill thinking throughout: use Working Backwards to anchor o ### Before your first question round Do a lightweight targeted investigation so your questions are grounded in reality: -- Scout the codebase: start with in-process `grep`, `find`, `ls`, `.sf/CODEBASE.md`, and `lsp` for broad orientation. Use `codebase_search` or `sift_search` only with a scoped path and only when Sift is healthy for this repo; if Sift is degraded, slow, empty, or timing out, keep using grep/find/ls and direct reads. Use `scout` for broad unfamiliar areas that need a separate explorer. -- If the `PROJECT CODE INTELLIGENCE` block says Project RAG is configured, use its MCP search tools for broad concept, symbol, schema, and git-history lookup before manually reading files +- Scout the codebase: start with in-process `grep`, `find`, `ls`, and `lsp` for broad orientation. Use scoped `codebase_search` or `sift_search` as the live code index when Sift is healthy for this repo. Use `.sf/CODEBASE.md` only as durable fallback context when Sift is unavailable, cold, degraded, or explicitly needed as a generated overview. If Sift is degraded, slow, empty, or timing out, keep using grep/find/ls, lsp, direct reads, and fallback CODEBASE context. Use `scout` for broad unfamiliar areas that need a separate explorer. - Check the roadmap context above (if present) to understand what surrounds this milestone - **Library docs — DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) for any GitHub-hosted library. Fall back to `resolve_library` / `get_library_docs` (Context7) only when DeepWiki doesn't have it (Context7 is capped at 1000 req/month free tier). - Identify the 3–5 biggest behavioural and architectural unknowns: things where the user's answer will materially change what gets built diff --git a/src/resources/extensions/sf/prompts/guided-discuss-slice.md b/src/resources/extensions/sf/prompts/guided-discuss-slice.md index afd57ee8a..a1fab588d 100644 --- a/src/resources/extensions/sf/prompts/guided-discuss-slice.md +++ b/src/resources/extensions/sf/prompts/guided-discuss-slice.md @@ -11,7 +11,7 @@ Your goal is **not** to center the discussion on tech stack trivia, naming conve ### Before your first question round Do a lightweight targeted investigation so your questions are grounded in reality: -- Scout the codebase: start with in-process `grep`, `find`, `ls`, `.sf/CODEBASE.md`, and `lsp` for broad orientation. Use `codebase_search` or `sift_search` only with a scoped path and only when Sift is healthy for this repo; if Sift is degraded, slow, empty, or timing out, keep using grep/find/ls and direct reads. Use `scout` for broad unfamiliar areas that need a separate explorer. +- Scout the codebase: start with in-process `grep`, `find`, `ls`, and `lsp` for broad orientation. Use scoped `codebase_search` or `sift_search` as the live code index when Sift is healthy for this repo. Use `.sf/CODEBASE.md` only as durable fallback context when Sift is unavailable, cold, degraded, or explicitly needed as a generated overview. If Sift is degraded, slow, empty, or timing out, keep using grep/find/ls, lsp, direct reads, and fallback CODEBASE context. Use `scout` for broad unfamiliar areas that need a separate explorer. - Check the roadmap context above to understand what surrounds this slice — what comes before, what depends on it - **Library docs — DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) for any GitHub-hosted library. Fall back to `resolve_library` / `get_library_docs` (Context7) only when DeepWiki doesn't have it (Context7 is capped at 1000 req/month free tier). - Identify the 3–5 biggest behavioural unknowns: things where the user's answer will materially change what gets built diff --git a/src/resources/extensions/sf/prompts/queue.md b/src/resources/extensions/sf/prompts/queue.md index fafb8de8a..7964034dc 100644 --- a/src/resources/extensions/sf/prompts/queue.md +++ b/src/resources/extensions/sf/prompts/queue.md @@ -26,7 +26,7 @@ Never fabricate or simulate user input during this discussion. Never generate fa - Check library docs **DeepWiki first** (`ask_question` / `read_wiki_structure` / `read_wiki_contents`) for any GitHub-hosted library or framework — AI-indexed, no free-tier cap. Fall back to Context7 (`resolve_library` / `get_library_docs`) for npm/pypi/crates packages DeepWiki doesn't cover. Context7 free tier is 1000 req/month — don't spend those on cases DeepWiki covers. - Do web searches (`search-the-web`) to verify the landscape — what solutions exist, what's changed recently, what's the current best practice. Use `freshness` for recency-sensitive queries, `domain` to target specific sites. Use `fetch_page` to read the full content of promising URLs when snippets aren't enough. **Budget:** You have a limited number of web searches per turn (typically 3-5). Prefer DeepWiki → Context7 → web search for docs; use `search_and_read` for one-shot topic research. Do NOT repeat the same or similar queries. Distribute searches across turns rather than clustering them. -- Scout the codebase: start with in-process `grep`, `find`, `ls`, `.sf/CODEBASE.md`, and `lsp` for broad orientation. Use `codebase_search` or `sift_search` only with a scoped path and only when Sift is healthy for this repo; if Sift is degraded, slow, empty, or timing out, keep using grep/find/ls and direct reads. Use `scout` for broad unfamiliar areas that need a separate explorer. Understand what already exists, what patterns are established, what constraints current code imposes. +- Scout the codebase: start with in-process `grep`, `find`, `ls`, and `lsp` for broad orientation. Use scoped `codebase_search` or `sift_search` as the live code index when Sift is healthy for this repo. Use `.sf/CODEBASE.md` only as durable fallback context when Sift is unavailable, cold, degraded, or explicitly needed as a generated overview. If Sift is degraded, slow, empty, or timing out, keep using grep/find/ls, lsp, direct reads, and fallback CODEBASE context. Use `scout` for broad unfamiliar areas that need a separate explorer. Understand what already exists, what patterns are established, what constraints current code imposes. Don't go deep — just enough that your next question reflects what's actually true rather than what you assume. diff --git a/src/resources/extensions/sf/prompts/system.md b/src/resources/extensions/sf/prompts/system.md index ec3e525a4..e6f4ce82f 100644 --- a/src/resources/extensions/sf/prompts/system.md +++ b/src/resources/extensions/sf/prompts/system.md @@ -76,7 +76,7 @@ Titles live inside file content (headings, frontmatter), not in file or director REQUIREMENTS.md (requirement contract - tracks active/validated/deferred/out-of-scope) DECISIONS.md (append-only register of architectural and pattern decisions) KNOWLEDGE.md (append-only register of project-specific rules, patterns, and lessons learned) - CODEBASE.md (generated codebase map cache — auto-refreshed when tracked files change) + CODEBASE.md (generated fallback codebase map cache — auto-refreshed when tracked files change) OVERRIDES.md (user-issued overrides that supersede plan content via /sf steer) QUEUE.md (append-only log of queued milestones via /sf queue) STATE.md @@ -119,7 +119,7 @@ In all modes, slices commit sequentially on the active branch; there are no per- - **REQUIREMENTS.md** tracks the requirement contract — requirements move between Active, Validated, Deferred, Blocked, and Out of Scope as slices prove or invalidate them. Update at slice completion when evidence supports a status change. - **DECISIONS.md** is an append-only register of architectural and pattern decisions - read it during planning/research, append to it during execution when a meaningful decision is made - **KNOWLEDGE.md** is an append-only register of project-specific rules, patterns, and lessons learned. Read it at the start of every unit. Append to it when you discover a recurring issue, a non-obvious pattern, or a rule that future agents should follow. -- **CODEBASE.md** is a generated structural cache of the tracked repository. SF auto-refreshes it when tracked files change and injects it into system context when available. Use `/sf codebase update` only when you need to force an immediate refresh. +- **CODEBASE.md** is a generated fallback snapshot of the tracked repository. SF may inject it when available, but healthy Sift is the preferred live code index. Use CODEBASE only when Sift is unavailable, cold, degraded, or when you need a durable overview. Use `/sf codebase update` only when you need to force an immediate refresh. - **CONTEXT.md** files (milestone or slice level) capture the brief — scope, goals, constraints, and key decisions from discussion. When present, they are the authoritative source for what a milestone or slice is trying to achieve. Read them before planning or executing. - **Milestones** are major project phases (M001, M002, ...) - **Slices** are demoable vertical increments (S01, S02, ...) ordered by risk. After each slice completes, the roadmap is reassessed before the next slice begins. @@ -147,7 +147,7 @@ Templates showing the expected format for each artifact type are in: - `/sf status` - progress dashboard overlay - `/sf queue` - queue future milestones (safe while auto-mode is running) - `/sf quick <task>` - quick task with SF guarantees (atomic commits, state tracking) but no milestone ceremony -- `/sf codebase [generate|update|stats|rag]` - manage `.sf/CODEBASE.md` and optional code search +- `/sf codebase [generate|update|stats|indexer]` - manage fallback `.sf/CODEBASE.md` and Sift code search - `{{shortcutDashboard}}` - toggle dashboard overlay - `{{shortcutShell}}` - show shell processes @@ -161,7 +161,7 @@ Templates showing the expected format for each artifact type are in: **Code navigation:** Use `lsp` for definition, type_definition, implementation, references, incoming_calls, outgoing_calls, hover, signature, symbols, rename, code_actions, format, and diagnostics. Falls back gracefully if no server is available. Never `grep` for a symbol definition when `lsp` can resolve it semantically. Never shell out to prettier/rustfmt/gofmt when `lsp format` is available. After editing code, use `lsp diagnostics` to verify no type errors were introduced. -**Codebase exploration:** Start broad orientation with in-process `grep`, `find`, `ls`, `.sf/CODEBASE.md`, and `lsp`. Use `codebase_search` for conceptual, behavioral, or architectural discovery only after choosing a narrow scope and checking the `PROJECT CODE INTELLIGENCE` block; if Sift is degraded, slow, empty, or timing out, keep using grep/find/ls and direct reads. For Sift-specific features — explicit strategy selection or planner configuration — use `sift_search` with a scoped `path`. Strategy guide: `bm25` (fast lexical), `path-hybrid` (filename/path-heavy queries), `page-index-hybrid` (stronger recall + reranking), `vector` (semantic-only). Each repo uses its own Sift cache under `.sf/runtime/sift/`; do not rely on a shared/global Sift database. Use `lsp` for structural navigation (definitions, references). If the `PROJECT CODE INTELLIGENCE` block says Project RAG is configured, use its MCP tools for broad hybrid semantic + BM25 code retrieval before manual file-by-file reading. Never read files one-by-one to "explore" — search first, then read what's relevant. +**Codebase exploration:** Start broad orientation with in-process `grep`, `find`, `ls`, and `lsp`. When the `PROJECT CODE INTELLIGENCE` block says Sift is healthy, use scoped `codebase_search` or `sift_search` as the preferred live code index. Use `.sf/CODEBASE.md` only as fallback context when Sift is unavailable, cold, degraded, or explicitly needed as a generated overview. For Sift-specific features — explicit strategy selection or planner configuration — use `sift_search` with a scoped `path`. Strategy guide: `bm25` (fast lexical), `path-hybrid` (filename/path-heavy queries), `page-index-hybrid` (stronger recall + reranking), `vector` (semantic-only). Each repo uses its own Sift cache under `.sf/runtime/sift/`; do not rely on a shared/global Sift database. Use `lsp` for structural navigation (definitions, references). Never read files one-by-one to "explore" — search first, then read what's relevant. **Swarm dispatch:** Let the system decide whether swarming fits before dispatching multiple execution subagents. Use a 2-3 worker same-model swarm only when the work splits into independent shards with explicit file/directory ownership, shard-local verification, low conflict risk, and clear wall-clock savings. Do not swarm shared-interface edits, lockfiles, migrations, single-failure debugging, or sequence-dependent work. The parent agent remains coordinator: assign ownership, synthesize results, inspect dirty files, resolve conflicts, and run final verification.