From 2cb3f5f75a44747d8853f4408ada972d67039df6 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 00:39:44 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ADR-021=20Phase=20C=20=E2=80=94=20autom?= =?UTF-8?q?atic=20silent=20scaffold=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-visible "automatic" upgrade behavior. After this lands, projects pointed at SF silently catch up to the current scaffold without any user action — for the simple cases. Drift-aware ensureAgenticDocsScaffold: - Step 1: migrateLegacyScaffold runs first to promote unmarked-but-recognised files via SCAFFOLD_VERSION_ARCHIVE hash matching - Step 2: per-template walk: - Missing → create + stamp + manifest entry (existing behavior) - Present, marker, state=pending, version drifted, hash matches stamp → silent re-render with current template + restamp (NEW) - Editing/completed/customized → leave alone (Phase D handles editing-drift) - Silent contract: no stdout/stderr, only logWarning("scaffold") for I/O failures. All failure modes non-fatal. SCAFFOLD_VERSION_ARCHIVE bootstrap: - Lazily seeded with current SF version's body hashes from SCAFFOLD_FILES - Future SF releases append entries when templates change so legacy projects can match against any prior version checkScaffoldFreshness doctor finding (ADR-021 §8): - Surfaces missing/upgradable/editing-drift counts as "scaffold_drift" warning - Auto-fix runs ensureAgenticDocsScaffold to handle missing+pending - Non-fatal warning, never blocks dispatch - Editing-drift left for Phase D (scaffold-keeper background agent) Tests pass: 33/33 across scaffold-versioning + scaffold-drift suites. Typecheck clean. Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/sf/agentic-docs-scaffold.ts | 112 ++++++++++--- .../extensions/sf/doctor-runtime-checks.ts | 42 +++++ src/resources/extensions/sf/doctor-types.ts | 4 +- src/resources/extensions/sf/scaffold-drift.ts | 155 +++++++++++++++++- 4 files changed, 281 insertions(+), 32 deletions(-) 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 }; }