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:
parent
8a1f131557
commit
d1be5d9b74
10 changed files with 447 additions and 17 deletions
|
|
@ -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)
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
268
src/resources/extensions/sf/milestone-framing-check.ts
Normal file
268
src/resources/extensions/sf/milestone-framing-check.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue