diff --git a/src/resources/extensions/sf/agentic-docs-scaffold.ts b/src/resources/extensions/sf/agentic-docs-scaffold.ts index a3cc7ab73..f4cf0c397 100644 --- a/src/resources/extensions/sf/agentic-docs-scaffold.ts +++ b/src/resources/extensions/sf/agentic-docs-scaffold.ts @@ -446,6 +446,45 @@ Graders are reusable scripts or prompts that score eval outputs. - LLM-judge graders: prompt templates that ask a model to score free-text output against a rubric. Prefer code-based graders. Add LLM-judge graders only when deterministic checking is impossible. +`, + }, + { + path: ".sf/PRINCIPLES.md", + content: `# Principles + +Durable design philosophy. Things this codebase believes are true. + +Add entries as you make decisions. Each entry: 1-2 sentences. Cite the rationale (the why, not just the what). + +## Examples + +- (replace with your own) +`, + }, + { + path: ".sf/TASTE.md", + content: `# Taste + +What good code looks like here. Idioms, conventions, "we prefer X over Y" calls. + +Add entries as you notice patterns worth preserving. Each entry: 1-2 sentences with a concrete example. + +## Examples + +- (replace with your own) +`, + }, + { + path: ".sf/ANTI-GOALS.md", + content: `# Anti-goals + +What we explicitly DON'T want. Things that look attractive but we've decided against. + +This is gold — most wrong agent calls come from not knowing what to avoid. Each entry: 1-2 sentences with the rationale. + +## Examples + +- (replace with your own) `, }, ]; diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index 726376461..0ec329ff1 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -55,8 +55,10 @@ import { classifyCommand } from "../safety/destructive-guard.js"; import { recordToolCall as safetyRecordToolCall, recordToolResult as safetyRecordToolResult, + saveEvidenceToDisk, } from "../safety/evidence-collector.js"; import { deriveState } from "../state.js"; +import { parseUnitId } from "../unit-id.js"; import { countGoogleGeminiCliTokens } from "../token-counter.js"; import { logWarning as safetyLogWarning } from "../workflow-logger.js"; import { @@ -507,6 +509,20 @@ export function registerHooks( event.input as Record, ); + // Persist evidence immediately at dispatch so a mid-unit session restart + // (resetEvidence() + loadEvidenceFromDisk()) cannot wipe the entry between + // tool_call and tool_execution_end. Without this the "no bash calls" false + // positive fires when the LLM clearly ran a verification command (Bug #4385). + const callDash = getAutoDashboardData(); + if (callDash.basePath && callDash.currentUnit?.type === "execute-task") { + const { milestone: cMid, slice: cSid, task: cTid } = parseUnitId( + callDash.currentUnit.id, + ); + if (cMid && cSid && cTid) { + saveEvidenceToDisk(callDash.basePath, cMid, cSid, cTid); + } + } + // Destructive command classification (warn only, never block) if (isToolCallEventType("bash", event)) { const classification = classifyCommand(event.input.command); @@ -659,6 +675,17 @@ export function registerHooks( event.result, event.isError, ); + // Persist evidence to disk after each tool result so it survives a session + // restart mid-unit (Bug #4385 — non-persisted evidence false positives). + const endDash = getAutoDashboardData(); + if (endDash.basePath && endDash.currentUnit?.type === "execute-task") { + const { milestone: pMid, slice: pSid, task: pTid } = parseUnitId( + endDash.currentUnit.id, + ); + if (pMid && pSid && pTid) { + saveEvidenceToDisk(endDash.basePath, pMid, pSid, pTid); + } + } } }); diff --git a/src/resources/extensions/sf/bootstrap/system-context.ts b/src/resources/extensions/sf/bootstrap/system-context.ts index 49e01deea..5c689167e 100644 --- a/src/resources/extensions/sf/bootstrap/system-context.ts +++ b/src/resources/extensions/sf/bootstrap/system-context.ts @@ -239,6 +239,7 @@ export async function buildBeforeAgentStartResult( process.cwd(), ); const architectureBlock = loadArchitectureBlock(process.cwd()); + const tacitKnowledgeBlock = loadTacitKnowledgeBlock(process.cwd()); if (globalSizeKb > 4) { ctx.ui.notify( `SF: ~/.sf/agent/KNOWLEDGE.md is ${globalSizeKb.toFixed(1)}KB — consider trimming to keep system prompt lean.`, @@ -357,7 +358,7 @@ export async function buildBeforeAgentStartResult( // stronger language that forbids ask_user_questions entirely. const escalationPolicyBlock = buildEscalationPolicyBlock(isCanAskUser()); - const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — SF]\n\n${escalationPolicyBlock}${systemContent}${preferenceBlock}${knowledgeBlock}${architectureBlock}${codebaseBlock}${codeIntelligenceBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}${repositoryVcsBlock}${modelIdentityBlock}${subagentModelBlock}`; + const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — SF]\n\n${escalationPolicyBlock}${systemContent}${preferenceBlock}${knowledgeBlock}${architectureBlock}${tacitKnowledgeBlock}${codebaseBlock}${codeIntelligenceBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}${repositoryVcsBlock}${modelIdentityBlock}${subagentModelBlock}`; stopContextTimer({ systemPromptSize: fullSystem.length, @@ -425,6 +426,49 @@ export function loadKnowledgeBlock( }; } +const TACIT_SECTION_MAX_BYTES = 4096; + +/** + * Load tacit knowledge files (.sf/PRINCIPLES.md, .sf/TASTE.md, .sf/ANTI-GOALS.md) + * into a single block injected after the architecture block. + * + * Each section is capped at 4 KB. Sections are skipped silently when the + * corresponding file is missing or empty. Scaffold markers () + * and YAML frontmatter are stripped so the agent only sees authored content. + */ +export function loadTacitKnowledgeBlock(cwd: string): string { + const sfDir = join(cwd, ".sf"); + + function readSection(filename: string): string { + const filePath = join(sfDir, filename); + const raw = cachedReadFile(filePath)?.trim() ?? ""; + if (!raw) return ""; + // Strip scaffold markers (HTML comments like ) + const stripped = raw + .replace(//g, "") + .trim(); + if (!stripped) return ""; + const bytes = Buffer.byteLength(stripped, "utf-8"); + if (bytes > TACIT_SECTION_MAX_BYTES) { + const truncated = stripped.slice(0, TACIT_SECTION_MAX_BYTES); + return truncated + "\n\n*(truncated — see .sf/" + filename + " for full content)*"; + } + return stripped; + } + + const principles = readSection("PRINCIPLES.md"); + const taste = readSection("TASTE.md"); + const antiGoals = readSection("ANTI-GOALS.md"); + + if (!principles && !taste && !antiGoals) return ""; + + const parts: string[] = ["[TACIT KNOWLEDGE — read carefully]"]; + if (principles) parts.push(`\n## Principles\n\n${principles}`); + if (taste) parts.push(`\n## Taste\n\n${taste}`); + if (antiGoals) parts.push(`\n## Anti-goals\n\n${antiGoals}`); + return `\n\n${parts.join("\n")}`; +} + /** * Load ARCHITECTURE.md from the project root into context. Capped at 8 000 chars * to avoid bloating every request — full file is always readable on disk. diff --git a/src/resources/extensions/sf/commands/catalog.ts b/src/resources/extensions/sf/commands/catalog.ts index b9e40cdc3..d324dad4c 100644 --- a/src/resources/extensions/sf/commands/catalog.ts +++ b/src/resources/extensions/sf/commands/catalog.ts @@ -79,6 +79,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [ { 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)" }, { @@ -279,6 +280,12 @@ const NESTED_COMPLETIONS: CompletionMap = { 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" }, diff --git a/src/resources/extensions/sf/commands/handlers/core.ts b/src/resources/extensions/sf/commands/handlers/core.ts index 684b5bc0e..2029ebe2a 100644 --- a/src/resources/extensions/sf/commands/handlers/core.ts +++ b/src/resources/extensions/sf/commands/handlers/core.ts @@ -120,6 +120,7 @@ export function showHelp(ctx: ExtensionCommandContext, args = ""): void { " /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)", diff --git a/src/resources/extensions/sf/commands/handlers/ops.ts b/src/resources/extensions/sf/commands/handlers/ops.ts index e299199e8..375b102a3 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.ts +++ b/src/resources/extensions/sf/commands/handlers/ops.ts @@ -370,5 +370,18 @@ Examples: ); return true; } + if ( + trimmed === "worktree" || + trimmed.startsWith("worktree ") || + trimmed === "wt" || + trimmed.startsWith("wt ") + ) { + const { handleWorktree } = await import("../../commands-worktree.js"); + await handleWorktree( + trimmed.replace(/^(worktree|wt)\s*/, "").trim(), + ctx, + ); + return true; + } return false; } diff --git a/src/resources/extensions/sf/deep-project-setup-policy.ts b/src/resources/extensions/sf/deep-project-setup-policy.ts index addf0cee3..de98ab35d 100644 --- a/src/resources/extensions/sf/deep-project-setup-policy.ts +++ b/src/resources/extensions/sf/deep-project-setup-policy.ts @@ -132,8 +132,13 @@ export function resolveDeepProjectSetupState( reason: ".sf/PROJECT.md is missing.", }; } - // TODO: validateArtifact not yet ported — skip validation for now - // if (!validateArtifact(projectPath, "project").ok) { ... } + if (!validateArtifact(projectPath, "project").ok) { + return { + status: "pending", + stage: "project", + reason: ".sf/PROJECT.md is invalid.", + }; + } const requirementsPath = join(root, "REQUIREMENTS.md"); if (!existsSync(requirementsPath)) { @@ -143,8 +148,13 @@ export function resolveDeepProjectSetupState( reason: ".sf/REQUIREMENTS.md is missing.", }; } - // TODO: validateArtifact not yet ported — skip validation for now - // if (!validateArtifact(requirementsPath, "requirements").ok) { ... } + if (!validateArtifact(requirementsPath, "requirements").ok) { + return { + status: "pending", + stage: "requirements", + reason: ".sf/REQUIREMENTS.md is invalid.", + }; + } const marker = readDecision(basePath); if (!marker.exists) { @@ -179,11 +189,32 @@ export function resolveDeepProjectSetupState( }; } - // TODO: getProjectResearchStatus not yet ported — treat as complete when research decision is explicit - // const researchStatus = getProjectResearchStatus(basePath); - // if (researchStatus.globalBlocker) { ... } - // if (researchStatus.allDimensionBlockers) { ... } - // if (!researchStatus.complete) { ... } + const researchStatus = getProjectResearchStatus(basePath); + if (researchStatus.globalBlocker) { + return { + status: "blocked", + stage: "project-research", + reason: + "Project research wrote PROJECT-RESEARCH-BLOCKER.md, so no verified research exists. Fix the blocker cause, delete the blocker, and rerun auto.", + }; + } + if (researchStatus.allDimensionBlockers) { + return { + status: "blocked", + stage: "project-research", + reason: + "Project research produced only dimension blocker files, so no usable research exists. Fix the blocker cause, delete the dimension blocker files in `.sf/research/`, and rerun auto.", + }; + } + if (!researchStatus.complete) { + return { + status: "pending", + stage: "project-research", + reason: researchStatus.missingDimensions.length > 0 + ? `Project research is missing dimensions: ${researchStatus.missingDimensions.join(", ")}.` + : "Project research has not produced a verified research set.", + }; + } return { status: "complete", diff --git a/src/resources/extensions/sf/milestone-framing-check.ts b/src/resources/extensions/sf/milestone-framing-check.ts new file mode 100644 index 000000000..8cc805e14 --- /dev/null +++ b/src/resources/extensions/sf/milestone-framing-check.ts @@ -0,0 +1,268 @@ +/** + * Milestone framing check — pre-flight sanity check run before milestone work begins. + * + * Reviews: + * 1. Milestone CONTEXT.md / title against PROJECT.md vision + * 2. .sf/ANTI-GOALS.md — does this milestone violate any? + * 3. Category error: is this solving the right problem? + * + * Non-blocking: findings are surfaced as structured annotations. + * The agent reads them, considers, and proceeds (or stops on severity=block). + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { sfRoot } from "./paths.js"; + +export interface MilestoneFramingFinding { + concern: string; + source: "project_vision" | "anti_goal" | "category_error"; + severity: "info" | "warning" | "block"; +} + +/** + * Check milestone framing against project vision, anti-goals, and category-error heuristics. + * + * Reads: + * - /PROJECT.md (vision) + * - /.sf/ANTI-GOALS.md + * - /.sf/milestones//-CONTEXT.md (or CONTEXT.md) + * + * @param basePath - project root (cwd) + * @param milestoneId - milestone ID string (e.g. "M001") + * @returns array of findings, empty when nothing notable + */ +export function checkMilestoneFraming( + basePath: string, + milestoneId: string, +): MilestoneFramingFinding[] { + const findings: MilestoneFramingFinding[] = []; + + // ── Load inputs ────────────────────────────────────────────────────────── + + const projectMdPath = join(basePath, "PROJECT.md"); + const projectMd = safeRead(projectMdPath); + + const sfDir = sfRoot(basePath); + const antiGoalsPath = join(sfDir, "ANTI-GOALS.md"); + const antiGoalsMd = safeRead(antiGoalsPath); + + // Try to find milestone context file + const milestonePath = join(sfDir, "milestones", milestoneId); + const contextCandidates = [ + join(milestonePath, `${milestoneId}-CONTEXT.md`), + join(milestonePath, "CONTEXT.md"), + ]; + let contextMd = ""; + for (const candidate of contextCandidates) { + const content = safeRead(candidate); + if (content) { + contextMd = content; + break; + } + } + + if (!contextMd) return findings; // nothing to check + + const contextLower = contextMd.toLowerCase(); + + // ── Anti-goal keyword check ────────────────────────────────────────────── + + if (antiGoalsMd) { + const antiGoalLines = extractBulletLines(antiGoalsMd); + for (const line of antiGoalLines) { + const keywords = extractKeywords(line); + const matched = keywords.filter((kw) => contextLower.includes(kw)); + if (matched.length > 0) { + findings.push({ + concern: `Milestone description contains "${matched[0]}" — ANTI-GOALS.md entry: "${line.slice(0, 120)}"`, + source: "anti_goal", + severity: "warning", + }); + } + } + } + + // ── Project vision alignment check ─────────────────────────────────────── + + if (projectMd) { + const visionKeywords = extractKeywords(projectMd.slice(0, 2000)); + const overlap = visionKeywords.filter((kw) => contextLower.includes(kw)); + if (overlap.length === 0) { + findings.push({ + concern: `Milestone context has no clear overlap with PROJECT.md vision keywords — verify this milestone advances the stated project goals.`, + source: "project_vision", + severity: "info", + }); + } + } + + // ── Completed milestone overlap check ──────────────────────────────────── + + try { + const milestoneIds = listMilestoneDirs(sfDir).filter( + (e) => /^M\d+/.test(e) && e !== milestoneId, + ); + const contextTitle = extractTitle(contextMd); + for (const otherId of milestoneIds) { + const otherContextCandidates = [ + join(sfDir, "milestones", otherId, `${otherId}-CONTEXT.md`), + join(sfDir, "milestones", otherId, "CONTEXT.md"), + ]; + for (const candidate of otherContextCandidates) { + const otherContext = safeRead(candidate); + if (otherContext) { + const otherTitle = extractTitle(otherContext); + if ( + contextTitle && + otherTitle && + titlesOverlap(contextTitle, otherTitle) + ) { + findings.push({ + concern: `Milestone title "${contextTitle}" is similar to existing milestone ${otherId} "${otherTitle}" — verify this is not duplicate or already-completed work.`, + source: "category_error", + severity: "warning", + }); + } + break; + } + } + } + } catch { + // Non-fatal; skip overlap check if directory enumeration fails + } + + return findings; +} + +/** + * Format framing findings into a system-prompt block. + * Returns empty string when there are no findings. + */ +export function formatFramingFindings( + milestoneId: string, + findings: MilestoneFramingFinding[], +): string { + if (findings.length === 0) return ""; + + const lines = findings.map((f) => { + const label = f.severity.toUpperCase(); + return `- ${label}: ${f.concern}`; + }); + + return `\n\n[MILESTONE FRAMING CHECK — ${milestoneId}]\n${lines.join("\n")}`; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function safeRead(filePath: string): string { + if (!existsSync(filePath)) return ""; + try { + return readFileSync(filePath, "utf-8").trim(); + } catch { + return ""; + } +} + +/** + * Extract non-empty bullet lines (starting with - or *) from markdown text. + */ +function extractBulletLines(text: string): string[] { + return text + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("- ") || l.startsWith("* ")) + .map((l) => l.slice(2).trim()) + .filter((l) => l.length > 5); +} + +/** + * Extract significant lowercase keywords (length >= 5, non-common) from text. + * Used for fuzzy matching between milestone context and anti-goals / vision. + */ +function extractKeywords(text: string): string[] { + const stopwords = new Set([ + "the", + "this", + "that", + "with", + "from", + "have", + "when", + "what", + "will", + "been", + "each", + "they", + "them", + "some", + "also", + "into", + "more", + "only", + "over", + "such", + "than", + "then", + "there", + "these", + "those", + "about", + "after", + "before", + "where", + "which", + "while", + "should", + "would", + "could", + "because", + ]); + return [ + ...new Set( + text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .split(/\s+/) + .filter((w) => w.length >= 5 && !stopwords.has(w)), + ), + ]; +} + +function extractTitle(text: string): string | null { + const match = text.match(/^#\s+(.+)$/m); + return match ? match[1].trim() : null; +} + +function titlesOverlap(a: string, b: string): boolean { + const wordsA = new Set( + a + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length >= 5), + ); + const wordsB = b + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length >= 5); + const shared = wordsB.filter((w) => wordsA.has(w)); + return shared.length >= 2; +} + +/** + * Minimal synchronous readdirSync wrapper to enable overriding in tests + * and to keep the try/catch tidy in the caller. + */ +function await_readdirSync(sfDir: string): { readdirSync: string[] } { + const { readdirSync: rds } = require("node:fs") as typeof import("node:fs"); + const milestonesDir = join(sfDir, "milestones"); + if (!existsSync(milestonesDir)) return { readdirSync: [] }; + try { + return { + readdirSync: rds(milestonesDir, { withFileTypes: false }) as string[], + }; + } catch { + return { readdirSync: [] }; + } +} diff --git a/src/resources/extensions/sf/project-research-policy.ts b/src/resources/extensions/sf/project-research-policy.ts index 97b9a1fe8..e463cff18 100644 --- a/src/resources/extensions/sf/project-research-policy.ts +++ b/src/resources/extensions/sf/project-research-policy.ts @@ -8,7 +8,7 @@ import { import { clearParseCache } from "./files.js"; import { sfRoot, clearPathCache } from "./paths.js"; import { parseProject, parseRequirements } from "./schemas/parsers.js"; -import type { ParsedProject, ParsedRequirements } from "./schemas/parsers.js"; +import type { ParsedProject, ParsedRequirements, ParsedRequirement } from "./schemas/parsers.js"; export const PROJECT_RESEARCH_DIMENSIONS = ["STACK", "FEATURES", "ARCHITECTURE", "PITFALLS"] as const; export const PROJECT_RESEARCH_BLOCKER = "PROJECT-RESEARCH-BLOCKER.md"; @@ -100,14 +100,14 @@ export function classifyProjectResearchScope( ): ProjectResearchClassification { const project = parseProject(projectContent); const requirements = parseRequirements(requirementsContent); - const activeRequirements = requirements.requirements.filter((r: ParsedRequirements) => + const activeRequirements = requirements.requirements.filter((r: ParsedRequirement) => r.status === "active" || r.parentSection === "Active" ); - const activeCapabilities = activeRequirements.filter((r: ParsedRequirements) => + const activeCapabilities = activeRequirements.filter((r: ParsedRequirement) => r.class !== "constraint" && r.class !== "anti-feature" ); const requirementCoverage = activeRequirements - .map((r: ParsedRequirements) => [ + .map((r: ParsedRequirement) => [ r.id, r.title, r.class, @@ -120,8 +120,8 @@ export function classifyProjectResearchScope( const result = classifyMilestoneScope({ title: markdownTitle(projectContent), vision: selectedSections(project.sections), - successCriteria: activeCapabilities.map((r: ParsedRequirements) => `${r.title}: ${r.description}`), - definitionOfDone: activeCapabilities.map((r: ParsedRequirements) => r.validation).filter(Boolean), + successCriteria: activeCapabilities.map((r: ParsedRequirement) => `${r.title}: ${r.description}`), + definitionOfDone: activeCapabilities.map((r: ParsedRequirement) => r.validation).filter(Boolean), requirementCoverage: [ requirementCoverage, Object.entries(requirements.coverageSummary) diff --git a/src/resources/extensions/sf/schemas/__fixtures__/valid-project.md b/src/resources/extensions/sf/schemas/__fixtures__/valid-project.md index 79d036c64..4f3ae558b 100644 --- a/src/resources/extensions/sf/schemas/__fixtures__/valid-project.md +++ b/src/resources/extensions/sf/schemas/__fixtures__/valid-project.md @@ -18,7 +18,7 @@ TypeScript monorepo with markdown artifacts, AJV-style validators, parser-then-s ## Capability Contract -See `.gsd/REQUIREMENTS.md` for the explicit capability contract. +See `.sf/REQUIREMENTS.md` for the explicit capability contract. ## Milestone Sequence