diff --git a/src/resources/extensions/sf/agentic-docs-scaffold.ts b/src/resources/extensions/sf/agentic-docs-scaffold.ts index 782798572..01770afcb 100644 --- a/src/resources/extensions/sf/agentic-docs-scaffold.ts +++ b/src/resources/extensions/sf/agentic-docs-scaffold.ts @@ -2,11 +2,14 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { + bodyHash, + extractMarker, recordScaffoldApply, stampScaffoldFile, - bodyHash, type ScaffoldManifestEntry, } from "./scaffold-versioning.js"; +import { migrateLegacyScaffold } from "./scaffold-drift.js"; +import { logWarning } from "./workflow-logger.js"; export interface ScaffoldFile { path: string; @@ -432,36 +435,99 @@ Prefer code-based graders. Add LLM-judge graders only when deterministic checkin }, ]; +/** + * Drift-aware scaffold sync (ADR-021 Phase C). + * + * Behavior: + * 1. Run legacy migration first — unmarked files whose body hash matches a + * known prior version in SCAFFOLD_VERSION_ARCHIVE get promoted to pending + * and stamped. Handles projects that pre-date the marker system. + * 2. For each scaffold template: + * - Missing on disk → write template, stamp marker, record manifest entry. + * - Present, marker, state=pending, version drifted, hash matches stamp → + * silent re-render with current template, restamp. + * - Present, marker says editing or completed → leave alone (Phase D + * handles editing-drift via the scaffold-keeper background agent). + * - Present without marker after migration → user-customised, leave alone. + * + * Silent contract: no stdout/stderr in normal paths. Only logWarning("scaffold") + * for unexpected I/O failures. Failure modes are non-fatal. + */ export function ensureAgenticDocsScaffold(basePath: string): void { const sfVersion = process.env.SF_VERSION || "0.0.0"; const appliedAt = new Date().toISOString(); + // Step 1: legacy migration — promote unmarked-but-recognised files. + try { + migrateLegacyScaffold(basePath); + } catch (err) { + logWarning("scaffold", "legacy migration failed", { + error: (err as Error).message, + }); + } + + // Step 2: missing-file creation + pending-state silent upgrade. for (const file of SCAFFOLD_FILES) { const target = join(basePath, file.path); - if (existsSync(target)) continue; - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, file.content, "utf-8"); - - // ADR-021 Phase A: stamp marker + record manifest entry. - // .siftignore (and other configured non-marker paths) skips inline - // stamping but still records a manifest entry against its body hash. const skipMarker = NO_MARKER_PATHS.has(file.path); - let recordedHash: string; - if (skipMarker) { - recordedHash = bodyHash(file.content); - } else { - stampScaffoldFile(target, file.path, sfVersion, "pending"); - recordedHash = bodyHash(file.content); + + if (!existsSync(target)) { + try { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, file.content, "utf-8"); + if (!skipMarker) { + stampScaffoldFile(target, file.path, sfVersion, "pending"); + } + const entry: ScaffoldManifestEntry = { + path: file.path, + template: file.path, + version: sfVersion, + appliedAt, + stateAtApply: "pending", + contentHash: bodyHash(file.content), + }; + recordScaffoldApply(basePath, entry); + } catch (err) { + logWarning("scaffold", "failed to write missing scaffold file", { + file: file.path, + error: (err as Error).message, + }); + } + continue; } - const entry: ScaffoldManifestEntry = { - path: file.path, - template: file.path, - version: sfVersion, - appliedAt, - stateAtApply: "pending", - contentHash: recordedHash, - }; - recordScaffoldApply(basePath, entry); + // Present — only refresh when state=pending AND drifted from current ship. + // .siftignore (NO_MARKER_PATHS) skips silent refresh; the manifest version + // alone isn't enough signal to safely overwrite a dotfile config. + if (skipMarker) continue; + + try { + const { marker, body } = extractMarker(target); + if (!marker) continue; // untracked / customised after migration — leave alone + if (marker.state !== "pending") continue; // editing or completed — Phase D territory + if (marker.version === sfVersion) continue; // already current + // Confirm on-disk hash matches the stamped hash. If diverged, the + // file was edited without removing the marker — treat as editing-drift + // and leave alone. + if (bodyHash(body) !== marker.hash) continue; + + // Silent re-render with current template + restamp. + writeFileSync(target, file.content, "utf-8"); + stampScaffoldFile(target, file.path, sfVersion, "pending"); + const entry: ScaffoldManifestEntry = { + path: file.path, + template: file.path, + version: sfVersion, + appliedAt, + stateAtApply: "pending", + contentHash: bodyHash(file.content), + }; + recordScaffoldApply(basePath, entry); + } catch (err) { + logWarning("scaffold", "failed to refresh pending scaffold file", { + file: file.path, + error: (err as Error).message, + }); + } } } diff --git a/src/resources/extensions/sf/doctor-runtime-checks.ts b/src/resources/extensions/sf/doctor-runtime-checks.ts index 2e5631701..fa801a2a5 100644 --- a/src/resources/extensions/sf/doctor-runtime-checks.ts +++ b/src/resources/extensions/sf/doctor-runtime-checks.ts @@ -24,6 +24,7 @@ import { } from "./native-git-bridge.js"; import { milestonesDir, resolveSfRootFile, sfRoot } from "./paths.js"; import { cleanNumberedSfVariants } from "./repo-identity.js"; +import { detectScaffoldDrift } from "./scaffold-drift.js"; import { isSessionStale, readAllSessionStatuses, @@ -385,6 +386,47 @@ export async function checkRuntimeHealth( // Non-fatal — gitignore check failed } + // ── Scaffold freshness (ADR-021) ────────────────────────────────────── + // Surfaces drift between this project's scaffold artifacts and the + // templates SF currently ships. Non-fatal — automatic sync runs in + // ensureAgenticDocsScaffold; this check is the user-visible signal. + try { + const { detectScaffoldDrift } = await import("./scaffold-drift.js"); + const report = detectScaffoldDrift(basePath); + const c = report.countsByBucket; + // Only emit a finding when something is actionable. `customized` and + // `untracked-with-no-archive-match` are non-actionable from SF's POV. + const actionable = c.missing + c.upgradable + c["editing-drift"]; + if (actionable > 0) { + const parts: string[] = []; + if (c.missing > 0) parts.push(`${c.missing} missing`); + if (c.upgradable > 0) parts.push(`${c.upgradable} pending-upgrade`); + if (c["editing-drift"] > 0) + parts.push(`${c["editing-drift"]} edited-drift`); + issues.push({ + severity: "warning", + code: "scaffold_drift", + scope: "project", + unitId: "project", + message: `Scaffold drift: ${parts.join(", ")}. Auto-sync handles missing+pending; edited-drift needs review.`, + file: ".sf/scaffold-manifest.json", + fixable: c.missing + c.upgradable > 0, + }); + + if (shouldFix("scaffold_drift") && c.missing + c.upgradable > 0) { + const { ensureAgenticDocsScaffold } = await import( + "./agentic-docs-scaffold.js" + ); + ensureAgenticDocsScaffold(basePath); + fixesApplied.push( + `scaffold sync: created ${c.missing} missing, refreshed ${c.upgradable} pending`, + ); + } + } + } catch { + // Non-fatal — scaffold drift check failed + } + // ── External state symlink health ────────────────────────────────────── try { const localSf = join(basePath, ".sf"); diff --git a/src/resources/extensions/sf/doctor-types.ts b/src/resources/extensions/sf/doctor-types.ts index e2927983d..42f32494d 100644 --- a/src/resources/extensions/sf/doctor-types.ts +++ b/src/resources/extensions/sf/doctor-types.ts @@ -80,7 +80,9 @@ export type DoctorIssueCode = | "db_done_task_no_summary" | "db_duplicate_id" | "db_unavailable" - | "projection_drift"; + | "projection_drift" + // ADR-021: scaffold versioning + | "scaffold_drift"; /** * Issue codes that represent global or completion-critical state. diff --git a/src/resources/extensions/sf/scaffold-drift.ts b/src/resources/extensions/sf/scaffold-drift.ts index f2c7ad72b..6e2b292ad 100644 --- a/src/resources/extensions/sf/scaffold-drift.ts +++ b/src/resources/extensions/sf/scaffold-drift.ts @@ -14,7 +14,11 @@ import { bodyHash, extractMarker, readScaffoldManifest, + recordScaffoldApply, + stampScaffoldFile, + type ScaffoldManifestEntry, } from "./scaffold-versioning.js"; +import { logWarning } from "./workflow-logger.js"; export type ScaffoldDriftBucket = | "missing" @@ -236,17 +240,152 @@ export function detectScaffoldDrift(basePath: string): ScaffoldDriftReport { } /** - * Phase C populates `SCAFFOLD_VERSION_ARCHIVE` when SF starts shipping known - * prior hashes. Until then, legacy hash-match migration is a no-op: there is - * no archive to compare against. + * Per-template archive of body hashes shipped by prior SF versions. * - * The function exists in Phase B as a stable import surface so callers can - * be wired up without waiting for Phase C. + * Each entry maps a logical template id (matching `SCAFFOLD_FILES[].path`) + * to the list of `{version, hash}` pairs it has shipped. This is the table + * `migrateLegacyScaffold` consults when it encounters a marker-less file + * on disk: if the file's body hash matches an archive entry, the file is + * still verbatim from a known prior version and can be promoted to + * `pending` and stamped (which then makes the next pass classify it as + * `upgradable`, triggering the silent re-render). + * + * Phase C bootstraps the archive with the **current** SF version's hashes, + * computed lazily from `SCAFFOLD_FILES` on first call. This means a project + * that sat on the current SF release without scaffold markers (because it + * predates ADR-021 Phase A) can still be recognised: its files match the + * current shipping body verbatim, so they get stamped and brought under + * management without overwriting anything. + * + * Future SF releases that change a template body must append the **previous** + * body hash for that template to this archive (with the version that shipped + * the previous body). The `migrateLegacyScaffold` flow is forwards-compatible + * with that growth: more entries simply mean more legacy files match. */ -export function migrateLegacyScaffold(_basePath: string): { +export const SCAFFOLD_VERSION_ARCHIVE: Record< + string, + Array<{ version: string; hash: string }> +> = {}; + +let archiveSeededWithCurrent = false; + +/** + * Lazily seed `SCAFFOLD_VERSION_ARCHIVE` with the current SF version's body + * hashes for every entry in `SCAFFOLD_FILES`. Idempotent — repeated calls + * are no-ops. Called from `migrateLegacyScaffold` on demand so the archive + * is always populated for at least the current shipping version. + */ +function seedArchiveWithCurrentShipVersion(): void { + if (archiveSeededWithCurrent) return; + archiveSeededWithCurrent = true; + const shipVersion = process.env.SF_VERSION || "0.0.0"; + for (const file of SCAFFOLD_FILES) { + const hash = bodyHash(file.content); + const list = SCAFFOLD_VERSION_ARCHIVE[file.path] ?? []; + // Avoid duplicate entries if a future hand-edited archive entry + // already mentions this version+hash combination. + if (!list.some((e) => e.version === shipVersion && e.hash === hash)) { + list.push({ version: shipVersion, hash }); + } + SCAFFOLD_VERSION_ARCHIVE[file.path] = list; + } +} + +/** + * Walk every `SCAFFOLD_FILES` entry and look for **unmarked** files whose + * body hash matches a known prior version recorded in + * `SCAFFOLD_VERSION_ARCHIVE`. Matching files are promoted to `pending` by + * stamping them with the matched version and recording a manifest entry. + * + * Behaviour: + * - Files with a marker already present → skipped (some other code path + * owns them). + * - Files missing on disk → skipped (the missing-file flow handles those). + * - Files in `SKIP_MARKER_PATHS` (e.g. `.siftignore`) → skipped here; the + * manifest is the versioning source for those. + * - Files whose body hash matches an archive entry → stamped with the + * matched version, manifest entry recorded, returned in `migrated`. + * - Files with no archive match → returned in `skipped`. Treated as + * user-customised; SF leaves them alone. + * + * Idempotent: a second invocation finds the markers it just wrote and + * skips them. Failure modes (read error, write error) are swallowed and + * logged via `logWarning("scaffold", ...)`. + */ +export function migrateLegacyScaffold(basePath: string): { migrated: string[]; skipped: string[]; } { - void _basePath; - return { migrated: [], skipped: [] }; + seedArchiveWithCurrentShipVersion(); + const migrated: string[] = []; + const skipped: string[] = []; + const appliedAt = new Date().toISOString(); + + for (const file of SCAFFOLD_FILES) { + if (SKIP_MARKER_PATHS.has(file.path)) continue; + const target = join(basePath, file.path); + if (!existsSync(target)) continue; + + let body: string; + let markerPresent = false; + try { + const extracted = extractMarker(target); + markerPresent = extracted.marker !== null; + body = extracted.body; + } catch (err) { + logWarning("scaffold", "failed to read file during legacy migration", { + file: file.path, + error: (err as Error).message, + }); + skipped.push(file.path); + continue; + } + + // File already managed — not a migration candidate. + if (markerPresent) continue; + + // extractMarker returns the entire file content as `body` when no + // marker is present, so this hash is the on-disk body hash. + let onDiskHash: string; + try { + onDiskHash = bodyHash(body); + } catch (err) { + logWarning("scaffold", "failed to hash file during legacy migration", { + file: file.path, + error: (err as Error).message, + }); + skipped.push(file.path); + continue; + } + + const archive = SCAFFOLD_VERSION_ARCHIVE[file.path] ?? []; + const match = archive.find((e) => e.hash === onDiskHash); + if (!match) { + skipped.push(file.path); + continue; + } + + // Promote: stamp with the matched version and record manifest entry. + try { + stampScaffoldFile(target, file.path, match.version, "pending"); + const entry: ScaffoldManifestEntry = { + path: file.path, + template: file.path, + version: match.version, + appliedAt, + stateAtApply: "pending", + contentHash: onDiskHash, + }; + recordScaffoldApply(basePath, entry); + migrated.push(file.path); + } catch (err) { + logWarning("scaffold", "failed to stamp legacy-matched file", { + file: file.path, + error: (err as Error).message, + }); + skipped.push(file.path); + } + } + + return { migrated, skipped }; }