feat: ADR-021 Phase D + E — scaffold-keeper agent + /sf scaffold sync command

Phase D: scaffold-keeper background agent
- scaffold-keeper.ts: dispatchScaffoldKeeperIfNeeded fires async after milestone
  completion and on stopAuto cleanup. Detects editing-drift items, writes
  <file>.proposed artifacts (template-only stub for now; later wires the
  records-keeper skill subagent for code-as-fact merging), emits a structured
  approval_request notification with stable dedupe_key so repeated runs don't
  spam the user.
- Wired into auto-post-unit.ts and auto.ts:stopAuto via fire-and-forget so
  the auto loop is never blocked by scaffold work.
- Failure modes non-fatal: try/catch around the dispatch, errors logged via
  logWarning("scaffold").

Phase E: /sf scaffold sync command (escape hatch)
- commands-scaffold-sync.ts: parseScaffoldSyncArgs + handleScaffoldSync.
- Flags:
    --dry-run         report what would change, no writes
    --include-editing run scaffold-keeper synchronously for editing-drift items
    --only=<glob>     scope to a path glob (suffix/prefix match)
- Wired into the SF command system via commands-bootstrap.ts, commands/catalog.ts,
  and commands/handlers/ops.ts following the existing /sf <verb> pattern.
- Reuses ensureAgenticDocsScaffold from Phase C — doesn't reimplement sync logic.

Doctor finding (checkScaffoldFreshness) refined to reference the new command.

Tests: 8 new cases in scaffold-keeper.test.ts. All 49 scaffold tests green.

Together with Phases A-C, this completes ADR-021. Documents are now versioned,
upgrades are automatic for the safe cases, and editing-drift surfaces through
.proposed artifacts and structured notifications. The scaffold-keeper agent
body is currently a template-only stub; replacing it with a real records-keeper
subagent dispatch is a follow-up that the architecture now enables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 00:45:54 +02:00
parent 14b5c2b12c
commit 39e2dc70c9
9 changed files with 792 additions and 1 deletions

View file

