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:
parent
deab93bed6
commit
2cb3f5f75a
4 changed files with 281 additions and 32 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue