sf snapshot: uncommitted changes after 61m inactivity

This commit is contained in:
Mikael Hugo 2026-05-07 16:39:39 +02:00
parent 8088489e38
commit deeb4dbd4e
8 changed files with 152 additions and 8 deletions

55
.sf/PREFERENCES.md Normal file
View file

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

View file

@ -162,6 +162,21 @@ export function hasMilestones(basePath: string): boolean {
}
}
export async function hasProjectMilestones(basePath: string): Promise<boolean> {
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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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