@ -1430,6 +1430,24 @@ export async function postUnitPostVerification(
}
}
// ── Scaffold-keeper dispatch (ADR-021 Phase D) ──
// After milestone completion, fire-and-forget the scaffold-keeper to
// detect editing-drift docs and stage `<file>.proposed` artifacts. Failure
// is non-fatal and must never break the auto loop, hence the broad try.
if (s.currentUnit?.type === "complete-milestone") {
try {
const { dispatchScaffoldKeeperFireAndForget } = await import(
"./scaffold-keeper.js"
);
dispatchScaffoldKeeperFireAndForget(s.basePath, ctx);
} catch (e) {
debugLog("postUnit", {
phase: "scaffold-keeper-dispatch",
error: e instanceof Error ? e.message : String(e),
});
}
}
// ── Post-unit hooks ──
if (s.currentUnit && !s.stepMode) {
const hookUnit = checkPostUnitHooks(

View file

@ -946,6 +946,23 @@ export async function stopAuto(
});
}
// ── Step 7b: Scaffold-keeper dispatch (ADR-021 Phase D) ──
// At session close, detect editing-drift docs and stage `<file>.proposed`
// artifacts via the scaffold-keeper. Fire-and-forget — must not block
// the cleanup path or break the stop sequence on failure.
try {
if (ctx && s.basePath) {
const { dispatchScaffoldKeeperFireAndForget } = await import(
"./scaffold-keeper.js"
);
dispatchScaffoldKeeperFireAndForget(s.basePath, ctx);
}
} catch (e) {
debugLog("stop-cleanup-scaffold-keeper", {
error: e instanceof Error ? e.message : String(e),
});
}
// ── Step 8: Ledger notification ──
try {
// Tag with structured metadata so headless-events.ts classifies via

View file

@ -64,6 +64,10 @@ const TOP_LEVEL_SUBCOMMANDS = [
cmd: "codebase",
desc: "Generate, refresh, and inspect the codebase map cache",
},
{
cmd: "scaffold",
desc: "Inspect or refresh ADR-021 versioned scaffold docs",
},
] as const;
function filterStartsWith(
@ -349,6 +353,19 @@ function getSfArgumentCompletions(prefix: string) {
);
}
if (parts[0] === "scaffold" && parts.length <= 2) {
return filterStartsWith(
partial,
[
{
cmd: "sync",
desc: "Refresh ADR-021 scaffold docs (drift report + apply pending upgrades)",
},
],
"scaffold",
);
}
if (parts[0] === "dispatch" && parts.length <= 2) {
return filterStartsWith(
partial,

View file

@ -0,0 +1,258 @@
/**
* commands-scaffold-sync.ts `/sf scaffold sync` (ADR-021 Phase E).
*
* Manual escape hatch over the Phase C automatic scaffold sync. Lets the user:
* - Inspect drift without modifying anything (`--dry-run`).
* - Force the same operation that would run on next SF startup (default).
* - Run scaffold-keeper synchronously for editing-drift items
* (`--include-editing`) when Phase D has shipped.
* - Restrict the operation to a path glob (`--only=<glob>`).
*
* The command is intentionally thin: it dispatches to
* `ensureAgenticDocsScaffold` and renders `detectScaffoldDrift`. It does not
* reimplement either.
*/
import type { ExtensionCommandContext } from "@singularity-forge/pi-coding-agent";
import { ensureAgenticDocsScaffold } from "./agentic-docs-scaffold.js";
import { projectRoot } from "./commands/context.js";
import {
detectScaffoldDrift,
type ScaffoldDriftItem,
type ScaffoldDriftReport,
} from "./scaffold-drift.js";
export interface ScaffoldSyncOptions {
dryRun: boolean;
includeEditing: boolean;
only?: string;
}
/** Parse the args string for `/sf scaffold sync`. Tolerates extra whitespace. */
export function parseScaffoldSyncArgs(args: string): ScaffoldSyncOptions {
const trimmed = (args || "").trim();
const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : [];
const opts: ScaffoldSyncOptions = {
dryRun: false,
includeEditing: false,
};
for (const tok of tokens) {
if (tok === "--dry-run") {
opts.dryRun = true;
} else if (tok === "--include-editing") {
opts.includeEditing = true;
} else if (tok.startsWith("--only=")) {
const value = tok.slice("--only=".length).trim();
if (value.length > 0) opts.only = value;
}
}
return opts;
}
/**
* Match a scaffold path against an `--only=<glob>` value.
*
* Supports the simple cases the brief calls out: `*` is treated as a wildcard,
* and as a fallback we accept plain prefix or suffix matches. We deliberately
* do not pull in a glob library Phase E is the escape hatch, not a
* production globber.
*/
export function matchesOnly(path: string, glob: string | undefined): boolean {
if (!glob) return true;
if (path === glob) return true;
if (glob.includes("*")) {
// Build a forgiving regex: escape regex metachars, then turn `*` into `.*`.
const pattern = glob
.split("*")
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
try {
return new RegExp(`^${pattern}$`).test(path);
} catch {
return false;
}
}
// Plain string: accept prefix or suffix match. Suffix is useful for
// `--only=RELIABILITY.md`; prefix for `--only=harness/`.
return path.startsWith(glob) || path.endsWith(glob);
}
/** Filter a drift report's items by an --only glob. Counts are recomputed. */
export function applyOnlyFilter(
report: ScaffoldDriftReport,
only: string | undefined,
): ScaffoldDriftReport {
if (!only) return report;
const items = report.items.filter((i) => matchesOnly(i.path, only));
const counts: ScaffoldDriftReport["countsByBucket"] = {
missing: 0,
upgradable: 0,
"editing-drift": 0,
untracked: 0,
customized: 0,
};
for (const item of items) {
counts[item.bucket] += 1;
}
return {
items,
countsByBucket: counts,
manifestPresent: report.manifestPresent,
};
}
function formatReportTable(report: ScaffoldDriftReport): string {
const c = report.countsByBucket;
const lines = [
"Scaffold drift report:",
` Missing : ${c.missing}`,
` Upgradable : ${c.upgradable}`,
` Pending : ${c.upgradable}`,
` Editing-drift: ${c["editing-drift"]}`,
` Untracked : ${c.untracked}`,
` Customized : ${c.customized}`,
];
const review = report.items.filter(
(i) => i.bucket === "missing" || i.bucket === "editing-drift",
);
if (review.length > 0) {
lines.push("");
lines.push("Items needing review:");
for (const item of review) {
lines.push(` ${item.path} (${item.bucket})`);
}
}
return lines.join("\n");
}
/**
* Format a brief deltas summary comparing pre- and post-sync drift reports.
* Helps the user see at a glance what the sync actually did.
*/
function formatSyncDelta(
before: ScaffoldDriftReport,
after: ScaffoldDriftReport,
): string | null {
const wroteMissing = before.countsByBucket.missing - after.countsByBucket.missing;
const upgraded = before.countsByBucket.upgradable - after.countsByBucket.upgradable;
const promoted = before.countsByBucket.untracked - after.countsByBucket.untracked;
if (wroteMissing <= 0 && upgraded <= 0 && promoted <= 0) return null;
const parts: string[] = [];
if (wroteMissing > 0) parts.push(`wrote ${wroteMissing} missing`);
if (upgraded > 0) parts.push(`refreshed ${upgraded} pending`);
if (promoted > 0) parts.push(`promoted ${promoted} legacy-matched`);
return `Sync complete — ${parts.join(", ")}.`;
}
/**
* Lazy import for Phase D's scaffold-keeper dispatcher. Returns `null` if
* Phase D has not shipped yet, in which case `--include-editing` reports the
* feature as unavailable rather than crashing.
*/
async function tryLoadScaffoldKeeper(): Promise<
| ((basePath: string, items: ScaffoldDriftItem[]) => Promise<{ proposed: string[] }>)
| null
> {
try {
// Phase D is expected to expose this from a `scaffold-keeper.js` sibling.
const mod: unknown = await import("./scaffold-keeper.js").catch(() => null);
if (
mod &&
typeof (mod as { dispatchScaffoldKeeperIfNeeded?: unknown })
.dispatchScaffoldKeeperIfNeeded === "function"
) {
return (
mod as {
dispatchScaffoldKeeperIfNeeded: (
basePath: string,
items: ScaffoldDriftItem[],
) => Promise<{ proposed: string[] }>;
}
).dispatchScaffoldKeeperIfNeeded;
}
} catch {
// fall through
}
return null;
}
/**
* Top-level handler for `/sf scaffold sync [args]`.
*
* Always notifies via `ctx.ui.notify` never throws on the sync paths
* themselves; underlying calls (`ensureAgenticDocsScaffold`,
* `detectScaffoldDrift`) are non-throwing per their contracts.
*/
export async function handleScaffoldSync(
args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
const opts = parseScaffoldSyncArgs(args);
const basePath = projectRoot();
// Dry-run: report only, no filesystem mutation.
if (opts.dryRun) {
const report = applyOnlyFilter(detectScaffoldDrift(basePath), opts.only);
ctx.ui.notify(formatReportTable(report), "info");
return;
}
// Default: run the same automatic-mode entry point, then report.
const before = applyOnlyFilter(detectScaffoldDrift(basePath), opts.only);
try {
ensureAgenticDocsScaffold(basePath);
} catch (err) {
ctx.ui.notify(
`Scaffold sync failed: ${(err as Error).message}`,
"warning",
);
return;
}
const after = applyOnlyFilter(detectScaffoldDrift(basePath), opts.only);
const delta = formatSyncDelta(before, after);
const reportText = formatReportTable(after);
const message = delta ? `${delta}\n\n${reportText}` : reportText;
ctx.ui.notify(message, "info");
if (!opts.includeEditing) return;
// --include-editing: synchronously dispatch Phase D's keeper for editing-drift.
const editingItems = after.items.filter((i) => i.bucket === "editing-drift");
if (editingItems.length === 0) {
ctx.ui.notify("No editing-drift items to merge.", "info");
return;
}
const dispatcher = await tryLoadScaffoldKeeper();
if (!dispatcher) {
ctx.ui.notify(
"--include-editing: scaffold-keeper not yet available (ADR-021 Phase D pending).",
"warning",
);
return;
}
try {
const result = await dispatcher(basePath, editingItems);
const proposed = Array.isArray(result?.proposed) ? result.proposed : [];
if (proposed.length === 0) {
ctx.ui.notify(
"scaffold-keeper completed without producing .proposed files.",
"info",
);
return;
}
const lines = [
`Wrote ${proposed.length} .proposed file${proposed.length === 1 ? "" : "s"}:`,
...proposed.map((p) => ` ${p}`),
];
ctx.ui.notify(lines.join("\n"), "info");
} catch (err) {
ctx.ui.notify(
`scaffold-keeper failed: ${(err as Error).message}`,
"warning",
);
}
}

View file

@ -18,7 +18,7 @@ export interface SfCommandDefinition {
type CompletionMap = Record<string, readonly SfCommandDefinition[]>;
export const SF_COMMAND_DESCRIPTION =
"SF — Singularity Forge: /sf help|start|templates|next|autonomous|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan";
"SF — Singularity Forge: /sf help|start|templates|next|autonomous|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold";
export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [
{ cmd: "help", desc: "Categorized command reference with descriptions" },
@ -155,6 +155,10 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [
{ cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" },
{ cmd: "pr-branch", desc: "Create clean PR branch filtering .sf/ commits" },
{ cmd: "add-tests", desc: "Generate tests for completed slices" },
{
cmd: "scaffold",
desc: "Inspect or refresh ADR-021 versioned scaffold docs (sync, --dry-run, --include-editing, --only=<glob>)",
},
];
const NESTED_COMPLETIONS: CompletionMap = {
@ -384,6 +388,21 @@ const NESTED_COMPLETIONS: CompletionMap = {
{ cmd: "--dry-run", desc: "Preview what would be filtered" },
{ cmd: "--name", desc: "Custom branch name" },
],
scaffold: [
{
cmd: "sync",
desc: "Refresh ADR-021 scaffold docs (drift report + apply pending upgrades)",
},
{ cmd: "sync --dry-run", desc: "Print drift report without modifying files" },
{
cmd: "sync --include-editing",
desc: "Run scaffold-keeper synchronously for editing-drift items",
},
{
cmd: "sync --only=",
desc: "Restrict the operation to a path glob (e.g. --only=harness/**)",
},
],
};
function filterOptions(

View file

@ -339,6 +339,23 @@ Examples:
await handleAddTests(trimmed.replace(/^add-tests\s*/, "").trim(), ctx, pi);
return true;
}
if (trimmed === "scaffold sync" || trimmed.startsWith("scaffold sync ")) {
const { handleScaffoldSync } = await import(
"../../commands-scaffold-sync.js"
);
await handleScaffoldSync(
trimmed.replace(/^scaffold sync\s*/, "").trim(),
ctx,
);
return true;
}
if (trimmed === "scaffold") {
ctx.ui.notify(
"Usage: /sf scaffold sync [--dry-run] [--include-editing] [--only=<glob>]",
"warning",
);
return true;
}
if (
trimmed === "extract-learnings" ||
trimmed.startsWith("extract-learnings ")

View file

@ -711,6 +711,66 @@ export async function checkRuntimeHealth(
} catch {
// Non-fatal — snapshot ref check failed
}
// ── Scaffold freshness (ADR-021 Phase C) ──────────────────────────────
// Visibility into scaffold drift. Phase C runs the silent path
// automatically on every SF startup, but the doctor finding lets users
// see what was upgraded and what is still pending review (editing-drift,
// untracked-without-archive-match). Severity: warning. Never blocks.
try {
const finding = checkScaffoldFreshness(basePath);
if (finding) issues.push(finding);
} catch {
// Non-fatal — scaffold freshness check failed
}
}
/**
* ADR-021 Phase C: report scaffold drift bucket counts as a doctor finding.
*
* Returns `null` when there is nothing actionable (everything is current or
* customised by intent). Otherwise returns a single warning summarising the
* bucket counts. The phrase "Run /sf scaffold sync" is forward-looking
* Phase E adds the command. Phase C runs the silent path automatically on
* every SF startup, so the user does not need to act on most of these.
*/
export function checkScaffoldFreshness(basePath: string): DoctorIssue | null {
let report: ReturnType<typeof detectScaffoldDrift>;
try {
report = detectScaffoldDrift(basePath);
} catch {
return null;
}
const counts = report.countsByBucket;
const actionable =
counts.missing +
counts.upgradable +
counts["editing-drift"] +
counts.untracked;
if (actionable === 0) return null;
const parts: string[] = [];
if (counts.missing > 0) parts.push(`${counts.missing} missing`);
if (counts.upgradable > 0) parts.push(`${counts.upgradable} pending upgrade`);
if (counts["editing-drift"] > 0)
parts.push(`${counts["editing-drift"]} editing-drift`);
if (counts.untracked > 0) parts.push(`${counts.untracked} untracked`);
const summary = parts.join(", ");
const guidance =
counts.upgradable + counts.missing > 0
? `Run /sf scaffold sync to refresh ${counts.upgradable + counts.missing} pending docs`
: "Run /sf scaffold sync to inspect drift";
return {
severity: "warning",
code: "scaffold_drift",
scope: "project",
unitId: "project",
message: `Scaffold drift: ${summary}. ${guidance}.`,
fixable: false,
};
}
/**

View file

@ -0,0 +1,184 @@
/**
* Scaffold-keeper background dispatcher (ADR-021 Phase D).
*
* Runs after milestone completion (and any other place SF chooses to call it).
* Walks the drift report, and for every `editing-drift` item produces a
* `<file>.proposed` artifact and emits a single structured `approval_request`
* notification so the user can review the proposal.
*
* Phase D ships the architecture and a deterministic stub for the proposed
* body: the current scaffold template content with a `<!-- sf-proposed: ... -->`
* preamble. A follow-up phase will replace the body generator with a real
* subagent dispatch that runs the `records-keeper` skill at that point the
* `<file>.proposed` body will be code-derived (records-keeper's "prefer source
* and tests for implemented behavior" contract) instead of template-only.
*
* Contract:
* - Non-blocking. Caller does not await the body. Failures are non-fatal:
* they log via `logWarning("scaffold", ...)` and never propagate.
* - Never overwrites `<file>` itself only writes `<file>.proposed`.
* - Emits exactly one `approval_request` notification per call when there is
* at least one editing-drift item; the stable `dedupe_key` prevents the
* same drift from spamming the user across repeated runs.
* - Silent path: when there are zero editing-drift items, no notification,
* no .proposed writes, no logs.
*/
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { SCAFFOLD_FILES } from "./agentic-docs-scaffold.js";
import { detectScaffoldDrift } from "./scaffold-drift.js";
import type { ScaffoldDriftItem } from "./scaffold-drift.js";
import { logWarning } from "./workflow-logger.js";
/**
* Minimal `notify`-only shape of `ExtensionContext`. Tests pass a stub; the
* real auto loop passes the full context. Keeping this typed locally avoids
* pulling the heavy `@singularity-forge/pi-coding-agent` import surface into a
* module that runs at session edges.
*/
export interface ScaffoldKeeperCtx {
ui: {
notify: (
message: string,
type?: "info" | "warning" | "error" | "success",
metadata?: {
kind?: "notice" | "approval_request" | "progress" | "terminal";
blocking?: boolean;
dedupe_key?: string;
source?: string;
},
) => void;
};
}
/**
* Build the .proposed body shipped by the Phase-D stub: the current scaffold
* template body, prefixed with a structured `<!-- sf-proposed: ... -->` block
* describing source vs target version. This matches ADR-021 §5 ("editing-drift").
*
* The records-keeper skill is referenced in the preamble so a human reviewing
* the artifact knows which skill the agent will run when wired up.
*/
function buildProposedBody(item: ScaffoldDriftItem, templateBody: string): string {
const sourceVersion = item.currentVersion ?? "unknown";
const targetVersion = item.shipVersion;
const preamble =
`<!-- sf-proposed: source=${sourceVersion} target=${targetVersion} ` +
`template=${item.template} skill=records-keeper ` +
`note="Stub body — replace with code-derived content via records-keeper subagent." ` +
`-->\n`;
return preamble + templateBody;
}
/**
* Scaffold-keeper entry point. Fire-and-forget caller should not await
* meaningful work, only the inevitable I/O completion.
*
* Steps:
* 1. detect drift; bail silently if no editing-drift items.
* 2. for each editing-drift item, write `<file>.proposed` with the stub
* body. Skip files with no matching `SCAFFOLD_FILES` entry (defensive).
* 3. emit a single `approval_request` notification summarizing the count.
*
* Returns the number of `.proposed` files written. Tests use this to assert
* the smoke-check behaviour; production callers ignore the return value.
*/
export async function dispatchScaffoldKeeperIfNeeded(
basePath: string,
ctx: ScaffoldKeeperCtx,
): Promise<number> {
let report: ReturnType<typeof detectScaffoldDrift>;
try {
report = detectScaffoldDrift(basePath);
} catch (err) {
logWarning("scaffold", "scaffold-keeper drift detection failed", {
error: (err as Error).message,
});
return 0;
}
if (report.countsByBucket["editing-drift"] === 0) {
return 0;
}
const editingDrift = report.items.filter((i) => i.bucket === "editing-drift");
let written = 0;
for (const item of editingDrift) {
try {
const template = SCAFFOLD_FILES.find((f) => f.path === item.template);
if (!template) {
logWarning("scaffold", "scaffold-keeper: no template for drift item", {
path: item.path,
});
continue;
}
const proposedPath = join(basePath, `${item.path}.proposed`);
const proposedBody = buildProposedBody(item, template.content);
mkdirSync(dirname(proposedPath), { recursive: true });
writeFileSync(proposedPath, proposedBody, "utf-8");
written += 1;
} catch (err) {
logWarning("scaffold", "scaffold-keeper: failed to write .proposed", {
path: item.path,
error: (err as Error).message,
});
}
}
// Stub notice — when subagent dispatch is wired, this log line goes away
// and the body becomes code-derived rather than template-only.
logWarning(
"scaffold",
"scaffold-keeper agent dispatch not yet wired — wrote .proposed with template only",
{ count: String(written) },
);
if (written > 0) {
try {
ctx.ui.notify(
`Scaffold drift: ${written} doc(s) need review. See .proposed files.`,
"warning",
{
kind: "approval_request",
blocking: false,
dedupe_key: "scaffold-drift",
source: "scaffold-keeper",
},
);
} catch (err) {
logWarning("scaffold", "scaffold-keeper: notify failed", {
error: (err as Error).message,
});
}
}
return written;
}
/**
* Sync wrapper: schedules `dispatchScaffoldKeeperIfNeeded` without blocking the
* caller. Errors swallowed and logged so a scaffold-keeper failure cannot break
* the auto loop. Used from auto-post-unit and auto.ts:stopAuto where the
* caller cannot reasonably `await` async work without re-architecting cleanup.
*/
export function dispatchScaffoldKeeperFireAndForget(
basePath: string,
ctx: ScaffoldKeeperCtx,
): void {
// Use queueMicrotask so the dispatch starts after the current sync stack
// unwinds — the caller's flow remains synchronous and unblocked.
queueMicrotask(() => {
dispatchScaffoldKeeperIfNeeded(basePath, ctx).catch((err: unknown) => {
logWarning("scaffold", "scaffold-keeper dispatch threw", {
error: err instanceof Error ? err.message : String(err),
});
});
});
}
// Test-only helper: not exported via index, but exposed for the smoke test.
export const __test = {
buildProposedBody,
};

View file

@ -0,0 +1,201 @@
/**
* Tests for scaffold-keeper (ADR-021 Phase D).
*
* Covers the four observable behaviors:
* 1. editing-drift produces a .proposed file and one notification
* 2. silent path: no editing-drift no notification, no .proposed writes
* 3. failures inside the dispatcher do not throw to the caller
* 4. completed/pending state files do not trigger the dispatcher
*/
import assert from "node:assert/strict";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, test } from "node:test";
import { SCAFFOLD_FILES } from "../agentic-docs-scaffold.ts";
import { dispatchScaffoldKeeperIfNeeded } from "../scaffold-keeper.ts";
import { stampScaffoldFile } from "../scaffold-versioning.ts";
interface NotifyCall {
message: string;
type?: string;
metadata?: Record<string, unknown>;
}
function makeStubCtx(): { ctx: { ui: { notify: (...args: unknown[]) => void } }; calls: NotifyCall[] } {
const calls: NotifyCall[] = [];
const ctx = {
ui: {
notify: (
message: string,
type?: string,
metadata?: Record<string, unknown>,
) => {
calls.push({ message, type, metadata });
},
},
};
return { ctx, calls };
}
function makeTmp(): string {
return mkdtempSync(join(tmpdir(), "sf-scaffold-keeper-"));
}
function pickMarkdownTarget(): { path: string; content: string } {
const f = SCAFFOLD_FILES.find((s) => s.path === "AGENTS.md");
if (!f) throw new Error("AGENTS.md must be in SCAFFOLD_FILES for this test");
return { path: f.path, content: f.content };
}
/**
* Set up a file in editing-drift state: stamped marker, then body mutated.
*/
function makeEditingDrift(dir: string): string {
const target = pickMarkdownTarget();
const fp = join(dir, target.path);
writeFileSync(fp, target.content, "utf-8");
stampScaffoldFile(fp, target.path, "1.0.0");
const stamped = readFileSync(fp, "utf-8");
const lines = stamped.split("\n");
const mutated =
lines[0] + "\n" + lines.slice(1).join("\n") + "\n# user customisation\n";
writeFileSync(fp, mutated, "utf-8");
return fp;
}
describe("dispatchScaffoldKeeperIfNeeded", () => {
let dir: string;
beforeEach(() => {
dir = makeTmp();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("editing-drift produces .proposed file and one notification", async () => {
makeEditingDrift(dir);
const { ctx, calls } = makeStubCtx();
const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);
assert.equal(written, 1);
const proposedPath = join(dir, "AGENTS.md.proposed");
assert.ok(existsSync(proposedPath), ".proposed file must exist");
const body = readFileSync(proposedPath, "utf-8");
assert.match(body, /<!-- sf-proposed:/);
assert.match(body, /skill=records-keeper/);
assert.equal(calls.length, 1);
assert.equal(calls[0].type, "warning");
assert.equal(calls[0].metadata?.kind, "approval_request");
assert.equal(calls[0].metadata?.dedupe_key, "scaffold-drift");
assert.equal(calls[0].metadata?.source, "scaffold-keeper");
assert.equal(calls[0].metadata?.blocking, false);
});
test("never overwrites the original file", async () => {
const fp = makeEditingDrift(dir);
const before = readFileSync(fp, "utf-8");
const { ctx } = makeStubCtx();
await dispatchScaffoldKeeperIfNeeded(dir, ctx);
const after = readFileSync(fp, "utf-8");
assert.equal(after, before, "original file must be untouched");
});
test("silent path: no editing-drift means no notification and no .proposed", async () => {
// All scaffold files are missing → all classified as `missing`, none as
// `editing-drift`. No notification, no .proposed files.
const { ctx, calls } = makeStubCtx();
const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);
assert.equal(written, 0);
assert.equal(calls.length, 0);
});
test("dedupe_key is stable across repeated invocations", async () => {
makeEditingDrift(dir);
const { ctx, calls } = makeStubCtx();
await dispatchScaffoldKeeperIfNeeded(dir, ctx);
await dispatchScaffoldKeeperIfNeeded(dir, ctx);
// Both calls should fire the notification with the same dedupe_key.
// Deduplication is the consumer's job; the keeper's contract is "stable
// key" so the consumer can collapse them.
assert.equal(calls.length, 2);
assert.equal(calls[0].metadata?.dedupe_key, "scaffold-drift");
assert.equal(calls[1].metadata?.dedupe_key, "scaffold-drift");
});
test("completed marker does not trigger dispatch", async () => {
const target = pickMarkdownTarget();
const fp = join(dir, target.path);
writeFileSync(fp, target.content, "utf-8");
stampScaffoldFile(fp, target.path, "1.0.0", "completed");
const { ctx, calls } = makeStubCtx();
const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);
assert.equal(written, 0);
assert.equal(calls.length, 0);
assert.ok(!existsSync(join(dir, "AGENTS.md.proposed")));
});
test("pending (not drifted) does not trigger dispatch", async () => {
const target = pickMarkdownTarget();
const fp = join(dir, target.path);
writeFileSync(fp, target.content, "utf-8");
// Stamp current SF_VERSION → bucket is `customized`/`upgradable`, never editing-drift.
stampScaffoldFile(fp, target.path, process.env.SF_VERSION || "0.0.0");
const { ctx, calls } = makeStubCtx();
const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);
assert.equal(written, 0);
assert.equal(calls.length, 0);
assert.ok(!existsSync(join(dir, "AGENTS.md.proposed")));
});
test("notify failure inside dispatcher does not throw to caller", async () => {
makeEditingDrift(dir);
const ctx = {
ui: {
notify: () => {
throw new Error("notify exploded");
},
},
};
// Must not throw despite notify throwing.
const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);
assert.equal(written, 1);
assert.ok(existsSync(join(dir, "AGENTS.md.proposed")));
});
test("smoke check — full happy-path workflow", async () => {
// Mirrors the ADR's verification step: stamp a file, mutate the body,
// run the keeper, assert .proposed exists and notify was called once.
const fp = makeEditingDrift(dir);
assert.ok(existsSync(fp));
const { ctx, calls } = makeStubCtx();
const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);
assert.equal(written, 1);
assert.equal(calls.length, 1);
assert.ok(existsSync(join(dir, "AGENTS.md.proposed")));
});
});