fix: stabilize sf startup and state linting

This commit is contained in:
Mikael Hugo 2026-05-05 19:21:43 +02:00
parent 46db1e95ef
commit 87d49abd87
16 changed files with 1111 additions and 34 deletions

View file

@ -51,3 +51,55 @@ test("notify_when_extension_notify_missing_routes_info_and_success_to_status", (
["Done", { append: true }],
]);
});
test("set_widget_when_host_supports_widgets_uses_dedicated_handler", () => {
const calls: unknown[][] = [];
const ui = createExtensionUIContext({
setExtensionWidget(key: string, content: unknown, options?: unknown) {
calls.push([key, content, options]);
},
});
const content = ["Ready"];
const options = { placement: "belowEditor" as const };
ui.setWidget("sf-notifications", content, options);
assert.deepEqual(calls, [["sf-notifications", content, options]]);
});
test("set_widget_when_widget_host_missing_routes_string_content_to_status", () => {
const statuses: unknown[][] = [];
const ui = createExtensionUIContext({
showStatus(message: string, options?: unknown) {
statuses.push([message, options]);
},
});
ui.setWidget("sf-notifications", ["Ready", "Next"], {
placement: "belowEditor",
});
assert.deepEqual(statuses, [["Ready\nNext", { append: false }]]);
});
test("set_widget_when_widget_host_missing_ignores_factory_without_throwing", () => {
let renderRequested = false;
const ui = createExtensionUIContext({
ui: {
requestRender() {
renderRequested = true;
},
},
});
ui.setWidget(
"sf-notifications",
() => ({
render: () => [],
invalidate: () => {},
}),
{ placement: "belowEditor" },
);
assert.equal(renderRequested, true);
});

View file

