feat(prefs): self-aligning template upgrades — sf keeps its own files synced

Companion to the earlier schema-versioning framework. Where that handles
data-shape evolution via forward migrations, this handles file-template
evolution via silent self-rewrite. The user shouldn't have to know:

- ensurePreferences() now stamps `last_synced_with_sf: <semver>` in the
  frontmatter when seeding a new project's PREFERENCES.md, recording the
  sf version that wrote the template.
- New module preferences-template-upgrade.ts:
  - detectTemplateDrift(prefs) — pure check, returns
    { fromVersion, toVersion, needsUpgrade }.
  - upgradePreferencesFileIfDrifted(path, prefs) — silently re-renders
    the file's frontmatter when fromVersion ≠ toVersion. Body (anything
    after the closing `---`) is preserved verbatim, so user notes stay.
- Wired into loadPreferencesFile() — every read self-aligns. No human
  warnings, no opt-in flow; sf keeps its own house in order.
- last_synced_with_sf added to SFPreferences + KNOWN_PREFERENCE_KEYS so
  it round-trips through validatePreferences without "unknown key"
  warnings.

Failure modes are non-fatal: missing file, malformed frontmatter, or
read-only filesystem all leave the file alone and return the in-memory
prefs unchanged. SF_VERSION env var (set by loader.ts) is the source of
truth for "current sf"; "0.0.0" sentinel skips upgrade so atypical entry
points don't stamp incorrect values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-29 15:05:37 +02:00
parent 4912f6ea80
commit 2afe2ac6f1
4 changed files with 126 additions and 1 deletions

View file

@ -351,8 +351,15 @@ export function ensurePreferences(basePath: string): boolean {
// fall through to bare template
}
// Stamp the sf version that wrote this template. Drift detection in
// checkPreferencesDrift uses this to flag stale templates after major
// sf updates. SF_VERSION is set by loader.ts; fall back to "0.0.0" if
// the env var is missing (atypical entry point).
const sfVersion = process.env.SF_VERSION || "0.0.0";
const template = `---
version: 1
last_synced_with_sf: ${yamlSafeString(sfVersion)}
${verifySection}always_use_skills: []
prefer_skills: []
avoid_skills: []

View file

@ -0,0 +1,104 @@
/**
* Self-aligning template upgrades for SF config files.
*
* Goal: when sf evolves, projects' .sf/PREFERENCES.md (and friends) get
* brought into alignment WITHOUT human intervention. The file's frontmatter
* stamps `last_synced_with_sf: <semver>`; on every load, sf compares to its
* own version and silently re-writes the frontmatter to match. The body
* (Markdown after the frontmatter) is preserved verbatim users can keep
* notes there and sf won't clobber them.
*
* What this is NOT: a human-facing warning system. The end-user shouldn't
* have to read drift advisories; sf keeps its own house in order.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js";
import type { SFPreferences } from "./preferences-types.js";
export interface TemplateDrift {
/** SF version recorded in the file (or "0.0.0" if missing). */
fromVersion: string;
/** SF version currently running. */
toVersion: string;
/** True iff fromVersion ≠ toVersion AND both are known. */
needsUpgrade: boolean;
}
export function detectTemplateDrift(prefs: SFPreferences): TemplateDrift {
const toVersion = process.env.SF_VERSION || "0.0.0";
const fromVersion = prefs.last_synced_with_sf || "0.0.0";
// Skip when either side is unknown — better to no-op than re-write with
// "0.0.0" stamp on a file that was hand-edited or generated outside the
// normal sf load path.
if (toVersion === "0.0.0" || fromVersion === "0.0.0") {
return { fromVersion, toVersion, needsUpgrade: false };
}
return {
fromVersion,
toVersion,
needsUpgrade: fromVersion !== toVersion,
};
}
/**
* If the file at `path` is drifted from current sf, re-render its
* frontmatter with a fresh `last_synced_with_sf` stamp (and any other
* fields that may have been migrated by validatePreferences). Body is
* preserved verbatim only the leading `--- ... ---` block is rewritten.
*
* Returns the (possibly stamp-updated) prefs object so callers can use the
* canonical view without re-reading the file.
*
* Failure modes are non-fatal: if the file disappeared, frontmatter can't
* be located, or write fails, the original prefs are returned unchanged.
* sf load paths must continue working even if disk-level upgrade is blocked.
*/
export function upgradePreferencesFileIfDrifted(
path: string,
prefs: SFPreferences,
): SFPreferences {
const drift = detectTemplateDrift(prefs);
if (!drift.needsUpgrade) return prefs;
const upgraded: SFPreferences = {
...prefs,
last_synced_with_sf: drift.toVersion,
};
if (!existsSync(path)) return upgraded;
let content: string;
try {
content = readFileSync(path, "utf-8");
} catch {
return upgraded;
}
const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n";
if (!content.startsWith(startMarker)) {
// File doesn't have the canonical frontmatter shape — leave it alone.
return upgraded;
}
const endIdx = content.indexOf("\n---", startMarker.length);
if (endIdx === -1) return upgraded;
// Body is everything after the frontmatter's closing `---` line.
const bodyStart = content.indexOf("\n", endIdx + 1);
const body = bodyStart === -1 ? "" : content.slice(bodyStart);
const newFrontmatter = `---\n${serializePreferencesToFrontmatter(
upgraded as unknown as Record<string, unknown>,
)}---`;
try {
writeFileSync(path, newFrontmatter + body, "utf-8");
} catch {
// Read-only filesystem, permission error, etc. — not fatal.
return upgraded;
}
return upgraded;
}

View file

@ -87,6 +87,7 @@ export const MODE_DEFAULTS: Record<WorkflowMode, Partial<SFPreferences>> = {
/** All recognized top-level keys in SFPreferences. Used to detect typos / stale config. */
export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"version",
"last_synced_with_sf",
"mode",
"always_use_skills",
"prefer_skills",
@ -347,6 +348,13 @@ export interface CodebaseMapPreferences {
export interface SFPreferences {
version?: number;
/**
* SF version (semver) that last wrote this file via the init template.
* Stamped automatically by ensurePreferences(); used by drift detection
* to suggest a template refresh when sf has evolved meaningfully since
* the file was generated. Not user-set.
*/
last_synced_with_sf?: string;
mode?: WorkflowMode;
always_use_skills?: string[];
prefer_skills?: string[];

View file

@ -28,6 +28,7 @@ import {
type SkillResolution,
type WorkflowMode,
} from "./preferences-types.js";
import { upgradePreferencesFileIfDrifted } from "./preferences-template-upgrade.js";
import { validatePreferences } from "./preferences-validation.js";
import type {
PostUnitHookConfig,
@ -261,12 +262,17 @@ function loadPreferencesFile(
if (!preferences) return null;
const validation = validatePreferences(preferences);
// Self-align: if the file's recorded sf version drifted from current,
// silently re-render the frontmatter so subsequent reads match. No
// human-facing warning — sf keeps its own files in sync. Body content
// (anything after the frontmatter) is preserved verbatim.
const aligned = upgradePreferencesFileIfDrifted(path, validation.preferences);
const allWarnings = [...validation.warnings, ...validation.errors];
return {
path,
scope,
preferences: validation.preferences,
preferences: aligned,
...(allWarnings.length > 0 ? { warnings: allWarnings } : {}),
};
}