From 2afe2ac6f168876e0c32c89a88edf172bf572c8a Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Wed, 29 Apr 2026 15:05:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(prefs):=20self-aligning=20template=20upgra?= =?UTF-8?q?des=20=E2=80=94=20sf=20keeps=20its=20own=20files=20synced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ` 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 --- src/resources/extensions/sf/gitignore.ts | 7 ++ .../sf/preferences-template-upgrade.ts | 104 ++++++++++++++++++ .../extensions/sf/preferences-types.ts | 8 ++ src/resources/extensions/sf/preferences.ts | 8 +- 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/sf/preferences-template-upgrade.ts diff --git a/src/resources/extensions/sf/gitignore.ts b/src/resources/extensions/sf/gitignore.ts index 8596a07fe..38e4cc5c3 100644 --- a/src/resources/extensions/sf/gitignore.ts +++ b/src/resources/extensions/sf/gitignore.ts @@ -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: [] diff --git a/src/resources/extensions/sf/preferences-template-upgrade.ts b/src/resources/extensions/sf/preferences-template-upgrade.ts new file mode 100644 index 000000000..0a8db1620 --- /dev/null +++ b/src/resources/extensions/sf/preferences-template-upgrade.ts @@ -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: `; 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, + )}---`; + + try { + writeFileSync(path, newFrontmatter + body, "utf-8"); + } catch { + // Read-only filesystem, permission error, etc. — not fatal. + return upgraded; + } + + return upgraded; +} diff --git a/src/resources/extensions/sf/preferences-types.ts b/src/resources/extensions/sf/preferences-types.ts index 2721fdf78..9ac2da38d 100644 --- a/src/resources/extensions/sf/preferences-types.ts +++ b/src/resources/extensions/sf/preferences-types.ts @@ -87,6 +87,7 @@ export const MODE_DEFAULTS: Record> = { /** All recognized top-level keys in SFPreferences. Used to detect typos / stale config. */ export const KNOWN_PREFERENCE_KEYS = new Set([ "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[]; diff --git a/src/resources/extensions/sf/preferences.ts b/src/resources/extensions/sf/preferences.ts index 64035f268..1c598cfdc 100644 --- a/src/resources/extensions/sf/preferences.ts +++ b/src/resources/extensions/sf/preferences.ts @@ -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 } : {}), }; }