@ -1,4 +1,7 @@
import type { ExtensionUIContext } from "../../../core/extensions/index.js";
import type {
ExtensionUIContext,
ExtensionWidgetOptions,
} from "../../../core/extensions/index.js";
import { appKey } from "../components/keybinding-hints.js";
import {
getAvailableThemesWithPaths,
@ -11,6 +14,11 @@ import {
type ExtensionNotifyType = "info" | "warning" | "error" | "success";
type ExtensionWidgetContent =
| string[]
| ((...args: any[]) => unknown)
| undefined;
function notifyHost(
host: any,
message: string,
@ -36,6 +44,33 @@ function notifyHost(
host.ui?.requestRender?.();
}
function setWidgetHost(
host: any,
key: string,
content: ExtensionWidgetContent,
options?: ExtensionWidgetOptions,
): void {
if (typeof host.setExtensionWidget === "function") {
host.setExtensionWidget(key, content, options);
return;
}
if (content === undefined) {
host.ui?.requestRender?.();
return;
}
if (Array.isArray(content) && typeof host.showStatus === "function") {
const message = content.filter(Boolean).join("\n");
if (message) {
host.showStatus(message, { append: false });
}
return;
}
host.ui?.requestRender?.();
}
export function createExtensionUIContext(host: any): ExtensionUIContext {
return {
select: (title, options, opts) =>
@ -67,7 +102,7 @@ export function createExtensionUIContext(host: any): ExtensionUIContext {
}
},
setWidget: (key, content, options) =>
host.setExtensionWidget(key, content, options),
setWidgetHost(host, key, content, options),
setFooter: (factory) => host.setExtensionFooter(factory),
setHeader: (factory) => host.setExtensionHeader(factory),
setTitle: (title) => host.ui.terminal.setTitle(title),

View file

@ -5,6 +5,7 @@ import { setupEditorSubmitHandler } from "./input-controller.js";
type HostOptions = {
knownSlashCommands?: string[];
slashCommandContext?: boolean;
};
function getSlashCommandName(text: string): string {
@ -62,16 +63,20 @@ function createHost(options: HostOptions = {}) {
renderRequests += 1;
},
},
getSlashCommandContext: () => ({
session: host.session,
showSettingsSelector: () => {
settingsOpened += 1;
},
showStatus: host.showStatus,
shutdown: async () => {
shutdowns += 1;
},
}),
...(options.slashCommandContext === false
? {}
: {
getSlashCommandContext: () => ({
session: host.session,
showSettingsSelector: () => {
settingsOpened += 1;
},
showStatus: host.showStatus,
shutdown: async () => {
shutdowns += 1;
},
}),
}),
handleBashCommand: async () => {},
showWarning(message: string) {
warnings.push(message);
@ -93,7 +98,6 @@ function createHost(options: HostOptions = {}) {
updatePendingMessagesDisplay() {
pendingDisplayUpdates += 1;
},
flushPendingBashComponents() {},
contextualTips: {
evaluate: () => undefined,
recordBashIncluded() {},
@ -211,6 +215,45 @@ test("input-controller: prompt template slash commands fall through to session.p
assert.deepEqual(errors, []);
});
test("input-controller: known extension slash command falls through when slash context is absent", async () => {
const { host, prompted, errors, history } = createHost({
knownSlashCommands: ["sf"],
slashCommandContext: false,
});
await host.defaultEditor.onSubmit("/sf next");
assert.deepEqual(prompted, ["/sf next"]);
assert.deepEqual(errors, []);
assert.deepEqual(history, ["/sf next"]);
});
test("input-controller: built-in slash command does not crash when slash context is absent", async () => {
const { host, prompted, errors, getSettingsOpened, getEditorText } =
createHost({
slashCommandContext: false,
});
await host.defaultEditor.onSubmit("/settings");
assert.equal(getSettingsOpened(), 0);
assert.deepEqual(prompted, []);
assert.deepEqual(errors, [
"Unknown command: /settings. Use slash autocomplete to see available commands.",
]);
assert.equal(getEditorText(), "");
});
test("input-controller: normal prompt submit does not require stale bash component flush hook", async () => {
const { host, prompted, errors, history } = createHost();
await host.defaultEditor.onSubmit("ping");
assert.deepEqual(prompted, ["ping"]);
assert.deepEqual(errors, []);
assert.deepEqual(history, ["ping"]);
});
test("input-controller: skill slash commands fall through to session.prompt", async () => {
const { host, prompted, errors } = createHost({
knownSlashCommands: ["skill:create-skill"],

View file

@ -4,7 +4,7 @@ import { dispatchSlashCommand } from "../slash-command-handlers.js";
export function setupEditorSubmitHandler(
host: InteractiveModeStateHost & {
getSlashCommandContext: () => any;
getSlashCommandContext?: () => any;
handleBashCommand: (
command: string,
excludeFromContext?: boolean,
@ -17,7 +17,6 @@ export function setupEditorSubmitHandler(
isKnownSlashCommand: (text: string) => boolean;
queueCompactionMessage: (text: string, mode: "steer" | "followUp") => void;
updatePendingMessagesDisplay: () => void;
flushPendingBashComponents: () => void;
contextualTips: ContextualTips;
getContextPercent: () => number | undefined;
options?: { submitPromptsDirectly?: boolean };
@ -28,10 +27,10 @@ export function setupEditorSubmitHandler(
if (!text) return;
if (text.startsWith("/") && !looksLikeFilePath(text)) {
const handled = await dispatchSlashCommand(
text,
host.getSlashCommandContext(),
);
const slashContext = host.getSlashCommandContext?.();
const handled = slashContext
? await dispatchSlashCommand(text, slashContext)
: false;
if (handled) {
host.editor.setText("");
return;
@ -114,8 +113,6 @@ export function setupEditorSubmitHandler(
return;
}
host.flushPendingBashComponents();
if (host.onInputCallback) {
host.onInputCallback(text);
host.editor.addToHistory?.(text);

View file

@ -107,6 +107,7 @@ import {
providerDisplayName,
} from "./components/model-selector.js";
import { SessionSelectorComponent } from "./components/session-selector.js";
import { SettingsSelectorComponent } from "./components/settings-selector.js";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js";
import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
@ -116,8 +117,12 @@ import { handleAgentEvent } from "./controllers/chat-controller.js";
import { createExtensionUIContext as buildExtensionUIContext } from "./controllers/extension-ui-controller.js";
import { setupEditorSubmitHandler as setupEditorSubmitHandlerController } from "./controllers/input-controller.js";
import { updateAvailableProviderCount as updateAvailableProviderCountController } from "./controllers/model-controller.js";
import { getAppKeyDisplay } from "./slash-command-handlers.js";
import {
getAppKeyDisplay,
type SlashCommandContext,
} from "./slash-command-handlers.js";
import {
getAvailableThemes,
getEditorTheme,
getMarkdownTheme,
initTheme,
@ -233,6 +238,8 @@ export class InteractiveMode {
// Track if editor is in bash mode (text starts with !)
private isBashMode = false;
private bashComponent: BashExecutionComponent | undefined = undefined;
private pendingBashComponents: BashExecutionComponent[] = [];
// Contextual tips — session-scoped, non-intrusive hints
private contextualTips = new ContextualTips();
@ -2104,6 +2111,203 @@ export class InteractiveMode {
}
}
getSlashCommandContext(): SlashCommandContext {
return {
session: this.session,
ui: this.ui,
keybindings: this.keybindings,
chatContainer: this.chatContainer,
statusContainer: this.statusContainer,
editorContainer: this.editorContainer,
headerContainer: this.headerContainer,
pendingMessagesContainer: this.pendingMessagesContainer,
editor: this.editor,
defaultEditor: this.defaultEditor,
sessionManager: this.sessionManager,
settingsManager: this.settingsManager,
invalidateFooter: () => this.footer.invalidate(),
showStatus: (message) => this.showStatus(message),
showError: (message) => this.showError(message),
showWarning: (message) => this.showWarning(message),
showSelector: (create) => this.showSelector(create),
updateEditorBorderColor: () => this.updateEditorBorderColor(),
getMarkdownThemeWithSettings: () => this.getMarkdownThemeWithSettings(),
requestRender: () => this.ui.requestRender(),
updateTerminalTitle: () => this.updateTerminalTitle(),
showSettingsSelector: () => this.showSettingsSelector(),
showModelsSelector: async () => this.showModelSelector(),
handleModelCommand: async (searchTerm) =>
this.showModelSelector(searchTerm),
showUserMessageSelector: () => this.showUserMessageSelector(),
showTreeSelector: () => this.showTreeSelector(),
showProviderManager: () =>
this.showError("Provider manager is unavailable in this build."),
showOAuthSelector: async () =>
this.showError("OAuth selector is unavailable in this build."),
showSessionSelector: () => this.showSessionSelector(),
handleClearCommand: () => this.handleClearCommand(),
handleReloadCommand: () => this.handleReloadCommand(),
handleDebugCommand: () => this.handleDebugCommand(),
shutdown: () => this.shutdown(),
executeCompaction: (instructions, isAuto) =>
this.executeCompaction(instructions, isAuto),
handleBashCommand: (command, options) =>
this.handleBashCommand(
command,
options?.excludeFromContext,
options?.displayCommand,
options?.loginShell,
),
};
}
private showSettingsSelector(): void {
this.showSelector((done) => {
const selector = new SettingsSelectorComponent(
{
autoCompact: this.session.autoCompactionEnabled,
showImages: this.settingsManager.getShowImages(),
autoResizeImages: this.settingsManager.getImageAutoResize(),
blockImages: this.settingsManager.getBlockImages(),
enableSkillCommands: this.settingsManager.getEnableSkillCommands(),
steeringMode: this.session.steeringMode,
followUpMode: this.session.followUpMode,
transport: this.settingsManager.getTransport(),
thinkingLevel: this.session.thinkingLevel,
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
currentTheme: this.settingsManager.getTheme() || "dark",
availableThemes: getAvailableThemes(),
hideThinkingBlock: this.hideThinkingBlock,
collapseChangelog: this.settingsManager.getCollapseChangelog(),
doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
treeFilterMode: this.settingsManager.getTreeFilterMode(),
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
editorPaddingX: this.settingsManager.getEditorPaddingX(),
autocompleteMaxVisible:
this.settingsManager.getAutocompleteMaxVisible(),
respectGitignoreInPicker:
this.settingsManager.getRespectGitignoreInPicker(),
quietStartup: this.settingsManager.getQuietStartup(),
clearOnShrink: this.settingsManager.getClearOnShrink(),
timestampFormat: this.settingsManager.getTimestampFormat(),
proxyFamilyProviders: {},
},
{
onAutoCompactChange: (enabled) => {
this.session.setAutoCompactionEnabled(enabled);
this.footer.setAutoCompactEnabled(enabled);
},
onShowImagesChange: (enabled) => {
this.settingsManager.setShowImages(enabled);
for (const child of this.chatContainer.children) {
if (child instanceof ToolExecutionComponent) {
child.setShowImages(enabled);
}
}
},
onAutoResizeImagesChange: (enabled) => {
this.settingsManager.setImageAutoResize(enabled);
},
onBlockImagesChange: (blocked) => {
this.settingsManager.setBlockImages(blocked);
},
onEnableSkillCommandsChange: (enabled) => {
this.settingsManager.setEnableSkillCommands(enabled);
this.setupAutocomplete();
},
onSteeringModeChange: (mode) => {
this.session.setSteeringMode(mode);
},
onFollowUpModeChange: (mode) => {
this.session.setFollowUpMode(mode);
},
onTransportChange: (transport) => {
this.settingsManager.setTransport(transport);
this.session.agent.setTransport(transport);
},
onThinkingLevelChange: (level) => {
this.session.setThinkingLevel(level);
this.footer.invalidate();
this.updateEditorBorderColor();
},
onThemeChange: (themeName) => {
const result = setTheme(themeName, true);
this.settingsManager.setTheme(themeName);
this.ui.invalidate();
if (!result.success) {
this.showError(
`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`,
);
}
},
onThemePreview: (themeName) => {
const result = setTheme(themeName, true);
if (result.success) {
this.ui.invalidate();
this.ui.requestRender();
}
},
onHideThinkingBlockChange: (hidden) => {
this.hideThinkingBlock = hidden;
this.settingsManager.setHideThinkingBlock(hidden);
this.rebuildChatFromMessages();
},
onCollapseChangelogChange: (collapsed) => {
this.settingsManager.setCollapseChangelog(collapsed);
},
onDoubleEscapeActionChange: (action) => {
this.settingsManager.setDoubleEscapeAction(action);
},
onTreeFilterModeChange: (mode) => {
this.settingsManager.setTreeFilterMode(mode);
},
onShowHardwareCursorChange: (enabled) => {
this.settingsManager.setShowHardwareCursor(enabled);
this.ui.setShowHardwareCursor(enabled);
},
onEditorPaddingXChange: (padding) => {
this.settingsManager.setEditorPaddingX(padding);
this.defaultEditor.setPaddingX(padding);
if (this.editor !== this.defaultEditor) {
this.editor.setPaddingX?.(padding);
}
},
onAutocompleteMaxVisibleChange: (maxVisible) => {
this.settingsManager.setAutocompleteMaxVisible(maxVisible);
this.defaultEditor.setAutocompleteMaxVisible(maxVisible);
if (this.editor !== this.defaultEditor) {
this.editor.setAutocompleteMaxVisible?.(maxVisible);
}
},
onRespectGitignoreInPickerChange: (enabled) => {
this.settingsManager.setRespectGitignoreInPicker(enabled);
this.autocompleteProvider?.setRespectGitignore(enabled);
},
onQuietStartupChange: (enabled) => {
this.settingsManager.setQuietStartup(enabled);
},
onClearOnShrinkChange: (enabled) => {
this.settingsManager.setClearOnShrink(enabled);
this.ui.setClearOnShrink(enabled);
},
onTimestampFormatChange: (format) => {
this.settingsManager.setTimestampFormat(format);
},
onProxyFamilyProviderChange: (familyPrefix, provider) => {
this.settingsManager.setProxyFamilyProvider(familyPrefix, [
provider,
]);
},
onCancel: () => {
done();
this.ui.requestRender();
},
},
);
return { component: selector, focus: selector.getSettingsList() };
});
}
private setupEditorSubmitHandler(): void {
setupEditorSubmitHandlerController(this as any);
}
@ -3600,6 +3804,94 @@ export class InteractiveMode {
}
}
private async handleBashCommand(
command: string,
excludeFromContext = false,
displayCommand?: string,
loginShell?: boolean,
): Promise<void> {
const extensionRunner = this.session.extensionRunner;
const label = displayCommand || command;
const eventResult = extensionRunner
? await extensionRunner.emitUserBash({
type: "user_bash",
command,
excludeFromContext,
cwd: process.cwd(),
})
: undefined;
this.bashComponent = new BashExecutionComponent(
label,
this.ui,
excludeFromContext,
);
if (this.session.isStreaming) {
this.pendingMessagesContainer.addChild(this.bashComponent);
this.pendingBashComponents.push(this.bashComponent);
} else {
this.chatContainer.addChild(this.bashComponent);
}
const interceptedResult = eventResult?.result;
if (interceptedResult) {
if (interceptedResult.output) {
this.bashComponent.appendOutput(interceptedResult.output);
}
this.bashComponent.setComplete(
interceptedResult.exitCode,
interceptedResult.cancelled,
interceptedResult.truncated
? ({
truncated: true,
content: interceptedResult.output,
} as TruncationResult)
: undefined,
interceptedResult.fullOutputPath,
);
this.session.recordBashResult(command, interceptedResult, {
excludeFromContext,
});
this.bashComponent = undefined;
this.ui.requestRender();
return;
}
this.ui.requestRender();
try {
const result = await this.session.executeBash(
command,
(chunk) => {
this.bashComponent?.appendOutput(chunk);
this.ui.requestRender();
},
{
excludeFromContext,
operations: eventResult?.operations,
loginShell,
},
);
this.bashComponent?.setComplete(
result.exitCode,
result.cancelled,
result.truncated
? ({ truncated: true, content: result.output } as TruncationResult)
: undefined,
result.fullOutputPath,
);
} catch (error) {
this.bashComponent?.setComplete(undefined, false);
this.showError(
`Bash command failed: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
this.bashComponent = undefined;
this.ui.requestRender();
}
private async executeCompaction(
customInstructions?: string,
isAuto = false,

View file

@ -310,6 +310,10 @@ export function syncResourceDir(srcDir: string, destDir: string): void {
return;
}
if (existsSync(destDir) && lstatSync(destDir).isSymbolicLink()) {
rmSync(destDir, { force: true });
}
makeTreeWritable(destDir);
pruneStaleSiblingFiles(srcDir, destDir);
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {

View file

@ -8,6 +8,7 @@ import {
nextMilestoneId,
} from "../guided-flow.js";
import { loadEffectiveSFPreferences } from "../preferences.js";
import { ALL_SCHEDULE_KINDS } from "../schedule/schedule-types.js";
import { markResolved, recordSelfFeedback } from "../self-feedback.js";
import {
executeCompleteMilestone,
@ -1013,10 +1014,12 @@ export function registerDbTools(pi) {
}),
}),
),
kind: Type.String({
description:
"Entry kind (reminder, milestone_check, review_due, recurring)",
}),
kind: Type.Union(
ALL_SCHEDULE_KINDS.map((k) => Type.Literal(k)),
{
description: `Entry kind (${ALL_SCHEDULE_KINDS.join(", ")})`,
},
),
title: Type.String({
description: "Entry title / reminder message",
}),
@ -1921,10 +1924,12 @@ export function registerDbTools(pi) {
}),
}),
),
kind: Type.String({
description:
"Entry kind (reminder, milestone_check, review_due, recurring)",
}),
kind: Type.Union(
ALL_SCHEDULE_KINDS.map((k) => Type.Literal(k)),
{
description: `Entry kind (${ALL_SCHEDULE_KINDS.join(", ")})`,
},
),
title: Type.String({
description: "Entry title / reminder message",
}),

View file

@ -91,6 +91,31 @@ import {
// printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
let isFirstSession = true;
let lastGeminiPreflightWarning;
async function runSessionStartupDoctorFix(ctx) {
if (process.env.SF_DISABLE_STARTUP_DOCTOR === "1") return;
try {
const { runSFDoctor, summarizeDoctorIssues } = await import("../doctor.js");
const report = await runSFDoctor(process.cwd(), { fix: true });
if (report.fixesApplied.length > 0) {
ctx.ui?.notify?.(
`Startup doctor: applied ${report.fixesApplied.length} fix(es).`,
"info",
);
}
const summary = summarizeDoctorIssues(report.issues);
if (summary.errors > 0) {
ctx.ui?.notify?.(
`Startup doctor found ${summary.errors} blocking issue(s). Run /sf doctor audit for details.`,
"warning",
);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
safetyLogWarning("startup-doctor", msg);
}
}
async function syncServiceTierStatus(ctx) {
const {
getEffectiveServiceTier,
@ -129,6 +154,7 @@ export function registerHooks(pi, ecosystemHandlers = []) {
resetAskUserQuestionsCache();
await syncServiceTierStatus(ctx);
await initializeLearningRuntime();
await runSessionStartupDoctorFix(ctx);
// Apply show_token_cost preference (#1515)
try {
const { loadEffectiveSFPreferences } = await import("../preferences.js");

View file

@ -5,8 +5,10 @@ import {
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { dirname, extname, join } from "node:path";
import { parse as parseYaml } from "yaml";
import { invalidateAllCaches } from "./cache.js";
import {
checkEngineHealth,
@ -24,6 +26,7 @@ import {
parseTaskPlanMustHaves,
saveFile,
} from "./files.js";
import { nativeScanSfTree } from "./native-parser-bridge.js";
import { parsePlan, parseRoadmap } from "./parsers.js";
import {
milestonesDir,
@ -62,6 +65,13 @@ const LEGACY_ROOT_HARNESS_PATHS = [
"harness/evals/AGENTS.md",
"harness/graders/AGENTS.md",
];
const SF_FORM_LINT_SKIP_DIRS = new Set([
"node_modules",
"worktrees",
"sift",
".sift",
]);
const SF_LEGACY_BRANDING_RE = /\bGSD\b/g;
function pruneEmptyDir(path) {
try {
@ -128,6 +138,133 @@ function checkGeneratedArtifactResidue(
}
}
function walkSfTreeFallback(root, prefix = "") {
const entries = [];
if (!existsSync(root)) return entries;
for (const name of readdirSync(root)) {
const fullPath = join(root, name);
const relPath = prefix ? `${prefix}/${name}` : name;
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
const isDir = stat.isDirectory();
entries.push({ path: relPath, name, isDir });
if (isDir && !SF_FORM_LINT_SKIP_DIRS.has(name)) {
entries.push(...walkSfTreeFallback(fullPath, relPath));
}
}
return entries;
}
function collectSfFormFiles(basePath) {
const root = sfRoot(basePath);
if (!existsSync(root)) return [];
const scanned = nativeScanSfTree(root) ?? walkSfTreeFallback(root);
return scanned
.filter((entry) => !entry.isDir)
.map((entry) => entry.path)
.filter((relPath) => {
const parts = relPath.split("/");
return !parts.some((part) => SF_FORM_LINT_SKIP_DIRS.has(part));
});
}
function parseJsonl(content) {
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
try {
JSON.parse(line);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return `line ${i + 1}: ${msg}`;
}
}
return null;
}
function parseMarkdownFrontmatter(content) {
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
return null;
}
const normalized = content.replace(/\r\n/g, "\n");
const end = normalized.indexOf("\n---\n", 4);
if (end === -1) return "frontmatter opening marker has no closing marker";
const frontmatter = normalized.slice(4, end);
try {
parseYaml(frontmatter);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
function checkSfFormSyntax(basePath, issues, fixesApplied, shouldFix) {
const root = sfRoot(basePath);
for (const relPath of collectSfFormFiles(basePath)) {
const filePath = join(root, relPath);
let content;
try {
content = readFileSync(filePath, "utf-8");
} catch {
continue;
}
const ext = extname(relPath).toLowerCase();
let parseError = null;
try {
if (ext === ".json") {
JSON.parse(content);
} else if (ext === ".jsonl") {
parseError = parseJsonl(content);
} else if (ext === ".yaml" || ext === ".yml") {
parseYaml(content);
} else if (ext === ".md") {
parseError = parseMarkdownFrontmatter(content);
}
} catch (error) {
parseError = error instanceof Error ? error.message : String(error);
}
if (parseError) {
issues.push({
severity: "error",
code: "invalid_sf_form",
scope: "project",
unitId: "project",
message: `.sf/${relPath} has invalid ${ext.slice(1) || "form"} syntax: ${parseError}`,
file: `.sf/${relPath}`,
fixable: false,
});
}
if (
(relPath.endsWith("AGENTS.md") || relPath.endsWith("CLAUDE.md")) &&
SF_LEGACY_BRANDING_RE.test(content)
) {
issues.push({
severity: "warning",
code: "legacy_gsd_scaffold_text",
scope: "project",
unitId: "project",
message: `.sf/${relPath} contains legacy GSD naming; generated SF guidance should use SF naming.`,
file: `.sf/${relPath}`,
fixable: true,
});
if (shouldFix("legacy_gsd_scaffold_text")) {
writeFileSync(
filePath,
content.replace(SF_LEGACY_BRANDING_RE, "SF"),
"utf-8",
);
fixesApplied.push(`rewrote legacy GSD naming in .sf/${relPath}`);
}
}
}
}
function parseEpochMs(value, fallbackMs) {
if (typeof value === "number" && Number.isFinite(value)) {
return value < 10_000_000_000 ? value * 1000 : value;
@ -1100,6 +1237,7 @@ export async function runSFDoctor(basePath, options) {
}
}
checkGeneratedArtifactResidue(basePath, issues, fixesApplied, shouldFix);
checkSfFormSyntax(basePath, issues, fixesApplied, shouldFix);
// Git health checks — timed
const t0git = Date.now();
const isolationMode =

View file

@ -135,7 +135,12 @@ schedule:
- Top-level `in:` entries fire at milestone **creation** time (due_at = now + duration).
- `on_complete.in:` entries fire when the milestone is **completed** (due_at = completion_time + duration).
- `kind` must be one of: `reminder`, `milestone_check`, `review_due`, `recurring`.
- `kind` must be one of: `review`, `audit`, `reminder`, `milestone_check`, `recurring`.
- `review` — surface title to next planning turn as context (preferred).
- `audit` — same as review but with stronger visual weight.
- `reminder` — general reminder.
- `milestone_check` — milestone health check.
- `recurring` — cron-based recurring entry.
- `title` becomes the reminder message.
- `payload` is optional kind-specific data.

View file

@ -20,7 +20,12 @@
*/
/**
* @typedef {("reminder"|"milestone_check"|"review_due"|"recurring")} ScheduleKind
* @typedef {("reminder"|"milestone_check"|"review_due"|"recurring"|"review"|"audit")} ScheduleKind
* "review" / "audit" surfaced to next planning turn (SF schedule system kinds).
* "review_due" legacy internal name for review (backward compat).
* "reminder" general reminder.
* "milestone_check" milestone health check.
* "recurring" cron-based recurring entry.
*/
/**
@ -87,13 +92,23 @@
// ─── Guards ─────────────────────────────────────────────────────────────────
/** @type {Set<string>} */
const VALID_KINDS = new Set([
export const VALID_KINDS = new Set([
"reminder",
"milestone_check",
"review_due",
"recurring",
"review",
"audit",
]);
/**
* All valid schedule kinds single source of truth for tooling and validation.
* Update VALID_KINDS and ScheduleKind typedef together.
*
* @type {string[]}
*/
export const ALL_SCHEDULE_KINDS = [...VALID_KINDS];
/**
* Validate that a string is a known schedule kind.
*

View file

@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, test } from "vitest";
import { runSFDoctor } from "../doctor.js";
const tmpDirs = [];
afterEach(() => {
while (tmpDirs.length > 0) {
const dir = tmpDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
});
function makeProject() {
const dir = mkdtempSync(join(tmpdir(), "sf-doctor-form-lint-"));
tmpDirs.push(dir);
mkdirSync(join(dir, ".sf"), { recursive: true });
return dir;
}
describe("doctor .sf form lint", () => {
test("runSFDoctor_reports_invalid_json_yaml_jsonl_and_markdown_frontmatter", async () => {
const project = makeProject();
writeFileSync(join(project, ".sf", "bad.json"), "{", "utf-8");
writeFileSync(join(project, ".sf", "bad.yaml"), "a: [", "utf-8");
writeFileSync(join(project, ".sf", "bad.jsonl"), '{"ok":true}\n{', "utf-8");
writeFileSync(
join(project, ".sf", "bad.md"),
"---\ntitle: [\n---\n# Bad\n",
"utf-8",
);
const report = await runSFDoctor(project, { scope: "project" });
const invalidFiles = report.issues
.filter((issue) => issue.code === "invalid_sf_form")
.map((issue) => issue.file)
.sort();
assert.deepEqual(invalidFiles, [
".sf/bad.json",
".sf/bad.jsonl",
".sf/bad.md",
".sf/bad.yaml",
]);
});
test("runSFDoctor_fix_rewrites_legacy_gsd_scaffold_text_in_sf_guidance", async () => {
const project = makeProject();
writeFileSync(
join(project, ".sf", "AGENTS.md"),
"# GSD guidance\n\nGSD owns this scaffold.\n",
"utf-8",
);
const report = await runSFDoctor(project, {
fix: true,
fixLevel: "all",
scope: "project",
});
assert.ok(
report.fixesApplied.includes(
"rewrote legacy GSD naming in .sf/AGENTS.md",
),
);
assert.equal(existsSync(join(project, ".sf", "AGENTS.md")), true);
assert.equal(
readFileSync(join(project, ".sf", "AGENTS.md"), "utf-8"),
"# SF guidance\n\nSF owns this scaffold.\n",
);
});
});

View file

@ -0,0 +1,109 @@
/**
* Schedule Kind Synchronisation tooling test.
*
* Purpose: verify that every schedule kind mentioned in prompts and documentation
* is present in VALID_KINDS (the single source of truth in schedule-types.js).
* Catches drift where a prompt documents a kind that is silently rejected by
* isValidKind().
*
* Consumer: CI test runner (vitest). Run via `npm test`.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it } from "vitest";
import { ALL_SCHEDULE_KINDS, VALID_KINDS } from "../schedule/schedule-types.js";
// Files that document or schema-validate ScheduleSpec.kind.
const TRACKED_FILES = [
join(import.meta.dirname, "../prompts/plan-milestone.md"),
join(import.meta.dirname, "../prompts/guided-plan-milestone.md"),
join(import.meta.dirname, "../bootstrap/db-tools.js"),
];
/**
* Extract all double-quoted strings that look like schedule kinds from a file.
* Matches patterns like kind: "review" or 'review' in YAML/markdown context.
*
* @param {string} content
* @returns {string[]}
*/
function extractKindLiterals(content) {
const found = new Set();
const re = /(?:kind\s*[:=]\s*["'])([a-z_]+)["']/gi;
let match;
while ((match = re.exec(content)) !== null) {
found.add(match[1].toLowerCase());
}
return [...found];
}
describe("schedule-kinds sync", () => {
describe("VALID_KINDS and ALL_SCHEDULE_KINDS", () => {
it("ALL_SCHEDULE_KINDS is a non-empty array", () => {
assert.ok(Array.isArray(ALL_SCHEDULE_KINDS));
assert.ok(ALL_SCHEDULE_KINDS.length > 0);
});
it("ALL_SCHEDULE_KINDS matches VALID_KINDS contents", () => {
assert.deepEqual(ALL_SCHEDULE_KINDS.sort(), [...VALID_KINDS].sort());
});
it("every ALL_SCHEDULE_KINDS entry is a non-empty string", () => {
for (const k of ALL_SCHEDULE_KINDS) {
assert.equal(typeof k, "string");
assert.ok(k.length > 0, `empty kind in ALL_SCHEDULE_KINDS`);
}
});
it("VALID_KINDS has no duplicate entries", () => {
const seen = new Set();
for (const k of VALID_KINDS) {
assert.ok(!seen.has(k), `duplicate kind: ${k}`);
seen.add(k);
}
});
});
describe("prompt kind references are in VALID_KINDS", () => {
for (const filePath of TRACKED_FILES) {
const content = readFileSync(filePath, "utf-8");
const mentioned = extractKindLiterals(content);
if (mentioned.length === 0) {
// No kind references found — skip but report
it(`${filePath.split("/").pop()}: finds no kind literals (add to TRACKED_FILES or remove from list)`, () => {
// pass — file may genuinely have no kind references
});
continue;
}
for (const kind of mentioned) {
it(`${filePath.split("/").pop()}: kind "${kind}" is in VALID_KINDS`, () => {
assert.ok(
VALID_KINDS.has(kind),
`"${kind}" is mentioned in ${filePath.split("/").pop()} ` +
`but is not in VALID_KINDS (${[...VALID_KINDS].join(", ")})`,
);
});
}
}
});
describe("VALID_KINDS has review and audit (SF schedule system kinds)", () => {
it('has "review" kind', () => {
assert.ok(
VALID_KINDS.has("review"),
"review kind missing from VALID_KINDS",
);
});
it('has "audit" kind', () => {
assert.ok(
VALID_KINDS.has("audit"),
"audit kind missing from VALID_KINDS",
);
});
});
});

View file

@ -0,0 +1,232 @@
/**
* Schedule Milestone Integration unit tests.
*
* Purpose: verify that appendScheduleSpec / appendScheduleSpecs correctly
* converts ScheduleSpec objects into ScheduleEntry records and appends them
* to the store. Covers all valid kinds, invalid-kind rejection, duration
* parsing, entry shape, and non-fatal error handling.
*
* Consumer: CI test runner (vitest). Run via `npm test`.
*/
import assert from "node:assert/strict";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, it } from "vitest";
import {
appendScheduleSpec,
appendScheduleSpecs,
} from "../schedule/schedule-milestone.js";
import { createScheduleStore } from "../schedule/schedule-store.js";
/** @type {string} */
let testDir;
beforeEach(() => {
testDir = join(
tmpdir(),
`sf-schedule-milestone-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
mkdirSync(join(testDir, ".sf"), { recursive: true });
});
afterEach(() => {
try {
rmSync(testDir, { recursive: true });
} catch {
// ignore
}
});
describe("appendScheduleSpec", () => {
describe("kind validation", () => {
for (const kind of [
"review",
"audit",
"reminder",
"milestone_check",
"review_due",
"recurring",
]) {
it(`kind "${kind}" creates an entry (not silently dropped)`, () => {
const spec = {
in: "1d",
kind,
title: `Test ${kind} entry`,
};
appendScheduleSpec(testDir, spec, "M001");
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
assert.ok(
entries.some((e) => e.kind === kind && e.status === "pending"),
`no pending entry with kind="${kind}" found. Entries: ${JSON.stringify(entries)}`,
);
});
}
it("invalid kind is skipped (no throw, no entry created)", () => {
const spec = {
in: "1d",
kind: "not_a_real_kind",
title: "Should be silently dropped",
};
// Should not throw
appendScheduleSpec(testDir, spec, "M001");
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
assert.equal(entries.length, 0, "invalid kind should create no entry");
});
});
describe("entry shape", () => {
it("creates entry with required fields", () => {
const spec = {
in: "2w",
kind: "review",
title: "Check adoption",
payload: { extra: "data" },
};
appendScheduleSpec(testDir, spec, "M042");
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
assert.equal(entries.length, 1);
const entry = entries[0];
assert.equal(entry.kind, "review");
assert.equal(entry.status, "pending");
assert.equal(entry.created_by, "milestone");
assert.equal(entry.payload.milestoneId, "M042");
assert.equal(entry.payload.message, "Check adoption");
assert.equal(entry.payload.extra, "data");
assert.ok(typeof entry.id === "string" && entry.id.length === 28);
assert.ok(typeof entry.due_at === "string");
assert.ok(typeof entry.created_at === "string");
});
it("sets due_at correctly for in: duration", () => {
const before = Date.now();
const spec = { in: "1d", kind: "reminder", title: "Daily check" };
appendScheduleSpec(testDir, spec);
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
const dueMs = new Date(entries[0].due_at).getTime();
const expectedMs = before + 24 * 60 * 60 * 1000;
assert.ok(
Math.abs(dueMs - expectedMs) < 5000,
`due_at ${dueMs} is more than 5s from expected ${expectedMs}`,
);
});
it("sets due_at correctly for on_complete.in duration", () => {
const before = Date.now();
const spec = {
on_complete: { in: "3d" },
kind: "audit",
title: "Post-completion audit",
};
appendScheduleSpec(testDir, spec, "M001");
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
const dueMs = new Date(entries[0].due_at).getTime();
const expectedMs = before + 3 * 24 * 60 * 60 * 1000;
assert.ok(
Math.abs(dueMs - expectedMs) < 5000,
`due_at ${dueMs} is more than 5s from expected ${expectedMs}`,
);
});
it("skips spec with no in or on_complete.in (no entry, no throw)", () => {
const spec = { kind: "review", title: "No duration" };
appendScheduleSpec(testDir, spec);
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
assert.equal(entries.length, 0);
});
});
describe("non-fatal error handling", () => {
it("throws nothing for null spec", () => {
assert.doesNotThrow(() => {
// @ts-expect-error — intentionally passing bad input
appendScheduleSpec(testDir, null);
});
});
it("throws nothing for non-object spec", () => {
assert.doesNotThrow(() => {
// @ts-expect-error
appendScheduleSpec(testDir, "not an object");
});
});
it("skips spec without milestoneId when milestoneId is undefined", () => {
const spec = { in: "1h", kind: "audit", title: "Ad-hoc audit" };
appendScheduleSpec(testDir, spec); // no milestoneId arg
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
assert.equal(entries.length, 1);
assert.equal(entries[0].payload.milestoneId, undefined);
});
});
});
describe("appendScheduleSpecs", () => {
it("processes an array of specs", () => {
const specs = [
{ in: "1d", kind: "review", title: "First review" },
{ in: "2d", kind: "audit", title: "Second audit" },
{ in: "3d", kind: "reminder", title: "Third reminder" },
];
appendScheduleSpecs(testDir, specs, "M001");
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
assert.equal(entries.length, 3);
assert.ok(entries.some((e) => e.kind === "review"));
assert.ok(entries.some((e) => e.kind === "audit"));
assert.ok(entries.some((e) => e.kind === "reminder"));
});
it("one bad spec does not block others", () => {
const specs = [
{ in: "1d", kind: "review", title: "Good one" },
{ in: "2d", kind: "bad_kind", title: "Bad kind" },
{ in: "3d", kind: "audit", title: "Good two" },
];
appendScheduleSpecs(testDir, specs, "M001");
const store = createScheduleStore(testDir);
const entries = store.readEntries("project");
assert.equal(
entries.length,
2,
"bad kind should be skipped, others preserved",
);
assert.ok(entries.every((e) => e.kind !== "bad_kind"));
});
it("handles empty array without throw", () => {
assert.doesNotThrow(() => {
appendScheduleSpecs(testDir, [], "M001");
});
});
it("handles non-array input without throw", () => {
assert.doesNotThrow(() => {
// @ts-expect-error
appendScheduleSpecs(testDir, "not an array", "M001");
});
});
});

View file

@ -26,6 +26,8 @@ describe("schedule-types", () => {
assert.equal(isValidKind("milestone_check"), true);
assert.equal(isValidKind("review_due"), true);
assert.equal(isValidKind("recurring"), true);
assert.equal(isValidKind("review"), true);
assert.equal(isValidKind("audit"), true);
});
it("rejects unknown kinds", () => {

View file

@ -1,10 +1,12 @@
import assert from "node:assert/strict";
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
@ -139,6 +141,44 @@ test("buildResourceLoader excludes duplicate top-level pi extensions when bundle
);
});
test("syncResourceDir replaces stale dev symlink before built resource sync", async () => {
const { syncResourceDir } = await import("../resource-loader.ts");
const tmp = mkdtempSync(join(tmpdir(), "sf-resource-loader-symlink-"));
const bundledDir = join(tmp, "dist-resources", "extensions");
const sourceDir = join(tmp, "src-resources", "extensions");
const agentExtensionsDir = join(tmp, "agent", "extensions");
const sourceDeclaration = join(sourceDir, "sf", "types.d.ts");
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
mkdirSync(join(bundledDir, "sf"), { recursive: true });
mkdirSync(join(sourceDir, "sf"), { recursive: true });
mkdirSync(join(tmp, "agent"), { recursive: true });
writeFileSync(join(bundledDir, "sf", "index.js"), "export {};\n");
writeFileSync(sourceDeclaration, "export type Preserved = true;\n");
symlinkSync(sourceDir, agentExtensionsDir, "dir");
syncResourceDir(bundledDir, agentExtensionsDir);
assert.equal(
lstatSync(agentExtensionsDir).isSymbolicLink(),
false,
"built resource sync must replace stale dev symlink instead of following it",
);
assert.equal(
existsSync(sourceDeclaration),
true,
"built resource sync must not prune declaration files from src/resources",
);
assert.equal(
existsSync(join(agentExtensionsDir, "sf", "index.js")),
true,
"built resources should be copied into the agent extensions directory",
);
});
test("initResources manifest tracks all bundled extension subdirectories including remote-questions (#2367)", async () => {
const { initResources } = await import("../resource-loader.ts");
const tmp = mkdtempSync(join(tmpdir(), "sf-resource-loader-manifest-"));