From deeb4dbd4e7423cb6f80ebd6947820f11dd5a491 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 7 May 2026 16:39:39 +0200 Subject: [PATCH] sf snapshot: uncommitted changes after 61m inactivity --- .sf/PREFERENCES.md | 55 +++++++++++++++++++ src/headless-context.ts | 15 +++++ src/headless.ts | 4 +- .../extensions/sf/doctor-engine-checks.js | 9 ++- src/resources/extensions/sf/guided-flow.js | 4 +- src/resources/extensions/sf/paths.js | 35 +++++++++++- src/resources/extensions/sf/state.js | 11 +++- .../headless-context-autobootstrap.test.ts | 27 ++++++++- 8 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 .sf/PREFERENCES.md diff --git a/.sf/PREFERENCES.md b/.sf/PREFERENCES.md new file mode 100644 index 000000000..2bea6887d --- /dev/null +++ b/.sf/PREFERENCES.md @@ -0,0 +1,55 @@ +--- +version: 1 +last_synced_with_sf: 2.75.3 +sf_template_state: pending +sf_template_hash: "sha256:287389de2f7e2bfa1c6043682cde774f8d39e2ed6591dcec633f6c72af8acac2" +verification_commands: + - "npm run typecheck:extensions" + - npm run build + - npm run lint + - "npm run test:sf-light" + - "bash -c 'set -e; for d in \"rust-engine\" \"rust-engine/crates/ast\" \"rust-engine/crates/engine\" \"rust-engine/crates/grep\"; do (cd \"$d\" && cargo fmt --check); done'" + - "bash -c 'set -e; for d in \"rust-engine\" \"rust-engine/crates/ast\" \"rust-engine/crates/engine\" \"rust-engine/crates/grep\"; do (cd \"$d\" && cargo check); done'" + - "bash -c 'set -e; for d in \"rust-engine\" \"rust-engine/crates/ast\" \"rust-engine/crates/engine\" \"rust-engine/crates/grep\"; do (cd \"$d\" && cargo test -- --test-threads=2); done'" + - "bash -c 'set -e; for d in \"rust-engine\" \"rust-engine/crates/ast\" \"rust-engine/crates/engine\" \"rust-engine/crates/grep\"; do (cd \"$d\" && cargo clippy -- -D warnings); done'" +always_use_skills: [] +prefer_skills: [] +avoid_skills: [] +skill_rules: [] +custom_instructions: [] +models: {} +skill_discovery: {} +auto_supervisor: {} +--- + +# SF Skill Preferences + +Project-specific guidance for skill selection and execution preferences. + +See `~/.sf/agent/extensions/sf/docs/preferences-reference.md` for full field documentation and examples. + +## Fields + +- `always_use_skills`: Skills that must be available during all SF operations +- `prefer_skills`: Skills to prioritize when multiple options exist +- `avoid_skills`: Skills to minimize or avoid (with lower priority than prefer) +- `skill_rules`: Context-specific rules (e.g., "use tool X for Y type of work") +- `custom_instructions`: Append-only project guidance (do not override system rules) +- `models`: Model preferences for specific task types +- `skill_discovery`: Automatic skill detection preferences +- `auto_supervisor`: Supervision and gating rules for autonomous modes +- `git`: Git preferences — `main_branch` (default branch name for new repos, e.g., "main", "master", "trunk"), `auto_push`, `snapshots`, etc. + +## Examples + +```yaml +prefer_skills: + - playwright + - resolve_library +avoid_skills: + - subagent # prefer direct execution in this project + +custom_instructions: + - "Always verify with browser_assert before marking UI work done" + - "Use Context7 for all library/framework decisions" +``` diff --git a/src/headless-context.ts b/src/headless-context.ts index 9c3ff6fa3..0b42f2d70 100644 --- a/src/headless-context.ts +++ b/src/headless-context.ts @@ -162,6 +162,21 @@ export function hasMilestones(basePath: string): boolean { } } +export async function hasProjectMilestones(basePath: string): Promise { + if (hasMilestones(basePath)) return true; + try { + const dynamicToolsPath = + "./resources/extensions/sf/bootstrap/dynamic-tools.js"; + const { ensureDbOpen } = await import(dynamicToolsPath); + if (!(await ensureDbOpen(basePath))) return false; + const sfDbPath = "./resources/extensions/sf/sf-db.js"; + const { getAllMilestones, isDbAvailable } = await import(sfDbPath); + return isDbAvailable() && getAllMilestones().length > 0; + } catch { + return false; + } +} + export function buildAutoBootstrapContext(basePath: string): string { const selectedFiles = collectAutoBootstrapFiles(basePath); const sourceFiles = collectSourceFiles(basePath); diff --git a/src/headless.ts b/src/headless.ts index ea88d1439..f20858ffc 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -31,7 +31,7 @@ import { import { bootstrapProject, buildAutoBootstrapContext, - hasMilestones, + hasProjectMilestones, loadContext, } from "./headless-context.js"; @@ -571,7 +571,7 @@ async function runHeadlessOnce( } if (options.command === "autonomous" && !options.resumeSession) { bootstrapProject(process.cwd()); - if (!hasMilestones(process.cwd())) { + if (!(await hasProjectMilestones(process.cwd()))) { if (!options.json) { process.stderr.write( "[headless] No milestones found; bootstrapping from repo docs and source inventory...\n", diff --git a/src/resources/extensions/sf/doctor-engine-checks.js b/src/resources/extensions/sf/doctor-engine-checks.js index c4bcd01f4..427bc17ed 100644 --- a/src/resources/extensions/sf/doctor-engine-checks.js +++ b/src/resources/extensions/sf/doctor-engine-checks.js @@ -27,6 +27,9 @@ import { renderAllProjections } from "./workflow-projections.js"; const LEGACY_MILESTONE_DIR_RE = /^(M\d+)-.+$/; const LEGACY_SLICE_DIR_RE = /^(S\d+)-.+$/; +function canonicalMilestonePrefix(id) { + return id.match(/^([A-Z]\d{3})/)?.[1] ?? id; +} function projectionDriftIssues(basePath, milestoneId) { const issues = []; @@ -403,16 +406,19 @@ export async function checkEngineHealth( const msDir = milestonesDir(basePath); if (existsSync(msDir)) { const validMilestoneIds = new Set(); + const validMilestonePrefixes = new Set(); if (isDbAvailable()) { // DB-authoritative: only DB rows count as valid for (const m of getAllMilestones()) { validMilestoneIds.add(m.id); + validMilestonePrefixes.add(canonicalMilestonePrefix(m.id)); } } else { // No DB: fall back to filesystem registry const state = await deriveState(basePath); for (const m of state.registry) { validMilestoneIds.add(m.id); + validMilestonePrefixes.add(canonicalMilestonePrefix(m.id)); } } for (const entry of readdirSync(msDir)) { @@ -427,7 +433,8 @@ export async function checkEngineHealth( if (!milestoneId) continue; if ( !validMilestoneIds.has(milestoneId) && - !validMilestoneIds.has(entry) + !validMilestoneIds.has(entry) && + !validMilestonePrefixes.has(canonicalMilestonePrefix(entry)) ) { issues.push({ severity: "warning", diff --git a/src/resources/extensions/sf/guided-flow.js b/src/resources/extensions/sf/guided-flow.js index f9c14ba5f..60b6f94f4 100644 --- a/src/resources/extensions/sf/guided-flow.js +++ b/src/resources/extensions/sf/guided-flow.js @@ -731,7 +731,7 @@ export async function showHeadlessMilestoneCreation( } /** * Single discuss-dispatch entry point for new milestones. - * auto=true → headless prompt, rootFiles seed, plan-milestone workflow, no pendingAutoStartMap + * auto=true → headless prompt, rootFiles seed, discuss-milestone workflow, no pendingAutoStartMap * auto=false → discuss prompt with preparation, discuss-milestone workflow, sets pendingAutoStartMap */ export async function dispatchNewMilestoneDiscuss( @@ -785,7 +785,7 @@ export async function dispatchNewMilestoneDiscuss( basePath, ); // Do NOT set pendingAutoStartMap — caller (bootstrapAutoSession) manages the loop - await dispatchWorkflow(pi, prompt, "sf-run", ctx, "plan-milestone"); + await dispatchWorkflow(pi, prompt, "sf-run", ctx, "discuss-milestone"); } else { pendingAutoStartMap.set(basePath, { ctx, diff --git a/src/resources/extensions/sf/paths.js b/src/resources/extensions/sf/paths.js index 7018f3e2e..337fb427e 100644 --- a/src/resources/extensions/sf/paths.js +++ b/src/resources/extensions/sf/paths.js @@ -134,6 +134,13 @@ export function clearPathCache() { export function buildMilestoneFileName(milestoneId, suffix) { return `${milestoneId}-${suffix}.md`; } +function canonicalMilestoneIdForDir(milestoneId, milestoneDir) { + const dirName = milestoneDir ? milestoneDir.split(/[/\\]/).pop() : null; + const canonicalPrefix = milestoneId.match(/^([A-Z]\d{3})-/)?.[1]; + return canonicalPrefix && dirName === canonicalPrefix + ? canonicalPrefix + : milestoneId; +} /** * Build a slice-level file name. * ("S01", "PLAN") → "S01-PLAN.md" @@ -163,6 +170,14 @@ export function resolveDir(parentDir, idPrefix) { // Exact match first (current convention: bare ID) const exact = entries.find((e) => e.isDirectory() && e.name === idPrefix); if (exact) return exact.name; + // Unique-ID fallback: M001-abc123 should still resolve bare M001/ + const canonicalPrefix = idPrefix.match(/^([A-Z]\d{3})-/)?.[1]; + if (canonicalPrefix) { + const canonical = entries.find( + (e) => e.isDirectory() && e.name === canonicalPrefix, + ); + if (canonical) return canonical.name; + } // Prefix match for legacy descriptor dirs: M001-SOMETHING const prefixed = entries.find( (e) => e.isDirectory() && e.name.startsWith(idPrefix + "-"), @@ -187,10 +202,27 @@ export function resolveFile(dir, idPrefix, suffix) { // Direct match: ID-SUFFIX.md const direct = entries.find((e) => e.toUpperCase() === target); if (direct) return direct; + // Unique-ID fallback: M001-abc123-CONTEXT.md should still resolve M001-CONTEXT.md + const canonicalPrefix = idPrefix.match(/^([A-Z]\d{3})-/)?.[1]; + if (canonicalPrefix) { + const canonicalTarget = `${canonicalPrefix}-${suffix}.md`.toUpperCase(); + const canonicalDirect = entries.find( + (e) => e.toUpperCase() === canonicalTarget, + ); + if (canonicalDirect) return canonicalDirect; + } // Legacy pattern match: ID-DESCRIPTOR-SUFFIX.md const pattern = new RegExp(`^${idPrefix}-.*-${suffix}\\.md$`, "i"); const match = entries.find((e) => pattern.test(e)); if (match) return match; + if (canonicalPrefix) { + const canonicalPattern = new RegExp( + `^${canonicalPrefix}-.*-${suffix}\\.md$`, + "i", + ); + const canonicalMatch = entries.find((e) => canonicalPattern.test(e)); + if (canonicalMatch) return canonicalMatch; + } // Legacy fallback: suffix.md const legacy = entries.find( (e) => e.toLowerCase() === `${suffix.toLowerCase()}.md`, @@ -526,7 +558,8 @@ export function relMilestoneFile(basePath, milestoneId, suffix) { const file = resolveFile(mDir, milestoneId, suffix); if (file) return `${mRel}/${file}`; } - return `${mRel}/${buildMilestoneFileName(milestoneId, suffix)}`; + const fileId = canonicalMilestoneIdForDir(milestoneId, mDir); + return `${mRel}/${buildMilestoneFileName(fileId, suffix)}`; } /** * Build relative .sf/ path to a slice directory. diff --git a/src/resources/extensions/sf/state.js b/src/resources/extensions/sf/state.js index cce643e8e..753f6b182 100644 --- a/src/resources/extensions/sf/state.js +++ b/src/resources/extensions/sf/state.js @@ -265,6 +265,9 @@ export async function deriveState(basePath) { function stripMilestonePrefix(title) { return title.replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, "") || title; } +function canonicalMilestonePrefix(id) { + return id.match(/^([A-Z]\d{3})/)?.[1] ?? id; +} function extractContextTitle(content, fallback) { if (!content) return fallback; const h1 = content.split("\n").find((line) => line.startsWith("# ")); @@ -290,8 +293,14 @@ function reconcileDiskToDb(basePath) { const diskIds = findMilestoneIds(basePath); if (diskIds.length > 0) { const dbIds = new Set(getAllMilestones().map((m) => m.id)); + const dbPrefixes = new Set( + Array.from(dbIds, (id) => canonicalMilestonePrefix(id)), + ); const diskOnlyIds = diskIds.filter( - (id) => !dbIds.has(id) && !isGhostMilestone(basePath, id), + (id) => + !dbIds.has(id) && + !dbPrefixes.has(canonicalMilestonePrefix(id)) && + !isGhostMilestone(basePath, id), ); if (diskOnlyIds.length > 0) { logWarning( diff --git a/src/tests/headless-context-autobootstrap.test.ts b/src/tests/headless-context-autobootstrap.test.ts index a4f41558f..2f183edac 100644 --- a/src/tests/headless-context-autobootstrap.test.ts +++ b/src/tests/headless-context-autobootstrap.test.ts @@ -2,12 +2,22 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { test } from "vitest"; +import { afterEach, test } from "vitest"; import { buildAutoBootstrapContext, hasMilestones, + hasProjectMilestones, } from "../headless-context.js"; +import { + closeDatabase, + insertMilestone, + openDatabase, +} from "../resources/extensions/sf/sf-db.js"; + +afterEach(() => { + closeDatabase(); +}); test("buildAutoBootstrapContext includes purpose docs and source inventory", () => { const root = mkdtempSync(join(tmpdir(), "sf-headless-bootstrap-")); @@ -44,3 +54,18 @@ test("hasMilestones only reports true when milestone directories exist", () => { mkdirSync(join(root, ".sf", "milestones", "M001"), { recursive: true }); assert.equal(hasMilestones(root), true); }); + +test("hasProjectMilestones_when_db_contains_milestone_without_disk_dir_reports_true", async () => { + const root = mkdtempSync(join(tmpdir(), "sf-headless-db-milestones-")); + mkdirSync(join(root, ".sf", "milestones"), { recursive: true }); + openDatabase(join(root, ".sf", "sf.db")); + insertMilestone({ + id: "M001-6377a4", + title: "DB-only milestone", + status: "queued", + }); + closeDatabase(); + + assert.equal(hasMilestones(root), false); + assert.equal(await hasProjectMilestones(root), true); +});