feat: ADR-021 Phase C — automatic silent scaffold sync

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 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 00:39:44 +02:00
parent deab93bed6
commit 2cb3f5f75a
4 changed files with 281 additions and 32 deletions

View file

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

View file

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

View file

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

View file

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