feat(sf): seed .sf/PRINCIPLES.md, TASTE.md, ANTI-GOALS.md (PDD-anchored)

Tacit knowledge files captured in tracked .sf/ artifacts (per ADR-001):
- PRINCIPLES.md: durable design philosophy, with PDD as the canonical
  change method (purpose / consumer / contract / failure boundary /
  evidence / non-goals / invariants — all 7 fields required)
- TASTE.md: what good code looks like in SF — verbose names, domain >
  layer, behavior-is-the-spec, minimum change, idempotent dispatch,
  fail-non-fatal, structured blocker format, PDD discipline
- ANTI-GOALS.md: 25 rule-coded anti-patterns (SF001-SF025) covering bare
  errors, type lies, magic strings, partial migrations, Ralph-loop retry,
  central federation, MCP between first-party services, implementation-
  mirror tests, coding-before-PDD-fields, happy-path-only, etc.

Translated from ACE-coder's STYLEGUIDE.md as the model. Anchored on
purpose-driven-development as the canonical change method. These three
files plus KNOWLEDGE.md plus DECISIONS.md are the tacit-knowledge layer
auto-injected into every agent context (via system-context.ts mtime cache).

Closes the "smart human gap" identified in this session: the difference
between SF behaving like a competent engineer in this codebase vs. a
generic LLM is the accumulated tacit knowledge available to the agent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 02:41:51 +02:00
parent 8a1f131557
commit d1be5d9b74
10 changed files with 447 additions and 17 deletions

View file

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

View file

@ -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<string, unknown>,
);
// 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);
}
}
}
});

View file

@ -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 (<!-- sf-scaffold: ... -->)
* 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 <!-- sf-scaffold: ... -->)
const stripped = raw
.replace(/<!--\s*sf-scaffold:[^>]*-->/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.

View file

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

View file

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

View file

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

View file

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

View file

@ -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:
* - <basePath>/PROJECT.md (vision)
* - <basePath>/.sf/ANTI-GOALS.md
* - <basePath>/.sf/milestones/<milestoneId>/<milestoneId>-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: [] };
}
}

View file

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

View file

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