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:
parent
14b5c2b12c
commit
39e2dc70c9
9 changed files with 792 additions and 1 deletions
|
|
@ -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 ──
|
// ── Post-unit hooks ──
|
||||||
if (s.currentUnit && !s.stepMode) {
|
if (s.currentUnit && !s.stepMode) {
|
||||||
const hookUnit = checkPostUnitHooks(
|
const hookUnit = checkPostUnitHooks(
|
||||||
|
|
|
||||||
|
|
@ -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 ──
|
// ── Step 8: Ledger notification ──
|
||||||
try {
|
try {
|
||||||
// Tag with structured metadata so headless-events.ts classifies via
|
// Tag with structured metadata so headless-events.ts classifies via
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ const TOP_LEVEL_SUBCOMMANDS = [
|
||||||
cmd: "codebase",
|
cmd: "codebase",
|
||||||
desc: "Generate, refresh, and inspect the codebase map cache",
|
desc: "Generate, refresh, and inspect the codebase map cache",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
cmd: "scaffold",
|
||||||
|
desc: "Inspect or refresh ADR-021 versioned scaffold docs",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function filterStartsWith(
|
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) {
|
if (parts[0] === "dispatch" && parts.length <= 2) {
|
||||||
return filterStartsWith(
|
return filterStartsWith(
|
||||||
partial,
|
partial,
|
||||||
|
|
|
||||||
258
src/resources/extensions/sf/commands-scaffold-sync.ts
Normal file
258
src/resources/extensions/sf/commands-scaffold-sync.ts
Normal 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ export interface SfCommandDefinition {
|
||||||
type CompletionMap = Record<string, readonly SfCommandDefinition[]>;
|
type CompletionMap = Record<string, readonly SfCommandDefinition[]>;
|
||||||
|
|
||||||
export const SF_COMMAND_DESCRIPTION =
|
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[] = [
|
export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [
|
||||||
{ cmd: "help", desc: "Categorized command reference with descriptions" },
|
{ 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: "backlog", desc: "Manage backlog items (add, promote, remove, list)" },
|
||||||
{ cmd: "pr-branch", desc: "Create clean PR branch filtering .sf/ commits" },
|
{ cmd: "pr-branch", desc: "Create clean PR branch filtering .sf/ commits" },
|
||||||
{ cmd: "add-tests", desc: "Generate tests for completed slices" },
|
{ 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 = {
|
const NESTED_COMPLETIONS: CompletionMap = {
|
||||||
|
|
@ -384,6 +388,21 @@ const NESTED_COMPLETIONS: CompletionMap = {
|
||||||
{ cmd: "--dry-run", desc: "Preview what would be filtered" },
|
{ cmd: "--dry-run", desc: "Preview what would be filtered" },
|
||||||
{ cmd: "--name", desc: "Custom branch name" },
|
{ 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(
|
function filterOptions(
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,23 @@ Examples:
|
||||||
await handleAddTests(trimmed.replace(/^add-tests\s*/, "").trim(), ctx, pi);
|
await handleAddTests(trimmed.replace(/^add-tests\s*/, "").trim(), ctx, pi);
|
||||||
return true;
|
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 (
|
if (
|
||||||
trimmed === "extract-learnings" ||
|
trimmed === "extract-learnings" ||
|
||||||
trimmed.startsWith("extract-learnings ")
|
trimmed.startsWith("extract-learnings ")
|
||||||
|
|
|
||||||
|
|
@ -711,6 +711,66 @@ export async function checkRuntimeHealth(
|
||||||
} catch {
|
} catch {
|
||||||
// Non-fatal — snapshot ref check failed
|
// 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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
184
src/resources/extensions/sf/scaffold-keeper.ts
Normal file
184
src/resources/extensions/sf/scaffold-keeper.ts
Normal 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,
|
||||||
|
};
|
||||||
201
src/resources/extensions/sf/tests/scaffold-keeper.test.ts
Normal file
201
src/resources/extensions/sf/tests/scaffold-keeper.test.ts
Normal 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")));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue